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

11 KiB
Raw Blame History

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-012026-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.