From 0105179e2102ca2a9e1cded41201da15d72b108b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 10:17:13 +0000 Subject: [PATCH] Rechtliches: Fristen- und Versionslogik mit Tests Co-Authored-By: Claude Opus 5.5 --- package.json | 3 +- src/lib/legal.js | 125 ++++++++++++++++++++++++++++++++++++++++++ tests/legal.test.mjs | 126 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 src/lib/legal.js create mode 100644 tests/legal.test.mjs diff --git a/package.json b/package.json index 90a36f0..f91e42b 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "node --test tests/*.test.mjs" } } diff --git a/src/lib/legal.js b/src/lib/legal.js new file mode 100644 index 0000000..72462dd --- /dev/null +++ b/src/lib/legal.js @@ -0,0 +1,125 @@ +// Reine Logik fuer den Bereich Rechtliches: Kuendigungsfristen der Anbieter-Vertraege +// und die Versionsgeschichte der Rechtstexte. Datumswerte sind immer 'YYYY-MM-DD'. + +const DAY = 24 * 60 * 60 * 1000 + +function toUtc(date) { + const [y, m, d] = date.split('-').map(Number) + return Date.UTC(y, m - 1, d) +} + +function fromUtc(ms) { + return new Date(ms).toISOString().slice(0, 10) +} + +function addDays(date, n) { + return fromUtc(toUtc(date) + n * DAY) +} + +function daysBetween(from, to) { + return Math.round((toUtc(to) - toUtc(from)) / DAY) +} + +function isSet(value) { + return value !== null && value !== undefined && value !== '' +} + +/** Monate addieren; der Tag wird auf das Monatsende geklemmt (31.01. + 1 = 28./29.02.). */ +export function addMonthsClamped(date, n) { + const [y, m, d] = date.split('-').map(Number) + const index = y * 12 + (m - 1) + n + const year = Math.floor(index / 12) + const month = index - year * 12 + const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate() + return fromUtc(Date.UTC(year, month, Math.min(d, lastDay))) +} + +function noticeDate(periodEnd, contract) { + const value = Number(contract.noticeValue) + return contract.noticeUnit === 'days' + ? addDays(periodEnd, -value) + : addMonthsClamped(periodEnd, -value) +} + +/** + * Naechster Kuendigungstermin eines Anbieter-Vertrags. + * state: active (noticeBy = spaetester Kuendigungstag), anytime (jederzeit kuendbar), + * ends (laeuft ohne Verlaengerung aus), ended, cancelled, incomplete (Angaben fehlen). + */ +export function nextCancellationDate(contract, today) { + if (isSet(contract.cancelledAt)) { + return { state: 'cancelled', periodEnd: contract.endsAt || null, noticeBy: null } + } + if (!isSet(contract.startDate) || !isSet(contract.termMonths)) { + return { state: 'incomplete', periodEnd: null, noticeBy: null } + } + const term = Number(contract.termMonths) + if (term === 0) return { state: 'anytime', periodEnd: null, noticeBy: null } + + const renewal = Number(contract.renewalMonths) || 0 + let periodEnd = addMonthsClamped(contract.startDate, term) + if (renewal === 0) { + return { state: periodEnd < today ? 'ended' : 'ends', periodEnd, noticeBy: null } + } + if (!isSet(contract.noticeValue) || !isSet(contract.noticeUnit)) { + return { state: 'incomplete', periodEnd: null, noticeBy: null } + } + while (noticeDate(periodEnd, contract) < today) { + periodEnd = addMonthsClamped(periodEnd, renewal) + } + return { state: 'active', periodEnd, noticeBy: noticeDate(periodEnd, contract) } +} + +/** Farbe in der Vertragsliste: rot <= 30 Tage, orange <= 90 Tage bis zum Kuendigungstermin. */ +export function deadlineTone(result, today) { + if (result.state === 'cancelled' || result.state === 'ended') return 'grey' + if (result.state === 'incomplete') return 'missing' + if (result.state !== 'active' || !result.noticeBy) return 'normal' + const days = daysBetween(today, result.noticeBy) + if (days <= 30) return 'red' + if (days <= 90) return 'orange' + return 'normal' +} + +/** Aktuell gueltige Version; bei mehreren (z. B. halb gescheiterte Abloesung) die juengste. */ +export function currentVersion(versions) { + return versions + .filter((v) => v.status === 'current') + .reduce((best, v) => (!best || v.validFrom > best.validFrom ? v : best), null) +} + +/** + * Was beim Anlegen einer Version zu tun ist: das neue Dokument und die Aenderungen an + * bestehenden Versionen. Eine juengere gueltige Version loest die aktuelle ab; eine + * nachgetragene aeltere Fassung wird direkt als abgeloest einsortiert. + */ +export function planNewVersion(versions, input) { + const siblings = versions.filter( + (v) => v.documentKey === input.documentKey && v.$id !== input.$id + ) + if (input.status !== 'current') { + return { create: { ...input, validTo: null }, updates: [] } + } + const current = currentVersion(siblings) + if (!current || input.validFrom >= current.validFrom) { + const updates = current + ? [{ id: current.$id, patch: { status: 'superseded', validTo: addDays(input.validFrom, -1) } }] + : [] + return { create: { ...input, validTo: null }, updates } + } + const nextFrom = siblings + .filter((v) => v.status !== 'draft' && v.validFrom > input.validFrom) + .map((v) => v.validFrom) + .sort()[0] + return { + create: { ...input, status: 'superseded', validTo: addDays(nextFrom, -1) }, + updates: [], + } +} + +/** 'YYYY-MM-DD' -> 'dd.mm.yyyy' */ +export function formatDate(date) { + if (!date) return '–' + const [y, m, d] = date.slice(0, 10).split('-') + return `${d}.${m}.${y}` +} diff --git a/tests/legal.test.mjs b/tests/legal.test.mjs new file mode 100644 index 0000000..de7938d --- /dev/null +++ b/tests/legal.test.mjs @@ -0,0 +1,126 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + addMonthsClamped, + nextCancellationDate, + deadlineTone, + currentVersion, + planNewVersion, + formatDate, +} from '../src/lib/legal.js' + +const yearly = { + startDate: '2025-03-12', + termMonths: 12, + renewalMonths: 12, + noticeValue: 1, + noticeUnit: 'months', +} + +test('addMonthsClamped klemmt auf Monatsende', () => { + assert.equal(addMonthsClamped('2026-01-31', 1), '2026-02-28') + assert.equal(addMonthsClamped('2028-01-31', 1), '2028-02-29') + assert.equal(addMonthsClamped('2026-03-15', 12), '2027-03-15') + assert.equal(addMonthsClamped('2026-03-31', -1), '2026-02-28') +}) + +test('laufender Jahresvertrag', () => { + assert.deepEqual(nextCancellationDate(yearly, '2026-09-30'), { + state: 'active', + periodEnd: '2027-03-12', + noticeBy: '2027-02-12', + }) +}) + +test('Kündigungstermin heute zählt noch, danach nächste Periode', () => { + assert.equal(nextCancellationDate(yearly, '2027-02-12').noticeBy, '2027-02-12') + const next = nextCancellationDate(yearly, '2027-02-13') + assert.equal(next.periodEnd, '2028-03-12') + assert.equal(next.noticeBy, '2028-02-12') +}) + +test('Kündigungsfrist in Tagen', () => { + const r = nextCancellationDate({ ...yearly, noticeValue: 30, noticeUnit: 'days' }, '2026-09-30') + assert.equal(r.periodEnd, '2027-03-12') + assert.equal(r.noticeBy, '2027-02-10') +}) + +test('Start am 31. läuft nicht in den Folgemonat über', () => { + const r = nextCancellationDate( + { startDate: '2026-01-31', termMonths: 1, renewalMonths: 1, noticeValue: 0, noticeUnit: 'days' }, + '2026-02-01' + ) + assert.equal(r.periodEnd, '2026-02-28') +}) + +test('jederzeit kündbar', () => { + const r = nextCancellationDate({ startDate: '2025-01-01', termMonths: 0 }, '2026-09-30') + assert.equal(r.state, 'anytime') + assert.equal(r.noticeBy, null) +}) + +test('ohne Verlängerung: endet, danach beendet', () => { + const c = { startDate: '2026-01-01', termMonths: 12, renewalMonths: 0 } + assert.deepEqual(nextCancellationDate(c, '2026-09-30'), { state: 'ends', periodEnd: '2027-01-01', noticeBy: null }) + assert.equal(nextCancellationDate(c, '2027-01-02').state, 'ended') +}) + +test('gekündigt', () => { + const r = nextCancellationDate({ ...yearly, cancelledAt: '2026-09-01', endsAt: '2027-03-12' }, '2026-09-30') + assert.deepEqual(r, { state: 'cancelled', periodEnd: '2027-03-12', noticeBy: null }) +}) + +test('unvollständige Angaben', () => { + assert.equal(nextCancellationDate({ ...yearly, startDate: '' }, '2026-09-30').state, 'incomplete') + assert.equal(nextCancellationDate({ ...yearly, noticeValue: null }, '2026-09-30').state, 'incomplete') + assert.equal(nextCancellationDate({ startDate: '2026-01-01' }, '2026-09-30').state, 'incomplete') +}) + +test('deadlineTone', () => { + const today = '2026-09-30' + const at = (noticeBy) => deadlineTone({ state: 'active', noticeBy, periodEnd: null }, today) + assert.equal(at('2026-10-30'), 'red') + assert.equal(at('2026-10-31'), 'orange') + assert.equal(at('2026-12-29'), 'orange') + assert.equal(at('2026-12-30'), 'normal') + assert.equal(deadlineTone({ state: 'cancelled' }, today), 'grey') + assert.equal(deadlineTone({ state: 'ended' }, today), 'grey') + assert.equal(deadlineTone({ state: 'incomplete' }, today), 'missing') + assert.equal(deadlineTone({ state: 'anytime' }, today), 'normal') + assert.equal(deadlineTone({ state: 'ends' }, today), 'normal') +}) + +const v5 = { $id: 'v5', documentKey: 'agb', version: 'v5', status: 'current', validFrom: '2026-05-01', validTo: null } +const v4 = { $id: 'v4', documentKey: 'agb', version: 'v4', status: 'superseded', validFrom: '2026-01-10', validTo: '2026-04-30' } + +test('neue gültige Version löst die aktuelle ab', () => { + const r = planNewVersion([v4, v5], { documentKey: 'agb', version: 'v6', status: 'current', validFrom: '2026-09-01' }) + assert.deepEqual(r.updates, [{ id: 'v5', patch: { status: 'superseded', validTo: '2026-08-31' } }]) + assert.equal(r.create.validTo, null) + assert.equal(r.create.status, 'current') +}) + +test('Entwurf löst nichts ab', () => { + const r = planNewVersion([v4, v5], { documentKey: 'agb', version: 'v6', status: 'draft', validFrom: '2026-12-01' }) + assert.deepEqual(r.updates, []) + assert.equal(r.create.status, 'draft') +}) + +test('Nachtrag einer alten Fassung löst die aktuelle nicht ab', () => { + const r = planNewVersion([v4, v5], { documentKey: 'agb', version: 'v4b', status: 'current', validFrom: '2026-02-01' }) + assert.deepEqual(r.updates, []) + assert.equal(r.create.status, 'superseded') + assert.equal(r.create.validTo, '2026-04-30') +}) + +test('currentVersion nimmt bei zwei gültigen die jüngere', () => { + const twin = { ...v5, $id: 'v6', version: 'v6', validFrom: '2026-09-01' } + assert.equal(currentVersion([v4, v5, twin]).$id, 'v6') + assert.equal(currentVersion([v4]), null) +}) + +test('formatDate', () => { + assert.equal(formatDate('2026-09-30'), '30.09.2026') + assert.equal(formatDate(''), '–') + assert.equal(formatDate(null), '–') +})