/** * 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 /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 { 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(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 = { 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 { 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 { 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; });