Stand vor Einführung des Nacht-Agenten
This commit is contained in:
commit
4763548bfb
168 changed files with 12726 additions and 0 deletions
25
packages/platform/package.json
Normal file
25
packages/platform/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
81
packages/platform/src/audit.ts
Normal file
81
packages/platform/src/audit.ts
Normal 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 };
|
||||
}
|
||||
41
packages/platform/src/config.ts
Normal file
41
packages/platform/src/config.ts
Normal 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');
|
||||
42
packages/platform/src/contractterms.ts
Normal file
42
packages/platform/src/contractterms.ts
Normal 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) };
|
||||
}
|
||||
24
packages/platform/src/crypto.ts
Normal file
24
packages/platform/src/crypto.ts
Normal 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);
|
||||
}
|
||||
44
packages/platform/src/db.ts
Normal file
44
packages/platform/src/db.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
18
packages/platform/src/jobs.ts
Normal file
18
packages/platform/src/jobs.ts
Normal 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); }
|
||||
}
|
||||
36
packages/platform/src/pricing.ts
Normal file
36
packages/platform/src/pricing.ts
Normal 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' });
|
||||
27
packages/platform/src/statemachine.ts
Normal file
27
packages/platform/src/statemachine.ts
Normal 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);
|
||||
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);
|
||||
});
|
||||
});
|
||||
1
packages/platform/tsconfig.json
Normal file
1
packages/platform/tsconfig.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "declaration": true, "types": ["node"] }, "include": ["src"] }
|
||||
Loading…
Add table
Add a link
Reference in a new issue