// 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." }); // ── 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}/dashboard?provisioned=1` }); } // ── Stripe-Checkout-Session ──────────────────────────────────────────────── if (!plan.stripePriceId) return res.status(500).json({ error: "Kein Stripe-Price für diesen Plan konfiguriert." }); const session = await stripe.checkout.sessions.create({ mode: "subscription", line_items: [{ price: plan.stripePriceId, quantity: 1 }], customer_email: req.account.email, client_reference_id: req.account.id, metadata: { accountId: req.account.id, planId: plan.id }, success_url: `${env.publicBaseUrl}/dashboard?provisioned=1`, cancel_url: `${env.publicBaseUrl}/plans?canceled=1`, }); res.json({ url: session.url }); }); // 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.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] ); } res.json({ received: true }); } catch (err) { console.error("Webhook-Verarbeitung fehlgeschlagen:", err); res.status(500).json({ error: "fulfillment failed" }); } });