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:
2026-05-31 12:29:55 +02:00
parent 6471221dec
commit fb89094b63
2 changed files with 78 additions and 8 deletions
+63 -2
View File
@@ -64,14 +64,29 @@ billingRouter.post("/checkout", requireAuth, async (req, res) => {
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." });
// 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 }],
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,
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`,
});
@@ -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.
billingRouter.post("/webhook", async (req, res) => {
let event;
@@ -105,6 +145,20 @@ billingRouter.post("/webhook", async (req, res) => {
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]);
@@ -113,6 +167,13 @@ billingRouter.post("/webhook", async (req, res) => {
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) {