feat: Bienenpatenschaft mit Urkunde, Abo, Medaillen und 3D-Konto
Setzt Teilprojekte 1-5 der Spec 2026-10-16 um: - Urkunden-Engine (lib/certificate): ein Layout fuer PDF, Vorschau und 3D-Textur, Vorlage classic@1, eingebettete OFL-Schriften, Testmatrix - Einmal-Patenschaft ueber Traegerprodukte (Kategorie PATENSCHAFT), Paket wird im Checkout in Patenschaft (Regelsatz) und Honig aufgeteilt, Kontopflicht, rein digitale Bestellungen (FulfillmentMethod DIGITAL) - Ausstellung nach Zahlung idempotent, Wiederholung im App-Prozess (instrumentation + background-jobs), Ablage in CERTIFICATE_DIR - Verkaufsseite /patenschaft mit Konfigurator und Live-Vorschau, Warenkorb mit Schluessel je Position - Kundenkonto: 3D-Ansicht von Urkunde und Medaille, Downloads, ZIP, Uebertragung per befristetem Einmal-Link, Treuestufen bis VIP - Admin: Angebote, Patenschaften, Druck, Korrektur mit Revisionen, Abos, Erntefenster, Deckung, SEPA-Frist, Treuestufen - Abo ueber Stripe Billing: Lieferung erst nach bezahlten Monaten, SEPA-Neukunden-Frist und Rueckbuchungsschutz, Honigreservierung, Kuendigungsbutton, Monatsrechnungen, Stripe-Einrichtungsskript Startangebote werden inaktiv angelegt; Livebetrieb erst nach Freigabe. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
dcb578f062
commit
f227aeb8f9
269 files changed
+28054
-141
No files matched your search
@@ -0,0 +1,133 @@
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user