Jak zbudowaliśmy automatyczne kredyty SLA w Stripe (engineering deep-dive)
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.
Architektura w 4 warstwach
Cały stack to cztery komponenty, każdy mały:
- Probe cron - co minutę pinguje 4 komponenty (API, DB, email, webhooks) i zapisuje wynik do
platform_health_checks. - Aggregation RPC - kiedy ktoś otwiera
/status, jeden Postgres call zlicza uptime 30d/90d, percentyle p50/p99 i zwraca strukturę dla UI. - Monthly sweep - 1. dnia miesiąca o 01:00 UTC cron oblicza pełen miesiąc uptime per organizacja i wpisuje raport do
sla_reportsz proponowanym kredytem (0/10/25/50/100%). - Apply Stripe credit - operator klika „Apply” w
/admin/sla, wywołujePOST /v1/customers/{id}/balance_transactionsz idempotency key, status raportu zmienia się naapplied.
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 odpowiadametadata- 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.
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:
- Bierze
LASTprobe per komponent (najświeższy stan). - Liczy
uptime_30diuptime_90dzCOUNT(status='ok') / COUNT(*). - Liczy
percentile_disc(0.5)ipercentile_disc(0.99)dla latency. - Dorzuca
active_incidentsi ostatnie 10recent_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.
/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/slai/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:
- Probe co minutę to dużo - dla mniejszego SaaS-a probe co 5 minut wystarczy i 12× zmniejsza wolumen.
- 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”.
- 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”.
apps/napraw-ksef/lib/sla.ts, lib/sla-stripe.ts, app/api/cron/sla-monthly-report/route.ts, supabase/migrations/016_sla_reports.sql.