diff --git a/.env.example b/.env.example index b1b061a..bc4dc5a 100644 --- a/.env.example +++ b/.env.example @@ -13,18 +13,27 @@ PUBLIC_BASE_URL=http://localhost:8787 # ═══ Auth ═══ # JWT-Signatur-Secret für HOST-Kundenkonten. openssl rand -hex 32 JWT_SECRET=CHANGE-ME-min-32-zeichen -# Konto mit dieser E-Mail wird automatisch Admin (Betreiber-Bereich /admin). -ADMIN_EMAIL=karim@gabrielevarano.ch +# Passwort für den Betreiber-Bereich (/admin), getrennt von Kundenkonten. +ADMIN_PASSWORD=CHANGE-ME-admin-passwort # ═══ Postgres (eigene HOST-DB, GETRENNT von Kunden-Rapport-Daten) ═══ DATABASE_URL=postgres://rapport_host:rapport_host@localhost:55432/rapport_host -# ═══ Stripe ═══ -# Test-Mode-Keys (sk_test_… / pk_test_…) für lokale Entwicklung. -# Live-Keys (sk_live_…) erst in Produktion. Test-Karten: 4242 4242 4242 4242 +# ═══ Stripe ═══ (solange CHANGE-ME → MOCK-Modus, kein echtes Geld) +# Setup wenn du so weit bist: +# 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_WEBHOOK_SECRET=whsec_CHANGE-ME -# Price-IDs aus dem Stripe-Dashboard (ein Recurring-Price pro Plan). STRIPE_PRICE_SOLO=price_CHANGE-ME STRIPE_PRICE_STUDIO=price_CHANGE-ME STRIPE_PRICE_BUSINESS=price_CHANGE-ME diff --git a/server/routes/billing.js b/server/routes/billing.js index 448d174..e1e7321 100644 --- a/server/routes/billing.js +++ b/server/routes/billing.js @@ -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) {