import type Stripe from "stripe"; 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 { 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"; const ctx = { now: EARLY_BIRD_NOW, secret: SECRET, adminEmail: null }; const admin = { type: "ADMIN" as const, id: "admin-test" }; let gateway: FakeGateway; beforeEach(async () => { await resetDb(); await setupCamp(); gateway = new FakeGateway("test"); }); async function paidInstallmentBooking() { const result = await startCheckout(db, gateway, bookingRequest({ planType: "THREE_INSTALLMENTS" }), ctx); if (!result.ok) throw new Error(result.error.code); const booking = await db.booking.findUniqueOrThrow({ where: { reference: result.reference } }); const s = gateway.completeSession(booking.stripeCheckoutSessionId!); const event = { id: `evt_${crypto.randomUUID()}`, type: "checkout.session.completed", livemode: false, data: { object: { id: s.id, status: s.status, payment_status: s.paymentStatus, payment_intent: s.paymentIntentId, customer: s.customerId, metadata: s.metadata } }, } as unknown as Stripe.Event; await handleStripeEvent(db, gateway, event, ctx); return db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: { orderBy: { seq: "asc" } } } }); } describe("Stornierung", () => { 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); const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: true, reservation: true } }); 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]); 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); }); }); describe("Erstattung", () => { test("nur bezahlte Raten, nie mehr als gezahlt, idempotent je Anfrage", async () => { const booking = await paidInstallmentBooking(); const [first, second] = booking.installments; const key = crypto.randomUUID(); expect((await refundInstallment(db, gateway, { installmentId: second!.id, amountCents: 100, requestKey: key, reason: "" }, admin)).ok).toBe(false); expect((await refundInstallment(db, gateway, { installmentId: first!.id, amountCents: 80_001, requestKey: key, reason: "" }, admin)).ok).toBe(false); expect((await refundInstallment(db, gateway, { installmentId: first!.id, amountCents: 30_000, requestKey: key, reason: "Storno" }, admin)).ok).toBe(true); // Doppelt abgeschicktes Formular (gleicher Schluessel) -> keine zweite Erstattung. await refundInstallment(db, gateway, { installmentId: first!.id, amountCents: 30_000, requestKey: key, reason: "Storno" }, admin); expect(gateway.refunds).toHaveLength(1); // Rest bleibt begrenzt auf die noch nicht erstatteten 500 €. const tooMuch = await refundInstallment(db, gateway, { installmentId: first!.id, amountCents: 50_001, requestKey: crypto.randomUUID(), reason: "" }, admin); expect(tooMuch.ok).toBe(false); }); }); describe("Gutscheinverwaltung", () => { test("Gueltigkeit als Kalendertage in Berlin, Codes normalisiert, keine Dubletten", async () => { const input = voucherInputSchema.parse({ code: " stammmodel-laura ", active: true, validFrom: "2026-11-01", validUntil: "2026-12-31", maxRedemptions: "1", boundEmail: "Laura@Example.org", internalNote: "", }); expect(input.code).toBe("STAMMMODEL-LAURA"); expect(input.validFrom!.toISOString()).toBe("2026-10-31T23:00:00.000Z"); // "bis einschliesslich 31.12." = exklusiv 01.01.2027 00:00 Berlin expect(input.validUntil!.toISOString()).toBe("2026-12-31T23:00:00.000Z"); expect(input.boundEmail).toBe("laura@example.org"); expect((await saveVoucher(db, null, input, admin)).ok).toBe(true); expect((await saveVoucher(db, null, input, admin)).ok).toBe(false); }); test("der Beispielcode aus dem Briefing existiert nicht automatisch", async () => { expect(await db.voucher.findUnique({ where: { code: "STAMMMODELLAURA1X0" } })).toBeNull(); }); });