Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-06-auftraege-02-projektkonto.md
T
ClaudeandClaude Opus 5.5 9d82fc579f Umsetzungspläne Aufträge und Rechnungen: Übersicht und 12 Strang-Pläne
Parallele Stränge L0–L7 (Portal), W0–W2 (WOMS), Z (Integration und
Kontrolle); Vertragsfragen der Planrunde entschieden (Abschnitt
„Nachträge nach der Planrunde“ in der Übersicht).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:49:15 +00:00

100 KiB
Raw Blame History

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_<n>, 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/<datei>; 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

free -m

Erwartet: Spalte „available“ mindestens 1500 MB, sonst warten.

  • Schritt 2: Worktree anlegen
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
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
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
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):

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
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:
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
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
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 <noreply@anthropic.com>
EOF
)"

Task 3: Stripe-Rechnungen laden (ladeStripeRechnungen)

Files:

  • Modify: server/services/auftraege/projektkonto.js
  • Modify: test/auftraegeProjektkonto.test.js

Interfaces:

  • Consumes: listInvoices() → Promise<Invoice[]> und mapInvoice(raw, { today }) (finance/stripeFinance.js), getStripe() (stripeBilling.js), berlinDate() (finance/dates.js).

  • Produces: ladeStripeRechnungen(projektRechnungen) → Promise<{ ok: boolean, alle: Invoice[], nachId: Map<string, Invoice> }>. 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:

// ---------- 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
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:
import { getStripe } from '../stripeBilling.js'
import { berlinDate } from '../finance/dates.js'
import { listInvoices, mapInvoice } from '../finance/stripeFinance.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_<n>) 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
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
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 <noreply@anthropic.com>
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:

// ---------- 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
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):
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:

/** Dokument lesen; unbekannt -> AuftragError 404 "<was> 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
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
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 <noreply@anthropic.com>
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:

// ---------- 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
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:
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
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
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 <noreply@anthropic.com>
EOF
)"

Task 6: Abzeichen „abgerechnet“ (abgerechnetFuerTicket)

Files:

  • Modify: server/services/auftraege/projektkonto.js
  • Modify: test/auftraegeProjektkonto.test.js

Interfaces:

  • Consumes: abgerechneteArbeitsblaetter(kundenId) → Map<worksheetId, { invoiceId, rechnungsnummer }> (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:

// ---------- 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
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:
/**
 * 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
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
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 <noreply@anthropic.com>
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=<id> → 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):

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=<unbekannt> -> 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
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

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
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 <noreply@anthropic.com>
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=<id> → 200 { abgerechnet: { <worksheetId>: '<Rechnungsnummer>' | '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:

// ---------- 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
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

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
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 <noreply@anthropic.com>
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
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
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
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_<n> 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: { <id>: 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_<n>. L1 prüft Rechnungs-IDs vor dem Lesen bei Stripe mit genau dieser Regex (Task 3: Test mit in_test_<n> 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.