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

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

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Figtree Project Authors (https://github.com/erikdkennedy/figtree)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
Figtree, statische TrueType-Schnitte Regular (400) und SemiBold (600) für das PDF der Auftragsbestätigung.
Lizenz: SIL Open Font License 1.1, Text in Figtree-OFL.txt.
Quelle: npm-Paket @expo-google-fonts/figtree@0.4.1 (400Regular/Figtree_400Regular.ttf, 600SemiBold/Figtree_600SemiBold.ttf, LICENSE_FONT als Figtree-OFL.txt), Stand 2026-10-06.
SHA-256 Figtree-Regular.ttf: 4e2e9876c4a908c2cbe410ceb648e4724464a84492bd49cb2d6e7b0d3f769a66
SHA-256 Figtree-SemiBold.ttf: 1b76aae525b51758fc0c9c5096f33d90831657d3ad2fe3c34fb7e666d76f8960
+14
View File
@@ -33,6 +33,7 @@ const termineEnabled = (process.env.TERMINE_ENABLED || '').trim().toLowerCase()
const shopEnabled = (process.env.SHOP_ENABLED || '').trim().toLowerCase()
const unterkunftEnabled = (process.env.UNTERKUNFT_ENABLED || '').trim().toLowerCase()
const zeitenEnabled = (process.env.ZEITEN_ENABLED || '').trim().toLowerCase()
const auftraegeEnabled = (process.env.AUFTRAEGE_ENABLED || '').trim().toLowerCase()
/** Komma-Liste aus einer Umgebungsvariablen: getrimmt, klein geschrieben, ohne Leereintraege; leer ergibt den Vorgabewert. */
function commaList(raw, fallback) {
@@ -782,6 +783,9 @@ export const config = {
// Oeffnungszeiten live (Einstellungen, Erinnerungsmerker; scripts/zeiten-setup.mjs)
oeffnungszeiten: process.env.APPWRITE_COLLECTION_OEFFNUNGSZEITEN || 'oeffnungszeiten',
zeitenErinnerungen: process.env.APPWRITE_COLLECTION_ZEITEN_ERINNERUNGEN || 'zeitenErinnerungen',
// Aufträge und Rechnungen (Auftragsbestätigungen, Projektrechnungen; scripts/auftraege-setup.mjs)
auftraege: process.env.APPWRITE_COLLECTION_AUFTRAEGE || 'auftraege',
projektRechnungen: process.env.APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN || 'projektRechnungen',
},
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
@@ -1119,6 +1123,16 @@ export const config = {
keinNeubau: commaList(process.env.ZEITEN_KEIN_NEUBAU, ['emmas-schatzkiste']),
},
// Aufträge und Rechnungen: Schalter nur für die AB-Funktionen und die Rechnungsarten anzahlung/schluss; wird bei
// jedem Aufruf gelesen, siehe server/services/auftraege/schalter.js
auftraege: {
// off (Vorgabe) | pilot (nur AUFTRAEGE_PILOT_EMAILS, '*' = alle) | on
mode: ['pilot', 'on'].includes(auftraegeEnabled) ? auftraegeEnabled : 'off',
pilotEmails: commaList(process.env.AUFTRAEGE_PILOT_EMAILS, []),
// Gesendete AB-PDFs: <uploadDir>/<kundenId>/<auftragId>.pdf
uploadDir: process.env.AUFTRAEGE_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'auftraege'),
},
invoiceNinja: {
url: (process.env.INVOICE_NINJA_URL || 'https://invoice.webklar.com').replace(/\/$/, ''),
apiToken: process.env.INVOICE_NINJA_API_TOKEN || '',
+10
View File
@@ -23,6 +23,8 @@ import { shopSeiteRouter } from './routes/shopSeite.js'
import unterkunftRoutes from './routes/unterkunft.js'
import { unterkunftSeiteRouter } from './routes/unterkunftSeite.js'
import zeitenRoutes from './routes/zeiten.js'
import publicAuftragRoutes from './routes/auftrag.js'
import kundeAuftraegeRoutes from './routes/auftraegeKunde.js'
import takeoverCreditRoutes from './routes/takeoverCredits.js'
import { purgeExpiredUploads } from './services/takeoverCredits.js'
import { purgePendingChatUploads } from './services/chatAttachments.js'
@@ -52,6 +54,8 @@ import publicZeitenRoutes from './routes/publicZeiten.js'
import adminAnalyseRoutes from './routes/admin/analyse.js'
import { legalRouter } from './services/legalDocs.js'
import adminFinanzenRoutes from './routes/admin/finanzen.js'
import adminProjektkontenRoutes from './routes/admin/projektkonten.js'
import adminAuftraegeRoutes from './routes/admin/auftraege.js'
import teamRoutes from './routes/team/index.js'
import legacyAdminRedirect from './routes/legacyAdminRedirect.js'
import { corsMiddleware } from './middleware/cors.js'
@@ -101,6 +105,9 @@ app.use('/api/shop/stripe-webhook', shopWebhookRoutes)
app.use('/api/shop', shopRoutes)
app.use('/api/unterkunft', unterkunftRoutes)
app.use('/api/zeiten', zeitenRoutes)
// Auftragsbestaetigung: oeffentliche Bestaetigung per Token (ohne Sitzung) und die Auftraege des angemeldeten Kunden
app.use('/api/auftrag', publicAuftragRoutes)
app.use('/api/auftraege', kundeAuftraegeRoutes)
app.use('/api/team', teamRoutes)
app.use('/api/admin/gitea', adminGiteaRoutes)
app.use('/api/admin/website-projects', adminPreviewRoutes)
@@ -110,6 +117,9 @@ app.use('/api/admin/customers', adminCustomersRoutes)
app.use('/api/admin/employees', adminEmployeesRoutes)
app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes)
app.use('/api/admin/analyse', adminAnalyseRoutes)
// Projektkonten und Auftraege (Admin): vor dem Finanzen-Router, dessen requireAppwriteStaff sonst jede Anfrage darunter zuerst prueft
app.use('/api/admin/finanzen/projektkonten', adminProjektkontenRoutes)
app.use('/api/admin/finanzen/auftraege', adminAuftraegeRoutes)
app.use('/api/admin/finanzen', adminFinanzenRoutes)
app.use('/webhook', giteaWebhookRoutes)
// Push-Zeitmail: Zeit eines Git-Pushes per Link aus der Mail eintragen (ohne Login, signierter Token)
@@ -8,7 +8,8 @@
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: E-Mail aus dem Formular · BCC intern
Platzhalter: {{name}}, {{produkt}}, {{bestellung}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abschluss, wechsel, zusatzspeicher, funktion, pruefen
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abschluss, wechsel, zusatzspeicher, funktion, pruefen, projekt, projekt_unternehmen
projekt = Widerruf eines Projektauftrags (Aufträge und Rechnungen): wir prüfen und melden uns, ohne Zusagen zu Erstattung oder Wertersatz; projekt_unternehmen (nur innerhalb von projekt) = die Auftragsbestätigung gehört zu einem Unternehmen, der Zusatz nennt, dass kein gesetzliches Widerrufsrecht besteht.
produkt = Katalogname ohne „WEBklar “ („Abo“ ohne gefundenes Abo); beim Widerruf einer Funktion ihr Name aus dem Register (News-Pinnwand, Terminplaner). Folgen je Bestellung wie in den Hinweisen zum Widerruf (Fassung 4): abschluss (Wertersatz bei Sofortbeginn, Zusatzspeicher voll erstattet), wechsel (Hosting-Abo läuft weiter, Mehrbetrag abzüglich Wertersatz), zusatzspeicher (volle Erstattung, kein Wertersatz), pruefen (keine offene Frist).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -56,6 +57,9 @@ a{color:#111111;}
<!-- WENN pruefen -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir prüfen, ob für diese Bestellung eine Widerrufsfrist läuft, und melden uns umgehend bei Ihnen.</div></td></tr></table></td></tr>
<!-- ENDE pruefen -->
<!-- WENN projekt -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.<!-- WENN projekt_unternehmen --> Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.<!-- ENDE projekt_unternehmen --></div></td></tr></table></td></tr>
<!-- ENDE projekt -->
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Ihr WEBklar-Team</strong></td></tr>
</table>
</td></tr>
@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K31 · Auftragsbestätigung (Projektauftrag; Variante Erinnerung, Variante Privatperson, Kopie für das Team)
Betreff: Ihre Auftragsbestätigung {{nummer}} – {{titel}}
Absender: „WEBklar“ <Portal-Postfach>; die Kopie für das Team kommt als „WEBklar Kundenportal“
Empfänger: Kunde (E-Mail der Auftragsbestätigung), ohne BCC · Kopie ohne Link an kenso@webklar.com, andrej@webklar.com
Platzhalter: {{name}}, {{nummer}}, {{titel}}, {{datum}}, {{betrag}}, {{link}}, {{gueltig_bis}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): erinnerung, privat, festpreis, aufwand, teamkopie, kundenmail
nummer = Nummer der Auftragsbestätigung (AB-JJJJ-NNNN), titel = Projekt bzw. Titel des Auftrags, datum = Tag des Sendens (Berliner Datum, TT.MM.JJJJ).
betrag = Gesamtpreis bei Festpreis (zum Beispiel 800,00 €) bzw. Stundensatz bei Aufwand (zum Beispiel 100,00 € pro Stunde); genau einer der Blöcke festpreis und aufwand ist an.
link = Bestätigungsseite mit dem persönlichen Link (…/auftrag.html#t=…); er steht nur in der Mail an den Kunden und ist in der Kopie für das Team leer. gueltig_bis = letzter Tag, an dem der Link gilt (TT.MM.JJJJ).
erinnerung = Erinnerung des Teams: dieselbe Auftragsbestätigung mit neuem Link, der frühere Link gilt nicht mehr.
privat = der Kunde ist Privatperson: Hinweis auf Widerrufsrecht, Muster-Widerrufsformular (in der angehängten Auftragsbestätigung) und den Beginn der Arbeiten; nicht in der Kopie für das Team.
kundenmail = die Mail an den Kunden: Knopf mit dem Link und Hinweis auf die Anhänge (die Auftragsbestätigung als PDF, <Nummer>.pdf, und die Allgemeinen Geschäftsbedingungen als PDF, WEBklar-AGB-<Fassung>.pdf).
teamkopie = die Kopie für das Team: statt des Knopfs der Hinweis, dass der Bestätigungslink nur in der Mail an den Kunden steht (ohne Link, ohne Anhänge). Genau einer der Blöcke kundenmail und teamkopie ist an.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Ihre Auftragsbestätigung {{nummer}} – {{titel}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Bitte prüfen und bestätigen Sie Ihren Auftrag {{nummer}}.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Auftrag</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">Auftragsbestätigung</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Ihre Auftragsbestätigung {{nummer}}</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>vielen Dank für Ihren Auftrag. Anbei erhalten Sie die Auftragsbestätigung {{nummer}} zum Projekt „{{titel}}“. Bitte prüfen Sie sie und bestätigen Sie den Auftrag; eine Anmeldung ist nicht nötig.</td></tr>
<!-- WENN erinnerung -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Erinnerung</div><div class="mut" style="color:#4a4843;">Wir haben Ihnen diese Auftragsbestätigung am {{datum}} schon einmal geschickt und noch keine Bestätigung von Ihnen erhalten. Der Link in der früheren E-Mail gilt nicht mehr.</div></td></tr></table></td></tr>
<!-- ENDE erinnerung -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Auftrag</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{nummer}} vom {{datum}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{titel}}</td></tr><!-- WENN festpreis --><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Gesamtpreis</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{betrag}}</strong> <span class="mut" style="color:#6b6964;">(Festpreis, gemäß § 19 UStG ohne Umsatzsteuer)</span></td></tr><!-- ENDE festpreis --><!-- WENN aufwand --><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Vergütung</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{betrag}}</strong> <span class="mut" style="color:#6b6964;">(nach Aufwand in Schritten von 15 Minuten, gemäß § 19 UStG ohne Umsatzsteuer)</span></td></tr><!-- ENDE aufwand --><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Bestätigung möglich bis</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{gueltig_bis}}</td></tr></table></td></tr>
<!-- WENN kundenmail -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{link}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Auftrag bestätigen &rarr;</a></td></tr></table></td></tr>
<!-- ENDE kundenmail -->
<!-- WENN teamkopie -->
<tr><td class="px mut" style="padding:28px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.</td></tr>
<!-- ENDE teamkopie -->
<!-- WENN kundenmail -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Anhänge</div><div class="mut" style="color:#4a4843;">Die Auftragsbestätigung ({{nummer}}.pdf) und unsere Allgemeinen Geschäftsbedingungen hängen als PDF an dieser E-Mail. Bitte bewahren Sie beide auf. Den Auftrag können Sie auch in Ihrem Kundenportal ansehen und bestätigen.</div></td></tr></table></td></tr>
<!-- ENDE kundenmail -->
<!-- WENN privat -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Ihr Widerrufsrecht</div><div class="mut" style="color:#4a4843;">Sie sind Verbraucher: Ihr Widerrufsrecht und das Muster-Widerrufsformular finden Sie in der angehängten Auftragsbestätigung; widerrufen können Sie auch über das <a href="https://project.webklar.com/widerrufen.html" style="color:#111111;">Widerrufsformular auf unserer Website</a>. Mit der Arbeit beginnen wir erst nach Ablauf der Widerrufsfrist, es sei denn, Sie verlangen beim Bestätigen ausdrücklich einen früheren Beginn.</div></td></tr></table></td></tr>
<!-- ENDE privat -->
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Bei Fragen antworten Sie einfach auf diese E-Mail.<br>Viele Grüße<br><strong>Ihr WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K32 · Auftrag bestätigt (Eingangsbestätigung der Bestätigung; Variante Privatperson mit oder ohne früheren Beginn)
Betreff: Ihre Bestätigung zu {{nummer}} ist eingegangen
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail der Auftragsbestätigung) · BCC intern
Platzhalter: {{name}}, {{nummer}}, {{titel}}, {{zeitpunkt}}, {{fruehester_beginn}}, {{fruehbeginn_text}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): fruehbeginn, ohne_fruehbeginn
zeitpunkt = Zeitpunkt der Bestätigung (TT.MM.JJJJ, HH:MM, Berliner Zeit). fruehester_beginn = frühester Arbeitsbeginn ohne ausdrücklichen Wunsch (TT.MM.JJJJ, Tag nach Ablauf der Widerrufsfrist), nur im Block ohne_fruehbeginn.
fruehbeginn = die Privatperson hat ausdrücklich verlangt, dass die Arbeit schon vor Ablauf der Widerrufsfrist beginnt; fruehbeginn_text = Wortlaut ihrer Erklärung (FRUEHBEGINN_TEXT aus server/services/auftraege/texte.js), nur in diesem Block, ohne weitere Sätze. ohne_fruehbeginn = sie hat das nicht verlangt. Bei Unternehmen ist keiner der beiden Blöcke an.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Ihre Bestätigung zu {{nummer}} ist eingegangen</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Wir haben Ihre Bestätigung zu {{nummer}} erhalten.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Bestätigung</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; Auftrag bestätigt</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Vielen Dank für Ihre Bestätigung</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>wir haben Ihre Bestätigung zu {{nummer}} am {{zeitpunkt}} Uhr erhalten. Bitte bewahren Sie diese E-Mail auf.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Auftrag</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{nummer}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{titel}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Bestätigt am</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{zeitpunkt}} Uhr</td></tr></table></td></tr>
<!-- WENN fruehbeginn -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Ihre Erklärung zum früheren Beginn</div><div class="mut" style="color:#4a4843;">{{fruehbeginn_text}}</div></td></tr></table></td></tr>
<!-- ENDE fruehbeginn -->
<!-- WENN ohne_fruehbeginn -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Beginn der Arbeiten</div><div class="mut" style="color:#4a4843;">Sie haben nicht verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Wir beginnen daher frühestens am {{fruehester_beginn}} mit der Arbeit.</div></td></tr></table></td></tr>
<!-- ENDE ohne_fruehbeginn -->
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Ihr WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -9,7 +9,8 @@
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{email}}, {{name}}, {{produkt}}, {{preis}}, {{kundennummer}}, {{bestellung}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}, {{ereignisse}}, {{pruefhinweis}}
preis = Preis der widerrufenen Funktion aus dem Register (nur im Block funktion, sonst leer).
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abo_gefunden, auf_kuendigung_gesetzt, kein_abo_gefunden, kuendigung_fehlgeschlagen, abschluss, wechsel, zusatzspeicher, funktion, keine_frist, nachweis_fehlt, wechsel_offen, abschluss_verspaetet, nicht_pruefbar, bereits_widerrufen
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abo_gefunden, auf_kuendigung_gesetzt, kein_abo_gefunden, kuendigung_fehlgeschlagen, abschluss, wechsel, zusatzspeicher, funktion, keine_frist, nachweis_fehlt, wechsel_offen, abschluss_verspaetet, nicht_pruefbar, bereits_widerrufen, projekt
projekt = Widerruf eines Projektauftrags (Aufträge und Rechnungen), ohne Abo und ohne Vertragsereignis: ereignisse = Ergebnis der Prüfung der Auftragsbestätigung, fristende = Ende der Widerrufsfrist (TT.MM.JJJJ, 24 Uhr), rechtzeitig = Eingang (RECHTZEITIG, NACH Fristende, ungeprüft …); die Zeile „Abo“ zeigt nur die Vertragsart.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
@@ -64,6 +65,9 @@ a{color:#111111;}
<!-- WENN funktion -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Widerruf einer Funktion ({{produkt}}, {{preis}} einmalig). Widerrufsfrist bis {{fristende}}, Eingang: <strong>{{rechtzeitig}}</strong>. Es wurde NICHTS automatisch geändert: kein Abo, kein Vertragsereignis. Bitte von Hand prüfen, die Zahlung in Stripe innerhalb von 14 Tagen erstatten, gegebenenfalls abzüglich Wertersatz für den begonnenen Einbau (Aufwand im Arbeitsblatt, ⚖ Q5), sofern der Kunde den Beginn vor Fristende ausdrücklich verlangt hat, und die Funktion im Ticketsystem abschalten (Kunde → Reiter „Funktionen“). Beim Terminplaner bleibt das Wartungs-Abo unberührt. Der Kunde hat „wir prüfen“ bekommen. Bezahlte Bestellungen dieser Funktion (je Projekt mit eigenem Fristende):<br>{{ereignisse}}</div></td></tr></table></td></tr>
<!-- ENDE funktion -->
<!-- WENN projekt -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Widerruf eines Projektauftrags. Geprüft wurde nur die Auftragsbestätigung (kein Abo, kein Vertragsereignis); die Zeile „Abo“ oben nennt nur die Vertragsart.<br>Ergebnis: {{ereignisse}}<br>Widerrufsfrist bis: {{fristende}}<br>Eingang: <strong>{{rechtzeitig}}</strong><br>Der Kunde hat „wir prüfen“ bekommen. Erstattung, Wertersatz und das Ende der Arbeit sind Handarbeit (Ticketsystem: Kunde → Aufträge und Rechnungen).</div></td></tr></table></td></tr>
<!-- ENDE projekt -->
<!-- WENN keine_frist -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">Nichts automatisch geändert – keine offene Frist gefunden: zu dieser Bestellung läuft keine Widerrufsfrist (mehr) oder es gibt keine Bestellung dieser Art. Bitte prüfen, bevor der Widerruf abgelehnt wird; der Kunde hat „wir prüfen“ bekommen.</div></td></tr></table></td></tr>
<!-- ENDE keine_frist -->
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
T22 · Auftrag bestätigt (Hinweis ans Team)
Betreff: [Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{kunde}}, {{nummer}}, {{titel}}, {{zeitpunkt}}, {{ueber}}, {{woms_link}}, {{fruehester_beginn}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): privat_fruehbeginn, privat_ohne_fruehbeginn
kunde = Firma, sonst Name des Kunden. ueber = Weg der Bestätigung (Bestätigungslink oder Kundenportal). zeitpunkt = TT.MM.JJJJ, HH:MM, Berliner Zeit. woms_link = Aufträge und Rechnungen des Kunden im Ticketsystem.
privat_fruehbeginn = Privatperson, früherer Beginn ausdrücklich verlangt (die Arbeit darf sofort starten). privat_ohne_fruehbeginn = Privatperson ohne diesen Wunsch (Arbeitsbeginn erst ab fruehester_beginn, TT.MM.JJJJ). Bei Unternehmen ist keiner der beiden Blöcke an.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>[Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">{{kunde}}: {{nummer}} bestätigt.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Intern · Kundenportal</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">Aufträge</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Auftrag bestätigt</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Kunde</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{kunde}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Auftrag</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{nummer}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{titel}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Bestätigt am</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeitpunkt}} Uhr</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Weg</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{ueber}}</td></tr></table></td></tr>
<!-- WENN privat_fruehbeginn -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Privatperson: früher Beginn verlangt</div><div class="mut" style="color:#4a4843;">Der Kunde hat ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Die Arbeit darf sofort starten. Bei einem Widerruf ist Wertersatz zu prüfen (§ 357a Abs. 2 BGB).</div></td></tr></table></td></tr>
<!-- ENDE privat_fruehbeginn -->
<!-- WENN privat_ohne_fruehbeginn -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Privatperson: kein früher Beginn</div><div class="mut" style="color:#4a4843;">Der Kunde hat keinen früheren Beginn verlangt. Arbeitsbeginn frühestens am {{fruehester_beginn}}, dem Tag nach Ablauf der Widerrufsfrist.</div></td></tr></table></td></tr>
<!-- ENDE privat_ohne_fruehbeginn -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{woms_link}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Im Ticketsystem öffnen &rarr;</a></td></tr></table></td></tr>
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+61 -13
View File
@@ -5,6 +5,8 @@ import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { getCustomerByEmail, getDocument, updateDocument } from '../services/appwriteAdmin.js'
import { eventIdFor, listContractEvents, openWithdrawals, recordContractEvent, updateContractEvent } from '../services/contractEvents.js'
import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js'
import { findAuftragByNummer } from '../services/auftraege/auftragStore.js'
import { widerrufeAuftrag } from '../services/auftraege/bestaetigung.js'
import { getLedger } from '../services/hourLedger.js'
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
import { changeStorage, previewStorageChange, recordPaidStorage, storageTaskKind } from '../services/mailStorage.js'
@@ -727,7 +729,8 @@ export function publicRateLimitState() {
// Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert
const CONTRACTS = ['abo', 'zusatzspeicher']
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'unterkunft', 'zeiten']
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'unterkunft', 'zeiten', 'projekt']
const AB_NUMMER = /^AB-\d{4}-\d{4}$/
// Bestellung einer Funktion -> Funktionsschluessel im Register ('funktion' meint die News-Pinnwand; jede Funktion hat ihre eigene Frist)
const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine', shop: 'shop', unterkunft: 'unterkunft', zeiten: 'zeiten' }
// Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals)
@@ -735,7 +738,7 @@ const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspe
/**
* Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl.
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop' | 'unterkunft' | 'zeiten'; packages (nur beim
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop' | 'unterkunft' | 'zeiten' | 'projekt'; packages (nur beim
* Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026).
*/
function validDeclaration(body) {
@@ -764,6 +767,7 @@ function validDeclaration(body) {
contract,
order,
packages,
abNummer: String(body?.abNummer || '').trim().toUpperCase().slice(0, 20),
}
}
@@ -804,7 +808,8 @@ function contractLabel(contract, sub) {
}
/** Beschriftung der widerrufenen Bestellung fuer K04 (an den Kunden) und T09 (intern). */
function orderLabel(order, sub, { customer = false } = {}) {
function orderLabel(order, sub, { customer = false, abNummer = '' } = {}) {
if (order === 'projekt') return AB_NUMMER.test(abNummer) ? `Projektauftrag (Auftragsbestätigung ${abNummer})` : 'Projektauftrag (Auftragsbestätigung)'
if (order === 'wechsel') return 'Wechsel ins Wartungs-Abo'
if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher'
if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)'
@@ -1148,6 +1153,37 @@ async function withdrawFeature(d, receivedAt, featureKey) {
return { customer, orders, deadline, lines, inTime: Boolean(deadline) && receivedAt <= deadline }
}
/** Projektauftrag (Aufträge und Rechnungen, Spec 2026-10-06 Abschnitt 2): Nummer und E-Mail-Adresse müssen zur gesendeten AB passen. Ohne gültige Nummer wird nichts nachgeschlagen. Wirft bei Datenbankfehlern. */
async function withdrawProjekt(d, receivedAt) {
const auftrag = AB_NUMMER.test(d.abNummer) ? await findAuftragByNummer(d.abNummer) : null
const adressen = [auftrag?.gesendetAn, auftrag?.kunde?.email].map((adresse) => String(adresse || '').trim().toLowerCase())
if (!auftrag || !adressen.includes(d.email.toLowerCase())) return { auftrag: null, ergebnis: 'ungeprueft', fristEnde: null }
return widerrufeAuftrag(auftrag, { jetzt: receivedAt })
}
const PROJEKT_EINGANG = { widerrufen: 'RECHTZEITIG', nach_frist: 'NACH Fristende, vor einer Ablehnung bitte prüfen', bereits_widerrufen: 'bereits widerrufen' }
/** Werte für die Zeilen „Ergebnis“, „Widerrufsfrist bis“ und „Eingang“ im Block projekt von T09 (nur das Team sieht sie). */
function projektWerte({ ergebnis, auftrag, fristEnde }, abNummer) {
const ergebnisText = {
widerrufen: () => `Auftragsbestätigung auf „widerrufen“ gesetzt${auftrag.fruehbeginn ? '; Frühbeginn war verlangt: Wertersatz prüfen (§ 357a Abs. 2 BGB)' : ''}`,
bereits_widerrufen: () => 'war schon widerrufen, nichts geändert',
kein_widerrufsrecht: () => 'Unternehmen: kein gesetzliches Widerrufsrecht, nichts geändert',
nach_frist: () => 'nichts geändert',
nicht_widerrufbar: () => `Status „${auftrag.status}“, nichts geändert, bitte prüfen`,
fehler: () => 'ungeprüft: Prüfung fehlgeschlagen (Datenbank), bitte von Hand prüfen',
}[ergebnis]
return {
ereignisse: ergebnisText
? ergebnisText()
: AB_NUMMER.test(abNummer)
? `ungeprüft: keine Auftragsbestätigung zu Nummer „${abNummer}“ und dieser E-Mail-Adresse gefunden, nichts geändert`
: 'ungeprüft: keine gültige AB-Nummer angegeben, nichts geändert, bitte von Hand zuordnen',
fristende: ergebnis === 'kein_widerrufsrecht' ? 'entfällt (Unternehmen)' : fristEnde ? `${fmtDate(fristEnde)}, 24 Uhr` : 'keine Frist gefunden',
rechtzeitig: PROJEKT_EINGANG[ergebnis] ?? 'ungeprüft',
}
}
/** Zeile je widerrufenem Ereignis fuer T09: ID, Inhalt (Speicher: Menge vorher -> nachher), Bestelldatum. */
function describeWithdrawn(events) {
return events
@@ -1169,10 +1205,14 @@ router.post('/public/withdraw', async (req, res) => {
// Funktion (News, Terminplaner oder Webshop): kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature)
const featureKey = FEATURE_OF_ORDER[d.order] ?? null
const feature = featureKey !== null
const projekt = d.order === 'projekt'
let pr = { auftrag: null, ergebnis: 'ungeprueft', fristEnde: null }
const funktion = feature ? featureConfig(featureKey) : null
let fn = { customer: null, orders: [], deadline: null, lines: '', inTime: false }
try {
if (feature) {
if (projekt) {
pr = await withdrawProjekt(d, receivedAt)
} else if (feature) {
fn = await withdrawFeature(d, receivedAt, featureKey)
result = { ...result, deadline: fn.deadline, inTime: fn.inTime, late: !fn.inTime && Boolean(fn.deadline) }
} else {
@@ -1184,8 +1224,11 @@ router.post('/public/withdraw', async (req, res) => {
}
} catch (err) {
console.error('[abo] Widerruf nicht verarbeitbar:', err.message)
if (projekt) pr = { auftrag: null, ergebnis: 'fehler', fristEnde: null }
}
const { inTime } = result
const projektAngaben = projekt ? projektWerte(pr, d.abNummer) : null
const produktText = projekt ? 'Projektauftrag' : feature ? funktion.label.name : productLabelOf(sub)
// Fuer T09: warum sich die Frist nicht pruefen liess
const pruefhinweis = result.unreadable ? 'Ereignisse nicht lesbar – bitte von Hand prüfen' : result.startMissing ? 'Abo-Beginn fehlt' : ''
@@ -1193,8 +1236,8 @@ router.post('/public/withdraw', async (req, res) => {
template: 'K04',
values: {
name: d.name,
bestellung: orderLabel(d.order, sub, { customer: true }),
produkt: feature ? funktion.label.name : productLabelOf(sub),
bestellung: orderLabel(d.order, sub, { customer: true, abNummer: d.abNummer }),
produkt: produktText,
eingang_zeitpunkt: fmtDateTime(receivedAt),
email: d.email,
kundennummer: d.customerNumber,
@@ -1205,7 +1248,9 @@ router.post('/public/withdraw', async (req, res) => {
wechsel: inTime && d.order === 'wechsel',
zusatzspeicher: inTime && d.order === 'zusatzspeicher',
funktion: feature,
pruefen: !inTime && !feature,
pruefen: !inTime && !feature && !projekt,
projekt,
projekt_unternehmen: projekt && pr.ergebnis === 'kein_widerrufsrecht',
},
}).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message))
@@ -1216,13 +1261,15 @@ router.post('/public/withdraw', async (req, res) => {
email: d.email,
name: d.name,
kundennummer: d.customerNumber,
bestellung: orderLabel(d.order, sub),
produkt: feature ? funktion.label.name : productLabelOf(sub),
bestellung: orderLabel(d.order, sub, { abNummer: d.abNummer }),
produkt: produktText,
preis: feature ? funktion.priceLabel : '',
stripe_abo_id: sub?.stripeSubscriptionId || '',
abo_beginn: fmtDate(sub?.startedAt),
fristende: result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden',
rechtzeitig: feature
fristende: projekt ? projektAngaben.fristende : result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden',
rechtzeitig: projekt
? projektAngaben.rechtzeitig
: feature
? !fn.customer
? 'ungeprüft (kein Kunde zu dieser E-Mail-Adresse gefunden)'
: !fn.orders.length
@@ -1243,14 +1290,14 @@ router.post('/public/withdraw', async (req, res) => {
: result.deadline
? 'ungeprüft (keine offene Frist: Bestellung gescheitert)'
: 'ungeprüft (keine Bestellung dieser Art gefunden)',
ereignisse: feature ? fn.lines : describeWithdrawn(result.withdrawn),
ereignisse: projekt ? projektAngaben.ereignisse : feature ? fn.lines : describeWithdrawn(result.withdrawn),
pruefhinweis,
},
blocks: {
kundennummer: Boolean(d.customerNumber),
abo_gefunden: Boolean(sub),
auf_kuendigung_gesetzt: result.canceled,
kein_abo_gefunden: !sub && !feature,
kein_abo_gefunden: !sub && !feature && !projekt,
kuendigung_fehlgeschlagen: result.cancelFailed,
abschluss: inTime && d.order === 'abschluss',
wechsel: inTime && d.order === 'wechsel',
@@ -1262,6 +1309,7 @@ router.post('/public/withdraw', async (req, res) => {
abschluss_verspaetet: Boolean(sub) && d.order === 'abschluss' && result.late && !result.alreadyWithdrawn,
nicht_pruefbar: Boolean(sub) && Boolean(pruefhinweis),
bereits_widerrufen: Boolean(sub) && result.alreadyWithdrawn,
projekt,
},
})
+145
View File
@@ -0,0 +1,145 @@
/**
* Auftragsbestätigungen (Admin), Mount: /api/admin/finanzen/auftraege (vor dem Finanzen-Router, Übersicht V5 „L3“).
* GET / (?kunde=), POST /, GET|PATCH|DELETE /:id, GET /:id/pdf, POST /:id/projekt, /:id/senden, /:id/erinnern,
* /:id/neue-fassung, /:id/stornieren. Nur dünne HTTP-Schicht: Kunde laden, Schalter prüfen, Dienst aufrufen, Fehler über
* sendeFehler. Ist der Schalter für den Kunden aus, antworten alle Routen 404 mit SCHALTER_AUS_MELDUNG.
*/
import { Router } from 'express'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { auftraegeAktiv } from '../../services/auftraege/schalter.js'
import { SCHALTER_AUS_MELDUNG } from '../../services/auftraege/konstanten.js'
import { AuftragError, sendeFehler } from '../../services/auftraege/fehler.js'
import { auftragFuerTeam } from '../../services/auftraege/auftragSicht.js'
import { listAuftraege } from '../../services/auftraege/auftragStore.js'
import {
aendereEntwurf, erinnereAuftrag, ladeAuftrag, ladeGespeichertesPdf, ladeKunde, legeEntwurfAn, loescheEntwurf, neueFassung,
ordneProjektZu, sendeAuftrag, storniereAuftrag, vorschauPdf,
} from '../../services/auftraege/auftraege.js'
const router = Router()
router.use(requireAppwriteStaff)
async function ladeKundeMitSchalter(kundenId) {
const kunde = await ladeKunde(kundenId)
if (!auftraegeAktiv(kunde)) throw new AuftragError(404, SCHALTER_AUS_MELDUNG)
return kunde
}
async function ladeMitSchalter(id) {
const auftrag = await ladeAuftrag(id)
await ladeKundeMitSchalter(auftrag.kundenId)
return auftrag
}
const vonAdmin = (req) => req.appwriteUser?.email
router.get('/', async (req, res) => {
try {
const kundenId = req.query.kunde
if (typeof kundenId !== 'string' || kundenId === '') return res.status(400).json({ error: 'Kunde fehlt.' })
await ladeKundeMitSchalter(kundenId)
return res.json({ auftraege: (await listAuftraege({ kundenId })).map(auftragFuerTeam) })
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigungen laden', err)
}
})
router.post('/', async (req, res) => {
try {
const kundenId = req.body?.kundenId
if (typeof kundenId === 'string' && kundenId !== '') await ladeKundeMitSchalter(kundenId)
return res.json({ auftrag: auftragFuerTeam(await legeEntwurfAn(req.body, { von: vonAdmin(req) })) })
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigung anlegen', err)
}
})
router.get('/:id', async (req, res) => {
try {
return res.json({ auftrag: auftragFuerTeam(await ladeMitSchalter(req.params.id)) })
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigung laden', err)
}
})
router.patch('/:id', async (req, res) => {
try {
await ladeMitSchalter(req.params.id)
return res.json({ auftrag: auftragFuerTeam(await aendereEntwurf(req.params.id, req.body ?? {})) })
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigung ändern', err)
}
})
router.delete('/:id', async (req, res) => {
try {
await ladeMitSchalter(req.params.id)
await loescheEntwurf(req.params.id)
return res.json({ ok: true })
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigung löschen', err)
}
})
router.get('/:id/pdf', async (req, res) => {
try {
const auftrag = await ladeMitSchalter(req.params.id)
const entwurf = auftrag.status === 'entwurf'
const buffer = entwurf ? await vorschauPdf(auftrag.id) : await ladeGespeichertesPdf(auftrag)
const name = entwurf ? 'Auftragsbestaetigung-Entwurf.pdf' : /^AB-\d{4}-\d{4}$/.test(auftrag.nummer) ? `${auftrag.nummer}.pdf` : 'Auftragsbestaetigung.pdf'
res.set({ 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="${name}"`, 'Cache-Control': 'no-store' })
return res.send(buffer)
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigung-PDF laden', err)
}
})
/** Antwort der Aktionen, die eine Mail auslösen: { auftrag, mailFehler }. */
const mitMail = ({ auftrag, mailFehler }) => ({ auftrag: auftragFuerTeam(auftrag), mailFehler })
router.post('/:id/senden', async (req, res) => {
try {
await ladeMitSchalter(req.params.id)
return res.json(mitMail(await sendeAuftrag(req.params.id, { von: vonAdmin(req) })))
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigung senden', err)
}
})
router.post('/:id/erinnern', async (req, res) => {
try {
await ladeMitSchalter(req.params.id)
return res.json(mitMail(await erinnereAuftrag(req.params.id)))
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigung erinnern', err)
}
})
router.post('/:id/neue-fassung', async (req, res) => {
try {
await ladeMitSchalter(req.params.id)
return res.json({ auftrag: auftragFuerTeam(await neueFassung(req.params.id, { von: vonAdmin(req) })) })
} catch (err) {
return sendeFehler(res, 'Neue Fassung anlegen', err)
}
})
router.post('/:id/stornieren', async (req, res) => {
try {
await ladeMitSchalter(req.params.id)
return res.json({ auftrag: auftragFuerTeam(await storniereAuftrag(req.params.id)) })
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigung stornieren', err)
}
})
router.post('/:id/projekt', async (req, res) => {
try {
await ladeMitSchalter(req.params.id)
return res.json({ auftrag: auftragFuerTeam(await ordneProjektZu(req.params.id, req.body?.projektId)) })
} catch (err) {
return sendeFehler(res, 'Projekt zuordnen', err)
}
})
export default router
+28 -5
View File
@@ -1,6 +1,7 @@
import { Router } from 'express'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { withLocks } from '../../services/keyedLock.js'
import { AuftragError } from '../../services/auftraege/fehler.js'
import { EnableBankingError } from '../../services/finance/enableBanking.js'
import { completeBankAuth, startBankAuth, syncBank } from '../../services/finance/bankSync.js'
import { getConnection, listTransactions, publicConnection, saveConnection } from '../../services/finance/bankStore.js'
@@ -21,13 +22,14 @@ import {
createProjectInvoice,
deleteDraft,
listInvoices,
rechnungsVorschau,
sendInvoiceNow,
voidInvoice,
} from '../../services/finance/stripeFinance.js'
/**
* Finanzen im WOMS (nur Admins): Übersicht, Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung,
* Rechnungen (lesen, anlegen, senden, stornieren, Entwurf löschen), Einstellungen und Fixkosten. Die Handler sind klein
* Rechnungen (lesen, anlegen, Vorschau, senden, stornieren, Entwurf löschen), Einstellungen und Fixkosten. Die Handler sind klein
* und rufen die Dienste in server/services/finance auf.
*/
const router = Router()
@@ -148,7 +150,7 @@ const isStripeError = (err) => typeof err?.type === 'string' && err.type.startsW
* Fehler einen status tragen: nur die beiden eigenen Fehlerarten werden weitergegeben.
*/
function sendFinanceError(res, what, err) {
if (err instanceof AssignError || err instanceof InvoiceError) {
if (err instanceof AssignError || err instanceof InvoiceError || err instanceof AuftragError) {
// Ein 502 an einer bestehenden Rechnung (nicht gesendet, nicht lesbar) hat seine Ursache bei Stripe: nur Art, Status und Code ins Protokoll
if (err.status === 502 && err.cause) logFailure(what, err.cause)
const body = { error: err.message }
@@ -186,7 +188,6 @@ router.delete('/buchungen/:id/zuordnung', async (req, res) => {
// --- Rechnungen (Stripe) ----------------------------------------------------------------------------------------
const INVOICE_FILTERS = new Set(['alle', 'offen', 'ueberfaellig', 'bezahlt', 'entwurf'])
const DEFAULT_DAYS_UNTIL_DUE = 14
const createdOf = (invoice) => invoice.createdDate ?? ''
router.get('/rechnungen', async (req, res) => {
@@ -208,12 +209,24 @@ router.get('/rechnungen', async (req, res) => {
})
router.post('/rechnungen', async (req, res) => {
const { customerId, items, daysUntilDue, memo, senden, idempotencyKey } = req.body || {}
const {
customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum,
daysUntilDue, memo, senden, idempotencyKey,
} = req.body || {}
try {
// Die Typen der Felder prueft baueRechnung (400); die Route reicht sie unveraendert durch
const rechnung = await createProjectInvoice({
customerId,
art,
projektId,
auftragId,
items,
daysUntilDue: daysUntilDue ?? DEFAULT_DAYS_UNTIL_DUE,
arbeitsblattIds,
anzahlung,
zeilentexte,
leistungszeitraum,
// Ohne Angabe entscheidet createProjectInvoice (AB-Zahlungsziel bei Anzahlung und Schluss, sonst 14 Tage)
daysUntilDue: daysUntilDue ?? undefined,
memo,
// Nur ein echtes Ja sendet sofort: alles andere bleibt ein Entwurf
senden: senden === true || senden === 'true',
@@ -227,6 +240,16 @@ router.post('/rechnungen', async (req, res) => {
}
})
// Vorschau: dieselben Pruefungen wie das Anlegen, aber ohne Schreibzugriff
router.post('/rechnungen/vorschau', async (req, res) => {
const { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum } = req.body || {}
try {
return res.json(await rechnungsVorschau({ customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum }))
} catch (err) {
return sendFinanceError(res, 'Rechnungsvorschau', err)
}
})
router.post('/rechnungen/:id/senden', async (req, res) => {
try {
return res.json({ rechnung: await sendInvoiceNow(req.params.id) })
+38
View File
@@ -0,0 +1,38 @@
/**
* Projektkonten (Admin). Mount: /api/admin/finanzen/projektkonten (vor dem Finanzen-Router).
* GET /?kunde=<id> Projektkonten eines Kunden samt Zeile "Ohne Projekt"
* GET /:projektId Konto, ABs, Rechnungen und offene Arbeitsblaetter eines Projekts
* Nur lesen (Strang L1).
*/
import { Router } from 'express'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { sendeFehler } from '../../services/auftraege/fehler.js'
import { projektkontenFuerKunde, projektkontoDetail } from '../../services/auftraege/projektkonto.js'
const router = Router()
router.use(requireAppwriteStaff)
// Appwrite-UID: erst pruefen, dann an Appwrite geben
const KENNUNG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
router.get('/', async (req, res) => {
const kunde = req.query.kunde
if (typeof kunde !== 'string' || !KENNUNG.test(kunde)) return res.status(400).json({ error: 'Kunde fehlt oder ist ungültig.' })
try {
res.json(await projektkontenFuerKunde(kunde))
} catch (err) {
sendeFehler(res, 'Projektkonten laden', err)
}
})
router.get('/:projektId', async (req, res) => {
const { projektId } = req.params
if (!KENNUNG.test(projektId)) return res.status(404).json({ error: 'Projekt nicht gefunden.' })
try {
res.json(await projektkontoDetail(projektId))
} catch (err) {
sendeFehler(res, 'Projektkonto laden', err)
}
})
export default router
+89
View File
@@ -0,0 +1,89 @@
/**
* Aufträge des angemeldeten Kunden (Übersicht V3/V5, Strang L5). Mount: /api/auftraege. requireSession schützt jede
* Route (fail-closed). Ein Kunde sieht und bestätigt nur eigene, gesendete Auftragsbestätigungen (keine Entwürfe);
* in der Kundenansicht ist Bestätigen nie angeboten, und der Wächter der Kundenansicht sperrt den POST von selbst
* (es gibt keinen Eintrag in KUNDENANSICHT_ALLOWED).
*/
import { Router } from 'express'
import { config } from '../config.js'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { isKundenansicht } from '../services/kundenansicht.js'
import { getAuftrag, listAuftraege } from '../services/auftraege/auftragStore.js'
import { oeffentlicheAnsicht } from '../services/auftraege/auftragSicht.js'
import { bestaetigeAuftrag, GESPERRT_MELDUNG } from '../services/auftraege/bestaetigung.js'
import { AuftragError, sendeFehler } from '../services/auftraege/fehler.js'
import { auftraegeAktiv } from '../services/auftraege/schalter.js'
import { sendePdf } from './auftrag.js'
const NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.'
const HAEKCHEN = 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.'
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
const router = Router()
router.use(requireSession)
router.use((_req, res, next) => {
res.set('Cache-Control', 'no-store')
next()
})
const ansicht = (a, req) => ({
...oeffentlicheAnsicht(a),
id: a.id,
kannBestaetigen: !isKundenansicht(req.session) && (a.status === 'versendet' || (a.status === 'abgerechnet' && !a.bestaetigtAm)),
})
/** Eigene, gesendete AB laden; sonst ist 404 schon gesendet und das Ergebnis `null`. */
async function ladeEigene(req, res) {
const nichtGefunden = () => {
res.status(404).json({ error: NICHT_GEFUNDEN })
return null
}
if (!ID_RE.test(String(req.params.id))) return nichtGefunden()
const kundenId = getSessionCustomerId(req)
const kunde = await getDocument(config.collections.customers, kundenId)
if (!auftraegeAktiv(kunde)) return nichtGefunden()
const a = await getAuftrag(req.params.id)
if (!a || a.kundenId !== kundenId || a.status === 'entwurf') return nichtGefunden()
return a
}
router.get('/', async (req, res) => {
try {
const kundenId = getSessionCustomerId(req)
const kunde = await getDocument(config.collections.customers, kundenId)
if (!auftraegeAktiv(kunde)) return res.json({ aktiv: false, auftraege: [] })
const liste = await listAuftraege({ kundenId })
res.json({ aktiv: true, auftraege: liste.filter((a) => a.status !== 'entwurf').map((a) => ansicht(a, req)) })
} catch (err) {
sendeFehler(res, 'auftraege', err)
}
})
router.get('/:id/pdf', async (req, res) => {
try {
const a = await ladeEigene(req, res)
if (a) await sendePdf(res, a)
} catch (err) {
sendeFehler(res, 'auftraege', err)
}
})
router.post('/:id/bestaetigen', async (req, res) => {
try {
const a = await ladeEigene(req, res)
if (!a) return
if (GESPERRT_MELDUNG[a.status]) return res.status(410).json({ error: GESPERRT_MELDUNG[a.status] })
const body = req.body && typeof req.body === 'object' ? req.body : {}
if (body.bestaetigt !== true) return res.status(400).json({ error: HAEKCHEN })
const neu = await bestaetigeAuftrag(a, { ueber: 'portal', fruehbeginn: body.fruehbeginn === true })
res.json({ auftrag: ansicht(neu, req) })
} catch (err) {
if (err instanceof AuftragError && err.status === 409 && Object.values(GESPERRT_MELDUNG).includes(err.message)) {
return res.status(410).json({ error: err.message })
}
sendeFehler(res, 'auftraege', err)
}
})
export default router
+118
View File
@@ -0,0 +1,118 @@
/**
* Öffentliche Link-Routen einer Auftragsbestätigung (Übersicht V5, Strang L5): ansehen, PDF, bestätigen – ohne
* Anmeldung, das Token steht im Link (Fragment `#t=`, die Seite schickt es hierher). Mount: /api/auftrag.
* Ratenbegrenzung nur im Arbeitsspeicher (GET 120/h, POST 10/h je IP); nichts von der Anfrage wird gespeichert oder
* protokolliert. Ungültige, unbekannte, abgelaufene Links und abgeschalteter Schalter antworten gleich (404).
*/
import { Router } from 'express'
import { createWriteLimiter } from '../services/writeLimiter.js'
import { findAuftragByTokenHash } from '../services/auftraege/auftragStore.js'
import { lesePdf } from '../services/auftraege/ablage.js'
import { oeffentlicheAnsicht } from '../services/auftraege/auftragSicht.js'
import { bestaetigeAuftrag, GESPERRT_MELDUNG, UNGUELTIGER_LINK } from '../services/auftraege/bestaetigung.js'
import { AuftragError, sendeFehler } from '../services/auftraege/fehler.js'
import { auftraegeAktiv } from '../services/auftraege/schalter.js'
import { hashToken } from '../services/auftraege/token.js'
const UNGUELTIG = UNGUELTIGER_LINK
const HAEKCHEN = 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.'
const ZU_VIELE = 'Zu viele Anfragen. Bitte später erneut versuchen.'
const echteUhr = () => new Date()
let uhr = echteUhr
const jetztMs = () => uhr().getTime()
const getLimiter = createWriteLimiter({ max: 120, now: jetztMs })
const postLimiter = createWriteLimiter({ max: 10, now: jetztMs })
const nachIp = (req) => String(req.ip)
/** Nur für Tests: feste Uhr der Routen (`null` = echte Uhr). */
export function setzeUhrFuerTests(fn) {
uhr = typeof fn === 'function' ? fn : echteUhr
}
/** Nur für Tests: Zähler leeren und echte Uhr. */
export function resetAuftragRoutesForTests() {
getLimiter.reset()
postLimiter.reset()
uhr = echteUhr
}
const router = Router()
router.use((_req, res, next) => {
res.set({
'Cache-Control': 'no-store',
'X-Robots-Tag': 'noindex',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
})
next()
})
/** Gemeinsamer Vorlauf der Link-Routen; bei `null` ist die Antwort (404/410) schon gesendet. */
async function ladeNachToken(req, res) {
const nichtGueltig = () => {
res.status(404).json({ error: UNGUELTIG })
return null
}
const hash = hashToken(req.params.token)
if (hash === null) return nichtGueltig()
const a = await findAuftragByTokenHash(hash)
if (!a) return nichtGueltig()
if (!auftraegeAktiv({ email: a.kunde?.email || a.gesendetAn })) return nichtGueltig()
if (a.status === 'entwurf') return nichtGueltig()
if (GESPERRT_MELDUNG[a.status]) {
res.status(410).json({ error: GESPERRT_MELDUNG[a.status] })
return null
}
const bis = a.tokenGueltigBis ? Date.parse(a.tokenGueltigBis) : Number.NaN
if (!Number.isFinite(bis) || jetztMs() > bis) return nichtGueltig()
return a
}
/** AB-PDF aus der Ablage senden (inline); fehlt die Datei, 404 `PDF nicht gefunden.`. */
export async function sendePdf(res, auftrag) {
let buffer
try {
buffer = await lesePdf(auftrag.pdfDatei)
} catch (err) {
if (err instanceof AuftragError) throw err
console.error(`[auftraege] PDF zu ${auftrag.nummer} nicht lesbar: ${err.code || err.message}`)
throw new AuftragError(404, 'PDF nicht gefunden.')
}
const nummer = String(auftrag.nummer || 'Auftragsbestaetigung').replace(/[^A-Za-z0-9-]/g, '') || 'Auftragsbestaetigung'
res.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="${nummer}.pdf"` })
res.end(buffer)
}
router.get('/:token', getLimiter.middleware(nachIp, ZU_VIELE), async (req, res) => {
try {
const a = await ladeNachToken(req, res)
if (a) res.json({ auftrag: oeffentlicheAnsicht(a) })
} catch (err) {
sendeFehler(res, 'auftrag', err)
}
})
router.get('/:token/pdf', getLimiter.middleware(nachIp, ZU_VIELE), async (req, res) => {
try {
const a = await ladeNachToken(req, res)
if (a) await sendePdf(res, a)
} catch (err) {
sendeFehler(res, 'auftrag', err)
}
})
router.post('/:token/bestaetigen', postLimiter.middleware(nachIp, ZU_VIELE), async (req, res) => {
try {
const a = await ladeNachToken(req, res)
if (!a) return
const body = req.body && typeof req.body === 'object' ? req.body : {}
if (body.bestaetigt !== true) return res.status(400).json({ error: HAEKCHEN })
const neu = await bestaetigeAuftrag(a, { ueber: 'link', fruehbeginn: body.fruehbeginn === true, jetzt: uhr() })
res.json({ auftrag: oeffentlicheAnsicht(neu) })
} catch (err) {
sendeFehler(res, 'auftrag', err)
}
})
export default router
+25
View File
@@ -0,0 +1,25 @@
/**
* Arbeitsblaetter fuers Ticketsystem. Mount: /api/team/arbeitsblaetter, in team/index.js hinter requireStaffJwt (jeder
* Mitarbeiter, daher kein eigener Schutz).
* GET /abgerechnet?ticketId=<id> { abgerechnet: { <Blatt-ID>: '<Rechnungsnummer>' | 'Entwurf' } }, ohne Betraege
*/
import { Router } from 'express'
import { sendeFehler } from '../../services/auftraege/fehler.js'
import { abgerechnetFuerTicket } from '../../services/auftraege/projektkonto.js'
const router = Router()
// Appwrite-UID: erst pruefen, dann an Appwrite geben
const KENNUNG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
router.get('/abgerechnet', async (req, res) => {
const ticketId = req.query.ticketId
if (typeof ticketId !== 'string' || !KENNUNG.test(ticketId)) return res.status(400).json({ error: 'ticketId fehlt oder ist ungültig.' })
try {
res.json({ abgerechnet: await abgerechnetFuerTicket(ticketId) })
} catch (err) {
sendeFehler(res, 'Abgerechnete Arbeitsblätter laden', err)
}
})
export default router
+3
View File
@@ -5,6 +5,7 @@ import { funktionenAdminRouter } from '../admin/funktionenAdmin.js'
import { offersAdminRouter } from '../offers.js'
import { storageTasksAdminRouter } from '../storageTasks.js'
import { takeoverAdminRouter } from '../takeoverCredits.js'
import arbeitsblaetterTeamRoutes from './arbeitsblaetter.js'
import customersRoutes from './customers.js'
import filesRoutes from './files.js'
import previewRoutes from './preview.js'
@@ -31,5 +32,7 @@ router.use('/termine', termineTeamRoutes)
router.use('/shop', shopTeamRoutes)
// Buchungskalender: Stand je Projekt und Entscheidung ueber die Anfrage
router.use('/unterkunft', unterkunftTeamRoutes)
// Abgerechnete Arbeitsblaetter (Abzeichen am Ticket, alle Mitarbeiter)
router.use('/arbeitsblaetter', arbeitsblaetterTeamRoutes)
export default router
+59
View File
@@ -0,0 +1,59 @@
/**
* Ablage der gesendeten AB-PDFs unter config.auftraege.uploadDir (wird bei jedem Aufruf gelesen):
* <uploadDir>/<kundenId>/<auftragId>.pdf, Verzeichnis 0700, Datei 0600. Überschreibt nie (die gesendete Fassung ist
* der Beleg). Kennungen und Pfade sind auf [A-Za-z0-9_-] begrenzt (kein Punkt außer vor „pdf“), damit kein „..“ und
* kein fremder Pfad möglich ist.
*/
import { createHash } from 'node:crypto'
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { config } from '../../config.js'
import { AuftragError } from './fehler.js'
const KENNUNG = /^[A-Za-z0-9_-]{1,64}$/
const PDF_PFAD = /^[A-Za-z0-9_-]{1,64}\/[A-Za-z0-9_-]{1,64}\.pdf$/
const nichtGefunden = () => new AuftragError(404, 'PDF nicht gefunden.')
const gueltigeKennung = (wert) => typeof wert === 'string' && KENNUNG.test(wert)
function pfadVon(pdfDatei) {
if (typeof pdfDatei !== 'string' || !PDF_PFAD.test(pdfDatei)) throw nichtGefunden()
return path.join(config.auftraege.uploadDir, pdfDatei)
}
export async function speicherePdf(kundenId, auftragId, buffer) {
if (!gueltigeKennung(kundenId) || !gueltigeKennung(auftragId)) throw new TypeError('Ungültige Kunden- oder Auftrags-ID für die PDF-Ablage.')
if (!Buffer.isBuffer(buffer) || buffer.length === 0) throw new TypeError('Das PDF muss ein nicht leerer Buffer sein.')
const pdfDatei = `${kundenId}/${auftragId}.pdf`
const datei = path.join(config.auftraege.uploadDir, pdfDatei)
await mkdir(path.dirname(datei), { recursive: true, mode: 0o700 })
try {
await writeFile(datei, buffer, { flag: 'wx', mode: 0o600 })
} catch (err) {
if (err?.code === 'EEXIST') throw new AuftragError(409, 'Das PDF dieser Auftragsbestätigung ist schon gespeichert.')
// eine angelegte Teildatei nicht liegen lassen
await unlink(datei).catch(() => {})
throw err
}
return { pdfDatei, pdfSha256: createHash('sha256').update(buffer).digest('hex') }
}
export async function lesePdf(pdfDatei) {
const datei = pfadVon(pdfDatei)
try {
return await readFile(datei)
} catch (err) {
if (err?.code === 'ENOENT') throw nichtGefunden()
throw err
}
}
/** Räumt ein abgelegtes PDF weg, wenn danach etwas scheitert (L3). Fehlt die Datei, ist das folgenlos. */
export async function entfernePdf(pdfDatei) {
const datei = pfadVon(pdfDatei)
try {
await unlink(datei)
} catch (err) {
if (err?.code !== 'ENOENT') throw err
}
}
@@ -0,0 +1,108 @@
/**
* Arbeitsblätter (Collection worksheets, gehört dem Ticketsystem) für die Projektabrechnung: laden, normieren
* (Berliner Tag, auf 15 Minuten aufgerundete Minuten, kundentauglicher Text) und als abgerechnet erkennen
* (Übersicht V3/V4). „Abgerechnet“ gibt es nur über aktive Projektrechnungen: ein stornierter Eintrag gibt seine
* Blätter frei, ein Entwurf sperrt sie. Abo-Zeit und Kommentare sind nie abrechenbar.
*/
import { config } from '../../config.js'
import { listAllDocuments, Query } from '../appwriteAdmin.js'
import { customerWorkEntries } from '../customerProjectView.js'
import { berlinDate } from '../finance/dates.js'
import { worksheetMinutes } from '../worksheetMinutes.js'
import { VEREINBARTE_STATUS, VIERTELSTUNDE } from './konstanten.js'
import { listProjektRechnungen } from './projektRechnungenStore.js'
const BLOCK = 100 // Werte je equal-Abfrage (Appwrite begrenzt die Werteliste)
const MAX_TEXT = 200
const DE_DATUM = /^(\d{2})\.(\d{2})\.(\d{4})$/
/** Berliner Kalendertag eines Arbeitsblatts; nie ein Wurf. Regel wie parseSheetDate in hourLedger.js. */
function blattDatum(doc) {
const roh = String(doc.startDate || doc.endDate || '').trim()
const de = DE_DATUM.exec(roh)
if (de) {
const [tag, monat, jahr] = [Number(de[1]), Number(de[2]), Number(de[3])]
const pruefung = new Date(Date.UTC(jahr, monat - 1, tag))
if (pruefung.getUTCFullYear() === jahr && pruefung.getUTCMonth() === monat - 1 && pruefung.getUTCDate() === tag) return `${de[3]}-${de[2]}-${de[1]}`
// ein unmögliches Datum (31.02.) ist nie ein Kandidat für new Date: V8 läse „12.13.2026“ als Dezember
}
const kandidaten = [de ? '' : roh, doc.createdAt, doc.$createdAt]
for (const kandidat of kandidaten) {
if (!kandidat) continue
const datum = new Date(kandidat)
if (!Number.isNaN(datum.getTime())) return berlinDate(datum)
}
return ''
}
/** Text wie in der Kundenansicht (customerWorkEntries): eine Zeile, höchstens 200 Zeichen. */
function blattText(doc) {
const [eintrag] = customerWorkEntries([doc], { includeUpdates: true })
if (!eintrag) return ''
const roh = Array.isArray(eintrag.changes) ? eintrag.changes.join('; ') : String(eintrag.text || '')
const text = roh.replace(/\s+/g, ' ').trim()
return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT - 1).trimEnd()}…` : text
}
function docToBlatt(doc) {
return {
id: doc.$id,
ticketId: String(doc.workorderId ?? ''),
woid: String(doc.woid ?? ''),
datum: blattDatum(doc),
minuten: Math.ceil(worksheetMinutes(doc) / VIERTELSTUNDE) * VIERTELSTUNDE,
text: blattText(doc),
aboZeit: doc.countsToPlan === true,
kommentar: doc.isComment === true,
}
}
const sortiert = (blaetter) => blaetter.sort((a, b) => a.datum.localeCompare(b.datum) || a.id.localeCompare(b.id))
async function ladeNach(attribut, werte) {
const liste = Array.isArray(werte) ? [...new Set(werte.filter((wert) => typeof wert === 'string' && wert !== ''))] : []
const blaetter = []
for (let i = 0; i < liste.length; i += BLOCK) {
const docs = await listAllDocuments(config.collections.worksheets, [Query.equal(attribut, liste.slice(i, i + BLOCK))])
blaetter.push(...docs.map(docToBlatt))
}
return sortiert(blaetter)
}
export const ladeArbeitsblaetter = (ticketIds) => ladeNach('workorderId', ticketIds)
/** Liefert nur gefundene Blätter; der Aufrufer vergleicht die Anzahl. */
export const ladeArbeitsblaetterNachIds = (ids) => ladeNach('$id', ids)
export const abrechenbar = (blatt) => !blatt.aboZeit && !blatt.kommentar && blatt.minuten > 0
/** Karte Arbeitsblatt-ID → { invoiceId, rechnungsnummer } aus aktiven Projektrechnungen (Entwurf: Nummer ''). */
export async function abgerechneteArbeitsblaetter(kundenId) {
const karte = new Map()
for (const rechnung of await listProjektRechnungen({ kundenId })) {
for (const id of rechnung.arbeitsblattIds) {
if (!karte.has(id)) karte.set(id, { invoiceId: rechnung.invoiceId, rechnungsnummer: rechnung.rechnungsnummer })
}
}
return karte
}
export async function offeneArbeitsblaetter(ticketIds, kundenId) {
const abgerechnet = await abgerechneteArbeitsblaetter(kundenId)
return (await ladeArbeitsblaetter(ticketIds)).filter((blatt) => abrechenbar(blatt) && !abgerechnet.has(blatt.id))
}
/**
* Zeitquelle eines Projekts: Vereinigung der zeitquelle aller vereinbarten ABs (VEREINBARTE_STATUS), sonst das
* Ticket des Projekts. Der Aufrufer übergibt nur die ABs dieses Projekts; hier wird nicht nach Projekt gefiltert.
*/
export function zeitquelleFuerProjekt(projekt, auftraege) {
const quelle = new Set()
for (const auftrag of Array.isArray(auftraege) ? auftraege : []) {
if (!VEREINBARTE_STATUS.includes(auftrag?.status)) continue
for (const id of auftrag.zeitquelle || []) if (typeof id === 'string' && id !== '') quelle.add(id)
}
if (quelle.size) return [...quelle]
const ticketId = projekt?.ticketId
return typeof ticketId === 'string' && ticketId !== '' ? [ticketId] : []
}
+577
View File
@@ -0,0 +1,577 @@
/**
* Abläufe der Auftragsbestätigung (AB): Eingabeprüfung, Entwurf, Vorschau, Senden, Erinnern, neue Fassung, Stornieren
* und nachträgliches Zuordnen eines Projekts (Spec Abschnitt 2, Übersicht V4 „L3 – AB-Kern“).
* Speicher nur über auftragStore.js und projektRechnungenStore.js, PDFs nur über ablage.js; PDF, Token und Mail kommen
* über `deps` (L4/L5, Standard: dynamischer Import beim Aufruf). Jede Änderung an einer bestehenden AB läuft unter
* sperreAuftrag(id) (konstanten.js), das Senden zusätzlich unter SPERRE_NUMMER; Mails gehen immer erst nach der Sperre.
*/
import { createHash } from 'node:crypto'
import { config } from '../../config.js'
import { getDocument, listDocuments, Query, updateDocument } from '../appwriteAdmin.js'
import { getSettings } from '../finance/settings.js'
import { withLocks } from '../keyedLock.js'
import { kundeSnapshot } from './auftragSicht.js'
import { entfernePdf, lesePdf, speicherePdf } from './ablage.js'
import { createAuftrag, deleteAuftrag, getAuftrag, listAuftraege, updateAuftrag } from './auftragStore.js'
import { AuftragError } from './fehler.js'
import { vergibNummer } from './nummern.js'
import { listProjektRechnungen, umhaengen, updateProjektRechnung } from './projektRechnungenStore.js'
import { TEXTE_FASSUNG } from './texte.js'
import {
AGB_FASSUNG, AUFTRAG_ARTEN, AUFTRAG_WEGE, KUNDENARTEN, LIMITS, SPERRE_NUMMER, sperreAuftrag, STUNDENSATZ_STANDARD_CENTS, VEREINBARTE_STATUS, VIERTELSTUNDE, ZAHLUNGSPLAN_ARTEN,
} from './konstanten.js'
// ---------------------------------------------------------------------------------------------------------------------
// Eingabeprüfung (rein, synchron)
// ---------------------------------------------------------------------------------------------------------------------
/** Appwrite-Dokument-ID (wie in auftragStore.js). */
export function istDokumentId(wert) {
return typeof wert === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(wert)
}
const fehler400 = (meldung) => new AuftragError(400, meldung)
const MELDUNG_POSITIONEN = 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.'
const MELDUNG_ART = 'Art: Bitte Festpreis oder Nach Aufwand wählen.'
const MELDUNG_PLAN = 'Zahlungsplan: Bitte eine Zahlungsart wählen.'
const MELDUNG_VOM = 'Auftrag vom: Bitte ein gültiges Datum angeben (JJJJ-MM-TT).'
const MELDUNG_WEG = 'Weg: Bitte den Weg der Absprache wählen.'
const MELDUNG_SUMME = 'Die Auftragssumme ist zu hoch (höchstens 999.999,99 €).'
const PREIS_GRENZE = 'zwischen 0,01 € und 100.000,00 € liegen.'
const istEinfachesObjekt = (wert) => Object.prototype.toString.call(wert) === '[object Object]'
function istKalendertag(wert) {
if (typeof wert !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(wert)) return false
const datum = new Date(`${wert}T00:00:00Z`)
return !Number.isNaN(datum.getTime()) && datum.toISOString().slice(0, 10) === wert
}
function pruefePosition(roh, n) {
const p = istEinfachesObjekt(roh) ? roh : {}
const beschreibung = typeof p.beschreibung === 'string' ? p.beschreibung.trim() : ''
if (beschreibung.length < 1 || beschreibung.length > LIMITS.maxBeschreibung) {
throw fehler400(`Position ${n}: Die Beschreibung braucht 1 bis ${LIMITS.maxBeschreibung} Zeichen.`)
}
if (!Number.isInteger(p.menge) || p.menge < 1 || p.menge > LIMITS.maxMenge) {
throw fehler400(`Position ${n}: Die Menge muss eine ganze Zahl von 1 bis ${LIMITS.maxMenge} sein.`)
}
if (!Number.isInteger(p.einzelpreisCents) || p.einzelpreisCents < 1 || p.einzelpreisCents > LIMITS.maxEinzelpreisCents) {
throw fehler400(`Position ${n}: Der Einzelpreis muss ${PREIS_GRENZE}`)
}
return { beschreibung, menge: p.menge, einzelpreisCents: p.einzelpreisCents }
}
function pruefePlan(roh) {
if (!istEinfachesObjekt(roh) || !ZAHLUNGSPLAN_ARTEN.includes(roh.art)) throw fehler400(MELDUNG_PLAN)
let prozent = null
let text = ''
if (roh.art === 'anzahlung') {
prozent = roh.prozent === undefined || roh.prozent === null ? LIMITS.standardAnzahlungProzent : roh.prozent
if (!Number.isInteger(prozent) || prozent < 1 || prozent > 99) throw fehler400('Zahlungsplan: Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.')
}
if (roh.art === 'frei') {
text = typeof roh.text === 'string' ? roh.text.trim() : ''
if (text.length < 1 || text.length > LIMITS.maxZahlungsplanText) throw fehler400(`Zahlungsplan: Der freie Text braucht 1 bis ${LIMITS.maxZahlungsplanText} Zeichen.`)
}
return { art: roh.art, prozent, text }
}
function pruefeOptionalesDatum(wert, meldung) {
if (wert === null || wert === '') return ''
if (!istKalendertag(wert)) throw fehler400(meldung)
return wert
}
/** Feldregeln: jedes übergebene Feld (Wert !== undefined) wird geprüft und bereinigt. */
const FELDREGELN = {
art: (w) => {
if (!AUFTRAG_ARTEN.includes(w)) throw fehler400(MELDUNG_ART)
return w
},
projektId: (w) => {
if (w === null || w === '') return ''
if (!istDokumentId(w)) throw fehler400('Projekt: Ungültige Projekt-Kennung.')
return w
},
titel: (w) => {
if (w === null) return ''
if (typeof w !== 'string' || w.trim().length > LIMITS.maxTitel) throw fehler400(`Titel: Höchstens ${LIMITS.maxTitel} Zeichen.`)
return w.trim()
},
kundenart: (w) => {
if (w === null || w === '') return ''
if (!KUNDENARTEN.includes(w)) throw fehler400('Kundenart: Bitte Unternehmen oder Privatperson wählen.')
return w
},
positionen: (w) => {
if (!Array.isArray(w) || w.length > LIMITS.maxPositionen) throw fehler400(MELDUNG_POSITIONEN)
return w.map((p, i) => pruefePosition(p, i + 1))
},
stundensatzCents: (w) => {
if (w === null) return null
if (!Number.isInteger(w) || w < 1 || w > LIMITS.maxEinzelpreisCents) throw fehler400(`Stundensatz: Der Stundensatz muss ${PREIS_GRENZE}`)
return w
},
schaetzungMinuten: (w) => {
if (w === null || w === '' || w === 0) return null
if (!Number.isInteger(w) || w % VIERTELSTUNDE !== 0 || w < VIERTELSTUNDE || w > LIMITS.maxSchaetzungMinuten) {
throw fehler400(`Schätzung: Die Schätzung muss ein Vielfaches von ${VIERTELSTUNDE} Minuten sein (15 bis 99.960).`)
}
return w
},
zahlungsplan: pruefePlan,
zahlungszielTage: (w) => {
if (w === null) return null
if (!Number.isInteger(w) || w < 0 || w > LIMITS.maxZahlungszielTage) {
throw fehler400(`Zahlungsziel: Das Zahlungsziel muss eine ganze Zahl von 0 bis ${LIMITS.maxZahlungszielTage} Tagen sein.`)
}
return w
},
auftragVom: (w) => {
if (!istKalendertag(w)) throw fehler400(MELDUNG_VOM)
return w
},
auftragWeg: (w) => {
if (typeof w !== 'string' || !Object.hasOwn(AUFTRAG_WEGE, w)) throw fehler400(MELDUNG_WEG)
return w
},
startAm: (w) => pruefeOptionalesDatum(w, 'Start: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'),
fertigBis: (w) => pruefeOptionalesDatum(w, 'Fertigstellung: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'),
zeitquelle: (w) => {
if (w === null) return []
if (!Array.isArray(w)) throw fehler400('Zeitquelle: Eine Liste von Ticket-Kennungen erwartet.')
if (!w.every(istDokumentId)) throw fehler400('Zeitquelle: Ungültige Ticket-Kennung.')
const ohneDoppelte = [...new Set(w)]
if (ohneDoppelte.length > LIMITS.maxZeitquellen) throw fehler400(`Zeitquelle: Höchstens ${LIMITS.maxZeitquellen} Tickets.`)
return ohneDoppelte
},
}
const FELD_REIHENFOLGE = Object.keys(FELDREGELN)
/**
* Prüft und bereinigt die Eingabe einer AB. Nur Felder der Whitelist (FELDREGELN) werden übernommen; status, nummer,
* tokenHash & Co. landen nie im Ergebnis. teilweise: nur die übergebenen Felder, ohne Pflicht-, Vorgabe- und Querprüfung.
*/
export function pruefeEingabe(eingabe, { teilweise = false } = {}) {
if (!istEinfachesObjekt(eingabe)) throw fehler400('Ungültige Eingabe.')
const felder = {}
for (const name of FELD_REIHENFOLGE) {
if (eingabe[name] !== undefined) felder[name] = FELDREGELN[name](eingabe[name])
}
if (teilweise) return felder
// Pflichtfelder: fehlt eines, kommt dieselbe Meldung wie bei einem ungültigen Wert
if (felder.art === undefined) throw fehler400(MELDUNG_ART)
if (felder.zahlungsplan === undefined) throw fehler400(MELDUNG_PLAN)
if (felder.auftragVom === undefined) throw fehler400(MELDUNG_VOM)
if (felder.auftragWeg === undefined) throw fehler400(MELDUNG_WEG)
const ergebnis = {
projektId: felder.projektId ?? '',
titel: felder.titel ?? '',
kundenart: felder.kundenart ?? '',
art: felder.art,
positionen: [],
stundensatzCents: null,
schaetzungMinuten: null,
summeCents: null,
zahlungsplan: felder.zahlungsplan,
zahlungszielTage: felder.zahlungszielTage ?? LIMITS.standardZahlungszielTage,
auftragVom: felder.auftragVom,
auftragWeg: felder.auftragWeg,
startAm: felder.startAm ?? '',
fertigBis: felder.fertigBis ?? '',
zeitquelle: felder.zeitquelle ?? [],
}
if (felder.art === 'festpreis') {
if (!felder.positionen || felder.positionen.length === 0) throw fehler400(MELDUNG_POSITIONEN)
const summe = felder.positionen.reduce((s, p) => s + p.menge * p.einzelpreisCents, 0)
if (summe > LIMITS.maxSummeCents) throw fehler400(MELDUNG_SUMME)
if (felder.zahlungsplan.art === 'monatlich') throw fehler400('Zahlungsplan: „Monatlich nach Aufwand“ geht nur bei einem Auftrag nach Aufwand.')
ergebnis.positionen = felder.positionen
ergebnis.summeCents = summe
} else {
if (felder.zahlungsplan.art === 'anzahlung') throw fehler400('Zahlungsplan: Eine Anzahlung geht nur bei einem Festpreis.')
ergebnis.stundensatzCents = felder.stundensatzCents ?? STUNDENSATZ_STANDARD_CENTS
ergebnis.schaetzungMinuten = felder.schaetzungMinuten ?? null
}
return ergebnis
}
// ---------------------------------------------------------------------------------------------------------------------
// Entwurf anlegen, ändern, löschen
// ---------------------------------------------------------------------------------------------------------------------
const hatText = (wert) => typeof wert === 'string' && wert.trim() !== ''
export async function ladeAuftrag(id) {
const auftrag = istDokumentId(id) ? await getAuftrag(id) : null
if (!auftrag) throw new AuftragError(404, 'Auftragsbestätigung nicht gefunden.')
return auftrag
}
export async function ladeKunde(kundenId) {
const nichtGefunden = () => new AuftragError(404, 'Kunde nicht gefunden.')
if (!istDokumentId(kundenId)) throw nichtGefunden()
try {
return await getDocument(config.collections.customers, kundenId)
} catch (err) {
if (err?.status === 404) throw nichtGefunden()
throw err
}
}
/** Die Felder, die die Eingabeprüfung kennt, aus einem gespeicherten Auftrag (ohne kundenId und kundenart). */
function eingabeAusAuftrag(auftrag) {
const { projektId, titel, art, positionen, stundensatzCents, schaetzungMinuten, zahlungsplan, zahlungszielTage, auftragVom, auftragWeg, startAm, fertigBis, zeitquelle } = auftrag
return { projektId, titel, art, positionen, stundensatzCents, schaetzungMinuten, zahlungsplan, zahlungszielTage, auftragVom, auftragWeg, startAm, fertigBis, zeitquelle }
}
/** Das Ticket eines Projekts als Zeitquelle (nie ein Leereintrag). */
function ticketsDesProjekts(projekt) {
return hatText(projekt?.ticketId) ? [projekt.ticketId.trim()] : []
}
async function ladeProjekt(projektId) {
try {
return await getDocument(config.collections.websiteProjects, projektId)
} catch (err) {
if (err?.status === 404) return null
throw err
}
}
/** Projekt und Tickets müssen dem Kunden gehören; liefert die Felder mit aufgelöstem Titel und aufgelöster Zeitquelle. */
async function pruefeBezuege(kundeDoc, felder) {
let titel = felder.titel
let zeitquelle = felder.zeitquelle
let projekt = null
if (felder.projektId) {
projekt = await ladeProjekt(felder.projektId)
if (!projekt || projekt.customerId !== kundeDoc.$id) throw new AuftragError(400, 'Projekt: Das Projekt gehört nicht zu diesem Kunden.')
}
if (!titel) titel = String(projekt?.projectName || '').trim().slice(0, LIMITS.maxTitel).trim()
if (!titel) throw new AuftragError(400, 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.')
if (zeitquelle.length === 0 && projekt) zeitquelle = ticketsDesProjekts(projekt)
if (zeitquelle.length > 0) {
const tickets = await listDocuments(config.collections.workorders, [Query.equal('$id', zeitquelle), Query.limit(LIMITS.maxZeitquellen)])
const gefunden = new Map(tickets.map((t) => [t.$id, t]))
if (!zeitquelle.every((id) => gefunden.get(id)?.customerId === kundeDoc.$id)) {
throw new AuftragError(400, 'Zeitquelle: Mindestens ein Ticket gehört nicht zu diesem Kunden.')
}
}
return { ...felder, titel, zeitquelle }
}
/** Setzt die Kundenart am Kunden, aber nur, wenn dort noch keine steht. */
async function schreibeKundenart(kundeDoc, kundenart) {
if (!kundenart || hatText(kundeDoc.kundenart)) return
await updateDocument(config.collections.customers, kundeDoc.$id, { kundenart })
}
/** Die Felder einer AB, wie createAuftrag und updateAuftrag sie bekommen. */
const speicherFelder = (f) => ({
projektId: f.projektId, titel: f.titel, zeitquelle: f.zeitquelle, art: f.art, positionen: f.positionen,
stundensatzCents: f.stundensatzCents, schaetzungMinuten: f.schaetzungMinuten, summeCents: f.summeCents,
zahlungsplan: f.zahlungsplan, zahlungszielTage: f.zahlungszielTage, auftragVom: f.auftragVom, auftragWeg: f.auftragWeg,
startAm: f.startAm, fertigBis: f.fertigBis,
})
export async function legeEntwurfAn(eingabe, { von } = {}) {
const felder = pruefeEingabe(eingabe)
if (!hatText(eingabe.kundenId)) throw new AuftragError(400, 'Kunde: Bitte einen Kunden angeben.')
const kundeDoc = await ladeKunde(eingabe.kundenId)
const fertig = await pruefeBezuege(kundeDoc, felder)
await schreibeKundenart(kundeDoc, felder.kundenart)
return createAuftrag({ kundenId: eingabe.kundenId, ...speicherFelder(fertig), erstelltVon: String(von || '') })
}
export async function aendereEntwurf(id, eingabe) {
return withLocks(sperreAuftrag(id), async () => {
const auftrag = await ladeAuftrag(id)
if (auftrag.status !== 'entwurf') throw new AuftragError(409, 'Nur ein Entwurf lässt sich ändern.')
if (eingabe?.kundenId !== undefined && eingabe.kundenId !== auftrag.kundenId) {
throw new AuftragError(400, 'Kunde: Der Kunde eines Auftrags lässt sich nicht ändern.')
}
const teil = pruefeEingabe(eingabe, { teilweise: true })
const felder = pruefeEingabe({ ...eingabeAusAuftrag(auftrag), ...teil })
const kundeDoc = await ladeKunde(auftrag.kundenId)
const fertig = await pruefeBezuege(kundeDoc, felder)
await schreibeKundenart(kundeDoc, felder.kundenart)
return updateAuftrag(id, speicherFelder(fertig))
})
}
export async function loescheEntwurf(id) {
return withLocks(sperreAuftrag(id), async () => {
const auftrag = await ladeAuftrag(id)
if (auftrag.status !== 'entwurf') throw new AuftragError(409, 'Nur ein Entwurf lässt sich löschen.')
await deleteAuftrag(id)
})
}
// ---------------------------------------------------------------------------------------------------------------------
// Abhängigkeiten (PDF, Token, Mail) und Vorschau-PDF
// ---------------------------------------------------------------------------------------------------------------------
/** Ins Protokoll nur Art, Status und Code, nie die Meldung (sie kann Adressen oder Pfade enthalten). */
function logFehler(was, err) {
console.error(`[auftraege] ${was} fehlgeschlagen:`, err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '')
}
// PDF, Token und Mail entstehen in L4/L5 parallel: Standard ist ein dynamischer Import beim Aufruf, Tests übergeben Attrappen
const STANDARD_QUELLEN = { erzeugeAuftragPdf: './pdf.js', neuerToken: './token.js', sendeAuftragMail: './mails.js' }
let depsFuerTests = null
/** Nur für Tests: Ersatz für die Standard-Abhängigkeiten (null stellt die dynamischen Importe wieder her). */
export function setzeDepsFuerTests(deps) {
depsFuerTests = deps
}
async function ladeDeps(deps, namen) {
const quelle = { ...depsFuerTests, ...deps }
const geladen = {}
for (const name of namen) geladen[name] = quelle[name] ?? (await import(STANDARD_QUELLEN[name]))[name]
return geladen
}
/** Erzeugt das PDF; ein AuftragError der PDF-Funktion (z. B. 409 „Entwurf unvollständig“) bleibt erhalten. */
async function pdfErzeugen(d, auftrag, optionen) {
let pdf
try {
pdf = await d.erzeugeAuftragPdf(auftrag, optionen)
if (!Buffer.isBuffer(pdf) || pdf.length === 0) throw new Error('leeres PDF')
} catch (err) {
if (err instanceof AuftragError) throw err
logFehler('PDF-Erzeugung', err)
throw new AuftragError(500, 'Das PDF konnte nicht erzeugt werden.')
}
return pdf
}
export async function vorschauPdf(id, { deps } = {}) {
const d = await ladeDeps(deps, ['erzeugeAuftragPdf'])
const auftrag = await ladeAuftrag(id)
if (auftrag.status !== 'entwurf') throw new AuftragError(409, 'Eine Vorschau gibt es nur für Entwürfe.')
const kundeDoc = await ladeKunde(auftrag.kundenId)
const { steuernummer, bankDetailsText } = await getSettings()
return pdfErzeugen(d, { ...auftrag, nummer: 'Entwurf', kunde: kundeSnapshot(kundeDoc) }, { settings: { steuernummer, bankDetailsText }, vorschau: true })
}
// ---------------------------------------------------------------------------------------------------------------------
// Senden
// ---------------------------------------------------------------------------------------------------------------------
/** Prüfreihenfolge: fester Kunde, Kundenart, E-Mail, Anschrift (jeweils mit fester Meldung). */
function pruefeKundeFuerVersand(kundeDoc) {
const status = String(kundeDoc.customerStatus || '').trim().toLowerCase()
if (status === 'lead' || status === 'lost') throw new AuftragError(400, 'Auftragsbestätigungen nur für feste Kunden.')
if (!KUNDENARTEN.includes(String(kundeDoc.kundenart || '').trim())) {
throw new AuftragError(400, 'Bitte zuerst die Kundenart angeben (Unternehmen oder Privatperson).')
}
if (!hatText(kundeDoc.email)) throw new AuftragError(400, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.')
if (!hatText(kundeDoc.street) || !hatText(kundeDoc.postalCode) || !hatText(kundeDoc.city)) {
throw new AuftragError(409, 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.')
}
}
/** Entfernt die eben abgelegte PDF, wenn danach etwas scheiterte – aber nur, wenn die AB nachweislich noch ein Entwurf ist. */
async function raeumePdfAuf(auftragId, pdfDatei) {
try {
const aktuell = await getAuftrag(auftragId)
if (aktuell?.status === 'entwurf') await entfernePdf(pdfDatei)
} catch (err) {
logFehler('PDF-Rest entfernen', err)
}
}
/** Fester Text für mailFehler: nie die Meldung von SMTP oder Bibliothek, höchstens ein kurzer Fehlercode. */
function mailFehlerText(err) {
const code = err?.code
return typeof code === 'string' && /^[A-Za-z0-9_.-]{1,40}$/.test(code)
? `E-Mail konnte nicht gesendet werden (${code}).`
: 'E-Mail konnte nicht gesendet werden.'
}
/** Versendet die K31 mit dem gespeicherten Dokument (nach der Sperre) und hält das Ergebnis in mailStatus fest. */
async function versendeMail(d, gespeichert, { pdf, token, erinnerung = false }) {
let mailPatch
try {
await d.sendeAuftragMail(gespeichert, { pdf, token, erinnerung })
mailPatch = { mailStatus: 'gesendet', mailFehler: '' }
} catch (err) {
logFehler('Mailversand', err)
mailPatch = { mailStatus: 'fehler', mailFehler: mailFehlerText(err) }
}
const final = await updateAuftrag(gespeichert.id, mailPatch)
return { auftrag: final, mailFehler: mailPatch.mailStatus === 'fehler' }
}
const MELDUNG_ERSETZTE_UNGUELTIG = 'Die ersetzte Auftragsbestätigung ist nicht mehr gültig. Bitte eine neue Fassung anlegen.'
/**
* Prüft die AB, die der Entwurf ersetzen soll: gültig sind versendet, bestätigt, abgerechnet oder (bei der Wiederholung
* nach einem Fehler) schon durch genau diesen Entwurf ersetzt. Zeigt ersetztId auf den Entwurf selbst (beschädigte Daten),
* gibt es sofort 409, damit ersetzeAlteAb nicht den schon gehaltenen Schlüssel noch einmal sperrt.
*/
async function pruefeErsetzteAb(entwurf) {
if (entwurf.ersetztId === entwurf.id) throw new AuftragError(409, MELDUNG_ERSETZTE_UNGUELTIG)
const alt = await getAuftrag(entwurf.ersetztId)
const gueltig = alt && (VEREINBARTE_STATUS.includes(alt.status) || (alt.status === 'ersetzt' && alt.ersetztDurchId === entwurf.id))
if (!gueltig) throw new AuftragError(409, MELDUNG_ERSETZTE_UNGUELTIG)
return alt
}
/**
* Ersetzt die alte AB (Status ersetzt + ersetztDurchId) und hängt ihre Projektrechnungen um – vor dem Speichern der neuen
* AB, wiederholbar. Der Stand der alten AB wird unter ihrer eigenen Sperre noch einmal geprüft; ihr tokenHash bleibt.
*/
async function ersetzeAlteAb(entwurf) {
await withLocks(sperreAuftrag(entwurf.ersetztId), async () => {
const alt = await pruefeErsetzteAb(entwurf)
if (alt.status !== 'ersetzt') await updateAuftrag(alt.id, { status: 'ersetzt', ersetztDurchId: entwurf.id })
await umhaengen(alt.id, entwurf.id)
})
}
export async function sendeAuftrag(id, { von, deps, jetzt = new Date() } = {}) {
void von // wird nicht gespeichert (V1 hat kein Feld dafür); die Team-Kopie der Mail belegt den Versand
const d = await ladeDeps(deps, ['erzeugeAuftragPdf', 'neuerToken', 'sendeAuftragMail'])
// Nummernvergabe und Statuswechsel unter einer Sperre: zwei Klicks ergeben eine Nummer, Ändern und Löschen warten
const { gespeichert, pdf, token } = await withLocks([...SPERRE_NUMMER, ...sperreAuftrag(id)], async () => {
const entwurf = await ladeAuftrag(id)
if (entwurf.status !== 'entwurf') throw new AuftragError(409, 'Nur ein Entwurf lässt sich senden.')
pruefeEingabe(eingabeAusAuftrag(entwurf))
const kundeDoc = await ladeKunde(entwurf.kundenId)
pruefeKundeFuerVersand(kundeDoc)
if (entwurf.ersetztId) await pruefeErsetzteAb(entwurf) // früh: vor Nummer und PDF
const kunde = kundeSnapshot(kundeDoc)
const { steuernummer, bankDetailsText } = await getSettings()
const { nummer, jahr, laufnummer } = await vergibNummer(jetzt)
const gesendetAm = jetzt.toISOString()
const zuSenden = { ...entwurf, status: 'versendet', nummer, jahr, laufnummer, kunde, gesendetAm, texteFassung: TEXTE_FASSUNG, agbFassung: AGB_FASSUNG }
const pdfBytes = await pdfErzeugen(d, zuSenden, { settings: { steuernummer, bankDetailsText } })
let ablage
try {
ablage = await speicherePdf(entwurf.kundenId, entwurf.id, pdfBytes)
} catch (err) {
// eine schon vorhandene Datei gehört nicht diesem Aufruf und bleibt unberührt
logFehler('PDF-Ablage', err)
throw new AuftragError(500, 'Das PDF konnte nicht erzeugt werden.')
}
try {
const neu = d.neuerToken(jetzt)
if (entwurf.ersetztId) await ersetzeAlteAb(entwurf)
const gespeichertesDokument = await updateAuftrag(entwurf.id, {
status: 'versendet', nummer, jahr, laufnummer, kunde, gesendetAm, texteFassung: TEXTE_FASSUNG, agbFassung: AGB_FASSUNG,
pdfDatei: ablage.pdfDatei, pdfSha256: ablage.pdfSha256, gesendetAn: kunde.email, tokenHash: neu.hash, tokenGueltigBis: neu.gueltigBis.toISOString(),
})
return { gespeichert: gespeichertesDokument, pdf: pdfBytes, token: neu.token }
} catch (err) {
await raeumePdfAuf(entwurf.id, ablage.pdfDatei)
throw err
}
})
return versendeMail(d, gespeichert, { pdf, token })
}
// ---------------------------------------------------------------------------------------------------------------------
// Erinnern („Erinnerung senden“ und „Erneut senden“)
// ---------------------------------------------------------------------------------------------------------------------
/** Liest die gespeicherte PDF und prüft die SHA-256-Summe: eine veränderte Datei wird nie verschickt oder angezeigt. */
export async function ladeGespeichertesPdf(auftrag) {
if (!hatText(auftrag.pdfDatei)) throw new AuftragError(404, 'PDF nicht gefunden.')
const buffer = await lesePdf(auftrag.pdfDatei)
if (createHash('sha256').update(buffer).digest('hex') !== auftrag.pdfSha256) {
console.error('[auftraege] Prüfsumme der gespeicherten PDF stimmt nicht:', auftrag.id)
throw new AuftragError(500, 'Die gespeicherte PDF-Datei stimmt nicht mit der Prüfsumme überein.')
}
return buffer
}
/**
* Neuer Bestätigungslink für eine versendete AB (der alte gilt ab dann nicht mehr) und erneute Mail mit der gespeicherten
* PDF. Nach einem Mail-Fehler ist es eine gewöhnliche Mail ohne Erinnerungs-Hinweis; gesendetAn wird die aktuelle Adresse.
*/
export async function erinnereAuftrag(id, { deps, jetzt = new Date() } = {}) {
const d = await ladeDeps(deps, ['neuerToken', 'sendeAuftragMail'])
const { gespeichert, pdf, token, erinnerung } = await withLocks(sperreAuftrag(id), async () => {
const alt = await ladeAuftrag(id)
if (alt.status !== 'versendet') throw new AuftragError(409, 'Eine Erinnerung gibt es nur für versendete Auftragsbestätigungen.')
const kundeDoc = await ladeKunde(alt.kundenId)
if (!hatText(kundeDoc.email)) throw new AuftragError(400, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.')
// vor dem Token-Tausch: eine beschädigte Datei darf den alten Link nicht entwerten
const pdfBytes = await ladeGespeichertesPdf(alt)
const neu = d.neuerToken(jetzt)
const dokument = await updateAuftrag(id, { tokenHash: neu.hash, tokenGueltigBis: neu.gueltigBis.toISOString(), gesendetAn: kundeDoc.email.trim() })
return { gespeichert: dokument, pdf: pdfBytes, token: neu.token, erinnerung: alt.mailStatus !== 'fehler' }
})
return versendeMail(d, gespeichert, { pdf, token, erinnerung })
}
// ---------------------------------------------------------------------------------------------------------------------
// Neue Fassung
// ---------------------------------------------------------------------------------------------------------------------
/** Legt aus einer versendeten, bestätigten oder abgerechneten AB einen neuen Entwurf an (ersetztId); die alte bleibt. */
export async function neueFassung(id, { von } = {}) {
return withLocks(sperreAuftrag(id), async () => {
const alt = await ladeAuftrag(id)
if (!VEREINBARTE_STATUS.includes(alt.status)) {
throw new AuftragError(409, 'Eine neue Fassung gibt es nur für versendete, bestätigte oder abgerechnete Aufträge.')
}
const vorhandene = await listAuftraege({ kundenId: alt.kundenId })
if (vorhandene.some((a) => a.status === 'entwurf' && a.ersetztId === alt.id)) {
throw new AuftragError(409, 'Zu dieser Auftragsbestätigung gibt es schon einen Entwurf für eine neue Fassung.')
}
return createAuftrag({ kundenId: alt.kundenId, ...speicherFelder(alt), ersetztId: alt.id, erstelltVon: String(von || '') })
})
}
// ---------------------------------------------------------------------------------------------------------------------
// Stornieren
// ---------------------------------------------------------------------------------------------------------------------
/** Storniert eine versendete oder bestätigte AB (ohne Mail); tokenHash, Nummer und PDF bleiben, der Link erklärt „storniert“. */
export async function storniereAuftrag(id) {
return withLocks(sperreAuftrag(id), async () => {
const auftrag = await ladeAuftrag(id) // der Stand wird in der Sperre gelesen
if (auftrag.status !== 'versendet' && auftrag.status !== 'bestaetigt') {
throw new AuftragError(409, 'Nur versendete oder bestätigte Auftragsbestätigungen lassen sich stornieren.')
}
return updateAuftrag(id, { status: 'storniert', storniertAm: new Date().toISOString() })
})
}
// ---------------------------------------------------------------------------------------------------------------------
// Projekt nachträglich zuordnen
// ---------------------------------------------------------------------------------------------------------------------
/**
* Ordnet einer AB ohne Projekt nachträglich ein Projekt des Kunden zu: setzt projektId, bei leerer Zeitquelle das Ticket des
* Projekts, und hängt die Projektrechnungen der AB mit leerer projektId um (Stripe bleibt unverändert). PDF, Nummer, Status
* und Token bleiben. Zuerst die Projektrechnungen, zuletzt die AB: scheitert etwas dazwischen, bleibt die AB ohne Projekt
* und die Wiederholung führt alles zu Ende.
*/
export async function ordneProjektZu(id, projektId) {
if (!hatText(projektId)) throw new AuftragError(400, 'Projekt: Bitte ein Projekt angeben.')
if (!istDokumentId(projektId)) throw new AuftragError(400, 'Projekt: Ungültige Projekt-Kennung.')
return withLocks(sperreAuftrag(id), async () => {
const auftrag = await ladeAuftrag(id)
if (['storniert', 'ersetzt', 'widerrufen'].includes(auftrag.status)) {
throw new AuftragError(409, 'Einer stornierten, ersetzten oder widerrufenen Auftragsbestätigung lässt sich kein Projekt mehr zuordnen.')
}
if (hatText(auftrag.projektId)) throw new AuftragError(409, 'Diese Auftragsbestätigung gehört schon zu einem Projekt.')
const projekt = await ladeProjekt(projektId)
if (!projekt || projekt.customerId !== auftrag.kundenId) throw new AuftragError(400, 'Projekt: Das Projekt gehört nicht zu diesem Kunden.')
const rechnungen = await listProjektRechnungen({ auftragId: id, nurAktiv: false })
for (const rechnung of rechnungen) {
if (rechnung.projektId === '') await updateProjektRechnung(rechnung.invoiceId, { projektId })
}
const patch = { projektId }
if (auftrag.zeitquelle.length === 0) patch.zeitquelle = ticketsDesProjekts(projekt)
return updateAuftrag(id, patch)
})
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Sichten auf eine Auftragsbestätigung (Übersicht V3/V4): Kundensnapshot, früheste Leistung bei Privatpersonen,
* Zahlungsplan- und Aufwandstext, Ansicht fürs Team und öffentliche Ansicht für Token- und Kundenrouten.
* Alles rein (kein Appwrite, keine Konfiguration); die Wortlaute kommen aus texte.js.
*/
import { withdrawalDeadline } from '../deadlines.js'
import { addDays, berlinDate } from '../finance/dates.js'
import { euroDe } from '../finance/format.js'
import { AGB_FASSUNG, KUNDENARTEN, LIMITS, STUNDENSATZ_STANDARD_CENTS } from './konstanten.js'
import { AB_TEXTE, BESTAETIGUNG_TEXT, FRUEHBEGINN_TEXT } from './texte.js'
// Kürzungen, damit der Snapshot samt JSON in die Spalte kundeJson (2000 Zeichen) passt
const SNAPSHOT_FELDER = [
['name', 'name', 120], ['firma', 'companyName', 120], ['strasse', 'street', 120], ['plz', 'postalCode', 20],
['ort', 'city', 80], ['land', 'country', 60], ['email', 'email', 200], ['kundennummer', 'code', 40],
]
const text = (wert, max) => (typeof wert === 'string' ? wert.trim().slice(0, max).trim() : '')
export function kundeSnapshot(customerDoc) {
const doc = customerDoc || {}
const snapshot = {}
for (const [ziel, quelle, max] of SNAPSHOT_FELDER) snapshot[ziel] = text(doc[quelle], max)
snapshot.kundenart = KUNDENARTEN.includes(doc.kundenart) ? doc.kundenart : ''
// feste Reihenfolge der Schlüssel wie KundeSnapshot in V3
const { name, firma, strasse, plz, ort, land, email, kundenart, kundennummer } = snapshot
return { name, firma, strasse, plz, ort, land, email, kundenart, kundennummer }
}
/** Früheste Leistung bei Privatpersonen ohne Frühbeginn: Tag nach Ablauf der Widerrufsfrist. */
export function fruehesterBeginn(auftrag) {
if (auftrag.kunde?.kundenart !== 'privat' || auftrag.fruehbeginn || !auftrag.gesendetAm) return null
const gesendet = new Date(auftrag.gesendetAm)
if (Number.isNaN(gesendet.getTime())) return null
return addDays(withdrawalDeadline(gesendet).lastDay, 1)
}
export function zahlungsplanText(auftrag) {
const ziel = Number.isSafeInteger(auftrag.zahlungszielTage) ? auftrag.zahlungszielTage : LIMITS.standardZahlungszielTage
const plan = auftrag.zahlungsplan || { art: 'nach_abnahme' }
const bausteine = AB_TEXTE.zahlungsplan
if (plan.art === 'monatlich') return bausteine.monatlich({ ziel })
if (plan.art === 'frei') return bausteine.frei({ text: plan.text || '', ziel })
if (plan.art === 'anzahlung' && Number.isSafeInteger(auftrag.summeCents) && typeof plan.prozent === 'number' && Number.isFinite(plan.prozent)) {
const anzahlung = Math.round((auftrag.summeCents * plan.prozent) / 100)
return bausteine.anzahlung({ prozent: plan.prozent, anzahlung: euroDe(anzahlung), rest: euroDe(auftrag.summeCents - anzahlung), ziel })
}
return bausteine.nach_abnahme({ ziel })
}
/** Stunden als Text mit Dezimalkomma ohne überflüssige Nullen: 600 → '10', 135 → '2,25', 90 → '1,5'. */
const stundenText = (minuten) => String(Math.round((minuten / 60) * 100) / 100).replace('.', ',')
export function aufwandText(auftrag) {
if (auftrag.art !== 'aufwand') return null
const minuten = auftrag.schaetzungMinuten
return AB_TEXTE.aufwand({
satz: euroDe(auftrag.stundensatzCents ?? STUNDENSATZ_STANDARD_CENTS),
schaetzung: Number.isSafeInteger(minuten) && minuten > 0 ? stundenText(minuten) : null,
})
}
/** Auftrag ohne tokenHash, pdfDatei und bestaetigtPdfSha256, plus die abgeleiteten Felder. */
export function auftragFuerTeam(auftrag) {
const { tokenHash, pdfDatei, bestaetigtPdfSha256, ...rest } = auftrag
return {
...rest,
pdfVorhanden: Boolean(pdfDatei),
fruehesterBeginn: fruehesterBeginn(auftrag),
zahlungsplanText: zahlungsplanText(auftrag),
aufwandText: aufwandText(auftrag),
}
}
/** Nur für Token- und Kundenrouten: keine Kundendaten, kein Token, keine Pfade. */
export function oeffentlicheAnsicht(auftrag) {
const kundenart = auftrag.kunde?.kundenart || ''
return {
nummer: auftrag.nummer,
datum: auftrag.gesendetAm ? berlinDate(new Date(auftrag.gesendetAm)) : null,
titel: auftrag.titel,
art: auftrag.art,
positionen: auftrag.positionen.map((p) => ({
beschreibung: p.beschreibung, menge: p.menge, einzelpreisCents: p.einzelpreisCents, summeCents: p.menge * p.einzelpreisCents,
})),
summeCents: auftrag.summeCents,
stundensatzCents: auftrag.stundensatzCents,
schaetzungMinuten: auftrag.schaetzungMinuten,
aufwandText: aufwandText(auftrag),
zahlungsplanText: zahlungsplanText(auftrag),
startAm: auftrag.startAm,
fertigBis: auftrag.fertigBis,
kundenart,
status: auftrag.status,
bestaetigtAm: auftrag.bestaetigtAm,
fruehbeginn: auftrag.fruehbeginn,
fruehesterBeginn: fruehesterBeginn(auftrag),
agbUrl: `/rechtliches/agb-${auftrag.agbFassung || AGB_FASSUNG}.pdf`,
texte: {
bestaetigung: BESTAETIGUNG_TEXT,
fruehbeginn: kundenart === 'privat' ? FRUEHBEGINN_TEXT : null,
kleinunternehmer: AB_TEXTE.kleinunternehmer,
},
}
}
+203
View File
@@ -0,0 +1,203 @@
/**
* Speicher der Auftragsbestätigungen (Collection auftraege, Übersicht V1/V3/V4).
* Die Feldlisten stammen aus AUFTRAEGE_COLLECTIONS (Typ und Größe der Spalten); nur vier Spalten sind JSON und
* heißen im Auftrag anders: kundeJson = kunde, zeitquelleJson = zeitquelle, positionenJson = positionen,
* zahlungsplanJson = zahlungsplan. Programmfehler (falsche Typen, ungültige Filter) sind TypeError, ein zu langer
* Text ist ein AuftragError 400. Schreibende Aufrufer halten bei Statusänderungen sperreAuftrag(id) (konstanten.js).
*/
import { config } from '../../config.js'
import { createDocument, deleteDocument, getDocument, listAllDocuments, listDocuments, Query, updateDocument } from '../appwriteAdmin.js'
import { AuftragError } from './fehler.js'
import { LIMITS, VEREINBARTE_STATUS, ZAHLUNGSPLAN_ARTEN } from './konstanten.js'
import { AUFTRAEGE_COLLECTIONS } from './schema.js'
const JSON_SPALTEN = { kundeJson: 'kunde', zeitquelleJson: 'zeitquelle', positionenJson: 'positionen', zahlungsplanJson: 'zahlungsplan' }
const SPALTE_ZU_FELD = (spalte) => JSON_SPALTEN[spalte] || spalte
const SPALTEN = AUFTRAEGE_COLLECTIONS.auftraege.attributes
const TEXT_SPALTEN = SPALTEN.filter((a) => a.type === 'string' && !JSON_SPALTEN[a.key])
const ZAHL_SPALTEN = SPALTEN.filter((a) => a.type === 'integer').map((a) => a.key)
const DATUM_SPALTEN = SPALTEN.filter((a) => a.type === 'datetime').map((a) => a.key)
const SPALTEN_NACH_FELD = new Map(SPALTEN.map((a) => [SPALTE_ZU_FELD(a.key), a]))
// Appwrite-Regel für Dokument-IDs
const DOKUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
const gueltigeId = (id) => typeof id === 'string' && DOKUMENT_ID.test(id)
const TOKEN_HASH = /^[0-9a-f]{64}$/
const AB_NUMMER = /^AB-\d{4}-\d{4}$/
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
function parseJson(text) {
if (typeof text !== 'string' || text === '') return undefined
try {
return JSON.parse(text)
} catch {
return undefined
}
}
function zeitpunkt(wert) {
if (wert === null || wert === undefined || wert === '') return null
const datum = new Date(wert)
return Number.isNaN(datum.getTime()) ? null : datum.toISOString()
}
export function docToAuftrag(doc) {
const auftrag = { id: doc.$id }
for (const { key } of TEXT_SPALTEN) auftrag[key] = typeof doc[key] === 'string' ? doc[key] : ''
for (const key of ZAHL_SPALTEN) auftrag[key] = Number.isSafeInteger(doc[key]) ? doc[key] : null
auftrag.zahlungszielTage = Number.isSafeInteger(doc.zahlungszielTage) ? doc.zahlungszielTage : LIMITS.standardZahlungszielTage
auftrag.fruehbeginn = doc.fruehbeginn === true
for (const key of DATUM_SPALTEN) auftrag[key] = zeitpunkt(doc[key])
auftrag.erstelltAm = zeitpunkt(doc.erstelltAm) ?? zeitpunkt(doc.$createdAt)
auftrag.geaendertAm = zeitpunkt(doc.geaendertAm) ?? zeitpunkt(doc.$updatedAt)
const kunde = parseJson(doc.kundeJson)
auftrag.kunde = istObjekt(kunde) ? kunde : null
const zeitquelle = parseJson(doc.zeitquelleJson)
auftrag.zeitquelle = Array.isArray(zeitquelle)
? zeitquelle.filter((id) => typeof id === 'string' && id.trim() !== '').map((id) => id.trim())
: []
const positionen = parseJson(doc.positionenJson)
auftrag.positionen = Array.isArray(positionen) ? positionen.filter(istObjekt) : []
const plan = parseJson(doc.zahlungsplanJson)
auftrag.zahlungsplan = istObjekt(plan)
? {
art: ZAHLUNGSPLAN_ARTEN.includes(plan.art) ? plan.art : 'nach_abnahme',
prozent: typeof plan.prozent === 'number' && Number.isFinite(plan.prozent) ? plan.prozent : null,
text: typeof plan.text === 'string' ? plan.text : '',
}
: { art: 'nach_abnahme', prozent: null, text: '' }
return auftrag
}
function pruefeLaenge(feld, text, spalte) {
if (text.length > spalte.size) throw new AuftragError(400, `Das Feld ${feld} ist zu lang.`)
return text
}
/**
* Wandelt nur die in `auftrag` vorhandenen Schlüssel um (auch teilweise Objekte, für updateAuftrag): id, $… und
* fremde Schlüssel bleiben weg, undefined fehlt, null löscht (Text → '', Zahl und Datum → null).
*/
export function auftragToDoc(auftrag) {
const doc = {}
for (const [feld, wert] of Object.entries(auftrag)) {
const spalte = SPALTEN_NACH_FELD.get(feld)
if (!spalte || wert === undefined) continue
const name = spalte.key
if (JSON_SPALTEN[name]) {
doc[name] = wert === null ? '' : pruefeLaenge(feld, JSON.stringify(wert), spalte)
} else if (spalte.type === 'string') {
if (wert === null) doc[name] = ''
else if (typeof wert === 'string') doc[name] = pruefeLaenge(feld, wert, spalte)
else throw new TypeError(`Auftrag.${feld} muss Text sein.`)
} else if (spalte.type === 'integer') {
if (wert !== null && !Number.isSafeInteger(wert)) throw new TypeError(`Auftrag.${feld} muss eine ganze Zahl oder null sein.`)
doc[name] = wert
} else if (spalte.type === 'boolean') {
if (typeof wert !== 'boolean') throw new TypeError(`Auftrag.${feld} muss ein Wahrheitswert sein.`)
doc[name] = wert
} else if (spalte.type === 'datetime') {
if (wert === null) doc[name] = null
else {
const iso = typeof wert === 'string' && wert !== '' ? zeitpunkt(wert) : wert instanceof Date ? zeitpunkt(wert) : null
if (!iso) throw new TypeError(`Auftrag.${feld} muss ein Zeitpunkt oder null sein.`)
doc[name] = iso
}
}
}
return doc
}
const nichtGefunden = () => new AuftragError(404, 'Auftragsbestätigung nicht gefunden.')
export async function getAuftrag(id) {
if (!gueltigeId(id)) return null
try {
return docToAuftrag(await getDocument(config.collections.auftraege, id))
} catch (err) {
if (err?.status === 404) return null
throw err
}
}
function filterAbfragen(filter, { text = [], zahl = [] }) {
const abfragen = []
for (const key of text) {
if (filter[key] === undefined) continue
if (typeof filter[key] !== 'string' || filter[key] === '') throw new TypeError(`Filter ${key} muss ein nicht leerer Text sein.`)
abfragen.push(Query.equal(key, filter[key]))
}
for (const key of zahl) {
if (filter[key] === undefined) continue
if (!Number.isSafeInteger(filter[key])) throw new TypeError(`Filter ${key} muss eine ganze Zahl sein.`)
abfragen.push(Query.equal(key, filter[key]))
}
return abfragen
}
/** Neueste zuerst. Ein angegebener, aber leerer oder ungültiger Filter ist ein TypeError, nie „alle“. */
export async function listAuftraege(filter = {}) {
const abfragen = filterAbfragen(filter, { text: ['kundenId', 'projektId'], zahl: ['jahr'] })
const docs = await listAllDocuments(config.collections.auftraege, abfragen)
return docs
.map(docToAuftrag)
.sort((a, b) => (b.erstelltAm || '').localeCompare(a.erstelltAm || '') || b.id.localeCompare(a.id))
}
export async function createAuftrag(daten) {
const jetzt = new Date().toISOString()
const doc = auftragToDoc({ ...daten, status: 'entwurf', erstelltAm: jetzt, geaendertAm: jetzt })
return docToAuftrag(await createDocument(config.collections.auftraege, doc))
}
export async function updateAuftrag(id, patch) {
if (!gueltigeId(id)) throw nichtGefunden()
const doc = auftragToDoc({ ...patch, geaendertAm: new Date().toISOString() })
try {
return docToAuftrag(await updateDocument(config.collections.auftraege, id, doc))
} catch (err) {
if (err?.status === 404) throw nichtGefunden()
throw err
}
}
export async function deleteAuftrag(id) {
if (!gueltigeId(id)) return
try {
await deleteDocument(config.collections.auftraege, id)
} catch (err) {
if (err?.status !== 404) throw err
}
}
async function findeEine(feld, wert) {
const docs = await listDocuments(config.collections.auftraege, [Query.equal(feld, wert), Query.limit(1)])
return docs.length ? docToAuftrag(docs[0]) : null
}
/** Nur ein 64-stelliger Kleinbuchstaben-Hex-Hash führt zu einer Abfrage (ein leerer Hash träfe jeden Entwurf). */
export async function findAuftragByTokenHash(hash) {
if (typeof hash !== 'string' || !TOKEN_HASH.test(hash)) return null
return findeEine('tokenHash', hash)
}
export async function findAuftragByNummer(nummer) {
if (typeof nummer !== 'string' || !AB_NUMMER.test(nummer)) return null
return findeEine('nummer', nummer)
}
const sendezeit = (auftrag) => new Date(auftrag.gesendetAm || auftrag.erstelltAm || 0).getTime() || 0
/** Jüngste zuerst nach Sendezeitpunkt (ersatzweise Anlagezeit), bei Gleichstand nach Kennung. */
export const juengsteZuerst = (a, b) => sendezeit(b) - sendezeit(a) || String(b.id).localeCompare(String(a.id))
/**
* Die Aufwands-AB, mit der ein Projekt abgerechnet und angezeigt wird: unter den gültigen (versendet, bestätigt) die jüngste
* nach gesendetAm, sonst null. Projektkonto und Rechnungsbau benutzen beide diese eine Auswahl.
*/
export function juengsteAufwandAb(liste) {
return liste.filter((ab) => ab.art === 'aufwand' && VEREINBARTE_STATUS.includes(ab.status)).sort(juengsteZuerst)[0] ?? null
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Bestätigen und Widerrufen einer Auftragsbestätigung (Übersicht V2/V4, Strang L5): idempotent, unter der Sperre `sperreAuftrag(id)`,
* Stand innerhalb der Sperre neu gelesen. K32 und T22 gehen erst nach der Freigabe der Sperre raus; Mailfehler stehen
* nur im Protokoll. Die erste Bestätigung zählt, jede weitere ändert nichts.
*/
import { withdrawalDeadline } from '../deadlines.js'
import { withLocks } from '../keyedLock.js'
import { getAuftrag, updateAuftrag } from './auftragStore.js'
import { AuftragError } from './fehler.js'
import { sperreAuftrag } from './konstanten.js'
import { sendeBestaetigtMails } from './mails.js'
import { FRUEHBEGINN_FASSUNG } from './texte.js'
export const GESPERRT_MELDUNG = {
ersetzt: 'Diese Auftragsbestätigung wurde ersetzt.',
storniert: 'Diese Auftragsbestätigung wurde storniert.',
widerrufen: 'Diese Auftragsbestätigung wurde widerrufen.',
}
export const UNGUELTIGER_LINK = 'Dieser Link ist ungültig oder abgelaufen.'
const NICHT_BESTAETIGBAR = 'Diese Auftragsbestätigung kann nicht bestätigt werden.'
const NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.'
const GESPERRT_STATUS = ['entwurf', 'ersetzt', 'storniert', 'widerrufen']
/** 'offen' (bestätigbar), 'bestaetigt' (schon bestätigt) oder 'gesperrt' (Entwurf, ersetzt, storniert, widerrufen). */
export function bestaetigungsLage(auftrag) {
if (GESPERRT_STATUS.includes(auftrag.status)) return 'gesperrt'
return auftrag.bestaetigtAm || auftrag.status === 'bestaetigt' ? 'bestaetigt' : 'offen'
}
function pruefeZeitpunkt(jetzt) {
if (!(jetzt instanceof Date) || Number.isNaN(jetzt.getTime())) throw new TypeError('jetzt muss ein gültiges Date sein')
}
export async function bestaetigeAuftrag(auftrag, { ueber, fruehbeginn, jetzt = new Date() } = {}) {
// synchron vor der Sperre: der zuerst gestartete Aufruf bekommt die Sperre zuerst
if (ueber !== 'link' && ueber !== 'portal') throw new TypeError('bestaetigeAuftrag: ueber muss „link“ oder „portal“ sein')
pruefeZeitpunkt(jetzt)
const { aktuell, bestaetigtJetzt } = await withLocks(sperreAuftrag(auftrag.id), async () => {
const frisch = await getAuftrag(auftrag.id)
if (!frisch) throw new AuftragError(404, NICHT_GEFUNDEN)
const lage = bestaetigungsLage(frisch)
if (lage === 'gesperrt') throw new AuftragError(409, GESPERRT_MELDUNG[frisch.status] ?? NICHT_BESTAETIGBAR)
if (ueber === 'link') {
// Die Route hat Token und Ablauf auf einem Stand ausserhalb der Sperre geprueft; eine Erinnerung (Token-Tausch) kann
// dazwischen gelaufen sein: dann gilt der alte Link nicht mehr, wie bei jedem ungueltigen Link (404)
const bis = frisch.tokenGueltigBis ? Date.parse(frisch.tokenGueltigBis) : Number.NaN
if (!frisch.tokenHash || frisch.tokenHash !== auftrag.tokenHash || !Number.isFinite(bis) || jetzt.getTime() > bis) {
throw new AuftragError(404, UNGUELTIGER_LINK)
}
}
if (lage === 'bestaetigt') return { aktuell: frisch, bestaetigtJetzt: false }
const privat = frisch.kunde?.kundenart === 'privat'
const patch = {
bestaetigtAm: jetzt.toISOString(),
bestaetigtUeber: ueber,
bestaetigtPdfSha256: frisch.pdfSha256,
fruehbeginn: privat && fruehbeginn === true,
fruehbeginnFassung: privat ? FRUEHBEGINN_FASSUNG : '',
}
if (frisch.status === 'versendet') patch.status = 'bestaetigt'
return { aktuell: await updateAuftrag(frisch.id, patch), bestaetigtJetzt: true }
})
if (bestaetigtJetzt) await sendeBestaetigtMails(aktuell)
return aktuell
}
/**
* Widerruf einer Privatperson innerhalb der Frist (V2: versendet|bestaetigt → widerrufen), unter derselben Sperre.
* Verschickt selbst keine Mails (das tut abo.js nach der Rückkehr, also nach der Freigabe der Sperre).
* Ergebnis: 'widerrufen' | 'bereits_widerrufen' | 'kein_widerrufsrecht' | 'nach_frist' | 'nicht_widerrufbar'.
*/
export async function widerrufeAuftrag(auftrag, { jetzt = new Date() } = {}) {
pruefeZeitpunkt(jetzt)
return withLocks(sperreAuftrag(auftrag.id), async () => {
const frisch = await getAuftrag(auftrag.id)
if (!frisch) throw new AuftragError(404, NICHT_GEFUNDEN)
const fristEnde = frisch.gesendetAm ? withdrawalDeadline(new Date(frisch.gesendetAm)).endsAt : null
const ergebnis = (e, a = frisch) => ({ auftrag: a, ergebnis: e, fristEnde })
if (frisch.status === 'widerrufen') return ergebnis('bereits_widerrufen')
if (frisch.kunde?.kundenart !== 'privat') return ergebnis('kein_widerrufsrecht')
if (!['versendet', 'bestaetigt'].includes(frisch.status) || fristEnde === null) return ergebnis('nicht_widerrufbar')
if (jetzt.getTime() > fristEnde.getTime()) return ergebnis('nach_frist')
return ergebnis('widerrufen', await updateAuftrag(frisch.id, { status: 'widerrufen', widerrufenAm: jetzt.toISOString() }))
})
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Fehlerklasse der Aufträge-Module und einheitliche Fehlerantwort der Routen.
* AuftragError nur für Fälle, die ein Nutzer verstehen soll (404, 409, 400 bei zu langem Text); Programmfehler
* (ungültige Kennungen oder Typen) sind TypeError und enden als 500.
*/
import { InvoiceError } from '../finance/stripeFinance.js'
export class AuftragError extends Error {
constructor(status, message) {
if (!Number.isInteger(status) || status < 400 || status > 599) {
throw new TypeError('AuftragError: Status muss eine ganze Zahl von 400 bis 599 sein.')
}
super(message)
this.name = 'AuftragError'
this.status = status
}
}
/**
* Antwortet auf einen Fehler: AuftragError und InvoiceError mit Status und Meldung, alles andere (auch Fehler mit
* Status von Appwrite oder Stripe) als 500 „Interner Fehler.“. Das Protokoll nennt nie die Meldung, nur die Art.
* InvoiceError wird nur in der Funktion benutzt, nie beim Laden: stripeFinance.js importiert ab L2 selbst Module
* dieses Verzeichnisses, so bleibt der Importzyklus harmlos.
*/
export function sendeFehler(res, was, err) {
if (err instanceof AuftragError || err instanceof InvoiceError) {
return res.status(err.status).json({ error: err.message })
}
console.error(`[auftraege] ${was} fehlgeschlagen:`, err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '')
return res.status(500).json({ error: 'Interner Fehler.' })
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Aufträge und Rechnungen: gemeinsame Konstanten (Übersicht V2).
* Gemeinsame Datei: nur L0 ändert sie (Ausnahme: die eine Zeile AGB_FASSUNG, die L7 anpassen darf).
*/
export const RECHNUNGS_ARTEN = ['frei', 'festbetrag', 'aufwand', 'anzahlung', 'schluss']
export const AB_RECHNUNGS_ARTEN = ['anzahlung', 'schluss'] // brauchen AB und Schalter
export const AUFTRAG_ARTEN = ['festpreis', 'aufwand']
export const AUFTRAG_STATUS = ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen']
export const VEREINBARTE_STATUS = ['versendet', 'bestaetigt', 'abgerechnet'] // zählen als „vereinbart“
export const AUFTRAG_WEGE = { muendlich: 'mündlich', telefon: 'telefonisch', email: 'per E-Mail', vor_ort: 'vor Ort', chat: 'im Chat' }
export const ZAHLUNGSPLAN_ARTEN = ['nach_abnahme', 'anzahlung', 'monatlich', 'frei']
export const KUNDENARTEN = ['unternehmen', 'privat']
export const STUNDENSATZ_STANDARD_CENTS = 10000
export const VIERTELSTUNDE = 15
export const TOKEN_GUELTIG_TAGE = 60
export const AGB_FASSUNG = '2026-09-30' // L7 darf diese eine Zeile anpassen
export const LIMITS = {
maxPositionen: 20, maxBeschreibung: 200, maxMenge: 999, maxEinzelpreisCents: 10_000_000,
maxSummeCents: 99_999_999, maxTitel: 200, maxZahlungsplanText: 500, maxZahlungszielTage: 60,
standardZahlungszielTage: 14, standardAnzahlungProzent: 50, maxZeitquellen: 10,
maxSchaetzungMinuten: 99_960, maxLeistungszeitraum: 140,
}
// withLocks sperrt jedes Listenelement einzeln: deshalb je Sperre GENAU EIN zusammengesetzter Schlüssel
export const SPERRE_NUMMER = ['auftraege-nummer']
export const sperreRechnung = (kundenId) => [`auftraege-rechnung:${kundenId}`] // Aufwand, Anzahlung, Schluss je Kunde
export const sperreAuftrag = (id) => [`auftraege-ab:${id}`] // jede Statusänderung einer AB (L2, L3, L5)
export const SCHALTER_AUS_MELDUNG = 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.'
+167
View File
@@ -0,0 +1,167 @@
/**
* Mails zur Auftragsbestätigung (Übersicht V4/V6, Strang L5): K31 (AB mit PDF und AGB an den Kunden, Teamkopie ohne
* Link), K32 (Eingangsbestätigung an den Kunden) und T22 (Hinweis ans Team). Der Bestätigungslink steht nur in der
* Mail an den Kunden: K31 geht ohne BCC raus, das Team bekommt eine Kopie ohne Link, Token und Anhänge.
* Kein Datenbankzugriff (außer lesePdf als Rückfall für die AB-PDF).
*/
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { config } from '../../config.js'
import { berlinDate, formatDe } from '../finance/dates.js'
import { euroDe } from '../finance/format.js'
import { sendAdminNotice, sendCustomerMail } from '../mailer.js'
import { lesePdf } from './ablage.js'
import { fruehesterBeginn } from './auftragSicht.js'
import { AGB_FASSUNG } from './konstanten.js'
import { FRUEHBEGINN_TEXT } from './texte.js'
import { hashToken } from './token.js'
// server/services/auftraege/ -> drei Ebenen nach oben = Repo
const STANDARD_AGB_VERZEICHNIS = fileURLToPath(new URL('../../../public/rechtliches/', import.meta.url))
let agbVerzeichnis = STANDARD_AGB_VERZEICHNIS
const FASSUNG_FORMAT = /^[A-Za-z0-9._-]{1,20}$/
/** Nur für Tests: anderes Verzeichnis für die AGB-PDFs (`null` = Vorgabe). */
export function setzeAgbVerzeichnisFuerTests(verzeichnis) {
agbVerzeichnis = verzeichnis || STANDARD_AGB_VERZEICHNIS
}
export function agbPdfPfad(fassung = AGB_FASSUNG) {
if (typeof fassung !== 'string' || !FASSUNG_FORMAT.test(fassung) || fassung.includes('..')) throw new TypeError('agbPdfPfad: ungültige AGB-Fassung')
return path.join(agbVerzeichnis, `agb-${fassung}.pdf`)
}
// Betreff-Zusaetze der K31 (die Vorlage selbst bleibt fuer alle Faelle gleich)
const ERINNERUNG_PRAEFIX = 'Erinnerung: '
const KOPIE_PRAEFIX = 'Kopie: '
const zeitFormat = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
function zeitpunkt(iso) {
const datum = new Date(iso)
if (!iso || Number.isNaN(datum.getTime())) throw new TypeError('Zeitpunkt fehlt oder ist ungültig')
return datum
}
const tagText = (iso) => formatDe(berlinDate(zeitpunkt(iso)))
const zeitpunktText = (iso) => `${tagText(iso)}, ${zeitFormat.format(zeitpunkt(iso))}`
const preisText = (auftrag) => (auftrag.art === 'aufwand' ? `${euroDe(auftrag.stundensatzCents)} pro Stunde` : euroDe(auftrag.summeCents))
const empfaenger = (auftrag) => String(auftrag.gesendetAn || auftrag.kunde?.email || '').trim()
export function k31Mail(auftrag, { token, erinnerung = false, teamkopie = false } = {}) {
const kunde = auftrag.kunde ?? {}
return {
template: 'K31',
values: {
name: kunde.name,
nummer: auftrag.nummer,
titel: auftrag.titel,
datum: tagText(auftrag.gesendetAm),
betrag: preisText(auftrag),
// die Teamkopie enthält den Link nie, auch wenn ein Token mitgegeben wird
link: teamkopie ? '' : `${config.publicBaseUrl}/auftrag.html#t=${token}`,
gueltig_bis: tagText(auftrag.tokenGueltigBis),
},
blocks: {
erinnerung: Boolean(erinnerung),
privat: kunde.kundenart === 'privat' && !teamkopie,
festpreis: auftrag.art === 'festpreis',
aufwand: auftrag.art === 'aufwand',
teamkopie: Boolean(teamkopie),
kundenmail: !teamkopie,
},
}
}
function frueherBeginn(auftrag) {
const privat = auftrag.kunde?.kundenart === 'privat'
const tag = fruehesterBeginn(auftrag)
return { privat, fruehbeginn: privat && Boolean(auftrag.fruehbeginn), ohne: privat && !auftrag.fruehbeginn, beginnText: tag ? formatDe(tag) : '' }
}
export function k32Mail(auftrag) {
const kunde = auftrag.kunde ?? {}
const f = frueherBeginn(auftrag)
return {
template: 'K32',
values: {
name: kunde.name,
nummer: auftrag.nummer,
titel: auftrag.titel,
zeitpunkt: zeitpunktText(auftrag.bestaetigtAm),
fruehester_beginn: f.beginnText,
fruehbeginn_text: f.fruehbeginn ? FRUEHBEGINN_TEXT : '',
},
blocks: { fruehbeginn: f.fruehbeginn, ohne_fruehbeginn: f.ohne },
}
}
const UEBER = { link: 'Bestätigungslink', portal: 'Kundenportal' }
export function t22Mail(auftrag) {
const kunde = auftrag.kunde ?? {}
const f = frueherBeginn(auftrag)
return {
template: 'T22',
values: {
kunde: kunde.firma || kunde.name || kunde.email || '',
nummer: auftrag.nummer,
titel: auftrag.titel,
zeitpunkt: zeitpunktText(auftrag.bestaetigtAm),
ueber: UEBER[auftrag.bestaetigtUeber] ?? '',
woms_link: `${config.womsBaseUrl}/customers/${auftrag.kundenId}?tab=invoices`,
fruehester_beginn: f.beginnText,
},
blocks: { privat_fruehbeginn: f.fruehbeginn, privat_ohne_fruehbeginn: f.ohne },
}
}
/**
* K31 an den Kunden (ohne BCC, mit AB-PDF und AGB), danach die Teamkopie ohne Link und Anhänge. Wirft bei jedem Fehler
* der Kundenmail (L3 setzt dann mailStatus „fehler“); eine gescheiterte Teamkopie gilt nicht als Fehler.
*/
export async function sendeAuftragMail(auftrag, { pdf, token, erinnerung = false } = {}) {
const adresse = empfaenger(auftrag)
if (!adresse) throw new Error('Keine E-Mail-Adresse für die Auftragsbestätigung')
if (hashToken(token) === null) throw new TypeError('sendeAuftragMail: token fehlt oder hat ein falsches Format')
const abPdf = Buffer.isBuffer(pdf) ? pdf : await lesePdf(auftrag.pdfDatei)
const fassung = auftrag.agbFassung || AGB_FASSUNG
let agbPdf
try {
agbPdf = await readFile(agbPdfPfad(fassung))
} catch (err) {
throw new Error(`AGB-PDF ${fassung} nicht lesbar: ${err.code || err.name}`)
}
await sendCustomerMail(adresse, {
...k31Mail(auftrag, { token, erinnerung }),
subjectPrefix: erinnerung ? ERINNERUNG_PRAEFIX : '',
attachments: [
{ filename: `${auftrag.nummer}.pdf`, content: abPdf, contentType: 'application/pdf' },
{ filename: `WEBklar-AGB-${fassung}.pdf`, content: agbPdf, contentType: 'application/pdf' },
],
bccTeam: false,
})
// Der Grund steht nicht im Protokoll: Meldungen des Mailservers nennen oft die Empfaengeradresse
const kopie = await sendAdminNotice({ ...k31Mail(auftrag, { erinnerung, teamkopie: true }), subjectPrefix: `${KOPIE_PRAEFIX}${erinnerung ? ERINNERUNG_PRAEFIX : ''}` })
if (!kopie.sent) console.error(`[auftraege] Teamkopie der Auftragsbestätigung ${auftrag.nummer} nicht gesendet`)
}
/** K32 an den Kunden (Kopie ans Team wie üblich: die Mail trägt keinen Link), danach T22 ans Team. Wirft nie. */
export async function sendeBestaetigtMails(auftrag) {
const adresse = empfaenger(auftrag)
if (!adresse) {
console.error(`[auftraege] K32 für ${auftrag.nummer} entfällt: keine E-Mail-Adresse`)
} else {
try {
await sendCustomerMail(adresse, k32Mail(auftrag))
} catch (err) {
console.error(`[auftraege] K32 für ${auftrag.nummer} fehlgeschlagen: ${err?.code || err?.name || 'Fehler'}`)
}
}
try {
const t22 = await sendAdminNotice(t22Mail(auftrag))
if (!t22.sent) console.error(`[auftraege] T22 für ${auftrag.nummer} nicht gesendet`)
} catch (err) {
console.error(`[auftraege] T22 für ${auftrag.nummer} fehlgeschlagen: ${err?.code || err?.name || 'Fehler'}`)
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Nummernvergabe der Auftragsbestätigungen: AB-JJJJ-NNNN, lückenlos je Berliner Kalenderjahr (Übersicht V4, L3).
* vergibNummer schreibt nichts und sperrt nicht selbst: der Aufrufer hält SPERRE_NUMMER (konstanten.js), und die Nummer
* ist erst mit dem Speichern der AB vergeben. Eine Nummer, die schon in Gebrauch ist, wird übersprungen.
*/
import { berlinDate } from '../finance/dates.js'
import { findAuftragByNummer, listAuftraege } from './auftragStore.js'
export function formatNummer(jahr, laufnummer) {
if (!Number.isInteger(jahr) || jahr < 2000 || jahr > 9999 || !Number.isInteger(laufnummer) || laufnummer < 1 || laufnummer > 9999) {
throw new RangeError('Ungültige Auftragsnummer.')
}
return `AB-${jahr}-${String(laufnummer).padStart(4, '0')}`
}
export async function vergibNummer(jetzt = new Date()) {
const jahr = Number(berlinDate(jetzt).slice(0, 4))
const vorhandene = await listAuftraege({ jahr })
let laufnummer = vorhandene.reduce((groesste, a) => Math.max(groesste, Number.isSafeInteger(a.laufnummer) ? a.laufnummer : 0), 0) + 1
while (await findAuftragByNummer(formatNummer(jahr, laufnummer))) laufnummer += 1
return { nummer: formatNummer(jahr, laufnummer), jahr, laufnummer }
}
+475
View File
@@ -0,0 +1,475 @@
import { readFileSync } from 'node:fs'
import * as fontkitModul from 'fontkit'
import PDFDocument from 'pdfkit'
import { euroDe } from '../finance/format.js'
import { berlinDate, formatDe } from '../finance/dates.js'
import { AuftragError } from './fehler.js'
import { AGB_FASSUNG, AUFTRAG_ARTEN, AUFTRAG_WEGE, ZAHLUNGSPLAN_ARTEN } from './konstanten.js'
import { AB_TEXTE, ABSENDER, WIDERRUFSBELEHRUNG_PROJEKT, WIDERRUFSFORMULAR } from './texte.js'
import { aufwandText, zahlungsplanText } from './auftragSicht.js'
/**
* PDF der Auftragsbestätigung (A4, pdfkit): Schriften und Zeichenabdeckung, Daten (alle Texte fertig formatiert),
* Seitenaufteilung (rein) und Zeichnen. Aufbau der Datei: 1 Schriften, 2 Daten, 3 Layout, 4 Zeichnen, 5 erzeugeAuftragPdf.
*/
// ===== 1. Schriften, Logo, Zeichenabdeckung =====
const fontkit = fontkitModul.default ?? fontkitModul
const SCHRIFT_VERZEICHNIS = new URL('../../assets/fonts/', import.meta.url)
const LOGO_DATEI = new URL('../../../public/webklar-logo.png', import.meta.url)
const SCHRIFT_DATEIEN = Object.freeze({ regular: 'Figtree-Regular.ttf', semibold: 'Figtree-SemiBold.ttf' })
let geladen = null
/** { regular: Buffer, semibold: Buffer, logo: Buffer, regularFont, semiboldFont } – bei jedem Aufruf dasselbe Objekt. */
export function ladeSchriften() {
if (!geladen) {
const regular = readFileSync(new URL(SCHRIFT_DATEIEN.regular, SCHRIFT_VERZEICHNIS))
const semibold = readFileSync(new URL(SCHRIFT_DATEIEN.semibold, SCHRIFT_VERZEICHNIS))
geladen = Object.freeze({
regular,
semibold,
logo: readFileSync(LOGO_DATEI),
regularFont: fontkit.create(regular),
semiboldFont: fontkit.create(semibold),
})
}
return geladen
}
/** Hat der Codepunkt ein Glyph in Regular UND SemiBold? */
export function hatGlyph(codePoint) {
const { regularFont, semiboldFont } = ladeSchriften()
return regularFont.hasGlyphForCodePoint(codePoint) && semiboldFont.hasGlyphForCodePoint(codePoint)
}
const ERSATZ = new Map([
[0x2010, '-'], [0x2011, '-'], [0x2012, '-'], [0x2013, '-'], [0x2014, '-'], [0x2015, '-'], [0x2212, '-'],
[0x2018, "'"], [0x2019, "'"], [0x201a, "'"], [0x2032, "'"],
[0x201c, '"'], [0x201d, '"'], [0x201e, '"'], [0x201f, '"'], [0x2033, '"'],
[0x2026, '...'], [0x20ac, 'EUR'], [0x2022, '-'], [0x00b7, '-'], [0x00d7, 'x'], [0x2192, '->'],
])
// Steuerzeichen (außer \n), weiche Trennung, Zero-Width-Zeichen, Variantenselektoren, BOM
const ENTFERNEN = /[\u{0}-\u{8}\u{B}-\u{1F}\u{7F}-\u{9F}\u{AD}\u{200B}-\u{200D}\u{2060}\u{FE00}-\u{FE0F}\u{FEFF}]/gu
/** Ersetzt Zeichen ohne Glyph, damit nie ein leeres Kästchen im PDF steht. */
export function bereinigeText(text, hatGlyphFn = hatGlyph) {
const roh = String(text ?? '')
.normalize('NFC')
.replace(/\r\n?|[\u{2028}\u{2029}]/gu, '\n')
.replace(/\t/g, ' ')
.replace(ENTFERNEN, '')
let aus = ''
for (const zeichen of roh) {
const cp = zeichen.codePointAt(0)
if (zeichen === '\n' || hatGlyphFn(cp)) aus += zeichen
else if (ERSATZ.has(cp)) aus += ERSATZ.get(cp)
else if (/\p{Zs}/u.test(zeichen)) aus += ' '
else if (/\p{M}/u.test(zeichen)) continue
else {
const basis = zeichen.normalize('NFD').codePointAt(0)
aus += basis !== cp && hatGlyphFn(basis) ? String.fromCodePoint(basis) : '?'
}
}
return aus
}
/** bereinigeText für jede Zeichenkette in beliebig verschachtelten Objekten und Listen. */
export function bereinigeTexte(wert, hatGlyphFn = hatGlyph) {
if (typeof wert === 'string') return bereinigeText(wert, hatGlyphFn)
if (Array.isArray(wert)) return wert.map((e) => bereinigeTexte(e, hatGlyphFn))
if (wert && typeof wert === 'object') return Object.fromEntries(Object.entries(wert).map(([k, v]) => [k, bereinigeTexte(v, hatGlyphFn)]))
return wert
}
// ===== 2. Daten =====
export const PDF_BESCHRIFTUNG = Object.freeze({
nummer: 'Nummer', datum: 'Datum', kundennummer: 'Kundennummer', projekt: 'Projekt',
leistung: 'Leistungsumfang', zahlung: 'Zahlung', termine: 'Termine', mitwirkung: 'Mitwirkung und Abnahme',
spalten: Object.freeze(['Pos.', 'Beschreibung', 'Menge', 'Einzelpreis', 'Summe']), gesamt: 'Gesamtpreis',
steuernummer: 'Steuernummer', telefon: 'Telefon', bank: 'Bankverbindung', kontakt: 'Kontakt', seite: 'Seite', von: 'von',
entwurf: 'ENTWURF', entwurfHinweis: 'Entwurf – Vorschau, noch keine gültige Auftragsbestätigung',
})
const fehlt = (was) => new AuftragError(409, `Entwurf unvollständig: ${was}`)
const zeilen = (text) => String(text ?? '').split('\n').map((z) => z.trim()).filter(Boolean)
const tag = (iso) => formatDe(berlinDate(new Date(iso)))
function pruefe(auftrag, vorschau) {
if (!auftrag?.kunde || typeof auftrag.kunde !== 'object') throw fehlt('Kundendaten fehlen.')
if (!vorschau && !auftrag.nummer) throw fehlt('Nummer fehlt.')
if (!String(auftrag.titel ?? '').trim()) throw fehlt('Titel fehlt.')
if (!/^\d{4}-\d{2}-\d{2}$/.test(auftrag.auftragVom ?? '')) throw fehlt('Datum der Absprache fehlt.')
if (!Object.hasOwn(AUFTRAG_WEGE, auftrag.auftragWeg)) throw fehlt('Weg der Absprache fehlt.')
if (!AUFTRAG_ARTEN.includes(auftrag.art)) throw fehlt('Art des Auftrags fehlt.')
if (!ZAHLUNGSPLAN_ARTEN.includes(auftrag.zahlungsplan?.art)) throw fehlt('Zahlungsplan fehlt.')
if (auftrag.art === 'festpreis') {
if (!Array.isArray(auftrag.positionen) || auftrag.positionen.length === 0) throw fehlt('Positionen fehlen.')
const summe = auftrag.positionen.reduce((s, p) => s + p.menge * p.einzelpreisCents, 0)
if (summe !== auftrag.summeCents) throw new AuftragError(409, 'Die Summe der Positionen stimmt nicht mit der Auftragssumme überein.')
} else if (!Number.isSafeInteger(auftrag.stundensatzCents) || auftrag.stundensatzCents <= 0) {
throw fehlt('Stundensatz fehlt.')
}
}
function empfaengerZeilen(kunde) {
const land = String(kunde.land ?? '').trim()
const ausland = land && !/^(deutschland|germany|de)$/i.test(land)
return [kunde.firma, kunde.name, kunde.strasse, [kunde.plz, kunde.ort].filter(Boolean).join(' '), ausland ? land : '']
.map((z) => String(z ?? '').trim())
.filter((z, i, alle) => z && alle.indexOf(z) === i)
}
/** Alle Texte und Zahlen des AB-PDFs, fertig formatiert (reine Funktion). */
export function auftragPdfDaten(auftrag, { settings = {}, vorschau = false, jetzt = new Date(), ersetzt = null } = {}) {
pruefe(auftrag, vorschau)
const b = PDF_BESCHRIFTUNG
const steuernummer = String(settings.steuernummer ?? '').trim()
const festpreis = auftrag.art === 'festpreis'
const privat = auftrag.kunde.kundenart === 'privat'
const termine = AB_TEXTE.termine({ start: auftrag.startAm ? formatDe(auftrag.startAm) : null, fertig: auftrag.fertigBis ? formatDe(auftrag.fertigBis) : null, privat })
const abschnitte = [
{ id: 'zahlung', ueberschrift: b.zahlung, text: zahlungsplanText(auftrag) },
...(termine ? [{ id: 'termine', ueberschrift: b.termine, text: termine }] : []),
{ id: 'mitwirkung', ueberschrift: b.mitwirkung, text: AB_TEXTE.mitwirkung },
]
return {
vorschau,
beschriftung: b,
titel: AB_TEXTE.titel,
nummer: auftrag.nummer || 'Entwurf',
datum: tag(auftrag.gesendetAm || jetzt),
kundennummer: String(auftrag.kunde.kundennummer ?? ''),
projekt: auftrag.titel.trim(),
absender: {
zeilen: [ABSENDER.name, ABSENDER.strasse, ABSENDER.plzOrt, `${b.telefon} ${ABSENDER.telefon}`, ABSENDER.email, ABSENDER.web, ...(steuernummer ? [`${b.steuernummer} ${steuernummer}`] : [])],
rueckzeile: `${ABSENDER.name} · ${ABSENDER.strasse} · ${ABSENDER.plzOrt}`,
},
empfaenger: empfaengerZeilen(auftrag.kunde),
einleitung: AB_TEXTE.einleitung({ datum: formatDe(auftrag.auftragVom), weg: AUFTRAG_WEGE[auftrag.auftragWeg] }),
leistung: festpreis
? {
art: 'festpreis',
ueberschrift: b.leistung,
positionen: auftrag.positionen.map((p, i) => ({
nr: String(i + 1), beschreibung: p.beschreibung.trim(), menge: String(p.menge),
einzelpreis: euroDe(p.einzelpreisCents), summe: euroDe(p.menge * p.einzelpreisCents),
})),
gesamt: { label: b.gesamt, betrag: euroDe(auftrag.summeCents) },
aufwandText: null,
}
: { art: 'aufwand', ueberschrift: b.leistung, positionen: [], gesamt: null, aufwandText: aufwandText(auftrag) },
kleinunternehmer: AB_TEXTE.kleinunternehmer,
abschnitte,
agb: AB_TEXTE.agb({ fassung: formatDe(auftrag.agbFassung || AGB_FASSUNG) }),
ersetzt: ersetzt ? AB_TEXTE.ersetzt({ nummer: ersetzt.nummer, datum: formatDe(ersetzt.datum) }) : null,
gruss: AB_TEXTE.gruss,
widerruf: privat ? { belehrung: WIDERRUFSBELEHRUNG_PROJEKT, formular: WIDERRUFSFORMULAR } : null,
fuss: {
bank: zeilen(settings.bankDetailsText),
kontakt: [`${ABSENDER.name} · vertreten durch ${ABSENDER.vertretenDurch}`, `${b.telefon} ${ABSENDER.telefon} · ${ABSENDER.email} · ${ABSENDER.web}`],
},
}
}
// ===== 3. Layout (rein, ohne pdfkit): Maße, Bausteine, Seitenaufteilung =====
export const SEITE = Object.freeze({ breite: 595.28, hoehe: 841.89, links: 56.7, rechts: 56.7, oben: 48, unten: 96 })
export const INHALT_BREITE = SEITE.breite - SEITE.links - SEITE.rechts // 481.88
const FARBEN = Object.freeze({ tinte: '#111111', gedaempft: '#6b6964', linie: '#e8e6e0', linieDunkel: '#c9c6bd', flaeche: '#f7f6f3', entwurf: '#b42318' })
export const SPALTEN = Object.freeze([28, 237.88, 44, 86, 86]) // Pos., Beschreibung, Menge, Einzelpreis, Summe
const ZELLEN_RAND = 6
const STIL = Object.freeze({
titel: { schrift: 'semibold', groesse: 22, lineGap: 0, farbe: FARBEN.tinte },
projekt: { schrift: 'regular', groesse: 11, lineGap: 2, farbe: FARBEN.tinte },
adresse: { schrift: 'regular', groesse: 10, lineGap: 2.5, farbe: FARBEN.tinte },
ueberschrift: { schrift: 'semibold', groesse: 11, lineGap: 0, farbe: FARBEN.tinte },
text: { schrift: 'regular', groesse: 9.5, lineGap: 2.5, farbe: FARBEN.tinte },
klein: { schrift: 'regular', groesse: 8.5, lineGap: 2, farbe: FARBEN.gedaempft },
tabellenkopf: { schrift: 'semibold', groesse: 8.5, lineGap: 0, farbe: FARBEN.gedaempft },
summe: { schrift: 'semibold', groesse: 10.5, lineGap: 0, farbe: FARBEN.tinte },
fussLabel: { schrift: 'semibold', groesse: 7.5, lineGap: 1.5, farbe: FARBEN.gedaempft },
fuss: { schrift: 'regular', groesse: 7.5, lineGap: 1.5, farbe: FARBEN.gedaempft },
})
const MASSE = Object.freeze({
kopfHoehe: 22, summeHoehe: 30, kastenRand: 8, adresseBreite: 250, infoX: 340, infoZeile: 15,
rueckY: 80, adresseY: 94, // Rücksendezeile und Anschriftfeld relativ zur Oberkante des Briefkopfs (DIN 5008, Form B)
fussHoehe: 82, folgeKopf: 40,
})
const SICHERHEIT = 1
export const BEREICHE = Object.freeze({
erste: { oben: SEITE.oben, unten: SEITE.hoehe - SEITE.unten - SICHERHEIT },
folge: { oben: SEITE.oben + MASSE.folgeKopf, unten: SEITE.hoehe - SEITE.unten - SICHERHEIT },
})
const atom = (id, typ, hoehe, extra = {}) => ({ id, typ, hoehe, abstandDavor: 0, haengtAnNaechstem: false, umbruchDavor: false, kopf: null, ...extra })
/** Alle Bausteine der AB in Zeichenreihenfolge. messe(text, stil, breite) liefert die Texthöhe in pt. */
export function baueAtome(daten, messe) {
const b = daten.beschriftung
const B = INHALT_BREITE
const atome = []
const absatz = (id, text, extra = {}, stil = STIL.text) => atome.push(atom(id, 'absatz', messe(text, stil, B), { text, stil: stil === STIL.klein ? 'klein' : 'text', abstandDavor: 6, ...extra }))
const ueberschrift = (id, text) => atome.push(atom(id, 'ueberschrift', messe(text, STIL.ueberschrift, B) + 5, { text, abstandDavor: 14, haengtAnNaechstem: true }))
// Briefkopf (nur Seite 1): Logo, Absender, Anschrift, Angaben, Titel
const infoZeilen = [[b.nummer, daten.nummer], [b.datum, daten.datum], ...(daten.kundennummer ? [[b.kundennummer, daten.kundennummer]] : [])]
const adresseHoehe = messe(daten.empfaenger.join('\n'), STIL.adresse, MASSE.adresseBreite)
const blockEnde = MASSE.adresseY + Math.max(adresseHoehe, infoZeilen.length * MASSE.infoZeile)
const titelY = blockEnde + 22
const projektY = titelY + STIL.titel.groesse * 1.2 + 6
const projektHoehe = messe(`${b.projekt}: ${daten.projekt}`, STIL.projekt, B)
atome.push(atom('briefkopf', 'briefkopf', projektY + projektHoehe + 4, { haengtAnNaechstem: true, masse: { titelY, projektY, projektHoehe, infoZeilen } }))
absatz('einleitung', daten.einleitung, { abstandDavor: 8 })
const l = daten.leistung
ueberschrift('leistung', l.ueberschrift)
if (l.art === 'festpreis') {
const kopf = atom('tabellenkopf', 'tabellenkopf', MASSE.kopfHoehe, { haengtAnNaechstem: true, spalten: b.spalten })
atome.push(kopf)
l.positionen.forEach((p, i) => {
const text = messe(p.beschreibung, STIL.text, SPALTEN[1] - 2 * ZELLEN_RAND)
const eineZeile = messe('X', STIL.text, SPALTEN[1])
atome.push(atom(`zeile-${i + 1}`, 'zeile', Math.max(text, eineZeile) + 2 * ZELLEN_RAND, { zelle: p, kopf, haengtAnNaechstem: i === l.positionen.length - 1 }))
})
atome.push(atom('summe', 'summe', MASSE.summeHoehe, { label: l.gesamt.label, betrag: l.gesamt.betrag, haengtAnNaechstem: true }))
} else {
atome.push(atom('aufwand', 'absatz', messe(l.aufwandText, STIL.text, B - 2 * MASSE.kastenRand) + 2 * MASSE.kastenRand, { text: l.aufwandText, stil: 'text', kasten: true, haengtAnNaechstem: true }))
}
absatz('kleinunternehmer', daten.kleinunternehmer, { abstandDavor: 8 }, STIL.klein)
for (const a of daten.abschnitte) {
ueberschrift(`${a.id}-kopf`, a.ueberschrift)
absatz(a.id, a.text, { abstandDavor: 0 })
}
absatz('agb', daten.agb, { abstandDavor: 12 })
if (daten.ersetzt) absatz('ersetzt', daten.ersetzt, { abstandDavor: 8 })
absatz('gruss', daten.gruss, { abstandDavor: 14 })
if (daten.widerruf) {
const { belehrung, formular } = daten.widerruf
atome.push(atom('widerruf-titel', 'ueberschrift', messe(belehrung.titel, STIL.titel, B) + 8, { text: belehrung.titel, stil: 'titel', umbruchDavor: true, haengtAnNaechstem: true }))
belehrung.abschnitte.forEach((a, i) => {
ueberschrift(`widerruf-${i + 1}-kopf`, a.ueberschrift)
a.absaetze.forEach((t, k) => absatz(`widerruf-${i + 1}-${k + 1}`, t, { abstandDavor: k === 0 ? 0 : 5 }))
})
atome.push(atom('formular-titel', 'ueberschrift', messe(formular.titel, STIL.ueberschrift, B) + 5, { text: formular.titel, abstandDavor: 20, haengtAnNaechstem: true }))
absatz('formular-einleitung', formular.einleitung, { abstandDavor: 0, haengtAnNaechstem: true }, STIL.klein)
formular.zeilen.forEach((t, i) => absatz(`formular-${i + 1}`, t, { abstandDavor: 8, haengtAnNaechstem: i < formular.zeilen.length - 1 }))
}
return atome
}
/** Teilt Bausteine auf Seiten auf (Ketten bleiben zusammen, Tabellenkopf wiederholt sich). */
export function paginiere(atome, bereiche = BEREICHE) {
const seiten = []
const folgeHoehe = bereiche.folge.unten - bereiche.folge.oben
let seite
let y
const neueSeite = () => {
const bereich = seiten.length === 0 ? bereiche.erste : bereiche.folge
seite = { nummer: seiten.length + 1, posten: [], unten: bereich.unten }
seiten.push(seite)
y = bereich.oben
}
const frisch = () => seite.posten.every((p) => p.wiederholt)
const abstand = (a) => (frisch() ? 0 : a.abstandDavor)
neueSeite()
for (let i = 0; i < atome.length; ) {
let j = i
while (j < atome.length - 1 && atome[j].haengtAnNaechstem) j += 1
const kette = atome.slice(i, j + 1)
i = j + 1
if (kette[0].umbruchDavor && !frisch()) neueSeite()
const bedarf = kette.reduce((summe, a, k) => summe + (k === 0 ? 0 : a.abstandDavor) + a.hoehe, 0)
const kopfHoehe = kette[0].kopf ? kette[0].kopf.hoehe : 0
if (!frisch() && y + abstand(kette[0]) + bedarf > seite.unten && bedarf + kopfHoehe <= folgeHoehe) neueSeite()
for (const a of kette) {
if (!frisch() && y + abstand(a) + a.hoehe > seite.unten) neueSeite()
if (seite.nummer > 1 && a.kopf && seite.posten.length === 0) {
seite.posten.push({ atom: a.kopf, y, wiederholt: true })
y += a.kopf.hoehe
}
y += abstand(a)
seite.posten.push({ atom: a, y, wiederholt: false })
y += a.hoehe
}
}
return seiten
}
// ===== 4. Zeichnen mit pdfkit =====
/** Zeichnen mit pdfkit. Alle Texte stehen fertig in `daten`; Höhen und Seiten kommen aus dem Abschnitt Layout. */
const SCHRIFT_NAMEN = { regular: 'Figtree', semibold: 'Figtree-SemiBold' }
const RECHTS = SEITE.breite - SEITE.rechts
const SPALTEN_X = SPALTEN.reduce((liste, breite) => [...liste, liste[liste.length - 1] + breite], [SEITE.links]).slice(0, SPALTEN.length)
function setzeStil(doc, stil) {
return doc.font(SCHRIFT_NAMEN[stil.schrift]).fontSize(stil.groesse).fillColor(stil.farbe)
}
function schreibe(doc, text, x, y, stil, optionen = {}) {
setzeStil(doc, stil).text(text, x, y, { lineGap: stil.lineGap, ...optionen })
}
const linie = (doc, x1, y, x2, farbe = FARBEN.linie, breite = 0.5) => {
doc.save().lineWidth(breite).strokeColor(farbe).moveTo(x1, y).lineTo(x2, y).stroke().restore()
}
function zeichneBriefkopf(doc, daten, atom, y0, assets) {
const b = daten.beschriftung
const m = atom.masse
doc.image(assets.logo, SEITE.links, y0, { width: 34 })
schreibe(doc, 'WEBklar', SEITE.links + 43, y0 + 6, { schrift: 'semibold', groesse: 20, lineGap: 0, farbe: FARBEN.tinte }, { characterSpacing: -0.4, lineBreak: false })
const [name, ...rest] = daten.absender.zeilen
const blockX = RECHTS - 190
schreibe(doc, name, blockX, y0, { ...STIL.klein, schrift: 'semibold', farbe: FARBEN.tinte }, { width: 190, align: 'right' })
schreibe(doc, rest.join('\n'), blockX, y0 + 13, STIL.klein, { width: 190, align: 'right' })
schreibe(doc, daten.absender.rueckzeile, SEITE.links, y0 + MASSE.rueckY, { ...STIL.klein, groesse: 7 }, { width: MASSE.adresseBreite, lineBreak: false, underline: true })
schreibe(doc, daten.empfaenger.join('\n'), SEITE.links, y0 + MASSE.adresseY, STIL.adresse, { width: MASSE.adresseBreite })
m.infoZeilen.forEach(([label, wert], i) => {
const zeileY = y0 + MASSE.adresseY + i * MASSE.infoZeile
schreibe(doc, label, MASSE.infoX, zeileY + 1, STIL.klein, { width: 90, lineBreak: false })
schreibe(doc, wert, MASSE.infoX + 92, zeileY, { ...STIL.text, schrift: 'semibold' }, { width: RECHTS - MASSE.infoX - 92, align: 'right' })
})
schreibe(doc, daten.titel, SEITE.links, y0 + m.titelY, STIL.titel, { width: INHALT_BREITE })
schreibe(doc, `${b.projekt}: ${daten.projekt}`, SEITE.links, y0 + m.projektY, STIL.projekt, { width: INHALT_BREITE })
}
function zeichneAtom(doc, daten, atom, y, assets) {
switch (atom.typ) {
case 'briefkopf':
return zeichneBriefkopf(doc, daten, atom, y, assets)
case 'ueberschrift':
return schreibe(doc, atom.text, SEITE.links, y, atom.stil === 'titel' ? STIL.titel : STIL.ueberschrift, { width: INHALT_BREITE })
case 'absatz': {
const stil = STIL[atom.stil]
if (atom.kasten) {
doc.save().roundedRect(SEITE.links, y, INHALT_BREITE, atom.hoehe, 4).lineWidth(0.5).fillAndStroke(FARBEN.flaeche, FARBEN.linie).restore()
return schreibe(doc, atom.text, SEITE.links + MASSE.kastenRand, y + MASSE.kastenRand, stil, { width: INHALT_BREITE - 2 * MASSE.kastenRand })
}
return schreibe(doc, atom.text, SEITE.links, y, stil, { width: INHALT_BREITE })
}
case 'tabellenkopf': {
doc.save().rect(SEITE.links, y, INHALT_BREITE, atom.hoehe).fill(FARBEN.flaeche).restore()
atom.spalten.forEach((text, i) => {
const rechts = i >= 2
schreibe(doc, text, SPALTEN_X[i] + (i === 0 ? 0 : ZELLEN_RAND), y + 7, STIL.tabellenkopf, { width: SPALTEN[i] - (i === 0 ? ZELLEN_RAND : 2 * ZELLEN_RAND), align: rechts ? 'right' : 'left', lineBreak: false })
})
return linie(doc, SEITE.links, y + atom.hoehe, RECHTS, FARBEN.linieDunkel, 0.75)
}
case 'zeile': {
const z = atom.zelle
const oben = y + ZELLEN_RAND
schreibe(doc, z.nr, SPALTEN_X[0], oben, STIL.text, { width: SPALTEN[0] - ZELLEN_RAND })
schreibe(doc, z.beschreibung, SPALTEN_X[1] + ZELLEN_RAND, oben, STIL.text, { width: SPALTEN[1] - 2 * ZELLEN_RAND })
schreibe(doc, z.menge, SPALTEN_X[2], oben, STIL.text, { width: SPALTEN[2] - ZELLEN_RAND, align: 'right' })
schreibe(doc, z.einzelpreis, SPALTEN_X[3], oben, STIL.text, { width: SPALTEN[3] - ZELLEN_RAND, align: 'right' })
schreibe(doc, z.summe, SPALTEN_X[4], oben, STIL.text, { width: SPALTEN[4] - ZELLEN_RAND, align: 'right' })
return linie(doc, SEITE.links, y + atom.hoehe, RECHTS)
}
case 'summe': {
linie(doc, SEITE.links, y, RECHTS, FARBEN.tinte, 0.75)
const labelBreite = SPALTEN_X[4] - SEITE.links - ZELLEN_RAND
schreibe(doc, atom.label, SEITE.links, y + 9, STIL.summe, { width: labelBreite, align: 'right', lineBreak: false })
return schreibe(doc, atom.betrag, SPALTEN_X[4], y + 9, STIL.summe, { width: SPALTEN[4] - ZELLEN_RAND, align: 'right', lineBreak: false })
}
default:
throw new Error(`Unbekannter Baustein: ${atom.typ}`)
}
}
function zeichneFolgeKopf(doc, daten, assets) {
const y = SEITE.oben
doc.image(assets.logo, SEITE.links, y, { width: 16 })
schreibe(doc, 'WEBklar', SEITE.links + 22, y + 2, { schrift: 'semibold', groesse: 11, lineGap: 0, farbe: FARBEN.tinte }, { characterSpacing: -0.2, lineBreak: false })
schreibe(doc, `${daten.titel} ${daten.nummer}`, RECHTS - 250, y + 4, STIL.klein, { width: 250, align: 'right', lineBreak: false })
linie(doc, SEITE.links, y + 24, RECHTS)
}
function zeichneFuss(doc, daten, nummer, gesamt) {
const b = daten.beschriftung
const y = SEITE.hoehe - MASSE.fussHoehe
linie(doc, SEITE.links, y, RECHTS)
const spalte = (x, breite, label, zeilen) => {
schreibe(doc, label, x, y + 8, STIL.fussLabel, { width: breite, lineBreak: false })
schreibe(doc, zeilen.join('\n'), x, y + 19, STIL.fuss, { width: breite, height: 46, ellipsis: true })
}
if (daten.fuss.bank.length) spalte(SEITE.links, 175, b.bank, daten.fuss.bank)
spalte(SEITE.links + 190, 225, b.kontakt, daten.fuss.kontakt)
schreibe(doc, `${b.seite} ${nummer} ${b.von} ${gesamt}`, RECHTS - 70, y + 8, STIL.fussLabel, { width: 70, align: 'right', lineBreak: false })
}
function zeichneWasserzeichen(doc, daten) {
const stil = { schrift: 'semibold', groesse: 110, lineGap: 0, farbe: FARBEN.entwurf }
setzeStil(doc, stil)
const breite = doc.widthOfString(daten.beschriftung.entwurf)
const mitteX = SEITE.breite / 2
const mitteY = SEITE.hoehe / 2
doc.save()
doc.fillOpacity(0.07)
doc.rotate(-35, { origin: [mitteX, mitteY] })
doc.text(daten.beschriftung.entwurf, mitteX - breite / 2, mitteY - (stil.groesse * 1.2) / 2, { lineBreak: false })
doc.restore()
schreibe(doc, daten.beschriftung.entwurfHinweis, SEITE.links, 22, { schrift: 'semibold', groesse: 8, lineGap: 0, farbe: FARBEN.entwurf }, { width: INHALT_BREITE, align: 'center', lineBreak: false })
}
/** Zeichnet alle Seiten; die erste Seite legt pdfkit selbst an. */
function zeichneAuftrag(doc, daten, seiten, assets) {
seiten.forEach((seite, index) => {
if (index > 0) doc.addPage()
if (daten.vorschau) zeichneWasserzeichen(doc, daten)
if (index > 0) zeichneFolgeKopf(doc, daten, assets)
for (const posten of seite.posten) zeichneAtom(doc, daten, posten.atom, posten.y, assets)
zeichneFuss(doc, daten, seite.nummer, seiten.length)
})
}
// ===== 5. Ablauf =====
async function ladeErsetzte(id) {
const { getAuftrag } = await import('./auftragStore.js')
const alt = await getAuftrag(id)
if (!alt?.nummer || !alt.gesendetAm) throw new AuftragError(409, 'Die ersetzte Auftragsbestätigung wurde nicht gefunden.')
return { nummer: alt.nummer, datum: berlinDate(new Date(alt.gesendetAm)) }
}
/** Das AB-PDF (A4) als Buffer. */
export async function erzeugeAuftragPdf(auftrag, { settings, vorschau = false, jetzt = new Date(), ersetzt } = {}) {
const alt = ersetzt !== undefined ? ersetzt : auftrag.ersetztId ? await ladeErsetzte(auftrag.ersetztId) : null
const daten = bereinigeTexte(auftragPdfDaten(auftrag, { settings, vorschau, jetzt, ersetzt: alt }))
const assets = ladeSchriften()
const doc = new PDFDocument({
size: 'A4', margins: { top: 0, bottom: 0, left: 0, right: 0 }, lang: 'de-DE', displayTitle: true,
info: {
Title: `${daten.titel} ${daten.nummer}`, Author: 'WEBklar GbR', Subject: daten.projekt, Producer: 'WEBklar Kundenportal',
CreationDate: new Date(auftrag.gesendetAm || jetzt),
},
})
doc.registerFont('Figtree', assets.regular)
doc.registerFont('Figtree-SemiBold', assets.semibold)
const teile = []
doc.on('data', (teil) => teile.push(teil))
const fertig = new Promise((resolve, reject) => {
doc.on('end', () => resolve(Buffer.concat(teile)))
doc.on('error', reject)
})
fertig.catch(() => {}) // scheitert das Zeichnen zuerst, soll das verworfene Versprechen nichts auslösen
try {
const messe = (text, stil, breite) => {
setzeStil(doc, stil)
return doc.heightOfString(text, { width: breite, lineGap: stil.lineGap })
}
const seiten = paginiere(baueAtome(daten, messe), BEREICHE)
zeichneAuftrag(doc, daten, seiten, assets)
doc.end()
} catch (fehler) {
doc.destroy()
throw fehler
}
return fertig
}
+283
View File
@@ -0,0 +1,283 @@
/**
* Rechnungszeilen je Art (rein, ohne Stripe und Appwrite): feste Positionen, Aufwand je Tag oder Kalenderwoche,
* Anzahlung und Schlussrechnung mit Minuszeilen. Betraege sind ganze Cent.
* Importzyklus (siehe Plan L2): zur Modulauswertung nichts aus fehler.js benutzen, nur innerhalb von Funktionen.
*/
import { AuftragError } from './fehler.js'
import { LIMITS } from './konstanten.js'
import { berlinDate, formatDe } from '../finance/dates.js'
import { euroDe } from '../finance/format.js'
const TAG_MS = 86_400_000
const istGanzzahlZwischen = (wert, min, max) => Number.isSafeInteger(wert) && wert >= min && wert <= max
/** Text auf hoechstens max Zeichen kuerzen (Auslassungszeichen am Ende, kein halbes Emoji). */
export function kuerzen(text, max) {
if (text.length <= max) return text
let teil = text.slice(0, max - 1)
const letzter = teil.charCodeAt(teil.length - 1)
if (letzter >= 0xd800 && letzter <= 0xdbff) teil = teil.slice(0, -1)
return `${teil.trimEnd()}…`
}
/** Minuten als Stunden mit Komma, ohne Endnullen: 135 -> "2,25", 390 -> "6,5", 60 -> "1". */
export function stundenText(minuten) {
return String(Math.round((minuten / 60) * 100) / 100).replace('.', ',')
}
/** Nur ein String YYYY-MM-DD, den es als Tag gibt. */
export function gueltigerTag(ymd) {
const treffer = typeof ymd === 'string' ? ymd.match(/^(\d{4})-(\d{2})-(\d{2})$/) : null
if (!treffer) return false
const [j, m, t] = treffer.slice(1).map(Number)
const pruef = new Date(Date.UTC(j, m - 1, t))
return pruef.getUTCFullYear() === j && pruef.getUTCMonth() === m - 1 && pruef.getUTCDate() === t
}
/** ISO-Woche samt ISO-Jahr; wirft RangeError bei ungueltigem Tag. */
export function kalenderwoche(ymd) {
if (!gueltigerTag(ymd)) throw new RangeError(`Ungültiges Datum: ${typeof ymd === 'string' ? ymd : typeof ymd}`)
const tag = new Date(`${ymd}T00:00:00Z`)
const wochentag = (tag.getUTCDay() + 6) % 7 // Montag = 0
const donnerstag = new Date(tag.getTime() + (3 - wochentag) * TAG_MS)
const jahr = donnerstag.getUTCFullYear()
const woche = Math.floor((donnerstag.getTime() - Date.UTC(jahr, 0, 1)) / TAG_MS / 7) + 1
return { jahr, woche }
}
/** "06.10.2026" | "01.10.–20.10.2026" | "28.12.2026–05.01.2027" (Halbgeviertstrich ohne Leerzeichen). */
export function leistungszeitraumText(von, bis) {
if (bis < von) throw new RangeError('Das Ende liegt vor dem Beginn.')
if (von === bis) return formatDe(von)
if (von.slice(0, 4) === bis.slice(0, 4)) return `${formatDe(von).slice(0, 6)}–${formatDe(bis)}`
return `${formatDe(von)}–${formatDe(bis)}`
}
/** Gemeinsamer Pruefer fuer feste und Zusatzpositionen (Eingabe { description, quantity, unitAmountCents }). */
function pruefeEingabePositionen(items, { leerErlaubt, praefix }) {
const leer = items === undefined || items === null
if (leer && leerErlaubt) return []
if (!Array.isArray(items) || items.length > LIMITS.maxPositionen || (items.length < 1 && !leerErlaubt)) {
throw new AuftragError(400, `Eine Rechnung braucht 1 bis ${LIMITS.maxPositionen} Positionen.`)
}
const positionen = items.map((item, index) => {
const label = `Position ${index + 1}`
const beschreibung = typeof item?.description === 'string' ? item.description.trim() : ''
if (beschreibung.length < 1 || beschreibung.length > LIMITS.maxBeschreibung) {
throw new AuftragError(400, `${label}: Die Beschreibung braucht 1 bis ${LIMITS.maxBeschreibung} Zeichen.`)
}
if (!istGanzzahlZwischen(item.quantity, 1, LIMITS.maxMenge)) {
throw new AuftragError(400, `${label}: Die Menge muss eine ganze Zahl von 1 bis ${LIMITS.maxMenge} sein.`)
}
if (!istGanzzahlZwischen(item.unitAmountCents, 1, LIMITS.maxEinzelpreisCents)) {
throw new AuftragError(400, `${label}: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`)
}
return {
schluessel: `${praefix}-${index + 1}`,
beschreibung,
menge: item.quantity,
einzelpreisCents: item.unitAmountCents,
betragCents: item.quantity * item.unitAmountCents,
}
})
if (positionen.reduce((summe, p) => summe + p.betragCents, 0) > LIMITS.maxSummeCents) {
throw new AuftragError(400, 'Rechnungssumme zu hoch.')
}
return positionen
}
/** Feste Positionen einer freien Rechnung oder eines Festbetrags: 1 bis 20, Schluessel pos-<n>. */
export function festePositionen(items) {
return pruefeEingabePositionen(items, { leerErlaubt: false, praefix: 'pos' })
}
/** Zusatzpositionen (Nachtraege einer Schlussrechnung): wie festePositionen, aber leer erlaubt, Schluessel zusatz-<n>. */
export function zusatzPositionen(items) {
return pruefeEingabePositionen(items, { leerErlaubt: true, praefix: 'zusatz' })
}
/** Prueft fertige Zeilen (auch Minuszeilen): Zahl 1 bis max, Beschreibungen, Summe; liefert die Summe in Cent. */
export function pruefePositionen(positionen, { max = LIMITS.maxPositionen } = {}) {
if (!Array.isArray(positionen) || positionen.length < 1 || positionen.length > max) {
throw new AuftragError(400, `Eine Rechnung braucht 1 bis ${max} Positionen.`)
}
positionen.forEach((p, index) => {
const laenge = typeof p.beschreibung === 'string' ? p.beschreibung.length : 0
if (laenge < 1 || laenge > LIMITS.maxBeschreibung) {
throw new AuftragError(400, `Position ${index + 1}: Die Beschreibung braucht 1 bis ${LIMITS.maxBeschreibung} Zeichen.`)
}
})
const summe = positionen.reduce((s, p) => s + p.betragCents, 0)
if (summe > LIMITS.maxSummeCents) throw new AuftragError(400, 'Rechnungssumme zu hoch.')
return summe
}
const vereinheitlicht = (text) => (typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : '')
/** Eine Zeile (Tag oder Woche) mit Standardtext oder ersetztem Text, Stunden und Satz; nie ueber 200 Zeichen. */
function aufwandZeile(einheit, satzCents, zeilentexte) {
const eigen = zeilentexte && Object.hasOwn(zeilentexte, einheit.schluessel) ? vereinheitlicht(zeilentexte[einheit.schluessel]) : ''
const standard = `${einheit.kopf}: ${einheit.texte.length ? einheit.texte.join('; ') : 'Arbeitszeit'}`
const schluss = ` – ${stundenText(einheit.minuten)} Std. à ${euroDe(satzCents)}`
const betragCents = Math.round((einheit.minuten * satzCents) / 60)
return {
schluessel: einheit.schluessel,
beschreibung: `${kuerzen(eigen || standard, LIMITS.maxBeschreibung - schluss.length)}${schluss}`,
menge: 1,
einzelpreisCents: betragCents,
betragCents,
}
}
/**
* Aufwandszeilen: eine Zeile je Tag; gibt es mehr Tage als maxZeilen, werden die aeltesten Kalenderwochen zu
* KW-Zeilen zusammengefasst. zeilentexte[schluessel] ersetzt den Text vor dem Gedankenstrich.
*/
export function aufwandPositionen(blaetter, { satzCents, zeilentexte = {}, maxZeilen = LIMITS.maxPositionen } = {}) {
if (!blaetter.length) return { positionen: [], minuten: 0, von: null, bis: null }
if (!istGanzzahlZwischen(satzCents, 1, LIMITS.maxEinzelpreisCents)) throw new AuftragError(400, 'Der Stundensatz ist ungültig.')
for (const blatt of blaetter) {
if (!gueltigerTag(blatt.datum) || !Number.isSafeInteger(blatt.minuten) || blatt.minuten <= 0) {
throw new AuftragError(400, 'Ein Arbeitsblatt hat kein gültiges Datum oder keine Zeit.')
}
}
const sortiert = [...blaetter].sort((a, b) => a.datum.localeCompare(b.datum) || String(a.id).localeCompare(String(b.id)))
const tage = []
for (const blatt of sortiert) {
let tag = tage.at(-1)
if (!tag || tag.datum !== blatt.datum) {
tag = { datum: blatt.datum, minuten: 0, texte: [] }
tage.push(tag)
}
tag.minuten += blatt.minuten
const text = vereinheitlicht(blatt.text)
if (text && !tag.texte.includes(text)) tag.texte.push(text)
}
const tagesEinheit = (tag) => ({ schluessel: tag.datum, kopf: formatDe(tag.datum), minuten: tag.minuten, texte: tag.texte })
const wochenEinheit = (woche) => {
const texte = []
for (const tag of woche.tage) for (const text of tag.texte) if (!texte.includes(text)) texte.push(text)
const ww = String(woche.woche).padStart(2, '0')
return { schluessel: `KW-${woche.jahr}-${ww}`, kopf: `KW ${ww}/${woche.jahr}`, minuten: woche.tage.reduce((s, t) => s + t.minuten, 0), texte }
}
const einheiten = []
if (tage.length <= maxZeilen) {
einheiten.push(...tage.map(tagesEinheit))
} else {
const wochen = []
for (const tag of tage) {
const kw = kalenderwoche(tag.datum)
let woche = wochen.find((w) => w.jahr === kw.jahr && w.woche === kw.woche)
if (!woche) {
woche = { ...kw, tage: [] }
wochen.push(woche)
}
woche.tage.push(tag)
}
let anzahl = tage.length
for (const woche of wochen) {
if (anzahl > maxZeilen && woche.tage.length > 1) {
anzahl -= woche.tage.length - 1
einheiten.push(wochenEinheit(woche))
} else einheiten.push(...woche.tage.map(tagesEinheit))
}
if (anzahl > maxZeilen) {
throw new AuftragError(400, `Zu viele Arbeitstage für eine Rechnung (auch nach Kalenderwochen mehr als ${maxZeilen} Zeilen). Bitte weniger Arbeitsblätter auswählen.`)
}
}
return {
positionen: einheiten.map((einheit) => aufwandZeile(einheit, satzCents, zeilentexte)),
minuten: tage.reduce((s, t) => s + t.minuten, 0),
von: tage[0].datum,
bis: tage.at(-1).datum,
}
}
/** Berliner Tag des Sendens der AB, sonst der Tag der Absprache (YYYY-MM-DD). */
export function auftragDatum(auftrag) {
if (auftrag.gesendetAm) {
const zeitpunkt = new Date(auftrag.gesendetAm)
if (!Number.isNaN(zeitpunkt.getTime())) return berlinDate(zeitpunkt)
}
return auftrag.auftragVom || ''
}
const angegeben = (wert) => wert !== undefined && wert !== null
/** Anzahlungszeile zu einer AB: Prozent oder Betrag, hoechstens Summe minus bereits Berechnetes. */
export function anzahlungPosition(auftrag, angaben = {}, bereitsBerechnetCents = 0) {
const { prozent: prozentEingabe, betragCents: betragEingabe, bezeichnung: bezeichnungEingabe = 'Anzahlung' } = angaben || {}
const bezeichnung = typeof bezeichnungEingabe === 'string' ? bezeichnungEingabe.trim() : ''
if (bezeichnung.length < 1 || bezeichnung.length > 60) throw new AuftragError(400, 'Die Bezeichnung braucht 1 bis 60 Zeichen.')
if (angegeben(prozentEingabe) && angegeben(betragEingabe)) {
throw new AuftragError(400, 'Anzahlung entweder in Prozent oder als Betrag angeben.')
}
let prozent = null
let betrag
if (angegeben(betragEingabe)) {
if (!Number.isSafeInteger(betragEingabe) || betragEingabe < 1) {
throw new AuftragError(400, 'Der Betrag muss eine ganze Zahl in Cent ab 1 sein.')
}
betrag = betragEingabe
} else {
prozent = angegeben(prozentEingabe) ? prozentEingabe
: auftrag.zahlungsplan?.art === 'anzahlung' && angegeben(auftrag.zahlungsplan.prozent) ? auftrag.zahlungsplan.prozent
: LIMITS.standardAnzahlungProzent
if (typeof prozent !== 'number' || !Number.isFinite(prozent) || prozent <= 0 || prozent > 100) {
throw new AuftragError(400, 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.')
}
betrag = Math.round((auftrag.summeCents * prozent) / 100)
if (betrag < 1) throw new AuftragError(400, 'Der Betrag muss mindestens 0,01 € betragen.')
}
const rest = auftrag.summeCents - bereitsBerechnetCents
if (rest <= 0) throw new AuftragError(409, 'Nichts mehr offen.')
if (betrag > rest) {
throw new AuftragError(400, `Die Anzahlung darf höchstens ${euroDe(rest)} betragen (Summe der Auftragsbestätigung minus bereits berechnet).`)
}
const prozentTeil = prozent === null ? '' : ` ${String(prozent).replace('.', ',')} %`
const davor = `${bezeichnung}${prozentTeil} gemäß Auftragsbestätigung ${auftrag.nummer} vom ${formatDe(auftragDatum(auftrag))}`
const titel = typeof auftrag.titel === 'string' ? auftrag.titel.trim() : ''
const beschreibung = titel ? `${davor} (${kuerzen(titel, LIMITS.maxBeschreibung - davor.length - 3)})` : davor
return { schluessel: 'anzahlung', beschreibung, menge: 1, einzelpreisCents: betrag, betragCents: betrag }
}
// Obergrenzen einer Schlussrechnung (Uebersicht, Nachtraege nach der Planrunde); alle anderen Rechnungen: LIMITS.maxPositionen
export const SCHLUSS_GRENZEN = { abPositionen: 20, zusatz: 10, abzuege: 10, zeilen: 40 }
/** Schlussrechnung: alle AB-Positionen, Nachtraege, je fruehere Rechnung eine Minuszeile. */
export function schlussPositionen(auftrag, fruehere, zusatz = []) {
const positionenAb = auftrag.positionen || []
if (positionenAb.length > SCHLUSS_GRENZEN.abPositionen) {
throw new AuftragError(400, `Die Auftragsbestätigung hat ${positionenAb.length} Positionen; eine Schlussrechnung übernimmt höchstens ${SCHLUSS_GRENZEN.abPositionen}.`)
}
if (Array.isArray(zusatz) && zusatz.length > SCHLUSS_GRENZEN.zusatz) {
throw new AuftragError(400, `Eine Schlussrechnung erlaubt höchstens ${SCHLUSS_GRENZEN.zusatz} Zusatzpositionen.`)
}
const abzuege = (fruehere || []).filter((f) => f.betragCents > 0)
if (abzuege.length > SCHLUSS_GRENZEN.abzuege) {
throw new AuftragError(400, `Die Schlussrechnung hätte ${abzuege.length} Abzüge für frühere Rechnungen; erlaubt sind höchstens ${SCHLUSS_GRENZEN.abzuege}.`)
}
const zeilen = positionenAb.map((p, i) => ({
schluessel: `ab-${i + 1}`,
beschreibung: p.beschreibung,
menge: p.menge,
einzelpreisCents: p.einzelpreisCents,
betragCents: p.menge * p.einzelpreisCents,
}))
zeilen.push(...zusatzPositionen(zusatz))
const sortiert = [...abzuege].sort((a, b) => a.datum.localeCompare(b.datum) || a.invoiceId.localeCompare(b.invoiceId))
for (const f of sortiert) {
zeilen.push({
schluessel: `abzug-${f.invoiceId}`,
beschreibung: `abzüglich ${f.art === 'anzahlung' ? 'Anzahlung' : 'Teilrechnung'}, Rechnung ${f.rechnungsnummer} vom ${formatDe(f.datum)}`,
menge: 1,
einzelpreisCents: -f.betragCents,
betragCents: -f.betragCents,
})
}
const summe = zeilen.reduce((s, p) => s + p.betragCents, 0)
if (summe > LIMITS.maxSummeCents) throw new AuftragError(400, 'Rechnungssumme zu hoch.')
if (summe <= 0) throw new AuftragError(409, 'Nichts mehr offen.')
return zeilen
}
@@ -0,0 +1,149 @@
/**
* Speicher der Projektrechnungen (Collection projektRechnungen, Übersicht V1/V3/V4). Je Stripe-Rechnung, die zu einem
* Projekt, einer AB oder einer Aufwandsabrechnung gehört, ein Dokument; Dokument-ID = Stripe-Rechnungs-ID. Aktiv
* heißt: die Rechnung zählt (ein Storno oder gelöschter Entwurf setzt aktiv auf false und gibt die Arbeitsblätter
* frei). Programmfehler (ungültige Kennungen, Typen, Filter) sind TypeError, AuftragError nur für 404/409/400.
*/
import { config } from '../../config.js'
import { createDocument, getDocument, listAllDocuments, Query, updateDocument } from '../appwriteAdmin.js'
import { AuftragError } from './fehler.js'
import { AUFTRAEGE_COLLECTIONS } from './schema.js'
// Wie STRIPE_INVOICE_ID im Bestand (/^in_\w+$/), höchstens 36 Zeichen (Grenze der Appwrite-Dokument-ID); der
// Stripe-Nachbau vergibt in_test_<n>
const RECHNUNGS_ID = /^in_\w{1,33}$/
const ARTEN = ['festbetrag', 'aufwand', 'anzahlung', 'schluss'] // frei hat nie ein Dokument
const JSON_GROESSE = AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes.find((a) => a.key === 'arbeitsblattIdsJson').size
const istText = (wert) => typeof wert === 'string'
const istNichtLeer = (wert) => istText(wert) && wert !== ''
function docToRechnung(doc) {
let ids = []
try {
const roh = JSON.parse(doc.arbeitsblattIdsJson)
if (Array.isArray(roh)) ids = roh.filter(istNichtLeer)
} catch {
// kaputtes oder leeres JSON: keine Arbeitsblätter
}
const text = (wert) => (istText(wert) ? wert : '')
const zeit = new Date(doc.erstelltAm || doc.$createdAt)
return {
invoiceId: doc.$id,
kundenId: text(doc.kundenId),
projektId: text(doc.projektId),
auftragId: text(doc.auftragId),
art: text(doc.art),
betragCents: Number.isSafeInteger(doc.betragCents) ? doc.betragCents : 0,
arbeitsblattIds: ids,
rechnungsnummer: text(doc.rechnungsnummer),
aktiv: doc.aktiv === true,
erstelltVon: text(doc.erstelltVon),
erstelltAm: Number.isNaN(zeit.getTime()) ? null : zeit.toISOString(),
}
}
/** Prüft die genannten Felder (jede Verletzung ist ein TypeError) und liefert das Dokument dazu. */
function feldDoc(daten, { vollstaendig }) {
const doc = {}
const hat = (key) => Object.hasOwn(daten, key) && daten[key] !== undefined
const text = (key, { pflicht = false } = {}) => {
if (!hat(key)) return
if (pflicht ? !istNichtLeer(daten[key]) : !istText(daten[key])) throw new TypeError(`Projektrechnung.${key} ist ungültig.`)
doc[key] = daten[key]
}
text('kundenId', { pflicht: true })
text('projektId')
text('auftragId')
text('rechnungsnummer')
text('erstelltVon')
if (hat('art')) {
if (!ARTEN.includes(daten.art)) throw new TypeError('Projektrechnung.art ist ungültig.')
doc.art = daten.art
}
if (hat('betragCents')) {
if (!Number.isSafeInteger(daten.betragCents) || daten.betragCents < 0) throw new TypeError('Projektrechnung.betragCents ist ungültig.')
doc.betragCents = daten.betragCents
}
if (hat('arbeitsblattIds')) {
if (!Array.isArray(daten.arbeitsblattIds) || !daten.arbeitsblattIds.every(istNichtLeer)) throw new TypeError('Projektrechnung.arbeitsblattIds ist ungültig.')
const json = JSON.stringify(daten.arbeitsblattIds)
if (json.length > JSON_GROESSE) throw new AuftragError(400, 'Zu viele Arbeitsblätter in einer Rechnung.')
doc.arbeitsblattIdsJson = json
}
if (hat('aktiv')) {
if (typeof daten.aktiv !== 'boolean') throw new TypeError('Projektrechnung.aktiv ist ungültig.')
doc.aktiv = daten.aktiv
}
if (vollstaendig && (!Object.hasOwn(doc, 'kundenId') || !Object.hasOwn(doc, 'art') || !Object.hasOwn(doc, 'betragCents'))) {
throw new TypeError('Projektrechnung braucht kundenId, art und betragCents.')
}
return doc
}
export async function getProjektRechnung(invoiceId) {
if (typeof invoiceId !== 'string' || !RECHNUNGS_ID.test(invoiceId)) return null
try {
return docToRechnung(await getDocument(config.collections.projektRechnungen, invoiceId))
} catch (err) {
if (err?.status === 404) return null
throw err
}
}
export async function createProjektRechnung(invoiceId, daten) {
if (typeof invoiceId !== 'string' || !RECHNUNGS_ID.test(invoiceId)) throw new TypeError('Ungültige Rechnungs-ID.')
const doc = {
projektId: '', auftragId: '', rechnungsnummer: '', erstelltVon: '', arbeitsblattIds: [], aktiv: true,
...Object.fromEntries(Object.entries(daten).filter(([, wert]) => wert !== undefined)),
}
const felder = { ...feldDoc(doc, { vollstaendig: true }), invoiceId, erstelltAm: new Date().toISOString() }
try {
return docToRechnung(await createDocument(config.collections.projektRechnungen, felder, invoiceId))
} catch (err) {
if (err?.status === 409) throw new AuftragError(409, 'Zu dieser Rechnung gibt es schon einen Eintrag.')
throw err
}
}
export async function updateProjektRechnung(invoiceId, patch) {
const nichtGefunden = () => new AuftragError(404, 'Projektrechnung nicht gefunden.')
if (typeof invoiceId !== 'string' || !RECHNUNGS_ID.test(invoiceId)) throw nichtGefunden()
const doc = feldDoc(patch, { vollstaendig: false })
try {
return docToRechnung(await updateDocument(config.collections.projektRechnungen, invoiceId, doc))
} catch (err) {
if (err?.status === 404) throw nichtGefunden()
throw err
}
}
/** Nach erstelltAm aufsteigend. Ein angegebener, aber leerer oder ungültiger Filter ist ein TypeError, nie „alle“. */
export async function listProjektRechnungen({ kundenId, projektId, auftragId, nurAktiv = true } = {}) {
const abfragen = []
for (const [key, wert] of Object.entries({ kundenId, projektId, auftragId })) {
if (wert === undefined) continue
if (!istNichtLeer(wert)) throw new TypeError(`Filter ${key} muss ein nicht leerer Text sein.`)
abfragen.push(Query.equal(key, wert))
}
if (typeof nurAktiv !== 'boolean') throw new TypeError('Filter nurAktiv muss ein Wahrheitswert sein.')
if (nurAktiv) abfragen.push(Query.equal('aktiv', true))
const docs = await listAllDocuments(config.collections.projektRechnungen, abfragen)
return docs
.map(docToRechnung)
.sort((a, b) => (a.erstelltAm || '').localeCompare(b.erstelltAm || '') || a.invoiceId.localeCompare(b.invoiceId))
}
/**
* Hängt alle Dokumente einer AB (auch stornierte) an die neue AB. Bei einem Fehler bleibt ein Teilstand, ein
* erneuter Aufruf setzt fort. Liefert die Anzahl umgehängter Dokumente.
*/
export async function umhaengen(auftragIdAlt, auftragIdNeu) {
if (!istNichtLeer(auftragIdAlt) || !istNichtLeer(auftragIdNeu)) throw new TypeError('umhaengen: beide AB-IDs müssen nicht leerer Text sein.')
if (auftragIdAlt === auftragIdNeu) return 0
const alte = await listProjektRechnungen({ auftragId: auftragIdAlt, nurAktiv: false })
for (const rechnung of alte) {
await updateDocument(config.collections.projektRechnungen, rechnung.invoiceId, { auftragId: auftragIdNeu })
}
return alte.length
}
+323
View File
@@ -0,0 +1,323 @@
import { config } from '../../config.js'
import { Query, getDocument, listDocuments } from '../appwriteAdmin.js'
import { getStripe } from '../stripeBilling.js'
import { berlinDate } from '../finance/dates.js'
import { listInvoices, mapInvoice } from '../finance/stripeFinance.js'
import { kindLabel } from '../projectKinds.js'
import { KUNDENARTEN, STUNDENSATZ_STANDARD_CENTS, VEREINBARTE_STATUS } from './konstanten.js'
import { AuftragError } from './fehler.js'
import { auftraegeAktiv } from './schalter.js'
import { auftragFuerTeam } from './auftragSicht.js'
import { listAuftraege, juengsteAufwandAb } from './auftragStore.js'
import { listProjektRechnungen } from './projektRechnungenStore.js'
import { abgerechneteArbeitsblaetter, abrechenbar, ladeArbeitsblaetter, zeitquelleFuerProjekt } from './arbeitsblaetter.js'
/**
* Projektkonto (Spec 2026-10-06, Abschnitt 4; Uebersicht V4 "L1 - Projektkonto"): reine Rechenregeln und die Ladefunktionen
* dazu. Alles hier liest nur, weder Appwrite noch Stripe werden geschrieben.
*/
// Rechnungsstatus laut mapInvoice (stripeFinance.js), die als "berechnet" zaehlen; Entwurf und storniert nie
const FINALISIERT = new Set(['offen', 'ueberfaellig', 'bezahlt', 'uneinbringlich'])
const summe = (liste, feld) => liste.reduce((s, eintrag) => s + eintrag[feld], 0)
const zeitWert = (minuten, satzCents) => Math.round((minuten * satzCents) / 60)
// Wert der Zeit = Summe der je Berliner Tag gerundeten Werte (Karte: Tag -> Minuten), genau wie die Zeilen einer Aufwandsrechnung
// (aufwandPositionen in L2: eine Zeile je Tag mit Math.round(Minuten × Satz / 60)); so laufen Konto und Rechnung nicht um Cent auseinander
const tageswert = (minutenJeTag, satzCents) => [...minutenJeTag.values()].reduce((s, minuten) => s + zeitWert(minuten, satzCents), 0)
const addiere = (karte, tag, minuten) => karte.set(tag, (karte.get(tag) ?? 0) + minuten)
/**
* Die Zahlen eines Projekts. auftraege: alle ABs des Projekts (jeder Status); rechnungen: [{ art, auftragId, invoice }] mit
* der Rechnung in mapInvoice-Form (nur aktive Projektrechnungen, deren Rechnung gefunden wurde); blaetter: Arbeitsblaetter der
* Zeitquelle (datum = Berliner Tag); abgerechnet: Map oder Set der Blatt-IDs aus aktiven Projektrechnungen (auch Entwuerfe);
* stripeOk false = die Rechnungen sind unbekannt (berechnet und bezahlt 0, offen null).
*/
export function rechneKonto({ auftraege = [], rechnungen = [], blaetter = [], abgerechnet = new Map(), stripeOk = true } = {}) {
const gueltig = auftraege.filter((auftrag) => VEREINBARTE_STATUS.includes(auftrag.status))
const festpreise = gueltig.filter((auftrag) => auftrag.art === 'festpreis')
const festpreisIds = new Set(festpreise.map((auftrag) => auftrag.id))
const festpreisCents = festpreise.length ? festpreise.reduce((s, auftrag) => s + (Number(auftrag.summeCents) || 0), 0) : null
const aufwandAb = juengsteAufwandAb(gueltig)
const aufwand = aufwandAb
? {
stundensatzCents: aufwandAb.stundensatzCents > 0 ? aufwandAb.stundensatzCents : STUNDENSATZ_STANDARD_CENTS,
schaetzungMinuten: aufwandAb.schaetzungMinuten ?? null,
}
: null
const satzCents = aufwand ? aufwand.stundensatzCents : STUNDENSATZ_STANDARD_CENTS
let berechnetCents = 0
let bezahltCents = 0
let aufFestpreisCents = 0
if (stripeOk) {
for (const { invoice, auftragId } of rechnungen) {
if (!FINALISIERT.has(invoice.status)) continue
berechnetCents += invoice.totalCents
bezahltCents += invoice.paidCents
// nur Rechnungen zu einer als vereinbart zaehlenden Festpreis-AB mindern das Offene
if (festpreisIds.has(auftragId)) aufFestpreisCents += invoice.totalCents
}
}
const offenCents = festpreisCents === null || !stripeOk ? null : Math.max(0, festpreisCents - aufFestpreisCents)
let minuten = 0
let aboMinuten = 0
let offeneMinuten = 0
const minutenJeTag = new Map() // alle abrechenbaren Blaetter (auch schon berechnete)
const offeneMinutenJeTag = new Map() // nur die offenen; ein Blatt ohne Datum (nur theoretisch) bildet einen eigenen Tag ''
for (const blatt of blaetter) {
if (abrechenbar(blatt)) {
minuten += blatt.minuten
addiere(minutenJeTag, blatt.datum, blatt.minuten)
if (!abgerechnet.has(blatt.id)) {
offeneMinuten += blatt.minuten
addiere(offeneMinutenJeTag, blatt.datum, blatt.minuten)
}
} else if (blatt.aboZeit && !blatt.kommentar) {
aboMinuten += blatt.minuten
}
}
return {
vereinbart: { festpreisCents, aufwand },
berechnetCents,
bezahltCents,
offenCents,
zeit: {
minuten,
aboMinuten,
offeneMinuten,
satzCents,
wertCents: tageswert(minutenJeTag, satzCents),
offenerWertCents: tageswert(offeneMinutenJeTag, satzCents),
},
}
}
/** Zeile "Ohne Projekt": Rechnungen in mapInvoice-Form; gezaehlt werden nur finalisierte. */
export function rechneOhneProjekt(rechnungen) {
const gezaehlt = rechnungen.filter((invoice) => FINALISIERT.has(invoice.status))
return { berechnetCents: summe(gezaehlt, 'totalCents'), bezahltCents: summe(gezaehlt, 'paidCents'), anzahl: gezaehlt.length }
}
// Einzellesen fehlender Rechnungen: wie die Leseanfragen in stripeFinance.js (15 s, eine Wiederholung); stripeFinance.js
// haelt seine Optionen privat, daher hier eine eigene Konstante
const LESE_OPTIONEN = { timeout: 15_000, maxNetworkRetries: 1 }
const EINZELN_PARALLEL = 10
// Format der Rechnungs-ID wie im Schema der Projektrechnungen (V1); die IDs des Stripe-Nachbaus (in_test_<n>) passen auch
const STRIPE_RECHNUNG = /^in_\w{1,33}$/
/** Eine Rechnung frisch von Stripe in mapInvoice-Form; null, wenn es sie nicht (mehr) gibt (404); andere Fehler fliegen. */
async function leseEinzeln(invoiceId) {
try {
const raw = await getStripe().invoices.retrieve(invoiceId, { expand: ['customer'] }, LESE_OPTIONEN)
return mapInvoice(raw, { today: berlinDate() })
} catch (err) {
if (err?.statusCode === 404 || err?.code === 'resource_missing') return null
throw err
}
}
/**
* Rechnungsdaten fuer eine Liste von Projektrechnungen: die Rechnungen der letzten 400 Tage aus listInvoices() (60-Sekunden-
* Zwischenspeicher); was dort fehlt (aelter, ueber 1000, frisch angelegt), wird einzeln gelesen, hoechstens 10 gleichzeitig.
* Scheitert Stripe (Liste oder Einzellesen ausser 404), ist ok false und alles leer; ins Protokoll kommen nur Art und Status.
*/
export async function ladeStripeRechnungen(projektRechnungen) {
try {
const alle = await listInvoices()
const nachId = new Map(alle.map((invoice) => [invoice.id, invoice]))
const fehlend = [...new Set(projektRechnungen.map((eintrag) => eintrag.invoiceId))].filter((id) => STRIPE_RECHNUNG.test(id) && !nachId.has(id))
for (let i = 0; i < fehlend.length; i += EINZELN_PARALLEL) {
const gelesen = await Promise.all(fehlend.slice(i, i + EINZELN_PARALLEL).map(leseEinzeln))
for (const invoice of gelesen) if (invoice) nachId.set(invoice.id, invoice)
}
return { ok: true, alle, nachId }
} catch (err) {
console.warn('[projektkonto] Stripe nicht lesbar:', err?.type || err?.name || 'Error', err?.statusCode ?? err?.status ?? '')
return { ok: false, alle: [], nachId: new Map() }
}
}
/** Dokument lesen; unbekannt -> AuftragError 404 "<was> nicht gefunden." */
async function ladeDokument(collection, id, was) {
try {
return await getDocument(collection, id)
} catch (err) {
if (err?.status === 404) throw new AuftragError(404, `${was} nicht gefunden.`)
throw err
}
}
const kundeAnsicht = (doc) => ({
id: doc.$id,
name: String(doc.companyName || '').trim() || String(doc.name || '').trim(),
kundenart: KUNDENARTEN.includes(doc.kundenart) ? doc.kundenart : '',
})
/** Ticket-Dokumente (fuer die Ticketnummer woid) zu den IDs, in Bloecken zu 100. */
async function ladeTickets(ids) {
const nachId = new Map()
for (let i = 0; i < ids.length; i += 100) {
const seite = await listDocuments(config.collections.workorders, [Query.equal('$id', ids.slice(i, i + 100)), Query.limit(100)])
for (const ticket of seite) nachId.set(ticket.$id, ticket)
}
return nachId
}
/**
* Tickets, Arbeitsblaetter (je Ticket gruppiert) und das Abgerechnete des Kunden fuer alle Zeitquellen auf einmal: je
* Anfrage einmal laden, egal wie viele Projekte. Ohne Ticket wird nichts abgefragt.
*/
async function ladeZeitDaten(kundenId, ticketIds) {
if (ticketIds.length === 0) return { tickets: new Map(), nachTicket: new Map(), abgerechnet: new Map() }
const [tickets, blaetter, abgerechnet] = await Promise.all([
ladeTickets(ticketIds),
ladeArbeitsblaetter(ticketIds),
abgerechneteArbeitsblaetter(kundenId),
])
const nachTicket = new Map()
for (const blatt of blaetter) {
if (!nachTicket.has(blatt.ticketId)) nachTicket.set(blatt.ticketId, [])
nachTicket.get(blatt.ticketId).push(blatt)
}
return { tickets, nachTicket, abgerechnet }
}
/** Zeitquelle des Projekts ohne Doppelte (Vereinigung der gueltigen ABs, sonst das Projektticket; siehe arbeitsblaetter.js). */
const zeitquelleVon = (projekt, abs) => [...new Set(zeitquelleFuerProjekt(projekt, abs))]
/**
* Ein Projektkonto (V3). abs: ABs des Projekts; prs: aktive Projektrechnungen des Projekts; stripeDaten: Ergebnis von
* ladeStripeRechnungen; zeit: Ergebnis von ladeZeitDaten. Liefert zusaetzlich die Rechnungen und Blaetter fuer das Detail.
*/
function baueKonto(projekt, { abs, zeitquelle, prs, stripeDaten, zeit }) {
const rechnungen = prs
.map((eintrag) => ({ art: eintrag.art, auftragId: eintrag.auftragId, invoice: stripeDaten.nachId.get(eintrag.invoiceId) }))
.filter((eintrag) => eintrag.invoice)
const blaetter = zeitquelle.flatMap((id) => zeit.nachTicket.get(id) ?? [])
return {
konto: {
projektId: projekt.$id,
name: projekt.projectName || '',
projektTyp: kindLabel(projekt.kind),
tickets: zeitquelle.map((id) => ({ id, woid: zeit.tickets.get(id)?.woid || '' })),
zeitquelle,
...rechneKonto({ auftraege: abs, rechnungen, blaetter, abgerechnet: zeit.abgerechnet, stripeOk: stripeDaten.ok }),
},
rechnungen,
blaetter,
}
}
/**
* Projektkonten eines Kunden (V4 "L1"): je sichtbarem Projekt ein Konto, dazu die Zeile "Ohne Projekt". Sichtbar sind alle
* Projekte des Kunden ausser Vorlagen; archivierte nur mit Geldbezug (aktive Projektrechnung oder AB mit Status aus
* VEREINBARTE_STATUS). Ist der AB-Schalter fuer den Kunden aus, werden ABs nicht gelesen (Vereinbart: keine AB).
*/
export async function projektkontenFuerKunde(kundenId) {
const kundeDoc = await ladeDokument(config.collections.customers, kundenId, 'Kunde')
const aktiv = auftraegeAktiv(kundeDoc)
const [projekte, auftraege, prs] = await Promise.all([
listDocuments(config.collections.websiteProjects, [Query.equal('customerId', kundenId), Query.orderDesc('$createdAt'), Query.limit(1000)]),
aktiv ? listAuftraege({ kundenId }) : [],
listProjektRechnungen({ kundenId }),
])
const stripeDaten = await ladeStripeRechnungen(prs)
const mitGeld = new Set([
...prs.map((eintrag) => eintrag.projektId),
...auftraege.filter((auftrag) => VEREINBARTE_STATUS.includes(auftrag.status)).map((auftrag) => auftrag.projektId),
])
const sichtbar = projekte.filter((projekt) => projekt.isTemplate !== true && (!projekt.archived || mitGeld.has(projekt.$id)))
const eintraege = sichtbar.map((projekt) => {
const abs = auftraege.filter((auftrag) => auftrag.projektId === projekt.$id)
return { projekt, abs, zeitquelle: zeitquelleVon(projekt, abs) }
})
const zeit = await ladeZeitDaten(kundenId, [...new Set(eintraege.flatMap((eintrag) => eintrag.zeitquelle))])
const konten = eintraege.map(
({ projekt, abs, zeitquelle }) =>
baueKonto(projekt, { abs, zeitquelle, prs: prs.filter((eintrag) => eintrag.projektId === projekt.$id), stripeDaten, zeit }).konto
)
// Ohne Projekt: finalisierte Projekt-Rechnungen des Kunden, die kein angezeigtes Konto beansprucht (keine Abo-Rechnungen)
const kandidaten = new Map()
for (const invoice of stripeDaten.alle) {
if (invoice.webklarCustomerId === kundenId && invoice.source === 'projekt') kandidaten.set(invoice.id, invoice)
}
for (const eintrag of prs) {
const invoice = stripeDaten.nachId.get(eintrag.invoiceId)
if (invoice) kandidaten.set(invoice.id, invoice)
}
const angezeigt = new Set(sichtbar.map((projekt) => projekt.$id))
const zugeordnet = new Set(prs.filter((eintrag) => angezeigt.has(eintrag.projektId)).map((eintrag) => eintrag.invoiceId))
const ohneProjekt = rechneOhneProjekt([...kandidaten.values()].filter((invoice) => !zugeordnet.has(invoice.id)))
return { auftraegeAktiv: aktiv, kunde: kundeAnsicht(kundeDoc), konten, ohneProjekt, stripeOk: stripeDaten.ok }
}
const neuesteZuerst = (a, b) => (b.createdDate ?? '').localeCompare(a.createdDate ?? '') || b.number.localeCompare(a.number) || b.id.localeCompare(a.id)
/**
* Ein Projektkonto mit seinen ABs, Rechnungen (aktive Projektrechnungen, soweit Stripe sie kennt) und den noch nicht
* abgerechneten Arbeitsblaettern (V4 "L1"). Bei Stripe-Ausfall: keine Rechnungen, stripeOk false.
*/
export async function projektkontoDetail(projektId) {
const projekt = await ladeDokument(config.collections.websiteProjects, projektId, 'Projekt')
if (!projekt.customerId) throw new AuftragError(404, 'Projekt nicht gefunden.')
const kundenId = projekt.customerId
const kundeDoc = await ladeDokument(config.collections.customers, kundenId, 'Kunde')
const aktiv = auftraegeAktiv(kundeDoc)
const [abs, prs] = await Promise.all([
aktiv ? listAuftraege({ kundenId, projektId }) : [],
listProjektRechnungen({ kundenId, projektId }),
])
const stripeDaten = await ladeStripeRechnungen(prs)
const zeitquelle = zeitquelleVon(projekt, abs)
const zeit = await ladeZeitDaten(kundenId, zeitquelle)
const { konto, rechnungen, blaetter } = baueKonto(projekt, { abs, zeitquelle, prs, stripeDaten, zeit })
return {
konto,
auftraege: abs.map((auftrag) => auftragFuerTeam(auftrag)),
rechnungen: rechnungen
.map(({ art, auftragId, invoice }) => ({
id: invoice.id,
number: invoice.number,
status: invoice.status,
createdDate: invoice.createdDate,
totalCents: invoice.totalCents,
paidCents: invoice.paidCents,
art,
auftragId,
}))
.sort(neuesteZuerst),
offeneArbeitsblaetter: blaetter
.filter((blatt) => abrechenbar(blatt) && !zeit.abgerechnet.has(blatt.id))
.sort((a, b) => a.datum.localeCompare(b.datum) || a.id.localeCompare(b.id)),
stripeOk: stripeDaten.ok,
}
}
/**
* Abzeichen "abgerechnet" an den Arbeitsblaettern eines Tickets (alle Mitarbeiter, ohne Betraege): { Blatt-ID: Rechnungsnummer
* oder 'Entwurf' }. Die Blatt-IDs kommen aus dem Ticket, die Zuordnung aus den aktiven Projektrechnungen seines Kunden.
*/
export async function abgerechnetFuerTicket(ticketId) {
let ticket
try {
ticket = await getDocument(config.collections.workorders, ticketId)
} catch (err) {
if (err?.status === 404) return {}
throw err
}
if (!ticket.customerId) return {}
const abgerechnet = await abgerechneteArbeitsblaetter(ticket.customerId)
if (abgerechnet.size === 0) return {}
const ergebnis = {}
for (const blatt of await ladeArbeitsblaetter([ticketId])) {
const eintrag = abgerechnet.get(blatt.id)
if (eintrag) ergebnis[blatt.id] = eintrag.rechnungsnummer || 'Entwurf'
}
return ergebnis
}
+418
View File
@@ -0,0 +1,418 @@
/**
* Baut aus der Eingabe einer Projektrechnung den RechnungsEntwurf (Positionen, Zusatzfelder, Metadaten, Projektdatensatz).
* Lesezugriffe: Kunde, Projekt, AB, Arbeitsblaetter und fruehere Rechnungen; geschrieben wird hier nichts (ausser der
* Selbstheilung veralteter Projektdatensaetze, siehe unten). Importiert nichts aus stripeFinance.js (Importzyklus).
*/
import { config } from '../../config.js'
import { getDocument, listAllDocuments, Query } from '../appwriteAdmin.js'
import { getStripe } from '../stripeBilling.js'
import { withLocks } from '../keyedLock.js'
import { AuftragError } from './fehler.js'
import {
RECHNUNGS_ARTEN, AB_RECHNUNGS_ARTEN, LIMITS, VEREINBARTE_STATUS, STUNDENSATZ_STANDARD_CENTS, SCHALTER_AUS_MELDUNG, sperreAuftrag,
} from './konstanten.js'
import {
festePositionen, zusatzPositionen, pruefePositionen, kuerzen, auftragDatum, aufwandPositionen, leistungszeitraumText,
anzahlungPosition, schlussPositionen, SCHLUSS_GRENZEN,
} from './positionen.js'
import { auftraegeAktiv } from './schalter.js'
import { getAuftrag, listAuftraege, updateAuftrag, juengsteAufwandAb } from './auftragStore.js'
import { getProjektRechnung, listProjektRechnungen, updateProjektRechnung } from './projektRechnungenStore.js'
import {
ladeArbeitsblaetter, ladeArbeitsblaetterNachIds, abrechenbar, abgerechneteArbeitsblaetter, offeneArbeitsblaetter, zeitquelleFuerProjekt,
} from './arbeitsblaetter.js'
import { berlinDate, formatDe } from '../finance/dates.js'
import { euroDe } from '../finance/format.js'
// Appwrite-Dokument-ID
const KENNUNG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
/** Kunde aus Appwrite; nur feste Kunden (nicht lead/lost) bekommen Rechnungen. */
export async function ladeFestenKunden(customerId) {
if (typeof customerId !== 'string' || !customerId || !KENNUNG.test(customerId)) throw new AuftragError(400, 'Kunde nicht gefunden.')
let kunde
try {
kunde = await getDocument(config.collections.customers, customerId)
} catch (err) {
if (err.status === 404) throw new AuftragError(400, 'Kunde nicht gefunden.')
throw err
}
const stufe = String(kunde.customerStatus || '').trim().toLowerCase()
if (stufe === 'lead' || stufe === 'lost') throw new AuftragError(400, 'Rechnungen nur für feste Kunden.')
return kunde
}
/**
* Projekt des Kunden; fremde und unbekannte Projekte sind nicht zu unterscheiden (404). Mit weich = true liefert jeder
* Fehler stattdessen null (nur Lesefehler von Appwrite werden weitergereicht).
*/
async function ladeProjekt(kundenId, projektId, { weich = false } = {}) {
const fehler = (status, meldung) => {
if (weich) return null
throw new AuftragError(status, meldung)
}
if (typeof projektId !== 'string' || !projektId) return fehler(400, 'Für diese Rechnungsart ist ein Projekt nötig.')
if (!KENNUNG.test(projektId)) return fehler(404, 'Projekt nicht gefunden.')
let projekt
try {
projekt = await getDocument(config.collections.websiteProjects, projektId)
} catch (err) {
if (err.status === 404) return fehler(404, 'Projekt nicht gefunden.')
throw err
}
if (projekt.customerId !== kundenId) return fehler(404, 'Projekt nicht gefunden.')
return { id: projekt.$id, name: projekt.projectName || projekt.subdomain || 'Projekt', ticketId: projekt.ticketId || '' }
}
/** AB des Kunden; fremde und unbekannte sind nicht zu unterscheiden (404). */
async function ladeAuftrag(kundenId, auftragId) {
if (typeof auftragId !== 'string' || !KENNUNG.test(auftragId)) throw new AuftragError(404, 'Auftrag nicht gefunden.')
const auftrag = await getAuftrag(auftragId)
if (!auftrag || auftrag.kundenId !== kundenId) throw new AuftragError(404, 'Auftrag nicht gefunden.')
return auftrag
}
/**
* Buchung nach einem Stripe-Erfolg: scheitert sie, wird nur protokolliert (eine festgeschriebene Rechnung scheitert nicht an
* Appwrite). Fehler mit Status 404 sind still. Wirft nie.
*/
export async function buche(was, fn) {
try {
await fn()
} catch (err) {
if (err?.status === 404) return
console.error(`[finanzen] ${was} fehlgeschlagen:`, err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '')
}
}
/** Schlussrechnung finalisiert: die AB geht von versendet oder bestaetigt auf abgerechnet. Unter der Sperre der AB. */
export async function setzeAbAbgerechnet(auftragId) {
await withLocks(sperreAuftrag(auftragId), async () => {
const ab = await getAuftrag(auftragId)
if (ab && (ab.status === 'versendet' || ab.status === 'bestaetigt')) await updateAuftrag(ab.id, { status: 'abgerechnet' })
})
}
/** Schlussrechnung weg (storniert, geloescht): die AB geht von abgerechnet zurueck. Unter der Sperre der AB. */
export async function setzeAbZurueck(auftragId) {
await withLocks(sperreAuftrag(auftragId), async () => {
const ab = await getAuftrag(auftragId)
if (ab && ab.status === 'abgerechnet') await updateAuftrag(ab.id, { status: ab.bestaetigtAm ? 'bestaetigt' : 'versendet' })
})
}
/** Projektdatensatz inaktiv setzen (Blaetter wieder frei); bei einer Schlussrechnung geht die AB zurueck. */
export async function gibProjektRechnungFrei(invoiceId) {
const doc = await updateProjektRechnung(invoiceId, { aktiv: false })
if (doc.art === 'schluss' && doc.auftragId) await setzeAbZurueck(doc.auftragId)
return doc
}
const fehltBeiStripe = (err) => err?.statusCode === 404 || err?.code === 'resource_missing'
/**
* Selbstheilung: belegt ist Map<blattId, { invoiceId, rechnungsnummer }>. Je Rechnung bei Stripe nachsehen: storniert oder
* geloescht -> Datensatz inaktiv, die Belegung gilt nicht; sonst gilt sie (mit der Nummer von Stripe, fehlende Nummer wird
* im Datensatz nachgetragen). Rueckgabe: die noch geltenden Belegungen.
*/
async function pruefeBelegungen(belegt) {
const urteile = new Map()
for (const { invoiceId } of belegt.values()) {
if (urteile.has(invoiceId)) continue
let roh
try {
roh = await getStripe().invoices.retrieve(invoiceId)
} catch (err) {
if (!fehltBeiStripe(err)) throw err
roh = null
}
if (!roh || roh.status === 'void') {
await buche('Rechnung freigeben', () => gibProjektRechnungFrei(invoiceId))
urteile.set(invoiceId, null)
continue
}
const nummer = roh.status === 'draft' ? '' : roh.number || ''
urteile.set(invoiceId, nummer)
}
const gueltig = new Map()
for (const [blattId, belegung] of belegt) {
const nummer = urteile.get(belegung.invoiceId)
if (nummer === null) continue
if (nummer && nummer !== belegung.rechnungsnummer && !gueltig.has(`nachgetragen:${belegung.invoiceId}`)) {
gueltig.set(`nachgetragen:${belegung.invoiceId}`, true)
await buche('Rechnungsnummer nachtragen', () => updateProjektRechnung(belegung.invoiceId, { rechnungsnummer: nummer }))
}
gueltig.set(blattId, { invoiceId: belegung.invoiceId, rechnungsnummer: nummer })
}
for (const schluessel of [...gueltig.keys()]) if (schluessel.startsWith('nachgetragen:')) gueltig.delete(schluessel)
return gueltig
}
function pruefeLeistungszeitraum(wert) {
if (wert === undefined || wert === null) return ''
if (typeof wert !== 'string' || wert.trim().length > LIMITS.maxLeistungszeitraum) {
throw new AuftragError(400, `Der Leistungszeitraum muss ein Text mit höchstens ${LIMITS.maxLeistungszeitraum} Zeichen sein.`)
}
return wert.trim()
}
/** Gemeinsamer Abschluss aller Arten: Grenzen, Zusatzfelder, Metadaten, Projektdatensatz. */
function schliesseAb({ kunde, art, projekt, auftrag, positionen, leistungszeitraum, arbeitsblattIds = [], createdBy }) {
const summeCents = pruefePositionen(positionen, { max: art === 'schluss' ? SCHLUSS_GRENZEN.zeilen : LIMITS.maxPositionen })
const projektId = projekt?.id ?? auftrag?.projektId ?? ''
const projektName = kuerzen(projekt?.name ?? auftrag?.titel ?? '', 140)
const customFields = []
if (art !== 'frei' && projektName) customFields.push({ name: 'Projekt', value: projektName })
if (auftrag) customFields.push({ name: 'Auftrag', value: `${auftrag.nummer} vom ${formatDe(auftragDatum(auftrag))}` })
if (leistungszeitraum) customFields.push({ name: 'Leistungszeitraum', value: leistungszeitraum })
const metadata = { webklarCustomerId: kunde.$id, quelle: 'woms', erstelltVon: String(createdBy || '').slice(0, 500), art }
if (projektId) metadata.projektId = projektId
if (projektName) metadata.projektName = projektName
if (auftrag) {
metadata.auftragId = auftrag.id
if (auftrag.nummer) metadata.auftragNummer = auftrag.nummer
}
const projektRechnung = art === 'frei' ? null : {
kundenId: kunde.$id,
projektId,
auftragId: auftrag?.id ?? '',
art,
betragCents: summeCents,
arbeitsblattIds: art === 'aufwand' ? [...arbeitsblattIds].sort() : [],
rechnungsnummer: '',
aktiv: true,
erstelltVon: String(createdBy || '').slice(0, 255),
erstelltAm: new Date().toISOString(),
}
return { kunde, art, projekt: projekt ?? null, auftrag: auftrag ?? null, positionen, summeCents, leistungszeitraum, customFields, metadata, projektRechnung }
}
/** Baut den RechnungsEntwurf einer Rechnung der Art frei, festbetrag, aufwand, anzahlung oder schluss. */
export async function baueRechnung(eingabe) {
const art = eingabe.art ?? 'frei'
if (typeof art !== 'string' || !RECHNUNGS_ARTEN.includes(art)) throw new AuftragError(400, 'Unbekannte Rechnungsart.')
const kunde = await ladeFestenKunden(eingabe.customerId)
if (AB_RECHNUNGS_ARTEN.includes(art) && !auftraegeAktiv(kunde)) throw new AuftragError(400, SCHALTER_AUS_MELDUNG)
const leistungszeitraum = pruefeLeistungszeitraum(eingabe.leistungszeitraum)
const basis = { kunde, art, leistungszeitraum, createdBy: eingabe.createdBy }
if (art === 'frei') return schliesseAb({ ...basis, positionen: festePositionen(eingabe.items) })
if (art === 'festbetrag') {
const projekt = await ladeProjekt(kunde.$id, eingabe.projektId)
return schliesseAb({ ...basis, projekt, positionen: festePositionen(eingabe.items) })
}
if (art === 'aufwand') return baueAufwand(basis, eingabe)
return baueAbRechnung(basis, eingabe)
}
const MAX_BLAETTER = 400
const KEIN_BLATT = 'Arbeitsblatt nicht gefunden.'
function pruefeZeilentexte(wert) {
if (wert === undefined || wert === null) return {}
if (typeof wert !== 'object' || Array.isArray(wert) || Object.values(wert).some((text) => typeof text !== 'string')) {
throw new AuftragError(400, 'Die Zeilentexte müssen ein Objekt mit Texten sein.')
}
return wert
}
/** Auftrag der Aufwandsrechnung: ausdruecklich genannt (muss passen) oder die juengste gueltige Aufwands-AB des Projekts. */
async function ladeAufwandAuftrag(kunde, projekt, auftragId) {
if (auftragId !== undefined && auftragId !== null && auftragId !== '') {
if (!auftraegeAktiv(kunde)) throw new AuftragError(400, SCHALTER_AUS_MELDUNG)
const auftrag = await ladeAuftrag(kunde.$id, auftragId)
if (auftrag.art !== 'aufwand') throw new AuftragError(400, 'Eine Aufwandsrechnung passt nur zu einer Aufwands-Auftragsbestätigung.')
if (!VEREINBARTE_STATUS.includes(auftrag.status)) throw new AuftragError(400, 'Die Auftragsbestätigung ist nicht mehr gültig.')
if (auftrag.projektId && auftrag.projektId !== projekt.id) throw new AuftragError(400, 'Der Auftrag gehört nicht zu diesem Projekt.')
return auftrag
}
if (!auftraegeAktiv(kunde)) return null
const liste = await listAuftraege({ kundenId: kunde.$id, projektId: projekt.id })
return juengsteAufwandAb(liste)
}
/** Ausdrueckliche Auswahl der Blaetter (Pruefung, Zugehoerigkeit zum Kunden, Doppelabrechnung mit Selbstheilung). */
async function waehleBlaetter(kunde, ids, eigeneTickets) {
if (!Array.isArray(ids) || ids.some((id) => typeof id !== 'string')) {
throw new AuftragError(400, 'Die Arbeitsblätter müssen als Liste von Kennungen übergeben werden.')
}
const eindeutig = [...new Set(ids)]
if (!eindeutig.length) throw new AuftragError(400, 'Keine Arbeitsblätter ausgewählt.')
if (eindeutig.length > MAX_BLAETTER) throw new AuftragError(400, `Zu viele Arbeitsblätter ausgewählt (höchstens ${MAX_BLAETTER}).`)
if (eindeutig.some((id) => !KENNUNG.test(id))) throw new AuftragError(400, KEIN_BLATT)
const blaetter = await ladeArbeitsblaetterNachIds(eindeutig)
if (blaetter.length !== eindeutig.length || blaetter.some((b) => !eigeneTickets.has(b.ticketId))) throw new AuftragError(400, KEIN_BLATT)
if (blaetter.some((b) => !abrechenbar(b))) {
throw new AuftragError(400, 'Mindestens ein Arbeitsblatt ist nicht abrechenbar (Abo-Zeit, Kommentar oder ohne Zeit).')
}
const belegtAlle = await abgerechneteArbeitsblaetter(kunde.$id)
const belegt = new Map(eindeutig.filter((id) => belegtAlle.has(id)).map((id) => [id, belegtAlle.get(id)]))
if (belegt.size) {
const gueltig = await pruefeBelegungen(belegt)
for (const id of eindeutig) {
const b = gueltig.get(id)
if (b) throw new AuftragError(409, `Arbeitsblatt bereits abgerechnet (${b.rechnungsnummer ? `Rechnung ${b.rechnungsnummer}` : 'Entwurf'}).`)
}
}
return blaetter
}
/** Standardauswahl: alle offenen Blaetter der Zeitquelle des Projekts; ist nichts offen, vorher Selbstheilung. */
async function standardBlaetter(kunde, projekt, eigeneTickets) {
const abs = auftraegeAktiv(kunde) ? await listAuftraege({ kundenId: kunde.$id, projektId: projekt.id }) : []
const zeitquelle = zeitquelleFuerProjekt(projekt, abs).filter((id) => eigeneTickets.has(id))
const lade = () => (zeitquelle.length ? offeneArbeitsblaetter(zeitquelle, kunde.$id) : [])
let offene = await lade()
if (!offene.length && zeitquelle.length) {
const belegtAlle = await abgerechneteArbeitsblaetter(kunde.$id)
const belegt = new Map()
for (const blatt of await ladeArbeitsblaetter(zeitquelle)) {
if (abrechenbar(blatt) && belegtAlle.has(blatt.id)) belegt.set(blatt.id, belegtAlle.get(blatt.id))
}
if (belegt.size) {
const gueltig = await pruefeBelegungen(belegt)
if (gueltig.size < belegt.size) offene = await lade()
}
}
if (!offene.length) throw new AuftragError(409, 'Keine offenen Arbeitsblätter zum Abrechnen.')
if (offene.length > MAX_BLAETTER) throw new AuftragError(400, `Zu viele Arbeitsblätter ausgewählt (höchstens ${MAX_BLAETTER}).`)
return offene
}
async function baueAufwand(basis, eingabe) {
const { kunde } = basis
const projekt = await ladeProjekt(kunde.$id, eingabe.projektId)
const auftrag = await ladeAufwandAuftrag(kunde, projekt, eingabe.auftragId)
const satzCents = auftrag?.stundensatzCents ?? STUNDENSATZ_STANDARD_CENTS
const zusatz = zusatzPositionen(eingabe.items)
const zeilentexte = pruefeZeilentexte(eingabe.zeilentexte)
const eigeneTickets = new Set(
(await listAllDocuments(config.collections.workorders, [Query.equal('customerId', kunde.$id)])).map((d) => d.$id)
)
const ausdruecklich = eingabe.arbeitsblattIds !== undefined && eingabe.arbeitsblattIds !== null
const blaetter = ausdruecklich ? await waehleBlaetter(kunde, eingabe.arbeitsblattIds, eigeneTickets) : await standardBlaetter(kunde, projekt, eigeneTickets)
const { positionen: tage, von, bis } = aufwandPositionen(blaetter, { satzCents, zeilentexte, maxZeilen: LIMITS.maxPositionen - zusatz.length })
return schliesseAb({
...basis,
projekt,
auftrag,
positionen: [...tage, ...zusatz],
leistungszeitraum: basis.leistungszeitraum || leistungszeitraumText(von, bis),
arbeitsblattIds: blaetter.map((b) => b.id),
})
}
const FINALISIERT = ['open', 'paid', 'uncollectible']
/**
* Fruehere Projektrechnungen einer AB (aktive Datensaetze) mit dem Stand bei Stripe. Selbstheilung: eine im Dashboard
* stornierte oder geloeschte Rechnung wird freigegeben (bei einer Schlussrechnung geht die AB zurueck) und uebersprungen.
*/
async function ladeFruehere(auftragId) {
const ergebnis = []
for (const doc of await listProjektRechnungen({ auftragId })) {
let roh
try {
roh = await getStripe().invoices.retrieve(doc.invoiceId)
} catch (err) {
if (!fehltBeiStripe(err)) throw err
roh = null
}
if (!roh || roh.status === 'void') {
await buche('Rechnung freigeben', () => gibProjektRechnungFrei(doc.invoiceId))
continue
}
const nummer = roh.number || ''
if (FINALISIERT.includes(roh.status) && nummer && !doc.rechnungsnummer) {
await buche('Rechnungsnummer nachtragen', () => updateProjektRechnung(doc.invoiceId, { rechnungsnummer: nummer }))
}
ergebnis.push({
invoiceId: doc.invoiceId,
art: doc.art,
status: roh.status,
rechnungsnummer: nummer,
datum: berlinDate(new Date((roh.status_transitions?.finalized_at ?? roh.created) * 1000)),
betragCents: roh.total ?? 0,
})
}
return ergebnis
}
/** Anzahlung und Schlussrechnung zu einer Festpreis-AB. */
async function baueAbRechnung(basis, eingabe) {
const { kunde, art } = basis
const { auftragId } = eingabe
if (typeof auftragId !== 'string' || !auftragId) throw new AuftragError(400, 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.')
let auftrag = await ladeAuftrag(kunde.$id, auftragId)
if (auftrag.art !== 'festpreis') throw new AuftragError(400, 'Anzahlung und Schlussrechnung gehen nur mit einer Festpreis-Auftragsbestätigung.')
if (auftrag.status === 'abgerechnet') {
await ladeFruehere(auftrag.id) // gibt eine im Dashboard stornierte Schlussrechnung frei und setzt die AB zurueck
auftrag = await ladeAuftrag(kunde.$id, auftragId)
}
if (!['versendet', 'bestaetigt'].includes(auftrag.status)) throw new AuftragError(400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.')
let projekt = null
if (typeof eingabe.projektId === 'string' && eingabe.projektId) {
projekt = await ladeProjekt(kunde.$id, eingabe.projektId)
if (auftrag.projektId && auftrag.projektId !== projekt.id) throw new AuftragError(400, 'Der Auftrag gehört nicht zu diesem Projekt.')
} else if (auftrag.projektId) {
projekt = await ladeProjekt(kunde.$id, auftrag.projektId, { weich: true })
}
const fruehere = await ladeFruehere(auftrag.id)
const entwurf = fruehere.find((f) => f.status === 'draft' && (art === 'schluss' || f.art === 'schluss'))
if (entwurf) {
throw new AuftragError(409, art === 'schluss'
? `Erst den Entwurf über ${euroDe(entwurf.betragCents)} senden oder löschen.`
: 'Zu dieser Auftragsbestätigung gibt es schon einen Entwurf der Schlussrechnung. Erst den Entwurf senden oder löschen.')
}
const finalisierte = fruehere.filter((f) => FINALISIERT.includes(f.status))
let positionen
if (art === 'anzahlung') {
const angaben = eingabe.anzahlung ?? {}
if (typeof angaben !== 'object' || Array.isArray(angaben)) throw new AuftragError(400, 'Die Angaben zur Anzahlung müssen ein Objekt sein.')
positionen = [anzahlungPosition(auftrag, angaben, finalisierte.reduce((s, f) => s + f.betragCents, 0))]
} else {
positionen = schlussPositionen(
auftrag,
finalisierte.map(({ invoiceId, rechnungsnummer, datum, betragCents, art: fArt }) => ({ invoiceId, rechnungsnummer, datum, betragCents, art: fArt })),
eingabe.items
)
}
return schliesseAb({ ...basis, projekt, auftrag, positionen })
}
const AB_UNGUELTIG = 'Die Auftragsbestätigung ist nicht mehr gültig (storniert, widerrufen, ersetzt oder schon abgerechnet). Der Entwurf wird nicht gesendet.'
/**
* Letzte Pruefung vor dem Senden eines Anzahlungs- oder Schluss-Entwurfs (Aufrufer haelt sperreRechnung des Kunden):
* die AB muss noch versendet oder bestaetigt sein, bei einer Schlussrechnung darf keine andere Schlussrechnung
* festgeschrieben sein, und bei einer Anzahlung darf die Summe aller festgeschriebenen Rechnungen zur AB plus diese die
* AB-Summe nicht uebersteigen (zwei Entwuerfe zaehlen beim Anlegen nicht mit). Sonst AuftragError 409; nichts wird
* geschrieben ausser der Selbstheilung von ladeFruehere. Die AB kommt aus dem Projektdatensatz (nach umhaengen die neue),
* ersatzweise aus den Stripe-Metadaten; ohne AB gibt es nichts zu pruefen.
*/
export async function pruefeVorSenden({ invoiceId, art, auftragIdMetadaten = '', totalCents }) {
if (!AB_RECHNUNGS_ARTEN.includes(art)) return
const auftragId = (await getProjektRechnung(invoiceId))?.auftragId || auftragIdMetadaten
if (!auftragId) return
const ab = await getAuftrag(auftragId)
if (!ab || !['versendet', 'bestaetigt'].includes(ab.status)) throw new AuftragError(409, AB_UNGUELTIG)
const andere = (await ladeFruehere(ab.id)).filter((f) => f.invoiceId !== invoiceId && FINALISIERT.includes(f.status))
if (art === 'schluss' && andere.some((f) => f.art === 'schluss')) {
throw new AuftragError(409, 'Zu dieser Auftragsbestätigung gibt es schon eine Schlussrechnung. Der Entwurf wird nicht gesendet.')
}
// Eine Schlussrechnung darf mit Nachtraegen ueber der AB-Summe liegen: die Obergrenze gilt nur fuer die Anzahlung
if (art === 'anzahlung') {
const berechnet = andere.reduce((summe, f) => summe + f.betragCents, 0)
if (berechnet + totalCents > ab.summeCents) {
throw new AuftragError(409, `Die Summe der Rechnungen würde die Auftragssumme von ${euroDe(ab.summeCents)} übersteigen (bereits berechnet ${euroDe(berechnet)}). Der Entwurf wird nicht gesendet.`)
}
}
}
/** Gibt es zur AB (aus dem Projektdatensatz der Rechnung, ersatzweise aus den Stripe-Metadaten) einen Entwurf der Schlussrechnung? */
export async function hatSchlussEntwurf({ invoiceId, auftragIdMetadaten = '' }) {
const auftragId = (await getProjektRechnung(invoiceId))?.auftragId || auftragIdMetadaten
if (!auftragId) return false
return (await ladeFruehere(auftragId)).some((f) => f.art === 'schluss' && f.status === 'draft')
}
+21
View File
@@ -0,0 +1,21 @@
import { config } from '../../config.js'
/**
* Pilot-Schalter der Aufträge (AUFTRAEGE_ENABLED = off (Vorgabe) | pilot | on). Betrifft nur die AB-Funktionen und
* die Rechnungsarten anzahlung/schluss; Projektkonto und die Arten frei, festbetrag, aufwand laufen immer.
* Liest config.auftraege bei jedem Aufruf.
*/
/**
* Darf dieser Kunde Auftragsbestätigungen nutzen? `kunde`: Kundendokument oder { email }. on = alle; pilot = Liste
* enthält '*' oder die (klein geschriebene, getrimmte) E-Mail-Adresse steht in AUFTRAEGE_PILOT_EMAILS (eine leere
* Liste lässt niemanden durch); alles andere, auch ein unbekannter Modus, gilt als off.
*/
export function auftraegeAktiv(kunde) {
const { mode, pilotEmails } = config.auftraege
if (mode === 'on') return true
if (mode !== 'pilot') return false
if (pilotEmails.includes('*')) return true
const adresse = String(kunde?.email || '').trim().toLowerCase()
return adresse !== '' && pilotEmails.includes(adresse)
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Aufträge und Rechnungen: Appwrite-Schema der Collections auftraege und projektRechnungen sowie der Zusatzfelder
* (Übersicht V1). Reine Daten, absichtlich ohne Import von server/config.js: test/support/schema.js liest sie vor
* useTestEnv(), ein früh geladenes config.js würde den Endpunkt des Appwrite-Nachbaus verfehlen. Die Collection-IDs
* stehen in config.collections.<Name>; scripts/auftraege-setup.mjs legt alles an.
*
* Attribut: { key, type: 'string' | 'integer' | 'boolean' | 'datetime', size (nur Text), required: false }
* Index: { key, type: 'key', attributes: [...] }
* Datumswerte sind UTC (toISOString()); Appwrite liefert sie als "+00:00" zurück, daher immer mit new Date() lesen.
*/
const str = (key, size) => ({ key, type: 'string', size, required: false })
const int = (key) => ({ key, type: 'integer', required: false })
const bool = (key) => ({ key, type: 'boolean', required: false })
const dt = (key) => ({ key, type: 'datetime', required: false })
const index = (key, ...attributes) => ({ key, type: 'key', attributes })
export const AUFTRAEGE_COLLECTIONS = {
auftraege: {
attributes: [
str('nummer', 20), int('jahr'), int('laufnummer'), str('status', 20),
str('kundenId', 64), str('kundeJson', 2000), str('projektId', 64), str('titel', 200),
str('zeitquelleJson', 1000), str('art', 20), str('positionenJson', 20000),
int('stundensatzCents'), int('schaetzungMinuten'), int('summeCents'),
str('zahlungsplanJson', 1000), int('zahlungszielTage'),
str('auftragVom', 10), str('auftragWeg', 20), str('startAm', 10), str('fertigBis', 10),
str('texteFassung', 20), str('agbFassung', 20), str('pdfDatei', 300), str('pdfSha256', 64),
dt('gesendetAm'), str('gesendetAn', 255), str('mailStatus', 20), str('mailFehler', 500),
str('tokenHash', 64), dt('tokenGueltigBis'),
dt('bestaetigtAm'), str('bestaetigtUeber', 10), str('bestaetigtPdfSha256', 64),
bool('fruehbeginn'), str('fruehbeginnFassung', 20),
str('ersetztId', 64), str('ersetztDurchId', 64), dt('storniertAm'), dt('widerrufenAm'),
str('erstelltVon', 255), dt('erstelltAm'), dt('geaendertAm'),
],
// idx_nummer bewusst kein Unique-Index: Entwürfe haben nummer ''. Die Eindeutigkeit stellt vergibNummer unter
// SPERRE_NUMMER her (L3).
indexes: [index('idx_kunde', 'kundenId'), index('idx_projekt', 'projektId'), index('idx_jahr', 'jahr'),
index('idx_token', 'tokenHash'), index('idx_nummer', 'nummer')],
},
// Dokument-ID = Stripe-Rechnungs-ID (muss /^in_\w{1,33}$/ erfüllen; der Stripe-Nachbau vergibt in_test_<n>)
projektRechnungen: {
attributes: [
str('invoiceId', 64), str('kundenId', 64), str('projektId', 64), str('auftragId', 64), str('art', 20),
int('betragCents'), str('arbeitsblattIdsJson', 20000), str('rechnungsnummer', 64), bool('aktiv'),
str('erstelltVon', 255), dt('erstelltAm'),
],
indexes: [index('idx_kunde', 'kundenId'), index('idx_projekt', 'projektId'), index('idx_auftrag', 'auftragId')],
},
}
// Zusätzliche Felder bestehender Collections (Schlüssel = Name in config.collections)
export const AUFTRAEGE_ZUSATZ_ATTRIBUTE = {
customers: [str('kundenart', 20)], // '' | 'unternehmen' | 'privat'
financeSettings: [str('steuernummer', 40)],
}
// Datumsfelder je Collection, für startAppwriteMock({ datetimes }) (Appwrite liefert sie mit "+00:00")
export const AUFTRAEGE_DATETIMES = Object.fromEntries(
Object.entries(AUFTRAEGE_COLLECTIONS).map(([name, { attributes }]) => [name, attributes.filter((a) => a.type === 'datetime').map((a) => a.key)]),
)
+176
View File
@@ -0,0 +1,176 @@
/**
* Wortlaute der Auftragsbestätigung (AB), der Bestätigung durch den Kunden und der Texte für Privatpersonen (Frühbeginn,
* Widerrufsbelehrung, Muster-Widerrufsformular). Reine Konstanten und Funktionen ohne Zugriff auf Appwrite, Stripe oder die
* Konfiguration. Endfassung der Rechtsrunde vom 06.10.2026 (Strang L7); die Zusammenstellung zur Freigabe liegt in
* /root/reports/auftraege-rechnungen-2026-10-06/rechtsrunde.md.
*
* Alle Kundentexte stehen in der Sie-Form und ohne Gedankenstriche im Fließtext. Widerrufsbelehrung und Muster-Widerrufsformular sind
* die Muster der Anlagen 1 und 2 zu Art. 246a § 1 Abs. 2 Satz 2 EGBGB für einen Dienstleistungsvertrag (Quellen:
* docs/rechtliches/quellen/anlage-1.txt und anlage-2.txt) und gleich der Belehrung für die Bestellung einer Funktion
* (widerrufsbelehrungFunktion in services/termine/rechtstexte.js); test/auftraegeRechtstexte.test.js prüft beides.
* Die Frühbeginn-Erklärung folgt den Erklärungen der Funktionen: § 356 Abs. 5 Nr. 2 BGB (Erlöschen), § 357a Abs. 2 BGB (Wertersatz).
*
* Ändert sich ein Wortlaut nach der Auslieferung, bekommt TEXTE_FASSUNG ein neues Datum. Die Fassung steht im Nachweis jeder
* Auftragsbestätigung (texteFassung); die Dateien früherer Fassungen unter public/rechtliches/ bleiben unverändert.
*/
import { AUFTRAG_WEGE, LIMITS } from './konstanten.js'
export const TEXTE_FASSUNG = '2026-10-06'
export const ABSENDER = { name: 'WEBklar GbR', strasse: 'Am Schwimmbad 10', plzOrt: '67722 Winnweiler',
telefon: '0176 23726355', email: 'support@webklar.com', web: 'webklar.com', vertretenDurch: 'Kenso Grimm und Justin Klein' }
// ---------------------------------------------------------------------------------------------------------- Hilfen
/** Pflichtwert: fehlt er, entsteht ein Fehler statt eines Textes mit „undefined“ (eine Auftragsbestätigung darf nie unvollständig entstehen). */
function pflicht(wert, name) {
if (wert === undefined || wert === null || String(wert).trim() === '') throw new TypeError(`Text der Auftragsbestätigung: ${name} fehlt.`)
return String(wert).trim()
}
/** Klartext der Absprache: der Schlüssel aus AUFTRAG_WEGE („telefon“) wird zu „telefonisch“, ein fertiger Klartext bleibt, ohne Angabe entfällt er. */
function wegText(weg) {
const w = typeof weg === 'string' ? weg.trim() : ''
if (w === '') return ''
return Object.hasOwn(AUFTRAG_WEGE, w) ? AUFTRAG_WEGE[w] : w
}
/** Zahlungsziel als Satzteil: 0 = sofort, sonst „innerhalb von n Tagen nach Rechnungsdatum“; fehlend oder ungültig = Vorgabe (14 Tage). */
function zielText(ziel) {
const n = typeof ziel === 'number' ? ziel : Number.parseInt(String(ziel), 10)
const tage = Number.isInteger(n) && n >= 0 ? n : LIMITS.standardZahlungszielTage
if (tage === 0) return 'sofort nach Rechnungsstellung'
return `innerhalb von ${tage} ${tage === 1 ? 'Tag' : 'Tagen'} nach Rechnungsdatum`
}
/** Zahl mit Dezimalkomma (33.5 wird „33,5“). */
const zahl = (wert) => String(wert).replace('.', ',')
const PRIVAT_BEGINN = 'Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.'
// ---------------------------------------------------------------------------------------------------------- Auftragsbestätigung
export const AB_TEXTE = {
titel: 'Auftragsbestätigung',
einleitung: ({ datum, weg } = {}) => {
const art = wegText(weg)
return `Vielen Dank für Ihren Auftrag vom ${pflicht(datum, 'datum')}${art ? ` (${art})` : ''}. Wir bestätigen ihn wie folgt:`
},
aufwand: ({ satz, schaetzung } = {}) => {
const saetze = [
`Wir rechnen nach Aufwand ab: ${pflicht(satz, 'satz')} pro Stunde, erfasst je Arbeitsschritt in Schritten von 15 Minuten; angefangene 15 Minuten werden aufgerundet.`,
'Wirkt eine weitere Person unterstützend mit, berechnen wir für sie die Hälfte der Arbeitszeit (AGB Nr. 9).',
]
if (schaetzung !== undefined && schaetzung !== null && String(schaetzung).trim() !== '') {
const stunden = String(schaetzung).trim()
saetze.push(`Der geschätzte Umfang beträgt etwa ${stunden} ${stunden === '1' ? 'Stunde' : 'Stunden'}; die Schätzung ist unverbindlich.`)
saetze.push('Zeichnet sich eine deutliche Überschreitung ab, melden wir uns vorher.')
}
return saetze.join(' ')
},
kleinunternehmer: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.',
zahlungsplan: {
nach_abnahme: ({ ziel } = {}) => `Wir stellen die Vergütung nach Abnahme in Rechnung; zahlbar ${zielText(ziel)}.`,
anzahlung: ({ prozent, anzahlung, rest, ziel } = {}) =>
`Anzahlung ${zahl(pflicht(prozent, 'prozent'))} % (${pflicht(anzahlung, 'anzahlung')}) nach Auftragsbestätigung, `
+ `Rest (${pflicht(rest, 'rest')}) nach Abnahme; zahlbar jeweils ${zielText(ziel)}.`,
monatlich: ({ ziel } = {}) => `Wir stellen den angefallenen Aufwand monatlich in Rechnung; zahlbar ${zielText(ziel)}.`,
frei: ({ text, ziel } = {}) => {
const t = pflicht(text, 'text')
return `${t}${/[.!?]$/.test(t) ? '' : '.'} Zahlbar jeweils ${zielText(ziel)}.`
},
},
termine: ({ start, fertig, privat = false } = {}) => {
const teile = []
if (start && fertig) teile.push(`Beginn: ${start}; voraussichtliche Fertigstellung: ${fertig}.`)
else if (start) teile.push(`Beginn: ${start}.`)
else if (fertig) teile.push(`Voraussichtliche Fertigstellung: ${fertig}.`)
if (privat) teile.push(PRIVAT_BEGINN)
return teile.length > 0 ? teile.join(' ') : null
},
mitwirkung:
'Mitwirkung und Abnahme richten sich nach Nr. 7 und Nr. 8 unserer Allgemeinen Geschäftsbedingungen. '
+ 'Bitte stellen Sie uns die benötigten Inhalte, Daten und Freigaben rechtzeitig bereit.',
agb: ({ fassung } = {}) =>
`Es gelten unsere Allgemeinen Geschäftsbedingungen in der Fassung vom ${pflicht(fassung, 'fassung')}, die dieser Bestätigung beiliegen.`,
ersetzt: ({ nummer, datum } = {}) => `Diese Auftragsbestätigung ersetzt ${pflicht(nummer, 'nummer')} vom ${pflicht(datum, 'datum')}.`,
gruss: 'Mit freundlichen Grüßen',
}
// ---------------------------------------------------------------------------------------------------------- Bestätigung und Frühbeginn
/** Häkchen auf der Bestätigungsseite und im Kundenportal (Wortlaut der Spezifikation; Alternative mit Bezug auf die AGB: Entscheidung E2 im Freigabe-Dokument). */
export const BESTAETIGUNG_TEXT = 'Ich bestätige den Auftrag zu diesen Bedingungen.'
/** Fassung der Frühbeginn-Erklärung; steht im Nachweis der Bestätigung (fruehbeginnFassung). */
export const FRUEHBEGINN_FASSUNG = '2026-10-06'
/** Zweites, freiwilliges Häkchen für Privatpersonen: Verlangen (§ 357a Abs. 2 Nr. 1 BGB), Zustimmung und Kenntnis (§ 356 Abs. 5 Nr. 2 BGB). */
export const FRUEHBEGINN_TEXT =
'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Ausführung dieses Projektauftrags beginnt. '
+ 'Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). '
+ 'Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).'
// ---------------------------------------------------------------------------------------------------------- Widerrufsbelehrung und Formular
const KONTAKT = `${ABSENDER.name}, ${ABSENDER.strasse}, ${ABSENDER.plzOrt}, Telefon: ${ABSENDER.telefon}, E-Mail: ${ABSENDER.email}`
/** Muster der Anlage 1 für einen Dienstleistungsvertrag; Lücken: Frist „des Vertragsabschlusses.“, Kontaktangaben, Widerrufsfunktion, Wertersatz. */
export const WIDERRUFSBELEHRUNG_PROJEKT = {
titel: 'Widerrufsbelehrung',
abschnitte: [
{
ueberschrift: 'Widerrufsrecht',
absaetze: [
'Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.',
'Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.',
`Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (${KONTAKT}) mittels einer eindeutigen Erklärung `
+ '(z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. '
+ 'Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.',
'Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben. '
+ 'Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) '
+ 'unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.',
'Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.',
],
},
{
ueberschrift: 'Folgen des Widerrufs',
absaetze: [
'Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten '
+ '(mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, '
+ 'günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, '
+ 'an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. '
+ 'Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, '
+ 'es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.',
'Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, '
+ 'der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, '
+ 'bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht.',
],
},
],
}
/** Muster der Anlage 2; „–“ ist das Aufzählungszeichen des Musters, die letzte Zeile gehört zum Muster. */
export const WIDERRUFSFORMULAR = {
titel: 'Muster-Widerrufsformular',
einleitung: '(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)',
zeilen: [
`– An ${ABSENDER.name}, ${ABSENDER.strasse}, ${ABSENDER.plzOrt}, E-Mail: ${ABSENDER.email}:`,
'– Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)',
'– Bestellt am (*)/erhalten am (*)',
'– Name des/der Verbraucher(s)',
'– Anschrift des/der Verbraucher(s)',
'– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)',
'– Datum',
'(*) Unzutreffendes streichen.',
],
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Bestätigungs-Token einer Auftragsbestätigung (Übersicht V4 „L5“): 32 Zufallsbytes als base64url (43 Zeichen),
* gespeichert wird nur der SHA-256-Hash in Hex. Gültig 60 Tage ab dem letzten Versand.
* Importiert bewusst nur node:crypto und konstanten.js (nie server/config.js).
*/
import { createHash, randomBytes } from 'node:crypto'
import { TOKEN_GUELTIG_TAGE } from './konstanten.js'
const TAG_MS = 86_400_000
const TOKEN_FORMAT = /^[A-Za-z0-9_-]{43}$/
const sha256Hex = (text) => createHash('sha256').update(text).digest('hex')
export function neuerToken(jetzt = new Date()) {
if (!(jetzt instanceof Date) || !Number.isFinite(jetzt.getTime())) throw new TypeError('neuerToken: jetzt muss ein gültiges Date sein')
const token = randomBytes(32).toString('base64url')
return { token, hash: sha256Hex(token), gueltigBis: new Date(jetzt.getTime() + TOKEN_GUELTIG_TAGE * TAG_MS) }
}
/** Hash eines Tokens aus einem Link; `null`, wenn der Text nicht das Format eines Tokens hat. */
export function hashToken(token) {
return typeof token === 'string' && TOKEN_FORMAT.test(token) ? sha256Hex(token) : null
}
+3 -1
View File
@@ -59,7 +59,8 @@ const visibleToCustomer = (raw) => ISSUED.has(raw.status) && (raw.total ?? 0) >
/**
* Stripe-Rechnung in der Form des Kunden-Tabs (wie sanitizeInvoice fuer InvoiceNinja, Betraege in Euro). Offen sind
* "open" und "uncollectible" (Stripe nimmt die Zahlung weiter an); ueberfaellig, wenn die Faelligkeit vor dem
* Berliner Tag today (YYYY-MM-DD) liegt.
* Berliner Tag today (YYYY-MM-DD) liegt. `projekt` ist der Projektname aus den Stripe-Metadaten (Projektrechnungen aus dem
* WOMS), sonst leer.
*/
export function customerStripeInvoice(raw, { today = berlinDate() } = {}) {
const outstanding = raw.status === 'open' || raw.status === 'uncollectible'
@@ -83,6 +84,7 @@ export function customerStripeInvoice(raw, { today = berlinDate() } = {}) {
overdue,
portalUrl: raw.hosted_invoice_url || '',
hasPdf: Boolean(raw.invoice_pdf),
projekt: raw.metadata?.projektName || '',
}
}
+13 -1
View File
@@ -21,6 +21,7 @@ const SETTINGS_ID = 'main'
// Dokument-IDs wie in Appwrite (hoechstens 36 Zeichen, auch eine UUID); alles andere kann es nicht geben
const DOCUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
const MAX_BANK_DETAILS = 500
const MAX_STEUERNUMMER = 40
const MAX_NAME = 120
const MAX_MATCH = 120
const MAX_BUFFER_CENTS = 100_000_000
@@ -35,6 +36,7 @@ export const DEFAULT_SETTINGS = Object.freeze({
previousYearRevenueCents: null,
estPrepaymentCents: 0,
bankDetailsText: '',
steuernummer: '',
})
const invalid = (field, message) => new SettingsError(400, `${field}: ${message}`)
@@ -74,6 +76,15 @@ const SETTINGS_RULES = {
wholeNumber('estPrepaymentCents', v, 0, Number.MAX_SAFE_INTEGER, 'Die ESt-Vorauszahlung muss eine ganze Zahl ab 0 Cent sein.'),
bankDetailsText: (v) =>
text('bankDetailsText', v, { min: 0, max: MAX_BANK_DETAILS }, `Der Bankverbindungstext muss ein Text mit höchstens ${MAX_BANK_DETAILS} Zeichen sein.`),
// Steuernummer oder USt-IdNr. für die Auftragsbestätigung: Buchstaben A-Z, Ziffern, Leerzeichen, / - und . (kein
// Tabulator, kein Zeilenumbruch, keine Umlaute); '' löscht den Wert. Die Meldung ohne Feldnamen (sie erscheint im Formular).
steuernummer: (v) => {
const trimmed = typeof v === 'string' ? v.trim() : null
if (trimmed === null || trimmed.length > MAX_STEUERNUMMER || !/^[A-Za-z0-9/ .-]*$/.test(trimmed)) {
throw new SettingsError(400, 'Steuernummer ungültig.')
}
return trimmed
},
}
const FIXED_COST_RULES = {
@@ -154,7 +165,8 @@ async function saveSettings(data) {
/**
* Einstellungen aendern; patch nennt nur die zu aendernden Felder: minBufferCents 0 bis 100.000.000,
* taxReservePercent 0 bis 60, previousYearRevenueCents >= 0 oder null (null loescht den Wert), estPrepaymentCents >= 0,
* bankDetailsText hoechstens 500 Zeichen (ohne Rand-Leerraum gespeichert). Ein unbekanntes oder ungueltiges Feld:
* bankDetailsText hoechstens 500 Zeichen (ohne Rand-Leerraum gespeichert), steuernummer hoechstens 40 Zeichen aus
* Buchstaben, Ziffern, Leerzeichen, / - und . (getrimmt gespeichert, '' loescht). Ein unbekanntes oder ungueltiges Feld:
* SettingsError 400 mit dem Feldnamen, es wird nichts gespeichert. Legt das Dokument an, falls es fehlt; ein leerer
* patch aendert und legt nichts an. Liefert die Einstellungen danach.
*/
+128 -78
View File
@@ -1,8 +1,11 @@
import { createHash } from 'node:crypto'
import { config } from '../../config.js'
import { getStripe, getOrCreateStripeCustomer, stripeCustomerFields, subscriptionIdOfInvoice } from '../stripeBilling.js'
import { getDocument } from '../appwriteAdmin.js'
import { addDays, berlinDate, daysBetween } from './dates.js'
import { RECHNUNGS_ARTEN, AB_RECHNUNGS_ARTEN, sperreRechnung } from '../auftraege/konstanten.js'
import { withLocks } from '../keyedLock.js'
import { baueRechnung, ladeFestenKunden, buche, gibProjektRechnungFrei, setzeAbAbgerechnet, pruefeVorSenden, hatSchlussEntwurf } from '../auftraege/rechnungsbau.js'
import { createProjektRechnung, getProjektRechnung, updateProjektRechnung } from '../auftraege/projektRechnungenStore.js'
import { getSettings } from './settings.js'
/**
@@ -87,6 +90,11 @@ export function mapInvoice(raw, { today }) {
pdfUrl: raw.invoice_pdf || null,
outOfBand: Boolean(metadata.bankTransactionId),
bankTransactionId: metadata.bankTransactionId || '',
projektId: metadata.projektId || '',
projektName: metadata.projektName || '',
art: RECHNUNGS_ARTEN.includes(metadata.art) ? metadata.art : subscriptionIdOfInvoice(raw) ? 'abo' : 'frei',
auftragId: metadata.auftragId || '',
auftragNummer: metadata.auftragNummer || '',
}
}
@@ -126,11 +134,6 @@ export async function listPayouts({ force = false } = {}) {
// Projektrechnungen schreiben
// ---------------------------------------------------------------------------
const MAX_ITEMS = 20
const MAX_DESCRIPTION = 200
const MAX_QUANTITY = 999
const MAX_UNIT_CENTS = 10_000_000
const MAX_TOTAL_CENTS = 99_999_999
const MAX_DAYS_UNTIL_DUE = 60
const DEFAULT_DAYS_UNTIL_DUE = 14
const MAX_MEMO = 1000
@@ -194,53 +197,18 @@ function assertAddressForSend(customer, totalCents) {
if (!complete && totalCents > SMALL_INVOICE_MAX_CENTS) throw httpError(409, ADDRESS_INCOMPLETE_MESSAGE)
}
/** Rechnungsfuss: Kleinunternehmer-Hinweis, Bankdaten (falls gesetzt), Hinweis auf den Verwendungszweck. */
export function invoiceFooter(bankDetailsText = '') {
/**
* Rechnungsfuss: Kleinunternehmer-Hinweis, Steuernummer (falls gesetzt, § 34a UStDV), Bankdaten (falls gesetzt), Hinweis
* auf den Verwendungszweck.
*/
export function invoiceFooter(bankDetailsText = '', steuernummer = '') {
const bank = typeof bankDetailsText === 'string' ? bankDetailsText.trim() : ''
return [config.stripe.invoiceFooter, bank, PAYMENT_REFERENCE_NOTE].filter(Boolean).join('\n')
const nummer = typeof steuernummer === 'string' ? steuernummer.trim() : ''
return [config.stripe.invoiceFooter, nummer ? `Steuernummer: ${nummer}` : '', bank, PAYMENT_REFERENCE_NOTE].filter(Boolean).join('\n')
}
const isWholeNumberBetween = (value, min, max) => Number.isInteger(value) && value >= min && value <= max
/** Positionen pruefen und bereinigen (Beschreibung ohne Rand-Leerzeichen); Fehler mit status 400. */
function validateItems(items) {
if (!Array.isArray(items) || items.length < 1 || items.length > MAX_ITEMS) {
throw httpError(400, `Eine Rechnung braucht 1 bis ${MAX_ITEMS} Positionen.`)
}
const lines = items.map((item, index) => {
const label = `Position ${index + 1}`
const description = typeof item?.description === 'string' ? item.description.trim() : ''
if (description.length < 1 || description.length > MAX_DESCRIPTION) {
throw httpError(400, `${label}: Die Beschreibung braucht 1 bis ${MAX_DESCRIPTION} Zeichen.`)
}
if (!isWholeNumberBetween(item.quantity, 1, MAX_QUANTITY)) {
throw httpError(400, `${label}: Die Menge muss eine ganze Zahl von 1 bis ${MAX_QUANTITY} sein.`)
}
if (!isWholeNumberBetween(item.unitAmountCents, 1, MAX_UNIT_CENTS)) {
throw httpError(400, `${label}: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`)
}
return { description, quantity: item.quantity, unitAmountCents: item.unitAmountCents }
})
const totalCents = lines.reduce((sum, line) => sum + line.quantity * line.unitAmountCents, 0)
if (totalCents > MAX_TOTAL_CENTS) throw httpError(400, 'Rechnungssumme zu hoch.')
return lines
}
/** Kunde aus Appwrite; nur feste Kunden (nicht lead/lost) bekommen Rechnungen. */
async function loadBillableCustomer(customerId) {
if (typeof customerId !== 'string' || !customerId) throw httpError(400, 'Kunde nicht gefunden.')
let customer
try {
customer = await getDocument(config.collections.customers, customerId)
} catch (err) {
if (err.status === 404) throw httpError(400, 'Kunde nicht gefunden.')
throw err
}
const stage = String(customer.customerStatus || '').trim().toLowerCase()
if (stage === 'lead' || stage === 'lost') throw httpError(400, 'Rechnungen nur für feste Kunden.')
return customer
}
/** Eine Rechnung frisch von Stripe (mit Kunde), roh; unbekannte Rechnung -> status 404. */
async function retrieveInvoice(invoiceId) {
if (typeof invoiceId !== 'string' || !/^in_\w+$/.test(invoiceId)) throw httpError(404, 'Rechnung nicht gefunden.')
@@ -257,12 +225,39 @@ async function readInvoice(invoiceId) {
return mapInvoice(await retrieveInvoice(invoiceId), { today: berlinDate() })
}
// Buchungen am Projektdatensatz nach einem Stripe-Erfolg: scheitert Appwrite, wird nur protokolliert (siehe buche)
const PROJEKT_ARTEN = RECHNUNGS_ARTEN.filter((art) => art !== 'frei')
async function bucheNummer(invoiceId, number, art) {
if (!PROJEKT_ARTEN.includes(art) || !number) return
await buche('Rechnungsnummer buchen', () => updateProjektRechnung(invoiceId, { rechnungsnummer: number }))
}
/** Finalisiert: Rechnungsnummer in den Datensatz; bei einer Schlussrechnung geht die AB auf abgerechnet. */
async function bucheFinalisiert(invoiceId, number, art, auftragId) {
if (!PROJEKT_ARTEN.includes(art)) return
await bucheNummer(invoiceId, number, art)
if (art !== 'schluss') return
// Massgebend ist die AB im Projektdatensatz (nach umhaengen die neue Fassung), nicht die in den Stripe-Metadaten
await buche('AB abrechnen', async () => {
const abId = (await getProjektRechnung(invoiceId))?.auftragId || auftragId
if (abId) await setzeAbAbgerechnet(abId)
})
}
/** Storniert oder geloescht: Datensatz inaktiv (Blaetter frei); bei einer Schlussrechnung geht die AB zurueck. */
async function bucheStorniert(invoiceId, art) {
if (!PROJEKT_ARTEN.includes(art)) return
await buche('Rechnung freigeben', () => gibProjektRechnungFrei(invoiceId))
}
// Finalisieren ohne Auto-Advance (die Rechnung bleibt in unserer Hand), dann per E-Mail senden. Scheitert nur das
// Senden, ist die Rechnung schon festgeschrieben (offen, mit Nummer): der Fehler (status 502) nennt sie, damit der
// Aufrufer sie erneut senden kann (sendInvoiceNow), statt eine zweite anzulegen.
async function finalizeAndSend(stripe, invoiceId) {
async function finalizeAndSend(stripe, invoiceId, { art = 'frei', auftragId = '' } = {}) {
const finalized = await stripe.invoices.finalizeInvoice(invoiceId, { auto_advance: false })
const number = finalized.number || ''
await bucheFinalisiert(invoiceId, number, art, auftragId)
try {
await stripe.invoices.sendInvoice(invoiceId)
} catch (cause) {
@@ -338,25 +333,40 @@ function validateIdempotencyKey(key) {
*/
export async function createProjectInvoice({
customerId,
art = 'frei',
projektId,
auftragId,
items,
daysUntilDue = DEFAULT_DAYS_UNTIL_DUE,
arbeitsblattIds,
anzahlung,
zeilentexte,
leistungszeitraum,
daysUntilDue,
memo = '',
senden = false,
idempotencyKey,
createdBy,
}) {
const lines = validateItems(items)
if (!isWholeNumberBetween(daysUntilDue, 0, MAX_DAYS_UNTIL_DUE)) {
// Ohne Angabe: bei Anzahlung und Schluss das Zahlungsziel der AB (siehe createInvoice), sonst 14 Tage
if (daysUntilDue !== undefined && !isWholeNumberBetween(daysUntilDue, 0, MAX_DAYS_UNTIL_DUE)) {
throw httpError(400, `Das Zahlungsziel muss eine ganze Zahl von 0 bis ${MAX_DAYS_UNTIL_DUE} Tagen sein.`)
}
const note = typeof memo === 'string' ? memo.trim() : ''
if (note.length > MAX_MEMO) throw httpError(400, `Der Vermerk darf höchstens ${MAX_MEMO} Zeichen lang sein.`)
const key = validateIdempotencyKey(idempotencyKey)
const fingerprint = createHash('sha256').update(JSON.stringify([customerId, lines, note, daysUntilDue, senden === true])).digest('hex')
// Der Fingerabdruck wirft nie: ein Array wird sortiert kopiert, jeder andere Wert bleibt; Typfehler meldet baueRechnung (400)
const sortiert = Array.isArray(arbeitsblattIds) ? [...arbeitsblattIds].sort() : arbeitsblattIds
const fingerprint = createHash('sha256')
.update(JSON.stringify([customerId, art, projektId ?? '', auftragId ?? '', items ?? null, sortiert ?? null, anzahlung ?? null, zeilentexte ?? null, leistungszeitraum ?? '', note, daysUntilDue, senden === true]))
.digest('hex')
claimCreate(fingerprint)
try {
const invoice = await createInvoice({ customerId, lines, daysUntilDue, note, senden: senden === true, key, createdBy })
const eingabe = { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, createdBy }
const anlegen = () => createInvoice({ eingabe, daysUntilDue, note, senden: senden === true, key })
// Aufwand, Anzahlung und Schluss: Pruefung, Entwurf, Posten, Projektdatensatz und Senden je Kunde nacheinander,
// damit zwei Admins nie dieselben Arbeitsblaetter oder dieselbe AB gleichzeitig abrechnen
const invoice = ['aufwand', 'anzahlung', 'schluss'].includes(art) ? await withLocks(sperreRechnung(customerId), anlegen) : await anlegen()
releaseCreate(fingerprint, true)
return invoice
} catch (err) {
@@ -366,13 +376,26 @@ export async function createProjectInvoice({
}
}
async function createInvoice({ customerId, lines, daysUntilDue, note, senden, key, createdBy }) {
const customer = await loadBillableCustomer(customerId)
/**
* Vorschau einer Projektrechnung: dieselben Pruefungen wie das Anlegen, aber kein Schreibzugriff (weder Stripe noch
* Appwrite), kein Schutz vor doppeltem Anlegen, keine Sperre.
*/
export async function rechnungsVorschau(eingabe) {
const { positionen, summeCents, leistungszeitraum, customFields } = await baueRechnung(eingabe)
return { positionen, summeCents, leistungszeitraum, customFields }
}
async function createInvoice({ eingabe, daysUntilDue: angegeben, note, senden, key }) {
const entwurf = await baueRechnung(eingabe)
// Zahlungsziel: die Angabe des Aufrufers, sonst bei Anzahlung und Schluss das der AB, sonst 14 Tage
const abZiel = AB_RECHNUNGS_ARTEN.includes(entwurf.art) ? entwurf.auftrag?.zahlungszielTage : undefined
const daysUntilDue = angegeben ?? (isWholeNumberBetween(abZiel, 0, MAX_DAYS_UNTIL_DUE) ? abZiel : DEFAULT_DAYS_UNTIL_DUE)
const customer = entwurf.kunde
// Ohne Adresse lehnt Stripe das Senden erst nach dem Festschreiben ab: lieber vorab pruefen
if (senden && !String(customer.email || '').trim()) throw httpError(400, NO_EMAIL_MESSAGE)
if (senden) assertAddressForSend(customer, lines.reduce((sum, line) => sum + line.quantity * line.unitAmountCents, 0))
if (senden) assertAddressForSend(customer, entwurf.summeCents)
const { bankDetailsText } = await getSettings()
const { bankDetailsText, steuernummer } = await getSettings()
const stripe = getStripe()
const stripeCustomerId = await getOrCreateStripeCustomer(customer)
// Vor dem ersten Rechnungsaufruf: scheitert das, gibt es keine halbfertige Rechnung
@@ -387,24 +410,25 @@ async function createInvoice({ customerId, lines, daysUntilDue, note, senden, ke
auto_advance: false,
pending_invoice_items_behavior: 'exclude',
currency: 'eur',
footer: invoiceFooter(bankDetailsText),
footer: invoiceFooter(bankDetailsText, steuernummer),
description: note || undefined,
metadata: { webklarCustomerId: customer.$id, quelle: 'woms', erstelltVon: String(createdBy || '').slice(0, 500) },
metadata: entwurf.metadata,
custom_fields: entwurf.customFields.length ? entwurf.customFields : undefined,
rendering: { pdf: { page_size: 'a4' } },
},
key ? { idempotencyKey: key } : undefined
)
invoiceId = draft.id
// der Reihe nach, damit die Positionen in der eingegebenen Reihenfolge auf der Rechnung stehen
for (const line of lines) {
await stripe.invoiceItems.create({
customer: stripeCustomerId,
invoice: invoiceId,
description: line.description,
quantity: line.quantity,
unit_amount_decimal: String(line.unitAmountCents),
currency: 'eur',
})
for (const position of entwurf.positionen) {
const posten = { customer: stripeCustomerId, invoice: invoiceId, description: position.beschreibung }
// Minuszeile (Abzug fruehere Rechnung): nur amount, ohne quantity
if (position.einzelpreisCents < 0) Object.assign(posten, { amount: position.betragCents })
else Object.assign(posten, { quantity: position.menge, unit_amount_decimal: String(position.einzelpreisCents) })
posten.currency = 'eur'
await stripe.invoiceItems.create(posten)
}
if (entwurf.projektRechnung) await createProjektRechnung(invoiceId, entwurf.projektRechnung)
} catch (err) {
// Halbfertigen Entwurf nicht liegen lassen; scheitert auch das, bleibt er als Entwurf sichtbar und loeschbar
if (invoiceId) await stripe.invoices.del(invoiceId).catch(() => {})
@@ -413,7 +437,7 @@ async function createInvoice({ customerId, lines, daysUntilDue, note, senden, ke
}
try {
const number = senden ? await finalizeAndSend(stripe, invoiceId) : ''
const number = senden ? await finalizeAndSend(stripe, invoiceId, { art: entwurf.art, auftragId: entwurf.auftrag?.id ?? '' }) : ''
try {
return await readInvoice(invoiceId)
} catch (cause) {
@@ -438,6 +462,17 @@ async function createInvoice({ customerId, lines, daysUntilDue, note, senden, ke
*/
export async function sendInvoiceNow(invoiceId) {
const raw = await retrieveInvoice(invoiceId)
const vorab = mapInvoice(raw, { today: berlinDate() })
// Anzahlungs- und Schluss-Entwuerfe: unter der Sperre des Kunden (wie das Anlegen) senden und vorher die AB erneut pruefen
if (vorab.source === 'projekt' && vorab.status === 'entwurf' && AB_RECHNUNGS_ARTEN.includes(vorab.art) && vorab.webklarCustomerId) {
return withLocks(sperreRechnung(vorab.webklarCustomerId), () => sendeRechnung(invoiceId, null, true))
}
return sendeRechnung(invoiceId, raw, false)
}
// raw = die schon gelesene Rechnung; unter der Sperre wird frisch gelesen (ein anderer Aufruf kann sie inzwischen gesendet haben)
async function sendeRechnung(invoiceId, raw, abPruefen) {
raw ??= await retrieveInvoice(invoiceId)
const invoice = mapInvoice(raw, { today: berlinDate() })
// Ein Abo-Entwurf gehoert Stripe: Finalisieren mit auto_advance=false wuerde den Einzug des Abos anhalten
if (invoice.source !== 'projekt') throw httpError(409, ABO_MESSAGE)
@@ -445,10 +480,11 @@ export async function sendInvoiceNow(invoiceId) {
if (!isDraft && invoice.status !== 'offen' && invoice.status !== 'ueberfaellig') {
throw httpError(409, 'Diese Rechnung kann nicht mehr gesendet werden.')
}
if (isDraft && abPruefen) await pruefeVorSenden({ invoiceId: invoice.id, art: invoice.art, auftragIdMetadaten: invoice.auftragId, totalCents: invoice.totalCents })
// Nur ein Entwurf wird festgeschrieben: erst dann zaehlen Kundendaten und Anschrift. Eine offene Rechnung steht schon
// fest (Anschrift und Empfaenger sind festgeschrieben), sie wird nur erneut gesendet.
let customer = null
if (isDraft && invoice.webklarCustomerId) customer = await loadBillableCustomer(invoice.webklarCustomerId)
if (isDraft && invoice.webklarCustomerId) customer = await ladeFestenKunden(invoice.webklarCustomerId)
// Nach dem Abgleich traegt der Stripe-Kunde die E-Mail des WOMS-Kunden; ohne die gilt die, die Stripe schon hat
const email = String(customer?.email || '').trim() || String(invoice.customerEmail || '').trim()
if (!email) throw httpError(400, NO_EMAIL_MESSAGE)
@@ -459,8 +495,12 @@ export async function sendInvoiceNow(invoiceId) {
// (Name, E-Mail, Anschrift stehen danach unveraenderlich auf der Rechnung). Ohne Kennung (z. B. Entwurf aus dem
// Stripe-Dashboard) gibt es keine WOMS-Daten zum Abgleichen.
if (customer) await syncStripeCustomer(getStripe(), stripeCustomerIdOf(raw), customer)
await finalizeAndSend(getStripe(), invoice.id)
} else await getStripe().invoices.sendInvoice(invoice.id)
await finalizeAndSend(getStripe(), invoice.id, { art: invoice.art, auftragId: invoice.auftragId })
} else {
// Selbstheilung nach einem ausgefallenen Buchen: eine fehlende Rechnungsnummer im Datensatz nachtragen
await bucheNummer(invoice.id, invoice.number, invoice.art)
await getStripe().invoices.sendInvoice(invoice.id)
}
return await readInvoice(invoice.id)
} finally {
invalidateFinanceCache()
@@ -472,12 +512,21 @@ export async function voidInvoice(invoiceId) {
const invoice = await readInvoice(invoiceId)
if (invoice.source !== 'projekt') throw httpError(409, ABO_MESSAGE)
if (invoice.status !== 'offen' && invoice.status !== 'ueberfaellig') throw httpError(409, 'Nur offene Rechnungen können storniert werden.')
try {
await getStripe().invoices.voidInvoice(invoice.id)
return await readInvoice(invoice.id)
} finally {
invalidateFinanceCache()
const stornieren = async () => {
// Der Schluss-Entwurf enthaelt die Minuszeile dieser Anzahlung: wuerde sie storniert, ginge er zu niedrig hinaus
if (invoice.art === 'anzahlung' && (await hatSchlussEntwurf({ invoiceId: invoice.id, auftragIdMetadaten: invoice.auftragId }))) {
throw httpError(409, 'Erst den Entwurf der Schlussrechnung löschen.')
}
try {
await getStripe().invoices.voidInvoice(invoice.id)
await bucheStorniert(invoice.id, invoice.art)
return await readInvoice(invoice.id)
} finally {
invalidateFinanceCache()
}
}
// Eine Anzahlung unter der Sperre des Kunden: das Anlegen und Senden einer Schlussrechnung laeuft nie zwischen Pruefung und Storno
return invoice.art === 'anzahlung' && invoice.webklarCustomerId ? withLocks(sperreRechnung(invoice.webklarCustomerId), stornieren) : stornieren()
}
/** Projekt-Entwurf bei Stripe loeschen, sonst status 409 (nur Entwuerfe lassen sich loeschen). */
@@ -487,6 +536,7 @@ export async function deleteDraft(invoiceId) {
if (invoice.status !== 'entwurf') throw httpError(409, 'Nur Entwürfe können gelöscht werden.')
try {
await getStripe().invoices.del(invoice.id)
await bucheStorniert(invoice.id, invoice.art)
} finally {
invalidateFinanceCache()
}
+3 -2
View File
@@ -35,10 +35,11 @@ function getTransporter() {
}
/** Vorlage rendern (server/mail-templates); Unstimmigkeiten landen im Log, die Mail geht trotzdem raus. */
function render({ template, values = {}, blocks = {} }) {
function render({ template, values = {}, blocks = {}, subjectPrefix = '' }) {
const mail = renderMail(template, values, blocks)
for (const problem of mail.problems) console.warn(`[mailer] Vorlage ${template}: ${problem}`)
return mail
// optional: Text vor dem Betreff der Vorlage (z. B. "Kopie: " bei einer Teamkopie)
return subjectPrefix ? { ...mail, subject: `${subjectPrefix}${mail.subject}` } : mail
}
// Throttle: pro Kunde maximal eine Benachrichtigung je Zeitfenster,