Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m40s
ApiCore now owns the remaining stock per batch; the shop sells from it and reports every paid sale and every return back. - Outbox apicore_stock_events, written in the same transaction as the status change: paid -> SALE, cancelled-after-payment / refunded return -> RETURN onto the batches of the sale, reopened paid order -> SALE. Whether a return is due is decided by the open sales in the outbox, so unpaid orders never book and nothing is booked twice. - Delivery right after the response (next/server after()) plus CronJob wabenhain-apicore-stock every 10 minutes with 1/5/15/60 min backoff; ApiCore deduplicates by event id. Rejected bookings stop and show up on the dashboard. - Reconcile: shop stock = ApiCore remaining - unconfirmed bookings - reservations of unpaid orders, in a serializable transaction like the checkout. Triggered after bookings, by the signed ApiCore webhook stock.changed (ids only, numbers are fetched) and by the sync button. - Manufactured goods (candles, propolis products, ...): one shop product per ApiCore article via Product.apicoreProductId, drafts created by the sync, existing products linkable in the product form; new category SONSTIGES. Stock field is read-only for linked products. - OrderItem.apicoreAllocations keeps which lots were sold (recalls). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
407 lines
14 KiB
TypeScript
407 lines
14 KiB
TypeScript
/**
|
|
* 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: "<p>ok</p>",
|
|
text: "ok",
|
|
})),
|
|
paymentFailedEmail: vi.fn(async () => ({
|
|
subject: "Zahlung fehlgeschlagen",
|
|
html: "<p>fehlgeschlagen</p>",
|
|
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: "[email protected]",
|
|
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<string, unknown> = {}) {
|
|
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<Response> {
|
|
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"));
|
|
});
|
|
});
|
|
});
|