254 lines
11 KiB
JavaScript
254 lines
11 KiB
JavaScript
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"));
|
|
});
|
|
}
|