Stand vor Einführung des Nacht-Agenten

This commit is contained in:
Kundencenter 2026-09-27 00:51:32 +02:00
commit 4763548bfb
168 changed files with 12726 additions and 0 deletions

View file

@ -0,0 +1,25 @@
{
"name": "@kc/platform",
"private": true,
"version": "0.1.0",
"type": "module",
"exports": {
"./*": {
"types": "./dist/*.d.ts",
"default": "./dist/*.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
},
"dependencies": {
"mysql2": "^3.24.4"
},
"devDependencies": {
"@types/node": "^26.6.3",
"typescript": "^7.0.2",
"vitest": "^5.0.2"
}
}

View file

@ -0,0 +1,81 @@
import { createHash } from 'node:crypto';
import type { PoolConnection } from 'mysql2/promise';
import { pool, one, query } from './db.js';
const SENSITIVE = /pass|secret|token|hash|code|key|totp/i;
/** Maskiert Geheimnisse rekursiv, bevor Zustände in das Audit-Protokoll gelangen. */
export function mask(v: unknown): unknown {
if (Array.isArray(v)) return v.map(mask);
if (v && typeof v === 'object') {
return Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, val]) => [k, SENSITIVE.test(k) ? '***' : mask(val)]));
}
return v;
}
export interface AuditInput {
actorType: 'user' | 'system' | 'anonymous';
actorId?: string | null;
orgId?: string | null;
action: string;
resourceType?: string;
resourceId?: string;
result?: 'success' | 'denied' | 'failure';
errorClass?: string;
connector?: string;
correlationId?: string;
ip?: string;
before?: unknown;
after?: unknown;
}
const canon = (o: unknown) => JSON.stringify(o);
/** Hängt ein Ereignis an die Hash-Kette an. Serialisiert über Zeilensperre auf dem letzten Eintrag. */
export async function audit(e: AuditInput, conn?: PoolConnection): Promise<void> {
const own = !conn;
const c = conn ?? (await pool.getConnection());
try {
if (own) await c.beginTransaction();
await c.query('SELECT GET_LOCK(?, 10)', ['kc_audit_chain']);
const last = await one<{ hash: string } & import('mysql2').RowDataPacket>('SELECT hash FROM audit_events ORDER BY id DESC LIMIT 1', [], c);
const prev = last?.hash ?? '0'.repeat(64);
const ts = new Date();
const body = {
ts: ts.toISOString(), actor_type: e.actorType, actor_id: e.actorId ?? null, org_id: e.orgId ?? null, action: e.action,
resource_type: e.resourceType ?? null, resource_id: e.resourceId ?? null, result: e.result ?? 'success',
error_class: e.errorClass ?? null, correlation_id: e.correlationId ?? null,
before: e.before === undefined ? null : mask(e.before), after: e.after === undefined ? null : mask(e.after),
};
const hash = createHash('sha256').update(prev + canon(body)).digest('hex');
await c.execute(
`INSERT INTO audit_events (ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, connector, correlation_id, ip, before_json, after_json, prev_hash, hash)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[ts, e.actorType, body.actor_id, body.org_id, e.action, body.resource_type, body.resource_id, body.result, body.error_class, e.connector ?? null,
body.correlation_id, e.ip ?? null, body.before === null ? null : JSON.stringify(body.before), body.after === null ? null : JSON.stringify(body.after), prev, hash],
);
if (own) await c.commit();
} catch (err) {
if (own) await c.rollback();
throw err;
} finally {
await c.query('SELECT RELEASE_LOCK(?)', ['kc_audit_chain']).catch(() => undefined);
if (own) c.release();
}
}
/** Prüft die Hash-Kette. Liefert die erste fehlerhafte ID oder null. */
export async function verifyAuditChain(q: (sql: string) => Promise<any[]> = (sql) => query(sql)): Promise<{ checked: number; brokenAt: number | null }> {
const rows = await q('SELECT id, ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, correlation_id, before_json, after_json, prev_hash, hash FROM audit_events ORDER BY id');
let prev = '0'.repeat(64);
for (const r of rows) {
const body = {
ts: (r.ts as Date).toISOString(), actor_type: r.actor_type, actor_id: r.actor_id, org_id: r.org_id, action: r.action,
resource_type: r.resource_type, resource_id: r.resource_id, result: r.result, error_class: r.error_class, correlation_id: r.correlation_id,
before: r.before_json ?? null, after: r.after_json ?? null,
};
const expect = createHash('sha256').update(prev + canon(body)).digest('hex');
if (r.prev_hash !== prev || r.hash !== expect) return { checked: rows.length, brokenAt: r.id as number };
prev = r.hash as string;
}
return { checked: rows.length, brokenAt: null };
}

View file

@ -0,0 +1,41 @@
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
/** Lädt *.env aus KC_ENV_DIR (Standard /etc/kundencenter), ohne bereits gesetzte Variablen zu überschreiben. */
function loadEnvDir(dir: string): void {
if (!existsSync(dir)) return;
for (const f of readdirSync(dir).filter((n) => n.endsWith('.env')).sort()) {
for (const line of readFileSync(join(dir, f), 'utf8').split('\n')) {
const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/.exec(line);
if (m && m[1] && process.env[m[1]] === undefined) process.env[m[1]] = m[2] ?? '';
}
}
}
loadEnvDir(process.env.KC_ENV_DIR ?? '/etc/kundencenter');
function req(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Konfiguration fehlt: ${name}`);
return v;
}
export const config = {
env: process.env.KC_NODE_ENV ?? 'development',
isProd: (process.env.KC_NODE_ENV ?? 'development') === 'production',
port: Number(process.env.KC_API_PORT ?? 4100),
baseUrl: process.env.KC_BASE_URL ?? 'http://localhost:4101',
/** Erlaubte Browser-Origins (CSRF/Origin-Prüfung): baseUrl plus optional KC_ALLOWED_ORIGINS (kommagetrennt). */
allowedOrigins: new Set([process.env.KC_BASE_URL ?? 'http://localhost:4101', ...(process.env.KC_ALLOWED_ORIGINS ?? '').split(',').map((o) => o.trim()).filter(Boolean)]),
secretKey: Buffer.from(req('KC_SECRET_KEY'), 'base64'),
db: {
host: process.env.DB_HOST ?? '127.0.0.1',
port: Number(process.env.DB_PORT ?? 3306),
database: req('DB_NAME'),
user: req('DB_USER'),
password: req('DB_PASSWORD'),
},
smtp: process.env.SMTP_HOST
? { host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT ?? 587), user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD, from: process.env.SMTP_FROM ?? 'kundencenter@localhost' }
: null,
};
if (config.secretKey.length !== 32) throw new Error('KC_SECRET_KEY muss 32 Byte (base64) sein');

View file

@ -0,0 +1,42 @@
/** Laufzeit-, Verlängerungs- und Kündigungslogik (rein, ohne Datenbank). Zeitpunkte in UTC. */
export interface Terms { termEnd: Date | null; renewal: 'auto' | 'none'; renewalTermMonths: number; noticeDays: number }
export function addMonths(d: Date, months: number): Date {
const r = new Date(d.getTime());
const day = r.getUTCDate();
r.setUTCDate(1); r.setUTCMonth(r.getUTCMonth() + months);
const last = new Date(Date.UTC(r.getUTCFullYear(), r.getUTCMonth() + 1, 0)).getUTCDate();
r.setUTCDate(Math.min(day, last)); // 31.01. + 1 Monat = 28./29.02.
return r;
}
const addDays = (d: Date, n: number) => new Date(d.getTime() + n * 86400000);
/**
* Wann endet der Vertrag, wenn jetzt gekündigt wird?
* - ohne Mindestlaufzeit (termEnd null): mit Frist ab jetzt.
* - ohne Verlängerung: zum Laufzeitende.
* - mit Verlängerung: zum nächsten Laufzeitende, das mindestens noticeDays in der Zukunft liegt.
*/
export function effectiveCancelDate(now: Date, t: Terms): Date {
if (!t.termEnd) return addDays(now, t.noticeDays);
if (t.renewal === 'none') return t.termEnd > now ? t.termEnd : now;
let end = t.termEnd; let guard = 0;
while (addDays(end, -t.noticeDays) < now) {
if (t.renewalTermMonths < 1 || ++guard > 1200) throw new RangeError('Ungültige Verlängerungslaufzeit');
end = addMonths(end, t.renewalTermMonths);
}
return end;
}
/** Verlängert das Laufzeitende, solange es in der Vergangenheit liegt (nur bei auto-Verlängerung und ohne Kündigung). */
export function renewedTermEnd(now: Date, termEnd: Date, renewalTermMonths: number): Date {
let end = termEnd; let guard = 0;
while (end <= now) { if (renewalTermMonths < 1 || ++guard > 1200) throw new RangeError('Ungültige Verlängerungslaufzeit'); end = addMonths(end, renewalTermMonths); }
return end;
}
/**
* Verbraucherverträge (Privatkunden): Nach der Erstlaufzeit läuft der Vertrag nur auf unbestimmte Zeit weiter
* und ist mit höchstens einem Monat Frist kündbar. HINWEIS: rechtlich vor Produktivbetrieb prüfen lassen.
*/
export function consumerTerms(renewalTermMonths: number, noticeDays: number): { renewalTermMonths: number; noticeDays: number } {
return { renewalTermMonths: renewalTermMonths > 0 ? 1 : 0, noticeDays: Math.min(noticeDays, 30) };
}

View file

@ -0,0 +1,24 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { config } from './config.js';
export const sha256 = (s: string): string => createHash('sha256').update(s).digest('hex');
export const randomToken = (bytes = 32): string => randomBytes(bytes).toString('base64url');
/** AES-256-GCM, Format v1:<iv>:<tag>:<ciphertext> (base64url). Key-ID im Präfix ermöglicht spätere Rotation. */
export function encrypt(plain: string): string {
const iv = randomBytes(12);
const c = createCipheriv('aes-256-gcm', config.secretKey, iv);
const ct = Buffer.concat([c.update(plain, 'utf8'), c.final()]);
return ['v1', iv.toString('base64url'), c.getAuthTag().toString('base64url'), ct.toString('base64url')].join(':');
}
export function decrypt(blob: string): string {
const [v, iv, tag, ct] = blob.split(':');
if (v !== 'v1' || !iv || !tag || !ct) throw new Error('Unbekanntes Verschlüsselungsformat');
const d = createDecipheriv('aes-256-gcm', config.secretKey, Buffer.from(iv, 'base64url'));
d.setAuthTag(Buffer.from(tag, 'base64url'));
return Buffer.concat([d.update(Buffer.from(ct, 'base64url')), d.final()]).toString('utf8');
}
export function safeEqual(a: string, b: string): boolean {
const x = Buffer.from(a), y = Buffer.from(b);
return x.length === y.length && timingSafeEqual(x, y);
}

View file

@ -0,0 +1,44 @@
import mysql from 'mysql2/promise';
import type { Pool, PoolConnection, RowDataPacket, ResultSetHeader } from 'mysql2/promise';
import { config } from './config.js';
export const pool: Pool = mysql.createPool({
...config.db,
connectionLimit: 10,
charset: 'utf8mb4',
timezone: 'Z',
dateStrings: false,
namedPlaceholders: false,
});
// Alle Zeiten in UTC, auch die DB-Defaults (CURRENT_TIMESTAMP) – sonst Versatz zur Serverzeit.
(pool as unknown as { on(e: string, f: (c: { query(s: string): void }) => void): void }).on('connection', (c) => c.query("SET time_zone = '+00:00'"));
export type Row = RowDataPacket;
type Exec = Pick<Pool | PoolConnection, 'execute'>;
export async function query<T extends Row = Row>(sql: string, params: unknown[] = [], c: Exec = pool): Promise<T[]> {
const [rows] = await c.execute<T[]>(sql, params as never[]);
return rows;
}
export async function one<T extends Row = Row>(sql: string, params: unknown[] = [], c: Exec = pool): Promise<T | undefined> {
return (await query<T>(sql, params, c))[0];
}
export async function run(sql: string, params: unknown[] = [], c: Exec = pool): Promise<ResultSetHeader> {
const [res] = await c.execute<ResultSetHeader>(sql, params as never[]);
return res;
}
export async function tx<T>(fn: (c: PoolConnection) => Promise<T>): Promise<T> {
const c = await pool.getConnection();
try {
await c.beginTransaction();
const out = await fn(c);
await c.commit();
return out;
} catch (e) {
await c.rollback();
throw e;
} finally {
c.release();
}
}

View file

@ -0,0 +1,18 @@
import { randomUUID } from 'node:crypto';
import type { PoolConnection } from 'mysql2/promise';
import { run } from './db.js';
/** Persistenten Auftrag einreihen. Mit idempotencyKey wird Doppelanlage verhindert. */
export async function enqueue(type: string, payload: unknown, opts: { idempotencyKey?: string; correlationId?: string; runAt?: Date } = {}, c?: PoolConnection): Promise<void> {
await run(
'INSERT IGNORE INTO jobs (id, type, payload, idempotency_key, correlation_id, run_at) VALUES (?,?,?,?,?,?)',
[randomUUID(), type, JSON.stringify(payload), opts.idempotencyKey ?? null, opts.correlationId ?? null, opts.runAt ?? new Date()],
c,
);
}
/** Wird von Job-Handlern geworfen, um die Wiederholungslogik zu steuern. */
export class JobFailure extends Error {
/** retry=false: nie automatisch wiederholen (z. B. destruktive Aktionen); finalStatus bestimmt den Endzustand. */
constructor(message: string, public retry: boolean, public finalStatus: 'failed' | 'needs_review' = 'needs_review', public delaySec?: number) { super(message); }
}

View file

@ -0,0 +1,36 @@
/**
* Serverseitige Preisberechnung in Cent (ganzzahlig, kaufmännisch gerundet je Position).
* Steuer in Basispunkten (1900 = 19 %); Rabatt in Basispunkten (1000 = 10 %).
* Preisbasis `net`: Beträge sind Nettopreise, Steuer wird aufgeschlagen.
* Preisbasis `gross`: Beträge sind Bruttopreise (z. B. Endkundenpreis 9,99 €) und bleiben exakt; Netto und Steuer werden herausgerechnet.
*/
export type Interval = 'once' | 'monthly' | 'yearly';
export type Basis = 'net' | 'gross';
export interface PriceInput {
basis?: Basis; setupCents: number; recurringCents: number; taxBp: number; interval: Interval; quantity: number; discountBp: number; currency?: string;
}
export interface PriceSnapshot {
basis: Basis; currency: string; interval: Interval; quantity: number; taxBp: number; discountBp: number;
unitSetupCents: number; unitRecurringCents: number;
setup: { net: number; tax: number; gross: number }; recurring: { net: number; tax: number; gross: number };
}
/** Kaufmännisches Runden (halb auf) für nicht-negative Ganzzahl-Division. */
const divRound = (n: number, d: number): number => Math.floor((n * 2 + d) / (d * 2));
export function calculatePrice(i: PriceInput): PriceSnapshot {
const ints: [string, number][] = [['setupCents', i.setupCents], ['recurringCents', i.recurringCents], ['taxBp', i.taxBp], ['quantity', i.quantity], ['discountBp', i.discountBp]];
for (const [n, v] of ints) if (!Number.isInteger(v) || v < 0) throw new RangeError(`${n} muss eine nicht-negative Ganzzahl sein`);
if (i.quantity < 1 || i.quantity > 10000) throw new RangeError('quantity außerhalb des Bereichs');
if (i.discountBp > 10000) throw new RangeError('discountBp darf höchstens 10000 sein');
if (i.interval === 'once' && i.recurringCents > 0) throw new RangeError('Einmalprodukte haben keinen wiederkehrenden Preis');
const basis: Basis = i.basis ?? 'net';
const line = (unit: number) => {
const amount = divRound(unit * i.quantity * (10000 - i.discountBp), 10000);
if (basis === 'gross') { const net = divRound(amount * 10000, 10000 + i.taxBp); return { net, tax: amount - net, gross: amount }; }
const tax = divRound(amount * i.taxBp, 10000);
return { net: amount, tax, gross: amount + tax };
};
return { basis, currency: i.currency ?? 'EUR', interval: i.interval, quantity: i.quantity, taxBp: i.taxBp, discountBp: i.discountBp,
unitSetupCents: i.setupCents, unitRecurringCents: i.recurringCents, setup: line(i.setupCents), recurring: line(i.recurringCents) };
}
export const formatEuro = (cents: number): string => (cents / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });

View file

@ -0,0 +1,27 @@
/** Statusautomaten für Bestellung und Vertrag. Zustandsänderungen laufen ausschließlich über `transition`. */
export type OrderStatus = 'pending_approval' | 'approved' | 'provisioning' | 'completed' | 'failed' | 'rejected' | 'cancelled';
export type OrderEvent = 'approve' | 'reject' | 'cancel' | 'start_provisioning' | 'complete' | 'fail' | 'retry';
export type ContractStatus = 'pending' | 'active' | 'suspended' | 'cancelled' | 'expired' | 'failed';
export type ContractEvent = 'activate' | 'suspend' | 'unsuspend' | 'cancel' | 'expire' | 'fail';
type Machine<S extends string, E extends string> = Partial<Record<S, Partial<Record<E, S>>>>;
export const ORDER_MACHINE: Machine<OrderStatus, OrderEvent> = {
pending_approval: { approve: 'approved', reject: 'rejected', cancel: 'cancelled' },
approved: { start_provisioning: 'provisioning', cancel: 'cancelled' },
provisioning: { complete: 'completed', fail: 'failed' },
failed: { retry: 'provisioning', cancel: 'cancelled' },
};
export const CONTRACT_MACHINE: Machine<ContractStatus, ContractEvent> = {
pending: { activate: 'active', fail: 'failed', cancel: 'cancelled' },
active: { suspend: 'suspended', cancel: 'cancelled', expire: 'expired' },
suspended: { unsuspend: 'active', cancel: 'cancelled', expire: 'expired' },
};
export class InvalidTransition extends Error {
constructor(public from: string, public event: string) { super(`Ungültiger Statuswechsel: ${event} ist im Zustand ${from} nicht erlaubt`); }
}
export function transition<S extends string, E extends string>(m: Machine<S, E>, from: S, event: E): S {
const to = m[from]?.[event];
if (!to) throw new InvalidTransition(from, event);
return to;
}
export const terminalOrder = (s: OrderStatus) => ['completed', 'rejected', 'cancelled'].includes(s);

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

View file

@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "declaration": true, "types": ["node"] }, "include": ["src"] }