Stand vor Einführung des Nacht-Agenten

This commit is contained in:
Kundencenter 2026-09-27 00:51:32 +02:00
commit 4763548bfb
168 changed files with 12726 additions and 0 deletions

View file

@ -0,0 +1,273 @@
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 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({}),
});
type VersionIn = z.infer<typeof versionSchema>;
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<string, unknown>, forActivation = false): Promise<void> {
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<typeof run>[2], productId: string, v: VersionIn, by: string): Promise<string> {
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);
await run('UPDATE products SET current_version_id = ? WHERE id = ?', [id, productId], c);
return id;
}
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<string> {
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<typeof bundleSchema>;
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');
return (await query(`${productSelect} ORDER BY p.created_at DESC`)).map(productView);
});
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]);
return { ...productView(r), 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<string, unknown>, true);
if (b.connectorInstanceId !== undefined) await checkConnector(b.connectorInstanceId, j(before.provisioning_json, {}) as Record<string, unknown>);
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 } }, 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 rows.map((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 });
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 };
});
});
},
};