Files
Webklar-Kundenbereich/test/auftraegeKontrolleRechnung.test.js
ClaudeandClaude Sonnet 5.5 c2245b6905 Kontrolle: Projektkonto und Rechnungsbau waehlen dieselbe Aufwands-AB
Gemeinsame Auswahl juengsteAufwandAb (gueltig, jueengste nach gesendetAm) im AB-Speicher.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:19:48 +00:00

148 lines
8.1 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.
// Schlusskontrolle (Z3.4): Korrekturen an Projektrechnungen zu Auftragsbestaetigungen
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'
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 { projektkontoDetail } = await import('../server/services/auftraege/projektkonto.js')
const { createProjectInvoice, rechnungsVorschau, sendInvoiceNow, voidInvoice, deleteDraft, invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js')
const { getAuftrag, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { umhaengen } = await import('../server/services/auftraege/projektRechnungenStore.js')
after(() => Promise.all([aw.close(), st.close()]))
seedRechnungsDaten(aw)
config.auftraege.mode = 'on'
beforeEach(async () => {
await leereRechnungsDaten(aw, st)
invalidateFinanceCache()
resetInvoiceCreateGuardForTests()
})
const letzterEntwurf = () => st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/invoices').at(-1)
let zaehler = 0
const anzahlung = (ab, prozent, extra = {}) =>
createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, projektId: 'p1', anzahlung: { prozent, bezeichnung: `Anzahlung ${++zaehler}` }, ...extra })
const schluss = (ab, extra = {}) => createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, projektId: 'p1', ...extra })
test('Zahlungsziel: Anzahlung und Schluss ohne eigene Angabe nehmen das Zahlungsziel der AB, eine Angabe gilt vor der AB', async () => {
const ab = await legeAuftragAn({ zahlungszielTage: 30 })
await anzahlung(ab, 50, { senden: true })
assert.equal(letzterEntwurf().body.days_until_due, '30')
await schluss(ab)
assert.equal(letzterEntwurf().body.days_until_due, '30')
const ab2 = await legeAuftragAn({ zahlungszielTage: 30, projektId: 'p3' }, { nummer: 'AB-2026-0003', laufnummer: 3 })
await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab2.id, projektId: 'p3', anzahlung: { prozent: 50 }, daysUntilDue: 7 })
assert.equal(letzterEntwurf().body.days_until_due, '7')
})
test('Zahlungsziel: ohne AB-Bezug (frei, festbetrag) bleibt es bei 14 Tagen', async () => {
await legeAuftragAn({ zahlungszielTage: 30 })
await createProjectInvoice({ customerId: 'k1', items: [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }] })
assert.equal(letzterEntwurf().body.days_until_due, '14')
})
const ENTWURF_ID = /^in_/
const finalisiert = (id) => st.calls.some((c) => c.method === 'POST' && c.path === `/v1/invoices/${id}/finalize`)
test('Senden: zwei Anzahlungs-Entwürfe à 60 % einer 800-€-AB – nur der erste geht, der zweite 409 und nichts finalisiert', async () => {
const ab = await legeAuftragAn()
const a = await anzahlung(ab, 60)
const b = await anzahlung(ab, 60)
assert.match(a.id, ENTWURF_ID)
await sendInvoiceNow(a.id)
st.calls.length = 0
await assert.rejects(sendInvoiceNow(b.id), (err) => err.status === 409 && /übersteig|Auftragssumme/.test(err.message))
assert.equal(finalisiert(b.id), false)
assert.equal(st.invoices.get(b.id).status, 'draft')
})
test('Senden: parallel gesendet geht von zwei 60-%-Entwürfen genau einer', async () => {
const ab = await legeAuftragAn()
const a = await anzahlung(ab, 60)
const b = await anzahlung(ab, 60)
const ergebnisse = await Promise.allSettled([sendInvoiceNow(a.id), sendInvoiceNow(b.id)])
assert.deepEqual(ergebnisse.map((e) => e.status).sort(), ['fulfilled', 'rejected'])
assert.equal([a.id, b.id].filter((id) => st.invoices.get(id).status !== 'draft').length, 1)
})
test('Senden: ein Anzahlungs-Entwurf zu einer stornierten, widerrufenen oder ersetzten AB wird nicht gesendet', async () => {
for (const status of ['storniert', 'widerrufen', 'ersetzt']) {
const ab = await legeAuftragAn({}, { nummer: `AB-2026-${status}` })
const entwurf = await anzahlung(ab, 30, { projektId: 'p1', anzahlung: { prozent: 30, bezeichnung: `A ${status}` } })
await updateAuftrag(ab.id, { status })
await assert.rejects(sendInvoiceNow(entwurf.id), (err) => err.status === 409 && /nicht mehr gültig/.test(err.message), status)
assert.equal(finalisiert(entwurf.id), false, status)
await deleteDraft(entwurf.id)
await updateAuftrag(ab.id, { status: 'storniert' })
}
})
test('Senden: ein Schluss-Entwurf wird nicht gesendet, wenn zur AB schon eine andere Schlussrechnung festgeschrieben ist', async () => {
const ab = await legeAuftragAn()
const s = await schluss(ab)
await legeRechnungAn(st, 'in_fremd1', { status: 'open', total: 80000, art: 'schluss', auftragId: ab.id })
await assert.rejects(sendInvoiceNow(s.id), (err) => err.status === 409)
assert.equal(finalisiert(s.id), false)
})
test('Senden: ein gültiger Anzahlungs- oder Schluss-Entwurf geht wie bisher', async () => {
const ab = await legeAuftragAn()
const a = await anzahlung(ab, 50)
assert.equal((await sendInvoiceNow(a.id)).status, 'offen')
const s = await schluss(ab)
assert.equal((await sendInvoiceNow(s.id)).status, 'offen')
assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet')
})
test('Storno: eine Anzahlung wird nicht storniert, solange zur AB ein Schluss-Entwurf besteht (409); nach dem Löschen des Entwurfs geht es', async () => {
const ab = await legeAuftragAn()
const a = await anzahlung(ab, 50, { senden: true })
const s = await schluss(ab)
await assert.rejects(voidInvoice(a.id), (err) => err.status === 409 && err.message === 'Erst den Entwurf der Schlussrechnung löschen.')
assert.equal(st.invoices.get(a.id).status, 'open')
await deleteDraft(s.id)
assert.equal((await voidInvoice(a.id)).status, 'storniert')
})
test('Storno: eine Anzahlung ohne Schluss-Entwurf lässt sich stornieren', async () => {
const ab = await legeAuftragAn()
const a = await anzahlung(ab, 50, { senden: true })
assert.equal((await voidInvoice(a.id)).status, 'storniert')
})
test('Finalisieren einer Schlussrechnung setzt die AB aus dem Projektdatensatz auf abgerechnet (nach Umhängen die neue AB)', async () => {
const ab = await legeAuftragAn()
const s = await schluss(ab)
// wie beim Senden einer neuen Fassung: neue AB versendet, alte ersetzt, Projektrechnungen umgehängt
const neu = await legeAuftragAn({ ersetztId: ab.id }, { nummer: 'AB-2026-0003', laufnummer: 3 })
await updateAuftrag(ab.id, { status: 'ersetzt', ersetztDurchId: neu.id })
await umhaengen(ab.id, neu.id)
await sendInvoiceNow(s.id)
assert.equal((await getAuftrag(neu.id)).status, 'abgerechnet')
assert.equal((await getAuftrag(ab.id)).status, 'ersetzt')
})
test('Stundensatz: bei zwei gültigen Aufwands-ABs rechnen Rechnung und Projektkonto mit derselben (jüngste nach gesendetAm)', async () => {
const basis = { art: 'aufwand', positionen: [], summeCents: null, zahlungsplan: { art: 'monatlich', prozent: null, text: '' } }
// A zuerst angelegt, aber später gesendet; B danach angelegt, aber früher gesendet
await legeAuftragAn({ ...basis, stundensatzCents: 8000 }, { nummer: 'AB-2026-0010', gesendetAm: '2026-10-09T08:00:00.000Z' })
await new Promise((resolve) => setTimeout(resolve, 15))
await legeAuftragAn({ ...basis, stundensatzCents: 12000 }, { nummer: 'AB-2026-0011', gesendetAm: '2026-10-06T08:00:00.000Z' })
const vorschau = await rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] })
const konto = await projektkontoDetail('p1')
assert.equal(konto.konto.zeit.satzCents, 8000)
assert.match(vorschau.positionen[0].beschreibung, /à 80,00\s€/)
})