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); }); }); 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 }); }); });