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) <[email protected]>
This commit is contained in:
1 parent
508ff24aa7
commit
4d1bcbbdb7
22 files changed
+808
-15
No files matched your search
@@ -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.
|
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-<Jahr>-<laufende Nummer>` (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
|
## Health-Endpunkte
|
||||||
|
|
||||||
Zwei getrennte Endpunkte, bewusst mit unterschiedlicher Aufgabe:
|
Zwei getrennte Endpunkte, bewusst mit unterschiedlicher Aufgabe:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -711,10 +711,46 @@ model Invoice {
|
|||||||
pdfPath String?
|
pdfPath String?
|
||||||
|
|
||||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
cancellation CancellationInvoice?
|
||||||
|
|
||||||
@@map("invoices")
|
@@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
|
// Marketing / Kontakt
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { requireAdmin } from "./_shared";
|
|||||||
import { fail, succeed } from "@/lib/flash";
|
import { fail, succeed } from "@/lib/flash";
|
||||||
import { normalizeContactEmail } from "@/lib/contact-email";
|
import { normalizeContactEmail } from "@/lib/contact-email";
|
||||||
import { settingsPathForGroup } from "@/lib/settings-tabs";
|
import { settingsPathForGroup } from "@/lib/settings-tabs";
|
||||||
|
import { INVOICE_COPY_KEY } from "@/lib/invoice-mail";
|
||||||
|
|
||||||
const COMPANY_EMAIL_KEY = "company.email";
|
const COMPANY_EMAIL_KEY = "company.email";
|
||||||
|
|
||||||
@@ -64,7 +65,16 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
|
|||||||
"Die Firmen-E-Mail ergibt keine gültige Adresse. Möglich sind z. B. [email protected], info [at] wabenhain.com oder info [á] wabenhain.com."
|
"Die Firmen-E-Mail ergibt keine gültige Adresse. Möglich sind z. B. [email protected], 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. [email protected] 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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { isLocale, defaultLocale } from "@/i18n/config";
|
|||||||
import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client";
|
import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client";
|
||||||
import { requireAdmin, str, strOrNull, fail } from "./_shared";
|
import { requireAdmin, str, strOrNull, fail } from "./_shared";
|
||||||
import { recordOrderReturn, recordOrderSale } from "@/lib/apicore-stock/stock-events";
|
import { recordOrderReturn, recordOrderSale } from "@/lib/apicore-stock/stock-events";
|
||||||
|
import { issueCancellationInvoice } from "@/lib/cancellation-invoice";
|
||||||
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
|
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
|
||||||
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
|
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
|
||||||
|
|
||||||
@@ -151,6 +152,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
payment: { select: { status: true, stripePaymentIntentId: true } },
|
payment: { select: { status: true, stripePaymentIntentId: true } },
|
||||||
items: { select: { productId: true, quantity: true, productName: true } },
|
items: { select: { productId: true, quantity: true, productName: true } },
|
||||||
returnRequest: { select: { status: true } },
|
returnRequest: { select: { status: true } },
|
||||||
|
invoice: { select: { cancellation: { select: { number: true } } } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!previous) return null;
|
if (!previous) return null;
|
||||||
@@ -159,6 +161,12 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
// Bestaetigung aus dem Formular (siehe needsConfirmToChangeStatus). Ohne
|
// Bestaetigung aus dem Formular (siehe needsConfirmToChangeStatus). Ohne
|
||||||
// sie wird nichts veraendert.
|
// sie wird nichts veraendert.
|
||||||
const reopening = needsConfirmToChangeStatus(previous.status, status);
|
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) {
|
if (reopening && !confirmedReopen) {
|
||||||
return { ...previous, shipment: null, cancelledNow: false, blocked: "needs-confirm" as const };
|
return { ...previous, shipment: null, cancelledNow: false, blocked: "needs-confirm" as const };
|
||||||
}
|
}
|
||||||
@@ -278,6 +286,12 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
if (result.blocked === "changed") {
|
if (result.blocked === "changed") {
|
||||||
return fail(detailPath, "Die Bestellung wurde zwischenzeitlich geändert. Bitte die Seite neu laden und erneut versuchen.");
|
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.
|
// undefined = keine E-Mail faellig; false = faellig, aber nicht versendet.
|
||||||
let mailSent: boolean | undefined;
|
let mailSent: boolean | undefined;
|
||||||
@@ -307,6 +321,15 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
stripeCheckoutSessionId: result.stripeCheckoutSessionId,
|
stripeCheckoutSessionId: result.stripeCheckoutSessionId,
|
||||||
payment: result.payment,
|
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) {
|
if (stornoWarnings.length > 0) {
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
|||||||
payment: true,
|
payment: true,
|
||||||
shipment: true,
|
shipment: true,
|
||||||
pickup: true,
|
pickup: true,
|
||||||
invoice: true,
|
invoice: { include: { cancellation: { select: { number: true } } } },
|
||||||
customer: { select: { email: true, firstName: true, lastName: true } },
|
customer: { select: { email: true, firstName: true, lastName: true } },
|
||||||
discountCode: { select: { code: true } },
|
discountCode: { select: { code: true } },
|
||||||
},
|
},
|
||||||
@@ -279,6 +279,19 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
|||||||
>
|
>
|
||||||
{t.adminOrderDetailPage.downloadPdf}
|
{t.adminOrderDetailPage.downloadPdf}
|
||||||
</a>
|
</a>
|
||||||
|
{order.invoice.cancellation && (
|
||||||
|
<>
|
||||||
|
<span className="mt-3">
|
||||||
|
{t.invoicePage.cancellationTitle} {order.invoice.cancellation.number}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={`/rechnung/${order.invoice.cancellation.number}/pdf`}
|
||||||
|
className="link-draw text-sm text-heading"
|
||||||
|
>
|
||||||
|
{t.adminOrderDetailPage.downloadPdf}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-ink/65">{t.adminOrderDetailPage.noInvoiceYet}</p>
|
<p className="text-ink/65">{t.adminOrderDetailPage.noInvoiceYet}</p>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { returnAcceptedEmail, returnRejectedEmail, returnRefundedEmail } from "@
|
|||||||
import { restockOrderItems } from "@/lib/inventory";
|
import { restockOrderItems } from "@/lib/inventory";
|
||||||
import { recordOrderReturn } from "@/lib/apicore-stock/stock-events";
|
import { recordOrderReturn } from "@/lib/apicore-stock/stock-events";
|
||||||
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
|
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
|
||||||
|
import { issueCancellationInvoice } from "@/lib/cancellation-invoice";
|
||||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||||
|
|
||||||
function str(formData: FormData, key: string): string {
|
function str(formData: FormData, key: string): string {
|
||||||
@@ -122,6 +123,15 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
|
|||||||
});
|
});
|
||||||
scheduleApiCoreStockSync(returnRequest.order.items.map((item) => item.productId));
|
scheduleApiCoreStockSync(returnRequest.order.items.map((item) => 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, {
|
await logAdminAction(admin, {
|
||||||
action: "return_request.refund",
|
action: "return_request.refund",
|
||||||
entityType: "ReturnRequest",
|
entityType: "ReturnRequest",
|
||||||
|
|||||||
@@ -58,6 +58,15 @@ vi.mock("@/lib/invoice-storage", () => ({
|
|||||||
storeInvoicePdf: vi.fn(async () => undefined),
|
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`
|
* Minimaler Prisma-Ersatz. `$transaction` reicht dieselbe Instanz als `tx`
|
||||||
* weiter — der Handler verlaesst sich auf die Atomaritaet der Datenbank, und
|
* 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
|
// Die Rechnung liegt der Bestaetigung bei — Gastbestellungen haben kein
|
||||||
// Konto, in dem sie sonst zu finden waere.
|
// Konto, in dem sie sonst zu finden waere.
|
||||||
expect(sendMail.mock.calls[0][0].attachments?.[0]?.filename).toBe("RE-2026-0001.pdf");
|
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 () => {
|
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 () => {
|
test("laesst den Zahlungsstatus bei einer TEILerstattung unveraendert", async () => {
|
||||||
// Der Restbetrag ist weiterhin eingezogen — auf "REFUNDED" gesetzt
|
// Der Restbetrag ist weiterhin eingezogen — auf "REFUNDED" gesetzt
|
||||||
// wuerde eine Auswertung den ganzen Umsatz abschreiben.
|
// wuerde eine Auswertung den ganzen Umsatz abschreiben.
|
||||||
@@ -376,6 +409,7 @@ describe("Stripe-Webhook", () => {
|
|||||||
|
|
||||||
expect(db.payment.update).not.toHaveBeenCalled();
|
expect(db.payment.update).not.toHaveBeenCalled();
|
||||||
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Teilerstattung"));
|
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 () => {
|
test("haengt eine Rueckbuchung an die Notizen der Bestellung an, ohne bestehende zu verlieren", async () => {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { invoiceRelativePath, storeInvoicePdf } from "@/lib/invoice-storage";
|
|||||||
import { formatInvoiceNumber } from "@/lib/invoice-number";
|
import { formatInvoiceNumber } from "@/lib/invoice-number";
|
||||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||||
import type Stripe from "stripe";
|
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 { recordOrderSale } from "@/lib/apicore-stock/stock-events";
|
||||||
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
|
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
|
||||||
|
|
||||||
@@ -174,7 +176,7 @@ async function recordRefund(charge: Stripe.Charge): Promise<void> {
|
|||||||
|
|
||||||
const payment = await prisma.payment.findFirst({
|
const payment = await prisma.payment.findFirst({
|
||||||
where: { stripePaymentIntentId: paymentIntentId },
|
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) {
|
if (!payment) {
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -194,6 +196,12 @@ async function recordRefund(charge: Stripe.Charge): Promise<void> {
|
|||||||
return;
|
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;
|
if (payment.status === "REFUNDED") return;
|
||||||
|
|
||||||
await prisma.payment.update({ where: { id: payment.id }, data: { status: "REFUNDED" } });
|
await prisma.payment.update({ where: { id: payment.id }, data: { status: "REFUNDED" } });
|
||||||
@@ -456,6 +464,17 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
|
// 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) {
|
} catch (error: unknown) {
|
||||||
// Laut sichtbar machen (statt wie zuvor unbehandelt bis in den
|
// Laut sichtbar machen (statt wie zuvor unbehandelt bis in den
|
||||||
// Next.js-Request-Handler durchzureichen) und weiterwerfen: der 500er
|
// Next.js-Request-Handler durchzureichen) und weiterwerfen: der 500er
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { NextResponse } from "next/server";
|
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";
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
|
|
||||||
const FAILED_ATTEMPTS_PER_WINDOW = 20;
|
const FAILED_ATTEMPTS_PER_WINDOW = 20;
|
||||||
const FAILED_WINDOW_MS = 15 * 60 * 1000;
|
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 }> }) {
|
export async function GET(request: Request, { params }: { params: Promise<{ invoiceNumber: string }> }) {
|
||||||
if (!erpExportAuthorized(request.headers.get("authorization"))) {
|
if (!erpExportAuthorized(request.headers.get("authorization"))) {
|
||||||
const ip = await getClientIp();
|
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 });
|
return NextResponse.json({ error: "Nicht berechtigt." }, { status });
|
||||||
}
|
}
|
||||||
const { invoiceNumber } = await params;
|
const { invoiceNumber } = await params;
|
||||||
const pdf = isInvoiceNumber(invoiceNumber) ? await erpInvoicePdf(invoiceNumber) : null;
|
const pdf = isErpDocumentNumber(invoiceNumber) ? await erpInvoicePdf(invoiceNumber) : null;
|
||||||
if (!pdf) {
|
if (!pdf) {
|
||||||
return NextResponse.json({ error: "Rechnung nicht gefunden." }, { status: 404 });
|
return NextResponse.json({ error: "Rechnung nicht gefunden." }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { NextResponse } from "next/server";
|
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";
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
|
|
||||||
const FAILED_ATTEMPTS_PER_WINDOW = 20;
|
const FAILED_ATTEMPTS_PER_WINDOW = 20;
|
||||||
const FAILED_WINDOW_MS = 15 * 60 * 1000;
|
const FAILED_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rechnungen fuer ERPNext, aelteste zuerst: GET /api/erp/rechnungen?seit=<ISO>&limit=<1-200>.
|
* Rechnungen fuer ERPNext, aelteste zuerst: GET /api/erp/rechnungen?seit=<ISO>&limit=<1-200>,
|
||||||
|
* Stornorechnungen mit &art=storno.
|
||||||
* Nur lesend und nur mit ERP_EXPORT_TOKEN, siehe lib/erp-export.ts.
|
* Nur lesend und nur mit ERP_EXPORT_TOKEN, siehe lib/erp-export.ts.
|
||||||
*/
|
*/
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
@@ -19,7 +20,10 @@ export async function GET(request: Request) {
|
|||||||
if (!query) {
|
if (!query) {
|
||||||
return NextResponse.json({ error: "seit (ISO-Zeitpunkt) und limit (1-200) pruefen." }, { status: 400 });
|
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(
|
return NextResponse.json(
|
||||||
{ invoices, hasMore: invoices.length === query.limit },
|
{ invoices, hasMore: invoices.length === query.limit },
|
||||||
{ headers: { "Cache-Control": "no-store" } }
|
{ headers: { "Cache-Control": "no-store" } }
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { getCurrentCustomer } from "@/lib/auth";
|
|||||||
import { getInvoiceForCustomer, getInvoiceByNumber } from "@/lib/account-data";
|
import { getInvoiceForCustomer, getInvoiceByNumber } from "@/lib/account-data";
|
||||||
import { generateInvoicePdf } from "@/lib/invoice-pdf";
|
import { generateInvoicePdf } from "@/lib/invoice-pdf";
|
||||||
import { readStoredInvoicePdf } from "@/lib/invoice-storage";
|
import { readStoredInvoicePdf } from "@/lib/invoice-storage";
|
||||||
|
import { cancellationPdf, isCancellationNumber } from "@/lib/cancellation-invoice";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -15,6 +17,24 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
|
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 =
|
const invoice =
|
||||||
customer.role === "ADMIN"
|
customer.role === "ADMIN"
|
||||||
? await getInvoiceByNumber(invoiceNumber)
|
? await getInvoiceByNumber(invoiceNumber)
|
||||||
|
|||||||
@@ -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<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);
|
||||||
|
});
|
||||||
@@ -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<CancellationResult | null> {
|
||||||
|
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<Awaited<ReturnType<typeof loadCancellation>>>;
|
||||||
|
|
||||||
|
function renderCancellationPdf(cancellation: LoadedCancellation): Promise<Buffer> {
|
||||||
|
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<void> {
|
||||||
|
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<Buffer | null> {
|
||||||
|
const cancellation = await loadCancellation(number);
|
||||||
|
if (!cancellation) return null;
|
||||||
|
const stored = cancellation.pdfPath ? await readStoredInvoicePdf(cancellation.pdfPath) : null;
|
||||||
|
return stored ?? (await renderCancellationPdf(cancellation));
|
||||||
|
}
|
||||||
@@ -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.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.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 },
|
{ 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 ---
|
// --- Liefergebiet ---
|
||||||
// Steuert dreierlei zugleich: die Laenderauswahl im Checkout, die
|
// Steuert dreierlei zugleich: die Laenderauswahl im Checkout, die
|
||||||
|
|||||||
@@ -101,6 +101,12 @@ interface EmailStrings {
|
|||||||
button: string;
|
button: string;
|
||||||
subject: (orderNumber: string) => 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 = {
|
const de: EmailStrings = {
|
||||||
@@ -202,6 +208,13 @@ const de: EmailStrings = {
|
|||||||
button: "ERNEUT BESTELLEN",
|
button: "ERNEUT BESTELLEN",
|
||||||
subject: (orderNumber) => `Zahlung zu Bestellung ${orderNumber} fehlgeschlagen — Wabenhain`,
|
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 <strong>${cancellationNumber}</strong>. Sie hebt die Rechnung <strong>${invoiceNumber}</strong> zu deiner Bestellung <strong>${orderNumber}</strong> vollständig auf. Bitte lege sie zu deinen Unterlagen.`,
|
||||||
|
subject: (cancellationNumber, orderNumber) => `Stornorechnung ${cancellationNumber} zu Bestellung ${orderNumber} — Wabenhain`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const en: EmailStrings = {
|
const en: EmailStrings = {
|
||||||
@@ -303,6 +316,13 @@ const en: EmailStrings = {
|
|||||||
button: "ORDER AGAIN",
|
button: "ORDER AGAIN",
|
||||||
subject: (orderNumber) => `Payment for order ${orderNumber} failed — Wabenhain`,
|
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 <strong>${cancellationNumber}</strong>. It fully reverses invoice <strong>${invoiceNumber}</strong> for your order <strong>${orderNumber}</strong>. Please keep it with your records.`,
|
||||||
|
subject: (cancellationNumber, orderNumber) => `Cancellation invoice ${cancellationNumber} for order ${orderNumber} — Wabenhain`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const emailStrings: Record<Locale, EmailStrings> = { de, en };
|
const emailStrings: Record<Locale, EmailStrings> = { de, en };
|
||||||
|
|||||||
@@ -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<EmailContent> {
|
||||||
|
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),
|
||||||
|
`
|
||||||
|
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(escapeHtml(input.firstName))}</h1>
|
||||||
|
<p style="margin:0;line-height:1.5;">${intro(escapeHtml)}</p>
|
||||||
|
`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
subject: s.subject(input.cancellationNumber, input.orderNumber),
|
||||||
|
html,
|
||||||
|
text: intro((value) => value).replace(/<\/?strong>/g, ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function paymentFailedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
|
export async function paymentFailedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
|
||||||
const s = getEmailStrings(order.locale).paymentFailed;
|
const s = getEmailStrings(order.locale).paymentFailed;
|
||||||
// Der Text (retryNote) fordert bereits zur erneuten Bestellung auf — ohne
|
// Der Text (retryNote) fordert bereits zur erneuten Bestellung auf — ohne
|
||||||
|
|||||||
@@ -8,14 +8,22 @@
|
|||||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
const findMany = vi.fn();
|
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-pdf", () => ({ generateInvoicePdf: vi.fn(async () => Buffer.from("pdf")) }));
|
||||||
vi.mock("@/lib/invoice-storage", () => ({ readStoredInvoicePdf: vi.fn(async () => null) }));
|
vi.mock("@/lib/invoice-storage", () => ({ readStoredInvoicePdf: vi.fn(async () => null) }));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
erpExportAuthorized,
|
erpExportAuthorized,
|
||||||
erpVatLines,
|
erpVatLines,
|
||||||
|
isErpDocumentNumber,
|
||||||
isInvoiceNumber,
|
isInvoiceNumber,
|
||||||
|
listErpCancellations,
|
||||||
listErpInvoices,
|
listErpInvoices,
|
||||||
parseErpQuery,
|
parseErpQuery,
|
||||||
toErpInvoice,
|
toErpInvoice,
|
||||||
@@ -42,18 +50,29 @@ describe("erpExportAuthorized", () => {
|
|||||||
|
|
||||||
describe("parseErpQuery und isInvoiceNumber", () => {
|
describe("parseErpQuery und isInvoiceNumber", () => {
|
||||||
test("verlangt einen gueltigen Zeitpunkt und begrenzt das Limit", () => {
|
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=2026-01-01"))?.limit).toBe(200);
|
||||||
expect(parseErpQuery(new URLSearchParams("seit=gestern"))).toBeNull();
|
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=0"))).toBeNull();
|
||||||
expect(parseErpQuery(new URLSearchParams("seit=2026-01-01&limit=201"))).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", () => {
|
test("erkennt nur Rechnungsnummern des Shops", () => {
|
||||||
expect(isInvoiceNumber("RE-2026-0001")).toBe(true);
|
expect(isInvoiceNumber("RE-2026-0001")).toBe(true);
|
||||||
expect(isInvoiceNumber("RE-2026-12345")).toBe(true);
|
expect(isInvoiceNumber("RE-2026-12345")).toBe(true);
|
||||||
expect(isInvoiceNumber("../RE-2026-0001")).toBe(false);
|
expect(isInvoiceNumber("../RE-2026-0001")).toBe(false);
|
||||||
expect(isInvoiceNumber("RE-26-1")).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(findMany).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ where: { issuedAt: { gte: since } }, orderBy: [{ issuedAt: "asc" }, { id: "asc" }], take: 50 })
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+51
-4
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { generateInvoicePdf } from "@/lib/invoice-pdf";
|
import { generateInvoicePdf } from "@/lib/invoice-pdf";
|
||||||
import { readStoredInvoicePdf } from "@/lib/invoice-storage";
|
import { readStoredInvoicePdf } from "@/lib/invoice-storage";
|
||||||
import { vatLinesWithShares, type VatLineWithShares } from "@/lib/vat";
|
import { vatLinesWithShares, type VatLineWithShares } from "@/lib/vat";
|
||||||
|
import { cancellationPdf, isCancellationNumber } from "@/lib/cancellation-invoice";
|
||||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,12 +38,27 @@ export function isInvoiceNumber(value: string): boolean {
|
|||||||
return INVOICE_NUMBER_PATTERN.test(value);
|
return INVOICE_NUMBER_PATTERN.test(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `seit` (ISO-Zeitpunkt, einschliesslich) und `limit` (1 bis MAX_PAGE_SIZE); null bei ungueltigen Werten. */
|
/** Rechnung (RE-…) oder Stornorechnung (ST-…). */
|
||||||
export function parseErpQuery(params: URLSearchParams): { since: Date; limit: number } | null {
|
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 since = new Date(params.get("seit") ?? "");
|
||||||
const limit = Number(params.get("limit") ?? MAX_PAGE_SIZE);
|
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;
|
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 = {
|
const invoiceInclude = {
|
||||||
@@ -53,6 +69,7 @@ const invoiceInclude = {
|
|||||||
discountCode: { select: { code: true } },
|
discountCode: { select: { code: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
cancellation: { select: { number: true } },
|
||||||
} satisfies Prisma.InvoiceInclude;
|
} satisfies Prisma.InvoiceInclude;
|
||||||
|
|
||||||
type ErpInvoiceRecord = Prisma.InvoiceGetPayload<{ include: typeof invoiceInclude }>;
|
type ErpInvoiceRecord = Prisma.InvoiceGetPayload<{ include: typeof invoiceInclude }>;
|
||||||
@@ -66,9 +83,12 @@ export const erpVatLines = vatLinesWithShares;
|
|||||||
export function toErpInvoice(invoice: ErpInvoiceRecord) {
|
export function toErpInvoice(invoice: ErpInvoiceRecord) {
|
||||||
const { order } = invoice;
|
const { order } = invoice;
|
||||||
return {
|
return {
|
||||||
|
type: "invoice" as "invoice" | "cancellation",
|
||||||
invoiceNumber: invoice.invoiceNumber,
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
issuedAt: invoice.issuedAt.toISOString(),
|
issuedAt: invoice.issuedAt.toISOString(),
|
||||||
totalCents: invoice.totalCents,
|
totalCents: invoice.totalCents,
|
||||||
|
/** Nummer der Stornorechnung, falls die Rechnung aufgehoben ist. */
|
||||||
|
cancelledBy: invoice.cancellation?.number ?? null,
|
||||||
order: {
|
order: {
|
||||||
id: order.id,
|
id: order.id,
|
||||||
orderNumber: order.orderNumber,
|
orderNumber: order.orderNumber,
|
||||||
@@ -121,11 +141,38 @@ export async function listErpInvoices(since: Date, limit: number): Promise<ErpIn
|
|||||||
return invoices.map(toErpInvoice);
|
return invoices.map(toErpInvoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stornorechnungen fuer ERPNext, aelteste zuerst. Gleiche Form wie die
|
||||||
|
* Rechnung (Positionen und Steuer je Satz der aufgehobenen Rechnung, positive
|
||||||
|
* Betraege), dazu `cancels` mit Nummer und Datum der Rechnung. ERPNext bucht
|
||||||
|
* daraus die Rechnungskorrektur gegen die Ausgangsrechnung.
|
||||||
|
*/
|
||||||
|
export async function listErpCancellations(since: Date, limit: number) {
|
||||||
|
const cancellations = await prisma.cancellationInvoice.findMany({
|
||||||
|
where: { issuedAt: { gte: since } },
|
||||||
|
orderBy: [{ issuedAt: "asc" }, { id: "asc" }],
|
||||||
|
take: limit,
|
||||||
|
include: { invoice: { include: invoiceInclude } },
|
||||||
|
});
|
||||||
|
return cancellations.map((cancellation) => ({
|
||||||
|
...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
|
* Das PDF so, wie die Kundschaft es bekommen hat: zuerst das abgelegte
|
||||||
* Dokument, nur ohne Ablage neu erzeugt (wie /rechnung/<nummer>/pdf).
|
* Dokument, nur ohne Ablage neu erzeugt (wie /rechnung/<nummer>/pdf). Gilt
|
||||||
|
* fuer Rechnungen (RE-…) und Stornorechnungen (ST-…).
|
||||||
*/
|
*/
|
||||||
export async function erpInvoicePdf(invoiceNumber: string): Promise<Buffer | null> {
|
export async function erpInvoicePdf(invoiceNumber: string): Promise<Buffer | null> {
|
||||||
|
if (isCancellationNumber(invoiceNumber)) return cancellationPdf(invoiceNumber);
|
||||||
const invoice = await prisma.invoice.findUnique({
|
const invoice = await prisma.invoice.findUnique({
|
||||||
where: { invoiceNumber },
|
where: { invoiceNumber },
|
||||||
include: { order: { include: { items: true, discountCode: { select: { code: true } } } } },
|
include: { order: { include: { items: true, discountCode: { select: { code: true } } } } },
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const values: Record<string, string> = {};
|
||||||
|
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 <Musterfrau>",
|
||||||
|
customerEmail: "[email protected]",
|
||||||
|
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: "[email protected]",
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string | null> {
|
||||||
|
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<InvoiceCopy, "pdf">): { 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) => `<p style="margin:0 0 8px;line-height:1.5;">${escapeHtml(line)}</p>`)
|
||||||
|
.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<boolean | null> {
|
||||||
|
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" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -22,3 +22,13 @@ const SEQUENCE_DIGITS = 4;
|
|||||||
export function formatInvoiceNumber(year: number, sequence: number): string {
|
export function formatInvoiceNumber(year: number, sequence: number): string {
|
||||||
return `RE-${year}-${String(sequence).padStart(SEQUENCE_DIGITS, "0")}`;
|
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")}`;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user