From e00b4ae57268828ed9dad47e7cf8b7e0df840a9f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 10:03:25 +0000 Subject: [PATCH 01/91] =?UTF-8?q?Spezifikation:=20Auftragsbest=C3=A4tigung?= =?UTF-8?q?=20und=20projektbezogene=20Rechnungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entwurf aus dem Brainstorming vom 06.10.2026 (Abschnitte im Chat freigegeben): Projektkonto, Rechnungsarten Anzahlung/Schluss/Aufwand/Festbetrag über Stripe, Auftragsbestätigung als eigenes PDF mit Bestätigungsklick und Verbraucher-Variante, zwei Pläne. Co-Authored-By: Claude Opus 5.5 --- ...6-10-06-auftraege-und-rechnungen-design.md | 402 ++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md diff --git a/docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md b/docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md new file mode 100644 index 0000000..8faba6a --- /dev/null +++ b/docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md @@ -0,0 +1,402 @@ +# Aufträge und Rechnungen: Auftragsbestätigung und projektbezogene Rechnungen + +Stand: 2026-10-06 · Status: Entwurf zur Freigabe (Abschnitte im Chat einzeln freigegeben) + +Grundlage: Wunsch des Nutzers vom 06.10.2026 („Auftragsbestätigung und Rechnungen bei uns, wenn wir auf Kunden gehen; +Projekt auswählen, Zusammenfassung sehen, Festbeträge möglich“), Recherche im selben Gespräch (Quellen am Ende), +Bestand: Finanzen-Spezifikation `2026-10-01-finanzen-design.md` (Stripe-Rechnungen aus WOMS, live seit 01.10.2026). + +## Ziel + +Im Ticketsystem (WOMS) legt ein Admin beim Kunden **Auftragsbestätigungen (AB)** und **Rechnungen zu einem Projekt** an. +Nach der Projektwahl zeigt eine **Zusammenfassung**, was vereinbart ist, was schon berechnet und bezahlt ist, was noch +offen ist und wie viel Zeit im Projekt steckt. Die Rechnung entsteht als Anzahlung, Schlussrechnung, nach Aufwand oder +als frei gewählter **Festbetrag** – auch wenn die erfasste Zeit mehr wert wäre (Beispiel: Frau Wolf, 500 € laut +mündlicher Absprache). + +Erfolg: +- Eine Projektrechnung (Festbetrag oder Aufwand) ist in unter zwei Minuten geschrieben und versendet. +- Eine AB geht mit PDF an den Kunden, der sie per Klick bestätigt; bei Privatkunden ist sie widerrufsrechtlich korrekt. +- Kein Arbeitsblatt wird doppelt berechnet, keine Anzahlung wird in der Schlussrechnung vergessen. +- Jede Rechnung erfüllt § 34a UStDV, jede gesendete AB liegt unveränderlich mit Prüfsumme vor. + +## Feststehende Rahmenbedingungen (geprüft) + +- **WEBklar GbR, Kleinunternehmer nach § 19 UStG.** Rechnungen müssen nach **§ 34a UStDV** (seit 01.01.2025) enthalten: + Name und Anschrift beider Seiten, **Steuernummer** (oder USt-IdNr./Kleinunternehmer-IdNr.), Ausstellungsdatum, Art + und Umfang der Leistung, Entgelt in einer Summe mit Hinweis auf die Kleinunternehmerbefreiung. Leistungszeitpunkt und + Rechnungsnummer sind für Kleinunternehmer keine Pflicht; § 33 UStDV (Kleinbetrag bis 250 €) bleibt unberührt. + **Befund:** Der heutige Rechnungsfuß (`config.stripe.invoiceFooter`) enthält keine Steuernummer. +- **Aufbewahrung:** Rechnungsdoppel 8 Jahre (§ 14b UStG seit 2025), Auftragsbestätigungen als Beleg 8 bzw. als + Geschäftsbrief 6 Jahre. E-Rechnungen muss ein Kleinunternehmer nicht ausstellen. +- **Verbraucher:** Bei Fernabsatzverträgen (Telefon, E-Mail, Chat) muss der Unternehmer den Vertrag auf einem + dauerhaften Datenträger bestätigen, spätestens bevor er mit der Leistung beginnt (§ 312f Abs. 2 BGB), samt der Angaben + nach Art. 246a EGBGB und Widerrufsbelehrung. Ohne Belehrung läuft die Widerrufsfrist bis zu 12 Monate und 14 Tage; ohne + ausdrückliches Verlangen des Verbrauchers vor Fristende schuldet er bei Widerruf keinen Wertersatz. +- **Kostenschätzung:** Zeichnet sich eine wesentliche Überschreitung eines Kostenanschlags ab, muss der Unternehmer den + Besteller unverzüglich informieren (§ 649 Abs. 2 BGB). +- **AGB (webklar.com/agb):** Vergütung „ergibt sich aus dem Angebot“, Aufwand in 15-Minuten-Schritten, Unterstützung + zur Hälfte (Nr. 9), Mitwirkung (Nr. 7), Abnahme (Nr. 8), „Projektleistungen sind nach Vereinbarung fällig“. + Stundensatz in den Abo-Bedingungen: 100 € pro Stunde (Endpreis, § 19 UStG). +- **Stripe** (SDK `stripe@19`, API 2025-10-29.clover): Rechnungen mit bis zu **4 Zusatzfeldern** im Kopf (Name ≤ 40, + Wert ≤ 140 Zeichen), Seitengröße per `rendering.pdf.page_size` (Standard außerhalb Japans: US-Letter), Rechnungsposten + mit **negativem `amount`** (mindert den Betrag), Steuer-IDs des Kontos im Dashboard (Einstellungen → Billing → + Rechnungen → „Steuerinformationen für Rechnungen“). Stripe-„Quotes“ taugen nicht als AB (Aufpreis, PDF-Titel + „Angebot“, keine Verbraucher-Variante). +- **Bestand im Portal-Server:** `createProjectInvoice` in `server/services/finance/stripeFinance.js` (Entwurf/Senden, + Adress-Sperre über 250 €, Doppel-Schutz), Finanzen-API `/api/admin/finanzen/*` mit `requireAppwriteStaff` (Admin, + zweiter Faktor), Mailer `sendCustomerMail` mit Anhängen, Fristenlogik `deadlines.js` (`withdrawalDeadline`), + Frühbeginn-Erklärungen nach Muster Terminplaner/Webshop, öffentliche Widerrufsseite `widerrufen.html` + (`/api/abo/public/withdraw`), Kundenansicht-Sperre (fail-closed), Arbeitszeit-Regeln `worksheetMinutes.js`. +- **Bestand im WOMS:** Reiter „Rechnungen“ am Kunden (nur Admins), Dialog „Rechnung schreiben“ (freie Positionen), + Finanzen-Seite. Projekte (`websiteProjects`) hängen an genau einem Ticket (`ticketId`), Arbeitsblätter + (`worksheets.totalTime`, `supporters`, `countsToPlan`) an Tickets. +- **WOMS-Redesign** (weißes Design, Pakete P4 Kunden und P7 Finanzen) baut parallel genau die betroffenen Dateien um. + +## Entscheidungen des Nutzers (06.10.2026) + +1. **Abrechnungsarten:** Festpreis, nach Aufwand (Arbeitsblätter × Stundensatz) und Anzahlung mit Schlussrechnung; ein + freier Festbetrag geht immer. +2. **Auch Privatkunden:** Kunden bekommen die Kundenart *Unternehmen / Privatperson*. Bei Privatpersonen enthält die AB + Widerrufsbelehrung und Muster-Widerrufsformular; Arbeitsbeginn vor Fristende nur auf ausdrückliches Verlangen. +3. **AB-Versand:** Mail mit PDF und Knopf „Auftrag bestätigen“ (Link ohne Anmeldung, auch im Portal sichtbar); der + Klick wird protokolliert. +4. **Ansatz A:** Rechnungen bleiben Stripe-Rechnungen (erweitert); die AB ist ein eigenes Dokument des Portal-Servers. +5. **Abschnitte 1–4 dieses Entwurfs** (Oberfläche, AB, Rechnungen, Technik/Livegang) wie unten beschrieben. +6. **Reihenfolge:** Portal-Server und Kundenseiten zuerst; WOMS-Oberfläche im neuen weißen Design, sobald die + Redesign-Stränge Kunden (P4) und Finanzen (P7) zusammengeführt sind. **Zwei Pläne** (siehe unten). + +## Begriffe + +| Begriff | Bedeutung | +|---|---| +| **Auftrag / AB** | Eine Auftragsbestätigung an einen Kunden, meist zu einem Projekt. Art *Festpreis* (Positionen) oder *Aufwand* (Stundensatz, Schätzung). | +| **Projektkonto** | Zusammenfassung je Projekt: vereinbart, berechnet, bezahlt, noch offen, Zeit erfasst. | +| **Zeitquelle** | Die Tickets, deren Arbeitsblätter zu einem Projekt/Auftrag zählen (Standard: das mit dem Projekt verknüpfte Ticket). | +| **Rechnungsart** | `anzahlung`, `schluss`, `aufwand`, `festbetrag` (mit Projekt) oder `frei` (ohne Projekt, wie heute). | +| **Abo-Zeit** | Arbeitsblätter mit `countsToPlan = true`; sie laufen über das Abo-Stundenkonto und werden nie projektbezogen berechnet. | + +## 1. Oberfläche in WOMS + +**Ort:** Kunde → Reiter **„Aufträge und Rechnungen“** (bisher „Rechnungen“, `id` bleibt `invoices`), nur für Admins. +Gebaut im neuen weißen Design (Komponenten des Redesigns, z. B. ``). + +Von oben nach unten: + +1. **Projekte (Projektkonto):** je Projekt des Kunden eine Zeile mit Name, Art und Ticketnummer; Spalten *Vereinbart* + (Summe der gültigen Festpreis-ABs bzw. „nach Aufwand, 100 €/Std.“ bzw. „keine AB“), *Berechnet* (darunter „bezahlt + x €“), *Noch offen*, *Zeit erfasst* (Stunden, darunter „Wert x €“). Knöpfe **„Auftragsbestätigung“** und + **„Rechnung“**. Eine letzte Zeile **„Ohne Projekt“** sammelt Rechnungen ohne Projektbezug. +2. **Aufträge:** Liste der ABs (Nummer, Projekt/Titel, Datum, Summe bzw. „nach Aufwand“, Status, Aktionen: PDF, + Erinnerung senden, Neue Fassung, Stornieren, Entwurf bearbeiten/löschen). +3. **Rechnungen:** die bestehende Rechnungsliste mit den zusätzlichen Spalten *Projekt* und *Art*. + +Der neue Dialog „Rechnung schreiben“ ersetzt den bisherigen überall. Seine Projektliste endet mit **„Ohne Projekt“** +(Art `frei`: freie Positionen wie bisher), so bleibt eine Rechnung ohne Projekt möglich. + +**Kundenart:** In der Kunden-Übersicht ein Feld „Kundenart: Unternehmen / Privatperson“ (leer = unbekannt). Ist es +leer, fragt der AB-Dialog im ersten Schritt danach und speichert die Antwort am Kunden. + +**Finanzen-Seite:** „Rechnung schreiben“ öffnet denselben neuen Dialog, mit Kundenwahl davor. Die Rechnungstabelle +bekommt die Spalten Projekt und Art. + +**Arbeitsblätter (Ticketseite):** Ein abgerechnetes Arbeitsblatt zeigt das Abzeichen „abgerechnet (Rechnungsnummer)“. +Ändert jemand dessen Zeit, warnt der Dialog: „Dieses Arbeitsblatt ist abgerechnet (WEBKLAR-0009). Eine Änderung ändert +die Rechnung nicht.“ (Keine harte Sperre: WOMS schreibt Arbeitsblätter direkt in Appwrite.) Das Abzeichen sehen alle +Mitarbeiter (ohne Beträge). + +### Dialog „Rechnung schreiben“ (neu) + +1. **Projekt** (vorausgewählt, wenn vom Projektkonto geöffnet) oder „Ohne Projekt“. +2. **Zusammenfassung** des Projektkontos (siehe Abschnitt 4) und **Art**: Anzahlung, Schlussrechnung, Nach Aufwand, + Festbetrag. Anzahlung und Schlussrechnung sind nur mit gültiger Festpreis-AB wählbar (Hinweis statt Sperre ohne + Erklärung: „Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.“). +3. **Positionen** (je Art vorausgefüllt, änderbar), **Leistungszeitraum** (optional, bei Aufwand vorausgefüllt), + Zahlungsziel (Standard 14 Tage), Notiz; Summe mit § 19-Hinweis. +4. **„Als Entwurf speichern“** oder **„Senden“** (mit Rückfrage wie heute; Adress-Sperre über 250 € wie heute). + +### Dialog „Auftragsbestätigung“ (neu) + +1. **Projekt** (oder freier Titel, wenn es noch kein Projekt gibt; später zuordenbar) und, falls leer, **Kundenart**. +2. **Auftrag:** Datum der Absprache und Weg (mündlich, telefonisch, per E-Mail, vor Ort, im Chat). Art **Festpreis** + (Positionen: Beschreibung, Menge, Einzelpreis; Grenzen wie bei Rechnungen) oder **Nach Aufwand** (Stundensatz, + Standard 100 €; Schätzung in Stunden, optional, in 15-Minuten-Schritten). +3. **Zahlung und Termine:** Zahlungsplan „Alles nach Abnahme“ · „Anzahlung x % bei Auftrag, Rest nach Abnahme“ + (Standard 50 %) · „Monatlich nach Aufwand“ (nur Aufwand) · „Frei“ (Text, höchstens 500 Zeichen); Zahlungsziel + (Standard 14 Tage); Start und voraussichtliche Fertigstellung (optional); Zeitquelle (Tickets des Kunden, Standard: + Ticket des Projekts). +4. **Vorschau** (PDF, nicht gespeichert) → „Als Entwurf speichern“ oder „Senden“ (mit Rückfrage). + +## 2. Auftragsbestätigung (AB) + +### Nummer und Status + +- Nummer **`AB-JJJJ-NNNN`**, fortlaufend je Kalenderjahr (Berliner Datum des Sendens), vergeben erst beim Senden unter + einer Sperre im Portal-Server (eine Instanz). Entwürfe haben keine Nummer. Scheitert das Senden vor dem Speichern des + PDFs, wird keine Nummer verbraucht. +- Status: `entwurf` → `versendet` → `bestaetigt` → `abgerechnet` (nach finalisierter Schlussrechnung); außerdem + `storniert` (von Hand, ohne Mail), `ersetzt` (durch eine neue Fassung) und `widerrufen` (Privatperson). +- **Unveränderlich nach dem Senden.** Änderung = „Neue Fassung“: Kopie als neuer Entwurf mit Vermerk + „Ersetzt AB-… vom …“; beim Senden der neuen AB wird die alte `ersetzt`. Rechnungen der alten AB wandern intern mit + (`projektRechnungen.auftragId` und Stripe-Metadaten auf die neue AB); der auf finalisierten Rechnungen aufgedruckte + Bezug bleibt unverändert. + +### Inhalt des PDFs (A4) + +- Kopf: WEBklar-Logo, WEBklar GbR, Anschrift, Kontakt, **Steuernummer** (Einstellung `financeSettings.steuernummer`). +- Empfänger: Firma bzw. Name, Anschrift (Straße, PLZ, Ort Pflicht für das Senden – auch unter 250 €). +- Titel „Auftragsbestätigung“, Nummer, Datum, Projekt bzw. Titel, Kundennummer (falls vorhanden). +- Einleitung: „Vielen Dank für Ihren Auftrag vom TT.MM.JJJJ (telefonisch). Wir bestätigen ihn wie folgt:“ +- Leistungsumfang: Positionen mit Menge, Einzelpreis, Summe und Gesamtpreis **oder** Aufwandsregel („100 € pro + Stunde in Schritten von 15 Minuten; unterstützt eine weitere Person, berechnen wir für sie die Hälfte der + Arbeitszeit (AGB Nr. 9). Geschätzter Umfang: etwa X Stunden (unverbindlich). Zeichnet sich eine deutliche + Überschreitung ab, melden wir uns vorher.“); Hinweis „Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.“ +- Zahlungsplan mit Beträgen (z. B. „Anzahlung 50 % (400,00 €) nach Auftragsbestätigung, Rest (400,00 €) nach Abnahme; + zahlbar jeweils innerhalb von 14 Tagen nach Rechnung“), Termine. +- Mitwirkung und Abnahme laut AGB Nr. 7 und 8; „Es gelten unsere Allgemeinen Geschäftsbedingungen in der Fassung vom + …, die dieser Bestätigung beiliegen.“ +- **Privatperson:** zusätzliche Seiten „Widerrufsbelehrung“ (Projektauftrag) und „Muster-Widerrufsformular“. +- Fuß: Bankverbindung (`financeSettings.bankDetailsText`), Kontakt, Seitenzahl. +- Texte und Fassung kommen aus einer Konstante im Server (`texteFassung`, Muster wie `config.termine`), die AB merkt sich + die Fassung. + +### Erzeugung und Ablage + +- PDF mit **pdfkit** (reines JavaScript; kein Chrome zur Laufzeit wegen knappem Arbeitsspeicher), Schrift Figtree aus + `public/fonts`, Logo aus `public/img`. +- Beim Senden einmal erzeugt und unter `upload-data/auftraege//.pdf` abgelegt, SHA-256 im Dokument; + danach wird nur noch diese Datei ausgeliefert. Die Datei liegt in der Hetzner-Serversicherung; jede Mail geht zudem + per BCC an das Team-Postfach. +- Die **AGB** werden als versionierte PDF-Datei ins Portal gedruckt (`public/rechtliches/agb-.pdf` mit + `.sha256`, über `scripts/build-legal-pdfs.sh`, Quelle: Website-AGB). Jede AB merkt sich `agbFassung`. + +### Versand + +- Mail **K31 „Auftragsbestätigung“** (Kunde, Design der Mailvorlagen, BCC Team): kurzer Text, Anhänge AB-PDF und + AGB-PDF, Knopf **„Auftrag bestätigen“** → `https://project.webklar.com/auftrag.html?t=`; bei Privatpersonen + Hinweis auf Widerrufsbelehrung und Frühbeginn. +- Token: 32 Zufallsbytes (base64url), gespeichert nur als SHA-256, gültig 60 Tage ab dem letzten Versand. + **„Erinnerung senden“** (WOMS, von Hand) schickt K31 erneut mit Erinnerungs-Hinweis und neuem Token (der alte gilt + nicht mehr). +- Mail-Status am AB (`mailStatus`: gesendet / nicht zugestellt + Zeitpunkt); bei Fehler „Erneut senden“. + +### Bestätigung + +- **Öffentliche Seite** `auftrag.html?t=…` (ohne Anmeldung, `noindex`, Ratenbegrenzung je IP im Arbeitsspeicher wie + bei der Widerrufsseite): Zusammenfassung (Nummer, Datum, Projekt, Positionen bzw. Aufwandsregel, Summe, + Zahlungsplan, Termine), Links auf AB-PDF und AGB-PDF, Häkchen „Ich bestätige den Auftrag zu diesen Bedingungen.“, + Knopf „Auftrag bestätigen“. Nach der Bestätigung zeigt die Seite „Bestätigt am …“. Ungültiger oder abgelaufener Link: + Erklärung und Kontaktweg. +- **Privatperson:** zweites, freiwilliges Häkchen mit der Frühbeginn-Erklärung (Wortlaut aus der Rechtsrunde, Muster + der Funktionen). Ohne Häkchen zeigt WOMS „Arbeitsbeginn frühestens am TT.MM.“ = Tag nach + `withdrawalDeadline(gesendetAm)`. +- **Im Portal** (angemeldet): Reiter Rechnungen → Abschnitt „Aufträge“ mit Status, PDF und demselben Bestätigen-Ablauf. +- **Kundenansicht:** Bestätigen ist gesperrt (Erklärung im Namen des Kunden; der Endpunkt steht nicht auf der + Freigabeliste der Kundenansicht-Sperre). +- **Protokoll am AB:** `bestaetigtAm`, `bestaetigtUeber` (`link` | `portal`), `bestaetigtPdfSha256` (muss + `pdfSha256` entsprechen), `texteFassung`, `fruehbeginn` (ja/nein) und dessen Textfassung. **Keine IP-Adresse.** + Eine zweite Bestätigung ändert nichts (die erste zählt). +- Danach: Mail **K32 „Auftrag bestätigt“** an den Kunden (Eingangsbestätigung mit Datum, Uhrzeit, Frühbeginn ja/nein) + und **T22 „Auftrag bestätigt“** an das Team. + +### Widerruf (Privatperson) + +- `widerrufen.html` bekommt die Vertragsart **„Projektauftrag (Auftragsbestätigung)“** mit Feld AB-Nummer. Die + Erklärung läuft über den bestehenden Weg (Eingangsbestätigung an den Kunden, Team-Mail); passt die AB zu Nummer und + E-Mail-Adresse, wird sie `widerrufen` und WOMS zeigt es. Erstattung und Wertersatz bleiben Handarbeit. + +## 3. Rechnungen + +Alle Projektrechnungen bleiben **Stripe-Rechnungen** (`collection_method: send_invoice`, Mail mit PDF und Zahlungsseite, +Karte/SEPA, Bankabgleich und Finanzen-Übersicht unverändert). + +### Arten + +| Art | Voraussetzung | Positionen | +|---|---|---| +| `anzahlung` | AB *Festpreis*, Status `versendet` oder `bestaetigt` | Eine Zeile „Anzahlung 50 % gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026 (Projekt)“; Betrag aus dem Zahlungsplan, änderbar in % oder €, höchstens AB-Summe minus bereits Berechnetes. Bezeichnung änderbar (auch „Teilrechnung“). | +| `schluss` | AB *Festpreis*, Status `versendet` oder `bestaetigt`; kein offener Entwurf zur AB | Alle AB-Positionen, zusätzliche Positionen (Nachträge) möglich, dann je früherer finalisierter, nicht stornierter Rechnung zur AB eine **Minuszeile** („abzüglich Anzahlung, Rechnung WEBKLAR-0007 vom 08.10.2026“, negativer `amount`). Summe muss > 0 sein, sonst „Nichts mehr offen“. Nach dem Finalisieren: AB `abgerechnet` (Storno setzt zurück). | +| `aufwand` | Projekt; Zeitquelle mit noch nicht abgerechneten Arbeitsblättern | Alle offenen Arbeitsblätter der Zeitquelle (ohne Abo-Zeit, ohne reine Kommentare, ohne 0 Minuten) sind vorausgewählt und abwählbar; **eine Zeile je Tag**: „06.10.2026: Startseite, Kontaktformular – 2,25 Std. à 100,00 €“ (Menge 1, Betrag = Stunden × Satz). Minuten je Arbeitsblatt wie `worksheetMinutes` (inkl. Unterstützung), je Eintrag auf 15 Minuten aufgerundet. Zeilentext = Kurzfassungen der Arbeitsblätter des Tages, gefiltert wie in der Kundenansicht (`customerProjectView`: ohne Git-Anhänge, SHAs, Kontonamen), auf 200 Zeichen gekürzt, im Dialog änderbar. Satz aus der jüngsten gültigen Aufwands-AB des Projekts, sonst 100 €. | +| `festbetrag` | Projekt | Freie Positionen wie heute (Frau Wolf: „Erstellung der Website anelia-wolf.com, Pauschalpreis laut Absprache“, 500 €). | +| `frei` | „Ohne Projekt“ gewählt | Freie Positionen wie bisher, ohne Zusatzfelder „Projekt“/„Auftrag“ (aber A4). | + +Grenzen wie heute (1–20 Positionen inkl. Minuszeilen; Beschreibung ≤ 200 Zeichen). Ergäbe eine Aufwandsrechnung mehr +als 20 Zeilen (zusätzliche Positionen zählen mit), werden die ältesten Tage zu Kalenderwochen zusammengefasst +(„KW 41/2026: … – 6,5 Std. à 100,00 €“), bis höchstens 20 Zeilen bleiben. + +### Auf jeder Projektrechnung + +- **Zusatzfelder** (`custom_fields`): „Projekt“ (Name, auf 140 Zeichen gekürzt), „Auftrag“ („AB-2026-0002 vom + 06.10.2026“, nur mit AB), „Leistungszeitraum“ (bei Aufwand erster bis letzter Tag, sonst optional; immer änderbar). +- **Metadaten:** `webklarCustomerId`, `quelle`, `erstelltVon` (wie heute) plus `projektId`, `projektName`, `auftragId`, + `auftragNummer`, `art`. +- **`rendering.pdf.page_size = 'a4'`** – auch im bisherigen Dialog. +- Fuß unverändert (§ 19-Hinweis, Bankverbindung, Verwendungszweck). + +### Abgerechnete Arbeitsblätter, Storno + +- Jede Projektrechnung bekommt ein Dokument in `projektRechnungen` (Kennung = Stripe-Rechnungs-ID) mit Projekt, AB, + Art, Betrag und den **abgerechneten Arbeitsblatt-IDs**. Beim Anlegen prüft der Server, dass keines der Blätter in einer + aktiven Projektrechnung steckt (sonst 409 „Arbeitsblatt bereits abgerechnet“). +- Entwurf löschen oder Rechnung stornieren setzt das Dokument auf `aktiv = false`: die Blätter sind wieder frei, der + Betrag zählt nicht mehr als berechnet; eine Schlussrechnung setzt die AB auf den vorigen Status zurück. + +### Steuernummer + +- **Sofort, unabhängig vom Bau (Nutzer):** Steuernummer der GbR im Stripe-Dashboard (Einstellungen → Billing → + Rechnungen → „Steuerinformationen für Rechnungen“) hinterlegen und als Standard setzen – dann steht sie auf allen + Rechnungen, auch Abo-Rechnungen. +- Beim Livegang an einem Entwurfs-PDF prüfen, wie Stripe sie beschriftet. Ist die Beschriftung unpassend, kommt sie + stattdessen in den Rechnungsfuß (`invoiceFooter`, und per Skript in `invoice_settings.footer` bestehender + Stripe-Kunden). +- Für das AB-PDF: neues Einstellungsfeld `steuernummer` in WOMS Finanzen → Einstellungen. + +### Kundenportal + +- Die Rechnungsliste zeigt zusätzlich das Projekt (`metadata.projektName`). + +## 4. Projektkonto (Rechenregeln) + +Je Projekt, berechnet im Portal-Server bei jeder Anfrage (Stripe-Daten aus dem bestehenden 60-Sekunden-Zwischenspeicher; +fehlt eine Rechnung dort, wird sie einzeln gelesen): + +| Feld | Regel | +|---|---| +| Vereinbart | Summe `summeCents` aller Festpreis-ABs des Projekts mit Status `versendet`, `bestaetigt` oder `abgerechnet`. Aufwands-ABs: Text „nach Aufwand, €/Std.“ (mit Schätzung). Ohne AB: „keine AB“. | +| Berechnet | Summe der Rechnungen aus aktiven `projektRechnungen` des Projekts, die finalisiert sind (offen, überfällig, bezahlt, uneinbringlich); Entwürfe und Stornos zählen nicht. | +| davon bezahlt | Summe `amount_paid` dieser Rechnungen. | +| Noch offen | Festpreis: Vereinbart − Berechnet (nie unter 0). Aufwand: Wert der noch nicht abgerechneten Zeit. Ohne AB: „–“. | +| Zeit erfasst | Minuten aller Arbeitsblätter der Zeitquelle ohne Abo-Zeit (Regeln wie Aufwand), darunter „Wert x €“ zum Satz der AB bzw. 100 €; Abo-Zeit getrennt als „über Abo: y Std.“. | + +Zeitquelle eines Projekts: Vereinigung der Zeitquellen seiner ABs, sonst das Ticket des Projekts; im Rechnungsdialog +können weitere Tickets des Kunden dazugenommen werden. „Ohne Projekt“: Stripe-Rechnungen des Kunden ohne aktive +`projektRechnungen`. + +## 5. Daten (Appwrite `woms-database`, nur Server-Schlüssel) + +Neue Collections (Setup-Skript `scripts/auftraege-setup.mjs`, idempotent wie `setup-finanzen.mjs`): + +- **`auftraege`**: `nummer`, `jahr`, `laufnummer`, `status`, `kundenId`, `kundeJson` (Name, Firma, Anschrift, E-Mail, + Kundenart zum Zeitpunkt des Sendens), `projektId`, `titel`, `zeitquelleTicketIds` (string[]), `art` + (`festpreis` | `aufwand`), `positionenJson`, `stundensatzCents`, `schaetzungMinuten`, `summeCents`, + `zahlungsplanJson`, `zahlungszielTage`, `auftragVom`, `auftragWeg`, `startAm`, `fertigBis`, `texteFassung`, + `agbFassung`, `pdfDatei`, `pdfSha256`, `gesendetAm`, `gesendetAn`, `mailStatus`, `mailFehler`, `tokenHash`, + `tokenGueltigBis`, `bestaetigtAm`, `bestaetigtUeber`, `bestaetigtPdfSha256`, `fruehbeginn`, `fruehbeginnFassung`, + `ersetztId`, `ersetztDurchId`, `storniertAm`, `widerrufenAm`, `erstelltVon`, `erstelltAm`, `geaendertAm`. + Indizes: `kundenId`, `projektId`, `nummer` (unique), `tokenHash`. +- **`projektRechnungen`**: Dokument-ID = Stripe-Rechnungs-ID; `kundenId`, `projektId`, `auftragId`, `art`, + `betragCents`, `arbeitsblattIds` (string[]), `aktiv` (bool), `erstelltVon`, `erstelltAm`. Indizes: `kundenId`, + `projektId`, `auftragId`. + +Neue Attribute: `customers.kundenart` (`''` | `unternehmen` | `privat`), `financeSettings.steuernummer`. + +Stripe-Rechnungen werden weiter nicht gespiegelt. + +## 6. Schnittstellen (Portal-Server) + +Admin (unter dem bestehenden Finanzen-Router, `requireAppwriteStaff`): + +| Methode, Pfad | Zweck | +|---|---| +| GET `/api/admin/finanzen/projektkonten?kunde=` | Projektkonten des Kunden inkl. „Ohne Projekt“ | +| GET `/api/admin/finanzen/projektkonten/:projektId` | Ein Projektkonto mit ABs, Rechnungen, offenen Arbeitsblättern | +| GET, POST `/api/admin/finanzen/auftraege` (`?kunde=`) | ABs lesen, Entwurf anlegen | +| GET, PATCH, DELETE `/api/admin/finanzen/auftraege/:id` | Lesen; Entwurf ändern/löschen | +| GET `/api/admin/finanzen/auftraege/:id/pdf` | Gespeichertes PDF bzw. Vorschau eines Entwurfs | +| POST `/api/admin/finanzen/auftraege/:id/senden` | Nummer, PDF, Ablage, Mail | +| POST `/api/admin/finanzen/auftraege/:id/erinnern` | K31 erneut, neuer Token | +| POST `/api/admin/finanzen/auftraege/:id/neue-fassung` | Kopie als Entwurf | +| POST `/api/admin/finanzen/auftraege/:id/stornieren` | Status `storniert` | +| POST `/api/admin/finanzen/rechnungen` | erweitert: `{ customerId, projektId?, auftragId?, art, items, arbeitsblattIds?, leistungszeitraum?, daysUntilDue, memo, senden, idempotencyKey }`; Positionen für `anzahlung`/`schluss`/`aufwand` baut der Server nach (Minuszeilen nur serverseitig) | +| PATCH `/api/admin/finanzen/einstellungen` | erweitert um `steuernummer` | + +Alle Mitarbeiter (`/api/team`, `requireStaffJwt`): GET `/api/team/arbeitsblaetter/abgerechnet?ticketId=` → +`{ worksheetId: rechnungsnummer }` (ohne Beträge). + +Kunden: GET `/api/auftraege` (eigene ABs), GET `/api/auftraege/:id/pdf`, POST `/api/auftraege/:id/bestaetigen` +(Portal-Sitzung; in der Kundenansicht gesperrt). + +Öffentlich (Token): GET `/api/auftrag/:token`, GET `/api/auftrag/:token/pdf`, POST `/api/auftrag/:token/bestaetigen` +(`{ bestaetigt: true, fruehbeginn }`). + +Widerruf: `/api/abo/public/withdraw` akzeptiert `order = projekt` mit `abNummer`. + +Kundenart: WOMS schreibt `customers.kundenart` direkt (wie die übrigen Kundenfelder). + +## 7. Mails + +Nächste freie Kennungen zum Zeitpunkt des Baus prüfen (derzeit K31, K32, T22): + +- **K31 Auftragsbestätigung** (Kunde; Anhänge AB-PDF und AGB-PDF; Variante Erinnerung; Variante Privatperson). +- **K32 Auftrag bestätigt** (Kunde; Eingangsbestätigung, Frühbeginn ja/nein). +- **T22 Auftrag bestätigt** (Team). +- Widerruf eines Projektauftrags nutzt die bestehenden Widerrufs-Mails (K04/T09) mit der neuen Vertragsart. + +## 8. Rechtsrunde (vor dem Livegang von Plan 2) + +Der Nutzer gibt frei; danach lädt Claude die Fassungen in WOMS → Rechtliches hoch: +- Textbausteine der AB (Einleitung, Aufwandsregel, Zahlungsplan, Mitwirkung/Abnahme, AGB-Satz). +- Widerrufsbelehrung für Projektaufträge und Muster-Widerrufsformular (Bauart wie `widerrufsbelehrung-funktionen`). +- Frühbeginn-Erklärung (Wortlaut nach Muster der Funktionen, passende Norm prüfen). +- Datenschutzerklärung: neuer Abschnitt „Auftragsbestätigungen“ (verarbeitete Daten, Bestätigungslink, keine IP, + Aufbewahrung 6 bzw. 8 Jahre) und Ergänzung des Widerrufs-Abschnitts. +- AGB als PDF (Fassung, aus der Website gedruckt). + +## 9. Fehlerverhalten + +| Fall | Verhalten | +|---|---| +| Stripe nicht erreichbar | Rechnung scheitert mit „Stripe ist gerade nicht erreichbar.“; ABs und Projektkonto (ohne Stripe-Teil, markiert) gehen weiter | +| PDF-Erzeugung scheitert | AB bleibt Entwurf, keine Nummer verbraucht, Meldung im Dialog | +| Mail scheitert | AB ist `versendet`, `mailStatus` „nicht zugestellt“, Knopf „Erneut senden“ | +| Link ungültig/abgelaufen | Seite erklärt es, nennt den Kontaktweg; WOMS kann per „Erinnerung senden“ einen neuen Link schicken | +| Doppelte Bestätigung | folgenlos, die erste zählt | +| Arbeitsblatt schon abgerechnet | 409 mit Rechnungsnummer | +| Schlussrechnung mit offenem Entwurf zur AB | 409 „Erst den Entwurf … senden oder löschen.“ | +| Fehlende Anschrift beim AB-Senden | 409 „Adresse des Kunden unvollständig“ | +| Kundenart leer beim AB-Senden | 400 (der Dialog fragt vorher) | + +## 10. Tests + +- **Unit** (`node --test`): Projektkonto-Regeln; Positionsbau je Art (Minuszeilen, Tageszeilen, 15-Minuten-Rundung, + Unterstützung, Wochen-Zusammenfassung über 20 Tage); AB-Nummern (Jahreswechsel, Sperre); Token (Hash, Ablauf, + Erneuerung); Frühbeginn-Datum; PDF enthält Pflichtangaben (Text aus dem PDF gelesen) und bei Privatpersonen die + Belehrung; Stripe-Parameter (`custom_fields`, `page_size`, Metadaten) gegen das Fake-Stripe. +- **Server-Ablauf** mit Appwrite-Nachbau, Fake-Stripe und SMTP-Senke (Rezept `kundenportal-testing`): AB anlegen → + senden → bestätigen (Link und Portal) → Anzahlung → Schlussrechnung; Aufwand mit Arbeitsblättern; Storno gibt frei; + Kundenansicht kann nicht bestätigen; Widerruf eines Projektauftrags. +- **WOMS:** Lib-Tests der Ansichtslogik; Bildschirmfotos über das Prüfwerkzeug des Redesigns (Desktop und Handy). +- **Kundenseiten:** Playwright (Handy, Desktop) gegen Fixture-Server. +- **Live:** nur Entwürfe mit dem Testkunden des Nutzers; nichts an echte Kunden. + +## 11. Pläne und Livegang + +- **Plan 1 – Rechnungen mit Projektkonto:** `projektRechnungen`, Projektkonto, Arten `aufwand`, `festbetrag` und + `frei`, Zusatzfelder, Metadaten, A4, Abzeichen „abgerechnet“, Projekt in der Kunden-Rechnungsliste, + WOMS-Oberfläche (Projektkonto, neuer Rechnungsdialog, Reiter umbenannt). In Plan 1 zeigt *Vereinbart* immer + „keine AB“. Keine Rechtsrunde nötig. +- **Plan 2 – Auftragsbestätigung:** `auftraege`, Kundenart, Steuernummer-Einstellung, PDF, Mails, Bestätigungsseite + und Portal-Abschnitt, Verbraucher-Variante, Widerruf, Arten `anzahlung` und `schluss` (mit Minuszeilen), + WOMS-Dialog und -Liste, Rechtsrunde. +- **Reihenfolge je Plan:** Portal-Server und Kundenseiten zuerst; WOMS-Oberfläche im neuen Design auf Basis der + zusammengeführten Redesign-Stränge Kunden (P4) und Finanzen (P7). Sind die Stränge beim Start der WOMS-Aufgaben noch + nicht zusammengeführt, wird mit dem Nutzer entschieden (warten oder auf `test` bauen und eine Mitnahme-Notiz für das + Redesign hinterlegen). +- **Livegang:** (0) Nutzer hinterlegt die Steuernummer in Stripe; (1) Setup-Skript; (2) Nutzer pusht Portal `main`; + (3) Nutzer pusht WOMS `test`; (4) Prüfung mit Testkunde (Entwürfe, PDF-Ansicht); bei Plan 2 vorher Rechtsrunde und + Upload in Rechtliches. + +## Nicht im Umfang + +Angebote vor dem Auftrag, Mahnwesen (Stripe-Erinnerungen wie heute), E-Rechnung, Abrechnung des Abo-Mehraufwands +(`hourLedger`), Nachlass-/Rabattzeilen, eigene Archivkopien der Stripe-PDFs, englische ABs, mehrere Währungen, +Projektmarge und Auswertungen, automatische Erinnerungen. + +## Quellen + +- § 34a UStDV: https://lxgesetze.de/ustdv/34a +- § 14b UStG: https://lxgesetze.de/ustg/14b +- § 312f BGB: https://www.haufe.de/id/norm/buergerliches-gesetzbuch-312f-abschriften-und-bestaetigungen-HI1049155_p312f.html +- Auftragsbestätigung (Inhalt): https://www.lexware.de/wissen/faktura-warenwirtschaft/auftragsbestaetigung-bedeutung-und-inhalt/ +- Stripe Rechnungen anpassen: https://docs.stripe.com/invoicing/customize +- Agentur-Abrechnung (Festpreis/Aufwand): https://everhour.com/de/invoices/rechnungssoftware-fuer-digitalagenturen From 0bb463eb7d487170db86401aca2b196ab01997a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 12:06:09 +0000 Subject: [PATCH 02/91] =?UTF-8?q?Spezifikation=20nachgesch=C3=A4rft,=20Pla?= =?UTF-8?q?n-=C3=9Cbersicht=20mit=20Vertr=C3=A4gen=20der=20Str=C3=A4nge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nachträge aus der Planrunde (Fragment-Link, Teamkopie ohne Link, Schalter AUFTRAEGE_ENABLED, Projekt später zuordnen, Schlussrechnung bis 40 Zeilen, zusammengesetzte Sperrschlüssel, Zuständigkeiten). Co-Authored-By: Claude Opus 5.5 --- .../2026-10-06-auftraege-00-uebersicht.md | 401 ++++++++++++++++++ ...6-10-06-auftraege-und-rechnungen-design.md | 49 ++- 2 files changed, 434 insertions(+), 16 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md diff --git a/docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md b/docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md new file mode 100644 index 0000000..4204879 --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md @@ -0,0 +1,401 @@ +# 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 status` sauber). +- **Phase W** startet, sobald die Redesign-Stränge Kunden (P4, P6) und Finanzen (P7) in `feature/design-weiss` zusammengeführt und getaggt sind (Tags `design-p4` … `design-p7`, siehe Memory „woms-theme-weiss“). Fehlen sie beim Start von Phase W, fragt der Orchestrator den Nutzer (warten oder auf `test` bauen und eine Mitnahme-Notiz in `/root/reports/ticketsystem-design-2026-10-04/strang/mitnehmen.md` hinterlegen). +- **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`. Kein `opus`/`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.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` (Eintrag `node_modules` **ohne** Schrägstrich, damit auch der Symlink ignoriert wird) und `fontkit` als direkte Abhängigkeit in `package.json` (Version wie die pdfkit-Abhängigkeit). +- L2: zusätzlich `test/financeStripeRead.test.js` (erwarteter Schlüsselsatz von `mapInvoice`). +- L5: zusätzlich `server/mail-templates/K04 Eingang Widerruf.html` und `T09 Widerruf eingegangen.html` (nur Block `projekt`). +- 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 in `test/customerInvoices.test.js` und `test/publicForms.test.js`. +- L7: zusätzlich `docs/rechtliches/agb.md`, `docs/rechtliches/quellen/normen-auftraege-2026-10-06.txt`, Stand-Zeile in `test/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 unter `src/components/auftraege/`, `CustomerDetailPage.jsx` (nur Reiterzeile), `CustomerInvoices.jsx` (gelöscht); W1 `ProjektkontoListe.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); W2 `AuftragListe.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). 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). + +**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{…}` oder `String.fromCodePoint(…)`. +- Kleinunternehmer: keine Umsatzsteuer; jede AB trägt „Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.“ +- Beträge immer ganze **Cent** (`Number.isSafeInteger`); Anzeige über `euroDe(cents)` aus `server/services/finance/format.js` (bzw. WOMS `euro`). +- Datum intern als Berliner Kalendertag `YYYY-MM-DD` (`berlinDate` aus `server/services/finance/dates.js`), Anzeige `TT.MM.JJJJ` (`formatDe`); Zeitpunkte als ISO-UTC (`toISOString()`), Appwrite liefert `+00:00` → immer mit `new 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 in `AUFTRAEGE_PILOT_EMAILS` steht; `*` = alle) | `on`. Er betrifft nur AB-Funktionen und die Rechnungsarten `anzahlung`/`schluss`; Projektkonto und die Arten `frei`, `festbetrag`, `aufwand` laufen 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 aus `test/support/` (`startAppwriteMock`, `startFakeStripe`, `useTestEnv`, `mailSink`, `startTestApp`), nie echtes Appwrite, Stripe oder SMTP. Neue Appwrite-Felder nur über `server/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 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/`. Die volle Suite (`npm test`) läuft **nur in Z1**, und schwere Läufe (volle Suite, Chrome-PDF-Druck, `npm ci`, `vite build`) laufen hinter `flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock`. Vorher `free -m`: unter 1500 MB „available“ warten. Nie `pkill -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 `; **kein Push** (der Nutzer pusht), nie `--force`, nie `git add -A`. +- Sicherheit der Umgebung (Memory „feedback-destruktive-skripte“): kein `rm -rf`/`rsync --delete` auß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 + +1. **Gleichzeitige Aufwandsrechnungen** mit denselben Arbeitsblättern (Doppelklick, zwei Admins): die zweite muss mit 409 „Arbeitsblatt bereits abgerechnet“ scheitern. Test in L2 (zwei parallele `createProjectInvoice` mit gleichen `arbeitsblattIds` unter Sperre `['auftraege-rechnung', kundenId]`). +2. **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). +3. **Schlussrechnung neben stornierten oder noch nicht gesendeten Rechnungen**: Stornos zählen nicht, ein offener Entwurf zur AB blockiert mit 409. Tests in L2. +4. **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. +5. **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`). + +```js +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_) + 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) + +```js +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 + +```js +// 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-.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 bei `kunde.kundenart === 'privat'`, `!fruehbeginn` und gesetztem `gesendetAm`: Tag nach `withdrawalDeadline(new Date(gesendetAm)).lastDay` (aus `server/services/deadlines.js`); sonst `null`. +- `zahlungsplanText(auftrag)` und `aufwandText(auftrag)`: aus den Vorlagen in `texte.js` (V7), Beträge mit `euroDe`, bei `anzahlung` Anzahlung = `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 in `config.auftraege.pilotEmails` oder 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` (setzt `erstelltAm`, `geaendertAm`, Status `entwurf`), `updateAuftrag(id, patch) → Auftrag` (setzt `geaendertAm`), `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 nach `datum`, dann `id`; Datum wie `parseSheetDate` in `hourLedger.js`: `TT.MM.JJJJ`, sonst ISO, sonst `$createdAt`, als Berliner Tag; Text gefiltert wie `customerWorkEntries` in `customerProjectView.js`), `ladeArbeitsblaetterNachIds(ids) → Arbeitsblatt[]`, `abrechenbar(blatt) → boolean` (`!aboZeit && !kommentar && minuten > 0`), `abgerechneteArbeitsblaetter(kundenId) → Map` (aus aktiven `projektRechnungen`), `offeneArbeitsblaetter(ticketIds, kundenId) → Arbeitsblatt[]` (abrechenbar und nicht abgerechnet), `zeitquelleFuerProjekt(projekt, auftraege) → string[]` (Vereinigung der `zeitquelle` aller ABs mit Status aus `VEREINBARTE_STATUS`, sonst `[projekt.ticketId]` ohne Leereinträge). +- `ablage.js`: `speicherePdf(kundenId, auftragId, buffer) → { pdfDatei: '/.pdf', pdfSha256 }` (unter `config.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$`, sonst `AuftragError(404, 'PDF nicht gefunden.')`). +- `settings.js` (Finanzen): Schlüssel `steuernummer` (Vorgabe `''`, getrimmt, höchstens 40 Zeichen, erlaubt Buchstaben, Ziffern, Leerzeichen, `/`, `-` und `.` (auch eine USt-IdNr. „DE…“); `''` löscht; sonst `SettingsError(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: – 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 “ 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` (`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` (wirft bei Fehler), `sendeBestaetigtMails(auftrag) → Promise`. 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: '' }`. 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=` | – | `{ 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=` | – | `{ abgerechnet: { : '' \| '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=` | – | `{ 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}}“; Platzhalter `name, nummer, titel, datum, betrag, link, gueltig_bis`; Blöcke `erinnerung, privat, festpreis, aufwand, teamkopie`. Anhänge: AB-PDF (`.pdf`) und AGB-PDF (`public/rechtliches/agb-.pdf`, Dateiname `WEBklar-AGB-.pdf`). **Kein** BCC an das Team (`bccTeam: false`), weil die Mail den Bestätigungslink trägt; das Team bekommt eine Kopie mit Block `teamkopie` an `CHAT_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ückfall `AGB_FASSUNG`); fehlt die Datei, wirft der Versand (→ `mailStatus: 'fehler'`). +- `K32 Auftrag bestaetigt.html` – Betreff „Ihre Bestätigung zu {{nummer}} ist eingegangen“; Platzhalter `name, nummer, titel, zeitpunkt, fruehester_beginn, fruehbeginn_text`; Blöcke `fruehbeginn` (gibt `FRUEHBEGINN_TEXT` wörtlich wieder; kein Satz „Ihr Widerrufsrecht bleibt unberührt“), `ohne_fruehbeginn`. +- `T22 Auftrag bestaetigt.html` – Betreff „[Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}}“; Platzhalter `kunde, nummer, titel, zeitpunkt, ueber, woms_link, fruehester_beginn`; Blöcke `privat_fruehbeginn, privat_ohne_fruehbeginn`. `woms_link = /customers/?tab=invoices`. +- Link der K31: `/auftrag.html#t=` (Fragment: landet nicht in Server- und Proxy-Protokollen; die Seite liest `#t=` und als Rückfall `?t=`). +- `K04 Eingang Widerruf.html` und `T09 Widerruf eingegangen.html` bekommen je einen Block `projekt` (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): + +```js +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 Kundenreiters `invoices` (Beschriftung „Aufträge und Rechnungen“), Props `{ customer, onShowOverview }`; lädt `getProjektkonten` und (wenn `auftraegeAktiv`) `listAuftraege`; zeigt `ProjektkontoListe`, `AuftragListe` (nur mit Schalter), Rechnungsliste (bestehende `InvoiceTable`) und öffnet `RechnungDialog` bzw. `AuftragDialog`. +- `ProjektkontoListe.jsx` (W1): Props `{ konten, ohneProjekt, auftraegeAktiv, stripeOk, onRechnung(projektId|null), onAuftrag(projektId|null) }`; die Kindkomponenten rendern ihre `Card` selbst. +- `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ädt `getProjektkonten` selbst; sitzt in `InvoiceTable.jsx` (dort ersetzt W1 das Modal) – `FinancePage.jsx` bleibt unverändert; ersetzt `InvoiceCreateModal` überall (auch `FinancePage`). +- `AuftragListe.jsx` (W2): Props `{ auftraege, konten, onChanged(), onBearbeiten(auftrag), onShowOverview }` (W0 reicht `konten` und `onShowOverview` durch); 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.js` und `src/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 aus `src/components/ui`, `Dialog`, `financeUi.js`). +- Weitere Änderungen: W1 `InvoiceTable.jsx` (Spalten Projekt, Art; Modal → `RechnungDialog`; „weitere Tickets“ liest W1 direkt aus Appwrite und schickt `arbeitsblattIds`), `WorksheetList.jsx` (Abzeichen), `WorksheetTimesModal.jsx` (Warnung); W2 `CustomerDetailPage.jsx` (Feld Kundenart in der Übersicht), `SettingsCard.jsx` (Steuernummer); W0 `src/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 nach `feature/auftraege` (`git merge --no-ff`), nach jedem Merge die Tests des Strangs; dann Durchlauf-Test `test/auftraegeAblauf.test.js` über `startTestApp` (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 unter `flock`; Node-20-Lauf der `test/auftraege*.test.js` (`docker run --rm --network host -m 1200m -v ::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.md` mit Push-Befehlen (Portal `feature/auftraege:main`, WOMS erst nach dem Redesign), Setup-Befehl `docker exec -w /app webklar-kundenbereich-server node scripts/auftraege-setup.mjs`, Compose-Zeilen für `AUFTRAEGE_ENABLED`/`AUFTRAEGE_PILOT_EMAILS` (Vorgabe aus), Steuernummer (Stripe-Dashboard + WOMS-Einstellung), Rechtsrunde (Freigabe-Dokument), Live-Prüfungen. + +## Livegang (Kurzfassung, Details in der Übergabe) + +1. Nutzer: Steuernummer im Stripe-Dashboard hinterlegen. +2. Nutzer: Portal `feature/auftraege` → `main` pushen; sofort danach Setup-Skript; Container-Log prüfen. Rechnungsteil (Projektkonto, Festbetrag, Aufwand, A4, Zusatzfelder) ist live, AB-Teil aus. +3. Nutzer: WOMS pushen, sobald das Redesign auf `test` ist. +4. Rechtsrunde: Nutzer gibt die Texte frei; Claude lädt sie in WOMS → Rechtliches hoch (kurzlebiger Schlüssel). +5. `AUFTRAEGE_ENABLED=pilot` mit Testkunde, Probe-AB an den Testkunden, dann `on`. diff --git a/docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md b/docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md index 8faba6a..d17657e 100644 --- a/docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md +++ b/docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md @@ -1,6 +1,6 @@ # Aufträge und Rechnungen: Auftragsbestätigung und projektbezogene Rechnungen -Stand: 2026-10-06 · Status: Entwurf zur Freigabe (Abschnitte im Chat einzeln freigegeben) +Stand: 2026-10-06 · Status: freigegeben (Abschnitte im Chat einzeln freigegeben; Nachträge aus der Planrunde eingearbeitet, siehe „Nachträge“ am Ende) Grundlage: Wunsch des Nutzers vom 06.10.2026 („Auftragsbestätigung und Rechnungen bei uns, wenn wir auf Kunden gehen; Projekt auswählen, Zusammenfassung sehen, Festbeträge möglich“), Recherche im selben Gespräch (Quellen am Ende), @@ -117,7 +117,7 @@ Mitarbeiter (ohne Beträge). ### Dialog „Auftragsbestätigung“ (neu) -1. **Projekt** (oder freier Titel, wenn es noch kein Projekt gibt; später zuordenbar) und, falls leer, **Kundenart**. +1. **Projekt** (oder freier Titel, wenn es noch kein Projekt gibt; später zuordenbar über „Projekt zuordnen“ in der AB-Liste, ohne das PDF zu ändern) und, falls leer, **Kundenart**. 2. **Auftrag:** Datum der Absprache und Weg (mündlich, telefonisch, per E-Mail, vor Ort, im Chat). Art **Festpreis** (Positionen: Beschreibung, Menge, Einzelpreis; Grenzen wie bei Rechnungen) oder **Nach Aufwand** (Stundensatz, Standard 100 €; Schätzung in Stunden, optional, in 15-Minuten-Schritten). @@ -138,8 +138,8 @@ Mitarbeiter (ohne Beträge). `storniert` (von Hand, ohne Mail), `ersetzt` (durch eine neue Fassung) und `widerrufen` (Privatperson). - **Unveränderlich nach dem Senden.** Änderung = „Neue Fassung“: Kopie als neuer Entwurf mit Vermerk „Ersetzt AB-… vom …“; beim Senden der neuen AB wird die alte `ersetzt`. Rechnungen der alten AB wandern intern mit - (`projektRechnungen.auftragId` und Stripe-Metadaten auf die neue AB); der auf finalisierten Rechnungen aufgedruckte - Bezug bleibt unverändert. + (`projektRechnungen.auftragId` auf die neue AB); Stripe-Metadaten und der auf finalisierten Rechnungen aufgedruckte + Bezug bleiben unverändert. ### Inhalt des PDFs (A4) @@ -162,8 +162,8 @@ Mitarbeiter (ohne Beträge). ### Erzeugung und Ablage -- PDF mit **pdfkit** (reines JavaScript; kein Chrome zur Laufzeit wegen knappem Arbeitsspeicher), Schrift Figtree aus - `public/fonts`, Logo aus `public/img`. +- PDF mit **pdfkit** (reines JavaScript; kein Chrome zur Laufzeit wegen knappem Arbeitsspeicher), statische + Figtree-Schnitte (OFL) unter `server/assets/fonts/`, Logo `public/webklar-logo.png`. - Beim Senden einmal erzeugt und unter `upload-data/auftraege//.pdf` abgelegt, SHA-256 im Dokument; danach wird nur noch diese Datei ausgeliefert. Die Datei liegt in der Hetzner-Serversicherung; jede Mail geht zudem per BCC an das Team-Postfach. @@ -172,9 +172,10 @@ Mitarbeiter (ohne Beträge). ### Versand -- Mail **K31 „Auftragsbestätigung“** (Kunde, Design der Mailvorlagen, BCC Team): kurzer Text, Anhänge AB-PDF und - AGB-PDF, Knopf **„Auftrag bestätigen“** → `https://project.webklar.com/auftrag.html?t=`; bei Privatpersonen - Hinweis auf Widerrufsbelehrung und Frühbeginn. +- Mail **K31 „Auftragsbestätigung“** (Kunde, Design der Mailvorlagen, **ohne** BCC ans Team, weil sie den + Bestätigungslink trägt): kurzer Text, Anhänge AB-PDF und AGB-PDF, Knopf **„Auftrag bestätigen“** → + `https://project.webklar.com/auftrag.html#t=` (Fragment: landet nicht in Server-Protokollen); bei + Privatpersonen Hinweis auf Widerrufsbelehrung und Frühbeginn. Das Team bekommt eine Kopie **ohne** Link. - Token: 32 Zufallsbytes (base64url), gespeichert nur als SHA-256, gültig 60 Tage ab dem letzten Versand. **„Erinnerung senden“** (WOMS, von Hand) schickt K31 erneut mit Erinnerungs-Hinweis und neuem Token (der alte gilt nicht mehr). @@ -182,7 +183,7 @@ Mitarbeiter (ohne Beträge). ### Bestätigung -- **Öffentliche Seite** `auftrag.html?t=…` (ohne Anmeldung, `noindex`, Ratenbegrenzung je IP im Arbeitsspeicher wie +- **Öffentliche Seite** `auftrag.html#t=…` (ohne Anmeldung, `noindex`, Ratenbegrenzung je IP im Arbeitsspeicher wie bei der Widerrufsseite): Zusammenfassung (Nummer, Datum, Projekt, Positionen bzw. Aufwandsregel, Summe, Zahlungsplan, Termine), Links auf AB-PDF und AGB-PDF, Häkchen „Ich bestätige den Auftrag zu diesen Bedingungen.“, Knopf „Auftrag bestätigen“. Nach der Bestätigung zeigt die Seite „Bestätigt am …“. Ungültiger oder abgelaufener Link: @@ -196,13 +197,13 @@ Mitarbeiter (ohne Beträge). - **Protokoll am AB:** `bestaetigtAm`, `bestaetigtUeber` (`link` | `portal`), `bestaetigtPdfSha256` (muss `pdfSha256` entsprechen), `texteFassung`, `fruehbeginn` (ja/nein) und dessen Textfassung. **Keine IP-Adresse.** Eine zweite Bestätigung ändert nichts (die erste zählt). -- Danach: Mail **K32 „Auftrag bestätigt“** an den Kunden (Eingangsbestätigung mit Datum, Uhrzeit, Frühbeginn ja/nein) - und **T22 „Auftrag bestätigt“** an das Team. +- Danach: Mail **K32 „Auftrag bestätigt“** an den Kunden (Eingangsbestätigung mit Datum, Uhrzeit, Frühbeginn ja/nein; + eine Frühbeginn-Erklärung wird wörtlich wiedergegeben) und **T22 „Auftrag bestätigt“** an das Team. ### Widerruf (Privatperson) -- `widerrufen.html` bekommt die Vertragsart **„Projektauftrag (Auftragsbestätigung)“** mit Feld AB-Nummer. Die - Erklärung läuft über den bestehenden Weg (Eingangsbestätigung an den Kunden, Team-Mail); passt die AB zu Nummer und +- `widerrufen.html` bekommt die Vertragsart **„Projektauftrag (Auftragsbestätigung)“** mit Feld AB-Nummer (optional, + „falls zur Hand“; der Widerruf ist formfrei). Die Erklärung läuft über den bestehenden Weg (Eingangsbestätigung an den Kunden, Team-Mail); passt die AB zu Nummer und E-Mail-Adresse, wird sie `widerrufen` und WOMS zeigt es. Erstattung und Wertersatz bleiben Handarbeit. ## 3. Rechnungen @@ -220,7 +221,8 @@ Karte/SEPA, Bankabgleich und Finanzen-Übersicht unverändert). | `festbetrag` | Projekt | Freie Positionen wie heute (Frau Wolf: „Erstellung der Website anelia-wolf.com, Pauschalpreis laut Absprache“, 500 €). | | `frei` | „Ohne Projekt“ gewählt | Freie Positionen wie bisher, ohne Zusatzfelder „Projekt“/„Auftrag“ (aber A4). | -Grenzen wie heute (1–20 Positionen inkl. Minuszeilen; Beschreibung ≤ 200 Zeichen). Ergäbe eine Aufwandsrechnung mehr +Grenzen wie heute (1–20 Positionen inkl. Minuszeilen; Beschreibung ≤ 200 Zeichen); eine Schlussrechnung darf bis +40 Zeilen haben (AB-Positionen ≤ 20, Zusatzpositionen ≤ 10, Abzüge ≤ 10). Ergäbe eine Aufwandsrechnung mehr als 20 Zeilen (zusätzliche Positionen zählen mit), werden die ältesten Tage zu Kalenderwochen zusammengefasst („KW 41/2026: … – 6,5 Std. à 100,00 €“), bis höchstens 20 Zeilen bleiben. @@ -283,7 +285,8 @@ Neue Collections (Setup-Skript `scripts/auftraege-setup.mjs`, idempotent wie `se `agbFassung`, `pdfDatei`, `pdfSha256`, `gesendetAm`, `gesendetAn`, `mailStatus`, `mailFehler`, `tokenHash`, `tokenGueltigBis`, `bestaetigtAm`, `bestaetigtUeber`, `bestaetigtPdfSha256`, `fruehbeginn`, `fruehbeginnFassung`, `ersetztId`, `ersetztDurchId`, `storniertAm`, `widerrufenAm`, `erstelltVon`, `erstelltAm`, `geaendertAm`. - Indizes: `kundenId`, `projektId`, `nummer` (unique), `tokenHash`. + Indizes: `kundenId`, `projektId`, `nummer` (normaler Index: Entwürfe haben keine Nummer; eindeutig über die Sperre + beim Senden), `tokenHash`. - **`projektRechnungen`**: Dokument-ID = Stripe-Rechnungs-ID; `kundenId`, `projektId`, `auftragId`, `art`, `betragCents`, `arbeitsblattIds` (string[]), `aktiv` (bool), `erstelltVon`, `erstelltAm`. Indizes: `kundenId`, `projektId`, `auftragId`. @@ -378,6 +381,10 @@ Der Nutzer gibt frei; danach lädt Claude die Fassungen in WOMS → Rechtliches - **Plan 2 – Auftragsbestätigung:** `auftraege`, Kundenart, Steuernummer-Einstellung, PDF, Mails, Bestätigungsseite und Portal-Abschnitt, Verbraucher-Variante, Widerruf, Arten `anzahlung` und `schluss` (mit Minuszeilen), WOMS-Dialog und -Liste, Rechtsrunde. +- **Parallel gebaut, gestaffelt live:** Auf Wunsch des Nutzers (06.10.2026) werden beide Pläne gleichzeitig in + parallelen Strängen gebaut (Umsetzungsplan `2026-10-06-auftraege-00-uebersicht.md`). Der AB-Teil (und die Arten + `anzahlung`/`schluss`) hängt hinter dem Schalter `AUFTRAEGE_ENABLED` (`off` | `pilot` | `on`, Vorgabe `off`), so geht + der Rechnungsteil ohne Rechtsrunde live; der Datenschutz-Abschnitt kommt erst mit dem AB-Livegang. - **Reihenfolge je Plan:** Portal-Server und Kundenseiten zuerst; WOMS-Oberfläche im neuen Design auf Basis der zusammengeführten Redesign-Stränge Kunden (P4) und Finanzen (P7). Sind die Stränge beim Start der WOMS-Aufgaben noch nicht zusammengeführt, wird mit dem Nutzer entschieden (warten oder auf `test` bauen und eine Mitnahme-Notiz für das @@ -400,3 +407,13 @@ Projektmarge und Auswertungen, automatische Erinnerungen. - Auftragsbestätigung (Inhalt): https://www.lexware.de/wissen/faktura-warenwirtschaft/auftragsbestaetigung-bedeutung-und-inhalt/ - Stripe Rechnungen anpassen: https://docs.stripe.com/invoicing/customize - Agentur-Abrechnung (Festpreis/Aufwand): https://everhour.com/de/invoices/rechnungssoftware-fuer-digitalagenturen + +## Nachträge aus der Planrunde (06.10.2026) + +- Kundentexte: Mails, PDF und die öffentliche Bestätigungsseite in Sie-Form; der Abschnitt „Aufträge“ im angemeldeten + Portal in Du-Form wie das übrige Portal. +- Steuernummer-Feld akzeptiert auch eine USt-IdNr. („DE…“). +- Frühbeginn-Erklärung stützt sich auf § 356 Abs. 5 Nr. 2 und § 357a Abs. 2 BGB (Wortlaut in der Rechtsrunde). +- Bei Weg „vor Ort“ und Kundenart Privatperson zeigt der AB-Dialog den Hinweis „zusätzlich ausdrucken und übergeben“ + (§ 312f Abs. 1 BGB), bis die Kanzlei-Frage beantwortet ist. +- Team-Mail T22 trägt im Betreff das Präfix „[Aufträge]“ wie die übrigen Team-Mails. From 9d82fc579f71351813fd2ef40d3404e2c4131ae1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 12:49:15 +0000 Subject: [PATCH 03/91] =?UTF-8?q?Umsetzungspl=C3=A4ne=20Auftr=C3=A4ge=20un?= =?UTF-8?q?d=20Rechnungen:=20=C3=9Cbersicht=20und=2012=20Strang-Pl=C3=A4ne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../2026-10-06-auftraege-00-uebersicht.md | 10 +- .../2026-10-06-auftraege-01-fundament.md | 2185 +++++++++++ .../2026-10-06-auftraege-02-projektkonto.md | 1770 +++++++++ .../2026-10-06-auftraege-03-rechnungen.md | 2093 ++++++++++ .../plans/2026-10-06-auftraege-04-ab-kern.md | 2447 ++++++++++++ .../plans/2026-10-06-auftraege-05-ab-pdf.md | 1645 ++++++++ .../2026-10-06-auftraege-06-bestaetigung.md | 2199 +++++++++++ .../2026-10-06-auftraege-07-kundenportal.md | 2184 +++++++++++ .../2026-10-06-auftraege-08-rechtstexte.md | 3041 +++++++++++++++ .../2026-10-06-auftraege-09-woms-basis.md | 973 +++++ ...2026-10-06-auftraege-10-woms-rechnungen.md | 1843 +++++++++ .../2026-10-06-auftraege-11-woms-auftraege.md | 1429 +++++++ ...0-06-auftraege-12-integration-kontrolle.md | 3380 +++++++++++++++++ 13 files changed, 25196 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-06-auftraege-01-fundament.md create mode 100644 docs/superpowers/plans/2026-10-06-auftraege-02-projektkonto.md create mode 100644 docs/superpowers/plans/2026-10-06-auftraege-03-rechnungen.md create mode 100644 docs/superpowers/plans/2026-10-06-auftraege-04-ab-kern.md create mode 100644 docs/superpowers/plans/2026-10-06-auftraege-05-ab-pdf.md create mode 100644 docs/superpowers/plans/2026-10-06-auftraege-06-bestaetigung.md create mode 100644 docs/superpowers/plans/2026-10-06-auftraege-07-kundenportal.md create mode 100644 docs/superpowers/plans/2026-10-06-auftraege-08-rechtstexte.md create mode 100644 docs/superpowers/plans/2026-10-06-auftraege-09-woms-basis.md create mode 100644 docs/superpowers/plans/2026-10-06-auftraege-10-woms-rechnungen.md create mode 100644 docs/superpowers/plans/2026-10-06-auftraege-11-woms-auftraege.md create mode 100644 docs/superpowers/plans/2026-10-06-auftraege-12-integration-kontrolle.md diff --git a/docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md b/docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md index 4204879..0476762 100644 --- a/docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md +++ b/docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md @@ -91,7 +91,7 @@ Die Strang-Pläne wurden parallel geschrieben; ihre „Vertragsfragen“ sind hi **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). Stripe-Metadaten wandern beim Ersetzen **nicht** mit (nur `projektRechnungen.auftragId`). +**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. @@ -101,6 +101,10 @@ Die Strang-Pläne wurden parallel geschrieben; ihre „Vertragsfragen“ sind hi **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). --- @@ -129,7 +133,7 @@ Die Strang-Pläne wurden parallel geschrieben; ihre „Vertragsfragen“ sind hi ## Review Focus -1. **Gleichzeitige Aufwandsrechnungen** mit denselben Arbeitsblättern (Doppelklick, zwei Admins): die zweite muss mit 409 „Arbeitsblatt bereits abgerechnet“ scheitern. Test in L2 (zwei parallele `createProjectInvoice` mit gleichen `arbeitsblattIds` unter Sperre `['auftraege-rechnung', kundenId]`). +1. **Gleichzeitige Aufwandsrechnungen** mit denselben Arbeitsblättern (Doppelklick, zwei Admins): die zweite muss mit 409 „Arbeitsblatt bereits abgerechnet“ scheitern. Test in L2 (zwei parallele `createProjectInvoice` mit gleichen `arbeitsblattIds` unter Sperre `sperreRechnung(kundenId)` = `['auftraege-rechnung:']`). 2. **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). 3. **Schlussrechnung neben stornierten oder noch nicht gesendeten Rechnungen**: Stornos zählen nicht, ein offener Entwurf zur AB blockiert mit 409. Tests in L2. 4. **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. @@ -333,7 +337,7 @@ Einbindung (L0): in `server/index.js` **vor** `app.use('/api/admin/finanzen', ad ### V6 Mails (L5) Vorlagen in `server/mail-templates/` mit Kopfkommentar wie die vorhandenen (`renderMail`): -- `K31 Auftragsbestaetigung.html` – Betreff „Ihre Auftragsbestätigung {{nummer}} – {{titel}}“; Platzhalter `name, nummer, titel, datum, betrag, link, gueltig_bis`; Blöcke `erinnerung, privat, festpreis, aufwand, teamkopie`. Anhänge: AB-PDF (`.pdf`) und AGB-PDF (`public/rechtliches/agb-.pdf`, Dateiname `WEBklar-AGB-.pdf`). **Kein** BCC an das Team (`bccTeam: false`), weil die Mail den Bestätigungslink trägt; das Team bekommt eine Kopie mit Block `teamkopie` an `CHAT_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ückfall `AGB_FASSUNG`); fehlt die Datei, wirft der Versand (→ `mailStatus: 'fehler'`). +- `K31 Auftragsbestaetigung.html` – Betreff „Ihre Auftragsbestätigung {{nummer}} – {{titel}}“; Platzhalter `name, nummer, titel, datum, betrag, link, gueltig_bis`; Blöcke `erinnerung, privat, festpreis, aufwand, kundenmail, teamkopie` (`kundenmail` = Gegenblock zu `teamkopie`, weil die Vorlagen nur positive Blöcke kennen; die Teamkopie geht als eine Mail ohne Anhänge über `sendAdminNotice` nach der Kundenmail). Anhänge: AB-PDF (`.pdf`) und AGB-PDF (`public/rechtliches/agb-.pdf`, Dateiname `WEBklar-AGB-.pdf`). **Kein** BCC an das Team (`bccTeam: false`), weil die Mail den Bestätigungslink trägt; das Team bekommt eine Kopie mit Block `teamkopie` an `CHAT_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ückfall `AGB_FASSUNG`); fehlt die Datei, wirft der Versand (→ `mailStatus: 'fehler'`). - `K32 Auftrag bestaetigt.html` – Betreff „Ihre Bestätigung zu {{nummer}} ist eingegangen“; Platzhalter `name, nummer, titel, zeitpunkt, fruehester_beginn, fruehbeginn_text`; Blöcke `fruehbeginn` (gibt `FRUEHBEGINN_TEXT` wörtlich wieder; kein Satz „Ihr Widerrufsrecht bleibt unberührt“), `ohne_fruehbeginn`. - `T22 Auftrag bestaetigt.html` – Betreff „[Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}}“; Platzhalter `kunde, nummer, titel, zeitpunkt, ueber, woms_link, fruehester_beginn`; Blöcke `privat_fruehbeginn, privat_ohne_fruehbeginn`. `woms_link = /customers/?tab=invoices`. - Link der K31: `/auftrag.html#t=` (Fragment: landet nicht in Server- und Proxy-Protokollen; die Seite liest `#t=` und als Rückfall `?t=`). diff --git a/docs/superpowers/plans/2026-10-06-auftraege-01-fundament.md b/docs/superpowers/plans/2026-10-06-auftraege-01-fundament.md new file mode 100644 index 0000000..a703134 --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-auftraege-01-fundament.md @@ -0,0 +1,2185 @@ +# Fundament – Umsetzungsplan (Strang L0) + +> **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:** Das gemeinsame Fundament für „Aufträge und Rechnungen“ im Portal-Server: Abhängigkeiten pdfkit und fontkit (samt `.gitignore`-Eintrag für den `node_modules`-Symlink der Lane-Worktrees), Appwrite-Schema und Setup-Skript, Konfiguration und Pilot-Schalter, Konstanten und Fehlerklasse, Speicher der Auftragsbestätigungen und Projektrechnungen, Arbeitsblatt-Zugriff, Sichten, PDF-Ablage, Einstellung „Steuernummer“, Router-Gerüste samt Einbindung – so dass die Stränge L1–L7 danach parallel darauf aufbauen können. + +**Architecture:** Aller neue Server-Code liegt unter `server/services/auftraege/` in kleinen Modulen mit den Signaturen aus der Übersicht (V1–V7). Appwrite-Zugriffe laufen über `server/services/appwriteAdmin.js`, Tests über die Nachbauten aus `test/support/`. Die fünf Router-Gerüste tragen nur den Zugangsschutz; die Handler füllen später L1, L3 und L5. L0 liegt auf dem kritischen Pfad und committet direkt auf `feature/auftraege`. + +**Tech Stack:** Node 20.19 (Container) und 24 (Host), Express 4, ES-Module, Appwrite-REST über `appwriteAdmin.js`, pdfkit und fontkit (neu, hier nur als Abhängigkeiten), Tests mit `node --test` und `test/support/*`. + +**Spec:** `docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md` (Stand auf `feature/auftraege`, inklusive „Nachträge aus der Planrunde“ am Ende) + +**Übersicht/Verträge:** `docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md` (V1–V8 und der Abschnitt „Nachträge nach der Planrunde“ gelten wörtlich; wo dieser Plan davon abweicht, gilt die Übersicht; alle Vertragsfragen dieses Plans sind dort entschieden, siehe am Ende) + +**Worktree und Branch:** `/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege`, Branch `feature/auftraege` + +## Global Constraints + +Alle Punkte der Übersicht gelten. Zusätze für diesen Strang: + +- Nur die Dateien aus der „Dateiübersicht“ unten anfassen (dazu gehören `package.json`, `package-lock.json` und `.gitignore`). Bestehende Tests ändern: ausschließlich `test/financeSettings.test.js` (Task 12) und `test/support/schema.js` (Task 3). +- Namen, Signaturen und Datenformen exakt wie Übersicht V1–V7 samt „Nachträge nach der Planrunde“ (dort für L0 festgelegt: `sperreAuftrag` und zusammengesetzte Sperrschlüssel, `AUFTRAEGE_DATETIMES`, `entfernePdf`, `fontkit`, `.gitignore`, Steuernummer mit Buchstaben, `texte.kleinunternehmer`, `privat` bei `termine`). +- Wortlaute und Fassungen, die L7 ändert (`texte.js`, `AGB_FASSUNG`), werden in Tests **nie wörtlich** festgenagelt, nur Form und eingesetzte Werte. +- Programmfehler (ungültige Kennungen oder Typen an Speicherfunktionen) sind `TypeError` (die Route macht daraus 500). `AuftragError` mit Status nur für Fälle, die ein Nutzer verstehen soll (404, 409, 400 bei zu langem Text). +- Tests mit Appwrite: `startAppwriteMock({ schema, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })` und `useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })` (diese Tests brauchen kein Stripe). Config und Server-Module erst danach per `await import(...)` laden. +- Tests, die Dateien brauchen, legen ein Verzeichnis per `mkdtempSync(join(tmpdir(), '-'))` an; der Ausführende setzt `TMPDIR` auf sein Scratchpad, damit alles dort landet. Gelöscht wird nur dieses selbst angelegte Verzeichnis (Präfixprüfung vor `rmSync`). Dem Befehl in Task 11 dazu `TMPDIR=` voranstellen. +- Router-Tests prüfen nur, was auch nach dem Füllen der Router durch L1, L3 und L5 gilt (Zugangsschutz, unbekannte Tiefenpfade), damit diese Tests nach den Merges grün bleiben. +- Die Tasks stehen in Abhängigkeitsreihenfolge (Task 7 braucht 2, 3 und 4; Task 9 braucht 8; Task 10 braucht 6); nicht umstellen. +- Jeder Testlauf: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/`. Mehrere Dateien in einem Aufruf nur mit `--test-concurrency=1`. Die volle Suite läuft nicht in L0 (nur Z1). + +## Review Focus + +1. **Arbeitsblatt-Datum:** `TT.MM.JJJJ`, ISO-Zeitpunkt UTC kurz vor und nach Mitternacht Berlin, leeres Datum (Rückgriff auf `$createdAt`), unmögliches Datum („31.02.2026“): immer der richtige Berliner Kalendertag, nie ein Wurf. Test: Task 9, „Datum eines Arbeitsblatts“. +2. **Arbeitsblatt-Zeit:** 10 Minuten werden 15, 80 Minuten plus eine unterstützende Person werden 135, 0 Minuten und `totalTime: 'abc'` ergeben 0; Abo-Zeit, Kommentare und 0 Minuten sind nie abrechenbar. Test: Task 9, „Minuten“ und „abrechenbar“. +3. **Abgerechnet nur aus aktiven Projektrechnungen:** ein stornierter Eintrag gibt seine Blätter frei, ein Entwurf sperrt sie (Rechnungsnummer leer), fremde Kunden bleiben unberührt. Tests: Task 8 (aktiv-Filter, `umhaengen`), Task 9 (`abgerechneteArbeitsblaetter`, `offeneArbeitsblaetter`). +4. **Leere oder ungültige Schlüssel treffen nie „alles“ oder fremde Dateien:** `findAuftragByTokenHash('')`, `findAuftragByNummer('')` (Entwürfe haben beides leer), Listenfilter mit leerem Kunden, Rechnungs-IDs wie `../x`, PDF-Pfade wie `c1/../geheim.pdf`, Überschreiben einer vorhandenen AB-Datei. Tests: Task 7, Task 8, Task 11. +5. **Pilot-Schalter:** `*` lässt alle durch, `off` schlägt `*`, Groß- und Kleinschreibung und Leerraum der E-Mail-Adresse, leere Liste lässt niemanden durch. Test: Task 4. + +## Dateiübersicht + +| Datei | Aufgabe | Task | +|---|---|---| +| `package.json`, `package-lock.json`, `.gitignore` | pdfkit, fontkit, `node_modules` auch als Symlink ignorieren | 1 | +| `server/services/auftraege/konstanten.js` | Konstanten (V2) | 2 | +| `server/services/auftraege/fehler.js` | `AuftragError`, `sendeFehler` | 2 | +| `server/services/auftraege/schema.js` | Collections und Zusatzfelder (V1) | 3 | +| `test/support/schema.js` | `schemaKeys` kennt die neuen Collections und die Steuernummer | 3 | +| `server/config.js` | `collections.auftraege`, `collections.projektRechnungen`, `config.auftraege` | 4 | +| `server/services/auftraege/schalter.js` | `auftraegeAktiv` | 4 | +| `scripts/auftraege-setup.mjs` | Setup der Collections und Zusatzfelder | 5 | +| `server/services/auftraege/texte.js` | vorläufige Textbausteine (V7) | 6 | +| `server/services/auftraege/auftragStore.js` | Speicher der ABs | 7 | +| `server/services/auftraege/projektRechnungenStore.js` | Speicher der Projektrechnungen | 8 | +| `server/services/auftraege/arbeitsblaetter.js` | Arbeitsblätter laden und abgleichen | 9 | +| `server/services/auftraege/auftragSicht.js` | Sichten auf eine AB | 10 | +| `server/services/auftraege/ablage.js` | PDF-Ablage | 11 | +| `server/services/finance/settings.js`, `test/financeSettings.test.js` | Einstellung `steuernummer` | 12 | +| `server/routes/admin/projektkonten.js`, `server/routes/admin/auftraege.js`, `server/routes/team/arbeitsblaetter.js`, `server/routes/auftrag.js`, `server/routes/auftraegeKunde.js`, `server/index.js`, `server/routes/team/index.js` | Router-Gerüste und Einbindung | 13 | +| `test/auftraege.test.js` (elf Dateien, je Task genannt) | Tests | 2–13 | + +--- + +### Task 1: Arbeitsumgebung, pdfkit, fontkit und .gitignore + +**Files:** +- Modify: `package.json`, `package-lock.json` (nur pdfkit, fontkit und deren neue Abhängigkeiten kommen dazu), `.gitignore` (Zeile `node_modules/` wird `node_modules`) +- Anlegen, unversioniert (steht in `.gitignore`): `node_modules/` als **echtes Verzeichnis** + +**Interfaces:** +- Produces: echtes `node_modules` im Worktree (die Lane-Worktrees verlinken es später mit `ln -s ../dev-auftraege/node_modules node_modules`); `.gitignore` ignoriert `node_modules` ohne Schrägstrich, also auch diesen Symlink (mit Schrägstrich träfe die Regel nur Verzeichnisse und der Symlink stünde in jedem `git status`); Abhängigkeit `pdfkit` (`import PDFDocument from 'pdfkit'` liefert eine Funktion, für L4); Abhängigkeit `fontkit` direkt in `package.json`, mit derselben Versionsangabe, die pdfkit für `fontkit` verlangt (L4 prüft damit die Glyphen und soll sich nicht auf eine mitinstallierte Abhängigkeit verlassen). + +- [ ] **Step 1: Arbeitsspeicher prüfen** + +Run: `free -m` +Expected: „available“ mindestens 1500 MB; darunter warten und erneut prüfen, kein `npm ci` vorher. + +- [ ] **Step 2: node_modules anlegen** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock nice -n 10 env NODE_OPTIONS=--max-old-space-size=1024 npm ci --no-audit --no-fund +``` + +Expected: Exit 0. Danach `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && test -d node_modules && ! test -L node_modules && echo echt` gibt `echt` aus, und `git status --short` zeigt nichts außer Plan- und Spezifikationsdateien unter `docs/superpowers/` (unversioniert oder vom Orchestrator geändert; sie gehören nicht zu L0). Scheitert `npm ci`, nicht auf `npm install` ausweichen, sondern dem Orchestrator melden. + +- [ ] **Step 3: Versionen bestimmen** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && npm view pdfkit version engines dependencies +``` + +Regel für pdfkit: Die ausgegebene `version` ist die neueste. Sie wird genommen, wenn `engines.node` fehlt oder Node 20.19 zulässt und die Abhängigkeiten reines JavaScript sind (kein `node-gyp`, keine nativen Pakete). Sonst die neueste ältere stabile Version mit dieser Eigenschaft wählen (`npm view pdfkit versions --json`) und für diese Version `npm view pdfkit@ dependencies` lesen. Keine Vorabversionen. Die gewählte Version heißt unten ``. Regel für fontkit: `` ist die Versionsangabe, die in `dependencies` von pdfkit bei `fontkit` steht (z. B. `^2.0.4`), unverändert. Fehlt `fontkit` dort, nichts raten, sondern dem Orchestrator melden. + +- [ ] **Step 4: pdfkit und fontkit eintragen** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock nice -n 10 env NODE_OPTIONS=--max-old-space-size=1024 npm install pdfkit@ fontkit@ --save --no-audit --no-fund +``` + +- [ ] **Step 5: .gitignore ändern** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && sed -i 's#^node_modules/$#node_modules#' .gitignore && git diff .gitignore +``` + +Expected: genau eine Zeile geändert, `-node_modules/` und `+node_modules` (Zeile 1; alle anderen Zeilen unverändert). + +- [ ] **Step 6: Prüfen** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && node -e "import('pdfkit').then(m=>console.log(typeof m.default))" +``` + +Expected: `function`. + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && node -e "import('fontkit').then(m=>console.log(typeof (m.create || m.default?.create)))" +``` + +Expected: `function`. + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git check-ignore -v node_modules; echo "exit=$?"; git status --short; git diff --stat; git diff package.json; git diff --numstat package-lock.json +``` + +Expected: `git check-ignore -v node_modules` nennt `.gitignore:1:node_modules` und endet mit `exit=0`; `git status --short` zeigt `node_modules` nicht (nur Plan- und Spezifikationsdateien unter `docs/superpowers/`) und sonst nur die drei geänderten Dateien `.gitignore`, `package.json`, `package-lock.json`; in `package.json` genau zwei neue Zeilen in `dependencies`: `"fontkit": ""` (bei einer exakten Version in pdfkit setzt npm ein `^` davor; zwischen `feiertagejs` und `jose`) und `"pdfkit": "^"` (zwischen `nodemailer` und `sharp`); in `package-lock.json` steht in der zweiten Spalte der numstat `0` (keine entfernten Zeilen). Weicht der Diff ab, den Diff lesen: erlaubt sind nur Einträge von pdfkit, fontkit und deren neuen Abhängigkeiten; alles andere zurücknehmen (`git checkout package-lock.json package.json`, Schritt 4 wiederholen) und dem Orchestrator melden. + +- [ ] **Step 7: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add package.json package-lock.json .gitignore && git commit -m "Aufträge: pdfkit und fontkit als Abhängigkeiten, node_modules auch als Symlink ignorieren" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Konstanten und Fehlerklasse + +**Files:** +- Create: `server/services/auftraege/konstanten.js`, `server/services/auftraege/fehler.js` +- Test: `test/auftraegeKonstanten.test.js` + +**Interfaces:** +- Consumes: `InvoiceError` aus `server/services/finance/stripeFinance.js` (nur in `sendeFehler`). +- Produces: alle Exporte aus Übersicht V2 (`RECHNUNGS_ARTEN`, `AB_RECHNUNGS_ARTEN`, `AUFTRAG_ARTEN`, `AUFTRAG_STATUS`, `VEREINBARTE_STATUS`, `AUFTRAG_WEGE`, `ZAHLUNGSPLAN_ARTEN`, `KUNDENARTEN`, `STUNDENSATZ_STANDARD_CENTS`, `VIERTELSTUNDE`, `TOKEN_GUELTIG_TAGE`, `AGB_FASSUNG`, `LIMITS`, `SPERRE_NUMMER = ['auftraege-nummer']`, `sperreRechnung(kundenId) → ['auftraege-rechnung:']`, `sperreAuftrag(auftragId) → ['auftraege-ab:']`, `SCHALTER_AUS_MELDUNG`); jede Sperre ist ein Array mit genau einem zusammengesetzten Schlüssel, weil `withLocks` jedes Listenelement einzeln sperrt; `class AuftragError extends Error { constructor(status, message) }` (Eigenschaft `status`); `sendeFehler(res, was, err)`. + +- [ ] **Step 1: Failing Test schreiben** + +```js +// test/auftraegeKonstanten.test.js +import { test, mock } from 'node:test' +import assert from 'node:assert/strict' +import * as K from '../server/services/auftraege/konstanten.js' +import { AuftragError, sendeFehler } from '../server/services/auftraege/fehler.js' +import { InvoiceError } from '../server/services/finance/stripeFinance.js' + +test('Konstanten stehen wie in Übersicht V2', () => { + assert.deepEqual(K.RECHNUNGS_ARTEN, ['frei', 'festbetrag', 'aufwand', 'anzahlung', 'schluss']) + assert.deepEqual(K.AB_RECHNUNGS_ARTEN, ['anzahlung', 'schluss']) + assert.deepEqual(K.AUFTRAG_ARTEN, ['festpreis', 'aufwand']) + assert.deepEqual(K.AUFTRAG_STATUS, ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen']) + assert.deepEqual(K.VEREINBARTE_STATUS, ['versendet', 'bestaetigt', 'abgerechnet']) + assert.deepEqual(K.AUFTRAG_WEGE, { muendlich: 'mündlich', telefon: 'telefonisch', email: 'per E-Mail', vor_ort: 'vor Ort', chat: 'im Chat' }) + assert.deepEqual(K.ZAHLUNGSPLAN_ARTEN, ['nach_abnahme', 'anzahlung', 'monatlich', 'frei']) + assert.deepEqual(K.KUNDENARTEN, ['unternehmen', 'privat']) + assert.equal(K.STUNDENSATZ_STANDARD_CENTS, 10000) + assert.equal(K.VIERTELSTUNDE, 15) + assert.equal(K.TOKEN_GUELTIG_TAGE, 60) + assert.match(K.AGB_FASSUNG, /^\d{4}-\d{2}-\d{2}$/) // den Wert legt L7 fest + assert.deepEqual(K.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: je Sperre genau ein zusammengesetzter Schlüssel (verschiedene Kunden und ABs blockieren sich nicht) + assert.deepEqual(K.SPERRE_NUMMER, ['auftraege-nummer']) + assert.deepEqual(K.sperreRechnung('c1'), ['auftraege-rechnung:c1']) + assert.deepEqual(K.sperreAuftrag('ab1'), ['auftraege-ab:ab1']) + assert.equal(K.SCHALTER_AUS_MELDUNG, 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.') + for (const status of K.VEREINBARTE_STATUS) assert.ok(K.AUFTRAG_STATUS.includes(status), status) + for (const art of K.AB_RECHNUNGS_ARTEN) assert.ok(K.RECHNUNGS_ARTEN.includes(art), art) +}) + +const fakeRes = () => ({ + statusCode: 200, + body: undefined, + status(code) { + this.statusCode = code + return this + }, + json(body) { + this.body = body + return this + }, +}) + +test('AuftragError trägt Status und Meldung; ein Status außerhalb 400 bis 599 ist ein Programmfehler', () => { + const err = new AuftragError(409, 'Arbeitsblatt bereits abgerechnet (WEBKLAR-0009).') + assert.ok(err instanceof Error) + assert.equal(err.name, 'AuftragError') + assert.equal(err.status, 409) + assert.equal(err.message, 'Arbeitsblatt bereits abgerechnet (WEBKLAR-0009).') + for (const status of [200, 399, 600, 404.5, '404', undefined, null]) { + assert.throws(() => new AuftragError(status, 'x'), TypeError, String(status)) + } +}) + +test('sendeFehler gibt AuftragError und InvoiceError mit Status und Meldung weiter', () => { + const a = fakeRes() + sendeFehler(a, 'AB senden', new AuftragError(400, 'Kundenart fehlt.')) + assert.deepEqual([a.statusCode, a.body], [400, { error: 'Kundenart fehlt.' }]) + const b = fakeRes() + sendeFehler(b, 'Rechnung anlegen', new InvoiceError(502, 'Stripe ist gerade nicht erreichbar.')) + assert.deepEqual([b.statusCode, b.body], [502, { error: 'Stripe ist gerade nicht erreichbar.' }]) +}) + +test('sendeFehler: alles andere wird 500 „Interner Fehler.“, das Protokoll nennt nie die Meldung', () => { + const log = mock.method(console, 'error', () => {}) + try { + const appwrite = Object.assign(new Error('Appwrite: Key sk_live_geheim abgelehnt'), { status: 400, type: 'user_unauthorized' }) + for (const err of [appwrite, 'sk_live_geheim', undefined, null]) { + const res = fakeRes() + sendeFehler(res, 'Projektkonto laden', err) + assert.deepEqual([res.statusCode, res.body], [500, { error: 'Interner Fehler.' }]) + } + assert.equal(log.mock.calls.length, 4) + const zeilen = JSON.stringify(log.mock.calls.map((call) => call.arguments)) + assert.ok(!zeilen.includes('geheim'), zeilen) + assert.ok(zeilen.includes('Projektkonto laden') && zeilen.includes('user_unauthorized'), zeilen) + } finally { + log.mock.restore() + } +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeKonstanten.test.js` +Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `server/services/auftraege/konstanten.js`. + +- [ ] **Step 3: Implementieren** + +- `server/services/auftraege/konstanten.js` (neu, ohne Importe): genau die Exporte des Blocks „V2 Konstanten“ der Übersicht, mit denselben Namen, Werten und Reihenfolge, wörtlich übernommen (einschließlich der Sperren `SPERRE_NUMMER`, `sperreRechnung`, `sperreAuftrag` mit je einem zusammengesetzten Schlüssel und deren Kommentaren). `AGB_FASSUNG` steht auf einer eigenen Zeile mit dem Kommentar `// L7 darf diese eine Zeile anpassen`. Kopfkommentar: gemeinsame Datei, nur L0 ändert sie (Ausnahme `AGB_FASSUNG`). +- `server/services/auftraege/fehler.js` (neu): + - `export class AuftragError extends Error`, `constructor(status, message)`: `status` muss eine ganze Zahl von 400 bis 599 sein, sonst `TypeError`; setzt `this.name = 'AuftragError'` und `this.status`. + - `export function sendeFehler(res, was, err)`: ist `err` eine `AuftragError` oder `InvoiceError` (Import aus `../finance/stripeFinance.js`, nur **innerhalb** der Funktion benutzen, nie beim Laden des Moduls; `stripeFinance.js` importiert ab L2 selbst Module dieses Verzeichnisses, so bleibt der Importzyklus harmlos), dann `res.status(err.status).json({ error: err.message })`. Alles andere, auch Fehler mit `status` (Appwrite, Stripe), wird mit `console.error('[auftraege] fehlgeschlagen:', err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '')` protokolliert (nie mit der Meldung) und als `res.status(500).json({ error: 'Interner Fehler.' })` beantwortet. + +- [ ] **Step 4: Lauf, erwartet PASS** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeKonstanten.test.js` +Expected: PASS (alle vier Tests, `fail 0`). + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/konstanten.js server/services/auftraege/fehler.js test/auftraegeKonstanten.test.js && git commit -m "Aufträge: Konstanten und Fehlerklasse (L0)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Appwrite-Schema und Test-Support + +**Files:** +- Create: `server/services/auftraege/schema.js` +- Modify: `test/support/schema.js` +- Test: `test/auftraegeSchema.test.js` + +**Interfaces:** +- Produces: `AUFTRAEGE_COLLECTIONS` (`{ auftraege, projektRechnungen }`, je `{ attributes, indexes }`) und `AUFTRAEGE_ZUSATZ_ATTRIBUTE` (`{ customers: [kundenart], financeSettings: [steuernummer] }`) wörtlich wie Übersicht V1; zusätzlich `AUFTRAEGE_DATETIMES` (`{ collection: [datetime-Schlüssel] }`, wie `ZEITEN_DATETIMES`, für `startAppwriteMock({ datetimes })`). `schemaKeys('auftraege')`, `schemaKeys('projektRechnungen')` und `schemaKeys('financeSettings')` (jetzt mit `steuernummer`) in `test/support/schema.js`. + +- [ ] **Step 1: Failing Test schreiben** + +```js +// test/auftraegeSchema.test.js +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { AUFTRAEGE_COLLECTIONS, AUFTRAEGE_DATETIMES, AUFTRAEGE_ZUSATZ_ATTRIBUTE } from '../server/services/auftraege/schema.js' +import { schemaKeys } from './support/schema.js' + +// Spalten aus Übersicht V1 in Kurzschrift: sN = Text mit Größe N, i = ganze Zahl, b = Wahrheitswert, d = Datum und Zeit +const SPALTEN = { + auftraege: `nummer:s20 jahr:i laufnummer:i status:s20 kundenId:s64 kundeJson:s2000 projektId:s64 titel:s200 + zeitquelleJson:s1000 art:s20 positionenJson:s20000 stundensatzCents:i schaetzungMinuten:i summeCents:i + zahlungsplanJson:s1000 zahlungszielTage:i auftragVom:s10 auftragWeg:s20 startAm:s10 fertigBis:s10 + texteFassung:s20 agbFassung:s20 pdfDatei:s300 pdfSha256:s64 gesendetAm:d gesendetAn:s255 mailStatus:s20 + mailFehler:s500 tokenHash:s64 tokenGueltigBis:d bestaetigtAm:d bestaetigtUeber:s10 bestaetigtPdfSha256:s64 + fruehbeginn:b fruehbeginnFassung:s20 ersetztId:s64 ersetztDurchId:s64 storniertAm:d widerrufenAm:d + erstelltVon:s255 erstelltAm:d geaendertAm:d`, + projektRechnungen: `invoiceId:s64 kundenId:s64 projektId:s64 auftragId:s64 art:s20 betragCents:i + arbeitsblattIdsJson:s20000 rechnungsnummer:s64 aktiv:b erstelltVon:s255 erstelltAm:d`, +} +function attribut(kurz) { + const [key, art] = kurz.split(':') + if (art === 'i') return { key, type: 'integer', required: false } + if (art === 'b') return { key, type: 'boolean', required: false } + if (art === 'd') return { key, type: 'datetime', required: false } + return { key, type: 'string', size: Number(art.slice(1)), required: false } +} +const erwartet = (name) => SPALTEN[name].trim().split(/\s+/).map(attribut) + +test('zwei Collections mit den Spalten aus Übersicht V1', () => { + assert.deepEqual(Object.keys(AUFTRAEGE_COLLECTIONS), ['auftraege', 'projektRechnungen']) + assert.deepEqual(AUFTRAEGE_COLLECTIONS.auftraege.attributes, erwartet('auftraege')) + assert.deepEqual(AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes, erwartet('projektRechnungen')) + assert.equal(AUFTRAEGE_COLLECTIONS.auftraege.attributes.length, 42) + assert.equal(AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes.length, 11) +}) + +test('Indizes und Zusatzfelder wie in Übersicht V1', () => { + assert.deepEqual(AUFTRAEGE_COLLECTIONS.auftraege.indexes, [ + { key: 'idx_kunde', type: 'key', attributes: ['kundenId'] }, + { key: 'idx_projekt', type: 'key', attributes: ['projektId'] }, + { key: 'idx_jahr', type: 'key', attributes: ['jahr'] }, + { key: 'idx_token', type: 'key', attributes: ['tokenHash'] }, + { key: 'idx_nummer', type: 'key', attributes: ['nummer'] }, + ]) + assert.deepEqual(AUFTRAEGE_COLLECTIONS.projektRechnungen.indexes, [ + { key: 'idx_kunde', type: 'key', attributes: ['kundenId'] }, + { key: 'idx_projekt', type: 'key', attributes: ['projektId'] }, + { key: 'idx_auftrag', type: 'key', attributes: ['auftragId'] }, + ]) + assert.deepEqual(AUFTRAEGE_ZUSATZ_ATTRIBUTE, { + customers: [{ key: 'kundenart', type: 'string', size: 20, required: false }], + financeSettings: [{ key: 'steuernummer', type: 'string', size: 40, required: false }], + }) +}) + +test('AUFTRAEGE_DATETIMES nennt je Collection genau die Datumsfelder', () => { + assert.deepEqual(AUFTRAEGE_DATETIMES, { + auftraege: ['gesendetAm', 'tokenGueltigBis', 'bestaetigtAm', 'storniertAm', 'widerrufenAm', 'erstelltAm', 'geaendertAm'], + projektRechnungen: ['erstelltAm'], + }) +}) + +test('schemaKeys kennt die neuen Collections und die Steuernummer; Bestehendes bleibt', () => { + assert.deepEqual(schemaKeys('auftraege'), AUFTRAEGE_COLLECTIONS.auftraege.attributes.map((a) => a.key)) + assert.deepEqual(schemaKeys('projektRechnungen'), AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes.map((a) => a.key)) + const einstellungen = schemaKeys('financeSettings') + for (const key of ['steuernummer', 'bankDetailsText', 'taxReservePercent']) assert.ok(einstellungen.includes(key), key) + assert.ok(!schemaKeys('financeFixedCosts').includes('steuernummer')) + assert.ok(schemaKeys('portalFeatures').includes('unlockedBy') && schemaKeys('termineConfig').includes('einstellung')) + assert.throws(() => schemaKeys('gibtEsNicht'), /fehlt im Setup-Skript/) +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeSchema.test.js` +Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `server/services/auftraege/schema.js`. + +- [ ] **Step 3: Implementieren** + +- `server/services/auftraege/schema.js` (neu): reine Daten, **kein** Import von `server/config.js` (Begründung im Kopfkommentar wie in `server/services/zeiten/schema.js`: `test/support/schema.js` liest die Datei vor `useTestEnv()`). Modulprivate Helfer `str(key, size)`, `int(key)`, `bool(key)`, `dt(key)`, `index(key, ...attrs)` wie dort (alle Attribute `required: false`). Exporte `AUFTRAEGE_COLLECTIONS` und `AUFTRAEGE_ZUSATZ_ATTRIBUTE` wörtlich aus Übersicht V1. Dazu `export const AUFTRAEGE_DATETIMES = Object.fromEntries(Object.entries(AUFTRAEGE_COLLECTIONS).map(([name, { attributes }]) => [name, attributes.filter((a) => a.type === 'datetime').map((a) => a.key)]))`. Kommentar an `idx_nummer`: bewusst kein Unique-Index, weil Entwürfe `nummer: ''` haben; die Eindeutigkeit stellt `vergibNummer` unter `SPERRE_NUMMER` her (L3). +- `test/support/schema.js` (drei Änderungen): (1) Import von `AUFTRAEGE_COLLECTIONS` und `AUFTRAEGE_ZUSATZ_ATTRIBUTE` aus `../../server/services/auftraege/schema.js`; (2) am Anfang von `schemaKeys`: `if (Object.hasOwn(AUFTRAEGE_COLLECTIONS, collection)) return AUFTRAEGE_COLLECTIONS[collection].attributes.map((a) => a.key)`; (3) in der Schleife über `SETUP_SCRIPTS` für jede Collection außer `portalFeatures` die Schlüssel aus `AUFTRAEGE_ZUSATZ_ATTRIBUTE` anhängen (nur wenn `Object.hasOwn(AUFTRAEGE_ZUSATZ_ATTRIBUTE, collection)`; das trifft `financeSettings`). Den Kommentar über der Funktion um die beiden Quellen ergänzen. `schemaKeys('customers')` bleibt ohne Schema (wie bisher wirft es): Tests mit `customers` starten den Nachbau ohne Schema für diese Collection. + +- [ ] **Step 4: Lauf, erwartet PASS** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/auftraegeSchema.test.js test/zeitenSetup.test.js test/financeSetup.test.js` +Expected: PASS (`fail 0`); die beiden bestehenden Dateien belegen, dass `schemaKeys` für Zeiten und Finanzen unverändert arbeitet. + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/schema.js test/support/schema.js test/auftraegeSchema.test.js && git commit -m "Aufträge: Appwrite-Schema der Collections auftraege und projektRechnungen" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Konfiguration und Pilot-Schalter + +**Files:** +- Modify: `server/config.js` +- Create: `server/services/auftraege/schalter.js` +- Test: `test/auftraegeSchalter.test.js` + +**Interfaces:** +- Produces: `config.collections.auftraege` (`APPWRITE_COLLECTION_AUFTRAEGE`, Vorgabe `'auftraege'`), `config.collections.projektRechnungen` (`APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN`, Vorgabe `'projektRechnungen'`), `config.auftraege = { mode, pilotEmails, uploadDir }`; `auftraegeAktiv(kunde) → boolean` (`kunde`: Kundendokument oder `{ email }`). + +- [ ] **Step 1: Failing Test schreiben** + +```js +// test/auftraegeSchalter.test.js +import { test, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import path from 'node:path' +import { config } from '../server/config.js' +import { auftraegeAktiv } from '../server/services/auftraege/schalter.js' + +const ENV = ['AUFTRAEGE_ENABLED', 'AUFTRAEGE_PILOT_EMAILS', 'AUFTRAEGE_UPLOAD_DIR', 'APPWRITE_COLLECTION_AUFTRAEGE', 'APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN'] + +let original +beforeEach(() => { + original = { ...config.auftraege } +}) +afterEach(() => { + Object.assign(config.auftraege, original) +}) + +/** Frische config.js-Instanz mit genau diesen Umgebungsvariablen (alle anderen entfernt). */ +let freshCounter = 0 +async function freshConfig(env = {}) { + const saved = Object.fromEntries(ENV.map((key) => [key, process.env[key]])) + for (const key of ENV) delete process.env[key] + Object.assign(process.env, env) + try { + freshCounter += 1 + return (await import(`../server/config.js?auftraege-test=${freshCounter}`)).config + } finally { + for (const key of ENV) { + if (saved[key] === undefined) delete process.env[key] + else process.env[key] = saved[key] + } + } +} + +test('Vorgabe: aus, keine Pilotadressen, Ablage unter upload-data/auftraege, Collection-IDs wie die Namen', async () => { + const fresh = await freshConfig() + assert.deepEqual(fresh.auftraege, { mode: 'off', pilotEmails: [], uploadDir: path.join(process.cwd(), 'upload-data', 'auftraege') }) + assert.equal(fresh.collections.auftraege, 'auftraege') + assert.equal(fresh.collections.projektRechnungen, 'projektRechnungen') +}) + +test('AUFTRAEGE_ENABLED: leer und Unbekanntes sind off, " ON " ist on, " Pilot " ist pilot mit bereinigter Liste', async () => { + for (const value of ['', ' ', 'banane', 'true', '1']) { + assert.equal((await freshConfig({ AUFTRAEGE_ENABLED: value })).auftraege.mode, 'off', JSON.stringify(value)) + } + assert.equal((await freshConfig({ AUFTRAEGE_ENABLED: ' ON ' })).auftraege.mode, 'on') + const fresh = await freshConfig({ AUFTRAEGE_ENABLED: ' Pilot ', AUFTRAEGE_PILOT_EMAILS: ' Kenso.Gri@Gmail.com, ,justin@webklar.com ' }) + assert.equal(fresh.auftraege.mode, 'pilot') + assert.deepEqual(fresh.auftraege.pilotEmails, ['kenso.gri@gmail.com', 'justin@webklar.com']) +}) + +test('Ablage und Collection-IDs lassen sich per Umgebung ändern', async () => { + const fresh = await freshConfig({ + AUFTRAEGE_UPLOAD_DIR: '/tmp/ab-test', + APPWRITE_COLLECTION_AUFTRAEGE: 'abX', + APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN: 'prX', + }) + assert.equal(fresh.auftraege.uploadDir, '/tmp/ab-test') + assert.deepEqual([fresh.collections.auftraege, fresh.collections.projektRechnungen], ['abX', 'prX']) +}) + +test('off sperrt alle, auch mit Stern in der Liste; ein unbekannter Modus gilt als off', () => { + config.auftraege.mode = 'off' + config.auftraege.pilotEmails = ['*', 'kenso.gri@gmail.com'] + assert.equal(auftraegeAktiv({ email: 'kenso.gri@gmail.com' }), false) + assert.equal(auftraegeAktiv({ email: 'jemand@example.com' }), false) + for (const value of [undefined, null, '', 'banane', 'PILOT']) { + config.auftraege.mode = value + assert.equal(auftraegeAktiv({ email: 'kenso.gri@gmail.com' }), false, String(value)) + } +}) + +test('pilot: nur Adressen der Liste, ohne Beachtung von Groß- und Kleinschreibung und Leerraum', () => { + config.auftraege.mode = 'pilot' + config.auftraege.pilotEmails = ['a@b.de'] + assert.equal(auftraegeAktiv({ email: 'A@b.de' }), true) + assert.equal(auftraegeAktiv({ email: ' a@b.de ' }), true) + assert.equal(auftraegeAktiv({ email: 'x@y.de' }), false) + assert.equal(auftraegeAktiv({ email: 'a@b.de.evil.de' }), false) + for (const kunde of [undefined, null, {}, { email: '' }, { email: ' ' }]) assert.equal(auftraegeAktiv(kunde), false) +}) + +test('pilot mit Stern lässt alle durch, auch ohne Adresse; eine leere Liste niemanden', () => { + config.auftraege.mode = 'pilot' + config.auftraege.pilotEmails = ['*'] + assert.equal(auftraegeAktiv({ email: 'x@y.de' }), true) + assert.equal(auftraegeAktiv(null), true) + config.auftraege.pilotEmails = [] + assert.equal(auftraegeAktiv({ email: 'a@b.de' }), false) +}) + +test('on gilt für alle; ein Kundendokument zählt wie { email }', () => { + config.auftraege.mode = 'on' + config.auftraege.pilotEmails = [] + assert.equal(auftraegeAktiv({ email: 'x@y.de' }), true) + assert.equal(auftraegeAktiv(undefined), true) + config.auftraege.mode = 'pilot' + config.auftraege.pilotEmails = ['k@example.invalid'] + assert.equal(auftraegeAktiv({ $id: 'c1', email: 'k@example.invalid', name: 'Kunde Eins' }), true) +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeSchalter.test.js` +Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `server/services/auftraege/schalter.js`. + +- [ ] **Step 3: Implementieren** + +- `server/config.js`, drei Einfügungen (neben den Zeiten-Gegenstücken): + 1. nach `const zeitenEnabled = ...`: `const auftraegeEnabled = (process.env.AUFTRAEGE_ENABLED || '').trim().toLowerCase()` + 2. in `collections` nach `zeitenErinnerungen`, mit Kommentar „Aufträge und Rechnungen (Auftragsbestätigungen, Projektrechnungen; scripts/auftraege-setup.mjs)“: + ```js + auftraege: process.env.APPWRITE_COLLECTION_AUFTRAEGE || 'auftraege', + projektRechnungen: process.env.APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN || 'projektRechnungen', + ``` + 3. nach dem Block `zeiten: { ... },` und vor `invoiceNinja`, mit Kommentar „Aufträge und Rechnungen: Schalter nur für die AB-Funktionen und die Rechnungsarten anzahlung/schluss; wird bei jedem Aufruf gelesen, siehe server/services/auftraege/schalter.js“: + ```js + auftraege: { + // off (Vorgabe) | pilot (nur AUFTRAEGE_PILOT_EMAILS, '*' = alle) | on + mode: ['pilot', 'on'].includes(auftraegeEnabled) ? auftraegeEnabled : 'off', + pilotEmails: commaList(process.env.AUFTRAEGE_PILOT_EMAILS, []), + // Gesendete AB-PDFs: //.pdf + uploadDir: process.env.AUFTRAEGE_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'auftraege'), + }, + ``` +- `server/services/auftraege/schalter.js` (neu): importiert `config` aus `../../config.js`, exportiert nur `auftraegeAktiv(kunde)`. Regeln wie `zeitenFuerKunde` in `server/services/zeiten/schalter.js`, gelesen wird `config.auftraege` bei jedem Aufruf: Modus `on` → `true`; Modus `pilot` → `true`, wenn die Liste `'*'` enthält, sonst wenn die kleingeschriebene, getrimmte E-Mail-Adresse (`kunde?.email`) nicht leer ist und in der Liste steht; jeder andere Modus (auch ein unbekannter Wert) → `false`. Der Kopfkommentar nennt: betrifft nur die AB-Funktionen und die Rechnungsarten `anzahlung`/`schluss`. + +- [ ] **Step 4: Lauf, erwartet PASS** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/auftraegeSchalter.test.js test/zeitenSchalter.test.js` +Expected: PASS (`fail 0`); `zeitenSchalter` belegt, dass die Änderung an `config.js` nichts Bestehendes verschiebt. + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/config.js server/services/auftraege/schalter.js test/auftraegeSchalter.test.js && git commit -m "Aufträge: Konfiguration und Pilot-Schalter AUFTRAEGE_ENABLED" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: Setup-Skript + +**Files:** +- Create: `scripts/auftraege-setup.mjs` +- Test: `test/auftraegeSetup.test.js` + +**Interfaces:** +- Consumes: `AUFTRAEGE_COLLECTIONS`, `AUFTRAEGE_ZUSATZ_ATTRIBUTE` (Task 3); `config.collections.{auftraege, projektRechnungen, customers, financeSettings}` (Task 4); `createAppwriteClient` aus `scripts/analyse-setup.mjs`. +- Produces: `main({ check = false, client, log = console.log } = {}) → Promise<{ missing, missingCollections, missingIndexes }>` mit Eintragsformen wie in `scripts/zeiten-setup.mjs`. + +- [ ] **Step 1: Failing Test schreiben** + +```js +// test/auftraegeSetup.test.js +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { config } from '../server/config.js' +import { AUFTRAEGE_COLLECTIONS } from '../server/services/auftraege/schema.js' +import { main } from '../scripts/auftraege-setup.mjs' + +// Geprüft wird nur mit dem Client mit Zustand unten (Muster: test/zeitenSetup.test.js), nie gegen Appwrite. +const NAMES = ['auftraege', 'projektRechnungen'] +const ids = NAMES.map((name) => config.collections[name]) +const customersId = config.collections.customers +const settingsId = config.collections.financeSettings +const keysOf = (name) => AUFTRAEGE_COLLECTIONS[name].attributes.map((a) => a.key) +const quiet = () => {} +const leer = () => ({ collections: [], attrs: [], indexes: [] }) + +/** Client mit Zustand: Attribute gibt es nur in vorhandenen Collections, Indizes nur auf vorhandenen Attributen. */ +function fakeClient(initial = {}) { + const start = { + [customersId]: { attrs: ['name', 'email'] }, + [settingsId]: { attrs: ['minBufferCents', 'bankDetailsText'] }, + ...initial, + } + const state = new Map( + Object.entries(start).map(([id, c]) => [id, { attrs: new Map((c.attrs || []).map((key) => [key, null])), indexes: new Set(c.indexes || []) }]), + ) + const calls = leer() + const need = (id) => { + if (!state.has(id)) throw Object.assign(new Error(`Collection ${id} fehlt`), { status: 404 }) + return state.get(id) + } + return { + state, + calls, + async collectionExists(id) { + return state.has(id) + }, + async ensureCollection(id, name) { + if (state.has(id)) return false + state.set(id, { attrs: new Map(), indexes: new Set() }) + calls.collections.push({ id, name }) + return true + }, + async listAttributes(id) { + return [...need(id).attrs.keys()].map((key) => ({ key })) + }, + async createAttribute(id, attr) { + const collection = need(id) + if (collection.attrs.has(attr.key)) throw new Error(`Attribut ${id}.${attr.key} existiert schon`) + collection.attrs.set(attr.key, attr) + calls.attrs.push(`${id}.${attr.key}`) + }, + async waitForAttribute(id, key) { + assert.ok(need(id).attrs.has(key), `wartet auf nie angelegtes Attribut ${id}.${key}`) + }, + async listIndexes(id) { + return [...need(id).indexes].map((key) => ({ key })) + }, + async createIndex(id, index) { + const collection = need(id) + for (const key of index.attributes) { + if (!collection.attrs.has(key)) throw new Error(`Index ${index.key}: Attribut ${key} fehlt in ${id}`) + } + collection.indexes.add(index.key) + calls.indexes.push(`${id}.${index.key}`) + }, + } +} + +const expectedAttrs = [ + ...NAMES.flatMap((name) => keysOf(name).map((key) => `${config.collections[name]}.${key}`)), + `${customersId}.kundenart`, + `${settingsId}.steuernummer`, +] +const expectedIndexes = NAMES.flatMap((name) => AUFTRAEGE_COLLECTIONS[name].indexes.map((i) => `${config.collections[name]}.${i.key}`)) + +test('main legt auf leerer Datenbank 2 Collections mit allen Attributen und Indizes an und ergänzt customers und financeSettings', async () => { + const client = fakeClient() + const result = await main({ client, log: quiet }) + assert.deepEqual(client.calls.collections, ids.map((id) => ({ id, name: id }))) + assert.deepEqual([...client.calls.attrs].sort(), [...expectedAttrs].sort()) + assert.deepEqual([...client.calls.indexes].sort(), [...expectedIndexes].sort()) + assert.equal(expectedAttrs.length, 55) + assert.equal(expectedIndexes.length, 8) + assert.equal(result.missing.length, 55) + assert.deepEqual(result.missingCollections, ids) + assert.equal(result.missingIndexes.length, 8) + // customers und financeSettings gibt es schon: sie werden nie angelegt, bekommen nur ihr neues Feld + assert.ok(!client.calls.collections.some((c) => [customersId, settingsId].includes(c.id))) + assert.deepEqual(client.state.get(customersId).attrs.get('kundenart'), { key: 'kundenart', type: 'string', size: 20, required: false }) + assert.deepEqual(client.state.get(settingsId).attrs.get('steuernummer'), { key: 'steuernummer', type: 'string', size: 40, required: false }) +}) + +test('main: zweiter Lauf legt nichts mehr an', async () => { + const client = fakeClient() + await main({ client, log: quiet }) + client.calls.collections.length = client.calls.attrs.length = client.calls.indexes.length = 0 + const result = await main({ client, log: quiet }) + assert.deepEqual(client.calls, leer()) + assert.deepEqual(result, { missing: [], missingCollections: [], missingIndexes: [] }) +}) + +test('main legt nur Fehlendes an, auch in einer schon vorhandenen Collection', async () => { + const pr = config.collections.projektRechnungen + const client = fakeClient({ + [pr]: { attrs: ['invoiceId', 'kundenId'], indexes: ['idx_kunde'] }, + [customersId]: { attrs: ['name', 'kundenart'] }, + }) + await main({ client, log: quiet }) + assert.deepEqual(client.calls.collections, [{ id: config.collections.auftraege, name: config.collections.auftraege }]) + assert.deepEqual( + client.calls.attrs.filter((k) => k.startsWith(`${pr}.`)), + keysOf('projektRechnungen').filter((k) => !['invoiceId', 'kundenId'].includes(k)).map((k) => `${pr}.${k}`), + ) + assert.deepEqual(client.calls.indexes.filter((k) => k.startsWith(`${pr}.`)), [`${pr}.idx_projekt`, `${pr}.idx_auftrag`]) + assert.ok(!client.calls.attrs.includes(`${customersId}.kundenart`)) + assert.ok(client.calls.attrs.includes(`${settingsId}.steuernummer`)) +}) + +test('--check meldet Fehlendes und schreibt nichts', async () => { + const client = fakeClient() + const lines = [] + const result = await main({ check: true, client, log: (line) => lines.push(line) }) + assert.deepEqual(client.calls, leer()) + assert.equal(client.state.size, 2, 'nur customers und financeSettings, nichts angelegt') + assert.deepEqual([...client.state.get(settingsId).attrs.keys()], ['minBufferCents', 'bankDetailsText']) + assert.deepEqual(result.missingCollections, ids) + assert.equal(result.missing.length, 55) + assert.ok(lines.some((l) => l.includes(config.collections.auftraege) && l.includes('FEHLT')), lines.join('\n')) + assert.ok(lines.some((l) => l.includes(`${settingsId}.steuernummer FEHLT`)), lines.join('\n')) + assert.ok(lines.some((l) => l.includes(`Index ${config.collections.auftraege}.idx_nummer FEHLT`)), lines.join('\n')) +}) + +test('--check bei vollständigem Stand: nichts fehlt; ein einzelnes fehlendes Feld wird genau genannt', async () => { + const client = fakeClient() + await main({ client, log: quiet }) + assert.deepEqual(await main({ check: true, client, log: quiet }), { missing: [], missingCollections: [], missingIndexes: [] }) + client.state.get(customersId).attrs.delete('kundenart') + const result = await main({ check: true, client, log: quiet }) + assert.deepEqual(result.missing.map((a) => `${a.collection}.${a.key}`), [`${customersId}.kundenart`]) + assert.deepEqual(result.missingCollections, []) +}) + +test('fehlt customers oder financeSettings, bricht main mit klarer Meldung ab, ohne etwas anzulegen', async () => { + for (const [fehlt, hinweis] of [[settingsId, /setup-finanzen/], [customersId, /Ticketsystem/]]) { + const client = fakeClient() + client.state.delete(fehlt) + await assert.rejects( + () => main({ client, log: quiet }), + (err) => err.message.includes(fehlt) && err.message.includes('fehlt') && hinweis.test(err.message), + fehlt, + ) + assert.deepEqual(client.calls, leer()) + } +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeSetup.test.js` +Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `scripts/auftraege-setup.mjs`. + +- [ ] **Step 3: Implementieren** + +`scripts/auftraege-setup.mjs` (neu). Aufbau, Ablauf und Logzeilen **1:1 wie `scripts/zeiten-setup.mjs`** (Datei lesen und übernehmen, der Test hängt an den Logzeilen `- FEHLT`, `- . FEHLT`, `- Index . FEHLT`): erst alles prüfen (ohne `--check` auch dann zuerst alles prüfen, damit ein Abbruch vor jeder Änderung passiert), dann Collections anlegen (`ensureCollection(id, id)`), Attribute anlegen, auf jedes Attribut warten, Indizes anlegen. Unterschiede zu zeiten-setup: +- Ziele: die Collections aus `AUFTRAEGE_COLLECTIONS` (je `config.collections[name]`, `own: true`) sowie zwei vorhandene Collections nur mit zusätzlichen Attributen und ohne Indizes: `config.collections.customers` mit `AUFTRAEGE_ZUSATZ_ATTRIBUTE.customers` und `config.collections.financeSettings` mit `AUFTRAEGE_ZUSATZ_ATTRIBUTE.financeSettings` (`own: false`). +- Fehlt eine nicht eigene Collection, wirft das Skript vor jeder Änderung `Error('Collection fehlt – ')`; Hinweis für `financeSettings`: `zuerst scripts/setup-finanzen.mjs ausfuehren`; für `customers`: `die Kunden-Collection gehört dem Ticketsystem (WOMS)`. +- Kopfkommentar: wozu das Skript dient, dass es idempotent ist, vor dem Code-Deploy und vor `AUFTRAEGE_ENABLED` ausgeführt wird (`docker exec -w /app webklar-kundenbereich-server node scripts/auftraege-setup.mjs`), Usage mit `--check`. +- Der Block `invokedDirectly` am Ende wie in zeiten-setup (Exit 1 bei `--check` mit Fehlendem). + +- [ ] **Step 4: Lauf, erwartet PASS** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeSetup.test.js` +Expected: PASS (sechs Tests, `fail 0`). + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add scripts/auftraege-setup.mjs test/auftraegeSetup.test.js && git commit -m "Aufträge: Setup-Skript für Collections und Zusatzfelder" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 6: Textbausteine (vorläufig) + +**Files:** +- Create: `server/services/auftraege/texte.js` +- Test: `test/auftraegeTextBausteine.test.js` + +**Interfaces:** +- Produces: alle Exporte aus Übersicht V7 (`TEXTE_FASSUNG`, `ABSENDER`, `AB_TEXTE`, `BESTAETIGUNG_TEXT`, `FRUEHBEGINN_FASSUNG`, `FRUEHBEGINN_TEXT`, `WIDERRUFSBELEHRUNG_PROJEKT`, `WIDERRUFSFORMULAR`). Parametertypen der Textfunktionen (in V7 offen, hier festgelegt): `datum`, `start`, `fertig`, `fassung` sind fertig formatierte Texte `TT.MM.JJJJ`; `weg` ist das deutsche Wort aus `AUFTRAG_WEGE` (z. B. `telefonisch`) oder leer; `satz`, `anzahlung`, `rest` sind Beträge wie von `euroDe(...)`; `prozent` und `ziel` (Tage) sind Zahlen; `schaetzung` ist ein Stundentext (`'10'`, `'2,25'`) oder `null`; `privat` (nur bei `termine`) ist ein optionaler Boolean, Vorgabe `false`: für Privatpersonen kommt der Satz zum Beginn nach Ablauf der Widerrufsfrist dazu (dann nie `null`, auch ohne Termine). Von L4 und L5 genau so aufzurufen. + +Die Wortlaute sind **vorläufig**; die endgültigen macht Strang L7 (nur Wortlaute, Exportnamen und Formen bleiben). + +- [ ] **Step 1: Failing Test schreiben** + +```js +// test/auftraegeTextBausteine.test.js +import { test } from 'node:test' +import assert from 'node:assert/strict' +import * as T from '../server/services/auftraege/texte.js' + +// Geprüft werden Form und eingesetzte Werte, nie ganze Sätze: die Wortlaute ändert L7. +const alleTexte = (wert) => + typeof wert === 'string' + ? [wert] + : Array.isArray(wert) + ? wert.flatMap(alleTexte) + : wert && typeof wert === 'object' + ? Object.values(wert).flatMap(alleTexte) + : [] + +test('Exportnamen und feste Angaben nach Übersicht V7', () => { + assert.match(T.TEXTE_FASSUNG, /^\d{4}-\d{2}-\d{2}$/) + assert.match(T.FRUEHBEGINN_FASSUNG, /^\d{4}-\d{2}-\d{2}$/) + assert.deepEqual(T.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', + }) + assert.equal(T.AB_TEXTE.titel, 'Auftragsbestätigung') + assert.equal(T.AB_TEXTE.kleinunternehmer, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.') + assert.equal(T.BESTAETIGUNG_TEXT, 'Ich bestätige den Auftrag zu diesen Bedingungen.') + assert.deepEqual(Object.keys(T.AB_TEXTE).sort(), ['agb', 'aufwand', 'einleitung', 'ersetzt', 'gruss', 'kleinunternehmer', 'mitwirkung', 'termine', 'titel', 'zahlungsplan']) + assert.deepEqual(Object.keys(T.AB_TEXTE.zahlungsplan).sort(), ['anzahlung', 'frei', 'monatlich', 'nach_abnahme']) + for (const name of ['mitwirkung', 'gruss']) assert.ok(typeof T.AB_TEXTE[name] === 'string' && T.AB_TEXTE[name].length > 5, name) + assert.ok(typeof T.FRUEHBEGINN_TEXT === 'string' && T.FRUEHBEGINN_TEXT.length > 50 && /Widerruf/.test(T.FRUEHBEGINN_TEXT)) +}) + +test('Einleitung, AGB-Satz, Ersetzt-Hinweis und Termine setzen ihre Werte ein', () => { + assert.match(T.AB_TEXTE.einleitung({ datum: '05.10.2026', weg: 'telefonisch' }), /05\.10\.2026.*telefonisch/) + assert.doesNotMatch(T.AB_TEXTE.einleitung({ datum: '05.10.2026', weg: '' }), /\(\s*\)/, 'ohne Weg keine leere Klammer') + assert.ok(T.AB_TEXTE.agb({ fassung: '30.09.2026' }).includes('30.09.2026')) + const ersetzt = T.AB_TEXTE.ersetzt({ nummer: 'AB-2026-0001', datum: '01.10.2026' }) + assert.ok(ersetzt.includes('AB-2026-0001') && ersetzt.includes('01.10.2026')) + assert.equal(T.AB_TEXTE.termine({ start: null, fertig: null }), null) + assert.equal(T.AB_TEXTE.termine({}), null) + assert.ok(T.AB_TEXTE.termine({ start: '01.11.2026', fertig: null }).includes('01.11.2026')) + const beide = T.AB_TEXTE.termine({ start: '01.11.2026', fertig: '15.12.2026' }) + assert.ok(beide.includes('01.11.2026') && beide.includes('15.12.2026')) + assert.ok(!T.AB_TEXTE.termine({ start: null, fertig: '15.12.2026' }).includes('null')) +}) + +test('Termine: für Privatpersonen kommt der Satz zum Beginn nach Ablauf der Widerrufsfrist dazu, auch ohne Termine', () => { + const ohne = T.AB_TEXTE.termine({ start: '01.11.2026', fertig: '15.12.2026' }) + assert.equal(T.AB_TEXTE.termine({ start: '01.11.2026', fertig: '15.12.2026', privat: false }), ohne) + assert.doesNotMatch(ohne, /Widerrufsfrist/) + const privat = T.AB_TEXTE.termine({ start: '01.11.2026', fertig: '15.12.2026', privat: true }) + assert.ok(privat.startsWith(ohne) && privat.length > ohne.length && /Widerrufsfrist/.test(privat), privat) + const nurSatz = T.AB_TEXTE.termine({ start: null, fertig: null, privat: true }) + assert.ok(typeof nurSatz === 'string' && /Widerrufsfrist/.test(nurSatz) && !nurSatz.includes('null'), String(nurSatz)) + assert.equal(T.AB_TEXTE.termine({ privat: true }), nurSatz) +}) + +test('Aufwandsregel und Zahlungsplan: Werte, Schätzung nur wenn gegeben, Ziel 0 heißt sofort', () => { + const mit = T.AB_TEXTE.aufwand({ satz: '100,00 €', schaetzung: '10' }) + assert.ok(mit.includes('100,00 €') && /\b15\b/.test(mit) && /\b9\b/.test(mit) && /\b10\b/.test(mit), mit) + const ohne = T.AB_TEXTE.aufwand({ satz: '100,00 €', schaetzung: null }) + assert.ok(!/\b10\b/.test(ohne) && ohne.length < mit.length, ohne) + assert.match(T.AB_TEXTE.aufwand({ satz: '100,00 €', schaetzung: '1' }), /\b1 Stunde(?!n)/) + + const z = T.AB_TEXTE.zahlungsplan + const anzahlung = z.anzahlung({ prozent: 50, anzahlung: '400,00 €', rest: '400,00 €', ziel: 14 }) + assert.ok(/50\s?%/.test(anzahlung) && anzahlung.split('400,00 €').length === 3 && /\b14\b/.test(anzahlung), anzahlung) + for (const art of ['nach_abnahme', 'monatlich']) { + assert.ok(/\b14\b/.test(z[art]({ ziel: 14 })), art) + assert.ok(!/\b0 Tag/.test(z[art]({ ziel: 0 })), `${art}: Ziel 0`) + } + const frei = z.frei({ text: 'Zahlung in zwei Raten.', ziel: 14 }) + assert.ok(frei.includes('Zahlung in zwei Raten') && !frei.includes('Raten..') && /\b14\b/.test(frei), frei) +}) + +test('Frühbeginn-Erklärung, Widerrufsbelehrung und Formular haben die Form aus V7 und nennen keine Funktionen-Einrichtung mehr', () => { + assert.equal(T.WIDERRUFSBELEHRUNG_PROJEKT.titel, 'Widerrufsbelehrung') + assert.ok(T.WIDERRUFSBELEHRUNG_PROJEKT.abschnitte.length >= 2) + for (const abschnitt of T.WIDERRUFSBELEHRUNG_PROJEKT.abschnitte) { + assert.ok(typeof abschnitt.ueberschrift === 'string' && abschnitt.ueberschrift.length > 0) + assert.ok(Array.isArray(abschnitt.absaetze) && abschnitt.absaetze.length > 0) + for (const absatz of abschnitt.absaetze) assert.ok(typeof absatz === 'string' && absatz.trim() !== '') + } + assert.equal(T.WIDERRUFSFORMULAR.titel, 'Muster-Widerrufsformular') + assert.ok(typeof T.WIDERRUFSFORMULAR.einleitung === 'string' && T.WIDERRUFSFORMULAR.einleitung.length > 0) + assert.ok(Array.isArray(T.WIDERRUFSFORMULAR.zeilen) && T.WIDERRUFSFORMULAR.zeilen.length >= 6) + const text = alleTexte([T.WIDERRUFSBELEHRUNG_PROJEKT, T.WIDERRUFSFORMULAR, T.FRUEHBEGINN_TEXT]).join('\n') + assert.doesNotMatch(text, /Einrichtung|Terminplaner|Wartungs-Abo|Funktionen/) + assert.match(text, /vierzehn Tage/) + assert.match(text, /Projektauftrag/) + assert.match(text, /widerrufen\.html/) + assert.match(text, /support@webklar\.com/) +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeTextBausteine.test.js` +Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `server/services/auftraege/texte.js`. + +- [ ] **Step 3: Implementieren** + +`server/services/auftraege/texte.js` (neu, ohne Importe), mit genau diesem Inhalt. Die Widerrufsbelehrung ist der Wortlaut aus `docs/rechtliches/widerrufsbelehrung-funktionen.md` (Fassung 2026-10-05), bezogen auf den Projektauftrag; das Formular ist deren Muster-Formular unverändert. `FRUEHBEGINN_TEXT` ist der Wortlaut des Nutzers. + +```js +/** + * Textbausteine der Auftragsbestätigung (Spec 2026-10-06, Abschnitt 2 „Inhalt des PDFs“, Übersicht V7). + * Reine Daten und Textfunktionen ohne Importe. VORLÄUFIGER Stand (angelegt von Strang L0): die endgültigen Wortlaute + * macht Strang L7 nach der Rechtsrunde, dabei ändern sich nur Wortlaute, nie Exportnamen oder Formen. + * Kundentexte stehen in der Sie-Form. Werte kommen fertig formatiert herein: Daten als 'TT.MM.JJJJ', Beträge wie von + * euroDe(...), Tage und Prozent als Zahl, Stunden als Text ('10', '2,25'), `weg` als deutsches Wort ('telefonisch'), + * `privat` (nur bei termine) als Boolean. + */ + +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', +} + +/** Zahlungsziel als Satzteil: 0 Tage heißt sofort, sonst „innerhalb von N Tagen nach Rechnung“. */ +const zielText = (ziel) => (ziel === 0 ? 'sofort nach Rechnungserhalt' : `innerhalb von ${ziel} ${ziel === 1 ? 'Tag' : 'Tagen'} nach Rechnung`) + +export const AB_TEXTE = { + titel: 'Auftragsbestätigung', + einleitung: ({ datum, weg }) => `Vielen Dank für Ihren Auftrag vom ${datum}${weg ? ` (${weg})` : ''}. Wir bestätigen ihn wie folgt:`, + aufwand: ({ satz, schaetzung }) => + `Die Abrechnung erfolgt nach Aufwand: ${satz} pro Stunde in Schritten von 15 Minuten. Unterstützt eine weitere Person, berechnen wir für sie die Hälfte der Arbeitszeit (AGB Nr. 9).` + + (schaetzung ? ` Geschätzter Umfang: etwa ${schaetzung} ${schaetzung === '1' ? 'Stunde' : 'Stunden'} (unverbindlich).` : '') + + ' Zeichnet sich eine deutliche Überschreitung ab, melden wir uns vorher.', + kleinunternehmer: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.', + zahlungsplan: { + nach_abnahme: ({ ziel }) => `Die Rechnung stellen wir nach Abnahme der Leistung; zahlbar ${zielText(ziel)}.`, + anzahlung: ({ prozent, anzahlung, rest, ziel }) => + `Anzahlung ${prozent} % (${anzahlung}) nach Auftragsbestätigung, Rest (${rest}) nach Abnahme; zahlbar jeweils ${zielText(ziel)}.`, + monatlich: ({ ziel }) => `Wir rechnen monatlich nach dem entstandenen Aufwand ab; zahlbar ${zielText(ziel)}.`, + frei: ({ text, ziel }) => `${text.trim().replace(/[\s.;:,]+$/, '')}; zahlbar ${zielText(ziel)}.`, + }, + termine: ({ start, fertig, privat = false } = {}) => { + const teile = [] + if (start && fertig) teile.push(`Beginn: ${start}; voraussichtliche Fertigstellung: ${fertig}.`) + else if (start) teile.push(`Beginn: ${start}.`) + else if (fertig) teile.push(`Voraussichtliche Fertigstellung: ${fertig}.`) + if (privat) teile.push('Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.') + return teile.length ? teile.join(' ') : null + }, + mitwirkung: 'Mitwirkungspflichten und Abnahme richten sich nach Nr. 7 und Nr. 8 unserer Allgemeinen Geschäftsbedingungen.', + agb: ({ fassung }) => `Es gelten unsere Allgemeinen Geschäftsbedingungen in der Fassung vom ${fassung}, die dieser Bestätigung beiliegen.`, + ersetzt: ({ nummer, datum }) => `Diese Auftragsbestätigung ersetzt ${nummer} vom ${datum}.`, + gruss: 'Mit freundlichen Grüßen', +} + +export const BESTAETIGUNG_TEXT = 'Ich bestätige den Auftrag zu diesen Bedingungen.' + +export const FRUEHBEGINN_FASSUNG = '2026-10-06' +export const FRUEHBEGINN_TEXT = + 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Arbeit an diesem Auftrag beginnt. Mir ist bekannt, dass ich bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachten Leistungen zahle (§ 357a Abs. 2 BGB).' + +export const WIDERRUFSBELEHRUNG_PROJEKT = { + titel: 'Widerrufsbelehrung', + abschnitte: [ + { + ueberschrift: 'Widerrufsrecht', + absaetze: [ + 'Diese Belehrung gilt für den Projektauftrag, den wir Ihnen mit dieser Auftragsbestätigung bestätigen.', + 'Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.', + 'Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.', + 'Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, Telefon: 0176 23726355, E-Mail: support@webklar.com) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.', + 'Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben; wählen Sie dort die Vertragsart „Projektauftrag (Auftragsbestätigung)“ und tragen Sie die Nummer Ihrer Auftragsbestätigung ein. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.', + 'Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.', + ], + }, + { + ueberschrift: 'Folgen des Widerrufs', + absaetze: [ + 'Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.', + 'Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht.', + ], + }, + ], +} + +export const WIDERRUFSFORMULAR = { + titel: 'Muster-Widerrufsformular', + einleitung: '(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)', + zeilen: [ + '– An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:', + '– Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)', + '– Bestellt am (*)/erhalten am (*)', + '– Name des/der Verbraucher(s)', + '– Anschrift des/der Verbraucher(s)', + '– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)', + '– Datum', + '(*) Unzutreffendes streichen.', + ], +} +``` + +- [ ] **Step 4: Lauf, erwartet PASS** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeTextBausteine.test.js` +Expected: PASS (fünf Tests, `fail 0`). + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/texte.js test/auftraegeTextBausteine.test.js && git commit -m "Aufträge: vorläufige Textbausteine der Auftragsbestätigung" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 7: Auftragsspeicher + +**Files:** +- Create: `server/services/auftraege/auftragStore.js` +- Test: `test/auftraegeAuftragStore.test.js` + +**Interfaces:** +- Consumes: `config.collections.auftraege` (Task 4); `AUFTRAEGE_COLLECTIONS` (Task 3, für die Spaltengrößen); `LIMITS`, `ZAHLUNGSPLAN_ARTEN` (Task 2); `AuftragError` (Task 2); aus `server/services/appwriteAdmin.js`: `createDocument`, `getDocument`, `updateDocument`, `deleteDocument`, `listAllDocuments`, `listDocuments`, `Query`. +- Produces (Übersicht V4): `docToAuftrag(doc) → Auftrag`, `auftragToDoc(auftrag) → object`, `getAuftrag(id) → Auftrag|null`, `listAuftraege({ kundenId, projektId, jahr } = {}) → Promise` (neueste zuerst), `createAuftrag(daten) → Promise`, `updateAuftrag(id, patch) → Promise`, `deleteAuftrag(id) → Promise`, `findAuftragByTokenHash(hash) → Promise`, `findAuftragByNummer(nummer) → Promise`. Form von `Auftrag`: Übersicht V3 (43 Felder). + +- [ ] **Step 1: Failing Test schreiben** + +```js +// test/auftraegeAuftragStore.test.js +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js' + +// Speicher der Auftragsbestätigungen. Nachbau von Appwrite mit dem Schema aus schema.js, Datumsfeldern wie in Appwrite +// ("+00:00") und dem Standard-Limit 25. +const aw = await startAppwriteMock({ schema: { auftraege: schemaKeys('auftraege') }, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { docToAuftrag, auftragToDoc, getAuftrag, listAuftraege, createAuftrag, updateAuftrag, deleteAuftrag, findAuftragByTokenHash, findAuftragByNummer } = + await import('../server/services/auftraege/auftragStore.js') +const { AuftragError } = await import('../server/services/auftraege/fehler.js') + +const COL = config.collections.auftraege +beforeEach(async () => { + for (const id of [...aw.docs(COL).keys()]) await deleteDocument(COL, id) +}) + +const ENTWURF = { + kundenId: 'c1', projektId: 'p1', titel: 'Website Bäckerei', zeitquelle: ['t1'], art: 'festpreis', + positionen: [{ beschreibung: 'Startseite', menge: 1, einzelpreisCents: 50000 }], + summeCents: 50000, zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14, + auftragVom: '2026-10-05', auftragWeg: 'telefon', erstelltVon: 'kenso@webklar.com', +} +const FELDER = 'id nummer jahr laufnummer status kundenId kunde projektId titel zeitquelle art positionen stundensatzCents schaetzungMinuten summeCents zahlungsplan zahlungszielTage auftragVom auftragWeg startAm fertigBis ersetztId ersetztDurchId texteFassung agbFassung pdfDatei pdfSha256 gesendetAm gesendetAn mailStatus mailFehler tokenHash tokenGueltigBis bestaetigtAm bestaetigtUeber bestaetigtPdfSha256 fruehbeginn fruehbeginnFassung storniertAm widerrufenAm erstelltVon erstelltAm geaendertAm' +const HASH = 'a'.repeat(64) +const seed = (id, felder = {}) => aw.seed(COL, { $id: id, kundenId: 'c1', status: 'entwurf', ...felder }) + +test('createAuftrag legt einen Entwurf an: Status erzwungen, Zeitpunkte gesetzt, JSON-Felder serialisiert', async () => { + const vor = Date.now() + const auftrag = await createAuftrag({ ...ENTWURF, status: 'versendet' }) + assert.deepEqual(Object.keys(auftrag).sort(), FELDER.split(' ').sort(), 'genau die 43 Felder aus V3') + assert.match(auftrag.id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) + assert.equal(auftrag.status, 'entwurf') + assert.deepEqual( + { nummer: auftrag.nummer, jahr: auftrag.jahr, laufnummer: auftrag.laufnummer, kunde: auftrag.kunde, gesendetAm: auftrag.gesendetAm, mailStatus: auftrag.mailStatus, fruehbeginn: auftrag.fruehbeginn }, + { nummer: '', jahr: null, laufnummer: null, kunde: null, gesendetAm: null, mailStatus: '', fruehbeginn: false }, + ) + assert.deepEqual(auftrag.positionen, ENTWURF.positionen) + assert.deepEqual(auftrag.zahlungsplan, ENTWURF.zahlungsplan) + assert.deepEqual(auftrag.zeitquelle, ['t1']) + assert.deepEqual([auftrag.stundensatzCents, auftrag.schaetzungMinuten], [null, null]) + assert.match(auftrag.erstelltAm, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + assert.equal(auftrag.geaendertAm, auftrag.erstelltAm) + assert.ok(Date.parse(auftrag.erstelltAm) >= vor) + const doc = aw.docs(COL).get(auftrag.id) + assert.equal(doc.status, 'entwurf') + assert.equal(doc.positionenJson, JSON.stringify(ENTWURF.positionen)) + assert.equal(doc.zeitquelleJson, '["t1"]') + assert.deepEqual(await getAuftrag(auftrag.id), auftrag) +}) + +test('docToAuftrag: fehlende Felder bekommen Vorgaben, kaputtes JSON wirft nie, Zeitpunkte werden zu ISO mit Z', () => { + const auftrag = docToAuftrag({ + $id: 'x1', kundenId: 'c1', status: 'versendet', nummer: 'AB-2026-0003', jahr: 2026, laufnummer: 3, + kundeJson: '', positionenJson: '{kaputt', zeitquelleJson: '["t1","",5," t2 "]', zahlungsplanJson: '[1]', + gesendetAm: '2026-10-06T10:00:00.000+00:00', tokenGueltigBis: null, fruehbeginn: true, summeCents: 1000, + $createdAt: '2026-10-06T08:00:00.000+00:00', $updatedAt: '2026-10-06T09:00:00.000+00:00', + }) + assert.equal(auftrag.id, 'x1') + assert.deepEqual([auftrag.nummer, auftrag.jahr, auftrag.laufnummer, auftrag.summeCents], ['AB-2026-0003', 2026, 3, 1000]) + assert.deepEqual([auftrag.kunde, auftrag.positionen, auftrag.zeitquelle], [null, [], ['t1', 't2']]) + assert.deepEqual(auftrag.zahlungsplan, { art: 'nach_abnahme', prozent: null, text: '' }) + assert.equal(auftrag.zahlungszielTage, 14) + assert.deepEqual([auftrag.gesendetAm, auftrag.tokenGueltigBis, auftrag.bestaetigtAm], ['2026-10-06T10:00:00.000Z', null, null]) + assert.equal(auftrag.fruehbeginn, true) + assert.deepEqual([auftrag.erstelltAm, auftrag.geaendertAm], ['2026-10-06T08:00:00.000Z', '2026-10-06T09:00:00.000Z']) +}) + +test('auftragToDoc: nur Felder aus V1, JSON serialisiert, undefined fehlt, null löscht', () => { + const doc = auftragToDoc({ + id: 'x1', $id: 'x1', fremd: 1, status: 'versendet', kunde: { name: 'Anna Beispiel', kundenart: 'privat' }, + zeitquelle: ['t1'], positionen: [], zahlungsplan: { art: 'frei', prozent: null, text: 'x' }, + gesendetAm: '2026-10-06T10:00:00.000Z', tokenGueltigBis: null, titel: undefined, tokenHash: null, + }) + assert.deepEqual(doc, { + status: 'versendet', kundeJson: '{"name":"Anna Beispiel","kundenart":"privat"}', zeitquelleJson: '["t1"]', positionenJson: '[]', + zahlungsplanJson: '{"art":"frei","prozent":null,"text":"x"}', gesendetAm: '2026-10-06T10:00:00.000Z', tokenGueltigBis: null, tokenHash: '', + }) + assert.equal(auftragToDoc({ kunde: null }).kundeJson, '') +}) + +test('auftragToDoc: ein Text über der Spaltengröße ist ein 400, ein falscher Typ ein Programmfehler', () => { + assert.throws(() => auftragToDoc({ titel: 'x'.repeat(201) }), (err) => err instanceof AuftragError && err.status === 400 && /titel/.test(err.message)) + assert.throws(() => auftragToDoc({ kunde: { name: 'x'.repeat(2001) } }), (err) => err instanceof AuftragError && err.status === 400 && /kunde/.test(err.message)) + assert.doesNotThrow(() => auftragToDoc({ titel: 'x'.repeat(200) })) + for (const falsch of [{ titel: 5 }, { summeCents: 1.5 }, { jahr: '2026' }, { fruehbeginn: 'ja' }, { gesendetAm: 'gestern' }, { gesendetAm: '' }]) { + assert.throws(() => auftragToDoc(falsch), TypeError, JSON.stringify(falsch)) + } +}) + +test('getAuftrag, updateAuftrag, deleteAuftrag: unbekannte und unmögliche IDs sind null, 404 bzw. folgenlos', async () => { + seed('u1', { titel: 'Alt', summeCents: 1000, erstelltAm: '2020-01-01T00:00:00.000Z', geaendertAm: '2020-01-01T00:00:00.000Z' }) + assert.equal((await getAuftrag('u1')).titel, 'Alt') + for (const id of ['gibtsnicht', '', '../x', 'a/b', undefined, null, 42, 'x'.repeat(37)]) assert.equal(await getAuftrag(id), null, String(id)) + + const auftrag = await updateAuftrag('u1', { titel: 'Neu', positionen: [{ beschreibung: 'A', menge: 2, einzelpreisCents: 500 }], status: 'versendet' }) + assert.deepEqual([auftrag.titel, auftrag.summeCents, auftrag.status], ['Neu', 1000, 'versendet']) + assert.equal(auftrag.positionen[0].menge, 2) + assert.equal(auftrag.erstelltAm, '2020-01-01T00:00:00.000Z') + assert.ok(auftrag.geaendertAm > '2020-01-01T00:00:00.000Z') + assert.equal(aw.docs(COL).get('u1').titel, 'Neu') + for (const id of ['gibtsnicht', '../x']) { + await assert.rejects(() => updateAuftrag(id, { titel: 'x' }), (err) => err instanceof AuftragError && err.status === 404, id) + } + + await deleteAuftrag('u1') + assert.equal(aw.docs(COL).size, 0) + await deleteAuftrag('u1') + await deleteAuftrag('../x') +}) + +test('listAuftraege: Filter, neueste zuerst, mehr als 25 Treffer', async () => { + seed('a1', { projektId: 'p1', jahr: 2026, erstelltAm: '2026-10-01T10:00:00.000Z' }) + seed('a2', { projektId: 'p2', jahr: 2026, erstelltAm: '2026-10-03T10:00:00.000Z' }) + seed('a3', { projektId: 'p1', jahr: 2025, erstelltAm: '2026-10-02T10:00:00.000Z' }) + seed('b1', { kundenId: 'c2', projektId: 'p1', jahr: 2026, erstelltAm: '2026-10-04T10:00:00.000Z' }) + const ids = async (filter) => (await listAuftraege(filter)).map((a) => a.id) + assert.deepEqual(await ids({ kundenId: 'c1' }), ['a2', 'a3', 'a1']) + assert.deepEqual(await ids({ kundenId: 'c1', projektId: 'p1' }), ['a3', 'a1']) + assert.deepEqual(await ids({ jahr: 2026 }), ['b1', 'a2', 'a1']) + assert.deepEqual(await ids(), ['b1', 'a2', 'a3', 'a1']) + assert.deepEqual(await ids({ kundenId: 'gibtsnicht' }), []) + for (let i = 0; i < 30; i += 1) seed(`m${String(i).padStart(2, '0')}`, { kundenId: 'c9', erstelltAm: `2026-09-${String(1 + (i % 28)).padStart(2, '0')}T10:00:00.000Z` }) + assert.equal((await listAuftraege({ kundenId: 'c9' })).length, 30) +}) + +test('listAuftraege: ein angegebener, aber leerer oder ungültiger Filter ist ein Fehler, nie „alle“', async () => { + seed('a1') + for (const filter of [{ kundenId: '' }, { kundenId: null }, { kundenId: 5 }, { projektId: '' }, { jahr: '2026' }, { jahr: 2026.5 }, { jahr: null }]) { + await assert.rejects(() => listAuftraege(filter), TypeError, JSON.stringify(filter)) + } +}) + +test('findAuftragByTokenHash und findAuftragByNummer: genau eine AB, leere und ungültige Schlüssel nie', async () => { + seed('e1', { nummer: '', tokenHash: '' }) + seed('e2') + seed('v1', { status: 'versendet', nummer: 'AB-2026-0001', tokenHash: HASH }) + assert.equal((await findAuftragByTokenHash(HASH)).id, 'v1') + assert.equal((await findAuftragByNummer('AB-2026-0001')).id, 'v1') + assert.equal(await findAuftragByTokenHash('b'.repeat(64)), null) + assert.equal(await findAuftragByNummer('AB-2026-0002'), null) + for (const hash of ['', ' ', 'abc', 'A'.repeat(64), `${HASH}0`, undefined, null, 5]) assert.equal(await findAuftragByTokenHash(hash), null, String(hash)) + for (const nummer of ['', 'Entwurf', 'AB-2026-1', 'ab-2026-0001', 'AB-2026-0001 ', undefined, null]) assert.equal(await findAuftragByNummer(nummer), null, String(nummer)) +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAuftragStore.test.js` +Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `server/services/auftraege/auftragStore.js`. + +- [ ] **Step 3: Implementieren** + +`server/services/auftraege/auftragStore.js` (neu). Feldlisten **nicht von Hand schreiben**, sondern aus `AUFTRAEGE_COLLECTIONS.auftraege.attributes` ableiten (Typ und Größe); nur vier Spalten werden auf Objektfelder abgebildet: `kundeJson` ↔ `kunde`, `zeitquelleJson` ↔ `zeitquelle`, `positionenJson` ↔ `positionen`, `zahlungsplanJson` ↔ `zahlungsplan`. Alle anderen Felder heißen im Auftrag wie die Spalte; dazu `id` = `$id`. + +- `docToAuftrag(doc)`: Vorgaben je Feld: + - Textspalten: der Wert, wenn er ein Text ist, sonst `''`. + - `jahr`, `laufnummer`, `stundensatzCents`, `schaetzungMinuten`, `summeCents`: ganze Zahl, sonst `null`. `zahlungszielTage`: ganze Zahl, sonst `LIMITS.standardZahlungszielTage`. `fruehbeginn`: `doc.fruehbeginn === true`. + - Datumsfelder (`gesendetAm`, `tokenGueltigBis`, `bestaetigtAm`, `storniertAm`, `widerrufenAm`): gültiger Zeitpunkt → `new Date(wert).toISOString()` (Appwrite liefert `+00:00`), sonst `null`. `erstelltAm` und `geaendertAm` ebenso, mit Rückgriff auf `$createdAt` bzw. `$updatedAt`. + - `kunde`: JSON-Objekt, sonst `null`. `zeitquelle`: JSON-Liste, nur nicht leere Texte (getrimmt), sonst `[]`. `positionen`: JSON-Liste, nur Objekte, sonst `[]`. `zahlungsplan`: JSON-Objekt, daraus `{ art, prozent, text }` mit `art` aus `ZAHLUNGSPLAN_ARTEN` (sonst `'nach_abnahme'`), `prozent` Zahl oder `null`, `text` Text; ist es kein Objekt: `{ art: 'nach_abnahme', prozent: null, text: '' }`. Kaputtes JSON wirft nie. +- `auftragToDoc(auftrag)`: wandelt nur die in `auftrag` vorhandenen Schlüssel um (auch teilweise Objekte, für `updateAuftrag`); `id`, `$…` und fremde Schlüssel bleiben weg, `undefined` fehlt. Regeln je Art: Text → String; `null` → `''`; anderer Typ → `TypeError`. Zahl → ganze Zahl (`Number.isSafeInteger`) oder `null`, sonst `TypeError`. `fruehbeginn` → Wahrheitswert, sonst `TypeError`. Datumsfeld → `null` bleibt `null`, ein `Date` oder gültiger Zeitpunkt-Text wird `toISOString()`, alles andere (auch `''`) ist `TypeError` (Appwrite weist `''` als Datum ab). JSON-Felder → `JSON.stringify(wert)` (`null` → `''`). Ist ein Text oder JSON länger als die Spalte (`size`), dann `AuftragError(400, 'Das Feld ist zu lang.')` (Auftragsfeld = `kunde` für `kundeJson` usw.). +- ID-Format für `getAuftrag`/`updateAuftrag`/`deleteAuftrag`: ein Text, der `/^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/` erfüllt (Appwrite-Regel). `getAuftrag`: ungültige ID oder 404 → `null` (ohne Abfrage bei ungültiger ID); andere Fehler weiterreichen. `updateAuftrag`: ungültige ID oder 404 → `AuftragError(404, 'Auftragsbestätigung nicht gefunden.')`; setzt immer `geaendertAm` auf jetzt. `deleteAuftrag`: ungültige ID oder 404 sind folgenlos. +- `createAuftrag(daten)`: `jetzt = new Date().toISOString()`; Dokument = `auftragToDoc({ ...daten, status: 'entwurf', erstelltAm: jetzt, geaendertAm: jetzt })`, angelegt mit `createDocument(config.collections.auftraege, doc)` (ID vergibt `ID.unique()` als Vorgabe); Rückgabe `docToAuftrag(Antwort)`. +- `listAuftraege(filter = {})`: je Schlüssel `kundenId`, `projektId` (nicht leerer Text) und `jahr` (ganze Zahl), der **nicht `undefined`** ist, ein `Query.equal`; ist ein angegebener Wert ungültig (leerer Text, `null`, falscher Typ), `TypeError` (nie stillschweigend „alle“). Abfrage mit `listAllDocuments` (Seiten à 500, Appwrite liefert sonst nur 25), danach `docToAuftrag`, sortiert nach `erstelltAm` absteigend, dann `id` absteigend. +- `findAuftragByTokenHash(hash)`: nur ein Text aus genau 64 Zeichen `[0-9a-f]` führt zu einer Abfrage (`Query.equal('tokenHash', hash)`, `Query.limit(1)`), sonst `null` ohne Abfrage (ein leerer Hash würde sonst jeden Entwurf treffen). `findAuftragByNummer(nummer)`: nur `/^AB-\d{4}-\d{4}$/`, sonst `null`. + +- [ ] **Step 4: Lauf, erwartet PASS** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAuftragStore.test.js` +Expected: PASS (acht Tests, `fail 0`). + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/auftragStore.js test/auftraegeAuftragStore.test.js && git commit -m "Aufträge: Speicher der Auftragsbestätigungen" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 8: Projektrechnungen-Speicher + +**Files:** +- Create: `server/services/auftraege/projektRechnungenStore.js` +- Test: `test/auftraegeProjektRechnungenStore.test.js` + +**Interfaces:** +- Consumes: `config.collections.projektRechnungen` (Task 4); `AuftragError` (Task 2); `createDocument`, `getDocument`, `updateDocument`, `listAllDocuments`, `Query` aus `appwriteAdmin.js`. +- Produces (Übersicht V4): `getProjektRechnung(invoiceId) → Promise`, `listProjektRechnungen({ kundenId, projektId, auftragId, nurAktiv = true } = {}) → Promise`, `createProjektRechnung(invoiceId, daten) → Promise`, `updateProjektRechnung(invoiceId, patch) → Promise`, `umhaengen(auftragIdAlt, auftragIdNeu) → Promise`. Form von `ProjektRechnung` (V3): `{ invoiceId, kundenId, projektId, auftragId, art, betragCents, arbeitsblattIds, rechnungsnummer, aktiv, erstelltVon, erstelltAm }`. + +- [ ] **Step 1: Failing Test schreiben** + +```js +// test/auftraegeProjektRechnungenStore.test.js +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js' + +// Speicher der Projektrechnungen (Dokument-ID = Stripe-Rechnungs-ID). Nachbau von Appwrite mit Schema und Standard-Limit 25. +const aw = await startAppwriteMock({ schema: { projektRechnungen: schemaKeys('projektRechnungen') }, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { getProjektRechnung, listProjektRechnungen, createProjektRechnung, updateProjektRechnung, umhaengen } = + await import('../server/services/auftraege/projektRechnungenStore.js') +const { AuftragError } = await import('../server/services/auftraege/fehler.js') + +const COL = config.collections.projektRechnungen +beforeEach(async () => { + for (const id of [...aw.docs(COL).keys()]) await deleteDocument(COL, id) +}) + +const DATEN = { kundenId: 'c1', projektId: 'p1', auftragId: 'ab1', art: 'aufwand', betragCents: 22500, arbeitsblattIds: ['w1', 'w2'], erstelltVon: 'kenso@webklar.com' } +const seed = (id, felder = {}) => + aw.seed(COL, { + $id: id, invoiceId: id, kundenId: 'c1', projektId: 'p1', auftragId: '', art: 'festbetrag', betragCents: 100, + arbeitsblattIdsJson: '[]', rechnungsnummer: '', aktiv: true, erstelltAm: '2026-10-01T10:00:00.000Z', ...felder, + }) + +test('createProjektRechnung: Dokument-ID ist die Rechnungs-ID, Standardwerte, Rückgabe in der Form aus V3', async () => { + const rechnung = await createProjektRechnung('in_1SD6vPRxAbCdEf', DATEN) + assert.deepEqual( + { ...rechnung, erstelltAm: undefined }, + { + invoiceId: 'in_1SD6vPRxAbCdEf', kundenId: 'c1', projektId: 'p1', auftragId: 'ab1', art: 'aufwand', betragCents: 22500, + arbeitsblattIds: ['w1', 'w2'], rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com', erstelltAm: undefined, + }, + ) + assert.match(rechnung.erstelltAm, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + const doc = aw.docs(COL).get('in_1SD6vPRxAbCdEf') + assert.equal(doc.invoiceId, 'in_1SD6vPRxAbCdEf') + assert.equal(doc.arbeitsblattIdsJson, '["w1","w2"]') + assert.equal(doc.aktiv, true) + assert.deepEqual(await getProjektRechnung('in_1SD6vPRxAbCdEf'), rechnung) + const minimal = await createProjektRechnung('in_test_1', { kundenId: 'c1', art: 'festbetrag', betragCents: 5000 }) + assert.deepEqual([minimal.projektId, minimal.auftragId, minimal.arbeitsblattIds, minimal.rechnungsnummer, minimal.aktiv], ['', '', [], '', true]) +}) + +test('Rechnungs-IDs wie Stripe sie vergibt (auch in_test_1 des Nachbaus); alles andere ist ein Programmfehler und legt nichts an', async () => { + for (const id of ['in_test_1', 'in_1SD6vPRxAbCdEf', `in_${'a'.repeat(33)}`]) assert.equal((await createProjektRechnung(id, DATEN)).invoiceId, id) + await Promise.all([...aw.docs(COL).keys()].map((id) => deleteDocument(COL, id))) + for (const id of ['', 'x', 'in_', 'in_a/b', 'in_../x', 'in_a-b', 'in_a.b', 'IN_abc', `in_${'a'.repeat(34)}`, undefined, 7]) { + await assert.rejects(() => createProjektRechnung(id, DATEN), TypeError, String(id)) + assert.equal(await getProjektRechnung(id), null, String(id)) + } + const falsch = [{ kundenId: '' }, { art: 'frei' }, { art: 'xyz' }, { betragCents: 1.5 }, { betragCents: -1 }, { arbeitsblattIds: 'w1' }, { arbeitsblattIds: [''] }, { aktiv: 'ja' }] + for (const abweichung of falsch) { + await assert.rejects(() => createProjektRechnung('in_abc', { ...DATEN, ...abweichung }), TypeError, JSON.stringify(abweichung)) + } + assert.equal(aw.docs(COL).size, 0) +}) + +test('createProjektRechnung: eine doppelte Rechnungs-ID ist 409', async () => { + await createProjektRechnung('in_abc', DATEN) + await assert.rejects(() => createProjektRechnung('in_abc', DATEN), (err) => err instanceof AuftragError && err.status === 409) +}) + +test('updateProjektRechnung ändert genannte Felder; eine unbekannte Rechnung ist 404', async () => { + await createProjektRechnung('in_abc', DATEN) + const gesendet = await updateProjektRechnung('in_abc', { rechnungsnummer: 'WEBKLAR-0009' }) + assert.deepEqual([gesendet.rechnungsnummer, gesendet.aktiv, gesendet.betragCents], ['WEBKLAR-0009', true, 22500]) + const storniert = await updateProjektRechnung('in_abc', { aktiv: false, arbeitsblattIds: ['w1'] }) + assert.deepEqual([storniert.aktiv, storniert.arbeitsblattIds, storniert.rechnungsnummer], [false, ['w1'], 'WEBKLAR-0009']) + assert.equal(aw.docs(COL).get('in_abc').aktiv, false) + for (const id of ['in_gibtsnicht', '../x', '']) { + await assert.rejects(() => updateProjektRechnung(id, { aktiv: false }), (err) => err instanceof AuftragError && err.status === 404, id) + } +}) + +test('listProjektRechnungen: nur aktive, Filter, nach erstelltAm aufsteigend, mehr als 25 Treffer', async () => { + seed('in_a', { erstelltAm: '2026-10-03T10:00:00.000Z', auftragId: 'ab1' }) + seed('in_b', { erstelltAm: '2026-10-01T10:00:00.000Z', auftragId: 'ab1' }) + seed('in_c', { aktiv: false, erstelltAm: '2026-10-02T10:00:00.000Z', auftragId: 'ab1' }) + seed('in_d', { kundenId: 'c2', projektId: 'p2', erstelltAm: '2026-10-04T10:00:00.000Z' }) + const ids = async (filter) => (await listProjektRechnungen(filter)).map((r) => r.invoiceId) + assert.deepEqual(await ids({ kundenId: 'c1' }), ['in_b', 'in_a']) + assert.deepEqual(await ids({ kundenId: 'c1', nurAktiv: false }), ['in_b', 'in_c', 'in_a']) + assert.deepEqual(await ids({ auftragId: 'ab1' }), ['in_b', 'in_a']) + assert.deepEqual(await ids({ projektId: 'p2' }), ['in_d']) + assert.deepEqual(await ids(), ['in_b', 'in_a', 'in_d']) + for (let i = 0; i < 30; i += 1) seed(`in_m${i}`, { kundenId: 'c9' }) + assert.equal((await listProjektRechnungen({ kundenId: 'c9' })).length, 30) +}) + +test('listProjektRechnungen: ein angegebener, aber leerer oder ungültiger Filter ist ein Fehler, nie „alle“', async () => { + seed('in_a') + for (const filter of [{ kundenId: '' }, { projektId: null }, { auftragId: 7 }, { nurAktiv: 'ja' }]) { + await assert.rejects(() => listProjektRechnungen(filter), TypeError, JSON.stringify(filter)) + } +}) + +test('umhaengen hängt alle Dokumente einer AB (auch stornierte) an die neue AB, ist wiederholbar und lässt andere unberührt', async () => { + seed('in_a', { auftragId: 'ab1' }) + seed('in_b', { auftragId: 'ab1', aktiv: false }) + seed('in_c', { auftragId: 'ab2' }) + assert.equal(await umhaengen('ab1', 'ab3'), 2) + assert.deepEqual([...aw.docs(COL).values()].map((d) => [d.invoiceId, d.auftragId]).sort(), [['in_a', 'ab3'], ['in_b', 'ab3'], ['in_c', 'ab2']]) + assert.equal(await umhaengen('ab1', 'ab3'), 0) + assert.equal(await umhaengen('ab3', 'ab3'), 0) + for (const [alt, neu] of [['', 'ab3'], ['ab1', ''], [undefined, 'ab3'], ['ab1', null]]) { + await assert.rejects(() => umhaengen(alt, neu), TypeError, JSON.stringify([alt, neu])) + } +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeProjektRechnungenStore.test.js` +Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `server/services/auftraege/projektRechnungenStore.js`. + +- [ ] **Step 3: Implementieren** + +`server/services/auftraege/projektRechnungenStore.js` (neu): +- Rechnungs-ID-Format: `/^in_\w{1,33}$/` (wie `STRIPE_INVOICE_ID = /^in_\w+$/` im Bestand, zusätzlich höchstens 36 Zeichen, die Grenze der Appwrite-Dokument-ID; der Stripe-Nachbau vergibt `in_test_`, V1 und Nachträge). +- Dokument → Objekt: `invoiceId` = `$id`; `arbeitsblattIds` aus `arbeitsblattIdsJson` (nur nicht leere Texte, kaputtes JSON → `[]`); `aktiv` = `doc.aktiv === true`; `kundenId`, `projektId`, `auftragId`, `art`, `rechnungsnummer`, `erstelltVon`: Text, sonst `''`; `betragCents`: ganze Zahl, sonst `0`; `erstelltAm`: ISO mit `Z` (`new Date(...).toISOString()`), Rückgriff `$createdAt`. +- `getProjektRechnung(invoiceId)`: ungültige ID → `null` ohne Abfrage; 404 → `null`; andere Fehler weiterreichen. +- `createProjektRechnung(invoiceId, daten)`: Prüfungen, jede Verletzung ist ein `TypeError`: ID passt zum Format; `kundenId` nicht leerer Text; `art` ∈ `['festbetrag', 'aufwand', 'anzahlung', 'schluss']` (`frei` hat nie ein Dokument); `betragCents` ganze Zahl ≥ 0; `arbeitsblattIds` (Vorgabe `[]`) Liste nicht leerer Texte; `aktiv` (Vorgabe `true`) Wahrheitswert; `projektId`, `auftragId`, `rechnungsnummer`, `erstelltVon` Text (Vorgabe `''`). Das Dokument bekommt die Rechnungs-ID als Dokument-ID und als Feld `invoiceId`, `arbeitsblattIdsJson = JSON.stringify(...)` (länger als 20000 Zeichen → `AuftragError(400, 'Zu viele Arbeitsblätter in einer Rechnung.')`), `erstelltAm` = jetzt. Appwrite-Antwort 409 → `AuftragError(409, 'Zu dieser Rechnung gibt es schon einen Eintrag.')`. Rückgabe: das Objekt. +- `updateProjektRechnung(invoiceId, patch)`: ungültige oder unbekannte ID → `AuftragError(404, 'Projektrechnung nicht gefunden.')`; änderbar sind `kundenId`, `projektId`, `auftragId`, `art`, `betragCents`, `arbeitsblattIds`, `rechnungsnummer`, `aktiv`, `erstelltVon` mit denselben Prüfungen wie beim Anlegen (nur für die genannten Schlüssel); `invoiceId`, `erstelltAm` und fremde Schlüssel werden ignoriert. Rückgabe: das Objekt nach der Änderung. +- `listProjektRechnungen(filter = {})`: je Schlüssel `kundenId`, `projektId`, `auftragId`, der nicht `undefined` ist, ein `Query.equal` (nicht leerer Text, sonst `TypeError`); `nurAktiv` (Vorgabe `true`, muss ein Wahrheitswert sein, sonst `TypeError`) fügt `Query.equal('aktiv', true)` hinzu. Abfrage mit `listAllDocuments`, sortiert nach `erstelltAm` **aufsteigend**, dann `invoiceId`. +- `umhaengen(auftragIdAlt, auftragIdNeu)`: beide nicht leerer Text, sonst `TypeError`; sind sie gleich → `0`. Sonst alle Dokumente mit `auftragId === auftragIdAlt` (`listProjektRechnungen({ auftragId: auftragIdAlt, nurAktiv: false })`) auf `auftragIdNeu` setzen; Rückgabe: Anzahl. Bei einem Fehler bleibt ein Teilstand, ein erneuter Aufruf setzt fort. + +- [ ] **Step 4: Lauf, erwartet PASS** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeProjektRechnungenStore.test.js` +Expected: PASS (sieben Tests, `fail 0`). + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/projektRechnungenStore.js test/auftraegeProjektRechnungenStore.test.js && git commit -m "Aufträge: Speicher der Projektrechnungen" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 9: Arbeitsblätter + +**Files:** +- Create: `server/services/auftraege/arbeitsblaetter.js` +- Test: `test/auftraegeArbeitsblaetter.test.js` + +**Interfaces:** +- Consumes: `listProjektRechnungen` (Task 8); `VIERTELSTUNDE`, `VEREINBARTE_STATUS` (Task 2); `config.collections.worksheets`; `listAllDocuments`, `Query` aus `appwriteAdmin.js`; `customerWorkEntries` aus `server/services/customerProjectView.js`; `worksheetMinutes` aus `server/services/worksheetMinutes.js`; `berlinDate` aus `server/services/finance/dates.js`. +- Produces (Übersicht V3/V4): `ladeArbeitsblaetter(ticketIds) → Promise`, `ladeArbeitsblaetterNachIds(ids) → Promise` (liefert nur gefundene Blätter, der Aufrufer vergleicht die Anzahl), `abrechenbar(blatt) → boolean`, `abgerechneteArbeitsblaetter(kundenId) → Promise>` (`rechnungsnummer` ist bei einem Entwurf `''`; die Team-Route zeigt dafür „Entwurf“), `offeneArbeitsblaetter(ticketIds, kundenId) → Promise`, `zeitquelleFuerProjekt(projekt, auftraege) → string[]`. Form `Arbeitsblatt`: `{ id, ticketId, woid, datum, minuten, text, aboZeit, kommentar }`. + +- [ ] **Step 1: Failing Test schreiben** + +```js +// test/auftraegeArbeitsblaetter.test.js +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js' + +// worksheets gehört dem Ticketsystem und hat im Nachbau kein Schema (jedes Feld ist erlaubt). +const aw = await startAppwriteMock({ schema: { projektRechnungen: schemaKeys('projektRechnungen') }, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { createProjektRechnung, updateProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js') +const A = await import('../server/services/auftraege/arbeitsblaetter.js') + +const WS = config.collections.worksheets +const PR = config.collections.projektRechnungen +beforeEach(async () => { + for (const col of [WS, PR]) for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id) + aw.resetListCalls() +}) + +const UNTERSTUETZUNG = JSON.stringify([{ employeeId: 'e2', name: 'Justin Klein', task: 'Review' }]) +const ZWEI_UNTERSTUETZER = JSON.stringify([ + { employeeId: 'e2', name: 'Justin Klein', task: 'Review' }, + { employeeId: 'e3', name: 'Nico', task: 'Test' }, +]) +const blatt = (id, felder = {}) => + aw.seed(WS, { + $id: id, workorderId: 't1', woid: '100101', wsid: id, startDate: '06.10.2026', endDate: '06.10.2026', totalTime: 60, + details: 'Startseite überarbeitet', serviceType: 'REMOTE', employeeName: 'Kenso Grimm', countsToPlan: false, isComment: false, supporters: '', ...felder, + }) +const nachId = async (ticketIds = ['t1']) => Object.fromEntries((await A.ladeArbeitsblaetter(ticketIds)).map((b) => [b.id, b])) + +test('ladeArbeitsblaetter liefert die normierte Form je Blatt', async () => { + blatt('w1') + assert.deepEqual(await A.ladeArbeitsblaetter(['t1']), [ + { id: 'w1', ticketId: 't1', woid: '100101', datum: '2026-10-06', minuten: 60, text: 'Startseite überarbeitet', aboZeit: false, kommentar: false }, + ]) +}) + +test('Datum eines Arbeitsblatts: TT.MM.JJJJ, ISO als Berliner Tag, leer fällt auf $createdAt, ein unmögliches Datum auch', async () => { + const faelle = { + d1: [{ startDate: '06.10.2026' }, '2026-10-06'], + d2: [{ startDate: '', endDate: '07.10.2026' }, '2026-10-07'], + d3: [{ startDate: '2026-10-06T21:59:59.000Z' }, '2026-10-06'], // 23:59:59 in Berlin + d4: [{ startDate: '2026-10-06T22:30:00.000Z' }, '2026-10-07'], // 00:30 in Berlin am nächsten Tag + d5: [{ startDate: '2026-10-06' }, '2026-10-06'], + d6: [{ startDate: '', $createdAt: '2026-10-08T21:30:00.000+00:00' }, '2026-10-08'], + d7: [{ startDate: '', $createdAt: '2026-10-08T22:00:00.000+00:00' }, '2026-10-09'], + d8: [{ startDate: '31.02.2026', $createdAt: '2026-10-01T10:00:00.000+00:00' }, '2026-10-01'], + d9: [{ startDate: 'gestern', $createdAt: '2026-10-02T10:00:00.000+00:00' }, '2026-10-02'], + d10: [{ startDate: '12.13.2026', $createdAt: '2026-10-03T10:00:00.000+00:00' }, '2026-10-03'], // nie als Monatsangabe der US-Schreibweise lesen + } + for (const [id, [felder]] of Object.entries(faelle)) blatt(id, { endDate: '', ...felder }) + const datum = Object.fromEntries(Object.values(await nachId()).map((b) => [b.id, b.datum])) + assert.deepEqual(datum, Object.fromEntries(Object.entries(faelle).map(([id, [, tag]]) => [id, tag]))) +}) + +test('Minuten: je Blatt inklusive Unterstützung auf 15 Minuten aufgerundet', async () => { + const faelle = [ + ['m1', { totalTime: 10 }, 15], + ['m2', { totalTime: 15 }, 15], + ['m3', { totalTime: 16 }, 30], + ['m4', { totalTime: 0 }, 0], + ['m5', { totalTime: 'abc' }, 0], + ['m6', { totalTime: 80, supporters: UNTERSTUETZUNG }, 135], // 80 + 45 Unterstützung = 125 + ['m7', { totalTime: 20, supporters: UNTERSTUETZUNG }, 45], // 20 + 15 = 35 + ['m8', { totalTime: 60, supporters: ZWEI_UNTERSTUETZER }, 120], // 60 + 30 + 30 + ] + for (const [id, felder] of faelle) blatt(id, felder) + const minuten = Object.fromEntries(Object.values(await nachId()).map((b) => [b.id, b.minuten])) + assert.deepEqual(minuten, Object.fromEntries(faelle.map(([id, , erwartet]) => [id, erwartet]))) +}) + +test('Abo-Zeit und Kommentar werden erkannt und sind nie abrechenbar, ebenso 0 Minuten', async () => { + blatt('f1') + blatt('f2', { countsToPlan: true }) + blatt('f3', { isComment: true, totalTime: 0 }) + blatt('f4', { totalTime: 0 }) + const b = await nachId() + assert.deepEqual([b.f1.aboZeit, b.f1.kommentar, A.abrechenbar(b.f1)], [false, false, true]) + assert.deepEqual([b.f2.aboZeit, A.abrechenbar(b.f2)], [true, false]) + assert.deepEqual([b.f3.kommentar, A.abrechenbar(b.f3)], [true, false]) + assert.equal(A.abrechenbar(b.f4), false) + const basis = { aboZeit: false, kommentar: false, minuten: 15 } + assert.equal(A.abrechenbar(basis), true) + assert.equal(A.abrechenbar({ ...basis, aboZeit: true }), false) + assert.equal(A.abrechenbar({ ...basis, kommentar: true }), false) + assert.equal(A.abrechenbar({ ...basis, minuten: 0 }), false) +}) + +test('Text: wie in der Kundenansicht, eine Zeile, höchstens 200 Zeichen, Git ohne Kürzel, Link und Namen', async () => { + blatt('x1', { details: ' Startseite\n\nKontaktformular überarbeitet ' }) + blatt('x2', { details: 'ä'.repeat(300) }) + blatt('x3', { + serviceType: 'GIT', + totalTime: 45, + details: [ + 'Git Push auf `knso/x` (Branch: main)', + 'Commit: 88d3c37', + '', + '- [88d3c37](https://git.webklar.com/knso/x/commit/88d3c37ab7b15337772cf59a5a9294ec0cc8cca4): feat: Kontaktformular repariert (Kenso Grimm)', + '- [1a2b3c4](https://git.webklar.com/knso/x/commit/1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b): fix: Footer', + '', + ].join('\n'), + }) + blatt('x4', { isComment: true, details: 'nur intern' }) + blatt('x5', { details: '' }) + const b = await nachId() + assert.equal(b.x1.text, 'Startseite Kontaktformular überarbeitet') + assert.equal(b.x2.text.length, 200) + assert.ok(b.x2.text.endsWith('…')) + assert.equal(b.x3.text, 'Kontaktformular repariert; Footer') + assert.doesNotMatch(b.x3.text, /88d3c37|git\.webklar|Kenso/) + assert.equal(b.x4.text, '') + assert.equal(b.x5.text, '') +}) + +test('ladeArbeitsblaetter: nur die genannten Tickets, nach Datum und ID sortiert, in Blöcken zu 100 Tickets', async () => { + blatt('w3', { startDate: '07.10.2026' }) + blatt('w2', { startDate: '06.10.2026' }) + blatt('w1', { startDate: '06.10.2026' }) + blatt('w9', { workorderId: 't2', startDate: '05.10.2026' }) + blatt('w8', { workorderId: 't3' }) + assert.deepEqual((await A.ladeArbeitsblaetter(['t1', 't2'])).map((b) => b.id), ['w9', 'w1', 'w2', 'w3']) + assert.deepEqual((await A.ladeArbeitsblaetter(['t1', 't1', '', null, 5])).map((b) => b.id), ['w1', 'w2', 'w3']) + aw.resetListCalls() + for (const leer of [[], undefined, null, [''], 'kein-array']) assert.deepEqual(await A.ladeArbeitsblaetter(leer), [], String(leer)) + assert.equal(aw.listCalls(WS).length, 0, 'ohne Ticket keine Abfrage') + const viele = Array.from({ length: 250 }, (_, i) => `t_${i}`) + viele[7] = 't1' + assert.deepEqual((await A.ladeArbeitsblaetter(viele)).map((b) => b.id), ['w1', 'w2', 'w3']) + const aufrufe = aw.listCalls(WS) + assert.equal(aufrufe.length, 3) + for (const aufruf of aufrufe) { + assert.ok(aufruf.queries.find((q) => q.method === 'equal' && q.attribute === 'workorderId').values.length <= 100) + } +}) + +test('ladeArbeitsblaetterNachIds liefert nur vorhandene Blätter, sortiert wie ladeArbeitsblaetter', async () => { + blatt('w1', { startDate: '07.10.2026' }) + blatt('w2', { workorderId: 't2', startDate: '06.10.2026' }) + assert.deepEqual((await A.ladeArbeitsblaetterNachIds(['w1', 'w2', 'gibtsnicht', 'w1'])).map((b) => [b.id, b.ticketId]), [['w2', 't2'], ['w1', 't1']]) + assert.deepEqual(await A.ladeArbeitsblaetterNachIds([]), []) +}) + +test('abgerechneteArbeitsblaetter: nur aktive Projektrechnungen dieses Kunden; ein Entwurf hat die Nummer leer', async () => { + const daten = (extra) => ({ kundenId: 'c1', projektId: 'p1', art: 'aufwand', betragCents: 1000, ...extra }) + await createProjektRechnung('in_gesendet', daten({ arbeitsblattIds: ['w1', 'w2'] })) + await updateProjektRechnung('in_gesendet', { rechnungsnummer: 'WEBKLAR-0009' }) + await createProjektRechnung('in_entwurf', daten({ arbeitsblattIds: ['w5'] })) + await createProjektRechnung('in_storniert', daten({ arbeitsblattIds: ['w3'] })) + await updateProjektRechnung('in_storniert', { aktiv: false }) + await createProjektRechnung('in_fremd', daten({ kundenId: 'c2', arbeitsblattIds: ['w4'] })) + const karte = await A.abgerechneteArbeitsblaetter('c1') + assert.ok(karte instanceof Map) + assert.deepEqual([...karte.entries()].sort(), [ + ['w1', { invoiceId: 'in_gesendet', rechnungsnummer: 'WEBKLAR-0009' }], + ['w2', { invoiceId: 'in_gesendet', rechnungsnummer: 'WEBKLAR-0009' }], + ['w5', { invoiceId: 'in_entwurf', rechnungsnummer: '' }], + ]) + await assert.rejects(() => A.abgerechneteArbeitsblaetter(''), TypeError) +}) + +test('offeneArbeitsblaetter: abrechenbar und noch nicht abgerechnet; ein stornierter Eintrag gibt das Blatt frei', async () => { + blatt('w1') // abgerechnet (siehe unten) + blatt('w6', { totalTime: 30 }) // offen + blatt('w7', { countsToPlan: true }) // Abo-Zeit + blatt('w8', { isComment: true, totalTime: 0 }) // Kommentar + blatt('w9', { totalTime: 0 }) // 0 Minuten + blatt('w10', { workorderId: 't2' }) // anderes Ticket + await createProjektRechnung('in_a', { kundenId: 'c1', projektId: 'p1', art: 'aufwand', betragCents: 1000, arbeitsblattIds: ['w1'] }) + assert.deepEqual((await A.offeneArbeitsblaetter(['t1'], 'c1')).map((b) => b.id), ['w6']) + await updateProjektRechnung('in_a', { aktiv: false }) + assert.deepEqual((await A.offeneArbeitsblaetter(['t1'], 'c1')).map((b) => b.id), ['w1', 'w6']) +}) + +test('zeitquelleFuerProjekt: Vereinigung der vereinbarten ABs, sonst das Ticket des Projekts', () => { + const ab = (status, zeitquelle) => ({ status, zeitquelle }) + const projekt = { $id: 'p1', ticketId: 't0' } + const alle = [ + ab('versendet', ['t1', 't2']), ab('bestaetigt', ['t2', 't3']), ab('abgerechnet', ['t4']), + ab('entwurf', ['t9']), ab('storniert', ['t8']), ab('ersetzt', ['t7']), ab('widerrufen', ['t6']), + ] + assert.deepEqual(A.zeitquelleFuerProjekt(projekt, alle), ['t1', 't2', 't3', 't4']) + assert.deepEqual(A.zeitquelleFuerProjekt(projekt, []), ['t0']) + assert.deepEqual(A.zeitquelleFuerProjekt(projekt, [ab('entwurf', ['t9'])]), ['t0']) + assert.deepEqual(A.zeitquelleFuerProjekt(projekt, [ab('versendet', [])]), ['t0']) + assert.deepEqual(A.zeitquelleFuerProjekt({ $id: 'p1' }, []), []) + assert.deepEqual(A.zeitquelleFuerProjekt({ ticketId: '' }, undefined), []) + assert.deepEqual(A.zeitquelleFuerProjekt(null, []), []) +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeArbeitsblaetter.test.js` +Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `server/services/auftraege/arbeitsblaetter.js`. + +- [ ] **Step 3: Implementieren** + +`server/services/auftraege/arbeitsblaetter.js` (neu). Je Dokument aus `worksheets` entsteht ein `Arbeitsblatt`: +- `id` = `$id`; `ticketId` = `String(workorderId ?? '')`; `woid` = `String(woid ?? '')`; `aboZeit` = `countsToPlan === true`; `kommentar` = `isComment === true` (nur echte Wahrheitswerte zählen). +- `minuten` = `Math.ceil(worksheetMinutes(doc) / VIERTELSTUNDE) * VIERTELSTUNDE` (`worksheetMinutes` enthält die Unterstützung, ein Wert, der keine Zahl ist, zählt als 0). +- `datum` (Berliner Kalendertag `YYYY-MM-DD`, Regel wie `parseSheetDate` in `server/services/hourLedger.js`, das nicht exportiert wird und unverändert bleibt): Rohwert = `startDate || endDate || ''`. Hat er die Form `^(\d{2})\.(\d{2})\.(\d{4})$`, entscheidet allein diese Form: ein echter Kalendertag ergibt unmittelbar `JJJJ-MM-TT`, ein unmöglicher (31.02.) wird übersprungen (nie `new Date` darauf anwenden: V8 läse „12.13.2026“ als Dezember). Sonst zählen als Kandidaten der Reihe nach der Rohwert (nur wenn nicht leer und nicht von der Form `TT.MM.JJJJ`), `createdAt` und `$createdAt`; der erste, den `new Date(...)` als gültigen Zeitpunkt liest, ergibt `berlinDate(date)`. Ist keiner gültig, `''`. +- `text`: `customerWorkEntries([doc], { includeUpdates: true })` (aus `../customerProjectView.js`) liefert eine Liste mit höchstens einem Eintrag; ohne Eintrag (Kommentar) → `''`; hat der erste Eintrag `changes` (Git-Push) → `changes.join('; ')`; sonst `entry.text`. Danach Leerraum zusammenfassen (`/\s+/g` → `' '`, `trim`) und bei mehr als 200 Zeichen auf 199 Zeichen kürzen (`trimEnd()`) plus `…`. +- Sortierung der Ergebnisliste: nach `datum`, dann nach `id`, jeweils einfacher Textvergleich. +- Laden: Ticket-IDs bzw. Blatt-IDs entdoppeln, nur nicht leere Texte; ein Argument, das keine Liste ist (auch ein Text), gilt als leere Liste; ohne gültigen Eintrag `[]` ohne Abfrage; in Blöcken zu je 100 Werten mit `listAllDocuments(config.collections.worksheets, [Query.equal('workorderId' | '$id', block)])` (Appwrite begrenzt die Werteliste je `equal`-Abfrage). +- `abrechenbar(blatt)` = `!blatt.aboZeit && !blatt.kommentar && blatt.minuten > 0`. +- `abgerechneteArbeitsblaetter(kundenId)`: `listProjektRechnungen({ kundenId })` (nur aktive; ungültiger `kundenId` → dessen `TypeError`), je Blatt-ID der erste Eintrag (älteste Rechnung) gewinnt: `{ invoiceId, rechnungsnummer }`. +- `offeneArbeitsblaetter(ticketIds, kundenId)`: `ladeArbeitsblaetter(ticketIds)` gefiltert auf `abrechenbar(b)` und nicht in der Abgerechnet-Karte. +- `zeitquelleFuerProjekt(projekt, auftraege)`: Vereinigung der `zeitquelle`-Listen aller Aufträge mit `status` in `VEREINBARTE_STATUS` (Reihenfolge des Auftretens, ohne Doppelte und Leereinträge); ist sie leer, `[projekt.ticketId]` ohne Leereintrag; `projekt` und `auftraege` dürfen fehlen (`null`/`undefined`). Es wird **nicht** nach Projekt gefiltert: der Aufrufer übergibt nur die Aufträge dieses Projekts. + +- [ ] **Step 4: Lauf, erwartet PASS** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeArbeitsblaetter.test.js` +Expected: PASS (zehn Tests, `fail 0`). + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/arbeitsblaetter.js test/auftraegeArbeitsblaetter.test.js && git commit -m "Aufträge: Arbeitsblätter laden, normieren und als abgerechnet erkennen" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 10: Sichten auf die Auftragsbestätigung + +**Files:** +- Create: `server/services/auftraege/auftragSicht.js` +- Test: `test/auftraegeAuftragSicht.test.js` + +**Interfaces:** +- Consumes: `AB_TEXTE`, `BESTAETIGUNG_TEXT`, `FRUEHBEGINN_TEXT` (Task 6); `AGB_FASSUNG`, `KUNDENARTEN`, `LIMITS`, `STUNDENSATZ_STANDARD_CENTS` (Task 2); `withdrawalDeadline` aus `server/services/deadlines.js` (liefert `{ lastDay: 'YYYY-MM-DD', endsAt: Date }`); `euroDe` aus `server/services/finance/format.js`; `addDays`, `berlinDate` aus `server/services/finance/dates.js`. +- Produces (Übersicht V3/V4): `kundeSnapshot(customerDoc) → KundeSnapshot`, `fruehesterBeginn(auftrag) → 'YYYY-MM-DD'|null`, `zahlungsplanText(auftrag) → string`, `aufwandText(auftrag) → string|null`, `auftragFuerTeam(auftrag) → AuftragTeam`, `oeffentlicheAnsicht(auftrag) → AuftragOeffentlich`. Alles rein (kein Appwrite, keine Konfiguration). + +- [ ] **Step 1: Failing Test schreiben** + +```js +// test/auftraegeAuftragSicht.test.js +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { kundeSnapshot, fruehesterBeginn, zahlungsplanText, aufwandText, auftragFuerTeam, oeffentlicheAnsicht } from '../server/services/auftraege/auftragSicht.js' +import { AGB_FASSUNG } from '../server/services/auftraege/konstanten.js' +import { AB_TEXTE, BESTAETIGUNG_TEXT, FRUEHBEGINN_TEXT } from '../server/services/auftraege/texte.js' +import { euroDe } from '../server/services/finance/format.js' + +const KUNDE = { name: 'Anna Beispiel', firma: '', strasse: 'Hauptstr. 1', plz: '67722', ort: 'Winnweiler', land: '', email: 'anna@example.invalid', kundenart: 'privat', kundennummer: 'K-1001' } +const festpreis = (felder = {}) => ({ + id: 'ab1', nummer: 'AB-2026-0002', jahr: 2026, laufnummer: 2, status: 'versendet', kundenId: 'c1', kunde: KUNDE, projektId: 'p1', titel: 'Website Bäckerei', + zeitquelle: ['t1'], art: 'festpreis', + positionen: [{ beschreibung: 'Startseite', menge: 2, einzelpreisCents: 30000 }, { beschreibung: 'Kontaktformular', menge: 1, einzelpreisCents: 20000 }], + stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000, zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14, + auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '2026-11-01', fertigBis: '', ersetztId: '', ersetztDurchId: '', texteFassung: '2026-10-06', agbFassung: '2026-09-30', + pdfDatei: 'c1/ab1.pdf', pdfSha256: 'a'.repeat(64), gesendetAm: '2026-10-06T10:00:00.000Z', gesendetAn: 'anna@example.invalid', mailStatus: 'gesendet', mailFehler: '', + tokenHash: 'b'.repeat(64), tokenGueltigBis: '2026-12-05T10:00:00.000Z', bestaetigtAm: null, bestaetigtUeber: '', bestaetigtPdfSha256: '', fruehbeginn: false, fruehbeginnFassung: '', + storniertAm: null, widerrufenAm: null, erstelltVon: 'kenso@webklar.com', erstelltAm: '2026-10-05T09:00:00.000Z', geaendertAm: '2026-10-06T10:00:00.000Z', ...felder, +}) +const aufwand = (felder = {}) => + festpreis({ art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' }, ...felder }) +const LEER = { name: '', firma: '', strasse: '', plz: '', ort: '', land: '', email: '', kundenart: '', kundennummer: '' } + +test('kundeSnapshot übernimmt die Kundendaten getrimmt; eine unbekannte Kundenart wird leer; null und {} sind leer', () => { + const doc = { $id: 'c1', name: ' Anna Beispiel ', companyName: '', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler', country: '', email: 'anna@example.invalid', kundenart: 'privat', code: 'K-1001', phone: '123', customerStatus: 'active' } + assert.deepEqual(kundeSnapshot(doc), KUNDE) + assert.deepEqual(kundeSnapshot({ ...doc, companyName: ' Bäckerei Beispiel GmbH ', kundenart: 'unternehmen' }), { ...KUNDE, firma: 'Bäckerei Beispiel GmbH', kundenart: 'unternehmen' }) + assert.equal(kundeSnapshot({ name: 'X', kundenart: 'firma' }).kundenart, '') + assert.deepEqual(kundeSnapshot({}), LEER) + assert.deepEqual(kundeSnapshot(null), LEER) +}) + +test('kundeSnapshot kürzt lange Werte, so dass die Spalte kundeJson (2000 Zeichen) reicht', () => { + const lang = 'x'.repeat(500) + const snapshot = kundeSnapshot({ name: lang, companyName: lang, street: lang, postalCode: lang, city: lang, country: lang, email: lang, kundenart: 'privat', code: lang }) + assert.ok(JSON.stringify(snapshot).length <= 2000) + assert.equal(snapshot.name.length, 120) +}) + +test('fruehesterBeginn: Tag nach Ablauf der Widerrufsfrist, nur für Privatpersonen ohne Frühbeginn', () => { + const faelle = [ + ['2026-10-06T10:00:00.000Z', '2026-10-21'], + ['2026-10-06T22:30:00.000Z', '2026-10-22'], // in Berlin schon der 07.10. + ['2026-10-10T08:00:00.000Z', '2026-10-27'], // Fristende fiele auf Samstag, 26.10. ist der Montag + ['2026-10-17T08:00:00.000Z', '2026-11-03'], // 31.10. (Samstag, Feiertag) und 01.11. (Sonntag), Fristende Montag 02.11. + ] + for (const [gesendetAm, erwartet] of faelle) assert.equal(fruehesterBeginn(festpreis({ gesendetAm })), erwartet, gesendetAm) + assert.equal(fruehesterBeginn(festpreis({ fruehbeginn: true })), null) + for (const kundenart of ['unternehmen', '']) assert.equal(fruehesterBeginn(festpreis({ kunde: { ...KUNDE, kundenart } })), null, kundenart) + assert.equal(fruehesterBeginn(festpreis({ kunde: null })), null) + assert.equal(fruehesterBeginn(festpreis({ gesendetAm: null, status: 'entwurf' })), null) + assert.equal(fruehesterBeginn(festpreis({ gesendetAm: 'kaputt' })), null) +}) + +test('zahlungsplanText: Anzahlung nach Prozent gerundet, Rest als Differenz', () => { + const text = zahlungsplanText(festpreis()) + assert.equal(text.split(euroDe(40000)).length, 3, text) // Anzahlung und Rest je 400,00 € + assert.match(text, /50\s?%/) + assert.match(text, /\b14\b/) + // Math.round(10001 × 33 / 100) = 3300, Rest 6701 + const ungerade = zahlungsplanText(festpreis({ summeCents: 10001, zahlungsplan: { art: 'anzahlung', prozent: 33, text: '' } })) + assert.ok(ungerade.includes(euroDe(3300)) && ungerade.includes(euroDe(6701)), ungerade) +}) + +test('zahlungsplanText: nach Abnahme, monatlich, frei; Anzahlung ohne Summe fällt auf nach Abnahme zurück', () => { + assert.match(zahlungsplanText(festpreis({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })), /\b14\b/) + assert.match(zahlungsplanText(aufwand({ zahlungszielTage: 7 })), /\b7\b/) + assert.ok(zahlungsplanText(festpreis({ zahlungsplan: { art: 'frei', prozent: null, text: 'Zahlung in zwei Raten.' } })).includes('Zahlung in zwei Raten')) + const ohneSumme = aufwand({ zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' } }) + assert.equal(zahlungsplanText(ohneSumme), zahlungsplanText({ ...ohneSumme, zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })) +}) + +test('aufwandText: nur bei Aufwand, mit Stundensatz und Schätzung in Stunden', () => { + const text = aufwandText(aufwand()) + assert.ok(text.includes(euroDe(10000))) + assert.match(text, /\b10\b/) + assert.doesNotMatch(aufwandText(aufwand({ schaetzungMinuten: null })), /\b10\b/) + assert.match(aufwandText(aufwand({ schaetzungMinuten: 135 })), /2,25/) + assert.match(aufwandText(aufwand({ schaetzungMinuten: 90 })), /1,5\b/) + assert.ok(aufwandText(aufwand({ stundensatzCents: null })).includes(euroDe(10000)), 'ohne Satz gilt der Standard') + assert.equal(aufwandText(festpreis()), null) +}) + +test('auftragFuerTeam: ohne Token-Hash, PDF-Pfad und Bestätigungs-Prüfsumme, mit den abgeleiteten Feldern', () => { + const auftrag = festpreis() + const kopie = structuredClone(auftrag) + const sicht = auftragFuerTeam(auftrag) + assert.deepEqual(auftrag, kopie, 'verändert die AB nicht') + const entfernt = ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256'] + for (const feld of entfernt) assert.equal(Object.hasOwn(sicht, feld), false, feld) + assert.equal(sicht.pdfSha256, 'a'.repeat(64)) + assert.equal(sicht.pdfVorhanden, true) + assert.equal(sicht.fruehesterBeginn, '2026-10-21') + assert.equal(sicht.zahlungsplanText, zahlungsplanText(auftrag)) + assert.equal(sicht.aufwandText, null) + assert.deepEqual( + Object.keys(sicht).sort(), + [...Object.keys(auftrag).filter((k) => !entfernt.includes(k)), 'pdfVorhanden', 'fruehesterBeginn', 'zahlungsplanText', 'aufwandText'].sort(), + ) + assert.equal(auftragFuerTeam(festpreis({ pdfDatei: '' })).pdfVorhanden, false) +}) + +test('oeffentlicheAnsicht: genau die Felder aus V3, Beträge je Position, Berliner Datum, nichts Internes', () => { + const ansicht = oeffentlicheAnsicht(festpreis()) + assert.deepEqual( + Object.keys(ansicht).sort(), + ['agbUrl', 'aufwandText', 'art', 'bestaetigtAm', 'datum', 'fertigBis', 'fruehbeginn', 'fruehesterBeginn', 'kundenart', 'nummer', 'positionen', 'schaetzungMinuten', 'startAm', 'status', 'stundensatzCents', 'summeCents', 'texte', 'titel', 'zahlungsplanText'].sort(), + ) + assert.deepEqual(ansicht.positionen, [ + { beschreibung: 'Startseite', menge: 2, einzelpreisCents: 30000, summeCents: 60000 }, + { beschreibung: 'Kontaktformular', menge: 1, einzelpreisCents: 20000, summeCents: 20000 }, + ]) + assert.deepEqual( + [ansicht.nummer, ansicht.datum, ansicht.titel, ansicht.art, ansicht.summeCents, ansicht.startAm, ansicht.kundenart, ansicht.status], + ['AB-2026-0002', '2026-10-06', 'Website Bäckerei', 'festpreis', 80000, '2026-11-01', 'privat', 'versendet'], + ) + assert.equal(ansicht.fruehesterBeginn, '2026-10-21') + assert.deepEqual(ansicht.texte, { bestaetigung: BESTAETIGUNG_TEXT, fruehbeginn: FRUEHBEGINN_TEXT, kleinunternehmer: AB_TEXTE.kleinunternehmer }) + assert.equal(ansicht.texte.kleinunternehmer, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.') + assert.equal(ansicht.agbUrl, '/rechtliches/agb-2026-09-30.pdf') + const text = JSON.stringify(ansicht) + for (const geheim of ['b'.repeat(64), 'c1/ab1.pdf', 'anna@example.invalid', 'Hauptstr. 1', 'kenso@webklar.com']) assert.ok(!text.includes(geheim), geheim) +}) + +test('oeffentlicheAnsicht: Unternehmen ohne Frühbeginn-Text, Datum nach Berliner Zeit, AGB-Adresse ohne gespeicherte Fassung', () => { + const firma = oeffentlicheAnsicht(festpreis({ kunde: { ...KUNDE, kundenart: 'unternehmen' }, gesendetAm: '2026-10-06T22:30:00.000Z', agbFassung: '' })) + assert.equal(firma.texte.fruehbeginn, null) + assert.equal(firma.texte.kleinunternehmer, AB_TEXTE.kleinunternehmer) + assert.equal(firma.fruehesterBeginn, null) + assert.equal(firma.datum, '2026-10-07') + assert.equal(firma.agbUrl, `/rechtliches/agb-${AGB_FASSUNG}.pdf`) + const aufwandAnsicht = oeffentlicheAnsicht(aufwand()) + assert.deepEqual([aufwandAnsicht.positionen, aufwandAnsicht.stundensatzCents, aufwandAnsicht.schaetzungMinuten], [[], 10000, 600]) + assert.equal(aufwandAnsicht.aufwandText, aufwandText(aufwand())) +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAuftragSicht.test.js` +Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `server/services/auftraege/auftragSicht.js`. + +- [ ] **Step 3: Implementieren** + +`server/services/auftraege/auftragSicht.js` (neu): +- `kundeSnapshot(customerDoc)`: aus dem Kundendokument (`null`/`{}` ergeben lauter `''`): `name` ← `name`, `firma` ← `companyName`, `strasse` ← `street`, `plz` ← `postalCode`, `ort` ← `city`, `land` ← `country` (so gespeichert, kann `''`, `DE` oder `Deutschland` sein), `email` ← `email`, `kundenart` ← `kundenart` nur wenn in `KUNDENARTEN`, sonst `''`, `kundennummer` ← `code`. Alle Werte getrimmt und auf diese Längen gekürzt: `name` 120, `firma` 120, `strasse` 120, `plz` 20, `ort` 80, `land` 60, `email` 200, `kundennummer` 40 (gekürzte Summe plus JSON passt in 2000 Zeichen). +- `fruehesterBeginn(auftrag)`: nur wenn `auftrag.kunde?.kundenart === 'privat'`, `!auftrag.fruehbeginn` und `gesendetAm` ein gültiger Zeitpunkt ist, sonst `null`: `addDays(withdrawalDeadline(new Date(auftrag.gesendetAm)).lastDay, 1)`. +- `zahlungsplanText(auftrag)`: `ziel` = `zahlungszielTage` (ganze Zahl, sonst `LIMITS.standardZahlungszielTage`). Je `zahlungsplan.art` der Baustein aus `AB_TEXTE.zahlungsplan` (`nach_abnahme({ ziel })`, `monatlich({ ziel })`, `frei({ text, ziel })`, `anzahlung({ prozent, anzahlung, rest, ziel })`). Bei `anzahlung`: `anzahlung = Math.round(summeCents * prozent / 100)`, `rest = summeCents - anzahlung`, beide mit `euroDe`; fehlt `summeCents` (nicht ganze Zahl) oder `prozent` (keine Zahl), gilt der Text von `nach_abnahme`. Ein fehlendes `zahlungsplan` gilt als `nach_abnahme`. +- `aufwandText(auftrag)`: `null`, wenn `art !== 'aufwand'`; sonst `AB_TEXTE.aufwand({ satz: euroDe(stundensatzCents ?? STUNDENSATZ_STANDARD_CENTS), schaetzung })` mit `schaetzung` = Stunden als Text aus `schaetzungMinuten` (`Math.round(minuten / 60 * 100) / 100`, Dezimalkomma, ohne überflüssige Nullen: 600 → `'10'`, 135 → `'2,25'`, 90 → `'1,5'`), `null` wenn leer oder ≤ 0. +- `auftragFuerTeam(auftrag)`: flache Kopie ohne `tokenHash`, `pdfDatei`, `bestaetigtPdfSha256`, plus `pdfVorhanden: Boolean(auftrag.pdfDatei)`, `fruehesterBeginn`, `zahlungsplanText`, `aufwandText`. Verändert das Argument nicht. +- `oeffentlicheAnsicht(auftrag)`: genau die 19 Felder aus V3 (`AuftragOeffentlich`): `datum` = `berlinDate(new Date(gesendetAm))` oder `null` ohne `gesendetAm`; `positionen` mit zusätzlichem `summeCents = menge * einzelpreisCents`; `kundenart` = `auftrag.kunde?.kundenart || ''`; `agbUrl` = `/rechtliches/agb-.pdf` (die bei der AB gespeicherte Fassung, V3); `texte = { bestaetigung: BESTAETIGUNG_TEXT, fruehbeginn: kundenart === 'privat' ? FRUEHBEGINN_TEXT : null, kleinunternehmer: AB_TEXTE.kleinunternehmer }` (V3); `fruehesterBeginn` und die beiden Texte wie oben. Keine Kundendaten, kein Token, keine Pfade. + +- [ ] **Step 4: Lauf, erwartet PASS** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAuftragSicht.test.js` +Expected: PASS (neun Tests, `fail 0`). + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/auftragSicht.js test/auftraegeAuftragSicht.test.js && git commit -m "Aufträge: Sichten auf die Auftragsbestätigung (Team, öffentlich, Frühbeginn)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 11: PDF-Ablage + +**Files:** +- Create: `server/services/auftraege/ablage.js` +- Test: `test/auftraegeAblage.test.js` + +**Interfaces:** +- Consumes: `config.auftraege.uploadDir` (Task 4); `AuftragError` (Task 2). +- Produces (Übersicht V4): `speicherePdf(kundenId, auftragId, buffer) → Promise<{ pdfDatei: '/.pdf', pdfSha256 }>` (legt Verzeichnis 0700 und Datei 0600 an, überschreibt nie), `lesePdf(pdfDatei) → Promise` (nur `^[A-Za-z0-9_-]{1,64}/[A-Za-z0-9_-]{1,64}\.pdf$`, sonst `AuftragError(404, 'PDF nicht gefunden.')`); zusätzlich `entfernePdf(pdfDatei) → Promise` (Nachträge L0: räumt ein abgelegtes PDF weg, wenn danach etwas scheitert; L3 nutzt genau diesen Namen). + +- [ ] **Step 1: Failing Test schreiben** + +```js +// test/auftraegeAblage.test.js +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +// Die Ablage liest ihren Ordner aus config.auftraege.uploadDir: AUFTRAEGE_UPLOAD_DIR muss vor dem ersten Import von config.js stehen. +// Die Ablage liegt in einem Unterordner der Wurzel, damit „Köder“-Dateien in der Wurzel einen Ausbruch aus der Ablage (../) sichtbar machen. +const PRAEFIX = path.join(os.tmpdir(), 'auftraege-ablage-') +const wurzel = mkdtempSync(PRAEFIX) +const basis = path.join(wurzel, 'ablage') +mkdirSync(basis, { mode: 0o700 }) +process.env.AUFTRAEGE_UPLOAD_DIR = basis +after(() => { + if (wurzel.startsWith(PRAEFIX)) rmSync(wurzel, { recursive: true, force: true }) +}) + +const { speicherePdf, lesePdf, entfernePdf } = await import('../server/services/auftraege/ablage.js') +const { AuftragError } = await import('../server/services/auftraege/fehler.js') + +const PDF = Buffer.from('%PDF-1.7\n% Auftragsbestätigung\n%%EOF\n') +const sha = (buffer) => createHash('sha256').update(buffer).digest('hex') +const nichtGefunden = (err) => err instanceof AuftragError && err.status === 404 && err.message === 'PDF nicht gefunden.' + +test('speicherePdf legt /.pdf an: Prüfsumme, Rechte 0700 und 0600, Inhalt unverändert', async () => { + const ergebnis = await speicherePdf('c1', 'ab1', PDF) + assert.deepEqual(ergebnis, { pdfDatei: 'c1/ab1.pdf', pdfSha256: sha(PDF) }) + assert.match(ergebnis.pdfSha256, /^[0-9a-f]{64}$/) + const datei = path.join(basis, 'c1', 'ab1.pdf') + assert.deepEqual(readFileSync(datei), PDF) + assert.equal(statSync(datei).mode & 0o777, 0o600) + assert.equal(statSync(path.join(basis, 'c1')).mode & 0o777, 0o700) + assert.deepEqual(await lesePdf('c1/ab1.pdf'), PDF) +}) + +test('speicherePdf überschreibt nie: die vorhandene Datei bleibt, der zweite Aufruf ist 409', async () => { + await speicherePdf('c2', 'ab1', PDF) + await assert.rejects( + () => speicherePdf('c2', 'ab1', Buffer.from('%PDF-1.7\nanderer Inhalt\n')), + (err) => err instanceof AuftragError && err.status === 409, + ) + assert.deepEqual(readFileSync(path.join(basis, 'c2', 'ab1.pdf')), PDF) + assert.equal((await speicherePdf('c3', 'ab1', PDF)).pdfDatei, 'c3/ab1.pdf', 'dieselbe AB-ID bei einem anderen Kunden ist eine andere Datei') +}) + +test('speicherePdf: ungültige Kennungen und Puffer sind Programmfehler und legen nichts an', async () => { + const vorher = readdirSync(basis).sort() + const kennungen = [['', 'ab1'], ['c1', ''], ['../x', 'ab1'], ['c1', '../ab1'], ['c1/x', 'ab1'], ['c1', 'a.b'], ['c1', 'a'.repeat(65)], [undefined, 'ab1'], ['c1', 5]] + for (const [kunde, ab] of kennungen) await assert.rejects(() => speicherePdf(kunde, ab, PDF), TypeError, JSON.stringify([kunde, ab])) + for (const buffer of [Buffer.alloc(0), 'text', undefined, null, new Uint8Array(3)]) { + await assert.rejects(() => speicherePdf('c9', 'ab9', buffer), TypeError, String(buffer)) + } + assert.deepEqual(readdirSync(basis).sort(), vorher) +}) + +test('lesePdf: Pfadtricks, fremde Zeichen und falsche Form sind 404; erlaubt sind 64 Zeichen aus A–Z, a–z, 0–9, _ und -', async () => { + await speicherePdf('c4', 'ab1', PDF) + writeFileSync(path.join(wurzel, 'geheim.pdf'), 'GEHEIM') // neben der Ablage: ein Ausbruch per ../ würde sie lesen + const falsch = [ + 'c4/../geheim.pdf', '../geheim.pdf', 'c4/ab1.pdf/../../geheim.pdf', '/c4/ab1.pdf', `${basis}/c4/ab1.pdf`, 'c4\\ab1.pdf', + 'c4/ab1.PDF', 'c4/ab1.pdf\0', 'c4/ab1.pdf.exe', 'c4/ab 1.pdf', 'c4/ab1', 'ab1.pdf', 'c4/x/ab1.pdf', '', ' ', + `${'a'.repeat(65)}/ab1.pdf`, `c4/${'a'.repeat(65)}.pdf`, 'c4/ä.pdf', undefined, null, 42, {}, + ] + for (const pdfDatei of falsch) await assert.rejects(() => lesePdf(pdfDatei), nichtGefunden, JSON.stringify(pdfDatei)) + await assert.rejects(() => lesePdf('c4/gibtsnicht.pdf'), nichtGefunden) + const lang = `${'A'.repeat(32)}_${'b'.repeat(31)}` + await speicherePdf('K_1-x', lang, PDF) + assert.deepEqual(await lesePdf(`K_1-x/${lang}.pdf`), PDF) +}) + +test('entfernePdf entfernt eine gespeicherte Datei und prüft den Pfad wie lesePdf; danach lässt sich dieselbe AB erneut speichern', async () => { + await speicherePdf('c5', 'ab1', PDF) + await entfernePdf('c5/ab1.pdf') + assert.equal(existsSync(path.join(basis, 'c5', 'ab1.pdf')), false) + await entfernePdf('c5/ab1.pdf') + await assert.rejects(() => lesePdf('c5/ab1.pdf'), nichtGefunden) + writeFileSync(path.join(wurzel, 'geheim2.pdf'), 'x') + for (const pfad of ['../geheim2.pdf', 'c5/../geheim2.pdf']) await assert.rejects(() => entfernePdf(pfad), nichtGefunden, pfad) + assert.ok(existsSync(path.join(wurzel, 'geheim2.pdf'))) + assert.equal((await speicherePdf('c5', 'ab1', PDF)).pdfDatei, 'c5/ab1.pdf') +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAblage.test.js` +Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `server/services/auftraege/ablage.js`. + +- [ ] **Step 3: Implementieren** + +`server/services/auftraege/ablage.js` (neu, Verzeichnis `config.auftraege.uploadDir` wird bei jedem Aufruf gelesen): +- Kennungen müssen Texte im Format `^[A-Za-z0-9_-]{1,64}$` sein (eine Zahl oder `undefined` ist ungültig, auch wenn ihre Textform passen würde); Dateiname immer `/.pdf`; Pfadformat für Lesen und Löschen `^[A-Za-z0-9_-]{1,64}/[A-Za-z0-9_-]{1,64}\.pdf$` (kein Punkt außer vor `pdf`, damit kein `..` möglich ist; `typeof pdfDatei !== 'string'` passt nie). +- `speicherePdf(kundenId, auftragId, buffer)`: prüft **zuerst** beide Kennungen und `Buffer.isBuffer(buffer) && buffer.length > 0` (`TypeError`, nichts wird angelegt). Dann `mkdir(/, { recursive: true, mode: 0o700 })`, Datei mit `writeFile(pfad, buffer, { flag: 'wx', mode: 0o600 })` (existiert sie schon: `AuftragError(409, 'Das PDF dieser Auftragsbestätigung ist schon gespeichert.')`, die vorhandene Datei bleibt unberührt; scheitert das Schreiben nach dem Anlegen, die Teildatei entfernen und den Fehler weiterreichen). Rückgabe `{ pdfDatei, pdfSha256 }` mit `pdfSha256` = SHA-256 des Puffers als Kleinbuchstaben-Hex. +- `lesePdf(pdfDatei)`: Pfadformat prüfen, sonst `AuftragError(404, 'PDF nicht gefunden.')`; Datei lesen; `ENOENT` → dieselbe 404; andere Fehler weiterreichen. +- `entfernePdf(pdfDatei)`: Pfadformat wie `lesePdf` (sonst dieselbe 404); `unlink`, `ENOENT` ist folgenlos. Das Kundenverzeichnis bleibt stehen. + +- [ ] **Step 4: Lauf, erwartet PASS** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAblage.test.js` +Expected: PASS (fünf Tests, `fail 0`). + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/ablage.js test/auftraegeAblage.test.js && git commit -m "Aufträge: PDF-Ablage mit Pfadprüfung" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 12: Steuernummer in den Finanz-Einstellungen + +**Files:** +- Modify: `server/services/finance/settings.js`, `test/financeSettings.test.js` + +**Interfaces:** +- Consumes: `SettingsError`, `SETTINGS_RULES`, `DEFAULT_SETTINGS` in `settings.js` (bestehend); `schemaKeys('financeSettings')` mit `steuernummer` (Task 3). +- Produces: Einstellung `steuernummer` (Vorgabe `''`, getrimmt, höchstens 40 Zeichen; erlaubt sind Buchstaben A–Z und a–z, Ziffern, Leerzeichen, `/`, `-` und `.`, also auch eine USt-IdNr. „DE…“; `''` löscht den Wert; sonst `SettingsError(400, 'Steuernummer ungültig.')`) in `getSettings()`, `patchSettings()` und damit in `GET`/`PATCH /api/admin/finanzen/einstellungen`. + +- [ ] **Step 1: Failing Test schreiben** + +In `test/financeSettings.test.js` drei Änderungen (die übrigen Tests der Datei bleiben unverändert): + +1. Zeile mit `const DEFAULTS = ...` (kurz vor den Abschnitt „Zugriff“): `steuernummer: ''` ergänzen: + +```js +const DEFAULTS = { minBufferCents: 0, taxReservePercent: 30, previousYearRevenueCents: null, estPrepaymentCents: 0, bankDetailsText: '', steuernummer: '' } +``` + +2. Den Titel des Tests `GET /einstellungen: nur die fuenf Felder, nie Appwrite-Metafelder` in `... nur die sechs Felder, nie Appwrite-Metafelder` ändern. + +3. Direkt nach dem Test `PATCH: der Bankverbindungstext wird ohne Rand-Leerraum gespeichert` einfügen: + +```js +test('PATCH steuernummer: Vorgabe leer, getrimmt gespeichert; Buchstaben, Ziffern, Leerzeichen, / - und . bis 40 Zeichen, sonst 400', async () => { + assert.equal((await getSettings()).einstellungen.steuernummer, '') + const getrimmt = await patchSettings({ steuernummer: ' 12/345/67890 ' }) + assert.equal(getrimmt.status, 200, JSON.stringify(getrimmt.body)) + assert.equal(getrimmt.body.einstellungen.steuernummer, '12/345/67890') + assert.equal(storedSettings().steuernummer, '12/345/67890') + assert.equal((await getSettings()).einstellungen.steuernummer, '12/345/67890') + for (const wert of ['DE123456789', 'de 123.456.789', '12 345 67890', '12-345-678', 'A'.repeat(40), ` ${'1'.repeat(40)} `, ' ', '']) { + const res = await patchSettings({ steuernummer: wert }) + assert.equal(res.status, 200, JSON.stringify(wert)) + assert.equal(res.body.einstellungen.steuernummer, wert.trim()) + assert.equal(storedSettings().steuernummer, wert.trim()) + } + assert.equal((await patchSettings({ steuernummer: 'DE123456789' })).status, 200) + for (const wert of [' + + +``` + +`public/auftrag.css`: Kopf-Kommentar („Bestätigungsseite für Auftragsbestätigungen und Abschnitt ‚Aufträge‘ im Portal; Farben wie die Mailvorlagen“). Die Datei wird auch vom Dashboard geladen, deshalb ist jede Regel an `body.ab-seite` bzw. `.ab-portal` gebunden (kein Eingriff in andere Seiten); einzige Ausnahmen sind `.ab-rechnungen-titel` (Task 7) und `.inv-project` (Task 8). Inhalt dieses Tasks (Teil „öffentliche Seite“): + +| Bereich | Vorgabe | +|---|---| +| Schlüssel (hell, auf `body.ab-seite`) | `--ab-grund #f1f0ec`, `--ab-karte #ffffff`, `--ab-text #111111`, `--ab-weich #4a4843`, `--ab-linie #e8e6e0`, `--ab-box #f7f6f3`, `--ab-knopf #111111`, `--ab-knopf-text #ffffff`, Pillen `--ab-warn-bg #efe6fb` / `--ab-warn-text #5a2a91`, `--ab-ok-bg #e4f2e6` / `--ab-ok-text #1d5b2c`, `--ab-rot-bg #fbe4e1` / `--ab-rot-text #8c1d18`, `--ab-muted-bg #ecebe6` / `--ab-muted-text #4a4843`, `--ab-fehler #b3261e` | +| Dunkel (`@media (prefers-color-scheme: dark)`) | `--ab-grund #161615`, `--ab-karte #1f1f1e`, `--ab-text #f2f1ee`, `--ab-weich #a8a6a0`, `--ab-linie #3a3a37`, `--ab-box #2a2a28`, `--ab-knopf #f2f1ee`, `--ab-knopf-text #161615`, Pillen und `--ab-fehler` entsprechend abgedunkelt (Kontrast mindestens 4,5 : 1) | +| Grundlayout | `body.ab-seite` ohne Rand, `-webkit-text-size-adjust: 100%`, `min-height: 100dvh`, Spalte (`display: flex; flex-direction: column`), Systemschrift 16 px/1,55; `.ab-innen` höchstens 40 rem, 16 px Rand; `.ab-kopf` mit Logo (36 px, Radius 10) und Wortmarke, oben `max(20px, env(safe-area-inset-top))`; `.ab-wurzel` als Karte (Radius 16, Rand `--ab-linie`, Innenabstand 28/24 px, auf dem Handy 22/18 px) mit `overflow-wrap: anywhere`; `.ab-haupt { flex: 1 0 auto }` | +| Inhalt | `.ab-pille` (Kapsel, 12 px fett) mit `ab-pille-ok/warn/muted/rot`; `.ab-h1` 1,6 rem; `.ab-h2` als Beschriftung (0,8 rem, Großbuchstaben, `--ab-weich`); `.ab-liste` Zeilen als Raster 9 rem + Rest (auf dem Handy untereinander); `.ab-zeile` mit Trennlinie; `.ab-gesamt` Zeile mit Abstand und fetter Summe; `.ab-hinweis` klein, `--ab-weich`; `.ab-text` behält Zeilenumbrüche (`white-space: pre-line`); `.ab-links a` mindestens 44 px hoch; `.ab-bestaetigt` als grüne Box (`--ab-ok-*`), `:focus-visible` mit Rahmen | +| Tabelle | Desktop: normale Tabelle; unter 640 px: `thead` nur für Screenreader (`.ab-vh`-Technik), `tr` als Block mit Trennlinie, `td` als Zeile mit `td::before { content: attr(data-label) }` (erste Zelle ohne Marke, fett) | +| Formular | `.ab-haken` Zeile mit 22-px-Haken (`accent-color`) und Text 15 px; `.ab-frei` klein, `--ab-weich`; `.ab-fehler` in `--ab-fehler`, fett; `.ab-knopf` mit `min-height: 48px`, Radius 10, `--ab-knopf`/`--ab-knopf-text`, auf dem Handy volle Breite, `:disabled` mit `opacity .45`, `:focus-visible` mit 3-px-Rahmen; `.ab-notiz`, `.ab-gesperrt` klein und `--ab-weich` | +| Fuß und Hilfsklassen | `.ab-fuss` klein, Links nebeneinander mit 16 px Abstand, unten `max(24px, env(safe-area-inset-bottom))`; `.ab-vh` (nur für Screenreader); `body.ab-seite .hidden { display: none !important }` | +| Installierte App | `.ab-app-back { display: none }`; `@media (display-mode: standalone)`: `.ab-app-back { display: inline-block; … }` und `.ab-kopf` oben `calc(env(safe-area-inset-top) + 12px)` (Muster `legal-form.css`) | + +- [ ] **Step 4: Lauf, erwartet PASS** + +Derselbe Befehl wie in Step 2. Erwartet: `fail 0`. + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && git add public/auftrag.html public/auftrag.css test/auftraegeKundenportalHuelle.test.js && git commit -m "Kundenportal Aufträge: auftrag.html und auftrag.css (noindex, Mail-Stil, Fußzeile, Standalone) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 7: Abschnitt „Aufträge“ im Portal `public/auftrag-portal.js` + +**Files:** +- Create: `public/auftrag-portal.js` +- Modify: `public/auftrag.css` (Teil „Portal“, `.ab-portal` und die Karten) +- Create: `test/auftraegeKundenportalPortal.test.js` + +**Interfaces:** +- Consumes: Task 3 (`texte`, `ANREDE_DU`, `statusAnzeige`, `kartenMeta`, `sortiereAuftraege`, `darfBestaetigen`, `agbPfad`), Task 4 (`h`, `renderZusammenfassung`, `renderDokumente`, `renderBestaetigtBlock`, `renderBestaetigung`); `request(path, { method, body })` wie `api()` in `app.js` (liefert JSON, wirft `Error` mit `status`; `body` ist bereits ein JSON-Text); HTTP laut V5: `GET /api/auftraege` → `{ aktiv, auftraege: (AuftragOeffentlich & { id, kannBestaetigen })[] }` (`kannBestaetigen` laut Plan von L5: `true` bei Status `versendet`/`abgerechnet` ohne `bestaetigtAm`, in der Kundenansicht immer `false`), `GET /api/auftraege/:id/pdf` (Link), `POST /api/auftraege/:id/bestaetigen` → `{ auftrag }`; Antworten 404 und 410 beim Bestätigen laut L5-Plan. +- Produces: `initAuftraege({ section, body, invoicesTitle, request, viewAs = false }) → Promise<{ sichtbar: boolean }>`; Klassen `ab-portal`, `ab-karte`, `ab-karte-kopf`, `ab-karte-titel`, `ab-karte-meta`, `ab-karte-aktionen`, `ab-details`; Marken `data-auftrag=""`, `data-status`. + +- [ ] **Step 1: Failing Test schreiben** + +`test/auftraegeKundenportalPortal.test.js`: + +```js +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { installFakeDom } from './support/fakeDom.js' +import { h } from '../public/auftrag-dom.js' +import { initAuftraege } from '../public/auftrag-portal.js' +import { ab, privat, bestaetigt, eintrag, pruefeQuelltext } from './auftraegeKundenportalDaten.js' + +const NB = '\u{a0}' +const mit = (fn) => async () => { + const fx = installFakeDom() + try { + await fn(fx) + } finally { + fx.restore() + } +} +function aufbau(fx) { + const body = h('div', { id: 'orders-body' }) + const section = h('section', { id: 'orders-section', class: 'ab-portal hidden' }, body) + const invoicesTitle = h('h2', { id: 'invoices-title', class: 'hidden' }, 'Rechnungen') + fx.document.body.append(section, invoicesTitle) + return { section, body, invoicesTitle } +} +/** request wie api() in app.js: gibt die nächste Antwort zurück oder wirft sie (Error mit status). */ +function fakeRequest(antworten) { + const aufrufe = [] + const request = async (pfad, optionen = {}) => { + aufrufe.push({ pfad, methode: optionen.method || 'GET', body: optionen.body ? JSON.parse(optionen.body) : undefined }) + const antwort = antworten.shift() + if (antwort instanceof Error) throw antwort + return antwort + } + return { request, aufrufe } +} +const fehler = (status, text = `Fehler ${status}`) => Object.assign(new Error(text), { status }) +const sichtbar = (el) => !el.classList.contains('hidden') +const liste = (...auftraege) => ({ aktiv: true, auftraege }) + +const A_OFFEN = eintrag({ id: 'ab1', nummer: 'AB-2026-0002' }) +const A_OHNE_RECHT = eintrag({ id: 'ab4', nummer: 'AB-2026-0004', kannBestaetigen: false }) // Status offen, der Server erlaubt das Bestätigen nicht +const A_FERTIG = eintrag({ id: 'ab2', ...bestaetigt(privat({ fruehbeginn: true, fruehesterBeginn: null })), nummer: 'AB-2026-0003', kannBestaetigen: false }) +const A_STORNO = eintrag({ + id: 'ab0', nummer: 'AB-2026-0001', status: 'storniert', kannBestaetigen: false, art: 'aufwand', positionen: [], summeCents: null, + aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten.', +}) +const bestaetige = async (fx, wurzel) => { + await fx.klick(fx.finde(wurzel, '[data-feld="bestaetigung"]')) + await fx.klick(fx.finde(wurzel, '[data-aktion="bestaetigen"]')) +} + +test('Der Abschnitt bleibt weg: Schalter aus, keine Aufträge, Ladefehler', mit(async (fx) => { + for (const antwort of [{ aktiv: false, auftraege: [] }, { aktiv: false, auftraege: [A_OFFEN] }, liste(), fehler(500), fehler(401)]) { + const els = aufbau(fx) + const { request, aufrufe } = fakeRequest([antwort]) + assert.deepEqual(await initAuftraege({ ...els, request, viewAs: false }), { sichtbar: false }) + assert.equal(sichtbar(els.section), false) + assert.equal(sichtbar(els.invoicesTitle), false) + assert.deepEqual(aufrufe, [{ pfad: '/api/auftraege', methode: 'GET', body: undefined }]) + els.section.remove() + els.invoicesTitle.remove() + } +})) + +test('Liste: Bestätigbares zuerst, Status, Zeile, PDF-Link; nur der offene Auftrag ist aufgeklappt und hat das Formular', mit(async (fx) => { + const els = aufbau(fx) + const { request } = fakeRequest([liste(A_STORNO, A_FERTIG, A_OHNE_RECHT, A_OFFEN)]) + assert.deepEqual(await initAuftraege({ ...els, request, viewAs: false }), { sichtbar: true }) + assert.equal(sichtbar(els.section), true) + assert.equal(sichtbar(els.invoicesTitle), true) + const karten = fx.alle(els.body, '.ab-karte') + assert.deepEqual(karten.map((k) => k.getAttribute('data-auftrag')), ['ab1', 'ab4', 'ab2', 'ab0']) + const [offen, ohneRecht, fertig, storno] = karten + assert.equal(fx.finde(offen, '.ab-pille').textContent, 'Bitte bestätigen') + assert.equal(fx.finde(ohneRecht, '.ab-pille').textContent, 'Versendet') + assert.equal(fx.finde(ohneRecht, 'form'), null, 'ohne kannBestaetigen vom Server gibt es kein Formular') + assert.equal(fx.finde(fertig, '.ab-pille').textContent, 'Bestätigt') + assert.equal(fx.finde(storno, '.ab-pille').textContent, 'Storniert') + assert.equal(fx.finde(offen, '.ab-karte-titel').textContent, 'Website Anelia Wolf') + assert.equal(fx.finde(offen, '.ab-karte-meta').textContent, `vom 06.10.2026 · 800,00${NB}€`) + assert.equal(fx.finde(storno, '.ab-karte-meta').textContent, 'vom 06.10.2026 · nach Aufwand') + assert.equal(fx.finde(offen, '[data-link="pdf"]').getAttribute('href'), '/api/auftraege/ab1/pdf') + assert.equal(fx.finde(offen, '[data-link="pdf"]').textContent, 'PDF ansehen') + assert.equal(fx.finde(offen, 'summary').textContent, 'Details ansehen und bestätigen') + assert.equal(fx.finde(fertig, 'summary').textContent, 'Details ansehen') + assert.equal(fx.finde(offen, 'details').hasAttribute('open'), true) + assert.equal(fx.finde(fertig, 'details').hasAttribute('open'), false) + assert.ok(fx.finde(offen, 'form [data-feld="bestaetigung"]')) + assert.equal(fx.finde(fertig, 'form'), null) + assert.equal(fx.finde(storno, 'form'), null) + assert.deepEqual(fx.alle(fx.finde(fertig, '[data-bestaetigt]'), 'p').map((p) => p.textContent), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf deinen Wunsch schon vor Ablauf der Widerrufsfrist.']) + assert.equal(fx.text(fx.finde(offen, 'legend')), 'Deine Bestätigung', 'Portal: Du-Form') + assert.equal(fx.text(fx.finde(offen, '.ab-notiz')), 'Deine Bestätigung wird mit Datum und Uhrzeit gespeichert.') + assert.deepEqual(fx.alle(offen, 'h4').map((x) => x.textContent), ['Leistungsumfang', 'Zahlung', 'Termine']) + assert.equal(fx.finde(offen, '[data-link="agb"]').getAttribute('href'), '/rechtliches/agb-2026-09-30.pdf') +})) + +test('Bestätigen: POST an /api/auftraege//bestaetigen, die Karte zeigt danach „Bestätigt“ (auch ohne id in der Antwort)', mit(async (fx) => { + const els = aufbau(fx) + const { request, aufrufe } = fakeRequest([liste(A_OFFEN), { auftrag: bestaetigt(ab()) }]) + await initAuftraege({ ...els, request, viewAs: false }) + await bestaetige(fx, els.body) + assert.deepEqual(aufrufe[1], { pfad: '/api/auftraege/ab1/bestaetigen', methode: 'POST', body: { bestaetigt: true, fruehbeginn: false } }) + const karte = fx.finde(els.body, '.ab-karte') + assert.equal(karte.getAttribute('data-auftrag'), 'ab1') + assert.equal(fx.finde(karte, '.ab-pille').textContent, 'Bestätigt') + assert.equal(fx.finde(karte, 'form'), null) + assert.equal(fx.finde(karte, 'details').hasAttribute('open'), true) + assert.equal(fx.document.activeElement, fx.finde(karte, '[data-bestaetigt]')) +})) + +test('Ansichtsmodus: Häkchen und Knopf gesperrt, Hinweis sichtbar, kein POST; der Abschnitt bleibt lesbar', mit(async (fx) => { + const els = aufbau(fx) + // wie L5 es liefert: in der Kundenansicht ist kannBestaetigen immer false; die Oberfläche zeigt die offene AB trotzdem mit gesperrtem Formular + const { request, aufrufe } = fakeRequest([liste(eintrag({ ...privat(), id: 'ab1', kannBestaetigen: false }))]) + await initAuftraege({ ...els, request, viewAs: true }) + assert.equal(fx.finde(els.body, '.ab-pille').textContent, 'Bitte bestätigen') + const form = fx.finde(els.body, 'form') + assert.equal(fx.alle(form, 'input').every((i) => i.disabled), true) + assert.equal(fx.finde(form, '[data-aktion="bestaetigen"]').disabled, true) + assert.equal(fx.finde(form, '[data-gesperrt]').textContent, 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.') + await fx.sende(form, 'submit') + assert.equal(aufrufe.length, 1, 'nur das Laden der Liste') + assert.ok(fx.finde(els.body, '[data-link="pdf"]')) + assert.ok(fx.finde(els.body, '[data-position]')) +})) + +test('Fehler beim Bestätigen: feste Meldung statt Servertext; ein 410 lädt die Liste neu', mit(async (fx) => { + const els = aufbau(fx) + const storniert = { ...A_OFFEN, status: 'storniert', kannBestaetigen: false } + const { request, aufrufe } = fakeRequest([liste(A_OFFEN), fehler(500), fehler(410, 'Diese Auftragsbestätigung wurde storniert.'), liste(storniert)]) + await initAuftraege({ ...els, request, viewAs: false }) + await bestaetige(fx, els.body) + assert.equal(fx.finde(els.body, '[data-fehler]').textContent, 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuche es später noch einmal.') + assert.ok(!fx.text(els.body).includes('Fehler 500')) + await fx.klick(fx.finde(els.body, '[data-aktion="bestaetigen"]')) + assert.equal(aufrufe.length, 4, 'Liste, POST, POST, Liste neu') + assert.equal(fx.finde(els.body, '.ab-pille').textContent, 'Storniert') + assert.equal(fx.finde(els.body, 'form'), null) +})) + +test('Fremdtext bleibt Text; die ID im PDF-Link wird kodiert', mit(async (fx) => { + const els = aufbau(fx) + const boese = ' & "Test"' + const { request } = fakeRequest([liste(eintrag({ id: 'a/b', titel: boese }))]) + await initAuftraege({ ...els, request, viewAs: false }) + assert.equal(fx.alle(els.body, 'script').length, 0) + assert.equal(fx.finde(els.body, '.ab-karte-titel').textContent, boese) + assert.equal(fx.finde(els.body, '[data-link="pdf"]').getAttribute('href'), '/api/auftraege/a%2Fb/pdf') +})) + +test('Quelltext und CSS: kein eigenes fetch, Karten und Abschnitt sind gestaltet', () => { + pruefeQuelltext('auftrag-portal.js', { importe: ['./auftrag-ansicht.js', './auftrag-dom.js'] }) + const css = readFileSync(new URL('../public/auftrag.css', import.meta.url), 'utf8') + for (const klasse of ['ab-portal', 'ab-karte', 'ab-karte-kopf', 'ab-karte-titel', 'ab-karte-meta', 'ab-karte-aktionen', 'ab-details']) { + assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[>]`), `.${klasse} ist gestaltet`) + } + assert.match(css, /\.ab-portal\s*\{[^}]*--ab-/, 'der Portal-Abschnitt setzt seine eigenen Farbschlüssel (dunkles Portal)') +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeKundenportalPortal.test.js +``` +Erwartet: FAIL (`Cannot find module '../public/auftrag-portal.js'`). + +- [ ] **Step 3: `public/auftrag-portal.js` und Portal-CSS** + +`initAuftraege({ section, body, invoicesTitle, request, viewAs })` (ES-Modul, Kopf-Kommentar „Abschnitt ‚Aufträge‘ im Reiter Rechnungen, geladen von app.js, loadCustomerOrders; `request` ist `api` aus app.js“). Anrede: **Du** wie das übrige Portal; das Modul benutzt nur `ANREDE_DU` und `texte(ANREDE_DU)`, kein eigener Text: + +- Wirksames `kannBestaetigen` je Eintrag, einmal beim Laden gesetzt: `darfBestaetigen(eintrag) && (viewAs || eintrag.kannBestaetigen === true)`. Grund: L5 liefert `kannBestaetigen` in der Kundenansicht immer als `false` (Vertragsfrage 3), die Oberfläche soll dort die offene AB trotzdem als „Bitte bestätigen“ mit gesperrtem Formular zeigen. Status-Pille (`statusAnzeige`), Sortierung, aufgeklappte Karte und Formular benutzen nur dieses wirksame Flag. +- Fehlen `section`, `body` oder `request`: `{ sichtbar: false }`. `lade()`: `await request('/api/auftraege')`; jeder Fehler → `{ sichtbar: false }` (kein Wurf, keine Anzeige). `data.aktiv !== true` oder keine Einträge → `{ sichtbar: false }`, `hidden` bleibt an Abschnitt und `invoicesTitle`. Sonst Einträge mit `typeof id === 'string'` und `id` nicht leer, `sortiereAuftraege`, Karten in `body` (`replaceChildren`), `hidden` von `section` und `invoicesTitle` entfernen, `{ sichtbar: true }`. +- Karte `article.ab-karte[data-auftrag=][data-status=]`: `div.ab-karte-kopf` mit Titelblock (`p.ab-karte-nummer` > `strong` Nummer, `p.ab-karte-titel` Titel (nur wenn vorhanden), `p.ab-karte-meta` aus `kartenMeta`) und `span.ab-pille.ab-pille-` aus `statusAnzeige`; `div.ab-karte-aktionen` mit `a.ab-link[data-link="pdf"]` (`texte(ANREDE_DU).kartePdf`, Ziel `/api/auftraege/${encodeURIComponent(id)}/pdf`, `target="_blank"`, `rel="noopener noreferrer"`); `details.ab-details` (Attribut `open` bei bestätigbaren Aufträgen und bei der Karte, die gerade bestätigt wurde) mit `summary` (`karteDetailsBestaetigen` bei bestätigbaren, sonst `karteDetails`), `renderZusammenfassung(auftrag, { ebene: 4 })`, `renderDokumente({ agbHref: agbPfad(auftrag.agbUrl) })`, dann `renderBestaetigtBlock(auftrag, { anrede: ANREDE_DU })` bzw. bei wirksamem `kannBestaetigen` das Formular. +- Formular: `renderBestaetigung(auftrag, { senden, nachErfolg, nachEnde, gesperrt: viewAs ? texte(ANREDE_DU).ansichtsmodus : '', anrede: ANREDE_DU })`; `senden(koerper)` ruft `request` mit dem Pfad `/api/auftraege//bestaetigen` (`id` per `encodeURIComponent`) und `{ method: 'POST', body: JSON.stringify(koerper) }` auf → `{ ok: true, auftrag: antwort.auftrag }`, bei Wurf `{ ok: false, status: Number(err?.status) || 0, body: { error: String(err?.message ?? '') } }` (der Text dient nur der Erkennung, `renderBestaetigung` zeigt ihn nie). `nachErfolg(neu)`: den Eintrag in der Liste durch `{ ...alt, ...neu, id: alt.id, kannBestaetigen: false }` ersetzen und alle Karten per `replaceChildren` neu zeichnen (die eben bestätigte Karte aufgeklappt, Reihenfolge wie beim Laden), dann in ihr `[data-bestaetigt]` fokussieren (der Plan von L5 liefert `id` und `kannBestaetigen` auch in der POST-Antwort; das Zusammenführen bleibt als Vorsicht). `nachEnde()`: Liste neu laden (`GET /api/auftraege`) und neu zeichnen; scheitert das, bleibt die Anzeige stehen. + +`public/auftrag.css` (Teil „Portal“, an `.ab-portal` gebunden): `.ab-portal` setzt dunkle Schlüssel `--ab-*` aus den Portalfarben (`--kb-surface`, `--kb-border`, `--kb-muted`, `--kb-purple`, Pillen wie `.pill-ok/-warn/-red/-muted` in `kundenbereich.css`), `margin: 0 0 28px`; `.ab-portal h2` 20 px; `.ab-karte` wie `.kb-panel` (Radius 16, Rand, 16/18 px Innenabstand, 12 px Abstand zwischen Karten); `.ab-karte-kopf` Zeile mit Abstand, Pille rechts, `overflow-wrap: anywhere`; `.ab-karte-nummer` fett, `.ab-karte-meta` klein und gedämpft; `.ab-karte-aktionen` Zeile mit 10 px Abstand, Links als Kapsel mit mindestens 44 px Höhe; `.ab-details > summary` mindestens 44 px, Farbe `#d4b4ff`, fett; `.ab-knopf` im Portal lila (`--kb-purple`, weiße Schrift, Kapsel, `min-height: 48px`); `.ab-haken`, `.ab-fehler` (`#fca5a5`), `.ab-notiz`, `.ab-gesperrt`, Tabelle (unter 640 px gestapelt wie auf der Seite) in den Portalfarben; `.ab-rechnungen-titel` 20 px. + +- [ ] **Step 4: Lauf, erwartet PASS** + +Derselbe Befehl wie in Step 2. Erwartet: `fail 0`. Danach `test/auftraegeKundenportalHuelle.test.js` erneut laufen lassen (CSS wurde ergänzt): `fail 0`. + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && git add public/auftrag-portal.js public/auftrag.css test/auftraegeKundenportalPortal.test.js && git commit -m "Kundenportal Aufträge: Abschnitt „Aufträge“ im Portal (Karten, Bestätigen, Ansichtsmodus gesperrt) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 8: Dashboard verdrahten, Projekt unter der Rechnungsnummer + +**Files:** +- Modify: `public/dashboard.html` (Ansicht `view-invoices`, Stylesheet) +- Modify: `public/app.js` (`invoiceNumberHtml`, `loadCustomerOrders`, Aufruf in `initDashboardPage`) +- Modify: `public/auftrag.css` (Regel `.inv-project`) +- Create: `test/auftraegeKundenportalDashboard.test.js` + +**Interfaces:** +- Consumes: `initAuftraege` (Task 7); `api`, `escapeHtml`, `chatState.viewAs` aus `app.js`; `projekt` aus `/api/invoices` (Task 2). +- Produces: in `dashboard.html` die Elemente `#orders-section`, `#orders-body`, `#invoices-title`; in `app.js` die globalen Funktionen `invoiceNumberHtml(i) → string` (maskiert) und `loadCustomerOrders() → Promise`. + +- [ ] **Step 1: Failing Test schreiben** + +`test/auftraegeKundenportalDashboard.test.js`: + +```js +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import vm from 'node:vm' + +const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8') +const html = lies('public/dashboard.html') +const app = lies('public/app.js') +const css = lies('public/auftrag.css') + +test('dashboard.html: auftrag.css nach zeiten.css; Abschnitt „Aufträge“ vor der Rechnungsliste, zunächst versteckt', () => { + assert.match(html, /\s*/) + const start = html.indexOf('id="view-invoices"') + const ende = html.indexOf('', html.indexOf('id="invoices-abo-note"')) + const ansicht = html.slice(start, ende) + assert.match(ansicht, /