import { describe, expect, it } from 'vitest'; import { calculatePrice } from '../src/pricing.js'; import { CONTRACT_MACHINE, ORDER_MACHINE, InvalidTransition, transition, type ContractEvent, type ContractStatus, type OrderEvent, type OrderStatus } from '../src/statemachine.js'; import { addMonths, consumerTerms, effectiveCancelDate, renewedTermEnd } from '../src/contractterms.js'; const P = { setupCents: 0, recurringCents: 0, taxBp: 1900, interval: 'yearly' as const, quantity: 1, discountBp: 0 }; describe('Preisberechnung', () => { it('rechnet Netto, Steuer und Brutto je Position in Cent', () => { const r = calculatePrice({ ...P, setupCents: 1000, recurringCents: 4999 }); expect(r.setup).toEqual({ net: 1000, tax: 190, gross: 1190 }); expect(r.recurring).toEqual({ net: 4999, tax: 950, gross: 5949 }); // 949,81 -> 950 }); it('wendet Menge und Rabatt vor der Steuer an und rundet kaufmännisch', () => { const r = calculatePrice({ ...P, recurringCents: 999, quantity: 3, discountBp: 1000 }); // 3*9,99*0,9 = 26,973 expect(r.recurring.net).toBe(2697); expect(r.recurring.tax).toBe(512); expect(r.recurring.gross).toBe(3209); expect(calculatePrice({ ...P, recurringCents: 5, discountBp: 5000 }).recurring.net).toBe(3); // 2,5 -> 3 }); it('unterstützt 7 %, 0 % und 100 % Rabatt', () => { expect(calculatePrice({ ...P, taxBp: 700, recurringCents: 10000 }).recurring.tax).toBe(700); expect(calculatePrice({ ...P, taxBp: 0, recurringCents: 10000 }).recurring.gross).toBe(10000); expect(calculatePrice({ ...P, recurringCents: 10000, discountBp: 10000 }).recurring).toEqual({ net: 0, tax: 0, gross: 0 }); }); it('Bruttopreise bleiben exakt; Netto und Steuer werden herausgerechnet', () => { const g = (cents: number, extra: object = {}) => calculatePrice({ ...P, basis: 'gross', recurringCents: cents, ...extra }).recurring; expect(g(299)).toEqual({ net: 251, tax: 48, gross: 299 }); // 2,99 € brutto expect(g(999)).toEqual({ net: 839, tax: 160, gross: 999 }); // 9,99 € bleibt 9,99 € (netto allein nicht darstellbar) expect(g(2990)).toEqual({ net: 2513, tax: 477, gross: 2990 }); expect(g(15900)).toEqual({ net: 13361, tax: 2539, gross: 15900 }); expect(g(999, { taxBp: 0 })).toEqual({ net: 999, tax: 0, gross: 999 }); expect(g(999, { discountBp: 1000 })).toEqual({ net: 755, tax: 144, gross: 899 }); // 9,99 * 0,9 = 8,991 -> 8,99 brutto for (let c = 0; c <= 20000; c += 7) { const r = g(c); expect(r.net + r.tax).toBe(r.gross); expect(r.gross).toBe(c); } // Summe stimmt immer expect(calculatePrice({ ...P, basis: 'gross', recurringCents: 999 }).basis).toBe('gross'); }); it('lehnt ungültige Eingaben ab', () => { for (const bad of [{ quantity: 0 }, { quantity: 1.5 }, { recurringCents: -1 }, { discountBp: 10001 }, { taxBp: NaN }, { interval: 'once' as const, recurringCents: 100 }]) expect(() => calculatePrice({ ...P, ...bad })).toThrow(RangeError); }); }); describe('Statusautomaten', () => { const orderStates: OrderStatus[] = ['pending_approval', 'approved', 'provisioning', 'completed', 'failed', 'rejected', 'cancelled']; const orderEvents: OrderEvent[] = ['approve', 'reject', 'cancel', 'start_provisioning', 'complete', 'fail', 'retry']; const contractStates: ContractStatus[] = ['pending', 'active', 'suspended', 'cancelled', 'expired', 'failed']; const contractEvents: ContractEvent[] = ['activate', 'suspend', 'unsuspend', 'cancel', 'expire', 'fail']; it('Bestellung: erlaubte Übergänge', () => { expect(transition(ORDER_MACHINE, 'pending_approval', 'approve')).toBe('approved'); expect(transition(ORDER_MACHINE, 'approved', 'start_provisioning')).toBe('provisioning'); expect(transition(ORDER_MACHINE, 'provisioning', 'complete')).toBe('completed'); expect(transition(ORDER_MACHINE, 'failed', 'retry')).toBe('provisioning'); }); it('Bestellung: alle anderen Übergänge sind verboten, Endzustände haben keine Ausgänge', () => { const allowed = new Set(['pending_approval:approve', 'pending_approval:reject', 'pending_approval:cancel', 'approved:start_provisioning', 'approved:cancel', 'provisioning:complete', 'provisioning:fail', 'failed:retry', 'failed:cancel']); for (const s of orderStates) for (const e of orderEvents) { if (allowed.has(`${s}:${e}`)) expect(() => transition(ORDER_MACHINE, s, e)).not.toThrow(); else expect(() => transition(ORDER_MACHINE, s, e)).toThrow(InvalidTransition); } }); it('Vertrag: alle Übergänge vollständig geprüft', () => { const allowed = new Set(['pending:activate', 'pending:fail', 'pending:cancel', 'active:suspend', 'active:cancel', 'active:expire', 'suspended:unsuspend', 'suspended:cancel', 'suspended:expire']); for (const s of contractStates) for (const e of contractEvents) { if (allowed.has(`${s}:${e}`)) expect(() => transition(CONTRACT_MACHINE, s, e)).not.toThrow(); else expect(() => transition(CONTRACT_MACHINE, s, e)).toThrow(InvalidTransition); } }); }); const d = (s: string) => new Date(s + 'T00:00:00Z'); describe('Laufzeit und Kündigung', () => { it('addMonths behandelt Monatsenden', () => { expect(addMonths(d('2026-01-31'), 1).toISOString().slice(0, 10)).toBe('2026-02-28'); expect(addMonths(d('2028-01-31'), 1).toISOString().slice(0, 10)).toBe('2028-02-29'); expect(addMonths(d('2026-11-15'), 3).toISOString().slice(0, 10)).toBe('2027-02-15'); }); it('Kündigung zum nächsten Laufzeitende unter Beachtung der Frist', () => { const t = { termEnd: d('2027-01-01'), renewal: 'auto' as const, renewalTermMonths: 12, noticeDays: 30 }; expect(effectiveCancelDate(d('2026-10-01'), t).toISOString().slice(0, 10)).toBe('2027-01-01'); // rechtzeitig expect(effectiveCancelDate(d('2026-12-15'), t).toISOString().slice(0, 10)).toBe('2028-01-01'); // Frist verpasst -> nächste Periode expect(effectiveCancelDate(d('2026-12-02'), t).toISOString().slice(0, 10)).toBe('2027-01-01'); // genau 30 Tage vorher expect(effectiveCancelDate(d('2026-12-03'), t).toISOString().slice(0, 10)).toBe('2028-01-01'); }); it('ohne Verlängerung: zum Laufzeitende; ohne Laufzeit: mit Frist ab jetzt', () => { expect(effectiveCancelDate(d('2026-10-01'), { termEnd: d('2027-01-01'), renewal: 'none', renewalTermMonths: 0, noticeDays: 30 }).toISOString().slice(0, 10)).toBe('2027-01-01'); expect(effectiveCancelDate(d('2026-10-01'), { termEnd: null, renewal: 'auto', renewalTermMonths: 1, noticeDays: 14 }).toISOString().slice(0, 10)).toBe('2026-10-15'); }); it('Verlängerung und Verbraucherregel', () => { expect(renewedTermEnd(d('2027-03-10'), d('2027-01-01'), 12).toISOString().slice(0, 10)).toBe('2028-01-01'); expect(renewedTermEnd(d('2026-10-01'), d('2027-01-01'), 12).toISOString().slice(0, 10)).toBe('2027-01-01'); expect(consumerTerms(12, 90)).toEqual({ renewalTermMonths: 1, noticeDays: 30 }); // Jahresvertrag: nach der Erstlaufzeit monatlich expect(consumerTerms(1, 30)).toEqual({ renewalTermMonths: 1, noticeDays: 30 }); // rollierender Monatsvertrag bleibt monatlich expect(consumerTerms(0, 30)).toEqual({ renewalTermMonths: 0, noticeDays: 30 }); // keine Verlängerung expect(() => effectiveCancelDate(d('2026-12-30'), { termEnd: d('2027-01-01'), renewal: 'auto', renewalTermMonths: 0, noticeDays: 30 })).toThrow(RangeError); }); });