Commit Graph
177 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 5 1a349d142e hashVersion-Downgrade geschlossen (Pentest R167-01, HIGH)
verifyIntegrity waehlte die Pruefstaerke nach der von der Zeile selbst
deklarierten hashVersion - und die ist nicht gehasht. Angriff: hashVersion 2->1
setzen, die nur von V2 abgedeckten Felder aendern (success false->true,
errorMessage leeren, resourceLabel umschreiben) und den schwachen V1-Hash ueber
die 7 unveraenderten Felder nachziehen. Ergebnis: Fehl-Login als Erfolg
getarnt, Pruefung meldet "gueltig". An der letzten Zeile der Kette entsteht
dabei nicht einmal ein Gap - dauerhaft unsichtbar.

Fix: Version-Floor. Die erwartete Pruefstaerke leitet sich aus der Kette ab
(MIN(id) WHERE hashVersion >= 2), nicht aus der Selbstauskunft. Ab dieser
Grenze muss jede Zeile V2 sein; weicht die deklarierte Version ab, gilt die
Zeile selbst als manipuliert. Geprueft wird immer mit dem erwarteten Verfahren.
Die Grenze laesst sich durch Herabstufen einzelner Zeilen nicht verschieben.

Zusaetzlich konsultiert die Pruefung jetzt das Loeschungs-Manifest: neu
unexplainedGaps - nur Luecken ohne protokollierte Loeschung sind
erklaerungsbeduerftig. Vorher war das Manifest rein informativ, wodurch sich
eine boeswillige Loeschung als harmloser Gap tarnen konnte.

Verifiziert (PoC nachgebaut): Downgrade mit Nachfolger erkannt, Downgrade der
Tail-Zeile erkannt, Gegenrichtung (V1 faelschlich als V2) erkannt, keine
Falschmeldungen auf Bestandsdaten. tsc + vite build gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:12:13 +02:00
duffyduckandClaude Opus 5 f2a4baacdb Audit-Haerten: Fork, Feldabdeckung, Refresh-Rauschen, Route (Pentest R166)
R166-01 (HIGH): Der GET_LOCK-Ansatz gab die Sperre im finally INNERHALB des
Transaktions-Callbacks frei, also vor dem COMMIT. Im Fenster Release-Commit las
der naechste Schreiber ein noch nicht sichtbares Kettenende - zwei Zeilen hingen
am selben Vorgaenger. Meine vorherige Messung war zu schwach: sie suchte Luecken
zwischen Nachbarn, nicht Forks. Fix: einzeiliger Mutex AuditChainLock mit
FOR UPDATE (InnoDB-Zeilensperren fallen erst beim COMMIT) plus
isolationLevel ReadCommitted. Belegt im Direktvergleich mit geweitetem Fenster:
Release-vor-Commit forkt, Zeilensperre nicht.

R166-02 (MEDIUM): Der Hash deckte nur 7 Felder ab. changesBefore/After, success,
ipAddress, resourceLabel, dataSubjectId, userId/customerId waren ungeschuetzt -
ein Einzeledit dort blieb unsichtbar. Fix: hashVersion + generateHashV2 ueber
alle Inhaltsspalten. Bestandszeilen behalten Version 1 und bleiben ohne Rehash
gueltig. Verifiziert: 5/5 zuvor ungeschuetzte Felder werden jetzt erkannt.

R166-03 (LOW): "kein Cookie" (normaler Erstbesuch) wurde als HIGH/abgelehnt
gefuehrt - jetzt eigener Ausgang mit LOW. Nur echte Ablehnung bleibt HIGH.

R166-04 (LOW, pre-existing): GET /retention-policies wurde von GET /:id
verschluckt. Konkrete Routen jetzt vor der Parameter-Route.

Design-Empfehlungen: runRetentionCleanup schreibt ein Loeschungs-Manifest
(ID-Bereich, Anzahl, Policy, Cutoff) als eigenen verketteten Eintrag - Luecken
ausserhalb bleiben erklaerungsbeduerftig. rehashAll schreibt einen Marker.

Verifiziert: 50 parallele Schreiber -> 50/50, 0 Forks, alle V2, manipuliert 0,
Luecken unveraendert 7. tsc + vite build gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 14:56:10 +02:00
duffyduckandClaude Opus 5 c7d6b6de7e Audit-Pruefung: "manipuliert" von "Luecke" getrennt + Retention fuer Routine-Auth
Problem 1 (Deutbarkeit): verifyIntegrity warf zwei voellig unterschiedliche
Befunde in einen Topf und meldete beides als "N manipulierte Eintraege". Eine
harmlose Verkettungsluecke sah damit aus wie ein Angriff - die Meldung war im
Alltag nicht deutbar und dadurch wertlos, dasselbe Muster wie beim
Refresh-Rauschen.

Fix: Rueckgabe um tamperedEntries (Inhalt nachtraeglich veraendert, ernst) und
chainGaps (Verkettung unterbrochen durch parallele Schreibvorgaenge oder
geloeschte Zeilen, meist harmlos) erweitert. invalidEntries bleibt als Summe
erhalten. Controller formuliert die Meldung eindeutig, Frontend-API-Typ
nachgezogen.

Problem 2 (Aufbewahrung): Token-Refreshes landen seit der Entrauschung als
Authentication/LOW. Diese Kombination traf auf keine spezifische Regel und fiel
in die Auffangregel * mit 3650 Tagen - das Rauschen waere 10 Jahre aufbewahrt
worden, echte Logins nur 2. Fix: Regel Authentication/LOW mit 90 Tagen, als
idempotente Migration und im Seed.

Sensitivitaet steuert die Aufbewahrung und ist keine Alarmstufe - normale
Logins und Zugriffe auf Bankdaten/Ausweise bleiben bewusst CRITICAL, ein
Herabstufen wuerde still die Aufbewahrungsfrist verlaengern.

Verifiziert: Live-Test gegen Dev-DB - echte Manipulation einer Zeile wird als
manipuliert erkannt und nicht mit Luecken verwechselt, Ketten-Luecken bleiben
bei 7, Originalzustand exakt wiederhergestellt. tsc + vite build gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:30:36 +02:00
duffyduckandClaude Opus 5 477a850a91 Audit-Kette: Race beim Fortschreiben behoben (parallele Requests)
createAuditLog las den Vorgaenger-Hash und schrieb den neuen Eintrag als zwei
getrennte Schritte. Zwei parallele Requests lasen denselben letzten Hash und
haengten sich beide daran - die Kette zerriss (Bruchstellen im Bestand vom
05.05. und 07.05.2026).

Fix: Lesen + Schreiben in einer Transaktion, serialisiert ueber einen benannten
MySQL-Lock (GET_LOCK). Der Lock liegt in der DB und wirkt daher auch ueber
mehrere App-Instanzen hinweg. Release im finally, weil benannte Locks nicht
transaktional sind - sonst wandert die Sperre mit der Verbindung zurueck in den
Pool und blockiert alle weiteren Schreiber.

Verworfener erster Ansatz: SELECT ... FOR UPDATE auf das Kettenende nimmt Gap-/
Next-Key-Locks, die mit den gleichzeitigen INSERTs kollidieren - gemessen gingen
38 von 40 parallelen Eintraegen durch Deadlocks verloren, still verschluckt vom
catch. Ein fehlender Audit-Eintrag ist unsichtbar und damit gefaehrlicher als
ein sichtbarer Kettenbruch.

Verifiziert: 100 parallele Schreiber -> 100/100 geschrieben, 0 neue Brueche
(444 ms); Folge-Schreiber in 6 ms, IS_FREE_LOCK frei (kein Lock-Leak).
Ungueltige Zeilen bleiben bei den 7 historischen. tsc gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 09:37:34 +02:00
duffyduckandClaude Opus 5 89ae7b73a1 Audit-Integritaet: Dauer-Fehlalarm ueber 67 % des Logs behoben
verifyIntegrity meldete 3107 von 4630 Zeilen als "manipuliert" - davon 3100
Fehlalarme, exakt die Zeilen mit resourceId = NULL aus 08.02.-01.05.2026.

Ursache: Der R121-Fix nahm an, resourceId sei beim Schreiben immer undefined
gewesen (Key faellt bei JSON.stringify weg) und daher wuerden alle
Bestands-Hashes ohne Rehash matchen. Das gilt erst ab ~01.05.2026 - aeltere
Zeilen wurden mit explizitem null serialisiert, der Key war drin.

Sicherheitsrelevant, weil ein staendig grundlos ausloesender Alarm ignoriert
wird - echte Manipulation ginge im Laerm unter.

Fix: generateHashLegacy() reproduziert das alte Schreibverhalten,
verifyIntegrity akzeptiert Altbestand ueber diesen Fallback (nur geprueft,
wenn die aktuelle Variante nicht passt). Bewusst KEIN Rehash - der wuerde die
Manipulations-Beweiskraft der Vergangenheit zerstoeren. Gespeicherte Hashes
bleiben unangetastet.

Verifiziert: ungueltig 3107 -> 7 (echte Ketten-Brueche). Adversarial
gegengetestet: Manipulation an userEmail/action/endpoint/createdAt/resourceId
wird bei alten wie neuen Zeilen zu 100 % erkannt (10/10). tsc gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 09:32:22 +02:00
duffyduckandClaude Opus 5 ad520e20a7 Audit-Log: Pfad-Matching im finish-Handler gefixt (Pentest R165-01)
Die Entrauschung aus de0d6bd war live wirkungslos - aber nicht wegen eines
Deploy-Miss, sondern weil der Code nie erreicht wurde: auditMiddleware liest
req.path erst im res.on('finish')-Handler. Express strippt beim Router-Dispatch
den Mount-Prefix aus req.url und stellt ihn nur beim next()-Durchlauf wieder
her - ein terminaler Handler (res.json()) ruft nie next(), also bleibt req.path
router-relativ (/refresh statt /api/auth/refresh). Saemtliche
path.includes('/auth/...')-Checks liefen ins Leere -> Fallback POST->CREATE mit
Default-Sensitivitaet CRITICAL.

Betraf nicht nur den neuen TOKEN_REFRESH: LOGIN/LOGOUT/LOGIN_FAILED waren im
Audit-Stream seit jeher generisch (pre-existing), ebenso das endpoint-Feld.
Der SecurityEvent-Stream war nie betroffen (eigene emit-Calls), daher lief das
Alerting korrekt.

Fix: vollen Pfad einmal synchron beim Eintritt festhalten (req.originalUrl,
wird von Express nie mutiert) und downstream ausschliesslich diesen nutzen -
determineAction, generateHumanLabel, extractDataSubjectId, manuallyLoggedPaths
und endpoint. TOKEN_REFRESH zusaetzlich in die "immer loggen"-Ausnahme.

Verifiziert (E2E mit echter Middleware gegen Dev-DB): TOKEN_REFRESH/LOW,
TOKEN_REFRESH/HIGH, LOGIN/CRITICAL, LOGIN_FAILED/CRITICAL, LOGOUT/CRITICAL,
alle mit vollem endpoint-Pfad. tsc gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:11:44 +02:00
duffyduckandClaude Opus 4.8 d599eb3702 Refresh-Fehlschlag: Detection-Gap geschlossen (Pentest R164-01)
Fehlgeschlagener /auth/refresh (Replay/Brute-Force auf geraubte
Refresh-Tokens) wurde als TOKEN_REFRESH/LOW geloggt und entging der
Alarmierung - ein Angreifer konnte von /login auf /refresh ausweichen,
um unter der LOGIN_FAILED-Schwelle zu bleiben.

Audit-Actions speisen die Alert-Engine nicht (die zaehlt SecurityEvent
via emit). Fix daher an zwei Ebenen:

- Detection: refresh()-Catch emittiert TOKEN_REJECTED -> greift die
  bestehende Schwelle (>=3 TOKEN_REJECTED/5min/IP -> CRITICAL). Severity
  wie Access-Token: abgelaufen/revoked = LOW (kein Sofort-Alert),
  ungueltige Signatur/Manipulation = HIGH. auth.service reicht dafuer
  err.code REFRESH_EXPIRED/REFRESH_INVALID durch. "Kein Cookie" emittiert
  bewusst nicht (normaler Erstbesuch).
- Audit-Triage: fehlgeschlagener Refresh -> Sensitivitaet HIGH statt LOW
  + Label "Token-Refresh abgelehnt". Action bleibt TOKEN_REFRESH
  (semantisch ein Refresh, kein Login).

Verifiziert: tsx-Test abgelaufen->LOW, manipuliert/garbage->HIGH; tsc gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 18:20:21 +02:00
duffyduckandClaude Opus 4.8 de0d6bd817 Audit-Log: stiller Token-Refresh entrauscht (eigene Action TOKEN_REFRESH)
POST /auth/refresh wurde als CREATE / "Anmeldung erstellt" / CRITICAL /
anonymous geloggt und sah damit wie eine anonyme Login-Flut aus. Es ist
aber der regulaere Silent-Refresh des Frontend-Interceptors (Access-Token
lebt nur im Speicher -> nach Reload/401 einmaliger Cookie-Refresh).

- Neuer AuditAction-Wert TOKEN_REFRESH (Migration 20260818120000,
  idempotentes MODIFY COLUMN)
- determineAction() mappt /auth/refresh -> TOKEN_REFRESH, Label
  "Sitzung verlaengert (Token erneuert)", Sensitivitaet explizit LOW
  (statt Default Authentication -> CRITICAL)
- LOGIN/LOGOUT/LOGIN_FAILED bleiben unveraendert CRITICAL
- Frontend: Filter-Option + dezente Badge-Farbe + Typ-Union
- anonymous bewusst beibehalten (Endpoint ohne authenticate-Middleware)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 17:51:21 +02:00
duffyduckandClaude Opus 4.8 7bdb885a10 Mass-Assignment-Schutz: Nested-Vertragsdetails (Pentest R162-01)
createContract/updateContract spreadeten energyDetails/tvDetails/
carInsuranceDetails/mobileDetails (via ...mobileData) roh an Prisma.
Injizierte id/contractId konnten ein Detail-Objekt auf einen Fremdvertrag
reparenten oder den PK frei setzen (stilles 200 statt 400) - MEDIUM
(Integritaet; kein Cross-Tenant, staff-only, Portal 403).

Fix: Feld-Whitelists (pickEnergyScalars/pickMobileScalars/pickTvScalars/
pickCarInsuranceScalars, analog R158) an allen Spread-Stellen in create+update.
internet war bereits explizit (preparedInternetData) - safe. Whitelists
programmatisch gegen die DB-Spalten abgeglichen (minus id/contractId/
verschluesselt) - alle Diffs leer.

Verifiziert: energyDetails{basePrice:99.99, id:999999, contractId:fremd}
-> basePrice aktualisiert, ecd.id + contractId unveraendert (kein Reparenting).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 16:10:45 +02:00
duffyduckandClaude Opus 4.8 8964820542 Kunde-E-Mail: Pflicht nur beim Anlegen, nicht beim Bearbeiten
Bestandskunden ohne E-Mail bleiben editierbar. Beim Anlegen ist E-Mail
weiterhin Pflicht (Frontend required + Backend createCustomer). Die
Domain-Pruefung (keine verwaltete Provider-Domain) greift unveraendert bei
create UND update, falls eine E-Mail gesetzt wird.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 14:26:03 +02:00
duffyduckandClaude Opus 4.8 15142676ef Aufgaben ohne Kunde/Vertrag anlegbar
ContractTask.contractId nullable (Migration). Neuer staff-only Endpoint
POST /tasks fuer allgemeine Aufgaben ohne Vertrag/Kunde. Ohne Vertrag gibt
es keinen Kunden -> visibleInPortal serverseitig immer false, Portal-Reply
403 bei contractloser Aufgabe, getAllTasks-Portal-Filter schliesst sie
automatisch aus (kein contract-Match).

Task-Modal (Mitarbeiter): Checkbox "Ohne Kunde (allgemeine Aufgabe)" blendet
Kunden-/Vertragsauswahl UND "Im Kundenportal sichtbar" aus. Task-Liste zeigt
solche Aufgaben als "Allgemeine Aufgabe (ohne Vertrag)" ohne Vertrags-Link/
Zum-Vertrag-Button.

Verifiziert: contractlose Aufgabe -> contractId null, visibleInPortal
erzwungen false (auch wenn true geschickt); mit Vertrag weiterhin waehlbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 14:15:33 +02:00
duffyduckandClaude Opus 4.8 1a45c24abf MaLo-ID an die Lieferadresse (Strom/Gas) + Address-owner-Regression gefixt
MaLo-ID (Marktlokation) gehoert zur (Liefer-)Adresse, nicht zum Vertrag.
Address bekommt maloIdElectricity + maloIdGas (getrennte Marktlokationen je
Sparte), pflegbar im AddressModal (nur Lieferadresse). Im Vertrag ist die
MaLo-ID jetzt ein Lesefeld, das je nach Vertragstyp die MaLo der gewaehlten
Lieferadresse zeigt; ContractDetail/-Modal ebenso.

Schema + Migration 20260814100000: 2 Spalten (idempotent) + Daten-Migration
(bestehende EnergyContractDetails.maloId -> jeweilige Lieferadresse,
ELECTRICITY->maloIdElectricity / GAS->maloIdGas). Migrationslogik verifiziert.

Dabei einen selbst verursachten Regressions-Bug gefixt: beim R156-Umbau waren
die 10 owner*-Adressfelder aus der Address-Whitelist gefallen -> Eigentuemer-
Sektion speicherte seit cb21a2c nicht mehr. Address-Whitelist jetzt via
Pick-Helper, programmatisch gegen alle DB-Spalten abgeglichen (owner* + MaLo
drin, id/customerId/Timestamps raus). BankCard/Document gegengeprueft: ok
(nur documentPath bewusst upload-only ausgeschlossen).

Verifiziert: tsc+build gruen; owner + maloId speichern wieder, Injection
(id/customerId) blockiert; Daten-Migration Strom->Strom / Gas->Gas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 14:38:16 +02:00
duffyduckandClaude Opus 4.8 94e4fdee23 Mass-Assignment-Schutz: Contract-Create/Update Feld-Whitelist (R158)
Letzter Spread-Endpunkt: createContract/updateContract reichten rohen
...contractData an Prisma durch (via as any im Controller). customerId ist
zwar legitim aenderbar (Kunden-Select aktiv), aber id/contractNumber/
createdAt/updatedAt/portalPasswordEncrypted und die cancellation*Path-Felder
waren so mit-injizierbar. Jetzt explizite Feld-Whitelist (pickContractScalars),
konsistent zur R156-Haertung von BankCard/Address/Document.

Whitelist autoritativ aus den DB-Spalten abgeleitet - der ContractCreateData-
Typ ist unvollstaendig: previousProviderId/previousContractNumber/
previousCustomerNumber/nextReviewDate sind echte Formularfelder, die sonst
still gebrochen waeren. cancellation*Path bleiben bewusst draussen (nur ueber
die Upload-/Delete-Endpunkte setzbar).

Verifiziert: legit Felder (inkl. der 4 zuvor untypisierten) persistieren;
injizierte id/contractNumber/cancellationLetterPath werden ignoriert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 19:27:25 +02:00
duffyduckandClaude Opus 4.8 fc3131059b Neuer Vertragsstatus "Gekuendigt / bestaetigt" + Kuendigungs-Workflow
Bisheriges "Gekuendigt" (CANCELLED) umbenannt in "Gekuendigt / Bestaetigung
abwarten" und wird jetzt automatisch gesetzt, sobald ein Kuendigungsschreiben
hochgeladen wird. Neuer Status CANCELLED_CONFIRMED ("Gekuendigt / bestaetigt")
wird automatisch gesetzt, sobald ein Kuendigungsbestaetigungsdatum vorliegt
(Dokument fuellt das Datum oder manuell) + Vertragsende = Kuendigungsdatum.

Schema: Enum-Wert CANCELLED_CONFIRMED + Migration (idempotentes MODIFY COLUMN);
Daten-Migration hebt bestehende CANCELLED (alte Logik: nur bei Bestaetigung
gesetzt) auf CANCELLED_CONFIRMED.

Backend: neue Trigger maybeMarkAwaitingConfirmationOnLetter (Schreiben->CANCELLED)
im Upload-Handler; maybeCancelOnCancellationConfirmation setzt jetzt
CANCELLED_CONFIRMED (auch aus CANCELLED). Cockpit-Semantik mitgewandert
(Fristen-Skip/"beendet" fuer CANCELLED_CONFIRMED; Ladeliste + Kuendigungs-
bestaetigungs-Filter erweitert).

Frontend: Labels/Farben/Status-Erklaerungen + Status-Dropdown in ContractList,
ContractDetail, ContractForm, ContractDetailModal, CustomerDetail
(CANCELLED orange "abwarten", CANCELLED_CONFIRMED rot).

Verifiziert: tsc+build gruen; Schreiben->CANCELLED, Bestaetigung->
CANCELLED_CONFIRMED+Enddatum; Daten-Migration idempotent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 18:14:34 +02:00
duffyduckandClaude Opus 4.8 cb21a2c9be Mass-Assignment-Schutz: Bankkarte/Adresse/Ausweis (Pentest R155)
Die create/update-Services reichten rohen req.body an Prisma durch, wodurch
customerId (Owner) und id (PK) per Update mutierbar waren - staff-only, kein
Cross-Tenant-Bruch, aber echte Integritaetsschwaeche; mit dem neuen
cardNumber-Feld liegt zudem Finanz-PII auf dieser Flaeche.

Fix: explizite Feld-Whitelist im Service (create+update) fuer BankCard,
Address und IdentityDocument - nur benannte Spalten gehen an Prisma, kein
...data/req.body-Spread mehr. Controller-Helper pickBankCardFields haelt
zusaetzlich die Audit-Logs sauber (keine Phantom-Eintraege injizierter Keys).

Verifiziert: updateBankCard mit {customerId:99999, id:88888, bogusField, ...}
-> id+customerId unveraendert, nur cardNumber gesetzt, Fremdfelder ignoriert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 16:40:15 +02:00
duffyduckandClaude Opus 4.8 0d42bd89e8 Bankkarte-/Ausweis-Details in Vertrag + neues Feld Kartennummer
Schema: BankCard.cardNumber (String?, optional) + Migration
20260813100000_bank_card_number (ADD COLUMN IF NOT EXISTS), auf Dev
angewandt + prisma generate; Prod via migrate deploy. Eingabefeld
"Kartennummer" im Bankkarten-Modal (Kundenakte).

Vertragsansicht (ContractDetail) und Vertrag bearbeiten (ContractForm)
zeigen jetzt bei Bankkarte zusaetzlich BIC/Bank/Kartennummer/Ablaufdatum
und bei Ausweis Behoerde/Ausstellung/Ablaufdatum sowie Geburtsort +
Geburtsdatum des Kunden - jeweils mit Copy-Button und nur wenn gesetzt.
Im Form je Select in eigenem div gewrappt (Grid-Alignment).

Verifiziert: tsc + vite build gruen, cardNumber Round-Trip (update->read),
Contract-Include liefert alle Felder inkl. customer.birthDate/birthPlace.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 15:00:29 +02:00
duffyduckandClaude Opus 4.8 06d51cf680 PDF-Viewer-Modal fuer Bankkarte-/Ausweis-Dokument
Neue Komponente PdfViewerModal (Modal + iframe auf viewUrl(documentPath),
inkl. Neuer-Tab/Download-Links). Buttons:
- Vertragsansicht (ContractDetail): im Card-Header von Bankkarte/Ausweis
- Vertrag bearbeiten (ContractForm): neben dem Label Bankkarte/Ausweis

Button erscheint NUR wenn ein documentPath hinterlegt/gewaehlt ist. Nutzt den
bestehenden /api/files/download-Endpoint (Per-File-Ownership-Check) - kein
neuer Zugriffspfad. iframe (nicht embed/object wegen object-src 'none');
same-origin via CSP default-src 'self' erlaubt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 13:15:57 +02:00
duffyduckandClaude Opus 4.8 b2b1cb3387 Vertragssuche: Karteninhaber (cardUser) durchsuchbar (Pentest R150)
getAllContracts durchsucht jetzt auch simCards.cardUser - zusaetzlich zu
Rufnummer/SIM-Nummer/IMEI. R150-Randnotiz, fachlich gewuenscht (Staff sucht
nach Karteninhaber). Portal-Suche bleibt durch das bestehende customerIds-
Scoping begrenzt.

Verifiziert: Suche nach gesetztem cardUser findet den Vertrag, Kontroll-
Suche 0 Treffer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 12:50:36 +02:00
duffyduckandClaude Opus 4.8 2d12221c41 Vertragsliste (Staff-Tabelle): Karteninhaber/Netz/Kuendigung nachgezogen
Der vorige Commit ergaenzte nur die Baum-Render-Pfade (Kundenakte + Portal).
Die Staff-Ansicht unter /contracts rendert aber eine eigene Tabelle aus der
flachen getAll-Liste - dort fehlten die neuen Felder noch. Jetzt zeigt auch
die Staff-Tabelle Karteninhaber + Netz hinter der Rufnummer und die
Kuendigungsbestaetigung in roter Schrift. Backend liefert die Felder via
getAllContracts bereits mit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 12:26:58 +02:00
duffyduckandClaude Opus 4.8 c5cf2c7416 Vertragslisten: Karteninhaber + Mobilfunknetz + Kuendigung (rot)
Beide Vertragslisten (Kundenakte-Baum + Hauptmenue /contracts) zeigen bei
Mobilfunkvertraegen mit Rufnummer jetzt zusaetzlich Karteninhaber
(SimCard.cardUser der angezeigten SIM, nur wenn gesetzt) und Netz
(mobileNetwork -> Telekom/Vodafone/Telefonica (o2)) inline. Vertraege mit
Kuendigungsbestaetigung (cancellationConfirmationDate) bekommen eine eigene
Zeile in roter, fetter Schrift.

Backend: getContractTreeForCustomer + getAllContracts selektieren nun
mobileNetwork + cardUser (Tree zusaetzlich cancellationConfirmationDate).
Shared-Helper getContractTypeInfo um cardUser/network + mobileNetworkLabel
erweitert. Beide Listen rendern ueber den Tree-Endpoint.

Verifiziert: tsc + vite build gruen, Tree-Service liefert die Felder,
Helper-Mapping getestet (Vodafone/Telekom/o2, leerer Karteninhaber ausgelassen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 12:18:24 +02:00
duffyduckandClaude Opus 4.8 8e46dbbfed Globaler /api-Rate-Limit-Backstop (Pentest R148)
Es gab bislang keinen generellen Limiter - nur Login/Passwort-Reset/
Staff-ReAuth/Consent; authentifizierte Endpoints waren gegen Enumeration/
DoS ungedrosselt. Neuer apiBackstopRateLimiter auf alle /api-Requests,
vor den Routern gemountet (ergaenzt die feineren Limiter, ersetzt sie nicht).

Key = nur IPv6-/56-normalisierte IP - bewusst nicht IP+User, da der User-
Claim hier nur unverifiziert lesbar waere (authenticate laeuft erst pro
Route) und ein Angreifer sonst per Fake-userId beliebig Buckets erzeugen
koennte. Limit per Env API_RATE_LIMIT_PER_MIN (Default 1200/min, Floor 60),
/api/health ausgenommen. Kein SecurityEvent pro Block (Flood-Amplification).
Deckt zugleich den offenen IPv6-Rate-Limit-Test ausserhalb der Auth-Pfade ab.

Verifiziert: 60x200 dann 429, health bleibt 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 14:54:12 +02:00
duffyduckandClaude Opus 4.8 91fbe12299 BLZ-Bankdaten: Laufzeit-Auto-Update ins Volume + Einstellungen-Seite
Statt npm-Rebuild-Wartung aktualisiert sich der Bankleitzahlen-Datensatz
jetzt zur Laufzeit. Ein Scheduler (taeglich 03:30 + Catch-up 90s) prueft
gemaess konfigurierbarem Intervall und laedt current.json/next.json von
npm/jsDelivr (Paket bankdata-germany) in das neue Bind-Mount-Volume
BANKDATA_DIR (./data/bankdata -> /app/bankdata).

Lookup bevorzugt den Volume-Datensatz vor den ins Image gebackenen Daten
(Fallback). Es wird kein Fremdcode ausgefuehrt - nur JSON gelesen und die
current+next-Delta-Logik nachgebaut. Validierung (>=1000 Eintraege, Format)
+ atomarer Write (tmp+rename) schuetzen den guten Stand vor Muell.

Datenschutz: Der Updater sendet keine Kundendaten, laedt nur eine
oeffentliche Datendatei; abschaltbar; bei Fehler/ohne Egress greift Builtin.

Neue Einstellungen-Seite /settings/bank-data zeigt Datenstand, Update-
Verfuegbarkeit und bietet "Jetzt aktualisieren" + Auto-Update-Schalter +
Intervall. Endpoints GET /api/settings/blz, POST /api/settings/blz/update-now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 13:15:07 +02:00
duffyduckandClaude Opus 4.8 73bd72bd9f Bankkarte: IBAN pruefen + BIC/Bank offline aus Bundesbank-BLZ ausfuellen
Neuer Button "BIC & Bank aus IBAN abrufen" im Bankkarten-Modal fuellt BIC +
Banknamen automatisch und validiert dabei die IBAN-Pruefziffer (mod-97).
Leeres IBAN-Feld -> OK-Messagebox statt Anfrage.

Datenschutzfreundlich/offline: kein Dritt-Dienst. Nachschlag im eigenen
Backend ueber die Bundesbank-Bankleitzahlendatei (bankdata-germany) +
ibantools fuer die Pruefziffer. Die IBAN verlaesst nie den Server; zurueck
kommen nur oeffentliche Bankverzeichnis-Daten.

Endpoint: POST /api/bank-cards/iban-lookup (nur eingeloggt).
Wartung: bankdata-germany/ibantools ~quartalsweise per npm update ziehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 12:32:53 +02:00
duffyduckandClaude Opus 4.8 4c5548ea3c Belegübersicht: fail-closed Portal-Scoping (R144)
Pentester R144 (fail-closed, kein Finding): listAll scopte ueber
'if (isCustomerPortal && customerId)'. Fiele customerId bei einem
Portal-Token mal falsy aus, rutschte er in den Staff-Zweig (alle
Belege).

Jetzt: Portal-Token wird IMMER gescoped; ohne customerId -> leere
Menge (customerIds=[]) statt undefined/Staff. In:[] kann nie matchen.
Aktuell nicht erreichbar (Portal-Token traegt immer customerId), aber
robuster.

Verifiziert: customerIds=[] -> 0 Belege.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 11:18:46 +02:00
duffyduckandClaude Opus 4.8 48e65be91c Hauptmenue: Gutschriften/Lieferscheine-Gesamtuebersicht (portal-scoped)
Neuer Menuepunkt 'Gutschriften' -> Seite /credit-notes mit Tabelle
aller Belege (Beleg-Nr, Art, Kunde, Vertrag, Betrag, Datum, PDF),
Suche + Pagination.

Neuer Endpoint GET /credit-notes (NICHT staff-only wie die uebrigen
Credit-Note-Endpoints): Staff sieht alle Belege aller Kunden, Portal-
Kunden nur eigene + vertretene (Vollmacht via hasAuthorization).
customerIds kommt aus dem JWT, nicht aus Query/Body -> nicht
manipulierbar. Fuer Portal wird receiptPath aus der Response entfernt
(Belege bleiben staff-only). Route requirePermission contracts:read.

Verifiziert: Staff -> alle Belege; Portal-scoped -> nur eigene, korrekt
zugeordnet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 10:23:53 +02:00
duffyduckandClaude Opus 4.8 5047bfaab3 Gutschrift: nur mit Empfaengeradresse anlegbar (Rechnung > Liefer)
Eine Gutschrift/ein Lieferschein braucht eine Empfaengeradresse aufs
Dokument. Rechnungsadresse hat Vorrang, sonst Lieferadresse. Ist keine
von beiden hinterlegt -> Anlegen blockiert.

- Frontend: Klick auf 'Gutschrift anlegen' prueft defaults.hasRecipient-
  Address; wenn false -> Modal-OK-Meldung statt Formular.
- Backend Defense-in-Depth: createCreditNote wirft 400, wenn weder
  billingAddressId noch addressId gesetzt. getCreditNoteDefaults liefert
  hasRecipientAddress.

Verifiziert: ohne Adresse -> hasRecipientAddress false + create 400;
mit Adresse -> ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 09:39:04 +02:00
duffyduckandClaude Opus 4.8 0078654465 Gutschrift: Beleg-Upload auch fuer Sachwerte + kein Unterschriftsblock bei Geld
- Beleg-Upload jetzt fuer beide Arten: bei Geld die Ueberweisungs-
  bestaetigung, bei Sachwert das unterschriebene Dokument. ReceiptControls
  in der Liste fuer Geld UND Sachwert (Label je nach Typ). Endpoint war
  schon typ-agnostisch.
- PDF-Unterschriftsblock nur noch bei Sachwert - eine Ueberweisung wird
  nicht unterschrieben (Beleg = hochgeladene Ueberweisungsbestaetigung).
  Bei Geld entfaellt der Unterschrift/Ort-Block; im Formular sind Ort +
  'Unterschrift am' bei Geld ausgeblendet.

Verifiziert: beide PDFs erzeugen sauber (je 1 Seite).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 09:26:35 +02:00
duffyduckandClaude Opus 4.8 5e606df49e Gutschrift: separater Lieferschein-Nummernkreis (GoBD, luecken-frei)
Pentester R142: Uebergang Geld->betragsloser Sachwert setzte die schon
vergebene Gutschriftsnummer auf null -> Luecke in der GS-Serie.

Loesung: betragsloser Sachwert = Lieferschein mit eigener
Lieferscheinnummer aus separatem Nummernkreis.

- Schema: CreditNote.deliveryNoteNumber (nullbar, unique) + neues Model
  DeliveryNoteNumberRange (Default-Praefix 'LS-') + Migration.
- deliveryNoteNumberRange.service (mirror, eigener Zaehler, FOR UPDATE).
- Nummern lazy pro Serie, NIE freigeben: Uebergaenge behalten die
  jeweils vergebene Nummer der anderen Serie reserviert -> kein
  Doppelverbrauch, keine Luecke. effectiveNumber() liefert je nach Typ
  die passende (LS/GS) fuer Anzeige/PDF/Audit.
- Endpunkte GET/PUT /credit-notes/delivery-note-number-range; Settings-
  Seite verwaltet jetzt beide Nummernkreise. PDF-Titel 'Sachwert-
  Uebergabe', Dateiname lieferschein-...
- Frontend: Typ + displayNumber in Liste/Modal.

Verifiziert: Sachwert 0 -> LS-Nr, GS-Zaehler unberuehrt; Geld -> GS-Nr;
Uebergaenge behalten beide Nummern (kein Neuverbrauch, keine Luecke).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 08:47:49 +02:00
duffyduckandClaude Opus 4.8 79f6f3e629 Portal-Passwort: Reveal/Send prueft Konsistenz gegen Login-Hash
Pentester-Hinweis: bcrypt-Hash (Login) und verschluesseltes Reveal-Feld
koennen out-of-sync sein -> Support liest ein Passwort vor, das beim
Login scheitert.

Analyse: alle aktuellen Schreibpfade sind konsistent (beide Felder
zusammen, oder encrypted=null, oder Rehash desselben Passworts) - der
Code erzeugt keinen Desync. Ursache = Altlast/manueller DB-Eingriff.

Fix (defensiv, unabhaengig von der Ursache):
- getCustomerPortalPassword liefert {status: ok|none|desync} und prueft
  den entschluesselten Klartext per bcrypt.compare gegen den Login-Hash.
- Bei desync (oder Entschluesselungsfehler) geben WEDER Reveal NOCH
  Send-Credentials das Passwort aus -> 409 'Dateninkonsistenz, bitte
  neu setzen'. Reveal-Read wird mit Status auditiert.
- Neues Diagnose-Script scripts/check-portal-password-sync.ts scannt
  alle Portal-Kunden auf Desync (nur Diagnose, aendert nichts) - fuer
  Prod, da der Pentester keinen FS-Zugriff hat.

Verifiziert: desync -> nicht ausgegeben; konsistent -> ok; kein PW ->
none. Scan laeuft (0 Desync auf Dev).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-11 22:46:53 +02:00
duffyduckandClaude Opus 4.8 15ac003dad Gutschrift: betragsloser Sachwert bekommt keine Gutschriftsnummer
Ein betragsloser Sachwert ist eher ein Lieferschein als eine Gutschrift
-> er soll KEINE Gutschriftsnummer aus dem Nummernkreis verbrauchen.

- Schema: CreditNote.number nullbar (Migration MODIFY ... NULL, UNIQUE
  bleibt - MySQL erlaubt mehrere NULLs).
- createCreditNote: betragsloser Sachwert -> number=null, assignNextNumber
  wird NICHT aufgerufen (Zaehler unangetastet).
- updateCreditNote: Uebergaenge - wird betragslos -> Nummer entfernen;
  bekommt nachtraeglich Betrag & hatte keine -> jetzt Nummer vergeben.
- PDF/Liste/Modal/Audit: Fallback 'Sachwert-Uebergabe'/'Beleg #id' wenn
  keine Nummer; PDF-Titel 'Sachwert-Uebergabe', kein ZUGFeRD (schon vorher).

Verifiziert: Sachwert 0 -> number null + Zaehler bleibt; Geld -> Nummer
+ Zaehler +1; Sachwert nachtraeglich mit Betrag -> Nummer vergeben.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 17:40:15 +02:00
duffyduckandClaude Opus 4.8 6f95005530 Gutschrift: Sachwert ohne Betrag = keine Rechnung (kein ZUGFeRD)
Eine Sachwert-Gutschrift darf betragslos sein (Betrag leer/0): dann
findet keine Rechnungsstellung statt - der Kunde hat den Gegenstand
einfach als Subvention erhalten.

- Backend: leeres amount -> 0; Sachwert erlaubt 0, Geld verlangt > 0
  (400 sonst). vatRelevant bei 0 erzwungen-false.
- PDF: betragsloser Sachwert -> Titel 'Sachwert-Uebergabe', kein
  Betrags-/USt-Block (Hinweis keine Rechnungsstellung), KEIN ZUGFeRD-
  Embedding. Mit Betrag -> unveraendert ZUGFeRD.
- Frontend: Wert-Feld bei Sachwert optional; USt-Block ausgeblendet ohne
  Betrag; Liste zeigt 'Sachwert ohne Betrag (keine Rechnung)'.

Verifiziert: Sachwert 0 -> kein factur-x.xml; Geld 0 -> 400;
Sachwert 200 -> ZUGFeRD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 13:22:13 +02:00
duffyduckandClaude Opus 4.8 4d14390b2a Cockpit-Filter Kuendigungsbestaetigung: Status EXPIRED mitzaehlen
Verträge mit hinterlegter Kuendigungsbestaetigung im Status EXPIRED
(Abgelaufen) werden jetzt auch im Cockpit-Filter gelistet, zusaetzlich
zu ACTIVE/DRAFT/CANCELLED. Die Cockpit-Query laedt EXPIRED ohnehin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 23:42:31 +02:00
duffyduckandClaude Opus 4.8 3e2d9395a7 Hygiene R140: Datei-Loesch-Helfer konsolidieren + DB-vor-Datei
Pentester-Hygiene zu a6b1dac:

1) Konsolidierung: neuer utils/fileCleanup.ts mit deleteFileAbsolute
   (absoluter Pfad, z.B. Multer-Temp) + deleteUploadByRelativePath
   (in DB gespeicherter /uploads/-Pfad). Ersetzt die 3x kopierten
   deleteFileIfExists/cleanupFile in creditNote-, upload- und
   customer-Service.

2) Reihenfolge: In deleteCreditNote/updateCreditNote erst die DB-
   Operation, DANN die Datei loeschen. Schlaegt der DB-Schritt fehl,
   bleibt die Datei erhalten (kein ins-Leere-zeigender Zustand).

Verifiziert: Update -> pdfPath null + alte Datei weg; Delete -> gibt
geloeschte Row zurueck (Audit) + Datei weg. Kein Regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 22:57:40 +02:00
duffyduckandClaude Opus 4.8 5f06ed9830 Fix Folgevertrag aus deaktiviertem Vertrag + Kundendaten-Modal erweitern
1) Folgevertrag-Bug: Backend legt den Folgevertrag korrekt mit
   previousContractId an. Der Verlust passierte im Frontend - das
   Vorgaenger-Dropdown holt Vertraege ueber getAll, das DEACTIVATED
   standardmaessig ausblendet. Beim Bearbeiten des Folgevertrags war der
   deaktivierte Vorgaenger nicht als Option da -> Verknuepfung ging beim
   Speichern verloren.
   Fix: getAllContracts + Controller + contractApi.getAll um
   includeDeactivated erweitert; Vorgaenger-Dropdown nutzt es und markiert
   deaktivierte Vertraege mit '· deaktiviert'. Verifiziert (Flag inkludiert
   deaktivierte; Folgevertrag setzt previousContractId).

2) Kundendaten-Modal: zeigt jetzt zusaetzlich Lieferadresse + (falls
   abweichend) Rechnungsadresse des geoeffneten Vertrags, die Stressfrei-
   Adresse des Vertrags einzeln und darunter alle weiteren Stressfrei-
   Adressen des Kunden. CustomerInfoModal nimmt optionale Vertragskontext-
   Props; ContractDetail + ContractForm uebergeben sie.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 22:49:41 +02:00
duffyduckandClaude Opus 4.8 a6b1dac606 Gutschriften: Dateien beim Loeschen/Bearbeiten mit aufraeumen (R138)
Pentester-Hinweis: generierte Gutschrift-PDFs blieben nach dem Loeschen
der Gutschrift als verwaiste Files im Upload-Ordner liegen (harmlos, da
ohne DB-Referenz nicht mehr abrufbar - aber unsauber).

deleteCreditNote entfernt jetzt PDF (pdfPath) + Ueberweisungsbeleg
(receiptPath) von der Platte. updateCreditNote loescht das alte PDF
beim Leeren von pdfPath. Kein verwaister Ordner-Muell mehr.

Verifiziert: PDF nach Erzeugung vorhanden, nach Loeschen der Gutschrift
weg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 22:31:33 +02:00
duffyduckandClaude Opus 4.8 5fb01627b0 Vertrag-Update: Kuendigungs-Datumsfelder robust normalisieren (R138)
cancellationConfirmationDate / cancellationConfirmationOptionsDate im
PUT /contracts/:id ueber validateOptionalIsoDate normalisieren: nimmt
Datum-only (YYYY-MM-DD) UND volles ISO, liefert einen sauberen Date an
Prisma. Vorher lehnte Prisma ein Datum-only ab (400 statt Verarbeitung).
Ungueltige Formate -> sauberes 400. Konsistent zum Upload-Pfad, der
confirmationDate bereits so validiert.

Verifiziert: '2027-09-13' akzeptiert + als DATETIME geschrieben;
deutsches Format abgelehnt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 21:40:31 +02:00
duffyduckandClaude Opus 4.8 8169c74d8e Vertrag: Auto-CANCELLED bei Kuendigungsbestaetigung + Cockpit-Filter
1) Auto-Status: Wird eine Kuendigungsbestaetigung hinzugefuegt (Datum
   ueber Formular und/oder Dokument-Upload), wird der Vertrag von ACTIVE
   auf CANCELLED gesetzt und das Vertragsende = Kuendigungsdatum.
   Zentrale Funktion maybeCancelOnCancellationConfirmation (idempotent,
   nur aus ACTIVE). Upload-Route ersetzt die alte Inline-Logik (setzt
   jetzt auch endDate); Update-Controller triggert nur bei neu/geaendertem
   Bestaetigungsdatum (manuelle Status-Korrekturen bleiben unangetastet).

2) Cockpit-Filter 'Kuendigungsbestaetigung': neue Liste
   cancellationConfirmations (Vertraege mit Bestaetigung in Status
   ACTIVE/DRAFT/CANCELLED) + Filter-Option im Cockpit-Dropdown. Eigene
   Liste, weil bereits CANCELLED-Vertraege mangels Issue sonst nicht in
   der Cockpit-Liste auftauchen.

Beides lokal verifiziert (Helper: ACTIVE->CANCELLED + endDate; Cockpit:
Vertrag erscheint in der Liste mit korrektem Status).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 15:13:05 +02:00
duffyduckandClaude Opus 4.8 5423c83009 Gutschriften Phase 3b/2: ZUGFeRD/Factur-X (hybrides PDF/A-3)
- zugferd.service.ts: CII-XML (EN 16931, urn:cen.eu:en16931:2017,
  Dokumenttyp 381 Gutschrift). Steuerkategorie S bei USt, sonst E mit
  Befreiungsgrund. Verkaeufer=Firma (CompanyProfile), Kaeufer=Kunde.
- zugferdPdf.service.ts: bettet factur-x.xml als AF /Data ein, setzt
  sRGB-OutputIntent + XMP (PDF/A-3B pdfaid + Factur-X-Extension-Schema)
  via pdf-lib.
- creditNotePdf: eingebettete DejaVuSans-Fonts (Pflicht fuer PDF/A)
  statt Standard-Helvetica; nach PDF-Erzeugung ZUGFeRD-Embedding.
- assets/fonts (DejaVuSans + Bold) + assets/icc (sRGB) ins Repo;
  Dockerfile kopiert backend/assets ins Runtime-Image.

Lokal strukturell verifiziert: 1 Seite, /AF, /Metadata, /OutputIntents,
EmbeddedFiles, Font eingebettet, XML wohlgeformt (xmllint), TypeCode
381, GrandTotal korrekt.

WICHTIG: vor Prod gegen einen ZUGFeRD-/Factur-X-Validator pruefen
(Staging + echte Firmendaten). Feinheiten (Trailer-ID, XMP, XML-MIME)
ggf. nach erstem Validator-Lauf nachziehen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-07 12:56:12 +02:00
duffyduckandClaude Opus 4.8 f24c4308aa Gutschriften: Auszahlungskonto (Bankkonto des Kunden) fuer Ueberweisungen
Bei Geld-Gutschriften kann die Auszahlung auf ein anderes Konto gehen
als das Vertrags-Abbuchkonto:

- CreditNote.payoutBankCardId + Migration (FK ON DELETE SET NULL, damit
  Loeschen einer Bankkarte die Gutschrift nicht mitreisst).
- Formular: Dropdown mit allen Bankkonten des Kunden (Default =
  Vertrags-Abbuchkonto). getCreditNoteDefaults liefert bankCards +
  contractBankCardId.
- Server prueft, dass die gewaehlte Bankkarte dem Kunden des Vertrags
  gehoert (kein Fremdkonto unterschieben).
- PDF: bei Ueberweisung 'Unsere Bankverbindung' (Absender) + darunter
  'an Bankkonto: <Kunden-IBAN> (<Inhaber>)'. Section-Zeile zeigt das
  Auszahlungskonto.

Lokal verifiziert (Anlage + PDF).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 13:49:35 +02:00
duffyduckandClaude Opus 4.8 642fb05c76 Gutschriften Phase 3b/1: Gutschrift-PDF (pdfkit)
PDF-Erzeugung fuer Gutschriften mit Firmenstammdaten als Absender,
Empfaenger (Kunde+Adresse), Netto/USt/Brutto (bzw. ohne USt-Ausweis),
Sachwert/'Ware erhalten' bzw. Ueberweisungs-Bankverbindung,
Unterschriftsblock, Firmen-Fusszeile.

Endpoint POST /credit-notes/:id/pdf; 'PDF'-Button in CreditNotesSection
(erzeugen + im Tab oeffnen bzw. vorhandenes ansehen). PDF-Pfad wird bei
inhaltlicher Aenderung geleert -> Neu-Erzeugung. Download ueber
fileDownload (credit-notes subDir, Vertrags-Ownership).

Lokal verifiziert (valides %PDF, korrekte Betraege).

ZUGFeRD-XML-Embedding (PDF/A-3, EN 16931) folgt als Teil 2 mit
Validator-Pruefung.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 13:32:34 +02:00
duffyduckandClaude Opus 4.8 9e38cb32f0 Gutschriften Phase 2b: Vertrags-UI + Beleg-Upload + Nummernkreis-Seite
Frontend:
- CreditNotesSection im Vertragsdetail (eigene Section wie Rechnungen):
  Liste + Anlegen/Bearbeiten (Modal). Geld/Sachwert mit passenden
  Feldern, USt-Block mit Live-Vorschau Netto/USt/Brutto, Datum, Ort,
  Unterschrift, 'Ware erhalten'. Loeschen mit Bestaetigung.
- Ueberweisungsbeleg-Upload/-Anzeige/-Entfernen (nur Geld-Gutschriften).
- Nummernkreis-Verwaltung als Settings-Seite mit Live-Vorschau der
  naechsten Nummer.
- Frontend-Typen (CreditNote etc.) + creditNoteApi.

Backend:
- Ueberweisungsbeleg-Upload-Route /upload/credit-notes/:id/receipt
  (+ DELETE), Portal geblockt, Ownership ueber Vertrag.
- fileDownload: Ownership-Aufloesung fuer credit-note-receipts +
  credit-notes (Vertrag) ergaenzt.

Als Section statt Tab-Balken, weil die Vertragsansicht kartenbasiert
ist (kein bestehender Tab-Balken); spaeter umbaubar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 13:15:02 +02:00
duffyduckandClaude Opus 4.8 3580c51cb1 Gutschriften Phase 2a: Kleinunternehmer-Flag am Kunden + USt-Default
Kunden-Feld vatExempt (Kleinunternehmer/USt-befreit §19) + Migration
(ADD COLUMN IF NOT EXISTS). Checkbox im Kundenformular nur fuer
Firmenkunden.

Gutschrift-Vorbelegung vatRelevant wird aus dem Kunden abgeleitet:
Firmenkunde ohne USt-Befreiung -> USt-relevant an (Netto), sonst aus
(wie Privat). Jede Gutschrift speichert ihren eigenen Snapshot, ein
spaeterer Statuswechsel des Kunden aendert bestehende Gutschriften
nicht.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 10:10:13 +02:00
duffyduckandClaude Opus 4.8 f1c37a7d25 Gutschriften Phase 1: Backend (Modell, Nummernkreis, CRUD, USt-Rechnung)
Neues Feature Gutschriftsverwaltung (Subventionen am Vertrag):

- Modelle CreditNote + CreditNoteNumberRange + Enums (GELD/SACHWERT,
  PRIVAT/FIRMA, NETTO/BRUTTO) + Migration (IF NOT EXISTS, auf Dev
  angewandt).
- USt pro Gutschrift waehlbar (vatRelevant + Basis Netto/Brutto +
  Satz); Netto/USt/Brutto werden berechnet und getrennt gespeichert
  (ZUGFeRD-tauglich). Kundentyp Privat/Firma aus Kunde vorbelegt.
- Nummernkreis in Settings verwaltbar; Nummernvergabe transaktional
  mit SELECT ... FOR UPDATE (keine Doppelvergabe). Bsp GS-2026-0001.
- Service/Controller/Routes: GET/POST /contracts/:id/credit-notes,
  GET .../defaults, GET/PUT/DELETE /credit-notes/:id,
  GET/PUT /credit-notes/number-range. Portal-Token geblockt (interner
  Bereich), CREATE/UPDATE/DELETE auditiert.

Verifiziert: USt-Rechnung (200 netto->238, 200 brutto->168,07+31,93)
und fortlaufende Nummernvergabe.

Phase 2 (Vertrag-UI + Beleg-Upload + Nummernkreis-UI) und Phase 3
(PDF + ZUGFeRD) folgen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 09:03:42 +02:00
duffyduckandClaude Opus 4.8 3bbe262039 Vertrag kopieren: Legacy-phoneNumber/simCardNumber auch leeren (R135/136)
Pentester-Fund: die Legacy-Scalar-Felder mobileDetails.phoneNumber und
simCardNumber (kein sichtbares Formularfeld mehr) wurden beim Kopieren
noch uebernommen - anders als die SIM-Karten-Liste und anders als
'Rufnummern werden nicht uebernommen'. Zwei getrennte Vertraege
desselben Kunden haetten so unbemerkt dieselbe Rufnummer getragen.

Datenintegritaet, kein Security-Loch. Beide Felder jetzt im Kopier-
Leer-Block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 13:26:42 +02:00
duffyduckandClaude Opus 4.8 2aee1f1114 Vertrag kopieren: Portal-Zugangsdaten mitkopieren
Auf Wunsch: Portal-Benutzername + Stressfrei-Verknuepfung/'nicht
benoetigt'-Flag werden im Kopier-Modus behalten (bei gleichem Anbieter
oft identisch). Nur das Passwort bleibt leer, weil es verschluesselt
gespeichert und - wie beim Bearbeiten - nicht im Klartext ins Feld
geladen wird. Banner-Hinweis ergaenzt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 12:58:41 +02:00
duffyduckandClaude Opus 4.8 a2ed0e94f1 Vertrag kopieren: neuen eigenstaendigen Vertrag aus Vorlage anlegen
Kopieren-Button in der Vertragsansicht oeffnet das Neu-Formular unter
/contracts/new?copyFrom=<id> mit allen Daten des Quellvertrags
vorbefuellt. Beim Speichern entsteht ein frischer, unabhaengiger
Vertrag (kein previousContractId-Link, kein VVL).

Use-Case: neuer Mobilfunk-/o.ae. Vertrag ist meist fast identisch;
nur Preis, Laufzeit, Kunden-/Vertragsnummer, Anbieter/Tarif aendern
sich - der Rest bleibt gleich.

Im Kopier-Modus geleert (Integritaet/Eindeutigkeit): Status->DRAFT,
Vorgaenger-Link, alle Datumsfelder, Kunden-/Vertragsnummer beim
Anbieter + Plattform-Nummern, Portal-Zugang. SIM-Karten & Rufnummern
werden nicht uebernommen. Rest (Anbieter/Tarif/Preise/Detailfelder/
Notizen) bleibt als Vorlage.

Rein Frontend (ContractForm copy-Mode + Button in ContractDetail),
kein neuer Endpoint - nutzt bestehendes Create.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 12:44:49 +02:00
duffyduckandClaude Opus 4.8 5bbb7dfffe PdfDragButton komplett entfernt (Plattformgrenze)
Test-Ergebnis: Browser kann einer fremden Desktop-App keine echte
lokale Datei zum Anhaengen uebergeben. URL-Drag -> Thunderbird nur Link
(0 Bytes, Fehler beim Senden); Datei-Drop in den Text -> nur Dateiname
als Text; auf die Anhang-Leiste (Thunderbird/Linux) -> ebenfalls kein
echter Anhang; Webmail -> gar nicht moeglich.

Entscheidung: Feature raus. Download- und Anzeigen-Button decken den
Bedarf zuverlaessig ab. PdfDragButton geloescht, fileUrl()-Token-Param
zurueckgebaut. Pentest R131 damit gegenstandslos (kein Drag mehr).

Die separaten Copy-Buttons fuer IBAN + Ausweisnummer im Vertrags-
formular bleiben erhalten.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 14:51:52 +02:00
duffyduckandClaude Opus 4.8 3073acb4b0 PdfDragButton: echten Datei-Inhalt (Blob) ziehen statt Link
Thunderbird & Co. hatten beim URL-basierten Drag nur einen LINK als
Anhang gespeichert (0 Bytes) und die Datei erst beim Senden nachgeladen
-> Fehler, weil kurzlebiger Token abgelaufen. Der User erwartet, dass
die Datei direkt angehaengt wird, nicht der Link.

Loesung: Datei wird vorab per Bearer-Auth (Axios-Instanz) als Blob
geladen und beim dragstart als echter Datei-Inhalt uebergeben:
dataTransfer.items.add(File) + DownloadURL mit lokaler blob:-URL.
-> Datei-INHALT wird uebertragen, kein Link.

Blob wird beim Mount vorgeladen (Ladezustand 'laedt ...'); der
Endpoint /files/download schreibt kein Audit-Log, daher kein Spam.

Security R131 damit vollstaendig erledigt: im Drag steckt weder eine
Server-URL noch ein Token -> ein Fehl-Drop kann gar nichts mehr leaken
(kein Access-, kein Download-Token). Token-Cache/getDownloadToken im
Drag-Pfad entfernt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 14:11:17 +02:00
duffyduckandClaude Opus 4.8 7d062bf9b2 PdfDragButton R131: 60s-Download-Token statt Access-Token, nur DownloadURL
Pentest R131 (LOW-MEDIUM): Der Drag-Button haengte den 15-Min-Access-
Token an die URL und schrieb ihn per text/plain + text/uri-list beim
Drag mit. Ein Fehl-Drop in ein Text-/Chat-/URL-Feld haette den vollen
Access-Token (alle Berechtigungen, 15 Min) als lesbaren Text geleakt.

Fix:
- fileUrl() akzeptiert jetzt optionalen expliziten Token.
- PdfDragButton nutzt den kurzlebigen 60s-Download-Token
  (authApi.getDownloadToken(), type:download, nur ?token=) statt des
  Access-Tokens. Modul-weiter Cache mit Dedup, auf mount + hover
  vorgewaermt (dragstart ist synchron, kann nicht awaiten).
- Es wird NUR noch DownloadURL im DataTransfer gesetzt, keine
  text/plain- oder text/uri-list-Repraesentation -> Fehl-Drop in ein
  Textfeld erzeugt gar keinen sichtbaren Text.
- Klick-Vorschau nutzt ebenfalls bevorzugt den Download-Token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 12:12:24 +02:00
duffyduckandClaude Opus 4.8 a509bb7826 PdfDragButton: PDF per Drag-and-Drop aus Bankkarte/Ausweis ziehen
Neue wiederverwendbare Komponente PdfDragButton: ziehbares Element,
mit dem der hinterlegte Scan direkt aus dem Browser in ein Mail-Fenster
(Anhang) oder den Datei-Explorer gezogen werden kann (Chromium-
DownloadURL, Format <mime>:<name>:<absolute-url>).

Bewusste Plattform-Grenze: PDF per Strg+V als Datei einfuegen geht im
Browser nicht (Web-Clipboard darf keine OS-Datei-Zwischenablage
befuellen) -> Drag-and-Drop. DownloadURL nur Chrome/Edge, nicht
Firefox (dort Klick-Fallback: Datei im Tab oeffnen).

Eingebaut an: ContractForm (neben IBAN/Ausweisnummer-Copy), Vertrags-
ansicht (Bankkarte/Ausweis-Card), Kundenakte-Tabs Bankkarten+Ausweise.
Nur bei vorhandenem documentPath. Nutzt bestehende fileUrl()-Download-
URL (Token als Query, Per-File-Ownership-Check unveraendert) - kein
neuer Endpoint, keine neue Angriffsflaeche.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 11:50:01 +02:00