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
- 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]>
188 lines
7.7 KiB
TypeScript
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));
|
|
}
|