Postmortem: Vier Rabatte Erzeugten 38 Fehlgeschlagene Stripe-Checkouts

Ein 46-minütiger Ausfall im Pricing-Stack legte fehlende Rabattprioritäten in TanStack Ships Abrechnungsmodul offen. Hier sind die nun standardmäßig eingebauten Schutzmechanismen und der Replay-Pfad.

Huifer
Huifer
23. Juli 202610 min read
Auch verfügbar auf:English · 中文

Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship. Im April 2026 habe ich einen Feiertagsrabatt, ein UTM-Angebot, ein Referral-Guthaben und einen Coupon auf einem Produktions-Checkout-Pfad aktiviert. Während eines 46-minütigen Fensters sind 38 von 214 Stripe-Checkout-Versuchen fehlgeschlagen, weil der kombinierte Rabatt den Rechnungszwischensumme überstieg. Ich habe die finale Anpassung begrenzt, Prioritäten definiert und alle 38 Warenkörbe erneut abgespielt; die erfolgreiche Checkout-Rate stieg von 82,2% auf 99,5%.

Verifizierte Quellen: TanStack Ship GitHub org · Stripe Checkout Discounts-Dokumentation · Stripe-Dokumentation Letztes Update: 23. Juli 2026 · Changelog


TL;DR: Ein Pricing-Stack mit vier Anpassungen (Feiertag + UTM-Angebot + Referral-Guthaben + Coupon) erzeugte 38 von 214 fehlgeschlagenen Stripe-Checkout-Versuchen in einem 46-minütigen Fenster, als der kombinierte Rabatt den Rechnungszwischensumme überstieg. Die Grundursache war fehlende Priorisierung, keine Untergrenze und keine Invariante in der Pricing-Engine. Die Lösung: deterministische Anpassungsreihenfolge, Zero-Floor-Guard, prüfbare Preisaufschlüsselung und idempotenter Replay-Helfer. Erholung: 82,2% → 99,5% Checkout-Erfolg. Die Schutzmechanismen sind nun Standard in TanStack Ships Abrechnungsmodul.


Hintergrund — Der Checkout-Pfad mit vier Anpassungen

TanStack Ships Abrechnungsmodul bietet eine Stripe-Checkout-Route pro Mandant an. Jeder Checkout liest vier Preisoberflächen, bevor die Stripe-Session erstellt wird: ein Feiertags-Kampagnenflag, ein UTM-basiertes Angebot, ein Referral-Guthaben und ein vom Benutzer eingegebener Coupon. Jede Oberfläche wurde unabhängig entwickelt und getestet. Jede Oberfläche behandelte den Fall „kein Rabatt angewendet" korrekt. Der Bug lag in der Komposition, nicht in einer einzelnen Oberfläche.

Zum Zeitpunkt des Vorfalls hatte der Produktionsmandant:

  • 3.184 zahlende Kunden
  • ca. 7 req/s Spitzenlast auf der Checkout-Route
  • 4 aktive Preisoberflächen ohne Prioritätsregeln
  • Eine Pricing-Engine, die alle vier Prozentwerte in einem Stripe-discounts[]-Array aufsummierte

Das Referenzdokument für die Stripe-Seite ist die Checkout-Discounts-Seite, die beschreibt, wie Stripe mehrere Rabatte auf eine Checkout Session anwendet. Stripe selbst warnt Sie nicht, wenn die Summe 100% des Zwischensummes überschreitet – es erstellt eine Session, deren Positionen netto einen negativen Betrag ergeben, den Stripe zum Bestätigungszeitpunkt mit einem 400er-Fehler ablehnt.

ts
// Original (fehlerhaft): Preisanpassungen ohne Priorität oder Untergrenze
export function composeAdjustments(input: PricingInput): StripeDiscount[] {
  const all: StripeDiscount[] = [];
  if (input.holiday)        all.push({ coupon: HOLIDAY_COUPON, percent_off: 25 });
  if (input.utmOffer)       all.push({ coupon: UTM_COUPON,     percent_off: 15 });
  if (input.referralCredit) all.push({ coupon: REFERRAL_COUPON, percent_off: 20 });
  if (input.coupon)         all.push(input.coupon); // beliebiger percent_off vom Benutzer
  return all; // Summe kann 100% überschreiten
}

Dieser Code zeigt die Form der composeAdjustments-Funktion vor dem Fix. Jeder Zweig war für sich korrekt. Zusammengesetzt mit den falschen Eingaben erzeugte er Rabattstapel von 90%, 100% und 105% des Zwischensummes.

Vorfall — 38 von 214 in 46 Minuten fehlgeschlagen

Zeitachse (alle Zeiten UTC, 12.04.2026):

  • 14:02:11 — Feiertags-Kampagnenflag im Admin-Panel aktiviert.
  • 14:02:48 — Erste Stripe-Checkout-Session mit dem zusammengesetzten Vier-Rabatt-Array erstellt.
  • 14:07:30 — Stripe-Support-Webhook lieferte einen 400er für checkout.session.expired mit der Ursache total_amount_zero_or_negative.
  • 14:09:04 — Cloudflare Workers Analytics zeigte einen Checkout-Fehlerrate-Sprung von 0,4% Baseline auf 17,8%.
  • 14:14:55 — PagerDuty-Alarm bei billing.checkout.error_rate > 10% für 5m.
  • 14:21:17 — Erste Reproduktion bestätigt: 38 von 214 Versuchen (17,8%) zwischen 14:02 und 14:48 fehlgeschlagen.
  • 14:48:02 — Feiertagsflag als kurzfristige Abhilfe deaktiviert.
  • 15:01:40 — Erfolgsrate des Checkouts wieder bei 99,5% (im normalen 0,3%-Rauschen).

Das 46-minütige Vorfallfenster ist der Zeitraum vom ersten zusammengesetzten Rabatt um 14:02 bis zur Abhilfe um 14:48. Der Schaden war zeitlich eng begrenzt, aber kundenseitig breit: 38 Kunden haben ihren Warenkorb nach einer Stripe-Fehlerseite abgebrochen, darunter 4 Kunden mit dem teuersten Tarif. Keiner wurde doppelt belastet; Stripe lehnte die Sessions ab, bevor Geld floss.

Untersuchung — Wie der zusammengesetzte Rabatt unter Null Fiel

Bei der Rückverfolgung des Fehlers fand sich der schlüssige Beweis in der Pricing-Engine-Antwort:

json
{
  "subtotal_cents": 4900,
  "adjustments": [
    { "kind": "holiday",        "percent_off": 25 },
    { "kind": "utm_offer",      "percent_off": 15 },
    { "kind": "referral_credit", "percent_off": 20 },
    { "kind": "coupon",          "percent_off": 50, "code": "WELCOME50" }
  ],
  "composed_discount_percent": 110,
  "stripe_session_total_cents": -100
}

Der zusammengesetzte Rabatt summierte sich auf 110% eines 49-Dollar-Zwischensummes und erzeugte einen negativen total_amount von -1,00 $. Stripes Checkout API lehnte die Session beim Erstellen mit dem Fehlercode total_amount_zero_or_negative ab. Die Session erreichte den Benutzer nicht – er sah einen 500er von unserer createCheckoutSession-Server-Funktion, was bei der gegebenen Eingabe korrekt war, aber keine hilfreiche Erfahrung.

Zwei zusammenhängende Erkenntnisse traten während der Analyse auf:

  1. Keine Priorisierung. Die vier Oberflächen wurden in der Reihenfolge angewendet, in der sie geprüft wurden, nicht nach einer Priorität. Ein Referral-Guthaben (gedacht als Letztes-Mittel-Marketingbudget) stapelte sich über einem 50%-Rabatt-WELCOME50-Coupon, ohne Überschreibungsregel.
  2. Keine Untergrenze. Die Pricing-Engine behandelte total_amount_cents < 0 als gültige Stripe-Eingabe. Es gab keine Invariante im Modul, die besagte: „Der Gesamtbetrag muss mindestens dem kleinsten Tarifpreis entsprechen."

Grundursache — Fehlende Priorisierung, Untergrenze und Invariante

Die Grundursache war ein fehlender Vertrag zwischen der Pricing-Engine und der Stripe-Checkout-Integration. Drei Zusicherungen fehlten:

  1. Anpassungspriorisierung. Wenn zwei oder mehr Prozentrabatte gelten, muss es eine dokumentierte Reihenfolge geben, in der sie sich zusammensetzen. Die konventionelle Reihenfolge – Coupon zuerst, dann Kampagne, dann Treueguthaben – wurde nirgends erzwungen.
  2. Zero-Floor-Guard. Die Pricing-Engine muss es ablehnen, einen Gesamtbetrag zu erzeugen, der kleiner oder gleich Null ist. Der kleinste kostenpflichtige Tarif war der Auslöser; darunter muss das System entweder auf den kleinsten Tarif begrenzen oder die Rabattkombination ablehnen.
  3. Prüfbare Preisaufschlüsselung. Die kundenseitige Bestellübersicht muss jede Anpassung mit ihrer Wirkung anzeigen, nicht nur eine einzelne obscure Gesamtsumme. Ohne sie kann der Support nicht erklären, warum ein Rabatt „nicht funktioniert" hat, und Engineering kann nicht debuggen, ohne die Eingabe erneut auszuführen.

Der Fix musste rückwärtskompatibel sein – bestehende Ein-Rabatt- und Zwei-Rabatt-Checkouts müssen weiterhin funktionieren – und idempotent – das erneute Abspielen derselben Eingabe muss dieselbe Stripe-Session erzeugen.

Fix — Deterministische Reihenfolge, Zero-Floor-Guard, Prüfbare Aufschlüsselung

Drei Änderungen wurden in dieser Reihenfolge ausgeliefert. Jede ist klein. Zusammen machen sie den Fehlermodus unmöglich auszudrücken.

Deterministische Anpassungsreihenfolge

ts
// Nach Fix: Priorität wird erzwungen; spätere Schichten können frühere Limits nicht überschreiten
const PRECEDENCE = ['coupon', 'campaign', 'loyalty'] as const;
export function composeAdjustments(input: PricingInput): StripeDiscount[] {
  const all: PricingAdjustment[] = [];
  if (input.coupon)          all.push({ kind: 'coupon',   percent_off: input.coupon.percent_off });
  if (input.holiday)         all.push({ kind: 'campaign', percent_off: 25 });
  if (input.referralCredit)  all.push({ kind: 'loyalty',  percent_off: 20 });
  return all.sort((a, b) => PRECEDENCE.indexOf(a.kind) - PRECEDENCE.indexOf(b.kind));
}

Die Sortierung ist beabsichtigt – sie macht die Kompositionsreihenfolge stabil über Läufe und Entwickler hinweg. Der Kommentar in der Datei verweist auf dieses Postmortem, damit die nächste Person, die den Code anfasst, die Begründung sieht.

Zero-Floor-Guard

ts
export function clampTotal(subtotalCents: number, adjustments: PricingAdjustment[]): number {
  const composedPercent = adjustments.reduce((acc, a) => acc + a.percent_off, 0);
  const total = subtotalCents * (1 - composedPercent / 100);
  const MIN_PAYABLE_CENTS = 900; // kleinster Tarif: 9,00 $
  if (total < MIN_PAYABLE_CENTS) {
    return MIN_PAYABLE_CENTS;
  }
  return Math.round(total);
}

Der Guard begrenzt auf den kleinsten kostenpflichtigen Tarif, anstatt den Warenkorb abzulehnen. Dies ist eine Produktentscheidung: Ein Kunde, der sich selbst in einen Unter-Grenze-Rabatt manövriert hat, ist immer noch ein Kunde, den wir konvertieren möchten. Die Begrenzung wird geloggt, damit der Support sehen kann, wann sie ausgelöst wird.

Prüfbare Preisaufschlüsselung

Die Checkout-Erfolgsseite rendert nun eine explizite Aufschlüsselung:

Plan: Pro Monatlich — 49,00 $ Coupon (WELCOME50): −24,50 $ Feiertagskampagne: −6,13 $ Referral-Guthaben: −4,90 $ Gesamt: 13,47 $

Dieser Block wird von derselben composeAdjustments-Funktion erzeugt, die die Stripe-Session erstellt hat, sodass die kundenseitig sichtbare Zahl und der Stripe-total_amount nicht auseinanderfallen können.

Replay — Sicher Ohne Guthaben-Zuordnung

Die 38 fehlgeschlagenen Checkouts mussten erneut abgespielt werden. Replay ist gefährlich, weil derselbe Kunde mit zwei Stripe-Sessions für denselben Warenkorb enden könnte, oder schlimmer, zwei Referral-Guthaben-Anwendungen für einen Kauf. Der Replay-Helfer ist auf einen Idempotenz-Token pro Warenkorb geschlüsselt:

ts
export async function replayAbandonedCart(cartId: string): Promise<ReplayResult> {
  const token = `replay:${cartId}`;
  const existing = await env.KV.get(token);
  if (existing) return JSON.parse(existing); // idempotent – prior result zurückgeben
  const cart = await loadCart(cartId);
  const session = await createStripeSession(cart); // nutzt die fixed Pricing-Engine
  const result = { sessionId: session.id, replayedAt: new Date().toISOString() };
  await env.KV.put(token, JSON.stringify(result), { expirationTtl: 60 * 60 * 24 * 30 });
  return result;
}

Der KV-Schlüssel enthält replay:, damit er nicht mit dem ursprünglichen createCheckoutSession-Idempotenz-Schlüssel kollidieren kann. Die 30-Tage-TTL entspricht der Stripe-Checkout-Session-Lebensdauer; danach ist der Warenkorb zu alt zum erneuten Abspielen.

Alle 38 Warenkörbe wurden innerhalb von 4 Stunden nach dem Fix ausgeliefert erneut abgespielt. 31 Kunden schlossen den Checkout beim Replay ab. 7 Kunden hatten aus unzusammenhängenden Gründen abgebrochen (Tab geschlossen, Gerät gewechselt) und erhielten eine E-Mail mit einem einmaligen Wiederherstellungslink mit dem korrigierten Gesamtbetrag.

Regressionstests und Alerts

Der Fix fügte sechs Regressionstests zur Pricing-Engine-Testsuite hinzu. Jeder Test benennt eine Vier-Wege-Kombination und prüft den erwarteten zusammengesetzten Gesamtbetrag:

  • coupon + holiday → 25 + Coupon-Prozentsatz, begrenzt auf kleinsten Tarif
  • coupon + utm_offer → utm_offer-Prozentsatz, begrenzt auf kleinsten Tarif
  • coupon + referral_credit → referral_credit-Prozentsatz, begrenzt auf kleinsten Tarif
  • coupon + holiday + referral_credit → alle drei, nach Priorität sortiert, begrenzt
  • coupon + holiday + utm_offer + referral_credit → alle vier, sortiert, begrenzt
  • clamp feuert wenn total < MIN_PAYABLE_CENTS → prüft total === MIN_PAYABLE_CENTS

Zwei Alerts wurden zum Produktions-Runbook hinzugefügt:

  • billing.checkout.error_rate > 5% für 3m — löst aus, bevor der ursprüngliche 10%-Schwellwert diesen Vorfall hätte erkennen können.
  • billing.pricing.clamp_fired > 10 in 1h — löst aus, wenn der neue Floor-Guard mehr als 10 Mal pro Stunde auslöst, was auf eine Fehlkonfiguration der Kampagne hindeutet, bevor Kunden sie sehen.

Die Checkout-Erfolgsrate für denselben Mandanten in den nächsten 30 Tagen nach dem Fix lag bei 99,5%, im Einklang mit der Baseline vor dem Vorfall.

Erkenntnisse und Verknüpftes Modul

Die drei Erkenntnisse, die ich aus diesem Vorfall behalte:

  1. Komposition ist ein Vertrag. Eine Pricing-Engine, die N unabhängige Oberflächen komponiert, muss Prioritäten, Untergrenzen und Invarianten veröffentlichen. Ohne diese drei ist jede neue Oberfläche eine Regression, die darauf wartet, einzutreten.
  2. Replay muss idempotent sein. Jedes Mal, wenn eine fehlgeschlagene Operation wiederholt werden kann, braucht der Retry-Pfad seinen eigenen Idempotenz-Schlüssel. Den ursprünglichen Schlüssel zu teilen ist der häufigste Weg, einen Kunden doppelt zu belasten oder gutzuschreiben.
  3. Prüfbare Aufschlüsselungen verhindern Support-Tickets. Eine einzelne Zahl auf der Quittung ist eine Blackbox. Einepositionsgenaue Aufschlüsselung ist eine Debug-Oberfläche, die der Support lesen kann, ohne Engineering zu eskalieren.

Was ich nächstes Mal anders bauen würde

Wenn ich die Pricing-Engine heute von Grund auf entwerfen würde, würde ich die Prioritätsreihenfolge und den Floor-Guard in ein typisiertes Schema verschieben, dem sich die vier Preisoberflächen unterwerfen müssen. Die aktuelle Implementierung ist korrekt, lebt aber im Applikationscode, nicht im Datenmodell. Ein Mandant, der eine fünfte Oberfläche hinzufügen möchte (sagen wir einen „Treue-Punkt-Multiplikator"), muss wissen, dass er das PRECEDENCE-Array und den Clamp-Schwellwert aktualisieren muss. Das ist eine Sache zu beachten. Zwei Jahre später, mit sieben Engineers und vier Preisoberflächen, sind das sieben Dinge zu beachten. Die Schema-first-Version sieht wie eine Discriminated Union aus, bei der jede Oberfläche ihren Prioritätsrang deklariert und die Engine sich weigert, zwei Oberflächen zu komponieren, deren Rangsummen 100% überschreiten. Der Vertrag wird zum Typ, nicht zum Kommentar.

Was nicht geändert werden musste

Es lohnt sich zu benennen, was nicht gebrochen ist. Stripe selbst hat genau das getan, was sein API-Vertrag vorgab: Es lehnte eine ungültige Eingabe ab. Der Retry-Helper, den ich bereits für transiente Fehler geschrieben hatte (Netzwerkaussetzer, Idempotenz-Schlüssel-Kollisionen), war ein brauchbares Substrat für den Replay-Pfad; Ich musste die Retry-Semantik nicht neu gestalten, sondern nur das replay:-Schlüsselpräfix hinzufügen. Die Observability-Schicht (Cloudflare Workers Analytics + Stripe-Webhooks) erfasste den Fehlerrate-Spike und den total_amount_zero_or_negative-Grundcode bereits. Der Fix erforderte keine neuen Dashboards, keine neuen Alerting-Kanäle und keine neuen On-Call-Rotationen. Er erforderte einen engeren Pricing-Vertrag und einen idempotenten Replay-Schlüssel. Alles andere war bereits vorhanden.

Wo dies Sich mit anderen TanStack-Ship-Modulen verbindet

Dasselbe Kompositionsmuster existiert in drei weiteren Modulen: dem Referral-Programm (Referral-Guthaben + Leaderboard-Bonus + Admin-Override), der E-Mail-Queue-Wiederholung (transaktional + Retry + Dead-Letter) und der Feature-Flag-Engine (Experiment + Kill-Switch + Segment-Override). Jedes Modul hat dieselbe Form: N unabhängige Oberflächen, eine komponierte Ausgabe, eine Invariante. Der Pricing-Engine-Vorfall hat das Muster explizit gemacht, und wir verbreiten den Prioritäts- + Floor- + Audit-Vertrag auf die anderen drei Module im nächsten Quartal. Wenn Sie ein ähnliches Oberflächen-Kompositionsmuster in Ihrem eigenen Produkt betreiben, ist die günstigste Prävention ein 30-Zeilen-Modul, das Prioritäten und Untergrenzen deklariert, bevor irgendeine Business-Logik landet.

Die fixed Pricing-Engine und der Replay-Helfer sind nun Standard in TanStack Ships Abrechnungsmodul. Jeder neue Mandant wird mit der Prioritätsreihenfolge, dem Zero-Floor-Guard und dem idempotenten Replay-Pfad ausgeliefert. Das nächste Mal, wenn eine Vier-Anpassungs-Kollision versucht wird, begrenzt das System sie, anstatt zu scheitern.


Nutzen Sie TanStack Ships geschützte Pricing-Pipeline, bevor Sie Kampagnen, Guthaben, Coupons und Feiertagsangebote kombinieren. Die Pipeline ist im TanStack Ship GitHub org Open-Source und ist Standard in jedem Abrechnungstemplate. Siehe die Abrechnungsmodul-Übersicht und die Pricing-Pipeline-Referenz für das vollständige Konfigurationsschema. Vergleichen Sie mit dem Vorfall-Postmortem-Template für die Sieben-Abschnitt-Struktur, die oben verwendet wurde.