Files
Wabenhain/src/lib/invoice-mail.ts
T
Nick SzaunigandClaude Opus 5.5 4d1bcbbdb7 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]>
2026-10-12 06:19:42 +02:00

68 lines
2.5 KiB
TypeScript

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" }],
});
}