Rechnungen: Aufwandsrechnung bauen (Auswahl, Satz aus der AB, Doppelabrechnung, 20 Zeilen)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:14:48 +00:00
1 parent b78ca971af
commit 11531cacb6
2 files changed
+329 -3

No files matched your search

+140
View File
@@ -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)
})