diff --git a/.gitignore b/.gitignore index b5af02b..a9cbd69 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -node_modules/ +node_modules .env .DS_Store preview-data/ diff --git a/docs/rechtliches/agb.md b/docs/rechtliches/agb.md new file mode 100644 index 0000000..d6387ff --- /dev/null +++ b/docs/rechtliches/agb.md @@ -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. diff --git a/docs/rechtliches/quellen/normen-auftraege-2026-10-06.txt b/docs/rechtliches/quellen/normen-auftraege-2026-10-06.txt new file mode 100644 index 0000000..34ccd29 --- /dev/null +++ b/docs/rechtliches/quellen/normen-auftraege-2026-10-06.txt @@ -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. diff --git a/docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md b/docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md new file mode 100644 index 0000000..0476762 --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md @@ -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.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 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/`. Die volle Suite (`npm test`) läuft **nur in Z1**, und schwere Läufe (volle Suite, Chrome-PDF-Druck, `npm ci`, `vite build`) laufen hinter `flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock`. Vorher `free -m`: unter 1500 MB „available“ warten. Nie `pkill -f` (trifft die eigene Shell), Prozesse nur per PID beenden. +- Git: jeder Strang nur in seinem Worktree und Branch; Commits klein, je Task mindestens einer, Nachricht deutsch, Schlusszeile `Co-Authored-By: Claude Opus 5.5 `; **kein Push** (der Nutzer pusht), nie `--force`, nie `git add -A`. +- Sicherheit der Umgebung (Memory „feedback-destruktive-skripte“): kein `rm -rf`/`rsync --delete` außerhalb des eigenen Worktrees bzw. Scratchpads; keine Live-Daten lesen oder schreiben (kein Prod-Appwrite, kein Live-Stripe, keine echten Mails); `build/` nie anfassen. +- Bestehendes Verhalten bleibt: alle vorhandenen Tests bleiben grün; die bisherigen Rechnungsfelder (`mapInvoice`) bleiben, neue kommen dazu. + +## Review Focus + +1. **Gleichzeitige Aufwandsrechnungen** mit denselben Arbeitsblättern (Doppelklick, zwei Admins): die zweite muss mit 409 „Arbeitsblatt bereits abgerechnet“ scheitern. Test in L2 (zwei parallele `createProjectInvoice` mit gleichen `arbeitsblattIds` unter Sperre `sperreRechnung(kundenId)` = `['auftraege-rechnung:']`). +2. **Alte Token nach Erinnerung, neuer Fassung, Storno oder Widerruf**: der alte Link darf nie eine AB bestätigen; die Seite erklärt den Grund. Tests in L5 (Erinnerung → alter Token 404; ersetzt/storniert/widerrufen → 410 mit Meldung). +3. **Schlussrechnung neben stornierten oder noch nicht gesendeten Rechnungen**: Stornos zählen nicht, ein offener Entwurf zur AB blockiert mit 409. Tests in L2. +4. **Unvollständige Kundendaten beim AB-Senden** (keine Kundenart, keine Anschrift, keine E-Mail, Interessent statt Kunde): klare Meldung **vor** Nummernvergabe und PDF, keine Nummer verbraucht. Tests in L3. +5. **Umlaute, €, Minuszeichen und lange Texte im PDF** (ä ö ü ß €, „−“, 200 Zeichen ohne Leerzeichen, 20 Positionen über eine Seite): lesbar, kein Glyphenausfall, Seitenumbruch mit Kopf der Tabelle. Tests in L4 (Text aus dem PDF lesen). + +--- + +## Verträge + +### V1 Collections und Attribute (`server/services/auftraege/schema.js`, L0) + +Bauart wie `server/services/zeiten/schema.js` (reine Daten, kein Import von `config.js`; Helfer `str(key,size)`, `int(key)`, `bool(key)`, `dt(key)`, `index(key, ...attrs)`; alle Attribute `required: false`). + +```js +export const AUFTRAEGE_COLLECTIONS = { + auftraege: { + attributes: [ + str('nummer', 20), int('jahr'), int('laufnummer'), str('status', 20), + str('kundenId', 64), str('kundeJson', 2000), str('projektId', 64), str('titel', 200), + str('zeitquelleJson', 1000), str('art', 20), str('positionenJson', 20000), + int('stundensatzCents'), int('schaetzungMinuten'), int('summeCents'), + str('zahlungsplanJson', 1000), int('zahlungszielTage'), + str('auftragVom', 10), str('auftragWeg', 20), str('startAm', 10), str('fertigBis', 10), + str('texteFassung', 20), str('agbFassung', 20), str('pdfDatei', 300), str('pdfSha256', 64), + dt('gesendetAm'), str('gesendetAn', 255), str('mailStatus', 20), str('mailFehler', 500), + str('tokenHash', 64), dt('tokenGueltigBis'), + dt('bestaetigtAm'), str('bestaetigtUeber', 10), str('bestaetigtPdfSha256', 64), + bool('fruehbeginn'), str('fruehbeginnFassung', 20), + str('ersetztId', 64), str('ersetztDurchId', 64), dt('storniertAm'), dt('widerrufenAm'), + str('erstelltVon', 255), dt('erstelltAm'), dt('geaendertAm'), + ], + indexes: [index('idx_kunde', 'kundenId'), index('idx_projekt', 'projektId'), index('idx_jahr', 'jahr'), + index('idx_token', 'tokenHash'), index('idx_nummer', 'nummer')], // idx_nummer NICHT unique: Entwürfe haben nummer '' + }, + // Dokument-ID = Stripe-Rechnungs-ID (muss /^in_\w{1,33}$/ erfüllen; der Stripe-Nachbau vergibt in_test_) + projektRechnungen: { + attributes: [ + str('invoiceId', 64), str('kundenId', 64), str('projektId', 64), str('auftragId', 64), str('art', 20), + int('betragCents'), str('arbeitsblattIdsJson', 20000), str('rechnungsnummer', 64), bool('aktiv'), + str('erstelltVon', 255), dt('erstelltAm'), + ], + indexes: [index('idx_kunde', 'kundenId'), index('idx_projekt', 'projektId'), index('idx_auftrag', 'auftragId')], + }, +} +export const AUFTRAEGE_ZUSATZ_ATTRIBUTE = { + customers: [str('kundenart', 20)], // '' | 'unternehmen' | 'privat' + financeSettings: [str('steuernummer', 40)], +} +``` + +`config.collections.auftraege` (`APPWRITE_COLLECTION_AUFTRAEGE`, Vorgabe `'auftraege'`), `config.collections.projektRechnungen` (`APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN`, Vorgabe `'projektRechnungen'`). +`config.auftraege = { mode, pilotEmails, uploadDir }` mit `mode` aus `AUFTRAEGE_ENABLED` (wie `config.zeiten.mode`), `pilotEmails = commaList(process.env.AUFTRAEGE_PILOT_EMAILS, [])`, `uploadDir = process.env.AUFTRAEGE_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'auftraege')`. + +### V2 Konstanten (`server/services/auftraege/konstanten.js`, L0) + +```js +export const RECHNUNGS_ARTEN = ['frei', 'festbetrag', 'aufwand', 'anzahlung', 'schluss'] +export const AB_RECHNUNGS_ARTEN = ['anzahlung', 'schluss'] // brauchen AB und Schalter +export const AUFTRAG_ARTEN = ['festpreis', 'aufwand'] +export const AUFTRAG_STATUS = ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen'] +export const VEREINBARTE_STATUS = ['versendet', 'bestaetigt', 'abgerechnet'] // zählen als „vereinbart“ +export const AUFTRAG_WEGE = { muendlich: 'mündlich', telefon: 'telefonisch', email: 'per E-Mail', vor_ort: 'vor Ort', chat: 'im Chat' } +export const ZAHLUNGSPLAN_ARTEN = ['nach_abnahme', 'anzahlung', 'monatlich', 'frei'] +export const KUNDENARTEN = ['unternehmen', 'privat'] +export const STUNDENSATZ_STANDARD_CENTS = 10000 +export const VIERTELSTUNDE = 15 +export const TOKEN_GUELTIG_TAGE = 60 +export const AGB_FASSUNG = '2026-09-30' // L7 darf diese eine Zeile anpassen +export const LIMITS = { + maxPositionen: 20, maxBeschreibung: 200, maxMenge: 999, maxEinzelpreisCents: 10_000_000, + maxSummeCents: 99_999_999, maxTitel: 200, maxZahlungsplanText: 500, maxZahlungszielTage: 60, + standardZahlungszielTage: 14, standardAnzahlungProzent: 50, maxZeitquellen: 10, + maxSchaetzungMinuten: 99_960, maxLeistungszeitraum: 140, +} +// withLocks sperrt jedes Listenelement einzeln: deshalb je Sperre GENAU EIN zusammengesetzter Schlüssel +export const SPERRE_NUMMER = ['auftraege-nummer'] +export const sperreRechnung = (kundenId) => [`auftraege-rechnung:${kundenId}`] // Aufwand, Anzahlung, Schluss je Kunde +export const sperreAuftrag = (id) => [`auftraege-ab:${id}`] // jede Statusänderung einer AB (L2, L3, L5) +export const SCHALTER_AUS_MELDUNG = 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.' +``` + +Fehlerklasse (`server/services/auftraege/fehler.js`, L0): `export class AuftragError extends Error { constructor(status, message) }` (Eigenschaft `status`) und `export function sendeFehler(res, was, err)`: `AuftragError` und `InvoiceError` (aus `stripeFinance.js`) → `res.status(err.status).json({ error: err.message })`; sonst Protokoll ohne Meldung (wie `logFailure` in `finanzen.js`) und `500 { error: 'Interner Fehler.' }`. + +Statusübergänge der AB (nur diese): +`entwurf → versendet` (L3 senden) · `versendet → bestaetigt` (L5) · `versendet|bestaetigt → abgerechnet` (L2, Schlussrechnung finalisiert) · `abgerechnet → bestaetigt|versendet` (L2, Storno der Schlussrechnung; Ziel `bestaetigt`, wenn `bestaetigtAm` gesetzt) · `versendet|bestaetigt → storniert` (L3) · `versendet|bestaetigt|abgerechnet → ersetzt` (L3, beim Senden der neuen Fassung) · `versendet|bestaetigt → widerrufen` (L5, nur Kundenart `privat`). Bestätigen ist auch bei `abgerechnet` ohne `bestaetigtAm` möglich (Status bleibt `abgerechnet`). + +### V3 Datenformen + +```js +// Auftrag (intern; auftragStore.docToAuftrag) +{ + id, nummer /* '' im Entwurf */, jahr /* number|null */, laufnummer /* number|null */, status, + kundenId, kunde /* KundeSnapshot|null, beim Senden eingefroren */, projektId /* '' ohne Projekt */, titel, + zeitquelle /* string[] Ticket-IDs (workorders.$id) */, art /* 'festpreis'|'aufwand' */, + positionen /* AbPosition[]; aufwand: [] */, stundensatzCents /* aufwand, sonst null */, + schaetzungMinuten /* aufwand optional, sonst null */, summeCents /* festpreis, sonst null */, + zahlungsplan /* { art, prozent: number|null, text: string } */, zahlungszielTage, + auftragVom /* 'YYYY-MM-DD' */, auftragWeg /* Schlüssel aus AUFTRAG_WEGE */, startAm, fertigBis /* 'YYYY-MM-DD'|'' */, + ersetztId, ersetztDurchId, texteFassung, agbFassung, pdfDatei, pdfSha256, + gesendetAm /* ISO|null */, gesendetAn, mailStatus /* ''|'gesendet'|'fehler' */, mailFehler, + tokenHash, tokenGueltigBis /* ISO|null */, + bestaetigtAm /* ISO|null */, bestaetigtUeber /* ''|'link'|'portal' */, bestaetigtPdfSha256, + fruehbeginn /* boolean */, fruehbeginnFassung, storniertAm, widerrufenAm /* ISO|null */, + erstelltVon, erstelltAm, geaendertAm, +} +// KundeSnapshot (auftragSicht.kundeSnapshot(customerDoc)) +{ name, firma, strasse, plz, ort, land, email, kundenart /* ''|'unternehmen'|'privat' */, kundennummer /* customers.code || '' */ } +// AbPosition +{ beschreibung, menge, einzelpreisCents } +// ProjektRechnung (projektRechnungenStore) +{ invoiceId, kundenId, projektId, auftragId, art /* 'festbetrag'|'aufwand'|'anzahlung'|'schluss' */, betragCents, + arbeitsblattIds /* string[] */, rechnungsnummer, aktiv /* boolean */, erstelltVon, erstelltAm } +// Arbeitsblatt (arbeitsblaetter.js; normiert) +{ id, ticketId /* worksheets.workorderId */, woid, datum /* 'YYYY-MM-DD' */, minuten /* auf 15 aufgerundet */, + text /* kundentauglich, ≤ 200 */, aboZeit /* countsToPlan === true */, kommentar /* isComment === true */ } +// Position (Rechnungszeile; positionen.js) +{ schluessel /* z. B. '2026-10-06', 'KW-2026-41', 'ab-1', 'zusatz-1', 'abzug-in_123' */, beschreibung, + menge, einzelpreisCents, betragCents /* menge × einzelpreis; Minuszeile: menge 1, beide negativ */ } +// Projektkonto (projektkonto.js) +{ projektId, name, projektTyp, tickets /* [{ id, woid }] */, zeitquelle /* string[] */, + vereinbart: { festpreisCents /* number|null */, aufwand /* { stundensatzCents, schaetzungMinuten }|null */ }, + berechnetCents, bezahltCents, offenCents /* number|null */, + zeit: { minuten, aboMinuten, offeneMinuten, satzCents, wertCents, offenerWertCents } } +// AuftragTeam (auftragSicht.auftragFuerTeam): Auftrag ohne tokenHash, pdfDatei, bestaetigtPdfSha256, plus +{ pdfVorhanden /* boolean */, fruehesterBeginn /* 'YYYY-MM-DD'|null */, zahlungsplanText, aufwandText /* string|null */ } +// AuftragOeffentlich (auftragSicht.oeffentlicheAnsicht) – nur für Token- und Kundenrouten +{ nummer, datum /* Berliner Tag von gesendetAm */, titel, art, + positionen /* [{ beschreibung, menge, einzelpreisCents, summeCents }] */, summeCents, stundensatzCents, + schaetzungMinuten, aufwandText, zahlungsplanText, startAm, fertigBis, kundenart, status, bestaetigtAm, + fruehbeginn, fruehesterBeginn, agbUrl /* '/rechtliches/agb-.pdf' */, + texte: { bestaetigung /* BESTAETIGUNG_TEXT */, fruehbeginn /* FRUEHBEGINN_TEXT oder null bei unternehmen */, + kleinunternehmer /* AB_TEXTE.kleinunternehmer */ } } +// Kundenrouten ergänzen id und kannBestaetigen = (status === 'versendet' || (status === 'abgerechnet' && !bestaetigtAm)) && nicht Kundenansicht +``` + +Regeln, die mehrere Stränge brauchen (alle in L0, `auftragSicht.js`): +- `fruehesterBeginn(auftrag)`: nur bei `kunde.kundenart === 'privat'`, `!fruehbeginn` und gesetztem `gesendetAm`: Tag nach `withdrawalDeadline(new Date(gesendetAm)).lastDay` (aus `server/services/deadlines.js`); sonst `null`. +- `zahlungsplanText(auftrag)` und `aufwandText(auftrag)`: aus den Vorlagen in `texte.js` (V7), Beträge mit `euroDe`, bei `anzahlung` Anzahlung = `Math.round(summeCents * prozent / 100)`, Rest = `summeCents − Anzahlung`. + +### V4 Module und Funktionen + +**L0 – Fundament** +- `schalter.js`: `auftraegeAktiv(kunde) → boolean` (`kunde`: Kunden-Dokument oder `{ email }`; `on` → true, `pilot` → E-Mail klein geschrieben in `config.auftraege.pilotEmails` oder Liste enthält `*`, sonst false). +- `auftragStore.js`: `docToAuftrag(doc)`, `auftragToDoc(auftrag)` (nur Felder aus V1, JSON-Felder serialisiert), `getAuftrag(id) → Auftrag|null`, `listAuftraege({ kundenId, projektId, jahr } = {}) → Auftrag[]` (neueste zuerst), `createAuftrag(daten) → Auftrag` (setzt `erstelltAm`, `geaendertAm`, Status `entwurf`), `updateAuftrag(id, patch) → Auftrag` (setzt `geaendertAm`), `deleteAuftrag(id)`, `findAuftragByTokenHash(hash) → Auftrag|null`, `findAuftragByNummer(nummer) → Auftrag|null`. +- `projektRechnungenStore.js`: `getProjektRechnung(invoiceId) → ProjektRechnung|null`, `listProjektRechnungen({ kundenId, projektId, auftragId, nurAktiv = true } = {})`, `createProjektRechnung(invoiceId, daten) → ProjektRechnung`, `updateProjektRechnung(invoiceId, patch) → ProjektRechnung`, `umhaengen(auftragIdAlt, auftragIdNeu) → number` (Anzahl umgehängter Dokumente). +- `auftragSicht.js`: `kundeSnapshot(customerDoc)`, `fruehesterBeginn(auftrag)`, `zahlungsplanText(auftrag)`, `aufwandText(auftrag)`, `auftragFuerTeam(auftrag)`, `oeffentlicheAnsicht(auftrag)`. +- `arbeitsblaetter.js`: `ladeArbeitsblaetter(ticketIds) → Arbeitsblatt[]` (sortiert nach `datum`, dann `id`; Datum wie `parseSheetDate` in `hourLedger.js`: `TT.MM.JJJJ`, sonst ISO, sonst `$createdAt`, als Berliner Tag; Text gefiltert wie `customerWorkEntries` in `customerProjectView.js`), `ladeArbeitsblaetterNachIds(ids) → Arbeitsblatt[]`, `abrechenbar(blatt) → boolean` (`!aboZeit && !kommentar && minuten > 0`), `abgerechneteArbeitsblaetter(kundenId) → Map` (aus aktiven `projektRechnungen`), `offeneArbeitsblaetter(ticketIds, kundenId) → Arbeitsblatt[]` (abrechenbar und nicht abgerechnet), `zeitquelleFuerProjekt(projekt, auftraege) → string[]` (Vereinigung der `zeitquelle` aller ABs mit Status aus `VEREINBARTE_STATUS`, sonst `[projekt.ticketId]` ohne Leereinträge). +- `ablage.js`: `speicherePdf(kundenId, auftragId, buffer) → { pdfDatei: '/.pdf', pdfSha256 }` (unter `config.auftraege.uploadDir`, Verzeichnis 0700, Datei 0600, überschreibt nie: vorhandene Datei → Fehler), `lesePdf(pdfDatei) → Buffer` (nur `^[A-Za-z0-9_-]{1,64}/[A-Za-z0-9_-]{1,64}\.pdf$`, sonst `AuftragError(404, 'PDF nicht gefunden.')`). +- `settings.js` (Finanzen): Schlüssel `steuernummer` (Vorgabe `''`, getrimmt, höchstens 40 Zeichen, erlaubt Buchstaben, Ziffern, Leerzeichen, `/`, `-` und `.` (auch eine USt-IdNr. „DE…“); `''` löscht; sonst `SettingsError(400, 'Steuernummer ungültig.')`). + +**L1 – Projektkonto**: `projektkonto.js`: `projektkontenFuerKunde(kundenId) → { auftraegeAktiv, kunde: { id, name, kundenart }, konten: Projektkonto[], ohneProjekt: { berechnetCents, bezahltCents, anzahl }, stripeOk }`; `projektkontoDetail(projektId) → { konto: Projektkonto, auftraege: AuftragTeam[], rechnungen: RechnungKurz[], offeneArbeitsblaetter: Arbeitsblatt[], stripeOk }` mit `RechnungKurz = { id, number, status, createdDate, totalCents, paidCents, art, auftragId }`. Rechenregeln: Spec Abschnitt 4; `berechnetCents`/`bezahltCents` nur aus finalisierten Rechnungen (Status `offen`, `ueberfaellig`, `bezahlt`, `uneinbringlich` laut `listInvoices()`) aktiver `projektRechnungen`; `offenCents = festpreisCents === null ? null : max(0, festpreisCents − Σ total der finalisierten Rechnungen mit auftragId einer Festpreis-AB)`. Scheitert Stripe: Beträge 0, `stripeOk: false`. + +**L2 – Rechnungen**: `positionen.js` (rein): `festePositionen(items) → Position[]` (Eingabe `{ description, quantity, unitAmountCents }` wie heute), `aufwandPositionen(blaetter, { satzCents, zeilentexte = {}, maxZeilen = 20 }) → { positionen, minuten, von, bis }` (eine Zeile je Tag, Text „06.10.2026: – 2,25 Std. à 100,00 €“, ohne Text „Arbeitszeit“; zu viele Zeilen → älteste Tage zu ISO-Kalenderwochen „KW 41/2026: …“, Schlüssel `KW-2026-41`; `zeilentexte[schluessel]` ersetzt den Text vor dem Gedankenstrich), `anzahlungPosition(auftrag, { prozent, betragCents, bezeichnung = 'Anzahlung' }, bereitsBerechnetCents) → Position`, `schlussPositionen(auftrag, fruehere, zusatz) → Position[]` (`fruehere: [{ invoiceId, rechnungsnummer, datum, betragCents, art }]`; Minuszeile „abzüglich Anzahlung, Rechnung vom “ bzw. „abzüglich Teilrechnung …“), `stundenText(minuten) → '2,25'`, `leistungszeitraumText(von, bis) → '06.10.2026' | '01.10.–20.10.2026' | '28.12.2026–05.01.2027'`. +`rechnungsbau.js`: `baueRechnung(eingabe) → RechnungsEntwurf` mit `eingabe = { customerId, art = 'frei', projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum }` und `RechnungsEntwurf = { kunde, art, projekt /* { id, name, ticketId }|null */, auftrag /* Auftrag|null */, positionen, summeCents, leistungszeitraum, customFields /* [{ name, value }] ≤ 4 */, metadata, projektRechnung /* Daten für createProjektRechnung ohne invoiceId|null */ }`. Zusatzfelder: `Projekt` (Name, auf 140 gekürzt; nicht bei `frei`), `Auftrag` („AB-2026-0002 vom 06.10.2026“, nur mit AB), `Leistungszeitraum` (wenn nicht leer). Metadaten zusätzlich zu den heutigen: `projektId`, `projektName`, `auftragId`, `auftragNummer`, `art`. +`stripeFinance.js`: `createProjectInvoice({ customerId, art = 'frei', projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, daysUntilDue, memo, senden, idempotencyKey, createdBy })` (Positionen über `baueRechnung`; positive Zeile → `quantity` + `unit_amount_decimal`, Minuszeile → `amount`; `custom_fields`; `rendering: { pdf: { page_size: 'a4' } }` für **alle** Rechnungen; `createProjektRechnung` nach dem Entwurf bei `art !== 'frei'`; Aufwand, Anzahlung und Schluss unter `withLocks(sperreRechnung(customerId))`), neue Funktion `rechnungsVorschau(eingabe) → { positionen, summeCents, leistungszeitraum, customFields }` (ohne Stripe-Schreibzugriff), `mapInvoice` liefert zusätzlich `projektId`, `projektName`, `art` (`metadata.art`, sonst `'abo'` bei Abo-Rechnungen bzw. `'frei'`), `auftragId`, `auftragNummer`; Finalisieren (`senden` oder `sendInvoiceNow`) setzt `projektRechnungen.rechnungsnummer` und bei `schluss` die AB auf `abgerechnet`; `voidInvoice`/`deleteDraft` setzen `aktiv: false` und nehmen `abgerechnet` zurück. + +**L3 – AB-Kern**: `nummern.js`: `formatNummer(jahr, laufnummer) → 'AB-2026-0001'`, `vergibNummer(jetzt = new Date()) → { nummer, jahr, laufnummer }` (größte `laufnummer` des Berliner Jahres + 1; Aufrufer hält `SPERRE_NUMMER`). `auftraege.js`: `pruefeEingabe(eingabe, { teilweise = false }) → bereinigte Felder` (AuftragError 400 mit Feldmeldung), `legeEntwurfAn(eingabe, { von })`, `aendereEntwurf(id, eingabe)`, `loescheEntwurf(id)`, `vorschauPdf(id, { deps }) → Buffer`, `sendeAuftrag(id, { von, deps }) → { auftrag, mailFehler: boolean }`, `erinnereAuftrag(id, { deps }) → { auftrag, mailFehler }`, `neueFassung(id, { von }) → Auftrag` (neuer Entwurf mit `ersetztId`), `storniereAuftrag(id) → Auftrag`. `deps = { erzeugeAuftragPdf, neuerToken, sendeAuftragMail }`; Vorgabe per **dynamischem Import** von `./pdf.js` (L4), `./token.js` (L5), `./mails.js` (L5) beim Aufruf, damit L3 ohne L4/L5 testbar ist (Tests übergeben Attrappen). Reihenfolge beim Senden: Eingabe, Kunde (fester Kunde, E-Mail, Anschrift, Kundenart) prüfen → Sperre `SPERRE_NUMMER` → Nummer → PDF erzeugen → `speicherePdf` → Token → Dokument speichern (Status `versendet`) → Sperre frei → Mail → `mailStatus`. Hat die AB `ersetztId`: alte AB `ersetzt` (+ `ersetztDurchId`), `umhaengen(alt, neu)`. + +**L4 – AB-PDF**: `pdf.js`: `auftragPdfDaten(auftrag, { settings, vorschau = false, jetzt, ersetzt }) → AuftragPdfDaten`, `erzeugeAuftragPdf(auftrag, { settings, vorschau = false, jetzt, ersetzt }) → Promise` (`ersetzt = { nummer, datum }` optional, sonst über `getAuftrag(auftrag.ersetztId)`; `AB_TEXTE.termine` bekommt `privat`). `auftrag.kunde` ist immer gesetzt (L3 übergibt bei der Vorschau `kundeSnapshot(liveKunde)` und `nummer: 'Entwurf'`). `settings = { steuernummer, bankDetailsText }`. Inhalt laut Spec Abschnitt 2 „Inhalt des PDFs“, Texte aus `texte.js`. Schrift: statische Figtree-Schnitte (Regular, SemiBold) als Dateien unter `server/assets/fonts/` (OFL, Lizenzdatei dazu); Logo aus `public/` (vorhandene Datei, die die Mailvorlagen nutzen). + +**L5 – Bestätigung und Mails**: `token.js`: `neuerToken(jetzt = new Date()) → { token, hash, gueltigBis /* Date */ }`, `hashToken(token) → string|null` (null bei falschem Format). `bestaetigung.js`: `bestaetigeAuftrag(auftrag, { ueber, fruehbeginn, jetzt = new Date() }) → Auftrag` (Regeln V2; idempotent; `fruehbeginn` nur bei `privat`; schickt danach `sendeBestaetigtMails`, Fehler nur ins Protokoll). `mails.js`: `sendeAuftragMail(auftrag, { pdf, token, erinnerung = false }) → Promise` (wirft bei Fehler), `sendeBestaetigtMails(auftrag) → Promise`. Widerruf in `server/routes/abo.js`: `order = 'projekt'` mit Feld `abNummer`. + +**L6 – Kundenportal**: nur Oberfläche und `customerInvoices.js` (Feld `projekt: raw.metadata?.projektName || ''`). + +**L7 – Rechtstexte**: Wortlaute in `texte.js`, neue Rechtstext-Dateien, Datenschutz, Druckskripte (Spec Abschnitt 8) und das Freigabe-Dokument `/root/reports/auftraege-rechnungen-2026-10-06/rechtsrunde.md`. + +### V5 HTTP-Endpunkte + +Antwortformat bei Fehlern immer `{ error: '' }`. Admin-Routen: Router mit `requireAppwriteStaff` (wie `finanzen.js`); Team-Routen: unter `/api/team` (dort gilt `requireStaffJwt`); Kundenrouten: `requireSession` und eigener Kunde; Token-Routen: ohne Anmeldung, Ratenbegrenzung im Arbeitsspeicher je IP (GET 120/h, POST 10/h → 429 `{ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }`). + +| Strang | Methode, Pfad | Eingabe | Antwort (200) | Fehler | +|---|---|---|---|---| +| L1 | GET `/api/admin/finanzen/projektkonten?kunde=` | – | `{ auftraegeAktiv, kunde, konten, ohneProjekt, stripeOk }` | 400 ohne Kunde, 404 Kunde unbekannt | +| L1 | GET `/api/admin/finanzen/projektkonten/:projektId` | – | `{ konto, auftraege, rechnungen, offeneArbeitsblaetter, stripeOk }` | 404 | +| L1 | GET `/api/team/arbeitsblaetter/abgerechnet?ticketId=` | – | `{ abgerechnet: { : '' \| 'Entwurf' } }` (unbekanntes Ticket: `{ abgerechnet: {} }`) | 400 ohne ticketId | +| L2 | POST `/api/admin/finanzen/rechnungen` | `{ customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung: { prozent, betragCents, bezeichnung }, zeilentexte, leistungszeitraum, daysUntilDue, memo, senden, idempotencyKey }` | `{ rechnung }` (wie heute, `mapInvoice` erweitert) | 400 Eingabe, 404, 409 (Arbeitsblatt bereits abgerechnet, offener Entwurf zur AB, Nichts mehr offen, Adresse), 502 | +| L2 | POST `/api/admin/finanzen/rechnungen/vorschau` | wie oben ohne `senden`, `idempotencyKey`, `daysUntilDue`, `memo` | `{ positionen, summeCents, leistungszeitraum, customFields }` | wie oben | +| L2 | GET `/api/admin/finanzen/rechnungen` | wie heute | Einträge zusätzlich mit `projektId`, `projektName`, `art`, `auftragId`, `auftragNummer` | – | +| L3 | GET `/api/admin/finanzen/auftraege?kunde=` | – | `{ auftraege: AuftragTeam[] }` | 400, 404 Schalter aus | +| L3 | POST `/api/admin/finanzen/auftraege` | `{ kundenId, projektId, titel, kundenart, art, positionen, stundensatzCents, schaetzungMinuten, zahlungsplan, zahlungszielTage, auftragVom, auftragWeg, startAm, fertigBis, zeitquelle }` (`kundenart` setzt `customers.kundenart`, wenn dort leer) | `{ auftrag: AuftragTeam }` | 400, 404 | +| L3 | GET / PATCH / DELETE `/api/admin/finanzen/auftraege/:id` | PATCH wie POST (teilweise) | `{ auftrag }` bzw. `{ ok: true }` | 404, 409 kein Entwurf | +| L3 | GET `/api/admin/finanzen/auftraege/:id/pdf` | – | `application/pdf` (gesendet: gespeicherte Datei; Entwurf: Vorschau mit „Entwurf“) | 404, 409 Entwurf unvollständig | +| L3 | POST `/api/admin/finanzen/auftraege/:id/projekt` | `{ projektId }` | `{ auftrag }` (setzt projektId und, wenn leer, zeitquelle = [Ticket des Projekts]; PDF unverändert) | 400, 404, 409 (hat schon ein Projekt; Status storniert/ersetzt/widerrufen) | +| L3 | POST `/api/admin/finanzen/auftraege/:id/senden` | – | `{ auftrag, mailFehler }` | 400 Kundenart/E-Mail/Interessent, 409 Adresse, kein Entwurf, Entwurf unvollständig; 500 PDF | +| L3 | POST `…/auftraege/:id/erinnern` | – | `{ auftrag, mailFehler }` | 409 nicht `versendet` | +| L3 | POST `…/auftraege/:id/neue-fassung` | – | `{ auftrag }` (neuer Entwurf) | 409 | +| L3 | POST `…/auftraege/:id/stornieren` | – | `{ auftrag }` | 409 | +| L5 | GET `/api/auftrag/:token` | – | `{ auftrag: AuftragOeffentlich }` | 404 `'Dieser Link ist ungültig oder abgelaufen.'`; 410 `'Diese Auftragsbestätigung wurde ersetzt.'` / `'… wurde storniert.'` / `'… wurde widerrufen.'`; 429 | +| L5 | GET `/api/auftrag/:token/pdf` | – | `application/pdf` | wie oben | +| L5 | POST `/api/auftrag/:token/bestaetigen` | `{ bestaetigt: true, fruehbeginn: boolean }` | `{ auftrag: AuftragOeffentlich }` | 400 „Bitte bestätigen Sie den Auftrag mit dem Häkchen.“, 404/410, 429 | +| L5 | GET `/api/auftraege` (Kunde) | – | `{ aktiv, auftraege: (AuftragOeffentlich & { id, kannBestaetigen })[] }` (keine Entwürfe) | 401 | +| L5 | GET `/api/auftraege/:id/pdf` (Kunde) | – | `application/pdf` | 404 fremd/unbekannt | +| L5 | POST `/api/auftraege/:id/bestaetigen` (Kunde) | `{ bestaetigt: true, fruehbeginn }` | `{ auftrag }` | 400 (Text wie Token-Route), 404, 410 (Meldungen wie Token-Route); in der Kundenansicht sperrt der Wächter (kein Eintrag in `KUNDENANSICHT_ALLOWED`) | +| L5 | POST `/api/abo/public/withdraw` | zusätzlich `order: 'projekt'`, `abNummer` (optional; ohne/ungültig nur Mails mit Vermerk „ungeprüft“) | wie heute | wie heute; gilt unabhängig vom Schalter | +| L0 | GET/PATCH `/api/admin/finanzen/einstellungen` | zusätzlich `steuernummer` | wie heute | 400 | + +Ist der Schalter für den Kunden aus: Admin-AB-Routen 404 mit `SCHALTER_AUS_MELDUNG`; Rechnungsarten `anzahlung`/`schluss` 400 mit derselben Meldung; Token- und Kundenrouten antworten wie bei unbekannter AB (404) bzw. `GET /api/auftraege` mit `{ aktiv: false, auftraege: [] }`. + +Einbindung (L0): in `server/index.js` **vor** `app.use('/api/admin/finanzen', adminFinanzenRoutes)`: `app.use('/api/admin/finanzen/projektkonten', adminProjektkontenRoutes)`, `app.use('/api/admin/finanzen/auftraege', adminAuftraegeRoutes)`; außerdem `app.use('/api/auftrag', publicAuftragRoutes)` und `app.use('/api/auftraege', kundeAuftraegeRoutes)`; in `server/routes/team/index.js` nach `requireStaffJwt`: `router.use('/arbeitsblaetter', arbeitsblaetterTeamRoutes)`. + +### V6 Mails (L5) + +Vorlagen in `server/mail-templates/` mit Kopfkommentar wie die vorhandenen (`renderMail`): +- `K31 Auftragsbestaetigung.html` – Betreff „Ihre Auftragsbestätigung {{nummer}} – {{titel}}“; Platzhalter `name, nummer, titel, datum, betrag, link, gueltig_bis`; Blöcke `erinnerung, privat, festpreis, aufwand, kundenmail, teamkopie` (`kundenmail` = Gegenblock zu `teamkopie`, weil die Vorlagen nur positive Blöcke kennen; die Teamkopie geht als eine Mail ohne Anhänge über `sendAdminNotice` nach der Kundenmail). Anhänge: AB-PDF (`.pdf`) und AGB-PDF (`public/rechtliches/agb-.pdf`, Dateiname `WEBklar-AGB-.pdf`). **Kein** BCC an das Team (`bccTeam: false`), weil die Mail den Bestätigungslink trägt; das Team bekommt eine Kopie mit Block `teamkopie` an `CHAT_NOTIFY_EMAILS` (statt Knopf: „Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.“), auch bei Erinnerungen. Anhang-Fassung: `auftrag.agbFassung` (Rückfall `AGB_FASSUNG`); fehlt die Datei, wirft der Versand (→ `mailStatus: 'fehler'`). +- `K32 Auftrag bestaetigt.html` – Betreff „Ihre Bestätigung zu {{nummer}} ist eingegangen“; Platzhalter `name, nummer, titel, zeitpunkt, fruehester_beginn, fruehbeginn_text`; Blöcke `fruehbeginn` (gibt `FRUEHBEGINN_TEXT` wörtlich wieder; kein Satz „Ihr Widerrufsrecht bleibt unberührt“), `ohne_fruehbeginn`. +- `T22 Auftrag bestaetigt.html` – Betreff „[Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}}“; Platzhalter `kunde, nummer, titel, zeitpunkt, ueber, woms_link, fruehester_beginn`; Blöcke `privat_fruehbeginn, privat_ohne_fruehbeginn`. `woms_link = /customers/?tab=invoices`. +- Link der K31: `/auftrag.html#t=` (Fragment: landet nicht in Server- und Proxy-Protokollen; die Seite liest `#t=` und als Rückfall `?t=`). +- `K04 Eingang Widerruf.html` und `T09 Widerruf eingegangen.html` bekommen je einen Block `projekt` (Vertragsart „Projektauftrag (Auftragsbestätigung )“; bei Unternehmen der Hinweis „kein gesetzliches Widerrufsrecht“ im K04-Block). + +### V7 Texte (`server/services/auftraege/texte.js`; L0 legt an, L7 formuliert) + +Exportnamen und Formen (verbindlich): + +```js +export const TEXTE_FASSUNG = '2026-10-06' +export const ABSENDER = { name: 'WEBklar GbR', strasse: 'Am Schwimmbad 10', plzOrt: '67722 Winnweiler', + telefon: '0176 23726355', email: 'support@webklar.com', web: 'webklar.com', vertretenDurch: 'Kenso Grimm und Justin Klein' } +export const AB_TEXTE = { + titel: 'Auftragsbestätigung', + einleitung: ({ datum, weg }) => string, // „Vielen Dank für Ihren Auftrag vom 05.10.2026 (telefonisch). Wir bestätigen ihn wie folgt:“ + aufwand: ({ satz, schaetzung /* '10' Stunden oder null */ }) => string, + kleinunternehmer: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.', + zahlungsplan: { + nach_abnahme: ({ ziel }) => string, anzahlung: ({ prozent, anzahlung, rest, ziel }) => string, + monatlich: ({ ziel }) => string, frei: ({ text, ziel }) => string, + }, + termine: ({ start /* 'TT.MM.JJJJ'|null */, fertig /* 'TT.MM.JJJJ'|null */, privat /* boolean */ }) => string|null, // privat: „Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.“ + mitwirkung: string, // Verweis auf AGB Nr. 7 und 8 + agb: ({ fassung /* 'TT.MM.JJJJ' */ }) => string, // „Es gelten unsere Allgemeinen Geschäftsbedingungen in der Fassung vom …, die dieser Bestätigung beiliegen.“ + ersetzt: ({ nummer, datum }) => string, // „Diese Auftragsbestätigung ersetzt AB-… vom ….“ + gruss: string, +} +export const BESTAETIGUNG_TEXT = 'Ich bestätige den Auftrag zu diesen Bedingungen.' +export const FRUEHBEGINN_FASSUNG = '2026-10-06' +export const FRUEHBEGINN_TEXT = string +export const WIDERRUFSBELEHRUNG_PROJEKT = { titel: 'Widerrufsbelehrung', abschnitte: [{ ueberschrift: string, absaetze: string[] }] } +export const WIDERRUFSFORMULAR = { titel: 'Muster-Widerrufsformular', einleitung: string, zeilen: string[] } +``` + +### V8 WOMS-Schnittstellen (W0/W1/W2) + +- `src/lib/financeApi.js` (W0 ergänzt): `getProjektkonten(kundeId)`, `getProjektkonto(projektId)`, `previewInvoice(body)`, `createInvoice(body)` (besteht, Körper erweitert), `listAuftraege(kundeId)`, `getAuftrag(id)`, `createAuftrag(body)`, `patchAuftrag(id, body)`, `deleteAuftrag(id)`, `sendAuftrag(id)`, `erinnereAuftrag(id)`, `neueFassungAuftrag(id)`, `storniereAuftrag(id)`, `getAuftragPdf(id) → Blob`. +- `src/lib/teamApi.js` (W0 ergänzt): `getAbgerechnet(ticketId) → { abgerechnet }`. +- `src/components/auftraege/AuftraegeUndRechnungen.jsx` (W0): Inhalt des Kundenreiters `invoices` (Beschriftung „Aufträge und Rechnungen“), Props `{ customer, onShowOverview }`; lädt `getProjektkonten` und (wenn `auftraegeAktiv`) `listAuftraege`; zeigt `ProjektkontoListe`, `AuftragListe` (nur mit Schalter), Rechnungsliste (bestehende `InvoiceTable`) und öffnet `RechnungDialog` bzw. `AuftragDialog`. +- `ProjektkontoListe.jsx` (W1): Props `{ konten, ohneProjekt, auftraegeAktiv, stripeOk, onRechnung(projektId|null), onAuftrag(projektId|null) }`; die Kindkomponenten rendern ihre `Card` selbst. +- `RechnungDialog.jsx` (W1): Props `{ open, onClose, onCreated(rechnung, info /* unverändert weiterreichen: senden, notice, tone, finalizedFilter */), kunde /* Kundendokument oder null = Kundenwahl */, projektId /* undefined = keine Vorauswahl, null = „Ohne Projekt“ */, auftraegeAktiv /* nur Startwert; danach gilt getProjektkonten */, onShowOverview /* optional */ }`; lädt `getProjektkonten` selbst; sitzt in `InvoiceTable.jsx` (dort ersetzt W1 das Modal) – `FinancePage.jsx` bleibt unverändert; ersetzt `InvoiceCreateModal` überall (auch `FinancePage`). +- `AuftragListe.jsx` (W2): Props `{ auftraege, konten, onChanged(), onBearbeiten(auftrag), onShowOverview }` (W0 reicht `konten` und `onShowOverview` durch); Aktion „Projekt zuordnen“ für ABs ohne Projekt (`ordneAuftragProjektZu(id, projektId)` in financeApi.js). +- `AuftragDialog.jsx` (W2): Props `{ open, onClose, onSaved(auftrag), kunde, projektId /* oder null */, auftrag /* Entwurf zum Bearbeiten oder null */, konten /* Projektkonten für die Projektwahl */, onShowOverview }`; Hinweise (nicht blockierend): fehlende Steuernummer im Schritt 4; bei Weg „vor Ort“ und Kundenart privat „zusätzlich ausdrucken und übergeben“ (§ 312f Abs. 1 BGB). PDF öffnen: `openInNewTab(async () => URL.createObjectURL(await getAuftragPdf(id)))`. +- Reine Ansichtslogik mit Tests: `src/lib/projektkontoView.js` und `src/lib/rechnungDialogView.js` (W1), `src/lib/auftragView.js` (W2). +- Je Strang eigene CSS-Datei: `src/components/auftraege/projektkonto.css`, `rechnung-dialog.css` (W1), `auftrag.css` (W2), Gestaltung nach den Regeln des Redesigns (Komponenten aus `src/components/ui`, `Dialog`, `financeUi.js`). +- Weitere Änderungen: W1 `InvoiceTable.jsx` (Spalten Projekt, Art; Modal → `RechnungDialog`; „weitere Tickets“ liest W1 direkt aus Appwrite und schickt `arbeitsblattIds`), `WorksheetList.jsx` (Abzeichen), `WorksheetTimesModal.jsx` (Warnung); W2 `CustomerDetailPage.jsx` (Feld Kundenart in der Übersicht), `SettingsCard.jsx` (Steuernummer); W0 `src/lib/customerTabs.js` (Beschriftung). + +--- + +## Integration, Kontrolle, Übergabe + +Plan-Datei `2026-10-06-auftraege-12-integration-kontrolle.md`: +- **Z1 Portal**: Merge in der Reihenfolge L1, L2, L3, L4, L7 (ohne seinen letzten Commit, den Datenschutz-Abschnitt: `git merge --no-ff auftraege/l7-rechtstexte~1`; der Datenschutz-Commit kommt erst beim AB-Livegang nach der Freigabe), L5, L6 nach `feature/auftraege` (`git merge --no-ff`), nach jedem Merge die Tests des Strangs; dann Durchlauf-Test `test/auftraegeAblauf.test.js` über `startTestApp` (Festbetrag → Projektkonto; Aufwand → abgerechnet → Storno → frei; AB → Senden → Token-Bestätigung → Anzahlung → Schlussrechnung mit Minuszeile → `abgerechnet` → Storno → zurück; Privatperson mit/ohne Frühbeginn; Kundenansicht gesperrt; Schalter aus → 404); volle Suite unter `flock`; Node-20-Lauf der `test/auftraege*.test.js` (`docker run --rm --network host -m 1200m -v ::ro node:20-alpine`). +- **Z2 WOMS**: Merge W1, W2; `npm test`; `vite build`; Bildschirmfotos (Desktop 1440 px, Handy 390 px) aller neuen Ansichten mit nachgebildeten Antworten. +- **Z3 Kontrolle**: fünf Prüfer parallel (sonnet) – Geld und Rechenlogik; Sicherheit und Datenschutz (Token, öffentliche Routen, Rechte, Pfade, IDOR, Kundenansicht); Recht und Texte (Spec, § 34a UStDV, § 312f BGB, Widerruf, Frühbeginn, Datenschutz); Oberfläche (Bildschirmfotos); Spec-Abdeckung (jede Zeile der Spec → Code/Test). Jeder Befund wird von einem zweiten Agenten (haiku) gegengeprüft; nur bestätigte Critical/Important werden korrigiert (sonnet), danach betroffene Tests und Z1-Durchlauf erneut. +- **Übergabe**: `/root/reports/auftraege-rechnungen-2026-10-06/uebergabe.md` mit Push-Befehlen (Portal `feature/auftraege:main`, WOMS erst nach dem Redesign), Setup-Befehl `docker exec -w /app webklar-kundenbereich-server node scripts/auftraege-setup.mjs`, Compose-Zeilen für `AUFTRAEGE_ENABLED`/`AUFTRAEGE_PILOT_EMAILS` (Vorgabe aus), Steuernummer (Stripe-Dashboard + WOMS-Einstellung), Rechtsrunde (Freigabe-Dokument), Live-Prüfungen. + +## Livegang (Kurzfassung, Details in der Übergabe) + +1. Nutzer: Steuernummer im Stripe-Dashboard hinterlegen. +2. Nutzer: Portal `feature/auftraege` → `main` pushen; sofort danach Setup-Skript; Container-Log prüfen. Rechnungsteil (Projektkonto, Festbetrag, Aufwand, A4, Zusatzfelder) ist live, AB-Teil aus. +3. Nutzer: WOMS pushen, sobald das Redesign auf `test` ist. +4. Rechtsrunde: Nutzer gibt die Texte frei; Claude lädt sie in WOMS → Rechtliches hoch (kurzlebiger Schlüssel). +5. `AUFTRAEGE_ENABLED=pilot` mit Testkunde, Probe-AB an den Testkunden, dann `on`. diff --git a/docs/superpowers/plans/2026-10-06-auftraege-01-fundament.md b/docs/superpowers/plans/2026-10-06-auftraege-01-fundament.md new file mode 100644 index 0000000..a703134 --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-auftraege-01-fundament.md @@ -0,0 +1,2185 @@ +# Fundament – Umsetzungsplan (Strang L0) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Das gemeinsame Fundament für „Aufträge und Rechnungen“ im Portal-Server: Abhängigkeiten pdfkit und fontkit (samt `.gitignore`-Eintrag für den `node_modules`-Symlink der Lane-Worktrees), Appwrite-Schema und Setup-Skript, Konfiguration und Pilot-Schalter, Konstanten und Fehlerklasse, Speicher der Auftragsbestätigungen und Projektrechnungen, Arbeitsblatt-Zugriff, Sichten, PDF-Ablage, Einstellung „Steuernummer“, Router-Gerüste samt Einbindung – so dass die Stränge L1–L7 danach parallel darauf aufbauen können. + +**Architecture:** Aller neue Server-Code liegt unter `server/services/auftraege/` in kleinen Modulen mit den Signaturen aus der Übersicht (V1–V7). Appwrite-Zugriffe laufen über `server/services/appwriteAdmin.js`, Tests über die Nachbauten aus `test/support/`. Die fünf Router-Gerüste tragen nur den Zugangsschutz; die Handler füllen später L1, L3 und L5. L0 liegt auf dem kritischen Pfad und committet direkt auf `feature/auftraege`. + +**Tech Stack:** Node 20.19 (Container) und 24 (Host), Express 4, ES-Module, Appwrite-REST über `appwriteAdmin.js`, pdfkit und fontkit (neu, hier nur als Abhängigkeiten), Tests mit `node --test` und `test/support/*`. + +**Spec:** `docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md` (Stand auf `feature/auftraege`, inklusive „Nachträge aus der Planrunde“ am Ende) + +**Übersicht/Verträge:** `docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md` (V1–V8 und der Abschnitt „Nachträge nach der Planrunde“ gelten wörtlich; wo dieser Plan davon abweicht, gilt die Übersicht; alle Vertragsfragen dieses Plans sind dort entschieden, siehe am Ende) + +**Worktree und Branch:** `/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege`, Branch `feature/auftraege` + +## Global Constraints + +Alle Punkte der Übersicht gelten. Zusätze für diesen Strang: + +- Nur die Dateien aus der „Dateiübersicht“ unten anfassen (dazu gehören `package.json`, `package-lock.json` und `.gitignore`). Bestehende Tests ändern: ausschließlich `test/financeSettings.test.js` (Task 12) und `test/support/schema.js` (Task 3). +- Namen, Signaturen und Datenformen exakt wie Übersicht V1–V7 samt „Nachträge nach der Planrunde“ (dort für L0 festgelegt: `sperreAuftrag` und zusammengesetzte Sperrschlüssel, `AUFTRAEGE_DATETIMES`, `entfernePdf`, `fontkit`, `.gitignore`, Steuernummer mit Buchstaben, `texte.kleinunternehmer`, `privat` bei `termine`). +- Wortlaute und Fassungen, die L7 ändert (`texte.js`, `AGB_FASSUNG`), werden in Tests **nie wörtlich** festgenagelt, nur Form und eingesetzte Werte. +- Programmfehler (ungültige Kennungen oder Typen an Speicherfunktionen) sind `TypeError` (die Route macht daraus 500). `AuftragError` mit Status nur für Fälle, die ein Nutzer verstehen soll (404, 409, 400 bei zu langem Text). +- Tests mit Appwrite: `startAppwriteMock({ schema, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })` und `useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })` (diese Tests brauchen kein Stripe). Config und Server-Module erst danach per `await import(...)` laden. +- Tests, die Dateien brauchen, legen ein Verzeichnis per `mkdtempSync(join(tmpdir(), '-'))` an; der Ausführende setzt `TMPDIR` auf sein Scratchpad, damit alles dort landet. Gelöscht wird nur dieses selbst angelegte Verzeichnis (Präfixprüfung vor `rmSync`). Dem Befehl in Task 11 dazu `TMPDIR=` voranstellen. +- Router-Tests prüfen nur, was auch nach dem Füllen der Router durch L1, L3 und L5 gilt (Zugangsschutz, unbekannte Tiefenpfade), damit diese Tests nach den Merges grün bleiben. +- Die Tasks stehen in Abhängigkeitsreihenfolge (Task 7 braucht 2, 3 und 4; Task 9 braucht 8; Task 10 braucht 6); nicht umstellen. +- Jeder Testlauf: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/`. Mehrere Dateien in einem Aufruf nur mit `--test-concurrency=1`. Die volle Suite läuft nicht in L0 (nur Z1). + +## Review Focus + +1. **Arbeitsblatt-Datum:** `TT.MM.JJJJ`, ISO-Zeitpunkt UTC kurz vor und nach Mitternacht Berlin, leeres Datum (Rückgriff auf `$createdAt`), unmögliches Datum („31.02.2026“): immer der richtige Berliner Kalendertag, nie ein Wurf. Test: Task 9, „Datum eines Arbeitsblatts“. +2. **Arbeitsblatt-Zeit:** 10 Minuten werden 15, 80 Minuten plus eine unterstützende Person werden 135, 0 Minuten und `totalTime: 'abc'` ergeben 0; Abo-Zeit, Kommentare und 0 Minuten sind nie abrechenbar. Test: Task 9, „Minuten“ und „abrechenbar“. +3. **Abgerechnet nur aus aktiven Projektrechnungen:** ein stornierter Eintrag gibt seine Blätter frei, ein Entwurf sperrt sie (Rechnungsnummer leer), fremde Kunden bleiben unberührt. Tests: Task 8 (aktiv-Filter, `umhaengen`), Task 9 (`abgerechneteArbeitsblaetter`, `offeneArbeitsblaetter`). +4. **Leere oder ungültige Schlüssel treffen nie „alles“ oder fremde Dateien:** `findAuftragByTokenHash('')`, `findAuftragByNummer('')` (Entwürfe haben beides leer), Listenfilter mit leerem Kunden, Rechnungs-IDs wie `../x`, PDF-Pfade wie `c1/../geheim.pdf`, Überschreiben einer vorhandenen AB-Datei. Tests: Task 7, Task 8, Task 11. +5. **Pilot-Schalter:** `*` lässt alle durch, `off` schlägt `*`, Groß- und Kleinschreibung und Leerraum der E-Mail-Adresse, leere Liste lässt niemanden durch. Test: Task 4. + +## Dateiübersicht + +| Datei | Aufgabe | Task | +|---|---|---| +| `package.json`, `package-lock.json`, `.gitignore` | pdfkit, fontkit, `node_modules` auch als Symlink ignorieren | 1 | +| `server/services/auftraege/konstanten.js` | Konstanten (V2) | 2 | +| `server/services/auftraege/fehler.js` | `AuftragError`, `sendeFehler` | 2 | +| `server/services/auftraege/schema.js` | Collections und Zusatzfelder (V1) | 3 | +| `test/support/schema.js` | `schemaKeys` kennt die neuen Collections und die Steuernummer | 3 | +| `server/config.js` | `collections.auftraege`, `collections.projektRechnungen`, `config.auftraege` | 4 | +| `server/services/auftraege/schalter.js` | `auftraegeAktiv` | 4 | +| `scripts/auftraege-setup.mjs` | Setup der Collections und Zusatzfelder | 5 | +| `server/services/auftraege/texte.js` | vorläufige Textbausteine (V7) | 6 | +| `server/services/auftraege/auftragStore.js` | Speicher der ABs | 7 | +| `server/services/auftraege/projektRechnungenStore.js` | Speicher der Projektrechnungen | 8 | +| `server/services/auftraege/arbeitsblaetter.js` | Arbeitsblätter laden und abgleichen | 9 | +| `server/services/auftraege/auftragSicht.js` | Sichten auf eine AB | 10 | +| `server/services/auftraege/ablage.js` | PDF-Ablage | 11 | +| `server/services/finance/settings.js`, `test/financeSettings.test.js` | Einstellung `steuernummer` | 12 | +| `server/routes/admin/projektkonten.js`, `server/routes/admin/auftraege.js`, `server/routes/team/arbeitsblaetter.js`, `server/routes/auftrag.js`, `server/routes/auftraegeKunde.js`, `server/index.js`, `server/routes/team/index.js` | Router-Gerüste und Einbindung | 13 | +| `test/auftraege.test.js` (elf Dateien, je Task genannt) | Tests | 2–13 | + +--- + +### Task 1: Arbeitsumgebung, pdfkit, fontkit und .gitignore + +**Files:** +- Modify: `package.json`, `package-lock.json` (nur pdfkit, fontkit und deren neue Abhängigkeiten kommen dazu), `.gitignore` (Zeile `node_modules/` wird `node_modules`) +- Anlegen, unversioniert (steht in `.gitignore`): `node_modules/` als **echtes Verzeichnis** + +**Interfaces:** +- Produces: echtes `node_modules` im Worktree (die Lane-Worktrees verlinken es später mit `ln -s ../dev-auftraege/node_modules node_modules`); `.gitignore` ignoriert `node_modules` ohne Schrägstrich, also auch diesen Symlink (mit Schrägstrich träfe die Regel nur Verzeichnisse und der Symlink stünde in jedem `git status`); Abhängigkeit `pdfkit` (`import PDFDocument from 'pdfkit'` liefert eine Funktion, für L4); Abhängigkeit `fontkit` direkt in `package.json`, mit derselben Versionsangabe, die pdfkit für `fontkit` verlangt (L4 prüft damit die Glyphen und soll sich nicht auf eine mitinstallierte Abhängigkeit verlassen). + +- [ ] **Step 1: Arbeitsspeicher prüfen** + +Run: `free -m` +Expected: „available“ mindestens 1500 MB; darunter warten und erneut prüfen, kein `npm ci` vorher. + +- [ ] **Step 2: node_modules anlegen** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock nice -n 10 env NODE_OPTIONS=--max-old-space-size=1024 npm ci --no-audit --no-fund +``` + +Expected: Exit 0. Danach `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && test -d node_modules && ! test -L node_modules && echo echt` gibt `echt` aus, und `git status --short` zeigt nichts außer Plan- und Spezifikationsdateien unter `docs/superpowers/` (unversioniert oder vom Orchestrator geändert; sie gehören nicht zu L0). Scheitert `npm ci`, nicht auf `npm install` ausweichen, sondern dem Orchestrator melden. + +- [ ] **Step 3: Versionen bestimmen** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && npm view pdfkit version engines dependencies +``` + +Regel für pdfkit: Die ausgegebene `version` ist die neueste. Sie wird genommen, wenn `engines.node` fehlt oder Node 20.19 zulässt und die Abhängigkeiten reines JavaScript sind (kein `node-gyp`, keine nativen Pakete). Sonst die neueste ältere stabile Version mit dieser Eigenschaft wählen (`npm view pdfkit versions --json`) und für diese Version `npm view pdfkit@ dependencies` lesen. Keine Vorabversionen. Die gewählte Version heißt unten ``. Regel für fontkit: `` ist die Versionsangabe, die in `dependencies` von pdfkit bei `fontkit` steht (z. B. `^2.0.4`), unverändert. Fehlt `fontkit` dort, nichts raten, sondern dem Orchestrator melden. + +- [ ] **Step 4: pdfkit und fontkit eintragen** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock nice -n 10 env NODE_OPTIONS=--max-old-space-size=1024 npm install pdfkit@ fontkit@ --save --no-audit --no-fund +``` + +- [ ] **Step 5: .gitignore ändern** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && sed -i 's#^node_modules/$#node_modules#' .gitignore && git diff .gitignore +``` + +Expected: genau eine Zeile geändert, `-node_modules/` und `+node_modules` (Zeile 1; alle anderen Zeilen unverändert). + +- [ ] **Step 6: Prüfen** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && node -e "import('pdfkit').then(m=>console.log(typeof m.default))" +``` + +Expected: `function`. + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && node -e "import('fontkit').then(m=>console.log(typeof (m.create || m.default?.create)))" +``` + +Expected: `function`. + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git check-ignore -v node_modules; echo "exit=$?"; git status --short; git diff --stat; git diff package.json; git diff --numstat package-lock.json +``` + +Expected: `git check-ignore -v node_modules` nennt `.gitignore:1:node_modules` und endet mit `exit=0`; `git status --short` zeigt `node_modules` nicht (nur Plan- und Spezifikationsdateien unter `docs/superpowers/`) und sonst nur die drei geänderten Dateien `.gitignore`, `package.json`, `package-lock.json`; in `package.json` genau zwei neue Zeilen in `dependencies`: `"fontkit": ""` (bei einer exakten Version in pdfkit setzt npm ein `^` davor; zwischen `feiertagejs` und `jose`) und `"pdfkit": "^"` (zwischen `nodemailer` und `sharp`); in `package-lock.json` steht in der zweiten Spalte der numstat `0` (keine entfernten Zeilen). Weicht der Diff ab, den Diff lesen: erlaubt sind nur Einträge von pdfkit, fontkit und deren neuen Abhängigkeiten; alles andere zurücknehmen (`git checkout package-lock.json package.json`, Schritt 4 wiederholen) und dem Orchestrator melden. + +- [ ] **Step 7: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add package.json package-lock.json .gitignore && git commit -m "Aufträge: pdfkit und fontkit als Abhängigkeiten, node_modules auch als Symlink ignorieren" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Konstanten und Fehlerklasse + +**Files:** +- Create: `server/services/auftraege/konstanten.js`, `server/services/auftraege/fehler.js` +- Test: `test/auftraegeKonstanten.test.js` + +**Interfaces:** +- Consumes: `InvoiceError` aus `server/services/finance/stripeFinance.js` (nur in `sendeFehler`). +- Produces: alle Exporte aus Übersicht V2 (`RECHNUNGS_ARTEN`, `AB_RECHNUNGS_ARTEN`, `AUFTRAG_ARTEN`, `AUFTRAG_STATUS`, `VEREINBARTE_STATUS`, `AUFTRAG_WEGE`, `ZAHLUNGSPLAN_ARTEN`, `KUNDENARTEN`, `STUNDENSATZ_STANDARD_CENTS`, `VIERTELSTUNDE`, `TOKEN_GUELTIG_TAGE`, `AGB_FASSUNG`, `LIMITS`, `SPERRE_NUMMER = ['auftraege-nummer']`, `sperreRechnung(kundenId) → ['auftraege-rechnung:']`, `sperreAuftrag(auftragId) → ['auftraege-ab:']`, `SCHALTER_AUS_MELDUNG`); jede Sperre ist ein Array mit genau einem zusammengesetzten Schlüssel, weil `withLocks` jedes Listenelement einzeln sperrt; `class AuftragError extends Error { constructor(status, message) }` (Eigenschaft `status`); `sendeFehler(res, was, err)`. + +- [ ] **Step 1: Failing Test schreiben** + +```js +// test/auftraegeKonstanten.test.js +import { test, mock } from 'node:test' +import assert from 'node:assert/strict' +import * as K from '../server/services/auftraege/konstanten.js' +import { AuftragError, sendeFehler } from '../server/services/auftraege/fehler.js' +import { InvoiceError } from '../server/services/finance/stripeFinance.js' + +test('Konstanten stehen wie in Übersicht V2', () => { + assert.deepEqual(K.RECHNUNGS_ARTEN, ['frei', 'festbetrag', 'aufwand', 'anzahlung', 'schluss']) + assert.deepEqual(K.AB_RECHNUNGS_ARTEN, ['anzahlung', 'schluss']) + assert.deepEqual(K.AUFTRAG_ARTEN, ['festpreis', 'aufwand']) + assert.deepEqual(K.AUFTRAG_STATUS, ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen']) + assert.deepEqual(K.VEREINBARTE_STATUS, ['versendet', 'bestaetigt', 'abgerechnet']) + assert.deepEqual(K.AUFTRAG_WEGE, { muendlich: 'mündlich', telefon: 'telefonisch', email: 'per E-Mail', vor_ort: 'vor Ort', chat: 'im Chat' }) + assert.deepEqual(K.ZAHLUNGSPLAN_ARTEN, ['nach_abnahme', 'anzahlung', 'monatlich', 'frei']) + assert.deepEqual(K.KUNDENARTEN, ['unternehmen', 'privat']) + assert.equal(K.STUNDENSATZ_STANDARD_CENTS, 10000) + assert.equal(K.VIERTELSTUNDE, 15) + assert.equal(K.TOKEN_GUELTIG_TAGE, 60) + assert.match(K.AGB_FASSUNG, /^\d{4}-\d{2}-\d{2}$/) // den Wert legt L7 fest + assert.deepEqual(K.LIMITS, { + maxPositionen: 20, maxBeschreibung: 200, maxMenge: 999, maxEinzelpreisCents: 10_000_000, + maxSummeCents: 99_999_999, maxTitel: 200, maxZahlungsplanText: 500, maxZahlungszielTage: 60, + standardZahlungszielTage: 14, standardAnzahlungProzent: 50, maxZeitquellen: 10, + maxSchaetzungMinuten: 99_960, maxLeistungszeitraum: 140, + }) + // withLocks sperrt jedes Listenelement einzeln: je Sperre genau ein zusammengesetzter Schlüssel (verschiedene Kunden und ABs blockieren sich nicht) + assert.deepEqual(K.SPERRE_NUMMER, ['auftraege-nummer']) + assert.deepEqual(K.sperreRechnung('c1'), ['auftraege-rechnung:c1']) + assert.deepEqual(K.sperreAuftrag('ab1'), ['auftraege-ab:ab1']) + assert.equal(K.SCHALTER_AUS_MELDUNG, 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.') + for (const status of K.VEREINBARTE_STATUS) assert.ok(K.AUFTRAG_STATUS.includes(status), status) + for (const art of K.AB_RECHNUNGS_ARTEN) assert.ok(K.RECHNUNGS_ARTEN.includes(art), art) +}) + +const fakeRes = () => ({ + statusCode: 200, + body: undefined, + status(code) { + this.statusCode = code + return this + }, + json(body) { + this.body = body + return this + }, +}) + +test('AuftragError trägt Status und Meldung; ein Status außerhalb 400 bis 599 ist ein Programmfehler', () => { + const err = new AuftragError(409, 'Arbeitsblatt bereits abgerechnet (WEBKLAR-0009).') + assert.ok(err instanceof Error) + assert.equal(err.name, 'AuftragError') + assert.equal(err.status, 409) + assert.equal(err.message, 'Arbeitsblatt bereits abgerechnet (WEBKLAR-0009).') + for (const status of [200, 399, 600, 404.5, '404', undefined, null]) { + assert.throws(() => new AuftragError(status, 'x'), TypeError, String(status)) + } +}) + +test('sendeFehler gibt AuftragError und InvoiceError mit Status und Meldung weiter', () => { + const a = fakeRes() + sendeFehler(a, 'AB senden', new AuftragError(400, 'Kundenart fehlt.')) + assert.deepEqual([a.statusCode, a.body], [400, { error: 'Kundenart fehlt.' }]) + const b = fakeRes() + sendeFehler(b, 'Rechnung anlegen', new InvoiceError(502, 'Stripe ist gerade nicht erreichbar.')) + assert.deepEqual([b.statusCode, b.body], [502, { error: 'Stripe ist gerade nicht erreichbar.' }]) +}) + +test('sendeFehler: alles andere wird 500 „Interner Fehler.“, das Protokoll nennt nie die Meldung', () => { + const log = mock.method(console, 'error', () => {}) + try { + const appwrite = Object.assign(new Error('Appwrite: Key sk_live_geheim abgelehnt'), { status: 400, type: 'user_unauthorized' }) + for (const err of [appwrite, 'sk_live_geheim', undefined, null]) { + const res = fakeRes() + sendeFehler(res, 'Projektkonto laden', err) + assert.deepEqual([res.statusCode, res.body], [500, { error: 'Interner Fehler.' }]) + } + assert.equal(log.mock.calls.length, 4) + const zeilen = JSON.stringify(log.mock.calls.map((call) => call.arguments)) + assert.ok(!zeilen.includes('geheim'), zeilen) + assert.ok(zeilen.includes('Projektkonto laden') && zeilen.includes('user_unauthorized'), zeilen) + } finally { + log.mock.restore() + } +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeKonstanten.test.js` +Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `server/services/auftraege/konstanten.js`. + +- [ ] **Step 3: Implementieren** + +- `server/services/auftraege/konstanten.js` (neu, ohne Importe): genau die Exporte des Blocks „V2 Konstanten“ der Übersicht, mit denselben Namen, Werten und Reihenfolge, wörtlich übernommen (einschließlich der Sperren `SPERRE_NUMMER`, `sperreRechnung`, `sperreAuftrag` mit je einem zusammengesetzten Schlüssel und deren Kommentaren). `AGB_FASSUNG` steht auf einer eigenen Zeile mit dem Kommentar `// L7 darf diese eine Zeile anpassen`. Kopfkommentar: gemeinsame Datei, nur L0 ändert sie (Ausnahme `AGB_FASSUNG`). +- `server/services/auftraege/fehler.js` (neu): + - `export class AuftragError extends Error`, `constructor(status, message)`: `status` muss eine ganze Zahl von 400 bis 599 sein, sonst `TypeError`; setzt `this.name = 'AuftragError'` und `this.status`. + - `export function sendeFehler(res, was, err)`: ist `err` eine `AuftragError` oder `InvoiceError` (Import aus `../finance/stripeFinance.js`, nur **innerhalb** der Funktion benutzen, nie beim Laden des Moduls; `stripeFinance.js` importiert ab L2 selbst Module dieses Verzeichnisses, so bleibt der Importzyklus harmlos), dann `res.status(err.status).json({ error: err.message })`. Alles andere, auch Fehler mit `status` (Appwrite, Stripe), wird mit `console.error('[auftraege] fehlgeschlagen:', err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '')` protokolliert (nie mit der Meldung) und als `res.status(500).json({ error: 'Interner Fehler.' })` beantwortet. + +- [ ] **Step 4: Lauf, erwartet PASS** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeKonstanten.test.js` +Expected: PASS (alle vier Tests, `fail 0`). + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/konstanten.js server/services/auftraege/fehler.js test/auftraegeKonstanten.test.js && git commit -m "Aufträge: Konstanten und Fehlerklasse (L0)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Appwrite-Schema und Test-Support + +**Files:** +- Create: `server/services/auftraege/schema.js` +- Modify: `test/support/schema.js` +- Test: `test/auftraegeSchema.test.js` + +**Interfaces:** +- Produces: `AUFTRAEGE_COLLECTIONS` (`{ auftraege, projektRechnungen }`, je `{ attributes, indexes }`) und `AUFTRAEGE_ZUSATZ_ATTRIBUTE` (`{ customers: [kundenart], financeSettings: [steuernummer] }`) wörtlich wie Übersicht V1; zusätzlich `AUFTRAEGE_DATETIMES` (`{ collection: [datetime-Schlüssel] }`, wie `ZEITEN_DATETIMES`, für `startAppwriteMock({ datetimes })`). `schemaKeys('auftraege')`, `schemaKeys('projektRechnungen')` und `schemaKeys('financeSettings')` (jetzt mit `steuernummer`) in `test/support/schema.js`. + +- [ ] **Step 1: Failing Test schreiben** + +```js +// test/auftraegeSchema.test.js +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { AUFTRAEGE_COLLECTIONS, AUFTRAEGE_DATETIMES, AUFTRAEGE_ZUSATZ_ATTRIBUTE } from '../server/services/auftraege/schema.js' +import { schemaKeys } from './support/schema.js' + +// Spalten aus Übersicht V1 in Kurzschrift: sN = Text mit Größe N, i = ganze Zahl, b = Wahrheitswert, d = Datum und Zeit +const SPALTEN = { + auftraege: `nummer:s20 jahr:i laufnummer:i status:s20 kundenId:s64 kundeJson:s2000 projektId:s64 titel:s200 + zeitquelleJson:s1000 art:s20 positionenJson:s20000 stundensatzCents:i schaetzungMinuten:i summeCents:i + zahlungsplanJson:s1000 zahlungszielTage:i auftragVom:s10 auftragWeg:s20 startAm:s10 fertigBis:s10 + texteFassung:s20 agbFassung:s20 pdfDatei:s300 pdfSha256:s64 gesendetAm:d gesendetAn:s255 mailStatus:s20 + mailFehler:s500 tokenHash:s64 tokenGueltigBis:d bestaetigtAm:d bestaetigtUeber:s10 bestaetigtPdfSha256:s64 + fruehbeginn:b fruehbeginnFassung:s20 ersetztId:s64 ersetztDurchId:s64 storniertAm:d widerrufenAm:d + erstelltVon:s255 erstelltAm:d geaendertAm:d`, + projektRechnungen: `invoiceId:s64 kundenId:s64 projektId:s64 auftragId:s64 art:s20 betragCents:i + arbeitsblattIdsJson:s20000 rechnungsnummer:s64 aktiv:b erstelltVon:s255 erstelltAm:d`, +} +function attribut(kurz) { + const [key, art] = kurz.split(':') + if (art === 'i') return { key, type: 'integer', required: false } + if (art === 'b') return { key, type: 'boolean', required: false } + if (art === 'd') return { key, type: 'datetime', required: false } + return { key, type: 'string', size: Number(art.slice(1)), required: false } +} +const erwartet = (name) => SPALTEN[name].trim().split(/\s+/).map(attribut) + +test('zwei Collections mit den Spalten aus Übersicht V1', () => { + assert.deepEqual(Object.keys(AUFTRAEGE_COLLECTIONS), ['auftraege', 'projektRechnungen']) + assert.deepEqual(AUFTRAEGE_COLLECTIONS.auftraege.attributes, erwartet('auftraege')) + assert.deepEqual(AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes, erwartet('projektRechnungen')) + assert.equal(AUFTRAEGE_COLLECTIONS.auftraege.attributes.length, 42) + assert.equal(AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes.length, 11) +}) + +test('Indizes und Zusatzfelder wie in Übersicht V1', () => { + assert.deepEqual(AUFTRAEGE_COLLECTIONS.auftraege.indexes, [ + { key: 'idx_kunde', type: 'key', attributes: ['kundenId'] }, + { key: 'idx_projekt', type: 'key', attributes: ['projektId'] }, + { key: 'idx_jahr', type: 'key', attributes: ['jahr'] }, + { key: 'idx_token', type: 'key', attributes: ['tokenHash'] }, + { key: 'idx_nummer', type: 'key', attributes: ['nummer'] }, + ]) + assert.deepEqual(AUFTRAEGE_COLLECTIONS.projektRechnungen.indexes, [ + { key: 'idx_kunde', type: 'key', attributes: ['kundenId'] }, + { key: 'idx_projekt', type: 'key', attributes: ['projektId'] }, + { key: 'idx_auftrag', type: 'key', attributes: ['auftragId'] }, + ]) + assert.deepEqual(AUFTRAEGE_ZUSATZ_ATTRIBUTE, { + customers: [{ key: 'kundenart', type: 'string', size: 20, required: false }], + financeSettings: [{ key: 'steuernummer', type: 'string', size: 40, required: false }], + }) +}) + +test('AUFTRAEGE_DATETIMES nennt je Collection genau die Datumsfelder', () => { + assert.deepEqual(AUFTRAEGE_DATETIMES, { + auftraege: ['gesendetAm', 'tokenGueltigBis', 'bestaetigtAm', 'storniertAm', 'widerrufenAm', 'erstelltAm', 'geaendertAm'], + projektRechnungen: ['erstelltAm'], + }) +}) + +test('schemaKeys kennt die neuen Collections und die Steuernummer; Bestehendes bleibt', () => { + assert.deepEqual(schemaKeys('auftraege'), AUFTRAEGE_COLLECTIONS.auftraege.attributes.map((a) => a.key)) + assert.deepEqual(schemaKeys('projektRechnungen'), AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes.map((a) => a.key)) + const einstellungen = schemaKeys('financeSettings') + for (const key of ['steuernummer', 'bankDetailsText', 'taxReservePercent']) assert.ok(einstellungen.includes(key), key) + assert.ok(!schemaKeys('financeFixedCosts').includes('steuernummer')) + assert.ok(schemaKeys('portalFeatures').includes('unlockedBy') && schemaKeys('termineConfig').includes('einstellung')) + assert.throws(() => schemaKeys('gibtEsNicht'), /fehlt im Setup-Skript/) +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeSchema.test.js` +Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `server/services/auftraege/schema.js`. + +- [ ] **Step 3: Implementieren** + +- `server/services/auftraege/schema.js` (neu): reine Daten, **kein** Import von `server/config.js` (Begründung im Kopfkommentar wie in `server/services/zeiten/schema.js`: `test/support/schema.js` liest die Datei vor `useTestEnv()`). Modulprivate Helfer `str(key, size)`, `int(key)`, `bool(key)`, `dt(key)`, `index(key, ...attrs)` wie dort (alle Attribute `required: false`). Exporte `AUFTRAEGE_COLLECTIONS` und `AUFTRAEGE_ZUSATZ_ATTRIBUTE` wörtlich aus Übersicht V1. Dazu `export const AUFTRAEGE_DATETIMES = Object.fromEntries(Object.entries(AUFTRAEGE_COLLECTIONS).map(([name, { attributes }]) => [name, attributes.filter((a) => a.type === 'datetime').map((a) => a.key)]))`. Kommentar an `idx_nummer`: bewusst kein Unique-Index, weil Entwürfe `nummer: ''` haben; die Eindeutigkeit stellt `vergibNummer` unter `SPERRE_NUMMER` her (L3). +- `test/support/schema.js` (drei Änderungen): (1) Import von `AUFTRAEGE_COLLECTIONS` und `AUFTRAEGE_ZUSATZ_ATTRIBUTE` aus `../../server/services/auftraege/schema.js`; (2) am Anfang von `schemaKeys`: `if (Object.hasOwn(AUFTRAEGE_COLLECTIONS, collection)) return AUFTRAEGE_COLLECTIONS[collection].attributes.map((a) => a.key)`; (3) in der Schleife über `SETUP_SCRIPTS` für jede Collection außer `portalFeatures` die Schlüssel aus `AUFTRAEGE_ZUSATZ_ATTRIBUTE` anhängen (nur wenn `Object.hasOwn(AUFTRAEGE_ZUSATZ_ATTRIBUTE, collection)`; das trifft `financeSettings`). Den Kommentar über der Funktion um die beiden Quellen ergänzen. `schemaKeys('customers')` bleibt ohne Schema (wie bisher wirft es): Tests mit `customers` starten den Nachbau ohne Schema für diese Collection. + +- [ ] **Step 4: Lauf, erwartet PASS** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/auftraegeSchema.test.js test/zeitenSetup.test.js test/financeSetup.test.js` +Expected: PASS (`fail 0`); die beiden bestehenden Dateien belegen, dass `schemaKeys` für Zeiten und Finanzen unverändert arbeitet. + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/schema.js test/support/schema.js test/auftraegeSchema.test.js && git commit -m "Aufträge: Appwrite-Schema der Collections auftraege und projektRechnungen" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Konfiguration und Pilot-Schalter + +**Files:** +- Modify: `server/config.js` +- Create: `server/services/auftraege/schalter.js` +- Test: `test/auftraegeSchalter.test.js` + +**Interfaces:** +- Produces: `config.collections.auftraege` (`APPWRITE_COLLECTION_AUFTRAEGE`, Vorgabe `'auftraege'`), `config.collections.projektRechnungen` (`APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN`, Vorgabe `'projektRechnungen'`), `config.auftraege = { mode, pilotEmails, uploadDir }`; `auftraegeAktiv(kunde) → boolean` (`kunde`: Kundendokument oder `{ email }`). + +- [ ] **Step 1: Failing Test schreiben** + +```js +// test/auftraegeSchalter.test.js +import { test, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import path from 'node:path' +import { config } from '../server/config.js' +import { auftraegeAktiv } from '../server/services/auftraege/schalter.js' + +const ENV = ['AUFTRAEGE_ENABLED', 'AUFTRAEGE_PILOT_EMAILS', 'AUFTRAEGE_UPLOAD_DIR', 'APPWRITE_COLLECTION_AUFTRAEGE', 'APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN'] + +let original +beforeEach(() => { + original = { ...config.auftraege } +}) +afterEach(() => { + Object.assign(config.auftraege, original) +}) + +/** Frische config.js-Instanz mit genau diesen Umgebungsvariablen (alle anderen entfernt). */ +let freshCounter = 0 +async function freshConfig(env = {}) { + const saved = Object.fromEntries(ENV.map((key) => [key, process.env[key]])) + for (const key of ENV) delete process.env[key] + Object.assign(process.env, env) + try { + freshCounter += 1 + return (await import(`../server/config.js?auftraege-test=${freshCounter}`)).config + } finally { + for (const key of ENV) { + if (saved[key] === undefined) delete process.env[key] + else process.env[key] = saved[key] + } + } +} + +test('Vorgabe: aus, keine Pilotadressen, Ablage unter upload-data/auftraege, Collection-IDs wie die Namen', async () => { + const fresh = await freshConfig() + assert.deepEqual(fresh.auftraege, { mode: 'off', pilotEmails: [], uploadDir: path.join(process.cwd(), 'upload-data', 'auftraege') }) + assert.equal(fresh.collections.auftraege, 'auftraege') + assert.equal(fresh.collections.projektRechnungen, 'projektRechnungen') +}) + +test('AUFTRAEGE_ENABLED: leer und Unbekanntes sind off, " ON " ist on, " Pilot " ist pilot mit bereinigter Liste', async () => { + for (const value of ['', ' ', 'banane', 'true', '1']) { + assert.equal((await freshConfig({ AUFTRAEGE_ENABLED: value })).auftraege.mode, 'off', JSON.stringify(value)) + } + assert.equal((await freshConfig({ AUFTRAEGE_ENABLED: ' ON ' })).auftraege.mode, 'on') + const fresh = await freshConfig({ AUFTRAEGE_ENABLED: ' Pilot ', AUFTRAEGE_PILOT_EMAILS: ' Kenso.Gri@Gmail.com, ,justin@webklar.com ' }) + assert.equal(fresh.auftraege.mode, 'pilot') + assert.deepEqual(fresh.auftraege.pilotEmails, ['kenso.gri@gmail.com', 'justin@webklar.com']) +}) + +test('Ablage und Collection-IDs lassen sich per Umgebung ändern', async () => { + const fresh = await freshConfig({ + AUFTRAEGE_UPLOAD_DIR: '/tmp/ab-test', + APPWRITE_COLLECTION_AUFTRAEGE: 'abX', + APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN: 'prX', + }) + assert.equal(fresh.auftraege.uploadDir, '/tmp/ab-test') + assert.deepEqual([fresh.collections.auftraege, fresh.collections.projektRechnungen], ['abX', 'prX']) +}) + +test('off sperrt alle, auch mit Stern in der Liste; ein unbekannter Modus gilt als off', () => { + config.auftraege.mode = 'off' + config.auftraege.pilotEmails = ['*', 'kenso.gri@gmail.com'] + assert.equal(auftraegeAktiv({ email: 'kenso.gri@gmail.com' }), false) + assert.equal(auftraegeAktiv({ email: 'jemand@example.com' }), false) + for (const value of [undefined, null, '', 'banane', 'PILOT']) { + config.auftraege.mode = value + assert.equal(auftraegeAktiv({ email: 'kenso.gri@gmail.com' }), false, String(value)) + } +}) + +test('pilot: nur Adressen der Liste, ohne Beachtung von Groß- und Kleinschreibung und Leerraum', () => { + config.auftraege.mode = 'pilot' + config.auftraege.pilotEmails = ['a@b.de'] + assert.equal(auftraegeAktiv({ email: 'A@b.de' }), true) + assert.equal(auftraegeAktiv({ email: ' a@b.de ' }), true) + assert.equal(auftraegeAktiv({ email: 'x@y.de' }), false) + assert.equal(auftraegeAktiv({ email: 'a@b.de.evil.de' }), false) + for (const kunde of [undefined, null, {}, { email: '' }, { email: ' ' }]) assert.equal(auftraegeAktiv(kunde), false) +}) + +test('pilot mit Stern lässt alle durch, auch ohne Adresse; eine leere Liste niemanden', () => { + config.auftraege.mode = 'pilot' + config.auftraege.pilotEmails = ['*'] + assert.equal(auftraegeAktiv({ email: 'x@y.de' }), true) + assert.equal(auftraegeAktiv(null), true) + config.auftraege.pilotEmails = [] + assert.equal(auftraegeAktiv({ email: 'a@b.de' }), false) +}) + +test('on gilt für alle; ein Kundendokument zählt wie { email }', () => { + config.auftraege.mode = 'on' + config.auftraege.pilotEmails = [] + assert.equal(auftraegeAktiv({ email: 'x@y.de' }), true) + assert.equal(auftraegeAktiv(undefined), true) + config.auftraege.mode = 'pilot' + config.auftraege.pilotEmails = ['k@example.invalid'] + assert.equal(auftraegeAktiv({ $id: 'c1', email: 'k@example.invalid', name: 'Kunde Eins' }), true) +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeSchalter.test.js` +Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `server/services/auftraege/schalter.js`. + +- [ ] **Step 3: Implementieren** + +- `server/config.js`, drei Einfügungen (neben den Zeiten-Gegenstücken): + 1. nach `const zeitenEnabled = ...`: `const auftraegeEnabled = (process.env.AUFTRAEGE_ENABLED || '').trim().toLowerCase()` + 2. in `collections` nach `zeitenErinnerungen`, mit Kommentar „Aufträge und Rechnungen (Auftragsbestätigungen, Projektrechnungen; scripts/auftraege-setup.mjs)“: + ```js + auftraege: process.env.APPWRITE_COLLECTION_AUFTRAEGE || 'auftraege', + projektRechnungen: process.env.APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN || 'projektRechnungen', + ``` + 3. nach dem Block `zeiten: { ... },` und vor `invoiceNinja`, mit Kommentar „Aufträge und Rechnungen: Schalter nur für die AB-Funktionen und die Rechnungsarten anzahlung/schluss; wird bei jedem Aufruf gelesen, siehe server/services/auftraege/schalter.js“: + ```js + auftraege: { + // off (Vorgabe) | pilot (nur AUFTRAEGE_PILOT_EMAILS, '*' = alle) | on + mode: ['pilot', 'on'].includes(auftraegeEnabled) ? auftraegeEnabled : 'off', + pilotEmails: commaList(process.env.AUFTRAEGE_PILOT_EMAILS, []), + // Gesendete AB-PDFs: //.pdf + uploadDir: process.env.AUFTRAEGE_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'auftraege'), + }, + ``` +- `server/services/auftraege/schalter.js` (neu): importiert `config` aus `../../config.js`, exportiert nur `auftraegeAktiv(kunde)`. Regeln wie `zeitenFuerKunde` in `server/services/zeiten/schalter.js`, gelesen wird `config.auftraege` bei jedem Aufruf: Modus `on` → `true`; Modus `pilot` → `true`, wenn die Liste `'*'` enthält, sonst wenn die kleingeschriebene, getrimmte E-Mail-Adresse (`kunde?.email`) nicht leer ist und in der Liste steht; jeder andere Modus (auch ein unbekannter Wert) → `false`. Der Kopfkommentar nennt: betrifft nur die AB-Funktionen und die Rechnungsarten `anzahlung`/`schluss`. + +- [ ] **Step 4: Lauf, erwartet PASS** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/auftraegeSchalter.test.js test/zeitenSchalter.test.js` +Expected: PASS (`fail 0`); `zeitenSchalter` belegt, dass die Änderung an `config.js` nichts Bestehendes verschiebt. + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/config.js server/services/auftraege/schalter.js test/auftraegeSchalter.test.js && git commit -m "Aufträge: Konfiguration und Pilot-Schalter AUFTRAEGE_ENABLED" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: Setup-Skript + +**Files:** +- Create: `scripts/auftraege-setup.mjs` +- Test: `test/auftraegeSetup.test.js` + +**Interfaces:** +- Consumes: `AUFTRAEGE_COLLECTIONS`, `AUFTRAEGE_ZUSATZ_ATTRIBUTE` (Task 3); `config.collections.{auftraege, projektRechnungen, customers, financeSettings}` (Task 4); `createAppwriteClient` aus `scripts/analyse-setup.mjs`. +- Produces: `main({ check = false, client, log = console.log } = {}) → Promise<{ missing, missingCollections, missingIndexes }>` mit Eintragsformen wie in `scripts/zeiten-setup.mjs`. + +- [ ] **Step 1: Failing Test schreiben** + +```js +// test/auftraegeSetup.test.js +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { config } from '../server/config.js' +import { AUFTRAEGE_COLLECTIONS } from '../server/services/auftraege/schema.js' +import { main } from '../scripts/auftraege-setup.mjs' + +// Geprüft wird nur mit dem Client mit Zustand unten (Muster: test/zeitenSetup.test.js), nie gegen Appwrite. +const NAMES = ['auftraege', 'projektRechnungen'] +const ids = NAMES.map((name) => config.collections[name]) +const customersId = config.collections.customers +const settingsId = config.collections.financeSettings +const keysOf = (name) => AUFTRAEGE_COLLECTIONS[name].attributes.map((a) => a.key) +const quiet = () => {} +const leer = () => ({ collections: [], attrs: [], indexes: [] }) + +/** Client mit Zustand: Attribute gibt es nur in vorhandenen Collections, Indizes nur auf vorhandenen Attributen. */ +function fakeClient(initial = {}) { + const start = { + [customersId]: { attrs: ['name', 'email'] }, + [settingsId]: { attrs: ['minBufferCents', 'bankDetailsText'] }, + ...initial, + } + const state = new Map( + Object.entries(start).map(([id, c]) => [id, { attrs: new Map((c.attrs || []).map((key) => [key, null])), indexes: new Set(c.indexes || []) }]), + ) + const calls = leer() + const need = (id) => { + if (!state.has(id)) throw Object.assign(new Error(`Collection ${id} fehlt`), { status: 404 }) + return state.get(id) + } + return { + state, + calls, + async collectionExists(id) { + return state.has(id) + }, + async ensureCollection(id, name) { + if (state.has(id)) return false + state.set(id, { attrs: new Map(), indexes: new Set() }) + calls.collections.push({ id, name }) + return true + }, + async listAttributes(id) { + return [...need(id).attrs.keys()].map((key) => ({ key })) + }, + async createAttribute(id, attr) { + const collection = need(id) + if (collection.attrs.has(attr.key)) throw new Error(`Attribut ${id}.${attr.key} existiert schon`) + collection.attrs.set(attr.key, attr) + calls.attrs.push(`${id}.${attr.key}`) + }, + async waitForAttribute(id, key) { + assert.ok(need(id).attrs.has(key), `wartet auf nie angelegtes Attribut ${id}.${key}`) + }, + async listIndexes(id) { + return [...need(id).indexes].map((key) => ({ key })) + }, + async createIndex(id, index) { + const collection = need(id) + for (const key of index.attributes) { + if (!collection.attrs.has(key)) throw new Error(`Index ${index.key}: Attribut ${key} fehlt in ${id}`) + } + collection.indexes.add(index.key) + calls.indexes.push(`${id}.${index.key}`) + }, + } +} + +const expectedAttrs = [ + ...NAMES.flatMap((name) => keysOf(name).map((key) => `${config.collections[name]}.${key}`)), + `${customersId}.kundenart`, + `${settingsId}.steuernummer`, +] +const expectedIndexes = NAMES.flatMap((name) => AUFTRAEGE_COLLECTIONS[name].indexes.map((i) => `${config.collections[name]}.${i.key}`)) + +test('main legt auf leerer Datenbank 2 Collections mit allen Attributen und Indizes an und ergänzt customers und financeSettings', async () => { + const client = fakeClient() + const result = await main({ client, log: quiet }) + assert.deepEqual(client.calls.collections, ids.map((id) => ({ id, name: id }))) + assert.deepEqual([...client.calls.attrs].sort(), [...expectedAttrs].sort()) + assert.deepEqual([...client.calls.indexes].sort(), [...expectedIndexes].sort()) + assert.equal(expectedAttrs.length, 55) + assert.equal(expectedIndexes.length, 8) + assert.equal(result.missing.length, 55) + assert.deepEqual(result.missingCollections, ids) + assert.equal(result.missingIndexes.length, 8) + // customers und financeSettings gibt es schon: sie werden nie angelegt, bekommen nur ihr neues Feld + assert.ok(!client.calls.collections.some((c) => [customersId, settingsId].includes(c.id))) + assert.deepEqual(client.state.get(customersId).attrs.get('kundenart'), { key: 'kundenart', type: 'string', size: 20, required: false }) + assert.deepEqual(client.state.get(settingsId).attrs.get('steuernummer'), { key: 'steuernummer', type: 'string', size: 40, required: false }) +}) + +test('main: zweiter Lauf legt nichts mehr an', async () => { + const client = fakeClient() + await main({ client, log: quiet }) + client.calls.collections.length = client.calls.attrs.length = client.calls.indexes.length = 0 + const result = await main({ client, log: quiet }) + assert.deepEqual(client.calls, leer()) + assert.deepEqual(result, { missing: [], missingCollections: [], missingIndexes: [] }) +}) + +test('main legt nur Fehlendes an, auch in einer schon vorhandenen Collection', async () => { + const pr = config.collections.projektRechnungen + const client = fakeClient({ + [pr]: { attrs: ['invoiceId', 'kundenId'], indexes: ['idx_kunde'] }, + [customersId]: { attrs: ['name', 'kundenart'] }, + }) + await main({ client, log: quiet }) + assert.deepEqual(client.calls.collections, [{ id: config.collections.auftraege, name: config.collections.auftraege }]) + assert.deepEqual( + client.calls.attrs.filter((k) => k.startsWith(`${pr}.`)), + keysOf('projektRechnungen').filter((k) => !['invoiceId', 'kundenId'].includes(k)).map((k) => `${pr}.${k}`), + ) + assert.deepEqual(client.calls.indexes.filter((k) => k.startsWith(`${pr}.`)), [`${pr}.idx_projekt`, `${pr}.idx_auftrag`]) + assert.ok(!client.calls.attrs.includes(`${customersId}.kundenart`)) + assert.ok(client.calls.attrs.includes(`${settingsId}.steuernummer`)) +}) + +test('--check meldet Fehlendes und schreibt nichts', async () => { + const client = fakeClient() + const lines = [] + const result = await main({ check: true, client, log: (line) => lines.push(line) }) + assert.deepEqual(client.calls, leer()) + assert.equal(client.state.size, 2, 'nur customers und financeSettings, nichts angelegt') + assert.deepEqual([...client.state.get(settingsId).attrs.keys()], ['minBufferCents', 'bankDetailsText']) + assert.deepEqual(result.missingCollections, ids) + assert.equal(result.missing.length, 55) + assert.ok(lines.some((l) => l.includes(config.collections.auftraege) && l.includes('FEHLT')), lines.join('\n')) + assert.ok(lines.some((l) => l.includes(`${settingsId}.steuernummer FEHLT`)), lines.join('\n')) + assert.ok(lines.some((l) => l.includes(`Index ${config.collections.auftraege}.idx_nummer FEHLT`)), lines.join('\n')) +}) + +test('--check bei vollständigem Stand: nichts fehlt; ein einzelnes fehlendes Feld wird genau genannt', async () => { + const client = fakeClient() + await main({ client, log: quiet }) + assert.deepEqual(await main({ check: true, client, log: quiet }), { missing: [], missingCollections: [], missingIndexes: [] }) + client.state.get(customersId).attrs.delete('kundenart') + const result = await main({ check: true, client, log: quiet }) + assert.deepEqual(result.missing.map((a) => `${a.collection}.${a.key}`), [`${customersId}.kundenart`]) + assert.deepEqual(result.missingCollections, []) +}) + +test('fehlt customers oder financeSettings, bricht main mit klarer Meldung ab, ohne etwas anzulegen', async () => { + for (const [fehlt, hinweis] of [[settingsId, /setup-finanzen/], [customersId, /Ticketsystem/]]) { + const client = fakeClient() + client.state.delete(fehlt) + await assert.rejects( + () => main({ client, log: quiet }), + (err) => err.message.includes(fehlt) && err.message.includes('fehlt') && hinweis.test(err.message), + fehlt, + ) + assert.deepEqual(client.calls, leer()) + } +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeSetup.test.js` +Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `scripts/auftraege-setup.mjs`. + +- [ ] **Step 3: Implementieren** + +`scripts/auftraege-setup.mjs` (neu). Aufbau, Ablauf und Logzeilen **1:1 wie `scripts/zeiten-setup.mjs`** (Datei lesen und übernehmen, der Test hängt an den Logzeilen `- FEHLT`, `- . FEHLT`, `- Index . FEHLT`): erst alles prüfen (ohne `--check` auch dann zuerst alles prüfen, damit ein Abbruch vor jeder Änderung passiert), dann Collections anlegen (`ensureCollection(id, id)`), Attribute anlegen, auf jedes Attribut warten, Indizes anlegen. Unterschiede zu zeiten-setup: +- Ziele: die Collections aus `AUFTRAEGE_COLLECTIONS` (je `config.collections[name]`, `own: true`) sowie zwei vorhandene Collections nur mit zusätzlichen Attributen und ohne Indizes: `config.collections.customers` mit `AUFTRAEGE_ZUSATZ_ATTRIBUTE.customers` und `config.collections.financeSettings` mit `AUFTRAEGE_ZUSATZ_ATTRIBUTE.financeSettings` (`own: false`). +- Fehlt eine nicht eigene Collection, wirft das Skript vor jeder Änderung `Error('Collection fehlt – ')`; Hinweis für `financeSettings`: `zuerst scripts/setup-finanzen.mjs ausfuehren`; für `customers`: `die Kunden-Collection gehört dem Ticketsystem (WOMS)`. +- Kopfkommentar: wozu das Skript dient, dass es idempotent ist, vor dem Code-Deploy und vor `AUFTRAEGE_ENABLED` ausgeführt wird (`docker exec -w /app webklar-kundenbereich-server node scripts/auftraege-setup.mjs`), Usage mit `--check`. +- Der Block `invokedDirectly` am Ende wie in zeiten-setup (Exit 1 bei `--check` mit Fehlendem). + +- [ ] **Step 4: Lauf, erwartet PASS** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeSetup.test.js` +Expected: PASS (sechs Tests, `fail 0`). + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add scripts/auftraege-setup.mjs test/auftraegeSetup.test.js && git commit -m "Aufträge: Setup-Skript für Collections und Zusatzfelder" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 6: Textbausteine (vorläufig) + +**Files:** +- Create: `server/services/auftraege/texte.js` +- Test: `test/auftraegeTextBausteine.test.js` + +**Interfaces:** +- Produces: alle Exporte aus Übersicht V7 (`TEXTE_FASSUNG`, `ABSENDER`, `AB_TEXTE`, `BESTAETIGUNG_TEXT`, `FRUEHBEGINN_FASSUNG`, `FRUEHBEGINN_TEXT`, `WIDERRUFSBELEHRUNG_PROJEKT`, `WIDERRUFSFORMULAR`). Parametertypen der Textfunktionen (in V7 offen, hier festgelegt): `datum`, `start`, `fertig`, `fassung` sind fertig formatierte Texte `TT.MM.JJJJ`; `weg` ist das deutsche Wort aus `AUFTRAG_WEGE` (z. B. `telefonisch`) oder leer; `satz`, `anzahlung`, `rest` sind Beträge wie von `euroDe(...)`; `prozent` und `ziel` (Tage) sind Zahlen; `schaetzung` ist ein Stundentext (`'10'`, `'2,25'`) oder `null`; `privat` (nur bei `termine`) ist ein optionaler Boolean, Vorgabe `false`: für Privatpersonen kommt der Satz zum Beginn nach Ablauf der Widerrufsfrist dazu (dann nie `null`, auch ohne Termine). Von L4 und L5 genau so aufzurufen. + +Die Wortlaute sind **vorläufig**; die endgültigen macht Strang L7 (nur Wortlaute, Exportnamen und Formen bleiben). + +- [ ] **Step 1: Failing Test schreiben** + +```js +// test/auftraegeTextBausteine.test.js +import { test } from 'node:test' +import assert from 'node:assert/strict' +import * as T from '../server/services/auftraege/texte.js' + +// Geprüft werden Form und eingesetzte Werte, nie ganze Sätze: die Wortlaute ändert L7. +const alleTexte = (wert) => + typeof wert === 'string' + ? [wert] + : Array.isArray(wert) + ? wert.flatMap(alleTexte) + : wert && typeof wert === 'object' + ? Object.values(wert).flatMap(alleTexte) + : [] + +test('Exportnamen und feste Angaben nach Übersicht V7', () => { + assert.match(T.TEXTE_FASSUNG, /^\d{4}-\d{2}-\d{2}$/) + assert.match(T.FRUEHBEGINN_FASSUNG, /^\d{4}-\d{2}-\d{2}$/) + assert.deepEqual(T.ABSENDER, { + name: 'WEBklar GbR', strasse: 'Am Schwimmbad 10', plzOrt: '67722 Winnweiler', telefon: '0176 23726355', + email: 'support@webklar.com', web: 'webklar.com', vertretenDurch: 'Kenso Grimm und Justin Klein', + }) + assert.equal(T.AB_TEXTE.titel, 'Auftragsbestätigung') + assert.equal(T.AB_TEXTE.kleinunternehmer, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.') + assert.equal(T.BESTAETIGUNG_TEXT, 'Ich bestätige den Auftrag zu diesen Bedingungen.') + assert.deepEqual(Object.keys(T.AB_TEXTE).sort(), ['agb', 'aufwand', 'einleitung', 'ersetzt', 'gruss', 'kleinunternehmer', 'mitwirkung', 'termine', 'titel', 'zahlungsplan']) + assert.deepEqual(Object.keys(T.AB_TEXTE.zahlungsplan).sort(), ['anzahlung', 'frei', 'monatlich', 'nach_abnahme']) + for (const name of ['mitwirkung', 'gruss']) assert.ok(typeof T.AB_TEXTE[name] === 'string' && T.AB_TEXTE[name].length > 5, name) + assert.ok(typeof T.FRUEHBEGINN_TEXT === 'string' && T.FRUEHBEGINN_TEXT.length > 50 && /Widerruf/.test(T.FRUEHBEGINN_TEXT)) +}) + +test('Einleitung, AGB-Satz, Ersetzt-Hinweis und Termine setzen ihre Werte ein', () => { + assert.match(T.AB_TEXTE.einleitung({ datum: '05.10.2026', weg: 'telefonisch' }), /05\.10\.2026.*telefonisch/) + assert.doesNotMatch(T.AB_TEXTE.einleitung({ datum: '05.10.2026', weg: '' }), /\(\s*\)/, 'ohne Weg keine leere Klammer') + assert.ok(T.AB_TEXTE.agb({ fassung: '30.09.2026' }).includes('30.09.2026')) + const ersetzt = T.AB_TEXTE.ersetzt({ nummer: 'AB-2026-0001', datum: '01.10.2026' }) + assert.ok(ersetzt.includes('AB-2026-0001') && ersetzt.includes('01.10.2026')) + assert.equal(T.AB_TEXTE.termine({ start: null, fertig: null }), null) + assert.equal(T.AB_TEXTE.termine({}), null) + assert.ok(T.AB_TEXTE.termine({ start: '01.11.2026', fertig: null }).includes('01.11.2026')) + const beide = T.AB_TEXTE.termine({ start: '01.11.2026', fertig: '15.12.2026' }) + assert.ok(beide.includes('01.11.2026') && beide.includes('15.12.2026')) + assert.ok(!T.AB_TEXTE.termine({ start: null, fertig: '15.12.2026' }).includes('null')) +}) + +test('Termine: für Privatpersonen kommt der Satz zum Beginn nach Ablauf der Widerrufsfrist dazu, auch ohne Termine', () => { + const ohne = T.AB_TEXTE.termine({ start: '01.11.2026', fertig: '15.12.2026' }) + assert.equal(T.AB_TEXTE.termine({ start: '01.11.2026', fertig: '15.12.2026', privat: false }), ohne) + assert.doesNotMatch(ohne, /Widerrufsfrist/) + const privat = T.AB_TEXTE.termine({ start: '01.11.2026', fertig: '15.12.2026', privat: true }) + assert.ok(privat.startsWith(ohne) && privat.length > ohne.length && /Widerrufsfrist/.test(privat), privat) + const nurSatz = T.AB_TEXTE.termine({ start: null, fertig: null, privat: true }) + assert.ok(typeof nurSatz === 'string' && /Widerrufsfrist/.test(nurSatz) && !nurSatz.includes('null'), String(nurSatz)) + assert.equal(T.AB_TEXTE.termine({ privat: true }), nurSatz) +}) + +test('Aufwandsregel und Zahlungsplan: Werte, Schätzung nur wenn gegeben, Ziel 0 heißt sofort', () => { + const mit = T.AB_TEXTE.aufwand({ satz: '100,00 €', schaetzung: '10' }) + assert.ok(mit.includes('100,00 €') && /\b15\b/.test(mit) && /\b9\b/.test(mit) && /\b10\b/.test(mit), mit) + const ohne = T.AB_TEXTE.aufwand({ satz: '100,00 €', schaetzung: null }) + assert.ok(!/\b10\b/.test(ohne) && ohne.length < mit.length, ohne) + assert.match(T.AB_TEXTE.aufwand({ satz: '100,00 €', schaetzung: '1' }), /\b1 Stunde(?!n)/) + + const z = T.AB_TEXTE.zahlungsplan + const anzahlung = z.anzahlung({ prozent: 50, anzahlung: '400,00 €', rest: '400,00 €', ziel: 14 }) + assert.ok(/50\s?%/.test(anzahlung) && anzahlung.split('400,00 €').length === 3 && /\b14\b/.test(anzahlung), anzahlung) + for (const art of ['nach_abnahme', 'monatlich']) { + assert.ok(/\b14\b/.test(z[art]({ ziel: 14 })), art) + assert.ok(!/\b0 Tag/.test(z[art]({ ziel: 0 })), `${art}: Ziel 0`) + } + const frei = z.frei({ text: 'Zahlung in zwei Raten.', ziel: 14 }) + assert.ok(frei.includes('Zahlung in zwei Raten') && !frei.includes('Raten..') && /\b14\b/.test(frei), frei) +}) + +test('Frühbeginn-Erklärung, Widerrufsbelehrung und Formular haben die Form aus V7 und nennen keine Funktionen-Einrichtung mehr', () => { + assert.equal(T.WIDERRUFSBELEHRUNG_PROJEKT.titel, 'Widerrufsbelehrung') + assert.ok(T.WIDERRUFSBELEHRUNG_PROJEKT.abschnitte.length >= 2) + for (const abschnitt of T.WIDERRUFSBELEHRUNG_PROJEKT.abschnitte) { + assert.ok(typeof abschnitt.ueberschrift === 'string' && abschnitt.ueberschrift.length > 0) + assert.ok(Array.isArray(abschnitt.absaetze) && abschnitt.absaetze.length > 0) + for (const absatz of abschnitt.absaetze) assert.ok(typeof absatz === 'string' && absatz.trim() !== '') + } + assert.equal(T.WIDERRUFSFORMULAR.titel, 'Muster-Widerrufsformular') + assert.ok(typeof T.WIDERRUFSFORMULAR.einleitung === 'string' && T.WIDERRUFSFORMULAR.einleitung.length > 0) + assert.ok(Array.isArray(T.WIDERRUFSFORMULAR.zeilen) && T.WIDERRUFSFORMULAR.zeilen.length >= 6) + const text = alleTexte([T.WIDERRUFSBELEHRUNG_PROJEKT, T.WIDERRUFSFORMULAR, T.FRUEHBEGINN_TEXT]).join('\n') + assert.doesNotMatch(text, /Einrichtung|Terminplaner|Wartungs-Abo|Funktionen/) + assert.match(text, /vierzehn Tage/) + assert.match(text, /Projektauftrag/) + assert.match(text, /widerrufen\.html/) + assert.match(text, /support@webklar\.com/) +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeTextBausteine.test.js` +Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `server/services/auftraege/texte.js`. + +- [ ] **Step 3: Implementieren** + +`server/services/auftraege/texte.js` (neu, ohne Importe), mit genau diesem Inhalt. Die Widerrufsbelehrung ist der Wortlaut aus `docs/rechtliches/widerrufsbelehrung-funktionen.md` (Fassung 2026-10-05), bezogen auf den Projektauftrag; das Formular ist deren Muster-Formular unverändert. `FRUEHBEGINN_TEXT` ist der Wortlaut des Nutzers. + +```js +/** + * Textbausteine der Auftragsbestätigung (Spec 2026-10-06, Abschnitt 2 „Inhalt des PDFs“, Übersicht V7). + * Reine Daten und Textfunktionen ohne Importe. VORLÄUFIGER Stand (angelegt von Strang L0): die endgültigen Wortlaute + * macht Strang L7 nach der Rechtsrunde, dabei ändern sich nur Wortlaute, nie Exportnamen oder Formen. + * Kundentexte stehen in der Sie-Form. Werte kommen fertig formatiert herein: Daten als 'TT.MM.JJJJ', Beträge wie von + * euroDe(...), Tage und Prozent als Zahl, Stunden als Text ('10', '2,25'), `weg` als deutsches Wort ('telefonisch'), + * `privat` (nur bei termine) als Boolean. + */ + +export const TEXTE_FASSUNG = '2026-10-06' + +export const ABSENDER = { + name: 'WEBklar GbR', + strasse: 'Am Schwimmbad 10', + plzOrt: '67722 Winnweiler', + telefon: '0176 23726355', + email: 'support@webklar.com', + web: 'webklar.com', + vertretenDurch: 'Kenso Grimm und Justin Klein', +} + +/** Zahlungsziel als Satzteil: 0 Tage heißt sofort, sonst „innerhalb von N Tagen nach Rechnung“. */ +const zielText = (ziel) => (ziel === 0 ? 'sofort nach Rechnungserhalt' : `innerhalb von ${ziel} ${ziel === 1 ? 'Tag' : 'Tagen'} nach Rechnung`) + +export const AB_TEXTE = { + titel: 'Auftragsbestätigung', + einleitung: ({ datum, weg }) => `Vielen Dank für Ihren Auftrag vom ${datum}${weg ? ` (${weg})` : ''}. Wir bestätigen ihn wie folgt:`, + aufwand: ({ satz, schaetzung }) => + `Die Abrechnung erfolgt nach Aufwand: ${satz} pro Stunde in Schritten von 15 Minuten. Unterstützt eine weitere Person, berechnen wir für sie die Hälfte der Arbeitszeit (AGB Nr. 9).` + + (schaetzung ? ` Geschätzter Umfang: etwa ${schaetzung} ${schaetzung === '1' ? 'Stunde' : 'Stunden'} (unverbindlich).` : '') + + ' Zeichnet sich eine deutliche Überschreitung ab, melden wir uns vorher.', + kleinunternehmer: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.', + zahlungsplan: { + nach_abnahme: ({ ziel }) => `Die Rechnung stellen wir nach Abnahme der Leistung; zahlbar ${zielText(ziel)}.`, + anzahlung: ({ prozent, anzahlung, rest, ziel }) => + `Anzahlung ${prozent} % (${anzahlung}) nach Auftragsbestätigung, Rest (${rest}) nach Abnahme; zahlbar jeweils ${zielText(ziel)}.`, + monatlich: ({ ziel }) => `Wir rechnen monatlich nach dem entstandenen Aufwand ab; zahlbar ${zielText(ziel)}.`, + frei: ({ text, ziel }) => `${text.trim().replace(/[\s.;:,]+$/, '')}; zahlbar ${zielText(ziel)}.`, + }, + termine: ({ start, fertig, privat = false } = {}) => { + const teile = [] + if (start && fertig) teile.push(`Beginn: ${start}; voraussichtliche Fertigstellung: ${fertig}.`) + else if (start) teile.push(`Beginn: ${start}.`) + else if (fertig) teile.push(`Voraussichtliche Fertigstellung: ${fertig}.`) + if (privat) teile.push('Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.') + return teile.length ? teile.join(' ') : null + }, + mitwirkung: 'Mitwirkungspflichten und Abnahme richten sich nach Nr. 7 und Nr. 8 unserer Allgemeinen Geschäftsbedingungen.', + agb: ({ fassung }) => `Es gelten unsere Allgemeinen Geschäftsbedingungen in der Fassung vom ${fassung}, die dieser Bestätigung beiliegen.`, + ersetzt: ({ nummer, datum }) => `Diese Auftragsbestätigung ersetzt ${nummer} vom ${datum}.`, + gruss: 'Mit freundlichen Grüßen', +} + +export const BESTAETIGUNG_TEXT = 'Ich bestätige den Auftrag zu diesen Bedingungen.' + +export const FRUEHBEGINN_FASSUNG = '2026-10-06' +export const FRUEHBEGINN_TEXT = + 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Arbeit an diesem Auftrag beginnt. Mir ist bekannt, dass ich bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachten Leistungen zahle (§ 357a Abs. 2 BGB).' + +export const WIDERRUFSBELEHRUNG_PROJEKT = { + titel: 'Widerrufsbelehrung', + abschnitte: [ + { + ueberschrift: 'Widerrufsrecht', + absaetze: [ + 'Diese Belehrung gilt für den Projektauftrag, den wir Ihnen mit dieser Auftragsbestätigung bestätigen.', + 'Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.', + 'Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.', + 'Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, Telefon: 0176 23726355, E-Mail: support@webklar.com) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.', + 'Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben; wählen Sie dort die Vertragsart „Projektauftrag (Auftragsbestätigung)“ und tragen Sie die Nummer Ihrer Auftragsbestätigung ein. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.', + 'Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.', + ], + }, + { + ueberschrift: 'Folgen des Widerrufs', + absaetze: [ + 'Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.', + 'Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht.', + ], + }, + ], +} + +export const WIDERRUFSFORMULAR = { + titel: 'Muster-Widerrufsformular', + einleitung: '(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)', + zeilen: [ + '– An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:', + '– Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)', + '– Bestellt am (*)/erhalten am (*)', + '– Name des/der Verbraucher(s)', + '– Anschrift des/der Verbraucher(s)', + '– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)', + '– Datum', + '(*) Unzutreffendes streichen.', + ], +} +``` + +- [ ] **Step 4: Lauf, erwartet PASS** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeTextBausteine.test.js` +Expected: PASS (fünf Tests, `fail 0`). + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/texte.js test/auftraegeTextBausteine.test.js && git commit -m "Aufträge: vorläufige Textbausteine der Auftragsbestätigung" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 7: Auftragsspeicher + +**Files:** +- Create: `server/services/auftraege/auftragStore.js` +- Test: `test/auftraegeAuftragStore.test.js` + +**Interfaces:** +- Consumes: `config.collections.auftraege` (Task 4); `AUFTRAEGE_COLLECTIONS` (Task 3, für die Spaltengrößen); `LIMITS`, `ZAHLUNGSPLAN_ARTEN` (Task 2); `AuftragError` (Task 2); aus `server/services/appwriteAdmin.js`: `createDocument`, `getDocument`, `updateDocument`, `deleteDocument`, `listAllDocuments`, `listDocuments`, `Query`. +- Produces (Übersicht V4): `docToAuftrag(doc) → Auftrag`, `auftragToDoc(auftrag) → object`, `getAuftrag(id) → Auftrag|null`, `listAuftraege({ kundenId, projektId, jahr } = {}) → Promise` (neueste zuerst), `createAuftrag(daten) → Promise`, `updateAuftrag(id, patch) → Promise`, `deleteAuftrag(id) → Promise`, `findAuftragByTokenHash(hash) → Promise`, `findAuftragByNummer(nummer) → Promise`. Form von `Auftrag`: Übersicht V3 (43 Felder). + +- [ ] **Step 1: Failing Test schreiben** + +```js +// test/auftraegeAuftragStore.test.js +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js' + +// Speicher der Auftragsbestätigungen. Nachbau von Appwrite mit dem Schema aus schema.js, Datumsfeldern wie in Appwrite +// ("+00:00") und dem Standard-Limit 25. +const aw = await startAppwriteMock({ schema: { auftraege: schemaKeys('auftraege') }, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { docToAuftrag, auftragToDoc, getAuftrag, listAuftraege, createAuftrag, updateAuftrag, deleteAuftrag, findAuftragByTokenHash, findAuftragByNummer } = + await import('../server/services/auftraege/auftragStore.js') +const { AuftragError } = await import('../server/services/auftraege/fehler.js') + +const COL = config.collections.auftraege +beforeEach(async () => { + for (const id of [...aw.docs(COL).keys()]) await deleteDocument(COL, id) +}) + +const ENTWURF = { + kundenId: 'c1', projektId: 'p1', titel: 'Website Bäckerei', zeitquelle: ['t1'], art: 'festpreis', + positionen: [{ beschreibung: 'Startseite', menge: 1, einzelpreisCents: 50000 }], + summeCents: 50000, zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14, + auftragVom: '2026-10-05', auftragWeg: 'telefon', erstelltVon: 'kenso@webklar.com', +} +const FELDER = 'id nummer jahr laufnummer status kundenId kunde projektId titel zeitquelle art positionen stundensatzCents schaetzungMinuten summeCents zahlungsplan zahlungszielTage auftragVom auftragWeg startAm fertigBis ersetztId ersetztDurchId texteFassung agbFassung pdfDatei pdfSha256 gesendetAm gesendetAn mailStatus mailFehler tokenHash tokenGueltigBis bestaetigtAm bestaetigtUeber bestaetigtPdfSha256 fruehbeginn fruehbeginnFassung storniertAm widerrufenAm erstelltVon erstelltAm geaendertAm' +const HASH = 'a'.repeat(64) +const seed = (id, felder = {}) => aw.seed(COL, { $id: id, kundenId: 'c1', status: 'entwurf', ...felder }) + +test('createAuftrag legt einen Entwurf an: Status erzwungen, Zeitpunkte gesetzt, JSON-Felder serialisiert', async () => { + const vor = Date.now() + const auftrag = await createAuftrag({ ...ENTWURF, status: 'versendet' }) + assert.deepEqual(Object.keys(auftrag).sort(), FELDER.split(' ').sort(), 'genau die 43 Felder aus V3') + assert.match(auftrag.id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) + assert.equal(auftrag.status, 'entwurf') + assert.deepEqual( + { nummer: auftrag.nummer, jahr: auftrag.jahr, laufnummer: auftrag.laufnummer, kunde: auftrag.kunde, gesendetAm: auftrag.gesendetAm, mailStatus: auftrag.mailStatus, fruehbeginn: auftrag.fruehbeginn }, + { nummer: '', jahr: null, laufnummer: null, kunde: null, gesendetAm: null, mailStatus: '', fruehbeginn: false }, + ) + assert.deepEqual(auftrag.positionen, ENTWURF.positionen) + assert.deepEqual(auftrag.zahlungsplan, ENTWURF.zahlungsplan) + assert.deepEqual(auftrag.zeitquelle, ['t1']) + assert.deepEqual([auftrag.stundensatzCents, auftrag.schaetzungMinuten], [null, null]) + assert.match(auftrag.erstelltAm, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + assert.equal(auftrag.geaendertAm, auftrag.erstelltAm) + assert.ok(Date.parse(auftrag.erstelltAm) >= vor) + const doc = aw.docs(COL).get(auftrag.id) + assert.equal(doc.status, 'entwurf') + assert.equal(doc.positionenJson, JSON.stringify(ENTWURF.positionen)) + assert.equal(doc.zeitquelleJson, '["t1"]') + assert.deepEqual(await getAuftrag(auftrag.id), auftrag) +}) + +test('docToAuftrag: fehlende Felder bekommen Vorgaben, kaputtes JSON wirft nie, Zeitpunkte werden zu ISO mit Z', () => { + const auftrag = docToAuftrag({ + $id: 'x1', kundenId: 'c1', status: 'versendet', nummer: 'AB-2026-0003', jahr: 2026, laufnummer: 3, + kundeJson: '', positionenJson: '{kaputt', zeitquelleJson: '["t1","",5," t2 "]', zahlungsplanJson: '[1]', + gesendetAm: '2026-10-06T10:00:00.000+00:00', tokenGueltigBis: null, fruehbeginn: true, summeCents: 1000, + $createdAt: '2026-10-06T08:00:00.000+00:00', $updatedAt: '2026-10-06T09:00:00.000+00:00', + }) + assert.equal(auftrag.id, 'x1') + assert.deepEqual([auftrag.nummer, auftrag.jahr, auftrag.laufnummer, auftrag.summeCents], ['AB-2026-0003', 2026, 3, 1000]) + assert.deepEqual([auftrag.kunde, auftrag.positionen, auftrag.zeitquelle], [null, [], ['t1', 't2']]) + assert.deepEqual(auftrag.zahlungsplan, { art: 'nach_abnahme', prozent: null, text: '' }) + assert.equal(auftrag.zahlungszielTage, 14) + assert.deepEqual([auftrag.gesendetAm, auftrag.tokenGueltigBis, auftrag.bestaetigtAm], ['2026-10-06T10:00:00.000Z', null, null]) + assert.equal(auftrag.fruehbeginn, true) + assert.deepEqual([auftrag.erstelltAm, auftrag.geaendertAm], ['2026-10-06T08:00:00.000Z', '2026-10-06T09:00:00.000Z']) +}) + +test('auftragToDoc: nur Felder aus V1, JSON serialisiert, undefined fehlt, null löscht', () => { + const doc = auftragToDoc({ + id: 'x1', $id: 'x1', fremd: 1, status: 'versendet', kunde: { name: 'Anna Beispiel', kundenart: 'privat' }, + zeitquelle: ['t1'], positionen: [], zahlungsplan: { art: 'frei', prozent: null, text: 'x' }, + gesendetAm: '2026-10-06T10:00:00.000Z', tokenGueltigBis: null, titel: undefined, tokenHash: null, + }) + assert.deepEqual(doc, { + status: 'versendet', kundeJson: '{"name":"Anna Beispiel","kundenart":"privat"}', zeitquelleJson: '["t1"]', positionenJson: '[]', + zahlungsplanJson: '{"art":"frei","prozent":null,"text":"x"}', gesendetAm: '2026-10-06T10:00:00.000Z', tokenGueltigBis: null, tokenHash: '', + }) + assert.equal(auftragToDoc({ kunde: null }).kundeJson, '') +}) + +test('auftragToDoc: ein Text über der Spaltengröße ist ein 400, ein falscher Typ ein Programmfehler', () => { + assert.throws(() => auftragToDoc({ titel: 'x'.repeat(201) }), (err) => err instanceof AuftragError && err.status === 400 && /titel/.test(err.message)) + assert.throws(() => auftragToDoc({ kunde: { name: 'x'.repeat(2001) } }), (err) => err instanceof AuftragError && err.status === 400 && /kunde/.test(err.message)) + assert.doesNotThrow(() => auftragToDoc({ titel: 'x'.repeat(200) })) + for (const falsch of [{ titel: 5 }, { summeCents: 1.5 }, { jahr: '2026' }, { fruehbeginn: 'ja' }, { gesendetAm: 'gestern' }, { gesendetAm: '' }]) { + assert.throws(() => auftragToDoc(falsch), TypeError, JSON.stringify(falsch)) + } +}) + +test('getAuftrag, updateAuftrag, deleteAuftrag: unbekannte und unmögliche IDs sind null, 404 bzw. folgenlos', async () => { + seed('u1', { titel: 'Alt', summeCents: 1000, erstelltAm: '2020-01-01T00:00:00.000Z', geaendertAm: '2020-01-01T00:00:00.000Z' }) + assert.equal((await getAuftrag('u1')).titel, 'Alt') + for (const id of ['gibtsnicht', '', '../x', 'a/b', undefined, null, 42, 'x'.repeat(37)]) assert.equal(await getAuftrag(id), null, String(id)) + + const auftrag = await updateAuftrag('u1', { titel: 'Neu', positionen: [{ beschreibung: 'A', menge: 2, einzelpreisCents: 500 }], status: 'versendet' }) + assert.deepEqual([auftrag.titel, auftrag.summeCents, auftrag.status], ['Neu', 1000, 'versendet']) + assert.equal(auftrag.positionen[0].menge, 2) + assert.equal(auftrag.erstelltAm, '2020-01-01T00:00:00.000Z') + assert.ok(auftrag.geaendertAm > '2020-01-01T00:00:00.000Z') + assert.equal(aw.docs(COL).get('u1').titel, 'Neu') + for (const id of ['gibtsnicht', '../x']) { + await assert.rejects(() => updateAuftrag(id, { titel: 'x' }), (err) => err instanceof AuftragError && err.status === 404, id) + } + + await deleteAuftrag('u1') + assert.equal(aw.docs(COL).size, 0) + await deleteAuftrag('u1') + await deleteAuftrag('../x') +}) + +test('listAuftraege: Filter, neueste zuerst, mehr als 25 Treffer', async () => { + seed('a1', { projektId: 'p1', jahr: 2026, erstelltAm: '2026-10-01T10:00:00.000Z' }) + seed('a2', { projektId: 'p2', jahr: 2026, erstelltAm: '2026-10-03T10:00:00.000Z' }) + seed('a3', { projektId: 'p1', jahr: 2025, erstelltAm: '2026-10-02T10:00:00.000Z' }) + seed('b1', { kundenId: 'c2', projektId: 'p1', jahr: 2026, erstelltAm: '2026-10-04T10:00:00.000Z' }) + const ids = async (filter) => (await listAuftraege(filter)).map((a) => a.id) + assert.deepEqual(await ids({ kundenId: 'c1' }), ['a2', 'a3', 'a1']) + assert.deepEqual(await ids({ kundenId: 'c1', projektId: 'p1' }), ['a3', 'a1']) + assert.deepEqual(await ids({ jahr: 2026 }), ['b1', 'a2', 'a1']) + assert.deepEqual(await ids(), ['b1', 'a2', 'a3', 'a1']) + assert.deepEqual(await ids({ kundenId: 'gibtsnicht' }), []) + for (let i = 0; i < 30; i += 1) seed(`m${String(i).padStart(2, '0')}`, { kundenId: 'c9', erstelltAm: `2026-09-${String(1 + (i % 28)).padStart(2, '0')}T10:00:00.000Z` }) + assert.equal((await listAuftraege({ kundenId: 'c9' })).length, 30) +}) + +test('listAuftraege: ein angegebener, aber leerer oder ungültiger Filter ist ein Fehler, nie „alle“', async () => { + seed('a1') + for (const filter of [{ kundenId: '' }, { kundenId: null }, { kundenId: 5 }, { projektId: '' }, { jahr: '2026' }, { jahr: 2026.5 }, { jahr: null }]) { + await assert.rejects(() => listAuftraege(filter), TypeError, JSON.stringify(filter)) + } +}) + +test('findAuftragByTokenHash und findAuftragByNummer: genau eine AB, leere und ungültige Schlüssel nie', async () => { + seed('e1', { nummer: '', tokenHash: '' }) + seed('e2') + seed('v1', { status: 'versendet', nummer: 'AB-2026-0001', tokenHash: HASH }) + assert.equal((await findAuftragByTokenHash(HASH)).id, 'v1') + assert.equal((await findAuftragByNummer('AB-2026-0001')).id, 'v1') + assert.equal(await findAuftragByTokenHash('b'.repeat(64)), null) + assert.equal(await findAuftragByNummer('AB-2026-0002'), null) + for (const hash of ['', ' ', 'abc', 'A'.repeat(64), `${HASH}0`, undefined, null, 5]) assert.equal(await findAuftragByTokenHash(hash), null, String(hash)) + for (const nummer of ['', 'Entwurf', 'AB-2026-1', 'ab-2026-0001', 'AB-2026-0001 ', undefined, null]) assert.equal(await findAuftragByNummer(nummer), null, String(nummer)) +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAuftragStore.test.js` +Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `server/services/auftraege/auftragStore.js`. + +- [ ] **Step 3: Implementieren** + +`server/services/auftraege/auftragStore.js` (neu). Feldlisten **nicht von Hand schreiben**, sondern aus `AUFTRAEGE_COLLECTIONS.auftraege.attributes` ableiten (Typ und Größe); nur vier Spalten werden auf Objektfelder abgebildet: `kundeJson` ↔ `kunde`, `zeitquelleJson` ↔ `zeitquelle`, `positionenJson` ↔ `positionen`, `zahlungsplanJson` ↔ `zahlungsplan`. Alle anderen Felder heißen im Auftrag wie die Spalte; dazu `id` = `$id`. + +- `docToAuftrag(doc)`: Vorgaben je Feld: + - Textspalten: der Wert, wenn er ein Text ist, sonst `''`. + - `jahr`, `laufnummer`, `stundensatzCents`, `schaetzungMinuten`, `summeCents`: ganze Zahl, sonst `null`. `zahlungszielTage`: ganze Zahl, sonst `LIMITS.standardZahlungszielTage`. `fruehbeginn`: `doc.fruehbeginn === true`. + - Datumsfelder (`gesendetAm`, `tokenGueltigBis`, `bestaetigtAm`, `storniertAm`, `widerrufenAm`): gültiger Zeitpunkt → `new Date(wert).toISOString()` (Appwrite liefert `+00:00`), sonst `null`. `erstelltAm` und `geaendertAm` ebenso, mit Rückgriff auf `$createdAt` bzw. `$updatedAt`. + - `kunde`: JSON-Objekt, sonst `null`. `zeitquelle`: JSON-Liste, nur nicht leere Texte (getrimmt), sonst `[]`. `positionen`: JSON-Liste, nur Objekte, sonst `[]`. `zahlungsplan`: JSON-Objekt, daraus `{ art, prozent, text }` mit `art` aus `ZAHLUNGSPLAN_ARTEN` (sonst `'nach_abnahme'`), `prozent` Zahl oder `null`, `text` Text; ist es kein Objekt: `{ art: 'nach_abnahme', prozent: null, text: '' }`. Kaputtes JSON wirft nie. +- `auftragToDoc(auftrag)`: wandelt nur die in `auftrag` vorhandenen Schlüssel um (auch teilweise Objekte, für `updateAuftrag`); `id`, `$…` und fremde Schlüssel bleiben weg, `undefined` fehlt. Regeln je Art: Text → String; `null` → `''`; anderer Typ → `TypeError`. Zahl → ganze Zahl (`Number.isSafeInteger`) oder `null`, sonst `TypeError`. `fruehbeginn` → Wahrheitswert, sonst `TypeError`. Datumsfeld → `null` bleibt `null`, ein `Date` oder gültiger Zeitpunkt-Text wird `toISOString()`, alles andere (auch `''`) ist `TypeError` (Appwrite weist `''` als Datum ab). JSON-Felder → `JSON.stringify(wert)` (`null` → `''`). Ist ein Text oder JSON länger als die Spalte (`size`), dann `AuftragError(400, 'Das Feld ist zu lang.')` (Auftragsfeld = `kunde` für `kundeJson` usw.). +- ID-Format für `getAuftrag`/`updateAuftrag`/`deleteAuftrag`: ein Text, der `/^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/` erfüllt (Appwrite-Regel). `getAuftrag`: ungültige ID oder 404 → `null` (ohne Abfrage bei ungültiger ID); andere Fehler weiterreichen. `updateAuftrag`: ungültige ID oder 404 → `AuftragError(404, 'Auftragsbestätigung nicht gefunden.')`; setzt immer `geaendertAm` auf jetzt. `deleteAuftrag`: ungültige ID oder 404 sind folgenlos. +- `createAuftrag(daten)`: `jetzt = new Date().toISOString()`; Dokument = `auftragToDoc({ ...daten, status: 'entwurf', erstelltAm: jetzt, geaendertAm: jetzt })`, angelegt mit `createDocument(config.collections.auftraege, doc)` (ID vergibt `ID.unique()` als Vorgabe); Rückgabe `docToAuftrag(Antwort)`. +- `listAuftraege(filter = {})`: je Schlüssel `kundenId`, `projektId` (nicht leerer Text) und `jahr` (ganze Zahl), der **nicht `undefined`** ist, ein `Query.equal`; ist ein angegebener Wert ungültig (leerer Text, `null`, falscher Typ), `TypeError` (nie stillschweigend „alle“). Abfrage mit `listAllDocuments` (Seiten à 500, Appwrite liefert sonst nur 25), danach `docToAuftrag`, sortiert nach `erstelltAm` absteigend, dann `id` absteigend. +- `findAuftragByTokenHash(hash)`: nur ein Text aus genau 64 Zeichen `[0-9a-f]` führt zu einer Abfrage (`Query.equal('tokenHash', hash)`, `Query.limit(1)`), sonst `null` ohne Abfrage (ein leerer Hash würde sonst jeden Entwurf treffen). `findAuftragByNummer(nummer)`: nur `/^AB-\d{4}-\d{4}$/`, sonst `null`. + +- [ ] **Step 4: Lauf, erwartet PASS** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAuftragStore.test.js` +Expected: PASS (acht Tests, `fail 0`). + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/auftragStore.js test/auftraegeAuftragStore.test.js && git commit -m "Aufträge: Speicher der Auftragsbestätigungen" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 8: Projektrechnungen-Speicher + +**Files:** +- Create: `server/services/auftraege/projektRechnungenStore.js` +- Test: `test/auftraegeProjektRechnungenStore.test.js` + +**Interfaces:** +- Consumes: `config.collections.projektRechnungen` (Task 4); `AuftragError` (Task 2); `createDocument`, `getDocument`, `updateDocument`, `listAllDocuments`, `Query` aus `appwriteAdmin.js`. +- Produces (Übersicht V4): `getProjektRechnung(invoiceId) → Promise`, `listProjektRechnungen({ kundenId, projektId, auftragId, nurAktiv = true } = {}) → Promise`, `createProjektRechnung(invoiceId, daten) → Promise`, `updateProjektRechnung(invoiceId, patch) → Promise`, `umhaengen(auftragIdAlt, auftragIdNeu) → Promise`. Form von `ProjektRechnung` (V3): `{ invoiceId, kundenId, projektId, auftragId, art, betragCents, arbeitsblattIds, rechnungsnummer, aktiv, erstelltVon, erstelltAm }`. + +- [ ] **Step 1: Failing Test schreiben** + +```js +// test/auftraegeProjektRechnungenStore.test.js +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js' + +// Speicher der Projektrechnungen (Dokument-ID = Stripe-Rechnungs-ID). Nachbau von Appwrite mit Schema und Standard-Limit 25. +const aw = await startAppwriteMock({ schema: { projektRechnungen: schemaKeys('projektRechnungen') }, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { getProjektRechnung, listProjektRechnungen, createProjektRechnung, updateProjektRechnung, umhaengen } = + await import('../server/services/auftraege/projektRechnungenStore.js') +const { AuftragError } = await import('../server/services/auftraege/fehler.js') + +const COL = config.collections.projektRechnungen +beforeEach(async () => { + for (const id of [...aw.docs(COL).keys()]) await deleteDocument(COL, id) +}) + +const DATEN = { kundenId: 'c1', projektId: 'p1', auftragId: 'ab1', art: 'aufwand', betragCents: 22500, arbeitsblattIds: ['w1', 'w2'], erstelltVon: 'kenso@webklar.com' } +const seed = (id, felder = {}) => + aw.seed(COL, { + $id: id, invoiceId: id, kundenId: 'c1', projektId: 'p1', auftragId: '', art: 'festbetrag', betragCents: 100, + arbeitsblattIdsJson: '[]', rechnungsnummer: '', aktiv: true, erstelltAm: '2026-10-01T10:00:00.000Z', ...felder, + }) + +test('createProjektRechnung: Dokument-ID ist die Rechnungs-ID, Standardwerte, Rückgabe in der Form aus V3', async () => { + const rechnung = await createProjektRechnung('in_1SD6vPRxAbCdEf', DATEN) + assert.deepEqual( + { ...rechnung, erstelltAm: undefined }, + { + invoiceId: 'in_1SD6vPRxAbCdEf', kundenId: 'c1', projektId: 'p1', auftragId: 'ab1', art: 'aufwand', betragCents: 22500, + arbeitsblattIds: ['w1', 'w2'], rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com', erstelltAm: undefined, + }, + ) + assert.match(rechnung.erstelltAm, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + const doc = aw.docs(COL).get('in_1SD6vPRxAbCdEf') + assert.equal(doc.invoiceId, 'in_1SD6vPRxAbCdEf') + assert.equal(doc.arbeitsblattIdsJson, '["w1","w2"]') + assert.equal(doc.aktiv, true) + assert.deepEqual(await getProjektRechnung('in_1SD6vPRxAbCdEf'), rechnung) + const minimal = await createProjektRechnung('in_test_1', { kundenId: 'c1', art: 'festbetrag', betragCents: 5000 }) + assert.deepEqual([minimal.projektId, minimal.auftragId, minimal.arbeitsblattIds, minimal.rechnungsnummer, minimal.aktiv], ['', '', [], '', true]) +}) + +test('Rechnungs-IDs wie Stripe sie vergibt (auch in_test_1 des Nachbaus); alles andere ist ein Programmfehler und legt nichts an', async () => { + for (const id of ['in_test_1', 'in_1SD6vPRxAbCdEf', `in_${'a'.repeat(33)}`]) assert.equal((await createProjektRechnung(id, DATEN)).invoiceId, id) + await Promise.all([...aw.docs(COL).keys()].map((id) => deleteDocument(COL, id))) + for (const id of ['', 'x', 'in_', 'in_a/b', 'in_../x', 'in_a-b', 'in_a.b', 'IN_abc', `in_${'a'.repeat(34)}`, undefined, 7]) { + await assert.rejects(() => createProjektRechnung(id, DATEN), TypeError, String(id)) + assert.equal(await getProjektRechnung(id), null, String(id)) + } + const falsch = [{ kundenId: '' }, { art: 'frei' }, { art: 'xyz' }, { betragCents: 1.5 }, { betragCents: -1 }, { arbeitsblattIds: 'w1' }, { arbeitsblattIds: [''] }, { aktiv: 'ja' }] + for (const abweichung of falsch) { + await assert.rejects(() => createProjektRechnung('in_abc', { ...DATEN, ...abweichung }), TypeError, JSON.stringify(abweichung)) + } + assert.equal(aw.docs(COL).size, 0) +}) + +test('createProjektRechnung: eine doppelte Rechnungs-ID ist 409', async () => { + await createProjektRechnung('in_abc', DATEN) + await assert.rejects(() => createProjektRechnung('in_abc', DATEN), (err) => err instanceof AuftragError && err.status === 409) +}) + +test('updateProjektRechnung ändert genannte Felder; eine unbekannte Rechnung ist 404', async () => { + await createProjektRechnung('in_abc', DATEN) + const gesendet = await updateProjektRechnung('in_abc', { rechnungsnummer: 'WEBKLAR-0009' }) + assert.deepEqual([gesendet.rechnungsnummer, gesendet.aktiv, gesendet.betragCents], ['WEBKLAR-0009', true, 22500]) + const storniert = await updateProjektRechnung('in_abc', { aktiv: false, arbeitsblattIds: ['w1'] }) + assert.deepEqual([storniert.aktiv, storniert.arbeitsblattIds, storniert.rechnungsnummer], [false, ['w1'], 'WEBKLAR-0009']) + assert.equal(aw.docs(COL).get('in_abc').aktiv, false) + for (const id of ['in_gibtsnicht', '../x', '']) { + await assert.rejects(() => updateProjektRechnung(id, { aktiv: false }), (err) => err instanceof AuftragError && err.status === 404, id) + } +}) + +test('listProjektRechnungen: nur aktive, Filter, nach erstelltAm aufsteigend, mehr als 25 Treffer', async () => { + seed('in_a', { erstelltAm: '2026-10-03T10:00:00.000Z', auftragId: 'ab1' }) + seed('in_b', { erstelltAm: '2026-10-01T10:00:00.000Z', auftragId: 'ab1' }) + seed('in_c', { aktiv: false, erstelltAm: '2026-10-02T10:00:00.000Z', auftragId: 'ab1' }) + seed('in_d', { kundenId: 'c2', projektId: 'p2', erstelltAm: '2026-10-04T10:00:00.000Z' }) + const ids = async (filter) => (await listProjektRechnungen(filter)).map((r) => r.invoiceId) + assert.deepEqual(await ids({ kundenId: 'c1' }), ['in_b', 'in_a']) + assert.deepEqual(await ids({ kundenId: 'c1', nurAktiv: false }), ['in_b', 'in_c', 'in_a']) + assert.deepEqual(await ids({ auftragId: 'ab1' }), ['in_b', 'in_a']) + assert.deepEqual(await ids({ projektId: 'p2' }), ['in_d']) + assert.deepEqual(await ids(), ['in_b', 'in_a', 'in_d']) + for (let i = 0; i < 30; i += 1) seed(`in_m${i}`, { kundenId: 'c9' }) + assert.equal((await listProjektRechnungen({ kundenId: 'c9' })).length, 30) +}) + +test('listProjektRechnungen: ein angegebener, aber leerer oder ungültiger Filter ist ein Fehler, nie „alle“', async () => { + seed('in_a') + for (const filter of [{ kundenId: '' }, { projektId: null }, { auftragId: 7 }, { nurAktiv: 'ja' }]) { + await assert.rejects(() => listProjektRechnungen(filter), TypeError, JSON.stringify(filter)) + } +}) + +test('umhaengen hängt alle Dokumente einer AB (auch stornierte) an die neue AB, ist wiederholbar und lässt andere unberührt', async () => { + seed('in_a', { auftragId: 'ab1' }) + seed('in_b', { auftragId: 'ab1', aktiv: false }) + seed('in_c', { auftragId: 'ab2' }) + assert.equal(await umhaengen('ab1', 'ab3'), 2) + assert.deepEqual([...aw.docs(COL).values()].map((d) => [d.invoiceId, d.auftragId]).sort(), [['in_a', 'ab3'], ['in_b', 'ab3'], ['in_c', 'ab2']]) + assert.equal(await umhaengen('ab1', 'ab3'), 0) + assert.equal(await umhaengen('ab3', 'ab3'), 0) + for (const [alt, neu] of [['', 'ab3'], ['ab1', ''], [undefined, 'ab3'], ['ab1', null]]) { + await assert.rejects(() => umhaengen(alt, neu), TypeError, JSON.stringify([alt, neu])) + } +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeProjektRechnungenStore.test.js` +Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `server/services/auftraege/projektRechnungenStore.js`. + +- [ ] **Step 3: Implementieren** + +`server/services/auftraege/projektRechnungenStore.js` (neu): +- Rechnungs-ID-Format: `/^in_\w{1,33}$/` (wie `STRIPE_INVOICE_ID = /^in_\w+$/` im Bestand, zusätzlich höchstens 36 Zeichen, die Grenze der Appwrite-Dokument-ID; der Stripe-Nachbau vergibt `in_test_`, V1 und Nachträge). +- Dokument → Objekt: `invoiceId` = `$id`; `arbeitsblattIds` aus `arbeitsblattIdsJson` (nur nicht leere Texte, kaputtes JSON → `[]`); `aktiv` = `doc.aktiv === true`; `kundenId`, `projektId`, `auftragId`, `art`, `rechnungsnummer`, `erstelltVon`: Text, sonst `''`; `betragCents`: ganze Zahl, sonst `0`; `erstelltAm`: ISO mit `Z` (`new Date(...).toISOString()`), Rückgriff `$createdAt`. +- `getProjektRechnung(invoiceId)`: ungültige ID → `null` ohne Abfrage; 404 → `null`; andere Fehler weiterreichen. +- `createProjektRechnung(invoiceId, daten)`: Prüfungen, jede Verletzung ist ein `TypeError`: ID passt zum Format; `kundenId` nicht leerer Text; `art` ∈ `['festbetrag', 'aufwand', 'anzahlung', 'schluss']` (`frei` hat nie ein Dokument); `betragCents` ganze Zahl ≥ 0; `arbeitsblattIds` (Vorgabe `[]`) Liste nicht leerer Texte; `aktiv` (Vorgabe `true`) Wahrheitswert; `projektId`, `auftragId`, `rechnungsnummer`, `erstelltVon` Text (Vorgabe `''`). Das Dokument bekommt die Rechnungs-ID als Dokument-ID und als Feld `invoiceId`, `arbeitsblattIdsJson = JSON.stringify(...)` (länger als 20000 Zeichen → `AuftragError(400, 'Zu viele Arbeitsblätter in einer Rechnung.')`), `erstelltAm` = jetzt. Appwrite-Antwort 409 → `AuftragError(409, 'Zu dieser Rechnung gibt es schon einen Eintrag.')`. Rückgabe: das Objekt. +- `updateProjektRechnung(invoiceId, patch)`: ungültige oder unbekannte ID → `AuftragError(404, 'Projektrechnung nicht gefunden.')`; änderbar sind `kundenId`, `projektId`, `auftragId`, `art`, `betragCents`, `arbeitsblattIds`, `rechnungsnummer`, `aktiv`, `erstelltVon` mit denselben Prüfungen wie beim Anlegen (nur für die genannten Schlüssel); `invoiceId`, `erstelltAm` und fremde Schlüssel werden ignoriert. Rückgabe: das Objekt nach der Änderung. +- `listProjektRechnungen(filter = {})`: je Schlüssel `kundenId`, `projektId`, `auftragId`, der nicht `undefined` ist, ein `Query.equal` (nicht leerer Text, sonst `TypeError`); `nurAktiv` (Vorgabe `true`, muss ein Wahrheitswert sein, sonst `TypeError`) fügt `Query.equal('aktiv', true)` hinzu. Abfrage mit `listAllDocuments`, sortiert nach `erstelltAm` **aufsteigend**, dann `invoiceId`. +- `umhaengen(auftragIdAlt, auftragIdNeu)`: beide nicht leerer Text, sonst `TypeError`; sind sie gleich → `0`. Sonst alle Dokumente mit `auftragId === auftragIdAlt` (`listProjektRechnungen({ auftragId: auftragIdAlt, nurAktiv: false })`) auf `auftragIdNeu` setzen; Rückgabe: Anzahl. Bei einem Fehler bleibt ein Teilstand, ein erneuter Aufruf setzt fort. + +- [ ] **Step 4: Lauf, erwartet PASS** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeProjektRechnungenStore.test.js` +Expected: PASS (sieben Tests, `fail 0`). + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/projektRechnungenStore.js test/auftraegeProjektRechnungenStore.test.js && git commit -m "Aufträge: Speicher der Projektrechnungen" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 9: Arbeitsblätter + +**Files:** +- Create: `server/services/auftraege/arbeitsblaetter.js` +- Test: `test/auftraegeArbeitsblaetter.test.js` + +**Interfaces:** +- Consumes: `listProjektRechnungen` (Task 8); `VIERTELSTUNDE`, `VEREINBARTE_STATUS` (Task 2); `config.collections.worksheets`; `listAllDocuments`, `Query` aus `appwriteAdmin.js`; `customerWorkEntries` aus `server/services/customerProjectView.js`; `worksheetMinutes` aus `server/services/worksheetMinutes.js`; `berlinDate` aus `server/services/finance/dates.js`. +- Produces (Übersicht V3/V4): `ladeArbeitsblaetter(ticketIds) → Promise`, `ladeArbeitsblaetterNachIds(ids) → Promise` (liefert nur gefundene Blätter, der Aufrufer vergleicht die Anzahl), `abrechenbar(blatt) → boolean`, `abgerechneteArbeitsblaetter(kundenId) → Promise>` (`rechnungsnummer` ist bei einem Entwurf `''`; die Team-Route zeigt dafür „Entwurf“), `offeneArbeitsblaetter(ticketIds, kundenId) → Promise`, `zeitquelleFuerProjekt(projekt, auftraege) → string[]`. Form `Arbeitsblatt`: `{ id, ticketId, woid, datum, minuten, text, aboZeit, kommentar }`. + +- [ ] **Step 1: Failing Test schreiben** + +```js +// test/auftraegeArbeitsblaetter.test.js +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js' + +// worksheets gehört dem Ticketsystem und hat im Nachbau kein Schema (jedes Feld ist erlaubt). +const aw = await startAppwriteMock({ schema: { projektRechnungen: schemaKeys('projektRechnungen') }, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { createProjektRechnung, updateProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js') +const A = await import('../server/services/auftraege/arbeitsblaetter.js') + +const WS = config.collections.worksheets +const PR = config.collections.projektRechnungen +beforeEach(async () => { + for (const col of [WS, PR]) for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id) + aw.resetListCalls() +}) + +const UNTERSTUETZUNG = JSON.stringify([{ employeeId: 'e2', name: 'Justin Klein', task: 'Review' }]) +const ZWEI_UNTERSTUETZER = JSON.stringify([ + { employeeId: 'e2', name: 'Justin Klein', task: 'Review' }, + { employeeId: 'e3', name: 'Nico', task: 'Test' }, +]) +const blatt = (id, felder = {}) => + aw.seed(WS, { + $id: id, workorderId: 't1', woid: '100101', wsid: id, startDate: '06.10.2026', endDate: '06.10.2026', totalTime: 60, + details: 'Startseite überarbeitet', serviceType: 'REMOTE', employeeName: 'Kenso Grimm', countsToPlan: false, isComment: false, supporters: '', ...felder, + }) +const nachId = async (ticketIds = ['t1']) => Object.fromEntries((await A.ladeArbeitsblaetter(ticketIds)).map((b) => [b.id, b])) + +test('ladeArbeitsblaetter liefert die normierte Form je Blatt', async () => { + blatt('w1') + assert.deepEqual(await A.ladeArbeitsblaetter(['t1']), [ + { id: 'w1', ticketId: 't1', woid: '100101', datum: '2026-10-06', minuten: 60, text: 'Startseite überarbeitet', aboZeit: false, kommentar: false }, + ]) +}) + +test('Datum eines Arbeitsblatts: TT.MM.JJJJ, ISO als Berliner Tag, leer fällt auf $createdAt, ein unmögliches Datum auch', async () => { + const faelle = { + d1: [{ startDate: '06.10.2026' }, '2026-10-06'], + d2: [{ startDate: '', endDate: '07.10.2026' }, '2026-10-07'], + d3: [{ startDate: '2026-10-06T21:59:59.000Z' }, '2026-10-06'], // 23:59:59 in Berlin + d4: [{ startDate: '2026-10-06T22:30:00.000Z' }, '2026-10-07'], // 00:30 in Berlin am nächsten Tag + d5: [{ startDate: '2026-10-06' }, '2026-10-06'], + d6: [{ startDate: '', $createdAt: '2026-10-08T21:30:00.000+00:00' }, '2026-10-08'], + d7: [{ startDate: '', $createdAt: '2026-10-08T22:00:00.000+00:00' }, '2026-10-09'], + d8: [{ startDate: '31.02.2026', $createdAt: '2026-10-01T10:00:00.000+00:00' }, '2026-10-01'], + d9: [{ startDate: 'gestern', $createdAt: '2026-10-02T10:00:00.000+00:00' }, '2026-10-02'], + d10: [{ startDate: '12.13.2026', $createdAt: '2026-10-03T10:00:00.000+00:00' }, '2026-10-03'], // nie als Monatsangabe der US-Schreibweise lesen + } + for (const [id, [felder]] of Object.entries(faelle)) blatt(id, { endDate: '', ...felder }) + const datum = Object.fromEntries(Object.values(await nachId()).map((b) => [b.id, b.datum])) + assert.deepEqual(datum, Object.fromEntries(Object.entries(faelle).map(([id, [, tag]]) => [id, tag]))) +}) + +test('Minuten: je Blatt inklusive Unterstützung auf 15 Minuten aufgerundet', async () => { + const faelle = [ + ['m1', { totalTime: 10 }, 15], + ['m2', { totalTime: 15 }, 15], + ['m3', { totalTime: 16 }, 30], + ['m4', { totalTime: 0 }, 0], + ['m5', { totalTime: 'abc' }, 0], + ['m6', { totalTime: 80, supporters: UNTERSTUETZUNG }, 135], // 80 + 45 Unterstützung = 125 + ['m7', { totalTime: 20, supporters: UNTERSTUETZUNG }, 45], // 20 + 15 = 35 + ['m8', { totalTime: 60, supporters: ZWEI_UNTERSTUETZER }, 120], // 60 + 30 + 30 + ] + for (const [id, felder] of faelle) blatt(id, felder) + const minuten = Object.fromEntries(Object.values(await nachId()).map((b) => [b.id, b.minuten])) + assert.deepEqual(minuten, Object.fromEntries(faelle.map(([id, , erwartet]) => [id, erwartet]))) +}) + +test('Abo-Zeit und Kommentar werden erkannt und sind nie abrechenbar, ebenso 0 Minuten', async () => { + blatt('f1') + blatt('f2', { countsToPlan: true }) + blatt('f3', { isComment: true, totalTime: 0 }) + blatt('f4', { totalTime: 0 }) + const b = await nachId() + assert.deepEqual([b.f1.aboZeit, b.f1.kommentar, A.abrechenbar(b.f1)], [false, false, true]) + assert.deepEqual([b.f2.aboZeit, A.abrechenbar(b.f2)], [true, false]) + assert.deepEqual([b.f3.kommentar, A.abrechenbar(b.f3)], [true, false]) + assert.equal(A.abrechenbar(b.f4), false) + const basis = { aboZeit: false, kommentar: false, minuten: 15 } + assert.equal(A.abrechenbar(basis), true) + assert.equal(A.abrechenbar({ ...basis, aboZeit: true }), false) + assert.equal(A.abrechenbar({ ...basis, kommentar: true }), false) + assert.equal(A.abrechenbar({ ...basis, minuten: 0 }), false) +}) + +test('Text: wie in der Kundenansicht, eine Zeile, höchstens 200 Zeichen, Git ohne Kürzel, Link und Namen', async () => { + blatt('x1', { details: ' Startseite\n\nKontaktformular überarbeitet ' }) + blatt('x2', { details: 'ä'.repeat(300) }) + blatt('x3', { + serviceType: 'GIT', + totalTime: 45, + details: [ + 'Git Push auf `knso/x` (Branch: main)', + 'Commit: 88d3c37', + '', + '- [88d3c37](https://git.webklar.com/knso/x/commit/88d3c37ab7b15337772cf59a5a9294ec0cc8cca4): feat: Kontaktformular repariert (Kenso Grimm)', + '- [1a2b3c4](https://git.webklar.com/knso/x/commit/1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b): fix: Footer', + '', + ].join('\n'), + }) + blatt('x4', { isComment: true, details: 'nur intern' }) + blatt('x5', { details: '' }) + const b = await nachId() + assert.equal(b.x1.text, 'Startseite Kontaktformular überarbeitet') + assert.equal(b.x2.text.length, 200) + assert.ok(b.x2.text.endsWith('…')) + assert.equal(b.x3.text, 'Kontaktformular repariert; Footer') + assert.doesNotMatch(b.x3.text, /88d3c37|git\.webklar|Kenso/) + assert.equal(b.x4.text, '') + assert.equal(b.x5.text, '') +}) + +test('ladeArbeitsblaetter: nur die genannten Tickets, nach Datum und ID sortiert, in Blöcken zu 100 Tickets', async () => { + blatt('w3', { startDate: '07.10.2026' }) + blatt('w2', { startDate: '06.10.2026' }) + blatt('w1', { startDate: '06.10.2026' }) + blatt('w9', { workorderId: 't2', startDate: '05.10.2026' }) + blatt('w8', { workorderId: 't3' }) + assert.deepEqual((await A.ladeArbeitsblaetter(['t1', 't2'])).map((b) => b.id), ['w9', 'w1', 'w2', 'w3']) + assert.deepEqual((await A.ladeArbeitsblaetter(['t1', 't1', '', null, 5])).map((b) => b.id), ['w1', 'w2', 'w3']) + aw.resetListCalls() + for (const leer of [[], undefined, null, [''], 'kein-array']) assert.deepEqual(await A.ladeArbeitsblaetter(leer), [], String(leer)) + assert.equal(aw.listCalls(WS).length, 0, 'ohne Ticket keine Abfrage') + const viele = Array.from({ length: 250 }, (_, i) => `t_${i}`) + viele[7] = 't1' + assert.deepEqual((await A.ladeArbeitsblaetter(viele)).map((b) => b.id), ['w1', 'w2', 'w3']) + const aufrufe = aw.listCalls(WS) + assert.equal(aufrufe.length, 3) + for (const aufruf of aufrufe) { + assert.ok(aufruf.queries.find((q) => q.method === 'equal' && q.attribute === 'workorderId').values.length <= 100) + } +}) + +test('ladeArbeitsblaetterNachIds liefert nur vorhandene Blätter, sortiert wie ladeArbeitsblaetter', async () => { + blatt('w1', { startDate: '07.10.2026' }) + blatt('w2', { workorderId: 't2', startDate: '06.10.2026' }) + assert.deepEqual((await A.ladeArbeitsblaetterNachIds(['w1', 'w2', 'gibtsnicht', 'w1'])).map((b) => [b.id, b.ticketId]), [['w2', 't2'], ['w1', 't1']]) + assert.deepEqual(await A.ladeArbeitsblaetterNachIds([]), []) +}) + +test('abgerechneteArbeitsblaetter: nur aktive Projektrechnungen dieses Kunden; ein Entwurf hat die Nummer leer', async () => { + const daten = (extra) => ({ kundenId: 'c1', projektId: 'p1', art: 'aufwand', betragCents: 1000, ...extra }) + await createProjektRechnung('in_gesendet', daten({ arbeitsblattIds: ['w1', 'w2'] })) + await updateProjektRechnung('in_gesendet', { rechnungsnummer: 'WEBKLAR-0009' }) + await createProjektRechnung('in_entwurf', daten({ arbeitsblattIds: ['w5'] })) + await createProjektRechnung('in_storniert', daten({ arbeitsblattIds: ['w3'] })) + await updateProjektRechnung('in_storniert', { aktiv: false }) + await createProjektRechnung('in_fremd', daten({ kundenId: 'c2', arbeitsblattIds: ['w4'] })) + const karte = await A.abgerechneteArbeitsblaetter('c1') + assert.ok(karte instanceof Map) + assert.deepEqual([...karte.entries()].sort(), [ + ['w1', { invoiceId: 'in_gesendet', rechnungsnummer: 'WEBKLAR-0009' }], + ['w2', { invoiceId: 'in_gesendet', rechnungsnummer: 'WEBKLAR-0009' }], + ['w5', { invoiceId: 'in_entwurf', rechnungsnummer: '' }], + ]) + await assert.rejects(() => A.abgerechneteArbeitsblaetter(''), TypeError) +}) + +test('offeneArbeitsblaetter: abrechenbar und noch nicht abgerechnet; ein stornierter Eintrag gibt das Blatt frei', async () => { + blatt('w1') // abgerechnet (siehe unten) + blatt('w6', { totalTime: 30 }) // offen + blatt('w7', { countsToPlan: true }) // Abo-Zeit + blatt('w8', { isComment: true, totalTime: 0 }) // Kommentar + blatt('w9', { totalTime: 0 }) // 0 Minuten + blatt('w10', { workorderId: 't2' }) // anderes Ticket + await createProjektRechnung('in_a', { kundenId: 'c1', projektId: 'p1', art: 'aufwand', betragCents: 1000, arbeitsblattIds: ['w1'] }) + assert.deepEqual((await A.offeneArbeitsblaetter(['t1'], 'c1')).map((b) => b.id), ['w6']) + await updateProjektRechnung('in_a', { aktiv: false }) + assert.deepEqual((await A.offeneArbeitsblaetter(['t1'], 'c1')).map((b) => b.id), ['w1', 'w6']) +}) + +test('zeitquelleFuerProjekt: Vereinigung der vereinbarten ABs, sonst das Ticket des Projekts', () => { + const ab = (status, zeitquelle) => ({ status, zeitquelle }) + const projekt = { $id: 'p1', ticketId: 't0' } + const alle = [ + ab('versendet', ['t1', 't2']), ab('bestaetigt', ['t2', 't3']), ab('abgerechnet', ['t4']), + ab('entwurf', ['t9']), ab('storniert', ['t8']), ab('ersetzt', ['t7']), ab('widerrufen', ['t6']), + ] + assert.deepEqual(A.zeitquelleFuerProjekt(projekt, alle), ['t1', 't2', 't3', 't4']) + assert.deepEqual(A.zeitquelleFuerProjekt(projekt, []), ['t0']) + assert.deepEqual(A.zeitquelleFuerProjekt(projekt, [ab('entwurf', ['t9'])]), ['t0']) + assert.deepEqual(A.zeitquelleFuerProjekt(projekt, [ab('versendet', [])]), ['t0']) + assert.deepEqual(A.zeitquelleFuerProjekt({ $id: 'p1' }, []), []) + assert.deepEqual(A.zeitquelleFuerProjekt({ ticketId: '' }, undefined), []) + assert.deepEqual(A.zeitquelleFuerProjekt(null, []), []) +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeArbeitsblaetter.test.js` +Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `server/services/auftraege/arbeitsblaetter.js`. + +- [ ] **Step 3: Implementieren** + +`server/services/auftraege/arbeitsblaetter.js` (neu). Je Dokument aus `worksheets` entsteht ein `Arbeitsblatt`: +- `id` = `$id`; `ticketId` = `String(workorderId ?? '')`; `woid` = `String(woid ?? '')`; `aboZeit` = `countsToPlan === true`; `kommentar` = `isComment === true` (nur echte Wahrheitswerte zählen). +- `minuten` = `Math.ceil(worksheetMinutes(doc) / VIERTELSTUNDE) * VIERTELSTUNDE` (`worksheetMinutes` enthält die Unterstützung, ein Wert, der keine Zahl ist, zählt als 0). +- `datum` (Berliner Kalendertag `YYYY-MM-DD`, Regel wie `parseSheetDate` in `server/services/hourLedger.js`, das nicht exportiert wird und unverändert bleibt): Rohwert = `startDate || endDate || ''`. Hat er die Form `^(\d{2})\.(\d{2})\.(\d{4})$`, entscheidet allein diese Form: ein echter Kalendertag ergibt unmittelbar `JJJJ-MM-TT`, ein unmöglicher (31.02.) wird übersprungen (nie `new Date` darauf anwenden: V8 läse „12.13.2026“ als Dezember). Sonst zählen als Kandidaten der Reihe nach der Rohwert (nur wenn nicht leer und nicht von der Form `TT.MM.JJJJ`), `createdAt` und `$createdAt`; der erste, den `new Date(...)` als gültigen Zeitpunkt liest, ergibt `berlinDate(date)`. Ist keiner gültig, `''`. +- `text`: `customerWorkEntries([doc], { includeUpdates: true })` (aus `../customerProjectView.js`) liefert eine Liste mit höchstens einem Eintrag; ohne Eintrag (Kommentar) → `''`; hat der erste Eintrag `changes` (Git-Push) → `changes.join('; ')`; sonst `entry.text`. Danach Leerraum zusammenfassen (`/\s+/g` → `' '`, `trim`) und bei mehr als 200 Zeichen auf 199 Zeichen kürzen (`trimEnd()`) plus `…`. +- Sortierung der Ergebnisliste: nach `datum`, dann nach `id`, jeweils einfacher Textvergleich. +- Laden: Ticket-IDs bzw. Blatt-IDs entdoppeln, nur nicht leere Texte; ein Argument, das keine Liste ist (auch ein Text), gilt als leere Liste; ohne gültigen Eintrag `[]` ohne Abfrage; in Blöcken zu je 100 Werten mit `listAllDocuments(config.collections.worksheets, [Query.equal('workorderId' | '$id', block)])` (Appwrite begrenzt die Werteliste je `equal`-Abfrage). +- `abrechenbar(blatt)` = `!blatt.aboZeit && !blatt.kommentar && blatt.minuten > 0`. +- `abgerechneteArbeitsblaetter(kundenId)`: `listProjektRechnungen({ kundenId })` (nur aktive; ungültiger `kundenId` → dessen `TypeError`), je Blatt-ID der erste Eintrag (älteste Rechnung) gewinnt: `{ invoiceId, rechnungsnummer }`. +- `offeneArbeitsblaetter(ticketIds, kundenId)`: `ladeArbeitsblaetter(ticketIds)` gefiltert auf `abrechenbar(b)` und nicht in der Abgerechnet-Karte. +- `zeitquelleFuerProjekt(projekt, auftraege)`: Vereinigung der `zeitquelle`-Listen aller Aufträge mit `status` in `VEREINBARTE_STATUS` (Reihenfolge des Auftretens, ohne Doppelte und Leereinträge); ist sie leer, `[projekt.ticketId]` ohne Leereintrag; `projekt` und `auftraege` dürfen fehlen (`null`/`undefined`). Es wird **nicht** nach Projekt gefiltert: der Aufrufer übergibt nur die Aufträge dieses Projekts. + +- [ ] **Step 4: Lauf, erwartet PASS** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeArbeitsblaetter.test.js` +Expected: PASS (zehn Tests, `fail 0`). + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/arbeitsblaetter.js test/auftraegeArbeitsblaetter.test.js && git commit -m "Aufträge: Arbeitsblätter laden, normieren und als abgerechnet erkennen" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 10: Sichten auf die Auftragsbestätigung + +**Files:** +- Create: `server/services/auftraege/auftragSicht.js` +- Test: `test/auftraegeAuftragSicht.test.js` + +**Interfaces:** +- Consumes: `AB_TEXTE`, `BESTAETIGUNG_TEXT`, `FRUEHBEGINN_TEXT` (Task 6); `AGB_FASSUNG`, `KUNDENARTEN`, `LIMITS`, `STUNDENSATZ_STANDARD_CENTS` (Task 2); `withdrawalDeadline` aus `server/services/deadlines.js` (liefert `{ lastDay: 'YYYY-MM-DD', endsAt: Date }`); `euroDe` aus `server/services/finance/format.js`; `addDays`, `berlinDate` aus `server/services/finance/dates.js`. +- Produces (Übersicht V3/V4): `kundeSnapshot(customerDoc) → KundeSnapshot`, `fruehesterBeginn(auftrag) → 'YYYY-MM-DD'|null`, `zahlungsplanText(auftrag) → string`, `aufwandText(auftrag) → string|null`, `auftragFuerTeam(auftrag) → AuftragTeam`, `oeffentlicheAnsicht(auftrag) → AuftragOeffentlich`. Alles rein (kein Appwrite, keine Konfiguration). + +- [ ] **Step 1: Failing Test schreiben** + +```js +// test/auftraegeAuftragSicht.test.js +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { kundeSnapshot, fruehesterBeginn, zahlungsplanText, aufwandText, auftragFuerTeam, oeffentlicheAnsicht } from '../server/services/auftraege/auftragSicht.js' +import { AGB_FASSUNG } from '../server/services/auftraege/konstanten.js' +import { AB_TEXTE, BESTAETIGUNG_TEXT, FRUEHBEGINN_TEXT } from '../server/services/auftraege/texte.js' +import { euroDe } from '../server/services/finance/format.js' + +const KUNDE = { name: 'Anna Beispiel', firma: '', strasse: 'Hauptstr. 1', plz: '67722', ort: 'Winnweiler', land: '', email: 'anna@example.invalid', kundenart: 'privat', kundennummer: 'K-1001' } +const festpreis = (felder = {}) => ({ + id: 'ab1', nummer: 'AB-2026-0002', jahr: 2026, laufnummer: 2, status: 'versendet', kundenId: 'c1', kunde: KUNDE, projektId: 'p1', titel: 'Website Bäckerei', + zeitquelle: ['t1'], art: 'festpreis', + positionen: [{ beschreibung: 'Startseite', menge: 2, einzelpreisCents: 30000 }, { beschreibung: 'Kontaktformular', menge: 1, einzelpreisCents: 20000 }], + stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000, zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14, + auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '2026-11-01', fertigBis: '', ersetztId: '', ersetztDurchId: '', texteFassung: '2026-10-06', agbFassung: '2026-09-30', + pdfDatei: 'c1/ab1.pdf', pdfSha256: 'a'.repeat(64), gesendetAm: '2026-10-06T10:00:00.000Z', gesendetAn: 'anna@example.invalid', mailStatus: 'gesendet', mailFehler: '', + tokenHash: 'b'.repeat(64), tokenGueltigBis: '2026-12-05T10:00:00.000Z', bestaetigtAm: null, bestaetigtUeber: '', bestaetigtPdfSha256: '', fruehbeginn: false, fruehbeginnFassung: '', + storniertAm: null, widerrufenAm: null, erstelltVon: 'kenso@webklar.com', erstelltAm: '2026-10-05T09:00:00.000Z', geaendertAm: '2026-10-06T10:00:00.000Z', ...felder, +}) +const aufwand = (felder = {}) => + festpreis({ art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' }, ...felder }) +const LEER = { name: '', firma: '', strasse: '', plz: '', ort: '', land: '', email: '', kundenart: '', kundennummer: '' } + +test('kundeSnapshot übernimmt die Kundendaten getrimmt; eine unbekannte Kundenart wird leer; null und {} sind leer', () => { + const doc = { $id: 'c1', name: ' Anna Beispiel ', companyName: '', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler', country: '', email: 'anna@example.invalid', kundenart: 'privat', code: 'K-1001', phone: '123', customerStatus: 'active' } + assert.deepEqual(kundeSnapshot(doc), KUNDE) + assert.deepEqual(kundeSnapshot({ ...doc, companyName: ' Bäckerei Beispiel GmbH ', kundenart: 'unternehmen' }), { ...KUNDE, firma: 'Bäckerei Beispiel GmbH', kundenart: 'unternehmen' }) + assert.equal(kundeSnapshot({ name: 'X', kundenart: 'firma' }).kundenart, '') + assert.deepEqual(kundeSnapshot({}), LEER) + assert.deepEqual(kundeSnapshot(null), LEER) +}) + +test('kundeSnapshot kürzt lange Werte, so dass die Spalte kundeJson (2000 Zeichen) reicht', () => { + const lang = 'x'.repeat(500) + const snapshot = kundeSnapshot({ name: lang, companyName: lang, street: lang, postalCode: lang, city: lang, country: lang, email: lang, kundenart: 'privat', code: lang }) + assert.ok(JSON.stringify(snapshot).length <= 2000) + assert.equal(snapshot.name.length, 120) +}) + +test('fruehesterBeginn: Tag nach Ablauf der Widerrufsfrist, nur für Privatpersonen ohne Frühbeginn', () => { + const faelle = [ + ['2026-10-06T10:00:00.000Z', '2026-10-21'], + ['2026-10-06T22:30:00.000Z', '2026-10-22'], // in Berlin schon der 07.10. + ['2026-10-10T08:00:00.000Z', '2026-10-27'], // Fristende fiele auf Samstag, 26.10. ist der Montag + ['2026-10-17T08:00:00.000Z', '2026-11-03'], // 31.10. (Samstag, Feiertag) und 01.11. (Sonntag), Fristende Montag 02.11. + ] + for (const [gesendetAm, erwartet] of faelle) assert.equal(fruehesterBeginn(festpreis({ gesendetAm })), erwartet, gesendetAm) + assert.equal(fruehesterBeginn(festpreis({ fruehbeginn: true })), null) + for (const kundenart of ['unternehmen', '']) assert.equal(fruehesterBeginn(festpreis({ kunde: { ...KUNDE, kundenart } })), null, kundenart) + assert.equal(fruehesterBeginn(festpreis({ kunde: null })), null) + assert.equal(fruehesterBeginn(festpreis({ gesendetAm: null, status: 'entwurf' })), null) + assert.equal(fruehesterBeginn(festpreis({ gesendetAm: 'kaputt' })), null) +}) + +test('zahlungsplanText: Anzahlung nach Prozent gerundet, Rest als Differenz', () => { + const text = zahlungsplanText(festpreis()) + assert.equal(text.split(euroDe(40000)).length, 3, text) // Anzahlung und Rest je 400,00 € + assert.match(text, /50\s?%/) + assert.match(text, /\b14\b/) + // Math.round(10001 × 33 / 100) = 3300, Rest 6701 + const ungerade = zahlungsplanText(festpreis({ summeCents: 10001, zahlungsplan: { art: 'anzahlung', prozent: 33, text: '' } })) + assert.ok(ungerade.includes(euroDe(3300)) && ungerade.includes(euroDe(6701)), ungerade) +}) + +test('zahlungsplanText: nach Abnahme, monatlich, frei; Anzahlung ohne Summe fällt auf nach Abnahme zurück', () => { + assert.match(zahlungsplanText(festpreis({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })), /\b14\b/) + assert.match(zahlungsplanText(aufwand({ zahlungszielTage: 7 })), /\b7\b/) + assert.ok(zahlungsplanText(festpreis({ zahlungsplan: { art: 'frei', prozent: null, text: 'Zahlung in zwei Raten.' } })).includes('Zahlung in zwei Raten')) + const ohneSumme = aufwand({ zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' } }) + assert.equal(zahlungsplanText(ohneSumme), zahlungsplanText({ ...ohneSumme, zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })) +}) + +test('aufwandText: nur bei Aufwand, mit Stundensatz und Schätzung in Stunden', () => { + const text = aufwandText(aufwand()) + assert.ok(text.includes(euroDe(10000))) + assert.match(text, /\b10\b/) + assert.doesNotMatch(aufwandText(aufwand({ schaetzungMinuten: null })), /\b10\b/) + assert.match(aufwandText(aufwand({ schaetzungMinuten: 135 })), /2,25/) + assert.match(aufwandText(aufwand({ schaetzungMinuten: 90 })), /1,5\b/) + assert.ok(aufwandText(aufwand({ stundensatzCents: null })).includes(euroDe(10000)), 'ohne Satz gilt der Standard') + assert.equal(aufwandText(festpreis()), null) +}) + +test('auftragFuerTeam: ohne Token-Hash, PDF-Pfad und Bestätigungs-Prüfsumme, mit den abgeleiteten Feldern', () => { + const auftrag = festpreis() + const kopie = structuredClone(auftrag) + const sicht = auftragFuerTeam(auftrag) + assert.deepEqual(auftrag, kopie, 'verändert die AB nicht') + const entfernt = ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256'] + for (const feld of entfernt) assert.equal(Object.hasOwn(sicht, feld), false, feld) + assert.equal(sicht.pdfSha256, 'a'.repeat(64)) + assert.equal(sicht.pdfVorhanden, true) + assert.equal(sicht.fruehesterBeginn, '2026-10-21') + assert.equal(sicht.zahlungsplanText, zahlungsplanText(auftrag)) + assert.equal(sicht.aufwandText, null) + assert.deepEqual( + Object.keys(sicht).sort(), + [...Object.keys(auftrag).filter((k) => !entfernt.includes(k)), 'pdfVorhanden', 'fruehesterBeginn', 'zahlungsplanText', 'aufwandText'].sort(), + ) + assert.equal(auftragFuerTeam(festpreis({ pdfDatei: '' })).pdfVorhanden, false) +}) + +test('oeffentlicheAnsicht: genau die Felder aus V3, Beträge je Position, Berliner Datum, nichts Internes', () => { + const ansicht = oeffentlicheAnsicht(festpreis()) + assert.deepEqual( + Object.keys(ansicht).sort(), + ['agbUrl', 'aufwandText', 'art', 'bestaetigtAm', 'datum', 'fertigBis', 'fruehbeginn', 'fruehesterBeginn', 'kundenart', 'nummer', 'positionen', 'schaetzungMinuten', 'startAm', 'status', 'stundensatzCents', 'summeCents', 'texte', 'titel', 'zahlungsplanText'].sort(), + ) + assert.deepEqual(ansicht.positionen, [ + { beschreibung: 'Startseite', menge: 2, einzelpreisCents: 30000, summeCents: 60000 }, + { beschreibung: 'Kontaktformular', menge: 1, einzelpreisCents: 20000, summeCents: 20000 }, + ]) + assert.deepEqual( + [ansicht.nummer, ansicht.datum, ansicht.titel, ansicht.art, ansicht.summeCents, ansicht.startAm, ansicht.kundenart, ansicht.status], + ['AB-2026-0002', '2026-10-06', 'Website Bäckerei', 'festpreis', 80000, '2026-11-01', 'privat', 'versendet'], + ) + assert.equal(ansicht.fruehesterBeginn, '2026-10-21') + assert.deepEqual(ansicht.texte, { bestaetigung: BESTAETIGUNG_TEXT, fruehbeginn: FRUEHBEGINN_TEXT, kleinunternehmer: AB_TEXTE.kleinunternehmer }) + assert.equal(ansicht.texte.kleinunternehmer, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.') + assert.equal(ansicht.agbUrl, '/rechtliches/agb-2026-09-30.pdf') + const text = JSON.stringify(ansicht) + for (const geheim of ['b'.repeat(64), 'c1/ab1.pdf', 'anna@example.invalid', 'Hauptstr. 1', 'kenso@webklar.com']) assert.ok(!text.includes(geheim), geheim) +}) + +test('oeffentlicheAnsicht: Unternehmen ohne Frühbeginn-Text, Datum nach Berliner Zeit, AGB-Adresse ohne gespeicherte Fassung', () => { + const firma = oeffentlicheAnsicht(festpreis({ kunde: { ...KUNDE, kundenart: 'unternehmen' }, gesendetAm: '2026-10-06T22:30:00.000Z', agbFassung: '' })) + assert.equal(firma.texte.fruehbeginn, null) + assert.equal(firma.texte.kleinunternehmer, AB_TEXTE.kleinunternehmer) + assert.equal(firma.fruehesterBeginn, null) + assert.equal(firma.datum, '2026-10-07') + assert.equal(firma.agbUrl, `/rechtliches/agb-${AGB_FASSUNG}.pdf`) + const aufwandAnsicht = oeffentlicheAnsicht(aufwand()) + assert.deepEqual([aufwandAnsicht.positionen, aufwandAnsicht.stundensatzCents, aufwandAnsicht.schaetzungMinuten], [[], 10000, 600]) + assert.equal(aufwandAnsicht.aufwandText, aufwandText(aufwand())) +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAuftragSicht.test.js` +Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `server/services/auftraege/auftragSicht.js`. + +- [ ] **Step 3: Implementieren** + +`server/services/auftraege/auftragSicht.js` (neu): +- `kundeSnapshot(customerDoc)`: aus dem Kundendokument (`null`/`{}` ergeben lauter `''`): `name` ← `name`, `firma` ← `companyName`, `strasse` ← `street`, `plz` ← `postalCode`, `ort` ← `city`, `land` ← `country` (so gespeichert, kann `''`, `DE` oder `Deutschland` sein), `email` ← `email`, `kundenart` ← `kundenart` nur wenn in `KUNDENARTEN`, sonst `''`, `kundennummer` ← `code`. Alle Werte getrimmt und auf diese Längen gekürzt: `name` 120, `firma` 120, `strasse` 120, `plz` 20, `ort` 80, `land` 60, `email` 200, `kundennummer` 40 (gekürzte Summe plus JSON passt in 2000 Zeichen). +- `fruehesterBeginn(auftrag)`: nur wenn `auftrag.kunde?.kundenart === 'privat'`, `!auftrag.fruehbeginn` und `gesendetAm` ein gültiger Zeitpunkt ist, sonst `null`: `addDays(withdrawalDeadline(new Date(auftrag.gesendetAm)).lastDay, 1)`. +- `zahlungsplanText(auftrag)`: `ziel` = `zahlungszielTage` (ganze Zahl, sonst `LIMITS.standardZahlungszielTage`). Je `zahlungsplan.art` der Baustein aus `AB_TEXTE.zahlungsplan` (`nach_abnahme({ ziel })`, `monatlich({ ziel })`, `frei({ text, ziel })`, `anzahlung({ prozent, anzahlung, rest, ziel })`). Bei `anzahlung`: `anzahlung = Math.round(summeCents * prozent / 100)`, `rest = summeCents - anzahlung`, beide mit `euroDe`; fehlt `summeCents` (nicht ganze Zahl) oder `prozent` (keine Zahl), gilt der Text von `nach_abnahme`. Ein fehlendes `zahlungsplan` gilt als `nach_abnahme`. +- `aufwandText(auftrag)`: `null`, wenn `art !== 'aufwand'`; sonst `AB_TEXTE.aufwand({ satz: euroDe(stundensatzCents ?? STUNDENSATZ_STANDARD_CENTS), schaetzung })` mit `schaetzung` = Stunden als Text aus `schaetzungMinuten` (`Math.round(minuten / 60 * 100) / 100`, Dezimalkomma, ohne überflüssige Nullen: 600 → `'10'`, 135 → `'2,25'`, 90 → `'1,5'`), `null` wenn leer oder ≤ 0. +- `auftragFuerTeam(auftrag)`: flache Kopie ohne `tokenHash`, `pdfDatei`, `bestaetigtPdfSha256`, plus `pdfVorhanden: Boolean(auftrag.pdfDatei)`, `fruehesterBeginn`, `zahlungsplanText`, `aufwandText`. Verändert das Argument nicht. +- `oeffentlicheAnsicht(auftrag)`: genau die 19 Felder aus V3 (`AuftragOeffentlich`): `datum` = `berlinDate(new Date(gesendetAm))` oder `null` ohne `gesendetAm`; `positionen` mit zusätzlichem `summeCents = menge * einzelpreisCents`; `kundenart` = `auftrag.kunde?.kundenart || ''`; `agbUrl` = `/rechtliches/agb-.pdf` (die bei der AB gespeicherte Fassung, V3); `texte = { bestaetigung: BESTAETIGUNG_TEXT, fruehbeginn: kundenart === 'privat' ? FRUEHBEGINN_TEXT : null, kleinunternehmer: AB_TEXTE.kleinunternehmer }` (V3); `fruehesterBeginn` und die beiden Texte wie oben. Keine Kundendaten, kein Token, keine Pfade. + +- [ ] **Step 4: Lauf, erwartet PASS** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAuftragSicht.test.js` +Expected: PASS (neun Tests, `fail 0`). + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/auftragSicht.js test/auftraegeAuftragSicht.test.js && git commit -m "Aufträge: Sichten auf die Auftragsbestätigung (Team, öffentlich, Frühbeginn)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 11: PDF-Ablage + +**Files:** +- Create: `server/services/auftraege/ablage.js` +- Test: `test/auftraegeAblage.test.js` + +**Interfaces:** +- Consumes: `config.auftraege.uploadDir` (Task 4); `AuftragError` (Task 2). +- Produces (Übersicht V4): `speicherePdf(kundenId, auftragId, buffer) → Promise<{ pdfDatei: '/.pdf', pdfSha256 }>` (legt Verzeichnis 0700 und Datei 0600 an, überschreibt nie), `lesePdf(pdfDatei) → Promise` (nur `^[A-Za-z0-9_-]{1,64}/[A-Za-z0-9_-]{1,64}\.pdf$`, sonst `AuftragError(404, 'PDF nicht gefunden.')`); zusätzlich `entfernePdf(pdfDatei) → Promise` (Nachträge L0: räumt ein abgelegtes PDF weg, wenn danach etwas scheitert; L3 nutzt genau diesen Namen). + +- [ ] **Step 1: Failing Test schreiben** + +```js +// test/auftraegeAblage.test.js +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +// Die Ablage liest ihren Ordner aus config.auftraege.uploadDir: AUFTRAEGE_UPLOAD_DIR muss vor dem ersten Import von config.js stehen. +// Die Ablage liegt in einem Unterordner der Wurzel, damit „Köder“-Dateien in der Wurzel einen Ausbruch aus der Ablage (../) sichtbar machen. +const PRAEFIX = path.join(os.tmpdir(), 'auftraege-ablage-') +const wurzel = mkdtempSync(PRAEFIX) +const basis = path.join(wurzel, 'ablage') +mkdirSync(basis, { mode: 0o700 }) +process.env.AUFTRAEGE_UPLOAD_DIR = basis +after(() => { + if (wurzel.startsWith(PRAEFIX)) rmSync(wurzel, { recursive: true, force: true }) +}) + +const { speicherePdf, lesePdf, entfernePdf } = await import('../server/services/auftraege/ablage.js') +const { AuftragError } = await import('../server/services/auftraege/fehler.js') + +const PDF = Buffer.from('%PDF-1.7\n% Auftragsbestätigung\n%%EOF\n') +const sha = (buffer) => createHash('sha256').update(buffer).digest('hex') +const nichtGefunden = (err) => err instanceof AuftragError && err.status === 404 && err.message === 'PDF nicht gefunden.' + +test('speicherePdf legt /.pdf an: Prüfsumme, Rechte 0700 und 0600, Inhalt unverändert', async () => { + const ergebnis = await speicherePdf('c1', 'ab1', PDF) + assert.deepEqual(ergebnis, { pdfDatei: 'c1/ab1.pdf', pdfSha256: sha(PDF) }) + assert.match(ergebnis.pdfSha256, /^[0-9a-f]{64}$/) + const datei = path.join(basis, 'c1', 'ab1.pdf') + assert.deepEqual(readFileSync(datei), PDF) + assert.equal(statSync(datei).mode & 0o777, 0o600) + assert.equal(statSync(path.join(basis, 'c1')).mode & 0o777, 0o700) + assert.deepEqual(await lesePdf('c1/ab1.pdf'), PDF) +}) + +test('speicherePdf überschreibt nie: die vorhandene Datei bleibt, der zweite Aufruf ist 409', async () => { + await speicherePdf('c2', 'ab1', PDF) + await assert.rejects( + () => speicherePdf('c2', 'ab1', Buffer.from('%PDF-1.7\nanderer Inhalt\n')), + (err) => err instanceof AuftragError && err.status === 409, + ) + assert.deepEqual(readFileSync(path.join(basis, 'c2', 'ab1.pdf')), PDF) + assert.equal((await speicherePdf('c3', 'ab1', PDF)).pdfDatei, 'c3/ab1.pdf', 'dieselbe AB-ID bei einem anderen Kunden ist eine andere Datei') +}) + +test('speicherePdf: ungültige Kennungen und Puffer sind Programmfehler und legen nichts an', async () => { + const vorher = readdirSync(basis).sort() + const kennungen = [['', 'ab1'], ['c1', ''], ['../x', 'ab1'], ['c1', '../ab1'], ['c1/x', 'ab1'], ['c1', 'a.b'], ['c1', 'a'.repeat(65)], [undefined, 'ab1'], ['c1', 5]] + for (const [kunde, ab] of kennungen) await assert.rejects(() => speicherePdf(kunde, ab, PDF), TypeError, JSON.stringify([kunde, ab])) + for (const buffer of [Buffer.alloc(0), 'text', undefined, null, new Uint8Array(3)]) { + await assert.rejects(() => speicherePdf('c9', 'ab9', buffer), TypeError, String(buffer)) + } + assert.deepEqual(readdirSync(basis).sort(), vorher) +}) + +test('lesePdf: Pfadtricks, fremde Zeichen und falsche Form sind 404; erlaubt sind 64 Zeichen aus A–Z, a–z, 0–9, _ und -', async () => { + await speicherePdf('c4', 'ab1', PDF) + writeFileSync(path.join(wurzel, 'geheim.pdf'), 'GEHEIM') // neben der Ablage: ein Ausbruch per ../ würde sie lesen + const falsch = [ + 'c4/../geheim.pdf', '../geheim.pdf', 'c4/ab1.pdf/../../geheim.pdf', '/c4/ab1.pdf', `${basis}/c4/ab1.pdf`, 'c4\\ab1.pdf', + 'c4/ab1.PDF', 'c4/ab1.pdf\0', 'c4/ab1.pdf.exe', 'c4/ab 1.pdf', 'c4/ab1', 'ab1.pdf', 'c4/x/ab1.pdf', '', ' ', + `${'a'.repeat(65)}/ab1.pdf`, `c4/${'a'.repeat(65)}.pdf`, 'c4/ä.pdf', undefined, null, 42, {}, + ] + for (const pdfDatei of falsch) await assert.rejects(() => lesePdf(pdfDatei), nichtGefunden, JSON.stringify(pdfDatei)) + await assert.rejects(() => lesePdf('c4/gibtsnicht.pdf'), nichtGefunden) + const lang = `${'A'.repeat(32)}_${'b'.repeat(31)}` + await speicherePdf('K_1-x', lang, PDF) + assert.deepEqual(await lesePdf(`K_1-x/${lang}.pdf`), PDF) +}) + +test('entfernePdf entfernt eine gespeicherte Datei und prüft den Pfad wie lesePdf; danach lässt sich dieselbe AB erneut speichern', async () => { + await speicherePdf('c5', 'ab1', PDF) + await entfernePdf('c5/ab1.pdf') + assert.equal(existsSync(path.join(basis, 'c5', 'ab1.pdf')), false) + await entfernePdf('c5/ab1.pdf') + await assert.rejects(() => lesePdf('c5/ab1.pdf'), nichtGefunden) + writeFileSync(path.join(wurzel, 'geheim2.pdf'), 'x') + for (const pfad of ['../geheim2.pdf', 'c5/../geheim2.pdf']) await assert.rejects(() => entfernePdf(pfad), nichtGefunden, pfad) + assert.ok(existsSync(path.join(wurzel, 'geheim2.pdf'))) + assert.equal((await speicherePdf('c5', 'ab1', PDF)).pdfDatei, 'c5/ab1.pdf') +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAblage.test.js` +Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `server/services/auftraege/ablage.js`. + +- [ ] **Step 3: Implementieren** + +`server/services/auftraege/ablage.js` (neu, Verzeichnis `config.auftraege.uploadDir` wird bei jedem Aufruf gelesen): +- Kennungen müssen Texte im Format `^[A-Za-z0-9_-]{1,64}$` sein (eine Zahl oder `undefined` ist ungültig, auch wenn ihre Textform passen würde); Dateiname immer `/.pdf`; Pfadformat für Lesen und Löschen `^[A-Za-z0-9_-]{1,64}/[A-Za-z0-9_-]{1,64}\.pdf$` (kein Punkt außer vor `pdf`, damit kein `..` möglich ist; `typeof pdfDatei !== 'string'` passt nie). +- `speicherePdf(kundenId, auftragId, buffer)`: prüft **zuerst** beide Kennungen und `Buffer.isBuffer(buffer) && buffer.length > 0` (`TypeError`, nichts wird angelegt). Dann `mkdir(/, { recursive: true, mode: 0o700 })`, Datei mit `writeFile(pfad, buffer, { flag: 'wx', mode: 0o600 })` (existiert sie schon: `AuftragError(409, 'Das PDF dieser Auftragsbestätigung ist schon gespeichert.')`, die vorhandene Datei bleibt unberührt; scheitert das Schreiben nach dem Anlegen, die Teildatei entfernen und den Fehler weiterreichen). Rückgabe `{ pdfDatei, pdfSha256 }` mit `pdfSha256` = SHA-256 des Puffers als Kleinbuchstaben-Hex. +- `lesePdf(pdfDatei)`: Pfadformat prüfen, sonst `AuftragError(404, 'PDF nicht gefunden.')`; Datei lesen; `ENOENT` → dieselbe 404; andere Fehler weiterreichen. +- `entfernePdf(pdfDatei)`: Pfadformat wie `lesePdf` (sonst dieselbe 404); `unlink`, `ENOENT` ist folgenlos. Das Kundenverzeichnis bleibt stehen. + +- [ ] **Step 4: Lauf, erwartet PASS** + +Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAblage.test.js` +Expected: PASS (fünf Tests, `fail 0`). + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git add server/services/auftraege/ablage.js test/auftraegeAblage.test.js && git commit -m "Aufträge: PDF-Ablage mit Pfadprüfung" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 12: Steuernummer in den Finanz-Einstellungen + +**Files:** +- Modify: `server/services/finance/settings.js`, `test/financeSettings.test.js` + +**Interfaces:** +- Consumes: `SettingsError`, `SETTINGS_RULES`, `DEFAULT_SETTINGS` in `settings.js` (bestehend); `schemaKeys('financeSettings')` mit `steuernummer` (Task 3). +- Produces: Einstellung `steuernummer` (Vorgabe `''`, getrimmt, höchstens 40 Zeichen; erlaubt sind Buchstaben A–Z und a–z, Ziffern, Leerzeichen, `/`, `-` und `.`, also auch eine USt-IdNr. „DE…“; `''` löscht den Wert; sonst `SettingsError(400, 'Steuernummer ungültig.')`) in `getSettings()`, `patchSettings()` und damit in `GET`/`PATCH /api/admin/finanzen/einstellungen`. + +- [ ] **Step 1: Failing Test schreiben** + +In `test/financeSettings.test.js` drei Änderungen (die übrigen Tests der Datei bleiben unverändert): + +1. Zeile mit `const DEFAULTS = ...` (kurz vor den Abschnitt „Zugriff“): `steuernummer: ''` ergänzen: + +```js +const DEFAULTS = { minBufferCents: 0, taxReservePercent: 30, previousYearRevenueCents: null, estPrepaymentCents: 0, bankDetailsText: '', steuernummer: '' } +``` + +2. Den Titel des Tests `GET /einstellungen: nur die fuenf Felder, nie Appwrite-Metafelder` in `... nur die sechs Felder, nie Appwrite-Metafelder` ändern. + +3. Direkt nach dem Test `PATCH: der Bankverbindungstext wird ohne Rand-Leerraum gespeichert` einfügen: + +```js +test('PATCH steuernummer: Vorgabe leer, getrimmt gespeichert; Buchstaben, Ziffern, Leerzeichen, / - und . bis 40 Zeichen, sonst 400', async () => { + assert.equal((await getSettings()).einstellungen.steuernummer, '') + const getrimmt = await patchSettings({ steuernummer: ' 12/345/67890 ' }) + assert.equal(getrimmt.status, 200, JSON.stringify(getrimmt.body)) + assert.equal(getrimmt.body.einstellungen.steuernummer, '12/345/67890') + assert.equal(storedSettings().steuernummer, '12/345/67890') + assert.equal((await getSettings()).einstellungen.steuernummer, '12/345/67890') + for (const wert of ['DE123456789', 'de 123.456.789', '12 345 67890', '12-345-678', 'A'.repeat(40), ` ${'1'.repeat(40)} `, ' ', '']) { + const res = await patchSettings({ steuernummer: wert }) + assert.equal(res.status, 200, JSON.stringify(wert)) + assert.equal(res.body.einstellungen.steuernummer, wert.trim()) + assert.equal(storedSettings().steuernummer, wert.trim()) + } + assert.equal((await patchSettings({ steuernummer: 'DE123456789' })).status, 200) + for (const wert of [' + + +``` + +`public/auftrag.css`: Kopf-Kommentar („Bestätigungsseite für Auftragsbestätigungen und Abschnitt ‚Aufträge‘ im Portal; Farben wie die Mailvorlagen“). Die Datei wird auch vom Dashboard geladen, deshalb ist jede Regel an `body.ab-seite` bzw. `.ab-portal` gebunden (kein Eingriff in andere Seiten); einzige Ausnahmen sind `.ab-rechnungen-titel` (Task 7) und `.inv-project` (Task 8). Inhalt dieses Tasks (Teil „öffentliche Seite“): + +| Bereich | Vorgabe | +|---|---| +| Schlüssel (hell, auf `body.ab-seite`) | `--ab-grund #f1f0ec`, `--ab-karte #ffffff`, `--ab-text #111111`, `--ab-weich #4a4843`, `--ab-linie #e8e6e0`, `--ab-box #f7f6f3`, `--ab-knopf #111111`, `--ab-knopf-text #ffffff`, Pillen `--ab-warn-bg #efe6fb` / `--ab-warn-text #5a2a91`, `--ab-ok-bg #e4f2e6` / `--ab-ok-text #1d5b2c`, `--ab-rot-bg #fbe4e1` / `--ab-rot-text #8c1d18`, `--ab-muted-bg #ecebe6` / `--ab-muted-text #4a4843`, `--ab-fehler #b3261e` | +| Dunkel (`@media (prefers-color-scheme: dark)`) | `--ab-grund #161615`, `--ab-karte #1f1f1e`, `--ab-text #f2f1ee`, `--ab-weich #a8a6a0`, `--ab-linie #3a3a37`, `--ab-box #2a2a28`, `--ab-knopf #f2f1ee`, `--ab-knopf-text #161615`, Pillen und `--ab-fehler` entsprechend abgedunkelt (Kontrast mindestens 4,5 : 1) | +| Grundlayout | `body.ab-seite` ohne Rand, `-webkit-text-size-adjust: 100%`, `min-height: 100dvh`, Spalte (`display: flex; flex-direction: column`), Systemschrift 16 px/1,55; `.ab-innen` höchstens 40 rem, 16 px Rand; `.ab-kopf` mit Logo (36 px, Radius 10) und Wortmarke, oben `max(20px, env(safe-area-inset-top))`; `.ab-wurzel` als Karte (Radius 16, Rand `--ab-linie`, Innenabstand 28/24 px, auf dem Handy 22/18 px) mit `overflow-wrap: anywhere`; `.ab-haupt { flex: 1 0 auto }` | +| Inhalt | `.ab-pille` (Kapsel, 12 px fett) mit `ab-pille-ok/warn/muted/rot`; `.ab-h1` 1,6 rem; `.ab-h2` als Beschriftung (0,8 rem, Großbuchstaben, `--ab-weich`); `.ab-liste` Zeilen als Raster 9 rem + Rest (auf dem Handy untereinander); `.ab-zeile` mit Trennlinie; `.ab-gesamt` Zeile mit Abstand und fetter Summe; `.ab-hinweis` klein, `--ab-weich`; `.ab-text` behält Zeilenumbrüche (`white-space: pre-line`); `.ab-links a` mindestens 44 px hoch; `.ab-bestaetigt` als grüne Box (`--ab-ok-*`), `:focus-visible` mit Rahmen | +| Tabelle | Desktop: normale Tabelle; unter 640 px: `thead` nur für Screenreader (`.ab-vh`-Technik), `tr` als Block mit Trennlinie, `td` als Zeile mit `td::before { content: attr(data-label) }` (erste Zelle ohne Marke, fett) | +| Formular | `.ab-haken` Zeile mit 22-px-Haken (`accent-color`) und Text 15 px; `.ab-frei` klein, `--ab-weich`; `.ab-fehler` in `--ab-fehler`, fett; `.ab-knopf` mit `min-height: 48px`, Radius 10, `--ab-knopf`/`--ab-knopf-text`, auf dem Handy volle Breite, `:disabled` mit `opacity .45`, `:focus-visible` mit 3-px-Rahmen; `.ab-notiz`, `.ab-gesperrt` klein und `--ab-weich` | +| Fuß und Hilfsklassen | `.ab-fuss` klein, Links nebeneinander mit 16 px Abstand, unten `max(24px, env(safe-area-inset-bottom))`; `.ab-vh` (nur für Screenreader); `body.ab-seite .hidden { display: none !important }` | +| Installierte App | `.ab-app-back { display: none }`; `@media (display-mode: standalone)`: `.ab-app-back { display: inline-block; … }` und `.ab-kopf` oben `calc(env(safe-area-inset-top) + 12px)` (Muster `legal-form.css`) | + +- [ ] **Step 4: Lauf, erwartet PASS** + +Derselbe Befehl wie in Step 2. Erwartet: `fail 0`. + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && git add public/auftrag.html public/auftrag.css test/auftraegeKundenportalHuelle.test.js && git commit -m "Kundenportal Aufträge: auftrag.html und auftrag.css (noindex, Mail-Stil, Fußzeile, Standalone) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 7: Abschnitt „Aufträge“ im Portal `public/auftrag-portal.js` + +**Files:** +- Create: `public/auftrag-portal.js` +- Modify: `public/auftrag.css` (Teil „Portal“, `.ab-portal` und die Karten) +- Create: `test/auftraegeKundenportalPortal.test.js` + +**Interfaces:** +- Consumes: Task 3 (`texte`, `ANREDE_DU`, `statusAnzeige`, `kartenMeta`, `sortiereAuftraege`, `darfBestaetigen`, `agbPfad`), Task 4 (`h`, `renderZusammenfassung`, `renderDokumente`, `renderBestaetigtBlock`, `renderBestaetigung`); `request(path, { method, body })` wie `api()` in `app.js` (liefert JSON, wirft `Error` mit `status`; `body` ist bereits ein JSON-Text); HTTP laut V5: `GET /api/auftraege` → `{ aktiv, auftraege: (AuftragOeffentlich & { id, kannBestaetigen })[] }` (`kannBestaetigen` laut Plan von L5: `true` bei Status `versendet`/`abgerechnet` ohne `bestaetigtAm`, in der Kundenansicht immer `false`), `GET /api/auftraege/:id/pdf` (Link), `POST /api/auftraege/:id/bestaetigen` → `{ auftrag }`; Antworten 404 und 410 beim Bestätigen laut L5-Plan. +- Produces: `initAuftraege({ section, body, invoicesTitle, request, viewAs = false }) → Promise<{ sichtbar: boolean }>`; Klassen `ab-portal`, `ab-karte`, `ab-karte-kopf`, `ab-karte-titel`, `ab-karte-meta`, `ab-karte-aktionen`, `ab-details`; Marken `data-auftrag=""`, `data-status`. + +- [ ] **Step 1: Failing Test schreiben** + +`test/auftraegeKundenportalPortal.test.js`: + +```js +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { installFakeDom } from './support/fakeDom.js' +import { h } from '../public/auftrag-dom.js' +import { initAuftraege } from '../public/auftrag-portal.js' +import { ab, privat, bestaetigt, eintrag, pruefeQuelltext } from './auftraegeKundenportalDaten.js' + +const NB = '\u{a0}' +const mit = (fn) => async () => { + const fx = installFakeDom() + try { + await fn(fx) + } finally { + fx.restore() + } +} +function aufbau(fx) { + const body = h('div', { id: 'orders-body' }) + const section = h('section', { id: 'orders-section', class: 'ab-portal hidden' }, body) + const invoicesTitle = h('h2', { id: 'invoices-title', class: 'hidden' }, 'Rechnungen') + fx.document.body.append(section, invoicesTitle) + return { section, body, invoicesTitle } +} +/** request wie api() in app.js: gibt die nächste Antwort zurück oder wirft sie (Error mit status). */ +function fakeRequest(antworten) { + const aufrufe = [] + const request = async (pfad, optionen = {}) => { + aufrufe.push({ pfad, methode: optionen.method || 'GET', body: optionen.body ? JSON.parse(optionen.body) : undefined }) + const antwort = antworten.shift() + if (antwort instanceof Error) throw antwort + return antwort + } + return { request, aufrufe } +} +const fehler = (status, text = `Fehler ${status}`) => Object.assign(new Error(text), { status }) +const sichtbar = (el) => !el.classList.contains('hidden') +const liste = (...auftraege) => ({ aktiv: true, auftraege }) + +const A_OFFEN = eintrag({ id: 'ab1', nummer: 'AB-2026-0002' }) +const A_OHNE_RECHT = eintrag({ id: 'ab4', nummer: 'AB-2026-0004', kannBestaetigen: false }) // Status offen, der Server erlaubt das Bestätigen nicht +const A_FERTIG = eintrag({ id: 'ab2', ...bestaetigt(privat({ fruehbeginn: true, fruehesterBeginn: null })), nummer: 'AB-2026-0003', kannBestaetigen: false }) +const A_STORNO = eintrag({ + id: 'ab0', nummer: 'AB-2026-0001', status: 'storniert', kannBestaetigen: false, art: 'aufwand', positionen: [], summeCents: null, + aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten.', +}) +const bestaetige = async (fx, wurzel) => { + await fx.klick(fx.finde(wurzel, '[data-feld="bestaetigung"]')) + await fx.klick(fx.finde(wurzel, '[data-aktion="bestaetigen"]')) +} + +test('Der Abschnitt bleibt weg: Schalter aus, keine Aufträge, Ladefehler', mit(async (fx) => { + for (const antwort of [{ aktiv: false, auftraege: [] }, { aktiv: false, auftraege: [A_OFFEN] }, liste(), fehler(500), fehler(401)]) { + const els = aufbau(fx) + const { request, aufrufe } = fakeRequest([antwort]) + assert.deepEqual(await initAuftraege({ ...els, request, viewAs: false }), { sichtbar: false }) + assert.equal(sichtbar(els.section), false) + assert.equal(sichtbar(els.invoicesTitle), false) + assert.deepEqual(aufrufe, [{ pfad: '/api/auftraege', methode: 'GET', body: undefined }]) + els.section.remove() + els.invoicesTitle.remove() + } +})) + +test('Liste: Bestätigbares zuerst, Status, Zeile, PDF-Link; nur der offene Auftrag ist aufgeklappt und hat das Formular', mit(async (fx) => { + const els = aufbau(fx) + const { request } = fakeRequest([liste(A_STORNO, A_FERTIG, A_OHNE_RECHT, A_OFFEN)]) + assert.deepEqual(await initAuftraege({ ...els, request, viewAs: false }), { sichtbar: true }) + assert.equal(sichtbar(els.section), true) + assert.equal(sichtbar(els.invoicesTitle), true) + const karten = fx.alle(els.body, '.ab-karte') + assert.deepEqual(karten.map((k) => k.getAttribute('data-auftrag')), ['ab1', 'ab4', 'ab2', 'ab0']) + const [offen, ohneRecht, fertig, storno] = karten + assert.equal(fx.finde(offen, '.ab-pille').textContent, 'Bitte bestätigen') + assert.equal(fx.finde(ohneRecht, '.ab-pille').textContent, 'Versendet') + assert.equal(fx.finde(ohneRecht, 'form'), null, 'ohne kannBestaetigen vom Server gibt es kein Formular') + assert.equal(fx.finde(fertig, '.ab-pille').textContent, 'Bestätigt') + assert.equal(fx.finde(storno, '.ab-pille').textContent, 'Storniert') + assert.equal(fx.finde(offen, '.ab-karte-titel').textContent, 'Website Anelia Wolf') + assert.equal(fx.finde(offen, '.ab-karte-meta').textContent, `vom 06.10.2026 · 800,00${NB}€`) + assert.equal(fx.finde(storno, '.ab-karte-meta').textContent, 'vom 06.10.2026 · nach Aufwand') + assert.equal(fx.finde(offen, '[data-link="pdf"]').getAttribute('href'), '/api/auftraege/ab1/pdf') + assert.equal(fx.finde(offen, '[data-link="pdf"]').textContent, 'PDF ansehen') + assert.equal(fx.finde(offen, 'summary').textContent, 'Details ansehen und bestätigen') + assert.equal(fx.finde(fertig, 'summary').textContent, 'Details ansehen') + assert.equal(fx.finde(offen, 'details').hasAttribute('open'), true) + assert.equal(fx.finde(fertig, 'details').hasAttribute('open'), false) + assert.ok(fx.finde(offen, 'form [data-feld="bestaetigung"]')) + assert.equal(fx.finde(fertig, 'form'), null) + assert.equal(fx.finde(storno, 'form'), null) + assert.deepEqual(fx.alle(fx.finde(fertig, '[data-bestaetigt]'), 'p').map((p) => p.textContent), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf deinen Wunsch schon vor Ablauf der Widerrufsfrist.']) + assert.equal(fx.text(fx.finde(offen, 'legend')), 'Deine Bestätigung', 'Portal: Du-Form') + assert.equal(fx.text(fx.finde(offen, '.ab-notiz')), 'Deine Bestätigung wird mit Datum und Uhrzeit gespeichert.') + assert.deepEqual(fx.alle(offen, 'h4').map((x) => x.textContent), ['Leistungsumfang', 'Zahlung', 'Termine']) + assert.equal(fx.finde(offen, '[data-link="agb"]').getAttribute('href'), '/rechtliches/agb-2026-09-30.pdf') +})) + +test('Bestätigen: POST an /api/auftraege//bestaetigen, die Karte zeigt danach „Bestätigt“ (auch ohne id in der Antwort)', mit(async (fx) => { + const els = aufbau(fx) + const { request, aufrufe } = fakeRequest([liste(A_OFFEN), { auftrag: bestaetigt(ab()) }]) + await initAuftraege({ ...els, request, viewAs: false }) + await bestaetige(fx, els.body) + assert.deepEqual(aufrufe[1], { pfad: '/api/auftraege/ab1/bestaetigen', methode: 'POST', body: { bestaetigt: true, fruehbeginn: false } }) + const karte = fx.finde(els.body, '.ab-karte') + assert.equal(karte.getAttribute('data-auftrag'), 'ab1') + assert.equal(fx.finde(karte, '.ab-pille').textContent, 'Bestätigt') + assert.equal(fx.finde(karte, 'form'), null) + assert.equal(fx.finde(karte, 'details').hasAttribute('open'), true) + assert.equal(fx.document.activeElement, fx.finde(karte, '[data-bestaetigt]')) +})) + +test('Ansichtsmodus: Häkchen und Knopf gesperrt, Hinweis sichtbar, kein POST; der Abschnitt bleibt lesbar', mit(async (fx) => { + const els = aufbau(fx) + // wie L5 es liefert: in der Kundenansicht ist kannBestaetigen immer false; die Oberfläche zeigt die offene AB trotzdem mit gesperrtem Formular + const { request, aufrufe } = fakeRequest([liste(eintrag({ ...privat(), id: 'ab1', kannBestaetigen: false }))]) + await initAuftraege({ ...els, request, viewAs: true }) + assert.equal(fx.finde(els.body, '.ab-pille').textContent, 'Bitte bestätigen') + const form = fx.finde(els.body, 'form') + assert.equal(fx.alle(form, 'input').every((i) => i.disabled), true) + assert.equal(fx.finde(form, '[data-aktion="bestaetigen"]').disabled, true) + assert.equal(fx.finde(form, '[data-gesperrt]').textContent, 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.') + await fx.sende(form, 'submit') + assert.equal(aufrufe.length, 1, 'nur das Laden der Liste') + assert.ok(fx.finde(els.body, '[data-link="pdf"]')) + assert.ok(fx.finde(els.body, '[data-position]')) +})) + +test('Fehler beim Bestätigen: feste Meldung statt Servertext; ein 410 lädt die Liste neu', mit(async (fx) => { + const els = aufbau(fx) + const storniert = { ...A_OFFEN, status: 'storniert', kannBestaetigen: false } + const { request, aufrufe } = fakeRequest([liste(A_OFFEN), fehler(500), fehler(410, 'Diese Auftragsbestätigung wurde storniert.'), liste(storniert)]) + await initAuftraege({ ...els, request, viewAs: false }) + await bestaetige(fx, els.body) + assert.equal(fx.finde(els.body, '[data-fehler]').textContent, 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuche es später noch einmal.') + assert.ok(!fx.text(els.body).includes('Fehler 500')) + await fx.klick(fx.finde(els.body, '[data-aktion="bestaetigen"]')) + assert.equal(aufrufe.length, 4, 'Liste, POST, POST, Liste neu') + assert.equal(fx.finde(els.body, '.ab-pille').textContent, 'Storniert') + assert.equal(fx.finde(els.body, 'form'), null) +})) + +test('Fremdtext bleibt Text; die ID im PDF-Link wird kodiert', mit(async (fx) => { + const els = aufbau(fx) + const boese = ' & "Test"' + const { request } = fakeRequest([liste(eintrag({ id: 'a/b', titel: boese }))]) + await initAuftraege({ ...els, request, viewAs: false }) + assert.equal(fx.alle(els.body, 'script').length, 0) + assert.equal(fx.finde(els.body, '.ab-karte-titel').textContent, boese) + assert.equal(fx.finde(els.body, '[data-link="pdf"]').getAttribute('href'), '/api/auftraege/a%2Fb/pdf') +})) + +test('Quelltext und CSS: kein eigenes fetch, Karten und Abschnitt sind gestaltet', () => { + pruefeQuelltext('auftrag-portal.js', { importe: ['./auftrag-ansicht.js', './auftrag-dom.js'] }) + const css = readFileSync(new URL('../public/auftrag.css', import.meta.url), 'utf8') + for (const klasse of ['ab-portal', 'ab-karte', 'ab-karte-kopf', 'ab-karte-titel', 'ab-karte-meta', 'ab-karte-aktionen', 'ab-details']) { + assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[>]`), `.${klasse} ist gestaltet`) + } + assert.match(css, /\.ab-portal\s*\{[^}]*--ab-/, 'der Portal-Abschnitt setzt seine eigenen Farbschlüssel (dunkles Portal)') +}) +``` + +- [ ] **Step 2: Lauf, erwartet FAIL** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeKundenportalPortal.test.js +``` +Erwartet: FAIL (`Cannot find module '../public/auftrag-portal.js'`). + +- [ ] **Step 3: `public/auftrag-portal.js` und Portal-CSS** + +`initAuftraege({ section, body, invoicesTitle, request, viewAs })` (ES-Modul, Kopf-Kommentar „Abschnitt ‚Aufträge‘ im Reiter Rechnungen, geladen von app.js, loadCustomerOrders; `request` ist `api` aus app.js“). Anrede: **Du** wie das übrige Portal; das Modul benutzt nur `ANREDE_DU` und `texte(ANREDE_DU)`, kein eigener Text: + +- Wirksames `kannBestaetigen` je Eintrag, einmal beim Laden gesetzt: `darfBestaetigen(eintrag) && (viewAs || eintrag.kannBestaetigen === true)`. Grund: L5 liefert `kannBestaetigen` in der Kundenansicht immer als `false` (Vertragsfrage 3), die Oberfläche soll dort die offene AB trotzdem als „Bitte bestätigen“ mit gesperrtem Formular zeigen. Status-Pille (`statusAnzeige`), Sortierung, aufgeklappte Karte und Formular benutzen nur dieses wirksame Flag. +- Fehlen `section`, `body` oder `request`: `{ sichtbar: false }`. `lade()`: `await request('/api/auftraege')`; jeder Fehler → `{ sichtbar: false }` (kein Wurf, keine Anzeige). `data.aktiv !== true` oder keine Einträge → `{ sichtbar: false }`, `hidden` bleibt an Abschnitt und `invoicesTitle`. Sonst Einträge mit `typeof id === 'string'` und `id` nicht leer, `sortiereAuftraege`, Karten in `body` (`replaceChildren`), `hidden` von `section` und `invoicesTitle` entfernen, `{ sichtbar: true }`. +- Karte `article.ab-karte[data-auftrag=][data-status=]`: `div.ab-karte-kopf` mit Titelblock (`p.ab-karte-nummer` > `strong` Nummer, `p.ab-karte-titel` Titel (nur wenn vorhanden), `p.ab-karte-meta` aus `kartenMeta`) und `span.ab-pille.ab-pille-` aus `statusAnzeige`; `div.ab-karte-aktionen` mit `a.ab-link[data-link="pdf"]` (`texte(ANREDE_DU).kartePdf`, Ziel `/api/auftraege/${encodeURIComponent(id)}/pdf`, `target="_blank"`, `rel="noopener noreferrer"`); `details.ab-details` (Attribut `open` bei bestätigbaren Aufträgen und bei der Karte, die gerade bestätigt wurde) mit `summary` (`karteDetailsBestaetigen` bei bestätigbaren, sonst `karteDetails`), `renderZusammenfassung(auftrag, { ebene: 4 })`, `renderDokumente({ agbHref: agbPfad(auftrag.agbUrl) })`, dann `renderBestaetigtBlock(auftrag, { anrede: ANREDE_DU })` bzw. bei wirksamem `kannBestaetigen` das Formular. +- Formular: `renderBestaetigung(auftrag, { senden, nachErfolg, nachEnde, gesperrt: viewAs ? texte(ANREDE_DU).ansichtsmodus : '', anrede: ANREDE_DU })`; `senden(koerper)` ruft `request` mit dem Pfad `/api/auftraege//bestaetigen` (`id` per `encodeURIComponent`) und `{ method: 'POST', body: JSON.stringify(koerper) }` auf → `{ ok: true, auftrag: antwort.auftrag }`, bei Wurf `{ ok: false, status: Number(err?.status) || 0, body: { error: String(err?.message ?? '') } }` (der Text dient nur der Erkennung, `renderBestaetigung` zeigt ihn nie). `nachErfolg(neu)`: den Eintrag in der Liste durch `{ ...alt, ...neu, id: alt.id, kannBestaetigen: false }` ersetzen und alle Karten per `replaceChildren` neu zeichnen (die eben bestätigte Karte aufgeklappt, Reihenfolge wie beim Laden), dann in ihr `[data-bestaetigt]` fokussieren (der Plan von L5 liefert `id` und `kannBestaetigen` auch in der POST-Antwort; das Zusammenführen bleibt als Vorsicht). `nachEnde()`: Liste neu laden (`GET /api/auftraege`) und neu zeichnen; scheitert das, bleibt die Anzeige stehen. + +`public/auftrag.css` (Teil „Portal“, an `.ab-portal` gebunden): `.ab-portal` setzt dunkle Schlüssel `--ab-*` aus den Portalfarben (`--kb-surface`, `--kb-border`, `--kb-muted`, `--kb-purple`, Pillen wie `.pill-ok/-warn/-red/-muted` in `kundenbereich.css`), `margin: 0 0 28px`; `.ab-portal h2` 20 px; `.ab-karte` wie `.kb-panel` (Radius 16, Rand, 16/18 px Innenabstand, 12 px Abstand zwischen Karten); `.ab-karte-kopf` Zeile mit Abstand, Pille rechts, `overflow-wrap: anywhere`; `.ab-karte-nummer` fett, `.ab-karte-meta` klein und gedämpft; `.ab-karte-aktionen` Zeile mit 10 px Abstand, Links als Kapsel mit mindestens 44 px Höhe; `.ab-details > summary` mindestens 44 px, Farbe `#d4b4ff`, fett; `.ab-knopf` im Portal lila (`--kb-purple`, weiße Schrift, Kapsel, `min-height: 48px`); `.ab-haken`, `.ab-fehler` (`#fca5a5`), `.ab-notiz`, `.ab-gesperrt`, Tabelle (unter 640 px gestapelt wie auf der Seite) in den Portalfarben; `.ab-rechnungen-titel` 20 px. + +- [ ] **Step 4: Lauf, erwartet PASS** + +Derselbe Befehl wie in Step 2. Erwartet: `fail 0`. Danach `test/auftraegeKundenportalHuelle.test.js` erneut laufen lassen (CSS wurde ergänzt): `fail 0`. + +- [ ] **Step 5: Commit** + +```bash +cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && git add public/auftrag-portal.js public/auftrag.css test/auftraegeKundenportalPortal.test.js && git commit -m "Kundenportal Aufträge: Abschnitt „Aufträge“ im Portal (Karten, Bestätigen, Ansichtsmodus gesperrt) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 8: Dashboard verdrahten, Projekt unter der Rechnungsnummer + +**Files:** +- Modify: `public/dashboard.html` (Ansicht `view-invoices`, Stylesheet) +- Modify: `public/app.js` (`invoiceNumberHtml`, `loadCustomerOrders`, Aufruf in `initDashboardPage`) +- Modify: `public/auftrag.css` (Regel `.inv-project`) +- Create: `test/auftraegeKundenportalDashboard.test.js` + +**Interfaces:** +- Consumes: `initAuftraege` (Task 7); `api`, `escapeHtml`, `chatState.viewAs` aus `app.js`; `projekt` aus `/api/invoices` (Task 2). +- Produces: in `dashboard.html` die Elemente `#orders-section`, `#orders-body`, `#invoices-title`; in `app.js` die globalen Funktionen `invoiceNumberHtml(i) → string` (maskiert) und `loadCustomerOrders() → Promise`. + +- [ ] **Step 1: Failing Test schreiben** + +`test/auftraegeKundenportalDashboard.test.js`: + +```js +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import vm from 'node:vm' + +const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8') +const html = lies('public/dashboard.html') +const app = lies('public/app.js') +const css = lies('public/auftrag.css') + +test('dashboard.html: auftrag.css nach zeiten.css; Abschnitt „Aufträge“ vor der Rechnungsliste, zunächst versteckt', () => { + assert.match(html, /\s*/) + const start = html.indexOf('id="view-invoices"') + const ende = html.indexOf('', html.indexOf('id="invoices-abo-note"')) + const ansicht = html.slice(start, ende) + assert.match(ansicht, / diff --git a/public/rechtliches/agb-2026-09-30.html b/public/rechtliches/agb-2026-09-30.html new file mode 100644 index 0000000..625fbf1 --- /dev/null +++ b/public/rechtliches/agb-2026-09-30.html @@ -0,0 +1,200 @@ + + + + + + + Allgemeine Geschäftsbedingungen – WEBklar + + + +
+ + +
WEBKLAR
+

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.

+
+ + diff --git a/public/rechtliches/agb-2026-09-30.pdf b/public/rechtliches/agb-2026-09-30.pdf new file mode 100644 index 0000000..60543fe Binary files /dev/null and b/public/rechtliches/agb-2026-09-30.pdf differ diff --git a/public/rechtliches/agb-2026-09-30.pdf.sha256 b/public/rechtliches/agb-2026-09-30.pdf.sha256 new file mode 100644 index 0000000..e3eeb2e --- /dev/null +++ b/public/rechtliches/agb-2026-09-30.pdf.sha256 @@ -0,0 +1 @@ +d271a7ed066ee758dd5dd85b865134a4f9f65e1bc2ee375af7ab959b6455d2de diff --git a/public/rechtliches/widerrufsbelehrung-projektauftrag-2026-10-06.html b/public/rechtliches/widerrufsbelehrung-projektauftrag-2026-10-06.html new file mode 100644 index 0000000..f0b59a5 --- /dev/null +++ b/public/rechtliches/widerrufsbelehrung-projektauftrag-2026-10-06.html @@ -0,0 +1,94 @@ + + + + + + + Widerrufsbelehrung und Muster-Widerrufsformular (Projektaufträge) – WEBklar + + + +
+ + +
WEBKLAR
+

Widerrufsbelehrung und Muster-Widerrufsformular (Projektaufträge)

+ +

Fassung 2026-10-06

+ +

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.

+ +

Widerrufsbelehrung

+ +

Widerrufsrecht

+ +

Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.

+ +

Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.

+ +

Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, Telefon: 0176 23726355, E-Mail: support@webklar.com) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.

+ +

Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben. 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.

+ +

Folgen des Widerrufs

+ +

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

+ +

(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)

+ +

– An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:

+ +

– Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)

+ +

– Bestellt am (*)/erhalten am (*)

+ +

– Name des/der Verbraucher(s)

+ +

– Anschrift des/der Verbraucher(s)

+ +

– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)

+ +

– Datum

+ +

(*) Unzutreffendes streichen.

+
+ + diff --git a/public/rechtliches/widerrufsbelehrung-projektauftrag-2026-10-06.pdf b/public/rechtliches/widerrufsbelehrung-projektauftrag-2026-10-06.pdf new file mode 100644 index 0000000..24849a5 Binary files /dev/null and b/public/rechtliches/widerrufsbelehrung-projektauftrag-2026-10-06.pdf differ diff --git a/public/rechtliches/widerrufsbelehrung-projektauftrag-2026-10-06.pdf.sha256 b/public/rechtliches/widerrufsbelehrung-projektauftrag-2026-10-06.pdf.sha256 new file mode 100644 index 0000000..2b239de --- /dev/null +++ b/public/rechtliches/widerrufsbelehrung-projektauftrag-2026-10-06.pdf.sha256 @@ -0,0 +1 @@ +f9948c0a656e5eaf62c964a933d4fda4b162cf1cdc4069632e1068c6488b8408 diff --git a/public/widerrufen.html b/public/widerrufen.html index 231320a..76fab22 100644 --- a/public/widerrufen.html +++ b/public/widerrufen.html @@ -24,8 +24,15 @@ + + +
+ + +
+ @@ -49,7 +56,8 @@ + diff --git a/scripts/auftraege-setup.mjs b/scripts/auftraege-setup.mjs new file mode 100644 index 0000000..830be01 --- /dev/null +++ b/scripts/auftraege-setup.mjs @@ -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) + }) +} diff --git a/scripts/build-legal-html.mjs b/scripts/build-legal-html.mjs index df03965..63bbc0d 100755 --- a/scripts/build-legal-html.mjs +++ b/scripts/build-legal-html.mjs @@ -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/-.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 diff --git a/scripts/build-legal-pdfs.sh b/scripts/build-legal-pdfs.sh index 247ba4b..c2f0637 100755 --- a/scripts/build-legal-pdfs.sh +++ b/scripts/build-legal-pdfs.sh @@ -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 .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 .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 diff --git a/server/assets/fonts/Figtree-OFL.txt b/server/assets/fonts/Figtree-OFL.txt new file mode 100644 index 0000000..80b12bb --- /dev/null +++ b/server/assets/fonts/Figtree-OFL.txt @@ -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. diff --git a/server/assets/fonts/Figtree-Regular.ttf b/server/assets/fonts/Figtree-Regular.ttf new file mode 100644 index 0000000..4b42cdb Binary files /dev/null and b/server/assets/fonts/Figtree-Regular.ttf differ diff --git a/server/assets/fonts/Figtree-SemiBold.ttf b/server/assets/fonts/Figtree-SemiBold.ttf new file mode 100644 index 0000000..aa2b78c Binary files /dev/null and b/server/assets/fonts/Figtree-SemiBold.ttf differ diff --git a/server/assets/fonts/QUELLE.txt b/server/assets/fonts/QUELLE.txt new file mode 100644 index 0000000..cdb6809 --- /dev/null +++ b/server/assets/fonts/QUELLE.txt @@ -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 diff --git a/server/config.js b/server/config.js index 930bec3..234b10d 100644 --- a/server/config.js +++ b/server/config.js @@ -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: //.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 || '', diff --git a/server/index.js b/server/index.js index 9736ef6..cd7bd06 100644 --- a/server/index.js +++ b/server/index.js @@ -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) diff --git a/server/mail-templates/K04 Eingang Widerruf.html b/server/mail-templates/K04 Eingang Widerruf.html index f7580ec..b530d33 100644 --- a/server/mail-templates/K04 Eingang Widerruf.html +++ b/server/mail-templates/K04 Eingang Widerruf.html @@ -8,7 +8,8 @@ Absender: „WEBklar“ 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). --> @@ -56,6 +57,9 @@ a{color:#111111;} + + + Viele Grüße
Ihr WEBklar-Team diff --git a/server/mail-templates/K31 Auftragsbestaetigung.html b/server/mail-templates/K31 Auftragsbestaetigung.html new file mode 100644 index 0000000..c9b20b9 --- /dev/null +++ b/server/mail-templates/K31 Auftragsbestaetigung.html @@ -0,0 +1,70 @@ + + + + + + + + +Ihre Auftragsbestätigung {{nummer}} – {{titel}} + + + + +Bitte prüfen und bestätigen Sie Ihren Auftrag {{nummer}}. ͏ ͏ ͏ + + + + + diff --git a/server/mail-templates/K32 Auftrag bestaetigt.html b/server/mail-templates/K32 Auftrag bestaetigt.html new file mode 100644 index 0000000..45a26b7 --- /dev/null +++ b/server/mail-templates/K32 Auftrag bestaetigt.html @@ -0,0 +1,56 @@ + + + + + + + + +Ihre Bestätigung zu {{nummer}} ist eingegangen + + + + +Wir haben Ihre Bestätigung zu {{nummer}} erhalten. ͏ ͏ ͏ + + + + + diff --git a/server/mail-templates/T09 Widerruf eingegangen.html b/server/mail-templates/T09 Widerruf eingegangen.html index 23dccc2..12d105c 100644 --- a/server/mail-templates/T09 Widerruf eingegangen.html +++ b/server/mail-templates/T09 Widerruf eingegangen.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. --> @@ -64,6 +65,9 @@ a{color:#111111;} + + + diff --git a/server/mail-templates/T22 Auftrag bestaetigt.html b/server/mail-templates/T22 Auftrag bestaetigt.html new file mode 100644 index 0000000..3830cb2 --- /dev/null +++ b/server/mail-templates/T22 Auftrag bestaetigt.html @@ -0,0 +1,55 @@ + + + + + + + + +[Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}} + + + + +{{kunde}}: {{nummer}} bestätigt. ͏ ͏ ͏ + + + + + diff --git a/server/routes/abo.js b/server/routes/abo.js index 739d185..d68b2e3 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -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, }, }) diff --git a/server/routes/admin/auftraege.js b/server/routes/admin/auftraege.js new file mode 100644 index 0000000..f9f7577 --- /dev/null +++ b/server/routes/admin/auftraege.js @@ -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 diff --git a/server/routes/admin/finanzen.js b/server/routes/admin/finanzen.js index 43d66a9..6adc5c6 100644 --- a/server/routes/admin/finanzen.js +++ b/server/routes/admin/finanzen.js @@ -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) }) diff --git a/server/routes/admin/projektkonten.js b/server/routes/admin/projektkonten.js new file mode 100644 index 0000000..ee7351d --- /dev/null +++ b/server/routes/admin/projektkonten.js @@ -0,0 +1,38 @@ +/** + * Projektkonten (Admin). Mount: /api/admin/finanzen/projektkonten (vor dem Finanzen-Router). + * GET /?kunde= 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 diff --git a/server/routes/auftraegeKunde.js b/server/routes/auftraegeKunde.js new file mode 100644 index 0000000..445047f --- /dev/null +++ b/server/routes/auftraegeKunde.js @@ -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 diff --git a/server/routes/auftrag.js b/server/routes/auftrag.js new file mode 100644 index 0000000..f120242 --- /dev/null +++ b/server/routes/auftrag.js @@ -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 diff --git a/server/routes/team/arbeitsblaetter.js b/server/routes/team/arbeitsblaetter.js new file mode 100644 index 0000000..0da67dd --- /dev/null +++ b/server/routes/team/arbeitsblaetter.js @@ -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= { abgerechnet: { : '' | '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 diff --git a/server/routes/team/index.js b/server/routes/team/index.js index c0b4f65..86dd6a4 100644 --- a/server/routes/team/index.js +++ b/server/routes/team/index.js @@ -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 diff --git a/server/services/auftraege/ablage.js b/server/services/auftraege/ablage.js new file mode 100644 index 0000000..4d6f0bf --- /dev/null +++ b/server/services/auftraege/ablage.js @@ -0,0 +1,59 @@ +/** + * Ablage der gesendeten AB-PDFs unter config.auftraege.uploadDir (wird bei jedem Aufruf gelesen): + * //.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 + } +} diff --git a/server/services/auftraege/arbeitsblaetter.js b/server/services/auftraege/arbeitsblaetter.js new file mode 100644 index 0000000..473d5bf --- /dev/null +++ b/server/services/auftraege/arbeitsblaetter.js @@ -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] : [] +} diff --git a/server/services/auftraege/auftraege.js b/server/services/auftraege/auftraege.js new file mode 100644 index 0000000..f50c02a --- /dev/null +++ b/server/services/auftraege/auftraege.js @@ -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) + }) +} diff --git a/server/services/auftraege/auftragSicht.js b/server/services/auftraege/auftragSicht.js new file mode 100644 index 0000000..1e80a63 --- /dev/null +++ b/server/services/auftraege/auftragSicht.js @@ -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, + }, + } +} diff --git a/server/services/auftraege/auftragStore.js b/server/services/auftraege/auftragStore.js new file mode 100644 index 0000000..d6d5363 --- /dev/null +++ b/server/services/auftraege/auftragStore.js @@ -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 +} diff --git a/server/services/auftraege/bestaetigung.js b/server/services/auftraege/bestaetigung.js new file mode 100644 index 0000000..efd8089 --- /dev/null +++ b/server/services/auftraege/bestaetigung.js @@ -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() })) + }) +} diff --git a/server/services/auftraege/fehler.js b/server/services/auftraege/fehler.js new file mode 100644 index 0000000..da772be --- /dev/null +++ b/server/services/auftraege/fehler.js @@ -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.' }) +} diff --git a/server/services/auftraege/konstanten.js b/server/services/auftraege/konstanten.js new file mode 100644 index 0000000..dd434ea --- /dev/null +++ b/server/services/auftraege/konstanten.js @@ -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.' diff --git a/server/services/auftraege/mails.js b/server/services/auftraege/mails.js new file mode 100644 index 0000000..c062765 --- /dev/null +++ b/server/services/auftraege/mails.js @@ -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'}`) + } +} diff --git a/server/services/auftraege/nummern.js b/server/services/auftraege/nummern.js new file mode 100644 index 0000000..01ca120 --- /dev/null +++ b/server/services/auftraege/nummern.js @@ -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 } +} diff --git a/server/services/auftraege/pdf.js b/server/services/auftraege/pdf.js new file mode 100644 index 0000000..0db6914 --- /dev/null +++ b/server/services/auftraege/pdf.js @@ -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 +} diff --git a/server/services/auftraege/positionen.js b/server/services/auftraege/positionen.js new file mode 100644 index 0000000..cda261b --- /dev/null +++ b/server/services/auftraege/positionen.js @@ -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-. */ +export function festePositionen(items) { + return pruefeEingabePositionen(items, { leerErlaubt: false, praefix: 'pos' }) +} + +/** Zusatzpositionen (Nachtraege einer Schlussrechnung): wie festePositionen, aber leer erlaubt, Schluessel zusatz-. */ +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 +} diff --git a/server/services/auftraege/projektRechnungenStore.js b/server/services/auftraege/projektRechnungenStore.js new file mode 100644 index 0000000..2c72369 --- /dev/null +++ b/server/services/auftraege/projektRechnungenStore.js @@ -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_ +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 +} diff --git a/server/services/auftraege/projektkonto.js b/server/services/auftraege/projektkonto.js new file mode 100644 index 0000000..4692138 --- /dev/null +++ b/server/services/auftraege/projektkonto.js @@ -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_) 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 " 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 +} diff --git a/server/services/auftraege/rechnungsbau.js b/server/services/auftraege/rechnungsbau.js new file mode 100644 index 0000000..b33ea8a --- /dev/null +++ b/server/services/auftraege/rechnungsbau.js @@ -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. 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') +} diff --git a/server/services/auftraege/schalter.js b/server/services/auftraege/schalter.js new file mode 100644 index 0000000..64d52e0 --- /dev/null +++ b/server/services/auftraege/schalter.js @@ -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) +} diff --git a/server/services/auftraege/schema.js b/server/services/auftraege/schema.js new file mode 100644 index 0000000..912a428 --- /dev/null +++ b/server/services/auftraege/schema.js @@ -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.; 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_) + 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)]), +) diff --git a/server/services/auftraege/texte.js b/server/services/auftraege/texte.js new file mode 100644 index 0000000..da8731a --- /dev/null +++ b/server/services/auftraege/texte.js @@ -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.', + ], +} diff --git a/server/services/auftraege/token.js b/server/services/auftraege/token.js new file mode 100644 index 0000000..aec22e4 --- /dev/null +++ b/server/services/auftraege/token.js @@ -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 +} diff --git a/server/services/customerInvoices.js b/server/services/customerInvoices.js index d5ff638..d910594 100644 --- a/server/services/customerInvoices.js +++ b/server/services/customerInvoices.js @@ -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 || '', } } diff --git a/server/services/finance/settings.js b/server/services/finance/settings.js index 0426cea..f312384 100644 --- a/server/services/finance/settings.js +++ b/server/services/finance/settings.js @@ -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. */ diff --git a/server/services/finance/stripeFinance.js b/server/services/finance/stripeFinance.js index 0106e24..f1cd819 100644 --- a/server/services/finance/stripeFinance.js +++ b/server/services/finance/stripeFinance.js @@ -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() } diff --git a/server/services/mailer.js b/server/services/mailer.js index aedd6b3..0486784 100644 --- a/server/services/mailer.js +++ b/server/services/mailer.js @@ -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, diff --git a/test/auftraegeAbEingabe.test.js b/test/auftraegeAbEingabe.test.js new file mode 100644 index 0000000..42f79b4 --- /dev/null +++ b/test/auftraegeAbEingabe.test.js @@ -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) +}) diff --git a/test/auftraegeAbFolge.test.js b/test/auftraegeAbFolge.test.js new file mode 100644 index 0000000..0d79d3c --- /dev/null +++ b/test/auftraegeAbFolge.test.js @@ -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) +}) diff --git a/test/auftraegeAbVertrag.test.js b/test/auftraegeAbVertrag.test.js new file mode 100644 index 0000000..a61654c --- /dev/null +++ b/test/auftraegeAbVertrag.test.js @@ -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 /.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]) +}) diff --git a/test/auftraegeAbVorschau.test.js b/test/auftraegeAbVorschau.test.js new file mode 100644 index 0000000..b264385 --- /dev/null +++ b/test/auftraegeAbVorschau.test.js @@ -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]) +}) diff --git a/test/auftraegeAblage.test.js b/test/auftraegeAblage.test.js new file mode 100644 index 0000000..713c929 --- /dev/null +++ b/test/auftraegeAblage.test.js @@ -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 /.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') +}) diff --git a/test/auftraegeAblauf.test.js b/test/auftraegeAblauf.test.js new file mode 100644 index 0000000..9798679 --- /dev/null +++ b/test/auftraegeAblauf.test.js @@ -0,0 +1,1139 @@ +// Durchlauf über alle neuen Endpunkte von „Aufträge und Rechnungen“ (Strang Z, Task Z1.3). +// Echter Server-Code (Router, Dienste, pdfkit); Nachbauten für Appwrite, Stripe und SMTP, nichts verlässt den Rechner. +// Aufruf: cd && free -m && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAblauf.test.js +// Jeder Test hat eigene Kunden und Projekte; die AB-Nummern laufen über alle Tests (D15 prüft sie am Ende). +// Der Vertrag entscheidet (Übersicht V1–V8, Spec). Was der Vertrag offen lässt, steht als „Annahme“ im Kommentar. +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import crypto from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { inspect } from 'node:util' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { mailSink } from './support/mailSink.js' +import { AUFTRAEGE_COLLECTIONS, AUFTRAEGE_ZUSATZ_ATTRIBUTE } from '../server/services/auftraege/schema.js' + +// --- Umgebung ------------------------------------------------------------------------------------------------ +// Ablage der AB-PDFs in einem Wegwerf-Ordner und Schalter an (Vorgabe wäre off): config.js liest beides beim ersten Import. +const ABLAGE = fs.mkdtempSync(path.join(os.tmpdir(), 'auftraege-ablauf-')) +process.env.AUFTRAEGE_UPLOAD_DIR = ABLAGE +process.env.AUFTRAEGE_ENABLED = 'on' + +// Alles, was dieser Prozess ins Protokoll schreibt, wird gesammelt: D16 prüft, dass kein Token und keine Client-Adresse darin steht. +const PROTOKOLL = [] +for (const stufe of ['log', 'info', 'warn', 'error']) { + const original = console[stufe].bind(console) + console[stufe] = (...teile) => { + PROTOKOLL.push(teile.map((teil) => (typeof teil === 'string' ? teil : inspect(teil, { depth: 5 }))).join(' ')) + original(...teile) + } +} + +const felder = (liste) => liste.map((attribut) => attribut.key) +const datumsfelder = (liste) => liste.filter((attribut) => attribut.type === 'datetime').map((attribut) => attribut.key) +const aw = await startAppwriteMock({ + schema: { + auftraege: felder(AUFTRAEGE_COLLECTIONS.auftraege.attributes), + projektRechnungen: felder(AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes), + financeSettings: [...new Set([...schemaKeys('financeSettings'), ...felder(AUFTRAEGE_ZUSATZ_ATTRIBUTE.financeSettings)])], + }, + datetimes: { + auftraege: datumsfelder(AUFTRAEGE_COLLECTIONS.auftraege.attributes), + projektRechnungen: datumsfelder(AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes), + }, + defaultLimit: 25, // wie Appwrite: ohne limit() kommen nur 25 Dokumente zurück (die Termine-Tests prüfen es ebenso) +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) + +const { config } = await import('../server/config.js') +const { startTestApp, send, sessionCookie } = await import('./support/testApp.js') +const { kundenansichtCookie } = await import('./support/fixtures.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') +const { invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js') +const { customerStripeInvoice } = await import('../server/services/customerInvoices.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { addDays, berlinDate, formatDe } = await import('../server/services/finance/dates.js') +const { withdrawalDeadline } = await import('../server/services/deadlines.js') +const { AGB_FASSUNG, SCHALTER_AUS_MELDUNG } = await import('../server/services/auftraege/konstanten.js') +const { formatNummer } = await import('../server/services/auftraege/nummern.js') +const { hashToken } = await import('../server/services/auftraege/token.js') +const { FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js') +const { default: adminProjektkontenRoutes } = await import('../server/routes/admin/projektkonten.js') +const { default: adminAuftraegeRoutes } = await import('../server/routes/admin/auftraege.js') +const { default: adminFinanzenRoutes } = await import('../server/routes/admin/finanzen.js') +const { default: publicAuftragRoutes } = await import('../server/routes/auftrag.js') +const { default: kundeAuftraegeRoutes } = await import('../server/routes/auftraegeKunde.js') +const { default: aboRoutes, resetPublicRateLimit } = await import('../server/routes/abo.js') +const { default: teamRoutes } = await import('../server/routes/team/index.js') + +// Eingebunden wie in server/index.js (V5 „Einbindung“): Wächter der Kundenansicht zuerst, spezifische Admin-Pfade vor dem Finanzen-Router +const app = await startTestApp({ + '/': kundenansichtGuard, + '/api/admin/finanzen/projektkonten': adminProjektkontenRoutes, + '/api/admin/finanzen/auftraege': adminAuftraegeRoutes, + '/api/admin/finanzen': adminFinanzenRoutes, + '/api/auftrag': publicAuftragRoutes, + '/api/auftraege': kundeAuftraegeRoutes, + '/api/abo': aboRoutes, + '/api/team': teamRoutes, +}) +const mails = mailSink() +setMailTransportForTests(mails) +after(async () => { + setMailTransportForTests(null) + await Promise.all([app.close(), aw.close(), st.close()]) + // nur der selbst angelegte Wegwerf-Ordner, nie ein Pfad von außen + if (ABLAGE.startsWith(`${os.tmpdir()}${path.sep}auftraege-ablauf-`)) fs.rmSync(ABLAGE, { recursive: true, force: true }) +}) +beforeEach(() => { + resetInvoiceCreateGuardForTests() // 30-Sekunden-Schutz gegen gleiche Rechnungen + invalidateFinanceCache() // Stripe-Zwischenspeicher (60 s) + resetPublicRateLimit() // Widerrufsseite: 10 Erklärungen je Stunde und Adresse +}) + +// --- Hilfen -------------------------------------------------------------------------------------------------- +const ADMIN = { Authorization: 'Bearer jwt-admin' } +const STAFF = { Authorization: 'Bearer jwt-staff' } // Mitarbeiter ohne Admin-Label +const KV = 'a'.repeat(32) // Kennung der Kundenansicht (Kopfzeile X-Kundenansicht) +const UNBEKANNT = 'Dieser Link ist ungültig oder abgelaufen.' +const HEUTE = berlinDate() +const JAHR = Number(HEUTE.slice(0, 4)) +const AB = () => config.collections.auftraege +const PR = () => config.collections.projektRechnungen +const ip = (n) => ({ 'x-forwarded-for': `203.0.113.${n}` }) // je Test eine eigene Client-Adresse (Ratenbegrenzung je Adresse) +const sha256 = (daten) => crypto.createHash('sha256').update(daten).digest('hex') +const pause = (ms) => new Promise((fertig) => setTimeout(fertig, ms)) +const kundeCookie = (id) => sessionCookie({ customerId: id, appwriteUserId: `u_${id}`, name: `Kontakt ${id}`, email: `${id}@kunde.example` }) +const admin = (methode, pfad, body, headers = {}) => send(methode, `/api/admin/finanzen${pfad}`, { body, headers: { ...ADMIN, ...headers } }) + +function erwarte(res, status, was, meldung) { + assert.equal(res.status, status, `${was}: erwartet ${status}, bekam ${res.status} ${JSON.stringify(res.body)}`) + if (meldung instanceof RegExp) assert.match(String(res.body?.error ?? ''), meldung, `${was}: Meldung`) + else if (typeof meldung === 'string') assert.equal(res.body?.error, meldung, `${was}: Meldung`) + return res.body +} +// Annahme (nicht im Vertrag): Erfolg heißt 200 oder 201 +function erfolg(res, was) { + assert.ok(res.status === 200 || res.status === 201, `${was}: erwartet 200 oder 201, bekam ${res.status} ${JSON.stringify(res.body)}`) + return res.body +} + +async function holePdf(pfad, { headers = {}, cookie } = {}) { + const res = await fetch(app.url + pfad, { headers: { ...(cookie ? { cookie } : {}), ...headers } }) + return { status: res.status, typ: res.headers.get('content-type') || '', bytes: Buffer.from(await res.arrayBuffer()) } +} +const istPdf = (bytes) => bytes.subarray(0, 5).toString('latin1') === '%PDF-' && bytes.length > 1500 && bytes.toString('latin1', Math.max(0, bytes.length - 64)).includes('%%EOF') +const seiten = (bytes) => (bytes.toString('latin1').match(/\/Type \/Page(?![s\w])/g) || []).length +const pdfDateien = () => (fs.existsSync(ABLAGE) ? fs.readdirSync(ABLAGE, { recursive: true }).filter((name) => String(name).endsWith('.pdf')) : []) + +const adressen = (feld) => (feld || []).map((eintrag) => eintrag.address) +const findeMail = (vorher, muster) => mails.sent.slice(vorher).find((mail) => muster.test(mail.subject)) +async function warteBis(pruefung, { ms = 5000, was = 'Bedingung' } = {}) { + const ende = Date.now() + ms + for (;;) { + const wert = await pruefung() + if (wert) return wert + if (Date.now() > ende) assert.fail(`Zeitüberschreitung beim Warten auf: ${was}`) + await pause(25) + } +} +const GESEHEN = new Set() // alle Token aus Mails und Fehlversuchen: D16 sucht sie in Dokumenten und im Protokoll +const TOKEN_IM_LINK = /\/auftrag\.html#t=([A-Za-z0-9_-]{43})(?![A-Za-z0-9_-])/ // V6: K31 verlinkt auftrag.html#t= (Fragment: landet in keinem Server- oder Proxy-Protokoll) +function tokenAus(mail) { + const treffer = String(mail.html).match(TOKEN_IM_LINK) || String(mail.text).match(TOKEN_IM_LINK) + assert.ok(treffer, `kein Bestätigungslink in der Mail "${mail.subject}"`) + GESEHEN.add(treffer[1]) + return treffer[1] +} + +// V6: K31 geht OHNE BCC an den Kunden (die Mail trägt den Bestätigungslink); das Team bekommt eine eigene Kopie ohne Link (Block teamkopie), +// auch bei Erinnerungen. k31Mails trennt beide. +const kundenAdresse = (kundenId) => `${kundenId}@kunde.example` +function k31Mails(vorher, kundenId) { + const alle = mails.sent.slice(vorher).filter((mail) => /Auftragsbestätigung AB-\d{4}-\d{4}/.test(mail.subject)) + const anKunde = alle.filter((mail) => adressen(mail.to).includes(kundenAdresse(kundenId))) + return { anKunde, teamkopien: alle.filter((mail) => !anKunde.includes(mail)) } +} +function pruefeTeamkopie(kundenMail, token, teamkopien, was) { + assert.deepEqual(adressen(kundenMail.bcc), [], `${was}: K31 an den Kunden hat keine BCC-Kopie (V6)`) + assert.ok(teamkopien.length >= 1, `${was}: das Team bekommt eine Kopie der K31`) + const empfaenger = teamkopien.flatMap((mail) => adressen(mail.to)) + for (const adresse of config.chatNotify.emails) assert.ok(empfaenger.includes(adresse), `${was}: Teamkopie an ${adresse}`) + for (const kopie of teamkopien) { + const inhalt = `${kopie.html}\n${kopie.text}` + assert.equal(inhalt.includes(token), false, `${was}: die Teamkopie enthält den Token`) + assert.equal(/auftrag\.html[?#]/.test(inhalt), false, `${was}: die Teamkopie enthält einen Bestätigungslink`) + assert.match(kopie.text, /Kopie für das Team/, `${was}: Hinweis „Kopie für das Team“ statt des Knopfes`) + } +} + +const stripeAufrufe = (ab, methode, pfad) => st.calls.slice(ab).filter((aufruf) => aufruf.method === methode && (pfad instanceof RegExp ? pfad.test(aufruf.path) : aufruf.path === pfad)) +const zeilen = (rechnungId) => st.invoices.get(rechnungId).lines.data +function bezahlen(rechnungId) { + const roh = st.invoices.get(rechnungId) + assert.ok(roh, `Rechnung ${rechnungId} fehlt im Stripe-Nachbau`) + st.invoices.set(rechnungId, { + ...roh, status: 'paid', paid: true, amount_paid: roh.total, amount_remaining: 0, + status_transitions: { ...roh.status_transitions, paid_at: Math.floor(Date.now() / 1000) }, + }) + invalidateFinanceCache() +} +async function rechnung(body, was) { + return erfolg(await admin('POST', '/rechnungen', { daysUntilDue: 14, senden: false, ...body }), was).rechnung +} +async function konto(kundenId, projektId) { + const body = erwarte(await admin('GET', `/projektkonten?kunde=${kundenId}`), 200, `Projektkonten von ${kundenId}`) + const eintrag = body.konten.find((k) => k.projektId === projektId) + assert.ok(eintrag, `Projekt ${projektId} fehlt in den Projektkonten von ${kundenId}`) + return { body, konto: eintrag } +} + +const AB_BASIS = { + titel: 'Neue Website', art: 'festpreis', projektId: '', + positionen: [{ beschreibung: 'Konzept und Gestaltung', menge: 1, einzelpreisCents: 50000 }], + stundensatzCents: null, schaetzungMinuten: null, + zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' }, zahlungszielTage: 14, + auftragVom: addDays(HEUTE, -1), auftragWeg: 'telefon', startAm: addDays(HEUTE, 7), fertigBis: addDays(HEUTE, 40), zeitquelle: [], +} +async function entwurf(kundenId, ueberschreibe = {}) { + const auftrag = erfolg(await admin('POST', '/auftraege', { ...AB_BASIS, kundenId, ...ueberschreibe }), `AB-Entwurf für ${kundenId}`).auftrag + assert.equal(auftrag.status, 'entwurf') + assert.equal(auftrag.nummer, '', 'Entwürfe haben keine Nummer') + return auftrag +} +const lies = async (id) => erwarte(await admin('GET', `/auftraege/${id}`), 200, `AB ${id} lesen`).auftrag +async function sende(id, was = 'AB senden') { + const vorher = mails.sent.length + const body = erwarte(await admin('POST', `/auftraege/${id}/senden`), 200, was) + assert.equal(body.mailFehler, false, `${was}: Mail nicht zugestellt`) + const { anKunde, teamkopien } = k31Mails(vorher, body.auftrag.kundenId) + assert.equal(anKunde.length, 1, `${was}: genau eine K31 an den Kunden (${mails.sent.slice(vorher).map((m) => `${m.subject} an ${adressen(m.to)}`).join(' | ')})`) + const token = tokenAus(anKunde[0]) + pruefeTeamkopie(anKunde[0], token, teamkopien, was) + return { auftrag: body.auftrag, mail: anKunde[0], token } +} +const ansicht = (token, n) => send('GET', `/api/auftrag/${token}`, { headers: ip(n) }) +const bestaetigen = (token, n, body = { bestaetigt: true, fruehbeginn: false }, extra = {}) => + send('POST', `/api/auftrag/${token}/bestaetigen`, { body, headers: { ...ip(n), ...(extra.headers || {}) }, cookie: extra.cookie }) +const vergebeneNummern = () => [...aw.docs(AB()).values()].filter((d) => d.nummer).map((d) => d.nummer).sort() + +// --- Testdaten ----------------------------------------------------------------------------------------------- +const ADRESSE = { street: 'Hauptstraße 1', postalCode: '10115', city: 'Berlin', country: 'Deutschland' } +const kundendoc = (id, ueberschreibe = {}) => ({ + $id: id, appwriteUserId: `u_${id}`, name: `Kontakt ${id}`, email: `${id}@kunde.example`, code: `K-${id}`, + customerStatus: 'active', status: 'active', portalAccessEnabled: true, kundenart: 'unternehmen', ...ADRESSE, ...ueberschreibe, +}) +const KUNDEN = { + kfirma: kundendoc('kfirma', { companyName: 'Beispiel GmbH', name: 'Anna Beispiel' }), + kprivat: kundendoc('kprivat', { kundenart: 'privat', name: 'Petra Privat' }), + kfremd: kundendoc('kfremd', { companyName: 'Fremd AG', name: 'Frank Fremd' }), + kluecke: kundendoc('kluecke', { kundenart: '', name: 'Lena Lücke' }), +} +for (const doc of Object.values(KUNDEN)) aw.seed('customers', doc) +const setzeKunde = (id, patch) => aw.seed('customers', { ...KUNDEN[id], ...patch }) + +function legeProjektAn(id, kundenId, ticketId, woid, name) { + aw.seed('workorders', { $id: ticketId, woid, customerId: kundenId, topic: name, status: 'Open', type: 'Webpage' }) + aw.seed('websiteProjects', { $id: id, customerId: kundenId, ticketId, projectName: name, projectType: 'website', status: 'active' }) +} +legeProjektAn('pfest', 'kfirma', 'tkfest', '100201', 'Website Beispiel') +legeProjektAn('paufw', 'kfirma', 'tkaufw', '100202', 'Online-Shop Beispiel') +legeProjektAn('pab', 'kfirma', 'tkab', '100203', 'Relaunch Größe & Maße') +legeProjektAn('pstorno', 'kfirma', 'tkstorno', '100204', 'Landingpage Beispiel') +legeProjektAn('pzuordnen', 'kfirma', 'tkzuordnen', '100205', 'Projekt zum Zuordnen') +legeProjektAn('pfremd', 'kfremd', 'tkfremd', '100299', 'Fremdes Projekt') +const blatt = (id, ticketId, ueberschreibe) => ({ + $id: id, workorderId: ticketId, woid: ticketId === 'tkaufw' ? '100202' : '100299', serviceType: 'Remote', employeeName: 'Justin Klein', + totalTime: 60, startDate: '06.10.2026', endDate: '06.10.2026', details: 'Arbeit', isComment: false, countsToPlan: false, ...ueberschreibe, +}) +const ANDREJ = [{ employeeId: 'andrej', name: 'Andrej Stevanovski', task: 'Farbkonzept' }] +// Zeit des Projekts „Online-Shop“: 06.10. = 60 + 75 min, 07.10. = 50 → 60 min, 08.10. = 40 + 30 Unterstützung → 75 min; zusammen 270 min = 4,5 Std. +aw.seed('worksheets', blatt('wsA', 'tkaufw', { totalTime: 60, details: 'Startseite' })) +aw.seed('worksheets', blatt('wsB', 'tkaufw', { totalTime: 75, details: 'Kontaktformular' })) +aw.seed('worksheets', blatt('wsC', 'tkaufw', { totalTime: 50, startDate: '07.10.2026', endDate: '07.10.2026', details: 'Impressum und Datenschutz' })) +aw.seed('worksheets', blatt('wsD', 'tkaufw', { totalTime: 40, startDate: '08.10.2026', endDate: '08.10.2026', details: 'Farbkonzept', supporters: JSON.stringify(ANDREJ) })) +// nie abrechenbar: Abo-Zeit, Kommentar, 0 Minuten; dazu ein Blatt eines anderen Kunden +aw.seed('worksheets', blatt('wsAbo', 'tkaufw', { totalTime: 90, countsToPlan: true, details: 'Wartung im Abo' })) +aw.seed('worksheets', blatt('wsKomm', 'tkaufw', { totalTime: 30, isComment: true, details: 'Interner Hinweis' })) +aw.seed('worksheets', blatt('wsNull', 'tkaufw', { totalTime: 0, details: 'Telefonat ohne Zeit' })) +aw.seed('worksheets', blatt('wsFremd', 'tkfremd', { totalTime: 60, details: 'Arbeit beim anderen Kunden' })) + +// --- D0: Einbindung, Seiten und Zugriffsschutz --------------------------------------------------------------- +test('D0 Einbindung: server/index.js hängt die neuen Router ein (Admin-Pfade vor dem Finanzen-Router), das Team-Gerüst hängt hinter requireStaffJwt', () => { + const index = fs.readFileSync(new URL('../server/index.js', import.meta.url), 'utf8') + const stelle = (pfad, was) => { + const treffer = new RegExp(`app\\.use\\(\\s*['"]${pfad}['"]\\s*,\\s*\\w+\\s*\\)`).exec(index) + assert.ok(treffer, `server/index.js: ${was} (${pfad}) fehlt`) + return treffer.index + } + const finanzen = stelle('/api/admin/finanzen', 'Finanzen-Router') + assert.ok(stelle('/api/admin/finanzen/projektkonten', 'Projektkonten') < finanzen, 'Projektkonten vor dem Finanzen-Router') + assert.ok(stelle('/api/admin/finanzen/auftraege', 'Aufträge (Admin)') < finanzen, 'Aufträge vor dem Finanzen-Router') + stelle('/api/auftrag', 'öffentliche Token-Routen') + stelle('/api/auftraege', 'Kundenrouten') + const team = fs.readFileSync(new URL('../server/routes/team/index.js', import.meta.url), 'utf8') + const jwt = team.indexOf('router.use(requireStaffJwt)') + assert.ok(jwt >= 0 && team.indexOf("router.use('/arbeitsblaetter'") > jwt, "Team: router.use('/arbeitsblaetter', …) steht hinter requireStaffJwt") +}) + +test('D0 Seiten: die Bestätigungsseite ist noindex, die Widerrufsseite kennt den Projektauftrag', () => { + const auftrag = fs.readFileSync(new URL('../public/auftrag.html', import.meta.url), 'utf8') + assert.match(auftrag, /]*(?:name=["']robots["'][^>]*noindex|noindex[^>]*name=["']robots["'])/i, 'auftrag.html trägt noindex') + const widerruf = fs.readFileSync(new URL('../public/widerrufen.html', import.meta.url), 'utf8') + assert.match(widerruf, /value=["']projekt["']/, 'Vertragsart mit order=projekt') + assert.match(widerruf, /Projektauftrag/, 'Beschriftung „Projektauftrag (Auftragsbestätigung)“') +}) + +test('D0 Zugriff: ohne Anmeldung 401, Mitarbeiter ohne Admin-Label 403, Kunden-Cookie ersetzt kein JWT, unbekannte Token einheitlich 404', async () => { + const adminRouten = [ + ['GET', '/projektkonten?kunde=kfirma'], ['GET', '/projektkonten/pfest'], ['GET', '/auftraege?kunde=kfirma'], ['POST', '/auftraege'], + ['GET', '/auftraege/x'], ['PATCH', '/auftraege/x'], ['DELETE', '/auftraege/x'], ['GET', '/auftraege/x/pdf'], ['POST', '/auftraege/x/senden'], + ['POST', '/auftraege/x/erinnern'], ['POST', '/auftraege/x/neue-fassung'], ['POST', '/auftraege/x/stornieren'], ['POST', '/auftraege/x/projekt'], ['POST', '/rechnungen/vorschau'], + ] + for (const [methode, pfad] of adminRouten) { + const body = methode === 'GET' || methode === 'DELETE' ? undefined : {} + const was = `${methode} ${pfad}` + erwarte(await send(methode, `/api/admin/finanzen${pfad}`, { body }), 401, `${was} ohne JWT`) + erwarte(await send(methode, `/api/admin/finanzen${pfad}`, { body, cookie: kundeCookie('kfirma') }), 401, `${was} mit Kunden-Cookie`) + erwarte(await send(methode, `/api/admin/finanzen${pfad}`, { body, headers: STAFF }), 403, `${was} als Mitarbeiter ohne Admin-Label`) + } + const abgerechnet = '/api/team/arbeitsblaetter/abgerechnet?ticketId=tkaufw' + erwarte(await send('GET', abgerechnet), 401, 'Team-Route ohne JWT') + erwarte(await send('GET', abgerechnet, { cookie: kundeCookie('kfirma') }), 401, 'Team-Route mit Kunden-Cookie') + erwarte(await send('GET', abgerechnet, { headers: { Authorization: 'Bearer jwt-kunde' } }), 403, 'Team-Route mit Kundenkonto ohne Label') + assert.deepEqual(Object.keys(erwarte(await send('GET', abgerechnet, { headers: STAFF }), 200, 'Team-Route als Mitarbeiter')), ['abgerechnet']) + for (const [methode, pfad] of [['GET', '/api/auftraege'], ['GET', '/api/auftraege/x/pdf'], ['POST', '/api/auftraege/x/bestaetigen']]) { + erwarte(await send(methode, pfad, { body: methode === 'POST' ? { bestaetigt: true } : undefined }), 401, `${methode} ${pfad} ohne Sitzung`) + } + for (const token of ['x', 'kurz', 'A'.repeat(43), 'A'.repeat(44), `${'A'.repeat(42)}%2F`]) { + erwarte(await ansicht(token, 1), 404, `Ansicht mit Token ${token}`, UNBEKANNT) + erwarte(await send('GET', `/api/auftrag/${token}/pdf`, { headers: ip(1) }), 404, `PDF mit Token ${token}`, UNBEKANNT) + erwarte(await bestaetigen(token, 2, { bestaetigt: true }), 404, `Bestätigen mit Token ${token}`, UNBEKANNT) + } +}) + +// --- D1: Einstellungen --------------------------------------------------------------------------------------- +test('D1 Einstellungen: die Steuernummer wird geprüft und gespeichert (sie steht später auf dem AB-PDF)', async () => { + assert.equal(erfolg(await admin('GET', '/einstellungen'), 'Einstellungen lesen').einstellungen.steuernummer, '') + // V4: erlaubt sind Buchstaben, Ziffern, Leerzeichen, / - und . (auch eine USt-IdNr. „DE…“), höchstens 40 Zeichen + for (const falsch of ['12/345/67890 & "Test"' + const { request } = fakeRequest([liste(eintrag({ id: 'a/b', titel: boese }))]) + await initAuftraege({ ...els, request, viewAs: false }) + assert.equal(fx.alle(els.body, 'script').length, 0) + assert.equal(fx.finde(els.body, '.ab-karte-titel').textContent, boese) + assert.equal(fx.finde(els.body, '[data-link="pdf"]').getAttribute('href'), '/api/auftraege/a%2Fb/pdf') +})) + +test('Quelltext und CSS: kein eigenes fetch, Karten und Abschnitt sind gestaltet', () => { + pruefeQuelltext('auftrag-portal.js', { importe: ['./auftrag-ansicht.js', './auftrag-dom.js'] }) + const css = readFileSync(new URL('../public/auftrag.css', import.meta.url), 'utf8') + for (const klasse of ['ab-portal', 'ab-karte', 'ab-karte-kopf', 'ab-karte-titel', 'ab-karte-meta', 'ab-karte-aktionen', 'ab-details']) { + assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[>]`), `.${klasse} ist gestaltet`) + } + assert.match(css, /\.ab-portal\s*\{[^}]*--ab-/, 'der Portal-Abschnitt setzt seine eigenen Farbschlüssel (dunkles Portal)') +}) diff --git a/test/auftraegeKundenportalRechnungen.test.js b/test/auftraegeKundenportalRechnungen.test.js new file mode 100644 index 0000000..295fae1 --- /dev/null +++ b/test/auftraegeKundenportalRechnungen.test.js @@ -0,0 +1,28 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { useTestEnv } from './support/testEnv.js' + +// Reiner Test von customerStripeInvoice: config.js liest die Umgebung beim ersten Import, die Adressen werden nie angefragt. +useTestEnv({ appwriteUrl: 'http://127.0.0.1:9', stripeUrl: 'http://127.0.0.1:9' }) +const { customerStripeInvoice } = await import('../server/services/customerInvoices.js') + +const roh = (over = {}) => ({ + id: 'in_1', number: 'WEBKLAR-0009', status: 'open', effective_at: 1_791_000_000, due_date: 1_792_000_000, + total: 50000, amount_remaining: 50000, hosted_invoice_url: 'https://invoice.stripe.com/i/in_1', + invoice_pdf: 'https://pay.stripe.com/invoice/in_1/pdf', metadata: {}, ...over, +}) + +test('Rechnung der Kunden-Liste: projekt kommt aus metadata.projektName, sonst leer', () => { + assert.equal(customerStripeInvoice(roh({ metadata: { projektName: 'Website Anelia Wolf' } })).projekt, 'Website Anelia Wolf') + assert.equal(customerStripeInvoice(roh()).projekt, '') + assert.equal(customerStripeInvoice(roh({ metadata: { webklarCustomerId: 'c1', art: 'abo' } })).projekt, '') + const ohneMetadaten = roh() + delete ohneMetadaten.metadata + assert.equal(customerStripeInvoice(ohneMetadaten).projekt, '') +}) + +test('Rechnung der Kunden-Liste: die bisherigen Felder bleiben, projekt kommt dazu', () => { + assert.deepEqual(Object.keys(customerStripeInvoice(roh())).sort(), [ + 'amount', 'balance', 'cancelled', 'date', 'dueDate', 'hasPdf', 'id', 'number', 'overdue', 'paid', 'portalUrl', 'projekt', 'source', 'status', + ]) +}) diff --git a/test/auftraegeKundenportalSeite.test.js b/test/auftraegeKundenportalSeite.test.js new file mode 100644 index 0000000..7ac8c5b --- /dev/null +++ b/test/auftraegeKundenportalSeite.test.js @@ -0,0 +1,160 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { installFakeDom } from './support/fakeDom.js' +import { h } from '../public/auftrag-dom.js' +import { initAuftragsseite } from '../public/auftrag.js' +import { TOKEN, KLEINUNTERNEHMER, ab, privat, bestaetigt, pruefeQuelltext } from './auftraegeKundenportalDaten.js' + +const ok = (body, status = 200) => ({ status, body }) +const mit = (antworten, fn) => async () => { + const fx = installFakeDom({ antworten }) + const root = h('div', { id: 'ab-wurzel' }) + const ansage = h('div', { id: 'ab-ansage' }) + fx.document.body.append(root, ansage) + try { + await fn(fx, root, ansage) + } finally { + fx.restore() + } +} +const start = (root, ansage, adresse = {}) => initAuftragsseite({ root, ansage, suche: '', hash: `#t=${TOKEN}`, ...adresse }) // K31 verlinkt #t= +const bestaetige = async (fx, root, { frueh = false } = {}) => { + await fx.klick(fx.finde(root, '[data-feld="bestaetigung"]')) + if (frueh) await fx.klick(fx.finde(root, '[data-feld="fruehbeginn"]')) + await fx.klick(fx.finde(root, '[data-aktion="bestaetigen"]')) +} + +test('Ohne gültigen Token: „ungültig“ und Kontaktweg, ohne Anfrage', mit([], async (fx, root, ansage) => { + for (const adresse of [{ hash: '' }, { hash: '#t=' }, { hash: '#t=zu-kurz' }, { hash: `#t=${TOKEN}x` }, { hash: '', suche: '?t=zu-kurz' }]) { + await start(root, ansage, adresse) + assert.equal(root.dataset.zustand, 'ungueltig', JSON.stringify(adresse)) + assert.equal(fx.finde(root, 'h1').textContent, 'Dieser Link ist ungültig oder abgelaufen.') + assert.equal(fx.finde(root, 'a[href="mailto:support@webklar.com"]').textContent, 'support@webklar.com') + assert.equal(fx.finde(root, 'a[href="tel:017623726355"]').textContent, '0176 23726355') + } + assert.equal(fx.fetchLog.length, 0) +})) + +test('Laden und Anzeigen: Kopf, Zusammenfassung, Unterlagen, Formular; der Token steht nur in Links', mit([ok({ auftrag: ab() })], async (fx, root, ansage) => { + await start(root, ansage) + assert.deepEqual(fx.fetchLog, [{ method: 'GET', url: `/api/auftrag/${TOKEN}`, body: undefined }]) + assert.equal(root.dataset.zustand, 'offen') + assert.equal(fx.finde(root, 'h1').textContent, 'Auftragsbestätigung AB-2026-0002') + assert.equal(fx.finde(root, '.ab-pille').textContent, 'Bitte bestätigen') + assert.equal(fx.alle(root, '[data-position]').length, 2) + const pdf = fx.finde(root, '[data-link="pdf"]') + const agb = fx.finde(root, '[data-link="agb"]') + assert.equal(pdf.getAttribute('href'), `/api/auftrag/${TOKEN}/pdf`) + assert.equal(agb.getAttribute('href'), '/rechtliches/agb-2026-09-30.pdf') + assert.ok(fx.finde(root, 'form [data-feld="bestaetigung"]')) + assert.equal(fx.finde(root, '[data-feld="fruehbeginn"]'), null) + assert.equal(fx.finde(root, '[data-hinweis]').textContent, KLEINUNTERNEHMER, 'der § 19-Satz kommt aus der API') + assert.equal(fx.finde(root, 'legend').textContent, 'Ihre Bestätigung', 'öffentliche Seite: Sie-Form') + assert.equal(fx.text(ansage), 'Ihre Auftragsbestätigung wurde geladen.') + assert.ok(!fx.text(root).includes(TOKEN) && !fx.text(ansage).includes(TOKEN)) +})) + +test('Der Token steht im Fragment (#t=, so verlinkt K31); als Rückfall genügt ?t=', mit([ok({ auftrag: ab() }), ok({ auftrag: ab() })], async (fx, root, ansage) => { + await start(root, ansage) + assert.equal(root.dataset.zustand, 'offen') + await start(root, ansage, { hash: '', suche: `?t=${TOKEN}` }) + assert.equal(root.dataset.zustand, 'offen') + assert.deepEqual(fx.fetchLog.map((a) => a.url), [`/api/auftrag/${TOKEN}`, `/api/auftrag/${TOKEN}`]) +})) + +test('Aufwand: Aufwandstext statt Tabelle', mit([ok({ auftrag: ab({ art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde.' }) })], async (fx, root, ansage) => { + await start(root, ansage) + assert.equal(fx.finde(root, '[data-aufwand]').textContent, '100,00 € pro Stunde.') + assert.equal(fx.finde(root, 'table'), null) +})) + +test('Bestätigen (Unternehmen): POST mit Häkchen, danach „bestätigt“ mit Zeitpunkt, Fokus und Ansage', mit([ok({ auftrag: ab() }), ok({ auftrag: bestaetigt(ab()) })], async (fx, root, ansage) => { + await start(root, ansage) + await bestaetige(fx, root) + assert.deepEqual(fx.fetchLog[1], { method: 'POST', url: `/api/auftrag/${TOKEN}/bestaetigen`, body: { bestaetigt: true, fruehbeginn: false } }) + assert.equal(root.dataset.zustand, 'bestaetigt') + assert.equal(fx.finde(root, 'h1').textContent, 'Ihr Auftrag ist bestätigt.') + assert.equal(fx.finde(root, '.ab-pille').textContent, 'Bestätigt') + const block = fx.finde(root, '[data-bestaetigt]') + assert.deepEqual(fx.alle(block, 'p').map((p) => p.textContent), ['Bestätigt am 07.10.2026, 10:15 Uhr']) + assert.equal(fx.finde(root, 'form'), null) + assert.equal(fx.document.activeElement, block) + assert.equal(fx.text(ansage), 'Ihr Auftrag ist bestätigt.') + assert.ok(fx.finde(root, '[data-link="pdf"]'), 'Unterlagen bleiben erreichbar') +})) + +test('Privatperson: zweites Häkchen; angezeigt wird, was der Server meldet (Frühbeginn „nein“ trotz Häkchen)', mit([ok({ auftrag: privat() }), ok({ auftrag: bestaetigt(privat({ fruehbeginn: false })) })], async (fx, root, ansage) => { + await start(root, ansage) + assert.ok(fx.finde(root, '[data-feld="fruehbeginn"]')) + await bestaetige(fx, root, { frueh: true }) + assert.deepEqual(fx.fetchLog[1].body, { bestaetigt: true, fruehbeginn: true }) + assert.deepEqual(fx.alle(fx.finde(root, '[data-bestaetigt]'), 'p').map((p) => p.textContent), [ + 'Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen frühestens am 21.10.2026.', + ]) +})) + +test('Schon bestätigt beim Laden: „bestätigt“ ohne Formular, mit der Zeile zum Beginn', mit([ok({ auftrag: bestaetigt(privat({ fruehbeginn: true, fruehesterBeginn: null })) })], async (fx, root, ansage) => { + await start(root, ansage) + assert.equal(root.dataset.zustand, 'bestaetigt') + assert.equal(fx.finde(root, 'form'), null) + assert.deepEqual(fx.alle(fx.finde(root, '[data-bestaetigt]'), 'p').map((p) => p.textContent), [ + 'Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist.', + ]) +})) + +test('Zustände: ungültig, ersetzt, storniert, widerrufen, zu viele Anfragen, Serverfehler – feste Texte, nie der Token', mit([], async (fx, root, ansage) => { + const faelle = [ + [ok({ error: 'Dieser Link ist ungültig oder abgelaufen.' }, 404), 'ungueltig', 'Dieser Link ist ungültig oder abgelaufen.'], + [ok({ error: 'Diese Auftragsbestätigung wurde ersetzt.' }, 410), 'beendet', 'Diese Auftragsbestätigung wurde ersetzt.'], + [ok({ error: 'Diese Auftragsbestätigung wurde storniert.' }, 410), 'beendet', 'Diese Auftragsbestätigung wurde storniert.'], + [ok({ error: 'Diese Auftragsbestätigung wurde widerrufen.' }, 410), 'beendet', 'Diese Auftragsbestätigung wurde widerrufen.'], + [ok({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }, 429), 'limit', 'Zu viele Anfragen'], + [ok({ error: 'Interner Fehler.' }, 500), 'fehler', 'Das hat leider nicht geklappt'], + [ok({}, 502), 'fehler', 'Das hat leider nicht geklappt'], + [ok({}, 200), 'fehler', 'Das hat leider nicht geklappt'], // Antwort ohne auftrag + ] + for (const [antwort, zustand, titel] of faelle) { + fx.antworten.push(antwort) + await start(root, ansage) + assert.equal(root.dataset.zustand, zustand, titel) + assert.equal(fx.finde(root, 'h1').textContent, titel) + assert.equal(fx.text(ansage), titel) + assert.equal(fx.finde(root, 'form'), null) + assert.ok(!fx.text(root).includes(TOKEN) && !fx.text(ansage).includes(TOKEN)) + assert.equal(Boolean(fx.finde(root, '[data-aktion="erneut"]')), ['limit', 'fehler'].includes(zustand), `Erneut-Knopf bei ${zustand}`) + } + assert.equal(fx.finde(root, 'a[href="mailto:support@webklar.com"]') !== null, true, 'auch beim Fehler steht der Kontaktweg da') +})) + +test('Netzfehler beim Laden: Fehlerzustand ohne Fremdtext, „Erneut versuchen“ lädt neu', mit([() => { throw new TypeError('fetch failed') }, ok({ auftrag: ab() })], async (fx, root, ansage) => { + await start(root, ansage) + assert.equal(root.dataset.zustand, 'fehler') + assert.ok(!fx.text(root).includes('fetch failed')) + await fx.klick(fx.finde(root, '[data-aktion="erneut"]')) + assert.equal(root.dataset.zustand, 'offen') + assert.equal(fx.fetchLog.length, 2) +})) + +test('Status wechselt zwischen Laden und Bestätigen (410): „storniert“, kein Formular mehr', mit([ok({ auftrag: ab() }), ok({ error: 'Diese Auftragsbestätigung wurde storniert.' }, 410)], async (fx, root, ansage) => { + await start(root, ansage) + await bestaetige(fx, root) + assert.equal(root.dataset.zustand, 'beendet') + assert.equal(fx.finde(root, 'h1').textContent, 'Diese Auftragsbestätigung wurde storniert.') + assert.equal(fx.finde(root, 'form'), null) +})) + +test('Fehler beim Bestätigen (429): Meldung in der Fehlerzeile, Seite bleibt offen, Häkchen bleiben', mit([ok({ auftrag: ab() }), ok({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }, 429)], async (fx, root, ansage) => { + await start(root, ansage) + await bestaetige(fx, root) + assert.equal(root.dataset.zustand, 'offen') + assert.equal(fx.finde(root, '[data-fehler]').textContent, 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten noch einmal.') + assert.equal(fx.finde(root, '[data-feld="bestaetigung"]').checked, true) + assert.equal(fx.finde(root, '[data-aktion="bestaetigen"]').disabled, false) + assert.ok(!fx.text(root).includes(TOKEN)) +})) + +test('Quelltext: genau ein fetch ohne Cookies, keine Protokollausgabe, nur relative Importe', () => { + const quelle = pruefeQuelltext('auftrag.js', { fetchAnzahl: 1, importe: ['./auftrag-ansicht.js', './auftrag-dom.js'] }) + assert.match(quelle, /credentials: 'omit'/) + assert.doesNotMatch(quelle, /location\.href|history\.|document\.referrer/) +}) diff --git a/test/auftraegeKundenportalWiderruf.test.js b/test/auftraegeKundenportalWiderruf.test.js new file mode 100644 index 0000000..0e18bcc --- /dev/null +++ b/test/auftraegeKundenportalWiderruf.test.js @@ -0,0 +1,110 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { installFakeDom } from './support/fakeDom.js' +import { h } from '../public/auftrag-dom.js' +import { initProjektWiderruf } from '../public/auftrag-widerruf.js' +import { pruefeQuelltext } from './auftraegeKundenportalDaten.js' + +const html = readFileSync(new URL('../public/widerrufen.html', import.meta.url), 'utf8') +const MELDUNG = 'Die Nummer sieht anders aus als AB-2026-0001. Bitte prüfen Sie die Nummer oder lassen Sie das Feld leer.' + +test('widerrufen.html: Vertragsart „Projektauftrag“ hinter „Öffnungszeiten live“, freiwilliges Feld abNummer vor dem Namen, Skripte', () => { + assert.match(html, /name="order" value="zeiten"[^>]*> Öffnungszeiten live<\/label>\s*