/** * Tests fuer den Stripe-Webhook — die Stelle, an der Geld in den Shop kommt. * * Hier entscheidet sich, ob eine Bestellung als bezahlt gilt, ob eine Rechnung * mit fortlaufender Nummer entsteht und ob ein Gutschein als eingeloest zaehlt. * Ein Fehler faellt an dieser Stelle im Betrieb praktisch nicht auf: Stripe * quittiert brav, die Kundschaft hat bezahlt, und erst Wochen spaeter fehlt * eine Rechnung oder ein Gutschein wurde doppelt gezaehlt. * * Datenbank und Stripe sind vollstaendig ersetzt. Geprueft wird nicht, ob * Prisma funktioniert, sondern ob dieser Handler unter den Bedingungen, die im * Betrieb tatsaechlich vorkommen, das Richtige tut — allen voran bei mehrfach * zugestellten Ereignissen, die Stripe ausdruecklich vorsieht. */ import { describe, test, expect, beforeEach, vi } from "vitest"; const constructEvent = vi.fn(); vi.mock("@/lib/stripe", () => ({ getStripeClient: () => ({ webhooks: { constructEvent }, }), })); const sendMail = vi.fn(); vi.mock("@/lib/mail", () => ({ sendMail: (...args: unknown[]) => sendMail(...args) })); vi.mock("@/lib/email-templates", () => ({ orderConfirmationEmail: vi.fn(async () => ({ subject: "Bestellbestaetigung", html: "

ok

", text: "ok", })), paymentFailedEmail: vi.fn(async () => ({ subject: "Zahlung fehlgeschlagen", html: "

fehlgeschlagen

", text: "fehlgeschlagen", })), })); const releaseUnpaidOrder = vi.fn(); vi.mock("@/lib/order-release", () => ({ releaseUnpaidOrder: (...args: unknown[]) => releaseUnpaidOrder(...args), })); vi.mock("@/lib/url", () => ({ getBaseUrl: () => "https://wabenhain.test" })); // Rechnungs-PDF: die Erzeugung selbst hat eigene Tests (invoice-pdf, // invoice-storage). Hier zaehlt nur, dass sie angestossen wird und dass das // erzeugte Dokument an der Bestaetigungsmail haengt. const getInvoiceByNumber = vi.fn(); vi.mock("@/lib/account-data", () => ({ getInvoiceByNumber: (...args: unknown[]) => getInvoiceByNumber(...args), })); vi.mock("@/lib/invoice-pdf", () => ({ generateInvoicePdf: vi.fn(async () => Buffer.from("pdf")) })); vi.mock("@/lib/invoice-storage", () => ({ invoiceRelativePath: vi.fn(() => "2026/mustermann-erika/RE-2026-0001.pdf"), storeInvoicePdf: vi.fn(async () => undefined), })); /** * Minimaler Prisma-Ersatz. `$transaction` reicht dieselbe Instanz als `tx` * weiter — der Handler verlaesst sich auf die Atomaritaet der Datenbank, und * die laesst sich in einem Unit-Test ohnehin nicht nachbilden. Geprueft wird * hier die Ablauflogik, nicht das Sperrverhalten. */ const db = { order: { updateMany: vi.fn(), findUnique: vi.fn(), findUniqueOrThrow: vi.fn(), update: vi.fn(), }, discountCode: { update: vi.fn() }, payment: { upsert: vi.fn(), findFirst: vi.fn(), update: vi.fn() }, invoice: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() }, invoiceCounter: { upsert: vi.fn() }, $transaction: vi.fn(), }; vi.mock("@/lib/prisma", () => ({ prisma: db })); // Bestandsabgleich mit ApiCore: hier nur, DASS die bezahlte Bestellung als // Verkauf in die Warteschlange geht; die Buchungslogik selbst testet // lib/apicore-stock. const recordOrderSale = vi.fn(async () => 0); const scheduleApiCoreStockSync = vi.fn(); vi.mock("@/lib/apicore-stock/stock-events", () => ({ recordOrderSale: (...args: unknown[]) => recordOrderSale(...(args as [])), })); vi.mock("@/lib/apicore-stock/schedule", () => ({ scheduleApiCoreStockSync: (...args: unknown[]) => scheduleApiCoreStockSync(...(args as [])), })); const { POST } = await import("./route"); const ORDER_ID = "order_1"; const paidOrder = { id: ORDER_ID, orderNumber: "WH-2026-AABBCCDD", email: "erika@example.org", firstName: "Erika", lastName: "Mustermann", locale: "de", fulfillmentMethod: "VERSAND", street: "Wiesenweg 1", zip: "86450", city: "Altenmuenster", giftMessage: null, subtotalCents: 1980, shippingCents: 700, discountCents: 0, totalCents: 2680, discountCodeId: null as string | null, items: [{ productName: "Fruehtracht 2026", quantity: 2, weightGrams: 500, totalCents: 1980 }], }; function session(overrides: Record = {}) { return { id: "cs_test_1", payment_status: "paid", payment_intent: "pi_test_1", amount_total: paidOrder.totalCents, metadata: { orderId: ORDER_ID, orderNumber: paidOrder.orderNumber }, ...overrides, }; } function post(): Promise { return POST( new Request("https://wabenhain.test/api/checkout/webhook", { method: "POST", headers: { "stripe-signature": "sig" }, body: "{}", }) ); } /** Stellt den Normalfall her: Bestellung steht auf "NEU" und wird beansprucht. */ function givenUnpaidOrderIsClaimed(order = paidOrder): void { db.order.updateMany.mockResolvedValue({ count: 1 }); db.order.findUniqueOrThrow.mockResolvedValue(order); db.invoice.findUnique.mockResolvedValue(null); db.invoiceCounter.upsert.mockResolvedValue({ lastNumber: 1 }); db.invoice.create.mockResolvedValue({ id: "inv_1", invoiceNumber: "RE-2026-0001" }); getInvoiceByNumber.mockResolvedValue({ id: "inv_1", invoiceNumber: "RE-2026-0001", issuedAt: new Date("2026-01-15T10:30:00.000Z"), totalCents: order.totalCents, order: { ...order, locale: "de" }, }); } beforeEach(() => { vi.clearAllMocks(); process.env.STRIPE_WEBHOOK_SECRET = "whsec_test"; db.$transaction.mockImplementation(async (arg: unknown) => typeof arg === "function" ? (arg as (tx: typeof db) => unknown)(db) : arg ); vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "warn").mockImplementation(() => {}); vi.spyOn(console, "info").mockImplementation(() => {}); }); describe("Stripe-Webhook", () => { test("weist eine Anfrage mit ungueltiger Signatur ab, ohne irgendetwas zu schreiben", async () => { constructEvent.mockImplementation(() => { throw new Error("No signatures found matching the expected signature for payload"); }); const response = await post(); expect(response.status).toBe(400); expect(db.order.updateMany).not.toHaveBeenCalled(); expect(sendMail).not.toHaveBeenCalled(); }); test("antwortet mit 500, solange STRIPE_WEBHOOK_SECRET fehlt", async () => { delete process.env.STRIPE_WEBHOOK_SECRET; const response = await post(); expect(response.status).toBe(500); expect(constructEvent).not.toHaveBeenCalled(); }); test('markiert eine Bestellung bei "completed" NICHT als bezahlt, solange payment_status "unpaid" ist', async () => { // SEPA-Lastschrift: die Sitzung ist abgeschlossen, das Geld aber noch nicht // eingezogen. Die Bestaetigung kommt spaeter als eigenes Ereignis. constructEvent.mockReturnValue({ type: "checkout.session.completed", data: { object: session({ payment_status: "unpaid" }) }, }); await post(); expect(db.order.updateMany).not.toHaveBeenCalled(); expect(sendMail).not.toHaveBeenCalled(); }); test("markiert die Bestellung als bezahlt, legt eine Rechnung an und verschickt die Bestaetigung", async () => { givenUnpaidOrderIsClaimed(); constructEvent.mockReturnValue({ type: "checkout.session.completed", data: { object: session() }, }); await post(); expect(db.order.updateMany).toHaveBeenCalledWith({ where: { id: ORDER_ID, status: "NEU" }, data: { status: "BEZAHLT" }, }); expect(db.payment.upsert).toHaveBeenCalledOnce(); expect(db.invoice.create).toHaveBeenCalledOnce(); expect(sendMail).toHaveBeenCalledOnce(); // Die Rechnung liegt der Bestaetigung bei — Gastbestellungen haben kein // Konto, in dem sie sonst zu finden waere. expect(sendMail.mock.calls[0][0].attachments?.[0]?.filename).toBe("RE-2026-0001.pdf"); }); test("zaehlt einen Gutschein bei mehrfach zugestelltem Ereignis nur einmal", async () => { const withCode = { ...paidOrder, discountCodeId: "code_1" }; givenUnpaidOrderIsClaimed(withCode); constructEvent.mockReturnValue({ type: "checkout.session.completed", data: { object: session() }, }); await post(); expect(db.discountCode.update).toHaveBeenCalledOnce(); // Zweite Zustellung desselben Ereignisses: die Bestellung steht bereits auf // "BEZAHLT", der Anspruch geht ins Leere (count 0). db.order.updateMany.mockResolvedValue({ count: 0 }); db.order.findUnique.mockResolvedValue({ status: "BEZAHLT", orderNumber: withCode.orderNumber }); db.invoice.findUnique.mockResolvedValue({ id: "inv_1", invoiceNumber: "RE-2026-0001" }); await post(); // Weiterhin genau ein Mal — Payment, Rechnung und Mail duerfen nachgeholt // werden, der Gutscheinzaehler aber nicht erneut erhoeht. Auch die // Rechnungsnummer wird nicht ein zweites Mal gezogen: eine Luecke in der // Nummernfolge ist genau das, was § 14 Abs. 4 Nr. 4 UStG ausschliesst. expect(db.discountCode.update).toHaveBeenCalledOnce(); expect(db.invoiceCounter.upsert).toHaveBeenCalledOnce(); }); test("liefert nichts aus und meldet laut, wenn eine Zahlung zu einer stornierten Bestellung eintrifft", async () => { // Regression: frueher ein stilles "return null". Das Geld war eingezogen, // im Shop entstand aber weder Rechnung noch Bestaetigung noch ein // Logeintrag — das faellt niemandem auf, bis sich die Kundschaft meldet. db.order.updateMany.mockResolvedValue({ count: 0 }); db.order.findUnique.mockResolvedValue({ status: "STORNIERT", orderNumber: paidOrder.orderNumber, }); constructEvent.mockReturnValue({ type: "checkout.session.completed", data: { object: session() }, }); await post(); expect(db.invoice.create).not.toHaveBeenCalled(); expect(sendMail).not.toHaveBeenCalled(); expect(console.error).toHaveBeenCalledWith( expect.stringContaining(`Bestellung ${paidOrder.orderNumber}, die auf "STORNIERT" steht`) ); }); test("meldet eine Betragsabweichung zwischen berechneter und eingezogener Summe", async () => { givenUnpaidOrderIsClaimed(); constructEvent.mockReturnValue({ type: "checkout.session.completed", data: { object: session({ amount_total: 2000 }) }, }); await post(); expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Betragsabweichung")); // Das Geld ist geflossen — die Bestellung bleibt trotzdem bezahlt. expect(db.payment.upsert).toHaveBeenCalledOnce(); }); test("loescht eine abgelaufene, nie bezahlte Bestellung und schickt dazu KEINE Mail", async () => { releaseUnpaidOrder.mockResolvedValue({ orderNumber: paidOrder.orderNumber, email: paidOrder.email, firstName: paidOrder.firstName, locale: "de", }); constructEvent.mockReturnValue({ type: "checkout.session.expired", data: { object: session({ payment_status: "unpaid" }) }, }); await post(); expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER_ID, "delete", paidOrder.totalCents); // Eine Mail zu einem Vorgang, den man selbst abgebrochen hat, verwirrt // mehr als sie hilft. expect(sendMail).not.toHaveBeenCalled(); }); test("storniert bei fehlgeschlagenem Einzug und benachrichtigt die Kundschaft", async () => { releaseUnpaidOrder.mockResolvedValue({ orderNumber: paidOrder.orderNumber, email: paidOrder.email, firstName: paidOrder.firstName, locale: "de", }); constructEvent.mockReturnValue({ type: "checkout.session.async_payment_failed", data: { object: session({ payment_status: "unpaid", amount_total: 2680 }) }, }); await post(); expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER_ID, "cancel", 2680); // Hier wurde tatsaechlich gezahlt und es ist gescheitert — jemand wartet // auf Ware und muss erfahren, dass daraus nichts wird. expect(sendMail).toHaveBeenCalledOnce(); }); describe("Geld, das den Shop wieder verlaesst", () => { test("setzt die Zahlung bei vollstaendiger Erstattung auf REFUNDED", async () => { db.payment.findFirst.mockResolvedValue({ id: "pay_1", status: "SUCCEEDED", order: { orderNumber: paidOrder.orderNumber }, }); constructEvent.mockReturnValue({ type: "charge.refunded", data: { object: { payment_intent: "pi_test_1", refunded: true, amount: 2680, amount_refunded: 2680, }, }, }); await post(); expect(db.payment.update).toHaveBeenCalledWith({ where: { id: "pay_1" }, data: { status: "REFUNDED" }, }); }); test("laesst den Zahlungsstatus bei einer TEILerstattung unveraendert", async () => { // Der Restbetrag ist weiterhin eingezogen — auf "REFUNDED" gesetzt // wuerde eine Auswertung den ganzen Umsatz abschreiben. db.payment.findFirst.mockResolvedValue({ id: "pay_1", status: "SUCCEEDED", order: { orderNumber: paidOrder.orderNumber }, }); constructEvent.mockReturnValue({ type: "charge.refunded", data: { object: { payment_intent: "pi_test_1", refunded: false, amount: 2680, amount_refunded: 500, }, }, }); await post(); expect(db.payment.update).not.toHaveBeenCalled(); expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Teilerstattung")); }); test("haengt eine Rueckbuchung an die Notizen der Bestellung an, ohne bestehende zu verlieren", async () => { db.payment.findFirst.mockResolvedValue({ orderId: ORDER_ID, order: { orderNumber: paidOrder.orderNumber, notes: "Kundenwunsch: bitte klingeln" }, }); constructEvent.mockReturnValue({ type: "charge.dispute.created", data: { object: { id: "dp_test_1", payment_intent: "pi_test_1", amount: 2680, reason: "fraudulent", }, }, }); await post(); const written = db.order.update.mock.calls[0][0].data.notes as string; expect(written).toContain("Kundenwunsch: bitte klingeln"); expect(written).toContain("dp_test_1"); expect(console.error).toHaveBeenCalledWith(expect.stringContaining("RUECKBUCHUNG")); }); }); });