feat: Folgeraten über Stripe Billing einziehen
Statt eigener Off-Session-Einzüge durch den Worker legt die Anwendung nach bezahlter erster Rate einen Stripe Subscription Schedule an. Stripe übernimmt Einzug, Wiederholungen (Smart Retries), 3-D-Secure-Links und Mahnungen. - Gateway: Zeitplan anlegen/beenden, Rechnungen lesen; chargeOffSession entfällt - Webhook: invoice.paid, invoice.payment_failed, invoice.payment_action_required - Worker: fehlende Zeitpläne anlegen, überfällige Raten abgleichen, Zeitpläne stornierter Buchungen beenden - Storno beendet den Zeitplan und storniert offene Rechnungen - "Rate bezahlen" führt auf Stripes Rechnungsseite - Migration: Booking.stripeScheduleId, Installment.stripeInvoiceId Zeitplan-Form mit Stripe-Testuhr gegen die Test-API geprüft. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
bcf0f5c937
commit
c1ad9ad2ca
18 files changed
+783
-538
No files matched your search
@@ -33,7 +33,7 @@ Stripe-Testbuchung mit Webhooks lokal:
|
||||
|
||||
```bash
|
||||
stripe listen --forward-to localhost:3000/api/stripe/webhook # gibt das whsec_… für STRIPE_WEBHOOK_SECRET aus
|
||||
pnpm worker # Reservierungen, Raten, E-Mails (im Cluster: CronJob)
|
||||
pnpm worker # Reservierungen, Raten-Abgleich, E-Mails (im Cluster: CronJob)
|
||||
```
|
||||
|
||||
Testkarte `4242 4242 4242 4242`, für 3-D-Secure-Fälle `4000 0027 6000 3184`. E-Mails landen mit `MAIL_TRANSPORT=file` als `.eml` in `.mail-outbox/` (oder in Mailpit unter http://localhost:8025).
|
||||
|
||||
+14
-10
@@ -44,22 +44,26 @@ Der gehostete Checkout bildet den Ablauf vollständig ab: Betrag, Ablaufzeit, da
|
||||
|
||||
## ADR-3: Endliche Ratenpläne
|
||||
|
||||
**Gewählt:** Die erste Rate läuft über Stripe Checkout mit `setup_future_usage=off_session`. Die Folgeraten sind einzelne Off-Session-PaymentIntents, die ein idempotenter Kubernetes-CronJob am Fälligkeitstag um 9:00 Uhr Berliner Zeit auslöst.
|
||||
**Gewählt (seit 05.10.2026): Stripe Billing zieht die Folgeraten ein.** Die erste Rate läuft wie bisher über Stripe Checkout mit `setup_future_usage=off_session`. Sobald sie bezahlt ist, legt die Anwendung bei Stripe einen Subscription Schedule an (`src/lib/installment-billing.ts`). Danach kümmert sich Stripe um Einzug, Wiederholungen, 3-D-Secure-Links und Mahnungen; die Anwendung verbucht nur noch die Rechnungs-Events.
|
||||
|
||||
**Verworfen: Subscription Schedules.** Sie sind laut Stripe-Doku das Standardmuster für Ratenzahlung, rechnen aber in festen Intervallen (z. B. monatlich). Die geforderte Regel „letzte Rate spätestens 30 Tage vor Anreise“ und eine vom Buchungstag abhängige Mitte lassen sich damit nicht sauber abbilden. Außerdem wäre es formal ein Abonnement.
|
||||
**Wie die Regel auf Stripe passt:** Die Folgeraten sind immer gleich hoch (ein Rest steckt in Rate 1). Der Zeitplan hat deshalb genau einen Preis: Start = Fälligkeit der zweiten Rate, Intervall = Abstand zwischen Rate 2 und 3 in Tagen (bei zwei Raten 1 Tag), Ende = Start + Anzahl Folgeraten × Intervall, `end_behavior=cancel`. Mit Stripe-Testuhr geprüft: genau eine Rechnung je Folgerate, danach endet das Abonnement ohne weitere Rechnung. Ein Wechsel von Preisen zwischen Phasen erzeugt dagegen keine Rechnung und wurde deshalb verworfen.
|
||||
|
||||
**Gründe für den gewählten Weg:**
|
||||
- Die Raten sind exakte, unveränderliche Datenbankzeilen (per DB-Trigger geschützt). Beträge wie 800 + 700 + 700 € sind direkt abbildbar.
|
||||
- Endlichkeit ist dreifach gesichert: Es gibt maximal drei Ratenzeilen, jeder Einzug prüft `paid + Rate ≤ Gesamt`, und nach der letzten Rate wird die Zahlungsmethode bei Stripe getrennt.
|
||||
**Vorher (bis 05.10.2026):** Off-Session-PaymentIntents, ausgelöst vom Worker-CronJob. Abgelöst, weil Wiederholungen, Bestätigungslinks und Mahnungen bei Stripe liegen sollen statt beim Betreiber. Invoice Payment Plans (Stripe) kamen nicht in Frage: Private Preview und kein automatischer Einzug.
|
||||
|
||||
**Sicherungen:**
|
||||
- Die Raten bleiben exakte, unveränderliche Datenbankzeilen (per DB-Trigger geschützt). Jede Stripe-Rechnung wird genau einer Rate zugeordnet (`Installment.stripeInvoiceId`); Stripe erzeugt die Rechnungen nacheinander, die neue gehört zur ersten noch offenen Rate.
|
||||
- Endlichkeit: Der Zeitplan endet nach der letzten Rate von selbst. Eine Zahlung ohne passende offene Rate oder über den Gesamtpreis hinaus landet als „Prüfung nötig“ im Admin. Nach der letzten Rate wird die Zahlungsmethode getrennt.
|
||||
- Storno beendet den Zeitplan sofort und storniert offene Rechnungen (`void`). Scheitert das bei Stripe, holt der Worker es nach; die Zahlungsmethode wird erst danach getrennt.
|
||||
- Fehlschlag: Stripe wiederholt nach den Einstellungen unter „Revenue recovery“ (Smart Retries). Die Rate steht auf `FAILED` mit nächstem Versuch, die Kundin bekommt eine Mail mit Zahlungslink. Gibt Stripe auf, wird die Buchung „Prüfung nötig“.
|
||||
- Verlangt die Bank eine Bestätigung, geht die Rate auf `REQUIRES_ACTION`. Die Kundin zahlt über Stripes Rechnungsseite (`hosted_invoice_url`), auch mit einer anderen Zahlungsmethode. Eine eigene Checkout-Session gibt es bewusst nicht, sonst würde Stripe parallel weiter einziehen.
|
||||
- Idempotenz: Der Zeitplan wird mit Schlüssel `schedule:<buchung>` angelegt. Doppelte oder ungeordnete Events werden über die Rechnungs-ID und den PaymentIntent nur einmal verbucht.
|
||||
- Verlorene Webhooks: Der Worker legt fehlende Zeitpläne an und fragt bei Raten, die seit 6 Stunden fällig und nicht verbucht sind, die Rechnungen des Zeitplans ab.
|
||||
- Zustimmung: Vor der Zahlung wird ausdrücklich dem Einzug zugestimmt, mit Zeitstempel `installmentMandateAt`.
|
||||
- Verlangt die Bank eine Authentifizierung, geht die Rate auf `REQUIRES_ACTION`, und die Kundin bekommt per E-Mail einen Link und zahlt über eine eigene Checkout-Session.
|
||||
- Bei Fehlschlag gibt es bis zu drei Versuche im Abstand von drei Tagen, danach „Prüfung nötig“ im Admin.
|
||||
- Idempotenzschlüssel `offsession:<rate>:<versuch>` verhindern Doppelabbuchungen, auch nach Netzwerkfehlern oder Worker-Abbrüchen.
|
||||
- Keine Gleichsetzung mit Klarna: Es ist die eigene Ratenvereinbarung des Veranstalters. Im Livemodus ist sie erst nach ausdrücklicher Freigabe verfügbar.
|
||||
- Keine Gleichsetzung mit Klarna: Es ist die eigene Ratenvereinbarung des Veranstalters, das Ausfallrisiko bleibt beim Veranstalter. Im Livemodus ist sie erst nach ausdrücklicher Freigabe verfügbar.
|
||||
|
||||
**Aufteilung:** Ist der Betrag ohne Centrest teilbar, sind alle Raten gleich. Sonst werden die Folgeraten auf volle 50 € abgerundet, der Rest fällt in Rate 1. Das reproduziert alle zwölf Vorgabewerte (Tests).
|
||||
|
||||
**Termine:** Die letzte Rate ist am Stichtag fällig (Anreise minus 30 Tage). Bei drei Raten liegt die zweite in der Mitte zwischen Buchungstag und Stichtag. Der Mindestabstand beträgt 14 Tage, sonst ist die Variante serverseitig deaktiviert. Diese Terminregel ist ein Vorschlag und braucht vor dem Live-Verkauf eine Freigabe.
|
||||
**Termine:** Die letzte Rate ist am Stichtag fällig (Anreise minus 30 Tage). Bei drei Raten liegt die zweite in der Mitte zwischen Buchungstag und Stichtag. Der Mindestabstand beträgt 14 Tage, sonst ist die Variante serverseitig deaktiviert. Stripe zieht ab dem Fälligkeitstag um 9:00 Uhr Berliner Zeit ein; liegt zwischen zwei Raten eine Zeitumstellung, verschiebt sich die Uhrzeit der dritten Rate um eine Stunde (Intervall in ganzen Tagen), der Tag bleibt gleich. Diese Terminregel ist ein Vorschlag und braucht vor dem Live-Verkauf eine Freigabe.
|
||||
|
||||
## ADR-4: Kapazität und Gutscheine ohne Überbuchung
|
||||
|
||||
|
||||
+10
-4
@@ -26,7 +26,7 @@ Der Build braucht Internet für `next/font` (Schriften werden eingebettet), aber
|
||||
## 2. Einmalig einrichten
|
||||
|
||||
**Was ausgerollt wird:** StatefulSet PostgreSQL, Services (App, Postgres), Ingress, App-Deployment (mit Migration als initContainer), zwei SMB-Volumes (Postgres-Daten, Medien) und zusätzlich:
|
||||
- der Worker-CronJob, nötig für Folgeraten, Reservierungsfreigabe und Mail-Wiederholung
|
||||
- der Worker-CronJob, nötig für Reservierungsfreigabe, Abgleich der Folgeraten und Mail-Wiederholung
|
||||
- ConfigMap
|
||||
- zwei NetworkPolicies (Schutz der Rate Limits und der Datenbank)
|
||||
|
||||
@@ -55,9 +55,13 @@ Migrationen sind vorwärtskompatibel zu schreiben (erst Spalten ergänzen, spät
|
||||
|
||||
- **Schlüssel:** Im Test `sk_test_…`, live `sk_live_…` (nur mit `APP_ENV=production`). Empfohlen ist ein eingeschränkter Schlüssel mit Schreibrechten auf Customers, Checkout Sessions, PaymentIntents, PaymentMethods und Refunds.
|
||||
- **Webhook-Endpunkt:** `https://<domain>/api/stripe/webhook`. Diese Events abonnieren:
|
||||
`checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired`, `payment_intent.succeeded`, `payment_intent.payment_failed`, `charge.refunded`, `refund.created`, `refund.updated`, `charge.dispute.created`.
|
||||
`checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired`, `invoice.paid`, `invoice.payment_failed`, `invoice.payment_action_required`, `charge.refunded`, `refund.created`, `refund.updated`, `charge.dispute.created`.
|
||||
Das Signing Secret gehört als `STRIPE_WEBHOOK_SECRET` ins Secret. Test- und Live-Endpunkt haben getrennte Secrets.
|
||||
- **Zahlungsarten** (Dashboard → Zahlungsmethoden): Für Ratenzahlung nur wiederverwendbare Methoden aktivieren (Karten, gegebenenfalls SEPA-Lastschrift). Checkout blendet bei Ratenzahlung ohnehin nur Methoden ein, die `setup_future_usage` unterstützen.
|
||||
- **Folgeraten (Stripe Billing):** Stripe zieht die Raten 2 und 3 über einen Subscription Schedule ein (docs/ARCHITEKTUR.md, ADR-3). Im Dashboard unter Billing → Einstellungen festlegen:
|
||||
- **Revenue recovery → Retries:** Smart Retries an (z. B. 4 Versuche innerhalb von 2 Wochen). Nach dem letzten Versuch: Abonnement kündigen. Die Buchung erscheint dann als „Prüfung nötig“.
|
||||
- **E-Mails an Kunden:** „Zahlung fehlgeschlagen“ und „Bestätigung für 3-D Secure erforderlich“ einschalten, damit Stripe selbst mahnt und Bestätigungslinks schickt. Die App schickt zusätzlich eine eigene deutschsprachige Mail mit Link zur Statusseite.
|
||||
- Der eingeschränkte Schlüssel braucht zusätzlich Schreibrechte für Products, Subscription Schedules, Subscriptions und Invoices.
|
||||
- **Test lokal:** `stripe listen --forward-to localhost:3000/api/stripe/webhook`
|
||||
- **Sicherheitsnetz:** Der Worker gleicht bei abgelaufenen Reservierungen und hängenden Einzügen immer mit Stripe ab. Ein verlorener Webhook führt so nicht zu verlorenen Zahlungen.
|
||||
|
||||
@@ -69,8 +73,10 @@ Migrationen sind vorwärtskompatibel zu schreiben (erst Spalten ergänzen, spät
|
||||
|
||||
Der CronJob `artista-modelcamp-worker` läuft alle 10 Minuten und kümmert sich um:
|
||||
- abgelaufene Reservierungen, vorher mit Rückfrage bei Stripe
|
||||
- hängende Einzüge
|
||||
- fällige Raten
|
||||
- hängende erste Zahlungen (SEPA)
|
||||
- fehlende Stripe-Ratenpläne anlegen (z. B. nach Bestätigung einer geprüften Buchung)
|
||||
- Abgleich überfälliger Raten mit den Stripe-Rechnungen (verlorene Webhooks)
|
||||
- Ratenpläne stornierter Buchungen beenden, falls das beim Storno scheiterte
|
||||
- das Trennen der Zahlungsmethode nach der letzten Rate
|
||||
- den E-Mail-Versand
|
||||
- das Aufräumen der Rate Limits
|
||||
|
||||
+3
-1
@@ -49,9 +49,11 @@ Hinweis: Läuft der Testserver nicht unter der Adresse aus `APP_URL`, lehnt der
|
||||
- Der Standalone-Build läuft mit Datenbank. Worker-Lauf und `admin:create` funktionieren, eine doppelte Anlage wird abgewiesen.
|
||||
- Build, Typprüfung und ESLint laufen ohne Fehler, Kustomize rendert 17 Ressourcen.
|
||||
|
||||
Lauf 05.10.2026 nach Umstellung der Folgeraten auf Stripe Billing: 85/85 Unit- und Integrationstests, Typprüfung und ESLint ohne Fehler. Zusätzlich `StripeGateway` gegen die echte Stripe-Test-API mit Testuhr geprüft: Zeitplan mit zwei Folgeraten erzeugt genau zwei bezahlte Rechnungen (je 700 €, Metadaten `bookingId`/`kind`, PaymentIntent mit Charge) und endet danach (`completed`); abgelehnte Karte ergibt offene Rechnung mit geplantem Wiederholungsversuch und Zahlungslink; Storno setzt den Zeitplan auf `canceled` und die offene Rechnung auf `void`, auch wiederholt und vor dem Start.
|
||||
|
||||
## Nicht ausgeführt / Einschränkungen
|
||||
|
||||
- **Echte Stripe-Testbuchung** gegen die Stripe-API: Ein Stripe-Testschlüssel lag nicht vor. Die Stripe-Logik ist gegen eine Attrappe mit Stripe-Semantik getestet (Idempotenz, Session-Zustände, Off-Session-Ergebnisse), die Webhook-Signatur mit der echten Stripe-Bibliothek. Mit Schlüssel: Anleitung in der README (`stripe listen`, Testkarten).
|
||||
- **Echte Stripe-Testbuchung** gegen die Stripe-API: Ein Stripe-Testschlüssel lag nicht vor. Die Stripe-Logik ist gegen eine Attrappe mit Stripe-Semantik getestet (Idempotenz, Session-Zustände, Rechnungen des Ratenplans), die Webhook-Signatur mit der echten Stripe-Bibliothek. Mit Schlüssel: Anleitung in der README (`stripe listen`, Testkarten).
|
||||
- **Docker-Image und Cluster:** Auf dem Entwicklungsrechner gibt es kein Docker und keinen Cluster-Zugang. Das Dockerfile ist nicht gebaut und nicht im Cluster ausgerollt. Geprüft sind der Standalone-Build und das Rendern der Manifeste.
|
||||
- **SMTP-Versand:** nur über den Datei-Testausgang geprüft.
|
||||
- **Referenzseite:** nur über eine Textzusammenfassung ausgewertet (siehe ARCHITEKTUR.md).
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Folgeraten ueber Stripe Billing (Subscription Schedule) statt eigenem Off-Session-Einzug.
|
||||
ALTER TABLE "Booking" ADD COLUMN "stripeScheduleId" TEXT;
|
||||
CREATE UNIQUE INDEX "Booking_stripeScheduleId_key" ON "Booking"("stripeScheduleId");
|
||||
|
||||
ALTER TABLE "Installment" ADD COLUMN "stripeInvoiceId" TEXT;
|
||||
CREATE UNIQUE INDEX "Installment_stripeInvoiceId_key" ON "Installment"("stripeInvoiceId");
|
||||
@@ -191,6 +191,9 @@ model Booking {
|
||||
/// Zahlungsmethode. Wird nach der letzten Rate bei Stripe getrennt.
|
||||
stripePaymentMethodId String?
|
||||
paymentMethodDetachedAt DateTime?
|
||||
/// Stripe-Billing-Zeitplan, der die Folgeraten einzieht (nur Ratenzahlung).
|
||||
/// Stripe uebernimmt Einzug, Wiederholungen und Mahnungen.
|
||||
stripeScheduleId String? @unique
|
||||
|
||||
paidCents Int @default(0)
|
||||
refundedCents Int @default(0)
|
||||
@@ -246,6 +249,8 @@ model Installment {
|
||||
|
||||
stripePaymentIntentId String? @unique
|
||||
stripeChargeId String? @unique
|
||||
/// Stripe-Rechnung, ueber die Stripe Billing diese Folgerate einzieht.
|
||||
stripeInvoiceId String? @unique
|
||||
/// Offene Checkout-Session, ueber die die Kundin diese Rate selbst zahlt.
|
||||
checkoutSessionId String? @unique
|
||||
checkoutExpiresAt DateTime?
|
||||
|
||||
@@ -108,7 +108,7 @@ export default async function SettingsPage({ searchParams }: { searchParams: Pro
|
||||
name="installmentRuleApproved"
|
||||
label="Terminregel für Raten freigegeben"
|
||||
defaultChecked={camp.installmentRuleApproved}
|
||||
hint="Letzte Rate am Stichtag, bei drei Raten die zweite in der Mitte; Einzug 9 Uhr."
|
||||
hint="Letzte Rate am Stichtag, bei drei Raten die zweite in der Mitte; Stripe zieht ab 9 Uhr ein."
|
||||
/>
|
||||
<Checkbox name="installmentsApproved" label="Ratenzahlung für den Live-Verkauf freigegeben" defaultChecked={camp.installmentsApproved} />
|
||||
<label className="mt-3 block">
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { checkSameOrigin, fail, ok, rateLimited, readJson } from "@/lib/api";
|
||||
import { abandonCheckout, startInstallmentCheckout } from "@/lib/checkout";
|
||||
import { abandonCheckout, installmentPaymentUrl } from "@/lib/checkout";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { requireAppSecret } from "@/lib/env";
|
||||
import { getGateway } from "@/lib/payment-gateway";
|
||||
import { RATE_LIMITS } from "@/lib/rate-limit";
|
||||
import { isWellFormedStatusToken, sha256Hex } from "@/lib/tokens";
|
||||
@@ -37,7 +36,7 @@ export async function POST(request: Request) {
|
||||
|
||||
switch (body.data.action) {
|
||||
case "pay_installment": {
|
||||
const result = await startInstallmentCheckout(db, gateway, { bookingId: booking.id, installmentId: body.data.installmentId }, { now, secret: requireAppSecret() });
|
||||
const result = await installmentPaymentUrl(db, gateway, { bookingId: booking.id, installmentId: body.data.installmentId });
|
||||
if (result.ok) return ok({ url: result.url });
|
||||
return result.error === "NOT_PAYABLE"
|
||||
? fail(409, { code: "NOT_PAYABLE", message: "Diese Rate kann gerade nicht bezahlt werden. Bitte lade die Seite neu." })
|
||||
|
||||
@@ -15,6 +15,7 @@ import { enqueueEmail } from "./email";
|
||||
import { bookingConfirmedEmail } from "./email-templates";
|
||||
import type { PaymentGateway } from "./payment-gateway";
|
||||
import { getCamp } from "./camp";
|
||||
import { stopCanceledInstallmentPlans } from "./installment-billing";
|
||||
import { addDays, berlinStartOfDay, isoToDbDate } from "./time";
|
||||
import { statusTokenFor } from "./tokens";
|
||||
import { normalizeEmail, normalizeVoucherCode } from "./vouchers";
|
||||
@@ -54,10 +55,19 @@ export async function cancelBooking(
|
||||
});
|
||||
if (!before) return { ok: false, message: "Buchung nicht gefunden oder bereits storniert." };
|
||||
|
||||
// Offene Stripe-Sessions schliessen und gespeicherte Zahlungsmethode
|
||||
// trennen: nach einer Stornierung darf nichts mehr abgebucht werden.
|
||||
const sessions = [before.stripeCheckoutSessionId, ...before.installments.map((i) => i.checkoutSessionId)].filter(Boolean) as string[];
|
||||
for (const id of sessions) await gateway.expireCheckoutSession(id).catch(() => undefined);
|
||||
// Offene Session schliessen, Stripe-Ratenplan beenden und gespeicherte
|
||||
// Zahlungsmethode trennen: nach einer Stornierung darf nichts mehr
|
||||
// abgebucht werden.
|
||||
if (before.stripeCheckoutSessionId) await gateway.expireCheckoutSession(before.stripeCheckoutSessionId).catch(() => undefined);
|
||||
try {
|
||||
await stopCanceledInstallmentPlans(db, gateway, [before.id], actor);
|
||||
} catch (error) {
|
||||
console.error(`[admin] Ratenplan fuer Buchung ${before.id} nicht beendet: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||
return {
|
||||
ok: false,
|
||||
message: "Buchung storniert, aber der Ratenplan bei Stripe ließ sich noch nicht beenden. Der Hintergrundlauf versucht es alle 10 Minuten erneut.",
|
||||
};
|
||||
}
|
||||
await detachFinishedPaymentMethods(db, gateway, [before.id]);
|
||||
return { ok: true, message: "Buchung storniert. Eine Erstattung wird separat ausgelöst." };
|
||||
}
|
||||
|
||||
+17
-62
@@ -120,77 +120,32 @@ export async function startCheckout(
|
||||
}
|
||||
}
|
||||
|
||||
export type InstallmentCheckoutResult =
|
||||
| { ok: true; url: string }
|
||||
| { ok: false; error: "NOT_PAYABLE" | "PAYMENT_PROVIDER" };
|
||||
export type InstallmentPaymentResult = { ok: true; url: string } | { ok: false; error: "NOT_PAYABLE" | "PAYMENT_PROVIDER" };
|
||||
|
||||
/**
|
||||
* Kundin zahlt eine Folgerate selbst (nach fehlgeschlagenem Einzug, bei
|
||||
* verlangter Authentifizierung oder freiwillig vorzeitig). Die Rate wird
|
||||
* fuer die Laufzeit der Session markiert, damit der Worker sie nicht
|
||||
* gleichzeitig off-session einzieht.
|
||||
* Kundin zahlt eine Folgerate selbst (nach fehlgeschlagenem Einzug oder bei
|
||||
* verlangter Bestaetigung). Bezahlt wird die offene Stripe-Rechnung dieser
|
||||
* Rate ueber Stripes Rechnungsseite — auch mit einer anderen Zahlungsmethode.
|
||||
* Eine eigene Checkout-Session gibt es bewusst nicht: Stripe wuerde die
|
||||
* Rechnung sonst parallel weiter einziehen.
|
||||
*/
|
||||
export async function startInstallmentCheckout(
|
||||
export async function installmentPaymentUrl(
|
||||
db: Db,
|
||||
gateway: PaymentGateway,
|
||||
params: { bookingId: string; installmentId: string },
|
||||
ctx: { now: Date; secret: string },
|
||||
): Promise<InstallmentCheckoutResult> {
|
||||
const sessionExpiresAt = new Date(ctx.now.getTime() + MIN_STRIPE_SESSION_MS + 60_000);
|
||||
|
||||
const claim = await db.$transaction(async (tx) => {
|
||||
await lockBooking(tx, params.bookingId);
|
||||
const booking = await tx.booking.findUniqueOrThrow({
|
||||
where: { id: params.bookingId },
|
||||
include: { installments: true },
|
||||
): Promise<InstallmentPaymentResult> {
|
||||
const installment = await db.installment.findFirst({
|
||||
where: { id: params.installmentId, bookingId: params.bookingId, seq: { gt: 1 } },
|
||||
include: { booking: { select: { status: true } } },
|
||||
});
|
||||
const installment = booking.installments.find((i) => i.id === params.installmentId);
|
||||
if (!installment || installment.seq === 1) return null;
|
||||
if (booking.status !== "CONFIRMED" || !booking.stripeCustomerId) return null;
|
||||
if (!["PENDING", "FAILED", "REQUIRES_ACTION"].includes(installment.status)) return null;
|
||||
if (booking.paidCents + installment.amountCents > booking.totalCents) return null;
|
||||
if (installment.checkoutSessionId && installment.checkoutExpiresAt && installment.checkoutExpiresAt > new Date(ctx.now.getTime() + 5 * 60_000)) {
|
||||
return { booking, installment, existingSessionId: installment.checkoutSessionId };
|
||||
}
|
||||
await tx.installment.update({
|
||||
where: { id: installment.id },
|
||||
data: { checkoutExpiresAt: sessionExpiresAt, checkoutSessionId: null },
|
||||
});
|
||||
return { booking, installment, existingSessionId: null as string | null };
|
||||
});
|
||||
if (!claim) return { ok: false, error: "NOT_PAYABLE" };
|
||||
|
||||
const { booking, installment } = claim;
|
||||
if (!installment?.stripeInvoiceId || installment.booking.status !== "CONFIRMED") return { ok: false, error: "NOT_PAYABLE" };
|
||||
if (installment.status !== "FAILED" && installment.status !== "REQUIRES_ACTION") return { ok: false, error: "NOT_PAYABLE" };
|
||||
try {
|
||||
if (claim.existingSessionId) {
|
||||
const existing = await gateway.retrieveCheckoutSession(claim.existingSessionId);
|
||||
if (existing.status === "open" && existing.url) return { ok: true, url: existing.url };
|
||||
}
|
||||
const token = statusTokenFor(booking.id, ctx.secret);
|
||||
const session = await gateway.createCheckoutSession(
|
||||
{
|
||||
customerId: booking.stripeCustomerId!,
|
||||
amountCents: installment.amountCents,
|
||||
productName: PRODUCT_NAME,
|
||||
description: `${PRODUCT_DESCRIPTION} · Rate ${installment.seq} von ${booking.installments.length}`,
|
||||
metadata: { kind: "installment", bookingId: booking.id, reference: booking.reference, installmentId: installment.id },
|
||||
saveForFutureUsage: booking.installments.some((i) => i.seq > installment.seq && i.status !== "PAID"),
|
||||
expiresAt: sessionExpiresAt,
|
||||
successUrl: appUrl(`/buchung/status/${token}?checkout=success`),
|
||||
cancelUrl: appUrl(`/buchung/status/${token}`),
|
||||
submitMessage: `Rate ${installment.seq} über ${formatEuro(installment.amountCents)} für Buchung ${booking.reference}.`,
|
||||
},
|
||||
`installment-checkout:${installment.id}:${sessionExpiresAt.getTime()}`,
|
||||
);
|
||||
await db.installment.update({ where: { id: installment.id }, data: { checkoutSessionId: session.id } });
|
||||
await audit(db, CUSTOMER, "checkout.installment_session_created", "Installment", installment.id, { sessionId: session.id });
|
||||
return { ok: true, url: session.url };
|
||||
const invoice = await gateway.retrieveInvoice(installment.stripeInvoiceId);
|
||||
if (invoice.status !== "open" || !invoice.hostedInvoiceUrl) return { ok: false, error: "NOT_PAYABLE" };
|
||||
return { ok: true, url: invoice.hostedInvoiceUrl };
|
||||
} catch (error) {
|
||||
console.error(`[checkout] Stripe-Fehler fuer Rate ${installment.id}: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||
await db.installment.updateMany({
|
||||
where: { id: installment.id, checkoutSessionId: null },
|
||||
data: { checkoutExpiresAt: null },
|
||||
});
|
||||
console.error(`[checkout] Rechnung fuer Rate ${installment.id} nicht abrufbar: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||
return { ok: false, error: "PAYMENT_PROVIDER" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Folgeraten ueber Stripe Billing (docs/ARCHITEKTUR.md, ADR-3).
|
||||
*
|
||||
* Nach der bezahlten ersten Rate wird bei Stripe ein Subscription Schedule
|
||||
* angelegt, der die Folgeraten zu ihren Faelligkeiten einzieht. Ab dann
|
||||
* kuemmert sich Stripe um Einzug, Wiederholungen und Mahnungen; diese
|
||||
* Anwendung verbucht nur die Rechnungen (Webhook, Abgleich im Worker).
|
||||
*
|
||||
* Voraussetzung, die payment-plan.ts garantiert: Alle Folgeraten sind gleich
|
||||
* hoch (ein etwaiger Rest steckt in der ersten Rate). Der Zeitplan besteht
|
||||
* deshalb aus einem einzigen Preis mit dem Ratenabstand in Tagen als
|
||||
* Intervall und endet nach der letzten Rate von selbst.
|
||||
*/
|
||||
import { audit, WORKER, type Actor } from "./audit";
|
||||
import type { Db } from "./db";
|
||||
import { applyInstallmentInvoicePaid, flagBookingForReview, type EventContext } from "./payment-events";
|
||||
import type { InvoiceInfo, PaymentGateway } from "./payment-gateway";
|
||||
import { berlinDateString, daysBetween } from "./time";
|
||||
|
||||
export const INSTALLMENT_PLAN_KIND = "installment_plan";
|
||||
/** Stripe verlangt einen Start in der Zukunft. */
|
||||
const MIN_START_LEAD_MS = 60_000;
|
||||
|
||||
export type ScheduleOutcome = "created" | "exists" | "skipped" | "needs_review";
|
||||
|
||||
type Ctx = Pick<EventContext, "now" | "secret" | "adminEmail">;
|
||||
|
||||
export async function ensureInstallmentSchedule(
|
||||
db: Db,
|
||||
gateway: PaymentGateway,
|
||||
bookingId: string,
|
||||
ctx: Ctx,
|
||||
actor: Actor,
|
||||
): Promise<ScheduleOutcome> {
|
||||
const booking = await db.booking.findUniqueOrThrow({
|
||||
where: { id: bookingId },
|
||||
include: { installments: { orderBy: { seq: "asc" } } },
|
||||
});
|
||||
if (booking.stripeScheduleId) return "exists";
|
||||
if (booking.planType === "FULL" || booking.status !== "CONFIRMED") return "skipped";
|
||||
if (booking.livemode !== (gateway.mode === "live")) return "skipped";
|
||||
const later = booking.installments.filter((i) => i.seq > 1 && i.status === "PENDING");
|
||||
if (later.length === 0) return "skipped";
|
||||
|
||||
const problem = schedulePlanProblem(booking, later, ctx.now);
|
||||
if (problem) {
|
||||
await db.$transaction((tx) => flagBookingForReview(tx, booking.id, problem, { ...ctx, actor }));
|
||||
return "needs_review";
|
||||
}
|
||||
|
||||
const first = later[0]!;
|
||||
const intervalDays =
|
||||
later.length === 1 ? 1 : daysBetween(berlinDateString(first.dueAt!), berlinDateString(later[1]!.dueAt!));
|
||||
const startAt = new Date(Math.max(first.dueAt!.getTime(), ctx.now.getTime() + MIN_START_LEAD_MS));
|
||||
const schedule = await gateway.createInstallmentSchedule(
|
||||
{
|
||||
customerId: booking.stripeCustomerId!,
|
||||
paymentMethodId: booking.stripePaymentMethodId!,
|
||||
startAt,
|
||||
amountCents: first.amountCents,
|
||||
intervalDays,
|
||||
count: later.length,
|
||||
description: `ARTISTA MODELCAMP 2027 · ${booking.reference} · Folgerate`,
|
||||
metadata: { kind: INSTALLMENT_PLAN_KIND, bookingId: booking.id, reference: booking.reference },
|
||||
},
|
||||
`schedule:${booking.id}`,
|
||||
);
|
||||
|
||||
const saved = await db.booking.updateMany({
|
||||
where: { id: booking.id, stripeScheduleId: null },
|
||||
data: { stripeScheduleId: schedule.id },
|
||||
});
|
||||
if (saved.count === 0) return "exists";
|
||||
await audit(db, actor, "installments.schedule_created", "Booking", booking.id, {
|
||||
scheduleId: schedule.id,
|
||||
startAt: startAt.toISOString(),
|
||||
intervalDays,
|
||||
count: later.length,
|
||||
});
|
||||
|
||||
// Wurde die Buchung waehrenddessen storniert, sofort wieder beenden.
|
||||
const current = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, select: { status: true } });
|
||||
if (current.status === "CANCELED") await gateway.cancelInstallmentSchedule(schedule.id);
|
||||
return "created";
|
||||
}
|
||||
|
||||
function schedulePlanProblem(
|
||||
booking: { stripeCustomerId: string | null; stripePaymentMethodId: string | null; paymentMethodDetachedAt: Date | null },
|
||||
later: { amountCents: number; dueAt: Date | null }[],
|
||||
now: Date,
|
||||
): string | null {
|
||||
if (!booking.stripeCustomerId || !booking.stripePaymentMethodId || booking.paymentMethodDetachedAt) {
|
||||
return "Keine gespeicherte Zahlungsmethode für die Folgeraten – Ratenplan bei Stripe nicht angelegt.";
|
||||
}
|
||||
if (later.some((i) => !i.dueAt) || later.some((i) => i.amountCents !== later[0]!.amountCents)) {
|
||||
return "Folgeraten passen nicht zu einem Stripe-Ratenplan (Betrag oder Termin) – bitte prüfen.";
|
||||
}
|
||||
if (later.at(-1)!.dueAt! < now) {
|
||||
return "Termin der letzten Rate ist bereits verstrichen – Ratenplan bei Stripe nicht angelegt.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beendet die Stripe-Ratenplaene stornierter Buchungen und trennt danach die
|
||||
* gespeicherte Zahlungsmethode. paymentMethodDetachedAt markiert "erledigt";
|
||||
* scheitert Stripe, bleibt die Buchung Kandidat fuer den naechsten Lauf.
|
||||
*/
|
||||
export async function stopCanceledInstallmentPlans(db: Db, gateway: PaymentGateway, bookingIds?: string[], actor: Actor = WORKER) {
|
||||
const candidates = await db.booking.findMany({
|
||||
where: {
|
||||
status: "CANCELED",
|
||||
stripeScheduleId: { not: null },
|
||||
paymentMethodDetachedAt: null,
|
||||
livemode: gateway.mode === "live",
|
||||
...(bookingIds ? { id: { in: bookingIds } } : {}),
|
||||
},
|
||||
take: 50,
|
||||
});
|
||||
for (const booking of candidates) {
|
||||
await gateway.cancelInstallmentSchedule(booking.stripeScheduleId!);
|
||||
if (booking.stripePaymentMethodId) await gateway.detachPaymentMethod(booking.stripePaymentMethodId);
|
||||
await db.booking.update({ where: { id: booking.id }, data: { paymentMethodDetachedAt: new Date() } });
|
||||
await audit(db, actor, "installments.schedule_canceled", "Booking", booking.id, { scheduleId: booking.stripeScheduleId });
|
||||
}
|
||||
return { stopped: candidates.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbucht eine bezahlte Rechnung des Zeitplans. Genutzt vom Webhook und vom
|
||||
* Abgleich im Worker; mehrfacher Aufruf ist unschaedlich.
|
||||
*/
|
||||
export async function settleInstallmentInvoice(
|
||||
db: Db,
|
||||
gateway: PaymentGateway,
|
||||
invoice: InvoiceInfo,
|
||||
ctx: Ctx,
|
||||
actor: Actor,
|
||||
): Promise<string | null> {
|
||||
const { kind, bookingId } = invoice.metadata;
|
||||
if (kind !== INSTALLMENT_PLAN_KIND || !bookingId) return null;
|
||||
if (invoice.status !== "paid" || !invoice.paymentIntentId) return null;
|
||||
const pi = await gateway.retrievePaymentIntent(invoice.paymentIntentId);
|
||||
await db.$transaction((tx) =>
|
||||
applyInstallmentInvoicePaid(
|
||||
tx,
|
||||
bookingId,
|
||||
invoice.id,
|
||||
{
|
||||
paymentIntentId: pi.id,
|
||||
chargeId: pi.chargeId,
|
||||
paymentMethodId: pi.paymentMethodId,
|
||||
customerId: null,
|
||||
amountCents: invoice.amountPaidCents,
|
||||
},
|
||||
{ ...ctx, actor },
|
||||
),
|
||||
);
|
||||
return bookingId;
|
||||
}
|
||||
+65
-28
@@ -31,9 +31,6 @@ import {
|
||||
import { statusTokenFor } from "./tokens";
|
||||
import { countUsedRedemptions } from "./vouchers";
|
||||
|
||||
export const MAX_OFF_SESSION_ATTEMPTS = 3;
|
||||
export const RETRY_DELAY_DAYS = 3;
|
||||
|
||||
export type EventContext = { now: Date; secret: string; actor: Actor; adminEmail: string | null };
|
||||
|
||||
export type PaymentInfo = {
|
||||
@@ -66,6 +63,11 @@ async function flagForReview(tx: Tx, booking: Booking, reason: string, ctx: Even
|
||||
if (ctx.adminEmail) await enqueueEmail(tx, adminReviewEmail(ctx.adminEmail, booking, reason));
|
||||
}
|
||||
|
||||
/** Fuer Probleme, die ausserhalb eines Zahlungsereignisses auffallen (z. B. Ratenplan bei Stripe). */
|
||||
export async function flagBookingForReview(tx: Tx, bookingId: string, reason: string, ctx: EventContext): Promise<void> {
|
||||
await flagForReview(tx, await loadLocked(tx, bookingId), reason, ctx);
|
||||
}
|
||||
|
||||
export type FirstPaymentOutcome = "confirmed" | "duplicate" | "needs_review";
|
||||
|
||||
/** Erste Rate (bzw. Einmalzahlung) ist bei Stripe erfolgreich eingegangen. */
|
||||
@@ -230,7 +232,7 @@ export async function applyCheckoutExpired(
|
||||
|
||||
export type InstallmentPaidOutcome = { outcome: "paid" | "duplicate" | "needs_review"; fullyPaid: boolean };
|
||||
|
||||
/** Folgerate bezahlt (Off-Session-Einzug oder selbst bezahlt per Link). */
|
||||
/** Folgerate bezahlt (ueber ihre Stripe-Rechnung, siehe applyInstallmentInvoicePaid). */
|
||||
export async function applyInstallmentPaid(
|
||||
tx: Tx,
|
||||
installmentId: string,
|
||||
@@ -294,47 +296,82 @@ export async function applyInstallmentPaid(
|
||||
}
|
||||
|
||||
/**
|
||||
* Off-Session-Einzug fehlgeschlagen. Greift nur, solange die Rate vom Worker
|
||||
* beansprucht ist (PROCESSING) — so zaehlt ein Fehler nie doppelt, egal ob
|
||||
* ihn der Worker direkt oder der Webhook zuerst meldet.
|
||||
* Ordnet eine Rechnung des Stripe-Zeitplans ihrer Folgerate zu. Stripe
|
||||
* erzeugt die Rechnungen nacheinander (die naechste erst im naechsten
|
||||
* Abrechnungszeitraum), deshalb gehoert eine neue Rechnung zur ersten noch
|
||||
* nicht verknuepften, unbezahlten Rate.
|
||||
*/
|
||||
export async function applyInstallmentFailed(
|
||||
async function installmentForInvoice(tx: Tx, booking: FullBooking, invoiceId: string): Promise<Installment | null> {
|
||||
const linked = booking.installments.find((i) => i.stripeInvoiceId === invoiceId);
|
||||
if (linked) return linked;
|
||||
const next = booking.installments.find((i) => i.seq > 1 && !i.stripeInvoiceId && i.status !== "PAID");
|
||||
if (!next) return null;
|
||||
return tx.installment.update({ where: { id: next.id }, data: { stripeInvoiceId: invoiceId } });
|
||||
}
|
||||
|
||||
/** Stripe Billing hat eine Folgerate eingezogen (automatisch oder von der Kundin ueber die Rechnungsseite bezahlt). */
|
||||
export async function applyInstallmentInvoicePaid(
|
||||
tx: Tx,
|
||||
installmentId: string,
|
||||
info: { paymentIntentId: string | null; failureCode: string | null; requiresAction: boolean },
|
||||
bookingId: string,
|
||||
invoiceId: string,
|
||||
info: PaymentInfo,
|
||||
ctx: EventContext,
|
||||
): Promise<InstallmentPaidOutcome | "unmatched"> {
|
||||
const booking = await loadLocked(tx, bookingId);
|
||||
const installment = await installmentForInvoice(tx, booking, invoiceId);
|
||||
if (!installment) {
|
||||
await flagForReview(tx, booking, `Zahlung über Rechnung ${invoiceId} passt zu keiner offenen Rate – bitte prüfen und ggf. erstatten.`, ctx);
|
||||
return "unmatched";
|
||||
}
|
||||
return applyInstallmentPaid(tx, installment.id, info, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe konnte eine Folgerate nicht einziehen. Stripe versucht es selbst
|
||||
* erneut (Smart Retries) und schickt bei verlangter Bestaetigung (3-D
|
||||
* Secure) einen Link. Hier wird nur der Stand festgehalten, die Kundin
|
||||
* informiert und — wenn Stripe aufgibt — ein Mensch eingeschaltet.
|
||||
*/
|
||||
export async function applyInstallmentInvoiceFailed(
|
||||
tx: Tx,
|
||||
bookingId: string,
|
||||
invoice: { id: string; attemptCount: number; nextPaymentAttempt: Date | null },
|
||||
info: { requiresAction: boolean },
|
||||
ctx: EventContext,
|
||||
): Promise<"recorded" | "ignored"> {
|
||||
const target = await tx.installment.findUniqueOrThrow({ where: { id: installmentId } });
|
||||
const booking = await loadLocked(tx, target.bookingId);
|
||||
const installment = booking.installments.find((i) => i.id === installmentId)!;
|
||||
if (installment.status !== "PROCESSING") return "ignored";
|
||||
const booking = await loadLocked(tx, bookingId);
|
||||
const installment = await installmentForInvoice(tx, booking, invoice.id);
|
||||
if (!installment || installment.status === "PAID" || installment.status === "CANCELED") return "ignored";
|
||||
|
||||
const status = info.requiresAction ? "REQUIRES_ACTION" : "FAILED";
|
||||
// Dasselbe Ergebnis desselben Versuchs (doppelt zugestellt oder als
|
||||
// payment_failed UND payment_action_required gemeldet) nur einmal zaehlen.
|
||||
if (installment.attemptCount >= invoice.attemptCount && installment.status === status) return "ignored";
|
||||
|
||||
const attempt = installment.attemptCount + 1;
|
||||
const retry = !info.requiresAction && attempt < MAX_OFF_SESSION_ATTEMPTS;
|
||||
const updatedInstallment = await tx.installment.update({
|
||||
where: { id: installmentId },
|
||||
where: { id: installment.id },
|
||||
data: {
|
||||
status: info.requiresAction ? "REQUIRES_ACTION" : "FAILED",
|
||||
attemptCount: attempt,
|
||||
failureCode: info.failureCode,
|
||||
stripePaymentIntentId: info.paymentIntentId ?? installment.stripePaymentIntentId,
|
||||
nextAttemptAt: retry ? new Date(ctx.now.getTime() + RETRY_DELAY_DAYS * 86_400_000) : null,
|
||||
status,
|
||||
attemptCount: Math.max(invoice.attemptCount, installment.attemptCount),
|
||||
lastAttemptAt: ctx.now,
|
||||
failureCode: info.requiresAction ? "authentication_required" : "payment_failed",
|
||||
nextAttemptAt: invoice.nextPaymentAttempt,
|
||||
},
|
||||
});
|
||||
await audit(tx, ctx.actor, "payment.installment_failed", "Installment", installmentId, {
|
||||
await audit(tx, ctx.actor, "payment.installment_failed", "Installment", installment.id, {
|
||||
bookingId: booking.id,
|
||||
seq: installment.seq,
|
||||
attempt,
|
||||
failureCode: info.failureCode,
|
||||
invoiceId: invoice.id,
|
||||
attempt: invoice.attemptCount,
|
||||
requiresAction: info.requiresAction,
|
||||
nextAttemptAt: updatedInstallment.nextAttemptAt?.toISOString() ?? null,
|
||||
nextAttemptAt: invoice.nextPaymentAttempt?.toISOString() ?? null,
|
||||
});
|
||||
await enqueueEmail(
|
||||
tx,
|
||||
installmentActionEmail(booking, updatedInstallment, statusTokenFor(booking.id, ctx.secret), info.requiresAction),
|
||||
);
|
||||
if (!retry && !info.requiresAction) {
|
||||
await flagForReview(tx, booking, `Rate ${installment.seq} ist nach ${attempt} Versuchen nicht eingegangen.`, ctx);
|
||||
if (!info.requiresAction && !invoice.nextPaymentAttempt) {
|
||||
await flagForReview(tx, booking, `Rate ${installment.seq} ist nach ${invoice.attemptCount} Versuchen nicht eingegangen.`, ctx);
|
||||
}
|
||||
return "recorded";
|
||||
}
|
||||
|
||||
+129
-46
@@ -7,12 +7,12 @@
|
||||
* Checkout erfasst sie, hier liegen nur Objekt-IDs.
|
||||
*
|
||||
* Architekturentscheidung Ratenzahlung (docs/ARCHITEKTUR.md, ADR-3):
|
||||
* Erste Rate ueber Stripe Checkout mit setup_future_usage=off_session,
|
||||
* Folgeraten als einzelne Off-Session-PaymentIntents, ausgeloest durch einen
|
||||
* idempotenten Kubernetes-CronJob. Kein Abonnement, kein Subscription
|
||||
* Schedule: deren Intervalle passen nicht zur Regel "letzte Rate 30 Tage vor
|
||||
* Anreise", und die Planlaenge ist hier durch die Ratenzeilen selbst
|
||||
* begrenzt.
|
||||
* Erste Rate ueber Stripe Checkout mit setup_future_usage=off_session.
|
||||
* Die Folgeraten zieht Stripe Billing ein: Nach der ersten Zahlung legt die
|
||||
* Anwendung einen Subscription Schedule an (Start = Faelligkeit der zweiten
|
||||
* Rate, Intervall = Abstand der Raten in Tagen, end_behavior=cancel). Stripe
|
||||
* uebernimmt Einzug, Wiederholungen (Smart Retries), Bestaetigungslinks bei
|
||||
* 3-D Secure und Mahnungen; die Anwendung verbucht nur die Rechnungs-Events.
|
||||
*/
|
||||
import Stripe from "stripe";
|
||||
import { env, stripeMode } from "./env";
|
||||
@@ -37,11 +37,32 @@ export type PaymentIntentInfo = {
|
||||
metadata: Record<string, string>;
|
||||
};
|
||||
|
||||
export type OffSessionResult = {
|
||||
outcome: "succeeded" | "processing" | "requires_action" | "failed";
|
||||
export type InvoiceInfo = {
|
||||
id: string;
|
||||
status: "draft" | "open" | "paid" | "uncollectible" | "void";
|
||||
amountPaidCents: number;
|
||||
paymentIntentId: string | null;
|
||||
chargeId: string | null;
|
||||
failureCode: string | null;
|
||||
hostedInvoiceUrl: string | null;
|
||||
attemptCount: number;
|
||||
/** null = Stripe versucht keinen weiteren automatischen Einzug. */
|
||||
nextPaymentAttempt: Date | null;
|
||||
/** Metadaten des Abonnements zum Rechnungszeitpunkt (bookingId usw.). */
|
||||
metadata: Record<string, string>;
|
||||
};
|
||||
|
||||
export type CreateInstallmentScheduleParams = {
|
||||
customerId: string;
|
||||
paymentMethodId: string;
|
||||
/** Faelligkeit der ersten Folgerate. */
|
||||
startAt: Date;
|
||||
/** Betrag je Folgerate (alle Folgeraten sind gleich hoch). */
|
||||
amountCents: number;
|
||||
/** Abstand zwischen den Folgeraten in Tagen. */
|
||||
intervalDays: number;
|
||||
/** Anzahl der Folgeraten. */
|
||||
count: number;
|
||||
description: string;
|
||||
metadata: Record<string, string>;
|
||||
};
|
||||
|
||||
export type CreateCheckoutParams = {
|
||||
@@ -65,10 +86,12 @@ export interface PaymentGateway {
|
||||
retrieveCheckoutSession(id: string): Promise<CheckoutSessionInfo>;
|
||||
expireCheckoutSession(id: string): Promise<void>;
|
||||
retrievePaymentIntent(id: string): Promise<PaymentIntentInfo>;
|
||||
chargeOffSession(
|
||||
params: { customerId: string; paymentMethodId: string; amountCents: number; description: string; metadata: Record<string, string> },
|
||||
idempotencyKey: string,
|
||||
): Promise<OffSessionResult>;
|
||||
createInstallmentSchedule(params: CreateInstallmentScheduleParams, idempotencyKey: string): Promise<{ id: string }>;
|
||||
/** Beendet den Zeitplan sofort und storniert offene Rechnungen: danach wird nichts mehr eingezogen. */
|
||||
cancelInstallmentSchedule(id: string): Promise<void>;
|
||||
retrieveInvoice(id: string): Promise<InvoiceInfo>;
|
||||
/** Alle Rechnungen, die der Zeitplan bisher erzeugt hat (fuer den Abgleich). */
|
||||
listScheduleInvoices(scheduleId: string): Promise<InvoiceInfo[]>;
|
||||
detachPaymentMethod(id: string): Promise<void>;
|
||||
createRefund(
|
||||
params: { paymentIntentId: string; amountCents: number; metadata: Record<string, string> },
|
||||
@@ -174,48 +197,85 @@ export class StripeGateway implements PaymentGateway {
|
||||
};
|
||||
}
|
||||
|
||||
async chargeOffSession(
|
||||
params: { customerId: string; paymentMethodId: string; amountCents: number; description: string; metadata: Record<string, string> },
|
||||
idempotencyKey: string,
|
||||
): Promise<OffSessionResult> {
|
||||
try {
|
||||
const pi = await this.stripe.paymentIntents.create(
|
||||
async createInstallmentSchedule(params: CreateInstallmentScheduleParams, idempotencyKey: string) {
|
||||
const productId = await this.installmentProductId();
|
||||
const start = Math.floor(params.startAt.getTime() / 1000);
|
||||
const schedule = await this.stripe.subscriptionSchedules.create(
|
||||
{
|
||||
amount: params.amountCents,
|
||||
currency: "eur",
|
||||
customer: params.customerId,
|
||||
payment_method: params.paymentMethodId,
|
||||
off_session: true,
|
||||
confirm: true,
|
||||
start_date: start,
|
||||
// Endet genau mit dem Abrechnungszeitraum der letzten Rate: Stripe
|
||||
// kuendigt dann ohne weitere Rechnung (mit Testuhr geprueft).
|
||||
end_behavior: "cancel",
|
||||
default_settings: {
|
||||
collection_method: "charge_automatically",
|
||||
default_payment_method: params.paymentMethodId,
|
||||
description: params.description,
|
||||
},
|
||||
phases: [
|
||||
{
|
||||
end_date: start + params.count * params.intervalDays * DAY_SECONDS,
|
||||
proration_behavior: "none",
|
||||
items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: "eur",
|
||||
product: productId,
|
||||
unit_amount: params.amountCents,
|
||||
recurring: { interval: "day", interval_count: params.intervalDays },
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: params.metadata,
|
||||
},
|
||||
],
|
||||
metadata: params.metadata,
|
||||
},
|
||||
{ idempotencyKey },
|
||||
);
|
||||
return {
|
||||
outcome:
|
||||
pi.status === "succeeded"
|
||||
? "succeeded"
|
||||
: pi.status === "processing"
|
||||
? "processing"
|
||||
: pi.status === "requires_action"
|
||||
? "requires_action"
|
||||
: "failed",
|
||||
paymentIntentId: pi.id,
|
||||
chargeId: idOf(pi.latest_charge),
|
||||
failureCode: pi.last_payment_error?.code ?? null,
|
||||
};
|
||||
return { id: schedule.id };
|
||||
}
|
||||
|
||||
/** Feste Produkt-ID: beim ersten Bedarf angelegt, danach wiederverwendet. */
|
||||
private async installmentProductId(): Promise<string> {
|
||||
try {
|
||||
return (await this.stripe.products.retrieve(INSTALLMENT_PRODUCT_ID)).id;
|
||||
} catch (error) {
|
||||
if (error instanceof Stripe.errors.StripeCardError) {
|
||||
return {
|
||||
outcome: error.code === "authentication_required" ? "requires_action" : "failed",
|
||||
paymentIntentId: error.payment_intent?.id ?? null,
|
||||
chargeId: null,
|
||||
failureCode: error.decline_code ?? error.code ?? "card_error",
|
||||
};
|
||||
if (!(error instanceof Stripe.errors.StripeInvalidRequestError)) throw error;
|
||||
const product = await this.stripe.products.create(
|
||||
{ id: INSTALLMENT_PRODUCT_ID, name: "ARTISTA MODELCAMP 2027, Folgerate" },
|
||||
{ idempotencyKey: `product:${INSTALLMENT_PRODUCT_ID}` },
|
||||
);
|
||||
return product.id;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
async cancelInstallmentSchedule(id: string): Promise<void> {
|
||||
const schedule = await this.stripe.subscriptionSchedules.retrieve(id);
|
||||
if (schedule.status === "not_started" || schedule.status === "active") {
|
||||
await this.stripe.subscriptionSchedules.cancel(id, { invoice_now: false, prorate: false });
|
||||
}
|
||||
const subscriptionId = idOf(schedule.subscription);
|
||||
if (!subscriptionId) return;
|
||||
// Offene Rechnungen wuerden sonst weiter automatisch eingezogen.
|
||||
for await (const invoice of this.stripe.invoices.list({ subscription: subscriptionId, status: "open", limit: 100 })) {
|
||||
await this.stripe.invoices.voidInvoice(invoice.id!);
|
||||
}
|
||||
}
|
||||
|
||||
async retrieveInvoice(id: string): Promise<InvoiceInfo> {
|
||||
return invoiceInfoFromStripe(await this.stripe.invoices.retrieve(id, { expand: ["payments"] }));
|
||||
}
|
||||
|
||||
async listScheduleInvoices(scheduleId: string): Promise<InvoiceInfo[]> {
|
||||
const schedule = await this.stripe.subscriptionSchedules.retrieve(scheduleId);
|
||||
const subscriptionId = idOf(schedule.subscription);
|
||||
if (!subscriptionId) return [];
|
||||
const invoices: InvoiceInfo[] = [];
|
||||
for await (const invoice of this.stripe.invoices.list({ subscription: subscriptionId, limit: 100, expand: ["data.payments"] })) {
|
||||
invoices.push(invoiceInfoFromStripe(invoice));
|
||||
}
|
||||
return invoices;
|
||||
}
|
||||
|
||||
async detachPaymentMethod(id: string): Promise<void> {
|
||||
@@ -239,6 +299,29 @@ export class StripeGateway implements PaymentGateway {
|
||||
}
|
||||
}
|
||||
|
||||
const DAY_SECONDS = 86_400;
|
||||
const INSTALLMENT_PRODUCT_ID = "artista_modelcamp_2027_folgerate";
|
||||
|
||||
/** Seit API 2025-03-31 haengt die Zahlung einer Rechnung an invoice.payments. */
|
||||
function paidPaymentIntentId(invoice: Stripe.Invoice): string | null {
|
||||
const payments = invoice.payments?.data ?? [];
|
||||
const paid = payments.find((p) => p.status === "paid") ?? payments[0];
|
||||
return idOf(paid?.payment.payment_intent);
|
||||
}
|
||||
|
||||
export function invoiceInfoFromStripe(invoice: Stripe.Invoice): InvoiceInfo {
|
||||
return {
|
||||
id: invoice.id!,
|
||||
status: (invoice.status ?? "draft") as InvoiceInfo["status"],
|
||||
amountPaidCents: invoice.amount_paid,
|
||||
paymentIntentId: paidPaymentIntentId(invoice),
|
||||
hostedInvoiceUrl: invoice.hosted_invoice_url ?? null,
|
||||
attemptCount: invoice.attempt_count,
|
||||
nextPaymentAttempt: invoice.next_payment_attempt ? new Date(invoice.next_payment_attempt * 1000) : null,
|
||||
metadata: (invoice.parent?.subscription_details?.metadata ?? {}) as Record<string, string>,
|
||||
};
|
||||
}
|
||||
|
||||
let gateway: PaymentGateway | null = null;
|
||||
|
||||
export function getGateway(): PaymentGateway {
|
||||
|
||||
+56
-81
@@ -13,6 +13,7 @@ import type Stripe from "stripe";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { STRIPE_ACTOR, type Actor } from "./audit";
|
||||
import type { Db } from "./db";
|
||||
import { ensureInstallmentSchedule, INSTALLMENT_PLAN_KIND, settleInstallmentInvoice } from "./installment-billing";
|
||||
import { normalizePaymentStatus, type CheckoutSessionInfo, type PaymentGateway } from "./payment-gateway";
|
||||
import {
|
||||
applyCheckoutExpired,
|
||||
@@ -20,8 +21,7 @@ import {
|
||||
applyFirstPaymentFailed,
|
||||
applyFirstPaymentProcessing,
|
||||
applyFirstPaymentSucceeded,
|
||||
applyInstallmentFailed,
|
||||
applyInstallmentPaid,
|
||||
applyInstallmentInvoiceFailed,
|
||||
applyRefundState,
|
||||
type EventContext,
|
||||
} from "./payment-events";
|
||||
@@ -61,16 +61,15 @@ export async function settleCheckoutSession(
|
||||
ctx: HandlerContext,
|
||||
actor: Actor = STRIPE_ACTOR,
|
||||
): Promise<string | null> {
|
||||
const { kind, bookingId, installmentId } = session.metadata;
|
||||
if (!bookingId || (kind !== "booking" && kind !== "installment")) return null;
|
||||
const { kind, bookingId } = session.metadata;
|
||||
if (!bookingId || kind !== "booking") return null;
|
||||
const booking = await db.booking.findUnique({ where: { id: bookingId }, select: { id: true, stripeCheckoutSessionId: true } });
|
||||
if (!booking) return null;
|
||||
// Eine Buchungs-Session muss zur gespeicherten Session passen (oder es ist
|
||||
// noch keine gespeichert, weil das Speichern nach Stripe scheiterte).
|
||||
if (kind === "booking" && booking.stripeCheckoutSessionId && booking.stripeCheckoutSessionId !== session.id) return null;
|
||||
// Die Session muss zur gespeicherten Session passen (oder es ist noch keine
|
||||
// gespeichert, weil das Speichern nach Stripe scheiterte).
|
||||
if (booking.stripeCheckoutSessionId && booking.stripeCheckoutSessionId !== session.id) return null;
|
||||
|
||||
const paid = session.paymentStatus === "paid";
|
||||
if (paid) {
|
||||
if (session.paymentStatus === "paid") {
|
||||
if (!session.paymentIntentId) throw new Error(`Bezahlte Session ${session.id} ohne PaymentIntent.`);
|
||||
const pi = await gateway.retrievePaymentIntent(session.paymentIntentId);
|
||||
const info = {
|
||||
@@ -80,29 +79,55 @@ export async function settleCheckoutSession(
|
||||
customerId: session.customerId,
|
||||
amountCents: pi.amountReceived,
|
||||
};
|
||||
if (kind === "booking") {
|
||||
await db.$transaction((tx) => applyFirstPaymentSucceeded(tx, bookingId, info, eventCtx(ctx, actor)));
|
||||
} else if (installmentId) {
|
||||
await db.$transaction((tx) => applyInstallmentPaid(tx, installmentId, info, eventCtx(ctx, actor)));
|
||||
}
|
||||
await startInstallmentSchedule(db, gateway, bookingId, ctx, actor);
|
||||
return bookingId;
|
||||
}
|
||||
|
||||
if (session.status === "complete") {
|
||||
if (kind === "booking") {
|
||||
await db.$transaction((tx) =>
|
||||
applyFirstPaymentProcessing(tx, bookingId, { paymentIntentId: session.paymentIntentId, customerId: session.customerId }, eventCtx(ctx, actor)),
|
||||
);
|
||||
} else if (installmentId) {
|
||||
await db.installment.updateMany({
|
||||
where: { id: installmentId, status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] } },
|
||||
data: { status: "PROCESSING", stripePaymentIntentId: session.paymentIntentId, lastAttemptAt: ctx.now },
|
||||
});
|
||||
}
|
||||
}
|
||||
return bookingId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ratenplan bei Stripe anlegen, sobald die erste Rate bezahlt ist. Ein
|
||||
* Fehler hier macht die verbuchte Zahlung nicht rueckgaengig: der Worker
|
||||
* holt das Anlegen beim naechsten Lauf nach.
|
||||
*/
|
||||
async function startInstallmentSchedule(db: Db, gateway: PaymentGateway, bookingId: string, ctx: HandlerContext, actor: Actor) {
|
||||
try {
|
||||
await ensureInstallmentSchedule(db, gateway, bookingId, ctx, actor);
|
||||
} catch (error) {
|
||||
console.error(`[webhook] Ratenplan fuer Buchung ${bookingId} nicht angelegt: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInstallmentInvoice(db: Db, gateway: PaymentGateway, event: Stripe.Event, ctx: HandlerContext): Promise<string[]> {
|
||||
const object = event.data.object as Stripe.Invoice;
|
||||
// Wahrheit bei Stripe nachladen statt dem Event-Inhalt zu vertrauen.
|
||||
const invoice = await gateway.retrieveInvoice(object.id!);
|
||||
const { kind, bookingId } = invoice.metadata;
|
||||
if (kind !== INSTALLMENT_PLAN_KIND || !bookingId) return [];
|
||||
if (event.type === "invoice.paid") {
|
||||
await settleInstallmentInvoice(db, gateway, invoice, ctx, STRIPE_ACTOR);
|
||||
return [bookingId];
|
||||
}
|
||||
if (invoice.status !== "open") return [];
|
||||
await db.$transaction((tx) =>
|
||||
applyInstallmentInvoiceFailed(
|
||||
tx,
|
||||
bookingId,
|
||||
invoice,
|
||||
{ requiresAction: event.type === "invoice.payment_action_required" },
|
||||
eventCtx(ctx),
|
||||
),
|
||||
);
|
||||
return [bookingId];
|
||||
}
|
||||
|
||||
async function dispatch(db: Db, gateway: PaymentGateway, event: Stripe.Event, ctx: HandlerContext): Promise<string[]> {
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed":
|
||||
@@ -112,73 +137,22 @@ async function dispatch(db: Db, gateway: PaymentGateway, event: Stripe.Event, ct
|
||||
}
|
||||
case "checkout.session.async_payment_failed": {
|
||||
const s = sessionInfoFromStripe(event.data.object);
|
||||
const { kind, bookingId, installmentId } = s.metadata;
|
||||
if (kind === "booking" && bookingId) {
|
||||
const { kind, bookingId } = s.metadata;
|
||||
if (kind !== "booking" || !bookingId) return [];
|
||||
await db.$transaction((tx) => applyFirstPaymentFailed(tx, bookingId, eventCtx(ctx)));
|
||||
return [bookingId];
|
||||
}
|
||||
if (kind === "installment" && installmentId) {
|
||||
await db.installment.updateMany({
|
||||
where: { id: installmentId, status: "PROCESSING" },
|
||||
data: { status: "FAILED", failureCode: "async_payment_failed", checkoutSessionId: null, checkoutExpiresAt: null },
|
||||
});
|
||||
return bookingId ? [bookingId] : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
case "checkout.session.expired": {
|
||||
const s = event.data.object;
|
||||
const { kind, bookingId, installmentId } = (s.metadata ?? {}) as Record<string, string>;
|
||||
if (kind === "booking" && bookingId) {
|
||||
const { kind, bookingId } = (s.metadata ?? {}) as Record<string, string>;
|
||||
if (kind !== "booking" || !bookingId) return [];
|
||||
await db.$transaction((tx) => applyCheckoutExpired(tx, bookingId, s.id, eventCtx(ctx)));
|
||||
return [bookingId];
|
||||
}
|
||||
if (kind === "installment" && installmentId) {
|
||||
await db.installment.updateMany({
|
||||
where: { id: installmentId, checkoutSessionId: s.id },
|
||||
data: { checkoutSessionId: null, checkoutExpiresAt: null },
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
case "payment_intent.succeeded": {
|
||||
const pi = event.data.object;
|
||||
if (pi.metadata?.kind !== "installment_offsession" || !pi.metadata.installmentId) return [];
|
||||
const outcome = await db.$transaction((tx) =>
|
||||
applyInstallmentPaid(
|
||||
tx,
|
||||
pi.metadata.installmentId!,
|
||||
{
|
||||
paymentIntentId: pi.id,
|
||||
chargeId: idOf(pi.latest_charge),
|
||||
paymentMethodId: idOf(pi.payment_method),
|
||||
customerId: idOf(pi.customer),
|
||||
amountCents: pi.amount_received,
|
||||
},
|
||||
eventCtx(ctx),
|
||||
),
|
||||
);
|
||||
void outcome;
|
||||
return pi.metadata.bookingId ? [pi.metadata.bookingId] : [];
|
||||
}
|
||||
case "payment_intent.payment_failed": {
|
||||
const pi = event.data.object;
|
||||
if (pi.metadata?.kind !== "installment_offsession" || !pi.metadata.installmentId) return [];
|
||||
const code = pi.last_payment_error?.code ?? null;
|
||||
await db.$transaction((tx) =>
|
||||
applyInstallmentFailed(
|
||||
tx,
|
||||
pi.metadata.installmentId!,
|
||||
{
|
||||
paymentIntentId: pi.id,
|
||||
failureCode: pi.last_payment_error?.decline_code ?? code,
|
||||
requiresAction: code === "authentication_required" || pi.status === "requires_action",
|
||||
},
|
||||
eventCtx(ctx),
|
||||
),
|
||||
);
|
||||
return pi.metadata.bookingId ? [pi.metadata.bookingId] : [];
|
||||
}
|
||||
case "invoice.paid":
|
||||
case "invoice.payment_failed":
|
||||
case "invoice.payment_action_required":
|
||||
return handleInstallmentInvoice(db, gateway, event, ctx);
|
||||
case "charge.refunded": {
|
||||
const charge = event.data.object;
|
||||
const piId = idOf(charge.payment_intent);
|
||||
@@ -209,8 +183,9 @@ const HANDLED = new Set<string>([
|
||||
"checkout.session.async_payment_succeeded",
|
||||
"checkout.session.async_payment_failed",
|
||||
"checkout.session.expired",
|
||||
"payment_intent.succeeded",
|
||||
"payment_intent.payment_failed",
|
||||
"invoice.paid",
|
||||
"invoice.payment_failed",
|
||||
"invoice.payment_action_required",
|
||||
"charge.refunded",
|
||||
"refund.created",
|
||||
"refund.updated",
|
||||
|
||||
+64
-171
@@ -1,25 +1,23 @@
|
||||
/**
|
||||
* Hintergrundaufgaben, ausgefuehrt vom Kubernetes-CronJob (scripts/worker.ts).
|
||||
*
|
||||
* Kein In-Process-Timer: Faelligkeiten stehen in der Datenbank, jeder Lauf
|
||||
* arbeitet den aktuellen Stand ab. Mehrere gleichzeitige Laeufe (z. B. ein
|
||||
* manueller Start waehrend des CronJobs) sind unschaedlich, weil jede Rate
|
||||
* vor dem Einzug atomar beansprucht wird und Stripe-Aufrufe
|
||||
* Idempotenzschluessel tragen.
|
||||
* Kein In-Process-Timer: jeder Lauf arbeitet den aktuellen Stand ab. Die
|
||||
* Folgeraten zieht Stripe Billing ein; der Worker legt nur fehlende
|
||||
* Ratenplaene an und gleicht verlorene Webhooks ab. Mehrere gleichzeitige
|
||||
* Laeufe sind unschaedlich, weil Stripe-Aufrufe Idempotenzschluessel tragen
|
||||
* und jede Verbuchung idempotent ist.
|
||||
*/
|
||||
import { audit, WORKER } from "./audit";
|
||||
import type { Db } from "./db";
|
||||
import { flushEmails } from "./email";
|
||||
import { MAX_OFF_SESSION_ATTEMPTS, applyCheckoutExpired, applyFirstPaymentFailed, applyInstallmentFailed, applyInstallmentPaid, type EventContext } from "./payment-events";
|
||||
import { ensureInstallmentSchedule, settleInstallmentInvoice, stopCanceledInstallmentPlans } from "./installment-billing";
|
||||
import { applyCheckoutExpired, applyFirstPaymentFailed, type EventContext } from "./payment-events";
|
||||
import type { PaymentGateway } from "./payment-gateway";
|
||||
import { purgeRateLimits } from "./rate-limit";
|
||||
import { settleCheckoutSession, type HandlerContext } from "./webhook";
|
||||
|
||||
/** Stripe-Idempotenzschluessel gelten 24 Stunden; ein haengender Einzug
|
||||
* wird nur innerhalb dieser Frist automatisch mit demselben Schluessel
|
||||
* wiederholt, danach entscheidet ein Mensch. */
|
||||
const SAFE_RETRY_WINDOW_MS = 20 * 60 * 60_000;
|
||||
const STUCK_AFTER_MS = 2 * 60 * 60_000;
|
||||
/** Stripe stellt Rechnungen erst rund eine Stunde nach Faelligkeit fertig. */
|
||||
const RECONCILE_AFTER_MS = 6 * 60 * 60_000;
|
||||
|
||||
function eventCtx(ctx: HandlerContext): EventContext {
|
||||
return { ...ctx, actor: WORKER };
|
||||
@@ -61,181 +59,71 @@ export async function releaseExpiredReservations(db: Db, gateway: PaymentGateway
|
||||
return { released, settled };
|
||||
}
|
||||
|
||||
/** Zieht faellige Folgeraten off-session ein. */
|
||||
export async function chargeDueInstallments(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
|
||||
const livemode = gateway.mode === "live";
|
||||
const due = await db.installment.findMany({
|
||||
/**
|
||||
* Legt den Stripe-Ratenplan fuer bestaetigte Ratenbuchungen an, falls das
|
||||
* direkt nach der ersten Zahlung scheiterte oder die Buchung erst spaeter
|
||||
* bestaetigt wurde (Pruefung im Adminbereich).
|
||||
*/
|
||||
export async function createMissingSchedules(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
|
||||
const bookings = await db.booking.findMany({
|
||||
where: {
|
||||
seq: { gt: 1 },
|
||||
booking: { status: "CONFIRMED", livemode },
|
||||
OR: [
|
||||
{ status: "PENDING", dueAt: { lte: ctx.now } },
|
||||
{ status: "FAILED", nextAttemptAt: { lte: ctx.now }, attemptCount: { lt: MAX_OFF_SESSION_ATTEMPTS } },
|
||||
],
|
||||
AND: [{ OR: [{ checkoutExpiresAt: null }, { checkoutExpiresAt: { lt: ctx.now } }] }],
|
||||
status: "CONFIRMED",
|
||||
planType: { not: "FULL" },
|
||||
stripeScheduleId: null,
|
||||
livemode: gateway.mode === "live",
|
||||
installments: { some: { seq: { gt: 1 }, status: "PENDING" } },
|
||||
},
|
||||
orderBy: { dueAt: "asc" },
|
||||
select: { id: true },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
const summary = { charged: 0, failed: 0, requiresAction: 0, processing: 0, skipped: 0 };
|
||||
for (const installment of due) {
|
||||
// Atomar beanspruchen: nur ein Lauf darf diese Rate einziehen.
|
||||
const claimed = await db.installment.updateMany({
|
||||
where: {
|
||||
id: installment.id,
|
||||
status: installment.status,
|
||||
OR: [{ checkoutExpiresAt: null }, { checkoutExpiresAt: { lt: ctx.now } }],
|
||||
},
|
||||
data: { status: "PROCESSING", lastAttemptAt: ctx.now },
|
||||
});
|
||||
if (claimed.count === 0) {
|
||||
summary.skipped++;
|
||||
continue;
|
||||
}
|
||||
const booking = await db.booking.findUniqueOrThrow({ where: { id: installment.bookingId } });
|
||||
|
||||
// Schutz gegen jede Abbuchung ueber den vereinbarten Gesamtpreis hinaus,
|
||||
// insbesondere nach der letzten Rate.
|
||||
if (booking.paidCents + installment.amountCents > booking.totalCents) {
|
||||
await db.installment.update({ where: { id: installment.id }, data: { status: "CANCELED", failureCode: "plan_already_paid" } });
|
||||
await audit(db, WORKER, "installment.skipped_overpayment_guard", "Installment", installment.id, {
|
||||
paidCents: booking.paidCents,
|
||||
totalCents: booking.totalCents,
|
||||
});
|
||||
summary.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!booking.stripeCustomerId || !booking.stripePaymentMethodId || booking.paymentMethodDetachedAt) {
|
||||
await db.$transaction((tx) =>
|
||||
applyInstallmentFailed(tx, installment.id, { paymentIntentId: null, failureCode: "no_saved_payment_method", requiresAction: true }, eventCtx(ctx)),
|
||||
);
|
||||
summary.requiresAction++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let result;
|
||||
const summary = { created: 0, needsReview: 0, failed: 0 };
|
||||
for (const booking of bookings) {
|
||||
try {
|
||||
result = await gateway.chargeOffSession(
|
||||
{
|
||||
customerId: booking.stripeCustomerId,
|
||||
paymentMethodId: booking.stripePaymentMethodId,
|
||||
amountCents: installment.amountCents,
|
||||
description: `ARTISTA MODELCAMP 2027 · ${booking.reference} · Rate ${installment.seq}`,
|
||||
metadata: {
|
||||
kind: "installment_offsession",
|
||||
bookingId: booking.id,
|
||||
installmentId: installment.id,
|
||||
reference: booking.reference,
|
||||
},
|
||||
},
|
||||
// Gleicher Schluessel bei Wiederholung nach Netzwerkfehler: Stripe
|
||||
// liefert dann das erste Ergebnis statt ein zweites Mal abzubuchen.
|
||||
`offsession:${installment.id}:${installment.attemptCount}`,
|
||||
);
|
||||
const outcome = await ensureInstallmentSchedule(db, gateway, booking.id, ctx, WORKER);
|
||||
if (outcome === "created") summary.created++;
|
||||
if (outcome === "needs_review") summary.needsReview++;
|
||||
} catch (error) {
|
||||
console.error(`[worker] Einzug Rate ${installment.id} unterbrochen: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||
await db.installment.updateMany({
|
||||
where: { id: installment.id, status: "PROCESSING" },
|
||||
data: { status: installment.status, nextAttemptAt: new Date(ctx.now.getTime() + 60 * 60_000) },
|
||||
});
|
||||
console.error(`[worker] Ratenplan fuer Buchung ${booking.id} nicht angelegt: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||
summary.failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.outcome === "succeeded" && result.paymentIntentId) {
|
||||
await db.$transaction((tx) =>
|
||||
applyInstallmentPaid(
|
||||
tx,
|
||||
installment.id,
|
||||
{
|
||||
paymentIntentId: result.paymentIntentId!,
|
||||
chargeId: result.chargeId,
|
||||
paymentMethodId: booking.stripePaymentMethodId,
|
||||
customerId: booking.stripeCustomerId,
|
||||
amountCents: installment.amountCents,
|
||||
},
|
||||
eventCtx(ctx),
|
||||
),
|
||||
);
|
||||
summary.charged++;
|
||||
} else if (result.outcome === "processing") {
|
||||
await db.installment.update({ where: { id: installment.id }, data: { stripePaymentIntentId: result.paymentIntentId } });
|
||||
summary.processing++;
|
||||
} else {
|
||||
await db.$transaction((tx) =>
|
||||
applyInstallmentFailed(
|
||||
tx,
|
||||
installment.id,
|
||||
{
|
||||
paymentIntentId: result.paymentIntentId,
|
||||
failureCode: result.failureCode,
|
||||
requiresAction: result.outcome === "requires_action",
|
||||
},
|
||||
eventCtx(ctx),
|
||||
),
|
||||
);
|
||||
if (result.outcome === "requires_action") summary.requiresAction++;
|
||||
else summary.failed++;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** Faengt haengende Einzuege und verlorene Webhooks auf. */
|
||||
export async function reconcileStuck(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
|
||||
const stuck = await db.installment.findMany({
|
||||
where: { status: "PROCESSING", lastAttemptAt: { lt: new Date(ctx.now.getTime() - STUCK_AFTER_MS) }, seq: { gt: 1 } },
|
||||
include: { booking: { select: { livemode: true } } },
|
||||
/**
|
||||
* Faengt verlorene Rechnungs-Webhooks auf: Ist eine Folgerate seit
|
||||
* RECONCILE_AFTER_MS faellig und nicht verbucht, fragt der Worker Stripe
|
||||
* nach den Rechnungen des Zeitplans und verbucht bezahlte.
|
||||
*/
|
||||
export async function reconcileInstallmentInvoices(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
|
||||
const bookings = await db.booking.findMany({
|
||||
where: {
|
||||
stripeScheduleId: { not: null },
|
||||
livemode: gateway.mode === "live",
|
||||
installments: {
|
||||
some: {
|
||||
seq: { gt: 1 },
|
||||
status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] },
|
||||
dueAt: { lt: new Date(ctx.now.getTime() - RECONCILE_AFTER_MS) },
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true, stripeScheduleId: true },
|
||||
take: 50,
|
||||
});
|
||||
let resolved = 0;
|
||||
for (const installment of stuck) {
|
||||
if (installment.booking.livemode !== (gateway.mode === "live")) continue;
|
||||
if (installment.stripePaymentIntentId) {
|
||||
const pi = await gateway.retrievePaymentIntent(installment.stripePaymentIntentId);
|
||||
if (pi.status === "succeeded") {
|
||||
await db.$transaction((tx) =>
|
||||
applyInstallmentPaid(
|
||||
tx,
|
||||
installment.id,
|
||||
{ paymentIntentId: pi.id, chargeId: pi.chargeId, paymentMethodId: pi.paymentMethodId, customerId: null, amountCents: pi.amountReceived },
|
||||
eventCtx(ctx),
|
||||
),
|
||||
);
|
||||
resolved++;
|
||||
} else if (pi.status === "requires_payment_method" || pi.status === "canceled" || pi.status === "requires_action") {
|
||||
await db.$transaction((tx) =>
|
||||
applyInstallmentFailed(
|
||||
tx,
|
||||
installment.id,
|
||||
{ paymentIntentId: pi.id, failureCode: `stuck_${pi.status}`, requiresAction: pi.status === "requires_action" },
|
||||
eventCtx(ctx),
|
||||
),
|
||||
);
|
||||
resolved++;
|
||||
let settled = 0;
|
||||
for (const booking of bookings) {
|
||||
const invoices = await gateway.listScheduleInvoices(booking.stripeScheduleId!);
|
||||
for (const invoice of invoices.filter((i) => i.status === "paid")) {
|
||||
if (await settleInstallmentInvoice(db, gateway, invoice, ctx, WORKER)) settled++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Kein Ergebnis gespeichert (z. B. Absturz waehrend des Aufrufs).
|
||||
if (installment.lastAttemptAt && ctx.now.getTime() - installment.lastAttemptAt.getTime() < SAFE_RETRY_WINDOW_MS) {
|
||||
// attemptCount bleibt gleich -> naechster Lauf nutzt denselben
|
||||
// Idempotenzschluessel und kann nicht doppelt abbuchen.
|
||||
await db.installment.updateMany({
|
||||
where: { id: installment.id, status: "PROCESSING" },
|
||||
data: { status: "FAILED", nextAttemptAt: ctx.now, failureCode: "worker_interrupted" },
|
||||
});
|
||||
} else {
|
||||
await db.installment.updateMany({
|
||||
where: { id: installment.id, status: "PROCESSING" },
|
||||
data: { status: "REQUIRES_ACTION", failureCode: "manual_check_required" },
|
||||
});
|
||||
await audit(db, WORKER, "installment.manual_check_required", "Installment", installment.id);
|
||||
}
|
||||
resolved++;
|
||||
}
|
||||
return { checked: bookings.length, settled };
|
||||
}
|
||||
|
||||
/** Faengt verzoegerte erste Zahlungen (SEPA) auf, deren Ergebnis-Webhook ausblieb. */
|
||||
export async function reconcileStuck(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
|
||||
let resolved = 0;
|
||||
// Verzoegerte erste Zahlungen (SEPA), deren Ergebnis-Webhook ausblieb.
|
||||
const processing = await db.booking.findMany({
|
||||
where: {
|
||||
@@ -276,6 +164,9 @@ export async function detachFinishedPaymentMethods(db: Db, gateway: PaymentGatew
|
||||
livemode: gateway.mode === "live",
|
||||
...(bookingIds ? { id: { in: bookingIds } } : {}),
|
||||
installments: { none: { status: { in: ["PENDING", "PROCESSING", "FAILED", "REQUIRES_ACTION"] } } },
|
||||
// Stornierte Buchungen mit Stripe-Ratenplan: erst den Plan beenden
|
||||
// (stopCanceledInstallmentPlans), sonst zoege Stripe weiter ein.
|
||||
NOT: { status: "CANCELED", stripeScheduleId: { not: null } },
|
||||
},
|
||||
take: 50,
|
||||
});
|
||||
@@ -290,9 +181,11 @@ export async function detachFinishedPaymentMethods(db: Db, gateway: PaymentGatew
|
||||
export async function runWorker(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
|
||||
const reservations = await releaseExpiredReservations(db, gateway, ctx);
|
||||
const reconcile = await reconcileStuck(db, gateway, ctx);
|
||||
const installments = await chargeDueInstallments(db, gateway, ctx);
|
||||
const schedules = await createMissingSchedules(db, gateway, ctx);
|
||||
const installments = await reconcileInstallmentInvoices(db, gateway, ctx);
|
||||
const stopped = await stopCanceledInstallmentPlans(db, gateway);
|
||||
const detach = await detachFinishedPaymentMethods(db, gateway);
|
||||
const emails = await flushEmails(db, { limit: 200 });
|
||||
const purged = await purgeRateLimits(db);
|
||||
return { reservations, reconcile, installments, detach, emails, purgedRateLimits: purged };
|
||||
return { reservations, reconcile, schedules, installments, stopped, detach, emails, purgedRateLimits: purged };
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { cancelBooking, refundInstallment, saveVoucher, voucherInputSchema } from "@/lib/admin-service";
|
||||
import { startCheckout } from "@/lib/checkout";
|
||||
import { handleStripeEvent } from "@/lib/webhook";
|
||||
import { chargeDueInstallments } from "@/lib/worker-jobs";
|
||||
import { stopCanceledInstallmentPlans } from "@/lib/installment-billing";
|
||||
import { detachFinishedPaymentMethods } from "@/lib/worker-jobs";
|
||||
import { bookingRequest, db, EARLY_BIRD_NOW, resetDb, SECRET, setupCamp } from "./support/db";
|
||||
import { FakeGateway } from "./support/fake-gateway";
|
||||
|
||||
@@ -33,8 +34,10 @@ async function paidInstallmentBooking() {
|
||||
}
|
||||
|
||||
describe("Stornierung", () => {
|
||||
test("stoppt alle weiteren Abbuchungen, gibt Platz frei, trennt die Zahlungsmethode", async () => {
|
||||
test("beendet den Stripe-Ratenplan, storniert offene Rechnungen, gibt Platz frei, trennt die Zahlungsmethode", async () => {
|
||||
const booking = await paidInstallmentBooking();
|
||||
const schedule = gateway.schedules.get(booking.stripeScheduleId!)!;
|
||||
const open = gateway.billNextInstallment(schedule.id, "failed");
|
||||
const result = await cancelBooking(db, gateway, { bookingId: booking.id, reason: "Test" }, admin);
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
@@ -42,14 +45,34 @@ describe("Stornierung", () => {
|
||||
expect(after.status).toBe("CANCELED");
|
||||
expect(after.reservation!.status).toBe("RELEASED");
|
||||
expect(after.installments.filter((i) => i.seq > 1).every((i) => i.status === "CANCELED")).toBe(true);
|
||||
expect(schedule.status).toBe("canceled");
|
||||
expect((await gateway.retrieveInvoice(open.id)).status).toBe("void");
|
||||
expect(() => gateway.billNextInstallment(schedule.id)).toThrow();
|
||||
expect(gateway.detached).toEqual([booking.stripePaymentMethodId]);
|
||||
|
||||
const later = { ...ctx, now: new Date("2027-06-01T12:00:00Z") };
|
||||
await chargeDueInstallments(db, gateway, later);
|
||||
expect(gateway.offSessionCharges).toHaveLength(0);
|
||||
expect(await db.auditLog.count({ where: { action: "booking.canceled", actorId: "admin-test" } })).toBe(1);
|
||||
});
|
||||
|
||||
test("Stripe nicht erreichbar: Storno bleibt, der Worker beendet den Ratenplan spaeter", async () => {
|
||||
const booking = await paidInstallmentBooking();
|
||||
const schedule = gateway.schedules.get(booking.stripeScheduleId!)!;
|
||||
const cancel = gateway.cancelInstallmentSchedule.bind(gateway);
|
||||
gateway.cancelInstallmentSchedule = async () => {
|
||||
throw new Error("Stripe nicht erreichbar (simuliert)");
|
||||
};
|
||||
const result = await cancelBooking(db, gateway, { bookingId: booking.id, reason: "Test" }, admin);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(schedule.status).toBe("active");
|
||||
// Der allgemeine Lauf trennt die Zahlungsmethode nicht, solange der Plan laeuft.
|
||||
await detachFinishedPaymentMethods(db, gateway);
|
||||
expect(gateway.detached).toEqual([]);
|
||||
|
||||
gateway.cancelInstallmentSchedule = cancel;
|
||||
expect((await stopCanceledInstallmentPlans(db, gateway)).stopped).toBe(1);
|
||||
expect(schedule.status).toBe("canceled");
|
||||
expect(gateway.detached).toEqual([booking.stripePaymentMethodId]);
|
||||
expect((await stopCanceledInstallmentPlans(db, gateway)).stopped).toBe(0);
|
||||
});
|
||||
|
||||
test("verlangt einen Grund", async () => {
|
||||
const booking = await paidInstallmentBooking();
|
||||
expect((await cancelBooking(db, gateway, { bookingId: booking.id, reason: " " }, admin)).ok).toBe(false);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import Stripe from "stripe";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { startCheckout, startInstallmentCheckout } from "@/lib/checkout";
|
||||
import { applyRefundState, MAX_OFF_SESSION_ATTEMPTS } from "@/lib/payment-events";
|
||||
import { installmentPaymentUrl, startCheckout } from "@/lib/checkout";
|
||||
import { applyRefundState } from "@/lib/payment-events";
|
||||
import { verifyStripeWebhook } from "@/lib/payment-gateway";
|
||||
import { handleStripeEvent, settleCheckoutSession } from "@/lib/webhook";
|
||||
import { chargeDueInstallments, detachFinishedPaymentMethods, releaseExpiredReservations } from "@/lib/worker-jobs";
|
||||
import { createMissingSchedules, detachFinishedPaymentMethods, reconcileInstallmentInvoices, releaseExpiredReservations } from "@/lib/worker-jobs";
|
||||
import { STRIPE_ACTOR } from "@/lib/audit";
|
||||
import type { PlanType } from "@/lib/payment-plan";
|
||||
import { bookingRequest, db, EARLY_BIRD_NOW, resetDb, SECRET, setupCamp } from "./support/db";
|
||||
@@ -212,113 +212,138 @@ describe("Verzoegerte und verspaetete Zahlung", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Folgeraten", () => {
|
||||
const afterLastDue = (booking: { installments: { dueAt: Date | null }[] }) => ({
|
||||
...ctx,
|
||||
now: new Date(Math.max(...booking.installments.map((i) => i.dueAt?.getTime() ?? 0)) + 60_000),
|
||||
describe("Folgeraten ueber Stripe Billing", () => {
|
||||
function invoiceEvent(type: string, invoice: { id: string }, id = `evt_${crypto.randomUUID()}`) {
|
||||
return { id, type, livemode: false, data: { object: { id: invoice.id, object: "invoice" } } } as unknown as Stripe.Event;
|
||||
}
|
||||
|
||||
async function scheduleOf(bookingId: string) {
|
||||
const booking = await db.booking.findUniqueOrThrow({ where: { id: bookingId } });
|
||||
return gateway.schedules.get(booking.stripeScheduleId!)!;
|
||||
}
|
||||
|
||||
async function installment(bookingId: string, seq: number) {
|
||||
return db.installment.findFirstOrThrow({ where: { bookingId, seq } });
|
||||
}
|
||||
|
||||
test("erste Rate bezahlt: Stripe-Ratenplan mit Betrag, Start, Abstand und Zahlungsmethode", async () => {
|
||||
const booking = await paidBooking("THREE_INSTALLMENTS");
|
||||
const [, second, third] = booking.installments;
|
||||
const schedule = await scheduleOf(booking.id);
|
||||
expect(schedule.params).toMatchObject({
|
||||
amountCents: 70_000,
|
||||
count: 2,
|
||||
startAt: second!.dueAt,
|
||||
intervalDays: Math.round((third!.dueAt!.getTime() - second!.dueAt!.getTime()) / 86_400_000),
|
||||
paymentMethodId: booking.stripePaymentMethodId,
|
||||
customerId: booking.stripeCustomerId,
|
||||
});
|
||||
expect(schedule.params.metadata).toMatchObject({ kind: "installment_plan", bookingId: booking.id });
|
||||
|
||||
// Erneut zugestelltes Checkout-Event legt keinen zweiten Plan an.
|
||||
const session = await gateway.retrieveCheckoutSession(booking.stripeCheckoutSessionId!);
|
||||
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.async_payment_succeeded", session), ctx);
|
||||
expect(gateway.schedules.size).toBe(1);
|
||||
});
|
||||
|
||||
test("Einzug aller Raten, danach keine weitere Abbuchung und Zahlungsmethode getrennt", async () => {
|
||||
test("Einmalzahlung legt keinen Ratenplan an", async () => {
|
||||
await paidBooking("FULL");
|
||||
expect(gateway.schedules.size).toBe(0);
|
||||
});
|
||||
|
||||
test("Stripe zieht alle Raten ein; danach keine weitere Rechnung, Zahlungsmethode getrennt", async () => {
|
||||
const booking = await paidBooking("THREE_INSTALLMENTS");
|
||||
const runCtx = afterLastDue(booking);
|
||||
const summary = await chargeDueInstallments(db, gateway, runCtx);
|
||||
expect(summary.charged).toBe(2);
|
||||
let after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
const schedule = await scheduleOf(booking.id);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const invoice = gateway.billNextInstallment(schedule.id);
|
||||
await handleStripeEvent(db, gateway, invoiceEvent("invoice.paid", invoice), ctx);
|
||||
}
|
||||
let after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: { orderBy: { seq: "asc" } } } });
|
||||
expect(after.paymentStatus).toBe("PAID");
|
||||
expect(after.paidCents).toBe(220_000);
|
||||
expect(after.installments.map((i) => i.status)).toEqual(["PAID", "PAID", "PAID"]);
|
||||
expect(gateway.billedCents).toBe(140_000);
|
||||
expect(() => gateway.billNextInstallment(schedule.id)).toThrow();
|
||||
|
||||
await detachFinishedPaymentMethods(db, gateway);
|
||||
after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: true } });
|
||||
expect(after.paymentMethodDetachedAt).not.toBeNull();
|
||||
expect(gateway.detached).toEqual([booking.stripePaymentMethodId]);
|
||||
|
||||
// Weitere Laeufe buchen nichts mehr ab.
|
||||
await chargeDueInstallments(db, gateway, runCtx);
|
||||
await chargeDueInstallments(db, gateway, { ...runCtx, now: new Date(runCtx.now.getTime() + 30 * 86_400_000) });
|
||||
expect(gateway.offSessionCharges).toHaveLength(2);
|
||||
expect(gateway.offSessionCharges.reduce((s, c) => s + c.amountCents, 0)).toBe(140_000);
|
||||
});
|
||||
|
||||
test("nur faellige Raten werden eingezogen", async () => {
|
||||
const booking = await paidBooking("THREE_INSTALLMENTS");
|
||||
const between = { ...ctx, now: new Date(booking.installments[1]!.dueAt!.getTime() + 60_000) };
|
||||
expect((await chargeDueInstallments(db, gateway, between)).charged).toBe(1);
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.paymentStatus).toBe("PARTIALLY_PAID");
|
||||
expect(after.paidCents).toBe(150_000);
|
||||
});
|
||||
|
||||
test("Fehlschlag: Wiederholung nach Wartezeit, nach dem letzten Versuch manuelle Pruefung", async () => {
|
||||
test("doppelt zugestelltes invoice.paid wird nur einmal verbucht", async () => {
|
||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||
gateway.offSessionOutcomes = ["failed", "failed", "failed"];
|
||||
let runCtx = afterLastDue(booking);
|
||||
for (let attempt = 1; attempt <= MAX_OFF_SESSION_ATTEMPTS; attempt++) {
|
||||
await chargeDueInstallments(db, gateway, runCtx);
|
||||
const inst = await db.installment.findFirstOrThrow({ where: { bookingId: booking.id, seq: 2 } });
|
||||
expect(inst.status).toBe("FAILED");
|
||||
expect(inst.attemptCount).toBe(attempt);
|
||||
// Vor Ablauf der Wartezeit kein erneuter Versuch.
|
||||
await chargeDueInstallments(db, gateway, runCtx);
|
||||
expect((await db.installment.findFirstOrThrow({ where: { id: inst.id } })).attemptCount).toBe(attempt);
|
||||
runCtx = { ...runCtx, now: new Date(runCtx.now.getTime() + 4 * 86_400_000) };
|
||||
}
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.status).toBe("NEEDS_REVIEW");
|
||||
expect(after.paymentStatus).toBe("PARTIALLY_PAID");
|
||||
expect(gateway.offSessionCharges).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("Bank verlangt Authentifizierung: Kundin bekommt Link und zahlt selbst", async () => {
|
||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||
gateway.offSessionOutcomes = ["requires_action"];
|
||||
const runCtx = afterLastDue(booking);
|
||||
await chargeDueInstallments(db, gateway, runCtx);
|
||||
const inst = await db.installment.findFirstOrThrow({ where: { bookingId: booking.id, seq: 2 } });
|
||||
expect(inst.status).toBe("REQUIRES_ACTION");
|
||||
expect(await db.emailMessage.count({ where: { dedupeKey: { startsWith: `installment-action:${inst.id}` } } })).toBe(1);
|
||||
|
||||
const pay = await startInstallmentCheckout(db, gateway, { bookingId: booking.id, installmentId: inst.id }, runCtx);
|
||||
expect(pay.ok).toBe(true);
|
||||
// Waehrend die Session offen ist, zieht der Worker nicht parallel ein.
|
||||
await chargeDueInstallments(db, gateway, { ...runCtx, now: new Date(runCtx.now.getTime() + 60_000) });
|
||||
expect(gateway.offSessionCharges).toHaveLength(0);
|
||||
|
||||
const sessionId = (await db.installment.findUniqueOrThrow({ where: { id: inst.id } })).checkoutSessionId!;
|
||||
const session = gateway.completeSession(sessionId);
|
||||
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.completed", session), runCtx);
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.paymentStatus).toBe("PAID");
|
||||
expect(after.paidCents).toBe(220_000);
|
||||
});
|
||||
|
||||
test("Webhook und Worker melden denselben Erfolg: nur einmal verbucht", async () => {
|
||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||
const runCtx = afterLastDue(booking);
|
||||
await chargeDueInstallments(db, gateway, runCtx);
|
||||
const inst = await db.installment.findFirstOrThrow({ where: { bookingId: booking.id, seq: 2 } });
|
||||
const pi = await gateway.retrievePaymentIntent(inst.stripePaymentIntentId!);
|
||||
const event = {
|
||||
id: "evt_pi_ok",
|
||||
type: "payment_intent.succeeded",
|
||||
livemode: false,
|
||||
data: {
|
||||
object: {
|
||||
id: pi.id,
|
||||
object: "payment_intent",
|
||||
amount_received: pi.amountReceived,
|
||||
latest_charge: pi.chargeId,
|
||||
payment_method: pi.paymentMethodId,
|
||||
customer: null,
|
||||
metadata: pi.metadata,
|
||||
},
|
||||
},
|
||||
} as unknown as Stripe.Event;
|
||||
await handleStripeEvent(db, gateway, event, runCtx);
|
||||
const invoice = gateway.billNextInstallment((await scheduleOf(booking.id)).id);
|
||||
await handleStripeEvent(db, gateway, invoiceEvent("invoice.paid", invoice), ctx);
|
||||
await handleStripeEvent(db, gateway, invoiceEvent("invoice.paid", invoice), ctx);
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.paidCents).toBe(220_000);
|
||||
expect(after.status).toBe("CONFIRMED");
|
||||
});
|
||||
|
||||
test("verlorener Rechnungs-Webhook: Worker gleicht mit Stripe ab", async () => {
|
||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||
gateway.billNextInstallment((await scheduleOf(booking.id)).id);
|
||||
const second = await installment(booking.id, 2);
|
||||
// Vor Faelligkeit wird nicht gefragt.
|
||||
expect((await reconcileInstallmentInvoices(db, gateway, ctx)).checked).toBe(0);
|
||||
const later = { ...ctx, now: new Date(second.dueAt!.getTime() + 7 * 60 * 60_000) };
|
||||
expect((await reconcileInstallmentInvoices(db, gateway, later)).settled).toBe(1);
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.paymentStatus).toBe("PAID");
|
||||
});
|
||||
|
||||
test("Fehlschlag: Stripe wiederholt, Kundin wird informiert, nach dem letzten Versuch Pruefung", async () => {
|
||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||
const failed = gateway.billNextInstallment((await scheduleOf(booking.id)).id, "failed");
|
||||
await handleStripeEvent(db, gateway, invoiceEvent("invoice.payment_failed", failed), ctx);
|
||||
await handleStripeEvent(db, gateway, invoiceEvent("invoice.payment_failed", failed), ctx);
|
||||
let second = await installment(booking.id, 2);
|
||||
expect(second.status).toBe("FAILED");
|
||||
expect(second.stripeInvoiceId).toBe(failed.id);
|
||||
expect(second.nextAttemptAt).not.toBeNull();
|
||||
expect(await db.emailMessage.count({ where: { dedupeKey: { startsWith: `installment-action:${second.id}` } } })).toBe(1);
|
||||
expect((await db.booking.findUniqueOrThrow({ where: { id: booking.id } })).status).toBe("CONFIRMED");
|
||||
|
||||
const final = gateway.retryInvoice(failed.id, "failed", { finalAttempt: true });
|
||||
await handleStripeEvent(db, gateway, invoiceEvent("invoice.payment_failed", final), ctx);
|
||||
second = await installment(booking.id, 2);
|
||||
expect(second.attemptCount).toBe(2);
|
||||
expect(second.nextAttemptAt).toBeNull();
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.status).toBe("NEEDS_REVIEW");
|
||||
expect(after.paymentStatus).toBe("PARTIALLY_PAID");
|
||||
});
|
||||
|
||||
test("Bank verlangt Bestaetigung: Kundin zahlt ueber Stripes Rechnungsseite", async () => {
|
||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||
const open = gateway.billNextInstallment((await scheduleOf(booking.id)).id, "requires_action");
|
||||
await handleStripeEvent(db, gateway, invoiceEvent("invoice.payment_action_required", open), ctx);
|
||||
const second = await installment(booking.id, 2);
|
||||
expect(second.status).toBe("REQUIRES_ACTION");
|
||||
|
||||
const pay = await installmentPaymentUrl(db, gateway, { bookingId: booking.id, installmentId: second.id });
|
||||
expect(pay).toEqual({ ok: true, url: `https://invoice.stripe.test/${open.id}` });
|
||||
|
||||
const paid = gateway.retryInvoice(open.id, "paid");
|
||||
await handleStripeEvent(db, gateway, invoiceEvent("invoice.paid", paid), ctx);
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.paymentStatus).toBe("PAID");
|
||||
expect(after.paidCents).toBe(220_000);
|
||||
// Bezahlte Rate hat keinen Zahlungslink mehr.
|
||||
expect((await installmentPaymentUrl(db, gateway, { bookingId: booking.id, installmentId: second.id })).ok).toBe(false);
|
||||
});
|
||||
|
||||
test("Ratenplan-Anlage scheitert: Zahlung bleibt verbucht, Worker holt die Anlage nach", async () => {
|
||||
gateway.failNextSchedule = true;
|
||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||
expect(booking.status).toBe("CONFIRMED");
|
||||
expect(booking.stripeScheduleId).toBeNull();
|
||||
expect((await createMissingSchedules(db, gateway, ctx)).created).toBe(1);
|
||||
expect(gateway.schedules.size).toBe(1);
|
||||
expect((await createMissingSchedules(db, gateway, ctx)).created).toBe(0);
|
||||
});
|
||||
|
||||
test("Erstattung aktualisiert Zahlungsstatus absolut (ungeordnete Events harmlos)", async () => {
|
||||
const booking = await paidBooking("FULL");
|
||||
const pi = booking.installments[0]!.stripePaymentIntentId!;
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
import type {
|
||||
CheckoutSessionInfo,
|
||||
CreateCheckoutParams,
|
||||
OffSessionResult,
|
||||
CreateInstallmentScheduleParams,
|
||||
InvoiceInfo,
|
||||
PaymentGateway,
|
||||
PaymentIntentInfo,
|
||||
} from "@/lib/payment-gateway";
|
||||
import type { StripeMode } from "@/lib/sales-gate";
|
||||
|
||||
type StoredSession = CheckoutSessionInfo & { params: CreateCheckoutParams };
|
||||
type StoredSchedule = { id: string; status: "active" | "canceled"; params: CreateInstallmentScheduleParams; invoiceIds: string[] };
|
||||
|
||||
/**
|
||||
* Stripe-Attrappe im Speicher. Bildet die fuer die Geschaeftslogik
|
||||
* relevanten Eigenschaften nach: Idempotenzschluessel, Session-Zustaende,
|
||||
* PaymentIntents und das Ergebnis von Off-Session-Einzuegen.
|
||||
* PaymentIntents und die Rechnungen eines Stripe-Billing-Ratenplans.
|
||||
*/
|
||||
export class FakeGateway implements PaymentGateway {
|
||||
private seq = 0;
|
||||
private readonly byKey = new Map<string, unknown>();
|
||||
readonly sessions = new Map<string, StoredSession>();
|
||||
readonly paymentIntents = new Map<string, PaymentIntentInfo>();
|
||||
readonly offSessionCharges: { key: string; amountCents: number; installmentId: string }[] = [];
|
||||
readonly schedules = new Map<string, StoredSchedule>();
|
||||
readonly invoices = new Map<string, InvoiceInfo>();
|
||||
readonly detached: string[] = [];
|
||||
readonly refunds: { id: string; paymentIntentId: string; amountCents: number }[] = [];
|
||||
/** Ergebnisse fuer die naechsten Off-Session-Einzuege (Standard: Erfolg). */
|
||||
offSessionOutcomes: OffSessionResult["outcome"][] = [];
|
||||
failNextCheckout = false;
|
||||
failNextSchedule = false;
|
||||
|
||||
constructor(readonly mode: StripeMode = "test") {}
|
||||
|
||||
@@ -101,32 +103,92 @@ export class FakeGateway implements PaymentGateway {
|
||||
return { ...pi };
|
||||
}
|
||||
|
||||
async chargeOffSession(
|
||||
params: { customerId: string; paymentMethodId: string; amountCents: number; metadata: Record<string, string> },
|
||||
key: string,
|
||||
): Promise<OffSessionResult> {
|
||||
return this.once(`pi:${key}`, () => {
|
||||
const outcome = this.offSessionOutcomes.shift() ?? "succeeded";
|
||||
const piId = this.id("pi");
|
||||
const chargeId = outcome === "succeeded" ? this.id("ch") : null;
|
||||
this.paymentIntents.set(piId, {
|
||||
id: piId,
|
||||
status: outcome === "succeeded" ? "succeeded" : outcome === "processing" ? "processing" : outcome === "requires_action" ? "requires_action" : "requires_payment_method",
|
||||
amountReceived: outcome === "succeeded" ? params.amountCents : 0,
|
||||
paymentMethodId: params.paymentMethodId,
|
||||
chargeId,
|
||||
metadata: params.metadata,
|
||||
async createInstallmentSchedule(params: CreateInstallmentScheduleParams, key: string) {
|
||||
if (this.failNextSchedule) {
|
||||
this.failNextSchedule = false;
|
||||
throw new Error("Stripe nicht erreichbar (simuliert)");
|
||||
}
|
||||
return this.once(`sub_sched:${key}`, () => {
|
||||
const id = this.id("sub_sched");
|
||||
this.schedules.set(id, { id, status: "active", params, invoiceIds: [] });
|
||||
return { id };
|
||||
});
|
||||
}
|
||||
|
||||
async cancelInstallmentSchedule(id: string): Promise<void> {
|
||||
const schedule = this.schedules.get(id);
|
||||
if (!schedule) throw new Error(`Unbekannter Zeitplan ${id}`);
|
||||
schedule.status = "canceled";
|
||||
for (const invoiceId of schedule.invoiceIds) {
|
||||
const invoice = this.invoices.get(invoiceId)!;
|
||||
if (invoice.status === "open") this.invoices.set(invoiceId, { ...invoice, status: "void", hostedInvoiceUrl: null });
|
||||
}
|
||||
}
|
||||
|
||||
async retrieveInvoice(id: string): Promise<InvoiceInfo> {
|
||||
const invoice = this.invoices.get(id);
|
||||
if (!invoice) throw new Error(`Unbekannte Rechnung ${id}`);
|
||||
return { ...invoice };
|
||||
}
|
||||
|
||||
async listScheduleInvoices(scheduleId: string): Promise<InvoiceInfo[]> {
|
||||
return (this.schedules.get(scheduleId)?.invoiceIds ?? []).map((id) => ({ ...this.invoices.get(id)! }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Simuliert den naechsten Einzug durch Stripe Billing: erzeugt die
|
||||
* naechste Rechnung des Zeitplans, bezahlt oder offen. Ein aktiver Zeitplan
|
||||
* erzeugt nie mehr Rechnungen als Folgeraten.
|
||||
*/
|
||||
billNextInstallment(scheduleId: string, outcome: "paid" | "failed" | "requires_action" = "paid", opts: { finalAttempt?: boolean } = {}): InvoiceInfo {
|
||||
const schedule = this.schedules.get(scheduleId);
|
||||
if (!schedule || schedule.status !== "active") throw new Error(`Zeitplan ${scheduleId} ist nicht aktiv`);
|
||||
if (schedule.invoiceIds.length >= schedule.params.count) throw new Error("Zeitplan hat bereits alle Raten abgerechnet");
|
||||
const id = this.id("in");
|
||||
schedule.invoiceIds.push(id);
|
||||
const invoice = this.invoiceResult(id, schedule, outcome, 1, opts.finalAttempt ?? false);
|
||||
this.invoices.set(id, invoice);
|
||||
return { ...invoice };
|
||||
}
|
||||
|
||||
/** Simuliert einen weiteren Versuch (Smart Retry oder Zahlung ueber die Rechnungsseite). */
|
||||
retryInvoice(invoiceId: string, outcome: "paid" | "failed" | "requires_action", opts: { finalAttempt?: boolean } = {}): InvoiceInfo {
|
||||
const current = this.invoices.get(invoiceId)!;
|
||||
if (current.status !== "open") throw new Error(`Rechnung ${invoiceId} ist nicht offen`);
|
||||
const schedule = [...this.schedules.values()].find((s) => s.invoiceIds.includes(invoiceId))!;
|
||||
const invoice = this.invoiceResult(invoiceId, schedule, outcome, current.attemptCount + 1, opts.finalAttempt ?? false);
|
||||
this.invoices.set(invoiceId, invoice);
|
||||
return { ...invoice };
|
||||
}
|
||||
|
||||
private invoiceResult(id: string, schedule: StoredSchedule, outcome: "paid" | "failed" | "requires_action", attempt: number, finalAttempt: boolean): InvoiceInfo {
|
||||
let paymentIntentId: string | null = null;
|
||||
if (outcome === "paid") {
|
||||
paymentIntentId = this.id("pi");
|
||||
this.paymentIntents.set(paymentIntentId, {
|
||||
id: paymentIntentId,
|
||||
status: "succeeded",
|
||||
amountReceived: schedule.params.amountCents,
|
||||
paymentMethodId: schedule.params.paymentMethodId,
|
||||
chargeId: this.id("ch"),
|
||||
metadata: {},
|
||||
});
|
||||
if (outcome === "succeeded") {
|
||||
this.offSessionCharges.push({ key, amountCents: params.amountCents, installmentId: params.metadata.installmentId! });
|
||||
}
|
||||
return {
|
||||
outcome,
|
||||
paymentIntentId: piId,
|
||||
chargeId,
|
||||
failureCode: outcome === "failed" ? "card_declined" : outcome === "requires_action" ? "authentication_required" : null,
|
||||
id,
|
||||
status: outcome === "paid" ? "paid" : "open",
|
||||
amountPaidCents: outcome === "paid" ? schedule.params.amountCents : 0,
|
||||
paymentIntentId,
|
||||
hostedInvoiceUrl: outcome === "paid" ? null : `https://invoice.stripe.test/${id}`,
|
||||
attemptCount: attempt,
|
||||
nextPaymentAttempt: outcome === "failed" && !finalAttempt ? new Date(Date.UTC(2027, 0, 1) + attempt * 86_400_000) : null,
|
||||
metadata: schedule.params.metadata,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Summe aller ueber den Zeitplan eingezogenen Betraege. */
|
||||
get billedCents(): number {
|
||||
return [...this.invoices.values()].reduce((sum, i) => sum + i.amountPaidCents, 0);
|
||||
}
|
||||
|
||||
async detachPaymentMethod(id: string): Promise<void> {
|
||||
|
||||
Reference in new issue
Block a user