feat(billing): Stripe-Code vollständig (Checkout, Portal, Webhooks) — Keys später
- /checkout: Subscription-Session (Karte), Customer-Wiederverwendung, locale de, Metadata auf Session+Subscription. TWINT bewusst weg (Stripe: nur Einmalzahlung). - /portal: Stripe Customer Portal (kündigen/Karte/Rechnungen); Mock → /konto/. - Webhook: + customer.subscription.updated (Status/Periode spiegeln, Instanz sperren/reaktivieren) + invoice.payment_failed (→ past_due). - .env.example: Stripe-Setup-Anleitung; ADMIN_EMAIL→ADMIN_PASSWORD korrigiert. Alles MOCK-fähig (CHANGE-ME → kein echtes Stripe). Echt-Test erst mit Keys. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+15
-6
@@ -13,18 +13,27 @@ PUBLIC_BASE_URL=http://localhost:8787
|
|||||||
# ═══ Auth ═══
|
# ═══ Auth ═══
|
||||||
# JWT-Signatur-Secret für HOST-Kundenkonten. openssl rand -hex 32
|
# JWT-Signatur-Secret für HOST-Kundenkonten. openssl rand -hex 32
|
||||||
JWT_SECRET=CHANGE-ME-min-32-zeichen
|
JWT_SECRET=CHANGE-ME-min-32-zeichen
|
||||||
# Konto mit dieser E-Mail wird automatisch Admin (Betreiber-Bereich /admin).
|
# Passwort für den Betreiber-Bereich (/admin), getrennt von Kundenkonten.
|
||||||
ADMIN_EMAIL=karim@gabrielevarano.ch
|
ADMIN_PASSWORD=CHANGE-ME-admin-passwort
|
||||||
|
|
||||||
# ═══ Postgres (eigene HOST-DB, GETRENNT von Kunden-Rapport-Daten) ═══
|
# ═══ Postgres (eigene HOST-DB, GETRENNT von Kunden-Rapport-Daten) ═══
|
||||||
DATABASE_URL=postgres://rapport_host:rapport_host@localhost:55432/rapport_host
|
DATABASE_URL=postgres://rapport_host:rapport_host@localhost:55432/rapport_host
|
||||||
|
|
||||||
# ═══ Stripe ═══
|
# ═══ Stripe ═══ (solange CHANGE-ME → MOCK-Modus, kein echtes Geld)
|
||||||
# Test-Mode-Keys (sk_test_… / pk_test_…) für lokale Entwicklung.
|
# Setup wenn du so weit bist:
|
||||||
# Live-Keys (sk_live_…) erst in Produktion. Test-Karten: 4242 4242 4242 4242
|
# 1. Stripe-Account, Test-Modus aktivieren
|
||||||
|
# 2. Entwickler → API-Keys: Secret Key (sk_test_…) → STRIPE_SECRET_KEY
|
||||||
|
# 3. Produkte: 3 Stück mit je 1 wiederkehrendem Preis (CHF/Monat) →
|
||||||
|
# Price-IDs (price_…) unten eintragen
|
||||||
|
# 4. Webhook auf https://DEINE-URL/api/billing/webhook, Events:
|
||||||
|
# checkout.session.completed, customer.subscription.updated,
|
||||||
|
# customer.subscription.deleted, invoice.payment_failed
|
||||||
|
# → Signing Secret (whsec_…) → STRIPE_WEBHOOK_SECRET
|
||||||
|
# Lokal testen: stripe listen --forward-to localhost:8787/api/billing/webhook
|
||||||
|
# TWINT: Stripe unterstützt es nur für Einmalzahlung, nicht Abos → Abo läuft
|
||||||
|
# über Karte; TWINT später als separater Einmal-Checkout.
|
||||||
STRIPE_SECRET_KEY=sk_test_CHANGE-ME
|
STRIPE_SECRET_KEY=sk_test_CHANGE-ME
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_CHANGE-ME
|
STRIPE_WEBHOOK_SECRET=whsec_CHANGE-ME
|
||||||
# Price-IDs aus dem Stripe-Dashboard (ein Recurring-Price pro Plan).
|
|
||||||
STRIPE_PRICE_SOLO=price_CHANGE-ME
|
STRIPE_PRICE_SOLO=price_CHANGE-ME
|
||||||
STRIPE_PRICE_STUDIO=price_CHANGE-ME
|
STRIPE_PRICE_STUDIO=price_CHANGE-ME
|
||||||
STRIPE_PRICE_BUSINESS=price_CHANGE-ME
|
STRIPE_PRICE_BUSINESS=price_CHANGE-ME
|
||||||
|
|||||||
@@ -64,14 +64,29 @@ billingRouter.post("/checkout", requireAuth, async (req, res) => {
|
|||||||
return res.json({ mock: true, url: `${env.publicBaseUrl}/konto/?provisioned=1` });
|
return res.json({ mock: true, url: `${env.publicBaseUrl}/konto/?provisioned=1` });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stripe-Checkout-Session ──────────────────────────────────────────────
|
// ── Stripe-Checkout-Session (Abo per Karte) ──────────────────────────────
|
||||||
|
// Hinweis: TWINT unterstützt Stripe NUR für Einmalzahlungen, nicht für
|
||||||
|
// Subscriptions — daher hier bewusst nur Karte. TWINT käme später als
|
||||||
|
// separater Einmal-Checkout (z.B. Jahres-Vorauszahlung).
|
||||||
if (!plan.stripePriceId) return res.status(500).json({ error: "Kein Stripe-Price für diesen Plan konfiguriert." });
|
if (!plan.stripePriceId) return res.status(500).json({ error: "Kein Stripe-Price für diesen Plan konfiguriert." });
|
||||||
|
|
||||||
|
// Wiederkehrenden Kunden mit bestehender Stripe-Customer-ID wiederverwenden,
|
||||||
|
// damit nicht pro Checkout ein neuer Customer entsteht.
|
||||||
|
const prev = await one(
|
||||||
|
"select stripe_customer_id from subscriptions where account_id = $1 and stripe_customer_id is not null order by created_at desc limit 1",
|
||||||
|
[req.account.id]
|
||||||
|
);
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
|
payment_method_types: ["card"],
|
||||||
line_items: [{ price: plan.stripePriceId, quantity: 1 }],
|
line_items: [{ price: plan.stripePriceId, quantity: 1 }],
|
||||||
customer_email: req.account.email,
|
...(prev?.stripe_customer_id
|
||||||
|
? { customer: prev.stripe_customer_id }
|
||||||
|
: { customer_email: req.account.email }),
|
||||||
client_reference_id: req.account.id,
|
client_reference_id: req.account.id,
|
||||||
|
locale: "de",
|
||||||
metadata: { accountId: req.account.id, planId: plan.id },
|
metadata: { accountId: req.account.id, planId: plan.id },
|
||||||
|
subscription_data: { metadata: { accountId: req.account.id, planId: plan.id } },
|
||||||
success_url: `${env.publicBaseUrl}/konto/?provisioned=1`,
|
success_url: `${env.publicBaseUrl}/konto/?provisioned=1`,
|
||||||
cancel_url: `${env.publicBaseUrl}/hosting-preise/?canceled=1`,
|
cancel_url: `${env.publicBaseUrl}/hosting-preise/?canceled=1`,
|
||||||
});
|
});
|
||||||
@@ -82,6 +97,31 @@ billingRouter.post("/checkout", requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Stripe Customer Portal: Kunde verwaltet Abo selbst ──────────────────────
|
||||||
|
// (kündigen, Zahlungsmittel ändern, Rechnungen herunterladen). Stripe hostet
|
||||||
|
// die Seite; wir erzeugen nur eine Session und leiten weiter.
|
||||||
|
billingRouter.post("/portal", requireAuth, async (req, res) => {
|
||||||
|
if (!stripeEnabled) {
|
||||||
|
// Im Mock gibt es kein Stripe-Portal → zurück zum Konto.
|
||||||
|
return res.json({ mock: true, url: `${env.publicBaseUrl}/konto/` });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const row = await one(
|
||||||
|
"select stripe_customer_id from subscriptions where account_id = $1 and stripe_customer_id is not null order by created_at desc limit 1",
|
||||||
|
[req.account.id]
|
||||||
|
);
|
||||||
|
if (!row?.stripe_customer_id) return res.status(400).json({ error: "Kein Abo zum Verwalten." });
|
||||||
|
const session = await stripe.billingPortal.sessions.create({
|
||||||
|
customer: row.stripe_customer_id,
|
||||||
|
return_url: `${env.publicBaseUrl}/konto/`,
|
||||||
|
});
|
||||||
|
res.json({ url: session.url });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("portal fehlgeschlagen:", err.message);
|
||||||
|
res.status(502).json({ error: "Portal fehlgeschlagen: " + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Stripe-Webhook. Braucht RAW Body (Signaturprüfung) — in index.js so verdrahtet.
|
// Stripe-Webhook. Braucht RAW Body (Signaturprüfung) — in index.js so verdrahtet.
|
||||||
billingRouter.post("/webhook", async (req, res) => {
|
billingRouter.post("/webhook", async (req, res) => {
|
||||||
let event;
|
let event;
|
||||||
@@ -105,6 +145,20 @@ billingRouter.post("/webhook", async (req, res) => {
|
|||||||
status: sub.status,
|
status: sub.status,
|
||||||
periodEnd: new Date(sub.current_period_end * 1000),
|
periodEnd: new Date(sub.current_period_end * 1000),
|
||||||
});
|
});
|
||||||
|
} else if (event.type === "customer.subscription.updated") {
|
||||||
|
// Status-/Perioden-Änderungen spiegeln (z.B. past_due, active nach Fix).
|
||||||
|
const sub = event.data.object;
|
||||||
|
await query(
|
||||||
|
`update subscriptions set status = $1, current_period_end = $2 where stripe_subscription_id = $3`,
|
||||||
|
[sub.status, new Date(sub.current_period_end * 1000), sub.id]
|
||||||
|
);
|
||||||
|
// Wieder aktiv → Instanz reaktivieren; nicht-aktiv → sperren.
|
||||||
|
const instStatus = sub.status === "active" ? "active" : "suspended";
|
||||||
|
await query(
|
||||||
|
`update instances set status = $1
|
||||||
|
where account_id = (select account_id from subscriptions where stripe_subscription_id = $2)`,
|
||||||
|
[instStatus, sub.id]
|
||||||
|
);
|
||||||
} else if (event.type === "customer.subscription.deleted") {
|
} else if (event.type === "customer.subscription.deleted") {
|
||||||
const sub = event.data.object;
|
const sub = event.data.object;
|
||||||
await query("update subscriptions set status = 'canceled' where stripe_subscription_id = $1", [sub.id]);
|
await query("update subscriptions set status = 'canceled' where stripe_subscription_id = $1", [sub.id]);
|
||||||
@@ -113,6 +167,13 @@ billingRouter.post("/webhook", async (req, res) => {
|
|||||||
where account_id = (select account_id from subscriptions where stripe_subscription_id = $1)`,
|
where account_id = (select account_id from subscriptions where stripe_subscription_id = $1)`,
|
||||||
[sub.id]
|
[sub.id]
|
||||||
);
|
);
|
||||||
|
} else if (event.type === "invoice.payment_failed") {
|
||||||
|
// Fehlgeschlagene Zahlung → Abo past_due markieren (im Cockpit sichtbar).
|
||||||
|
const inv = event.data.object;
|
||||||
|
if (inv.subscription) {
|
||||||
|
await query("update subscriptions set status = 'past_due' where stripe_subscription_id = $1", [inv.subscription]);
|
||||||
|
console.warn(`⚠ Zahlung fehlgeschlagen für Subscription ${inv.subscription}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
res.json({ received: true });
|
res.json({ received: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user