Rechtliches: Fristen- und Versionslogik mit Tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
701ff1a8e8
commit
0105179e21
3 files changed
+253
-1
No files matched your search
+2
-1
@@ -18,6 +18,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "node --test tests/*.test.mjs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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}`
|
||||||
|
}
|
||||||
@@ -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), '–')
|
||||||
|
})
|
||||||
Reference in new issue
Block a user