Files
teamwallet/docs/superpowers/specs/2026-08-03-cashbox-export-design.md
Bastian Wagner b8bcf329c5 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>
2026-08-03 20:35:19 +02:00

186 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.