import { createHmac, timingSafeEqual } from "node:crypto"; const stripeSecretKey = String(process.env.STRIPE_SECRET_KEY || "").trim(); const stripeWebhookSecret = String(process.env.STRIPE_WEBHOOK_SECRET || "").trim(); const stripeApiBase = String(process.env.STRIPE_API_BASE || "https://api.stripe.com").replace(/\/$/, ""); export const catalog = Object.freeze({ manager_standard: { sku: "ENX-MANAGER", name: "Manager ohne Peak Shaving", unitAmount: 30815, setupAmount: 11460, entitlement: true }, manager_peak: { sku: "ENX-MANAGER-PEAK", name: "Manager mit Peak Shaving", unitAmount: 32905, setupAmount: 14180, entitlement: true }, manager_peak_upgrade: { sku: "ENX-MANAGER-PEAK-UPGRADE", name: "Upgrade auf Peak Shaving", unitAmount: 2090, setupAmount: 0, entitlement: true }, battery: { sku: "ENX-BATTERY", name: "Batteriespeicher", unitAmount: 4195, setupAmount: 5455, entitlement: true }, buffer_storage: { sku: "ENX-BUFFER", name: "Pufferspeicher", unitAmount: 2515, setupAmount: 3270, entitlement: true }, ev_charger: { sku: "ENX-EV", name: "EV-Ladestation", unitAmount: 3365, setupAmount: 4375, entitlement: true }, consumer_single: { sku: "ENX-CONSUMER-1", name: "Verbraucher einstufig", unitAmount: 2095, setupAmount: 2725, entitlement: true }, boiler_multi: { sku: "ENX-BOILER-MULTI", name: "Boiler mehrstufig", unitAmount: 2515, setupAmount: 3270, entitlement: true }, heat_pump: { sku: "ENX-HEAT-PUMP", name: "Wärmepumpe", unitAmount: 2095, setupAmount: 2725, entitlement: true }, forecast_pv: { sku: "ENX-FORECAST-PV", name: "PV-Ertragsprognose", unitAmount: 0, setupAmount: 0, entitlement: true, termMonths: 12 }, forecast_load: { sku: "ENX-FORECAST-LOAD", name: "Verbrauchsprognose", unitAmount: 0, setupAmount: 0, entitlement: true, termMonths: 12 }, grid_schedule: { sku: "ENX-GRID-SCHEDULE", name: "Intelligenter Netzfahrplan", unitAmount: 5000, setupAmount: 0, entitlement: true, termMonths: 12 }, cost_report: { sku: "ENX-BILLING", name: "Abrechnungsmanager", unitAmount: 24515, setupAmount: 3270, entitlement: true }, cost_meter_power: { sku: "ENX-BILLING-POWER", name: "Stromzähler Abrechnung", unitAmount: 500, setupAmount: 0, entitlement: true }, cost_meter_aux: { sku: "ENX-BILLING-AUX", name: "Wärme- oder Wasserzähler Abrechnung", unitAmount: 300, setupAmount: 0, entitlement: true } }); const moduleKeys = ["battery", "buffer_storage", "ev_charger", "consumer_single", "boiler_multi", "heat_pump"]; const forecastKeys = ["forecast_pv", "forecast_load", "grid_schedule"]; const licenseKeys = [...moduleKeys, ...forecastKeys]; export function publicCatalog(priceCatalog = catalog) { return Object.fromEntries(Object.entries(priceCatalog).map(([key, item]) => [key, { sku: item.sku, name: item.name, unitAmount: item.unitAmount, setupAmount: item.setupAmount || 0, termMonths: item.termMonths || null }])); } function quantity(value) { const number = Number(value || 0); if (!Number.isInteger(number) || number < 0 || number > 999) throw Object.assign(new Error("invalid_quantity"), { status: 400 }); return number; } export function buildOrderLines( managerVariant, requestedModules = {}, setupRequest = {}, ownedManagerVariant = null, priceCatalog = catalog, vatRate = 0 ) { const manager = priceCatalog[managerVariant]; if (!manager || !["manager_standard", "manager_peak"].includes(managerVariant)) { throw Object.assign(new Error("invalid_manager"), { status: 400, publicMessage: "Ungültige Manager-Variante." }); } if (ownedManagerVariant && !["manager_standard", "manager_peak"].includes(ownedManagerVariant)) { throw Object.assign(new Error("invalid_owned_manager"), { status: 400 }); } const lines = []; if (!ownedManagerVariant) { lines.push({ ...manager, catalogKey: managerVariant, quantity: 1, lineType: "license" }); } else if (ownedManagerVariant === "manager_standard" && managerVariant === "manager_peak") { lines.push({ ...priceCatalog.manager_peak_upgrade, catalogKey: "manager_peak", quantity: 1, lineType: "license" }); } if (setupRequest.manager === true && manager.setupAmount > 0) { lines.push({ sku: `${manager.sku}-SETUP`, name: `Generator / Ersteinrichtung ${manager.name}`, unitAmount: manager.setupAmount, catalogKey: managerVariant, quantity: 1, lineType: "setup", entitlement: false }); } for (const key of licenseKeys) { const count = quantity(requestedModules[key]); if (!count) continue; const item = priceCatalog[key]; if (!item || item.available === false) { throw Object.assign(new Error("catalog_item_unavailable"), { status: 400, publicMessage: "Die gewählte Lizenz ist derzeit nicht verfügbar." }); } if (key === "grid_schedule" && managerVariant !== "manager_peak") { throw Object.assign(new Error("grid_schedule_requires_peak"), { status: 400, publicMessage: "Der intelligente Netzfahrplan benötigt den Manager mit Peak Shaving." }); } lines.push({ ...item, catalogKey: key, quantity: count, lineType: "license" }); } const setupModules = setupRequest.modules || {}; for (const key of moduleKeys) { const count = quantity(setupModules[key]); if (!count) continue; const item = priceCatalog[key]; lines.push({ sku: `${item.sku}-SETUP`, name: `Generator / Ersteinrichtung ${item.name}`, unitAmount: item.setupAmount, catalogKey: key, quantity: count, lineType: "setup", entitlement: false }); } if (!lines.length) { throw Object.assign(new Error("nothing_to_purchase"), { status: 400, publicMessage: "Für diese Auswahl ist keine Lizenzerweiterung oder Ersteinrichtung erforderlich." }); } const normalizedVatRate = Number(vatRate); if (!Number.isFinite(normalizedVatRate) || normalizedVatRate < 0 || normalizedVatRate > 100) { throw Object.assign(new Error("invalid_vat_rate"), { status: 500 }); } const netAmount = lines.reduce((sum, line) => sum + line.unitAmount * line.quantity, 0); const vatAmount = Math.round(netAmount * normalizedVatRate / 100); if (vatAmount > 0) { lines.push({ sku: "ENX-VAT", name: `MwSt. ${normalizedVatRate.toFixed(1)} %`, unitAmount: vatAmount, catalogKey: "vat", quantity: 1, lineType: "tax", entitlement: false }); } return lines; } export function buildBillingOrderLines( requestedMeters = {}, ownedQuantities = {}, setupRequested = false, priceCatalog = catalog, vatRate = 0 ) { const lines = []; const base = priceCatalog.cost_report; if (quantity(ownedQuantities.cost_report) < 1) { lines.push({ ...base, catalogKey: "cost_report", quantity: 1, lineType: "license" }); } for (const key of ["cost_meter_power", "cost_meter_aux"]) { const count = quantity(requestedMeters[key]); if (!count) continue; lines.push({ ...priceCatalog[key], catalogKey: key, quantity: count, lineType: "license" }); } if (setupRequested && base.setupAmount > 0) { lines.push({ sku: `${base.sku}-SETUP`, name: `Generator / Ersteinrichtung ${base.name}`, unitAmount: base.setupAmount, catalogKey: "cost_report", quantity: 1, lineType: "setup", entitlement: false }); } if (!lines.length) { throw Object.assign(new Error("nothing_to_purchase"), { status: 400, publicMessage: "Für diese Abrechnungslizenz ist keine Erweiterung ausgewählt." }); } const normalizedVatRate = Number(vatRate); if (!Number.isFinite(normalizedVatRate) || normalizedVatRate < 0 || normalizedVatRate > 100) { throw Object.assign(new Error("invalid_vat_rate"), { status: 500 }); } const netAmount = lines.reduce((sum, line) => sum + line.unitAmount * line.quantity, 0); const vatAmount = Math.round(netAmount * normalizedVatRate / 100); if (vatAmount > 0) { lines.push({ sku: "ENX-VAT", name: `MwSt. ${normalizedVatRate.toFixed(1)} %`, unitAmount: vatAmount, catalogKey: "vat", quantity: 1, lineType: "tax", entitlement: false }); } return lines; } export function stripeConfigured() { return /^(sk|rk)_test_/.test(stripeSecretKey) && stripeWebhookSecret.startsWith("whsec_"); } export async function createCheckoutSession({ orderId, userId, email, lines, successUrl, cancelUrl }) { if (!stripeConfigured()) throw Object.assign(new Error("stripe_not_configured"), { status: 503 }); const params = new URLSearchParams(); params.set("mode", "payment"); params.set("customer_email", email); params.set("client_reference_id", orderId); params.set("metadata[order_id]", orderId); params.set("metadata[user_id]", String(userId)); params.set("success_url", successUrl); params.set("cancel_url", cancelUrl); lines.forEach((line, index) => { const root = `line_items[${index}]`; params.set(`${root}[quantity]`, String(line.quantity)); params.set(`${root}[price_data][currency]`, "chf"); params.set(`${root}[price_data][unit_amount]`, String(line.unitAmount)); params.set(`${root}[price_data][product_data][name]`, line.name); params.set(`${root}[price_data][product_data][metadata][sku]`, line.sku); }); const response = await fetch(`${stripeApiBase}/v1/checkout/sessions`, { method: "POST", headers: { Authorization: `Bearer ${stripeSecretKey}`, "Content-Type": "application/x-www-form-urlencoded" }, body: params }); const payload = await response.json().catch(() => ({})); if (!response.ok) { const providerError = payload.error || {}; const error = Object.assign(new Error("stripe_checkout_failed"), { status: 502, publicMessage: providerError.message || "Stripe Checkout konnte nicht geöffnet werden.", diagnostic: { status: response.status, type: providerError.type || "unknown", code: providerError.code || "unknown", param: providerError.param || null, requestId: response.headers.get("request-id") || null } }); throw error; } if (!payload.id || !payload.url) { throw Object.assign(new Error("stripe_checkout_invalid_response"), { status: 502, publicMessage: "Stripe Checkout hat keine gültige Sitzung zurückgegeben." }); } return { id: payload.id, url: payload.url, expiresAt: payload.expires_at || null }; } export function verifyStripeSignature(rawBody, signatureHeader) { if (!stripeWebhookSecret || !signatureHeader) return false; const parts = String(signatureHeader).split(",").map((part) => part.split("=")); const timestamp = parts.find(([key]) => key === "t")?.[1]; const signatures = parts.filter(([key]) => key === "v1").map(([, value]) => value); if (!/^\d+$/.test(timestamp || "") || !signatures.length) return false; if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300) return false; const expected = createHmac("sha256", stripeWebhookSecret) .update(`${timestamp}.${rawBody.toString("utf8")}`) .digest("hex"); return signatures.some((signature) => { if (!/^[0-9a-f]{64}$/i.test(signature)) return false; return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex")); }); }