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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 14:07:30 +02:00
1 parent dcb578f062
commit f227aeb8f9
269 files changed
+28054 -141

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
import { processPendingSponsorships } from "@/lib/sponsorship/issue";
import { runSubscriptionJobs } from "@/lib/sponsorship-subscription/jobs";
/**
* Hintergrundaufgaben im App-Prozess, gestartet aus src/instrumentation.ts.
*
* Warum im App-Prozess statt als Kubernetes-CronJob wie der Bestandsabgleich:
* die Urkunden werden in CERTIFICATE_DIR geschrieben, und das liegt auf dem
* ReadWriteOnce-Volume des App-Pods. Ein CronJob-Pod auf einem anderen Knoten
* koennte es nicht einhaengen. Der App-Prozess hat es ohnehin.
*
* Jede Aufgabe ist idempotent (atomare Claims in der Datenbank). Laufen
* mehrere Instanzen, tun sie hoechstens doppelte Arbeit, nie doppelte
* Wirkung. Fehler werden protokolliert und brechen den Takt nicht ab.
*/
const INTERVAL_MS = 10 * 60 * 1000;
/** Erster Lauf kurz nach dem Start, nicht waehrend Migrationen und Warm-up. */
const FIRST_RUN_DELAY_MS = 60 * 1000;
let started = false;
let running = false;
export async function runBackgroundJobsOnce(now: Date = new Date()): Promise<void> {
if (running) return;
running = true;
try {
const sponsorships = await processPendingSponsorships({ now, limit: 50 });
if (sponsorships.ready + sponsorships.retry + sponsorships.failed > 0) {
console.info(
`[hintergrund] Urkunden: ${sponsorships.ready} fertig, ${sponsorships.retry} erneut geplant, ${sponsorships.failed} Fehler.`
);
}
await runSubscriptionJobs(now);
} catch (error: unknown) {
console.error("[hintergrund] Lauf fehlgeschlagen:", error);
} finally {
running = false;
}
}
export function startBackgroundJobs(): void {
if (started || process.env.BACKGROUND_JOBS === "off") return;
started = true;
const timer = setTimeout(function tick() {
void runBackgroundJobsOnce().finally(() => {
setTimeout(tick, INTERVAL_MS).unref();
});
}, FIRST_RUN_DELAY_MS);
// Der Takt allein darf den Prozess nicht am Beenden hindern.
timer.unref();
}
+87
View File
@@ -0,0 +1,87 @@
import { CERT_COLORS, type PathElement } from "@/lib/certificate/elements";
import { compose, cubicXAtY, ellipse, scale, transformPath, translate, type Point } from "@/lib/certificate/geometry";
/**
* Biene in Draufsicht als feine Strichzeichnung — symmetrisch, damit sie auf
* einer zentrierten Achse ruhig steht. Selbst gezeichnet (keine fremden
* Bildrechte), im Strich der Marken-Grafik (components/bee.tsx).
*
* Gezeichnet im Raster 0–100 (Mitte x = 50, Fluegelspanne 17,5–82,5).
* Koerperteile tragen eine Papierfuellung: so verdecken sie die darunter
* liegenden Fluegelansaetze wie bei einer Federzeichnung, ohne dass sich
* Linien kreuzen. Alle Linien haben dieselbe Staerke in Seitenpunkt.
*/
const SPAN = { min: 17.5, max: 82.5 };
const mirror = (d: string): string => transformPath(d, [-1, 0, 0, 1, 100, 0]);
const FOREWING = "M45.6 28.2C39 21.5 27 11.5 18.5 11.8C13.6 12 14.2 18.2 21 22.6C28 27.2 38.5 30.2 45.6 30.2Z";
const FOREWING_VEIN = "M43.6 29.3C36 25.6 28 20.4 22 16.2M34.5 25.3C30.5 26.2 26.5 25.6 23.2 23.4";
const HINDWING = "M45.2 32.8C39 32.4 29 33.8 25.6 37.6C23.4 40.2 26.6 42.2 31.6 41C37 39.6 42.4 36.6 45.2 34.6Z";
const HINDWING_VEIN = "M43.4 34.4C37.6 35.8 32 37.4 27.6 38.8";
// Drei Beinpaare, die unter dem Koerper hervorkommen (vom Koerper verdeckt).
const LEGS = "M45.4 34.4C43 35.8 41.4 37.6 40.4 40M46 36.8C43.8 39.4 42.8 42 42.8 45M47.2 38.6C45.6 42 45 46 45.6 50";
// Hinterleib: rechte Haelfte aus zwei Kurven, linke gespiegelt.
const ABDOMEN_TOP: Point = { x: 50, y: 38.6 };
const ABDOMEN_WIDEST: Point = { x: 59.2, y: 55 };
const ABDOMEN_TIP: Point = { x: 50, y: 72 };
const UPPER = [ABDOMEN_TOP, { x: 58.8, y: 38.6 }, { x: 60.2, y: 48.5 }, ABDOMEN_WIDEST] as const;
const LOWER = [ABDOMEN_WIDEST, { x: 58, y: 63 }, { x: 53, y: 69.5 }, ABDOMEN_TIP] as const;
const ABDOMEN =
"M50 38.6C58.8 38.6 60.2 48.5 59.2 55C58 63 53 69.5 50 72C47 69.5 42 63 40.8 55C39.8 48.5 41.2 38.6 50 38.6Z";
/** Querstreifen, die genau am Umriss des Hinterleibs enden (leicht gewoelbt). */
function stripe(y: number): string {
const segment = y <= ABDOMEN_WIDEST.y ? UPPER : LOWER;
const right = cubicXAtY(segment[0], segment[1], segment[2], segment[3], y);
const left = 100 - right;
return `M${left} ${y}Q50 ${y + 2.6} ${right} ${y}`;
}
// Gekniete Fuehler wie bei der Honigbiene.
const ANTENNA = "M48.4 16.2L47 11.6C45.6 9 43.4 7.6 40.8 7.4";
export interface BeeOptions {
/** Mitte der Biene auf der x-Achse. */
cx: number;
/** Oberkante (Fuehlerspitzen). */
top: number;
/** Fluegelspanne in Punkt. */
width: number;
strokeWidth: number;
color?: string;
}
/** Hoehe der Zeichnung bei gegebener Spannweite (Fuehler bis Stachel). */
export function beeHeight(width: number): number {
return ((75 - 7) / (SPAN.max - SPAN.min)) * width;
}
export function beePaths({ cx, top, width, strokeWidth, color = CERT_COLORS.honeyDark }: BeeOptions): PathElement[] {
const unit = width / (SPAN.max - SPAN.min);
const place = compose(translate(-50, -7), scale(unit), translate(cx, top));
const stroke = (d: string, fill?: string): PathElement => ({
kind: "path",
role: "illustration",
d: transformPath(d, place),
stroke: color,
strokeWidth,
fill,
lineCap: "round",
lineJoin: "round",
});
const paper = CERT_COLORS.paper;
return [
stroke([FOREWING, mirror(FOREWING), HINDWING, mirror(HINDWING)].join("")),
stroke([FOREWING_VEIN, mirror(FOREWING_VEIN), HINDWING_VEIN, mirror(HINDWING_VEIN)].join("")),
stroke(LEGS + mirror(LEGS)),
stroke(ABDOMEN, paper),
stroke(`${[47, 53.5, 60].map(stripe).join("")}M50 72L50 75`),
stroke(ellipse(50, 31.6, 6.8, 6.6), paper),
stroke(ellipse(50, 20.2, 4.6, 4.4), paper),
stroke(ANTENNA + mirror(ANTENNA)),
];
}
+69
View File
@@ -0,0 +1,69 @@
import { CERT_COLORS, type ImageElement, type PathElement } from "@/lib/certificate/elements";
import { compose, polyline, scale, transformPath, translate, rect } from "@/lib/certificate/geometry";
/**
* Das Waben-Zeichen (components/logomark.tsx, lib/invoice-pdf-brand.ts) als
* Vektorpfade: Hexagon in Honiggold, darin drei Tannen in Waldgruen. Gleiche
* Geometrie wie im Web-Logo (viewBox 100). Die Strichstaerken skalieren mit,
* genau wie dort — das Zeichen sieht in jeder Groesse gleich aus.
*
* Wird gezeichnet, wenn kein Logo hochgeladen ist. Das hochgeladene Logo
* selbst wird nie nachgezeichnet oder umgefaerbt (Spec "Markengrundlage").
*/
const HEXAGON = polyline(
[
{ x: 50, y: 5 },
{ x: 88.97, y: 27.5 },
{ x: 88.97, y: 72.5 },
{ x: 50, y: 95 },
{ x: 11.03, y: 72.5 },
{ x: 11.03, y: 27.5 },
],
true
);
const TREES = [
{ x: 20, y: 45, scale: 0.7 },
{ x: 38, y: 32, scale: 0.95 },
{ x: 56, y: 45, scale: 0.7 },
] as const;
const TREE_PARTS = [
polyline([{ x: 10, y: 2 }, { x: 5, y: 13 }, { x: 15, y: 13 }], true),
polyline([{ x: 10, y: 10 }, { x: 2, y: 22 }, { x: 18, y: 22 }], true),
rect({ x: 8, y: 22, width: 4, height: 4 }),
];
/** Waben-Zeichen in einem Quadrat der Kantenlaenge `size` mit linker oberer Ecke (x, y). */
export function logomarkPaths(x: number, y: number, size: number): PathElement[] {
const unit = size / 100;
const place = compose(scale(unit), translate(x, y));
const hexagon: PathElement = {
kind: "path",
role: "logo",
d: transformPath(HEXAGON, place),
stroke: CERT_COLORS.honey,
strokeWidth: 2.5 * unit,
lineJoin: "miter",
};
const trees = TREES.map((tree): PathElement => {
const matrix = compose(scale(tree.scale), translate(tree.x, tree.y), place);
return {
kind: "path",
role: "logo",
d: TREE_PARTS.map((part) => transformPath(part, matrix)).join(""),
stroke: CERT_COLORS.forest,
strokeWidth: 1.4 * tree.scale * unit,
lineJoin: "round",
};
});
return [hexagon, ...trees];
}
/** Quadrat fuer das Waben-Zeichen innerhalb der Logo-Box, nach deren Ausrichtung. */
export function logoFallbackPaths(box: ImageElement): PathElement[] {
const size = Math.min(box.width, box.height);
const offset = box.align === "left" ? 0 : box.align === "right" ? box.width - size : (box.width - size) / 2;
return logomarkPaths(box.x + offset, box.y + (box.height - size) / 2, size);
}
+224
View File
@@ -0,0 +1,224 @@
import { measureText, shapeText, loadCertificateFonts, type FontKey } from "@/lib/certificate/fonts";
import { pathBounds, unionBox, type Box } from "@/lib/certificate/geometry";
import { baselineInBox } from "@/lib/certificate/typeset";
/**
* Bausteine des Urkunden-Layouts: positionierte Elemente, die PDF- und
* Bild-Renderer gleichermassen abarbeiten. Ein Element beschreibt WAS wo
* steht — nicht WIE es gezeichnet wird. Text bleibt Text (im PDF echter Text,
* im Bild Glyphenpfade aus derselben Schriftdatei), Pfade sind Vektoren.
*
* Koordinaten in Punkt, Ursprung oben links, y nach unten; Text-y ist die
* Grundlinie.
*/
/** A4 hochkant in Punkt. */
export const PAGE_WIDTH = 595.28;
export const PAGE_HEIGHT = 841.89;
/** Inhalt mindestens 15 mm vom Blattrand, Rahmenlinien mindestens 12 mm. */
export const SAFE_CONTENT = 42.52;
export const SAFE_FRAME = 34.02;
/** Kleinste Schrift (Kennung, Hinweis) und kleinste Fliesstextgroesse. */
export const MIN_TEXT_SIZE = 8;
export const MIN_BODY_SIZE = 11;
/**
* Markenfarben fuer Papier — dieselben Werte wie app/globals.css. Gold ist
* eine flache Druckfarbe; kein Verlauf, keine Folienimitation.
*/
export const CERT_COLORS = {
forest: "#24352b",
honeyDark: "#8a6531",
honey: "#b88a44",
sage: "#788774",
paper: "#ffffff",
} as const;
export type TextRole =
| "title"
| "subtitle"
| "occasion"
| "lead"
| "recipient"
| "beforeBee"
| "beeName"
| "afterBee"
| "giver"
| "dedication"
| "term"
| "number"
| "notice";
/** Fliesstext-Rollen: hier gilt die Mindestgroesse fuer Fliesstext (11 pt). */
export const BODY_ROLES: readonly TextRole[] = ["beforeBee", "afterBee", "giver", "dedication", "lead", "occasion"];
export type TextAlign = "left" | "center" | "right";
export interface TextElement {
kind: "text";
role: TextRole;
text: string;
font: FontKey;
size: number;
color: string;
/** Linke Kante des Satzkastens. */
x: number;
/** Grundlinie. */
y: number;
/** Breite des Satzkastens; die Zeile wird darin nach `align` ausgerichtet. */
width: number;
align: TextAlign;
letterSpacing?: number;
}
export type PathRole = "frame" | "ornament" | "illustration" | "logo";
export interface PathElement {
kind: "path";
role: PathRole;
d: string;
stroke?: string;
fill?: string;
strokeWidth?: number;
opacity?: number;
lineCap?: "butt" | "round" | "square";
lineJoin?: "miter" | "round" | "bevel";
}
/** Platz fuer das Markenlogo; ohne hochgeladenes Logo zeichnen die Renderer dort das Waben-Zeichen. */
export interface ImageElement {
kind: "image";
src: "logo";
x: number;
y: number;
width: number;
height: number;
align: TextAlign;
}
export type LayoutElement = TextElement | PathElement | ImageElement;
export interface CertificateLayout {
templateVersion: string;
width: typeof PAGE_WIDTH;
height: typeof PAGE_HEIGHT;
elements: readonly LayoutElement[];
}
/** Feld, das nicht in die kleinste Stufe passt ("layout": der Gesamtinhalt ist zu hoch). */
export type CertificateField = "beeName" | "recipientName" | "giverNames" | "dedication" | "occasion" | "layout";
export class CertificateLayoutError extends Error {
readonly field: CertificateField;
constructor(field: CertificateField, message?: string) {
super(message ?? `Urkunden-Layout: Feld "${field}" passt nicht in die kleinste Schriftstufe.`);
this.name = "CertificateLayoutError";
this.field = field;
}
}
/** Beginn der Zeile auf der x-Achse — EINE Berechnung fuer beide Renderer. */
export function textStartX(el: TextElement): number {
if (el.align === "left") return el.x;
const advance = measureText(el.font, el.text, el.size, { letterSpacing: el.letterSpacing });
return el.align === "center" ? el.x + (el.width - advance) / 2 : el.x + el.width - advance;
}
/** Tatsaechliche Tintenflaeche einer Textzeile (Glyphen-Begrenzungen), fuer Tests auf Ueberlappung und Rand. */
export function textInkBounds(el: TextElement): Box {
const font = loadCertificateFonts()[el.font];
const scale = el.size / font.unitsPerEm;
const startX = textStartX(el);
const boxes = shapeText(el.font, el.text)
.map((entry, index) => {
const bbox = entry.glyph.bbox;
if (!Number.isFinite(bbox.minX) || bbox.width === 0) return null;
const originX = startX + (entry.penX + entry.xOffset) * scale + (el.letterSpacing ?? 0) * index;
return {
x: originX + bbox.minX * scale,
y: el.y - (bbox.maxY + entry.yOffset) * scale,
width: bbox.width * scale,
height: bbox.height * scale,
};
})
.filter((box): box is Box => box !== null);
return boxes.length ? unionBox(boxes) : { x: startX, y: el.y, width: 0, height: 0 };
}
export function elementBounds(el: LayoutElement): Box {
if (el.kind === "text") return textInkBounds(el);
if (el.kind === "image") return { x: el.x, y: el.y, width: el.width, height: el.height };
const box = pathBounds(el.d);
const half = (el.stroke ? (el.strokeWidth ?? 1) : 0) / 2;
return { x: box.x - half, y: box.y - half, width: box.width + 2 * half, height: box.height + 2 * half };
}
/* ------------------------------------------------------------------------
* Vertikaler Fluss: Vorlagen beschreiben ihren Inhalt als Folge von Zeilen,
* Abstaenden und Zeichnungen, messen die Gesamthoehe und setzen den Fluss
* dann an eine Position. So lassen sich Layoutvarianten (ohne, kurze, lange
* Widmung) ueber verschiedene Abstaende ausdruecken, ohne Koordinaten von
* Hand zu verschieben.
* --------------------------------------------------------------------- */
export interface FlowText {
kind: "text";
/** Hoehe des Zeilenkastens; die Versalhoehe sitzt darin mittig. */
lineHeight: number;
element: Omit<TextElement, "kind" | "y">;
}
export interface FlowSpace {
kind: "space";
height: number;
}
export interface FlowArt {
kind: "art";
height: number;
draw: (top: number) => LayoutElement[];
}
export type FlowItem = FlowText | FlowSpace | FlowArt;
export function flowHeight(items: readonly FlowItem[]): number {
return items.reduce((sum, item) => sum + (item.kind === "text" ? item.lineHeight : item.height), 0);
}
export function placeFlow(items: readonly FlowItem[], top: number): LayoutElement[] {
const elements: LayoutElement[] = [];
let cursor = top;
for (const item of items) {
if (item.kind === "text") {
const y = baselineInBox(item.element.font, item.element.size, cursor, item.lineHeight);
elements.push({ kind: "text", ...item.element, y });
cursor += item.lineHeight;
} else if (item.kind === "art") {
elements.push(...item.draw(cursor));
cursor += item.height;
} else {
cursor += item.height;
}
}
return elements;
}
/**
* Mehrere Zeilen derselben Art als Flusszeilen. Eine leere Zeile (Absatz in
* der Widmung) wird zu einem halben Zeilenabstand statt zu einem leeren
* Textelement — so entsteht kein unsichtbarer Text im PDF.
*/
export function textLines(
lines: readonly string[],
lineHeight: number,
base: Omit<TextElement, "kind" | "y" | "text">
): FlowItem[] {
return lines.map((text): FlowItem =>
text === "" ? space(lineHeight * 0.5) : { kind: "text", lineHeight, element: { ...base, text } }
);
}
export function space(height: number): FlowSpace {
return { kind: "space", height };
}
+224
View File
@@ -0,0 +1,224 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { create, type Font, type Glyph } from "fontkit";
/**
* Schriften der Urkunde: Cormorant Garamond (Ueberschrift, Namen, Widmung)
* und Inter (Kleintext). Beide unter SIL Open Font License, als statische
* TTF mit Lizenzdatei in assets/fonts — sie duerfen als Teilmenge in PDFs
* eingebettet werden.
*
* Dieses Modul ist die EINZIGE Stelle, an der Text vermessen wird. PDF
* (pdfkit) und Bild (SVG mit Glyphenpfaden) setzen dieselben Zeilen an
* dieselben Positionen nur dann, wenn beide dieselben Laufweiten sehen.
* Deshalb bildet layoutRun() unten die Wortzerlegung von pdfkit genau nach
* (siehe dort) — Kerning und Ligaturen kommen in beiden Ausgaben aus
* demselben fontkit-Aufruf auf derselben Datei.
*
* Nur serverseitig nutzbar (node:fs). Das zod-Schema in schema.ts bleibt
* bewusst frei davon, damit es auch im Browser laeuft; die Glyphenpruefung
* passiert auf dem Server (layout.ts → checkCertificateFits).
*/
export type FontKey =
| "serif"
| "serifItalic"
| "serifMedium"
| "serifMediumItalic"
| "serifSemiBold"
| "serifSemiBoldItalic"
| "sans"
| "sansMedium"
| "sansSemiBold";
// turbopackIgnore: der Bundler soll den Pfad nicht als Modul aufloesen; die
// Dateien liegen zur Laufzeit neben der App (wie in lib/einvoice/facturx-pdf.ts).
const FONT_DIR = path.join(/*turbopackIgnore: true*/ process.cwd(), "assets", "fonts");
export const FONT_FILES: Record<FontKey, string> = {
serif: path.join(FONT_DIR, "CormorantGaramond-Regular.ttf"),
serifItalic: path.join(FONT_DIR, "CormorantGaramond-Italic.ttf"),
serifMedium: path.join(FONT_DIR, "CormorantGaramond-Medium.ttf"),
serifMediumItalic: path.join(FONT_DIR, "CormorantGaramond-MediumItalic.ttf"),
serifSemiBold: path.join(FONT_DIR, "CormorantGaramond-SemiBold.ttf"),
serifSemiBoldItalic: path.join(FONT_DIR, "CormorantGaramond-SemiBoldItalic.ttf"),
sans: path.join(FONT_DIR, "Inter-Regular.ttf"),
sansMedium: path.join(FONT_DIR, "Inter-Medium.ttf"),
sansSemiBold: path.join(FONT_DIR, "Inter-SemiBold.ttf"),
};
export const FONT_KEYS = Object.keys(FONT_FILES) as FontKey[];
export interface TextOptions {
/** Zusaetzlicher Abstand nach jedem Glyphen ausser dem letzten, in Punkt (PDF: Tc). */
letterSpacing?: number;
}
let fontCache: Readonly<Record<FontKey, Font>> | undefined;
function openFont(file: string): Font {
const font = create(readFileSync(file));
// Eine TTF ist nie eine Sammlung — falls doch eine TTC hier landet, lieber
// laut scheitern als stillschweigend eine falsche Schnittvariante setzen.
if ("fonts" in font) throw new Error(`Schriftdatei ${file} ist eine Sammlung, erwartet wird eine einzelne TTF.`);
return font;
}
/**
* Alle Urkundenschriften, einmal pro Prozess gelesen. Fehlt eine Datei, wirft
* der Aufruf — anders als bei der Rechnung gibt es hier keinen Rueckfall auf
* Standardschriften: eine Urkunde in Helvetica waere kein verkaufbares
* Produkt, und die Vorschau muss exakt die spaetere PDF zeigen.
*/
export function loadCertificateFonts(): Record<FontKey, Font> {
if (!fontCache) {
const entries = FONT_KEYS.map((key) => [key, openFont(FONT_FILES[key])] as const);
fontCache = Object.freeze(Object.fromEntries(entries) as Record<FontKey, Font>);
}
return fontCache;
}
export interface PositionedGlyph {
glyph: Glyph;
/** Stiftposition vor diesem Glyphen in Font-Einheiten, ohne Sperrung. */
penX: number;
xOffset: number;
yOffset: number;
xAdvance: number;
}
/**
* Setzt `text` genau so, wie pdfkit es tut: pdfkit (EmbeddedFont.layout)
* zerlegt jede Zeile nach jedem Leerzeichen bzw. Tab in Stuecke und setzt
* jedes Stueck einzeln — Kerning ueber die Grenze "Leerzeichen → naechstes
* Wort" entfaellt dort. Wuerde die Bildausgabe den ganzen String auf einmal
* setzen, laegen ihre Glyphen um Bruchteile eines Punkts anders als in der
* PDF und lange Zeilen koennten anders umbrechen.
*/
function layoutRun(font: Font, text: string): PositionedGlyph[] {
const pieces: string[] = [];
let last = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === " " || text[i] === "\t") {
pieces.push(text.slice(last, i + 1));
last = i + 1;
}
}
if (last < text.length) pieces.push(text.slice(last));
const result: PositionedGlyph[] = [];
let penX = 0;
for (const piece of pieces) {
const run = font.layout(piece);
run.glyphs.forEach((glyph, index) => {
const position = run.positions[index];
result.push({
glyph,
penX,
xOffset: position.xOffset,
yOffset: position.yOffset,
xAdvance: position.xAdvance,
});
penX += position.xAdvance;
});
}
return result;
}
/** Gesetzte Glyphen fuer Renderer und Tests (z.B. Tintengrenzen). */
export function shapeText(font: FontKey, text: string): PositionedGlyph[] {
return layoutRun(loadCertificateFonts()[font], text);
}
/** Laufweite in Punkt inklusive Kerning und optionaler Sperrung. */
export function measureText(font: FontKey, text: string, size: number, opts?: TextOptions): number {
const loaded = loadCertificateFonts()[font];
const glyphs = layoutRun(loaded, text);
if (glyphs.length === 0) return 0;
const advance = glyphs.reduce((sum, entry) => sum + entry.xAdvance, 0);
const spacing = (opts?.letterSpacing ?? 0) * (glyphs.length - 1);
return (advance * size) / loaded.unitsPerEm + spacing;
}
/** Zahl fuer Pfaddaten: 0,01 pt reicht (bei 300 dpi rund 0,04 px). */
function num(value: number): string {
const rounded = Math.round(value * 100) / 100;
return Object.is(rounded, -0) ? "0" : String(rounded);
}
/**
* Text als SVG-Pfaddaten (nur absolute M/L/Q/C/Z) in einem Koordinatensystem
* mit y nach unten — dem der Seite. `x` ist der Beginn der Zeile, `baselineY`
* die Grundlinie. Glyphenvorschuebe identisch zu measureText().
*/
export function textToPath(
font: FontKey,
text: string,
size: number,
x: number,
baselineY: number,
opts?: TextOptions
): string {
const loaded = loadCertificateFonts()[font];
const scale = size / loaded.unitsPerEm;
const spacing = opts?.letterSpacing ?? 0;
const parts: string[] = [];
layoutRun(loaded, text).forEach((entry, index) => {
const originX = x + (entry.penX + entry.xOffset) * scale + spacing * index;
const originY = baselineY - entry.yOffset * scale;
const px = (fx: number): string => num(originX + fx * scale);
const py = (fy: number): string => num(originY - fy * scale);
for (const { command, args } of entry.glyph.path.commands) {
switch (command) {
case "moveTo":
parts.push(`M${px(args[0])} ${py(args[1])}`);
break;
case "lineTo":
parts.push(`L${px(args[0])} ${py(args[1])}`);
break;
case "quadraticCurveTo":
parts.push(`Q${px(args[0])} ${py(args[1])} ${px(args[2])} ${py(args[3])}`);
break;
case "bezierCurveTo":
parts.push(`C${px(args[0])} ${py(args[1])} ${px(args[2])} ${py(args[3])} ${px(args[4])} ${py(args[5])}`);
break;
case "closePath":
parts.push("Z");
break;
}
}
});
return parts.join("");
}
/**
* Zeichen aus `text`, die in mindestens einer der angegebenen Schriften keine
* Glyphe haben (eindeutig, in Reihenfolge des ersten Auftretens). Ein Feld
* kann je nach Vorlage und Schriftstufe in mehreren Schnitten erscheinen —
* es muss deshalb in allen darstellbar sein, sonst druckte die PDF ein
* Ersatzkaestchen. Leerraum und Zeilenumbrueche werden nicht geprueft.
*/
export function findUnsupportedCharacters(text: string, fontKeys: FontKey[]): string[] {
const fonts = loadCertificateFonts();
const missing = new Set<string>();
for (const char of text) {
if (/\s/u.test(char)) continue;
const codePoint = char.codePointAt(0);
if (codePoint === undefined) continue;
if (fontKeys.some((key) => !fonts[key].hasGlyphForCodePoint(codePoint))) missing.add(char);
}
return [...missing];
}
/** Steigung/Unterlaenge der Schrift in Punkt bei `size` (fuer Zeilenabstaende). */
export function fontMetrics(font: FontKey, size: number): { ascent: number; descent: number; capHeight: number; xHeight: number } {
const loaded = loadCertificateFonts()[font];
const scale = size / loaded.unitsPerEm;
return {
ascent: loaded.ascent * scale,
descent: -loaded.descent * scale,
capHeight: loaded.capHeight * scale,
xHeight: loaded.xHeight * scale,
};
}
+244
View File
@@ -0,0 +1,244 @@
/**
* Vektor-Hilfen fuer Rahmen, Ornamente und Illustrationen der Urkunde.
*
* Alle Pfade der Engine bestehen nur aus absoluten M/L/Q/C/Z-Befehlen in
* Seitenkoordinaten (Punkt, y nach unten). Das haelt beide Renderer simpel
* (pdfkit doc.path und SVG <path> verstehen das gleich) und erlaubt es, die
* Grenzen jedes Pfads ohne SVG-Bibliothek zu berechnen — die Layout-Tests
* pruefen damit Sicherheitszone und Ueberlappung.
*
* Illustrationen werden in einem eigenen kleinen Koordinatensystem
* gezeichnet und mit transformPath() an ihren Platz gebracht. Strichstaerken
* gehoeren dagegen zum Element und sind immer in Seitenpunkt angegeben —
* so bleibt die Linie gleich fein, egal wie gross eine Zeichnung skaliert.
*/
export interface Point {
x: number;
y: number;
}
export interface Box {
x: number;
y: number;
width: number;
height: number;
}
/** Affine Matrix [a, b, c, d, e, f] wie in SVG/PDF: x' = a·x + c·y + e, y' = b·x + d·y + f. */
export type Matrix = readonly [number, number, number, number, number, number];
export const IDENTITY: Matrix = [1, 0, 0, 1, 0, 0];
/** Verkettung: erst `inner`, dann `outer`. */
export function multiply(outer: Matrix, inner: Matrix): Matrix {
const [a1, b1, c1, d1, e1, f1] = outer;
const [a2, b2, c2, d2, e2, f2] = inner;
return [
a1 * a2 + c1 * b2,
b1 * a2 + d1 * b2,
a1 * c2 + c1 * d2,
b1 * c2 + d1 * d2,
a1 * e2 + c1 * f2 + e1,
b1 * e2 + d1 * f2 + f1,
];
}
export function translate(x: number, y: number): Matrix {
return [1, 0, 0, 1, x, y];
}
export function scale(sx: number, sy: number = sx): Matrix {
return [sx, 0, 0, sy, 0, 0];
}
export function rotate(degrees: number, cx = 0, cy = 0): Matrix {
const r = (degrees * Math.PI) / 180;
const cos = Math.cos(r);
const sin = Math.sin(r);
return multiply(translate(cx, cy), multiply([cos, sin, -sin, cos, 0, 0], translate(-cx, -cy)));
}
/** Verkettet von links nach rechts in Anwendungsreihenfolge: compose(a, b) = erst a, dann b. */
export function compose(...matrices: Matrix[]): Matrix {
return matrices.reduce((acc, m) => multiply(m, acc), IDENTITY);
}
export function apply(m: Matrix, p: Point): Point {
return { x: m[0] * p.x + m[2] * p.y + m[4], y: m[1] * p.x + m[3] * p.y + m[5] };
}
/** Zahl fuer Pfaddaten, 0,01 pt genau (wie fonts.ts). */
export function num(value: number): string {
const rounded = Math.round(value * 100) / 100;
return Object.is(rounded, -0) ? "0" : String(rounded);
}
interface Segment {
command: "M" | "L" | "Q" | "C" | "Z";
points: Point[];
}
const ARITY: Record<Segment["command"], number> = { M: 1, L: 1, Q: 2, C: 3, Z: 0 };
/** Zerlegt Pfaddaten der Engine. Andere Befehle (relativ, Boegen) sind hier bewusst nicht erlaubt. */
export function parsePath(d: string): Segment[] {
const tokens = d.match(/[MLQCZ]|-?\d*\.?\d+(?:e-?\d+)?/gi) ?? [];
const segments: Segment[] = [];
let i = 0;
while (i < tokens.length) {
const command = tokens[i++].toUpperCase();
if (!(command in ARITY)) throw new Error(`Pfadbefehl ${command} wird nicht unterstuetzt.`);
const typed = command as Segment["command"];
const points: Point[] = [];
for (let k = 0; k < ARITY[typed]; k++) {
points.push({ x: Number(tokens[i]), y: Number(tokens[i + 1]) });
i += 2;
}
segments.push({ command: typed, points });
}
return segments;
}
function serialize(segments: Segment[]): string {
return segments
.map(({ command, points }) => command + points.map((p) => `${num(p.x)} ${num(p.y)}`).join(" "))
.join("");
}
export function transformPath(d: string, m: Matrix): string {
return serialize(parsePath(d).map(({ command, points }) => ({ command, points: points.map((p) => apply(m, p)) })));
}
/**
* Begrenzungsrechteck ueber alle Stuetz- und Kontrollpunkte. Etwas groesser
* als die echte Kurve — fuer Sicherheitszone und Kollisionspruefung genau
* die richtige Richtung (lieber zu vorsichtig).
*/
export function pathBounds(d: string): Box {
const points = parsePath(d).flatMap((segment) => segment.points);
if (points.length === 0) return { x: 0, y: 0, width: 0, height: 0 };
const xs = points.map((p) => p.x);
const ys = points.map((p) => p.y);
const minX = Math.min(...xs);
const minY = Math.min(...ys);
return { x: minX, y: minY, width: Math.max(...xs) - minX, height: Math.max(...ys) - minY };
}
/**
* Grenzen je Teilpfad (jedes M beginnt einen neuen). Eine Illustration aus
* vielen Blaettern ist ein Pfad — ihr Gesamtrechteck waere fuer die
* Kollisionspruefung viel zu grob, die Teilrechtecke liegen eng an.
*/
export function subpathBounds(d: string): Box[] {
const boxes: Box[] = [];
let current: Point[] = [];
const flush = (): void => {
if (current.length === 0) return;
const xs = current.map((p) => p.x);
const ys = current.map((p) => p.y);
const minX = Math.min(...xs);
const minY = Math.min(...ys);
boxes.push({ x: minX, y: minY, width: Math.max(...xs) - minX, height: Math.max(...ys) - minY });
current = [];
};
for (const segment of parsePath(d)) {
if (segment.command === "M") flush();
current.push(...segment.points);
}
flush();
return boxes;
}
export function line(a: Point, b: Point): string {
return `M${num(a.x)} ${num(a.y)}L${num(b.x)} ${num(b.y)}`;
}
export function polyline(points: Point[], closed = false): string {
const body = points.map((p, index) => `${index === 0 ? "M" : "L"}${num(p.x)} ${num(p.y)}`).join("");
return closed ? `${body}Z` : body;
}
export function rect(box: Box): string {
const { x, y, width, height } = box;
return polyline(
[
{ x, y },
{ x: x + width, y },
{ x: x + width, y: y + height },
{ x, y: y + height },
],
true
);
}
// Kontrollpunktabstand fuer einen Viertelkreis aus einer kubischen Bezierkurve.
const KAPPA = 0.5522847498;
/** Ellipse aus vier Bezierboegen, optional um ihren Mittelpunkt gedreht. */
export function ellipse(cx: number, cy: number, rx: number, ry: number, rotation = 0): string {
const kx = rx * KAPPA;
const ky = ry * KAPPA;
const raw =
`M${cx + rx} ${cy}` +
`C${cx + rx} ${cy + ky} ${cx + kx} ${cy + ry} ${cx} ${cy + ry}` +
`C${cx - kx} ${cy + ry} ${cx - rx} ${cy + ky} ${cx - rx} ${cy}` +
`C${cx - rx} ${cy - ky} ${cx - kx} ${cy - ry} ${cx} ${cy - ry}` +
`C${cx + kx} ${cy - ry} ${cx + rx} ${cy - ky} ${cx + rx} ${cy}Z`;
return transformPath(raw, rotation ? rotate(rotation, cx, cy) : IDENTITY);
}
export function circle(cx: number, cy: number, r: number): string {
return ellipse(cx, cy, r, r);
}
/** Spitz stehendes Sechseck (wie Logomark und HoneycombPattern), r = Umkreisradius. */
export function hexagonPoints(cx: number, cy: number, r: number): Point[] {
return Array.from({ length: 6 }, (_, i) => {
const angle = (Math.PI / 3) * i - Math.PI / 2;
return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) };
});
}
export function hexagon(cx: number, cy: number, r: number): string {
return polyline(hexagonPoints(cx, cy, r), true);
}
export function cubicPoint(p0: Point, p1: Point, p2: Point, p3: Point, t: number): Point {
const u = 1 - t;
return {
x: u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x,
y: u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y,
};
}
/** x einer (in y monotonen) kubischen Kurve bei gegebenem y — fuer Linien, die genau am Umriss enden. */
export function cubicXAtY(p0: Point, p1: Point, p2: Point, p3: Point, y: number): number {
let lo = 0;
let hi = 1;
const rising = p3.y > p0.y;
for (let i = 0; i < 40; i++) {
const mid = (lo + hi) / 2;
const point = cubicPoint(p0, p1, p2, p3, mid);
if (point.y < y === rising) lo = mid;
else hi = mid;
}
return cubicPoint(p0, p1, p2, p3, (lo + hi) / 2).x;
}
export function intersects(a: Box, b: Box, tolerance = 0): boolean {
return (
a.x < b.x + b.width - tolerance &&
b.x < a.x + a.width - tolerance &&
a.y < b.y + b.height - tolerance &&
b.y < a.y + a.height - tolerance
);
}
export function unionBox(boxes: Box[]): Box {
const minX = Math.min(...boxes.map((b) => b.x));
const minY = Math.min(...boxes.map((b) => b.y));
const maxX = Math.max(...boxes.map((b) => b.x + b.width));
const maxY = Math.max(...boxes.map((b) => b.y + b.height));
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
+246
View File
@@ -0,0 +1,246 @@
// @vitest-environment node
//
// Layout-Testmatrix der Spec ("Prüfung → Layout-Testmatrix"). Node statt
// jsdom, weil fontkit die Schriften ueber node:fs liest.
import { describe, expect, test } from "vitest";
import {
CertificateLayoutError,
MIN_BODY_SIZE,
MIN_TEXT_SIZE,
PAGE_HEIGHT,
PAGE_WIDTH,
SAFE_CONTENT,
SAFE_FRAME,
checkCertificateFits,
elementBounds,
layoutCertificate,
layoutLines,
type CertificateLayout,
type TextElement,
} from "./layout";
import { BODY_ROLES } from "./elements";
import { measureText } from "./fonts";
import { intersects, subpathBounds } from "./geometry";
import { CERTIFICATE_LIMITS, OCCASIONS, fixedTermFor, personalizationSchema, type CertificateTerm, type Personalization } from "./schema";
import { listTemplateVersions } from "./templates";
import { composeCertificateText } from "./text";
import type { Locale } from "@/i18n/config";
const START = "2026-10-16";
const TERMS: Record<string, CertificateTerm> = {
einmalig: fixedTermFor(START),
aboAktiv: { kind: "OPEN", start: START },
aboBeendet: { kind: "ENDED", start: START, end: "2027-09-30" },
};
// Widmung an der Grenze: 280 Zeichen, 5 Umbrueche, vier Absaetze knapp ueber
// einer Zeile — so entstehen die meisten Zeilen, die 280 Zeichen hergeben.
const MAX_DEDICATION = [
"Möge diese kleine Biene dich an sonnige Tage im Garten erinnern,",
"an Lindenblüten, Lavendel und an den Duft von frischem Honigbrot,",
"an lange Abende auf der Bank vor dem Haus und an alle Gespräche.",
"Danke für alles, was du für uns getan hast, jeden einzelnen Tag!",
"Alles Liebe",
"Anna",
].join("\n");
// Bewusst breite Buchstaben, an den Grenzen der Eingabelimits.
const MAX_BEE = "Wilhelmine-Mümmelwunder";
const MAX_BEE_SINGLE_WORD = "Sonnenblumenhonigliebste";
const MAX_RECIPIENT = "Marie-Luise Schmidt-Habermann von Hohenlinden-Württemberg";
const MAX_GIVERS = "Anna, Ben, Carla, Dominik, Emilia, Friedrich und die ganze Familie Özdemir-Lange";
const DATASETS: Record<string, Omit<Personalization, "occasion" | "startDate">> = {
sehrKurz: { beeName: "Bo", recipientName: "Li" },
maximal: { beeName: MAX_BEE, recipientName: MAX_RECIPIENT, giverNames: MAX_GIVERS, dedication: MAX_DEDICATION },
maximalEinWort: { beeName: MAX_BEE_SINGLE_WORD, recipientName: MAX_RECIPIENT, giverNames: MAX_GIVERS, dedication: MAX_DEDICATION },
doppelname: { beeName: "Marie-Luise", recipientName: "Marie-Luise Schmidt-Habermann", giverNames: "Anna und Ben" },
sonderzeichen: { beeName: "Zoë", recipientName: "Jürgen Weiß Ñúñez O’Brien", giverNames: "Anna, Ben, Carla und Familie Özdemir" },
ohneWidmung: { beeName: "Summsi", recipientName: "Erika Musterfrau" },
maxWidmung: { beeName: "Summsi", recipientName: "Erika Musterfrau", giverNames: "Anna", dedication: MAX_DEDICATION },
};
function build(version: string, data: Omit<Personalization, "occasion" | "startDate">, occasion: Personalization["occasion"], locale: Locale, term: CertificateTerm) {
// Durch das Schema, wie im Betrieb: normalisiert, Schenkende bei SELBST verworfen.
const personalization = personalizationSchema.parse({ ...data, occasion, startDate: START });
const text = composeCertificateText({ personalization, locale, term, number: "WH-P-2026-00042" });
return { personalization, text, layout: layoutCertificate(text, version) };
}
function texts(layout: CertificateLayout): TextElement[] {
return layout.elements.filter((el): el is TextElement => el.kind === "text");
}
/** Alle Pruefungen, die fuer jede Urkunde gelten. */
function assertSound(layout: CertificateLayout, personalization: Personalization): void {
expect(layout.width).toBe(PAGE_WIDTH);
expect(layout.height).toBe(PAGE_HEIGHT);
for (const el of layout.elements) {
const box = elementBounds(el);
const margin = el.kind === "path" && el.role === "frame" ? SAFE_FRAME : SAFE_CONTENT;
expect(box.x, `${el.kind} links im Rand`).toBeGreaterThanOrEqual(margin);
expect(box.y, `${el.kind} oben im Rand`).toBeGreaterThanOrEqual(margin);
expect(box.x + box.width, `${el.kind} rechts im Rand`).toBeLessThanOrEqual(PAGE_WIDTH - margin);
expect(box.y + box.height, `${el.kind} unten im Rand`).toBeLessThanOrEqual(PAGE_HEIGHT - margin);
}
const lines = texts(layout);
for (const el of lines) {
expect(el.text.trim(), "keine leeren Textzeilen").not.toBe("");
expect(el.size).toBeGreaterThanOrEqual(BODY_ROLES.includes(el.role) ? MIN_BODY_SIZE : MIN_TEXT_SIZE);
// Jede Zeile passt in ihren Satzkasten — nichts wird abgeschnitten.
expect(measureText(el.font, el.text, el.size, { letterSpacing: el.letterSpacing })).toBeLessThanOrEqual(el.width + 0.01);
}
// Keine Ueberlagerung: Tinte zweier Zeilen beruehrt sich nicht, Text liegt
// nicht auf Ornamenten, Illustrationen oder dem Logo (Rahmen umschliessen
// alles und sind ausgenommen).
const inks = lines.map((el) => ({ el, box: elementBounds(el) }));
inks.forEach((a, i) => {
inks.slice(i + 1).forEach((b) => {
expect(intersects(a.box, b.box), `"${a.el.text}" ueberlagert "${b.el.text}"`).toBe(false);
});
});
const decorations = layout.elements.filter((el) => el.kind === "image" || (el.kind === "path" && el.role !== "frame"));
const decorationBoxes = decorations.flatMap((deco) => {
if (deco.kind !== "path") return [elementBounds(deco)];
const half = deco.stroke ? (deco.strokeWidth ?? 1) / 2 : 0;
return subpathBounds(deco.d).map((b) => ({ x: b.x - half, y: b.y - half, width: b.width + 2 * half, height: b.height + 2 * half }));
});
for (const { el, box } of inks) {
for (const decoBox of decorationBoxes) {
expect(intersects(box, decoBox), `"${el.text}" liegt auf Schmuck bei ${Math.round(decoBox.x)}/${Math.round(decoBox.y)}`).toBe(false);
}
}
// Namen hoechstens zwei Zeilen, getrennt nur an Leerzeichen oder nach Bindestrich.
for (const [role, name] of [
["recipient", personalization.recipientName],
["beeName", personalization.beeName],
] as const) {
const parts = lines.filter((el) => el.role === role).map((el) => el.text);
expect(parts.length).toBeGreaterThanOrEqual(1);
expect(parts.length).toBeLessThanOrEqual(2);
if (parts.length === 2) {
const joined = parts[0].endsWith("-") ? parts[0] + parts[1] : `${parts[0]} ${parts[1]}`;
expect(joined).toBe(name);
} else {
expect(parts[0]).toBe(name);
}
}
// Leere Felder: keine Zeile, keine verwaiste Bezeichnung.
const roles = new Set(lines.map((el) => el.role));
expect(roles.has("giver")).toBe(Boolean(personalization.giverNames));
expect(roles.has("dedication")).toBe(Boolean(personalization.dedication));
expect(roles.has("lead")).toBe(personalization.occasion !== "SELBST");
for (const role of ["title", "subtitle", "term", "number", "notice"] as const) expect(roles.has(role)).toBe(true);
// Genau ein Logo-Platz (hochgeladenes Logo oder Waben-Zeichen).
expect(layout.elements.filter((el) => el.kind === "image")).toHaveLength(1);
}
describe.each(listTemplateVersions())("Vorlage %s", (version) => {
describe.each(Object.entries(DATASETS))("Datensatz %s", (_name, data) => {
test.each(["de", "en"] as const)("Geschenk und eigene Patenschaft (%s)", (locale) => {
for (const occasion of ["GEBURTSTAG", "SELBST"] as const) {
const { layout, personalization } = build(version, data, occasion, locale, TERMS.einmalig);
assertSound(layout, personalization);
}
});
});
test.each(["de", "en"] as const)("jeder Anlass und jede Laufzeitform (%s)", { timeout: 60_000 }, (locale) => {
for (const occasion of OCCASIONS) {
for (const term of Object.values(TERMS)) {
const { layout, personalization } = build(version, DATASETS.maxWidmung, occasion, locale, term);
assertSound(layout, personalization);
const occasionLines = texts(layout).filter((el) => el.role === "occasion");
expect(occasionLines).toHaveLength(["GEBURTSTAG", "WEIHNACHTEN", "DANKESCHOEN"].includes(occasion) ? 1 : 0);
}
}
});
test("Widmung wird woertlich und vollstaendig gesetzt (nur umbrochen)", () => {
const { layout } = build(version, DATASETS.maxWidmung, "GESCHENK", "de", TERMS.einmalig);
const dedication = texts(layout).filter((el) => el.role === "dedication").map((el) => el.text);
expect(dedication.join(" ").replace(/\s+/g, " ")).toBe(MAX_DEDICATION.replace(/\s+/g, " "));
});
test("Layoutvarianten: ohne Widmung grosszuegiger als mit langer Widmung", () => {
const gap = (layout: CertificateLayout) => {
const recipient = texts(layout).filter((el) => el.role === "recipient").at(-1);
const before = texts(layout).find((el) => el.role === "beforeBee");
return (before?.y ?? 0) - (recipient?.y ?? 0);
};
// Die Variante folgt aus der gemessenen Hoehe: erst wenn der Inhalt den
// Raum fuellt (lange Namen, Schenkende, maximale Widmung), wird es dichter.
const withoutDedication = { ...DATASETS.maximal, dedication: undefined };
const airy = build(version, withoutDedication, "GEBURTSTAG", "de", TERMS.einmalig).layout;
const dense = build(version, DATASETS.maximal, "GEBURTSTAG", "de", TERMS.einmalig).layout;
expect(gap(airy)).toBeGreaterThan(gap(dense));
});
test("Bienenname in festen Stufen; zu langer Name ohne Trennstelle ist ein Fehler mit Feld", () => {
const sizes = (beeName: string) =>
texts(build(version, { ...DATASETS.ohneWidmung, beeName }, "GESCHENK", "de", TERMS.einmalig).layout)
.filter((el) => el.role === "beeName")
.map((el) => el.size);
const short = sizes("Summsi")[0];
const long = sizes(MAX_BEE_SINGLE_WORD)[0];
expect(long).toBeLessThan(short);
const text = composeCertificateText({
personalization: { beeName: "W".repeat(40), recipientName: "Erika", occasion: "GESCHENK", startDate: START },
locale: "de",
term: TERMS.einmalig,
number: "WH-P-2026-00042",
});
expect(() => layoutCertificate(text, version)).toThrow(CertificateLayoutError);
try {
layoutCertificate(text, version);
} catch (error: unknown) {
expect((error as CertificateLayoutError).field).toBe("beeName");
}
});
});
describe("Eingabelimits passen in die kleinste Stufe", () => {
test("Grenzwerte entsprechen CERTIFICATE_LIMITS", () => {
expect([...MAX_BEE].length).toBeLessThanOrEqual(CERTIFICATE_LIMITS.beeName.max);
expect([...MAX_BEE_SINGLE_WORD].length).toBe(CERTIFICATE_LIMITS.beeName.max);
expect([...MAX_RECIPIENT].length).toBeLessThanOrEqual(CERTIFICATE_LIMITS.recipientName.max);
expect([...MAX_GIVERS].length).toBe(CERTIFICATE_LIMITS.giverNames.max);
expect([...MAX_DEDICATION].length).toBeLessThanOrEqual(CERTIFICATE_LIMITS.dedication.max);
expect(MAX_DEDICATION.split("\n")).toHaveLength(CERTIFICATE_LIMITS.dedication.maxLineBreaks + 1);
});
});
describe("checkCertificateFits", () => {
const base: Personalization = { beeName: "Summsi", recipientName: "Erika Musterfrau", occasion: "GEBURTSTAG", startDate: START };
test("gueltige Personalisierung passt und liefert das Layout", () => {
const result = checkCertificateFits(base, "de");
expect(result.ok).toBe(true);
if (result.ok) expect(layoutLines(result.layout)).toContain("beeName: Summsi");
});
test("fehlende Glyphe (Emoji) wird mit Feld und Zeichen gemeldet", () => {
expect(checkCertificateFits({ ...base, dedication: "Alles Liebe 🐝" }, "de")).toEqual({
ok: false,
field: "dedication",
reason: "glyphs",
characters: ["🐝"],
message: "Die Widmung enthält Zeichen, die auf der Urkunde nicht gedruckt werden können: 🐝",
});
});
test("unteilbares ueberlanges Wort in der Widmung ist ein Feldfehler, kein Absturz", () => {
expect(checkCertificateFits({ ...base, dedication: "x".repeat(120) }, "de")).toMatchObject({
ok: false,
field: "dedication",
reason: "fit",
message: expect.stringContaining("Die Widmung passt so nicht"),
});
});
});
+114
View File
@@ -0,0 +1,114 @@
import type { Locale } from "@/i18n/config";
import { CertificateLayoutError, type CertificateField, type CertificateLayout } from "@/lib/certificate/elements";
import { FONT_KEYS, findUnsupportedCharacters } from "@/lib/certificate/fonts";
import { fixedTermFor, type CertificateTerm, type Personalization } from "@/lib/certificate/schema";
import { composeCertificateText, type CertificateText } from "@/lib/certificate/text";
import { CURRENT_TEMPLATE_VERSION, getTemplate } from "@/lib/certificate/templates";
/**
* Einstieg in die Layout-Berechnung (Spec "Ein Layout, mehrere Ausgaben"):
*
* composeCertificateText() → aufgeloester Text
* layoutCertificate() → positionierte Elemente (diese Datei)
* renderCertificatePdf() / renderCertificateSvg() / renderCertificateImage()
*
* Reine Funktionen ohne Datenbank: dieselbe Eingabe ergibt dasselbe Layout,
* auf dem Server fuer Vorschau, Textur und PDF gleichermassen.
*/
export {
CertificateLayoutError,
PAGE_HEIGHT,
PAGE_WIDTH,
SAFE_CONTENT,
SAFE_FRAME,
MIN_TEXT_SIZE,
MIN_BODY_SIZE,
textStartX,
elementBounds,
type CertificateField,
type CertificateLayout,
type LayoutElement,
type TextElement,
type PathElement,
type ImageElement,
type TextRole,
} from "@/lib/certificate/elements";
export { CURRENT_TEMPLATE_VERSION, getTemplate } from "@/lib/certificate/templates";
export function layoutCertificate(text: CertificateText, templateVersion: string = CURRENT_TEMPLATE_VERSION): CertificateLayout {
return getTemplate(templateVersion).layout(text);
}
/** Alle Textzeilen in Zeichenreihenfolge als "rolle: text" — Grundlage fuer den Zeilenvergleich der Ausgaben. */
export function layoutLines(layout: CertificateLayout): string[] {
return layout.elements.flatMap((el) => (el.kind === "text" ? [`${el.role}: ${el.text}`] : []));
}
export type FitCheck =
| { ok: true; layout: CertificateLayout }
| { ok: false; field: CertificateField; reason: "glyphs"; characters: string[]; message: string }
| { ok: false; field: CertificateField; reason: "fit"; message: string };
const FIELD_LABELS: Record<CertificateField, string> = {
beeName: "Der Name der Patenbiene",
recipientName: "Der Name auf der Urkunde",
giverNames: "Die Angabe der Schenkenden",
dedication: "Die Widmung",
occasion: "Die Anlasszeile",
layout: "Der Urkundentext",
};
/** Deutsche Meldung fuer Formular und Checkout; die Felder field/reason erlauben eine eigene Uebersetzung. */
function fitMessage(field: CertificateField, reason: "glyphs" | "fit", characters: string[] = []): string {
if (reason === "glyphs") {
return `${FIELD_LABELS[field]} enthält Zeichen, die auf der Urkunde nicht gedruckt werden können: ${characters.join(" ")}`;
}
return `${FIELD_LABELS[field]} passt so nicht auf die Urkunde. Bitte kürzen oder an einer Leerstelle teilen.`;
}
// Breiteste plausible Nummer fuer die Pruefung vor der Bestellung (die echte
// Nummer entsteht erst nach der Zahlung).
const SAMPLE_NUMBER = "WH-P-2026-00000";
const CUSTOMER_FIELDS: ReadonlyArray<[CertificateField, keyof Personalization]> = [
["beeName", "beeName"],
["recipientName", "recipientName"],
["giverNames", "giverNames"],
["dedication", "dedication"],
];
/**
* Serverseitige Pruefung einer bereits per zod validierten Personalisierung:
* 1. jedes Zeichen der Kundenfelder hat in allen Urkundenschriften eine
* Glyphe (bewusst alle Schnitte, nicht nur die der aktuellen Vorlage —
* so bleibt eine Bestellung auch mit einer spaeteren Vorlage druckbar);
* 2. das Layout passt in die kleinste Stufe.
* Vorschau-API und Checkout nutzen dieselbe Funktion.
*/
export function checkCertificateFits(
personalization: Personalization,
locale: Locale,
opts?: { term?: CertificateTerm; templateVersion?: string; number?: string }
): FitCheck {
for (const [field, key] of CUSTOMER_FIELDS) {
const value = personalization[key];
if (!value) continue;
const characters = findUnsupportedCharacters(value, FONT_KEYS);
if (characters.length > 0) return { ok: false, field, reason: "glyphs", characters, message: fitMessage(field, "glyphs", characters) };
}
const text = composeCertificateText({
personalization,
locale,
term: opts?.term ?? fixedTermFor(personalization.startDate),
number: opts?.number ?? SAMPLE_NUMBER,
});
try {
return { ok: true, layout: layoutCertificate(text, opts?.templateVersion) };
} catch (error: unknown) {
if (error instanceof CertificateLayoutError) {
return { ok: false, field: error.field, reason: "fit", message: fitMessage(error.field, "fit") };
}
throw error;
}
}
+52
View File
@@ -0,0 +1,52 @@
import sharp from "sharp";
/**
* Markenlogo auf der Urkunde. Erlaubt sind PNG, JPEG und SVG (Spec: fuer den
* Druck als SVG oder als PNG mit mindestens 300 dpi in Druckgroesse). Das
* Logo wird nur eingepasst — nie verzerrt, umgefaerbt oder nachgezeichnet.
*
* pdfkit kann kein SVG einbetten. Fuer die PDF wird ein SVG-Logo deshalb mit
* hoher Aufloesung gerastert (LOGO_RASTER_PX auf der laengeren Seite — bei
* einer Logo-Box von rund 3 cm deutlich ueber 600 dpi). Die Bildausgaben
* betten das SVG direkt ein.
*/
export type LogoKind = "png" | "jpeg" | "svg";
export interface SniffedLogo {
data: Buffer;
kind: LogoKind;
}
const LOGO_RASTER_PX = 1600;
export class UnsupportedLogoError extends Error {
constructor() {
super("Logo fuer die Urkunde muss PNG, JPEG oder SVG sein.");
this.name = "UnsupportedLogoError";
}
}
/** Erkennt das Format am Dateiinhalt (nicht an Endung oder MIME-Angabe). */
export function sniffLogo(data: Buffer): SniffedLogo {
if (data.length >= 8 && data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
return { data, kind: "png" };
}
if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return { data, kind: "jpeg" };
const head = data.subarray(0, 1024).toString("utf8").replace(/^\uFEFF/, "").trimStart();
if ((head.startsWith("<?xml") || head.startsWith("<svg") || head.startsWith("<!--")) && head.includes("<svg")) {
return { data, kind: "svg" };
}
throw new UnsupportedLogoError();
}
/** Logo in einer Form, die pdfkit einbetten kann (PNG/JPEG unveraendert, SVG gerastert). */
export async function logoForPdf(data: Buffer): Promise<SniffedLogo> {
const logo = sniffLogo(data);
if (logo.kind !== "svg") return logo;
const png = await sharp(data, { density: 600 })
.resize(LOGO_RASTER_PX, LOGO_RASTER_PX, { fit: "inside", withoutEnlargement: false })
.png()
.toBuffer();
return { data: png, kind: "png" };
}
+155
View File
@@ -0,0 +1,155 @@
import sharp from "sharp";
import type { Locale } from "@/i18n/config";
import { logoFallbackPaths } from "@/lib/certificate/art/logomark";
import {
CERT_COLORS,
PAGE_HEIGHT,
PAGE_WIDTH,
textStartX,
type CertificateLayout,
type ImageElement,
type PathElement,
type TextElement,
} from "@/lib/certificate/elements";
import { measureText, textToPath, type FontKey } from "@/lib/certificate/fonts";
import { sniffLogo, type LogoKind } from "@/lib/certificate/logo";
/**
* Bildausgabe der Urkunde: Vorschau (mit Wasserzeichen) und 3D-Textur (ohne).
*
* Jede Textzeile wird mit textToPath() aus derselben TTF in Glyphenpfade
* umgewandelt, an exakt die Position, die auch der PDF-Renderer nutzt
* (textStartX + Grundlinie). Es gibt kein <text>-Element: das Ergebnis haengt
* nicht von Schriften auf dem Server oder im Browser ab, und Vorschau,
* Textur und PDF brechen garantiert gleich um — sie lesen dasselbe Layout.
*
* Jede Zeile steht in einer eigenen Gruppe mit data-role/data-text, damit
* Tests die Zeilen von SVG und PDF vergleichen koennen (render.test.ts).
*/
export const WATERMARK_TEXT: Record<Locale, string> = {
de: "Unverbindliche Vorschau",
en: "Preview – not binding",
};
/**
* Wasserzeichen: "de"/"en" ergibt den festen Vorschautext der Sprache, jeder
* andere Wert wird woertlich gesetzt (z.B. "Korrekturvorschau" im Admin).
* Weglassen fuer die Textur.
*/
export type WatermarkOption = Locale | (string & {}) | null;
export interface SvgOptions {
watermark?: WatermarkOption;
/** Hochgeladenes Logo (PNG oder SVG); ohne Logo erscheint das Waben-Zeichen. */
logo?: Buffer | null;
/** Pixelbreite des Bildes; Hoehe folgt dem A4-Seitenverhaeltnis. */
widthPx?: number;
}
export interface ImageOptions {
widthPx: number;
watermark?: WatermarkOption;
format: "png" | "webp";
logo?: Buffer | null;
}
function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function pathMarkup(el: PathElement): string {
const attrs = [
`d="${el.d}"`,
`fill="${el.fill ?? "none"}"`,
el.stroke ? `stroke="${el.stroke}" stroke-width="${el.strokeWidth ?? 1}"` : "",
el.lineCap ? `stroke-linecap="${el.lineCap}"` : "",
el.lineJoin ? `stroke-linejoin="${el.lineJoin}"` : "",
el.opacity !== undefined && el.opacity < 1 ? `opacity="${el.opacity}"` : "",
].filter(Boolean);
return `<path data-role="${el.role}" ${attrs.join(" ")}/>`;
}
function textMarkup(el: TextElement): string {
const d = textToPath(el.font, el.text, el.size, textStartX(el), el.y, { letterSpacing: el.letterSpacing });
return `<g data-role="${el.role}" data-text="${escapeXml(el.text)}" fill="${el.color}"><path d="${d}"/></g>`;
}
const ALIGN_TO_SVG: Record<ImageElement["align"], string> = { left: "xMinYMid", center: "xMidYMid", right: "xMaxYMid" };
function logoMarkup(el: ImageElement, logo: { data: Buffer; kind: LogoKind } | null): string {
if (!logo) return logoFallbackPaths(el).map(pathMarkup).join("");
const mime = logo.kind === "svg" ? "image/svg+xml" : "image/png";
// Einpassen ohne Verzerrung, ausgerichtet wie die Box (Spec: Logo nicht verzerren oder umfaerben).
return (
`<image data-role="logo" x="${el.x}" y="${el.y}" width="${el.width}" height="${el.height}" ` +
`preserveAspectRatio="${ALIGN_TO_SVG[el.align]} meet" href="data:${mime};base64,${logo.data.toString("base64")}"/>`
);
}
// Wasserzeichen: drei diagonale Zeilen uebereinander, jede um ihre eigene
// Mitte gedreht — so bleibt jede Zeile vollstaendig auf dem Blatt. Honiggold
// dunkel mit geringer Deckkraft: deutlich genug, dass ein Bildschirmfoto
// nicht als Urkunde taugt, zurueckhaltend genug, dass Namen und Widmung
// lesbar bleiben.
const WATERMARK_FONT: FontKey = "sansMedium";
const WATERMARK_SIZE = 26;
const WATERMARK_SPACING = 3;
const WATERMARK_ANGLE = -35;
const WATERMARK_ROWS = [-0.31, 0, 0.31];
function watermarkText(option: string): string {
return option === "de" || option === "en" ? WATERMARK_TEXT[option] : option;
}
function watermarkMarkup(option: string): string {
const label = watermarkText(option);
const text = label.toUpperCase();
const width = measureText(WATERMARK_FONT, text, WATERMARK_SIZE, { letterSpacing: WATERMARK_SPACING });
const cx = PAGE_WIDTH / 2;
const rows = WATERMARK_ROWS.map((offset) => {
const cy = PAGE_HEIGHT / 2 + offset * PAGE_HEIGHT;
const d = textToPath(WATERMARK_FONT, text, WATERMARK_SIZE, cx - width / 2, cy + WATERMARK_SIZE * 0.36, {
letterSpacing: WATERMARK_SPACING,
});
return `<path transform="rotate(${WATERMARK_ANGLE} ${cx} ${cy.toFixed(2)})" d="${d}"/>`;
}).join("");
return `<g data-watermark="${escapeXml(label)}" fill="${CERT_COLORS.honeyDark}" opacity="0.16">${rows}</g>`;
}
/** SVG der Urkunde, Text vollstaendig als Glyphenpfade. */
export function renderCertificateSvg(layout: CertificateLayout, opts: SvgOptions = {}): string {
const widthPx = opts.widthPx ?? 1240;
const heightPx = Math.round((widthPx * layout.height) / layout.width);
const logo = opts.logo ? sniffLogo(opts.logo) : null;
const body = layout.elements
.map((el) => {
if (el.kind === "path") return pathMarkup(el);
if (el.kind === "text") return textMarkup(el);
return logoMarkup(el, logo);
})
.join("\n");
return [
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${layout.width} ${layout.height}" width="${widthPx}" height="${heightPx}" data-template="${escapeXml(layout.templateVersion)}">`,
`<rect width="${layout.width}" height="${layout.height}" fill="${CERT_COLORS.paper}"/>`,
body,
opts.watermark ? watermarkMarkup(opts.watermark) : "",
"</svg>",
].join("\n");
}
/** Raster (PNG oder WebP) aus dem SVG. 2480 px Breite entsprechen 300 dpi auf A4. */
export async function renderCertificateImage(layout: CertificateLayout, opts: ImageOptions): Promise<Buffer> {
const svg = renderCertificateSvg(layout, { watermark: opts.watermark, logo: opts.logo, widthPx: opts.widthPx });
// limitInputPixels: die Textur mit 2480 px liegt ueber der sharp-Vorgabe nicht, aber eine
// spaetere 600-dpi-Ausgabe soll nicht still scheitern.
const image = sharp(Buffer.from(svg), { limitInputPixels: 10_000 * 14_000 }).flatten({ background: CERT_COLORS.paper });
return opts.format === "png" ? image.png().toBuffer() : image.webp({ quality: 88 }).toBuffer();
}
+143
View File
@@ -0,0 +1,143 @@
import PDFDocument from "pdfkit";
import { logoFallbackPaths } from "@/lib/certificate/art/logomark";
import { textStartX, type CertificateLayout, type ImageElement, type PathElement, type TextElement } from "@/lib/certificate/elements";
import { FONT_FILES, FONT_KEYS, type FontKey } from "@/lib/certificate/fonts";
import { logoForPdf, type SniffedLogo } from "@/lib/certificate/logo";
import type { CertificateText } from "@/lib/certificate/text";
/**
* Druck-PDF der Urkunde mit pdfkit: genau eine Seite A4 hochkant, Text als
* echter Text mit eingebetteten Schrift-Teilmengen (Cormorant Garamond,
* Inter aus assets/fonts), Rahmen und Illustrationen als Vektorpfade.
*
* Positionen kommen unveraendert aus dem Layout: jede Zeile beginnt bei
* textStartX() und steht mit baseline "alphabetic" genau auf der
* Layout-Grundlinie (pdfkit misst y sonst von der Oberkante der Zeile).
* Kein Umbruch durch pdfkit (lineBreak: false) — umbrochen hat das Layout.
*
* Byte-genau reproduzierbar: gleiche Eingabe und gleiches creationDate
* ergeben dieselbe Datei. Die Ablage schreibt PDFs exklusiv und vergleicht
* bei einer Wiederholung die SHA-256; das aktuelle Datum als Vorgabe wuerde
* das brechen.
*/
export const CERTIFICATE_AUTHOR = "Imkerei Wabenhain";
/** Fester Zeitstempel, falls der Aufrufer keinen mitgibt (nie die aktuelle Uhrzeit). */
const DEFAULT_CREATION_DATE = new Date("2026-01-01T00:00:00Z");
export interface CertificatePdfMeta {
title: string;
/** Hochgeladenes Markenlogo als PNG, JPEG oder SVG; null/fehlend = Waben-Zeichen. */
logo?: Buffer | null;
/** Zeitpunkt der Revision — setzt CreationDate und ModDate. */
creationDate?: Date;
}
/** Titel der PDF-Metadaten: "Bienenpatenschaft {Bienenname}" bzw. englisch. */
export function certificatePdfTitle(text: CertificateText): string {
return `${text.title} ${text.beeName}`;
}
/** Was der PDF-Renderer pro Textzeile zeichnet — fuer den Zeilenvergleich mit der Bildausgabe. */
export interface PdfTextOp {
role: TextElement["role"];
text: string;
font: FontKey;
size: number;
x: number;
y: number;
characterSpacing: number;
}
export function pdfTextOps(layout: CertificateLayout): PdfTextOp[] {
return layout.elements.flatMap((el) =>
el.kind === "text"
? [{ role: el.role, text: el.text, font: el.font, size: el.size, x: textStartX(el), y: el.y, characterSpacing: el.letterSpacing ?? 0 }]
: []
);
}
/** XMP/Info-Felder duerfen kein XML aufbrechen (wie xmpSafe in lib/einvoice/facturx-pdf.ts). */
function metaSafe(value: string): string {
return value.replace(/[<>&]/g, " ").replace(/\s+/g, " ").trim();
}
function drawPath(doc: PDFKit.PDFDocument, el: PathElement): void {
doc.save();
doc.path(el.d);
if (el.opacity !== undefined) doc.opacity(el.opacity);
if (el.lineCap) doc.lineCap(el.lineCap);
if (el.lineJoin) doc.lineJoin(el.lineJoin);
if (el.fill && el.stroke) {
doc.lineWidth(el.strokeWidth ?? 1).fillAndStroke(el.fill, el.stroke);
} else if (el.fill) {
doc.fill(el.fill);
} else if (el.stroke) {
doc.lineWidth(el.strokeWidth ?? 1).stroke(el.stroke);
}
doc.restore();
}
function drawText(doc: PDFKit.PDFDocument, op: PdfTextOp, color: string): void {
doc
.font(op.font)
.fontSize(op.size)
.fillColor(color)
.text(op.text, op.x, op.y, {
lineBreak: false,
baseline: "alphabetic",
characterSpacing: op.characterSpacing,
});
}
/** Logo eingepasst wie im SVG (preserveAspectRatio meet), ausgerichtet nach der Box. */
function drawLogo(doc: PDFKit.PDFDocument, el: ImageElement, logo: SniffedLogo | null): void {
if (!logo) {
logoFallbackPaths(el).forEach((path) => drawPath(doc, path));
return;
}
// pdfkit kennt nur "center"/"right"; ohne Angabe steht das Bild links.
const align = el.align === "left" ? undefined : el.align;
doc.image(logo.data, el.x, el.y, { fit: [el.width, el.height], align, valign: "center" });
}
export async function renderCertificatePdf(layout: CertificateLayout, meta: CertificatePdfMeta): Promise<Buffer> {
const logo = meta.logo ? await logoForPdf(meta.logo) : null;
const date = meta.creationDate ?? DEFAULT_CREATION_DATE;
const doc = new PDFDocument({
size: [layout.width, layout.height],
margin: 0,
pdfVersion: "1.7",
// Eingebettete Grundschrift statt der Standard-Helvetica, die sonst
// nicht eingebettet im Dokument stuende (wie pdfaOptions der Rechnung).
font: FONT_FILES.sans,
info: {
Title: metaSafe(meta.title),
Author: CERTIFICATE_AUTHOR,
Creator: CERTIFICATE_AUTHOR,
Producer: CERTIFICATE_AUTHOR,
CreationDate: date,
ModDate: date,
},
});
for (const key of FONT_KEYS) doc.registerFont(key, FONT_FILES[key]);
const chunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
const done = new Promise<Buffer>((resolve, reject) => {
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
});
const ops = pdfTextOps(layout);
let textIndex = 0;
for (const el of layout.elements) {
if (el.kind === "path") drawPath(doc, el);
else if (el.kind === "image") drawLogo(doc, el, logo);
else drawText(doc, ops[textIndex++], el.color);
}
doc.end();
return done;
}
+232
View File
@@ -0,0 +1,232 @@
// @vitest-environment node
//
// Node statt jsdom: pdfkit erkennt Schriften und Bilder an
// "instanceof Uint8Array", unter jsdom stammt Buffer aus einem anderen Realm
// (siehe lib/invoice-pdf.test.ts). sharp und fontkit brauchen ohnehin Node.
import { inflateSync } from "node:zlib";
import PDFDocument from "pdfkit";
import sharp from "sharp";
import { describe, expect, test } from "vitest";
import { layoutCertificate, layoutLines, PAGE_HEIGHT, textStartX, type CertificateLayout } from "./layout";
import { FONT_FILES, measureText, type FontKey } from "./fonts";
import { renderCertificateImage, renderCertificateSvg, WATERMARK_TEXT } from "./render-image";
import { CERTIFICATE_AUTHOR, certificatePdfTitle, pdfTextOps, renderCertificatePdf } from "./render-pdf";
import { fixedTermFor, type Personalization } from "./schema";
import { listTemplateVersions } from "./templates";
import { composeCertificateText } from "./text";
const PERSONALIZATION: Personalization = {
beeName: "Summsi",
recipientName: "Marie-Luise Schmidt",
giverNames: "Anna & Ben",
dedication: 'Für die beste Gärtnerin –\n<script>alert("x")</script>',
occasion: "WEIHNACHTEN",
startDate: "2026-12-24",
};
function sample(version?: string) {
const text = composeCertificateText({
personalization: PERSONALIZATION,
locale: "de",
term: fixedTermFor(PERSONALIZATION.startDate),
number: "WH-P-2026-00042",
});
return { text, layout: layoutCertificate(text, version) };
}
const CREATED = new Date("2026-12-24T09:30:00Z");
/** Entpackte Inhaltsstroeme der PDF (Seiteninhalt, Fonts, ...) als Text. */
function inflatedStreams(pdf: Buffer): string[] {
const raw = pdf.toString("latin1");
const streams: string[] = [];
const pattern = /stream\r?\n/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(raw))) {
const start = match.index + match[0].length;
const end = raw.indexOf("endstream", start);
const chunk = pdf.subarray(start, end);
try {
streams.push(inflateSync(chunk).toString("latin1"));
} catch {
streams.push(chunk.toString("latin1"));
}
}
return streams;
}
function pageCount(pdf: Buffer): number {
return (pdf.toString("latin1").match(/\/Type \/Page\b(?!s)/g) ?? []).length;
}
/** Text-Matrizen (Tm) aus dem Seiteninhalt, je BT…ET die erste. */
function textOrigins(pdf: Buffer): Array<{ x: number; y: number }> {
const content = inflatedStreams(pdf).find((stream) => stream.includes(" Tm") && stream.includes("BT"));
if (!content) return [];
return [...content.matchAll(/BT\s+1 0 0 1 (-?[\d.]+) (-?[\d.]+) Tm/g)].map((m) => ({ x: Number(m[1]), y: Number(m[2]) }));
}
describe.each(listTemplateVersions())("PDF-Ausgabe %s", (version) => {
test("PDF, genau eine Seite A4, Metadaten, eingebettete Schrift-Teilmengen", async () => {
const { text, layout } = sample(version);
const pdf = await renderCertificatePdf(layout, { title: certificatePdfTitle(text), creationDate: CREATED });
const raw = pdf.toString("latin1");
expect(raw.startsWith("%PDF-")).toBe(true);
expect(pageCount(pdf)).toBe(1);
expect(raw).toMatch(/\/MediaBox \[0 0 595\.28 841\.89\]/);
expect(raw).toContain("(Bienenpatenschaft Summsi)");
expect(raw).toContain(`(${CERTIFICATE_AUTHOR})`);
// TrueType-Teilmengen (FontFile2) mit Subset-Praefix "ABCDEF+".
expect((raw.match(/\/FontFile2/g) ?? []).length).toBeGreaterThanOrEqual(3);
expect(raw).toMatch(/\/BaseFont \/[A-Z]{6}\+CormorantGaramond-SemiBoldItalic/);
expect(raw).toMatch(/\/BaseFont \/[A-Z]{6}\+Inter-Regular/);
// Keine nicht eingebettete Standardschrift.
expect(raw).not.toMatch(/\/BaseFont \/Helvetica/);
});
test("Text steht exakt an den Layout-Positionen (Zeilenanfang und Grundlinie)", async () => {
const { text, layout } = sample(version);
const pdf = await renderCertificatePdf(layout, { title: certificatePdfTitle(text), creationDate: CREATED });
const origins = textOrigins(pdf);
const ops = pdfTextOps(layout);
expect(origins).toHaveLength(ops.length);
origins.forEach((origin, index) => {
expect(origin.x).toBeCloseTo(ops[index].x, 2);
// pdfkit spiegelt die y-Achse: Tm-y = Seitenhoehe − Grundlinie.
expect(origin.y).toBeCloseTo(PAGE_HEIGHT - ops[index].y, 2);
});
});
test("byte-genau reproduzierbar bei gleichem creationDate", async () => {
const { text, layout } = sample(version);
const meta = { title: certificatePdfTitle(text), creationDate: CREATED };
const [a, b] = await Promise.all([renderCertificatePdf(layout, meta), renderCertificatePdf(layout, meta)]);
expect(a.equals(b)).toBe(true);
const other = await renderCertificatePdf(layout, { ...meta, creationDate: new Date("2027-01-01T00:00:00Z") });
expect(other.equals(a)).toBe(false);
});
});
describe("Laufweiten: Engine und pdfkit messen gleich", () => {
test.each<[FontKey, string]>([
["serifSemiBoldItalic", "Hildegard-Wilhelmine"],
["serif", "Einer Biene der Imkerei Wabenhain wurde symbolisch der Name"],
["serifItalic", "Für die beste Gärtnerin der Welt – mögen deine Blüten immer summen."],
["sans", "Patenschaft vom 24. Dezember 2026 bis 23. Dezember 2027"],
["serifMedium", "Jürgen Weiß Ñúñez O’Brien AVA To Wa"],
])("%s: %s", (font, value) => {
const doc = new PDFDocument({ font: FONT_FILES.sans });
doc.registerFont(font, FONT_FILES[font]);
doc.font(font).fontSize(23);
expect(measureText(font, value, 23)).toBeCloseTo(doc.widthOfString(value), 6);
});
});
describe.each(listTemplateVersions())("Bildausgabe %s", (version) => {
test("SVG ohne <text>; eine Pfadgruppe je Textzeile, gleiche Zeilen wie die PDF", () => {
const { layout } = sample(version);
const svg = renderCertificateSvg(layout);
expect(svg).not.toMatch(/<text[\s>]/);
const groups = [...svg.matchAll(/<g data-role="([a-zA-Z]+)" data-text="([^"]*)"/g)].map((m) => ({ role: m[1], text: m[2] }));
const unescape = (value: string) =>
value.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
const svgLines = groups.map((g) => `${g.role}: ${unescape(g.text)}`);
const pdfLines = pdfTextOps(layout).map((op) => `${op.role}: ${op.text}`);
expect(svgLines).toEqual(layoutLines(layout));
expect(pdfLines).toEqual(layoutLines(layout));
});
test("Glyphenpfade beginnen am selben Zeilenanfang wie im PDF", () => {
const { layout } = sample(version);
const svg = renderCertificateSvg(layout);
const beeName = layout.elements.find((el) => el.kind === "text" && el.role === "beeName");
if (!beeName || beeName.kind !== "text") throw new Error("kein Bienenname im Layout");
const group = svg.match(/<g data-role="beeName" data-text="Summsi"[^>]*><path d="M(-?[\d.]+) /);
expect(group).not.toBeNull();
// Erster Punkt des "S" liegt innerhalb seiner Laufweite ab textStartX.
const firstX = Number(group?.[1]);
const start = textStartX(beeName);
expect(firstX).toBeGreaterThanOrEqual(start - 2);
expect(firstX).toBeLessThanOrEqual(start + measureText(beeName.font, "S", beeName.size));
});
test("Kundentext wird escaped, nie als Markup eingebettet", () => {
const { layout } = sample(version);
const svg = renderCertificateSvg(layout);
expect(svg).not.toContain("<script");
expect(svg).toContain("&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;");
expect(svg).toContain("Anna &amp; Ben");
});
});
describe("Wasserzeichen und Bildgroesse", () => {
test("Vorschau traegt das Wasserzeichen als Glyphenpfade, die Textur nicht", () => {
const { layout } = sample();
const preview = renderCertificateSvg(layout, { watermark: "de" });
expect(preview).toContain(`data-watermark="${WATERMARK_TEXT.de}"`);
expect(preview).toMatch(/<g data-watermark="[^"]+"[^>]*><path transform="rotate\(-35 [^)]+\)" d="M/);
expect((preview.match(/<path transform="rotate\(-35/g) ?? []).length).toBe(3);
expect(renderCertificateSvg(layout, { watermark: "en" })).toContain('data-watermark="Preview – not binding"');
expect(renderCertificateSvg(layout)).not.toContain("data-watermark");
// Freier Text (Admin-Korrekturvorschau) wird woertlich gesetzt.
expect(renderCertificateSvg(layout, { watermark: "Korrekturvorschau" })).toContain('data-watermark="Korrekturvorschau"');
});
test("Bildbreite wie angefordert, Hoehe im A4-Verhaeltnis", async () => {
const { layout } = sample();
const webp = await renderCertificateImage(layout, { widthPx: 600, format: "webp", watermark: "de" });
const meta = await sharp(webp).metadata();
expect(meta.format).toBe("webp");
expect(meta.width).toBe(600);
expect(meta.height).toBe(Math.round((600 * 841.89) / 595.28));
});
});
describe("Logo", () => {
async function pngLogo(): Promise<Buffer> {
return sharp({ create: { width: 400, height: 120, channels: 4, background: { r: 36, g: 53, b: 43, alpha: 1 } } }).png().toBuffer();
}
const SVG_LOGO = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 30"><rect width="100" height="30" fill="#24352b"/></svg>');
function imageCount(pdf: Buffer): number {
return (pdf.toString("latin1").match(/\/Subtype \/Image/g) ?? []).length;
}
test("ohne Logo: Waben-Zeichen als Vektor, kein Rasterbild", async () => {
const { text, layout } = sample();
const pdf = await renderCertificatePdf(layout, { title: certificatePdfTitle(text), creationDate: CREATED });
expect(imageCount(pdf)).toBe(0);
expect(renderCertificateSvg(layout)).toContain('data-role="logo" d=');
});
test.each([
["PNG", pngLogo],
["SVG", async () => SVG_LOGO],
])("%s-Logo wird eingebettet und eingepasst", async (_label, load) => {
const { text, layout } = sample();
const logo = await load();
const pdf = await renderCertificatePdf(layout, { title: certificatePdfTitle(text), logo, creationDate: CREATED });
expect(imageCount(pdf)).toBeGreaterThanOrEqual(1);
expect(pageCount(pdf)).toBe(1);
const svg = renderCertificateSvg(layout, { logo });
expect(svg).toMatch(/<image data-role="logo" [^>]*preserveAspectRatio="xMidYMid meet" href="data:image\/(png|svg\+xml);base64,/);
});
test("unbekanntes Logoformat wird abgelehnt", async () => {
const { text, layout } = sample();
await expect(renderCertificatePdf(layout, { title: certificatePdfTitle(text), logo: Buffer.from("GIF89a") })).rejects.toThrow(/PNG, JPEG oder SVG/);
});
});
describe("Gleiche Zeilen in allen Ausgaben fuer die Vorlagen-Matrix", () => {
test.each(listTemplateVersions())("%s: Vorschau, Textur und PDF teilen das Layout", (version) => {
const { layout } = sample(version);
const linesOf = (l: CertificateLayout) => layoutLines(l);
const preview = renderCertificateSvg(layout, { watermark: "de" });
const texture = renderCertificateSvg(layout, { widthPx: 2048 });
const count = (svg: string) => (svg.match(/<g data-role="[a-zA-Z]+" data-text=/g) ?? []).length;
expect(count(preview)).toBe(linesOf(layout).length);
expect(count(texture)).toBe(linesOf(layout).length);
expect(pdfTextOps(layout)).toHaveLength(linesOf(layout).length);
});
});
+154
View File
@@ -0,0 +1,154 @@
// @vitest-environment node
//
// Node statt jsdom: findUnsupportedCharacters liest die Schriften mit node:fs
// (fontkit), und die Glyphenpruefung gehoert zur Eingabevalidierung.
import { describe, expect, test } from "vitest";
import {
CERTIFICATE_LIMITS,
countCharacters,
fixedTermFor,
isIsoDate,
normalizeCertificateText,
personalizationSchema,
startDateRange,
type Personalization,
} from "./schema";
import { findUnsupportedCharacters } from "./fonts";
const base = {
beeName: "Summsi",
recipientName: "Erika Musterfrau",
occasion: "GEBURTSTAG",
startDate: "2026-10-16",
} as const;
function issues(input: Record<string, unknown>): Array<{ path: string; message: string }> {
const result = personalizationSchema.safeParse({ ...base, ...input });
if (result.success) return [];
return result.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message }));
}
describe("normalizeCertificateText", () => {
test("fasst Leerraum zusammen, kuerzt Raender und normalisiert nach NFC", () => {
// "Zoe" mit kombinierendem Trema (U+0308) wird zum vorkomponierten "ë".
expect(normalizeCertificateText(" Zoë Muster \t ")).toBe("Zoë Muster");
expect(normalizeCertificateText("Zoë").length).toBe(3);
});
test("entfernt Steuer- und Formatzeichen (Nullbreite, Leserichtung)", () => {
expect(normalizeCertificateText("An\u200Bna\u0007 \u202EBen")).toBe("Anna Ben");
});
test("einzeilig werden Zeilenumbrueche zu Leerzeichen", () => {
expect(normalizeCertificateText("Anna\nund\r\nBen")).toBe("Anna und Ben");
});
test("mehrzeilig bleiben Umbrueche, mehrere Leerzeilen werden eine", () => {
expect(normalizeCertificateText("Liebe Grüße \r\n\n\n\n von uns \n", { multiline: true })).toBe(
"Liebe Grüße\n\nvon uns"
);
});
});
describe("personalizationSchema", () => {
test("akzeptiert eine gueltige Eingabe und liefert den Personalization-Typ", () => {
const parsed: Personalization = personalizationSchema.parse({ ...base, giverNames: " Anna und Ben " });
expect(parsed).toEqual({ ...base, giverNames: "Anna und Ben" });
});
test("Grenzen des Bienennamens: 2 und 24 Zeichen", () => {
expect(issues({ beeName: "Li" })).toEqual([]);
expect(issues({ beeName: "L" })).toEqual([{ path: "beeName", message: "too_short" }]);
expect(issues({ beeName: "x".repeat(CERTIFICATE_LIMITS.beeName.max) })).toEqual([]);
expect(issues({ beeName: "x".repeat(CERTIFICATE_LIMITS.beeName.max + 1) })).toEqual([
{ path: "beeName", message: "too_long" },
]);
});
test("Grenzen des Namens auf der Urkunde", () => {
expect(issues({ recipientName: "Bo" })).toEqual([]);
expect(issues({ recipientName: " " })).toEqual([{ path: "recipientName", message: "required" }]);
expect(issues({ recipientName: "a".repeat(CERTIFICATE_LIMITS.recipientName.max + 1) })).toEqual([
{ path: "recipientName", message: "too_long" },
]);
});
test("zaehlt Codepunkte: 'Zoë' mit kombinierendem Zeichen bleibt 3 Zeichen lang", () => {
expect(countCharacters(normalizeCertificateText("Zoë"))).toBe(3);
expect(personalizationSchema.parse({ ...base, beeName: "Zoë" }).beeName).toBe("Zoë");
});
test("Namen ohne Buchstaben werden abgelehnt", () => {
expect(issues({ beeName: "--" })).toEqual([{ path: "beeName", message: "no_letter" }]);
});
test("Steuerzeichen zaehlen nicht mit und landen nicht in der Ausgabe", () => {
const parsed = personalizationSchema.parse({ ...base, beeName: "B\u0000\u0001o" });
expect(parsed.beeName).toBe("Bo");
});
test("leere optionale Felder werden zu undefined", () => {
const parsed = personalizationSchema.parse({ ...base, giverNames: " ", dedication: "\n \n" });
expect(parsed.giverNames).toBeUndefined();
expect(parsed.dedication).toBeUndefined();
expect("giverNames" in parsed).toBe(false);
});
test("bei SELBST werden Schenkende verworfen", () => {
const parsed = personalizationSchema.parse({ ...base, occasion: "SELBST", giverNames: "Anna" });
expect(parsed.giverNames).toBeUndefined();
expect("giverNames" in parsed).toBe(false);
});
test("Schenkende hoechstens 80 Zeichen", () => {
expect(issues({ giverNames: "a".repeat(80) })).toEqual([]);
expect(issues({ giverNames: "a".repeat(81) })).toEqual([{ path: "giverNames", message: "too_long" }]);
});
test("Widmung: 280 Zeichen und 5 Umbrueche erlaubt, 6 Umbrueche nicht", () => {
expect(issues({ dedication: "a".repeat(280) })).toEqual([]);
expect(issues({ dedication: "a".repeat(281) })).toEqual([{ path: "dedication", message: "too_long" }]);
expect(issues({ dedication: ["a", "b", "c", "d", "e", "f"].join("\n") })).toEqual([]);
expect(issues({ dedication: ["a", "b", "c", "d", "e", "f", "g"].join("\n") })).toEqual([
{ path: "dedication", message: "too_many_line_breaks" },
]);
});
test("Widmung mit Markup bleibt reiner Text (keine Entfernung, keine Interpretation)", () => {
const parsed = personalizationSchema.parse({ ...base, dedication: "<script>alert(1)</script> & Co" });
expect(parsed.dedication).toBe("<script>alert(1)</script> & Co");
});
test("unbekannter Anlass und ungueltiges Datum werden abgelehnt", () => {
expect(issues({ occasion: "OSTERN" })[0]?.path).toBe("occasion");
expect(issues({ startDate: "2026-02-30" })).toEqual([{ path: "startDate", message: "invalid_date" }]);
expect(issues({ startDate: "16.10.2026" })).toEqual([{ path: "startDate", message: "invalid_date" }]);
});
});
describe("Glyphenpruefung", () => {
test("Umlaute, Akzente und typografische Apostrophe sind darstellbar", () => {
expect(findUnsupportedCharacters("Jürgen Weiß Zoë Ñúñez O’Brien Marie-Luise ß – „“", ["serif", "serifItalic", "sans"])).toEqual([]);
});
test("Emoji werden gemeldet, jedes Zeichen nur einmal", () => {
expect(findUnsupportedCharacters("Summsi 🐝🐝 ☃", ["serif"])).toEqual(["🐝", "☃"]);
});
});
describe("Daten", () => {
test("Einmal-Laufzeit endet 12 Monate minus einen Tag nach dem Start", () => {
expect(fixedTermFor("2026-10-16")).toEqual({ kind: "FIXED", start: "2026-10-16", end: "2027-10-15" });
expect(fixedTermFor("2027-03-01")).toEqual({ kind: "FIXED", start: "2027-03-01", end: "2028-02-29" });
expect(fixedTermFor("2026-01-01")).toEqual({ kind: "FIXED", start: "2026-01-01", end: "2026-12-31" });
});
test("Startdatum: frühestens heute, spätestens sechs Monate danach", () => {
expect(startDateRange("2026-10-16")).toEqual({ min: "2026-10-16", max: "2027-04-16" });
});
test("isIsoDate erkennt echte Kalendertage", () => {
expect(isIsoDate("2028-02-29")).toBe(true);
expect(isIsoDate("2027-02-29")).toBe(false);
});
});
+173
View File
@@ -0,0 +1,173 @@
import { z } from "zod";
/**
* Personalisierung der Bienenpatenschaft — EIN Schema fuer Formular (Client)
* und Checkout/Ausstellung (Server). Deshalb ohne Node-Abhaengigkeiten: die
* Glyphenpruefung gegen die eingebetteten Schriften braucht fontkit und
* laeuft nur serverseitig (layout.ts → checkCertificateFits).
*
* Fehlermeldungen sind feste Codes statt Saetze: das Formular uebersetzt sie
* in die Seitensprache, der Server gibt sie unveraendert weiter.
*/
export const OCCASIONS = ["SELBST", "GEBURTSTAG", "WEIHNACHTEN", "DANKESCHOEN", "GESCHENK"] as const;
export type Occasion = (typeof OCCASIONS)[number];
/**
* Eingabelimits in Zeichen (Unicode-Codepunkte, siehe countCharacters).
* Festgelegt durch die Layout-Testmatrix (layout.test.ts): jede Kombination
* an der Grenze passt in allen Vorlagen und beiden Sprachen in die kleinste
* Schriftstufe. Wer die Werte erhoeht, muss die Matrix erneut laufen lassen.
*/
export const CERTIFICATE_LIMITS = {
beeName: { min: 2, max: 24 },
recipientName: { min: 2, max: 60 },
giverNames: { max: 80 },
dedication: { max: 280, maxLineBreaks: 5 },
} as const;
export type CertificateErrorCode =
| "required"
| "too_short"
| "too_long"
| "too_many_line_breaks"
| "no_letter"
| "invalid_date";
/** Zeichenzaehlung fuer Limits und Zaehler im Formular: Codepunkte, nicht UTF-16-Einheiten ("Zoë" = 3). */
export function countCharacters(value: string): number {
return [...value].length;
}
export function countLineBreaks(value: string): number {
return (value.match(/\n/g) ?? []).length;
}
// Steuer- und Formatzeichen (Cc, Cf): unsichtbar oder gefaehrlich — z.B.
// U+202E dreht die Leserichtung und koennte auf der Urkunde einen anderen
// Namen vortaeuschen, Nullbreiten-Zeichen umgehen Limits und Zaehler.
const INVISIBLE = /[\p{Cc}\p{Cf}]/gu;
/**
* Normalisiert eine Eingabe: Unicode NFC, Steuer- und Formatzeichen entfernt,
* Leerraum zusammengefasst, Raender gekuerzt. Mehrzeilig (Widmung) bleiben
* Zeilenumbrueche erhalten — jede Zeile wird fuer sich bereinigt, mehrere
* Leerzeilen hintereinander werden zu einer. Am Wortlaut aendert sich nichts.
*/
export function normalizeCertificateText(value: string, opts?: { multiline?: boolean }): string {
// Zeilentrenner vereinheitlichen: CRLF/CR, Zeilen- und Absatztrenner (Zl, Zp), NEL.
const unified = value.normalize("NFC").replace(/\r\n?|[\p{Zl}\p{Zp}\x85]/gu, "\n");
if (!opts?.multiline) {
return unified.replace(/\s+/gu, " ").replace(INVISIBLE, "").replace(/ {2,}/g, " ").trim();
}
const lines = unified.split("\n").map((line) =>
line.replace(/\s+/gu, " ").replace(INVISIBLE, "").replace(/ {2,}/g, " ").trim()
);
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
}
const HAS_LETTER = /\p{L}/u;
function nameField(limits: { min: number; max: number }) {
return z
.string({ error: "required" })
.transform((value) => normalizeCertificateText(value))
.pipe(
z
.string()
.refine((value) => value.length > 0, { message: "required", abort: true })
.refine((value) => countCharacters(value) >= limits.min, { message: "too_short", abort: true })
.refine((value) => countCharacters(value) <= limits.max, { message: "too_long", abort: true })
.refine((value) => HAS_LETTER.test(value), { message: "no_letter" })
);
}
/** Optionales Feld: leer oder nur Leerraum ergibt undefined — keine leere Zeile auf der Urkunde. */
function optionalField(max: number, multiline: boolean, maxLineBreaks?: number) {
return z
.string()
.optional()
.nullable()
.transform((value) => {
const normalized = value ? normalizeCertificateText(value, { multiline }) : "";
return normalized.length > 0 ? normalized : undefined;
})
.pipe(
z
.string()
.refine((value) => countCharacters(value) <= max, { message: "too_long", abort: true })
.refine((value) => maxLineBreaks === undefined || countLineBreaks(value) <= maxLineBreaks, {
message: "too_many_line_breaks",
})
.optional()
);
}
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
/** true, wenn `value` ein echtes Kalenderdatum im Format JJJJ-MM-TT ist (kein 31. Februar). */
export function isIsoDate(value: string): boolean {
const match = ISO_DATE.exec(value);
if (!match) return false;
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
const date = new Date(Date.UTC(year, month - 1, day));
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
}
export const personalizationSchema = z
.object({
beeName: nameField(CERTIFICATE_LIMITS.beeName),
recipientName: nameField(CERTIFICATE_LIMITS.recipientName),
giverNames: optionalField(CERTIFICATE_LIMITS.giverNames.max, false),
dedication: optionalField(CERTIFICATE_LIMITS.dedication.max, true, CERTIFICATE_LIMITS.dedication.maxLineBreaks),
occasion: z.enum(OCCASIONS, { error: "required" }),
startDate: z.string({ error: "required" }).refine(isIsoDate, { message: "invalid_date" }),
})
// Bei "fuer mich selbst" gibt es keine Schenkenden; ein aus einem frueheren
// Formularschritt stehengebliebener Wert darf nicht auf die Urkunde.
.transform(
({ giverNames, ...rest }): Personalization =>
rest.occasion === "SELBST" || giverNames === undefined ? rest : { ...rest, giverNames }
);
export type PersonalizationInput = z.input<typeof personalizationSchema>;
export interface Personalization {
beeName: string;
recipientName: string;
giverNames?: string;
dedication?: string;
occasion: Occasion;
/** JJJJ-MM-TT */
startDate: string;
}
/** Ein Tag als JJJJ-MM-TT in UTC — Urkundendaten haben keine Uhrzeit. */
export function toIsoDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
function addMonths(isoDate: string, months: number, dayOffset = 0): string {
const [year, month, day] = isoDate.split("-").map(Number);
// Date.UTC rollt Ueberlaeufe selbst (Tag 0 = letzter Tag des Vormonats).
return toIsoDate(new Date(Date.UTC(year, month - 1 + months, day + dayOffset)));
}
/**
* Erlaubtes Startdatum einer Einmal-Patenschaft: frühestens der Bestelltag,
* spätestens sechs Monate danach. Abos beginnen mit der ersten Zahlung und
* brauchen diese Pruefung nicht.
*/
export function startDateRange(today: string): { min: string; max: string } {
return { min: today, max: addMonths(today, 6) };
}
export type CertificateTerm =
| { kind: "FIXED"; start: string; end: string }
| { kind: "OPEN"; start: string }
| { kind: "ENDED"; start: string; end: string };
/** Einmal-Patenschaft: zwoelf Monate, Ende = Start + 12 Monate − 1 Tag. */
export function fixedTermFor(start: string): CertificateTerm {
return { kind: "FIXED", start, end: addMonths(start, 12, -1) };
}
+210
View File
@@ -0,0 +1,210 @@
import { beeHeight, beePaths } from "@/lib/certificate/art/bee";
import {
CERT_COLORS,
PAGE_HEIGHT,
PAGE_WIDTH,
flowHeight,
placeFlow,
space,
textLines,
type CertificateLayout,
type FlowItem,
type LayoutElement,
type PathElement,
type TextElement,
} from "@/lib/certificate/elements";
import { measureText } from "@/lib/certificate/fonts";
import { hexagon, line, num } from "@/lib/certificate/geometry";
import type { CertificateText } from "@/lib/certificate/text";
import { chooseFlow, requireName, requireParagraphs, type VariantStep } from "@/lib/certificate/templates/shared";
import type { CertificateTemplate } from "@/lib/certificate/templates/types";
/**
* classic@1 — klassisch-editorial: zentrierte Achse, feiner Doppelrahmen in
* Honiggold-dunkel (innen mit eingezogenen Ecken), kleine Biene als
* Strichzeichnung ueber der Ueberschrift. Bienenname und Name auf der
* Urkunde bilden die Mitte; Daten, Nummer und Hinweis stehen klein am Fuss.
*/
const VERSION = "classic@1";
const CX = PAGE_WIDTH / 2;
const C = CERT_COLORS;
const FRAME_OUTER = 36;
const FRAME_INNER = 41.5;
const CORNER = 9;
const TEXT_WIDTH = 352;
const BALANCED = { balanced: true } as const;
const DEDICATION_WIDTH = 350;
const NAME_WIDTH = 410;
const MIDDLE_TOP = 226;
const BOTTOM_ANCHOR = 772;
function centered(width: number): { x: number; width: number; align: "center" } {
return { x: CX - width / 2, width, align: "center" };
}
/** Doppelrahmen: aussen gerade, innen mit konkaven Viertelkreis-Ecken. */
function frame(): PathElement[] {
const o = FRAME_OUTER;
const outer = `M${o} ${o}L${num(PAGE_WIDTH - o)} ${o}L${num(PAGE_WIDTH - o)} ${num(PAGE_HEIGHT - o)}L${o} ${num(PAGE_HEIGHT - o)}Z`;
const l = FRAME_INNER;
const t = FRAME_INNER;
const r = PAGE_WIDTH - FRAME_INNER;
const b = PAGE_HEIGHT - FRAME_INNER;
const k = CORNER * 0.5523;
const n = num;
const inner =
`M${n(l + CORNER)} ${n(t)}L${n(r - CORNER)} ${n(t)}` +
`C${n(r - CORNER)} ${n(t + k)} ${n(r - k)} ${n(t + CORNER)} ${n(r)} ${n(t + CORNER)}` +
`L${n(r)} ${n(b - CORNER)}` +
`C${n(r - k)} ${n(b - CORNER)} ${n(r - CORNER)} ${n(b - k)} ${n(r - CORNER)} ${n(b)}` +
`L${n(l + CORNER)} ${n(b)}` +
`C${n(l + CORNER)} ${n(b - k)} ${n(l + k)} ${n(b - CORNER)} ${n(l)} ${n(b - CORNER)}` +
`L${n(l)} ${n(t + CORNER)}` +
`C${n(l + k)} ${n(t + CORNER)} ${n(l + CORNER)} ${n(t + k)} ${n(l + CORNER)} ${n(t)}Z`;
return [
{ kind: "path", role: "frame", d: outer, stroke: C.honeyDark, strokeWidth: 0.9, lineJoin: "miter" },
{ kind: "path", role: "frame", d: inner, stroke: C.honeyDark, strokeWidth: 0.4, lineJoin: "miter" },
];
}
/** Kurze Linie mit kleinem Sechseck in der Mitte — trennt Haupttext und Widmung. */
function hexRule(cy: number, halfLength: number): PathElement[] {
const r = 2.6;
const gap = 5.5;
return [
{ kind: "path", role: "ornament", d: line({ x: CX - halfLength, y: cy }, { x: CX - gap, y: cy }), stroke: C.honeyDark, strokeWidth: 0.5 },
{ kind: "path", role: "ornament", d: line({ x: CX + gap, y: cy }, { x: CX + halfLength, y: cy }), stroke: C.honeyDark, strokeWidth: 0.5 },
{ kind: "path", role: "ornament", d: hexagon(CX, cy, r), stroke: C.honeyDark, strokeWidth: 0.6, lineJoin: "miter" },
];
}
function header(text: CertificateText): LayoutElement[] {
const beeWidth = 46;
const beeTop = 80;
const title = text.title.toUpperCase();
const titleSize = 22;
const titleSpacing = 4.6;
const titleY = beeTop + beeHeight(beeWidth) + 40;
const subtitle = text.subtitle.toUpperCase();
const subtitleSpacing = 3;
const subtitleY = titleY + 22;
const subWidth = measureText("sansMedium", subtitle, 8, { letterSpacing: subtitleSpacing });
const ruleY = subtitleY - 2.9;
const ruleGap = 12;
const ruleLength = 30;
const titleEl: TextElement = {
kind: "text",
role: "title",
text: title,
font: "serifSemiBold",
size: titleSize,
color: C.forest,
y: titleY,
letterSpacing: titleSpacing,
...centered(NAME_WIDTH + 40),
};
const subtitleEl: TextElement = {
kind: "text",
role: "subtitle",
text: subtitle,
font: "sansMedium",
size: 8,
color: C.honeyDark,
y: subtitleY,
letterSpacing: subtitleSpacing,
...centered(200),
};
const left = CX - subWidth / 2 - ruleGap;
const right = CX + subWidth / 2 + ruleGap;
// Zwei getrennte Linien links und rechts von "URKUNDE" (eigene Elemente,
// damit ihre Grenzen den Text nicht einschliessen).
const rules = [
line({ x: left - ruleLength, y: ruleY }, { x: left, y: ruleY }),
line({ x: right, y: ruleY }, { x: right + ruleLength, y: ruleY }),
].map((d): PathElement => ({ kind: "path", role: "ornament", d, stroke: C.honeyDark, strokeWidth: 0.5 }));
return [...beePaths({ cx: CX, top: beeTop, width: beeWidth, strokeWidth: 0.6 }), titleEl, subtitleEl, ...rules];
}
/** Fuss: Waben-Zeichen bzw. Logo, Laufzeit, Nummer, Hinweis — von unten angeordnet. */
function footer(text: CertificateText): { elements: LayoutElement[]; top: number } {
const noticeWidth = 340;
const notice = requireParagraphs("layout", "sans", text.notice, [8], noticeWidth, 4, BALANCED);
const items: FlowItem[] = [
{
kind: "art",
height: 34,
draw: (top) => [{ kind: "image", src: "logo", x: CX - 60, y: top, width: 120, height: 34, align: "center" }],
},
space(10),
{ kind: "text", lineHeight: 13, element: { role: "term", text: text.termLine, font: "sans", size: 8.5, color: C.forest, ...centered(TEXT_WIDTH) } },
{
kind: "text",
lineHeight: 12,
element: { role: "number", text: text.numberLine.toUpperCase(), font: "sansMedium", size: 8, color: C.honeyDark, letterSpacing: 1.1, ...centered(TEXT_WIDTH) },
},
space(8),
...textLines(notice.lines, 11, { role: "notice", font: "sans", size: 8, color: C.forest, ...centered(noticeWidth) }),
];
const top = BOTTOM_ANCHOR - flowHeight(items);
return { elements: placeFlow(items, top), top };
}
function middle(text: CertificateText, step: VariantStep): FlowItem[] {
const g = (value: number): FlowItem => space(value * step.gapScale);
const items: FlowItem[] = [];
if (text.occasionLine) {
items.push({ kind: "text", lineHeight: 22, element: { role: "occasion", text: text.occasionLine, font: "serifItalic", size: 18, color: C.honeyDark, ...centered(TEXT_WIDTH) } }, g(10));
}
if (text.leadLine) {
items.push({ kind: "text", lineHeight: 20, element: { role: "lead", text: text.leadLine, font: "serifItalic", size: 15, color: C.forest, ...centered(TEXT_WIDTH) } }, g(2));
}
const recipient = requireName("recipientName", "serifMedium", text.recipientName, [32, 28, 24], NAME_WIDTH);
items.push(...textLines(recipient.lines, recipient.size * 1.18, { role: "recipient", font: "serifMedium", size: recipient.size, color: C.forest, ...centered(NAME_WIDTH) }), g(14));
const before = requireParagraphs("layout", "serif", text.beforeBee, [13], TEXT_WIDTH, 3, BALANCED);
items.push(...textLines(before.lines, 18.5, { role: "beforeBee", font: "serif", size: 13, color: C.forest, ...centered(TEXT_WIDTH) }), g(6));
const bee = requireName("beeName", "serifSemiBoldItalic", text.beeName, [56, 48, 42, 36], NAME_WIDTH);
items.push(...textLines(bee.lines, bee.size * 1.12, { role: "beeName", font: "serifSemiBoldItalic", size: bee.size, color: C.forest, ...centered(NAME_WIDTH) }), g(6));
const after = requireParagraphs("layout", "serif", text.afterBee, [13], TEXT_WIDTH, 3, BALANCED);
items.push(...textLines(after.lines, 18.5, { role: "afterBee", font: "serif", size: 13, color: C.forest, ...centered(TEXT_WIDTH) }));
if (text.giverLine) {
const giver = requireParagraphs("giverNames", "serifItalic", text.giverLine, [14, 13], TEXT_WIDTH, 2, BALANCED);
items.push(g(18), ...textLines(giver.lines, giver.size * 1.35, { role: "giver", font: "serifItalic", size: giver.size, color: C.forest, ...centered(TEXT_WIDTH) }));
}
if (text.dedication) {
const size = step.dedicationSize;
const dedication = requireParagraphs("dedication", "serifItalic", text.dedication, [size], DEDICATION_WIDTH, 10, BALANCED);
items.push(
g(22),
{ kind: "art", height: 8, draw: (top) => hexRule(top + 4, 34) },
g(16),
...textLines(dedication.lines, size * 1.4, { role: "dedication", font: "serifItalic", size, color: C.forest, ...centered(DEDICATION_WIDTH) })
);
}
return items;
}
function layout(text: CertificateText): CertificateLayout {
const foot = footer(text);
const available = foot.top - 24 - MIDDLE_TOP;
const chosen = chooseFlow(available, (step) => middle(text, step), text.dedication ? "dedication" : "layout");
// Optisch etwas ueber der Mitte des freien Raums — wie ein Satzspiegel.
const top = MIDDLE_TOP + (available - chosen.height) * 0.42;
return {
templateVersion: VERSION,
width: PAGE_WIDTH,
height: PAGE_HEIGHT,
elements: [...frame(), ...header(text), ...placeFlow(chosen.items, top), ...foot.elements],
};
}
export const classicTemplate: CertificateTemplate = { version: VERSION, layout };
+29
View File
@@ -0,0 +1,29 @@
import { classicTemplate } from "@/lib/certificate/templates/classic";
import type { CertificateTemplate } from "@/lib/certificate/templates/types";
export type { CertificateTemplate } from "@/lib/certificate/templates/types";
/**
* Alle Vorlagenversionen. Alte Versionen bleiben hier eingetragen, solange
* es Patenschaften mit dieser Version geben kann — also praktisch immer.
*
* Entscheidung 2026-10-16: Von drei Entwuerfen (classic, wabe, botanisch)
* wurde classic@1 umgesetzt; die beiden anderen liegen nicht im Produktcode
* (Spec "Drei Varianten, dann eine Entscheidung").
*/
const TEMPLATES: Readonly<Record<string, CertificateTemplate>> = {
[classicTemplate.version]: classicTemplate,
};
/** Version fuer neue Patenschaften. */
export const CURRENT_TEMPLATE_VERSION: string = classicTemplate.version;
export function getTemplate(version: string): CertificateTemplate {
const template = TEMPLATES[version];
if (!template) throw new Error(`Unbekannte Urkundenvorlage "${version}".`);
return template;
}
export function listTemplateVersions(): string[] {
return Object.keys(TEMPLATES);
}
+95
View File
@@ -0,0 +1,95 @@
import {
CertificateLayoutError,
flowHeight,
type CertificateField,
type FlowItem,
} from "@/lib/certificate/elements";
import type { FontKey, TextOptions } from "@/lib/certificate/fonts";
import { fitName, fitParagraphs, type FitResult } from "@/lib/certificate/typeset";
/**
* Gemeinsame Regeln aller Vorlagen: Namen und Fliesstext in festen Stufen
* setzen, Layoutvariante waehlen. Die Vorlagen unterscheiden sich in
* Anordnung und Schmuck, nicht in diesen Regeln.
*/
/** Name in Stufen setzen oder mit dem Feld als Fehler abbrechen. */
export function requireName(
field: CertificateField,
font: FontKey,
text: string,
sizes: readonly number[],
maxWidth: number,
opts?: TextOptions
): FitResult {
const result = fitName(font, text, sizes, maxWidth, opts);
if (!result) throw new CertificateLayoutError(field);
return result;
}
/** Fliesstext in Stufen setzen oder mit dem Feld als Fehler abbrechen. */
export function requireParagraphs(
field: CertificateField,
font: FontKey,
text: string,
sizes: readonly number[],
maxWidth: number,
maxLines: number,
opts?: TextOptions & { balanced?: boolean }
): FitResult {
const result = fitParagraphs(font, text, sizes, maxWidth, maxLines, opts);
if (!result) throw new CertificateLayoutError(field);
return result;
}
/**
* Layoutvarianten (Spec): ohne Widmung, kurze Widmung, lange Widmung. Jede
* Stufe kombiniert eine Widmungsgroesse mit einem Faktor fuer die Abstaende;
* gewaehlt wird die erste Stufe, deren gemessene Hoehe in den verfuegbaren
* Raum passt. So bleibt eine Urkunde ohne Widmung grosszuegig und eine mit
* langer Widmung wird dichter — die Seite selbst wird nie verkleinert.
*/
export interface VariantStep {
dedicationSize: number;
gapScale: number;
}
export const VARIANT_STEPS: readonly VariantStep[] = [
{ dedicationSize: 14, gapScale: 1 },
{ dedicationSize: 14, gapScale: 0.85 },
{ dedicationSize: 13, gapScale: 0.8 },
{ dedicationSize: 13, gapScale: 0.68 },
{ dedicationSize: 12, gapScale: 0.62 },
{ dedicationSize: 12, gapScale: 0.5 },
{ dedicationSize: 11.5, gapScale: 0.42 },
];
export interface ChosenFlow {
items: FlowItem[];
height: number;
step: VariantStep;
}
/**
* Probiert die Stufen der Reihe nach. `build` darf selbst einen
* CertificateLayoutError werfen (z.B. Widmung passt nicht in die
* Zeilenzahl); dann gilt die Stufe als nicht passend.
*/
export function chooseFlow(
available: number,
build: (step: VariantStep) => FlowItem[],
overflowField: CertificateField
): ChosenFlow {
let lastError: CertificateLayoutError | null = null;
for (const step of VARIANT_STEPS) {
try {
const items = build(step);
const height = flowHeight(items);
if (height <= available) return { items, height, step };
} catch (error: unknown) {
if (!(error instanceof CertificateLayoutError)) throw error;
lastError = error;
}
}
throw lastError ?? new CertificateLayoutError(overflowField);
}
+16
View File
@@ -0,0 +1,16 @@
import type { CertificateLayout } from "@/lib/certificate/elements";
import type { CertificateText } from "@/lib/certificate/text";
/**
* Eine Urkundenvorlage mit fester Kennung `<name>@<version>`. Eine
* Designaenderung bekommt eine neue Version; die alte bleibt im Code, damit
* bestehende Patenschaften (und ihre Systemrevisionen, z.B. bei Abo-Ende)
* weiterhin genau so aussehen wie beim Kauf.
*
* Die Schriften kommen aus lib/certificate/fonts.ts (prozessweit gecacht),
* deshalb nimmt layout() nur den aufgeloesten Text.
*/
export interface CertificateTemplate {
version: string;
layout(text: CertificateText): CertificateLayout;
}
+140
View File
@@ -0,0 +1,140 @@
import { describe, expect, test } from "vitest";
import { composeCertificateText, formatCertificateDate, type CertificateText } from "./text";
import { fixedTermFor, OCCASIONS, type Occasion, type Personalization } from "./schema";
import type { Locale } from "@/i18n/config";
const NUMBER = "WH-P-2026-00042";
function compose(overrides: Partial<Personalization> = {}, locale: Locale = "de", term = fixedTermFor("2026-10-16")) {
const personalization: Personalization = {
beeName: "Summsi",
recipientName: "Erika Musterfrau",
occasion: "GESCHENK",
startDate: "2026-10-16",
...overrides,
};
return composeCertificateText({ personalization, locale, term, number: NUMBER });
}
/** Alle Zeilen, die auf der Urkunde stehen koennen, als ein Text. */
function allText(text: CertificateText): string {
return [
text.title,
text.subtitle,
text.occasionLine,
text.leadLine,
text.recipientName,
text.beforeBee,
text.beeName,
text.afterBee,
text.giverLine,
text.dedication,
text.termLine,
text.numberLine,
text.notice,
]
.filter((line): line is string => line !== null)
.join("\n");
}
describe("composeCertificateText", () => {
test("verschenkte Patenschaft: 'Für' + Name, passiver Haupttext", () => {
const text = compose();
expect(text.leadLine).toBe("Für");
expect(text.recipientName).toBe("Erika Musterfrau");
expect(text.beforeBee).toBe("Einer Biene der Imkerei Wabenhain wurde symbolisch der Name");
expect(text.beeName).toBe("Summsi");
expect(text.afterBee).toBe("gegeben. Die Patenschaft unterstützt die Imkerei und ihre Bienenvölker.");
});
test("eigene Patenschaft: kein 'Für', Name ist Satzsubjekt, keine Schenkenden", () => {
const text = compose({ occasion: "SELBST", giverNames: "Anna" });
expect(text.leadLine).toBeNull();
expect(text.occasionLine).toBeNull();
expect(text.giverLine).toBeNull();
expect(`${text.recipientName} ${text.beforeBee} ${text.beeName} ${text.afterBee}`).toBe(
"Erika Musterfrau hat einer Biene der Imkerei Wabenhain symbolisch den Namen Summsi gegeben und unterstützt damit die Imkerei und ihre Bienenvölker."
);
});
test("Anlasszeilen nur fuer Geburtstag, Weihnachten und Dankeschön", () => {
const lines = Object.fromEntries(OCCASIONS.map((occasion) => [occasion, compose({ occasion }).occasionLine]));
expect(lines).toEqual({
SELBST: null,
GEBURTSTAG: "Zum Geburtstag",
WEIHNACHTEN: "Zu Weihnachten",
DANKESCHOEN: "Als Dankeschön",
GESCHENK: null,
});
const en = Object.fromEntries(OCCASIONS.map((occasion) => [occasion, compose({ occasion }, "en").occasionLine]));
expect(en).toEqual({
SELBST: null,
GEBURTSTAG: "A Birthday Gift",
WEIHNACHTEN: "A Christmas Gift",
DANKESCHOEN: "With Thanks",
GESCHENK: null,
});
});
test("leere Felder ergeben keine Zeile und keine verwaiste Bezeichnung", () => {
const text = compose({ giverNames: undefined, dedication: undefined });
expect(text.giverLine).toBeNull();
expect(text.dedication).toBeNull();
expect(allText(text)).not.toMatch(/Geschenkt von\s*$|Von:|\{|\}|undefined|null/m);
const blank = compose({ giverNames: " ", dedication: " \n " });
expect(blank.giverLine).toBeNull();
expect(blank.dedication).toBeNull();
});
test("Schenkende und Widmung werden woertlich uebernommen", () => {
const dedication = "Für die beste Imkerin\n<b>nicht fett</b> & 100 % Liebe";
const text = compose({ giverNames: "Anna, Ben, Carla und Familie Özdemir", dedication });
expect(text.giverLine).toBe("Geschenkt von Anna, Ben, Carla und Familie Özdemir");
expect(text.dedication).toBe(dedication);
expect(compose({ giverNames: "Anna" }, "en").giverLine).toBe("A gift from Anna");
});
test("drei Laufzeitformen auf Deutsch", () => {
expect(compose().termLine).toBe("Patenschaft vom 16. Oktober 2026 bis 15. Oktober 2027");
expect(compose({}, "de", { kind: "OPEN", start: "2026-10-16" }).termLine).toBe("Patenschaft seit 16. Oktober 2026");
expect(compose({}, "de", { kind: "ENDED", start: "2026-10-16", end: "2027-03-15" }).termLine).toBe(
"Patenschaft vom 16. Oktober 2026 bis 15. März 2027"
);
});
test("drei Laufzeitformen auf Englisch", () => {
expect(compose({}, "en").termLine).toBe("Sponsorship from 16 October 2026 to 15 October 2027");
expect(compose({}, "en", { kind: "OPEN", start: "2026-10-16" }).termLine).toBe("Sponsorship since 16 October 2026");
expect(compose({}, "en", { kind: "ENDED", start: "2026-10-16", end: "2027-03-15" }).termLine).toBe(
"Sponsorship from 16 October 2026 to 15 March 2027"
);
});
test("Kennung und Hinweis", () => {
expect(compose().numberLine).toBe("Nr. WH-P-2026-00042");
expect(compose({}, "en").numberLine).toBe("No. WH-P-2026-00042");
expect(compose().notice).toBe(
"Die Namensgebung ist symbolisch. Sie gilt keiner einzeln begleiteten Biene, sondern unterstützt die Imkerei Wabenhain und ihre Bienenvölker."
);
});
test("Datum ohne Zeitzonenversatz", () => {
expect(formatCertificateDate("2027-01-01", "de")).toBe("1. Januar 2027");
expect(formatCertificateDate("2027-01-01", "en")).toBe("1 January 2027");
});
test.each(["de", "en"] as const)("keine Pronomen fuer Personen, keine Anreden, keine Einzelbetreuung (%s)", (locale) => {
for (const occasion of OCCASIONS as readonly Occasion[]) {
const composed = compose({ occasion, giverNames: "Anna" }, locale);
// Der Hinweis ist woertlich aus der Spec ("Sie gilt …" bezieht sich auf die
// Namensgebung) und wird oben separat geprueft.
const text = allText(composed).replace(composed.notice, "").toLowerCase();
// "ihre"/"its" kommen nur als Bezug auf die Imkerei vor ("die Imkerei und ihre Bienenvölker").
const withoutApiary = text.replace(/die imkerei und ihre bienenvölker|the apiary and its bee colonies|imkerei wabenhain und ihre bienenvölker|wabenhain apiary and its bee colonies/g, "");
expect(withoutApiary).not.toMatch(
/\b(er|sie|ihm|ihr|ihn|sein|seine|seiner|ihre|ihrer|du|dein|deine|dir|dich|you|your|he|she|him|her|his|hers|liebe|lieber|dear|herr|frau|mr|mrs|ms)\b/
);
expect(text).not.toMatch(/betreut|begleitet ihre|verfolg|deine biene|your bee|cared for|follow/);
}
});
});
+148
View File
@@ -0,0 +1,148 @@
import type { Locale } from "@/i18n/config";
import type { CertificateTerm, Occasion, Personalization } from "@/lib/certificate/schema";
// Bequemer Import fuer Aufrufer, die Text und Laufzeit zusammen brauchen.
export { fixedTermFor } from "@/lib/certificate/schema";
/**
* Urkundentext aus festen Bausteinen — keine KI, kein Freitext ausser den
* Kundenfeldern. Reine Funktion: dieselbe Eingabe ergibt immer denselben
* Wortlaut, den der Kunde in der Vorschau sieht und der in der Revision
* gespeichert wird.
*
* Regeln aus der Spec, hier bewusst eingehalten:
* - keine Pronomen und keine aus dem Namen abgeleitete Anrede (kein
* "Liebe/Lieber", kein "ihre/seine", im Englischen kein "your/her/his");
* - nichts behauptet die Betreuung oder Verfolgung EINER einzelnen Biene;
* - leere optionale Felder ergeben null — der Renderer setzt dann weder eine
* Zeile noch eine verwaiste Bezeichnung wie "Geschenkt von";
* - die Widmung wird wortwoertlich uebernommen (nur normalisiert, siehe
* schema.ts); Darstellung erfolgt immer als Text, nie als Markup.
*/
export interface CertificateText {
locale: Locale;
/** "Bienenpatenschaft" */
title: string;
/** "Urkunde" — klein unter der Ueberschrift */
subtitle: string;
/** Anlasszeile; null bei "fuer mich selbst" und "allgemeines Geschenk". */
occasionLine: string | null;
/** Kurze Einleitung vor dem Namen ("Für"); null bei eigener Patenschaft. */
leadLine: string | null;
/** Name der Person auf der Urkunde — gestalterischer Mittelpunkt neben dem Bienennamen. */
recipientName: string;
beforeBee: string;
beeName: string;
afterBee: string;
/** "Geschenkt von …"; null ohne Schenkende oder bei eigener Patenschaft. */
giverLine: string | null;
dedication: string | null;
termLine: string;
numberLine: string;
notice: string;
}
export interface ComposeInput {
personalization: Personalization;
locale: Locale;
term: CertificateTerm;
/** Patenschaftsnummer WH-P-JJJJ-NNNNN */
number: string;
}
interface Phrases {
title: string;
subtitle: string;
occasions: Partial<Record<Occasion, string>>;
giftLead: string;
giftBeforeBee: string;
giftAfterBee: string;
selfBeforeBee: string;
selfAfterBee: string;
giver: (names: string) => string;
fixed: (start: string, end: string) => string;
open: (start: string) => string;
number: (value: string) => string;
notice: string;
dateLocale: string;
}
const PHRASES: Record<Locale, Phrases> = {
de: {
title: "Bienenpatenschaft",
subtitle: "Urkunde",
occasions: { GEBURTSTAG: "Zum Geburtstag", WEIHNACHTEN: "Zu Weihnachten", DANKESCHOEN: "Als Dankeschön" },
giftLead: "Für",
giftBeforeBee: "Einer Biene der Imkerei Wabenhain wurde symbolisch der Name",
giftAfterBee: "gegeben. Die Patenschaft unterstützt die Imkerei und ihre Bienenvölker.",
selfBeforeBee: "hat einer Biene der Imkerei Wabenhain symbolisch den Namen",
selfAfterBee: "gegeben und unterstützt damit die Imkerei und ihre Bienenvölker.",
giver: (names) => `Geschenkt von ${names}`,
fixed: (start, end) => `Patenschaft vom ${start} bis ${end}`,
open: (start) => `Patenschaft seit ${start}`,
number: (value) => `Nr. ${value}`,
// Wortlaut aus der Spec, nicht umformulieren (rechtlich abgestimmt).
notice:
"Die Namensgebung ist symbolisch. Sie gilt keiner einzeln begleiteten Biene, sondern unterstützt die Imkerei Wabenhain und ihre Bienenvölker.",
dateLocale: "de-DE",
},
en: {
title: "Bee Sponsorship",
subtitle: "Certificate",
occasions: { GEBURTSTAG: "A Birthday Gift", WEIHNACHTEN: "A Christmas Gift", DANKESCHOEN: "With Thanks" },
giftLead: "For",
giftBeforeBee: "A bee of the Wabenhain apiary has symbolically been given the name",
giftAfterBee: "The sponsorship supports the apiary and its bee colonies.",
selfBeforeBee: "has symbolically given a bee of the Wabenhain apiary the name",
selfAfterBee: "and thereby supports the apiary and its bee colonies.",
giver: (names) => `A gift from ${names}`,
fixed: (start, end) => `Sponsorship from ${start} to ${end}`,
open: (start) => `Sponsorship since ${start}`,
number: (value) => `No. ${value}`,
notice:
"The naming is symbolic. It does not refer to an individually accompanied bee, but supports the Wabenhain apiary and its bee colonies.",
dateLocale: "en-GB",
},
};
/** "16. Oktober 2026" / "16 October 2026" — Datum ohne Zeitzonen-Verschiebung. */
export function formatCertificateDate(isoDate: string, locale: Locale): string {
const [year, month, day] = isoDate.split("-").map(Number);
return new Intl.DateTimeFormat(PHRASES[locale].dateLocale, {
day: "numeric",
month: "long",
year: "numeric",
timeZone: "UTC",
}).format(new Date(Date.UTC(year, month - 1, day)));
}
function termLine(term: CertificateTerm, phrases: Phrases, locale: Locale): string {
const start = formatCertificateDate(term.start, locale);
if (term.kind === "OPEN") return phrases.open(start);
return phrases.fixed(start, formatCertificateDate(term.end, locale));
}
export function composeCertificateText({ personalization, locale, term, number }: ComposeInput): CertificateText {
const phrases = PHRASES[locale];
const isGift = personalization.occasion !== "SELBST";
const givers = isGift ? personalization.giverNames?.trim() : undefined;
const dedication = personalization.dedication?.trim();
return {
locale,
title: phrases.title,
subtitle: phrases.subtitle,
occasionLine: phrases.occasions[personalization.occasion] ?? null,
leadLine: isGift ? phrases.giftLead : null,
recipientName: personalization.recipientName,
beforeBee: isGift ? phrases.giftBeforeBee : phrases.selfBeforeBee,
beeName: personalization.beeName,
afterBee: isGift ? phrases.giftAfterBee : phrases.selfAfterBee,
giverLine: givers ? phrases.giver(givers) : null,
dedication: dedication ? dedication : null,
termLine: termLine(term, phrases, locale),
numberLine: phrases.number(number),
notice: phrases.notice,
};
}
+222
View File
@@ -0,0 +1,222 @@
import { fontMetrics, measureText, type FontKey, type TextOptions } from "@/lib/certificate/fonts";
/**
* Umbruchregeln der Urkunde. Lange Inhalte werden ueber Regeln geloest,
* nie ueber das Verkleinern der ganzen Seite (Spec "Druck- und
* PDF-Anforderungen"):
*
* - Namen brechen nur an Leerzeichen oder nach einem Bindestrich, hoechstens
* auf zwei Zeilen, nie mitten im Wort. Leerzeichen gehen vor Bindestrich:
* "Marie-Luise / Schmidt-Habermann" statt "Marie-Luise Schmidt- / Habermann".
* - Jedes Feld hat eine feste Liste von Schriftgroessen. Erst wird jede Stufe
* einzeilig versucht, dann zweizeilig — ein Name in 46 pt auf einer Zeile
* wirkt ruhiger als derselbe Name in 54 pt auf zwei.
* - Passt nichts, liefern die Funktionen null; das Layout macht daraus einen
* CertificateLayoutError mit dem betroffenen Feld.
*/
export interface FitResult {
size: number;
lines: string[];
}
function fits(font: FontKey, text: string, size: number, maxWidth: number, opts?: TextOptions): boolean {
return measureText(font, text, size, opts) <= maxWidth;
}
interface BreakCandidate {
first: string;
second: string;
}
function breakCandidates(text: string): { spaces: BreakCandidate[]; hyphens: BreakCandidate[] } {
const spaces: BreakCandidate[] = [];
const hyphens: BreakCandidate[] = [];
for (let i = 1; i < text.length - 1; i++) {
if (text[i] === " ") spaces.push({ first: text.slice(0, i), second: text.slice(i + 1) });
// Bindestrich bleibt am Zeilenende stehen; nur zwischen zwei Nicht-Leerzeichen.
if (text[i] === "-" && text[i - 1] !== " " && text[i + 1] !== " ") {
hyphens.push({ first: text.slice(0, i + 1), second: text.slice(i + 1) });
}
}
return { spaces, hyphens };
}
/**
* Bester Zweizeilen-Umbruch eines Namens oder null. "Bester" = die laengere
* Zeile ist am kuerzesten. `allow` begrenzt auf Leerzeichen (erster Durchgang
* in fitName) oder laesst zusaetzlich Bindestriche zu.
*/
export function breakName(
font: FontKey,
text: string,
size: number,
maxWidth: number,
opts?: TextOptions,
allow: "spaces" | "all" = "all"
): [string, string] | null {
const { spaces, hyphens } = breakCandidates(text);
for (const group of allow === "spaces" ? [spaces] : [spaces, hyphens]) {
let best: { lines: [string, string]; longest: number } | null = null;
for (const candidate of group) {
const widths = [candidate.first, candidate.second].map((line) => measureText(font, line, size, opts));
const longest = Math.max(...widths);
if (longest > maxWidth) continue;
if (!best || longest < best.longest) best = { lines: [candidate.first, candidate.second], longest };
}
if (best) return best.lines;
}
return null;
}
/**
* Name in festen Groessenstufen: erst einzeilig in jeder Stufe, dann
* zweizeilig an einem Leerzeichen in jeder Stufe, zuletzt nach einem
* Bindestrich. Ein Umbruch zwischen zwei Namensteilen in kleinerer Stufe
* liest sich besser als ein getrennter Doppelname in groesserer.
*/
export function fitName(
font: FontKey,
text: string,
sizes: readonly number[],
maxWidth: number,
opts?: TextOptions
): FitResult | null {
for (const size of sizes) {
if (fits(font, text, size, maxWidth, opts)) return { size, lines: [text] };
}
for (const allow of ["spaces", "all"] as const) {
for (const size of sizes) {
const lines = breakName(font, text, size, maxWidth, opts, allow);
if (lines) return { size, lines };
}
}
return null;
}
/**
* Fliesstext-Umbruch (gierig) an Leerzeichen; ein einzelnes zu langes Wort
* darf zusaetzlich nach Bindestrich oder Schraegstrich brechen. Passt auch
* ein Teilstueck nicht, ist das Ergebnis null — mitten im Wort wird nie
* getrennt. Leere Absaetze (doppelter Zeilenumbruch in der Widmung) bleiben
* als leere Zeile erhalten.
*/
export function wrapParagraphs(
font: FontKey,
text: string,
size: number,
maxWidth: number,
opts?: TextOptions
): string[] | null {
const result: string[] = [];
for (const paragraph of text.split("\n")) {
if (paragraph.trim() === "") {
result.push("");
continue;
}
const tokens = splitLongWords(font, paragraph.split(" "), size, maxWidth, opts);
if (!tokens) return null;
let current = "";
for (const token of tokens) {
// Ein Token, das auf "-" oder "/" endet und aus einem geteilten Wort stammt, haengt ohne Leerzeichen an.
const candidate = current === "" ? token.text : current + (token.joinWithSpace ? " " : "") + token.text;
if (fits(font, candidate, size, maxWidth, opts)) {
current = candidate;
} else {
result.push(current);
current = token.text;
}
}
result.push(current);
}
return result;
}
interface Token {
text: string;
joinWithSpace: boolean;
}
function splitLongWords(
font: FontKey,
words: string[],
size: number,
maxWidth: number,
opts?: TextOptions
): Token[] | null {
const tokens: Token[] = [];
for (const word of words) {
if (fits(font, word, size, maxWidth, opts)) {
tokens.push({ text: word, joinWithSpace: true });
continue;
}
const pieces = word.split(/(?<=[-/])/);
if (pieces.some((piece) => !fits(font, piece, size, maxWidth, opts))) return null;
pieces.forEach((piece, index) => tokens.push({ text: piece, joinWithSpace: index === 0 }));
}
return tokens;
}
/**
* Ausgeglichener Umbruch: die schmalste Satzbreite, die dieselbe Zeilenzahl
* ergibt. Bei zentriertem Satz vermeidet das eine lange Zeile ueber einem
* einzelnen Restwort ("… und ihre / Bienenvölker.") — die Zeilen werden
* aehnlich lang, wie ein Setzer es von Hand umbrechen wuerde.
*/
export function wrapBalanced(
font: FontKey,
text: string,
size: number,
maxWidth: number,
opts?: TextOptions
): string[] | null {
// Jeder Absatz fuer sich: die Zeilenumbrueche der Kundin bleiben, wie sie sind.
if (text.includes("\n")) {
const parts = text.split("\n").map((paragraph) => wrapBalanced(font, paragraph, size, maxWidth, opts));
return parts.some((part) => part === null) ? null : parts.flatMap((part) => part ?? []);
}
const greedy = wrapParagraphs(font, text, size, maxWidth, opts);
if (!greedy || greedy.length <= 1) return greedy;
let lo = maxWidth * 0.4;
let hi = maxWidth;
let best = greedy;
for (let i = 0; i < 18; i++) {
const mid = (lo + hi) / 2;
const lines = wrapParagraphs(font, text, size, mid, opts);
if (lines && lines.length === greedy.length) {
best = lines;
hi = mid;
} else {
lo = mid;
}
}
return best;
}
/** Fliesstext in festen Stufen; erste Stufe mit hoechstens `maxLines` Zeilen. */
export function fitParagraphs(
font: FontKey,
text: string,
sizes: readonly number[],
maxWidth: number,
maxLines: number,
opts?: TextOptions & { balanced?: boolean }
): FitResult | null {
for (const size of sizes) {
const lines = opts?.balanced
? wrapBalanced(font, text, size, maxWidth, opts)
: wrapParagraphs(font, text, size, maxWidth, opts);
if (lines && lines.length <= maxLines) return { size, lines };
}
return null;
}
/**
* Grundlinie innerhalb eines Zeilenkastens: die Versalhoehe sitzt optisch
* mittig. So ergeben gleiche Abstaende zwischen Bloecken unterschiedlicher
* Groesse auch gleich wirkende Luft.
*/
export function baselineInBox(font: FontKey, size: number, boxTop: number, boxHeight: number): number {
const { capHeight } = fontMetrics(font, size);
return boxTop + (boxHeight + capHeight) / 2;
}
+6 -2
View File
@@ -56,7 +56,9 @@ export async function getActiveProducts(
await prisma.product.findMany({
where: {
isActive: true,
...(options.category ? { category: options.category } : {}),
// Traegerprodukte der Bienenpatenschaft sind keine Shopartikel: sie
// werden ueber /patenschaft personalisiert verkauft, nie direkt.
...(options.category ? { category: options.category } : { category: { not: "PATENSCHAFT" } }),
...(options.onlyAvailable ? { inventory: { quantity: { gt: 0 } } } : {}),
},
include: { harvest: true, batch: true, inventory: { select: { quantity: true } } },
@@ -102,7 +104,9 @@ export async function getProductBySlug(
where: { slug },
include: { harvest: true, batch: true, inventory: { select: { quantity: true } } },
});
return product ? normalizeProduct(product) : null;
// Eine Patenschaft hat keine Produktseite (sie braucht eine Personalisierung).
if (!product || product.category === "PATENSCHAFT") return null;
return normalizeProduct(product);
}
export async function getProductsByIds(
+3
View File
@@ -18,6 +18,7 @@ interface EmailStrings {
total: string;
deliveryTo: string;
pickupNote: string;
digitalNote: string;
giftNote: string;
trackOrder: string;
subject: (orderNumber: string) => string;
@@ -121,6 +122,7 @@ const de: EmailStrings = {
total: "Gesamt",
deliveryTo: "Lieferung an:",
pickupNote: "Abholung in Altenmünster — wir melden uns, sobald deine Bestellung bereitsteht.",
digitalNote: "Deine Bestellung enthält nur digitale Leistungen — es wird nichts verschickt.",
giftNote: "🎁 Deine persönliche Nachricht wird von Hand auf eine Karte geschrieben und der Bestellung beigelegt.",
trackOrder: "BESTELLUNG VERFOLGEN",
subject: (orderNumber) => `Bestellbestätigung ${orderNumber} — Wabenhain`,
@@ -229,6 +231,7 @@ const en: EmailStrings = {
total: "Total",
deliveryTo: "Delivering to:",
pickupNote: "Pickup in Altenmünster — we'll be in touch once your order is ready.",
digitalNote: "Your order contains digital items only — nothing will be shipped.",
giftNote: "🎁 Your personal message will be handwritten on a card and included with your order.",
trackOrder: "TRACK ORDER",
subject: (orderNumber) => `Order confirmation ${orderNumber} — Wabenhain`,
+18 -7
View File
@@ -6,7 +6,7 @@ import { escapeHtml } from "@/lib/escape-html";
import { withdrawalReceiptEmailContent, type WithdrawalDeclarationInput } from "@/lib/withdrawal";
import type { Locale } from "@/i18n/config";
interface EmailContent {
export interface EmailContent {
subject: string;
html: string;
text: string;
@@ -23,7 +23,7 @@ interface EmailContent {
* z. B. den Vornamen HTML samt Phishing-Link in eine echte Shop-Mail
* einschleusen. Die Text-Variante bleibt unmaskiert (kein HTML).
*/
async function emailLayout(locale: Locale, preheader: string, bodyHtml: string): Promise<string> {
export async function emailLayout(locale: Locale, preheader: string, bodyHtml: string): Promise<string> {
const company = await getCompanyInfo();
return `<!doctype html>
<html lang="${locale}">
@@ -58,16 +58,25 @@ async function emailLayout(locale: Locale, preheader: string, bodyHtml: string):
}
/** Nur http(s)-Links — ein "javascript:"-Link aus einem Admin-Feld (Tracking) wird weggelassen. */
function button(url: string, label: string): string {
export function button(url: string, label: string): string {
if (!/^https?:\/\//i.test(url)) return "";
return `<a href="${escapeHtml(url)}" style="display:inline-block;margin-top:16px;padding:12px 24px;background-color:#24352B;color:#F4EFE5;text-decoration:none;font-size:14px;letter-spacing:1px;">${escapeHtml(label)}</a>`;
}
/**
* " (500 g)" hinter dem Positionsnamen — aber nur bei Ware mit Gewicht. Eine
* Patenschaft (Gewicht 0) stuende sonst als "Bienenpatenschaft (0 g)" in der
* Bestaetigung.
*/
function weightSuffix(weightGrams: number): string {
return weightGrams > 0 ? ` (${weightGrams} g)` : "";
}
interface OrderConfirmationInput {
locale: Locale;
orderNumber: string;
firstName: string;
fulfillmentMethod: "VERSAND" | "ABHOLUNG";
fulfillmentMethod: "VERSAND" | "ABHOLUNG" | "DIGITAL";
items: { productName: string; quantity: number; weightGrams: number; totalCents: number }[];
subtotalCents: number;
shippingCents: number;
@@ -85,14 +94,16 @@ export async function orderConfirmationEmail(order: OrderConfirmationInput): Pro
const rows = order.items
.map(
(item) =>
`<tr><td style="padding:6px 0;">${item.quantity} × ${escapeHtml(item.productName)} (${item.weightGrams} g)</td><td style="padding:6px 0;text-align:right;">${formatPrice(item.totalCents, "EUR", order.locale)}</td></tr>`
`<tr><td style="padding:6px 0;">${item.quantity} × ${escapeHtml(item.productName)}${weightSuffix(item.weightGrams)}</td><td style="padding:6px 0;text-align:right;">${formatPrice(item.totalCents, "EUR", order.locale)}</td></tr>`
)
.join("");
const deliveryHtml =
order.fulfillmentMethod === "VERSAND"
? `<p style="margin:4px 0;">${s.deliveryTo}<br>${escapeHtml(order.street)}<br>${escapeHtml(order.zip)} ${escapeHtml(order.city)}</p>`
: `<p style="margin:4px 0;">${s.pickupNote}</p>`;
: order.fulfillmentMethod === "DIGITAL"
? `<p style="margin:4px 0;">${s.digitalNote}</p>`
: `<p style="margin:4px 0;">${s.pickupNote}</p>`;
const giftHtml = order.giftMessage
? `<p style="margin:16px 0 0;padding:12px 16px;background-color:#F4EFE5;font-size:13px;color:#24352B;">
@@ -122,7 +133,7 @@ export async function orderConfirmationEmail(order: OrderConfirmationInput): Pro
);
const text = `${s.heading(order.firstName)}\n\n${order.orderNumber}\n\n${order.items
.map((i) => `${i.quantity} x ${i.productName} (${i.weightGrams} g) — ${formatPrice(i.totalCents, "EUR", order.locale)}`)
.map((i) => `${i.quantity} x ${i.productName}${weightSuffix(i.weightGrams)} — ${formatPrice(i.totalCents, "EUR", order.locale)}`)
.join("\n")}\n\n${s.subtotal}: ${formatPrice(order.subtotalCents, "EUR", order.locale)}\n${s.shipping}: ${
order.shippingCents === 0 ? s.freeShipping : formatPrice(order.shippingCents, "EUR", order.locale)
}${order.discountCents ? `\n${s.discount}: −${formatPrice(order.discountCents, "EUR", order.locale)}` : ""}\n${s.total}: ${formatPrice(order.totalCents, "EUR", order.locale)}${
+4 -1
View File
@@ -333,7 +333,10 @@ function drawItemRow(ctx: Ctx, item: InvoiceItem, startY: number): number {
const y = ensureSpace(ctx, startY, rowHeight, true);
doc.font("Helvetica").fontSize(9.5).fillColor(C.ink).text(item.productName, COL.name.x, y + 8, { width: COL.name.w });
doc.fontSize(7.5).fillColor(C.sage).text(`${item.weightGrams} g`, COL.name.x, y + 10 + nameHeight, { lineBreak: false });
// Ohne Gewicht (Patenschaft, Abo-Monat) keine Zeile "0 g".
if (item.weightGrams > 0) {
doc.fontSize(7.5).fillColor(C.sage).text(`${item.weightGrams} g`, COL.name.x, y + 10 + nameHeight, { lineBreak: false });
}
const cell = (text: string, col: { x: number; w: number }, align: "center" | "right", bold = false) =>
doc
+12 -1
View File
@@ -70,6 +70,7 @@ const READ_INDIRECTLY: Record<string, string> = {
VAT_RATE_PERCENT_KERZEN: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
VAT_RATE_PERCENT_PROPOLIS: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
VAT_RATE_PERCENT_SONSTIGES: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
VAT_RATE_PERCENT_PATENSCHAFT: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
};
/**
@@ -84,13 +85,23 @@ const USED_OUTSIDE_THE_APP: Record<string, string> = {
ADMIN_PASSWORD: "prisma/seed.ts — legt beim Start das erste Admin-Konto an.",
};
/**
* Schluessel, die die Laufzeit selbst setzt — sie gehoeren in kein Manifest.
* src/instrumentation.ts liest sie, um Hintergrundaufgaben nur im
* Node.js-Server und nicht waehrend "next build" zu starten.
*/
const SET_BY_RUNTIME: Record<string, string> = {
NEXT_RUNTIME: "Next.js setzt \"nodejs\" bzw. \"edge\" fuer jede Laufzeit.",
NEXT_PHASE: "Next.js setzt die Phase (z.B. phase-production-build).",
};
describe("Kubernetes-Manifeste und Code", () => {
test("die Manifeste liefern jede Variable, die die App liest", () => {
// Arrange
const provided = new Set([...manifestKeys(CONFIGMAP), ...manifestKeys(SECRET)]);
// Act
const missing = [...envNamesUsedInCode(SRC)].filter((name) => !provided.has(name)).sort();
const missing = [...envNamesUsedInCode(SRC)].filter((name) => !provided.has(name) && !(name in SET_BY_RUNTIME)).sort();
// Assert
expect(missing, `In k8s/ fehlen: ${missing.join(", ")}`).toEqual([]);
+82
View File
@@ -0,0 +1,82 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
import { pickLevel, pickSubscriptionLevel, rankLevels } from "@/lib/loyalty";
const regular = rankLevels(
[
{ id: "4", kind: "STAMMKUNDE", threshold: 25, name: "Goldwabe", nameEn: "Golden comb" },
{ id: "1", kind: "STAMMKUNDE", threshold: 2, name: "Blütenbesuch", nameEn: null },
{ id: "3", kind: "STAMMKUNDE", threshold: 10, name: "Honigschatz", nameEn: "Honey treasure" },
{ id: "2", kind: "STAMMKUNDE", threshold: 5, name: "Wabenbau", nameEn: "Comb builder" },
],
"de"
);
const subscription = rankLevels(
[
...Array.from({ length: 24 }, (_, i) => ({ id: `a${i + 1}`, kind: "ABO" as const, threshold: i + 1, name: `Stufe ${i + 1}`, nameEn: null })),
{ id: "vip", kind: "ABO" as const, threshold: null, name: "VIP", nameEn: null },
],
"de"
);
describe("Stammkunden-Stufe", () => {
it("sortiert nach Schwelle und vergibt Raenge", () => {
expect(regular.map((level) => [level.threshold, level.rank])).toEqual([
[2, 1],
[5, 2],
[10, 3],
[25, 4],
]);
});
it("hat unter der ersten Schwelle keine Stufe, aber ein Ziel", () => {
const result = pickLevel(regular, 1);
expect(result.level).toBeNull();
expect(result.next?.name).toBe("Blütenbesuch");
});
it("erreicht eine Stufe genau an ihrer Schwelle", () => {
expect(pickLevel(regular, 5).level?.name).toBe("Wabenbau");
expect(pickLevel(regular, 9).level?.name).toBe("Wabenbau");
expect(pickLevel(regular, 9).next?.name).toBe("Honigschatz");
});
it("sinkt, wenn weniger bezahlte Bestellungen zaehlen (Erstattung, Storno)", () => {
expect(pickLevel(regular, 10).level?.name).toBe("Honigschatz");
expect(pickLevel(regular, 9).level?.name).toBe("Wabenbau");
});
it("hat oberhalb der hoechsten Stufe kein naechstes Ziel", () => {
expect(pickLevel(regular, 40)).toMatchObject({ level: { name: "Goldwabe" }, next: null });
});
it("nutzt den englischen Namen, falls gepflegt", () => {
const english = rankLevels([{ id: "x", kind: "STAMMKUNDE", threshold: 2, name: "Blütenbesuch", nameEn: "Blossom visit" }], "en");
expect(english[0].name).toBe("Blossom visit");
});
});
describe("Abo-Stufe und VIP", () => {
it("hat ohne bezahlten Monat keine Stufe", () => {
expect(pickSubscriptionLevel(subscription, 0)).toEqual({ level: null, isVip: false });
});
it("steigt je bezahltem Monat bis 24", () => {
expect(pickSubscriptionLevel(subscription, 1).level?.threshold).toBe(1);
expect(pickSubscriptionLevel(subscription, 24)).toMatchObject({ level: { threshold: 24 }, isVip: false });
});
it("wird VIP erst jenseits der hoechsten Zahl-Schwelle", () => {
expect(pickSubscriptionLevel(subscription, 25)).toMatchObject({ level: { name: "VIP" }, isVip: true });
expect(pickSubscriptionLevel(subscription, 60).isVip).toBe(true);
});
it("vergibt ohne gepflegte VIP-Stufe nie VIP", () => {
const withoutVip = subscription.filter((level) => level.threshold !== null);
expect(pickSubscriptionLevel(withoutVip, 30)).toMatchObject({ level: { threshold: 24 }, isVip: false });
});
});
+136
View File
@@ -0,0 +1,136 @@
import { prisma } from "@/lib/prisma";
import { defaultLocale, type Locale } from "@/i18n/config";
import type { LoyaltyKind } from "@/generated/prisma/client";
/**
* Treuestufen (Spec "Medaillen und Treuestufen", Teil E).
*
* Stufen werden aus Bestellungen und Zahlungen berechnet, nie gespeichert —
* so kann eine Stufe nicht von den tatsaechlichen Daten abweichen. Eine
* Erstattung (Payment REFUNDED) oder Stornierung senkt die Stammkunden-Stufe
* automatisch, eine wirksame Kuendigung (Abo BEENDET) laesst die Abo-Stufe
* verschwinden.
*
* Die Schwellen pflegt der Admin (LoyaltyLevel). Abo-Stufen: VIP ist die
* Stufe ohne Zahl und gilt, sobald die bezahlten Monate die hoechste
* Zahl-Schwelle uebersteigen.
*/
export interface LoyaltyLevelView {
id: string;
kind: LoyaltyKind;
/** null = VIP. */
threshold: number | null;
name: string;
/** 1-basierte Position unter den Zahl-Stufen derselben Art; VIP bekommt die Position nach der hoechsten. */
rank: number;
}
export interface CustomerLoyalty {
regular: { level: LoyaltyLevelView | null; paidOrders: number; next: LoyaltyLevelView | null };
subscription: { level: LoyaltyLevelView | null; months: number; isVip: boolean } | null;
}
interface LevelRow {
id: string;
kind: LoyaltyKind;
threshold: number | null;
name: string;
nameEn: string | null;
}
/** Zahl-Stufen aufsteigend, VIP (falls vorhanden) zuletzt; mit Rang und lokalisiertem Namen. */
export function rankLevels(rows: readonly LevelRow[], locale: Locale): LoyaltyLevelView[] {
const numeric = rows
.filter((row): row is LevelRow & { threshold: number } => row.threshold !== null)
.sort((a, b) => a.threshold - b.threshold);
const vip = rows.find((row) => row.threshold === null);
const ordered: LevelRow[] = vip ? [...numeric, vip] : numeric;
return ordered.map((row, index) => ({
id: row.id,
kind: row.kind,
threshold: row.threshold,
name: (locale === "en" && row.nameEn) || row.name,
rank: index + 1,
}));
}
/** Hoechste erreichte Zahl-Stufe (Schwelle ≤ Anzahl) und die naechste darueber. */
export function pickLevel(
levels: readonly LoyaltyLevelView[],
count: number
): { level: LoyaltyLevelView | null; next: LoyaltyLevelView | null } {
const numeric = levels.filter((level) => level.threshold !== null);
let level: LoyaltyLevelView | null = null;
let next: LoyaltyLevelView | null = null;
for (const candidate of numeric) {
if ((candidate.threshold ?? 0) <= count) level = candidate;
else if (!next) next = candidate;
}
return { level, next };
}
/** Abo-Stufe aus bezahlten Monaten; VIP erst jenseits der hoechsten Zahl-Schwelle. */
export function pickSubscriptionLevel(
levels: readonly LoyaltyLevelView[],
months: number
): { level: LoyaltyLevelView | null; isVip: boolean } {
const numeric = levels.filter((level) => level.threshold !== null);
const vip = levels.find((level) => level.threshold === null) ?? null;
const highest = numeric.at(-1)?.threshold ?? null;
if (vip && highest !== null && months > highest) return { level: vip, isVip: true };
return { level: pickLevel(levels, months).level, isVip: false };
}
/**
* Bezahlte Bestellungen fuer die Stammkunden-Stufe: Zahlung erfolgreich (nicht
* erstattet), nicht storniert, und keine Abo-Monatsrechnung oder Abo-Lieferung —
* die haben ihre eigenen Stufen.
*/
async function countPaidOrders(customerId: string): Promise<number> {
return prisma.order.count({
where: {
customerId,
status: { not: "STORNIERT" },
payment: { is: { status: "SUCCEEDED" } },
subscriptionPayment: { is: null },
subscriptionDelivery: { is: null },
},
});
}
/**
* Bezahlte Monate des am laengsten laufenden Abos. Nur laufende Abos zaehlen
* (auch gekuendigte bis zum Ende des bezahlten Zeitraums und solche im
* Verzug); beendete tragen nichts bei — ein neues Abo beginnt bei null.
* Zurueckgebuchte Zahlungen zaehlen nicht.
*/
async function longestSubscriptionMonths(customerId: string): Promise<number | null> {
const subscriptions = await prisma.sponsorshipSubscription.findMany({
where: { customerId, status: { in: ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] } },
select: { _count: { select: { payments: { where: { reversedAt: null } } } } },
});
if (subscriptions.length === 0) return null;
return Math.max(...subscriptions.map((subscription) => subscription._count.payments));
}
export async function getCustomerLoyalty(customerId: string, locale: Locale = defaultLocale): Promise<CustomerLoyalty> {
const [rows, paidOrders, months] = await Promise.all([
prisma.loyaltyLevel.findMany({ select: { id: true, kind: true, threshold: true, name: true, nameEn: true } }),
countPaidOrders(customerId),
longestSubscriptionMonths(customerId),
]);
const regularLevels = rankLevels(
rows.filter((row) => row.kind === "STAMMKUNDE" && row.threshold !== null),
locale
);
const subscriptionLevels = rankLevels(
rows.filter((row) => row.kind === "ABO"),
locale
);
return {
regular: { ...pickLevel(regularLevels, paidOrders), paidOrders },
subscription: months === null ? null : { ...pickSubscriptionLevel(subscriptionLevels, months), months },
};
}
+280
View File
@@ -0,0 +1,280 @@
import type { FontKey } from "@/lib/certificate/fonts";
import {
beeEmblem,
hexCrest,
hexDivider,
honeycombEmblem,
lensLeaf,
lindenEmblem,
placeAll,
wreathBranch,
} from "./emblems";
import { liningNumerals } from "./numerals";
import { medalPalette, metalKey, type MetalKey } from "./palette";
import { chain, circlePath, ringPath, rotate, scale, translate, type ReliefPath } from "./paths";
import {
SEPARATOR,
centeredLine,
layoutCenterText,
layoutLegend,
type CenterTextOptions,
type PlacedLine,
} from "./text-layout";
import type { MedalInput, MedalPalette } from "./types";
/**
* Aufbau einer Medaille im Raster 1024 × 1024 (Mitte 512):
*
* 500 Aussenkante
* 450 Innenkante des Randes (in 3D der erhabene Rand)
* 434 Perlkreis
* 396–416 Umschrift (oben aussen stehend, unten innen stehend)
* 372 feiner Ring um das Feld
* < 368 Feld mit Emblem und Mitteltext
*
* Dieselben Radien nutzt die 3D-Ansicht (components/patenschaft-3d/coin-geometry).
*/
export const MEDAL_GEOMETRY = {
size: 1024,
center: 512,
outerRadius: 500,
rimInnerRadius: 450,
beadRadius: 434,
legendTopBaseline: 396,
legendBottomBaseline: 416,
fieldRingRadius: 372,
fieldRadius: 368,
} as const;
const C = MEDAL_GEOMETRY.center;
export type FieldDecor = "none" | "rings" | "sunburst" | "guilloche" | "honeycomb";
export type MedalArtwork = {
metal: MetalKey;
palette: MedalPalette;
/** Zweifarbige Muenze: Ring (Umschriftzone) im Randmetall, Feld hell. */
bimetal: boolean;
decor: FieldDecor;
relief: ReliefPath[];
/** Gesetzte Mitteltext-Zeilen (fuer Tests: Tintengrenzen). */
centerLines: PlacedLine[];
/** Sicherer Kreis, in dem der Mitteltext liegen muss. */
centerSafeRadius: number;
};
const LEGEND_FONT: FontKey = "serifSemiBold";
const NAME_FONT: FontKey = "serifMediumItalic";
const LEVEL_FONT: FontKey = "serifMedium";
function upper(text: string): string {
return text.normalize("NFC").toLocaleUpperCase("de-DE");
}
/** Steuerzeichen und doppelte Leerzeichen raus — die Eingabe ist sonst schon geprueft. */
function clean(text: string): string {
return text
.normalize("NFC")
.replace(/[\u0000-\u001f\u007f-\u009f]/g, "")
.replace(/\s+/g, " ")
.trim();
}
/** Perlkreis: kleine Kugeln dicht an dicht, wie bei gepraegten Medaillen. */
function beadRing(): ReliefPath {
const radius = MEDAL_GEOMETRY.beadRadius;
const count = Math.round((2 * Math.PI * radius) / 14.5);
const d = Array.from({ length: count }, (_, i) => {
const angle = (i / count) * Math.PI * 2;
return circlePath(C + Math.cos(angle) * radius, C + Math.sin(angle) * radius, 4.3);
}).join("");
return { d, fillRule: "nonzero" };
}
/**
* Umschrift: Schriftgroesse sinkt in Stufen, bis der Text in den erlaubten
* Bogen passt (oben hoechstens 220°, unten 150°, damit beide nie zusammenstossen).
*/
function legend(text: string, position: "top" | "bottom"): ReliefPath[] {
const maxSpan = ((position === "top" ? 220 : 150) * Math.PI) / 180;
const radius = position === "top" ? MEDAL_GEOMETRY.legendTopBaseline : MEDAL_GEOMETRY.legendBottomBaseline;
for (const size of [31, 29, 27, 25, 23]) {
const layout = layoutLegend(upper(text), {
cx: C,
cy: C,
radius,
position,
font: LEGEND_FONT,
size,
letterSpacing: size * 0.2,
});
if (layout.arcLength / radius <= maxSpan || size === 23) return layout.paths;
}
return [];
}
type CenterSpec = Omit<CenterTextOptions, "cx" | "cy" | "font"> & { font?: FontKey };
function centerText(text: string, spec: CenterSpec): { paths: ReliefPath[]; lines: PlacedLine[] } {
const layout = layoutCenterText(text, {
...spec,
cx: C,
cy: C,
font: spec.font ?? NAME_FONT,
centerY: C + spec.centerY,
minTop: C + spec.minTop,
maxBottom: C + spec.maxBottom,
});
return { paths: layout.paths, lines: layout.lines };
}
const NAME_SIZES = { singleLineSizes: [104, 92, 82, 74], twoLineSizes: [70, 62, 54, 48] } as const;
function frame(): ReliefPath[] {
return [beadRing(), ringPath(C, C, MEDAL_GEOMETRY.fieldRingRadius, 3.4)];
}
function emblemAt(paths: ReliefPath[], x: number, y: number, s: number, angle = 0): ReliefPath[] {
return placeAll(paths, chain(scale(s), rotate(angle), translate(C + x, C + y)));
}
/** Zwei Kranzzweige im unteren Feld (rechts und links symmetrisch). */
function lowerWreath(): ReliefPath[] {
const r = 300;
const deg = Math.PI / 180;
return [...wreathBranch(C, C, r, 30 * deg, 80 * deg, 6), ...wreathBranch(C, C, r, 150 * deg, 100 * deg, 6)];
}
/** Silber: kleine Lindenblaetter an den Enden des Trennzeichens. */
function dividerLeaves(y: number): ReliefPath[] {
return [
...emblemAt([lensLeaf(30, 8)], 92, y + 2, 1, 1.25),
...emblemAt([lensLeaf(30, 8)], -92, y + 2, 1, -1.25),
];
}
function patenschaftArtwork(input: Extract<MedalInput, { kind: "PATENSCHAFT" }>) {
const wreath = input.tier === "ABO";
const safeRadius = wreath ? 278 : 330;
const name = centerText(clean(input.beeName) || "–", { ...NAME_SIZES, centerY: 34, minTop: -64, maxBottom: 148, safeRadius });
const emblem =
input.tier === "GOLD"
? [...emblemAt(hexCrest(118), 0, -192, 1), ...emblemAt(beeEmblem(), 0, -186, 0.66)]
: emblemAt(beeEmblem(), 0, -196, 1);
const divider = hexDivider(C, C + 184, 70);
const extras = input.tier === "SILBER" ? dividerLeaves(184) : wreath ? lowerWreath() : [];
const decor: FieldDecor = { BRONZE: "none", SILBER: "rings", GOLD: "sunburst", ABO: "rings" }[input.tier] as FieldDecor;
return {
decor,
safeRadius,
lines: name.lines,
relief: [
...frame(),
...legend(`Wabenhain ${SEPARATOR} Bienenpatenschaft ${SEPARATOR} ${input.year}`, "top"),
...legend(`${SEPARATOR} ${clean(input.number)} ${SEPARATOR}`, "bottom"),
...emblem,
...name.paths,
...divider,
...extras,
],
};
}
function stammkundeArtwork(input: Extract<MedalInput, { kind: "STAMMKUNDE" }>) {
const safeRadius = 330;
const level = Math.max(1, Math.round(input.level));
const name = centerText(clean(input.name) || `Stufe ${level}`, {
font: LEVEL_FONT,
singleLineSizes: [96, 86, 76, 68],
twoLineSizes: [64, 56, 50, 44],
centerY: 26,
minTop: -66,
maxBottom: 140,
safeRadius,
});
return {
decor: (level >= 4 ? "honeycomb" : level === 3 ? "sunburst" : level === 2 ? "rings" : "none") as FieldDecor,
safeRadius,
lines: name.lines,
relief: [
...frame(),
...legend(`Wabenhain ${SEPARATOR} Stammkundschaft`, "top"),
...legend(`${SEPARATOR} Stufe ${level} ${SEPARATOR}`, "bottom"),
...emblemAt(honeycombEmblem(31, 5.5), 0, -196, 1),
...name.paths,
...hexDivider(C, C + 176, 70),
],
};
}
function aboArtwork(input: Extract<MedalInput, { kind: "ABO" }>) {
const months = Math.max(1, Math.round(input.months));
const digits = String(months);
const safeRadius = 300;
const number = liningNumerals("serifSemiBold", digits, digits.length > 2 ? 150 : 190, C, C + 22);
const numberLine: PlacedLine = { text: digits, size: 190, x: number.box.left, baseline: number.box.bottom, box: number.box };
const unit = centeredLine(months === 1 ? "MONAT" : "MONATE", LEGEND_FONT, 36, C, C + 186, 11);
return {
decor: "rings" as FieldDecor,
safeRadius,
lines: [numberLine, unit.line],
relief: [
...frame(),
...legend(`Wabenhain ${SEPARATOR} Patenschaft im Abo`, "top"),
...legend(`${SEPARATOR} Danke für die Treue ${SEPARATOR}`, "bottom"),
...emblemAt(lindenEmblem(), 0, -206, 1),
number.path,
unit.path,
],
};
}
function vipArtwork() {
const safeRadius = 278;
const title = centerText("VIP", {
font: "serifSemiBold",
letterSpacing: 26,
singleLineSizes: [168, 150],
twoLineSizes: [],
centerY: 40,
minTop: -70,
maxBottom: 140,
safeRadius,
});
return {
decor: "guilloche" as FieldDecor,
safeRadius,
lines: title.lines,
relief: [
...frame(),
...legend(`Wabenhain ${SEPARATOR} Bienenpatenschaft`, "top"),
...legend(`In besonderer Wertschätzung`, "bottom"),
...emblemAt(hexCrest(112), 0, -196, 1),
...emblemAt(beeEmblem(), 0, -190, 0.62),
...title.paths,
...hexDivider(C, C + 178, 64),
...lowerWreath(),
],
};
}
export function composeMedal(input: MedalInput): MedalArtwork {
const metal = metalKey(input);
const parts =
input.kind === "PATENSCHAFT"
? patenschaftArtwork(input)
: input.kind === "STAMMKUNDE"
? stammkundeArtwork(input)
: input.kind === "ABO"
? aboArtwork(input)
: vipArtwork();
return {
metal,
palette: medalPalette(input),
bimetal: metal === "BIMETALL",
decor: parts.decor,
relief: parts.relief.filter((p) => p.d.length > 0),
centerLines: parts.lines,
centerSafeRadius: parts.safeRadius,
};
}
+229
View File
@@ -0,0 +1,229 @@
import {
chain,
ellipsePoints,
hexRing,
hexagonPoints,
polygonPath,
rotate,
sampleCubic,
strokePolyline,
transformPath,
translate,
type Affine,
type Point,
type ReliefPath,
} from "./paths";
/**
* Selbst gezeichnete Praegebilder. Jedes Emblem liegt in eigenen Koordinaten
* um (0, 0) und wird per Abbildung platziert. Alles sind Flaechen (siehe
* paths.ts), damit das Relief in 3D extrudiert werden kann.
*/
const fill = (d: string): ReliefPath => ({ d, fillRule: "nonzero" });
export function placeAll(paths: readonly ReliefPath[], m: Affine): ReliefPath[] {
return paths.map((p) => ({ ...p, d: transformPath(p.d, m) }));
}
/** Ellipse als Ring (Kontur als Flaeche), z.B. fuer Fluegel. */
function ellipseRing(cx: number, cy: number, rx: number, ry: number, rotation: number, width: number): ReliefPath {
const outer = ellipsePoints(cx, cy, rx, ry, rotation, 56);
const inner = ellipsePoints(cx, cy, rx - width, ry - width, rotation, 56).reverse();
return { d: polygonPath(outer) + polygonPath(inner), fillRule: "evenodd" };
}
/** Halbe Breite des Hinterleibs an der Hoehe y (Ei-Form, unten spitzer). */
function abdomenHalfWidth(y: number): number {
const center = 40;
const half = 58;
const t = (y - center) / half;
if (Math.abs(t) >= 1) return 0;
return 27 * Math.sqrt(1 - t * t) * (1 - 0.32 * Math.max(0, t));
}
function abdomenBand(y0: number, y1: number): string {
const steps = 10;
const ys = Array.from({ length: steps + 1 }, (_, i) => y0 + ((y1 - y0) * i) / steps);
const right = ys.map((y) => ({ x: abdomenHalfWidth(y), y }));
const left = [...ys].reverse().map((y) => ({ x: -abdomenHalfWidth(y), y }));
return polygonPath([...right, ...left]);
}
/**
* Biene in Draufsicht mit ausgebreiteten Fluegeln — das Bienenbild antiker
* Muenzen (Ephesos), klar und symmetrisch, statt einer Comic-Biene.
* Hoehe ca. 215 (y −115 … +100), Breite ca. 240.
*/
export function beeEmblem(): ReliefPath[] {
const paths: ReliefPath[] = [];
paths.push(fill(polygonPath(ellipsePoints(0, -72, 14, 12.5))));
paths.push(fill(polygonPath(ellipsePoints(0, -41, 21, 22))));
// Hinterleib aus fuenf Segmenten mit schmalen Fugen: liest sich als Streifen.
const bands: Array<[number, number]> = [
[-15, 3],
[7, 25],
[29, 47],
[51, 68],
[72, 97],
];
bands.forEach(([a, b]) => paths.push(fill(abdomenBand(a, b))));
for (const side of [1, -1]) {
const sx = (x: number) => x * side;
paths.push(ellipseRing(sx(64), -52, 54, 21, -0.42 * side, 5));
paths.push(ellipseRing(sx(52), -16, 37, 15, -0.1 * side, 4.5));
const foreVein = sampleCubic({ x: sx(22), y: -44 }, { x: sx(45), y: -56 }, { x: sx(70), y: -66 }, { x: sx(96), y: -72 }, 16);
const hindVein = sampleCubic({ x: sx(24), y: -24 }, { x: sx(40), y: -22 }, { x: sx(58), y: -19 }, { x: sx(76), y: -16 }, 12);
paths.push(fill(strokePolyline(foreVein, 2.6, 0.45)));
paths.push(fill(strokePolyline(hindVein, 2.4, 0.45)));
const antenna = sampleCubic({ x: sx(5), y: -82 }, { x: sx(9), y: -100 }, { x: sx(19), y: -110 }, { x: sx(31), y: -113 }, 14);
paths.push(fill(strokePolyline(antenna, 3.2)));
paths.push(fill(polygonPath(ellipsePoints(sx(32), -113, 4.6, 4.6, 0, 20))));
}
return paths;
}
/** Wabenrosette aus sieben Zellen, die mittlere voll. Radius einer Zelle `r`. */
export function honeycombEmblem(r = 34, width = 6): ReliefPath[] {
const w = Math.sqrt(3) * r;
const centers: Array<[number, number]> = [
[w, 0],
[-w, 0],
[w / 2, 1.5 * r],
[-w / 2, 1.5 * r],
[w / 2, -1.5 * r],
[-w / 2, -1.5 * r],
];
const gap = 4;
return [
fill(polygonPath(hexagonPoints(0, 0, r - width - gap))),
hexRing(0, 0, r - 1, width),
...centers.map(([x, y]) => hexRing(x, y, r - 1, width)),
];
}
/** Herzfoermiges Lindenblatt, Stielansatz bei (0,0), Spitze bei (0,−length), mit Mittelrippe. */
export function lindenLeaf(length: number): ReliefPath {
const L = length;
const outline =
`M0 0C${-0.16 * L} ${-0.05 * L} ${-0.62 * L} ${-0.12 * L} ${-0.5 * L} ${-0.52 * L}` +
`C${-0.42 * L} ${-0.84 * L} ${-0.1 * L} ${-0.9 * L} 0 ${-L}` +
`C${0.1 * L} ${-0.9 * L} ${0.42 * L} ${-0.84 * L} ${0.5 * L} ${-0.52 * L}` +
`C${0.62 * L} ${-0.12 * L} ${0.16 * L} ${-0.05 * L} 0 0Z`;
const rib = polygonPath([
{ x: -1.3, y: -0.12 * L },
{ x: -0.5, y: -0.84 * L },
{ x: 0.5, y: -0.84 * L },
{ x: 1.3, y: -0.12 * L },
]);
return { d: outline + rib, fillRule: "evenodd" };
}
/** Schmales, spitzes Blatt (Lorbeer-/Kranzblatt) mit Mittelrippe. */
export function lensLeaf(length: number, halfWidth: number): ReliefPath {
const L = length;
const w = halfWidth;
const outline = `M0 0C${w} ${-0.28 * L} ${w} ${-0.68 * L} 0 ${-L}C${-w} ${-0.68 * L} ${-w} ${-0.28 * L} 0 0Z`;
const rib = polygonPath([
{ x: -0.9, y: -0.1 * L },
{ x: -0.35, y: -0.82 * L },
{ x: 0.35, y: -0.82 * L },
{ x: 0.9, y: -0.1 * L },
]);
return { d: outline + rib, fillRule: "evenodd" };
}
/** Lindenbluete: fuenf Bluetenblaetter um eine volle Mitte. */
export function lindenBlossom(cx: number, cy: number, r: number): ReliefPath[] {
const petals = Array.from({ length: 5 }, (_, i) => {
const angle = (Math.PI * 2 * i) / 5 - Math.PI / 2;
const px = cx + Math.cos(angle) * r * 0.62;
const py = cy + Math.sin(angle) * r * 0.62;
return fill(polygonPath(ellipsePoints(px, py, r * 0.42, r * 0.3, angle, 18)));
});
return [...petals, fill(polygonPath(ellipsePoints(cx, cy, r * 0.26, r * 0.26, 0, 14)))];
}
/** Lindenblatt mit eingetieften Adern (Mittelrippe und drei Paar Seitenadern). */
function veinedLindenLeaf(length: number): ReliefPath {
const L = length;
const outline = lindenLeaf(L).d.split("Z")[0] + "Z";
const slit = (points: Point[], width: number) => strokePolyline(points, width, 0.35);
const midrib = slit([{ x: 0, y: -0.06 * L }, { x: 0, y: -0.88 * L }], 3);
const veins = [0.22, 0.42, 0.6].flatMap((t, i) => {
const y = -t * L;
const reach = 0.36 - i * 0.06;
return [1, -1].map((side) =>
slit(sampleCubic({ x: side * 3.5, y }, { x: side * 0.12 * L, y: y - 0.05 * L }, { x: side * 0.24 * L, y: y - 0.12 * L }, { x: side * reach * L, y: y - 0.2 * L }, 10), 2.4),
);
});
return { d: outline + midrib + veins.join(""), fillRule: "evenodd" };
}
/**
* Linde: grosses Herzblatt mit Adern, am Stiel zwei Blueten. Hoehe ca. 190,
* Breite ca. 200, um (0,0).
*/
export function lindenEmblem(): ReliefPath[] {
const stem = sampleCubic({ x: 0, y: 72 }, { x: -2, y: 84 }, { x: -6, y: 94 }, { x: -14, y: 104 }, 10);
const pedicelLeft = sampleCubic({ x: -6, y: 92 }, { x: -24, y: 90 }, { x: -44, y: 84 }, { x: -62, y: 72 }, 10);
const pedicelRight = sampleCubic({ x: -4, y: 88 }, { x: 18, y: 92 }, { x: 40, y: 88 }, { x: 60, y: 76 }, 10);
return [
...placeAll([veinedLindenLeaf(160)], translate(0, 74)),
fill(strokePolyline(stem, 4)),
fill(strokePolyline(pedicelLeft, 2.6)),
fill(strokePolyline(pedicelRight, 2.6)),
...lindenBlossom(-70, 66, 19),
...lindenBlossom(68, 70, 19),
];
}
/**
* Halber Kranz entlang eines Kreisbogens um den Medaillenmittelpunkt
* (Winkel in Radiant, SVG-Richtung: 0 = rechts, PI/2 = unten). Blaetter
* wechseln innen/aussen, wie bei einem gebundenen Zweig.
*/
export function wreathBranch(cx: number, cy: number, radius: number, from: number, to: number, leaves: number): ReliefPath[] {
const arc = Array.from({ length: 41 }, (_, i) => {
const angle = from + ((to - from) * i) / 40;
return { x: cx + Math.cos(angle) * radius, y: cy + Math.sin(angle) * radius };
});
const direction = Math.sign(to - from) || 1;
const leafPaths = Array.from({ length: leaves }, (_, i) => {
const t = (i + 0.6) / (leaves + 0.2);
const angle = from + (to - from) * t;
const base = { x: cx + Math.cos(angle) * radius, y: cy + Math.sin(angle) * radius };
// Tangente in Laufrichtung, Blatt um 35° nach innen bzw. aussen gedreht.
const tangent = angle + (direction * Math.PI) / 2;
const outward = i % 2 === 0 ? 1 : -1;
const tilt = tangent + Math.PI / 2 + outward * direction * 0.62;
const size = 1 - 0.35 * t;
return placeAll([lensLeaf(50 * size, 12.5 * size)], chain(rotate(tilt), translate(base.x, base.y)))[0];
});
const tip = arc[arc.length - 1];
return [fill(strokePolyline(arc, 4, 0.55)), ...leafPaths, ...lindenBlossom(tip.x, tip.y, 11)];
}
/** Sechseck-Rahmen mit feiner Innenlinie (Wappen fuer Gold und VIP). */
export function hexCrest(radius: number): ReliefPath[] {
return [hexRing(0, 0, radius, 6.5), hexRing(0, 0, radius - 12, 2.4)];
}
/** Trennzeichen unter dem Namen: zwei feine Linien und eine kleine Wabe. */
export function hexDivider(cx: number, cy: number, halfWidth: number): ReliefPath[] {
const line = (x0: number, x1: number) => fill(strokePolyline([{ x: x0, y: cy }, { x: x1, y: cy }], 2.2));
return [
line(cx - halfWidth, cx - 16),
line(cx + 16, cx + halfWidth),
fill(polygonPath(hexagonPoints(cx, cy, 7.5))),
];
}
/** Hilfsfunktion fuer Tests: alle Koordinaten eines Pfades (nur M/L/Q/C/Z). */
export function pathPoints(d: string): Point[] {
const numbers = (d.match(/-?\d*\.?\d+/g) ?? []).map(Number);
const points: Point[] = [];
for (let i = 0; i + 1 < numbers.length; i += 2) points.push({ x: numbers[i], y: numbers[i + 1] });
return points;
}
+3
View File
@@ -0,0 +1,3 @@
export type { MedalInput, MedalPalette, PatenschaftTier } from "./types";
export { medalPalette } from "./palette";
export { medalReliefSvg, renderMedalPng, renderMedalSvg } from "./render";
+158
View File
@@ -0,0 +1,158 @@
// @vitest-environment node
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { MEDAL_GEOMETRY, composeMedal } from "./compose";
import { medalPalette, medalReliefSvg, renderMedalPng, renderMedalSvg, type MedalInput } from "./index";
const patenschaft = (tier: "BRONZE" | "SILBER" | "GOLD" | "ABO", beeName = "Summsi"): MedalInput => ({
kind: "PATENSCHAFT",
tier,
beeName,
year: 2026,
number: "WH-P-2026-00042",
});
const ALL: MedalInput[] = [
patenschaft("BRONZE"),
patenschaft("SILBER"),
patenschaft("GOLD"),
patenschaft("ABO"),
{ kind: "STAMMKUNDE", level: 1, name: "Blütenbesuch" },
{ kind: "STAMMKUNDE", level: 4, name: "Goldwabe" },
{ kind: "ABO", months: 1 },
{ kind: "ABO", months: 24 },
{ kind: "VIP" },
];
describe("renderMedalSvg", () => {
it.each(ALL.map((input) => [input.kind, input] as const))("%s: quadratisches SVG ohne <text>", (_kind, input) => {
const svg = renderMedalSvg(input);
expect(svg.startsWith("<svg")).toBe(true);
expect(svg).toContain('viewBox="0 0 1024 1024"');
expect(svg).not.toMatch(/<text\b/i);
expect(svg).not.toMatch(/<rect\b[^>]*width="(100%|1024)"/);
});
it("ist deterministisch", () => {
expect(renderMedalSvg(patenschaft("GOLD"))).toBe(renderMedalSvg(patenschaft("GOLD")));
});
it("setzt Nutzereingaben nur als Glyphenpfade, nie als Markup", () => {
const svg = renderMedalSvg(patenschaft("BRONZE", '<script>"x"'));
expect(svg).not.toContain("<script");
expect(svg).not.toContain('"x"');
});
it("unterscheidet VIP deutlich von Gold", () => {
expect(renderMedalSvg({ kind: "VIP" })).not.toBe(renderMedalSvg(patenschaft("GOLD")));
expect(medalPalette({ kind: "VIP" })).not.toEqual(medalPalette(patenschaft("GOLD")));
expect(composeMedal({ kind: "VIP" }).decor).toBe("guilloche");
});
it("gibt jeder Patenschaftsstufe ein eigenes Metall", () => {
const bases = (["BRONZE", "SILBER", "GOLD", "ABO"] as const).map((tier) => medalPalette(patenschaft(tier)).base);
expect(new Set(bases).size).toBe(4);
});
});
describe("medalReliefSvg", () => {
it("enthaelt nur gefuellte Pfade in einer Farbe, ohne Text und Verlaeufe", () => {
const svg = medalReliefSvg(patenschaft("GOLD", "Zoë"));
expect(svg).toContain('viewBox="0 0 1024 1024"');
expect(svg).not.toMatch(/<text\b|Gradient|<use\b/i);
const fills = new Set([...svg.matchAll(/fill="([^"]+)"/g)].map((m) => m[1]));
expect([...fills]).toEqual(["#000000"]);
expect((svg.match(/<path /g) ?? []).length).toBeGreaterThan(20);
});
it("bleibt innerhalb der Praegeflaeche (Rand ist in 3D Geometrie)", () => {
const svg = medalReliefSvg({ kind: "VIP" });
// Bogen-Argumente (Radien, Flags) sind keine Koordinaten und fallen weg.
const numbers = [...svg.matchAll(/d="([^"]+)"/g)].flatMap((m) =>
(m[1].replace(/A[^MLQCZA]*/g, "").match(/-?\d*\.?\d+/g) ?? []).map(Number),
);
// Grob: keine Koordinate ausserhalb des Innenrands (450 um die Mitte 512).
numbers.forEach((n) => expect(Math.abs(n - 512)).toBeLessThan(MEDAL_GEOMETRY.rimInnerRadius + 1));
});
});
describe("renderMedalPng", () => {
it("liefert standardmaessig 2048 px mit transparentem Hintergrund", async () => {
const png = await renderMedalPng({ kind: "VIP" });
const meta = await sharp(png).metadata();
expect(meta.format).toBe("png");
expect(meta.width).toBe(2048);
expect(meta.height).toBe(2048);
expect(meta.hasAlpha).toBe(true);
const corner = await sharp(png).extract({ left: 0, top: 0, width: 4, height: 4 }).raw().toBuffer();
expect(corner[3]).toBe(0);
});
it("rendert auf Wunsch kleiner", async () => {
const meta = await sharp(await renderMedalPng(patenschaft("SILBER"), 256)).metadata();
expect(meta.width).toBe(256);
});
});
describe("Mitteltext bleibt im Feld", () => {
const names = [
"Zoë",
"Ñúñez",
"Wilhelmine Augusta Maxi",
"Wilhelmine-Augusta-Maxima",
"Bienenköniginwunderbarer",
"WWWWWWWWWWWWWWWWWWWWWWWW",
"Al",
];
const tiers = ["BRONZE", "SILBER", "GOLD", "ABO"] as const;
it.each(tiers.flatMap((tier) => names.map((name) => [tier, name] as const)))("%s · %s", (tier, name) => {
const art = composeMedal(patenschaft(tier, name));
expect(art.centerLines.length).toBeGreaterThanOrEqual(1);
expect(art.centerLines.length).toBeLessThanOrEqual(2);
for (const line of art.centerLines) {
const { left, right, top, bottom } = line.box;
expect(right).toBeGreaterThan(left);
for (const [x, y] of [
[left, top],
[right, top],
[left, bottom],
[right, bottom],
]) {
expect(Math.hypot(x - 512, y - 512)).toBeLessThanOrEqual(art.centerSafeRadius + 1e-6);
}
}
});
it("bricht lange Namen mit Leerzeichen auf zwei Zeilen statt sie winzig zu setzen", () => {
const art = composeMedal(patenschaft("BRONZE", "Wilhelmine Augusta Maxi"));
expect(art.centerLines.map((l) => l.text)).toEqual(["Wilhelmine", "Augusta Maxi"]);
expect(art.centerLines[0].size).toBeGreaterThanOrEqual(48);
});
it("setzt kurze Namen gross und einzeilig", () => {
const art = composeMedal(patenschaft("SILBER", "Zoë"));
expect(art.centerLines).toHaveLength(1);
expect(art.centerLines[0].size).toBe(104);
});
it("haelt Stufennamen und Monatszahl im Feld", () => {
const inputs: MedalInput[] = [
{ kind: "STAMMKUNDE", level: 3, name: "Honigschatz der Stammkundschaft" },
{ kind: "ABO", months: 24 },
{ kind: "VIP" },
];
for (const input of inputs) {
const art = composeMedal(input);
for (const line of art.centerLines) {
const corners = [
[line.box.left, line.box.top],
[line.box.right, line.box.bottom],
[line.box.left, line.box.bottom],
[line.box.right, line.box.top],
];
corners.forEach(([x, y]) => expect(Math.hypot(x - 512, y - 512)).toBeLessThan(MEDAL_GEOMETRY.fieldRadius));
}
}
});
});
+65
View File
@@ -0,0 +1,65 @@
import { readFileSync } from "node:fs";
import { create, type Font } from "fontkit";
import { FONT_FILES, type FontKey } from "@/lib/certificate/fonts";
import { fmt, type ReliefPath } from "./paths";
/**
* Grosse Zahl in Versalziffern (OpenType "lnum"). Cormorant setzt Ziffern
* standardmaessig als Mediaevalziffern — als einzelne grosse Zahl liest sich
* die "1" dann wie ein "I". Die gemeinsame Textfunktion (certificate/fonts)
* kennt keine Features; deshalb hier ein eigener, schmaler Zugriff auf
* dieselben Schriftdateien.
*/
const cache = new Map<FontKey, Font>();
function font(key: FontKey): Font {
const cached = cache.get(key);
if (cached) return cached;
const opened = create(readFileSync(FONT_FILES[key]));
if ("fonts" in opened) throw new Error(`Schriftdatei fuer ${key} ist eine Sammlung, erwartet wird eine einzelne TTF.`);
cache.set(key, opened);
return opened;
}
export type NumeralLayout = {
path: ReliefPath;
box: { left: number; right: number; top: number; bottom: number };
};
/** Setzt `digits` mit Versalziffern, optisch mittig um (cx, centerY) der Versalhoehe. */
export function liningNumerals(key: FontKey, digits: string, size: number, cx: number, centerY: number): NumeralLayout {
const f = font(key);
const run = f.layout(digits, ["lnum", "kern"]);
const k = size / f.unitsPerEm;
let pen = 0;
const placed = run.glyphs.map((glyph, i) => {
const origin = pen + run.positions[i].xOffset;
pen += run.positions[i].xAdvance;
return { glyph, origin };
});
const left = Math.min(...placed.map((p) => p.origin + p.glyph.bbox.minX)) * k;
const right = Math.max(...placed.map((p) => p.origin + p.glyph.bbox.maxX)) * k;
const maxY = Math.max(...placed.map((p) => p.glyph.bbox.maxY));
const minY = Math.min(...placed.map((p) => p.glyph.bbox.minY));
// Mitte der Ziffernhoehe auf centerY: Versalziffern stehen auf der Grundlinie.
const baseline = centerY + ((maxY + minY) / 2) * k;
const x = cx - (left + right) / 2;
const parts: string[] = [];
for (const { glyph, origin } of placed) {
const px = (v: number) => fmt(x + (origin + v) * k);
const py = (v: number) => fmt(baseline - v * k);
for (const { command, args } of glyph.path.commands) {
if (command === "moveTo") parts.push(`M${px(args[0])} ${py(args[1])}`);
else if (command === "lineTo") parts.push(`L${px(args[0])} ${py(args[1])}`);
else if (command === "quadraticCurveTo") parts.push(`Q${px(args[0])} ${py(args[1])} ${px(args[2])} ${py(args[3])}`);
else if (command === "bezierCurveTo")
parts.push(`C${px(args[0])} ${py(args[1])} ${px(args[2])} ${py(args[3])} ${px(args[4])} ${py(args[5])}`);
else if (command === "closePath") parts.push("Z");
}
}
return {
path: { d: parts.join(""), fillRule: "nonzero" },
box: { left: x + left, right: x + right, top: baseline - maxY * k, bottom: baseline - minY * k },
};
}
+46
View File
@@ -0,0 +1,46 @@
import type { MedalInput, MedalPalette } from "./types";
/**
* Metalle der Medaillen. Ohne Datei- oder Schriftzugriff, damit Seiten und
* die 3D-Ansicht die Farben auch ohne Renderer abfragen koennen.
*
* - base: Feld (Praegeflaeche). Bei VIP ist das Feld Waldgruen-Email.
* - light: Relief (erhabene Formen) und Glanzlichter.
* - dark: Schatten und Tiefen.
* - rim: Rand und Koerper.
*/
export type MetalKey = "BRONZE" | "SILBER" | "GOLD" | "BIMETALL" | "VIP";
export const METALS: Record<MetalKey, MedalPalette> = {
BRONZE: { base: "#9c6337", light: "#d9a675", dark: "#55301a", rim: "#8c5730" },
SILBER: { base: "#a9aeb4", light: "#eef0f2", dark: "#5c6168", rim: "#979ca3" },
GOLD: { base: "#c29a48", light: "#f1d892", dark: "#6f5016", rim: "#b38c3d" },
// Zweifarbig wie eine Bimetallmuenze: Ring in Honiggold, Feld hell (Neusilber).
BIMETALL: { base: "#b7b9b8", light: "#efe1b6", dark: "#5e5a50", rim: "#c09848" },
// Edler Sonderfall: Feld in Waldgruen-Email, Relief und Rand in Honiggold.
VIP: { base: "#24352b", light: "#ecd08a", dark: "#121c16", rim: "#c49c4a" },
};
/** Stammkunden-Stufe → Metall: 1 Bronze, 2 Silber, ab 3 Gold. */
function metalForLevel(level: number): MetalKey {
if (level <= 1) return "BRONZE";
if (level === 2) return "SILBER";
return "GOLD";
}
export function metalKey(input: MedalInput): MetalKey {
switch (input.kind) {
case "PATENSCHAFT":
return input.tier === "ABO" ? "BIMETALL" : input.tier;
case "STAMMKUNDE":
return metalForLevel(input.level);
case "ABO":
return "BIMETALL";
case "VIP":
return "VIP";
}
}
export function medalPalette(input: MedalInput): MedalPalette {
return { ...METALS[metalKey(input)] };
}
+43
View File
@@ -0,0 +1,43 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { apply, chain, hexRing, polygonPath, rotate, strokePolyline, transformPath, translate } from "./paths";
import { layoutLegend } from "./text-layout";
describe("Abbildungen", () => {
it("verkettet in Lesereihenfolge", () => {
const m = chain(rotate(Math.PI / 2), translate(10, 0));
const p = apply(m, { x: 1, y: 0 });
expect(p.x).toBeCloseTo(10);
expect(p.y).toBeCloseTo(1);
});
it("transformiert alle Koordinatenpaare absoluter Pfade", () => {
expect(transformPath("M0 0L1 2Q3 4 5 6C1 1 2 2 3 3Z", translate(10, 20))).toBe("M10 20L11 22Q13 24 15 26C11 21 12 22 13 23Z");
});
it("lehnt relative Pfadbefehle ab", () => {
expect(() => transformPath("m0 0l1 1z", translate(1, 1))).toThrow();
});
});
describe("Formen", () => {
it("baut geschlossene Polygone und Konturflaechen", () => {
expect(polygonPath([{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }])).toBe("M0 0L1 0L1 1Z");
expect(strokePolyline([{ x: 0, y: 0 }, { x: 10, y: 0 }], 2)).toMatch(/^M.*Z$/);
expect(strokePolyline([{ x: 0, y: 0 }], 2)).toBe("");
expect(hexRing(0, 0, 10, 2).fillRule).toBe("evenodd");
});
});
describe("Umschrift", () => {
it("liegt oben symmetrisch um 12 Uhr und unten um 6 Uhr", () => {
const base = { cx: 512, cy: 512, radius: 400, font: "serifSemiBold" as const, size: 30, letterSpacing: 6 };
const top = layoutLegend("WABENHAIN", { ...base, position: "top" });
expect((top.startAngle + top.endAngle) / 2).toBeCloseTo(-Math.PI / 2);
expect(top.endAngle).toBeGreaterThan(top.startAngle);
const bottom = layoutLegend("WABENHAIN", { ...base, position: "bottom" });
expect((bottom.startAngle + bottom.endAngle) / 2).toBeCloseTo(Math.PI / 2);
expect(bottom.endAngle).toBeLessThan(bottom.startAngle);
expect(top.paths).toHaveLength(9);
});
});
+167
View File
@@ -0,0 +1,167 @@
/**
* Kleine Vektorwerkzeuge fuer die Medaillen-Grafik. Alles, was erhaben
* gepraegt wird, ist eine gefuellte Flaeche (keine Kontur), weil die 3D-
* Ansicht genau diese Flaechen extrudiert. Linien (Fuehler, Stiele, Adern)
* werden deshalb hier zu schmalen Flaechen aufgeweitet.
*/
export type Point = { x: number; y: number };
export type ReliefPath = { d: string; fillRule: "nonzero" | "evenodd" };
/** Affine Abbildung x' = a·x + c·y + e, y' = b·x + d·y + f (wie SVG matrix()). */
export type Affine = { a: number; b: number; c: number; d: number; e: number; f: number };
export const IDENTITY: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
export function fmt(value: number): string {
const rounded = Math.round(value * 100) / 100;
return Object.is(rounded, -0) ? "0" : String(rounded);
}
/** Erst `first`, dann `second` anwenden. */
export function compose(first: Affine, second: Affine): Affine {
return {
a: second.a * first.a + second.c * first.b,
b: second.b * first.a + second.d * first.b,
c: second.a * first.c + second.c * first.d,
d: second.b * first.c + second.d * first.d,
e: second.a * first.e + second.c * first.f + second.e,
f: second.b * first.e + second.d * first.f + second.f,
};
}
export function translate(x: number, y: number): Affine {
return { ...IDENTITY, e: x, f: y };
}
export function rotate(radians: number): Affine {
const cos = Math.cos(radians);
const sin = Math.sin(radians);
return { a: cos, b: sin, c: -sin, d: cos, e: 0, f: 0 };
}
export function scale(sx: number, sy = sx): Affine {
return { ...IDENTITY, a: sx, d: sy };
}
/** Mehrere Abbildungen in Lesereihenfolge (erste zuerst) verketten. */
export function chain(...steps: Affine[]): Affine {
return steps.reduce(compose, IDENTITY);
}
export function apply(m: Affine, p: Point): Point {
return { x: m.a * p.x + m.c * p.y + m.e, y: m.b * p.x + m.d * p.y + m.f };
}
/**
* Wendet eine Abbildung auf Pfaddaten an. Erlaubt sind nur absolute
* M/L/Q/C/Z — genau das, was textToPath() und die Helfer hier erzeugen —,
* weil jedes Argument dort ein Koordinatenpaar ist.
*/
export function transformPath(d: string, m: Affine): string {
const tokens = d.match(/[MLQCZ]|-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? [];
const out: string[] = [];
let pending: number[] = [];
const flush = () => {
for (let i = 0; i + 1 < pending.length; i += 2) {
const p = apply(m, { x: pending[i], y: pending[i + 1] });
out.push(`${fmt(p.x)} ${fmt(p.y)}`);
}
pending = [];
};
for (const token of tokens) {
if (/^[MLQCZ]$/i.test(token)) {
if (!/^[MLQCZ]$/.test(token)) throw new Error(`Nur absolute Pfadbefehle erlaubt, gefunden: ${token}`);
flush();
out.push(token);
} else {
pending.push(Number(token));
}
}
flush();
return out.join(" ").replace(/ ?([MLQCZ]) ?/g, "$1");
}
export function polygonPath(points: readonly Point[]): string {
if (points.length === 0) return "";
const [first, ...rest] = points;
return `M${fmt(first.x)} ${fmt(first.y)}${rest.map((p) => `L${fmt(p.x)} ${fmt(p.y)}`).join("")}Z`;
}
/** Kreis als zwei Boegen; `reverse` kehrt die Umlaufrichtung um (Loch bei nonzero). */
export function circlePath(cx: number, cy: number, r: number, reverse = false): string {
const sweep = reverse ? 0 : 1;
return `M${fmt(cx - r)} ${fmt(cy)}A${fmt(r)} ${fmt(r)} 0 1 ${sweep} ${fmt(cx + r)} ${fmt(cy)}A${fmt(r)} ${fmt(r)} 0 1 ${sweep} ${fmt(cx - r)} ${fmt(cy)}Z`;
}
/** Kreisring als Flaeche (Aussenkreis plus gegenlaeufiger Innenkreis). */
export function ringPath(cx: number, cy: number, radius: number, width: number): ReliefPath {
return { d: circlePath(cx, cy, radius) + circlePath(cx, cy, radius - width, true), fillRule: "evenodd" };
}
export function ellipsePoints(cx: number, cy: number, rx: number, ry: number, rotation = 0, segments = 48): Point[] {
const m = chain(rotate(rotation), translate(cx, cy));
return Array.from({ length: segments }, (_, i) => {
const t = (i / segments) * Math.PI * 2;
return apply(m, { x: Math.cos(t) * rx, y: Math.sin(t) * ry });
});
}
export function sampleCubic(p0: Point, p1: Point, p2: Point, p3: Point, segments = 24): Point[] {
return Array.from({ length: segments + 1 }, (_, i) => {
const t = i / segments;
const u = 1 - t;
return {
x: u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x,
y: u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y,
};
});
}
/**
* Linienzug als Flaeche der Breite `width` mit runden Enden. `taper` laesst
* die Linie zum Ende hin duenner werden (Fuehler, Blattadern).
*/
export function strokePolyline(points: readonly Point[], width: number, taper = 1): string {
if (points.length < 2) return "";
const half = (i: number) => (width / 2) * (1 - (1 - taper) * (i / (points.length - 1)));
const normalAt = (i: number): Point => {
const prev = points[Math.max(0, i - 1)];
const next = points[Math.min(points.length - 1, i + 1)];
const dx = next.x - prev.x;
const dy = next.y - prev.y;
const len = Math.hypot(dx, dy) || 1;
return { x: -dy / len, y: dx / len };
};
const left = points.map((p, i) => ({ x: p.x + normalAt(i).x * half(i), y: p.y + normalAt(i).y * half(i) }));
const right = points.map((p, i) => ({ x: p.x - normalAt(i).x * half(i), y: p.y - normalAt(i).y * half(i) }));
const cap = (center: Point, from: Point, radius: number, steps = 6): Point[] => {
const start = Math.atan2(from.y - center.y, from.x - center.x);
return Array.from({ length: steps - 1 }, (_, k) => {
const angle = start + (Math.PI * (k + 1)) / steps;
return { x: center.x + Math.cos(angle) * radius, y: center.y + Math.sin(angle) * radius };
});
};
const last = points.length - 1;
return polygonPath([
...left,
...cap(points[last], left[last], half(last)),
...[...right].reverse(),
...cap(points[0], right[0], half(0)),
]);
}
/** Spitz stehendes Sechseck wie im Logomark. */
export function hexagonPoints(cx: number, cy: number, radius: number): Point[] {
return Array.from({ length: 6 }, (_, i) => {
const angle = (Math.PI / 3) * i - Math.PI / 2;
return { x: cx + radius * Math.cos(angle), y: cy + radius * Math.sin(angle) };
});
}
/** Sechseckiger Ring (Wabenzelle als Kontur). */
export function hexRing(cx: number, cy: number, radius: number, width: number): ReliefPath {
const inner = hexagonPoints(cx, cy, radius - width / Math.cos(Math.PI / 6)).reverse();
return { d: polygonPath(hexagonPoints(cx, cy, radius)) + polygonPath(inner), fillRule: "evenodd" };
}
+169
View File
@@ -0,0 +1,169 @@
import sharp from "sharp";
import { MEDAL_GEOMETRY, composeMedal, type FieldDecor, type MedalArtwork } from "./compose";
import { fmt, hexagonPoints, polygonPath, type ReliefPath } from "./paths";
import type { MedalInput, MedalPalette } from "./types";
const { size: SIZE, center: C } = MEDAL_GEOMETRY;
/**
* Bildschirmdarstellung einer gepraegten Medaille. Metallglanz entsteht aus
* wenigen, ruhigen Mitteln: Verlaeufe auf Rand und Feld, ein Relief mit
* feinem Schatten unten rechts und Licht oben links, eine Kante, die das
* Licht faengt. Kein Effektfeuerwerk — es soll nach Muenze aussehen, nicht
* nach Abzeichen. (Nur digital; gedruckt wird die Medaille nicht.)
*/
/** Mischt zwei Hex-Farben (t = 0 → a, 1 → b). */
function mix(a: string, b: string, t: number): string {
const parse = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
const [ra, ga, ba] = parse(a);
const [rb, gb, bb] = parse(b);
const channel = (x: number, y: number) => Math.round(x + (y - x) * t).toString(16).padStart(2, "0");
return `#${channel(ra, rb)}${channel(ga, gb)}${channel(ba, bb)}`;
}
function reliefGroup(relief: readonly ReliefPath[]): string {
return relief.map((p) => `<path d="${p.d}" fill-rule="${p.fillRule}"/>`).join("");
}
function gradients(art: MedalArtwork): string {
const p = art.palette;
const field = p.base;
const vip = art.metal === "VIP";
return `
<linearGradient id="m-rim" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="${mix(p.rim, p.light, 0.75)}"/>
<stop offset="0.38" stop-color="${p.rim}"/>
<stop offset="0.72" stop-color="${mix(p.rim, p.dark, 0.35)}"/>
<stop offset="1" stop-color="${mix(p.rim, p.light, 0.25)}"/>
</linearGradient>
<linearGradient id="m-wall" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="${mix(p.rim, p.dark, 0.7)}"/>
<stop offset="0.5" stop-color="${p.rim}"/>
<stop offset="1" stop-color="${mix(p.rim, "#ffffff", 0.55)}"/>
</linearGradient>
<radialGradient id="m-field" cx="0.4" cy="0.34" r="0.72">
<stop offset="0" stop-color="${vip ? mix(field, "#3f5a49", 0.6) : mix(field, p.light, 0.12)}"/>
<stop offset="0.55" stop-color="${vip ? field : mix(field, p.dark, 0.24)}"/>
<stop offset="1" stop-color="${mix(field, p.dark, 0.55)}"/>
</radialGradient>
<radialGradient id="m-ring" cx="0.42" cy="0.36" r="0.7">
<stop offset="0" stop-color="${mix(p.rim, p.light, 0.5)}"/>
<stop offset="0.65" stop-color="${p.rim}"/>
<stop offset="1" stop-color="${mix(p.rim, p.dark, 0.3)}"/>
</radialGradient>
<linearGradient id="m-relief" gradientUnits="userSpaceOnUse" x1="170" y1="130" x2="854" y2="894">
<stop offset="0" stop-color="${mix(p.light, "#ffffff", 0.5)}"/>
<stop offset="0.45" stop-color="${mix(p.light, "#ffffff", 0.12)}"/>
<stop offset="0.8" stop-color="${mix(p.light, vip ? p.rim : p.base, 0.3)}"/>
<stop offset="1" stop-color="${mix(p.light, "#ffffff", 0.25)}"/>
</linearGradient>
<radialGradient id="m-sheen" cx="0.3" cy="0.24" r="0.55">
<stop offset="0" stop-color="#ffffff" stop-opacity="${vip ? 0.16 : 0.22}"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
<clipPath id="m-field-clip"><circle cx="${C}" cy="${C}" r="${art.bimetal ? MEDAL_GEOMETRY.fieldRadius : MEDAL_GEOMETRY.rimInnerRadius - 3}"/></clipPath>`;
}
/** Feine Gravur im Feld (nicht erhaben): je Stufe ein eigenes Muster. */
function decorMarkup(decor: FieldDecor, palette: MedalPalette, vip: boolean): string {
const line = vip ? mix(palette.light, palette.base, 0.25) : mix(palette.light, "#ffffff", 0.3);
const shade = vip ? "#0b130f" : palette.dark;
const r = MEDAL_GEOMETRY.fieldRadius;
switch (decor) {
case "rings": {
const rings = Array.from({ length: Math.floor(r / 7) }, (_, i) => 10 + i * 7)
.map((radius, i) => `<circle cx="${C}" cy="${C}" r="${radius}" fill="none" stroke="${i % 2 ? shade : line}" stroke-width="0.8"/>`)
.join("");
return `<g opacity="0.16">${rings}</g>`;
}
case "sunburst": {
const rays = 120;
const wedges = Array.from({ length: rays / 2 }, (_, i) => {
const a0 = ((i * 2) / rays) * Math.PI * 2;
const a1 = ((i * 2 + 1) / rays) * Math.PI * 2;
return `M${C} ${C}L${fmt(C + Math.cos(a0) * r)} ${fmt(C + Math.sin(a0) * r)}L${fmt(C + Math.cos(a1) * r)} ${fmt(C + Math.sin(a1) * r)}Z`;
}).join("");
return `<path d="${wedges}" fill="${line}" opacity="0.08"/>`;
}
case "guilloche": {
// Rosette wie auf guillochiertem Email: Wellenlinien mit versetzter Phase.
const curves = Array.from({ length: 16 }, (_, k) => {
const phase = (k / 16) * ((Math.PI * 2) / 14);
const points = Array.from({ length: 241 }, (_, i) => {
const t = (i / 240) * Math.PI * 2;
const radius = 230 + 46 * Math.sin(14 * t + phase * 14);
return `${fmt(C + Math.cos(t) * radius)} ${fmt(C + Math.sin(t) * radius)}`;
});
return `M${points.join("L")}Z`;
}).join("");
const inner = Array.from({ length: 12 }, (_, i) => `<circle cx="${C}" cy="${C}" r="${40 + i * 13}" fill="none" stroke="${line}" stroke-width="0.7"/>`).join("");
return `<g opacity="0.2"><path d="${curves}" fill="none" stroke="${line}" stroke-width="0.8"/>${inner}</g>`;
}
case "honeycomb": {
const cell = 26;
const w = Math.sqrt(3) * cell;
const cells: string[] = [];
for (let row = -9; row <= 9; row += 1) {
for (let col = -8; col <= 8; col += 1) {
const x = C + col * w + (row % 2 ? w / 2 : 0);
const y = C + row * cell * 1.5;
if (Math.hypot(x - C, y - C) < r + cell) cells.push(polygonPath(hexagonPoints(x, y, cell - 1.5)));
}
}
return `<path d="${cells.join("")}" fill="none" stroke="${line}" stroke-width="1" opacity="0.18"/>`;
}
default:
return "";
}
}
function svgDocument(art: MedalArtwork): string {
const p = art.palette;
const vip = art.metal === "VIP";
const g = MEDAL_GEOMETRY;
const shadow = vip ? "#07100b" : mix(p.dark, "#000000", 0.25);
const fieldRadius = art.bimetal ? g.fieldRadius : g.rimInnerRadius;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${SIZE} ${SIZE}" width="${SIZE}" height="${SIZE}">
<defs>${gradients(art)}<g id="m-r">${reliefGroup(art.relief)}</g></defs>
<circle cx="${C}" cy="${C}" r="${g.outerRadius}" fill="url(#m-rim)"/>
<circle cx="${C}" cy="${C}" r="${g.outerRadius - 1}" fill="none" stroke="${mix(p.rim, p.dark, 0.6)}" stroke-width="2" opacity="0.7"/>
<circle cx="${C}" cy="${C}" r="${g.rimInnerRadius}" fill="url(#${art.bimetal ? "m-ring" : "m-field"})"/>
${art.bimetal ? `<circle cx="${C}" cy="${C}" r="${fieldRadius}" fill="url(#m-field)"/>` : ""}
<g clip-path="url(#m-field-clip)">${decorMarkup(art.decor, p, vip)}</g>
<circle cx="${C}" cy="${C}" r="${g.rimInnerRadius + 3}" fill="none" stroke="url(#m-wall)" stroke-width="6"/>
${art.bimetal ? `<circle cx="${C}" cy="${C}" r="${fieldRadius + 1}" fill="none" stroke="url(#m-wall)" stroke-width="2.5"/>` : ""}
<g fill="${shadow}" opacity="${vip ? 0.75 : 0.62}" transform="translate(2.4 3)"><use href="#m-r"/></g>
<g fill="#ffffff" opacity="${vip ? 0.28 : 0.42}" transform="translate(-1.3 -1.5)"><use href="#m-r"/></g>
<g fill="url(#m-relief)" stroke="${shadow}" stroke-opacity="0.22" stroke-width="0.7"><use href="#m-r"/></g>
<circle cx="${C}" cy="${C}" r="${g.outerRadius}" fill="url(#m-sheen)"/>
</svg>`;
}
/** Medaille als SVG (viewBox 0 0 1024 1024, transparenter Hintergrund, Text als Pfade). */
export function renderMedalSvg(input: MedalInput): string {
return svgDocument(composeMedal(input));
}
/**
* Nur die erhabenen Formen (Perlkreis, Umschrift, Emblem, Mitteltext) in
* einer Fuellung — Vorlage fuer das extrudierte Relief der 3D-Ansicht.
* Der Rand selbst ist dort Geometrie und fehlt hier bewusst.
*/
export function medalReliefSvg(input: MedalInput): string {
const art = composeMedal(input);
const paths = art.relief.map((p) => `<path d="${p.d}" fill="#000000" fill-rule="${p.fillRule}"/>`).join("");
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${SIZE} ${SIZE}" width="${SIZE}" height="${SIZE}">${paths}</svg>`;
}
/** PNG mit transparentem Hintergrund, standardmaessig 2048 px. */
export async function renderMedalPng(input: MedalInput, sizePx = 2048): Promise<Buffer> {
const sizeInt = Math.max(16, Math.round(sizePx));
// Dichte so waehlen, dass das SVG direkt in Zielgroesse gerastert wird
// (nicht klein gerastert und hochskaliert — sonst werden Kanten weich).
const density = (72 * sizeInt) / SIZE;
return sharp(Buffer.from(renderMedalSvg(input)), { density })
.resize(sizeInt, sizeInt)
.png({ compressionLevel: 9 })
.toBuffer();
}
+248
View File
@@ -0,0 +1,248 @@
import { fontMetrics, measureText, shapeText, textToPath, type FontKey } from "@/lib/certificate/fonts";
import { chain, hexagonPoints, polygonPath, rotate, transformPath, translate, type ReliefPath } from "./paths";
/**
* Text der Medaille, ausschliesslich als Glyphenpfade (kein <text>), damit
* PNG, SVG und 3D-Relief ohne Browserschriften identisch aussehen.
*/
export const SEPARATOR = "·";
export type LegendOptions = {
cx: number;
cy: number;
/** Grundlinienradius. Oben stehen die Buchstaben nach aussen, unten nach innen. */
radius: number;
position: "top" | "bottom";
font: FontKey;
size: number;
letterSpacing: number;
};
export type LegendLayout = { paths: ReliefPath[]; startAngle: number; endAngle: number; arcLength: number };
type LegendItem = { kind: "glyph"; char: string; advance: number } | { kind: "separator"; advance: number };
function legendItems(text: string, options: LegendOptions): LegendItem[] {
return [...text].map((char) =>
char === SEPARATOR
? { kind: "separator", advance: options.size * 0.95 }
: { kind: "glyph", char, advance: measureText(options.font, char, options.size) },
);
}
/**
* Umschrift auf dem Kreis, Glyphe fuer Glyphe: jede Glyphe wird an ihrer
* Mitte auf den Bogen gesetzt und senkrecht zum Radius gedreht. Oben laeuft
* der Text im Uhrzeigersinn (Fuesse innen), unten gegen den Uhrzeigersinn
* (Fuesse aussen) — so ist beides aufrecht lesbar, wie auf Muenzen ueblich.
* Trennpunkte werden als kleine Waben gesetzt.
*/
export function layoutLegend(text: string, options: LegendOptions): LegendLayout {
const items = legendItems(text, options);
const spacing = options.letterSpacing;
const arcLength = items.reduce((sum, item) => sum + item.advance, 0) + spacing * Math.max(0, items.length - 1);
const span = arcLength / options.radius;
const top = options.position === "top";
const center = top ? -Math.PI / 2 : Math.PI / 2;
const startAngle = top ? center - span / 2 : center + span / 2;
const direction = top ? 1 : -1;
const capHeight = fontMetrics(options.font, options.size).capHeight;
const paths: ReliefPath[] = [];
let offset = 0;
for (const item of items) {
const angle = startAngle + (direction * (offset + item.advance / 2)) / options.radius;
const rotation = top ? angle + Math.PI / 2 : angle - Math.PI / 2;
const placement = chain(
rotate(rotation),
translate(options.cx + Math.cos(angle) * options.radius, options.cy + Math.sin(angle) * options.radius),
);
if (item.kind === "glyph") {
const d = textToPath(options.font, item.char, options.size, -item.advance / 2, 0);
if (d) paths.push({ d: transformPath(d, placement), fillRule: "nonzero" });
} else {
const hex = polygonPath(hexagonPoints(0, -capHeight / 2, options.size * 0.13));
paths.push({ d: transformPath(hex, placement), fillRule: "nonzero" });
}
offset += item.advance + spacing;
}
const endAngle = startAngle + (direction * arcLength) / options.radius;
return { paths, startAngle, endAngle, arcLength };
}
/** Tintengrenzen einer gesetzten Zeile relativ zu Zeilenbeginn und Grundlinie (y nach unten). */
export type InkBox = { left: number; right: number; top: number; bottom: number; advance: number };
export function measureInk(font: FontKey, text: string, size: number, letterSpacing = 0): InkBox {
const glyphs = shapeText(font, text);
// Font-Einheiten → Punkt: aus der Laufweite abgeleitet, damit dieselbe
// Skalierung gilt wie in measureText/textToPath.
const totalAdvance = glyphs.reduce((sum, entry) => sum + entry.xAdvance, 0);
const unitsPerEm = totalAdvance > 0 ? measureText(font, text, size) / totalAdvance : 0;
let left = Infinity;
let right = -Infinity;
let top = Infinity;
let bottom = -Infinity;
glyphs.forEach((entry, index) => {
const bbox = entry.glyph.bbox;
if (!Number.isFinite(bbox.minX) || bbox.maxX <= bbox.minX) return;
const originX = (entry.penX + entry.xOffset) * unitsPerEm + letterSpacing * index;
left = Math.min(left, originX + bbox.minX * unitsPerEm);
right = Math.max(right, originX + bbox.maxX * unitsPerEm);
top = Math.min(top, -(bbox.maxY + entry.yOffset) * unitsPerEm);
bottom = Math.max(bottom, -(bbox.minY + entry.yOffset) * unitsPerEm);
});
const advance = measureText(font, text, size, { letterSpacing });
if (!Number.isFinite(left)) return { left: 0, right: advance, top: 0, bottom: 0, advance };
return { left, right, top, bottom, advance };
}
export type PlacedLine = {
text: string;
size: number;
/** Zeilenbeginn (x) und Grundlinie (y) im Medaillenkoordinatensystem. */
x: number;
baseline: number;
/** Tintenrechteck der Zeile, absolut. */
box: { left: number; right: number; top: number; bottom: number };
};
export type CenterTextOptions = {
cx: number;
cy: number;
font: FontKey;
letterSpacing?: number;
/** Mitte des Textblocks (auf Versalhoehe bezogen). */
centerY: number;
/** Oberste und unterste erlaubte Tintenkante. */
minTop: number;
maxBottom: number;
/** Alle Ecken muessen in diesem Kreis um (cx, cy) liegen. */
safeRadius: number;
singleLineSizes: readonly number[];
twoLineSizes: readonly number[];
/** Zeilenabstand als Vielfaches der Schriftgroesse. */
lineGap?: number;
};
export type CenterTextLayout = { lines: PlacedLine[]; paths: ReliefPath[] };
function placeLine(text: string, size: number, baseline: number, options: CenterTextOptions): PlacedLine {
const ink = measureInk(options.font, text, size, options.letterSpacing ?? 0);
// Optisch mittig nach der Tinte, nicht nach der Laufweite: kursive
// Ueberhaenge und Sperrung am Zeilenende verschieben sonst die Mitte.
const x = options.cx - (ink.left + ink.right) / 2;
return {
text,
size,
x,
baseline,
box: { left: x + ink.left, right: x + ink.right, top: baseline + ink.top, bottom: baseline + ink.bottom },
};
}
export function lineFits(line: PlacedLine, options: CenterTextOptions): boolean {
const { box } = line;
if (box.top < options.minTop || box.bottom > options.maxBottom) return false;
const corners = [
[box.left, box.top],
[box.right, box.top],
[box.left, box.bottom],
[box.right, box.bottom],
];
return corners.every(([x, y]) => Math.hypot(x - options.cx, y - options.cy) <= options.safeRadius);
}
function singleLine(text: string, size: number, options: CenterTextOptions): PlacedLine[] {
const capHeight = fontMetrics(options.font, size).capHeight;
return [placeLine(text, size, options.centerY + capHeight / 2, options)];
}
/** Moegliche Umbrueche: nach Leerzeichen (entfaellt) oder nach Bindestrich (bleibt stehen). */
function splitCandidates(text: string): Array<[string, string]> {
const candidates: Array<[string, string]> = [];
[...text].forEach((char, index) => {
if (char === " ") candidates.push([text.slice(0, index).trimEnd(), text.slice(index + 1).trimStart()]);
if (char === "-" && index > 0 && index < text.length - 1) candidates.push([text.slice(0, index + 1), text.slice(index + 1)]);
});
return candidates.filter(([a, b]) => a.length > 0 && b.length > 0);
}
function twoLines(text: string, size: number, options: CenterTextOptions): PlacedLine[] | null {
const candidates = splitCandidates(text);
if (candidates.length === 0) return null;
const font = options.font;
const spacing = options.letterSpacing ?? 0;
// Ausgewogenster Umbruch: die laengere der beiden Zeilen so kurz wie moeglich.
const [first, second] = candidates.reduce((best, candidate) => {
const width = (pair: [string, string]) =>
Math.max(measureText(font, pair[0], size, { letterSpacing: spacing }), measureText(font, pair[1], size, { letterSpacing: spacing }));
return width(candidate) < width(best) ? candidate : best;
});
const gap = size * (options.lineGap ?? 1.06);
const capHeight = fontMetrics(font, size).capHeight;
const firstBaseline = options.centerY - gap / 2 + capHeight / 2;
return [placeLine(first, size, firstBaseline, options), placeLine(second, size, firstBaseline + gap, options)];
}
/** Kleinste Notstufe: verkleinert in 2er-Schritten, bis eine Zeile passt. */
const EMERGENCY_MIN_SIZE = 18;
/**
* Setzt einen Namen in das Mittelfeld: erst eine Zeile in festen Stufen,
* dann zwei Zeilen (Umbruch nur an Leerzeichen/Bindestrich), zuletzt eine
* immer kleinere Zeile. Die Einzeilen-Stufen enden bewusst frueh: zwei
* gut lesbare Zeilen sind schoener als eine winzige. Die Tinte verlaesst
* das Feld nie.
*/
export function layoutCenterText(text: string, options: CenterTextOptions): CenterTextLayout {
const fits = (lines: PlacedLine[] | null): lines is PlacedLine[] => !!lines && lines.every((line) => lineFits(line, options));
let lines: PlacedLine[] | null = null;
for (const size of options.singleLineSizes) {
const candidate = singleLine(text, size, options);
if (fits(candidate)) {
lines = candidate;
break;
}
}
if (!lines) {
for (const size of options.twoLineSizes) {
const candidate = twoLines(text, size, options);
if (fits(candidate)) {
lines = candidate;
break;
}
}
}
if (!lines) {
// Weiter in Zweierschritten unterhalb der kleinsten Einzeilen-Stufe —
// fuer Namen ohne Umbruchstelle, die einzeilig bleiben muessen.
const start = Math.min(...options.singleLineSizes) - 2;
for (let size = start; size >= EMERGENCY_MIN_SIZE; size -= 2) {
lines = singleLine(text, size, options);
if (fits(lines)) break;
}
}
const placed = lines ?? singleLine(text, EMERGENCY_MIN_SIZE, options);
const spacing = options.letterSpacing ?? 0;
const paths = placed
.map((line) => textToPath(options.font, line.text, line.size, line.x, line.baseline, { letterSpacing: spacing }))
.filter((d) => d.length > 0)
.map((d): ReliefPath => ({ d, fillRule: "nonzero" }));
return { lines: placed, paths };
}
/** Einzelne, fest gesetzte Zeile (z.B. „MONATE“), horizontal mittig. */
export function centeredLine(text: string, font: FontKey, size: number, cx: number, baseline: number, letterSpacing = 0): { line: PlacedLine; path: ReliefPath } {
const ink = measureInk(font, text, size, letterSpacing);
const x = cx - (ink.left + ink.right) / 2;
const line: PlacedLine = {
text,
size,
x,
baseline,
box: { left: x + ink.left, right: x + ink.right, top: baseline + ink.top, bottom: baseline + ink.bottom },
};
return { line, path: { d: textToPath(font, text, size, x, baseline, { letterSpacing }), fillRule: "nonzero" } };
}
+11
View File
@@ -0,0 +1,11 @@
/** Stufe einer Patenschafts-Medaille (am Angebot im Admin eingestellt). */
export type PatenschaftTier = "BRONZE" | "SILBER" | "GOLD" | "ABO";
export type MedalInput =
| { kind: "PATENSCHAFT"; tier: PatenschaftTier; beeName: string; year: number; number: string }
| { kind: "STAMMKUNDE"; level: number; name: string }
| { kind: "ABO"; months: number }
| { kind: "VIP" };
/** Metallfarben fuer die 3D-Ansicht (Material) und die Bildausgabe. */
export type MedalPalette = { base: string; light: string; dark: string; rim: string };
+33
View File
@@ -0,0 +1,33 @@
import { describe, test, expect } from "vitest";
import { safeNextPath, loginPathWithNext } from "./safe-next-path";
describe("safeNextPath", () => {
test("accepts paths of this site", () => {
expect(safeNextPath("/patenschaft")).toBe("/patenschaft");
expect(safeNextPath("/checkout?step=1")).toBe("/checkout?step=1");
});
test.each([
["//evil.example"],
["/\\evil.example"],
["https://evil.example"],
["patenschaft"],
["/ /evil.example"],
["/\tevil"],
["/a\\b"],
[""],
])("rejects %s", (value) => {
expect(safeNextPath(value)).toBeNull();
});
test("rejects non-strings and overlong values", () => {
expect(safeNextPath(null)).toBeNull();
expect(safeNextPath(["/patenschaft"])).toBeNull();
expect(safeNextPath(`/${"a".repeat(300)}`)).toBeNull();
});
test("builds the login path with an encoded next", () => {
expect(loginPathWithNext("/patenschaft")).toBe("/konto?next=%2Fpatenschaft");
expect(loginPathWithNext(null)).toBe("/konto");
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* Ruecksprungziel nach der Anmeldung (/konto?next=/patenschaft).
*
* Nur Pfade dieser Seite: "//host" und "/\host" werten Browser als Adresse
* auf einem fremden Host — ein ungeprueftes next waere eine offene
* Weiterleitung, mit der ein Link auf wabenhain.com nach dem Login auf eine
* nachgebaute Seite fuehrt (dieselbe Regel wie redirectWithinSite in
* lib/redirect.ts). Steuerzeichen und Leerraum fallen ebenfalls durch, weil
* Browser sie beim Aufloesen teils entfernen und so aus "/ /host" wieder
* "//host" machen.
*/
const MAX_NEXT_LENGTH = 200;
export function safeNextPath(value: unknown): string | null {
if (typeof value !== "string") return null;
if (value.length === 0 || value.length > MAX_NEXT_LENGTH) return null;
if (!value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\")) return null;
if (/[\s\\\p{Cc}]/u.test(value)) return null;
return value;
}
/** Login-Seite mit erhaltenem Ruecksprungziel, z.B. fuer Fehlermeldungen. */
export function loginPathWithNext(next: string | null): string {
return next ? `/konto?next=${encodeURIComponent(next)}` : "/konto";
}
@@ -0,0 +1,81 @@
import { prisma } from "@/lib/prisma";
import type { SponsorshipCycle, SubscriptionStatus, DeliveryStatus } from "@/generated/prisma/client";
import { deliveryProgress } from "@/lib/sponsorship-subscription/entitlement";
import { contractReference } from "@/lib/sponsorship-subscription/cancellation";
/**
* Abo eines Kontos fuer die Kontoseite und den Kuendigungsknopf. Gefiltert
* nach customerId: ein fremdes Abo ist hier schlicht nicht vorhanden (404).
*/
export interface OwnedSubscriptionView {
id: string;
status: SubscriptionStatus;
offerName: string;
cycle: SponsorshipCycle | null;
/** Zuletzt eingezogener Monatsbetrag, sonst der Preis des Angebots. */
monthlyCents: number;
contractReference: string;
beeName: string | null;
sponsorshipId: string | null;
currentPeriodEnd: Date | null;
cancelAt: Date | null;
endedAt: Date | null;
paidMonths: number;
progress: { paid: number; required: number } | null;
/** Bezahlte Monate, die wegen der SEPA-Neukunden-Frist noch nicht zaehlen. */
heldPayments: number;
deliveries: { id: string; dueAt: Date; jars: number; status: DeliveryStatus }[];
stripeCustomerId: string | null;
customerEmail: string;
}
function beeNameOf(personalization: unknown): string | null {
const inner = (personalization as { personalization?: { beeName?: unknown } } | null)?.personalization;
return typeof inner?.beeName === "string" ? inner.beeName : null;
}
export async function getOwnedSubscription(customerId: string, id: string, now: Date = new Date()): Promise<OwnedSubscriptionView | null> {
const subscription = await prisma.sponsorshipSubscription.findFirst({
where: { id, customerId },
include: {
offer: { select: { name: true, cycle: true, priceCents: true, honeyJars: true } },
sponsorship: { select: { id: true, number: true } },
customer: { select: { email: true, stripeCustomerId: true } },
payments: {
orderBy: { paidAt: "asc" },
select: { id: true, paidAt: true, countsFrom: true, reversedAt: true, deliveryId: true, amountCents: true },
},
deliveries: { orderBy: { dueAt: "desc" }, select: { id: true, dueAt: true, jars: true, status: true } },
},
});
if (!subscription) return null;
const valid = subscription.payments.filter((p) => p.reversedAt === null);
const last = valid[valid.length - 1];
const hasHoney = subscription.offer.honeyJars > 0 && subscription.offer.cycle !== null;
return {
id: subscription.id,
status: subscription.status,
offerName: subscription.offer.name,
cycle: subscription.offer.cycle,
monthlyCents: last?.amountCents ?? subscription.offer.priceCents,
contractReference: contractReference(subscription),
beeName: beeNameOf(subscription.personalization),
sponsorshipId: subscription.sponsorship?.id ?? null,
currentPeriodEnd: subscription.currentPeriodEnd,
cancelAt: subscription.cancelAt,
endedAt: subscription.endedAt,
paidMonths: valid.length,
progress: hasHoney && subscription.offer.cycle ? deliveryProgress(subscription.offer.cycle, subscription.payments) : null,
heldPayments: valid.filter((p) => p.deliveryId === null && p.countsFrom > now).length,
deliveries: subscription.deliveries,
stripeCustomerId: subscription.customer.stripeCustomerId,
customerEmail: subscription.customer.email,
};
}
/** Kuendigung moeglich? Nur laufende Vertraege; ein gekuendigtes Abo zeigt stattdessen sein Ende. */
export function canCancel(view: Pick<OwnedSubscriptionView, "status" | "cancelAt">): boolean {
return (view.status === "AKTIV" || view.status === "IM_VERZUG") && view.cancelAt === null;
}
@@ -0,0 +1,43 @@
import { prisma } from "@/lib/prisma";
/**
* Eintraege fuer die Aufmerksamkeitsliste im Admin (Spec "Lieferauftraege",
* "SEPA-Lastschrift und Rueckbuchungen"): zurueckgestellte Honiglieferungen
* (keine Adresse, kein Bestand) und Abos im Verzug nach einer Rueckbuchung.
* Die Liste selbst baut der Admin-Bereich; hier entstehen nur die Texte.
*/
export interface SubscriptionAttentionItem {
kind: "delivery_deferred" | "dispute";
subscriptionId: string;
text: string;
since: Date;
}
export async function listSubscriptionAttention(): Promise<SubscriptionAttentionItem[]> {
const [deferred, disputed] = await Promise.all([
prisma.subscriptionDelivery.findMany({
where: { status: "ZURUECKGESTELLT", orderId: null, subscription: { status: { in: ["AKTIV", "GEKUENDIGT"] } } },
select: { subscriptionId: true, jars: true, note: true, dueAt: true, subscription: { select: { customer: { select: { email: true } } } } },
orderBy: { dueAt: "asc" },
}),
prisma.sponsorshipSubscription.findMany({
where: { status: "IM_VERZUG", payments: { some: { reversedAt: { not: null } } } },
select: { id: true, delinquentSince: true, updatedAt: true, customer: { select: { email: true } } },
orderBy: { delinquentSince: "asc" },
}),
]);
return [
...deferred.map((delivery) => ({
kind: "delivery_deferred" as const,
subscriptionId: delivery.subscriptionId,
text: `Abo-Honiglieferung (${delivery.jars} Gläser) für ${delivery.subscription.customer.email}: ${delivery.note ?? "zurückgestellt"}`,
since: delivery.dueAt,
})),
...disputed.map((subscription) => ({
kind: "dispute" as const,
subscriptionId: subscription.id,
text: `Abo von ${subscription.customer.email} nach Rückbuchung im Verzug — endet ohne Ausgleich 14 Tage nach Beginn.`,
since: subscription.delinquentSince ?? subscription.updatedAt,
})),
];
}
@@ -0,0 +1,176 @@
import { describe, expect, test } from "vitest";
import {
deriveSubscriptionStatus,
hasOpenDispute,
isDisputeDelinquencyExpired,
lastPaidDay,
monthlyOrderLines,
type RulePayment,
} from "./billing-rules";
const DAY = 24 * 60 * 60 * 1000;
const NOW = new Date("2026-10-16T12:00:00.000Z");
function paid(daysAgo: number, overrides: Partial<RulePayment> = {}): RulePayment {
const paidAt = new Date(NOW.getTime() - daysAgo * DAY);
return { paidAt, periodEnd: new Date(paidAt.getTime() + 30 * DAY), reversedAt: null, ...overrides };
}
describe("monthlyOrderLines", () => {
const base = {
offerName: "Vierteljährlich",
honeyShareCents: 600,
periodStart: new Date("2026-10-16T10:00:00.000Z"),
periodEnd: new Date("2026-11-16T10:00:00.000Z"),
locale: "de" as const,
regularVatPercent: 19,
honeyVatPercent: 7,
};
test("teilt den Monatsbetrag in Patenschaft (Regelsatz) und Honiganteil (Honigsatz)", () => {
const lines = monthlyOrderLines({ ...base, amountCents: 1500 });
expect(lines).toEqual([
{
role: "ABO",
productName: "Bienenpatenschaft-Abo Vierteljährlich (16.10.2026 – 15.11.2026)",
totalCents: 900,
vatRatePercent: 19,
},
{ role: "ABO_HONEY", productName: "Honiganteil (Vorauszahlung)", totalCents: 600, vatRatePercent: 7 },
]);
});
test("ohne Honiganteil entsteht nur die Patenschaftszeile", () => {
const lines = monthlyOrderLines({ ...base, honeyShareCents: 0, amountCents: 1500 });
expect(lines.map((l) => l.role)).toEqual(["ABO"]);
expect(lines[0].totalCents).toBe(1500);
});
test("ein kleinerer Rechnungsbetrag als der Honiganteil wird nie negativ", () => {
const lines = monthlyOrderLines({ ...base, amountCents: 400 });
expect(lines).toEqual([expect.objectContaining({ role: "ABO_HONEY", totalCents: 400 })]);
});
test("Summe der Zeilen ist immer der eingezogene Betrag", () => {
for (const amountCents of [0, 1, 599, 600, 601, 2500]) {
const sum = monthlyOrderLines({ ...base, amountCents }).reduce((s, l) => s + l.totalCents, 0);
expect(sum).toBe(amountCents);
}
});
test("englische Bezeichnungen", () => {
const lines = monthlyOrderLines({ ...base, locale: "en", amountCents: 1500 });
expect(lines[0].productName).toBe("Bee sponsorship subscription Vierteljährlich (16 Oct 2026 – 15 Nov 2026)");
expect(lines[1].productName).toBe("Honey share (prepayment)");
});
});
describe("hasOpenDispute", () => {
test("eine zurueckgebuchte Zahlung ohne spaetere Zahlung ist offen", () => {
expect(hasOpenDispute([paid(40), paid(10, { reversedAt: new Date(NOW.getTime() - 2 * DAY) })])).toBe(true);
});
test("eine danach bezahlte Rechnung schliesst den Verzug", () => {
expect(hasOpenDispute([paid(40, { reversedAt: new Date(NOW.getTime() - 20 * DAY) }), paid(5)])).toBe(false);
});
test("ohne Rueckbuchung kein offener Verzug", () => {
expect(hasOpenDispute([paid(40), paid(10)])).toBe(false);
});
});
describe("deriveSubscriptionStatus", () => {
const base = { current: "AKTIV" as const, cancelScheduled: false, hasOpenDispute: false };
test.each([
["active", "AKTIV"],
["trialing", "AKTIV"],
["past_due", "IM_VERZUG"],
["unpaid", "IM_VERZUG"],
["canceled", "END"],
["incomplete_expired", "END"],
] as const)("Stripe-Status %s ergibt %s", (stripeStatus, expected) => {
expect(deriveSubscriptionStatus({ ...base, stripeStatus })).toBe(expected);
});
test("vorgemerkte Kuendigung ergibt GEKUENDIGT", () => {
expect(deriveSubscriptionStatus({ ...base, stripeStatus: "active", cancelScheduled: true })).toBe("GEKUENDIGT");
});
test("eine offene Rueckbuchung haelt das Abo im Verzug, auch wenn Stripe es aktiv fuehrt", () => {
expect(deriveSubscriptionStatus({ ...base, stripeStatus: "active", hasOpenDispute: true })).toBe("IM_VERZUG");
});
test("ein beendetes Abo lebt nie wieder auf", () => {
expect(deriveSubscriptionStatus({ ...base, current: "BEENDET", stripeStatus: "active" })).toBe("BEENDET");
});
test("incomplete laesst den bisherigen Status stehen", () => {
expect(deriveSubscriptionStatus({ ...base, current: "AUSSTEHEND", stripeStatus: "incomplete" })).toBe("AUSSTEHEND");
});
});
describe("lastPaidDay", () => {
const start = new Date("2026-07-16T00:00:00.000Z");
test("ist der Tag vor dem Ende des letzten nicht zurueckgebuchten Zeitraums", () => {
const payments = [
{ paidAt: start, periodEnd: new Date("2026-08-16T10:00:00.000Z"), reversedAt: null },
{ paidAt: start, periodEnd: new Date("2026-09-16T10:00:00.000Z"), reversedAt: null },
{ paidAt: start, periodEnd: new Date("2026-10-16T10:00:00.000Z"), reversedAt: NOW },
];
expect(lastPaidDay(payments, start)).toEqual(new Date("2026-09-15T00:00:00.000Z"));
});
test("ohne gueltige Zahlung ist es der Starttag", () => {
expect(lastPaidDay([], start)).toEqual(start);
});
});
describe("isDisputeDelinquencyExpired", () => {
const reversal = paid(40, { reversedAt: new Date(NOW.getTime() - 15 * DAY) });
test("nach 14 Tagen Verzug wegen Rueckbuchung ohne neue Zahlung: abgelaufen", () => {
expect(
isDisputeDelinquencyExpired({
status: "IM_VERZUG",
delinquentSince: new Date(NOW.getTime() - 15 * DAY),
payments: [reversal],
now: NOW,
})
).toBe(true);
});
test("vor Ablauf der 14 Tage nicht", () => {
expect(
isDisputeDelinquencyExpired({
status: "IM_VERZUG",
delinquentSince: new Date(NOW.getTime() - 13 * DAY),
payments: [reversal],
now: NOW,
})
).toBe(false);
});
test("Verzug ohne Rueckbuchung (fehlgeschlagene Zahlung) beendet Stripe selbst, nicht dieser Lauf", () => {
expect(
isDisputeDelinquencyExpired({
status: "IM_VERZUG",
delinquentSince: new Date(NOW.getTime() - 30 * DAY),
payments: [paid(40)],
now: NOW,
})
).toBe(false);
});
test("eine danach bezahlte Rechnung verhindert das Ende", () => {
expect(
isDisputeDelinquencyExpired({
status: "IM_VERZUG",
delinquentSince: new Date(NOW.getTime() - 15 * DAY),
payments: [reversal, paid(2)],
now: NOW,
})
).toBe(false);
});
});
@@ -0,0 +1,163 @@
import type { SubscriptionStatus } from "@/generated/prisma/client";
import type { Locale } from "@/i18n/config";
/**
* Reine Regeln der Abo-Abrechnung — ohne Datenbank und ohne Stripe, damit sie
* vollstaendig testbar sind. Die Handler (webhook-handlers.ts, ending.ts,
* jobs.ts) laden die Daten und wenden diese Regeln an.
*/
const DAY_MS = 24 * 60 * 60 * 1000;
/** Spec "SEPA-Lastschrift und Rueckbuchungen", Punkt 4. */
export const DISPUTE_GRACE_DAYS = 14;
const TIME_ZONE = "Europe/Berlin";
export interface RulePayment {
paidAt: Date;
periodEnd: Date;
reversedAt: Date | null;
}
// ---------------------------------------------------------------------------
// Monatsbestellung
// ---------------------------------------------------------------------------
export interface MonthlyOrderLine {
/** "ABO" (Patenschaft, Regelsatz) oder "ABO_HONEY" (Honiganteil, Honigsatz). */
role: "ABO" | "ABO_HONEY";
productName: string;
totalCents: number;
vatRatePercent: number;
}
const LINE_NAMES = {
de: {
main: (offer: string, period: string) => `Bienenpatenschaft-Abo ${offer} (${period})`,
honey: "Honiganteil (Vorauszahlung)",
},
en: {
main: (offer: string, period: string) => `Bee sponsorship subscription ${offer} (${period})`,
honey: "Honey share (prepayment)",
},
} as const;
function formatDay(date: Date, locale: Locale): string {
return locale === "de"
? new Intl.DateTimeFormat("de-DE", { timeZone: TIME_ZONE, day: "2-digit", month: "2-digit", year: "numeric" }).format(date)
: new Intl.DateTimeFormat("en-GB", { timeZone: TIME_ZONE, day: "numeric", month: "short", year: "numeric" }).format(date);
}
/**
* Leistungszeitraum fuer die Rechnung (§ 14 Abs. 4 Nr. 6 UStG): vom ersten
* bis zum letzten Tag des bezahlten Monats. Stripes periodEnd ist der Beginn
* des naechsten Zeitraums, der letzte Tag liegt also einen Tag davor.
*/
export function formatServicePeriod(periodStart: Date, periodEnd: Date, locale: Locale): string {
return `${formatDay(periodStart, locale)} – ${formatDay(new Date(periodEnd.getTime() - DAY_MS), locale)}`;
}
/**
* Positionen der Wabenhain-Bestellung zu einer bezahlten Monatsrechnung
* (Spec "Steuer"): der Honiganteil zum Honig-Satz, der Rest zum Regelsatz.
* Grundlage ist der tatsaechlich eingezogene Betrag, nicht der heutige
* Angebotspreis — laufende Abos behalten ihren alten Preis. Die Summe der
* Zeilen ist deshalb immer genau der eingezogene Betrag.
*/
export function monthlyOrderLines(input: {
offerName: string;
amountCents: number;
honeyShareCents: number;
periodStart: Date;
periodEnd: Date;
locale: Locale;
regularVatPercent: number;
honeyVatPercent: number;
}): MonthlyOrderLine[] {
const names = LINE_NAMES[input.locale];
const honeyCents = Math.max(0, Math.min(input.honeyShareCents, input.amountCents));
const mainCents = input.amountCents - honeyCents;
const main: MonthlyOrderLine = {
role: "ABO",
productName: names.main(input.offerName, formatServicePeriod(input.periodStart, input.periodEnd, input.locale)),
totalCents: mainCents,
vatRatePercent: input.regularVatPercent,
};
const honey: MonthlyOrderLine = {
role: "ABO_HONEY",
productName: names.honey,
totalCents: honeyCents,
vatRatePercent: input.honeyVatPercent,
};
if (honeyCents === 0) return [main];
if (mainCents === 0) return [honey];
return [main, honey];
}
// ---------------------------------------------------------------------------
// Status
// ---------------------------------------------------------------------------
/**
* Offene Rueckbuchung: eine zurueckgebuchte Zahlung, nach der keine neue
* Zahlung eingegangen ist. Stripe fuehrt das Abo dabei weiter als "active" —
* der Verzug existiert nur hier.
*/
export function hasOpenDispute(payments: readonly RulePayment[]): boolean {
return payments.some(
(reversed) =>
reversed.reversedAt !== null &&
!payments.some((p) => p.reversedAt === null && p.paidAt > (reversed.reversedAt as Date))
);
}
/** "END" heisst: das Abo ist bei Stripe beendet und muss hier beendet werden (ending.ts). */
export function deriveSubscriptionStatus(input: {
current: SubscriptionStatus;
stripeStatus: string;
cancelScheduled: boolean;
hasOpenDispute: boolean;
}): SubscriptionStatus | "END" {
if (input.current === "BEENDET") return "BEENDET";
if (input.stripeStatus === "canceled" || input.stripeStatus === "incomplete_expired") return "END";
if (input.stripeStatus === "incomplete") return input.current;
if (input.stripeStatus === "past_due" || input.stripeStatus === "unpaid" || input.hasOpenDispute) return "IM_VERZUG";
if (input.cancelScheduled) return "GEKUENDIGT";
return "AKTIV";
}
/**
* Laeuft der Verzug nach einer Rueckbuchung seit mehr als 14 Tagen ohne neue
* Zahlung, endet das Abo (Spec Punkt 4). Verzug wegen einer fehlgeschlagenen
* Zahlung beendet Stripe nach dem letzten Versuch selbst
* (customer.subscription.deleted) — hier nicht.
*/
export function isDisputeDelinquencyExpired(input: {
status: SubscriptionStatus;
delinquentSince: Date | null;
payments: readonly RulePayment[];
now: Date;
}): boolean {
if (input.status !== "IM_VERZUG" || !input.delinquentSince) return false;
if (!hasOpenDispute(input.payments)) return false;
return input.now.getTime() - input.delinquentSince.getTime() >= DISPUTE_GRACE_DAYS * DAY_MS;
}
// ---------------------------------------------------------------------------
// Ende
// ---------------------------------------------------------------------------
function utcDay(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}
/**
* Letzter bezahlter Tag eines beendeten Abos fuer die Urkunde "vom … bis …":
* der Tag vor dem Ende des letzten nicht zurueckgebuchten Zeitraums,
* frühestens der Starttag.
*/
export function lastPaidDay(payments: readonly RulePayment[], startDate: Date): Date {
const ends = payments.filter((p) => p.reversedAt === null).map((p) => p.periodEnd.getTime());
if (ends.length === 0) return utcDay(startDate);
const day = utcDay(new Date(Math.max(...ends) - DAY_MS));
return day < utcDay(startDate) ? utcDay(startDate) : day;
}
@@ -0,0 +1,111 @@
import { prisma } from "@/lib/prisma";
import { getStripeClient } from "@/lib/stripe";
import { sendMail } from "@/lib/mail";
import { getBaseUrl } from "@/lib/url";
import { isLocale, defaultLocale } from "@/i18n/config";
import { applyStripeSubscription, currentPeriodEndOf } from "@/lib/sponsorship-subscription/stripe-sync";
import { subscriptionCancelledEmail } from "@/lib/sponsorship-subscription/emails";
/**
* Kuendigung eines Abos (§ 312k BGB, Spec "Kuendigung"): wirksam zum Ende des
* bezahlten Monats (cancel_at_period_end). Gemeinsamer Weg fuer den
* Kuendigungsknopf im Konto und die oeffentliche Seite /vertraege-kuendigen.
*
* Das Ende selbst (Status BEENDET, Urkunde "vom … bis …") kommt spaeter mit
* customer.subscription.deleted von Stripe (ending.ts).
*/
/** Abos, die sich kuendigen lassen. AUSSTEHEND hat noch keinen Vertrag, BEENDET keinen mehr. */
const CANCELLABLE = ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] as const;
export type CancellationResult =
| { ok: true; effectiveEnd: Date; alreadyCancelled: boolean }
| { ok: false; reason: "not_found" | "not_cancellable" };
/** Lesbare Vertragsnummer: die Patenschaftsnummer, solange es noch keine gibt die Abo-ID. */
export function contractReference(subscription: { id: string; sponsorship: { number: string } | null }): string {
return subscription.sponsorship?.number ?? subscription.id;
}
export async function cancelSubscription(input: {
subscriptionId: string;
receivedAt: Date;
/** false = keine Bestaetigungsmail (die oeffentliche Seite schickt ihre eigene Eingangsbestaetigung). */
sendConfirmation: boolean;
}): Promise<CancellationResult> {
const subscription = await prisma.sponsorshipSubscription.findUnique({
where: { id: input.subscriptionId },
include: {
offer: { select: { name: true } },
customer: { select: { email: true, firstName: true } },
sponsorship: { select: { number: true } },
},
});
if (!subscription) return { ok: false, reason: "not_found" };
if (!(CANCELLABLE as readonly string[]).includes(subscription.status) || !subscription.stripeSubscriptionId) {
return { ok: false, reason: "not_cancellable" };
}
const stripe = getStripeClient();
const current = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId);
const alreadyCancelled = current.cancel_at_period_end || current.cancel_at !== null;
const updated = alreadyCancelled
? current
: // Von Natur aus idempotent (setzt nur ein Kennzeichen) — bewusst ohne
// Idempotenzschluessel, dessen 24-Stunden-Antwortcache sonst einen
// inzwischen geaenderten Stand zurueckgeben koennte.
await stripe.subscriptions.update(subscription.stripeSubscriptionId, { cancel_at_period_end: true });
await applyStripeSubscription(updated);
const effectiveEnd =
(updated.cancel_at ? new Date(updated.cancel_at * 1000) : null) ??
currentPeriodEndOf(updated) ??
subscription.currentPeriodEnd ??
input.receivedAt;
if (input.sendConfirmation && !alreadyCancelled) {
const content = await subscriptionCancelledEmail({
locale: isLocale(subscription.locale) ? subscription.locale : defaultLocale,
firstName: subscription.customer.firstName,
offerName: subscription.offer.name,
number: contractReference(subscription),
effectiveEnd,
receivedAt: input.receivedAt,
accountUrl: `${getBaseUrl()}/konto/patenschaft/abo/${subscription.id}`,
});
const sent = await sendMail({ to: subscription.customer.email, ...content });
if (!sent) console.error(`[abo] Kuendigungsbestaetigung an ${subscription.customer.email} konnte nicht versendet werden.`);
}
return { ok: true, effectiveEnd, alreadyCancelled };
}
const PORTAL_METADATA_KEY = "wabenhain";
export const PORTAL_METADATA_VALUE = "abo-zahlungsart";
/**
* Konfiguration des Stripe-Kundenportals, die NUR das Aendern der Zahlungsart
* erlaubt (angelegt von scripts/stripe-setup.ts). Bewusst nie die
* Standardkonfiguration: die koennte Kuendigung oder Tarifwechsel erlauben,
* und die Kuendigung laeuft ausschliesslich ueber den eigenen Knopf.
*/
async function paymentMethodPortalConfiguration(): Promise<string | null> {
const fromEnv = process.env.STRIPE_PORTAL_CONFIGURATION_ID?.trim();
if (fromEnv) return fromEnv;
const list = await getStripeClient().billingPortal.configurations.list({ active: true, limit: 100 });
return list.data.find((config) => config.metadata?.[PORTAL_METADATA_KEY] === PORTAL_METADATA_VALUE)?.id ?? null;
}
/** URL einer Kundenportal-Sitzung zum Aendern der Zahlungsart, oder null, wenn das Portal nicht eingerichtet ist. */
export async function createPaymentMethodPortalUrl(input: { stripeCustomerId: string; returnUrl: string }): Promise<string | null> {
const configuration = await paymentMethodPortalConfiguration();
if (!configuration) {
console.error("[abo] Kein Kundenportal fuer die Zahlungsart eingerichtet — bitte scripts/stripe-setup.ts --apply ausfuehren.");
return null;
}
const session = await getStripeClient().billingPortal.sessions.create({
customer: input.stripeCustomerId,
configuration,
return_url: input.returnUrl,
});
return session.url;
}
@@ -0,0 +1,36 @@
import { prisma } from "@/lib/prisma";
import { getStripeClient } from "@/lib/stripe";
/**
* Stand einer Abo-Checkout-Sitzung fuer die Dankeseite. Gelesen wird bei
* Stripe; angezeigt wird nur, was zum angemeldeten Konto gehoert — eine
* fremde oder erfundene session_id ergibt dieselbe neutrale Antwort wie eine
* nicht abgeschlossene Sitzung.
*/
export type SubscriptionReturn =
| { kind: "active"; subscriptionId: string }
| { kind: "processing"; subscriptionId: string }
| { kind: "notCompleted" };
const SESSION_ID = /^cs_(test|live)_[A-Za-z0-9]{10,200}$/;
export async function resolveSubscriptionReturn(sessionId: string, customerId: string): Promise<SubscriptionReturn> {
if (!SESSION_ID.test(sessionId)) return { kind: "notCompleted" };
const local = await prisma.sponsorshipSubscription.findUnique({
where: { stripeCheckoutSessionId: sessionId },
select: { id: true, customerId: true },
});
if (!local || local.customerId !== customerId) return { kind: "notCompleted" };
try {
const session = await getStripeClient().checkout.sessions.retrieve(sessionId);
if (session.status !== "complete") return { kind: "notCompleted" };
// SEPA: Sitzung abgeschlossen, Lastschrift noch in Pruefung.
return session.payment_status === "unpaid"
? { kind: "processing", subscriptionId: local.id }
: { kind: "active", subscriptionId: local.id };
} catch (error: unknown) {
console.error(`[abo] Checkout-Sitzung ${sessionId} konnte nicht gelesen werden:`, error);
return { kind: "processing", subscriptionId: local.id };
}
}
@@ -0,0 +1,39 @@
import { describe, expect, test, vi } from "vitest";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/stripe", () => ({ getStripeClient: () => ({}) }));
const { subscriptionCheckoutParams } = await import("./checkout");
const base = {
subscriptionId: "sub_local",
stripeCustomerId: "cus_1",
stripePriceId: "price_1",
sepaBlocked: false,
locale: "de" as const,
baseUrl: "https://wabenhain.test",
};
describe("subscriptionCheckoutParams", () => {
test("eingebettete Abo-Sitzung mit Metadaten an Sitzung und Abo und Rueckkehr auf die Dankeseite", () => {
expect(subscriptionCheckoutParams(base)).toMatchObject({
ui_mode: "embedded_page",
mode: "subscription",
customer: "cus_1",
line_items: [{ price: "price_1", quantity: 1 }],
metadata: { sponsorshipSubscriptionId: "sub_local" },
subscription_data: { metadata: { sponsorshipSubscriptionId: "sub_local" } },
return_url: "https://wabenhain.test/patenschaft/abo/danke?session_id={CHECKOUT_SESSION_ID}",
});
});
test("setzt nie payment_method_types (dynamische Zahlarten aus dem Dashboard)", () => {
expect(subscriptionCheckoutParams(base)).not.toHaveProperty("payment_method_types");
expect(subscriptionCheckoutParams({ ...base, sepaBlocked: true })).not.toHaveProperty("payment_method_types");
});
test("schliesst SEPA nur nach einer Rueckbuchung aus", () => {
expect(subscriptionCheckoutParams(base)).not.toHaveProperty("excluded_payment_method_types");
expect(subscriptionCheckoutParams({ ...base, sepaBlocked: true }).excluded_payment_method_types).toEqual(["sepa_debit"]);
});
});
@@ -0,0 +1,155 @@
import type Stripe from "stripe";
import { prisma } from "@/lib/prisma";
import { getStripeClient } from "@/lib/stripe";
import { getBaseUrl } from "@/lib/url";
import type { Prisma } from "@/generated/prisma/client";
import type { Locale } from "@/i18n/config";
import type { Personalization } from "@/lib/certificate/schema";
import { toStoredPersonalization } from "@/lib/sponsorship/certificate-document";
import { syncOfferStripePrice } from "@/lib/sponsorship-subscription/stripe-prices";
/**
* Abschluss eines Abos ueber eine eingebettete Stripe-Checkout-Sitzung im
* Modus "subscription" (Spec "Abrechnung"). Das lokale Abo entsteht VOR der
* Sitzung (Status AUSSTEHEND), damit jedes spaetere Ereignis — auch eines,
* das vor checkout.session.completed ankommt — ueber die Metadaten-ID
* zugeordnet werden kann.
*/
/**
* Kennung des Checkout-Ablaufs im Stripe-Dashboard (Vergleich der Ablaeufe).
* Fester Suffix aus acht zufaelligen Buchstaben, wie Stripe es empfiehlt.
*/
export const ABO_INTEGRATION_IDENTIFIER = "wabenhain-patenschaft-abo-qkzrwbxe";
export interface SubscriptionCheckoutCustomer {
id: string;
email: string;
firstName: string;
lastName: string;
stripeCustomerId: string | null;
sepaBlockedAt: Date | null;
}
/**
* Parameter der Checkout-Sitzung als reine Funktion — testbar ohne Stripe.
* Nie payment_method_types: Stripe zeigt dynamisch die im Dashboard
* aktivierten Zahlarten, die wiederkehrende Zahlungen koennen. Nach einer
* SEPA-Rueckbuchung wird nur SEPA ausgeschlossen (Spec Punkt 5).
*/
export function subscriptionCheckoutParams(input: {
subscriptionId: string;
stripeCustomerId: string;
stripePriceId: string;
sepaBlocked: boolean;
locale: Locale;
baseUrl: string;
}): Stripe.Checkout.SessionCreateParams {
const metadata = { sponsorshipSubscriptionId: input.subscriptionId };
return {
ui_mode: "embedded_page",
mode: "subscription",
customer: input.stripeCustomerId,
line_items: [{ price: input.stripePriceId, quantity: 1 }],
metadata,
subscription_data: { metadata },
locale: input.locale,
return_url: `${input.baseUrl}/patenschaft/abo/danke?session_id={CHECKOUT_SESSION_ID}`,
redirect_on_completion: "always",
integration_identifier: ABO_INTEGRATION_IDENTIFIER,
...(input.sepaBlocked ? { excluded_payment_method_types: ["sepa_debit"] } : {}),
};
}
/**
* Stripe-Kunde des Kontos, beim ersten Abo angelegt. Der Idempotenzschluessel
* je Konto verhindert bei zwei gleichzeitigen Abschluessen einen zweiten
* Stripe-Kunden; gespeichert wird nur, solange noch keiner hinterlegt ist.
*/
export async function ensureStripeCustomer(customer: SubscriptionCheckoutCustomer): Promise<string> {
if (customer.stripeCustomerId) return customer.stripeCustomerId;
const created = await getStripeClient().customers.create(
{
email: customer.email,
name: `${customer.firstName} ${customer.lastName}`.trim(),
metadata: { customerId: customer.id },
},
{ idempotencyKey: `wabenhain-customer-${customer.id}` }
);
await prisma.customer.updateMany({
where: { id: customer.id, stripeCustomerId: null },
data: { stripeCustomerId: created.id },
});
const stored = await prisma.customer.findUniqueOrThrow({ where: { id: customer.id }, select: { stripeCustomerId: true } });
return stored.stripeCustomerId ?? created.id;
}
/** Stripe-Preis des Angebots; fehlt er oder ist er veraltet, wird er jetzt angelegt. */
async function currentStripePrice(offerId: string): Promise<string> {
const read = () =>
prisma.sponsorshipOffer.findUniqueOrThrow({
where: { id: offerId },
select: { priceCents: true, stripePriceId: true, stripePriceAmountCents: true },
});
let offer = await read();
if (!offer.stripePriceId || offer.stripePriceAmountCents !== offer.priceCents) {
await syncOfferStripePrice(offerId);
offer = await read();
}
if (!offer.stripePriceId) throw new Error(`Abo-Angebot ${offerId} hat keinen Stripe-Preis.`);
return offer.stripePriceId;
}
export async function createSubscriptionCheckout(input: {
customer: SubscriptionCheckoutCustomer;
offerId: string;
personalization: Personalization;
locale: Locale;
}): Promise<{ clientSecret: string; subscriptionId: string }> {
const stripe = getStripeClient();
const stripePriceId = await currentStripePrice(input.offerId);
const stripeCustomerId = await ensureStripeCustomer(input.customer);
const subscription = await prisma.sponsorshipSubscription.create({
data: {
customerId: input.customer.id,
offerId: input.offerId,
status: "AUSSTEHEND",
personalization: toStoredPersonalization(input.personalization) as unknown as Prisma.InputJsonValue,
locale: input.locale,
},
select: { id: true },
});
let session: Stripe.Checkout.Session | null = null;
try {
session = await stripe.checkout.sessions.create(
subscriptionCheckoutParams({
subscriptionId: subscription.id,
stripeCustomerId,
stripePriceId,
sepaBlocked: input.customer.sepaBlockedAt !== null,
locale: input.locale,
baseUrl: getBaseUrl(),
}),
{ idempotencyKey: `wabenhain-abo-checkout-${subscription.id}` }
);
if (!session.client_secret) throw new Error(`Stripe-Sitzung ${session.id} ohne client_secret.`);
await prisma.sponsorshipSubscription.update({
where: { id: subscription.id },
data: { stripeCheckoutSessionId: session.id },
});
return { clientSecret: session.client_secret, subscriptionId: subscription.id };
} catch (error: unknown) {
// Ohne Sitzung (oder ohne gespeicherte Sitzungs-ID) ist das Abo nie
// abschliessbar: Sitzung schliessen, Datensatz entfernen. Das
// client_secret hat niemand gesehen.
if (session) {
await stripe.checkout.sessions.expire(session.id).catch((expireError: unknown) => {
console.error(`[abo] Sitzung ${session?.id} konnte nicht geschlossen werden:`, expireError);
});
}
await prisma.sponsorshipSubscription.deleteMany({ where: { id: subscription.id, status: "AUSSTEHEND" } });
throw error;
}
}
@@ -0,0 +1,208 @@
/**
* Lieferauftraege im Abo: nur aus bezahlten Monaten, nie doppelt, und ohne
* Adresse oder Bestand zurueckgestellt statt still verloren.
*/
import { beforeEach, describe, expect, test, vi } from "vitest";
const m = vi.hoisted(() => ({
db: {
subscriptionDelivery: { findMany: vi.fn(), findUniqueOrThrow: vi.fn(), create: vi.fn(), updateMany: vi.fn() },
sponsorshipSubscription: { findMany: vi.fn() },
subscriptionPayment: { updateMany: vi.fn() },
inventory: { updateMany: vi.fn() },
order: { create: vi.fn() },
customer: { findUniqueOrThrow: vi.fn() },
$transaction: vi.fn(),
},
sendMail: vi.fn(),
recordOrderSale: vi.fn(),
scheduleApiCoreStockSync: vi.fn(),
}));
vi.mock("@/lib/prisma", () => ({ prisma: m.db }));
vi.mock("@/lib/mail", () => ({ sendMail: m.sendMail }));
vi.mock("@/lib/url", () => ({ getBaseUrl: () => "https://wabenhain.test" }));
vi.mock("@/lib/apicore-stock/stock-events", () => ({ recordOrderSale: m.recordOrderSale }));
vi.mock("@/lib/apicore-stock/schedule", () => ({ scheduleApiCoreStockSync: m.scheduleApiCoreStockSync }));
vi.mock("@/lib/sponsorship-subscription/emails", () => ({
subscriptionDeliveryEmail: vi.fn(async () => ({ subject: "Lieferung", html: "<p></p>", text: "" })),
}));
const { createDueDeliveries, NOTE_NO_ADDRESS, NOTE_NO_STOCK } = await import("./deliveries");
const NOW = new Date("2026-10-16T12:00:00.000Z");
const DAY = 24 * 60 * 60 * 1000;
const paid = (id: string, daysAgo: number) => {
const paidAt = new Date(NOW.getTime() - daysAgo * DAY);
return { id, paidAt, countsFrom: paidAt, reversedAt: null, deliveryId: null };
};
const ADDRESS = {
firstName: "Erika",
lastName: "Mustermann",
street: "Wiesenweg 1",
zip: "86450",
city: "Altenmünster",
country: "DE",
packstationNumber: null,
postNumber: null,
};
function eligibleSubscription(payments = [paid("p1", 70), paid("p2", 40), paid("p3", 10)]) {
return {
id: "sub_local",
status: "AKTIV",
offer: { cycle: "VIERTELJAEHRLICH", honeyJars: 2, harvestWindows: [] },
payments,
};
}
const delivery = {
id: "del_1",
jars: 2,
orderId: null,
subscription: {
id: "sub_local",
status: "AKTIV",
locale: "de",
customerId: "cust_1",
offerId: "offer_1",
offer: {
name: "Vierteljährlich",
honeyProduct: { id: "honey_1", name: "Frühtracht 500 g", batchId: "batch_1", weightGrams: 500, category: "HONIG", vatRatePercent: null },
},
},
};
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "info").mockImplementation(() => {});
m.db.$transaction.mockImplementation(async (fn: (tx: typeof m.db) => unknown) => fn(m.db));
m.db.subscriptionDelivery.findMany.mockResolvedValue([]);
m.db.sponsorshipSubscription.findMany.mockResolvedValue([eligibleSubscription()]);
m.db.subscriptionDelivery.create.mockResolvedValue({ id: "del_1" });
m.db.subscriptionPayment.updateMany.mockResolvedValue({ count: 3 });
m.db.subscriptionDelivery.findUniqueOrThrow.mockResolvedValue(delivery);
m.db.subscriptionDelivery.updateMany.mockResolvedValue({ count: 1 });
m.db.customer.findUniqueOrThrow.mockResolvedValue({
email: "[email protected]",
firstName: "Erika",
lastName: "Mustermann",
phone: null,
addresses: [ADDRESS],
});
m.db.inventory.updateMany.mockResolvedValue({ count: 1 });
m.db.order.create.mockResolvedValue({ id: "order_9", orderNumber: "WH-2026-00000009" });
m.sendMail.mockResolvedValue(true);
});
describe("createDueDeliveries", () => {
test("legt nach drei bezahlten Monaten eine 0-€-Versandbestellung an und verbraucht die Zahlungen", async () => {
const result = await createDueDeliveries(NOW);
expect(result).toEqual({ created: 1, deferred: 0 });
expect(m.db.subscriptionPayment.updateMany).toHaveBeenCalledWith({
where: { id: { in: ["p1", "p2", "p3"] }, deliveryId: null, reversedAt: null },
data: { deliveryId: "del_1" },
});
expect(m.db.inventory.updateMany).toHaveBeenCalledWith({
where: { productId: "honey_1", quantity: { gte: 2 } },
data: { quantity: { decrement: 2 } },
});
const order = m.db.order.create.mock.calls[0][0].data;
expect(order).toMatchObject({ status: "BEZAHLT", fulfillmentMethod: "VERSAND", totalCents: 0, street: "Wiesenweg 1" });
expect(order.items.create).toMatchObject({ productId: "honey_1", quantity: 2, unitPriceCents: 0, totalCents: 0 });
expect(m.db.subscriptionDelivery.updateMany).toHaveBeenCalledWith({
where: { id: "del_1", orderId: null },
data: { orderId: "order_9", status: "ANGELEGT", note: null },
});
expect(m.recordOrderSale).toHaveBeenCalledWith(m.db, "order_9");
expect(m.scheduleApiCoreStockSync).toHaveBeenCalledWith(["honey_1"]);
expect(m.sendMail).toHaveBeenCalledOnce();
});
test("zwei bezahlte Monate: keine Lieferung", async () => {
m.db.sponsorshipSubscription.findMany.mockResolvedValue([eligibleSubscription([paid("p1", 40), paid("p2", 10)])]);
const result = await createDueDeliveries(NOW);
expect(result).toEqual({ created: 0, deferred: 0 });
expect(m.db.subscriptionDelivery.create).not.toHaveBeenCalled();
});
test("ein gleichzeitiger zweiter Lauf, der die Zahlungen nicht mehr bekommt, legt nichts an", async () => {
m.db.subscriptionPayment.updateMany.mockResolvedValue({ count: 2 });
const result = await createDueDeliveries(NOW);
expect(result).toEqual({ created: 0, deferred: 0 });
expect(m.db.order.create).not.toHaveBeenCalled();
expect(m.sendMail).not.toHaveBeenCalled();
});
test("ohne Standardadresse wird die Lieferung mit Hinweis zurueckgestellt", async () => {
m.db.customer.findUniqueOrThrow.mockResolvedValue({
email: "[email protected]",
firstName: "Erika",
lastName: "Mustermann",
phone: null,
addresses: [],
});
const result = await createDueDeliveries(NOW);
expect(result).toEqual({ created: 0, deferred: 1 });
expect(m.db.order.create).not.toHaveBeenCalled();
expect(m.db.subscriptionDelivery.updateMany).toHaveBeenCalledWith({
where: { id: "del_1", orderId: null },
data: { status: "ZURUECKGESTELLT", note: NOTE_NO_ADDRESS },
});
});
test("ohne ausreichenden Bestand wird die Lieferung zurueckgestellt, nichts wird abgebucht", async () => {
m.db.inventory.updateMany.mockResolvedValue({ count: 0 });
const result = await createDueDeliveries(NOW);
expect(result).toEqual({ created: 0, deferred: 1 });
expect(m.db.order.create).not.toHaveBeenCalled();
expect(m.db.subscriptionDelivery.updateMany).toHaveBeenCalledWith({
where: { id: "del_1", orderId: null },
data: { status: "ZURUECKGESTELLT", note: NOTE_NO_STOCK },
});
});
test("zurueckgestellte Lieferungen werden erneut versucht, sobald die Adresse da ist", async () => {
m.db.subscriptionDelivery.findMany.mockResolvedValue([{ id: "del_1" }]);
m.db.sponsorshipSubscription.findMany.mockResolvedValue([]);
const result = await createDueDeliveries(NOW);
expect(result).toEqual({ created: 1, deferred: 0 });
expect(m.db.subscriptionDelivery.create).not.toHaveBeenCalled();
});
test("eine bereits angelegte Lieferung wird nicht ein zweites Mal bestellt", async () => {
m.db.subscriptionDelivery.findMany.mockResolvedValue([{ id: "del_1" }]);
m.db.sponsorshipSubscription.findMany.mockResolvedValue([]);
m.db.subscriptionDelivery.findUniqueOrThrow.mockResolvedValue({ ...delivery, orderId: "order_9" });
const result = await createDueDeliveries(NOW);
expect(result).toEqual({ created: 0, deferred: 0 });
expect(m.db.order.create).not.toHaveBeenCalled();
});
test("im Verzug ruht eine faellige Lieferung", async () => {
m.db.subscriptionDelivery.findMany.mockResolvedValue([{ id: "del_1" }]);
m.db.sponsorshipSubscription.findMany.mockResolvedValue([]);
m.db.subscriptionDelivery.findUniqueOrThrow.mockResolvedValue({
...delivery,
subscription: { ...delivery.subscription, status: "IM_VERZUG" },
});
await createDueDeliveries(NOW);
expect(m.db.order.create).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,241 @@
import { prisma } from "@/lib/prisma";
import { sendMail } from "@/lib/mail";
import { getBaseUrl } from "@/lib/url";
import { vatRateForProduct } from "@/lib/vat";
import { recordOrderSale } from "@/lib/apicore-stock/stock-events";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
import { isLocale, defaultLocale } from "@/i18n/config";
import { computeDeliveryEntitlement } from "@/lib/sponsorship-subscription/entitlement";
import { generateOrderNumber, orderContactForCustomer } from "@/lib/sponsorship-subscription/lookup";
import { subscriptionDeliveryEmail } from "@/lib/sponsorship-subscription/emails";
/**
* Honiglieferungen im Abo (Spec "Lieferauftraege").
*
* Ist der Anspruch erfuellt (entitlement.ts), entsteht ein
* Lieferauftrag als normale Bestellung: Status BEZAHLT, Betrag 0 €, Versand an
* die Standardadresse, Honigpositionen des verknuepften Produkts. Er
* erscheint in der bestehenden Pack- und DHL-Liste; der Honig wurde mit den
* Monatsrechnungen bereits als Vorauszahlung berechnet.
*
* Schutz gegen doppelte Laeufe: die Lieferung beansprucht ihre Zahlungen
* (deliveryId) in derselben Transaktion, in der sie entsteht. Ein zweiter,
* gleichzeitiger Lauf findet die Zahlungen verbraucht und verwirft seine
* Lieferung. Die Bestellung wird ueber orderId = null beansprucht.
*
* Fehlt die Adresse oder der Bestand, bleibt die Lieferung ZURUECKGESTELLT
* (mit Hinweis fuer die Aufmerksamkeitsliste) und haelt ihre Zahlungen; jeder
* weitere Lauf versucht sie erneut.
*/
export const NOTE_NO_ADDRESS = "Keine Standardadresse im Kundenkonto – Lieferung zurückgestellt, bitte Adresse erfragen.";
export const NOTE_NO_STOCK = "Bestand des Honigprodukts reicht nicht – Lieferung zurückgestellt, bitte nachlegen.";
const NOTE_PENDING = "Wird angelegt.";
const NOTE_PAUSED = "Abo im Verzug oder beendet – Lieferung ruht.";
class ClaimLost extends Error {}
class OutOfStock extends Error {}
type FulfillResult = "created" | "deferred" | "skipped";
function loadDelivery(id: string) {
return prisma.subscriptionDelivery.findUniqueOrThrow({
where: { id },
include: {
subscription: {
select: {
id: true,
status: true,
locale: true,
customerId: true,
offerId: true,
offer: {
select: {
name: true,
honeyProduct: {
select: { id: true, name: true, batchId: true, weightGrams: true, category: true, vatRatePercent: true },
},
},
},
},
},
},
});
}
async function defer(deliveryId: string, note: string): Promise<FulfillResult> {
await prisma.subscriptionDelivery.updateMany({
where: { id: deliveryId, orderId: null },
data: { status: "ZURUECKGESTELLT", note },
});
return "deferred";
}
/** Legt zur Lieferung die 0-€-Bestellung an, bucht den Honig ab und informiert die Kundschaft. */
export async function fulfillDelivery(deliveryId: string): Promise<FulfillResult> {
const delivery = await loadDelivery(deliveryId);
if (delivery.orderId) return "skipped";
const { subscription } = delivery;
if (subscription.status !== "AKTIV" && subscription.status !== "GEKUENDIGT") return defer(deliveryId, NOTE_PAUSED);
const honey = subscription.offer.honeyProduct;
if (!honey) return defer(deliveryId, NOTE_NO_STOCK);
const contact = await orderContactForCustomer(subscription.customerId);
if (!contact.hasAddress) return defer(deliveryId, NOTE_NO_ADDRESS);
const locale = isLocale(subscription.locale) ? subscription.locale : defaultLocale;
let orderNumber: string;
try {
orderNumber = await prisma.$transaction(async (tx) => {
// Nur die eigene Menge pruefen: die Abo-Reservierung dieses Abos ist
// genau fuer diese Lieferung da und wird hier verbraucht.
const reserved = await tx.inventory.updateMany({
where: { productId: honey.id, quantity: { gte: delivery.jars } },
data: { quantity: { decrement: delivery.jars } },
});
if (reserved.count === 0) throw new OutOfStock();
const order = await tx.order.create({
data: {
orderNumber: generateOrderNumber(),
customerId: subscription.customerId,
status: "BEZAHLT",
fulfillmentMethod: "VERSAND",
locale,
email: contact.email,
firstName: contact.firstName,
lastName: contact.lastName,
phone: contact.phone,
street: contact.street,
zip: contact.zip,
city: contact.city,
country: contact.country,
packstationNumber: contact.packstationNumber,
postNumber: contact.postNumber,
subtotalCents: 0,
totalCents: 0,
notes: `Abo-Honiglieferung (${subscription.offer.name}), mit den Monatsrechnungen bereits bezahlt. Abo ${subscription.id}.`,
items: {
create: {
productId: honey.id,
batchId: honey.batchId,
productName: honey.name,
unitPriceCents: 0,
quantity: delivery.jars,
weightGrams: honey.weightGrams,
totalCents: 0,
vatRatePercent: vatRateForProduct(honey, process.env),
sponsorshipOfferId: subscription.offerId,
sponsorshipRole: "ABO_DELIVERY",
},
},
},
select: { id: true, orderNumber: true },
});
const claim = await tx.subscriptionDelivery.updateMany({
where: { id: deliveryId, orderId: null },
data: { orderId: order.id, status: "ANGELEGT", note: null },
});
if (claim.count === 0) throw new ClaimLost();
// Wie bei jeder bezahlten Bestellung: Abbuchung in ApiCore vormerken.
await recordOrderSale(tx, order.id);
return order.orderNumber;
});
} catch (error: unknown) {
if (error instanceof OutOfStock) return defer(deliveryId, NOTE_NO_STOCK);
if (error instanceof ClaimLost) return "skipped";
throw error;
}
scheduleApiCoreStockSync([honey.id]);
console.info(`[abo] Honiglieferung ${orderNumber} fuer Abo ${subscription.id} angelegt (${delivery.jars} Glaeser).`);
const content = await subscriptionDeliveryEmail({
locale,
firstName: contact.firstName,
jars: delivery.jars,
productName: honey.name,
accountUrl: `${getBaseUrl()}/konto/patenschaft/abo/${subscription.id}`,
});
const sent = await sendMail({ to: contact.email, ...content });
if (!sent) console.error(`[abo] Liefermail zu ${orderNumber} an ${contact.email} konnte nicht versendet werden.`);
return "created";
}
/** Legt fuer ein Abo eine Lieferung an und beansprucht ihre Zahlungen — oder nichts, wenn ein anderer Lauf schneller war. */
async function claimDelivery(subscriptionId: string, jars: number, paymentIds: string[], now: Date): Promise<string | null> {
try {
return await prisma.$transaction(async (tx) => {
const delivery = await tx.subscriptionDelivery.create({
data: { subscriptionId, dueAt: now, jars, status: "ZURUECKGESTELLT", note: NOTE_PENDING },
select: { id: true },
});
const claim = await tx.subscriptionPayment.updateMany({
where: { id: { in: paymentIds }, deliveryId: null, reversedAt: null },
data: { deliveryId: delivery.id },
});
if (claim.count !== paymentIds.length) throw new ClaimLost();
return delivery.id;
});
} catch (error: unknown) {
if (error instanceof ClaimLost) return null;
throw error;
}
}
export interface DeliveryRunResult {
created: number;
deferred: number;
}
export async function createDueDeliveries(now: Date): Promise<DeliveryRunResult> {
const result: DeliveryRunResult = { created: 0, deferred: 0 };
const count = (outcome: FulfillResult) => {
if (outcome === "created") result.created++;
if (outcome === "deferred") result.deferred++;
};
// Zuerst zurueckgestellte Lieferungen erneut versuchen (Adresse
// nachgetragen, Bestand nachgelegt).
const deferred = await prisma.subscriptionDelivery.findMany({
where: { status: "ZURUECKGESTELLT", orderId: null, subscription: { status: { in: ["AKTIV", "GEKUENDIGT"] } } },
select: { id: true },
orderBy: { dueAt: "asc" },
});
for (const { id } of deferred) count(await fulfillDelivery(id));
const subscriptions = await prisma.sponsorshipSubscription.findMany({
where: {
status: { in: ["AKTIV", "GEKUENDIGT"] },
// Hoechstens eine offene Lieferung je Abo: erst die zurueckgestellte erledigen.
deliveries: { none: { status: "ZURUECKGESTELLT", orderId: null } },
offer: { honeyJars: { gt: 0 }, honeyProductId: { not: null }, cycle: { not: null } },
},
select: {
id: true,
status: true,
offer: {
select: { cycle: true, honeyJars: true, harvestWindows: { select: { startsOn: true, endsOn: true } } },
},
payments: {
where: { deliveryId: null, reversedAt: null },
select: { id: true, paidAt: true, countsFrom: true, reversedAt: true, deliveryId: true },
},
},
});
for (const subscription of subscriptions) {
const cycle = subscription.offer.cycle;
if (!cycle) continue;
const entitlement = computeDeliveryEntitlement({
cycle,
payments: subscription.payments,
status: subscription.status,
now,
harvestWindows: subscription.offer.harvestWindows,
});
if (!entitlement.due) continue;
const deliveryId = await claimDelivery(subscription.id, subscription.offer.honeyJars, entitlement.paymentIds, now);
if (deliveryId) count(await fulfillDelivery(deliveryId));
}
return result;
}
@@ -0,0 +1,138 @@
import type Stripe from "stripe";
import { prisma } from "@/lib/prisma";
import { getStripeClient } from "@/lib/stripe";
import { sendMail } from "@/lib/mail";
import { isLocale, defaultLocale } from "@/i18n/config";
import { DISPUTE_GRACE_DAYS } from "@/lib/sponsorship-subscription/billing-rules";
import { stripeIdOf } from "@/lib/sponsorship-subscription/lookup";
import { subscriptionRefOfInvoice } from "@/lib/sponsorship-subscription/invoice-paid";
import { syncSubscriptionFromStripe } from "@/lib/sponsorship-subscription/stripe-sync";
import { subscriptionDisputeEmail } from "@/lib/sponsorship-subscription/emails";
/**
* Rueckbuchungen von Abo-Zahlungen (Spec "SEPA-Lastschrift und
* Rueckbuchungen", Punkte 3 bis 5). Die bestehende Vermerk-Logik an der
* Bestellung (recordDispute im Webhook) laeuft unveraendert weiter; hier
* kommt hinzu, was nur Abos betrifft:
*
* - die Zahlung gilt als zurueckgebucht und zaehlt nie mehr fuer eine Lieferung,
* - das Abo geht sofort in Verzug (keine Lieferungen, Reservierung frei),
* - nach einer SEPA-Rueckbuchung schliesst das Konto neue Abos nur noch mit
* Karte oder Wallet ab,
* - die Kundschaft bekommt eine Mail mit der Bitte um Ausgleich.
*
* Der Anspruch ueber reversedAt (updateMany … where reversedAt = null) macht
* das idempotent: ein doppelt zugestelltes Ereignis verschickt keine zweite Mail.
*/
function loadPaymentByIntent(paymentIntentId: string) {
return prisma.subscriptionPayment.findFirst({
where: { stripePaymentIntentId: paymentIntentId },
include: {
subscription: {
select: {
id: true,
status: true,
locale: true,
stripeSubscriptionId: true,
offer: { select: { name: true } },
customer: { select: { id: true, email: true, firstName: true } },
},
},
},
});
}
/**
* Kommt die Rueckbuchung vor dem invoice.paid derselben Zahlung an (Stripe
* garantiert keine Reihenfolge), gibt es die Zahlung hier noch nicht. Gehoert
* der PaymentIntent zu einem unserer Abos, wird geworfen: Stripe stellt die
* Rueckbuchung spaeter erneut zu, dann ist die Zahlung da.
*/
async function assertNotPendingSubscriptionPayment(paymentIntentId: string): Promise<void> {
const invoicePayments = await getStripeClient().invoicePayments.list({
payment: { type: "payment_intent", payment_intent: paymentIntentId },
limit: 1,
expand: ["data.invoice"],
});
const invoice = invoicePayments.data[0]?.invoice;
if (!invoice || typeof invoice === "string" || invoice.deleted) return;
const ref = subscriptionRefOfInvoice(invoice);
if (!ref) return;
const known = await prisma.sponsorshipSubscription.count({
where: {
OR: [{ stripeSubscriptionId: ref.stripeSubscriptionId }, ...(ref.metadataId ? [{ id: ref.metadataId }] : [])],
},
});
if (known > 0) {
throw new Error(`Rueckbuchung zu ${paymentIntentId} vor der zugehoerigen Abo-Zahlung eingetroffen — Stripe stellt erneut zu.`);
}
}
export async function handleSubscriptionDisputeCreated(dispute: Stripe.Dispute, now: Date = new Date()): Promise<void> {
const paymentIntentId = stripeIdOf(dispute.payment_intent);
if (!paymentIntentId) return;
const payment = await loadPaymentByIntent(paymentIntentId);
if (!payment) {
await assertNotPendingSubscriptionPayment(paymentIntentId);
return;
}
const claim = await prisma.subscriptionPayment.updateMany({
where: { id: payment.id, reversedAt: null },
data: { reversedAt: now },
});
if (claim.count === 0) return;
const subscription = payment.subscription;
console.error(
`[abo] RUECKBUCHUNG der Abo-Zahlung ${payment.stripeInvoiceId} (Abo ${subscription.id}, Dispute ${dispute.id}): Abo im Verzug, endet ohne Ausgleich nach ${DISPUTE_GRACE_DAYS} Tagen.`
);
if (subscription.status !== "BEENDET") {
await prisma.sponsorshipSubscription.updateMany({
where: { id: subscription.id, status: { not: "BEENDET" } },
data: { status: "IM_VERZUG", delinquentSince: now },
});
}
const methodType = payment.paymentMethodType ?? dispute.payment_method_details?.type ?? null;
if (methodType === "sepa_debit") {
await prisma.customer.updateMany({
where: { id: subscription.customer.id, sepaBlockedAt: null },
data: { sepaBlockedAt: now },
});
}
if (subscription.status === "BEENDET") return;
const content = await subscriptionDisputeEmail({
locale: isLocale(subscription.locale) ? subscription.locale : defaultLocale,
firstName: subscription.customer.firstName,
offerName: subscription.offer.name,
amountCents: dispute.amount,
graceDays: DISPUTE_GRACE_DAYS,
});
const sent = await sendMail({ to: subscription.customer.email, ...content });
if (!sent) console.error(`[abo] Mail zur Rueckbuchung an ${subscription.customer.email} konnte nicht versendet werden.`);
}
/**
* Gewonnene Rueckbuchung (Karte): die Zahlung zaehlt wieder, der Status wird
* aus Stripe neu bestimmt. Die SEPA-Sperre des Kontos bleibt — sie hebt nur
* der Admin auf.
*/
export async function handleSubscriptionDisputeClosed(dispute: Stripe.Dispute): Promise<void> {
if (dispute.status !== "won") return;
const paymentIntentId = stripeIdOf(dispute.payment_intent);
if (!paymentIntentId) return;
const payment = await loadPaymentByIntent(paymentIntentId);
if (!payment || !payment.reversedAt) return;
await prisma.subscriptionPayment.updateMany({
where: { id: payment.id, reversedAt: { not: null } },
data: { reversedAt: null },
});
if (payment.subscription.stripeSubscriptionId && payment.subscription.status !== "BEENDET") {
await syncSubscriptionFromStripe(payment.subscription.stripeSubscriptionId);
}
}
+262
View File
@@ -0,0 +1,262 @@
import { emailLayout, button, type EmailContent } from "@/lib/email-templates";
import { escapeHtml } from "@/lib/escape-html";
import { formatDate, formatPrice } from "@/lib/format";
import { formatReceivedAt } from "@/lib/withdrawal";
import type { Locale } from "@/i18n/config";
/**
* Mails des Abos. Wie die uebrigen Patenschaftsmails ohne Versprechen von
* Besuchen, Berichten, Fotos oder Umweltwirkungen — sie sagen nur, was
* tatsaechlich geschieht: Rechnung, Honiglieferung, Zahlungsstand, Kuendigung.
*/
const H1 = 'style="font-size:20px;color:#24352B;margin:0 0 12px;"';
const P = 'style="margin:0 0 16px;line-height:1.6;"';
const BOX = 'style="margin:0 0 16px;padding:16px;background-color:#F4EFE5;line-height:1.6;"';
const strings = {
de: {
hello: (name: string) => `Hallo ${name},`,
accountButton: "Zum Abo im Konto",
invoiceSubject: (number: string) => `Deine Rechnung ${number} zur Bienenpatenschaft im Abo`,
invoicePreheader: "Danke für deine Zahlung – die Rechnung liegt bei.",
invoiceBody: (offer: string, amount: string, period: string) =>
`vielen Dank für deine Zahlung von <strong>${amount}</strong> für dein Abo „${offer}“ (Zeitraum ${period}). Die Rechnung findest du im Anhang und jederzeit in deinem Konto.`,
invoiceFirst:
"Deine Urkunde wird gerade erstellt; sobald sie fertig ist, bekommst du eine eigene Mail und findest sie in deinem Konto unter „Patenschaft“.",
deliverySubject: "Deine Honiglieferung ist in Vorbereitung",
deliveryPreheader: "Dein Abo-Honig wird gepackt.",
deliveryBody: (jars: number, product: string) =>
`für dein Abo ist eine Honiglieferung fällig: <strong>${jars} × ${product}</strong>. Wir packen sie und schicken sie dir an deine Standardadresse. Sobald sie unterwegs ist, bekommst du die Sendungsnummer.`,
disputeSubject: "Deine Abo-Zahlung wurde zurückgebucht",
disputePreheader: "Bitte gleiche den offenen Betrag aus.",
disputeBody: (amount: string, offer: string) =>
`deine Bank hat eine Zahlung von <strong>${amount}</strong> für dein Abo „${offer}“ zurückgebucht. Bis der Betrag ausgeglichen ist, ruht dein Abo: Es werden keine Honiglieferungen angelegt.`,
disputeAction: (days: number) =>
`Bitte antworte auf diese Mail, damit wir dir sagen können, wie du den Betrag begleichen kannst. Ohne Ausgleich innerhalb von ${days} Tagen endet das Abo wie bei einer Kündigung.`,
cancelSubject: "Bestätigung deiner Kündigung",
cancelPreheader: (end: string) => `Dein Abo endet zum ${end}.`,
cancelHeading: "Deine Kündigung ist eingegangen",
cancelBody: (offer: string, number: string, end: string) =>
`wir bestätigen die Kündigung deines Abos „${offer}“${number ? ` (Nr. ${number})` : ""}. Die Kündigung wird zum Ende des bezahlten Monats wirksam, also zum <strong>${end}</strong>. Bis dahin läuft das Abo normal weiter; danach wird nichts mehr abgebucht.`,
cancelAfter:
"Deine Urkunde bleibt in deinem Konto und erhält mit dem Ende automatisch das Enddatum. Medaille und Downloads bleiben erhalten.",
receivedAt: "Eingegangen am",
publicSubject: "Eingangsbestätigung deiner Kündigung",
publicHeading: "Deine Kündigung ist bei uns eingegangen",
publicIntro: "Hiermit bestätigen wir den Eingang deiner Kündigungserklärung, die du über unsere Kündigungsseite abgegeben hast. Sie lautet:",
publicMatched: (end: string) =>
`Wir haben dein Abo gefunden. Die Kündigung wird zum Ende des bezahlten Monats wirksam, also zum <strong>${end}</strong>.`,
publicUnmatched:
"Wir ordnen deine Kündigung jetzt deinem Vertrag zu und melden uns, falls wir dazu noch etwas von dir brauchen. Wirksam wird sie zum Ende des bezahlten Monats.",
declaration: (reference: string) => `Hiermit kündige ich meinen Vertrag ${reference} zum nächstmöglichen Zeitpunkt.`,
name: "Name",
email: "E-Mail",
message: "Nachricht",
},
en: {
hello: (name: string) => `Hello ${name},`,
accountButton: "View subscription",
invoiceSubject: (number: string) => `Your invoice ${number} for your bee sponsorship subscription`,
invoicePreheader: "Thank you for your payment – the invoice is attached.",
invoiceBody: (offer: string, amount: string, period: string) =>
`thank you for your payment of <strong>${amount}</strong> for your subscription “${offer}” (period ${period}). The invoice is attached and always available in your account.`,
invoiceFirst:
"Your certificate is being created; as soon as it is ready you will receive a separate email and find it in your account under “Sponsorship”.",
deliverySubject: "Your honey delivery is being prepared",
deliveryPreheader: "Your subscription honey is being packed.",
deliveryBody: (jars: number, product: string) =>
`a honey delivery is due for your subscription: <strong>${jars} × ${product}</strong>. We are packing it and will send it to your default address. You will receive the tracking number as soon as it is on its way.`,
disputeSubject: "Your subscription payment was charged back",
disputePreheader: "Please settle the outstanding amount.",
disputeBody: (amount: string, offer: string) =>
`your bank has charged back a payment of <strong>${amount}</strong> for your subscription “${offer}”. Until the amount is settled, your subscription is paused: no honey deliveries will be created.`,
disputeAction: (days: number) =>
`Please reply to this email so we can tell you how to settle the amount. Without settlement within ${days} days, the subscription ends as if it had been cancelled.`,
cancelSubject: "Confirmation of your cancellation",
cancelPreheader: (end: string) => `Your subscription ends on ${end}.`,
cancelHeading: "We have received your cancellation",
cancelBody: (offer: string, number: string, end: string) =>
`we confirm the cancellation of your subscription “${offer}”${number ? ` (no. ${number})` : ""}. It takes effect at the end of the paid month, on <strong>${end}</strong>. Until then the subscription continues as usual; nothing will be charged afterwards.`,
cancelAfter:
"Your certificate stays in your account and automatically receives the end date. Your medal and downloads remain available.",
receivedAt: "Received on",
publicSubject: "Confirmation of receipt of your cancellation",
publicHeading: "We have received your cancellation",
publicIntro: "We hereby confirm receipt of the notice of cancellation you submitted via our cancellation page. It reads:",
publicMatched: (end: string) =>
`We have found your subscription. The cancellation takes effect at the end of the paid month, on <strong>${end}</strong>.`,
publicUnmatched:
"We are now matching your cancellation to your contract and will get in touch if we need anything else from you. It takes effect at the end of the paid month.",
declaration: (reference: string) => `I hereby cancel my contract ${reference} at the earliest possible date.`,
name: "Name",
email: "Email",
message: "Message",
},
} as const;
const stripTags = (value: string): string => value.replace(/<\/?strong>/g, "");
/** Zeilenumbrueche im Betreff wuerden als weitere Mail-Header gelesen. */
const singleLine = (value: string): string => value.replace(/[\r\n]+/g, " ");
export async function subscriptionInvoiceEmail(input: {
locale: Locale;
firstName: string;
offerName: string;
invoiceNumber: string;
amountCents: number;
period: string;
isFirstPayment: boolean;
accountUrl: string;
}): Promise<EmailContent> {
const s = strings[input.locale];
const amount = formatPrice(input.amountCents, "EUR", input.locale);
const html = await emailLayout(
input.locale,
s.invoicePreheader,
`
<h1 ${H1}>${s.hello(escapeHtml(input.firstName))}</h1>
<p ${P}>${s.invoiceBody(escapeHtml(input.offerName), escapeHtml(amount), escapeHtml(input.period))}</p>
${input.isFirstPayment ? `<p ${P}>${s.invoiceFirst}</p>` : ""}
<div>${button(input.accountUrl, s.accountButton)}</div>
`
);
const text = [
s.hello(input.firstName),
"",
stripTags(s.invoiceBody(input.offerName, amount, input.period)),
...(input.isFirstPayment ? ["", s.invoiceFirst] : []),
"",
`${s.accountButton}: ${input.accountUrl}`,
].join("\n");
return { subject: singleLine(s.invoiceSubject(input.invoiceNumber)), html, text };
}
export async function subscriptionDeliveryEmail(input: {
locale: Locale;
firstName: string;
jars: number;
productName: string;
accountUrl: string;
}): Promise<EmailContent> {
const s = strings[input.locale];
const html = await emailLayout(
input.locale,
s.deliveryPreheader,
`
<h1 ${H1}>${s.hello(escapeHtml(input.firstName))}</h1>
<p ${P}>${s.deliveryBody(input.jars, escapeHtml(input.productName))}</p>
<div>${button(input.accountUrl, s.accountButton)}</div>
`
);
const text = `${s.hello(input.firstName)}\n\n${stripTags(s.deliveryBody(input.jars, input.productName))}\n\n${s.accountButton}: ${input.accountUrl}`;
return { subject: s.deliverySubject, html, text };
}
export async function subscriptionDisputeEmail(input: {
locale: Locale;
firstName: string;
offerName: string;
amountCents: number;
graceDays: number;
}): Promise<EmailContent> {
const s = strings[input.locale];
const amount = formatPrice(input.amountCents, "EUR", input.locale);
const html = await emailLayout(
input.locale,
s.disputePreheader,
`
<h1 ${H1}>${s.hello(escapeHtml(input.firstName))}</h1>
<p ${P}>${s.disputeBody(escapeHtml(amount), escapeHtml(input.offerName))}</p>
<p ${P}>${s.disputeAction(input.graceDays)}</p>
`
);
const text = `${s.hello(input.firstName)}\n\n${stripTags(s.disputeBody(amount, input.offerName))}\n\n${s.disputeAction(input.graceDays)}`;
return { subject: s.disputeSubject, html, text };
}
/** Bestaetigung der Kuendigung aus dem Konto oder einer zugeordneten oeffentlichen Kuendigung (§ 312k Abs. 4 BGB). */
export async function subscriptionCancelledEmail(input: {
locale: Locale;
firstName: string;
offerName: string;
number: string;
effectiveEnd: Date;
receivedAt: Date;
accountUrl: string;
}): Promise<EmailContent> {
const s = strings[input.locale];
const end = formatDate(input.effectiveEnd, input.locale);
const when = formatReceivedAt(input.receivedAt, input.locale);
const html = await emailLayout(
input.locale,
s.cancelPreheader(end),
`
<h1 ${H1}>${s.cancelHeading}</h1>
<p ${P}>${s.hello(escapeHtml(input.firstName))}</p>
<p ${P}>${s.cancelBody(escapeHtml(input.offerName), escapeHtml(input.number), escapeHtml(end))}</p>
<p ${P}><strong>${s.receivedAt}:</strong> ${escapeHtml(when)}</p>
<p ${P}>${s.cancelAfter}</p>
<div>${button(input.accountUrl, s.accountButton)}</div>
`
);
const text = [
s.cancelHeading,
"",
s.hello(input.firstName),
"",
stripTags(s.cancelBody(input.offerName, input.number, end)),
"",
`${s.receivedAt}: ${when}`,
"",
s.cancelAfter,
"",
`${s.accountButton}: ${input.accountUrl}`,
].join("\n");
return { subject: s.cancelSubject, html, text };
}
export interface PublicCancellationDeclaration {
name: string;
email: string;
reference: string;
message: string | null;
}
/** Wortlaut der oeffentlichen Kuendigungserklaerung — Mail, Bestaetigungsseite und Admin zeigen dasselbe. */
export function cancellationDeclarationLines(input: PublicCancellationDeclaration, locale: Locale): string[] {
const s = strings[locale];
return [
s.declaration(input.reference),
`${s.name}: ${input.name}`,
`${s.email}: ${input.email}`,
...(input.message ? [`${s.message}: ${input.message}`] : []),
];
}
/** Eingangsbestaetigung der Seite /vertraege-kuendigen, Muster wie die Widerrufsfunktion. */
export async function publicCancellationReceiptEmail(input: {
locale: Locale;
declaration: PublicCancellationDeclaration;
receivedAt: Date;
effectiveEnd: Date | null;
}): Promise<EmailContent> {
const s = strings[input.locale];
const lines = cancellationDeclarationLines(input.declaration, input.locale);
const when = formatReceivedAt(input.receivedAt, input.locale);
const outcome = input.effectiveEnd ? s.publicMatched(formatDate(input.effectiveEnd, input.locale)) : s.publicUnmatched;
const html = await emailLayout(
input.locale,
`${s.receivedAt}: ${when}`,
`
<h1 ${H1}>${s.publicHeading}</h1>
<p ${P}>${s.publicIntro}</p>
<div ${BOX}>${lines.map((line) => escapeHtml(line)).join("<br>")}</div>
<p ${P}><strong>${s.receivedAt}:</strong> ${escapeHtml(when)}</p>
<p style="margin:0;line-height:1.6;">${outcome}</p>
`
);
const text = [s.publicHeading, "", s.publicIntro, "", ...lines, "", `${s.receivedAt}: ${when}`, "", stripTags(outcome)].join("\n");
return { subject: s.publicSubject, html, text };
}
@@ -0,0 +1,143 @@
/**
* Ende eines Abos: Status, Enddatum der Patenschaft und genau EINE neue
* System-Revision "vom … bis …" — auch bei doppeltem Ende.
*/
import { beforeEach, describe, expect, test, vi } from "vitest";
const m = vi.hoisted(() => ({
db: {
sponsorshipSubscription: { updateMany: vi.fn(), findUnique: vi.fn() },
sponsorship: { updateMany: vi.fn() },
sponsorshipRevision: { findUnique: vi.fn(), create: vi.fn() },
$transaction: vi.fn(),
},
renderCertificateDocumentPdf: vi.fn(),
storeCertificatePdf: vi.fn(),
}));
vi.mock("@/lib/prisma", () => ({ prisma: m.db }));
vi.mock("@/lib/sponsorship/certificate-document", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/sponsorship/certificate-document")>();
return { ...actual, renderCertificateDocumentPdf: m.renderCertificateDocumentPdf };
});
vi.mock("@/lib/sponsorship/storage", () => ({
certificateRelativePath: ({ number, revision, year }: { number: string; revision: number; year: number }) =>
`${year}/${number}/r${revision}.pdf`,
storeCertificatePdf: m.storeCertificatePdf,
}));
const { endSubscription, ENDED_REVISION_REASON } = await import("./ending");
const ENDED_AT = new Date("2026-12-16T10:00:00.000Z");
const PERSONALIZATION = {
beeName: "Summsi",
recipientName: "Erika Mustermann",
occasion: "SELBST",
startDate: "2026-07-16",
};
function subscription(overrides: Record<string, unknown> = {}) {
return {
status: "BEENDET",
endedAt: ENDED_AT,
payments: [
{ paidAt: new Date("2026-07-16T10:00:00.000Z"), periodEnd: new Date("2026-08-16T10:00:00.000Z"), reversedAt: null },
{ paidAt: new Date("2026-11-16T10:00:00.000Z"), periodEnd: new Date("2026-12-16T10:00:00.000Z"), reversedAt: null },
],
sponsorship: {
id: "sp_1",
number: "WH-P-2026-00042",
locale: "de",
startDate: new Date("2026-07-16T00:00:00.000Z"),
endDate: null,
currentRevision: 1,
createdAt: new Date("2026-07-16T10:05:00.000Z"),
},
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
m.db.$transaction.mockImplementation(async (fn: (tx: typeof m.db) => unknown) => fn(m.db));
m.db.sponsorshipSubscription.findUnique.mockResolvedValue(subscription());
m.db.sponsorshipRevision.findUnique.mockResolvedValue({
personalization: PERSONALIZATION,
templateVersion: "wabe-classic@1",
term: { kind: "OPEN", start: "2026-07-16" },
});
m.db.sponsorship.updateMany.mockResolvedValue({ count: 1 });
m.renderCertificateDocumentPdf.mockResolvedValue({ pdf: Buffer.from("pdf"), text: { beeName: "Summsi" } });
m.storeCertificatePdf.mockResolvedValue({ sha256: "abc" });
});
describe("endSubscription", () => {
test("beendet das Abo und legt die ENDED-Revision mit dem letzten bezahlten Tag an", async () => {
await endSubscription("sub_local", ENDED_AT);
expect(m.db.sponsorshipSubscription.updateMany).toHaveBeenCalledWith({
where: { id: "sub_local", status: { not: "BEENDET" } },
data: { status: "BEENDET", endedAt: ENDED_AT, delinquentSince: null },
});
const [document, creationDate] = m.renderCertificateDocumentPdf.mock.calls[0];
expect(document).toMatchObject({
term: { kind: "ENDED", start: "2026-07-16", end: "2026-12-15" },
number: "WH-P-2026-00042",
templateVersion: "wabe-classic@1",
});
expect(creationDate).toEqual(ENDED_AT);
expect(m.db.sponsorship.updateMany).toHaveBeenCalledWith({
where: { id: "sp_1", currentRevision: 1 },
data: { currentRevision: 2, endDate: new Date("2026-12-15T00:00:00.000Z") },
});
expect(m.db.sponsorshipRevision.create.mock.calls[0][0].data).toMatchObject({
revision: 2,
pdfPath: "2026/WH-P-2026-00042/r2.pdf",
createdByLabel: "System",
reason: ENDED_REVISION_REASON,
});
});
test("ein zweites Ende legt keine weitere Revision an", async () => {
m.db.sponsorshipRevision.findUnique.mockResolvedValue({
personalization: PERSONALIZATION,
templateVersion: "wabe-classic@1",
term: { kind: "ENDED", start: "2026-07-16", end: "2026-12-15" },
});
await endSubscription("sub_local", ENDED_AT);
expect(m.renderCertificateDocumentPdf).not.toHaveBeenCalled();
expect(m.db.sponsorshipRevision.create).not.toHaveBeenCalled();
});
test("ohne erzeugte Urkunde wird nur das Ende gesetzt — die erste Revision entsteht dann gleich mit Enddatum", async () => {
m.db.sponsorshipSubscription.findUnique.mockResolvedValue(
subscription({ sponsorship: { ...subscription().sponsorship, currentRevision: 0 } })
);
await endSubscription("sub_local", ENDED_AT);
expect(m.db.sponsorship.updateMany).toHaveBeenCalledWith({
where: { id: "sp_1", endDate: null },
data: { endDate: new Date("2026-12-15T00:00:00.000Z") },
});
expect(m.renderCertificateDocumentPdf).not.toHaveBeenCalled();
});
test("nie bezahlt: keine Patenschaft, nichts zu beenden", async () => {
m.db.sponsorshipSubscription.findUnique.mockResolvedValue(subscription({ sponsorship: null }));
await endSubscription("sub_local", ENDED_AT);
expect(m.db.sponsorship.updateMany).not.toHaveBeenCalled();
});
test("ein paralleler Lauf war schneller: keine zweite Revision", async () => {
m.db.sponsorship.updateMany.mockResolvedValue({ count: 0 });
await endSubscription("sub_local", ENDED_AT);
expect(m.db.sponsorshipRevision.create).not.toHaveBeenCalled();
});
});
+133
View File
@@ -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,
},
});
});
}
@@ -0,0 +1,198 @@
/**
* Lieferanspruch im Abo — die Regel, die verhindert, dass jemand mehr Honig
* bekommt, als er bezahlt hat (Spec "Schutz vor Verlust", Review Focus 4).
*/
import { describe, expect, test } from "vitest";
import {
computeDeliveryEntitlement,
countsFromFor,
deliveryProgress,
type EntitlementPayment,
} from "./entitlement";
const DAY = 24 * 60 * 60 * 1000;
const NOW = new Date("2026-10-16T12:00:00.000Z");
function payment(id: string, daysAgo: number, overrides: Partial<EntitlementPayment> = {}): EntitlementPayment {
const paidAt = new Date(NOW.getTime() - daysAgo * DAY);
return { id, paidAt, countsFrom: paidAt, reversedAt: null, deliveryId: null, ...overrides };
}
const threePaid = [payment("p1", 70), payment("p2", 40), payment("p3", 10)];
describe("computeDeliveryEntitlement – vierteljaehrlich", () => {
test("drei bezahlte, unverbrauchte Monate ergeben eine Lieferung und verbrauchen genau diese drei", () => {
const result = computeDeliveryEntitlement({
cycle: "VIERTELJAEHRLICH",
payments: threePaid,
status: "AKTIV",
now: NOW,
harvestWindows: [],
});
expect(result).toEqual({ due: true, paymentIds: ["p1", "p2", "p3"] });
});
test("zwei bezahlte Monate ergeben keine Lieferung (Kuendigung nach zwei Monaten: kein Honig)", () => {
const result = computeDeliveryEntitlement({
cycle: "VIERTELJAEHRLICH",
payments: threePaid.slice(0, 2),
status: "GEKUENDIGT",
now: NOW,
harvestWindows: [],
});
expect(result.due).toBe(false);
});
test("ein gekuendigtes Abo mit drei bezahlten Monaten bekommt die bezahlte Lieferung", () => {
const result = computeDeliveryEntitlement({
cycle: "VIERTELJAEHRLICH",
payments: threePaid,
status: "GEKUENDIGT",
now: NOW,
harvestWindows: [],
});
expect(result.due).toBe(true);
});
test("zurueckgebuchte Zahlungen zaehlen nicht", () => {
const result = computeDeliveryEntitlement({
cycle: "VIERTELJAEHRLICH",
payments: [payment("p1", 70), payment("p2", 40, { reversedAt: NOW }), payment("p3", 10)],
status: "AKTIV",
now: NOW,
harvestWindows: [],
});
expect(result.due).toBe(false);
});
test("bereits verbrauchte Zahlungen zaehlen nicht ein zweites Mal", () => {
const result = computeDeliveryEntitlement({
cycle: "VIERTELJAEHRLICH",
payments: [payment("p1", 70, { deliveryId: "d1" }), payment("p2", 40), payment("p3", 10)],
status: "AKTIV",
now: NOW,
harvestWindows: [],
});
expect(result.due).toBe(false);
});
test("eine SEPA-Zahlung in der Neukunden-Frist zaehlt erst ab countsFrom", () => {
const held = [payment("p1", 70, { countsFrom: new Date(NOW.getTime() + DAY) }), payment("p2", 40), payment("p3", 10)];
const input = { cycle: "VIERTELJAEHRLICH" as const, payments: held, status: "AKTIV" as const, harvestWindows: [] };
expect(computeDeliveryEntitlement({ ...input, now: NOW }).due).toBe(false);
expect(computeDeliveryEntitlement({ ...input, now: new Date(NOW.getTime() + 2 * DAY) }).due).toBe(true);
});
test("bei sechs offenen Monaten werden zuerst die drei aeltesten verbraucht", () => {
const six = [6, 5, 4, 3, 2, 1].map((n) => payment(`p${n}`, n * 30));
const result = computeDeliveryEntitlement({
cycle: "VIERTELJAEHRLICH",
payments: [...six].reverse(),
status: "AKTIV",
now: NOW,
harvestWindows: [],
});
expect(result).toEqual({ due: true, paymentIds: ["p6", "p5", "p4"] });
});
test.each(["IM_VERZUG", "BEENDET", "AUSSTEHEND"] as const)("im Status %s entsteht nie eine Lieferung", (status) => {
const result = computeDeliveryEntitlement({
cycle: "VIERTELJAEHRLICH",
payments: threePaid,
status,
now: NOW,
harvestWindows: [],
});
expect(result.due).toBe(false);
});
});
describe("computeDeliveryEntitlement – nach der Ernte", () => {
const sixPaid = [6, 5, 4, 3, 2, 1].map((n) => payment(`p${n}`, n * 30));
const openWindow = { startsOn: new Date("2026-10-01T00:00:00.000Z"), endsOn: new Date("2026-10-16T00:00:00.000Z") };
const pastWindow = { startsOn: new Date("2026-06-01T00:00:00.000Z"), endsOn: new Date("2026-06-30T00:00:00.000Z") };
test("im Erntefenster mit sechs bezahlten Monaten: Lieferung, alle sechs verbraucht", () => {
const result = computeDeliveryEntitlement({
cycle: "ERNTE",
payments: sixPaid,
status: "AKTIV",
now: NOW,
harvestWindows: [pastWindow, openWindow],
});
expect(result.due).toBe(true);
if (result.due) expect([...result.paymentIds].sort()).toEqual(["p1", "p2", "p3", "p4", "p5", "p6"]);
});
test("das Fenster gilt bis zum Ende seines letzten Tages", () => {
const lateEvening = new Date("2026-10-16T23:30:00.000Z");
const result = computeDeliveryEntitlement({
cycle: "ERNTE",
payments: sixPaid,
status: "AKTIV",
now: lateEvening,
harvestWindows: [openWindow],
});
expect(result.due).toBe(true);
});
test("ausserhalb eines Fensters keine Lieferung", () => {
const result = computeDeliveryEntitlement({
cycle: "ERNTE",
payments: sixPaid,
status: "AKTIV",
now: NOW,
harvestWindows: [pastWindow],
});
expect(result.due).toBe(false);
});
test("fuenf bezahlte Monate reichen im Fenster nicht", () => {
const result = computeDeliveryEntitlement({
cycle: "ERNTE",
payments: sixPaid.slice(1),
status: "AKTIV",
now: NOW,
harvestWindows: [openWindow],
});
expect(result.due).toBe(false);
});
});
describe("countsFromFor – SEPA-Neukunden-Frist", () => {
const paidAt = new Date("2026-10-01T08:00:00.000Z");
test("erste SEPA-Zahlung eines Kontos zaehlt erst nach der Frist", () => {
expect(countsFromFor({ paidAt, paymentMethodType: "sepa_debit", isFirstPaymentOfCustomer: true, holdDays: 56 })).toEqual(
new Date(paidAt.getTime() + 56 * DAY)
);
});
test("weitere SEPA-Zahlungen zaehlen sofort", () => {
expect(countsFromFor({ paidAt, paymentMethodType: "sepa_debit", isFirstPaymentOfCustomer: false, holdDays: 56 })).toEqual(paidAt);
});
test("Karte zaehlt auch bei der ersten Zahlung sofort", () => {
expect(countsFromFor({ paidAt, paymentMethodType: "card", isFirstPaymentOfCustomer: true, holdDays: 56 })).toEqual(paidAt);
});
test("Frist 0 schaltet die Regel ab", () => {
expect(countsFromFor({ paidAt, paymentMethodType: "sepa_debit", isFirstPaymentOfCustomer: true, holdDays: 0 })).toEqual(paidAt);
});
});
describe("deliveryProgress", () => {
test("zaehlt bezahlte, unverbrauchte Monate bis zur naechsten Lieferung", () => {
expect(deliveryProgress("VIERTELJAEHRLICH", [payment("p1", 40), payment("p2", 10)])).toEqual({ paid: 2, required: 3 });
});
test("zaehlt zurueckgebuchte und verbrauchte Monate nicht und deckelt beim Bedarf", () => {
const payments = [
payment("p0", 100, { deliveryId: "d1" }),
payment("p1", 70, { reversedAt: NOW }),
...[4, 3, 2, 1].map((n) => payment(`q${n}`, n * 10)),
];
expect(deliveryProgress("VIERTELJAEHRLICH", payments)).toEqual({ paid: 3, required: 3 });
expect(deliveryProgress("ERNTE", payments)).toEqual({ paid: 4, required: 6 });
});
});
@@ -0,0 +1,114 @@
import type { SponsorshipCycle, SubscriptionStatus } from "@/generated/prisma/client";
/**
* Lieferanspruch im Abo (Spec "Schutz vor Verlust: Lieferung erst nach
* Bezahlung").
*
* Der Kunde kann monatlich kuendigen, bekommt aber nie mehr Honig, als er
* bezahlt hat. Deshalb entsteht eine Lieferung nur aus bezahlten,
* nicht zurueckgebuchten und noch nicht verbrauchten Monatszahlungen, die
* bereits "zaehlen" (countsFrom, SEPA-Neukunden-Frist). Jede Lieferung
* verbraucht die Zahlungen, aus denen sie entstand (deliveryId) — ein zweiter
* Lauf findet sie nicht noch einmal.
*
* Bewusst rein und ohne Datenbank: diese Regel ist die Stelle, an der Ware
* ohne Gegenwert das Haus verlassen koennte. Sie muss sich vollstaendig ohne
* Datenbank testen lassen.
*/
/** Vierteljaehrlich: eine Lieferung je drei bezahlte Monate. */
export const QUARTERLY_PAYMENTS = 3;
/** Ernte: frühestens nach sechs bezahlten Monaten seit der letzten Lieferung. */
export const HARVEST_MIN_PAYMENTS = 6;
const DAY_MS = 24 * 60 * 60 * 1000;
export interface EntitlementPayment {
id: string;
paidAt: Date;
countsFrom: Date;
reversedAt: Date | null;
deliveryId: string | null;
}
export interface HarvestWindowRange {
/** Erster Tag (Datum, UTC). */
startsOn: Date;
/** Letzter Tag (Datum, UTC) — gilt bis zu seinem Ende. */
endsOn: Date;
}
export interface EntitlementInput {
cycle: SponsorshipCycle;
payments: readonly EntitlementPayment[];
status: SubscriptionStatus;
now: Date;
harvestWindows: readonly HarvestWindowRange[];
}
export type EntitlementResult =
| { due: true; paymentIds: string[] }
| { due: false; reason: "status" | "payments" | "window" };
/**
* Im Verzug (offene oder zurueckgebuchte Zahlung) ruhen Lieferungen; ein
* beendetes Abo liefert nichts mehr. Gekuendigte Abos liefern, was bis zum
* Ende bezahlt ist.
*/
const DELIVERING_STATUSES: ReadonlySet<SubscriptionStatus> = new Set(["AKTIV", "GEKUENDIGT"]);
/** Zahlungen, die fuer die naechste Lieferung zaehlen, aelteste zuerst. */
function countablePayments(payments: readonly EntitlementPayment[], now: Date | null): EntitlementPayment[] {
return payments
.filter((p) => p.reversedAt === null && p.deliveryId === null && (now === null || p.countsFrom <= now))
.sort((a, b) => a.paidAt.getTime() - b.paidAt.getTime());
}
function isInsideWindow(now: Date, window: HarvestWindowRange): boolean {
const start = Date.UTC(window.startsOn.getUTCFullYear(), window.startsOn.getUTCMonth(), window.startsOn.getUTCDate());
const endExclusive =
Date.UTC(window.endsOn.getUTCFullYear(), window.endsOn.getUTCMonth(), window.endsOn.getUTCDate()) + DAY_MS;
return now.getTime() >= start && now.getTime() < endExclusive;
}
export function computeDeliveryEntitlement(input: EntitlementInput): EntitlementResult {
if (!DELIVERING_STATUSES.has(input.status)) return { due: false, reason: "status" };
const countable = countablePayments(input.payments, input.now);
if (input.cycle === "VIERTELJAEHRLICH") {
if (countable.length < QUARTERLY_PAYMENTS) return { due: false, reason: "payments" };
return { due: true, paymentIds: countable.slice(0, QUARTERLY_PAYMENTS).map((p) => p.id) };
}
if (!input.harvestWindows.some((window) => isInsideWindow(input.now, window))) {
return { due: false, reason: "window" };
}
if (countable.length < HARVEST_MIN_PAYMENTS) return { due: false, reason: "payments" };
return { due: true, paymentIds: countable.map((p) => p.id) };
}
/**
* Ab wann eine Zahlung fuer den Lieferanspruch zaehlt. Bei SEPA kann die Bank
* die Lastschrift acht Wochen lang ohne Grund zurueckholen; die erste
* Abo-Zahlung eines Kontos zaehlt deshalb erst nach dieser Frist (Spec
* "SEPA-Lastschrift und Rueckbuchungen", Punkt 2). Alle weiteren zaehlen
* sofort — dann traegt das bereits laufende Abo das Risiko einer Rueckbuchung.
*/
export function countsFromFor(input: {
paidAt: Date;
paymentMethodType: string | null;
isFirstPaymentOfCustomer: boolean;
holdDays: number;
}): Date {
const held = input.paymentMethodType === "sepa_debit" && input.isFirstPaymentOfCustomer && input.holdDays > 0;
return held ? new Date(input.paidAt.getTime() + input.holdDays * DAY_MS) : input.paidAt;
}
/** Bezahlte Monate seit der letzten Lieferung fuer die Anzeige "x von 3 Monaten bezahlt". */
export function deliveryProgress(
cycle: SponsorshipCycle,
payments: readonly EntitlementPayment[]
): { paid: number; required: number } {
const required = cycle === "VIERTELJAEHRLICH" ? QUARTERLY_PAYMENTS : HARVEST_MIN_PAYMENTS;
return { paid: Math.min(countablePayments(payments, null).length, required), required };
}
@@ -0,0 +1,223 @@
/**
* invoice.paid — die Stelle, an der aus einer Stripe-Zahlung ein bezahlter
* Abo-Monat wird. Geprueft wird vor allem das Verhalten bei doppelt und
* verspaetet zugestellten Ereignissen sowie die SEPA-Neukunden-Frist.
*/
import { beforeEach, describe, expect, test, vi } from "vitest";
import type Stripe from "stripe";
const m = vi.hoisted(() => ({
stripe: {
invoicePayments: { list: vi.fn() },
paymentIntents: { retrieve: vi.fn() },
},
db: {
sponsorshipSubscription: { findUnique: vi.fn(), findUniqueOrThrow: vi.fn(), updateMany: vi.fn() },
subscriptionPayment: { findUnique: vi.fn(), findUniqueOrThrow: vi.fn(), create: vi.fn(), count: vi.fn() },
},
getSepaHoldDays: vi.fn(),
ensureSubscriptionSponsorship: vi.fn(),
fulfillSubscriptionPayment: vi.fn(),
}));
vi.mock("@/lib/stripe", () => ({ getStripeClient: () => m.stripe }));
vi.mock("@/lib/prisma", () => ({ prisma: m.db }));
vi.mock("@/lib/sponsorship/settings", () => ({ getSepaHoldDays: m.getSepaHoldDays }));
vi.mock("@/lib/sponsorship-subscription/sponsorship", () => ({
ensureSubscriptionSponsorship: m.ensureSubscriptionSponsorship,
}));
vi.mock("@/lib/sponsorship-subscription/monthly-order", () => ({
fulfillSubscriptionPayment: m.fulfillSubscriptionPayment,
}));
const { handleInvoicePaid } = await import("./invoice-paid");
const DAY = 24 * 60 * 60 * 1000;
const PAID_AT = 1_790_000_000; // Sekunden
const LOCAL = {
id: "sub_local",
customerId: "cust_1",
status: "AKTIV",
stripeSubscriptionId: "sub_stripe",
cancelAt: null,
delinquentSince: null,
startedAt: null,
currentPeriodEnd: null,
};
function invoice(overrides: Partial<Stripe.Invoice> = {}): Stripe.Invoice {
return {
id: "in_1",
amount_paid: 1500,
period_start: PAID_AT,
period_end: PAID_AT,
status_transitions: { paid_at: PAID_AT, finalized_at: null, marked_uncollectible_at: null, voided_at: null },
parent: {
type: "subscription_details",
quote_details: null,
subscription_details: { subscription: "sub_stripe", metadata: { sponsorshipSubscriptionId: "sub_local" } },
},
lines: { data: [{ period: { start: PAID_AT, end: PAID_AT + 30 * 86400 } }] },
...overrides,
} as unknown as Stripe.Invoice;
}
function givenPaymentMethod(type: string): void {
m.stripe.invoicePayments.list.mockResolvedValue({
data: [{ payment: { type: "payment_intent", payment_intent: "pi_1" } }],
});
m.stripe.paymentIntents.retrieve.mockResolvedValue({
payment_method_types: [type],
latest_charge: { payment_method_details: { type } },
});
}
beforeEach(() => {
vi.clearAllMocks();
m.db.sponsorshipSubscription.findUnique.mockResolvedValue(LOCAL);
m.db.sponsorshipSubscription.findUniqueOrThrow.mockResolvedValue({ ...LOCAL, payments: [] });
m.db.subscriptionPayment.findUnique.mockResolvedValue(null);
m.db.subscriptionPayment.count.mockResolvedValue(0);
m.db.subscriptionPayment.create.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
id: "pay_1",
...data,
}));
m.getSepaHoldDays.mockResolvedValue(56);
givenPaymentMethod("card");
});
describe("handleInvoicePaid", () => {
test("legt die Zahlung an, stellt die Patenschaft aus und erzeugt Bestellung und Rechnung", async () => {
await handleInvoicePaid(invoice());
const data = m.db.subscriptionPayment.create.mock.calls[0][0].data;
expect(data).toMatchObject({
subscriptionId: "sub_local",
stripeInvoiceId: "in_1",
stripePaymentIntentId: "pi_1",
paymentMethodType: "card",
amountCents: 1500,
paidAt: new Date(PAID_AT * 1000),
periodEnd: new Date((PAID_AT + 30 * 86400) * 1000),
countsFrom: new Date(PAID_AT * 1000),
});
expect(m.ensureSubscriptionSponsorship).toHaveBeenCalledWith("sub_local");
expect(m.fulfillSubscriptionPayment).toHaveBeenCalledWith("pay_1", { isFirstPayment: true });
});
test("die erste SEPA-Zahlung eines Kontos zaehlt erst nach der Neukunden-Frist", async () => {
givenPaymentMethod("sepa_debit");
await handleInvoicePaid(invoice());
expect(m.db.subscriptionPayment.create.mock.calls[0][0].data.countsFrom).toEqual(new Date(PAID_AT * 1000 + 56 * DAY));
});
test("eine weitere SEPA-Zahlung des Kontos zaehlt sofort", async () => {
givenPaymentMethod("sepa_debit");
m.db.subscriptionPayment.count.mockResolvedValue(2);
await handleInvoicePaid(invoice());
expect(m.db.subscriptionPayment.create.mock.calls[0][0].data.countsFrom).toEqual(new Date(PAID_AT * 1000));
});
test("ein doppelt zugestelltes Ereignis legt keine zweite Zahlung an und fragt Stripe nicht erneut", async () => {
m.db.subscriptionPayment.findUnique.mockResolvedValue({
id: "pay_1",
paidAt: new Date(PAID_AT * 1000),
periodEnd: new Date((PAID_AT + 30 * 86400) * 1000),
});
await handleInvoicePaid(invoice());
expect(m.db.subscriptionPayment.create).not.toHaveBeenCalled();
expect(m.stripe.invoicePayments.list).not.toHaveBeenCalled();
// Bestellung/Rechnung werden nachgeholt, falls ein frueherer Lauf abbrach —
// dort ist jeder Schritt selbst idempotent.
expect(m.fulfillSubscriptionPayment).toHaveBeenCalledWith("pay_1", expect.anything());
});
test("zwei gleichzeitige Laeufe: der Verlierer uebernimmt die Zahlung des Gewinners", async () => {
m.db.subscriptionPayment.create.mockRejectedValue(Object.assign(new Error("Unique"), { code: "P2002" }));
m.db.subscriptionPayment.findUniqueOrThrow.mockResolvedValue({
id: "pay_winner",
paidAt: new Date(PAID_AT * 1000),
periodEnd: new Date((PAID_AT + 30 * 86400) * 1000),
});
await handleInvoicePaid(invoice());
expect(m.fulfillSubscriptionPayment).toHaveBeenCalledWith("pay_winner", expect.anything());
});
test("Rechnungen ohne Abo (oder fremder Abos) werden ignoriert", async () => {
await handleInvoicePaid(invoice({ parent: null }));
m.db.sponsorshipSubscription.findUnique.mockResolvedValue(null);
await handleInvoicePaid(
invoice({
parent: {
type: "subscription_details",
quote_details: null,
subscription_details: { subscription: "sub_other", metadata: {} },
},
} as Partial<Stripe.Invoice>)
);
expect(m.db.subscriptionPayment.create).not.toHaveBeenCalled();
expect(m.fulfillSubscriptionPayment).not.toHaveBeenCalled();
});
test("bestaetigt den Abschluss: AUSSTEHEND wird AKTIV", async () => {
m.db.sponsorshipSubscription.findUniqueOrThrow.mockResolvedValue({ ...LOCAL, status: "AUSSTEHEND", payments: [] });
await handleInvoicePaid(invoice());
expect(m.db.sponsorshipSubscription.updateMany).toHaveBeenCalledWith({
where: { id: "sub_local", status: "AUSSTEHEND" },
data: expect.objectContaining({ status: "AKTIV", startedAt: new Date(PAID_AT * 1000) }),
});
});
test("eine nach Verzugsbeginn bezahlte Rechnung hebt den Verzug auf", async () => {
m.db.sponsorshipSubscription.findUniqueOrThrow.mockResolvedValue({
...LOCAL,
status: "IM_VERZUG",
delinquentSince: new Date(PAID_AT * 1000 - DAY),
payments: [],
});
await handleInvoicePaid(invoice());
expect(m.db.sponsorshipSubscription.updateMany.mock.calls[0][0].data).toMatchObject({
status: "AKTIV",
delinquentSince: null,
});
});
test("ein verspaetet zugestelltes invoice.paid einer aelteren Rechnung hebt einen juengeren Verzug nicht auf", async () => {
m.db.sponsorshipSubscription.findUniqueOrThrow.mockResolvedValue({
...LOCAL,
status: "IM_VERZUG",
delinquentSince: new Date(PAID_AT * 1000 + DAY),
payments: [],
});
await handleInvoicePaid(invoice());
expect(m.db.sponsorshipSubscription.updateMany.mock.calls[0][0].data.status).toBe("IM_VERZUG");
});
test("eine offene Rueckbuchung bleibt bestehen, auch wenn eine Rechnung bezahlt wird", async () => {
m.db.sponsorshipSubscription.findUniqueOrThrow.mockResolvedValue({
...LOCAL,
status: "IM_VERZUG",
delinquentSince: new Date(PAID_AT * 1000 - DAY),
payments: [{ paidAt: new Date(PAID_AT * 1000 - 40 * DAY), periodEnd: new Date(), reversedAt: new Date(PAID_AT * 1000 + 1000) }],
});
await handleInvoicePaid(invoice());
expect(m.db.sponsorshipSubscription.updateMany.mock.calls[0][0].data.status).toBe("IM_VERZUG");
});
});
@@ -0,0 +1,139 @@
import type Stripe from "stripe";
import { prisma } from "@/lib/prisma";
import { getStripeClient } from "@/lib/stripe";
import { getSepaHoldDays } from "@/lib/sponsorship/settings";
import type { SponsorshipSubscription, SubscriptionPayment } from "@/generated/prisma/client";
import { countsFromFor } from "@/lib/sponsorship-subscription/entitlement";
import { hasOpenDispute } from "@/lib/sponsorship-subscription/billing-rules";
import { findLocalSubscription, isUniqueViolation, stripeIdOf } from "@/lib/sponsorship-subscription/lookup";
import { ensureSubscriptionSponsorship } from "@/lib/sponsorship-subscription/sponsorship";
import { fulfillSubscriptionPayment } from "@/lib/sponsorship-subscription/monthly-order";
/**
* invoice.paid: eine Monatsrechnung ist bezahlt.
*
* 1. SubscriptionPayment anlegen (eindeutig je Stripe-Rechnung — der Schutz
* gegen doppelt zugestellte Ereignisse liegt in der Datenbank).
* 2. Mit der ersten Zahlung die Patenschaft anlegen und die Urkunde ausstellen.
* 3. Wabenhain-Bestellung, Rechnung und Rechnungsmail (monthly-order.ts).
* 4. Status nachziehen: AUSSTEHEND → AKTIV, Verzug aufheben, wenn diese
* Zahlung nach seinem Beginn kam.
*
* Jeder Schritt ist fuer sich wiederholbar. Scheitert einer, wirft der
* Handler — Stripe stellt das Ereignis erneut zu, und der Wiederholungslauf
* setzt dort fort, wo es fehlte.
*/
/** Stripe-Abo und Metadaten-ID einer Abo-Rechnung (seit API 2025-03-31 unter parent.subscription_details). */
export function subscriptionRefOfInvoice(invoice: Stripe.Invoice): { stripeSubscriptionId: string; metadataId: string | null } | null {
const details = invoice.parent?.subscription_details;
const stripeSubscriptionId = stripeIdOf(details?.subscription ?? null);
if (!stripeSubscriptionId) return null;
return { stripeSubscriptionId, metadataId: details?.metadata?.sponsorshipSubscriptionId ?? null };
}
function invoicePeriod(invoice: Stripe.Invoice): { start: Date; end: Date } {
const periods = invoice.lines?.data?.map((line) => line.period) ?? [];
if (periods.length === 0) {
return { start: new Date(invoice.period_start * 1000), end: new Date(invoice.period_end * 1000) };
}
return {
start: new Date(Math.min(...periods.map((p) => p.start)) * 1000),
end: new Date(Math.max(...periods.map((p) => p.end)) * 1000),
};
}
/** PaymentIntent und Zahlart einer bezahlten Rechnung (Karte, SEPA, Wallet …). */
export async function resolveInvoicePayment(
invoiceId: string
): Promise<{ paymentIntentId: string | null; paymentMethodType: string | null }> {
const stripe = getStripeClient();
const payments = await stripe.invoicePayments.list({ invoice: invoiceId, status: "paid", limit: 10 });
const paymentIntentId = stripeIdOf(payments.data.find((p) => p.payment.type === "payment_intent")?.payment.payment_intent);
if (!paymentIntentId) return { paymentIntentId: null, paymentMethodType: null };
const intent = await stripe.paymentIntents.retrieve(paymentIntentId, { expand: ["latest_charge"] });
const charge = intent.latest_charge && typeof intent.latest_charge === "object" ? intent.latest_charge : null;
return {
paymentIntentId,
paymentMethodType: charge?.payment_method_details?.type ?? intent.payment_method_types[0] ?? null,
};
}
async function ensurePaymentRecord(invoice: Stripe.Invoice, local: SponsorshipSubscription): Promise<SubscriptionPayment> {
const existing = await prisma.subscriptionPayment.findUnique({ where: { stripeInvoiceId: invoice.id } });
if (existing) return existing;
const { paymentIntentId, paymentMethodType } = await resolveInvoicePayment(invoice.id);
const paidAt = invoice.status_transitions?.paid_at ? new Date(invoice.status_transitions.paid_at * 1000) : new Date();
const period = invoicePeriod(invoice);
const earlier = await prisma.subscriptionPayment.count({ where: { subscription: { customerId: local.customerId } } });
const countsFrom = countsFromFor({
paidAt,
paymentMethodType,
isFirstPaymentOfCustomer: earlier === 0,
holdDays: await getSepaHoldDays(),
});
try {
return await prisma.subscriptionPayment.create({
data: {
subscriptionId: local.id,
stripeInvoiceId: invoice.id,
stripePaymentIntentId: paymentIntentId,
paymentMethodType,
amountCents: invoice.amount_paid,
paidAt,
periodStart: period.start,
periodEnd: period.end,
countsFrom,
},
});
} catch (error: unknown) {
if (!isUniqueViolation(error)) throw error;
return prisma.subscriptionPayment.findUniqueOrThrow({ where: { stripeInvoiceId: invoice.id } });
}
}
/** Status nach einer Zahlung: Abschluss bestaetigt, Verzug durch eine juengere Zahlung beendet. */
async function markPaid(subscriptionId: string, payment: SubscriptionPayment): Promise<void> {
const local = await prisma.sponsorshipSubscription.findUniqueOrThrow({
where: { id: subscriptionId },
include: { payments: { select: { paidAt: true, periodEnd: true, reversedAt: true } } },
});
if (local.status === "BEENDET") return;
const settledStatus = local.cancelAt ? "GEKUENDIGT" : "AKTIV";
// Ein verspaetet zugestelltes invoice.paid einer AELTEREN Rechnung hebt
// einen juengeren Verzug nicht auf.
const clearsDelinquency =
local.status === "IM_VERZUG" &&
(!local.delinquentSince || payment.paidAt >= local.delinquentSince) &&
!hasOpenDispute(local.payments);
const status = local.status === "AUSSTEHEND" || clearsDelinquency ? settledStatus : local.status;
const currentPeriodEnd =
local.currentPeriodEnd && local.currentPeriodEnd > payment.periodEnd ? local.currentPeriodEnd : payment.periodEnd;
await prisma.sponsorshipSubscription.updateMany({
where: { id: local.id, status: local.status },
data: {
status,
startedAt: local.startedAt ?? payment.paidAt,
currentPeriodEnd,
...(status !== "IM_VERZUG" ? { delinquentSince: null } : {}),
},
});
}
export async function handleInvoicePaid(invoice: Stripe.Invoice): Promise<void> {
const ref = subscriptionRefOfInvoice(invoice);
if (!ref) return;
const local = await findLocalSubscription(ref.stripeSubscriptionId, ref.metadataId);
if (!local) return;
const payment = await ensurePaymentRecord(invoice, local);
await ensureSubscriptionSponsorship(local.id);
const isFirstPayment =
(await prisma.subscriptionPayment.count({ where: { subscriptionId: local.id, paidAt: { lt: payment.paidAt } } })) === 0;
await fulfillSubscriptionPayment(payment.id, { isFirstPayment });
await markPaid(local.id, payment);
}
@@ -0,0 +1,73 @@
import { prisma } from "@/lib/prisma";
import { getInvoiceByNumber } from "@/lib/account-data";
import { generateInvoicePdf } from "@/lib/invoice-pdf";
import { invoiceRelativePath, storeInvoicePdf } from "@/lib/invoice-storage";
import { formatInvoiceNumber } from "@/lib/invoice-number";
import { isLocale, defaultLocale } from "@/i18n/config";
/**
* Rechnung zu einer Abo-Monatsbestellung — dasselbe Verfahren wie fulfillOrder
* im Stripe-Webhook (api/checkout/webhook/route.ts), hier bewusst als kleine
* Kopie statt eines Umbaus jener Datei, deren Verhalten unveraendert bleiben
* soll: Nummer und Rechnung in EINER Transaktion mit Zeilensperre auf dem
* Jahreszaehler, Hochzaehlen nur beim tatsaechlichen Anlegen (keine Luecken in
* der Nummernfolge, § 14 Abs. 4 Nr. 4 UStG).
*/
export interface IssuedInvoice {
id: string;
invoiceNumber: string;
/** true = in diesem Lauf angelegt (nur dann geht die Rechnungsmail hinaus). */
created: boolean;
}
export async function issueInvoiceForOrder(orderId: string, totalCents: number): Promise<IssuedInvoice> {
return prisma.$transaction(async (tx) => {
const existing = await tx.invoice.findUnique({ where: { orderId }, select: { id: true, invoiceNumber: true } });
if (existing) return { ...existing, created: false };
const year = new Date().getFullYear();
const counter = await tx.invoiceCounter.upsert({
where: { year },
create: { year, lastNumber: 1 },
update: { lastNumber: { increment: 1 } },
select: { lastNumber: true },
});
const invoice = await tx.invoice.create({
data: { orderId, invoiceNumber: formatInvoiceNumber(year, counter.lastNumber), totalCents },
select: { id: true, invoiceNumber: true },
});
return { ...invoice, created: true };
});
}
/**
* PDF erzeugen und ablegen (wie storeInvoiceDocument im Webhook). Fehler
* ergeben null statt einer Ausnahme: die Rechnung steht, und
* /rechnung/<nummer>/pdf erzeugt das Dokument beim Abruf neu.
*/
export async function storeInvoiceDocument(invoiceNumber: string): Promise<Buffer | null> {
try {
const invoice = await getInvoiceByNumber(invoiceNumber);
if (!invoice) return null;
const pdf = await generateInvoicePdf({
locale: isLocale(invoice.order.locale) ? invoice.order.locale : defaultLocale,
invoiceNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt,
totalCents: invoice.totalCents,
order: invoice.order,
});
const relativePath = invoiceRelativePath({
year: invoice.issuedAt.getFullYear(),
lastName: invoice.order.lastName,
firstName: invoice.order.firstName,
invoiceNumber: invoice.invoiceNumber,
});
await storeInvoicePdf(relativePath, pdf);
await prisma.invoice.update({ where: { id: invoice.id }, data: { pdfPath: relativePath } });
return pdf;
} catch (error: unknown) {
console.error(`[abo] Rechnung ${invoiceNumber} konnte nicht abgelegt werden:`, error);
return null;
}
}
@@ -0,0 +1,86 @@
/** Verzugslauf: Rueckbuchung ohne Ausgleich nach 14 Tagen beendet das Abo — bei Stripe und hier. */
import { beforeEach, describe, expect, test, vi } from "vitest";
const m = vi.hoisted(() => ({
db: { sponsorshipSubscription: { findMany: vi.fn() } },
stripe: { subscriptions: { retrieve: vi.fn(), cancel: vi.fn() } },
endSubscription: vi.fn(),
}));
vi.mock("@/lib/prisma", () => ({ prisma: m.db }));
vi.mock("@/lib/stripe", () => ({ getStripeClient: () => m.stripe }));
vi.mock("@/lib/sponsorship-subscription/ending", () => ({
endSubscription: m.endSubscription,
ensureEndedCertificate: vi.fn(),
}));
vi.mock("@/lib/sponsorship-subscription/deliveries", () => ({ createDueDeliveries: vi.fn() }));
const { endExpiredDisputes } = await import("./jobs");
const NOW = new Date("2026-10-30T12:00:00.000Z");
const DAY = 24 * 60 * 60 * 1000;
function candidate(overrides: Record<string, unknown> = {}) {
return {
id: "sub_local",
status: "IM_VERZUG",
delinquentSince: new Date(NOW.getTime() - 15 * DAY),
stripeSubscriptionId: "sub_stripe",
payments: [
{ paidAt: new Date(NOW.getTime() - 40 * DAY), periodEnd: new Date(NOW.getTime() - 10 * DAY), reversedAt: new Date(NOW.getTime() - 15 * DAY) },
],
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "info").mockImplementation(() => {});
m.stripe.subscriptions.retrieve.mockResolvedValue({ status: "active" });
});
describe("endExpiredDisputes", () => {
test("beendet das Abo zuerst bei Stripe, dann hier", async () => {
m.db.sponsorshipSubscription.findMany.mockResolvedValue([candidate()]);
expect(await endExpiredDisputes(NOW)).toBe(1);
expect(m.stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_stripe", {}, expect.anything());
expect(m.endSubscription).toHaveBeenCalledWith("sub_local", NOW);
expect(m.stripe.subscriptions.cancel.mock.invocationCallOrder[0]).toBeLessThan(
m.endSubscription.mock.invocationCallOrder[0]
);
});
test("ein bei Stripe schon beendetes Abo wird nicht erneut gekuendigt, aber hier beendet", async () => {
m.db.sponsorshipSubscription.findMany.mockResolvedValue([candidate()]);
m.stripe.subscriptions.retrieve.mockResolvedValue({ status: "canceled" });
await endExpiredDisputes(NOW);
expect(m.stripe.subscriptions.cancel).not.toHaveBeenCalled();
expect(m.endSubscription).toHaveBeenCalled();
});
test("Verzug ohne Rueckbuchung (fehlgeschlagene Zahlung) bleibt Stripes Sache", async () => {
m.db.sponsorshipSubscription.findMany.mockResolvedValue([
candidate({ payments: [{ paidAt: new Date(NOW.getTime() - 40 * DAY), periodEnd: NOW, reversedAt: null }] }),
]);
expect(await endExpiredDisputes(NOW)).toBe(0);
expect(m.stripe.subscriptions.cancel).not.toHaveBeenCalled();
});
test("eine nach der Rueckbuchung bezahlte Rechnung verhindert das Ende", async () => {
m.db.sponsorshipSubscription.findMany.mockResolvedValue([
candidate({
payments: [
...candidate().payments,
{ paidAt: new Date(NOW.getTime() - 2 * DAY), periodEnd: new Date(NOW.getTime() + 28 * DAY), reversedAt: null },
],
}),
]);
expect(await endExpiredDisputes(NOW)).toBe(0);
});
});
+81
View File
@@ -0,0 +1,81 @@
import { prisma } from "@/lib/prisma";
import { getStripeClient } from "@/lib/stripe";
import { DISPUTE_GRACE_DAYS, isDisputeDelinquencyExpired } from "@/lib/sponsorship-subscription/billing-rules";
import { createDueDeliveries } from "@/lib/sponsorship-subscription/deliveries";
import { endSubscription, ensureEndedCertificate } from "@/lib/sponsorship-subscription/ending";
/**
* Abo-Aufgaben im Hintergrundtakt (lib/background-jobs.ts, alle zehn Minuten):
*
* 1. faellige Honiglieferungen anlegen (deliveries.ts),
* 2. Abos nach einer Rueckbuchung ohne Ausgleich nach 14 Tagen beenden,
* 3. liegengebliebene Enden nachholen (Urkunde "vom … bis …").
*
* Jede Aufgabe ist idempotent und fuer sich abgesichert: ein Fehler in einer
* haelt die anderen nicht auf.
*/
const DAY_MS = 24 * 60 * 60 * 1000;
async function runStep(name: string, step: () => Promise<void>): Promise<void> {
try {
await step();
} catch (error: unknown) {
console.error(`[abo] Hintergrundaufgabe "${name}" fehlgeschlagen:`, error);
}
}
/** Beendet das Stripe-Abo sofort — ein bereits beendetes bleibt, wie es ist. */
async function cancelAtStripe(stripeSubscriptionId: string): Promise<void> {
const stripe = getStripeClient();
const current = await stripe.subscriptions.retrieve(stripeSubscriptionId);
if (current.status === "canceled" || current.status === "incomplete_expired") return;
await stripe.subscriptions.cancel(stripeSubscriptionId, {}, { idempotencyKey: `wabenhain-abo-dispute-end-${stripeSubscriptionId}` });
}
export async function endExpiredDisputes(now: Date): Promise<number> {
const candidates = await prisma.sponsorshipSubscription.findMany({
where: { status: "IM_VERZUG", delinquentSince: { lte: new Date(now.getTime() - DISPUTE_GRACE_DAYS * DAY_MS) } },
select: {
id: true,
status: true,
delinquentSince: true,
stripeSubscriptionId: true,
payments: { select: { paidAt: true, periodEnd: true, reversedAt: true } },
},
});
let ended = 0;
for (const candidate of candidates) {
if (!isDisputeDelinquencyExpired({ ...candidate, now })) continue;
// Erst bei Stripe beenden: sonst liefe die Abbuchung weiter, waehrend
// das Abo hier schon beendet ist.
if (candidate.stripeSubscriptionId) await cancelAtStripe(candidate.stripeSubscriptionId);
await endSubscription(candidate.id, now);
console.info(`[abo] Abo ${candidate.id} nach Rueckbuchung ohne Ausgleich beendet.`);
ended++;
}
return ended;
}
/** Enden, deren Urkunde noch fehlt (z.B. Lauf nach dem Statuswechsel abgebrochen). */
async function repairEndedCertificates(): Promise<void> {
const open = await prisma.sponsorshipSubscription.findMany({
where: { status: "BEENDET", sponsorship: { is: { endDate: null } } },
select: { id: true },
take: 20,
});
for (const { id } of open) await ensureEndedCertificate(id);
}
export async function runSubscriptionJobs(now: Date): Promise<void> {
await runStep("Honiglieferungen", async () => {
const result = await createDueDeliveries(now);
if (result.created + result.deferred > 0) {
console.info(`[abo] Honiglieferungen: ${result.created} angelegt, ${result.deferred} zurueckgestellt.`);
}
});
await runStep("Verzug nach Rueckbuchung", async () => {
await endExpiredDisputes(now);
});
await runStep("Urkunden beendeter Abos", repairEndedCertificates);
}
+105
View File
@@ -0,0 +1,105 @@
import { randomBytes } from "node:crypto";
import { prisma } from "@/lib/prisma";
import type { SponsorshipSubscription } from "@/generated/prisma/client";
/**
* Gemeinsame Bausteine der Abo-Handler: Stripe-IDs lesen, das lokale Abo zu
* einem Stripe-Abo finden, Kontakt- und Lieferdaten einer Monats- oder
* Lieferbestellung aus dem Konto bilden.
*/
/** Die ID eines Stripe-Objekts, gleich ob es als ID oder eingebettet kommt. */
export function stripeIdOf(value: string | { id: string } | null | undefined): string | null {
if (!value) return null;
return typeof value === "string" ? value : value.id;
}
/**
* Lokales Abo zu einem Stripe-Abo. Erste Wahl ist die gespeicherte
* stripeSubscriptionId. Kommt ein Ereignis vor checkout.session.completed an
* (Stripe garantiert keine Reihenfolge), ist sie noch leer — dann hilft die
* beim Checkout gesetzte Metadaten-ID, und die Verknuepfung wird hier
* nachgeholt. Die Metadaten sind nur dieser Rueckfallweg, nie die erste Wahl.
*/
export async function findLocalSubscription(
stripeSubscriptionId: string,
metadataId: string | null | undefined
): Promise<SponsorshipSubscription | null> {
const linked = await prisma.sponsorshipSubscription.findUnique({ where: { stripeSubscriptionId } });
if (linked) return linked;
if (!metadataId) return null;
const candidate = await prisma.sponsorshipSubscription.findUnique({ where: { id: metadataId } });
if (!candidate) {
console.error(`[abo] Stripe-Abo ${stripeSubscriptionId} verweist auf das unbekannte Abo ${metadataId}.`);
return null;
}
if (candidate.stripeSubscriptionId && candidate.stripeSubscriptionId !== stripeSubscriptionId) {
console.error(
`[abo] Abo ${metadataId} ist bereits mit ${candidate.stripeSubscriptionId} verknuepft, Ereignis betrifft ${stripeSubscriptionId} — nicht zugeordnet.`
);
return null;
}
await prisma.sponsorshipSubscription.updateMany({
where: { id: candidate.id, stripeSubscriptionId: null },
data: { stripeSubscriptionId },
});
return prisma.sponsorshipSubscription.findUnique({ where: { id: candidate.id } });
}
/** Wie im Checkout: kryptografisch zufaellig, das @unique faengt den Rest ab. */
export function generateOrderNumber(now: Date = new Date()): string {
return `WH-${now.getFullYear()}-${randomBytes(4).toString("hex").toUpperCase()}`;
}
export interface OrderContact {
email: string;
firstName: string;
lastName: string;
phone: string | null;
street: string;
zip: string;
city: string;
country: string;
packstationNumber: string | null;
postNumber: string | null;
/** false = keine Standardadresse hinterlegt (Felder leer wie bei Abholbestellungen). */
hasAddress: boolean;
}
/**
* Kontakt- und Adressdaten fuer eine Bestellung aus dem Konto: die
* Standardadresse, falls vorhanden, sonst leere Adressfelder wie bei
* Abholbestellungen.
*/
export async function orderContactForCustomer(customerId: string): Promise<OrderContact> {
const customer = await prisma.customer.findUniqueOrThrow({
where: { id: customerId },
select: {
email: true,
firstName: true,
lastName: true,
phone: true,
addresses: { where: { isDefault: true }, take: 1 },
},
});
const address = customer.addresses[0];
return {
email: customer.email,
firstName: address?.firstName ?? customer.firstName,
lastName: address?.lastName ?? customer.lastName,
phone: customer.phone,
street: address?.street ?? "",
zip: address?.zip ?? "",
city: address?.city ?? "",
country: address?.country ?? "DE",
packstationNumber: address?.packstationNumber ?? null,
postNumber: address?.postNumber ?? null,
hasAddress: Boolean(address),
};
}
/** Eindeutigkeitsverletzung (P2002) — Zeichen dafuer, dass ein paralleler Lauf schneller war. */
export function isUniqueViolation(error: unknown): boolean {
return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "P2002";
}
@@ -0,0 +1,156 @@
/**
* Monatsbestellung, Rechnung und Rechnungsmail zu einer bezahlten
* Abo-Rechnung — genau eine Bestellung, genau eine Rechnung, genau eine Mail,
* auch wenn invoice.paid mehrfach ankommt.
*/
import { beforeEach, describe, expect, test, vi } from "vitest";
const m = vi.hoisted(() => ({
db: {
subscriptionPayment: { findUniqueOrThrow: vi.fn(), updateMany: vi.fn() },
order: { create: vi.fn(), findUniqueOrThrow: vi.fn() },
customer: { findUniqueOrThrow: vi.fn() },
$transaction: vi.fn(),
},
issueInvoiceForOrder: vi.fn(),
storeInvoiceDocument: vi.fn(),
sendMail: vi.fn(),
sendInvoiceCopy: vi.fn(),
}));
vi.mock("@/lib/prisma", () => ({ prisma: m.db }));
vi.mock("@/lib/mail", () => ({ sendMail: m.sendMail }));
vi.mock("@/lib/invoice-mail", () => ({ sendInvoiceCopy: m.sendInvoiceCopy }));
vi.mock("@/lib/url", () => ({ getBaseUrl: () => "https://wabenhain.test" }));
vi.mock("@/lib/sponsorship-subscription/invoice", () => ({
issueInvoiceForOrder: m.issueInvoiceForOrder,
storeInvoiceDocument: m.storeInvoiceDocument,
}));
vi.mock("@/lib/sponsorship-subscription/stripe-prices", () => ({ syncOfferStripePrice: vi.fn() }));
vi.mock("@/lib/sponsorship-subscription/emails", () => ({
subscriptionInvoiceEmail: vi.fn(async () => ({ subject: "Rechnung", html: "<p></p>", text: "" })),
}));
const { fulfillSubscriptionPayment } = await import("./monthly-order");
function payment(overrides: Record<string, unknown> = {}) {
return {
id: "pay_1",
subscriptionId: "sub_local",
orderId: null as string | null,
stripePaymentIntentId: "pi_1",
amountCents: 1500,
paidAt: new Date("2026-10-16T10:00:00.000Z"),
periodStart: new Date("2026-10-16T10:00:00.000Z"),
periodEnd: new Date("2026-11-16T10:00:00.000Z"),
subscription: {
customerId: "cust_1",
offerId: "offer_1",
locale: "de",
offer: {
name: "Vierteljährlich",
honeyShareCentsPerMonth: 600,
product: { id: "carrier_1", category: "PATENSCHAFT", vatRatePercent: 19 },
},
},
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
process.env.VAT_RATE_PERCENT_HONIG = "7";
m.db.$transaction.mockImplementation(async (fn: (tx: typeof m.db) => unknown) => fn(m.db));
m.db.subscriptionPayment.findUniqueOrThrow.mockResolvedValue(payment());
m.db.subscriptionPayment.updateMany.mockResolvedValue({ count: 1 });
m.db.customer.findUniqueOrThrow.mockResolvedValue({
email: "[email protected]",
firstName: "Erika",
lastName: "Mustermann",
phone: null,
addresses: [],
});
m.db.order.create.mockResolvedValue({ id: "order_1" });
m.db.order.findUniqueOrThrow.mockResolvedValue({
orderNumber: "WH-2026-AABBCCDD",
email: "[email protected]",
firstName: "Erika",
lastName: "Mustermann",
});
m.issueInvoiceForOrder.mockResolvedValue({ id: "inv_1", invoiceNumber: "RE-2026-0007", created: true });
m.storeInvoiceDocument.mockResolvedValue(Buffer.from("pdf"));
m.sendMail.mockResolvedValue(true);
});
describe("fulfillSubscriptionPayment", () => {
test("legt eine digitale, bezahlte Bestellung mit Steueraufteilung an und verschickt die Rechnung", async () => {
await fulfillSubscriptionPayment("pay_1", { isFirstPayment: true });
const data = m.db.order.create.mock.calls[0][0].data;
expect(data).toMatchObject({
status: "BEZAHLT",
fulfillmentMethod: "DIGITAL",
customerId: "cust_1",
totalCents: 1500,
street: "",
zip: "",
city: "",
payment: { create: expect.objectContaining({ status: "SUCCEEDED", amountCents: 1500, stripePaymentIntentId: "pi_1" }) },
});
expect(data.items.create).toEqual([
expect.objectContaining({ productId: "carrier_1", totalCents: 900, vatRatePercent: 19, sponsorshipRole: "ABO", sponsorshipOfferId: "offer_1" }),
expect.objectContaining({
productId: "carrier_1",
productName: "Honiganteil (Vorauszahlung)",
totalCents: 600,
vatRatePercent: 7,
sponsorshipRole: "ABO_HONEY",
}),
]);
expect(m.db.subscriptionPayment.updateMany).toHaveBeenCalledWith({
where: { id: "pay_1", orderId: null },
data: { orderId: "order_1" },
});
expect(m.issueInvoiceForOrder).toHaveBeenCalledWith("order_1", 1500);
expect(m.sendMail).toHaveBeenCalledOnce();
expect(m.sendMail.mock.calls[0][0].attachments[0].filename).toBe("RE-2026-0007.pdf");
expect(m.sendInvoiceCopy).toHaveBeenCalledOnce();
});
test("Wiederholung: keine zweite Bestellung, keine zweite Rechnung, keine zweite Mail", async () => {
m.db.subscriptionPayment.findUniqueOrThrow.mockResolvedValue(payment({ orderId: "order_1" }));
m.issueInvoiceForOrder.mockResolvedValue({ id: "inv_1", invoiceNumber: "RE-2026-0007", created: false });
await fulfillSubscriptionPayment("pay_1", { isFirstPayment: false });
expect(m.db.order.create).not.toHaveBeenCalled();
expect(m.sendMail).not.toHaveBeenCalled();
});
test("zwei gleichzeitige Laeufe: der Verlierer verwirft seine Bestellung und nutzt die des Gewinners", async () => {
m.db.subscriptionPayment.updateMany.mockResolvedValue({ count: 0 });
m.db.subscriptionPayment.findUniqueOrThrow
.mockResolvedValueOnce(payment())
.mockResolvedValueOnce(payment({ orderId: "order_winner" }));
m.issueInvoiceForOrder.mockResolvedValue({ id: "inv_1", invoiceNumber: "RE-2026-0007", created: false });
await fulfillSubscriptionPayment("pay_1", { isFirstPayment: false });
expect(m.issueInvoiceForOrder).toHaveBeenCalledWith("order_winner", 1500);
});
test("uebernimmt die Standardadresse des Kontos", async () => {
m.db.customer.findUniqueOrThrow.mockResolvedValue({
email: "[email protected]",
firstName: "Erika",
lastName: "Mustermann",
phone: null,
addresses: [{ firstName: "Erika", lastName: "Mustermann", street: "Wiesenweg 1", zip: "86450", city: "Altenmünster", country: "DE", packstationNumber: null, postNumber: null }],
});
await fulfillSubscriptionPayment("pay_1", { isFirstPayment: false });
expect(m.db.order.create.mock.calls[0][0].data).toMatchObject({ street: "Wiesenweg 1", zip: "86450", city: "Altenmünster" });
});
});
@@ -0,0 +1,180 @@
import { prisma } from "@/lib/prisma";
import { sendMail } from "@/lib/mail";
import { sendInvoiceCopy } from "@/lib/invoice-mail";
import { getBaseUrl } from "@/lib/url";
import { vatRateForCategory, vatRateForProduct } from "@/lib/vat";
import { isLocale, defaultLocale, type Locale } from "@/i18n/config";
import { formatServicePeriod, monthlyOrderLines } from "@/lib/sponsorship-subscription/billing-rules";
import { generateOrderNumber, orderContactForCustomer } from "@/lib/sponsorship-subscription/lookup";
import { issueInvoiceForOrder, storeInvoiceDocument } from "@/lib/sponsorship-subscription/invoice";
import { syncOfferStripePrice } from "@/lib/sponsorship-subscription/stripe-prices";
import { subscriptionInvoiceEmail } from "@/lib/sponsorship-subscription/emails";
/**
* Jede bezahlte Monatsrechnung wird zu einer Wabenhain-Bestellung (Status
* BEZAHLT, digital) mit Zahlung, Rechnung im bestehenden Nummernkreis und
* Rechnungsmail (Spec "Abrechnung"). So erscheinen Abo-Umsaetze in
* Rechnungsliste, ERP-Export und Auswertung wie jeder andere Verkauf.
*
* Jeder Schritt ist fuer sich wiederholbar: ein erneut zugestelltes
* invoice.paid legt weder eine zweite Bestellung (Anspruch ueber
* SubscriptionPayment.orderId) noch eine zweite Rechnung (Invoice.orderId
* eindeutig) an, und die Mail geht nur in dem Lauf hinaus, der die Rechnung
* angelegt hat.
*/
function loadPayment(paymentId: string) {
return prisma.subscriptionPayment.findUniqueOrThrow({
where: { id: paymentId },
include: {
subscription: {
include: {
offer: { include: { product: { select: { id: true, category: true, vatRatePercent: true } } } },
},
},
},
});
}
type PaymentWithContext = Awaited<ReturnType<typeof loadPayment>>;
async function carrierProduct(payment: PaymentWithContext): Promise<{ id: string; category: string; vatRatePercent: number | null }> {
const existing = payment.subscription.offer.product;
if (existing) return existing;
// Altbestand ohne Traegerprodukt: einmal nachziehen (idempotent).
await syncOfferStripePrice(payment.subscription.offerId);
const offer = await prisma.sponsorshipOffer.findUniqueOrThrow({
where: { id: payment.subscription.offerId },
select: { product: { select: { id: true, category: true, vatRatePercent: true } } },
});
if (!offer.product) throw new Error(`Abo-Angebot ${payment.subscription.offerId} ohne Traegerprodukt.`);
return offer.product;
}
/** Legt die Monatsbestellung samt Zahlung an, falls es sie noch nicht gibt. Gibt die Bestell-ID zurueck. */
async function ensureMonthlyOrder(payment: PaymentWithContext, locale: Locale): Promise<string> {
if (payment.orderId) return payment.orderId;
const product = await carrierProduct(payment);
const contact = await orderContactForCustomer(payment.subscription.customerId);
const lines = monthlyOrderLines({
offerName: payment.subscription.offer.name,
amountCents: payment.amountCents,
honeyShareCents: payment.subscription.offer.honeyShareCentsPerMonth,
periodStart: payment.periodStart,
periodEnd: payment.periodEnd,
locale,
// Regelsatz wie bei der Hauptposition einer Einmal-Patenschaft
// (checkout-expand: Satz des Traegerprodukts), Honig zum Honig-Satz.
regularVatPercent: vatRateForProduct(product, process.env),
honeyVatPercent: vatRateForCategory("HONIG", process.env),
});
return prisma.$transaction(async (tx) => {
const order = await tx.order.create({
data: {
orderNumber: generateOrderNumber(),
customerId: payment.subscription.customerId,
status: "BEZAHLT",
fulfillmentMethod: "DIGITAL",
locale,
email: contact.email,
firstName: contact.firstName,
lastName: contact.lastName,
phone: contact.phone,
street: contact.street,
zip: contact.zip,
city: contact.city,
country: contact.country,
subtotalCents: payment.amountCents,
totalCents: payment.amountCents,
items: {
create: lines.map((line) => ({
productId: product.id,
productName: line.productName,
unitPriceCents: line.totalCents,
quantity: 1,
weightGrams: 0,
totalCents: line.totalCents,
vatRatePercent: line.vatRatePercent,
sponsorshipOfferId: payment.subscription.offerId,
sponsorshipRole: line.role,
})),
},
payment: {
create: {
stripePaymentIntentId: payment.stripePaymentIntentId,
status: "SUCCEEDED",
amountCents: payment.amountCents,
paidAt: payment.paidAt,
},
},
},
select: { id: true },
});
// Anspruch: nur ein Lauf verknuepft eine Bestellung. Verliert dieser
// hier, wird seine Bestellung mit der Transaktion verworfen.
const claim = await tx.subscriptionPayment.updateMany({
where: { id: payment.id, orderId: null },
data: { orderId: order.id },
});
if (claim.count === 0) throw new MonthlyOrderRaceLost();
return order.id;
});
}
class MonthlyOrderRaceLost extends Error {}
export async function fulfillSubscriptionPayment(paymentId: string, options: { isFirstPayment: boolean }): Promise<void> {
let payment = await loadPayment(paymentId);
const locale: Locale = isLocale(payment.subscription.locale) ? payment.subscription.locale : defaultLocale;
let orderId: string;
try {
orderId = await ensureMonthlyOrder(payment, locale);
} catch (error: unknown) {
if (!(error instanceof MonthlyOrderRaceLost)) throw error;
payment = await loadPayment(paymentId);
if (!payment.orderId) throw error;
orderId = payment.orderId;
}
const invoice = await issueInvoiceForOrder(orderId, payment.amountCents);
if (!invoice.created) return;
const order = await prisma.order.findUniqueOrThrow({
where: { id: orderId },
select: { orderNumber: true, email: true, firstName: true, lastName: true },
});
const pdf = await storeInvoiceDocument(invoice.invoiceNumber);
const content = await subscriptionInvoiceEmail({
locale,
firstName: order.firstName,
offerName: payment.subscription.offer.name,
invoiceNumber: invoice.invoiceNumber,
amountCents: payment.amountCents,
period: formatServicePeriod(payment.periodStart, payment.periodEnd, locale),
isFirstPayment: options.isFirstPayment,
accountUrl: `${getBaseUrl()}/konto/patenschaft/abo/${payment.subscriptionId}`,
});
const sent = await sendMail({
to: order.email,
...content,
...(pdf ? { attachments: [{ filename: `${invoice.invoiceNumber}.pdf`, content: pdf, contentType: "application/pdf" }] } : {}),
});
if (!sent) {
// Kein Wiederholungsversuch ueber Stripe: die Rechnung liegt im Konto,
// eine zweite Zustellung koennte sonst doppelte Mails erzeugen.
console.error(`[abo] Rechnungsmail ${invoice.invoiceNumber} an ${order.email} konnte nicht versendet werden.`);
}
if (pdf) {
await sendInvoiceCopy({
title: "Rechnung",
number: invoice.invoiceNumber,
orderNumber: order.orderNumber,
customerName: `${order.firstName} ${order.lastName}`.trim(),
customerEmail: order.email,
pdf,
});
}
}
@@ -0,0 +1,110 @@
/**
* Oeffentliche Kuendigung (/vertraege-kuendigen): Die Erklaerung wird IMMER
* gespeichert und bestaetigt; bei Stripe vorgemerkt wird sie nur, wenn Name,
* E-Mail und Vertragsnummer zum Abo passen.
*/
import { beforeEach, describe, expect, test, vi } from "vitest";
const m = vi.hoisted(() => ({
db: {
sponsorship: { findUnique: vi.fn() },
sponsorshipSubscription: { findUnique: vi.fn() },
auditLog: { create: vi.fn() },
},
cancelSubscription: vi.fn(),
sendMail: vi.fn(),
}));
vi.mock("@/lib/prisma", () => ({ prisma: m.db }));
vi.mock("@/lib/mail", () => ({ sendMail: m.sendMail }));
vi.mock("@/lib/sponsorship-subscription/cancellation", () => ({ cancelSubscription: m.cancelSubscription }));
vi.mock("@/lib/sponsorship-subscription/emails", async (importOriginal) => {
const actual = await importOriginal<typeof import("./emails")>();
return {
cancellationDeclarationLines: actual.cancellationDeclarationLines,
publicCancellationReceiptEmail: vi.fn(async () => ({ subject: "Eingang", html: "<p></p>", text: "" })),
};
});
const { matchesCustomer, submitPublicCancellation } = await import("./public-cancellation");
const NOW = new Date("2026-10-16T12:00:00.000Z");
const SUBSCRIPTION = {
id: "cm1abcdefghijklmnop",
customer: { email: "[email protected]", firstName: "Erika", lastName: "Müller" },
};
const INPUT = { name: "Erika Müller", email: "[email protected]", reference: "WH-P-2026-00042", message: null };
beforeEach(() => {
vi.clearAllMocks();
m.db.sponsorship.findUnique.mockResolvedValue({ subscription: SUBSCRIPTION });
m.db.sponsorshipSubscription.findUnique.mockResolvedValue(null);
m.cancelSubscription.mockResolvedValue({ ok: true, effectiveEnd: new Date("2026-11-16T10:00:00.000Z"), alreadyCancelled: false });
m.sendMail.mockResolvedValue(true);
delete process.env.ADMIN_EMAIL;
});
describe("matchesCustomer", () => {
test("passt bei gleicher E-Mail und enthaltenem Nachnamen, unabhaengig von Gross-/Kleinschreibung und Akzenten", () => {
expect(matchesCustomer({ name: "erika MUELLER", email: "[email protected]" }, SUBSCRIPTION.customer)).toBe(true);
expect(matchesCustomer({ name: "erika MÜLLER", email: "[email protected] " }, SUBSCRIPTION.customer)).toBe(true);
expect(matchesCustomer({ name: "E. Muller", email: "[email protected]" }, SUBSCRIPTION.customer)).toBe(true);
});
test("passt nicht bei fremder E-Mail oder fremdem Namen", () => {
expect(matchesCustomer({ name: "Erika Müller", email: "[email protected]" }, SUBSCRIPTION.customer)).toBe(false);
expect(matchesCustomer({ name: "Max Mustermann", email: "[email protected]" }, SUBSCRIPTION.customer)).toBe(false);
});
});
describe("submitPublicCancellation", () => {
test("passende Angaben: Kuendigung bei Stripe vormerken, speichern, Eingang mit Enddatum bestaetigen", async () => {
const result = await submitPublicCancellation(INPUT, { locale: "de", verified: true, now: NOW });
expect(m.cancelSubscription).toHaveBeenCalledWith({ subscriptionId: SUBSCRIPTION.id, receivedAt: NOW, sendConfirmation: false });
expect(m.db.auditLog.create.mock.calls[0][0].data).toMatchObject({
action: "subscription.cancellation_request",
entityType: "SponsorshipSubscription",
entityId: SUBSCRIPTION.id,
});
expect(m.sendMail).toHaveBeenCalledWith(expect.objectContaining({ to: "[email protected]" }));
expect(result.confirmationSent).toBe(true);
expect(result.lines[0]).toContain("WH-P-2026-00042");
});
test("unpassende Angaben: nichts bei Stripe, aber gespeichert und bestaetigt — gleiche Antwort", async () => {
const result = await submitPublicCancellation({ ...INPUT, email: "[email protected]" }, { locale: "de", verified: true, now: NOW });
expect(m.cancelSubscription).not.toHaveBeenCalled();
expect(m.db.auditLog.create.mock.calls[0][0].data.entityId).toBeNull();
expect(m.sendMail).toHaveBeenCalledWith(expect.objectContaining({ to: "[email protected]" }));
expect(Object.keys(result).sort()).toEqual(["confirmationSent", "lines", "receivedAt"]);
});
test("ohne bestandene Sicherheitspruefung wird nichts automatisch gekuendigt, die Erklaerung aber gespeichert", async () => {
await submitPublicCancellation(INPUT, { locale: "de", verified: false, now: NOW });
expect(m.cancelSubscription).not.toHaveBeenCalled();
expect(m.db.auditLog.create).toHaveBeenCalledOnce();
expect(m.sendMail).toHaveBeenCalledOnce();
});
test("findet das Abo auch ueber die Vertragsnummer aus dem Konto (Abo-ID)", async () => {
m.db.sponsorshipSubscription.findUnique.mockResolvedValue(SUBSCRIPTION);
await submitPublicCancellation({ ...INPUT, reference: SUBSCRIPTION.id }, { locale: "de", verified: true, now: NOW });
expect(m.db.sponsorshipSubscription.findUnique).toHaveBeenCalledWith(expect.objectContaining({ where: { id: SUBSCRIPTION.id } }));
expect(m.cancelSubscription).toHaveBeenCalled();
});
test("ein Fehler bei Stripe verhindert weder Speicherung noch Eingangsbestaetigung", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
m.cancelSubscription.mockRejectedValue(new Error("Stripe nicht erreichbar"));
const result = await submitPublicCancellation(INPUT, { locale: "de", verified: true, now: NOW });
expect(m.db.auditLog.create).toHaveBeenCalledOnce();
expect(result.confirmationSent).toBe(true);
});
});
@@ -0,0 +1,149 @@
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { sendMail } from "@/lib/mail";
import { escapeHtml } from "@/lib/escape-html";
import { formatReceivedAt } from "@/lib/withdrawal";
import type { Locale } from "@/i18n/config";
import { cancelSubscription } from "@/lib/sponsorship-subscription/cancellation";
import {
cancellationDeclarationLines,
publicCancellationReceiptEmail,
type PublicCancellationDeclaration,
} from "@/lib/sponsorship-subscription/emails";
/**
* Kuendigung ohne Anmeldung (§ 312k BGB, Seite /vertraege-kuendigen), nach
* dem Muster der Widerrufsfunktion (api/widerruf):
*
* - Die Erklaerung wird IMMER gespeichert und ihr Eingang per Mail
* bestaetigt: sie ist mit dem Zugang abgegeben, die Zuordnung zum Vertrag
* ist unsere Aufgabe.
* - Automatisch bei Stripe vorgemerkt wird die Kuendigung nur, wenn E-Mail,
* Name und Vertragsnummer zum Abo passen und die Sicherheitspruefung
* bestanden ist. Sonst bearbeitet der Admin sie von Hand.
* - Die Antwort ist fuer passende und unpassende Angaben gleich — sonst
* liessen sich Vertragsnummern samt E-Mail-Adresse durchprobieren.
*
* Gespeichert wird im Audit-Log (adminId leer, Aktion
* "subscription.cancellation_request"): eine eigene Tabelle wie
* WithdrawalDeclaration waere sauberer, braucht aber eine Schemaaenderung.
*/
export const publicCancellationSchema = z.object({
name: z.string().trim().min(2).max(120),
email: z.string().trim().toLowerCase().max(254).email(),
reference: z.string().trim().min(1).max(60),
message: z
.string()
.trim()
.max(2000)
.nullish()
.transform((value) => value || null),
});
export type PublicCancellationInput = z.infer<typeof publicCancellationSchema>;
export const CANCELLATION_REQUEST_ACTION = "subscription.cancellation_request";
const TRANSLITERATION: Record<string, string> = { ä: "ae", ö: "oe", ü: "ue", ß: "ss" };
function stripMarks(value: string): string {
return value.normalize("NFKD").replace(/\p{M}/gu, "");
}
/** Zwei Lesarten je Name: ohne Akzente ("muller") und umschrieben ("mueller"). */
function nameForms(value: string): string[] {
const lower = value.toLowerCase().trim();
return [stripMarks(lower), stripMarks(lower.replace(/[äöüß]/g, (char) => TRANSLITERATION[char] ?? char))];
}
/** Passt die Angabe zum Konto? Gleiche E-Mail und der Nachname des Kontos steckt im angegebenen Namen. */
export function matchesCustomer(
input: { name: string; email: string },
customer: { email: string; firstName: string; lastName: string }
): boolean {
if (input.email.trim().toLowerCase() !== customer.email.trim().toLowerCase()) return false;
const given = nameForms(input.name);
return nameForms(customer.lastName).some((last) => last.length > 0 && given.some((form) => form.includes(last)));
}
const subscriptionSelect = {
id: true,
customer: { select: { email: true, firstName: true, lastName: true } },
} as const;
async function findReferencedSubscription(reference: string) {
const upper = reference.trim().toUpperCase();
if (/^WH-P-\d{4}-\d{5}$/.test(upper)) {
const sponsorship = await prisma.sponsorship.findUnique({
where: { number: upper },
select: { subscription: { select: subscriptionSelect } },
});
return sponsorship?.subscription ?? null;
}
if (!/^[a-z0-9]{10,40}$/i.test(reference.trim())) return null;
return prisma.sponsorshipSubscription.findUnique({ where: { id: reference.trim() }, select: subscriptionSelect });
}
export interface PublicCancellationResult {
receivedAt: string;
lines: string[];
confirmationSent: boolean;
}
export async function submitPublicCancellation(
input: PublicCancellationDeclaration,
context: { locale: Locale; verified: boolean; now: Date }
): Promise<PublicCancellationResult> {
const subscription = await findReferencedSubscription(input.reference);
const matched = subscription && matchesCustomer(input, subscription.customer) ? subscription : null;
let effectiveEnd: Date | null = null;
if (matched && context.verified) {
try {
const result = await cancelSubscription({ subscriptionId: matched.id, receivedAt: context.now, sendConfirmation: false });
if (result.ok) effectiveEnd = result.effectiveEnd;
} catch (error: unknown) {
console.error(`[abo] Oeffentliche Kuendigung von Abo ${matched.id} konnte nicht vorgemerkt werden:`, error);
}
}
const lines = cancellationDeclarationLines(input, context.locale);
const outcome = effectiveEnd
? `Bei Stripe zum ${effectiveEnd.toISOString().slice(0, 10)} vorgemerkt.`
: matched
? "Zugeordnet, aber NICHT automatisch vorgemerkt — bitte von Hand kündigen."
: "Keinem Abo zugeordnet — bitte manuell prüfen.";
await prisma.auditLog.create({
data: {
adminId: null,
adminEmail: input.email,
action: CANCELLATION_REQUEST_ACTION,
entityType: "SponsorshipSubscription",
entityId: matched?.id ?? null,
detail: [...cancellationDeclarationLines(input, "de"), `Eingegangen am: ${formatReceivedAt(context.now, "de")}`, outcome].join("\n"),
},
});
const receipt = await publicCancellationReceiptEmail({
locale: context.locale,
declaration: input,
receivedAt: context.now,
effectiveEnd,
});
const confirmationSent = await sendMail({ to: input.email, ...receipt });
// Interne Benachrichtigung wie beim Widerruf: eine nicht automatisch
// vorgemerkte Kuendigung muss rechtzeitig von Hand erledigt werden.
const adminEmail = process.env.ADMIN_EMAIL;
if (adminEmail) {
const notice = [...cancellationDeclarationLines(input, "de"), "", outcome, `Eingangsbestätigung: ${confirmationSent ? "versendet" : "NICHT versendet"}`];
await sendMail({
to: adminEmail,
subject: `Neue Kündigung zu ${input.reference.replace(/[\r\n]+/g, " ")}`,
text: notice.join("\n"),
html: `<p style="font-family:Arial,sans-serif;line-height:1.6;">${notice.map(escapeHtml).join("<br>")}</p>`,
});
}
return { receivedAt: formatReceivedAt(context.now, context.locale), lines, confirmationSent };
}
@@ -0,0 +1,77 @@
import { prisma } from "@/lib/prisma";
/**
* Abo-Reservierung von Honig.
*
* Kein Abo darf ohne Honig bleiben, weil der Shop zu viele Glaeser einzeln
* verkauft hat. Deshalb zaehlt fuer Einzelverkauf und Pakete nur der FREIE
* Bestand: Lagerbestand minus die Glaeser, die laufende Abos noch beanspruchen.
*
* Die Reservierung ist keine eigene Buchung im Lager, sondern wird bei jeder
* Abfrage aus den Abos berechnet. So kann sie nie von der Wirklichkeit
* abweichen: endet ein Abo, ist sein Anteil im selben Moment frei, ohne dass
* irgendwo eine Gegenbuchung vergessen werden koennte.
*
* - Vierteljaehrlich: je laufendem Abo die Glaeser der naechsten Lieferung.
* Gekuendigte Abos zaehlen bis zu ihrem Ende mit — lieber einen Moment zu
* vorsichtig als einer bezahlten Lieferung den Honig wegverkaufen.
* - Ernte: die im Admin je Erntefenster geplanten Abo-Glaeser, solange das
* Fenster nicht vorbei ist, abzueglich der in diesem Fenster bereits
* angelegten Lieferungen (deren Glaeser sind dann schon vom Lager gebucht).
*/
const RESERVING_STATUSES = ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] as const;
type Db = Pick<typeof prisma, "sponsorshipSubscription" | "harvestWindow" | "subscriptionDelivery" | "inventory">;
export async function reservedJarsForProduct(productId: string, now: Date = new Date(), db: Db = prisma): Promise<number> {
const quarterly = await db.sponsorshipSubscription.findMany({
where: {
status: { in: [...RESERVING_STATUSES] },
// Nach einer Rueckbuchung ruhen die Lieferungen, und die Reservierung
// wird freigegeben (Spec "SEPA-Lastschrift und Rueckbuchungen", Punkt 3).
// Verzug wegen einer fehlgeschlagenen Zahlung haelt sie dagegen: Stripe
// versucht es erneut, und meist klappt es.
NOT: { status: "IM_VERZUG", payments: { some: { reversedAt: { not: null } } } },
offer: { honeyProductId: productId, cycle: "VIERTELJAEHRLICH" },
},
select: { offer: { select: { honeyJars: true } } },
});
const quarterlyJars = quarterly.reduce((sum, sub) => sum + sub.offer.honeyJars, 0);
const today = startOfUtcDay(now);
const windows = await db.harvestWindow.findMany({
where: { endsOn: { gte: today }, offer: { honeyProductId: productId, cycle: "ERNTE" } },
select: { offerId: true, startsOn: true, endsOn: true, plannedJars: true },
});
let harvestJars = 0;
for (const window of windows) {
const delivered = await db.subscriptionDelivery.aggregate({
where: {
status: "ANGELEGT",
subscription: { offerId: window.offerId },
dueAt: { gte: window.startsOn, lte: endOfUtcDay(window.endsOn) },
},
_sum: { jars: true },
});
harvestJars += Math.max(0, window.plannedJars - (delivered._sum.jars ?? 0));
}
return quarterlyJars + harvestJars;
}
/** Freier Bestand eines Produkts fuer Einzelverkauf und Pakete (nie negativ). */
export async function availableForSale(productId: string, now: Date = new Date(), db: Db = prisma): Promise<number> {
const inventory = await db.inventory.findUnique({ where: { productId }, select: { quantity: true } });
if (!inventory) return 0;
const reserved = await reservedJarsForProduct(productId, now, db);
return Math.max(0, inventory.quantity - reserved);
}
function startOfUtcDay(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}
function endOfUtcDay(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 23, 59, 59, 999));
}
@@ -0,0 +1,78 @@
import { prisma } from "@/lib/prisma";
import type { Prisma } from "@/generated/prisma/client";
import { allocateSponsorshipNumber } from "@/lib/sponsorship/number";
import { processSponsorship } from "@/lib/sponsorship/issue";
import {
fromIsoDate,
parseStoredPersonalization,
toIsoDate,
toStoredPersonalization,
} from "@/lib/sponsorship/certificate-document";
import { isUniqueViolation } from "@/lib/sponsorship-subscription/lookup";
/**
* Die Patenschaft eines Abos entsteht mit der ersten bezahlten Rechnung
* (Spec "Ablauf nach der Zahlung": "Beim Abo entsteht sie mit der ersten
* bezahlten Rechnung"). Startdatum ist der Tag dieser Zahlung — das beim
* Abschluss gewaehlte Datum wird dabei ersetzt.
*
* Idempotent ueber das eindeutige subscriptionId an der Patenschaft: ein
* paralleler oder wiederholter Lauf scheitert an der Datenbank und uebernimmt
* dann die vorhandene.
*/
export async function ensureSubscriptionSponsorship(subscriptionId: string): Promise<string | null> {
const subscription = await prisma.sponsorshipSubscription.findUnique({
where: { id: subscriptionId },
include: {
sponsorship: { select: { id: true } },
payments: { where: { reversedAt: null }, orderBy: { paidAt: "asc" }, take: 1, select: { paidAt: true } },
},
});
if (!subscription) return null;
let sponsorshipId = subscription.sponsorship?.id ?? null;
if (!sponsorshipId) {
const first = subscription.payments[0];
if (!first) return null;
const stored = parseStoredPersonalization(subscription.personalization);
const startDate = toIsoDate(first.paidAt);
try {
sponsorshipId = await prisma.$transaction(async (tx) => {
const number = await allocateSponsorshipNumber(tx, first.paidAt.getUTCFullYear());
const created = await tx.sponsorship.create({
data: {
number,
ownerId: subscription.customerId,
offerId: subscription.offerId,
subscriptionId: subscription.id,
locale: subscription.locale,
startDate: fromIsoDate(startDate),
endDate: null,
},
select: { id: true },
});
// Gespeicherte Personalisierung auf den echten Beginn ziehen, damit
// Revision und Patenschaft dasselbe Startdatum tragen.
await tx.sponsorshipSubscription.update({
where: { id: subscription.id },
data: {
personalization: toStoredPersonalization(
{ ...stored.personalization, startDate },
stored.templateVersion
) as unknown as Prisma.InputJsonValue,
},
});
return created.id;
});
} catch (error: unknown) {
if (!isUniqueViolation(error)) throw error;
const existing = await prisma.sponsorship.findUnique({ where: { subscriptionId }, select: { id: true } });
sponsorshipId = existing?.id ?? null;
}
}
// Wirft nie: Fehler plant processSponsorship selbst fuer den naechsten
// Hintergrundlauf ein.
if (sponsorshipId) await processSponsorship(sponsorshipId);
return sponsorshipId;
}
@@ -0,0 +1,133 @@
/**
* Stripe-Produkt und -Preis eines Abo-Angebots. Geprueft wird vor allem, dass
* ein erneuter Aufruf nichts doppelt anlegt und eine Preisaenderung einen
* NEUEN Preis erzeugt (laufende Abos behalten ihren alten).
*/
import { beforeEach, describe, expect, test, vi } from "vitest";
const stripe = {
products: { create: vi.fn(), update: vi.fn() },
prices: { create: vi.fn() },
};
vi.mock("@/lib/stripe", () => ({ getStripeClient: () => stripe }));
const db = {
sponsorshipOffer: { findUnique: vi.fn(), update: vi.fn() },
product: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
};
vi.mock("@/lib/prisma", () => ({ prisma: db }));
const { syncOfferStripePrice } = await import("./stripe-prices");
function offer(overrides: Record<string, unknown> = {}) {
return {
id: "offer_1",
slug: "vierteljaehrlich",
kind: "ABO",
name: "Vierteljährlich",
description: "Monatlich zahlen, Honig nach drei bezahlten Monaten.",
priceCents: 1500,
productId: null as string | null,
stripeProductId: null as string | null,
stripePriceId: null as string | null,
stripePriceAmountCents: null as number | null,
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
stripe.products.create.mockResolvedValue({ id: "prod_1" });
stripe.products.update.mockResolvedValue({ id: "prod_1" });
stripe.prices.create.mockResolvedValue({ id: "price_2" });
db.product.findUnique.mockResolvedValue(null);
db.product.create.mockResolvedValue({ id: "carrier_1" });
db.product.update.mockResolvedValue({ id: "carrier_1" });
});
describe("syncOfferStripePrice", () => {
test("legt fuer ein neues Abo-Angebot Stripe-Produkt, Monatspreis und Traegerprodukt an", async () => {
db.sponsorshipOffer.findUnique.mockResolvedValue(offer());
await syncOfferStripePrice("offer_1");
expect(stripe.products.create).toHaveBeenCalledWith(
expect.objectContaining({ name: "Bienenpatenschaft-Abo Vierteljährlich", metadata: { offerId: "offer_1" } }),
expect.objectContaining({ idempotencyKey: expect.any(String) })
);
const [priceParams] = stripe.prices.create.mock.calls[0];
expect(priceParams).toMatchObject({
product: "prod_1",
currency: "eur",
unit_amount: 1500,
recurring: { interval: "month" },
});
expect(db.product.create).toHaveBeenCalledWith({
data: expect.objectContaining({
slug: "patenschaft-abo-vierteljaehrlich",
name: "Bienenpatenschaft-Abo Vierteljährlich",
category: "PATENSCHAFT",
weightGrams: 0,
pricePerKgCents: 0,
isActive: true,
}),
});
expect(db.sponsorshipOffer.update).toHaveBeenCalledWith({
where: { id: "offer_1" },
data: { stripeProductId: "prod_1", stripePriceId: "price_2", stripePriceAmountCents: 1500, productId: "carrier_1" },
});
});
test("legt bei unveraendertem Preis keinen neuen Stripe-Preis und kein neues Traegerprodukt an", async () => {
db.sponsorshipOffer.findUnique.mockResolvedValue(
offer({ productId: "carrier_1", stripeProductId: "prod_1", stripePriceId: "price_1", stripePriceAmountCents: 1500 })
);
await syncOfferStripePrice("offer_1");
expect(stripe.products.create).not.toHaveBeenCalled();
expect(stripe.prices.create).not.toHaveBeenCalled();
expect(db.product.create).not.toHaveBeenCalled();
expect(db.sponsorshipOffer.update).toHaveBeenCalledWith({
where: { id: "offer_1" },
data: { stripeProductId: "prod_1", stripePriceId: "price_1", stripePriceAmountCents: 1500, productId: "carrier_1" },
});
});
test("erzeugt bei geaendertem Preis einen neuen Stripe-Preis am bestehenden Produkt", async () => {
db.sponsorshipOffer.findUnique.mockResolvedValue(
offer({ priceCents: 1800, productId: "carrier_1", stripeProductId: "prod_1", stripePriceId: "price_1", stripePriceAmountCents: 1500 })
);
await syncOfferStripePrice("offer_1");
expect(stripe.products.create).not.toHaveBeenCalled();
expect(stripe.prices.create).toHaveBeenCalledWith(
expect.objectContaining({ product: "prod_1", unit_amount: 1800 }),
expect.anything()
);
expect(db.sponsorshipOffer.update.mock.calls[0][0].data).toMatchObject({
stripePriceId: "price_2",
stripePriceAmountCents: 1800,
});
});
test("verknuepft ein bereits vorhandenes Traegerprodukt mit passendem Slug statt ein zweites anzulegen", async () => {
db.sponsorshipOffer.findUnique.mockResolvedValue(offer());
db.product.findUnique.mockResolvedValue({ id: "carrier_old", sponsorshipOffer: null });
await syncOfferStripePrice("offer_1");
expect(db.product.create).not.toHaveBeenCalled();
expect(db.product.update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: "carrier_old" } }));
});
test("laesst Einmal-Angebote unberuehrt", async () => {
db.sponsorshipOffer.findUnique.mockResolvedValue(offer({ kind: "EINMALIG" }));
await syncOfferStripePrice("offer_1");
expect(stripe.products.create).not.toHaveBeenCalled();
expect(db.sponsorshipOffer.update).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,120 @@
import { prisma } from "@/lib/prisma";
import { getStripeClient } from "@/lib/stripe";
/**
* Stripe-Produkt und Monatspreis eines Abo-Angebots (Spec "Abrechnung").
*
* Wird beim Speichern im Admin und vor jedem Abo-Abschluss aufgerufen und ist
* idempotent: ohne Aenderung passiert bei Stripe nichts. Aendert sich der
* Monatspreis, entsteht ein NEUER Stripe-Preis fuer neue Abos; der alte
* bleibt bestehen, laufende Abos werden weiter zu ihm abgerechnet. Preise in
* Stripe lassen sich ohnehin nicht im Betrag aendern.
*
* Daneben haengt am Angebot ein Traegerprodukt (Kategorie PATENSCHAFT): jede
* bezahlte Monatsrechnung wird zu einer Wabenhain-Bestellung, und deren
* Positionen brauchen eine productId. Das Produkt ist nie im Shop gelistet
* (Shop-Listen filtern PATENSCHAFT), und der Checkout lehnt Abo-Angebote ab
* (checkout-expand: nur EINMALIG).
*/
const CARRIER_PREFIX = "patenschaft-abo-";
export function carrierProductName(offerName: string): string {
return `Bienenpatenschaft-Abo ${offerName}`;
}
interface OfferForSync {
id: string;
slug: string;
name: string;
description: string;
priceCents: number;
productId: string | null;
}
/** Legt das Traegerprodukt an oder bringt es auf den Stand des Angebots. */
async function ensureCarrierProduct(offer: OfferForSync): Promise<string> {
const data = {
name: carrierProductName(offer.name),
description: offer.description,
category: "PATENSCHAFT" as const,
weightGrams: 0,
pricePerKgCents: 0,
priceCents: offer.priceCents,
isActive: true,
};
if (offer.productId) {
await prisma.product.update({ where: { id: offer.productId }, data });
return offer.productId;
}
const slug = `${CARRIER_PREFIX}${offer.slug}`;
// Ein Lauf, der das Produkt anlegte, aber vor dem Verknuepfen abbrach,
// hinterlaesst ein Produkt mit genau diesem Slug — wiederverwenden statt am
// eindeutigen Slug zu scheitern.
const existing = await prisma.product.findUnique({
where: { slug },
select: { id: true, sponsorshipOffer: { select: { id: true } } },
});
if (existing && (!existing.sponsorshipOffer || existing.sponsorshipOffer.id === offer.id)) {
await prisma.product.update({ where: { id: existing.id }, data });
return existing.id;
}
if (existing) {
throw new Error(`Traegerprodukt "${slug}" gehoert bereits zu einem anderen Angebot.`);
}
const created = await prisma.product.create({ data: { ...data, slug } });
return created.id;
}
export async function syncOfferStripePrice(offerId: string): Promise<void> {
const offer = await prisma.sponsorshipOffer.findUnique({ where: { id: offerId } });
if (!offer) throw new Error(`Angebot ${offerId} nicht gefunden.`);
if (offer.kind !== "ABO") return;
if (!Number.isInteger(offer.priceCents) || offer.priceCents <= 0) {
throw new Error(`Abo-Angebot "${offer.name}" hat keinen gueltigen Monatspreis.`);
}
const stripe = getStripeClient();
const name = carrierProductName(offer.name);
let stripeProductId = offer.stripeProductId;
if (!stripeProductId) {
// Idempotenzschluessel je Angebot: ein abgebrochener und wiederholter
// Aufruf bekommt dasselbe Produkt statt eines zweiten.
const created = await stripe.products.create(
{ name, metadata: { offerId: offer.id } },
{ idempotencyKey: `wabenhain-abo-product-${offer.id}` }
);
stripeProductId = created.id;
}
let stripePriceId = offer.stripePriceId;
let stripePriceAmountCents = offer.stripePriceAmountCents;
if (!stripePriceId || stripePriceAmountCents !== offer.priceCents) {
const price = await stripe.prices.create(
{
product: stripeProductId,
currency: "eur",
unit_amount: offer.priceCents,
recurring: { interval: "month" },
// Shop-Preise sind Bruttopreise (lib/vat.ts) — auch bei Stripe.
tax_behavior: "inclusive",
metadata: { offerId: offer.id },
},
{ idempotencyKey: `wabenhain-abo-price-${offer.id}-${offer.priceCents}` }
);
stripePriceId = price.id;
stripePriceAmountCents = offer.priceCents;
} else if (offer.stripeProductId) {
// Nur der Name kann sich ohne Preisaenderung geaendert haben; er steht
// im Checkout und auf Stripe-Belegen.
await stripe.products.update(stripeProductId, { name });
}
const productId = await ensureCarrierProduct(offer);
await prisma.sponsorshipOffer.update({
where: { id: offer.id },
data: { stripeProductId, stripePriceId, stripePriceAmountCents, productId },
});
}
@@ -0,0 +1,66 @@
import type Stripe from "stripe";
import { prisma } from "@/lib/prisma";
import { getStripeClient } from "@/lib/stripe";
import { deriveSubscriptionStatus, hasOpenDispute } from "@/lib/sponsorship-subscription/billing-rules";
import { findLocalSubscription } from "@/lib/sponsorship-subscription/lookup";
import { endSubscription } from "@/lib/sponsorship-subscription/ending";
/**
* Uebernimmt den Stand eines Stripe-Abos in das lokale Abo: Status,
* Ende des bezahlten Zeitraums, vorgemerkte Kuendigung.
*
* Bei customer.subscription.updated wird das Abo frisch bei Stripe gelesen
* statt dem Ereignis zu vertrauen: Stripe garantiert keine Reihenfolge, und
* ein verspaetet zugestelltes aelteres Ereignis duerfte eine juengere
* Kuendigung oder Zahlung nicht ueberschreiben.
*/
function secondsToDate(value: number | null | undefined): Date | null {
return typeof value === "number" ? new Date(value * 1000) : null;
}
/** Ende des laufenden Zeitraums. Seit API 2025-03-31 steht es an den Positionen, nicht mehr am Abo. */
export function currentPeriodEndOf(subscription: Stripe.Subscription): Date | null {
const ends = subscription.items.data.map((item) => item.current_period_end).filter((v): v is number => typeof v === "number");
return ends.length > 0 ? new Date(Math.max(...ends) * 1000) : null;
}
export async function applyStripeSubscription(subscription: Stripe.Subscription): Promise<void> {
const local = await findLocalSubscription(subscription.id, subscription.metadata?.sponsorshipSubscriptionId);
if (!local) return;
const periodEnd = currentPeriodEndOf(subscription);
const cancelAt = secondsToDate(subscription.cancel_at) ?? (subscription.cancel_at_period_end ? periodEnd : null);
const payments = await prisma.subscriptionPayment.findMany({
where: { subscriptionId: local.id },
select: { paidAt: true, periodEnd: true, reversedAt: true },
});
const next = deriveSubscriptionStatus({
current: local.status,
stripeStatus: subscription.status,
cancelScheduled: cancelAt !== null,
hasOpenDispute: hasOpenDispute(payments),
});
if (next === "END") {
await endSubscription(local.id, secondsToDate(subscription.ended_at) ?? new Date());
return;
}
if (next === "BEENDET") return;
await prisma.sponsorshipSubscription.updateMany({
where: { id: local.id, status: { not: "BEENDET" } },
data: {
status: next,
currentPeriodEnd: periodEnd ?? local.currentPeriodEnd,
cancelAt,
startedAt: local.startedAt ?? secondsToDate(subscription.start_date),
delinquentSince: next === "IM_VERZUG" ? (local.delinquentSince ?? new Date()) : null,
},
});
}
export async function syncSubscriptionFromStripe(stripeSubscriptionId: string): Promise<void> {
const subscription = await getStripeClient().subscriptions.retrieve(stripeSubscriptionId);
await applyStripeSubscription(subscription);
}
@@ -0,0 +1,278 @@
/**
* Abo-Ereignisse ausser invoice.paid (eigene Datei): Abschluss, Abo-Stand,
* Ende, fehlgeschlagene Zahlung und Rueckbuchungen — jeweils mit doppelter
* und verspaeteter Zustellung, wie Stripe sie ausdruecklich vorsieht.
*/
import { beforeEach, describe, expect, test, vi } from "vitest";
import type Stripe from "stripe";
const m = vi.hoisted(() => ({
stripe: {
subscriptions: { retrieve: vi.fn() },
invoicePayments: { list: vi.fn() },
},
db: {
sponsorshipSubscription: { findUnique: vi.fn(), updateMany: vi.fn(), count: vi.fn() },
subscriptionPayment: { findFirst: vi.fn(), findMany: vi.fn(), updateMany: vi.fn(), count: vi.fn() },
customer: { updateMany: vi.fn() },
},
sendMail: vi.fn(),
endSubscription: vi.fn(),
handleInvoicePaid: vi.fn(),
}));
vi.mock("@/lib/stripe", () => ({ getStripeClient: () => m.stripe }));
vi.mock("@/lib/prisma", () => ({ prisma: m.db }));
vi.mock("@/lib/mail", () => ({ sendMail: m.sendMail }));
vi.mock("@/lib/sponsorship-subscription/ending", () => ({ endSubscription: m.endSubscription }));
vi.mock("@/lib/sponsorship-subscription/emails", () => ({
subscriptionDisputeEmail: vi.fn(async () => ({ subject: "Rueckbuchung", html: "<p></p>", text: "" })),
}));
vi.mock("@/lib/sponsorship-subscription/invoice-paid", async (importOriginal) => {
const actual = await importOriginal<typeof import("./invoice-paid")>();
return { subscriptionRefOfInvoice: actual.subscriptionRefOfInvoice, handleInvoicePaid: m.handleInvoicePaid };
});
const { handleSubscriptionEvent } = await import("./webhook-handlers");
const LOCAL = {
id: "sub_local",
status: "AKTIV",
stripeSubscriptionId: "sub_stripe",
delinquentSince: null as Date | null,
startedAt: new Date("2026-07-01T00:00:00.000Z"),
currentPeriodEnd: null as Date | null,
};
function event<T>(type: string, object: T): Stripe.Event {
return { type, data: { object } } as unknown as Stripe.Event;
}
function stripeSubscription(overrides: Partial<Stripe.Subscription> = {}): Stripe.Subscription {
return {
id: "sub_stripe",
status: "active",
cancel_at: null,
cancel_at_period_end: false,
ended_at: null,
start_date: 1_780_000_000,
metadata: { sponsorshipSubscriptionId: "sub_local" },
items: { data: [{ current_period_end: 1_790_000_000 }] },
...overrides,
} as unknown as Stripe.Subscription;
}
const disputedPayment = {
id: "pay_1",
stripeInvoiceId: "in_1",
paymentMethodType: "sepa_debit",
reversedAt: null as Date | null,
subscriptionId: "sub_local",
subscription: {
id: "sub_local",
status: "AKTIV",
locale: "de",
stripeSubscriptionId: "sub_stripe",
offer: { name: "Vierteljährlich" },
customer: { id: "cust_1", email: "[email protected]", firstName: "Erika" },
},
};
function dispute(overrides: Partial<Stripe.Dispute> = {}): Stripe.Dispute {
return { id: "dp_1", payment_intent: "pi_1", amount: 1500, status: "needs_response", ...overrides } as unknown as Stripe.Dispute;
}
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
m.db.sponsorshipSubscription.findUnique.mockResolvedValue(LOCAL);
m.db.subscriptionPayment.findMany.mockResolvedValue([]);
m.db.subscriptionPayment.count.mockResolvedValue(0);
m.sendMail.mockResolvedValue(true);
});
describe("checkout.session.completed (Abo)", () => {
test("verknuepft das Stripe-Abo und setzt AUSSTEHEND auf AKTIV", async () => {
m.db.sponsorshipSubscription.findUnique
.mockResolvedValueOnce({ ...LOCAL, status: "AUSSTEHEND", stripeSubscriptionId: null }) // per Metadaten-ID
.mockResolvedValueOnce(null) // per stripeSubscriptionId noch nicht verknuepft
.mockResolvedValueOnce({ ...LOCAL, status: "AUSSTEHEND", stripeSubscriptionId: null }) // Kandidat
.mockResolvedValueOnce({ ...LOCAL, status: "AUSSTEHEND" });
await handleSubscriptionEvent(
event("checkout.session.completed", {
id: "cs_1",
mode: "subscription",
subscription: "sub_stripe",
metadata: { sponsorshipSubscriptionId: "sub_local" },
})
);
expect(m.db.sponsorshipSubscription.updateMany).toHaveBeenCalledWith({
where: { id: "sub_local", stripeSubscriptionId: null },
data: { stripeSubscriptionId: "sub_stripe" },
});
expect(m.db.sponsorshipSubscription.updateMany).toHaveBeenCalledWith({
where: { id: "sub_local", status: "AUSSTEHEND" },
data: expect.objectContaining({ status: "AKTIV" }),
});
});
test("laesst Einmal-Checkouts unberuehrt", async () => {
await handleSubscriptionEvent(event("checkout.session.completed", { id: "cs_1", mode: "payment", metadata: { orderId: "o1" } }));
expect(m.db.sponsorshipSubscription.findUnique).not.toHaveBeenCalled();
});
});
describe("customer.subscription.updated", () => {
test("liest das Abo frisch bei Stripe statt dem (moeglicherweise veralteten) Ereignis zu vertrauen", async () => {
m.stripe.subscriptions.retrieve.mockResolvedValue(stripeSubscription({ cancel_at_period_end: true }));
await handleSubscriptionEvent(event("customer.subscription.updated", stripeSubscription({ cancel_at_period_end: false })));
expect(m.stripe.subscriptions.retrieve).toHaveBeenCalledWith("sub_stripe");
expect(m.db.sponsorshipSubscription.updateMany).toHaveBeenCalledWith({
where: { id: "sub_local", status: { not: "BEENDET" } },
data: expect.objectContaining({
status: "GEKUENDIGT",
cancelAt: new Date(1_790_000_000 * 1000),
currentPeriodEnd: new Date(1_790_000_000 * 1000),
}),
});
});
test("past_due setzt das Abo in Verzug und haelt den Verzugsbeginn fest", async () => {
m.stripe.subscriptions.retrieve.mockResolvedValue(stripeSubscription({ status: "past_due" }));
await handleSubscriptionEvent(event("customer.subscription.updated", stripeSubscription()));
const data = m.db.sponsorshipSubscription.updateMany.mock.calls[0][0].data;
expect(data.status).toBe("IM_VERZUG");
expect(data.delinquentSince).toBeInstanceOf(Date);
});
test("fremde Stripe-Abos kosten keinen API-Aufruf", async () => {
m.db.sponsorshipSubscription.count.mockResolvedValue(0);
await handleSubscriptionEvent(event("customer.subscription.updated", stripeSubscription({ metadata: {} })));
expect(m.stripe.subscriptions.retrieve).not.toHaveBeenCalled();
});
});
describe("customer.subscription.deleted", () => {
test("beendet das Abo zum Endzeitpunkt von Stripe", async () => {
await handleSubscriptionEvent(
event("customer.subscription.deleted", stripeSubscription({ status: "canceled", ended_at: 1_791_000_000 }))
);
expect(m.endSubscription).toHaveBeenCalledWith("sub_local", new Date(1_791_000_000 * 1000));
});
});
describe("invoice.payment_failed", () => {
const failed = {
id: "in_2",
parent: { subscription_details: { subscription: "sub_stripe", metadata: {} } },
};
test("setzt ein laufendes Abo in Verzug", async () => {
await handleSubscriptionEvent(event("invoice.payment_failed", failed));
expect(m.db.sponsorshipSubscription.updateMany).toHaveBeenCalledWith({
where: { id: "sub_local", status: { in: ["AKTIV", "GEKUENDIGT"] } },
data: expect.objectContaining({ status: "IM_VERZUG" }),
});
});
test("kommt der Fehlschlag nach dem Erfolg derselben Rechnung an, passiert nichts", async () => {
m.db.subscriptionPayment.count.mockResolvedValue(1);
await handleSubscriptionEvent(event("invoice.payment_failed", failed));
expect(m.db.sponsorshipSubscription.updateMany).not.toHaveBeenCalled();
});
});
describe("charge.dispute.created", () => {
test("bucht die Zahlung zurueck, setzt Verzug, sperrt SEPA und bittet per Mail um Ausgleich", async () => {
m.db.subscriptionPayment.findFirst.mockResolvedValue(disputedPayment);
m.db.subscriptionPayment.updateMany.mockResolvedValue({ count: 1 });
await handleSubscriptionEvent(event("charge.dispute.created", dispute()));
expect(m.db.subscriptionPayment.updateMany).toHaveBeenCalledWith({
where: { id: "pay_1", reversedAt: null },
data: { reversedAt: expect.any(Date) },
});
expect(m.db.sponsorshipSubscription.updateMany).toHaveBeenCalledWith({
where: { id: "sub_local", status: { not: "BEENDET" } },
data: { status: "IM_VERZUG", delinquentSince: expect.any(Date) },
});
expect(m.db.customer.updateMany).toHaveBeenCalledWith({
where: { id: "cust_1", sepaBlockedAt: null },
data: { sepaBlockedAt: expect.any(Date) },
});
expect(m.sendMail).toHaveBeenCalledOnce();
});
test("ein doppelt zugestelltes Ereignis verschickt keine zweite Mail", async () => {
m.db.subscriptionPayment.findFirst.mockResolvedValue({ ...disputedPayment, reversedAt: new Date() });
m.db.subscriptionPayment.updateMany.mockResolvedValue({ count: 0 });
await handleSubscriptionEvent(event("charge.dispute.created", dispute()));
expect(m.sendMail).not.toHaveBeenCalled();
expect(m.db.sponsorshipSubscription.updateMany).not.toHaveBeenCalled();
});
test("Kartenzahlung: keine SEPA-Sperre", async () => {
m.db.subscriptionPayment.findFirst.mockResolvedValue({ ...disputedPayment, paymentMethodType: "card" });
m.db.subscriptionPayment.updateMany.mockResolvedValue({ count: 1 });
await handleSubscriptionEvent(event("charge.dispute.created", dispute()));
expect(m.db.customer.updateMany).not.toHaveBeenCalled();
});
test("Rueckbuchung vor der zugehoerigen Abo-Zahlung: wirft, damit Stripe spaeter erneut zustellt", async () => {
m.db.subscriptionPayment.findFirst.mockResolvedValue(null);
m.stripe.invoicePayments.list.mockResolvedValue({
data: [{ invoice: { id: "in_1", parent: { subscription_details: { subscription: "sub_stripe", metadata: {} } } } }],
});
m.db.sponsorshipSubscription.count.mockResolvedValue(1);
await expect(handleSubscriptionEvent(event("charge.dispute.created", dispute()))).rejects.toThrow(/erneut/);
});
test("Rueckbuchung einer Einmalbestellung wird hier ignoriert", async () => {
m.db.subscriptionPayment.findFirst.mockResolvedValue(null);
m.stripe.invoicePayments.list.mockResolvedValue({ data: [] });
await handleSubscriptionEvent(event("charge.dispute.created", dispute()));
expect(m.db.subscriptionPayment.updateMany).not.toHaveBeenCalled();
});
});
describe("charge.dispute.closed", () => {
test("gewonnen: die Zahlung zaehlt wieder, der Status wird aus Stripe neu bestimmt", async () => {
m.db.subscriptionPayment.findFirst.mockResolvedValue({ ...disputedPayment, reversedAt: new Date() });
m.stripe.subscriptions.retrieve.mockResolvedValue(stripeSubscription());
await handleSubscriptionEvent(event("charge.dispute.closed", dispute({ status: "won" })));
expect(m.db.subscriptionPayment.updateMany).toHaveBeenCalledWith({
where: { id: "pay_1", reversedAt: { not: null } },
data: { reversedAt: null },
});
expect(m.stripe.subscriptions.retrieve).toHaveBeenCalledWith("sub_stripe");
});
test("verloren: nichts aendert sich", async () => {
await handleSubscriptionEvent(event("charge.dispute.closed", dispute({ status: "lost" })));
expect(m.db.subscriptionPayment.findFirst).not.toHaveBeenCalled();
});
});
describe("Weiterleitung", () => {
test("invoice.paid geht an den eigenen Handler, unbekannte Ereignisse werden ignoriert", async () => {
await handleSubscriptionEvent(event("invoice.paid", { id: "in_1" }));
await handleSubscriptionEvent(event("product.updated", { id: "prod_1" }));
expect(m.handleInvoicePaid).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,93 @@
import type Stripe from "stripe";
import { prisma } from "@/lib/prisma";
import { findLocalSubscription, stripeIdOf } from "@/lib/sponsorship-subscription/lookup";
import { applyStripeSubscription, syncSubscriptionFromStripe } from "@/lib/sponsorship-subscription/stripe-sync";
import { handleInvoicePaid, subscriptionRefOfInvoice } from "@/lib/sponsorship-subscription/invoice-paid";
import { handleSubscriptionDisputeClosed, handleSubscriptionDisputeCreated } from "@/lib/sponsorship-subscription/disputes";
/**
* Stripe-Ereignisse der Bienenpatenschaft im Abo. Der Webhook
* (api/checkout/webhook/route.ts) reicht jedes Ereignis hierher weiter,
* nachdem seine eigenen Zweige gelaufen sind; was kein Abo betrifft, wird
* hier ignoriert.
*
* Alle Handler sind idempotent und vertragen doppelte wie verspaetete
* Zustellung (Stripe garantiert weder Einmaligkeit noch Reihenfolge). Ein
* voruebergehender Fehler wird geworfen: der Webhook antwortet dann 500,
* und Stripe stellt erneut zu.
*/
export async function handleSubscriptionCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
if (session.mode !== "subscription") return;
const metadataId = session.metadata?.sponsorshipSubscriptionId ?? null;
const local = metadataId
? await prisma.sponsorshipSubscription.findUnique({ where: { id: metadataId } })
: await prisma.sponsorshipSubscription.findUnique({ where: { stripeCheckoutSessionId: session.id } });
if (!local) {
console.error(`[abo] Checkout ${session.id} abgeschlossen, aber kein passendes Abo gefunden.`);
return;
}
const stripeSubscriptionId = stripeIdOf(session.subscription);
if (stripeSubscriptionId) await findLocalSubscription(stripeSubscriptionId, local.id);
await prisma.sponsorshipSubscription.updateMany({
where: { id: local.id, status: "AUSSTEHEND" },
data: { status: "AKTIV", startedAt: new Date() },
});
}
/**
* Fehlgeschlagene Abbuchung: das Abo ruht, bis Stripe erfolgreich nachzieht
* (Smart Retries) oder es nach dem letzten Versuch beendet. Ein Abo im
* Abschluss (erste Zahlung im Checkout gescheitert) bleibt AUSSTEHEND.
*/
export async function handleInvoicePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
const ref = subscriptionRefOfInvoice(invoice);
if (!ref) return;
const local = await findLocalSubscription(ref.stripeSubscriptionId, ref.metadataId);
if (!local) return;
// Schon bezahlt (das Fehlschlag-Ereignis kam nach dem Erfolg an): nichts tun.
const alreadyPaid = await prisma.subscriptionPayment.count({ where: { stripeInvoiceId: invoice.id } });
if (alreadyPaid > 0) return;
await prisma.sponsorshipSubscription.updateMany({
where: { id: local.id, status: { in: ["AKTIV", "GEKUENDIGT"] } },
data: { status: "IM_VERZUG", delinquentSince: new Date() },
});
}
/** Nur Abos der Bienenpatenschaft bei Stripe nachlesen — andere kosten keinen API-Aufruf. */
async function isSponsorshipSubscription(subscription: Stripe.Subscription): Promise<boolean> {
if (subscription.metadata?.sponsorshipSubscriptionId) return true;
const linked = await prisma.sponsorshipSubscription.count({ where: { stripeSubscriptionId: subscription.id } });
return linked > 0;
}
export async function handleSubscriptionEvent(event: Stripe.Event): Promise<void> {
switch (event.type) {
case "checkout.session.completed":
await handleSubscriptionCheckoutCompleted(event.data.object);
return;
case "customer.subscription.created":
case "customer.subscription.updated":
if (await isSponsorshipSubscription(event.data.object)) await syncSubscriptionFromStripe(event.data.object.id);
return;
case "customer.subscription.deleted":
// Endzustand: das Ereignis selbst genuegt, ein erneuter Abruf brachte nichts Neueres.
await applyStripeSubscription(event.data.object);
return;
case "invoice.paid":
await handleInvoicePaid(event.data.object);
return;
case "invoice.payment_failed":
await handleInvoicePaymentFailed(event.data.object);
return;
case "charge.dispute.created":
await handleSubscriptionDisputeCreated(event.data.object);
return;
case "charge.dispute.closed":
await handleSubscriptionDisputeClosed(event.data.object);
return;
default:
return;
}
}
+60
View File
@@ -0,0 +1,60 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
import { computeLifecycle, parseStoredTerm } from "@/lib/sponsorship/access";
const day = (iso: string) => new Date(`${iso}T00:00:00.000Z`);
describe("Status einer Einmal-Patenschaft", () => {
const base = { startDate: day("2026-11-01"), endDate: day("2027-10-31"), subscription: null };
it("beginnt erst am Startdatum", () => {
expect(computeLifecycle({ ...base, now: new Date("2026-10-31T23:59:00Z") })).toBe("SCHEDULED");
expect(computeLifecycle({ ...base, now: new Date("2026-11-01T00:00:00Z") })).toBe("ACTIVE");
});
it("ist am letzten Tag noch aktiv und danach abgelaufen", () => {
expect(computeLifecycle({ ...base, now: new Date("2027-10-31T22:00:00Z") })).toBe("ACTIVE");
expect(computeLifecycle({ ...base, now: new Date("2027-11-01T00:00:01Z") })).toBe("EXPIRED");
});
});
describe("Status einer Abo-Patenschaft", () => {
const now = new Date("2027-03-01T12:00:00Z");
const base = { startDate: day("2026-10-16"), endDate: null };
it("laeuft aktiv und auch im Verzug weiter", () => {
expect(computeLifecycle({ ...base, subscription: { status: "AKTIV" }, now })).toBe("ACTIVE");
expect(computeLifecycle({ ...base, subscription: { status: "IM_VERZUG" }, now })).toBe("ACTIVE");
});
it("ist nach der Kuendigung bis zum Ende als gekuendigt markiert", () => {
expect(computeLifecycle({ ...base, subscription: { status: "GEKUENDIGT" }, now })).toBe("CANCELLING");
});
it("ist beendet, sobald das Abo endet oder das Enddatum vorbei ist", () => {
expect(computeLifecycle({ ...base, subscription: { status: "BEENDET" }, now })).toBe("ENDED");
expect(computeLifecycle({ ...base, endDate: day("2027-02-15"), subscription: { status: "GEKUENDIGT" }, now })).toBe("ENDED");
});
});
describe("Gespeicherte Laufzeit", () => {
it("liest die drei Formen", () => {
expect(parseStoredTerm({ kind: "FIXED", start: "2026-10-16", end: "2027-10-15" })).toEqual({
kind: "FIXED",
start: "2026-10-16",
end: "2027-10-15",
});
expect(parseStoredTerm({ kind: "OPEN", start: "2026-10-16", end: "egal" })).toEqual({ kind: "OPEN", start: "2026-10-16" });
expect(parseStoredTerm({ kind: "ENDED", start: "2026-10-16", end: "2027-01-31" })?.kind).toBe("ENDED");
});
it("weist Unbekanntes zurueck", () => {
expect(parseStoredTerm(null)).toBeNull();
expect(parseStoredTerm({ kind: "FIXED", start: "2026-10-16" })).toBeNull();
expect(parseStoredTerm({ kind: "EWIG", start: "2026-10-16" })).toBeNull();
expect(parseStoredTerm({ kind: "OPEN", start: "<script>" })).toBeNull();
});
});
+263
View File
@@ -0,0 +1,263 @@
import { prisma } from "@/lib/prisma";
import { isLocale, defaultLocale, type Locale } from "@/i18n/config";
import { personalizationSchema, type CertificateTerm, type Personalization } from "@/lib/certificate/schema";
import {
parseStoredPersonalization,
termForSponsorship,
type CertificateDocumentInput,
} from "@/lib/sponsorship/certificate-document";
import type {
MedalTier,
SponsorshipOfferKind,
SponsorshipStatus,
SubscriptionStatus,
} from "@/generated/prisma/client";
/**
* Lesender Zugriff auf die Patenschaften eines Kontos — fuer den Kontobereich
* und die Download-Routen. Jede Abfrage filtert nach ownerId: eine fremde
* Patenschaft ist fuer diese Funktionen schlicht nicht vorhanden, die
* Aufrufer antworten dann mit 404 (kein Unterschied zwischen "gibt es nicht"
* und "gehoert jemand anderem").
*
* Kundinnen und Kunden sehen immer die aktuelle Revision; aeltere bleiben nur
* fuer den Admin sichtbar.
*/
/** Was die Patenschaft gerade ist — aus Daten berechnet, nie gespeichert (Spec "Statuswerte"). */
export type SponsorshipLifecycle =
/** Einmal-Patenschaft mit Startdatum in der Zukunft (Geschenk zum Termin). */
| "SCHEDULED"
| "ACTIVE"
/** Einmal-Patenschaft nach ihren zwoelf Monaten. */
| "EXPIRED"
/** Abo gekuendigt, laeuft bis zum Ende des bezahlten Monats. */
| "CANCELLING"
/** Abo beendet. */
| "ENDED";
export interface LifecycleInput {
startDate: Date;
endDate: Date | null;
subscription: { status: SubscriptionStatus } | null;
now: Date;
}
/** Kalendertag (UTC) als Zahl, damit "am letzten Tag noch aktiv" ohne Uhrzeitfehler stimmt. */
function dayNumber(date: Date): number {
return Math.floor(date.getTime() / 86_400_000);
}
export function computeLifecycle({ startDate, endDate, subscription, now }: LifecycleInput): SponsorshipLifecycle {
const today = dayNumber(now);
if (subscription) {
if (subscription.status === "BEENDET") return "ENDED";
if (endDate && today > dayNumber(endDate)) return "ENDED";
if (subscription.status === "GEKUENDIGT") return "CANCELLING";
return "ACTIVE";
}
if (today < dayNumber(startDate)) return "SCHEDULED";
if (endDate && today > dayNumber(endDate)) return "EXPIRED";
return "ACTIVE";
}
export interface CurrentRevisionView {
revision: number;
personalization: Personalization;
term: CertificateTerm;
templateVersion: string;
pdfPath: string;
createdAt: Date;
}
export interface OwnedSponsorship {
id: string;
number: string;
locale: Locale;
startDate: Date;
endDate: Date | null;
status: SponsorshipStatus;
createdAt: Date;
/** Name der Biene — aus der Urkunde, solange sie fehlt aus der Bestellung. Leer nur bei kaputten Daten. */
beeName: string;
recipientName: string;
offer: { name: string; kind: SponsorshipOfferKind; medalTier: MedalTier };
subscription: { id: string; status: SubscriptionStatus; cancelAt: Date | null; endedAt: Date | null } | null;
/** null = Urkunde wird noch erstellt (AUSSTEHEND oder FEHLER ohne Revision). */
revision: CurrentRevisionView | null;
lifecycle: SponsorshipLifecycle;
}
const sponsorshipSelect = {
id: true,
number: true,
locale: true,
startDate: true,
endDate: true,
status: true,
currentRevision: true,
createdAt: true,
offer: { select: { name: true, nameEn: true, kind: true, medalTier: true } },
subscription: { select: { id: true, status: true, cancelAt: true, endedAt: true, personalization: true } },
orderItem: { select: { personalization: true } },
// Revisionsnummern steigen nur; die hoechste ist die aktuelle. Gefiltert
// wird unten trotzdem gegen currentRevision, falls ein Lauf gerade eine
// neue Revision schreibt, die noch nicht als aktuell markiert ist.
revisions: {
orderBy: { revision: "desc" },
take: 2,
select: { revision: true, personalization: true, term: true, templateVersion: true, pdfPath: true, createdAt: true },
},
} as const;
type SponsorshipRow = NonNullable<Awaited<ReturnType<typeof findOwnedRow>>>;
function findOwnedRow(customerId: string, id: string) {
return prisma.sponsorship.findFirst({ where: { id, ownerId: customerId }, select: sponsorshipSelect });
}
const TERM_KINDS = new Set(["FIXED", "OPEN", "ENDED"]);
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
/** Gespeicherte Laufzeitform pruefen statt blind zu casten — sie steuert, was gerendert wird. */
export function parseStoredTerm(value: unknown): CertificateTerm | null {
if (!value || typeof value !== "object") return null;
const raw = value as { kind?: unknown; start?: unknown; end?: unknown };
if (typeof raw.kind !== "string" || !TERM_KINDS.has(raw.kind)) return null;
if (typeof raw.start !== "string" || !ISO_DATE.test(raw.start)) return null;
if (raw.kind === "OPEN") return { kind: "OPEN", start: raw.start };
if (typeof raw.end !== "string" || !ISO_DATE.test(raw.end)) return null;
return { kind: raw.kind as "FIXED" | "ENDED", start: raw.start, end: raw.end };
}
function toRevisionView(row: SponsorshipRow): CurrentRevisionView | null {
if (row.currentRevision === 0) return null;
const current = row.revisions.find((revision) => revision.revision === row.currentRevision);
if (!current) return null;
const personalization = personalizationSchema.safeParse(current.personalization);
const term = parseStoredTerm(current.term);
if (!personalization.success || !term) {
// Eine Revision, die das eigene Schema nicht mehr besteht, ist ein
// Datenfehler — laut melden, die Seite zeigt dann "wird erstellt" statt
// einer falschen Urkunde.
console.error(`[patenschaft] Revision ${current.revision} von ${row.number} ist nicht lesbar.`);
return null;
}
return {
revision: current.revision,
personalization: personalization.data,
term,
templateVersion: current.templateVersion,
pdfPath: current.pdfPath,
createdAt: current.createdAt,
};
}
/** Personalisierung aus der Bestellung bzw. dem Abo — fuer den Namen, solange die Urkunde fehlt. */
function pendingPersonalization(row: SponsorshipRow): Personalization | null {
const source = row.orderItem?.personalization ?? row.subscription?.personalization;
try {
return parseStoredPersonalization(source).personalization;
} catch {
return null;
}
}
function toOwnedSponsorship(row: SponsorshipRow, locale: Locale, now: Date): OwnedSponsorship {
const revision = toRevisionView(row);
const personalization = revision?.personalization ?? pendingPersonalization(row);
return {
id: row.id,
number: row.number,
locale: isLocale(row.locale) ? row.locale : defaultLocale,
startDate: row.startDate,
endDate: row.endDate,
status: row.status,
createdAt: row.createdAt,
beeName: personalization?.beeName ?? "",
recipientName: personalization?.recipientName ?? "",
offer: {
name: (locale === "en" && row.offer.nameEn) || row.offer.name,
kind: row.offer.kind,
medalTier: row.offer.medalTier,
},
subscription: row.subscription
? {
id: row.subscription.id,
status: row.subscription.status,
cancelAt: row.subscription.cancelAt,
endedAt: row.subscription.endedAt,
}
: null,
revision,
lifecycle: computeLifecycle({
startDate: row.startDate,
endDate: row.endDate,
subscription: row.subscription,
now,
}),
};
}
// cuid-Kennungen; alles andere ist sicher keine Patenschaft und muss die
// Datenbank gar nicht erst erreichen.
const ID_PATTERN = /^[a-z0-9]{8,40}$/i;
export async function listOwnedSponsorships(
customerId: string,
locale: Locale = defaultLocale,
now: Date = new Date()
): Promise<OwnedSponsorship[]> {
const rows = await prisma.sponsorship.findMany({
where: { ownerId: customerId },
select: sponsorshipSelect,
orderBy: [{ startDate: "desc" }, { createdAt: "desc" }],
});
return rows.map((row) => toOwnedSponsorship(row, locale, now));
}
/** Die Patenschaft, wenn sie diesem Konto gehoert — sonst null (Aufrufer antworten mit 404). */
export async function getOwnedSponsorship(
customerId: string,
id: string,
locale: Locale = defaultLocale,
now: Date = new Date()
): Promise<OwnedSponsorship | null> {
if (!ID_PATTERN.test(id)) return null;
const row = await findOwnedRow(customerId, id);
return row ? toOwnedSponsorship(row, locale, now) : null;
}
/**
* Eingabe fuer die Bildausgaben (Textur) aus der gespeicherten aktuellen
* Revision — dieselben Daten, aus denen ihre PDF entstand. Die Laufzeit kommt
* aus der Revision, nicht aus dem heutigen Stand der Patenschaft: die Textur
* zeigt genau das, was in der PDF steht.
*/
export function currentRevisionDocument(sponsorship: OwnedSponsorship): CertificateDocumentInput | null {
if (!sponsorship.revision) return null;
return {
personalization: sponsorship.revision.personalization,
locale: sponsorship.locale,
term: sponsorship.revision.term,
number: sponsorship.number,
templateVersion: sponsorship.revision.templateVersion,
};
}
/** Laufzeit fuer die Anzeige: aus der Revision, solange es keine gibt aus den Daten der Patenschaft. */
export function displayTerm(sponsorship: OwnedSponsorship): CertificateTerm {
return (
sponsorship.revision?.term ??
termForSponsorship({
startDate: sponsorship.startDate,
endDate: sponsorship.endDate,
isSubscription: sponsorship.subscription !== null,
})
);
}
/** Uebertragbar sind nur fertige Einmal-Patenschaften (Spec "Übertragung"). */
export function isTransferable(sponsorship: OwnedSponsorship): boolean {
return sponsorship.subscription === null && sponsorship.revision !== null;
}
+113
View File
@@ -0,0 +1,113 @@
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`);
}
+216
View File
@@ -0,0 +1,216 @@
import type { Prisma, ProductCategory } from "@/generated/prisma/client";
import { personalizationSchema } from "@/lib/certificate/schema";
import { checkCertificateFits } from "@/lib/certificate/layout";
import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates";
import type { SponsorshipOfferView } from "@/lib/sponsorship/offers";
import type { Locale } from "@/i18n/config";
/**
* Wandelt die Warenkorbpositionen in Bestellpositionen um und loest dabei
* Patenschaftspakete in ihre Bestandteile auf.
*
* Ein Paket wird zu bis zu zwei Positionen:
* - MAIN: die Patenschaft (Traegerprodukt, Regelsteuersatz), traegt die
* Personalisierung. Keine Bestandsreservierung — eine Urkunde ist nie
* ausverkauft.
* - HONEY: der Honiganteil als Position des verknuepften Honigprodukts zum
* Honig-Steuersatz. Dadurch reserviert der Checkout den Honig wie jedes
* andere Glas, die Rechnung zeigt die Steueraufteilung, und die
* ApiCore-Abbuchung bei Zahlung (recordOrderSale) bucht den Honig ohne
* Sonderweg aus dem Bestand.
*
* Bewusst als reine Funktion ohne Datenbankzugriff: der Checkout laedt alles
* vorher, und diese Regeln lassen sich ohne Datenbank testen.
*/
export interface CheckoutCartItem {
productId: string;
quantity: number;
personalization?: unknown;
}
export interface CheckoutProduct {
id: string;
name: string;
batchId: string | null;
priceCents: number;
weightGrams: number;
category: ProductCategory;
isActive: boolean;
}
export interface OrderItemDraft {
productId: string;
batchId: string | null;
productName: string;
unitPriceCents: number;
quantity: number;
weightGrams: number;
totalCents: number;
vatRatePercent: number;
personalization?: Prisma.InputJsonValue;
sponsorshipOfferId?: string;
sponsorshipRole?: "MAIN" | "HONEY";
}
export interface ReservationLine {
productId: string;
quantity: number;
productName: string;
}
export type ExpandResult =
| {
ok: true;
items: OrderItemDraft[];
reserve: ReservationLine[];
/** Menge fuer Versandstaffel und Mengenrabatte (nur physische Ware). */
physicalQuantity: number;
hasPhysical: boolean;
hasSponsorship: boolean;
}
| { ok: false; error: string };
export interface ExpandInput {
cartItems: CheckoutCartItem[];
products: ReadonlyMap<string, CheckoutProduct>;
/** Angebote nach Traegerprodukt-ID. */
offers: ReadonlyMap<string, SponsorshipOfferView>;
vatRateFor: (product: CheckoutProduct) => number;
locale: Locale;
now: Date;
}
/** Spaetester Patenschaftsbeginn: sechs Monate nach dem Bestelltag. */
const MAX_START_OFFSET_MONTHS = 6;
export function startDateWindow(now: Date): { min: string; max: string } {
const min = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const max = new Date(min);
max.setUTCMonth(max.getUTCMonth() + MAX_START_OFFSET_MONTHS);
return { min: min.toISOString().slice(0, 10), max: max.toISOString().slice(0, 10) };
}
export function expandCheckoutItems(input: ExpandInput): ExpandResult {
const items: OrderItemDraft[] = [];
const reserve: ReservationLine[] = [];
let physicalQuantity = 0;
let hasSponsorship = false;
const window = startDateWindow(input.now);
for (const cartItem of input.cartItems) {
const product = input.products.get(cartItem.productId);
if (!product || !product.isActive) {
return { ok: false, error: "Ein Produkt im Warenkorb ist nicht mehr verfügbar." };
}
if (product.category !== "PATENSCHAFT") {
if (cartItem.personalization !== undefined) {
return { ok: false, error: "Ungültige Eingabe." };
}
if (product.priceCents <= 0) {
return { ok: false, error: "Ein Produkt im Warenkorb ist nicht mehr verfügbar." };
}
items.push({
productId: product.id,
batchId: product.batchId,
productName: product.name,
unitPriceCents: product.priceCents,
quantity: cartItem.quantity,
weightGrams: product.weightGrams,
totalCents: product.priceCents * cartItem.quantity,
vatRatePercent: input.vatRateFor(product),
});
reserve.push({ productId: product.id, quantity: cartItem.quantity, productName: product.name });
physicalQuantity += cartItem.quantity;
continue;
}
hasSponsorship = true;
const offer = input.offers.get(product.id);
if (!offer || offer.kind !== "EINMALIG" || !offer.isAvailable) {
return { ok: false, error: "Diese Patenschaft ist derzeit nicht bestellbar." };
}
// Jede Patenschaft hat ihre eigene Personalisierung und ihre eigene
// Urkunde — eine Menge > 1 haette eine Widmung fuer mehrere Urkunden.
if (cartItem.quantity !== 1) {
return { ok: false, error: "Jede Patenschaft wird einzeln personalisiert." };
}
const parsed = personalizationSchema.safeParse(cartItem.personalization);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Die Personalisierung ist ungültig." };
}
const personalization = parsed.data;
if (personalization.startDate < window.min || personalization.startDate > window.max) {
return { ok: false, error: "Das Startdatum muss zwischen heute und in sechs Monaten liegen." };
}
const fit = checkCertificateFits(personalization, input.locale);
if (!fit.ok) {
return { ok: false, error: fit.message };
}
const honeyTotal = offer.honeyJars * offer.honeyShareCentsPerJar;
const mainCents = offer.priceCents - honeyTotal;
if (mainCents <= 0) {
return { ok: false, error: "Diese Patenschaft ist derzeit nicht bestellbar." };
}
items.push({
productId: product.id,
batchId: null,
productName: offer.name,
unitPriceCents: mainCents,
quantity: 1,
weightGrams: 0,
totalCents: mainCents,
vatRatePercent: input.vatRateFor(product),
personalization: { personalization, templateVersion: CURRENT_TEMPLATE_VERSION } as unknown as Prisma.InputJsonValue,
sponsorshipOfferId: offer.id,
sponsorshipRole: "MAIN",
});
if (offer.honeyJars > 0) {
const honey = offer.honeyProduct ? input.products.get(offer.honeyProduct.id) : undefined;
if (!honey || !honey.isActive) {
return { ok: false, error: "Diese Patenschaft ist derzeit nicht bestellbar." };
}
items.push({
productId: honey.id,
batchId: honey.batchId,
productName: `${honey.name} – ${offer.name}`,
unitPriceCents: offer.honeyShareCentsPerJar,
quantity: offer.honeyJars,
weightGrams: honey.weightGrams,
totalCents: honeyTotal,
vatRatePercent: input.vatRateFor(honey),
sponsorshipOfferId: offer.id,
sponsorshipRole: "HONEY",
});
reserve.push({ productId: honey.id, quantity: offer.honeyJars, productName: honey.name });
physicalQuantity += offer.honeyJars;
}
}
return {
ok: true,
items,
reserve: mergeReservations(reserve),
physicalQuantity,
hasPhysical: physicalQuantity > 0,
hasSponsorship,
};
}
/**
* Fasst Reservierungen desselben Produkts zusammen. Ohne das wuerden zwei
* Pakete mit demselben Honig zwei getrennte "noch mindestens n da?"-Pruefungen
* bestehen, obwohl zusammen zu wenig da ist.
*/
function mergeReservations(lines: ReservationLine[]): ReservationLine[] {
const merged = new Map<string, ReservationLine>();
for (const line of lines) {
const existing = merged.get(line.productId);
merged.set(line.productId, existing ? { ...existing, quantity: existing.quantity + line.quantity } : line);
}
return [...merged.values()];
}
+189
View File
@@ -0,0 +1,189 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
// Die reinen Funktionen ziehen ueber ihre Module lib/prisma.ts nach — fuer
// diese Tests reicht ein leerer Ersatz, eine Datenbank wird nie gefragt.
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
import { formatSponsorshipNumber } from "@/lib/sponsorship/number";
import { certificateRelativePath } from "@/lib/sponsorship/storage";
import { retryDelayMs, MAX_ISSUE_ATTEMPTS } from "@/lib/sponsorship/issue";
import { isOfferAvailable, type SponsorshipOfferView } from "@/lib/sponsorship/offers";
import { expandCheckoutItems, startDateWindow, type CheckoutProduct } from "@/lib/sponsorship/checkout-expand";
describe("Patenschaftsnummer", () => {
it("ist fuenfstellig fortlaufend je Jahr", () => {
expect(formatSponsorshipNumber(2026, 42)).toBe("WH-P-2026-00042");
expect(formatSponsorshipNumber(2027, 12345)).toBe("WH-P-2027-12345");
});
});
describe("Ablagepfad der Urkunde", () => {
it("enthaelt nur Jahr, Nummer und Revision — keine Namen", () => {
expect(certificateRelativePath({ number: "WH-P-2026-00042", revision: 3, year: 2026 })).toBe(
"2026/WH-P-2026-00042/r3.pdf"
);
});
it("weist manipulierte Nummern ab", () => {
expect(() => certificateRelativePath({ number: "../../etc", revision: 1, year: 2026 })).toThrow();
});
});
describe("Wiederholung der Ausstellung", () => {
it("verdoppelt den Abstand und deckelt bei 12 Stunden", () => {
expect(retryDelayMs(1)).toBe(5 * 60 * 1000);
expect(retryDelayMs(2)).toBe(10 * 60 * 1000);
expect(retryDelayMs(MAX_ISSUE_ATTEMPTS + 10)).toBe(12 * 60 * 60 * 1000);
});
});
describe("Bestellbarkeit eines Angebots", () => {
const base = {
isActive: true,
kind: "EINMALIG" as const,
hasCarrierProduct: true,
honeyJars: 3,
honeyProductActive: true,
freeJars: 3,
maxActiveSubscriptions: null,
activeSubscriptions: 0,
};
it("braucht genug FREIEN Honig fuer ein Paket", () => {
expect(isOfferAvailable(base)).toBe(true);
expect(isOfferAvailable({ ...base, freeJars: 2 })).toBe(false);
});
it("ist ohne Honigprodukt oder ohne Traegerprodukt nicht bestellbar", () => {
expect(isOfferAvailable({ ...base, honeyProductActive: null })).toBe(false);
expect(isOfferAvailable({ ...base, hasCarrierProduct: false })).toBe(false);
});
it("stoppt Abos, wenn das Kontingent voll ist", () => {
const abo = { ...base, kind: "ABO" as const, hasCarrierProduct: false, maxActiveSubscriptions: 5 };
expect(isOfferAvailable({ ...abo, activeSubscriptions: 4 })).toBe(true);
expect(isOfferAvailable({ ...abo, activeSubscriptions: 5 })).toBe(false);
});
});
describe("Aufloesen von Patenschaftspaketen im Checkout", () => {
const now = new Date("2026-11-02T10:00:00Z");
const honey: CheckoutProduct = {
id: "honey",
name: "Waldhonig 500 g",
batchId: "batch-1",
priceCents: 990,
weightGrams: 500,
category: "HONIG",
isActive: true,
};
const carrier: CheckoutProduct = {
id: "carrier",
name: "Bienenpatenschaft Wabenfreund",
batchId: null,
priceCents: 3800,
weightGrams: 0,
category: "PATENSCHAFT",
isActive: true,
};
const offer: SponsorshipOfferView = {
id: "offer",
slug: "wabenfreund",
kind: "EINMALIG",
name: "Wabenfreund",
description: "",
features: [],
priceCents: 5900,
isPhysical: true,
honeyJars: 3,
honeyShareCentsPerJar: 700,
honeyShareCentsPerMonth: 0,
cycle: null,
medalTier: "GOLD",
productId: "carrier",
honeyProduct: { id: "honey", name: "Waldhonig 500 g", weightGrams: 500 },
isAvailable: true,
};
const personalization = {
beeName: "Summsi",
recipientName: "Marie-Luise Schmidt",
giverNames: "Anna und Ben",
dedication: "Für die schönste Zeit.",
occasion: "GEBURTSTAG",
startDate: "2026-11-02",
};
const input = (cartItems: { productId: string; quantity: number; personalization?: unknown }[]) => ({
cartItems,
products: new Map([
["honey", honey],
["carrier", carrier],
]),
offers: new Map([["carrier", offer]]),
vatRateFor: (product: CheckoutProduct) => (product.category === "HONIG" ? 7 : 19),
locale: "de" as const,
now,
});
it("teilt den Paketpreis in Patenschaft (19 %) und Honig (7 %) auf", () => {
const result = expandCheckoutItems(input([{ productId: "carrier", quantity: 1, personalization }]));
expect(result.ok).toBe(true);
if (!result.ok) return;
const [main, honeyLine] = result.items;
expect(main).toMatchObject({ productId: "carrier", totalCents: 3800, vatRatePercent: 19, sponsorshipRole: "MAIN" });
expect(honeyLine).toMatchObject({ productId: "honey", quantity: 3, unitPriceCents: 700, totalCents: 2100, vatRatePercent: 7, sponsorshipRole: "HONEY" });
expect(main.totalCents + honeyLine.totalCents).toBe(5900);
// Nur der Honig wird reserviert, die Urkunde hat keinen Bestand.
expect(result.reserve).toEqual([{ productId: "honey", quantity: 3, productName: "Waldhonig 500 g" }]);
expect(result.physicalQuantity).toBe(3);
});
it("haelt zwei gleiche Pakete auseinander und fasst ihre Reservierung zusammen", () => {
const result = expandCheckoutItems(
input([
{ productId: "carrier", quantity: 1, personalization },
{ productId: "carrier", quantity: 1, personalization: { ...personalization, beeName: "Brummi" } },
])
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.items.filter((item) => item.sponsorshipRole === "MAIN")).toHaveLength(2);
expect(result.reserve).toEqual([{ productId: "honey", quantity: 6, productName: "Waldhonig 500 g" }]);
});
it("weist Menge > 1, fehlende oder manipulierte Personalisierung ab", () => {
expect(expandCheckoutItems(input([{ productId: "carrier", quantity: 2, personalization }])).ok).toBe(false);
expect(expandCheckoutItems(input([{ productId: "carrier", quantity: 1 }])).ok).toBe(false);
expect(
expandCheckoutItems(
input([{ productId: "carrier", quantity: 1, personalization: { ...personalization, dedication: "x".repeat(2000) } }])
).ok
).toBe(false);
});
it("verlangt ein Startdatum zwischen heute und in sechs Monaten", () => {
const window = startDateWindow(now);
expect(window).toEqual({ min: "2026-11-02", max: "2027-05-02" });
expect(
expandCheckoutItems(input([{ productId: "carrier", quantity: 1, personalization: { ...personalization, startDate: "2026-11-01" } }])).ok
).toBe(false);
});
it("verkauft kein Paket, dessen Angebot nicht bestellbar ist", () => {
const result = expandCheckoutItems({
...input([{ productId: "carrier", quantity: 1, personalization }]),
offers: new Map([["carrier", { ...offer, isAvailable: false }]]),
});
expect(result.ok).toBe(false);
});
it("erkennt eine rein digitale Bestellung", () => {
const digitalOffer = { ...offer, isPhysical: false, honeyJars: 0, honeyShareCentsPerJar: 0, priceCents: 1900, honeyProduct: null };
const result = expandCheckoutItems({
...input([{ productId: "carrier", quantity: 1, personalization }]),
offers: new Map([["carrier", digitalOffer]]),
});
expect(result.ok && result.hasPhysical).toBe(false);
expect(result.ok && result.items).toHaveLength(1);
});
});
+80
View File
@@ -0,0 +1,80 @@
import { emailLayout, button, type EmailContent } from "@/lib/email-templates";
import { escapeHtml } from "@/lib/escape-html";
import type { Locale } from "@/i18n/config";
/**
* Mails der Bienenpatenschaft. Bewusst ohne Versprechen von Besuchen,
* Berichten, Fotos oder Umweltwirkungen (siehe Spec, "Nicht im Umfang") —
* die Mails sagen nur, was tatsaechlich geliefert wird: Urkunde, Medaille,
* Downloads im Konto und je nach Paket Honig.
*/
const strings = {
de: {
readySubject: (bee: string) => `Deine Urkunde für Biene ${bee} ist da`,
readyPreheader: "Urkunde, Medaille und Downloads warten in deinem Konto.",
readyHeading: (name: string) => `Hallo ${name},`,
readyBody: (bee: string, number: string) =>
`die Urkunde zur Bienenpatenschaft für <strong>${bee}</strong> (Nr. ${number}) ist fertig. In deinem Konto findest du sie unter „Patenschaft“ – mit 3D-Ansicht, Medaille und allen Dateien zum Herunterladen.`,
readyButton: "Zur Patenschaft",
readyNotice:
"Die Namensgebung ist symbolisch. Sie gilt keiner einzeln begleiteten Biene, sondern unterstützt die Imkerei Wabenhain und ihre Bienenvölker.",
correctedSubject: (bee: string) => `Deine korrigierte Urkunde für Biene ${bee}`,
correctedBody: (bee: string) =>
`wir haben die Urkunde für <strong>${bee}</strong> korrigiert. Die neue Fassung liegt ab sofort in deinem Konto.`,
},
en: {
readySubject: (bee: string) => `Your certificate for bee ${bee} is ready`,
readyPreheader: "Certificate, medal and downloads are waiting in your account.",
readyHeading: (name: string) => `Hello ${name},`,
readyBody: (bee: string, number: string) =>
`the certificate for your bee sponsorship of <strong>${bee}</strong> (no. ${number}) is ready. You will find it in your account under “Sponsorship” – with a 3D view, your medal and all files to download.`,
readyButton: "Open sponsorship",
readyNotice:
"The naming is symbolic. It does not refer to an individually tracked bee; it supports the Wabenhain apiary and its colonies.",
correctedSubject: (bee: string) => `Your corrected certificate for bee ${bee}`,
correctedBody: (bee: string) =>
`we have corrected the certificate for <strong>${bee}</strong>. The new version is now available in your account.`,
},
} as const;
interface ReadyMailInput {
locale: Locale;
firstName: string;
beeName: string;
number: string;
accountUrl: string;
}
export async function sponsorshipReadyEmail(input: ReadyMailInput): Promise<EmailContent> {
const s = strings[input.locale];
const bee = escapeHtml(input.beeName);
const html = await emailLayout(
input.locale,
s.readyPreheader,
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.readyHeading(escapeHtml(input.firstName))}</h1>
<p style="margin:0 0 16px;line-height:1.6;">${s.readyBody(bee, escapeHtml(input.number))}</p>
<div>${button(input.accountUrl, s.readyButton)}</div>
<p style="margin:24px 0 0;font-size:12px;line-height:1.5;color:#666;">${s.readyNotice}</p>
`
);
const text = `${s.readyHeading(input.firstName)}\n\n${s.readyBody(input.beeName, input.number).replace(/<\/?strong>/g, "")}\n\n${s.readyButton}: ${input.accountUrl}\n\n${s.readyNotice}`;
return { subject: s.readySubject(input.beeName), html, text };
}
export async function sponsorshipCorrectedEmail(input: ReadyMailInput): Promise<EmailContent> {
const s = strings[input.locale];
const bee = escapeHtml(input.beeName);
const html = await emailLayout(
input.locale,
s.readyPreheader,
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.readyHeading(escapeHtml(input.firstName))}</h1>
<p style="margin:0 0 16px;line-height:1.6;">${s.correctedBody(bee)}</p>
<div>${button(input.accountUrl, s.readyButton)}</div>
`
);
const text = `${s.readyHeading(input.firstName)}\n\n${s.correctedBody(input.beeName).replace(/<\/?strong>/g, "")}\n\n${s.readyButton}: ${input.accountUrl}`;
return { subject: s.correctedSubject(input.beeName), html, text };
}
+259
View File
@@ -0,0 +1,259 @@
import { prisma } from "@/lib/prisma";
import type { Prisma } from "@/generated/prisma/client";
import { sendMail } from "@/lib/mail";
import { getBaseUrl } from "@/lib/url";
import { isLocale, defaultLocale, type Locale } from "@/i18n/config";
import { allocateSponsorshipNumber } from "@/lib/sponsorship/number";
import { certificateRelativePath, storeCertificatePdf } from "@/lib/sponsorship/storage";
import {
fromIsoDate,
parseStoredPersonalization,
renderCertificateDocumentPdf,
termForSponsorship,
} from "@/lib/sponsorship/certificate-document";
import { fixedTermFor } from "@/lib/certificate/text";
import { sponsorshipReadyEmail } from "@/lib/sponsorship/emails";
/**
* Ausstellung der Urkunde nach bestaetigter Zahlung.
*
* Zwei Schritte mit eigener Fehlerbehandlung:
*
* 1. createSponsorshipsForOrder legt im selben Transaktionslauf, der die
* Bestellung als bezahlt markiert, je Patenschaftsposition eine
* Patenschaft an. Der eindeutige orderItemId ist der Schutz gegen doppelt
* zugestellte Webhooks: eine zweite Anlage scheitert an der Datenbank,
* nicht an einer Pruefung, die zwei parallele Laeufe beide bestehen
* koennten.
* 2. processSponsorship rendert die PDF, legt sie ab, speichert die erste
* Revision und verschickt die Mail. Scheitert das, bleibt die Patenschaft
* AUSSTEHEND und der CronJob (scripts/sponsorship-jobs.ts) versucht es mit
* wachsendem Abstand erneut — die Zahlung ist erfolgt, ein 500er an Stripe
* wuerde daran nichts verbessern.
*/
export const MAX_ISSUE_ATTEMPTS = 8;
const BASE_BACKOFF_MS = 5 * 60 * 1000;
const MAX_BACKOFF_MS = 12 * 60 * 60 * 1000;
/** Wartezeit vor dem n-ten Wiederholungsversuch (n ab 1): 5, 10, 20 … Minuten, hoechstens 12 Stunden. */
export function retryDelayMs(attempt: number): number {
return Math.min(BASE_BACKOFF_MS * 2 ** Math.max(0, attempt - 1), MAX_BACKOFF_MS);
}
/** Legt fehlende Patenschaften zu den bezahlten Patenschaftspositionen einer Bestellung an. */
export async function createSponsorshipsForOrder(tx: Prisma.TransactionClient, orderId: string): Promise<number> {
const order = await tx.order.findUnique({
where: { id: orderId },
select: {
customerId: true,
locale: true,
orderNumber: true,
items: {
where: { sponsorshipRole: "MAIN" },
select: { id: true, personalization: true, sponsorshipOfferId: true, sponsorship: { select: { id: true } } },
},
},
});
if (!order) return 0;
let created = 0;
for (const item of order.items) {
if (item.sponsorship) continue;
if (!order.customerId || !item.sponsorshipOfferId) {
// Kann nur durch einen Fehler im Checkout entstehen (der verlangt fuer
// Patenschaften ein Konto). Laut melden statt still eine Patenschaft
// ohne Besitzer anzulegen, die niemand je sehen koennte.
console.error(
`[patenschaft] Bestellung ${order.orderNumber}, Position ${item.id}: Patenschaft ohne Konto oder Angebot — nicht angelegt, bitte pruefen.`
);
continue;
}
const stored = parseStoredPersonalization(item.personalization);
const start = stored.personalization.startDate;
const term = fixedTermFor(start);
const number = await allocateSponsorshipNumber(tx, new Date().getUTCFullYear());
await tx.sponsorship.create({
data: {
number,
ownerId: order.customerId,
offerId: item.sponsorshipOfferId,
orderItemId: item.id,
locale: isLocale(order.locale) ? order.locale : defaultLocale,
startDate: fromIsoDate(start),
endDate: term.kind === "OPEN" ? null : fromIsoDate(term.end),
},
});
created++;
}
return created;
}
type SponsorshipForIssue = NonNullable<Awaited<ReturnType<typeof loadForIssue>>>;
function loadForIssue(id: string) {
return prisma.sponsorship.findUnique({
where: { id },
include: {
orderItem: { select: { personalization: true } },
subscription: { select: { personalization: true } },
owner: { select: { email: true, firstName: true } },
},
});
}
/** Rendert und speichert Revision 1, falls es sie noch nicht gibt. */
async function ensureFirstRevision(sponsorship: SponsorshipForIssue): Promise<void> {
if (sponsorship.currentRevision > 0) return;
const source = sponsorship.orderItem?.personalization ?? sponsorship.subscription?.personalization;
const stored = parseStoredPersonalization(source);
const locale: Locale = isLocale(sponsorship.locale) ? sponsorship.locale : defaultLocale;
const term = termForSponsorship({
startDate: sponsorship.startDate,
endDate: sponsorship.endDate,
isSubscription: sponsorship.subscriptionId !== null,
});
const input = {
personalization: stored.personalization,
locale,
term,
number: sponsorship.number,
templateVersion: stored.templateVersion,
};
// Erstellungsdatum = Anlage der Patenschaft, nicht "jetzt": ein
// Wiederholungslauf erzeugt so byte-gleiche PDFs (siehe storeCertificatePdf).
const { pdf, text } = await renderCertificateDocumentPdf(input, sponsorship.createdAt);
const pdfPath = certificateRelativePath({
number: sponsorship.number,
revision: 1,
year: sponsorship.createdAt.getUTCFullYear(),
});
const { sha256 } = await storeCertificatePdf(pdfPath, pdf);
await prisma.$transaction(async (tx) => {
const claim = await tx.sponsorship.updateMany({
where: { id: sponsorship.id, currentRevision: 0 },
data: { currentRevision: 1, lastError: null, nextAttemptAt: null },
});
// Ein paralleler Lauf war schneller — seine Revision gilt, die Datei ist
// dieselbe (gleicher Pfad, gleicher Inhalt).
if (claim.count === 0) return;
await tx.sponsorshipRevision.create({
data: {
sponsorshipId: sponsorship.id,
revision: 1,
personalization: input.personalization as unknown as Prisma.InputJsonValue,
term: term as unknown as Prisma.InputJsonValue,
text: text as unknown as Prisma.InputJsonValue,
templateVersion: input.templateVersion,
pdfPath,
pdfSha256: sha256,
createdByLabel: "System",
},
});
});
}
/**
* Verschickt die Mail "Deine Urkunde ist da" genau einmal. mailSentAt wird
* VOR dem Versand atomar beansprucht; scheitert der Versand, wird der
* Anspruch zurueckgenommen, damit der naechste Lauf es erneut versucht.
*/
async function sendReadyMailOnce(sponsorship: SponsorshipForIssue): Promise<boolean> {
const claim = await prisma.sponsorship.updateMany({
where: { id: sponsorship.id, mailSentAt: null, currentRevision: { gt: 0 } },
data: { mailSentAt: new Date() },
});
if (claim.count === 0) return true;
const revision = await prisma.sponsorshipRevision.findUnique({
where: { sponsorshipId_revision: { sponsorshipId: sponsorship.id, revision: 1 } },
select: { text: true },
});
const beeName = (revision?.text as { beeName?: string } | null)?.beeName ?? "";
const locale: Locale = isLocale(sponsorship.locale) ? sponsorship.locale : defaultLocale;
const { subject, html, text } = await sponsorshipReadyEmail({
locale,
firstName: sponsorship.owner.firstName,
beeName,
number: sponsorship.number,
accountUrl: `${getBaseUrl()}/konto/patenschaft/${sponsorship.id}`,
});
const sent = await sendMail({ to: sponsorship.owner.email, subject, html, text });
if (!sent) {
await prisma.sponsorship.update({ where: { id: sponsorship.id }, data: { mailSentAt: null } });
return false;
}
return true;
}
export type ProcessResult = "ready" | "retry" | "failed" | "missing";
/**
* Bringt eine Patenschaft so weit wie moeglich: Urkunde erzeugen, Mail
* verschicken, Status setzen. Fehler werden festgehalten und fuer den
* naechsten Versuch eingeplant, nie geworfen.
*/
export async function processSponsorship(id: string, now: Date = new Date()): Promise<ProcessResult> {
const sponsorship = await loadForIssue(id);
if (!sponsorship) return "missing";
if (sponsorship.status === "BEREIT" || sponsorship.status === "GEDRUCKT") return "ready";
try {
await ensureFirstRevision(sponsorship);
const fresh = await loadForIssue(id);
if (!fresh) return "missing";
const mailed = await sendReadyMailOnce(fresh);
if (!mailed) throw new Error("Mailversand fehlgeschlagen (SMTP).");
await prisma.sponsorship.updateMany({
where: { id, status: { in: ["AUSSTEHEND", "FEHLER"] } },
data: { status: "BEREIT", lastError: null, nextAttemptAt: null },
});
return "ready";
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const attempts = sponsorship.pdfAttempts + 1;
const exhausted = attempts >= MAX_ISSUE_ATTEMPTS;
console.error(`[patenschaft] ${sponsorship.number}: Versuch ${attempts} fehlgeschlagen: ${message}`);
await prisma.sponsorship.update({
where: { id },
data: {
pdfAttempts: attempts,
lastError: message.slice(0, 2000),
nextAttemptAt: exhausted ? null : new Date(now.getTime() + retryDelayMs(attempts)),
status: exhausted ? "FEHLER" : "AUSSTEHEND",
},
});
return exhausted ? "failed" : "retry";
}
}
/** Alle faelligen offenen Patenschaften abarbeiten (CronJob und Webhook). */
export async function processPendingSponsorships(options: { orderId?: string; limit?: number; now?: Date } = {}): Promise<Record<ProcessResult, number>> {
const now = options.now ?? new Date();
const pending = await prisma.sponsorship.findMany({
where: {
status: "AUSSTEHEND",
...(options.orderId
? { orderItem: { orderId: options.orderId } }
: { OR: [{ nextAttemptAt: null }, { nextAttemptAt: { lte: now } }] }),
},
select: { id: true },
orderBy: { createdAt: "asc" },
take: options.limit ?? 50,
});
const counts: Record<ProcessResult, number> = { ready: 0, retry: 0, failed: 0, missing: 0 };
for (const { id } of pending) {
counts[await processSponsorship(id, now)]++;
}
return counts;
}
/** Admin: eine Patenschaft im Status FEHLER erneut anstossen (Zaehler zurueck). */
export async function resetSponsorshipForRetry(id: string): Promise<void> {
await prisma.sponsorship.updateMany({
where: { id, status: "FEHLER" },
data: { status: "AUSSTEHEND", pdfAttempts: 0, nextAttemptAt: null, lastError: null },
});
}
+23
View File
@@ -0,0 +1,23 @@
import type { Prisma } from "@/generated/prisma/client";
/**
* Patenschaftsnummer WH-P-JJJJ-NNNNN.
*
* Fortlaufend je Jahr ueber eine Zaehlerzeile mit Sperre — dasselbe Muster
* wie die Rechnungsnummern (siehe InvoiceCounter im Schema): ein Upsert mit
* Inkrement sperrt die Zeile bis zum Ende der Transaktion, zwei gleichzeitig
* bezahlte Bestellungen bekommen so nie dieselbe Nummer.
*/
export function formatSponsorshipNumber(year: number, sequence: number): string {
return `WH-P-${year}-${String(sequence).padStart(5, "0")}`;
}
export async function allocateSponsorshipNumber(tx: Prisma.TransactionClient, year: number): Promise<string> {
const counter = await tx.sponsorshipCounter.upsert({
where: { year },
create: { year, lastNumber: 1 },
update: { lastNumber: { increment: 1 } },
select: { lastNumber: true },
});
return formatSponsorshipNumber(year, counter.lastNumber);
}
+167
View File
@@ -0,0 +1,167 @@
import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array";
import { availableForSale } from "@/lib/sponsorship-subscription/reservation";
import type { Locale } from "@/i18n/config";
import type {
MedalTier,
SponsorshipCycle,
SponsorshipOfferKind,
} from "@/generated/prisma/client";
/**
* Angebote der Bienenpatenschaft, so aufbereitet, wie Verkaufsseite,
* Warenkorb und Checkout sie brauchen: lokalisiert, mit Leistungsliste als
* Array und mit der Aussage, ob das Angebot gerade bestellbar ist.
*
* "Bestellbar" haengt am Honig: ein physisches Paket ohne genug Glaeser des
* verknuepften Honigprodukts darf nicht verkauft werden — sonst bekaeme die
* Kundschaft eine Urkunde ohne den bezahlten Honig. Gezaehlt wird der FREIE
* Bestand, also abzueglich der fuer Abos reservierten Glaeser. Bei Abos
* zaehlt zusaetzlich das Kontingent (maxActiveSubscriptions).
*/
export interface SponsorshipOfferView {
id: string;
slug: string;
kind: SponsorshipOfferKind;
name: string;
description: string;
features: string[];
/** EINMALIG: Paketpreis brutto. ABO: Monatspreis brutto. */
priceCents: number;
isPhysical: boolean;
honeyJars: number;
honeyShareCentsPerJar: number;
honeyShareCentsPerMonth: number;
cycle: SponsorshipCycle | null;
medalTier: MedalTier;
/** Traegerprodukt (nur EINMALIG). */
productId: string | null;
honeyProduct: { id: string; name: string; weightGrams: number } | null;
/** false = derzeit nicht bestellbar (Honig fehlt, Kontingent voll, Angebot aus). */
isAvailable: boolean;
}
const offerInclude = {
honeyProduct: { select: { id: true, name: true, weightGrams: true, isActive: true } },
} as const;
async function loadOfferRows(where: { isActive?: boolean; kind?: SponsorshipOfferKind; slug?: string; productId?: { in: string[] } }) {
return prisma.sponsorshipOffer.findMany({
where,
include: offerInclude,
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
});
}
type OfferRow = Awaited<ReturnType<typeof loadOfferRows>>[number];
function localize(row: OfferRow, locale: Locale, isAvailable: boolean): SponsorshipOfferView {
const en = locale === "en";
const featuresEn = asStringArray(row.featuresEn);
return {
id: row.id,
slug: row.slug,
kind: row.kind,
name: (en && row.nameEn) || row.name,
description: (en && row.descriptionEn) || row.description,
features: en && featuresEn.length > 0 ? featuresEn : asStringArray(row.features),
priceCents: row.priceCents,
isPhysical: row.isPhysical,
honeyJars: row.honeyJars,
honeyShareCentsPerJar: row.honeyShareCentsPerJar,
honeyShareCentsPerMonth: row.honeyShareCentsPerMonth,
cycle: row.cycle,
medalTier: row.medalTier,
productId: row.productId,
honeyProduct: row.honeyProduct
? { id: row.honeyProduct.id, name: row.honeyProduct.name, weightGrams: row.honeyProduct.weightGrams }
: null,
isAvailable,
};
}
async function freeJarsByProduct(rows: OfferRow[]): Promise<Map<string, number>> {
const ids = [...new Set(rows.map((row) => row.honeyProduct?.id).filter((id): id is string => Boolean(id)))];
const entries = await Promise.all(ids.map(async (id) => [id, await availableForSale(id)] as const));
return new Map(entries);
}
async function activeSubscriptionCounts(rows: OfferRow[]): Promise<Map<string, number>> {
const aboIds = rows.filter((row) => row.kind === "ABO" && row.maxActiveSubscriptions !== null).map((row) => row.id);
if (aboIds.length === 0) return new Map();
const groups = await prisma.sponsorshipSubscription.groupBy({
by: ["offerId"],
where: { offerId: { in: aboIds }, status: { in: ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] } },
_count: { _all: true },
});
return new Map(groups.map((group) => [group.offerId, group._count._all]));
}
/** Reine Entscheidung "bestellbar?", getrennt von den Abfragen, damit sie testbar bleibt. */
export function isOfferAvailable(input: {
isActive: boolean;
kind: SponsorshipOfferKind;
hasCarrierProduct: boolean;
honeyJars: number;
honeyProductActive: boolean | null;
freeJars: number;
maxActiveSubscriptions: number | null;
activeSubscriptions: number;
}): boolean {
if (!input.isActive) return false;
if (input.kind === "EINMALIG" && !input.hasCarrierProduct) return false;
if (input.honeyJars > 0) {
if (!input.honeyProductActive) return false;
if (input.freeJars < input.honeyJars) return false;
}
if (input.kind === "ABO" && input.maxActiveSubscriptions !== null) {
if (input.activeSubscriptions >= input.maxActiveSubscriptions) return false;
}
return true;
}
async function toViews(rows: OfferRow[], locale: Locale): Promise<SponsorshipOfferView[]> {
const [jars, subscriptionCounts] = await Promise.all([freeJarsByProduct(rows), activeSubscriptionCounts(rows)]);
return rows.map((row) =>
localize(
row,
locale,
isOfferAvailable({
isActive: row.isActive,
kind: row.kind,
hasCarrierProduct: row.productId !== null,
honeyJars: row.honeyJars,
honeyProductActive: row.honeyProduct ? row.honeyProduct.isActive : null,
freeJars: row.honeyProduct ? (jars.get(row.honeyProduct.id) ?? 0) : 0,
maxActiveSubscriptions: row.maxActiveSubscriptions,
activeSubscriptions: subscriptionCounts.get(row.id) ?? 0,
})
)
);
}
export async function getActiveOffers(locale: Locale, kind?: SponsorshipOfferKind): Promise<SponsorshipOfferView[]> {
return toViews(await loadOfferRows({ isActive: true, ...(kind ? { kind } : {}) }), locale);
}
export async function getOfferBySlug(slug: string, locale: Locale): Promise<SponsorshipOfferView | null> {
const [view] = await toViews(await loadOfferRows({ slug }), locale);
return view ?? null;
}
/** Angebote zu Traegerprodukten (der Checkout kennt nur productIds). */
export async function getOffersByProductIds(
productIds: string[],
locale: Locale
): Promise<Map<string, SponsorshipOfferView>> {
if (productIds.length === 0) return new Map();
const views = await toViews(await loadOfferRows({ productId: { in: productIds } }), locale);
return new Map(
views.flatMap((view) => (view.productId ? [[view.productId, view] as const] : []))
);
}
/** Gibt es ueberhaupt ein aktives Angebot? Steuert Navigation und Verkaufsseite. */
export async function hasActiveOffers(): Promise<boolean> {
return (await prisma.sponsorshipOffer.count({ where: { isActive: true } })) > 0;
}
+152
View File
@@ -0,0 +1,152 @@
import { describe, expect, it, vi } from "vitest";
// revise.ts zieht ueber Prisma, Mail und Audit-Log Infrastruktur nach; die
// hier getesteten Teile sind rein und brauchen davon nichts.
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/mail", () => ({ sendMail: vi.fn() }));
vi.mock("@/lib/audit-log", () => ({ logAdminAction: vi.fn() }));
import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates";
import type { Personalization } from "@/lib/certificate/schema";
import { diffPersonalization, parseRevisionInput, revisionTerm, type RevisionContext } from "@/lib/sponsorship/revise";
const base: Personalization = {
beeName: "Summsi",
recipientName: "Anna Muster",
giverNames: "Ben und Carla",
dedication: "Alles Liebe",
occasion: "GEBURTSTAG",
startDate: "2026-11-01",
};
function context(overrides: Partial<RevisionContext> = {}): RevisionContext {
return {
current: { personalization: base, templateVersion: CURRENT_TEMPLATE_VERSION },
isSubscription: false,
orderDate: "2026-10-16",
...overrides,
};
}
const reason = "Tippfehler im Namen";
describe("diffPersonalization", () => {
it("meldet keine Aenderung bei gleicher Fassung", () => {
expect(diffPersonalization(base, { ...base })).toEqual([]);
});
it("nennt genau die geaenderten Felder in fester Reihenfolge", () => {
const after = { ...base, startDate: "2026-12-01", beeName: "Summse" };
expect(diffPersonalization(base, after)).toEqual(["beeName", "startDate"]);
});
it("behandelt fehlende und leere optionale Felder als gleich", () => {
const before = { ...base, dedication: undefined };
expect(diffPersonalization(before, { ...before, dedication: undefined })).toEqual([]);
expect(diffPersonalization(before, { ...before, dedication: "Neu" })).toEqual(["dedication"]);
});
});
describe("parseRevisionInput", () => {
it("verlangt einen Aenderungsgrund", () => {
const result = parseRevisionInput({ personalization: { ...base, beeName: "Summse" }, reason: " x " }, context());
expect(result).toEqual({ ok: false, error: { code: "reason", min: 3, max: 500 } });
});
it("lehnt eine unbekannte Vorlagenversion ab", () => {
const result = parseRevisionInput(
{ personalization: { ...base, beeName: "Summse" }, templateVersion: "gibt-es-nicht@9", reason },
context()
);
expect(result).toEqual({ ok: false, error: { code: "unknown_template" } });
});
it("prueft mit dem Kundenschema und nennt das Feld", () => {
const result = parseRevisionInput({ personalization: { ...base, beeName: "S" }, reason }, context());
expect(result).toEqual({ ok: false, error: { code: "invalid", field: "beeName", message: "too_short" } });
});
it("lehnt eine ueberlange Widmung ab", () => {
const result = parseRevisionInput({ personalization: { ...base, dedication: "a".repeat(281) }, reason }, context());
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatchObject({ code: "invalid", field: "dedication" });
});
it("weist eine Korrektur ohne Aenderung zurueck", () => {
const result = parseRevisionInput({ personalization: { ...base }, reason }, context());
expect(result).toEqual({ ok: false, error: { code: "unchanged" } });
});
it("normalisiert wie das Kundenformular (Leerraum zaehlt nicht als Aenderung)", () => {
const result = parseRevisionInput({ personalization: { ...base, beeName: " Summsi " }, reason }, context());
expect(result).toEqual({ ok: false, error: { code: "unchanged" } });
});
it("liefert geaenderte Felder und den getrimmten Grund", () => {
const result = parseRevisionInput({ personalization: { ...base, beeName: "Summse" }, reason: ` ${reason} ` }, context());
expect(result).toEqual({
ok: true,
value: {
personalization: { ...base, beeName: "Summse" },
templateVersion: CURRENT_TEMPLATE_VERSION,
reason,
changedFields: ["beeName"],
templateChanged: false,
},
});
});
it("verwirft Schenkende bei 'fuer mich selbst' und meldet das als Aenderung", () => {
const result = parseRevisionInput({ personalization: { ...base, occasion: "SELBST" }, reason }, context());
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.personalization.giverNames).toBeUndefined();
expect(result.value.changedFields).toEqual(["giverNames", "occasion"]);
}
});
it("erlaubt ein Startdatum nur im Fenster ab Bestelltag bis sechs Monate danach", () => {
const tooLate = parseRevisionInput({ personalization: { ...base, startDate: "2027-05-01" }, reason }, context());
expect(tooLate).toEqual({
ok: false,
error: { code: "start_date_out_of_range", min: "2026-10-16", max: "2027-04-16" },
});
const inside = parseRevisionInput({ personalization: { ...base, startDate: "2027-04-16" }, reason }, context());
expect(inside.ok).toBe(true);
});
it("laesst das Startdatum eines Abos unveraendert", () => {
const result = parseRevisionInput(
{ personalization: { ...base, startDate: "2027-01-01", beeName: "Summse" }, reason },
context({ isSubscription: true })
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.personalization.startDate).toBe(base.startDate);
expect(result.value.changedFields).toEqual(["beeName"]);
}
});
});
describe("revisionTerm", () => {
it("Einmal-Patenschaft: zwoelf Monate ab dem neuen Start", () => {
const term = revisionTerm({
isSubscription: false,
personalization: { ...base, startDate: "2026-12-01" },
startDate: new Date("2026-11-01T00:00:00Z"),
endDate: new Date("2027-10-31T00:00:00Z"),
});
expect(term).toEqual({ kind: "FIXED", start: "2026-12-01", end: "2027-11-30" });
});
it("Abo: laufend ohne Ende, beendet mit Ende", () => {
const start = new Date("2026-11-01T00:00:00Z");
expect(revisionTerm({ isSubscription: true, personalization: base, startDate: start, endDate: null })).toEqual({
kind: "OPEN",
start: "2026-11-01",
});
expect(
revisionTerm({ isSubscription: true, personalization: base, startDate: start, endDate: new Date("2027-02-28T00:00:00Z") })
).toEqual({ kind: "ENDED", start: "2026-11-01", end: "2027-02-28" });
});
});
+315
View File
@@ -0,0 +1,315 @@
import { prisma } from "@/lib/prisma";
import type { Prisma } from "@/generated/prisma/client";
import { logAdminAction } from "@/lib/audit-log";
import { sendMail } from "@/lib/mail";
import { getBaseUrl } from "@/lib/url";
import { defaultLocale, isLocale, type Locale } from "@/i18n/config";
import {
fixedTermFor,
personalizationSchema,
startDateRange,
type CertificateTerm,
type Personalization,
} from "@/lib/certificate/schema";
import { checkCertificateFits } from "@/lib/certificate/layout";
import type { CertificateField } from "@/lib/certificate/elements";
import { listTemplateVersions } from "@/lib/certificate/templates";
import {
fromIsoDate,
parseStoredPersonalization,
renderCertificateDocumentPdf,
termForSponsorship,
toIsoDate,
} from "@/lib/sponsorship/certificate-document";
import { certificateRelativePath, readCertificatePdf, storeCertificatePdf } from "@/lib/sponsorship/storage";
import { sponsorshipCorrectedEmail } from "@/lib/sponsorship/emails";
/**
* Korrektur einer Urkunde durch den Admin (Spec, "Adminbereich → Korrektur").
*
* Eine Korrektur ueberschreibt nie etwas: sie erzeugt eine neue Revision mit
* eigener PDF, die alte bleibt samt Datei abrufbar. Was die Kundschaft einmal
* heruntergeladen hat, laesst sich so jederzeit belegen.
*
* Die reinen Teile (Pruefung der Eingabe, Vergleich der Felder, Laufzeit)
* sind eigene Funktionen ohne Datenbank — die Korrekturvorschau im Admin
* nutzt dieselben, damit Vorschau und gespeicherte Revision nicht
* auseinanderlaufen.
*/
/** Felder des Kundenformulars, die eine Korrektur aendern kann. */
export const REVISABLE_FIELDS = ["beeName", "recipientName", "giverNames", "dedication", "occasion", "startDate"] as const;
export type RevisableField = (typeof REVISABLE_FIELDS)[number];
export const REASON_LIMITS = { min: 3, max: 500 } as const;
/** Welche Felder sich zwischen zwei Fassungen unterscheiden (leer = gleich). */
export function diffPersonalization(before: Personalization, after: Personalization): RevisableField[] {
return REVISABLE_FIELDS.filter((field) => (before[field] ?? "") !== (after[field] ?? ""));
}
export type RevisionInputError =
| { code: "invalid"; field: RevisableField | null; message: string }
| { code: "start_date_out_of_range"; min: string; max: string }
| { code: "unknown_template" }
| { code: "reason"; min: number; max: number }
| { code: "unchanged" };
export interface RevisionContext {
/** Personalisierung und Vorlage der aktuellen Revision. */
current: { personalization: Personalization; templateVersion: string };
/** Abo-Patenschaften beginnen mit der ersten Zahlung — ihr Startdatum ist nicht korrigierbar. */
isSubscription: boolean;
/** Tag der Bestellung (JJJJ-MM-TT): Grundlage des erlaubten Startfensters. */
orderDate: string;
}
export interface ParsedRevisionInput {
personalization: Personalization;
templateVersion: string;
reason: string;
changedFields: RevisableField[];
templateChanged: boolean;
}
/**
* Prueft die Formulareingabe einer Korrektur mit demselben Schema wie das
* Kundenformular. Das Startdatum einer Einmal-Patenschaft darf im selben
* Fenster liegen wie beim Kauf (Bestelltag bis sechs Monate danach); bei
* Abos bleibt es unveraendert. Eine Korrektur ohne jede Aenderung wird
* abgelehnt — sie wuerde nur eine inhaltsgleiche Revision erzeugen.
*/
export function parseRevisionInput(
input: { personalization: unknown; templateVersion?: string | null; reason: string },
context: RevisionContext
): { ok: true; value: ParsedRevisionInput } | { ok: false; error: RevisionInputError } {
const reason = input.reason.trim();
if (reason.length < REASON_LIMITS.min || reason.length > REASON_LIMITS.max) {
return { ok: false, error: { code: "reason", ...REASON_LIMITS } };
}
const templateVersion = input.templateVersion?.trim() || context.current.templateVersion;
if (!listTemplateVersions().includes(templateVersion)) {
return { ok: false, error: { code: "unknown_template" } };
}
const raw = input.personalization && typeof input.personalization === "object" ? input.personalization : {};
const withStart = context.isSubscription
? { ...raw, startDate: context.current.personalization.startDate }
: raw;
const parsed = personalizationSchema.safeParse(withStart);
if (!parsed.success) {
const issue = parsed.error.issues[0];
const field = REVISABLE_FIELDS.find((name) => name === issue?.path[0]) ?? null;
return { ok: false, error: { code: "invalid", field, message: issue?.message ?? "invalid" } };
}
const personalization: Personalization = parsed.data;
if (!context.isSubscription && personalization.startDate !== context.current.personalization.startDate) {
const range = startDateRange(context.orderDate);
if (personalization.startDate < range.min || personalization.startDate > range.max) {
return { ok: false, error: { code: "start_date_out_of_range", ...range } };
}
}
const changedFields = diffPersonalization(context.current.personalization, personalization);
const templateChanged = templateVersion !== context.current.templateVersion;
if (changedFields.length === 0 && !templateChanged) {
return { ok: false, error: { code: "unchanged" } };
}
return { ok: true, value: { personalization, templateVersion, reason, changedFields, templateChanged } };
}
/**
* Laufzeit der korrigierten Urkunde. Einmal-Patenschaft: zwoelf Monate ab dem
* (moeglicherweise korrigierten) Startdatum. Abo: unveraendert aus den Daten
* der Patenschaft (laufend oder beendet).
*/
export function revisionTerm(input: {
isSubscription: boolean;
personalization: Personalization;
startDate: Date;
endDate: Date | null;
}): CertificateTerm {
if (!input.isSubscription) return fixedTermFor(input.personalization.startDate);
return termForSponsorship({ startDate: input.startDate, endDate: input.endDate, isSubscription: true });
}
export interface ReviseSponsorshipInput {
sponsorshipId: string;
/** Rohe Formularwerte; werden hier geprueft. */
personalization: unknown;
reason: string;
admin: { id: string; email: string };
notifyCustomer: boolean;
/** Leer = Version der bisherigen Revision (Spec: "Standardmaessig mit der Vorlagenversion der bisherigen Revision"). */
templateVersion?: string | null;
}
export type ReviseSponsorshipError =
| RevisionInputError
| { code: "not_found" }
| { code: "no_revision" }
| { code: "glyphs"; field: CertificateField; characters: string[] }
| { code: "fit"; field: CertificateField }
| { code: "conflict" };
export type ReviseSponsorshipResult =
| { ok: true; revision: number; changedFields: RevisableField[]; templateChanged: boolean; mailSent: boolean | null }
| { ok: false; error: ReviseSponsorshipError };
/** Wie viele belegte Revisionsnummern hoechstens uebersprungen werden (siehe nextFreeRevision). */
const MAX_REVISION_PROBES = 5;
async function loadForRevision(id: string) {
return prisma.sponsorship.findUnique({
where: { id },
include: {
owner: { select: { email: true, firstName: true } },
orderItem: { select: { order: { select: { createdAt: true } } } },
},
});
}
type SponsorshipForRevision = NonNullable<Awaited<ReturnType<typeof loadForRevision>>>;
/**
* Naechste Revisionsnummer, deren Datei noch nicht existiert. Bricht ein Lauf
* nach dem Ablegen der PDF, aber vor dem Speichern der Revision ab, bleibt
* eine verwaiste Datei zurueck. Weil Urkunden nie ueberschrieben werden
* (storeCertificatePdf), wuerde jede weitere Korrektur an genau dieser Datei
* scheitern — deshalb wird die belegte Nummer uebersprungen.
*/
async function nextFreeRevision(sponsorship: SponsorshipForRevision): Promise<{ revision: number; pdfPath: string }> {
const year = sponsorship.createdAt.getUTCFullYear();
for (let probe = 1; probe <= MAX_REVISION_PROBES; probe++) {
const revision = sponsorship.currentRevision + probe;
const pdfPath = certificateRelativePath({ number: sponsorship.number, revision, year });
const taken = await prisma.sponsorshipRevision.count({ where: { sponsorshipId: sponsorship.id, revision } });
if (taken === 0 && (await readCertificatePdf(pdfPath)) === null) return { revision, pdfPath };
}
throw new Error(`Keine freie Revisionsnummer fuer ${sponsorship.number} gefunden.`);
}
function describeChange(parsed: ParsedRevisionInput, revision: number): string {
const fields = parsed.changedFields.length > 0 ? parsed.changedFields.join(", ") : "keine Felder";
const template = parsed.templateChanged ? `; Vorlage ${parsed.templateVersion}` : "";
return `Revision ${revision}: geändert ${fields}${template}. Grund: ${parsed.reason}`;
}
async function notifyCustomer(sponsorship: SponsorshipForRevision, locale: Locale, beeName: string): Promise<boolean> {
const { subject, html, text } = await sponsorshipCorrectedEmail({
locale,
firstName: sponsorship.owner.firstName,
beeName,
number: sponsorship.number,
accountUrl: `${getBaseUrl()}/konto/patenschaft/${sponsorship.id}`,
});
return sendMail({ to: sponsorship.owner.email, subject, html, text });
}
/**
* Legt eine korrigierte Revision an: pruefen (Schema, Startfenster,
* Glyphen, Platz), PDF rendern (Erstellungsdatum = jetzt), unter Revision
* n+1 ablegen, Revision speichern, currentRevision hochzaehlen, bei
* Einmal-Patenschaften Start und Ende nachziehen, Audit-Log schreiben und
* auf Wunsch die Kundschaft benachrichtigen.
*
* Zwei Admins, die gleichzeitig korrigieren: das Hochzaehlen ist an den
* bisherigen Stand gebunden (updateMany … where currentRevision = n). Der
* langsamere Lauf bekommt "conflict" und muss neu laden, statt die eben
* gespeicherte Korrektur unbemerkt zu ueberholen.
*/
export async function reviseSponsorship(input: ReviseSponsorshipInput): Promise<ReviseSponsorshipResult> {
const sponsorship = await loadForRevision(input.sponsorshipId);
if (!sponsorship) return { ok: false, error: { code: "not_found" } };
if (sponsorship.currentRevision === 0) return { ok: false, error: { code: "no_revision" } };
const currentRow = await prisma.sponsorshipRevision.findUnique({
where: { sponsorshipId_revision: { sponsorshipId: sponsorship.id, revision: sponsorship.currentRevision } },
select: { personalization: true, templateVersion: true },
});
if (!currentRow) return { ok: false, error: { code: "no_revision" } };
const current = parseStoredPersonalization({
personalization: currentRow.personalization,
templateVersion: currentRow.templateVersion,
});
const isSubscription = sponsorship.subscriptionId !== null;
const parsed = parseRevisionInput(
{ personalization: input.personalization, templateVersion: input.templateVersion, reason: input.reason },
{
current,
isSubscription,
orderDate: toIsoDate(sponsorship.orderItem?.order.createdAt ?? sponsorship.createdAt),
}
);
if (!parsed.ok) return { ok: false, error: parsed.error };
const locale: Locale = isLocale(sponsorship.locale) ? sponsorship.locale : defaultLocale;
const { personalization, templateVersion } = parsed.value;
const term = revisionTerm({
isSubscription,
personalization,
startDate: sponsorship.startDate,
endDate: sponsorship.endDate,
});
const fit = checkCertificateFits(personalization, locale, { term, templateVersion, number: sponsorship.number });
if (!fit.ok) {
return fit.reason === "glyphs"
? { ok: false, error: { code: "glyphs", field: fit.field, characters: fit.characters } }
: { ok: false, error: { code: "fit", field: fit.field } };
}
const document = { personalization, locale, term, number: sponsorship.number, templateVersion };
const { pdf, text } = await renderCertificateDocumentPdf(document, new Date());
const { revision, pdfPath } = await nextFreeRevision(sponsorship);
const { sha256 } = await storeCertificatePdf(pdfPath, pdf);
const startChanged = !isSubscription && parsed.value.changedFields.includes("startDate");
const claimed = await prisma.$transaction(async (tx) => {
const claim = await tx.sponsorship.updateMany({
where: { id: sponsorship.id, currentRevision: sponsorship.currentRevision },
data: {
currentRevision: revision,
...(startChanged && term.kind !== "OPEN"
? { startDate: fromIsoDate(term.start), endDate: fromIsoDate(term.end) }
: {}),
},
});
if (claim.count === 0) return false;
await tx.sponsorshipRevision.create({
data: {
sponsorshipId: sponsorship.id,
revision,
personalization: personalization as unknown as Prisma.InputJsonValue,
term: term as unknown as Prisma.InputJsonValue,
text: text as unknown as Prisma.InputJsonValue,
templateVersion,
pdfPath,
pdfSha256: sha256,
createdById: input.admin.id,
createdByLabel: input.admin.email,
reason: parsed.value.reason,
},
});
return true;
});
if (!claimed) return { ok: false, error: { code: "conflict" } };
await logAdminAction(input.admin, {
action: "sponsorship.revise",
entityType: "Sponsorship",
entityId: sponsorship.id,
detail: `${sponsorship.number} – ${describeChange(parsed.value, revision)}`,
});
const mailSent = input.notifyCustomer ? await notifyCustomer(sponsorship, locale, text.beeName) : null;
return {
ok: true,
revision,
changedFields: parsed.value.changedFields,
templateChanged: parsed.value.templateChanged,
mailSent,
};
}
+45
View File
@@ -0,0 +1,45 @@
import { prisma } from "@/lib/prisma";
/**
* Einstellungen der Bienenpatenschaft, die der Inhaber im Admin pflegt.
*
* Gespeichert als SiteContent-Zeile (generisches Key-Value-Overlay), damit
* dafuer keine eigene Tabelle noetig ist. Fehlt die Zeile, gilt die Vorgabe —
* die Einstellung funktioniert also schon vor der ersten Bearbeitung.
*/
/** SiteContent-Schluessel der SEPA-Neukunden-Frist; valueDe enthaelt die Tage als Ganzzahl. */
export const SEPA_HOLD_DAYS_KEY = "sponsorship.sepaHoldDays";
/**
* Vorgabe: 56 Tage, die Acht-Wochen-Frist, in der eine SEPA-Lastschrift ohne
* Angabe von Gruenden zurueckgebucht werden kann (Spec, "SEPA-Lastschrift
* und Rueckbuchungen").
*/
export const DEFAULT_SEPA_HOLD_DAYS = 56;
/** Obergrenze gegen Tippfehler (5600 statt 56 wuerde Lieferungen jahrelang zurueckhalten). */
export const MAX_SEPA_HOLD_DAYS = 365;
/**
* Liest den gespeicherten Wert. Unbrauchbares (leer, keine Ganzzahl, ausserhalb
* 0–365) faellt auf die Vorgabe zurueck: lieber die vorsichtige Frist als gar
* keine, weil ein kaputter Wert sonst still den Schutz vor Rueckbuchungen
* abschalten wuerde.
*/
export function parseSepaHoldDays(value: string | null | undefined): number {
if (value === null || value === undefined) return DEFAULT_SEPA_HOLD_DAYS;
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) return DEFAULT_SEPA_HOLD_DAYS;
const days = Number(trimmed);
return days <= MAX_SEPA_HOLD_DAYS ? days : DEFAULT_SEPA_HOLD_DAYS;
}
/**
* SEPA-Neukunden-Frist in Tagen: so lange zaehlt die erste SEPA-Zahlung eines
* Kontos nicht fuer den Lieferanspruch. 0 = Frist abgeschaltet.
*/
export async function getSepaHoldDays(): Promise<number> {
const row = await prisma.siteContent.findUnique({ where: { key: SEPA_HOLD_DAYS_KEY }, select: { valueDe: true } });
return parseSepaHoldDays(row?.valueDe);
}
+98
View File
@@ -0,0 +1,98 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
/**
* Ablage der Urkunden-PDFs.
*
* Aus demselben Grund wie die Rechnungen (lib/invoice-storage.ts) BEWUSST
* NICHT unter UPLOAD_DIR: app/uploads/[...path] liefert jede Datei dort ohne
* Anmeldung aus. Eine Urkunde traegt Namen von Kaeufer, Beschenkten und
* Schenkenden und eine persoenliche Widmung — sie darf nur ueber die Routen
* ausgeliefert werden, die Anmeldung und Besitz pruefen.
*
* Abgelegt wird jede Revision als eigene Datei und nie ueberschrieben: was
* die Kundschaft einmal heruntergeladen hat, bleibt genau so erhalten, auch
* wenn spaeter korrigiert wird oder sich die Gestaltung aendert.
*
* In Kubernetes zeigt CERTIFICATE_DIR auf ein Verzeichnis im gesicherten
* Volume (siehe k8s/05-app-deployment.yaml und den Sicherungs-CronJob).
*/
export const CERTIFICATE_DIR = path.resolve(
/*turbopackIgnore: true*/ process.env.CERTIFICATE_DIR || "./certificates"
);
/**
* Harte Pruefung beim Laden des Moduls wie bei INVOICE_DIR: lieber startet
* die Anwendung nicht, als dass Urkunden still oeffentlich abrufbar werden,
* weil jemand CERTIFICATE_DIR unter das Upload-Verzeichnis gelegt hat.
*/
function assertCertificatesAreNotPubliclyServed(): void {
const uploads = path.resolve(/*turbopackIgnore: true*/ process.env.UPLOAD_DIR || "./uploads");
const withSep = uploads.endsWith(path.sep) ? uploads : uploads + path.sep;
if (CERTIFICATE_DIR === uploads || CERTIFICATE_DIR.startsWith(withSep)) {
throw new Error(
`CERTIFICATE_DIR ("${CERTIFICATE_DIR}") liegt innerhalb von UPLOAD_DIR ("${uploads}"). ` +
"Das Upload-Verzeichnis wird oeffentlich ausgeliefert — Urkunden mit Namen und Widmungen waeren fuer " +
"jeden abrufbar. Bitte ein eigenes Verzeichnis ausserhalb von UPLOAD_DIR waehlen."
);
}
}
assertCertificatesAreNotPubliclyServed();
/**
* Relativer Pfad einer Revision, z.B. "2026/WH-P-2026-00042/r1.pdf". Nur aus
* Nummer und Revisionszahl gebildet — keine Namen im Dateisystem, damit eine
* Korrektur des Namens keinen veralteten Namen im Pfad stehen laesst.
*/
export function certificateRelativePath(input: { number: string; revision: number; year: number }): string {
if (!/^WH-P-\d{4}-\d{5}$/.test(input.number)) {
throw new Error(`Ungueltige Patenschaftsnummer fuer den Ablagepfad: ${input.number}`);
}
return path.posix.join(String(input.year), input.number, `r${input.revision}.pdf`);
}
export function resolveCertificatePath(relativePath: string): string {
const resolved = path.resolve(/*turbopackIgnore: true*/ CERTIFICATE_DIR, relativePath);
const withSep = CERTIFICATE_DIR.endsWith(path.sep) ? CERTIFICATE_DIR : CERTIFICATE_DIR + path.sep;
if (!resolved.startsWith(withSep)) {
throw new Error(`Urkundenpfad liegt ausserhalb von CERTIFICATE_DIR: ${relativePath}`);
}
return resolved;
}
/**
* Schreibt die PDF exklusiv ("wx"): existiert die Datei schon, ist das ein
* Programmfehler (Revisionen werden nie ueberschrieben) und kein Fall fuer
* stilles Ersetzen. Ausnahme: dieselbe Datei mit identischem Inhalt — das
* passiert, wenn ein Lauf nach dem Schreiben, aber vor dem Speichern der
* Revision abbrach und wiederholt wird.
*/
export async function storeCertificatePdf(relativePath: string, pdf: Buffer): Promise<{ sha256: string }> {
const target = resolveCertificatePath(relativePath);
await mkdir(path.dirname(target), { recursive: true });
const sha256 = sha256Hex(pdf);
try {
await writeFile(target, pdf, { flag: "wx" });
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
const existing = await readFile(target);
if (sha256Hex(existing) !== sha256) {
throw new Error(`Urkunde ${relativePath} existiert bereits mit anderem Inhalt — wird nicht ueberschrieben.`);
}
}
return { sha256 };
}
export async function readCertificatePdf(relativePath: string): Promise<Buffer | null> {
try {
return await readFile(resolveCertificatePath(relativePath));
} catch {
return null;
}
}
export function sha256Hex(data: Buffer): string {
return createHash("sha256").update(data).digest("hex");
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Gueltigkeiten eines Uebertragungslinks. Eigene Datei ohne Server-Importe,
* damit der Dialog im Browser dieselbe Liste nutzt wie die Server-Action.
*/
export const TRANSFER_VALIDITIES = ["24h", "7d", "30d"] as const;
export type TransferValidity = (typeof TRANSFER_VALIDITIES)[number];
export function isTransferValidity(value: unknown): value is TransferValidity {
return typeof value === "string" && (TRANSFER_VALIDITIES as readonly string[]).includes(value);
}
+269
View File
@@ -0,0 +1,269 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* Uebertragung gegen eine kleine In-Memory-Datenbank: sie bildet genau die
* Abfragen nach, die lib/sponsorship/transfer.ts stellt — inklusive der
* bedingten updateMany-Aufrufe, auf denen der Schutz gegen doppelte
* Einloesung beruht, und des Zuruecksetzens eines Transaktionslaufs.
*/
type Where = Record<string, unknown>;
interface SponsorshipRow {
id: string;
ownerId: string;
subscriptionId: string | null;
currentRevision: number;
locale: string;
revisions: { personalization: unknown; term: unknown }[];
}
interface TransferRow {
id: string;
sponsorshipId: string;
tokenHash: string;
expiresAt: Date;
createdById: string;
claimedById: string | null;
claimedAt: Date | null;
revokedAt: Date | null;
createdAt: Date;
}
const db = { sponsorships: [] as SponsorshipRow[], transfers: [] as TransferRow[] };
let nextId = 1;
function matches(row: Record<string, unknown>, where: Where): boolean {
return Object.entries(where).every(([key, expected]) => {
if (key === "sponsorship") {
const sponsorship = db.sponsorships.find((s) => s.id === row.sponsorshipId);
return sponsorship ? matches(sponsorship as unknown as Record<string, unknown>, expected as Where) : false;
}
if (expected !== null && typeof expected === "object" && "gt" in (expected as object)) {
return (row[key] as Date).getTime() > ((expected as { gt: Date }).gt).getTime();
}
return row[key] === expected;
});
}
function withSponsorship(transfer: TransferRow) {
const sponsorship = db.sponsorships.find((s) => s.id === transfer.sponsorshipId);
return { ...transfer, sponsorship: sponsorship ? { ...sponsorship } : null };
}
const client = {
sponsorship: {
findFirst: async ({ where }: { where: Where }) =>
db.sponsorships.find((row) => matches(row as unknown as Record<string, unknown>, where)) ?? null,
updateMany: async ({ where, data }: { where: Where; data: Partial<SponsorshipRow> }) => {
const hits = db.sponsorships.filter((row) => matches(row as unknown as Record<string, unknown>, where));
db.sponsorships = db.sponsorships.map((row) => (hits.includes(row) ? { ...row, ...data } : row));
return { count: hits.length };
},
},
sponsorshipTransfer: {
create: async ({ data }: { data: Omit<TransferRow, "id" | "claimedById" | "claimedAt" | "revokedAt" | "createdAt"> }) => {
const row: TransferRow = { ...data, id: `t${nextId++}`, claimedById: null, claimedAt: null, revokedAt: null, createdAt: new Date() };
db.transfers = [...db.transfers, row];
return { id: row.id };
},
updateMany: async ({ where, data }: { where: Where; data: Partial<TransferRow> }) => {
const hits = db.transfers.filter((row) => matches(row as unknown as Record<string, unknown>, where));
db.transfers = db.transfers.map((row) => (hits.includes(row) ? { ...row, ...data } : row));
return { count: hits.length };
},
findUnique: async ({ where }: { where: { tokenHash: string } }) => {
const row = db.transfers.find((transfer) => transfer.tokenHash === where.tokenHash);
return row ? withSponsorship(row) : null;
},
findFirst: async ({ where }: { where: Where }) =>
db.transfers.find((row) => matches(row as unknown as Record<string, unknown>, where)) ?? null,
},
async $transaction<T>(fn: (tx: unknown) => Promise<T>): Promise<T> {
const snapshot = { sponsorships: [...db.sponsorships], transfers: [...db.transfers] };
try {
return await fn(client);
} catch (error) {
db.sponsorships = snapshot.sponsorships;
db.transfers = snapshot.transfers;
throw error;
}
},
};
vi.mock("@/lib/prisma", () => ({ prisma: client }));
const {
claimTransfer,
createTransfer,
evaluateClaim,
getOpenTransfer,
hashTransferToken,
previewClaim,
revokeTransfer,
transferExpiry,
} = await import("@/lib/sponsorship/transfer");
const NOW = new Date("2026-10-16T12:00:00Z");
const HOUR = 60 * 60 * 1000;
const personalization = { beeName: "Summsi", recipientName: "Anna Beispiel", occasion: "GEBURTSTAG", startDate: "2026-10-16" };
const term = { kind: "FIXED", start: "2026-10-16", end: "2027-10-15" };
function seed(overrides: Partial<SponsorshipRow> = {}): SponsorshipRow {
const row: SponsorshipRow = {
id: `s${nextId++}`,
ownerId: "owner",
subscriptionId: null,
currentRevision: 1,
locale: "de",
revisions: [{ personalization, term }],
...overrides,
};
db.sponsorships = [...db.sponsorships, row];
return row;
}
async function linkFor(sponsorshipId: string, validity: "24h" | "7d" | "30d" = "24h"): Promise<string> {
const result = await createTransfer({ ownerId: "owner", sponsorshipId, validity, now: NOW });
if (!result.ok) throw new Error("Link wurde nicht erstellt");
return result.token;
}
beforeEach(() => {
db.sponsorships = [];
db.transfers = [];
vi.spyOn(console, "info").mockImplementation(() => undefined);
});
describe("Link erstellen", () => {
it("speichert nur den Hash eines 256-Bit-Tokens", async () => {
const sponsorship = seed();
const token = await linkFor(sponsorship.id);
expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(db.transfers).toHaveLength(1);
expect(db.transfers[0].tokenHash).toBe(hashTransferToken(token));
expect(JSON.stringify(db.transfers)).not.toContain(token);
});
it("haelt hoechstens einen offenen Link je Patenschaft", async () => {
const sponsorship = seed();
const first = await linkFor(sponsorship.id);
await linkFor(sponsorship.id);
expect(db.transfers.filter((row) => row.revokedAt === null)).toHaveLength(1);
expect((await claimTransfer({ token: first, claimantId: "friend", now: NOW })).ok).toBe(false);
});
it("lehnt Abo-Patenschaften, fremde und noch nicht ausgestellte ab", async () => {
const subscription = seed({ subscriptionId: "sub1" });
const pending = seed({ currentRevision: 0 });
const foreign = seed({ ownerId: "someone-else" });
for (const [sponsorship, reason] of [
[subscription, "NOT_TRANSFERABLE"],
[pending, "NOT_TRANSFERABLE"],
[foreign, "NOT_FOUND"],
] as const) {
expect(await createTransfer({ ownerId: "owner", sponsorshipId: sponsorship.id, validity: "7d", now: NOW })).toEqual({
ok: false,
reason,
});
}
expect(db.transfers).toHaveLength(0);
});
it("rechnet die Gueltigkeit exakt", () => {
expect(transferExpiry("24h", NOW).getTime() - NOW.getTime()).toBe(24 * HOUR);
expect(transferExpiry("7d", NOW).getTime() - NOW.getTime()).toBe(7 * 24 * HOUR);
expect(transferExpiry("30d", NOW).getTime() - NOW.getTime()).toBe(30 * 24 * HOUR);
});
});
describe("Link einloesen", () => {
it("uebertraegt die Patenschaft genau einmal", async () => {
const sponsorship = seed();
const token = await linkFor(sponsorship.id);
expect(await claimTransfer({ token, claimantId: "friend", now: NOW })).toEqual({ ok: true, sponsorshipId: sponsorship.id });
expect(db.sponsorships[0].ownerId).toBe("friend");
expect(db.transfers[0]).toMatchObject({ claimedById: "friend", claimedAt: NOW });
expect(await claimTransfer({ token, claimantId: "third", now: NOW })).toEqual({ ok: false, reason: "INVALID" });
expect(db.sponsorships[0].ownerId).toBe("friend");
});
it("weist parallele Einloesungen bis auf eine ab", async () => {
const sponsorship = seed();
const token = await linkFor(sponsorship.id);
const results = await Promise.all([
claimTransfer({ token, claimantId: "a", now: NOW }),
claimTransfer({ token, claimantId: "b", now: NOW }),
]);
expect(results.filter((result) => result.ok)).toHaveLength(1);
});
it("verfaellt nach Ablauf", async () => {
const sponsorship = seed();
const token = await linkFor(sponsorship.id, "24h");
const later = new Date(NOW.getTime() + 24 * HOUR);
expect(await claimTransfer({ token, claimantId: "friend", now: later })).toEqual({ ok: false, reason: "INVALID" });
expect(db.sponsorships[0].ownerId).toBe("owner");
expect((await previewClaim(token, "friend", later)).status).toBe("INVALID");
});
it("gilt nach dem Widerruf nicht mehr", async () => {
const sponsorship = seed();
const token = await linkFor(sponsorship.id);
expect(await getOpenTransfer(sponsorship.id, NOW)).not.toBeNull();
expect(await revokeTransfer({ ownerId: "intruder", transferId: db.transfers[0].id, now: NOW })).toBe(false);
expect(await revokeTransfer({ ownerId: "owner", transferId: db.transfers[0].id, now: NOW })).toBe(true);
expect(await getOpenTransfer(sponsorship.id, NOW)).toBeNull();
expect(await claimTransfer({ token, claimantId: "friend", now: NOW })).toEqual({ ok: false, reason: "INVALID" });
});
it("laesst die eigene Patenschaft nicht einloesen", async () => {
const sponsorship = seed();
const token = await linkFor(sponsorship.id);
expect(await claimTransfer({ token, claimantId: "owner", now: NOW })).toEqual({ ok: false, reason: "OWN" });
expect(db.transfers[0].claimedAt).toBeNull();
expect((await previewClaim(token, "owner", NOW)).status).toBe("OWN");
});
it("lehnt eine inzwischen zum Abo gewordene oder weitergegebene Patenschaft ab", async () => {
const sponsorship = seed();
const token = await linkFor(sponsorship.id);
db.sponsorships = db.sponsorships.map((row) => ({ ...row, subscriptionId: "sub" }));
expect(await claimTransfer({ token, claimantId: "friend", now: NOW })).toEqual({ ok: false, reason: "INVALID" });
db.sponsorships = db.sponsorships.map((row) => ({ ...row, subscriptionId: null, ownerId: "new-owner" }));
expect(await claimTransfer({ token, claimantId: "friend", now: NOW })).toEqual({ ok: false, reason: "INVALID" });
});
it("zeigt vor der Uebernahme nur Bienenname und Laufzeit", async () => {
const sponsorship = seed();
const token = await linkFor(sponsorship.id);
expect(await previewClaim(token, "friend", NOW)).toEqual({ status: "VALID", beeName: "Summsi", term, locale: "de" });
});
it("behandelt unbekannte und kaputte Tokens neutral", async () => {
seed();
expect((await previewClaim("x".repeat(43), "friend", NOW)).status).toBe("INVALID");
expect((await previewClaim("../etc/passwd", "friend", NOW)).status).toBe("INVALID");
expect(await claimTransfer({ token: "kurz", claimantId: "friend", now: NOW })).toEqual({ ok: false, reason: "INVALID" });
});
});
describe("evaluateClaim", () => {
const transfer = { expiresAt: new Date(NOW.getTime() + HOUR), claimedAt: null, revokedAt: null, createdById: "owner" };
const sponsorship = { ownerId: "owner", subscriptionId: null, currentRevision: 1 };
it("ist am Ablaufzeitpunkt selbst schon ungueltig", () => {
expect(evaluateClaim({ transfer, sponsorship, claimantId: "friend", now: NOW })).toBe("OK");
expect(evaluateClaim({ transfer: { ...transfer, expiresAt: NOW }, sponsorship, claimantId: "friend", now: NOW })).toBe("INVALID");
});
it("kennt keinen Link ohne Patenschaft", () => {
expect(evaluateClaim({ transfer: null, sponsorship: null, claimantId: "friend", now: NOW })).toBe("INVALID");
});
});
+267
View File
@@ -0,0 +1,267 @@
import { createHash, randomBytes } from "node:crypto";
import { prisma } from "@/lib/prisma";
import { isLocale, defaultLocale, type Locale } from "@/i18n/config";
import { personalizationSchema, type CertificateTerm } from "@/lib/certificate/schema";
import { parseStoredTerm } from "@/lib/sponsorship/access";
import type { TransferValidity } from "@/lib/sponsorship/transfer-validity";
/**
* Uebertragung einer Einmal-Patenschaft an ein anderes Konto (Spec
* "Übertragung an Beschenkte").
*
* - Der Link enthaelt 32 Zufallsbytes (256 Bit) als base64url; gespeichert
* wird nur der SHA-256-Hash. Wer die Datenbank liest, kann damit nichts
* einloesen.
* - Je Patenschaft gibt es hoechstens einen offenen Link: ein neuer widerruft
* den alten im selben Transaktionslauf.
* - Eingeloest wird ueber bedingte updateMany-Aufrufe (offen, nicht
* abgelaufen, Besitzer unveraendert). Zwei parallele Einloesungen koennen so
* nicht beide gewinnen — die Datenbank wertet die Bedingung atomar aus.
*
* Protokoll: AuditLog ist ein Admin-Protokoll (Spalte "Admin", adminEmail als
* Snapshot des handelnden Admins) und passt nicht fuer Handlungen von
* Kundschaft. Die Transfer-Zeile selbst ist der Nachweis — erstellt von,
* erstellt am, widerrufen am, eingeloest von, eingeloest am — und der Admin
* zeigt sie als Uebertragungshistorie. Zusaetzlich steht jede Aenderung als
* eigene Zeile im Server-Log (nur Kennungen, keine Namen).
*/
export { TRANSFER_VALIDITIES, isTransferValidity, type TransferValidity } from "@/lib/sponsorship/transfer-validity";
const HOUR_MS = 60 * 60 * 1000;
const VALIDITY_MS: Record<TransferValidity, number> = {
"24h": 24 * HOUR_MS,
"7d": 7 * 24 * HOUR_MS,
"30d": 30 * 24 * HOUR_MS,
};
/** 32 Bytes base64url ohne Fuellzeichen = genau 43 Zeichen. */
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
export function isWellFormedTransferToken(token: string): boolean {
return TOKEN_PATTERN.test(token);
}
export function generateTransferToken(): string {
return randomBytes(32).toString("base64url");
}
export function hashTransferToken(token: string): string {
return createHash("sha256").update(token, "utf8").digest("hex");
}
export function transferExpiry(validity: TransferValidity, now: Date): Date {
return new Date(now.getTime() + VALIDITY_MS[validity]);
}
export function transferClaimPath(token: string): string {
return `/patenschaft/uebernehmen/${token}`;
}
export interface ClaimCheckInput {
transfer: { expiresAt: Date; claimedAt: Date | null; revokedAt: Date | null; createdById: string } | null;
sponsorship: { ownerId: string; subscriptionId: string | null; currentRevision: number } | null;
claimantId: string;
now: Date;
}
export type ClaimDecision = "OK" | "INVALID" | "OWN";
/**
* Reine Entscheidung, ob ein Link eingeloest werden darf. "INVALID" deckt
* bewusst alle Gruende ab (abgelaufen, widerrufen, eingeloest, unbekannt,
* Abo, Besitzer gewechselt) — die Seite zeigt dafuer dieselbe neutrale
* Meldung und verraet nichts ueber die Patenschaft.
*/
export function evaluateClaim({ transfer, sponsorship, claimantId, now }: ClaimCheckInput): ClaimDecision {
if (!transfer || !sponsorship) return "INVALID";
if (transfer.revokedAt || transfer.claimedAt) return "INVALID";
if (transfer.expiresAt.getTime() <= now.getTime()) return "INVALID";
if (sponsorship.subscriptionId !== null) return "INVALID";
if (sponsorship.currentRevision === 0) return "INVALID";
// Der Link gilt nur, solange die Patenschaft noch dem Konto gehoert, das
// ihn erstellt hat.
if (sponsorship.ownerId !== transfer.createdById) return "INVALID";
if (sponsorship.ownerId === claimantId) return "OWN";
return "OK";
}
export type CreateTransferResult =
| { ok: true; token: string; transferId: string; expiresAt: Date }
| { ok: false; reason: "NOT_FOUND" | "NOT_TRANSFERABLE" };
export async function createTransfer(input: {
ownerId: string;
sponsorshipId: string;
validity: TransferValidity;
now?: Date;
}): Promise<CreateTransferResult> {
const now = input.now ?? new Date();
const token = generateTransferToken();
const expiresAt = transferExpiry(input.validity, now);
const result = await prisma.$transaction(async (tx): Promise<CreateTransferResult> => {
const sponsorship = await tx.sponsorship.findFirst({
where: { id: input.sponsorshipId, ownerId: input.ownerId },
select: { id: true, subscriptionId: true, currentRevision: true },
});
if (!sponsorship) return { ok: false, reason: "NOT_FOUND" };
if (sponsorship.subscriptionId !== null || sponsorship.currentRevision === 0) {
return { ok: false, reason: "NOT_TRANSFERABLE" };
}
await tx.sponsorshipTransfer.updateMany({
where: { sponsorshipId: sponsorship.id, claimedAt: null, revokedAt: null },
data: { revokedAt: now },
});
const created = await tx.sponsorshipTransfer.create({
data: {
sponsorshipId: sponsorship.id,
tokenHash: hashTransferToken(token),
expiresAt,
createdById: input.ownerId,
},
select: { id: true },
});
return { ok: true, token, transferId: created.id, expiresAt };
});
if (result.ok) {
console.info(
`[patenschaft] Uebertragungslink ${result.transferId} erstellt fuer Patenschaft ${input.sponsorshipId} von Konto ${input.ownerId}, gueltig bis ${expiresAt.toISOString()}.`
);
}
return result;
}
/** Widerruft einen offenen Link des Besitzers. false = nicht (mehr) offen oder nicht seiner. */
export async function revokeTransfer(input: { ownerId: string; transferId: string; now?: Date }): Promise<boolean> {
const now = input.now ?? new Date();
const revoked = await prisma.sponsorshipTransfer.updateMany({
where: {
id: input.transferId,
claimedAt: null,
revokedAt: null,
createdById: input.ownerId,
sponsorship: { ownerId: input.ownerId },
},
data: { revokedAt: now },
});
if (revoked.count > 0) {
console.info(`[patenschaft] Uebertragungslink ${input.transferId} von Konto ${input.ownerId} widerrufen.`);
}
return revoked.count > 0;
}
export interface OpenTransfer {
id: string;
expiresAt: Date;
createdAt: Date;
}
export async function getOpenTransfer(sponsorshipId: string, now: Date = new Date()): Promise<OpenTransfer | null> {
return prisma.sponsorshipTransfer.findFirst({
where: { sponsorshipId, claimedAt: null, revokedAt: null, expiresAt: { gt: now } },
orderBy: { createdAt: "desc" },
select: { id: true, expiresAt: true, createdAt: true },
});
}
const claimSelect = {
id: true,
expiresAt: true,
claimedAt: true,
revokedAt: true,
createdById: true,
sponsorship: {
select: {
id: true,
ownerId: true,
subscriptionId: true,
currentRevision: true,
locale: true,
revisions: { orderBy: { revision: "desc" }, take: 1, select: { personalization: true, term: true } },
},
},
} as const;
export type ClaimPreview =
| { status: "VALID"; beeName: string; term: CertificateTerm; locale: Locale }
| { status: "OWN" }
| { status: "INVALID" };
/** Was die Uebernahme-Seite vor der Bestaetigung zeigen darf. */
export async function previewClaim(token: string, claimantId: string, now: Date = new Date()): Promise<ClaimPreview> {
if (!isWellFormedTransferToken(token)) return { status: "INVALID" };
const transfer = await prisma.sponsorshipTransfer.findUnique({
where: { tokenHash: hashTransferToken(token) },
select: claimSelect,
});
const decision = evaluateClaim({ transfer, sponsorship: transfer?.sponsorship ?? null, claimantId, now });
if (decision !== "OK" || !transfer) return { status: decision === "OWN" ? "OWN" : "INVALID" };
const latest = transfer.sponsorship.revisions[0];
const personalization = personalizationSchema.safeParse(latest?.personalization);
const term = parseStoredTerm(latest?.term);
if (!personalization.success || !term) return { status: "INVALID" };
return {
status: "VALID",
beeName: personalization.data.beeName,
term,
locale: isLocale(transfer.sponsorship.locale) ? transfer.sponsorship.locale : defaultLocale,
};
}
export type ClaimResult = { ok: true; sponsorshipId: string } | { ok: false; reason: "INVALID" | "OWN" };
/** Uebernimmt die Patenschaft in das Konto `claimantId` — genau einmal je Link. */
export async function claimTransfer(input: { token: string; claimantId: string; now?: Date }): Promise<ClaimResult> {
const now = input.now ?? new Date();
if (!isWellFormedTransferToken(input.token)) return { ok: false, reason: "INVALID" };
const tokenHash = hashTransferToken(input.token);
const result = await prisma.$transaction(async (tx): Promise<ClaimResult & { fromOwnerId?: string; transferId?: string }> => {
const transfer = await tx.sponsorshipTransfer.findUnique({ where: { tokenHash }, select: claimSelect });
const decision = evaluateClaim({
transfer,
sponsorship: transfer?.sponsorship ?? null,
claimantId: input.claimantId,
now,
});
if (decision !== "OK" || !transfer) return { ok: false, reason: decision === "OWN" ? "OWN" : "INVALID" };
const claimed = await tx.sponsorshipTransfer.updateMany({
where: { id: transfer.id, claimedAt: null, revokedAt: null, expiresAt: { gt: now } },
data: { claimedById: input.claimantId, claimedAt: now },
});
if (claimed.count === 0) return { ok: false, reason: "INVALID" };
const moved = await tx.sponsorship.updateMany({
where: { id: transfer.sponsorship.id, ownerId: transfer.createdById, subscriptionId: null },
data: { ownerId: input.claimantId },
});
if (moved.count === 0) {
// Besitzer hat sich zwischen Lesen und Schreiben geaendert: der
// Transaktionslauf wird zurueckgerollt, damit der Link nicht als
// eingeloest gilt, ohne dass etwas uebertragen wurde.
throw new ClaimConflictError();
}
return { ok: true, sponsorshipId: transfer.sponsorship.id, fromOwnerId: transfer.createdById, transferId: transfer.id };
}).catch((error: unknown) => {
if (error instanceof ClaimConflictError) return { ok: false as const, reason: "INVALID" as const };
throw error;
});
if (result.ok) {
console.info(
`[patenschaft] Uebertragungslink ${result.transferId} eingeloest: Patenschaft ${result.sponsorshipId} von Konto ${result.fromOwnerId} an Konto ${input.claimantId}.`
);
return { ok: true, sponsorshipId: result.sponsorshipId };
}
return { ok: false, reason: result.reason };
}
class ClaimConflictError extends Error {
constructor() {
super("Patenschaft wurde waehrend der Uebernahme veraendert.");
}
}
+6 -1
View File
@@ -202,7 +202,9 @@ export const checkoutSchema = z.object({
firstName: nameSchema("Bitte gib deinen Vornamen an."),
lastName: nameSchema("Bitte gib deinen Nachnamen an."),
phone: z.string().trim().max(40).optional(),
fulfillmentMethod: z.enum(["VERSAND", "ABHOLUNG"]),
// DIGITAL: Bestellung ohne physische Ware (Patenschaft ohne Paket). Der
// Server prueft selbst, ob das zum Warenkorb passt (api/checkout).
fulfillmentMethod: z.enum(["VERSAND", "ABHOLUNG", "DIGITAL"]),
street: z.string().trim().max(200).optional(),
zip: z.string().trim().max(10).optional(),
city: z.string().trim().max(100).optional(),
@@ -212,6 +214,9 @@ export const checkoutSchema = z.object({
z.object({
productId: z.string().min(1).max(64),
quantity: z.coerce.number().int().min(1).max(20),
// Nur bei Patenschaften; Inhalt prueft lib/sponsorship/checkout-expand.ts
// mit dem Schema der Urkunde.
personalization: z.unknown().optional(),
})
)
.min(1, "Der Warenkorb ist leer.")
+4
View File
@@ -100,6 +100,10 @@ const CATEGORY_ENV_NAMES = {
KERZEN: "VAT_RATE_PERCENT_KERZEN",
PROPOLIS: "VAT_RATE_PERCENT_PROPOLIS",
SONSTIGES: "VAT_RATE_PERCENT_SONSTIGES",
// Bienenpatenschaft (Urkunde, symbolische Namensgebung): eine Leistung,
// kein Lebensmittel — Regelsatz. Der Honiganteil eines Pakets steht als
// eigene Position mit dem Honig-Satz auf der Rechnung (lib/sponsorship).
PATENSCHAFT: "VAT_RATE_PERCENT_PATENSCHAFT",
} as const;
export type VatCategory = keyof typeof CATEGORY_ENV_NAMES;