Buchungskalender: origin/main (Aufträge) zusammenführen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 15:46:31 +00:00
commit fd971309a8
145 files changed
+42590 -129

No files matched your search

+149
View File
@@ -0,0 +1,149 @@
# Allgemeine Geschäftsbedingungen
**Fassung 2026-09-30** · Wortgleiche Kopie der Allgemeinen Geschäftsbedingungen auf webklar.com/agb
## Kaufvertrag – WEBklar (Modularer Projektvertrag)
**zwischen**
WEBklar (im Folgenden „Anbieter“)
**und**
Kunde laut Angebot (im Folgenden „Kunde“)
## 1. Vertragsgegenstand
(1) Gegenstand dieses Vertrages ist die Erbringung der im Angebot definierten Leistungen.
(2) Der Vertrag besteht aus diesem Grundvertrag sowie den ausgewählten Leistungsmodulen.
(3) Maßgeblich ist das jeweils angenommene Angebot von WEBklar.
## 2. Leistungsart
(1) Sämtliche Leistungen von WEBklar stellen Dienst- und Entwicklungsleistungen dar.
(2) Ein bestimmter wirtschaftlicher, technischer oder rechtlicher Erfolg wird nicht geschuldet.
(3) WEBklar erbringt keinen laufenden Betrieb, sofern dieser nicht explizit vereinbart wurde.
## 3. Leistungsmodul A – Webseite (einmalig)
(aktiv, wenn im Angebot enthalten)
(1) WEBklar erstellt eine individuelle Webseite gemäß Angebot.
(2) Die Umsetzung erfolgt nach den vom Kunden gelieferten Inhalten und Vorgaben.
(3) Zusätzliche Leistungen wie Domain, Hosting, Wartung oder SEO sind nicht Bestandteil, sofern sie nicht gesondert beauftragt wurden.
(4) Der Kunde ist nicht berechtigt, Änderungen am Quellcode selbst vorzunehmen.
(5) Änderungen erfolgen ausschließlich durch WEBklar gegen gesonderte Vergütung.
## 4. Leistungsmodul B – Automatisierung / Virtualisierung (einmalig)
(aktiv, wenn im Angebot enthalten)
(1) WEBklar entwickelt individuelle Automatisierungen, Apps oder Virtualisierungssysteme.
(2) Die Leistung stellt eine reine Entwicklungsleistung dar.
(3) Optional kann eine Beratungsleistung Bestandteil des Projektes sein.
(4) Ein laufender Betrieb, Monitoring oder Wartung ist nicht geschuldet, außer dies wurde explizit vereinbart.
(5) Der Kunde entscheidet über Inhalte, Daten und Prozesse und trägt dafür die rechtliche Verantwortung.
## 5. Leistungsmodul C – Hosting
(aktiv, wenn im Angebot enthalten)
(1) Hosting bietet WEBklar als Hosting-Abo an. Für das Hosting-Abo und das Wartungs-Abo gelten die Vertragsbedingungen für die WEBklar-Abos mit Leistungsbeschreibungen; sie gehen diesen AGB vor.
(2) Bestehende Hosting-Verträge mit jährlicher Laufzeit behalten die bei ihrem Abschluss vereinbarten Bedingungen. Abweichend davon können Verbraucher solche Verträge nach einer automatischen Verlängerung jederzeit mit einer Frist von einem Monat kündigen.
(3) WEBklar ist berechtigt, externe Anbieter (z. B. Rechenzentren) einzusetzen.
## 6. Quellcode und Eigentum
### 6.1 Webseite
(1) Der Kunde erhält ein einfaches, zeitlich unbegrenztes Nutzungsrecht an der fertigen Webseite.
(2) Der Quellcode der Webseite wird nur auf ausdrückliche Anfrage und nach Vereinbarung herausgegeben.
(3) Ohne Vereinbarung verbleibt der Quellcode bei WEBklar und wird archiviert.
### 6.2 Apps, Automatisierungen und Backend
(1) Der Quellcode von Apps, Automatisierungen und Backend-Systemen verbleibt vollständig bei WEBklar.
(2) Eine Herausgabe erfolgt ausschließlich nach gesonderter schriftlicher Vereinbarung.
(3) Der Kunde erhält lediglich Zugriff auf die Bedienoberfläche bzw. das Frontend.
## 7. Mitwirkungspflichten des Kunden
(1) Der Kunde stellt alle benötigten Inhalte, Daten und Freigaben rechtzeitig bereit.
(2) Verzögerungen durch fehlende Mitwirkung gehen nicht zu Lasten von WEBklar.
(3) WEBklar ist nicht verpflichtet, rechtliche Prüfungen der Inhalte vorzunehmen.
## 8. Abnahme
(1) Nach Fertigstellung wird dem Kunden die Leistung zur Abnahme bereitgestellt.
(2) Erfolgt innerhalb von 14 Tagen keine Rückmeldung, gilt die Leistung als abgenommen.
(3) Nach Abnahme sind nur noch kostenpflichtige Änderungen möglich.
## 9. Vergütung und Zahlung
(1) Die Vergütung ergibt sich aus dem Angebot.
(2) Wird nach Aufwand abgerechnet, erfasst WEBklar die Arbeitszeit je Arbeitsschritt in Schritten von 15 Minuten; angefangene 15 Minuten werden aufgerundet. Wirkt eine weitere Person unterstützend mit, rechnet WEBklar für sie die Hälfte der Arbeitszeit des Arbeitsschritts an, aufgerundet auf volle 15 Minuten; bei mehreren unterstützenden Personen gilt das für jede. Jeder Arbeitsschritt und jede Unterstützung ist im Kundenportal einsehbar.
(3) Projektleistungen sind nach Vereinbarung fällig.
(4) Für die Abos gelten die Vertragsbedingungen für die WEBklar-Abos.
## 10. Zahlungsverzug
(1) Bei Zahlungsverzug erfolgen bis zu zwei Mahnungen.
(2) Danach ist WEBklar berechtigt:
- Leistungen zu sperren
- Verzugszinsen zu berechnen
- den Vertrag außerordentlich zu kündigen
## 11. Haftung
(1) WEBklar haftet unbeschränkt bei Vorsatz und grober Fahrlässigkeit, bei Verletzung von Leben, Körper oder Gesundheit, nach dem Produkthaftungsgesetz und soweit WEBklar eine Garantie übernommen hat.
(2) Bei leicht fahrlässiger Verletzung einer wesentlichen Vertragspflicht haftet WEBklar nur für den vertragstypischen, vorhersehbaren Schaden. Wesentlich sind Pflichten, deren Erfüllung die ordnungsgemäße Durchführung des Vertrags überhaupt erst ermöglicht und auf deren Einhaltung der Kunde regelmäßig vertrauen darf. Im Übrigen ist die Haftung für leichte Fahrlässigkeit ausgeschlossen.
(3) Der Kunde bewahrt Kopien der Inhalte und Daten auf, die er WEBklar übergibt oder selbst einpflegt. Für den Verlust solcher Inhalte und Daten haftet WEBklar außer in den Fällen von Absatz 1 nur in Höhe des Aufwands, der für ihre Wiederherstellung nötig gewesen wäre, wenn der Kunde diese Kopien aufbewahrt hätte.
(4) Diese Regeln gelten auch zugunsten der Mitarbeiter und Beauftragten von WEBklar.
## 12. Kündigung
(1) Laufzeitverträge können zum Ende der jeweiligen Laufzeit gekündigt werden. Laufzeit und Kündigung der Abos richten sich nach den Vertragsbedingungen für die WEBklar-Abos.
(2) Das Recht zur außerordentlichen Kündigung bleibt unberührt.
## 13. Referenzen
WEBklar darf das Projekt nur nach ausdrücklicher Zustimmung des Kunden als Referenz verwenden.
## 14. Schlussbestimmungen
(1) Es gilt deutsches Recht.
(2) Gerichtsstand ist der Sitz von WEBklar, soweit zulässig.
(3) Sollten einzelne Bestimmungen unwirksam sein, bleibt der Vertrag im Übrigen wirksam.
@@ -0,0 +1,103 @@
Normen, auf die sich die Texte der Auftragsbestätigung, der Kundenseiten und der Mails stützen (Auszüge, unverändert, abgerufen 2026-10-06 von gesetze-im-internet.de).
Die Datei dient als Nachweis der Zählung; test/auftraegeRechtstexte.test.js gleicht die Zitate in den Texten mit ihr ab.
Prüfung vom 06.10.2026 (zweiter Abruf von § 649 und § 650 BGB): Der Kostenanschlag steht in § 649 BGB (amtliche Überschrift „Kostenanschlag“). Absatz 2 lautet: „Ist eine solche Überschreitung des Anschlags zu erwarten, so hat der Unternehmer dem Besteller unverzüglich Anzeige zu machen.“ § 650 BGB regelt dagegen den Werklieferungsvertrag und Verbraucherverträge über die Herstellung digitaler Produkte; ein Verweis auf „§ 650“ für den Kostenanschlag ist falsch. Der Satz „Zeichnet sich eine deutliche Überschreitung ab, melden wir uns vorher.“ (AB_TEXTE.aufwand) stützt sich auf § 649 Absatz 2.
Quelle: https://www.gesetze-im-internet.de/bgb/__312f.html (Auszug: § 312f Absatz 1 und 2 BGB)
(1) Bei außerhalb von Geschäftsräumen geschlossenen Verträgen ist der Unternehmer verpflichtet, dem Verbraucher alsbald auf Papier zur Verfügung zu stellen
1.
eine Abschrift eines Vertragsdokuments, das von den Vertragsschließenden so unterzeichnet wurde, dass ihre Identität erkennbar ist, oder
2.
eine Bestätigung des Vertrags, in der der Vertragsinhalt wiedergegeben ist.
Wenn der Verbraucher zustimmt, kann für die Abschrift oder die Bestätigung des Vertrags auch ein anderer dauerhafter Datenträger verwendet werden. Die Bestätigung nach Satz 1 muss die in Artikel 246a des Einführungsgesetzes zum Bürgerlichen Gesetzbuche genannten Angaben nur enthalten, wenn der Unternehmer dem Verbraucher diese Informationen nicht bereits vor Vertragsschluss in Erfüllung seiner Informationspflichten nach § 312d Absatz 1 auf einem dauerhaften Datenträger zur Verfügung gestellt hat.
(2) Bei Fernabsatzverträgen ist der Unternehmer verpflichtet, dem Verbraucher eine Bestätigung des Vertrags, in der der Vertragsinhalt wiedergegeben ist, innerhalb einer angemessenen Frist nach Vertragsschluss, spätestens jedoch bei der Lieferung der Ware oder bevor mit der Ausführung der Dienstleistung begonnen wird, auf einem dauerhaften Datenträger zur Verfügung zu stellen. Die Bestätigung nach Satz 1 muss die in Artikel 246a des Einführungsgesetzes zum Bürgerlichen Gesetzbuche genannten Angaben enthalten, es sei denn, der Unternehmer hat dem Verbraucher diese Informationen bereits vor Vertragsschluss in Erfüllung seiner Informationspflichten nach § 312d Absatz 1 auf einem dauerhaften Datenträger zur Verfügung gestellt.
Quelle: https://www.gesetze-im-internet.de/bgb/__355.html (Auszug: § 355 Absatz 1 BGB)
(1) Wird einem Verbraucher durch Gesetz ein Widerrufsrecht nach dieser Vorschrift eingeräumt, so sind der Verbraucher und der Unternehmer an ihre auf den Abschluss des Vertrags gerichteten Willenserklärungen nicht mehr gebunden, wenn der Verbraucher seine Willenserklärung fristgerecht widerrufen hat. Der Widerruf erfolgt durch Erklärung gegenüber dem Unternehmer. Aus der Erklärung muss der Entschluss des Verbrauchers zum Widerruf des Vertrags eindeutig hervorgehen. Der Widerruf muss keine Begründung enthalten. Zur Fristwahrung genügt die rechtzeitige Absendung des Widerrufs.
Quelle: https://www.gesetze-im-internet.de/bgb/__356.html (Auszug: § 356 Absatz 4 bis 6 BGB)
(4) Das Widerrufsrecht erlischt spätestens zwölf Monate und 14 Tage nach dem in Absatz 2 oder § 355 Absatz 2 Satz 2 genannten Zeitpunkt. Bei Verträgen über Finanzdienstleistungen gilt Satz 1 nicht, wenn der Verbraucher nicht nach Artikel 246b § 1 Absatz 1 Nummer 16 des Einführungsgesetzes zum Bürgerlichen Gesetzbuche über sein Widerrufsrecht belehrt wurde.
(5) Das Widerrufsrecht erlischt bei Verträgen über die Erbringung von Dienstleistungen auch unter folgenden Voraussetzungen:
1.
bei einem Vertrag, der den Verbraucher nicht zur Zahlung eines Preises verpflichtet, wenn der Unternehmer die Dienstleistung vollständig erbracht hat,
2.
bei einem Vertrag, der den Verbraucher zur Zahlung eines Preises verpflichtet, mit der vollständigen Erbringung der Dienstleistung, wenn der Verbraucher vor Beginn der Erbringung
a)
ausdrücklich zugestimmt hat, dass der Unternehmer mit der Erbringung der Dienstleistung vor Ablauf der Widerrufsfrist beginnt,
b)
bei einem außerhalb von Geschäftsräumen geschlossenen Vertrag die Zustimmung nach Buchstabe a auf einem dauerhaften Datenträger übermittelt hat und
c)
seine Kenntnis davon bestätigt hat, dass sein Widerrufsrecht mit vollständiger Vertragserfüllung durch den Unternehmer erlischt,
3.
bei einem Vertrag, bei dem der Verbraucher den Unternehmer ausdrücklich aufgefordert hat, ihn aufzusuchen, um Reparaturarbeiten auszuführen, mit der vollständigen Erbringung der Dienstleistung, wenn der Verbraucher die in Nummer 2 Buchstabe a und b genannten Voraussetzungen erfüllt hat,
4.
bei einem Vertrag über die Erbringung von Finanzdienstleistungen, wenn der Vertrag von beiden Seiten auf ausdrücklichen Wunsch des Verbrauchers vollständig erfüllt ist, bevor der Verbraucher sein Widerrufsrecht ausübt.
(6) Das Widerrufsrecht erlischt bei Verträgen über die Bereitstellung von nicht auf einem körperlichen Datenträger befindlichen digitalen Inhalten auch unter folgenden Voraussetzungen:
1.
bei einem Vertrag, der den Verbraucher nicht zur Zahlung eines Preises verpflichtet, wenn der Unternehmer mit der Vertragserfüllung begonnen hat,
2.
bei einem Vertrag, der den Verbraucher zur Zahlung eines Preises verpflichtet, wenn
a)
der Unternehmer mit der Vertragserfüllung begonnen hat,
b)
der Verbraucher ausdrücklich zugestimmt hat, dass der Unternehmer mit der Vertragserfüllung vor Ablauf der Widerrufsfrist beginnt,
c)
der Verbraucher seine Kenntnis davon bestätigt hat, dass durch seine Zustimmung nach Buchstabe b mit Beginn der Vertragserfüllung sein Widerrufsrecht erlischt, und
d)
der Unternehmer dem Verbraucher eine Bestätigung gemäß § 312f zur Verfügung gestellt hat.
Quelle: https://www.gesetze-im-internet.de/bgb/__356a.html (Auszug: § 356a Absatz 2 und 4 BGB)
(2) Die Widerrufsfunktion muss dem Verbraucher ermöglichen, eine Widerrufserklärung an den Unternehmer zu übermitteln und dem Unternehmer in oder mit der Widerrufserklärung ohne Weiteres folgende Informationen bereitzustellen oder zu bestätigen:
1.
den Namen des Verbrauchers,
2.
Angaben zur Identifizierung des Vertrags oder des Teils des Vertrags, den der Verbraucher widerrufen möchte,
3.
Angaben zum elektronischen Kommunikationsmittel, mit welchem dem Verbraucher eine Eingangsbestätigung für den Widerruf zu übermitteln ist.
(4) Der Unternehmer hat dem Verbraucher, wenn dieser die Bestätigungsfunktion aktiviert hat, auf einem dauerhaften Datenträger unverzüglich eine Eingangsbestätigung zu übermitteln, die zumindest den Inhalt der Widerrufserklärung nach Absatz 2 sowie das Datum und die Uhrzeit ihres Eingangs enthält.
Quelle: https://www.gesetze-im-internet.de/bgb/__357a.html (Auszug: § 357a Absatz 2 BGB)
(2) Der Verbraucher hat Wertersatz für die bis zum Widerruf erbrachten Dienstleistungen, für die der Vertrag die Zahlung eines Preises vorsieht, oder die bis zum Widerruf erfolgte Lieferung von Wasser, Gas oder Strom in nicht bestimmten Mengen oder nicht begrenztem Volumen oder von Fernwärme zu leisten, wenn
1.
der Verbraucher von dem Unternehmer ausdrücklich verlangt hat, dass mit der Leistung vor Ablauf der Widerrufsfrist begonnen werden soll,
2.
bei einem außerhalb von Geschäftsräumen geschlossenen Vertrag der Verbraucher das Verlangen nach Nummer 1 auf einem dauerhaften Datenträger übermittelt hat und
3.
der Unternehmer den Verbraucher nach Artikel 246a § 1 Absatz 2 Satz 1 Nummer 1 und 3 des Einführungsgesetzes zum Bürgerlichen Gesetzbuche ordnungsgemäß informiert hat.
Bei der Berechnung des Wertersatzes ist der vereinbarte Gesamtpreis zu Grunde zu legen. Ist der vereinbarte Gesamtpreis unverhältnismäßig hoch, so ist der Wertersatz auf der Grundlage des Marktwerts der erbrachten Leistung zu berechnen.
Quelle: https://www.gesetze-im-internet.de/bgbeg/art_246a__1.html (Auszug: Artikel 246a § 1 Absatz 3 EGBGB)
(3) Der Unternehmer hat den Verbraucher auch zu informieren, wenn
1.
dem Verbraucher nach § 312g Absatz 2 Nummer 1, 2, 5 und 7 bis 13 des Bürgerlichen Gesetzbuchs ein Widerrufsrecht nicht zusteht, dass der Verbraucher seine Willenserklärung nicht widerrufen kann, oder
2.
das Widerrufsrecht des Verbrauchers nach § 312g Absatz 2 Nummer 3, 4 und 6 sowie § 356 Absatz 5 und 6 des Bürgerlichen Gesetzbuchs vorzeitig erlöschen kann, über die Umstände, unter denen der Verbraucher ein zunächst bestehendes Widerrufsrecht verliert.
Quelle: https://www.gesetze-im-internet.de/bgb/__640.html (Auszug: § 640 Absatz 2 BGB)
(2) Als abgenommen gilt ein Werk auch, wenn der Unternehmer dem Besteller nach Fertigstellung des Werks eine angemessene Frist zur Abnahme gesetzt hat und der Besteller die Abnahme nicht innerhalb dieser Frist unter Angabe mindestens eines Mangels verweigert hat. Ist der Besteller ein Verbraucher, so treten die Rechtsfolgen des Satzes 1 nur dann ein, wenn der Unternehmer den Besteller zusammen mit der Aufforderung zur Abnahme auf die Folgen einer nicht erklärten oder ohne Angabe von Mängeln verweigerten Abnahme hingewiesen hat; der Hinweis muss in Textform erfolgen.
Quelle: https://www.gesetze-im-internet.de/bgb/__649.html (§ 649 BGB, erneut abgerufen 2026-10-06; die amtliche Überschrift lautet „Kostenanschlag“)
§ 649 Kostenanschlag
(1) Ist dem Vertrag ein Kostenanschlag zugrunde gelegt worden, ohne dass der Unternehmer die Gewähr für die Richtigkeit des Anschlags übernommen hat, und ergibt sich, dass das Werk nicht ohne eine wesentliche Überschreitung des Anschlags ausführbar ist, so steht dem Unternehmer, wenn der Besteller den Vertrag aus diesem Grund kündigt, nur der im § 645 Abs. 1 bestimmte Anspruch zu.
(2) Ist eine solche Überschreitung des Anschlags zu erwarten, so hat der Unternehmer dem Besteller unverzüglich Anzeige zu machen.
Quelle: https://www.gesetze-im-internet.de/bgb/__650.html (Auszug: § 650 Absatz 2 BGB, zur Abgrenzung: § 650 ist nicht der Kostenanschlag; er regelt den Werklieferungsvertrag und Verbraucherverträge über digitale Produkte)
§ 650 Werklieferungsvertrag; Verbrauchervertrag über die Herstellung digitaler Produkte
(2) Auf einen Verbrauchervertrag, bei dem der Unternehmer sich verpflichtet,
1.
digitale Inhalte herzustellen,
2.
einen Erfolg durch eine digitale Dienstleistung herbeizuführen oder
3.
einen körperlichen Datenträger herzustellen, der ausschließlich als Träger digitaler Inhalte dient,
sind die §§ 633 bis 639 über die Rechte bei Mängeln sowie § 640 über die Abnahme nicht anzuwenden. An die Stelle der nach Satz 1 nicht anzuwendenden Vorschriften treten die Vorschriften des Abschnitts 3 Titel 2a. Die §§ 641, 644 und 645 sind mit der Maßgabe anzuwenden, dass an die Stelle der Abnahme die Bereitstellung des digitalen Produkts (§ 327b Absatz 3 bis 5) tritt.
Quelle: https://www.gesetze-im-internet.de/ao_1977/__147.html (Auszug: § 147 Absatz 1 Nummer 2 bis 4, Absatz 3 Satz 1 und Absatz 4 AO)
2.
die empfangenen Handels- oder Geschäftsbriefe,
3.
Wiedergaben der abgesandten Handels- oder Geschäftsbriefe,
4.
Buchungsbelege,
(3) Die in Absatz 1 Nummer 1 und 4a aufgeführten Unterlagen sind zehn Jahre, die in Absatz 1 Nummer 4 aufgeführten Unterlagen acht Jahre und die sonstigen in Absatz 1 aufgeführten Unterlagen sechs Jahre aufzubewahren, sofern nicht in anderen Steuergesetzen kürzere Aufbewahrungsfristen zugelassen sind.
(4) Die Aufbewahrungsfrist beginnt mit dem Schluss des Kalenderjahrs, in dem die letzte Eintragung in das Buch gemacht, das Inventar, die Eröffnungsbilanz, der Jahresabschluss oder der Lagebericht aufgestellt, der Handels- oder Geschäftsbrief empfangen oder abgesandt worden oder der Buchungsbeleg entstanden ist, ferner die Aufzeichnung vorgenommen worden ist oder die sonstigen Unterlagen entstanden sind.
@@ -0,0 +1,405 @@
# 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<Thema>.test.js` an; bestehende Testdateien nur, wenn die Tabelle sie nennt | je Lane |
### Nachträge nach der Planrunde (verbindlich, 06.10.2026)
Die Strang-Pläne wurden parallel geschrieben; ihre „Vertragsfragen“ sind hier entschieden. Wo ein Strang-Plan davon abweicht, gilt dieser Abschnitt.
**Zusätzliche Dateien je Strang (Zuständigkeit):**
- L0: zusätzlich `.gitignore` (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); hängt unter derselben `sperreAuftrag(id)` zuerst die Projektrechnungen der AB mit `projektId === ''` um (Stripe unverändert), zuletzt die AB. Stripe-Metadaten wandern beim Ersetzen **nicht** mit (nur `projektRechnungen.auftragId`).
**L4:** statische Figtree-TTFs aus `@expo-google-fonts/figtree` (OFL) unter `server/assets/fonts/`; reicht `privat` an `AB_TEXTE.termine` durch; leere Steuernummer → Zeile entfällt.
**L5:** K31-Link mit `#t=`; K31 ohne Team-BCC, Teamkopie ohne Link (Block `teamkopie`); T22-Betreff mit Präfix „[Aufträge]“; K32 mit `fruehbeginn_text`; K04/T09 Block `projekt`; `fruehbeginnFassung` bei Privatpersonen immer gespeichert, bei Unternehmen leer; Widerruf unabhängig vom Schalter, AB-Nummer optional.
**L6:** öffentliche Seite liest `#t=` (Rückfall `?t=`), sendet `credentials: 'omit'`; Abschnitt „Aufträge“ im Portal in Du-Form; Kleinunternehmer-Satz aus `texte.kleinunternehmer`; AB-Nummer auf der Widerrufsseite optional (Hinweis „falls zur Hand“); rechtlich gefärbte Sätze der Seiten stehen im Freigabe-Dokument von L7.
**L7:** Norm der Frühbeginn-Erklärung § 356 Abs. 5 Nr. 2 und § 357a Abs. 2 BGB; § 649 BGB (Kostenanschlag) ist die richtige Norm für den Überschreitungssatz; Datenschutz-Abschnitt als **letzter** Commit des Strangs (wird erst beim AB-Livegang gemergt); alte AGB-PDF-Fassungen bleiben liegen; Karten in WOMS → Rechtliches laut Freigabe-Dokument (Karte ohne Datei entfällt).
**WOMS:** Bildschirmfotos nur mit eigenen Skripten im Scratchpad (das Prüfwerkzeug des Redesigns unter `/root/reports/ticketsystem-design-2026-10-04/werkzeug*` wird nicht geändert); der WOMS-Push erfolgt erst, wenn das Redesign auf `test` liegt, und vorher wird `feature/auftraege` auf den dann aktuellen `origin/test` gebracht.
**Z:** Node-20-Läufe mit `node:20-alpine`; Merge mit `--no-ff` (kein Rebase der Strang-Branches); Gegenprüfung (haiku) nur für Critical/Important, Minors gehen ungeprüft als Liste an den Nutzer; die Prüfbilder des Endstands werden nach `/root/reports/auftraege-rechnungen-2026-10-06/bilder/` kopiert (der Scratchpad überlebt keinen Neustart); ein Löschlauf für alte ABs nach Ablauf der Aufbewahrung ist nicht im Umfang (Minor-Liste).
**Livegang:** Das Setup-Skript läuft **vor** dem Push (Wegwerf-Container mit dem Worktree, siehe Plan 12) oder als Ausweichweg sofort danach per `docker exec`; es legt `projektRechnungen` in jedem Fall an. Der WOMS-Push setzt das Attribut `customers.kundenart` voraus (Setup vorher).
---
## Global Constraints
- Sprache: alle Texte Deutsch; Mails, PDF und die öffentliche Bestätigungsseite in der **Sie**-Form; im angemeldeten Kundenportal (Dashboard, Abschnitt „Aufträge“) die **Du**-Form wie im übrigen Portal; WOMS wie bestehend.
- Schreibweise im Code: nie ein Unicode-Escape mit genau vier Hexziffern (Backslash, `u`, vier Ziffern) schreiben – das Schreibwerkzeug macht daraus das Zeichen selbst (z. B. ein unsichtbares geschütztes Leerzeichen); stattdessen `\u{…}` 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 <worktree> && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/<eigene Dateien>`. Die volle Suite (`npm test`) läuft **nur in Z1**, und schwere Läufe (volle Suite, Chrome-PDF-Druck, `npm ci`, `vite build`) laufen 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 <noreply@anthropic.com>`; **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 `sperreRechnung(kundenId)` = `['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_<n>)
projektRechnungen: {
attributes: [
str('invoiceId', 64), str('kundenId', 64), str('projektId', 64), str('auftragId', 64), str('art', 20),
int('betragCents'), str('arbeitsblattIdsJson', 20000), str('rechnungsnummer', 64), bool('aktiv'),
str('erstelltVon', 255), dt('erstelltAm'),
],
indexes: [index('idx_kunde', 'kundenId'), index('idx_projekt', 'projektId'), index('idx_auftrag', 'auftragId')],
},
}
export const AUFTRAEGE_ZUSATZ_ATTRIBUTE = {
customers: [str('kundenart', 20)], // '' | 'unternehmen' | 'privat'
financeSettings: [str('steuernummer', 40)],
}
```
`config.collections.auftraege` (`APPWRITE_COLLECTION_AUFTRAEGE`, Vorgabe `'auftraege'`), `config.collections.projektRechnungen` (`APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN`, Vorgabe `'projektRechnungen'`).
`config.auftraege = { mode, pilotEmails, uploadDir }` mit `mode` aus `AUFTRAEGE_ENABLED` (wie `config.zeiten.mode`), `pilotEmails = commaList(process.env.AUFTRAEGE_PILOT_EMAILS, [])`, `uploadDir = process.env.AUFTRAEGE_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'auftraege')`.
### V2 Konstanten (`server/services/auftraege/konstanten.js`, L0)
```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-<auftrag.agbFassung || AGB_FASSUNG>.pdf' */,
texte: { bestaetigung /* BESTAETIGUNG_TEXT */, fruehbeginn /* FRUEHBEGINN_TEXT oder null bei unternehmen */,
kleinunternehmer /* AB_TEXTE.kleinunternehmer */ } }
// Kundenrouten ergänzen id und kannBestaetigen = (status === 'versendet' || (status === 'abgerechnet' && !bestaetigtAm)) && nicht Kundenansicht
```
Regeln, die mehrere Stränge brauchen (alle in L0, `auftragSicht.js`):
- `fruehesterBeginn(auftrag)`: nur 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<worksheetId, { invoiceId, rechnungsnummer }>` (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: '<kundenId>/<auftragId>.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: <Texte des Tages, mit '; ' verbunden, gekürzt> – 2,25 Std. à 100,00 €“, ohne Text „Arbeitszeit“; zu viele Zeilen → älteste Tage zu ISO-Kalenderwochen „KW 41/2026: …“, Schlüssel `KW-2026-41`; `zeilentexte[schluessel]` ersetzt den Text vor dem Gedankenstrich), `anzahlungPosition(auftrag, { prozent, betragCents, bezeichnung = 'Anzahlung' }, bereitsBerechnetCents) → Position`, `schlussPositionen(auftrag, fruehere, zusatz) → Position[]` (`fruehere: [{ invoiceId, rechnungsnummer, datum, betragCents, art }]`; Minuszeile „abzüglich Anzahlung, Rechnung <Nr> vom <TT.MM.JJJJ>“ bzw. „abzüglich Teilrechnung …“), `stundenText(minuten) → '2,25'`, `leistungszeitraumText(von, bis) → '06.10.2026' | '01.10.–20.10.2026' | '28.12.2026–05.01.2027'`.
`rechnungsbau.js`: `baueRechnung(eingabe) → RechnungsEntwurf` mit `eingabe = { customerId, art = 'frei', projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum }` und `RechnungsEntwurf = { kunde, art, projekt /* { id, name, ticketId }|null */, auftrag /* Auftrag|null */, positionen, summeCents, leistungszeitraum, customFields /* [{ name, value }] ≤ 4 */, metadata, projektRechnung /* Daten für createProjektRechnung ohne invoiceId|null */ }`. Zusatzfelder: `Projekt` (Name, auf 140 gekürzt; nicht bei `frei`), `Auftrag` („AB-2026-0002 vom 06.10.2026“, nur mit AB), `Leistungszeitraum` (wenn nicht leer). Metadaten zusätzlich zu den heutigen: `projektId`, `projektName`, `auftragId`, `auftragNummer`, `art`.
`stripeFinance.js`: `createProjectInvoice({ customerId, art = 'frei', projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, daysUntilDue, memo, senden, idempotencyKey, createdBy })` (Positionen über `baueRechnung`; positive Zeile → `quantity` + `unit_amount_decimal`, Minuszeile → `amount`; `custom_fields`; `rendering: { pdf: { page_size: 'a4' } }` für **alle** Rechnungen; `createProjektRechnung` nach dem Entwurf bei `art !== 'frei'`; Aufwand, Anzahlung und Schluss unter `withLocks(sperreRechnung(customerId))`), neue Funktion `rechnungsVorschau(eingabe) → { positionen, summeCents, leistungszeitraum, customFields }` (ohne Stripe-Schreibzugriff), `mapInvoice` liefert zusätzlich `projektId`, `projektName`, `art` (`metadata.art`, sonst `'abo'` bei Abo-Rechnungen bzw. `'frei'`), `auftragId`, `auftragNummer`; Finalisieren (`senden` oder `sendInvoiceNow`) setzt `projektRechnungen.rechnungsnummer` und bei `schluss` die AB auf `abgerechnet`; `voidInvoice`/`deleteDraft` setzen `aktiv: false` und nehmen `abgerechnet` zurück.
**L3 – AB-Kern**: `nummern.js`: `formatNummer(jahr, laufnummer) → 'AB-2026-0001'`, `vergibNummer(jetzt = new Date()) → { nummer, jahr, laufnummer }` (größte `laufnummer` des Berliner Jahres + 1; Aufrufer hält `SPERRE_NUMMER`). `auftraege.js`: `pruefeEingabe(eingabe, { teilweise = false }) → bereinigte Felder` (AuftragError 400 mit Feldmeldung), `legeEntwurfAn(eingabe, { von })`, `aendereEntwurf(id, eingabe)`, `loescheEntwurf(id)`, `vorschauPdf(id, { deps }) → Buffer`, `sendeAuftrag(id, { von, deps }) → { auftrag, mailFehler: boolean }`, `erinnereAuftrag(id, { deps }) → { auftrag, mailFehler }`, `neueFassung(id, { von }) → Auftrag` (neuer Entwurf mit `ersetztId`), `storniereAuftrag(id) → Auftrag`. `deps = { erzeugeAuftragPdf, neuerToken, sendeAuftragMail }`; Vorgabe per **dynamischem Import** von `./pdf.js` (L4), `./token.js` (L5), `./mails.js` (L5) beim Aufruf, damit L3 ohne L4/L5 testbar ist (Tests übergeben Attrappen). Reihenfolge beim Senden: Eingabe, Kunde (fester Kunde, E-Mail, Anschrift, Kundenart) prüfen → Sperre `SPERRE_NUMMER` → Nummer → PDF erzeugen → `speicherePdf` → Token → Dokument speichern (Status `versendet`) → Sperre frei → Mail → `mailStatus`. Hat die AB `ersetztId`: alte AB `ersetzt` (+ `ersetztDurchId`), `umhaengen(alt, neu)`.
**L4 – AB-PDF**: `pdf.js`: `auftragPdfDaten(auftrag, { settings, vorschau = false, jetzt, ersetzt }) → AuftragPdfDaten`, `erzeugeAuftragPdf(auftrag, { settings, vorschau = false, jetzt, ersetzt }) → Promise<Buffer>` (`ersetzt = { nummer, datum }` optional, sonst über `getAuftrag(auftrag.ersetztId)`; `AB_TEXTE.termine` bekommt `privat`). `auftrag.kunde` ist immer gesetzt (L3 übergibt bei der Vorschau `kundeSnapshot(liveKunde)` und `nummer: 'Entwurf'`). `settings = { steuernummer, bankDetailsText }`. Inhalt laut Spec Abschnitt 2 „Inhalt des PDFs“, Texte aus `texte.js`. Schrift: statische Figtree-Schnitte (Regular, SemiBold) als Dateien unter `server/assets/fonts/` (OFL, Lizenzdatei dazu); Logo aus `public/` (vorhandene Datei, die die Mailvorlagen nutzen).
**L5 – Bestätigung und Mails**: `token.js`: `neuerToken(jetzt = new Date()) → { token, hash, gueltigBis /* Date */ }`, `hashToken(token) → string|null` (null bei falschem Format). `bestaetigung.js`: `bestaetigeAuftrag(auftrag, { ueber, fruehbeginn, jetzt = new Date() }) → Auftrag` (Regeln V2; idempotent; `fruehbeginn` nur bei `privat`; schickt danach `sendeBestaetigtMails`, Fehler nur ins Protokoll). `mails.js`: `sendeAuftragMail(auftrag, { pdf, token, erinnerung = false }) → Promise<void>` (wirft bei Fehler), `sendeBestaetigtMails(auftrag) → Promise<void>`. Widerruf in `server/routes/abo.js`: `order = 'projekt'` mit Feld `abNummer`.
**L6 – Kundenportal**: nur Oberfläche und `customerInvoices.js` (Feld `projekt: raw.metadata?.projektName || ''`).
**L7 – Rechtstexte**: Wortlaute in `texte.js`, neue Rechtstext-Dateien, Datenschutz, Druckskripte (Spec Abschnitt 8) und das Freigabe-Dokument `/root/reports/auftraege-rechnungen-2026-10-06/rechtsrunde.md`.
### V5 HTTP-Endpunkte
Antwortformat bei Fehlern immer `{ error: '<Meldung>' }`. Admin-Routen: Router mit `requireAppwriteStaff` (wie `finanzen.js`); Team-Routen: unter `/api/team` (dort gilt `requireStaffJwt`); Kundenrouten: `requireSession` und eigener Kunde; Token-Routen: ohne Anmeldung, Ratenbegrenzung im Arbeitsspeicher je IP (GET 120/h, POST 10/h → 429 `{ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }`).
| Strang | Methode, Pfad | Eingabe | Antwort (200) | Fehler |
|---|---|---|---|---|
| L1 | GET `/api/admin/finanzen/projektkonten?kunde=<id>` | – | `{ auftraegeAktiv, kunde, konten, ohneProjekt, stripeOk }` | 400 ohne Kunde, 404 Kunde unbekannt |
| L1 | GET `/api/admin/finanzen/projektkonten/:projektId` | – | `{ konto, auftraege, rechnungen, offeneArbeitsblaetter, stripeOk }` | 404 |
| L1 | GET `/api/team/arbeitsblaetter/abgerechnet?ticketId=<id>` | – | `{ abgerechnet: { <worksheetId>: '<Rechnungsnummer>' \| 'Entwurf' } }` (unbekanntes Ticket: `{ abgerechnet: {} }`) | 400 ohne ticketId |
| L2 | POST `/api/admin/finanzen/rechnungen` | `{ customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung: { prozent, betragCents, bezeichnung }, zeilentexte, leistungszeitraum, daysUntilDue, memo, senden, idempotencyKey }` | `{ rechnung }` (wie heute, `mapInvoice` erweitert) | 400 Eingabe, 404, 409 (Arbeitsblatt bereits abgerechnet, offener Entwurf zur AB, Nichts mehr offen, Adresse), 502 |
| L2 | POST `/api/admin/finanzen/rechnungen/vorschau` | wie oben ohne `senden`, `idempotencyKey`, `daysUntilDue`, `memo` | `{ positionen, summeCents, leistungszeitraum, customFields }` | wie oben |
| L2 | GET `/api/admin/finanzen/rechnungen` | wie heute | Einträge zusätzlich mit `projektId`, `projektName`, `art`, `auftragId`, `auftragNummer` | – |
| L3 | GET `/api/admin/finanzen/auftraege?kunde=<id>` | – | `{ auftraege: AuftragTeam[] }` | 400, 404 Schalter aus |
| L3 | POST `/api/admin/finanzen/auftraege` | `{ kundenId, projektId, titel, kundenart, art, positionen, stundensatzCents, schaetzungMinuten, zahlungsplan, zahlungszielTage, auftragVom, auftragWeg, startAm, fertigBis, zeitquelle }` (`kundenart` setzt `customers.kundenart`, wenn dort leer) | `{ auftrag: AuftragTeam }` | 400, 404 |
| L3 | GET / PATCH / DELETE `/api/admin/finanzen/auftraege/:id` | PATCH wie POST (teilweise) | `{ auftrag }` bzw. `{ ok: true }` | 404, 409 kein Entwurf |
| L3 | GET `/api/admin/finanzen/auftraege/:id/pdf` | – | `application/pdf` (gesendet: gespeicherte Datei; Entwurf: Vorschau mit „Entwurf“) | 404, 409 Entwurf unvollständig |
| L3 | POST `/api/admin/finanzen/auftraege/:id/projekt` | `{ projektId }` | `{ auftrag }` (setzt projektId und, wenn leer, zeitquelle = [Ticket des Projekts]; PDF unverändert) | 400, 404, 409 (hat schon ein Projekt; Status storniert/ersetzt/widerrufen) |
| L3 | POST `/api/admin/finanzen/auftraege/:id/senden` | – | `{ auftrag, mailFehler }` | 400 Kundenart/E-Mail/Interessent, 409 Adresse, kein Entwurf, Entwurf unvollständig; 500 PDF |
| L3 | POST `…/auftraege/:id/erinnern` | – | `{ auftrag, mailFehler }` | 409 nicht `versendet` |
| L3 | POST `…/auftraege/:id/neue-fassung` | – | `{ auftrag }` (neuer Entwurf) | 409 |
| L3 | POST `…/auftraege/:id/stornieren` | – | `{ auftrag }` | 409 |
| L5 | GET `/api/auftrag/:token` | – | `{ auftrag: AuftragOeffentlich }` | 404 `'Dieser Link ist ungültig oder abgelaufen.'`; 410 `'Diese Auftragsbestätigung wurde ersetzt.'` / `'… wurde storniert.'` / `'… wurde widerrufen.'`; 429 |
| L5 | GET `/api/auftrag/:token/pdf` | – | `application/pdf` | wie oben |
| L5 | POST `/api/auftrag/:token/bestaetigen` | `{ bestaetigt: true, fruehbeginn: boolean }` | `{ auftrag: AuftragOeffentlich }` | 400 „Bitte bestätigen Sie den Auftrag mit dem Häkchen.“, 404/410, 429 |
| L5 | GET `/api/auftraege` (Kunde) | – | `{ aktiv, auftraege: (AuftragOeffentlich & { id, kannBestaetigen })[] }` (keine Entwürfe) | 401 |
| L5 | GET `/api/auftraege/:id/pdf` (Kunde) | – | `application/pdf` | 404 fremd/unbekannt |
| L5 | POST `/api/auftraege/:id/bestaetigen` (Kunde) | `{ bestaetigt: true, fruehbeginn }` | `{ auftrag }` | 400 (Text wie Token-Route), 404, 410 (Meldungen wie Token-Route); in der Kundenansicht sperrt der Wächter (kein Eintrag in `KUNDENANSICHT_ALLOWED`) |
| L5 | POST `/api/abo/public/withdraw` | zusätzlich `order: 'projekt'`, `abNummer` (optional; ohne/ungültig nur Mails mit Vermerk „ungeprüft“) | wie heute | wie heute; gilt unabhängig vom Schalter |
| L0 | GET/PATCH `/api/admin/finanzen/einstellungen` | zusätzlich `steuernummer` | wie heute | 400 |
Ist der Schalter für den Kunden aus: Admin-AB-Routen 404 mit `SCHALTER_AUS_MELDUNG`; Rechnungsarten `anzahlung`/`schluss` 400 mit derselben Meldung; Token- und Kundenrouten antworten wie bei unbekannter AB (404) bzw. `GET /api/auftraege` mit `{ aktiv: false, auftraege: [] }`.
Einbindung (L0): in `server/index.js` **vor** `app.use('/api/admin/finanzen', adminFinanzenRoutes)`: `app.use('/api/admin/finanzen/projektkonten', adminProjektkontenRoutes)`, `app.use('/api/admin/finanzen/auftraege', adminAuftraegeRoutes)`; außerdem `app.use('/api/auftrag', publicAuftragRoutes)` und `app.use('/api/auftraege', kundeAuftraegeRoutes)`; in `server/routes/team/index.js` nach `requireStaffJwt`: `router.use('/arbeitsblaetter', arbeitsblaetterTeamRoutes)`.
### V6 Mails (L5)
Vorlagen in `server/mail-templates/` mit Kopfkommentar wie die vorhandenen (`renderMail`):
- `K31 Auftragsbestaetigung.html` – Betreff „Ihre Auftragsbestätigung {{nummer}} – {{titel}}“; 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 (`<nummer>.pdf`) und AGB-PDF (`public/rechtliches/agb-<AGB_FASSUNG>.pdf`, Dateiname `WEBklar-AGB-<AGB_FASSUNG>.pdf`). **Kein** BCC an das Team (`bccTeam: false`), weil die Mail den Bestätigungslink trägt; das Team bekommt eine Kopie mit 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 = <womsBaseUrl>/customers/<kundenId>?tab=invoices`.
- Link der K31: `<config.publicBaseUrl>/auftrag.html#t=<token>` (Fragment: landet nicht in Server- und Proxy-Protokollen; die Seite liest `#t=` und als Rückfall `?t=`).
- `K04 Eingang Widerruf.html` und `T09 Widerruf eingegangen.html` bekommen je einen Block `projekt` (Vertragsart „Projektauftrag (Auftragsbestätigung <Nr>)“; 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 <worktree>:<worktree>:ro node:20-alpine`).
- **Z2 WOMS**: Merge W1, W2; `npm test`; `vite build`; Bildschirmfotos (Desktop 1440 px, Handy 390 px) aller neuen Ansichten mit nachgebildeten Antworten.
- **Z3 Kontrolle**: fünf Prüfer parallel (sonnet) – Geld und Rechenlogik; Sicherheit und Datenschutz (Token, öffentliche Routen, Rechte, Pfade, IDOR, Kundenansicht); Recht und Texte (Spec, § 34a UStDV, § 312f BGB, Widerruf, Frühbeginn, Datenschutz); Oberfläche (Bildschirmfotos); Spec-Abdeckung (jede Zeile der Spec → Code/Test). Jeder Befund wird von einem zweiten Agenten (haiku) gegengeprüft; nur bestätigte Critical/Important werden korrigiert (sonnet), danach betroffene Tests und Z1-Durchlauf erneut.
- **Übergabe**: `/root/reports/auftraege-rechnungen-2026-10-06/uebergabe.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`.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,973 @@
# WOMS-Basis – Umsetzungsplan (Strang W0)
> **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 WOMS bekommt die gemeinsame Grundlage für „Aufträge und Rechnungen“: alle neuen Portal-Aufrufe (V8) im API-Client, den Kundenreiter „Aufträge und Rechnungen“ mit dem Container `AuftraegeUndRechnungen` (Ladezustände, Dialogzustand, bestehende Rechnungsliste, Adresswarnung) und vier gültige Platzhalter, die W1 und W2 jeweils durch ihre echte Komponente ersetzen.
**Architecture:** Der API-Client bleibt dünn: Pfade und Methoden stehen im reinen Modul `src/lib/auftraegeEndpoints.js` (unter Node mit Fetch-Attrappe testbar, weil `financeApi.js` Appwrite lädt), und `financeRequest.js` lernt Blob-Antworten (`as: 'blob'`) für das PDF. Die Ansichtslogik des Reiters (Ladezustand, frische Kundenart, Meldungen) steht getestet in `src/lib/auftraegeAnsicht.js`; der Container verdrahtet sie mit `useFinanceResource`, den vier Platzhaltern und der bestehenden `InvoiceTable` und ersetzt `CustomerInvoices` im Reiter. Die Bausteine des weißen Designs werden nur benutzt, nie verändert.
**Tech Stack:** React 18, Vite 5, react-router-dom 6, reines CSS (keine neue Datei), Tests mit `node --test` und dem esbuild-Lader `tests/unit/load.mjs` (`node:test`, `assert/strict`), Appwrite Web SDK 13 (nur in `financeApi.js` und `teamApi.js`, nie in getesteten Modulen), Node 24 auf dem Host.
**Spec:** `/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege/docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md` (Abschnitt 1 „Oberfläche in WOMS“, dazu 4 „Projektkonto“ und 6 „Schnittstellen“).
**Übersicht/Verträge:** `/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege/docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md` (verbindlich: V5 Endpunkte, V8 WOMS-Schnittstellen und der Abschnitt „Nachträge nach der Planrunde“; dazu V3 Datenformen). Die „Vertragsfragen“ am Ende dieser Datei sind dort entschieden; weicht diese Datei von der Übersicht ab, gilt die Übersicht.
Jeder Implementierer liest die Spec (Abschnitt 1), die Übersicht (V5, V8) und diese Datei.
---
## Global Constraints
Alle Punkte der Übersicht gelten (Sprache, Git ohne Push und ohne `git add -A`, Ressourcen und `flock`, Sicherheit der Umgebung, `build/` nie anfassen, vorhandene Tests bleiben grün). Für W0 kommt hinzu:
- **Startbedingung (hart):** Der Strang beginnt erst, wenn die Redesign-Pakete P4 (Kunden), P6 (Kunden-Unterseiten) und P7 (Finanzen) in `feature/design-weiss` zusammengeführt und als `design-p4`, `design-p6`, `design-p7` getaggt sind. Task 1 prüft das. Fehlt etwas, gilt **STOPP**: nichts anlegen, dem Orchestrator melden (er fragt den Nutzer: warten oder auf `test` bauen und eine Mitnahme-Notiz in `/root/reports/ticketsystem-design-2026-10-04/strang/mitnehmen.md` hinterlegen).
- **Anker statt Zeilennummern:** Dieser Plan nennt keine Zeilennummern. Maßgeblich sind Namen und Ankertexte im zusammengeführten Stand; Task 1 Step 5 prüft sie gegen den Stand beim Start.
- **Stand von `test`:** `origin/test` ist dem Redesign voraus (Öffnungszeiten: `zeitenView.js`, zusätzliche Zeilen in `financeRequest.js` und `teamApi.js`). Änderungen an diesen Dateien hängen sich an Ankertexte an und lassen fremde Zeilen unberührt.
- **Dateien:** W0 ändert nur die Dateien der Tabelle „Dateien“. Alle anderen Redesign-Dateien (`InvoiceTable.jsx`, `AddressWarning.jsx`, `InvoiceCreateModal.jsx`, `FinancePage.jsx`, `global.css`, …) bleiben unberührt; sie gehören W1 und W2.
- **Regeln des Redesigns für neue Dateien:** nur Bausteine aus `src/components/ui` und vorhandene Klassen (`msg`, `loading`, `link`, `fin-notice`, `fin-load-error`); keine Icon-Importe (`react-icons`, `@tabler`), kein `style={{…}}`, keine Farbliterale, keine KOMPAT-Klassen (`ui-btn`, `form-control`, `form-group`, `form-label`, `ui-card`, `badge-…`, `overlay-content`, `overlay-close`, `spinner`, `kpi`), keine neue CSS-Datei. P10 räumt KOMPAT und die Icon-Pakete weg; Neues darf davon nichts brauchen. Kommentare und Texte deutsch wie in den Nachbardateien.
- **Tests:** `node:test`, `assert/strict`, `loadModule` aus `tests/unit/load.mjs`. `financeApi.js`, `teamApi.js` und alle Komponenten laden Appwrite und sind unter Node nicht ladbar; ihre Logik steht in reinen `src/lib`-Modulen. Neue Tests liegen in neuen Dateien (Ausnahme: `customerTabs.test.mjs`).
- **Befehle mit Speichergrenzen** (Server mit wenig Arbeitsspeicher). `WT` ist `/home/webklar/apps/ticket.webklar.com/dev-auftraege`, `L` ist `/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad`. Jede Shell-Zeile steht für sich (Umgebungsvariablen bleiben nicht erhalten), deshalb stehen die Pfade ausgeschrieben in den Steps.
- Einzeltest: `cd $WT && NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/<datei>.test.mjs`
- Volle Suite, `npm ci` und Build laufen **nur** hinter `flock $L/auftraege-schwer.lock`, vorher `free -m` (unter 1500 MB „available“: warten), und unter `systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0` (Arbeitsweise des Redesigns). Läuft das Redesign noch schwere Läufe, zusätzlich dessen Sperre `/root/reports/ticketsystem-design-2026-10-04/sperren/schwer.lock` (erst unsere, dann deren).
- Build: `node node_modules/vite/bin/vite.js build --outDir $L/auftraege-w0-dist --emptyOutDir` mit `NODE_OPTIONS=--max-old-space-size=1200` (nie `npm run build`, nie nach `dist/` im Worktree).
- Bash-Timeout 600000 ms; dauert ein Lauf länger, mit `run_in_background` starten. Nie `pkill -f`.
- **Git:** Worktree `/home/webklar/apps/ticket.webklar.com/dev-auftraege`, Branch `feature/auftraege`. Im Repo `build` (Branch `test`) entsteht nur der Worktree-Eintrag aus Task 1, sonst wird dort nichts angefasst. Commits einzeln mit Dateinamen, deutsche Nachricht, Schlusszeile `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>` (zweites `-m`). Kein Push, nie `--force`.
- **Suche:** In dieser Shell ist `grep` eine Funktion, die Dateien still auslässt. Die Steps benutzen `rg` (13.0.0, ohne Lookarounds); notfalls `command grep -a`.
## Dateien
| Datei | Aktion | Aufgabe | Task |
|---|---|---|---|
| `src/lib/financeRequest.js` | ändern | `request(path, { method, body, as })`: `as: 'blob'` liefert bei Erfolg den rohen Inhalt (PDF) | 2 |
| `tests/unit/financeRequestBlob.test.mjs` | neu | Tests dazu (Fetch-Attrappe) | 2 |
| `src/lib/auftraegeEndpoints.js` | neu | rein: `createAuftraegeEndpoints(request)` (14 Funktionen nach V5, darunter `ordneAuftragProjektZu`) und `abgerechnetPfad(ticketId)` | 3 |
| `tests/unit/auftraegeEndpoints.test.mjs` | neu | Pfade, Methoden, Körper, Kodierung, PDF-Blob | 3 |
| `src/lib/financeApi.js` | ändern | exportiert die 14 Funktionen (Verdrahtung mit Appwrite-JWT) | 3 |
| `src/lib/teamApi.js` | ändern | `getAbgerechnet(ticketId)` | 3 |
| `src/lib/customerTabs.js`, `tests/unit/customerTabs.test.mjs` | ändern | Beschriftung „Aufträge und Rechnungen“ | 4 |
| `src/lib/auftraegeAnsicht.js` | neu | rein: Laden, Ladezustand, frische Kundenart, Meldungen | 5, 6 |
| `tests/unit/auftraegeAnsicht.test.mjs` | neu | Tests dazu | 5, 6 |
| `src/components/auftraege/ProjektkontoListe.jsx`, `RechnungDialog.jsx`, `AuftragListe.jsx`, `AuftragDialog.jsx` | neu | Platzhalter mit den Props aus V8 (samt `konten` und `onShowOverview`), rendern `null`; W1 bzw. W2 ersetzen die Dateien | 7 |
| `src/components/auftraege/AuftraegeUndRechnungen.jsx` | neu | Container des Reiters; reicht `konten` und `onShowOverview` an Liste und Dialoge durch | 7 |
| `src/pages/CustomerDetailPage.jsx` | ändern | Reiter `invoices` zeigt den Container | 8 |
| `src/components/finance/CustomerInvoices.jsx` | löschen | ersetzt durch den Container (keine Nutzer mehr) | 8 |
## Review Focus
Fälle, die die Spec nicht ausdrücklich nennt und die im Betrieb am ehesten auftreten, wahrscheinlichster zuerst:
1. **Schalter `AUFTRAEGE_ENABLED` aus (Vorgabe beim Livegang):** `auftraegeAktiv` ist `false` oder fehlt. Erwartet: kein Abruf der Aufträge (er würde 404 liefern), kein Aufträge-Abschnitt, keine Fehlermeldung; Projekte und Rechnungen laufen wie sonst. Test: Task 5, „Schalter aus“ und „unvollständige Antworten“.
2. **Projektkonten-Abruf scheitert** (Setup-Skript nicht gelaufen, Portal-Fehler 500, Portal ohne den Endpunkt): Erwartet: Der Abschnitt „Projekte“ zeigt die Meldung mit „Erneut versuchen“, die Rechnungsliste und ihr Knopf „Rechnung schreiben“ bleiben bedienbar, Aufträge werden nicht abgerufen. Test: Task 5, „Projektkonten scheitern“ und „vor der ersten Antwort“.
3. **Aufträge-Abruf scheitert allein** (Schalter an, Collection fehlt): Erwartet: Projekte bleiben sichtbar, der Aufträge-Abschnitt zeigt seine eigene Meldung. Test: Task 5, „Aufträge scheitern allein“.
4. **Neuladen scheitert, nachdem Daten da sind** (Portal antwortet nach dem Senden einer Rechnung kurz nicht): Erwartet: Die alten Zeilen bleiben stehen, darüber steht eine Warnung mit „Erneut versuchen“, nichts springt auf „Wird geladen …“. Test: Task 5, „Neuladen scheitert“.
5. **Kundenart eben gespeichert, die Kundenseite hält noch den alten Datensatz:** Erwartet: Die Dialoge bekommen den frischen Wert des Portals, der AB-Dialog fragt nicht erneut. Test: Task 6, `kundeMitKundenart`.
Weitere Randfälle haben eigene Tests in den Tasks: PDF-Abruf mit Fehlerantwort statt Blob und Kennungen mit Sonderzeichen (Task 2 und 3).
---
### Task 1: Startbedingung prüfen und Worktree anlegen
**Files:** keine Codeänderung. Legt `/home/webklar/apps/ticket.webklar.com/dev-auftraege` (Branch `feature/auftraege`) an.
**Interfaces:**
- Consumes: Tags `design-p4`, `design-p6`, `design-p7` (und ggf. `design-p8` bis `design-p10`) im WOMS-Repo, erreichbar über `/home/webklar/apps/ticket.webklar.com/build`.
- Produces: Worktree mit **echtem** `node_modules` (W1 und W2 verlinken es per `ln -s ../dev-auftraege/node_modules node_modules`), den Basis-Stand `B` (Tag-Name) und die Ausgangs-Testzahl `N0`. Beides gehört in die Abschlussmeldung (Task 9).
- [ ] **Step 1: Redesign-Tags und Basis bestimmen**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/build
for t in design-p4 design-p6 design-p7; do git rev-parse -q --verify "refs/tags/$t^{commit}" >/dev/null && echo "da $t" || echo "FEHLT $t"; done
for t in design-p10 design-p9 design-p8 design-p7; do git rev-parse -q --verify "refs/tags/$t^{commit}" >/dev/null && { echo "BASIS=$t"; break; }; done
```
Expected: dreimal `da …` und eine Zeile `BASIS=design-p…`. Das ist der höchste vorhandene Tag und damit `B`; nennt der Orchestrator ausdrücklich einen anderen Stand, gilt dieser. Fehlt einer der drei Tags: **STOPP**.
- [ ] **Step 2: Inhalt prüfen (Tags allein genügen nicht)**
Run (mit `B` aus Step 1):
```bash
cd /home/webklar/apps/ticket.webklar.com/build
B=<B aus Step 1>
git merge-base --is-ancestor design-p7 "$B" && echo "design-p7 liegt im Basis-Stand"
git cat-file -e "$B:src/lib/customerList.js" && echo "P4 ok"
git cat-file -e "$B:tests/unit/creditFormat.test.mjs" && echo "P6 ok"
git cat-file -e "$B:src/lib/financeUi.js" && echo "P7 ok"
git merge-base --is-ancestor origin/test "$B" && echo "Basis enthält origin/test" || echo "HINWEIS: origin/test ist weiter als die Basis (nur melden)"
```
Expected: vier Bestätigungen (`design-p7 …`, `P4 ok`, `P6 ok`, `P7 ok`); die letzte Zeile ist nur ein Hinweis. Fehlt eine der vier: **STOPP** mit Meldung „Startbedingung nicht erfüllt: <was>“ samt Ausgabe von `git tag -l 'design-p*' 'strang-p*'` und `git log --oneline -3 design/kunden design/finanzen`.
- [ ] **Step 3: Worktree anlegen**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com
test ! -e dev-auftraege && ! git -C build rev-parse -q --verify refs/heads/feature/auftraege >/dev/null && git -C build worktree add ../dev-auftraege -b feature/auftraege <B> && git -C dev-auftraege log --oneline -1
```
Expected: Der Worktree entsteht, die letzte Zeile ist der Commit der Basis. Existiert Ordner oder Branch schon: **STOPP** (ein früherer Versuch; Orchestrator fragen).
- [ ] **Step 4: `node_modules` wie im Redesign (echtes `npm ci`, kein Symlink)**
Run:
```bash
free -m | head -2
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 nice -n 10 npm ci --no-audit --no-fund && git status --short
```
Expected: Exit 0 und keine Zeile von `git status` (`node_modules` steht in `.gitignore`).
- [ ] **Step 5: Anker gegen den Basis-Stand prüfen**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
rg -n "CustomerInvoices" src/pages/CustomerDetailPage.jsx
rg -n "label: 'Rechnungen'" src/lib/customerTabs.js
rg -n "return async function request" src/lib/financeRequest.js
rg -n "getCustomerAboStatus" src/lib/teamApi.js
rg -n "export default function (CustomerInvoices|InvoiceTable|AddressWarning)" src/components/finance
rg -n "export function (useFinanceResource|useScrollIntoView)" src/hooks
rg -n "export function (customerInvoicesGate|invoiceDoneMessage)" src/lib/financeView.js
rg -n "export function noticeProps" src/lib/financeUi.js
rg -n "export \{ default as (Card|Button|EmptyState) " src/components/ui/index.js
rg -n "^\.fin-load-error|^\.fin-notice" src/styles
```
Expected: jeder Befehl mindestens ein Treffer (`CustomerDetailPage.jsx` zwei: Import und Verwendung, `export function (useFinanceResource|useScrollIntoView)` zwei). Fehlt ein Anker, weil das Redesign etwas umbenannt hat: **STOPP** und melden. Einzige Ausnahme: fehlen `.fin-load-error` und `.fin-notice` im CSS, benutzt Task 7 nur `msg …` ohne diese Zusatzklassen.
- [ ] **Step 6: Ausgangslage der Tests**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad
free -m | head -2
flock $L/auftraege-schwer.lock env NODE_OPTIONS=--max-old-space-size=1024 timeout 900 nice -n 10 npm test > $L/auftraege-w0-test.log 2>&1; echo "exit $?"
rg "^(ℹ|#) (tests|pass|fail) " $L/auftraege-w0-test.log
```
Expected: `exit 0`, `fail 0`. Die Zahl hinter `pass` ist `N0` (notieren; Soll am Ende: `N0 + 21`). Schlägt schon die Basis fehl: **STOPP** und melden (Basis kaputt).
Kein Commit.
### Task 2: `financeRequest` liest Dateien (Blob)
**Files:**
- Modify: `src/lib/financeRequest.js`
- Test: `tests/unit/financeRequestBlob.test.mjs` (neu)
**Interfaces:**
- Consumes: `createFinanceRequest({ baseUrl, createJWT, fetchImpl })` (besteht).
- Produces: `request(path, { method = 'GET', body, as })`. Mit `as: 'blob'` liefert eine erfolgreiche Antwort (`res.ok`) `await res.blob()`. Fehlerantworten kommen unverändert als JSON-Meldung des Portals (Fehlertexte, Status, JWT-Wiederholung bei 401). Ohne `as` oder mit jedem anderen Wert bleibt alles wie bisher. Das nutzt `getAuftragPdf` (Task 3).
- [ ] **Step 1: Failing Test schreiben**
`tests/unit/financeRequestBlob.test.mjs`:
```js
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { createFinanceRequest, PORTAL_DOWN_TEXT } = await loadModule('src/lib/financeRequest.js')
const BASE = 'https://portal.example.invalid/api/admin/finanzen'
/** Antwort wie fetch: { ok, status, json(), blob() }; ohne `json` ist der Körper kein JSON (json() wirft). */
const reply = (status, { json, blob } = {}) => ({
ok: status >= 200 && status < 300,
status,
json: async () => {
if (json === undefined) throw new SyntaxError('Unexpected token % in JSON')
return json
},
blob: async () => blob,
})
function client({ responses = [], createJWT } = {}) {
const calls = []
const queue = [...responses]
const api = createFinanceRequest({
baseUrl: BASE,
createJWT: createJWT || (async () => ({ jwt: 'jwt-1' })),
fetchImpl: async (url, init) => {
calls.push({ url, init })
return queue.shift() || reply(200, { json: {} })
},
})
return { api, calls }
}
const fails = async (promise) => promise.then(() => assert.fail('hätte scheitern müssen'), (err) => err)
const pdf = () => new Blob(['%PDF-1.7 Probe'], { type: 'application/pdf' })
test('as blob: Erfolg liefert den rohen Inhalt als Blob, mit Bearer-JWT, ohne JSON zu lesen', async () => {
const { api, calls } = client({
responses: [{ ...reply(200, { blob: pdf() }), json: async () => assert.fail('json() darf bei as blob nicht gelesen werden') }],
})
const result = await api('/auftraege/a1/pdf', { as: 'blob' })
assert.ok(result instanceof Blob)
assert.equal(result.type, 'application/pdf')
assert.equal(await result.text(), '%PDF-1.7 Probe')
assert.equal(calls[0].url, `${BASE}/auftraege/a1/pdf`)
assert.equal(calls[0].init.method, 'GET')
assert.equal(calls[0].init.headers.Authorization, 'Bearer jwt-1')
})
test('as blob: Fehler kommen weiter als Meldung des Portals mit Status (kein kaputtes PDF)', async () => {
const { api } = client({ responses: [reply(404, { json: { error: 'PDF nicht gefunden.' } })] })
const err = await fails(api('/auftraege/a1/pdf', { as: 'blob' }))
assert.equal(err.message, 'PDF nicht gefunden.')
assert.equal(err.status, 404)
// Proxy-Fehlerseite ohne JSON: dieselbe Meldung wie bei JSON-Anfragen
const { api: api2 } = client({ responses: [reply(502)] })
assert.equal((await fails(api2('/auftraege/a1/pdf', { as: 'blob' }))).message, PORTAL_DOWN_TEXT)
})
test('as blob: bei 401 einmal mit frischem JWT wiederholt, dann kommt der Blob', async () => {
let n = 0
const datei = pdf()
const { api, calls } = client({
createJWT: async () => ({ jwt: `jwt-${++n}` }),
responses: [reply(401, { json: { error: 'abgelaufen' } }), reply(200, { blob: datei })],
})
assert.equal(await api('/auftraege/a1/pdf', { as: 'blob' }), datei)
assert.deepEqual(calls.map((c) => c.init.headers.Authorization), ['Bearer jwt-1', 'Bearer jwt-2'])
})
test('ohne as bleibt es JSON (der Blob wird nicht gelesen), ein unbekannter Wert verhält sich ebenso', async () => {
const { api } = client({
responses: [{ ...reply(200, { json: { ok: 1 } }), blob: async () => assert.fail('blob() darf ohne as nicht gelesen werden') }],
})
assert.deepEqual(await api('/x'), { ok: 1 })
const { api: api2 } = client({ responses: [reply(200, { json: { ok: 2 } })] })
assert.deepEqual(await api2('/x', { as: 'text' }), { ok: 2 })
})
```
- [ ] **Step 2: Test laufen lassen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/financeRequestBlob.test.mjs`
Expected: FAIL in zwei Tests („Erfolg liefert den rohen Inhalt als Blob“ und „bei 401 …“); `request` ignoriert `as` und liefert `{}`. Die anderen beiden bestehen schon.
- [ ] **Step 3: `as: 'blob'` in `createFinanceRequest` einbauen**
In `src/lib/financeRequest.js`: Die innere Funktion bekommt die Signatur `request(path, { method = 'GET', body, as } = {})`. Direkt nach dem `fetchWithRetry`-Aufruf und vor dem Lesen des JSON kommt `if (as === 'blob' && res.ok) return res.blob()` (mit einem Kommentar: Dateien wie das PDF der Auftragsbestätigung kommen roh, Fehler weiter als JSON). Header, JWT-Cache, Wiederholung bei 401 und alle Fehlertexte bleiben, ebenso die fremden Zeilen (`code`, `fehler`) im Fehlerzweig. Den Kommentarblock über `createFinanceRequest` um einen Satz zu `as: 'blob'` ergänzen.
- [ ] **Step 4: Tests laufen lassen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/financeRequestBlob.test.mjs tests/unit/financeRequest.test.mjs`
Expected: PASS, in der neuen Datei `pass 4`, `fail 0`; die bestehende Datei bleibt grün.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
git add src/lib/financeRequest.js tests/unit/financeRequestBlob.test.mjs
git commit -m "Finanzen-Anfrage: Dateien lesen (as: 'blob'), z. B. das PDF einer Auftragsbestätigung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 3: API-Schicht – Pfade rein, `financeApi.js` und `teamApi.js` verdrahtet
**Files:**
- Create: `src/lib/auftraegeEndpoints.js`
- Test: `tests/unit/auftraegeEndpoints.test.mjs` (neu)
- Modify: `src/lib/financeApi.js`, `src/lib/teamApi.js`
**Interfaces:**
- Consumes: `request(path, { method, body, as })` aus `createFinanceRequest` (Task 2).
- Produces:
- `createAuftraegeEndpoints(request) → { getProjektkonten, getProjektkonto, previewInvoice, listAuftraege, getAuftrag, createAuftrag, patchAuftrag, deleteAuftrag, sendAuftrag, erinnereAuftrag, neueFassungAuftrag, storniereAuftrag, ordneAuftragProjektZu, getAuftragPdf }` (14 Funktionen) und `abgerechnetPfad(ticketId) → string` (reine Datei ohne Importe).
- Aus `src/lib/financeApi.js` exportiert (V8; Basis `/api/admin/finanzen`, Antwortformen nach V5):
- `getProjektkonten(kundeId) → { auftraegeAktiv, kunde, konten, ohneProjekt, stripeOk }`
- `getProjektkonto(projektId) → { konto, auftraege, rechnungen, offeneArbeitsblaetter, stripeOk }`
- `previewInvoice(body) → { positionen, summeCents, leistungszeitraum, customFields }`
- `listAuftraege(kundeId) → { auftraege }`
- `getAuftrag(id)`, `createAuftrag(body)`, `patchAuftrag(id, body) → { auftrag }`; `deleteAuftrag(id) → { ok: true }`
- `sendAuftrag(id)`, `erinnereAuftrag(id) → { auftrag, mailFehler }`; `neueFassungAuftrag(id)`, `storniereAuftrag(id) → { auftrag }`
- `ordneAuftragProjektZu(id, projektId) → { auftrag }` (Nachtrag der Planrunde, V5 Zeile L3: ordnet einer AB ohne Projekt ein Projekt zu; 409, wenn sie schon eines hat oder `storniert`, `ersetzt` oder `widerrufen` ist; W2 braucht es für „Projekt zuordnen“)
- `getAuftragPdf(id) → Promise<Blob>`
- Aus `src/lib/teamApi.js`: `getAbgerechnet(ticketId) → { abgerechnet: { <worksheetId>: '<Rechnungsnummer>' | 'Entwurf' } }` (V5; für alle Mitarbeiter, ohne Beträge).
- `createInvoice(body)` besteht und reicht den erweiterten Körper unverändert durch; nur der Kommentar wird nachgezogen.
- [ ] **Step 1: Failing Test schreiben**
`tests/unit/auftraegeEndpoints.test.mjs`:
```js
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { createAuftraegeEndpoints, abgerechnetPfad } = await loadModule('src/lib/auftraegeEndpoints.js')
const { createFinanceRequest } = await loadModule('src/lib/financeRequest.js')
const BASE = 'https://portal.example.invalid/api/admin/finanzen'
// Echte Anfrage-Schicht mit Fetch-Attrappe: geprüft wird, was das Portal erreicht (URL, Methode, Kopf, JSON-Körper)
function setup(answer = () => ({ ok: true, status: 200, json: async () => ({ ok: true }) })) {
const calls = []
const request = createFinanceRequest({
baseUrl: BASE,
createJWT: async () => ({ jwt: 'jwt-1' }),
fetchImpl: async (url, init) => {
calls.push({ url, method: init.method, body: init.body, auth: init.headers.Authorization })
return answer()
},
})
return { api: createAuftraegeEndpoints(request), calls }
}
// [Funktion, Argumente, Methode, Pfad ab BASE, Körper]
const FAELLE = [
['getProjektkonten', ['k1'], 'GET', '/projektkonten?kunde=k1', undefined],
['getProjektkonto', ['p1'], 'GET', '/projektkonten/p1', undefined],
['previewInvoice', [{ customerId: 'k1', art: 'aufwand', projektId: 'p1' }], 'POST', '/rechnungen/vorschau', { customerId: 'k1', art: 'aufwand', projektId: 'p1' }],
['listAuftraege', ['k1'], 'GET', '/auftraege?kunde=k1', undefined],
['getAuftrag', ['a1'], 'GET', '/auftraege/a1', undefined],
['createAuftrag', [{ kundenId: 'k1', titel: 'Website' }], 'POST', '/auftraege', { kundenId: 'k1', titel: 'Website' }],
['patchAuftrag', ['a1', { titel: 'Neu' }], 'PATCH', '/auftraege/a1', { titel: 'Neu' }],
['deleteAuftrag', ['a1'], 'DELETE', '/auftraege/a1', undefined],
['sendAuftrag', ['a1'], 'POST', '/auftraege/a1/senden', undefined],
['erinnereAuftrag', ['a1'], 'POST', '/auftraege/a1/erinnern', undefined],
['neueFassungAuftrag', ['a1'], 'POST', '/auftraege/a1/neue-fassung', undefined],
['storniereAuftrag', ['a1'], 'POST', '/auftraege/a1/stornieren', undefined],
['ordneAuftragProjektZu', ['a1', 'p1'], 'POST', '/auftraege/a1/projekt', { projektId: 'p1' }],
]
test('alle 14 Funktionen sind da (V8 samt „Projekt zuordnen“ aus V5)', () => {
const { api } = setup()
assert.deepEqual(Object.keys(api).sort(), [
'createAuftrag', 'deleteAuftrag', 'erinnereAuftrag', 'getAuftrag', 'getAuftragPdf', 'getProjektkonten', 'getProjektkonto',
'listAuftraege', 'neueFassungAuftrag', 'ordneAuftragProjektZu', 'patchAuftrag', 'previewInvoice', 'sendAuftrag', 'storniereAuftrag',
])
})
test('Pfad, Methode, Kopf und Körper jeder Funktion (die Vorschau trägt keinen Schlüssel dazu)', async () => {
for (const [name, args, method, path, body] of FAELLE) {
const { api, calls } = setup()
await api[name](...args)
assert.equal(calls.length, 1, name)
assert.equal(calls[0].url, `${BASE}${path}`, name)
assert.equal(calls[0].method, method, name)
assert.equal(calls[0].auth, 'Bearer jwt-1', name)
assert.equal(calls[0].body, body === undefined ? undefined : JSON.stringify(body), name)
}
})
test('Kennungen werden URL-kodiert, auch im Pfad der abgerechneten Arbeitsblätter', async () => {
const { api, calls } = setup()
await api.getProjektkonten('k 1&x=2')
await api.getAuftrag('a/b?c')
await api.storniereAuftrag('a#1')
await api.ordneAuftragProjektZu('a b', 'p/1')
assert.deepEqual(calls.map((c) => c.url.slice(BASE.length)), [
'/projektkonten?kunde=k%201%26x%3D2',
'/auftraege/a%2Fb%3Fc',
'/auftraege/a%231/stornieren',
'/auftraege/a%20b/projekt',
])
assert.equal(calls[3].body, JSON.stringify({ projektId: 'p/1' })) // die Projektkennung steht im Körper, nicht im Pfad
assert.equal(abgerechnetPfad('100131'), '/arbeitsblaetter/abgerechnet?ticketId=100131')
assert.equal(abgerechnetPfad('t 1/2'), '/arbeitsblaetter/abgerechnet?ticketId=t%201%2F2')
})
test('getAuftragPdf liefert das PDF als Blob; eine Fehlerantwort kommt als Meldung des Portals', async () => {
const datei = new Blob(['%PDF-1.7'], { type: 'application/pdf' })
const ok = setup(() => ({ ok: true, status: 200, json: async () => assert.fail('kein JSON lesen'), blob: async () => datei }))
assert.equal(await ok.api.getAuftragPdf('a1'), datei)
assert.equal(ok.calls[0].url, `${BASE}/auftraege/a1/pdf`)
assert.equal(ok.calls[0].method, 'GET')
const fehler = setup(() => ({ ok: false, status: 404, json: async () => ({ error: 'PDF nicht gefunden.' }), blob: async () => assert.fail('kein Blob lesen') }))
await assert.rejects(fehler.api.getAuftragPdf('a1'), (e) => e.message === 'PDF nicht gefunden.' && e.status === 404)
})
```
- [ ] **Step 2: Test laufen lassen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/auftraegeEndpoints.test.mjs`
Expected: FAIL, `src/lib/auftraegeEndpoints.js` fehlt.
- [ ] **Step 3: `src/lib/auftraegeEndpoints.js` implementieren**
Reine Datei ohne Importe, deutscher Kopfkommentar im Stil von `financeRequest.js`. `createAuftraegeEndpoints(request)` gibt ein Objekt mit genau den 14 Funktionen zurück. Jede ruft `request` mit Pfad relativ zu `/api/admin/finanzen`; jede Kennung läuft durch `encodeURIComponent`; Aufrufe ohne Körper übergeben keinen Körper. Tabelle:
| Funktion | Methode | Pfad | Körper |
|---|---|---|---|
| `getProjektkonten(kundeId)` | GET | `/projektkonten?kunde=<kundeId>` | – |
| `getProjektkonto(projektId)` | GET | `/projektkonten/<projektId>` | – |
| `previewInvoice(body)` | POST | `/rechnungen/vorschau` | `body` unverändert |
| `listAuftraege(kundeId)` | GET | `/auftraege?kunde=<kundeId>` | – |
| `getAuftrag(id)` | GET | `/auftraege/<id>` | – |
| `createAuftrag(body)` | POST | `/auftraege` | `body` |
| `patchAuftrag(id, body)` | PATCH | `/auftraege/<id>` | `body` |
| `deleteAuftrag(id)` | DELETE | `/auftraege/<id>` | – |
| `sendAuftrag(id)` | POST | `/auftraege/<id>/senden` | – |
| `erinnereAuftrag(id)` | POST | `/auftraege/<id>/erinnern` | – |
| `neueFassungAuftrag(id)` | POST | `/auftraege/<id>/neue-fassung` | – |
| `storniereAuftrag(id)` | POST | `/auftraege/<id>/stornieren` | – |
| `ordneAuftragProjektZu(id, projektId)` | POST | `/auftraege/<id>/projekt` | `{ projektId }` |
| `getAuftragPdf(id)` | GET | `/auftraege/<id>/pdf` | – (Option `as: 'blob'`) |
`abgerechnetPfad(ticketId)` liefert `/arbeitsblaetter/abgerechnet?ticketId=<kodiert>` (Basis `/api/team`, siehe Step 4).
- [ ] **Step 4: Verdrahtung in `financeApi.js` und `teamApi.js`**
- `src/lib/financeApi.js`: `import { createAuftraegeEndpoints } from './auftraegeEndpoints'` zu den Importen. Hinter dem Block „Rechnungen“ ein Abschnitt mit Kommentar „Projektkonten, Aufträge und Rechnungsvorschau (Pfade und Methoden: auftraegeEndpoints.js, getestet)“ und `export const { getProjektkonten, getProjektkonto, previewInvoice, listAuftraege, getAuftrag, createAuftrag, patchAuftrag, deleteAuftrag, sendAuftrag, erinnereAuftrag, neueFassungAuftrag, storniereAuftrag, ordneAuftragProjektZu, getAuftragPdf } = createAuftraegeEndpoints(request)` (die vorhandene Konstante `request` wird übergeben). Der Kommentar über `createInvoice` nennt den erweiterten Körper `{ customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, daysUntilDue, memo, senden }`; die Funktion selbst bleibt unverändert.
- `src/lib/teamApi.js`: `import { abgerechnetPfad } from './auftraegeEndpoints'` zu den Importen; vor dem Block „Abo-Status“ ein Abschnitt „Abgerechnete Arbeitsblätter (alle Mitarbeiter, ohne Beträge)“ mit `export const getAbgerechnet = (ticketId) => request(abgerechnetPfad(ticketId))` und einem Kommentar zur Antwortform.
- [ ] **Step 5: Tests und Syntaxprüfung**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/auftraegeEndpoints.test.mjs
node_modules/.bin/esbuild src/lib/financeApi.js src/lib/teamApi.js --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w0-syntax --log-level=warning
```
Expected: `pass 4`, `fail 0`; esbuild ohne Ausgabe und Exit 0 (nur Syntax, die Dateien lassen sich unter Node nicht bündeln).
- [ ] **Step 6: Commit**
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
git add src/lib/auftraegeEndpoints.js tests/unit/auftraegeEndpoints.test.mjs src/lib/financeApi.js src/lib/teamApi.js
git commit -m "Finanzen-API: Projektkonten, Auftragsbestätigungen (samt Projekt zuordnen), Rechnungsvorschau und abgerechnete Arbeitsblätter (Pfade rein und getestet)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 4: Reiter-Beschriftung „Aufträge und Rechnungen“
**Files:**
- Modify: `src/lib/customerTabs.js`, `tests/unit/customerTabs.test.mjs`
**Interfaces:**
- Consumes: `customerTabs(isAdmin)` und `resolveCustomerTab(tab, isAdmin)` (bestehen).
- Produces: `customerTabs(true)[2]` ist `{ id: 'invoices', label: 'Aufträge und Rechnungen' }`. Die `id` bleibt `invoices` (Mails des Portals und Aufgaben der Finanzen-Seite verlinken `?tab=invoices`), der Reiter bleibt nur für Admins.
- [ ] **Step 1: Test anpassen (zuerst)**
In `tests/unit/customerTabs.test.mjs` die Zeile `{ id: 'invoices', label: 'Rechnungen' },` zu `{ id: 'invoices', label: 'Aufträge und Rechnungen' },` ändern und den Titel des ersten Tests zu `'customerTabs: volle Reiterfolge der Spec, „Aufträge und Rechnungen“ nur für Admins'`. Alles andere bleibt (es pinnt unter anderem, dass `customerTabs(false)` keinen Reiter `invoices` enthält und `resolveCustomerTab('invoices', false)` auf `overview` fällt).
- [ ] **Step 2: Test laufen lassen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/customerTabs.test.mjs`
Expected: FAIL (die Beschriftung lautet noch „Rechnungen“).
- [ ] **Step 3: Beschriftung ändern**
In `src/lib/customerTabs.js` das Label des Eintrags `invoices` auf `'Aufträge und Rechnungen'` setzen und die beiden Kommentare, die „Rechnungen“ als Reiternamen nennen (Kopfkommentar der Datei und Doc-Kommentar von `customerTabs`), auf „Aufträge und Rechnungen“ nachziehen. `adminOnly` und alle anderen Reiter bleiben.
- [ ] **Step 4: Test laufen lassen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/customerTabs.test.mjs`
Expected: PASS, `fail 0`.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
git add src/lib/customerTabs.js tests/unit/customerTabs.test.mjs
git commit -m "Kundenreiter „Rechnungen“ heißt jetzt „Aufträge und Rechnungen“ (id bleibt invoices)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 5: Ansichtslogik – Laden und Ladezustand
**Files:**
- Create: `src/lib/auftraegeAnsicht.js`
- Test: `tests/unit/auftraegeAnsicht.test.mjs` (neu)
**Interfaces:**
- Consumes: nichts (reine Datei; die API-Funktionen kommen als Argument).
- Produces, alle aus `src/lib/auftraegeAnsicht.js`:
- `KONTEN_FEHLER = 'Die Projektübersicht konnte nicht geladen werden.'`, `AUFTRAEGE_FEHLER = 'Die Aufträge konnten nicht geladen werden.'`
- `ladeAuftraegeAnsicht(kundeId, { getProjektkonten, listAuftraege }) → Promise<{ konten, auftraege, auftraegeFehler }>`: `konten` ist die Antwort von `getProjektkonten`, `auftraege` die Liste oder `null`, `auftraegeFehler` Text oder `''`.
- `ansichtsZustand({ data, error } = {}) → AnsichtsZustand` (`data` und `error` kommen aus `useFinanceResource`):
```
{ projekteStatus: 'laden' | 'fehler' | 'bereit', fehler: string, nachladeFehler: string,
auftraegeAktiv: boolean, auftraegeStatus: 'aus' | 'bereit' | 'fehler', auftraegeFehler: string,
konten: Projektkonto[], ohneProjekt: { berechnetCents, bezahltCents, anzahl }, stripeOk: boolean,
kunde: { id, name, kundenart } | null, auftraege: AuftragTeam[] }
```
- [ ] **Step 1: Failing Test schreiben**
`tests/unit/auftraegeAnsicht.test.mjs`:
```js
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { KONTEN_FEHLER, AUFTRAEGE_FEHLER, ladeAuftraegeAnsicht, ansichtsZustand } = await loadModule('src/lib/auftraegeAnsicht.js')
// Antwort von getProjektkonten (V5) mit den Feldern, die die Ansicht braucht
const konten = (extra = {}) => ({
auftraegeAktiv: false,
kunde: { id: 'k1', name: 'Anelia Wolf', kundenart: 'privat' },
konten: [{ projektId: 'p1', name: 'Website' }],
ohneProjekt: { berechnetCents: 5000, bezahltCents: 0, anzahl: 1 },
stripeOk: true,
...extra,
})
const OHNE_PROJEKT_LEER = { berechnetCents: 0, bezahltCents: 0, anzahl: 0 }
// Attrappe für getProjektkonten und listAuftraege: gibt Werte zurück, wirft Fehler und merkt sich die Aufrufe
function fakeApi({ projektkonten, auftraege } = {}) {
const calls = []
const antwort = (wert) => {
if (wert instanceof Error) throw wert
return wert
}
return {
calls,
getProjektkonten: async (id) => { calls.push(['konten', id]); return antwort(projektkonten) },
listAuftraege: async (id) => { calls.push(['auftraege', id]); return antwort(auftraege) },
}
}
test('Schalter aus: die Aufträge werden gar nicht erst abgerufen', async () => {
const api = fakeApi({ projektkonten: konten({ auftraegeAktiv: false }), auftraege: new Error('404 darf nie ankommen') })
const result = await ladeAuftraegeAnsicht('k1', api)
assert.deepEqual(api.calls, [['konten', 'k1']])
assert.equal(result.konten.auftraegeAktiv, false)
assert.equal(result.auftraege, null)
assert.equal(result.auftraegeFehler, '')
// ein fehlendes Feld gilt wie „aus“, nur true schaltet an; eine Antwort ohne Objekt wird zu {}
const fehlt = fakeApi({ projektkonten: { konten: [] } })
assert.equal((await ladeAuftraegeAnsicht('k1', fehlt)).auftraege, null)
assert.deepEqual(fehlt.calls, [['konten', 'k1']])
const nichts = await ladeAuftraegeAnsicht('k1', fakeApi({ projektkonten: null }))
assert.deepEqual(nichts, { konten: {}, auftraege: null, auftraegeFehler: '' })
})
test('Schalter an: erst die Projektkonten, dann die Aufträge des Kunden', async () => {
const liste = [{ id: 'a1', nummer: 'AB-2026-0001', status: 'versendet' }]
const api = fakeApi({ projektkonten: konten({ auftraegeAktiv: true }), auftraege: { auftraege: liste } })
const result = await ladeAuftraegeAnsicht('k1', api)
assert.deepEqual(api.calls, [['konten', 'k1'], ['auftraege', 'k1']])
assert.deepEqual(result.auftraege, liste)
assert.equal(result.auftraegeFehler, '')
// eine Antwort ohne Liste gibt eine leere Liste statt eines Absturzes
const leer = await ladeAuftraegeAnsicht('k1', fakeApi({ projektkonten: konten({ auftraegeAktiv: true }), auftraege: {} }))
assert.deepEqual(leer.auftraege, [])
})
test('Aufträge scheitern allein: die Projektkonten bleiben, der Fehler steht eigens da', async () => {
const api = fakeApi({
projektkonten: konten({ auftraegeAktiv: true }),
auftraege: Object.assign(new Error('Interner Fehler.'), { status: 500 }),
})
const result = await ladeAuftraegeAnsicht('k1', api)
assert.equal(result.konten.konten.length, 1)
assert.equal(result.auftraege, null)
assert.equal(result.auftraegeFehler, 'Interner Fehler.')
const ohneText = await ladeAuftraegeAnsicht('k1', fakeApi({ projektkonten: konten({ auftraegeAktiv: true }), auftraege: new Error('') }))
assert.equal(ohneText.auftraegeFehler, AUFTRAEGE_FEHLER)
assert.equal(AUFTRAEGE_FEHLER, 'Die Aufträge konnten nicht geladen werden.')
})
test('Projektkonten scheitern: der Fehler geht weiter, die Aufträge werden nicht abgerufen', async () => {
const fehler = Object.assign(new Error('Interner Fehler.'), { status: 500 })
const api = fakeApi({ projektkonten: fehler, auftraege: { auftraege: [] } })
await assert.rejects(ladeAuftraegeAnsicht('k1', api), (e) => e === fehler)
assert.deepEqual(api.calls, [['konten', 'k1']])
})
test('ansichtsZustand: vor der ersten Antwort „laden“, nach einem Fehler „fehler“ mit der Meldung', () => {
const laden = ansichtsZustand({ data: null, error: '' })
assert.equal(laden.projekteStatus, 'laden')
assert.equal(laden.auftraegeStatus, 'aus') // der Schalter ist noch unbekannt: kein Abschnitt, kein Flackern
assert.deepEqual(laden.konten, [])
assert.equal(ansichtsZustand().projekteStatus, 'laden')
const fehler = ansichtsZustand({ data: null, error: KONTEN_FEHLER })
assert.equal(fehler.projekteStatus, 'fehler')
assert.equal(fehler.fehler, KONTEN_FEHLER)
assert.equal(fehler.auftraegeStatus, 'aus') // ohne Projektkonten kein Schalter, also auch keine Aufträge
assert.equal(KONTEN_FEHLER, 'Die Projektübersicht konnte nicht geladen werden.')
})
test('ansichtsZustand: Schalter aus – Projekte bereit, kein Aufträge-Abschnitt', () => {
const z = ansichtsZustand({ data: { konten: konten({ auftraegeAktiv: false }), auftraege: null, auftraegeFehler: '' }, error: '' })
assert.equal(z.projekteStatus, 'bereit')
assert.equal(z.auftraegeAktiv, false)
assert.equal(z.auftraegeStatus, 'aus')
assert.equal(z.konten.length, 1)
assert.deepEqual(z.ohneProjekt, { berechnetCents: 5000, bezahltCents: 0, anzahl: 1 })
assert.equal(z.stripeOk, true)
assert.deepEqual(z.kunde, { id: 'k1', name: 'Anelia Wolf', kundenart: 'privat' })
assert.equal(z.nachladeFehler, '')
})
test('ansichtsZustand: Schalter an – Aufträge bereit oder mit eigenem Fehler, die Projekte bleiben', () => {
const liste = [{ id: 'a1' }]
const bereit = ansichtsZustand({ data: { konten: konten({ auftraegeAktiv: true }), auftraege: liste, auftraegeFehler: '' }, error: '' })
assert.equal(bereit.auftraegeAktiv, true)
assert.equal(bereit.auftraegeStatus, 'bereit')
assert.deepEqual(bereit.auftraege, liste)
const kaputt = ansichtsZustand({
data: { konten: konten({ auftraegeAktiv: true }), auftraege: null, auftraegeFehler: 'Interner Fehler.' },
error: '',
})
assert.equal(kaputt.projekteStatus, 'bereit')
assert.equal(kaputt.auftraegeStatus, 'fehler')
assert.equal(kaputt.auftraegeFehler, 'Interner Fehler.')
assert.deepEqual(kaputt.auftraege, [])
})
test('ansichtsZustand: Neuladen scheitert – die alten Daten bleiben, die Meldung steht daneben', () => {
const data = { konten: konten({ auftraegeAktiv: true }), auftraege: [{ id: 'a1' }], auftraegeFehler: '' }
const z = ansichtsZustand({ data, error: 'Das Kundenportal ist gerade nicht erreichbar.' })
assert.equal(z.projekteStatus, 'bereit')
assert.equal(z.fehler, '')
assert.equal(z.nachladeFehler, 'Das Kundenportal ist gerade nicht erreichbar.')
assert.equal(z.konten.length, 1)
assert.equal(z.auftraegeStatus, 'bereit')
assert.equal(z.auftraege.length, 1)
})
test('ansichtsZustand: unvollständige Antworten brechen die Ansicht nicht', () => {
for (const data of [{}, { konten: null }, { konten: {} }, { konten: { konten: 'x', ohneProjekt: 5, kunde: 'k' } }]) {
const z = ansichtsZustand({ data, error: '' })
assert.equal(z.projekteStatus, 'bereit')
assert.deepEqual(z.konten, [])
assert.deepEqual(z.ohneProjekt, OHNE_PROJEKT_LEER)
assert.equal(z.stripeOk, true)
assert.equal(z.kunde, null)
assert.equal(z.auftraegeAktiv, false)
assert.equal(z.auftraegeStatus, 'aus')
}
assert.equal(ansichtsZustand({ data: { konten: konten({ stripeOk: false }) }, error: '' }).stripeOk, false)
})
```
- [ ] **Step 2: Test laufen lassen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/auftraegeAnsicht.test.mjs`
Expected: FAIL, `src/lib/auftraegeAnsicht.js` fehlt.
- [ ] **Step 3: `src/lib/auftraegeAnsicht.js` implementieren**
Reine Datei (kein React, kein Appwrite, noch keine Importe), deutscher Kopfkommentar „Ansichtslogik des Reiters Aufträge und Rechnungen“. Regeln:
- Die zwei Fehlertexte werden exportiert (Wortlaut siehe Interfaces).
- `ladeAuftraegeAnsicht`: ruft zuerst `getProjektkonten(kundeId)`; ein Fehler dort wird nicht abgefangen. Ist die Antwort kein Objekt, gilt `{}`. Nur wenn `konten.auftraegeAktiv === true`, folgt `listAuftraege(kundeId)`: Erfolg gibt `antwort.auftraege`, wenn das ein Array ist, sonst `[]`; ein Fehler gibt `auftraege: null` und `auftraegeFehler: err.message`, bei leerer Meldung `AUFTRAEGE_FEHLER`. Sonst `auftraege: null`, `auftraegeFehler: ''`. Rückgabe immer `{ konten, auftraege, auftraegeFehler }`.
- `ansichtsZustand({ data, error } = {})`:
- Ohne `data`: `projekteStatus` ist `'fehler'` (mit `fehler = error`), wenn `error` nicht leer ist, sonst `'laden'`; `auftraegeStatus` ist dann `'aus'`; `konten` `[]`, `ohneProjekt` eine Kopie von `{ berechnetCents: 0, bezahltCents: 0, anzahl: 0 }`, `stripeOk` true, `kunde` null, `auftraege` `[]`.
- Mit `data`: `projekteStatus: 'bereit'`, `fehler: ''`, `nachladeFehler: error || ''`. Gelesen wird aus `data.konten` (nur wenn Objekt, sonst `{}`): `auftraegeAktiv = (data.konten.auftraegeAktiv === true)`, `konten` nur wenn Array, `ohneProjekt` nur wenn Objekt (sonst die Null-Kopie), `stripeOk = (data.konten.stripeOk !== false)`, `kunde` nur wenn Objekt (sonst `null`).
- `auftraegeStatus`: `'aus'` ohne Schalter, `'fehler'` bei Schalter und nicht leerem `data.auftraegeFehler`, sonst `'bereit'`. `auftraegeFehler` nur im Fall `'fehler'`. `auftraege` ist `data.auftraege`, wenn Schalter an und Array, sonst `[]`.
- [ ] **Step 4: Test laufen lassen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/auftraegeAnsicht.test.mjs`
Expected: PASS, `pass 9`, `fail 0`.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
git add src/lib/auftraegeAnsicht.js tests/unit/auftraegeAnsicht.test.mjs
git commit -m "Aufträge und Rechnungen: Laden und Ladezustand des Reiters als reine Funktionen (mit Tests)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 6: Ansichtslogik – Kundenart und Meldungen
**Files:**
- Modify: `src/lib/auftraegeAnsicht.js`, `tests/unit/auftraegeAnsicht.test.mjs`
**Interfaces:**
- Consumes: `invoiceDoneMessage(actionKey, rechnung)` aus `src/lib/financeView.js` (liefert für `'create-send'` „Rechnung <Nr> wurde an <E-Mail> gesendet.“ und für `'create-draft'` „Der Entwurf wurde gespeichert.“).
- Produces, aus `src/lib/auftraegeAnsicht.js`:
- `kundeMitKundenart(customer, kundeInfo) → customer'`: Ist `kundeInfo.kundenart` eines von `'unternehmen'`, `'privat'`, kommt `{ ...customer, kundenart: <dieser Wert> }` zurück (das Dokument selbst bleibt unverändert). In allen anderen Fällen (`kundeInfo` fehlt, kein Objekt, Wert leer, unbekannt oder keine Zeichenkette) die **unveränderte Referenz** `customer`; `null` bleibt `null`.
- `erstelltMeldung(rechnung, info) → { tone, text }`: wie `InvoiceTable.handleCreated`: mit `info.notice` (Zeichenkette) `{ tone: info.tone || 'ok', text: info.notice }`, sonst `{ tone: 'ok', text: invoiceDoneMessage(info.senden ? 'create-send' : 'create-draft', rechnung) }`. `info` darf fehlen oder `null` sein.
- `auftragMeldung(auftrag) → { tone, text }` für `onSaved(auftrag)`.
- [ ] **Step 1: Failing Tests ergänzen**
In `tests/unit/auftraegeAnsicht.test.mjs` die `loadModule`-Zeile um `kundeMitKundenart, erstelltMeldung, auftragMeldung` erweitern und anhängen:
```js
test('kundeMitKundenart: ein gültiger Wert des Portals gewinnt gegen das veraltete Kundendokument', () => {
const doc = { $id: 'k1', name: 'Anelia Wolf', kundenart: '' }
assert.deepEqual(kundeMitKundenart(doc, { id: 'k1', kundenart: 'privat' }), { ...doc, kundenart: 'privat' })
assert.equal(doc.kundenart, '') // das Dokument der Seite wird nicht verändert
assert.equal(kundeMitKundenart({ ...doc, kundenart: 'unternehmen' }, { kundenart: 'privat' }).kundenart, 'privat')
})
test('kundeMitKundenart: ohne gültigen Wert vom Portal bleibt das Dokument (dieselbe Referenz)', () => {
const doc = { $id: 'k1', kundenart: 'unternehmen' }
for (const info of [null, undefined, {}, { kundenart: '' }, { kundenart: 'firma' }, { kundenart: 5 }, 'privat']) {
assert.equal(kundeMitKundenart(doc, info), doc)
}
assert.equal(kundeMitKundenart(null, { kundenart: 'privat' }), null)
})
test('erstelltMeldung: gesendet, Entwurf und die Meldung nach festgeschriebener, nicht gesendeter Rechnung', () => {
assert.deepEqual(
erstelltMeldung({ number: 'WEBKLAR-0009', customerEmail: 'a@b.de' }, { senden: true }),
{ tone: 'ok', text: 'Rechnung WEBKLAR-0009 wurde an a@b.de gesendet.' },
)
assert.deepEqual(erstelltMeldung({ id: 'in_1' }, { senden: false }), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' })
assert.deepEqual(erstelltMeldung(null, undefined), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' })
assert.deepEqual(erstelltMeldung(null, null), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' })
const text = 'Rechnung WEBKLAR-0010 wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.'
assert.deepEqual(erstelltMeldung(null, { senden: true, notice: text, tone: 'warn', finalizedFilter: 'offen' }), { tone: 'warn', text })
assert.deepEqual(erstelltMeldung(null, { senden: true, notice: text }), { tone: 'ok', text })
})
test('auftragMeldung: Entwurf, gesendet und gesendet ohne zugestellte Mail', () => {
assert.deepEqual(auftragMeldung({ status: 'entwurf', nummer: '' }), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' })
assert.deepEqual(
auftragMeldung({ status: 'versendet', nummer: 'AB-2026-0001', gesendetAn: 'k@x.de', mailStatus: 'gesendet' }),
{ tone: 'ok', text: 'Auftragsbestätigung AB-2026-0001 wurde an k@x.de gesendet.' },
)
assert.deepEqual(
auftragMeldung({ status: 'versendet', nummer: 'AB-2026-0001', gesendetAn: 'k@x.de', mailStatus: 'fehler' }),
{ tone: 'warn', text: 'Auftragsbestätigung AB-2026-0001 ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.' },
)
assert.deepEqual(auftragMeldung(null), { tone: 'ok', text: 'Die Auftragsbestätigung wurde gespeichert.' })
})
```
- [ ] **Step 2: Test laufen lassen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/auftraegeAnsicht.test.mjs`
Expected: FAIL in den vier neuen Tests (`kundeMitKundenart is not a function` und Verwandte); die neun aus Task 5 bestehen weiter.
- [ ] **Step 3: Die drei Funktionen implementieren**
In `src/lib/auftraegeAnsicht.js` `import { invoiceDoneMessage } from './financeView'` ergänzen. Die erlaubten Kundenarten sind zwei Konstanten `'unternehmen'` und `'privat'` (Wertebereich von `customers.kundenart` nach V1; bewusst lokal, `auftragView.js` gehört W2). `auftragMeldung` prüft der Reihe nach, mit `a = auftrag` (Objekt, sonst `{}`) und `name = a.nummer ? 'Auftragsbestätigung <nummer>' : 'Die Auftragsbestätigung'`:
1. `a.status === 'entwurf'` → `{ tone: 'ok', text: 'Der Entwurf wurde gespeichert.' }`
2. `a.mailStatus === 'fehler'` → `{ tone: 'warn', text: '<name> ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.' }`
3. `a.nummer` → `{ tone: 'ok', text: '<name> wurde[ an <gesendetAn>] gesendet.' }` (der Teil „an …“ nur bei nicht leerem `gesendetAn`)
4. sonst → `{ tone: 'ok', text: 'Die Auftragsbestätigung wurde gespeichert.' }`
- [ ] **Step 4: Test laufen lassen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/auftraegeAnsicht.test.mjs`
Expected: PASS, `pass 13`, `fail 0`.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
git add src/lib/auftraegeAnsicht.js tests/unit/auftraegeAnsicht.test.mjs
git commit -m "Aufträge und Rechnungen: frische Kundenart und Rückmeldungen nach Rechnung und Auftrag (mit Tests)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 7: Platzhalter und Container
**Files:**
- Create: `src/components/auftraege/ProjektkontoListe.jsx`, `RechnungDialog.jsx`, `AuftragListe.jsx`, `AuftragDialog.jsx`, `AuftraegeUndRechnungen.jsx`
**Interfaces:**
- Consumes: `Card`, `Button`, `EmptyState` (`../ui`), `AddressWarning` und `InvoiceTable` (`../finance/…`, Default-Exporte), `useFinanceResource` und `useScrollIntoView` (`../../hooks/…`, benannte Exporte), `getProjektkonten` und `listAuftraege` (`../../lib/financeApi`, Task 3), `customerInvoicesGate` (`../../lib/financeView`), `noticeProps` (`../../lib/financeUi`), alles aus `../../lib/auftraegeAnsicht` (Task 5 und 6).
- Produces (Default-Exporte, Dateiname = Komponentenname, Props nach V8):
- `AuftraegeUndRechnungen({ customer, onShowOverview })`
- `ProjektkontoListe({ konten, ohneProjekt, auftraegeAktiv, stripeOk, onRechnung, onAuftrag })`
- `RechnungDialog({ open, onClose, onCreated, kunde, projektId, auftraegeAktiv, onShowOverview })`
- `AuftragListe({ auftraege, konten, onChanged, onBearbeiten, onShowOverview })`
- `AuftragDialog({ open, onClose, onSaved, kunde, projektId, auftrag, konten, onShowOverview })`
Die Props entsprechen V8 nach den Nachträgen der Planrunde: Der Container reicht `konten` und `onShowOverview` an `AuftragListe` und `AuftragDialog` durch und `onShowOverview` (optional) auch an `RechnungDialog`.
- [ ] **Step 1: Vier Platzhalter schreiben**
Jede Datei ist `export default function <Dateiname>({ … })` mit der Prop-Signatur aus den Interfaces (destrukturiert, auch wenn nichts davon benutzt wird; sie dokumentiert den Vertrag) und gibt `null` zurück (kein React-Import nötig), mit einem Kommentarkopf in genau diesem Sinn (Platzhalter von W0, Strang W1 bzw. W2 ersetzt die Datei vollständig, Dateiname, Default-Export und Props bleiben):
- `ProjektkontoListe.jsx` (W1): `projektId` in `onRechnung(projektId)` und `onAuftrag(projektId)` ist die Kennung des Projekts, `null` die Zeile „Ohne Projekt“. Die fertige Komponente rendert ihren Abschnitt selbst: `<Card title="Projekte">` (ein `<section>`; den Abstand zu den Nachbarn liefert `.page section + section`).
- `RechnungDialog.jsx` (W1): `kunde` ist das Kundendokument oder `null` (Kundenwahl im Dialog); `projektId`: `undefined` = keine Vorauswahl, `null` = „Ohne Projekt“, Text = dieses Projekt; `auftraegeAktiv` ist nur der Startwert (danach gilt `getProjektkonten`, das der Dialog selbst lädt); `onShowOverview` ist optional („Zur Übersicht“ in der Adresswarnung); `onCreated(rechnung, info)`: `info` unverändert weiterreichen wie im bisherigen `InvoiceCreateModal` (`{ senden }`, bei festgeschriebener, nicht versendeter Rechnung `{ senden: true, notice, tone: 'warn', finalizedFilter }`), danach ruft der Dialog `onClose()`. Der Dialog sitzt zusätzlich in `InvoiceTable.jsx` (dort ersetzt W1 das Modal), `FinancePage.jsx` bleibt unverändert.
- `AuftragListe.jsx` (W2): `auftraege` sind `AuftragTeam[]` (V3); `konten` liefern den Projektnamen der Zeile und die Auswahl für „Projekt zuordnen“ (Aktion für ABs ohne Projekt, ruft `ordneAuftragProjektZu(id, projektId)` aus `financeApi.js`); `onShowOverview` ist der Weg „Zur Übersicht“ aus Meldungen. Die fertige Komponente rendert `<Card title="Aufträge">` selbst und ruft nach jeder Aktion `onChanged()`; „Entwurf bearbeiten“ ruft `onBearbeiten(auftrag)`, nach „Neue Fassung“ ruft sie `onBearbeiten(neuerEntwurf)`.
- `AuftragDialog.jsx` (W2): `kunde` kommt mit aktueller `kundenart` (der Container gleicht sie über `kundeMitKundenart` ab); `projektId` ist das vorausgewählte Projekt oder `null` (freier Titel); `auftrag` der Entwurf zum Bearbeiten oder `null`; `konten` die Projektkonten für die Projektwahl; `onShowOverview` der Weg „Zur Übersicht“; nach Speichern oder Senden ruft der Dialog `onSaved(auftrag)` (`AuftragTeam`, mit `mailStatus`) und danach `onClose()`. PDF-Vorschau über `openInNewTab(async () => URL.createObjectURL(await getAuftragPdf(id)))`.
- [ ] **Step 2: Container `AuftraegeUndRechnungen.jsx` schreiben**
Aufbau und Regeln (ersetzt `CustomerInvoices`; deutscher Kopfkommentar zu Aufgabe und Reihenfolge):
- **Außen** `export default function AuftraegeUndRechnungen({ customer, onShowOverview })`: Ist `customerInvoicesGate(customer).ok` falsch, kommt wie bisher `EmptyState` mit `gate.title`, `gate.hint` und (nur mit `onShowOverview`) `<Button variant="primary" onClick={onShowOverview}>Daten erfassen</Button>`; es werden keine Abrufe gestartet. Sonst rendert sie die innere Komponente `AuftraegeInhalt` (die Hooks stehen erst dort).
- **Innen** `AuftraegeInhalt({ customer, onShowOverview })`:
- `const res = useFinanceResource(() => ladeAuftraegeAnsicht(customer.$id, { getProjektkonten, listAuftraege }), KONTEN_FEHLER)`; `const ansicht = ansichtsZustand(res)`; `const reload = res.reload`.
- Zustand: `meldung` (`{ tone, text }` oder `null`), `rechnungsDialog` (`{ projektId }` oder `null`), `auftragsDialog` (`{ projektId, auftrag }` oder `null`), `listeToken` (Zahl, Start 0), dazu `meldungRef` mit `useScrollIntoView(meldungRef, meldung)`.
- `const kunde = kundeMitKundenart(customer, ansicht.kunde)` (geht an beide Dialoge).
- Öffnen eines Dialogs setzt zuerst `meldung` auf `null`.
- `RechnungDialog.onCreated(rechnung, info)`: Dialogzustand `null`, `setMeldung(erstelltMeldung(rechnung, info))`, `setListeToken((n) => n + 1)`, `reload()`. `AuftragDialog.onSaved(auftrag)`: Dialogzustand `null`, `setMeldung(auftragMeldung(auftrag))`, `reload()`. `onClose` setzt den jeweiligen Zustand auf `null` (der Dialog ruft `onCreated` und danach `onClose`; die Handler sind in jeder Reihenfolge und mehrfach unschädlich).
- **Reihenfolge der Ausgabe** (ein Fragment; die Abschnitte sind `<section>`, alles andere steht davor, damit `.page section + section` überall den Abstand liefert):
| Nr. | Element | Bedingung | Umsetzung |
|---|---|---|---|
| a | Adresswarnung | immer | `<AddressWarning customer={customer} onShowOverview={onShowOverview} variant="page" />` |
| b | Rückmeldung | `meldung` | `<p ref={meldungRef} {...noticeProps(meldung.tone, 'fin-notice')}>{meldung.text}</p>` |
| c | Nachladefehler | `projekteStatus === 'bereit'` und `nachladeFehler` | `<p {...noticeProps('warn', 'fin-notice')}>Die Übersicht konnte nicht neu geladen werden: {nachladeFehler}{' '}<button type="button" className="link" onClick={reload}>Erneut versuchen</button></p>` |
| d | Projekte | `laden` | `<Card title="Projekte"><div className="loading" role="status">Projekte werden geladen …</div></Card>` |
| | | `fehler` | `<Card title="Projekte"><div className="fin-load-error"><p className="msg warn" role="alert">{fehler}</p><Button onClick={reload}>Erneut versuchen</Button></div></Card>` |
| | | `bereit` | `<ProjektkontoListe konten ohneProjekt auftraegeAktiv stripeOk onRechnung onAuftrag />` mit den Werten aus `ansicht`; `onRechnung(projektId)` öffnet den Rechnungsdialog, `onAuftrag(projektId)` den Auftragsdialog mit `auftrag: null` |
| e | Aufträge | `auftraegeStatus === 'bereit'` | `<AuftragListe auftraege={ansicht.auftraege} konten={ansicht.konten} onChanged={reload} onBearbeiten={(auftrag) => … Auftragsdialog mit projektId: auftrag.projektId \|\| null und dem Entwurf} onShowOverview={onShowOverview} />` |
| | | `'fehler'` | `<Card title="Aufträge">` mit demselben `fin-load-error`-Block wie bei Projekten, Text `ansicht.auftraegeFehler` |
| | | `'aus'` | nichts |
| f | Rechnungen | immer | `<InvoiceTable customer={customer} onShowOverview={onShowOverview} refreshToken={listeToken} onChanged={reload} />` (nur diese bisherigen Props) |
| g | Dialoge | Zustand gesetzt | `<RechnungDialog open … kunde={kunde} projektId={rechnungsDialog.projektId} auftraegeAktiv={ansicht.auftraegeAktiv} onShowOverview={onShowOverview} />` bzw. `<AuftragDialog open … kunde={kunde} projektId={…} auftrag={…} konten={ansicht.konten} onShowOverview={onShowOverview} />`, nur gemountet, solange der Zustand gesetzt ist (frischer Zustand bei jedem Öffnen) |
- Regeln dazu: Fehlt `.fin-load-error` oder `.fin-notice` im CSS (Task 1 Step 5), entfallen diese beiden Zusatzklassen. Die Dialoge stehen als Geschwister hinter den Abschnitten, **nie** innerhalb von `InvoiceTable` oder `.fin-body` (dessen `container-type` würde `position: fixed` verbiegen). `InvoiceTable` behält seinen eigenen Knopf „Rechnung schreiben“; der Container fasst ihn nicht an. Kein `style`, keine neue CSS-Datei, keine Icons.
- [ ] **Step 3: Syntaxprüfung**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
node_modules/.bin/esbuild src/components/auftraege/*.jsx --loader:.jsx=jsx --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w0-syntax --log-level=warning
```
Expected: keine Ausgabe, Exit 0 (reine Syntaxprüfung; den Import-Graphen prüft der Build in Task 9).
- [ ] **Step 4: Struktur gegen die Tabelle prüfen (Review Focus 1 bis 3)**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
rg -n "<InvoiceTable|<AddressWarning|<ProjektkontoListe|<AuftragListe|<RechnungDialog|<AuftragDialog|useFinanceResource\(|ansichtsZustand\(|kundeMitKundenart\(" src/components/auftraege/AuftraegeUndRechnungen.jsx
rg -n "konten=\{ansicht\.konten\}|onShowOverview=\{onShowOverview\}" src/components/auftraege/AuftraegeUndRechnungen.jsx
```
Expected: im ersten Befehl mindestens ein Treffer je Muster. Im zweiten mindestens dreimal `konten={ansicht.konten}` (`ProjektkontoListe`, `AuftragListe`, `AuftragDialog`) und mindestens fünfmal `onShowOverview={onShowOverview}` (`AddressWarning`, `InvoiceTable`, `RechnungDialog`, `AuftragListe`, `AuftragDialog`). Beim Lesen der Datei gegenprüfen: `<InvoiceTable` und `<AddressWarning` stehen **nicht** in einem Ausdruck, der von `projekteStatus` oder `auftraegeStatus` abhängt (die Rechnungsliste bleibt bei jedem Ausfall der Projektkonten und Aufträge bedienbar); `<AuftragListe` steht nur im Fall `auftraegeStatus === 'bereit'`; es gibt keinen Aufruf von `listAuftraege` außerhalb von `ladeAuftraegeAnsicht`.
- [ ] **Step 5: Hygiene**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && rg -n "react-icons|@tabler|style=\{\{|ui-btn|form-control|form-group|form-label|ui-card|badge-|overlay-content|overlay-close|spinner|kpi|#[0-9a-fA-F]{3,8}\b|rgba?\(|console\.log" src/components/auftraege src/lib/auftraegeAnsicht.js src/lib/auftraegeEndpoints.js`
Expected: keine Treffer.
- [ ] **Step 6: Commit**
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
git add src/components/auftraege/ProjektkontoListe.jsx src/components/auftraege/RechnungDialog.jsx src/components/auftraege/AuftragListe.jsx src/components/auftraege/AuftragDialog.jsx src/components/auftraege/AuftraegeUndRechnungen.jsx
git commit -m "Aufträge und Rechnungen: Container des Kundenreiters und vier Platzhalter für W1 und W2" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 8: Einbindung im Kundenreiter, `CustomerInvoices` entfällt
**Files:**
- Modify: `src/pages/CustomerDetailPage.jsx`
- Delete: `src/components/finance/CustomerInvoices.jsx`
**Interfaces:**
- Consumes: `AuftraegeUndRechnungen({ customer, onShowOverview })` (Task 7).
- Produces: Der Reiter `invoices` der Kundenseite zeigt den Container. Die Adresswarnung (`AddressWarning variant="page"`) und die Sperre „Noch ein Lead“ usw. stecken jetzt im Container und bleiben sichtbar. `AddressWarning.jsx` selbst bleibt unverändert (Nutzer: der Container und der Rechnungsdialog); `CustomerInvoices.jsx` hat danach keinen Nutzer mehr und wird gelöscht.
- [ ] **Step 1: Seite umstellen**
In `src/pages/CustomerDetailPage.jsx`:
- Den Import `import CustomerInvoices from '../components/finance/CustomerInvoices'` ersetzen durch `import AuftraegeUndRechnungen from '../components/auftraege/AuftraegeUndRechnungen'`.
- Die Verwendung `{activeTab === 'invoices' && <CustomerInvoices customer={customer} onShowOverview={() => setTab('overview')} />}` ersetzen durch `{activeTab === 'invoices' && <AuftraegeUndRechnungen key={customer.$id} customer={customer} onShowOverview={() => setTab('overview')} />}` (der `key` sorgt dafür, dass ein anderer Kunde nie den Zustand eines früheren erbt).
- Sonst bleibt die Datei unverändert (laut Übersicht ändert W0 nur Import und Reiterzeile; das Feld Kundenart in der Übersicht gehört W2).
- [ ] **Step 2: Alte Datei entfernen**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
rg -n "CustomerInvoices" src tests
git rm src/components/finance/CustomerInvoices.jsx
rg -n "CustomerInvoices" src tests
```
Expected: Vor dem Löschen Treffer nur in `CustomerInvoices.jsx` selbst (und ggf. in Kommentaren); danach keine Treffer. Bleibt ein Import oder eine Verwendung stehen, wird sie umgestellt, bevor gelöscht wird.
- [ ] **Step 3: Syntax und Anker**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
node_modules/.bin/esbuild src/pages/CustomerDetailPage.jsx --loader:.jsx=jsx --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w0-syntax --log-level=warning
rg -n "AuftraegeUndRechnungen" src/pages/CustomerDetailPage.jsx
```
Expected: esbuild ohne Ausgabe; zwei Treffer (Import und Verwendung).
- [ ] **Step 4: Commit**
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
git add src/pages/CustomerDetailPage.jsx
git commit -m "Kundenseite: Reiter „Aufträge und Rechnungen“ zeigt den neuen Container, CustomerInvoices entfällt" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
(Die mit `git rm` vorgemerkte Löschung gehört in denselben Commit.)
### Task 9: Selbstprüfung
**Files:** keine Änderung, außer Korrekturen aus den Prüfungen.
**Interfaces:**
- Consumes: alle Ergebnisse der Tasks 1 bis 8.
- Produces: die Abschlussmeldung an den Orchestrator (Step 6). Von diesem Stand legt der Orchestrator die Worktrees von W1 und W2 an.
- [ ] **Step 1: Volle Suite (hinter dem `flock`)**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad
free -m | head -2
flock $L/auftraege-schwer.lock env NODE_OPTIONS=--max-old-space-size=1024 timeout 900 nice -n 10 npm test > $L/auftraege-w0-test.log 2>&1; echo "exit $?"
rg "^(ℹ|#) (tests|pass|fail) " $L/auftraege-w0-test.log
```
Expected: `exit 0`, `fail 0`, `pass` = `N0 + 21` (4 Blob, 4 Endpunkte, 13 Ansichtslogik; `customerTabs` ändert nur eine Erwartung).
- [ ] **Step 2: Build (hinter dem `flock`, mit Speichergrenze)**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad
free -m | head -2
flock $L/auftraege-schwer.lock systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1200 nice -n 10 node node_modules/vite/bin/vite.js build --outDir $L/auftraege-w0-dist --emptyOutDir > $L/auftraege-w0-build.log 2>&1; echo "exit $?"
tail -8 $L/auftraege-w0-build.log
```
Expected: `exit 0`, die letzten Zeilen nennen die gebauten Dateien und „built in …“, keine Fehlermeldung zu fehlenden Exporten oder Importen. Schlägt der Build fehl, wird die Ursache in der Datei des zuständigen Tasks korrigiert (neuer Commit „Korrektur: …“), danach Step 1 und 2 wiederholt.
- [ ] **Step 3: Vertragsliste abhaken (V8)**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
for n in getProjektkonten getProjektkonto previewInvoice listAuftraege getAuftrag createAuftrag patchAuftrag deleteAuftrag sendAuftrag erinnereAuftrag neueFassungAuftrag storniereAuftrag ordneAuftragProjektZu getAuftragPdf; do rg -q "\b$n\b" src/lib/financeApi.js && echo "ok $n" || echo "FEHLT $n"; done
rg -n "export const createInvoice|export const getAbgerechnet" src/lib/financeApi.js src/lib/teamApi.js
for f in AuftraegeUndRechnungen ProjektkontoListe RechnungDialog AuftragListe AuftragDialog; do rg -c "export default function $f\b" src/components/auftraege/$f.jsx; done
rg -n "label: 'Aufträge und Rechnungen'" src/lib/customerTabs.js
rg -n "AuftraegeUndRechnungen" src/pages/CustomerDetailPage.jsx
```
Expected: 14-mal `ok …`, `createInvoice` und `getAbgerechnet` gefunden, fünfmal `1`, die Beschriftung und die Einbindung gefunden. Abgehakt wird außerdem (aus den Tests):
- [ ] `getAuftragPdf` liefert einen Blob (Task 3, Test „getAuftragPdf …“); `ordneAuftragProjektZu` sendet `POST …/auftraege/<id>/projekt` mit `{ projektId }` (Task 3, Test „Pfad, Methode, Kopf und Körper …“).
- [ ] Der Container hat die Props `{ customer, onShowOverview }`, die Platzhalter die Props aus V8 samt `konten` und `onShowOverview`.
- [ ] Der Container reicht `konten` und `onShowOverview` an `AuftragListe` und `AuftragDialog` durch, `onShowOverview` auch an `RechnungDialog` (Task 7, Step 4).
- [ ] `AddressWarning variant="page"` steht im Container (Task 7, Zeile a) und `CustomerInvoices` ist weg (Task 8).
- [ ] Der Reiter bleibt nur für Admins (Test `customerTabs`).
- [ ] **Step 4: Hygiene der neuen Dateien**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && rg -n "react-icons|@tabler|style=\{\{|ui-btn|form-control|form-group|form-label|ui-card|badge-|overlay-content|overlay-close|spinner|kpi|#[0-9a-fA-F]{3,8}\b|rgba?\(|console\.log" src/components/auftraege src/lib/auftraegeAnsicht.js src/lib/auftraegeEndpoints.js`
Expected: keine Treffer.
- [ ] **Step 5: Dateiliste und Git-Zustand**
Run (mit `B` aus Task 1):
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
B=<B aus Task 1>
git diff --name-status "$B"..HEAD
git log --oneline "$B"..HEAD
git status --short
```
Expected: genau diese 17 Pfade (Reihenfolge egal): neu `src/components/auftraege/{AuftraegeUndRechnungen,ProjektkontoListe,RechnungDialog,AuftragListe,AuftragDialog}.jsx`, `src/lib/auftraegeAnsicht.js`, `src/lib/auftraegeEndpoints.js`, `tests/unit/{auftraegeAnsicht,auftraegeEndpoints,financeRequestBlob}.test.mjs`; geändert `src/lib/{customerTabs,financeApi,financeRequest,teamApi}.js`, `src/pages/CustomerDetailPage.jsx`, `tests/unit/customerTabs.test.mjs`; gelöscht `src/components/finance/CustomerInvoices.jsx`. Sieben Commits (plus eventuelle „Korrektur“-Commits), `git status --short` leer. Nichts gepusht.
- [ ] **Step 6: Abschlussmeldung an den Orchestrator**
Die Antwort nennt: Basis `B` und `N0`, letzten Commit (`git rev-parse --short HEAD`), Testzahl und Build-Ergebnis, die 17 Pfade, Abweichungen von diesem Plan samt Grund, und den Hinweis: „`node_modules` im Worktree ist echt und kann von W1 und W2 verlinkt werden.“ Dazu der Hinweis, dass der Container V8 samt Nachträgen der Planrunde umsetzt (Kindkomponenten rendern ihre `Card` selbst; `projektId` `undefined` = keine Vorauswahl, `null` = „Ohne Projekt“; `info` von `onCreated` unverändert; `konten` und `onShowOverview` durchgereicht).
---
## Vertragsfragen
Alle Fragen aus der Erstfassung dieses Plans sind **entschieden (siehe Übersicht, Nachträge nach der Planrunde, V5 und V8)**; die Übersicht gilt. Die Liste hält fest, was entschieden wurde und wo dieser Plan es umsetzt.
1. **Wer öffnet „Rechnung schreiben“ im Kundenreiter?** Entschieden (siehe Übersicht, Nachträge): `RechnungDialog` sitzt in `InvoiceTable.jsx` (dort ersetzt W1 das Modal), `FinancePage.jsx` bleibt unverändert. Umsetzung: Der Container öffnet `RechnungDialog` nur für die Projektzeilen und nutzt `InvoiceTable` mit seinen bisherigen Props (`customer`, `onShowOverview`, `refreshToken`, `onChanged`); der Dialog lädt Projektliste und Schalter selbst über `getProjektkonten` (`auftraegeAktiv` ist nur der Startwert). Es bleibt dabei: Nach dem Anlegen über eine Projektzeile behält die Liste ihren Filter, die Meldung des Containers nennt Entwurf oder Rechnung (Task 6 und 7); ein Entwurf liegt dann unter „Entwürfe“.
2. **`projektId` im `RechnungDialog`.** Entschieden (siehe Übersicht, Nachträge; V8): `undefined` = keine Vorauswahl (Kartenknopf in `InvoiceTable`), `null` = „Ohne Projekt“ (Zeile „Ohne Projekt“), Text = dieses Projekt. Umsetzung: Der Container reicht die Kennung aus `onRechnung(projektId)` unverändert durch (Task 7, Zeile g).
3. **`onCreated(rechnung, info)`.** Entschieden (siehe Übersicht, Nachträge; V8): `info` unverändert weiterreichen (`senden`, `notice`, `tone`, `finalizedFilter`). Umsetzung: `erstelltMeldung` wertet `notice` und `tone` aus (Task 6).
4. **Abschnittsüberschriften.** Entschieden (siehe Übersicht, Nachträge; V8): Die Kindkomponenten rendern ihre `Card` samt Titel selbst. Umsetzung: Kommentare der Platzhalter und Container (Zustände „laden“ und „fehler“ mit denselben Titeln, Task 7).
5. **PDF öffnen (W2).** Entschieden (siehe Übersicht, Nachträge; V8): `openInNewTab(async () => URL.createObjectURL(await getAuftragPdf(id)))`. Umsetzung: `getAuftragPdf` liefert den Blob (Task 2 und 3); das Öffnen gehört W2 (Hinweis im Kommentar von `AuftragDialog.jsx`).
6. **Testbefehl.** Entschieden (siehe Übersicht, Nachträge; Tech Stack): Die WOMS-Tests laufen mit `npm test` (`tests/unit/*.test.mjs`). Umsetzung: Task 1 Step 6 und Task 9 Step 1.
7. **Form von `abgerechnet`.** Entschieden (siehe Übersicht, Nachträge; V5): `{ abgerechnet: { <worksheetId>: '<Rechnungsnummer>' | 'Entwurf' } }`, für ein unbekanntes Ticket `{ abgerechnet: {} }`. W0 folgt V5 (Task 3); Spec Abschnitt 6 nennt die kürzere Form. Das Abzeichen für `'Entwurf'` legt W1 fest.
8. **Dateizuständigkeit WOMS.** Entschieden (siehe Übersicht, Nachträge, Absatz „WOMS“): W0 besitzt API-Schicht (`financeApi.js`, `financeRequest.js`, `teamApi.js`, `auftraegeEndpoints.js`), `auftraegeAnsicht.js`, `customerTabs.js`, den Container samt vier Platzhaltern, die Reiterzeile in `CustomerDetailPage.jsx` und die Löschung von `CustomerInvoices.jsx`; W1 die Projektliste, den Rechnungsdialog, `InvoiceTable.jsx`, `WorksheetList.jsx`, `WorksheetTimesModal.jsx` und die Löschung von `InvoiceCreateModal.jsx`; W2 die Auftragsliste, den Auftragsdialog, das Feld Kundenart in `CustomerDetailPage.jsx` und `SettingsCard.jsx`. W2 startet nach W0, also gibt es in `CustomerDetailPage.jsx` keinen Konflikt.
9. **Basis und Push-Reihenfolge.** Entschieden (siehe Übersicht, Nachträge): `feature/auftraege` (WOMS) enthält alle Redesign-Commits bis zum Basis-Tag `B`. Der Nutzer pusht erst, wenn das Redesign auf `test` liegt; vorher wird der Zweig auf `origin/test` aktualisiert (Z2). Die Übergabe nennt `B`; Task 1 Step 2 meldet, wenn `origin/test` weiter ist als `B`.
10. **Prüfwerkzeug des Redesigns.** Entschieden (siehe Übersicht, Nachträge): Das Redesign-Werkzeug wird nicht geändert; Bildschirmfotos entstehen mit eigenen Skripten im Scratchpad (W1, W2, Z2). W0 macht keine Bildschirmfotos und fasst das Werkzeug nicht an.
11. **Kundenart, Schreibweg.** Entschieden (siehe Übersicht, Nachträge; V5): Die AB-Dialoge (W2) schicken `kundenart` mit `POST` und `PATCH …/auftraege`, das Portal setzt sie am Kunden, wenn dort leer; die Übersicht (W2) schreibt das Feld bei Änderung direkt nach Appwrite. W0 gleicht in beiden Fällen über `kunde.kundenart` aus der Antwort von `getProjektkonten` ab (`kundeMitKundenart`, Task 6 und 7).
12. **`onShowOverview` im `RechnungDialog`.** Entschieden (siehe Übersicht, Nachträge; V8): optionale Prop des Dialogs. Umsetzung: Der Container reicht sie durch (Task 7, Zeile g).
Mit der Planrunde kamen zwei Festlegungen für W0 dazu, die dieser Plan umsetzt:
13. **`ordneAuftragProjektZu(id, projektId)`.** V5 (Zeile L3) hat die Route `POST …/auftraege/:id/projekt` mit `{ projektId }`; W2 braucht sie für „Projekt zuordnen“ (Spec: „später zuordenbar“). Umsetzung: Task 3 (Pfad in `auftraegeEndpoints.js`, Test, Export in `financeApi.js`).
14. **`konten` und `onShowOverview` für `AuftragListe` und `AuftragDialog`.** V8 (Nachträge): Der Container reicht beides durch (Projektname und Auswahl „Projekt zuordnen“ in der Liste, „Zur Übersicht“ in Meldungen). Umsetzung: Task 7 (Props der Platzhalter, Zeilen e und g, Step 4).
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,419 @@
# Aufträge und Rechnungen: Auftragsbestätigung und projektbezogene Rechnungen
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),
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. `<Dialog>`).
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 ü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).
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` auf die neue AB); Stripe-Metadaten und der auf finalisierten Rechnungen aufgedruckte
Bezug bleiben 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), statische
Figtree-Schnitte (OFL) unter `server/assets/fonts/`, Logo `public/webklar-logo.png`.
- Beim Senden einmal erzeugt und unter `upload-data/auftraege/<kundenId>/<abId>.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-<fassung>.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, **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=<token>` (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).
- 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;
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 (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
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); 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.
### 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, <Satz> €/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` (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`.
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.
- **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
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
## 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.