Stand vor Einführung des Nacht-Agenten
This commit is contained in:
commit
4763548bfb
168 changed files with 12726 additions and 0 deletions
93
packages/platform/test/logic.test.ts
Normal file
93
packages/platform/test/logic.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue