Files
Artista-Filmcamp/tests/payments.integration.test.ts
T
Nick SzaunigandClaude Opus 5.5 1ce7947d93 fix: Stripe-Ratenplan bei Prüfung anhalten, doppelte Pläne verhindern
Befunde aus dem Sicherheits-Review des vorherigen Commits:

- Gerät eine Buchung in "Prüfung nötig" (Rückbuchung, Doppelzahlung ...),
  wird der Stripe-Plan beendet und offene Rechnungen storniert. Nach
  Bestätigung legt der Worker einen neuen Plan für die offenen Raten an.
  Vorher zog Stripe auch bei Buchungen in Prüfung weiter ein.
- Vor dem Anlegen wird bei Stripe nach einem laufenden Plan der Buchung
  gesucht; der Idempotenzschlüssel enthält Generation und Start. Verhindert
  einen zweiten Plan nach verlorener ID und die Rückgabe des alten,
  beendeten Plans nach einer Prüfung.
- Start bei bereits verstrichener Fälligkeit deterministisch am nächsten
  9-Uhr-Termin, letzte Rate bleibt am Stichtag.
- Rückbuchungs-Event meldet die betroffene Buchung zurück.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 06:28:41 +02:00

409 lines
22 KiB
TypeScript

import Stripe from "stripe";
import { beforeEach, describe, expect, test } from "vitest";
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 { 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";
const ctx = { now: EARLY_BIRD_NOW, secret: SECRET, adminEmail: "[email protected]" };
let gateway: FakeGateway;
beforeEach(async () => {
await resetDb();
await setupCamp();
gateway = new FakeGateway("test");
});
async function checkout(planType: PlanType = "FULL", voucherCode?: string) {
const request = bookingRequest({ planType, voucherCode });
const result = await startCheckout(db, gateway, request, ctx);
if (!result.ok) throw new Error(`Checkout fehlgeschlagen: ${result.error.code}`);
const booking = await db.booking.findUniqueOrThrow({ where: { reference: result.reference } });
return { request, result, booking };
}
function sessionEvent(type: string, session: ReturnType<FakeGateway["completeSession"]>, id = `evt_${crypto.randomUUID()}`) {
return {
id,
type,
livemode: false,
data: {
object: {
id: session.id,
object: "checkout.session",
status: session.status,
payment_status: session.paymentStatus,
payment_intent: session.paymentIntentId,
customer: session.customerId,
url: null,
metadata: session.metadata,
},
},
} as unknown as Stripe.Event;
}
async function paidBooking(planType: PlanType = "FULL") {
const { booking } = await checkout(planType);
const session = gateway.completeSession(booking.stripeCheckoutSessionId!);
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.completed", session), ctx);
return db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: { orderBy: { seq: "asc" } } } });
}
describe("Checkout-Erstellung", () => {
test("idempotent: derselbe Buchungsversuch liefert dieselbe Stripe-Session", async () => {
const { request, result } = await checkout();
const again = await startCheckout(db, gateway, request, ctx);
expect(again.ok && again.url).toBe(result.url);
expect(gateway.sessions.size).toBe(1);
});
test("Session laeuft mit der Preisbindung ab; Ratenplan speichert die Zahlungsmethode", async () => {
const { booking } = await checkout("THREE_INSTALLMENTS");
const session = gateway.sessions.get(booking.stripeCheckoutSessionId!)!;
expect(session.params.amountCents).toBe(80_000);
expect(session.params.saveForFutureUsage).toBe(true);
expect(session.params.expiresAt.getTime()).toBe(booking.priceLockedUntil.getTime());
expect(session.params.submitMessage).toContain("Heute fällig: 800,00 €");
});
test("Stripe-Ausfall gibt Platz und Gutschein sofort wieder frei", async () => {
await db.voucher.create({ data: { code: "CODE-X" } });
gateway.failNextCheckout = true;
const result = await startCheckout(db, gateway, bookingRequest({ voucherCode: "CODE-X" }), ctx);
expect(result.ok ? "ok" : result.error.code).toBe("PAYMENT_PROVIDER");
expect(await db.seatReservation.count({ where: { status: "RELEASED" } })).toBe(1);
expect(await db.voucherRedemption.count({ where: { status: "RELEASED" } })).toBe(1);
});
});
describe("Webhook", () => {
test("Signatur wird gegen den unveraenderten Body geprueft", () => {
const secret = "whsec_test_secret";
const payload = JSON.stringify({ id: "evt_1", object: "event", type: "checkout.session.completed", data: { object: {} } });
const header = Stripe.webhooks.generateTestHeaderString({ payload, secret });
expect(verifyStripeWebhook(payload, header, secret).id).toBe("evt_1");
expect(() => verifyStripeWebhook(payload.replace("evt_1", "evt_2"), header, secret)).toThrow();
expect(() => verifyStripeWebhook(payload, header, "whsec_falsch")).toThrow();
});
test("Einmalzahlung: bestaetigt und vollstaendig bezahlt, Mail im Postausgang", async () => {
const booking = await paidBooking("FULL");
expect(booking.status).toBe("CONFIRMED");
expect(booking.paymentStatus).toBe("PAID");
expect(booking.paidCents).toBe(220_000);
expect(await db.seatReservation.count({ where: { status: "CONVERTED" } })).toBe(1);
expect(await db.emailMessage.count({ where: { dedupeKey: `booking-confirmed:${booking.id}` } })).toBe(1);
});
test("Ratenzahlung: erste Rate bezahlt heisst Teilzahlung, nicht vollstaendig bezahlt", async () => {
const booking = await paidBooking("TWO_INSTALLMENTS");
expect(booking.status).toBe("CONFIRMED");
expect(booking.paymentStatus).toBe("PARTIALLY_PAID");
expect(booking.paidCents).toBe(110_000);
expect(booking.stripePaymentMethodId).toMatch(/^pm_/);
expect(booking.installments.map((i) => i.status)).toEqual(["PAID", "PENDING"]);
});
test("doppelt zugestelltes Event wird nur einmal verbucht", async () => {
const { booking } = await checkout();
const session = gateway.completeSession(booking.stripeCheckoutSessionId!);
const event = sessionEvent("checkout.session.completed", session, "evt_dup");
expect((await handleStripeEvent(db, gateway, event, ctx)).outcome).toBe("processed");
expect((await handleStripeEvent(db, gateway, event, ctx)).outcome).toBe("duplicate");
// Anderes Event, gleicher Inhalt (z. B. async_payment_succeeded): idempotent.
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.async_payment_succeeded", session), ctx);
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
expect(after.paidCents).toBe(220_000);
});
test("ungeordnet: 'expired' nach 'completed' hebt die Buchung nicht auf", async () => {
const { booking } = await checkout();
const session = gateway.completeSession(booking.stripeCheckoutSessionId!);
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.completed", session), ctx);
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.expired", { ...session, status: "expired" }), ctx);
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
expect(after.status).toBe("CONFIRMED");
});
test("Abbruch/Ablauf: unbezahlte Buchung verfaellt, Platz wird frei", async () => {
const { booking } = await checkout();
const session = await gateway.retrieveCheckoutSession(booking.stripeCheckoutSessionId!);
await handleStripeEvent(
db,
gateway,
sessionEvent("checkout.session.expired", { ...session, status: "expired" } as ReturnType<FakeGateway["completeSession"]>),
ctx,
);
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { reservation: true } });
expect(after.status).toBe("EXPIRED");
expect(after.reservation!.status).toBe("RELEASED");
});
test("Livemodus-Event im Testbetrieb wird ignoriert", async () => {
const { booking } = await checkout();
const session = gateway.completeSession(booking.stripeCheckoutSessionId!);
const event = { ...sessionEvent("checkout.session.completed", session), livemode: true } as Stripe.Event;
expect((await handleStripeEvent(db, gateway, event, ctx)).outcome).toBe("ignored");
expect((await db.booking.findUniqueOrThrow({ where: { id: booking.id } })).status).toBe("PENDING");
});
});
describe("Verzoegerte und verspaetete Zahlung", () => {
test("verzoegerte Zahlung haelt den Platz, Fehlschlag gibt ihn frei", async () => {
const { booking } = await checkout();
const session = gateway.completeSession(booking.stripeCheckoutSessionId!, { paid: false });
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.completed", session), ctx);
let after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { reservation: true } });
expect(after.paymentStatus).toBe("PROCESSING");
expect(after.reservation!.status).toBe("HELD");
// Reservierungsfrist laeuft ab, der Platz bleibt trotzdem gehalten.
const later = { ...ctx, now: new Date(EARLY_BIRD_NOW.getTime() + 3 * 86_400_000) };
await releaseExpiredReservations(db, gateway, later);
after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { reservation: true } });
expect(after.reservation!.status).toBe("HELD");
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.async_payment_failed", session), later);
after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { reservation: true } });
expect(after.status).toBe("EXPIRED");
expect(after.paymentStatus).toBe("FAILED");
expect(after.reservation!.status).toBe("RELEASED");
});
test("verlorener Webhook: Worker fragt Stripe und verbucht die Zahlung statt freizugeben", async () => {
const { booking } = await checkout();
gateway.completeSession(booking.stripeCheckoutSessionId!);
const later = { ...ctx, now: new Date(EARLY_BIRD_NOW.getTime() + 60 * 60_000) };
const result = await releaseExpiredReservations(db, gateway, later);
expect(result.settled).toBe(1);
expect((await db.booking.findUniqueOrThrow({ where: { id: booking.id } })).status).toBe("CONFIRMED");
});
test("verspaetete Zahlung nach Freigabe: Platz noch frei -> bestaetigt", async () => {
const { booking } = await checkout();
const session = gateway.completeSession(booking.stripeCheckoutSessionId!);
// Platz wurde (z. B. durch einen Fehler) bereits freigegeben.
await db.seatReservation.update({ where: { bookingId: booking.id }, data: { status: "RELEASED" } });
await db.booking.update({ where: { id: booking.id }, data: { status: "EXPIRED" } });
await settleCheckoutSession(db, gateway, session, ctx, STRIPE_ACTOR);
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { reservation: true } });
expect(after.status).toBe("CONFIRMED");
expect(after.reservation!.status).toBe("CONVERTED");
});
test("verspaetete Zahlung nach Freigabe ohne freien Platz -> Pruefung statt Ueberbuchung", async () => {
await setupCamp({ capacity: 1 });
const { booking } = await checkout();
const session = gateway.completeSession(booking.stripeCheckoutSessionId!);
await db.seatReservation.update({ where: { bookingId: booking.id }, data: { status: "RELEASED" } });
await db.booking.update({ where: { id: booking.id }, data: { status: "EXPIRED" } });
await paidBooking("FULL"); // jemand anderes bekommt den letzten Platz
await settleCheckoutSession(db, gateway, session, ctx, STRIPE_ACTOR);
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
expect(after.status).toBe("NEEDS_REVIEW");
expect(after.paidCents).toBe(220_000);
expect(await db.seatReservation.count({ where: { status: "CONVERTED" } })).toBe(1);
expect(await db.emailMessage.count({ where: { toAddress: "[email protected]" } })).toBe(1);
});
});
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("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 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 }, include: { installments: true } });
expect(after.paymentMethodDetachedAt).not.toBeNull();
expect(gateway.detached).toEqual([booking.stripePaymentMethodId]);
});
test("doppelt zugestelltes invoice.paid wird nur einmal verbucht", async () => {
const booking = await paidBooking("TWO_INSTALLMENTS");
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("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");
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!;
const ectx = { ...ctx, actor: STRIPE_ACTOR };
await db.$transaction((tx) => applyRefundState(tx, { paymentIntentId: pi, amountRefundedCents: 220_000 }, ectx));
await db.$transaction((tx) => applyRefundState(tx, { paymentIntentId: pi, amountRefundedCents: 50_000 }, ectx));
await db.$transaction((tx) => applyRefundState(tx, { paymentIntentId: pi, amountRefundedCents: 220_000 }, ectx));
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
expect(after.refundedCents).toBe(220_000);
expect(after.paymentStatus).toBe("REFUNDED");
});
});