From 3305371111e85b01f0fbe2589e0783c60140c5b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:11:32 +0000 Subject: [PATCH] Projektkonto: reine Rechenregeln (vereinbart, berechnet, offen, Zeitwert je Tag gerundet, Ohne Projekt) Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/projektkonto.js | 93 ++++++++++ test/auftraegeProjektkonto.test.js | 213 ++++++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 server/services/auftraege/projektkonto.js create mode 100644 test/auftraegeProjektkonto.test.js diff --git a/server/services/auftraege/projektkonto.js b/server/services/auftraege/projektkonto.js new file mode 100644 index 0000000..cf41592 --- /dev/null +++ b/server/services/auftraege/projektkonto.js @@ -0,0 +1,93 @@ +import { STUNDENSATZ_STANDARD_CENTS, VEREINBARTE_STATUS } from './konstanten.js' +import { abrechenbar } from './arbeitsblaetter.js' + +/** + * Projektkonto (Spec 2026-10-06, Abschnitt 4; Uebersicht V4 "L1 - Projektkonto"): reine Rechenregeln und die Ladefunktionen + * dazu. Alles hier liest nur, weder Appwrite noch Stripe werden geschrieben. + */ + +// Rechnungsstatus laut mapInvoice (stripeFinance.js), die als "berechnet" zaehlen; Entwurf und storniert nie +const FINALISIERT = new Set(['offen', 'ueberfaellig', 'bezahlt', 'uneinbringlich']) + +const summe = (liste, feld) => liste.reduce((s, eintrag) => s + eintrag[feld], 0) +const zeitpunkt = (auftrag) => new Date(auftrag.gesendetAm || auftrag.erstelltAm || 0).getTime() || 0 +const juengsteZuerst = (a, b) => zeitpunkt(b) - zeitpunkt(a) || String(b.id).localeCompare(String(a.id)) +const zeitWert = (minuten, satzCents) => Math.round((minuten * satzCents) / 60) +// Wert der Zeit = Summe der je Berliner Tag gerundeten Werte (Karte: Tag -> Minuten), genau wie die Zeilen einer Aufwandsrechnung +// (aufwandPositionen in L2: eine Zeile je Tag mit Math.round(Minuten × Satz / 60)); so laufen Konto und Rechnung nicht um Cent auseinander +const tageswert = (minutenJeTag, satzCents) => [...minutenJeTag.values()].reduce((s, minuten) => s + zeitWert(minuten, satzCents), 0) +const addiere = (karte, tag, minuten) => karte.set(tag, (karte.get(tag) ?? 0) + minuten) + +/** + * Die Zahlen eines Projekts. auftraege: alle ABs des Projekts (jeder Status); rechnungen: [{ art, auftragId, invoice }] mit + * der Rechnung in mapInvoice-Form (nur aktive Projektrechnungen, deren Rechnung gefunden wurde); blaetter: Arbeitsblaetter der + * Zeitquelle (datum = Berliner Tag); abgerechnet: Map oder Set der Blatt-IDs aus aktiven Projektrechnungen (auch Entwuerfe); + * stripeOk false = die Rechnungen sind unbekannt (berechnet und bezahlt 0, offen null). + */ +export function rechneKonto({ auftraege = [], rechnungen = [], blaetter = [], abgerechnet = new Map(), stripeOk = true } = {}) { + const gueltig = auftraege.filter((auftrag) => VEREINBARTE_STATUS.includes(auftrag.status)) + const festpreise = gueltig.filter((auftrag) => auftrag.art === 'festpreis') + const festpreisIds = new Set(festpreise.map((auftrag) => auftrag.id)) + const festpreisCents = festpreise.length ? festpreise.reduce((s, auftrag) => s + (Number(auftrag.summeCents) || 0), 0) : null + const aufwandAb = gueltig.filter((auftrag) => auftrag.art === 'aufwand').sort(juengsteZuerst)[0] ?? null + const aufwand = aufwandAb + ? { + stundensatzCents: aufwandAb.stundensatzCents > 0 ? aufwandAb.stundensatzCents : STUNDENSATZ_STANDARD_CENTS, + schaetzungMinuten: aufwandAb.schaetzungMinuten ?? null, + } + : null + const satzCents = aufwand ? aufwand.stundensatzCents : STUNDENSATZ_STANDARD_CENTS + + let berechnetCents = 0 + let bezahltCents = 0 + let aufFestpreisCents = 0 + if (stripeOk) { + for (const { invoice, auftragId } of rechnungen) { + if (!FINALISIERT.has(invoice.status)) continue + berechnetCents += invoice.totalCents + bezahltCents += invoice.paidCents + // nur Rechnungen zu einer als vereinbart zaehlenden Festpreis-AB mindern das Offene + if (festpreisIds.has(auftragId)) aufFestpreisCents += invoice.totalCents + } + } + const offenCents = festpreisCents === null || !stripeOk ? null : Math.max(0, festpreisCents - aufFestpreisCents) + + let minuten = 0 + let aboMinuten = 0 + let offeneMinuten = 0 + const minutenJeTag = new Map() // alle abrechenbaren Blaetter (auch schon berechnete) + const offeneMinutenJeTag = new Map() // nur die offenen; ein Blatt ohne Datum (nur theoretisch) bildet einen eigenen Tag '' + for (const blatt of blaetter) { + if (abrechenbar(blatt)) { + minuten += blatt.minuten + addiere(minutenJeTag, blatt.datum, blatt.minuten) + if (!abgerechnet.has(blatt.id)) { + offeneMinuten += blatt.minuten + addiere(offeneMinutenJeTag, blatt.datum, blatt.minuten) + } + } else if (blatt.aboZeit && !blatt.kommentar) { + aboMinuten += blatt.minuten + } + } + + return { + vereinbart: { festpreisCents, aufwand }, + berechnetCents, + bezahltCents, + offenCents, + zeit: { + minuten, + aboMinuten, + offeneMinuten, + satzCents, + wertCents: tageswert(minutenJeTag, satzCents), + offenerWertCents: tageswert(offeneMinutenJeTag, satzCents), + }, + } +} + +/** Zeile "Ohne Projekt": Rechnungen in mapInvoice-Form; gezaehlt werden nur finalisierte. */ +export function rechneOhneProjekt(rechnungen) { + const gezaehlt = rechnungen.filter((invoice) => FINALISIERT.has(invoice.status)) + return { berechnetCents: summe(gezaehlt, 'totalCents'), bezahltCents: summe(gezaehlt, 'paidCents'), anzahl: gezaehlt.length } +} diff --git a/test/auftraegeProjektkonto.test.js b/test/auftraegeProjektkonto.test.js new file mode 100644 index 0000000..4c805f2 --- /dev/null +++ b/test/auftraegeProjektkonto.test.js @@ -0,0 +1,213 @@ +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 }) +})