diff --git a/server/services/auftraege/positionen.js b/server/services/auftraege/positionen.js new file mode 100644 index 0000000..bcbca49 --- /dev/null +++ b/server/services/auftraege/positionen.js @@ -0,0 +1,113 @@ +/** + * Rechnungszeilen je Art (rein, ohne Stripe und Appwrite): feste Positionen, Aufwand je Tag oder Kalenderwoche, + * Anzahlung und Schlussrechnung mit Minuszeilen. Betraege sind ganze Cent. + * Importzyklus (siehe Plan L2): zur Modulauswertung nichts aus fehler.js benutzen, nur innerhalb von Funktionen. + */ +import { AuftragError } from './fehler.js' +import { LIMITS } from './konstanten.js' +import { formatDe } from '../finance/dates.js' + +const TAG_MS = 86_400_000 + +const istGanzzahlZwischen = (wert, min, max) => Number.isSafeInteger(wert) && wert >= min && wert <= max + +/** Text auf hoechstens max Zeichen kuerzen (Auslassungszeichen am Ende, kein halbes Emoji). */ +export function kuerzen(text, max) { + if (text.length <= max) return text + let teil = text.slice(0, max - 1) + const letzter = teil.charCodeAt(teil.length - 1) + if (letzter >= 0xd800 && letzter <= 0xdbff) teil = teil.slice(0, -1) + return `${teil.trimEnd()}…` +} + +/** Minuten als Stunden mit Komma, ohne Endnullen: 135 -> "2,25", 390 -> "6,5", 60 -> "1". */ +export function stundenText(minuten) { + return String(Math.round((minuten / 60) * 100) / 100).replace('.', ',') +} + +/** Nur ein String YYYY-MM-DD, den es als Tag gibt. */ +export function gueltigerTag(ymd) { + const treffer = typeof ymd === 'string' ? ymd.match(/^(\d{4})-(\d{2})-(\d{2})$/) : null + if (!treffer) return false + const [j, m, t] = treffer.slice(1).map(Number) + const pruef = new Date(Date.UTC(j, m - 1, t)) + return pruef.getUTCFullYear() === j && pruef.getUTCMonth() === m - 1 && pruef.getUTCDate() === t +} + +/** ISO-Woche samt ISO-Jahr; wirft RangeError bei ungueltigem Tag. */ +export function kalenderwoche(ymd) { + if (!gueltigerTag(ymd)) throw new RangeError(`Ungültiges Datum: ${typeof ymd === 'string' ? ymd : typeof ymd}`) + const tag = new Date(`${ymd}T00:00:00Z`) + const wochentag = (tag.getUTCDay() + 6) % 7 // Montag = 0 + const donnerstag = new Date(tag.getTime() + (3 - wochentag) * TAG_MS) + const jahr = donnerstag.getUTCFullYear() + const woche = Math.floor((donnerstag.getTime() - Date.UTC(jahr, 0, 1)) / TAG_MS / 7) + 1 + return { jahr, woche } +} + +/** "06.10.2026" | "01.10.–20.10.2026" | "28.12.2026–05.01.2027" (Halbgeviertstrich ohne Leerzeichen). */ +export function leistungszeitraumText(von, bis) { + if (bis < von) throw new RangeError('Das Ende liegt vor dem Beginn.') + if (von === bis) return formatDe(von) + if (von.slice(0, 4) === bis.slice(0, 4)) return `${formatDe(von).slice(0, 6)}–${formatDe(bis)}` + return `${formatDe(von)}–${formatDe(bis)}` +} + +/** Gemeinsamer Pruefer fuer feste und Zusatzpositionen (Eingabe { description, quantity, unitAmountCents }). */ +function pruefeEingabePositionen(items, { leerErlaubt, praefix }) { + const leer = items === undefined || items === null + if (leer && leerErlaubt) return [] + if (!Array.isArray(items) || items.length > LIMITS.maxPositionen || (items.length < 1 && !leerErlaubt)) { + throw new AuftragError(400, `Eine Rechnung braucht 1 bis ${LIMITS.maxPositionen} Positionen.`) + } + const positionen = items.map((item, index) => { + const label = `Position ${index + 1}` + const beschreibung = typeof item?.description === 'string' ? item.description.trim() : '' + if (beschreibung.length < 1 || beschreibung.length > LIMITS.maxBeschreibung) { + throw new AuftragError(400, `${label}: Die Beschreibung braucht 1 bis ${LIMITS.maxBeschreibung} Zeichen.`) + } + if (!istGanzzahlZwischen(item.quantity, 1, LIMITS.maxMenge)) { + throw new AuftragError(400, `${label}: Die Menge muss eine ganze Zahl von 1 bis ${LIMITS.maxMenge} sein.`) + } + if (!istGanzzahlZwischen(item.unitAmountCents, 1, LIMITS.maxEinzelpreisCents)) { + throw new AuftragError(400, `${label}: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`) + } + return { + schluessel: `${praefix}-${index + 1}`, + beschreibung, + menge: item.quantity, + einzelpreisCents: item.unitAmountCents, + betragCents: item.quantity * item.unitAmountCents, + } + }) + if (positionen.reduce((summe, p) => summe + p.betragCents, 0) > LIMITS.maxSummeCents) { + throw new AuftragError(400, 'Rechnungssumme zu hoch.') + } + return positionen +} + +/** Feste Positionen einer freien Rechnung oder eines Festbetrags: 1 bis 20, Schluessel pos-. */ +export function festePositionen(items) { + return pruefeEingabePositionen(items, { leerErlaubt: false, praefix: 'pos' }) +} + +/** Zusatzpositionen (Nachtraege einer Schlussrechnung): wie festePositionen, aber leer erlaubt, Schluessel zusatz-. */ +export function zusatzPositionen(items) { + return pruefeEingabePositionen(items, { leerErlaubt: true, praefix: 'zusatz' }) +} + +/** Prueft fertige Zeilen (auch Minuszeilen): Zahl 1 bis max, Beschreibungen, Summe; liefert die Summe in Cent. */ +export function pruefePositionen(positionen, { max = LIMITS.maxPositionen } = {}) { + if (!Array.isArray(positionen) || positionen.length < 1 || positionen.length > max) { + throw new AuftragError(400, `Eine Rechnung braucht 1 bis ${max} Positionen.`) + } + positionen.forEach((p, index) => { + const laenge = typeof p.beschreibung === 'string' ? p.beschreibung.length : 0 + if (laenge < 1 || laenge > LIMITS.maxBeschreibung) { + throw new AuftragError(400, `Position ${index + 1}: Die Beschreibung braucht 1 bis ${LIMITS.maxBeschreibung} Zeichen.`) + } + }) + const summe = positionen.reduce((s, p) => s + p.betragCents, 0) + if (summe > LIMITS.maxSummeCents) throw new AuftragError(400, 'Rechnungssumme zu hoch.') + return summe +} diff --git a/test/auftraegePositionen.test.js b/test/auftraegePositionen.test.js new file mode 100644 index 0000000..4504892 --- /dev/null +++ b/test/auftraegePositionen.test.js @@ -0,0 +1,93 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + stundenText, leistungszeitraumText, kalenderwoche, gueltigerTag, kuerzen, + festePositionen, zusatzPositionen, pruefePositionen, +} 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.') +})