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:
1 parent
b78ca971af
commit
11531cacb6
2 files changed
+329
-3
No files matched your search
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user