import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { randomUUID } from 'node:crypto'; import { readdirSync, readFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { join } from 'node:path'; import { calculatePrice } from '@kc/platform/pricing'; import { getConnector, loadInstance } from '@kc/connectors'; import { ConnectorError } from '@kc/connector-sdk'; import { one, query, run, tx } from '../../core/db.js'; import { audit } from '../../core/audit.js'; import { clientIp, requireAuth, requirePermission } from '../../core/auth.js'; import { AppError, badRequest, conflict, notFound } from '../../core/errors.js'; import { canInOrg } from '../../core/policy.js'; import type { KcModule } from '../../core/module.js'; const int = (max: number) => z.number().int().min(0).max(max); const termPriceSchema = z.array(z.object({ termMonths: z.number().int().min(1).max(120), recurringCents: int(100_000_00) })).max(12) .refine((l) => new Set(l.map((t) => t.termMonths)).size === l.length, 'Jede Laufzeit darf nur einmal vorkommen'); const versionSchema = z.object({ name: z.string().trim().min(1).max(200), description: z.string().trim().max(2000).optional(), taxRateId: z.string().uuid(), priceBasis: z.enum(['net', 'gross']).default('net'), setupCents: int(100_000_00).default(0), recurringCents: int(100_000_00).default(0), billingInterval: z.enum(['once', 'monthly', 'yearly']), termMonths: int(120).default(0), renewal: z.enum(['auto', 'none']).default('none'), renewalTermMonths: int(120).optional(), noticeDays: int(365).default(30), provisioning: z.record(z.string(), z.unknown()).default({}), // Staffelpreise nach Laufzeit (optional): z. B. 1/3/6/12/24 Monate mit je eigenem Gesamtpreis für die Laufzeit. // Wählt ein Kunde/Personal bei der Bestellung eine Laufzeit daraus, gilt deren Preis statt recurringCents/billingInterval. termPrices: termPriceSchema.default([]), }); type VersionIn = z.infer; import { CUSTOMER_ACTIONS } from '../../core/actions.js'; const ACTIONS = z.array(z.enum(CUSTOMER_ACTIONS)); /** Fachregeln für Laufzeiten/Preise je Abrechnungsintervall. */ function checkTerms(v: VersionIn): number { if (v.billingInterval === 'once') { if (v.recurringCents > 0) throw badRequest('Einmalprodukte haben keinen wiederkehrenden Preis', 'BAD_TERMS'); if (v.termMonths > 0 || v.renewal === 'auto') throw badRequest('Einmalprodukte haben keine Laufzeit und keine Verlängerung', 'BAD_TERMS'); return 0; } const per = v.billingInterval === 'monthly' ? 1 : 12; const renewalMonths = v.renewal === 'auto' ? (v.renewalTermMonths ?? per) : 0; if (v.renewal === 'auto' && renewalMonths < 1) throw badRequest('Bei automatischer Verlängerung ist eine Verlängerungslaufzeit nötig', 'BAD_TERMS'); if (v.termMonths > 0 && v.termMonths % per !== 0) throw badRequest(`Die Laufzeit muss ein Vielfaches des Abrechnungsintervalls (${per} Monat${per > 1 ? 'e' : ''}) sein`, 'BAD_TERMS'); return renewalMonths; } async function checkConnector(instanceId: string | null | undefined, provisioning: Record, forActivation = false): Promise { if (!instanceId) { if (Object.keys(provisioning).length) throw badRequest('Provisionierungsparameter ohne Verbindung', 'BAD_PROVISIONING'); return; } const inst = await one('SELECT connector_key, capabilities_json FROM connector_instances WHERE id = ?', [instanceId]); if (!inst) throw badRequest('Verbindung nicht gefunden', 'BAD_CONNECTOR'); const c = getConnector(inst.connector_key); const msg = c.validateProvisioning?.(provisioning) ?? null; if (msg) throw badRequest(`Provisionierung ungültig: ${msg}`, 'BAD_PROVISIONING'); const caps: string[] = inst.capabilities_json ? (typeof inst.capabilities_json === 'string' ? JSON.parse(inst.capabilities_json) : inst.capabilities_json) : []; if (caps.length && !caps.includes('lifecycle.create')) throw badRequest('Diese Verbindung kann aktuell keine Objekte anlegen (Zugangsdaten mit Schreibrechten prüfen)', 'NO_CREATE_CAPABILITY'); // Edition (Schlüssel-Präfix) und festes Ablaufdatum: ein aktives Produkt darf sie nur versprechen, wenn der Anbieter sie nachweislich umsetzt. if (forActivation) { if (provisioning.keyPrefix && !caps.includes('license.key_prefix')) throw badRequest('Produkte mit Edition (Schlüssel-Präfix) können erst aktiviert werden, wenn das Lizenzsystem diese Erweiterung unterstützt. Als Entwurf ist es gespeichert.', 'NEEDS_LICENSE_EXTENSION'); if (provisioning.validityDays && !caps.includes('license.expiry')) throw badRequest('Produkte mit festem Ablaufdatum können erst aktiviert werden, wenn das Lizenzsystem diese Erweiterung unterstützt.', 'NEEDS_LICENSE_EXTENSION'); } } async function insertVersion(c: Parameters[2], productId: string, v: VersionIn, by: string): Promise { const tax = await one('SELECT id, rate_bp, active FROM tax_rates WHERE id = ?', [v.taxRateId], c); if (!tax || !tax.active) throw badRequest('Steuersatz nicht gefunden', 'BAD_TAX'); const renewalMonths = checkTerms(v); const last = await one('SELECT COALESCE(MAX(version), 0) AS n FROM product_versions WHERE product_id = ?', [productId], c); const id = randomUUID(); await run( `INSERT INTO product_versions (id, product_id, version, name, description, tax_rate_id, tax_bp, price_basis, setup_cents, recurring_cents, billing_interval, term_months, renewal, renewal_term_months, notice_days, provisioning_json, created_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, [id, productId, Number(last!.n) + 1, v.name, v.description ?? null, v.taxRateId, tax.rate_bp, v.priceBasis, v.setupCents, v.recurringCents, v.billingInterval, v.termMonths, v.renewal, renewalMonths, v.noticeDays, JSON.stringify(v.provisioning), by], c); for (const t of v.termPrices) await run('INSERT INTO product_term_prices (id, product_version_id, term_months, recurring_cents) VALUES (?,?,?,?)', [randomUUID(), id, t.termMonths, t.recurringCents], c); await run('UPDATE products SET current_version_id = ? WHERE id = ?', [id, productId], c); return id; } const termPricesFor = (versionId: string | null) => versionId ? query('SELECT term_months, recurring_cents FROM product_term_prices WHERE product_version_id = ? ORDER BY term_months', [versionId]) : Promise.resolve([]); interface NewProduct { sku: string; category: string; connectorInstanceId?: string | null; externalRef?: string; orderableByCustomer: boolean; requiresApproval: boolean; customerActions: string[]; status: 'draft' | 'active'; version: VersionIn } /** Legt ein Produkt mit erster Version an (Regeln, Verbindungsprüfung, Eindeutigkeit der Artikelnummer). */ async function createProduct(b: NewProduct, actorId: string): Promise { if (await one('SELECT 1 AS x FROM products WHERE sku = ?', [b.sku])) throw conflict('Artikelnummer bereits vergeben', 'SKU_EXISTS'); await checkConnector(b.connectorInstanceId, b.version.provisioning, b.status === 'active'); const id = randomUUID(); await tx(async (c) => { await run('INSERT INTO products (id, sku, category, status, connector_instance_id, external_ref, orderable_by_customer, requires_approval, customer_actions) VALUES (?,?,?,?,?,?,?,?,?)', [id, b.sku, b.category, b.status, b.connectorInstanceId ?? null, b.externalRef ?? null, b.orderableByCustomer ? 1 : 0, b.requiresApproval ? 1 : 0, JSON.stringify(b.customerActions)], c); await insertVersion(c, id, b.version, actorId); }); return id; } // ---- Produktpakete (Vorlagen, z. B. Editionen einer Software) -------------------- const bundleSchema = z.object({ key: z.string().regex(/^[a-z0-9-]{2,40}$/), name: z.string().max(200), description: z.string().max(2000).optional(), source: z.string().max(200).optional(), products: z.array(z.object({ sku: z.string().regex(/^[A-Za-z0-9._-]{2,50}$/), name: z.string().max(200), description: z.string().max(2000).optional(), category: z.enum(['hosting', 'license', 'addon', 'service']), priceBasis: z.enum(['net', 'gross']), setupCents: int(100_000_00), recurringCents: int(100_000_00), taxBp: int(10000), interval: z.enum(['once', 'monthly', 'yearly']), termMonths: int(120), renewal: z.enum(['auto', 'none']), renewalTermMonths: int(120), noticeDays: int(365), provisioning: z.record(z.string(), z.unknown()), orderableByCustomer: z.boolean().default(false), requiresApproval: z.boolean().default(true), customerActions: ACTIONS.default([]), })).min(1).max(50), }); type Bundle = z.infer; const bundleDir = fileURLToPath(new URL('../../../../../bundles/', import.meta.url)); function loadBundles(): Bundle[] { if (!existsSync(bundleDir)) return []; const out: Bundle[] = []; for (const f of readdirSync(bundleDir).filter((n) => n.endsWith('.json')).sort()) { const r = bundleSchema.safeParse(JSON.parse(readFileSync(join(bundleDir, f), 'utf8'))); if (r.success) out.push(r.data); } return out; } const versionView = (r: any) => ({ id: r.vid ?? r.id, version: r.version, name: r.vname ?? r.name, description: r.description, taxBp: r.tax_bp, priceBasis: r.price_basis, setupCents: r.setup_cents, recurringCents: r.recurring_cents, currency: r.currency, billingInterval: r.billing_interval, termMonths: r.term_months, renewal: r.renewal, renewalTermMonths: r.renewal_term_months, noticeDays: r.notice_days, }); const productSelect = `SELECT p.*, v.id AS vid, v.version, v.name AS vname, v.description, v.tax_bp, v.price_basis, v.setup_cents, v.recurring_cents, v.currency, v.billing_interval, v.term_months, v.renewal, v.renewal_term_months, v.notice_days, v.provisioning_json, i.name AS connector_name FROM products p LEFT JOIN product_versions v ON v.id = p.current_version_id LEFT JOIN connector_instances i ON i.id = p.connector_instance_id`; const j = (v: unknown, d: unknown) => (v == null ? d : typeof v === 'string' ? JSON.parse(v) : v); const productView = (r: any) => ({ id: r.id, sku: r.sku, category: r.category, status: r.status, connectorInstanceId: r.connector_instance_id, externalRef: r.external_ref ?? null, connectorName: r.connector_name, orderableByCustomer: !!r.orderable_by_customer, requiresApproval: !!r.requires_approval, customerActions: j(r.customer_actions, []), provisioning: j(r.provisioning_json, {}), current: r.vid ? versionView(r) : null, }); export const catalogModule: KcModule = { name: 'catalog', permissions: { staff: { support: ['products.read'], accounting: ['products.read'], admin: ['products.read', 'products.write'], superadmin: ['products.read', 'products.write'] } }, register(app: FastifyInstance) { app.get('/admin/tax-rates', async (req) => { requirePermission(req, 'products.read'); return (await query('SELECT id, name, rate_bp FROM tax_rates WHERE active = 1 ORDER BY rate_bp DESC')).map((t) => ({ id: t.id, name: t.name, rateBp: t.rate_bp })); }); app.get('/admin/products', async (req) => { requirePermission(req, 'products.read'); const rows = await query(`${productSelect} ORDER BY p.created_at DESC`); return Promise.all(rows.map(async (r) => ({ ...productView(r), termPrices: (await termPricesFor(r.vid ?? null)).map((t: any) => ({ termMonths: t.term_months, recurringCents: t.recurring_cents })) }))); }); app.get('/admin/products/:id', async (req) => { requirePermission(req, 'products.read'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params); const r = await one(`${productSelect} WHERE p.id = ?`, [id]); if (!r) throw notFound(); const versions = await query('SELECT * FROM product_versions WHERE product_id = ? ORDER BY version DESC', [id]); const termPrices = (await termPricesFor(r.vid ?? null)).map((t: any) => ({ termMonths: t.term_months, recurringCents: t.recurring_cents })); return { ...productView(r), termPrices, versions: versions.map(versionView) }; }); app.post('/admin/products', async (req) => { const a = requirePermission(req, 'products.write'); const b = z.object({ sku: z.string().trim().regex(/^[A-Za-z0-9._-]{2,50}$/, 'Nur Buchstaben, Ziffern, Punkt, Unterstrich und Bindestrich'), category: z.enum(['hosting', 'license', 'addon', 'service']), connectorInstanceId: z.string().uuid().nullable().optional(), externalRef: z.string().trim().max(100).optional(), orderableByCustomer: z.boolean().default(false), requiresApproval: z.boolean().default(true), customerActions: ACTIONS.default([]), status: z.enum(['draft', 'active']).default('draft'), version: versionSchema }).parse(req.body); if (b.externalRef && !b.connectorInstanceId) throw badRequest('Herkunft ohne Verbindung', 'BAD_CONNECTOR'); const id = await createProduct(b, a.user.id); await audit({ actorType: 'user', actorId: a.user.id, action: 'product.create', resourceType: 'product', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { sku: b.sku, importedFrom: b.externalRef, status: b.status, version: b.version.name } }); return { id }; }); /** Preis-/Vertragsänderung = neue unveränderliche Version. Bestehende Bestellungen und Verträge behalten ihren Snapshot. */ app.post('/admin/products/:id/versions', async (req) => { const a = requirePermission(req, 'products.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params); const v = versionSchema.parse(req.body); const p = await one('SELECT id, connector_instance_id, status FROM products WHERE id = ?', [id]); if (!p) throw notFound(); await checkConnector(p.connector_instance_id, v.provisioning, p.status === 'active'); const vid = await tx((c) => insertVersion(c, id, v, a.user.id)); await audit({ actorType: 'user', actorId: a.user.id, action: 'product.version', resourceType: 'product', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { versionId: vid, setupCents: v.setupCents, recurringCents: v.recurringCents } }); return { versionId: vid }; }); app.patch('/admin/products/:id', async (req) => { const a = requirePermission(req, 'products.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params); const b = z.object({ status: z.enum(['draft', 'active', 'retired']).optional(), orderableByCustomer: z.boolean().optional(), requiresApproval: z.boolean().optional(), customerActions: ACTIONS.optional(), connectorInstanceId: z.string().uuid().nullable().optional() }).parse(req.body); const before = await one(`${productSelect} WHERE p.id = ?`, [id]); if (!before) throw notFound(); if (b.status === 'active' && !before.vid) throw badRequest('Ohne Version nicht aktivierbar'); if (b.status === 'active') await checkConnector(before.connector_instance_id, j(before.provisioning_json, {}) as Record, true); if (b.connectorInstanceId !== undefined) await checkConnector(b.connectorInstanceId, j(before.provisioning_json, {}) as Record); await run('UPDATE products SET status = COALESCE(?, status), orderable_by_customer = COALESCE(?, orderable_by_customer), requires_approval = COALESCE(?, requires_approval), customer_actions = COALESCE(?, customer_actions), connector_instance_id = ? WHERE id = ?', [b.status ?? null, b.orderableByCustomer === undefined ? null : b.orderableByCustomer ? 1 : 0, b.requiresApproval === undefined ? null : b.requiresApproval ? 1 : 0, b.customerActions ? JSON.stringify(b.customerActions) : null, b.connectorInstanceId === undefined ? before.connector_instance_id : b.connectorInstanceId, id]); await audit({ actorType: 'user', actorId: a.user.id, action: 'product.update', resourceType: 'product', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), before: { status: before.status, orderableByCustomer: !!before.orderable_by_customer, requiresApproval: !!before.requires_approval }, after: b }); return { status: 'ok' }; }); /** * Übernahme: Angebote/Programme eines Anbieters live auslesen (Verbindung, Zugangsdaten bleiben serverseitig). * Zeigt je Eintrag, wie viele Produkte bereits daraus angelegt wurden. */ app.get('/admin/connectors/:id/catalog', async (req) => { requirePermission(req, 'products.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params); if (!(await one('SELECT 1 AS x FROM connector_instances WHERE id = ?', [id]))) throw notFound(); try { const { connector, ctx } = await loadInstance(id, req.correlationId); if (!connector.listCatalog || !(await connector.capabilities(ctx)).includes('catalog.list')) throw badRequest('Diese Verbindung unterstützt keine Produktübernahme', 'NO_CATALOG'); const items = await connector.listCatalog(ctx); const counts = await query('SELECT external_ref, COUNT(*) AS n FROM products WHERE connector_instance_id = ? AND external_ref IS NOT NULL GROUP BY external_ref', [id]); const byRef = new Map(counts.map((c) => [c.external_ref as string, Number(c.n)])); return items.map((i) => ({ ...i, importedProducts: byRef.get(i.externalRef) ?? 0 })); } catch (e) { if (e instanceof ConnectorError) throw new AppError(502, 'CONNECTOR_ERROR', `Der Anbieter konnte nicht gelesen werden: ${e.userMessage}`); throw e; } }); /** Vorlagenpakete (Dateien in /bundles): Vorschau mit Preisen, Laufzeiten und Provisionierung. */ app.get('/admin/product-bundles', async (req) => { requirePermission(req, 'products.write'); const taken = new Set((await query('SELECT sku FROM products')).map((r) => r.sku as string)); return loadBundles().map((b) => ({ ...b, products: b.products.map((p) => ({ ...p, exists: taken.has(p.sku) })) })); }); /** Importiert ausgewählte Produkte eines Pakets als Entwürfe und verknüpft sie mit Verbindung und Anbieter-Programm. */ app.post('/admin/product-bundles/:key/import', async (req) => { const a = requirePermission(req, 'products.write'); const { key } = z.object({ key: z.string().max(40) }).parse(req.params); const b = z.object({ connectorInstanceId: z.string().uuid(), programRef: z.string().trim().min(1).max(100), skus: z.array(z.string()).min(1).max(50) }).parse(req.body); const bundle = loadBundles().find((x) => x.key === key); if (!bundle) throw notFound('Paket nicht gefunden'); const inst = await one('SELECT connector_key FROM connector_instances WHERE id = ?', [b.connectorInstanceId]); if (!inst) throw badRequest('Verbindung nicht gefunden', 'BAD_CONNECTOR'); const tax = await query('SELECT id, rate_bp FROM tax_rates WHERE active = 1'); const created: { sku: string; id: string }[] = []; const skipped: { sku: string; reason: string }[] = []; for (const sku of b.skus) { const p = bundle.products.find((x) => x.sku === sku); if (!p) { skipped.push({ sku, reason: 'nicht im Paket' }); continue; } const t = tax.find((x) => Number(x.rate_bp) === p.taxBp); if (!t) { skipped.push({ sku, reason: `Steuersatz ${p.taxBp / 100} % nicht angelegt` }); continue; } try { const provisioning = inst.connector_key === 'licensing' ? { programId: Number(p.provisioning.programId ?? b.programRef), ...p.provisioning } : p.provisioning; if (inst.connector_key === 'licensing') provisioning.programId = Number(b.programRef); const id = await createProduct({ sku: p.sku, category: p.category, connectorInstanceId: b.connectorInstanceId, externalRef: b.programRef, orderableByCustomer: p.orderableByCustomer, requiresApproval: p.requiresApproval, customerActions: p.customerActions, status: 'draft', version: { name: p.name, description: p.description, taxRateId: t.id, priceBasis: p.priceBasis, setupCents: p.setupCents, recurringCents: p.recurringCents, billingInterval: p.interval, termMonths: p.termMonths, renewal: p.renewal, renewalTermMonths: p.renewalTermMonths || undefined, noticeDays: p.noticeDays, provisioning, termPrices: [] } }, a.user.id); created.push({ sku, id }); } catch (e) { skipped.push({ sku, reason: e instanceof AppError ? e.message : 'Fehler beim Anlegen' }); } } await audit({ actorType: 'user', actorId: a.user.id, action: 'product.bundle.import', resourceType: 'product', connector: inst.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { bundle: key, programRef: b.programRef, created: created.map((c) => c.sku), skipped } }); return { created, skipped }; }); /** * Neuen Hosting-Tarif beim Anbieter anlegen UND als Produkt definieren. Wirkt sofort beim Anbieter (Tarif entsteht dort): * Name muss frei sein, Größen in GB, "unbegrenzt" nur wenn die Instanz es kennt. Danach entsteht das Produkt (Entwurf oder aktiv). */ app.post('/admin/connectors/:id/hosting-plans', async (req) => { const a = requirePermission(req, 'products.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params); const lim = z.number().min(0).max(1_000_000).nullable().optional(); const b = z.object({ plan: z.object({ name: z.string().trim().min(1).max(100), limits: z.object({ diskSpaceGb: lim, trafficGb: lim, domains: lim, subdomains: lim, emailAccounts: lim, emailAddresses: lim, emailForwardings: lim, databases: lim, ftpUsers: lim, scheduledTasks: lim }).default({}), permissions: z.record(z.string(), z.boolean()).optional() }), product: z.object({ sku: z.string().trim().regex(/^[A-Za-z0-9._-]{2,50}$/), status: z.enum(['draft', 'active']).default('draft'), orderableByCustomer: z.boolean().default(false), requiresApproval: z.boolean().default(true), customerActions: ACTIONS.default([]), version: versionSchema.omit({ provisioning: true }) }), }).parse(req.body); const inst = await one('SELECT id, connector_key, capabilities_json FROM connector_instances WHERE id = ?', [id]); if (!inst) throw notFound(); const caps: string[] = inst.capabilities_json ? (typeof inst.capabilities_json === 'string' ? JSON.parse(inst.capabilities_json) : inst.capabilities_json) : []; if (!caps.includes('catalog.write')) throw badRequest('Diese Verbindung kann keine Tarife anlegen', 'NO_CATALOG_WRITE'); if (await one('SELECT 1 AS x FROM products WHERE sku = ?', [b.product.sku])) throw conflict('Artikelnummer bereits vergeben', 'SKU_EXISTS'); let item; try { const { connector, ctx } = await loadInstance(id, req.correlationId); item = await connector.createCatalogItem!(ctx, b.plan); } catch (e) { if (e instanceof ConnectorError) throw new AppError(e.code === 'CONFLICT' ? 409 : e.code === 'INVALID_INPUT' ? 400 : 502, e.code === 'CONFLICT' ? 'PLAN_EXISTS' : 'CONNECTOR_ERROR', e.code === 'INVALID_INPUT' || e.code === 'CONFLICT' ? e.message : `Der Anbieter meldet: ${e.userMessage}`); throw e; } await audit({ actorType: 'user', actorId: a.user.id, action: 'catalog.plan.create', resourceType: 'connector', resourceId: id, connector: inst.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { name: b.plan.name, ref: item.externalRef, limits: b.plan.limits } }); try { const productId = await createProduct({ sku: b.product.sku, category: item.category, connectorInstanceId: id, externalRef: item.externalRef, orderableByCustomer: b.product.orderableByCustomer, requiresApproval: b.product.requiresApproval, customerActions: b.product.customerActions, status: b.product.status, version: { ...b.product.version, provisioning: item.provisioning } }, a.user.id); await audit({ actorType: 'user', actorId: a.user.id, action: 'product.create', resourceType: 'product', resourceId: productId, correlationId: req.correlationId, ip: clientIp(req), after: { sku: b.product.sku, importedFrom: item.externalRef, viaPlanCreate: true } }); return { productId, plan: { ref: item.externalRef, name: item.name, features: item.features } }; } catch (e) { // Der Tarif existiert beim Anbieter bereits: nicht erneut anlegen, sondern über "Übernehmen" als Produkt anlegen throw new AppError(e instanceof AppError ? e.status : 500, 'PRODUCT_AFTER_PLAN_FAILED', `Der Tarif „${item.name}“ wurde beim Anbieter angelegt, das Produkt konnte aber nicht angelegt werden${e instanceof AppError ? `: ${e.message}` : ''}. Bitte den Tarif über „Aus Verbindung übernehmen“ als Produkt übernehmen.`); } }); /** Katalog für Kunden: nur aktive, bestellbare Produkte, Preise für die jeweilige Organisation (Netto/Brutto). */ app.get('/catalog', async (req) => { const a = requireAuth(req); const q = z.object({ org: z.string().uuid() }).parse(req.query); if (!canInOrg(a.principal, q.org, 'orders.read', 'products.read')) throw notFound(); const org = await one('SELECT customer_type FROM organizations WHERE id = ?', [q.org]); const rows = await query(`${productSelect} WHERE p.status = 'active' AND p.orderable_by_customer = 1 ORDER BY v.name`); return Promise.all(rows.map(async (r) => { const price = calculatePrice({ basis: r.price_basis, setupCents: r.setup_cents, recurringCents: r.recurring_cents, taxBp: r.tax_bp, interval: r.billing_interval, quantity: 1, discountBp: 0 }); // Staffelpreise nach Laufzeit (optional): je gewählter Laufzeit ein eigener Gesamtpreis statt des Basispreises. const tiers = (await termPricesFor(r.vid ?? null)).map((t: any) => ({ termMonths: t.term_months, price: calculatePrice({ basis: r.price_basis, setupCents: 0, recurringCents: t.recurring_cents, taxBp: r.tax_bp, interval: r.billing_interval, quantity: 1, discountBp: 0 }).recurring, })); return { id: r.id, sku: r.sku, category: r.category, name: r.vname, description: r.description, requiresApproval: !!r.requires_approval, customerType: org?.customer_type, price, termMonths: r.term_months, renewal: r.renewal, renewalTermMonths: r.renewal_term_months, noticeDays: r.notice_days, termPrices: tiers }; })); }); }, };