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 }) }) // ---------- Stripe lesen (Task 3) ---------- const noonBerlin = (ymd) => Math.floor(new Date(`${ymd}T12:00:00+02:00`).getTime() / 1000) // Rohe Stripe-Rechnung wie in financeStripeRead.test.js; status in Stripes Schreibweise (draft, open, paid, void, uncollectible) const stripeInvoice = ( id, { status = 'open', total = 0, paid = 0, number = '', kunde = 'c1', created = '2026-10-01', due = '2099-12-31', abo = false } = {} ) => ({ id, object: 'invoice', number: number || null, status, customer: `cus_${kunde}`, customer_name: 'Kunde', customer_email: 'k@example.invalid', created: noonBerlin(created), due_date: noonBerlin(due), total, amount_due: total, amount_paid: paid, amount_remaining: total - paid, status_transitions: { paid_at: null }, metadata: { webklarCustomerId: kunde, quelle: 'woms' }, ...(abo ? { parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_1' } } } : {}), lines: { object: 'list', data: [], has_more: false }, }) const pr = (invoiceId) => ({ invoiceId }) test('ladeStripeRechnungen: Liste zuerst, Fehlendes einzeln gelesen, Unbekanntes bleibt aussen vor', async () => { st.invoices.set('in_1', stripeInvoice('in_1', { total: 4000, number: 'WEBKLAR-0001' })) await listInvoices() // füllt den 60-Sekunden-Zwischenspeicher; in_9 fehlt dort st.invoices.set('in_9', stripeInvoice('in_9', { status: 'paid', total: 7000, paid: 7000, number: 'WEBKLAR-0009' })) st.calls.length = 0 const result = await ladeStripeRechnungen([pr('in_1'), pr('in_9'), pr('in_404')]) assert.equal(result.ok, true) assert.deepEqual([...result.nachId.keys()].sort(), ['in_1', 'in_9']) assert.deepEqual(result.alle.map((invoice) => invoice.id), ['in_1']) const gelesen = result.nachId.get('in_9') assert.deepEqual([gelesen.status, gelesen.totalCents, gelesen.paidCents], ['bezahlt', 7000, 7000]) assert.deepEqual(st.calls.map((call) => `${call.method} ${call.path}`).sort(), ['GET /v1/invoices/in_404', 'GET /v1/invoices/in_9']) }) test('ladeStripeRechnungen: Stripe-Ausfall ergibt ok false und ein Protokoll ohne Fehlertext', async () => { const warn = mock.method(console, 'warn', () => {}) st.failNext({ method: 'GET', path: '/v1/invoices', status: 400, message: 'geheim-kaputt' }) const result = await ladeStripeRechnungen([pr('in_1')]) assert.deepEqual([result.ok, result.alle, [...result.nachId.keys()]], [false, [], []]) assert.equal(warn.mock.callCount(), 1) assert.ok(!JSON.stringify(warn.mock.calls[0].arguments).includes('geheim-kaputt')) }) test('ladeStripeRechnungen: ein anderer Fehler als 404 beim Einzellesen ist ein Ausfall', async () => { mock.method(console, 'warn', () => {}) st.invoices.set('in_1', stripeInvoice('in_1', { total: 4000 })) await listInvoices() st.invoices.set('in_9', stripeInvoice('in_9', { total: 7000 })) st.failNext({ method: 'GET', path: '/v1/invoices/in_9', status: 400, message: 'kaputt' }) const result = await ladeStripeRechnungen([pr('in_1'), pr('in_9')]) assert.equal(result.ok, false) assert.equal(result.nachId.size, 0) }) test('ladeStripeRechnungen: viele fehlende Rechnungen werden vollständig gelesen (auch IDs wie die des Stripe-Nachbaus)', async () => { await listInvoices() const ids = Array.from({ length: 25 }, (_, i) => `in_test_${i + 1}`) for (const id of ids) st.invoices.set(id, stripeInvoice(id, { status: 'paid', total: 100, paid: 100 })) st.calls.length = 0 const result = await ladeStripeRechnungen(ids.map(pr)) assert.equal(result.ok, true) assert.equal(result.nachId.size, 25) assert.equal(st.calls.filter((call) => call.method === 'GET' && /^\/v1\/invoices\/in_/.test(call.path)).length, 25) }) test('ladeStripeRechnungen: unmögliche Rechnungs-IDs gehen nie an Stripe', async () => { await listInvoices() st.calls.length = 0 // Pfadzeichen, ohne Präfix, kein Text, nur das Präfix, zu lang (V1: 1 bis 33 Zeichen nach „in_“) const result = await ladeStripeRechnungen([pr('../customers'), pr('x'), pr(undefined), pr('in_'), pr(`in_${'x'.repeat(34)}`)]) assert.equal(result.ok, true) assert.equal(result.nachId.size, 0) assert.equal(st.calls.length, 0) }) // ---------- Projektkonten eines Kunden (Task 4) ---------- const seedTicket = (id, woid, customerId = 'c1') => aw.seed(config.collections.workorders, { $id: id, woid, customerId, topic: `Ticket ${woid}`, status: 'open' }) const seedProjekt = (id, over = {}) => aw.seed(config.collections.websiteProjects, { $id: id, customerId: 'c1', projectName: `Projekt ${id}`, ticketId: '', kind: 'website', archived: false, ...over, }) let blattNr = 0 const seedBlatt = (id, ticketId, minuten, over = {}) => aw.seed(config.collections.worksheets, { $id: id, wsid: String(100000 + (blattNr += 1)), woid: ticketId === 't2' ? '100002' : '100001', workorderId: ticketId, employeeId: 'e1', employeeName: 'Kenso Grimm', employeeShort: 'KNSO', serviceType: 'Remote', totalTime: minuten, supporters: '', startDate: '06.10.2026', endDate: '06.10.2026', startTime: '10:00', endTime: '11:00', details: 'Startseite überarbeitet', isComment: false, countsToPlan: false, ...over, }) const seedPr = (invoiceId, { arbeitsblattIds = [], ...over } = {}) => aw.seed(config.collections.projektRechnungen, { $id: invoiceId, invoiceId, kundenId: 'c1', projektId: 'p1', auftragId: '', art: 'festbetrag', betragCents: 0, arbeitsblattIdsJson: JSON.stringify(arbeitsblattIds), rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com', erstelltAm: '2026-10-01T10:00:00.000Z', ...over, }) const seedAuftrag = (id, over = {}) => aw.seed(config.collections.auftraege, { $id: id, nummer: 'AB-2026-0001', jahr: 2026, laufnummer: 1, status: 'versendet', kundenId: 'c1', kundeJson: JSON.stringify({ name: 'Kunde Eins', firma: '', strasse: 'Hauptstraße 1', plz: '67722', ort: 'Winnweiler', land: 'DE', email: 'k@example.invalid', kundenart: 'unternehmen', kundennummer: '', }), projektId: 'p1', titel: 'Website', zeitquelleJson: '["t1"]', art: 'festpreis', positionenJson: JSON.stringify([{ beschreibung: 'Website', menge: 1, einzelpreisCents: 80000 }]), stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000, zahlungsplanJson: JSON.stringify({ art: 'nach_abnahme', prozent: null, text: '' }), zahlungszielTage: 14, auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '', fertigBis: '', texteFassung: '2026-10-06', agbFassung: '2026-09-30', pdfDatei: `c1/${id}.pdf`, pdfSha256: 'a'.repeat(64), gesendetAm: '2026-10-06T08:00:00.000Z', gesendetAn: 'k@example.invalid', mailStatus: 'gesendet', mailFehler: '', tokenHash: 'b'.repeat(64), tokenGueltigBis: '2026-12-05T08:00:00.000Z', bestaetigtAm: null, bestaetigtUeber: '', bestaetigtPdfSha256: '', fruehbeginn: false, fruehbeginnFassung: '', ersetztId: '', ersetztDurchId: '', storniertAm: null, widerrufenAm: null, erstelltVon: 'kenso@webklar.com', erstelltAm: '2026-10-06T07:00:00.000Z', geaendertAm: '2026-10-06T08:00:00.000Z', ...over, }) test('Projekte des Kunden: neueste zuerst, archivierte nur mit Geldbezug, Vorlagen und fremde Projekte nie', async () => { seedProjekt('p1', { $createdAt: '2026-09-01T10:00:00.000Z' }) seedProjekt('p2', { $createdAt: '2026-09-02T10:00:00.000Z' }) seedProjekt('p3', { $createdAt: '2026-09-03T10:00:00.000Z', archived: true }) // aktive Projektrechnung -> sichtbar seedProjekt('p4', { $createdAt: '2026-09-04T10:00:00.000Z', archived: true }) // ohne alles -> weg seedProjekt('p5', { $createdAt: '2026-09-05T10:00:00.000Z', archived: true }) // nur Entwurfs-AB -> weg seedProjekt('p6', { $createdAt: '2026-09-06T10:00:00.000Z', archived: true }) // gültige AB -> sichtbar seedProjekt('p7', { $createdAt: '2026-09-07T10:00:00.000Z', isTemplate: true }) // Vorlage -> weg seedProjekt('p8', { $createdAt: '2026-09-08T10:00:00.000Z', customerId: 'c2' }) // fremder Kunde -> weg seedPr('in_1', { projektId: 'p3' }) st.invoices.set('in_1', stripeInvoice('in_1', { total: 1000, number: 'WEBKLAR-0001' })) seedAuftrag('a5', { projektId: 'p5', status: 'entwurf', nummer: '' }) seedAuftrag('a6', { projektId: 'p6', status: 'versendet' }) const result = await projektkontenFuerKunde('c1') assert.deepEqual(result.konten.map((konto) => konto.projektId), ['p6', 'p3', 'p2', 'p1']) }) test('Projekt ohne Ticket: leere Zeitquelle und Zeit 0, Rechnungen und AB zählen trotzdem', async () => { seedProjekt('p1', { ticketId: '', kind: 'webapp' }) seedAuftrag('a1', { projektId: 'p1', summeCents: 50000, zeitquelleJson: '[]' }) seedPr('in_1', { projektId: 'p1', auftragId: 'a1', art: 'anzahlung' }) st.invoices.set('in_1', stripeInvoice('in_1', { status: 'paid', total: 25000, paid: 25000, number: 'WEBKLAR-0001' })) const result = await projektkontenFuerKunde('c1') assert.equal(result.konten.length, 1) const [konto] = result.konten assert.deepEqual(Object.keys(konto).sort(), [ 'berechnetCents', 'bezahltCents', 'name', 'offenCents', 'projektId', 'projektTyp', 'tickets', 'vereinbart', 'zeit', 'zeitquelle', ]) assert.deepEqual([konto.name, konto.projektTyp], ['Projekt p1', 'Web-App']) assert.deepEqual([konto.tickets, konto.zeitquelle], [[], []]) assert.deepEqual(konto.zeit, { minuten: 0, aboMinuten: 0, offeneMinuten: 0, satzCents: 10000, wertCents: 0, offenerWertCents: 0 }) assert.deepEqual([konto.vereinbart.festpreisCents, konto.berechnetCents, konto.bezahltCents, konto.offenCents], [50000, 25000, 25000, 25000]) assert.equal(aw.listCalls(config.collections.worksheets).length, 0) }) test('Ticket mit mehreren Projekten: Zeit erfasst zählt in beiden Konten, offen nur einmal', async () => { seedTicket('t1', '100001') seedProjekt('p1', { ticketId: 't1', $createdAt: '2026-09-01T10:00:00.000Z' }) seedProjekt('p2', { ticketId: 't1', $createdAt: '2026-09-02T10:00:00.000Z' }) seedBlatt('w1', 't1', 60) seedBlatt('w2', 't1', 30) // p1 hat w1 schon in einer Aufwandsrechnung (Entwurf): das Blatt ist abgerechnet, die Rechnung zählt noch nicht als berechnet seedPr('in_1', { projektId: 'p1', art: 'aufwand', arbeitsblattIds: ['w1'] }) st.invoices.set('in_1', stripeInvoice('in_1', { status: 'draft', total: 10000 })) const { konten } = await projektkontenFuerKunde('c1') const nach = Object.fromEntries(konten.map((konto) => [konto.projektId, konto])) const erwartet = { minuten: 90, aboMinuten: 0, offeneMinuten: 30, satzCents: 10000, wertCents: 15000, offenerWertCents: 5000 } assert.deepEqual(nach.p1.zeit, erwartet) assert.deepEqual(nach.p2.zeit, erwartet) assert.deepEqual([nach.p1.berechnetCents, nach.p2.berechnetCents], [0, 0]) assert.deepEqual(nach.p2.tickets, [{ id: 't1', woid: '100001' }]) }) test('Zeitquelle: die gültige AB bestimmt die Tickets, ein Entwurf nicht', async () => { seedTicket('t1', '100001') seedTicket('t2', '100002') seedProjekt('p1', { ticketId: 't1' }) seedBlatt('w1', 't1', 60) seedBlatt('w2', 't2', 45) seedAuftrag('a1', { projektId: 'p1', status: 'entwurf', zeitquelleJson: '["t2"]' }) let { konten } = await projektkontenFuerKunde('c1') assert.deepEqual(konten[0].zeitquelle, ['t1']) assert.equal(konten[0].zeit.minuten, 60) await updateDocument(config.collections.auftraege, 'a1', { status: 'versendet' }) ;({ konten } = await projektkontenFuerKunde('c1')) assert.deepEqual(konten[0].zeitquelle, ['t2']) assert.deepEqual(konten[0].tickets, [{ id: 't2', woid: '100002' }]) assert.equal(konten[0].zeit.minuten, 45) }) test('Entwürfe, Stornos und inaktive Projektrechnungen zählen nicht, Blätter in Entwürfen sind trotzdem nicht mehr offen', async () => { seedTicket('t1', '100001') seedProjekt('p1', { ticketId: 't1' }) seedBlatt('w1', 't1', 60) seedBlatt('w2', 't1', 30) seedPr('in_1', { art: 'aufwand', arbeitsblattIds: ['w1'] }) // Entwurf seedPr('in_2') // bei Stripe storniert seedPr('in_3', { aktiv: false }) // zurückgenommen (inaktiv) seedPr('in_4') // offen seedPr('in_5') // überfällig st.invoices.set('in_1', stripeInvoice('in_1', { status: 'draft', total: 10000 })) st.invoices.set('in_2', stripeInvoice('in_2', { status: 'void', total: 7000, number: 'WEBKLAR-0002' })) st.invoices.set('in_3', stripeInvoice('in_3', { status: 'void', total: 9000, number: 'WEBKLAR-0003' })) st.invoices.set('in_4', stripeInvoice('in_4', { status: 'open', total: 3000, number: 'WEBKLAR-0004' })) st.invoices.set('in_5', stripeInvoice('in_5', { status: 'open', total: 2000, number: 'WEBKLAR-0005', due: '2020-01-01' })) const result = await projektkontenFuerKunde('c1') const [konto] = result.konten assert.deepEqual([konto.berechnetCents, konto.bezahltCents], [5000, 0]) assert.deepEqual([konto.zeit.minuten, konto.zeit.offeneMinuten], [90, 30]) assert.deepEqual(result.ohneProjekt, { berechnetCents: 0, bezahltCents: 0, anzahl: 0 }) assert.equal(result.stripeOk, true) }) test('Ohne Projekt: finalisierte Projekt-Rechnungen des Kunden ohne Zuordnung, ohne Abo, Entwürfe, Stornos und fremde Kunden', async () => { seedProjekt('p1') seedPr('in_16', { projektId: 'p1' }) st.invoices.set('in_10', stripeInvoice('in_10', { status: 'open', total: 12000, number: 'WEBKLAR-0010' })) st.invoices.set('in_11', stripeInvoice('in_11', { status: 'paid', total: 8000, paid: 8000, number: 'WEBKLAR-0011' })) st.invoices.set('in_12', stripeInvoice('in_12', { status: 'draft', total: 5000 })) st.invoices.set('in_13', stripeInvoice('in_13', { status: 'void', total: 4000, number: 'WEBKLAR-0013' })) st.invoices.set('in_14', stripeInvoice('in_14', { status: 'paid', total: 2000, paid: 2000, number: 'WEBKLAR-0014', abo: true })) st.invoices.set('in_15', stripeInvoice('in_15', { status: 'open', total: 7000, number: 'WEBKLAR-0015', kunde: 'c2' })) st.invoices.set('in_16', stripeInvoice('in_16', { status: 'open', total: 3000, number: 'WEBKLAR-0016' })) const result = await projektkontenFuerKunde('c1') assert.deepEqual(result.ohneProjekt, { berechnetCents: 20000, bezahltCents: 8000, anzahl: 2 }) assert.equal(result.konten[0].berechnetCents, 3000) }) test('Ohne Projekt fängt auch Projektrechnungen auf, deren Projekt es nicht mehr gibt', async () => { seedPr('in_20', { projektId: 'geloescht' }) st.invoices.set('in_20', stripeInvoice('in_20', { status: 'open', total: 6000, number: 'WEBKLAR-0020' })) const result = await projektkontenFuerKunde('c1') assert.deepEqual(result.konten, []) assert.deepEqual(result.ohneProjekt, { berechnetCents: 6000, bezahltCents: 0, anzahl: 1 }) }) test('Eine Rechnung, die der Zwischenspeicher nicht kennt, wird einzeln gelesen und zählt', async () => { seedProjekt('p1') seedPr('in_1', { projektId: 'p1' }) await listInvoices() // Zwischenspeicher (noch ohne in_1) füllen st.invoices.set('in_1', stripeInvoice('in_1', { status: 'paid', total: 50000, paid: 50000, number: 'WEBKLAR-0001' })) const result = await projektkontenFuerKunde('c1') assert.deepEqual([result.stripeOk, result.konten[0].berechnetCents, result.konten[0].bezahltCents], [true, 50000, 50000]) assert.deepEqual(result.ohneProjekt, { berechnetCents: 0, bezahltCents: 0, anzahl: 0 }) }) test('Stripe-Ausfall: stripeOk false, Beträge 0, Zeit und Vereinbartes bleiben', async () => { mock.method(console, 'warn', () => {}) seedTicket('t1', '100001') seedProjekt('p1', { ticketId: 't1' }) seedBlatt('w1', 't1', 60) seedAuftrag('a1', { projektId: 'p1', summeCents: 80000 }) seedPr('in_1', { projektId: 'p1', auftragId: 'a1', art: 'anzahlung' }) st.failNext({ method: 'GET', path: '/v1/invoices', status: 400, message: 'kaputt' }) const result = await projektkontenFuerKunde('c1') assert.equal(result.stripeOk, false) assert.deepEqual(result.ohneProjekt, { berechnetCents: 0, bezahltCents: 0, anzahl: 0 }) const [konto] = result.konten assert.deepEqual([konto.berechnetCents, konto.bezahltCents, konto.offenCents, konto.vereinbart.festpreisCents], [0, 0, null, 80000]) assert.deepEqual(konto.zeit, { minuten: 60, aboMinuten: 0, offeneMinuten: 60, satzCents: 10000, wertCents: 10000, offenerWertCents: 10000 }) }) test('Zeitwert im Konto: je Berliner Tag gerundet, zum Satz der Aufwands-AB; der offene Wert ebenso', async () => { seedTicket('t1', '100001') seedProjekt('p1', { ticketId: 't1' }) seedBlatt('w1', 't1', 15, { startDate: '05.10.2026', endDate: '05.10.2026' }) seedBlatt('w2', 't1', 15, { startDate: '06.10.2026', endDate: '06.10.2026' }) seedBlatt('w3', 't1', 15, { startDate: '06.10.2026', endDate: '06.10.2026' }) seedBlatt('w4', 't1', 15, { startDate: '07.10.2026', endDate: '07.10.2026' }) seedAuftrag('a1', { projektId: 'p1', art: 'aufwand', summeCents: null, positionenJson: '[]', stundensatzCents: 9990, schaetzungMinuten: 300 }) seedPr('in_1', { projektId: 'p1', art: 'aufwand', arbeitsblattIds: ['w2'] }) st.invoices.set('in_1', stripeInvoice('in_1', { status: 'draft', total: 2498 })) const { konten } = await projektkontenFuerKunde('c1') const [konto] = konten assert.deepEqual(konto.vereinbart, { festpreisCents: null, aufwand: { stundensatzCents: 9990, schaetzungMinuten: 300 } }) assert.equal(konto.offenCents, null) // 99,90 €/Std.: 15 Minuten = 2498 Cent, 30 Minuten = 4995 Cent. Je Tag: 2498 + 4995 (zwei Blätter am 06.10.) + 2498 = 9991 // (je Blatt wären es 9992, über alle 60 Minuten 9990). Offen ohne w2: drei Tage zu 15 Minuten = 3 × 2498 = 7494 (über 45 Minuten 7493) assert.deepEqual(konto.zeit, { minuten: 60, aboMinuten: 0, offeneMinuten: 45, satzCents: 9990, wertCents: 9991, offenerWertCents: 7494 }) }) test('Schalter aus: ABs werden nicht gelesen, pilot nur für Pilotkunden', async () => { seedProjekt('p1') seedAuftrag('a1', { projektId: 'p1', summeCents: 80000 }) config.auftraege.mode = 'off' aw.resetListCalls() let result = await projektkontenFuerKunde('c1') assert.equal(result.auftraegeAktiv, false) assert.equal(result.konten[0].vereinbart.festpreisCents, null) assert.equal(aw.listCalls(config.collections.auftraege).length, 0) config.auftraege.mode = 'pilot' config.auftraege.pilotEmails = ['k@example.invalid'] result = await projektkontenFuerKunde('c1') assert.equal(result.auftraegeAktiv, true) assert.equal(result.konten[0].vereinbart.festpreisCents, 80000) assert.equal((await projektkontenFuerKunde('c2')).auftraegeAktiv, false) }) test('kunde: Firmenname vor Name, Kundenart nur aus der Liste, unbekannter Kunde -> 404', async () => { aw.seed(config.collections.customers, { $id: 'c5', name: 'Hans Müller', companyName: 'Müller GmbH', email: 'h@example.invalid', kundenart: 'unternehmen' }) aw.seed(config.collections.customers, { $id: 'c6', name: 'Max Muster', companyName: '', email: 'm@example.invalid', kundenart: 'komisch' }) assert.deepEqual((await projektkontenFuerKunde('c5')).kunde, { id: 'c5', name: 'Müller GmbH', kundenart: 'unternehmen' }) assert.deepEqual((await projektkontenFuerKunde('c6')).kunde, { id: 'c6', name: 'Max Muster', kundenart: '' }) assert.deepEqual((await projektkontenFuerKunde('c1')).kunde, { id: 'c1', name: 'Kunde Eins', kundenart: '' }) await assert.rejects(projektkontenFuerKunde('gibtsnicht'), { status: 404, message: 'Kunde nicht gefunden.' }) }) // ---------- Projektkonto-Detail (Task 5) ---------- test('Detail: Konto, ABs, Rechnungen (neueste zuerst, Entwürfe dabei, Inaktive nicht) und offene Arbeitsblätter', async () => { seedTicket('t1', '100001') seedProjekt('p1', { ticketId: 't1' }) seedBlatt('w1', 't1', 60) // in Rechnung in_2 seedBlatt('w2', 't1', 30) seedBlatt('w3', 't1', 45, { countsToPlan: true }) // Abo-Zeit seedBlatt('w4', 't1', 15, { isComment: true }) // Kommentar seedBlatt('w5', 't1', 15, { startDate: '05.10.2026', endDate: '05.10.2026' }) seedAuftrag('a1', { projektId: 'p1', summeCents: 80000 }) seedAuftrag('a2', { projektId: 'p1', status: 'entwurf', nummer: '', laufnummer: null, gesendetAm: null }) seedPr('in_1', { auftragId: 'a1', art: 'anzahlung', rechnungsnummer: 'WEBKLAR-0001' }) seedPr('in_2', { art: 'aufwand', arbeitsblattIds: ['w1'], rechnungsnummer: 'WEBKLAR-0002' }) seedPr('in_3', { aktiv: false }) seedPr('in_4', { art: 'festbetrag' }) st.invoices.set('in_1', stripeInvoice('in_1', { status: 'open', total: 40000, number: 'WEBKLAR-0001', created: '2026-10-01' })) st.invoices.set('in_2', stripeInvoice('in_2', { status: 'paid', total: 10000, paid: 10000, number: 'WEBKLAR-0002', created: '2026-10-03' })) st.invoices.set('in_3', stripeInvoice('in_3', { status: 'void', total: 5000, number: 'WEBKLAR-0003', created: '2026-10-02' })) st.invoices.set('in_4', stripeInvoice('in_4', { status: 'draft', total: 5000, created: '2026-10-04' })) const detail = await projektkontoDetail('p1') assert.deepEqual(Object.keys(detail).sort(), ['auftraege', 'konto', 'offeneArbeitsblaetter', 'rechnungen', 'stripeOk']) assert.equal(detail.stripeOk, true) const { konto } = detail assert.equal(konto.projektId, 'p1') assert.deepEqual(konto.vereinbart, { festpreisCents: 80000, aufwand: null }) assert.deepEqual([konto.berechnetCents, konto.bezahltCents, konto.offenCents], [50000, 10000, 40000]) assert.deepEqual(konto.zeit, { minuten: 105, aboMinuten: 45, offeneMinuten: 45, satzCents: 10000, wertCents: 17500, offenerWertCents: 7500 }) assert.deepEqual(detail.rechnungen, [ { id: 'in_4', number: '', status: 'entwurf', createdDate: '2026-10-04', totalCents: 5000, paidCents: 0, art: 'festbetrag', auftragId: '' }, { id: 'in_2', number: 'WEBKLAR-0002', status: 'bezahlt', createdDate: '2026-10-03', totalCents: 10000, paidCents: 10000, art: 'aufwand', auftragId: '' }, { id: 'in_1', number: 'WEBKLAR-0001', status: 'offen', createdDate: '2026-10-01', totalCents: 40000, paidCents: 0, art: 'anzahlung', auftragId: 'a1' }, ]) // sortiert nach Datum (w5 am 05.10., w2 am 06.10.); Abo-Zeit, Kommentar und Berechnetes fehlen assert.deepEqual(detail.offeneArbeitsblaetter.map((eintrag) => eintrag.id), ['w5', 'w2']) assert.deepEqual(detail.auftraege.map((auftrag) => [auftrag.id, auftrag.status]).sort(), [['a1', 'versendet'], ['a2', 'entwurf']]) for (const auftrag of detail.auftraege) { for (const feld of ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256']) assert.ok(!(feld in auftrag), `${feld} darf nicht in der Antwort stehen`) } }) test('Detail: unbekanntes Projekt und Projekt ohne Kunde -> 404', async () => { seedProjekt('p9', { customerId: '' }) await assert.rejects(projektkontoDetail('gibtsnicht'), { status: 404, message: 'Projekt nicht gefunden.' }) await assert.rejects(projektkontoDetail('p9'), { status: 404, message: 'Projekt nicht gefunden.' }) }) test('Detail bei Stripe-Ausfall: stripeOk false, keine Rechnungen, Beträge 0, Arbeitsblätter und ABs bleiben', async () => { mock.method(console, 'warn', () => {}) seedTicket('t1', '100001') seedProjekt('p1', { ticketId: 't1' }) seedBlatt('w1', 't1', 60) seedAuftrag('a1', { projektId: 'p1', summeCents: 80000 }) seedPr('in_1', { auftragId: 'a1', art: 'anzahlung' }) st.failNext({ method: 'GET', path: '/v1/invoices', status: 400, message: 'kaputt' }) const detail = await projektkontoDetail('p1') assert.equal(detail.stripeOk, false) assert.deepEqual(detail.rechnungen, []) assert.deepEqual([detail.konto.berechnetCents, detail.konto.bezahltCents, detail.konto.offenCents], [0, 0, null]) assert.deepEqual(detail.offeneArbeitsblaetter.map((eintrag) => eintrag.id), ['w1']) assert.deepEqual(detail.auftraege.map((auftrag) => auftrag.id), ['a1']) }) test('Detail bei ausgeschaltetem Schalter: keine ABs, Vereinbart leer, ABs werden nicht gelesen', async () => { seedProjekt('p1') seedAuftrag('a1', { projektId: 'p1', summeCents: 80000 }) config.auftraege.mode = 'off' aw.resetListCalls() const detail = await projektkontoDetail('p1') assert.deepEqual(detail.auftraege, []) assert.equal(detail.konto.vereinbart.festpreisCents, null) assert.equal(aw.listCalls(config.collections.auftraege).length, 0) }) // ---------- Abzeichen "abgerechnet" (Task 6) ---------- test('abgerechnetFuerTicket: Rechnungsnummer oder Entwurf, nur Blätter dieses Tickets, nur aktive Projektrechnungen des Kunden', async () => { seedTicket('t1', '100001') seedTicket('t2', '100002') seedBlatt('w1', 't1', 60) seedBlatt('w2', 't1', 30) seedBlatt('w3', 't1', 15) seedBlatt('w9', 't2', 45) seedPr('in_1', { rechnungsnummer: 'WEBKLAR-0007', arbeitsblattIds: ['w1'] }) seedPr('in_2', { rechnungsnummer: '', arbeitsblattIds: ['w2', 'w9'] }) // Entwurf, ein Blatt gehört zu t2 seedPr('in_3', { rechnungsnummer: 'WEBKLAR-0003', arbeitsblattIds: ['w3'], aktiv: false }) // zurückgenommen seedPr('in_4', { kundenId: 'c2', rechnungsnummer: 'WEBKLAR-0004', arbeitsblattIds: ['w1'] }) // anderer Kunde assert.deepEqual(await abgerechnetFuerTicket('t1'), { w1: 'WEBKLAR-0007', w2: 'Entwurf' }) assert.deepEqual(await abgerechnetFuerTicket('t2'), { w9: 'Entwurf' }) }) test('abgerechnetFuerTicket: unbekanntes Ticket, Ticket ohne Kunde oder nichts abgerechnet -> leer', async () => { seedTicket('t1', '100001') seedTicket('t3', '100003', '') seedBlatt('w1', 't1', 60) assert.deepEqual(await abgerechnetFuerTicket('gibtsnicht'), {}) assert.deepEqual(await abgerechnetFuerTicket('t3'), {}) assert.deepEqual(await abgerechnetFuerTicket('t1'), {}) })