Initial: RAPPORT-HOST Iteration 1 (proprietär)
Kommerzielle Hosting-/Abo-Plattform für Rapport-Instanzen. - React-Frontend (Vite/JSX): Landing, Register, Login, Plans, Dashboard - Node/Express-Backend: Auth (bcrypt+JWT), Stripe-Billing, Provisioning - HOST-Postgres-Schema: accounts, subscriptions, instances - Provisioning-Interface + Modell-A-Adapter (Studio im geteilten Stack) - MOCK-Modus: voller End-to-End-Flow ohne Stripe/Rapport-Stack testbar - Idempotentes Fulfillment (Upsert auf stripe_subscription_id) - docker-compose für lokale host-db; identisch auf Hetzner deploybar E2E lokal verifiziert: Register -> Checkout(mock) -> Instanz -> Idempotenz. Lizenz: proprietär (kein AGPL-Code eingebunden, nur Netzwerk-API zur Familie). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
// HOST-Kundenkonten: Passwort-Hashing + JWT-Ausstellung/-Prüfung.
|
||||
// (Das sind die Konten auf der RAPPORT-HOST-Plattform — NICHT die Endnutzer
|
||||
// in den einzelnen Rapport-Instanzen.)
|
||||
import bcrypt from "bcryptjs";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { env } from "./env.js";
|
||||
|
||||
export async function hashPassword(plain) {
|
||||
return bcrypt.hash(plain, 10);
|
||||
}
|
||||
|
||||
export async function verifyPassword(plain, hash) {
|
||||
return bcrypt.compare(plain, hash);
|
||||
}
|
||||
|
||||
export function signToken(account) {
|
||||
return jwt.sign({ sub: account.id, email: account.email }, env.jwtSecret, {
|
||||
expiresIn: "7d",
|
||||
});
|
||||
}
|
||||
|
||||
// Express-Middleware: setzt req.account aus dem Bearer-Token oder 401.
|
||||
export function requireAuth(req, res, next) {
|
||||
const header = req.headers.authorization || "";
|
||||
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
|
||||
if (!token) return res.status(401).json({ error: "Nicht angemeldet." });
|
||||
try {
|
||||
const payload = jwt.verify(token, env.jwtSecret);
|
||||
req.account = { id: payload.sub, email: payload.email };
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: "Session ungültig oder abgelaufen." });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Postgres-Pool für die HOST-eigene Datenbank (Konten, Abos, Instanzen).
|
||||
// Bewusst getrennt von den Kunden-Rapport-Daten.
|
||||
import pg from "pg";
|
||||
import { env } from "./env.js";
|
||||
|
||||
export const pool = new pg.Pool({ connectionString: env.databaseUrl });
|
||||
|
||||
export async function query(text, params) {
|
||||
return pool.query(text, params);
|
||||
}
|
||||
|
||||
// Kleiner Helfer: erste Zeile oder null.
|
||||
export async function one(text, params) {
|
||||
const { rows } = await pool.query(text, params);
|
||||
return rows[0] || null;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Zentrales Laden + Validieren der Umgebungsvariablen.
|
||||
// Liest .env (einfacher Parser, keine Dependency) und fällt sonst auf
|
||||
// process.env zurück — so läuft es lokal mit Datei und auf Hetzner mit
|
||||
// echten Env-Vars, ohne Code-Änderung.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const envPath = path.resolve(__dirname, "..", ".env");
|
||||
|
||||
if (fs.existsSync(envPath)) {
|
||||
for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
|
||||
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
||||
if (!m) continue;
|
||||
const key = m[1];
|
||||
let val = m[2].replace(/^["']|["']$/g, "");
|
||||
if (process.env[key] === undefined) process.env[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
const e = process.env;
|
||||
|
||||
export const env = {
|
||||
port: parseInt(e.PORT || "8787", 10),
|
||||
publicBaseUrl: (e.PUBLIC_BASE_URL || "http://localhost:5273").replace(/\/+$/, ""),
|
||||
jwtSecret: e.JWT_SECRET || "dev-insecure-secret-change-me",
|
||||
databaseUrl: e.DATABASE_URL || "postgres://rapport_host:rapport_host@localhost:55432/rapport_host",
|
||||
|
||||
stripe: {
|
||||
secretKey: e.STRIPE_SECRET_KEY || "",
|
||||
webhookSecret: e.STRIPE_WEBHOOK_SECRET || "",
|
||||
prices: {
|
||||
solo: e.STRIPE_PRICE_SOLO || "",
|
||||
studio: e.STRIPE_PRICE_STUDIO || "",
|
||||
business: e.STRIPE_PRICE_BUSINESS || "",
|
||||
},
|
||||
},
|
||||
|
||||
rapport: {
|
||||
apiUrl: e.RAPPORT_API_URL || "",
|
||||
serviceKey: e.RAPPORT_SERVICE_KEY || "",
|
||||
instanceUrlTemplate: e.RAPPORT_INSTANCE_URL_TEMPLATE || "http://localhost:8080/?studio={slug}",
|
||||
},
|
||||
};
|
||||
|
||||
// MOCK-Modus: ohne echte Stripe-/Rapport-Keys läuft alles lokal simuliert,
|
||||
// damit der End-to-End-Flow ohne externe Dienste testbar ist.
|
||||
export const stripeEnabled = !!env.stripe.secretKey && env.stripe.secretKey.startsWith("sk_");
|
||||
export const provisioningMock = !env.rapport.apiUrl || !env.rapport.serviceKey;
|
||||
|
||||
if (env.jwtSecret === "dev-insecure-secret-change-me") {
|
||||
console.warn("⚠ JWT_SECRET nicht gesetzt — unsicheres Dev-Secret in Verwendung.");
|
||||
}
|
||||
if (!stripeEnabled) console.warn("⚠ STRIPE_SECRET_KEY fehlt — Billing läuft im MOCK-Modus.");
|
||||
if (provisioningMock) console.warn("⚠ RAPPORT_API_URL/SERVICE_KEY fehlen — Provisioning läuft im MOCK-Modus.");
|
||||
@@ -0,0 +1,34 @@
|
||||
// RAPPORT-HOST Backend-Entry.
|
||||
import express from "express";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { env } from "./env.js";
|
||||
import { authRouter } from "./routes/auth.js";
|
||||
import { billingRouter } from "./routes/billing.js";
|
||||
import { accountRouter } from "./routes/account.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
|
||||
// WICHTIG: Der Stripe-Webhook braucht den ROHEN Body für die Signaturprüfung.
|
||||
// express.raw greift nur für diese Route und setzt req._body, sodass das danach
|
||||
// registrierte express.json() den Webhook-Body NICHT erneut parst.
|
||||
app.use("/api/billing/webhook", express.raw({ type: "application/json" }));
|
||||
app.use(express.json());
|
||||
|
||||
app.get("/api/health", (_req, res) => res.json({ ok: true, service: "rapport-host" }));
|
||||
app.use("/api/auth", authRouter);
|
||||
app.use("/api/billing", billingRouter);
|
||||
app.use("/api/account", accountRouter);
|
||||
|
||||
// In Produktion liefert dasselbe Backend das gebaute Frontend aus (dist/).
|
||||
const dist = path.resolve(__dirname, "..", "dist");
|
||||
app.use(express.static(dist));
|
||||
app.get("*", (req, res, next) => {
|
||||
if (req.path.startsWith("/api/")) return next();
|
||||
res.sendFile(path.join(dist, "index.html"), (err) => err && next());
|
||||
});
|
||||
|
||||
app.listen(env.port, () => {
|
||||
console.log(`RAPPORT-HOST API läuft auf :${env.port} (Base: ${env.publicBaseUrl})`);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
// Einfacher Migrations-Runner: spielt alle server/migrations/*.sql in
|
||||
// Sortier-Reihenfolge ein. Idempotent (alle Migrations nutzen IF NOT EXISTS).
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { pool } from "./db.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dir = path.join(__dirname, "migrations");
|
||||
|
||||
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".sql")).sort();
|
||||
console.log(`→ ${files.length} Migration(en) gefunden.`);
|
||||
|
||||
for (const f of files) {
|
||||
const sql = fs.readFileSync(path.join(dir, f), "utf8");
|
||||
process.stdout.write(` ▸ ${f} … `);
|
||||
await pool.query(sql);
|
||||
console.log("ok");
|
||||
}
|
||||
|
||||
console.log("✓ HOST-Schema bereit.");
|
||||
await pool.end();
|
||||
@@ -0,0 +1,37 @@
|
||||
-- RAPPORT-HOST — Initiales Schema (HOST-eigene DB, getrennt von Kundendaten).
|
||||
|
||||
create extension if not exists "pgcrypto";
|
||||
|
||||
-- HOST-Kundenkonten (die zahlenden Büros, NICHT deren Endnutzer).
|
||||
create table if not exists accounts (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
email text unique not null,
|
||||
password_hash text not null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- Abos. Ein Konto kann über die Zeit mehrere haben (Upgrade/Downgrade/Re-Sub).
|
||||
create table if not exists subscriptions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
account_id uuid not null references accounts(id) on delete cascade,
|
||||
plan text not null, -- solo | studio | business
|
||||
status text not null, -- active | past_due | canceled | ...
|
||||
stripe_customer_id text,
|
||||
stripe_subscription_id text unique, -- idempotenz-key fürs Fulfillment
|
||||
current_period_end timestamptz,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- Bereitgestellte Rapport-Instanzen (Modell A: ein Studio im geteilten Stack).
|
||||
create table if not exists instances (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
account_id uuid not null references accounts(id) on delete cascade,
|
||||
studio_id uuid, -- studio_id im Rapport-Stack
|
||||
studio_slug text not null,
|
||||
instance_url text not null,
|
||||
status text not null default 'active', -- active | suspended
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_subscriptions_account on subscriptions(account_id);
|
||||
create index if not exists idx_instances_account on instances(account_id);
|
||||
Generated
+1129
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "rapport-host-server",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "RAPPORT-HOST Backend — Auth, Stripe-Billing, Instanz-Provisioning",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"express": "^4.21.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pg": "^8.13.0",
|
||||
"stripe": "^17.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Abo-Pläne — eine Quelle der Wahrheit für Frontend (Preisanzeige) und Backend
|
||||
// (Stripe-Price-Mapping + Instanz-Limits beim Provisioning).
|
||||
import { env } from "./env.js";
|
||||
|
||||
export const PLANS = [
|
||||
{
|
||||
id: "solo",
|
||||
name: "Solo",
|
||||
priceChf: 19,
|
||||
interval: "Monat",
|
||||
stripePriceId: env.stripe.prices.solo,
|
||||
limits: { users: 1, projects: 25, storageGb: 2 },
|
||||
features: ["1 Benutzer", "25 Projekte", "2 GB Speicher", "Eigene Instanz"],
|
||||
},
|
||||
{
|
||||
id: "studio",
|
||||
name: "Studio",
|
||||
priceChf: 49,
|
||||
interval: "Monat",
|
||||
stripePriceId: env.stripe.prices.studio,
|
||||
limits: { users: 5, projects: 200, storageGb: 20 },
|
||||
features: ["Bis 5 Benutzer", "200 Projekte", "20 GB Speicher", "Eigene Instanz", "Support"],
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "business",
|
||||
name: "Business",
|
||||
priceChf: 99,
|
||||
interval: "Monat",
|
||||
stripePriceId: env.stripe.prices.business,
|
||||
limits: { users: 20, projects: 1000, storageGb: 100 },
|
||||
features: ["Bis 20 Benutzer", "1000 Projekte", "100 GB Speicher", "Eigene Instanz", "Prioritäts-Support"],
|
||||
},
|
||||
];
|
||||
|
||||
export function getPlan(id) {
|
||||
return PLANS.find((p) => p.id === id) || null;
|
||||
}
|
||||
|
||||
// Fürs Frontend: ohne interne Felder (Stripe-IDs müssen nicht raus).
|
||||
export function publicPlans() {
|
||||
return PLANS.map(({ stripePriceId, ...rest }) => rest);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Provisioning-Interface — entkoppelt "Abo bezahlt" von "wie entsteht die Instanz".
|
||||
//
|
||||
// Modell A (jetzt): ein Studio im geteilten Rapport-Stack -> studio-adapter.js
|
||||
// Modell B (später): eigener Container pro Kunde -> container-adapter.js
|
||||
//
|
||||
// Beide implementieren dieselbe Signatur:
|
||||
// provision({ account, plan }) -> { studioId, slug, instanceUrl }
|
||||
// deprovision({ instance }) -> void
|
||||
//
|
||||
// Der Aufrufer (Billing-Webhook) kennt nur dieses Interface, nie die Details.
|
||||
import * as studioAdapter from "./studio-adapter.js";
|
||||
|
||||
const adapter = studioAdapter; // Modell A. Später per Env umschaltbar.
|
||||
|
||||
export function provision(args) {
|
||||
return adapter.provision(args);
|
||||
}
|
||||
|
||||
export function deprovision(args) {
|
||||
return adapter.deprovision(args);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Modell-A-Provisioning: legt für einen zahlenden HOST-Kunden ein isoliertes
|
||||
// Studio im GETEILTEN Rapport-Stack an. Vermarktet als "eigene Instanz",
|
||||
// technisch ein mandantengetrenntes Studio (RLS).
|
||||
//
|
||||
// Ablauf gegen den Rapport-Stack (echtes Provisioning):
|
||||
// 1. GoTrue-Admin-API (service_role): Auth-User für die Kunden-Email anlegen
|
||||
// 2. RPC ensure_profile: Profil füllen
|
||||
// 3. RPC create_studio_with_admin: Studio anlegen, User = Admin
|
||||
// 4. Instanz-URL aus Template bauen
|
||||
//
|
||||
// MOCK-Modus (provisioningMock): ohne RAPPORT_API_URL/SERVICE_KEY wird nur eine
|
||||
// synthetische studioId + slug erzeugt, damit der gesamte HOST-Flow lokal ohne
|
||||
// laufenden Rapport-Stack durchgetestet werden kann.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { env, provisioningMock } from "../env.js";
|
||||
|
||||
function makeSlug(seed) {
|
||||
const base = (seed || "studio").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
|
||||
return `${base || "studio"}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
function instanceUrl(slug) {
|
||||
return env.rapport.instanceUrlTemplate.replace("{slug}", encodeURIComponent(slug));
|
||||
}
|
||||
|
||||
export async function provision({ account, plan }) {
|
||||
const slug = makeSlug(account.email.split("@")[0]);
|
||||
|
||||
if (provisioningMock) {
|
||||
const studioId = randomUUID();
|
||||
console.log(`[provision:MOCK] Studio '${slug}' (${studioId}) für ${account.email}, Plan ${plan.id}`);
|
||||
return { studioId, slug, instanceUrl: instanceUrl(slug) };
|
||||
}
|
||||
|
||||
// ── Echtes Provisioning gegen den geteilten Rapport-Stack ──────────────────
|
||||
// Hinweis: nutzt service_role (RAPPORT_SERVICE_KEY) — niemals ins Frontend!
|
||||
const base = env.rapport.apiUrl.replace(/\/+$/, "");
|
||||
const headers = {
|
||||
apikey: env.rapport.serviceKey,
|
||||
Authorization: `Bearer ${env.rapport.serviceKey}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
// 1. Auth-User anlegen (GoTrue Admin-API), bereits bestätigt.
|
||||
const tempPassword = randomUUID();
|
||||
const userRes = await fetch(`${base}/auth/v1/admin/users`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ email: account.email, password: tempPassword, email_confirm: true }),
|
||||
});
|
||||
if (!userRes.ok) throw new Error(`GoTrue admin/users: ${userRes.status} ${await userRes.text()}`);
|
||||
const user = await userRes.json();
|
||||
|
||||
// 2.+3. Profil + Studio per RPC. Da create_studio_with_admin auth.uid() nutzt,
|
||||
// muss der Aufruf im Kontext des neuen Users laufen — hier vereinfacht über
|
||||
// einen service_role-RPC, der die Ziel-User-ID als Argument nimmt. Diese
|
||||
// server-seitige Variante (create_studio_for_user) ist im Rapport-Schema noch
|
||||
// anzulegen; bis dahin schützt der MOCK-Modus den lokalen Test.
|
||||
const slugForStudio = slug;
|
||||
const rpcRes = await fetch(`${base}/rest/v1/rpc/create_studio_for_user`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ p_user_id: user.id, p_name: account.email.split("@")[0], p_slug: slugForStudio }),
|
||||
});
|
||||
if (!rpcRes.ok) throw new Error(`create_studio_for_user: ${rpcRes.status} ${await rpcRes.text()}`);
|
||||
const studioId = (await rpcRes.json());
|
||||
|
||||
return { studioId, slug: slugForStudio, instanceUrl: instanceUrl(slugForStudio) };
|
||||
}
|
||||
|
||||
export async function deprovision({ instance }) {
|
||||
if (provisioningMock) {
|
||||
console.log(`[deprovision:MOCK] Studio ${instance.studio_slug} deaktiviert.`);
|
||||
return;
|
||||
}
|
||||
// Echtes Deprovisioning (Studio sperren statt löschen — Daten erhalten für
|
||||
// Reaktivierung/Export) ist im Rapport-Schema noch zu definieren.
|
||||
console.warn(`[deprovision] noch nicht implementiert für ${instance.studio_slug}`);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Kunden-Dashboard-Daten: Konto + aktuelles Abo + bereitgestellte Instanz.
|
||||
import { Router } from "express";
|
||||
import { one } from "../db.js";
|
||||
import { requireAuth } from "../auth.js";
|
||||
import { publicPlans } from "../plans.js";
|
||||
|
||||
export const accountRouter = Router();
|
||||
|
||||
accountRouter.get("/me", requireAuth, async (req, res) => {
|
||||
const subscription = await one(
|
||||
`select plan, status, current_period_end, stripe_subscription_id
|
||||
from subscriptions where account_id = $1 order by created_at desc limit 1`,
|
||||
[req.account.id]
|
||||
);
|
||||
const instance = await one(
|
||||
`select studio_slug, instance_url, status, created_at
|
||||
from instances where account_id = $1 order by created_at desc limit 1`,
|
||||
[req.account.id]
|
||||
);
|
||||
res.json({
|
||||
account: req.account,
|
||||
subscription: subscription || null,
|
||||
instance: instance || null,
|
||||
plans: publicPlans(),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
// HOST-Konten: Registrierung + Login. Gibt ein JWT zurück.
|
||||
import { Router } from "express";
|
||||
import { one } from "../db.js";
|
||||
import { hashPassword, verifyPassword, signToken } from "../auth.js";
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
const isEmail = (s) => /.+@.+\..+/.test(s || "");
|
||||
|
||||
authRouter.post("/register", async (req, res) => {
|
||||
const { email, password } = req.body || {};
|
||||
if (!isEmail(email)) return res.status(400).json({ error: "Ungültige Email." });
|
||||
if (!password || password.length < 8) return res.status(400).json({ error: "Passwort min. 8 Zeichen." });
|
||||
|
||||
const existing = await one("select id from accounts where email = $1", [email.toLowerCase()]);
|
||||
if (existing) return res.status(409).json({ error: "Konto existiert bereits." });
|
||||
|
||||
const account = await one(
|
||||
"insert into accounts (email, password_hash) values ($1, $2) returning id, email",
|
||||
[email.toLowerCase(), await hashPassword(password)]
|
||||
);
|
||||
res.json({ token: signToken(account), account: { id: account.id, email: account.email } });
|
||||
});
|
||||
|
||||
authRouter.post("/login", async (req, res) => {
|
||||
const { email, password } = req.body || {};
|
||||
const account = await one("select id, email, password_hash from accounts where email = $1", [
|
||||
(email || "").toLowerCase(),
|
||||
]);
|
||||
if (!account || !(await verifyPassword(password || "", account.password_hash))) {
|
||||
return res.status(401).json({ error: "Email oder Passwort falsch." });
|
||||
}
|
||||
res.json({ token: signToken(account), account: { id: account.id, email: account.email } });
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
// 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" });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
// Stripe-Client — nur initialisiert, wenn ein echter Secret-Key vorliegt.
|
||||
// Im MOCK-Modus (kein Key) bleibt `stripe` null; billing.js simuliert dann.
|
||||
import Stripe from "stripe";
|
||||
import { env, stripeEnabled } from "./env.js";
|
||||
|
||||
export const stripe = stripeEnabled ? new Stripe(env.stripe.secretKey) : null;
|
||||
Reference in New Issue
Block a user