Rechnungskopie: unter Einstellungen > Firma > "Rechnungskopie an" laesst sich eine Adresse hinterlegen; jede Rechnung und Stornorechnung geht zusaetzlich als eigene Mail mit derselben E-Rechnung dorthin (lib/invoice-mail.ts). Stornorechnung: eine Rechnung wird nie geloescht. Bei Stornierung im Admin, erstatteter Retoure oder voller Erstattung bei Stripe entsteht genau eine Stornorechnung ST-JJJJ-NNNN (eigener lueckenloser Nummernkreis) mit allen Positionen und Bezug auf die Rechnung, als E-Rechnung (Gutschrift 381) an die Kundschaft und als Kopie. Gleichzeitige Ausloeser (Admin-Storno und Stripe-Webhook derselben Erstattung) enden bei einer Stornorechnung. Bestellungen mit Stornorechnung lassen sich nicht wieder oeffnen. ERP-Export: GET /api/erp/rechnungen?art=storno liefert Stornorechnungen mit Bezug (cancels), Rechnungen tragen cancelledBy; PDFs auch fuer ST-Nummern. Admin-Bestelldetail zeigt die Stornorechnung mit Download. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
126 lines
5.5 KiB
TypeScript
126 lines
5.5 KiB
TypeScript
/**
|
|
* Tests fuer die Stornorechnung: hoechstens eine je Rechnung, auch wenn
|
|
* Admin-Stornierung und Stripe-Webhook derselben Erstattung gleichzeitig
|
|
* ankommen, und sie geht an die Kundschaft und als Kopie hinaus.
|
|
*/
|
|
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
|
|
const tx = {
|
|
cancellationCounter: { upsert: vi.fn() },
|
|
cancellationInvoice: { create: vi.fn() },
|
|
};
|
|
const db = {
|
|
invoice: { findUnique: vi.fn() },
|
|
cancellationInvoice: { findUnique: vi.fn(), update: vi.fn() },
|
|
$transaction: vi.fn(async (run: (client: typeof tx) => Promise<unknown>) => run(tx)),
|
|
};
|
|
vi.mock("@/lib/prisma", () => ({ prisma: db }));
|
|
|
|
const generateInvoicePdf = vi.fn(async () => Buffer.from("storno-pdf"));
|
|
vi.mock("@/lib/invoice-pdf", () => ({ generateInvoicePdf: (...args: unknown[]) => generateInvoicePdf(...(args as [])) }));
|
|
const storeInvoicePdf = vi.fn(async () => undefined);
|
|
vi.mock("@/lib/invoice-storage", () => ({
|
|
invoiceRelativePath: vi.fn(() => "2026/musterfrau-erika/ST-2026-0003.pdf"),
|
|
storeInvoicePdf: (...args: unknown[]) => storeInvoicePdf(...(args as [])),
|
|
readStoredInvoicePdf: vi.fn(async () => null),
|
|
}));
|
|
const sendMail = vi.fn(async () => true);
|
|
vi.mock("@/lib/mail", () => ({ sendMail: (...args: unknown[]) => sendMail(...(args as [])) }));
|
|
vi.mock("@/lib/email-templates", () => ({
|
|
cancellationInvoiceEmail: vi.fn(async () => ({ subject: "Stornorechnung", html: "<p>x</p>", text: "x" })),
|
|
}));
|
|
const sendInvoiceCopy = vi.fn(async () => true);
|
|
vi.mock("@/lib/invoice-mail", () => ({ sendInvoiceCopy: (...args: unknown[]) => sendInvoiceCopy(...(args as [])) }));
|
|
|
|
const { issueCancellationInvoice, isCancellationNumber } = await import("./cancellation-invoice");
|
|
|
|
const order = {
|
|
orderNumber: "WH-2026-0042",
|
|
email: "[email protected]",
|
|
firstName: "Erika",
|
|
lastName: "Musterfrau",
|
|
locale: "de",
|
|
items: [],
|
|
};
|
|
const loaded = {
|
|
number: "ST-2026-0003",
|
|
issuedAt: new Date("2026-10-14T09:00:00Z"),
|
|
pdfPath: null,
|
|
invoice: { invoiceNumber: "RE-2026-0042", issuedAt: new Date("2026-10-12T10:00:00Z"), totalCents: 5375, order },
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
db.invoice.findUnique.mockResolvedValue({ id: "inv1", totalCents: 5375, cancellation: null });
|
|
tx.cancellationCounter.upsert.mockResolvedValue({ lastNumber: 3 });
|
|
tx.cancellationInvoice.create.mockImplementation(async ({ data }: { data: { number: string } }) => ({ number: data.number }));
|
|
db.cancellationInvoice.findUnique.mockResolvedValue(loaded);
|
|
});
|
|
|
|
describe("issueCancellationInvoice", () => {
|
|
test("ohne Rechnung gibt es nichts aufzuheben", async () => {
|
|
db.invoice.findUnique.mockResolvedValue(null);
|
|
|
|
expect(await issueCancellationInvoice("order1", "STORNIERUNG")).toBeNull();
|
|
expect(db.$transaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("legt die Stornorechnung mit eigener Nummer an und verschickt sie mit Kopie", async () => {
|
|
// Act
|
|
const result = await issueCancellationInvoice("order1", "STORNIERUNG");
|
|
|
|
// Assert
|
|
const year = new Date().getFullYear();
|
|
expect(result).toEqual({ number: `ST-${year}-0003`, created: true });
|
|
expect(tx.cancellationInvoice.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({ data: { invoiceId: "inv1", number: `ST-${year}-0003`, totalCents: 5375, reason: "STORNIERUNG" } })
|
|
);
|
|
expect(generateInvoicePdf).toHaveBeenCalledWith(
|
|
expect.objectContaining({ invoiceNumber: "RE-2026-0042", cancellation: { number: "ST-2026-0003", issuedAt: loaded.issuedAt } })
|
|
);
|
|
expect(storeInvoicePdf).toHaveBeenCalledOnce();
|
|
expect(sendMail).toHaveBeenCalledWith(
|
|
expect.objectContaining({ to: "[email protected]", attachments: [expect.objectContaining({ filename: `ST-${year}-0003.pdf` })] })
|
|
);
|
|
expect(sendInvoiceCopy).toHaveBeenCalledWith(expect.objectContaining({ title: "Stornorechnung", orderNumber: "WH-2026-0042" }));
|
|
});
|
|
|
|
test("zweiter Ausloeser findet die vorhandene Stornorechnung, ohne erneut zu verschicken", async () => {
|
|
db.invoice.findUnique.mockResolvedValue({ id: "inv1", totalCents: 5375, cancellation: { number: "ST-2026-0001" } });
|
|
|
|
expect(await issueCancellationInvoice("order1", "ERSTATTUNG")).toEqual({ number: "ST-2026-0001", created: false });
|
|
expect(db.$transaction).not.toHaveBeenCalled();
|
|
expect(sendMail).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("gleichzeitig angelegt: die zweite Transaktion scheitert eindeutig und liefert die erste", async () => {
|
|
// Arrange: die Datenbank lehnt die zweite Stornorechnung zur selben Rechnung ab.
|
|
tx.cancellationInvoice.create.mockRejectedValue(Object.assign(new Error("Unique constraint failed"), { code: "P2002" }));
|
|
db.cancellationInvoice.findUnique.mockResolvedValue({ number: "ST-2026-0001" });
|
|
|
|
// Act
|
|
const result = await issueCancellationInvoice("order1", "ERSTATTUNG");
|
|
|
|
// Assert
|
|
expect(result).toEqual({ number: "ST-2026-0001", created: false });
|
|
expect(sendMail).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("ein Fehler beim Verschicken laesst die Stornorechnung bestehen", async () => {
|
|
generateInvoicePdf.mockRejectedValueOnce(new Error("Schrift fehlt"));
|
|
const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
|
|
const result = await issueCancellationInvoice("order1", "RETOURE");
|
|
|
|
expect(result?.created).toBe(true);
|
|
expect(errors).toHaveBeenCalled();
|
|
errors.mockRestore();
|
|
});
|
|
});
|
|
|
|
test("isCancellationNumber erkennt nur ST-Nummern", () => {
|
|
expect(isCancellationNumber("ST-2026-0001")).toBe(true);
|
|
expect(isCancellationNumber("RE-2026-0001")).toBe(false);
|
|
expect(isCancellationNumber("ST-2026-0001/../x")).toBe(false);
|
|
});
|