/**
* 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"));
});
});
});