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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-12 06:19:42 +02:00
1 parent 508ff24aa7
commit 4d1bcbbdb7
22 files changed
+809 -16

No files matched your search

+125
View File
@@ -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);
});
+165
View File
@@ -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));
}
+2
View File
@@ -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
+20
View File
@@ -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 <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 = {
@@ -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 <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 };
+25
View File
@@ -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> {
const s = getEmailStrings(order.locale).paymentFailed;
// Der Text (retryNote) fordert bereits zur erneuten Bestellung auf — ohne
+55 -2
View File
@@ -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);
});
});
+51 -4
View File
@@ -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<ErpIn
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
* 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> {
if (isCancellationNumber(invoiceNumber)) return cancellationPdf(invoiceNumber);
const invoice = await prisma.invoice.findUnique({
where: { invoiceNumber },
include: { order: { include: { items: true, discountCode: { select: { code: true } } } } },
+56
View File
@@ -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 &lt;Musterfrau&gt;");
expect(text).toContain("EN 16931");
});
});
+67
View File
@@ -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" }],
});
}
+10
View File
@@ -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")}`;
}