import { test, after, beforeEach, mock } 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' // Projektkonto (Strang L1): reine Rechenregeln, Stripe lesen, Konten je Kunde und Projekt, Abzeichen "abgerechnet". // Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird (Muster financeStripeRead.test.js und die Tests von L0). // worksheets, workorders, websiteProjects und customers gehoeren dem Ticketsystem und haben im Nachbau kein Schema. 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 { seedFixtures } = await import('./support/fixtures.js') const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') const { invalidateFinanceCache, listInvoices } = await import('../server/services/finance/stripeFinance.js') const { rechneKonto, rechneOhneProjekt, ladeStripeRechnungen, projektkontenFuerKunde, projektkontoDetail, abgerechnetFuerTicket } = await import( '../server/services/auftraege/projektkonto.js' ) const COLLECTIONS = ['websiteProjects', 'workorders', 'worksheets', 'auftraege', 'projektRechnungen'] async function reset() { mock.restoreAll() invalidateFinanceCache() st.calls.length = 0 st.invoices.clear() aw.resetListCalls() config.auftraege.mode = 'on' config.auftraege.pilotEmails = [] for (const name of COLLECTIONS) { for (const id of aw.docs(config.collections[name]).keys()) await deleteDocument(config.collections[name], id) } } beforeEach(reset) after(() => Promise.all([aw.close(), st.close()])) seedFixtures(aw) // ---------- reine Rechenregeln (Task 2) ---------- const ab = (over = {}) => ({ id: 'a1', status: 'versendet', art: 'festpreis', summeCents: 80000, stundensatzCents: null, schaetzungMinuten: null, gesendetAm: '2026-10-06T08:00:00.000Z', erstelltAm: '2026-10-06T07:00:00.000Z', ...over, }) const rg = (id, status, totalCents, paidCents, auftragId = '', art = 'festbetrag') => ({ art, auftragId, invoice: { id, status, totalCents, paidCents } }) const blatt = (id, minuten, over = {}) => ({ id, ticketId: 't1', woid: '100001', datum: '2026-10-06', minuten, text: '', aboZeit: false, kommentar: false, ...over }) const NULL_ZEIT = { minuten: 0, aboMinuten: 0, offeneMinuten: 0, satzCents: 10000, wertCents: 0, offenerWertCents: 0 } test('rechneKonto: ohne AB, Rechnungen und Zeit ist alles leer, der Satz ist 100 €', () => { assert.deepEqual(rechneKonto({}), { vereinbart: { festpreisCents: null, aufwand: null }, berechnetCents: 0, bezahltCents: 0, offenCents: null, zeit: NULL_ZEIT, }) }) test('rechneKonto: vereinbart sind nur Festpreis-ABs mit Status versendet, bestaetigt oder abgerechnet', () => { const konto = rechneKonto({ auftraege: [ ab({ id: 'a1', summeCents: 80000 }), ab({ id: 'a2', status: 'bestaetigt', summeCents: 20000 }), ab({ id: 'a3', status: 'abgerechnet', summeCents: 5000 }), ...['entwurf', 'storniert', 'ersetzt', 'widerrufen'].map((status, i) => ab({ id: `x${i}`, status, summeCents: 99900 })), ], }) assert.deepEqual(konto.vereinbart, { festpreisCents: 105000, aufwand: null }) }) test('rechneKonto: die jüngste gültige Aufwands-AB bestimmt Satz und Schätzung', () => { const aufwand = (over) => ab({ art: 'aufwand', summeCents: null, ...over }) const konto = rechneKonto({ auftraege: [ aufwand({ id: 'a1', stundensatzCents: 9000, schaetzungMinuten: 600, gesendetAm: '2026-10-01T08:00:00.000Z' }), aufwand({ id: 'a2', stundensatzCents: 12000, schaetzungMinuten: 300, gesendetAm: '2026-10-05T08:00:00.000Z' }), aufwand({ id: 'a3', stundensatzCents: 15000, status: 'entwurf', gesendetAm: null, erstelltAm: '2026-10-06T08:00:00.000Z' }), aufwand({ id: 'a4', stundensatzCents: 20000, status: 'storniert', gesendetAm: '2026-10-06T09:00:00.000Z' }), ], blaetter: [blatt('w1', 60)], }) assert.deepEqual(konto.vereinbart, { festpreisCents: null, aufwand: { stundensatzCents: 12000, schaetzungMinuten: 300 } }) assert.equal(konto.offenCents, null) assert.equal(konto.zeit.satzCents, 12000) assert.equal(konto.zeit.wertCents, 12000) }) test('rechneKonto: berechnet und bezahlt zählen nur finalisierte Rechnungen, Entwürfe und Stornos nicht', () => { const konto = rechneKonto({ rechnungen: [ rg('in_1', 'offen', 40000, 0), rg('in_2', 'ueberfaellig', 10000, 0), rg('in_3', 'bezahlt', 25000, 25000), rg('in_4', 'uneinbringlich', 5000, 0), rg('in_5', 'entwurf', 99900, 0), rg('in_6', 'storniert', 77700, 0), ], }) assert.equal(konto.berechnetCents, 80000) assert.equal(konto.bezahltCents, 25000) }) test('rechneKonto: Festpreis-AB plus Aufwandsrechnungen – offen rechnet nur mit den Rechnungen zur Festpreis-AB', () => { const konto = rechneKonto({ auftraege: [ab({ id: 'a1', summeCents: 80000 })], rechnungen: [ rg('in_1', 'bezahlt', 40000, 40000, 'a1', 'anzahlung'), rg('in_2', 'offen', 30000, 0, '', 'aufwand'), rg('in_3', 'bezahlt', 5000, 5000, '', 'festbetrag'), ], }) assert.equal(konto.berechnetCents, 75000) assert.equal(konto.bezahltCents, 45000) assert.equal(konto.offenCents, 40000) }) test('rechneKonto: offen ist nie negativ, Anzahlung plus Schlussrechnung ergeben genau 0', () => { const auftraege = [ab({ id: 'a1', summeCents: 80000 })] const anzahlung = rg('in_1', 'bezahlt', 40000, 40000, 'a1', 'anzahlung') assert.equal(rechneKonto({ auftraege, rechnungen: [anzahlung, rg('in_2', 'offen', 40000, 0, 'a1', 'schluss')] }).offenCents, 0) const nachtraege = rechneKonto({ auftraege, rechnungen: [anzahlung, rg('in_2', 'offen', 60000, 0, 'a1', 'schluss')] }) assert.equal(nachtraege.offenCents, 0) assert.equal(nachtraege.berechnetCents, 100000) }) test('rechneKonto: Rechnungen zu einer stornierten AB mindern das Offene der gültigen AB nicht', () => { const konto = rechneKonto({ auftraege: [ab({ id: 'a1', status: 'storniert', summeCents: 80000 }), ab({ id: 'a2', summeCents: 50000 })], rechnungen: [rg('in_1', 'offen', 40000, 0, 'a1', 'anzahlung')], }) assert.equal(konto.vereinbart.festpreisCents, 50000) assert.equal(konto.berechnetCents, 40000) assert.equal(konto.offenCents, 50000) }) test('rechneKonto: ohne Stripe sind berechnet und bezahlt 0 und offen unbekannt, Vereinbartes und Zeit bleiben', () => { const konto = rechneKonto({ auftraege: [ab({ id: 'a1', summeCents: 80000 })], rechnungen: [rg('in_1', 'bezahlt', 40000, 40000, 'a1', 'anzahlung')], blaetter: [blatt('w1', 60)], stripeOk: false, }) assert.deepEqual([konto.berechnetCents, konto.bezahltCents, konto.offenCents], [0, 0, null]) assert.equal(konto.vereinbart.festpreisCents, 80000) assert.deepEqual(konto.zeit, { ...NULL_ZEIT, minuten: 60, offeneMinuten: 60, wertCents: 10000, offenerWertCents: 10000 }) }) test('rechneKonto: Zeit erfasst ohne Abo-Zeit, Kommentare und 0 Minuten – Abo getrennt, Berechnetes nicht mehr offen', () => { const blaetter = [ blatt('w1', 60), blatt('w2', 30), blatt('w3', 45, { aboZeit: true }), blatt('w4', 15, { kommentar: true }), blatt('w5', 0), blatt('w6', 90, { aboZeit: true, kommentar: true }), ] const abgerechnet = new Map([['w1', { invoiceId: 'in_1', rechnungsnummer: 'WEBKLAR-0001' }]]) assert.deepEqual(rechneKonto({ blaetter, abgerechnet }).zeit, { minuten: 90, aboMinuten: 45, offeneMinuten: 30, satzCents: 10000, wertCents: 15000, offenerWertCents: 5000, }) }) test('rechneKonto: der Wert der Zeit ist die Summe der je Berliner Tag gerundeten Werte (wie die Zeilen der Aufwandsrechnung)', () => { const mitSatz = (stundensatzCents) => [ab({ art: 'aufwand', summeCents: null, stundensatzCents })] const blaetter = [ blatt('w1', 15, { datum: '2026-10-05' }), blatt('w2', 15, { datum: '2026-10-06' }), blatt('w3', 15, { datum: '2026-10-06' }), blatt('w4', 15, { datum: '2026-10-07' }), ] const abgerechnet = new Map([['w2', { invoiceId: 'in_1', rechnungsnummer: 'WEBKLAR-0001' }]]) // 99,90 €/Std.: 15 Minuten = 24,975 € → 2498 Cent, 30 Minuten = 49,95 € → 4995 Cent const krumm = rechneKonto({ auftraege: mitSatz(9990), blaetter, abgerechnet }).zeit // je Tag gerundet: 2498 (05.10.) + 4995 (06.10., zwei Blätter) + 2498 (07.10.) = 9991; je Blatt wären es 9992, über alle 60 Minuten 9990 assert.equal(krumm.wertCents, 9991) // offen (w2 ist berechnet): drei Tage zu je 15 Minuten = 3 × 2498 = 7494; über alle 45 Minuten wären es 7493 assert.deepEqual([krumm.minuten, krumm.offeneMinuten, krumm.offenerWertCents], [60, 45, 7494]) // 95,00 €/Std.: jede Viertelstunde geht glatt auf (2375 Cent), Tag für Tag wie insgesamt const glatt = rechneKonto({ auftraege: mitSatz(9500), blaetter, abgerechnet }).zeit assert.deepEqual([glatt.wertCents, glatt.offenerWertCents], [9500, 7125]) }) test('rechneOhneProjekt: nur finalisierte Rechnungen zählen', () => { const inv = (status, totalCents, paidCents) => ({ status, totalCents, paidCents }) const rechnungen = [ inv('offen', 12000, 0), inv('bezahlt', 8000, 8000), inv('entwurf', 5000, 0), inv('storniert', 4000, 0), inv('uneinbringlich', 1000, 0), inv('ueberfaellig', 2000, 500), ] assert.deepEqual(rechneOhneProjekt(rechnungen), { berechnetCents: 23000, bezahltCents: 8500, anzahl: 4 }) assert.deepEqual(rechneOhneProjekt([]), { berechnetCents: 0, bezahltCents: 0, anzahl: 0 }) })