# Projektkonto – Umsetzungsplan (Strang L1) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Der Portal-Server berechnet je Projekt eines Kunden das Projektkonto (vereinbart, berechnet, bezahlt, noch offen, Zeit erfasst) samt Zeile „Ohne Projekt“ und liefert es WOMS über zwei Admin-Routen; alle Mitarbeiter bekommen über eine Team-Route je Arbeitsblatt eines Tickets die Angabe „abgerechnet (Rechnungsnummer | Entwurf)“. **Architecture:** Ein Dienstmodul `server/services/auftraege/projektkonto.js` trennt reine Rechenregeln (`rechneKonto`, `rechneOhneProjekt`; ohne Netz, mit Tabellentests) von den Ladefunktionen (`ladeStripeRechnungen`, `projektkontenFuerKunde`, `projektkontoDetail`, `abgerechnetFuerTicket`), die nur **lesen**: Projekte, Tickets, Arbeitsblätter, ABs und Projektrechnungen über die L0-Bausteine bzw. Appwrite, Rechnungsstatus über das bestehende `listInvoices()` (60-Sekunden-Zwischenspeicher) plus Einzellesen fehlender Rechnungen. Die beiden Router-Gerüste aus L0 werden nur gefüllt. `stripeFinance.js` bleibt unverändert. **Tech Stack:** Node 20/24, Express 4 (Router), ES-Module, `node:test` mit den Nachbauten aus `test/support/` (`startAppwriteMock`, `startFakeStripe`, `useTestEnv`, `startTestApp`). **Spec:** `docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md`, Abschnitt 4 „Projektkonto (Rechenregeln)“, Abschnitt 6 „Schnittstellen“, Abschnitt 9 „Fehlerverhalten“. **Übersicht/Verträge:** `docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md` (V1 Schema, V2 Konstanten/Fehler, V3 Datenformen, V4 „L1 – Projektkonto“ und L0-Module, V5 Endpunkte „L1“). Spec, Übersicht und diese Datei liegen im Basis-Worktree `/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege/docs/superpowers/…`; der Lane-Worktree enthält sie nur, wenn sie dort committed sind – von dort lesen. **Worktree/Branch:** `/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1`, Branch `auftraege/l1-projektkonto` (aus `feature/auftraege` nach L0). --- ## Entscheidungen dieses Strangs 1. **Archivierte Projekte** (`websiteProjects.archived`): Ein archiviertes Projekt erscheint nur mit Geldbezug, also mit mindestens einer aktiven Projektrechnung (auch Entwurf) oder einer AB mit Status `versendet`, `bestaetigt` oder `abgerechnet`; sonst ist es ausgeblendet. Vorlagen (`isTemplate === true`) nie. *Grund:* Das Portal blendet archivierte Projekte aus (Entscheidung 04.10.2026), aber eine offene Rechnung oder Anzahlung darf nie aus der Zusammenfassung verschwinden; archivierte Projekte ohne Geldbezug wären nur Rauschen in Liste und Projektwahl. Das Detail (`/:projektId`) funktioniert für jedes Projekt, auch archivierte. 2. **„Ohne Projekt“** zählt nur Rechnungen mit `source: 'projekt'` (keine Abo-Rechnungen), nur finalisierte (offen, überfällig, bezahlt, uneinbringlich); `anzahl` ist die Zahl der gezählten Rechnungen. *Grund:* Abo-Rechnungen verwaltet Stripe, sie haben ihren Reiter „Abo“ und würden die Zeile mit monatlichen Beträgen füllen, die mit Projekten nichts zu tun haben; gesucht sind die freien Rechnungen und die Altrechnungen von vor dem Projektkonto. Zusätzlich fängt die Zeile Projektrechnungen auf, deren Projekt nicht (mehr) angezeigt wird (gelöscht, einem anderen Kunden zugeordnet, Vorlage), damit kein Betrag aus der Summe fällt. 3. **Schalter aus** (`auftraegeAktiv(kunde)` ist false): L1 liest die ABs gar nicht – Vereinbart ist „keine AB“, `auftraege: []`. *Grund:* Der Rechnungsteil soll vor der Rechtsrunde laufen, auch wenn die Collection `auftraege` noch fehlt oder leer ist (V5: Projektkonto läuft immer). 4. **Stripe-Ausfall:** `stripeOk: false`, `berechnetCents`/`bezahltCents` 0, `offenCents` **null** (nicht „Festpreis − 0“, das wäre irreführend), Zeit und Vereinbartes bleiben, Rechnungsliste im Detail leer. Fehler werden nur mit Art und Status ins Protokoll geschrieben, nie mit der Meldung. 5. **Ticket mit mehreren Projekten:** „Zeit erfasst“ zählt in jedem dieser Konten (Anzeige); „offen“ zählt ein Blatt nur einmal, weil „abgerechnet“ je Kunde gilt (`abgerechneteArbeitsblaetter(kundenId)`). 6. **„Noch offen“ nur mit Festpreis-ABs:** abgezogen werden die Summen der finalisierten Rechnungen, deren `auftragId` zu einer Festpreis-AB gehört, die als vereinbart zählt. Rechnungen ohne AB, zu Aufwands-ABs oder zu stornierten/ersetzten ABs mindern nichts, zählen aber für „berechnet“. 7. **Satz und Zeitwert:** Der Satz kommt aus der jüngsten gültigen Aufwands-AB (`gesendetAm`, sonst `erstelltAm`; gültig = Status in `VEREINBARTE_STATUS`), sonst 100 € (`STUNDENSATZ_STANDARD_CENTS`). Der Wert der Zeit ist die **Summe der je Berliner Tag gerundeten Werte**: `Math.round(Tagesminuten × Satz / 60)` je `datum` der Arbeitsblätter, dann addiert (Planrunde, siehe Übersicht, Nachträge). So rechnet auch `aufwandPositionen` in L2 (eine Zeile je Tag), damit Konto und Rechnung bei krummen Sätzen wie 99,90 €/Std. nicht um Cent auseinanderlaufen. `offenerWertCents` ebenso, aus den Minuten der offenen Blätter je Tag. Fasst L2 bei mehr als 20 Tagen die ältesten Tage zu Kalenderwochen zusammen, kann die Rechnung um wenige Cent abweichen; das Konto bleibt bei der Tagesrechnung. 8. **Einzellesen:** Fehlt eine Projektrechnung im Zwischenspeicher, wird die Rechnung mit `invoices.retrieve` gelesen (höchstens 10 gleichzeitig). 404 heißt „gibt es nicht mehr“ und wird ignoriert; jeder andere Fehler gilt als Stripe-Ausfall. 9. **Anzeigewerte:** `kunde.name` ist Firmenname vor Name (wie der Stripe-Empfänger), `kunde.kundenart` nur ein Wert aus `KUNDENARTEN`, sonst `''`; `projektTyp` ist der Anzeigename der Projektart (`kindLabel(websiteProjects.kind)`, `''` ohne Art). 10. **Reihenfolgen** (V3/V4 legen sie nicht fest, WOMS verlässt sich darauf): `konten` neueste Projekte zuerst (`$createdAt` absteigend); `rechnungen` neueste zuerst (`createdDate`, dann `number`, dann `id`, jeweils absteigend); `offeneArbeitsblaetter` nach `datum`, dann `id` aufsteigend; `auftraege` wie `listAuftraege` (neueste zuerst). ## Global Constraints Alle Punkte der Übersicht gelten. Zusätzlich für L1: - **Nur lesen:** Das Dienstmodul und beide Router schreiben nie nach Appwrite oder Stripe. Aus `stripeFinance.js` werden nur `listInvoices` und `mapInvoice` benutzt; die Datei bleibt unverändert. - **Eigene Dateien:** genau `server/services/auftraege/projektkonto.js`, `server/routes/admin/projektkonten.js`, `server/routes/team/arbeitsblaetter.js`, `test/auftraegeProjektkonto.test.js`, `test/auftraegeRoutesProjektkonten.test.js`. Keine neuen Config-Felder, keine neuen Appwrite-Attribute, keine Änderung an `server/index.js`, `server/routes/team/index.js`, `test/support/*`. - **L0-Abweichungen:** Verhält sich ein L0-Baustein anders als der Vertrag (V4) sagt, wird er nicht in L0-Dateien repariert; die Abweichung kommt in den Abschlussbericht. Test nur anpassen, wenn die Übersicht eindeutig etwas anderes festlegt. - **Team-Route ohne Beträge:** `/api/team/arbeitsblaetter/abgerechnet` liefert nur Rechnungsnummern bzw. `'Entwurf'`, nie Beträge oder Rechnungs-IDs. - **Kennungen prüfen:** Query-/Pfadwerte (`kunde`, `projektId`, `ticketId`) müssen `/^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/` erfüllen (Appwrite-UID), bevor sie an Appwrite gehen; Rechnungs-IDs vor dem Lesen bei Stripe `/^in_\w{1,33}$/` (V1; der Stripe-Nachbau vergibt `in_test_`, auch das passt). - **Tests:** Der Schalter wird in Tests direkt gesetzt (`config.auftraege.mode = 'on' | 'off' | 'pilot'`, `config.auftraege.pilotEmails`); jeder Test startet mit leeren Collections und `invalidateFinanceCache()`. Stripe-Fehler im Test mit Status 400 einplanen (4xx wiederholt stripe-node nicht). Der Appwrite-Nachbau startet wie bei L0 mit `schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') }`, `datetimes: AUFTRAEGE_DATETIMES` und `defaultLimit: 25` (Appwrites Standard-Limit, damit eine fehlende Begrenzung auffällt); `customers`, `websiteProjects`, `workorders` und `worksheets` laufen ohne Schema. - **Testbefehl** (Ressourcen laut Übersicht): `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/`; mehrere Dateien in einem Aufruf nur mit `--test-concurrency=1`; vorher `free -m` („available“ ≥ 1500 MB). In Shell-Befehlen `command grep -a` statt `grep`. - **Schreibweise im Code** (Übersicht): nie ein Unicode-Escape aus Backslash, `u` und genau vier Hexziffern schreiben (das Schreibwerkzeug macht daraus das Zeichen selbst); Sonderzeichen stehen als Zeichen im Text, sonst `\u{…}` oder `String.fromCodePoint(…)`. ## Review Focus 1. **Projekt ohne Ticket:** leere Zeitquelle ergibt Zeit 0 und `tickets: []` ohne Fehler und ohne Abfrage der Arbeitsblätter; Rechnungen und AB zählen trotzdem. Test: Task 4 »Projekt ohne Ticket …«. 2. **Ticket mit mehreren Projekten:** „Zeit erfasst“ in beiden Konten gleich, ein berechnetes (auch nur im Entwurf stehendes) Blatt ist für beide nicht mehr offen. Test: Task 4 »Ticket mit mehreren Projekten …«. 3. **Festpreis-AB plus Aufwandsrechnungen:** `offenCents` rechnet nur mit Rechnungen zu Festpreis-ABs, nie unter 0; Aufwands- und Festbetragsrechnungen erhöhen nur „berechnet“. Tests: Task 2 »Festpreis-AB plus Aufwandsrechnungen …«, »offen ist nie negativ …«, »Rechnungen zu einer stornierten AB …«; Task 5 »Detail: Konto, ABs, Rechnungen …«. 4. **Entwürfe, Stornos und inaktive Projektrechnungen zählen nicht** (weder berechnet noch bezahlt); ein Arbeitsblatt in einem Entwurf ist aber „abgerechnet“ und zeigt in der Team-Route `Entwurf`. Tests: Task 2 »berechnet und bezahlt zählen nur finalisierte …«, Task 4 »Entwürfe, Stornos und inaktive …«, Task 6 »abgerechnetFuerTicket: Rechnungsnummer oder Entwurf …«. 5. **Stripe-Ausfall:** `stripeOk: false`, Beträge 0, `offenCents` null, Zeit und Vereinbartes bleiben, die Routen antworten trotzdem 200; ein fehlendes Einzellesen (404) ist kein Ausfall, jeder andere Fehler schon; das Protokoll enthält keine Fehlermeldung. Tests: Task 3 (alle), Task 4 »Stripe-Ausfall …«, Task 5 »Detail bei Stripe-Ausfall …«, Task 7 »Stripe-Ausfall: beide Routen antworten 200 …«. --- ## Tasks ### Task 1: Worktree einrichten und L0-Vertrag prüfen **Files:** keine (Einrichtung, kein Commit). **Interfaces:** - Consumes: Stand von `feature/auftraege` nach L0 – Module und Gerüste laut V4/V5. - Produces: Worktree `dev-auftraege-l1` auf Branch `auftraege/l1-projektkonto` mit `node_modules`-Symlink. - [ ] **Schritt 1: Speicher prüfen** ```bash free -m ``` Erwartet: Spalte „available“ mindestens 1500 MB, sonst warten. - [ ] **Schritt 2: Worktree anlegen** ```bash git -C /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege worktree add ../dev-auftraege-l1 -b auftraege/l1-projektkonto feature/auftraege cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 ln -s ../dev-auftraege/node_modules node_modules git branch --show-current ls node_modules/express/package.json ``` Erwartet: `auftraege/l1-projektkonto` und der Pfad zu `express/package.json`. Fehlt `node_modules` im Basis-Worktree, ist L0 nicht fertig: **anhalten und melden**. - [ ] **Schritt 3: L0-Dateien prüfen** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 for f in schema konstanten schalter auftragStore projektRechnungenStore auftragSicht arbeitsblaetter fehler texte ablage; do test -f server/services/auftraege/$f.js && echo "ok $f" || echo "FEHLT $f" done test -f server/routes/admin/projektkonten.js && echo "ok Gerüst projektkonten" || echo "FEHLT Gerüst projektkonten" test -f server/routes/team/arbeitsblaetter.js && echo "ok Gerüst arbeitsblaetter" || echo "FEHLT Gerüst arbeitsblaetter" ``` Erwartet: nur `ok`-Zeilen. - [ ] **Schritt 4: Exporte und Verdrahtung prüfen** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 chk() { command grep -a -q -E "export (async function|function|const|class) $2\b" "$1" && echo "ok $2" || echo "FEHLT $2 in $1"; } A=server/services/auftraege chk $A/arbeitsblaetter.js ladeArbeitsblaetter chk $A/arbeitsblaetter.js abrechenbar chk $A/arbeitsblaetter.js abgerechneteArbeitsblaetter chk $A/arbeitsblaetter.js zeitquelleFuerProjekt chk $A/auftragStore.js listAuftraege chk $A/projektRechnungenStore.js listProjektRechnungen chk $A/auftragSicht.js auftragFuerTeam chk $A/schalter.js auftraegeAktiv chk $A/fehler.js AuftragError chk $A/fehler.js sendeFehler chk $A/konstanten.js VEREINBARTE_STATUS chk $A/konstanten.js STUNDENSATZ_STANDARD_CENTS chk $A/konstanten.js KUNDENARTEN chk $A/schema.js AUFTRAEGE_COLLECTIONS chk $A/schema.js AUFTRAEGE_DATETIMES command grep -a -n "AUFTRAEGE_COLLECTIONS" test/support/schema.js command grep -a -n "projektkonten" server/index.js command grep -a -n "arbeitsblaetter" server/routes/team/index.js command grep -a -n "requireAppwriteStaff" server/routes/admin/projektkonten.js command grep -a -n -E "auftraege|projektRechnungen" server/config.js | head command grep -a -n -x "node_modules" .gitignore cat server/routes/team/arbeitsblaetter.js ``` Erwartet: alle `chk`-Zeilen `ok` (steht ein Name stattdessen in einem `export { … }`, von Hand bestätigen); je ein Treffer für `AUFTRAEGE_COLLECTIONS` in `test/support/schema.js` (`schemaKeys` kennt die neuen Collections), `projektkonten` in `server/index.js`, `arbeitsblaetter` in `server/routes/team/index.js` und `requireAppwriteStaff` im Admin-Gerüst (L0 baut den Schutz laut Übersicht, Nachträge, ins Gerüst; das Team-Gerüst hängt hinter `requireStaffJwt` und trägt keinen eigenen); `config.auftraege` samt `collections.auftraege` und `collections.projektRechnungen` in `server/config.js`; das Team-Gerüst exportiert einen Router per `export default`. Fehlt etwas davon: **anhalten und melden**, nichts in L0-Dateien nachbauen. Nur die `.gitignore`-Zeile `node_modules` (ohne Schrägstrich, laut Übersicht von L0) ist weich: fehlt sie, zeigt `git status` später `?? node_modules`; das ist kein Fehler dieses Strangs (Task 9, Schritt 2), nichts selbst ändern. - [ ] **Schritt 5: Werkzeugkette prüfen** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/financeStripeRead.test.js ``` Erwartet: PASS (`fail 0`; je nach Reporter steht `# ` oder `ℹ ` davor). Kein Commit in diesem Task. --- ### Task 2: Reine Rechenregeln (`rechneKonto`, `rechneOhneProjekt`) **Files:** - Create: `server/services/auftraege/projektkonto.js` - Create: `test/auftraegeProjektkonto.test.js` **Interfaces:** - Consumes: `VEREINBARTE_STATUS`, `STUNDENSATZ_STANDARD_CENTS` (`konstanten.js`); `abrechenbar(blatt) → boolean` (`arbeitsblaetter.js`: `!aboZeit && !kommentar && minuten > 0`). Eingabeformen: `Auftrag` (V3: `id, status, art, summeCents, stundensatzCents, schaetzungMinuten, gesendetAm, erstelltAm`), `Arbeitsblatt` (V3: `id, datum, minuten, aboZeit, kommentar`; `datum` ist der Berliner Kalendertag), Rechnung in der Form von `mapInvoice` (`id, status, totalCents, paidCents`, Status deutsch: `entwurf, offen, ueberfaellig, bezahlt, storniert, uneinbringlich`). - Produces: - `rechneKonto({ auftraege = [], rechnungen = [], blaetter = [], abgerechnet = new Map(), stripeOk = true }) → { vereinbart: { festpreisCents: number|null, aufwand: { stundensatzCents, schaetzungMinuten: number|null }|null }, berechnetCents, bezahltCents, offenCents: number|null, zeit: { minuten, aboMinuten, offeneMinuten, satzCents, wertCents, offenerWertCents } }`. `rechnungen: [{ art, auftragId, invoice }]`; `abgerechnet`: Map oder Set, nur `.has(blattId)` wird benutzt. Zeitwert (Planrunde): `wertCents` = Summe über die Berliner Tage (`datum`) von `Math.round(Tagesminuten × satzCents / 60)` aller abrechenbaren Blätter, `offenerWertCents` ebenso nur über die offenen Blätter (wie die Zeilen von `aufwandPositionen` in L2). - `rechneOhneProjekt(rechnungen) → { berechnetCents, bezahltCents, anzahl }` (Eingabe: Rechnungen in `mapInvoice`-Form; gezählt werden nur finalisierte). - [ ] **Schritt 1: Failing Test** – neue Datei `test/auftraegeProjektkonto.test.js` (Kopf mit Nachbauten und Aufräumen; die Namen aus Task 3 bis 6 werden hier schon importiert und sind bis dahin `undefined`): ```js 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 }) }) ``` - [ ] **Schritt 2: Lauf, erwartet FAIL** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeProjektkonto.test.js ``` Erwartet: FAIL, `Cannot find module '…/server/services/auftraege/projektkonto.js'`. - [ ] **Schritt 3: Implementieren** – neue Datei `server/services/auftraege/projektkonto.js`: ```js 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 } } ``` - [ ] **Schritt 4: Lauf, PASS** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeProjektkonto.test.js ``` Erwartet: alle elf Tests grün. Stimmt ein Wert nicht, weil `abrechenbar` anders arbeitet als im Vertrag, siehe „L0-Abweichungen“ in den Global Constraints. - [ ] **Schritt 5: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 git add server/services/auftraege/projektkonto.js test/auftraegeProjektkonto.test.js git commit -m "$(cat <<'EOF' Projektkonto: reine Rechenregeln (vereinbart, berechnet, offen, Zeitwert je Tag gerundet, Ohne Projekt) Co-Authored-By: Claude Opus 5.5 EOF )" ``` --- ### Task 3: Stripe-Rechnungen laden (`ladeStripeRechnungen`) **Files:** - Modify: `server/services/auftraege/projektkonto.js` - Modify: `test/auftraegeProjektkonto.test.js` **Interfaces:** - Consumes: `listInvoices() → Promise` und `mapInvoice(raw, { today })` (`finance/stripeFinance.js`), `getStripe()` (`stripeBilling.js`), `berlinDate()` (`finance/dates.js`). - Produces: `ladeStripeRechnungen(projektRechnungen) → Promise<{ ok: boolean, alle: Invoice[], nachId: Map }>`. `projektRechnungen`: Liste mit `invoiceId` (V3 `ProjektRechnung`). `alle` ist die Liste aus `listInvoices()`; `nachId` enthält `alle` plus die einzeln gelesenen Rechnungen; IDs, die `/^in_\w{1,33}$/` (V1) nicht erfüllen, werden nie bei Stripe angefragt. Bei jedem Stripe-Fehler: `{ ok: false, alle: [], nachId: leere Map }` und genau eine Zeile `console.warn` ohne Fehlertext. - [ ] **Schritt 1: Failing Test** – ans Ende von `test/auftraegeProjektkonto.test.js` anhängen: ```js // ---------- 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) }) ``` - [ ] **Schritt 2: Lauf, erwartet FAIL** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeProjektkonto.test.js ``` Erwartet: die fünf neuen Tests FAIL (`ladeStripeRechnungen is not a function`), die elf aus Task 2 PASS. - [ ] **Schritt 3: Implementieren** – in `projektkonto.js` die Importe ergänzen und unter `rechneOhneProjekt` anfügen: ```js import { getStripe } from '../stripeBilling.js' import { berlinDate } from '../finance/dates.js' import { listInvoices, mapInvoice } from '../finance/stripeFinance.js' ``` ```js // Einzellesen fehlender Rechnungen: wie die Leseanfragen in stripeFinance.js (15 s, eine Wiederholung); stripeFinance.js // haelt seine Optionen privat, daher hier eine eigene Konstante const LESE_OPTIONEN = { timeout: 15_000, maxNetworkRetries: 1 } const EINZELN_PARALLEL = 10 // Format der Rechnungs-ID wie im Schema der Projektrechnungen (V1); die IDs des Stripe-Nachbaus (in_test_) passen auch const STRIPE_RECHNUNG = /^in_\w{1,33}$/ /** Eine Rechnung frisch von Stripe in mapInvoice-Form; null, wenn es sie nicht (mehr) gibt (404); andere Fehler fliegen. */ async function leseEinzeln(invoiceId) { try { const raw = await getStripe().invoices.retrieve(invoiceId, { expand: ['customer'] }, LESE_OPTIONEN) return mapInvoice(raw, { today: berlinDate() }) } catch (err) { if (err?.statusCode === 404 || err?.code === 'resource_missing') return null throw err } } /** * Rechnungsdaten fuer eine Liste von Projektrechnungen: die Rechnungen der letzten 400 Tage aus listInvoices() (60-Sekunden- * Zwischenspeicher); was dort fehlt (aelter, ueber 1000, frisch angelegt), wird einzeln gelesen, hoechstens 10 gleichzeitig. * Scheitert Stripe (Liste oder Einzellesen ausser 404), ist ok false und alles leer; ins Protokoll kommen nur Art und Status. */ export async function ladeStripeRechnungen(projektRechnungen) { try { const alle = await listInvoices() const nachId = new Map(alle.map((invoice) => [invoice.id, invoice])) const fehlend = [...new Set(projektRechnungen.map((eintrag) => eintrag.invoiceId))].filter((id) => STRIPE_RECHNUNG.test(id) && !nachId.has(id)) for (let i = 0; i < fehlend.length; i += EINZELN_PARALLEL) { const gelesen = await Promise.all(fehlend.slice(i, i + EINZELN_PARALLEL).map(leseEinzeln)) for (const invoice of gelesen) if (invoice) nachId.set(invoice.id, invoice) } return { ok: true, alle, nachId } } catch (err) { console.warn('[projektkonto] Stripe nicht lesbar:', err?.type || err?.name || 'Error', err?.statusCode ?? err?.status ?? '') return { ok: false, alle: [], nachId: new Map() } } } ``` - [ ] **Schritt 4: Lauf, PASS** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeProjektkonto.test.js ``` Erwartet: 16 Tests grün. - [ ] **Schritt 5: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 git add server/services/auftraege/projektkonto.js test/auftraegeProjektkonto.test.js git commit -m "$(cat <<'EOF' Projektkonto: Stripe-Rechnungen lesen (Liste, Einzellesen fehlender, Ausfall) Co-Authored-By: Claude Opus 5.5 EOF )" ``` --- ### Task 4: Projektkonten eines Kunden (`projektkontenFuerKunde`) **Files:** - Modify: `server/services/auftraege/projektkonto.js` - Modify: `test/auftraegeProjektkonto.test.js` **Interfaces:** - Consumes: `getDocument`, `listDocuments`, `Query` (`appwriteAdmin.js`); `auftraegeAktiv(kunde)` (`schalter.js`); `listAuftraege({ kundenId })` (`auftragStore.js`); `listProjektRechnungen({ kundenId })` (`projektRechnungenStore.js`); `ladeArbeitsblaetter(ticketIds)`, `abgerechneteArbeitsblaetter(kundenId) → Map`, `zeitquelleFuerProjekt(projekt, auftraege) → string[]` (`arbeitsblaetter.js`); `kindLabel(kind)` (`projectKinds.js`); `KUNDENARTEN`; `AuftragError` (`fehler.js`); `rechneKonto`, `rechneOhneProjekt`, `ladeStripeRechnungen` (Tasks 2 und 3). - Produces: `projektkontenFuerKunde(kundenId) → Promise<{ auftraegeAktiv: boolean, kunde: { id, name, kundenart }, konten: Projektkonto[], ohneProjekt: { berechnetCents, bezahltCents, anzahl }, stripeOk: boolean }>` mit `Projektkonto = { projektId, name, projektTyp, tickets: [{ id, woid }], zeitquelle: string[], vereinbart, berechnetCents, bezahltCents, offenCents, zeit }` genau wie V3. Unbekannter Kunde: `AuftragError(404, 'Kunde nicht gefunden.')`. Interne Helfer, die Task 5 wiederverwendet: `ladeDokument`, `kundeAnsicht`, `ladeTickets`, `ladeZeitDaten`, `zeitquelleVon`, `baueKonto`. - [ ] **Schritt 1: Failing Test** – ans Ende von `test/auftraegeProjektkonto.test.js` anhängen: ```js // ---------- 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.' }) }) ``` - [ ] **Schritt 2: Lauf, erwartet FAIL** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeProjektkonto.test.js ``` Erwartet: die zwölf neuen Tests FAIL (`projektkontenFuerKunde is not a function`), die 16 aus Task 2 und 3 PASS. - [ ] **Schritt 3: Implementieren** – in `projektkonto.js` die Importe so ergänzen (gesamter Importblock danach): ```js import { config } from '../../config.js' import { Query, getDocument, listDocuments } from '../appwriteAdmin.js' import { getStripe } from '../stripeBilling.js' import { berlinDate } from '../finance/dates.js' import { listInvoices, mapInvoice } from '../finance/stripeFinance.js' import { kindLabel } from '../projectKinds.js' import { KUNDENARTEN, STUNDENSATZ_STANDARD_CENTS, VEREINBARTE_STATUS } from './konstanten.js' import { AuftragError } from './fehler.js' import { auftraegeAktiv } from './schalter.js' import { listAuftraege } from './auftragStore.js' import { listProjektRechnungen } from './projektRechnungenStore.js' import { abgerechneteArbeitsblaetter, abrechenbar, ladeArbeitsblaetter, zeitquelleFuerProjekt } from './arbeitsblaetter.js' ``` Unter `ladeStripeRechnungen` anfügen: ```js /** Dokument lesen; unbekannt -> AuftragError 404 " nicht gefunden." */ async function ladeDokument(collection, id, was) { try { return await getDocument(collection, id) } catch (err) { if (err?.status === 404) throw new AuftragError(404, `${was} nicht gefunden.`) throw err } } const kundeAnsicht = (doc) => ({ id: doc.$id, name: String(doc.companyName || '').trim() || String(doc.name || '').trim(), kundenart: KUNDENARTEN.includes(doc.kundenart) ? doc.kundenart : '', }) /** Ticket-Dokumente (fuer die Ticketnummer woid) zu den IDs, in Bloecken zu 100. */ async function ladeTickets(ids) { const nachId = new Map() for (let i = 0; i < ids.length; i += 100) { const seite = await listDocuments(config.collections.workorders, [Query.equal('$id', ids.slice(i, i + 100)), Query.limit(100)]) for (const ticket of seite) nachId.set(ticket.$id, ticket) } return nachId } /** * Tickets, Arbeitsblaetter (je Ticket gruppiert) und das Abgerechnete des Kunden fuer alle Zeitquellen auf einmal: je * Anfrage einmal laden, egal wie viele Projekte. Ohne Ticket wird nichts abgefragt. */ async function ladeZeitDaten(kundenId, ticketIds) { if (ticketIds.length === 0) return { tickets: new Map(), nachTicket: new Map(), abgerechnet: new Map() } const [tickets, blaetter, abgerechnet] = await Promise.all([ ladeTickets(ticketIds), ladeArbeitsblaetter(ticketIds), abgerechneteArbeitsblaetter(kundenId), ]) const nachTicket = new Map() for (const blatt of blaetter) { if (!nachTicket.has(blatt.ticketId)) nachTicket.set(blatt.ticketId, []) nachTicket.get(blatt.ticketId).push(blatt) } return { tickets, nachTicket, abgerechnet } } /** Zeitquelle des Projekts ohne Doppelte (Vereinigung der gueltigen ABs, sonst das Projektticket; siehe arbeitsblaetter.js). */ const zeitquelleVon = (projekt, abs) => [...new Set(zeitquelleFuerProjekt(projekt, abs))] /** * Ein Projektkonto (V3). abs: ABs des Projekts; prs: aktive Projektrechnungen des Projekts; stripeDaten: Ergebnis von * ladeStripeRechnungen; zeit: Ergebnis von ladeZeitDaten. Liefert zusaetzlich die Rechnungen und Blaetter fuer das Detail. */ function baueKonto(projekt, { abs, zeitquelle, prs, stripeDaten, zeit }) { const rechnungen = prs .map((eintrag) => ({ art: eintrag.art, auftragId: eintrag.auftragId, invoice: stripeDaten.nachId.get(eintrag.invoiceId) })) .filter((eintrag) => eintrag.invoice) const blaetter = zeitquelle.flatMap((id) => zeit.nachTicket.get(id) ?? []) return { konto: { projektId: projekt.$id, name: projekt.projectName || '', projektTyp: kindLabel(projekt.kind), tickets: zeitquelle.map((id) => ({ id, woid: zeit.tickets.get(id)?.woid || '' })), zeitquelle, ...rechneKonto({ auftraege: abs, rechnungen, blaetter, abgerechnet: zeit.abgerechnet, stripeOk: stripeDaten.ok }), }, rechnungen, blaetter, } } /** * Projektkonten eines Kunden (V4 "L1"): je sichtbarem Projekt ein Konto, dazu die Zeile "Ohne Projekt". Sichtbar sind alle * Projekte des Kunden ausser Vorlagen; archivierte nur mit Geldbezug (aktive Projektrechnung oder AB mit Status aus * VEREINBARTE_STATUS). Ist der AB-Schalter fuer den Kunden aus, werden ABs nicht gelesen (Vereinbart: keine AB). */ export async function projektkontenFuerKunde(kundenId) { const kundeDoc = await ladeDokument(config.collections.customers, kundenId, 'Kunde') const aktiv = auftraegeAktiv(kundeDoc) const [projekte, auftraege, prs] = await Promise.all([ listDocuments(config.collections.websiteProjects, [Query.equal('customerId', kundenId), Query.orderDesc('$createdAt'), Query.limit(1000)]), aktiv ? listAuftraege({ kundenId }) : [], listProjektRechnungen({ kundenId }), ]) const stripeDaten = await ladeStripeRechnungen(prs) const mitGeld = new Set([ ...prs.map((eintrag) => eintrag.projektId), ...auftraege.filter((auftrag) => VEREINBARTE_STATUS.includes(auftrag.status)).map((auftrag) => auftrag.projektId), ]) const sichtbar = projekte.filter((projekt) => projekt.isTemplate !== true && (!projekt.archived || mitGeld.has(projekt.$id))) const eintraege = sichtbar.map((projekt) => { const abs = auftraege.filter((auftrag) => auftrag.projektId === projekt.$id) return { projekt, abs, zeitquelle: zeitquelleVon(projekt, abs) } }) const zeit = await ladeZeitDaten(kundenId, [...new Set(eintraege.flatMap((eintrag) => eintrag.zeitquelle))]) const konten = eintraege.map( ({ projekt, abs, zeitquelle }) => baueKonto(projekt, { abs, zeitquelle, prs: prs.filter((eintrag) => eintrag.projektId === projekt.$id), stripeDaten, zeit }).konto ) // Ohne Projekt: finalisierte Projekt-Rechnungen des Kunden, die kein angezeigtes Konto beansprucht (keine Abo-Rechnungen) const kandidaten = new Map() for (const invoice of stripeDaten.alle) { if (invoice.webklarCustomerId === kundenId && invoice.source === 'projekt') kandidaten.set(invoice.id, invoice) } for (const eintrag of prs) { const invoice = stripeDaten.nachId.get(eintrag.invoiceId) if (invoice) kandidaten.set(invoice.id, invoice) } const angezeigt = new Set(sichtbar.map((projekt) => projekt.$id)) const zugeordnet = new Set(prs.filter((eintrag) => angezeigt.has(eintrag.projektId)).map((eintrag) => eintrag.invoiceId)) const ohneProjekt = rechneOhneProjekt([...kandidaten.values()].filter((invoice) => !zugeordnet.has(invoice.id))) return { auftraegeAktiv: aktiv, kunde: kundeAnsicht(kundeDoc), konten, ohneProjekt, stripeOk: stripeDaten.ok } } ``` - [ ] **Schritt 4: Lauf, PASS** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeProjektkonto.test.js ``` Erwartet: 28 Tests grün. Schlägt ein Test wegen der Form von L0-Daten fehl (Minuten, Datum, `zeitquelle`, Schalter), gilt „L0-Abweichungen“ in den Global Constraints. - [ ] **Schritt 5: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 git add server/services/auftraege/projektkonto.js test/auftraegeProjektkonto.test.js git commit -m "$(cat <<'EOF' Projektkonto: Konten je Kunde (Projektwahl, Zeitquelle, Zeitwert je Tag, Ohne Projekt, Schalter, Stripe-Ausfall) Co-Authored-By: Claude Opus 5.5 EOF )" ``` --- ### Task 5: Projektkonto-Detail (`projektkontoDetail`) **Files:** - Modify: `server/services/auftraege/projektkonto.js` - Modify: `test/auftraegeProjektkonto.test.js` **Interfaces:** - Consumes: Helfer aus Task 4 (`ladeDokument`, `ladeZeitDaten`, `zeitquelleVon`, `baueKonto`), `ladeStripeRechnungen`, `abrechenbar`; `auftragFuerTeam(auftrag) → AuftragTeam` (`auftragSicht.js`); `listAuftraege({ kundenId, projektId })`, `listProjektRechnungen({ kundenId, projektId })`. - Produces: `projektkontoDetail(projektId) → Promise<{ konto: Projektkonto, auftraege: AuftragTeam[], rechnungen: RechnungKurz[], offeneArbeitsblaetter: Arbeitsblatt[], stripeOk: boolean }>` mit `RechnungKurz = { id, number, status, createdDate, totalCents, paidCents, art, auftragId }` (`art` und `auftragId` aus der Projektrechnung, der Rest aus der Stripe-Rechnung). `rechnungen`: aktive Projektrechnungen des Projekts samt Entwürfen, neueste zuerst (`createdDate` absteigend, dann `number`, dann `id`); inaktive nie; bei Stripe-Ausfall `[]`. `auftraege`: alle ABs des Projekts jedes Status (neueste zuerst, wie `listAuftraege`), bei ausgeschaltetem Schalter `[]`. `offeneArbeitsblaetter`: abrechenbar und nicht abgerechnet, sortiert nach `datum`, dann `id`. Unbekanntes Projekt oder Projekt ohne `customerId`: `AuftragError(404, 'Projekt nicht gefunden.')`. - [ ] **Schritt 1: Failing Test** – ans Ende von `test/auftraegeProjektkonto.test.js` anhängen: ```js // ---------- 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) }) ``` Prüfwerte für den ersten Test: Minuten 60 (w1) + 30 (w2) + 15 (w5) = 105, Abo 45 (w3), offen 30 + 15 = 45 (w1 steht in der Aufwandsrechnung in_2), Wert je Tag gerundet 25,00 € (05.10.) + 150,00 € (06.10., 90 Minuten) = 175,00 €, offener Wert 25,00 € + 50,00 € = 75,00 €; berechnet = in_1 (40.000) + in_2 (10.000), der Entwurf in_4 und die inaktive in_3 zählen nicht, offen = 80.000 − 40.000 (nur in_1 gehört zur Festpreis-AB a1). - [ ] **Schritt 2: Lauf, erwartet FAIL** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeProjektkonto.test.js ``` Erwartet: die vier neuen Tests FAIL (`projektkontoDetail is not a function`), die 28 vorherigen PASS. - [ ] **Schritt 3: Implementieren** – Import `import { auftragFuerTeam } from './auftragSicht.js'` ergänzen und unter `projektkontenFuerKunde` anfügen: ```js const neuesteZuerst = (a, b) => (b.createdDate ?? '').localeCompare(a.createdDate ?? '') || b.number.localeCompare(a.number) || b.id.localeCompare(a.id) /** * Ein Projektkonto mit seinen ABs, Rechnungen (aktive Projektrechnungen, soweit Stripe sie kennt) und den noch nicht * abgerechneten Arbeitsblaettern (V4 "L1"). Bei Stripe-Ausfall: keine Rechnungen, stripeOk false. */ export async function projektkontoDetail(projektId) { const projekt = await ladeDokument(config.collections.websiteProjects, projektId, 'Projekt') if (!projekt.customerId) throw new AuftragError(404, 'Projekt nicht gefunden.') const kundenId = projekt.customerId const kundeDoc = await ladeDokument(config.collections.customers, kundenId, 'Kunde') const aktiv = auftraegeAktiv(kundeDoc) const [abs, prs] = await Promise.all([ aktiv ? listAuftraege({ kundenId, projektId }) : [], listProjektRechnungen({ kundenId, projektId }), ]) const stripeDaten = await ladeStripeRechnungen(prs) const zeitquelle = zeitquelleVon(projekt, abs) const zeit = await ladeZeitDaten(kundenId, zeitquelle) const { konto, rechnungen, blaetter } = baueKonto(projekt, { abs, zeitquelle, prs, stripeDaten, zeit }) return { konto, auftraege: abs.map((auftrag) => auftragFuerTeam(auftrag)), rechnungen: rechnungen .map(({ art, auftragId, invoice }) => ({ id: invoice.id, number: invoice.number, status: invoice.status, createdDate: invoice.createdDate, totalCents: invoice.totalCents, paidCents: invoice.paidCents, art, auftragId, })) .sort(neuesteZuerst), offeneArbeitsblaetter: blaetter .filter((blatt) => abrechenbar(blatt) && !zeit.abgerechnet.has(blatt.id)) .sort((a, b) => a.datum.localeCompare(b.datum) || a.id.localeCompare(b.id)), stripeOk: stripeDaten.ok, } } ``` - [ ] **Schritt 4: Lauf, PASS** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeProjektkonto.test.js ``` Erwartet: 32 Tests grün. - [ ] **Schritt 5: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 git add server/services/auftraege/projektkonto.js test/auftraegeProjektkonto.test.js git commit -m "$(cat <<'EOF' Projektkonto: Detail mit ABs, Rechnungen und offenen Arbeitsblättern Co-Authored-By: Claude Opus 5.5 EOF )" ``` --- ### Task 6: Abzeichen „abgerechnet“ (`abgerechnetFuerTicket`) **Files:** - Modify: `server/services/auftraege/projektkonto.js` - Modify: `test/auftraegeProjektkonto.test.js` **Interfaces:** - Consumes: `abgerechneteArbeitsblaetter(kundenId) → Map` (nur aktive Projektrechnungen des Kunden), `ladeArbeitsblaetter([ticketId])`, `getDocument` (Ticket aus `workorders`, Feld `customerId`). - Produces: `abgerechnetFuerTicket(ticketId) → Promise<{ [worksheetId]: string }>`: je abgerechnetem Arbeitsblatt dieses Tickets die Rechnungsnummer, bei noch nicht finalisierter Rechnung (leere `rechnungsnummer`) `'Entwurf'`. Unbekanntes Ticket, Ticket ohne `customerId` oder nichts abgerechnet: `{}`. - [ ] **Schritt 1: Failing Test** – ans Ende von `test/auftraegeProjektkonto.test.js` anhängen: ```js // ---------- 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'), {}) }) ``` - [ ] **Schritt 2: Lauf, erwartet FAIL** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeProjektkonto.test.js ``` Erwartet: die zwei neuen Tests FAIL (`abgerechnetFuerTicket is not a function`), die 32 vorherigen PASS. - [ ] **Schritt 3: Implementieren** – unter `projektkontoDetail` anfügen: ```js /** * Abzeichen "abgerechnet" an den Arbeitsblaettern eines Tickets (alle Mitarbeiter, ohne Betraege): { Blatt-ID: Rechnungsnummer * oder 'Entwurf' }. Die Blatt-IDs kommen aus dem Ticket, die Zuordnung aus den aktiven Projektrechnungen seines Kunden. */ export async function abgerechnetFuerTicket(ticketId) { let ticket try { ticket = await getDocument(config.collections.workorders, ticketId) } catch (err) { if (err?.status === 404) return {} throw err } if (!ticket.customerId) return {} const abgerechnet = await abgerechneteArbeitsblaetter(ticket.customerId) if (abgerechnet.size === 0) return {} const ergebnis = {} for (const blatt of await ladeArbeitsblaetter([ticketId])) { const eintrag = abgerechnet.get(blatt.id) if (eintrag) ergebnis[blatt.id] = eintrag.rechnungsnummer || 'Entwurf' } return ergebnis } ``` - [ ] **Schritt 4: Lauf, PASS** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeProjektkonto.test.js ``` Erwartet: 34 Tests grün. - [ ] **Schritt 5: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 git add server/services/auftraege/projektkonto.js test/auftraegeProjektkonto.test.js git commit -m "$(cat <<'EOF' Projektkonto: Abzeichen abgerechnet je Ticket (Rechnungsnummer oder Entwurf) Co-Authored-By: Claude Opus 5.5 EOF )" ``` --- ### Task 7: Admin-Routen `GET /api/admin/finanzen/projektkonten` **Files:** - Modify: `server/routes/admin/projektkonten.js` (Gerüst aus L0) - Create: `test/auftraegeRoutesProjektkonten.test.js` **Interfaces:** - Consumes: `requireAppwriteStaff` (`middleware/appwriteStaff.js`: Bearer-JWT, Label `admin`; 401 ohne/ungültiges JWT, 403 ohne Label), `sendeFehler(res, was, err)` (`fehler.js`: `AuftragError`/`InvoiceError` → deren Status und Meldung, alles andere → protokolliert, `500 { error: 'Interner Fehler.' }`), `projektkontenFuerKunde`, `projektkontoDetail`. - Produces (Router, in L0 unter `/api/admin/finanzen/projektkonten` **vor** `/api/admin/finanzen` eingehängt): - `GET /?kunde=` → 200 `{ auftraegeAktiv, kunde, konten, ohneProjekt, stripeOk }`; 400 `{ error: 'Kunde fehlt oder ist ungültig.' }`; 404 `{ error: 'Kunde nicht gefunden.' }`. - `GET /:projektId` → 200 `{ konto, auftraege, rechnungen, offeneArbeitsblaetter, stripeOk }`; 404 `{ error: 'Projekt nicht gefunden.' }` (auch bei unmöglicher Kennung). - [ ] **Schritt 1: Failing Test** – neue Datei `test/auftraegeRoutesProjektkonten.test.js` (die Team-Tests aus Task 8 kommen in dieselbe Datei): ```js 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' // Routen des Projektkontos (Strang L1): Admin (requireAppwriteStaff) und Team (requireStaffJwt). Muster: financeRoutesBank.test.js // (JWT-Köpfe des Appwrite-Nachbaus), shopTeam.test.js (Team-Eingang) und den Gerüsttest von L0 (auftraegeGeruest.test.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 { seedFixtures } = await import('./support/fixtures.js') const { startTestApp, rawGet } = await import('./support/testApp.js') const { default: projektkontenRoutes } = await import('../server/routes/admin/projektkonten.js') const { default: teamRoutes } = await import('../server/routes/team/index.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { invalidateFinanceCache } = await import('../server/services/finance/stripeFinance.js') const app = await startTestApp({ '/api/admin/finanzen/projektkonten': projektkontenRoutes, '/api/team': teamRoutes }) after(() => Promise.all([app.close(), aw.close(), st.close()])) seedFixtures(aw) const admin = { headers: { Authorization: 'Bearer jwt-admin' } } const staff = { headers: { Authorization: 'Bearer jwt-staff' } } const ohneLabel = { headers: { Authorization: 'Bearer jwt-kunde' } } const BASIS = '/api/admin/finanzen/projektkonten' const COLLECTIONS = ['websiteProjects', 'workorders', 'worksheets', 'auftraege', 'projektRechnungen'] async function reset() { mock.restoreAll() invalidateFinanceCache() st.calls.length = 0 st.invoices.clear() 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) const blatt = (id, minuten) => ({ $id: id, wsid: `10000${id.slice(1)}`, woid: '100001', workorderId: 't1', employeeId: 'e1', employeeName: 'Kenso Grimm', employeeShort: 'KNSO', serviceType: 'Remote', totalTime: minuten, supporters: '', startDate: '06.10.2026', endDate: '06.10.2026', details: 'Startseite', isComment: false, countsToPlan: false, }) function seedBasis() { aw.seed(config.collections.workorders, { $id: 't1', woid: '100001', customerId: 'c1', topic: 'Website', status: 'open' }) aw.seed(config.collections.websiteProjects, { $id: 'p1', customerId: 'c1', projectName: 'Website Müller', ticketId: 't1', kind: 'website', archived: false }) aw.seed(config.collections.worksheets, blatt('w1', 60)) aw.seed(config.collections.worksheets, blatt('w2', 30)) } test('Projektkonto-Routen: ohne JWT 401, ohne Admin-Label 403', async () => { for (const path of [`${BASIS}?kunde=c1`, `${BASIS}/p1`]) { assert.equal((await rawGet(path)).status, 401, `${path} ohne JWT`) assert.equal((await rawGet(path, { headers: { Authorization: 'Bearer jwt-unbekannt' } })).status, 401, `${path} mit falschem JWT`) assert.equal((await rawGet(path, staff)).status, 403, `${path} als Mitarbeiter ohne Admin-Label`) assert.equal((await rawGet(path, ohneLabel)).status, 403, `${path} als Konto ohne Label`) } }) test('GET ?kunde=: ohne, leer, doppelt oder mit unmöglicher Kennung -> 400', async () => { for (const query of ['', '?kunde=', '?kunde=c1&kunde=c2', '?kunde=../x', `?kunde=${'c'.repeat(37)}`, '?kunde[a]=b']) { const res = await rawGet(`${BASIS}${query}`, admin) assert.equal(res.status, 400, query) assert.deepEqual(res.body, { error: 'Kunde fehlt oder ist ungültig.' }, query) } }) test('GET ?kunde= -> 404', async () => { const res = await rawGet(`${BASIS}?kunde=gibtsnicht`, admin) assert.equal(res.status, 404) assert.deepEqual(res.body, { error: 'Kunde nicht gefunden.' }) }) test('GET ?kunde=c1: Projektkonten mit Zeit, Antwort mit genau den vereinbarten Schlüsseln', async () => { seedBasis() const res = await rawGet(`${BASIS}?kunde=c1`, admin) assert.equal(res.status, 200) assert.deepEqual(Object.keys(res.body).sort(), ['auftraegeAktiv', 'konten', 'kunde', 'ohneProjekt', 'stripeOk']) assert.deepEqual(res.body.kunde, { id: 'c1', name: 'Kunde Eins', kundenart: '' }) assert.deepEqual(res.body.ohneProjekt, { berechnetCents: 0, bezahltCents: 0, anzahl: 0 }) assert.equal(res.body.stripeOk, true) assert.equal(res.body.auftraegeAktiv, true) assert.deepEqual(res.body.konten.map((konto) => [konto.projektId, konto.name, konto.projektTyp, konto.zeit.minuten]), [['p1', 'Website Müller', 'Website', 90]]) assert.deepEqual(res.body.konten[0].tickets, [{ id: 't1', woid: '100001' }]) }) test('GET /:projektId: Konto, ABs, Rechnungen und offene Arbeitsblätter; unbekannt und unmöglich -> 404', async () => { seedBasis() const res = await rawGet(`${BASIS}/p1`, admin) assert.equal(res.status, 200) assert.deepEqual(Object.keys(res.body).sort(), ['auftraege', 'konto', 'offeneArbeitsblaetter', 'rechnungen', 'stripeOk']) assert.equal(res.body.konto.projektId, 'p1') assert.deepEqual(res.body.offeneArbeitsblaetter.map((eintrag) => eintrag.id), ['w1', 'w2']) for (const id of ['gibtsnicht', '%20x']) { const fehler = await rawGet(`${BASIS}/${id}`, admin) assert.equal(fehler.status, 404, id) assert.deepEqual(fehler.body, { error: 'Projekt nicht gefunden.' }, id) } }) test('Stripe-Ausfall: beide Routen antworten 200 mit stripeOk false', async () => { mock.method(console, 'warn', () => {}) seedBasis() st.failNext({ method: 'GET', path: '/v1/invoices', status: 400, message: 'kaputt' }) const liste = await rawGet(`${BASIS}?kunde=c1`, admin) assert.equal(liste.status, 200) assert.equal(liste.body.stripeOk, false) assert.equal(liste.body.konten[0].zeit.minuten, 90) st.failNext({ method: 'GET', path: '/v1/invoices', status: 400, message: 'kaputt' }) const detail = await rawGet(`${BASIS}/p1`, admin) assert.equal(detail.status, 200) assert.equal(detail.body.stripeOk, false) }) test('Fehler in Appwrite: 500 mit fester Meldung, ohne Text von Appwrite', async () => { mock.method(console, 'error', () => {}) seedBasis() aw.failNext({ collection: config.collections.websiteProjects, method: 'GET', status: 500, message: 'geheim-appwrite' }) const res = await rawGet(`${BASIS}?kunde=c1`, admin) assert.equal(res.status, 500) assert.deepEqual(res.body, { error: 'Interner Fehler.' }) }) ``` - [ ] **Schritt 2: Lauf, erwartet FAIL** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeRoutesProjektkonten.test.js ``` Erwartet: FAIL bei sechs der sieben Tests (das Gerüst hat noch keine Handler: 404 statt 200/400); nur der Zugriffstest (401/403) kann schon grün sein, weil `requireAppwriteStaff` im Gerüst steht. - [ ] **Schritt 3: Implementieren** – `server/routes/admin/projektkonten.js` öffnen. Das L0-Gerüst enthält `Router()`, `router.use(requireAppwriteStaff)` als erste Middleware und `export default router` (der Schutz gehört laut Übersicht ins Gerüst; nicht noch einmal einbauen); das bleibt unverändert. Ergänzen: - Importe: `sendeFehler` aus `../../services/auftraege/fehler.js`, `projektkontenFuerKunde` und `projektkontoDetail` aus `../../services/auftraege/projektkonto.js`. - Konstante `KENNUNG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/`. - `router.get('/', …)`: `const kunde = req.query.kunde`; ist es kein String oder erfüllt `KENNUNG` nicht (fehlt, leer, doppelt, verschachtelt, zu lang): `res.status(400).json({ error: 'Kunde fehlt oder ist ungültig.' })`. Sonst `res.json(await projektkontenFuerKunde(kunde))`; jeder Fehler (`catch`) → `sendeFehler(res, 'Projektkonten laden', err)`. - `router.get('/:projektId', …)`: erfüllt `req.params.projektId` `KENNUNG` nicht: `res.status(404).json({ error: 'Projekt nicht gefunden.' })`. Sonst `res.json(await projektkontoDetail(req.params.projektId))`; Fehler → `sendeFehler(res, 'Projektkonto laden', err)`. - Keine weiteren Routen, keine Schreibzugriffe. Tiefenpfade wie `/a/b/c/d` bleiben 404 (so bleibt der Gerüsttest von L0, `test/auftraegeGeruest.test.js`, grün). - [ ] **Schritt 4: Lauf, PASS** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeRoutesProjektkonten.test.js ``` Erwartet: 7 Tests grün. - [ ] **Schritt 5: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 git add server/routes/admin/projektkonten.js test/auftraegeRoutesProjektkonten.test.js git commit -m "$(cat <<'EOF' Projektkonto: Admin-Routen für Konten je Kunde und Detail je Projekt Co-Authored-By: Claude Opus 5.5 EOF )" ``` --- ### Task 8: Team-Route `GET /api/team/arbeitsblaetter/abgerechnet` **Files:** - Modify: `server/routes/team/arbeitsblaetter.js` (Gerüst aus L0) - Modify: `test/auftraegeRoutesProjektkonten.test.js` **Interfaces:** - Consumes: `abgerechnetFuerTicket(ticketId)` (Task 6), `sendeFehler`; in L0 eingehängt als `router.use('/arbeitsblaetter', …)` in `server/routes/team/index.js` hinter `requireStaffJwt` (jeder Mitarbeiter mit Label `mitarbeiter` oder `admin`; ohne JWT 401, Konto ohne Label 403). - Produces: `GET /abgerechnet?ticketId=` → 200 `{ abgerechnet: { : '' | 'Entwurf' } }` (unbekanntes Ticket: `{ abgerechnet: {} }`); 400 `{ error: 'ticketId fehlt oder ist ungültig.' }`. - [ ] **Schritt 1: Failing Test** – ans Ende von `test/auftraegeRoutesProjektkonten.test.js` anhängen: ```js // ---------- Team-Route (Task 8) ---------- const ABGERECHNET = '/api/team/arbeitsblaetter/abgerechnet' 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, }) test('Abzeichen-Route: ohne JWT 401, Konto ohne Label 403, Mitarbeiter und Admin dürfen', async () => { const path = `${ABGERECHNET}?ticketId=t1` assert.equal((await rawGet(path)).status, 401) assert.equal((await rawGet(path, ohneLabel)).status, 403) assert.equal((await rawGet(path, staff)).status, 200) assert.equal((await rawGet(path, admin)).status, 200) }) test('Abzeichen-Route: ticketId fehlt, ist leer, doppelt oder unmöglich -> 400', async () => { for (const query of ['', '?ticketId=', '?ticketId=t1&ticketId=t2', '?ticketId=../x', `?ticketId=${'t'.repeat(37)}`, '?ticketId[a]=b']) { const res = await rawGet(`${ABGERECHNET}${query}`, staff) assert.equal(res.status, 400, query) assert.deepEqual(res.body, { error: 'ticketId fehlt oder ist ungültig.' }, query) } }) test('Abzeichen-Route: { abgerechnet: { Blatt: Rechnungsnummer | Entwurf } } ohne Beträge, unbekanntes Ticket leer', async () => { seedBasis() seedPr('in_1', { rechnungsnummer: 'WEBKLAR-0007', arbeitsblattIds: ['w1'] }) seedPr('in_2', { rechnungsnummer: '', arbeitsblattIds: ['w2'] }) const res = await rawGet(`${ABGERECHNET}?ticketId=t1`, staff) assert.equal(res.status, 200) assert.deepEqual(res.body, { abgerechnet: { w1: 'WEBKLAR-0007', w2: 'Entwurf' } }) assert.ok(!/cents|betrag|in_1|in_2/i.test(JSON.stringify(res.body)), 'weder Beträge noch Rechnungs-IDs') const unbekannt = await rawGet(`${ABGERECHNET}?ticketId=gibtsnicht`, staff) assert.equal(unbekannt.status, 200) assert.deepEqual(unbekannt.body, { abgerechnet: {} }) }) ``` - [ ] **Schritt 2: Lauf, erwartet FAIL** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeRoutesProjektkonten.test.js ``` Erwartet: alle drei neuen Tests FAIL (das Gerüst hat noch keinen Handler: 404 statt 200/400), die sieben aus Task 7 PASS. - [ ] **Schritt 3: Implementieren** – `server/routes/team/arbeitsblaetter.js` öffnen. Das L0-Gerüst exportiert einen Router (`export default router`); es braucht keine eigene Anmeldeprüfung (sie liegt in `team/index.js`). Ergänzen: - Importe: `sendeFehler` aus `../../services/auftraege/fehler.js`, `abgerechnetFuerTicket` aus `../../services/auftraege/projektkonto.js`. - Konstante `KENNUNG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/`. - `router.get('/abgerechnet', …)`: `const ticketId = req.query.ticketId`; kein String oder `KENNUNG` nicht erfüllt: `res.status(400).json({ error: 'ticketId fehlt oder ist ungültig.' })`. Sonst `res.json({ abgerechnet: await abgerechnetFuerTicket(ticketId) })`; Fehler → `sendeFehler(res, 'Abgerechnete Arbeitsblätter laden', err)`. - Keine weiteren Routen; nichts anderes als diese Antwort ausgeben (keine Beträge, keine Rechnungs-IDs). - [ ] **Schritt 4: Lauf, PASS** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeRoutesProjektkonten.test.js ``` Erwartet: 10 Tests grün. - [ ] **Schritt 5: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 git add server/routes/team/arbeitsblaetter.js test/auftraegeRoutesProjektkonten.test.js git commit -m "$(cat <<'EOF' Projektkonto: Team-Route für das Abzeichen abgerechnet am Arbeitsblatt Co-Authored-By: Claude Opus 5.5 EOF )" ``` --- ### Task 9: Selbstprüfung **Files:** keine neuen; nur Korrekturen, falls etwas nicht stimmt (dann eigener Commit mit einzeln genannten Dateien). - [ ] **Schritt 1: Eigene Testdateien und den Gerüsttest von L0 laufen lassen** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/auftraegeProjektkonto.test.js test/auftraegeRoutesProjektkonten.test.js timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeGeruest.test.js ``` Erwartet: im ersten Lauf `pass 44`, `fail 0` (34 + 10 Tests; je nach Reporter steht `# ` oder `ℹ ` davor); im zweiten `fail 0` (der Gerüsttest von L0 gilt auch nach dem Füllen der Router: Zugangsschutz und Tiefenpfade bleiben wie gehabt). - [ ] **Schritt 2: Nur eigene Dateien geändert** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 git diff --name-only feature/auftraege...HEAD git log --oneline feature/auftraege..HEAD git status --short ``` Erwartet: genau diese fünf Dateien (Reihenfolge egal): ``` server/routes/admin/projektkonten.js server/routes/team/arbeitsblaetter.js server/services/auftraege/projektkonto.js test/auftraegeProjektkonto.test.js test/auftraegeRoutesProjektkonten.test.js ``` dazu sieben Commits (Tasks 2 bis 8) und **keine** Ausgabe von `git status --short`: L0 trägt laut Übersicht (Nachträge) in `.gitignore` den Eintrag `node_modules` ohne Schrägstrich ein, der auch den Symlink aus Task 1 ignoriert. Steht dort `?? node_modules`, fehlt diese L0-Änderung: kein Fehler dieses Strangs, nichts selbst ändern (`.gitignore` gehört L0), im Bericht melden. Jede andere Datei: zurücknehmen, sie gehört einem anderen Strang. - [ ] **Schritt 3: Keine Schreibzugriffe, keine Unicode-Escapes mit vier Hexziffern** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l1 command grep -a -n -E "createDocument|updateDocument|deleteDocument|createDocuments|\.create\(|\.update\(|\.del\(|finalizeInvoice|voidInvoice|sendInvoice" server/services/auftraege/projektkonto.js server/routes/admin/projektkonten.js server/routes/team/arbeitsblaetter.js command grep -a -n -E '\\u[0-9a-fA-F]{4}' server/services/auftraege/projektkonto.js server/routes/admin/projektkonten.js server/routes/team/arbeitsblaetter.js test/auftraegeProjektkonto.test.js test/auftraegeRoutesProjektkonten.test.js ``` Erwartet: beide Male keine Ausgabe (Regel der Übersicht: das Schreibwerkzeug macht aus einem solchen Escape das Zeichen selbst). - [ ] **Schritt 4: Vertragsliste abhaken** - [ ] V4 `projektkontenFuerKunde(kundenId)`: Rückgabe `{ auftraegeAktiv, kunde: { id, name, kundenart }, konten, ohneProjekt: { berechnetCents, bezahltCents, anzahl }, stripeOk }` (Task 4 »kunde: …«, »Ohne Projekt: …«; Task 7 Schlüsselprüfung). - [ ] V3 `Projektkonto` mit genau den Schlüsseln `projektId, name, projektTyp, tickets, zeitquelle, vereinbart, berechnetCents, bezahltCents, offenCents, zeit` (Task 4 »Projekt ohne Ticket …«). - [ ] V4 `projektkontoDetail(projektId)`: `{ konto, auftraege: AuftragTeam[], rechnungen: RechnungKurz[], offeneArbeitsblaetter, stripeOk }` mit `RechnungKurz = { id, number, status, createdDate, totalCents, paidCents, art, auftragId }` (Task 5 »Detail: Konto, ABs, …«). - [ ] Rechenregeln Spec 4: Vereinbart, Berechnet nur finalisiert, bezahlt, offen nur aus Festpreis-ABs und nie unter 0, Zeit ohne Abo/Kommentar/0 Minuten, Satz der AB bzw. 100 € (Task 2). - [ ] Zeitwert (Planrunde): Summe der je Berliner Tag gerundeten Werte, `offenerWertCents` ebenso (Task 2 »der Wert der Zeit ist die Summe der je Berliner Tag gerundeten Werte …«, Task 4 »Zeitwert im Konto …«). - [ ] Rechnungs-IDs bei Stripe nur nach `/^in_\w{1,33}$/` gelesen (Task 3 »unmögliche Rechnungs-IDs …«, `in_test_` wird gelesen). - [ ] Stripe-Ausfall: `stripeOk: false`, Beträge 0, Arbeitszeit bleibt (Task 3, 4, 5, 7). - [ ] „Ohne Projekt“: nur `source: 'projekt'`, finalisiert, ohne zugeordnete (Task 4). - [ ] V5 `GET /api/admin/finanzen/projektkonten?kunde=`: 400 ohne Kunde, 404 unbekannter Kunde; `GET …/:projektId`: 404; Admin-only (401/403) (Task 7). - [ ] V5 `GET /api/team/arbeitsblaetter/abgerechnet?ticketId=`: `{ abgerechnet: { : Nummer | 'Entwurf' } }`, 400 (Task 6 und 8). - [ ] Fehlerantworten immer `{ error }`, 500 nur mit fester Meldung (Task 7 »Fehler in Appwrite …«). - [ ] `stripeFinance.js`, `config.js`, `schema.js`, `test/support/*`, `server/index.js`, `server/routes/team/index.js` unverändert (Schritt 2). - [ ] **Schritt 5: Abschlussbericht** an den Orchestrator: Branch, Commit-Liste, Testergebnis (44 grün plus der Gerüsttest von L0), Abweichungen von L0-Bausteinen (falls Tests deswegen angepasst werden mussten); die Vertragsfragen unten sind entschieden und brauchen keine Rückmeldung. Nicht pushen. --- ## Vertragsfragen Alle neun Fragen dieses Strangs sind in der Planrunde **entschieden (siehe Übersicht, Nachträge)**: Abschnitt „Nachträge nach der Planrunde“ sowie V1 und V5. Dieser Plan setzt die Entscheidungen um; es bleibt keine offene Vertragsfrage. 1. **Rechnungs-ID-Regex (V1) gegen den Stripe-Nachbau.** Entschieden (siehe Übersicht, Nachträge): V1 verlangt jetzt `/^in_\w{1,33}$/`, der Nachbau vergibt `in_test_`. L1 prüft Rechnungs-IDs vor dem Lesen bei Stripe mit genau dieser Regex (Task 3: Test mit `in_test_` und mit zu langer ID). 2. **`projektTyp` (V3) ohne festgelegten Inhalt.** Entschieden (siehe Übersicht, Nachträge): `projektTyp` ist der Anzeigename der Projektart (`kindLabel(websiteProjects.kind)`, z. B. „Website“; `''`, solange die Art nicht gesetzt ist); W1 zeigt ihn unverändert (Task 4). 3. **`offenCents` bei Stripe-Ausfall.** Entschieden (siehe Übersicht, Nachträge): `null` (Anzeige „–“) zusammen mit `stripeOk: false`, nicht „Festpreis − 0“ (Task 2 »ohne Stripe …«, Task 4 und 5 »Stripe-Ausfall«). 4. **„Ohne Projekt“ präzisiert.** Entschieden (siehe Übersicht, Nachträge): finalisierte Rechnungen mit `source: 'projekt'` ohne aktive Projektrechnung plus verwaiste Projektrechnungen (Projekt gelöscht, umgehängt, Vorlage); Abo-Rechnungen zählen nicht (Task 4). 5. **Auseinanderlauf Stripe und Projektrechnung.** Entschieden (siehe Übersicht, Nachträge): die Selbstheilung macht L2 (stößt eine Prüfung auf eine Projektrechnung, deren Stripe-Rechnung storniert oder gelöscht ist, wird sie `aktiv: false` und die Prüfung wiederholt). L1 liest nur und ändert dafür nichts. 6. **Rundung des Zeitwerts.** Entschieden (siehe Übersicht, Nachträge): Summe der je Berliner Tag gerundeten Werte (`Math.round(Tagesminuten × Satz / 60)`, wie `aufwandPositionen` in L2), für `wertCents` und `offenerWertCents`; umgesetzt in Task 2 und mit krummem Satz (99,90 €/Std.) geprüft in Task 2 und Task 4. Bekannte Restabweichung: Fasst L2 bei mehr als 20 Tagen die ältesten Tage zu Kalenderwochen zusammen, kann die Rechnung um wenige Cent abweichen. (Bei 95,00 €/Std. geht jede Viertelstunde glatt auf, dort fällt kein Unterschied an; Task 2 prüft deshalb 99,90 €.) 7. **Team-Route bei unbekanntem Ticket.** Entschieden (siehe Übersicht, Nachträge): `200 { abgerechnet: {} }`, wie V5 es jetzt nennt (Task 6 und 8). 8. **Schalter aus liest keine ABs.** Entschieden (siehe Übersicht, Nachträge): so; das Setup-Skript legt `projektRechnungen` in jedem Fall an und läuft vor dem Push (Übersicht, „Livegang“). 9. **Router-Gerüst.** Entschieden (siehe Übersicht, Nachträge): das L0-Gerüst trägt `router.use(requireAppwriteStaff)`; L1 baut den Schutz nicht ein, prüft ihn in Task 1 und testet 401/403 in Task 7.