kundencenter/apps/api/test/orders.test.ts

329 lines
30 KiB
TypeScript
Raw Normal View History

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('Vertrag nachträglich anpassen (Superadmin)', () => {
it('berechnet den Preis serverseitig neu, ändert Konditionen und protokolliert; nur Superadmin', async () => {
const admin = await staff('adj-adm@shop.test', 'admin'); const sa = await staff('adj-sa@shop.test', 'superadmin');
const P = await customer(admin, 'private', 'Anna Anpass', 'adj-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: 'ADJ-1', category: 'service', status: 'active', orderableByCustomer: true, requiresApproval: false,
version: { name: 'Altvertrag', taxRateId: t19, setupCents: 0, recurringCents: 0, billingInterval: 'yearly', termMonths: 12, renewal: 'auto', renewalTermMonths: 12, noticeDays: 30, provisioning: {} } })).json();
await call(app, P.client, 'POST', '/orders', { orgId: P.org, items: [{ productId: prod.id }] });
await drain();
const k = (await call(app, P.client, 'GET', '/contracts')).json()[0];
const body = { recurringCents: 2499, basis: 'gross', reason: 'Unkostenpreis vereinbart', termEnd: '2027-04-24', noticeDays: 14 };
expect((await call(app, admin, 'PATCH', `/admin/contracts/${k.id}`, body)).statusCode).toBe(403);
expect((await call(app, P.client, 'PATCH', `/admin/contracts/${k.id}`, body)).statusCode).toBe(403);
expect((await call(app, sa, 'PATCH', `/admin/contracts/${k.id}`, { ...body, reason: '' })).statusCode).toBe(400);
expect((await call(app, sa, 'PATCH', `/admin/contracts/${k.id}`, { renewal: 'auto', renewalTermMonths: 0, reason: 'ungültig' })).statusCode).toBe(400);
expect((await call(app, admin, 'GET', `/contracts/${k.id}`)).json().canEdit).toBe(false);
expect((await call(app, sa, 'GET', `/contracts/${k.id}`)).json().canEdit).toBe(true);
expect((await call(app, sa, 'PATCH', `/admin/contracts/${k.id}`, body)).statusCode).toBe(200);
const d = (await call(app, P.client, 'GET', `/contracts/${k.id}`)).json();
expect(d.price.recurring).toEqual({ net: 2100, tax: 399, gross: 2499 }); expect(d.price.basis).toBe('gross'); expect(d.price.name).toBe('Altvertrag');
expect(d.noticeDays).toBe(14); expect(new Date(d.termEnd).getFullYear()).toBe(2027); expect(d.canEdit).toBe(false);
const log = await one("SELECT after_json FROM audit_events WHERE action = 'contract.adjust' AND resource_id = ?", [k.id]);
expect(JSON.stringify(log?.after_json)).toContain('Unkostenpreis vereinbart');
});
});
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 });
});
});