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
+809
-16
No files matched your search
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user