Rechnungen: Anzahlung und Schlussrechnung bauen (Minuszeilen, Entwurfsschutz, Selbstheilung)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
11531cacb6
commit
4f8e956574
2 files changed
+256
-11
No files matched your search
@@ -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.')
|
||||
})
|
||||
Reference in new issue
Block a user