docs: add design spec for cashbox export + recurring PDF mailing
Covers on-demand CSV/PDF export of real cash-affecting transactions and an optional per-team recurring PDF mailing to arbitrary email addresses, following the same brainstorming process used for recurring transactions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
185
docs/superpowers/specs/2026-08-03-cashbox-export-design.md
Normal file
185
docs/superpowers/specs/2026-08-03-cashbox-export-design.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Kassenbuch-Export (manuell + automatischer PDF-Versand)
|
||||
|
||||
Status: approved
|
||||
Datum: 2026-08-03
|
||||
|
||||
## Kontext
|
||||
|
||||
TeamWallet bietet mit dem Kassenjournal (`teams/:id/transactions/journal`, Cashbox-Seite im
|
||||
Frontend) bereits eine paginierte, filterbare Ansicht aller Buchungen. Es gibt aber keine
|
||||
Möglichkeit, diese Daten für die Vereinsbuchhaltung oder eine Kassenprüfung zu exportieren — weder
|
||||
manuell (CSV/PDF-Download) noch automatisiert (regelmäßiger Versand an Vorstand/Kassenprüfer). Beide
|
||||
Fähigkeiten fehlen komplett (kein Export-Code, `mail`-Modul aktuell nur für Login/Passwort-Reset
|
||||
genutzt).
|
||||
|
||||
Ziel: Ein Kassenwart/Captain/Coach kann (a) für einen frei wählbaren Zeitraum einen Kassenbuch-Export
|
||||
als CSV und/oder PDF herunterladen, und (b) optional einen wiederkehrenden automatischen PDF-Versand
|
||||
an beliebige E-Mail-Adressen einrichten (z.B. monatlich an den Vereinsvorstand).
|
||||
|
||||
Das Feature wurde im Brainstorming aus mehreren Optionen ausgewählt (Alternativen: Saldo-
|
||||
Erinnerungen, server-seitige Journal-Filterung, Belege-Anhang, Vier-Augen-Prinzip — diese sind nicht
|
||||
Teil dieses Plans). Ausdrücklich nicht Teil dieses oder eines zukünftigen Plans: Team
|
||||
verlassen/löschen.
|
||||
|
||||
## Fachliche Einordnung
|
||||
|
||||
Ein "Kassenbuch" bildet nur **echte Kassenbewegungen** ab — Buchungen, die laut `setBalance()`
|
||||
(`Transaction`- und `TeamWalletTransaction`-Entity) tatsächlich `team.balance` verändern:
|
||||
|
||||
- `Transaction` mit `type.id === 0` (`payment`) — Spieler zahlt echtes Geld ein.
|
||||
- **Alle** `TeamWalletTransaction`-Einträge (`credit` und `expense`) — direkte Kassenbewegungen ohne
|
||||
Spielerbezug.
|
||||
|
||||
Spieler-Fälligkeiten (`fee`/`levy`/`fine`, `type.id > 10`) verändern nur die Spielerschuld, nie den
|
||||
Kassenbestand, und werden **bewusst ausgeschlossen** (entspricht der gewählten Option "Nur
|
||||
Kassenjournal (Team-Saldo)").
|
||||
|
||||
Der Export zeigt einen **Periodensaldo** (laufende Summe ab 0, beginnend am gewählten Startdatum),
|
||||
keinen historischen Kontostand — eine Rekonstruktion des absoluten Kontostands zu einem beliebigen
|
||||
Vergangenheitszeitpunkt wäre für den ersten Wurf YAGNI.
|
||||
|
||||
## Entscheidungen aus dem Brainstorming
|
||||
|
||||
- **Format**: CSV und PDF, beide.
|
||||
- **Zeitraum (manueller Export)**: frei wählbares Von/Bis-Datum.
|
||||
- **Inhalt**: nur Kassenjournal (Team-Saldo), keine Spieler-Fälligkeiten.
|
||||
- **Berechtigung**: wie Buchungen anlegen (`transaction_create_min_role`) — sowohl für den manuellen
|
||||
Export als auch für die Konfiguration des automatischen Versands.
|
||||
- **Automatischer Versand — Intervalle**: monatlich, quartalsweise, jährlich (identisch zu den
|
||||
wiederkehrenden Buchungen).
|
||||
- **Automatischer Versand — Zeitraum**: immer der jeweils **abgelaufene volle Zeitraum** (z.B. bei
|
||||
monatlichem Versand am 1. des Monats immer genau der komplette Vormonat), nicht "seit letztem
|
||||
Versand" (das wäre bei verpassten Läufen mehrdeutig).
|
||||
- **Automatischer Versand — Umfang**: **eine** Konfiguration pro Team (eine Empfängerliste, ein
|
||||
Intervall, pausierbar) statt mehrerer unabhängiger Abos.
|
||||
- **Out of Scope**: Team verlassen/löschen (bereits an anderer Stelle ausgeschlossen).
|
||||
|
||||
## Architektur / Komponenten
|
||||
|
||||
### 1. Backend: neues Modul `cashbox-export/`
|
||||
|
||||
Struktur analog zu `recurring-transactions/` (eigenständiges Modul statt Erweiterung von
|
||||
`teams.service.ts`, das bereits die Journal- und Statistik-Logik trägt).
|
||||
|
||||
**`cashbox-export.service.ts`**:
|
||||
- `buildRows(team: Team, from: string, to: string): CashboxExportRow[]` — reine Funktion auf einer
|
||||
bereits geladenen `Team`-Entity (inkl. `players.transactions.type`, `transactions.type`). Filtert
|
||||
auf echte Kassenbewegungen (s.o.), grenzt auf `[from, to]` ein (Ende inklusiv, Tagesende), sortiert
|
||||
chronologisch aufsteigend, berechnet laufenden `runningTotal`. Wird sowohl vom HTTP-Pfad als auch
|
||||
vom Scheduler verwendet — keine Duplikation der Filterlogik.
|
||||
- `getExportRowsForUser(teamId, userId, from, to)` — HTTP-Pfad: prüft `transaction_create_min_role`
|
||||
via `TeamAccessService.assertAtLeast`, lädt das Team, ruft `buildRows` auf.
|
||||
- `buildCsv(team, rows, from, to): string` — Semikolon-getrennt, deutsches Komma als
|
||||
Dezimaltrennzeichen (Excel-DE-Standard), RFC4180-Escaping für Notizen mit Semikolon/Anführungszeichen/
|
||||
Zeilenumbruch. Spalten: Datum, Typ, Wer (Spielername oder "Teamkasse"), Notiz, Betrag, Periodensaldo.
|
||||
Bei leerem Zeitraum: nur Kopfzeile + Hinweiszeile "Keine Buchungen im gewählten Zeitraum".
|
||||
- `buildPdf(team, rows, from, to): Buffer` — einfache Tabellen-PDF via neuer Abhängigkeit **`pdfkit`**
|
||||
(kein Chromium/Puppeteer nötig): Kopf mit Teamname + Zeitraum + Erstellungsdatum, Tabelle, Fußzeile
|
||||
mit Periodensaldo. Gleiche Leerzeitraum-Behandlung wie CSV.
|
||||
|
||||
**`cashbox-export.controller.ts`** (Pfad `cashbox-export`, `version: '1'`, nur `AuthGuard('jwt')`,
|
||||
Berechtigung im Service):
|
||||
- `GET cashbox-export/:teamId?from=&to=&format=csv|pdf` — liefert Datei über `@Res({passthrough:
|
||||
false})` mit manuell gesetzten Headern (`Content-Type`, `Content-Disposition: attachment;
|
||||
filename="kassenbuch_<teamAlias>_<from>_<to>.<ext>"`), kein globaler Response-Interceptor im
|
||||
Projekt vorhanden, der das stören würde.
|
||||
- `GET cashbox-export/:teamId/subscription` — aktuelle Versand-Konfiguration (oder Default:
|
||||
`{ recipients: [], interval: 'monthly', active: false }`).
|
||||
- `PUT cashbox-export/:teamId/subscription` — Upsert (Empfänger/Intervall/Aktiv-Status).
|
||||
|
||||
**Neue Entity `entities/cashbox-export-subscription.entity.ts`**: `id`, `team` (ManyToOne, in der
|
||||
Praxis 1:1 durch Anwendungslogik im Service erzwungen — nur eine Subscription pro Team wird gepflegt/
|
||||
aktualisiert statt neu angelegt), `recipients` (`simple-array`-Spalte, Liste von E-Mail-Strings),
|
||||
`interval` (`RecurringTransactionIntervalEnum`, wiederverwendet aus dem `recurring-transactions`-
|
||||
Modul — fachlich identisches Konzept), `active` (default `false`), `nextRunDate` (string, ISO-Datum),
|
||||
`createdAt`.
|
||||
|
||||
**DTO `UpsertCashboxExportSubscriptionDto`**: `recipients: string[]` (`@IsEmail({}, {each:true})`),
|
||||
`interval`, `active`. Validierung: `active === true` mit leerer `recipients`-Liste wird mit 400
|
||||
abgelehnt (ergibt keinen Sinn, nichts zu versenden aber "aktiv").
|
||||
|
||||
**`cashbox-export.scheduler.ts`** (`@Cron`, zeitlich versetzt zum bestehenden
|
||||
Recurring-Transactions-Job, z.B. `04:00 Uhr` statt `03:00 Uhr`, um DB-Last zu entzerren):
|
||||
1. Lädt alle `active: true`-Subscriptions mit `nextRunDate <= heute` (inkl. `team`).
|
||||
2. Pro fälliger Subscription: bestimmt den **abgelaufenen** Zeitraum passend zum `interval`
|
||||
ausgehend von `nextRunDate` (z.B. `nextRunDate = 2026-09-01`, `interval = monthly` → Zeitraum
|
||||
`2026-08-01`–`2026-08-31`), lädt das Team (inkl. Relationen), ruft `buildRows` + `buildPdf` auf
|
||||
(Wiederverwendung derselben Logik wie der manuelle Export), verschickt das PDF per
|
||||
`MailService`/`MailerService`-Attachment an alle `recipients` (neues Template
|
||||
`mail-templates/cashbox-export.hbs`, analog Aufbau zu `reset-password.hbs`), rückt `nextRunDate`
|
||||
um das Intervall vor (gleiche `setUTCMonth`-Arithmetik wie im Recurring-Transactions-Scheduler:
|
||||
`+1`/`+3`/`+12` Monate) und speichert.
|
||||
3. Ein verpasster Tag (Server-Downtime) wird beim nächsten Lauf automatisch nachgeholt (rein
|
||||
datumsbasierter Check wie beim Recurring-Transactions-Scheduler).
|
||||
|
||||
**Registrierung**: `CashboxExportModule` in `src/app.module.ts` ergänzen (analog `PenaltyModule`/
|
||||
`RecurringTransactionsModule`); `MailModule` importieren für den Versand.
|
||||
|
||||
**Logging-Events**: `cashbox_export_download`, `cashbox_export_subscription_update`,
|
||||
`cashbox_export_subscription_run` in `logging-event.type.ts` ergänzen.
|
||||
|
||||
### 2. Frontend
|
||||
|
||||
**Cashbox-Toolbar**: neuer "Export"-Button (sichtbar nur mit `canDo(team(), 'transactionCreate')`,
|
||||
kein neuer Permission-Key) öffnet einen Dialog mit Von/Bis-Datumsfeldern und Format-Auswahl
|
||||
(CSV/PDF), löst über `CashboxExportApi.exportCashbox(teamId, from, to, format)`
|
||||
(`responseType: 'blob'`) den Download aus. Ein kleiner `FileDownloadService.save(blob, filename)`
|
||||
kapselt den Anchor-Click-Mechanismus, damit die Dialog-Komponente ohne echte DOM-Downloads getestet
|
||||
werden kann (Service wird im Test gemockt).
|
||||
|
||||
Im selben Export-Bereich zusätzlich ein Zahnrad/Link "Automatischen Versand einrichten" → eigener
|
||||
Dialog: Chip-Liste für E-Mail-Adressen (hinzufügen/entfernen, clientseitige Format-Validierung vor
|
||||
dem Speichern), Intervall-Dropdown, Aktiv/Pausiert-Toggle, Speichern-Button. Neue Methoden
|
||||
`CashboxExportApi.getSubscription(teamId)` / `updateSubscription(teamId, dto)`.
|
||||
|
||||
Neues Model `models/cashbox-export.model.ts` (`CashboxExportFormat`, `CashboxExportSubscription`,
|
||||
`UpdateCashboxExportSubscription`).
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
- `from > to` → 400 (Backend), Submit-Button im Dialog zusätzlich clientseitig deaktiviert.
|
||||
- Keine Buchungen im Zeitraum → Datei wird trotzdem erzeugt (Kopfzeile + Hinweistext), kein Fehler.
|
||||
- Ungültige E-Mail-Adresse in der Empfängerliste → 400 (DTO-Validierung), Inline-Fehler im Dialog.
|
||||
- `active: true` mit leerer Empfängerliste → 400.
|
||||
- Fehlende Berechtigung → bestehender `assertAtLeast`-Wurf (403), keine neue Behandlung nötig.
|
||||
|
||||
## Testing
|
||||
|
||||
**Backend**:
|
||||
- `cashbox-export.service.spec.ts` — Filterlogik (Ausschluss fee/levy/fine, Einschluss payment +
|
||||
alle TeamWallet-Typen), Datumsgrenzen (inklusive Tagesende), laufender Saldo, leerer Zeitraum,
|
||||
Berechtigungsdurchsetzung.
|
||||
- `cashbox-export.http.spec.ts` — Auth erforderlich, korrekte Header/Content-Type je Format, CSV-
|
||||
Inhalt exakt geprüft (String-Vergleich), PDF nur auf `%PDF-`-Signatur + Non-Empty geprüft (kein
|
||||
Byte-Vergleich).
|
||||
- `cashbox-export-subscription.service.spec.ts` — Upsert, Validierung (aktiv + leere Liste),
|
||||
Berechtigung.
|
||||
- `cashbox-export.scheduler.spec.ts` — Perioden-Berechnung je Intervall (`it.each`), PDF+Mail-
|
||||
Dispatch mit gemocktem `MailerService` (Attachment vorhanden, korrekte Empfänger/Betreff),
|
||||
`nextRunDate`-Vorrücken, überspringt inaktive/nicht-fällige Subscriptions, Downtime-Nachholung.
|
||||
|
||||
**Frontend**:
|
||||
- `cashbox-export-api.spec.ts` — korrekte HTTP-Calls (Query-Params, `responseType: 'blob'`,
|
||||
Subscription-GET/PUT).
|
||||
- Export-Dialog-Spec — Formvalidierung (`from <= to`), Permission-Gating, ruft
|
||||
`FileDownloadService.save` mit korrekten Argumenten auf.
|
||||
- Subscription-Dialog-Spec — Laden/Speichern, Chip-Validierung, Permission-Gating.
|
||||
|
||||
## Bewusst nicht enthalten (YAGNI)
|
||||
|
||||
- Kein historischer Anfangssaldo (nur Periodensaldo ab 0 innerhalb des Exportzeitraums).
|
||||
- Kein Export der Spieler-Fälligkeiten (fee/levy/fine).
|
||||
- Kein Excel-(.xlsx)-Format, nur CSV+PDF.
|
||||
- Keine mehreren Versand-Konfigurationen pro Team.
|
||||
- Kein CSV im automatischen Versand, nur PDF.
|
||||
- Keine Empfänger-Verifizierung (Double-Opt-In) für frei eingetragene Adressen.
|
||||
|
||||
## Verifikation
|
||||
|
||||
- **Backend-Unit-Tests**: siehe oben, alle grün, `nest build` sauber.
|
||||
- **Frontend-Unit-Tests**: siehe oben, alle grün, `tsc --noEmit` + `ng build` sauber.
|
||||
- **Manuell**: Backend lokal starten, über die neue UI einen CSV- und einen PDF-Export für einen
|
||||
Zeitraum mit bekannten Testbuchungen herunterladen und Inhalt/Saldo stichprobenartig prüfen; eine
|
||||
Subscription mit `nextRunDate` = heute anlegen, Scheduler-Methode einmalig manuell aufrufen, prüfen
|
||||
dass eine E-Mail mit PDF-Anhang an alle konfigurierten Adressen geht und `nextRunDate` korrekt
|
||||
vorrückt.
|
||||
Reference in New Issue
Block a user