feat: Bienenpatenschaft mit Urkunde, Abo, Medaillen und 3D-Konto

Setzt Teilprojekte 1-5 der Spec 2026-10-16 um:
- Urkunden-Engine (lib/certificate): ein Layout fuer PDF, Vorschau und
  3D-Textur, Vorlage classic@1, eingebettete OFL-Schriften, Testmatrix
- Einmal-Patenschaft ueber Traegerprodukte (Kategorie PATENSCHAFT),
  Paket wird im Checkout in Patenschaft (Regelsatz) und Honig aufgeteilt,
  Kontopflicht, rein digitale Bestellungen (FulfillmentMethod DIGITAL)
- Ausstellung nach Zahlung idempotent, Wiederholung im App-Prozess
  (instrumentation + background-jobs), Ablage in CERTIFICATE_DIR
- Verkaufsseite /patenschaft mit Konfigurator und Live-Vorschau,
  Warenkorb mit Schluessel je Position
- Kundenkonto: 3D-Ansicht von Urkunde und Medaille, Downloads, ZIP,
  Uebertragung per befristetem Einmal-Link, Treuestufen bis VIP
- Admin: Angebote, Patenschaften, Druck, Korrektur mit Revisionen,
  Abos, Erntefenster, Deckung, SEPA-Frist, Treuestufen
- Abo ueber Stripe Billing: Lieferung erst nach bezahlten Monaten,
  SEPA-Neukunden-Frist und Rueckbuchungsschutz, Honigreservierung,
  Kuendigungsbutton, Monatsrechnungen, Stripe-Einrichtungsskript

Startangebote werden inaktiv angelegt; Livebetrieb erst nach Freigabe.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 14:07:30 +02:00
1 parent dcb578f062
commit f227aeb8f9
269 files changed
+28054 -141

No files matched your search

+189
View File
@@ -0,0 +1,189 @@
/**
* Stripe-Einrichtung fuer das Abo der Bienenpatenschaft (Spec
* "Stripe-Einrichtung", docs/EINRICHTUNG.md).
*
* Wiederholbar und ohne Geheimnisse im Code: liest STRIPE_SECRET_KEY aus der
* Umgebung (.env), vergleicht den Ist-Stand mit dem Soll und zeigt, was es
* aendern wuerde. Ohne --apply wird NICHTS geschrieben.
*
* pnpm tsx scripts/stripe-setup.ts # Probelauf (Standard)
* pnpm tsx scripts/stripe-setup.ts --apply # Aenderungen schreiben
* pnpm tsx scripts/stripe-setup.ts --base-url=https://wabenhain.com
*
* Gegen ein Livekonto (sk_live_/rk_live_) schreibt es nur mit dem
* zusaetzlichen Schalter --live-ok — erst nach ausdruecklicher Freigabe des
* Inhabers.
*
* Was es einrichtet:
* 1. Webhook-Endpunkt <BASE_URL>/api/checkout/webhook mit allen Ereignissen
* (die bestehenden sechs plus die Abo-Ereignisse). Bestehende Ereignisse
* bleiben, es wird nur ergaenzt.
* 2. Kundenportal-Konfiguration, die NUR das Aendern der Zahlungsart erlaubt
* (keine Kuendigung, kein Tarifwechsel, keine Kundendaten, keine
* Stripe-Rechnungen). Die App findet sie ueber ihre Metadaten.
* Was nur im Dashboard geht, gibt es am Ende als Checkliste aus.
*/
import "dotenv/config";
import Stripe from "stripe";
const REQUIRED_EVENTS = [
// bestehend (Einmalbestellungen)
"checkout.session.completed",
"checkout.session.async_payment_succeeded",
"checkout.session.async_payment_failed",
"checkout.session.expired",
"charge.refunded",
"charge.dispute.created",
// Abo
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"invoice.paid",
"invoice.payment_failed",
"charge.dispute.closed",
] as const satisfies readonly Stripe.WebhookEndpointCreateParams.EnabledEvent[];
/** Muss zu PORTAL_METADATA_VALUE in src/lib/sponsorship-subscription/cancellation.ts passen. */
const PORTAL_METADATA = { wabenhain: "abo-zahlungsart" } as const;
const PORTAL_FEATURES = {
payment_method_update: { enabled: true },
subscription_cancel: { enabled: false },
subscription_update: { enabled: false },
customer_update: { enabled: false },
invoice_history: { enabled: false },
} as const;
interface Options {
apply: boolean;
liveOk: boolean;
baseUrl: string;
}
function parseOptions(argv: string[]): Options {
const baseArg = argv.find((arg) => arg.startsWith("--base-url="))?.slice("--base-url=".length);
const baseUrl = (baseArg || process.env.NEXT_PUBLIC_SITE_URL || "https://wabenhain.com").replace(/\/+$/, "");
return { apply: argv.includes("--apply"), liveOk: argv.includes("--live-ok"), baseUrl };
}
function log(line: string): void {
process.stdout.write(`${line}\n`);
}
async function ensureWebhook(stripe: Stripe, options: Options): Promise<void> {
const url = `${options.baseUrl}/api/checkout/webhook`;
const endpoints = await stripe.webhookEndpoints.list({ limit: 100 });
const existing = endpoints.data.find((endpoint) => endpoint.url === url);
if (!existing) {
log(`Webhook: kein Endpunkt fuer ${url} — ${options.apply ? "wird angelegt" : "wuerde angelegt"} mit ${REQUIRED_EVENTS.length} Ereignissen.`);
if (!options.apply) return;
const created = await stripe.webhookEndpoints.create({
url,
enabled_events: [...REQUIRED_EVENTS],
description: "Wabenhain Shop (Bestellungen und Abos)",
});
log(` angelegt: ${created.id}. Das Signatur-Geheimnis steht NUR in dieser Antwort und im Dashboard:`);
log(" im Dashboard unter Entwickler → Webhooks → Endpunkt → Signaturgeheimnis anzeigen, als STRIPE_WEBHOOK_SECRET setzen.");
return;
}
const enabled = new Set<string>(existing.enabled_events);
if (enabled.has("*")) {
log(`Webhook ${existing.id}: empfaengt alle Ereignisse ("*") — nichts zu tun.`);
return;
}
const missing = REQUIRED_EVENTS.filter((event) => !enabled.has(event));
if (missing.length === 0) {
log(`Webhook ${existing.id}: alle ${REQUIRED_EVENTS.length} Ereignisse abonniert — nichts zu tun.`);
return;
}
log(`Webhook ${existing.id}: ${missing.length} Ereignisse fehlen — ${options.apply ? "werden ergaenzt" : "wuerden ergaenzt"}:`);
for (const event of missing) log(` + ${event}`);
if (existing.status !== "enabled") log(` Hinweis: Endpunkt ist "${existing.status}".`);
if (!options.apply) return;
await stripe.webhookEndpoints.update(existing.id, {
enabled_events: [...existing.enabled_events, ...missing] as Stripe.WebhookEndpointUpdateParams.EnabledEvent[],
});
log(" ergaenzt.");
}
function portalDeviations(config: Stripe.BillingPortal.Configuration): string[] {
const features = config.features;
const actual: Record<keyof typeof PORTAL_FEATURES, boolean> = {
payment_method_update: features.payment_method_update.enabled,
subscription_cancel: features.subscription_cancel.enabled,
subscription_update: features.subscription_update.enabled,
customer_update: features.customer_update.enabled,
invoice_history: features.invoice_history.enabled,
};
return (Object.keys(PORTAL_FEATURES) as (keyof typeof PORTAL_FEATURES)[])
.filter((key) => actual[key] !== PORTAL_FEATURES[key].enabled)
.map((key) => `${key}: ${actual[key]} → ${PORTAL_FEATURES[key].enabled}`);
}
async function ensurePortal(stripe: Stripe, options: Options): Promise<void> {
const configs = await stripe.billingPortal.configurations.list({ active: true, limit: 100 });
const existing = configs.data.find((config) => config.metadata?.wabenhain === PORTAL_METADATA.wabenhain);
const defaultReturnUrl = `${options.baseUrl}/konto/patenschaft`;
if (!existing) {
log(`Kundenportal: keine Wabenhain-Konfiguration — ${options.apply ? "wird angelegt" : "wuerde angelegt"} (nur Zahlungsart aendern).`);
if (!options.apply) return;
const created = await stripe.billingPortal.configurations.create({
name: "Wabenhain Abo – nur Zahlungsart",
features: PORTAL_FEATURES,
default_return_url: defaultReturnUrl,
metadata: PORTAL_METADATA,
});
log(` angelegt: ${created.id} (die App findet sie ueber die Metadaten; optional STRIPE_PORTAL_CONFIGURATION_ID=${created.id}).`);
return;
}
const deviations = portalDeviations(existing);
if (deviations.length === 0) {
log(`Kundenportal ${existing.id}: erlaubt nur das Aendern der Zahlungsart — nichts zu tun.`);
return;
}
log(`Kundenportal ${existing.id}: weicht ab — ${options.apply ? "wird korrigiert" : "wuerde korrigiert"}:`);
for (const deviation of deviations) log(` ~ ${deviation}`);
if (!options.apply) return;
await stripe.billingPortal.configurations.update(existing.id, { features: PORTAL_FEATURES, default_return_url: defaultReturnUrl });
log(" korrigiert.");
}
function printDashboardChecklist(): void {
log("");
log("Nur im Stripe-Dashboard einstellbar (siehe docs/EINRICHTUNG.md):");
log(" [ ] Billing → Einstellungen → Abos und E-Mails → Fehlgeschlagene Zahlungen: Smart Retries an.");
log(" [ ] … nach dem letzten erfolglosen Versuch: Abo kuendigen (nicht 'als unbezahlt markieren').");
log(" [ ] … Kunden-E-Mails: Rechnungen/Belege fuer erfolgreiche Zahlungen, bevorstehende Verlaengerungen und");
log(" fehlgeschlagene Zahlungen AUS — Wabenhain verschickt eigene Rechnungen und Mails.");
log(" [ ] Einstellungen → Zahlungsmethoden: Karte, Apple Pay, Google Pay, SEPA-Lastschrift aktiv (Google Pay pruefen).");
log(" [ ] Apple Pay / Google Pay im eingebetteten Checkout: Domain wabenhain.com unter Zahlungsmethoden-Domains registrieren.");
log(" [ ] Stripe Tax nur mit aktiver Registrierung einschalten — sonst bleibt es aus (Preise sind Bruttopreise).");
}
async function main(): Promise<void> {
const options = parseOptions(process.argv.slice(2));
const key = process.env.STRIPE_SECRET_KEY?.trim();
if (!key) throw new Error("STRIPE_SECRET_KEY ist nicht gesetzt.");
const live = /^(sk|rk)_live_/.test(key);
if (live && options.apply && !options.liveOk) {
throw new Error("Livekonto: --apply schreibt hier nur zusammen mit --live-ok (nach Freigabe des Inhabers).");
}
log(`Stripe-Einrichtung (${live ? "LIVEMODUS" : "Testmodus"}, ${options.apply ? "SCHREIBT" : "Probelauf, schreibt nichts"})`);
log(`Basis-URL: ${options.baseUrl}`);
log("");
const stripe = new Stripe(key);
await ensureWebhook(stripe, options);
await ensurePortal(stripe, options);
printDashboardChecklist();
if (!options.apply) log("\nProbelauf beendet. Mit --apply schreiben.");
}
main().catch((error: unknown) => {
process.stderr.write(`Fehler: ${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});