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