From 75532537469ed5e2c4fe334dafe120f6814d6235 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:12:07 +0000 Subject: [PATCH 01/12] =?UTF-8?q?Rechnungen:=20Positionen-Grundlagen=20(St?= =?UTF-8?q?undentext,=20Kalenderwoche,=20feste=20Positionen,=20Pr=C3=BCfun?= =?UTF-8?q?g)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/positionen.js | 113 ++++++++++++++++++++++++ test/auftraegePositionen.test.js | 93 +++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 server/services/auftraege/positionen.js create mode 100644 test/auftraegePositionen.test.js 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.') +}) From 6c1eaf697bb09b533fd389c9a8111f5188f3a6cc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:12:28 +0000 Subject: [PATCH 02/12] Rechnungen: Aufwandszeilen je Tag, Kalenderwochen-Zusammenfassung ab 20 Zeilen Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/positionen.js | 82 ++++++++++++++++++ test/auftraegePositionen.test.js | 106 +++++++++++++++++++++++- 2 files changed, 187 insertions(+), 1 deletion(-) diff --git a/server/services/auftraege/positionen.js b/server/services/auftraege/positionen.js index bcbca49..b83be80 100644 --- a/server/services/auftraege/positionen.js +++ b/server/services/auftraege/positionen.js @@ -6,6 +6,7 @@ import { AuftragError } from './fehler.js' import { LIMITS } from './konstanten.js' import { formatDe } from '../finance/dates.js' +import { euroDe } from '../finance/format.js' const TAG_MS = 86_400_000 @@ -111,3 +112,84 @@ export function pruefePositionen(positionen, { max = LIMITS.maxPositionen } = {} if (summe > LIMITS.maxSummeCents) throw new AuftragError(400, 'Rechnungssumme zu hoch.') return summe } + +const vereinheitlicht = (text) => (typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : '') + +/** Eine Zeile (Tag oder Woche) mit Standardtext oder ersetztem Text, Stunden und Satz; nie ueber 200 Zeichen. */ +function aufwandZeile(einheit, satzCents, zeilentexte) { + const eigen = zeilentexte && Object.hasOwn(zeilentexte, einheit.schluessel) ? vereinheitlicht(zeilentexte[einheit.schluessel]) : '' + const standard = `${einheit.kopf}: ${einheit.texte.length ? einheit.texte.join('; ') : 'Arbeitszeit'}` + const schluss = ` – ${stundenText(einheit.minuten)} Std. à ${euroDe(satzCents)}` + const betragCents = Math.round((einheit.minuten * satzCents) / 60) + return { + schluessel: einheit.schluessel, + beschreibung: `${kuerzen(eigen || standard, LIMITS.maxBeschreibung - schluss.length)}${schluss}`, + menge: 1, + einzelpreisCents: betragCents, + betragCents, + } +} + +/** + * Aufwandszeilen: eine Zeile je Tag; gibt es mehr Tage als maxZeilen, werden die aeltesten Kalenderwochen zu + * KW-Zeilen zusammengefasst. zeilentexte[schluessel] ersetzt den Text vor dem Gedankenstrich. + */ +export function aufwandPositionen(blaetter, { satzCents, zeilentexte = {}, maxZeilen = LIMITS.maxPositionen } = {}) { + if (!blaetter.length) return { positionen: [], minuten: 0, von: null, bis: null } + if (!istGanzzahlZwischen(satzCents, 1, LIMITS.maxEinzelpreisCents)) throw new AuftragError(400, 'Der Stundensatz ist ungültig.') + for (const blatt of blaetter) { + if (!gueltigerTag(blatt.datum) || !Number.isSafeInteger(blatt.minuten) || blatt.minuten <= 0) { + throw new AuftragError(400, 'Ein Arbeitsblatt hat kein gültiges Datum oder keine Zeit.') + } + } + const sortiert = [...blaetter].sort((a, b) => a.datum.localeCompare(b.datum) || String(a.id).localeCompare(String(b.id))) + const tage = [] + for (const blatt of sortiert) { + let tag = tage.at(-1) + if (!tag || tag.datum !== blatt.datum) { + tag = { datum: blatt.datum, minuten: 0, texte: [] } + tage.push(tag) + } + tag.minuten += blatt.minuten + const text = vereinheitlicht(blatt.text) + if (text && !tag.texte.includes(text)) tag.texte.push(text) + } + const tagesEinheit = (tag) => ({ schluessel: tag.datum, kopf: formatDe(tag.datum), minuten: tag.minuten, texte: tag.texte }) + const wochenEinheit = (woche) => { + const texte = [] + for (const tag of woche.tage) for (const text of tag.texte) if (!texte.includes(text)) texte.push(text) + const ww = String(woche.woche).padStart(2, '0') + return { schluessel: `KW-${woche.jahr}-${ww}`, kopf: `KW ${ww}/${woche.jahr}`, minuten: woche.tage.reduce((s, t) => s + t.minuten, 0), texte } + } + const einheiten = [] + if (tage.length <= maxZeilen) { + einheiten.push(...tage.map(tagesEinheit)) + } else { + const wochen = [] + for (const tag of tage) { + const kw = kalenderwoche(tag.datum) + let woche = wochen.find((w) => w.jahr === kw.jahr && w.woche === kw.woche) + if (!woche) { + woche = { ...kw, tage: [] } + wochen.push(woche) + } + woche.tage.push(tag) + } + let anzahl = tage.length + for (const woche of wochen) { + if (anzahl > maxZeilen && woche.tage.length > 1) { + anzahl -= woche.tage.length - 1 + einheiten.push(wochenEinheit(woche)) + } else einheiten.push(...woche.tage.map(tagesEinheit)) + } + if (anzahl > maxZeilen) { + throw new AuftragError(400, `Zu viele Arbeitstage für eine Rechnung (auch nach Kalenderwochen mehr als ${maxZeilen} Zeilen). Bitte weniger Arbeitsblätter auswählen.`) + } + } + return { + positionen: einheiten.map((einheit) => aufwandZeile(einheit, satzCents, zeilentexte)), + minuten: tage.reduce((s, t) => s + t.minuten, 0), + von: tage[0].datum, + bis: tage.at(-1).datum, + } +} diff --git a/test/auftraegePositionen.test.js b/test/auftraegePositionen.test.js index 4504892..ca90f10 100644 --- a/test/auftraegePositionen.test.js +++ b/test/auftraegePositionen.test.js @@ -2,7 +2,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { stundenText, leistungszeitraumText, kalenderwoche, gueltigerTag, kuerzen, - festePositionen, zusatzPositionen, pruefePositionen, + festePositionen, zusatzPositionen, pruefePositionen, aufwandPositionen, } from '../server/services/auftraege/positionen.js' /** Wirft die Funktion einen AuftragError mit diesem Status und dieser Meldung? */ @@ -91,3 +91,107 @@ test('zusatzPositionen: leer erlaubt, Schlüssel zusatz-n, gleiche Prüfungen; p 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.') + } +}) From 2d91a54e4a648246649518613cd2c19634f49a65 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:12:52 +0000 Subject: [PATCH 03/12] 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) +}) From fd189b085d3456cd9d7cb17cd4694bfa797a1201 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:13:17 +0000 Subject: [PATCH 04/12] =?UTF-8?q?Stripe-Nachbau:=20Zusatzfelder,=20Seiteng?= =?UTF-8?q?r=C3=B6=C3=9Fe=20und=20Minuszeilen=20wie=20bei=20Stripe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- test/auftraegeFakeStripe.test.js | 52 ++++++++++++++++++++++++++++++++ test/support/fakeStripe.js | 26 +++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 test/auftraegeFakeStripe.test.js diff --git a/test/auftraegeFakeStripe.test.js b/test/auftraegeFakeStripe.test.js new file mode 100644 index 0000000..01cf13f --- /dev/null +++ b/test/auftraegeFakeStripe.test.js @@ -0,0 +1,52 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' + +const aw = await startAppwriteMock({ schema: {} }) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { getStripe } = await import('../server/services/stripeBilling.js') +after(() => Promise.all([aw.close(), st.close()])) + +const refused = (promise, pattern) => assert.rejects(promise, (err) => err.statusCode === 400 && pattern.test(err.message)) +const create = (extra = {}) => getStripe().invoices.create({ customer: 'cus_x', collection_method: 'send_invoice', days_until_due: 14, ...extra }) +const item = (invoice, extra) => getStripe().invoiceItems.create({ customer: 'cus_x', invoice, description: 'Posten', currency: 'eur', ...extra }) + +test('Stripe-Nachbau: custom_fields und rendering werden gespeichert und mit der Rechnung geliefert', async () => { + const felder = [{ name: 'Projekt', value: 'Website anelia-wolf.com' }, { name: 'Auftrag', value: 'AB-2026-0002 vom 06.10.2026' }] + const invoice = await create({ custom_fields: felder, rendering: { pdf: { page_size: 'a4' } } }) + assert.deepEqual(st.invoices.get(invoice.id).custom_fields, felder) + assert.deepEqual(st.invoices.get(invoice.id).rendering, { pdf: { page_size: 'a4' } }) + const gelesen = await getStripe().invoices.retrieve(invoice.id) + assert.deepEqual(gelesen.custom_fields, felder) + assert.deepEqual(gelesen.rendering, { pdf: { page_size: 'a4' } }) + await item(invoice.id, { quantity: 1, unit_amount_decimal: '1000' }) + assert.deepEqual(st.invoices.get(invoice.id).custom_fields, felder, 'Posten ändern die Zusatzfelder nicht') + const schlicht = await create() + assert.equal(st.invoices.get(schlicht.id).custom_fields, null) + assert.equal(st.invoices.get(schlicht.id).rendering, null) +}) + +test('Stripe-Nachbau: Grenzen der Zusatzfelder (4, Name 40, Wert 140) und der Seitengröße wie bei Stripe', async () => { + const viele = Array.from({ length: 5 }, (_, i) => ({ name: `Feld ${i}`, value: 'x' })) + await refused(create({ custom_fields: viele }), /up to 4 custom fields/) + await refused(create({ custom_fields: [{ name: 'n'.repeat(41), value: 'x' }] }), /names must be between 1 and 40/) + await refused(create({ custom_fields: [{ name: 'n', value: 'v'.repeat(141) }] }), /values must be between 1 and 140/) + await refused(create({ rendering: { pdf: { page_size: 'din-a4' } } }), /page_size/) + const vier = await create({ custom_fields: viele.slice(0, 4).map((f) => ({ ...f, value: 'v'.repeat(140) })), rendering: { pdf: { page_size: 'letter' } } }) + assert.equal(st.invoices.get(vier.id).custom_fields.length, 4) +}) + +test('Stripe-Nachbau: Posten mit negativem amount (ohne quantity) mindern die Summe; amount mit quantity oder Einzelpreis wird abgelehnt', async () => { + const invoice = await create() + await item(invoice.id, { description: 'Leistung', quantity: 1, unit_amount_decimal: '40000' }) + await item(invoice.id, { description: 'abzüglich Anzahlung', amount: -15000 }) + const gespeichert = st.invoices.get(invoice.id) + assert.deepEqual(gespeichert.lines.data.map((l) => [l.description, l.quantity, l.amount]), [['Leistung', 1, 40000], ['abzüglich Anzahlung', 1, -15000]]) + assert.deepEqual([gespeichert.total, gespeichert.amount_due], [25000, 25000]) + await refused(item(invoice.id, { amount: -100, quantity: 2 }), /only specify one of these parameters/) + await refused(item(invoice.id, { amount: -100, unit_amount_decimal: '100' }), /only specify one of these parameters/) + assert.equal(st.invoices.get(invoice.id).total, 25000) +}) diff --git a/test/support/fakeStripe.js b/test/support/fakeStripe.js index 0df7463..6ad8177 100644 --- a/test/support/fakeStripe.js +++ b/test/support/fakeStripe.js @@ -26,7 +26,10 @@ import http from 'node:http' * Wert loescht), .../finalize (nur draft -> open, vergibt die Nummer WK-0001, WK-0002, ... und die Faelligkeit), * .../send (nur open), .../void (nur open -> void), .../pay (nur open -> paid, auch paid_out_of_band; wie die echte API * liefert die Rechnung danach kein Feld paid_out_of_band, nur der Aufruf in calls zeigt es) und - * DELETE /v1/invoices/:id (nur draft); jeder andere Zustand ist wie bei Stripe ein 400. Der Kunde einer neuen + * DELETE /v1/invoices/:id (nur draft); jeder andere Zustand ist wie bei Stripe ein 400. + * custom_fields (hoechstens 4, Name 1 bis 40, Wert 1 bis 140 Zeichen) und rendering[pdf][page_size] (a4, auto, letter) werden wie + * bei Stripe geprueft, gespeichert (sonst null) und mit der Rechnung geliefert; Rechnungsposten mit amount (auch negativ) + * tragen weder quantity noch unit_amount_decimal. Der Kunde einer neuen * Rechnung muss nicht in st.customers stehen (dann bleiben Name und E-Mail leer). Traegt ein Aufruf die Kopfzeile * Idempotency-Key, steht sie als idempotencyKey in calls; delayNext verzoegert eine Antwort (Zeitgrenzen testen). * Kunden: GET /v1/customers/search (Metadaten), POST /v1/customers (anlegen) und POST /v1/customers/:id (aendern: @@ -461,6 +464,22 @@ export async function startFakeStripe() { } const metadata = mergeMetadata({}, data.metadata) if (Array.isArray(metadata)) return metadata + const customFields = data.custom_fields + if (customFields !== undefined) { + if (!Array.isArray(customFields) || customFields.length > 4) return invalid('custom_fields', 'You can specify up to 4 custom fields.') + for (const [i, field] of customFields.entries()) { + if (typeof field?.name !== 'string' || field.name.length < 1 || field.name.length > 40) { + return invalid(`custom_fields[${i}][name]`, 'Custom field names must be between 1 and 40 characters.') + } + if (typeof field.value !== 'string' || field.value.length < 1 || field.value.length > 140) { + return invalid(`custom_fields[${i}][value]`, 'Custom field values must be between 1 and 140 characters.') + } + } + } + const pageSize = data.rendering?.pdf?.page_size + if (pageSize !== undefined && !['a4', 'auto', 'letter'].includes(pageSize)) { + return invalid('rendering[pdf][page_size]', 'Invalid page_size: must be one of a4, auto, letter.') + } const customer = customers.get(data.customer) const invoice = { id: nextId('in'), @@ -479,6 +498,8 @@ export async function startFakeStripe() { description: data.description ?? null, footer: data.footer ?? null, metadata, + custom_fields: customFields ?? null, + rendering: data.rendering ?? null, created: nowUnix(), total: 0, subtotal: 0, @@ -496,6 +517,9 @@ export async function startFakeStripe() { if (method === 'POST' && path === '/v1/invoiceitems') { const data = unflatten(body) if (!data.customer) return invalid('customer', 'Missing required param: customer.') + if (data.amount !== undefined && (data.quantity !== undefined || data.unit_amount_decimal !== undefined)) { + return invalid('amount', 'You may only specify one of these parameters: amount, or quantity with unit_amount_decimal.') + } const quantity = data.quantity !== undefined ? Number(data.quantity) : 1 if (!Number.isInteger(quantity) || quantity < 0) return invalid('quantity', 'Invalid non-negative integer: quantity.') let unit = null From b78ca971af288efec1f20917df4f13b156e6b313 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:13:52 +0000 Subject: [PATCH 05/12] =?UTF-8?q?Rechnungen:=20baueRechnung=20f=C3=BCr=20f?= =?UTF-8?q?reie=20Rechnung=20und=20Festbetrag,=20Testhilfe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/rechnungsbau.js | 98 +++++++++++++++++++ test/auftraegeRechnungenHilfe.js | 100 +++++++++++++++++++ test/auftraegeRechnungsbau.test.js | 114 ++++++++++++++++++++++ 3 files changed, 312 insertions(+) create mode 100644 server/services/auftraege/rechnungsbau.js create mode 100644 test/auftraegeRechnungenHilfe.js create mode 100644 test/auftraegeRechnungsbau.test.js diff --git a/server/services/auftraege/rechnungsbau.js b/server/services/auftraege/rechnungsbau.js new file mode 100644 index 0000000..6f0ae0c --- /dev/null +++ b/server/services/auftraege/rechnungsbau.js @@ -0,0 +1,98 @@ +/** + * Baut aus der Eingabe einer Projektrechnung den RechnungsEntwurf (Positionen, Zusatzfelder, Metadaten, Projektdatensatz). + * Lesezugriffe: Kunde, Projekt, AB, Arbeitsblaetter und fruehere Rechnungen; geschrieben wird hier nichts (ausser der + * Selbstheilung veralteter Projektdatensaetze, siehe unten). Importiert nichts aus stripeFinance.js (Importzyklus). + */ +import { config } from '../../config.js' +import { getDocument } from '../appwriteAdmin.js' +import { AuftragError } from './fehler.js' +import { RECHNUNGS_ARTEN, LIMITS } from './konstanten.js' +import { festePositionen, pruefePositionen, kuerzen, auftragDatum, SCHLUSS_GRENZEN } from './positionen.js' +import { formatDe } from '../finance/dates.js' + +// Appwrite-Dokument-ID +const KENNUNG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ + +/** Kunde aus Appwrite; nur feste Kunden (nicht lead/lost) bekommen Rechnungen. */ +export async function ladeFestenKunden(customerId) { + if (typeof customerId !== 'string' || !customerId || !KENNUNG.test(customerId)) throw new AuftragError(400, 'Kunde nicht gefunden.') + let kunde + try { + kunde = await getDocument(config.collections.customers, customerId) + } catch (err) { + if (err.status === 404) throw new AuftragError(400, 'Kunde nicht gefunden.') + throw err + } + const stufe = String(kunde.customerStatus || '').trim().toLowerCase() + if (stufe === 'lead' || stufe === 'lost') throw new AuftragError(400, 'Rechnungen nur für feste Kunden.') + return kunde +} + +/** Projekt des Kunden; fremde und unbekannte Projekte sind nicht zu unterscheiden (404). */ +async function ladeProjekt(kundenId, projektId) { + if (typeof projektId !== 'string' || !projektId) throw new AuftragError(400, 'Für diese Rechnungsart ist ein Projekt nötig.') + if (!KENNUNG.test(projektId)) throw new AuftragError(404, 'Projekt nicht gefunden.') + let projekt + try { + projekt = await getDocument(config.collections.websiteProjects, projektId) + } catch (err) { + if (err.status === 404) throw new AuftragError(404, 'Projekt nicht gefunden.') + throw err + } + if (projekt.customerId !== kundenId) throw new AuftragError(404, 'Projekt nicht gefunden.') + return { id: projekt.$id, name: projekt.projectName || projekt.subdomain || 'Projekt', ticketId: projekt.ticketId || '' } +} + +function pruefeLeistungszeitraum(wert) { + if (wert === undefined || wert === null) return '' + if (typeof wert !== 'string' || wert.trim().length > LIMITS.maxLeistungszeitraum) { + throw new AuftragError(400, `Der Leistungszeitraum muss ein Text mit höchstens ${LIMITS.maxLeistungszeitraum} Zeichen sein.`) + } + return wert.trim() +} + +/** Gemeinsamer Abschluss aller Arten: Grenzen, Zusatzfelder, Metadaten, Projektdatensatz. */ +function schliesseAb({ kunde, art, projekt, auftrag, positionen, leistungszeitraum, arbeitsblattIds = [], createdBy }) { + const summeCents = pruefePositionen(positionen, { max: art === 'schluss' ? SCHLUSS_GRENZEN.zeilen : LIMITS.maxPositionen }) + const projektId = projekt?.id ?? auftrag?.projektId ?? '' + const projektName = kuerzen(projekt?.name ?? auftrag?.titel ?? '', 140) + const customFields = [] + if (art !== 'frei' && projektName) customFields.push({ name: 'Projekt', value: projektName }) + if (auftrag) customFields.push({ name: 'Auftrag', value: `${auftrag.nummer} vom ${formatDe(auftragDatum(auftrag))}` }) + if (leistungszeitraum) customFields.push({ name: 'Leistungszeitraum', value: leistungszeitraum }) + const metadata = { webklarCustomerId: kunde.$id, quelle: 'woms', erstelltVon: String(createdBy || '').slice(0, 500), art } + if (projektId) metadata.projektId = projektId + if (projektName) metadata.projektName = projektName + if (auftrag) { + metadata.auftragId = auftrag.id + if (auftrag.nummer) metadata.auftragNummer = auftrag.nummer + } + const projektRechnung = art === 'frei' ? null : { + kundenId: kunde.$id, + projektId, + auftragId: auftrag?.id ?? '', + art, + betragCents: summeCents, + arbeitsblattIds: art === 'aufwand' ? [...arbeitsblattIds].sort() : [], + rechnungsnummer: '', + aktiv: true, + erstelltVon: String(createdBy || '').slice(0, 255), + erstelltAm: new Date().toISOString(), + } + return { kunde, art, projekt: projekt ?? null, auftrag: auftrag ?? null, positionen, summeCents, leistungszeitraum, customFields, metadata, projektRechnung } +} + +/** Baut den RechnungsEntwurf einer Rechnung der Art frei, festbetrag, aufwand, anzahlung oder schluss. */ +export async function baueRechnung(eingabe) { + const art = eingabe.art ?? 'frei' + if (typeof art !== 'string' || !RECHNUNGS_ARTEN.includes(art)) throw new AuftragError(400, 'Unbekannte Rechnungsart.') + const kunde = await ladeFestenKunden(eingabe.customerId) + const leistungszeitraum = pruefeLeistungszeitraum(eingabe.leistungszeitraum) + const basis = { kunde, art, leistungszeitraum, createdBy: eingabe.createdBy } + if (art === 'frei') return schliesseAb({ ...basis, positionen: festePositionen(eingabe.items) }) + if (art === 'festbetrag') { + const projekt = await ladeProjekt(kunde.$id, eingabe.projektId) + return schliesseAb({ ...basis, projekt, positionen: festePositionen(eingabe.items) }) + } + throw new AuftragError(501, 'Rechnungsart noch nicht umgesetzt.') +} diff --git a/test/auftraegeRechnungenHilfe.js b/test/auftraegeRechnungenHilfe.js new file mode 100644 index 0000000..b818327 --- /dev/null +++ b/test/auftraegeRechnungenHilfe.js @@ -0,0 +1,100 @@ +// Gemeinsame Testdaten für die Rechnungstests des Strangs L2. Erst nach useTestEnv() importieren (zieht config.js). +import { config } from '../server/config.js' +import { deleteDocument } from '../server/services/appwriteAdmin.js' +import { createAuftrag, updateAuftrag } from '../server/services/auftraege/auftragStore.js' +import { createProjektRechnung } from '../server/services/auftraege/projektRechnungenStore.js' + +/** n Arbeitstage (Montag bis Freitag) ab start (YYYY-MM-DD, einschließlich). */ +export function 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 +} + +const deutsch = (tag) => tag.split('-').reverse().join('.') + +/** + * Kunden k1 (fest, mit Anschrift: Frau Wolf), k2 (fest), lead1 (Interessent); Projekte p1 (k1, Ticket t1), p2 (k2, Ticket t2), + * p3 (k1, Ticket t4); Tickets t1 bis t4 (t2 gehört k2); Arbeitsblätter ws1 bis wsT3 (Oktober) und wl01 bis wl20 (Großprojekt). + */ +export function seedRechnungsDaten(aw) { + const kunde = (id, over = {}) => + aw.seed('customers', { $id: id, email: `${id}@example.invalid`, name: `Kunde ${id}`, customerStatus: 'active', status: 'active', street: 'Hauptstr. 1', postalCode: '10115', city: 'Berlin', ...over }) + kunde('k1', { name: 'Anelia Wolf', email: 'wolf@example.invalid' }) + kunde('k2') + kunde('lead1', { customerStatus: 'lead' }) + const projekt = (id, customerId, projectName, ticketId) => aw.seed('websiteProjects', { $id: id, customerId, projectName, ticketId }) + projekt('p1', 'k1', 'Website anelia-wolf.com', 't1') + projekt('p2', 'k2', 'Fremdes Projekt', 't2') + projekt('p3', 'k1', 'Großprojekt', 't4') + for (const [id, customerId, woid] of [['t1', 'k1', 'WO-100'], ['t2', 'k2', 'WO-200'], ['t3', 'k1', 'WO-101'], ['t4', 'k1', 'WO-102']]) { + aw.seed('workorders', { $id: id, customerId, woid, topic: `Ticket ${woid}`, status: 'occupied' }) + } + const blatt = (id, workorderId, startDate, totalTime, details, over = {}) => + aw.seed('worksheets', { $id: id, workorderId, woid: 'WO', startDate, totalTime, details, serviceType: 'remote', countsToPlan: false, isComment: false, ...over }) + blatt('ws1', 't1', '06.10.2026', 90, 'Startseite') + blatt('ws2', 't1', '06.10.2026', 45, 'Kontaktformular') + blatt('ws3', 't1', '07.10.2026', 60, 'Impressum') + blatt('wsAbo', 't1', '07.10.2026', 60, 'Wartung', { countsToPlan: true }) + blatt('wsKomm', 't1', '07.10.2026', 30, 'Notiz', { isComment: true }) + blatt('wsNull', 't1', '07.10.2026', 0, 'Leer') + blatt('wsFremd', 't2', '07.10.2026', 60, 'Fremd') + blatt('wsT3', 't3', '08.10.2026', 30, 'Zusatzticket') + // Großprojekt: 20 Arbeitstage (vier Wochen, KW 45 bis 48) zu je 60 Minuten + werktage('2026-11-02', 20).forEach((tag, i) => blatt(`wl${String(i + 1).padStart(2, '0')}`, 't4', deutsch(tag), 60, `Etappe ${i + 1}`)) +} + +/** + * Eine versendete Festpreis-AB des Kunden k1 zu p1 (80.000 Cent: Website 600 €, Texte 2 × 100 €; Anzahlung 50 %; AB-2026-0002, + * gesendet am 06.10.2026) über die L0-Speicher anlegen. over gilt für createAuftrag, patch für das anschließende updateAuftrag + * (Status, Nummer, bestaetigtAm, …). + */ +export async function legeAuftragAn(over = {}, patch = {}) { + const roh = await createAuftrag({ + kundenId: 'k1', projektId: 'p1', titel: 'Website anelia-wolf.com', zeitquelle: [], art: 'festpreis', + positionen: [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 60000 }, { beschreibung: 'Texte', menge: 2, einzelpreisCents: 10000 }], + stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000, + zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14, + auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '', fertigBis: '', erstelltVon: 'kenso@webklar.com', + ...over, + }) + return updateAuftrag(roh.id, { status: 'versendet', nummer: 'AB-2026-0002', jahr: 2026, laufnummer: 2, gesendetAm: '2026-10-06T08:15:00.000Z', ...patch }) +} + +const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000) + +/** + * Eine Projektrechnung wie nach createProjectInvoice: Stripe-Rechnung im Nachbau (status draft, open, paid oder void; ohneStripe: die + * Rechnung gibt es bei Stripe nicht, z. B. im Dashboard gelöscht) plus aktiver Projektdatensatz. docNummer ist die Nummer im Datensatz + * (Vorgabe: die der Rechnung, bei einem Entwurf leer). + */ +export async function legeRechnungAn(st, id, { + status = 'draft', nummer = 'WEBKLAR-0007', total = 40000, art = 'aufwand', auftragId = '', projektId = 'p1', + arbeitsblattIds = [], finalisiert = '2026-10-08T10:00:00.000Z', ohneStripe = false, docNummer, +} = {}) { + const entwurf = status === 'draft' + if (!ohneStripe) { + st.invoices.set(id, { + id, object: 'invoice', number: entwurf ? null : nummer, status, customer: 'cus_x', created: unix(finalisiert), total, amount_due: total, + amount_paid: status === 'paid' ? total : 0, amount_remaining: status === 'paid' ? 0 : total, + status_transitions: { finalized_at: entwurf ? null : unix(finalisiert) }, metadata: { art }, lines: { object: 'list', data: [], has_more: false }, + }) + } + await createProjektRechnung(id, { + kundenId: 'k1', projektId, auftragId, art, betragCents: total, arbeitsblattIds, + rechnungsnummer: docNummer ?? (entwurf ? '' : nummer), aktiv: true, erstelltVon: 'k', erstelltAm: new Date().toISOString(), + }) +} + +/** Aufträge und Projektdatensätze löschen, Stripe-Nachbau (Rechnungen, Kunden, Aufrufe) leeren. */ +export async function leereRechnungsDaten(aw, st) { + for (const sammlung of [config.collections.auftraege, config.collections.projektRechnungen]) { + for (const id of aw.docs(sammlung).keys()) await deleteDocument(sammlung, id) + } + st.invoices.clear() + st.customers.clear() + st.calls.length = 0 +} diff --git a/test/auftraegeRechnungsbau.test.js b/test/auftraegeRechnungsbau.test.js new file mode 100644 index 0000000..009c17b --- /dev/null +++ b/test/auftraegeRechnungsbau.test.js @@ -0,0 +1,114 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js' + +// Nachbau von Appwrite mit dem Schema und den Datumsfeldern der neuen Collections und dem Standard-Limit 25 (wie in den Tests von L0) +const aw = await startAppwriteMock({ + schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') }, + datetimes: AUFTRAEGE_DATETIMES, + defaultLimit: 25, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { seedRechnungsDaten, legeAuftragAn, legeRechnungAn, leereRechnungsDaten } = await import('./auftraegeRechnungenHilfe.js') +const { baueRechnung } = await import('../server/services/auftraege/rechnungsbau.js') +const { getProjektRechnung, updateProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js') +const { getAuftrag } = await import('../server/services/auftraege/auftragStore.js') + +after(() => Promise.all([aw.close(), st.close()])) +seedRechnungsDaten(aw) +config.auftraege.mode = 'on' +beforeEach(() => leereRechnungsDaten(aw, st)) + +const NBSP = String.fromCharCode(160) // geschütztes Leerzeichen U+00A0, wie euroDe es vor € setzt +const SCHALTER_AUS = 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.' +const POSTEN = [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }] +const wirft = (promise, status, message) => + assert.rejects(promise, (err) => { + assert.equal(err.status, status, `Status ${err.status}: ${err.message}`) + assert.equal(err.message, message) + return true + }) + +test('baueRechnung frei: freie Positionen, nur Kopf-Metadaten, keine Zusatzfelder, kein Projektdatensatz', async () => { + const e = await baueRechnung({ customerId: 'k1', items: POSTEN, createdBy: 'kenso@webklar.com' }) + assert.equal(e.art, 'frei') + assert.equal(e.kunde.$id, 'k1') + assert.equal(e.projekt, null) + assert.equal(e.auftrag, null) + assert.deepEqual(e.positionen, [{ schluessel: 'pos-1', beschreibung: 'Domain', menge: 1, einzelpreisCents: 1200, betragCents: 1200 }]) + assert.equal(e.summeCents, 1200) + assert.equal(e.leistungszeitraum, '') + assert.deepEqual(e.customFields, []) + assert.deepEqual(e.metadata, { webklarCustomerId: 'k1', quelle: 'woms', erstelltVon: 'kenso@webklar.com', art: 'frei' }) + assert.equal(e.projektRechnung, null) + assert.equal((await baueRechnung({ customerId: 'k1', items: POSTEN })).metadata.erstelltVon, '') +}) + +test('baueRechnung frei: Projekt, Auftrag, Arbeitsblätter und Anzahlung der Eingabe bleiben ohne Wirkung', async () => { + const e = await baueRechnung({ customerId: 'k1', art: 'frei', items: POSTEN, projektId: 'p1', auftragId: 'a1', arbeitsblattIds: ['ws1'], anzahlung: { prozent: 10 } }) + assert.equal(e.projekt, null) + assert.equal(e.auftrag, null) + assert.equal(e.projektRechnung, null) + assert.deepEqual(e.customFields, []) + assert.deepEqual(Object.keys(e.metadata).sort(), ['art', 'erstelltVon', 'quelle', 'webklarCustomerId']) +}) + +test('baueRechnung: Leistungszeitraum ist optional, wird getrimmt und hat höchstens 140 Zeichen', async () => { + const e = await baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum: ' Oktober 2026 ' }) + assert.equal(e.leistungszeitraum, 'Oktober 2026') + assert.deepEqual(e.customFields, [{ name: 'Leistungszeitraum', value: 'Oktober 2026' }]) + assert.equal((await baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum: 'z'.repeat(140) })).leistungszeitraum.length, 140) + for (const leer of [undefined, null, '', ' ']) assert.deepEqual((await baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum: leer })).customFields, []) + const MELDUNG = 'Der Leistungszeitraum muss ein Text mit höchstens 140 Zeichen sein.' + for (const leistungszeitraum of ['z'.repeat(141), 5, {}]) await wirft(baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum }), 400, MELDUNG) +}) + +test('baueRechnung: unbekannte Art, unbekannter oder nicht fester Kunde, ungültige Positionen -> 400 ohne Stripe-Aufruf', async () => { + for (const art of ['quatsch', 'FREI', 5, {}]) await wirft(baueRechnung({ customerId: 'k1', art, items: POSTEN }), 400, 'Unbekannte Rechnungsart.') + for (const customerId of [undefined, '', 'gibtEsNicht', '../x', 5]) await wirft(baueRechnung({ customerId, items: POSTEN }), 400, 'Kunde nicht gefunden.') + await wirft(baueRechnung({ customerId: 'lead1', items: POSTEN }), 400, 'Rechnungen nur für feste Kunden.') + await wirft(baueRechnung({ customerId: 'k1' }), 400, 'Eine Rechnung braucht 1 bis 20 Positionen.') + assert.equal(st.calls.length, 0) +}) + +test('baueRechnung festbetrag: Projekt, Zusatzfeld Projekt, Metadaten und Projektdatensatz', async () => { + const items = [{ description: 'Erstellung der Website anelia-wolf.com, Pauschalpreis laut Absprache', quantity: 1, unitAmountCents: 50000 }] + const e = await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items, createdBy: 'kenso@webklar.com' }) + assert.deepEqual(e.projekt, { id: 'p1', name: 'Website anelia-wolf.com', ticketId: 't1' }) + assert.equal(e.auftrag, null) + assert.equal(e.summeCents, 50000) + assert.deepEqual(e.customFields, [{ name: 'Projekt', value: 'Website anelia-wolf.com' }]) + assert.deepEqual(e.metadata, { webklarCustomerId: 'k1', quelle: 'woms', erstelltVon: 'kenso@webklar.com', art: 'festbetrag', projektId: 'p1', projektName: 'Website anelia-wolf.com' }) + const { erstelltAm, ...rest } = e.projektRechnung + assert.match(erstelltAm, /^\d{4}-\d{2}-\d{2}T/) + assert.deepEqual(rest, { kundenId: 'k1', projektId: 'p1', auftragId: '', art: 'festbetrag', betragCents: 50000, arbeitsblattIds: [], rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com' }) + // eine genannte auftragId bleibt bei dieser Art ohne Wirkung + assert.equal((await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', auftragId: 'a1', items })).auftrag, null) +}) + +test('baueRechnung festbetrag: lange Projektnamen werden auf 140 Zeichen gekürzt, leere ersetzt', async () => { + aw.seed('websiteProjects', { $id: 'plang', customerId: 'k1', projectName: 'P'.repeat(200), ticketId: '' }) + aw.seed('websiteProjects', { $id: 'psub', customerId: 'k1', projectName: '', subdomain: 'wolf-test', ticketId: '' }) + aw.seed('websiteProjects', { $id: 'pleer', customerId: 'k1', ticketId: '' }) + const e = await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId: 'plang', items: POSTEN }) + assert.equal(e.customFields[0].value.length, 140) + assert.ok(e.customFields[0].value.endsWith('P…')) + assert.equal(e.metadata.projektName, e.customFields[0].value) + const name = async (projektId) => (await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId, items: POSTEN })).customFields[0].value + assert.equal(await name('psub'), 'wolf-test') + assert.equal(await name('pleer'), 'Projekt') +}) + +test('baueRechnung festbetrag: Projekt ist Pflicht, fremde und unbekannte Projekte sind 404 (kein Unterschied), ungültige Positionen 400', async () => { + const mit = (projektId, items = POSTEN) => baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId, items }) + for (const projektId of [undefined, '', null, 5]) await wirft(mit(projektId), 400, 'Für diese Rechnungsart ist ein Projekt nötig.') + for (const projektId of ['gibtEsNicht', 'p2', '../x']) await wirft(mit(projektId), 404, 'Projekt nicht gefunden.') + await wirft(mit('p1', []), 400, 'Eine Rechnung braucht 1 bis 20 Positionen.') + assert.equal(st.calls.length, 0) +}) From 11531cacb697655feaf913d946546f44e63bb169 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:14:48 +0000 Subject: [PATCH 06/12] Rechnungen: Aufwandsrechnung bauen (Auswahl, Satz aus der AB, Doppelabrechnung, 20 Zeilen) Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/rechnungsbau.js | 192 +++++++++++++++++++++- test/auftraegeRechnungsbau.test.js | 140 ++++++++++++++++ 2 files changed, 329 insertions(+), 3 deletions(-) diff --git a/server/services/auftraege/rechnungsbau.js b/server/services/auftraege/rechnungsbau.js index 6f0ae0c..d6afe5d 100644 --- a/server/services/auftraege/rechnungsbau.js +++ b/server/services/auftraege/rechnungsbau.js @@ -4,10 +4,22 @@ * Selbstheilung veralteter Projektdatensaetze, siehe unten). Importiert nichts aus stripeFinance.js (Importzyklus). */ import { config } from '../../config.js' -import { getDocument } from '../appwriteAdmin.js' +import { getDocument, listAllDocuments, Query } from '../appwriteAdmin.js' +import { getStripe } from '../stripeBilling.js' +import { withLocks } from '../keyedLock.js' import { AuftragError } from './fehler.js' -import { RECHNUNGS_ARTEN, LIMITS } from './konstanten.js' -import { festePositionen, pruefePositionen, kuerzen, auftragDatum, SCHLUSS_GRENZEN } from './positionen.js' +import { + RECHNUNGS_ARTEN, LIMITS, VEREINBARTE_STATUS, STUNDENSATZ_STANDARD_CENTS, SCHALTER_AUS_MELDUNG, sperreAuftrag, +} from './konstanten.js' +import { + festePositionen, zusatzPositionen, pruefePositionen, kuerzen, auftragDatum, aufwandPositionen, leistungszeitraumText, SCHLUSS_GRENZEN, +} from './positionen.js' +import { auftraegeAktiv } from './schalter.js' +import { getAuftrag, listAuftraege, updateAuftrag } from './auftragStore.js' +import { updateProjektRechnung } from './projektRechnungenStore.js' +import { + ladeArbeitsblaetter, ladeArbeitsblaetterNachIds, abrechenbar, abgerechneteArbeitsblaetter, offeneArbeitsblaetter, zeitquelleFuerProjekt, +} from './arbeitsblaetter.js' import { formatDe } from '../finance/dates.js' // Appwrite-Dokument-ID @@ -43,6 +55,82 @@ async function ladeProjekt(kundenId, projektId) { return { id: projekt.$id, name: projekt.projectName || projekt.subdomain || 'Projekt', ticketId: projekt.ticketId || '' } } +/** AB des Kunden; fremde und unbekannte sind nicht zu unterscheiden (404). */ +async function ladeAuftrag(kundenId, auftragId) { + if (typeof auftragId !== 'string' || !KENNUNG.test(auftragId)) throw new AuftragError(404, 'Auftrag nicht gefunden.') + const auftrag = await getAuftrag(auftragId) + if (!auftrag || auftrag.kundenId !== kundenId) throw new AuftragError(404, 'Auftrag nicht gefunden.') + return auftrag +} + +/** + * Buchung nach einem Stripe-Erfolg: scheitert sie, wird nur protokolliert (eine festgeschriebene Rechnung scheitert nicht an + * Appwrite). Fehler mit Status 404 sind still. Wirft nie. + */ +export async function buche(was, fn) { + try { + await fn() + } catch (err) { + if (err?.status === 404) return + console.error(`[finanzen] ${was} fehlgeschlagen:`, err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '') + } +} + +/** Schlussrechnung weg (storniert, geloescht): die AB geht von abgerechnet zurueck. Unter der Sperre der AB. */ +export async function setzeAbZurueck(auftragId) { + await withLocks(sperreAuftrag(auftragId), async () => { + const ab = await getAuftrag(auftragId) + if (ab && ab.status === 'abgerechnet') await updateAuftrag(ab.id, { status: ab.bestaetigtAm ? 'bestaetigt' : 'versendet' }) + }) +} + +/** Projektdatensatz inaktiv setzen (Blaetter wieder frei); bei einer Schlussrechnung geht die AB zurueck. */ +export async function gibProjektRechnungFrei(invoiceId) { + const doc = await updateProjektRechnung(invoiceId, { aktiv: false }) + if (doc.art === 'schluss' && doc.auftragId) await setzeAbZurueck(doc.auftragId) + return doc +} + +const fehltBeiStripe = (err) => err?.statusCode === 404 || err?.code === 'resource_missing' + +/** + * Selbstheilung: belegt ist Map. Je Rechnung bei Stripe nachsehen: storniert oder + * geloescht -> Datensatz inaktiv, die Belegung gilt nicht; sonst gilt sie (mit der Nummer von Stripe, fehlende Nummer wird + * im Datensatz nachgetragen). Rueckgabe: die noch geltenden Belegungen. + */ +async function pruefeBelegungen(belegt) { + const urteile = new Map() + for (const { invoiceId } of belegt.values()) { + if (urteile.has(invoiceId)) continue + let roh + try { + roh = await getStripe().invoices.retrieve(invoiceId) + } catch (err) { + if (!fehltBeiStripe(err)) throw err + roh = null + } + if (!roh || roh.status === 'void') { + await buche('Rechnung freigeben', () => gibProjektRechnungFrei(invoiceId)) + urteile.set(invoiceId, null) + continue + } + const nummer = roh.status === 'draft' ? '' : roh.number || '' + urteile.set(invoiceId, nummer) + } + const gueltig = new Map() + for (const [blattId, belegung] of belegt) { + const nummer = urteile.get(belegung.invoiceId) + if (nummer === null) continue + if (nummer && nummer !== belegung.rechnungsnummer && !gueltig.has(`nachgetragen:${belegung.invoiceId}`)) { + gueltig.set(`nachgetragen:${belegung.invoiceId}`, true) + await buche('Rechnungsnummer nachtragen', () => updateProjektRechnung(belegung.invoiceId, { rechnungsnummer: nummer })) + } + gueltig.set(blattId, { invoiceId: belegung.invoiceId, rechnungsnummer: nummer }) + } + for (const schluessel of [...gueltig.keys()]) if (schluessel.startsWith('nachgetragen:')) gueltig.delete(schluessel) + return gueltig +} + function pruefeLeistungszeitraum(wert) { if (wert === undefined || wert === null) return '' if (typeof wert !== 'string' || wert.trim().length > LIMITS.maxLeistungszeitraum) { @@ -94,5 +182,103 @@ export async function baueRechnung(eingabe) { const projekt = await ladeProjekt(kunde.$id, eingabe.projektId) return schliesseAb({ ...basis, projekt, positionen: festePositionen(eingabe.items) }) } + if (art === 'aufwand') return baueAufwand(basis, eingabe) throw new AuftragError(501, 'Rechnungsart noch nicht umgesetzt.') } + +const MAX_BLAETTER = 400 +const KEIN_BLATT = 'Arbeitsblatt nicht gefunden.' + +function pruefeZeilentexte(wert) { + if (wert === undefined || wert === null) return {} + if (typeof wert !== 'object' || Array.isArray(wert) || Object.values(wert).some((text) => typeof text !== 'string')) { + throw new AuftragError(400, 'Die Zeilentexte müssen ein Objekt mit Texten sein.') + } + return wert +} + +/** Auftrag der Aufwandsrechnung: ausdruecklich genannt (muss passen) oder die juengste gueltige Aufwands-AB des Projekts. */ +async function ladeAufwandAuftrag(kunde, projekt, auftragId) { + if (auftragId !== undefined && auftragId !== null && auftragId !== '') { + if (!auftraegeAktiv(kunde)) throw new AuftragError(400, SCHALTER_AUS_MELDUNG) + const auftrag = await ladeAuftrag(kunde.$id, auftragId) + if (auftrag.art !== 'aufwand') throw new AuftragError(400, 'Eine Aufwandsrechnung passt nur zu einer Aufwands-Auftragsbestätigung.') + if (!VEREINBARTE_STATUS.includes(auftrag.status)) throw new AuftragError(400, 'Die Auftragsbestätigung ist nicht mehr gültig.') + if (auftrag.projektId && auftrag.projektId !== projekt.id) throw new AuftragError(400, 'Der Auftrag gehört nicht zu diesem Projekt.') + return auftrag + } + if (!auftraegeAktiv(kunde)) return null + const liste = await listAuftraege({ kundenId: kunde.$id, projektId: projekt.id }) + return liste.find((ab) => ab.art === 'aufwand' && VEREINBARTE_STATUS.includes(ab.status)) ?? null +} + +/** Ausdrueckliche Auswahl der Blaetter (Pruefung, Zugehoerigkeit zum Kunden, Doppelabrechnung mit Selbstheilung). */ +async function waehleBlaetter(kunde, ids, eigeneTickets) { + if (!Array.isArray(ids) || ids.some((id) => typeof id !== 'string')) { + throw new AuftragError(400, 'Die Arbeitsblätter müssen als Liste von Kennungen übergeben werden.') + } + const eindeutig = [...new Set(ids)] + if (!eindeutig.length) throw new AuftragError(400, 'Keine Arbeitsblätter ausgewählt.') + if (eindeutig.length > MAX_BLAETTER) throw new AuftragError(400, `Zu viele Arbeitsblätter ausgewählt (höchstens ${MAX_BLAETTER}).`) + if (eindeutig.some((id) => !KENNUNG.test(id))) throw new AuftragError(400, KEIN_BLATT) + const blaetter = await ladeArbeitsblaetterNachIds(eindeutig) + if (blaetter.length !== eindeutig.length || blaetter.some((b) => !eigeneTickets.has(b.ticketId))) throw new AuftragError(400, KEIN_BLATT) + if (blaetter.some((b) => !abrechenbar(b))) { + throw new AuftragError(400, 'Mindestens ein Arbeitsblatt ist nicht abrechenbar (Abo-Zeit, Kommentar oder ohne Zeit).') + } + const belegtAlle = await abgerechneteArbeitsblaetter(kunde.$id) + const belegt = new Map(eindeutig.filter((id) => belegtAlle.has(id)).map((id) => [id, belegtAlle.get(id)])) + if (belegt.size) { + const gueltig = await pruefeBelegungen(belegt) + for (const id of eindeutig) { + const b = gueltig.get(id) + if (b) throw new AuftragError(409, `Arbeitsblatt bereits abgerechnet (${b.rechnungsnummer ? `Rechnung ${b.rechnungsnummer}` : 'Entwurf'}).`) + } + } + return blaetter +} + +/** Standardauswahl: alle offenen Blaetter der Zeitquelle des Projekts; ist nichts offen, vorher Selbstheilung. */ +async function standardBlaetter(kunde, projekt, eigeneTickets) { + const abs = auftraegeAktiv(kunde) ? await listAuftraege({ kundenId: kunde.$id, projektId: projekt.id }) : [] + const zeitquelle = zeitquelleFuerProjekt(projekt, abs).filter((id) => eigeneTickets.has(id)) + const lade = () => (zeitquelle.length ? offeneArbeitsblaetter(zeitquelle, kunde.$id) : []) + let offene = await lade() + if (!offene.length && zeitquelle.length) { + const belegtAlle = await abgerechneteArbeitsblaetter(kunde.$id) + const belegt = new Map() + for (const blatt of await ladeArbeitsblaetter(zeitquelle)) { + if (abrechenbar(blatt) && belegtAlle.has(blatt.id)) belegt.set(blatt.id, belegtAlle.get(blatt.id)) + } + if (belegt.size) { + const gueltig = await pruefeBelegungen(belegt) + if (gueltig.size < belegt.size) offene = await lade() + } + } + if (!offene.length) throw new AuftragError(409, 'Keine offenen Arbeitsblätter zum Abrechnen.') + if (offene.length > MAX_BLAETTER) throw new AuftragError(400, `Zu viele Arbeitsblätter ausgewählt (höchstens ${MAX_BLAETTER}).`) + return offene +} + +async function baueAufwand(basis, eingabe) { + const { kunde } = basis + const projekt = await ladeProjekt(kunde.$id, eingabe.projektId) + const auftrag = await ladeAufwandAuftrag(kunde, projekt, eingabe.auftragId) + const satzCents = auftrag?.stundensatzCents ?? STUNDENSATZ_STANDARD_CENTS + const zusatz = zusatzPositionen(eingabe.items) + const zeilentexte = pruefeZeilentexte(eingabe.zeilentexte) + const eigeneTickets = new Set( + (await listAllDocuments(config.collections.workorders, [Query.equal('customerId', kunde.$id)])).map((d) => d.$id) + ) + const ausdruecklich = eingabe.arbeitsblattIds !== undefined && eingabe.arbeitsblattIds !== null + const blaetter = ausdruecklich ? await waehleBlaetter(kunde, eingabe.arbeitsblattIds, eigeneTickets) : await standardBlaetter(kunde, projekt, eigeneTickets) + const { positionen: tage, von, bis } = aufwandPositionen(blaetter, { satzCents, zeilentexte, maxZeilen: LIMITS.maxPositionen - zusatz.length }) + return schliesseAb({ + ...basis, + projekt, + auftrag, + positionen: [...tage, ...zusatz], + leistungszeitraum: basis.leistungszeitraum || leistungszeitraumText(von, bis), + arbeitsblattIds: blaetter.map((b) => b.id), + }) +} diff --git a/test/auftraegeRechnungsbau.test.js b/test/auftraegeRechnungsbau.test.js index 009c17b..10a2437 100644 --- a/test/auftraegeRechnungsbau.test.js +++ b/test/auftraegeRechnungsbau.test.js @@ -112,3 +112,143 @@ test('baueRechnung festbetrag: Projekt ist Pflicht, fremde und unbekannte Projek await wirft(mit('p1', []), 400, 'Eine Rechnung braucht 1 bis 20 Positionen.') assert.equal(st.calls.length, 0) }) + +const eingabe = (over = {}) => ({ customerId: 'k1', art: 'aufwand', projektId: 'p1', createdBy: 'kenso@webklar.com', ...over }) +const aufwandAb = (patch = {}, over = {}) => + legeAuftragAn( + { art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 12000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' }, zeitquelle: ['t1', 't3'], ...over }, + { nummer: 'AB-2026-0005', laufnummer: 5, status: 'bestaetigt', bestaetigtAm: '2026-10-07T09:00:00.000Z', ...patch } + ) + +test('baueRechnung aufwand: alle offenen Blätter des Projekttickets, eine Zeile je Tag, Satz 100 €, Zeitraum, Zusatzfeld', async () => { + const e = await baueRechnung(eingabe()) + assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.betragCents]), [['2026-10-06', 22500], ['2026-10-07', 10000]]) + assert.equal(e.positionen[0].beschreibung, `06.10.2026: Startseite; Kontaktformular – 2,25 Std. à 100,00${NBSP}€`) + assert.equal(e.positionen[1].beschreibung, `07.10.2026: Impressum – 1 Std. à 100,00${NBSP}€`) + assert.equal(e.summeCents, 32500) + assert.equal(e.leistungszeitraum, '06.10.–07.10.2026') + assert.deepEqual(e.customFields, [{ name: 'Projekt', value: 'Website anelia-wolf.com' }, { name: 'Leistungszeitraum', value: '06.10.–07.10.2026' }]) + assert.deepEqual(e.projekt, { id: 'p1', name: 'Website anelia-wolf.com', ticketId: 't1' }) + assert.equal(e.auftrag, null) + assert.equal(e.metadata.art, 'aufwand') + assert.equal(e.metadata.projektId, 'p1') + const { erstelltAm, ...rest } = e.projektRechnung + assert.ok(erstelltAm) + assert.deepEqual(rest, { kundenId: 'k1', projektId: 'p1', auftragId: '', art: 'aufwand', betragCents: 32500, arbeitsblattIds: ['ws1', 'ws2', 'ws3'], rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com' }) + assert.equal(st.calls.length, 0, 'Aufwand liest nichts bei Stripe') +}) + +test('baueRechnung aufwand: ausgewählte Blätter (auch von einem anderen Ticket des Kunden), Zusatzposition, eigener Zeitraum und Zeilentext', async () => { + const e = await baueRechnung(eingabe({ + arbeitsblattIds: ['ws2', 'wsT3', 'ws2'], items: POSTEN, leistungszeitraum: ' Oktober 2026 ', + zeilentexte: { '2026-10-08': '08.10.2026: Zusatzticket Impressum' }, + })) + assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.betragCents]), [['2026-10-06', 7500], ['2026-10-08', 5000], ['zusatz-1', 1200]]) + assert.equal(e.positionen[1].beschreibung, `08.10.2026: Zusatzticket Impressum – 0,5 Std. à 100,00${NBSP}€`) + assert.equal(e.summeCents, 13700) + assert.equal(e.leistungszeitraum, 'Oktober 2026') + assert.deepEqual(e.projektRechnung.arbeitsblattIds, ['ws2', 'wsT3']) +}) + +test('baueRechnung aufwand: ungültige Auswahl -> 400 ohne Stripe-Aufruf (fremde, unbekannte, unabrechenbare, zu viele Blätter)', async () => { + const mit = (arbeitsblattIds) => baueRechnung(eingabe({ arbeitsblattIds })) + await wirft(mit([]), 400, 'Keine Arbeitsblätter ausgewählt.') + for (const kaputt of ['ws1', [5], { 0: 'ws1' }]) await wirft(mit(kaputt), 400, 'Die Arbeitsblätter müssen als Liste von Kennungen übergeben werden.') + for (const id of ['gibtEsNicht', 'wsFremd', '../x']) await wirft(mit(['ws1', id]), 400, 'Arbeitsblatt nicht gefunden.') + for (const id of ['wsAbo', 'wsKomm', 'wsNull']) { + await wirft(mit(['ws1', id]), 400, 'Mindestens ein Arbeitsblatt ist nicht abrechenbar (Abo-Zeit, Kommentar oder ohne Zeit).') + } + await wirft(mit(Array.from({ length: 401 }, (_, i) => `ws${i}`)), 400, 'Zu viele Arbeitsblätter ausgewählt (höchstens 400).') + assert.equal(st.calls.length, 0) +}) + +test('baueRechnung aufwand: bereits abgerechnete Blätter -> 409 mit der Nummer von Stripe oder Entwurf, stornierte geben frei, Standardauswahl nur Offenes', async () => { + await legeRechnungAn(st, 'in_alt1', { status: 'open', nummer: 'WEBKLAR-0009', arbeitsblattIds: ['ws1'] }) + const mit = () => baueRechnung(eingabe({ arbeitsblattIds: ['ws1', 'ws3'] })) + await wirft(mit(), 409, 'Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0009).') + st.invoices.set('in_alt1', { ...st.invoices.get('in_alt1'), status: 'draft', number: null }) + await wirft(mit(), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).') + assert.deepEqual((await baueRechnung(eingabe())).projektRechnung.arbeitsblattIds, ['ws2', 'ws3']) + await updateProjektRechnung('in_alt1', { aktiv: false }) + assert.equal((await mit()).positionen.length, 2) + await legeRechnungAn(st, 'in_alt2', { arbeitsblattIds: ['ws1', 'ws2', 'ws3'] }) + await wirft(baueRechnung(eingabe()), 409, 'Keine offenen Arbeitsblätter zum Abrechnen.') +}) + +test('baueRechnung aufwand: Selbstheilung - eine im Stripe-Dashboard stornierte oder gelöschte Rechnung hält ihre Blätter nicht fest, eine fehlende Nummer wird nachgetragen', async () => { + await legeRechnungAn(st, 'in_leer1', { status: 'void', nummer: 'WEBKLAR-0010', arbeitsblattIds: ['ws1'] }) + await legeRechnungAn(st, 'in_leer2', { ohneStripe: true, arbeitsblattIds: ['ws2'] }) + const e = await baueRechnung(eingabe({ arbeitsblattIds: ['ws1', 'ws2'] })) + assert.deepEqual([e.positionen.length, e.summeCents], [1, 22500]) + for (const id of ['in_leer1', 'in_leer2']) assert.equal((await getProjektRechnung(id)).aktiv, false, id) + // auch die Standardauswahl heilt, bevor sie "nichts offen" meldet + await legeRechnungAn(st, 'in_leer3', { status: 'void', nummer: 'WEBKLAR-0012', arbeitsblattIds: ['ws1', 'ws2', 'ws3'] }) + assert.deepEqual((await baueRechnung(eingabe())).projektRechnung.arbeitsblattIds, ['ws1', 'ws2', 'ws3']) + assert.equal((await getProjektRechnung('in_leer3')).aktiv, false) + // bei Stripe finalisiert, im Datensatz fehlt die Nummer: die Meldung nennt die Nummer von Stripe, der Datensatz wird nachgetragen + await legeRechnungAn(st, 'in_offen', { status: 'open', nummer: 'WEBKLAR-0011', docNummer: '', arbeitsblattIds: ['ws3'] }) + await wirft(baueRechnung(eingabe({ arbeitsblattIds: ['ws3'] })), 409, 'Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0011).') + assert.equal((await getProjektRechnung('in_offen')).rechnungsnummer, 'WEBKLAR-0011') +}) + +test('baueRechnung aufwand: Satz, Auftrag und Zeitquelle aus der jüngsten gültigen Aufwands-AB; ohne Schalter bleibt es bei 100 €', async () => { + const ab = await aufwandAb() + const e = await baueRechnung(eingabe()) + assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.betragCents]), [['2026-10-06', 27000], ['2026-10-07', 12000], ['2026-10-08', 6000]]) + assert.ok(e.positionen[0].beschreibung.endsWith(` – 2,25 Std. à 120,00${NBSP}€`)) + assert.equal(e.leistungszeitraum, '06.10.–08.10.2026') + assert.equal(e.auftrag.nummer, 'AB-2026-0005') + assert.deepEqual(e.customFields.map((f) => f.name), ['Projekt', 'Auftrag', 'Leistungszeitraum']) + assert.equal(e.customFields[1].value, 'AB-2026-0005 vom 06.10.2026') + assert.deepEqual([e.metadata.auftragId, e.metadata.auftragNummer], [ab.id, 'AB-2026-0005']) + assert.equal(e.projektRechnung.auftragId, ab.id) + // ein neuerer, aber stornierter Aufwandsauftrag ändert nichts + await aufwandAb({ nummer: 'AB-2026-0006', status: 'storniert' }, { stundensatzCents: 15000 }) + assert.equal((await baueRechnung(eingabe())).auftrag.nummer, 'AB-2026-0005') + config.auftraege.mode = 'off' + try { + const ohne = await baueRechnung(eingabe({ arbeitsblattIds: ['ws1'] })) + assert.equal(ohne.auftrag, null) + assert.equal(ohne.positionen[0].betragCents, 15000) // 90 Minuten zu 100 € + await wirft(baueRechnung(eingabe({ auftragId: ab.id })), 400, SCHALTER_AUS) + } finally { + config.auftraege.mode = 'on' + } +}) + +test('baueRechnung aufwand: ausdrücklich genannter Auftrag muss passen (Aufwand, eigener Kunde, gültig, gleiches Projekt)', async () => { + const festpreis = await legeAuftragAn() + const fremd = await legeAuftragAn({ kundenId: 'k2', projektId: 'p2' }, { nummer: 'AB-2026-0008' }) + const entwurf = await aufwandAb({ nummer: 'AB-2026-0009', status: 'entwurf' }) + const anderes = await aufwandAb({ nummer: 'AB-2026-0010' }, { projektId: 'p3' }) + const mit = (auftragId) => baueRechnung(eingabe({ auftragId })) + await wirft(mit(festpreis.id), 400, 'Eine Aufwandsrechnung passt nur zu einer Aufwands-Auftragsbestätigung.') + await wirft(mit(fremd.id), 404, 'Auftrag nicht gefunden.') + await wirft(mit('gibtEsNicht'), 404, 'Auftrag nicht gefunden.') + await wirft(mit(entwurf.id), 400, 'Die Auftragsbestätigung ist nicht mehr gültig.') + await wirft(mit(anderes.id), 400, 'Der Auftrag gehört nicht zu diesem Projekt.') + const gueltig = await aufwandAb({ nummer: 'AB-2026-0011' }) + assert.equal((await mit(gueltig.id)).auftrag.id, gueltig.id) +}) + +test('baueRechnung aufwand: Zusatzpositionen zählen zu den 20 Zeilen, die ältesten Wochen werden zu KW-Zeilen', async () => { + const ohne = await baueRechnung(eingabe({ projektId: 'p3' })) + assert.equal(ohne.positionen.length, 20) + assert.equal(ohne.positionen[0].schluessel, '2026-11-02') + const mit = await baueRechnung(eingabe({ projektId: 'p3', items: POSTEN })) + assert.equal(mit.positionen.length, 17) + assert.deepEqual([mit.positionen[0].schluessel, mit.positionen[1].schluessel, mit.positionen.at(-1).schluessel], ['KW-2026-45', '2026-11-09', 'zusatz-1']) + assert.equal(mit.summeCents, 20 * 10000 + 1200) + assert.ok(mit.positionen.every((p) => p.beschreibung.length <= 200)) +}) + +test('baueRechnung aufwand: Projekt ist Pflicht, fremdes Projekt 404, Projekt ohne offene Blätter 409, Zeilentexte müssen ein Objekt mit Texten sein', async () => { + for (const projektId of [undefined, '', null]) await wirft(baueRechnung(eingabe({ projektId })), 400, 'Für diese Rechnungsart ist ein Projekt nötig.') + await wirft(baueRechnung(eingabe({ projektId: 'p2' })), 404, 'Projekt nicht gefunden.') + aw.seed('websiteProjects', { $id: 'pohne', customerId: 'k1', projectName: 'Ohne Ticket', ticketId: '' }) + await wirft(baueRechnung(eingabe({ projektId: 'pohne' })), 409, 'Keine offenen Arbeitsblätter zum Abrechnen.') + for (const zeilentexte of [['x'], 'x', 5, { '2026-10-06': 5 }]) { + await wirft(baueRechnung(eingabe({ zeilentexte })), 400, 'Die Zeilentexte müssen ein Objekt mit Texten sein.') + } + assert.equal((await baueRechnung(eingabe({ zeilentexte: null }))).positionen.length, 2) +}) From 4f8e9565749707551e09b383ed0183c98e98800c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:15:25 +0000 Subject: [PATCH 07/12] Rechnungen: Anzahlung und Schlussrechnung bauen (Minuszeilen, Entwurfsschutz, Selbstheilung) Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/rechnungsbau.js | 112 ++++++++++++++-- test/auftraegeRechnungsbau.test.js | 155 ++++++++++++++++++++++ 2 files changed, 256 insertions(+), 11 deletions(-) diff --git a/server/services/auftraege/rechnungsbau.js b/server/services/auftraege/rechnungsbau.js index d6afe5d..080f0c6 100644 --- a/server/services/auftraege/rechnungsbau.js +++ b/server/services/auftraege/rechnungsbau.js @@ -9,18 +9,20 @@ import { getStripe } from '../stripeBilling.js' import { withLocks } from '../keyedLock.js' import { AuftragError } from './fehler.js' import { - RECHNUNGS_ARTEN, LIMITS, VEREINBARTE_STATUS, STUNDENSATZ_STANDARD_CENTS, SCHALTER_AUS_MELDUNG, sperreAuftrag, + RECHNUNGS_ARTEN, AB_RECHNUNGS_ARTEN, LIMITS, VEREINBARTE_STATUS, STUNDENSATZ_STANDARD_CENTS, SCHALTER_AUS_MELDUNG, sperreAuftrag, } from './konstanten.js' import { - festePositionen, zusatzPositionen, pruefePositionen, kuerzen, auftragDatum, aufwandPositionen, leistungszeitraumText, SCHLUSS_GRENZEN, + festePositionen, zusatzPositionen, pruefePositionen, kuerzen, auftragDatum, aufwandPositionen, leistungszeitraumText, + anzahlungPosition, schlussPositionen, SCHLUSS_GRENZEN, } from './positionen.js' import { auftraegeAktiv } from './schalter.js' import { getAuftrag, listAuftraege, updateAuftrag } from './auftragStore.js' -import { updateProjektRechnung } from './projektRechnungenStore.js' +import { listProjektRechnungen, updateProjektRechnung } from './projektRechnungenStore.js' import { ladeArbeitsblaetter, ladeArbeitsblaetterNachIds, abrechenbar, abgerechneteArbeitsblaetter, offeneArbeitsblaetter, zeitquelleFuerProjekt, } from './arbeitsblaetter.js' -import { formatDe } from '../finance/dates.js' +import { berlinDate, formatDe } from '../finance/dates.js' +import { euroDe } from '../finance/format.js' // Appwrite-Dokument-ID const KENNUNG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ @@ -40,18 +42,25 @@ export async function ladeFestenKunden(customerId) { return kunde } -/** Projekt des Kunden; fremde und unbekannte Projekte sind nicht zu unterscheiden (404). */ -async function ladeProjekt(kundenId, projektId) { - if (typeof projektId !== 'string' || !projektId) throw new AuftragError(400, 'Für diese Rechnungsart ist ein Projekt nötig.') - if (!KENNUNG.test(projektId)) throw new AuftragError(404, 'Projekt nicht gefunden.') +/** + * Projekt des Kunden; fremde und unbekannte Projekte sind nicht zu unterscheiden (404). Mit weich = true liefert jeder + * Fehler stattdessen null (nur Lesefehler von Appwrite werden weitergereicht). + */ +async function ladeProjekt(kundenId, projektId, { weich = false } = {}) { + const fehler = (status, meldung) => { + if (weich) return null + throw new AuftragError(status, meldung) + } + if (typeof projektId !== 'string' || !projektId) return fehler(400, 'Für diese Rechnungsart ist ein Projekt nötig.') + if (!KENNUNG.test(projektId)) return fehler(404, 'Projekt nicht gefunden.') let projekt try { projekt = await getDocument(config.collections.websiteProjects, projektId) } catch (err) { - if (err.status === 404) throw new AuftragError(404, 'Projekt nicht gefunden.') + if (err.status === 404) return fehler(404, 'Projekt nicht gefunden.') throw err } - if (projekt.customerId !== kundenId) throw new AuftragError(404, 'Projekt nicht gefunden.') + if (projekt.customerId !== kundenId) return fehler(404, 'Projekt nicht gefunden.') return { id: projekt.$id, name: projekt.projectName || projekt.subdomain || 'Projekt', ticketId: projekt.ticketId || '' } } @@ -175,6 +184,7 @@ export async function baueRechnung(eingabe) { const art = eingabe.art ?? 'frei' if (typeof art !== 'string' || !RECHNUNGS_ARTEN.includes(art)) throw new AuftragError(400, 'Unbekannte Rechnungsart.') const kunde = await ladeFestenKunden(eingabe.customerId) + if (AB_RECHNUNGS_ARTEN.includes(art) && !auftraegeAktiv(kunde)) throw new AuftragError(400, SCHALTER_AUS_MELDUNG) const leistungszeitraum = pruefeLeistungszeitraum(eingabe.leistungszeitraum) const basis = { kunde, art, leistungszeitraum, createdBy: eingabe.createdBy } if (art === 'frei') return schliesseAb({ ...basis, positionen: festePositionen(eingabe.items) }) @@ -183,7 +193,7 @@ export async function baueRechnung(eingabe) { return schliesseAb({ ...basis, projekt, positionen: festePositionen(eingabe.items) }) } if (art === 'aufwand') return baueAufwand(basis, eingabe) - throw new AuftragError(501, 'Rechnungsart noch nicht umgesetzt.') + return baueAbRechnung(basis, eingabe) } const MAX_BLAETTER = 400 @@ -282,3 +292,83 @@ async function baueAufwand(basis, eingabe) { arbeitsblattIds: blaetter.map((b) => b.id), }) } + +const FINALISIERT = ['open', 'paid', 'uncollectible'] + +/** + * Fruehere Projektrechnungen einer AB (aktive Datensaetze) mit dem Stand bei Stripe. Selbstheilung: eine im Dashboard + * stornierte oder geloeschte Rechnung wird freigegeben (bei einer Schlussrechnung geht die AB zurueck) und uebersprungen. + */ +async function ladeFruehere(auftragId) { + const ergebnis = [] + for (const doc of await listProjektRechnungen({ auftragId })) { + let roh + try { + roh = await getStripe().invoices.retrieve(doc.invoiceId) + } catch (err) { + if (!fehltBeiStripe(err)) throw err + roh = null + } + if (!roh || roh.status === 'void') { + await buche('Rechnung freigeben', () => gibProjektRechnungFrei(doc.invoiceId)) + continue + } + const nummer = roh.number || '' + if (FINALISIERT.includes(roh.status) && nummer && !doc.rechnungsnummer) { + await buche('Rechnungsnummer nachtragen', () => updateProjektRechnung(doc.invoiceId, { rechnungsnummer: nummer })) + } + ergebnis.push({ + invoiceId: doc.invoiceId, + art: doc.art, + status: roh.status, + rechnungsnummer: nummer, + datum: berlinDate(new Date((roh.status_transitions?.finalized_at ?? roh.created) * 1000)), + betragCents: roh.total ?? 0, + }) + } + return ergebnis +} + +/** Anzahlung und Schlussrechnung zu einer Festpreis-AB. */ +async function baueAbRechnung(basis, eingabe) { + const { kunde, art } = basis + const { auftragId } = eingabe + if (typeof auftragId !== 'string' || !auftragId) throw new AuftragError(400, 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.') + let auftrag = await ladeAuftrag(kunde.$id, auftragId) + if (auftrag.art !== 'festpreis') throw new AuftragError(400, 'Anzahlung und Schlussrechnung gehen nur mit einer Festpreis-Auftragsbestätigung.') + if (auftrag.status === 'abgerechnet') { + await ladeFruehere(auftrag.id) // gibt eine im Dashboard stornierte Schlussrechnung frei und setzt die AB zurueck + auftrag = await ladeAuftrag(kunde.$id, auftragId) + } + if (!['versendet', 'bestaetigt'].includes(auftrag.status)) throw new AuftragError(400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.') + + let projekt = null + if (typeof eingabe.projektId === 'string' && eingabe.projektId) { + projekt = await ladeProjekt(kunde.$id, eingabe.projektId) + if (auftrag.projektId && auftrag.projektId !== projekt.id) throw new AuftragError(400, 'Der Auftrag gehört nicht zu diesem Projekt.') + } else if (auftrag.projektId) { + projekt = await ladeProjekt(kunde.$id, auftrag.projektId, { weich: true }) + } + + const fruehere = await ladeFruehere(auftrag.id) + const entwurf = fruehere.find((f) => f.status === 'draft' && (art === 'schluss' || f.art === 'schluss')) + if (entwurf) { + throw new AuftragError(409, art === 'schluss' + ? `Erst den Entwurf über ${euroDe(entwurf.betragCents)} senden oder löschen.` + : 'Zu dieser Auftragsbestätigung gibt es schon einen Entwurf der Schlussrechnung. Erst den Entwurf senden oder löschen.') + } + const finalisierte = fruehere.filter((f) => FINALISIERT.includes(f.status)) + let positionen + if (art === 'anzahlung') { + const angaben = eingabe.anzahlung ?? {} + if (typeof angaben !== 'object' || Array.isArray(angaben)) throw new AuftragError(400, 'Die Angaben zur Anzahlung müssen ein Objekt sein.') + positionen = [anzahlungPosition(auftrag, angaben, finalisierte.reduce((s, f) => s + f.betragCents, 0))] + } else { + positionen = schlussPositionen( + auftrag, + finalisierte.map(({ invoiceId, rechnungsnummer, datum, betragCents, art: fArt }) => ({ invoiceId, rechnungsnummer, datum, betragCents, art: fArt })), + eingabe.items + ) + } + return schliesseAb({ ...basis, projekt, auftrag, positionen }) +} diff --git a/test/auftraegeRechnungsbau.test.js b/test/auftraegeRechnungsbau.test.js index 10a2437..deb8842 100644 --- a/test/auftraegeRechnungsbau.test.js +++ b/test/auftraegeRechnungsbau.test.js @@ -252,3 +252,158 @@ test('baueRechnung aufwand: Projekt ist Pflicht, fremdes Projekt 404, Projekt oh } assert.equal((await baueRechnung(eingabe({ zeilentexte: null }))).positionen.length, 2) }) + +/** Frühere Rechnung zur AB (Art anzahlung, wenn nicht anders gesagt): Stripe-Rechnung im Nachbau plus aktiver Projektdatensatz. */ +const fruehereRechnung = (ab, { id, art = 'anzahlung', status = 'open', ...optionen }) => legeRechnungAn(st, id, { art, status, auftragId: ab.id, ...optionen }) + +test('baueRechnung anzahlung: Position aus dem Zahlungsplan, Zusatzfelder Projekt und Auftrag, Metadaten, Projektdatensatz', async () => { + const ab = await legeAuftragAn() + const e = await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, createdBy: 'kenso@webklar.com' }) + assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.beschreibung, p.betragCents]), [ + ['anzahlung', 'Anzahlung 50 % gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026 (Website anelia-wolf.com)', 40000], + ]) + assert.equal(e.summeCents, 40000) + assert.deepEqual(e.customFields, [{ name: 'Projekt', value: 'Website anelia-wolf.com' }, { name: 'Auftrag', value: 'AB-2026-0002 vom 06.10.2026' }]) + assert.deepEqual(e.metadata, { + webklarCustomerId: 'k1', quelle: 'woms', erstelltVon: 'kenso@webklar.com', art: 'anzahlung', + projektId: 'p1', projektName: 'Website anelia-wolf.com', auftragId: ab.id, auftragNummer: 'AB-2026-0002', + }) + assert.equal(e.auftrag.id, ab.id) + assert.equal(e.projekt.id, 'p1') + const { erstelltAm, ...rest } = e.projektRechnung + assert.ok(erstelltAm) + assert.deepEqual(rest, { kundenId: 'k1', projektId: 'p1', auftragId: ab.id, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [], rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com' }) + assert.equal(st.calls.length, 0, 'ohne frühere Rechnungen gibt es nichts bei Stripe zu lesen') +}) + +test('baueRechnung anzahlung: Prozent oder Betrag änderbar; nur finalisierte Rechnungen mindern das Offene', async () => { + const ab = await legeAuftragAn() + const lauf = (anzahlung) => baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung }) + assert.ok((await lauf({ prozent: 25, bezeichnung: 'Teilrechnung' })).positionen[0].beschreibung.startsWith('Teilrechnung 25 % gemäß')) + assert.equal((await lauf({ betragCents: 25000 })).summeCents, 25000) + await fruehereRechnung(ab, { id: 'in_f1', status: 'open', total: 40000 }) + await fruehereRechnung(ab, { id: 'in_f2', status: 'void', total: 99000, nummer: 'WEBKLAR-0008' }) // storniert: zählt nicht + await fruehereRechnung(ab, { id: 'in_f3', status: 'draft', total: 5000 }) // Entwurf einer Anzahlung: zählt nicht + assert.equal((await lauf(undefined)).summeCents, 40000) + assert.equal((await getProjektRechnung('in_f2')).aktiv, false, 'die im Dashboard stornierte Rechnung wurde freigegeben') + await wirft(lauf({ betragCents: 40001 }), 400, `Die Anzahlung darf höchstens 400,00${NBSP}€ betragen (Summe der Auftragsbestätigung minus bereits berechnet).`) + await fruehereRechnung(ab, { id: 'in_f4', status: 'paid', total: 40000, nummer: 'WEBKLAR-0009' }) + await wirft(lauf(undefined), 409, 'Nichts mehr offen.') + for (const kaputt of ['viel', 5, ['x']]) await wirft(lauf(kaputt), 400, 'Die Angaben zur Anzahlung müssen ein Objekt sein.') +}) + +test('baueRechnung anzahlung: ein Entwurf der Schlussrechnung zur AB blockiert weitere Anzahlungen (409)', async () => { + const ab = await legeAuftragAn() + await fruehereRechnung(ab, { id: 'in_s1', status: 'draft', art: 'schluss', total: 40000 }) + await wirft( + baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id }), + 409, + 'Zu dieser Auftragsbestätigung gibt es schon einen Entwurf der Schlussrechnung. Erst den Entwurf senden oder löschen.' + ) +}) + +test('baueRechnung anzahlung/schluss: Voraussetzungen (Schalter, Auftrag, Festpreis, Status, Kunde, Projekt)', async () => { + const ab = await legeAuftragAn() + for (const art of ['anzahlung', 'schluss']) { + await wirft(baueRechnung({ customerId: 'k1', art }), 400, 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.') + await wirft(baueRechnung({ customerId: 'k1', art, auftragId: 'gibtEsNicht' }), 404, 'Auftrag nicht gefunden.') + await wirft(baueRechnung({ customerId: 'k2', art, auftragId: ab.id }), 404, 'Auftrag nicht gefunden.') + await wirft(baueRechnung({ customerId: 'k1', art, auftragId: ab.id, projektId: 'p3' }), 400, 'Der Auftrag gehört nicht zu diesem Projekt.') + config.auftraege.mode = 'off' + try { + await wirft(baueRechnung({ customerId: 'k1', art, auftragId: ab.id }), 400, SCHALTER_AUS) + await wirft(baueRechnung({ customerId: 'k1', art }), 400, SCHALTER_AUS) + } finally { + config.auftraege.mode = 'on' + } + } + const aufwand = await legeAuftragAn({ art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000 }, { nummer: 'AB-2026-0006' }) + await wirft(baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: aufwand.id }), 400, 'Anzahlung und Schlussrechnung gehen nur mit einer Festpreis-Auftragsbestätigung.') + for (const [i, status] of ['entwurf', 'storniert', 'ersetzt', 'widerrufen', 'abgerechnet'].entries()) { + const x = await legeAuftragAn({}, { nummer: `AB-2026-010${i}`, status }) + for (const art of ['anzahlung', 'schluss']) await wirft(baueRechnung({ customerId: 'k1', art, auftragId: x.id }), 400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.') + } + const bestaetigt = await legeAuftragAn({}, { nummer: 'AB-2026-0007', status: 'bestaetigt', bestaetigtAm: '2026-10-07T09:00:00.000Z' }) + assert.equal((await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: bestaetigt.id })).summeCents, 40000) +}) + +test('baueRechnung schluss: AB-Positionen, Zusatz und eine Minuszeile je finalisierter, nicht stornierter früherer Rechnung', async () => { + const ab = await legeAuftragAn() + await fruehereRechnung(ab, { id: 'in_f1', status: 'open', total: 40000, nummer: 'WEBKLAR-0007', finalisiert: '2026-10-08T10:00:00.000Z' }) + await fruehereRechnung(ab, { id: 'in_f2', status: 'void', total: 20000, nummer: 'WEBKLAR-0008' }) + const e = await baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id, items: [{ description: 'Domain-Registrierung', quantity: 1, unitAmountCents: 1500 }] }) + assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.betragCents]), [['ab-1', 60000], ['ab-2', 20000], ['zusatz-1', 1500], ['abzug-in_f1', -40000]]) + assert.equal(e.positionen.at(-1).beschreibung, 'abzüglich Anzahlung, Rechnung WEBKLAR-0007 vom 08.10.2026') + assert.equal(e.summeCents, 41500) + assert.deepEqual(e.customFields.map((f) => f.name), ['Projekt', 'Auftrag']) + assert.equal(e.projektRechnung.art, 'schluss') + assert.equal(e.projektRechnung.betragCents, 41500) + assert.equal(e.projektRechnung.auftragId, ab.id) +}) + +test('baueRechnung schluss: offener Entwurf zur AB blockiert (409), Rechnungen fremder ABs und bei Stripe gelöschte zählen nicht, nichts mehr offen -> 409', async () => { + const ab = await legeAuftragAn() + const andere = await legeAuftragAn({}, { nummer: 'AB-2026-0003' }) + await fruehereRechnung(andere, { id: 'in_g1', status: 'draft', total: 11111 }) + await fruehereRechnung(andere, { id: 'in_g2', status: 'paid', total: 22222, nummer: 'WEBKLAR-0005' }) + const lauf = () => baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id }) + assert.equal((await lauf()).summeCents, 80000) + await fruehereRechnung(ab, { id: 'in_h1', status: 'draft', total: 40000 }) + await wirft(lauf(), 409, `Erst den Entwurf über 400,00${NBSP}€ senden oder löschen.`) + await updateProjektRechnung('in_h1', { aktiv: false }) + assert.equal((await lauf()).summeCents, 80000) + await fruehereRechnung(ab, { id: 'in_h2', status: 'paid', total: 80000, nummer: 'WEBKLAR-0008' }) + await wirft(lauf(), 409, 'Nichts mehr offen.') + await updateProjektRechnung('in_h2', { aktiv: false }) + // aktiver Projektdatensatz, aber die Rechnung gibt es bei Stripe nicht mehr (im Dashboard gelöscht): zählt nicht + await fruehereRechnung(ab, { id: 'in_weg', ohneStripe: true, total: 5000, docNummer: 'X' }) + const e = await lauf() + assert.equal(e.summeCents, 80000) + assert.equal(e.positionen.length, 2) + assert.equal((await getProjektRechnung('in_weg')).aktiv, false, 'der Datensatz der gelöschten Rechnung wurde freigegeben') +}) + +test('baueRechnung anzahlung: AB ohne Projekt oder mit gelöschtem Projekt -> Zusatzfeld Projekt zeigt den AB-Titel; ein genanntes fremdes Projekt 404', async () => { + const ohne = await legeAuftragAn({ projektId: '', titel: 'Logo-Entwurf' }, { nummer: 'AB-2026-0012' }) + const e = await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ohne.id }) + assert.equal(e.projekt, null) + assert.deepEqual(e.customFields[0], { name: 'Projekt', value: 'Logo-Entwurf' }) + assert.equal(e.metadata.projektId, undefined) + assert.equal(e.projektRechnung.projektId, '') + const weg = await legeAuftragAn({ projektId: 'pWeg' }, { nummer: 'AB-2026-0013' }) + const w = await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: weg.id }) + assert.equal(w.projekt, null) + assert.deepEqual([w.metadata.projektId, w.projektRechnung.projektId, w.customFields[0].value], ['pWeg', 'pWeg', 'Website anelia-wolf.com']) + assert.equal((await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ohne.id, projektId: 'p1' })).projekt.id, 'p1') + await wirft(baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ohne.id, projektId: 'p2' }), 404, 'Projekt nicht gefunden.') +}) + +test('baueRechnung schluss: eine im Dashboard stornierte Schlussrechnung gibt die AB wieder frei (Selbstheilung), eine offene hält sie', async () => { + const ab = await legeAuftragAn({}, { status: 'abgerechnet', bestaetigtAm: '2026-10-07T09:00:00.000Z' }) + await fruehereRechnung(ab, { id: 'in_sl1', art: 'schluss', status: 'open', total: 80000 }) + await wirft(baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id }), 400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.') + assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet') + st.invoices.set('in_sl1', { ...st.invoices.get('in_sl1'), status: 'void' }) // im Dashboard storniert + const e = await baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id }) + assert.equal(e.summeCents, 80000) + assert.equal((await getAuftrag(ab.id)).status, 'bestaetigt') + assert.equal((await getProjektRechnung('in_sl1')).aktiv, false) +}) + +test('baueRechnung schluss: bis zu 40 Zeilen (20 AB-Positionen, 10 Nachträge, 10 Abzüge); mehr Abzüge oder Nachträge -> 400', async () => { + const positionen = Array.from({ length: 20 }, (_, i) => ({ beschreibung: `Pos ${i + 1}`, menge: 1, einzelpreisCents: 1000 })) + const ab = await legeAuftragAn({ positionen, summeCents: 20000 }, { nummer: 'AB-2026-0014' }) + const nachtraege = (n) => Array.from({ length: n }, (_, i) => ({ description: `Nachtrag ${i + 1}`, quantity: 1, unitAmountCents: 500 })) + const lauf = (items) => baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id, items }) + const abzug = (i) => fruehereRechnung(ab, { id: `in_z${String(i).padStart(2, '0')}`, status: 'paid', total: 100, nummer: `WK-${String(i).padStart(4, '0')}` }) + assert.equal((await lauf(undefined)).positionen.length, 20) + await abzug(1) + const eins = await lauf(undefined) + assert.deepEqual([eins.positionen.length, eins.summeCents], [21, 19900], '20 AB-Positionen und ein Abzug sind gültig') + for (let i = 2; i <= 10; i += 1) await abzug(i) + const voll = await lauf(nachtraege(10)) + assert.deepEqual([voll.positionen.length, voll.summeCents, voll.projektRechnung.betragCents], [40, 24000, 24000]) + await wirft(lauf(nachtraege(11)), 400, 'Eine Schlussrechnung erlaubt höchstens 10 Zusatzpositionen.') + await abzug(11) + await wirft(lauf(undefined), 400, 'Die Schlussrechnung hätte 11 Abzüge für frühere Rechnungen; erlaubt sind höchstens 10.') +}) From c116c3cb4f087b9ca1b7148686945d38da8beefc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:15:44 +0000 Subject: [PATCH 08/12] Rechnungen: mapInvoice liefert Projekt, Art und Auftrag Co-Authored-By: Claude Sonnet 5.5 --- server/services/finance/stripeFinance.js | 6 +++ test/auftraegeStripe.test.js | 57 ++++++++++++++++++++++++ test/financeStripeRead.test.js | 5 +++ 3 files changed, 68 insertions(+) create mode 100644 test/auftraegeStripe.test.js diff --git a/server/services/finance/stripeFinance.js b/server/services/finance/stripeFinance.js index 0106e24..85c584e 100644 --- a/server/services/finance/stripeFinance.js +++ b/server/services/finance/stripeFinance.js @@ -3,6 +3,7 @@ import { config } from '../../config.js' import { getStripe, getOrCreateStripeCustomer, stripeCustomerFields, subscriptionIdOfInvoice } from '../stripeBilling.js' import { getDocument } from '../appwriteAdmin.js' import { addDays, berlinDate, daysBetween } from './dates.js' +import { RECHNUNGS_ARTEN } from '../auftraege/konstanten.js' import { getSettings } from './settings.js' /** @@ -87,6 +88,11 @@ export function mapInvoice(raw, { today }) { pdfUrl: raw.invoice_pdf || null, outOfBand: Boolean(metadata.bankTransactionId), bankTransactionId: metadata.bankTransactionId || '', + projektId: metadata.projektId || '', + projektName: metadata.projektName || '', + art: RECHNUNGS_ARTEN.includes(metadata.art) ? metadata.art : subscriptionIdOfInvoice(raw) ? 'abo' : 'frei', + auftragId: metadata.auftragId || '', + auftragNummer: metadata.auftragNummer || '', } } diff --git a/test/auftraegeStripe.test.js b/test/auftraegeStripe.test.js new file mode 100644 index 0000000..b617218 --- /dev/null +++ b/test/auftraegeStripe.test.js @@ -0,0 +1,57 @@ +import { test, after, beforeEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js' + +const aw = await startAppwriteMock({ + schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') }, + datetimes: AUFTRAEGE_DATETIMES, + defaultLimit: 25, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { seedRechnungsDaten, legeAuftragAn, leereRechnungsDaten } = await import('./auftraegeRechnungenHilfe.js') +const { + mapInvoice, createProjectInvoice, rechnungsVorschau, sendInvoiceNow, voidInvoice, deleteDraft, invalidateFinanceCache, resetInvoiceCreateGuardForTests, +} = await import('../server/services/finance/stripeFinance.js') +const { getProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js') +const { getAuftrag } = await import('../server/services/auftraege/auftragStore.js') +const { sperreAuftrag } = await import('../server/services/auftraege/konstanten.js') +const { withLocks } = await import('../server/services/keyedLock.js') +const { berlinDate, formatDe } = await import('../server/services/finance/dates.js') + +after(() => Promise.all([aw.close(), st.close()])) +seedRechnungsDaten(aw) +config.auftraege.mode = 'on' +beforeEach(async () => { + mock.restoreAll() + await leereRechnungsDaten(aw, st) + invalidateFinanceCache() + resetInvoiceCreateGuardForTests() +}) + +const NBSP = String.fromCharCode(160) // geschütztes Leerzeichen U+00A0, wie euroDe es vor € setzt +const POSTEN = [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }] +const callsTo = (method, path) => st.calls.filter((c) => c.method === method && (path instanceof RegExp ? path.test(c.path) : c.path === path)) +const writes = () => st.calls.filter((c) => c.method !== 'GET') +const wirft = (promise, status, message) => + assert.rejects(promise, (err) => { + assert.equal(err.status, status, `Status ${err.status}: ${err.message}`) + assert.equal(err.message, message) + return true + }) + +test('mapInvoice: Projektfelder aus den Metadaten, art mit Rückfall abo oder frei', () => { + const roh = (over = {}) => ({ id: 'in_1', object: 'invoice', status: 'open', customer: 'cus_1', created: 1_790_000_000, total: 1000, metadata: {}, lines: { object: 'list', data: [] }, ...over }) + const felder = (r) => [r.projektId, r.projektName, r.art, r.auftragId, r.auftragNummer] + const heute = { today: '2026-10-06' } + assert.deepEqual(felder(mapInvoice(roh(), heute)), ['', '', 'frei', '', '']) + assert.deepEqual(felder(mapInvoice(roh({ subscription: 'sub_1' }), heute)), ['', '', 'abo', '', '']) + const metadata = { projektId: 'p1', projektName: 'Website Wolf', art: 'schluss', auftragId: 'a1', auftragNummer: 'AB-2026-0002' } + assert.deepEqual(felder(mapInvoice(roh({ metadata }), heute)), ['p1', 'Website Wolf', 'schluss', 'a1', 'AB-2026-0002']) + for (const art of ['quatsch', 'abo', '']) assert.equal(mapInvoice(roh({ metadata: { art } }), heute).art, 'frei', `art "${art}"`) +}) diff --git a/test/financeStripeRead.test.js b/test/financeStripeRead.test.js index f0996f5..0bb31c6 100644 --- a/test/financeStripeRead.test.js +++ b/test/financeStripeRead.test.js @@ -114,6 +114,11 @@ test('mapInvoice: Felder, Betraege in Cent und Berliner Kalendertage', () => { pdfUrl: 'https://invoice.stripe.test/i/in_1/pdf', outOfBand: false, bankTransactionId: '', + projektId: '', + projektName: '', + art: 'frei', + auftragId: '', + auftragNummer: '', }) }) From d264f2af5a87f69fecfacdca3abd102d9db7afba Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:16:48 +0000 Subject: [PATCH 09/12] Rechnungen: Projektrechnungen bei Stripe (Zusatzfelder, A4, Minuszeilen, Projektdatensatz) und Vorschau Co-Authored-By: Claude Sonnet 5.5 --- server/routes/admin/finanzen.js | 3 +- server/services/finance/stripeFinance.js | 103 +++++++++------------ test/auftraegeStripe.test.js | 108 +++++++++++++++++++++++ 3 files changed, 151 insertions(+), 63 deletions(-) diff --git a/server/routes/admin/finanzen.js b/server/routes/admin/finanzen.js index 43d66a9..f48008e 100644 --- a/server/routes/admin/finanzen.js +++ b/server/routes/admin/finanzen.js @@ -1,6 +1,7 @@ import { Router } from 'express' import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' import { withLocks } from '../../services/keyedLock.js' +import { AuftragError } from '../../services/auftraege/fehler.js' import { EnableBankingError } from '../../services/finance/enableBanking.js' import { completeBankAuth, startBankAuth, syncBank } from '../../services/finance/bankSync.js' import { getConnection, listTransactions, publicConnection, saveConnection } from '../../services/finance/bankStore.js' @@ -148,7 +149,7 @@ const isStripeError = (err) => typeof err?.type === 'string' && err.type.startsW * Fehler einen status tragen: nur die beiden eigenen Fehlerarten werden weitergegeben. */ function sendFinanceError(res, what, err) { - if (err instanceof AssignError || err instanceof InvoiceError) { + if (err instanceof AssignError || err instanceof InvoiceError || err instanceof AuftragError) { // Ein 502 an einer bestehenden Rechnung (nicht gesendet, nicht lesbar) hat seine Ursache bei Stripe: nur Art, Status und Code ins Protokoll if (err.status === 502 && err.cause) logFailure(what, err.cause) const body = { error: err.message } diff --git a/server/services/finance/stripeFinance.js b/server/services/finance/stripeFinance.js index 85c584e..468a5f1 100644 --- a/server/services/finance/stripeFinance.js +++ b/server/services/finance/stripeFinance.js @@ -1,9 +1,10 @@ import { createHash } from 'node:crypto' import { config } from '../../config.js' import { getStripe, getOrCreateStripeCustomer, stripeCustomerFields, subscriptionIdOfInvoice } from '../stripeBilling.js' -import { getDocument } from '../appwriteAdmin.js' import { addDays, berlinDate, daysBetween } from './dates.js' import { RECHNUNGS_ARTEN } from '../auftraege/konstanten.js' +import { baueRechnung, ladeFestenKunden } from '../auftraege/rechnungsbau.js' +import { createProjektRechnung } from '../auftraege/projektRechnungenStore.js' import { getSettings } from './settings.js' /** @@ -132,11 +133,6 @@ export async function listPayouts({ force = false } = {}) { // Projektrechnungen schreiben // --------------------------------------------------------------------------- -const MAX_ITEMS = 20 -const MAX_DESCRIPTION = 200 -const MAX_QUANTITY = 999 -const MAX_UNIT_CENTS = 10_000_000 -const MAX_TOTAL_CENTS = 99_999_999 const MAX_DAYS_UNTIL_DUE = 60 const DEFAULT_DAYS_UNTIL_DUE = 14 const MAX_MEMO = 1000 @@ -208,45 +204,6 @@ export function invoiceFooter(bankDetailsText = '') { const isWholeNumberBetween = (value, min, max) => Number.isInteger(value) && value >= min && value <= max -/** Positionen pruefen und bereinigen (Beschreibung ohne Rand-Leerzeichen); Fehler mit status 400. */ -function validateItems(items) { - if (!Array.isArray(items) || items.length < 1 || items.length > MAX_ITEMS) { - throw httpError(400, `Eine Rechnung braucht 1 bis ${MAX_ITEMS} Positionen.`) - } - const lines = items.map((item, index) => { - const label = `Position ${index + 1}` - const description = typeof item?.description === 'string' ? item.description.trim() : '' - if (description.length < 1 || description.length > MAX_DESCRIPTION) { - throw httpError(400, `${label}: Die Beschreibung braucht 1 bis ${MAX_DESCRIPTION} Zeichen.`) - } - if (!isWholeNumberBetween(item.quantity, 1, MAX_QUANTITY)) { - throw httpError(400, `${label}: Die Menge muss eine ganze Zahl von 1 bis ${MAX_QUANTITY} sein.`) - } - if (!isWholeNumberBetween(item.unitAmountCents, 1, MAX_UNIT_CENTS)) { - throw httpError(400, `${label}: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`) - } - return { description, quantity: item.quantity, unitAmountCents: item.unitAmountCents } - }) - const totalCents = lines.reduce((sum, line) => sum + line.quantity * line.unitAmountCents, 0) - if (totalCents > MAX_TOTAL_CENTS) throw httpError(400, 'Rechnungssumme zu hoch.') - return lines -} - -/** Kunde aus Appwrite; nur feste Kunden (nicht lead/lost) bekommen Rechnungen. */ -async function loadBillableCustomer(customerId) { - if (typeof customerId !== 'string' || !customerId) throw httpError(400, 'Kunde nicht gefunden.') - let customer - try { - customer = await getDocument(config.collections.customers, customerId) - } catch (err) { - if (err.status === 404) throw httpError(400, 'Kunde nicht gefunden.') - throw err - } - const stage = String(customer.customerStatus || '').trim().toLowerCase() - if (stage === 'lead' || stage === 'lost') throw httpError(400, 'Rechnungen nur für feste Kunden.') - return customer -} - /** Eine Rechnung frisch von Stripe (mit Kunde), roh; unbekannte Rechnung -> status 404. */ async function retrieveInvoice(invoiceId) { if (typeof invoiceId !== 'string' || !/^in_\w+$/.test(invoiceId)) throw httpError(404, 'Rechnung nicht gefunden.') @@ -344,14 +301,20 @@ function validateIdempotencyKey(key) { */ export async function createProjectInvoice({ customerId, + art = 'frei', + projektId, + auftragId, items, + arbeitsblattIds, + anzahlung, + zeilentexte, + leistungszeitraum, daysUntilDue = DEFAULT_DAYS_UNTIL_DUE, memo = '', senden = false, idempotencyKey, createdBy, }) { - const lines = validateItems(items) if (!isWholeNumberBetween(daysUntilDue, 0, MAX_DAYS_UNTIL_DUE)) { throw httpError(400, `Das Zahlungsziel muss eine ganze Zahl von 0 bis ${MAX_DAYS_UNTIL_DUE} Tagen sein.`) } @@ -359,10 +322,15 @@ export async function createProjectInvoice({ if (note.length > MAX_MEMO) throw httpError(400, `Der Vermerk darf höchstens ${MAX_MEMO} Zeichen lang sein.`) const key = validateIdempotencyKey(idempotencyKey) - const fingerprint = createHash('sha256').update(JSON.stringify([customerId, lines, note, daysUntilDue, senden === true])).digest('hex') + // Der Fingerabdruck wirft nie: ein Array wird sortiert kopiert, jeder andere Wert bleibt; Typfehler meldet baueRechnung (400) + const sortiert = Array.isArray(arbeitsblattIds) ? [...arbeitsblattIds].sort() : arbeitsblattIds + const fingerprint = createHash('sha256') + .update(JSON.stringify([customerId, art, projektId ?? '', auftragId ?? '', items ?? null, sortiert ?? null, anzahlung ?? null, zeilentexte ?? null, leistungszeitraum ?? '', note, daysUntilDue, senden === true])) + .digest('hex') claimCreate(fingerprint) try { - const invoice = await createInvoice({ customerId, lines, daysUntilDue, note, senden: senden === true, key, createdBy }) + const eingabe = { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, createdBy } + const invoice = await createInvoice({ eingabe, daysUntilDue, note, senden: senden === true, key }) releaseCreate(fingerprint, true) return invoice } catch (err) { @@ -372,11 +340,21 @@ export async function createProjectInvoice({ } } -async function createInvoice({ customerId, lines, daysUntilDue, note, senden, key, createdBy }) { - const customer = await loadBillableCustomer(customerId) +/** + * Vorschau einer Projektrechnung: dieselben Pruefungen wie das Anlegen, aber kein Schreibzugriff (weder Stripe noch + * Appwrite), kein Schutz vor doppeltem Anlegen, keine Sperre. + */ +export async function rechnungsVorschau(eingabe) { + const { positionen, summeCents, leistungszeitraum, customFields } = await baueRechnung(eingabe) + return { positionen, summeCents, leistungszeitraum, customFields } +} + +async function createInvoice({ eingabe, daysUntilDue, note, senden, key }) { + const entwurf = await baueRechnung(eingabe) + const customer = entwurf.kunde // Ohne Adresse lehnt Stripe das Senden erst nach dem Festschreiben ab: lieber vorab pruefen if (senden && !String(customer.email || '').trim()) throw httpError(400, NO_EMAIL_MESSAGE) - if (senden) assertAddressForSend(customer, lines.reduce((sum, line) => sum + line.quantity * line.unitAmountCents, 0)) + if (senden) assertAddressForSend(customer, entwurf.summeCents) const { bankDetailsText } = await getSettings() const stripe = getStripe() @@ -395,22 +373,23 @@ async function createInvoice({ customerId, lines, daysUntilDue, note, senden, ke currency: 'eur', footer: invoiceFooter(bankDetailsText), description: note || undefined, - metadata: { webklarCustomerId: customer.$id, quelle: 'woms', erstelltVon: String(createdBy || '').slice(0, 500) }, + metadata: entwurf.metadata, + custom_fields: entwurf.customFields.length ? entwurf.customFields : undefined, + rendering: { pdf: { page_size: 'a4' } }, }, key ? { idempotencyKey: key } : undefined ) invoiceId = draft.id // der Reihe nach, damit die Positionen in der eingegebenen Reihenfolge auf der Rechnung stehen - for (const line of lines) { - await stripe.invoiceItems.create({ - customer: stripeCustomerId, - invoice: invoiceId, - description: line.description, - quantity: line.quantity, - unit_amount_decimal: String(line.unitAmountCents), - currency: 'eur', - }) + for (const position of entwurf.positionen) { + const posten = { customer: stripeCustomerId, invoice: invoiceId, description: position.beschreibung } + // Minuszeile (Abzug fruehere Rechnung): nur amount, ohne quantity + if (position.einzelpreisCents < 0) Object.assign(posten, { amount: position.betragCents }) + else Object.assign(posten, { quantity: position.menge, unit_amount_decimal: String(position.einzelpreisCents) }) + posten.currency = 'eur' + await stripe.invoiceItems.create(posten) } + if (entwurf.projektRechnung) await createProjektRechnung(invoiceId, entwurf.projektRechnung) } catch (err) { // Halbfertigen Entwurf nicht liegen lassen; scheitert auch das, bleibt er als Entwurf sichtbar und loeschbar if (invoiceId) await stripe.invoices.del(invoiceId).catch(() => {}) @@ -454,7 +433,7 @@ export async function sendInvoiceNow(invoiceId) { // Nur ein Entwurf wird festgeschrieben: erst dann zaehlen Kundendaten und Anschrift. Eine offene Rechnung steht schon // fest (Anschrift und Empfaenger sind festgeschrieben), sie wird nur erneut gesendet. let customer = null - if (isDraft && invoice.webklarCustomerId) customer = await loadBillableCustomer(invoice.webklarCustomerId) + if (isDraft && invoice.webklarCustomerId) customer = await ladeFestenKunden(invoice.webklarCustomerId) // Nach dem Abgleich traegt der Stripe-Kunde die E-Mail des WOMS-Kunden; ohne die gilt die, die Stripe schon hat const email = String(customer?.email || '').trim() || String(invoice.customerEmail || '').trim() if (!email) throw httpError(400, NO_EMAIL_MESSAGE) diff --git a/test/auftraegeStripe.test.js b/test/auftraegeStripe.test.js index b617218..c81715d 100644 --- a/test/auftraegeStripe.test.js +++ b/test/auftraegeStripe.test.js @@ -55,3 +55,111 @@ test('mapInvoice: Projektfelder aus den Metadaten, art mit Rückfall abo oder fr assert.deepEqual(felder(mapInvoice(roh({ metadata }), heute)), ['p1', 'Website Wolf', 'schluss', 'a1', 'AB-2026-0002']) for (const art of ['quatsch', 'abo', '']) assert.equal(mapInvoice(roh({ metadata: { art } }), heute).art, 'frei', `art "${art}"`) }) + +test('createProjectInvoice frei: A4 für jede Rechnung, Art in den Metadaten, keine Zusatzfelder, kein Projektdatensatz', async () => { + const rechnung = await createProjectInvoice({ customerId: 'k1', items: POSTEN, createdBy: 'kenso@webklar.com' }) + const [create] = callsTo('POST', '/v1/invoices') + assert.equal(create.body['rendering[pdf][page_size]'], 'a4') + assert.equal(create.body['metadata[art]'], 'frei') + assert.equal(create.body['metadata[webklarCustomerId]'], 'k1') + assert.ok(!Object.keys(create.body).some((k) => k.startsWith('custom_fields'))) + assert.ok(!Object.keys(create.body).some((k) => /^metadata\[(projektId|projektName|auftragId|auftragNummer)\]$/.test(k))) + assert.deepEqual([rechnung.art, rechnung.status, rechnung.totalCents, rechnung.projektId], ['frei', 'entwurf', 1200, '']) + assert.equal(aw.docs(config.collections.projektRechnungen).size, 0) +}) + +test('createProjectInvoice festbetrag: Zusatzfeld Projekt, Metadaten und ein Projektdatensatz mit der Rechnungs-ID', async () => { + const items = [{ description: 'Erstellung der Website anelia-wolf.com, Pauschalpreis laut Absprache', quantity: 1, unitAmountCents: 50000 }] + const rechnung = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items, createdBy: 'kenso@webklar.com' }) + const [create] = callsTo('POST', '/v1/invoices') + assert.equal(create.body['custom_fields[0][name]'], 'Projekt') + assert.equal(create.body['custom_fields[0][value]'], 'Website anelia-wolf.com') + assert.equal(create.body['custom_fields[1][name]'], undefined) + assert.equal(create.body['rendering[pdf][page_size]'], 'a4') + assert.deepEqual([create.body['metadata[art]'], create.body['metadata[projektId]'], create.body['metadata[projektName]']], ['festbetrag', 'p1', 'Website anelia-wolf.com']) + assert.deepEqual([rechnung.art, rechnung.projektId, rechnung.projektName, rechnung.totalCents], ['festbetrag', 'p1', 'Website anelia-wolf.com', 50000]) + const doc = await getProjektRechnung(rechnung.id) + assert.deepEqual( + [doc.invoiceId, doc.kundenId, doc.projektId, doc.auftragId, doc.art, doc.betragCents, doc.rechnungsnummer, doc.aktiv, doc.erstelltVon], + [rechnung.id, 'k1', 'p1', '', 'festbetrag', 50000, '', true, 'kenso@webklar.com'] + ) +}) + +test('createProjectInvoice aufwand: eine Zeile je Tag bei Stripe, Blätter im Projektdatensatz, Zeitraum als Zusatzfeld', async () => { + const rechnung = await createProjectInvoice({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws2', 'ws3'], createdBy: 'k' }) + const items = callsTo('POST', '/v1/invoiceitems') + assert.deepEqual(items.map((c) => [c.body.quantity, c.body.unit_amount_decimal, c.body.amount]), [['1', '22500', undefined], ['1', '10000', undefined]]) + assert.ok(items[0].body.description.startsWith('06.10.2026: ')) + const [create] = callsTo('POST', '/v1/invoices') + assert.deepEqual([0, 1].map((i) => create.body[`custom_fields[${i}][name]`]), ['Projekt', 'Leistungszeitraum']) + assert.equal(create.body['custom_fields[1][value]'], '06.10.–07.10.2026') + assert.equal(rechnung.totalCents, 32500) + assert.deepEqual((await getProjektRechnung(rechnung.id)).arbeitsblattIds, ['ws1', 'ws2', 'ws3']) +}) + +test('createProjectInvoice schluss: positive Zeilen mit Menge und Einzelpreis, die Minuszeile nur mit amount (ohne quantity)', async () => { + const ab = await legeAuftragAn() + const anzahlung = await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, senden: true, createdBy: 'k' }) + assert.deepEqual([anzahlung.totalCents, anzahlung.art, anzahlung.auftragNummer, anzahlung.status], [40000, 'anzahlung', 'AB-2026-0002', 'offen']) + st.calls.length = 0 + const schluss = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, createdBy: 'k' }) + const [create] = callsTo('POST', '/v1/invoices') + assert.deepEqual( + [0, 1].map((i) => [create.body[`custom_fields[${i}][name]`], create.body[`custom_fields[${i}][value]`]]), + [['Projekt', 'Website anelia-wolf.com'], ['Auftrag', 'AB-2026-0002 vom 06.10.2026']] + ) + const items = callsTo('POST', '/v1/invoiceitems') + const kunde = items[0].body.customer + assert.deepEqual(items.map((c) => c.body), [ + { customer: kunde, invoice: schluss.id, description: 'Website', quantity: '1', unit_amount_decimal: '60000', currency: 'eur' }, + { customer: kunde, invoice: schluss.id, description: 'Texte', quantity: '2', unit_amount_decimal: '10000', currency: 'eur' }, + { customer: kunde, invoice: schluss.id, description: `abzüglich Anzahlung, Rechnung ${anzahlung.number} vom ${formatDe(berlinDate())}`, amount: '-40000', currency: 'eur' }, + ]) + assert.deepEqual([schluss.totalCents, schluss.art, schluss.status], [40000, 'schluss', 'entwurf']) +}) + +test('createProjectInvoice: scheitert der Projektdatensatz, wird der Entwurf bei Stripe gelöscht und nichts bleibt zurück', async () => { + const eingabe = { customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, createdBy: 'k' } + aw.failNext({ collection: config.collections.projektRechnungen, method: 'POST', status: 500 }) + await assert.rejects(createProjectInvoice(eingabe)) + assert.equal(st.invoices.size, 0) + assert.equal(callsTo('DELETE', /^\/v1\/invoices\/in_/).length, 1) + assert.equal(aw.docs(config.collections.projektRechnungen).size, 0) + // der Schutz vor doppeltem Anlegen ist wieder frei + assert.equal((await createProjectInvoice(eingabe)).status, 'entwurf') +}) + +test('createProjectInvoice: der Fingerabdruck unterscheidet Art, Projekt, Auftrag und Anzahlung, gleicher Inhalt bleibt 30 Sekunden gesperrt', async () => { + const basis = { customerId: 'k1', items: POSTEN } + const GESPERRT = 'Diese Rechnung wird gerade schon erstellt.' + await createProjectInvoice(basis) + await createProjectInvoice({ ...basis, art: 'festbetrag', projektId: 'p1' }) + await createProjectInvoice({ ...basis, art: 'festbetrag', projektId: 'p3' }) + await wirft(createProjectInvoice({ ...basis, art: 'festbetrag', projektId: 'p3' }), 409, GESPERRT) + const ab = await legeAuftragAn() + await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 50 } }) + await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 25 } }) + await wirft(createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 25 } }), 409, GESPERRT) + assert.equal(st.invoices.size, 5) +}) + +test('rechnungsVorschau: Positionen, Summe, Zeitraum und Zusatzfelder ohne Stripe-Schreibzugriff und ohne Projektdatensatz', async () => { + const v = await rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p1' }) + assert.deepEqual(Object.keys(v).sort(), ['customFields', 'leistungszeitraum', 'positionen', 'summeCents']) + assert.equal(v.summeCents, 32500) + assert.deepEqual(v.positionen.map((p) => p.schluessel), ['2026-10-06', '2026-10-07']) + assert.equal(v.leistungszeitraum, '06.10.–07.10.2026') + assert.deepEqual(v.customFields.map((f) => f.name), ['Projekt', 'Leistungszeitraum']) + assert.equal(writes().length, 0) + assert.equal(aw.docs(config.collections.projektRechnungen).size, 0) +}) + +test('rechnungsVorschau: dieselben Prüfungen wie das Anlegen; Anzahlung ohne Angaben zeigt den Standardwert', async () => { + await createProjectInvoice({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] }) + await wirft(rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws3'] }), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).') + await wirft(rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p2' }), 404, 'Projekt nicht gefunden.') + const ab = await legeAuftragAn() + const v = await rechnungsVorschau({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id }) + assert.deepEqual([v.summeCents, v.positionen[0].schluessel], [40000, 'anzahlung']) + assert.equal(aw.docs(config.collections.projektRechnungen).size, 1, 'nur der Entwurf von oben') +}) From ab3e6938dab5de5df50635a8eacfbfd577fc50c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:17:11 +0000 Subject: [PATCH 10/12] Rechnungen: Sperre je Kunde gegen doppelte Abrechnung (Aufwand, Anzahlung, Schluss) Co-Authored-By: Claude Sonnet 5.5 --- server/services/finance/stripeFinance.js | 8 +++-- test/auftraegeStripe.test.js | 40 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/server/services/finance/stripeFinance.js b/server/services/finance/stripeFinance.js index 468a5f1..b4787f2 100644 --- a/server/services/finance/stripeFinance.js +++ b/server/services/finance/stripeFinance.js @@ -2,7 +2,8 @@ import { createHash } from 'node:crypto' import { config } from '../../config.js' import { getStripe, getOrCreateStripeCustomer, stripeCustomerFields, subscriptionIdOfInvoice } from '../stripeBilling.js' import { addDays, berlinDate, daysBetween } from './dates.js' -import { RECHNUNGS_ARTEN } from '../auftraege/konstanten.js' +import { RECHNUNGS_ARTEN, sperreRechnung } from '../auftraege/konstanten.js' +import { withLocks } from '../keyedLock.js' import { baueRechnung, ladeFestenKunden } from '../auftraege/rechnungsbau.js' import { createProjektRechnung } from '../auftraege/projektRechnungenStore.js' import { getSettings } from './settings.js' @@ -330,7 +331,10 @@ export async function createProjectInvoice({ claimCreate(fingerprint) try { const eingabe = { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, createdBy } - const invoice = await createInvoice({ eingabe, daysUntilDue, note, senden: senden === true, key }) + const anlegen = () => createInvoice({ eingabe, daysUntilDue, note, senden: senden === true, key }) + // Aufwand, Anzahlung und Schluss: Pruefung, Entwurf, Posten, Projektdatensatz und Senden je Kunde nacheinander, + // damit zwei Admins nie dieselben Arbeitsblaetter oder dieselbe AB gleichzeitig abrechnen + const invoice = ['aufwand', 'anzahlung', 'schluss'].includes(art) ? await withLocks(sperreRechnung(customerId), anlegen) : await anlegen() releaseCreate(fingerprint, true) return invoice } catch (err) { diff --git a/test/auftraegeStripe.test.js b/test/auftraegeStripe.test.js index c81715d..043ecc6 100644 --- a/test/auftraegeStripe.test.js +++ b/test/auftraegeStripe.test.js @@ -163,3 +163,43 @@ test('rechnungsVorschau: dieselben Prüfungen wie das Anlegen; Anzahlung ohne An assert.deepEqual([v.summeCents, v.positionen[0].schluessel], [40000, 'anzahlung']) assert.equal(aw.docs(config.collections.projektRechnungen).size, 1, 'nur der Entwurf von oben') }) + +test('Aufwand: zwei gleichzeitige Rechnungen mit denselben Blättern -> genau eine entsteht, die andere bekommt 409', async () => { + st.delayNext({ method: 'POST', path: '/v1/invoices', ms: 150 }) + const eingabe = (memo) => ({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws2'], memo, createdBy: 'k' }) + const ergebnisse = await Promise.allSettled([createProjectInvoice(eingabe('Admin A')), createProjectInvoice(eingabe('Admin B'))]) + assert.deepEqual(ergebnisse.map((e) => e.status), ['fulfilled', 'rejected']) + assert.equal(ergebnisse[1].reason.status, 409) + assert.equal(ergebnisse[1].reason.message, 'Arbeitsblatt bereits abgerechnet (Entwurf).') + assert.equal(st.invoices.size, 1) + assert.equal(callsTo('POST', '/v1/invoices').length, 1) + assert.equal(aw.docs(config.collections.projektRechnungen).size, 1) +}) + +test('Aufwand: eine nur teilweise überschneidende Auswahl wird abgewiesen, die übrigen Blätter bleiben frei', async () => { + const eingabe = (ids, memo) => ({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ids, memo }) + await createProjectInvoice(eingabe(['ws1', 'ws2'], 'A')) + await wirft(createProjectInvoice(eingabe(['ws2', 'ws3'], 'B')), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).') + const rest = await createProjectInvoice(eingabe(['ws3'], 'C')) + assert.equal(rest.totalCents, 10000) + assert.equal(st.invoices.size, 2) +}) + +test('Doppelklick (gleicher Inhalt gleichzeitig): der Schutz vor doppeltem Anlegen greift, es entsteht eine Rechnung', async () => { + const eingabe = { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] } + const ergebnisse = await Promise.allSettled([createProjectInvoice(eingabe), createProjectInvoice(eingabe)]) + assert.deepEqual(ergebnisse.map((e) => e.status), ['fulfilled', 'rejected']) + assert.equal(ergebnisse[1].reason.message, 'Diese Rechnung wird gerade schon erstellt.') + assert.equal(st.invoices.size, 1) +}) + +test('Schluss: zwei gleichzeitige Schlussrechnungen zur selben AB -> eine entsteht, die andere stößt auf den Entwurf (409)', async () => { + const ab = await legeAuftragAn() + st.delayNext({ method: 'POST', path: '/v1/invoices', ms: 150 }) + const eingabe = (memo) => ({ customerId: 'k1', art: 'schluss', auftragId: ab.id, memo }) + const ergebnisse = await Promise.allSettled([createProjectInvoice(eingabe('A')), createProjectInvoice(eingabe('B'))]) + assert.deepEqual(ergebnisse.map((e) => e.status), ['fulfilled', 'rejected']) + assert.equal(ergebnisse[1].reason.status, 409) + assert.equal(ergebnisse[1].reason.message, `Erst den Entwurf über 800,00${NBSP}€ senden oder löschen.`) + assert.equal(st.invoices.size, 1) +}) From 88a7305e41397677d92a0091180adce1b1dc842f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:17:58 +0000 Subject: [PATCH 11/12] =?UTF-8?q?Rechnungen:=20Rechnungsnummer=20buchen,?= =?UTF-8?q?=20AB=20abgerechnet,=20Storno=20und=20L=C3=B6schen=20geben=20fr?= =?UTF-8?q?ei?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/rechnungsbau.js | 8 +++ server/services/finance/stripeFinance.js | 40 +++++++++-- test/auftraegeStripe.test.js | 83 +++++++++++++++++++++++ 3 files changed, 125 insertions(+), 6 deletions(-) diff --git a/server/services/auftraege/rechnungsbau.js b/server/services/auftraege/rechnungsbau.js index 080f0c6..190a12f 100644 --- a/server/services/auftraege/rechnungsbau.js +++ b/server/services/auftraege/rechnungsbau.js @@ -85,6 +85,14 @@ export async function buche(was, fn) { } } +/** Schlussrechnung finalisiert: die AB geht von versendet oder bestaetigt auf abgerechnet. Unter der Sperre der AB. */ +export async function setzeAbAbgerechnet(auftragId) { + await withLocks(sperreAuftrag(auftragId), async () => { + const ab = await getAuftrag(auftragId) + if (ab && (ab.status === 'versendet' || ab.status === 'bestaetigt')) await updateAuftrag(ab.id, { status: 'abgerechnet' }) + }) +} + /** Schlussrechnung weg (storniert, geloescht): die AB geht von abgerechnet zurueck. Unter der Sperre der AB. */ export async function setzeAbZurueck(auftragId) { await withLocks(sperreAuftrag(auftragId), async () => { diff --git a/server/services/finance/stripeFinance.js b/server/services/finance/stripeFinance.js index b4787f2..d3f2a6c 100644 --- a/server/services/finance/stripeFinance.js +++ b/server/services/finance/stripeFinance.js @@ -4,8 +4,8 @@ import { getStripe, getOrCreateStripeCustomer, stripeCustomerFields, subscriptio import { addDays, berlinDate, daysBetween } from './dates.js' import { RECHNUNGS_ARTEN, sperreRechnung } from '../auftraege/konstanten.js' import { withLocks } from '../keyedLock.js' -import { baueRechnung, ladeFestenKunden } from '../auftraege/rechnungsbau.js' -import { createProjektRechnung } from '../auftraege/projektRechnungenStore.js' +import { baueRechnung, ladeFestenKunden, buche, gibProjektRechnungFrei, setzeAbAbgerechnet } from '../auftraege/rechnungsbau.js' +import { createProjektRechnung, updateProjektRechnung } from '../auftraege/projektRechnungenStore.js' import { getSettings } from './settings.js' /** @@ -221,12 +221,34 @@ async function readInvoice(invoiceId) { return mapInvoice(await retrieveInvoice(invoiceId), { today: berlinDate() }) } +// Buchungen am Projektdatensatz nach einem Stripe-Erfolg: scheitert Appwrite, wird nur protokolliert (siehe buche) +const PROJEKT_ARTEN = RECHNUNGS_ARTEN.filter((art) => art !== 'frei') + +async function bucheNummer(invoiceId, number, art) { + if (!PROJEKT_ARTEN.includes(art) || !number) return + await buche('Rechnungsnummer buchen', () => updateProjektRechnung(invoiceId, { rechnungsnummer: number })) +} + +/** Finalisiert: Rechnungsnummer in den Datensatz; bei einer Schlussrechnung geht die AB auf abgerechnet. */ +async function bucheFinalisiert(invoiceId, number, art, auftragId) { + if (!PROJEKT_ARTEN.includes(art)) return + await bucheNummer(invoiceId, number, art) + if (art === 'schluss' && auftragId) await buche('AB abrechnen', () => setzeAbAbgerechnet(auftragId)) +} + +/** Storniert oder geloescht: Datensatz inaktiv (Blaetter frei); bei einer Schlussrechnung geht die AB zurueck. */ +async function bucheStorniert(invoiceId, art) { + if (!PROJEKT_ARTEN.includes(art)) return + await buche('Rechnung freigeben', () => gibProjektRechnungFrei(invoiceId)) +} + // Finalisieren ohne Auto-Advance (die Rechnung bleibt in unserer Hand), dann per E-Mail senden. Scheitert nur das // Senden, ist die Rechnung schon festgeschrieben (offen, mit Nummer): der Fehler (status 502) nennt sie, damit der // Aufrufer sie erneut senden kann (sendInvoiceNow), statt eine zweite anzulegen. -async function finalizeAndSend(stripe, invoiceId) { +async function finalizeAndSend(stripe, invoiceId, { art = 'frei', auftragId = '' } = {}) { const finalized = await stripe.invoices.finalizeInvoice(invoiceId, { auto_advance: false }) const number = finalized.number || '' + await bucheFinalisiert(invoiceId, number, art, auftragId) try { await stripe.invoices.sendInvoice(invoiceId) } catch (cause) { @@ -402,7 +424,7 @@ async function createInvoice({ eingabe, daysUntilDue, note, senden, key }) { } try { - const number = senden ? await finalizeAndSend(stripe, invoiceId) : '' + const number = senden ? await finalizeAndSend(stripe, invoiceId, { art: entwurf.art, auftragId: entwurf.auftrag?.id ?? '' }) : '' try { return await readInvoice(invoiceId) } catch (cause) { @@ -448,8 +470,12 @@ export async function sendInvoiceNow(invoiceId) { // (Name, E-Mail, Anschrift stehen danach unveraenderlich auf der Rechnung). Ohne Kennung (z. B. Entwurf aus dem // Stripe-Dashboard) gibt es keine WOMS-Daten zum Abgleichen. if (customer) await syncStripeCustomer(getStripe(), stripeCustomerIdOf(raw), customer) - await finalizeAndSend(getStripe(), invoice.id) - } else await getStripe().invoices.sendInvoice(invoice.id) + await finalizeAndSend(getStripe(), invoice.id, { art: invoice.art, auftragId: invoice.auftragId }) + } else { + // Selbstheilung nach einem ausgefallenen Buchen: eine fehlende Rechnungsnummer im Datensatz nachtragen + await bucheNummer(invoice.id, invoice.number, invoice.art) + await getStripe().invoices.sendInvoice(invoice.id) + } return await readInvoice(invoice.id) } finally { invalidateFinanceCache() @@ -463,6 +489,7 @@ export async function voidInvoice(invoiceId) { if (invoice.status !== 'offen' && invoice.status !== 'ueberfaellig') throw httpError(409, 'Nur offene Rechnungen können storniert werden.') try { await getStripe().invoices.voidInvoice(invoice.id) + await bucheStorniert(invoice.id, invoice.art) return await readInvoice(invoice.id) } finally { invalidateFinanceCache() @@ -476,6 +503,7 @@ export async function deleteDraft(invoiceId) { if (invoice.status !== 'entwurf') throw httpError(409, 'Nur Entwürfe können gelöscht werden.') try { await getStripe().invoices.del(invoice.id) + await bucheStorniert(invoice.id, invoice.art) } finally { invalidateFinanceCache() } diff --git a/test/auftraegeStripe.test.js b/test/auftraegeStripe.test.js index 043ecc6..23d50f8 100644 --- a/test/auftraegeStripe.test.js +++ b/test/auftraegeStripe.test.js @@ -203,3 +203,86 @@ test('Schluss: zwei gleichzeitige Schlussrechnungen zur selben AB -> eine entste assert.equal(ergebnisse[1].reason.message, `Erst den Entwurf über 800,00${NBSP}€ senden oder löschen.`) assert.equal(st.invoices.size, 1) }) + +test('Finalisieren trägt die Rechnungsnummer in den Projektdatensatz ein (senden und sendInvoiceNow); freie Rechnungen haben keinen Datensatz', async () => { + const gesendet = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, senden: true }) + assert.match(gesendet.number, /^WK-\d{4}$/) + assert.equal((await getProjektRechnung(gesendet.id)).rechnungsnummer, gesendet.number) + const entwurf = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, memo: 'später' }) + assert.equal((await getProjektRechnung(entwurf.id)).rechnungsnummer, '') + const nachher = await sendInvoiceNow(entwurf.id) + assert.equal((await getProjektRechnung(entwurf.id)).rechnungsnummer, nachher.number) + const frei = await createProjectInvoice({ customerId: 'k1', items: POSTEN, memo: 'frei', senden: true }) + assert.equal(await getProjektRechnung(frei.id), null) +}) + +test('Schlussrechnung: nach dem Finalisieren steht die AB auf abgerechnet; Anzahlung und Entwurf ändern den Status nicht', async () => { + const ab = await legeAuftragAn() + await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, senden: true }) + assert.equal((await getAuftrag(ab.id)).status, 'versendet') + const entwurf = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id }) + assert.equal((await getAuftrag(ab.id)).status, 'versendet') + const gesendet = await sendInvoiceNow(entwurf.id) + assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet') + assert.equal((await getProjektRechnung(entwurf.id)).rechnungsnummer, gesendet.number) + await wirft(createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, memo: 'noch eine' }), 400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.') +}) + +test('Storno der Schlussrechnung: Datensatz inaktiv, AB zurück auf bestaetigt (mit bestaetigtAm) bzw. versendet; Storno einer Anzahlung ändert die AB nie', async () => { + for (const [patch, erwartet] of [[{}, 'versendet'], [{ status: 'bestaetigt', bestaetigtAm: '2026-10-07T09:00:00.000Z' }, 'bestaetigt']]) { + await leereRechnungsDaten(aw, st) + resetInvoiceCreateGuardForTests() + const ab = await legeAuftragAn({}, patch) + const anzahlung = await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, senden: true }) + await voidInvoice(anzahlung.id) + assert.equal((await getProjektRechnung(anzahlung.id)).aktiv, false) + assert.equal((await getAuftrag(ab.id)).status, patch.status ?? 'versendet') + const schluss = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, senden: true }) + assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet') + await voidInvoice(schluss.id) + assert.equal((await getProjektRechnung(schluss.id)).aktiv, false) + assert.equal((await getAuftrag(ab.id)).status, erwartet) + // die stornierten Rechnungen zählen nicht: eine neue Schlussrechnung geht über die volle Summe + assert.equal((await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, memo: 'neu' })).totalCents, 80000) + } +}) + +test('Entwurf löschen und Storno geben die Arbeitsblätter frei (Datensatz inaktiv)', async () => { + const args = { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'], createdBy: 'k' } + const entwurf = await createProjectInvoice(args) + await wirft(createProjectInvoice({ ...args, memo: 'nochmal' }), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).') + await deleteDraft(entwurf.id) + assert.equal((await getProjektRechnung(entwurf.id)).aktiv, false) + const offen = await createProjectInvoice({ ...args, memo: 'nochmal', senden: true }) + assert.equal(offen.status, 'offen') + await wirft(createProjectInvoice({ ...args, memo: 'drittes Mal' }), 409, `Arbeitsblatt bereits abgerechnet (Rechnung ${offen.number}).`) + await voidInvoice(offen.id) + assert.equal((await getProjektRechnung(offen.id)).aktiv, false) + assert.equal((await createProjectInvoice({ ...args, memo: 'viertes Mal' })).status, 'entwurf') +}) + +test('Scheitert das Buchen nach dem Finalisieren, wird die Rechnung trotzdem gesendet (nur Protokoll); erneutes Senden trägt die Nummer nach', async () => { + const log = mock.method(console, 'error', () => {}) + aw.failNext({ collection: config.collections.projektRechnungen, method: 'PATCH', status: 500 }) + const rechnung = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, senden: true }) + assert.equal(rechnung.status, 'offen') + assert.equal(callsTo('POST', /\/send$/).length, 1) + assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes('Rechnungsnummer buchen'))) + assert.equal((await getProjektRechnung(rechnung.id)).rechnungsnummer, '') + const erneut = await sendInvoiceNow(rechnung.id) + assert.equal(erneut.status, 'offen') + assert.equal((await getProjektRechnung(rechnung.id)).rechnungsnummer, rechnung.number) +}) + +test('Die AB ändert L2 nur unter der AB-Sperre: eine laufende Statusänderung eines anderen Strangs wird nicht überholt', async () => { + const ab = await legeAuftragAn() + const entwurf = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id }) + let freigeben + const gehalten = withLocks(sperreAuftrag(ab.id), () => new Promise((resolve) => { freigeben = resolve })) + const senden = sendInvoiceNow(entwurf.id) + await new Promise((resolve) => setTimeout(resolve, 200)) + assert.equal((await getAuftrag(ab.id)).status, 'versendet', 'L2 wartet auf die Sperre der AB') + freigeben() + await Promise.all([gehalten, senden]) + assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet') +}) From d2c48ab99bced7a1344cff6ce3330780f9adf39b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:18:36 +0000 Subject: [PATCH 12/12] =?UTF-8?q?Rechnungen:=20Routen=20f=C3=BCr=20Projekt?= =?UTF-8?q?rechnungen=20und=20Vorschau?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/routes/admin/finanzen.js | 26 ++++- test/auftraegeRoutesRechnungen.test.js | 127 +++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 test/auftraegeRoutesRechnungen.test.js diff --git a/server/routes/admin/finanzen.js b/server/routes/admin/finanzen.js index f48008e..62e0b9e 100644 --- a/server/routes/admin/finanzen.js +++ b/server/routes/admin/finanzen.js @@ -22,13 +22,14 @@ import { createProjectInvoice, deleteDraft, listInvoices, + rechnungsVorschau, sendInvoiceNow, voidInvoice, } from '../../services/finance/stripeFinance.js' /** * Finanzen im WOMS (nur Admins): Übersicht, Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung, - * Rechnungen (lesen, anlegen, senden, stornieren, Entwurf löschen), Einstellungen und Fixkosten. Die Handler sind klein + * Rechnungen (lesen, anlegen, Vorschau, senden, stornieren, Entwurf löschen), Einstellungen und Fixkosten. Die Handler sind klein * und rufen die Dienste in server/services/finance auf. */ const router = Router() @@ -209,11 +210,22 @@ router.get('/rechnungen', async (req, res) => { }) router.post('/rechnungen', async (req, res) => { - const { customerId, items, daysUntilDue, memo, senden, idempotencyKey } = req.body || {} + const { + customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, + daysUntilDue, memo, senden, idempotencyKey, + } = req.body || {} try { + // Die Typen der Felder prueft baueRechnung (400); die Route reicht sie unveraendert durch const rechnung = await createProjectInvoice({ customerId, + art, + projektId, + auftragId, items, + arbeitsblattIds, + anzahlung, + zeilentexte, + leistungszeitraum, daysUntilDue: daysUntilDue ?? DEFAULT_DAYS_UNTIL_DUE, memo, // Nur ein echtes Ja sendet sofort: alles andere bleibt ein Entwurf @@ -228,6 +240,16 @@ router.post('/rechnungen', async (req, res) => { } }) +// Vorschau: dieselben Pruefungen wie das Anlegen, aber ohne Schreibzugriff +router.post('/rechnungen/vorschau', async (req, res) => { + const { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum } = req.body || {} + try { + return res.json(await rechnungsVorschau({ customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum })) + } catch (err) { + return sendFinanceError(res, 'Rechnungsvorschau', err) + } +}) + router.post('/rechnungen/:id/senden', async (req, res) => { try { return res.json({ rechnung: await sendInvoiceNow(req.params.id) }) diff --git a/test/auftraegeRoutesRechnungen.test.js b/test/auftraegeRoutesRechnungen.test.js new file mode 100644 index 0000000..c67eb44 --- /dev/null +++ b/test/auftraegeRoutesRechnungen.test.js @@ -0,0 +1,127 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js' + +const aw = await startAppwriteMock({ + schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') }, + datetimes: AUFTRAEGE_DATETIMES, + defaultLimit: 25, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { seedRechnungsDaten, legeAuftragAn, leereRechnungsDaten } = await import('./auftraegeRechnungenHilfe.js') +const { invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js') +const { startTestApp, post, rawGet } = await import('./support/testApp.js') +const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js') + +const app = await startTestApp({ '/api/admin/finanzen': financeRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) +seedRechnungsDaten(aw) +config.auftraege.mode = 'on' +beforeEach(async () => { + await leereRechnungsDaten(aw, st) + invalidateFinanceCache() + resetInvoiceCreateGuardForTests() +}) + +const admin = { headers: { Authorization: 'Bearer jwt-admin' } } +const staff = { headers: { Authorization: 'Bearer jwt-staff' } } +const base = '/api/admin/finanzen' +const POSTEN = [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }] +const SCHALTER_AUS = 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.' +const schreibaufrufe = () => st.calls.filter((c) => c.method !== 'GET') +const erwarte = async (pfad, body, status, error) => { + const res = await post(`${base}${pfad}`, body, admin) + assert.equal(res.status, status, JSON.stringify(res.body)) + assert.deepEqual(res.body, { error }) +} + +test('POST /rechnungen: der bisherige Körper (ohne art) legt eine freie A4-Rechnung an', async () => { + const res = await post(`${base}/rechnungen`, { customerId: 'k1', items: POSTEN, daysUntilDue: 14, memo: '', senden: false, idempotencyKey: 'form-1' }, admin) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual([res.body.rechnung.art, res.body.rechnung.status, res.body.rechnung.totalCents], ['frei', 'entwurf', 1200]) + assert.equal(st.calls.find((c) => c.method === 'POST' && c.path === '/v1/invoices').body['rendering[pdf][page_size]'], 'a4') +}) + +test('POST /rechnungen: Festbetrag, Aufwand, Anzahlung und Schluss tragen Projekt, Auftrag und Art in der Antwort', async () => { + const ab = await legeAuftragAn() + const antwort = async (body) => { + const res = await post(`${base}/rechnungen`, body, admin) + assert.equal(res.status, 200, JSON.stringify(res.body)) + return res.body.rechnung + } + const fest = await antwort({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN }) + assert.deepEqual([fest.art, fest.projektId, fest.projektName, fest.auftragId], ['festbetrag', 'p1', 'Website anelia-wolf.com', '']) + const aufwand = await antwort({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws2'] }) + assert.deepEqual([aufwand.art, aufwand.totalCents], ['aufwand', 22500]) + const anzahlung = await antwort({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 25 }, senden: 'true' }) + assert.deepEqual([anzahlung.art, anzahlung.status, anzahlung.totalCents, anzahlung.auftragNummer], ['anzahlung', 'offen', 20000, 'AB-2026-0002']) + const schluss = await antwort({ customerId: 'k1', art: 'schluss', auftragId: ab.id }) + assert.deepEqual([schluss.art, schluss.totalCents], ['schluss', 60000]) +}) + +test('POST /rechnungen/vorschau: Positionen, Summe, Zeitraum und Zusatzfelder, ohne Stripe-Schreibzugriff und ohne Datensatz', async () => { + const res = await post(`${base}/rechnungen/vorschau`, { customerId: 'k1', art: 'aufwand', projektId: 'p1' }, admin) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(Object.keys(res.body).sort(), ['customFields', 'leistungszeitraum', 'positionen', 'summeCents']) + assert.equal(res.body.summeCents, 32500) + assert.deepEqual(res.body.positionen.map((p) => p.schluessel), ['2026-10-06', '2026-10-07']) + assert.equal(res.body.leistungszeitraum, '06.10.–07.10.2026') + assert.deepEqual(res.body.customFields.map((f) => f.name), ['Projekt', 'Leistungszeitraum']) + assert.equal(schreibaufrufe().length, 0) + assert.equal(aw.docs(config.collections.projektRechnungen).size, 0) +}) + +test('Fehler kommen mit Status und nur der Meldung an (400, 404, 409), Schalter aus 400; nichts bei Stripe', async () => { + const ab = await legeAuftragAn() + for (const pfad of ['/rechnungen', '/rechnungen/vorschau']) { + await erwarte(pfad, { customerId: 'k1', art: 'quatsch', items: POSTEN }, 400, 'Unbekannte Rechnungsart.') + await erwarte(pfad, { customerId: 'gibtEsNicht', items: POSTEN }, 400, 'Kunde nicht gefunden.') + await erwarte(pfad, { customerId: 'k1', art: 'festbetrag', projektId: 'p2', items: POSTEN }, 404, 'Projekt nicht gefunden.') + await erwarte(pfad, { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['wsFremd'] }, 400, 'Arbeitsblatt nicht gefunden.') + await erwarte(pfad, { customerId: 'k1', art: 'schluss', auftragId: 'gibtEsNicht' }, 404, 'Auftrag nicht gefunden.') + config.auftraege.mode = 'off' + try { + await erwarte(pfad, { customerId: 'k1', art: 'anzahlung', auftragId: ab.id }, 400, SCHALTER_AUS) + } finally { + config.auftraege.mode = 'on' + } + } + assert.equal(schreibaufrufe().length, 0) +}) + +test('Abgerechnete Arbeitsblätter: Anlegen und Vorschau antworten 409 mit der Meldung', async () => { + const res = await post(`${base}/rechnungen`, { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] }, admin) + assert.equal(res.status, 200, JSON.stringify(res.body)) + for (const pfad of ['/rechnungen', '/rechnungen/vorschau']) { + await erwarte(pfad, { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws3'], memo: 'zweite' }, 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).') + } +}) + +test('Falsche Feldtypen im Körper -> 400 mit Meldung, nichts bei Stripe', async () => { + const ab = await legeAuftragAn() + await erwarte('/rechnungen', { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: 'ws1' }, 400, 'Die Arbeitsblätter müssen als Liste von Kennungen übergeben werden.') + await erwarte('/rechnungen', { customerId: 'k1', art: 'aufwand', projektId: 'p1', zeilentexte: ['x'] }, 400, 'Die Zeilentexte müssen ein Objekt mit Texten sein.') + await erwarte('/rechnungen', { customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: 'viel' }, 400, 'Die Angaben zur Anzahlung müssen ein Objekt sein.') + await erwarte('/rechnungen', { customerId: 'k1', items: 'x' }, 400, 'Eine Rechnung braucht 1 bis 20 Positionen.') + await erwarte('/rechnungen', { customerId: 'k1', art: {}, items: POSTEN }, 400, 'Unbekannte Rechnungsart.') + assert.equal(schreibaufrufe().length, 0) +}) + +test('Rechte: ohne Anmeldung 401, Mitarbeiter ohne Admin-Label 403; GET /rechnungen trägt die neuen Felder', async () => { + for (const pfad of ['/rechnungen', '/rechnungen/vorschau']) { + assert.equal((await post(`${base}${pfad}`, { customerId: 'k1', items: POSTEN })).status, 401) + assert.equal((await post(`${base}${pfad}`, { customerId: 'k1', items: POSTEN }, staff)).status, 403) + } + assert.equal(st.invoices.size, 0) + await post(`${base}/rechnungen`, { customerId: 'k1', items: POSTEN, memo: 'frei' }, admin) + await post(`${base}/rechnungen`, { customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN }, admin) + const liste = (await rawGet(`${base}/rechnungen`, admin)).body.rechnungen + const felder = (r) => [r.art, r.projektId, r.projektName, r.auftragId, r.auftragNummer] + assert.deepEqual(liste.map(felder).sort(), [['festbetrag', 'p1', 'Website anelia-wolf.com', '', ''], ['frei', '', '', '', '']]) +})