From 2d91a54e4a648246649518613cd2c19634f49a65 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:12:52 +0000 Subject: [PATCH] Rechnungen: Anzahlung und Schlussrechnung mit Minuszeilen (Positionen) Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/positionen.js | 90 +++++++++++++++++++- test/auftraegePositionen.test.js | 106 ++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) diff --git a/server/services/auftraege/positionen.js b/server/services/auftraege/positionen.js index b83be80..cda261b 100644 --- a/server/services/auftraege/positionen.js +++ b/server/services/auftraege/positionen.js @@ -5,7 +5,7 @@ */ import { AuftragError } from './fehler.js' import { LIMITS } from './konstanten.js' -import { formatDe } from '../finance/dates.js' +import { berlinDate, formatDe } from '../finance/dates.js' import { euroDe } from '../finance/format.js' const TAG_MS = 86_400_000 @@ -193,3 +193,91 @@ export function aufwandPositionen(blaetter, { satzCents, zeilentexte = {}, maxZe bis: tage.at(-1).datum, } } + +/** Berliner Tag des Sendens der AB, sonst der Tag der Absprache (YYYY-MM-DD). */ +export function auftragDatum(auftrag) { + if (auftrag.gesendetAm) { + const zeitpunkt = new Date(auftrag.gesendetAm) + if (!Number.isNaN(zeitpunkt.getTime())) return berlinDate(zeitpunkt) + } + return auftrag.auftragVom || '' +} + +const angegeben = (wert) => wert !== undefined && wert !== null + +/** Anzahlungszeile zu einer AB: Prozent oder Betrag, hoechstens Summe minus bereits Berechnetes. */ +export function anzahlungPosition(auftrag, angaben = {}, bereitsBerechnetCents = 0) { + const { prozent: prozentEingabe, betragCents: betragEingabe, bezeichnung: bezeichnungEingabe = 'Anzahlung' } = angaben || {} + const bezeichnung = typeof bezeichnungEingabe === 'string' ? bezeichnungEingabe.trim() : '' + if (bezeichnung.length < 1 || bezeichnung.length > 60) throw new AuftragError(400, 'Die Bezeichnung braucht 1 bis 60 Zeichen.') + if (angegeben(prozentEingabe) && angegeben(betragEingabe)) { + throw new AuftragError(400, 'Anzahlung entweder in Prozent oder als Betrag angeben.') + } + let prozent = null + let betrag + if (angegeben(betragEingabe)) { + if (!Number.isSafeInteger(betragEingabe) || betragEingabe < 1) { + throw new AuftragError(400, 'Der Betrag muss eine ganze Zahl in Cent ab 1 sein.') + } + betrag = betragEingabe + } else { + prozent = angegeben(prozentEingabe) ? prozentEingabe + : auftrag.zahlungsplan?.art === 'anzahlung' && angegeben(auftrag.zahlungsplan.prozent) ? auftrag.zahlungsplan.prozent + : LIMITS.standardAnzahlungProzent + if (typeof prozent !== 'number' || !Number.isFinite(prozent) || prozent <= 0 || prozent > 100) { + throw new AuftragError(400, 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.') + } + betrag = Math.round((auftrag.summeCents * prozent) / 100) + if (betrag < 1) throw new AuftragError(400, 'Der Betrag muss mindestens 0,01 € betragen.') + } + const rest = auftrag.summeCents - bereitsBerechnetCents + if (rest <= 0) throw new AuftragError(409, 'Nichts mehr offen.') + if (betrag > rest) { + throw new AuftragError(400, `Die Anzahlung darf höchstens ${euroDe(rest)} betragen (Summe der Auftragsbestätigung minus bereits berechnet).`) + } + const prozentTeil = prozent === null ? '' : ` ${String(prozent).replace('.', ',')} %` + const davor = `${bezeichnung}${prozentTeil} gemäß Auftragsbestätigung ${auftrag.nummer} vom ${formatDe(auftragDatum(auftrag))}` + const titel = typeof auftrag.titel === 'string' ? auftrag.titel.trim() : '' + const beschreibung = titel ? `${davor} (${kuerzen(titel, LIMITS.maxBeschreibung - davor.length - 3)})` : davor + return { schluessel: 'anzahlung', beschreibung, menge: 1, einzelpreisCents: betrag, betragCents: betrag } +} + +// Obergrenzen einer Schlussrechnung (Uebersicht, Nachtraege nach der Planrunde); alle anderen Rechnungen: LIMITS.maxPositionen +export const SCHLUSS_GRENZEN = { abPositionen: 20, zusatz: 10, abzuege: 10, zeilen: 40 } + +/** Schlussrechnung: alle AB-Positionen, Nachtraege, je fruehere Rechnung eine Minuszeile. */ +export function schlussPositionen(auftrag, fruehere, zusatz = []) { + const positionenAb = auftrag.positionen || [] + if (positionenAb.length > SCHLUSS_GRENZEN.abPositionen) { + throw new AuftragError(400, `Die Auftragsbestätigung hat ${positionenAb.length} Positionen; eine Schlussrechnung übernimmt höchstens ${SCHLUSS_GRENZEN.abPositionen}.`) + } + if (Array.isArray(zusatz) && zusatz.length > SCHLUSS_GRENZEN.zusatz) { + throw new AuftragError(400, `Eine Schlussrechnung erlaubt höchstens ${SCHLUSS_GRENZEN.zusatz} Zusatzpositionen.`) + } + const abzuege = (fruehere || []).filter((f) => f.betragCents > 0) + if (abzuege.length > SCHLUSS_GRENZEN.abzuege) { + throw new AuftragError(400, `Die Schlussrechnung hätte ${abzuege.length} Abzüge für frühere Rechnungen; erlaubt sind höchstens ${SCHLUSS_GRENZEN.abzuege}.`) + } + const zeilen = positionenAb.map((p, i) => ({ + schluessel: `ab-${i + 1}`, + beschreibung: p.beschreibung, + menge: p.menge, + einzelpreisCents: p.einzelpreisCents, + betragCents: p.menge * p.einzelpreisCents, + })) + zeilen.push(...zusatzPositionen(zusatz)) + const sortiert = [...abzuege].sort((a, b) => a.datum.localeCompare(b.datum) || a.invoiceId.localeCompare(b.invoiceId)) + for (const f of sortiert) { + zeilen.push({ + schluessel: `abzug-${f.invoiceId}`, + beschreibung: `abzüglich ${f.art === 'anzahlung' ? 'Anzahlung' : 'Teilrechnung'}, Rechnung ${f.rechnungsnummer} vom ${formatDe(f.datum)}`, + menge: 1, + einzelpreisCents: -f.betragCents, + betragCents: -f.betragCents, + }) + } + const summe = zeilen.reduce((s, p) => s + p.betragCents, 0) + if (summe > LIMITS.maxSummeCents) throw new AuftragError(400, 'Rechnungssumme zu hoch.') + if (summe <= 0) throw new AuftragError(409, 'Nichts mehr offen.') + return zeilen +} diff --git a/test/auftraegePositionen.test.js b/test/auftraegePositionen.test.js index ca90f10..490cd48 100644 --- a/test/auftraegePositionen.test.js +++ b/test/auftraegePositionen.test.js @@ -3,6 +3,7 @@ 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? */ @@ -195,3 +196,108 @@ test('aufwandPositionen: ungültiger Satz, leere Auswahl, kaputte Blätter', () 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) +})