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

+11 -1
View File
@@ -9,6 +9,7 @@ import { requireAdmin } from "./_shared";
import { fail, succeed } from "@/lib/flash";
import { normalizeContactEmail } from "@/lib/contact-email";
import { settingsPathForGroup } from "@/lib/settings-tabs";
import { INVOICE_COPY_KEY } from "@/lib/invoice-mail";
const COMPANY_EMAIL_KEY = "company.email";
@@ -64,7 +65,16 @@ export async function updateSiteContentAction(formData: FormData): Promise<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."
);
}
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 });
}
}
+23
View File
@@ -20,6 +20,7 @@ import { isLocale, defaultLocale } from "@/i18n/config";
import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client";
import { requireAdmin, str, strOrNull, fail } from "./_shared";
import { recordOrderReturn, recordOrderSale } from "@/lib/apicore-stock/stock-events";
import { issueCancellationInvoice } from "@/lib/cancellation-invoice";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
@@ -151,6 +152,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
payment: { select: { status: true, stripePaymentIntentId: true } },
items: { select: { productId: true, quantity: true, productName: true } },
returnRequest: { select: { status: true } },
invoice: { select: { cancellation: { select: { number: true } } } },
},
});
if (!previous) return null;
@@ -159,6 +161,12 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
// Bestaetigung aus dem Formular (siehe needsConfirmToChangeStatus). Ohne
// sie wird nichts veraendert.
const reopening = needsConfirmToChangeStatus(previous.status, status);
// Mit Stornorechnung ist die Rechnung aufgehoben und das Geld erstattet;
// ein Wiederoeffnen waere ein Verkauf ohne Rechnung. Dann bitte eine neue
// Bestellung anlegen.
if (reopening && previous.invoice?.cancellation) {
return { ...previous, shipment: null, cancelledNow: false, blocked: "cancellation-invoice" as const };
}
if (reopening && !confirmedReopen) {
return { ...previous, shipment: null, cancelledNow: false, blocked: "needs-confirm" as const };
}
@@ -278,6 +286,12 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
if (result.blocked === "changed") {
return fail(detailPath, "Die Bestellung wurde zwischenzeitlich geändert. Bitte die Seite neu laden und erneut versuchen.");
}
if (result.blocked === "cancellation-invoice") {
return fail(
detailPath,
`Zu dieser Bestellung gibt es die Stornorechnung ${result.invoice?.cancellation?.number}. Sie kann nicht wieder geöffnet werden — bitte eine neue Bestellung anlegen.`
);
}
// undefined = keine E-Mail faellig; false = faellig, aber nicht versendet.
let mailSent: boolean | undefined;
@@ -307,6 +321,15 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
stripeCheckoutSessionId: result.stripeCheckoutSessionId,
payment: result.payment,
});
// Hatte die Bestellung eine Rechnung, wird sie durch eine
// Stornorechnung aufgehoben (nie geloescht) — auch wenn die Erstattung
// oben scheiterte: verkauft ist dann trotzdem nichts mehr.
try {
await issueCancellationInvoice(orderId, "STORNIERUNG");
} catch (error: unknown) {
console.error(`[order-cancel] Stornorechnung fuer ${result.orderNumber} fehlgeschlagen:`, error);
stornoWarnings.push("Die Stornorechnung konnte nicht erstellt werden — bitte die Stornierung erneut auslösen oder den Support informieren.");
}
if (stornoWarnings.length > 0) {
await logAdminAction(admin, {
+14 -1
View File
@@ -29,7 +29,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
payment: true,
shipment: true,
pickup: true,
invoice: true,
invoice: { include: { cancellation: { select: { number: true } } } },
customer: { select: { email: true, firstName: true, lastName: true } },
discountCode: { select: { code: true } },
},
@@ -279,6 +279,19 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
>
{t.adminOrderDetailPage.downloadPdf}
</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>
) : (
<p className="text-ink/65">{t.adminOrderDetailPage.noInvoiceYet}</p>
+10
View File
@@ -11,6 +11,7 @@ import { returnAcceptedEmail, returnRejectedEmail, returnRefundedEmail } from "@
import { restockOrderItems } from "@/lib/inventory";
import { recordOrderReturn } from "@/lib/apicore-stock/stock-events";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
import { issueCancellationInvoice } from "@/lib/cancellation-invoice";
import { isLocale, defaultLocale } from "@/i18n/config";
function str(formData: FormData, key: string): string {
@@ -122,6 +123,15 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
});
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, {
action: "return_request.refund",
entityType: "ReturnRequest",
@@ -58,6 +58,15 @@ vi.mock("@/lib/invoice-storage", () => ({
storeInvoicePdf: vi.fn(async () => undefined),
}));
// Kopie an die Admin-Adresse und Stornorechnung haben eigene Tests; hier
// zaehlt, dass der Webhook sie anstoesst.
const sendInvoiceCopy = vi.fn(async () => true);
vi.mock("@/lib/invoice-mail", () => ({ sendInvoiceCopy: (...args: unknown[]) => sendInvoiceCopy(...(args as [])) }));
const issueCancellationInvoice = vi.fn(async () => ({ number: "ST-2026-0001", created: true }));
vi.mock("@/lib/cancellation-invoice", () => ({
issueCancellationInvoice: (...args: unknown[]) => issueCancellationInvoice(...(args as [])),
}));
/**
* Minimaler Prisma-Ersatz. `$transaction` reicht dieselbe Instanz als `tx`
* weiter — der Handler verlaesst sich auf die Atomaritaet der Datenbank, und
@@ -219,6 +228,10 @@ describe("Stripe-Webhook", () => {
// Die Rechnung liegt der Bestaetigung bei — Gastbestellungen haben kein
// Konto, in dem sie sonst zu finden waere.
expect(sendMail.mock.calls[0][0].attachments?.[0]?.filename).toBe("RE-2026-0001.pdf");
// Kopie derselben E-Rechnung an die im Admin hinterlegte Adresse.
expect(sendInvoiceCopy).toHaveBeenCalledWith(
expect.objectContaining({ title: "Rechnung", number: "RE-2026-0001", customerEmail: paidOrder.email })
);
});
test("zaehlt einen Gutschein bei mehrfach zugestelltem Ereignis nur einmal", async () => {
@@ -352,6 +365,26 @@ describe("Stripe-Webhook", () => {
});
});
test("hebt die Rechnung bei vollstaendiger Erstattung per Stornorechnung auf", async () => {
// Auch wenn die Zahlung schon REFUNDED ist (Erstattung aus dem Admin):
// der Aufruf ist idempotent und findet die Stornorechnung nur wieder.
db.payment.findFirst.mockResolvedValue({
id: "pay_1",
status: "REFUNDED",
orderId: ORDER_ID,
order: { orderNumber: paidOrder.orderNumber },
});
constructEvent.mockReturnValue({
type: "charge.refunded",
data: { object: { payment_intent: "pi_test_1", refunded: true, amount: 2680, amount_refunded: 2680 } },
});
await post();
expect(issueCancellationInvoice).toHaveBeenCalledWith(ORDER_ID, "ERSTATTUNG");
expect(db.payment.update).not.toHaveBeenCalled();
});
test("laesst den Zahlungsstatus bei einer TEILerstattung unveraendert", async () => {
// Der Restbetrag ist weiterhin eingezogen — auf "REFUNDED" gesetzt
// wuerde eine Auswertung den ganzen Umsatz abschreiben.
@@ -376,6 +409,7 @@ describe("Stripe-Webhook", () => {
expect(db.payment.update).not.toHaveBeenCalled();
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Teilerstattung"));
expect(issueCancellationInvoice).not.toHaveBeenCalled();
});
test("haengt eine Rueckbuchung an die Notizen der Bestellung an, ohne bestehende zu verlieren", async () => {
+20 -1
View File
@@ -11,6 +11,8 @@ import { invoiceRelativePath, storeInvoicePdf } from "@/lib/invoice-storage";
import { formatInvoiceNumber } from "@/lib/invoice-number";
import { isLocale, defaultLocale } from "@/i18n/config";
import type Stripe from "stripe";
import { sendInvoiceCopy } from "@/lib/invoice-mail";
import { issueCancellationInvoice } from "@/lib/cancellation-invoice";
import { recordOrderSale } from "@/lib/apicore-stock/stock-events";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
@@ -174,7 +176,7 @@ async function recordRefund(charge: Stripe.Charge): Promise<void> {
const payment = await prisma.payment.findFirst({
where: { stripePaymentIntentId: paymentIntentId },
select: { id: true, status: true, order: { select: { orderNumber: true } } },
select: { id: true, status: true, orderId: true, order: { select: { orderNumber: true } } },
});
if (!payment) {
console.warn(
@@ -194,6 +196,12 @@ async function recordRefund(charge: Stripe.Charge): Promise<void> {
return;
}
// Volle Erstattung: die Rechnung ist damit aufgehoben, es entsteht die
// Stornorechnung. Auch wenn die Zahlung schon auf REFUNDED steht — dann kam
// die Erstattung aus der Stornierung im Admin oder der Retoure, und dieser
// Aufruf findet die dort angelegte Stornorechnung nur wieder.
await issueCancellationInvoice(payment.orderId, "ERSTATTUNG");
if (payment.status === "REFUNDED") return;
await prisma.payment.update({ where: { id: payment.id }, data: { status: "REFUNDED" } });
@@ -456,6 +464,17 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<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) {
// Laut sichtbar machen (statt wie zuvor unbehandelt bis in den
// Next.js-Request-Handler durchzureichen) und weiterwerfen: der 500er
@@ -1,11 +1,11 @@
import { NextResponse } from "next/server";
import { erpExportAuthorized, erpInvoicePdf, isInvoiceNumber } from "@/lib/erp-export";
import { erpExportAuthorized, erpInvoicePdf, isErpDocumentNumber } from "@/lib/erp-export";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
const FAILED_ATTEMPTS_PER_WINDOW = 20;
const FAILED_WINDOW_MS = 15 * 60 * 1000;
/** Rechnungs-PDF fuer ERPNext (Anhang an der Ausgangsrechnung). Nur mit ERP_EXPORT_TOKEN. */
/** Rechnungs- oder Stornorechnungs-PDF fuer ERPNext (Anhang am Beleg). Nur mit ERP_EXPORT_TOKEN. */
export async function GET(request: Request, { params }: { params: Promise<{ invoiceNumber: string }> }) {
if (!erpExportAuthorized(request.headers.get("authorization"))) {
const ip = await getClientIp();
@@ -13,7 +13,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ invo
return NextResponse.json({ error: "Nicht berechtigt." }, { status });
}
const { invoiceNumber } = await params;
const pdf = isInvoiceNumber(invoiceNumber) ? await erpInvoicePdf(invoiceNumber) : null;
const pdf = isErpDocumentNumber(invoiceNumber) ? await erpInvoicePdf(invoiceNumber) : null;
if (!pdf) {
return NextResponse.json({ error: "Rechnung nicht gefunden." }, { status: 404 });
}
+7 -3
View File
@@ -1,12 +1,13 @@
import { NextResponse } from "next/server";
import { erpExportAuthorized, listErpInvoices, parseErpQuery } from "@/lib/erp-export";
import { erpExportAuthorized, listErpCancellations, listErpInvoices, parseErpQuery } from "@/lib/erp-export";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
const FAILED_ATTEMPTS_PER_WINDOW = 20;
const FAILED_WINDOW_MS = 15 * 60 * 1000;
/**
* Rechnungen fuer ERPNext, aelteste zuerst: GET /api/erp/rechnungen?seit=<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.
*/
export async function GET(request: Request) {
@@ -19,7 +20,10 @@ export async function GET(request: Request) {
if (!query) {
return NextResponse.json({ error: "seit (ISO-Zeitpunkt) und limit (1-200) pruefen." }, { status: 400 });
}
const invoices = await listErpInvoices(query.since, query.limit);
const invoices =
query.kind === "storno"
? await listErpCancellations(query.since, query.limit)
: await listErpInvoices(query.since, query.limit);
return NextResponse.json(
{ invoices, hasMore: invoices.length === query.limit },
{ headers: { "Cache-Control": "no-store" } }
@@ -3,6 +3,8 @@ import { getCurrentCustomer } from "@/lib/auth";
import { getInvoiceForCustomer, getInvoiceByNumber } from "@/lib/account-data";
import { generateInvoicePdf } from "@/lib/invoice-pdf";
import { readStoredInvoicePdf } from "@/lib/invoice-storage";
import { cancellationPdf, isCancellationNumber } from "@/lib/cancellation-invoice";
import { prisma } from "@/lib/prisma";
import { isLocale, defaultLocale } from "@/i18n/config";
export async function GET(
@@ -15,6 +17,24 @@ export async function GET(
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
}
// Stornorechnung (ST-…): dieselbe Besitzpruefung ueber die Bestellung der
// aufgehobenen Rechnung, dann das abgelegte bzw. neu erzeugte PDF.
if (isCancellationNumber(invoiceNumber)) {
const owned =
customer.role === "ADMIN" ||
(await prisma.cancellationInvoice.count({ where: { number: invoiceNumber, invoice: { order: { customerId: customer.id } } } })) > 0;
const pdf = owned ? await cancellationPdf(invoiceNumber) : null;
if (!pdf) {
return NextResponse.json({ error: "Rechnung nicht gefunden." }, { status: 404 });
}
return new NextResponse(new Uint8Array(pdf), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${invoiceNumber}.pdf"`,
},
});
}
const invoice =
customer.role === "ADMIN"
? await getInvoiceByNumber(invoiceNumber)
+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")}`;
}