Files
Webklar-Kundenbereich/test/auftraegeRechnungsbau.test.js

410 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})
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)
})
/** 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.')
})