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,141 @@
|
||||
/**
|
||||
* Rendert Beispiel-Urkunden fuer die Sichtpruefung (Spec "Visuelle
|
||||
* Kontrolle"): je Vorlage und Datensatz ein PNG mit 300 dpi (2480 px breit)
|
||||
* und die Druck-PDF, dazu eine Vorschau mit Wasserzeichen.
|
||||
*
|
||||
* Aufruf: pnpm tsx scripts/render-certificate-samples.ts <ausgabeordner> [--templates=a@1,b@1] [--samples=standard,lang]
|
||||
*
|
||||
* Schreibt nur in den angegebenen Ordner; keine Datenbank, keine Uploads.
|
||||
*/
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { layoutCertificate } from "../src/lib/certificate/layout";
|
||||
import { renderCertificateImage } from "../src/lib/certificate/render-image";
|
||||
import { certificatePdfTitle, renderCertificatePdf } from "../src/lib/certificate/render-pdf";
|
||||
import { fixedTermFor, type CertificateTerm, type Personalization } from "../src/lib/certificate/schema";
|
||||
import { listTemplateVersions } from "../src/lib/certificate/templates";
|
||||
import { composeCertificateText } from "../src/lib/certificate/text";
|
||||
import type { Locale } from "../src/i18n/config";
|
||||
|
||||
interface Sample {
|
||||
personalization: Personalization;
|
||||
locale: Locale;
|
||||
term: CertificateTerm;
|
||||
number: string;
|
||||
}
|
||||
|
||||
const START = "2026-12-24";
|
||||
const NUMBER = "WH-P-2026-00042";
|
||||
|
||||
function sample(personalization: Personalization, extra: Partial<Omit<Sample, "personalization">> = {}): Sample {
|
||||
return { personalization, locale: "de", term: fixedTermFor(personalization.startDate), number: NUMBER, ...extra };
|
||||
}
|
||||
|
||||
const MAX_DEDICATION =
|
||||
"Für die Frau, die jeden Garten zum Summen bringt.\nMöge diese kleine Biene dich an all die sonnigen Tage erinnern, die wir gemeinsam zwischen Lavendel und Linden verbracht haben.\n\nIn Liebe und Dankbarkeit,\nAnna, Ben und Carla";
|
||||
|
||||
const SAMPLES: Record<string, Sample> = {
|
||||
// Identische Beispieldaten fuer den Vergleich der drei Varianten.
|
||||
standard: sample({
|
||||
beeName: "Summsi",
|
||||
recipientName: "Marie-Luise Schmidt",
|
||||
giverNames: "Anna und Ben",
|
||||
dedication: "Für die beste Gärtnerin der Welt –\nmögen deine Blüten immer summen.",
|
||||
occasion: "WEIHNACHTEN",
|
||||
startDate: START,
|
||||
}),
|
||||
kurz: sample({ beeName: "Bo", recipientName: "Li", occasion: "SELBST", startDate: START }),
|
||||
lang: sample({
|
||||
beeName: "Hildegard-Wilhelmine",
|
||||
recipientName: "Marie-Luise Schmidt-Habermann von Hohenlinden",
|
||||
giverNames: "Anna, Ben, Carla und Familie Özdemir aus dem Nachbarhaus",
|
||||
dedication: MAX_DEDICATION,
|
||||
occasion: "GEBURTSTAG",
|
||||
startDate: START,
|
||||
}),
|
||||
// An den Eingabelimits (CERTIFICATE_LIMITS): 23/24, 57/60, 80/80, 277/280 Zeichen, 5 Umbrueche.
|
||||
maximal: sample(
|
||||
{
|
||||
beeName: "Wilhelmine-Mümmelwunder",
|
||||
recipientName: "Marie-Luise Schmidt-Habermann von Hohenlinden-Württemberg",
|
||||
giverNames: "Anna, Ben, Carla, Dominik, Emilia, Friedrich und die ganze Familie Özdemir-Lange",
|
||||
dedication: [
|
||||
"Möge diese kleine Biene dich an sonnige Tage im Garten erinnern,",
|
||||
"an Lindenblüten, Lavendel und an den Duft von frischem Honigbrot,",
|
||||
"an lange Abende auf der Bank vor dem Haus und an alle Gespräche.",
|
||||
"Danke für alles, was du für uns getan hast, jeden einzelnen Tag!",
|
||||
"Alles Liebe",
|
||||
"Anna",
|
||||
].join("\n"),
|
||||
occasion: "DANKESCHOEN",
|
||||
startDate: START,
|
||||
},
|
||||
{ locale: "de" }
|
||||
),
|
||||
sonderzeichen: sample(
|
||||
{
|
||||
beeName: "Zoë",
|
||||
recipientName: "Jürgen Weiß & Ñúñez O’Brien",
|
||||
giverNames: "Anna, Ben, Carla und Familie Özdemir",
|
||||
occasion: "DANKESCHOEN",
|
||||
startDate: START,
|
||||
},
|
||||
{ term: { kind: "OPEN", start: START } }
|
||||
),
|
||||
englisch: sample(
|
||||
{
|
||||
beeName: "Honeybell",
|
||||
recipientName: "Charlotte Ashworth-Blake",
|
||||
giverNames: "Oliver and Grace",
|
||||
dedication: "For the kindest gardener we know —\nmay your days always hum with sunshine.",
|
||||
occasion: "GEBURTSTAG",
|
||||
startDate: START,
|
||||
},
|
||||
{ locale: "en" }
|
||||
),
|
||||
beendet: sample(
|
||||
{ beeName: "Lindenblüte", recipientName: "Familie Wagner", occasion: "GESCHENK", startDate: START },
|
||||
{ term: { kind: "ENDED", start: START, end: "2027-05-31" } }
|
||||
),
|
||||
};
|
||||
|
||||
function parseList(flag: string): string[] | null {
|
||||
const arg = process.argv.find((value) => value.startsWith(`--${flag}=`));
|
||||
return arg ? arg.slice(flag.length + 3).split(",").filter(Boolean) : null;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const outDir = process.argv[2];
|
||||
if (!outDir || outDir.startsWith("--")) {
|
||||
throw new Error("Aufruf: pnpm tsx scripts/render-certificate-samples.ts <ausgabeordner> [--templates=…] [--samples=…]");
|
||||
}
|
||||
await mkdir(outDir, { recursive: true });
|
||||
const templates = parseList("templates") ?? listTemplateVersions();
|
||||
const samples = parseList("samples") ?? Object.keys(SAMPLES);
|
||||
|
||||
for (const version of templates) {
|
||||
for (const name of samples) {
|
||||
const data = SAMPLES[name];
|
||||
if (!data) throw new Error(`Unbekannter Datensatz "${name}".`);
|
||||
const text = composeCertificateText(data);
|
||||
const layout = layoutCertificate(text, version);
|
||||
const base = path.join(outDir, `${version.replace("@", "-v")}_${name}`);
|
||||
const [png, pdf] = await Promise.all([
|
||||
renderCertificateImage(layout, { widthPx: 2480, format: "png" }),
|
||||
renderCertificatePdf(layout, { title: certificatePdfTitle(text), creationDate: new Date(`${START}T00:00:00Z`) }),
|
||||
]);
|
||||
await writeFile(`${base}.png`, png);
|
||||
await writeFile(`${base}.pdf`, pdf);
|
||||
if (name === "standard") {
|
||||
const preview = await renderCertificateImage(layout, { widthPx: 1240, format: "png", watermark: data.locale });
|
||||
await writeFile(`${base}_vorschau.png`, preview);
|
||||
}
|
||||
process.stdout.write(`${base}.png / .pdf\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
process.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Rendert Beispiel-Medaillen aller Arten und Stufen als PNG (und SVG) zur
|
||||
* Sichtpruefung. Aufruf: pnpm tsx scripts/render-medal-samples.ts <Zielordner> [Groesse]
|
||||
*/
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { medalReliefSvg, renderMedalPng, renderMedalSvg, type MedalInput } from "../src/lib/medals";
|
||||
|
||||
const SAMPLES: Array<[string, MedalInput]> = [
|
||||
["patenschaft-bronze", { kind: "PATENSCHAFT", tier: "BRONZE", beeName: "Summsi", year: 2026, number: "WH-P-2026-00042" }],
|
||||
["patenschaft-silber", { kind: "PATENSCHAFT", tier: "SILBER", beeName: "Zoë", year: 2026, number: "WH-P-2026-00043" }],
|
||||
["patenschaft-gold", { kind: "PATENSCHAFT", tier: "GOLD", beeName: "Ñúñez", year: 2026, number: "WH-P-2026-00044" }],
|
||||
["patenschaft-abo", { kind: "PATENSCHAFT", tier: "ABO", beeName: "Hildegard", year: 2026, number: "WH-P-2026-00045" }],
|
||||
["patenschaft-lang-24", { kind: "PATENSCHAFT", tier: "BRONZE", beeName: "Wilhelmine Augusta Maxi", year: 2026, number: "WH-P-2026-00046" }],
|
||||
["patenschaft-lang-ohne-umbruch", { kind: "PATENSCHAFT", tier: "GOLD", beeName: "Bienenköniginwunderbarer", year: 2026, number: "WH-P-2026-00047" }],
|
||||
["patenschaft-abo-lang", { kind: "PATENSCHAFT", tier: "ABO", beeName: "Wilhelmine-Augusta Maxi", year: 2026, number: "WH-P-2026-00048" }],
|
||||
["stammkunde-1", { kind: "STAMMKUNDE", level: 1, name: "Blütenbesuch" }],
|
||||
["stammkunde-2", { kind: "STAMMKUNDE", level: 2, name: "Wabenbau" }],
|
||||
["stammkunde-3", { kind: "STAMMKUNDE", level: 3, name: "Honigschatz" }],
|
||||
["stammkunde-4", { kind: "STAMMKUNDE", level: 4, name: "Goldwabe" }],
|
||||
["abo-1", { kind: "ABO", months: 1 }],
|
||||
["abo-12", { kind: "ABO", months: 12 }],
|
||||
["abo-24", { kind: "ABO", months: 24 }],
|
||||
["vip", { kind: "VIP" }],
|
||||
];
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const target = process.argv[2];
|
||||
if (!target) throw new Error("Zielordner fehlt: pnpm tsx scripts/render-medal-samples.ts <Zielordner> [Groesse]");
|
||||
const size = Number(process.argv[3] ?? 1024);
|
||||
await mkdir(target, { recursive: true });
|
||||
for (const [name, input] of SAMPLES) {
|
||||
await writeFile(path.join(target, `${name}.png`), await renderMedalPng(input, size));
|
||||
await writeFile(path.join(target, `${name}.svg`), renderMedalSvg(input));
|
||||
if (name === "patenschaft-gold") await writeFile(path.join(target, `${name}-relief.svg`), medalReliefSvg(input));
|
||||
}
|
||||
process.stdout.write(`${SAMPLES.length} Medaillen in ${target}\n`);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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