2026-09-27 00:51:32 +02:00
import { beforeAll , describe , expect , it } from 'vitest' ;
import type { FastifyInstance } from 'fastify' ;
import { buildApp } from '../src/server.js' ;
import { one , query , run } from '../src/core/db.js' ;
import { enqueue } from '../src/core/jobs.js' ;
import { processContractLifecycle , provisionOrder } from '@kc/connectors' ;
import { resetMock } from '@kc/connector-mock' ;
import { runOnce } from '../../worker/src/jobs.js' ;
import { call , code , login , makeUser } from './helpers.js' ;
let app : FastifyInstance ;
beforeAll ( async ( ) = > { app = await buildApp ( ) ; await app . ready ( ) ; } ) ;
const drain = async ( ) = > { for ( let i = 0 ; i < 30 ; i ++ ) { await run ( "UPDATE jobs SET run_at = UTC_TIMESTAMP(3) WHERE status IN ('scheduled','retrying')" ) ; if ( ! ( await runOnce ( ( ) = > undefined ) ) ) break ; } } ;
async function staff ( email : string , role : string ) {
await makeUser ( { email , kind : 'staff' , staffRole : role } ) ;
const { client } = await login ( app , email ) ;
const s = ( await call ( app , client , 'POST' , '/auth/mfa/setup' ) ) . json ( ) ;
await call ( app , client , 'POST' , '/auth/mfa/confirm' , { code : code ( s . secret ) } ) ;
return client ;
}
async function customer ( admin : any , type : 'private' | 'business' , name : string , mail : string ) {
const c = ( await call ( app , admin , 'POST' , '/admin/customers' , { type , name , owner : { email : mail , name } } ) ) . json ( ) ;
await app . inject ( { method : 'POST' , url : '/v1/auth/invite/accept' , payload : { token : new URL ( c . inviteLink ) . searchParams . get ( 'token' ) , password : 'passwort-kunde-123' , repeat : 'passwort-kunde-123' } } ) ;
return { org : c.id as string , client : ( await login ( app , mail , 'passwort-kunde-123' ) ) . client } ;
}
const version = ( over : object = { } ) = > ( { name : 'Lizenz Pro' , taxRateId : '' , setupCents : 1000 , recurringCents : 4999 , billingInterval : 'yearly' , termMonths : 12 , renewal : 'auto' , renewalTermMonths : 12 , noticeDays : 30 , provisioning : { } , . . . over } ) ;
describe ( 'Produkte, Bestellungen, Verträge' , ( ) = > {
it ( 'Katalog, Bestellung, Freigabe, Bereitstellung, Snapshot, Kündigung, Mandantentrennung' , async ( ) = > {
resetMock ( ) ;
const admin = await staff ( 'adm@shop.test' , 'admin' ) ; const sa = await staff ( 'sa@shop.test' , 'superadmin' ) ; const sup = await staff ( 'sup@shop.test' , 'support' ) ;
const A = await customer ( admin , 'business' , 'Firma A' , 'a@shop.test' ) ; const B = await customer ( admin , 'business' , 'Firma B' , 'b@shop.test' ) ; const P = await customer ( admin , 'private' , 'Erika Muster' , 'p@shop.test' ) ;
const conn = ( await call ( app , sa , 'POST' , '/admin/connectors' , { connector : 'mock' , name : 'Mock-Shop' , values : { instance : 'shop' } } ) ) . json ( ) ;
await drain ( ) ;
const tax = ( await call ( app , admin , 'GET' , '/admin/tax-rates' ) ) . json ( ) ; const t19 = tax . find ( ( t : any ) = > t . rateBp === 1900 ) . id ;
expect ( tax . map ( ( t : any ) = > t . rateBp ) . sort ( ( x : number , y : number ) = > x - y ) ) . toEqual ( [ 0 , 700 , 1900 ] ) ;
// Rechte + Regeln beim Anlegen
const mkBody = ( v : object , o : object = { } ) = > ( { sku : 'LIC-PRO' , category : 'license' , connectorInstanceId : conn.id , orderableByCustomer : true , requiresApproval : true , customerActions : [ 'suspend' ] , status : 'active' , version : { . . . version ( { taxRateId : t19 } ) , . . . v } , . . . o } ) ;
expect ( ( await call ( app , sup , 'POST' , '/admin/products' , mkBody ( { } ) ) ) . statusCode ) . toBe ( 403 ) ;
expect ( ( await call ( app , admin , 'POST' , '/admin/products' , mkBody ( { billingInterval : 'once' , recurringCents : 100 , termMonths : 0 , renewal : 'none' } ) ) ) . json ( ) . error . code ) . toBe ( 'BAD_TERMS' ) ;
expect ( ( await call ( app , admin , 'POST' , '/admin/products' , mkBody ( { termMonths : 5 } ) ) ) . json ( ) . error . code ) . toBe ( 'BAD_TERMS' ) ; // 5 kein Vielfaches von 12
const prod = ( await call ( app , admin , 'POST' , '/admin/products' , mkBody ( { } ) ) ) . json ( ) ;
expect ( ( await call ( app , admin , 'POST' , '/admin/products' , mkBody ( { } ) ) ) . json ( ) . error . code ) . toBe ( 'SKU_EXISTS' ) ;
// Kundenkatalog: Preise serverseitig (Netto/Brutto), nur für eigene Organisation
const cat = ( await call ( app , A . client , 'GET' , ` /catalog?org= ${ A . org } ` ) ) . json ( ) ;
expect ( cat ) . toHaveLength ( 1 ) ; expect ( cat [ 0 ] . price . recurring ) . toEqual ( { net : 4999 , tax : 950 , gross : 5949 } ) ;
expect ( ( await call ( app , A . client , 'GET' , ` /catalog?org= ${ B . org } ` ) ) . statusCode ) . toBe ( 404 ) ;
// Bestellen: Rabatt verboten, ohne Freigabe nicht ausführbar, fremde Org verboten
const order = ( over : object = { } , c = A ) = > call ( app , c . client , 'POST' , '/orders' , { orgId : c.org , items : [ { productId : prod.id } ] , . . . over } ) ;
expect ( ( await order ( { items : [ { productId : prod.id , discountBp : 1000 } ] } ) ) . statusCode ) . toBe ( 403 ) ;
expect ( ( await call ( app , B . client , 'POST' , '/orders' , { orgId : A.org , items : [ { productId : prod.id } ] } ) ) . statusCode ) . toBe ( 404 ) ;
const o1 = ( await order ( ) ) . json ( ) ; expect ( o1 . status ) . toBe ( 'pending_approval' ) ; expect ( o1 . number ) . toMatch ( /^B-/ ) ;
expect ( ( await call ( app , A . client , 'POST' , ` /admin/orders/ ${ o1 . id } /approve ` ) ) . statusCode ) . toBe ( 403 ) ;
expect ( ( await call ( app , sup , 'POST' , ` /admin/orders/ ${ o1 . id } /approve ` ) ) . statusCode ) . toBe ( 403 ) ;
expect ( ( await one ( "SELECT COUNT(*) n FROM resources WHERE org_id = ?" , [ A . org ] ) ) ! . n ) . toBe ( 0 ) ;
expect ( ( await call ( app , B . client , 'GET' , ` /orders/ ${ o1 . id } ` ) ) . statusCode ) . toBe ( 404 ) ;
expect ( ( await call ( app , B . client , 'GET' , '/orders' ) ) . json ( ) ) . toHaveLength ( 0 ) ;
// Freigabe -> Bereitstellung über Worker -> Vertrag aktiv, Ressource beim Kunden
expect ( ( await call ( app , admin , 'POST' , ` /admin/orders/ ${ o1 . id } /approve ` ) ) . statusCode ) . toBe ( 200 ) ;
expect ( ( await call ( app , admin , 'POST' , ` /admin/orders/ ${ o1 . id } /approve ` ) ) . statusCode ) . toBe ( 409 ) ; // nicht doppelt freigeben
await drain ( ) ;
const done = ( await call ( app , A . client , 'GET' , ` /orders/ ${ o1 . id } ` ) ) . json ( ) ;
expect ( done . status ) . toBe ( 'completed' ) ;
const ct = ( await call ( app , A . client , 'GET' , '/contracts' ) ) . json ( ) [ 0 ] ;
expect ( ct . status ) . toBe ( 'active' ) ; expect ( ct . number ) . toMatch ( /^V-/ ) ; expect ( ct . resourceId ) . toBeTruthy ( ) ;
const months = ( new Date ( ct . termEnd ) . getTime ( ) - new Date ( ct . startedAt ) . getTime ( ) ) / 86400000 ; expect ( months ) . toBeGreaterThan ( 360 ) ; expect ( months ) . toBeLessThan ( 370 ) ;
const res = ( await call ( app , A . client , 'GET' , ` /resources/ ${ ct . resourceId } ` ) ) . json ( ) ;
expect ( res . state ) . toBe ( 'active' ) ; expect ( res . allowedActions ) . toEqual ( [ 'suspend' ] ) ; // Produktregel begrenzt Kundenaktionen
expect ( ( await call ( app , B . client , 'GET' , ` /resources/ ${ ct . resourceId } ` ) ) . statusCode ) . toBe ( 404 ) ;
expect ( ( await call ( app , B . client , 'GET' , ` /contracts/ ${ ct . id } ` ) ) . statusCode ) . toBe ( 404 ) ;
// Bereitstellung ist idempotent: erneuter Lauf erzeugt nichts Neues
const before = ( await one ( 'SELECT COUNT(*) n FROM resources' ) ) ! . n ;
await run ( "UPDATE orders SET status='provisioning' WHERE id = ?" , [ o1 . id ] ) ;
await provisionOrder ( o1 . id , { id : 'x' , correlationId : 'c' , attempt : 1 , maxAttempts : 5 } ) ;
expect ( ( await one ( 'SELECT COUNT(*) n FROM resources' ) ) ! . n ) . toBe ( before ) ;
expect ( ( await one ( 'SELECT status FROM orders WHERE id = ?' , [ o1 . id ] ) ) ! . status ) . toBe ( 'completed' ) ;
// Preis-Snapshot bleibt bei Preisänderung unverändert; neue Bestellung nutzt neue Version
expect ( ( await call ( app , admin , 'POST' , ` /admin/products/ ${ prod . id } /versions ` , version ( { taxRateId : t19 , recurringCents : 9999 } ) ) ) . statusCode ) . toBe ( 200 ) ;
expect ( ( await call ( app , A . client , 'GET' , ` /contracts/ ${ ct . id } ` ) ) . json ( ) . price . recurring . net ) . toBe ( 4999 ) ;
const cat2 = ( await call ( app , A . client , 'GET' , ` /catalog?org= ${ A . org } ` ) ) . json ( ) ; expect ( cat2 [ 0 ] . price . recurring . net ) . toBe ( 9999 ) ;
// Personal bestellt mit Rabatt: sofort freigegeben, serverseitig berechnet
const o2 = ( await call ( app , admin , 'POST' , '/orders' , { orgId : B.org , items : [ { productId : prod.id , discountBp : 1000 } ] } ) ) . json ( ) ; expect ( o2 . status ) . toBe ( 'provisioning' ) ;
await drain ( ) ;
const o2d = ( await call ( app , admin , 'GET' , ` /orders/ ${ o2 . id } ` ) ) . json ( ) ;
expect ( o2d . status ) . toBe ( 'completed' ) ; expect ( o2d . items [ 0 ] . snapshot . recurring ) . toEqual ( { net : 8999 , tax : 1710 , gross : 10709 } ) ; // 99,99 * 0,9 = 89,991
expect ( o2d . items [ 0 ] . snapshot . setup . net ) . toBe ( 900 ) ;
// Privatkunde: Verbraucherregel (Verlängerung 1 Monat, Frist max. 30 Tage)
const o3 = ( await order ( { } , P ) ) . json ( ) ; await call ( app , admin , 'POST' , ` /admin/orders/ ${ o3 . id } /approve ` ) ; await drain ( ) ;
const pc = ( await call ( app , P . client , 'GET' , '/contracts' ) ) . json ( ) [ 0 ] ;
expect ( pc . renewalTermMonths ) . toBe ( 1 ) ; expect ( pc . noticeDays ) . toBe ( 30 ) ;
// Kündigung: zum Laufzeitende, widerrufbar, Kunde darf nicht sofort beenden
expect ( ( await call ( app , A . client , 'POST' , ` /contracts/ ${ ct . id } /cancel ` , { immediate : true } ) ) . json ( ) . error . code ) . toBe ( 'STAFF_ONLY' ) ;
expect ( ( await call ( app , B . client , 'POST' , ` /contracts/ ${ ct . id } /cancel ` ) ) . statusCode ) . toBe ( 404 ) ;
const cn = ( await call ( app , A . client , 'POST' , ` /contracts/ ${ ct . id } /cancel ` ) ) . json ( ) ;
expect ( new Date ( cn . cancelEffectiveAt ) . getTime ( ) ) . toBe ( new Date ( ct . termEnd ) . getTime ( ) ) ;
expect ( ( await call ( app , A . client , 'POST' , ` /contracts/ ${ ct . id } /cancel ` ) ) . statusCode ) . toBe ( 409 ) ;
expect ( ( await call ( app , A . client , 'POST' , ` /contracts/ ${ ct . id } /cancel/revoke ` ) ) . statusCode ) . toBe ( 200 ) ;
await call ( app , A . client , 'POST' , ` /contracts/ ${ ct . id } /cancel ` ) ;
// Zeitsprung: Kündigung wird wirksam -> Vertrag beendet, Ressource gesperrt (nicht gelöscht)
await run ( 'UPDATE contracts SET cancel_effective_at = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 MINUTE) WHERE id = ?' , [ ct . id ] ) ;
const life = await processContractLifecycle ( new Date ( ) , ( t , p , o ) = > enqueue ( t , p , o ) ) ; expect ( life . ended ) . toBe ( 1 ) ;
await processContractLifecycle ( new Date ( ) , ( t , p , o ) = > enqueue ( t , p , o ) ) ; // idempotent
expect ( ( await query ( "SELECT id FROM jobs WHERE idempotency_key = ?" , [ ` contract-end: ${ ct . id } ` ] ) ) . length ) . toBe ( 1 ) ;
await drain ( ) ;
expect ( ( await call ( app , A . client , 'GET' , ` /contracts/ ${ ct . id } ` ) ) . json ( ) . status ) . toBe ( 'cancelled' ) ;
expect ( ( await call ( app , admin , 'GET' , ` /resources/ ${ ct . resourceId } ` ) ) . json ( ) . state ) . toBe ( 'suspended' ) ;
expect ( ( await one ( "SELECT COUNT(*) n FROM audit_events WHERE action IN ('order.create','order.approve','contract.activate','contract.cancel.request','contract.cancel.effective')" ) ) ! . n ) . toBeGreaterThanOrEqual ( 8 ) ;
} ) ;
it ( 'automatische Verlängerung ohne Kündigung' , async ( ) = > {
const c = await one ( "SELECT id FROM contracts WHERE status = 'active' LIMIT 1" ) ;
await run ( 'UPDATE contracts SET term_end = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 DAY) WHERE id = ?' , [ c ! . id ] ) ;
const r = await processContractLifecycle ( new Date ( ) , ( t , p , o ) = > enqueue ( t , p , o ) ) ;
expect ( r . renewed ) . toBeGreaterThanOrEqual ( 1 ) ;
const after = await one ( 'SELECT term_end, status FROM contracts WHERE id = ?' , [ c ! . id ] ) ;
expect ( new Date ( after ! . term_end ) . getTime ( ) ) . toBeGreaterThan ( Date . now ( ) ) ; expect ( after ! . status ) . toBe ( 'active' ) ;
} ) ;
it ( 'Fehlerfälle: unklarer Ausgang wird nie automatisch wiederholt; sichere Fehler begrenzt' , async ( ) = > {
const admin = await staff ( 'adm2@shop.test' , 'admin' ) ; const sa = await staff ( 'sa2@shop.test' , 'superadmin' ) ;
const C = await customer ( admin , 'business' , 'Firma C' , 'c@shop.test' ) ;
const inst = await one ( "SELECT id FROM connector_instances LIMIT 1" ) ;
const t19 = ( await call ( app , admin , 'GET' , '/admin/tax-rates' ) ) . json ( ) . find ( ( t : any ) = > t . rateBp === 1900 ) . id ;
const mk = async ( sku : string , failCreate : string ) = > ( await call ( app , admin , 'POST' , '/admin/products' , { sku , category : 'license' , connectorInstanceId : inst ! . id , status : 'active' , version : version ( { taxRateId : t19 , provisioning : { failCreate } } ) } ) ) . json ( ) ;
const resBefore = ( await one ( 'SELECT COUNT(*) n FROM resources' ) ) ! . n ;
// Timeout: unklar, ob angelegt -> sofort failed, ambiguous, kein Retry
const pT = await mk ( 'FAIL-TIMEOUT' , 'timeout' ) ;
const oT = ( await call ( app , admin , 'POST' , '/orders' , { orgId : C.org , items : [ { productId : pT.id } ] } ) ) . json ( ) ; await drain ( ) ;
const dT = ( await call ( app , admin , 'GET' , ` /orders/ ${ oT . id } ` ) ) . json ( ) ;
expect ( dT . status ) . toBe ( 'failed' ) ; expect ( dT . failureAmbiguous ) . toBe ( true ) ; expect ( dT . failureNote ) . toMatch ( /unklar/ ) ;
expect ( ( await one ( "SELECT attempts, status FROM jobs WHERE type='order.provision' AND JSON_VALUE(payload,'$.orderId') = ?" , [ oT . id ] ) ) ) . toMatchObject ( { attempts : 1 , status : 'needs_review' } ) ;
expect ( ( await call ( app , admin , 'POST' , ` /admin/orders/ ${ oT . id } /retry ` , { } ) ) . json ( ) . error . code ) . toBe ( 'CONFIRM_REQUIRED' ) ;
expect ( ( await call ( app , admin , 'POST' , ` /admin/orders/ ${ oT . id } /retry ` , { confirmChecked : true } ) ) . statusCode ) . toBe ( 200 ) ;
// Verbindung verweigert: sicher nicht gesendet -> begrenzt wiederholt, dann failed (nicht ambiguous)
const pU = await mk ( 'FAIL-UNREACH' , 'unreachable' ) ;
const oU = ( await call ( app , admin , 'POST' , '/orders' , { orgId : C.org , items : [ { productId : pU.id } ] } ) ) . json ( ) ; await drain ( ) ;
const dU = ( await call ( app , admin , 'GET' , ` /orders/ ${ oU . id } ` ) ) . json ( ) ;
expect ( dU . status ) . toBe ( 'failed' ) ; expect ( dU . failureAmbiguous ) . toBe ( false ) ;
expect ( ( await one ( "SELECT attempts FROM jobs WHERE type='order.provision' AND JSON_VALUE(payload,'$.orderId') = ?" , [ oU . id ] ) ) ! . attempts ) . toBe ( 5 ) ;
expect ( ( await one ( 'SELECT COUNT(*) n FROM resources' ) ) ! . n ) . toBe ( resBefore ) ;
// Verträge der fehlgeschlagenen Bestellung bleiben ausstehend (nicht aktiv)
expect ( ( await call ( app , admin , 'GET' , ` /contracts?org= ${ C . org } ` ) ) . json ( ) . every ( ( c : any ) = > c . status === 'pending' ) ) . toBe ( true ) ;
// Zurückziehen einer gescheiterten Bestellung beendet die Verträge
expect ( ( await call ( app , admin , 'POST' , ` /orders/ ${ oU . id } /cancel ` ) ) . statusCode ) . toBe ( 200 ) ;
} ) ;
} ) ;
describe ( 'Produktübernahme aus Verbindungen' , ( ) = > {
it ( 'liest Vorlagen des Anbieters, übernimmt sie mit Details und zählt Übernahmen' , async ( ) = > {
resetMock ( ) ;
const admin = await staff ( 'imp-adm@shop.test' , 'admin' ) ; const sa = await staff ( 'imp-sa@shop.test' , 'superadmin' ) ; const sup = await staff ( 'imp-sup@shop.test' , 'support' ) ;
const conn = ( await call ( app , sa , 'POST' , '/admin/connectors' , { connector : 'mock' , name : 'Mock-Import' , values : { instance : 'imp' } } ) ) . json ( ) ;
await drain ( ) ;
const t19 = ( await call ( app , admin , 'GET' , '/admin/tax-rates' ) ) . json ( ) . find ( ( t : any ) = > t . rateBp === 1900 ) . id ;
expect ( ( await call ( app , sup , 'GET' , ` /admin/connectors/ ${ conn . id } /catalog ` ) ) . statusCode ) . toBe ( 403 ) ;
const cat = ( await call ( app , admin , 'GET' , ` /admin/connectors/ ${ conn . id } /catalog ` ) ) . json ( ) ;
expect ( cat . map ( ( c : any ) = > c . externalRef ) ) . toEqual ( [ 'mock-prog-1' , 'mock-prog-2' ] ) ;
expect ( cat . every ( ( c : any ) = > c . importedProducts === 0 ) ) . toBe ( true ) ;
expect ( cat [ 0 ] . hints [ 0 ] . label ) . toBe ( '5 Benutzer' ) ;
// Übernahme mit selbst definierten Details (zwei Varianten desselben Angebots)
const imp = ( sku : string , over : object = { } ) = > call ( app , admin , 'POST' , '/admin/products' , { sku , category : 'license' , connectorInstanceId : conn.id , externalRef : 'mock-prog-1' , orderableByCustomer : true , requiresApproval : false , customerActions : [ 'suspend' ] ,
version : { name : 'Alpha Jahreslizenz' , taxRateId : t19 , setupCents : 0 , recurringCents : 12000 , billingInterval : 'yearly' , termMonths : 12 , renewal : 'auto' , renewalTermMonths : 12 , noticeDays : 30 , provisioning : { userLimit : 5 } , . . . over } } ) ;
const a1 = await imp ( 'ALPHA-Y' ) ; expect ( a1 . statusCode ) . toBe ( 200 ) ;
expect ( ( await imp ( 'ALPHA-M' , { name : 'Alpha Monatslizenz' , recurringCents : 1200 , billingInterval : 'monthly' , termMonths : 0 , renewalTermMonths : 1 } ) ) . statusCode ) . toBe ( 200 ) ;
const cat2 = ( await call ( app , admin , 'GET' , ` /admin/connectors/ ${ conn . id } /catalog ` ) ) . json ( ) ;
expect ( cat2 . find ( ( c : any ) = > c . externalRef === 'mock-prog-1' ) . importedProducts ) . toBe ( 2 ) ;
expect ( cat2 . find ( ( c : any ) = > c . externalRef === 'mock-prog-2' ) . importedProducts ) . toBe ( 0 ) ;
const p = ( await call ( app , admin , 'GET' , ` /admin/products/ ${ a1 . json ( ) . id } ` ) ) . json ( ) ; expect ( p . externalRef ) . toBe ( 'mock-prog-1' ) ;
// Herkunft nur mit Verbindung
expect ( ( await call ( app , admin , 'POST' , '/admin/products' , { sku : 'X-1' , category : 'license' , externalRef : 'x' , version : { name : 'X' , taxRateId : t19 , billingInterval : 'once' , provisioning : { } } } ) ) . json ( ) . error . code ) . toBe ( 'BAD_CONNECTOR' ) ;
// Ausfall des Anbieters: verständlicher Fehler statt Absturz
await call ( app , sa , 'PATCH' , ` /admin/connectors/ ${ conn . id } ` , { values : { simulateOutage : 'true' } } ) ;
const down = await call ( app , admin , 'GET' , ` /admin/connectors/ ${ conn . id } /catalog ` ) ;
expect ( down . statusCode ) . toBe ( 502 ) ; expect ( down . json ( ) . error . message ) . toMatch ( /nicht erreichbar/ ) ;
} ) ;
} ) ;
describe ( 'Bruttopreise' , ( ) = > {
it ( 'bestellt zu exakten Endkundenpreisen (Privatkunde 9,99 € brutto) und speichert die Basis im Snapshot' , async ( ) = > {
const admin = await staff ( 'gross-adm@shop.test' , 'admin' ) ;
const P = await customer ( admin , 'private' , 'Erika Muster' , 'gross-p@shop.test' ) ;
const t19 = ( await call ( app , admin , 'GET' , '/admin/tax-rates' ) ) . json ( ) . find ( ( t : any ) = > t . rateBp === 1900 ) . id ;
const prod = ( await call ( app , admin , 'POST' , '/admin/products' , { sku : 'GROSS-1' , category : 'service' , status : 'active' , orderableByCustomer : true , requiresApproval : false ,
version : { name : 'Beispiel brutto' , taxRateId : t19 , priceBasis : 'gross' , setupCents : 0 , recurringCents : 999 , billingInterval : 'monthly' , termMonths : 0 , renewal : 'auto' , renewalTermMonths : 1 , noticeDays : 30 , provisioning : { } } } ) ) . json ( ) ;
const cat = ( await call ( app , P . client , 'GET' , ` /catalog?org= ${ P . org } ` ) ) . json ( ) ;
expect ( cat [ 0 ] . price . recurring ) . toEqual ( { net : 839 , tax : 160 , gross : 999 } ) ; expect ( cat [ 0 ] . price . basis ) . toBe ( 'gross' ) ;
const o = ( await call ( app , P . client , 'POST' , '/orders' , { orgId : P.org , items : [ { productId : prod.id } ] } ) ) . json ( ) ;
await drain ( ) ;
const d = ( await call ( app , P . client , 'GET' , ` /orders/ ${ o . id } ` ) ) . json ( ) ;
expect ( d . status ) . toBe ( 'completed' ) ; expect ( d . items [ 0 ] . snapshot . recurring . gross ) . toBe ( 999 ) ; expect ( d . items [ 0 ] . snapshot . basis ) . toBe ( 'gross' ) ;
// Preisänderung gilt nur für neue Bestellungen; alter Vertrag bleibt 9,99 €
await call ( app , admin , 'POST' , ` /admin/products/ ${ prod . id } /versions ` , { name : 'Beispiel brutto' , taxRateId : t19 , priceBasis : 'gross' , recurringCents : 1299 , billingInterval : 'monthly' , termMonths : 0 , renewal : 'auto' , renewalTermMonths : 1 , noticeDays : 30 , provisioning : { } } ) ;
expect ( ( await call ( app , P . client , 'GET' , '/contracts' ) ) . json ( ) [ 0 ] . price . recurring . gross ) . toBe ( 999 ) ;
} ) ;
} ) ;
describe ( 'Familytool-Editionen (Paket), Edition und Laufzeitpflege' , ( ) = > {
it ( 'importiert das Paket als Entwürfe, sperrt Aktivierung ohne Anbieter-Erweiterung, liefert Editionen und verlängert die Lizenz mit dem Vertrag' , async ( ) = > {
resetMock ( ) ;
const admin = await staff ( 'ft-adm@shop.test' , 'admin' ) ; const sa = await staff ( 'ft-sa@shop.test' , 'superadmin' ) ; const sup = await staff ( 'ft-sup@shop.test' , 'support' ) ;
const P = await customer ( admin , 'private' , 'Erika Muster' , 'ft-p@shop.test' ) ;
const conn = ( await call ( app , sa , 'POST' , '/admin/connectors' , { connector : 'mock' , name : 'Mock-FT' , values : { instance : 'ft' } } ) ) . json ( ) ; await drain ( ) ;
expect ( ( await call ( app , sup , 'GET' , '/admin/product-bundles' ) ) . statusCode ) . toBe ( 403 ) ;
const bundles = ( await call ( app , admin , 'GET' , '/admin/product-bundles' ) ) . json ( ) ;
const ft = bundles . find ( ( b : any ) = > b . key === 'familytool-vorlage' ) ; expect ( ft . products . map ( ( p : any ) = > p . sku ) ) . toEqual ( [ 'FT-PREMIUM-M' , 'FT-PREMIUM-Y' , 'FT-UNLIMITED-M' , 'FT-UNLIMITED-Y' , 'FT-LIFETIME' , 'FT-TRIAL' ] ) ;
expect ( ft . products . find ( ( p : any ) = > p . sku === 'FT-UNLIMITED-M' ) . recurringCents ) . toBe ( 999 ) ;
const skus = ft . products . map ( ( p : any ) = > p . sku ) ;
const imp = ( await call ( app , admin , 'POST' , '/admin/product-bundles/familytool-vorlage/import' , { connectorInstanceId : conn.id , programRef : 'mock-prog-1' , skus } ) ) . json ( ) ;
expect ( imp . created ) . toHaveLength ( 6 ) ; expect ( imp . skipped ) . toHaveLength ( 0 ) ;
// nochmals importieren: alles übersprungen (Artikelnummern existieren), nichts doppelt
const again = ( await call ( app , admin , 'POST' , '/admin/product-bundles/familytool-vorlage/import' , { connectorInstanceId : conn.id , programRef : 'mock-prog-1' , skus } ) ) . json ( ) ;
expect ( again . created ) . toHaveLength ( 0 ) ; expect ( again . skipped ) . toHaveLength ( 6 ) ;
const list = ( await call ( app , admin , 'GET' , '/admin/products' ) ) . json ( ) ; expect ( list . filter ( ( p : any ) = > p . sku . startsWith ( 'FT-' ) ) . every ( ( p : any ) = > p . status === 'draft' ) ) . toBe ( true ) ;
const byS = ( s : string ) = > list . find ( ( p : any ) = > p . sku === s ) ;
expect ( byS ( 'FT-PREMIUM-Y' ) . provisioning ) . toMatchObject ( { keyPrefix : 'PREMIUM' , durationType : 'YEAR' } ) ;
// Aktivierungssperre: Anbieter meldet Präfix-Unterstützung nicht -> 400, Entwurf bleibt
await run ( "UPDATE connector_instances SET capabilities_json = JSON_REMOVE(capabilities_json, '$[11]', '$[10]') WHERE id = ?" , [ conn . id ] ) ;
const caps = ( await one ( 'SELECT capabilities_json c FROM connector_instances WHERE id = ?' , [ conn . id ] ) ) ! . c ; expect ( JSON . stringify ( caps ) ) . not . toContain ( 'license.key_prefix' ) ;
const blocked = await call ( app , admin , 'PATCH' , ` /admin/products/ ${ byS ( 'FT-PREMIUM-M' ) . id } ` , { status : 'active' } ) ;
expect ( blocked . statusCode ) . toBe ( 400 ) ; expect ( blocked . json ( ) . error . code ) . toBe ( 'NEEDS_LICENSE_EXTENSION' ) ;
// Unlimited ohne Präfix ist trotzdem aktivierbar
expect ( ( await call ( app , admin , 'PATCH' , ` /admin/products/ ${ byS ( 'FT-UNLIMITED-M' ) . id } ` , { status : 'active' } ) ) . statusCode ) . toBe ( 200 ) ;
// Nach Erweiterung (Abgleich meldet Fähigkeit wieder) sind alle aktivierbar
await call ( app , sa , 'POST' , ` /admin/connectors/ ${ conn . id } /sync ` ) ; await drain ( ) ;
for ( const s of [ 'FT-PREMIUM-M' , 'FT-PREMIUM-Y' ] ) expect ( ( await call ( app , admin , 'PATCH' , ` /admin/products/ ${ byS ( s ) . id } ` , { status : 'active' } ) ) . statusCode ) . toBe ( 200 ) ;
// Privatkunde bestellt Premium monatlich: exakt 2,99 € brutto, Edition PREMIUM, rollierender Monatsvertrag
const cat = ( await call ( app , P . client , 'GET' , ` /catalog?org= ${ P . org } ` ) ) . json ( ) ;
expect ( cat . find ( ( c : any ) = > c . sku === 'FT-PREMIUM-M' ) . price . recurring ) . toEqual ( { net : 251 , tax : 48 , gross : 299 } ) ;
const oM = ( await call ( app , P . client , 'POST' , '/orders' , { orgId : P.org , items : [ { productId : byS ( 'FT-PREMIUM-M' ) . id } ] } ) ) . json ( ) ; expect ( oM . status ) . toBe ( 'provisioning' ) ; await drain ( ) ;
const cM = ( await call ( app , P . client , 'GET' , '/contracts' ) ) . json ( ) [ 0 ] ;
expect ( cM . status ) . toBe ( 'active' ) ; expect ( cM . termEnd ) . toBeTruthy ( ) ; // Verlängerungstakt trotz Mindestlaufzeit 0
const rM = ( await call ( app , P . client , 'GET' , ` /resources/ ${ cM . resourceId } ` ) ) . json ( ) ; expect ( rM . details . edition ) . toBe ( 'PREMIUM' ) ;
// Kunde und Herkunft wurden an den Anbieter gemeldet
expect ( rM . details . context ) . toMatchObject ( { source : 'kundencenter' , customerName : 'Erika Muster' , customerEmail : 'ft-p@shop.test' , contractNumber : cM.number } ) ;
expect ( rM . details . context . customerNumber ) . toMatch ( /^K-\d+$/ ) ; expect ( rM . details . context . orderNumber ) . toMatch ( /^B-\d+$/ ) ;
// Vertragsverlängerung zieht die Lizenz mit
const until0 = new Date ( rM . validUntil ) . getTime ( ) ;
await run ( 'UPDATE contracts SET term_end = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 DAY) WHERE id = ?' , [ cM . id ] ) ;
expect ( ( await processContractLifecycle ( new Date ( ) , ( t , p , o ) = > enqueue ( t , p , o ) ) ) . renewed ) . toBe ( 1 ) ; await drain ( ) ;
const after = ( await call ( app , admin , 'GET' , ` /resources/ ${ cM . resourceId } ` ) ) . json ( ) ;
const cAfter = ( await call ( app , admin , 'GET' , ` /contracts/ ${ cM . id } ` ) ) . json ( ) ;
expect ( new Date ( after . validUntil ) . toISOString ( ) ) . toBe ( new Date ( cAfter . termEnd ) . toISOString ( ) ) ; expect ( new Date ( after . validUntil ) . getTime ( ) ) . toBeGreaterThan ( until0 - 86400000 ) ;
// Lifetime: Freigabe nötig, Edition LIFETIME, unbefristet, keine Verlängerung
await call ( app , admin , 'PATCH' , ` /admin/products/ ${ byS ( 'FT-LIFETIME' ) . id } ` , { status : 'active' } ) ;
const oL = ( await call ( app , admin , 'POST' , '/orders' , { orgId : P.org , items : [ { productId : byS ( 'FT-LIFETIME' ) . id } ] } ) ) . json ( ) ; await drain ( ) ;
const cL = ( await call ( app , admin , 'GET' , ` /contracts?org= ${ P . org } ` ) ) . json ( ) . find ( ( c : any ) = > c . id !== cM . id ) ;
expect ( cL . termEnd ) . toBeNull ( ) ; expect ( ( await call ( app , admin , 'GET' , ` /resources/ ${ cL . resourceId } ` ) ) . json ( ) . details . edition ) . toBe ( 'LIFETIME' ) ;
expect ( cL . price . setup . gross ) . toBe ( 15900 ) ;
// Trial: 14 Tage Gültigkeit, Edition TRIAL
await call ( app , admin , 'PATCH' , ` /admin/products/ ${ byS ( 'FT-TRIAL' ) . id } ` , { status : 'active' } ) ;
await call ( app , admin , 'POST' , '/orders' , { orgId : P.org , items : [ { productId : byS ( 'FT-TRIAL' ) . id } ] } ) ; await drain ( ) ;
const trial = ( await call ( app , admin , 'GET' , ` /contracts?org= ${ P . org } ` ) ) . json ( ) . find ( ( c : any ) = > c . price . sku === 'FT-TRIAL' ) ;
const tr = ( await call ( app , admin , 'GET' , ` /resources/ ${ trial . resourceId } ` ) ) . json ( ) ; expect ( tr . details . edition ) . toBe ( 'TRIAL' ) ;
const days = ( new Date ( tr . validUntil ) . getTime ( ) - Date . now ( ) ) / 86400000 ; expect ( days ) . toBeGreaterThan ( 13 ) ; expect ( days ) . toBeLessThan ( 14.1 ) ;
} ) ;
} ) ;
2026-09-27 18:35:34 +02:00
describe ( 'Staffelpreise nach Laufzeit' , ( ) = > {
it ( 'Produkt mit mehreren Laufzeiten: Bestellung wählt eine Staffel, Preis/Laufzeit werden serverseitig aus der Staffel übernommen' , async ( ) = > {
const admin = await staff ( 'term-admin@example.com' , 'admin' ) ;
const A = await customer ( admin , 'business' , 'Firma Term' , 'term-a@example.com' ) ;
const t19 = ( await call ( app , admin , 'GET' , '/admin/tax-rates' ) ) . json ( ) . find ( ( t : any ) = > t . rateBp === 1900 ) . id ;
const prod = await call ( app , admin , 'POST' , '/admin/products' , {
sku : 'HOST-TERM' , category : 'hosting' , orderableByCustomer : true , requiresApproval : false , customerActions : [ ] , status : 'active' ,
version : { name : 'Hosting Staffel' , taxRateId : t19 , setupCents : 500 , recurringCents : 999 , billingInterval : 'monthly' , termMonths : 1 , renewal : 'auto' , renewalTermMonths : 1 , noticeDays : 30 , provisioning : { } ,
termPrices : [ { termMonths : 1 , recurringCents : 999 } , { termMonths : 3 , recurringCents : 2699 } , { termMonths : 12 , recurringCents : 9588 } , { termMonths : 24 , recurringCents : 17988 } ] } ,
} ) ;
expect ( prod . statusCode ) . toBe ( 200 ) ;
// doppelte Laufzeit wird abgelehnt
expect ( ( await call ( app , admin , 'POST' , '/admin/products' , {
sku : 'HOST-TERM-BAD' , category : 'hosting' , orderableByCustomer : true , requiresApproval : false , customerActions : [ ] , status : 'draft' ,
version : { name : 'x' , taxRateId : t19 , setupCents : 0 , recurringCents : 0 , billingInterval : 'monthly' , termMonths : 1 , renewal : 'none' , noticeDays : 30 , provisioning : { } , termPrices : [ { termMonths : 12 , recurringCents : 100 } , { termMonths : 12 , recurringCents : 200 } ] } ,
} ) ) . statusCode ) . toBe ( 400 ) ;
const cat = ( await call ( app , A . client , 'GET' , ` /catalog?org= ${ A . org } ` ) ) . json ( ) ;
const item = cat . find ( ( c : any ) = > c . sku === 'HOST-TERM' ) ;
expect ( item . termPrices ) . toEqual ( [
{ termMonths : 1 , price : { net : 999 , tax : 190 , gross : 1189 } } , { termMonths : 3 , price : { net : 2699 , tax : 513 , gross : 3212 } } ,
{ termMonths : 12 , price : { net : 9588 , tax : 1822 , gross : 11410 } } , { termMonths : 24 , price : { net : 17988 , tax : 3418 , gross : 21406 } } ,
] ) ;
// nicht angebotene Laufzeit wird abgelehnt
expect ( ( await call ( app , A . client , 'POST' , '/orders' , { orgId : A.org , items : [ { productId : prod.json ( ) . id , termMonths : 6 } ] } ) ) . statusCode ) . toBe ( 400 ) ;
const order = ( await call ( app , A . client , 'POST' , '/orders' , { orgId : A.org , items : [ { productId : prod.json ( ) . id , termMonths : 12 } ] } ) ) . json ( ) ;
const items = ( await call ( app , admin , 'GET' , ` /orders/ ${ order . id } ` ) ) . json ( ) . items ;
expect ( items [ 0 ] . snapshot . recurring ) . toEqual ( { net : 9588 , tax : 1822 , gross : 11410 } ) ;
expect ( items [ 0 ] . snapshot . terms ) . toEqual ( { termMonths : 12 , renewal : 'auto' , renewalTermMonths : 12 , noticeDays : 30 } ) ;
} ) ;
} ) ;