From 4d1bcbbdb7acad2b73098c4062a75d0ac205df65 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Mon, 12 Oct 2026 06:19:42 +0200 Subject: [PATCH] feat(invoice): Stornorechnung und Rechnungskopie an Admin-Adresse 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) --- README.md | 19 ++ .../migration.sql | 35 ++++ prisma/schema.prisma | 38 +++- src/app/admin/actions/content.ts | 12 +- src/app/admin/actions/orders.ts | 23 +++ src/app/admin/bestellungen/[id]/page.tsx | 15 +- src/app/admin/retouren/actions.ts | 10 ++ src/app/api/checkout/webhook/route.test.ts | 34 ++++ src/app/api/checkout/webhook/route.ts | 21 ++- .../rechnungen/[invoiceNumber]/pdf/route.ts | 6 +- src/app/api/erp/rechnungen/route.ts | 10 +- src/app/rechnung/[invoiceNumber]/pdf/route.ts | 20 +++ src/lib/cancellation-invoice.test.ts | 125 +++++++++++++ src/lib/cancellation-invoice.ts | 165 ++++++++++++++++++ src/lib/content-registry.ts | 2 + src/lib/email-strings.ts | 20 +++ src/lib/email-templates.ts | 25 +++ src/lib/erp-export.test.ts | 57 +++++- src/lib/erp-export.ts | 55 +++++- src/lib/invoice-mail.test.ts | 56 ++++++ src/lib/invoice-mail.ts | 67 +++++++ src/lib/invoice-number.ts | 10 ++ 22 files changed, 809 insertions(+), 16 deletions(-) create mode 100644 prisma/migrations/20261012010000_cancellation_invoices/migration.sql create mode 100644 src/lib/cancellation-invoice.test.ts create mode 100644 src/lib/cancellation-invoice.ts create mode 100644 src/lib/invoice-mail.test.ts create mode 100644 src/lib/invoice-mail.ts diff --git a/README.md b/README.md index eb13e4d..2d16120 100644 --- a/README.md +++ b/README.md @@ -458,6 +458,25 @@ Vor dem Hochzählen prüft dieselbe Transaktion, ob zur Bestellung bereits eine Vier Stellen halten die Nummern gleich lang und damit sortierbar. Gibt es in einem Jahr mehr als 9999 Rechnungen, wächst die Nummer mit, statt abgeschnitten zu werden. +### E-Rechnung + +Jedes Rechnungs-PDF ist eine hybride E-Rechnung nach **ZUGFeRD 2.x / Factur-X, Profil EN 16931**: ein PDF/A-3b mit eingebettetem `factur-x.xml` (UN/CEFACT CII D16B). Das XML ist der maßgebliche Teil; Buchhaltungsprogramme (ERPNext, DATEV, lexoffice …) lesen es direkt. Erzeugt in `lib/einvoice/cii.ts` (XML) und `lib/einvoice/facturx-pdf.ts` (PDF/A, XMP-Metadaten, Anhang). + +- **Summen:** Netto und Steuer je Satz kommen unverändert aus der Aufstellung des PDFs. Die Positionen tragen ihren Nettoanteil (centgenau verteilt), Versand und Rabatt stehen als Zu- und Abschlag je Satz. Bei Bruttopreisen kann ein Prüfprogramm, das die Steuer aus dem Netto nachrechnet, einen Cent Unterschied melden; EN 16931 lässt das ausdrücklich zu (BR-S-09/BR-CO-17), der Steuerbetrag entspricht dem aus dem Bruttopreis herausgerechneten. +- **Schriften:** PDF/A verlangt eingebettete Schriften. Statt Helvetica/Times werden Liberation Sans/Serif aus `assets/fonts` eingebettet (gleiche Laufweiten, SIL Open Font License); das Dockerfile kopiert das Verzeichnis ins Image. +- **Ohne USt-IdNr.** (Einstellungen → Firma) oder bei Positionen ohne Steuersatz entsteht nur das PDF, mit Warnung im Log. +- Geprüft mit XSD und CEN-Schematron EN 16931 sowie Mustang (veraPDF): PDF/A-3b konform, XML gültig. + +### Rechnungskopie + +Unter Einstellungen → Firma → **„Rechnungskopie an“** lässt sich eine Adresse hinterlegen (z. B. die Buchhaltung). Jede Rechnung und jede Stornorechnung geht dann zusätzlich als eigene Mail mit derselben E-Rechnung dorthin (`lib/invoice-mail.ts`). Leer = keine Kopie. + +### Stornorechnung + +Eine ausgestellte Rechnung wird nie gelöscht oder geändert. Wird eine Bestellung mit Rechnung storniert (Admin), eine Retoure erstattet oder die Zahlung bei Stripe voll erstattet, entsteht **eine** Stornorechnung `ST--` (eigener Nummernkreis, Tabelle `cancellation_counters`) mit allen Positionen der Rechnung, negativen Beträgen auf dem PDF und Bezug auf die Rechnung (XML: Gutschrift 381 mit Verweis). Sie geht als E-Rechnung an die Kundschaft und als Kopie an die hinterlegte Adresse. Laufen Admin-Stornierung und Stripe-Webhook derselben Erstattung gleichzeitig, bleibt es bei einer Stornorechnung (eindeutige `invoiceId`, die zweite Transaktion rollt samt Zähler zurück). Eine Bestellung mit Stornorechnung lässt sich nicht wieder öffnen. Teilerstattungen bei Stripe erzeugen keine Stornorechnung (Hinweis im Log). + +ERPNext holt Stornorechnungen über `GET /api/erp/rechnungen?seit=…&art=storno` und bucht sie als Rechnungskorrektur gegen die Ausgangsrechnung; die PDFs liegen unter `/api/erp/rechnungen/ST-…/pdf`. + ## Health-Endpunkte Zwei getrennte Endpunkte, bewusst mit unterschiedlicher Aufgabe: diff --git a/prisma/migrations/20261012010000_cancellation_invoices/migration.sql b/prisma/migrations/20261012010000_cancellation_invoices/migration.sql new file mode 100644 index 0000000..7b2ffdc --- /dev/null +++ b/prisma/migrations/20261012010000_cancellation_invoices/migration.sql @@ -0,0 +1,35 @@ +-- Stornorechnungen: Gegenbeleg mit eigenem Nummernkreis (ST-JJJJ-NNNN) statt +-- Loeschen oder Aendern einer Rechnung. Rein additiv. +-- CreateTable +CREATE TABLE "cancellation_counters" ( + "year" INTEGER NOT NULL, + "lastNumber" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "cancellation_counters_pkey" PRIMARY KEY ("year") +); + +-- CreateTable +CREATE TABLE "cancellation_invoices" ( + "id" TEXT NOT NULL, + "invoiceId" TEXT NOT NULL, + "number" TEXT NOT NULL, + "issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "totalCents" INTEGER NOT NULL, + "reason" TEXT NOT NULL, + "pdfPath" TEXT, + + CONSTRAINT "cancellation_invoices_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "cancellation_invoices_invoiceId_key" ON "cancellation_invoices"("invoiceId"); + +-- CreateIndex +CREATE UNIQUE INDEX "cancellation_invoices_number_key" ON "cancellation_invoices"("number"); + +-- CreateIndex +CREATE INDEX "cancellation_invoices_issuedAt_idx" ON "cancellation_invoices"("issuedAt"); + +-- AddForeignKey +ALTER TABLE "cancellation_invoices" ADD CONSTRAINT "cancellation_invoices_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4b95de4..849f469 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -710,11 +710,47 @@ model Invoice { /// einem oeffentlich abrufbaren Pfad. pdfPath String? - order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + cancellation CancellationInvoice? @@map("invoices") } +/// Eigener fortlaufender Nummernkreis der Stornorechnungen (ST-JJJJ-NNNN), +/// je Kalenderjahr — aus demselben Grund eine Zeile mit Sperre wie +/// InvoiceCounter. +model CancellationCounter { + year Int @id + lastNumber Int @default(0) + + @@map("cancellation_counters") +} + +/// Stornorechnung: hebt eine Rechnung vollstaendig auf. Eine Rechnung wird +/// nie geloescht oder geaendert (§ 14 UStG, GoBD); wird die Bestellung +/// storniert oder voll erstattet, entsteht stattdessen dieser Gegenbeleg mit +/// eigener Nummer und Bezug auf die Rechnung (lib/cancellation-invoice.ts). +/// Hoechstens einer je Rechnung. ERPNext uebernimmt ihn als +/// Rechnungskorrektur, die Ausgangsrechnung steht dort dann auf +/// "Credit Note Issued". +model CancellationInvoice { + id String @id @default(cuid()) + invoiceId String @unique + number String @unique + issuedAt DateTime @default(now()) + /// Betrag der aufgehobenen Rechnung (brutto, positiv). + totalCents Int + /// STORNIERUNG (Admin), RETOURE (Retoure erstattet) oder ERSTATTUNG (volle Erstattung bei Stripe). + reason String + /// Wie Invoice.pdfPath, relativ zu INVOICE_DIR. + pdfPath String? + + invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + + @@index([issuedAt]) + @@map("cancellation_invoices") +} + // --------------------------------------------------------------------------- // Marketing / Kontakt // --------------------------------------------------------------------------- diff --git a/src/app/admin/actions/content.ts b/src/app/admin/actions/content.ts index f576885..fb41d6b 100644 --- a/src/app/admin/actions/content.ts +++ b/src/app/admin/actions/content.ts @@ -9,6 +9,7 @@ import { requireAdmin } from "./_shared"; import { fail, succeed } from "@/lib/flash"; import { normalizeContactEmail } from "@/lib/contact-email"; import { settingsPathForGroup } from "@/lib/settings-tabs"; +import { INVOICE_COPY_KEY } from "@/lib/invoice-mail"; const COMPANY_EMAIL_KEY = "company.email"; @@ -64,7 +65,16 @@ export async function updateSiteContentAction(formData: FormData): Promise "Die Firmen-E-Mail ergibt keine gültige Adresse. Möglich sind z. B. info@wabenhain.com, info [at] wabenhain.com oder info [á] wabenhain.com." ); } - updates.push({ key: def.key, valueDe: def.key === COMPANY_EMAIL_KEY ? valueDe.trim() : valueDe, valueEn: null }); + // Die Rechnungskopie geht an genau diese Adresse: leer (keine Kopie) oder + // eine echte Adresse, sonst verschwaenden die Kopien stillschweigend. + if (def.key === INVOICE_COPY_KEY && valueDe.trim() !== "" && normalizeContactEmail(valueDe) === null) { + return fail( + settingsPathForGroup(group), + "„Rechnungskopie an“ ist keine gültige E-Mail-Adresse. Bitte z. B. buchhaltung@wabenhain.com eintragen oder das Feld leer lassen." + ); + } + const trimmed = def.key === COMPANY_EMAIL_KEY || def.key === INVOICE_COPY_KEY; + updates.push({ key: def.key, valueDe: trimmed ? valueDe.trim() : valueDe, valueEn: null }); } } diff --git a/src/app/admin/actions/orders.ts b/src/app/admin/actions/orders.ts index a9f94c3..c433ff1 100644 --- a/src/app/admin/actions/orders.ts +++ b/src/app/admin/actions/orders.ts @@ -20,6 +20,7 @@ import { isLocale, defaultLocale } from "@/i18n/config"; import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client"; import { requireAdmin, str, strOrNull, fail } from "./_shared"; import { recordOrderReturn, recordOrderSale } from "@/lib/apicore-stock/stock-events"; +import { issueCancellationInvoice } from "@/lib/cancellation-invoice"; import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule"; import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash"; @@ -151,6 +152,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise payment: { select: { status: true, stripePaymentIntentId: true } }, items: { select: { productId: true, quantity: true, productName: true } }, returnRequest: { select: { status: true } }, + invoice: { select: { cancellation: { select: { number: true } } } }, }, }); if (!previous) return null; @@ -159,6 +161,12 @@ export async function updateOrderStatusAction(formData: FormData): Promise // Bestaetigung aus dem Formular (siehe needsConfirmToChangeStatus). Ohne // sie wird nichts veraendert. const reopening = needsConfirmToChangeStatus(previous.status, status); + // Mit Stornorechnung ist die Rechnung aufgehoben und das Geld erstattet; + // ein Wiederoeffnen waere ein Verkauf ohne Rechnung. Dann bitte eine neue + // Bestellung anlegen. + if (reopening && previous.invoice?.cancellation) { + return { ...previous, shipment: null, cancelledNow: false, blocked: "cancellation-invoice" as const }; + } if (reopening && !confirmedReopen) { return { ...previous, shipment: null, cancelledNow: false, blocked: "needs-confirm" as const }; } @@ -278,6 +286,12 @@ export async function updateOrderStatusAction(formData: FormData): Promise if (result.blocked === "changed") { return fail(detailPath, "Die Bestellung wurde zwischenzeitlich geändert. Bitte die Seite neu laden und erneut versuchen."); } + if (result.blocked === "cancellation-invoice") { + return fail( + detailPath, + `Zu dieser Bestellung gibt es die Stornorechnung ${result.invoice?.cancellation?.number}. Sie kann nicht wieder geöffnet werden — bitte eine neue Bestellung anlegen.` + ); + } // undefined = keine E-Mail faellig; false = faellig, aber nicht versendet. let mailSent: boolean | undefined; @@ -307,6 +321,15 @@ export async function updateOrderStatusAction(formData: FormData): Promise stripeCheckoutSessionId: result.stripeCheckoutSessionId, payment: result.payment, }); + // Hatte die Bestellung eine Rechnung, wird sie durch eine + // Stornorechnung aufgehoben (nie geloescht) — auch wenn die Erstattung + // oben scheiterte: verkauft ist dann trotzdem nichts mehr. + try { + await issueCancellationInvoice(orderId, "STORNIERUNG"); + } catch (error: unknown) { + console.error(`[order-cancel] Stornorechnung fuer ${result.orderNumber} fehlgeschlagen:`, error); + stornoWarnings.push("Die Stornorechnung konnte nicht erstellt werden — bitte die Stornierung erneut auslösen oder den Support informieren."); + } if (stornoWarnings.length > 0) { await logAdminAction(admin, { diff --git a/src/app/admin/bestellungen/[id]/page.tsx b/src/app/admin/bestellungen/[id]/page.tsx index 20b80d7..fc013e7 100644 --- a/src/app/admin/bestellungen/[id]/page.tsx +++ b/src/app/admin/bestellungen/[id]/page.tsx @@ -29,7 +29,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best payment: true, shipment: true, pickup: true, - invoice: true, + invoice: { include: { cancellation: { select: { number: true } } } }, customer: { select: { email: true, firstName: true, lastName: true } }, discountCode: { select: { code: true } }, }, @@ -279,6 +279,19 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best > {t.adminOrderDetailPage.downloadPdf} + {order.invoice.cancellation && ( + <> + + {t.invoicePage.cancellationTitle} {order.invoice.cancellation.number} + + + {t.adminOrderDetailPage.downloadPdf} + + + )} ) : (

{t.adminOrderDetailPage.noInvoiceYet}

diff --git a/src/app/admin/retouren/actions.ts b/src/app/admin/retouren/actions.ts index 65155df..9e68048 100644 --- a/src/app/admin/retouren/actions.ts +++ b/src/app/admin/retouren/actions.ts @@ -11,6 +11,7 @@ import { returnAcceptedEmail, returnRejectedEmail, returnRefundedEmail } from "@ import { restockOrderItems } from "@/lib/inventory"; import { recordOrderReturn } from "@/lib/apicore-stock/stock-events"; import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule"; +import { issueCancellationInvoice } from "@/lib/cancellation-invoice"; import { isLocale, defaultLocale } from "@/i18n/config"; function str(formData: FormData, key: string): string { @@ -122,6 +123,15 @@ export async function refundReturnRequestAction(formData: FormData): Promise item.productId)); + // Voll erstattet: die Rechnung wird durch eine Stornorechnung aufgehoben. + // Ein Fehler hier nimmt die Erstattung nicht zurueck; die Stornorechnung + // holt spaetestens der Stripe-Webhook der Erstattung nach. + try { + await issueCancellationInvoice(returnRequest.orderId, "RETOURE"); + } catch (error: unknown) { + console.error(`[return-refund] Stornorechnung fuer ${returnRequest.order.orderNumber} fehlgeschlagen:`, error); + } + await logAdminAction(admin, { action: "return_request.refund", entityType: "ReturnRequest", diff --git a/src/app/api/checkout/webhook/route.test.ts b/src/app/api/checkout/webhook/route.test.ts index 17ee0f9..d3aafbf 100644 --- a/src/app/api/checkout/webhook/route.test.ts +++ b/src/app/api/checkout/webhook/route.test.ts @@ -58,6 +58,15 @@ vi.mock("@/lib/invoice-storage", () => ({ storeInvoicePdf: vi.fn(async () => undefined), })); +// Kopie an die Admin-Adresse und Stornorechnung haben eigene Tests; hier +// zaehlt, dass der Webhook sie anstoesst. +const sendInvoiceCopy = vi.fn(async () => true); +vi.mock("@/lib/invoice-mail", () => ({ sendInvoiceCopy: (...args: unknown[]) => sendInvoiceCopy(...(args as [])) })); +const issueCancellationInvoice = vi.fn(async () => ({ number: "ST-2026-0001", created: true })); +vi.mock("@/lib/cancellation-invoice", () => ({ + issueCancellationInvoice: (...args: unknown[]) => issueCancellationInvoice(...(args as [])), +})); + /** * Minimaler Prisma-Ersatz. `$transaction` reicht dieselbe Instanz als `tx` * weiter — der Handler verlaesst sich auf die Atomaritaet der Datenbank, und @@ -219,6 +228,10 @@ describe("Stripe-Webhook", () => { // 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"); + // Kopie derselben E-Rechnung an die im Admin hinterlegte Adresse. + expect(sendInvoiceCopy).toHaveBeenCalledWith( + expect.objectContaining({ title: "Rechnung", number: "RE-2026-0001", customerEmail: paidOrder.email }) + ); }); test("zaehlt einen Gutschein bei mehrfach zugestelltem Ereignis nur einmal", async () => { @@ -352,6 +365,26 @@ describe("Stripe-Webhook", () => { }); }); + test("hebt die Rechnung bei vollstaendiger Erstattung per Stornorechnung auf", async () => { + // Auch wenn die Zahlung schon REFUNDED ist (Erstattung aus dem Admin): + // der Aufruf ist idempotent und findet die Stornorechnung nur wieder. + db.payment.findFirst.mockResolvedValue({ + id: "pay_1", + status: "REFUNDED", + orderId: ORDER_ID, + 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(issueCancellationInvoice).toHaveBeenCalledWith(ORDER_ID, "ERSTATTUNG"); + expect(db.payment.update).not.toHaveBeenCalled(); + }); + test("laesst den Zahlungsstatus bei einer TEILerstattung unveraendert", async () => { // Der Restbetrag ist weiterhin eingezogen — auf "REFUNDED" gesetzt // wuerde eine Auswertung den ganzen Umsatz abschreiben. @@ -376,6 +409,7 @@ describe("Stripe-Webhook", () => { expect(db.payment.update).not.toHaveBeenCalled(); expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Teilerstattung")); + expect(issueCancellationInvoice).not.toHaveBeenCalled(); }); test("haengt eine Rueckbuchung an die Notizen der Bestellung an, ohne bestehende zu verlieren", async () => { diff --git a/src/app/api/checkout/webhook/route.ts b/src/app/api/checkout/webhook/route.ts index 2b87d19..97e0d74 100644 --- a/src/app/api/checkout/webhook/route.ts +++ b/src/app/api/checkout/webhook/route.ts @@ -11,6 +11,8 @@ import { invoiceRelativePath, storeInvoicePdf } from "@/lib/invoice-storage"; import { formatInvoiceNumber } from "@/lib/invoice-number"; import { isLocale, defaultLocale } from "@/i18n/config"; import type Stripe from "stripe"; +import { sendInvoiceCopy } from "@/lib/invoice-mail"; +import { issueCancellationInvoice } from "@/lib/cancellation-invoice"; import { recordOrderSale } from "@/lib/apicore-stock/stock-events"; import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule"; @@ -174,7 +176,7 @@ async function recordRefund(charge: Stripe.Charge): Promise { const payment = await prisma.payment.findFirst({ where: { stripePaymentIntentId: paymentIntentId }, - select: { id: true, status: true, order: { select: { orderNumber: true } } }, + select: { id: true, status: true, orderId: true, order: { select: { orderNumber: true } } }, }); if (!payment) { console.warn( @@ -194,6 +196,12 @@ async function recordRefund(charge: Stripe.Charge): Promise { return; } + // Volle Erstattung: die Rechnung ist damit aufgehoben, es entsteht die + // Stornorechnung. Auch wenn die Zahlung schon auf REFUNDED steht — dann kam + // die Erstattung aus der Stornierung im Admin oder der Retoure, und dieser + // Aufruf findet die dort angelegte Stornorechnung nur wieder. + await issueCancellationInvoice(payment.orderId, "ERSTATTUNG"); + if (payment.status === "REFUNDED") return; await prisma.payment.update({ where: { id: payment.id }, data: { status: "REFUNDED" } }); @@ -456,6 +464,17 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise { } : {}), }); + // Kopie der E-Rechnung an die im Admin hinterlegte Adresse (lib/invoice-mail.ts). + if (invoicePdf) { + await sendInvoiceCopy({ + title: "Rechnung", + number: invoice.invoiceNumber, + orderNumber: order.orderNumber, + customerName: `${order.firstName} ${order.lastName}`.trim(), + customerEmail: order.email, + pdf: invoicePdf, + }); + } } catch (error: unknown) { // Laut sichtbar machen (statt wie zuvor unbehandelt bis in den // Next.js-Request-Handler durchzureichen) und weiterwerfen: der 500er diff --git a/src/app/api/erp/rechnungen/[invoiceNumber]/pdf/route.ts b/src/app/api/erp/rechnungen/[invoiceNumber]/pdf/route.ts index c97f65d..edae7a9 100644 --- a/src/app/api/erp/rechnungen/[invoiceNumber]/pdf/route.ts +++ b/src/app/api/erp/rechnungen/[invoiceNumber]/pdf/route.ts @@ -1,11 +1,11 @@ import { NextResponse } from "next/server"; -import { erpExportAuthorized, erpInvoicePdf, isInvoiceNumber } from "@/lib/erp-export"; +import { erpExportAuthorized, erpInvoicePdf, isErpDocumentNumber } from "@/lib/erp-export"; import { rateLimit, getClientIp } from "@/lib/rate-limit"; const FAILED_ATTEMPTS_PER_WINDOW = 20; const FAILED_WINDOW_MS = 15 * 60 * 1000; -/** Rechnungs-PDF fuer ERPNext (Anhang an der Ausgangsrechnung). Nur mit ERP_EXPORT_TOKEN. */ +/** Rechnungs- oder Stornorechnungs-PDF fuer ERPNext (Anhang am Beleg). Nur mit ERP_EXPORT_TOKEN. */ export async function GET(request: Request, { params }: { params: Promise<{ invoiceNumber: string }> }) { if (!erpExportAuthorized(request.headers.get("authorization"))) { const ip = await getClientIp(); @@ -13,7 +13,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ invo return NextResponse.json({ error: "Nicht berechtigt." }, { status }); } const { invoiceNumber } = await params; - const pdf = isInvoiceNumber(invoiceNumber) ? await erpInvoicePdf(invoiceNumber) : null; + const pdf = isErpDocumentNumber(invoiceNumber) ? await erpInvoicePdf(invoiceNumber) : null; if (!pdf) { return NextResponse.json({ error: "Rechnung nicht gefunden." }, { status: 404 }); } diff --git a/src/app/api/erp/rechnungen/route.ts b/src/app/api/erp/rechnungen/route.ts index 99453d5..b8bbd6d 100644 --- a/src/app/api/erp/rechnungen/route.ts +++ b/src/app/api/erp/rechnungen/route.ts @@ -1,12 +1,13 @@ import { NextResponse } from "next/server"; -import { erpExportAuthorized, listErpInvoices, parseErpQuery } from "@/lib/erp-export"; +import { erpExportAuthorized, listErpCancellations, listErpInvoices, parseErpQuery } from "@/lib/erp-export"; import { rateLimit, getClientIp } from "@/lib/rate-limit"; const FAILED_ATTEMPTS_PER_WINDOW = 20; const FAILED_WINDOW_MS = 15 * 60 * 1000; /** - * Rechnungen fuer ERPNext, aelteste zuerst: GET /api/erp/rechnungen?seit=&limit=<1-200>. + * Rechnungen fuer ERPNext, aelteste zuerst: GET /api/erp/rechnungen?seit=&limit=<1-200>, + * Stornorechnungen mit &art=storno. * Nur lesend und nur mit ERP_EXPORT_TOKEN, siehe lib/erp-export.ts. */ export async function GET(request: Request) { @@ -19,7 +20,10 @@ export async function GET(request: Request) { if (!query) { return NextResponse.json({ error: "seit (ISO-Zeitpunkt) und limit (1-200) pruefen." }, { status: 400 }); } - const invoices = await listErpInvoices(query.since, query.limit); + const invoices = + query.kind === "storno" + ? await listErpCancellations(query.since, query.limit) + : await listErpInvoices(query.since, query.limit); return NextResponse.json( { invoices, hasMore: invoices.length === query.limit }, { headers: { "Cache-Control": "no-store" } } diff --git a/src/app/rechnung/[invoiceNumber]/pdf/route.ts b/src/app/rechnung/[invoiceNumber]/pdf/route.ts index 7c27cf3..9e6604c 100644 --- a/src/app/rechnung/[invoiceNumber]/pdf/route.ts +++ b/src/app/rechnung/[invoiceNumber]/pdf/route.ts @@ -3,6 +3,8 @@ import { getCurrentCustomer } from "@/lib/auth"; import { getInvoiceForCustomer, getInvoiceByNumber } from "@/lib/account-data"; import { generateInvoicePdf } from "@/lib/invoice-pdf"; import { readStoredInvoicePdf } from "@/lib/invoice-storage"; +import { cancellationPdf, isCancellationNumber } from "@/lib/cancellation-invoice"; +import { prisma } from "@/lib/prisma"; import { isLocale, defaultLocale } from "@/i18n/config"; export async function GET( @@ -15,6 +17,24 @@ export async function GET( return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 }); } + // Stornorechnung (ST-…): dieselbe Besitzpruefung ueber die Bestellung der + // aufgehobenen Rechnung, dann das abgelegte bzw. neu erzeugte PDF. + if (isCancellationNumber(invoiceNumber)) { + const owned = + customer.role === "ADMIN" || + (await prisma.cancellationInvoice.count({ where: { number: invoiceNumber, invoice: { order: { customerId: customer.id } } } })) > 0; + const pdf = owned ? await cancellationPdf(invoiceNumber) : null; + if (!pdf) { + return NextResponse.json({ error: "Rechnung nicht gefunden." }, { status: 404 }); + } + return new NextResponse(new Uint8Array(pdf), { + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `attachment; filename="${invoiceNumber}.pdf"`, + }, + }); + } + const invoice = customer.role === "ADMIN" ? await getInvoiceByNumber(invoiceNumber) diff --git a/src/lib/cancellation-invoice.test.ts b/src/lib/cancellation-invoice.test.ts new file mode 100644 index 0000000..4af43ed --- /dev/null +++ b/src/lib/cancellation-invoice.test.ts @@ -0,0 +1,125 @@ +/** + * 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) => 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: "

x

", 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: "erika@example.org", + 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: "erika@example.org", 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); +}); diff --git a/src/lib/cancellation-invoice.ts b/src/lib/cancellation-invoice.ts new file mode 100644 index 0000000..e6e8dff --- /dev/null +++ b/src/lib/cancellation-invoice.ts @@ -0,0 +1,165 @@ +import { prisma } from "@/lib/prisma"; +import { formatCancellationNumber } from "@/lib/invoice-number"; +import { generateInvoicePdf } from "@/lib/invoice-pdf"; +import { invoiceRelativePath, readStoredInvoicePdf, storeInvoicePdf } from "@/lib/invoice-storage"; +import { isUniqueConstraintError } from "@/lib/harvest-slug"; +import { sendMail } from "@/lib/mail"; +import { cancellationInvoiceEmail } from "@/lib/email-templates"; +import { sendInvoiceCopy } from "@/lib/invoice-mail"; +import { isLocale, defaultLocale } from "@/i18n/config"; + +/** + * Stornorechnung: der Gegenbeleg, wenn eine Bestellung mit Rechnung + * storniert oder voll erstattet wird. + * + * Eine ausgestellte Rechnung wird nie geloescht oder geaendert (§ 14 UStG, + * GoBD). Stattdessen entsteht EINE Stornorechnung je Rechnung, mit eigener + * fortlaufender Nummer (ST-JJJJ-NNNN), allen Positionen der Rechnung und + * Bezug auf sie. Sie geht als E-Rechnung an die Kundschaft und als Kopie an + * die Admin-Adresse; ERPNext holt sie ueber den ERP-Export ab und bucht sie + * als Rechnungskorrektur gegen die Ausgangsrechnung. + * + * Ausloeser (alle idempotent ueber denselben Weg): + * - Stornierung im Admin (STORNIERUNG) + * - Erstattung einer Retoure (RETOURE) + * - volle Erstattung direkt bei Stripe (ERSTATTUNG, Webhook charge.refunded) + * Eine Stornierung im Admin loest selbst eine Stripe-Erstattung aus, deren + * Webhook gleichzeitig hier ankommen kann. Beide Wege fuehren zu derselben + * einen Stornorechnung: die zweite scheitert am eindeutigen invoiceId und + * rollt samt Zaehler zurueck, es entsteht also auch keine Nummernluecke. + */ + +export type CancellationReason = "STORNIERUNG" | "RETOURE" | "ERSTATTUNG"; + +export interface CancellationResult { + number: string; + /** false: es gab sie schon (zweiter Ausloeser derselben Erstattung). */ + created: boolean; +} + +const CANCELLATION_NUMBER_PATTERN = /^ST-\d{4}-\d{4,}$/; + +export function isCancellationNumber(value: string): boolean { + return CANCELLATION_NUMBER_PATTERN.test(value); +} + +/** + * Legt die Stornorechnung zur Rechnung der Bestellung an und verschickt sie. + * null, wenn die Bestellung keine Rechnung hat (nie bezahlt) — dann gibt es + * nichts aufzuheben. + */ +export async function issueCancellationInvoice(orderId: string, reason: CancellationReason): Promise { + const invoice = await prisma.invoice.findUnique({ + where: { orderId }, + select: { id: true, totalCents: true, cancellation: { select: { number: true } } }, + }); + if (!invoice) return null; + if (invoice.cancellation) return { number: invoice.cancellation.number, created: false }; + + let number: string; + try { + number = await prisma.$transaction(async (tx) => { + const year = new Date().getFullYear(); + const counter = await tx.cancellationCounter.upsert({ + where: { year }, + create: { year, lastNumber: 1 }, + update: { lastNumber: { increment: 1 } }, + select: { lastNumber: true }, + }); + const created = await tx.cancellationInvoice.create({ + data: { + invoiceId: invoice.id, + number: formatCancellationNumber(year, counter.lastNumber), + totalCents: invoice.totalCents, + reason, + }, + select: { number: true }, + }); + return created.number; + }); + } catch (error: unknown) { + if (!isUniqueConstraintError(error)) throw error; + const existing = await prisma.cancellationInvoice.findUnique({ where: { invoiceId: invoice.id }, select: { number: true } }); + if (!existing) throw error; + return { number: existing.number, created: false }; + } + + await deliverCancellationInvoice(number); + return { number, created: true }; +} + +async function loadCancellation(number: string) { + return prisma.cancellationInvoice.findUnique({ + where: { number }, + include: { invoice: { include: { order: { include: { items: true, discountCode: { select: { code: true } } } } } } }, + }); +} + +type LoadedCancellation = NonNullable>>; + +function renderCancellationPdf(cancellation: LoadedCancellation): Promise { + const { invoice } = cancellation; + return generateInvoicePdf({ + locale: isLocale(invoice.order.locale) ? invoice.order.locale : defaultLocale, + invoiceNumber: invoice.invoiceNumber, + issuedAt: invoice.issuedAt, + totalCents: invoice.totalCents, + order: invoice.order, + cancellation: { number: cancellation.number, issuedAt: cancellation.issuedAt }, + }); +} + +/** + * PDF erzeugen und ablegen, an die Kundschaft und als Kopie verschicken. + * Wirft nicht: die Stornorechnung steht bereits fest, und ihr PDF laesst sich + * jederzeit ueber cancellationPdf() neu erzeugen. Ein Fehler hier darf die + * Stornierung nicht als gescheitert erscheinen lassen. + */ +async function deliverCancellationInvoice(number: string): Promise { + try { + const cancellation = await loadCancellation(number); + if (!cancellation) return; + const { order } = cancellation.invoice; + const pdf = await renderCancellationPdf(cancellation); + try { + const relativePath = invoiceRelativePath({ + year: cancellation.issuedAt.getFullYear(), + lastName: order.lastName, + firstName: order.firstName, + invoiceNumber: number, + }); + await storeInvoicePdf(relativePath, pdf); + await prisma.cancellationInvoice.update({ where: { number }, data: { pdfPath: relativePath } }); + } catch (error: unknown) { + console.error(`[cancellation] ${number} konnte nicht abgelegt werden:`, error); + } + + const attachment = { filename: `${number}.pdf`, content: pdf, contentType: "application/pdf" }; + const { subject, html, text } = await cancellationInvoiceEmail({ + locale: isLocale(order.locale) ? order.locale : defaultLocale, + orderNumber: order.orderNumber, + firstName: order.firstName, + cancellationNumber: number, + invoiceNumber: cancellation.invoice.invoiceNumber, + }); + await sendMail({ to: order.email, subject, html, text, attachments: [attachment] }); + await sendInvoiceCopy({ + title: "Stornorechnung", + number, + orderNumber: order.orderNumber, + customerName: `${order.firstName} ${order.lastName}`.trim(), + customerEmail: order.email, + pdf, + }); + } catch (error: unknown) { + console.error(`[cancellation] ${number} konnte nicht erzeugt oder verschickt werden:`, error); + } +} + +/** Das PDF der Stornorechnung: abgelegt, sonst neu erzeugt. null, wenn es die Nummer nicht gibt. */ +export async function cancellationPdf(number: string): Promise { + const cancellation = await loadCancellation(number); + if (!cancellation) return null; + const stored = cancellation.pdfPath ? await readStoredInvoicePdf(cancellation.pdfPath) : null; + return stored ?? (await renderCancellationPdf(cancellation)); +} diff --git a/src/lib/content-registry.ts b/src/lib/content-registry.ts index f25bfe3..db740a7 100644 --- a/src/lib/content-registry.ts +++ b/src/lib/content-registry.ts @@ -74,6 +74,8 @@ export const CONTENT_REGISTRY: ContentFieldDef[] = [ { key: "company.street", group: "company", labelDe: "Straße", labelEn: "Street", type: "text", localized: false, getDefault: () => siteConfig.address.street }, { key: "company.zip", group: "company", labelDe: "PLZ", labelEn: "ZIP", type: "text", localized: false, getDefault: () => siteConfig.address.zip }, { key: "company.city", group: "company", labelDe: "Ort", labelEn: "City", type: "text", localized: false, getDefault: () => siteConfig.address.city }, + // Kopie jeder Rechnung und Stornorechnung (lib/invoice-mail.ts). Leer = keine Kopie. + { key: "invoice.copyEmail", group: "company", labelDe: "Rechnungskopie an (E-Mail-Adresse; jede Rechnung und Stornorechnung geht als E-Rechnung zusätzlich an diese Adresse, leer = keine Kopie)", labelEn: "Invoice copy to (email address; every invoice and cancellation invoice is also sent as an e-invoice to this address, empty = no copy)", type: "text", localized: false, getDefault: () => "" }, // --- Liefergebiet --- // Steuert dreierlei zugleich: die Laenderauswahl im Checkout, die diff --git a/src/lib/email-strings.ts b/src/lib/email-strings.ts index c92d3f2..fe0b5dc 100644 --- a/src/lib/email-strings.ts +++ b/src/lib/email-strings.ts @@ -101,6 +101,12 @@ interface EmailStrings { button: string; subject: (orderNumber: string) => string; }; + cancellationInvoice: { + preheader: (orderNumber: string) => string; + heading: (firstName: string) => string; + intro: (cancellationNumber: string, invoiceNumber: string, orderNumber: string) => string; + subject: (cancellationNumber: string, orderNumber: string) => string; + }; } const de: EmailStrings = { @@ -202,6 +208,13 @@ const de: EmailStrings = { button: "ERNEUT BESTELLEN", subject: (orderNumber) => `Zahlung zu Bestellung ${orderNumber} fehlgeschlagen — Wabenhain`, }, + cancellationInvoice: { + preheader: (orderNumber) => `Stornorechnung zu deiner Bestellung ${orderNumber}.`, + heading: (firstName) => `Deine Stornorechnung, ${firstName}`, + intro: (cancellationNumber, invoiceNumber, orderNumber) => + `Anbei findest du die Stornorechnung ${cancellationNumber}. Sie hebt die Rechnung ${invoiceNumber} zu deiner Bestellung ${orderNumber} vollständig auf. Bitte lege sie zu deinen Unterlagen.`, + subject: (cancellationNumber, orderNumber) => `Stornorechnung ${cancellationNumber} zu Bestellung ${orderNumber} — Wabenhain`, + }, }; const en: EmailStrings = { @@ -303,6 +316,13 @@ const en: EmailStrings = { button: "ORDER AGAIN", subject: (orderNumber) => `Payment for order ${orderNumber} failed — Wabenhain`, }, + cancellationInvoice: { + preheader: (orderNumber) => `Cancellation invoice for your order ${orderNumber}.`, + heading: (firstName) => `Your cancellation invoice, ${firstName}`, + intro: (cancellationNumber, invoiceNumber, orderNumber) => + `Please find attached the cancellation invoice ${cancellationNumber}. It fully reverses invoice ${invoiceNumber} for your order ${orderNumber}. Please keep it with your records.`, + subject: (cancellationNumber, orderNumber) => `Cancellation invoice ${cancellationNumber} for order ${orderNumber} — Wabenhain`, + }, }; const emailStrings: Record = { de, en }; diff --git a/src/lib/email-templates.ts b/src/lib/email-templates.ts index f4c4dc7..c14e8fb 100644 --- a/src/lib/email-templates.ts +++ b/src/lib/email-templates.ts @@ -375,6 +375,31 @@ export async function returnRefundedEmail(order: OrderStatusUpdateInput): Promis }; } +interface CancellationInvoiceEmailInput extends OrderStatusUpdateInput { + cancellationNumber: string; + invoiceNumber: string; +} + +/** Begleitmail zur Stornorechnung (PDF als Anhang, siehe lib/cancellation-invoice.ts). */ +export async function cancellationInvoiceEmail(input: CancellationInvoiceEmailInput): Promise { + const s = getEmailStrings(input.locale).cancellationInvoice; + const intro = (escape: (value: string) => string) => + s.intro(escape(input.cancellationNumber), escape(input.invoiceNumber), escape(input.orderNumber)); + const html = await emailLayout( + input.locale, + s.preheader(input.orderNumber), + ` +

${s.heading(escapeHtml(input.firstName))}

+

${intro(escapeHtml)}

+ ` + ); + return { + subject: s.subject(input.cancellationNumber, input.orderNumber), + html, + text: intro((value) => value).replace(/<\/?strong>/g, ""), + }; +} + export async function paymentFailedEmail(order: OrderStatusUpdateInput): Promise { const s = getEmailStrings(order.locale).paymentFailed; // Der Text (retryNote) fordert bereits zur erneuten Bestellung auf — ohne diff --git a/src/lib/erp-export.test.ts b/src/lib/erp-export.test.ts index 8754014..32f134a 100644 --- a/src/lib/erp-export.test.ts +++ b/src/lib/erp-export.test.ts @@ -8,14 +8,22 @@ import { describe, test, expect, vi, beforeEach } from "vitest"; const findMany = vi.fn(); -vi.mock("@/lib/prisma", () => ({ prisma: { invoice: { findMany: (...args: unknown[]) => findMany(...args) } } })); +const findCancellations = vi.fn(); +vi.mock("@/lib/prisma", () => ({ + prisma: { + invoice: { findMany: (...args: unknown[]) => findMany(...args) }, + cancellationInvoice: { findMany: (...args: unknown[]) => findCancellations(...args) }, + }, +})); vi.mock("@/lib/invoice-pdf", () => ({ generateInvoicePdf: vi.fn(async () => Buffer.from("pdf")) })); vi.mock("@/lib/invoice-storage", () => ({ readStoredInvoicePdf: vi.fn(async () => null) })); import { erpExportAuthorized, erpVatLines, + isErpDocumentNumber, isInvoiceNumber, + listErpCancellations, listErpInvoices, parseErpQuery, toErpInvoice, @@ -42,18 +50,29 @@ describe("erpExportAuthorized", () => { describe("parseErpQuery und isInvoiceNumber", () => { test("verlangt einen gueltigen Zeitpunkt und begrenzt das Limit", () => { - expect(parseErpQuery(new URLSearchParams("seit=2026-01-01T00:00:00Z&limit=50"))).toEqual({ since: new Date("2026-01-01T00:00:00Z"), limit: 50 }); + expect(parseErpQuery(new URLSearchParams("seit=2026-01-01T00:00:00Z&limit=50"))).toEqual({ + since: new Date("2026-01-01T00:00:00Z"), + limit: 50, + kind: "rechnung", + }); expect(parseErpQuery(new URLSearchParams("seit=2026-01-01"))?.limit).toBe(200); expect(parseErpQuery(new URLSearchParams("seit=gestern"))).toBeNull(); expect(parseErpQuery(new URLSearchParams("seit=2026-01-01&limit=0"))).toBeNull(); expect(parseErpQuery(new URLSearchParams("seit=2026-01-01&limit=201"))).toBeNull(); }); + test("liefert Stornorechnungen nur mit art=storno", () => { + expect(parseErpQuery(new URLSearchParams("seit=2026-01-01&art=storno"))?.kind).toBe("storno"); + expect(parseErpQuery(new URLSearchParams("seit=2026-01-01&art=alles"))).toBeNull(); + }); + test("erkennt nur Rechnungsnummern des Shops", () => { expect(isInvoiceNumber("RE-2026-0001")).toBe(true); expect(isInvoiceNumber("RE-2026-12345")).toBe(true); expect(isInvoiceNumber("../RE-2026-0001")).toBe(false); expect(isInvoiceNumber("RE-26-1")).toBe(false); + expect(isErpDocumentNumber("ST-2026-0001")).toBe(true); + expect(isErpDocumentNumber("ST-2026-0001.pdf")).toBe(false); }); }); @@ -172,5 +191,39 @@ describe("listErpInvoices", () => { expect(findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { issuedAt: { gte: since } }, orderBy: [{ issuedAt: "asc" }, { id: "asc" }], take: 50 }) ); + expect(result[0]).toMatchObject({ type: "invoice", cancelledBy: null }); + }); +}); + +describe("listErpCancellations", () => { + beforeEach(() => findCancellations.mockReset()); + + test("liefert die Stornorechnung mit Positionen der Rechnung und Bezug", async () => { + // Arrange + findCancellations.mockResolvedValue([ + { + id: "st1", + number: "ST-2026-0002", + issuedAt: new Date("2026-10-05T08:00:00Z"), + totalCents: 3620, + reason: "STORNIERUNG", + invoice: { ...record, cancellation: { number: "ST-2026-0002" } }, + }, + ]); + + // Act + const [erp] = await listErpCancellations(new Date("2026-10-01T00:00:00Z"), 10); + + // Assert + expect(erp).toMatchObject({ + type: "cancellation", + invoiceNumber: "ST-2026-0002", + issuedAt: "2026-10-05T08:00:00.000Z", + totalCents: 3620, + reason: "STORNIERUNG", + cancels: { invoiceNumber: "RE-2026-0007", issuedAt: "2026-10-01T10:00:00.000Z" }, + }); + expect(erp.items).toHaveLength(2); + expect(erp.vat?.reduce((sum, line) => sum + line.grossCents, 0)).toBe(3620); }); }); diff --git a/src/lib/erp-export.ts b/src/lib/erp-export.ts index c9d761f..0b67628 100644 --- a/src/lib/erp-export.ts +++ b/src/lib/erp-export.ts @@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma"; import { generateInvoicePdf } from "@/lib/invoice-pdf"; import { readStoredInvoicePdf } from "@/lib/invoice-storage"; import { vatLinesWithShares, type VatLineWithShares } from "@/lib/vat"; +import { cancellationPdf, isCancellationNumber } from "@/lib/cancellation-invoice"; import { isLocale, defaultLocale } from "@/i18n/config"; /** @@ -37,12 +38,27 @@ export function isInvoiceNumber(value: string): boolean { return INVOICE_NUMBER_PATTERN.test(value); } -/** `seit` (ISO-Zeitpunkt, einschliesslich) und `limit` (1 bis MAX_PAGE_SIZE); null bei ungueltigen Werten. */ -export function parseErpQuery(params: URLSearchParams): { since: Date; limit: number } | null { +/** Rechnung (RE-…) oder Stornorechnung (ST-…). */ +export function isErpDocumentNumber(value: string): boolean { + return isInvoiceNumber(value) || isCancellationNumber(value); +} + +/** Welche Belege: "rechnung" (Vorgabe, wie bisher) oder "storno" (Stornorechnungen). */ +export type ErpDocumentKind = "rechnung" | "storno"; + +/** + * `seit` (ISO-Zeitpunkt, einschliesslich), `limit` (1 bis MAX_PAGE_SIZE) und + * `art` (rechnung/storno); null bei ungueltigen Werten. Stornorechnungen + * kommen bewusst nur auf ausdrueckliche Nachfrage: ein aelterer Import, der + * sie nicht kennt, saehe sie sonst als weitere Rechnung. + */ +export function parseErpQuery(params: URLSearchParams): { since: Date; limit: number; kind: ErpDocumentKind } | null { const since = new Date(params.get("seit") ?? ""); const limit = Number(params.get("limit") ?? MAX_PAGE_SIZE); + const kind = params.get("art") ?? "rechnung"; if (Number.isNaN(since.getTime()) || !Number.isInteger(limit) || limit < 1 || limit > MAX_PAGE_SIZE) return null; - return { since, limit }; + if (kind !== "rechnung" && kind !== "storno") return null; + return { since, limit, kind }; } const invoiceInclude = { @@ -53,6 +69,7 @@ const invoiceInclude = { discountCode: { select: { code: true } }, }, }, + cancellation: { select: { number: true } }, } satisfies Prisma.InvoiceInclude; type ErpInvoiceRecord = Prisma.InvoiceGetPayload<{ include: typeof invoiceInclude }>; @@ -66,9 +83,12 @@ export const erpVatLines = vatLinesWithShares; export function toErpInvoice(invoice: ErpInvoiceRecord) { const { order } = invoice; return { + type: "invoice" as "invoice" | "cancellation", invoiceNumber: invoice.invoiceNumber, issuedAt: invoice.issuedAt.toISOString(), totalCents: invoice.totalCents, + /** Nummer der Stornorechnung, falls die Rechnung aufgehoben ist. */ + cancelledBy: invoice.cancellation?.number ?? null, order: { id: order.id, orderNumber: order.orderNumber, @@ -121,11 +141,38 @@ export async function listErpInvoices(since: Date, limit: number): Promise ({ + ...toErpInvoice(cancellation.invoice), + type: "cancellation" as const, + invoiceNumber: cancellation.number, + issuedAt: cancellation.issuedAt.toISOString(), + totalCents: cancellation.totalCents, + cancelledBy: null, + cancels: { invoiceNumber: cancellation.invoice.invoiceNumber, issuedAt: cancellation.invoice.issuedAt.toISOString() }, + reason: cancellation.reason, + })); +} + /** * Das PDF so, wie die Kundschaft es bekommen hat: zuerst das abgelegte - * Dokument, nur ohne Ablage neu erzeugt (wie /rechnung//pdf). + * Dokument, nur ohne Ablage neu erzeugt (wie /rechnung//pdf). Gilt + * fuer Rechnungen (RE-…) und Stornorechnungen (ST-…). */ export async function erpInvoicePdf(invoiceNumber: string): Promise { + if (isCancellationNumber(invoiceNumber)) return cancellationPdf(invoiceNumber); const invoice = await prisma.invoice.findUnique({ where: { invoiceNumber }, include: { order: { include: { items: true, discountCode: { select: { code: true } } } } }, diff --git a/src/lib/invoice-mail.test.ts b/src/lib/invoice-mail.test.ts new file mode 100644 index 0000000..edacdc2 --- /dev/null +++ b/src/lib/invoice-mail.test.ts @@ -0,0 +1,56 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; + +const values: Record = {}; +vi.mock("@/lib/content", () => ({ + getContentValues: vi.fn(async (keys: string[]) => Object.fromEntries(keys.map((key) => [key, values[key] ?? ""]))), +})); +const sendMail = vi.fn(async () => true); +vi.mock("@/lib/mail", () => ({ sendMail: (...args: unknown[]) => sendMail(...(args as [])) })); + +const { INVOICE_COPY_KEY, getInvoiceCopyAddress, invoiceCopyContent, sendInvoiceCopy } = await import("./invoice-mail"); + +const copy = { + title: "Rechnung", + number: "RE-2026-0042", + orderNumber: "WH-2026-0042", + customerName: "Erika ", + customerEmail: "erika@example.org", + pdf: Buffer.from("pdf"), +}; + +beforeEach(() => { + vi.clearAllMocks(); + delete values[INVOICE_COPY_KEY]; +}); + +describe("Rechnungskopie", () => { + test("ohne hinterlegte Adresse keine Kopie", async () => { + expect(await getInvoiceCopyAddress()).toBeNull(); + expect(await sendInvoiceCopy(copy)).toBeNull(); + expect(sendMail).not.toHaveBeenCalled(); + }); + + test("schickt die E-Rechnung als Anhang an die hinterlegte Adresse", async () => { + // Arrange: verschleiert eingetragen wie die Firmen-E-Mail. + values[INVOICE_COPY_KEY] = " buchhaltung [at] wabenhain.com "; + + // Act + const sent = await sendInvoiceCopy(copy); + + // Assert + expect(sent).toBe(true); + expect(sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: "buchhaltung@wabenhain.com", + subject: "Kopie: Rechnung RE-2026-0042 (Bestellung WH-2026-0042)", + attachments: [expect.objectContaining({ filename: "RE-2026-0042.pdf", contentType: "application/pdf" })], + }) + ); + }); + + test("maskiert Kundendaten im HTML-Teil", () => { + const { html, text } = invoiceCopyContent(copy); + expect(html).toContain("Erika <Musterfrau>"); + expect(text).toContain("EN 16931"); + }); +}); diff --git a/src/lib/invoice-mail.ts b/src/lib/invoice-mail.ts new file mode 100644 index 0000000..808506b --- /dev/null +++ b/src/lib/invoice-mail.ts @@ -0,0 +1,67 @@ +import { getContentValues } from "@/lib/content"; +import { normalizeContactEmail } from "@/lib/contact-email"; +import { escapeHtml } from "@/lib/escape-html"; +import { sendMail } from "@/lib/mail"; + +/** + * Kopie jeder Rechnung und Stornorechnung an eine im Admin hinterlegte + * Adresse (Einstellungen > Firma > "Rechnungskopie an"), etwa fuer die + * Buchhaltung. Die Kopie ist eine eigene Mail mit demselben PDF (E-Rechnung + * mit eingebettetem XML), nicht ein BCC der Kundenmail: so steht im Betreff, + * welcher Beleg es ist, und die Kundenmail bleibt unveraendert. + */ + +export const INVOICE_COPY_KEY = "invoice.copyEmail"; + +/** Die hinterlegte Adresse, oder null, wenn keine (gueltige) eingetragen ist. */ +export async function getInvoiceCopyAddress(): Promise { + try { + const values = await getContentValues([INVOICE_COPY_KEY], "de"); + const raw = values[INVOICE_COPY_KEY]?.trim() ?? ""; + return raw ? normalizeContactEmail(raw) : null; + } catch (error: unknown) { + console.error("[invoice-mail] Adresse fuer die Rechnungskopie nicht lesbar:", error); + return null; + } +} + +export interface InvoiceCopy { + /** "Rechnung" oder "Stornorechnung". */ + title: string; + number: string; + orderNumber: string; + customerName: string; + customerEmail: string; + pdf: Buffer; +} + +/** Betreff und Text der Kopie (ohne Versand, testbar). */ +export function invoiceCopyContent(copy: Omit): { subject: string; text: string; html: string } { + const subject = `Kopie: ${copy.title} ${copy.number} (Bestellung ${copy.orderNumber})`; + const text = + `${copy.title} ${copy.number} zur Bestellung ${copy.orderNumber} an ${copy.customerName} <${copy.customerEmail}>.\n` + + "Anbei die E-Rechnung (PDF/A-3 mit eingebettetem XML nach EN 16931, ZUGFeRD/Factur-X)."; + const html = text + .split("\n") + .map((line) => `

${escapeHtml(line)}

`) + .join(""); + return { subject, text, html }; +} + +/** + * Schickt die Kopie. null: keine Adresse hinterlegt; sonst, ob der + * Mailserver die Mail angenommen hat. Wirft nie (wie sendMail): eine + * fehlende Kopie darf keine Bestellung oder Stornierung aufhalten. + */ +export async function sendInvoiceCopy(copy: InvoiceCopy): Promise { + const to = await getInvoiceCopyAddress(); + if (!to) return null; + const { subject, text, html } = invoiceCopyContent(copy); + return sendMail({ + to, + subject, + text, + html, + attachments: [{ filename: `${copy.number}.pdf`, content: copy.pdf, contentType: "application/pdf" }], + }); +} diff --git a/src/lib/invoice-number.ts b/src/lib/invoice-number.ts index 0a1a38f..6ad57b6 100644 --- a/src/lib/invoice-number.ts +++ b/src/lib/invoice-number.ts @@ -22,3 +22,13 @@ const SEQUENCE_DIGITS = 4; export function formatInvoiceNumber(year: number, sequence: number): string { return `RE-${year}-${String(sequence).padStart(SEQUENCE_DIGITS, "0")}`; } + +/** + * Bildet "ST-2026-0001": Stornorechnungen haben einen eigenen fortlaufenden + * Nummernkreis (Tabelle cancellation_counters). Mehrere Nummernkreise sind + * zulaessig, solange jeder fuer sich lueckenlos ist; so bleibt auch die + * RE-Folge ohne Spruenge. + */ +export function formatCancellationNumber(year: number, sequence: number): string { + return `ST-${year}-${String(sequence).padStart(SEQUENCE_DIGITS, "0")}`; +}