Files
Wabenhain/src/lib/cancellation-invoice.ts
T
Nick SzaunigandClaude Opus 5.5 515fca1c16
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m57s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 3m7s
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Docker Image bauen und veroeffentlichen / test (push) Canceled after 1m22s
fix: Befunde aus Sicherheits- und Korrektheitsreview der Bienenpatenschaft
- Oeffentliche Kuendigung: exakte Identifikation, Limits je Vertrag und
  Adresse, Bestaetigung nur an die Kontoadresse mit Ruecknahme-Link (HMAC)
- Vorschau: Body-Grenze beim Lesen, Parallelitaets- und Minutenlimit, Cache
- Rabatte gelten nicht fuer Patenschaftspositionen
- Abo aktiv erst nach bestaetigter Zahlung, offene Abschluesse zaehlen
  zum Kontingent und werden nach 24 h aufgeraeumt, Sitzung 58 Minuten
- Konto-Loeschung nur ohne laufendes Abo, Urkunden-Dateien werden entfernt
- Abo-Lieferungen mit Zahlungszeile (DHL-Marke moeglich)
- Shop zeigt freien Bestand nach Abo-Reservierung
- Storno/volle Erstattung widerruft die Patenschaft (Status WIDERRUFEN)
- Umsatzsteuer der Patenschaft per VAT_RATE_PERCENT_PATENSCHAFT (19 %)
- Digitale Bestellungen archivieren sich, Kalendertage in Europe/Berlin,
  Monatsenden begrenzt, Datenauskunft um Patenschaften ergaenzt

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 15:09:32 +02:00

188 lines
7.7 KiB
TypeScript

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<CancellationResult | null> {
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<void> {
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<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));
}