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,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;
|
||||
});
|
||||
Reference in new issue
Block a user