// Billing: Plan wählen → Stripe-Checkout → (Webhook) → Instanz freischalten. // // Stripe-Modus: /checkout erstellt eine Checkout-Session, Stripe leitet den // Kunden weiter; nach Zahlung ruft Stripe /webhook auf, das die // Instanz provisioniert. // MOCK-Modus: ohne Stripe-Key provisioniert /checkout sofort selbst (so ist // der ganze Flow lokal ohne Stripe testbar) und gibt eine // interne Erfolgs-URL zurück. import { Router } from "express"; import { one, query } from "../db.js"; import { requireAuth } from "../auth.js"; import { getPlan, publicPlans } from "../plans.js"; import { env, stripeEnabled } from "../env.js"; import { stripe } from "../stripe.js"; import { provision } from "../provisioning/index.js"; export const billingRouter = Router(); billingRouter.get("/plans", (_req, res) => res.json({ plans: publicPlans() })); // Gemeinsame Fulfillment-Logik: Abo speichern + Instanz provisionieren. // Idempotent über stripe_subscription_id (bzw. Mock-Kennung). async function fulfill({ accountId, plan, stripeCustomerId, stripeSubscriptionId, status, periodEnd }) { const account = await one("select id, email from accounts where id = $1", [accountId]); if (!account) throw new Error("Konto nicht gefunden: " + accountId); await query( `insert into subscriptions (account_id, plan, status, stripe_customer_id, stripe_subscription_id, current_period_end) values ($1,$2,$3,$4,$5,$6) on conflict (stripe_subscription_id) do update set status = excluded.status, current_period_end = excluded.current_period_end`, [accountId, plan.id, status, stripeCustomerId, stripeSubscriptionId, periodEnd] ); // Nur provisionieren, wenn für dieses Konto noch keine aktive Instanz da ist. const existing = await one("select id from instances where account_id = $1 limit 1", [accountId]); if (existing) return; const result = await provision({ account, plan }); await query( `insert into instances (account_id, studio_id, studio_slug, instance_url, status) values ($1,$2,$3,$4,'active')`, [accountId, result.studioId, result.slug, result.instanceUrl] ); console.log(`✓ Instanz freigeschaltet für ${account.email}: ${result.instanceUrl}`); } billingRouter.post("/checkout", requireAuth, async (req, res) => { const plan = getPlan(req.body?.planId); if (!plan) return res.status(400).json({ error: "Unbekannter Plan." }); try { // ── MOCK: sofort freischalten ──────────────────────────────────────────── if (!stripeEnabled) { await fulfill({ accountId: req.account.id, plan, stripeCustomerId: "mock_cus_" + req.account.id, stripeSubscriptionId: "mock_sub_" + req.account.id + "_" + plan.id, status: "active", periodEnd: new Date(Date.now() + 30 * 864e5), }); return res.json({ mock: true, url: `${env.publicBaseUrl}/konto/?provisioned=1` }); } // ── 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." }); // 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({ mode: "subscription", payment_method_types: ["card"], line_items: [{ price: plan.stripePriceId, quantity: 1 }], ...(prev?.stripe_customer_id ? { customer: prev.stripe_customer_id } : { customer_email: req.account.email }), client_reference_id: req.account.id, locale: "de", 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`, cancel_url: `${env.publicBaseUrl}/hosting-preise/?canceled=1`, }); res.json({ url: session.url }); } catch (err) { console.error("checkout fehlgeschlagen:", err.message); res.status(502).json({ error: "Checkout fehlgeschlagen: " + err.message }); } }); // ── 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. billingRouter.post("/webhook", async (req, res) => { let event; try { event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], env.stripe.webhookSecret); } catch (err) { console.error("Webhook-Signatur ungültig:", err.message); return res.status(400).send(`Webhook Error: ${err.message}`); } try { if (event.type === "checkout.session.completed") { const s = event.data.object; const plan = getPlan(s.metadata?.planId); const sub = await stripe.subscriptions.retrieve(s.subscription); await fulfill({ accountId: s.metadata?.accountId || s.client_reference_id, plan, stripeCustomerId: s.customer, stripeSubscriptionId: s.subscription, status: sub.status, 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") { const sub = event.data.object; await query("update subscriptions set status = 'canceled' where stripe_subscription_id = $1", [sub.id]); await query( `update instances set status = 'suspended' where account_id = (select account_id from subscriptions where stripe_subscription_id = $1)`, [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 }); } catch (err) { console.error("Webhook-Verarbeitung fehlgeschlagen:", err); res.status(500).json({ error: "fulfillment failed" }); } });