Jak zbudowaliśmy automatyczne kredyty SLA w Stripe (engineering deep-dive)

Zespół NaprawKSeF·10 min czytania

SLA 99.9% to obietnica, którą łatwo wpisać na pricing page i bardzo trudno utrzymać. Jeszcze trudniej jest policzyć ją uczciwie i wystawić klientowi kredyt, kiedy się jej nie dotrzyma. Pokazujemy nasz pełny pipeline: od próbki co minutę, przez miesięczny sweep i 4-stopniowy kredyt, po Stripe customer balance z idempotency key. 350 linii kodu, zero ręcznych operacji.

Po co o tym piszę: nie znalazłem ani jednego artykułu po polsku, który pokazuje SLA jako prawdziwy mechanizm techniczny, a nie marketing. Większość polskich SaaS-ów „ma SLA”, ale kiedy pytasz jak je liczą - okazuje się, że ręcznie i przy reklamacji. My poszliśmy w drugą stronę: jeśli zejdziemy poniżej 99.9%, klient zobaczy kredyt w swojej Stripe billing zanim w ogóle do nas napisze.

Architektura w 4 warstwach

Cały stack to cztery komponenty, każdy mały:

  1. Probe cron - co minutę pinguje 4 komponenty (API, DB, email, webhooks) i zapisuje wynik do platform_health_checks.
  2. Aggregation RPC - kiedy ktoś otwiera /status, jeden Postgres call zlicza uptime 30d/90d, percentyle p50/p99 i zwraca strukturę dla UI.
  3. Monthly sweep - 1. dnia miesiąca o 01:00 UTC cron oblicza pełen miesiąc uptime per organizacja i wpisuje raport do sla_reports z proponowanym kredytem (0/10/25/50/100%).
  4. Apply Stripe credit - operator klika „Apply” w /admin/sla, wywołuje POST /v1/customers/{id}/balance_transactions z idempotency key, status raportu zmienia się na applied.

Co krytyczne: nigdzie nie ma stanu poza Postgresem. Cron może paść, Stripe może odpowiedzieć timeoutem, operator może kliknąć dwa razy - żadnego z tych przypadków nie zduplikuje kredytu.


Warstwa 1 - probe co minutę

Cron Coolify dzwoni do /api/cron/health-probe co 60s. Ta ścieżka uruchamia runHealthProbe() - funkcja w lib/platform-status.ts, która równolegle pinguje 4 komponenty:

const results = await Promise.all([
  probeApi(ctx),       // GET /api/v1/health (self-check)
  probeDatabase(),     // SELECT 1 z LIMIT 0 (zero data transfer)
  probeEmail(),        // GET https://api.resend.com/domains
  probeWebhooks(),     // count(*) z deliveries gdzie next_retry < now-5min
]);

Każdy probe zwraca:

  • status: "ok" | "degraded" | "down"
  • latency_ms - kiedy degraduje, ale jeszcze odpowiada
  • metadata - opcjonalne kontekstowe info (np. backlog webhooków)

Próg degraded vs down jest dobrany per komponent. API ze średnim czasem > 1500ms → degraded; HTTP 5xx → down. Database > 800ms → degraded. Webhooks backlog > 50 → degraded, > 500 → down. To są liczby empiryczne - w Twoim systemie będą inne.

Zapisujemy cztery wiersze co minutę. W skali miesiąca to 4 × 60 × 24 × 30 = 172,800 wierszy. Tabela ma indeks na (component, created_at DESC) i partycji jeszcze nie potrzebujemy - Postgres trzyma to bez stękania.

Pułapka, w którą wpadliśmy: pierwsze 2 tygodnie liczyliśmy uptime tylko z probe'ów. Potem przyszedł incydent typu „klient nie może się zalogować”, którego nasz probe nie widział, bo dotyczył jednej organizacji. Dodaliśmy per-customer pulse - co request klienta jest probem. Outage'y typu single-tenant teraz wpadają w statystyki.

Warstwa 2 - agregacja do /status

Strona /status nie pyta o raw wiersze - to byłoby szaleństwo dla 172k wierszy/miesiąc. Zamiast tego pyta RPC get_platform_status_snapshot(), które zwraca cały snapshot w jednym JSON-ie. RPC to plpgsql function, która:

  1. Bierze LAST probe per komponent (najświeższy stan).
  2. Liczy uptime_30d i uptime_90d z COUNT(status='ok') / COUNT(*).
  3. Liczy percentile_disc(0.5) i percentile_disc(0.99) dla latency.
  4. Dorzuca active_incidents i ostatnie 10 recent_incidents.

Edge cache 30s (Cache-Control: public, s-maxage=30) + fakt, że probe i tak chodzi raz na minutę = strona ładuje się natychmiast nawet jak ktoś podpina monitor wzywający co sekundę.


Warstwa 3 - miesięczny sweep + scale credit

Cron sla-monthly-report dzwoni do /api/cron/sla-monthly-report 1. dnia miesiąca o 01:00 UTC. Endpoint wywołuje runMonthlySlaSweep() z lib/sla.ts:

const SLA_TARGET_PCT = 99.9;
const SLA_CREDIT_TIERS = [
  { minUptimePct: 99.0, creditPct: 10 },
  { minUptimePct: 95.0, creditPct: 25 },
  { minUptimePct: 90.0, creditPct: 50 },
  { minUptimePct: 0,    creditPct: 100 }, // fallback
];

export function calculateCreditPct(uptime: number): number {
  if (uptime >= SLA_TARGET_PCT) return 0;
  for (const tier of SLA_CREDIT_TIERS) {
    if (uptime >= tier.minUptimePct) return tier.creditPct;
  }
  return 100;
}

Sweep iteruje wszystkie aktywne organizacje, dla każdej liczy uptime z compute_monthly_uptime RPC (te same próbki co /status, ale z window funkcją po pełnym miesiącu), i upsertuje wiersz do sla_reports z statusem pending jeśli kredyt > 0%.

Wiersz pending jest idempotentny per (organization_id, month_start) - jeśli cron padnie i odpalimy go ponownie, drugi raport tej samej organizacji za ten sam miesiąc nadpisze pierwszy, nie utworzy duplikatu.

Dlaczego pending, nie applied od razu: kredyt może zostać wystawiony błędnie (incydent po naszej stronie nie był winą klienta - np. KSeF MF padło i to fałszywie obciążyło nasze probe). Stąd manual review w /admin/sla: operator widzi listę pending'ów, decyduje Apply / Waive / Reset.

Warstwa 4 - Stripe customer balance + idempotency

Kiedy operator klika „Apply”, wywołujemy applyStripeServiceCredit() z lib/sla-stripe.ts:

const creditGrosz = Math.round((monthlyAmountGrosz * creditPct) / 100);

const tx = await stripe.customers.createBalanceTransaction(
  stripeCustomerId,
  {
    // Negatywne = kredyt na korzyść klienta.
    // Stripe applies do następnej faktury automatycznie.
    amount: -creditGrosz,
    currency: "pln",
    description: `Kredyt SLA ${creditPct}% za ${monthStart}`,
  },
  {
    // Klucz idempotencji = report_id.
    // Drugi click "Apply" zwróci tę samą transakcję, nie utworzy nowej.
    idempotencyKey: `sla_credit:${reportId}`,
  },
);

Wartość kredytu wpada jako ujemne saldo na koncie klienta w Stripe. Przy następnej fakturze Stripe automatycznie odejmuje saldo - nie ma żadnej dodatkowej operacji po naszej stronie. Klient widzi to w swoim Customer Portal: „Account balance: -49,00 PLN (Credit)”.

Po sukcesie wpisujemy stripe_credit_id + applied_at do raportu i zapisujemy event w sla_credit_audit_log z actor_user_id operatora i poprzednim statusem. Cała ścieżka jest niezawodna - żaden node nie może zostawić systemu w „half-applied” stanie.


Co klient widzi w /dashboard/sla

Klient nie musi czekać do końca miesiąca, żeby zobaczyć projekcję. /dashboard/sla liczy live monthly uptime z próbek od początku bieżącego miesiąca i pokazuje:

  • Live banner: „Uptime w tym miesiącu: 99.97% - SLA spełniony.”
  • Tabela historyczna: każdy poprzedni miesiąc z uptime, downtime, kredyt (% + PLN), status (applied/waived/n/a), data zastosowania.
  • Scale tier explanation: jak liczymy tier z uptime.

Wszystko ładuje się z /api/dashboard/sla - ta sama RLS-em chroniona tabela sla_reports, do której pisał cron.


Linijka po linijce: ile to kodu

Cały SLA-credit pipeline to:

  • lib/platform-status.ts - 650 linii (4 probes + orchestrator + snapshot loader + event fan-out).
  • lib/sla.ts - 200 linii (math + monthly sweep).
  • lib/sla-stripe.ts - 80 linii (Stripe wrapper).
  • migrations/012_platform_status.sql + 016_sla_reports.sql - łącznie ok 600 linii SQL z RPC, indeksy, RLS, triggery.
  • UI: /dashboard/sla i /admin/sla - ok 600 linii TSX, z czego większość to layout.

To wszystko. Można to zbudować w 2-3 sprinty z gotowym Stripe SDK i odrobiną doświadczenia z Postgresem. Nasz cały scope SLA + monitoring + dashboard zamknął się w fazach 6 i 8.5 - łącznie 5 dni pracy.


Czego byśmy dziś nie zrobili tak samo

Trzy decyzje, które warto przemyśleć, zanim skopiujesz nasz stack:

  1. Probe co minutę to dużo - dla mniejszego SaaS-a probe co 5 minut wystarczy i 12× zmniejsza wolumen.
  2. Pending → manual apply może być za wolne - jeśli jesteś enterprise-only i ufasz swoim probom, ustaw auto-apply z retencyjnym 7-dniowym oknem na waive. My zostawiamy manual, bo większość klientów to SMB i czasem dogadujemy się „wystawimy 25% zamiast 50% w zamian za przedłużenie kontraktu”.
  3. Postgres dla próbek to nie jest ostateczna odpowiedź - przy 1M+ wierszy/miesiąc rozważ TimescaleDB albo ClickHouse na probe storage, a Postgres tylko dla raportów. Tabelę partycjonujemy po miesiącu, ale długoterminowo trzeba będzie migrować.

Co dalej dla nas

Następny krok to per-customer SLO (a nie per-platform): jeśli jeden klient ma awarię swojego webhook endpointu, której nasze probe nie złapią, ale wpływa to na jego „uptime z perspektywy klienta” - chcemy to też pokazać i pozwolić wystawić kredyt proporcjonalny do jego doświadczenia. Targetujemy v1.2 w Q4 2026.

A jeśli akurat budujesz coś podobnego i chcesz przegadać - pisz na support@naprawksef.pl. Robimy 30-min call z każdym, kto pyta „jak to zrobiliście, że idempotency key faktycznie działa po retry”.

Chcesz zobaczyć cały kod? Jest open-source friendly w naszym monorepo. Najważniejsze pliki: apps/napraw-ksef/lib/sla.ts, lib/sla-stripe.ts, app/api/cron/sla-monthly-report/route.ts, supabase/migrations/016_sla_reports.sql.
Z ruchu z bloga prosto do produktu

Sprawdź fakturę zanim KSeF ją odrzuci

Wgraj XML do walidatora, zobacz konkretne błędy i od razu sprawdź, które z nich naprawisz automatycznie. To najszybszy sposób, żeby przejść z teorii do realnej poprawki.

  • • darmowa walidacja bez wdrożenia
  • • konkretne wskazanie błędów i XPath
  • • plan Starter od 29 PLN/mies. dla regularnej pracy