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

+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));
}