import { prisma } from "@/lib/prisma"; import type { Prisma } from "@/generated/prisma/client"; import { isLocale, defaultLocale, type Locale } from "@/i18n/config"; import { parseStoredPersonalization, renderCertificateDocumentPdf, toIsoDate, } from "@/lib/sponsorship/certificate-document"; import { certificateRelativePath, storeCertificatePdf } from "@/lib/sponsorship/storage"; import { lastPaidDay } from "@/lib/sponsorship-subscription/billing-rules"; import type { CertificateTerm } from "@/lib/certificate/schema"; /** * Ende eines Abos (Spec "Kuendigung → Wird die Kuendigung wirksam"): * * 1. Status BEENDET mit Endzeitpunkt. Die Honig-Reservierung haengt am Status * (reservation.ts) und ist damit im selben Moment frei. * 2. Die Patenschaft bekommt als Ende den letzten bezahlten Tag. * 3. Die Urkunde bekommt eine neue System-Revision "vom … bis …" (Ersteller * "System", Grund "Abo beendet"). Die bisherigen Revisionen bleiben. * * Wiederholbar: ein zweiter Aufruf (doppeltes customer.subscription.deleted, * Verzugslauf nach einem Webhook) findet das Abo beendet und die Revision * vorhanden und tut nichts. Bricht ein Lauf nach Schritt 1 ab, holt der * naechste die Revision nach. */ export const ENDED_REVISION_REASON = "Abo beendet"; export async function endSubscription(subscriptionId: string, endedAt: Date): Promise { await prisma.sponsorshipSubscription.updateMany({ where: { id: subscriptionId, status: { not: "BEENDET" } }, data: { status: "BEENDET", endedAt, delinquentSince: null }, }); await ensureEndedCertificate(subscriptionId); } async function loadForEnding(subscriptionId: string) { return prisma.sponsorshipSubscription.findUnique({ where: { id: subscriptionId }, select: { status: true, endedAt: true, payments: { select: { paidAt: true, periodEnd: true, reversedAt: true } }, sponsorship: { select: { id: true, number: true, locale: true, startDate: true, endDate: true, currentRevision: true, createdAt: true, }, }, }, }); } /** Setzt das Ende der Patenschaft und legt die ENDED-Revision an, falls noch nicht geschehen. */ export async function ensureEndedCertificate(subscriptionId: string): Promise { const subscription = await loadForEnding(subscriptionId); if (!subscription || subscription.status !== "BEENDET") return; const sponsorship = subscription.sponsorship; // Nie bezahlt: keine Patenschaft, keine Urkunde — nichts zu beenden. if (!sponsorship) return; const endedAt = subscription.endedAt ?? new Date(); const endDate = sponsorship.endDate ?? lastPaidDay(subscription.payments, sponsorship.startDate); // Noch keine Urkunde erzeugt (Ausstellung steht aus): die erste Revision // entsteht dann gleich mit "vom … bis …" (termForSponsorship liest endDate). if (sponsorship.currentRevision === 0) { await prisma.sponsorship.updateMany({ where: { id: sponsorship.id, endDate: null }, data: { endDate } }); return; } const current = await prisma.sponsorshipRevision.findUnique({ where: { sponsorshipId_revision: { sponsorshipId: sponsorship.id, revision: sponsorship.currentRevision } }, select: { personalization: true, templateVersion: true, term: true }, }); if (!current) return; if ((current.term as { kind?: unknown } | null)?.kind === "ENDED") { await prisma.sponsorship.updateMany({ where: { id: sponsorship.id, endDate: null }, data: { endDate } }); return; } const stored = parseStoredPersonalization({ personalization: current.personalization, templateVersion: current.templateVersion, }); const locale: Locale = isLocale(sponsorship.locale) ? sponsorship.locale : defaultLocale; const term: CertificateTerm = { kind: "ENDED", start: toIsoDate(sponsorship.startDate), end: toIsoDate(endDate) }; const revision = sponsorship.currentRevision + 1; // Systemrevisionen nutzen die Vorlagenversion der vorherigen Revision // (Spec "Vorlagenversion"). Erstellungsdatum = Ende des Abos: ein // Wiederholungslauf erzeugt dieselbe Datei, storeCertificatePdf nimmt sie an. const { pdf, text } = await renderCertificateDocumentPdf( { personalization: stored.personalization, locale, term, number: sponsorship.number, templateVersion: stored.templateVersion }, endedAt ); const pdfPath = certificateRelativePath({ number: sponsorship.number, revision, year: sponsorship.createdAt.getUTCFullYear(), }); const { sha256 } = await storeCertificatePdf(pdfPath, pdf); // Ende und Revision gemeinsam: scheitert das Rendern oben, bleibt endDate // leer, und der Hintergrundlauf (jobs.ts) findet das Abo wieder. await prisma.$transaction(async (tx) => { const claim = await tx.sponsorship.updateMany({ where: { id: sponsorship.id, currentRevision: sponsorship.currentRevision }, data: { currentRevision: revision, endDate }, }); // Ein paralleler Lauf (oder eine Korrektur im Admin) war schneller. if (claim.count === 0) return; await tx.sponsorshipRevision.create({ data: { sponsorshipId: sponsorship.id, revision, personalization: stored.personalization as unknown as Prisma.InputJsonValue, term: term as unknown as Prisma.InputJsonValue, text: text as unknown as Prisma.InputJsonValue, templateVersion: stored.templateVersion, pdfPath, pdfSha256: sha256, createdByLabel: "System", reason: ENDED_REVISION_REASON, }, }); }); }