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) +})