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"; import { revokeOrderSponsorships } from "@/lib/sponsorship/revoke"; /** * 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 { const invoice = await prisma.invoice.findUnique({ where: { orderId }, select: { id: true, totalCents: true, cancellation: { select: { number: true } } }, }); if (!invoice) return null; // Auch beim zweiten Ausloeser: der Widerruf ist idempotent und holt nach, // falls er beim ersten Mal gescheitert ist. if (invoice.cancellation) { await revokeSponsorships(orderId); 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; await revokeSponsorships(orderId); return { number: existing.number, created: false }; } await revokeSponsorships(orderId); await deliverCancellationInvoice(number); return { number, created: true }; } /** * Storniert oder voll erstattet = Patenschaft widerrufen (lib/sponsorship/ * revoke.ts). Wirft nicht: die Stornorechnung steht bereits fest; ein * Fehlschlag wird laut geloggt und beim naechsten Ausloeser nachgeholt. */ async function revokeSponsorships(orderId: string): Promise { try { const revoked = await revokeOrderSponsorships(orderId); if (revoked.length > 0) console.info(`[cancellation] ${revoked.length} Patenschaft(en) der Bestellung ${orderId} widerrufen.`); } catch (error: unknown) { console.error(`[cancellation] Patenschaften der Bestellung ${orderId} konnten NICHT widerrufen werden — bitte im Admin pruefen:`, error); } } 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>>; function renderCancellationPdf(cancellation: LoadedCancellation): Promise { 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 { 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 { const cancellation = await loadCancellation(number); if (!cancellation) return null; const stored = cancellation.pdfPath ? await readStoredInvoicePdf(cancellation.pdfPath) : null; return stored ?? (await renderCancellationPdf(cancellation)); }