// 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€/) })