2026-09-27 00:51:32 +02:00
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' ;
2026-10-01 12:24:48 +02:00
import { can , canInOrg } from '../../core/policy.js' ;
2026-09-27 00:51:32 +02:00
import type { KcModule } from '../../core/module.js' ;
const int = ( max : number ) = > z . number ( ) . int ( ) . min ( 0 ) . max ( max ) ;
2026-09-27 18:35:34 +02:00
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' ) ;
2026-09-27 00:51:32 +02:00
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 ( { } ) ,
2026-09-27 18:35:34 +02:00
// 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 ( [ ] ) ,
2026-09-27 00:51:32 +02:00
} ) ;
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 ) ;
2026-09-27 18:35:34 +02:00
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 ) ;
2026-09-27 00:51:32 +02:00
await run ( 'UPDATE products SET current_version_id = ? WHERE id = ?' , [ id , productId ] , c ) ;
return id ;
}
2026-09-27 18:35:34 +02:00
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 ( [ ] ) ;
2026-09-27 00:51:32 +02:00
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' ) ;
2026-09-27 18:35:34 +02:00
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 } ) ) } ) ) ) ;
2026-09-27 00:51:32 +02:00
} ) ;
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 ] ) ;
2026-09-27 18:35:34 +02:00
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 ) } ;
2026-09-27 00:51:32 +02:00
} ) ;
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' ,
2026-09-27 18:35:34 +02:00
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 ) ;
2026-09-27 00:51:32 +02:00
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 ] ) ;
2026-10-01 12:24:48 +02:00
// Personal darf auch Produkte bestellen, die Kunden nicht selbst bestellen können (siehe POST /orders)
const staff = can ( a . principal , 'orders.write' ) ;
const rows = await query ( ` ${ productSelect } WHERE p.status = 'active' ${ staff ? '' : ' AND p.orderable_by_customer = 1' } ORDER BY v.name ` ) ;
2026-09-27 18:35:34 +02:00
return Promise . all ( rows . map ( async ( r ) = > {
2026-09-27 00:51:32 +02:00
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 } ) ;
2026-09-27 18:35:34 +02:00
// 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 ,
} ) ) ;
2026-10-01 12:24:48 +02:00
return { id : r.id , sku : r.sku , category : r.category , name : r.vname , description : r.description , requiresApproval : ! ! r . requires_approval , orderableByCustomer : ! ! r . orderable_by_customer , customerType : org?.customer_type , price , termMonths : r.term_months , renewal : r.renewal , renewalTermMonths : r.renewal_term_months , noticeDays : r.notice_days , termPrices : tiers } ;
2026-09-27 18:35:34 +02:00
} ) ) ;
2026-09-27 00:51:32 +02:00
} ) ;
} ,
} ;