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]>
190 lines
8.5 KiB
TypeScript
190 lines
8.5 KiB
TypeScript
/**
|
||
* 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;
|
||
});
|