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

+1 -1
View File
@@ -1,4 +1,4 @@
node_modules/
node_modules
.env
.DS_Store
preview-data/
+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.
+188 -2
View File
@@ -14,9 +14,11 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"feiertagejs": "1.5.1",
"fontkit": "^2.0.4",
"jose": "^6.0.11",
"node-appwrite": "^14.1.0",
"nodemailer": "^9.0.3",
"pdfkit": "^0.20.2",
"sharp": "^0.34.5",
"stripe": "^19.3.0"
},
@@ -571,6 +573,39 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -596,6 +631,26 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
@@ -620,6 +675,15 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -658,6 +722,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -744,6 +817,12 @@
"node": ">=8"
}
},
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -876,6 +955,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/feiertagejs": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/feiertagejs/-/feiertagejs-1.5.1.tgz",
@@ -885,6 +970,12 @@
"node": ">=8.0.0"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -903,6 +994,23 @@
"node": ">= 0.8"
}
},
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1059,6 +1167,25 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1191,6 +1318,12 @@
"node": ">= 0.8"
}
},
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1206,6 +1339,28 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/pdfkit": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.2.tgz",
"integrity": "sha512-Q/w03ICAQyXfHNfTsg1udp0ADerdBN0s7a6XSPL7J7Ro6ABnafoBOCDBstIO9U02mvzHEV8HrvFIw5iV5ejIRA==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^1.3.0",
"@noble/hashes": "^1.8.0",
"fflate": "^0.8.3",
"fontkit": "^2.0.4",
"linebreak": "^1.1.0",
"png-js": "^2.0.0"
}
},
"node_modules/png-js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
"integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==",
"dependencies": {
"fflate": "^0.8.2"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1258,6 +1413,12 @@
"node": ">= 0.8"
}
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1492,6 +1653,12 @@
}
}
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1505,8 +1672,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/type-is": {
"version": "1.6.18",
@@ -1521,6 +1687,26 @@
"node": ">= 0.6"
}
},
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+2
View File
@@ -21,9 +21,11 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"feiertagejs": "1.5.1",
"fontkit": "^2.0.4",
"jose": "^6.0.11",
"node-appwrite": "^14.1.0",
"nodemailer": "^9.0.3",
"pdfkit": "^0.20.2",
"sharp": "^0.34.5",
"stripe": "^19.3.0"
}
+22 -1
View File
@@ -584,6 +584,13 @@ function renderViewAsBanner(me) {
}
/* ------------------------------------------------------------- Rechnungen --- */
// @rechnungsnummer-anfang
/** Rechnungsnummer mit dem Projekt darunter (Stripe-Metadaten projektName, Feld `projekt`); beides maskiert. */
function invoiceNumberHtml(i) {
const project = i.projekt ? `<span class="inv-project">${escapeHtml(i.projekt)}</span>` : ''
return `${escapeHtml(i.number)}${project}`
}
// @rechnungsnummer-ende
async function loadCustomerInvoices() {
const body = document.getElementById('invoices-body')
if (!body) return
@@ -615,7 +622,7 @@ async function loadCustomerInvoices() {
<tbody>
${invoices.map((i) => `
<tr class="${Number(i.balance) > 0 ? 'is-open' : 'is-paid'}">
<td class="inv-no">${escapeHtml(i.number)}</td>
<td class="inv-no">${invoiceNumberHtml(i)}</td>
<td class="inv-date">${fmtDateShort(i.date)}</td>
<td class="inv-amount">${fmtEuro(i.amount)}</td>
<td class="inv-open">${fmtEuro(i.balance)}</td>
@@ -632,6 +639,19 @@ async function loadCustomerInvoices() {
}
}
// Abschnitt "Aufträge" im Reiter Rechnungen (public/auftrag-portal.js): Fehler lassen den Abschnitt weg und stören die Rechnungen nicht.
function loadCustomerOrders() {
return import('/auftrag-portal.js')
.then((m) => m.initAuftraege({
section: document.getElementById('orders-section'),
body: document.getElementById('orders-body'),
invoicesTitle: document.getElementById('invoices-title'),
request: api,
viewAs: chatState.viewAs,
}))
.catch(() => {})
}
/* -------------------------------------------------------------- Aktivität --- */
// Abschnitt "Was wir zuletzt erledigt haben" unter Projekte: je Auftrag eine Karte mit Status, Fortschrittsleiste
// und Arbeiten (Bausteine der Projektdetails; Texte kommen aufbereitet vom Server). Zuerst die drei neuesten,
@@ -2064,6 +2084,7 @@ async function initDashboardPage() {
if (aboParam) window.history.replaceState(null, '', `/dashboard.html${window.location.hash}`)
loadContracts({ afterCheckout: aboParam === 'success' })
loadCustomerInvoices()
loadCustomerOrders()
activityScrollPending = currentHash() === 'fortschritt'
loadCustomerActivity().then(scrollToActivity)
+260
View File
@@ -0,0 +1,260 @@
// Reine Ansichtslogik der Auftragsbestätigung (AB) im Kundenportal: Token, Beträge, Daten, Zustände, Status, AB-Nummer
// und alle Seitentexte. Ohne Importe, ohne DOM, ohne fetch; jede Funktion verträgt fehlende Felder.
// Die Anrede wählt der Aufrufer über ANREDE_SIE (öffentliche Seite, Widerrufsseite) oder ANREDE_DU (angemeldetes Portal).
export const TOKEN_MUSTER = /^[A-Za-z0-9_-]{43}$/
export const KONTAKT = { email: 'support@webklar.com', telefon: '0176 23726355' }
export const BESTAETIGUNG_FALLBACK = 'Ich bestätige den Auftrag zu diesen Bedingungen.'
export const ANREDE_SIE = 'sie'
export const ANREDE_DU = 'du'
/** Texte, die in beiden Anreden gleich lauten (weder Du- noch Sie-Formen). */
export const TEXTE_GEMEINSAM = {
titelOffen: 'Auftragsbestätigung',
leistung: 'Leistungsumfang',
aufwand: 'Abrechnung nach Aufwand',
zahlung: 'Zahlung',
termine: 'Termine',
unterlagen: 'Unterlagen',
spalten: ['Beschreibung', 'Menge', 'Einzelpreis', 'Summe'],
gesamtpreis: 'Gesamtpreis',
linkPdf: 'Auftragsbestätigung als PDF ansehen',
linkAgb: 'Allgemeine Geschäftsbedingungen (PDF)',
freiwillig: '(freiwillig)',
knopf: 'Auftrag bestätigen',
sendet: 'Wird gesendet …',
ansichtsmodus: 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.',
erneut: 'Erneut versuchen',
kartePdf: 'PDF ansehen',
karteDetails: 'Details ansehen',
karteDetailsBestaetigen: 'Details ansehen und bestätigen',
}
/** Öffentliche Seite und Widerrufsseite (Sie-Form); enthält auch die Schlüssel, die das Portal in Du überschreibt. */
export const TEXTE_SIE = {
laedt: 'Ihre Auftragsbestätigung wird geladen …',
geladen: 'Ihre Auftragsbestätigung wurde geladen.',
titelBestaetigt: 'Ihr Auftrag ist bestätigt.',
intro: 'Bitte prüfen Sie die Angaben. Mit einem Klick bestätigen Sie den Auftrag.',
kontaktVor: 'Sie erreichen uns unter',
keinJs: 'Bitte aktivieren Sie JavaScript, um Ihren Auftrag zu bestätigen.',
abNummerUngueltig: 'Die Nummer sieht anders aus als AB-2026-0001. Bitte prüfen Sie die Nummer oder lassen Sie das Feld leer.',
formular: 'Ihre Bestätigung',
speicherHinweis: 'Ihre Bestätigung wird mit Datum und Uhrzeit gespeichert.',
fruehbeginnWunsch: 'Wir beginnen auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist.',
fehler: {
keine: 'Keine Verbindung. Bitte prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.',
haken: 'Bitte setzen Sie das Häkchen, um den Auftrag zu bestätigen.',
anmeldung: 'Ihre Anmeldung ist abgelaufen. Bitte laden Sie die Seite neu und melden Sie sich erneut an.',
gesperrt: 'Das Bestätigen ist gerade nicht möglich. Bitte laden Sie die Seite neu.',
zuviele: 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten noch einmal.',
sonst: 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuchen Sie es später noch einmal.',
},
}
/** Angemeldetes Portal (Du-Form): nur die Schlüssel, die anders lauten. */
export const TEXTE_DU = {
formular: 'Deine Bestätigung',
speicherHinweis: 'Deine Bestätigung wird mit Datum und Uhrzeit gespeichert.',
fruehbeginnWunsch: 'Wir beginnen auf deinen Wunsch schon vor Ablauf der Widerrufsfrist.',
fehler: {
keine: 'Keine Verbindung. Bitte prüfe deine Internetverbindung und versuche es erneut.',
haken: 'Bitte setze das Häkchen, um den Auftrag zu bestätigen.',
anmeldung: 'Deine Anmeldung ist abgelaufen. Bitte lade die Seite neu und melde dich erneut an.',
gesperrt: 'Das Bestätigen ist gerade nicht möglich. Bitte lade die Seite neu.',
zuviele: 'Zu viele Anfragen. Bitte versuche es in einigen Minuten noch einmal.',
sonst: 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuche es später noch einmal.',
},
}
/** Texte der gewählten Anrede (ohne Angabe: Sie). */
export const texte = (anrede = ANREDE_SIE) => ({ ...TEXTE_GEMEINSAM, ...(anrede === ANREDE_DU ? TEXTE_DU : TEXTE_SIE) })
/* ------------------------------------------------------------------ Token und Links --- */
/** Token aus der Adresse: zuerst das Fragment (so verlinkt K31), dann die Suche (Rückfall). '' = ungültig. */
export function tokenAusAdresse(suche = '', hash = '') {
for (const roh of [hash, suche]) {
const teil = String(roh ?? '').replace(/^[#?]/, '')
if (!teil) continue
const werte = new URLSearchParams(teil).getAll('t')
if (werte.length === 1 && TOKEN_MUSTER.test(werte[0])) return werte[0]
}
return ''
}
export const pdfPfad = (token) => `/api/auftrag/${encodeURIComponent(token)}/pdf`
/** AGB-Link: nur ein PDF unter /rechtliches/, sonst ''. */
export const agbPfad = (agbUrl) => (typeof agbUrl === 'string' && /^\/rechtliches\/[A-Za-z0-9][A-Za-z0-9._-]*\.pdf$/.test(agbUrl) ? agbUrl : '')
/* ------------------------------------------------------------------ Beträge und Zeiten --- */
const EURO = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' })
const BERLIN_TAG = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' })
const BERLIN_ZEIT = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
})
/** Ganze Cent als deutscher Euro-Betrag (negativ mit dem Minuszeichen), sonst '–'. */
export function euroDe(cents) {
if (!Number.isSafeInteger(cents)) return '–'
const text = EURO.format(cents / 100)
return text.startsWith('-') ? `\u{2212}${text.slice(1)}` : text
}
/** 'YYYY-MM-DD' oder ISO-Zeitpunkt (Berliner Tag) oder 'TT.MM.JJJJ' als 'TT.MM.JJJJ', sonst ''. */
export function datumDe(wert) {
if (typeof wert !== 'string') return ''
if (/^\d{2}\.\d{2}\.\d{4}$/.test(wert)) return wert
if (/^\d{4}-\d{2}-\d{2}$/.test(wert)) return `${wert.slice(8, 10)}.${wert.slice(5, 7)}.${wert.slice(0, 4)}`
if (/^\d{4}-\d{2}-\d{2}T/.test(wert)) {
const zeit = new Date(wert)
if (Number.isNaN(zeit.getTime())) return ''
return datumDe(BERLIN_TAG.format(zeit))
}
return ''
}
/** 'TT.MM.JJJJ, HH:MM Uhr' in Berliner Zeit, sonst ''. */
export function zeitpunktDe(iso) {
if (typeof iso !== 'string' || !iso) return ''
const zeit = new Date(iso)
if (Number.isNaN(zeit.getTime())) return ''
const teile = Object.fromEntries(BERLIN_ZEIT.formatToParts(zeit).map((teil) => [teil.type, teil.value]))
return `${teile.day}.${teile.month}.${teile.year}, ${teile.hour}:${teile.minute} Uhr`
}
/* ------------------------------------------------------------------ Zustand der AB --- */
export const zeigtFruehbeginn = (a) => typeof a?.texte?.fruehbeginn === 'string' && a.texte.fruehbeginn.trim() !== ''
export const darfBestaetigen = (a) => !a?.bestaetigtAm && (a?.status === 'versendet' || a?.status === 'abgerechnet')
const STATUS = {
versendet: { text: 'Versendet', ton: 'muted' },
bestaetigt: { text: 'Bestätigt', ton: 'ok' },
abgerechnet: { text: 'Abgerechnet', ton: 'ok' },
storniert: { text: 'Storniert', ton: 'muted' },
ersetzt: { text: 'Ersetzt', ton: 'muted' },
widerrufen: { text: 'Widerrufen', ton: 'rot' },
}
export function statusAnzeige(a) {
if (a?.kannBestaetigen === true && !a.bestaetigtAm) return { text: 'Bitte bestätigen', ton: 'warn' }
const eintrag = STATUS[a?.status]
return eintrag ? { ...eintrag } : { text: '–', ton: 'muted' }
}
export function kartenMeta(a) {
const teile = []
const datum = datumDe(a?.datum)
if (datum) teile.push(`vom ${datum}`)
if (a?.art === 'aufwand') teile.push('nach Aufwand')
else if (Number.isSafeInteger(a?.summeCents)) teile.push(euroDe(a.summeCents))
return teile.join(' · ')
}
/** Kopie: zuerst die AB, die noch bestätigt werden kann, dann nach Nummer absteigend (Zeichenvergleich). */
export function sortiereAuftraege(liste) {
if (!Array.isArray(liste)) return []
return [...liste].sort((x, y) => {
const a = x?.kannBestaetigen === true ? 0 : 1
const b = y?.kannBestaetigen === true ? 0 : 1
if (a !== b) return a - b
const nx = String(x?.nummer ?? '')
const ny = String(y?.nummer ?? '')
return nx < ny ? 1 : nx > ny ? -1 : 0
})
}
/* ------------------------------------------------------------------ Inhalte --- */
export function zusammenfassung(a) {
const auftrag = a ?? {}
const aufwand = auftrag.art === 'aufwand'
const kopf = [
{ label: 'Nummer', wert: auftrag.nummer || '' },
{ label: 'Datum', wert: datumDe(auftrag.datum) },
{ label: 'Projekt', wert: auftrag.titel || '' },
].filter((zeile) => zeile.wert)
const positionen = (Array.isArray(auftrag.positionen) ? auftrag.positionen : []).map((p) => ({
beschreibung: String(p?.beschreibung ?? ''),
menge: String(p?.menge ?? ''),
einzelpreis: euroDe(p?.einzelpreisCents),
summe: euroDe(p?.summeCents),
}))
const termine = [
{ label: 'Start', wert: datumDe(auftrag.startAm) },
{ label: 'Voraussichtliche Fertigstellung', wert: datumDe(auftrag.fertigBis) },
].filter((zeile) => zeile.wert)
const hinweis = typeof auftrag.texte?.kleinunternehmer === 'string' ? auftrag.texte.kleinunternehmer.trim() : ''
return {
kopf,
art: aufwand ? 'aufwand' : 'festpreis',
positionen,
summe: !aufwand && Number.isSafeInteger(auftrag.summeCents) ? euroDe(auftrag.summeCents) : '',
aufwand: aufwand ? String(auftrag.aufwandText ?? '') : '',
zahlung: String(auftrag.zahlungsplanText ?? ''),
termine,
hinweis,
}
}
export function bestaetigtZeilen(a, anrede = ANREDE_SIE) {
if (!a?.bestaetigtAm) return []
const zeitpunkt = zeitpunktDe(a.bestaetigtAm)
const zeilen = [zeitpunkt ? `Bestätigt am ${zeitpunkt}` : 'Bestätigt']
if (a.kundenart === 'privat') {
if (a.fruehbeginn === true) zeilen.push(texte(anrede).fruehbeginnWunsch)
else if (datumDe(a.fruehesterBeginn)) zeilen.push(`Wir beginnen frühestens am ${datumDe(a.fruehesterBeginn)}.`)
}
return zeilen
}
/* ------------------------------------------------------------------ Antworten des Servers --- */
const BEENDET_GRUENDE = [['ersetzt', /ersetzt/i], ['storniert', /storniert/i], ['widerrufen', /widerrufen/i]]
/** Zustand der öffentlichen Seite aus Status und Antwort (nur feste eigene Texte, nie Servertext). */
export function zustandAusAntwort(status, body) {
if (status === 404) {
return {
zustand: 'ungueltig', titel: 'Dieser Link ist ungültig oder abgelaufen.',
text: 'Bitte melden Sie sich bei uns. Wir senden Ihnen gern einen neuen Link.',
}
}
if (status === 410) {
const meldung = typeof body?.error === 'string' ? body.error : ''
const grund = BEENDET_GRUENDE.find(([, muster]) => muster.test(meldung))?.[0] || ''
return {
zustand: 'beendet',
titel: grund ? `Diese Auftragsbestätigung wurde ${grund}.` : 'Diese Auftragsbestätigung ist nicht mehr gültig.',
text: grund === 'ersetzt'
? 'Bitte verwenden Sie den Link in der neuesten Auftragsbestätigung, die wir Ihnen geschickt haben.'
: 'Bei Fragen melden Sie sich gern bei uns.',
}
}
if (status === 429) return { zustand: 'limit', titel: 'Zu viele Anfragen', text: 'Bitte versuchen Sie es in einigen Minuten noch einmal.' }
return {
zustand: 'fehler', titel: 'Das hat leider nicht geklappt',
text: 'Ihre Auftragsbestätigung konnte gerade nicht geladen werden. Bitte versuchen Sie es in einigen Minuten noch einmal.',
}
}
/** Meldung beim Bestätigen nach Status (feste eigene Texte der gewählten Anrede). */
export function fehlerMeldung(status, anrede = ANREDE_SIE) {
const fehler = texte(anrede).fehler
if (status === 0) return fehler.keine
if (status === 400) return fehler.haken
if (status === 401) return fehler.anmeldung
if (status === 403 || status === 409) return fehler.gesperrt
if (status === 429) return fehler.zuviele
return fehler.sonst
}
/** Freiwillige AB-Nummer der Widerrufsseite: 'AB-JJJJ-NNNN' oder ''. */
export function abNummerNormalisiert(eingabe) {
const text = String(eingabe ?? '').trim().toUpperCase().replace(/[\s\-\u{2010}-\u{2015}\u{2212}]+/gu, '-')
return /^AB-\d{4}-\d{4}$/.test(text) ? text : ''
}
+126
View File
@@ -0,0 +1,126 @@
/* Auftragsbestätigung im Kundenportal: DOM-Bausteine (Zusammenfassung, Bestätigt-Block, Unterlagen, Bestätigen-Formular).
Kundentext und Servertext kommen ausschließlich über textContent in die Seite (h), nie über innerHTML.
Alle Texte stehen in auftrag-ansicht.js; die Anrede (Sie auf der öffentlichen Seite, Du im Portal) wählt der Aufrufer. */
import { ANREDE_SIE, BESTAETIGUNG_FALLBACK, bestaetigtZeilen, fehlerMeldung, texte, zeigtFruehbeginn, zusammenfassung } from './auftrag-ansicht.js'
/** Element bauen: Texte nur als Textknoten, Attribute mit on… als Ereignis, true setzt ein Attribut ohne Wert, false/null/undefined lassen es weg. */
export function h(tag, attrs, ...kinder) {
const el = document.createElement(tag)
for (const [name, wert] of Object.entries(attrs || {})) {
if (wert === undefined || wert === null || wert === false) continue
if (name === 'class') el.className = wert
else if (name.startsWith('on')) el.addEventListener(name.slice(2), wert)
else el.setAttribute(name, wert === true ? '' : wert)
}
for (const kind of kinder.flat()) if (kind !== undefined && kind !== null && kind !== false) el.append(kind)
return el
}
const zeile = (label, wert) => h('div', { class: 'ab-zeile' }, h('dt', null, label), h('dd', null, wert))
const liste = (zeilen) => h('dl', { class: 'ab-liste' }, zeilen.map((z) => zeile(z.label, z.wert)))
/** Zusammenfassung der AB (Kopf, Leistung bzw. Aufwand, Zahlung, Termine); ebene = Überschriftenebene der Abschnitte. */
export function renderZusammenfassung(auftrag, { ebene = 2 } = {}) {
const t = texte(ANREDE_SIE)
const z = zusammenfassung(auftrag)
const kopfzeile = (text) => h(`h${ebene}`, { class: 'ab-h2' }, text)
const abschnitt = (name, titel, ...inhalt) => h('section', { class: 'ab-abschnitt', 'data-abschnitt': name }, kopfzeile(titel), ...inhalt)
const hinweis = z.hinweis ? h('p', { class: 'ab-hinweis', 'data-hinweis': true }, z.hinweis) : null
const teile = []
if (z.kopf.length) teile.push(liste(z.kopf))
if (z.art === 'aufwand') {
teile.push(abschnitt('aufwand', t.aufwand, h('p', { class: 'ab-text', 'data-aufwand': true }, z.aufwand), hinweis))
} else {
const tabelle = z.positionen.length
? h('table', { class: 'ab-tabelle', 'data-positionen': true },
h('thead', null, h('tr', null, t.spalten.map((spalte) => h('th', { scope: 'col' }, spalte)))),
h('tbody', null, z.positionen.map((p) => h('tr', { 'data-position': true },
[p.beschreibung, p.menge, p.einzelpreis, p.summe].map((wert, i) => h('td', { 'data-label': t.spalten[i] }, wert))))))
: null
const gesamt = z.summe ? h('p', { class: 'ab-gesamt', 'data-gesamt': true }, h('span', null, t.gesamtpreis), ' ', h('strong', null, z.summe)) : null
teile.push(abschnitt('leistung', t.leistung, tabelle, gesamt, hinweis))
}
if (z.zahlung) teile.push(abschnitt('zahlung', t.zahlung, h('p', { class: 'ab-text', 'data-zahlung': true }, z.zahlung)))
if (z.termine.length) teile.push(abschnitt('termine', t.termine, liste(z.termine)))
return h('div', { class: 'ab-zusammenfassung', 'data-art': z.art }, teile)
}
/** Block „bestätigt“ (Zeitpunkt, bei Privatpersonen Zeile zum Beginn); null ohne Bestätigung. */
export function renderBestaetigtBlock(auftrag, { anrede = ANREDE_SIE } = {}) {
const zeilen = bestaetigtZeilen(auftrag, anrede)
if (!zeilen.length) return null
return h('div', { class: 'ab-bestaetigt', 'data-bestaetigt': true, role: 'status', tabindex: '-1' },
zeilen.map((text, i) => h('p', null, i === 0 ? h('strong', null, text) : text)))
}
/** Links auf die AB als PDF und die AGB (neuer Tab); null ohne Ziele. */
export function renderDokumente({ pdfHref = '', agbHref = '' } = {}) {
const t = texte(ANREDE_SIE)
const link = (art, href, text) => h('li', null, h('a', { class: 'ab-link', 'data-link': art, href, target: '_blank', rel: 'noopener noreferrer' }, text))
const eintraege = [pdfHref ? link('pdf', pdfHref, t.linkPdf) : null, agbHref ? link('agb', agbHref, t.linkAgb) : null].filter(Boolean)
return eintraege.length ? h('ul', { class: 'ab-links' }, eintraege) : null
}
/**
* Formular zum Bestätigen: ein Häkchen, bei Privatpersonen ein zweites, freiwilliges (Frühbeginn). Sendet genau einmal:
* senden(koerper) → { ok: true, auftrag } | { ok: false, status, body }; nachErfolg(auftrag); nachEnde(status, body) bei 404/410.
* gesperrt (Text) = Ansichtsmodus: Häkchen und Knopf gesperrt, Hinweis sichtbar.
*/
export function renderBestaetigung(auftrag, { senden, nachErfolg, nachEnde, gesperrt = '', anrede = ANREDE_SIE } = {}) {
const t = texte(anrede)
let busy = false
const haken1 = h('input', { type: 'checkbox', 'data-feld': 'bestaetigung', required: true, disabled: Boolean(gesperrt) })
const haken2 = zeigtFruehbeginn(auftrag) ? h('input', { type: 'checkbox', 'data-feld': 'fruehbeginn', disabled: Boolean(gesperrt) }) : null
const fehler = h('p', { class: 'ab-fehler', 'data-fehler': true, role: 'alert', hidden: true })
const knopf = h('button', { type: 'submit', class: 'ab-knopf', 'data-aktion': 'bestaetigen', disabled: true }, t.knopf)
const aktualisiere = () => { knopf.disabled = Boolean(gesperrt) || busy || !haken1.checked }
const form = h('form', {
class: 'ab-bestaetigen',
novalidate: true,
onsubmit: async (ev) => {
ev.preventDefault()
if (busy || gesperrt || !haken1.checked) return
busy = true
fehler.hidden = true
knopf.disabled = true
knopf.setAttribute('aria-busy', 'true')
knopf.textContent = t.sendet
let ergebnis
try {
ergebnis = await senden({ bestaetigt: true, fruehbeginn: Boolean(haken2?.checked) })
} catch {
ergebnis = { ok: false, status: 0, body: {} }
}
knopf.removeAttribute('aria-busy')
knopf.textContent = t.knopf
if (ergebnis?.ok) {
nachErfolg?.(ergebnis.auftrag)
return
}
const status = ergebnis?.status ?? 0
if (status === 404 || status === 410) {
nachEnde?.(status, ergebnis?.body)
return
}
busy = false
aktualisiere()
fehler.textContent = fehlerMeldung(status, anrede)
fehler.hidden = false
},
},
h('fieldset', { class: 'ab-feldgruppe' },
h('legend', null, t.formular),
h('label', { class: 'ab-haken' }, haken1, h('span', null, auftrag?.texte?.bestaetigung || BESTAETIGUNG_FALLBACK)),
haken2 ? h('label', { class: 'ab-haken' }, haken2, h('span', null, auftrag.texte.fruehbeginn, ' ', h('small', { class: 'ab-frei' }, t.freiwillig))) : null),
fehler,
knopf,
h('p', { class: 'ab-notiz' }, t.speicherHinweis),
gesperrt ? h('p', { class: 'ab-gesperrt', 'data-gesperrt': true }, gesperrt) : null)
haken1.addEventListener('change', aktualisiere)
aktualisiere()
return form
}
+84
View File
@@ -0,0 +1,84 @@
/* Abschnitt „Aufträge“ im Reiter Rechnungen des Kundenportals (geladen von app.js, loadCustomerOrders; request ist api aus app.js).
Kundentext kommt nur über textContent in die Seite. Alle Texte stehen in auftrag-ansicht.js; das Portal spricht in der Du-Form. */
import {
ANREDE_DU, agbPfad, darfBestaetigen, kartenMeta, sortiereAuftraege, statusAnzeige, texte,
} from './auftrag-ansicht.js'
import {
h, renderBestaetigtBlock, renderBestaetigung, renderDokumente, renderZusammenfassung,
} from './auftrag-dom.js'
export async function initAuftraege({ section, body, invoicesTitle, request, viewAs = false } = {}) {
if (!section || !body || typeof request !== 'function') return { sichtbar: false }
const t = texte(ANREDE_DU)
let eintraege = []
/** Bestätigen darf, wer der Server dafür freigibt; in der Kundenansicht zeigt die Oberfläche die offene AB mit gesperrtem Formular. */
const wirksam = (e) => darfBestaetigen(e) && (viewAs || e.kannBestaetigen === true)
async function ladeListe() {
const daten = await request('/api/auftraege')
if (daten?.aktiv !== true || !Array.isArray(daten.auftraege)) return []
return daten.auftraege.filter((e) => e && typeof e.id === 'string' && e.id !== '')
}
function karte(auftrag, offeneId) {
const kann = wirksam(auftrag)
const status = statusAnzeige({ ...auftrag, kannBestaetigen: kann })
const pfad = `/api/auftraege/${encodeURIComponent(auftrag.id)}`
async function senden(koerper) {
try {
const antwort = await request(`${pfad}/bestaetigen`, { method: 'POST', body: JSON.stringify(koerper) })
return antwort?.auftrag ? { ok: true, auftrag: antwort.auftrag } : { ok: false, status: 0, body: {} }
} catch (err) {
return { ok: false, status: Number(err?.status) || 0, body: { error: String(err?.message ?? '') } }
}
}
function nachErfolg(neu) {
eintraege = eintraege.map((alt) => (alt.id === auftrag.id ? { ...alt, ...neu, id: alt.id, kannBestaetigen: false } : alt))
zeichne(auftrag.id)
body.querySelector(`[data-auftrag="${CSS.escape(auftrag.id)}"] [data-bestaetigt]`)?.focus()
}
async function nachEnde() {
try {
eintraege = await ladeListe()
zeichne('')
} catch {
/* die Anzeige bleibt stehen */
}
}
return h('article', { class: 'ab-karte', 'data-auftrag': auftrag.id, 'data-status': auftrag.status || '' },
h('div', { class: 'ab-karte-kopf' },
h('div', null,
h('p', { class: 'ab-karte-nummer' }, h('strong', null, auftrag.nummer || '')),
auftrag.titel ? h('p', { class: 'ab-karte-titel' }, auftrag.titel) : null,
h('p', { class: 'ab-karte-meta' }, kartenMeta(auftrag))),
h('span', { class: `ab-pille ab-pille-${status.ton}` }, status.text)),
h('div', { class: 'ab-karte-aktionen' },
h('a', { class: 'ab-link', 'data-link': 'pdf', href: `${pfad}/pdf`, target: '_blank', rel: 'noopener noreferrer' }, t.kartePdf)),
h('details', { class: 'ab-details', open: kann || auftrag.id === offeneId },
h('summary', null, kann ? t.karteDetailsBestaetigen : t.karteDetails),
renderZusammenfassung(auftrag, { ebene: 4 }),
renderDokumente({ agbHref: agbPfad(auftrag.agbUrl) }),
renderBestaetigtBlock(auftrag, { anrede: ANREDE_DU }),
kann
? renderBestaetigung(auftrag, { senden, nachErfolg, nachEnde, gesperrt: viewAs ? t.ansichtsmodus : '', anrede: ANREDE_DU })
: null))
}
function zeichne(offeneId) {
body.replaceChildren(...sortiereAuftraege(eintraege.map((e) => ({ ...e, kannBestaetigen: wirksam(e) }))).map((e) => karte(eintraege.find((x) => x.id === e.id), offeneId)))
}
try {
eintraege = await ladeListe()
} catch {
return { sichtbar: false }
}
if (!eintraege.length) return { sichtbar: false }
zeichne('')
section.classList.remove('hidden')
invoicesTitle?.classList.remove('hidden')
return { sichtbar: true }
}
+56
View File
@@ -0,0 +1,56 @@
/* Widerrufsseite: Vertragsart „Projektauftrag“ mit freiwilliger Nummer der Auftragsbestätigung (Feld abNummer).
Rechtsfunktion: dieses Modul darf die anderen Vertragsarten nie stören. Fällt es aus, funktioniert der Widerruf wie bisher.
Das Feld ist nur bei „Projektauftrag“ sichtbar; eine eingegebene, aber unpassende Nummer wird gemeldet und stoppt das Absenden,
ein leeres Feld lässt den Widerruf durch (der Widerruf bleibt formfrei). Die Meldung steht in auftrag-ansicht.js. */
import { abNummerNormalisiert, texte } from './auftrag-ansicht.js'
export function initProjektWiderruf(form) {
const wrap = form?.querySelector('#ab-nummer-wrap')
const feld = form?.querySelector('#abNummer')
const fehler = form?.querySelector('#form-error')
if (!form || !wrap || !feld || !fehler) return
const gewaehlt = () => form.querySelector('[name="order"]:checked')?.value
let eigeneMeldung = false
const meldungWeg = () => {
if (!eigeneMeldung) return
eigeneMeldung = false
fehler.textContent = ''
fehler.classList.add('hidden')
}
const zeige = () => {
const projekt = gewaehlt() === 'projekt'
wrap.classList.toggle('hidden', !projekt)
if (!projekt) {
feld.value = ''
meldungWeg()
}
}
form.addEventListener('change', zeige)
form.addEventListener('click', (ev) => {
if (!ev.target?.closest?.('button[type="submit"], input[type="submit"]') || gewaehlt() !== 'projekt') return
const eingabe = feld.value.trim()
if (!eingabe) {
feld.value = ''
meldungWeg()
return
}
const nummer = abNummerNormalisiert(eingabe)
if (!nummer) {
ev.preventDefault()
fehler.textContent = texte().abNummerUngueltig
fehler.classList.remove('hidden')
eigeneMeldung = true
feld.focus()
return
}
feld.value = nummer
meldungWeg()
})
zeige()
}
if (typeof document !== 'undefined' && document.getElementById('decl-form') && document.getElementById('ab-nummer-wrap')) {
initProjektWiderruf(document.getElementById('decl-form'))
}
+161
View File
@@ -0,0 +1,161 @@
/* Bestätigungsseite für Auftragsbestätigungen (auftrag.html) und Abschnitt „Aufträge“ im Portal; Farben wie die Mailvorlagen.
Die Datei wird auch vom Dashboard geladen: jede Regel ist an body.ab-seite bzw. .ab-portal gebunden, Ausnahmen sind
.ab-rechnungen-titel und .inv-project am Ende. */
/* ------------------------------------------------------------------ Farben --- */
body.ab-seite {
--ab-grund: #f1f0ec; --ab-karte: #ffffff; --ab-text: #111111; --ab-weich: #4a4843; --ab-linie: #e8e6e0; --ab-box: #f7f6f3;
--ab-knopf: #111111; --ab-knopf-text: #ffffff; --ab-fokus: #5a2a91;
--ab-warn-bg: #efe6fb; --ab-warn-text: #5a2a91; --ab-ok-bg: #e4f2e6; --ab-ok-text: #1d5b2c;
--ab-rot-bg: #fbe4e1; --ab-rot-text: #8c1d18; --ab-muted-bg: #ecebe6; --ab-muted-text: #4a4843; --ab-fehler: #b3261e;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
body.ab-seite {
--ab-grund: #161615; --ab-karte: #1f1f1e; --ab-text: #f2f1ee; --ab-weich: #a8a6a0; --ab-linie: #3a3a37; --ab-box: #2a2a28;
--ab-knopf: #f2f1ee; --ab-knopf-text: #161615; --ab-fokus: #d9c2f5;
--ab-warn-bg: #2d2140; --ab-warn-text: #d9c2f5; --ab-ok-bg: #1e3322; --ab-ok-text: #a9dcb3;
--ab-rot-bg: #3d1f1c; --ab-rot-text: #f4b5ae; --ab-muted-bg: #2d2d2b; --ab-muted-text: #a8a6a0; --ab-fehler: #ff8a80;
}
}
/* ------------------------------------------------------------------ Grundlayout --- */
body.ab-seite {
margin: 0; min-height: 100dvh; display: flex; flex-direction: column;
background: var(--ab-grund); color: var(--ab-text);
font: 16px/1.55 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-text-size-adjust: 100%;
}
.ab-seite .hidden { display: none !important; }
.ab-seite .ab-innen { max-width: 40rem; margin: 0 auto; padding: 0 16px; box-sizing: border-box; width: 100%; }
.ab-seite .ab-kopf { padding: max(20px, env(safe-area-inset-top)) 0 12px; }
.ab-seite .ab-kopf .ab-innen { display: flex; align-items: center; gap: 10px; }
.ab-seite .ab-logo { width: 36px; height: 36px; border-radius: 10px; display: block; }
.ab-seite .ab-marke { font-weight: 800; letter-spacing: 0.08em; font-size: 15px; }
.ab-seite .ab-haupt { flex: 1 0 auto; padding-bottom: 24px; }
.ab-seite .ab-wurzel {
background: var(--ab-karte); border: 1px solid var(--ab-linie); border-radius: 16px; padding: 28px 24px;
overflow-wrap: anywhere; word-break: normal;
}
.ab-seite .ab-laden { margin: 0; color: var(--ab-weich); }
.ab-seite a { color: inherit; text-underline-offset: 2px; }
.ab-seite .ab-vh {
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.ab-seite .ab-fuss { flex-shrink: 0; padding: 8px 0 max(24px, env(safe-area-inset-bottom)); font-size: 13px; color: var(--ab-weich); }
.ab-seite .ab-fuss .ab-innen { display: flex; gap: 16px; flex-wrap: wrap; }
.ab-seite .ab-fuss a { color: var(--ab-weich); display: inline-flex; align-items: center; min-height: 44px; }
/* ------------------------------------------------------------------ Inhalt --- */
.ab-seite .ab-pille, .ab-portal .ab-pille {
display: inline-block; margin: 0 0 12px; padding: 4px 12px; border-radius: 999px; font-size: 12px; font-weight: 700; line-height: 1.4;
background: var(--ab-muted-bg); color: var(--ab-muted-text);
}
.ab-seite .ab-pille-warn, .ab-portal .ab-pille-warn { background: var(--ab-warn-bg); color: var(--ab-warn-text); }
.ab-seite .ab-pille-ok, .ab-portal .ab-pille-ok { background: var(--ab-ok-bg); color: var(--ab-ok-text); }
.ab-seite .ab-pille-rot, .ab-portal .ab-pille-rot { background: var(--ab-rot-bg); color: var(--ab-rot-text); }
.ab-seite .ab-h1 { font-size: 1.6rem; line-height: 1.25; margin: 0 0 12px; }
.ab-seite .ab-h1:focus { outline: none; }
.ab-seite .ab-intro, .ab-seite .ab-text { margin: 0 0 16px; }
.ab-seite .ab-text, .ab-portal .ab-text { white-space: pre-line; }
.ab-seite .ab-kontakt { margin: 0 0 16px; color: var(--ab-weich); }
.ab-seite .ab-h2, .ab-portal .ab-h2 {
font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 700; color: var(--ab-weich); margin: 24px 0 8px;
}
.ab-seite .ab-liste, .ab-portal .ab-liste { margin: 0 0 8px; padding: 0; }
.ab-seite .ab-zeile, .ab-portal .ab-zeile {
display: grid; grid-template-columns: 9rem 1fr; gap: 4px 12px; padding: 8px 0; border-top: 1px solid var(--ab-linie);
}
.ab-seite .ab-zeile dt, .ab-portal .ab-zeile dt { color: var(--ab-weich); }
.ab-seite .ab-zeile dd, .ab-portal .ab-zeile dd { margin: 0; }
.ab-seite .ab-gesamt, .ab-portal .ab-gesamt { display: flex; justify-content: space-between; gap: 12px; margin: 12px 0 8px; padding-top: 12px; border-top: 2px solid var(--ab-text); }
.ab-seite .ab-gesamt strong, .ab-portal .ab-gesamt strong { font-weight: 800; }
.ab-seite .ab-hinweis, .ab-portal .ab-hinweis { margin: 4px 0 0; font-size: 0.85rem; color: var(--ab-weich); }
.ab-seite .ab-links, .ab-portal .ab-links { list-style: none; margin: 0 0 16px; padding: 0; }
.ab-seite .ab-links a, .ab-portal .ab-links a { display: flex; align-items: center; min-height: 44px; line-height: 1.35; padding: 4px 0; font-weight: 600; }
.ab-seite .ab-bestaetigt, .ab-portal .ab-bestaetigt {
background: var(--ab-ok-bg); color: var(--ab-ok-text); border-radius: 12px; padding: 12px 16px; margin: 0 0 16px;
}
.ab-seite .ab-bestaetigt p, .ab-portal .ab-bestaetigt p { margin: 2px 0; }
.ab-seite .ab-bestaetigt:focus-visible, .ab-portal .ab-bestaetigt:focus-visible { outline: 3px solid var(--ab-fokus); outline-offset: 2px; }
/* ------------------------------------------------------------------ Tabelle --- */
.ab-seite .ab-tabelle, .ab-portal .ab-tabelle { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
.ab-seite .ab-tabelle th, .ab-portal .ab-tabelle th { text-align: left; font-weight: 600; color: var(--ab-weich); padding: 6px 8px 6px 0; border-bottom: 1px solid var(--ab-linie); }
.ab-seite .ab-tabelle td, .ab-portal .ab-tabelle td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--ab-linie); vertical-align: top; }
.ab-seite .ab-tabelle th:nth-child(n+2), .ab-seite .ab-tabelle td:nth-child(n+2),
.ab-portal .ab-tabelle th:nth-child(n+2), .ab-portal .ab-tabelle td:nth-child(n+2) { text-align: right; white-space: nowrap; }
@media (max-width: 640px) {
.ab-seite .ab-tabelle thead, .ab-portal .ab-tabelle thead {
position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
}
.ab-seite .ab-tabelle, .ab-seite .ab-tabelle tbody, .ab-seite .ab-tabelle tr, .ab-seite .ab-tabelle td,
.ab-portal .ab-tabelle, .ab-portal .ab-tabelle tbody, .ab-portal .ab-tabelle tr, .ab-portal .ab-tabelle td { display: block; }
.ab-seite .ab-tabelle tr, .ab-portal .ab-tabelle tr { padding: 8px 0; border-bottom: 1px solid var(--ab-linie); }
.ab-seite .ab-tabelle td, .ab-portal .ab-tabelle td {
display: flex; justify-content: space-between; gap: 12px; border: 0; padding: 2px 0; text-align: right; white-space: normal;
}
.ab-seite .ab-tabelle td:first-child, .ab-portal .ab-tabelle td:first-child { display: block; text-align: left; font-weight: 700; }
.ab-seite .ab-tabelle td:nth-child(n+2), .ab-portal .ab-tabelle td:nth-child(n+2) { white-space: normal; }
.ab-seite .ab-tabelle td:nth-child(n+2)::before, .ab-portal .ab-tabelle td:nth-child(n+2)::before { content: attr(data-label); color: var(--ab-weich); text-align: left; }
.ab-seite .ab-zeile, .ab-portal .ab-zeile { grid-template-columns: 1fr; gap: 0; }
.ab-seite .ab-wurzel { padding: 22px 18px; }
}
/* ------------------------------------------------------------------ Formular --- */
.ab-seite .ab-bestaetigen, .ab-portal .ab-bestaetigen { margin-top: 24px; padding-top: 8px; border-top: 1px solid var(--ab-linie); }
.ab-seite .ab-feldgruppe, .ab-portal .ab-feldgruppe { border: 0; margin: 0; padding: 0; min-width: 0; }
.ab-seite .ab-feldgruppe legend, .ab-portal .ab-feldgruppe legend { font-weight: 700; padding: 0; margin: 12px 0 4px; }
.ab-seite .ab-haken, .ab-portal .ab-haken { display: flex; gap: 12px; align-items: flex-start; padding: 10px 0; min-height: 44px; font-size: 15px; cursor: pointer; }
.ab-seite .ab-haken input, .ab-portal .ab-haken input { flex: 0 0 22px; width: 22px; height: 22px; margin: 1px 0 0; accent-color: var(--ab-knopf); }
.ab-seite .ab-frei, .ab-portal .ab-frei { color: var(--ab-weich); font-size: 0.85em; }
.ab-seite .ab-fehler, .ab-portal .ab-fehler { margin: 8px 0; color: var(--ab-fehler); font-weight: 700; }
.ab-seite .ab-fehler[hidden], .ab-portal .ab-fehler[hidden] { display: none; }
.ab-seite .ab-knopf, .ab-portal .ab-knopf {
display: inline-block; min-height: 48px; padding: 0 24px; border: 0; border-radius: 10px; cursor: pointer;
background: var(--ab-knopf); color: var(--ab-knopf-text); font: inherit; font-weight: 700;
}
.ab-seite .ab-knopf:disabled, .ab-portal .ab-knopf:disabled { opacity: 0.45; cursor: default; }
.ab-seite .ab-knopf:focus-visible, .ab-portal .ab-knopf:focus-visible,
.ab-seite .ab-link:focus-visible, .ab-portal .ab-link:focus-visible { outline: 3px solid var(--ab-fokus); outline-offset: 2px; }
.ab-seite .ab-notiz, .ab-portal .ab-notiz, .ab-seite .ab-gesperrt, .ab-portal .ab-gesperrt { margin: 12px 0 0; font-size: 0.85rem; color: var(--ab-weich); }
@media (max-width: 640px) {
.ab-seite .ab-knopf, .ab-portal .ab-knopf { width: 100%; }
}
/* ------------------------------------------------------------------ Portal (Reiter Rechnungen, dunkel) --- */
.ab-portal {
--ab-karte: var(--kb-surface); --ab-text: #ffffff; --ab-weich: var(--kb-muted); --ab-linie: var(--kb-border); --ab-box: rgba(255, 255, 255, 0.04);
--ab-knopf: var(--kb-purple); --ab-knopf-text: #ffffff; --ab-fokus: #d4b4ff;
--ab-warn-bg: rgba(157, 61, 254, 0.16); --ab-warn-text: #d8b4fe; --ab-ok-bg: rgba(74, 222, 128, 0.1); --ab-ok-text: #86efac;
--ab-rot-bg: rgba(248, 113, 113, 0.12); --ab-rot-text: #fca5a5; --ab-muted-bg: rgba(255, 255, 255, 0.06); --ab-muted-text: var(--kb-muted);
--ab-fehler: #fca5a5;
margin: 0 0 28px; color: var(--ab-text);
}
.ab-portal h2 { font-size: 20px; margin: 0 0 6px; }
.ab-portal .ab-karte {
margin: 12px 0 0; padding: 16px 18px; border-radius: 16px; background: var(--ab-karte); border: 1px solid var(--ab-linie); overflow-wrap: anywhere;
}
.ab-portal .ab-karte-kopf { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.ab-portal .ab-karte-kopf .ab-pille { margin: 0; flex: none; }
.ab-portal .ab-karte-kopf p { margin: 0; }
.ab-portal .ab-karte-titel { margin-top: 2px; }
.ab-portal .ab-karte-meta { margin-top: 2px; font-size: 13px; color: var(--ab-weich); }
.ab-portal .ab-karte-aktionen { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
.ab-portal .ab-karte-aktionen a {
display: inline-flex; align-items: center; min-height: 44px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--kb-border-strong);
color: var(--ab-text); text-decoration: none; font-weight: 600; font-size: 14px;
}
.ab-portal .ab-details > summary { cursor: pointer; display: flex; align-items: center; min-height: 44px; color: #d4b4ff; font-weight: 700; }
.ab-portal .ab-details[open] > summary { margin-bottom: 4px; }
.ab-portal .ab-knopf { border-radius: 999px; }
.ab-rechnungen-titel { font-size: 20px; margin: 0 0 10px; }
.inv-project { display: block; margin-top: 2px; font-size: 12.5px; font-weight: 400; color: var(--kb-muted, rgba(255, 255, 255, 0.6)); overflow-wrap: anywhere; }
/* ------------------------------------------------------------------ Installierte App --- */
.ab-app-back { display: none; }
@media (display-mode: standalone) {
.ab-app-back {
display: inline-block; margin: calc(env(safe-area-inset-top) + 12px) 16px 0; font-size: 15px; font-weight: 600; color: var(--ab-text); text-decoration: none;
}
.ab-seite .ab-kopf { padding-top: 12px; }
}
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#f1f0ec" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#161615" media="(prefers-color-scheme: dark)">
<title>Auftragsbestätigung – WEBklar</title>
<link rel="icon" href="/favicon.ico" sizes="32x32">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/auftrag.css">
<noscript><style>.ab-laden{display:none}</style></noscript>
</head>
<body class="ab-seite">
<!-- Bestätigungsseite für eine Auftragsbestätigung (Link aus der Mail K31: /auftrag.html#t=<Token>, Rückfall ?t=<Token>).
Der Token steht nur in der Adresse und im Speicher der Seite: keine Cookies, kein Browserspeicher, keine Fremdanfragen,
nichts im Protokoll (auftrag.js). Ohne Anmeldung erreichbar, nicht für Suchmaschinen. -->
<a class="ab-app-back" href="/dashboard.html">‹ Zurück zum Kundenbereich</a>
<header class="ab-kopf">
<div class="ab-innen"><img class="ab-logo" src="/webklar-logo.png" width="36" height="36" alt=""><span class="ab-marke">WEBklar</span></div>
</header>
<main class="ab-haupt">
<div class="ab-innen">
<div class="ab-wurzel" id="ab-wurzel" data-zustand="laedt" aria-busy="true">
<noscript><p>Bitte aktivieren Sie JavaScript, um Ihren Auftrag zu bestätigen.</p></noscript>
<p class="ab-laden">Ihre Auftragsbestätigung wird geladen …</p>
</div>
</div>
</main>
<div class="ab-vh" id="ab-ansage" aria-live="polite"></div>
<footer class="ab-fuss">
<div class="ab-innen">
<a href="/datenschutz.html" target="_blank" rel="noopener noreferrer">Datenschutz</a>
<a href="https://webklar.com/impressum" target="_blank" rel="noopener noreferrer">Impressum</a>
</div>
</footer>
<script type="module" src="/auftrag.js"></script>
</body>
</html>
+107
View File
@@ -0,0 +1,107 @@
/*! WEBklar Aufträge: öffentliche Bestätigungsseite (auftrag.html#t=<Token>, Rückfall ?t=). Link aus der Mail K31.
* Der Token steht nur in der Adresse, in Links und im Speicher der Seite, nie in Text, Ansage, Fehlermeldung oder Protokoll.
* Keine Cookies (credentials: 'omit'), kein Browserspeicher, keine Drittanfragen. Alle Texte stehen in auftrag-ansicht.js (Sie-Form)
* und kommen nur über textContent in die Seite. */
import {
KONTAKT, agbPfad, darfBestaetigen, pdfPfad, statusAnzeige, texte, tokenAusAdresse, zustandAusAntwort,
} from './auftrag-ansicht.js'
import { h, renderBestaetigtBlock, renderBestaetigung, renderDokumente, renderZusammenfassung } from './auftrag-dom.js'
/** Einzige Anfrage der Seite: { status, body }; jeder Fehler des Netzes ist Status 0 (nie die Meldung des Browsers weiterreichen). */
async function anfrage(token, aktion, { method = 'GET', body } = {}) {
try {
const antwort = await fetch(`/api/auftrag/${encodeURIComponent(token)}${aktion ? `/${aktion}` : ''}`, {
method,
credentials: 'omit',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
})
const daten = await antwort.json().catch(() => ({}))
return { status: antwort.status, body: daten !== null && typeof daten === 'object' ? daten : {} }
} catch {
return { status: 0, body: {} }
}
}
export async function initAuftragsseite({ root, ansage, suche = '', hash = '' }) {
const t = texte()
const token = tokenAusAdresse(suche, hash)
const sage = (text) => { ansage.textContent = text }
const zeige = (zustand, ...kinder) => {
root.dataset.zustand = zustand
root.setAttribute('aria-busy', zustand === 'laedt' ? 'true' : 'false')
root.replaceChildren(...kinder.filter(Boolean))
}
const kontakt = () => h('p', { class: 'ab-kontakt' },
t.kontaktVor, ' ',
h('a', { href: `mailto:${KONTAKT.email}` }, KONTAKT.email), ' oder ',
h('a', { href: `tel:${KONTAKT.telefon.replace(/\D/g, '')}` }, KONTAKT.telefon), '.')
function zeigeZustand({ zustand, titel, text }) {
zeige(zustand,
h('h1', { class: 'ab-h1', 'data-titel': true, tabindex: '-1' }, titel),
h('p', { class: 'ab-text' }, text),
zustand === 'limit' ? null : kontakt(),
zustand === 'limit' || zustand === 'fehler' ? h('button', { type: 'button', class: 'ab-knopf', 'data-aktion': 'erneut', onclick: () => lade() }, t.erneut) : null)
sage(titel)
}
async function senden(koerper) {
const { status, body } = await anfrage(token, 'bestaetigen', { method: 'POST', body: koerper })
if (status === 200 && body.auftrag) return { ok: true, auftrag: body.auftrag }
return { ok: false, status: status === 200 ? 0 : status, body }
}
function zeigeAuftrag(auftrag) {
const bestaetigt = Boolean(auftrag.bestaetigtAm)
const offen = darfBestaetigen(auftrag)
const status = statusAnzeige({ ...auftrag, kannBestaetigen: offen })
zeige(bestaetigt ? 'bestaetigt' : 'offen',
h('p', { class: `ab-pille ab-pille-${status.ton}` }, status.text),
h('h1', { class: 'ab-h1', 'data-titel': true, tabindex: '-1' }, bestaetigt ? t.titelBestaetigt : `${t.titelOffen} ${auftrag.nummer || ''}`.trim()),
bestaetigt ? null : h('p', { class: 'ab-intro' }, t.intro),
renderBestaetigtBlock(auftrag),
renderZusammenfassung(auftrag),
h('section', { class: 'ab-abschnitt' },
h('h2', { class: 'ab-h2' }, t.unterlagen),
renderDokumente({ pdfHref: pdfPfad(token), agbHref: agbPfad(auftrag.agbUrl) })),
offen
? renderBestaetigung(auftrag, {
senden,
nachErfolg: (neu) => {
zeigeAuftrag(neu)
sage(t.titelBestaetigt)
root.querySelector('[data-bestaetigt]')?.focus()
},
nachEnde: (code, antwortBody) => zeigeZustand(zustandAusAntwort(code, antwortBody)),
})
: null)
}
async function lade() {
if (!token) {
zeigeZustand(zustandAusAntwort(404, {}))
return
}
zeige('laedt', h('p', { class: 'ab-laden' }, t.laedt))
const { status, body } = await anfrage(token, '')
if (status === 200 && body.auftrag) {
zeigeAuftrag(body.auftrag)
sage(t.geladen)
} else {
zeigeZustand(zustandAusAntwort(status === 200 ? 0 : status, body))
}
}
await lade()
}
if (typeof document !== 'undefined' && document.getElementById('ab-wurzel')) {
initAuftragsseite({
root: document.getElementById('ab-wurzel'),
ansage: document.getElementById('ab-ansage'),
suche: window.location.search,
hash: window.location.hash,
})
}
+9
View File
@@ -27,6 +27,7 @@
<link rel="stylesheet" href="/shop.css">
<link rel="stylesheet" href="/buchungen.css">
<link rel="stylesheet" href="/zeiten.css">
<link rel="stylesheet" href="/auftrag.css">
</head>
<body class="portal-page dashboard-page kb-page">
<div class="dot-background" aria-hidden="true">
@@ -232,6 +233,14 @@
<h1>Rechnungen</h1>
<p>Deine Rechnungen ansehen, bezahlen und herunterladen.</p>
</header>
<!-- Aufträge: Auftragsbestätigungen ansehen und bestätigen (auftrag-portal.js, von app.js loadCustomerOrders); erscheint nur,
wenn Aufträge freigeschaltet sind und der Kunde welche hat -->
<section class="ab-portal hidden" id="orders-section" aria-labelledby="orders-title">
<h2 id="orders-title">Aufträge</h2>
<p class="kb-note">Deine Auftragsbestätigungen. Offene Aufträge bestätigst du hier mit einem Klick.</p>
<div id="orders-body"></div>
</section>
<h2 class="ab-rechnungen-titel hidden" id="invoices-title">Rechnungen</h2>
<div class="kb-panel" id="invoices-body"><p class="kb-empty">Lade…</p></div>
<p class="kb-note hidden" id="invoices-abo-note">Die Zahlungsart für dein Abo änderst du über dein <button type="button" class="kb-link" data-open-account>Profilbild</button> oben rechts unter „Abo verwalten &amp; Rechnungen“.</p>
</section>
+200
View File
@@ -0,0 +1,200 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="webklar-fassung" content="2026-09-30">
<title>Allgemeine Geschäftsbedingungen – WEBklar</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
main { max-width: 780px; margin: 0 auto; padding: 44px 20px 80px; }
.brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; }
h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; }
h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; }
h3 { font-size: 16.5px; margin: 22px 0 6px; }
p, li { margin: 6px 0; }
ul { padding-left: 24px; }
.meta { color: #6b6b70; font-size: 14px; }
.box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
.box.widerspruch { border: 2px solid #6d1fd1; }
table { border-collapse: collapse; width: 100%; font-size: 15px; }
td, th { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; }
th { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: #6b6b70; }
a { color: #6d1fd1; }
nav.top { font-size: 14px; margin-bottom: 22px; }
.offen { background: #fff3cd; padding: 0 4px; border-radius: 4px; }
@media print {
@page { size: A4; margin: 18mm 17mm 18mm;
@top-left { content: "WEBklar · AGB"; font: 8pt sans-serif; color: #888; }
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #888; } }
body { background: #fff; font-size: 10pt; line-height: 1.45; }
main { max-width: none; padding: 0; }
nav.top { display: none; }
h1 { font-size: 20pt; }
h2 { font-size: 13pt; margin-top: 16pt; break-after: avoid; }
a { color: inherit; text-decoration: none; }
.box, tr, ol.abs > li { break-inside: avoid; }
}
</style>
</head>
<body>
<main>
<nav class="top"><a href="/dashboard.html">← Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a> · <a href="/rechtliches/widerrufsbelehrung-projektauftrag-2026-10-06.html">Widerrufsbelehrung Projektaufträge</a> · <a href="/rechtliches/agb-2026-09-30.html">AGB</a> · <a href="/datenschutz.html">Datenschutzerklärung</a> · <a href="https://webklar.com/impressum">Impressum</a></nav>
<div class="brand">WEBKLAR</div>
<h1>Allgemeine Geschäftsbedingungen</h1>
<p class="meta"><strong>Fassung 2026-09-30</strong> · Wortgleiche Kopie der Allgemeinen Geschäftsbedingungen auf webklar.com/agb</p>
<h2>Kaufvertrag – WEBklar (Modularer Projektvertrag)</h2>
<p><strong>zwischen</strong></p>
<p>WEBklar (im Folgenden „Anbieter“)</p>
<p><strong>und</strong></p>
<p>Kunde laut Angebot (im Folgenden „Kunde“)</p>
<h2>1. Vertragsgegenstand</h2>
<p>(1) Gegenstand dieses Vertrages ist die Erbringung der im Angebot definierten Leistungen.</p>
<p>(2) Der Vertrag besteht aus diesem Grundvertrag sowie den ausgewählten Leistungsmodulen.</p>
<p>(3) Maßgeblich ist das jeweils angenommene Angebot von WEBklar.</p>
<h2>2. Leistungsart</h2>
<p>(1) Sämtliche Leistungen von WEBklar stellen Dienst- und Entwicklungsleistungen dar.</p>
<p>(2) Ein bestimmter wirtschaftlicher, technischer oder rechtlicher Erfolg wird nicht geschuldet.</p>
<p>(3) WEBklar erbringt keinen laufenden Betrieb, sofern dieser nicht explizit vereinbart wurde.</p>
<h2>3. Leistungsmodul A – Webseite (einmalig)</h2>
<p>(aktiv, wenn im Angebot enthalten)</p>
<p>(1) WEBklar erstellt eine individuelle Webseite gemäß Angebot.</p>
<p>(2) Die Umsetzung erfolgt nach den vom Kunden gelieferten Inhalten und Vorgaben.</p>
<p>(3) Zusätzliche Leistungen wie Domain, Hosting, Wartung oder SEO sind nicht Bestandteil, sofern sie nicht gesondert beauftragt wurden.</p>
<p>(4) Der Kunde ist nicht berechtigt, Änderungen am Quellcode selbst vorzunehmen.</p>
<p>(5) Änderungen erfolgen ausschließlich durch WEBklar gegen gesonderte Vergütung.</p>
<h2>4. Leistungsmodul B – Automatisierung / Virtualisierung (einmalig)</h2>
<p>(aktiv, wenn im Angebot enthalten)</p>
<p>(1) WEBklar entwickelt individuelle Automatisierungen, Apps oder Virtualisierungssysteme.</p>
<p>(2) Die Leistung stellt eine reine Entwicklungsleistung dar.</p>
<p>(3) Optional kann eine Beratungsleistung Bestandteil des Projektes sein.</p>
<p>(4) Ein laufender Betrieb, Monitoring oder Wartung ist nicht geschuldet, außer dies wurde explizit vereinbart.</p>
<p>(5) Der Kunde entscheidet über Inhalte, Daten und Prozesse und trägt dafür die rechtliche Verantwortung.</p>
<h2>5. Leistungsmodul C – Hosting</h2>
<p>(aktiv, wenn im Angebot enthalten)</p>
<p>(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.</p>
<p>(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.</p>
<p>(3) WEBklar ist berechtigt, externe Anbieter (z. B. Rechenzentren) einzusetzen.</p>
<h2>6. Quellcode und Eigentum</h2>
<h3>6.1 Webseite</h3>
<p>(1) Der Kunde erhält ein einfaches, zeitlich unbegrenztes Nutzungsrecht an der fertigen Webseite.</p>
<p>(2) Der Quellcode der Webseite wird nur auf ausdrückliche Anfrage und nach Vereinbarung herausgegeben.</p>
<p>(3) Ohne Vereinbarung verbleibt der Quellcode bei WEBklar und wird archiviert.</p>
<h3>6.2 Apps, Automatisierungen und Backend</h3>
<p>(1) Der Quellcode von Apps, Automatisierungen und Backend-Systemen verbleibt vollständig bei WEBklar.</p>
<p>(2) Eine Herausgabe erfolgt ausschließlich nach gesonderter schriftlicher Vereinbarung.</p>
<p>(3) Der Kunde erhält lediglich Zugriff auf die Bedienoberfläche bzw. das Frontend.</p>
<h2>7. Mitwirkungspflichten des Kunden</h2>
<p>(1) Der Kunde stellt alle benötigten Inhalte, Daten und Freigaben rechtzeitig bereit.</p>
<p>(2) Verzögerungen durch fehlende Mitwirkung gehen nicht zu Lasten von WEBklar.</p>
<p>(3) WEBklar ist nicht verpflichtet, rechtliche Prüfungen der Inhalte vorzunehmen.</p>
<h2>8. Abnahme</h2>
<p>(1) Nach Fertigstellung wird dem Kunden die Leistung zur Abnahme bereitgestellt.</p>
<p>(2) Erfolgt innerhalb von 14 Tagen keine Rückmeldung, gilt die Leistung als abgenommen.</p>
<p>(3) Nach Abnahme sind nur noch kostenpflichtige Änderungen möglich.</p>
<h2>9. Vergütung und Zahlung</h2>
<p>(1) Die Vergütung ergibt sich aus dem Angebot.</p>
<p>(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.</p>
<p>(3) Projektleistungen sind nach Vereinbarung fällig.</p>
<p>(4) Für die Abos gelten die Vertragsbedingungen für die WEBklar-Abos.</p>
<h2>10. Zahlungsverzug</h2>
<p>(1) Bei Zahlungsverzug erfolgen bis zu zwei Mahnungen.</p>
<p>(2) Danach ist WEBklar berechtigt:</p>
<ul>
<li>Leistungen zu sperren</li>
<li>Verzugszinsen zu berechnen</li>
<li>den Vertrag außerordentlich zu kündigen</li>
</ul>
<h2>11. Haftung</h2>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<p>(4) Diese Regeln gelten auch zugunsten der Mitarbeiter und Beauftragten von WEBklar.</p>
<h2>12. Kündigung</h2>
<p>(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.</p>
<p>(2) Das Recht zur außerordentlichen Kündigung bleibt unberührt.</p>
<h2>13. Referenzen</h2>
<p>WEBklar darf das Projekt nur nach ausdrücklicher Zustimmung des Kunden als Referenz verwenden.</p>
<h2>14. Schlussbestimmungen</h2>
<p>(1) Es gilt deutsches Recht.</p>
<p>(2) Gerichtsstand ist der Sitz von WEBklar, soweit zulässig.</p>
<p>(3) Sollten einzelne Bestimmungen unwirksam sein, bleibt der Vertrag im Übrigen wirksam.</p>
</main>
</body>
</html>
Binary file not shown.
@@ -0,0 +1 @@
d271a7ed066ee758dd5dd85b865134a4f9f65e1bc2ee375af7ab959b6455d2de
@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="webklar-fassung" content="2026-10-06">
<title>Widerrufsbelehrung und Muster-Widerrufsformular (Projektaufträge) – WEBklar</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
main { max-width: 780px; margin: 0 auto; padding: 44px 20px 80px; }
.brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; }
h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; }
h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; }
h3 { font-size: 16.5px; margin: 22px 0 6px; }
p, li { margin: 6px 0; }
ul { padding-left: 24px; }
.meta { color: #6b6b70; font-size: 14px; }
.box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
.box.widerspruch { border: 2px solid #6d1fd1; }
table { border-collapse: collapse; width: 100%; font-size: 15px; }
td, th { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; }
th { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: #6b6b70; }
a { color: #6d1fd1; }
nav.top { font-size: 14px; margin-bottom: 22px; }
.offen { background: #fff3cd; padding: 0 4px; border-radius: 4px; }
@media print {
@page { size: A4; margin: 18mm 17mm 18mm;
@top-left { content: "WEBklar · Widerrufsbelehrung Projektaufträge"; font: 8pt sans-serif; color: #888; }
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #888; } }
body { background: #fff; font-size: 10pt; line-height: 1.45; }
main { max-width: none; padding: 0; }
nav.top { display: none; }
h1 { font-size: 20pt; }
h2 { font-size: 13pt; margin-top: 16pt; break-after: avoid; }
a { color: inherit; text-decoration: none; }
.box, tr, ol.abs > li { break-inside: avoid; }
}
</style>
</head>
<body>
<main>
<nav class="top"><a href="/dashboard.html">← Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a> · <a href="/rechtliches/widerrufsbelehrung-projektauftrag-2026-10-06.html">Widerrufsbelehrung Projektaufträge</a> · <a href="/rechtliches/agb-2026-09-30.html">AGB</a> · <a href="/datenschutz.html">Datenschutzerklärung</a> · <a href="https://webklar.com/impressum">Impressum</a></nav>
<div class="brand">WEBKLAR</div>
<h1>Widerrufsbelehrung und Muster-Widerrufsformular (Projektaufträge)</h1>
<p class="meta"><strong>Fassung 2026-10-06</strong></p>
<p>Das Widerrufsrecht gilt nur für Verbraucher. Wir fügen die Belehrung und das Muster-Widerrufsformular jeder Auftragsbestätigung an eine Privatperson bei und zeigen sie hier zusätzlich allen Kunden. Die Belehrung gilt für den Auftrag, den die jeweilige Auftragsbestätigung bestätigt.</p>
<h2>Widerrufsbelehrung</h2>
<h3>Widerrufsrecht</h3>
<p>Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.</p>
<p>Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.</p>
<p>Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, Telefon: 0176 23726355, E-Mail: support@webklar.com) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.</p>
<p>Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.</p>
<p>Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.</p>
<h3>Folgen des Widerrufs</h3>
<p>Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.</p>
<p>Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht.</p>
<h2>Muster-Widerrufsformular</h2>
<p>(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)</p>
<p>– An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:</p>
<p>– Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)</p>
<p>– Bestellt am (*)/erhalten am (*)</p>
<p>– Name des/der Verbraucher(s)</p>
<p>– Anschrift des/der Verbraucher(s)</p>
<p>– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)</p>
<p>– Datum</p>
<p>(*) Unzutreffendes streichen.</p>
</main>
</body>
</html>
@@ -0,0 +1 @@
f9948c0a656e5eaf62c964a933d4fda4b162cf1cdc4069632e1068c6488b8408
+9 -1
View File
@@ -24,8 +24,15 @@
<label class="radio"><input type="radio" name="order" value="termine" data-text="Terminplaner (Einrichtung)"> Terminplaner (Einrichtung)</label>
<label class="radio"><input type="radio" name="order" value="shop" data-text="Webshop (Einrichtung)"> Webshop (Einrichtung)</label>
<label class="radio"><input type="radio" name="order" value="zeiten" data-text="Öffnungszeiten live"> Öffnungszeiten live</label>
<label class="radio"><input type="radio" name="order" value="projekt" data-text="Projektauftrag (Auftragsbestätigung)"> Projektauftrag (Auftragsbestätigung)</label>
</fieldset>
<!-- Nur bei „Projektauftrag“ und freiwillig (auftrag-widerruf.js blendet das Feld sonst aus und leert es); ohne das Skript bleibt es sichtbar -->
<div id="ab-nummer-wrap">
<label for="abNummer" data-label-for="abNummer" data-label="Nummer der Auftragsbestätigung">Nummer der Auftragsbestätigung <small>(falls zur Hand, zum Beispiel AB-2026-0001)</small></label>
<input id="abNummer" name="abNummer" maxlength="20" autocomplete="off" autocapitalize="characters" spellcheck="false" placeholder="AB-2026-0001">
</div>
<label for="name" data-label-for="name" data-label="Name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="200">
@@ -49,7 +56,8 @@
</main>
<script src="/legal-form.js"></script>
<script>
initLegalForm({ endpoint: '/api/abo/public/withdraw', fields: ['order', 'name', 'customerNumber', 'email'] })
initLegalForm({ endpoint: '/api/abo/public/withdraw', fields: ['order', 'abNummer', 'name', 'customerNumber', 'email'] })
</script>
<script type="module" src="/auftrag-widerruf.js"></script>
</body>
</html>
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env node
/**
* Aufträge und Rechnungen: legt die Collections auftraege und projektRechnungen mit Attributen und Indizes in
* Appwrite an (nur Server-Key, keine Client-Rechte) und ergänzt die vorhandenen Collections customers (Feld
* kundenart) und financeSettings (Feld steuernummer). Das Schema steht in server/services/auftraege/schema.js.
* Idempotent: Vorhandenes wird übersprungen, fehlende Attribute und Indizes auch in schon vorhandenen Collections
* nachgezogen. customers (gehört dem Ticketsystem) und financeSettings (scripts/setup-finanzen.mjs) legt dieses
* Skript nie an; fehlen sie, bricht es vor jeder Änderung ab. Vor dem Code-Deploy und vor dem Einschalten
* (AUFTRAEGE_ENABLED) ausführen: der Code schreibt die neuen Felder, ohne Schema antwortet Appwrite mit
* "Unknown attribute".
* Usage:
* docker exec -w /app webklar-kundenbereich-server node scripts/auftraege-setup.mjs
* node scripts/auftraege-setup.mjs --check (nur melden, nichts anlegen; Exit 1 bei Fehlendem)
* node scripts/auftraege-setup.mjs (Fehlendes anlegen)
*/
import { pathToFileURL } from 'node:url'
import { config } from '../server/config.js'
import { AUFTRAEGE_COLLECTIONS, AUFTRAEGE_ZUSATZ_ATTRIBUTE } from '../server/services/auftraege/schema.js'
import { createAppwriteClient } from './analyse-setup.mjs'
// Hinweis, falls eine nicht eigene Collection fehlt
const HINWEIS = {
financeSettings: 'zuerst scripts/setup-finanzen.mjs ausfuehren',
customers: 'die Kunden-Collection gehört dem Ticketsystem (WOMS)',
}
export async function main({ check = false, client, log = console.log } = {}) {
const api = client || createAppwriteClient()
const missing = []
const missingCollections = []
const missingIndexes = []
const plan = []
// Eigene Collections plus die vorhandenen customers und financeSettings (nur zusätzliche Attribute, keine Indizes)
const targets = [
...Object.entries(AUFTRAEGE_COLLECTIONS).map(([name, { attributes, indexes }]) => ({ name, id: config.collections[name], attributes, indexes, own: true })),
...Object.entries(AUFTRAEGE_ZUSATZ_ATTRIBUTE).map(([name, attributes]) => ({ name, id: config.collections[name], attributes, indexes: [], own: false })),
]
// Erst alles pruefen, dann (ohne --check) anlegen, damit --check garantiert nichts veraendert
for (const { name, id, attributes, indexes, own } of targets) {
const exists = await api.collectionExists(id)
if (!exists && !own) throw new Error(`Collection ${id} fehlt – ${HINWEIS[name]}`)
const haveAttributes = exists ? new Set((await api.listAttributes(id)).map((a) => a.key)) : new Set()
const haveIndexes = exists ? new Set((await api.listIndexes(id)).map((i) => i.key)) : new Set()
const absentAttributes = attributes.filter((a) => !haveAttributes.has(a.key))
const absentIndexes = indexes.filter((i) => !haveIndexes.has(i.key))
if (!exists) {
log(`${check ? '-' : '+'} Collection ${id}${check ? ' FEHLT' : ''}`)
missingCollections.push(id)
} else {
log(`= ${id}: ${haveAttributes.size} Attribute, ${haveIndexes.size} Indizes vorhanden`)
}
if (check) {
absentAttributes.forEach((a) => log(`- ${id}.${a.key} FEHLT`))
absentIndexes.forEach((i) => log(`- Index ${id}.${i.key} FEHLT`))
}
absentAttributes.forEach((a) => missing.push({ ...a, collection: id }))
absentIndexes.forEach((i) => missingIndexes.push({ ...i, collection: id }))
plan.push({ id, exists, absentAttributes, absentIndexes })
}
const result = { missing, missingCollections, missingIndexes }
if (check) {
const open = missing.length + missingCollections.length + missingIndexes.length
log(open ? `${open} Eintrag/Eintraege fehlen.` : 'Alles vorhanden.')
return result
}
for (const { id, exists, absentAttributes } of plan) {
if (!exists) await api.ensureCollection(id, id)
for (const attr of absentAttributes) {
await api.createAttribute(id, attr)
log(`+ Attribut ${id}.${attr.key}`)
}
}
// Indizes brauchen verfuegbare Attribute: erst warten, dann anlegen
for (const { id, absentAttributes } of plan) {
for (const attr of absentAttributes) await api.waitForAttribute(id, attr.key)
}
for (const { id, absentIndexes } of plan) {
for (const index of absentIndexes) {
await api.createIndex(id, index)
log(`+ Index ${id}.${index.key}`)
}
}
log('Fertig.')
return result
}
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
if (invokedDirectly) {
const check = process.argv.includes('--check')
main({ check })
.then(({ missing, missingCollections, missingIndexes }) =>
process.exit(check && (missing.length || missingCollections.length || missingIndexes.length) ? 1 : 0))
.catch((e) => {
console.error(e.message)
process.exit(1)
})
}
+70 -5
View File
@@ -14,7 +14,11 @@
// Achtung: Ändert sich die Widerrufsbelehrung der Funktionen (neue Fassung bei termine/shop), braucht auch unterkunft
// eine neue Fassung (config.unterkunft.fassung); eine ausgelieferte Datei wird nie an Ort und Stelle neu gebaut.
//
// Fassung = config.termine.avvVersion (Satz termine) bzw. config.shop.fassung (Satz shop) bzw. config.unterkunft.fassung (Satz unterkunft). Die Ausgabe enthält keine Datumsangabe außer der Fassung und hängt nur vom Markdown und
// node scripts/build-legal-html.mjs auftraege [--check]
// dasselbe für die Widerrufsbelehrung der Projektaufträge (Wortlaut aus server/services/auftraege/texte.js)
// und die AGB-Kopie (Quelle docs/rechtliches/agb.md)
//
// Fassung = config.termine.avvVersion (Satz termine), config.shop.fassung (Satz shop) bzw. config.unterkunft.fassung (Satz unterkunft) bzw. TEXTE_FASSUNG und AGB_FASSUNG (Satz auftraege). Die Ausgabe enthält keine Datumsangabe außer der Fassung und hängt nur vom Markdown und
// von der Fassung ab (zweimal erzeugen ergibt dieselben Bytes). Unterstützte Teilmenge von Markdown: Überschriften # bis ###
// (# nur als erste Zeile), Absätze, Listen mit "- ", **fett**, `code` und Tabellen (Kopfzeile, Trennzeile |---|---|, Zeilen).
// Jede andere Syntax (Codeblöcke, Zitate, Bilder, Links, rohes HTML, nummerierte Listen, Einrückungen, Trennlinien) bricht laut ab,
@@ -24,6 +28,8 @@ import { basename, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { config } from '../server/config.js'
import { legalPaths } from '../server/services/legalDocs.js'
import { TEXTE_FASSUNG, WIDERRUFSBELEHRUNG_PROJEKT, WIDERRUFSFORMULAR } from '../server/services/auftraege/texte.js'
import { AGB_FASSUNG } from '../server/services/auftraege/konstanten.js'
const ROOT = fileURLToPath(new URL('..', import.meta.url))
const FASSUNG_RE = /^\d{4}-\d{2}-\d{2}$/
@@ -333,7 +339,6 @@ export function unterkunftDokumente(fassung = config.unterkunft.fassung) {
}
/** Sätze der Kommandozeile: Name => Dokumente. */
const SAETZE = { termine: termineDokumente, shop: shopDokumente, unterkunft: unterkunftDokumente }
/** Markdown eines Dokuments; trägt die Quelle eine andere Fassung (d.stempel), wird die Fassungszeile auf die des Dokuments gesetzt. */
export function quelleVon(d, root = ROOT) {
@@ -343,23 +348,83 @@ export function quelleVon(d, root = ROOT) {
if (!md.includes(alt)) throw new MarkdownFehler(3, `${d.md}: die Zeile ${alt} fehlt`)
return md.replace(alt, `**Fassung ${d.meta.fassung}**`)
}
// ---------------------------------------------------------------------------------------------------------- Projektaufträge
/** Einleitung der Seite zur Widerrufsbelehrung der Projektaufträge (Seitentext, nicht Teil der Belehrung nach dem Muster). */
export const PROJEKT_WIDERRUF_EINLEITUNG =
'Das Widerrufsrecht gilt nur für Verbraucher. Wir fügen die Belehrung und das Muster-Widerrufsformular jeder Auftragsbestätigung an eine Privatperson bei '
+ 'und zeigen sie hier zusätzlich allen Kunden. Die Belehrung gilt für den Auftrag, den die jeweilige Auftragsbestätigung bestätigt.'
/**
* Markdown der Seite „Widerrufsbelehrung und Muster-Widerrufsformular (Projektaufträge)“ aus server/services/auftraege/texte.js: Überschrift,
* Fassung, Einleitung, dann die Abschnitte der Belehrung und das Formular, jede Formularzeile als eigener Absatz.
*/
export function projektWiderrufMarkdown(fassung = TEXTE_FASSUNG, belehrung = WIDERRUFSBELEHRUNG_PROJEKT, formular = WIDERRUFSFORMULAR) {
const teile = [
'# Widerrufsbelehrung und Muster-Widerrufsformular (Projektaufträge)',
`**Fassung ${fassung}**`,
PROJEKT_WIDERRUF_EINLEITUNG,
`## ${belehrung.titel}`,
...belehrung.abschnitte.flatMap((a) => [`### ${a.ueberschrift}`, ...a.absaetze]),
`## ${formular.titel}`,
formular.einleitung,
...formular.zeilen,
]
return `${teile.join('\n\n')}\n`
}
/**
* Die zwei Seiten des Satzes auftraege: die Widerrufsbelehrung der Projektaufträge (Fassung TEXTE_FASSUNG, Wortlaut aus texte.js, ohne Markdown-Datei)
* und die AGB-Kopie (Fassung AGB_FASSUNG, Quelle docs/rechtliches/agb.md). Beide Seiten verlinken einander. Die Adressen stehen nicht in legalDocs.js:
* die Mails und das PDF der Auftragsbestätigung verlinken die versionierten Dateien (public/rechtliches/<slug>-<Fassung>.html|pdf) unmittelbar.
*/
export function auftraegeDokumente(texteFassung = TEXTE_FASSUNG, agbFassung = AGB_FASSUNG) {
const widerrufAdresse = `/rechtliches/widerrufsbelehrung-projektauftrag-${texteFassung}.html`
const agbAdresse = `/rechtliches/agb-${agbFassung}.html`
const nav = [
{ href: '/dashboard.html', text: '← Kundenportal' },
{ href: '/abo-bedingungen.html', text: 'Abo-Bedingungen' },
{ href: widerrufAdresse, text: 'Widerrufsbelehrung Projektaufträge' },
{ href: agbAdresse, text: 'AGB' },
{ href: '/datenschutz.html', text: 'Datenschutzerklärung' },
{ href: 'https://webklar.com/impressum', text: 'Impressum' },
]
return [
{
doc: 'widerruf',
md: null,
markdown: () => projektWiderrufMarkdown(texteFassung),
html: `public${widerrufAdresse}`,
meta: { fassung: texteFassung, titel: 'Widerrufsbelehrung und Muster-Widerrufsformular (Projektaufträge)', seitenkopf: 'Widerrufsbelehrung Projektaufträge', nav },
},
{
doc: 'agb',
md: 'docs/rechtliches/agb.md',
html: `public${agbAdresse}`,
meta: { fassung: agbFassung, titel: 'Allgemeine Geschäftsbedingungen', seitenkopf: 'AGB', nav },
},
]
}
/** Sätze der Kommandozeile: Name => Dokumente. */
const SAETZE = { termine: termineDokumente, shop: shopDokumente, unterkunft: unterkunftDokumente, auftraege: auftraegeDokumente }
// ---------------------------------------------------------------------------------------------------------- Aufruf
/**
* Kommandozeile: `termine|shop|unterkunft [--check]`. root = Wurzel des Projekts (Tests übergeben ein Verzeichnis mit Kopien).
* Kommandozeile: `termine|shop|unterkunft|auftraege [--check]`. root = Wurzel des Projekts (Tests übergeben ein Verzeichnis mit Kopien).
* => Exit-Code: 0 ok, 1 mindestens eine Seite weicht ab (nur --check), 2 falscher Aufruf
*/
export function lauf(argv, { root = ROOT, log = console.log, fehler = console.error } = {}) {
const [satz, ...optionen] = argv
const pruefen = optionen.includes('--check')
if (!Object.hasOwn(SAETZE, satz ?? '') || optionen.some((o) => o !== '--check')) {
fehler('Aufruf: node scripts/build-legal-html.mjs termine|shop|unterkunft [--check]')
fehler('Aufruf: node scripts/build-legal-html.mjs termine|shop|unterkunft [--check]\n node scripts/build-legal-html.mjs auftraege [--check]')
return 2
}
let abweichungen = 0
for (const d of SAETZE[satz]()) {
const html = render(quelleVon(d, root), d.meta)
const html = render(d.markdown ? d.markdown() : quelleVon(d, root), d.meta)
const ziel = join(root, d.html)
if (pruefen) {
const gleich = existsSync(ziel) && readFileSync(ziel, 'utf8') === html
+50 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Erzeugt die Druck-/PDF-Fassungen der Vertragsunterlagen aus den HTML-Quellen.
# Laeuft auf dem Host (braucht Chrome/Chromium), die PDFs werden eingecheckt.
# Usage: CHROME=/pfad/zu/chrome scripts/build-legal-pdfs.sh [all|analyse|termine|shop|unterkunft|datenschutz]
# Usage: CHROME=/pfad/zu/chrome scripts/build-legal-pdfs.sh [all|analyse|termine|shop|unterkunft|auftraege|datenschutz]
# analyse = nur AVV und Leistungsbeschreibung der Website-Analyse (Fassung ANALYSE_VERSION), andere PDFs bleiben unveraendert
# termine = Leistungsbeschreibung, AVV und Widerrufsbelehrung des Terminplaners (Fassung TERMINE_VERSION, Standard: config.termine.avvVersion);
# die HTML-Seiten kommen vorher aus `node scripts/build-legal-html.mjs termine`. Neben jedem PDF liegt <PDF>.sha256 mit der
@@ -12,6 +12,9 @@
# gehoert zu termine (dieselbe Datei) und wird hier nicht gedruckt. `all` druckt auch diese beiden PDFs.
# unterkunft = Leistungsbeschreibung, AVV und Widerrufsbelehrung des Buchungskalenders (Fassung UNTERKUNFT_VERSION, Standard: config.unterkunft.fassung); die HTML-Seiten
# kommen vorher aus `node scripts/build-legal-html.mjs unterkunft`, Pruefsumme und Neudruck wie bei termine. `all` druckt diese PDFs nicht mit.
# auftraege = Widerrufsbelehrung der Projektauftraege (Fassung TEXTE_VERSION, Standard: TEXTE_FASSUNG aus server/services/auftraege/texte.js) und AGB
# (Fassung AGB_VERSION, Standard: AGB_FASSUNG aus server/services/auftraege/konstanten.js); die HTML-Seiten kommen vorher aus
# `node scripts/build-legal-html.mjs auftraege`, Pruefsumme und Neudruck wie bei termine und shop. `all` druckt auch diese beiden PDFs.
# datenschutz = nur die Datenschutzerklaerung (docs/WEBklar-Datenschutzerklaerung.pdf); daneben liegt <PDF>.sha256 mit der SHA-256 von
# public/datenschutz.html (test/legalTexts.test.js prueft sie); ein PDF mit passender Pruefsumme wird nicht neu gedruckt (FORCE=1 druckt neu)
# Vorher `free -m` pruefen und mit `nice` aufrufen; jeder Chrome-Aufruf laeuft hoechstens 120 Sekunden (timeout), die PDFs nacheinander.
@@ -59,6 +62,47 @@ unterkunft_version() {
echo "$v"
}
# Fassung der Projektauftrags-Texte (Widerrufsbelehrung): TEXTE_VERSION, sonst TEXTE_FASSUNG aus server/services/auftraege/texte.js
texte_version() {
local v="${TEXTE_VERSION:-}"
if [ -z "$v" ]; then
v="$(timeout 60 node --max-old-space-size=256 --input-type=module -e "const { TEXTE_FASSUNG } = await import('./server/services/auftraege/texte.js'); console.log(TEXTE_FASSUNG)")"
fi
[[ "$v" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || { echo "Ungueltige Fassung: $v" >&2; exit 1; }
echo "$v"
}
# Fassung der AGB-Kopie: AGB_VERSION, sonst AGB_FASSUNG aus server/services/auftraege/konstanten.js
agb_version() {
local v="${AGB_VERSION:-}"
if [ -z "$v" ]; then
v="$(timeout 60 node --max-old-space-size=256 --input-type=module -e "const { AGB_FASSUNG } = await import('./server/services/auftraege/konstanten.js'); console.log(AGB_FASSUNG)")"
fi
[[ "$v" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || { echo "Ungueltige Fassung: $v" >&2; exit 1; }
echo "$v"
}
# Widerrufsbelehrung der Projektauftraege und AGB drucken (je PDF nur, wenn die Pruefsumme der HTML-Seite nicht mehr passt)
auftraege_pdfs() {
local texte agb eintrag slug fassung quelle ziel summe
texte="$(texte_version)"
agb="$(agb_version)"
for eintrag in "widerrufsbelehrung-projektauftrag:$texte" "agb:$agb"; do
slug="${eintrag%%:*}"
fassung="${eintrag##*:}"
quelle="public/rechtliches/$slug-$fassung.html"
ziel="public/rechtliches/$slug-$fassung.pdf"
[ -f "$quelle" ] || { echo "HTML fehlt: $quelle (zuerst node scripts/build-legal-html.mjs auftraege)"; exit 1; }
summe="$(sha256sum "$quelle" | cut -d' ' -f1)"
if [ -z "${FORCE:-}" ] && [ -f "$ziel" ] && [ "$(cat "$ziel.sha256" 2>/dev/null)" = "$summe" ]; then
echo "= $ziel (unveraendert)"
else
pdf "$quelle" "$ziel"
echo "$summe" > "$ziel.sha256"
fi
done
}
# Leistungsbeschreibung und AVV des Webshops drucken (je PDF nur, wenn die Pruefsumme der HTML-Seite nicht mehr passt)
shop_pdfs() {
local fassung slug quelle ziel summe
@@ -115,6 +159,9 @@ unterkunft)
fi
done
;;
auftraege)
auftraege_pdfs
;;
datenschutz)
quelle="public/datenschutz.html"
ziel="docs/WEBklar-Datenschutzerklaerung.pdf"
@@ -132,8 +179,9 @@ all)
pdf public/abo-bedingungen.html public/WEBklar-Abo-Vertragsunterlagen.pdf
pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf
shop_pdfs
auftraege_pdfs
;;
*)
echo "Usage: $0 [all|analyse|termine|shop|unterkunft|datenschutz]"; exit 1
echo "Usage: $0 [all|analyse|termine|shop|unterkunft|datenschutz]"; echo " $0 auftraege (Widerrufsbelehrung der Projektauftraege und AGB)"; exit 1
;;
esac
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Figtree Project Authors (https://github.com/erikdkennedy/figtree)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
Figtree, statische TrueType-Schnitte Regular (400) und SemiBold (600) für das PDF der Auftragsbestätigung.
Lizenz: SIL Open Font License 1.1, Text in Figtree-OFL.txt.
Quelle: npm-Paket @expo-google-fonts/figtree@0.4.1 (400Regular/Figtree_400Regular.ttf, 600SemiBold/Figtree_600SemiBold.ttf, LICENSE_FONT als Figtree-OFL.txt), Stand 2026-10-06.
SHA-256 Figtree-Regular.ttf: 4e2e9876c4a908c2cbe410ceb648e4724464a84492bd49cb2d6e7b0d3f769a66
SHA-256 Figtree-SemiBold.ttf: 1b76aae525b51758fc0c9c5096f33d90831657d3ad2fe3c34fb7e666d76f8960
+14
View File
@@ -33,6 +33,7 @@ const termineEnabled = (process.env.TERMINE_ENABLED || '').trim().toLowerCase()
const shopEnabled = (process.env.SHOP_ENABLED || '').trim().toLowerCase()
const unterkunftEnabled = (process.env.UNTERKUNFT_ENABLED || '').trim().toLowerCase()
const zeitenEnabled = (process.env.ZEITEN_ENABLED || '').trim().toLowerCase()
const auftraegeEnabled = (process.env.AUFTRAEGE_ENABLED || '').trim().toLowerCase()
/** Komma-Liste aus einer Umgebungsvariablen: getrimmt, klein geschrieben, ohne Leereintraege; leer ergibt den Vorgabewert. */
function commaList(raw, fallback) {
@@ -782,6 +783,9 @@ export const config = {
// Oeffnungszeiten live (Einstellungen, Erinnerungsmerker; scripts/zeiten-setup.mjs)
oeffnungszeiten: process.env.APPWRITE_COLLECTION_OEFFNUNGSZEITEN || 'oeffnungszeiten',
zeitenErinnerungen: process.env.APPWRITE_COLLECTION_ZEITEN_ERINNERUNGEN || 'zeitenErinnerungen',
// Aufträge und Rechnungen (Auftragsbestätigungen, Projektrechnungen; scripts/auftraege-setup.mjs)
auftraege: process.env.APPWRITE_COLLECTION_AUFTRAEGE || 'auftraege',
projektRechnungen: process.env.APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN || 'projektRechnungen',
},
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
@@ -1119,6 +1123,16 @@ export const config = {
keinNeubau: commaList(process.env.ZEITEN_KEIN_NEUBAU, ['emmas-schatzkiste']),
},
// Aufträge und Rechnungen: Schalter nur für die AB-Funktionen und die Rechnungsarten anzahlung/schluss; wird bei
// jedem Aufruf gelesen, siehe server/services/auftraege/schalter.js
auftraege: {
// off (Vorgabe) | pilot (nur AUFTRAEGE_PILOT_EMAILS, '*' = alle) | on
mode: ['pilot', 'on'].includes(auftraegeEnabled) ? auftraegeEnabled : 'off',
pilotEmails: commaList(process.env.AUFTRAEGE_PILOT_EMAILS, []),
// Gesendete AB-PDFs: <uploadDir>/<kundenId>/<auftragId>.pdf
uploadDir: process.env.AUFTRAEGE_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'auftraege'),
},
invoiceNinja: {
url: (process.env.INVOICE_NINJA_URL || 'https://invoice.webklar.com').replace(/\/$/, ''),
apiToken: process.env.INVOICE_NINJA_API_TOKEN || '',
+10
View File
@@ -23,6 +23,8 @@ import { shopSeiteRouter } from './routes/shopSeite.js'
import unterkunftRoutes from './routes/unterkunft.js'
import { unterkunftSeiteRouter } from './routes/unterkunftSeite.js'
import zeitenRoutes from './routes/zeiten.js'
import publicAuftragRoutes from './routes/auftrag.js'
import kundeAuftraegeRoutes from './routes/auftraegeKunde.js'
import takeoverCreditRoutes from './routes/takeoverCredits.js'
import { purgeExpiredUploads } from './services/takeoverCredits.js'
import { purgePendingChatUploads } from './services/chatAttachments.js'
@@ -52,6 +54,8 @@ import publicZeitenRoutes from './routes/publicZeiten.js'
import adminAnalyseRoutes from './routes/admin/analyse.js'
import { legalRouter } from './services/legalDocs.js'
import adminFinanzenRoutes from './routes/admin/finanzen.js'
import adminProjektkontenRoutes from './routes/admin/projektkonten.js'
import adminAuftraegeRoutes from './routes/admin/auftraege.js'
import teamRoutes from './routes/team/index.js'
import legacyAdminRedirect from './routes/legacyAdminRedirect.js'
import { corsMiddleware } from './middleware/cors.js'
@@ -101,6 +105,9 @@ app.use('/api/shop/stripe-webhook', shopWebhookRoutes)
app.use('/api/shop', shopRoutes)
app.use('/api/unterkunft', unterkunftRoutes)
app.use('/api/zeiten', zeitenRoutes)
// Auftragsbestaetigung: oeffentliche Bestaetigung per Token (ohne Sitzung) und die Auftraege des angemeldeten Kunden
app.use('/api/auftrag', publicAuftragRoutes)
app.use('/api/auftraege', kundeAuftraegeRoutes)
app.use('/api/team', teamRoutes)
app.use('/api/admin/gitea', adminGiteaRoutes)
app.use('/api/admin/website-projects', adminPreviewRoutes)
@@ -110,6 +117,9 @@ app.use('/api/admin/customers', adminCustomersRoutes)
app.use('/api/admin/employees', adminEmployeesRoutes)
app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes)
app.use('/api/admin/analyse', adminAnalyseRoutes)
// Projektkonten und Auftraege (Admin): vor dem Finanzen-Router, dessen requireAppwriteStaff sonst jede Anfrage darunter zuerst prueft
app.use('/api/admin/finanzen/projektkonten', adminProjektkontenRoutes)
app.use('/api/admin/finanzen/auftraege', adminAuftraegeRoutes)
app.use('/api/admin/finanzen', adminFinanzenRoutes)
app.use('/webhook', giteaWebhookRoutes)
// Push-Zeitmail: Zeit eines Git-Pushes per Link aus der Mail eintragen (ohne Login, signierter Token)
@@ -8,7 +8,8 @@
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: E-Mail aus dem Formular · BCC intern
Platzhalter: {{name}}, {{produkt}}, {{bestellung}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abschluss, wechsel, zusatzspeicher, funktion, pruefen
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abschluss, wechsel, zusatzspeicher, funktion, pruefen, projekt, projekt_unternehmen
projekt = Widerruf eines Projektauftrags (Aufträge und Rechnungen): wir prüfen und melden uns, ohne Zusagen zu Erstattung oder Wertersatz; projekt_unternehmen (nur innerhalb von projekt) = die Auftragsbestätigung gehört zu einem Unternehmen, der Zusatz nennt, dass kein gesetzliches Widerrufsrecht besteht.
produkt = Katalogname ohne „WEBklar “ („Abo“ ohne gefundenes Abo); beim Widerruf einer Funktion ihr Name aus dem Register (News-Pinnwand, Terminplaner). Folgen je Bestellung wie in den Hinweisen zum Widerruf (Fassung 4): abschluss (Wertersatz bei Sofortbeginn, Zusatzspeicher voll erstattet), wechsel (Hosting-Abo läuft weiter, Mehrbetrag abzüglich Wertersatz), zusatzspeicher (volle Erstattung, kein Wertersatz), pruefen (keine offene Frist).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -56,6 +57,9 @@ a{color:#111111;}
<!-- WENN pruefen -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir prüfen, ob für diese Bestellung eine Widerrufsfrist läuft, und melden uns umgehend bei Ihnen.</div></td></tr></table></td></tr>
<!-- ENDE pruefen -->
<!-- WENN projekt -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.<!-- WENN projekt_unternehmen --> Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.<!-- ENDE projekt_unternehmen --></div></td></tr></table></td></tr>
<!-- ENDE projekt -->
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Ihr WEBklar-Team</strong></td></tr>
</table>
</td></tr>
@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K31 · Auftragsbestätigung (Projektauftrag; Variante Erinnerung, Variante Privatperson, Kopie für das Team)
Betreff: Ihre Auftragsbestätigung {{nummer}} – {{titel}}
Absender: „WEBklar“ <Portal-Postfach>; die Kopie für das Team kommt als „WEBklar Kundenportal“
Empfänger: Kunde (E-Mail der Auftragsbestätigung), ohne BCC · Kopie ohne Link an kenso@webklar.com, andrej@webklar.com
Platzhalter: {{name}}, {{nummer}}, {{titel}}, {{datum}}, {{betrag}}, {{link}}, {{gueltig_bis}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): erinnerung, privat, festpreis, aufwand, teamkopie, kundenmail
nummer = Nummer der Auftragsbestätigung (AB-JJJJ-NNNN), titel = Projekt bzw. Titel des Auftrags, datum = Tag des Sendens (Berliner Datum, TT.MM.JJJJ).
betrag = Gesamtpreis bei Festpreis (zum Beispiel 800,00 €) bzw. Stundensatz bei Aufwand (zum Beispiel 100,00 € pro Stunde); genau einer der Blöcke festpreis und aufwand ist an.
link = Bestätigungsseite mit dem persönlichen Link (…/auftrag.html#t=…); er steht nur in der Mail an den Kunden und ist in der Kopie für das Team leer. gueltig_bis = letzter Tag, an dem der Link gilt (TT.MM.JJJJ).
erinnerung = Erinnerung des Teams: dieselbe Auftragsbestätigung mit neuem Link, der frühere Link gilt nicht mehr.
privat = der Kunde ist Privatperson: Hinweis auf Widerrufsrecht, Muster-Widerrufsformular (in der angehängten Auftragsbestätigung) und den Beginn der Arbeiten; nicht in der Kopie für das Team.
kundenmail = die Mail an den Kunden: Knopf mit dem Link und Hinweis auf die Anhänge (die Auftragsbestätigung als PDF, <Nummer>.pdf, und die Allgemeinen Geschäftsbedingungen als PDF, WEBklar-AGB-<Fassung>.pdf).
teamkopie = die Kopie für das Team: statt des Knopfs der Hinweis, dass der Bestätigungslink nur in der Mail an den Kunden steht (ohne Link, ohne Anhänge). Genau einer der Blöcke kundenmail und teamkopie ist an.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Ihre Auftragsbestätigung {{nummer}} – {{titel}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Bitte prüfen und bestätigen Sie Ihren Auftrag {{nummer}}.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Auftrag</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">Auftragsbestätigung</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Ihre Auftragsbestätigung {{nummer}}</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>vielen Dank für Ihren Auftrag. Anbei erhalten Sie die Auftragsbestätigung {{nummer}} zum Projekt „{{titel}}“. Bitte prüfen Sie sie und bestätigen Sie den Auftrag; eine Anmeldung ist nicht nötig.</td></tr>
<!-- WENN erinnerung -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Erinnerung</div><div class="mut" style="color:#4a4843;">Wir haben Ihnen diese Auftragsbestätigung am {{datum}} schon einmal geschickt und noch keine Bestätigung von Ihnen erhalten. Der Link in der früheren E-Mail gilt nicht mehr.</div></td></tr></table></td></tr>
<!-- ENDE erinnerung -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Auftrag</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{nummer}} vom {{datum}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{titel}}</td></tr><!-- WENN festpreis --><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Gesamtpreis</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{betrag}}</strong> <span class="mut" style="color:#6b6964;">(Festpreis, gemäß § 19 UStG ohne Umsatzsteuer)</span></td></tr><!-- ENDE festpreis --><!-- WENN aufwand --><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Vergütung</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{betrag}}</strong> <span class="mut" style="color:#6b6964;">(nach Aufwand in Schritten von 15 Minuten, gemäß § 19 UStG ohne Umsatzsteuer)</span></td></tr><!-- ENDE aufwand --><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Bestätigung möglich bis</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{gueltig_bis}}</td></tr></table></td></tr>
<!-- WENN kundenmail -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{link}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Auftrag bestätigen &rarr;</a></td></tr></table></td></tr>
<!-- ENDE kundenmail -->
<!-- WENN teamkopie -->
<tr><td class="px mut" style="padding:28px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.</td></tr>
<!-- ENDE teamkopie -->
<!-- WENN kundenmail -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Anhänge</div><div class="mut" style="color:#4a4843;">Die Auftragsbestätigung ({{nummer}}.pdf) und unsere Allgemeinen Geschäftsbedingungen hängen als PDF an dieser E-Mail. Bitte bewahren Sie beide auf. Den Auftrag können Sie auch in Ihrem Kundenportal ansehen und bestätigen.</div></td></tr></table></td></tr>
<!-- ENDE kundenmail -->
<!-- WENN privat -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Ihr Widerrufsrecht</div><div class="mut" style="color:#4a4843;">Sie sind Verbraucher: Ihr Widerrufsrecht und das Muster-Widerrufsformular finden Sie in der angehängten Auftragsbestätigung; widerrufen können Sie auch über das <a href="https://project.webklar.com/widerrufen.html" style="color:#111111;">Widerrufsformular auf unserer Website</a>. Mit der Arbeit beginnen wir erst nach Ablauf der Widerrufsfrist, es sei denn, Sie verlangen beim Bestätigen ausdrücklich einen früheren Beginn.</div></td></tr></table></td></tr>
<!-- ENDE privat -->
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Bei Fragen antworten Sie einfach auf diese E-Mail.<br>Viele Grüße<br><strong>Ihr WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K32 · Auftrag bestätigt (Eingangsbestätigung der Bestätigung; Variante Privatperson mit oder ohne früheren Beginn)
Betreff: Ihre Bestätigung zu {{nummer}} ist eingegangen
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail der Auftragsbestätigung) · BCC intern
Platzhalter: {{name}}, {{nummer}}, {{titel}}, {{zeitpunkt}}, {{fruehester_beginn}}, {{fruehbeginn_text}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): fruehbeginn, ohne_fruehbeginn
zeitpunkt = Zeitpunkt der Bestätigung (TT.MM.JJJJ, HH:MM, Berliner Zeit). fruehester_beginn = frühester Arbeitsbeginn ohne ausdrücklichen Wunsch (TT.MM.JJJJ, Tag nach Ablauf der Widerrufsfrist), nur im Block ohne_fruehbeginn.
fruehbeginn = die Privatperson hat ausdrücklich verlangt, dass die Arbeit schon vor Ablauf der Widerrufsfrist beginnt; fruehbeginn_text = Wortlaut ihrer Erklärung (FRUEHBEGINN_TEXT aus server/services/auftraege/texte.js), nur in diesem Block, ohne weitere Sätze. ohne_fruehbeginn = sie hat das nicht verlangt. Bei Unternehmen ist keiner der beiden Blöcke an.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Ihre Bestätigung zu {{nummer}} ist eingegangen</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Wir haben Ihre Bestätigung zu {{nummer}} erhalten.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Bestätigung</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; Auftrag bestätigt</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Vielen Dank für Ihre Bestätigung</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>wir haben Ihre Bestätigung zu {{nummer}} am {{zeitpunkt}} Uhr erhalten. Bitte bewahren Sie diese E-Mail auf.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Auftrag</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{nummer}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{titel}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Bestätigt am</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{zeitpunkt}} Uhr</td></tr></table></td></tr>
<!-- WENN fruehbeginn -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Ihre Erklärung zum früheren Beginn</div><div class="mut" style="color:#4a4843;">{{fruehbeginn_text}}</div></td></tr></table></td></tr>
<!-- ENDE fruehbeginn -->
<!-- WENN ohne_fruehbeginn -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Beginn der Arbeiten</div><div class="mut" style="color:#4a4843;">Sie haben nicht verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Wir beginnen daher frühestens am {{fruehester_beginn}} mit der Arbeit.</div></td></tr></table></td></tr>
<!-- ENDE ohne_fruehbeginn -->
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Ihr WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -9,7 +9,8 @@
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{email}}, {{name}}, {{produkt}}, {{preis}}, {{kundennummer}}, {{bestellung}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}, {{ereignisse}}, {{pruefhinweis}}
preis = Preis der widerrufenen Funktion aus dem Register (nur im Block funktion, sonst leer).
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abo_gefunden, auf_kuendigung_gesetzt, kein_abo_gefunden, kuendigung_fehlgeschlagen, abschluss, wechsel, zusatzspeicher, funktion, keine_frist, nachweis_fehlt, wechsel_offen, abschluss_verspaetet, nicht_pruefbar, bereits_widerrufen
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abo_gefunden, auf_kuendigung_gesetzt, kein_abo_gefunden, kuendigung_fehlgeschlagen, abschluss, wechsel, zusatzspeicher, funktion, keine_frist, nachweis_fehlt, wechsel_offen, abschluss_verspaetet, nicht_pruefbar, bereits_widerrufen, projekt
projekt = Widerruf eines Projektauftrags (Aufträge und Rechnungen), ohne Abo und ohne Vertragsereignis: ereignisse = Ergebnis der Prüfung der Auftragsbestätigung, fristende = Ende der Widerrufsfrist (TT.MM.JJJJ, 24 Uhr), rechtzeitig = Eingang (RECHTZEITIG, NACH Fristende, ungeprüft …); die Zeile „Abo“ zeigt nur die Vertragsart.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
@@ -64,6 +65,9 @@ a{color:#111111;}
<!-- WENN funktion -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Widerruf einer Funktion ({{produkt}}, {{preis}} einmalig). Widerrufsfrist bis {{fristende}}, Eingang: <strong>{{rechtzeitig}}</strong>. Es wurde NICHTS automatisch geändert: kein Abo, kein Vertragsereignis. Bitte von Hand prüfen, die Zahlung in Stripe innerhalb von 14 Tagen erstatten, gegebenenfalls abzüglich Wertersatz für den begonnenen Einbau (Aufwand im Arbeitsblatt, ⚖ Q5), sofern der Kunde den Beginn vor Fristende ausdrücklich verlangt hat, und die Funktion im Ticketsystem abschalten (Kunde → Reiter „Funktionen“). Beim Terminplaner bleibt das Wartungs-Abo unberührt. Der Kunde hat „wir prüfen“ bekommen. Bezahlte Bestellungen dieser Funktion (je Projekt mit eigenem Fristende):<br>{{ereignisse}}</div></td></tr></table></td></tr>
<!-- ENDE funktion -->
<!-- WENN projekt -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Widerruf eines Projektauftrags. Geprüft wurde nur die Auftragsbestätigung (kein Abo, kein Vertragsereignis); die Zeile „Abo“ oben nennt nur die Vertragsart.<br>Ergebnis: {{ereignisse}}<br>Widerrufsfrist bis: {{fristende}}<br>Eingang: <strong>{{rechtzeitig}}</strong><br>Der Kunde hat „wir prüfen“ bekommen. Erstattung, Wertersatz und das Ende der Arbeit sind Handarbeit (Ticketsystem: Kunde → Aufträge und Rechnungen).</div></td></tr></table></td></tr>
<!-- ENDE projekt -->
<!-- WENN keine_frist -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">Nichts automatisch geändert – keine offene Frist gefunden: zu dieser Bestellung läuft keine Widerrufsfrist (mehr) oder es gibt keine Bestellung dieser Art. Bitte prüfen, bevor der Widerruf abgelehnt wird; der Kunde hat „wir prüfen“ bekommen.</div></td></tr></table></td></tr>
<!-- ENDE keine_frist -->
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
T22 · Auftrag bestätigt (Hinweis ans Team)
Betreff: [Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{kunde}}, {{nummer}}, {{titel}}, {{zeitpunkt}}, {{ueber}}, {{woms_link}}, {{fruehester_beginn}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): privat_fruehbeginn, privat_ohne_fruehbeginn
kunde = Firma, sonst Name des Kunden. ueber = Weg der Bestätigung (Bestätigungslink oder Kundenportal). zeitpunkt = TT.MM.JJJJ, HH:MM, Berliner Zeit. woms_link = Aufträge und Rechnungen des Kunden im Ticketsystem.
privat_fruehbeginn = Privatperson, früherer Beginn ausdrücklich verlangt (die Arbeit darf sofort starten). privat_ohne_fruehbeginn = Privatperson ohne diesen Wunsch (Arbeitsbeginn erst ab fruehester_beginn, TT.MM.JJJJ). Bei Unternehmen ist keiner der beiden Blöcke an.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>[Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">{{kunde}}: {{nummer}} bestätigt.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Intern · Kundenportal</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">Aufträge</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Auftrag bestätigt</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Kunde</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{kunde}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Auftrag</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{nummer}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{titel}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Bestätigt am</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeitpunkt}} Uhr</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Weg</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{ueber}}</td></tr></table></td></tr>
<!-- WENN privat_fruehbeginn -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Privatperson: früher Beginn verlangt</div><div class="mut" style="color:#4a4843;">Der Kunde hat ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Die Arbeit darf sofort starten. Bei einem Widerruf ist Wertersatz zu prüfen (§ 357a Abs. 2 BGB).</div></td></tr></table></td></tr>
<!-- ENDE privat_fruehbeginn -->
<!-- WENN privat_ohne_fruehbeginn -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Privatperson: kein früher Beginn</div><div class="mut" style="color:#4a4843;">Der Kunde hat keinen früheren Beginn verlangt. Arbeitsbeginn frühestens am {{fruehester_beginn}}, dem Tag nach Ablauf der Widerrufsfrist.</div></td></tr></table></td></tr>
<!-- ENDE privat_ohne_fruehbeginn -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{woms_link}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Im Ticketsystem öffnen &rarr;</a></td></tr></table></td></tr>
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+61 -13
View File
@@ -5,6 +5,8 @@ import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { getCustomerByEmail, getDocument, updateDocument } from '../services/appwriteAdmin.js'
import { eventIdFor, listContractEvents, openWithdrawals, recordContractEvent, updateContractEvent } from '../services/contractEvents.js'
import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js'
import { findAuftragByNummer } from '../services/auftraege/auftragStore.js'
import { widerrufeAuftrag } from '../services/auftraege/bestaetigung.js'
import { getLedger } from '../services/hourLedger.js'
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
import { changeStorage, previewStorageChange, recordPaidStorage, storageTaskKind } from '../services/mailStorage.js'
@@ -727,7 +729,8 @@ export function publicRateLimitState() {
// Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert
const CONTRACTS = ['abo', 'zusatzspeicher']
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'unterkunft', 'zeiten']
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'unterkunft', 'zeiten', 'projekt']
const AB_NUMMER = /^AB-\d{4}-\d{4}$/
// Bestellung einer Funktion -> Funktionsschluessel im Register ('funktion' meint die News-Pinnwand; jede Funktion hat ihre eigene Frist)
const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine', shop: 'shop', unterkunft: 'unterkunft', zeiten: 'zeiten' }
// Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals)
@@ -735,7 +738,7 @@ const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspe
/**
* Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl.
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop' | 'unterkunft' | 'zeiten'; packages (nur beim
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop' | 'unterkunft' | 'zeiten' | 'projekt'; packages (nur beim
* Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026).
*/
function validDeclaration(body) {
@@ -764,6 +767,7 @@ function validDeclaration(body) {
contract,
order,
packages,
abNummer: String(body?.abNummer || '').trim().toUpperCase().slice(0, 20),
}
}
@@ -804,7 +808,8 @@ function contractLabel(contract, sub) {
}
/** Beschriftung der widerrufenen Bestellung fuer K04 (an den Kunden) und T09 (intern). */
function orderLabel(order, sub, { customer = false } = {}) {
function orderLabel(order, sub, { customer = false, abNummer = '' } = {}) {
if (order === 'projekt') return AB_NUMMER.test(abNummer) ? `Projektauftrag (Auftragsbestätigung ${abNummer})` : 'Projektauftrag (Auftragsbestätigung)'
if (order === 'wechsel') return 'Wechsel ins Wartungs-Abo'
if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher'
if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)'
@@ -1148,6 +1153,37 @@ async function withdrawFeature(d, receivedAt, featureKey) {
return { customer, orders, deadline, lines, inTime: Boolean(deadline) && receivedAt <= deadline }
}
/** Projektauftrag (Aufträge und Rechnungen, Spec 2026-10-06 Abschnitt 2): Nummer und E-Mail-Adresse müssen zur gesendeten AB passen. Ohne gültige Nummer wird nichts nachgeschlagen. Wirft bei Datenbankfehlern. */
async function withdrawProjekt(d, receivedAt) {
const auftrag = AB_NUMMER.test(d.abNummer) ? await findAuftragByNummer(d.abNummer) : null
const adressen = [auftrag?.gesendetAn, auftrag?.kunde?.email].map((adresse) => String(adresse || '').trim().toLowerCase())
if (!auftrag || !adressen.includes(d.email.toLowerCase())) return { auftrag: null, ergebnis: 'ungeprueft', fristEnde: null }
return widerrufeAuftrag(auftrag, { jetzt: receivedAt })
}
const PROJEKT_EINGANG = { widerrufen: 'RECHTZEITIG', nach_frist: 'NACH Fristende, vor einer Ablehnung bitte prüfen', bereits_widerrufen: 'bereits widerrufen' }
/** Werte für die Zeilen „Ergebnis“, „Widerrufsfrist bis“ und „Eingang“ im Block projekt von T09 (nur das Team sieht sie). */
function projektWerte({ ergebnis, auftrag, fristEnde }, abNummer) {
const ergebnisText = {
widerrufen: () => `Auftragsbestätigung auf „widerrufen“ gesetzt${auftrag.fruehbeginn ? '; Frühbeginn war verlangt: Wertersatz prüfen (§ 357a Abs. 2 BGB)' : ''}`,
bereits_widerrufen: () => 'war schon widerrufen, nichts geändert',
kein_widerrufsrecht: () => 'Unternehmen: kein gesetzliches Widerrufsrecht, nichts geändert',
nach_frist: () => 'nichts geändert',
nicht_widerrufbar: () => `Status „${auftrag.status}“, nichts geändert, bitte prüfen`,
fehler: () => 'ungeprüft: Prüfung fehlgeschlagen (Datenbank), bitte von Hand prüfen',
}[ergebnis]
return {
ereignisse: ergebnisText
? ergebnisText()
: AB_NUMMER.test(abNummer)
? `ungeprüft: keine Auftragsbestätigung zu Nummer „${abNummer}“ und dieser E-Mail-Adresse gefunden, nichts geändert`
: 'ungeprüft: keine gültige AB-Nummer angegeben, nichts geändert, bitte von Hand zuordnen',
fristende: ergebnis === 'kein_widerrufsrecht' ? 'entfällt (Unternehmen)' : fristEnde ? `${fmtDate(fristEnde)}, 24 Uhr` : 'keine Frist gefunden',
rechtzeitig: PROJEKT_EINGANG[ergebnis] ?? 'ungeprüft',
}
}
/** Zeile je widerrufenem Ereignis fuer T09: ID, Inhalt (Speicher: Menge vorher -> nachher), Bestelldatum. */
function describeWithdrawn(events) {
return events
@@ -1169,10 +1205,14 @@ router.post('/public/withdraw', async (req, res) => {
// Funktion (News, Terminplaner oder Webshop): kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature)
const featureKey = FEATURE_OF_ORDER[d.order] ?? null
const feature = featureKey !== null
const projekt = d.order === 'projekt'
let pr = { auftrag: null, ergebnis: 'ungeprueft', fristEnde: null }
const funktion = feature ? featureConfig(featureKey) : null
let fn = { customer: null, orders: [], deadline: null, lines: '', inTime: false }
try {
if (feature) {
if (projekt) {
pr = await withdrawProjekt(d, receivedAt)
} else if (feature) {
fn = await withdrawFeature(d, receivedAt, featureKey)
result = { ...result, deadline: fn.deadline, inTime: fn.inTime, late: !fn.inTime && Boolean(fn.deadline) }
} else {
@@ -1184,8 +1224,11 @@ router.post('/public/withdraw', async (req, res) => {
}
} catch (err) {
console.error('[abo] Widerruf nicht verarbeitbar:', err.message)
if (projekt) pr = { auftrag: null, ergebnis: 'fehler', fristEnde: null }
}
const { inTime } = result
const projektAngaben = projekt ? projektWerte(pr, d.abNummer) : null
const produktText = projekt ? 'Projektauftrag' : feature ? funktion.label.name : productLabelOf(sub)
// Fuer T09: warum sich die Frist nicht pruefen liess
const pruefhinweis = result.unreadable ? 'Ereignisse nicht lesbar – bitte von Hand prüfen' : result.startMissing ? 'Abo-Beginn fehlt' : ''
@@ -1193,8 +1236,8 @@ router.post('/public/withdraw', async (req, res) => {
template: 'K04',
values: {
name: d.name,
bestellung: orderLabel(d.order, sub, { customer: true }),
produkt: feature ? funktion.label.name : productLabelOf(sub),
bestellung: orderLabel(d.order, sub, { customer: true, abNummer: d.abNummer }),
produkt: produktText,
eingang_zeitpunkt: fmtDateTime(receivedAt),
email: d.email,
kundennummer: d.customerNumber,
@@ -1205,7 +1248,9 @@ router.post('/public/withdraw', async (req, res) => {
wechsel: inTime && d.order === 'wechsel',
zusatzspeicher: inTime && d.order === 'zusatzspeicher',
funktion: feature,
pruefen: !inTime && !feature,
pruefen: !inTime && !feature && !projekt,
projekt,
projekt_unternehmen: projekt && pr.ergebnis === 'kein_widerrufsrecht',
},
}).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message))
@@ -1216,13 +1261,15 @@ router.post('/public/withdraw', async (req, res) => {
email: d.email,
name: d.name,
kundennummer: d.customerNumber,
bestellung: orderLabel(d.order, sub),
produkt: feature ? funktion.label.name : productLabelOf(sub),
bestellung: orderLabel(d.order, sub, { abNummer: d.abNummer }),
produkt: produktText,
preis: feature ? funktion.priceLabel : '',
stripe_abo_id: sub?.stripeSubscriptionId || '',
abo_beginn: fmtDate(sub?.startedAt),
fristende: result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden',
rechtzeitig: feature
fristende: projekt ? projektAngaben.fristende : result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden',
rechtzeitig: projekt
? projektAngaben.rechtzeitig
: feature
? !fn.customer
? 'ungeprüft (kein Kunde zu dieser E-Mail-Adresse gefunden)'
: !fn.orders.length
@@ -1243,14 +1290,14 @@ router.post('/public/withdraw', async (req, res) => {
: result.deadline
? 'ungeprüft (keine offene Frist: Bestellung gescheitert)'
: 'ungeprüft (keine Bestellung dieser Art gefunden)',
ereignisse: feature ? fn.lines : describeWithdrawn(result.withdrawn),
ereignisse: projekt ? projektAngaben.ereignisse : feature ? fn.lines : describeWithdrawn(result.withdrawn),
pruefhinweis,
},
blocks: {
kundennummer: Boolean(d.customerNumber),
abo_gefunden: Boolean(sub),
auf_kuendigung_gesetzt: result.canceled,
kein_abo_gefunden: !sub && !feature,
kein_abo_gefunden: !sub && !feature && !projekt,
kuendigung_fehlgeschlagen: result.cancelFailed,
abschluss: inTime && d.order === 'abschluss',
wechsel: inTime && d.order === 'wechsel',
@@ -1262,6 +1309,7 @@ router.post('/public/withdraw', async (req, res) => {
abschluss_verspaetet: Boolean(sub) && d.order === 'abschluss' && result.late && !result.alreadyWithdrawn,
nicht_pruefbar: Boolean(sub) && Boolean(pruefhinweis),
bereits_widerrufen: Boolean(sub) && result.alreadyWithdrawn,
projekt,
},
})
+145
View File
@@ -0,0 +1,145 @@
/**
* Auftragsbestätigungen (Admin), Mount: /api/admin/finanzen/auftraege (vor dem Finanzen-Router, Übersicht V5 „L3“).
* GET / (?kunde=), POST /, GET|PATCH|DELETE /:id, GET /:id/pdf, POST /:id/projekt, /:id/senden, /:id/erinnern,
* /:id/neue-fassung, /:id/stornieren. Nur dünne HTTP-Schicht: Kunde laden, Schalter prüfen, Dienst aufrufen, Fehler über
* sendeFehler. Ist der Schalter für den Kunden aus, antworten alle Routen 404 mit SCHALTER_AUS_MELDUNG.
*/
import { Router } from 'express'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { auftraegeAktiv } from '../../services/auftraege/schalter.js'
import { SCHALTER_AUS_MELDUNG } from '../../services/auftraege/konstanten.js'
import { AuftragError, sendeFehler } from '../../services/auftraege/fehler.js'
import { auftragFuerTeam } from '../../services/auftraege/auftragSicht.js'
import { listAuftraege } from '../../services/auftraege/auftragStore.js'
import {
aendereEntwurf, erinnereAuftrag, ladeAuftrag, ladeGespeichertesPdf, ladeKunde, legeEntwurfAn, loescheEntwurf, neueFassung,
ordneProjektZu, sendeAuftrag, storniereAuftrag, vorschauPdf,
} from '../../services/auftraege/auftraege.js'
const router = Router()
router.use(requireAppwriteStaff)
async function ladeKundeMitSchalter(kundenId) {
const kunde = await ladeKunde(kundenId)
if (!auftraegeAktiv(kunde)) throw new AuftragError(404, SCHALTER_AUS_MELDUNG)
return kunde
}
async function ladeMitSchalter(id) {
const auftrag = await ladeAuftrag(id)
await ladeKundeMitSchalter(auftrag.kundenId)
return auftrag
}
const vonAdmin = (req) => req.appwriteUser?.email
router.get('/', async (req, res) => {
try {
const kundenId = req.query.kunde
if (typeof kundenId !== 'string' || kundenId === '') return res.status(400).json({ error: 'Kunde fehlt.' })
await ladeKundeMitSchalter(kundenId)
return res.json({ auftraege: (await listAuftraege({ kundenId })).map(auftragFuerTeam) })
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigungen laden', err)
}
})
router.post('/', async (req, res) => {
try {
const kundenId = req.body?.kundenId
if (typeof kundenId === 'string' && kundenId !== '') await ladeKundeMitSchalter(kundenId)
return res.json({ auftrag: auftragFuerTeam(await legeEntwurfAn(req.body, { von: vonAdmin(req) })) })
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigung anlegen', err)
}
})
router.get('/:id', async (req, res) => {
try {
return res.json({ auftrag: auftragFuerTeam(await ladeMitSchalter(req.params.id)) })
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigung laden', err)
}
})
router.patch('/:id', async (req, res) => {
try {
await ladeMitSchalter(req.params.id)
return res.json({ auftrag: auftragFuerTeam(await aendereEntwurf(req.params.id, req.body ?? {})) })
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigung ändern', err)
}
})
router.delete('/:id', async (req, res) => {
try {
await ladeMitSchalter(req.params.id)
await loescheEntwurf(req.params.id)
return res.json({ ok: true })
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigung löschen', err)
}
})
router.get('/:id/pdf', async (req, res) => {
try {
const auftrag = await ladeMitSchalter(req.params.id)
const entwurf = auftrag.status === 'entwurf'
const buffer = entwurf ? await vorschauPdf(auftrag.id) : await ladeGespeichertesPdf(auftrag)
const name = entwurf ? 'Auftragsbestaetigung-Entwurf.pdf' : /^AB-\d{4}-\d{4}$/.test(auftrag.nummer) ? `${auftrag.nummer}.pdf` : 'Auftragsbestaetigung.pdf'
res.set({ 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="${name}"`, 'Cache-Control': 'no-store' })
return res.send(buffer)
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigung-PDF laden', err)
}
})
/** Antwort der Aktionen, die eine Mail auslösen: { auftrag, mailFehler }. */
const mitMail = ({ auftrag, mailFehler }) => ({ auftrag: auftragFuerTeam(auftrag), mailFehler })
router.post('/:id/senden', async (req, res) => {
try {
await ladeMitSchalter(req.params.id)
return res.json(mitMail(await sendeAuftrag(req.params.id, { von: vonAdmin(req) })))
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigung senden', err)
}
})
router.post('/:id/erinnern', async (req, res) => {
try {
await ladeMitSchalter(req.params.id)
return res.json(mitMail(await erinnereAuftrag(req.params.id)))
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigung erinnern', err)
}
})
router.post('/:id/neue-fassung', async (req, res) => {
try {
await ladeMitSchalter(req.params.id)
return res.json({ auftrag: auftragFuerTeam(await neueFassung(req.params.id, { von: vonAdmin(req) })) })
} catch (err) {
return sendeFehler(res, 'Neue Fassung anlegen', err)
}
})
router.post('/:id/stornieren', async (req, res) => {
try {
await ladeMitSchalter(req.params.id)
return res.json({ auftrag: auftragFuerTeam(await storniereAuftrag(req.params.id)) })
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigung stornieren', err)
}
})
router.post('/:id/projekt', async (req, res) => {
try {
await ladeMitSchalter(req.params.id)
return res.json({ auftrag: auftragFuerTeam(await ordneProjektZu(req.params.id, req.body?.projektId)) })
} catch (err) {
return sendeFehler(res, 'Projekt zuordnen', err)
}
})
export default router
+28 -5
View File
@@ -1,6 +1,7 @@
import { Router } from 'express'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { withLocks } from '../../services/keyedLock.js'
import { AuftragError } from '../../services/auftraege/fehler.js'
import { EnableBankingError } from '../../services/finance/enableBanking.js'
import { completeBankAuth, startBankAuth, syncBank } from '../../services/finance/bankSync.js'
import { getConnection, listTransactions, publicConnection, saveConnection } from '../../services/finance/bankStore.js'
@@ -21,13 +22,14 @@ import {
createProjectInvoice,
deleteDraft,
listInvoices,
rechnungsVorschau,
sendInvoiceNow,
voidInvoice,
} from '../../services/finance/stripeFinance.js'
/**
* Finanzen im WOMS (nur Admins): Übersicht, Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung,
* Rechnungen (lesen, anlegen, senden, stornieren, Entwurf löschen), Einstellungen und Fixkosten. Die Handler sind klein
* Rechnungen (lesen, anlegen, Vorschau, senden, stornieren, Entwurf löschen), Einstellungen und Fixkosten. Die Handler sind klein
* und rufen die Dienste in server/services/finance auf.
*/
const router = Router()
@@ -148,7 +150,7 @@ const isStripeError = (err) => typeof err?.type === 'string' && err.type.startsW
* Fehler einen status tragen: nur die beiden eigenen Fehlerarten werden weitergegeben.
*/
function sendFinanceError(res, what, err) {
if (err instanceof AssignError || err instanceof InvoiceError) {
if (err instanceof AssignError || err instanceof InvoiceError || err instanceof AuftragError) {
// Ein 502 an einer bestehenden Rechnung (nicht gesendet, nicht lesbar) hat seine Ursache bei Stripe: nur Art, Status und Code ins Protokoll
if (err.status === 502 && err.cause) logFailure(what, err.cause)
const body = { error: err.message }
@@ -186,7 +188,6 @@ router.delete('/buchungen/:id/zuordnung', async (req, res) => {
// --- Rechnungen (Stripe) ----------------------------------------------------------------------------------------
const INVOICE_FILTERS = new Set(['alle', 'offen', 'ueberfaellig', 'bezahlt', 'entwurf'])
const DEFAULT_DAYS_UNTIL_DUE = 14
const createdOf = (invoice) => invoice.createdDate ?? ''
router.get('/rechnungen', async (req, res) => {
@@ -208,12 +209,24 @@ router.get('/rechnungen', async (req, res) => {
})
router.post('/rechnungen', async (req, res) => {
const { customerId, items, daysUntilDue, memo, senden, idempotencyKey } = req.body || {}
const {
customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum,
daysUntilDue, memo, senden, idempotencyKey,
} = req.body || {}
try {
// Die Typen der Felder prueft baueRechnung (400); die Route reicht sie unveraendert durch
const rechnung = await createProjectInvoice({
customerId,
art,
projektId,
auftragId,
items,
daysUntilDue: daysUntilDue ?? DEFAULT_DAYS_UNTIL_DUE,
arbeitsblattIds,
anzahlung,
zeilentexte,
leistungszeitraum,
// Ohne Angabe entscheidet createProjectInvoice (AB-Zahlungsziel bei Anzahlung und Schluss, sonst 14 Tage)
daysUntilDue: daysUntilDue ?? undefined,
memo,
// Nur ein echtes Ja sendet sofort: alles andere bleibt ein Entwurf
senden: senden === true || senden === 'true',
@@ -227,6 +240,16 @@ router.post('/rechnungen', async (req, res) => {
}
})
// Vorschau: dieselben Pruefungen wie das Anlegen, aber ohne Schreibzugriff
router.post('/rechnungen/vorschau', async (req, res) => {
const { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum } = req.body || {}
try {
return res.json(await rechnungsVorschau({ customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum }))
} catch (err) {
return sendFinanceError(res, 'Rechnungsvorschau', err)
}
})
router.post('/rechnungen/:id/senden', async (req, res) => {
try {
return res.json({ rechnung: await sendInvoiceNow(req.params.id) })
+38
View File
@@ -0,0 +1,38 @@
/**
* Projektkonten (Admin). Mount: /api/admin/finanzen/projektkonten (vor dem Finanzen-Router).
* GET /?kunde=<id> Projektkonten eines Kunden samt Zeile "Ohne Projekt"
* GET /:projektId Konto, ABs, Rechnungen und offene Arbeitsblaetter eines Projekts
* Nur lesen (Strang L1).
*/
import { Router } from 'express'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { sendeFehler } from '../../services/auftraege/fehler.js'
import { projektkontenFuerKunde, projektkontoDetail } from '../../services/auftraege/projektkonto.js'
const router = Router()
router.use(requireAppwriteStaff)
// Appwrite-UID: erst pruefen, dann an Appwrite geben
const KENNUNG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
router.get('/', async (req, res) => {
const kunde = req.query.kunde
if (typeof kunde !== 'string' || !KENNUNG.test(kunde)) return res.status(400).json({ error: 'Kunde fehlt oder ist ungültig.' })
try {
res.json(await projektkontenFuerKunde(kunde))
} catch (err) {
sendeFehler(res, 'Projektkonten laden', err)
}
})
router.get('/:projektId', async (req, res) => {
const { projektId } = req.params
if (!KENNUNG.test(projektId)) return res.status(404).json({ error: 'Projekt nicht gefunden.' })
try {
res.json(await projektkontoDetail(projektId))
} catch (err) {
sendeFehler(res, 'Projektkonto laden', err)
}
})
export default router
+89
View File
@@ -0,0 +1,89 @@
/**
* Aufträge des angemeldeten Kunden (Übersicht V3/V5, Strang L5). Mount: /api/auftraege. requireSession schützt jede
* Route (fail-closed). Ein Kunde sieht und bestätigt nur eigene, gesendete Auftragsbestätigungen (keine Entwürfe);
* in der Kundenansicht ist Bestätigen nie angeboten, und der Wächter der Kundenansicht sperrt den POST von selbst
* (es gibt keinen Eintrag in KUNDENANSICHT_ALLOWED).
*/
import { Router } from 'express'
import { config } from '../config.js'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { isKundenansicht } from '../services/kundenansicht.js'
import { getAuftrag, listAuftraege } from '../services/auftraege/auftragStore.js'
import { oeffentlicheAnsicht } from '../services/auftraege/auftragSicht.js'
import { bestaetigeAuftrag, GESPERRT_MELDUNG } from '../services/auftraege/bestaetigung.js'
import { AuftragError, sendeFehler } from '../services/auftraege/fehler.js'
import { auftraegeAktiv } from '../services/auftraege/schalter.js'
import { sendePdf } from './auftrag.js'
const NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.'
const HAEKCHEN = 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.'
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
const router = Router()
router.use(requireSession)
router.use((_req, res, next) => {
res.set('Cache-Control', 'no-store')
next()
})
const ansicht = (a, req) => ({
...oeffentlicheAnsicht(a),
id: a.id,
kannBestaetigen: !isKundenansicht(req.session) && (a.status === 'versendet' || (a.status === 'abgerechnet' && !a.bestaetigtAm)),
})
/** Eigene, gesendete AB laden; sonst ist 404 schon gesendet und das Ergebnis `null`. */
async function ladeEigene(req, res) {
const nichtGefunden = () => {
res.status(404).json({ error: NICHT_GEFUNDEN })
return null
}
if (!ID_RE.test(String(req.params.id))) return nichtGefunden()
const kundenId = getSessionCustomerId(req)
const kunde = await getDocument(config.collections.customers, kundenId)
if (!auftraegeAktiv(kunde)) return nichtGefunden()
const a = await getAuftrag(req.params.id)
if (!a || a.kundenId !== kundenId || a.status === 'entwurf') return nichtGefunden()
return a
}
router.get('/', async (req, res) => {
try {
const kundenId = getSessionCustomerId(req)
const kunde = await getDocument(config.collections.customers, kundenId)
if (!auftraegeAktiv(kunde)) return res.json({ aktiv: false, auftraege: [] })
const liste = await listAuftraege({ kundenId })
res.json({ aktiv: true, auftraege: liste.filter((a) => a.status !== 'entwurf').map((a) => ansicht(a, req)) })
} catch (err) {
sendeFehler(res, 'auftraege', err)
}
})
router.get('/:id/pdf', async (req, res) => {
try {
const a = await ladeEigene(req, res)
if (a) await sendePdf(res, a)
} catch (err) {
sendeFehler(res, 'auftraege', err)
}
})
router.post('/:id/bestaetigen', async (req, res) => {
try {
const a = await ladeEigene(req, res)
if (!a) return
if (GESPERRT_MELDUNG[a.status]) return res.status(410).json({ error: GESPERRT_MELDUNG[a.status] })
const body = req.body && typeof req.body === 'object' ? req.body : {}
if (body.bestaetigt !== true) return res.status(400).json({ error: HAEKCHEN })
const neu = await bestaetigeAuftrag(a, { ueber: 'portal', fruehbeginn: body.fruehbeginn === true })
res.json({ auftrag: ansicht(neu, req) })
} catch (err) {
if (err instanceof AuftragError && err.status === 409 && Object.values(GESPERRT_MELDUNG).includes(err.message)) {
return res.status(410).json({ error: err.message })
}
sendeFehler(res, 'auftraege', err)
}
})
export default router
+118
View File
@@ -0,0 +1,118 @@
/**
* Öffentliche Link-Routen einer Auftragsbestätigung (Übersicht V5, Strang L5): ansehen, PDF, bestätigen – ohne
* Anmeldung, das Token steht im Link (Fragment `#t=`, die Seite schickt es hierher). Mount: /api/auftrag.
* Ratenbegrenzung nur im Arbeitsspeicher (GET 120/h, POST 10/h je IP); nichts von der Anfrage wird gespeichert oder
* protokolliert. Ungültige, unbekannte, abgelaufene Links und abgeschalteter Schalter antworten gleich (404).
*/
import { Router } from 'express'
import { createWriteLimiter } from '../services/writeLimiter.js'
import { findAuftragByTokenHash } from '../services/auftraege/auftragStore.js'
import { lesePdf } from '../services/auftraege/ablage.js'
import { oeffentlicheAnsicht } from '../services/auftraege/auftragSicht.js'
import { bestaetigeAuftrag, GESPERRT_MELDUNG, UNGUELTIGER_LINK } from '../services/auftraege/bestaetigung.js'
import { AuftragError, sendeFehler } from '../services/auftraege/fehler.js'
import { auftraegeAktiv } from '../services/auftraege/schalter.js'
import { hashToken } from '../services/auftraege/token.js'
const UNGUELTIG = UNGUELTIGER_LINK
const HAEKCHEN = 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.'
const ZU_VIELE = 'Zu viele Anfragen. Bitte später erneut versuchen.'
const echteUhr = () => new Date()
let uhr = echteUhr
const jetztMs = () => uhr().getTime()
const getLimiter = createWriteLimiter({ max: 120, now: jetztMs })
const postLimiter = createWriteLimiter({ max: 10, now: jetztMs })
const nachIp = (req) => String(req.ip)
/** Nur für Tests: feste Uhr der Routen (`null` = echte Uhr). */
export function setzeUhrFuerTests(fn) {
uhr = typeof fn === 'function' ? fn : echteUhr
}
/** Nur für Tests: Zähler leeren und echte Uhr. */
export function resetAuftragRoutesForTests() {
getLimiter.reset()
postLimiter.reset()
uhr = echteUhr
}
const router = Router()
router.use((_req, res, next) => {
res.set({
'Cache-Control': 'no-store',
'X-Robots-Tag': 'noindex',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
})
next()
})
/** Gemeinsamer Vorlauf der Link-Routen; bei `null` ist die Antwort (404/410) schon gesendet. */
async function ladeNachToken(req, res) {
const nichtGueltig = () => {
res.status(404).json({ error: UNGUELTIG })
return null
}
const hash = hashToken(req.params.token)
if (hash === null) return nichtGueltig()
const a = await findAuftragByTokenHash(hash)
if (!a) return nichtGueltig()
if (!auftraegeAktiv({ email: a.kunde?.email || a.gesendetAn })) return nichtGueltig()
if (a.status === 'entwurf') return nichtGueltig()
if (GESPERRT_MELDUNG[a.status]) {
res.status(410).json({ error: GESPERRT_MELDUNG[a.status] })
return null
}
const bis = a.tokenGueltigBis ? Date.parse(a.tokenGueltigBis) : Number.NaN
if (!Number.isFinite(bis) || jetztMs() > bis) return nichtGueltig()
return a
}
/** AB-PDF aus der Ablage senden (inline); fehlt die Datei, 404 `PDF nicht gefunden.`. */
export async function sendePdf(res, auftrag) {
let buffer
try {
buffer = await lesePdf(auftrag.pdfDatei)
} catch (err) {
if (err instanceof AuftragError) throw err
console.error(`[auftraege] PDF zu ${auftrag.nummer} nicht lesbar: ${err.code || err.message}`)
throw new AuftragError(404, 'PDF nicht gefunden.')
}
const nummer = String(auftrag.nummer || 'Auftragsbestaetigung').replace(/[^A-Za-z0-9-]/g, '') || 'Auftragsbestaetigung'
res.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="${nummer}.pdf"` })
res.end(buffer)
}
router.get('/:token', getLimiter.middleware(nachIp, ZU_VIELE), async (req, res) => {
try {
const a = await ladeNachToken(req, res)
if (a) res.json({ auftrag: oeffentlicheAnsicht(a) })
} catch (err) {
sendeFehler(res, 'auftrag', err)
}
})
router.get('/:token/pdf', getLimiter.middleware(nachIp, ZU_VIELE), async (req, res) => {
try {
const a = await ladeNachToken(req, res)
if (a) await sendePdf(res, a)
} catch (err) {
sendeFehler(res, 'auftrag', err)
}
})
router.post('/:token/bestaetigen', postLimiter.middleware(nachIp, ZU_VIELE), async (req, res) => {
try {
const a = await ladeNachToken(req, res)
if (!a) return
const body = req.body && typeof req.body === 'object' ? req.body : {}
if (body.bestaetigt !== true) return res.status(400).json({ error: HAEKCHEN })
const neu = await bestaetigeAuftrag(a, { ueber: 'link', fruehbeginn: body.fruehbeginn === true, jetzt: uhr() })
res.json({ auftrag: oeffentlicheAnsicht(neu) })
} catch (err) {
sendeFehler(res, 'auftrag', err)
}
})
export default router
+25
View File
@@ -0,0 +1,25 @@
/**
* Arbeitsblaetter fuers Ticketsystem. Mount: /api/team/arbeitsblaetter, in team/index.js hinter requireStaffJwt (jeder
* Mitarbeiter, daher kein eigener Schutz).
* GET /abgerechnet?ticketId=<id> { abgerechnet: { <Blatt-ID>: '<Rechnungsnummer>' | 'Entwurf' } }, ohne Betraege
*/
import { Router } from 'express'
import { sendeFehler } from '../../services/auftraege/fehler.js'
import { abgerechnetFuerTicket } from '../../services/auftraege/projektkonto.js'
const router = Router()
// Appwrite-UID: erst pruefen, dann an Appwrite geben
const KENNUNG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
router.get('/abgerechnet', async (req, res) => {
const ticketId = req.query.ticketId
if (typeof ticketId !== 'string' || !KENNUNG.test(ticketId)) return res.status(400).json({ error: 'ticketId fehlt oder ist ungültig.' })
try {
res.json({ abgerechnet: await abgerechnetFuerTicket(ticketId) })
} catch (err) {
sendeFehler(res, 'Abgerechnete Arbeitsblätter laden', err)
}
})
export default router
+3
View File
@@ -5,6 +5,7 @@ import { funktionenAdminRouter } from '../admin/funktionenAdmin.js'
import { offersAdminRouter } from '../offers.js'
import { storageTasksAdminRouter } from '../storageTasks.js'
import { takeoverAdminRouter } from '../takeoverCredits.js'
import arbeitsblaetterTeamRoutes from './arbeitsblaetter.js'
import customersRoutes from './customers.js'
import filesRoutes from './files.js'
import previewRoutes from './preview.js'
@@ -31,5 +32,7 @@ router.use('/termine', termineTeamRoutes)
router.use('/shop', shopTeamRoutes)
// Buchungskalender: Stand je Projekt und Entscheidung ueber die Anfrage
router.use('/unterkunft', unterkunftTeamRoutes)
// Abgerechnete Arbeitsblaetter (Abzeichen am Ticket, alle Mitarbeiter)
router.use('/arbeitsblaetter', arbeitsblaetterTeamRoutes)
export default router
+59
View File
@@ -0,0 +1,59 @@
/**
* Ablage der gesendeten AB-PDFs unter config.auftraege.uploadDir (wird bei jedem Aufruf gelesen):
* <uploadDir>/<kundenId>/<auftragId>.pdf, Verzeichnis 0700, Datei 0600. Überschreibt nie (die gesendete Fassung ist
* der Beleg). Kennungen und Pfade sind auf [A-Za-z0-9_-] begrenzt (kein Punkt außer vor „pdf“), damit kein „..“ und
* kein fremder Pfad möglich ist.
*/
import { createHash } from 'node:crypto'
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { config } from '../../config.js'
import { AuftragError } from './fehler.js'
const KENNUNG = /^[A-Za-z0-9_-]{1,64}$/
const PDF_PFAD = /^[A-Za-z0-9_-]{1,64}\/[A-Za-z0-9_-]{1,64}\.pdf$/
const nichtGefunden = () => new AuftragError(404, 'PDF nicht gefunden.')
const gueltigeKennung = (wert) => typeof wert === 'string' && KENNUNG.test(wert)
function pfadVon(pdfDatei) {
if (typeof pdfDatei !== 'string' || !PDF_PFAD.test(pdfDatei)) throw nichtGefunden()
return path.join(config.auftraege.uploadDir, pdfDatei)
}
export async function speicherePdf(kundenId, auftragId, buffer) {
if (!gueltigeKennung(kundenId) || !gueltigeKennung(auftragId)) throw new TypeError('Ungültige Kunden- oder Auftrags-ID für die PDF-Ablage.')
if (!Buffer.isBuffer(buffer) || buffer.length === 0) throw new TypeError('Das PDF muss ein nicht leerer Buffer sein.')
const pdfDatei = `${kundenId}/${auftragId}.pdf`
const datei = path.join(config.auftraege.uploadDir, pdfDatei)
await mkdir(path.dirname(datei), { recursive: true, mode: 0o700 })
try {
await writeFile(datei, buffer, { flag: 'wx', mode: 0o600 })
} catch (err) {
if (err?.code === 'EEXIST') throw new AuftragError(409, 'Das PDF dieser Auftragsbestätigung ist schon gespeichert.')
// eine angelegte Teildatei nicht liegen lassen
await unlink(datei).catch(() => {})
throw err
}
return { pdfDatei, pdfSha256: createHash('sha256').update(buffer).digest('hex') }
}
export async function lesePdf(pdfDatei) {
const datei = pfadVon(pdfDatei)
try {
return await readFile(datei)
} catch (err) {
if (err?.code === 'ENOENT') throw nichtGefunden()
throw err
}
}
/** Räumt ein abgelegtes PDF weg, wenn danach etwas scheitert (L3). Fehlt die Datei, ist das folgenlos. */
export async function entfernePdf(pdfDatei) {
const datei = pfadVon(pdfDatei)
try {
await unlink(datei)
} catch (err) {
if (err?.code !== 'ENOENT') throw err
}
}
@@ -0,0 +1,108 @@
/**
* Arbeitsblätter (Collection worksheets, gehört dem Ticketsystem) für die Projektabrechnung: laden, normieren
* (Berliner Tag, auf 15 Minuten aufgerundete Minuten, kundentauglicher Text) und als abgerechnet erkennen
* (Übersicht V3/V4). „Abgerechnet“ gibt es nur über aktive Projektrechnungen: ein stornierter Eintrag gibt seine
* Blätter frei, ein Entwurf sperrt sie. Abo-Zeit und Kommentare sind nie abrechenbar.
*/
import { config } from '../../config.js'
import { listAllDocuments, Query } from '../appwriteAdmin.js'
import { customerWorkEntries } from '../customerProjectView.js'
import { berlinDate } from '../finance/dates.js'
import { worksheetMinutes } from '../worksheetMinutes.js'
import { VEREINBARTE_STATUS, VIERTELSTUNDE } from './konstanten.js'
import { listProjektRechnungen } from './projektRechnungenStore.js'
const BLOCK = 100 // Werte je equal-Abfrage (Appwrite begrenzt die Werteliste)
const MAX_TEXT = 200
const DE_DATUM = /^(\d{2})\.(\d{2})\.(\d{4})$/
/** Berliner Kalendertag eines Arbeitsblatts; nie ein Wurf. Regel wie parseSheetDate in hourLedger.js. */
function blattDatum(doc) {
const roh = String(doc.startDate || doc.endDate || '').trim()
const de = DE_DATUM.exec(roh)
if (de) {
const [tag, monat, jahr] = [Number(de[1]), Number(de[2]), Number(de[3])]
const pruefung = new Date(Date.UTC(jahr, monat - 1, tag))
if (pruefung.getUTCFullYear() === jahr && pruefung.getUTCMonth() === monat - 1 && pruefung.getUTCDate() === tag) return `${de[3]}-${de[2]}-${de[1]}`
// ein unmögliches Datum (31.02.) ist nie ein Kandidat für new Date: V8 läse „12.13.2026“ als Dezember
}
const kandidaten = [de ? '' : roh, doc.createdAt, doc.$createdAt]
for (const kandidat of kandidaten) {
if (!kandidat) continue
const datum = new Date(kandidat)
if (!Number.isNaN(datum.getTime())) return berlinDate(datum)
}
return ''
}
/** Text wie in der Kundenansicht (customerWorkEntries): eine Zeile, höchstens 200 Zeichen. */
function blattText(doc) {
const [eintrag] = customerWorkEntries([doc], { includeUpdates: true })
if (!eintrag) return ''
const roh = Array.isArray(eintrag.changes) ? eintrag.changes.join('; ') : String(eintrag.text || '')
const text = roh.replace(/\s+/g, ' ').trim()
return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT - 1).trimEnd()}…` : text
}
function docToBlatt(doc) {
return {
id: doc.$id,
ticketId: String(doc.workorderId ?? ''),
woid: String(doc.woid ?? ''),
datum: blattDatum(doc),
minuten: Math.ceil(worksheetMinutes(doc) / VIERTELSTUNDE) * VIERTELSTUNDE,
text: blattText(doc),
aboZeit: doc.countsToPlan === true,
kommentar: doc.isComment === true,
}
}
const sortiert = (blaetter) => blaetter.sort((a, b) => a.datum.localeCompare(b.datum) || a.id.localeCompare(b.id))
async function ladeNach(attribut, werte) {
const liste = Array.isArray(werte) ? [...new Set(werte.filter((wert) => typeof wert === 'string' && wert !== ''))] : []
const blaetter = []
for (let i = 0; i < liste.length; i += BLOCK) {
const docs = await listAllDocuments(config.collections.worksheets, [Query.equal(attribut, liste.slice(i, i + BLOCK))])
blaetter.push(...docs.map(docToBlatt))
}
return sortiert(blaetter)
}
export const ladeArbeitsblaetter = (ticketIds) => ladeNach('workorderId', ticketIds)
/** Liefert nur gefundene Blätter; der Aufrufer vergleicht die Anzahl. */
export const ladeArbeitsblaetterNachIds = (ids) => ladeNach('$id', ids)
export const abrechenbar = (blatt) => !blatt.aboZeit && !blatt.kommentar && blatt.minuten > 0
/** Karte Arbeitsblatt-ID → { invoiceId, rechnungsnummer } aus aktiven Projektrechnungen (Entwurf: Nummer ''). */
export async function abgerechneteArbeitsblaetter(kundenId) {
const karte = new Map()
for (const rechnung of await listProjektRechnungen({ kundenId })) {
for (const id of rechnung.arbeitsblattIds) {
if (!karte.has(id)) karte.set(id, { invoiceId: rechnung.invoiceId, rechnungsnummer: rechnung.rechnungsnummer })
}
}
return karte
}
export async function offeneArbeitsblaetter(ticketIds, kundenId) {
const abgerechnet = await abgerechneteArbeitsblaetter(kundenId)
return (await ladeArbeitsblaetter(ticketIds)).filter((blatt) => abrechenbar(blatt) && !abgerechnet.has(blatt.id))
}
/**
* Zeitquelle eines Projekts: Vereinigung der zeitquelle aller vereinbarten ABs (VEREINBARTE_STATUS), sonst das
* Ticket des Projekts. Der Aufrufer übergibt nur die ABs dieses Projekts; hier wird nicht nach Projekt gefiltert.
*/
export function zeitquelleFuerProjekt(projekt, auftraege) {
const quelle = new Set()
for (const auftrag of Array.isArray(auftraege) ? auftraege : []) {
if (!VEREINBARTE_STATUS.includes(auftrag?.status)) continue
for (const id of auftrag.zeitquelle || []) if (typeof id === 'string' && id !== '') quelle.add(id)
}
if (quelle.size) return [...quelle]
const ticketId = projekt?.ticketId
return typeof ticketId === 'string' && ticketId !== '' ? [ticketId] : []
}
+577
View File
@@ -0,0 +1,577 @@
/**
* Abläufe der Auftragsbestätigung (AB): Eingabeprüfung, Entwurf, Vorschau, Senden, Erinnern, neue Fassung, Stornieren
* und nachträgliches Zuordnen eines Projekts (Spec Abschnitt 2, Übersicht V4 „L3 – AB-Kern“).
* Speicher nur über auftragStore.js und projektRechnungenStore.js, PDFs nur über ablage.js; PDF, Token und Mail kommen
* über `deps` (L4/L5, Standard: dynamischer Import beim Aufruf). Jede Änderung an einer bestehenden AB läuft unter
* sperreAuftrag(id) (konstanten.js), das Senden zusätzlich unter SPERRE_NUMMER; Mails gehen immer erst nach der Sperre.
*/
import { createHash } from 'node:crypto'
import { config } from '../../config.js'
import { getDocument, listDocuments, Query, updateDocument } from '../appwriteAdmin.js'
import { getSettings } from '../finance/settings.js'
import { withLocks } from '../keyedLock.js'
import { kundeSnapshot } from './auftragSicht.js'
import { entfernePdf, lesePdf, speicherePdf } from './ablage.js'
import { createAuftrag, deleteAuftrag, getAuftrag, listAuftraege, updateAuftrag } from './auftragStore.js'
import { AuftragError } from './fehler.js'
import { vergibNummer } from './nummern.js'
import { listProjektRechnungen, umhaengen, updateProjektRechnung } from './projektRechnungenStore.js'
import { TEXTE_FASSUNG } from './texte.js'
import {
AGB_FASSUNG, AUFTRAG_ARTEN, AUFTRAG_WEGE, KUNDENARTEN, LIMITS, SPERRE_NUMMER, sperreAuftrag, STUNDENSATZ_STANDARD_CENTS, VEREINBARTE_STATUS, VIERTELSTUNDE, ZAHLUNGSPLAN_ARTEN,
} from './konstanten.js'
// ---------------------------------------------------------------------------------------------------------------------
// Eingabeprüfung (rein, synchron)
// ---------------------------------------------------------------------------------------------------------------------
/** Appwrite-Dokument-ID (wie in auftragStore.js). */
export function istDokumentId(wert) {
return typeof wert === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(wert)
}
const fehler400 = (meldung) => new AuftragError(400, meldung)
const MELDUNG_POSITIONEN = 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.'
const MELDUNG_ART = 'Art: Bitte Festpreis oder Nach Aufwand wählen.'
const MELDUNG_PLAN = 'Zahlungsplan: Bitte eine Zahlungsart wählen.'
const MELDUNG_VOM = 'Auftrag vom: Bitte ein gültiges Datum angeben (JJJJ-MM-TT).'
const MELDUNG_WEG = 'Weg: Bitte den Weg der Absprache wählen.'
const MELDUNG_SUMME = 'Die Auftragssumme ist zu hoch (höchstens 999.999,99 €).'
const PREIS_GRENZE = 'zwischen 0,01 € und 100.000,00 € liegen.'
const istEinfachesObjekt = (wert) => Object.prototype.toString.call(wert) === '[object Object]'
function istKalendertag(wert) {
if (typeof wert !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(wert)) return false
const datum = new Date(`${wert}T00:00:00Z`)
return !Number.isNaN(datum.getTime()) && datum.toISOString().slice(0, 10) === wert
}
function pruefePosition(roh, n) {
const p = istEinfachesObjekt(roh) ? roh : {}
const beschreibung = typeof p.beschreibung === 'string' ? p.beschreibung.trim() : ''
if (beschreibung.length < 1 || beschreibung.length > LIMITS.maxBeschreibung) {
throw fehler400(`Position ${n}: Die Beschreibung braucht 1 bis ${LIMITS.maxBeschreibung} Zeichen.`)
}
if (!Number.isInteger(p.menge) || p.menge < 1 || p.menge > LIMITS.maxMenge) {
throw fehler400(`Position ${n}: Die Menge muss eine ganze Zahl von 1 bis ${LIMITS.maxMenge} sein.`)
}
if (!Number.isInteger(p.einzelpreisCents) || p.einzelpreisCents < 1 || p.einzelpreisCents > LIMITS.maxEinzelpreisCents) {
throw fehler400(`Position ${n}: Der Einzelpreis muss ${PREIS_GRENZE}`)
}
return { beschreibung, menge: p.menge, einzelpreisCents: p.einzelpreisCents }
}
function pruefePlan(roh) {
if (!istEinfachesObjekt(roh) || !ZAHLUNGSPLAN_ARTEN.includes(roh.art)) throw fehler400(MELDUNG_PLAN)
let prozent = null
let text = ''
if (roh.art === 'anzahlung') {
prozent = roh.prozent === undefined || roh.prozent === null ? LIMITS.standardAnzahlungProzent : roh.prozent
if (!Number.isInteger(prozent) || prozent < 1 || prozent > 99) throw fehler400('Zahlungsplan: Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.')
}
if (roh.art === 'frei') {
text = typeof roh.text === 'string' ? roh.text.trim() : ''
if (text.length < 1 || text.length > LIMITS.maxZahlungsplanText) throw fehler400(`Zahlungsplan: Der freie Text braucht 1 bis ${LIMITS.maxZahlungsplanText} Zeichen.`)
}
return { art: roh.art, prozent, text }
}
function pruefeOptionalesDatum(wert, meldung) {
if (wert === null || wert === '') return ''
if (!istKalendertag(wert)) throw fehler400(meldung)
return wert
}
/** Feldregeln: jedes übergebene Feld (Wert !== undefined) wird geprüft und bereinigt. */
const FELDREGELN = {
art: (w) => {
if (!AUFTRAG_ARTEN.includes(w)) throw fehler400(MELDUNG_ART)
return w
},
projektId: (w) => {
if (w === null || w === '') return ''
if (!istDokumentId(w)) throw fehler400('Projekt: Ungültige Projekt-Kennung.')
return w
},
titel: (w) => {
if (w === null) return ''
if (typeof w !== 'string' || w.trim().length > LIMITS.maxTitel) throw fehler400(`Titel: Höchstens ${LIMITS.maxTitel} Zeichen.`)
return w.trim()
},
kundenart: (w) => {
if (w === null || w === '') return ''
if (!KUNDENARTEN.includes(w)) throw fehler400('Kundenart: Bitte Unternehmen oder Privatperson wählen.')
return w
},
positionen: (w) => {
if (!Array.isArray(w) || w.length > LIMITS.maxPositionen) throw fehler400(MELDUNG_POSITIONEN)
return w.map((p, i) => pruefePosition(p, i + 1))
},
stundensatzCents: (w) => {
if (w === null) return null
if (!Number.isInteger(w) || w < 1 || w > LIMITS.maxEinzelpreisCents) throw fehler400(`Stundensatz: Der Stundensatz muss ${PREIS_GRENZE}`)
return w
},
schaetzungMinuten: (w) => {
if (w === null || w === '' || w === 0) return null
if (!Number.isInteger(w) || w % VIERTELSTUNDE !== 0 || w < VIERTELSTUNDE || w > LIMITS.maxSchaetzungMinuten) {
throw fehler400(`Schätzung: Die Schätzung muss ein Vielfaches von ${VIERTELSTUNDE} Minuten sein (15 bis 99.960).`)
}
return w
},
zahlungsplan: pruefePlan,
zahlungszielTage: (w) => {
if (w === null) return null
if (!Number.isInteger(w) || w < 0 || w > LIMITS.maxZahlungszielTage) {
throw fehler400(`Zahlungsziel: Das Zahlungsziel muss eine ganze Zahl von 0 bis ${LIMITS.maxZahlungszielTage} Tagen sein.`)
}
return w
},
auftragVom: (w) => {
if (!istKalendertag(w)) throw fehler400(MELDUNG_VOM)
return w
},
auftragWeg: (w) => {
if (typeof w !== 'string' || !Object.hasOwn(AUFTRAG_WEGE, w)) throw fehler400(MELDUNG_WEG)
return w
},
startAm: (w) => pruefeOptionalesDatum(w, 'Start: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'),
fertigBis: (w) => pruefeOptionalesDatum(w, 'Fertigstellung: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'),
zeitquelle: (w) => {
if (w === null) return []
if (!Array.isArray(w)) throw fehler400('Zeitquelle: Eine Liste von Ticket-Kennungen erwartet.')
if (!w.every(istDokumentId)) throw fehler400('Zeitquelle: Ungültige Ticket-Kennung.')
const ohneDoppelte = [...new Set(w)]
if (ohneDoppelte.length > LIMITS.maxZeitquellen) throw fehler400(`Zeitquelle: Höchstens ${LIMITS.maxZeitquellen} Tickets.`)
return ohneDoppelte
},
}
const FELD_REIHENFOLGE = Object.keys(FELDREGELN)
/**
* Prüft und bereinigt die Eingabe einer AB. Nur Felder der Whitelist (FELDREGELN) werden übernommen; status, nummer,
* tokenHash & Co. landen nie im Ergebnis. teilweise: nur die übergebenen Felder, ohne Pflicht-, Vorgabe- und Querprüfung.
*/
export function pruefeEingabe(eingabe, { teilweise = false } = {}) {
if (!istEinfachesObjekt(eingabe)) throw fehler400('Ungültige Eingabe.')
const felder = {}
for (const name of FELD_REIHENFOLGE) {
if (eingabe[name] !== undefined) felder[name] = FELDREGELN[name](eingabe[name])
}
if (teilweise) return felder
// Pflichtfelder: fehlt eines, kommt dieselbe Meldung wie bei einem ungültigen Wert
if (felder.art === undefined) throw fehler400(MELDUNG_ART)
if (felder.zahlungsplan === undefined) throw fehler400(MELDUNG_PLAN)
if (felder.auftragVom === undefined) throw fehler400(MELDUNG_VOM)
if (felder.auftragWeg === undefined) throw fehler400(MELDUNG_WEG)
const ergebnis = {
projektId: felder.projektId ?? '',
titel: felder.titel ?? '',
kundenart: felder.kundenart ?? '',
art: felder.art,
positionen: [],
stundensatzCents: null,
schaetzungMinuten: null,
summeCents: null,
zahlungsplan: felder.zahlungsplan,
zahlungszielTage: felder.zahlungszielTage ?? LIMITS.standardZahlungszielTage,
auftragVom: felder.auftragVom,
auftragWeg: felder.auftragWeg,
startAm: felder.startAm ?? '',
fertigBis: felder.fertigBis ?? '',
zeitquelle: felder.zeitquelle ?? [],
}
if (felder.art === 'festpreis') {
if (!felder.positionen || felder.positionen.length === 0) throw fehler400(MELDUNG_POSITIONEN)
const summe = felder.positionen.reduce((s, p) => s + p.menge * p.einzelpreisCents, 0)
if (summe > LIMITS.maxSummeCents) throw fehler400(MELDUNG_SUMME)
if (felder.zahlungsplan.art === 'monatlich') throw fehler400('Zahlungsplan: „Monatlich nach Aufwand“ geht nur bei einem Auftrag nach Aufwand.')
ergebnis.positionen = felder.positionen
ergebnis.summeCents = summe
} else {
if (felder.zahlungsplan.art === 'anzahlung') throw fehler400('Zahlungsplan: Eine Anzahlung geht nur bei einem Festpreis.')
ergebnis.stundensatzCents = felder.stundensatzCents ?? STUNDENSATZ_STANDARD_CENTS
ergebnis.schaetzungMinuten = felder.schaetzungMinuten ?? null
}
return ergebnis
}
// ---------------------------------------------------------------------------------------------------------------------
// Entwurf anlegen, ändern, löschen
// ---------------------------------------------------------------------------------------------------------------------
const hatText = (wert) => typeof wert === 'string' && wert.trim() !== ''
export async function ladeAuftrag(id) {
const auftrag = istDokumentId(id) ? await getAuftrag(id) : null
if (!auftrag) throw new AuftragError(404, 'Auftragsbestätigung nicht gefunden.')
return auftrag
}
export async function ladeKunde(kundenId) {
const nichtGefunden = () => new AuftragError(404, 'Kunde nicht gefunden.')
if (!istDokumentId(kundenId)) throw nichtGefunden()
try {
return await getDocument(config.collections.customers, kundenId)
} catch (err) {
if (err?.status === 404) throw nichtGefunden()
throw err
}
}
/** Die Felder, die die Eingabeprüfung kennt, aus einem gespeicherten Auftrag (ohne kundenId und kundenart). */
function eingabeAusAuftrag(auftrag) {
const { projektId, titel, art, positionen, stundensatzCents, schaetzungMinuten, zahlungsplan, zahlungszielTage, auftragVom, auftragWeg, startAm, fertigBis, zeitquelle } = auftrag
return { projektId, titel, art, positionen, stundensatzCents, schaetzungMinuten, zahlungsplan, zahlungszielTage, auftragVom, auftragWeg, startAm, fertigBis, zeitquelle }
}
/** Das Ticket eines Projekts als Zeitquelle (nie ein Leereintrag). */
function ticketsDesProjekts(projekt) {
return hatText(projekt?.ticketId) ? [projekt.ticketId.trim()] : []
}
async function ladeProjekt(projektId) {
try {
return await getDocument(config.collections.websiteProjects, projektId)
} catch (err) {
if (err?.status === 404) return null
throw err
}
}
/** Projekt und Tickets müssen dem Kunden gehören; liefert die Felder mit aufgelöstem Titel und aufgelöster Zeitquelle. */
async function pruefeBezuege(kundeDoc, felder) {
let titel = felder.titel
let zeitquelle = felder.zeitquelle
let projekt = null
if (felder.projektId) {
projekt = await ladeProjekt(felder.projektId)
if (!projekt || projekt.customerId !== kundeDoc.$id) throw new AuftragError(400, 'Projekt: Das Projekt gehört nicht zu diesem Kunden.')
}
if (!titel) titel = String(projekt?.projectName || '').trim().slice(0, LIMITS.maxTitel).trim()
if (!titel) throw new AuftragError(400, 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.')
if (zeitquelle.length === 0 && projekt) zeitquelle = ticketsDesProjekts(projekt)
if (zeitquelle.length > 0) {
const tickets = await listDocuments(config.collections.workorders, [Query.equal('$id', zeitquelle), Query.limit(LIMITS.maxZeitquellen)])
const gefunden = new Map(tickets.map((t) => [t.$id, t]))
if (!zeitquelle.every((id) => gefunden.get(id)?.customerId === kundeDoc.$id)) {
throw new AuftragError(400, 'Zeitquelle: Mindestens ein Ticket gehört nicht zu diesem Kunden.')
}
}
return { ...felder, titel, zeitquelle }
}
/** Setzt die Kundenart am Kunden, aber nur, wenn dort noch keine steht. */
async function schreibeKundenart(kundeDoc, kundenart) {
if (!kundenart || hatText(kundeDoc.kundenart)) return
await updateDocument(config.collections.customers, kundeDoc.$id, { kundenart })
}
/** Die Felder einer AB, wie createAuftrag und updateAuftrag sie bekommen. */
const speicherFelder = (f) => ({
projektId: f.projektId, titel: f.titel, zeitquelle: f.zeitquelle, art: f.art, positionen: f.positionen,
stundensatzCents: f.stundensatzCents, schaetzungMinuten: f.schaetzungMinuten, summeCents: f.summeCents,
zahlungsplan: f.zahlungsplan, zahlungszielTage: f.zahlungszielTage, auftragVom: f.auftragVom, auftragWeg: f.auftragWeg,
startAm: f.startAm, fertigBis: f.fertigBis,
})
export async function legeEntwurfAn(eingabe, { von } = {}) {
const felder = pruefeEingabe(eingabe)
if (!hatText(eingabe.kundenId)) throw new AuftragError(400, 'Kunde: Bitte einen Kunden angeben.')
const kundeDoc = await ladeKunde(eingabe.kundenId)
const fertig = await pruefeBezuege(kundeDoc, felder)
await schreibeKundenart(kundeDoc, felder.kundenart)
return createAuftrag({ kundenId: eingabe.kundenId, ...speicherFelder(fertig), erstelltVon: String(von || '') })
}
export async function aendereEntwurf(id, eingabe) {
return withLocks(sperreAuftrag(id), async () => {
const auftrag = await ladeAuftrag(id)
if (auftrag.status !== 'entwurf') throw new AuftragError(409, 'Nur ein Entwurf lässt sich ändern.')
if (eingabe?.kundenId !== undefined && eingabe.kundenId !== auftrag.kundenId) {
throw new AuftragError(400, 'Kunde: Der Kunde eines Auftrags lässt sich nicht ändern.')
}
const teil = pruefeEingabe(eingabe, { teilweise: true })
const felder = pruefeEingabe({ ...eingabeAusAuftrag(auftrag), ...teil })
const kundeDoc = await ladeKunde(auftrag.kundenId)
const fertig = await pruefeBezuege(kundeDoc, felder)
await schreibeKundenart(kundeDoc, felder.kundenart)
return updateAuftrag(id, speicherFelder(fertig))
})
}
export async function loescheEntwurf(id) {
return withLocks(sperreAuftrag(id), async () => {
const auftrag = await ladeAuftrag(id)
if (auftrag.status !== 'entwurf') throw new AuftragError(409, 'Nur ein Entwurf lässt sich löschen.')
await deleteAuftrag(id)
})
}
// ---------------------------------------------------------------------------------------------------------------------
// Abhängigkeiten (PDF, Token, Mail) und Vorschau-PDF
// ---------------------------------------------------------------------------------------------------------------------
/** Ins Protokoll nur Art, Status und Code, nie die Meldung (sie kann Adressen oder Pfade enthalten). */
function logFehler(was, err) {
console.error(`[auftraege] ${was} fehlgeschlagen:`, err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '')
}
// PDF, Token und Mail entstehen in L4/L5 parallel: Standard ist ein dynamischer Import beim Aufruf, Tests übergeben Attrappen
const STANDARD_QUELLEN = { erzeugeAuftragPdf: './pdf.js', neuerToken: './token.js', sendeAuftragMail: './mails.js' }
let depsFuerTests = null
/** Nur für Tests: Ersatz für die Standard-Abhängigkeiten (null stellt die dynamischen Importe wieder her). */
export function setzeDepsFuerTests(deps) {
depsFuerTests = deps
}
async function ladeDeps(deps, namen) {
const quelle = { ...depsFuerTests, ...deps }
const geladen = {}
for (const name of namen) geladen[name] = quelle[name] ?? (await import(STANDARD_QUELLEN[name]))[name]
return geladen
}
/** Erzeugt das PDF; ein AuftragError der PDF-Funktion (z. B. 409 „Entwurf unvollständig“) bleibt erhalten. */
async function pdfErzeugen(d, auftrag, optionen) {
let pdf
try {
pdf = await d.erzeugeAuftragPdf(auftrag, optionen)
if (!Buffer.isBuffer(pdf) || pdf.length === 0) throw new Error('leeres PDF')
} catch (err) {
if (err instanceof AuftragError) throw err
logFehler('PDF-Erzeugung', err)
throw new AuftragError(500, 'Das PDF konnte nicht erzeugt werden.')
}
return pdf
}
export async function vorschauPdf(id, { deps } = {}) {
const d = await ladeDeps(deps, ['erzeugeAuftragPdf'])
const auftrag = await ladeAuftrag(id)
if (auftrag.status !== 'entwurf') throw new AuftragError(409, 'Eine Vorschau gibt es nur für Entwürfe.')
const kundeDoc = await ladeKunde(auftrag.kundenId)
const { steuernummer, bankDetailsText } = await getSettings()
return pdfErzeugen(d, { ...auftrag, nummer: 'Entwurf', kunde: kundeSnapshot(kundeDoc) }, { settings: { steuernummer, bankDetailsText }, vorschau: true })
}
// ---------------------------------------------------------------------------------------------------------------------
// Senden
// ---------------------------------------------------------------------------------------------------------------------
/** Prüfreihenfolge: fester Kunde, Kundenart, E-Mail, Anschrift (jeweils mit fester Meldung). */
function pruefeKundeFuerVersand(kundeDoc) {
const status = String(kundeDoc.customerStatus || '').trim().toLowerCase()
if (status === 'lead' || status === 'lost') throw new AuftragError(400, 'Auftragsbestätigungen nur für feste Kunden.')
if (!KUNDENARTEN.includes(String(kundeDoc.kundenart || '').trim())) {
throw new AuftragError(400, 'Bitte zuerst die Kundenart angeben (Unternehmen oder Privatperson).')
}
if (!hatText(kundeDoc.email)) throw new AuftragError(400, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.')
if (!hatText(kundeDoc.street) || !hatText(kundeDoc.postalCode) || !hatText(kundeDoc.city)) {
throw new AuftragError(409, 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.')
}
}
/** Entfernt die eben abgelegte PDF, wenn danach etwas scheiterte – aber nur, wenn die AB nachweislich noch ein Entwurf ist. */
async function raeumePdfAuf(auftragId, pdfDatei) {
try {
const aktuell = await getAuftrag(auftragId)
if (aktuell?.status === 'entwurf') await entfernePdf(pdfDatei)
} catch (err) {
logFehler('PDF-Rest entfernen', err)
}
}
/** Fester Text für mailFehler: nie die Meldung von SMTP oder Bibliothek, höchstens ein kurzer Fehlercode. */
function mailFehlerText(err) {
const code = err?.code
return typeof code === 'string' && /^[A-Za-z0-9_.-]{1,40}$/.test(code)
? `E-Mail konnte nicht gesendet werden (${code}).`
: 'E-Mail konnte nicht gesendet werden.'
}
/** Versendet die K31 mit dem gespeicherten Dokument (nach der Sperre) und hält das Ergebnis in mailStatus fest. */
async function versendeMail(d, gespeichert, { pdf, token, erinnerung = false }) {
let mailPatch
try {
await d.sendeAuftragMail(gespeichert, { pdf, token, erinnerung })
mailPatch = { mailStatus: 'gesendet', mailFehler: '' }
} catch (err) {
logFehler('Mailversand', err)
mailPatch = { mailStatus: 'fehler', mailFehler: mailFehlerText(err) }
}
const final = await updateAuftrag(gespeichert.id, mailPatch)
return { auftrag: final, mailFehler: mailPatch.mailStatus === 'fehler' }
}
const MELDUNG_ERSETZTE_UNGUELTIG = 'Die ersetzte Auftragsbestätigung ist nicht mehr gültig. Bitte eine neue Fassung anlegen.'
/**
* Prüft die AB, die der Entwurf ersetzen soll: gültig sind versendet, bestätigt, abgerechnet oder (bei der Wiederholung
* nach einem Fehler) schon durch genau diesen Entwurf ersetzt. Zeigt ersetztId auf den Entwurf selbst (beschädigte Daten),
* gibt es sofort 409, damit ersetzeAlteAb nicht den schon gehaltenen Schlüssel noch einmal sperrt.
*/
async function pruefeErsetzteAb(entwurf) {
if (entwurf.ersetztId === entwurf.id) throw new AuftragError(409, MELDUNG_ERSETZTE_UNGUELTIG)
const alt = await getAuftrag(entwurf.ersetztId)
const gueltig = alt && (VEREINBARTE_STATUS.includes(alt.status) || (alt.status === 'ersetzt' && alt.ersetztDurchId === entwurf.id))
if (!gueltig) throw new AuftragError(409, MELDUNG_ERSETZTE_UNGUELTIG)
return alt
}
/**
* Ersetzt die alte AB (Status ersetzt + ersetztDurchId) und hängt ihre Projektrechnungen um – vor dem Speichern der neuen
* AB, wiederholbar. Der Stand der alten AB wird unter ihrer eigenen Sperre noch einmal geprüft; ihr tokenHash bleibt.
*/
async function ersetzeAlteAb(entwurf) {
await withLocks(sperreAuftrag(entwurf.ersetztId), async () => {
const alt = await pruefeErsetzteAb(entwurf)
if (alt.status !== 'ersetzt') await updateAuftrag(alt.id, { status: 'ersetzt', ersetztDurchId: entwurf.id })
await umhaengen(alt.id, entwurf.id)
})
}
export async function sendeAuftrag(id, { von, deps, jetzt = new Date() } = {}) {
void von // wird nicht gespeichert (V1 hat kein Feld dafür); die Team-Kopie der Mail belegt den Versand
const d = await ladeDeps(deps, ['erzeugeAuftragPdf', 'neuerToken', 'sendeAuftragMail'])
// Nummernvergabe und Statuswechsel unter einer Sperre: zwei Klicks ergeben eine Nummer, Ändern und Löschen warten
const { gespeichert, pdf, token } = await withLocks([...SPERRE_NUMMER, ...sperreAuftrag(id)], async () => {
const entwurf = await ladeAuftrag(id)
if (entwurf.status !== 'entwurf') throw new AuftragError(409, 'Nur ein Entwurf lässt sich senden.')
pruefeEingabe(eingabeAusAuftrag(entwurf))
const kundeDoc = await ladeKunde(entwurf.kundenId)
pruefeKundeFuerVersand(kundeDoc)
if (entwurf.ersetztId) await pruefeErsetzteAb(entwurf) // früh: vor Nummer und PDF
const kunde = kundeSnapshot(kundeDoc)
const { steuernummer, bankDetailsText } = await getSettings()
const { nummer, jahr, laufnummer } = await vergibNummer(jetzt)
const gesendetAm = jetzt.toISOString()
const zuSenden = { ...entwurf, status: 'versendet', nummer, jahr, laufnummer, kunde, gesendetAm, texteFassung: TEXTE_FASSUNG, agbFassung: AGB_FASSUNG }
const pdfBytes = await pdfErzeugen(d, zuSenden, { settings: { steuernummer, bankDetailsText } })
let ablage
try {
ablage = await speicherePdf(entwurf.kundenId, entwurf.id, pdfBytes)
} catch (err) {
// eine schon vorhandene Datei gehört nicht diesem Aufruf und bleibt unberührt
logFehler('PDF-Ablage', err)
throw new AuftragError(500, 'Das PDF konnte nicht erzeugt werden.')
}
try {
const neu = d.neuerToken(jetzt)
if (entwurf.ersetztId) await ersetzeAlteAb(entwurf)
const gespeichertesDokument = await updateAuftrag(entwurf.id, {
status: 'versendet', nummer, jahr, laufnummer, kunde, gesendetAm, texteFassung: TEXTE_FASSUNG, agbFassung: AGB_FASSUNG,
pdfDatei: ablage.pdfDatei, pdfSha256: ablage.pdfSha256, gesendetAn: kunde.email, tokenHash: neu.hash, tokenGueltigBis: neu.gueltigBis.toISOString(),
})
return { gespeichert: gespeichertesDokument, pdf: pdfBytes, token: neu.token }
} catch (err) {
await raeumePdfAuf(entwurf.id, ablage.pdfDatei)
throw err
}
})
return versendeMail(d, gespeichert, { pdf, token })
}
// ---------------------------------------------------------------------------------------------------------------------
// Erinnern („Erinnerung senden“ und „Erneut senden“)
// ---------------------------------------------------------------------------------------------------------------------
/** Liest die gespeicherte PDF und prüft die SHA-256-Summe: eine veränderte Datei wird nie verschickt oder angezeigt. */
export async function ladeGespeichertesPdf(auftrag) {
if (!hatText(auftrag.pdfDatei)) throw new AuftragError(404, 'PDF nicht gefunden.')
const buffer = await lesePdf(auftrag.pdfDatei)
if (createHash('sha256').update(buffer).digest('hex') !== auftrag.pdfSha256) {
console.error('[auftraege] Prüfsumme der gespeicherten PDF stimmt nicht:', auftrag.id)
throw new AuftragError(500, 'Die gespeicherte PDF-Datei stimmt nicht mit der Prüfsumme überein.')
}
return buffer
}
/**
* Neuer Bestätigungslink für eine versendete AB (der alte gilt ab dann nicht mehr) und erneute Mail mit der gespeicherten
* PDF. Nach einem Mail-Fehler ist es eine gewöhnliche Mail ohne Erinnerungs-Hinweis; gesendetAn wird die aktuelle Adresse.
*/
export async function erinnereAuftrag(id, { deps, jetzt = new Date() } = {}) {
const d = await ladeDeps(deps, ['neuerToken', 'sendeAuftragMail'])
const { gespeichert, pdf, token, erinnerung } = await withLocks(sperreAuftrag(id), async () => {
const alt = await ladeAuftrag(id)
if (alt.status !== 'versendet') throw new AuftragError(409, 'Eine Erinnerung gibt es nur für versendete Auftragsbestätigungen.')
const kundeDoc = await ladeKunde(alt.kundenId)
if (!hatText(kundeDoc.email)) throw new AuftragError(400, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.')
// vor dem Token-Tausch: eine beschädigte Datei darf den alten Link nicht entwerten
const pdfBytes = await ladeGespeichertesPdf(alt)
const neu = d.neuerToken(jetzt)
const dokument = await updateAuftrag(id, { tokenHash: neu.hash, tokenGueltigBis: neu.gueltigBis.toISOString(), gesendetAn: kundeDoc.email.trim() })
return { gespeichert: dokument, pdf: pdfBytes, token: neu.token, erinnerung: alt.mailStatus !== 'fehler' }
})
return versendeMail(d, gespeichert, { pdf, token, erinnerung })
}
// ---------------------------------------------------------------------------------------------------------------------
// Neue Fassung
// ---------------------------------------------------------------------------------------------------------------------
/** Legt aus einer versendeten, bestätigten oder abgerechneten AB einen neuen Entwurf an (ersetztId); die alte bleibt. */
export async function neueFassung(id, { von } = {}) {
return withLocks(sperreAuftrag(id), async () => {
const alt = await ladeAuftrag(id)
if (!VEREINBARTE_STATUS.includes(alt.status)) {
throw new AuftragError(409, 'Eine neue Fassung gibt es nur für versendete, bestätigte oder abgerechnete Aufträge.')
}
const vorhandene = await listAuftraege({ kundenId: alt.kundenId })
if (vorhandene.some((a) => a.status === 'entwurf' && a.ersetztId === alt.id)) {
throw new AuftragError(409, 'Zu dieser Auftragsbestätigung gibt es schon einen Entwurf für eine neue Fassung.')
}
return createAuftrag({ kundenId: alt.kundenId, ...speicherFelder(alt), ersetztId: alt.id, erstelltVon: String(von || '') })
})
}
// ---------------------------------------------------------------------------------------------------------------------
// Stornieren
// ---------------------------------------------------------------------------------------------------------------------
/** Storniert eine versendete oder bestätigte AB (ohne Mail); tokenHash, Nummer und PDF bleiben, der Link erklärt „storniert“. */
export async function storniereAuftrag(id) {
return withLocks(sperreAuftrag(id), async () => {
const auftrag = await ladeAuftrag(id) // der Stand wird in der Sperre gelesen
if (auftrag.status !== 'versendet' && auftrag.status !== 'bestaetigt') {
throw new AuftragError(409, 'Nur versendete oder bestätigte Auftragsbestätigungen lassen sich stornieren.')
}
return updateAuftrag(id, { status: 'storniert', storniertAm: new Date().toISOString() })
})
}
// ---------------------------------------------------------------------------------------------------------------------
// Projekt nachträglich zuordnen
// ---------------------------------------------------------------------------------------------------------------------
/**
* Ordnet einer AB ohne Projekt nachträglich ein Projekt des Kunden zu: setzt projektId, bei leerer Zeitquelle das Ticket des
* Projekts, und hängt die Projektrechnungen der AB mit leerer projektId um (Stripe bleibt unverändert). PDF, Nummer, Status
* und Token bleiben. Zuerst die Projektrechnungen, zuletzt die AB: scheitert etwas dazwischen, bleibt die AB ohne Projekt
* und die Wiederholung führt alles zu Ende.
*/
export async function ordneProjektZu(id, projektId) {
if (!hatText(projektId)) throw new AuftragError(400, 'Projekt: Bitte ein Projekt angeben.')
if (!istDokumentId(projektId)) throw new AuftragError(400, 'Projekt: Ungültige Projekt-Kennung.')
return withLocks(sperreAuftrag(id), async () => {
const auftrag = await ladeAuftrag(id)
if (['storniert', 'ersetzt', 'widerrufen'].includes(auftrag.status)) {
throw new AuftragError(409, 'Einer stornierten, ersetzten oder widerrufenen Auftragsbestätigung lässt sich kein Projekt mehr zuordnen.')
}
if (hatText(auftrag.projektId)) throw new AuftragError(409, 'Diese Auftragsbestätigung gehört schon zu einem Projekt.')
const projekt = await ladeProjekt(projektId)
if (!projekt || projekt.customerId !== auftrag.kundenId) throw new AuftragError(400, 'Projekt: Das Projekt gehört nicht zu diesem Kunden.')
const rechnungen = await listProjektRechnungen({ auftragId: id, nurAktiv: false })
for (const rechnung of rechnungen) {
if (rechnung.projektId === '') await updateProjektRechnung(rechnung.invoiceId, { projektId })
}
const patch = { projektId }
if (auftrag.zeitquelle.length === 0) patch.zeitquelle = ticketsDesProjekts(projekt)
return updateAuftrag(id, patch)
})
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Sichten auf eine Auftragsbestätigung (Übersicht V3/V4): Kundensnapshot, früheste Leistung bei Privatpersonen,
* Zahlungsplan- und Aufwandstext, Ansicht fürs Team und öffentliche Ansicht für Token- und Kundenrouten.
* Alles rein (kein Appwrite, keine Konfiguration); die Wortlaute kommen aus texte.js.
*/
import { withdrawalDeadline } from '../deadlines.js'
import { addDays, berlinDate } from '../finance/dates.js'
import { euroDe } from '../finance/format.js'
import { AGB_FASSUNG, KUNDENARTEN, LIMITS, STUNDENSATZ_STANDARD_CENTS } from './konstanten.js'
import { AB_TEXTE, BESTAETIGUNG_TEXT, FRUEHBEGINN_TEXT } from './texte.js'
// Kürzungen, damit der Snapshot samt JSON in die Spalte kundeJson (2000 Zeichen) passt
const SNAPSHOT_FELDER = [
['name', 'name', 120], ['firma', 'companyName', 120], ['strasse', 'street', 120], ['plz', 'postalCode', 20],
['ort', 'city', 80], ['land', 'country', 60], ['email', 'email', 200], ['kundennummer', 'code', 40],
]
const text = (wert, max) => (typeof wert === 'string' ? wert.trim().slice(0, max).trim() : '')
export function kundeSnapshot(customerDoc) {
const doc = customerDoc || {}
const snapshot = {}
for (const [ziel, quelle, max] of SNAPSHOT_FELDER) snapshot[ziel] = text(doc[quelle], max)
snapshot.kundenart = KUNDENARTEN.includes(doc.kundenart) ? doc.kundenart : ''
// feste Reihenfolge der Schlüssel wie KundeSnapshot in V3
const { name, firma, strasse, plz, ort, land, email, kundenart, kundennummer } = snapshot
return { name, firma, strasse, plz, ort, land, email, kundenart, kundennummer }
}
/** Früheste Leistung bei Privatpersonen ohne Frühbeginn: Tag nach Ablauf der Widerrufsfrist. */
export function fruehesterBeginn(auftrag) {
if (auftrag.kunde?.kundenart !== 'privat' || auftrag.fruehbeginn || !auftrag.gesendetAm) return null
const gesendet = new Date(auftrag.gesendetAm)
if (Number.isNaN(gesendet.getTime())) return null
return addDays(withdrawalDeadline(gesendet).lastDay, 1)
}
export function zahlungsplanText(auftrag) {
const ziel = Number.isSafeInteger(auftrag.zahlungszielTage) ? auftrag.zahlungszielTage : LIMITS.standardZahlungszielTage
const plan = auftrag.zahlungsplan || { art: 'nach_abnahme' }
const bausteine = AB_TEXTE.zahlungsplan
if (plan.art === 'monatlich') return bausteine.monatlich({ ziel })
if (plan.art === 'frei') return bausteine.frei({ text: plan.text || '', ziel })
if (plan.art === 'anzahlung' && Number.isSafeInteger(auftrag.summeCents) && typeof plan.prozent === 'number' && Number.isFinite(plan.prozent)) {
const anzahlung = Math.round((auftrag.summeCents * plan.prozent) / 100)
return bausteine.anzahlung({ prozent: plan.prozent, anzahlung: euroDe(anzahlung), rest: euroDe(auftrag.summeCents - anzahlung), ziel })
}
return bausteine.nach_abnahme({ ziel })
}
/** Stunden als Text mit Dezimalkomma ohne überflüssige Nullen: 600 → '10', 135 → '2,25', 90 → '1,5'. */
const stundenText = (minuten) => String(Math.round((minuten / 60) * 100) / 100).replace('.', ',')
export function aufwandText(auftrag) {
if (auftrag.art !== 'aufwand') return null
const minuten = auftrag.schaetzungMinuten
return AB_TEXTE.aufwand({
satz: euroDe(auftrag.stundensatzCents ?? STUNDENSATZ_STANDARD_CENTS),
schaetzung: Number.isSafeInteger(minuten) && minuten > 0 ? stundenText(minuten) : null,
})
}
/** Auftrag ohne tokenHash, pdfDatei und bestaetigtPdfSha256, plus die abgeleiteten Felder. */
export function auftragFuerTeam(auftrag) {
const { tokenHash, pdfDatei, bestaetigtPdfSha256, ...rest } = auftrag
return {
...rest,
pdfVorhanden: Boolean(pdfDatei),
fruehesterBeginn: fruehesterBeginn(auftrag),
zahlungsplanText: zahlungsplanText(auftrag),
aufwandText: aufwandText(auftrag),
}
}
/** Nur für Token- und Kundenrouten: keine Kundendaten, kein Token, keine Pfade. */
export function oeffentlicheAnsicht(auftrag) {
const kundenart = auftrag.kunde?.kundenart || ''
return {
nummer: auftrag.nummer,
datum: auftrag.gesendetAm ? berlinDate(new Date(auftrag.gesendetAm)) : null,
titel: auftrag.titel,
art: auftrag.art,
positionen: auftrag.positionen.map((p) => ({
beschreibung: p.beschreibung, menge: p.menge, einzelpreisCents: p.einzelpreisCents, summeCents: p.menge * p.einzelpreisCents,
})),
summeCents: auftrag.summeCents,
stundensatzCents: auftrag.stundensatzCents,
schaetzungMinuten: auftrag.schaetzungMinuten,
aufwandText: aufwandText(auftrag),
zahlungsplanText: zahlungsplanText(auftrag),
startAm: auftrag.startAm,
fertigBis: auftrag.fertigBis,
kundenart,
status: auftrag.status,
bestaetigtAm: auftrag.bestaetigtAm,
fruehbeginn: auftrag.fruehbeginn,
fruehesterBeginn: fruehesterBeginn(auftrag),
agbUrl: `/rechtliches/agb-${auftrag.agbFassung || AGB_FASSUNG}.pdf`,
texte: {
bestaetigung: BESTAETIGUNG_TEXT,
fruehbeginn: kundenart === 'privat' ? FRUEHBEGINN_TEXT : null,
kleinunternehmer: AB_TEXTE.kleinunternehmer,
},
}
}
+203
View File
@@ -0,0 +1,203 @@
/**
* Speicher der Auftragsbestätigungen (Collection auftraege, Übersicht V1/V3/V4).
* Die Feldlisten stammen aus AUFTRAEGE_COLLECTIONS (Typ und Größe der Spalten); nur vier Spalten sind JSON und
* heißen im Auftrag anders: kundeJson = kunde, zeitquelleJson = zeitquelle, positionenJson = positionen,
* zahlungsplanJson = zahlungsplan. Programmfehler (falsche Typen, ungültige Filter) sind TypeError, ein zu langer
* Text ist ein AuftragError 400. Schreibende Aufrufer halten bei Statusänderungen sperreAuftrag(id) (konstanten.js).
*/
import { config } from '../../config.js'
import { createDocument, deleteDocument, getDocument, listAllDocuments, listDocuments, Query, updateDocument } from '../appwriteAdmin.js'
import { AuftragError } from './fehler.js'
import { LIMITS, VEREINBARTE_STATUS, ZAHLUNGSPLAN_ARTEN } from './konstanten.js'
import { AUFTRAEGE_COLLECTIONS } from './schema.js'
const JSON_SPALTEN = { kundeJson: 'kunde', zeitquelleJson: 'zeitquelle', positionenJson: 'positionen', zahlungsplanJson: 'zahlungsplan' }
const SPALTE_ZU_FELD = (spalte) => JSON_SPALTEN[spalte] || spalte
const SPALTEN = AUFTRAEGE_COLLECTIONS.auftraege.attributes
const TEXT_SPALTEN = SPALTEN.filter((a) => a.type === 'string' && !JSON_SPALTEN[a.key])
const ZAHL_SPALTEN = SPALTEN.filter((a) => a.type === 'integer').map((a) => a.key)
const DATUM_SPALTEN = SPALTEN.filter((a) => a.type === 'datetime').map((a) => a.key)
const SPALTEN_NACH_FELD = new Map(SPALTEN.map((a) => [SPALTE_ZU_FELD(a.key), a]))
// Appwrite-Regel für Dokument-IDs
const DOKUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
const gueltigeId = (id) => typeof id === 'string' && DOKUMENT_ID.test(id)
const TOKEN_HASH = /^[0-9a-f]{64}$/
const AB_NUMMER = /^AB-\d{4}-\d{4}$/
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
function parseJson(text) {
if (typeof text !== 'string' || text === '') return undefined
try {
return JSON.parse(text)
} catch {
return undefined
}
}
function zeitpunkt(wert) {
if (wert === null || wert === undefined || wert === '') return null
const datum = new Date(wert)
return Number.isNaN(datum.getTime()) ? null : datum.toISOString()
}
export function docToAuftrag(doc) {
const auftrag = { id: doc.$id }
for (const { key } of TEXT_SPALTEN) auftrag[key] = typeof doc[key] === 'string' ? doc[key] : ''
for (const key of ZAHL_SPALTEN) auftrag[key] = Number.isSafeInteger(doc[key]) ? doc[key] : null
auftrag.zahlungszielTage = Number.isSafeInteger(doc.zahlungszielTage) ? doc.zahlungszielTage : LIMITS.standardZahlungszielTage
auftrag.fruehbeginn = doc.fruehbeginn === true
for (const key of DATUM_SPALTEN) auftrag[key] = zeitpunkt(doc[key])
auftrag.erstelltAm = zeitpunkt(doc.erstelltAm) ?? zeitpunkt(doc.$createdAt)
auftrag.geaendertAm = zeitpunkt(doc.geaendertAm) ?? zeitpunkt(doc.$updatedAt)
const kunde = parseJson(doc.kundeJson)
auftrag.kunde = istObjekt(kunde) ? kunde : null
const zeitquelle = parseJson(doc.zeitquelleJson)
auftrag.zeitquelle = Array.isArray(zeitquelle)
? zeitquelle.filter((id) => typeof id === 'string' && id.trim() !== '').map((id) => id.trim())
: []
const positionen = parseJson(doc.positionenJson)
auftrag.positionen = Array.isArray(positionen) ? positionen.filter(istObjekt) : []
const plan = parseJson(doc.zahlungsplanJson)
auftrag.zahlungsplan = istObjekt(plan)
? {
art: ZAHLUNGSPLAN_ARTEN.includes(plan.art) ? plan.art : 'nach_abnahme',
prozent: typeof plan.prozent === 'number' && Number.isFinite(plan.prozent) ? plan.prozent : null,
text: typeof plan.text === 'string' ? plan.text : '',
}
: { art: 'nach_abnahme', prozent: null, text: '' }
return auftrag
}
function pruefeLaenge(feld, text, spalte) {
if (text.length > spalte.size) throw new AuftragError(400, `Das Feld ${feld} ist zu lang.`)
return text
}
/**
* Wandelt nur die in `auftrag` vorhandenen Schlüssel um (auch teilweise Objekte, für updateAuftrag): id, $… und
* fremde Schlüssel bleiben weg, undefined fehlt, null löscht (Text → '', Zahl und Datum → null).
*/
export function auftragToDoc(auftrag) {
const doc = {}
for (const [feld, wert] of Object.entries(auftrag)) {
const spalte = SPALTEN_NACH_FELD.get(feld)
if (!spalte || wert === undefined) continue
const name = spalte.key
if (JSON_SPALTEN[name]) {
doc[name] = wert === null ? '' : pruefeLaenge(feld, JSON.stringify(wert), spalte)
} else if (spalte.type === 'string') {
if (wert === null) doc[name] = ''
else if (typeof wert === 'string') doc[name] = pruefeLaenge(feld, wert, spalte)
else throw new TypeError(`Auftrag.${feld} muss Text sein.`)
} else if (spalte.type === 'integer') {
if (wert !== null && !Number.isSafeInteger(wert)) throw new TypeError(`Auftrag.${feld} muss eine ganze Zahl oder null sein.`)
doc[name] = wert
} else if (spalte.type === 'boolean') {
if (typeof wert !== 'boolean') throw new TypeError(`Auftrag.${feld} muss ein Wahrheitswert sein.`)
doc[name] = wert
} else if (spalte.type === 'datetime') {
if (wert === null) doc[name] = null
else {
const iso = typeof wert === 'string' && wert !== '' ? zeitpunkt(wert) : wert instanceof Date ? zeitpunkt(wert) : null
if (!iso) throw new TypeError(`Auftrag.${feld} muss ein Zeitpunkt oder null sein.`)
doc[name] = iso
}
}
}
return doc
}
const nichtGefunden = () => new AuftragError(404, 'Auftragsbestätigung nicht gefunden.')
export async function getAuftrag(id) {
if (!gueltigeId(id)) return null
try {
return docToAuftrag(await getDocument(config.collections.auftraege, id))
} catch (err) {
if (err?.status === 404) return null
throw err
}
}
function filterAbfragen(filter, { text = [], zahl = [] }) {
const abfragen = []
for (const key of text) {
if (filter[key] === undefined) continue
if (typeof filter[key] !== 'string' || filter[key] === '') throw new TypeError(`Filter ${key} muss ein nicht leerer Text sein.`)
abfragen.push(Query.equal(key, filter[key]))
}
for (const key of zahl) {
if (filter[key] === undefined) continue
if (!Number.isSafeInteger(filter[key])) throw new TypeError(`Filter ${key} muss eine ganze Zahl sein.`)
abfragen.push(Query.equal(key, filter[key]))
}
return abfragen
}
/** Neueste zuerst. Ein angegebener, aber leerer oder ungültiger Filter ist ein TypeError, nie „alle“. */
export async function listAuftraege(filter = {}) {
const abfragen = filterAbfragen(filter, { text: ['kundenId', 'projektId'], zahl: ['jahr'] })
const docs = await listAllDocuments(config.collections.auftraege, abfragen)
return docs
.map(docToAuftrag)
.sort((a, b) => (b.erstelltAm || '').localeCompare(a.erstelltAm || '') || b.id.localeCompare(a.id))
}
export async function createAuftrag(daten) {
const jetzt = new Date().toISOString()
const doc = auftragToDoc({ ...daten, status: 'entwurf', erstelltAm: jetzt, geaendertAm: jetzt })
return docToAuftrag(await createDocument(config.collections.auftraege, doc))
}
export async function updateAuftrag(id, patch) {
if (!gueltigeId(id)) throw nichtGefunden()
const doc = auftragToDoc({ ...patch, geaendertAm: new Date().toISOString() })
try {
return docToAuftrag(await updateDocument(config.collections.auftraege, id, doc))
} catch (err) {
if (err?.status === 404) throw nichtGefunden()
throw err
}
}
export async function deleteAuftrag(id) {
if (!gueltigeId(id)) return
try {
await deleteDocument(config.collections.auftraege, id)
} catch (err) {
if (err?.status !== 404) throw err
}
}
async function findeEine(feld, wert) {
const docs = await listDocuments(config.collections.auftraege, [Query.equal(feld, wert), Query.limit(1)])
return docs.length ? docToAuftrag(docs[0]) : null
}
/** Nur ein 64-stelliger Kleinbuchstaben-Hex-Hash führt zu einer Abfrage (ein leerer Hash träfe jeden Entwurf). */
export async function findAuftragByTokenHash(hash) {
if (typeof hash !== 'string' || !TOKEN_HASH.test(hash)) return null
return findeEine('tokenHash', hash)
}
export async function findAuftragByNummer(nummer) {
if (typeof nummer !== 'string' || !AB_NUMMER.test(nummer)) return null
return findeEine('nummer', nummer)
}
const sendezeit = (auftrag) => new Date(auftrag.gesendetAm || auftrag.erstelltAm || 0).getTime() || 0
/** Jüngste zuerst nach Sendezeitpunkt (ersatzweise Anlagezeit), bei Gleichstand nach Kennung. */
export const juengsteZuerst = (a, b) => sendezeit(b) - sendezeit(a) || String(b.id).localeCompare(String(a.id))
/**
* Die Aufwands-AB, mit der ein Projekt abgerechnet und angezeigt wird: unter den gültigen (versendet, bestätigt) die jüngste
* nach gesendetAm, sonst null. Projektkonto und Rechnungsbau benutzen beide diese eine Auswahl.
*/
export function juengsteAufwandAb(liste) {
return liste.filter((ab) => ab.art === 'aufwand' && VEREINBARTE_STATUS.includes(ab.status)).sort(juengsteZuerst)[0] ?? null
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Bestätigen und Widerrufen einer Auftragsbestätigung (Übersicht V2/V4, Strang L5): idempotent, unter der Sperre `sperreAuftrag(id)`,
* Stand innerhalb der Sperre neu gelesen. K32 und T22 gehen erst nach der Freigabe der Sperre raus; Mailfehler stehen
* nur im Protokoll. Die erste Bestätigung zählt, jede weitere ändert nichts.
*/
import { withdrawalDeadline } from '../deadlines.js'
import { withLocks } from '../keyedLock.js'
import { getAuftrag, updateAuftrag } from './auftragStore.js'
import { AuftragError } from './fehler.js'
import { sperreAuftrag } from './konstanten.js'
import { sendeBestaetigtMails } from './mails.js'
import { FRUEHBEGINN_FASSUNG } from './texte.js'
export const GESPERRT_MELDUNG = {
ersetzt: 'Diese Auftragsbestätigung wurde ersetzt.',
storniert: 'Diese Auftragsbestätigung wurde storniert.',
widerrufen: 'Diese Auftragsbestätigung wurde widerrufen.',
}
export const UNGUELTIGER_LINK = 'Dieser Link ist ungültig oder abgelaufen.'
const NICHT_BESTAETIGBAR = 'Diese Auftragsbestätigung kann nicht bestätigt werden.'
const NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.'
const GESPERRT_STATUS = ['entwurf', 'ersetzt', 'storniert', 'widerrufen']
/** 'offen' (bestätigbar), 'bestaetigt' (schon bestätigt) oder 'gesperrt' (Entwurf, ersetzt, storniert, widerrufen). */
export function bestaetigungsLage(auftrag) {
if (GESPERRT_STATUS.includes(auftrag.status)) return 'gesperrt'
return auftrag.bestaetigtAm || auftrag.status === 'bestaetigt' ? 'bestaetigt' : 'offen'
}
function pruefeZeitpunkt(jetzt) {
if (!(jetzt instanceof Date) || Number.isNaN(jetzt.getTime())) throw new TypeError('jetzt muss ein gültiges Date sein')
}
export async function bestaetigeAuftrag(auftrag, { ueber, fruehbeginn, jetzt = new Date() } = {}) {
// synchron vor der Sperre: der zuerst gestartete Aufruf bekommt die Sperre zuerst
if (ueber !== 'link' && ueber !== 'portal') throw new TypeError('bestaetigeAuftrag: ueber muss „link“ oder „portal“ sein')
pruefeZeitpunkt(jetzt)
const { aktuell, bestaetigtJetzt } = await withLocks(sperreAuftrag(auftrag.id), async () => {
const frisch = await getAuftrag(auftrag.id)
if (!frisch) throw new AuftragError(404, NICHT_GEFUNDEN)
const lage = bestaetigungsLage(frisch)
if (lage === 'gesperrt') throw new AuftragError(409, GESPERRT_MELDUNG[frisch.status] ?? NICHT_BESTAETIGBAR)
if (ueber === 'link') {
// Die Route hat Token und Ablauf auf einem Stand ausserhalb der Sperre geprueft; eine Erinnerung (Token-Tausch) kann
// dazwischen gelaufen sein: dann gilt der alte Link nicht mehr, wie bei jedem ungueltigen Link (404)
const bis = frisch.tokenGueltigBis ? Date.parse(frisch.tokenGueltigBis) : Number.NaN
if (!frisch.tokenHash || frisch.tokenHash !== auftrag.tokenHash || !Number.isFinite(bis) || jetzt.getTime() > bis) {
throw new AuftragError(404, UNGUELTIGER_LINK)
}
}
if (lage === 'bestaetigt') return { aktuell: frisch, bestaetigtJetzt: false }
const privat = frisch.kunde?.kundenart === 'privat'
const patch = {
bestaetigtAm: jetzt.toISOString(),
bestaetigtUeber: ueber,
bestaetigtPdfSha256: frisch.pdfSha256,
fruehbeginn: privat && fruehbeginn === true,
fruehbeginnFassung: privat ? FRUEHBEGINN_FASSUNG : '',
}
if (frisch.status === 'versendet') patch.status = 'bestaetigt'
return { aktuell: await updateAuftrag(frisch.id, patch), bestaetigtJetzt: true }
})
if (bestaetigtJetzt) await sendeBestaetigtMails(aktuell)
return aktuell
}
/**
* Widerruf einer Privatperson innerhalb der Frist (V2: versendet|bestaetigt → widerrufen), unter derselben Sperre.
* Verschickt selbst keine Mails (das tut abo.js nach der Rückkehr, also nach der Freigabe der Sperre).
* Ergebnis: 'widerrufen' | 'bereits_widerrufen' | 'kein_widerrufsrecht' | 'nach_frist' | 'nicht_widerrufbar'.
*/
export async function widerrufeAuftrag(auftrag, { jetzt = new Date() } = {}) {
pruefeZeitpunkt(jetzt)
return withLocks(sperreAuftrag(auftrag.id), async () => {
const frisch = await getAuftrag(auftrag.id)
if (!frisch) throw new AuftragError(404, NICHT_GEFUNDEN)
const fristEnde = frisch.gesendetAm ? withdrawalDeadline(new Date(frisch.gesendetAm)).endsAt : null
const ergebnis = (e, a = frisch) => ({ auftrag: a, ergebnis: e, fristEnde })
if (frisch.status === 'widerrufen') return ergebnis('bereits_widerrufen')
if (frisch.kunde?.kundenart !== 'privat') return ergebnis('kein_widerrufsrecht')
if (!['versendet', 'bestaetigt'].includes(frisch.status) || fristEnde === null) return ergebnis('nicht_widerrufbar')
if (jetzt.getTime() > fristEnde.getTime()) return ergebnis('nach_frist')
return ergebnis('widerrufen', await updateAuftrag(frisch.id, { status: 'widerrufen', widerrufenAm: jetzt.toISOString() }))
})
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Fehlerklasse der Aufträge-Module und einheitliche Fehlerantwort der Routen.
* AuftragError nur für Fälle, die ein Nutzer verstehen soll (404, 409, 400 bei zu langem Text); Programmfehler
* (ungültige Kennungen oder Typen) sind TypeError und enden als 500.
*/
import { InvoiceError } from '../finance/stripeFinance.js'
export class AuftragError extends Error {
constructor(status, message) {
if (!Number.isInteger(status) || status < 400 || status > 599) {
throw new TypeError('AuftragError: Status muss eine ganze Zahl von 400 bis 599 sein.')
}
super(message)
this.name = 'AuftragError'
this.status = status
}
}
/**
* Antwortet auf einen Fehler: AuftragError und InvoiceError mit Status und Meldung, alles andere (auch Fehler mit
* Status von Appwrite oder Stripe) als 500 „Interner Fehler.“. Das Protokoll nennt nie die Meldung, nur die Art.
* InvoiceError wird nur in der Funktion benutzt, nie beim Laden: stripeFinance.js importiert ab L2 selbst Module
* dieses Verzeichnisses, so bleibt der Importzyklus harmlos.
*/
export function sendeFehler(res, was, err) {
if (err instanceof AuftragError || err instanceof InvoiceError) {
return res.status(err.status).json({ error: err.message })
}
console.error(`[auftraege] ${was} fehlgeschlagen:`, err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '')
return res.status(500).json({ error: 'Interner Fehler.' })
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Aufträge und Rechnungen: gemeinsame Konstanten (Übersicht V2).
* Gemeinsame Datei: nur L0 ändert sie (Ausnahme: die eine Zeile AGB_FASSUNG, die L7 anpassen darf).
*/
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.'
+167
View File
@@ -0,0 +1,167 @@
/**
* Mails zur Auftragsbestätigung (Übersicht V4/V6, Strang L5): K31 (AB mit PDF und AGB an den Kunden, Teamkopie ohne
* Link), K32 (Eingangsbestätigung an den Kunden) und T22 (Hinweis ans Team). Der Bestätigungslink steht nur in der
* Mail an den Kunden: K31 geht ohne BCC raus, das Team bekommt eine Kopie ohne Link, Token und Anhänge.
* Kein Datenbankzugriff (außer lesePdf als Rückfall für die AB-PDF).
*/
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { config } from '../../config.js'
import { berlinDate, formatDe } from '../finance/dates.js'
import { euroDe } from '../finance/format.js'
import { sendAdminNotice, sendCustomerMail } from '../mailer.js'
import { lesePdf } from './ablage.js'
import { fruehesterBeginn } from './auftragSicht.js'
import { AGB_FASSUNG } from './konstanten.js'
import { FRUEHBEGINN_TEXT } from './texte.js'
import { hashToken } from './token.js'
// server/services/auftraege/ -> drei Ebenen nach oben = Repo
const STANDARD_AGB_VERZEICHNIS = fileURLToPath(new URL('../../../public/rechtliches/', import.meta.url))
let agbVerzeichnis = STANDARD_AGB_VERZEICHNIS
const FASSUNG_FORMAT = /^[A-Za-z0-9._-]{1,20}$/
/** Nur für Tests: anderes Verzeichnis für die AGB-PDFs (`null` = Vorgabe). */
export function setzeAgbVerzeichnisFuerTests(verzeichnis) {
agbVerzeichnis = verzeichnis || STANDARD_AGB_VERZEICHNIS
}
export function agbPdfPfad(fassung = AGB_FASSUNG) {
if (typeof fassung !== 'string' || !FASSUNG_FORMAT.test(fassung) || fassung.includes('..')) throw new TypeError('agbPdfPfad: ungültige AGB-Fassung')
return path.join(agbVerzeichnis, `agb-${fassung}.pdf`)
}
// Betreff-Zusaetze der K31 (die Vorlage selbst bleibt fuer alle Faelle gleich)
const ERINNERUNG_PRAEFIX = 'Erinnerung: '
const KOPIE_PRAEFIX = 'Kopie: '
const zeitFormat = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
function zeitpunkt(iso) {
const datum = new Date(iso)
if (!iso || Number.isNaN(datum.getTime())) throw new TypeError('Zeitpunkt fehlt oder ist ungültig')
return datum
}
const tagText = (iso) => formatDe(berlinDate(zeitpunkt(iso)))
const zeitpunktText = (iso) => `${tagText(iso)}, ${zeitFormat.format(zeitpunkt(iso))}`
const preisText = (auftrag) => (auftrag.art === 'aufwand' ? `${euroDe(auftrag.stundensatzCents)} pro Stunde` : euroDe(auftrag.summeCents))
const empfaenger = (auftrag) => String(auftrag.gesendetAn || auftrag.kunde?.email || '').trim()
export function k31Mail(auftrag, { token, erinnerung = false, teamkopie = false } = {}) {
const kunde = auftrag.kunde ?? {}
return {
template: 'K31',
values: {
name: kunde.name,
nummer: auftrag.nummer,
titel: auftrag.titel,
datum: tagText(auftrag.gesendetAm),
betrag: preisText(auftrag),
// die Teamkopie enthält den Link nie, auch wenn ein Token mitgegeben wird
link: teamkopie ? '' : `${config.publicBaseUrl}/auftrag.html#t=${token}`,
gueltig_bis: tagText(auftrag.tokenGueltigBis),
},
blocks: {
erinnerung: Boolean(erinnerung),
privat: kunde.kundenart === 'privat' && !teamkopie,
festpreis: auftrag.art === 'festpreis',
aufwand: auftrag.art === 'aufwand',
teamkopie: Boolean(teamkopie),
kundenmail: !teamkopie,
},
}
}
function frueherBeginn(auftrag) {
const privat = auftrag.kunde?.kundenart === 'privat'
const tag = fruehesterBeginn(auftrag)
return { privat, fruehbeginn: privat && Boolean(auftrag.fruehbeginn), ohne: privat && !auftrag.fruehbeginn, beginnText: tag ? formatDe(tag) : '' }
}
export function k32Mail(auftrag) {
const kunde = auftrag.kunde ?? {}
const f = frueherBeginn(auftrag)
return {
template: 'K32',
values: {
name: kunde.name,
nummer: auftrag.nummer,
titel: auftrag.titel,
zeitpunkt: zeitpunktText(auftrag.bestaetigtAm),
fruehester_beginn: f.beginnText,
fruehbeginn_text: f.fruehbeginn ? FRUEHBEGINN_TEXT : '',
},
blocks: { fruehbeginn: f.fruehbeginn, ohne_fruehbeginn: f.ohne },
}
}
const UEBER = { link: 'Bestätigungslink', portal: 'Kundenportal' }
export function t22Mail(auftrag) {
const kunde = auftrag.kunde ?? {}
const f = frueherBeginn(auftrag)
return {
template: 'T22',
values: {
kunde: kunde.firma || kunde.name || kunde.email || '',
nummer: auftrag.nummer,
titel: auftrag.titel,
zeitpunkt: zeitpunktText(auftrag.bestaetigtAm),
ueber: UEBER[auftrag.bestaetigtUeber] ?? '',
woms_link: `${config.womsBaseUrl}/customers/${auftrag.kundenId}?tab=invoices`,
fruehester_beginn: f.beginnText,
},
blocks: { privat_fruehbeginn: f.fruehbeginn, privat_ohne_fruehbeginn: f.ohne },
}
}
/**
* K31 an den Kunden (ohne BCC, mit AB-PDF und AGB), danach die Teamkopie ohne Link und Anhänge. Wirft bei jedem Fehler
* der Kundenmail (L3 setzt dann mailStatus „fehler“); eine gescheiterte Teamkopie gilt nicht als Fehler.
*/
export async function sendeAuftragMail(auftrag, { pdf, token, erinnerung = false } = {}) {
const adresse = empfaenger(auftrag)
if (!adresse) throw new Error('Keine E-Mail-Adresse für die Auftragsbestätigung')
if (hashToken(token) === null) throw new TypeError('sendeAuftragMail: token fehlt oder hat ein falsches Format')
const abPdf = Buffer.isBuffer(pdf) ? pdf : await lesePdf(auftrag.pdfDatei)
const fassung = auftrag.agbFassung || AGB_FASSUNG
let agbPdf
try {
agbPdf = await readFile(agbPdfPfad(fassung))
} catch (err) {
throw new Error(`AGB-PDF ${fassung} nicht lesbar: ${err.code || err.name}`)
}
await sendCustomerMail(adresse, {
...k31Mail(auftrag, { token, erinnerung }),
subjectPrefix: erinnerung ? ERINNERUNG_PRAEFIX : '',
attachments: [
{ filename: `${auftrag.nummer}.pdf`, content: abPdf, contentType: 'application/pdf' },
{ filename: `WEBklar-AGB-${fassung}.pdf`, content: agbPdf, contentType: 'application/pdf' },
],
bccTeam: false,
})
// Der Grund steht nicht im Protokoll: Meldungen des Mailservers nennen oft die Empfaengeradresse
const kopie = await sendAdminNotice({ ...k31Mail(auftrag, { erinnerung, teamkopie: true }), subjectPrefix: `${KOPIE_PRAEFIX}${erinnerung ? ERINNERUNG_PRAEFIX : ''}` })
if (!kopie.sent) console.error(`[auftraege] Teamkopie der Auftragsbestätigung ${auftrag.nummer} nicht gesendet`)
}
/** K32 an den Kunden (Kopie ans Team wie üblich: die Mail trägt keinen Link), danach T22 ans Team. Wirft nie. */
export async function sendeBestaetigtMails(auftrag) {
const adresse = empfaenger(auftrag)
if (!adresse) {
console.error(`[auftraege] K32 für ${auftrag.nummer} entfällt: keine E-Mail-Adresse`)
} else {
try {
await sendCustomerMail(adresse, k32Mail(auftrag))
} catch (err) {
console.error(`[auftraege] K32 für ${auftrag.nummer} fehlgeschlagen: ${err?.code || err?.name || 'Fehler'}`)
}
}
try {
const t22 = await sendAdminNotice(t22Mail(auftrag))
if (!t22.sent) console.error(`[auftraege] T22 für ${auftrag.nummer} nicht gesendet`)
} catch (err) {
console.error(`[auftraege] T22 für ${auftrag.nummer} fehlgeschlagen: ${err?.code || err?.name || 'Fehler'}`)
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Nummernvergabe der Auftragsbestätigungen: AB-JJJJ-NNNN, lückenlos je Berliner Kalenderjahr (Übersicht V4, L3).
* vergibNummer schreibt nichts und sperrt nicht selbst: der Aufrufer hält SPERRE_NUMMER (konstanten.js), und die Nummer
* ist erst mit dem Speichern der AB vergeben. Eine Nummer, die schon in Gebrauch ist, wird übersprungen.
*/
import { berlinDate } from '../finance/dates.js'
import { findAuftragByNummer, listAuftraege } from './auftragStore.js'
export function formatNummer(jahr, laufnummer) {
if (!Number.isInteger(jahr) || jahr < 2000 || jahr > 9999 || !Number.isInteger(laufnummer) || laufnummer < 1 || laufnummer > 9999) {
throw new RangeError('Ungültige Auftragsnummer.')
}
return `AB-${jahr}-${String(laufnummer).padStart(4, '0')}`
}
export async function vergibNummer(jetzt = new Date()) {
const jahr = Number(berlinDate(jetzt).slice(0, 4))
const vorhandene = await listAuftraege({ jahr })
let laufnummer = vorhandene.reduce((groesste, a) => Math.max(groesste, Number.isSafeInteger(a.laufnummer) ? a.laufnummer : 0), 0) + 1
while (await findAuftragByNummer(formatNummer(jahr, laufnummer))) laufnummer += 1
return { nummer: formatNummer(jahr, laufnummer), jahr, laufnummer }
}
+475
View File
@@ -0,0 +1,475 @@
import { readFileSync } from 'node:fs'
import * as fontkitModul from 'fontkit'
import PDFDocument from 'pdfkit'
import { euroDe } from '../finance/format.js'
import { berlinDate, formatDe } from '../finance/dates.js'
import { AuftragError } from './fehler.js'
import { AGB_FASSUNG, AUFTRAG_ARTEN, AUFTRAG_WEGE, ZAHLUNGSPLAN_ARTEN } from './konstanten.js'
import { AB_TEXTE, ABSENDER, WIDERRUFSBELEHRUNG_PROJEKT, WIDERRUFSFORMULAR } from './texte.js'
import { aufwandText, zahlungsplanText } from './auftragSicht.js'
/**
* PDF der Auftragsbestätigung (A4, pdfkit): Schriften und Zeichenabdeckung, Daten (alle Texte fertig formatiert),
* Seitenaufteilung (rein) und Zeichnen. Aufbau der Datei: 1 Schriften, 2 Daten, 3 Layout, 4 Zeichnen, 5 erzeugeAuftragPdf.
*/
// ===== 1. Schriften, Logo, Zeichenabdeckung =====
const fontkit = fontkitModul.default ?? fontkitModul
const SCHRIFT_VERZEICHNIS = new URL('../../assets/fonts/', import.meta.url)
const LOGO_DATEI = new URL('../../../public/webklar-logo.png', import.meta.url)
const SCHRIFT_DATEIEN = Object.freeze({ regular: 'Figtree-Regular.ttf', semibold: 'Figtree-SemiBold.ttf' })
let geladen = null
/** { regular: Buffer, semibold: Buffer, logo: Buffer, regularFont, semiboldFont } – bei jedem Aufruf dasselbe Objekt. */
export function ladeSchriften() {
if (!geladen) {
const regular = readFileSync(new URL(SCHRIFT_DATEIEN.regular, SCHRIFT_VERZEICHNIS))
const semibold = readFileSync(new URL(SCHRIFT_DATEIEN.semibold, SCHRIFT_VERZEICHNIS))
geladen = Object.freeze({
regular,
semibold,
logo: readFileSync(LOGO_DATEI),
regularFont: fontkit.create(regular),
semiboldFont: fontkit.create(semibold),
})
}
return geladen
}
/** Hat der Codepunkt ein Glyph in Regular UND SemiBold? */
export function hatGlyph(codePoint) {
const { regularFont, semiboldFont } = ladeSchriften()
return regularFont.hasGlyphForCodePoint(codePoint) && semiboldFont.hasGlyphForCodePoint(codePoint)
}
const ERSATZ = new Map([
[0x2010, '-'], [0x2011, '-'], [0x2012, '-'], [0x2013, '-'], [0x2014, '-'], [0x2015, '-'], [0x2212, '-'],
[0x2018, "'"], [0x2019, "'"], [0x201a, "'"], [0x2032, "'"],
[0x201c, '"'], [0x201d, '"'], [0x201e, '"'], [0x201f, '"'], [0x2033, '"'],
[0x2026, '...'], [0x20ac, 'EUR'], [0x2022, '-'], [0x00b7, '-'], [0x00d7, 'x'], [0x2192, '->'],
])
// Steuerzeichen (außer \n), weiche Trennung, Zero-Width-Zeichen, Variantenselektoren, BOM
const ENTFERNEN = /[\u{0}-\u{8}\u{B}-\u{1F}\u{7F}-\u{9F}\u{AD}\u{200B}-\u{200D}\u{2060}\u{FE00}-\u{FE0F}\u{FEFF}]/gu
/** Ersetzt Zeichen ohne Glyph, damit nie ein leeres Kästchen im PDF steht. */
export function bereinigeText(text, hatGlyphFn = hatGlyph) {
const roh = String(text ?? '')
.normalize('NFC')
.replace(/\r\n?|[\u{2028}\u{2029}]/gu, '\n')
.replace(/\t/g, ' ')
.replace(ENTFERNEN, '')
let aus = ''
for (const zeichen of roh) {
const cp = zeichen.codePointAt(0)
if (zeichen === '\n' || hatGlyphFn(cp)) aus += zeichen
else if (ERSATZ.has(cp)) aus += ERSATZ.get(cp)
else if (/\p{Zs}/u.test(zeichen)) aus += ' '
else if (/\p{M}/u.test(zeichen)) continue
else {
const basis = zeichen.normalize('NFD').codePointAt(0)
aus += basis !== cp && hatGlyphFn(basis) ? String.fromCodePoint(basis) : '?'
}
}
return aus
}
/** bereinigeText für jede Zeichenkette in beliebig verschachtelten Objekten und Listen. */
export function bereinigeTexte(wert, hatGlyphFn = hatGlyph) {
if (typeof wert === 'string') return bereinigeText(wert, hatGlyphFn)
if (Array.isArray(wert)) return wert.map((e) => bereinigeTexte(e, hatGlyphFn))
if (wert && typeof wert === 'object') return Object.fromEntries(Object.entries(wert).map(([k, v]) => [k, bereinigeTexte(v, hatGlyphFn)]))
return wert
}
// ===== 2. Daten =====
export const PDF_BESCHRIFTUNG = Object.freeze({
nummer: 'Nummer', datum: 'Datum', kundennummer: 'Kundennummer', projekt: 'Projekt',
leistung: 'Leistungsumfang', zahlung: 'Zahlung', termine: 'Termine', mitwirkung: 'Mitwirkung und Abnahme',
spalten: Object.freeze(['Pos.', 'Beschreibung', 'Menge', 'Einzelpreis', 'Summe']), gesamt: 'Gesamtpreis',
steuernummer: 'Steuernummer', telefon: 'Telefon', bank: 'Bankverbindung', kontakt: 'Kontakt', seite: 'Seite', von: 'von',
entwurf: 'ENTWURF', entwurfHinweis: 'Entwurf – Vorschau, noch keine gültige Auftragsbestätigung',
})
const fehlt = (was) => new AuftragError(409, `Entwurf unvollständig: ${was}`)
const zeilen = (text) => String(text ?? '').split('\n').map((z) => z.trim()).filter(Boolean)
const tag = (iso) => formatDe(berlinDate(new Date(iso)))
function pruefe(auftrag, vorschau) {
if (!auftrag?.kunde || typeof auftrag.kunde !== 'object') throw fehlt('Kundendaten fehlen.')
if (!vorschau && !auftrag.nummer) throw fehlt('Nummer fehlt.')
if (!String(auftrag.titel ?? '').trim()) throw fehlt('Titel fehlt.')
if (!/^\d{4}-\d{2}-\d{2}$/.test(auftrag.auftragVom ?? '')) throw fehlt('Datum der Absprache fehlt.')
if (!Object.hasOwn(AUFTRAG_WEGE, auftrag.auftragWeg)) throw fehlt('Weg der Absprache fehlt.')
if (!AUFTRAG_ARTEN.includes(auftrag.art)) throw fehlt('Art des Auftrags fehlt.')
if (!ZAHLUNGSPLAN_ARTEN.includes(auftrag.zahlungsplan?.art)) throw fehlt('Zahlungsplan fehlt.')
if (auftrag.art === 'festpreis') {
if (!Array.isArray(auftrag.positionen) || auftrag.positionen.length === 0) throw fehlt('Positionen fehlen.')
const summe = auftrag.positionen.reduce((s, p) => s + p.menge * p.einzelpreisCents, 0)
if (summe !== auftrag.summeCents) throw new AuftragError(409, 'Die Summe der Positionen stimmt nicht mit der Auftragssumme überein.')
} else if (!Number.isSafeInteger(auftrag.stundensatzCents) || auftrag.stundensatzCents <= 0) {
throw fehlt('Stundensatz fehlt.')
}
}
function empfaengerZeilen(kunde) {
const land = String(kunde.land ?? '').trim()
const ausland = land && !/^(deutschland|germany|de)$/i.test(land)
return [kunde.firma, kunde.name, kunde.strasse, [kunde.plz, kunde.ort].filter(Boolean).join(' '), ausland ? land : '']
.map((z) => String(z ?? '').trim())
.filter((z, i, alle) => z && alle.indexOf(z) === i)
}
/** Alle Texte und Zahlen des AB-PDFs, fertig formatiert (reine Funktion). */
export function auftragPdfDaten(auftrag, { settings = {}, vorschau = false, jetzt = new Date(), ersetzt = null } = {}) {
pruefe(auftrag, vorschau)
const b = PDF_BESCHRIFTUNG
const steuernummer = String(settings.steuernummer ?? '').trim()
const festpreis = auftrag.art === 'festpreis'
const privat = auftrag.kunde.kundenart === 'privat'
const termine = AB_TEXTE.termine({ start: auftrag.startAm ? formatDe(auftrag.startAm) : null, fertig: auftrag.fertigBis ? formatDe(auftrag.fertigBis) : null, privat })
const abschnitte = [
{ id: 'zahlung', ueberschrift: b.zahlung, text: zahlungsplanText(auftrag) },
...(termine ? [{ id: 'termine', ueberschrift: b.termine, text: termine }] : []),
{ id: 'mitwirkung', ueberschrift: b.mitwirkung, text: AB_TEXTE.mitwirkung },
]
return {
vorschau,
beschriftung: b,
titel: AB_TEXTE.titel,
nummer: auftrag.nummer || 'Entwurf',
datum: tag(auftrag.gesendetAm || jetzt),
kundennummer: String(auftrag.kunde.kundennummer ?? ''),
projekt: auftrag.titel.trim(),
absender: {
zeilen: [ABSENDER.name, ABSENDER.strasse, ABSENDER.plzOrt, `${b.telefon} ${ABSENDER.telefon}`, ABSENDER.email, ABSENDER.web, ...(steuernummer ? [`${b.steuernummer} ${steuernummer}`] : [])],
rueckzeile: `${ABSENDER.name} · ${ABSENDER.strasse} · ${ABSENDER.plzOrt}`,
},
empfaenger: empfaengerZeilen(auftrag.kunde),
einleitung: AB_TEXTE.einleitung({ datum: formatDe(auftrag.auftragVom), weg: AUFTRAG_WEGE[auftrag.auftragWeg] }),
leistung: festpreis
? {
art: 'festpreis',
ueberschrift: b.leistung,
positionen: auftrag.positionen.map((p, i) => ({
nr: String(i + 1), beschreibung: p.beschreibung.trim(), menge: String(p.menge),
einzelpreis: euroDe(p.einzelpreisCents), summe: euroDe(p.menge * p.einzelpreisCents),
})),
gesamt: { label: b.gesamt, betrag: euroDe(auftrag.summeCents) },
aufwandText: null,
}
: { art: 'aufwand', ueberschrift: b.leistung, positionen: [], gesamt: null, aufwandText: aufwandText(auftrag) },
kleinunternehmer: AB_TEXTE.kleinunternehmer,
abschnitte,
agb: AB_TEXTE.agb({ fassung: formatDe(auftrag.agbFassung || AGB_FASSUNG) }),
ersetzt: ersetzt ? AB_TEXTE.ersetzt({ nummer: ersetzt.nummer, datum: formatDe(ersetzt.datum) }) : null,
gruss: AB_TEXTE.gruss,
widerruf: privat ? { belehrung: WIDERRUFSBELEHRUNG_PROJEKT, formular: WIDERRUFSFORMULAR } : null,
fuss: {
bank: zeilen(settings.bankDetailsText),
kontakt: [`${ABSENDER.name} · vertreten durch ${ABSENDER.vertretenDurch}`, `${b.telefon} ${ABSENDER.telefon} · ${ABSENDER.email} · ${ABSENDER.web}`],
},
}
}
// ===== 3. Layout (rein, ohne pdfkit): Maße, Bausteine, Seitenaufteilung =====
export const SEITE = Object.freeze({ breite: 595.28, hoehe: 841.89, links: 56.7, rechts: 56.7, oben: 48, unten: 96 })
export const INHALT_BREITE = SEITE.breite - SEITE.links - SEITE.rechts // 481.88
const FARBEN = Object.freeze({ tinte: '#111111', gedaempft: '#6b6964', linie: '#e8e6e0', linieDunkel: '#c9c6bd', flaeche: '#f7f6f3', entwurf: '#b42318' })
export const SPALTEN = Object.freeze([28, 237.88, 44, 86, 86]) // Pos., Beschreibung, Menge, Einzelpreis, Summe
const ZELLEN_RAND = 6
const STIL = Object.freeze({
titel: { schrift: 'semibold', groesse: 22, lineGap: 0, farbe: FARBEN.tinte },
projekt: { schrift: 'regular', groesse: 11, lineGap: 2, farbe: FARBEN.tinte },
adresse: { schrift: 'regular', groesse: 10, lineGap: 2.5, farbe: FARBEN.tinte },
ueberschrift: { schrift: 'semibold', groesse: 11, lineGap: 0, farbe: FARBEN.tinte },
text: { schrift: 'regular', groesse: 9.5, lineGap: 2.5, farbe: FARBEN.tinte },
klein: { schrift: 'regular', groesse: 8.5, lineGap: 2, farbe: FARBEN.gedaempft },
tabellenkopf: { schrift: 'semibold', groesse: 8.5, lineGap: 0, farbe: FARBEN.gedaempft },
summe: { schrift: 'semibold', groesse: 10.5, lineGap: 0, farbe: FARBEN.tinte },
fussLabel: { schrift: 'semibold', groesse: 7.5, lineGap: 1.5, farbe: FARBEN.gedaempft },
fuss: { schrift: 'regular', groesse: 7.5, lineGap: 1.5, farbe: FARBEN.gedaempft },
})
const MASSE = Object.freeze({
kopfHoehe: 22, summeHoehe: 30, kastenRand: 8, adresseBreite: 250, infoX: 340, infoZeile: 15,
rueckY: 80, adresseY: 94, // Rücksendezeile und Anschriftfeld relativ zur Oberkante des Briefkopfs (DIN 5008, Form B)
fussHoehe: 82, folgeKopf: 40,
})
const SICHERHEIT = 1
export const BEREICHE = Object.freeze({
erste: { oben: SEITE.oben, unten: SEITE.hoehe - SEITE.unten - SICHERHEIT },
folge: { oben: SEITE.oben + MASSE.folgeKopf, unten: SEITE.hoehe - SEITE.unten - SICHERHEIT },
})
const atom = (id, typ, hoehe, extra = {}) => ({ id, typ, hoehe, abstandDavor: 0, haengtAnNaechstem: false, umbruchDavor: false, kopf: null, ...extra })
/** Alle Bausteine der AB in Zeichenreihenfolge. messe(text, stil, breite) liefert die Texthöhe in pt. */
export function baueAtome(daten, messe) {
const b = daten.beschriftung
const B = INHALT_BREITE
const atome = []
const absatz = (id, text, extra = {}, stil = STIL.text) => atome.push(atom(id, 'absatz', messe(text, stil, B), { text, stil: stil === STIL.klein ? 'klein' : 'text', abstandDavor: 6, ...extra }))
const ueberschrift = (id, text) => atome.push(atom(id, 'ueberschrift', messe(text, STIL.ueberschrift, B) + 5, { text, abstandDavor: 14, haengtAnNaechstem: true }))
// Briefkopf (nur Seite 1): Logo, Absender, Anschrift, Angaben, Titel
const infoZeilen = [[b.nummer, daten.nummer], [b.datum, daten.datum], ...(daten.kundennummer ? [[b.kundennummer, daten.kundennummer]] : [])]
const adresseHoehe = messe(daten.empfaenger.join('\n'), STIL.adresse, MASSE.adresseBreite)
const blockEnde = MASSE.adresseY + Math.max(adresseHoehe, infoZeilen.length * MASSE.infoZeile)
const titelY = blockEnde + 22
const projektY = titelY + STIL.titel.groesse * 1.2 + 6
const projektHoehe = messe(`${b.projekt}: ${daten.projekt}`, STIL.projekt, B)
atome.push(atom('briefkopf', 'briefkopf', projektY + projektHoehe + 4, { haengtAnNaechstem: true, masse: { titelY, projektY, projektHoehe, infoZeilen } }))
absatz('einleitung', daten.einleitung, { abstandDavor: 8 })
const l = daten.leistung
ueberschrift('leistung', l.ueberschrift)
if (l.art === 'festpreis') {
const kopf = atom('tabellenkopf', 'tabellenkopf', MASSE.kopfHoehe, { haengtAnNaechstem: true, spalten: b.spalten })
atome.push(kopf)
l.positionen.forEach((p, i) => {
const text = messe(p.beschreibung, STIL.text, SPALTEN[1] - 2 * ZELLEN_RAND)
const eineZeile = messe('X', STIL.text, SPALTEN[1])
atome.push(atom(`zeile-${i + 1}`, 'zeile', Math.max(text, eineZeile) + 2 * ZELLEN_RAND, { zelle: p, kopf, haengtAnNaechstem: i === l.positionen.length - 1 }))
})
atome.push(atom('summe', 'summe', MASSE.summeHoehe, { label: l.gesamt.label, betrag: l.gesamt.betrag, haengtAnNaechstem: true }))
} else {
atome.push(atom('aufwand', 'absatz', messe(l.aufwandText, STIL.text, B - 2 * MASSE.kastenRand) + 2 * MASSE.kastenRand, { text: l.aufwandText, stil: 'text', kasten: true, haengtAnNaechstem: true }))
}
absatz('kleinunternehmer', daten.kleinunternehmer, { abstandDavor: 8 }, STIL.klein)
for (const a of daten.abschnitte) {
ueberschrift(`${a.id}-kopf`, a.ueberschrift)
absatz(a.id, a.text, { abstandDavor: 0 })
}
absatz('agb', daten.agb, { abstandDavor: 12 })
if (daten.ersetzt) absatz('ersetzt', daten.ersetzt, { abstandDavor: 8 })
absatz('gruss', daten.gruss, { abstandDavor: 14 })
if (daten.widerruf) {
const { belehrung, formular } = daten.widerruf
atome.push(atom('widerruf-titel', 'ueberschrift', messe(belehrung.titel, STIL.titel, B) + 8, { text: belehrung.titel, stil: 'titel', umbruchDavor: true, haengtAnNaechstem: true }))
belehrung.abschnitte.forEach((a, i) => {
ueberschrift(`widerruf-${i + 1}-kopf`, a.ueberschrift)
a.absaetze.forEach((t, k) => absatz(`widerruf-${i + 1}-${k + 1}`, t, { abstandDavor: k === 0 ? 0 : 5 }))
})
atome.push(atom('formular-titel', 'ueberschrift', messe(formular.titel, STIL.ueberschrift, B) + 5, { text: formular.titel, abstandDavor: 20, haengtAnNaechstem: true }))
absatz('formular-einleitung', formular.einleitung, { abstandDavor: 0, haengtAnNaechstem: true }, STIL.klein)
formular.zeilen.forEach((t, i) => absatz(`formular-${i + 1}`, t, { abstandDavor: 8, haengtAnNaechstem: i < formular.zeilen.length - 1 }))
}
return atome
}
/** Teilt Bausteine auf Seiten auf (Ketten bleiben zusammen, Tabellenkopf wiederholt sich). */
export function paginiere(atome, bereiche = BEREICHE) {
const seiten = []
const folgeHoehe = bereiche.folge.unten - bereiche.folge.oben
let seite
let y
const neueSeite = () => {
const bereich = seiten.length === 0 ? bereiche.erste : bereiche.folge
seite = { nummer: seiten.length + 1, posten: [], unten: bereich.unten }
seiten.push(seite)
y = bereich.oben
}
const frisch = () => seite.posten.every((p) => p.wiederholt)
const abstand = (a) => (frisch() ? 0 : a.abstandDavor)
neueSeite()
for (let i = 0; i < atome.length; ) {
let j = i
while (j < atome.length - 1 && atome[j].haengtAnNaechstem) j += 1
const kette = atome.slice(i, j + 1)
i = j + 1
if (kette[0].umbruchDavor && !frisch()) neueSeite()
const bedarf = kette.reduce((summe, a, k) => summe + (k === 0 ? 0 : a.abstandDavor) + a.hoehe, 0)
const kopfHoehe = kette[0].kopf ? kette[0].kopf.hoehe : 0
if (!frisch() && y + abstand(kette[0]) + bedarf > seite.unten && bedarf + kopfHoehe <= folgeHoehe) neueSeite()
for (const a of kette) {
if (!frisch() && y + abstand(a) + a.hoehe > seite.unten) neueSeite()
if (seite.nummer > 1 && a.kopf && seite.posten.length === 0) {
seite.posten.push({ atom: a.kopf, y, wiederholt: true })
y += a.kopf.hoehe
}
y += abstand(a)
seite.posten.push({ atom: a, y, wiederholt: false })
y += a.hoehe
}
}
return seiten
}
// ===== 4. Zeichnen mit pdfkit =====
/** Zeichnen mit pdfkit. Alle Texte stehen fertig in `daten`; Höhen und Seiten kommen aus dem Abschnitt Layout. */
const SCHRIFT_NAMEN = { regular: 'Figtree', semibold: 'Figtree-SemiBold' }
const RECHTS = SEITE.breite - SEITE.rechts
const SPALTEN_X = SPALTEN.reduce((liste, breite) => [...liste, liste[liste.length - 1] + breite], [SEITE.links]).slice(0, SPALTEN.length)
function setzeStil(doc, stil) {
return doc.font(SCHRIFT_NAMEN[stil.schrift]).fontSize(stil.groesse).fillColor(stil.farbe)
}
function schreibe(doc, text, x, y, stil, optionen = {}) {
setzeStil(doc, stil).text(text, x, y, { lineGap: stil.lineGap, ...optionen })
}
const linie = (doc, x1, y, x2, farbe = FARBEN.linie, breite = 0.5) => {
doc.save().lineWidth(breite).strokeColor(farbe).moveTo(x1, y).lineTo(x2, y).stroke().restore()
}
function zeichneBriefkopf(doc, daten, atom, y0, assets) {
const b = daten.beschriftung
const m = atom.masse
doc.image(assets.logo, SEITE.links, y0, { width: 34 })
schreibe(doc, 'WEBklar', SEITE.links + 43, y0 + 6, { schrift: 'semibold', groesse: 20, lineGap: 0, farbe: FARBEN.tinte }, { characterSpacing: -0.4, lineBreak: false })
const [name, ...rest] = daten.absender.zeilen
const blockX = RECHTS - 190
schreibe(doc, name, blockX, y0, { ...STIL.klein, schrift: 'semibold', farbe: FARBEN.tinte }, { width: 190, align: 'right' })
schreibe(doc, rest.join('\n'), blockX, y0 + 13, STIL.klein, { width: 190, align: 'right' })
schreibe(doc, daten.absender.rueckzeile, SEITE.links, y0 + MASSE.rueckY, { ...STIL.klein, groesse: 7 }, { width: MASSE.adresseBreite, lineBreak: false, underline: true })
schreibe(doc, daten.empfaenger.join('\n'), SEITE.links, y0 + MASSE.adresseY, STIL.adresse, { width: MASSE.adresseBreite })
m.infoZeilen.forEach(([label, wert], i) => {
const zeileY = y0 + MASSE.adresseY + i * MASSE.infoZeile
schreibe(doc, label, MASSE.infoX, zeileY + 1, STIL.klein, { width: 90, lineBreak: false })
schreibe(doc, wert, MASSE.infoX + 92, zeileY, { ...STIL.text, schrift: 'semibold' }, { width: RECHTS - MASSE.infoX - 92, align: 'right' })
})
schreibe(doc, daten.titel, SEITE.links, y0 + m.titelY, STIL.titel, { width: INHALT_BREITE })
schreibe(doc, `${b.projekt}: ${daten.projekt}`, SEITE.links, y0 + m.projektY, STIL.projekt, { width: INHALT_BREITE })
}
function zeichneAtom(doc, daten, atom, y, assets) {
switch (atom.typ) {
case 'briefkopf':
return zeichneBriefkopf(doc, daten, atom, y, assets)
case 'ueberschrift':
return schreibe(doc, atom.text, SEITE.links, y, atom.stil === 'titel' ? STIL.titel : STIL.ueberschrift, { width: INHALT_BREITE })
case 'absatz': {
const stil = STIL[atom.stil]
if (atom.kasten) {
doc.save().roundedRect(SEITE.links, y, INHALT_BREITE, atom.hoehe, 4).lineWidth(0.5).fillAndStroke(FARBEN.flaeche, FARBEN.linie).restore()
return schreibe(doc, atom.text, SEITE.links + MASSE.kastenRand, y + MASSE.kastenRand, stil, { width: INHALT_BREITE - 2 * MASSE.kastenRand })
}
return schreibe(doc, atom.text, SEITE.links, y, stil, { width: INHALT_BREITE })
}
case 'tabellenkopf': {
doc.save().rect(SEITE.links, y, INHALT_BREITE, atom.hoehe).fill(FARBEN.flaeche).restore()
atom.spalten.forEach((text, i) => {
const rechts = i >= 2
schreibe(doc, text, SPALTEN_X[i] + (i === 0 ? 0 : ZELLEN_RAND), y + 7, STIL.tabellenkopf, { width: SPALTEN[i] - (i === 0 ? ZELLEN_RAND : 2 * ZELLEN_RAND), align: rechts ? 'right' : 'left', lineBreak: false })
})
return linie(doc, SEITE.links, y + atom.hoehe, RECHTS, FARBEN.linieDunkel, 0.75)
}
case 'zeile': {
const z = atom.zelle
const oben = y + ZELLEN_RAND
schreibe(doc, z.nr, SPALTEN_X[0], oben, STIL.text, { width: SPALTEN[0] - ZELLEN_RAND })
schreibe(doc, z.beschreibung, SPALTEN_X[1] + ZELLEN_RAND, oben, STIL.text, { width: SPALTEN[1] - 2 * ZELLEN_RAND })
schreibe(doc, z.menge, SPALTEN_X[2], oben, STIL.text, { width: SPALTEN[2] - ZELLEN_RAND, align: 'right' })
schreibe(doc, z.einzelpreis, SPALTEN_X[3], oben, STIL.text, { width: SPALTEN[3] - ZELLEN_RAND, align: 'right' })
schreibe(doc, z.summe, SPALTEN_X[4], oben, STIL.text, { width: SPALTEN[4] - ZELLEN_RAND, align: 'right' })
return linie(doc, SEITE.links, y + atom.hoehe, RECHTS)
}
case 'summe': {
linie(doc, SEITE.links, y, RECHTS, FARBEN.tinte, 0.75)
const labelBreite = SPALTEN_X[4] - SEITE.links - ZELLEN_RAND
schreibe(doc, atom.label, SEITE.links, y + 9, STIL.summe, { width: labelBreite, align: 'right', lineBreak: false })
return schreibe(doc, atom.betrag, SPALTEN_X[4], y + 9, STIL.summe, { width: SPALTEN[4] - ZELLEN_RAND, align: 'right', lineBreak: false })
}
default:
throw new Error(`Unbekannter Baustein: ${atom.typ}`)
}
}
function zeichneFolgeKopf(doc, daten, assets) {
const y = SEITE.oben
doc.image(assets.logo, SEITE.links, y, { width: 16 })
schreibe(doc, 'WEBklar', SEITE.links + 22, y + 2, { schrift: 'semibold', groesse: 11, lineGap: 0, farbe: FARBEN.tinte }, { characterSpacing: -0.2, lineBreak: false })
schreibe(doc, `${daten.titel} ${daten.nummer}`, RECHTS - 250, y + 4, STIL.klein, { width: 250, align: 'right', lineBreak: false })
linie(doc, SEITE.links, y + 24, RECHTS)
}
function zeichneFuss(doc, daten, nummer, gesamt) {
const b = daten.beschriftung
const y = SEITE.hoehe - MASSE.fussHoehe
linie(doc, SEITE.links, y, RECHTS)
const spalte = (x, breite, label, zeilen) => {
schreibe(doc, label, x, y + 8, STIL.fussLabel, { width: breite, lineBreak: false })
schreibe(doc, zeilen.join('\n'), x, y + 19, STIL.fuss, { width: breite, height: 46, ellipsis: true })
}
if (daten.fuss.bank.length) spalte(SEITE.links, 175, b.bank, daten.fuss.bank)
spalte(SEITE.links + 190, 225, b.kontakt, daten.fuss.kontakt)
schreibe(doc, `${b.seite} ${nummer} ${b.von} ${gesamt}`, RECHTS - 70, y + 8, STIL.fussLabel, { width: 70, align: 'right', lineBreak: false })
}
function zeichneWasserzeichen(doc, daten) {
const stil = { schrift: 'semibold', groesse: 110, lineGap: 0, farbe: FARBEN.entwurf }
setzeStil(doc, stil)
const breite = doc.widthOfString(daten.beschriftung.entwurf)
const mitteX = SEITE.breite / 2
const mitteY = SEITE.hoehe / 2
doc.save()
doc.fillOpacity(0.07)
doc.rotate(-35, { origin: [mitteX, mitteY] })
doc.text(daten.beschriftung.entwurf, mitteX - breite / 2, mitteY - (stil.groesse * 1.2) / 2, { lineBreak: false })
doc.restore()
schreibe(doc, daten.beschriftung.entwurfHinweis, SEITE.links, 22, { schrift: 'semibold', groesse: 8, lineGap: 0, farbe: FARBEN.entwurf }, { width: INHALT_BREITE, align: 'center', lineBreak: false })
}
/** Zeichnet alle Seiten; die erste Seite legt pdfkit selbst an. */
function zeichneAuftrag(doc, daten, seiten, assets) {
seiten.forEach((seite, index) => {
if (index > 0) doc.addPage()
if (daten.vorschau) zeichneWasserzeichen(doc, daten)
if (index > 0) zeichneFolgeKopf(doc, daten, assets)
for (const posten of seite.posten) zeichneAtom(doc, daten, posten.atom, posten.y, assets)
zeichneFuss(doc, daten, seite.nummer, seiten.length)
})
}
// ===== 5. Ablauf =====
async function ladeErsetzte(id) {
const { getAuftrag } = await import('./auftragStore.js')
const alt = await getAuftrag(id)
if (!alt?.nummer || !alt.gesendetAm) throw new AuftragError(409, 'Die ersetzte Auftragsbestätigung wurde nicht gefunden.')
return { nummer: alt.nummer, datum: berlinDate(new Date(alt.gesendetAm)) }
}
/** Das AB-PDF (A4) als Buffer. */
export async function erzeugeAuftragPdf(auftrag, { settings, vorschau = false, jetzt = new Date(), ersetzt } = {}) {
const alt = ersetzt !== undefined ? ersetzt : auftrag.ersetztId ? await ladeErsetzte(auftrag.ersetztId) : null
const daten = bereinigeTexte(auftragPdfDaten(auftrag, { settings, vorschau, jetzt, ersetzt: alt }))
const assets = ladeSchriften()
const doc = new PDFDocument({
size: 'A4', margins: { top: 0, bottom: 0, left: 0, right: 0 }, lang: 'de-DE', displayTitle: true,
info: {
Title: `${daten.titel} ${daten.nummer}`, Author: 'WEBklar GbR', Subject: daten.projekt, Producer: 'WEBklar Kundenportal',
CreationDate: new Date(auftrag.gesendetAm || jetzt),
},
})
doc.registerFont('Figtree', assets.regular)
doc.registerFont('Figtree-SemiBold', assets.semibold)
const teile = []
doc.on('data', (teil) => teile.push(teil))
const fertig = new Promise((resolve, reject) => {
doc.on('end', () => resolve(Buffer.concat(teile)))
doc.on('error', reject)
})
fertig.catch(() => {}) // scheitert das Zeichnen zuerst, soll das verworfene Versprechen nichts auslösen
try {
const messe = (text, stil, breite) => {
setzeStil(doc, stil)
return doc.heightOfString(text, { width: breite, lineGap: stil.lineGap })
}
const seiten = paginiere(baueAtome(daten, messe), BEREICHE)
zeichneAuftrag(doc, daten, seiten, assets)
doc.end()
} catch (fehler) {
doc.destroy()
throw fehler
}
return fertig
}
+283
View File
@@ -0,0 +1,283 @@
/**
* Rechnungszeilen je Art (rein, ohne Stripe und Appwrite): feste Positionen, Aufwand je Tag oder Kalenderwoche,
* Anzahlung und Schlussrechnung mit Minuszeilen. Betraege sind ganze Cent.
* Importzyklus (siehe Plan L2): zur Modulauswertung nichts aus fehler.js benutzen, nur innerhalb von Funktionen.
*/
import { AuftragError } from './fehler.js'
import { LIMITS } from './konstanten.js'
import { berlinDate, formatDe } from '../finance/dates.js'
import { euroDe } from '../finance/format.js'
const TAG_MS = 86_400_000
const istGanzzahlZwischen = (wert, min, max) => Number.isSafeInteger(wert) && wert >= min && wert <= max
/** Text auf hoechstens max Zeichen kuerzen (Auslassungszeichen am Ende, kein halbes Emoji). */
export function kuerzen(text, max) {
if (text.length <= max) return text
let teil = text.slice(0, max - 1)
const letzter = teil.charCodeAt(teil.length - 1)
if (letzter >= 0xd800 && letzter <= 0xdbff) teil = teil.slice(0, -1)
return `${teil.trimEnd()}…`
}
/** Minuten als Stunden mit Komma, ohne Endnullen: 135 -> "2,25", 390 -> "6,5", 60 -> "1". */
export function stundenText(minuten) {
return String(Math.round((minuten / 60) * 100) / 100).replace('.', ',')
}
/** Nur ein String YYYY-MM-DD, den es als Tag gibt. */
export function gueltigerTag(ymd) {
const treffer = typeof ymd === 'string' ? ymd.match(/^(\d{4})-(\d{2})-(\d{2})$/) : null
if (!treffer) return false
const [j, m, t] = treffer.slice(1).map(Number)
const pruef = new Date(Date.UTC(j, m - 1, t))
return pruef.getUTCFullYear() === j && pruef.getUTCMonth() === m - 1 && pruef.getUTCDate() === t
}
/** ISO-Woche samt ISO-Jahr; wirft RangeError bei ungueltigem Tag. */
export function kalenderwoche(ymd) {
if (!gueltigerTag(ymd)) throw new RangeError(`Ungültiges Datum: ${typeof ymd === 'string' ? ymd : typeof ymd}`)
const tag = new Date(`${ymd}T00:00:00Z`)
const wochentag = (tag.getUTCDay() + 6) % 7 // Montag = 0
const donnerstag = new Date(tag.getTime() + (3 - wochentag) * TAG_MS)
const jahr = donnerstag.getUTCFullYear()
const woche = Math.floor((donnerstag.getTime() - Date.UTC(jahr, 0, 1)) / TAG_MS / 7) + 1
return { jahr, woche }
}
/** "06.10.2026" | "01.10.–20.10.2026" | "28.12.2026–05.01.2027" (Halbgeviertstrich ohne Leerzeichen). */
export function leistungszeitraumText(von, bis) {
if (bis < von) throw new RangeError('Das Ende liegt vor dem Beginn.')
if (von === bis) return formatDe(von)
if (von.slice(0, 4) === bis.slice(0, 4)) return `${formatDe(von).slice(0, 6)}–${formatDe(bis)}`
return `${formatDe(von)}–${formatDe(bis)}`
}
/** Gemeinsamer Pruefer fuer feste und Zusatzpositionen (Eingabe { description, quantity, unitAmountCents }). */
function pruefeEingabePositionen(items, { leerErlaubt, praefix }) {
const leer = items === undefined || items === null
if (leer && leerErlaubt) return []
if (!Array.isArray(items) || items.length > LIMITS.maxPositionen || (items.length < 1 && !leerErlaubt)) {
throw new AuftragError(400, `Eine Rechnung braucht 1 bis ${LIMITS.maxPositionen} Positionen.`)
}
const positionen = items.map((item, index) => {
const label = `Position ${index + 1}`
const beschreibung = typeof item?.description === 'string' ? item.description.trim() : ''
if (beschreibung.length < 1 || beschreibung.length > LIMITS.maxBeschreibung) {
throw new AuftragError(400, `${label}: Die Beschreibung braucht 1 bis ${LIMITS.maxBeschreibung} Zeichen.`)
}
if (!istGanzzahlZwischen(item.quantity, 1, LIMITS.maxMenge)) {
throw new AuftragError(400, `${label}: Die Menge muss eine ganze Zahl von 1 bis ${LIMITS.maxMenge} sein.`)
}
if (!istGanzzahlZwischen(item.unitAmountCents, 1, LIMITS.maxEinzelpreisCents)) {
throw new AuftragError(400, `${label}: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`)
}
return {
schluessel: `${praefix}-${index + 1}`,
beschreibung,
menge: item.quantity,
einzelpreisCents: item.unitAmountCents,
betragCents: item.quantity * item.unitAmountCents,
}
})
if (positionen.reduce((summe, p) => summe + p.betragCents, 0) > LIMITS.maxSummeCents) {
throw new AuftragError(400, 'Rechnungssumme zu hoch.')
}
return positionen
}
/** Feste Positionen einer freien Rechnung oder eines Festbetrags: 1 bis 20, Schluessel pos-<n>. */
export function festePositionen(items) {
return pruefeEingabePositionen(items, { leerErlaubt: false, praefix: 'pos' })
}
/** Zusatzpositionen (Nachtraege einer Schlussrechnung): wie festePositionen, aber leer erlaubt, Schluessel zusatz-<n>. */
export function zusatzPositionen(items) {
return pruefeEingabePositionen(items, { leerErlaubt: true, praefix: 'zusatz' })
}
/** Prueft fertige Zeilen (auch Minuszeilen): Zahl 1 bis max, Beschreibungen, Summe; liefert die Summe in Cent. */
export function pruefePositionen(positionen, { max = LIMITS.maxPositionen } = {}) {
if (!Array.isArray(positionen) || positionen.length < 1 || positionen.length > max) {
throw new AuftragError(400, `Eine Rechnung braucht 1 bis ${max} Positionen.`)
}
positionen.forEach((p, index) => {
const laenge = typeof p.beschreibung === 'string' ? p.beschreibung.length : 0
if (laenge < 1 || laenge > LIMITS.maxBeschreibung) {
throw new AuftragError(400, `Position ${index + 1}: Die Beschreibung braucht 1 bis ${LIMITS.maxBeschreibung} Zeichen.`)
}
})
const summe = positionen.reduce((s, p) => s + p.betragCents, 0)
if (summe > LIMITS.maxSummeCents) throw new AuftragError(400, 'Rechnungssumme zu hoch.')
return summe
}
const vereinheitlicht = (text) => (typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : '')
/** Eine Zeile (Tag oder Woche) mit Standardtext oder ersetztem Text, Stunden und Satz; nie ueber 200 Zeichen. */
function aufwandZeile(einheit, satzCents, zeilentexte) {
const eigen = zeilentexte && Object.hasOwn(zeilentexte, einheit.schluessel) ? vereinheitlicht(zeilentexte[einheit.schluessel]) : ''
const standard = `${einheit.kopf}: ${einheit.texte.length ? einheit.texte.join('; ') : 'Arbeitszeit'}`
const schluss = ` – ${stundenText(einheit.minuten)} Std. à ${euroDe(satzCents)}`
const betragCents = Math.round((einheit.minuten * satzCents) / 60)
return {
schluessel: einheit.schluessel,
beschreibung: `${kuerzen(eigen || standard, LIMITS.maxBeschreibung - schluss.length)}${schluss}`,
menge: 1,
einzelpreisCents: betragCents,
betragCents,
}
}
/**
* Aufwandszeilen: eine Zeile je Tag; gibt es mehr Tage als maxZeilen, werden die aeltesten Kalenderwochen zu
* KW-Zeilen zusammengefasst. zeilentexte[schluessel] ersetzt den Text vor dem Gedankenstrich.
*/
export function aufwandPositionen(blaetter, { satzCents, zeilentexte = {}, maxZeilen = LIMITS.maxPositionen } = {}) {
if (!blaetter.length) return { positionen: [], minuten: 0, von: null, bis: null }
if (!istGanzzahlZwischen(satzCents, 1, LIMITS.maxEinzelpreisCents)) throw new AuftragError(400, 'Der Stundensatz ist ungültig.')
for (const blatt of blaetter) {
if (!gueltigerTag(blatt.datum) || !Number.isSafeInteger(blatt.minuten) || blatt.minuten <= 0) {
throw new AuftragError(400, 'Ein Arbeitsblatt hat kein gültiges Datum oder keine Zeit.')
}
}
const sortiert = [...blaetter].sort((a, b) => a.datum.localeCompare(b.datum) || String(a.id).localeCompare(String(b.id)))
const tage = []
for (const blatt of sortiert) {
let tag = tage.at(-1)
if (!tag || tag.datum !== blatt.datum) {
tag = { datum: blatt.datum, minuten: 0, texte: [] }
tage.push(tag)
}
tag.minuten += blatt.minuten
const text = vereinheitlicht(blatt.text)
if (text && !tag.texte.includes(text)) tag.texte.push(text)
}
const tagesEinheit = (tag) => ({ schluessel: tag.datum, kopf: formatDe(tag.datum), minuten: tag.minuten, texte: tag.texte })
const wochenEinheit = (woche) => {
const texte = []
for (const tag of woche.tage) for (const text of tag.texte) if (!texte.includes(text)) texte.push(text)
const ww = String(woche.woche).padStart(2, '0')
return { schluessel: `KW-${woche.jahr}-${ww}`, kopf: `KW ${ww}/${woche.jahr}`, minuten: woche.tage.reduce((s, t) => s + t.minuten, 0), texte }
}
const einheiten = []
if (tage.length <= maxZeilen) {
einheiten.push(...tage.map(tagesEinheit))
} else {
const wochen = []
for (const tag of tage) {
const kw = kalenderwoche(tag.datum)
let woche = wochen.find((w) => w.jahr === kw.jahr && w.woche === kw.woche)
if (!woche) {
woche = { ...kw, tage: [] }
wochen.push(woche)
}
woche.tage.push(tag)
}
let anzahl = tage.length
for (const woche of wochen) {
if (anzahl > maxZeilen && woche.tage.length > 1) {
anzahl -= woche.tage.length - 1
einheiten.push(wochenEinheit(woche))
} else einheiten.push(...woche.tage.map(tagesEinheit))
}
if (anzahl > maxZeilen) {
throw new AuftragError(400, `Zu viele Arbeitstage für eine Rechnung (auch nach Kalenderwochen mehr als ${maxZeilen} Zeilen). Bitte weniger Arbeitsblätter auswählen.`)
}
}
return {
positionen: einheiten.map((einheit) => aufwandZeile(einheit, satzCents, zeilentexte)),
minuten: tage.reduce((s, t) => s + t.minuten, 0),
von: tage[0].datum,
bis: tage.at(-1).datum,
}
}
/** Berliner Tag des Sendens der AB, sonst der Tag der Absprache (YYYY-MM-DD). */
export function auftragDatum(auftrag) {
if (auftrag.gesendetAm) {
const zeitpunkt = new Date(auftrag.gesendetAm)
if (!Number.isNaN(zeitpunkt.getTime())) return berlinDate(zeitpunkt)
}
return auftrag.auftragVom || ''
}
const angegeben = (wert) => wert !== undefined && wert !== null
/** Anzahlungszeile zu einer AB: Prozent oder Betrag, hoechstens Summe minus bereits Berechnetes. */
export function anzahlungPosition(auftrag, angaben = {}, bereitsBerechnetCents = 0) {
const { prozent: prozentEingabe, betragCents: betragEingabe, bezeichnung: bezeichnungEingabe = 'Anzahlung' } = angaben || {}
const bezeichnung = typeof bezeichnungEingabe === 'string' ? bezeichnungEingabe.trim() : ''
if (bezeichnung.length < 1 || bezeichnung.length > 60) throw new AuftragError(400, 'Die Bezeichnung braucht 1 bis 60 Zeichen.')
if (angegeben(prozentEingabe) && angegeben(betragEingabe)) {
throw new AuftragError(400, 'Anzahlung entweder in Prozent oder als Betrag angeben.')
}
let prozent = null
let betrag
if (angegeben(betragEingabe)) {
if (!Number.isSafeInteger(betragEingabe) || betragEingabe < 1) {
throw new AuftragError(400, 'Der Betrag muss eine ganze Zahl in Cent ab 1 sein.')
}
betrag = betragEingabe
} else {
prozent = angegeben(prozentEingabe) ? prozentEingabe
: auftrag.zahlungsplan?.art === 'anzahlung' && angegeben(auftrag.zahlungsplan.prozent) ? auftrag.zahlungsplan.prozent
: LIMITS.standardAnzahlungProzent
if (typeof prozent !== 'number' || !Number.isFinite(prozent) || prozent <= 0 || prozent > 100) {
throw new AuftragError(400, 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.')
}
betrag = Math.round((auftrag.summeCents * prozent) / 100)
if (betrag < 1) throw new AuftragError(400, 'Der Betrag muss mindestens 0,01 € betragen.')
}
const rest = auftrag.summeCents - bereitsBerechnetCents
if (rest <= 0) throw new AuftragError(409, 'Nichts mehr offen.')
if (betrag > rest) {
throw new AuftragError(400, `Die Anzahlung darf höchstens ${euroDe(rest)} betragen (Summe der Auftragsbestätigung minus bereits berechnet).`)
}
const prozentTeil = prozent === null ? '' : ` ${String(prozent).replace('.', ',')} %`
const davor = `${bezeichnung}${prozentTeil} gemäß Auftragsbestätigung ${auftrag.nummer} vom ${formatDe(auftragDatum(auftrag))}`
const titel = typeof auftrag.titel === 'string' ? auftrag.titel.trim() : ''
const beschreibung = titel ? `${davor} (${kuerzen(titel, LIMITS.maxBeschreibung - davor.length - 3)})` : davor
return { schluessel: 'anzahlung', beschreibung, menge: 1, einzelpreisCents: betrag, betragCents: betrag }
}
// Obergrenzen einer Schlussrechnung (Uebersicht, Nachtraege nach der Planrunde); alle anderen Rechnungen: LIMITS.maxPositionen
export const SCHLUSS_GRENZEN = { abPositionen: 20, zusatz: 10, abzuege: 10, zeilen: 40 }
/** Schlussrechnung: alle AB-Positionen, Nachtraege, je fruehere Rechnung eine Minuszeile. */
export function schlussPositionen(auftrag, fruehere, zusatz = []) {
const positionenAb = auftrag.positionen || []
if (positionenAb.length > SCHLUSS_GRENZEN.abPositionen) {
throw new AuftragError(400, `Die Auftragsbestätigung hat ${positionenAb.length} Positionen; eine Schlussrechnung übernimmt höchstens ${SCHLUSS_GRENZEN.abPositionen}.`)
}
if (Array.isArray(zusatz) && zusatz.length > SCHLUSS_GRENZEN.zusatz) {
throw new AuftragError(400, `Eine Schlussrechnung erlaubt höchstens ${SCHLUSS_GRENZEN.zusatz} Zusatzpositionen.`)
}
const abzuege = (fruehere || []).filter((f) => f.betragCents > 0)
if (abzuege.length > SCHLUSS_GRENZEN.abzuege) {
throw new AuftragError(400, `Die Schlussrechnung hätte ${abzuege.length} Abzüge für frühere Rechnungen; erlaubt sind höchstens ${SCHLUSS_GRENZEN.abzuege}.`)
}
const zeilen = positionenAb.map((p, i) => ({
schluessel: `ab-${i + 1}`,
beschreibung: p.beschreibung,
menge: p.menge,
einzelpreisCents: p.einzelpreisCents,
betragCents: p.menge * p.einzelpreisCents,
}))
zeilen.push(...zusatzPositionen(zusatz))
const sortiert = [...abzuege].sort((a, b) => a.datum.localeCompare(b.datum) || a.invoiceId.localeCompare(b.invoiceId))
for (const f of sortiert) {
zeilen.push({
schluessel: `abzug-${f.invoiceId}`,
beschreibung: `abzüglich ${f.art === 'anzahlung' ? 'Anzahlung' : 'Teilrechnung'}, Rechnung ${f.rechnungsnummer} vom ${formatDe(f.datum)}`,
menge: 1,
einzelpreisCents: -f.betragCents,
betragCents: -f.betragCents,
})
}
const summe = zeilen.reduce((s, p) => s + p.betragCents, 0)
if (summe > LIMITS.maxSummeCents) throw new AuftragError(400, 'Rechnungssumme zu hoch.')
if (summe <= 0) throw new AuftragError(409, 'Nichts mehr offen.')
return zeilen
}
@@ -0,0 +1,149 @@
/**
* Speicher der Projektrechnungen (Collection projektRechnungen, Übersicht V1/V3/V4). Je Stripe-Rechnung, die zu einem
* Projekt, einer AB oder einer Aufwandsabrechnung gehört, ein Dokument; Dokument-ID = Stripe-Rechnungs-ID. Aktiv
* heißt: die Rechnung zählt (ein Storno oder gelöschter Entwurf setzt aktiv auf false und gibt die Arbeitsblätter
* frei). Programmfehler (ungültige Kennungen, Typen, Filter) sind TypeError, AuftragError nur für 404/409/400.
*/
import { config } from '../../config.js'
import { createDocument, getDocument, listAllDocuments, Query, updateDocument } from '../appwriteAdmin.js'
import { AuftragError } from './fehler.js'
import { AUFTRAEGE_COLLECTIONS } from './schema.js'
// Wie STRIPE_INVOICE_ID im Bestand (/^in_\w+$/), höchstens 36 Zeichen (Grenze der Appwrite-Dokument-ID); der
// Stripe-Nachbau vergibt in_test_<n>
const RECHNUNGS_ID = /^in_\w{1,33}$/
const ARTEN = ['festbetrag', 'aufwand', 'anzahlung', 'schluss'] // frei hat nie ein Dokument
const JSON_GROESSE = AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes.find((a) => a.key === 'arbeitsblattIdsJson').size
const istText = (wert) => typeof wert === 'string'
const istNichtLeer = (wert) => istText(wert) && wert !== ''
function docToRechnung(doc) {
let ids = []
try {
const roh = JSON.parse(doc.arbeitsblattIdsJson)
if (Array.isArray(roh)) ids = roh.filter(istNichtLeer)
} catch {
// kaputtes oder leeres JSON: keine Arbeitsblätter
}
const text = (wert) => (istText(wert) ? wert : '')
const zeit = new Date(doc.erstelltAm || doc.$createdAt)
return {
invoiceId: doc.$id,
kundenId: text(doc.kundenId),
projektId: text(doc.projektId),
auftragId: text(doc.auftragId),
art: text(doc.art),
betragCents: Number.isSafeInteger(doc.betragCents) ? doc.betragCents : 0,
arbeitsblattIds: ids,
rechnungsnummer: text(doc.rechnungsnummer),
aktiv: doc.aktiv === true,
erstelltVon: text(doc.erstelltVon),
erstelltAm: Number.isNaN(zeit.getTime()) ? null : zeit.toISOString(),
}
}
/** Prüft die genannten Felder (jede Verletzung ist ein TypeError) und liefert das Dokument dazu. */
function feldDoc(daten, { vollstaendig }) {
const doc = {}
const hat = (key) => Object.hasOwn(daten, key) && daten[key] !== undefined
const text = (key, { pflicht = false } = {}) => {
if (!hat(key)) return
if (pflicht ? !istNichtLeer(daten[key]) : !istText(daten[key])) throw new TypeError(`Projektrechnung.${key} ist ungültig.`)
doc[key] = daten[key]
}
text('kundenId', { pflicht: true })
text('projektId')
text('auftragId')
text('rechnungsnummer')
text('erstelltVon')
if (hat('art')) {
if (!ARTEN.includes(daten.art)) throw new TypeError('Projektrechnung.art ist ungültig.')
doc.art = daten.art
}
if (hat('betragCents')) {
if (!Number.isSafeInteger(daten.betragCents) || daten.betragCents < 0) throw new TypeError('Projektrechnung.betragCents ist ungültig.')
doc.betragCents = daten.betragCents
}
if (hat('arbeitsblattIds')) {
if (!Array.isArray(daten.arbeitsblattIds) || !daten.arbeitsblattIds.every(istNichtLeer)) throw new TypeError('Projektrechnung.arbeitsblattIds ist ungültig.')
const json = JSON.stringify(daten.arbeitsblattIds)
if (json.length > JSON_GROESSE) throw new AuftragError(400, 'Zu viele Arbeitsblätter in einer Rechnung.')
doc.arbeitsblattIdsJson = json
}
if (hat('aktiv')) {
if (typeof daten.aktiv !== 'boolean') throw new TypeError('Projektrechnung.aktiv ist ungültig.')
doc.aktiv = daten.aktiv
}
if (vollstaendig && (!Object.hasOwn(doc, 'kundenId') || !Object.hasOwn(doc, 'art') || !Object.hasOwn(doc, 'betragCents'))) {
throw new TypeError('Projektrechnung braucht kundenId, art und betragCents.')
}
return doc
}
export async function getProjektRechnung(invoiceId) {
if (typeof invoiceId !== 'string' || !RECHNUNGS_ID.test(invoiceId)) return null
try {
return docToRechnung(await getDocument(config.collections.projektRechnungen, invoiceId))
} catch (err) {
if (err?.status === 404) return null
throw err
}
}
export async function createProjektRechnung(invoiceId, daten) {
if (typeof invoiceId !== 'string' || !RECHNUNGS_ID.test(invoiceId)) throw new TypeError('Ungültige Rechnungs-ID.')
const doc = {
projektId: '', auftragId: '', rechnungsnummer: '', erstelltVon: '', arbeitsblattIds: [], aktiv: true,
...Object.fromEntries(Object.entries(daten).filter(([, wert]) => wert !== undefined)),
}
const felder = { ...feldDoc(doc, { vollstaendig: true }), invoiceId, erstelltAm: new Date().toISOString() }
try {
return docToRechnung(await createDocument(config.collections.projektRechnungen, felder, invoiceId))
} catch (err) {
if (err?.status === 409) throw new AuftragError(409, 'Zu dieser Rechnung gibt es schon einen Eintrag.')
throw err
}
}
export async function updateProjektRechnung(invoiceId, patch) {
const nichtGefunden = () => new AuftragError(404, 'Projektrechnung nicht gefunden.')
if (typeof invoiceId !== 'string' || !RECHNUNGS_ID.test(invoiceId)) throw nichtGefunden()
const doc = feldDoc(patch, { vollstaendig: false })
try {
return docToRechnung(await updateDocument(config.collections.projektRechnungen, invoiceId, doc))
} catch (err) {
if (err?.status === 404) throw nichtGefunden()
throw err
}
}
/** Nach erstelltAm aufsteigend. Ein angegebener, aber leerer oder ungültiger Filter ist ein TypeError, nie „alle“. */
export async function listProjektRechnungen({ kundenId, projektId, auftragId, nurAktiv = true } = {}) {
const abfragen = []
for (const [key, wert] of Object.entries({ kundenId, projektId, auftragId })) {
if (wert === undefined) continue
if (!istNichtLeer(wert)) throw new TypeError(`Filter ${key} muss ein nicht leerer Text sein.`)
abfragen.push(Query.equal(key, wert))
}
if (typeof nurAktiv !== 'boolean') throw new TypeError('Filter nurAktiv muss ein Wahrheitswert sein.')
if (nurAktiv) abfragen.push(Query.equal('aktiv', true))
const docs = await listAllDocuments(config.collections.projektRechnungen, abfragen)
return docs
.map(docToRechnung)
.sort((a, b) => (a.erstelltAm || '').localeCompare(b.erstelltAm || '') || a.invoiceId.localeCompare(b.invoiceId))
}
/**
* Hängt alle Dokumente einer AB (auch stornierte) an die neue AB. Bei einem Fehler bleibt ein Teilstand, ein
* erneuter Aufruf setzt fort. Liefert die Anzahl umgehängter Dokumente.
*/
export async function umhaengen(auftragIdAlt, auftragIdNeu) {
if (!istNichtLeer(auftragIdAlt) || !istNichtLeer(auftragIdNeu)) throw new TypeError('umhaengen: beide AB-IDs müssen nicht leerer Text sein.')
if (auftragIdAlt === auftragIdNeu) return 0
const alte = await listProjektRechnungen({ auftragId: auftragIdAlt, nurAktiv: false })
for (const rechnung of alte) {
await updateDocument(config.collections.projektRechnungen, rechnung.invoiceId, { auftragId: auftragIdNeu })
}
return alte.length
}
+323
View File
@@ -0,0 +1,323 @@
import { config } from '../../config.js'
import { Query, getDocument, listDocuments } from '../appwriteAdmin.js'
import { getStripe } from '../stripeBilling.js'
import { berlinDate } from '../finance/dates.js'
import { listInvoices, mapInvoice } from '../finance/stripeFinance.js'
import { kindLabel } from '../projectKinds.js'
import { KUNDENARTEN, STUNDENSATZ_STANDARD_CENTS, VEREINBARTE_STATUS } from './konstanten.js'
import { AuftragError } from './fehler.js'
import { auftraegeAktiv } from './schalter.js'
import { auftragFuerTeam } from './auftragSicht.js'
import { listAuftraege, juengsteAufwandAb } from './auftragStore.js'
import { listProjektRechnungen } from './projektRechnungenStore.js'
import { abgerechneteArbeitsblaetter, abrechenbar, ladeArbeitsblaetter, zeitquelleFuerProjekt } from './arbeitsblaetter.js'
/**
* Projektkonto (Spec 2026-10-06, Abschnitt 4; Uebersicht V4 "L1 - Projektkonto"): reine Rechenregeln und die Ladefunktionen
* dazu. Alles hier liest nur, weder Appwrite noch Stripe werden geschrieben.
*/
// Rechnungsstatus laut mapInvoice (stripeFinance.js), die als "berechnet" zaehlen; Entwurf und storniert nie
const FINALISIERT = new Set(['offen', 'ueberfaellig', 'bezahlt', 'uneinbringlich'])
const summe = (liste, feld) => liste.reduce((s, eintrag) => s + eintrag[feld], 0)
const zeitWert = (minuten, satzCents) => Math.round((minuten * satzCents) / 60)
// Wert der Zeit = Summe der je Berliner Tag gerundeten Werte (Karte: Tag -> Minuten), genau wie die Zeilen einer Aufwandsrechnung
// (aufwandPositionen in L2: eine Zeile je Tag mit Math.round(Minuten × Satz / 60)); so laufen Konto und Rechnung nicht um Cent auseinander
const tageswert = (minutenJeTag, satzCents) => [...minutenJeTag.values()].reduce((s, minuten) => s + zeitWert(minuten, satzCents), 0)
const addiere = (karte, tag, minuten) => karte.set(tag, (karte.get(tag) ?? 0) + minuten)
/**
* Die Zahlen eines Projekts. auftraege: alle ABs des Projekts (jeder Status); rechnungen: [{ art, auftragId, invoice }] mit
* der Rechnung in mapInvoice-Form (nur aktive Projektrechnungen, deren Rechnung gefunden wurde); blaetter: Arbeitsblaetter der
* Zeitquelle (datum = Berliner Tag); abgerechnet: Map oder Set der Blatt-IDs aus aktiven Projektrechnungen (auch Entwuerfe);
* stripeOk false = die Rechnungen sind unbekannt (berechnet und bezahlt 0, offen null).
*/
export function rechneKonto({ auftraege = [], rechnungen = [], blaetter = [], abgerechnet = new Map(), stripeOk = true } = {}) {
const gueltig = auftraege.filter((auftrag) => VEREINBARTE_STATUS.includes(auftrag.status))
const festpreise = gueltig.filter((auftrag) => auftrag.art === 'festpreis')
const festpreisIds = new Set(festpreise.map((auftrag) => auftrag.id))
const festpreisCents = festpreise.length ? festpreise.reduce((s, auftrag) => s + (Number(auftrag.summeCents) || 0), 0) : null
const aufwandAb = juengsteAufwandAb(gueltig)
const aufwand = aufwandAb
? {
stundensatzCents: aufwandAb.stundensatzCents > 0 ? aufwandAb.stundensatzCents : STUNDENSATZ_STANDARD_CENTS,
schaetzungMinuten: aufwandAb.schaetzungMinuten ?? null,
}
: null
const satzCents = aufwand ? aufwand.stundensatzCents : STUNDENSATZ_STANDARD_CENTS
let berechnetCents = 0
let bezahltCents = 0
let aufFestpreisCents = 0
if (stripeOk) {
for (const { invoice, auftragId } of rechnungen) {
if (!FINALISIERT.has(invoice.status)) continue
berechnetCents += invoice.totalCents
bezahltCents += invoice.paidCents
// nur Rechnungen zu einer als vereinbart zaehlenden Festpreis-AB mindern das Offene
if (festpreisIds.has(auftragId)) aufFestpreisCents += invoice.totalCents
}
}
const offenCents = festpreisCents === null || !stripeOk ? null : Math.max(0, festpreisCents - aufFestpreisCents)
let minuten = 0
let aboMinuten = 0
let offeneMinuten = 0
const minutenJeTag = new Map() // alle abrechenbaren Blaetter (auch schon berechnete)
const offeneMinutenJeTag = new Map() // nur die offenen; ein Blatt ohne Datum (nur theoretisch) bildet einen eigenen Tag ''
for (const blatt of blaetter) {
if (abrechenbar(blatt)) {
minuten += blatt.minuten
addiere(minutenJeTag, blatt.datum, blatt.minuten)
if (!abgerechnet.has(blatt.id)) {
offeneMinuten += blatt.minuten
addiere(offeneMinutenJeTag, blatt.datum, blatt.minuten)
}
} else if (blatt.aboZeit && !blatt.kommentar) {
aboMinuten += blatt.minuten
}
}
return {
vereinbart: { festpreisCents, aufwand },
berechnetCents,
bezahltCents,
offenCents,
zeit: {
minuten,
aboMinuten,
offeneMinuten,
satzCents,
wertCents: tageswert(minutenJeTag, satzCents),
offenerWertCents: tageswert(offeneMinutenJeTag, satzCents),
},
}
}
/** Zeile "Ohne Projekt": Rechnungen in mapInvoice-Form; gezaehlt werden nur finalisierte. */
export function rechneOhneProjekt(rechnungen) {
const gezaehlt = rechnungen.filter((invoice) => FINALISIERT.has(invoice.status))
return { berechnetCents: summe(gezaehlt, 'totalCents'), bezahltCents: summe(gezaehlt, 'paidCents'), anzahl: gezaehlt.length }
}
// Einzellesen fehlender Rechnungen: wie die Leseanfragen in stripeFinance.js (15 s, eine Wiederholung); stripeFinance.js
// haelt seine Optionen privat, daher hier eine eigene Konstante
const LESE_OPTIONEN = { timeout: 15_000, maxNetworkRetries: 1 }
const EINZELN_PARALLEL = 10
// Format der Rechnungs-ID wie im Schema der Projektrechnungen (V1); die IDs des Stripe-Nachbaus (in_test_<n>) passen auch
const STRIPE_RECHNUNG = /^in_\w{1,33}$/
/** Eine Rechnung frisch von Stripe in mapInvoice-Form; null, wenn es sie nicht (mehr) gibt (404); andere Fehler fliegen. */
async function leseEinzeln(invoiceId) {
try {
const raw = await getStripe().invoices.retrieve(invoiceId, { expand: ['customer'] }, LESE_OPTIONEN)
return mapInvoice(raw, { today: berlinDate() })
} catch (err) {
if (err?.statusCode === 404 || err?.code === 'resource_missing') return null
throw err
}
}
/**
* Rechnungsdaten fuer eine Liste von Projektrechnungen: die Rechnungen der letzten 400 Tage aus listInvoices() (60-Sekunden-
* Zwischenspeicher); was dort fehlt (aelter, ueber 1000, frisch angelegt), wird einzeln gelesen, hoechstens 10 gleichzeitig.
* Scheitert Stripe (Liste oder Einzellesen ausser 404), ist ok false und alles leer; ins Protokoll kommen nur Art und Status.
*/
export async function ladeStripeRechnungen(projektRechnungen) {
try {
const alle = await listInvoices()
const nachId = new Map(alle.map((invoice) => [invoice.id, invoice]))
const fehlend = [...new Set(projektRechnungen.map((eintrag) => eintrag.invoiceId))].filter((id) => STRIPE_RECHNUNG.test(id) && !nachId.has(id))
for (let i = 0; i < fehlend.length; i += EINZELN_PARALLEL) {
const gelesen = await Promise.all(fehlend.slice(i, i + EINZELN_PARALLEL).map(leseEinzeln))
for (const invoice of gelesen) if (invoice) nachId.set(invoice.id, invoice)
}
return { ok: true, alle, nachId }
} catch (err) {
console.warn('[projektkonto] Stripe nicht lesbar:', err?.type || err?.name || 'Error', err?.statusCode ?? err?.status ?? '')
return { ok: false, alle: [], nachId: new Map() }
}
}
/** Dokument lesen; unbekannt -> AuftragError 404 "<was> nicht gefunden." */
async function ladeDokument(collection, id, was) {
try {
return await getDocument(collection, id)
} catch (err) {
if (err?.status === 404) throw new AuftragError(404, `${was} nicht gefunden.`)
throw err
}
}
const kundeAnsicht = (doc) => ({
id: doc.$id,
name: String(doc.companyName || '').trim() || String(doc.name || '').trim(),
kundenart: KUNDENARTEN.includes(doc.kundenart) ? doc.kundenart : '',
})
/** Ticket-Dokumente (fuer die Ticketnummer woid) zu den IDs, in Bloecken zu 100. */
async function ladeTickets(ids) {
const nachId = new Map()
for (let i = 0; i < ids.length; i += 100) {
const seite = await listDocuments(config.collections.workorders, [Query.equal('$id', ids.slice(i, i + 100)), Query.limit(100)])
for (const ticket of seite) nachId.set(ticket.$id, ticket)
}
return nachId
}
/**
* Tickets, Arbeitsblaetter (je Ticket gruppiert) und das Abgerechnete des Kunden fuer alle Zeitquellen auf einmal: je
* Anfrage einmal laden, egal wie viele Projekte. Ohne Ticket wird nichts abgefragt.
*/
async function ladeZeitDaten(kundenId, ticketIds) {
if (ticketIds.length === 0) return { tickets: new Map(), nachTicket: new Map(), abgerechnet: new Map() }
const [tickets, blaetter, abgerechnet] = await Promise.all([
ladeTickets(ticketIds),
ladeArbeitsblaetter(ticketIds),
abgerechneteArbeitsblaetter(kundenId),
])
const nachTicket = new Map()
for (const blatt of blaetter) {
if (!nachTicket.has(blatt.ticketId)) nachTicket.set(blatt.ticketId, [])
nachTicket.get(blatt.ticketId).push(blatt)
}
return { tickets, nachTicket, abgerechnet }
}
/** Zeitquelle des Projekts ohne Doppelte (Vereinigung der gueltigen ABs, sonst das Projektticket; siehe arbeitsblaetter.js). */
const zeitquelleVon = (projekt, abs) => [...new Set(zeitquelleFuerProjekt(projekt, abs))]
/**
* Ein Projektkonto (V3). abs: ABs des Projekts; prs: aktive Projektrechnungen des Projekts; stripeDaten: Ergebnis von
* ladeStripeRechnungen; zeit: Ergebnis von ladeZeitDaten. Liefert zusaetzlich die Rechnungen und Blaetter fuer das Detail.
*/
function baueKonto(projekt, { abs, zeitquelle, prs, stripeDaten, zeit }) {
const rechnungen = prs
.map((eintrag) => ({ art: eintrag.art, auftragId: eintrag.auftragId, invoice: stripeDaten.nachId.get(eintrag.invoiceId) }))
.filter((eintrag) => eintrag.invoice)
const blaetter = zeitquelle.flatMap((id) => zeit.nachTicket.get(id) ?? [])
return {
konto: {
projektId: projekt.$id,
name: projekt.projectName || '',
projektTyp: kindLabel(projekt.kind),
tickets: zeitquelle.map((id) => ({ id, woid: zeit.tickets.get(id)?.woid || '' })),
zeitquelle,
...rechneKonto({ auftraege: abs, rechnungen, blaetter, abgerechnet: zeit.abgerechnet, stripeOk: stripeDaten.ok }),
},
rechnungen,
blaetter,
}
}
/**
* Projektkonten eines Kunden (V4 "L1"): je sichtbarem Projekt ein Konto, dazu die Zeile "Ohne Projekt". Sichtbar sind alle
* Projekte des Kunden ausser Vorlagen; archivierte nur mit Geldbezug (aktive Projektrechnung oder AB mit Status aus
* VEREINBARTE_STATUS). Ist der AB-Schalter fuer den Kunden aus, werden ABs nicht gelesen (Vereinbart: keine AB).
*/
export async function projektkontenFuerKunde(kundenId) {
const kundeDoc = await ladeDokument(config.collections.customers, kundenId, 'Kunde')
const aktiv = auftraegeAktiv(kundeDoc)
const [projekte, auftraege, prs] = await Promise.all([
listDocuments(config.collections.websiteProjects, [Query.equal('customerId', kundenId), Query.orderDesc('$createdAt'), Query.limit(1000)]),
aktiv ? listAuftraege({ kundenId }) : [],
listProjektRechnungen({ kundenId }),
])
const stripeDaten = await ladeStripeRechnungen(prs)
const mitGeld = new Set([
...prs.map((eintrag) => eintrag.projektId),
...auftraege.filter((auftrag) => VEREINBARTE_STATUS.includes(auftrag.status)).map((auftrag) => auftrag.projektId),
])
const sichtbar = projekte.filter((projekt) => projekt.isTemplate !== true && (!projekt.archived || mitGeld.has(projekt.$id)))
const eintraege = sichtbar.map((projekt) => {
const abs = auftraege.filter((auftrag) => auftrag.projektId === projekt.$id)
return { projekt, abs, zeitquelle: zeitquelleVon(projekt, abs) }
})
const zeit = await ladeZeitDaten(kundenId, [...new Set(eintraege.flatMap((eintrag) => eintrag.zeitquelle))])
const konten = eintraege.map(
({ projekt, abs, zeitquelle }) =>
baueKonto(projekt, { abs, zeitquelle, prs: prs.filter((eintrag) => eintrag.projektId === projekt.$id), stripeDaten, zeit }).konto
)
// Ohne Projekt: finalisierte Projekt-Rechnungen des Kunden, die kein angezeigtes Konto beansprucht (keine Abo-Rechnungen)
const kandidaten = new Map()
for (const invoice of stripeDaten.alle) {
if (invoice.webklarCustomerId === kundenId && invoice.source === 'projekt') kandidaten.set(invoice.id, invoice)
}
for (const eintrag of prs) {
const invoice = stripeDaten.nachId.get(eintrag.invoiceId)
if (invoice) kandidaten.set(invoice.id, invoice)
}
const angezeigt = new Set(sichtbar.map((projekt) => projekt.$id))
const zugeordnet = new Set(prs.filter((eintrag) => angezeigt.has(eintrag.projektId)).map((eintrag) => eintrag.invoiceId))
const ohneProjekt = rechneOhneProjekt([...kandidaten.values()].filter((invoice) => !zugeordnet.has(invoice.id)))
return { auftraegeAktiv: aktiv, kunde: kundeAnsicht(kundeDoc), konten, ohneProjekt, stripeOk: stripeDaten.ok }
}
const neuesteZuerst = (a, b) => (b.createdDate ?? '').localeCompare(a.createdDate ?? '') || b.number.localeCompare(a.number) || b.id.localeCompare(a.id)
/**
* Ein Projektkonto mit seinen ABs, Rechnungen (aktive Projektrechnungen, soweit Stripe sie kennt) und den noch nicht
* abgerechneten Arbeitsblaettern (V4 "L1"). Bei Stripe-Ausfall: keine Rechnungen, stripeOk false.
*/
export async function projektkontoDetail(projektId) {
const projekt = await ladeDokument(config.collections.websiteProjects, projektId, 'Projekt')
if (!projekt.customerId) throw new AuftragError(404, 'Projekt nicht gefunden.')
const kundenId = projekt.customerId
const kundeDoc = await ladeDokument(config.collections.customers, kundenId, 'Kunde')
const aktiv = auftraegeAktiv(kundeDoc)
const [abs, prs] = await Promise.all([
aktiv ? listAuftraege({ kundenId, projektId }) : [],
listProjektRechnungen({ kundenId, projektId }),
])
const stripeDaten = await ladeStripeRechnungen(prs)
const zeitquelle = zeitquelleVon(projekt, abs)
const zeit = await ladeZeitDaten(kundenId, zeitquelle)
const { konto, rechnungen, blaetter } = baueKonto(projekt, { abs, zeitquelle, prs, stripeDaten, zeit })
return {
konto,
auftraege: abs.map((auftrag) => auftragFuerTeam(auftrag)),
rechnungen: rechnungen
.map(({ art, auftragId, invoice }) => ({
id: invoice.id,
number: invoice.number,
status: invoice.status,
createdDate: invoice.createdDate,
totalCents: invoice.totalCents,
paidCents: invoice.paidCents,
art,
auftragId,
}))
.sort(neuesteZuerst),
offeneArbeitsblaetter: blaetter
.filter((blatt) => abrechenbar(blatt) && !zeit.abgerechnet.has(blatt.id))
.sort((a, b) => a.datum.localeCompare(b.datum) || a.id.localeCompare(b.id)),
stripeOk: stripeDaten.ok,
}
}
/**
* Abzeichen "abgerechnet" an den Arbeitsblaettern eines Tickets (alle Mitarbeiter, ohne Betraege): { Blatt-ID: Rechnungsnummer
* oder 'Entwurf' }. Die Blatt-IDs kommen aus dem Ticket, die Zuordnung aus den aktiven Projektrechnungen seines Kunden.
*/
export async function abgerechnetFuerTicket(ticketId) {
let ticket
try {
ticket = await getDocument(config.collections.workorders, ticketId)
} catch (err) {
if (err?.status === 404) return {}
throw err
}
if (!ticket.customerId) return {}
const abgerechnet = await abgerechneteArbeitsblaetter(ticket.customerId)
if (abgerechnet.size === 0) return {}
const ergebnis = {}
for (const blatt of await ladeArbeitsblaetter([ticketId])) {
const eintrag = abgerechnet.get(blatt.id)
if (eintrag) ergebnis[blatt.id] = eintrag.rechnungsnummer || 'Entwurf'
}
return ergebnis
}
+418
View File
@@ -0,0 +1,418 @@
/**
* Baut aus der Eingabe einer Projektrechnung den RechnungsEntwurf (Positionen, Zusatzfelder, Metadaten, Projektdatensatz).
* Lesezugriffe: Kunde, Projekt, AB, Arbeitsblaetter und fruehere Rechnungen; geschrieben wird hier nichts (ausser der
* Selbstheilung veralteter Projektdatensaetze, siehe unten). Importiert nichts aus stripeFinance.js (Importzyklus).
*/
import { config } from '../../config.js'
import { getDocument, listAllDocuments, Query } from '../appwriteAdmin.js'
import { getStripe } from '../stripeBilling.js'
import { withLocks } from '../keyedLock.js'
import { AuftragError } from './fehler.js'
import {
RECHNUNGS_ARTEN, AB_RECHNUNGS_ARTEN, LIMITS, VEREINBARTE_STATUS, STUNDENSATZ_STANDARD_CENTS, SCHALTER_AUS_MELDUNG, sperreAuftrag,
} from './konstanten.js'
import {
festePositionen, zusatzPositionen, pruefePositionen, kuerzen, auftragDatum, aufwandPositionen, leistungszeitraumText,
anzahlungPosition, schlussPositionen, SCHLUSS_GRENZEN,
} from './positionen.js'
import { auftraegeAktiv } from './schalter.js'
import { getAuftrag, listAuftraege, updateAuftrag, juengsteAufwandAb } from './auftragStore.js'
import { getProjektRechnung, listProjektRechnungen, updateProjektRechnung } from './projektRechnungenStore.js'
import {
ladeArbeitsblaetter, ladeArbeitsblaetterNachIds, abrechenbar, abgerechneteArbeitsblaetter, offeneArbeitsblaetter, zeitquelleFuerProjekt,
} from './arbeitsblaetter.js'
import { berlinDate, formatDe } from '../finance/dates.js'
import { euroDe } from '../finance/format.js'
// Appwrite-Dokument-ID
const KENNUNG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
/** Kunde aus Appwrite; nur feste Kunden (nicht lead/lost) bekommen Rechnungen. */
export async function ladeFestenKunden(customerId) {
if (typeof customerId !== 'string' || !customerId || !KENNUNG.test(customerId)) throw new AuftragError(400, 'Kunde nicht gefunden.')
let kunde
try {
kunde = await getDocument(config.collections.customers, customerId)
} catch (err) {
if (err.status === 404) throw new AuftragError(400, 'Kunde nicht gefunden.')
throw err
}
const stufe = String(kunde.customerStatus || '').trim().toLowerCase()
if (stufe === 'lead' || stufe === 'lost') throw new AuftragError(400, 'Rechnungen nur für feste Kunden.')
return kunde
}
/**
* Projekt des Kunden; fremde und unbekannte Projekte sind nicht zu unterscheiden (404). Mit weich = true liefert jeder
* Fehler stattdessen null (nur Lesefehler von Appwrite werden weitergereicht).
*/
async function ladeProjekt(kundenId, projektId, { weich = false } = {}) {
const fehler = (status, meldung) => {
if (weich) return null
throw new AuftragError(status, meldung)
}
if (typeof projektId !== 'string' || !projektId) return fehler(400, 'Für diese Rechnungsart ist ein Projekt nötig.')
if (!KENNUNG.test(projektId)) return fehler(404, 'Projekt nicht gefunden.')
let projekt
try {
projekt = await getDocument(config.collections.websiteProjects, projektId)
} catch (err) {
if (err.status === 404) return fehler(404, 'Projekt nicht gefunden.')
throw err
}
if (projekt.customerId !== kundenId) return fehler(404, 'Projekt nicht gefunden.')
return { id: projekt.$id, name: projekt.projectName || projekt.subdomain || 'Projekt', ticketId: projekt.ticketId || '' }
}
/** AB des Kunden; fremde und unbekannte sind nicht zu unterscheiden (404). */
async function ladeAuftrag(kundenId, auftragId) {
if (typeof auftragId !== 'string' || !KENNUNG.test(auftragId)) throw new AuftragError(404, 'Auftrag nicht gefunden.')
const auftrag = await getAuftrag(auftragId)
if (!auftrag || auftrag.kundenId !== kundenId) throw new AuftragError(404, 'Auftrag nicht gefunden.')
return auftrag
}
/**
* Buchung nach einem Stripe-Erfolg: scheitert sie, wird nur protokolliert (eine festgeschriebene Rechnung scheitert nicht an
* Appwrite). Fehler mit Status 404 sind still. Wirft nie.
*/
export async function buche(was, fn) {
try {
await fn()
} catch (err) {
if (err?.status === 404) return
console.error(`[finanzen] ${was} fehlgeschlagen:`, err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '')
}
}
/** Schlussrechnung finalisiert: die AB geht von versendet oder bestaetigt auf abgerechnet. Unter der Sperre der AB. */
export async function setzeAbAbgerechnet(auftragId) {
await withLocks(sperreAuftrag(auftragId), async () => {
const ab = await getAuftrag(auftragId)
if (ab && (ab.status === 'versendet' || ab.status === 'bestaetigt')) await updateAuftrag(ab.id, { status: 'abgerechnet' })
})
}
/** Schlussrechnung weg (storniert, geloescht): die AB geht von abgerechnet zurueck. Unter der Sperre der AB. */
export async function setzeAbZurueck(auftragId) {
await withLocks(sperreAuftrag(auftragId), async () => {
const ab = await getAuftrag(auftragId)
if (ab && ab.status === 'abgerechnet') await updateAuftrag(ab.id, { status: ab.bestaetigtAm ? 'bestaetigt' : 'versendet' })
})
}
/** Projektdatensatz inaktiv setzen (Blaetter wieder frei); bei einer Schlussrechnung geht die AB zurueck. */
export async function gibProjektRechnungFrei(invoiceId) {
const doc = await updateProjektRechnung(invoiceId, { aktiv: false })
if (doc.art === 'schluss' && doc.auftragId) await setzeAbZurueck(doc.auftragId)
return doc
}
const fehltBeiStripe = (err) => err?.statusCode === 404 || err?.code === 'resource_missing'
/**
* Selbstheilung: belegt ist Map<blattId, { invoiceId, rechnungsnummer }>. Je Rechnung bei Stripe nachsehen: storniert oder
* geloescht -> Datensatz inaktiv, die Belegung gilt nicht; sonst gilt sie (mit der Nummer von Stripe, fehlende Nummer wird
* im Datensatz nachgetragen). Rueckgabe: die noch geltenden Belegungen.
*/
async function pruefeBelegungen(belegt) {
const urteile = new Map()
for (const { invoiceId } of belegt.values()) {
if (urteile.has(invoiceId)) continue
let roh
try {
roh = await getStripe().invoices.retrieve(invoiceId)
} catch (err) {
if (!fehltBeiStripe(err)) throw err
roh = null
}
if (!roh || roh.status === 'void') {
await buche('Rechnung freigeben', () => gibProjektRechnungFrei(invoiceId))
urteile.set(invoiceId, null)
continue
}
const nummer = roh.status === 'draft' ? '' : roh.number || ''
urteile.set(invoiceId, nummer)
}
const gueltig = new Map()
for (const [blattId, belegung] of belegt) {
const nummer = urteile.get(belegung.invoiceId)
if (nummer === null) continue
if (nummer && nummer !== belegung.rechnungsnummer && !gueltig.has(`nachgetragen:${belegung.invoiceId}`)) {
gueltig.set(`nachgetragen:${belegung.invoiceId}`, true)
await buche('Rechnungsnummer nachtragen', () => updateProjektRechnung(belegung.invoiceId, { rechnungsnummer: nummer }))
}
gueltig.set(blattId, { invoiceId: belegung.invoiceId, rechnungsnummer: nummer })
}
for (const schluessel of [...gueltig.keys()]) if (schluessel.startsWith('nachgetragen:')) gueltig.delete(schluessel)
return gueltig
}
function pruefeLeistungszeitraum(wert) {
if (wert === undefined || wert === null) return ''
if (typeof wert !== 'string' || wert.trim().length > LIMITS.maxLeistungszeitraum) {
throw new AuftragError(400, `Der Leistungszeitraum muss ein Text mit höchstens ${LIMITS.maxLeistungszeitraum} Zeichen sein.`)
}
return wert.trim()
}
/** Gemeinsamer Abschluss aller Arten: Grenzen, Zusatzfelder, Metadaten, Projektdatensatz. */
function schliesseAb({ kunde, art, projekt, auftrag, positionen, leistungszeitraum, arbeitsblattIds = [], createdBy }) {
const summeCents = pruefePositionen(positionen, { max: art === 'schluss' ? SCHLUSS_GRENZEN.zeilen : LIMITS.maxPositionen })
const projektId = projekt?.id ?? auftrag?.projektId ?? ''
const projektName = kuerzen(projekt?.name ?? auftrag?.titel ?? '', 140)
const customFields = []
if (art !== 'frei' && projektName) customFields.push({ name: 'Projekt', value: projektName })
if (auftrag) customFields.push({ name: 'Auftrag', value: `${auftrag.nummer} vom ${formatDe(auftragDatum(auftrag))}` })
if (leistungszeitraum) customFields.push({ name: 'Leistungszeitraum', value: leistungszeitraum })
const metadata = { webklarCustomerId: kunde.$id, quelle: 'woms', erstelltVon: String(createdBy || '').slice(0, 500), art }
if (projektId) metadata.projektId = projektId
if (projektName) metadata.projektName = projektName
if (auftrag) {
metadata.auftragId = auftrag.id
if (auftrag.nummer) metadata.auftragNummer = auftrag.nummer
}
const projektRechnung = art === 'frei' ? null : {
kundenId: kunde.$id,
projektId,
auftragId: auftrag?.id ?? '',
art,
betragCents: summeCents,
arbeitsblattIds: art === 'aufwand' ? [...arbeitsblattIds].sort() : [],
rechnungsnummer: '',
aktiv: true,
erstelltVon: String(createdBy || '').slice(0, 255),
erstelltAm: new Date().toISOString(),
}
return { kunde, art, projekt: projekt ?? null, auftrag: auftrag ?? null, positionen, summeCents, leistungszeitraum, customFields, metadata, projektRechnung }
}
/** Baut den RechnungsEntwurf einer Rechnung der Art frei, festbetrag, aufwand, anzahlung oder schluss. */
export async function baueRechnung(eingabe) {
const art = eingabe.art ?? 'frei'
if (typeof art !== 'string' || !RECHNUNGS_ARTEN.includes(art)) throw new AuftragError(400, 'Unbekannte Rechnungsart.')
const kunde = await ladeFestenKunden(eingabe.customerId)
if (AB_RECHNUNGS_ARTEN.includes(art) && !auftraegeAktiv(kunde)) throw new AuftragError(400, SCHALTER_AUS_MELDUNG)
const leistungszeitraum = pruefeLeistungszeitraum(eingabe.leistungszeitraum)
const basis = { kunde, art, leistungszeitraum, createdBy: eingabe.createdBy }
if (art === 'frei') return schliesseAb({ ...basis, positionen: festePositionen(eingabe.items) })
if (art === 'festbetrag') {
const projekt = await ladeProjekt(kunde.$id, eingabe.projektId)
return schliesseAb({ ...basis, projekt, positionen: festePositionen(eingabe.items) })
}
if (art === 'aufwand') return baueAufwand(basis, eingabe)
return baueAbRechnung(basis, eingabe)
}
const MAX_BLAETTER = 400
const KEIN_BLATT = 'Arbeitsblatt nicht gefunden.'
function pruefeZeilentexte(wert) {
if (wert === undefined || wert === null) return {}
if (typeof wert !== 'object' || Array.isArray(wert) || Object.values(wert).some((text) => typeof text !== 'string')) {
throw new AuftragError(400, 'Die Zeilentexte müssen ein Objekt mit Texten sein.')
}
return wert
}
/** Auftrag der Aufwandsrechnung: ausdruecklich genannt (muss passen) oder die juengste gueltige Aufwands-AB des Projekts. */
async function ladeAufwandAuftrag(kunde, projekt, auftragId) {
if (auftragId !== undefined && auftragId !== null && auftragId !== '') {
if (!auftraegeAktiv(kunde)) throw new AuftragError(400, SCHALTER_AUS_MELDUNG)
const auftrag = await ladeAuftrag(kunde.$id, auftragId)
if (auftrag.art !== 'aufwand') throw new AuftragError(400, 'Eine Aufwandsrechnung passt nur zu einer Aufwands-Auftragsbestätigung.')
if (!VEREINBARTE_STATUS.includes(auftrag.status)) throw new AuftragError(400, 'Die Auftragsbestätigung ist nicht mehr gültig.')
if (auftrag.projektId && auftrag.projektId !== projekt.id) throw new AuftragError(400, 'Der Auftrag gehört nicht zu diesem Projekt.')
return auftrag
}
if (!auftraegeAktiv(kunde)) return null
const liste = await listAuftraege({ kundenId: kunde.$id, projektId: projekt.id })
return juengsteAufwandAb(liste)
}
/** Ausdrueckliche Auswahl der Blaetter (Pruefung, Zugehoerigkeit zum Kunden, Doppelabrechnung mit Selbstheilung). */
async function waehleBlaetter(kunde, ids, eigeneTickets) {
if (!Array.isArray(ids) || ids.some((id) => typeof id !== 'string')) {
throw new AuftragError(400, 'Die Arbeitsblätter müssen als Liste von Kennungen übergeben werden.')
}
const eindeutig = [...new Set(ids)]
if (!eindeutig.length) throw new AuftragError(400, 'Keine Arbeitsblätter ausgewählt.')
if (eindeutig.length > MAX_BLAETTER) throw new AuftragError(400, `Zu viele Arbeitsblätter ausgewählt (höchstens ${MAX_BLAETTER}).`)
if (eindeutig.some((id) => !KENNUNG.test(id))) throw new AuftragError(400, KEIN_BLATT)
const blaetter = await ladeArbeitsblaetterNachIds(eindeutig)
if (blaetter.length !== eindeutig.length || blaetter.some((b) => !eigeneTickets.has(b.ticketId))) throw new AuftragError(400, KEIN_BLATT)
if (blaetter.some((b) => !abrechenbar(b))) {
throw new AuftragError(400, 'Mindestens ein Arbeitsblatt ist nicht abrechenbar (Abo-Zeit, Kommentar oder ohne Zeit).')
}
const belegtAlle = await abgerechneteArbeitsblaetter(kunde.$id)
const belegt = new Map(eindeutig.filter((id) => belegtAlle.has(id)).map((id) => [id, belegtAlle.get(id)]))
if (belegt.size) {
const gueltig = await pruefeBelegungen(belegt)
for (const id of eindeutig) {
const b = gueltig.get(id)
if (b) throw new AuftragError(409, `Arbeitsblatt bereits abgerechnet (${b.rechnungsnummer ? `Rechnung ${b.rechnungsnummer}` : 'Entwurf'}).`)
}
}
return blaetter
}
/** Standardauswahl: alle offenen Blaetter der Zeitquelle des Projekts; ist nichts offen, vorher Selbstheilung. */
async function standardBlaetter(kunde, projekt, eigeneTickets) {
const abs = auftraegeAktiv(kunde) ? await listAuftraege({ kundenId: kunde.$id, projektId: projekt.id }) : []
const zeitquelle = zeitquelleFuerProjekt(projekt, abs).filter((id) => eigeneTickets.has(id))
const lade = () => (zeitquelle.length ? offeneArbeitsblaetter(zeitquelle, kunde.$id) : [])
let offene = await lade()
if (!offene.length && zeitquelle.length) {
const belegtAlle = await abgerechneteArbeitsblaetter(kunde.$id)
const belegt = new Map()
for (const blatt of await ladeArbeitsblaetter(zeitquelle)) {
if (abrechenbar(blatt) && belegtAlle.has(blatt.id)) belegt.set(blatt.id, belegtAlle.get(blatt.id))
}
if (belegt.size) {
const gueltig = await pruefeBelegungen(belegt)
if (gueltig.size < belegt.size) offene = await lade()
}
}
if (!offene.length) throw new AuftragError(409, 'Keine offenen Arbeitsblätter zum Abrechnen.')
if (offene.length > MAX_BLAETTER) throw new AuftragError(400, `Zu viele Arbeitsblätter ausgewählt (höchstens ${MAX_BLAETTER}).`)
return offene
}
async function baueAufwand(basis, eingabe) {
const { kunde } = basis
const projekt = await ladeProjekt(kunde.$id, eingabe.projektId)
const auftrag = await ladeAufwandAuftrag(kunde, projekt, eingabe.auftragId)
const satzCents = auftrag?.stundensatzCents ?? STUNDENSATZ_STANDARD_CENTS
const zusatz = zusatzPositionen(eingabe.items)
const zeilentexte = pruefeZeilentexte(eingabe.zeilentexte)
const eigeneTickets = new Set(
(await listAllDocuments(config.collections.workorders, [Query.equal('customerId', kunde.$id)])).map((d) => d.$id)
)
const ausdruecklich = eingabe.arbeitsblattIds !== undefined && eingabe.arbeitsblattIds !== null
const blaetter = ausdruecklich ? await waehleBlaetter(kunde, eingabe.arbeitsblattIds, eigeneTickets) : await standardBlaetter(kunde, projekt, eigeneTickets)
const { positionen: tage, von, bis } = aufwandPositionen(blaetter, { satzCents, zeilentexte, maxZeilen: LIMITS.maxPositionen - zusatz.length })
return schliesseAb({
...basis,
projekt,
auftrag,
positionen: [...tage, ...zusatz],
leistungszeitraum: basis.leistungszeitraum || leistungszeitraumText(von, bis),
arbeitsblattIds: blaetter.map((b) => b.id),
})
}
const FINALISIERT = ['open', 'paid', 'uncollectible']
/**
* Fruehere Projektrechnungen einer AB (aktive Datensaetze) mit dem Stand bei Stripe. Selbstheilung: eine im Dashboard
* stornierte oder geloeschte Rechnung wird freigegeben (bei einer Schlussrechnung geht die AB zurueck) und uebersprungen.
*/
async function ladeFruehere(auftragId) {
const ergebnis = []
for (const doc of await listProjektRechnungen({ auftragId })) {
let roh
try {
roh = await getStripe().invoices.retrieve(doc.invoiceId)
} catch (err) {
if (!fehltBeiStripe(err)) throw err
roh = null
}
if (!roh || roh.status === 'void') {
await buche('Rechnung freigeben', () => gibProjektRechnungFrei(doc.invoiceId))
continue
}
const nummer = roh.number || ''
if (FINALISIERT.includes(roh.status) && nummer && !doc.rechnungsnummer) {
await buche('Rechnungsnummer nachtragen', () => updateProjektRechnung(doc.invoiceId, { rechnungsnummer: nummer }))
}
ergebnis.push({
invoiceId: doc.invoiceId,
art: doc.art,
status: roh.status,
rechnungsnummer: nummer,
datum: berlinDate(new Date((roh.status_transitions?.finalized_at ?? roh.created) * 1000)),
betragCents: roh.total ?? 0,
})
}
return ergebnis
}
/** Anzahlung und Schlussrechnung zu einer Festpreis-AB. */
async function baueAbRechnung(basis, eingabe) {
const { kunde, art } = basis
const { auftragId } = eingabe
if (typeof auftragId !== 'string' || !auftragId) throw new AuftragError(400, 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.')
let auftrag = await ladeAuftrag(kunde.$id, auftragId)
if (auftrag.art !== 'festpreis') throw new AuftragError(400, 'Anzahlung und Schlussrechnung gehen nur mit einer Festpreis-Auftragsbestätigung.')
if (auftrag.status === 'abgerechnet') {
await ladeFruehere(auftrag.id) // gibt eine im Dashboard stornierte Schlussrechnung frei und setzt die AB zurueck
auftrag = await ladeAuftrag(kunde.$id, auftragId)
}
if (!['versendet', 'bestaetigt'].includes(auftrag.status)) throw new AuftragError(400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.')
let projekt = null
if (typeof eingabe.projektId === 'string' && eingabe.projektId) {
projekt = await ladeProjekt(kunde.$id, eingabe.projektId)
if (auftrag.projektId && auftrag.projektId !== projekt.id) throw new AuftragError(400, 'Der Auftrag gehört nicht zu diesem Projekt.')
} else if (auftrag.projektId) {
projekt = await ladeProjekt(kunde.$id, auftrag.projektId, { weich: true })
}
const fruehere = await ladeFruehere(auftrag.id)
const entwurf = fruehere.find((f) => f.status === 'draft' && (art === 'schluss' || f.art === 'schluss'))
if (entwurf) {
throw new AuftragError(409, art === 'schluss'
? `Erst den Entwurf über ${euroDe(entwurf.betragCents)} senden oder löschen.`
: 'Zu dieser Auftragsbestätigung gibt es schon einen Entwurf der Schlussrechnung. Erst den Entwurf senden oder löschen.')
}
const finalisierte = fruehere.filter((f) => FINALISIERT.includes(f.status))
let positionen
if (art === 'anzahlung') {
const angaben = eingabe.anzahlung ?? {}
if (typeof angaben !== 'object' || Array.isArray(angaben)) throw new AuftragError(400, 'Die Angaben zur Anzahlung müssen ein Objekt sein.')
positionen = [anzahlungPosition(auftrag, angaben, finalisierte.reduce((s, f) => s + f.betragCents, 0))]
} else {
positionen = schlussPositionen(
auftrag,
finalisierte.map(({ invoiceId, rechnungsnummer, datum, betragCents, art: fArt }) => ({ invoiceId, rechnungsnummer, datum, betragCents, art: fArt })),
eingabe.items
)
}
return schliesseAb({ ...basis, projekt, auftrag, positionen })
}
const AB_UNGUELTIG = 'Die Auftragsbestätigung ist nicht mehr gültig (storniert, widerrufen, ersetzt oder schon abgerechnet). Der Entwurf wird nicht gesendet.'
/**
* Letzte Pruefung vor dem Senden eines Anzahlungs- oder Schluss-Entwurfs (Aufrufer haelt sperreRechnung des Kunden):
* die AB muss noch versendet oder bestaetigt sein, bei einer Schlussrechnung darf keine andere Schlussrechnung
* festgeschrieben sein, und bei einer Anzahlung darf die Summe aller festgeschriebenen Rechnungen zur AB plus diese die
* AB-Summe nicht uebersteigen (zwei Entwuerfe zaehlen beim Anlegen nicht mit). Sonst AuftragError 409; nichts wird
* geschrieben ausser der Selbstheilung von ladeFruehere. Die AB kommt aus dem Projektdatensatz (nach umhaengen die neue),
* ersatzweise aus den Stripe-Metadaten; ohne AB gibt es nichts zu pruefen.
*/
export async function pruefeVorSenden({ invoiceId, art, auftragIdMetadaten = '', totalCents }) {
if (!AB_RECHNUNGS_ARTEN.includes(art)) return
const auftragId = (await getProjektRechnung(invoiceId))?.auftragId || auftragIdMetadaten
if (!auftragId) return
const ab = await getAuftrag(auftragId)
if (!ab || !['versendet', 'bestaetigt'].includes(ab.status)) throw new AuftragError(409, AB_UNGUELTIG)
const andere = (await ladeFruehere(ab.id)).filter((f) => f.invoiceId !== invoiceId && FINALISIERT.includes(f.status))
if (art === 'schluss' && andere.some((f) => f.art === 'schluss')) {
throw new AuftragError(409, 'Zu dieser Auftragsbestätigung gibt es schon eine Schlussrechnung. Der Entwurf wird nicht gesendet.')
}
// Eine Schlussrechnung darf mit Nachtraegen ueber der AB-Summe liegen: die Obergrenze gilt nur fuer die Anzahlung
if (art === 'anzahlung') {
const berechnet = andere.reduce((summe, f) => summe + f.betragCents, 0)
if (berechnet + totalCents > ab.summeCents) {
throw new AuftragError(409, `Die Summe der Rechnungen würde die Auftragssumme von ${euroDe(ab.summeCents)} übersteigen (bereits berechnet ${euroDe(berechnet)}). Der Entwurf wird nicht gesendet.`)
}
}
}
/** Gibt es zur AB (aus dem Projektdatensatz der Rechnung, ersatzweise aus den Stripe-Metadaten) einen Entwurf der Schlussrechnung? */
export async function hatSchlussEntwurf({ invoiceId, auftragIdMetadaten = '' }) {
const auftragId = (await getProjektRechnung(invoiceId))?.auftragId || auftragIdMetadaten
if (!auftragId) return false
return (await ladeFruehere(auftragId)).some((f) => f.art === 'schluss' && f.status === 'draft')
}
+21
View File
@@ -0,0 +1,21 @@
import { config } from '../../config.js'
/**
* Pilot-Schalter der Aufträge (AUFTRAEGE_ENABLED = off (Vorgabe) | pilot | on). Betrifft nur die AB-Funktionen und
* die Rechnungsarten anzahlung/schluss; Projektkonto und die Arten frei, festbetrag, aufwand laufen immer.
* Liest config.auftraege bei jedem Aufruf.
*/
/**
* Darf dieser Kunde Auftragsbestätigungen nutzen? `kunde`: Kundendokument oder { email }. on = alle; pilot = Liste
* enthält '*' oder die (klein geschriebene, getrimmte) E-Mail-Adresse steht in AUFTRAEGE_PILOT_EMAILS (eine leere
* Liste lässt niemanden durch); alles andere, auch ein unbekannter Modus, gilt als off.
*/
export function auftraegeAktiv(kunde) {
const { mode, pilotEmails } = config.auftraege
if (mode === 'on') return true
if (mode !== 'pilot') return false
if (pilotEmails.includes('*')) return true
const adresse = String(kunde?.email || '').trim().toLowerCase()
return adresse !== '' && pilotEmails.includes(adresse)
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Aufträge und Rechnungen: Appwrite-Schema der Collections auftraege und projektRechnungen sowie der Zusatzfelder
* (Übersicht V1). Reine Daten, absichtlich ohne Import von server/config.js: test/support/schema.js liest sie vor
* useTestEnv(), ein früh geladenes config.js würde den Endpunkt des Appwrite-Nachbaus verfehlen. Die Collection-IDs
* stehen in config.collections.<Name>; scripts/auftraege-setup.mjs legt alles an.
*
* Attribut: { key, type: 'string' | 'integer' | 'boolean' | 'datetime', size (nur Text), required: false }
* Index: { key, type: 'key', attributes: [...] }
* Datumswerte sind UTC (toISOString()); Appwrite liefert sie als "+00:00" zurück, daher immer mit new Date() lesen.
*/
const str = (key, size) => ({ key, type: 'string', size, required: false })
const int = (key) => ({ key, type: 'integer', required: false })
const bool = (key) => ({ key, type: 'boolean', required: false })
const dt = (key) => ({ key, type: 'datetime', required: false })
const index = (key, ...attributes) => ({ key, type: 'key', attributes })
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'),
],
// idx_nummer bewusst kein Unique-Index: Entwürfe haben nummer ''. Die Eindeutigkeit stellt vergibNummer unter
// SPERRE_NUMMER her (L3).
indexes: [index('idx_kunde', 'kundenId'), index('idx_projekt', 'projektId'), index('idx_jahr', 'jahr'),
index('idx_token', 'tokenHash'), index('idx_nummer', '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')],
},
}
// Zusätzliche Felder bestehender Collections (Schlüssel = Name in config.collections)
export const AUFTRAEGE_ZUSATZ_ATTRIBUTE = {
customers: [str('kundenart', 20)], // '' | 'unternehmen' | 'privat'
financeSettings: [str('steuernummer', 40)],
}
// Datumsfelder je Collection, für startAppwriteMock({ datetimes }) (Appwrite liefert sie mit "+00:00")
export const AUFTRAEGE_DATETIMES = Object.fromEntries(
Object.entries(AUFTRAEGE_COLLECTIONS).map(([name, { attributes }]) => [name, attributes.filter((a) => a.type === 'datetime').map((a) => a.key)]),
)
+176
View File
@@ -0,0 +1,176 @@
/**
* Wortlaute der Auftragsbestätigung (AB), der Bestätigung durch den Kunden und der Texte für Privatpersonen (Frühbeginn,
* Widerrufsbelehrung, Muster-Widerrufsformular). Reine Konstanten und Funktionen ohne Zugriff auf Appwrite, Stripe oder die
* Konfiguration. Endfassung der Rechtsrunde vom 06.10.2026 (Strang L7); die Zusammenstellung zur Freigabe liegt in
* /root/reports/auftraege-rechnungen-2026-10-06/rechtsrunde.md.
*
* Alle Kundentexte stehen in der Sie-Form und ohne Gedankenstriche im Fließtext. Widerrufsbelehrung und Muster-Widerrufsformular sind
* die Muster der Anlagen 1 und 2 zu Art. 246a § 1 Abs. 2 Satz 2 EGBGB für einen Dienstleistungsvertrag (Quellen:
* docs/rechtliches/quellen/anlage-1.txt und anlage-2.txt) und gleich der Belehrung für die Bestellung einer Funktion
* (widerrufsbelehrungFunktion in services/termine/rechtstexte.js); test/auftraegeRechtstexte.test.js prüft beides.
* Die Frühbeginn-Erklärung folgt den Erklärungen der Funktionen: § 356 Abs. 5 Nr. 2 BGB (Erlöschen), § 357a Abs. 2 BGB (Wertersatz).
*
* Ändert sich ein Wortlaut nach der Auslieferung, bekommt TEXTE_FASSUNG ein neues Datum. Die Fassung steht im Nachweis jeder
* Auftragsbestätigung (texteFassung); die Dateien früherer Fassungen unter public/rechtliches/ bleiben unverändert.
*/
import { AUFTRAG_WEGE, LIMITS } from './konstanten.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' }
// ---------------------------------------------------------------------------------------------------------- Hilfen
/** Pflichtwert: fehlt er, entsteht ein Fehler statt eines Textes mit „undefined“ (eine Auftragsbestätigung darf nie unvollständig entstehen). */
function pflicht(wert, name) {
if (wert === undefined || wert === null || String(wert).trim() === '') throw new TypeError(`Text der Auftragsbestätigung: ${name} fehlt.`)
return String(wert).trim()
}
/** Klartext der Absprache: der Schlüssel aus AUFTRAG_WEGE („telefon“) wird zu „telefonisch“, ein fertiger Klartext bleibt, ohne Angabe entfällt er. */
function wegText(weg) {
const w = typeof weg === 'string' ? weg.trim() : ''
if (w === '') return ''
return Object.hasOwn(AUFTRAG_WEGE, w) ? AUFTRAG_WEGE[w] : w
}
/** Zahlungsziel als Satzteil: 0 = sofort, sonst „innerhalb von n Tagen nach Rechnungsdatum“; fehlend oder ungültig = Vorgabe (14 Tage). */
function zielText(ziel) {
const n = typeof ziel === 'number' ? ziel : Number.parseInt(String(ziel), 10)
const tage = Number.isInteger(n) && n >= 0 ? n : LIMITS.standardZahlungszielTage
if (tage === 0) return 'sofort nach Rechnungsstellung'
return `innerhalb von ${tage} ${tage === 1 ? 'Tag' : 'Tagen'} nach Rechnungsdatum`
}
/** Zahl mit Dezimalkomma (33.5 wird „33,5“). */
const zahl = (wert) => String(wert).replace('.', ',')
const PRIVAT_BEGINN = 'Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.'
// ---------------------------------------------------------------------------------------------------------- Auftragsbestätigung
export const AB_TEXTE = {
titel: 'Auftragsbestätigung',
einleitung: ({ datum, weg } = {}) => {
const art = wegText(weg)
return `Vielen Dank für Ihren Auftrag vom ${pflicht(datum, 'datum')}${art ? ` (${art})` : ''}. Wir bestätigen ihn wie folgt:`
},
aufwand: ({ satz, schaetzung } = {}) => {
const saetze = [
`Wir rechnen nach Aufwand ab: ${pflicht(satz, 'satz')} pro Stunde, erfasst je Arbeitsschritt in Schritten von 15 Minuten; angefangene 15 Minuten werden aufgerundet.`,
'Wirkt eine weitere Person unterstützend mit, berechnen wir für sie die Hälfte der Arbeitszeit (AGB Nr. 9).',
]
if (schaetzung !== undefined && schaetzung !== null && String(schaetzung).trim() !== '') {
const stunden = String(schaetzung).trim()
saetze.push(`Der geschätzte Umfang beträgt etwa ${stunden} ${stunden === '1' ? 'Stunde' : 'Stunden'}; die Schätzung ist unverbindlich.`)
saetze.push('Zeichnet sich eine deutliche Überschreitung ab, melden wir uns vorher.')
}
return saetze.join(' ')
},
kleinunternehmer: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.',
zahlungsplan: {
nach_abnahme: ({ ziel } = {}) => `Wir stellen die Vergütung nach Abnahme in Rechnung; zahlbar ${zielText(ziel)}.`,
anzahlung: ({ prozent, anzahlung, rest, ziel } = {}) =>
`Anzahlung ${zahl(pflicht(prozent, 'prozent'))} % (${pflicht(anzahlung, 'anzahlung')}) nach Auftragsbestätigung, `
+ `Rest (${pflicht(rest, 'rest')}) nach Abnahme; zahlbar jeweils ${zielText(ziel)}.`,
monatlich: ({ ziel } = {}) => `Wir stellen den angefallenen Aufwand monatlich in Rechnung; zahlbar ${zielText(ziel)}.`,
frei: ({ text, ziel } = {}) => {
const t = pflicht(text, 'text')
return `${t}${/[.!?]$/.test(t) ? '' : '.'} Zahlbar jeweils ${zielText(ziel)}.`
},
},
termine: ({ start, fertig, privat = false } = {}) => {
const teile = []
if (start && fertig) teile.push(`Beginn: ${start}; voraussichtliche Fertigstellung: ${fertig}.`)
else if (start) teile.push(`Beginn: ${start}.`)
else if (fertig) teile.push(`Voraussichtliche Fertigstellung: ${fertig}.`)
if (privat) teile.push(PRIVAT_BEGINN)
return teile.length > 0 ? teile.join(' ') : null
},
mitwirkung:
'Mitwirkung und Abnahme richten sich nach Nr. 7 und Nr. 8 unserer Allgemeinen Geschäftsbedingungen. '
+ 'Bitte stellen Sie uns die benötigten Inhalte, Daten und Freigaben rechtzeitig bereit.',
agb: ({ fassung } = {}) =>
`Es gelten unsere Allgemeinen Geschäftsbedingungen in der Fassung vom ${pflicht(fassung, 'fassung')}, die dieser Bestätigung beiliegen.`,
ersetzt: ({ nummer, datum } = {}) => `Diese Auftragsbestätigung ersetzt ${pflicht(nummer, 'nummer')} vom ${pflicht(datum, 'datum')}.`,
gruss: 'Mit freundlichen Grüßen',
}
// ---------------------------------------------------------------------------------------------------------- Bestätigung und Frühbeginn
/** Häkchen auf der Bestätigungsseite und im Kundenportal (Wortlaut der Spezifikation; Alternative mit Bezug auf die AGB: Entscheidung E2 im Freigabe-Dokument). */
export const BESTAETIGUNG_TEXT = 'Ich bestätige den Auftrag zu diesen Bedingungen.'
/** Fassung der Frühbeginn-Erklärung; steht im Nachweis der Bestätigung (fruehbeginnFassung). */
export const FRUEHBEGINN_FASSUNG = '2026-10-06'
/** Zweites, freiwilliges Häkchen für Privatpersonen: Verlangen (§ 357a Abs. 2 Nr. 1 BGB), Zustimmung und Kenntnis (§ 356 Abs. 5 Nr. 2 BGB). */
export const FRUEHBEGINN_TEXT =
'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Ausführung dieses Projektauftrags beginnt. '
+ 'Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). '
+ 'Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).'
// ---------------------------------------------------------------------------------------------------------- Widerrufsbelehrung und Formular
const KONTAKT = `${ABSENDER.name}, ${ABSENDER.strasse}, ${ABSENDER.plzOrt}, Telefon: ${ABSENDER.telefon}, E-Mail: ${ABSENDER.email}`
/** Muster der Anlage 1 für einen Dienstleistungsvertrag; Lücken: Frist „des Vertragsabschlusses.“, Kontaktangaben, Widerrufsfunktion, Wertersatz. */
export const WIDERRUFSBELEHRUNG_PROJEKT = {
titel: 'Widerrufsbelehrung',
abschnitte: [
{
ueberschrift: 'Widerrufsrecht',
absaetze: [
'Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.',
'Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.',
`Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (${KONTAKT}) mittels einer eindeutigen Erklärung `
+ '(z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. '
+ 'Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.',
'Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben. '
+ 'Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) '
+ 'unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.',
'Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.',
],
},
{
ueberschrift: 'Folgen des Widerrufs',
absaetze: [
'Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten '
+ '(mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, '
+ 'günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, '
+ 'an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. '
+ 'Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, '
+ 'es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.',
'Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, '
+ 'der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, '
+ 'bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht.',
],
},
],
}
/** Muster der Anlage 2; „–“ ist das Aufzählungszeichen des Musters, die letzte Zeile gehört zum Muster. */
export const WIDERRUFSFORMULAR = {
titel: 'Muster-Widerrufsformular',
einleitung: '(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)',
zeilen: [
`– An ${ABSENDER.name}, ${ABSENDER.strasse}, ${ABSENDER.plzOrt}, E-Mail: ${ABSENDER.email}:`,
'– Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)',
'– Bestellt am (*)/erhalten am (*)',
'– Name des/der Verbraucher(s)',
'– Anschrift des/der Verbraucher(s)',
'– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)',
'– Datum',
'(*) Unzutreffendes streichen.',
],
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Bestätigungs-Token einer Auftragsbestätigung (Übersicht V4 „L5“): 32 Zufallsbytes als base64url (43 Zeichen),
* gespeichert wird nur der SHA-256-Hash in Hex. Gültig 60 Tage ab dem letzten Versand.
* Importiert bewusst nur node:crypto und konstanten.js (nie server/config.js).
*/
import { createHash, randomBytes } from 'node:crypto'
import { TOKEN_GUELTIG_TAGE } from './konstanten.js'
const TAG_MS = 86_400_000
const TOKEN_FORMAT = /^[A-Za-z0-9_-]{43}$/
const sha256Hex = (text) => createHash('sha256').update(text).digest('hex')
export function neuerToken(jetzt = new Date()) {
if (!(jetzt instanceof Date) || !Number.isFinite(jetzt.getTime())) throw new TypeError('neuerToken: jetzt muss ein gültiges Date sein')
const token = randomBytes(32).toString('base64url')
return { token, hash: sha256Hex(token), gueltigBis: new Date(jetzt.getTime() + TOKEN_GUELTIG_TAGE * TAG_MS) }
}
/** Hash eines Tokens aus einem Link; `null`, wenn der Text nicht das Format eines Tokens hat. */
export function hashToken(token) {
return typeof token === 'string' && TOKEN_FORMAT.test(token) ? sha256Hex(token) : null
}
+3 -1
View File
@@ -59,7 +59,8 @@ const visibleToCustomer = (raw) => ISSUED.has(raw.status) && (raw.total ?? 0) >
/**
* Stripe-Rechnung in der Form des Kunden-Tabs (wie sanitizeInvoice fuer InvoiceNinja, Betraege in Euro). Offen sind
* "open" und "uncollectible" (Stripe nimmt die Zahlung weiter an); ueberfaellig, wenn die Faelligkeit vor dem
* Berliner Tag today (YYYY-MM-DD) liegt.
* Berliner Tag today (YYYY-MM-DD) liegt. `projekt` ist der Projektname aus den Stripe-Metadaten (Projektrechnungen aus dem
* WOMS), sonst leer.
*/
export function customerStripeInvoice(raw, { today = berlinDate() } = {}) {
const outstanding = raw.status === 'open' || raw.status === 'uncollectible'
@@ -83,6 +84,7 @@ export function customerStripeInvoice(raw, { today = berlinDate() } = {}) {
overdue,
portalUrl: raw.hosted_invoice_url || '',
hasPdf: Boolean(raw.invoice_pdf),
projekt: raw.metadata?.projektName || '',
}
}
+13 -1
View File
@@ -21,6 +21,7 @@ const SETTINGS_ID = 'main'
// Dokument-IDs wie in Appwrite (hoechstens 36 Zeichen, auch eine UUID); alles andere kann es nicht geben
const DOCUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
const MAX_BANK_DETAILS = 500
const MAX_STEUERNUMMER = 40
const MAX_NAME = 120
const MAX_MATCH = 120
const MAX_BUFFER_CENTS = 100_000_000
@@ -35,6 +36,7 @@ export const DEFAULT_SETTINGS = Object.freeze({
previousYearRevenueCents: null,
estPrepaymentCents: 0,
bankDetailsText: '',
steuernummer: '',
})
const invalid = (field, message) => new SettingsError(400, `${field}: ${message}`)
@@ -74,6 +76,15 @@ const SETTINGS_RULES = {
wholeNumber('estPrepaymentCents', v, 0, Number.MAX_SAFE_INTEGER, 'Die ESt-Vorauszahlung muss eine ganze Zahl ab 0 Cent sein.'),
bankDetailsText: (v) =>
text('bankDetailsText', v, { min: 0, max: MAX_BANK_DETAILS }, `Der Bankverbindungstext muss ein Text mit höchstens ${MAX_BANK_DETAILS} Zeichen sein.`),
// Steuernummer oder USt-IdNr. für die Auftragsbestätigung: Buchstaben A-Z, Ziffern, Leerzeichen, / - und . (kein
// Tabulator, kein Zeilenumbruch, keine Umlaute); '' löscht den Wert. Die Meldung ohne Feldnamen (sie erscheint im Formular).
steuernummer: (v) => {
const trimmed = typeof v === 'string' ? v.trim() : null
if (trimmed === null || trimmed.length > MAX_STEUERNUMMER || !/^[A-Za-z0-9/ .-]*$/.test(trimmed)) {
throw new SettingsError(400, 'Steuernummer ungültig.')
}
return trimmed
},
}
const FIXED_COST_RULES = {
@@ -154,7 +165,8 @@ async function saveSettings(data) {
/**
* Einstellungen aendern; patch nennt nur die zu aendernden Felder: minBufferCents 0 bis 100.000.000,
* taxReservePercent 0 bis 60, previousYearRevenueCents >= 0 oder null (null loescht den Wert), estPrepaymentCents >= 0,
* bankDetailsText hoechstens 500 Zeichen (ohne Rand-Leerraum gespeichert). Ein unbekanntes oder ungueltiges Feld:
* bankDetailsText hoechstens 500 Zeichen (ohne Rand-Leerraum gespeichert), steuernummer hoechstens 40 Zeichen aus
* Buchstaben, Ziffern, Leerzeichen, / - und . (getrimmt gespeichert, '' loescht). Ein unbekanntes oder ungueltiges Feld:
* SettingsError 400 mit dem Feldnamen, es wird nichts gespeichert. Legt das Dokument an, falls es fehlt; ein leerer
* patch aendert und legt nichts an. Liefert die Einstellungen danach.
*/
+128 -78
View File
@@ -1,8 +1,11 @@
import { createHash } from 'node:crypto'
import { config } from '../../config.js'
import { getStripe, getOrCreateStripeCustomer, stripeCustomerFields, subscriptionIdOfInvoice } from '../stripeBilling.js'
import { getDocument } from '../appwriteAdmin.js'
import { addDays, berlinDate, daysBetween } from './dates.js'
import { RECHNUNGS_ARTEN, AB_RECHNUNGS_ARTEN, sperreRechnung } from '../auftraege/konstanten.js'
import { withLocks } from '../keyedLock.js'
import { baueRechnung, ladeFestenKunden, buche, gibProjektRechnungFrei, setzeAbAbgerechnet, pruefeVorSenden, hatSchlussEntwurf } from '../auftraege/rechnungsbau.js'
import { createProjektRechnung, getProjektRechnung, updateProjektRechnung } from '../auftraege/projektRechnungenStore.js'
import { getSettings } from './settings.js'
/**
@@ -87,6 +90,11 @@ export function mapInvoice(raw, { today }) {
pdfUrl: raw.invoice_pdf || null,
outOfBand: Boolean(metadata.bankTransactionId),
bankTransactionId: metadata.bankTransactionId || '',
projektId: metadata.projektId || '',
projektName: metadata.projektName || '',
art: RECHNUNGS_ARTEN.includes(metadata.art) ? metadata.art : subscriptionIdOfInvoice(raw) ? 'abo' : 'frei',
auftragId: metadata.auftragId || '',
auftragNummer: metadata.auftragNummer || '',
}
}
@@ -126,11 +134,6 @@ export async function listPayouts({ force = false } = {}) {
// Projektrechnungen schreiben
// ---------------------------------------------------------------------------
const MAX_ITEMS = 20
const MAX_DESCRIPTION = 200
const MAX_QUANTITY = 999
const MAX_UNIT_CENTS = 10_000_000
const MAX_TOTAL_CENTS = 99_999_999
const MAX_DAYS_UNTIL_DUE = 60
const DEFAULT_DAYS_UNTIL_DUE = 14
const MAX_MEMO = 1000
@@ -194,53 +197,18 @@ function assertAddressForSend(customer, totalCents) {
if (!complete && totalCents > SMALL_INVOICE_MAX_CENTS) throw httpError(409, ADDRESS_INCOMPLETE_MESSAGE)
}
/** Rechnungsfuss: Kleinunternehmer-Hinweis, Bankdaten (falls gesetzt), Hinweis auf den Verwendungszweck. */
export function invoiceFooter(bankDetailsText = '') {
/**
* Rechnungsfuss: Kleinunternehmer-Hinweis, Steuernummer (falls gesetzt, § 34a UStDV), Bankdaten (falls gesetzt), Hinweis
* auf den Verwendungszweck.
*/
export function invoiceFooter(bankDetailsText = '', steuernummer = '') {
const bank = typeof bankDetailsText === 'string' ? bankDetailsText.trim() : ''
return [config.stripe.invoiceFooter, bank, PAYMENT_REFERENCE_NOTE].filter(Boolean).join('\n')
const nummer = typeof steuernummer === 'string' ? steuernummer.trim() : ''
return [config.stripe.invoiceFooter, nummer ? `Steuernummer: ${nummer}` : '', bank, PAYMENT_REFERENCE_NOTE].filter(Boolean).join('\n')
}
const isWholeNumberBetween = (value, min, max) => Number.isInteger(value) && value >= min && value <= max
/** Positionen pruefen und bereinigen (Beschreibung ohne Rand-Leerzeichen); Fehler mit status 400. */
function validateItems(items) {
if (!Array.isArray(items) || items.length < 1 || items.length > MAX_ITEMS) {
throw httpError(400, `Eine Rechnung braucht 1 bis ${MAX_ITEMS} Positionen.`)
}
const lines = items.map((item, index) => {
const label = `Position ${index + 1}`
const description = typeof item?.description === 'string' ? item.description.trim() : ''
if (description.length < 1 || description.length > MAX_DESCRIPTION) {
throw httpError(400, `${label}: Die Beschreibung braucht 1 bis ${MAX_DESCRIPTION} Zeichen.`)
}
if (!isWholeNumberBetween(item.quantity, 1, MAX_QUANTITY)) {
throw httpError(400, `${label}: Die Menge muss eine ganze Zahl von 1 bis ${MAX_QUANTITY} sein.`)
}
if (!isWholeNumberBetween(item.unitAmountCents, 1, MAX_UNIT_CENTS)) {
throw httpError(400, `${label}: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`)
}
return { description, quantity: item.quantity, unitAmountCents: item.unitAmountCents }
})
const totalCents = lines.reduce((sum, line) => sum + line.quantity * line.unitAmountCents, 0)
if (totalCents > MAX_TOTAL_CENTS) throw httpError(400, 'Rechnungssumme zu hoch.')
return lines
}
/** Kunde aus Appwrite; nur feste Kunden (nicht lead/lost) bekommen Rechnungen. */
async function loadBillableCustomer(customerId) {
if (typeof customerId !== 'string' || !customerId) throw httpError(400, 'Kunde nicht gefunden.')
let customer
try {
customer = await getDocument(config.collections.customers, customerId)
} catch (err) {
if (err.status === 404) throw httpError(400, 'Kunde nicht gefunden.')
throw err
}
const stage = String(customer.customerStatus || '').trim().toLowerCase()
if (stage === 'lead' || stage === 'lost') throw httpError(400, 'Rechnungen nur für feste Kunden.')
return customer
}
/** Eine Rechnung frisch von Stripe (mit Kunde), roh; unbekannte Rechnung -> status 404. */
async function retrieveInvoice(invoiceId) {
if (typeof invoiceId !== 'string' || !/^in_\w+$/.test(invoiceId)) throw httpError(404, 'Rechnung nicht gefunden.')
@@ -257,12 +225,39 @@ async function readInvoice(invoiceId) {
return mapInvoice(await retrieveInvoice(invoiceId), { today: berlinDate() })
}
// Buchungen am Projektdatensatz nach einem Stripe-Erfolg: scheitert Appwrite, wird nur protokolliert (siehe buche)
const PROJEKT_ARTEN = RECHNUNGS_ARTEN.filter((art) => art !== 'frei')
async function bucheNummer(invoiceId, number, art) {
if (!PROJEKT_ARTEN.includes(art) || !number) return
await buche('Rechnungsnummer buchen', () => updateProjektRechnung(invoiceId, { rechnungsnummer: number }))
}
/** Finalisiert: Rechnungsnummer in den Datensatz; bei einer Schlussrechnung geht die AB auf abgerechnet. */
async function bucheFinalisiert(invoiceId, number, art, auftragId) {
if (!PROJEKT_ARTEN.includes(art)) return
await bucheNummer(invoiceId, number, art)
if (art !== 'schluss') return
// Massgebend ist die AB im Projektdatensatz (nach umhaengen die neue Fassung), nicht die in den Stripe-Metadaten
await buche('AB abrechnen', async () => {
const abId = (await getProjektRechnung(invoiceId))?.auftragId || auftragId
if (abId) await setzeAbAbgerechnet(abId)
})
}
/** Storniert oder geloescht: Datensatz inaktiv (Blaetter frei); bei einer Schlussrechnung geht die AB zurueck. */
async function bucheStorniert(invoiceId, art) {
if (!PROJEKT_ARTEN.includes(art)) return
await buche('Rechnung freigeben', () => gibProjektRechnungFrei(invoiceId))
}
// Finalisieren ohne Auto-Advance (die Rechnung bleibt in unserer Hand), dann per E-Mail senden. Scheitert nur das
// Senden, ist die Rechnung schon festgeschrieben (offen, mit Nummer): der Fehler (status 502) nennt sie, damit der
// Aufrufer sie erneut senden kann (sendInvoiceNow), statt eine zweite anzulegen.
async function finalizeAndSend(stripe, invoiceId) {
async function finalizeAndSend(stripe, invoiceId, { art = 'frei', auftragId = '' } = {}) {
const finalized = await stripe.invoices.finalizeInvoice(invoiceId, { auto_advance: false })
const number = finalized.number || ''
await bucheFinalisiert(invoiceId, number, art, auftragId)
try {
await stripe.invoices.sendInvoice(invoiceId)
} catch (cause) {
@@ -338,25 +333,40 @@ function validateIdempotencyKey(key) {
*/
export async function createProjectInvoice({
customerId,
art = 'frei',
projektId,
auftragId,
items,
daysUntilDue = DEFAULT_DAYS_UNTIL_DUE,
arbeitsblattIds,
anzahlung,
zeilentexte,
leistungszeitraum,
daysUntilDue,
memo = '',
senden = false,
idempotencyKey,
createdBy,
}) {
const lines = validateItems(items)
if (!isWholeNumberBetween(daysUntilDue, 0, MAX_DAYS_UNTIL_DUE)) {
// Ohne Angabe: bei Anzahlung und Schluss das Zahlungsziel der AB (siehe createInvoice), sonst 14 Tage
if (daysUntilDue !== undefined && !isWholeNumberBetween(daysUntilDue, 0, MAX_DAYS_UNTIL_DUE)) {
throw httpError(400, `Das Zahlungsziel muss eine ganze Zahl von 0 bis ${MAX_DAYS_UNTIL_DUE} Tagen sein.`)
}
const note = typeof memo === 'string' ? memo.trim() : ''
if (note.length > MAX_MEMO) throw httpError(400, `Der Vermerk darf höchstens ${MAX_MEMO} Zeichen lang sein.`)
const key = validateIdempotencyKey(idempotencyKey)
const fingerprint = createHash('sha256').update(JSON.stringify([customerId, lines, note, daysUntilDue, senden === true])).digest('hex')
// Der Fingerabdruck wirft nie: ein Array wird sortiert kopiert, jeder andere Wert bleibt; Typfehler meldet baueRechnung (400)
const sortiert = Array.isArray(arbeitsblattIds) ? [...arbeitsblattIds].sort() : arbeitsblattIds
const fingerprint = createHash('sha256')
.update(JSON.stringify([customerId, art, projektId ?? '', auftragId ?? '', items ?? null, sortiert ?? null, anzahlung ?? null, zeilentexte ?? null, leistungszeitraum ?? '', note, daysUntilDue, senden === true]))
.digest('hex')
claimCreate(fingerprint)
try {
const invoice = await createInvoice({ customerId, lines, daysUntilDue, note, senden: senden === true, key, createdBy })
const eingabe = { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, createdBy }
const anlegen = () => createInvoice({ eingabe, daysUntilDue, note, senden: senden === true, key })
// Aufwand, Anzahlung und Schluss: Pruefung, Entwurf, Posten, Projektdatensatz und Senden je Kunde nacheinander,
// damit zwei Admins nie dieselben Arbeitsblaetter oder dieselbe AB gleichzeitig abrechnen
const invoice = ['aufwand', 'anzahlung', 'schluss'].includes(art) ? await withLocks(sperreRechnung(customerId), anlegen) : await anlegen()
releaseCreate(fingerprint, true)
return invoice
} catch (err) {
@@ -366,13 +376,26 @@ export async function createProjectInvoice({
}
}
async function createInvoice({ customerId, lines, daysUntilDue, note, senden, key, createdBy }) {
const customer = await loadBillableCustomer(customerId)
/**
* Vorschau einer Projektrechnung: dieselben Pruefungen wie das Anlegen, aber kein Schreibzugriff (weder Stripe noch
* Appwrite), kein Schutz vor doppeltem Anlegen, keine Sperre.
*/
export async function rechnungsVorschau(eingabe) {
const { positionen, summeCents, leistungszeitraum, customFields } = await baueRechnung(eingabe)
return { positionen, summeCents, leistungszeitraum, customFields }
}
async function createInvoice({ eingabe, daysUntilDue: angegeben, note, senden, key }) {
const entwurf = await baueRechnung(eingabe)
// Zahlungsziel: die Angabe des Aufrufers, sonst bei Anzahlung und Schluss das der AB, sonst 14 Tage
const abZiel = AB_RECHNUNGS_ARTEN.includes(entwurf.art) ? entwurf.auftrag?.zahlungszielTage : undefined
const daysUntilDue = angegeben ?? (isWholeNumberBetween(abZiel, 0, MAX_DAYS_UNTIL_DUE) ? abZiel : DEFAULT_DAYS_UNTIL_DUE)
const customer = entwurf.kunde
// Ohne Adresse lehnt Stripe das Senden erst nach dem Festschreiben ab: lieber vorab pruefen
if (senden && !String(customer.email || '').trim()) throw httpError(400, NO_EMAIL_MESSAGE)
if (senden) assertAddressForSend(customer, lines.reduce((sum, line) => sum + line.quantity * line.unitAmountCents, 0))
if (senden) assertAddressForSend(customer, entwurf.summeCents)
const { bankDetailsText } = await getSettings()
const { bankDetailsText, steuernummer } = await getSettings()
const stripe = getStripe()
const stripeCustomerId = await getOrCreateStripeCustomer(customer)
// Vor dem ersten Rechnungsaufruf: scheitert das, gibt es keine halbfertige Rechnung
@@ -387,24 +410,25 @@ async function createInvoice({ customerId, lines, daysUntilDue, note, senden, ke
auto_advance: false,
pending_invoice_items_behavior: 'exclude',
currency: 'eur',
footer: invoiceFooter(bankDetailsText),
footer: invoiceFooter(bankDetailsText, steuernummer),
description: note || undefined,
metadata: { webklarCustomerId: customer.$id, quelle: 'woms', erstelltVon: String(createdBy || '').slice(0, 500) },
metadata: entwurf.metadata,
custom_fields: entwurf.customFields.length ? entwurf.customFields : undefined,
rendering: { pdf: { page_size: 'a4' } },
},
key ? { idempotencyKey: key } : undefined
)
invoiceId = draft.id
// der Reihe nach, damit die Positionen in der eingegebenen Reihenfolge auf der Rechnung stehen
for (const line of lines) {
await stripe.invoiceItems.create({
customer: stripeCustomerId,
invoice: invoiceId,
description: line.description,
quantity: line.quantity,
unit_amount_decimal: String(line.unitAmountCents),
currency: 'eur',
})
for (const position of entwurf.positionen) {
const posten = { customer: stripeCustomerId, invoice: invoiceId, description: position.beschreibung }
// Minuszeile (Abzug fruehere Rechnung): nur amount, ohne quantity
if (position.einzelpreisCents < 0) Object.assign(posten, { amount: position.betragCents })
else Object.assign(posten, { quantity: position.menge, unit_amount_decimal: String(position.einzelpreisCents) })
posten.currency = 'eur'
await stripe.invoiceItems.create(posten)
}
if (entwurf.projektRechnung) await createProjektRechnung(invoiceId, entwurf.projektRechnung)
} catch (err) {
// Halbfertigen Entwurf nicht liegen lassen; scheitert auch das, bleibt er als Entwurf sichtbar und loeschbar
if (invoiceId) await stripe.invoices.del(invoiceId).catch(() => {})
@@ -413,7 +437,7 @@ async function createInvoice({ customerId, lines, daysUntilDue, note, senden, ke
}
try {
const number = senden ? await finalizeAndSend(stripe, invoiceId) : ''
const number = senden ? await finalizeAndSend(stripe, invoiceId, { art: entwurf.art, auftragId: entwurf.auftrag?.id ?? '' }) : ''
try {
return await readInvoice(invoiceId)
} catch (cause) {
@@ -438,6 +462,17 @@ async function createInvoice({ customerId, lines, daysUntilDue, note, senden, ke
*/
export async function sendInvoiceNow(invoiceId) {
const raw = await retrieveInvoice(invoiceId)
const vorab = mapInvoice(raw, { today: berlinDate() })
// Anzahlungs- und Schluss-Entwuerfe: unter der Sperre des Kunden (wie das Anlegen) senden und vorher die AB erneut pruefen
if (vorab.source === 'projekt' && vorab.status === 'entwurf' && AB_RECHNUNGS_ARTEN.includes(vorab.art) && vorab.webklarCustomerId) {
return withLocks(sperreRechnung(vorab.webklarCustomerId), () => sendeRechnung(invoiceId, null, true))
}
return sendeRechnung(invoiceId, raw, false)
}
// raw = die schon gelesene Rechnung; unter der Sperre wird frisch gelesen (ein anderer Aufruf kann sie inzwischen gesendet haben)
async function sendeRechnung(invoiceId, raw, abPruefen) {
raw ??= await retrieveInvoice(invoiceId)
const invoice = mapInvoice(raw, { today: berlinDate() })
// Ein Abo-Entwurf gehoert Stripe: Finalisieren mit auto_advance=false wuerde den Einzug des Abos anhalten
if (invoice.source !== 'projekt') throw httpError(409, ABO_MESSAGE)
@@ -445,10 +480,11 @@ export async function sendInvoiceNow(invoiceId) {
if (!isDraft && invoice.status !== 'offen' && invoice.status !== 'ueberfaellig') {
throw httpError(409, 'Diese Rechnung kann nicht mehr gesendet werden.')
}
if (isDraft && abPruefen) await pruefeVorSenden({ invoiceId: invoice.id, art: invoice.art, auftragIdMetadaten: invoice.auftragId, totalCents: invoice.totalCents })
// Nur ein Entwurf wird festgeschrieben: erst dann zaehlen Kundendaten und Anschrift. Eine offene Rechnung steht schon
// fest (Anschrift und Empfaenger sind festgeschrieben), sie wird nur erneut gesendet.
let customer = null
if (isDraft && invoice.webklarCustomerId) customer = await loadBillableCustomer(invoice.webklarCustomerId)
if (isDraft && invoice.webklarCustomerId) customer = await ladeFestenKunden(invoice.webklarCustomerId)
// Nach dem Abgleich traegt der Stripe-Kunde die E-Mail des WOMS-Kunden; ohne die gilt die, die Stripe schon hat
const email = String(customer?.email || '').trim() || String(invoice.customerEmail || '').trim()
if (!email) throw httpError(400, NO_EMAIL_MESSAGE)
@@ -459,8 +495,12 @@ export async function sendInvoiceNow(invoiceId) {
// (Name, E-Mail, Anschrift stehen danach unveraenderlich auf der Rechnung). Ohne Kennung (z. B. Entwurf aus dem
// Stripe-Dashboard) gibt es keine WOMS-Daten zum Abgleichen.
if (customer) await syncStripeCustomer(getStripe(), stripeCustomerIdOf(raw), customer)
await finalizeAndSend(getStripe(), invoice.id)
} else await getStripe().invoices.sendInvoice(invoice.id)
await finalizeAndSend(getStripe(), invoice.id, { art: invoice.art, auftragId: invoice.auftragId })
} else {
// Selbstheilung nach einem ausgefallenen Buchen: eine fehlende Rechnungsnummer im Datensatz nachtragen
await bucheNummer(invoice.id, invoice.number, invoice.art)
await getStripe().invoices.sendInvoice(invoice.id)
}
return await readInvoice(invoice.id)
} finally {
invalidateFinanceCache()
@@ -472,12 +512,21 @@ export async function voidInvoice(invoiceId) {
const invoice = await readInvoice(invoiceId)
if (invoice.source !== 'projekt') throw httpError(409, ABO_MESSAGE)
if (invoice.status !== 'offen' && invoice.status !== 'ueberfaellig') throw httpError(409, 'Nur offene Rechnungen können storniert werden.')
try {
await getStripe().invoices.voidInvoice(invoice.id)
return await readInvoice(invoice.id)
} finally {
invalidateFinanceCache()
const stornieren = async () => {
// Der Schluss-Entwurf enthaelt die Minuszeile dieser Anzahlung: wuerde sie storniert, ginge er zu niedrig hinaus
if (invoice.art === 'anzahlung' && (await hatSchlussEntwurf({ invoiceId: invoice.id, auftragIdMetadaten: invoice.auftragId }))) {
throw httpError(409, 'Erst den Entwurf der Schlussrechnung löschen.')
}
try {
await getStripe().invoices.voidInvoice(invoice.id)
await bucheStorniert(invoice.id, invoice.art)
return await readInvoice(invoice.id)
} finally {
invalidateFinanceCache()
}
}
// Eine Anzahlung unter der Sperre des Kunden: das Anlegen und Senden einer Schlussrechnung laeuft nie zwischen Pruefung und Storno
return invoice.art === 'anzahlung' && invoice.webklarCustomerId ? withLocks(sperreRechnung(invoice.webklarCustomerId), stornieren) : stornieren()
}
/** Projekt-Entwurf bei Stripe loeschen, sonst status 409 (nur Entwuerfe lassen sich loeschen). */
@@ -487,6 +536,7 @@ export async function deleteDraft(invoiceId) {
if (invoice.status !== 'entwurf') throw httpError(409, 'Nur Entwürfe können gelöscht werden.')
try {
await getStripe().invoices.del(invoice.id)
await bucheStorniert(invoice.id, invoice.art)
} finally {
invalidateFinanceCache()
}
+3 -2
View File
@@ -35,10 +35,11 @@ function getTransporter() {
}
/** Vorlage rendern (server/mail-templates); Unstimmigkeiten landen im Log, die Mail geht trotzdem raus. */
function render({ template, values = {}, blocks = {} }) {
function render({ template, values = {}, blocks = {}, subjectPrefix = '' }) {
const mail = renderMail(template, values, blocks)
for (const problem of mail.problems) console.warn(`[mailer] Vorlage ${template}: ${problem}`)
return mail
// optional: Text vor dem Betreff der Vorlage (z. B. "Kopie: " bei einer Teamkopie)
return subjectPrefix ? { ...mail, subject: `${subjectPrefix}${mail.subject}` } : mail
}
// Throttle: pro Kunde maximal eine Benachrichtigung je Zeitfenster,
+189
View File
@@ -0,0 +1,189 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { useTestEnv } from './support/testEnv.js'
import { festpreisEingabe, aufwandEingabe, wirftFehler, MELDUNG } from './helpers/auftraegeAb.js'
// Rein: kein Appwrite-Zugriff. Die Umgebung zeigt trotzdem auf tote lokale Adressen, damit nie ein echtes System erreicht wird.
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1/v1', stripeUrl: 'http://127.0.0.1:1' })
const { pruefeEingabe } = await import('../server/services/auftraege/auftraege.js')
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
const pos = (patch = {}) => ({ beschreibung: 'Webdesign', menge: 1, einzelpreisCents: 100, ...patch })
const neunPositionen = () => Array.from({ length: 9 }, () => pos({ einzelpreisCents: 10_000_000 }))
const E = {
art: 'Art: Bitte Festpreis oder Nach Aufwand wählen.',
beschreibung: (n = 1) => `Position ${n}: Die Beschreibung braucht 1 bis 200 Zeichen.`,
menge: 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.',
preis: (n = 1) => `Position ${n}: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`,
summe: 'Die Auftragssumme ist zu hoch (höchstens 999.999,99 €).',
plan: 'Zahlungsplan: Bitte eine Zahlungsart wählen.',
prozent: 'Zahlungsplan: Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.',
frei: 'Zahlungsplan: Der freie Text braucht 1 bis 500 Zeichen.',
ziel: 'Zahlungsziel: Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.',
datum: 'Auftrag vom: Bitte ein gültiges Datum angeben (JJJJ-MM-TT).',
titel: 'Titel: Höchstens 200 Zeichen.',
zeitquelle: 'Zeitquelle: Eine Liste von Ticket-Kennungen erwartet.',
stundensatz: 'Stundensatz: Der Stundensatz muss zwischen 0,01 € und 100.000,00 € liegen.',
schaetzung: 'Schätzung: Die Schätzung muss ein Vielfaches von 15 Minuten sein (15 bis 99.960).',
}
test('Festpreis: bereinigte Felder – Texte getrimmt, Summe berechnet, Aufwandsfelder leer, Vorgaben gesetzt', () => {
const felder = pruefeEingabe(
festpreisEingabe({
titel: ' Neue Website ',
positionen: [{ beschreibung: ' Webdesign ', menge: 2, einzelpreisCents: 30000 }],
zahlungsplan: { art: 'nach_abnahme' },
zahlungszielTage: undefined,
stundensatzCents: 12345, // gehört nicht zu einem Festpreis und fällt weg
schaetzungMinuten: 30,
}),
)
assert.deepEqual(felder, {
projektId: 'p_projekt01',
titel: 'Neue Website',
kundenart: '',
art: 'festpreis',
positionen: [{ beschreibung: 'Webdesign', menge: 2, einzelpreisCents: 30000 }],
stundensatzCents: null,
schaetzungMinuten: null,
summeCents: 60000,
zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' },
zahlungszielTage: 14,
auftragVom: '2026-10-05',
auftragWeg: 'telefon',
startAm: '',
fertigBis: '',
zeitquelle: [],
})
})
test('Aufwand: Stundensatz-Vorgabe 100 €, Schätzung optional, Positionen fallen weg, Summe leer', () => {
const felder = pruefeEingabe(aufwandEingabe({ stundensatzCents: undefined, schaetzungMinuten: null, positionen: festpreisEingabe().positionen }))
assert.deepEqual(felder, {
projektId: 'p_projekt01', titel: '', kundenart: '', art: 'aufwand', positionen: [], stundensatzCents: 10000, schaetzungMinuten: null,
summeCents: null, zahlungsplan: { art: 'monatlich', prozent: null, text: '' }, zahlungszielTage: 14, auftragVom: '2026-10-05',
auftragWeg: 'email', startAm: '', fertigBis: '', zeitquelle: [],
})
})
test('Zahlungsplan: Anzahlung nimmt 50 % vor, frei trägt den Text, die anderen Arten verwerfen prozent und text', () => {
const plan = (zahlungsplan, basis = festpreisEingabe) => pruefeEingabe(basis({ zahlungsplan })).zahlungsplan
assert.deepEqual(plan({ art: 'anzahlung' }), { art: 'anzahlung', prozent: 50, text: '' })
assert.deepEqual(plan({ art: 'anzahlung', prozent: 30, text: 'ignoriert' }), { art: 'anzahlung', prozent: 30, text: '' })
assert.deepEqual(plan({ art: 'frei', prozent: 40, text: ' Zahlbar in zwei Raten. ' }), { art: 'frei', prozent: null, text: 'Zahlbar in zwei Raten.' })
assert.deepEqual(plan({ art: 'monatlich', text: 'x' }, aufwandEingabe), { art: 'monatlich', prozent: null, text: '' })
assert.deepEqual(plan({ art: 'nach_abnahme', prozent: 10 }, aufwandEingabe), { art: 'nach_abnahme', prozent: null, text: '' })
})
test('Grenzwerte, die noch gelten: 20 Positionen, Summe genau 999.999,99 €, Zahlungsziel 0 und 60, Schätzung 15 und 99.960 Minuten', () => {
const lang = (cents = 100, menge = 1) => ({ beschreibung: 'x'.repeat(200), menge, einzelpreisCents: cents })
assert.equal(pruefeEingabe(festpreisEingabe({ positionen: Array.from({ length: 20 }, () => lang()) })).positionen.length, 20)
assert.equal(pruefeEingabe(festpreisEingabe({ positionen: [...neunPositionen(), pos({ einzelpreisCents: 9_999_999 })] })).summeCents, 99_999_999)
assert.equal(pruefeEingabe(festpreisEingabe({ positionen: [lang(100, 999)] })).summeCents, 99_900)
assert.equal(pruefeEingabe(festpreisEingabe({ zahlungszielTage: 0 })).zahlungszielTage, 0)
assert.equal(pruefeEingabe(festpreisEingabe({ zahlungszielTage: 60 })).zahlungszielTage, 60)
assert.equal(pruefeEingabe(aufwandEingabe({ schaetzungMinuten: 15 })).schaetzungMinuten, 15)
assert.equal(pruefeEingabe(aufwandEingabe({ schaetzungMinuten: 99_960 })).schaetzungMinuten, 99_960)
for (const keine of [0, '', null]) assert.equal(pruefeEingabe(aufwandEingabe({ schaetzungMinuten: keine })).schaetzungMinuten, null)
assert.equal(pruefeEingabe(aufwandEingabe({ stundensatzCents: 1 })).stundensatzCents, 1)
assert.equal(pruefeEingabe(aufwandEingabe({ stundensatzCents: 10_000_000 })).stundensatzCents, 10_000_000)
})
const FESTPREIS_FEHLER = [
['Art fehlt', { art: undefined }, E.art],
['Art unbekannt', { art: 'pauschal' }, E.art],
['keine Positionen', { positionen: [] }, MELDUNG.positionen],
['Positionen fehlen', { positionen: undefined }, MELDUNG.positionen],
['21 Positionen', { positionen: Array.from({ length: 21 }, () => pos()) }, MELDUNG.positionen],
['Position ist kein Objekt', { positionen: ['x'] }, E.beschreibung()],
['Beschreibung leer', { positionen: [pos({ beschreibung: ' ' })] }, E.beschreibung()],
['Beschreibung 201 Zeichen (zweite Position)', { positionen: [pos(), pos({ beschreibung: 'x'.repeat(201) })] }, E.beschreibung(2)],
['Menge 0', { positionen: [pos({ menge: 0 })] }, E.menge],
['Menge 1000', { positionen: [pos({ menge: 1000 })] }, E.menge],
['Menge 1,5', { positionen: [pos({ menge: 1.5 })] }, E.menge],
['Menge als Text', { positionen: [pos({ menge: '2' })] }, E.menge],
['Einzelpreis 0 (zweite Position)', { positionen: [pos(), pos({ einzelpreisCents: 0 })] }, E.preis(2)],
['Einzelpreis über 100.000 €', { positionen: [pos({ einzelpreisCents: 10_000_001 })] }, E.preis()],
['Einzelpreis mit Nachkommastelle', { positionen: [pos({ einzelpreisCents: 99.5 })] }, E.preis()],
['Summe zu hoch', { positionen: [pos({ menge: 11, einzelpreisCents: 10_000_000 })] }, E.summe],
['Summe ein Cent über dem Höchstwert', { positionen: [...neunPositionen(), pos({ einzelpreisCents: 10_000_000 })] }, E.summe],
['Zahlungsplan fehlt', { zahlungsplan: undefined }, E.plan],
['Zahlungsart unbekannt', { zahlungsplan: { art: 'bar' } }, E.plan],
['Zahlungsplan kein Objekt', { zahlungsplan: 'anzahlung' }, E.plan],
['monatlich bei Festpreis', { zahlungsplan: { art: 'monatlich' } }, 'Zahlungsplan: „Monatlich nach Aufwand“ geht nur bei einem Auftrag nach Aufwand.'],
['Anzahlung 0 %', { zahlungsplan: { art: 'anzahlung', prozent: 0 } }, E.prozent],
['Anzahlung 100 %', { zahlungsplan: { art: 'anzahlung', prozent: 100 } }, E.prozent],
['Anzahlung 12,5 %', { zahlungsplan: { art: 'anzahlung', prozent: 12.5 } }, E.prozent],
['Anzahlung als Text', { zahlungsplan: { art: 'anzahlung', prozent: '50' } }, E.prozent],
['freier Text leer', { zahlungsplan: { art: 'frei', text: ' ' } }, E.frei],
['freier Text 501 Zeichen', { zahlungsplan: { art: 'frei', text: 'x'.repeat(501) } }, E.frei],
['Zahlungsziel -1', { zahlungszielTage: -1 }, E.ziel],
['Zahlungsziel 61', { zahlungszielTage: 61 }, E.ziel],
['Zahlungsziel 14,5', { zahlungszielTage: 14.5 }, E.ziel],
['Zahlungsziel als Text', { zahlungszielTage: '14' }, E.ziel],
['Auftragsdatum fehlt', { auftragVom: undefined }, E.datum],
['30. Februar', { auftragVom: '2026-02-30' }, E.datum],
['deutsches Datumsformat', { auftragVom: '05.10.2026' }, E.datum],
['Datum als Zahl', { auftragVom: 20261005 }, E.datum],
['Weg fehlt', { auftragWeg: undefined }, 'Weg: Bitte den Weg der Absprache wählen.'],
['Weg unbekannt', { auftragWeg: 'brieftaube' }, 'Weg: Bitte den Weg der Absprache wählen.'],
['Start kein Datum', { startAm: '2026-13-01' }, 'Start: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'],
['Fertigstellung kein Datum', { fertigBis: 'bald' }, 'Fertigstellung: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'],
['Titel 201 Zeichen', { titel: 'x'.repeat(201) }, E.titel],
['Titel keine Zeichenkette', { titel: 5 }, E.titel],
['Projekt-Kennung mit ../', { projektId: '../x' }, 'Projekt: Ungültige Projekt-Kennung.'],
['Kundenart unbekannt', { kundenart: 'firma' }, 'Kundenart: Bitte Unternehmen oder Privatperson wählen.'],
['Zeitquelle keine Liste', { zeitquelle: 't_1' }, E.zeitquelle],
['11 Tickets', { zeitquelle: Array.from({ length: 11 }, (_, i) => `t_${i}`) }, 'Zeitquelle: Höchstens 10 Tickets.'],
['Ticket-Kennung mit ../', { zeitquelle: ['t_ok', '../x'] }, 'Zeitquelle: Ungültige Ticket-Kennung.'],
]
const AUFWAND_FEHLER = [
['Stundensatz 0', { stundensatzCents: 0 }, E.stundensatz],
['Stundensatz über 100.000 €', { stundensatzCents: 10_000_001 }, E.stundensatz],
['Stundensatz mit Nachkommastelle', { stundensatzCents: 100.5 }, E.stundensatz],
['Stundensatz als Text', { stundensatzCents: '100' }, E.stundensatz],
['Schätzung 10 Minuten', { schaetzungMinuten: 10 }, E.schaetzung],
['Schätzung negativ', { schaetzungMinuten: -15 }, E.schaetzung],
['Schätzung über dem Höchstwert', { schaetzungMinuten: 99_975 }, E.schaetzung],
['Schätzung als Text', { schaetzungMinuten: '15' }, E.schaetzung],
['Anzahlung bei Aufwand', { zahlungsplan: { art: 'anzahlung', prozent: 50 } }, 'Zahlungsplan: Eine Anzahlung geht nur bei einem Festpreis.'],
]
test('Ablehnungen beim Festpreis: jede Eingabe scheitert mit genau ihrer Meldung (400)', () => {
for (const [name, patch, meldung] of FESTPREIS_FEHLER) wirftFehler(() => pruefeEingabe(festpreisEingabe(patch)), 400, meldung, name)
})
test('Ablehnungen bei Aufwand: Stundensatz, Schätzung und Anzahlung', () => {
for (const [name, patch, meldung] of AUFWAND_FEHLER) wirftFehler(() => pruefeEingabe(aufwandEingabe(patch)), 400, meldung, name)
})
test('Eingaben, die kein einfaches Objekt sind, ergeben „Ungültige Eingabe.“ als AuftragError', () => {
for (const eingabe of [null, undefined, 'x', 42, [], [festpreisEingabe()]]) {
for (const teilweise of [false, true]) {
assert.throws(() => pruefeEingabe(eingabe, { teilweise }), (err) => err instanceof AuftragError && err.status === 400 && err.message === 'Ungültige Eingabe.')
}
}
})
test('Unbekannte und geschützte Felder landen nie in den bereinigten Feldern', () => {
const sauber = pruefeEingabe(festpreisEingabe())
const mitMuell = pruefeEingabe({
...festpreisEingabe(), status: 'bestaetigt', nummer: 'AB-2026-0001', jahr: 2026, tokenHash: 'x', ersetztId: 'a_alt',
erstelltVon: 'boese@example.invalid', pdfDatei: 'c/a.pdf', kunde: { name: 'x' }, id: 'a_fremd',
})
assert.deepEqual(mitMuell, sauber)
})
test('teilweise: nur übergebene Felder werden geprüft und bereinigt, nichts wird ergänzt', () => {
assert.deepEqual(pruefeEingabe({}, { teilweise: true }), {})
assert.deepEqual(pruefeEingabe({ kundenId: 'c_x', status: 'bestaetigt' }, { teilweise: true }), {})
assert.deepEqual(pruefeEingabe({ titel: ' Neu ', zeitquelle: ['t_1', 't_1', 't_2'] }, { teilweise: true }), { titel: 'Neu', zeitquelle: ['t_1', 't_2'] })
assert.deepEqual(pruefeEingabe({ art: 'aufwand' }, { teilweise: true }), { art: 'aufwand' })
assert.deepEqual(pruefeEingabe({ zahlungsplan: { art: 'anzahlung' } }, { teilweise: true }), { zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' } })
assert.deepEqual(
pruefeEingabe({ positionen: [{ beschreibung: ' A ', menge: 1, einzelpreisCents: 5 }] }, { teilweise: true }),
{ positionen: [{ beschreibung: 'A', menge: 1, einzelpreisCents: 5 }] },
)
wirftFehler(() => pruefeEingabe({ zahlungszielTage: 99 }, { teilweise: true }), 400, E.ziel)
wirftFehler(() => pruefeEingabe({ auftragVom: null }, { teilweise: true }), 400, E.datum)
})
+388
View File
@@ -0,0 +1,388 @@
import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import {
starteUmgebung, KUNDE_ID, PROJEKT_ID, TICKET_ID, ZWEITES_TICKET, FREMD, VON, MELDUNG, festpreisEingabe, kundeDoc, projektDoc,
baueDeps, sha256, abgelehnt, dateienIn, ergebnis, tor, warte, wartebis,
} from './helpers/auftraegeAb.js'
const umg = await starteUmgebung()
const { aw, config, uploadDir } = umg
after(() => umg.schliesse())
beforeEach(umg.zuruecksetzen)
const { getAuftrag, updateAuftrag, findAuftragByTokenHash } = await import('../server/services/auftraege/auftragStore.js')
const { createProjektRechnung, getProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js')
const { lesePdf } = await import('../server/services/auftraege/ablage.js')
const { sperreAuftrag } = await import('../server/services/auftraege/konstanten.js')
const { withLocks } = await import('../server/services/keyedLock.js')
const { legeEntwurfAn, aendereEntwurf, loescheEntwurf, sendeAuftrag, erinnereAuftrag, neueFassung, storniereAuftrag, ordneProjektZu } = await import('../server/services/auftraege/auftraege.js')
const JETZT = new Date('2026-10-06T10:00:00Z')
/** Eine versendete AB (Entwurf anlegen und senden); liefert den Auftrag. */
async function gesendet(deps, patch) {
const entwurf = await legeEntwurfAn(festpreisEingabe(patch), { von: VON })
return (await sendeAuftrag(entwurf.id, { von: VON, deps, jetzt: JETZT })).auftrag
}
/**
* Hält die Sperre der AB wie L5 beim Bestätigen (sperreAuftrag(id), V2), startet die Aktion, wartet, bis sie an der Sperre
* steht, führt waehrend() aus (dort schreibt „L5“) und liefert das Ergebnis der Aktion ({ wert } oder { fehler }) nach dem Freigeben.
*/
async function unterFremderSperre(id, { aktion, bereit = () => true, waehrend }) {
const sperre = tor()
const haelt = withLocks(sperreAuftrag(id), () => sperre.promise)
const lauf = ergebnis(aktion())
await wartebis(bereit)
await warte(30) // die Aktion steht jetzt an der Sperre und darf noch nichts geschrieben haben
await waehrend()
sperre.freigeben()
await haelt
return lauf
}
test('erinnereAuftrag: neuer Token (der alte gilt nicht mehr), gleiche PDF-Datei, Mail mit erinnerung: true, Versanddatum bleibt', async () => {
const { deps, calls } = baueDeps()
const erste = await gesendet(deps)
const spaeter = new Date('2026-10-20T08:00:00Z')
const { auftrag, mailFehler } = await erinnereAuftrag(erste.id, { deps, jetzt: spaeter })
assert.equal(mailFehler, false)
assert.equal(calls.token.length, 2)
assert.notEqual(auftrag.tokenHash, erste.tokenHash)
assert.equal(auftrag.tokenHash, sha256(calls.token[1]))
assert.equal(new Date(auftrag.tokenGueltigBis).getTime(), spaeter.getTime() + 60 * 86_400_000)
assert.equal(await findAuftragByTokenHash(erste.tokenHash), null)
assert.equal((await findAuftragByTokenHash(auftrag.tokenHash)).id, erste.id)
assert.deepEqual(
[auftrag.status, auftrag.nummer, auftrag.gesendetAm, auftrag.pdfDatei, auftrag.pdfSha256, auftrag.mailStatus],
['versendet', erste.nummer, erste.gesendetAm, erste.pdfDatei, erste.pdfSha256, 'gesendet'],
)
assert.equal(calls.pdf.length, 1) // kein neues PDF
assert.equal(calls.mail.length, 2)
assert.deepEqual(calls.mail[1].pdf, await lesePdf(erste.pdfDatei))
assert.deepEqual([calls.mail[1].token, calls.mail[1].erinnerung], [calls.token[1], true])
assert.deepEqual(await getAuftrag(erste.id), auftrag)
})
test('erinnereAuftrag nach einem Mail-Fehler: gewöhnliche Mail ohne Erinnerungs-Hinweis an die aktuelle Adresse des Kunden', async () => {
mock.method(console, 'error', () => {})
const erste = await gesendet(baueDeps({ mailFehler: new Error('Verbindung abgebrochen') }).deps)
assert.deepEqual([erste.mailStatus, erste.gesendetAn], ['fehler', 'anna@example.invalid'])
aw.seed(config.collections.customers, kundeDoc({ email: 'neu@example.invalid' })) // der Admin hat die Adresse korrigiert
const { deps, calls } = baueDeps()
const { auftrag, mailFehler } = await erinnereAuftrag(erste.id, { deps })
assert.equal(mailFehler, false)
assert.deepEqual([auftrag.mailStatus, auftrag.mailFehler, auftrag.gesendetAn], ['gesendet', '', 'neu@example.invalid'])
assert.deepEqual([calls.mail[0].erinnerung, calls.mail[0].auftrag.gesendetAn], [false, 'neu@example.invalid'])
})
test('erinnereAuftrag: scheitert die Mail, gilt trotzdem der neue Token; das Ergebnis meldet mailFehler', async () => {
mock.method(console, 'error', () => {})
const erste = await gesendet(baueDeps().deps)
const kaputt = baueDeps({ mailFehler: Object.assign(new Error('x'), { code: 'ECONNREFUSED' }) })
const { auftrag, mailFehler } = await erinnereAuftrag(erste.id, { deps: kaputt.deps })
assert.equal(mailFehler, true)
assert.deepEqual([auftrag.status, auftrag.mailStatus, auftrag.mailFehler], ['versendet', 'fehler', 'E-Mail konnte nicht gesendet werden (ECONNREFUSED).'])
assert.equal(auftrag.tokenHash, sha256(kaputt.calls.token[0]))
assert.equal(await findAuftragByTokenHash(erste.tokenHash), null)
})
test('erinnereAuftrag: nur versendete (409), unbekannt 404, ohne E-Mail 400, beschädigte PDF-Datei 500 – jeweils ohne neuen Token', async () => {
mock.method(console, 'error', () => {})
const { deps } = baueDeps()
const a = await gesendet(deps)
for (const status of ['entwurf', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen']) {
await updateAuftrag(a.id, { status })
await abgelehnt(erinnereAuftrag(a.id, { deps }), 409, MELDUNG.keineErinnerung, status)
}
await abgelehnt(erinnereAuftrag('a_gibtsnicht', { deps }), 404, MELDUNG.auftragFehlt)
await updateAuftrag(a.id, { status: 'versendet' })
aw.seed(config.collections.customers, kundeDoc({ email: ' ' }))
await abgelehnt(erinnereAuftrag(a.id, { deps }), 400, MELDUNG.keineMail)
aw.seed(config.collections.customers, kundeDoc())
fs.writeFileSync(path.join(uploadDir, a.pdfDatei), 'manipuliert')
await abgelehnt(erinnereAuftrag(a.id, { deps }), 500, MELDUNG.pruefsumme)
assert.equal((await getAuftrag(a.id)).tokenHash, a.tokenHash)
})
test('erinnereAuftrag wartet auf die Sperre der AB und prüft den Stand danach (L5 bestätigt gerade: keine Erinnerung mehr)', async () => {
const { deps, calls } = baueDeps()
const a = await gesendet(deps)
const lauf = await unterFremderSperre(a.id, {
aktion: () => erinnereAuftrag(a.id, { deps }),
waehrend: async () => {
assert.equal((await getAuftrag(a.id)).tokenHash, a.tokenHash) // noch kein neuer Token
await updateAuftrag(a.id, { status: 'bestaetigt', bestaetigtAm: new Date().toISOString(), bestaetigtUeber: 'link' })
},
})
assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.keineErinnerung])
assert.equal(calls.mail.length, 1) // nur die Mail des ersten Sendens
assert.equal((await getAuftrag(a.id)).tokenHash, a.tokenHash)
})
test('neueFassung: Kopie als Entwurf mit ersetztId, ohne Nummer, Schnappschuss, PDF und Token; die alte AB bleibt unberührt', async () => {
const alt = await gesendet(baueDeps().deps)
const neu = await neueFassung(alt.id, { von: 'justin@webklar.com' })
assert.deepEqual([neu.status, neu.ersetztId, neu.nummer, neu.kunde, neu.erstelltVon], ['entwurf', alt.id, '', null, 'justin@webklar.com'])
assert.ok(!neu.pdfDatei && !neu.tokenHash && !neu.gesendetAm)
for (const feld of ['kundenId', 'projektId', 'titel', 'zeitquelle', 'art', 'positionen', 'stundensatzCents', 'schaetzungMinuten', 'summeCents', 'zahlungsplan', 'zahlungszielTage', 'auftragVom', 'auftragWeg', 'startAm', 'fertigBis']) {
assert.deepEqual(neu[feld], alt[feld], feld)
}
assert.deepEqual(await getAuftrag(alt.id), alt)
})
test('neueFassung: nur für versendete, bestätigte und abgerechnete (409); je AB nur ein offener Entwurf', async () => {
const alt = await gesendet(baueDeps().deps)
for (const status of ['entwurf', 'storniert', 'ersetzt', 'widerrufen']) {
await updateAuftrag(alt.id, { status })
await abgelehnt(neueFassung(alt.id, { von: VON }), 409, MELDUNG.keineNeueFassung, status)
}
for (const status of ['bestaetigt', 'abgerechnet', 'versendet']) {
await updateAuftrag(alt.id, { status })
const neu = await neueFassung(alt.id, { von: VON })
await abgelehnt(neueFassung(alt.id, { von: VON }), 409, MELDUNG.schonEntwurf, status)
await loescheEntwurf(neu.id)
}
await abgelehnt(neueFassung('a_gibtsnicht', { von: VON }), 404, MELDUNG.auftragFehlt)
})
test('Senden der neuen Fassung: die alte AB wird ersetzt, ihre Rechnungen wandern mit, der alte Link bleibt auffindbar', async () => {
const { deps } = baueDeps()
const alt = await gesendet(deps)
await createProjektRechnung('in_Anzahlung1', {
kundenId: KUNDE_ID, projektId: PROJEKT_ID, auftragId: alt.id, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [],
rechnungsnummer: 'WEBKLAR-0007', aktiv: true, erstelltVon: VON, erstelltAm: new Date().toISOString(),
})
const entwurf = await neueFassung(alt.id, { von: VON })
await aendereEntwurf(entwurf.id, { positionen: [{ beschreibung: 'Webdesign Startseite', menge: 1, einzelpreisCents: 70000 }] })
const { auftrag: neu } = await sendeAuftrag(entwurf.id, { von: VON, deps, jetzt: new Date('2026-10-07T10:00:00Z') })
assert.deepEqual([neu.nummer, neu.status, neu.ersetztId, neu.summeCents], ['AB-2026-0002', 'versendet', alt.id, 70000])
const altNachher = await getAuftrag(alt.id)
assert.deepEqual([altNachher.status, altNachher.ersetztDurchId, altNachher.tokenHash], ['ersetzt', neu.id, alt.tokenHash])
assert.equal((await findAuftragByTokenHash(alt.tokenHash)).id, alt.id)
assert.equal((await getProjektRechnung('in_Anzahlung1')).auftragId, neu.id)
})
test('Senden der neuen Fassung: ist die alte AB inzwischen storniert, gibt es 409 vor der Nummer', async () => {
const { deps, calls } = baueDeps()
const alt = await gesendet(deps)
const neu = await neueFassung(alt.id, { von: VON })
await updateAuftrag(alt.id, { status: 'storniert' })
await abgelehnt(sendeAuftrag(neu.id, { von: VON, deps, jetzt: JETZT }), 409, MELDUNG.ersetzteUngueltig)
assert.equal(calls.pdf.length, 1) // nur die PDF der ersten AB
const danach = await getAuftrag(neu.id)
assert.deepEqual([danach.status, danach.nummer], ['entwurf', ''])
assert.equal((await getAuftrag(alt.id)).status, 'storniert')
})
test('Senden der neuen Fassung: scheitert das Speichern erst nach dem Ersetzen der alten AB, führt die Wiederholung alles zu Ende', async () => {
mock.method(console, 'error', () => {})
const { deps } = baueDeps()
const alt = await gesendet(deps)
await createProjektRechnung('in_Anzahlung2', {
kundenId: KUNDE_ID, projektId: PROJEKT_ID, auftragId: alt.id, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [],
rechnungsnummer: 'WEBKLAR-0008', aktiv: true, erstelltVon: VON, erstelltAm: new Date().toISOString(),
})
const neu = await neueFassung(alt.id, { von: VON })
// erster PATCH auf auftraege: alte AB ersetzt (geht durch); zweiter PATCH: Speichern der neuen AB (scheitert)
aw.failNext({ collection: config.collections.auftraege, method: 'PATCH', status: 500, after: 1 })
await assert.rejects(sendeAuftrag(neu.id, { von: VON, deps, jetzt: JETZT }), (err) => err.status === 500)
assert.equal((await getAuftrag(neu.id)).status, 'entwurf')
assert.deepEqual(dateienIn(uploadDir), [alt.pdfDatei]) // die Datei der neuen AB wurde wieder entfernt
const { auftrag } = await sendeAuftrag(neu.id, { von: VON, deps, jetzt: JETZT })
assert.equal(auftrag.nummer, 'AB-2026-0002')
assert.deepEqual([(await getAuftrag(alt.id)).status, (await getAuftrag(alt.id)).ersetztDurchId], ['ersetzt', neu.id])
assert.equal((await getProjektRechnung('in_Anzahlung2')).auftragId, neu.id)
})
test('Senden der neuen Fassung wartet auf die Sperre der alten AB und prüft sie danach noch einmal (L5 widerruft gerade)', async () => {
const { deps, calls } = baueDeps()
const alt = await gesendet(deps)
const neu = await neueFassung(alt.id, { von: VON })
const lauf = await unterFremderSperre(alt.id, {
aktion: () => sendeAuftrag(neu.id, { von: VON, deps, jetzt: JETZT }),
bereit: () => calls.pdf.length === 2, // das PDF der neuen AB ist fertig, als Nächstes kommt das Ersetzen
waehrend: async () => {
assert.equal((await getAuftrag(alt.id)).status, 'versendet') // noch nicht ersetzt
await updateAuftrag(alt.id, { status: 'widerrufen', widerrufenAm: new Date().toISOString() })
},
})
assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.ersetzteUngueltig])
const danach = await getAuftrag(neu.id)
assert.deepEqual([danach.status, danach.nummer], ['entwurf', ''])
assert.deepEqual(dateienIn(uploadDir), [alt.pdfDatei]) // die Datei der neuen AB wurde wieder entfernt
assert.equal((await getAuftrag(alt.id)).status, 'widerrufen')
})
test('Senden: zeigt die ersetztId eines Entwurfs auf ihn selbst (beschädigte Daten), gibt es 409 statt eines Hängers an der eigenen Sperre', { timeout: 5000 }, async () => {
const { deps, calls } = baueDeps()
const entwurf = await legeEntwurfAn(festpreisEingabe(), { von: VON })
await updateAuftrag(entwurf.id, { ersetztId: entwurf.id })
await abgelehnt(sendeAuftrag(entwurf.id, { von: VON, deps, jetzt: JETZT }), 409, MELDUNG.ersetzteUngueltig)
assert.deepEqual([calls.pdf.length, calls.mail.length], [0, 0])
const danach = await getAuftrag(entwurf.id)
assert.deepEqual([danach.status, danach.nummer], ['entwurf', ''])
assert.deepEqual(dateienIn(uploadDir), [])
})
test('storniereAuftrag: versendet und bestätigt werden storniert (ohne Mail); Token, Nummer und PDF bleiben, damit der Link „storniert“ erklären kann', async () => {
const { deps, calls } = baueDeps()
const a = await gesendet(deps)
const b = await storniereAuftrag(a.id)
assert.equal(b.status, 'storniert')
assert.ok(Math.abs(new Date(b.storniertAm).getTime() - Date.now()) < 60_000)
assert.deepEqual([b.nummer, b.tokenHash, b.pdfDatei], [a.nummer, a.tokenHash, a.pdfDatei])
assert.equal((await findAuftragByTokenHash(a.tokenHash)).id, a.id)
assert.equal(calls.mail.length, 1) // nur die Mail des Sendens
const c = await gesendet(deps)
await updateAuftrag(c.id, { status: 'bestaetigt', bestaetigtAm: new Date().toISOString(), bestaetigtUeber: 'link' })
assert.equal((await storniereAuftrag(c.id)).status, 'storniert')
})
test('storniereAuftrag: sonst 409 (auch ein zweites Mal), unbekannt 404', async () => {
const a = await gesendet(baueDeps().deps)
for (const status of ['entwurf', 'abgerechnet', 'ersetzt', 'storniert', 'widerrufen']) {
await updateAuftrag(a.id, { status })
await abgelehnt(storniereAuftrag(a.id), 409, MELDUNG.keinStorno, status)
}
await abgelehnt(storniereAuftrag('a_gibtsnicht'), 404, MELDUNG.auftragFehlt)
})
test('storniereAuftrag wartet auf die Sperre der AB (L5 hält sie beim Widerruf) und sieht danach den neuen Stand', async () => {
const a = await gesendet(baueDeps().deps)
const lauf = await unterFremderSperre(a.id, {
aktion: () => storniereAuftrag(a.id),
waehrend: async () => {
assert.equal((await getAuftrag(a.id)).status, 'versendet') // noch nicht storniert
await updateAuftrag(a.id, { status: 'widerrufen', widerrufenAm: new Date().toISOString() })
},
})
assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.keinStorno])
assert.equal((await getAuftrag(a.id)).status, 'widerrufen')
})
const OHNE_PROJEKT = { projektId: '', titel: 'Beratung ohne Projekt' }
const ZWEITES_PROJEKT = 'p_zweites02'
const legeZweitesProjektAn = () => aw.seed(config.collections.websiteProjects, projektDoc({ $id: ZWEITES_PROJEKT, projectName: 'Zweites Projekt', ticketId: ZWEITES_TICKET }))
/** Alle Felder außer den genannten (für den Vergleich „sonst hat sich nichts geändert“). */
const ohne = (auftrag, ...felder) => Object.fromEntries(Object.entries(auftrag).filter(([name]) => !felder.includes(name)))
test('ordneProjektZu: eine versendete AB ohne Projekt bekommt Projekt und Zeitquelle des Projekts; Nummer, Status, PDF, Prüfsumme, Token und Versanddaten bleiben', async () => {
const { deps, calls } = baueDeps()
const alt = await gesendet(deps, OHNE_PROJEKT)
assert.deepEqual([alt.projektId, alt.zeitquelle], ['', []])
const neu = await ordneProjektZu(alt.id, PROJEKT_ID)
assert.deepEqual([neu.projektId, neu.zeitquelle], [PROJEKT_ID, [TICKET_ID]])
assert.deepEqual(ohne(neu, 'projektId', 'zeitquelle', 'geaendertAm'), ohne(alt, 'projektId', 'zeitquelle', 'geaendertAm'))
assert.deepEqual(await getAuftrag(alt.id), neu)
assert.equal((await findAuftragByTokenHash(alt.tokenHash)).id, alt.id) // der Link gilt weiter
assert.deepEqual(dateienIn(uploadDir), [alt.pdfDatei]) // keine neue Datei
assert.deepEqual(await lesePdf(alt.pdfDatei), Buffer.from(`%PDF-1.4 ${alt.nummer}`))
assert.deepEqual([calls.pdf.length, calls.token.length, calls.mail.length], [1, 1, 1]) // nichts neu erzeugt, keine Mail
})
/** Projektrechnung einer AB, wie L2 sie anlegt (hier ohne Projekt); die Rechnungs-ID muss zu /^in_\w{1,33}$/ passen. */
const projektRechnung = (auftragId, patch = {}) => ({
kundenId: KUNDE_ID, projektId: '', auftragId, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [],
rechnungsnummer: 'WEBKLAR-0007', aktiv: true, erstelltVon: VON, ...patch,
})
test('ordneProjektZu: hängt auch die Projektrechnungen der AB ohne Projekt um (aktive und stornierte); schon gesetzte Projekte, andere ABs und alle übrigen Felder bleiben', async () => {
const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
const andere = await gesendet(baueDeps().deps, OHNE_PROJEKT)
await createProjektRechnung('in_OhneProjekt1', projektRechnung(a.id))
await createProjektRechnung('in_OhneProjekt2', projektRechnung(a.id, { aktiv: false, rechnungsnummer: '' })) // storniert
await createProjektRechnung('in_MitProjekt', projektRechnung(a.id, { projektId: 'p_anderes', art: 'aufwand' }))
await createProjektRechnung('in_AndereAb', projektRechnung(andere.id))
const mitProjekt = await getProjektRechnung('in_MitProjekt')
const vorher = await getProjektRechnung('in_OhneProjekt1')
await ordneProjektZu(a.id, PROJEKT_ID)
const nachher = await getProjektRechnung('in_OhneProjekt1')
assert.equal(nachher.projektId, PROJEKT_ID)
assert.deepEqual(ohne(nachher, 'projektId'), ohne(vorher, 'projektId')) // sonst hat sich nichts geändert
assert.equal((await getProjektRechnung('in_OhneProjekt2')).projektId, PROJEKT_ID, 'auch eine stornierte Projektrechnung')
assert.deepEqual(await getProjektRechnung('in_MitProjekt'), mitProjekt) // schon ein anderes Projekt: bleibt
assert.equal((await getProjektRechnung('in_AndereAb')).projektId, '') // andere AB: bleibt
assert.equal((await getAuftrag(a.id)).projektId, PROJEKT_ID)
})
test('ordneProjektZu: scheitert das Umhängen einer Projektrechnung, bleibt die AB ohne Projekt, und die Wiederholung führt alles zu Ende', async () => {
mock.method(console, 'error', () => {})
const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
await createProjektRechnung('in_Wiederholung1', projektRechnung(a.id))
await createProjektRechnung('in_Wiederholung2', projektRechnung(a.id))
aw.failNext({ collection: config.collections.projektRechnungen, method: 'PATCH', status: 500, after: 1 }) // die erste geht durch, die zweite scheitert
await assert.rejects(ordneProjektZu(a.id, PROJEKT_ID), (err) => err.status === 500)
assert.deepEqual([(await getAuftrag(a.id)).projektId, (await getProjektRechnung('in_Wiederholung1')).projektId, (await getProjektRechnung('in_Wiederholung2')).projektId], ['', PROJEKT_ID, ''])
assert.equal((await ordneProjektZu(a.id, PROJEKT_ID)).projektId, PROJEKT_ID) // kein 409: die AB hatte noch kein Projekt
for (const id of ['in_Wiederholung1', 'in_Wiederholung2']) assert.equal((await getProjektRechnung(id)).projektId, PROJEKT_ID, id)
})
test('ordneProjektZu: auch ein Entwurf bekommt das Projekt (der Titel bleibt); eine gewählte Zeitquelle bleibt; ein Projekt ohne Ticket ergibt keine Leereinträge', async () => {
const entwurf = await legeEntwurfAn(festpreisEingabe({ ...OHNE_PROJEKT, titel: 'Freier Titel', zeitquelle: [ZWEITES_TICKET] }), { von: VON })
const a = await ordneProjektZu(entwurf.id, PROJEKT_ID)
assert.deepEqual([a.status, a.nummer, a.projektId, a.titel, a.zeitquelle], ['entwurf', '', PROJEKT_ID, 'Freier Titel', [ZWEITES_TICKET]])
aw.seed(config.collections.websiteProjects, projektDoc({ $id: 'p_ohneticket', projectName: 'Projekt ohne Ticket', ticketId: '' }))
const frei = await legeEntwurfAn(festpreisEingabe(OHNE_PROJEKT), { von: VON })
const b = await ordneProjektZu(frei.id, 'p_ohneticket')
assert.deepEqual([b.projektId, b.zeitquelle], ['p_ohneticket', []])
})
test('ordneProjektZu: fehlende oder ungültige Kennung, Projekt eines anderen Kunden und unbekanntes Projekt sind 400, eine unbekannte AB ist 404 – nichts ändert sich', async () => {
const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
const faelle = [
['Kennung fehlt', undefined, MELDUNG.projektFehlt],
['Kennung nur Leerzeichen', ' ', MELDUNG.projektFehlt],
['Kennung keine Zeichenkette', 5, MELDUNG.projektFehlt],
['Kennung mit ../', '../x', MELDUNG.projektUngueltig],
['Projekt eines anderen Kunden', FREMD.projekt, MELDUNG.projektFremd],
['unbekanntes Projekt', 'p_gibtsnicht', MELDUNG.projektFremd],
]
for (const [name, projektId, meldung] of faelle) await abgelehnt(ordneProjektZu(a.id, projektId), 400, meldung, name)
await abgelehnt(ordneProjektZu('a_gibtsnicht', PROJEKT_ID), 404, MELDUNG.auftragFehlt)
await abgelehnt(ordneProjektZu('../x', PROJEKT_ID), 404, MELDUNG.auftragFehlt)
assert.deepEqual(await getAuftrag(a.id), a)
})
test('ordneProjektZu: eine AB mit Projekt (auch dasselbe) sowie eine stornierte, ersetzte oder widerrufene AB lassen sich nicht zuordnen (409); versendet, bestätigt und abgerechnet gehen', async () => {
const mit = await gesendet(baueDeps().deps) // gehört schon zu PROJEKT_ID
legeZweitesProjektAn()
await abgelehnt(ordneProjektZu(mit.id, PROJEKT_ID), 409, MELDUNG.schonProjekt, 'dasselbe Projekt')
await abgelehnt(ordneProjektZu(mit.id, ZWEITES_PROJEKT), 409, MELDUNG.schonProjekt, 'anderes Projekt')
await abgelehnt(ordneProjektZu(mit.id, FREMD.projekt), 409, MELDUNG.schonProjekt, 'fremdes Projekt: der Stand der AB gilt zuerst')
assert.deepEqual(await getAuftrag(mit.id), mit)
const frei = await gesendet(baueDeps().deps, OHNE_PROJEKT)
for (const status of ['storniert', 'ersetzt', 'widerrufen']) {
await updateAuftrag(frei.id, { status })
await abgelehnt(ordneProjektZu(frei.id, PROJEKT_ID), 409, MELDUNG.keinProjekt, status)
}
assert.equal((await getAuftrag(frei.id)).projektId, '')
for (const status of ['versendet', 'bestaetigt', 'abgerechnet']) {
await updateAuftrag(frei.id, { status, projektId: '', zeitquelle: [] })
assert.equal((await ordneProjektZu(frei.id, PROJEKT_ID)).projektId, PROJEKT_ID, status)
}
})
test('ordneProjektZu wartet auf die Sperre der AB und prüft den Stand danach (L5 widerruft gerade: 409)', async () => {
const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
const lauf = await unterFremderSperre(a.id, {
aktion: () => ordneProjektZu(a.id, PROJEKT_ID),
waehrend: async () => {
assert.equal((await getAuftrag(a.id)).projektId, '') // noch nichts geschrieben
await updateAuftrag(a.id, { status: 'widerrufen', widerrufenAm: new Date().toISOString() })
},
})
assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.keinProjekt])
assert.equal((await getAuftrag(a.id)).projektId, '')
})
test('ordneProjektZu: zwei Aufrufe gleichzeitig (zwei Admins) – genau einer gelingt, der andere ist 409', async () => {
const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
legeZweitesProjektAn()
const ergebnisse = await Promise.allSettled([ordneProjektZu(a.id, PROJEKT_ID), ordneProjektZu(a.id, ZWEITES_PROJEKT)])
assert.deepEqual(ergebnisse.map((r) => r.status).sort(), ['fulfilled', 'rejected'])
const abgewiesen = ergebnisse.find((r) => r.status === 'rejected').reason
assert.deepEqual([abgewiesen.status, abgewiesen.message], [409, MELDUNG.schonProjekt])
const gewinner = ergebnisse.find((r) => r.status === 'fulfilled').value
assert.deepEqual(await getAuftrag(a.id), gewinner)
})
+90
View File
@@ -0,0 +1,90 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { starteUmgebung, entwurfsDaten, sha256, KUNDE_ID, PROJEKT_ID, VON } from './helpers/auftraegeAb.js'
// Vertragsprüfung: Die L0-Module (Fundament) liefern, was der Strang L3 braucht (Übersicht V1 bis V4). Schlägt sie fehl,
// weicht L0 vom Vertrag ab: nichts in fremden Dateien reparieren, sondern stoppen und melden.
const umg = await starteUmgebung()
after(() => umg.schliesse())
const DIENSTE = '../server/services/auftraege'
const store = await import(`${DIENSTE}/auftragStore.js`)
const ablage = await import(`${DIENSTE}/ablage.js`)
const { getSettings } = await import('../server/services/finance/settings.js')
const EXPORTE = {
'konstanten.js': ['AUFTRAG_ARTEN', 'AUFTRAG_WEGE', 'ZAHLUNGSPLAN_ARTEN', 'KUNDENARTEN', 'VEREINBARTE_STATUS', 'STUNDENSATZ_STANDARD_CENTS', 'VIERTELSTUNDE', 'TOKEN_GUELTIG_TAGE', 'AGB_FASSUNG', 'LIMITS', 'SPERRE_NUMMER', 'sperreAuftrag', 'SCHALTER_AUS_MELDUNG'],
'fehler.js': ['AuftragError', 'sendeFehler'],
'schalter.js': ['auftraegeAktiv'],
'auftragStore.js': ['getAuftrag', 'listAuftraege', 'createAuftrag', 'updateAuftrag', 'deleteAuftrag', 'findAuftragByTokenHash', 'findAuftragByNummer'],
'projektRechnungenStore.js': ['getProjektRechnung', 'listProjektRechnungen', 'createProjektRechnung', 'updateProjektRechnung', 'umhaengen'],
'auftragSicht.js': ['kundeSnapshot', 'auftragFuerTeam'],
'ablage.js': ['speicherePdf', 'lesePdf', 'entfernePdf'], // entfernePdf: Nachtrag der Planrunde (Übersicht, Nachträge L0)
'texte.js': ['TEXTE_FASSUNG'],
}
test('L0-Module, Konfiguration und Einstellungen liefern die Namen der Übersicht', async () => {
for (const [datei, namen] of Object.entries(EXPORTE)) {
const modul = await import(`${DIENSTE}/${datei}`)
for (const name of namen) assert.ok(name in modul, `${datei} exportiert ${name} nicht`)
}
for (const feld of ['mode', 'pilotEmails', 'uploadDir']) assert.ok(feld in umg.config.auftraege, `config.auftraege.${feld} fehlt`)
assert.equal(umg.config.auftraege.uploadDir, umg.uploadDir)
assert.equal(umg.config.collections.auftraege, 'auftraege')
assert.equal(umg.config.collections.projektRechnungen, 'projektRechnungen')
assert.equal((await getSettings()).steuernummer, '')
// Sperren (V2): je Sperre ein zusammengesetzter Schlüssel, weil withLocks jedes Listenelement für sich sperrt
const { SPERRE_NUMMER, sperreAuftrag } = await import(`${DIENSTE}/konstanten.js`)
assert.deepEqual(SPERRE_NUMMER, ['auftraege-nummer'])
assert.deepEqual(sperreAuftrag('a_x1'), ['auftraege-ab:a_x1'])
})
test('createAuftrag legt einen Entwurf mit den Leerwerten aus V3 an, updateAuftrag ändert nur die genannten Felder', async () => {
const a = await store.createAuftrag(entwurfsDaten())
assert.deepEqual(
[a.status, a.nummer, a.jahr, a.laufnummer, a.kunde, a.gesendetAm, a.tokenGueltigBis, a.bestaetigtAm, a.bestaetigtUeber, a.mailStatus, a.fruehbeginn, a.erstelltVon],
['entwurf', '', null, null, null, null, null, null, '', '', false, VON],
)
assert.equal(typeof a.id, 'string')
assert.ok(!Number.isNaN(new Date(a.erstelltAm).getTime()))
assert.deepEqual(a.positionen, entwurfsDaten().positionen)
assert.deepEqual(a.zahlungsplan, { art: 'anzahlung', prozent: 50, text: '' })
assert.deepEqual(await store.getAuftrag(a.id), a)
const b = await store.updateAuftrag(a.id, { titel: 'Anderer Titel' })
assert.equal(b.titel, 'Anderer Titel')
assert.deepEqual([b.positionen, b.zeitquelle, b.zahlungsplan, b.summeCents], [a.positionen, a.zeitquelle, a.zahlungsplan, a.summeCents])
assert.deepEqual(await store.getAuftrag(a.id), b)
assert.equal(await store.getAuftrag('a_gibtsnicht'), null)
})
test('listAuftraege liefert alle Treffer, auch mehr als die 25 Dokumente, die Appwrite ohne limit liefert', async () => {
for (let i = 0; i < 30; i += 1) await store.createAuftrag(entwurfsDaten({ kundenId: 'c_liste01' }))
assert.equal((await store.listAuftraege({ kundenId: 'c_liste01' })).length, 30)
})
test('ablage: speicherePdf legt <kundenId>/<id>.pdf an, überschreibt nie; lesePdf liest und lehnt fremde Pfade ab; entfernePdf räumt auf', async () => {
const pdf = Buffer.from('%PDF-1.4 vertrag')
assert.deepEqual(await ablage.speicherePdf('c_ablage', 'a_ablage', pdf), { pdfDatei: 'c_ablage/a_ablage.pdf', pdfSha256: sha256(pdf) })
assert.deepEqual(await ablage.lesePdf('c_ablage/a_ablage.pdf'), pdf)
await assert.rejects(async () => ablage.speicherePdf('c_ablage', 'a_ablage', Buffer.from('%PDF-1.4 anders')))
await assert.rejects(async () => ablage.lesePdf('../etc/passwd'), (err) => err.status === 404)
await ablage.entfernePdf('c_ablage/a_ablage.pdf')
await assert.rejects(async () => ablage.lesePdf('c_ablage/a_ablage.pdf'), (err) => err.status === 404)
assert.equal((await ablage.speicherePdf('c_ablage', 'a_ablage', pdf)).pdfDatei, 'c_ablage/a_ablage.pdf')
})
test('umhaengen hängt alle Dokumente einer AB auf die neue um und zählt sie; ein zweiter Aufruf findet nichts mehr; list und update genügen für die Projektzuordnung', async () => {
const { createProjektRechnung, getProjektRechnung, listProjektRechnungen, updateProjektRechnung, umhaengen } = await import(`${DIENSTE}/projektRechnungenStore.js`)
const daten = (auftragId) => ({ kundenId: KUNDE_ID, projektId: PROJEKT_ID, auftragId, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [], rechnungsnummer: 'WEBKLAR-0001', aktiv: true, erstelltVon: VON, erstelltAm: new Date().toISOString() })
await createProjektRechnung('in_Umhaengen1', daten('a_alt'))
await createProjektRechnung('in_Umhaengen2', daten('a_anders'))
assert.equal(await umhaengen('a_alt', 'a_neu'), 1)
assert.equal((await getProjektRechnung('in_Umhaengen1')).auftragId, 'a_neu')
assert.equal((await getProjektRechnung('in_Umhaengen2')).auftragId, 'a_anders')
assert.equal(await umhaengen('a_alt', 'a_neu'), 0)
// Projektzuordnung (Task 11): alle Dokumente einer AB, auch inaktive; die projektId lässt sich einzeln ändern, sonst bleibt alles
await updateProjektRechnung('in_Umhaengen1', { aktiv: false })
assert.deepEqual((await listProjektRechnungen({ auftragId: 'a_neu', nurAktiv: false })).map((r) => r.invoiceId), ['in_Umhaengen1'])
assert.deepEqual(await listProjektRechnungen({ auftragId: 'a_neu' }), [])
const geaendert = await updateProjektRechnung('in_Umhaengen1', { projektId: 'p_neu01' })
assert.deepEqual([geaendert.projektId, geaendert.auftragId, geaendert.betragCents, geaendert.aktiv], ['p_neu01', 'a_neu', 40000, false])
})
+53
View File
@@ -0,0 +1,53 @@
import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { starteUmgebung, VON, MELDUNG, festpreisEingabe, kundeDoc, baueDeps, abgelehnt, dateienIn } from './helpers/auftraegeAb.js'
const umg = await starteUmgebung()
const { aw, config, uploadDir } = umg
after(() => umg.schliesse())
beforeEach(umg.zuruecksetzen)
const { getAuftrag, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { kundeSnapshot } = await import('../server/services/auftraege/auftragSicht.js')
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
const { legeEntwurfAn, vorschauPdf, setzeDepsFuerTests } = await import('../server/services/auftraege/auftraege.js')
beforeEach(() => setzeDepsFuerTests(null))
test('vorschauPdf: Entwurf mit „Entwurf“ als Nummer, aktuellem Kunden und den Einstellungen – ohne etwas zu speichern', async () => {
aw.seed(config.collections.financeSettings, { $id: 'main', steuernummer: '12/345/67890', bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00' })
const entwurf = await legeEntwurfAn(festpreisEingabe(), { von: VON })
aw.seed(config.collections.customers, kundeDoc({ street: 'Neue Straße 5' })) // der Kunde ändert sich nach dem Entwurf
const { deps, calls } = baueDeps()
assert.deepEqual(await vorschauPdf(entwurf.id, { deps }), Buffer.from('%PDF-1.4 Entwurf'))
assert.equal(calls.pdf.length, 1)
const { auftrag, optionen } = calls.pdf[0]
assert.deepEqual([auftrag.id, auftrag.nummer], [entwurf.id, 'Entwurf'])
assert.deepEqual(auftrag.kunde, kundeSnapshot(kundeDoc({ street: 'Neue Straße 5' })))
assert.equal(auftrag.kunde.strasse, 'Neue Straße 5')
assert.deepEqual(optionen, { settings: { steuernummer: '12/345/67890', bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00' }, vorschau: true })
assert.equal(calls.token.length + calls.mail.length, 0)
assert.deepEqual(dateienIn(uploadDir), [])
const danach = await getAuftrag(entwurf.id)
assert.deepEqual([danach.status, danach.nummer, danach.kunde], ['entwurf', '', null])
})
test('vorschauPdf: nur Entwürfe (409), unbekannt (404); wirft die PDF-Erzeugung oder liefert sie nichts, gibt es 500 mit fester Meldung; ein AuftragError der PDF-Funktion bleibt erhalten', async () => {
mock.method(console, 'error', () => {})
const entwurf = await legeEntwurfAn(festpreisEingabe(), { von: VON })
await abgelehnt(vorschauPdf('a_gibtsnicht', { deps: baueDeps().deps }), 404, MELDUNG.auftragFehlt)
await abgelehnt(vorschauPdf(entwurf.id, { deps: baueDeps({ pdfFehler: new Error('pdfkit kaputt') }).deps }), 500, MELDUNG.pdf)
await abgelehnt(vorschauPdf(entwurf.id, { deps: baueDeps({ pdf: Buffer.alloc(0) }).deps }), 500, MELDUNG.pdf)
const unvollstaendig = new AuftragError(409, 'Entwurf unvollständig: Anschrift fehlt.')
await abgelehnt(vorschauPdf(entwurf.id, { deps: baueDeps({ pdfFehler: unvollstaendig }).deps }), 409, 'Entwurf unvollständig: Anschrift fehlt.')
await updateAuftrag(entwurf.id, { status: 'versendet' })
await abgelehnt(vorschauPdf(entwurf.id, { deps: baueDeps().deps }), 409, 'Eine Vorschau gibt es nur für Entwürfe.')
})
test('deps: ohne Angabe gilt der Ersatz aus setzeDepsFuerTests, ausdrückliche Angaben haben Vorrang, nötig ist nur die benutzte Funktion', async () => {
const entwurf = await legeEntwurfAn(festpreisEingabe(), { von: VON })
const ersatz = baueDeps({ pdf: Buffer.from('%PDF-1.4 ersatz') })
const eigene = baueDeps({ pdf: Buffer.from('%PDF-1.4 eigene') })
setzeDepsFuerTests(ersatz.deps)
assert.deepEqual(await vorschauPdf(entwurf.id), Buffer.from('%PDF-1.4 ersatz'))
assert.deepEqual(await vorschauPdf(entwurf.id, { deps: { erzeugeAuftragPdf: eigene.deps.erzeugeAuftragPdf } }), Buffer.from('%PDF-1.4 eigene'))
assert.deepEqual([ersatz.calls.pdf.length, eigene.calls.pdf.length], [1, 1])
})
+83
View File
@@ -0,0 +1,83 @@
// test/auftraegeAblage.test.js
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
// Die Ablage liest ihren Ordner aus config.auftraege.uploadDir: AUFTRAEGE_UPLOAD_DIR muss vor dem ersten Import von config.js stehen.
// Die Ablage liegt in einem Unterordner der Wurzel, damit „Köder“-Dateien in der Wurzel einen Ausbruch aus der Ablage (../) sichtbar machen.
const PRAEFIX = path.join(os.tmpdir(), 'auftraege-ablage-')
const wurzel = mkdtempSync(PRAEFIX)
const basis = path.join(wurzel, 'ablage')
mkdirSync(basis, { mode: 0o700 })
process.env.AUFTRAEGE_UPLOAD_DIR = basis
after(() => {
if (wurzel.startsWith(PRAEFIX)) rmSync(wurzel, { recursive: true, force: true })
})
const { speicherePdf, lesePdf, entfernePdf } = await import('../server/services/auftraege/ablage.js')
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
const PDF = Buffer.from('%PDF-1.7\n% Auftragsbestätigung\n%%EOF\n')
const sha = (buffer) => createHash('sha256').update(buffer).digest('hex')
const nichtGefunden = (err) => err instanceof AuftragError && err.status === 404 && err.message === 'PDF nicht gefunden.'
test('speicherePdf legt <kundenId>/<auftragId>.pdf an: Prüfsumme, Rechte 0700 und 0600, Inhalt unverändert', async () => {
const ergebnis = await speicherePdf('c1', 'ab1', PDF)
assert.deepEqual(ergebnis, { pdfDatei: 'c1/ab1.pdf', pdfSha256: sha(PDF) })
assert.match(ergebnis.pdfSha256, /^[0-9a-f]{64}$/)
const datei = path.join(basis, 'c1', 'ab1.pdf')
assert.deepEqual(readFileSync(datei), PDF)
assert.equal(statSync(datei).mode & 0o777, 0o600)
assert.equal(statSync(path.join(basis, 'c1')).mode & 0o777, 0o700)
assert.deepEqual(await lesePdf('c1/ab1.pdf'), PDF)
})
test('speicherePdf überschreibt nie: die vorhandene Datei bleibt, der zweite Aufruf ist 409', async () => {
await speicherePdf('c2', 'ab1', PDF)
await assert.rejects(
() => speicherePdf('c2', 'ab1', Buffer.from('%PDF-1.7\nanderer Inhalt\n')),
(err) => err instanceof AuftragError && err.status === 409,
)
assert.deepEqual(readFileSync(path.join(basis, 'c2', 'ab1.pdf')), PDF)
assert.equal((await speicherePdf('c3', 'ab1', PDF)).pdfDatei, 'c3/ab1.pdf', 'dieselbe AB-ID bei einem anderen Kunden ist eine andere Datei')
})
test('speicherePdf: ungültige Kennungen und Puffer sind Programmfehler und legen nichts an', async () => {
const vorher = readdirSync(basis).sort()
const kennungen = [['', 'ab1'], ['c1', ''], ['../x', 'ab1'], ['c1', '../ab1'], ['c1/x', 'ab1'], ['c1', 'a.b'], ['c1', 'a'.repeat(65)], [undefined, 'ab1'], ['c1', 5]]
for (const [kunde, ab] of kennungen) await assert.rejects(() => speicherePdf(kunde, ab, PDF), TypeError, JSON.stringify([kunde, ab]))
for (const buffer of [Buffer.alloc(0), 'text', undefined, null, new Uint8Array(3)]) {
await assert.rejects(() => speicherePdf('c9', 'ab9', buffer), TypeError, String(buffer))
}
assert.deepEqual(readdirSync(basis).sort(), vorher)
})
test('lesePdf: Pfadtricks, fremde Zeichen und falsche Form sind 404; erlaubt sind 64 Zeichen aus A–Z, a–z, 0–9, _ und -', async () => {
await speicherePdf('c4', 'ab1', PDF)
writeFileSync(path.join(wurzel, 'geheim.pdf'), 'GEHEIM') // neben der Ablage: ein Ausbruch per ../ würde sie lesen
const falsch = [
'c4/../geheim.pdf', '../geheim.pdf', 'c4/ab1.pdf/../../geheim.pdf', '/c4/ab1.pdf', `${basis}/c4/ab1.pdf`, 'c4\\ab1.pdf',
'c4/ab1.PDF', 'c4/ab1.pdf\0', 'c4/ab1.pdf.exe', 'c4/ab 1.pdf', 'c4/ab1', 'ab1.pdf', 'c4/x/ab1.pdf', '', ' ',
`${'a'.repeat(65)}/ab1.pdf`, `c4/${'a'.repeat(65)}.pdf`, 'c4/ä.pdf', undefined, null, 42, {},
]
for (const pdfDatei of falsch) await assert.rejects(() => lesePdf(pdfDatei), nichtGefunden, JSON.stringify(pdfDatei))
await assert.rejects(() => lesePdf('c4/gibtsnicht.pdf'), nichtGefunden)
const lang = `${'A'.repeat(32)}_${'b'.repeat(31)}`
await speicherePdf('K_1-x', lang, PDF)
assert.deepEqual(await lesePdf(`K_1-x/${lang}.pdf`), PDF)
})
test('entfernePdf entfernt eine gespeicherte Datei und prüft den Pfad wie lesePdf; danach lässt sich dieselbe AB erneut speichern', async () => {
await speicherePdf('c5', 'ab1', PDF)
await entfernePdf('c5/ab1.pdf')
assert.equal(existsSync(path.join(basis, 'c5', 'ab1.pdf')), false)
await entfernePdf('c5/ab1.pdf')
await assert.rejects(() => lesePdf('c5/ab1.pdf'), nichtGefunden)
writeFileSync(path.join(wurzel, 'geheim2.pdf'), 'x')
for (const pfad of ['../geheim2.pdf', 'c5/../geheim2.pdf']) await assert.rejects(() => entfernePdf(pfad), nichtGefunden, pfad)
assert.ok(existsSync(path.join(wurzel, 'geheim2.pdf')))
assert.equal((await speicherePdf('c5', 'ab1', PDF)).pdfDatei, 'c5/ab1.pdf')
})
File diff suppressed because it is too large. Load diff
+196
View File
@@ -0,0 +1,196 @@
// test/auftraegeArbeitsblaetter.test.js
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
// worksheets gehört dem Ticketsystem und hat im Nachbau kein Schema (jedes Feld ist erlaubt).
const aw = await startAppwriteMock({ schema: { projektRechnungen: schemaKeys('projektRechnungen') }, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { createProjektRechnung, updateProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js')
const A = await import('../server/services/auftraege/arbeitsblaetter.js')
const WS = config.collections.worksheets
const PR = config.collections.projektRechnungen
beforeEach(async () => {
for (const col of [WS, PR]) for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id)
aw.resetListCalls()
})
const UNTERSTUETZUNG = JSON.stringify([{ employeeId: 'e2', name: 'Justin Klein', task: 'Review' }])
const ZWEI_UNTERSTUETZER = JSON.stringify([
{ employeeId: 'e2', name: 'Justin Klein', task: 'Review' },
{ employeeId: 'e3', name: 'Nico', task: 'Test' },
])
const blatt = (id, felder = {}) =>
aw.seed(WS, {
$id: id, workorderId: 't1', woid: '100101', wsid: id, startDate: '06.10.2026', endDate: '06.10.2026', totalTime: 60,
details: 'Startseite überarbeitet', serviceType: 'REMOTE', employeeName: 'Kenso Grimm', countsToPlan: false, isComment: false, supporters: '', ...felder,
})
const nachId = async (ticketIds = ['t1']) => Object.fromEntries((await A.ladeArbeitsblaetter(ticketIds)).map((b) => [b.id, b]))
test('ladeArbeitsblaetter liefert die normierte Form je Blatt', async () => {
blatt('w1')
assert.deepEqual(await A.ladeArbeitsblaetter(['t1']), [
{ id: 'w1', ticketId: 't1', woid: '100101', datum: '2026-10-06', minuten: 60, text: 'Startseite überarbeitet', aboZeit: false, kommentar: false },
])
})
test('Datum eines Arbeitsblatts: TT.MM.JJJJ, ISO als Berliner Tag, leer fällt auf $createdAt, ein unmögliches Datum auch', async () => {
const faelle = {
d1: [{ startDate: '06.10.2026' }, '2026-10-06'],
d2: [{ startDate: '', endDate: '07.10.2026' }, '2026-10-07'],
d3: [{ startDate: '2026-10-06T21:59:59.000Z' }, '2026-10-06'], // 23:59:59 in Berlin
d4: [{ startDate: '2026-10-06T22:30:00.000Z' }, '2026-10-07'], // 00:30 in Berlin am nächsten Tag
d5: [{ startDate: '2026-10-06' }, '2026-10-06'],
d6: [{ startDate: '', $createdAt: '2026-10-08T21:30:00.000+00:00' }, '2026-10-08'],
d7: [{ startDate: '', $createdAt: '2026-10-08T22:00:00.000+00:00' }, '2026-10-09'],
d8: [{ startDate: '31.02.2026', $createdAt: '2026-10-01T10:00:00.000+00:00' }, '2026-10-01'],
d9: [{ startDate: 'gestern', $createdAt: '2026-10-02T10:00:00.000+00:00' }, '2026-10-02'],
d10: [{ startDate: '12.13.2026', $createdAt: '2026-10-03T10:00:00.000+00:00' }, '2026-10-03'], // nie als Monatsangabe der US-Schreibweise lesen
}
for (const [id, [felder]] of Object.entries(faelle)) blatt(id, { endDate: '', ...felder })
const datum = Object.fromEntries(Object.values(await nachId()).map((b) => [b.id, b.datum]))
assert.deepEqual(datum, Object.fromEntries(Object.entries(faelle).map(([id, [, tag]]) => [id, tag])))
})
test('Minuten: je Blatt inklusive Unterstützung auf 15 Minuten aufgerundet', async () => {
const faelle = [
['m1', { totalTime: 10 }, 15],
['m2', { totalTime: 15 }, 15],
['m3', { totalTime: 16 }, 30],
['m4', { totalTime: 0 }, 0],
['m5', { totalTime: 'abc' }, 0],
['m6', { totalTime: 80, supporters: UNTERSTUETZUNG }, 135], // 80 + 45 Unterstützung = 125
['m7', { totalTime: 20, supporters: UNTERSTUETZUNG }, 45], // 20 + 15 = 35
['m8', { totalTime: 60, supporters: ZWEI_UNTERSTUETZER }, 120], // 60 + 30 + 30
]
for (const [id, felder] of faelle) blatt(id, felder)
const minuten = Object.fromEntries(Object.values(await nachId()).map((b) => [b.id, b.minuten]))
assert.deepEqual(minuten, Object.fromEntries(faelle.map(([id, , erwartet]) => [id, erwartet])))
})
test('Abo-Zeit und Kommentar werden erkannt und sind nie abrechenbar, ebenso 0 Minuten', async () => {
blatt('f1')
blatt('f2', { countsToPlan: true })
blatt('f3', { isComment: true, totalTime: 0 })
blatt('f4', { totalTime: 0 })
const b = await nachId()
assert.deepEqual([b.f1.aboZeit, b.f1.kommentar, A.abrechenbar(b.f1)], [false, false, true])
assert.deepEqual([b.f2.aboZeit, A.abrechenbar(b.f2)], [true, false])
assert.deepEqual([b.f3.kommentar, A.abrechenbar(b.f3)], [true, false])
assert.equal(A.abrechenbar(b.f4), false)
const basis = { aboZeit: false, kommentar: false, minuten: 15 }
assert.equal(A.abrechenbar(basis), true)
assert.equal(A.abrechenbar({ ...basis, aboZeit: true }), false)
assert.equal(A.abrechenbar({ ...basis, kommentar: true }), false)
assert.equal(A.abrechenbar({ ...basis, minuten: 0 }), false)
})
test('Text: wie in der Kundenansicht, eine Zeile, höchstens 200 Zeichen, Git ohne Kürzel, Link und Namen', async () => {
blatt('x1', { details: ' Startseite\n\nKontaktformular überarbeitet ' })
blatt('x2', { details: 'ä'.repeat(300) })
blatt('x3', {
serviceType: 'GIT',
totalTime: 45,
details: [
'Git Push auf `knso/x` (Branch: main)',
'Commit: 88d3c37',
'',
'- [88d3c37](https://git.webklar.com/knso/x/commit/88d3c37ab7b15337772cf59a5a9294ec0cc8cca4): feat: Kontaktformular repariert (Kenso Grimm)',
'- [1a2b3c4](https://git.webklar.com/knso/x/commit/1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b): fix: Footer',
'',
].join('\n'),
})
blatt('x4', { isComment: true, details: 'nur intern' })
blatt('x5', { details: '' })
const b = await nachId()
assert.equal(b.x1.text, 'Startseite Kontaktformular überarbeitet')
assert.equal(b.x2.text.length, 200)
assert.ok(b.x2.text.endsWith('…'))
assert.equal(b.x3.text, 'Kontaktformular repariert; Footer')
assert.doesNotMatch(b.x3.text, /88d3c37|git\.webklar|Kenso/)
assert.equal(b.x4.text, '')
assert.equal(b.x5.text, '')
})
test('ladeArbeitsblaetter: nur die genannten Tickets, nach Datum und ID sortiert, in Blöcken zu 100 Tickets', async () => {
blatt('w3', { startDate: '07.10.2026' })
blatt('w2', { startDate: '06.10.2026' })
blatt('w1', { startDate: '06.10.2026' })
blatt('w9', { workorderId: 't2', startDate: '05.10.2026' })
blatt('w8', { workorderId: 't3' })
assert.deepEqual((await A.ladeArbeitsblaetter(['t1', 't2'])).map((b) => b.id), ['w9', 'w1', 'w2', 'w3'])
assert.deepEqual((await A.ladeArbeitsblaetter(['t1', 't1', '', null, 5])).map((b) => b.id), ['w1', 'w2', 'w3'])
aw.resetListCalls()
for (const leer of [[], undefined, null, [''], 'kein-array']) assert.deepEqual(await A.ladeArbeitsblaetter(leer), [], String(leer))
assert.equal(aw.listCalls(WS).length, 0, 'ohne Ticket keine Abfrage')
const viele = Array.from({ length: 250 }, (_, i) => `t_${i}`)
viele[7] = 't1'
assert.deepEqual((await A.ladeArbeitsblaetter(viele)).map((b) => b.id), ['w1', 'w2', 'w3'])
const aufrufe = aw.listCalls(WS)
assert.equal(aufrufe.length, 3)
for (const aufruf of aufrufe) {
assert.ok(aufruf.queries.find((q) => q.method === 'equal' && q.attribute === 'workorderId').values.length <= 100)
}
})
test('ladeArbeitsblaetterNachIds liefert nur vorhandene Blätter, sortiert wie ladeArbeitsblaetter', async () => {
blatt('w1', { startDate: '07.10.2026' })
blatt('w2', { workorderId: 't2', startDate: '06.10.2026' })
assert.deepEqual((await A.ladeArbeitsblaetterNachIds(['w1', 'w2', 'gibtsnicht', 'w1'])).map((b) => [b.id, b.ticketId]), [['w2', 't2'], ['w1', 't1']])
assert.deepEqual(await A.ladeArbeitsblaetterNachIds([]), [])
})
test('abgerechneteArbeitsblaetter: nur aktive Projektrechnungen dieses Kunden; ein Entwurf hat die Nummer leer', async () => {
const daten = (extra) => ({ kundenId: 'c1', projektId: 'p1', art: 'aufwand', betragCents: 1000, ...extra })
await createProjektRechnung('in_gesendet', daten({ arbeitsblattIds: ['w1', 'w2'] }))
await updateProjektRechnung('in_gesendet', { rechnungsnummer: 'WEBKLAR-0009' })
await createProjektRechnung('in_entwurf', daten({ arbeitsblattIds: ['w5'] }))
await createProjektRechnung('in_storniert', daten({ arbeitsblattIds: ['w3'] }))
await updateProjektRechnung('in_storniert', { aktiv: false })
await createProjektRechnung('in_fremd', daten({ kundenId: 'c2', arbeitsblattIds: ['w4'] }))
const karte = await A.abgerechneteArbeitsblaetter('c1')
assert.ok(karte instanceof Map)
assert.deepEqual([...karte.entries()].sort(), [
['w1', { invoiceId: 'in_gesendet', rechnungsnummer: 'WEBKLAR-0009' }],
['w2', { invoiceId: 'in_gesendet', rechnungsnummer: 'WEBKLAR-0009' }],
['w5', { invoiceId: 'in_entwurf', rechnungsnummer: '' }],
])
await assert.rejects(() => A.abgerechneteArbeitsblaetter(''), TypeError)
})
test('offeneArbeitsblaetter: abrechenbar und noch nicht abgerechnet; ein stornierter Eintrag gibt das Blatt frei', async () => {
blatt('w1') // abgerechnet (siehe unten)
blatt('w6', { totalTime: 30 }) // offen
blatt('w7', { countsToPlan: true }) // Abo-Zeit
blatt('w8', { isComment: true, totalTime: 0 }) // Kommentar
blatt('w9', { totalTime: 0 }) // 0 Minuten
blatt('w10', { workorderId: 't2' }) // anderes Ticket
await createProjektRechnung('in_a', { kundenId: 'c1', projektId: 'p1', art: 'aufwand', betragCents: 1000, arbeitsblattIds: ['w1'] })
assert.deepEqual((await A.offeneArbeitsblaetter(['t1'], 'c1')).map((b) => b.id), ['w6'])
await updateProjektRechnung('in_a', { aktiv: false })
assert.deepEqual((await A.offeneArbeitsblaetter(['t1'], 'c1')).map((b) => b.id), ['w1', 'w6'])
})
test('zeitquelleFuerProjekt: Vereinigung der vereinbarten ABs, sonst das Ticket des Projekts', () => {
const ab = (status, zeitquelle) => ({ status, zeitquelle })
const projekt = { $id: 'p1', ticketId: 't0' }
const alle = [
ab('versendet', ['t1', 't2']), ab('bestaetigt', ['t2', 't3']), ab('abgerechnet', ['t4']),
ab('entwurf', ['t9']), ab('storniert', ['t8']), ab('ersetzt', ['t7']), ab('widerrufen', ['t6']),
]
assert.deepEqual(A.zeitquelleFuerProjekt(projekt, alle), ['t1', 't2', 't3', 't4'])
assert.deepEqual(A.zeitquelleFuerProjekt(projekt, []), ['t0'])
assert.deepEqual(A.zeitquelleFuerProjekt(projekt, [ab('entwurf', ['t9'])]), ['t0'])
assert.deepEqual(A.zeitquelleFuerProjekt(projekt, [ab('versendet', [])]), ['t0'])
assert.deepEqual(A.zeitquelleFuerProjekt({ $id: 'p1' }, []), [])
assert.deepEqual(A.zeitquelleFuerProjekt({ ticketId: '' }, undefined), [])
assert.deepEqual(A.zeitquelleFuerProjekt(null, []), [])
})
+135
View File
@@ -0,0 +1,135 @@
// test/auftraegeAuftragSicht.test.js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { kundeSnapshot, fruehesterBeginn, zahlungsplanText, aufwandText, auftragFuerTeam, oeffentlicheAnsicht } from '../server/services/auftraege/auftragSicht.js'
import { AGB_FASSUNG } from '../server/services/auftraege/konstanten.js'
import { AB_TEXTE, BESTAETIGUNG_TEXT, FRUEHBEGINN_TEXT } from '../server/services/auftraege/texte.js'
import { euroDe } from '../server/services/finance/format.js'
const KUNDE = { name: 'Anna Beispiel', firma: '', strasse: 'Hauptstr. 1', plz: '67722', ort: 'Winnweiler', land: '', email: 'anna@example.invalid', kundenart: 'privat', kundennummer: 'K-1001' }
const festpreis = (felder = {}) => ({
id: 'ab1', nummer: 'AB-2026-0002', jahr: 2026, laufnummer: 2, status: 'versendet', kundenId: 'c1', kunde: KUNDE, projektId: 'p1', titel: 'Website Bäckerei',
zeitquelle: ['t1'], art: 'festpreis',
positionen: [{ beschreibung: 'Startseite', menge: 2, einzelpreisCents: 30000 }, { beschreibung: 'Kontaktformular', menge: 1, einzelpreisCents: 20000 }],
stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000, zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14,
auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '2026-11-01', fertigBis: '', ersetztId: '', ersetztDurchId: '', texteFassung: '2026-10-06', agbFassung: '2026-09-30',
pdfDatei: 'c1/ab1.pdf', pdfSha256: 'a'.repeat(64), gesendetAm: '2026-10-06T10:00:00.000Z', gesendetAn: 'anna@example.invalid', mailStatus: 'gesendet', mailFehler: '',
tokenHash: 'b'.repeat(64), tokenGueltigBis: '2026-12-05T10:00:00.000Z', bestaetigtAm: null, bestaetigtUeber: '', bestaetigtPdfSha256: '', fruehbeginn: false, fruehbeginnFassung: '',
storniertAm: null, widerrufenAm: null, erstelltVon: 'kenso@webklar.com', erstelltAm: '2026-10-05T09:00:00.000Z', geaendertAm: '2026-10-06T10:00:00.000Z', ...felder,
})
const aufwand = (felder = {}) =>
festpreis({ art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' }, ...felder })
const LEER = { name: '', firma: '', strasse: '', plz: '', ort: '', land: '', email: '', kundenart: '', kundennummer: '' }
test('kundeSnapshot übernimmt die Kundendaten getrimmt; eine unbekannte Kundenart wird leer; null und {} sind leer', () => {
const doc = { $id: 'c1', name: ' Anna Beispiel ', companyName: '', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler', country: '', email: 'anna@example.invalid', kundenart: 'privat', code: 'K-1001', phone: '123', customerStatus: 'active' }
assert.deepEqual(kundeSnapshot(doc), KUNDE)
assert.deepEqual(kundeSnapshot({ ...doc, companyName: ' Bäckerei Beispiel GmbH ', kundenart: 'unternehmen' }), { ...KUNDE, firma: 'Bäckerei Beispiel GmbH', kundenart: 'unternehmen' })
assert.equal(kundeSnapshot({ name: 'X', kundenart: 'firma' }).kundenart, '')
assert.deepEqual(kundeSnapshot({}), LEER)
assert.deepEqual(kundeSnapshot(null), LEER)
})
test('kundeSnapshot kürzt lange Werte, so dass die Spalte kundeJson (2000 Zeichen) reicht', () => {
const lang = 'x'.repeat(500)
const snapshot = kundeSnapshot({ name: lang, companyName: lang, street: lang, postalCode: lang, city: lang, country: lang, email: lang, kundenart: 'privat', code: lang })
assert.ok(JSON.stringify(snapshot).length <= 2000)
assert.equal(snapshot.name.length, 120)
})
test('fruehesterBeginn: Tag nach Ablauf der Widerrufsfrist, nur für Privatpersonen ohne Frühbeginn', () => {
const faelle = [
['2026-10-06T10:00:00.000Z', '2026-10-21'],
['2026-10-06T22:30:00.000Z', '2026-10-22'], // in Berlin schon der 07.10.
['2026-10-10T08:00:00.000Z', '2026-10-27'], // Fristende fiele auf Samstag, 26.10. ist der Montag
['2026-10-17T08:00:00.000Z', '2026-11-03'], // 31.10. (Samstag, Feiertag) und 01.11. (Sonntag), Fristende Montag 02.11.
]
for (const [gesendetAm, erwartet] of faelle) assert.equal(fruehesterBeginn(festpreis({ gesendetAm })), erwartet, gesendetAm)
assert.equal(fruehesterBeginn(festpreis({ fruehbeginn: true })), null)
for (const kundenart of ['unternehmen', '']) assert.equal(fruehesterBeginn(festpreis({ kunde: { ...KUNDE, kundenart } })), null, kundenart)
assert.equal(fruehesterBeginn(festpreis({ kunde: null })), null)
assert.equal(fruehesterBeginn(festpreis({ gesendetAm: null, status: 'entwurf' })), null)
assert.equal(fruehesterBeginn(festpreis({ gesendetAm: 'kaputt' })), null)
})
test('zahlungsplanText: Anzahlung nach Prozent gerundet, Rest als Differenz', () => {
const text = zahlungsplanText(festpreis())
assert.equal(text.split(euroDe(40000)).length, 3, text) // Anzahlung und Rest je 400,00 €
assert.match(text, /50\s?%/)
assert.match(text, /\b14\b/)
// Math.round(10001 × 33 / 100) = 3300, Rest 6701
const ungerade = zahlungsplanText(festpreis({ summeCents: 10001, zahlungsplan: { art: 'anzahlung', prozent: 33, text: '' } }))
assert.ok(ungerade.includes(euroDe(3300)) && ungerade.includes(euroDe(6701)), ungerade)
})
test('zahlungsplanText: nach Abnahme, monatlich, frei; Anzahlung ohne Summe fällt auf nach Abnahme zurück', () => {
assert.match(zahlungsplanText(festpreis({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })), /\b14\b/)
assert.match(zahlungsplanText(aufwand({ zahlungszielTage: 7 })), /\b7\b/)
assert.ok(zahlungsplanText(festpreis({ zahlungsplan: { art: 'frei', prozent: null, text: 'Zahlung in zwei Raten.' } })).includes('Zahlung in zwei Raten'))
const ohneSumme = aufwand({ zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' } })
assert.equal(zahlungsplanText(ohneSumme), zahlungsplanText({ ...ohneSumme, zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } }))
})
test('aufwandText: nur bei Aufwand, mit Stundensatz und Schätzung in Stunden', () => {
const text = aufwandText(aufwand())
assert.ok(text.includes(euroDe(10000)))
assert.match(text, /\b10\b/)
assert.doesNotMatch(aufwandText(aufwand({ schaetzungMinuten: null })), /\b10\b/)
assert.match(aufwandText(aufwand({ schaetzungMinuten: 135 })), /2,25/)
assert.match(aufwandText(aufwand({ schaetzungMinuten: 90 })), /1,5\b/)
assert.ok(aufwandText(aufwand({ stundensatzCents: null })).includes(euroDe(10000)), 'ohne Satz gilt der Standard')
assert.equal(aufwandText(festpreis()), null)
})
test('auftragFuerTeam: ohne Token-Hash, PDF-Pfad und Bestätigungs-Prüfsumme, mit den abgeleiteten Feldern', () => {
const auftrag = festpreis()
const kopie = structuredClone(auftrag)
const sicht = auftragFuerTeam(auftrag)
assert.deepEqual(auftrag, kopie, 'verändert die AB nicht')
const entfernt = ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256']
for (const feld of entfernt) assert.equal(Object.hasOwn(sicht, feld), false, feld)
assert.equal(sicht.pdfSha256, 'a'.repeat(64))
assert.equal(sicht.pdfVorhanden, true)
assert.equal(sicht.fruehesterBeginn, '2026-10-21')
assert.equal(sicht.zahlungsplanText, zahlungsplanText(auftrag))
assert.equal(sicht.aufwandText, null)
assert.deepEqual(
Object.keys(sicht).sort(),
[...Object.keys(auftrag).filter((k) => !entfernt.includes(k)), 'pdfVorhanden', 'fruehesterBeginn', 'zahlungsplanText', 'aufwandText'].sort(),
)
assert.equal(auftragFuerTeam(festpreis({ pdfDatei: '' })).pdfVorhanden, false)
})
test('oeffentlicheAnsicht: genau die Felder aus V3, Beträge je Position, Berliner Datum, nichts Internes', () => {
const ansicht = oeffentlicheAnsicht(festpreis())
assert.deepEqual(
Object.keys(ansicht).sort(),
['agbUrl', 'aufwandText', 'art', 'bestaetigtAm', 'datum', 'fertigBis', 'fruehbeginn', 'fruehesterBeginn', 'kundenart', 'nummer', 'positionen', 'schaetzungMinuten', 'startAm', 'status', 'stundensatzCents', 'summeCents', 'texte', 'titel', 'zahlungsplanText'].sort(),
)
assert.deepEqual(ansicht.positionen, [
{ beschreibung: 'Startseite', menge: 2, einzelpreisCents: 30000, summeCents: 60000 },
{ beschreibung: 'Kontaktformular', menge: 1, einzelpreisCents: 20000, summeCents: 20000 },
])
assert.deepEqual(
[ansicht.nummer, ansicht.datum, ansicht.titel, ansicht.art, ansicht.summeCents, ansicht.startAm, ansicht.kundenart, ansicht.status],
['AB-2026-0002', '2026-10-06', 'Website Bäckerei', 'festpreis', 80000, '2026-11-01', 'privat', 'versendet'],
)
assert.equal(ansicht.fruehesterBeginn, '2026-10-21')
assert.deepEqual(ansicht.texte, { bestaetigung: BESTAETIGUNG_TEXT, fruehbeginn: FRUEHBEGINN_TEXT, kleinunternehmer: AB_TEXTE.kleinunternehmer })
assert.equal(ansicht.texte.kleinunternehmer, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.')
assert.equal(ansicht.agbUrl, '/rechtliches/agb-2026-09-30.pdf')
const text = JSON.stringify(ansicht)
for (const geheim of ['b'.repeat(64), 'c1/ab1.pdf', 'anna@example.invalid', 'Hauptstr. 1', 'kenso@webklar.com']) assert.ok(!text.includes(geheim), geheim)
})
test('oeffentlicheAnsicht: Unternehmen ohne Frühbeginn-Text, Datum nach Berliner Zeit, AGB-Adresse ohne gespeicherte Fassung', () => {
const firma = oeffentlicheAnsicht(festpreis({ kunde: { ...KUNDE, kundenart: 'unternehmen' }, gesendetAm: '2026-10-06T22:30:00.000Z', agbFassung: '' }))
assert.equal(firma.texte.fruehbeginn, null)
assert.equal(firma.texte.kleinunternehmer, AB_TEXTE.kleinunternehmer)
assert.equal(firma.fruehesterBeginn, null)
assert.equal(firma.datum, '2026-10-07')
assert.equal(firma.agbUrl, `/rechtliches/agb-${AGB_FASSUNG}.pdf`)
const aufwandAnsicht = oeffentlicheAnsicht(aufwand())
assert.deepEqual([aufwandAnsicht.positionen, aufwandAnsicht.stundensatzCents, aufwandAnsicht.schaetzungMinuten], [[], 10000, 600])
assert.equal(aufwandAnsicht.aufwandText, aufwandText(aufwand()))
})
+152
View File
@@ -0,0 +1,152 @@
// test/auftraegeAuftragStore.test.js
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
// Speicher der Auftragsbestätigungen. Nachbau von Appwrite mit dem Schema aus schema.js, Datumsfeldern wie in Appwrite
// ("+00:00") und dem Standard-Limit 25.
const aw = await startAppwriteMock({ schema: { auftraege: schemaKeys('auftraege') }, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { docToAuftrag, auftragToDoc, getAuftrag, listAuftraege, createAuftrag, updateAuftrag, deleteAuftrag, findAuftragByTokenHash, findAuftragByNummer } =
await import('../server/services/auftraege/auftragStore.js')
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
const COL = config.collections.auftraege
beforeEach(async () => {
for (const id of [...aw.docs(COL).keys()]) await deleteDocument(COL, id)
})
const ENTWURF = {
kundenId: 'c1', projektId: 'p1', titel: 'Website Bäckerei', zeitquelle: ['t1'], art: 'festpreis',
positionen: [{ beschreibung: 'Startseite', menge: 1, einzelpreisCents: 50000 }],
summeCents: 50000, zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14,
auftragVom: '2026-10-05', auftragWeg: 'telefon', erstelltVon: 'kenso@webklar.com',
}
const FELDER = 'id nummer jahr laufnummer status kundenId kunde projektId titel zeitquelle art positionen stundensatzCents schaetzungMinuten summeCents zahlungsplan zahlungszielTage auftragVom auftragWeg startAm fertigBis ersetztId ersetztDurchId texteFassung agbFassung pdfDatei pdfSha256 gesendetAm gesendetAn mailStatus mailFehler tokenHash tokenGueltigBis bestaetigtAm bestaetigtUeber bestaetigtPdfSha256 fruehbeginn fruehbeginnFassung storniertAm widerrufenAm erstelltVon erstelltAm geaendertAm'
const HASH = 'a'.repeat(64)
const seed = (id, felder = {}) => aw.seed(COL, { $id: id, kundenId: 'c1', status: 'entwurf', ...felder })
test('createAuftrag legt einen Entwurf an: Status erzwungen, Zeitpunkte gesetzt, JSON-Felder serialisiert', async () => {
const vor = Date.now()
const auftrag = await createAuftrag({ ...ENTWURF, status: 'versendet' })
assert.deepEqual(Object.keys(auftrag).sort(), FELDER.split(' ').sort(), 'genau die 43 Felder aus V3')
assert.match(auftrag.id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
assert.equal(auftrag.status, 'entwurf')
assert.deepEqual(
{ nummer: auftrag.nummer, jahr: auftrag.jahr, laufnummer: auftrag.laufnummer, kunde: auftrag.kunde, gesendetAm: auftrag.gesendetAm, mailStatus: auftrag.mailStatus, fruehbeginn: auftrag.fruehbeginn },
{ nummer: '', jahr: null, laufnummer: null, kunde: null, gesendetAm: null, mailStatus: '', fruehbeginn: false },
)
assert.deepEqual(auftrag.positionen, ENTWURF.positionen)
assert.deepEqual(auftrag.zahlungsplan, ENTWURF.zahlungsplan)
assert.deepEqual(auftrag.zeitquelle, ['t1'])
assert.deepEqual([auftrag.stundensatzCents, auftrag.schaetzungMinuten], [null, null])
assert.match(auftrag.erstelltAm, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
assert.equal(auftrag.geaendertAm, auftrag.erstelltAm)
assert.ok(Date.parse(auftrag.erstelltAm) >= vor)
const doc = aw.docs(COL).get(auftrag.id)
assert.equal(doc.status, 'entwurf')
assert.equal(doc.positionenJson, JSON.stringify(ENTWURF.positionen))
assert.equal(doc.zeitquelleJson, '["t1"]')
assert.deepEqual(await getAuftrag(auftrag.id), auftrag)
})
test('docToAuftrag: fehlende Felder bekommen Vorgaben, kaputtes JSON wirft nie, Zeitpunkte werden zu ISO mit Z', () => {
const auftrag = docToAuftrag({
$id: 'x1', kundenId: 'c1', status: 'versendet', nummer: 'AB-2026-0003', jahr: 2026, laufnummer: 3,
kundeJson: '', positionenJson: '{kaputt', zeitquelleJson: '["t1","",5," t2 "]', zahlungsplanJson: '[1]',
gesendetAm: '2026-10-06T10:00:00.000+00:00', tokenGueltigBis: null, fruehbeginn: true, summeCents: 1000,
$createdAt: '2026-10-06T08:00:00.000+00:00', $updatedAt: '2026-10-06T09:00:00.000+00:00',
})
assert.equal(auftrag.id, 'x1')
assert.deepEqual([auftrag.nummer, auftrag.jahr, auftrag.laufnummer, auftrag.summeCents], ['AB-2026-0003', 2026, 3, 1000])
assert.deepEqual([auftrag.kunde, auftrag.positionen, auftrag.zeitquelle], [null, [], ['t1', 't2']])
assert.deepEqual(auftrag.zahlungsplan, { art: 'nach_abnahme', prozent: null, text: '' })
assert.equal(auftrag.zahlungszielTage, 14)
assert.deepEqual([auftrag.gesendetAm, auftrag.tokenGueltigBis, auftrag.bestaetigtAm], ['2026-10-06T10:00:00.000Z', null, null])
assert.equal(auftrag.fruehbeginn, true)
assert.deepEqual([auftrag.erstelltAm, auftrag.geaendertAm], ['2026-10-06T08:00:00.000Z', '2026-10-06T09:00:00.000Z'])
})
test('auftragToDoc: nur Felder aus V1, JSON serialisiert, undefined fehlt, null löscht', () => {
const doc = auftragToDoc({
id: 'x1', $id: 'x1', fremd: 1, status: 'versendet', kunde: { name: 'Anna Beispiel', kundenart: 'privat' },
zeitquelle: ['t1'], positionen: [], zahlungsplan: { art: 'frei', prozent: null, text: 'x' },
gesendetAm: '2026-10-06T10:00:00.000Z', tokenGueltigBis: null, titel: undefined, tokenHash: null,
})
assert.deepEqual(doc, {
status: 'versendet', kundeJson: '{"name":"Anna Beispiel","kundenart":"privat"}', zeitquelleJson: '["t1"]', positionenJson: '[]',
zahlungsplanJson: '{"art":"frei","prozent":null,"text":"x"}', gesendetAm: '2026-10-06T10:00:00.000Z', tokenGueltigBis: null, tokenHash: '',
})
assert.equal(auftragToDoc({ kunde: null }).kundeJson, '')
})
test('auftragToDoc: ein Text über der Spaltengröße ist ein 400, ein falscher Typ ein Programmfehler', () => {
assert.throws(() => auftragToDoc({ titel: 'x'.repeat(201) }), (err) => err instanceof AuftragError && err.status === 400 && /titel/.test(err.message))
assert.throws(() => auftragToDoc({ kunde: { name: 'x'.repeat(2001) } }), (err) => err instanceof AuftragError && err.status === 400 && /kunde/.test(err.message))
assert.doesNotThrow(() => auftragToDoc({ titel: 'x'.repeat(200) }))
for (const falsch of [{ titel: 5 }, { summeCents: 1.5 }, { jahr: '2026' }, { fruehbeginn: 'ja' }, { gesendetAm: 'gestern' }, { gesendetAm: '' }]) {
assert.throws(() => auftragToDoc(falsch), TypeError, JSON.stringify(falsch))
}
})
test('getAuftrag, updateAuftrag, deleteAuftrag: unbekannte und unmögliche IDs sind null, 404 bzw. folgenlos', async () => {
seed('u1', { titel: 'Alt', summeCents: 1000, erstelltAm: '2020-01-01T00:00:00.000Z', geaendertAm: '2020-01-01T00:00:00.000Z' })
assert.equal((await getAuftrag('u1')).titel, 'Alt')
for (const id of ['gibtsnicht', '', '../x', 'a/b', undefined, null, 42, 'x'.repeat(37)]) assert.equal(await getAuftrag(id), null, String(id))
const auftrag = await updateAuftrag('u1', { titel: 'Neu', positionen: [{ beschreibung: 'A', menge: 2, einzelpreisCents: 500 }], status: 'versendet' })
assert.deepEqual([auftrag.titel, auftrag.summeCents, auftrag.status], ['Neu', 1000, 'versendet'])
assert.equal(auftrag.positionen[0].menge, 2)
assert.equal(auftrag.erstelltAm, '2020-01-01T00:00:00.000Z')
assert.ok(auftrag.geaendertAm > '2020-01-01T00:00:00.000Z')
assert.equal(aw.docs(COL).get('u1').titel, 'Neu')
for (const id of ['gibtsnicht', '../x']) {
await assert.rejects(() => updateAuftrag(id, { titel: 'x' }), (err) => err instanceof AuftragError && err.status === 404, id)
}
await deleteAuftrag('u1')
assert.equal(aw.docs(COL).size, 0)
await deleteAuftrag('u1')
await deleteAuftrag('../x')
})
test('listAuftraege: Filter, neueste zuerst, mehr als 25 Treffer', async () => {
seed('a1', { projektId: 'p1', jahr: 2026, erstelltAm: '2026-10-01T10:00:00.000Z' })
seed('a2', { projektId: 'p2', jahr: 2026, erstelltAm: '2026-10-03T10:00:00.000Z' })
seed('a3', { projektId: 'p1', jahr: 2025, erstelltAm: '2026-10-02T10:00:00.000Z' })
seed('b1', { kundenId: 'c2', projektId: 'p1', jahr: 2026, erstelltAm: '2026-10-04T10:00:00.000Z' })
const ids = async (filter) => (await listAuftraege(filter)).map((a) => a.id)
assert.deepEqual(await ids({ kundenId: 'c1' }), ['a2', 'a3', 'a1'])
assert.deepEqual(await ids({ kundenId: 'c1', projektId: 'p1' }), ['a3', 'a1'])
assert.deepEqual(await ids({ jahr: 2026 }), ['b1', 'a2', 'a1'])
assert.deepEqual(await ids(), ['b1', 'a2', 'a3', 'a1'])
assert.deepEqual(await ids({ kundenId: 'gibtsnicht' }), [])
for (let i = 0; i < 30; i += 1) seed(`m${String(i).padStart(2, '0')}`, { kundenId: 'c9', erstelltAm: `2026-09-${String(1 + (i % 28)).padStart(2, '0')}T10:00:00.000Z` })
assert.equal((await listAuftraege({ kundenId: 'c9' })).length, 30)
})
test('listAuftraege: ein angegebener, aber leerer oder ungültiger Filter ist ein Fehler, nie „alle“', async () => {
seed('a1')
for (const filter of [{ kundenId: '' }, { kundenId: null }, { kundenId: 5 }, { projektId: '' }, { jahr: '2026' }, { jahr: 2026.5 }, { jahr: null }]) {
await assert.rejects(() => listAuftraege(filter), TypeError, JSON.stringify(filter))
}
})
test('findAuftragByTokenHash und findAuftragByNummer: genau eine AB, leere und ungültige Schlüssel nie', async () => {
seed('e1', { nummer: '', tokenHash: '' })
seed('e2')
seed('v1', { status: 'versendet', nummer: 'AB-2026-0001', tokenHash: HASH })
assert.equal((await findAuftragByTokenHash(HASH)).id, 'v1')
assert.equal((await findAuftragByNummer('AB-2026-0001')).id, 'v1')
assert.equal(await findAuftragByTokenHash('b'.repeat(64)), null)
assert.equal(await findAuftragByNummer('AB-2026-0002'), null)
for (const hash of ['', ' ', 'abc', 'A'.repeat(64), `${HASH}0`, undefined, null, 5]) assert.equal(await findAuftragByTokenHash(hash), null, String(hash))
for (const nummer of ['', 'Entwurf', 'AB-2026-1', 'ab-2026-0001', 'AB-2026-0001 ', undefined, null]) assert.equal(await findAuftragByNummer(nummer), null, String(nummer))
})
+278
View File
@@ -0,0 +1,278 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, UNGUELTIG, TOKEN_NEU, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'
// bestaetigung.js: Bestätigen (idempotent, unter Sperre) und Widerrufen einer AB; Mails über die Senke
const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { auftragToDoc, getAuftrag, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
const { FRUEHBEGINN_FASSUNG, FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js')
const { sperreAuftrag } = await import('../server/services/auftraege/konstanten.js')
const { withLocks } = await import('../server/services/keyedLock.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { bestaetigeAuftrag, bestaetigungsLage, widerrufeAuftrag } = await import('../server/services/auftraege/bestaetigung.js')
after(() => {
setMailTransportForTests(null)
return aw.close()
})
const JETZT = new Date('2026-10-07T08:30:00.000Z') // 10:30 Uhr in Berlin (MESZ)
const ms = (iso) => new Date(iso).getTime()
let sink
let protokoll
beforeEach(() => {
sink = mailSink()
setMailTransportForTests(sink)
protokoll = mock.method(console, 'error', () => {})
})
afterEach(() => mock.restoreAll())
const seed = (over = {}) => {
const ab = abDokument(over)
aw.seed('auftraege', { $id: ab.id, ...auftragToDoc(ab) })
return ab
}
const betreffe = () => sink.sent.map((mail) => mail.subject)
const K32 = 'Ihre Bestätigung zu AB-2026-0001 ist eingegangen'
const T22 = '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster'
const norm = (text) => text.replace(/\s+/g, ' ').trim()
test('bestaetigungsLage: offen, bestaetigt oder gesperrt', () => {
const lage = (over) => bestaetigungsLage(abDokument(over))
assert.equal(lage({}), 'offen')
assert.equal(lage({ status: 'abgerechnet' }), 'offen')
assert.equal(lage({ status: 'abgerechnet', bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'bestaetigt')
assert.equal(lage({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'bestaetigt')
for (const status of ['entwurf', 'ersetzt', 'storniert', 'widerrufen']) assert.equal(lage({ status, bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'gesperrt', status)
})
test('bestaetigeAuftrag: Privatperson über den Link mit Frühbeginn – Protokoll am Dokument, K32 und T22', async () => {
const ab = seed()
const neu = await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: true, jetzt: JETZT })
assert.equal(neu.status, 'bestaetigt')
assert.equal(ms(neu.bestaetigtAm), JETZT.getTime())
assert.equal(neu.bestaetigtUeber, 'link')
assert.equal(neu.bestaetigtPdfSha256, ab.pdfSha256)
assert.equal(neu.fruehbeginn, true)
assert.equal(neu.fruehbeginnFassung, FRUEHBEGINN_FASSUNG)
const doc = await getAuftrag('ab1')
assert.deepEqual([doc.status, doc.bestaetigtUeber, doc.fruehbeginn, ms(doc.bestaetigtAm)], ['bestaetigt', 'link', true, JETZT.getTime()])
assert.deepEqual(betreffe(), [K32, T22])
assert.ok(norm(sink.sent[0].text).includes(norm(FRUEHBEGINN_TEXT)), 'K32 gibt die Erklärung wörtlich wieder')
assert.ok(sink.sent[1].text.includes(`${config.womsBaseUrl}/customers/c1?tab=invoices`))
})
test('bestaetigeAuftrag: Privatperson über das Portal ohne Frühbeginn – frühester Beginn nach der Widerrufsfrist', async () => {
const neu = await bestaetigeAuftrag(seed(), { ueber: 'portal', fruehbeginn: false, jetzt: JETZT })
assert.equal(neu.bestaetigtUeber, 'portal')
assert.equal(neu.fruehbeginn, false)
assert.equal(neu.fruehbeginnFassung, FRUEHBEGINN_FASSUNG, 'die angezeigte Fassung der Erklärung wird auch ohne Haken festgehalten')
assert.ok(sink.sent[0].text.includes('Wir beginnen daher frühestens am 21.10.2026 mit der Arbeit.'))
assert.ok(sink.sent[1].text.includes('Arbeitsbeginn frühestens am 21.10.2026'))
assert.ok(sink.sent[1].text.includes('Weg: Kundenportal'))
})
test('bestaetigeAuftrag: Unternehmen – fruehbeginn wird ignoriert, keine Beginn-Hinweise in K32 und T22', async () => {
const neu = await bestaetigeAuftrag(seed({ kunde: kunde('unternehmen') }), { ueber: 'link', fruehbeginn: true, jetzt: JETZT })
assert.equal(neu.fruehbeginn, false)
assert.equal(neu.fruehbeginnFassung, '')
for (const mail of sink.sent) assert.doesNotMatch(mail.text, /Beginn der Arbeiten|frühestens|Erklärung zum früheren Beginn|Die Arbeit darf sofort/)
assert.equal(sink.sent[1].subject, '[Aufträge] Auftrag AB-2026-0001 bestätigt: Muster GmbH')
})
test('bestaetigeAuftrag: abgerechnete AB ohne frühere Bestätigung – Status bleibt abgerechnet', async () => {
const neu = await bestaetigeAuftrag(seed({ status: 'abgerechnet' }), { ueber: 'portal', fruehbeginn: false, jetzt: JETZT })
assert.equal(neu.status, 'abgerechnet')
assert.equal(ms(neu.bestaetigtAm), JETZT.getTime())
assert.equal((await getAuftrag('ab1')).status, 'abgerechnet')
assert.equal(sink.sent.length, 2)
})
test('bestaetigeAuftrag: die zweite Bestätigung ändert nichts und verschickt nichts (die erste zählt, auch bei veraltetem Eingabeobjekt)', async () => {
const ab = seed()
const erste = await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: true, jetzt: JETZT })
const zweite = await bestaetigeAuftrag(ab, { ueber: 'portal', fruehbeginn: false, jetzt: new Date(JETZT.getTime() + 3_600_000) })
assert.equal(ms(zweite.bestaetigtAm), ms(erste.bestaetigtAm))
assert.equal(zweite.bestaetigtUeber, 'link')
assert.equal(zweite.fruehbeginn, true)
assert.equal(sink.sent.length, 2, 'nur die Mails der ersten Bestätigung')
})
test('bestaetigeAuftrag: zwei gleichzeitige Bestätigungen (Doppelklick, Link und Portal) – eine zählt, genau zwei Mails', async () => {
const ab = seed()
const [a, b] = await Promise.all([
bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }),
bestaetigeAuftrag(ab, { ueber: 'portal', fruehbeginn: false, jetzt: new Date(JETZT.getTime() + 1000) }),
])
assert.equal(ms(a.bestaetigtAm), ms(b.bestaetigtAm))
assert.equal(a.bestaetigtUeber, 'link', 'der zuerst gestartete Aufruf gewinnt')
assert.equal(b.bestaetigtUeber, 'link')
assert.equal(sink.sent.length, 2)
})
test('bestaetigeAuftrag: läuft unter der Sperre sperreAuftrag(id); K32 und T22 gehen erst nach ihrer Freigabe raus', async () => {
const ab = seed()
let freigeben
const gehalten = new Promise((resolve) => {
freigeben = resolve
})
const halter = withLocks(sperreAuftrag('ab1'), () => gehalten)
let fertig = false
const bestaetigt = bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }).then((neu) => {
fertig = true
return neu
})
await new Promise((resolve) => setTimeout(resolve, 100))
assert.equal(fertig, false, 'wartet, solange ein anderer die Sperre der AB hält')
assert.equal((await getAuftrag('ab1')).status, 'versendet')
freigeben()
await halter
assert.equal((await bestaetigt).status, 'bestaetigt')
// Mails erst nach der Freigabe: im Versand ist die Sperre der AB wieder frei (sonst käme das Rennen nach 300 ms mit false zurück)
seed()
sink.sent.length = 0
const frei = []
setMailTransportForTests({
async sendMail(mail) {
frei.push(await Promise.race([withLocks(sperreAuftrag('ab1'), async () => true), new Promise((resolve) => setTimeout(() => resolve(false), 300))]))
return sink.sendMail(mail)
},
})
await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT })
assert.deepEqual(frei, [true, true])
assert.equal(sink.sent.length, 2)
})
test('bestaetigeAuftrag: Entwurf, ersetzt, storniert und widerrufen sind nicht bestätigbar (409), nichts ändert sich, keine Mail', async () => {
const faelle = [['entwurf', 'Diese Auftragsbestätigung kann nicht bestätigt werden.'], ['ersetzt', 'Diese Auftragsbestätigung wurde ersetzt.'], ['storniert', 'Diese Auftragsbestätigung wurde storniert.'], ['widerrufen', 'Diese Auftragsbestätigung wurde widerrufen.']]
for (const [status, meldung] of faelle) {
const ab = seed({ status })
await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 409 && err.message === meldung, status)
const doc = await getAuftrag('ab1')
assert.deepEqual([doc.status, doc.bestaetigtAm], [status, null], status)
}
assert.equal(sink.sent.length, 0)
})
test('bestaetigeAuftrag: scheitert der Mailversand, bleibt die Bestätigung bestehen (Fehler nur im Protokoll)', async () => {
setMailTransportForTests({ async sendMail() { throw new Error('SMTP 451') } })
const neu = await bestaetigeAuftrag(seed(), { ueber: 'link', fruehbeginn: false, jetzt: JETZT })
assert.equal(neu.status, 'bestaetigt')
assert.equal((await getAuftrag('ab1')).status, 'bestaetigt')
assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('AB-2026-0001')), 'Fehler mit Nummer im Protokoll')
})
test('bestaetigeAuftrag: ungültiger Weg oder Zeitpunkt wirft TypeError, unbekannte AB 404', async () => {
const ab = seed()
await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'telefon', fruehbeginn: false, jetzt: JETZT }), TypeError)
await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: 'gestern' }), TypeError)
await assert.rejects(bestaetigeAuftrag(abDokument({ id: 'gibtsnicht' }), { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 404)
assert.equal(sink.sent.length, 0)
})
test('bestaetigeAuftrag über den Link: ein Token-Tausch (Erinnerung) oder Ablauf zwischen Prüfung und Sperre ergibt 404 wie bei ungültigem Link', async () => {
const alt = seed()
const nichtsGeaendert = async () => {
assert.equal((await getAuftrag('ab1')).status, 'versendet')
assert.equal(sink.sent.length, 0)
}
// die Erinnerung tauscht den Token, nachdem die Route den alten geprüft hat
const { hashToken } = await import('../server/services/auftraege/token.js')
await updateAuftrag('ab1', { tokenHash: hashToken(TOKEN_NEU) })
await assert.rejects(bestaetigeAuftrag(alt, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 404 && err.message === UNGUELTIG)
await nichtsGeaendert()
// der Link ist inzwischen abgelaufen
await updateAuftrag('ab1', { tokenHash: alt.tokenHash, tokenGueltigBis: '2026-10-07T08:29:59.000Z' })
await assert.rejects(bestaetigeAuftrag(alt, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 404 && err.message === UNGUELTIG)
await nichtsGeaendert()
// das Portal braucht keinen Link: dort zaehlt der Token nicht
assert.equal((await bestaetigeAuftrag(alt, { ueber: 'portal', fruehbeginn: false, jetzt: JETZT })).status, 'bestaetigt')
})
// gesendet am 06.10.2026 12:00 Berlin: die Frist endet am 20.10.2026 um 24 Uhr (= 21:59:59,999 UTC)
const FRIST_ENDE = new Date('2026-10-20T21:59:59.999Z')
test('widerrufeAuftrag: Privatperson – am letzten Tag bis 24 Uhr (eingeschlossen) widerrufen, eine Millisekunde später nach_frist', async () => {
const ab = seed()
const ok = await widerrufeAuftrag(ab, { jetzt: FRIST_ENDE })
assert.equal(ok.ergebnis, 'widerrufen')
assert.equal(ok.auftrag.status, 'widerrufen')
assert.equal(ms(ok.auftrag.widerrufenAm), FRIST_ENDE.getTime())
assert.equal(ok.fristEnde.getTime(), FRIST_ENDE.getTime())
assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
seed()
const spaet = await widerrufeAuftrag(ab, { jetzt: new Date(FRIST_ENDE.getTime() + 1) })
assert.equal(spaet.ergebnis, 'nach_frist')
assert.equal(spaet.auftrag.status, 'versendet')
assert.equal((await getAuftrag('ab1')).widerrufenAm, null)
})
test('widerrufeAuftrag: bestätigte AB wird widerrufen, bestaetigtAm bleibt als Beleg; Unternehmen: kein_widerrufsrecht', async () => {
const bestaetigt = seed({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', fruehbeginn: true })
const r = await widerrufeAuftrag(bestaetigt, { jetzt: JETZT })
assert.equal(r.ergebnis, 'widerrufen')
assert.equal(r.auftrag.fruehbeginn, true)
assert.equal(ms((await getAuftrag('ab1')).bestaetigtAm), JETZT.getTime())
const firma = seed({ kunde: kunde('unternehmen') })
const k = await widerrufeAuftrag(firma, { jetzt: JETZT })
assert.equal(k.ergebnis, 'kein_widerrufsrecht')
assert.equal((await getAuftrag('ab1')).status, 'versendet')
})
test('widerrufeAuftrag: bereits widerrufen, abgerechnet, ersetzt, storniert, Entwurf – nichts ändert sich', async () => {
const faelle = [['widerrufen', 'bereits_widerrufen'], ['abgerechnet', 'nicht_widerrufbar'], ['ersetzt', 'nicht_widerrufbar'], ['storniert', 'nicht_widerrufbar'], ['entwurf', 'nicht_widerrufbar']]
for (const [status, ergebnis] of faelle) {
const ab = seed({ status, widerrufenAm: status === 'widerrufen' ? '2026-10-08T08:00:00.000Z' : null })
const r = await widerrufeAuftrag(ab, { jetzt: JETZT })
assert.equal(r.ergebnis, ergebnis, status)
const doc = await getAuftrag('ab1')
assert.equal(doc.status, status, status)
assert.equal(doc.widerrufenAm === null ? null : ms(doc.widerrufenAm), status === 'widerrufen' ? new Date('2026-10-08T08:00:00.000Z').getTime() : null, status)
}
})
test('widerrufeAuftrag: wartet auf die Sperre sperreAuftrag(id) und liest den Stand erst darin (veraltetes Eingabeobjekt)', async () => {
const veraltet = seed()
let freigeben
const gehalten = new Promise((resolve) => {
freigeben = resolve
})
const halter = withLocks(sperreAuftrag('ab1'), () => gehalten)
let fertig = false
const widerruf = widerrufeAuftrag(veraltet, { jetzt: JETZT }).then((r) => {
fertig = true
return r
})
await new Promise((resolve) => setTimeout(resolve, 100))
assert.equal(fertig, false, 'wartet, solange ein anderer die Sperre der AB hält')
seed({ status: 'storniert' }) // währenddessen von Hand storniert (L3)
freigeben()
await halter
const r = await widerruf
assert.equal(r.ergebnis, 'nicht_widerrufbar', 'maßgeblich ist der Stand innerhalb der Sperre')
assert.equal((await getAuftrag('ab1')).status, 'storniert')
assert.equal(sink.sent.length, 0, 'widerrufeAuftrag verschickt keine Mails')
})
test('Bestätigen und Widerrufen gleichzeitig: der Widerruf geht nie verloren, in jeder Reihenfolge', async () => {
const ab = seed()
const [bestaetigt, widerrufen] = await Promise.all([
bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }),
widerrufeAuftrag(ab, { jetzt: JETZT }),
])
assert.equal(bestaetigt.status, 'bestaetigt')
assert.equal(widerrufen.ergebnis, 'widerrufen')
assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
seed()
const umgekehrt = await Promise.allSettled([
widerrufeAuftrag(ab, { jetzt: JETZT }),
bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }),
])
assert.equal(umgekehrt[0].value.ergebnis, 'widerrufen')
assert.ok(umgekehrt[1].reason instanceof AuftragError && umgekehrt[1].reason.status === 409)
assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
})
+44
View File
@@ -0,0 +1,44 @@
import { AUFTRAEGE_COLLECTIONS } from '../server/services/auftraege/schema.js'
import { hashToken } from '../server/services/auftraege/token.js'
// Testdaten der Strang-L5-Tests: gesendete AB, Token, Schema und Datumsfelder für den Appwrite-Nachbau.
// Importiert nie server/config.js: die Tests setzen die Umgebung erst danach (useTestEnv).
const attribute = AUFTRAEGE_COLLECTIONS.auftraege.attributes
export const AUFTRAEGE_SCHEMA = { auftraege: attribute.map((a) => a.key) }
export const AUFTRAEGE_DATETIMES = { auftraege: attribute.filter((a) => a.type === 'datetime').map((a) => a.key) }
export const TOKEN = 'T'.repeat(43)
export const TOKEN_NEU = 'U'.repeat(43)
export const SHA = 'a'.repeat(64)
export const UNGUELTIG = 'Dieser Link ist ungültig oder abgelaufen.'
export const kunde = (kundenart = 'privat', extra = {}) => ({
name: 'Erika Muster',
firma: kundenart === 'unternehmen' ? 'Muster GmbH' : '',
strasse: 'Hauptstr. 1',
plz: '67722',
ort: 'Winnweiler',
land: 'DE',
email: 'k@example.invalid',
kundenart,
kundennummer: 'K-100',
...extra,
})
/** Gesendete Festpreis-AB AB-2026-0001 des Kunden c1 (Form „Auftrag“ aus V3), gesendet am 06.10.2026 (Berlin 12:00), Link bis 05.12.2026. */
export const abDokument = (over = {}) => ({
id: 'ab1', nummer: 'AB-2026-0001', jahr: 2026, laufnummer: 1, status: 'versendet',
kundenId: 'c1', kunde: kunde(), projektId: 'p1', titel: 'Website für Musterbäckerei', zeitquelle: [],
art: 'festpreis', positionen: [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 80000 }],
stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' }, zahlungszielTage: 14,
auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '', fertigBis: '',
ersetztId: '', ersetztDurchId: '', texteFassung: '2026-10-06', agbFassung: '2026-09-30',
pdfDatei: 'c1/ab1.pdf', pdfSha256: SHA,
gesendetAm: '2026-10-06T10:00:00.000Z', gesendetAn: 'k@example.invalid', mailStatus: 'gesendet', mailFehler: '',
tokenHash: hashToken(TOKEN), tokenGueltigBis: '2026-12-05T10:00:00.000Z',
bestaetigtAm: null, bestaetigtUeber: '', bestaetigtPdfSha256: '', fruehbeginn: false, fruehbeginnFassung: '',
storniertAm: null, widerrufenAm: null,
erstelltVon: 'kenso@webklar.com', erstelltAm: '2026-10-06T09:00:00.000Z', geaendertAm: '2026-10-06T10:00:00.000Z',
...over,
})
+119
View File
@@ -0,0 +1,119 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import {
starteUmgebung, KUNDE_ID, PROJEKT_ID, TICKET_ID, ZWEITES_TICKET, LEAD_KUNDE, FREMD, VON, MELDUNG,
festpreisEingabe, aufwandEingabe, abgelehnt,
} from './helpers/auftraegeAb.js'
const umg = await starteUmgebung()
const { aw, config } = umg
after(() => umg.schliesse())
beforeEach(umg.zuruecksetzen)
const { getDocument } = await import('../server/services/appwriteAdmin.js')
const { getAuftrag, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { legeEntwurfAn, aendereEntwurf, loescheEntwurf } = await import('../server/services/auftraege/auftraege.js')
const kundenartVon = async (id) => (await getDocument(config.collections.customers, id)).kundenart || ''
const FREMDES_PROJEKT = MELDUNG.projektFremd
const FREMDES_TICKET = 'Zeitquelle: Mindestens ein Ticket gehört nicht zu diesem Kunden.'
test('legeEntwurfAn: Festpreis-Entwurf mit Titel und Zeitquelle aus dem Projekt, Summe und Ersteller', async () => {
const a = await legeEntwurfAn(festpreisEingabe(), { von: VON })
assert.deepEqual([a.status, a.nummer, a.kundenId, a.projektId, a.titel], ['entwurf', '', KUNDE_ID, PROJEKT_ID, 'Website Beispiel'])
assert.deepEqual(a.zeitquelle, [TICKET_ID])
assert.deepEqual([a.art, a.summeCents, a.erstelltVon], ['festpreis', 80000, VON])
assert.deepEqual(a.zahlungsplan, { art: 'anzahlung', prozent: 50, text: '' })
assert.deepEqual(await getAuftrag(a.id), a)
})
test('legeEntwurfAn: ein eigener Titel gilt; ohne Projekt braucht es einen Titel', async () => {
assert.equal((await legeEntwurfAn(festpreisEingabe({ titel: ' Relaunch Phase 1 ' }), { von: VON })).titel, 'Relaunch Phase 1')
await abgelehnt(legeEntwurfAn(festpreisEingabe({ projektId: '' }), { von: VON }), 400, 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.')
const frei = await legeEntwurfAn(festpreisEingabe({ projektId: '', titel: 'Logo-Entwurf' }), { von: VON })
assert.deepEqual([frei.projektId, frei.zeitquelle], ['', []])
})
test('legeEntwurfAn: Aufwand-Entwurf ohne Positionen und Summe, mit Stundensatz, Schätzung und gewählten Tickets', async () => {
const a = await legeEntwurfAn(aufwandEingabe({ zeitquelle: [TICKET_ID, ZWEITES_TICKET] }), { von: VON })
assert.deepEqual([a.art, a.positionen, a.summeCents, a.stundensatzCents, a.schaetzungMinuten], ['aufwand', [], null, 10000, 600])
assert.deepEqual(a.zeitquelle, [TICKET_ID, ZWEITES_TICKET])
assert.deepEqual(a.zahlungsplan, { art: 'monatlich', prozent: null, text: '' })
})
test('legeEntwurfAn: Entwürfe gibt es auch für Interessenten (nur das Senden ist festen Kunden vorbehalten)', async () => {
const a = await legeEntwurfAn(festpreisEingabe({ kundenId: LEAD_KUNDE, projektId: '', titel: 'Angebotsentwurf' }), { von: VON })
assert.deepEqual([a.kundenId, a.status], [LEAD_KUNDE, 'entwurf'])
})
test('kundenart aus der Eingabe wird am Kunden gespeichert – aber nur, wenn dort noch keine steht', async () => {
await legeEntwurfAn(festpreisEingabe({ kundenId: FREMD.kunde, projektId: FREMD.projekt, kundenart: 'privat' }), { von: VON })
assert.equal(await kundenartVon(FREMD.kunde), 'privat')
await legeEntwurfAn(festpreisEingabe({ kundenart: 'privat' }), { von: VON }) // der Kunde ist schon 'unternehmen'
assert.equal(await kundenartVon(KUNDE_ID), 'unternehmen')
await legeEntwurfAn(festpreisEingabe({ kundenId: LEAD_KUNDE, projektId: '', titel: 'x', kundenart: '' }), { von: VON })
assert.equal(await kundenartVon(LEAD_KUNDE), '')
})
test('fremde Bezüge werden abgelehnt, ohne dass etwas gespeichert wird (auch nicht die Kundenart)', async () => {
const eingabe = (patch) => festpreisEingabe({ kundenId: FREMD.kunde, projektId: FREMD.projekt, kundenart: 'privat', ...patch })
const faelle = [
['Projekt eines anderen Kunden', { projektId: PROJEKT_ID }, FREMDES_PROJEKT],
['unbekanntes Projekt', { projektId: 'p_gibtsnicht' }, FREMDES_PROJEKT],
['Ticket eines anderen Kunden', { zeitquelle: [TICKET_ID] }, FREMDES_TICKET],
['ein Ticket unbekannt', { zeitquelle: [FREMD.ticket, 't_gibtsnicht'] }, FREMDES_TICKET],
]
for (const [name, patch, meldung] of faelle) await abgelehnt(legeEntwurfAn(eingabe(patch), { von: VON }), 400, meldung, name)
assert.equal(aw.docs(config.collections.auftraege).size, 0)
assert.equal(await kundenartVon(FREMD.kunde), '')
})
test('Kunde: ohne Kennung 400, unbekannt 404, Kennung mit Sonderzeichen 404', async () => {
await abgelehnt(legeEntwurfAn(festpreisEingabe({ kundenId: undefined }), { von: VON }), 400, 'Kunde: Bitte einen Kunden angeben.')
await abgelehnt(legeEntwurfAn(festpreisEingabe({ kundenId: 'c_gibtsnicht' }), { von: VON }), 404, MELDUNG.kundeFehlt)
await abgelehnt(legeEntwurfAn(festpreisEingabe({ kundenId: '../users' }), { von: VON }), 404, MELDUNG.kundeFehlt)
})
test('status, nummer, Token und Ersteller aus der Eingabe werden ignoriert', async () => {
const a = await legeEntwurfAn(
{ ...festpreisEingabe(), status: 'bestaetigt', nummer: 'AB-2026-0001', jahr: 2026, tokenHash: 'abc', ersetztId: 'a_alt', erstelltVon: 'boese@example.invalid', pdfDatei: 'x/y.pdf' },
{ von: VON },
)
assert.deepEqual([a.status, a.nummer, a.jahr, a.erstelltVon], ['entwurf', '', null, VON])
assert.ok(!a.tokenHash && !a.ersetztId && !a.pdfDatei)
})
test('aendereEntwurf: nur die genannten Felder ändern sich, der Rest bleibt', async () => {
const a = await legeEntwurfAn(festpreisEingabe(), { von: VON })
const b = await aendereEntwurf(a.id, { titel: 'Neuer Titel', zahlungszielTage: 30 })
assert.deepEqual([b.titel, b.zahlungszielTage, b.summeCents], ['Neuer Titel', 30, 80000])
assert.deepEqual([b.positionen, b.zahlungsplan, b.zeitquelle], [a.positionen, a.zahlungsplan, a.zeitquelle])
assert.deepEqual(await getAuftrag(a.id), b)
})
test('aendereEntwurf: Wechsel der Art prüft das Ganze (Anzahlung geht nicht bei Aufwand), mit passendem Plan klappt er', async () => {
const a = await legeEntwurfAn(festpreisEingabe(), { von: VON })
await abgelehnt(aendereEntwurf(a.id, { art: 'aufwand' }), 400, 'Zahlungsplan: Eine Anzahlung geht nur bei einem Festpreis.')
assert.equal((await getAuftrag(a.id)).art, 'festpreis')
const b = await aendereEntwurf(a.id, { art: 'aufwand', stundensatzCents: 12000, zahlungsplan: { art: 'monatlich' } })
assert.deepEqual([b.art, b.positionen, b.summeCents, b.stundensatzCents, b.schaetzungMinuten], ['aufwand', [], null, 12000, null])
})
test('aendereEntwurf: Kunde lässt sich nicht wechseln, fremdes Projekt wird abgelehnt, unbekannte Aufträge sind 404', async () => {
const a = await legeEntwurfAn(festpreisEingabe(), { von: VON })
await abgelehnt(aendereEntwurf(a.id, { kundenId: FREMD.kunde }), 400, 'Kunde: Der Kunde eines Auftrags lässt sich nicht ändern.')
assert.equal((await aendereEntwurf(a.id, { kundenId: KUNDE_ID, titel: 'Gleicher Kunde' })).titel, 'Gleicher Kunde')
await abgelehnt(aendereEntwurf(a.id, { projektId: FREMD.projekt }), 400, FREMDES_PROJEKT)
await abgelehnt(aendereEntwurf('a_gibtsnicht', { titel: 'x' }), 404, MELDUNG.auftragFehlt)
await abgelehnt(aendereEntwurf('../x', { titel: 'x' }), 404, MELDUNG.auftragFehlt)
})
test('Ändern und Löschen gibt es nur für Entwürfe (409); Löschen entfernt den Entwurf', async () => {
const a = await legeEntwurfAn(festpreisEingabe(), { von: VON })
await updateAuftrag(a.id, { status: 'versendet', nummer: 'AB-2026-0001', jahr: 2026, laufnummer: 1 })
await abgelehnt(aendereEntwurf(a.id, { titel: 'Nachträglich' }), 409, MELDUNG.nurEntwurfAendern)
await abgelehnt(loescheEntwurf(a.id), 409, MELDUNG.nurEntwurfLoeschen)
assert.equal((await getAuftrag(a.id)).titel, 'Website Beispiel')
const b = await legeEntwurfAn(festpreisEingabe(), { von: VON })
await loescheEntwurf(b.id)
assert.equal(await getAuftrag(b.id), null)
await abgelehnt(loescheEntwurf(b.id), 404, MELDUNG.auftragFehlt)
})
+52
View File
@@ -0,0 +1,52 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
const aw = await startAppwriteMock({ schema: {} })
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { getStripe } = await import('../server/services/stripeBilling.js')
after(() => Promise.all([aw.close(), st.close()]))
const refused = (promise, pattern) => assert.rejects(promise, (err) => err.statusCode === 400 && pattern.test(err.message))
const create = (extra = {}) => getStripe().invoices.create({ customer: 'cus_x', collection_method: 'send_invoice', days_until_due: 14, ...extra })
const item = (invoice, extra) => getStripe().invoiceItems.create({ customer: 'cus_x', invoice, description: 'Posten', currency: 'eur', ...extra })
test('Stripe-Nachbau: custom_fields und rendering werden gespeichert und mit der Rechnung geliefert', async () => {
const felder = [{ name: 'Projekt', value: 'Website anelia-wolf.com' }, { name: 'Auftrag', value: 'AB-2026-0002 vom 06.10.2026' }]
const invoice = await create({ custom_fields: felder, rendering: { pdf: { page_size: 'a4' } } })
assert.deepEqual(st.invoices.get(invoice.id).custom_fields, felder)
assert.deepEqual(st.invoices.get(invoice.id).rendering, { pdf: { page_size: 'a4' } })
const gelesen = await getStripe().invoices.retrieve(invoice.id)
assert.deepEqual(gelesen.custom_fields, felder)
assert.deepEqual(gelesen.rendering, { pdf: { page_size: 'a4' } })
await item(invoice.id, { quantity: 1, unit_amount_decimal: '1000' })
assert.deepEqual(st.invoices.get(invoice.id).custom_fields, felder, 'Posten ändern die Zusatzfelder nicht')
const schlicht = await create()
assert.equal(st.invoices.get(schlicht.id).custom_fields, null)
assert.equal(st.invoices.get(schlicht.id).rendering, null)
})
test('Stripe-Nachbau: Grenzen der Zusatzfelder (4, Name 40, Wert 140) und der Seitengröße wie bei Stripe', async () => {
const viele = Array.from({ length: 5 }, (_, i) => ({ name: `Feld ${i}`, value: 'x' }))
await refused(create({ custom_fields: viele }), /up to 4 custom fields/)
await refused(create({ custom_fields: [{ name: 'n'.repeat(41), value: 'x' }] }), /names must be between 1 and 40/)
await refused(create({ custom_fields: [{ name: 'n', value: 'v'.repeat(141) }] }), /values must be between 1 and 140/)
await refused(create({ rendering: { pdf: { page_size: 'din-a4' } } }), /page_size/)
const vier = await create({ custom_fields: viele.slice(0, 4).map((f) => ({ ...f, value: 'v'.repeat(140) })), rendering: { pdf: { page_size: 'letter' } } })
assert.equal(st.invoices.get(vier.id).custom_fields.length, 4)
})
test('Stripe-Nachbau: Posten mit negativem amount (ohne quantity) mindern die Summe; amount mit quantity oder Einzelpreis wird abgelehnt', async () => {
const invoice = await create()
await item(invoice.id, { description: 'Leistung', quantity: 1, unit_amount_decimal: '40000' })
await item(invoice.id, { description: 'abzüglich Anzahlung', amount: -15000 })
const gespeichert = st.invoices.get(invoice.id)
assert.deepEqual(gespeichert.lines.data.map((l) => [l.description, l.quantity, l.amount]), [['Leistung', 1, 40000], ['abzüglich Anzahlung', 1, -15000]])
assert.deepEqual([gespeichert.total, gespeichert.amount_due], [25000, 25000])
await refused(item(invoice.id, { amount: -100, quantity: 2 }), /only specify one of these parameters/)
await refused(item(invoice.id, { amount: -100, unit_amount_decimal: '100' }), /only specify one of these parameters/)
assert.equal(st.invoices.get(invoice.id).total, 25000)
})
+92
View File
@@ -0,0 +1,92 @@
// test/auftraegeGeruest.test.js
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
// Gerüste der Router (L0): geprüft wird nur, was auch nach dem Füllen durch L1, L3 und L5 gilt: Zugangsschutz, unbekannte
// Tiefenpfade (…/a/b/c/d) und die Reihenfolge der Einbindung im Quelltext.
const aw = await startAppwriteMock()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { startTestApp, rawGet } = await import('./support/testApp.js')
const { seedFixtures, customerCookie } = await import('./support/fixtures.js')
const { default: projektkonten } = await import('../server/routes/admin/projektkonten.js')
const { default: auftraegeAdmin } = await import('../server/routes/admin/auftraege.js')
const { default: auftragOeffentlich } = await import('../server/routes/auftrag.js')
const { default: auftraegeKunde } = await import('../server/routes/auftraegeKunde.js')
const { default: teamRoutes } = await import('../server/routes/team/index.js')
const app = await startTestApp({
'/api/admin/finanzen/projektkonten': projektkonten,
'/api/admin/finanzen/auftraege': auftraegeAdmin,
'/api/team': teamRoutes,
'/api/auftrag': auftragOeffentlich,
'/api/auftraege': auftraegeKunde,
})
after(() => Promise.all([app.close(), aw.close()]))
seedFixtures(aw)
const bearer = (jwt) => ({ headers: { authorization: `Bearer ${jwt}` } })
const quelle = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
test('Admin-Router: ohne JWT 401, Mitarbeiter ohne Admin-Label und Kunden 403, Admin kommt durch', async () => {
for (const pfad of ['/api/admin/finanzen/projektkonten/a/b/c/d', '/api/admin/finanzen/auftraege/a/b/c/d']) {
assert.equal((await rawGet(pfad)).status, 401, pfad)
assert.equal((await rawGet(pfad, bearer('jwt-staff'))).status, 403, `${pfad} jwt-staff`)
assert.equal((await rawGet(pfad, bearer('jwt-kunde'))).status, 403, `${pfad} jwt-kunde`)
assert.equal((await rawGet(pfad, bearer('jwt-admin'))).status, 404, `${pfad} jwt-admin: unbekannter Pfad`)
}
})
test('Team-Router: /arbeitsblaetter liegt hinter requireStaffJwt (jeder Mitarbeiter darf, Kunden nicht)', async () => {
const pfad = '/api/team/arbeitsblaetter/a/b/c/d'
assert.equal((await rawGet(pfad)).status, 401)
assert.equal((await rawGet(pfad, bearer('jwt-kunde'))).status, 403)
assert.equal((await rawGet(pfad, bearer('jwt-staff'))).status, 404, 'Mitarbeiter ohne Admin-Label kommen durch')
assert.equal((await rawGet('/api/team/arbeitsblaetter/abgerechnet?ticketId=t1')).status, 401)
})
test('Kunden-Router verlangt eine Anmeldung, der öffentliche Router nicht', async () => {
const ohne = await rawGet('/api/auftraege/a/b/c/d')
assert.equal(ohne.status, 401)
assert.deepEqual(ohne.body, { error: 'Nicht angemeldet' })
assert.equal((await rawGet('/api/auftraege/a/b/c/d', { cookie: customerCookie('c1') })).status, 404)
for (const pfad of ['/api/auftrag/a/b/c/d', '/api/auftrag/a/b/c/d/pdf']) assert.equal((await rawGet(pfad)).status, 404, pfad)
})
test('server/index.js: Admin-Router vor dem Finanzen-Router, Kunden- und öffentlicher Router hinter Wächter und Parser', () => {
const index = quelle('server/index.js')
const stelle = (zeile) => {
const pos = index.indexOf(zeile)
assert.ok(pos >= 0, `${zeile} fehlt`)
assert.equal(index.indexOf(zeile, pos + 1), -1, `${zeile} steht mehrfach`)
return pos
}
const projekt = stelle("app.use('/api/admin/finanzen/projektkonten', adminProjektkontenRoutes)")
const auftraege = stelle("app.use('/api/admin/finanzen/auftraege', adminAuftraegeRoutes)")
const finanzen = stelle("app.use('/api/admin/finanzen', adminFinanzenRoutes)")
assert.ok(projekt < finanzen && auftraege < finanzen, 'sonst prüft der Finanzen-Router zuerst')
const waechter = stelle('app.use(kundenansichtGuard)')
const parser = stelle("app.use(express.json({ limit: '2mb'")
const oeffentlich = stelle("app.use('/api/auftrag', publicAuftragRoutes)")
const kunde = stelle("app.use('/api/auftraege', kundeAuftraegeRoutes)")
assert.ok(waechter < oeffentlich && waechter < kunde && parser < oeffentlich && parser < kunde)
for (const [name, datei] of [
['adminProjektkontenRoutes', 'admin/projektkonten'],
['adminAuftraegeRoutes', 'admin/auftraege'],
['publicAuftragRoutes', 'auftrag'],
['kundeAuftraegeRoutes', 'auftraegeKunde'],
]) {
assert.ok(index.includes(`import ${name} from './routes/${datei}.js'`), name)
}
})
test('server/routes/team/index.js: /arbeitsblaetter hängt hinter requireStaffJwt', () => {
const team = quelle('server/routes/team/index.js')
const jwt = team.indexOf('router.use(requireStaffJwt)')
const mount = team.indexOf("router.use('/arbeitsblaetter', arbeitsblaetterTeamRoutes)")
assert.ok(jwt >= 0 && mount > jwt)
assert.equal(team.indexOf("router.use('/arbeitsblaetter'", mount + 1), -1)
assert.ok(team.includes("import arbeitsblaetterTeamRoutes from './arbeitsblaetter.js'"))
})
+85
View File
@@ -0,0 +1,85 @@
// test/auftraegeKonstanten.test.js
import { test, mock } from 'node:test'
import assert from 'node:assert/strict'
import * as K from '../server/services/auftraege/konstanten.js'
import { AuftragError, sendeFehler } from '../server/services/auftraege/fehler.js'
import { InvoiceError } from '../server/services/finance/stripeFinance.js'
test('Konstanten stehen wie in Übersicht V2', () => {
assert.deepEqual(K.RECHNUNGS_ARTEN, ['frei', 'festbetrag', 'aufwand', 'anzahlung', 'schluss'])
assert.deepEqual(K.AB_RECHNUNGS_ARTEN, ['anzahlung', 'schluss'])
assert.deepEqual(K.AUFTRAG_ARTEN, ['festpreis', 'aufwand'])
assert.deepEqual(K.AUFTRAG_STATUS, ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen'])
assert.deepEqual(K.VEREINBARTE_STATUS, ['versendet', 'bestaetigt', 'abgerechnet'])
assert.deepEqual(K.AUFTRAG_WEGE, { muendlich: 'mündlich', telefon: 'telefonisch', email: 'per E-Mail', vor_ort: 'vor Ort', chat: 'im Chat' })
assert.deepEqual(K.ZAHLUNGSPLAN_ARTEN, ['nach_abnahme', 'anzahlung', 'monatlich', 'frei'])
assert.deepEqual(K.KUNDENARTEN, ['unternehmen', 'privat'])
assert.equal(K.STUNDENSATZ_STANDARD_CENTS, 10000)
assert.equal(K.VIERTELSTUNDE, 15)
assert.equal(K.TOKEN_GUELTIG_TAGE, 60)
assert.match(K.AGB_FASSUNG, /^\d{4}-\d{2}-\d{2}$/) // den Wert legt L7 fest
assert.deepEqual(K.LIMITS, {
maxPositionen: 20, maxBeschreibung: 200, maxMenge: 999, maxEinzelpreisCents: 10_000_000,
maxSummeCents: 99_999_999, maxTitel: 200, maxZahlungsplanText: 500, maxZahlungszielTage: 60,
standardZahlungszielTage: 14, standardAnzahlungProzent: 50, maxZeitquellen: 10,
maxSchaetzungMinuten: 99_960, maxLeistungszeitraum: 140,
})
// withLocks sperrt jedes Listenelement einzeln: je Sperre genau ein zusammengesetzter Schlüssel (verschiedene Kunden und ABs blockieren sich nicht)
assert.deepEqual(K.SPERRE_NUMMER, ['auftraege-nummer'])
assert.deepEqual(K.sperreRechnung('c1'), ['auftraege-rechnung:c1'])
assert.deepEqual(K.sperreAuftrag('ab1'), ['auftraege-ab:ab1'])
assert.equal(K.SCHALTER_AUS_MELDUNG, 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.')
for (const status of K.VEREINBARTE_STATUS) assert.ok(K.AUFTRAG_STATUS.includes(status), status)
for (const art of K.AB_RECHNUNGS_ARTEN) assert.ok(K.RECHNUNGS_ARTEN.includes(art), art)
})
const fakeRes = () => ({
statusCode: 200,
body: undefined,
status(code) {
this.statusCode = code
return this
},
json(body) {
this.body = body
return this
},
})
test('AuftragError trägt Status und Meldung; ein Status außerhalb 400 bis 599 ist ein Programmfehler', () => {
const err = new AuftragError(409, 'Arbeitsblatt bereits abgerechnet (WEBKLAR-0009).')
assert.ok(err instanceof Error)
assert.equal(err.name, 'AuftragError')
assert.equal(err.status, 409)
assert.equal(err.message, 'Arbeitsblatt bereits abgerechnet (WEBKLAR-0009).')
for (const status of [200, 399, 600, 404.5, '404', undefined, null]) {
assert.throws(() => new AuftragError(status, 'x'), TypeError, String(status))
}
})
test('sendeFehler gibt AuftragError und InvoiceError mit Status und Meldung weiter', () => {
const a = fakeRes()
sendeFehler(a, 'AB senden', new AuftragError(400, 'Kundenart fehlt.'))
assert.deepEqual([a.statusCode, a.body], [400, { error: 'Kundenart fehlt.' }])
const b = fakeRes()
sendeFehler(b, 'Rechnung anlegen', new InvoiceError(502, 'Stripe ist gerade nicht erreichbar.'))
assert.deepEqual([b.statusCode, b.body], [502, { error: 'Stripe ist gerade nicht erreichbar.' }])
})
test('sendeFehler: alles andere wird 500 „Interner Fehler.“, das Protokoll nennt nie die Meldung', () => {
const log = mock.method(console, 'error', () => {})
try {
const appwrite = Object.assign(new Error('Appwrite: Key sk_live_geheim abgelehnt'), { status: 400, type: 'user_unauthorized' })
for (const err of [appwrite, 'sk_live_geheim', undefined, null]) {
const res = fakeRes()
sendeFehler(res, 'Projektkonto laden', err)
assert.deepEqual([res.statusCode, res.body], [500, { error: 'Interner Fehler.' }])
}
assert.equal(log.mock.calls.length, 4)
const zeilen = JSON.stringify(log.mock.calls.map((call) => call.arguments))
assert.ok(!zeilen.includes('geheim'), zeilen)
assert.ok(zeilen.includes('Projektkonto laden') && zeilen.includes('user_unauthorized'), zeilen)
} finally {
log.mock.restore()
}
})
+147
View File
@@ -0,0 +1,147 @@
// Schlusskontrolle (Z3.4): Korrekturen an Projektrechnungen zu Auftragsbestaetigungen
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
const aw = await startAppwriteMock({
schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') },
datetimes: AUFTRAEGE_DATETIMES,
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { seedRechnungsDaten, legeAuftragAn, legeRechnungAn, leereRechnungsDaten } = await import('./auftraegeRechnungenHilfe.js')
const { projektkontoDetail } = await import('../server/services/auftraege/projektkonto.js')
const { createProjectInvoice, rechnungsVorschau, sendInvoiceNow, voidInvoice, deleteDraft, invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js')
const { getAuftrag, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { umhaengen } = await import('../server/services/auftraege/projektRechnungenStore.js')
after(() => Promise.all([aw.close(), st.close()]))
seedRechnungsDaten(aw)
config.auftraege.mode = 'on'
beforeEach(async () => {
await leereRechnungsDaten(aw, st)
invalidateFinanceCache()
resetInvoiceCreateGuardForTests()
})
const letzterEntwurf = () => st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/invoices').at(-1)
let zaehler = 0
const anzahlung = (ab, prozent, extra = {}) =>
createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, projektId: 'p1', anzahlung: { prozent, bezeichnung: `Anzahlung ${++zaehler}` }, ...extra })
const schluss = (ab, extra = {}) => createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, projektId: 'p1', ...extra })
test('Zahlungsziel: Anzahlung und Schluss ohne eigene Angabe nehmen das Zahlungsziel der AB, eine Angabe gilt vor der AB', async () => {
const ab = await legeAuftragAn({ zahlungszielTage: 30 })
await anzahlung(ab, 50, { senden: true })
assert.equal(letzterEntwurf().body.days_until_due, '30')
await schluss(ab)
assert.equal(letzterEntwurf().body.days_until_due, '30')
const ab2 = await legeAuftragAn({ zahlungszielTage: 30, projektId: 'p3' }, { nummer: 'AB-2026-0003', laufnummer: 3 })
await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab2.id, projektId: 'p3', anzahlung: { prozent: 50 }, daysUntilDue: 7 })
assert.equal(letzterEntwurf().body.days_until_due, '7')
})
test('Zahlungsziel: ohne AB-Bezug (frei, festbetrag) bleibt es bei 14 Tagen', async () => {
await legeAuftragAn({ zahlungszielTage: 30 })
await createProjectInvoice({ customerId: 'k1', items: [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }] })
assert.equal(letzterEntwurf().body.days_until_due, '14')
})
const ENTWURF_ID = /^in_/
const finalisiert = (id) => st.calls.some((c) => c.method === 'POST' && c.path === `/v1/invoices/${id}/finalize`)
test('Senden: zwei Anzahlungs-Entwürfe à 60 % einer 800-€-AB – nur der erste geht, der zweite 409 und nichts finalisiert', async () => {
const ab = await legeAuftragAn()
const a = await anzahlung(ab, 60)
const b = await anzahlung(ab, 60)
assert.match(a.id, ENTWURF_ID)
await sendInvoiceNow(a.id)
st.calls.length = 0
await assert.rejects(sendInvoiceNow(b.id), (err) => err.status === 409 && /übersteig|Auftragssumme/.test(err.message))
assert.equal(finalisiert(b.id), false)
assert.equal(st.invoices.get(b.id).status, 'draft')
})
test('Senden: parallel gesendet geht von zwei 60-%-Entwürfen genau einer', async () => {
const ab = await legeAuftragAn()
const a = await anzahlung(ab, 60)
const b = await anzahlung(ab, 60)
const ergebnisse = await Promise.allSettled([sendInvoiceNow(a.id), sendInvoiceNow(b.id)])
assert.deepEqual(ergebnisse.map((e) => e.status).sort(), ['fulfilled', 'rejected'])
assert.equal([a.id, b.id].filter((id) => st.invoices.get(id).status !== 'draft').length, 1)
})
test('Senden: ein Anzahlungs-Entwurf zu einer stornierten, widerrufenen oder ersetzten AB wird nicht gesendet', async () => {
for (const status of ['storniert', 'widerrufen', 'ersetzt']) {
const ab = await legeAuftragAn({}, { nummer: `AB-2026-${status}` })
const entwurf = await anzahlung(ab, 30, { projektId: 'p1', anzahlung: { prozent: 30, bezeichnung: `A ${status}` } })
await updateAuftrag(ab.id, { status })
await assert.rejects(sendInvoiceNow(entwurf.id), (err) => err.status === 409 && /nicht mehr gültig/.test(err.message), status)
assert.equal(finalisiert(entwurf.id), false, status)
await deleteDraft(entwurf.id)
await updateAuftrag(ab.id, { status: 'storniert' })
}
})
test('Senden: ein Schluss-Entwurf wird nicht gesendet, wenn zur AB schon eine andere Schlussrechnung festgeschrieben ist', async () => {
const ab = await legeAuftragAn()
const s = await schluss(ab)
await legeRechnungAn(st, 'in_fremd1', { status: 'open', total: 80000, art: 'schluss', auftragId: ab.id })
await assert.rejects(sendInvoiceNow(s.id), (err) => err.status === 409)
assert.equal(finalisiert(s.id), false)
})
test('Senden: ein gültiger Anzahlungs- oder Schluss-Entwurf geht wie bisher', async () => {
const ab = await legeAuftragAn()
const a = await anzahlung(ab, 50)
assert.equal((await sendInvoiceNow(a.id)).status, 'offen')
const s = await schluss(ab)
assert.equal((await sendInvoiceNow(s.id)).status, 'offen')
assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet')
})
test('Storno: eine Anzahlung wird nicht storniert, solange zur AB ein Schluss-Entwurf besteht (409); nach dem Löschen des Entwurfs geht es', async () => {
const ab = await legeAuftragAn()
const a = await anzahlung(ab, 50, { senden: true })
const s = await schluss(ab)
await assert.rejects(voidInvoice(a.id), (err) => err.status === 409 && err.message === 'Erst den Entwurf der Schlussrechnung löschen.')
assert.equal(st.invoices.get(a.id).status, 'open')
await deleteDraft(s.id)
assert.equal((await voidInvoice(a.id)).status, 'storniert')
})
test('Storno: eine Anzahlung ohne Schluss-Entwurf lässt sich stornieren', async () => {
const ab = await legeAuftragAn()
const a = await anzahlung(ab, 50, { senden: true })
assert.equal((await voidInvoice(a.id)).status, 'storniert')
})
test('Finalisieren einer Schlussrechnung setzt die AB aus dem Projektdatensatz auf abgerechnet (nach Umhängen die neue AB)', async () => {
const ab = await legeAuftragAn()
const s = await schluss(ab)
// wie beim Senden einer neuen Fassung: neue AB versendet, alte ersetzt, Projektrechnungen umgehängt
const neu = await legeAuftragAn({ ersetztId: ab.id }, { nummer: 'AB-2026-0003', laufnummer: 3 })
await updateAuftrag(ab.id, { status: 'ersetzt', ersetztDurchId: neu.id })
await umhaengen(ab.id, neu.id)
await sendInvoiceNow(s.id)
assert.equal((await getAuftrag(neu.id)).status, 'abgerechnet')
assert.equal((await getAuftrag(ab.id)).status, 'ersetzt')
})
test('Stundensatz: bei zwei gültigen Aufwands-ABs rechnen Rechnung und Projektkonto mit derselben (jüngste nach gesendetAm)', async () => {
const basis = { art: 'aufwand', positionen: [], summeCents: null, zahlungsplan: { art: 'monatlich', prozent: null, text: '' } }
// A zuerst angelegt, aber später gesendet; B danach angelegt, aber früher gesendet
await legeAuftragAn({ ...basis, stundensatzCents: 8000 }, { nummer: 'AB-2026-0010', gesendetAm: '2026-10-09T08:00:00.000Z' })
await new Promise((resolve) => setTimeout(resolve, 15))
await legeAuftragAn({ ...basis, stundensatzCents: 12000 }, { nummer: 'AB-2026-0011', gesendetAm: '2026-10-06T08:00:00.000Z' })
const vorschau = await rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] })
const konto = await projektkontoDetail('p1')
assert.equal(konto.konto.zeit.satzCents, 8000)
assert.match(vorschau.positionen[0].beschreibung, /à 80,00\s€/)
})
+143
View File
@@ -0,0 +1,143 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'
// Portal-Routen /api/auftraege (V5): nur eigene, gesendete ABs; Schalter; Bestätigen mit Portal-Sitzung
process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-kunde-'))
process.env.AUFTRAEGE_ENABLED = 'on'
const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { startTestApp, send } = await import('./support/testApp.js')
const { seedFixtures, customerCookie, adminCookie } = await import('./support/fixtures.js')
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { speicherePdf } = await import('../server/services/auftraege/ablage.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { default: kundeRoutes } = await import('../server/routes/auftraegeKunde.js')
const app = await startTestApp({ '/api/auftraege': kundeRoutes })
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close()])
})
seedFixtures(aw)
const PDF = '%PDF-1.4 Kunden-AB'
for (const [kundenId, id] of [['c1', 'ab1'], ['c2', 'ab3'], ['c1', 'ab4']]) await speicherePdf(kundenId, id, Buffer.from(PDF))
const NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.'
const ENTWURF = { id: 'ab2', nummer: '', jahr: null, laufnummer: null, status: 'entwurf', gesendetAm: null, gesendetAn: '', tokenHash: '', tokenGueltigBis: null, pdfDatei: '', pdfSha256: '' }
const AB4 = { id: 'ab4', nummer: 'AB-2026-0003', status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', pdfDatei: 'c1/ab4.pdf', tokenHash: '' }
const AB3 = { id: 'ab3', nummer: 'AB-2026-0002', kundenId: 'c2', pdfDatei: 'c2/ab3.pdf', kunde: kunde('privat', { email: 'k2@example.invalid' }), tokenHash: '' }
let sink
const seed = (over = {}) => aw.seed('auftraege', { $id: over.id || 'ab1', ...auftragToDoc(abDokument(over)) })
beforeEach(() => {
config.auftraege.mode = 'on'
config.auftraege.pilotEmails = []
sink = mailSink()
setMailTransportForTests(sink)
for (const over of [{}, ENTWURF, AB3, AB4]) seed(over)
})
const cookie = customerCookie('c1')
const get = (pfad, opts = { cookie }) => send('GET', `/api/auftraege${pfad}`, opts)
const post = (pfad, body, opts = { cookie }) => send('POST', `/api/auftraege${pfad}`, { ...opts, body })
test('GET /api/auftraege: nur eigene, nur gesendete ABs, mit id und kannBestaetigen, ohne interne Felder', async () => {
const res = await get('')
assert.equal(res.status, 200)
assert.equal(res.body.aktiv, true)
assert.deepEqual(res.body.auftraege.map((a) => a.id).sort(), ['ab1', 'ab4'])
const ab1 = res.body.auftraege.find((a) => a.id === 'ab1')
const ab4 = res.body.auftraege.find((a) => a.id === 'ab4')
assert.deepEqual([ab1.nummer, ab1.status, ab1.kannBestaetigen, ab4.status, ab4.kannBestaetigen], ['AB-2026-0001', 'versendet', true, 'bestaetigt', false])
for (const intern of ['kundenId', 'kunde', 'tokenHash', 'tokenGueltigBis', 'pdfDatei', 'pdfSha256', 'gesendetAn']) assert.ok(!(intern in ab1), intern)
assert.equal(res.headers.get('cache-control'), 'no-store')
})
test('kannBestaetigen wie in V3: versendet und abgerechnet ohne frühere Bestätigung ja, sonst nein', async () => {
const faelle = [
[{}, true],
[{ status: 'abgerechnet' }, true],
[{ status: 'abgerechnet', bestaetigtAm: '2026-10-07T08:30:00.000Z' }, false],
[{ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z' }, false],
[{ status: 'ersetzt' }, false],
[{ status: 'storniert' }, false],
[{ status: 'widerrufen' }, false],
]
for (const [over, erwartet] of faelle) {
seed(over)
const ab1 = (await get('')).body.auftraege.find((a) => a.id === 'ab1')
assert.ok(ab1, `${JSON.stringify(over)}: auch ersetzte, stornierte und widerrufene ABs stehen in der Liste`)
assert.equal(ab1.kannBestaetigen, erwartet, JSON.stringify(over))
}
})
test('ohne Anmeldung und mit alter Mitarbeiter-Sitzung: 401, nichts ändert sich', async () => {
for (const opts of [{}, { cookie: adminCookie() }]) {
assert.equal((await get('', opts)).status, 401)
assert.equal((await get('/ab1/pdf', opts)).status, 401)
assert.equal((await post('/ab1/bestaetigen', { bestaetigt: true }, opts)).status, 401)
}
assert.equal((await getAuftrag('ab1')).bestaetigtAm, null)
})
test('Schalter aus: leere Liste, PDF und Bestätigen wie bei unbekannter AB; im Pilot nur für Adressen der Liste', async () => {
config.auftraege.mode = 'off'
assert.deepEqual((await get('')).body, { aktiv: false, auftraege: [] })
for (const res of [await get('/ab1/pdf'), await post('/ab1/bestaetigen', { bestaetigt: true })]) assert.deepEqual([res.status, res.body], [404, { error: NICHT_GEFUNDEN }])
assert.equal((await getAuftrag('ab1')).bestaetigtAm, null)
config.auftraege.mode = 'pilot'
config.auftraege.pilotEmails = ['k@example.invalid']
assert.equal((await get('')).body.aktiv, true)
assert.deepEqual((await get('', { cookie: customerCookie('c2') })).body, { aktiv: false, auftraege: [] })
})
test('GET /api/auftraege/:id/pdf: eigene AB (auch ersetzt), nie fremde, Entwürfe, unbekannte oder ungültige IDs', async () => {
const eigene = await get('/ab1/pdf')
assert.deepEqual([eigene.status, eigene.headers.get('content-type'), eigene.body], [200, 'application/pdf', PDF])
assert.equal(eigene.headers.get('content-disposition'), 'inline; filename="AB-2026-0001.pdf"')
seed({ status: 'ersetzt' })
assert.equal((await get('/ab1/pdf')).status, 200)
for (const id of ['ab3', 'ab2', 'gibtsnicht', '..%2F..%2Fx', 'a'.repeat(40)]) {
const res = await get(`/${id}/pdf`)
assert.deepEqual([res.status, res.body], [404, { error: NICHT_GEFUNDEN }], id)
}
})
test('POST …/bestaetigen: bestätigt über das Portal (Protokoll, K32, T22); der zweite Aufruf ändert nichts', async () => {
const vorher = Date.now()
const res = await post('/ab1/bestaetigen', { bestaetigt: true, fruehbeginn: true })
assert.equal(res.status, 200)
assert.deepEqual([res.body.auftrag.id, res.body.auftrag.status, res.body.auftrag.kannBestaetigen], ['ab1', 'bestaetigt', false])
const doc = await getAuftrag('ab1')
assert.deepEqual([doc.bestaetigtUeber, doc.fruehbeginn], ['portal', true])
assert.ok(new Date(doc.bestaetigtAm).getTime() >= vorher - 1000 && new Date(doc.bestaetigtAm).getTime() <= Date.now() + 1000)
assert.equal(sink.sent.length, 2)
const zweite = await post('/ab1/bestaetigen', { bestaetigt: true, fruehbeginn: false })
assert.deepEqual([zweite.status, zweite.body], [200, res.body])
assert.equal(sink.sent.length, 2)
})
test('POST …/bestaetigen: ohne bestaetigt: true 400; fremde, Entwurf, unbekannte AB 404; ersetzt, storniert, widerrufen 410 – nie eine Änderung', async () => {
for (const body of [{}, { bestaetigt: 'ja' }, { bestaetigt: false }]) {
const res = await post('/ab1/bestaetigen', body)
assert.deepEqual([res.status, res.body], [400, { error: 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' }])
}
for (const id of ['ab3', 'ab2', 'gibtsnicht']) {
const res = await post(`/${id}/bestaetigen`, { bestaetigt: true })
assert.deepEqual([res.status, res.body], [404, { error: NICHT_GEFUNDEN }], id)
}
for (const [status, text] of [['ersetzt', 'ersetzt'], ['storniert', 'storniert'], ['widerrufen', 'widerrufen']]) {
seed({ status })
const res = await post('/ab1/bestaetigen', { bestaetigt: true })
assert.deepEqual([res.status, res.body], [410, { error: `Diese Auftragsbestätigung wurde ${text}.` }], status)
assert.equal((await getAuftrag('ab1')).bestaetigtAm, null, status)
}
assert.equal((await getAuftrag('ab3')).bestaetigtAm, null)
assert.equal(sink.sent.length, 0)
})
+89
View File
@@ -0,0 +1,89 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, TOKEN, abDokument } from './auftraegeBestaetigungHilfe.js'
// Kundenansicht (Mitarbeiter im Portal eines Kunden): Bestätigen ist eine Erklärung im Namen des Kunden und bleibt gesperrt
process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-kundenansicht-'))
process.env.AUFTRAEGE_ENABLED = 'on'
const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { startTestApp, send } = await import('./support/testApp.js')
const { seedFixtures, customerCookie, kundenansichtCookie } = await import('./support/fixtures.js')
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { speicherePdf } = await import('../server/services/auftraege/ablage.js')
const { kundenansichtGuard, KUNDENANSICHT_ALLOWED } = await import('../server/middleware/kundenansichtGuard.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { default: kundeRoutes } = await import('../server/routes/auftraegeKunde.js')
const { default: tokenRoutes } = await import('../server/routes/auftrag.js')
// Mount-Pfade und Reihenfolge wie in server/index.js: der Wächter steht vor den Routen
const app = await startTestApp({ '/': kundenansichtGuard, '/api/auftraege': kundeRoutes, '/api/auftrag': tokenRoutes })
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close()])
})
seedFixtures(aw)
await speicherePdf('c1', 'ab1', Buffer.from('%PDF-1.4 Test'))
const VIEW = 'a'.repeat(32)
const kv = () => ({ cookie: kundenansichtCookie({ customerId: 'c1', viewId: VIEW }), headers: { 'x-kundenansicht': VIEW } })
let sink
beforeEach(() => {
sink = mailSink()
setMailTransportForTests(sink)
aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument()) })
})
const unveraendert = async () => {
const doc = await getAuftrag('ab1')
assert.deepEqual([doc.status, doc.bestaetigtAm, sink.sent.length], ['versendet', null, 0])
}
test('kein Eintrag der Erlaubnisliste gilt für Aufträge', () => {
for (const pfad of ['/api/auftraege', '/api/auftraege/ab1/bestaetigen', '/api/auftraege/ab1/pdf', `/api/auftrag/${TOKEN}`, `/api/auftrag/${TOKEN}/bestaetigen`]) {
assert.ok(!KUNDENANSICHT_ALLOWED.some(([, muster]) => muster.test(pfad)), pfad)
}
})
test('POST /api/auftraege/:id/bestaetigen in der Kundenansicht: 403 view_blocked, die AB bleibt unverändert', async () => {
const res = await send('POST', '/api/auftraege/ab1/bestaetigen', { ...kv(), body: { bestaetigt: true, fruehbeginn: true } })
assert.equal(res.status, 403)
assert.equal(res.body.code, 'view_blocked')
await unveraendert()
})
test('alle Schreib-Routen des Kunden-Routers sind in der Kundenansicht gesperrt (nur Bestätigen ist eine)', async () => {
const schreibRouten = kundeRoutes.stack
.filter((schicht) => schicht.route)
.flatMap((schicht) => Object.keys(schicht.route.methods).filter((m) => !['get', 'head', 'options', '_all'].includes(m)).map((m) => [m.toUpperCase(), `/api/auftraege${String(schicht.route.path).replace(/:[A-Za-z]+/g, 'ab1')}`]))
assert.deepEqual(schreibRouten, [['POST', '/api/auftraege/ab1/bestaetigen']])
for (const [methode, pfad] of schreibRouten) {
const res = await send(methode, pfad, { ...kv(), body: { bestaetigt: true } })
assert.equal(res.status, 403, `${methode} ${pfad}`)
}
await unveraendert()
})
test('auch der öffentliche Link bestätigt nichts, wenn der Browser eine Kundenansicht-Sitzung trägt', async () => {
const mitKennung = await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { ...kv(), body: { bestaetigt: true } })
assert.deepEqual([mitKennung.status, mitKennung.body.code], [403, 'view_blocked'])
const ohneKennung = await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { cookie: kv().cookie, body: { bestaetigt: true } })
assert.deepEqual([ohneKennung.status, ohneKennung.body.code], [409, 'view_replaced'])
await unveraendert()
})
test('Lesen bleibt erlaubt, kannBestaetigen ist in der Kundenansicht false; der Kunde selbst darf bestätigen', async () => {
const liste = await send('GET', '/api/auftraege', kv())
assert.equal(liste.status, 200)
assert.deepEqual(liste.body.auftraege.map((a) => [a.id, a.kannBestaetigen]), [['ab1', false]])
assert.equal((await send('GET', '/api/auftraege/ab1/pdf', kv())).status, 200)
const selbst = await send('GET', '/api/auftraege', { cookie: customerCookie('c1') })
assert.deepEqual(selbst.body.auftraege.map((a) => [a.id, a.kannBestaetigen]), [['ab1', true]])
const res = await send('POST', '/api/auftraege/ab1/bestaetigen', { cookie: customerCookie('c1'), body: { bestaetigt: true } })
assert.equal(res.status, 200)
assert.equal((await getAuftrag('ab1')).status, 'bestaetigt')
})
+211
View File
@@ -0,0 +1,211 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
TOKEN_MUSTER, KONTAKT, ANREDE_SIE, ANREDE_DU, TEXTE_GEMEINSAM, TEXTE_SIE, TEXTE_DU, texte, tokenAusAdresse, euroDe, datumDe, zeitpunktDe,
pdfPfad, agbPfad, zeigtFruehbeginn, darfBestaetigen, statusAnzeige, kartenMeta, zusammenfassung, bestaetigtZeilen, zustandAusAntwort,
fehlerMeldung, sortiereAuftraege, abNummerNormalisiert,
} from '../public/auftrag-ansicht.js'
import { TOKEN, BESTAETIGT_AM, KLEINUNTERNEHMER, ab, privat, bestaetigt, hatDuForm, hatSieForm, pruefeQuelltext } from './auftraegeKundenportalDaten.js'
const NB = '\u{a0}' // geschütztes Leerzeichen vor „€“ (wie euroDe auf dem Server)
test('Token: 43 Zeichen base64url, zuerst aus #t= (so verlinkt K31), sonst aus ?t=, nie mehrdeutig', () => {
assert.equal(TOKEN.length, 43)
assert.match(TOKEN, TOKEN_MUSTER)
assert.equal(tokenAusAdresse('', `#t=${TOKEN}`), TOKEN)
assert.equal(tokenAusAdresse(`?t=${TOKEN}`, ''), TOKEN)
assert.equal(tokenAusAdresse(`?x=1&t=${TOKEN}`, ''), TOKEN)
const anderer = 'y'.repeat(43)
assert.equal(tokenAusAdresse(`?t=${TOKEN}`, `#t=${anderer}`), anderer, 'das Fragment gewinnt')
assert.equal(tokenAusAdresse(`?t=${TOKEN}`, '#t=abc'), TOKEN, 'ungültiges Fragment: weiter mit der Suche')
for (const hash of ['', '#', '#t=', '#t=abc', `#t=${TOKEN}x`, `#t=${TOKEN.slice(0, 42)}!`, `#t=${TOKEN}&t=${TOKEN}`, `#token=${TOKEN}`]) {
assert.equal(tokenAusAdresse('', hash), '', hash)
}
assert.equal(tokenAusAdresse(`?t=${TOKEN}&t=${TOKEN}`, ''), '')
assert.equal(tokenAusAdresse(undefined, undefined), '')
})
test('Beträge: ganze Cent als deutscher Euro-Betrag wie auf dem Server', () => {
assert.equal(euroDe(80000), `800,00${NB}€`)
assert.equal(euroDe(123456), `1.234,56${NB}€`)
assert.equal(euroDe(5), `0,05${NB}€`)
assert.equal(euroDe(0), `0,00${NB}€`)
assert.equal(euroDe(-449), `−4,49${NB}€`)
for (const kaputt of [1.5, '800', null, undefined, Number.NaN]) assert.equal(euroDe(kaputt), '–')
})
test('Datum und Zeit: Berliner Zeit, unabhängig von der Zeitzone des Browsers', () => {
assert.equal(datumDe('2026-10-06'), '06.10.2026')
assert.equal(datumDe('06.10.2026'), '06.10.2026')
assert.equal(datumDe('2026-10-06T22:30:00.000Z'), '07.10.2026') // 00:30 MESZ am Folgetag
for (const kaputt of ['', null, undefined, 'morgen', '2026-10', 20261006]) assert.equal(datumDe(kaputt), '')
assert.equal(zeitpunktDe(BESTAETIGT_AM), '07.10.2026, 10:15 Uhr')
assert.equal(zeitpunktDe('2026-10-07T08:15:00.000+00:00'), '07.10.2026, 10:15 Uhr') // so liefert Appwrite
assert.equal(zeitpunktDe('2026-12-01T08:15:00Z'), '01.12.2026, 09:15 Uhr') // Winterzeit
assert.equal(zeitpunktDe('2026-10-06T22:05:00Z'), '07.10.2026, 00:05 Uhr') // Mitternacht ohne „24“
for (const kaputt of ['', null, undefined, 'bald']) assert.equal(zeitpunktDe(kaputt), '')
})
test('Links: PDF-Pfad aus dem Token, AGB nur als PDF unter /rechtliches/', () => {
assert.equal(pdfPfad(TOKEN), `/api/auftrag/${TOKEN}/pdf`)
assert.equal(agbPfad('/rechtliches/agb-2026-09-30.pdf'), '/rechtliches/agb-2026-09-30.pdf')
for (const boese of ['https://evil.example/agb.pdf', '//evil.example/agb.pdf', '/rechtliches/../x.pdf', '/rechtliches/agb.pdf?x=1', '/rechtliches/agb.html', 'javascript:alert(1)', '', null, undefined, 5]) {
assert.equal(agbPfad(boese), '', String(boese))
}
})
test('Häkchen: Frühbeginn nur mit Text vom Server; Bestätigen nur, solange nicht bestätigt', () => {
assert.equal(zeigtFruehbeginn(privat()), true)
assert.equal(zeigtFruehbeginn(ab()), false)
assert.equal(zeigtFruehbeginn(privat({ texte: { bestaetigung: 'x', fruehbeginn: ' ' } })), false)
assert.equal(zeigtFruehbeginn({}), false)
assert.equal(darfBestaetigen(ab()), true)
assert.equal(darfBestaetigen(ab({ status: 'abgerechnet' })), true) // Schlussrechnung vor der Bestätigung (Übersicht V2)
assert.equal(darfBestaetigen(bestaetigt(ab())), false)
assert.equal(darfBestaetigen(ab({ status: 'abgerechnet', bestaetigtAm: BESTAETIGT_AM })), false)
for (const status of ['storniert', 'ersetzt', 'widerrufen', 'entwurf']) assert.equal(darfBestaetigen(ab({ status })), false, status)
})
test('Status: Beschriftung und Ton; „Bitte bestätigen“ nur mit kannBestaetigen und ohne Bestätigung', () => {
assert.deepEqual(statusAnzeige({ status: 'versendet', kannBestaetigen: true, bestaetigtAm: null }), { text: 'Bitte bestätigen', ton: 'warn' })
assert.deepEqual(statusAnzeige({ status: 'abgerechnet', kannBestaetigen: true, bestaetigtAm: null }), { text: 'Bitte bestätigen', ton: 'warn' })
assert.deepEqual(statusAnzeige({ status: 'versendet' }), { text: 'Versendet', ton: 'muted' })
assert.deepEqual(statusAnzeige({ status: 'bestaetigt', bestaetigtAm: BESTAETIGT_AM, kannBestaetigen: false }), { text: 'Bestätigt', ton: 'ok' })
assert.deepEqual(statusAnzeige({ status: 'abgerechnet', bestaetigtAm: BESTAETIGT_AM }), { text: 'Abgerechnet', ton: 'ok' })
assert.deepEqual(statusAnzeige({ status: 'storniert' }), { text: 'Storniert', ton: 'muted' })
assert.deepEqual(statusAnzeige({ status: 'ersetzt' }), { text: 'Ersetzt', ton: 'muted' })
assert.deepEqual(statusAnzeige({ status: 'widerrufen' }), { text: 'Widerrufen', ton: 'rot' })
assert.deepEqual(statusAnzeige({ status: 'unbekannt' }), { text: '–', ton: 'muted' })
assert.deepEqual(statusAnzeige(undefined), { text: '–', ton: 'muted' })
})
test('Karten-Zeile und Sortierung der Portalliste', () => {
assert.equal(kartenMeta(ab()), `vom 06.10.2026 · 800,00${NB}€`)
assert.equal(kartenMeta(ab({ art: 'aufwand', positionen: [], summeCents: null })), 'vom 06.10.2026 · nach Aufwand')
assert.equal(kartenMeta(ab({ datum: '' })), `800,00${NB}€`)
assert.equal(kartenMeta({}), '')
const liste = [
{ nummer: 'AB-2026-0001', kannBestaetigen: false }, { nummer: 'AB-2026-0003', kannBestaetigen: false },
{ nummer: 'AB-2026-0002', kannBestaetigen: true }, { nummer: 'AB-2025-0009', kannBestaetigen: false },
]
assert.deepEqual(sortiereAuftraege(liste).map((a) => a.nummer), ['AB-2026-0002', 'AB-2026-0003', 'AB-2026-0001', 'AB-2025-0009'])
assert.equal(liste[0].nummer, 'AB-2026-0001', 'die Eingabe bleibt unverändert')
assert.deepEqual(sortiereAuftraege(undefined), [])
})
test('Zusammenfassung (Festpreis): Kopf, Positionen, Summe, Zahlungsplan, Termine, § 19 aus der API', () => {
assert.deepEqual(zusammenfassung(ab()), {
kopf: [{ label: 'Nummer', wert: 'AB-2026-0002' }, { label: 'Datum', wert: '06.10.2026' }, { label: 'Projekt', wert: 'Website Anelia Wolf' }],
art: 'festpreis',
positionen: [
{ beschreibung: 'Erstellung der Website anelia-wolf.com', menge: '1', einzelpreis: `600,00${NB}€`, summe: `600,00${NB}€` },
{ beschreibung: 'Texte und Bildbearbeitung', menge: '2', einzelpreis: `100,00${NB}€`, summe: `200,00${NB}€` },
],
summe: `800,00${NB}€`,
aufwand: '',
zahlung: ab().zahlungsplanText,
termine: [{ label: 'Start', wert: '12.10.2026' }, { label: 'Voraussichtliche Fertigstellung', wert: '30.11.2026' }],
hinweis: KLEINUNTERNEHMER,
})
})
test('Zusammenfassung (Aufwand): Aufwandstext statt Positionen; Termine nur, wenn gesetzt; ohne Satz der API kein § 19-Hinweis; leere Eingabe wirft nicht', () => {
const z = zusammenfassung(ab({
art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten.',
zahlungsplanText: 'Monatlich nach Aufwand.', startAm: '2026-10-12', fertigBis: '',
}))
assert.equal(z.art, 'aufwand')
assert.deepEqual(z.positionen, [])
assert.equal(z.summe, '')
assert.equal(z.aufwand, '100,00 € pro Stunde in Schritten von 15 Minuten.')
assert.equal(z.zahlung, 'Monatlich nach Aufwand.')
assert.deepEqual(z.termine, [{ label: 'Start', wert: '12.10.2026' }])
assert.equal(z.hinweis, KLEINUNTERNEHMER)
assert.equal(zusammenfassung(ab({ texte: { bestaetigung: 'x', fruehbeginn: null } })).hinweis, '', 'keine eigene Ersatzkonstante: der Satz kommt von der API')
assert.deepEqual(zusammenfassung({}), { kopf: [], art: 'festpreis', positionen: [], summe: '', aufwand: '', zahlung: '', termine: [], hinweis: '' })
assert.doesNotThrow(() => zusammenfassung(undefined))
})
test('Bestätigt-Zeilen: Zeitpunkt, bei Privatpersonen Frühbeginn bzw. frühester Beginn, in beiden Anreden', () => {
assert.deepEqual(bestaetigtZeilen(ab()), [])
assert.deepEqual(bestaetigtZeilen(bestaetigt(ab())), ['Bestätigt am 07.10.2026, 10:15 Uhr'])
assert.deepEqual(bestaetigtZeilen(bestaetigt(ab({ fruehesterBeginn: '2026-10-21' }))), ['Bestätigt am 07.10.2026, 10:15 Uhr'], 'Unternehmen: keine Zeile zum Beginn')
assert.deepEqual(bestaetigtZeilen(bestaetigt(privat())), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen frühestens am 21.10.2026.'])
const wunsch = bestaetigt(privat({ fruehbeginn: true, fruehesterBeginn: null }))
assert.deepEqual(bestaetigtZeilen(wunsch), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist.'])
assert.deepEqual(bestaetigtZeilen(wunsch, ANREDE_DU), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf deinen Wunsch schon vor Ablauf der Widerrufsfrist.'])
assert.deepEqual(bestaetigtZeilen(bestaetigt(privat({ fruehesterBeginn: null }))), ['Bestätigt am 07.10.2026, 10:15 Uhr'])
assert.deepEqual(bestaetigtZeilen({ bestaetigtAm: 'kaputt' }), ['Bestätigt'])
})
test('Zustände aus der Serverantwort: feste Texte, der 410-Grund nur aus dem Stichwort', () => {
const kontakt = 'Bitte melden Sie sich bei uns. Wir senden Ihnen gern einen neuen Link.'
assert.deepEqual(zustandAusAntwort(404, { error: 'Dieser Link ist ungültig oder abgelaufen.' }), { zustand: 'ungueltig', titel: 'Dieser Link ist ungültig oder abgelaufen.', text: kontakt })
assert.deepEqual(zustandAusAntwort(410, { error: 'Diese Auftragsbestätigung wurde ersetzt.' }), {
zustand: 'beendet', titel: 'Diese Auftragsbestätigung wurde ersetzt.', text: 'Bitte verwenden Sie den Link in der neuesten Auftragsbestätigung, die wir Ihnen geschickt haben.',
})
for (const grund of ['storniert', 'widerrufen']) {
assert.deepEqual(zustandAusAntwort(410, { error: `Diese Auftragsbestätigung wurde ${grund}.` }), {
zustand: 'beendet', titel: `Diese Auftragsbestätigung wurde ${grund}.`, text: 'Bei Fragen melden Sie sich gern bei uns.',
})
}
for (const body of [{ error: `Irgendwas mit ${TOKEN}` }, {}, undefined, null]) { // fremder Text (auch mit Token) wird nie angezeigt
const z = zustandAusAntwort(410, body)
assert.equal(z.titel, 'Diese Auftragsbestätigung ist nicht mehr gültig.')
assert.equal(z.text, 'Bei Fragen melden Sie sich gern bei uns.')
}
assert.deepEqual(zustandAusAntwort(429, { error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }), { zustand: 'limit', titel: 'Zu viele Anfragen', text: 'Bitte versuchen Sie es in einigen Minuten noch einmal.' })
for (const status of [0, 400, 401, 500, 502, 503]) {
assert.deepEqual(zustandAusAntwort(status, { error: 'Interner Fehler.' }), {
zustand: 'fehler', titel: 'Das hat leider nicht geklappt', text: 'Ihre Auftragsbestätigung konnte gerade nicht geladen werden. Bitte versuchen Sie es in einigen Minuten noch einmal.',
})
}
})
test('Fehlermeldungen beim Bestätigen: feste eigene Texte nach Status, Sie auf der Seite und Du im Portal', () => {
assert.equal(fehlerMeldung(0), 'Keine Verbindung. Bitte prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.')
assert.equal(fehlerMeldung(400), 'Bitte setzen Sie das Häkchen, um den Auftrag zu bestätigen.')
assert.equal(fehlerMeldung(401), 'Ihre Anmeldung ist abgelaufen. Bitte laden Sie die Seite neu und melden Sie sich erneut an.')
for (const status of [403, 409]) assert.equal(fehlerMeldung(status), 'Das Bestätigen ist gerade nicht möglich. Bitte laden Sie die Seite neu.')
assert.equal(fehlerMeldung(429), 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten noch einmal.')
for (const status of [500, 502, 503, undefined]) assert.equal(fehlerMeldung(status), 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuchen Sie es später noch einmal.')
assert.equal(fehlerMeldung(0, ANREDE_DU), 'Keine Verbindung. Bitte prüfe deine Internetverbindung und versuche es erneut.')
assert.equal(fehlerMeldung(400, ANREDE_DU), 'Bitte setze das Häkchen, um den Auftrag zu bestätigen.')
assert.equal(fehlerMeldung(401, ANREDE_DU), 'Deine Anmeldung ist abgelaufen. Bitte lade die Seite neu und melde dich erneut an.')
for (const status of [403, 409]) assert.equal(fehlerMeldung(status, ANREDE_DU), 'Das Bestätigen ist gerade nicht möglich. Bitte lade die Seite neu.')
assert.equal(fehlerMeldung(429, ANREDE_DU), 'Zu viele Anfragen. Bitte versuche es in einigen Minuten noch einmal.')
for (const status of [500, 502, 503, undefined]) assert.equal(fehlerMeldung(status, ANREDE_DU), 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuche es später noch einmal.')
})
const werte = (objekt) => Object.values(objekt).flatMap((wert) => (wert && typeof wert === 'object' ? werte(wert) : [wert])).filter((wert) => typeof wert === 'string')
test('Anrede: gemeinsame Texte neutral, öffentliche Seite Sie, Portal Du; jede Du-Variante hat ihr Sie-Gegenstück', () => {
assert.equal(ANREDE_SIE, 'sie')
assert.equal(ANREDE_DU, 'du')
for (const text of werte(TEXTE_GEMEINSAM)) assert.ok(!hatDuForm(text) && !hatSieForm(text), text)
for (const text of werte(TEXTE_SIE)) assert.ok(!hatDuForm(text), text)
for (const text of werte(TEXTE_DU)) assert.ok(!hatSieForm(text), text)
assert.deepEqual(Object.keys(TEXTE_DU).sort(), ['fehler', 'formular', 'fruehbeginnWunsch', 'speicherHinweis'])
for (const schluessel of Object.keys(TEXTE_DU)) assert.ok(schluessel in TEXTE_SIE, schluessel)
assert.deepEqual(Object.keys(TEXTE_DU.fehler), Object.keys(TEXTE_SIE.fehler))
assert.equal(texte().formular, 'Ihre Bestätigung', 'ohne Angabe gilt Sie')
assert.equal(texte(ANREDE_SIE).speicherHinweis, 'Ihre Bestätigung wird mit Datum und Uhrzeit gespeichert.')
assert.equal(texte(ANREDE_DU).formular, 'Deine Bestätigung')
assert.equal(texte(ANREDE_DU).speicherHinweis, 'Deine Bestätigung wird mit Datum und Uhrzeit gespeichert.')
assert.equal(texte(ANREDE_DU).knopf, 'Auftrag bestätigen', 'gemeinsame Texte gibt es in beiden Anreden')
assert.equal(texte(ANREDE_DU).ansichtsmodus, 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.')
})
test('AB-Nummer: Schreibweisen der Praxis werden zu AB-JJJJ-NNNN, alles andere zu leer', () => {
assert.equal(abNummerNormalisiert('AB-2026-0001'), 'AB-2026-0001')
assert.equal(abNummerNormalisiert(' ab-2026-0001 '), 'AB-2026-0001')
assert.equal(abNummerNormalisiert('ab 2026 0001'), 'AB-2026-0001')
assert.equal(abNummerNormalisiert('AB – 2026 – 0001'), 'AB-2026-0001') // Halbgeviertstrich mit Leerzeichen
assert.equal(abNummerNormalisiert('AB−2026−0001'), 'AB-2026-0001') // Minuszeichen
for (const kaputt of ['', ' ', 'AB-2026-01', 'AB-26-0001', 'RE-2026-0001', 'AB-2026-0001-1', 'AB20260001', undefined, null]) assert.equal(abNummerNormalisiert(kaputt), '', String(kaputt))
})
test('Quelltext und Kontakt: reines Modul ohne Importe, fetch und DOM; Kontaktdaten wie in den Rechtstexten', () => {
pruefeQuelltext('auftrag-ansicht.js', { mitTexten: true })
assert.deepEqual(KONTAKT, { email: 'support@webklar.com', telefon: '0176 23726355' })
})
@@ -0,0 +1,63 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
const html = lies('public/dashboard.html')
const app = lies('public/app.js')
const css = lies('public/auftrag.css')
test('dashboard.html: auftrag.css nach zeiten.css; Abschnitt „Aufträge“ vor der Rechnungsliste, zunächst versteckt', () => {
assert.match(html, /<link rel="stylesheet" href="\/zeiten\.css">\s*<link rel="stylesheet" href="\/auftrag\.css">/)
const start = html.indexOf('id="view-invoices"')
const ende = html.indexOf('</section>', html.indexOf('id="invoices-abo-note"'))
const ansicht = html.slice(start, ende)
assert.match(ansicht, /<section class="ab-portal hidden" id="orders-section" aria-labelledby="orders-title">\s*<h2 id="orders-title">Aufträge<\/h2>[\s\S]*<div id="orders-body"><\/div>\s*<\/section>\s*<h2 class="ab-rechnungen-titel hidden" id="invoices-title">Rechnungen<\/h2>\s*<div class="kb-panel" id="invoices-body">/)
assert.match(ansicht, /<p class="kb-note">Deine Auftragsbestätigungen\. Offene Aufträge bestätigst du hier mit einem Klick\.<\/p>/, 'Portal: Du-Form')
assert.ok(ansicht.indexOf('id="orders-section"') < ansicht.indexOf('id="invoices-body"'))
assert.equal([...html.matchAll(/id="orders-section"/g)].length, 1)
assert.match(ansicht, /<h1>Rechnungen<\/h1>/, 'Kopf und Hinweis des Reiters bleiben')
})
test('app.js: loadCustomerOrders lädt auftrag-portal.js nach der Rechnungsliste, mit api und Ansichtsmodus; Fehler bleiben still', () => {
const funktion = app.slice(app.indexOf('function loadCustomerOrders()'), app.indexOf('/* ---', app.indexOf('function loadCustomerOrders()')))
assert.ok(funktion.length > 40, 'Funktion gefunden')
assert.match(funktion, /import\('\/auftrag-portal\.js'\)/)
assert.match(funktion, /m\.initAuftraege\(/)
for (const teil of ["section: document.getElementById('orders-section')", "body: document.getElementById('orders-body')", "invoicesTitle: document.getElementById('invoices-title')", 'request: api', 'viewAs: chatState.viewAs']) {
assert.ok(funktion.includes(teil), teil)
}
assert.match(funktion, /\.catch\(\(\) => \{\}\)/)
const dashboard = app.slice(app.indexOf('async function initDashboardPage'))
const aufruf = (name) => dashboard.search(new RegExp(`^\\s*${name}\\(\\)\\s*$`, 'm'))
assert.ok(aufruf('loadCustomerInvoices') > 0 && aufruf('loadCustomerOrders') > aufruf('loadCustomerInvoices'), 'ein eigener Aufruf, nach der Rechnungsliste')
assert.equal([...app.matchAll(/function loadCustomerOrders\(\)/g)].length, 1)
})
test('invoiceNumberHtml: Projekt unter der Rechnungsnummer, alles maskiert', () => {
const ausschnitt = (muster) => {
const treffer = app.match(muster)
assert.ok(treffer, `${muster} nicht in app.js`)
return treffer[0]
}
const code = [
ausschnitt(/function escapeHtml\(str\) \{[\s\S]*?\n\}/),
ausschnitt(/\/\/ @rechnungsnummer-anfang[\s\S]*?\/\/ @rechnungsnummer-ende/),
].join('\n')
const { invoiceNumberHtml } = vm.runInNewContext(`${code}\n({ invoiceNumberHtml })`)
assert.equal(invoiceNumberHtml({ number: 'WEBKLAR-0009', projekt: 'Website Anelia Wolf' }), 'WEBKLAR-0009<span class="inv-project">Website Anelia Wolf</span>')
assert.equal(invoiceNumberHtml({ number: 'WEBKLAR-0009', projekt: '' }), 'WEBKLAR-0009')
assert.equal(invoiceNumberHtml({ number: 'H96T-0004' }), 'H96T-0004')
assert.equal(
invoiceNumberHtml({ number: 'X', projekt: '<img src=x onerror=alert(1)> & "Bäckerei"' }),
'X<span class="inv-project">&lt;img src=x onerror=alert(1)&gt; &amp; &quot;Bäckerei&quot;</span>',
)
assert.match(app, /<td class="inv-no">\$\{invoiceNumberHtml\(i\)\}<\/td>/)
assert.doesNotMatch(app, /\$\{i\.projekt\}/, 'der Projektname geht nie unmaskiert in eine Vorlage')
})
test('auftrag.css: Projektname unter der Rechnungsnummer (Tabelle und Handy-Karte)', () => {
assert.match(css, /\.inv-project\s*\{[^}]*display:\s*block/)
assert.match(css, /\.inv-project\s*\{[^}]*overflow-wrap:\s*anywhere/)
})
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more