v1.23: Nebenkostenabrechnung als PDF aus Kosten- und Zaehlermodul

This commit is contained in:
2026-10-04 14:38:51 +02:00
parent 7026019ced
commit 9707b6700d
9 changed files with 1258 additions and 16 deletions
Binary file not shown.
+17
View File
@@ -189,6 +189,23 @@ tar -czf mp-dist.tar.gz -C dist .
## 8. Verlauf / offene Punkte ## 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-<unit>-<jahr>.pdf`, idempotentes
documents-Row (Titel `Nebenkostenabrechnung <Jahr> – <Haus>, Whg. <Nr>`,
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 **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, automatischem CC an die Verwaltung. Mail-API (mail-server/api/server.py,
auf dem Server manuell ausgerollt, Container mp-mail-api per docker restart – auf dem Server manuell ausgerollt, Container mp-mail-api per docker restart –
+15 -14
View File
@@ -20,11 +20,11 @@ ws = wb.active
ws.title = 'Featureliste' ws.title = 'Featureliste'
ws.sheet_view.showGridLines = False ws.sheet_view.showGridLines = False
ws.merge_cells('B2:K2') ws.merge_cells('B2:L2')
c = ws['B2'] c = ws['B2']
c.value = 'UAT & Feature-Wunschliste – Hinterstein Mieterportal' c.value = 'UAT & Feature-Wunschliste – Hinterstein Mieterportal'
c.font = Font(bold=True, size=16, color=DUNKEL) c.font = Font(bold=True, size=16, color=DUNKEL)
ws.merge_cells('B3:K3') ws.merge_cells('B3:L3')
c = ws['B3'] c = ws['B3']
c.value = ('Gewünschte Features erfassen: Zeile ergänzen, Modul wählen, Priorität setzen. ' 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.') '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 col = 2 + i * 2
lc = ws.cell(row=5, column=col, value=label) lc = ws.cell(row=5, column=col, value=label)
lc.font = Font(size=9, bold=True, color='64748B') 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.font = Font(size=14, bold=True, color=AKZENT)
vc.alignment = Alignment(horizontal='left') vc.alignment = Alignment(horizontal='left')
KOPF = ['ID', 'Modul', 'Feature / Anforderung', 'Beschreibung / Akzeptanzkriterium', KOPF = ['ID', 'Modul', 'Feature / Anforderung', 'Beschreibung / Akzeptanzkriterium',
'Priorität', 'Wunschtermin', 'Eingereicht von', 'Eingereicht am', 'Status', 'Priorität', 'Wunschtermin', 'Eingereicht von', 'Eingereicht am', 'Status',
'Ergebnis im Test / Bemerkung'] 'Ziel-Version', 'Ergebnis im Test / Bemerkung']
BREITEN = [7, 20, 30, 48, 11, 13, 15, 12, 12, 34] BREITEN = [7, 20, 30, 48, 11, 13, 15, 12, 12, 12, 34]
SPALTEN = ['B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K'] SPALTEN = ['B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L']
HEADER_ZEILE = 9 HEADER_ZEILE = 9
for j, (name, breite, buchstabe) in enumerate(zip(KOPF, BREITEN, SPALTEN)): for j, (name, breite, buchstabe) in enumerate(zip(KOPF, BREITEN, SPALTEN)):
@@ -64,23 +64,23 @@ STATUS = ['Offen', 'In Prüfung', 'Umgesetzt', 'Abgelehnt']
beispiele = [ beispiele = [
['001', 'Steuer & Kosten', 'Nebenkostenabrechnung als PDF', ['001', 'Steuer & Kosten', 'Nebenkostenabrechnung als PDF',
'Pro Wohnung und Abrechnungsjahr eine PDF-Abrechnung aus Kosten + Zählerständen erzeugen und im Dokumentenmodul ablegen.', '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', ['002', 'Benutzerverwaltung', 'E-Mail-Adressen importierter Mieter erfassen',
'Fehlende E-Mail-Adressen der importierten Mieter erfassen, damit Benachrichtigungen versendet werden können.', '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', ['003', 'Mietkonto & Zahlungen', 'Zahlungseingang manuell als erledigt markieren',
'Verwaltung kann eine offene Buchung als „bezahlt“ markieren; Saldo und Fälligkeiten aktualisieren sich.', 'Verwaltung kann eine offene Buchung als „bezahlt“ markieren; Saldo und Fälligkeiten aktualisieren sich.',
'Niedrig', '', '', '', 'Offen', ''], 'Niedrig', '', '', '', 'Offen', '', ''],
] ]
duenn = Side(style='thin', color='E2E8F0') duenn = Side(style='thin', color='E2E8F0')
for i in range(100): for i in range(100):
zeile = 10 + i 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): for j, wert in enumerate(daten):
cell = ws.cell(row=zeile, column=2 + j, value=wert) cell = ws.cell(row=zeile, column=2 + j, value=wert)
cell.font = Font(size=10) 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) cell.border = Border(bottom=duenn)
if i % 2 == 1: if i % 2 == 1:
cell.fill = PatternFill('solid', fgColor=GRAU) 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.'), 'notieren, wann das Feature als fertig gilt. Je konkreter das Akzeptanzkriterium, desto schneller die Umsetzung.'),
('Priorität', ('Priorität',
'Hoch = dringend für den Betrieb · Mittel = wichtig, aber zeitunkritisch · Niedrig = Nice-to-have.'), 'Hoch = dringend für den Betrieb · Mittel = wichtig, aber zeitunkritisch · Niedrig = Nice-to-have.'),
('Status (wird von der Entwicklung gepflegt)', ('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).'), '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', ('UAT – Abnahme testen',
'Nach Umsetzung im Portal gegen das Akzeptanzkriterium testen. Ergebnis in der Spalte ' 'Nach Umsetzung im Portal gegen das Akzeptanzkriterium testen. Ergebnis in der Spalte '
'„Ergebnis im Test / Bemerkung“ festhalten: „OK“ oder Fehlerbeschreibung mit Schritten zum Nachvollziehen. ' '„Ergebnis im Test / Bemerkung“ festhalten: „OK“ oder Fehlerbeschreibung mit Schritten zum Nachvollziehen. '
@@ -156,7 +157,7 @@ for titel, text in bloecke:
zeile += 2 zeile += 2
wa.merge_cells(f'B{zeile}:C{zeile}') 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') c.font = Font(size=9, italic=True, color='94A3B8')
out = 'UAT-Featureliste Mieterportal.xlsx' out = 'UAT-Featureliste Mieterportal.xlsx'
+822
View File
@@ -10,12 +10,22 @@
"dependencies": { "dependencies": {
"express": "^4.21.2", "express": "^4.21.2",
"multer": "^2.0.2", "multer": "^2.0.2",
"pdfkit": "^0.15.2",
"pg": "^8.13.1" "pg": "^8.13.1"
}, },
"engines": { "engines": {
"node": ">=20" "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": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -35,12 +45,63 @@
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT" "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": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "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": { "node_modules/body-parser": {
"version": "1.20.8", "version": "1.20.8",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
@@ -65,6 +126,24 @@
"npm": "1.2.8000 || >= 1.4.16" "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": { "node_modules/busboy": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@@ -85,6 +164,24 @@
"node": ">= 0.8" "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": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "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" "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": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -150,6 +256,13 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT" "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": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -159,6 +272,72 @@
"ms": "2.0.0" "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": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -178,6 +357,12 @@
"npm": "1.2.8000 || >= 1.4.16" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -225,6 +410,26 @@
"node": ">= 0.4" "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": { "node_modules/es-object-atoms": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
@@ -316,6 +521,38 @@
"node": ">= 0.8" "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": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -343,6 +580,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -392,6 +638,30 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/has-symbols": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -404,6 +674,21 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/hasown": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -454,6 +739,20 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -463,6 +762,264 @@
"node": ">= 0.10" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -577,6 +1134,51 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -589,6 +1191,12 @@
"node": ">= 0.8" "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": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -604,6 +1212,19 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT" "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": { "node_modules/pg": {
"version": "8.23.1", "version": "8.23.1",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
@@ -693,6 +1314,23 @@
"split2": "^4.1.0" "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": { "node_modules/postgres-array": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -789,6 +1427,32 @@
"node": ">= 0.8" "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": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -809,6 +1473,23 @@
], ],
"license": "MIT" "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": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -860,6 +1541,38 @@
"node": ">= 0.8.0" "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": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -956,6 +1669,19 @@
"node": ">= 0.8" "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": { "node_modules/streamsearch": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
@@ -964,6 +1690,12 @@
"node": ">=10.0.0" "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": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -973,6 +1705,12 @@
"node": ">=0.6" "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": { "node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -986,6 +1724,32 @@
"node": ">= 0.6" "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": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -1013,6 +1777,64 @@
"node": ">= 0.8" "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": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+1
View File
@@ -13,6 +13,7 @@
"dependencies": { "dependencies": {
"express": "^4.21.2", "express": "^4.21.2",
"multer": "^2.0.2", "multer": "^2.0.2",
"pdfkit": "^0.15.2",
"pg": "^8.13.1" "pg": "^8.13.1"
} }
} }
+245
View File
@@ -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 }
+19
View File
@@ -14,6 +14,7 @@ const multer = require('multer')
const { pool, init, sessionsAufraeumen } = require('./db') const { pool, init, sessionsAufraeumen } = require('./db')
const { seed, seedeZaehler, seedeHaeuser, seedeKosten } = require('./seed') const { seed, seedeZaehler, seedeHaeuser, seedeKosten } = require('./seed')
const mail = require('./mail') const mail = require('./mail')
const { erzeugeAbrechnung } = require('./abrechnung')
const PORT = Number(process.env.PORT || 3000) const PORT = Number(process.env.PORT || 3000)
const SESSION_DAUER_MS = 8 * 60 * 60 * 1000 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) } } 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 ───────────────────────────────────────── // ── Fehlerbehandlung ─────────────────────────────────────────
app.use((err, _req, res, _next) => { app.use((err, _req, res, _next) => {
console.error('[api]', err) console.error('[api]', err)
+116 -2
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react' 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 { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' 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<string | null>(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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2">
<FileDown className="h-4 w-4" /> Abrechnung erzeugen
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Nebenkostenabrechnung als PDF</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-slate-500">
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.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>Wohnung</Label>
<Select value={einheit} onValueChange={setEinheit}>
<SelectTrigger>
<SelectValue placeholder="Wohnung wählen" />
</SelectTrigger>
<SelectContent>
{units.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.building}, Whg. {u.number}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="ab-jahr">Abrechnungsjahr</Label>
<Input id="ab-jahr" inputMode="numeric" value={jahr} onChange={(e) => setJahr(e.target.value)} />
</div>
</div>
{fehler && <p className="text-sm font-medium text-red-600">{fehler}</p>}
{ergebnis && (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm dark:border-emerald-900 dark:bg-emerald-950">
<div className="flex items-center gap-2 font-semibold text-emerald-800 dark:text-emerald-300">
<Check className="h-4 w-4" /> Abrechnung erzeugt
</div>
<div className="mt-2 grid grid-cols-3 gap-2 text-center">
<div>
<div className="text-xs text-slate-500">Kosten</div>
<div className="font-semibold">{fmtEUR.format(ergebnis.zusammenfassung.summe)}</div>
</div>
<div>
<div className="text-xs text-slate-500">Vorauszahlung</div>
<div className="font-semibold">{fmtEUR.format(ergebnis.zusammenfassung.vorauszahlung)}</div>
</div>
<div>
<div className="text-xs text-slate-500">
{ergebnis.zusammenfassung.differenz > 0 ? 'Nachzahlung' : 'Erstattung'}
</div>
<div className={`font-semibold ${ergebnis.zusammenfassung.differenz > 0 ? 'text-red-600' : 'text-emerald-600'}`}>
{fmtEUR.format(Math.abs(ergebnis.zusammenfassung.differenz))}
</div>
</div>
</div>
<a
href={`/api/documents/${ergebnis.dokumentId}/download`}
className="mt-3 inline-flex items-center gap-1.5 text-sm font-semibold text-emerald-700 underline-offset-2 hover:underline dark:text-emerald-300"
>
<Landmark className="h-4 w-4" /> PDF herunterladen
</a>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Schließen</Button>
<Button onClick={submit} disabled={!einheit || !jahr.trim() || sende} className="gap-2">
<FileDown className="h-4 w-4" /> {sende ? 'Erzeuge PDF …' : ergebnis ? 'Erneut erzeugen' : 'PDF erzeugen'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/** Eingegangene SparkReceipt-Belege (nur Verwaltung). */ /** Eingegangene SparkReceipt-Belege (nur Verwaltung). */
function BelegeListe() { function BelegeListe() {
const { belege, setBelegZugeordnet, analysiereBeleg, units } = useStore() 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'} : 'Umlegbare und nicht umlegbare Kosten nach Haus und Wohnung – Grundlage für die Nebenkostenabrechnung'}
</p> </p>
</div> </div>
{role === 'admin' && tab === 'kosten' && <KostenDialog />} {role === 'admin' && tab === 'kosten' && (
<div className="flex flex-wrap gap-2">
<KostenDialog />
<AbrechnungDialog />
</div>
)}
</div> </div>
{role === 'admin' && ( {role === 'admin' && (
+23
View File
@@ -174,6 +174,12 @@ interface Store extends Bootstrap {
deleteKosten: (id: string) => Promise<string | null> deleteKosten: (id: string) => Promise<string | null>
// ── Belege (SparkReceipt-Import; Verwaltung ordnet zu) ── // ── Belege (SparkReceipt-Import; Verwaltung ordnet zu) ──
setBelegZugeordnet: (id: string, zugeordnet: boolean) => Promise<string | null> setBelegZugeordnet: (id: string, zugeordnet: boolean) => Promise<string | null>
// ── 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. */ /** Beleg erneut per OCR analysieren und Zuordnung aktualisieren. */
analysiereBeleg: (id: string) => Promise<string | null> analysiereBeleg: (id: string) => Promise<string | null>
} }
@@ -509,6 +515,23 @@ export function StoreProvider({ children }: { children: ReactNode }) {
return fehlerNachricht(e) 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]) }), [daten, bereit, me, role, meineEinheit, ladeAlles])
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider> return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>