diff --git a/UAT-Featureliste Mieterportal.xlsx b/UAT-Featureliste Mieterportal.xlsx index c3a7849..2bd0e45 100644 Binary files a/UAT-Featureliste Mieterportal.xlsx and b/UAT-Featureliste Mieterportal.xlsx differ diff --git a/kimi.md b/kimi.md index 36d56a7..07ae0ca 100644 --- a/kimi.md +++ b/kimi.md @@ -189,6 +189,23 @@ tar -czf mp-dist.tar.gz -C dist . ## 8. Verlauf / offene Punkte +**Erledigt 04.10. (v1.23):** Nebenkostenabrechnung als PDF direkt aus dem +Kosten- und Zählerstandsmodul. Neues Modul `server/src/abrechnung.js` +(pdfkit, neue Dependency – Achtung: Standardfont Helvetica/WinAnsi, kein +U+2212-Minus; ASCII '-' verwenden): lädt Wohnung, Hauseinheiten +(Flächenanteil = Wohnungsgröße/Hausgesamtfläche), umlegbare Kosten des Hauses +(jahr + (unit_id = Wohnung ODER NULL)), gruppiert nach Kostenart; Vorauszahlung += (Warm- − Kaltmiete) × 12; Differenz → Nachzahlung (rot) oder Erstattung +(grün). PDF nach UPLOAD_DIR als `ab--.pdf`, idempotentes +documents-Row (Titel `Nebenkostenabrechnung – , Whg. `, +Kategorie „Nebenkostenabrechnung“, alte Datei wird ersetzt). Endpoint +`POST /api/abrechnungen` (auth, adminOnly) → `{dokument, zusammenfassung}`. +Frontend: `AbrechnungDialog` in src/pages/Kosten.tsx (Button im Kosten-Header +neben KostenDialog), Ergebnisbox mit Download-Link. E2E live: Testkosten +(hausweit + wohnungsbezogen) → Abrechnung we1/2026 → PDF geprüft (Umlaute, +€, ² ok) → Testdaten bereinigt. UAT-Liste: neue Spalte „Ziel-Version" (Spalte +K), COUNTIF-Bugfix (zählte fälschlich Spalte I statt J). + **Erledigt 04.10. (v1.22):** Direkt-E-Mail der Verwaltung an Mieter mit automatischem CC an die Verwaltung. Mail-API (mail-server/api/server.py, auf dem Server manuell ausgerollt, Container mp-mail-api per docker restart – diff --git a/make-uat-liste.py b/make-uat-liste.py index c4a28e2..ca0a2d2 100644 --- a/make-uat-liste.py +++ b/make-uat-liste.py @@ -20,11 +20,11 @@ ws = wb.active ws.title = 'Featureliste' ws.sheet_view.showGridLines = False -ws.merge_cells('B2:K2') +ws.merge_cells('B2:L2') c = ws['B2'] c.value = 'UAT & Feature-Wunschliste – Hinterstein Mieterportal' c.font = Font(bold=True, size=16, color=DUNKEL) -ws.merge_cells('B3:K3') +ws.merge_cells('B3:L3') c = ws['B3'] c.value = ('Gewünschte Features erfassen: Zeile ergänzen, Modul wählen, Priorität setzen. ' 'Die Entwicklung aktualisiert den Status. Beispielzeilen 001–003 gern überschreiben.') @@ -36,15 +36,15 @@ for i, (label, wert) in enumerate(kennz): col = 2 + i * 2 lc = ws.cell(row=5, column=col, value=label) lc.font = Font(size=9, bold=True, color='64748B') - vc = ws.cell(row=6, column=col, value=f'=COUNTIF($I$10:$I$109,"{wert}")') + vc = ws.cell(row=6, column=col, value=f'=COUNTIF($J$10:$J$109,"{wert}")') vc.font = Font(size=14, bold=True, color=AKZENT) vc.alignment = Alignment(horizontal='left') KOPF = ['ID', 'Modul', 'Feature / Anforderung', 'Beschreibung / Akzeptanzkriterium', 'Priorität', 'Wunschtermin', 'Eingereicht von', 'Eingereicht am', 'Status', - 'Ergebnis im Test / Bemerkung'] -BREITEN = [7, 20, 30, 48, 11, 13, 15, 12, 12, 34] -SPALTEN = ['B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K'] + 'Ziel-Version', 'Ergebnis im Test / Bemerkung'] +BREITEN = [7, 20, 30, 48, 11, 13, 15, 12, 12, 12, 34] +SPALTEN = ['B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L'] HEADER_ZEILE = 9 for j, (name, breite, buchstabe) in enumerate(zip(KOPF, BREITEN, SPALTEN)): @@ -64,23 +64,23 @@ STATUS = ['Offen', 'In Prüfung', 'Umgesetzt', 'Abgelehnt'] beispiele = [ ['001', 'Steuer & Kosten', 'Nebenkostenabrechnung als PDF', 'Pro Wohnung und Abrechnungsjahr eine PDF-Abrechnung aus Kosten + Zählerständen erzeugen und im Dokumentenmodul ablegen.', - 'Mittel', '', '', '', 'Offen', ''], + 'Mittel', '', '', '', 'Umgesetzt', 'v1.23', ''], ['002', 'Benutzerverwaltung', 'E-Mail-Adressen importierter Mieter erfassen', 'Fehlende E-Mail-Adressen der importierten Mieter erfassen, damit Benachrichtigungen versendet werden können.', - 'Hoch', '', '', '', 'Offen', ''], + 'Hoch', '', '', '', 'Offen', '', ''], ['003', 'Mietkonto & Zahlungen', 'Zahlungseingang manuell als erledigt markieren', 'Verwaltung kann eine offene Buchung als „bezahlt“ markieren; Saldo und Fälligkeiten aktualisieren sich.', - 'Niedrig', '', '', '', 'Offen', ''], + 'Niedrig', '', '', '', 'Offen', '', ''], ] duenn = Side(style='thin', color='E2E8F0') for i in range(100): zeile = 10 + i - daten = beispiele[i] if i < len(beispiele) else [f'{i+1:03d}', '', '', '', '', '', '', '', 'Offen', ''] + daten = beispiele[i] if i < len(beispiele) else [f'{i+1:03d}', '', '', '', '', '', '', '', 'Offen', '', ''] for j, wert in enumerate(daten): cell = ws.cell(row=zeile, column=2 + j, value=wert) cell.font = Font(size=10) - cell.alignment = Alignment(vertical='top', wrap_text=(j in (2, 3, 9))) + cell.alignment = Alignment(vertical='top', wrap_text=(j in (2, 3, 10))) cell.border = Border(bottom=duenn) if i % 2 == 1: cell.fill = PatternFill('solid', fgColor=GRAU) @@ -134,8 +134,9 @@ bloecke = [ 'notieren, wann das Feature als fertig gilt. Je konkreter das Akzeptanzkriterium, desto schneller die Umsetzung.'), ('Priorität', 'Hoch = dringend für den Betrieb · Mittel = wichtig, aber zeitunkritisch · Niedrig = Nice-to-have.'), - ('Status (wird von der Entwicklung gepflegt)', - 'Offen → In Prüfung (Aufwand wird bewertet) → Umgesetzt (nach Auslieferung) bzw. Abgelehnt (mit Begründung in der letzten Spalte).'), + ('Status & Ziel-Version (wird von der Entwicklung gepflegt)', + 'Offen → In Prüfung (Aufwand wird bewertet) → Umgesetzt (nach Auslieferung) bzw. Abgelehnt (mit Begründung in der letzten Spalte). ' + 'Sobald ein Punkt eingeplant ist, trägt die Entwicklung die geplante Auslieferung in der Spalte „Ziel-Version“ ein (z. B. v1.24).'), ('UAT – Abnahme testen', 'Nach Umsetzung im Portal gegen das Akzeptanzkriterium testen. Ergebnis in der Spalte ' '„Ergebnis im Test / Bemerkung“ festhalten: „OK“ oder Fehlerbeschreibung mit Schritten zum Nachvollziehen. ' @@ -156,7 +157,7 @@ for titel, text in bloecke: zeile += 2 wa.merge_cells(f'B{zeile}:C{zeile}') -c = wa.cell(row=zeile, column=2, value='Aktueller Portal-Stand: v1.21 (Live-Stammdaten importiert)') +c = wa.cell(row=zeile, column=2, value='Aktueller Portal-Stand: v1.23 (Nebenkostenabrechnung als PDF)') c.font = Font(size=9, italic=True, color='94A3B8') out = 'UAT-Featureliste Mieterportal.xlsx' diff --git a/server/package-lock.json b/server/package-lock.json index ddfc2ab..37c5c20 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -10,12 +10,22 @@ "dependencies": { "express": "^4.21.2", "multer": "^2.0.2", + "pdfkit": "^0.15.2", "pg": "^8.13.1" }, "engines": { "node": ">=20" } }, + "node_modules/@swc/helpers": { + "version": "0.3.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.3.17.tgz", + "integrity": "sha512-tb7Iu+oZ+zWJZ3HJqwx8oNwSDIU440hmVMDPhpACWQWnrZHK99Bxs70gT1L2dnr5Hg50ZRWEFkQCAnOVVV0z1Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -35,12 +45,63 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.8", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", @@ -65,6 +126,24 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -85,6 +164,24 @@ "node": ">= 0.8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -114,6 +211,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -150,6 +256,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "deprecated": "Active development of CryptoJS has been discontinued. This library is no longer maintained.", + "license": "MIT" + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -159,6 +272,72 @@ "ms": "2.0.0" } }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -178,6 +357,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -225,6 +410,26 @@ "node": ">= 0.4" } }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -316,6 +521,38 @@ "node": ">= 0.8" } }, + "node_modules/fontkit": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-1.9.0.tgz", + "integrity": "sha512-HkW/8Lrk8jl18kzQHvAw9aTHe1cqsyx5sDnxncx652+CIfhawokEPkeM3BoIC+z/Xv7a0yMr0f3pRRwhGH455g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.3.13", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "deep-equal": "^2.0.5", + "dfa": "^1.2.0", + "restructure": "^2.0.1", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.3.1", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -343,6 +580,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -392,6 +638,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -404,6 +674,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -454,6 +739,20 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -463,6 +762,264 @@ "node": ">= 0.10" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/jpeg-exif": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz", + "integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -577,6 +1134,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -589,6 +1191,12 @@ "node": ">= 0.8" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -604,6 +1212,19 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pdfkit": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.15.2.tgz", + "integrity": "sha512-s3GjpdBFSCaeDSX/v73MI5UsPqH1kjKut2AXCgxQ5OH10lPVOu5q5vLAG0OCpz/EYqKsTSw1WHpENqMvp43RKg==", + "license": "MIT", + "dependencies": { + "crypto-js": "^4.2.0", + "fontkit": "^1.8.1", + "jpeg-exif": "^1.1.4", + "linebreak": "^1.0.2", + "png-js": "^1.0.0" + } + }, "node_modules/pg": { "version": "8.23.1", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", @@ -693,6 +1314,23 @@ "split2": "^4.1.0" } }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -789,6 +1427,32 @@ "node": ">= 0.8" } }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/restructure": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-2.0.1.tgz", + "integrity": "sha512-e0dOpjm5DseomnXx2M5lpdZ5zoHqF1+bqdMJUohoYVVQa7cBdnk7fdmeI6byNWP/kiME72EeTiSypTCVnpLiDg==", + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -809,6 +1473,23 @@ ], "license": "MIT" }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -860,6 +1541,38 @@ "node": ">= 0.8.0" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -956,6 +1669,19 @@ "node": ">= 0.8" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -964,6 +1690,12 @@ "node": ">=10.0.0" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -973,6 +1705,12 @@ "node": ">=0.6" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -986,6 +1724,32 @@ "node": ">= 0.6" } }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1013,6 +1777,64 @@ "node": ">= 0.8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/server/package.json b/server/package.json index 1e05b5e..8c0ba32 100644 --- a/server/package.json +++ b/server/package.json @@ -13,6 +13,7 @@ "dependencies": { "express": "^4.21.2", "multer": "^2.0.2", + "pdfkit": "^0.15.2", "pg": "^8.13.1" } } diff --git a/server/src/abrechnung.js b/server/src/abrechnung.js new file mode 100644 index 0000000..eb8a4d6 --- /dev/null +++ b/server/src/abrechnung.js @@ -0,0 +1,245 @@ +/** Nebenkostenabrechnung als PDF (v1.23). + * + * Erzeugt pro Wohnung und Abrechnungsjahr eine PDF-Abrechnung direkt aus + * dem Kosten- und Zählerstandsmodul: + * - umlagefähige, hausweite Kosten → Verteilung nach Wohnflächenanteil + * - umlagefähige, wohnungsbezogene Kosten → voll der Wohnung + * - Abzug der Nebenkosten-Vorauszahlung (Warmmiete − Kaltmiete) × 12 + * - Anlage: Zählerstände der Wohnung (Stand + Datum) + * Das PDF landet im Upload-Verzeichnis und wird als Dokument + * („Nebenkostenabrechnung") der Wohnung zugeordnet – erneutes Erzeugen + * für dieselbe Wohnung + Jahr ersetzt Datei und Dokument. + */ + +const fs = require('fs') +const path = require('path') +const PDFDocument = require('pdfkit') + +const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads') +const FARBE_DUNKEL = '#22333b' +const FARBE_AKZENT = '#f59e0b' +const FARBE_SOFT = '#64748b' + +const eur = (n) => + Number(n || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €' +const zahl = (n) => + Number(n || 0).toLocaleString('de-DE', { maximumFractionDigits: 2 }) +const dDatum = () => new Date().toLocaleDateString('de-DE') + +/** Sammelt alle Positionen und Zahlen für Wohnung + Jahr. */ +async function abrechnungsDaten(pool, unitId, jahr) { + const { rows: einheiten } = await pool.query('SELECT * FROM units WHERE id = $1', [unitId]) + if (!einheiten.length) throw Object.assign(new Error('Wohnung nicht gefunden.'), { status: 404 }) + const einheit = einheiten[0] + + const { rows: hausEinheiten } = await pool.query( + 'SELECT * FROM units WHERE building = $1', [einheit.building], + ) + const hausFlaeche = hausEinheiten.reduce((s, u) => s + Number(u.size || 0), 0) + const anteil = hausFlaeche > 0 ? Number(einheit.size || 0) / hausFlaeche : 0 + + const { rows: kosten } = await pool.query( + `SELECT * FROM costs WHERE building = $1 AND jahr = $2 AND umlegbar = TRUE + AND (unit_id = $3 OR unit_id IS NULL) ORDER BY kostenart`, + [einheit.building, jahr, einheit.id], + ) + const positionen = [] + for (const k of kosten) { + const wohnungsAnteil = k.unit_id + ? Number(k.betrag) // wohnungsbezogen: voller Betrag + : Number(k.betrag) * anteil // hausweit: nach Wohnflächenanteil + positionen.push({ + kostenart: k.kostenart, + notiz: k.notiz, + hausGesamt: k.unit_id ? null : Number(k.betrag), + verteilung: k.unit_id ? 'wohnungsbezogen' : `${zahl(einheit.size)} von ${zahl(hausFlaeche)} m²`, + betrag: wohnungsAnteil, + }) + } + // Zusammenfassen nach Kostenart + const jeArt = new Map() + for (const p of positionen) { + const alt = jeArt.get(p.kostenart) || { ...p, betrag: 0, hausGesamt: p.hausGesamt } + alt.betrag += p.betrag + if (p.hausGesamt !== null) alt.hausGesamt = (alt.hausGesamt || 0) + p.hausGesamt + jeArt.set(p.kostenart, alt) + } + + const summe = [...jeArt.values()].reduce((s, p) => s + p.betrag, 0) + const vorauszahlung = (Number(einheit.warm_rent) - Number(einheit.cold_rent)) * 12 + const differenz = summe - vorauszahlung + + const { rows: zaehler } = await pool.query( + `SELECT nummer, art, stand, stand_am FROM meters WHERE unit_id = $1 ORDER BY art, nummer`, + [einheit.id], + ) + return { einheit, jahr, positionen: [...jeArt.values()], summe, vorauszahlung, differenz, zaehler } +} + +/** Zeichnet das PDF und liefert den Dateinamen (im UPLOAD_DIR). */ +async function bauePdf(daten) { + const { einheit, jahr, positionen, summe, vorauszahlung, differenz, zaehler } = daten + const dateiName = `ab-${einheit.id}-${jahr}.pdf` + const vollerPfad = path.join(UPLOAD_DIR, dateiName) + + await new Promise((resolve, reject) => { + const doc = new PDFDocument({ size: 'A4', margin: 50, info: { + Title: `Nebenkostenabrechnung ${jahr} – ${einheit.building}, Whg. ${einheit.number}`, + Author: 'Hinterstein Immobilien', + } }) + const strom = fs.createWriteStream(vollerPfad) + strom.on('finish', resolve) + strom.on('error', reject) + doc.pipe(strom) + + const breite = doc.page.width - 100 // nutzbare Breite + let y = doc.y + + // ── Kopf ── + doc.font('Helvetica-Bold').fontSize(16).fillColor(FARBE_DUNKEL) + doc.text('Hinterstein Immobilien', 50, y) + doc.font('Helvetica').fontSize(9).fillColor(FARBE_SOFT) + doc.text('Nebenkostenabrechnung', 50, y + 20, { align: 'right', width: breite }) + doc.moveTo(50, y + 36).lineTo(50 + breite, y + 36).lineWidth(1.5).strokeColor(FARBE_AKZENT).stroke() + y += 50 + + doc.font('Helvetica-Bold').fontSize(13).fillColor(FARBE_DUNKEL) + doc.text(`Nebenkostenabrechnung ${jahr}`, 50, y) + y += 22 + doc.font('Helvetica').fontSize(10).fillColor('#0f172a') + doc.text(`Abrechnungszeitraum: 01.01.${jahr} – 31.12.${jahr}`, 50, y) + y += 16 + doc.text(`Objekt: ${einheit.building} · Wohnung ${einheit.number} · ${zahl(einheit.size)} m²`, 50, y) + y += 16 + doc.text(`Mieter: ${einheit.tenant || '–'}`, 50, y) + y += 16 + doc.text(`Erstellt am ${dDatum()}`, 50, y) + y += 26 + + // ── Kostenpositionen ── + doc.font('Helvetica-Bold').fontSize(11).fillColor(FARBE_DUNKEL) + doc.text('Umlagefähige Betriebskosten', 50, y) + y += 18 + + const spalten = { art: 50, gesamt: 300, schluessel: 390, betrag: 480 } + doc.font('Helvetica-Bold').fontSize(8.5).fillColor(FARBE_SOFT) + doc.text('KOSTENART', spalten.art, y) + doc.text('HAUS GESAMT', spalten.gesamt, y, { width: 85, align: 'right' }) + doc.text('VERTEILUNG', spalten.schluessel, y) + doc.text('IHRE KOSTEN', spalten.betrag, y, { width: 65, align: 'right' }) + y += 6 + doc.moveTo(50, y).lineTo(50 + breite, y).lineWidth(0.5).strokeColor('#cbd5e1').stroke() + y += 12 + + doc.font('Helvetica').fontSize(9.5).fillColor('#0f172a') + for (const p of positionen) { + if (y > 730) { doc.addPage(); y = 60 } + doc.text(p.kostenart, spalten.art, y, { width: 240 }) + doc.text(p.hausGesamt !== null ? eur(p.hausGesamt) : '–', spalten.gesamt, y, { width: 85, align: 'right' }) + doc.fillColor(FARBE_SOFT).text(p.verteilung, spalten.schluessel, y, { width: 85 }) + doc.fillColor('#0f172a').text(eur(p.betrag), spalten.betrag, y, { width: 65, align: 'right' }) + y += 18 + } + y += 2 + doc.moveTo(50, y).lineTo(50 + breite, y).lineWidth(0.5).strokeColor('#cbd5e1').stroke() + y += 14 + + // ── Summenblock ── + const zeile = (label, wert, fett, farbe) => { + doc.font(fett ? 'Helvetica-Bold' : 'Helvetica').fontSize(10).fillColor(farbe || '#0f172a') + doc.text(label, 50, y, { width: 400 }) + doc.text(wert, 430, y, { width: 115, align: 'right' }) + y += 17 + } + zeile('Summe umlagefähige Kosten', eur(summe), true) + zeile(`Nebenkostenvorauszahlung (${eur(Number(einheit.warm_rent) - Number(einheit.cold_rent))}/Monat × 12)`, '- ' + eur(vorauszahlung)) + y += 4 + doc.moveTo(320, y - 4).lineTo(545, y - 4).lineWidth(0.5).strokeColor('#cbd5e1').stroke() + const nachzahlung = differenz > 0 + zeile( + nachzahlung ? 'Nachzahlung' : 'Erstattung', + (nachzahlung ? '+ ' : '- ') + eur(Math.abs(differenz)), + true, + nachzahlung ? '#b91c1c' : '#047857', + ) + y += 14 + + // ── Anlage Zählerstände ── + if (zaehler.length) { + if (y > 640) { doc.addPage(); y = 60 } + doc.font('Helvetica-Bold').fontSize(11).fillColor(FARBE_DUNKEL) + doc.text('Anlage: Zählerstände der Wohnung', 50, y) + y += 16 + doc.font('Helvetica-Bold').fontSize(8.5).fillColor(FARBE_SOFT) + doc.text('ZÄHLERART', 50, y) + doc.text('NUMMER', 160, y) + doc.text('STAND', 330, y, { width: 90, align: 'right' }) + doc.text('STAND VOM', 440, y, { width: 105, align: 'right' }) + y += 6 + doc.moveTo(50, y).lineTo(50 + breite, y).lineWidth(0.5).strokeColor('#cbd5e1').stroke() + y += 12 + doc.font('Helvetica').fontSize(9.5).fillColor('#0f172a') + for (const z of zaehler) { + if (y > 730) { doc.addPage(); y = 60 } + doc.text(z.art, 50, y) + doc.text(z.nummer, 160, y, { width: 150 }) + doc.text(zahl(z.stand), 330, y, { width: 90, align: 'right' }) + doc.text(z.stand_am || '–', 440, y, { width: 105, align: 'right' }) + y += 18 + } + y += 10 + doc.font('Helvetica').fontSize(8).fillColor(FARBE_SOFT) + doc.text('Hinweis: Verbrauchskosten (z. B. Wasser) sind im Abrechnungszeitraum in den o. g. Kostenarten enthalten.', 50, y, { width: breite }) + y += 12 + } + + // ── Fußzeile ── + doc.font('Helvetica').fontSize(8).fillColor(FARBE_SOFT) + doc.text( + 'Hinterstein Immobilien · Diese Abrechnung wurde automatisch aus dem Mieterportal erzeugt (Kosten- und Zählerstandsmodul).', + 50, Math.max(y + 8, 770), { width: breite }, + ) + + doc.end() + }) + return dateiName +} + +/** Erzeugt die Abrechnung: PDF + Dokumenten-Eintrag (ersetzt vorhandene). */ +async function erzeugeAbrechnung(pool, unitId, jahr) { + const daten = await abrechnungsDaten(pool, unitId, jahr) + const { einheit } = daten + const dateiName = await bauePdf(daten) + const groesseBytes = fs.statSync(path.join(UPLOAD_DIR, dateiName)).size + const groesse = groesseBytes > 1024 + ? `${zahl(groesseBytes / 1024)} KB` + : `${groesseBytes} B` + const titel = `Nebenkostenabrechnung ${jahr} – ${einheit.building}, Whg. ${einheit.number}` + + const { rows: vorhanden } = await pool.query( + 'SELECT * FROM documents WHERE title = $1 AND unit_id = $2', [titel, einheit.id], + ) + let dokument + if (vorhanden.length) { + const alt = vorhanden[0].file_path + if (alt && alt !== dateiName) { + await fs.promises.unlink(path.join(UPLOAD_DIR, alt)).catch(() => {}) + } + const { rows } = await pool.query( + `UPDATE documents SET file_name = $1, file_path = $2, size = $3, date = $4, category = 'Nebenkostenabrechnung' + WHERE id = $5 RETURNING *`, + [titel, dateiName, groesse, dDatum(), vorhanden[0].id], + ) + dokument = rows[0] + } else { + const { rows } = await pool.query( + `INSERT INTO documents (id, unit_id, title, category, size, date, file_name, file_path) + VALUES ($1,$2,$3,'Nebenkostenabrechnung',$4,$5,$6,$7) RETURNING *`, + [`d-ab-${einheit.id}-${jahr}`, einheit.id, titel, groesse, dDatum(), titel, dateiName], + ) + dokument = rows[0] + } + return { dokument, daten } +} + +module.exports = { erzeugeAbrechnung } diff --git a/server/src/index.js b/server/src/index.js index 6eb822a..1e7dd53 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -14,6 +14,7 @@ const multer = require('multer') const { pool, init, sessionsAufraeumen } = require('./db') const { seed, seedeZaehler, seedeHaeuser, seedeKosten } = require('./seed') const mail = require('./mail') +const { erzeugeAbrechnung } = require('./abrechnung') const PORT = Number(process.env.PORT || 3000) const SESSION_DAUER_MS = 8 * 60 * 60 * 1000 @@ -1364,6 +1365,24 @@ app.post('/api/ki-antwort', async (req, res, next) => { } catch (e) { next(e) } }) +// ── Nebenkostenabrechnung als PDF (v1.23) ───────────────────── +app.post('/api/abrechnungen', auth, adminOnly, async (req, res, next) => { + try { + const unitId = String((req.body || {}).unitId || '') + const jahr = Number((req.body || {}).jahr) + if (!unitId || !Number.isInteger(jahr) || jahr < 2000 || jahr > 2100) + return res.status(400).json({ fehler: 'Wohnung und gültiges Jahr erforderlich.' }) + const { dokument, daten } = await erzeugeAbrechnung(pool, unitId, jahr) + console.log(`[abrechnung] ${dokument.title} erzeugt (${dokument.size})`) + res.json({ dokument: mapDocument(dokument), zusammenfassung: { + summe: daten.summe, vorauszahlung: daten.vorauszahlung, differenz: daten.differenz, + } }) + } catch (e) { + if (e.status) return res.status(e.status).json({ fehler: e.message }) + next(e) + } +}) + // ── Fehlerbehandlung ───────────────────────────────────────── app.use((err, _req, res, _next) => { console.error('[api]', err) diff --git a/src/pages/Kosten.tsx b/src/pages/Kosten.tsx index 8eb1c07..403fc0a 100644 --- a/src/pages/Kosten.tsx +++ b/src/pages/Kosten.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react' -import { Calculator, Receipt, ReceiptText, PiggyBank, Plus, Pencil, Trash2, Coins, FileScan, Check, ExternalLink, ScanSearch } from 'lucide-react' +import { Calculator, Receipt, ReceiptText, PiggyBank, Plus, Pencil, Trash2, Coins, FileScan, Check, ExternalLink, ScanSearch, FileDown, Landmark } from 'lucide-react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' @@ -193,6 +193,115 @@ function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }: ) } +/** Nebenkostenabrechnung als PDF erzeugen (nur Verwaltung, v1.23). */ +function AbrechnungDialog() { + const { units, erzeugeAbrechnung } = useStore() + const [open, setOpen] = useState(false) + const [einheit, setEinheit] = useState(units[0]?.id ?? '') + const [jahr, setJahr] = useState(String(new Date().getFullYear())) + const [sende, setSende] = useState(false) + const [fehler, setFehler] = useState(null) + const [ergebnis, setErgebnis] = useState<{ + dokumentId: string + zusammenfassung: { summe: number; vorauszahlung: number; differenz: number } + } | null>(null) + + const submit = async () => { + if (!einheit || sende) return + setSende(true) + setFehler(null) + setErgebnis(null) + const r = await erzeugeAbrechnung(einheit, Number(jahr)) + setSende(false) + if (r.fehler) return setFehler(r.fehler) + if (r.dokument && r.zusammenfassung) { + setErgebnis({ dokumentId: r.dokument.id, zusammenfassung: r.zusammenfassung }) + } + } + + return ( + + + + + + + Nebenkostenabrechnung als PDF + +
+

+ Erstellt die Abrechnung direkt aus dem Kosten- und Zählerstandsmodul: + umlegbare Kosten des Hauses werden nach Wohnflächenanteil verteilt, + die Nebenkostenvorauszahlung (Warm- − Kaltmiete × 12) wird abgezogen. + Das PDF landet im Dokumentenmodul der Wohnung. +

+
+
+ + +
+
+ + setJahr(e.target.value)} /> +
+
+ {fehler &&

{fehler}

} + {ergebnis && ( +
+
+ Abrechnung erzeugt +
+
+
+
Kosten
+
{fmtEUR.format(ergebnis.zusammenfassung.summe)}
+
+
+
Vorauszahlung
+
{fmtEUR.format(ergebnis.zusammenfassung.vorauszahlung)}
+
+
+
+ {ergebnis.zusammenfassung.differenz > 0 ? 'Nachzahlung' : 'Erstattung'} +
+
0 ? 'text-red-600' : 'text-emerald-600'}`}> + {fmtEUR.format(Math.abs(ergebnis.zusammenfassung.differenz))} +
+
+
+ + PDF herunterladen + +
+ )} +
+ + + + +
+
+ ) +} + /** Eingegangene SparkReceipt-Belege (nur Verwaltung). */ function BelegeListe() { const { belege, setBelegZugeordnet, analysiereBeleg, units } = useStore() @@ -414,7 +523,12 @@ export default function Kosten() { : 'Umlegbare und nicht umlegbare Kosten nach Haus und Wohnung – Grundlage für die Nebenkostenabrechnung'}

- {role === 'admin' && tab === 'kosten' && } + {role === 'admin' && tab === 'kosten' && ( +
+ + +
+ )} {role === 'admin' && ( diff --git a/src/store/store.tsx b/src/store/store.tsx index 172a043..cee8759 100644 --- a/src/store/store.tsx +++ b/src/store/store.tsx @@ -174,6 +174,12 @@ interface Store extends Bootstrap { deleteKosten: (id: string) => Promise // ── Belege (SparkReceipt-Import; Verwaltung ordnet zu) ── setBelegZugeordnet: (id: string, zugeordnet: boolean) => Promise + // ── Nebenkostenabrechnung als PDF (v1.23) ───────────────── + erzeugeAbrechnung: (unitId: string, jahr: number) => Promise<{ + fehler: string | null + dokument?: DocumentItem + zusammenfassung?: { summe: number; vorauszahlung: number; differenz: number } + }> /** Beleg erneut per OCR analysieren und Zuordnung aktualisieren. */ analysiereBeleg: (id: string) => Promise } @@ -509,6 +515,23 @@ export function StoreProvider({ children }: { children: ReactNode }) { return fehlerNachricht(e) } }, + // ── Nebenkostenabrechnung (v1.23) ───────────────────────── + async erzeugeAbrechnung(unitId, jahr) { + try { + const r = await apiPost<{ dokument: DocumentItem; zusammenfassung: { summe: number; vorauszahlung: number; differenz: number } }>( + '/api/abrechnungen', { unitId, jahr }, + ) + setDaten((d) => ({ + ...d, + documents: d.documents.some((x) => x.id === r.dokument.id) + ? d.documents.map((x) => (x.id === r.dokument.id ? r.dokument : x)) + : [...d.documents, r.dokument], + })) + return { fehler: null, dokument: r.dokument, zusammenfassung: r.zusammenfassung } + } catch (e) { + return { fehler: fehlerNachricht(e) } + } + }, }), [daten, bereit, me, role, meineEinheit, ladeAlles]) return {children}