4b96e1a6b0
- env.js: '…CHANGE-ME'-Platzhalter aus .env.example zählen als NICHT gesetzt. Vorher galt sk_test_CHANGE-ME als echter Stripe-Key → echter API-Call mit ungültigem Key → 401 → unhandledRejection → Server-Crash. - billing.js: /checkout in try/catch → 502 statt Empty-Reply/Crash. - index.js: globaler Express-Error-Handler + unhandledRejection-Guard, damit ein einzelner async-Fehler nie den ganzen Prozess killt. E2E verifiziert (Mock): register→checkout→instance, idempotent (1 sub/1 inst), 401 bei falschem PW, Server lebt nach allen Requests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
123 lines
5.4 KiB
JavaScript
123 lines
5.4 KiB
JavaScript
// 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}/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 });
|
|
} catch (err) {
|
|
console.error("checkout fehlgeschlagen:", err.message);
|
|
res.status(502).json({ error: "Checkout 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.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" });
|
|
}
|
|
});
|