diff --git a/docs/ARCHITEKTUR.md b/docs/ARCHITEKTUR.md index d8b4276..46d94d5 100644 --- a/docs/ARCHITEKTUR.md +++ b/docs/ARCHITEKTUR.md @@ -56,7 +56,8 @@ Der gehostete Checkout bildet den Ablauf vollständig ab: Betrag, Ablaufzeit, da - 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:` angelegt. Doppelte oder ungeordnete Events werden über die Rechnungs-ID und den PaymentIntent nur einmal verbucht. +- „Prüfung nötig“ hält den Plan an: Gerät eine Buchung in Prüfung (z. B. Rückbuchung, Doppelzahlung), wird der Stripe-Plan beendet, offene Rechnungen werden storniert und unbezahlte Folgeraten gehen zurück auf `PENDING`. Bestätigt ein Mensch die Buchung, legt der Worker einen neuen Plan für die offenen Raten an (Start an der Fälligkeit oder, falls vorbei, am nächsten 9-Uhr-Termin; letzte Rate bleibt am Stichtag). So zieht Stripe wie früher der Worker nur bei bestätigten Buchungen ein. +- Idempotenz: Vor dem Anlegen sucht die Anwendung bei Stripe nach einem laufenden Plan dieser Buchung (Metadaten `bookingId`) und übernimmt ihn, auch wenn das Speichern der ID früher scheiterte. Angelegt wird mit Schlüssel `schedule:::`; die Generation steigt mit jedem Anhalten, sonst lieferte Stripe binnen 24 Stunden den alten, beendeten Plan zurück. 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`. - 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. diff --git a/docs/TESTS.md b/docs/TESTS.md index 2a92296..37dce2a 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -49,7 +49,7 @@ 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. +Lauf 06.10.2026 nach Umstellung der Folgeraten auf Stripe Billing (inkl. Anhalten bei Prüfung und Schutz vor doppeltem Plan): 88/88 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 diff --git a/src/lib/installment-billing.ts b/src/lib/installment-billing.ts index ee43ddd..61f2909 100644 --- a/src/lib/installment-billing.ts +++ b/src/lib/installment-billing.ts @@ -15,7 +15,9 @@ 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"; +import { lockBooking } from "./booking-service"; +import { CHARGE_HOUR_BERLIN } from "./payment-plan"; +import { addDays, berlinDateString, berlinStartOfDay, daysBetween } from "./time"; export const INSTALLMENT_PLAN_KIND = "installment_plan"; /** Stripe verlangt einen Start in der Zukunft. */ @@ -49,22 +51,40 @@ export async function ensureInstallmentSchedule( } const first = later[0]!; + const startAt = scheduleStart(first.dueAt!, ctx.now); 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}`, - ); + later.length === 1 ? 1 : daysBetween(berlinDateString(startAt), berlinDateString(later.at(-1)!.dueAt!)); + if (intervalDays < 1) { + const reason = "Bis zur letzten Rate bleibt kein Abstand mehr – Ratenplan bei Stripe nicht angelegt."; + await db.$transaction((tx) => flagBookingForReview(tx, booking.id, reason, { ...ctx, actor })); + return "needs_review"; + } + + // Schutz gegen einen zweiten Plan, auch wenn das Speichern der ID frueher + // scheiterte und der Idempotenzschluessel (24 h) abgelaufen ist. + const existing = await gateway.findInstallmentSchedule(booking.stripeCustomerId!, booking.id); + // Nach einer Pruefung entsteht ein neuer Plan; ohne Generation lieferte + // Stripe zum alten Schluessel den bereits beendeten Plan zurueck. + const generation = await db.auditLog.count({ + where: { entityType: "Booking", entityId: booking.id, action: "installments.schedule_held_for_review" }, + }); + const schedule = + existing ?? + (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 }, + }, + // startAt ist fuer alle gleichzeitigen Aufrufer gleich (Faelligkeit + // oder naechster 9-Uhr-Termin), der Schluessel also auch. + `schedule:${booking.id}:${generation}:${startAt.getTime()}`, + )); const saved = await db.booking.updateMany({ where: { id: booking.id, stripeScheduleId: null }, @@ -84,6 +104,52 @@ export async function ensureInstallmentSchedule( return "created"; } +/** + * Start des Plans: die Faelligkeit der naechsten Rate. Ist sie schon vorbei + * (Buchung erst nach Pruefung bestaetigt), der naechste 9-Uhr-Termin in + * Berlin — deterministisch, damit Wiederholungen denselben Plan meinen. + */ +function scheduleStart(dueAt: Date, now: Date): Date { + const earliest = now.getTime() + MIN_START_LEAD_MS; + if (dueAt.getTime() >= earliest) return dueAt; + const today = berlinDateString(now); + const todayAtNine = berlinStartOfDay(today, CHARGE_HOUR_BERLIN); + return todayAtNine.getTime() >= earliest ? todayAtNine : berlinStartOfDay(addDays(today, 1), CHARGE_HOUR_BERLIN); +} + +/** + * Haelt den Stripe-Plan an, solange eine Buchung in Pruefung ist (z. B. + * Rueckbuchung, Doppelzahlung): Plan beenden, offene Rechnungen stornieren, + * unbezahlte Folgeraten wieder auf PENDING. Bestaetigt ein Mensch die + * Buchung, legt createMissingSchedules einen neuen Plan fuer die offenen + * Raten an; storniert er, bleibt es dabei. + */ +export async function holdSchedulesInReview(db: Db, gateway: PaymentGateway, bookingIds?: string[], actor: Actor = WORKER) { + const candidates = await db.booking.findMany({ + where: { + status: "NEEDS_REVIEW", + stripeScheduleId: { not: null }, + livemode: gateway.mode === "live", + ...(bookingIds ? { id: { in: bookingIds } } : {}), + }, + select: { id: true, stripeScheduleId: true }, + take: 50, + }); + for (const booking of candidates) { + await gateway.cancelInstallmentSchedule(booking.stripeScheduleId!); + await db.$transaction(async (tx) => { + await lockBooking(tx, booking.id); + await tx.installment.updateMany({ + where: { bookingId: booking.id, seq: { gt: 1 }, status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] } }, + data: { status: "PENDING", stripeInvoiceId: null, nextAttemptAt: null }, + }); + await tx.booking.update({ where: { id: booking.id }, data: { stripeScheduleId: null } }); + await audit(tx, actor, "installments.schedule_held_for_review", "Booking", booking.id, { scheduleId: booking.stripeScheduleId }); + }); + } + return { held: candidates.length }; +} + function schedulePlanProblem( booking: { stripeCustomerId: string | null; stripePaymentMethodId: string | null; paymentMethodDetachedAt: Date | null }, later: { amountCents: number; dueAt: Date | null }[], diff --git a/src/lib/payment-gateway.ts b/src/lib/payment-gateway.ts index f877b39..a7e5911 100644 --- a/src/lib/payment-gateway.ts +++ b/src/lib/payment-gateway.ts @@ -87,6 +87,8 @@ export interface PaymentGateway { expireCheckoutSession(id: string): Promise; retrievePaymentIntent(id: string): Promise; createInstallmentSchedule(params: CreateInstallmentScheduleParams, idempotencyKey: string): Promise<{ id: string }>; + /** Laufender oder noch nicht gestarteter Plan dieser Buchung bei Stripe, falls vorhanden. */ + findInstallmentSchedule(customerId: string, bookingId: string): Promise<{ id: string } | null>; /** Beendet den Zeitplan sofort und storniert offene Rechnungen: danach wird nichts mehr eingezogen. */ cancelInstallmentSchedule(id: string): Promise; retrieveInvoice(id: string): Promise; @@ -236,6 +238,14 @@ export class StripeGateway implements PaymentGateway { return { id: schedule.id }; } + async findInstallmentSchedule(customerId: string, bookingId: string): Promise<{ id: string } | null> { + for await (const schedule of this.stripe.subscriptionSchedules.list({ customer: customerId, limit: 100 })) { + const live = schedule.status === "not_started" || schedule.status === "active"; + if (live && schedule.metadata?.bookingId === bookingId) return { id: schedule.id }; + } + return null; + } + /** Feste Produkt-ID: beim ersten Bedarf angelegt, danach wiederverwendet. */ private async installmentProductId(): Promise { try { diff --git a/src/lib/webhook.ts b/src/lib/webhook.ts index ba4a340..f6440cf 100644 --- a/src/lib/webhook.ts +++ b/src/lib/webhook.ts @@ -13,7 +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 { ensureInstallmentSchedule, holdSchedulesInReview, INSTALLMENT_PLAN_KIND, settleInstallmentInvoice } from "./installment-billing"; import { normalizePaymentStatus, type CheckoutSessionInfo, type PaymentGateway } from "./payment-gateway"; import { applyCheckoutExpired, @@ -170,8 +170,10 @@ async function dispatch(db: Db, gateway: PaymentGateway, event: Stripe.Event, ct } case "charge.dispute.created": { const piId = idOf(event.data.object.payment_intent); - if (piId) await db.$transaction((tx) => applyDispute(tx, piId, eventCtx(ctx))); - return []; + if (!piId) return []; + await db.$transaction((tx) => applyDispute(tx, piId, eventCtx(ctx))); + const disputed = await db.installment.findUnique({ where: { stripePaymentIntentId: piId }, select: { bookingId: true } }); + return disputed ? [disputed.bookingId] : []; } default: return []; @@ -218,6 +220,14 @@ export async function handleStripeEvent( try { const bookingIds = await dispatch(db, gateway, event, ctx); await db.stripeEvent.update({ where: { id: event.id }, data: { status: "PROCESSED", processedAt: new Date(), error: null } }); + // Geriet eine Buchung in Pruefung (Rueckbuchung, Doppelzahlung ...), + // zieht Stripe bis zur Entscheidung nichts weiter ein. Scheitert das + // hier, erledigt es der Worker. + if (bookingIds.length > 0) { + await holdSchedulesInReview(db, gateway, bookingIds, STRIPE_ACTOR).catch((error: unknown) => { + console.error(`[webhook] Ratenplan nicht angehalten: ${error instanceof Error ? error.message : "unbekannt"}`); + }); + } return { outcome: "processed", bookingIds }; } catch (error) { const message = error instanceof Error ? error.message.slice(0, 500) : "unbekannter Fehler"; diff --git a/src/lib/worker-jobs.ts b/src/lib/worker-jobs.ts index cd9e2b9..ffeceda 100644 --- a/src/lib/worker-jobs.ts +++ b/src/lib/worker-jobs.ts @@ -10,7 +10,7 @@ import { audit, WORKER } from "./audit"; import type { Db } from "./db"; import { flushEmails } from "./email"; -import { ensureInstallmentSchedule, settleInstallmentInvoice, stopCanceledInstallmentPlans } from "./installment-billing"; +import { ensureInstallmentSchedule, holdSchedulesInReview, settleInstallmentInvoice, stopCanceledInstallmentPlans } from "./installment-billing"; import { applyCheckoutExpired, applyFirstPaymentFailed, type EventContext } from "./payment-events"; import type { PaymentGateway } from "./payment-gateway"; import { purgeRateLimits } from "./rate-limit"; @@ -181,11 +181,12 @@ 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 held = await holdSchedulesInReview(db, gateway); 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, schedules, installments, stopped, detach, emails, purgedRateLimits: purged }; + return { reservations, reconcile, held, schedules, installments, stopped, detach, emails, purgedRateLimits: purged }; } diff --git a/tests/payments.integration.test.ts b/tests/payments.integration.test.ts index 7f922c0..c1bad67 100644 --- a/tests/payments.integration.test.ts +++ b/tests/payments.integration.test.ts @@ -6,6 +6,7 @@ import { verifyStripeWebhook } from "@/lib/payment-gateway"; import { handleStripeEvent, settleCheckoutSession } from "@/lib/webhook"; import { createMissingSchedules, detachFinishedPaymentMethods, reconcileInstallmentInvoices, releaseExpiredReservations } from "@/lib/worker-jobs"; import { STRIPE_ACTOR } from "@/lib/audit"; +import { confirmReviewedBooking } from "@/lib/admin-service"; import type { PlanType } from "@/lib/payment-plan"; import { bookingRequest, db, EARLY_BIRD_NOW, resetDb, SECRET, setupCamp } from "./support/db"; import { FakeGateway } from "./support/fake-gateway"; @@ -334,6 +335,55 @@ describe("Folgeraten ueber Stripe Billing", () => { expect((await installmentPaymentUrl(db, gateway, { bookingId: booking.id, installmentId: second.id })).ok).toBe(false); }); + test("Rueckbuchung: Stripe zieht bis zur Entscheidung nichts ein, nach Bestaetigung neuer Plan", async () => { + const booking = await paidBooking("THREE_INSTALLMENTS"); + const first = await scheduleOf(booking.id); + const dispute = { + id: `evt_${crypto.randomUUID()}`, + type: "charge.dispute.created", + livemode: false, + data: { object: { id: "dp_1", object: "dispute", payment_intent: booking.installments[0]!.stripePaymentIntentId } }, + } as unknown as Stripe.Event; + await handleStripeEvent(db, gateway, dispute, ctx); + + let after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: { orderBy: { seq: "asc" } } } }); + expect(after.status).toBe("NEEDS_REVIEW"); + expect(after.stripeScheduleId).toBeNull(); + expect(first.status).toBe("canceled"); + expect(after.installments.map((i) => i.status)).toEqual(["PAID", "PENDING", "PENDING"]); + // Waehrend der Pruefung legt der Worker keinen neuen Plan an. + expect((await createMissingSchedules(db, gateway, ctx)).created).toBe(0); + + expect((await confirmReviewedBooking(db, booking.id, { type: "ADMIN", id: "admin-test" }, ctx.now)).ok).toBe(true); + expect((await createMissingSchedules(db, gateway, ctx)).created).toBe(1); + after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: true } }); + const second = gateway.schedules.get(after.stripeScheduleId!)!; + expect(second.id).not.toBe(first.id); + expect(second.params).toMatchObject({ amountCents: 70_000, count: 2 }); + }); + + test("ID des Plans ging verloren: kein zweiter Plan bei Stripe", async () => { + const booking = await paidBooking("TWO_INSTALLMENTS"); + await db.booking.update({ where: { id: booking.id }, data: { stripeScheduleId: null } }); + expect((await createMissingSchedules(db, gateway, ctx)).created).toBe(1); + expect(gateway.schedules.size).toBe(1); + const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } }); + expect(after.stripeScheduleId).toBe([...gateway.schedules.keys()][0]); + }); + + test("Faelligkeit schon vorbei: Start am naechsten 9-Uhr-Termin, letzte Rate bleibt am Stichtag", async () => { + gateway.failNextSchedule = true; + const booking = await paidBooking("THREE_INSTALLMENTS"); + const [, second, third] = booking.installments; + const late = { ...ctx, now: new Date(second!.dueAt!.getTime() + 3 * 86_400_000 + 2 * 60 * 60_000) }; + expect((await createMissingSchedules(db, gateway, late)).created).toBe(1); + const schedule = [...gateway.schedules.values()][0]!; + expect(schedule.params.startAt.getTime()).toBeGreaterThan(late.now.getTime()); + expect(schedule.params.startAt.getTime() - late.now.getTime()).toBeLessThanOrEqual(86_400_000); + const end = schedule.params.startAt.getTime() + schedule.params.intervalDays * 86_400_000; + expect(Math.abs(end - third!.dueAt!.getTime())).toBeLessThanOrEqual(60 * 60_000); + }); + test("Ratenplan-Anlage scheitert: Zahlung bleibt verbucht, Worker holt die Anlage nach", async () => { gateway.failNextSchedule = true; const booking = await paidBooking("TWO_INSTALLMENTS"); diff --git a/tests/support/fake-gateway.ts b/tests/support/fake-gateway.ts index 630c7af..844998a 100644 --- a/tests/support/fake-gateway.ts +++ b/tests/support/fake-gateway.ts @@ -115,6 +115,13 @@ export class FakeGateway implements PaymentGateway { }); } + async findInstallmentSchedule(customerId: string, bookingId: string): Promise<{ id: string } | null> { + const found = [...this.schedules.values()].find( + (s) => s.status === "active" && s.params.customerId === customerId && s.params.metadata.bookingId === bookingId, + ); + return found ? { id: found.id } : null; + } + async cancelInstallmentSchedule(id: string): Promise { const schedule = this.schedules.get(id); if (!schedule) throw new Error(`Unbekannter Zeitplan ${id}`);