Files
Wabenhain/src/lib/sponsorship/certificate-document.ts
T
Nick SzaunigandClaude Opus 5.5 f227aeb8f9 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]>
2026-10-16 14:07:30 +02:00

114 lines
4.4 KiB
TypeScript

import { composeCertificateText, type CertificateText } from "@/lib/certificate/text";
import { layoutCertificate } from "@/lib/certificate/layout";
import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates";
import { renderCertificatePdf } from "@/lib/certificate/render-pdf";
import { renderCertificateImage, type WatermarkOption } from "@/lib/certificate/render-image";
import { fixedTermFor, personalizationSchema, type CertificateTerm, type Personalization } from "@/lib/certificate/schema";
import type { Locale } from "@/i18n/config";
/**
* Bruecke zwischen Patenschaft (Datenbank) und Urkunden-Engine
* (lib/certificate). Hier und nur hier wird entschieden, mit welchen Daten
* eine Urkunde gerendert wird — Ausstellung, Korrektur, Vorschau und die
* 3D-Textur rufen alle diese Funktionen auf. Dadurch koennen Vorschau und
* gespeicherte PDF nicht auseinanderlaufen: gleiche Eingabe, gleicher Weg.
*/
/** Was an der Bestellposition bzw. am Abo als Personalisierung gespeichert wird. */
export interface StoredPersonalization {
personalization: Personalization;
templateVersion: string;
}
export interface CertificateDocumentInput {
personalization: Personalization;
locale: Locale;
term: CertificateTerm;
number: string;
templateVersion: string;
}
/** Bei Vorschauen vor dem Kauf gibt es noch keine Nummer; sichtbar ist dieser Platzhalter. */
export const PREVIEW_NUMBER = "WH-P-0000-00000";
/**
* Liest eine gespeicherte Personalisierung und prueft sie erneut gegen das
* Schema. Was in der Datenbank steht, ist beim Kauf geprueft worden — eine
* Schemaaenderung seitdem soll aber laut auffallen statt still eine Urkunde
* mit unerwarteten Daten zu erzeugen.
*/
export function parseStoredPersonalization(value: unknown): StoredPersonalization {
if (!value || typeof value !== "object") {
throw new Error("Keine Personalisierung gespeichert.");
}
const raw = value as { personalization?: unknown; templateVersion?: unknown };
const parsed = personalizationSchema.safeParse(raw.personalization);
if (!parsed.success) {
throw new Error(`Gespeicherte Personalisierung ungueltig: ${parsed.error.issues[0]?.message ?? "unbekannt"}`);
}
return {
personalization: parsed.data,
templateVersion: typeof raw.templateVersion === "string" ? raw.templateVersion : CURRENT_TEMPLATE_VERSION,
};
}
export function toStoredPersonalization(personalization: Personalization, templateVersion = CURRENT_TEMPLATE_VERSION): StoredPersonalization {
return { personalization, templateVersion };
}
/** Laufzeitform einer Patenschaft aus ihren Daten (YYYY-MM-DD). */
export function termForSponsorship(input: { startDate: Date; endDate: Date | null; isSubscription: boolean }): CertificateTerm {
const start = toIsoDate(input.startDate);
if (!input.isSubscription) {
return input.endDate ? { kind: "FIXED", start, end: toIsoDate(input.endDate) } : fixedTermFor(start);
}
return input.endDate ? { kind: "ENDED", start, end: toIsoDate(input.endDate) } : { kind: "OPEN", start };
}
export function composeDocumentText(input: CertificateDocumentInput): CertificateText {
return composeCertificateText({
personalization: input.personalization,
locale: input.locale,
term: input.term,
number: input.number,
});
}
export async function renderCertificateDocumentPdf(
input: CertificateDocumentInput,
creationDate: Date
): Promise<{ pdf: Buffer; text: CertificateText }> {
const text = composeDocumentText(input);
const layout = layoutCertificate(text, input.templateVersion);
const pdf = await renderCertificatePdf(layout, {
title: `Bienenpatenschaft ${text.beeName}`,
logo: null,
creationDate,
});
return { pdf, text };
}
export async function renderCertificateDocumentImage(
input: CertificateDocumentInput,
options: { widthPx: number; watermark?: WatermarkOption; format: "webp" | "png" }
): Promise<Buffer> {
const layout = layoutCertificate(composeDocumentText(input), input.templateVersion);
return renderCertificateImage(layout, options);
}
/**
* Wasserzeichen der Vorschau. Die Engine setzt den Wortlaut je Sprache selbst
* (render-image.ts, WATERMARK_TEXT) — hier wird nur die Sprache gewaehlt.
*/
export function previewWatermark(locale: Locale): Locale {
return locale;
}
export function toIsoDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
export function fromIsoDate(value: string): Date {
return new Date(`${value}T00:00:00.000Z`);
}