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>
54 KiB
Aufträge und Rechnungen – Umsetzungsplan, Übersicht und Verträge
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. Dieser Plan ist auf parallele Stränge ausgelegt (Nutzerwunsch 06.10.2026: „so viele Sub-Agents wie möglich, viel nebenbei, zum Schluss die Kontrolle“). Jeder Strang hat eine eigene Plan-Datei, einen eigenen Worktree und eigene Dateien. Diese Übersicht ist der Vertrag zwischen den Strängen: Namen, Signaturen, Datenformen und Endpunkte stehen nur hier und gelten für alle.
Goal: Admins legen in WOMS beim Kunden Auftragsbestätigungen (AB) und Rechnungen zu einem Projekt an, sehen je Projekt ein Projektkonto (vereinbart, berechnet, bezahlt, offen, Zeit) und rechnen per Anzahlung, Schlussrechnung, nach Aufwand oder als Festbetrag ab.
Architecture: Der Portal-Server (kundenbereich.webklar.com) bekommt einen Baustein server/services/auftraege/ (Speicher, Projektkonto, Positionsbau, AB-Logik, PDF über pdfkit, Bestätigungs-Token, Mails) und erweitert die bestehende Stripe-Rechnung (stripeFinance.js). WOMS (ticket.webklar.com) zeigt Projektkonten, AB- und Rechnungsdialoge im neuen weißen Design; das Kundenportal bekommt eine Bestätigungsseite, einen Abschnitt „Aufträge“ und die Vertragsart „Projektauftrag“ auf der Widerrufsseite. Der AB-Teil hängt hinter dem Schalter AUFTRAEGE_ENABLED (Vorgabe off), damit der Rechnungsteil vor der Rechtsrunde live gehen kann.
Tech Stack: Node 20.19 (Container) und 24 (Host), Express 4, Appwrite-REST über server/services/appwriteAdmin.js, Stripe SDK 19 (API 2025-10-29.clover), pdfkit (neu), nodemailer mit HTML-Mailvorlagen (server/mail-templates), Tests mit node --test und den Nachbauten in test/support/ (Appwrite, Stripe, Mail-Senke, Test-App); WOMS: React 18, Vite, Tests mit npm test (tests/unit/*.test.mjs).
Spec: docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md (Commit e00b4ae). Jeder Implementierer liest Spec und diese Übersicht und seine Strang-Datei.
Ausführungsmodell
Phase A L0 Fundament (1 Agent) ───────────────────────────────┐
Phase B L1 Projektkonto · L2 Rechnungen · L3 AB-Kern · L4 AB-PDF · L5 Bestätigung+Mails · L6 Kundenportal · L7 Rechtstexte (7 Agents parallel)
Phase W [WOMS-Redesign P4+P6+P7 zusammengeführt] → W0 WOMS-Basis (1 Agent) → W1 Rechnungs-UI · W2 AB-UI (2 Agents parallel)
Phase Z Z1 Portal-Integration ‖ Z2 WOMS-Integration → Z3 Kontrolle (5 Prüfer parallel → Bestätigung → Korrektur) → Übergabe
- Ein Implementierer je Strang (Modell
sonnet), Tasks im Strang der Reihe nach, TDD. Keine Reviewer je Task (Nutzerwunsch); jeder Strang endet mit seiner Selbstprüfung (letzter Task der Strang-Datei: eigene Tests grün, Vertragsliste abgehakt,git statussauber). - Phase W startet, sobald die Redesign-Stränge Kunden (P4, P6) und Finanzen (P7) in
feature/design-weisszusammengeführt und getaggt sind (Tagsdesign-p4…design-p7, siehe Memory „woms-theme-weiss“). Fehlen sie beim Start von Phase W, fragt der Orchestrator den Nutzer (warten oder auftestbauen und eine Mitnahme-Notiz in/root/reports/ticketsystem-design-2026-10-04/strang/mitnehmen.mdhinterlegen). - Phase Z: Z1 und Z2 dürfen parallel laufen; Z3 erst, wenn beide fertig sind. Kleine Prüfungen in Z3 (Bestätigung einzelner Befunde, Ein-Datei-Korrekturen) mit
haiku. Keinopus/fable. - Ausführung wahlweise mit dem Workflow-Werkzeug (Phasen wie oben) oder mit parallelen Agent-Aufrufen; die Plan-Dateien sind für beides gleich.
Stränge, Plan-Dateien, Worktrees
| Strang | Plan-Datei (docs/superpowers/plans/) |
Worktree | Branch | Start |
|---|---|---|---|---|
| L0 Fundament | 2026-10-06-auftraege-01-fundament.md |
/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege |
feature/auftraege |
sofort |
| L1 Projektkonto | …-02-projektkonto.md |
…/dev-auftraege-l1 |
auftraege/l1-projektkonto |
nach L0 |
| L2 Rechnungen | …-03-rechnungen.md |
…/dev-auftraege-l2 |
auftraege/l2-rechnungen |
nach L0 |
| L3 AB-Kern | …-04-ab-kern.md |
…/dev-auftraege-l3 |
auftraege/l3-ab-kern |
nach L0 |
| L4 AB-PDF | …-05-ab-pdf.md |
…/dev-auftraege-l4 |
auftraege/l4-ab-pdf |
nach L0 |
| L5 Bestätigung + Mails | …-06-bestaetigung.md |
…/dev-auftraege-l5 |
auftraege/l5-bestaetigung |
nach L0 |
| L6 Kundenportal | …-07-kundenportal.md |
…/dev-auftraege-l6 |
auftraege/l6-kundenportal |
nach L0 |
| L7 Rechtstexte | …-08-rechtstexte.md |
…/dev-auftraege-l7 |
auftraege/l7-rechtstexte |
nach L0 |
| W0 WOMS-Basis | …-09-woms-basis.md |
/home/webklar/apps/ticket.webklar.com/dev-auftraege |
feature/auftraege (WOMS) |
Redesign P4+P6+P7 da |
| W1 Rechnungs-UI | …-10-woms-rechnungen.md |
…/ticket.webklar.com/dev-auftraege-w1 |
auftraege/w1-rechnungen |
nach W0 |
| W2 AB-UI | …-11-woms-auftraege.md |
…/ticket.webklar.com/dev-auftraege-w2 |
auftraege/w2-auftraege |
nach W0 |
| Z Integration + Kontrolle | …-12-integration-kontrolle.md |
Basis-Worktrees | feature/auftraege (beide Repos) |
nach Phase B bzw. W |
Lane-Worktrees entstehen aus dem Stand nach L0:
git -C /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege worktree add ../dev-auftraege-l1 -b auftraege/l1-projektkonto feature/auftraege
und bekommen ln -s ../dev-auftraege/node_modules node_modules (L0 legt dort ein echtes node_modules an). Der Symlink ist unversioniert: nie git add -A, immer Dateien einzeln.
Dateizuständigkeit (Konfliktschutz)
Jede Datei hat genau einen Besitzer-Strang. Ein Strang ändert nur seine Dateien; braucht er etwas aus einer fremden Datei, nutzt er den Vertrag unten. Gemeinsame Dateien (server/config.js, server/index.js, server/routes/team/index.js, package.json, package-lock.json, test/support/*, server/services/auftraege/konstanten.js) ändert nur L0. Ausnahmen stehen in der Tabelle.
| Datei | Besitzer |
|---|---|
server/services/auftraege/schema.js, konstanten.js, schalter.js, auftragStore.js, projektRechnungenStore.js, auftragSicht.js, arbeitsblaetter.js, ablage.js, fehler.js |
L0 |
server/services/auftraege/texte.js |
L0 legt an (vorläufige Wortlaute), danach nur L7 (nur Wortlaute, Exportnamen bleiben) |
scripts/auftraege-setup.mjs, server/services/finance/settings.js (Feld steuernummer) |
L0 |
Router-Gerüste server/routes/admin/projektkonten.js, server/routes/team/arbeitsblaetter.js |
L0 legt an, L1 füllt |
Router-Gerüst server/routes/admin/auftraege.js |
L0 legt an, L3 füllt |
Router-Gerüste server/routes/auftrag.js, server/routes/auftraegeKunde.js |
L0 legt an, L5 füllt |
server/services/auftraege/projektkonto.js |
L1 |
server/services/auftraege/positionen.js, rechnungsbau.js; server/services/finance/stripeFinance.js; server/routes/admin/finanzen.js; test/support/fakeStripe.js (Ausnahme von „nur L0“: nur Ergänzungen für Rechnungsposten mit amount, custom_fields, rendering) |
L2 |
server/services/auftraege/nummern.js, auftraege.js |
L3 |
server/services/auftraege/pdf.js, server/assets/fonts/* |
L4 |
server/services/auftraege/token.js, bestaetigung.js, mails.js; server/mail-templates/K31 …, K32 …, T22 …; server/routes/abo.js (Widerruf projekt); test/mailTemplates.test.js (falls er die Vorlagenliste pinnt) |
L5 |
public/auftrag.html, public/auftrag.js, public/auftrag.css, public/dashboard.html, public/app.js, public/widerrufen.html, server/services/customerInvoices.js |
L6 |
public/rechtliches/* (neue Dateien), scripts/build-legal-html.mjs, scripts/build-legal-pdfs.sh, public/datenschutz.html, docs/WEBklar-Datenschutzerklaerung.pdf (+ .sha256), konstanten.js nur AGB_FASSUNG |
L7 |
Tests: jede Lane legt eigene Dateien test/auftraege<Thema>.test.js an; bestehende Testdateien nur, wenn die Tabelle sie nennt |
je Lane |
Nachträge nach der Planrunde (verbindlich, 06.10.2026)
Die Strang-Pläne wurden parallel geschrieben; ihre „Vertragsfragen“ sind hier entschieden. Wo ein Strang-Plan davon abweicht, gilt dieser Abschnitt.
Zusätzliche Dateien je Strang (Zuständigkeit):
- L0: zusätzlich
.gitignore(Eintragnode_modulesohne Schrägstrich, damit auch der Symlink ignoriert wird) undfontkitals direkte Abhängigkeit inpackage.json(Version wie die pdfkit-Abhängigkeit). - L2: zusätzlich
test/financeStripeRead.test.js(erwarteter Schlüsselsatz vonmapInvoice). - L5: zusätzlich
server/mail-templates/K04 Eingang Widerruf.htmlundT09 Widerruf eingegangen.html(nur Blockprojekt). - L6: zusätzlich
public/auftrag-ansicht.js,public/auftrag-dom.js,public/auftrag-portal.js,public/auftrag-widerruf.js,test/auftraegeKundenportalDaten.js,test/ui/auftraege-kundenportal.ui.mjs, je eine Zeile intest/customerInvoices.test.jsundtest/publicForms.test.js. - L7: zusätzlich
docs/rechtliches/agb.md,docs/rechtliches/quellen/normen-auftraege-2026-10-06.txt, Stand-Zeile intest/legalTexts.test.js. - Alle: Testhilfen ohne
.test.js(z. B.test/helpers/auftraegeAb.js,test/auftraege…Hilfe.js) gehören dem anlegenden Strang. - WOMS: W0
src/lib/financeApi.js,src/lib/financeRequest.js(Blob),src/lib/teamApi.js,src/lib/auftraegeEndpoints.js,src/lib/auftraegeAnsicht.js,src/lib/customerTabs.js(+ Test), Container und vier Startdateien untersrc/components/auftraege/,CustomerDetailPage.jsx(nur Reiterzeile),CustomerInvoices.jsx(gelöscht); W1ProjektkontoListe.jsx,RechnungDialog.jsx,projektkonto.css,rechnung-dialog.css,src/lib/projektkontoView.js,src/lib/rechnungDialogView.js(+ weitere eigene Libs),InvoiceTable.jsx,WorksheetList.jsx,WorksheetTimesModal.jsx,InvoiceCreateModal.jsx(gelöscht); W2AuftragListe.jsx,AuftragDialog.jsx,auftrag.css,src/lib/auftragView.js,src/lib/auftragPdf.js,src/lib/kundenFelder.js,CustomerDetailPage.jsx(Feld Kundenart),SettingsCard.jsx.
Sperren: siehe V2 (zusammengesetzte Einzel-Schlüssel). Reihenfolge: wer beide braucht, nimmt erst sperreRechnung, darin sperreAuftrag; innerhalb von sperreAuftrag nie sperreRechnung. Mails (K32/T22) erst nach Freigabe der Sperre.
L0: ablage.entfernePdf(pdfDatei) (räumt eine abgelegte PDF weg, wenn danach etwas scheitert; L3 nutzt genau diesen Namen); Export AUFTRAEGE_DATETIMES; schemaKeys auch für financeSettings; oeffentlicheAnsicht liefert texte.kleinunternehmer; Steuernummer mit Buchstaben (V4).
L1: Zeitwert = Summe der je Tag gerundeten Werte (wie aufwandPositionen); projektTyp = Anzeigename der Projektart; offenCents bei Stripe-Ausfall null; „Ohne Projekt“ = finalisierte Rechnungen mit source: 'projekt' ohne aktive Projektrechnung plus verwaiste Projektrechnungen; archivierte Projekte nur mit Geldbezug; bei ausgeschaltetem Schalter werden ABs nicht gelesen.
L2: baueRechnung bekommt zusätzlich createdBy; zeilentexte[schluessel] ersetzt den ganzen Text vor dem letzten „ – “ (einschließlich Datum bzw. „KW …“); Selbstheilung: stößt eine Prüfung („bereits abgerechnet“, offener Entwurf zur AB) auf eine Projektrechnung, deren Stripe-Rechnung storniert oder gelöscht ist, wird sie aktiv: false und die Prüfung wiederholt; nur finalisierte Rechnungen zählen als „bereits berechnet“, ein Schluss-Entwurf blockiert weitere Anzahlungen; Schlussrechnung bis 40 Zeilen; Aufwand ohne auftragId nimmt die jüngste gültige Aufwands-AB nur bei eingeschaltetem Schalter (sonst 100 €); Buchungsfehler nach einem Stripe-Erfolg werden protokolliert, nicht geworfen; Schreibweisen „KW 05/2026“ (Schlüssel KW-2026-05), stundenText ohne Endnullen, Texte eines Tages mit „; “ verbunden.
L3: Senden: Sperren SPERRE_NUMMER + sperreAuftrag(id) → innerhalb der Sperre Eingabe und Kunde prüfen → Nummer → PDF (AuftragError aus erzeugeAuftragPdf, z. B. 409 „Entwurf unvollständig …“, wird durchgereicht; andere Fehler 500) → speicherePdf → Token → speichern (scheitert danach etwas: entfernePdf) → Sperren frei → Mail mit dem gespeicherten Dokument. „Erneut senden“ läuft über /erinnern: nach einem Mail-Fehler als normale K31, sonst als Erinnerung; gesendetAn = aktuelle Kunden-E-Mail. Neu: ordneProjektZu(id, projektId) + Route POST …/auftraege/:id/projekt (V5); hängt unter derselben sperreAuftrag(id) zuerst die Projektrechnungen der AB mit projektId === '' um (Stripe unverändert), zuletzt die AB. Stripe-Metadaten wandern beim Ersetzen nicht mit (nur projektRechnungen.auftragId).
L4: statische Figtree-TTFs aus @expo-google-fonts/figtree (OFL) unter server/assets/fonts/; reicht privat an AB_TEXTE.termine durch; leere Steuernummer → Zeile entfällt.
L5: K31-Link mit #t=; K31 ohne Team-BCC, Teamkopie ohne Link (Block teamkopie); T22-Betreff mit Präfix „[Aufträge]“; K32 mit fruehbeginn_text; K04/T09 Block projekt; fruehbeginnFassung bei Privatpersonen immer gespeichert, bei Unternehmen leer; Widerruf unabhängig vom Schalter, AB-Nummer optional.
L6: öffentliche Seite liest #t= (Rückfall ?t=), sendet credentials: 'omit'; Abschnitt „Aufträge“ im Portal in Du-Form; Kleinunternehmer-Satz aus texte.kleinunternehmer; AB-Nummer auf der Widerrufsseite optional (Hinweis „falls zur Hand“); rechtlich gefärbte Sätze der Seiten stehen im Freigabe-Dokument von L7.
L7: Norm der Frühbeginn-Erklärung § 356 Abs. 5 Nr. 2 und § 357a Abs. 2 BGB; § 649 BGB (Kostenanschlag) ist die richtige Norm für den Überschreitungssatz; Datenschutz-Abschnitt als letzter Commit des Strangs (wird erst beim AB-Livegang gemergt); alte AGB-PDF-Fassungen bleiben liegen; Karten in WOMS → Rechtliches laut Freigabe-Dokument (Karte ohne Datei entfällt).
WOMS: Bildschirmfotos nur mit eigenen Skripten im Scratchpad (das Prüfwerkzeug des Redesigns unter /root/reports/ticketsystem-design-2026-10-04/werkzeug* wird nicht geändert); der WOMS-Push erfolgt erst, wenn das Redesign auf test liegt, und vorher wird feature/auftraege auf den dann aktuellen origin/test gebracht.
Z: Node-20-Läufe mit node:20-alpine; Merge mit --no-ff (kein Rebase der Strang-Branches); Gegenprüfung (haiku) nur für Critical/Important, Minors gehen ungeprüft als Liste an den Nutzer; die Prüfbilder des Endstands werden nach /root/reports/auftraege-rechnungen-2026-10-06/bilder/ kopiert (der Scratchpad überlebt keinen Neustart); ein Löschlauf für alte ABs nach Ablauf der Aufbewahrung ist nicht im Umfang (Minor-Liste).
Livegang: Das Setup-Skript läuft vor dem Push (Wegwerf-Container mit dem Worktree, siehe Plan 12) oder als Ausweichweg sofort danach per docker exec; es legt projektRechnungen in jedem Fall an. Der WOMS-Push setzt das Attribut customers.kundenart voraus (Setup vorher).
Global Constraints
- Sprache: alle Texte Deutsch; Mails, PDF und die öffentliche Bestätigungsseite in der Sie-Form; im angemeldeten Kundenportal (Dashboard, Abschnitt „Aufträge“) die Du-Form wie im übrigen Portal; WOMS wie bestehend.
- Schreibweise im Code: nie ein Unicode-Escape mit genau vier Hexziffern (Backslash,
u, vier Ziffern) schreiben – das Schreibwerkzeug macht daraus das Zeichen selbst (z. B. ein unsichtbares geschütztes Leerzeichen); stattdessen\u{…}oderString.fromCodePoint(…). - Kleinunternehmer: keine Umsatzsteuer; jede AB trägt „Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.“
- Beträge immer ganze Cent (
Number.isSafeInteger); Anzeige übereuroDe(cents)ausserver/services/finance/format.js(bzw. WOMSeuro). - Datum intern als Berliner Kalendertag
YYYY-MM-DD(berlinDateausserver/services/finance/dates.js), AnzeigeTT.MM.JJJJ(formatDe); Zeitpunkte als ISO-UTC (toISOString()), Appwrite liefert+00:00→ immer mitnew Date()lesen. - Stundensatz Standard 100 € = 10000 Cent; Minuten je Arbeitsblatt =
worksheetMinutes(ws)(inkl. Unterstützung), je Arbeitsblatt auf 15 Minuten aufgerundet. - Abo-Zeit (
worksheets.countsToPlan === true) und Kommentare (isComment === true) werden nie projektbezogen berechnet. - Grenzen (Rechnung und AB): 1–20 Positionen bzw. Zeilen (Minuszeilen zählen mit; Ausnahme: eine Schlussrechnung darf bis 40 Zeilen haben – AB-Positionen ≤ 20, Zusatzpositionen ≤ 10, Abzüge ≤ 10), Beschreibung 1–200 Zeichen, Menge ganzzahlig 1–999, Einzelpreis 1–10.000.000 Cent, Summe ≤ 99.999.999 Cent, Zahlungsziel ganzzahlig 0–60 Tage (Standard 14), Leistungszeitraum ≤ 140 Zeichen.
- AB-Nummer
AB-JJJJ-NNNN(4 Ziffern, je Kalenderjahr ab 0001, Berliner Jahr des Sendens). - Bestätigungs-Token: 32 Zufallsbytes base64url (43 Zeichen), gespeichert nur als SHA-256 (hex), gültig 60 Tage ab dem letzten Versand.
- Keine IP-Adressen speichern (Ratenbegrenzung nur im Arbeitsspeicher).
- Schalter
AUFTRAEGE_ENABLED=off(Vorgabe) |pilot(nur Kunden, deren E-Mail inAUFTRAEGE_PILOT_EMAILSsteht;*= alle) |on. Er betrifft nur AB-Funktionen und die Rechnungsartenanzahlung/schluss; Projektkonto und die Artenfrei,festbetrag,aufwandlaufen immer. - Kein Chrome zur Laufzeit; PDFs der AB über pdfkit.
- Mail-Kennungen: K31 Auftragsbestätigung, K32 Auftrag bestätigt (Kunde), T22 Auftrag bestätigt (Team).
- Tests:
node --test; nur Nachbauten austest/support/(startAppwriteMock,startFakeStripe,useTestEnv,mailSink,startTestApp), nie echtes Appwrite, Stripe oder SMTP. Neue Appwrite-Felder nur überserver/services/auftraege/schema.js(der Appwrite-Nachbau prüft gegen das Schema). - Ressourcen (Server mit wenig Arbeitsspeicher, Memory „server-speicher“): Testläufe nur gezielt und begrenzt:
cd <worktree> && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/<eigene Dateien>. Die volle Suite (npm test) läuft nur in Z1, und schwere Läufe (volle Suite, Chrome-PDF-Druck,npm ci,vite build) laufen hinterflock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock. Vorherfree -m: unter 1500 MB „available“ warten. Niepkill -f(trifft die eigene Shell), Prozesse nur per PID beenden. - Git: jeder Strang nur in seinem Worktree und Branch; Commits klein, je Task mindestens einer, Nachricht deutsch, Schlusszeile
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>; kein Push (der Nutzer pusht), nie--force, niegit add -A. - Sicherheit der Umgebung (Memory „feedback-destruktive-skripte“): kein
rm -rf/rsync --deleteaußerhalb des eigenen Worktrees bzw. Scratchpads; keine Live-Daten lesen oder schreiben (kein Prod-Appwrite, kein Live-Stripe, keine echten Mails);build/nie anfassen. - Bestehendes Verhalten bleibt: alle vorhandenen Tests bleiben grün; die bisherigen Rechnungsfelder (
mapInvoice) bleiben, neue kommen dazu.
Review Focus
- Gleichzeitige Aufwandsrechnungen mit denselben Arbeitsblättern (Doppelklick, zwei Admins): die zweite muss mit 409 „Arbeitsblatt bereits abgerechnet“ scheitern. Test in L2 (zwei parallele
createProjectInvoicemit gleichenarbeitsblattIdsunter SperresperreRechnung(kundenId)=['auftraege-rechnung:<kundenId>']). - Alte Token nach Erinnerung, neuer Fassung, Storno oder Widerruf: der alte Link darf nie eine AB bestätigen; die Seite erklärt den Grund. Tests in L5 (Erinnerung → alter Token 404; ersetzt/storniert/widerrufen → 410 mit Meldung).
- Schlussrechnung neben stornierten oder noch nicht gesendeten Rechnungen: Stornos zählen nicht, ein offener Entwurf zur AB blockiert mit 409. Tests in L2.
- Unvollständige Kundendaten beim AB-Senden (keine Kundenart, keine Anschrift, keine E-Mail, Interessent statt Kunde): klare Meldung vor Nummernvergabe und PDF, keine Nummer verbraucht. Tests in L3.
- Umlaute, €, Minuszeichen und lange Texte im PDF (ä ö ü ß €, „−“, 200 Zeichen ohne Leerzeichen, 20 Positionen über eine Seite): lesbar, kein Glyphenausfall, Seitenumbruch mit Kopf der Tabelle. Tests in L4 (Text aus dem PDF lesen).
Verträge
V1 Collections und Attribute (server/services/auftraege/schema.js, L0)
Bauart wie server/services/zeiten/schema.js (reine Daten, kein Import von config.js; Helfer str(key,size), int(key), bool(key), dt(key), index(key, ...attrs); alle Attribute required: false).
export const AUFTRAEGE_COLLECTIONS = {
auftraege: {
attributes: [
str('nummer', 20), int('jahr'), int('laufnummer'), str('status', 20),
str('kundenId', 64), str('kundeJson', 2000), str('projektId', 64), str('titel', 200),
str('zeitquelleJson', 1000), str('art', 20), str('positionenJson', 20000),
int('stundensatzCents'), int('schaetzungMinuten'), int('summeCents'),
str('zahlungsplanJson', 1000), int('zahlungszielTage'),
str('auftragVom', 10), str('auftragWeg', 20), str('startAm', 10), str('fertigBis', 10),
str('texteFassung', 20), str('agbFassung', 20), str('pdfDatei', 300), str('pdfSha256', 64),
dt('gesendetAm'), str('gesendetAn', 255), str('mailStatus', 20), str('mailFehler', 500),
str('tokenHash', 64), dt('tokenGueltigBis'),
dt('bestaetigtAm'), str('bestaetigtUeber', 10), str('bestaetigtPdfSha256', 64),
bool('fruehbeginn'), str('fruehbeginnFassung', 20),
str('ersetztId', 64), str('ersetztDurchId', 64), dt('storniertAm'), dt('widerrufenAm'),
str('erstelltVon', 255), dt('erstelltAm'), dt('geaendertAm'),
],
indexes: [index('idx_kunde', 'kundenId'), index('idx_projekt', 'projektId'), index('idx_jahr', 'jahr'),
index('idx_token', 'tokenHash'), index('idx_nummer', 'nummer')], // idx_nummer NICHT unique: Entwürfe haben nummer ''
},
// Dokument-ID = Stripe-Rechnungs-ID (muss /^in_\w{1,33}$/ erfüllen; der Stripe-Nachbau vergibt in_test_<n>)
projektRechnungen: {
attributes: [
str('invoiceId', 64), str('kundenId', 64), str('projektId', 64), str('auftragId', 64), str('art', 20),
int('betragCents'), str('arbeitsblattIdsJson', 20000), str('rechnungsnummer', 64), bool('aktiv'),
str('erstelltVon', 255), dt('erstelltAm'),
],
indexes: [index('idx_kunde', 'kundenId'), index('idx_projekt', 'projektId'), index('idx_auftrag', 'auftragId')],
},
}
export const AUFTRAEGE_ZUSATZ_ATTRIBUTE = {
customers: [str('kundenart', 20)], // '' | 'unternehmen' | 'privat'
financeSettings: [str('steuernummer', 40)],
}
config.collections.auftraege (APPWRITE_COLLECTION_AUFTRAEGE, Vorgabe 'auftraege'), config.collections.projektRechnungen (APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN, Vorgabe 'projektRechnungen').
config.auftraege = { mode, pilotEmails, uploadDir } mit mode aus AUFTRAEGE_ENABLED (wie config.zeiten.mode), pilotEmails = commaList(process.env.AUFTRAEGE_PILOT_EMAILS, []), uploadDir = process.env.AUFTRAEGE_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'auftraege').
V2 Konstanten (server/services/auftraege/konstanten.js, L0)
export const RECHNUNGS_ARTEN = ['frei', 'festbetrag', 'aufwand', 'anzahlung', 'schluss']
export const AB_RECHNUNGS_ARTEN = ['anzahlung', 'schluss'] // brauchen AB und Schalter
export const AUFTRAG_ARTEN = ['festpreis', 'aufwand']
export const AUFTRAG_STATUS = ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen']
export const VEREINBARTE_STATUS = ['versendet', 'bestaetigt', 'abgerechnet'] // zählen als „vereinbart“
export const AUFTRAG_WEGE = { muendlich: 'mündlich', telefon: 'telefonisch', email: 'per E-Mail', vor_ort: 'vor Ort', chat: 'im Chat' }
export const ZAHLUNGSPLAN_ARTEN = ['nach_abnahme', 'anzahlung', 'monatlich', 'frei']
export const KUNDENARTEN = ['unternehmen', 'privat']
export const STUNDENSATZ_STANDARD_CENTS = 10000
export const VIERTELSTUNDE = 15
export const TOKEN_GUELTIG_TAGE = 60
export const AGB_FASSUNG = '2026-09-30' // L7 darf diese eine Zeile anpassen
export const LIMITS = {
maxPositionen: 20, maxBeschreibung: 200, maxMenge: 999, maxEinzelpreisCents: 10_000_000,
maxSummeCents: 99_999_999, maxTitel: 200, maxZahlungsplanText: 500, maxZahlungszielTage: 60,
standardZahlungszielTage: 14, standardAnzahlungProzent: 50, maxZeitquellen: 10,
maxSchaetzungMinuten: 99_960, maxLeistungszeitraum: 140,
}
// withLocks sperrt jedes Listenelement einzeln: deshalb je Sperre GENAU EIN zusammengesetzter Schlüssel
export const SPERRE_NUMMER = ['auftraege-nummer']
export const sperreRechnung = (kundenId) => [`auftraege-rechnung:${kundenId}`] // Aufwand, Anzahlung, Schluss je Kunde
export const sperreAuftrag = (id) => [`auftraege-ab:${id}`] // jede Statusänderung einer AB (L2, L3, L5)
export const SCHALTER_AUS_MELDUNG = 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.'
Fehlerklasse (server/services/auftraege/fehler.js, L0): export class AuftragError extends Error { constructor(status, message) } (Eigenschaft status) und export function sendeFehler(res, was, err): AuftragError und InvoiceError (aus stripeFinance.js) → res.status(err.status).json({ error: err.message }); sonst Protokoll ohne Meldung (wie logFailure in finanzen.js) und 500 { error: 'Interner Fehler.' }.
Statusübergänge der AB (nur diese):
entwurf → versendet (L3 senden) · versendet → bestaetigt (L5) · versendet|bestaetigt → abgerechnet (L2, Schlussrechnung finalisiert) · abgerechnet → bestaetigt|versendet (L2, Storno der Schlussrechnung; Ziel bestaetigt, wenn bestaetigtAm gesetzt) · versendet|bestaetigt → storniert (L3) · versendet|bestaetigt|abgerechnet → ersetzt (L3, beim Senden der neuen Fassung) · versendet|bestaetigt → widerrufen (L5, nur Kundenart privat). Bestätigen ist auch bei abgerechnet ohne bestaetigtAm möglich (Status bleibt abgerechnet).
V3 Datenformen
// Auftrag (intern; auftragStore.docToAuftrag)
{
id, nummer /* '' im Entwurf */, jahr /* number|null */, laufnummer /* number|null */, status,
kundenId, kunde /* KundeSnapshot|null, beim Senden eingefroren */, projektId /* '' ohne Projekt */, titel,
zeitquelle /* string[] Ticket-IDs (workorders.$id) */, art /* 'festpreis'|'aufwand' */,
positionen /* AbPosition[]; aufwand: [] */, stundensatzCents /* aufwand, sonst null */,
schaetzungMinuten /* aufwand optional, sonst null */, summeCents /* festpreis, sonst null */,
zahlungsplan /* { art, prozent: number|null, text: string } */, zahlungszielTage,
auftragVom /* 'YYYY-MM-DD' */, auftragWeg /* Schlüssel aus AUFTRAG_WEGE */, startAm, fertigBis /* 'YYYY-MM-DD'|'' */,
ersetztId, ersetztDurchId, texteFassung, agbFassung, pdfDatei, pdfSha256,
gesendetAm /* ISO|null */, gesendetAn, mailStatus /* ''|'gesendet'|'fehler' */, mailFehler,
tokenHash, tokenGueltigBis /* ISO|null */,
bestaetigtAm /* ISO|null */, bestaetigtUeber /* ''|'link'|'portal' */, bestaetigtPdfSha256,
fruehbeginn /* boolean */, fruehbeginnFassung, storniertAm, widerrufenAm /* ISO|null */,
erstelltVon, erstelltAm, geaendertAm,
}
// KundeSnapshot (auftragSicht.kundeSnapshot(customerDoc))
{ name, firma, strasse, plz, ort, land, email, kundenart /* ''|'unternehmen'|'privat' */, kundennummer /* customers.code || '' */ }
// AbPosition
{ beschreibung, menge, einzelpreisCents }
// ProjektRechnung (projektRechnungenStore)
{ invoiceId, kundenId, projektId, auftragId, art /* 'festbetrag'|'aufwand'|'anzahlung'|'schluss' */, betragCents,
arbeitsblattIds /* string[] */, rechnungsnummer, aktiv /* boolean */, erstelltVon, erstelltAm }
// Arbeitsblatt (arbeitsblaetter.js; normiert)
{ id, ticketId /* worksheets.workorderId */, woid, datum /* 'YYYY-MM-DD' */, minuten /* auf 15 aufgerundet */,
text /* kundentauglich, ≤ 200 */, aboZeit /* countsToPlan === true */, kommentar /* isComment === true */ }
// Position (Rechnungszeile; positionen.js)
{ schluessel /* z. B. '2026-10-06', 'KW-2026-41', 'ab-1', 'zusatz-1', 'abzug-in_123' */, beschreibung,
menge, einzelpreisCents, betragCents /* menge × einzelpreis; Minuszeile: menge 1, beide negativ */ }
// Projektkonto (projektkonto.js)
{ projektId, name, projektTyp, tickets /* [{ id, woid }] */, zeitquelle /* string[] */,
vereinbart: { festpreisCents /* number|null */, aufwand /* { stundensatzCents, schaetzungMinuten }|null */ },
berechnetCents, bezahltCents, offenCents /* number|null */,
zeit: { minuten, aboMinuten, offeneMinuten, satzCents, wertCents, offenerWertCents } }
// AuftragTeam (auftragSicht.auftragFuerTeam): Auftrag ohne tokenHash, pdfDatei, bestaetigtPdfSha256, plus
{ pdfVorhanden /* boolean */, fruehesterBeginn /* 'YYYY-MM-DD'|null */, zahlungsplanText, aufwandText /* string|null */ }
// AuftragOeffentlich (auftragSicht.oeffentlicheAnsicht) – nur für Token- und Kundenrouten
{ nummer, datum /* Berliner Tag von gesendetAm */, titel, art,
positionen /* [{ beschreibung, menge, einzelpreisCents, summeCents }] */, summeCents, stundensatzCents,
schaetzungMinuten, aufwandText, zahlungsplanText, startAm, fertigBis, kundenart, status, bestaetigtAm,
fruehbeginn, fruehesterBeginn, agbUrl /* '/rechtliches/agb-<auftrag.agbFassung || AGB_FASSUNG>.pdf' */,
texte: { bestaetigung /* BESTAETIGUNG_TEXT */, fruehbeginn /* FRUEHBEGINN_TEXT oder null bei unternehmen */,
kleinunternehmer /* AB_TEXTE.kleinunternehmer */ } }
// Kundenrouten ergänzen id und kannBestaetigen = (status === 'versendet' || (status === 'abgerechnet' && !bestaetigtAm)) && nicht Kundenansicht
Regeln, die mehrere Stränge brauchen (alle in L0, auftragSicht.js):
fruehesterBeginn(auftrag): nur beikunde.kundenart === 'privat',!fruehbeginnund gesetztemgesendetAm: Tag nachwithdrawalDeadline(new Date(gesendetAm)).lastDay(ausserver/services/deadlines.js); sonstnull.zahlungsplanText(auftrag)undaufwandText(auftrag): aus den Vorlagen intexte.js(V7), Beträge miteuroDe, beianzahlungAnzahlung =Math.round(summeCents * prozent / 100), Rest =summeCents − Anzahlung.
V4 Module und Funktionen
L0 – Fundament
schalter.js:auftraegeAktiv(kunde) → boolean(kunde: Kunden-Dokument oder{ email };on→ true,pilot→ E-Mail klein geschrieben inconfig.auftraege.pilotEmailsoder Liste enthält*, sonst false).auftragStore.js:docToAuftrag(doc),auftragToDoc(auftrag)(nur Felder aus V1, JSON-Felder serialisiert),getAuftrag(id) → Auftrag|null,listAuftraege({ kundenId, projektId, jahr } = {}) → Auftrag[](neueste zuerst),createAuftrag(daten) → Auftrag(setzterstelltAm,geaendertAm, Statusentwurf),updateAuftrag(id, patch) → Auftrag(setztgeaendertAm),deleteAuftrag(id),findAuftragByTokenHash(hash) → Auftrag|null,findAuftragByNummer(nummer) → Auftrag|null.projektRechnungenStore.js:getProjektRechnung(invoiceId) → ProjektRechnung|null,listProjektRechnungen({ kundenId, projektId, auftragId, nurAktiv = true } = {}),createProjektRechnung(invoiceId, daten) → ProjektRechnung,updateProjektRechnung(invoiceId, patch) → ProjektRechnung,umhaengen(auftragIdAlt, auftragIdNeu) → number(Anzahl umgehängter Dokumente).auftragSicht.js:kundeSnapshot(customerDoc),fruehesterBeginn(auftrag),zahlungsplanText(auftrag),aufwandText(auftrag),auftragFuerTeam(auftrag),oeffentlicheAnsicht(auftrag).arbeitsblaetter.js:ladeArbeitsblaetter(ticketIds) → Arbeitsblatt[](sortiert nachdatum, dannid; Datum wieparseSheetDateinhourLedger.js:TT.MM.JJJJ, sonst ISO, sonst$createdAt, als Berliner Tag; Text gefiltert wiecustomerWorkEntriesincustomerProjectView.js),ladeArbeitsblaetterNachIds(ids) → Arbeitsblatt[],abrechenbar(blatt) → boolean(!aboZeit && !kommentar && minuten > 0),abgerechneteArbeitsblaetter(kundenId) → Map<worksheetId, { invoiceId, rechnungsnummer }>(aus aktivenprojektRechnungen),offeneArbeitsblaetter(ticketIds, kundenId) → Arbeitsblatt[](abrechenbar und nicht abgerechnet),zeitquelleFuerProjekt(projekt, auftraege) → string[](Vereinigung derzeitquellealler ABs mit Status ausVEREINBARTE_STATUS, sonst[projekt.ticketId]ohne Leereinträge).ablage.js:speicherePdf(kundenId, auftragId, buffer) → { pdfDatei: '<kundenId>/<auftragId>.pdf', pdfSha256 }(unterconfig.auftraege.uploadDir, Verzeichnis 0700, Datei 0600, überschreibt nie: vorhandene Datei → Fehler),lesePdf(pdfDatei) → Buffer(nur^[A-Za-z0-9_-]{1,64}/[A-Za-z0-9_-]{1,64}\.pdf$, sonstAuftragError(404, 'PDF nicht gefunden.')).settings.js(Finanzen): Schlüsselsteuernummer(Vorgabe'', getrimmt, höchstens 40 Zeichen, erlaubt Buchstaben, Ziffern, Leerzeichen,/,-und.(auch eine USt-IdNr. „DE…“);''löscht; sonstSettingsError(400, 'Steuernummer ungültig.')).
L1 – Projektkonto: projektkonto.js: projektkontenFuerKunde(kundenId) → { auftraegeAktiv, kunde: { id, name, kundenart }, konten: Projektkonto[], ohneProjekt: { berechnetCents, bezahltCents, anzahl }, stripeOk }; projektkontoDetail(projektId) → { konto: Projektkonto, auftraege: AuftragTeam[], rechnungen: RechnungKurz[], offeneArbeitsblaetter: Arbeitsblatt[], stripeOk } mit RechnungKurz = { id, number, status, createdDate, totalCents, paidCents, art, auftragId }. Rechenregeln: Spec Abschnitt 4; berechnetCents/bezahltCents nur aus finalisierten Rechnungen (Status offen, ueberfaellig, bezahlt, uneinbringlich laut listInvoices()) aktiver projektRechnungen; offenCents = festpreisCents === null ? null : max(0, festpreisCents − Σ total der finalisierten Rechnungen mit auftragId einer Festpreis-AB). Scheitert Stripe: Beträge 0, stripeOk: false.
L2 – Rechnungen: positionen.js (rein): festePositionen(items) → Position[] (Eingabe { description, quantity, unitAmountCents } wie heute), aufwandPositionen(blaetter, { satzCents, zeilentexte = {}, maxZeilen = 20 }) → { positionen, minuten, von, bis } (eine Zeile je Tag, Text „06.10.2026: <Texte des Tages, mit '; ' verbunden, gekürzt> – 2,25 Std. à 100,00 €“, ohne Text „Arbeitszeit“; zu viele Zeilen → älteste Tage zu ISO-Kalenderwochen „KW 41/2026: …“, Schlüssel KW-2026-41; zeilentexte[schluessel] ersetzt den Text vor dem Gedankenstrich), anzahlungPosition(auftrag, { prozent, betragCents, bezeichnung = 'Anzahlung' }, bereitsBerechnetCents) → Position, schlussPositionen(auftrag, fruehere, zusatz) → Position[] (fruehere: [{ invoiceId, rechnungsnummer, datum, betragCents, art }]; Minuszeile „abzüglich Anzahlung, Rechnung vom <TT.MM.JJJJ>“ bzw. „abzüglich Teilrechnung …“), stundenText(minuten) → '2,25', leistungszeitraumText(von, bis) → '06.10.2026' | '01.10.–20.10.2026' | '28.12.2026–05.01.2027'.
rechnungsbau.js: baueRechnung(eingabe) → RechnungsEntwurf mit eingabe = { customerId, art = 'frei', projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum } und RechnungsEntwurf = { kunde, art, projekt /* { id, name, ticketId }|null */, auftrag /* Auftrag|null */, positionen, summeCents, leistungszeitraum, customFields /* [{ name, value }] ≤ 4 */, metadata, projektRechnung /* Daten für createProjektRechnung ohne invoiceId|null */ }. Zusatzfelder: Projekt (Name, auf 140 gekürzt; nicht bei frei), Auftrag („AB-2026-0002 vom 06.10.2026“, nur mit AB), Leistungszeitraum (wenn nicht leer). Metadaten zusätzlich zu den heutigen: projektId, projektName, auftragId, auftragNummer, art.
stripeFinance.js: createProjectInvoice({ customerId, art = 'frei', projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, daysUntilDue, memo, senden, idempotencyKey, createdBy }) (Positionen über baueRechnung; positive Zeile → quantity + unit_amount_decimal, Minuszeile → amount; custom_fields; rendering: { pdf: { page_size: 'a4' } } für alle Rechnungen; createProjektRechnung nach dem Entwurf bei art !== 'frei'; Aufwand, Anzahlung und Schluss unter withLocks(sperreRechnung(customerId))), neue Funktion rechnungsVorschau(eingabe) → { positionen, summeCents, leistungszeitraum, customFields } (ohne Stripe-Schreibzugriff), mapInvoice liefert zusätzlich projektId, projektName, art (metadata.art, sonst 'abo' bei Abo-Rechnungen bzw. 'frei'), auftragId, auftragNummer; Finalisieren (senden oder sendInvoiceNow) setzt projektRechnungen.rechnungsnummer und bei schluss die AB auf abgerechnet; voidInvoice/deleteDraft setzen aktiv: false und nehmen abgerechnet zurück.
L3 – AB-Kern: nummern.js: formatNummer(jahr, laufnummer) → 'AB-2026-0001', vergibNummer(jetzt = new Date()) → { nummer, jahr, laufnummer } (größte laufnummer des Berliner Jahres + 1; Aufrufer hält SPERRE_NUMMER). auftraege.js: pruefeEingabe(eingabe, { teilweise = false }) → bereinigte Felder (AuftragError 400 mit Feldmeldung), legeEntwurfAn(eingabe, { von }), aendereEntwurf(id, eingabe), loescheEntwurf(id), vorschauPdf(id, { deps }) → Buffer, sendeAuftrag(id, { von, deps }) → { auftrag, mailFehler: boolean }, erinnereAuftrag(id, { deps }) → { auftrag, mailFehler }, neueFassung(id, { von }) → Auftrag (neuer Entwurf mit ersetztId), storniereAuftrag(id) → Auftrag. deps = { erzeugeAuftragPdf, neuerToken, sendeAuftragMail }; Vorgabe per dynamischem Import von ./pdf.js (L4), ./token.js (L5), ./mails.js (L5) beim Aufruf, damit L3 ohne L4/L5 testbar ist (Tests übergeben Attrappen). Reihenfolge beim Senden: Eingabe, Kunde (fester Kunde, E-Mail, Anschrift, Kundenart) prüfen → Sperre SPERRE_NUMMER → Nummer → PDF erzeugen → speicherePdf → Token → Dokument speichern (Status versendet) → Sperre frei → Mail → mailStatus. Hat die AB ersetztId: alte AB ersetzt (+ ersetztDurchId), umhaengen(alt, neu).
L4 – AB-PDF: pdf.js: auftragPdfDaten(auftrag, { settings, vorschau = false, jetzt, ersetzt }) → AuftragPdfDaten, erzeugeAuftragPdf(auftrag, { settings, vorschau = false, jetzt, ersetzt }) → Promise<Buffer> (ersetzt = { nummer, datum } optional, sonst über getAuftrag(auftrag.ersetztId); AB_TEXTE.termine bekommt privat). auftrag.kunde ist immer gesetzt (L3 übergibt bei der Vorschau kundeSnapshot(liveKunde) und nummer: 'Entwurf'). settings = { steuernummer, bankDetailsText }. Inhalt laut Spec Abschnitt 2 „Inhalt des PDFs“, Texte aus texte.js. Schrift: statische Figtree-Schnitte (Regular, SemiBold) als Dateien unter server/assets/fonts/ (OFL, Lizenzdatei dazu); Logo aus public/ (vorhandene Datei, die die Mailvorlagen nutzen).
L5 – Bestätigung und Mails: token.js: neuerToken(jetzt = new Date()) → { token, hash, gueltigBis /* Date */ }, hashToken(token) → string|null (null bei falschem Format). bestaetigung.js: bestaetigeAuftrag(auftrag, { ueber, fruehbeginn, jetzt = new Date() }) → Auftrag (Regeln V2; idempotent; fruehbeginn nur bei privat; schickt danach sendeBestaetigtMails, Fehler nur ins Protokoll). mails.js: sendeAuftragMail(auftrag, { pdf, token, erinnerung = false }) → Promise<void> (wirft bei Fehler), sendeBestaetigtMails(auftrag) → Promise<void>. Widerruf in server/routes/abo.js: order = 'projekt' mit Feld abNummer.
L6 – Kundenportal: nur Oberfläche und customerInvoices.js (Feld projekt: raw.metadata?.projektName || '').
L7 – Rechtstexte: Wortlaute in texte.js, neue Rechtstext-Dateien, Datenschutz, Druckskripte (Spec Abschnitt 8) und das Freigabe-Dokument /root/reports/auftraege-rechnungen-2026-10-06/rechtsrunde.md.
V5 HTTP-Endpunkte
Antwortformat bei Fehlern immer { error: '<Meldung>' }. Admin-Routen: Router mit requireAppwriteStaff (wie finanzen.js); Team-Routen: unter /api/team (dort gilt requireStaffJwt); Kundenrouten: requireSession und eigener Kunde; Token-Routen: ohne Anmeldung, Ratenbegrenzung im Arbeitsspeicher je IP (GET 120/h, POST 10/h → 429 { error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }).
| Strang | Methode, Pfad | Eingabe | Antwort (200) | Fehler |
|---|---|---|---|---|
| L1 | GET /api/admin/finanzen/projektkonten?kunde=<id> |
– | { auftraegeAktiv, kunde, konten, ohneProjekt, stripeOk } |
400 ohne Kunde, 404 Kunde unbekannt |
| L1 | GET /api/admin/finanzen/projektkonten/:projektId |
– | { konto, auftraege, rechnungen, offeneArbeitsblaetter, stripeOk } |
404 |
| L1 | GET /api/team/arbeitsblaetter/abgerechnet?ticketId=<id> |
– | { abgerechnet: { <worksheetId>: '<Rechnungsnummer>' | 'Entwurf' } } (unbekanntes Ticket: { abgerechnet: {} }) |
400 ohne ticketId |
| L2 | POST /api/admin/finanzen/rechnungen |
{ customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung: { prozent, betragCents, bezeichnung }, zeilentexte, leistungszeitraum, daysUntilDue, memo, senden, idempotencyKey } |
{ rechnung } (wie heute, mapInvoice erweitert) |
400 Eingabe, 404, 409 (Arbeitsblatt bereits abgerechnet, offener Entwurf zur AB, Nichts mehr offen, Adresse), 502 |
| L2 | POST /api/admin/finanzen/rechnungen/vorschau |
wie oben ohne senden, idempotencyKey, daysUntilDue, memo |
{ positionen, summeCents, leistungszeitraum, customFields } |
wie oben |
| L2 | GET /api/admin/finanzen/rechnungen |
wie heute | Einträge zusätzlich mit projektId, projektName, art, auftragId, auftragNummer |
– |
| L3 | GET /api/admin/finanzen/auftraege?kunde=<id> |
– | { auftraege: AuftragTeam[] } |
400, 404 Schalter aus |
| L3 | POST /api/admin/finanzen/auftraege |
{ kundenId, projektId, titel, kundenart, art, positionen, stundensatzCents, schaetzungMinuten, zahlungsplan, zahlungszielTage, auftragVom, auftragWeg, startAm, fertigBis, zeitquelle } (kundenart setzt customers.kundenart, wenn dort leer) |
{ auftrag: AuftragTeam } |
400, 404 |
| L3 | GET / PATCH / DELETE /api/admin/finanzen/auftraege/:id |
PATCH wie POST (teilweise) | { auftrag } bzw. { ok: true } |
404, 409 kein Entwurf |
| L3 | GET /api/admin/finanzen/auftraege/:id/pdf |
– | application/pdf (gesendet: gespeicherte Datei; Entwurf: Vorschau mit „Entwurf“) |
404, 409 Entwurf unvollständig |
| L3 | POST /api/admin/finanzen/auftraege/:id/projekt |
{ projektId } |
{ auftrag } (setzt projektId und, wenn leer, zeitquelle = [Ticket des Projekts]; PDF unverändert) |
400, 404, 409 (hat schon ein Projekt; Status storniert/ersetzt/widerrufen) |
| L3 | POST /api/admin/finanzen/auftraege/:id/senden |
– | { auftrag, mailFehler } |
400 Kundenart/E-Mail/Interessent, 409 Adresse, kein Entwurf, Entwurf unvollständig; 500 PDF |
| L3 | POST …/auftraege/:id/erinnern |
– | { auftrag, mailFehler } |
409 nicht versendet |
| L3 | POST …/auftraege/:id/neue-fassung |
– | { auftrag } (neuer Entwurf) |
409 |
| L3 | POST …/auftraege/:id/stornieren |
– | { auftrag } |
409 |
| L5 | GET /api/auftrag/:token |
– | { auftrag: AuftragOeffentlich } |
404 'Dieser Link ist ungültig oder abgelaufen.'; 410 'Diese Auftragsbestätigung wurde ersetzt.' / '… wurde storniert.' / '… wurde widerrufen.'; 429 |
| L5 | GET /api/auftrag/:token/pdf |
– | application/pdf |
wie oben |
| L5 | POST /api/auftrag/:token/bestaetigen |
{ bestaetigt: true, fruehbeginn: boolean } |
{ auftrag: AuftragOeffentlich } |
400 „Bitte bestätigen Sie den Auftrag mit dem Häkchen.“, 404/410, 429 |
| L5 | GET /api/auftraege (Kunde) |
– | { aktiv, auftraege: (AuftragOeffentlich & { id, kannBestaetigen })[] } (keine Entwürfe) |
401 |
| L5 | GET /api/auftraege/:id/pdf (Kunde) |
– | application/pdf |
404 fremd/unbekannt |
| L5 | POST /api/auftraege/:id/bestaetigen (Kunde) |
{ bestaetigt: true, fruehbeginn } |
{ auftrag } |
400 (Text wie Token-Route), 404, 410 (Meldungen wie Token-Route); in der Kundenansicht sperrt der Wächter (kein Eintrag in KUNDENANSICHT_ALLOWED) |
| L5 | POST /api/abo/public/withdraw |
zusätzlich order: 'projekt', abNummer (optional; ohne/ungültig nur Mails mit Vermerk „ungeprüft“) |
wie heute | wie heute; gilt unabhängig vom Schalter |
| L0 | GET/PATCH /api/admin/finanzen/einstellungen |
zusätzlich steuernummer |
wie heute | 400 |
Ist der Schalter für den Kunden aus: Admin-AB-Routen 404 mit SCHALTER_AUS_MELDUNG; Rechnungsarten anzahlung/schluss 400 mit derselben Meldung; Token- und Kundenrouten antworten wie bei unbekannter AB (404) bzw. GET /api/auftraege mit { aktiv: false, auftraege: [] }.
Einbindung (L0): in server/index.js vor app.use('/api/admin/finanzen', adminFinanzenRoutes): app.use('/api/admin/finanzen/projektkonten', adminProjektkontenRoutes), app.use('/api/admin/finanzen/auftraege', adminAuftraegeRoutes); außerdem app.use('/api/auftrag', publicAuftragRoutes) und app.use('/api/auftraege', kundeAuftraegeRoutes); in server/routes/team/index.js nach requireStaffJwt: router.use('/arbeitsblaetter', arbeitsblaetterTeamRoutes).
V6 Mails (L5)
Vorlagen in server/mail-templates/ mit Kopfkommentar wie die vorhandenen (renderMail):
K31 Auftragsbestaetigung.html– Betreff „Ihre Auftragsbestätigung {{nummer}} – {{titel}}“; Platzhaltername, nummer, titel, datum, betrag, link, gueltig_bis; Blöckeerinnerung, privat, festpreis, aufwand, kundenmail, teamkopie(kundenmail= Gegenblock zuteamkopie, weil die Vorlagen nur positive Blöcke kennen; die Teamkopie geht als eine Mail ohne Anhänge übersendAdminNoticenach der Kundenmail). Anhänge: AB-PDF (<nummer>.pdf) und AGB-PDF (public/rechtliches/agb-<AGB_FASSUNG>.pdf, DateinameWEBklar-AGB-<AGB_FASSUNG>.pdf). Kein BCC an das Team (bccTeam: false), weil die Mail den Bestätigungslink trägt; das Team bekommt eine Kopie mit BlockteamkopieanCHAT_NOTIFY_EMAILS(statt Knopf: „Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.“), auch bei Erinnerungen. Anhang-Fassung:auftrag.agbFassung(RückfallAGB_FASSUNG); fehlt die Datei, wirft der Versand (→mailStatus: 'fehler').K32 Auftrag bestaetigt.html– Betreff „Ihre Bestätigung zu {{nummer}} ist eingegangen“; Platzhaltername, nummer, titel, zeitpunkt, fruehester_beginn, fruehbeginn_text; Blöckefruehbeginn(gibtFRUEHBEGINN_TEXTwörtlich wieder; kein Satz „Ihr Widerrufsrecht bleibt unberührt“),ohne_fruehbeginn.T22 Auftrag bestaetigt.html– Betreff „[Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}}“; Platzhalterkunde, nummer, titel, zeitpunkt, ueber, woms_link, fruehester_beginn; Blöckeprivat_fruehbeginn, privat_ohne_fruehbeginn.woms_link = <womsBaseUrl>/customers/<kundenId>?tab=invoices.- Link der K31:
<config.publicBaseUrl>/auftrag.html#t=<token>(Fragment: landet nicht in Server- und Proxy-Protokollen; die Seite liest#t=und als Rückfall?t=). K04 Eingang Widerruf.htmlundT09 Widerruf eingegangen.htmlbekommen je einen Blockprojekt(Vertragsart „Projektauftrag (Auftragsbestätigung )“; bei Unternehmen der Hinweis „kein gesetzliches Widerrufsrecht“ im K04-Block).
V7 Texte (server/services/auftraege/texte.js; L0 legt an, L7 formuliert)
Exportnamen und Formen (verbindlich):
export const TEXTE_FASSUNG = '2026-10-06'
export const ABSENDER = { name: 'WEBklar GbR', strasse: 'Am Schwimmbad 10', plzOrt: '67722 Winnweiler',
telefon: '0176 23726355', email: 'support@webklar.com', web: 'webklar.com', vertretenDurch: 'Kenso Grimm und Justin Klein' }
export const AB_TEXTE = {
titel: 'Auftragsbestätigung',
einleitung: ({ datum, weg }) => string, // „Vielen Dank für Ihren Auftrag vom 05.10.2026 (telefonisch). Wir bestätigen ihn wie folgt:“
aufwand: ({ satz, schaetzung /* '10' Stunden oder null */ }) => string,
kleinunternehmer: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.',
zahlungsplan: {
nach_abnahme: ({ ziel }) => string, anzahlung: ({ prozent, anzahlung, rest, ziel }) => string,
monatlich: ({ ziel }) => string, frei: ({ text, ziel }) => string,
},
termine: ({ start /* 'TT.MM.JJJJ'|null */, fertig /* 'TT.MM.JJJJ'|null */, privat /* boolean */ }) => string|null, // privat: „Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.“
mitwirkung: string, // Verweis auf AGB Nr. 7 und 8
agb: ({ fassung /* 'TT.MM.JJJJ' */ }) => string, // „Es gelten unsere Allgemeinen Geschäftsbedingungen in der Fassung vom …, die dieser Bestätigung beiliegen.“
ersetzt: ({ nummer, datum }) => string, // „Diese Auftragsbestätigung ersetzt AB-… vom ….“
gruss: string,
}
export const BESTAETIGUNG_TEXT = 'Ich bestätige den Auftrag zu diesen Bedingungen.'
export const FRUEHBEGINN_FASSUNG = '2026-10-06'
export const FRUEHBEGINN_TEXT = string
export const WIDERRUFSBELEHRUNG_PROJEKT = { titel: 'Widerrufsbelehrung', abschnitte: [{ ueberschrift: string, absaetze: string[] }] }
export const WIDERRUFSFORMULAR = { titel: 'Muster-Widerrufsformular', einleitung: string, zeilen: string[] }
V8 WOMS-Schnittstellen (W0/W1/W2)
src/lib/financeApi.js(W0 ergänzt):getProjektkonten(kundeId),getProjektkonto(projektId),previewInvoice(body),createInvoice(body)(besteht, Körper erweitert),listAuftraege(kundeId),getAuftrag(id),createAuftrag(body),patchAuftrag(id, body),deleteAuftrag(id),sendAuftrag(id),erinnereAuftrag(id),neueFassungAuftrag(id),storniereAuftrag(id),getAuftragPdf(id) → Blob.src/lib/teamApi.js(W0 ergänzt):getAbgerechnet(ticketId) → { abgerechnet }.src/components/auftraege/AuftraegeUndRechnungen.jsx(W0): Inhalt des Kundenreitersinvoices(Beschriftung „Aufträge und Rechnungen“), Props{ customer, onShowOverview }; lädtgetProjektkontenund (wennauftraegeAktiv)listAuftraege; zeigtProjektkontoListe,AuftragListe(nur mit Schalter), Rechnungsliste (bestehendeInvoiceTable) und öffnetRechnungDialogbzw.AuftragDialog.ProjektkontoListe.jsx(W1): Props{ konten, ohneProjekt, auftraegeAktiv, stripeOk, onRechnung(projektId|null), onAuftrag(projektId|null) }; die Kindkomponenten rendern ihreCardselbst.RechnungDialog.jsx(W1): Props{ open, onClose, onCreated(rechnung, info /* unverändert weiterreichen: senden, notice, tone, finalizedFilter */), kunde /* Kundendokument oder null = Kundenwahl */, projektId /* undefined = keine Vorauswahl, null = „Ohne Projekt“ */, auftraegeAktiv /* nur Startwert; danach gilt getProjektkonten */, onShowOverview /* optional */ }; lädtgetProjektkontenselbst; sitzt inInvoiceTable.jsx(dort ersetzt W1 das Modal) –FinancePage.jsxbleibt unverändert; ersetztInvoiceCreateModalüberall (auchFinancePage).AuftragListe.jsx(W2): Props{ auftraege, konten, onChanged(), onBearbeiten(auftrag), onShowOverview }(W0 reichtkontenundonShowOverviewdurch); Aktion „Projekt zuordnen“ für ABs ohne Projekt (ordneAuftragProjektZu(id, projektId)in financeApi.js).AuftragDialog.jsx(W2): Props{ open, onClose, onSaved(auftrag), kunde, projektId /* oder null */, auftrag /* Entwurf zum Bearbeiten oder null */, konten /* Projektkonten für die Projektwahl */, onShowOverview }; Hinweise (nicht blockierend): fehlende Steuernummer im Schritt 4; bei Weg „vor Ort“ und Kundenart privat „zusätzlich ausdrucken und übergeben“ (§ 312f Abs. 1 BGB). PDF öffnen:openInNewTab(async () => URL.createObjectURL(await getAuftragPdf(id))).- Reine Ansichtslogik mit Tests:
src/lib/projektkontoView.jsundsrc/lib/rechnungDialogView.js(W1),src/lib/auftragView.js(W2). - Je Strang eigene CSS-Datei:
src/components/auftraege/projektkonto.css,rechnung-dialog.css(W1),auftrag.css(W2), Gestaltung nach den Regeln des Redesigns (Komponenten aussrc/components/ui,Dialog,financeUi.js). - Weitere Änderungen: W1
InvoiceTable.jsx(Spalten Projekt, Art; Modal →RechnungDialog; „weitere Tickets“ liest W1 direkt aus Appwrite und schicktarbeitsblattIds),WorksheetList.jsx(Abzeichen),WorksheetTimesModal.jsx(Warnung); W2CustomerDetailPage.jsx(Feld Kundenart in der Übersicht),SettingsCard.jsx(Steuernummer); W0src/lib/customerTabs.js(Beschriftung).
Integration, Kontrolle, Übergabe
Plan-Datei 2026-10-06-auftraege-12-integration-kontrolle.md:
- Z1 Portal: Merge in der Reihenfolge L1, L2, L3, L4, L7 (ohne seinen letzten Commit, den Datenschutz-Abschnitt:
git merge --no-ff auftraege/l7-rechtstexte~1; der Datenschutz-Commit kommt erst beim AB-Livegang nach der Freigabe), L5, L6 nachfeature/auftraege(git merge --no-ff), nach jedem Merge die Tests des Strangs; dann Durchlauf-Testtest/auftraegeAblauf.test.jsüberstartTestApp(Festbetrag → Projektkonto; Aufwand → abgerechnet → Storno → frei; AB → Senden → Token-Bestätigung → Anzahlung → Schlussrechnung mit Minuszeile →abgerechnet→ Storno → zurück; Privatperson mit/ohne Frühbeginn; Kundenansicht gesperrt; Schalter aus → 404); volle Suite unterflock; Node-20-Lauf dertest/auftraege*.test.js(docker run --rm --network host -m 1200m -v <worktree>:<worktree>:ro node:20-alpine). - Z2 WOMS: Merge W1, W2;
npm test;vite build; Bildschirmfotos (Desktop 1440 px, Handy 390 px) aller neuen Ansichten mit nachgebildeten Antworten. - Z3 Kontrolle: fünf Prüfer parallel (sonnet) – Geld und Rechenlogik; Sicherheit und Datenschutz (Token, öffentliche Routen, Rechte, Pfade, IDOR, Kundenansicht); Recht und Texte (Spec, § 34a UStDV, § 312f BGB, Widerruf, Frühbeginn, Datenschutz); Oberfläche (Bildschirmfotos); Spec-Abdeckung (jede Zeile der Spec → Code/Test). Jeder Befund wird von einem zweiten Agenten (haiku) gegengeprüft; nur bestätigte Critical/Important werden korrigiert (sonnet), danach betroffene Tests und Z1-Durchlauf erneut.
- Übergabe:
/root/reports/auftraege-rechnungen-2026-10-06/uebergabe.mdmit Push-Befehlen (Portalfeature/auftraege:main, WOMS erst nach dem Redesign), Setup-Befehldocker exec -w /app webklar-kundenbereich-server node scripts/auftraege-setup.mjs, Compose-Zeilen fürAUFTRAEGE_ENABLED/AUFTRAEGE_PILOT_EMAILS(Vorgabe aus), Steuernummer (Stripe-Dashboard + WOMS-Einstellung), Rechtsrunde (Freigabe-Dokument), Live-Prüfungen.
Livegang (Kurzfassung, Details in der Übergabe)
- Nutzer: Steuernummer im Stripe-Dashboard hinterlegen.
- Nutzer: Portal
feature/auftraege→mainpushen; sofort danach Setup-Skript; Container-Log prüfen. Rechnungsteil (Projektkonto, Festbetrag, Aufwand, A4, Zusatzfelder) ist live, AB-Teil aus. - Nutzer: WOMS pushen, sobald das Redesign auf
testist. - Rechtsrunde: Nutzer gibt die Texte frei; Claude lädt sie in WOMS → Rechtliches hoch (kurzlebiger Schlüssel).
AUFTRAEGE_ENABLED=pilotmit Testkunde, Probe-AB an den Testkunden, dannon.