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

Startangebote werden inaktiv angelegt; Livebetrieb erst nach Freigabe.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 14:07:30 +02:00

190 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
});