Files
Webklar-Kundenbereich/test/auftraegePositionen.test.js
T

304 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
stundenText, leistungszeitraumText, kalenderwoche, gueltigerTag, kuerzen,
festePositionen, zusatzPositionen, pruefePositionen, aufwandPositionen,
auftragDatum, anzahlungPosition, schlussPositionen, SCHLUSS_GRENZEN,
} from '../server/services/auftraege/positionen.js'
/** Wirft die Funktion einen AuftragError mit diesem Status und dieser Meldung? */
const wirft = (fn, status, message) =>
assert.throws(fn, (err) => {
assert.equal(err.status, status, `Status ${err.status}: ${err.message}`)
assert.equal(err.message, message)
return true
})
const ANZAHL = 'Eine Rechnung braucht 1 bis 20 Positionen.'
const ok = { description: 'Domain', quantity: 1, unitAmountCents: 1200 }
test('stundenText: Minuten als Stunden mit Komma, ohne überflüssige Nullen', () => {
const faelle = [[0, '0'], [15, '0,25'], [30, '0,5'], [45, '0,75'], [60, '1'], [90, '1,5'], [135, '2,25'], [390, '6,5'], [600, '10']]
for (const [minuten, text] of faelle) assert.equal(stundenText(minuten), text, `${minuten} Minuten`)
})
test('leistungszeitraumText: ein Tag, ein Jahr, Jahreswechsel (Halbgeviertstrich U+2013 ohne Leerzeichen)', () => {
assert.equal(leistungszeitraumText('2026-10-06', '2026-10-06'), '06.10.2026')
assert.equal(leistungszeitraumText('2026-10-01', '2026-10-20'), '01.10.–20.10.2026')
assert.equal(leistungszeitraumText('2026-09-28', '2026-10-05'), '28.09.–05.10.2026')
assert.equal(leistungszeitraumText('2026-12-28', '2027-01-05'), '28.12.2026–05.01.2027')
})
test('kalenderwoche: ISO-Woche samt ISO-Jahr, gueltigerTag: nur Tage, die es gibt', () => {
const faelle = [
['2026-10-05', 2026, 41], ['2026-10-06', 2026, 41], ['2026-10-11', 2026, 41], ['2026-10-12', 2026, 42],
['2025-12-29', 2026, 1], ['2026-01-01', 2026, 1], ['2026-12-31', 2026, 53], ['2027-01-03', 2026, 53],
['2027-01-04', 2027, 1], ['2024-12-30', 2025, 1], ['2021-01-03', 2020, 53],
]
for (const [tag, jahr, woche] of faelle) assert.deepEqual(kalenderwoche(tag), { jahr, woche }, tag)
assert.throws(() => kalenderwoche('2026-02-30'), RangeError)
assert.equal(gueltigerTag('2026-02-28'), true)
for (const tag of ['2026-02-30', '2026-13-01', '06.10.2026', '2026-1-1', '', undefined, 20261006]) assert.equal(gueltigerTag(tag), false, String(tag))
})
test('kuerzen: höchstens max Zeichen, Auslassungszeichen am Ende, kein halbes Emoji', () => {
assert.equal(kuerzen('kurz', 10), 'kurz')
assert.equal(kuerzen('abcdefghij', 10), 'abcdefghij')
assert.equal(kuerzen('abcdefghijk', 10), 'abcdefghi…')
assert.equal(kuerzen('ab cdefghijk', 4), 'ab…')
assert.equal(kuerzen('abcd😀fghij', 6), 'abcd…')
})
test('festePositionen: Eingabe wie bisher, Beschreibung getrimmt, Schlüssel pos-n, Betrag = Menge × Einzelpreis', () => {
assert.deepEqual(festePositionen([{ description: ' Website ', quantity: 2, unitAmountCents: 5000 }, ok]), [
{ schluessel: 'pos-1', beschreibung: 'Website', menge: 2, einzelpreisCents: 5000, betragCents: 10000 },
{ schluessel: 'pos-2', beschreibung: 'Domain', menge: 1, einzelpreisCents: 1200, betragCents: 1200 },
])
assert.equal(festePositionen(Array.from({ length: 20 }, () => ok)).length, 20)
})
test('festePositionen: Grenzen und Meldungen wie bei der bisherigen Rechnung', () => {
wirft(() => festePositionen([]), 400, ANZAHL)
wirft(() => festePositionen(undefined), 400, ANZAHL)
wirft(() => festePositionen(Array.from({ length: 21 }, () => ok)), 400, ANZAHL)
for (const description of ['', ' ', 'x'.repeat(201), 5]) wirft(() => festePositionen([{ ...ok, description }]), 400, 'Position 1: Die Beschreibung braucht 1 bis 200 Zeichen.')
for (const quantity of [0, 1000, 1.5, '2', -1, null]) wirft(() => festePositionen([ok, { ...ok, quantity }]), 400, 'Position 2: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
for (const unitAmountCents of [0, 10_000_001, 12.5, '5000', -100, null]) {
wirft(() => festePositionen([{ ...ok, unitAmountCents }]), 400, 'Position 1: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.')
}
const neun = Array.from({ length: 9 }, (_, i) => ({ description: `Teil ${i + 1}`, quantity: 1, unitAmountCents: 10_000_000 }))
wirft(() => festePositionen([...neun, { description: 'Rest', quantity: 1, unitAmountCents: 10_000_000 }]), 400, 'Rechnungssumme zu hoch.')
assert.equal(festePositionen([...neun, { description: 'Rest', quantity: 1, unitAmountCents: 9_999_999 }]).at(-1).betragCents, 9_999_999)
})
test('zusatzPositionen: leer erlaubt, Schlüssel zusatz-n, gleiche Prüfungen; pruefePositionen: Zahl (mit wählbarer Obergrenze), Texte, Summe mit Minuszeilen', () => {
for (const leer of [undefined, null, []]) assert.deepEqual(zusatzPositionen(leer), [])
assert.deepEqual(zusatzPositionen([{ description: 'Domain', quantity: 2, unitAmountCents: 1500 }]), [
{ schluessel: 'zusatz-1', beschreibung: 'Domain', menge: 2, einzelpreisCents: 1500, betragCents: 3000 },
])
wirft(() => zusatzPositionen('Domain'), 400, ANZAHL)
wirft(() => zusatzPositionen(Array.from({ length: 21 }, () => ok)), 400, ANZAHL)
wirft(() => zusatzPositionen([{ ...ok, description: '' }]), 400, 'Position 1: Die Beschreibung braucht 1 bis 200 Zeichen.')
const pos = (beschreibung, cents) => ({ schluessel: 'x', beschreibung, menge: 1, einzelpreisCents: cents, betragCents: cents })
assert.equal(pruefePositionen([pos('A', 5000), pos('abzüglich Anzahlung', -2000)]), 3000)
assert.equal(pruefePositionen([pos('A', 99_999_999)]), 99_999_999)
wirft(() => pruefePositionen([]), 400, ANZAHL)
wirft(() => pruefePositionen(Array.from({ length: 21 }, () => pos('A', 1))), 400, ANZAHL)
// eine Schlussrechnung darf bis 40 Zeilen haben: der Aufrufer hebt die Obergrenze an
assert.equal(pruefePositionen(Array.from({ length: 40 }, () => pos('A', 1)), { max: 40 }), 40)
wirft(() => pruefePositionen(Array.from({ length: 41 }, () => pos('A', 1)), { max: 40 }), 400, 'Eine Rechnung braucht 1 bis 40 Positionen.')
wirft(() => pruefePositionen([], { max: 40 }), 400, 'Eine Rechnung braucht 1 bis 40 Positionen.')
wirft(() => pruefePositionen([pos('A', 1), pos('', 1)]), 400, 'Position 2: Die Beschreibung braucht 1 bis 200 Zeichen.')
wirft(() => pruefePositionen([pos('x'.repeat(201), 1)]), 400, 'Position 1: Die Beschreibung braucht 1 bis 200 Zeichen.')
wirft(() => pruefePositionen([pos('A', 99_999_999), pos('B', 1)]), 400, 'Rechnungssumme zu hoch.')
})
const NBSP = String.fromCharCode(160) // geschütztes Leerzeichen U+00A0, wie euroDe es vor € setzt
const blatt = (id, datum, minuten, text = '', over = {}) => ({ id, ticketId: 't1', woid: 'WO-1', datum, minuten, text, aboZeit: false, kommentar: false, ...over })
const werktage = (start, n) => {
const tage = []
for (let ms = Date.parse(`${start}T00:00:00Z`); tage.length < n; ms += 86_400_000) {
const tag = new Date(ms)
if (tag.getUTCDay() % 6 !== 0) tage.push(tag.toISOString().slice(0, 10))
}
return tage
}
test('aufwandPositionen: eine Zeile je Tag mit Texten, Stunden und Satz (Menge 1, Betrag = Stunden × Satz)', () => {
const erg = aufwandPositionen([blatt('w1', '2026-10-06', 90, 'Startseite, Kontaktformular'), blatt('w2', '2026-10-06', 45, '')], { satzCents: 10000 })
assert.deepEqual(erg.positionen, [
{ schluessel: '2026-10-06', beschreibung: `06.10.2026: Startseite, Kontaktformular – 2,25 Std. à 100,00${NBSP}€`, menge: 1, einzelpreisCents: 22500, betragCents: 22500 },
])
assert.deepEqual([erg.minuten, erg.von, erg.bis], [135, '2026-10-06', '2026-10-06'])
})
test('aufwandPositionen: Texte eines Tages mit "; " verbunden, Doppelte entfallen, Leerraum vereinheitlicht, ohne Text "Arbeitszeit"', () => {
const erg = aufwandPositionen([
blatt('w1', '2026-10-06', 60, 'Startseite'), blatt('w2', '2026-10-06', 60, 'Kontaktformular\nmit Anhang'), blatt('w3', '2026-10-06', 15, ' Startseite '),
blatt('w4', '2026-10-07', 30, ''),
], { satzCents: 10000 })
assert.deepEqual(erg.positionen.map((p) => p.beschreibung), [
`06.10.2026: Startseite; Kontaktformular mit Anhang – 2,25 Std. à 100,00${NBSP}€`,
`07.10.2026: Arbeitszeit – 0,5 Std. à 100,00${NBSP}€`,
])
assert.deepEqual(erg.positionen.map((p) => p.betragCents), [22500, 5000])
})
test('aufwandPositionen: Reihenfolge der Eingabe egal (nach Tag und Kennung), von und bis, Satz ohne glatte Cent je Viertelstunde', () => {
const erg = aufwandPositionen([blatt('w3', '2026-10-08', 15, 'C'), blatt('w1', '2026-10-06', 15, 'A'), blatt('w2', '2026-10-07', 15, 'B')], { satzCents: 9999 })
assert.deepEqual(erg.positionen.map((p) => p.schluessel), ['2026-10-06', '2026-10-07', '2026-10-08'])
assert.deepEqual([erg.von, erg.bis, erg.minuten], ['2026-10-06', '2026-10-08', 45])
assert.equal(erg.positionen[0].beschreibung, `06.10.2026: A – 0,25 Std. à 99,99${NBSP}€`)
assert.equal(erg.positionen[0].betragCents, 2500) // 15 Minuten × 99,99 €/Std. = 24,9975 € → ganze Cent gerundet
})
test('aufwandPositionen: zeilentexte ersetzen den Text vor dem Gedankenstrich; leer oder unbekannt wirkt nicht; Zeile bleibt bei höchstens 200 Zeichen', () => {
const standard = `06.10.2026: Startseite – 2,25 Std. à 100,00${NBSP}€`
const mit = (zeilentexte, text = 'Startseite') =>
aufwandPositionen([blatt('w1', '2026-10-06', 135, text)], { satzCents: 10000, zeilentexte }).positionen[0].beschreibung
assert.equal(mit({ '2026-10-06': '06.10.2026: Neue Startseite' }), `06.10.2026: Neue Startseite – 2,25 Std. à 100,00${NBSP}€`)
for (const wirkungslos of [undefined, null, {}, { '2026-10-06': ' ' }, { '2026-10-07': 'anderer Tag' }, { '2026-10-06': 5 }]) assert.equal(mit(wirkungslos), standard)
const schluss = ` – 2,25 Std. à 100,00${NBSP}€`
const ersetzt = mit({ '2026-10-06': 'x'.repeat(300) })
assert.equal(ersetzt.length, 200)
assert.ok(ersetzt.endsWith(`x…${schluss}`))
const lang = mit(undefined, 'y'.repeat(300))
assert.equal(lang.length, 200)
assert.ok(lang.startsWith('06.10.2026: yyy'))
assert.ok(lang.endsWith(`y…${schluss}`))
})
test('aufwandPositionen: zu viele Tage -> die ältesten Wochen werden zu KW-Zeilen (ISO-Woche, älteste zuerst), Einzeltage bleiben Tageszeilen', () => {
// KW 41: 5., 6., 7.10.; KW 42: 12., 13., 14.10.; KW 43: 19., 20.10.; je 90 Minuten
const tage = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-12', '2026-10-13', '2026-10-14', '2026-10-19', '2026-10-20']
const blaetter = tage.map((tag, i) => blatt(`w${i + 1}`, tag, 90, 'ABCDEFGH'[i]))
const lauf = (maxZeilen) => aufwandPositionen(blaetter, { satzCents: 10000, maxZeilen })
assert.equal(lauf(8).positionen.length, 8)
assert.deepEqual(lauf(6).positionen.map((p) => p.schluessel), ['KW-2026-41', '2026-10-12', '2026-10-13', '2026-10-14', '2026-10-19', '2026-10-20'])
assert.deepEqual(lauf(6).positionen[0], {
schluessel: 'KW-2026-41', beschreibung: `KW 41/2026: A; B; C – 4,5 Std. à 100,00${NBSP}€`, menge: 1, einzelpreisCents: 45000, betragCents: 45000,
})
assert.deepEqual(lauf(5).positionen.map((p) => p.schluessel), ['KW-2026-41', 'KW-2026-42', '2026-10-19', '2026-10-20'])
assert.deepEqual(lauf(3).positionen.map((p) => p.schluessel), ['KW-2026-41', 'KW-2026-42', 'KW-2026-43'])
assert.equal(lauf(3).positionen[2].beschreibung, `KW 43/2026: G; H – 3 Std. à 100,00${NBSP}€`)
for (const n of [8, 6, 5, 3]) assert.equal(lauf(n).positionen.reduce((s, p) => s + p.betragCents, 0), 8 * 15000, `Summe bei ${n} Zeilen`)
assert.deepEqual([lauf(3).von, lauf(3).bis, lauf(3).minuten], ['2026-10-05', '2026-10-20', 720])
wirft(() => lauf(2), 400, 'Zu viele Arbeitstage für eine Rechnung (auch nach Kalenderwochen mehr als 2 Zeilen). Bitte weniger Arbeitsblätter auswählen.')
// eine Woche mit nur einem Tag wird nicht zusammengefasst (es spart keine Zeile)
const einzel = [blatt('a', '2026-10-06', 60, 'A'), blatt('b', '2026-10-12', 60, 'B'), blatt('c', '2026-10-13', 60, 'C'), blatt('d', '2026-10-14', 60, 'D')]
assert.deepEqual(aufwandPositionen(einzel, { satzCents: 10000, maxZeilen: 2 }).positionen.map((p) => p.schluessel), ['2026-10-06', 'KW-2026-42'])
})
test('aufwandPositionen: Standard 20 Zeilen, zeilentexte auch für KW-Zeilen, Woche 53 über den Jahreswechsel, mehr als 20 Wochen -> 400', () => {
const blaetter = werktage('2026-10-05', 25).map((tag, i) => blatt(`w${String(i + 1).padStart(2, '0')}`, tag, 60, `T${i + 1}`)) // KW 41 bis 45
const erg = aufwandPositionen(blaetter, { satzCents: 10000, zeilentexte: { 'KW-2026-42': 'KW 42/2026: Relaunch Teil 2' } })
assert.equal(erg.positionen.length, 17)
assert.deepEqual(erg.positionen.slice(0, 3).map((p) => p.schluessel), ['KW-2026-41', 'KW-2026-42', '2026-10-19'])
assert.equal(erg.positionen[1].beschreibung, `KW 42/2026: Relaunch Teil 2 – 5 Std. à 100,00${NBSP}€`)
assert.equal(erg.minuten, 1500)
const wechsel = ['2026-12-30', '2026-12-31', '2027-01-01', '2027-01-04'].map((tag, i) => blatt(`j${i}`, tag, 60, `J${i}`))
const zwei = aufwandPositionen(wechsel, { satzCents: 10000, maxZeilen: 2 })
assert.deepEqual(zwei.positionen.map((p) => p.schluessel), ['KW-2026-53', '2027-01-04'])
assert.equal(zwei.positionen[0].beschreibung, `KW 53/2026: J0; J1; J2 – 3 Std. à 100,00${NBSP}€`)
const montage = Array.from({ length: 21 }, (_, i) => new Date(Date.UTC(2026, 0, 5 + 7 * i)).toISOString().slice(0, 10))
const wochen = montage.map((tag, i) => blatt(`m${i + 1}`, tag, 60, 'x'))
wirft(() => aufwandPositionen(wochen, { satzCents: 10000 }), 400, 'Zu viele Arbeitstage für eine Rechnung (auch nach Kalenderwochen mehr als 20 Zeilen). Bitte weniger Arbeitsblätter auswählen.')
assert.equal(aufwandPositionen(wochen.slice(0, 20), { satzCents: 10000 }).positionen.length, 20)
})
test('aufwandPositionen: ungültiger Satz, leere Auswahl, kaputte Blätter', () => {
const eins = [blatt('w1', '2026-10-06', 60, 'A')]
for (const satzCents of [0, -1, 1.5, '10000', 10_000_001, undefined]) wirft(() => aufwandPositionen(eins, { satzCents }), 400, 'Der Stundensatz ist ungültig.')
assert.deepEqual(aufwandPositionen([], { satzCents: 10000 }), { positionen: [], minuten: 0, von: null, bis: null })
for (const kaputt of [{ datum: '06.10.2026' }, { datum: '2026-13-01' }, { minuten: 0 }, { minuten: 7.5 }, { minuten: '60' }]) {
wirft(() => aufwandPositionen([blatt('w1', '2026-10-06', 60, 'A', kaputt)], { satzCents: 10000 }), 400, 'Ein Arbeitsblatt hat kein gültiges Datum oder keine Zeit.')
}
})
const AB = {
id: 'a1', nummer: 'AB-2026-0002', titel: 'Website anelia-wolf.com', art: 'festpreis', status: 'versendet',
gesendetAm: '2026-10-06T08:15:00.000+00:00', auftragVom: '2026-10-05', summeCents: 80000,
positionen: [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 60000 }, { beschreibung: 'Texte', menge: 2, einzelpreisCents: 10000 }],
zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' },
}
const ANZ_KOPF = 'gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026 (Website anelia-wolf.com)'
test('auftragDatum: Berliner Tag des Sendens, sonst der Tag der Absprache', () => {
assert.equal(auftragDatum(AB), '2026-10-06')
assert.equal(auftragDatum({ ...AB, gesendetAm: '2026-10-06T22:30:00.000+00:00' }), '2026-10-07')
assert.equal(auftragDatum({ ...AB, gesendetAm: null }), '2026-10-05')
})
test('anzahlungPosition: ohne Angaben der Prozentsatz aus dem Zahlungsplan (sonst 50 %), Text wie in der Spec', () => {
assert.deepEqual(anzahlungPosition(AB, {}, 0), {
schluessel: 'anzahlung', beschreibung: `Anzahlung 50 % ${ANZ_KOPF}`, menge: 1, einzelpreisCents: 40000, betragCents: 40000,
})
assert.equal(anzahlungPosition({ ...AB, zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } }, {}, 0).betragCents, 40000)
assert.equal(anzahlungPosition({ ...AB, zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' } }, undefined, 0).betragCents, 24000)
})
test('anzahlungPosition: Prozent oder Betrag, Bezeichnung änderbar, Text je Variante, lange Titel gekürzt', () => {
assert.equal(anzahlungPosition(AB, { prozent: 30 }, 0).betragCents, 24000)
assert.equal(anzahlungPosition(AB, { prozent: 100 }, 0).betragCents, 80000)
const teil = anzahlungPosition(AB, { prozent: 25, bezeichnung: ' Teilrechnung ' }, 0)
assert.deepEqual([teil.beschreibung, teil.betragCents], [`Teilrechnung 25 % ${ANZ_KOPF}`, 20000])
const betrag = anzahlungPosition(AB, { betragCents: 25000 }, 0)
assert.deepEqual([betrag.beschreibung, betrag.betragCents], [`Anzahlung ${ANZ_KOPF}`, 25000])
const komma = anzahlungPosition(AB, { prozent: 33.3 }, 0)
assert.deepEqual([komma.beschreibung, komma.betragCents], [`Anzahlung 33,3 % ${ANZ_KOPF}`, 26640])
const lang = anzahlungPosition({ ...AB, titel: 'T'.repeat(300) }, { prozent: 50 }, 0).beschreibung
assert.equal(lang.length, 200)
assert.ok(lang.startsWith('Anzahlung 50 % gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026 (TTT'))
assert.ok(lang.endsWith('T…)'))
assert.equal(anzahlungPosition({ ...AB, titel: '' }, { prozent: 50 }, 0).beschreibung, 'Anzahlung 50 % gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026')
})
test('anzahlungPosition: höchstens Summe minus bereits Berechnetes, nichts mehr offen -> 409', () => {
assert.equal(anzahlungPosition(AB, {}, 40000).betragCents, 40000)
wirft(() => anzahlungPosition(AB, {}, 50000), 400, `Die Anzahlung darf höchstens 300,00${NBSP}€ betragen (Summe der Auftragsbestätigung minus bereits berechnet).`)
wirft(() => anzahlungPosition(AB, { betragCents: 80001 }, 0), 400, `Die Anzahlung darf höchstens 800,00${NBSP}€ betragen (Summe der Auftragsbestätigung minus bereits berechnet).`)
wirft(() => anzahlungPosition(AB, {}, 80000), 409, 'Nichts mehr offen.')
wirft(() => anzahlungPosition(AB, {}, 90000), 409, 'Nichts mehr offen.')
})
test('anzahlungPosition: ungültige Angaben', () => {
wirft(() => anzahlungPosition(AB, { prozent: 30, betragCents: 100 }, 0), 400, 'Anzahlung entweder in Prozent oder als Betrag angeben.')
for (const prozent of [0, -5, 100.5, Number.NaN, '50', Infinity]) wirft(() => anzahlungPosition(AB, { prozent }, 0), 400, 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.')
for (const betragCents of [0, -5, 12.5, '100', Number.NaN]) wirft(() => anzahlungPosition(AB, { betragCents }, 0), 400, 'Der Betrag muss eine ganze Zahl in Cent ab 1 sein.')
wirft(() => anzahlungPosition(AB, { prozent: 0.0001 }, 0), 400, 'Der Betrag muss mindestens 0,01 € betragen.')
for (const bezeichnung of ['', ' ', 'B'.repeat(61), 7]) wirft(() => anzahlungPosition(AB, { bezeichnung }, 0), 400, 'Die Bezeichnung braucht 1 bis 60 Zeichen.')
})
const FRUEH7 = { invoiceId: 'in_7', rechnungsnummer: 'WEBKLAR-0007', datum: '2026-10-08', betragCents: 40000, art: 'anzahlung' }
test('schlussPositionen: alle AB-Positionen, Zusatz, dann je frühere Rechnung eine Minuszeile (negativer Betrag, Menge 1)', () => {
assert.deepEqual(schlussPositionen(AB, [FRUEH7], [{ description: 'Domain-Registrierung', quantity: 1, unitAmountCents: 1500 }]), [
{ schluessel: 'ab-1', beschreibung: 'Website', menge: 1, einzelpreisCents: 60000, betragCents: 60000 },
{ schluessel: 'ab-2', beschreibung: 'Texte', menge: 2, einzelpreisCents: 10000, betragCents: 20000 },
{ schluessel: 'zusatz-1', beschreibung: 'Domain-Registrierung', menge: 1, einzelpreisCents: 1500, betragCents: 1500 },
{ schluessel: 'abzug-in_7', beschreibung: 'abzüglich Anzahlung, Rechnung WEBKLAR-0007 vom 08.10.2026', menge: 1, einzelpreisCents: -40000, betragCents: -40000 },
])
})
test('schlussPositionen: ohne frühere Rechnungen nur AB-Positionen; Teilrechnung für andere Arten; Abzüge nach Datum; Summe 0 ohne Zeile', () => {
assert.deepEqual(schlussPositionen(AB, [], []).map((p) => p.schluessel), ['ab-1', 'ab-2'])
assert.deepEqual(schlussPositionen(AB, []).map((p) => p.schluessel), ['ab-1', 'ab-2'])
const spaeter = { invoiceId: 'in_9', rechnungsnummer: 'WEBKLAR-0009', datum: '2026-10-12', betragCents: 10000, art: 'festbetrag' }
const ohneBetrag = { ...FRUEH7, invoiceId: 'in_0', betragCents: 0 }
assert.deepEqual(schlussPositionen(AB, [spaeter, ohneBetrag, FRUEH7]).slice(2).map((p) => [p.schluessel, p.beschreibung, p.betragCents]), [
['abzug-in_7', 'abzüglich Anzahlung, Rechnung WEBKLAR-0007 vom 08.10.2026', -40000],
['abzug-in_9', 'abzüglich Teilrechnung, Rechnung WEBKLAR-0009 vom 12.10.2026', -10000],
])
})
test('schlussPositionen: Summe nie 0 oder darunter (409), nie über 99.999.999 (400)', () => {
wirft(() => schlussPositionen(AB, [{ ...FRUEH7, betragCents: 80000 }]), 409, 'Nichts mehr offen.')
wirft(() => schlussPositionen(AB, [{ ...FRUEH7, betragCents: 90000 }]), 409, 'Nichts mehr offen.')
assert.equal(schlussPositionen(AB, [{ ...FRUEH7, betragCents: 79999 }]).reduce((s, p) => s + p.betragCents, 0), 1)
const gross = { ...AB, positionen: Array.from({ length: 10 }, (_, i) => ({ beschreibung: `P${i}`, menge: 1, einzelpreisCents: 10_000_000 })) }
wirft(() => schlussPositionen(gross, []), 400, 'Rechnungssumme zu hoch.')
})
test('schlussPositionen: bis zu 40 Zeilen (20 AB-Positionen, 10 Nachträge, 10 Abzüge), jede Gruppe mit eigener Grenze und Meldung', () => {
assert.deepEqual(SCHLUSS_GRENZEN, { abPositionen: 20, zusatz: 10, abzuege: 10, zeilen: 40 })
const abMit = (n) => ({ ...AB, summeCents: n * 1000, positionen: Array.from({ length: n }, (_, i) => ({ beschreibung: `Pos ${i + 1}`, menge: 1, einzelpreisCents: 1000 })) })
const abzug = (n) => ({ invoiceId: `in_k${String(n).padStart(2, '0')}`, rechnungsnummer: `WK-${String(n).padStart(4, '0')}`, datum: '2026-10-08', betragCents: 100, art: 'anzahlung' })
const abzuege = (n) => Array.from({ length: n }, (_, i) => abzug(i + 1))
const nachtraege = (n) => Array.from({ length: n }, (_, i) => ({ description: `Nachtrag ${i + 1}`, quantity: 1, unitAmountCents: 500 }))
// 20 AB-Positionen und ein Abzug: gültig (21 Zeilen)
assert.equal(schlussPositionen(abMit(20), abzuege(1)).length, 21)
// volle Ausbaustufe: 20 + 10 + 10 = 40 Zeilen, Reihenfolge AB, Nachträge, Abzüge
const voll = schlussPositionen(abMit(20), abzuege(10), nachtraege(10))
assert.equal(voll.length, 40)
assert.deepEqual([0, 19, 20, 29, 30, 39].map((i) => voll[i].schluessel), ['ab-1', 'ab-20', 'zusatz-1', 'zusatz-10', 'abzug-in_k01', 'abzug-in_k10'])
assert.equal(voll.reduce((s, p) => s + p.betragCents, 0), 20000 + 5000 - 1000)
// jede Gruppe über ihrer Grenze: 400 mit eigener Meldung
wirft(() => schlussPositionen(abMit(20), abzuege(11)), 400, 'Die Schlussrechnung hätte 11 Abzüge für frühere Rechnungen; erlaubt sind höchstens 10.')
wirft(() => schlussPositionen(abMit(20), [], nachtraege(11)), 400, 'Eine Schlussrechnung erlaubt höchstens 10 Zusatzpositionen.')
wirft(() => schlussPositionen(abMit(21), []), 400, 'Die Auftragsbestätigung hat 21 Positionen; eine Schlussrechnung übernimmt höchstens 20.')
// andere Eingaben für die Zusatzpositionen prüft zusatzPositionen wie sonst
wirft(() => schlussPositionen(AB, [], 'Domain'), 400, ANZAHL)
})