304 lines
23 KiB
JavaScript
304 lines
23 KiB
JavaScript
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)
|
||
})
|