Compare commits

...

28 Commits

Author SHA1 Message Date
Bastian Wagner
da5998487a fix: address cashbox-export whole-branch review findings
- Reject dates that are not strict YYYY-MM-DD (was accepting full ISO
  datetimes, which silently produced empty exports instead of a 400)
  and reject from > to with a 400 before touching the team/DB.
- Emit the cashbox_export_download and cashbox_export_subscription_update
  audit log events that were declared but never fired, matching the
  audit trail every sibling feature already has.
- Restore full type checking on the pdfkit import via `import = require()`
  instead of an untyped require() with an eslint-disable.
- Tighten a cashbox.spec.ts assertion to check the exact dialog class
  instead of expect.anything(), so it can't pass with the wrong dialog
  wired to the Export button.
- Style and announce the export dialogs' error messages using this
  codebase's established error-message/role=alert pattern.
2026-08-04 09:20:44 +02:00
Bastian Wagner
ce0b500d7a fix: re-check canBook() inside export dialog methods (defense in depth)
openExportDialog()/openExportSubscriptionDialog() only guarded on teamId
truthiness, relying solely on the template @if for permission gating.
Every other permission-gated method in Cashbox (submitPlayerBooking,
reverseBooking) re-checks the permission internally too. Add the same
guard here, plus a test asserting direct invocation without booking
rights does not call dialog.open.
2026-08-04 08:59:49 +02:00
Bastian Wagner
1c214c5f06 feat: wire cashbox export and subscription dialogs into Cashbox page
Adds an Export button (opens CashboxExportDialog directly) plus a
secondary menu (opens CashboxExportSubscriptionDialog) to the journal
toolbar, gated by the existing canBook permission signal.
2026-08-04 08:53:05 +02:00
Bastian Wagner
148269da11 fix: add error handling to CashboxExportSubscriptionDialog 2026-08-04 08:39:10 +02:00
Bastian Wagner
db061a7ef7 feat: add CashboxExportSubscriptionDialog 2026-08-04 08:34:01 +02:00
Bastian Wagner
11b70b9337 feat: add error handling to CashboxExportDialog 2026-08-04 08:29:22 +02:00
Bastian Wagner
5c3ff52689 feat: add CashboxExportDialog 2026-08-04 08:24:57 +02:00
Bastian Wagner
a65c7b35b3 feat: add CashboxExportApi 2026-08-04 08:19:09 +02:00
Bastian Wagner
72fa1d4331 feat: add cashbox export model and FileDownloadService 2026-08-04 08:14:58 +02:00
Bastian Wagner
d628d5e4d7 fix: add LoggingModule import to CashboxExportModule 2026-08-04 08:10:04 +02:00
Bastian Wagner
57869d5fc1 feat: register CashboxExportModule 2026-08-04 08:03:29 +02:00
Bastian Wagner
eed8266da2 fix: add error handling for CashboxExportScheduler subscription processing
- Wrap runOne(subscription) in try/catch to ensure one subscription failure doesn't block remaining subscriptions
- Log failed subscriptions with new 'cashbox_export_subscription_run_fail' event
- Add new LOGEVENT type for subscription run failures
- Add test to verify second subscription processes even when first fails (continues processing independently)
- All 7 tests passing: 6 original + 1 new failure handling test

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 07:56:39 +02:00
Bastian Wagner
5779483b21 feat: add CashboxExportScheduler for recurring PDF mailing
- Implement CashboxExportScheduler with @Cron(EVERY_DAY_AT_4AM)
- Query due subscriptions (active=true, nextRunDate <= today)
- For each subscription: fetch team, build PDF, send email, advance nextRunDate
- Support monthly/quarterly/yearly intervals via INTERVAL_MONTHS map
- Add cashbox_export_subscription_run to LOGEVENT type for logging
- All 6 tests passing: empty state, monthly/quarterly/yearly periods, multiple subscriptions

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 07:49:58 +02:00
Bastian Wagner
196898b993 feat: add MailService.cashboxExport and email template 2026-08-04 07:44:12 +02:00
Bastian Wagner
42e2bbc4ff feat: add cashbox export subscription endpoints
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 07:39:20 +02:00
Bastian Wagner
a11eb73ce9 feat: add CashboxExportSubscription entity and service 2026-08-04 07:28:01 +02:00
Bastian Wagner
009aae1f2b feat: add cashbox export download endpoint 2026-08-03 21:37:14 +02:00
Bastian Wagner
18df224386 test: enforce permission check ordering in CashboxExportService 2026-08-03 21:33:36 +02:00
Bastian Wagner
396dc29cf5 feat: add CashboxExportService.exportForUser 2026-08-03 21:30:22 +02:00
Bastian Wagner
f8857f71e1 feat: add buildPdf for cashbox export
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 21:20:57 +02:00
Bastian Wagner
0b408f7d73 fix: use symmetric rounding for negative amounts in formatGermanAmount 2026-08-03 21:16:05 +02:00
Bastian Wagner
c6df6a6d38 fix: handle negative-zero and half-cent rounding in formatGermanAmount 2026-08-03 21:12:27 +02:00
Bastian Wagner
cab04c5869 feat: add buildCsv for cashbox export 2026-08-03 21:08:03 +02:00
Bastian Wagner
cf7c3efb0f fix: add null type guards to buildRows to prevent crashes on missing types 2026-08-03 21:02:52 +02:00
Bastian Wagner
a9df62a249 feat: add buildRows for cashbox export row filtering 2026-08-03 20:57:36 +02:00
Bastian Wagner
d8883d4687 chore: add pdfkit for cashbox PDF export 2026-08-03 20:51:48 +02:00
Bastian Wagner
15d5f1d4e4 docs: add implementation plan for cashbox export feature
Task-by-task TDD plan covering manual CSV/PDF export, the recurring
PDF-mailing subscription, and the frontend wiring, grounded in the
existing recurring-transactions module and mail service as precedent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 20:46:42 +02:00
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
39 changed files with 5008 additions and 5 deletions

File diff suppressed because it is too large Load Diff

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

View File

@@ -30,6 +30,7 @@
"passport": "0.6.0",
"passport-anonymous": "1.0.1",
"passport-jwt": "4.0.0",
"pdfkit": "^0.19.1",
"pg": "8.8.0",
"reflect-metadata": "0.1.13",
"rimraf": "3.0.2",
@@ -49,6 +50,7 @@
"@types/node": "16.18.3",
"@types/passport-anonymous": "1.0.3",
"@types/passport-jwt": "3.0.7",
"@types/pdfkit": "^0.17.6",
"@types/supertest": "2.0.12",
"@typescript-eslint/eslint-plugin": "5.43.0",
"@typescript-eslint/parser": "5.43.0",
@@ -3559,6 +3561,30 @@
"typeorm": "^0.3.0"
}
},
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3730,6 +3756,21 @@
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.3.tgz",
"integrity": "sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg=="
},
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@swc/helpers/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/@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@@ -4008,6 +4049,16 @@
"@types/passport": "*"
}
},
"node_modules/@types/pdfkit": {
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/prettier": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz",
@@ -5212,6 +5263,24 @@
"node": ">=8"
}
},
"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/browserslist": {
"version": "4.21.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz",
@@ -6186,6 +6255,12 @@
"wrappy": "1"
}
},
"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/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
@@ -7601,8 +7676,7 @@
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-diff": {
"version": "1.2.0",
@@ -7797,6 +7871,32 @@
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
"dev": true
},
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/fontkit/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/fork-ts-checker-webpack-plugin": {
"version": "7.2.13",
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-7.2.13.tgz",
@@ -12009,6 +12109,12 @@
"node": ">=10"
}
},
"node_modules/js-md5": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
"license": "MIT"
},
"node_modules/js-sdsl": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.1.5.tgz",
@@ -12255,6 +12361,25 @@
"resolved": "https://registry.npmjs.org/libqp/-/libqp-1.1.0.tgz",
"integrity": "sha512-4Rgfa0hZpG++t1Vi2IiqXG9Ad1ig4QTmtuZF946QJP4bPqOYC78ixUXgz5TW/wE7lNaNKlplSYTxQ+fR2KZ0EA=="
},
"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/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -14134,6 +14259,12 @@
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
"integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="
},
"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/param-case": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-2.1.1.tgz",
@@ -14305,6 +14436,20 @@
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10="
},
"node_modules/pdfkit": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
"integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^1.0.0",
"@noble/hashes": "^1.6.0",
"fontkit": "^2.0.4",
"js-md5": "^0.8.3",
"linebreak": "^1.1.0",
"png-js": "^1.1.0"
}
},
"node_modules/pg": {
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz",
@@ -14471,6 +14616,14 @@
"node": ">=4"
}
},
"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/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -15074,6 +15227,12 @@
"node": ">=8"
}
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/ret": {
"version": "0.1.15",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
@@ -16097,6 +16256,12 @@
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"dev": true
},
"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/tlds": {
"version": "1.231.0",
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.231.0.tgz",
@@ -16850,6 +17015,32 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"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/universalify": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
@@ -20151,6 +20342,16 @@
"uuid": "8.3.2"
}
},
"@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="
},
"@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="
},
"@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -20280,6 +20481,21 @@
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.3.tgz",
"integrity": "sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg=="
},
"@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"requires": {
"tslib": "^2.8.0"
},
"dependencies": {
"tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
}
}
},
"@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@@ -20555,6 +20771,15 @@
"@types/passport": "*"
}
},
"@types/pdfkit": {
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/prettier": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz",
@@ -21474,6 +21699,22 @@
"fill-range": "^7.0.1"
}
},
"brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"requires": {
"base64-js": "^1.1.2"
}
},
"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==",
"requires": {
"pako": "~1.0.5"
}
},
"browserslist": {
"version": "4.21.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz",
@@ -22200,6 +22441,11 @@
"wrappy": "1"
}
},
"dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="
},
"diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
@@ -23240,8 +23486,7 @@
"fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"fast-diff": {
"version": "1.2.0",
@@ -23410,6 +23655,29 @@
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
"dev": true
},
"fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"requires": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
},
"dependencies": {
"clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="
}
}
},
"fork-ts-checker-webpack-plugin": {
"version": "7.2.13",
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-7.2.13.tgz",
@@ -26466,6 +26734,11 @@
}
}
},
"js-md5": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ=="
},
"js-sdsl": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.1.5.tgz",
@@ -26673,6 +26946,22 @@
"resolved": "https://registry.npmjs.org/libqp/-/libqp-1.1.0.tgz",
"integrity": "sha512-4Rgfa0hZpG++t1Vi2IiqXG9Ad1ig4QTmtuZF946QJP4bPqOYC78ixUXgz5TW/wE7lNaNKlplSYTxQ+fR2KZ0EA=="
},
"linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"requires": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
},
"dependencies": {
"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=="
}
}
},
"lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -28201,6 +28490,11 @@
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
"integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="
},
"pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
},
"param-case": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-2.1.1.tgz",
@@ -28334,6 +28628,19 @@
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10="
},
"pdfkit": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
"integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
"requires": {
"@noble/ciphers": "^1.0.0",
"@noble/hashes": "^1.6.0",
"fontkit": "^2.0.4",
"js-md5": "^0.8.3",
"linebreak": "^1.1.0",
"png-js": "^1.1.0"
}
},
"pg": {
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz",
@@ -28459,6 +28766,14 @@
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
"dev": true
},
"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==",
"requires": {
"browserify-zlib": "^0.2.0"
}
},
"postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -28939,6 +29254,11 @@
"signal-exit": "^3.0.2"
}
},
"restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="
},
"ret": {
"version": "0.1.15",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
@@ -29705,6 +30025,11 @@
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"dev": true
},
"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=="
},
"tlds": {
"version": "1.231.0",
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.231.0.tgz",
@@ -30156,6 +30481,31 @@
"which-boxed-primitive": "^1.0.2"
}
},
"unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"requires": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"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==",
"requires": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
},
"dependencies": {
"pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="
}
}
},
"universalify": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",

View File

@@ -49,6 +49,7 @@
"passport": "0.6.0",
"passport-anonymous": "1.0.1",
"passport-jwt": "4.0.0",
"pdfkit": "^0.19.1",
"pg": "8.8.0",
"reflect-metadata": "0.1.13",
"rimraf": "3.0.2",
@@ -68,6 +69,7 @@
"@types/node": "16.18.3",
"@types/passport-anonymous": "1.0.3",
"@types/passport-jwt": "3.0.7",
"@types/pdfkit": "^0.17.6",
"@types/supertest": "2.0.12",
"@typescript-eslint/eslint-plugin": "5.43.0",
"@typescript-eslint/parser": "5.43.0",

View File

@@ -24,6 +24,7 @@ import { LoggingModule } from './database/logging/logging.module';
import { TranslateModule } from './translate/translate.module';
import { PenaltyModule } from './penalty/penalty.module';
import { RecurringTransactionsModule } from './recurring-transactions/recurring-transactions.module';
import { CashboxExportModule } from './cashbox-export/cashbox-export.module';
@Module({
imports: [
@@ -59,6 +60,7 @@ import { RecurringTransactionsModule } from './recurring-transactions/recurring-
TranslateModule,
PenaltyModule,
RecurringTransactionsModule,
CashboxExportModule,
],
providers: [],
})

View File

@@ -0,0 +1,125 @@
import { BadRequestException } from '@nestjs/common';
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum';
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
describe('CashboxExportSubscriptionService', () => {
const repository = { findOne: jest.fn(), create: jest.fn((v) => v), save: jest.fn(async (v) => v) };
const access = { assertAtLeast: jest.fn() };
const logger = { info: jest.fn() };
let service: CashboxExportSubscriptionService;
beforeEach(() => {
jest.clearAllMocks();
service = new CashboxExportSubscriptionService(repository as any, access as any, logger as any);
});
it('returns a paused default when no subscription exists yet', async () => {
repository.findOne.mockResolvedValue(null);
await expect(service.getSubscription(5, 42)).resolves.toEqual({
recipients: [],
interval: RecurringTransactionIntervalEnum.monthly,
active: false,
nextRunDate: null,
});
expect(access.assertAtLeast).toHaveBeenCalledWith(
42,
5,
'transaction_create_min_role',
TeamRolesEnum.scnd_treasurer,
);
});
it('returns the existing subscription', async () => {
repository.findOne.mockResolvedValue({
recipients: ['vorstand@example.com'],
interval: RecurringTransactionIntervalEnum.yearly,
active: true,
nextRunDate: '2027-01-01T00:00:00.000Z',
});
await expect(service.getSubscription(5, 42)).resolves.toEqual({
recipients: ['vorstand@example.com'],
interval: RecurringTransactionIntervalEnum.yearly,
active: true,
nextRunDate: '2027-01-01T00:00:00.000Z',
});
});
it('rejects activating with an empty recipient list', async () => {
repository.findOne.mockResolvedValue(null);
await expect(
service.upsertSubscription(5, 42, {
recipients: [],
interval: RecurringTransactionIntervalEnum.monthly,
active: true,
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(repository.save).not.toHaveBeenCalled();
});
it('creates a new subscription and computes the next period boundary on first activation', async () => {
repository.findOne.mockResolvedValue(null);
jest.useFakeTimers().setSystemTime(new Date('2026-08-15T10:00:00.000Z'));
const result = await service.upsertSubscription(5, 42, {
recipients: ['vorstand@example.com'],
interval: RecurringTransactionIntervalEnum.monthly,
active: true,
});
expect(result.nextRunDate).toBe('2026-09-01T00:00:00.000Z');
expect(repository.save).toHaveBeenCalledWith(
expect.objectContaining({
recipients: ['vorstand@example.com'],
interval: RecurringTransactionIntervalEnum.monthly,
active: true,
nextRunDate: '2026-09-01T00:00:00.000Z',
}),
);
expect(logger.info).toHaveBeenCalledWith({
event: 'cashbox_export_subscription_update',
details: 'teamId=5 active=true interval=monthly recipients=1',
userId: 42,
});
jest.useRealTimers();
});
it('keeps the existing nextRunDate when editing recipients without changing interval or activation state', async () => {
repository.findOne.mockResolvedValue({
recipients: ['old@example.com'],
interval: RecurringTransactionIntervalEnum.monthly,
active: true,
nextRunDate: '2026-09-01T00:00:00.000Z',
});
const result = await service.upsertSubscription(5, 42, {
recipients: ['new@example.com'],
interval: RecurringTransactionIntervalEnum.monthly,
active: true,
});
expect(result.nextRunDate).toBe('2026-09-01T00:00:00.000Z');
});
it('recomputes nextRunDate when the interval changes', async () => {
repository.findOne.mockResolvedValue({
recipients: ['a@example.com'],
interval: RecurringTransactionIntervalEnum.monthly,
active: true,
nextRunDate: '2026-09-01T00:00:00.000Z',
});
jest.useFakeTimers().setSystemTime(new Date('2026-08-15T10:00:00.000Z'));
const result = await service.upsertSubscription(5, 42, {
recipients: ['a@example.com'],
interval: RecurringTransactionIntervalEnum.yearly,
active: true,
});
expect(result.nextRunDate).toBe('2027-08-01T00:00:00.000Z');
jest.useRealTimers();
});
});

View File

@@ -0,0 +1,106 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { LoggingService } from 'src/database/logging/logging.service';
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
import { TeamAccessService } from 'src/teams/team-access.service';
import { Repository } from 'typeorm';
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
import { CashboxExportSubscriptionResponseDTO } from './dto/cashbox-export-subscription-response.dto';
import { UpsertCashboxExportSubscriptionDTO } from './dto/upsert-cashbox-export-subscription.dto';
import { CashboxExportSubscription } from './entities/cashbox-export-subscription.entity';
const INTERVAL_MONTHS: Record<RecurringTransactionIntervalEnum, number> = {
[RecurringTransactionIntervalEnum.monthly]: 1,
[RecurringTransactionIntervalEnum.quarterly]: 3,
[RecurringTransactionIntervalEnum.yearly]: 12,
};
@Injectable()
export class CashboxExportSubscriptionService {
constructor(
@InjectRepository(CashboxExportSubscription)
private readonly repository: Repository<CashboxExportSubscription>,
private readonly access: TeamAccessService,
private readonly logger: LoggingService,
) {}
async getSubscription(
teamId: number,
userId: number,
): Promise<CashboxExportSubscriptionResponseDTO> {
await this.assertAccess(userId, teamId);
const existing = await this.repository.findOne({ where: { team: { id: teamId } } });
if (!existing) {
return {
recipients: [],
interval: RecurringTransactionIntervalEnum.monthly,
active: false,
nextRunDate: null,
};
}
return this.toResponse(existing);
}
async upsertSubscription(
teamId: number,
userId: number,
dto: UpsertCashboxExportSubscriptionDTO,
): Promise<CashboxExportSubscriptionResponseDTO> {
await this.assertAccess(userId, teamId);
if (dto.active && dto.recipients.length === 0) {
throw new BadRequestException(
'Ein aktivierter automatischer Versand benötigt mindestens eine Empfängeradresse.',
);
}
const existing = await this.repository.findOne({ where: { team: { id: teamId } } });
const needsNewSchedule =
!existing || (dto.active && (!existing.active || existing.interval !== dto.interval));
const entity =
existing ??
this.repository.create({ team: { id: teamId } as any, nextRunDate: null, active: false });
entity.recipients = dto.recipients;
entity.interval = dto.interval;
entity.active = dto.active;
if (needsNewSchedule) {
entity.nextRunDate = this.nextBoundary(dto.interval);
}
const saved = await this.repository.save(entity);
await this.logger.info({
event: 'cashbox_export_subscription_update',
details: `teamId=${teamId} active=${dto.active} interval=${dto.interval} recipients=${dto.recipients.length}`,
userId,
});
return this.toResponse(saved);
}
private async assertAccess(userId: number, teamId: number): Promise<void> {
await this.access.assertAtLeast(
userId,
teamId,
'transaction_create_min_role',
TeamRolesEnum.scnd_treasurer,
);
}
private nextBoundary(interval: RecurringTransactionIntervalEnum): string {
const now = new Date();
const date = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
date.setUTCMonth(date.getUTCMonth() + INTERVAL_MONTHS[interval]);
return date.toISOString();
}
private toResponse(
entity: CashboxExportSubscription,
): CashboxExportSubscriptionResponseDTO {
return {
recipients: entity.recipients,
interval: entity.interval,
active: entity.active,
nextRunDate: entity.nextRunDate,
};
}
}

View File

@@ -0,0 +1,69 @@
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Put,
Query,
Request,
Res,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth } from '@nestjs/swagger';
import type { Response } from 'express';
import { CashboxExportQueryDto } from './dto/cashbox-export-query.dto';
import { UpsertCashboxExportSubscriptionDTO } from './dto/upsert-cashbox-export-subscription.dto';
import { CashboxExportService } from './cashbox-export.service';
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
type AuthenticatedRequest = { user: { id: number } };
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'))
@Controller({ path: 'cashbox-export', version: '1' })
export class CashboxExportController {
constructor(
private readonly service: CashboxExportService,
private readonly subscriptionService: CashboxExportSubscriptionService,
) {}
@Get(':teamId')
async exportCashbox(
@Request() request: AuthenticatedRequest,
@Param('teamId', ParseIntPipe) teamId: number,
@Query() query: CashboxExportQueryDto,
@Res({ passthrough: false }) res: Response,
): Promise<void> {
const { buffer, contentType, filename } = await this.service.exportForUser(
teamId,
request.user.id,
query.from,
query.to,
query.format,
);
res.set({
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${filename}"`,
});
res.send(buffer);
}
@Get(':teamId/subscription')
getSubscription(
@Request() request: AuthenticatedRequest,
@Param('teamId', ParseIntPipe) teamId: number,
) {
return this.subscriptionService.getSubscription(teamId, request.user.id);
}
@Put(':teamId/subscription')
upsertSubscription(
@Request() request: AuthenticatedRequest,
@Param('teamId', ParseIntPipe) teamId: number,
@Body() dto: UpsertCashboxExportSubscriptionDTO,
) {
return this.subscriptionService.upsertSubscription(teamId, request.user.id, dto);
}
}

View File

@@ -0,0 +1,173 @@
import {
INestApplication,
UnauthorizedException,
ValidationPipe,
VersioningType,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum';
import validationOptions from '../utils/validation-options';
import { CashboxExportController } from './cashbox-export.controller';
import { CashboxExportService } from './cashbox-export.service';
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
describe('cashbox export HTTP boundary', () => {
let app: INestApplication;
const service = {
exportForUser: jest.fn(),
};
const subscriptionService = {
getSubscription: jest.fn(),
upsertSubscription: jest.fn(),
};
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [CashboxExportController],
providers: [
{ provide: CashboxExportService, useValue: service },
{ provide: CashboxExportSubscriptionService, useValue: subscriptionService },
],
})
.overrideGuard(AuthGuard('jwt'))
.useValue({
canActivate(context) {
const httpRequest = context.switchToHttp().getRequest();
if (httpRequest.headers.authorization !== 'Bearer user') {
throw new UnauthorizedException();
}
httpRequest.user = { id: 42, role: { id: 2 } };
return true;
},
})
.compile();
app = module.createNestApplication();
app.setGlobalPrefix('api');
app.enableVersioning({ type: VersioningType.URI });
app.useGlobalPipes(new ValidationPipe(validationOptions));
await app.init();
});
afterAll(() => app.close());
beforeEach(() => jest.clearAllMocks());
it('requires authentication', async () => {
await request(app.getHttpServer())
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=csv')
.expect(401);
});
it('rejects an invalid format', async () => {
await request(app.getHttpServer())
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=xls')
.set('Authorization', 'Bearer user')
.expect(422);
expect(service.exportForUser).not.toHaveBeenCalled();
});
it('rejects a full ISO datetime instead of a plain YYYY-MM-DD date for from', async () => {
await request(app.getHttpServer())
.get(
'/api/v1/cashbox-export/5?from=2026-08-01T12:00:00Z&to=2026-08-31&format=csv',
)
.set('Authorization', 'Bearer user')
.expect(422);
expect(service.exportForUser).not.toHaveBeenCalled();
});
it('rejects a malformed date string for to', async () => {
await request(app.getHttpServer())
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=not-a-date&format=csv')
.set('Authorization', 'Bearer user')
.expect(422);
expect(service.exportForUser).not.toHaveBeenCalled();
});
it('returns a CSV file with correct headers and content', async () => {
const csvBuffer = Buffer.from('Datum;Typ;Wer;Notiz;Betrag;Periodensaldo', 'utf-8');
service.exportForUser.mockResolvedValue({
buffer: csvBuffer,
contentType: 'text/csv; charset=utf-8',
filename: 'kassenbuch_team-a_2026-08-01_2026-08-31.csv',
});
const response = await request(app.getHttpServer())
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=csv')
.set('Authorization', 'Bearer user')
.expect(200);
expect(response.headers['content-type']).toContain('text/csv');
expect(response.headers['content-disposition']).toContain(
'kassenbuch_team-a_2026-08-01_2026-08-31.csv',
);
expect(response.text).toBe(csvBuffer.toString('utf-8'));
expect(service.exportForUser).toHaveBeenCalledWith(5, 42, '2026-08-01', '2026-08-31', 'csv');
});
it('returns a PDF file with correct headers and binary content', async () => {
const pdfBuffer = Buffer.from('%PDF-1.4 fake content');
service.exportForUser.mockResolvedValue({
buffer: pdfBuffer,
contentType: 'application/pdf',
filename: 'kassenbuch_team-a_2026-08-01_2026-08-31.pdf',
});
const response = await request(app.getHttpServer())
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=pdf')
.set('Authorization', 'Bearer user')
.expect(200);
expect(response.headers['content-type']).toBe('application/pdf');
expect(Buffer.from(response.body).equals(pdfBuffer)).toBe(true);
});
it('reads the current subscription', async () => {
const subscription = {
recipients: ['vorstand@example.com'],
interval: RecurringTransactionIntervalEnum.monthly,
active: true,
nextRunDate: '2026-09-01T00:00:00.000Z',
};
subscriptionService.getSubscription.mockResolvedValue(subscription);
await request(app.getHttpServer())
.get('/api/v1/cashbox-export/5/subscription')
.set('Authorization', 'Bearer user')
.expect(200)
.expect(subscription);
expect(subscriptionService.getSubscription).toHaveBeenCalledWith(5, 42);
});
it('rejects an invalid recipient email on upsert', async () => {
await request(app.getHttpServer())
.put('/api/v1/cashbox-export/5/subscription')
.set('Authorization', 'Bearer user')
.send({ recipients: ['not-an-email'], interval: 'monthly', active: true })
.expect(422);
expect(subscriptionService.upsertSubscription).not.toHaveBeenCalled();
});
it('accepts a valid subscription upsert', async () => {
const subscription = {
recipients: ['vorstand@example.com'],
interval: RecurringTransactionIntervalEnum.monthly,
active: true,
nextRunDate: '2026-09-01T00:00:00.000Z',
};
subscriptionService.upsertSubscription.mockResolvedValue(subscription);
await request(app.getHttpServer())
.put('/api/v1/cashbox-export/5/subscription')
.set('Authorization', 'Bearer user')
.send({ recipients: ['vorstand@example.com'], interval: 'monthly', active: true })
.expect(200)
.expect(subscription);
expect(subscriptionService.upsertSubscription).toHaveBeenCalledWith(5, 42, {
recipients: ['vorstand@example.com'],
interval: 'monthly',
active: true,
});
});
});

View File

@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LoggingModule } from 'src/database/logging/logging.module';
import { MailModule } from 'src/mail/mail.module';
import { Team } from 'src/teams/entities/team.entity';
import { TeamsModule } from 'src/teams/teams.module';
import { CashboxExportSubscription } from './entities/cashbox-export-subscription.entity';
import { CashboxExportController } from './cashbox-export.controller';
import { CashboxExportService } from './cashbox-export.service';
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
import { CashboxExportScheduler } from './cashbox-export.scheduler';
@Module({
controllers: [CashboxExportController],
providers: [CashboxExportService, CashboxExportSubscriptionService, CashboxExportScheduler],
imports: [
TypeOrmModule.forFeature([Team, CashboxExportSubscription]),
TeamsModule,
MailModule,
LoggingModule,
],
})
export class CashboxExportModule {}

View File

@@ -0,0 +1,146 @@
import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum';
import { CashboxExportScheduler } from './cashbox-export.scheduler';
describe('CashboxExportScheduler', () => {
const subscriptionRepository = { find: jest.fn(), save: jest.fn((v) => v) };
const teamRepository = { findOne: jest.fn() };
const mailService = { cashboxExport: jest.fn() };
const logger = { info: jest.fn(), error: jest.fn() };
let scheduler: CashboxExportScheduler;
beforeEach(() => {
jest.clearAllMocks();
scheduler = new CashboxExportScheduler(
subscriptionRepository as any,
teamRepository as any,
mailService as any,
logger as any,
);
});
it('does nothing when no subscription is due', async () => {
subscriptionRepository.find.mockResolvedValue([]);
await scheduler.runDueSubscriptions();
expect(teamRepository.findOne).not.toHaveBeenCalled();
expect(mailService.cashboxExport).not.toHaveBeenCalled();
});
it('emails the elapsed monthly period and advances nextRunDate', async () => {
subscriptionRepository.find.mockResolvedValue([
{
id: 1,
team: { id: 5 },
recipients: ['vorstand@example.com'],
interval: RecurringTransactionIntervalEnum.monthly,
nextRunDate: '2026-09-01T00:00:00.000Z',
active: true,
},
]);
teamRepository.findOne.mockResolvedValue({
id: 5,
name: 'Team A',
alias: 'team-a',
transactions: [
{ date: '2026-08-15T00:00:00.000Z', amount: 10, note: 'Sponsoring', type: { name: 'credit' } },
],
players: [],
});
await scheduler.runDueSubscriptions();
expect(teamRepository.findOne).toHaveBeenCalledWith({
where: { id: 5 },
relations: ['players', 'players.transactions', 'transactions'],
});
expect(mailService.cashboxExport).toHaveBeenCalledTimes(1);
const [mailData, attachment, filename] = mailService.cashboxExport.mock.calls[0];
expect(mailData).toEqual({
to: 'vorstand@example.com',
data: { teamName: 'Team A', from: '2026-08-01', to: '2026-08-31' },
});
expect(Buffer.isBuffer(attachment)).toBe(true);
expect(filename).toBe('kassenbuch_team-a_2026-08-01_2026-08-31.pdf');
expect(subscriptionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ nextRunDate: '2026-10-01T00:00:00.000Z' }),
);
expect(logger.info).toHaveBeenCalledWith({
event: 'cashbox_export_subscription_run',
details: 'teamId=5 recipients=1 from=2026-08-01 to=2026-08-31',
userId: -1,
});
});
it.each([
[RecurringTransactionIntervalEnum.monthly, '2026-09-01T00:00:00.000Z', '2026-08-01', '2026-08-31', '2026-10-01T00:00:00.000Z'],
[RecurringTransactionIntervalEnum.quarterly, '2026-09-01T00:00:00.000Z', '2026-06-01', '2026-08-31', '2026-12-01T00:00:00.000Z'],
[RecurringTransactionIntervalEnum.yearly, '2027-01-01T00:00:00.000Z', '2026-01-01', '2026-12-31', '2028-01-01T00:00:00.000Z'],
])(
'computes the elapsed period and next run date for %s',
async (interval, nextRunDate, expectedFrom, expectedTo, expectedNext) => {
subscriptionRepository.find.mockResolvedValue([
{ id: 1, team: { id: 5 }, recipients: ['a@example.com'], interval, nextRunDate, active: true },
]);
teamRepository.findOne.mockResolvedValue({
id: 5,
name: 'Team A',
alias: 'team-a',
transactions: [],
players: [],
});
await scheduler.runDueSubscriptions();
const [mailData] = mailService.cashboxExport.mock.calls[0];
expect(mailData.data.from).toBe(expectedFrom);
expect(mailData.data.to).toBe(expectedTo);
expect(subscriptionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ nextRunDate: expectedNext }),
);
},
);
it('processes multiple due subscriptions independently', async () => {
subscriptionRepository.find.mockResolvedValue([
{ id: 1, team: { id: 5 }, recipients: ['a@example.com'], interval: RecurringTransactionIntervalEnum.monthly, nextRunDate: '2026-09-01T00:00:00.000Z', active: true },
{ id: 2, team: { id: 6 }, recipients: ['b@example.com'], interval: RecurringTransactionIntervalEnum.monthly, nextRunDate: '2026-09-01T00:00:00.000Z', active: true },
]);
teamRepository.findOne.mockResolvedValue({
id: 5,
name: 'Team A',
alias: 'team-a',
transactions: [],
players: [],
});
await scheduler.runDueSubscriptions();
expect(mailService.cashboxExport).toHaveBeenCalledTimes(2);
});
it('continues processing when one subscription fails', async () => {
subscriptionRepository.find.mockResolvedValue([
{ id: 1, team: { id: 5 }, recipients: ['a@example.com'], interval: RecurringTransactionIntervalEnum.monthly, nextRunDate: '2026-09-01T00:00:00.000Z', active: true },
{ id: 2, team: { id: 6 }, recipients: ['b@example.com'], interval: RecurringTransactionIntervalEnum.monthly, nextRunDate: '2026-09-01T00:00:00.000Z', active: true },
]);
teamRepository.findOne.mockResolvedValue({
id: 5,
name: 'Team A',
alias: 'team-a',
transactions: [],
players: [],
});
mailService.cashboxExport.mockRejectedValueOnce(new Error('smtp down'));
await scheduler.runDueSubscriptions();
expect(mailService.cashboxExport).toHaveBeenCalledTimes(2);
expect(subscriptionRepository.save).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith({
event: 'cashbox_export_subscription_run_fail',
details: expect.stringContaining('subscriptionId=1 teamId=5'),
userId: -1,
});
});
});

View File

@@ -0,0 +1,95 @@
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { LoggingService } from 'src/database/logging/logging.service';
import { Team } from 'src/teams/entities/team.entity';
import { MailService } from 'src/mail/mail.service';
import { LessThanOrEqual, Repository } from 'typeorm';
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
import { buildPdf, buildRows } from './cashbox-export.utils';
import { CashboxExportSubscription } from './entities/cashbox-export-subscription.entity';
const INTERVAL_MONTHS: Record<RecurringTransactionIntervalEnum, number> = {
[RecurringTransactionIntervalEnum.monthly]: 1,
[RecurringTransactionIntervalEnum.quarterly]: 3,
[RecurringTransactionIntervalEnum.yearly]: 12,
};
@Injectable()
export class CashboxExportScheduler {
constructor(
@InjectRepository(CashboxExportSubscription)
private readonly subscriptionRepository: Repository<CashboxExportSubscription>,
@InjectRepository(Team)
private readonly teamRepository: Repository<Team>,
private readonly mailService: MailService,
private readonly logger: LoggingService,
) {}
@Cron(CronExpression.EVERY_DAY_AT_4AM)
async runDueSubscriptions(): Promise<void> {
const today = new Date().toISOString();
const due = await this.subscriptionRepository.find({
where: { active: true, nextRunDate: LessThanOrEqual(today) },
relations: ['team'],
});
for (const subscription of due) {
try {
await this.runOne(subscription);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
await this.logger.error({
event: 'cashbox_export_subscription_run_fail',
details: `recurring subscription failed: subscriptionId=${subscription.id} teamId=${subscription.team.id}: ${errorMessage}`,
userId: -1,
});
}
}
}
private async runOne(subscription: CashboxExportSubscription): Promise<void> {
const team = await this.teamRepository.findOne({
where: { id: subscription.team.id },
relations: ['players', 'players.transactions', 'transactions'],
});
if (!team) return;
const { from, to } = this.periodBounds(subscription.nextRunDate, subscription.interval);
const rows = buildRows(team, from, to);
const pdf = await buildPdf(team, rows, from, to);
const filename = `kassenbuch_${team.alias}_${from}_${to}.pdf`;
await this.mailService.cashboxExport(
{ to: subscription.recipients.join(', '), data: { teamName: team.name, from, to } },
pdf,
filename,
);
subscription.nextRunDate = this.advance(subscription.nextRunDate, subscription.interval);
await this.subscriptionRepository.save(subscription);
await this.logger.info({
event: 'cashbox_export_subscription_run',
details: `teamId=${team.id} recipients=${subscription.recipients.length} from=${from} to=${to}`,
userId: -1,
});
}
private periodBounds(
nextRunDate: string,
interval: RecurringTransactionIntervalEnum,
): { from: string; to: string } {
const end = new Date(nextRunDate);
end.setUTCDate(end.getUTCDate() - 1);
const start = new Date(nextRunDate);
start.setUTCMonth(start.getUTCMonth() - INTERVAL_MONTHS[interval]);
return { from: start.toISOString().slice(0, 10), to: end.toISOString().slice(0, 10) };
}
private advance(nextRunDate: string, interval: RecurringTransactionIntervalEnum): string {
const date = new Date(nextRunDate);
date.setUTCMonth(date.getUTCMonth() + INTERVAL_MONTHS[interval]);
return date.toISOString();
}
}

View File

@@ -0,0 +1,107 @@
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
import { CashboxExportService } from './cashbox-export.service';
describe('CashboxExportService', () => {
const teamRepository = { findOne: jest.fn() };
const access = { assertAtLeast: jest.fn() };
const logger = { info: jest.fn() };
let service: CashboxExportService;
let callOrder: string[];
const team = {
id: 5,
name: 'Team A',
alias: 'team-a',
transactions: [
{ date: '2026-08-05T00:00:00.000Z', amount: 10, note: 'Sponsoring', type: { name: 'credit' } },
],
players: [],
};
beforeEach(() => {
jest.clearAllMocks();
callOrder = [];
access.assertAtLeast.mockImplementation(async () => {
callOrder.push('assertAtLeast');
});
teamRepository.findOne.mockImplementation(async () => {
callOrder.push('findOne');
return team;
});
service = new CashboxExportService(teamRepository as any, access as any, logger as any);
});
it('checks permission before loading data', async () => {
await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv');
expect(access.assertAtLeast).toHaveBeenCalledWith(
42,
5,
'transaction_create_min_role',
TeamRolesEnum.scnd_treasurer,
);
});
it('calls assertAtLeast before findOne to enforce permission check ordering', async () => {
await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv');
expect(callOrder).toEqual(['assertAtLeast', 'findOne']);
});
it('skips team lookup when permission check rejects', async () => {
access.assertAtLeast.mockRejectedValueOnce(new ForbiddenException('Insufficient permissions'));
await expect(
service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv'),
).rejects.toBeInstanceOf(ForbiddenException);
expect(teamRepository.findOne).not.toHaveBeenCalled();
});
it('throws NotFoundException for an unknown team', async () => {
teamRepository.findOne.mockResolvedValueOnce(null);
await expect(
service.exportForUser(999, 42, '2026-08-01', '2026-08-31', 'csv'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects a range where from is after to without querying the team', async () => {
await expect(
service.exportForUser(5, 42, '2026-08-31', '2026-08-01', 'csv'),
).rejects.toBeInstanceOf(BadRequestException);
expect(access.assertAtLeast).not.toHaveBeenCalled();
expect(teamRepository.findOne).not.toHaveBeenCalled();
});
it('builds a CSV buffer with the correct content type and filename', async () => {
const result = await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv');
expect(result.contentType).toBe('text/csv; charset=utf-8');
expect(result.filename).toBe('kassenbuch_team-a_2026-08-01_2026-08-31.csv');
expect(result.buffer.toString('utf-8')).toContain('Sponsoring');
});
it('builds a PDF buffer with the correct content type and filename', async () => {
const result = await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'pdf');
expect(result.contentType).toBe('application/pdf');
expect(result.filename).toBe('kassenbuch_team-a_2026-08-01_2026-08-31.pdf');
expect(result.buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
});
it('logs a cashbox_export_download event after a successful export', async () => {
await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv');
expect(logger.info).toHaveBeenCalledWith({
event: 'cashbox_export_download',
details: 'teamId=5 format=csv from=2026-08-01 to=2026-08-31',
userId: 42,
});
});
});

View File

@@ -0,0 +1,70 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { LoggingService } from 'src/database/logging/logging.service';
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
import { Team } from 'src/teams/entities/team.entity';
import { TeamAccessService } from 'src/teams/team-access.service';
import { Repository } from 'typeorm';
import { buildCsv, buildPdf, buildRows } from './cashbox-export.utils';
@Injectable()
export class CashboxExportService {
constructor(
@InjectRepository(Team)
private readonly teamRepository: Repository<Team>,
private readonly access: TeamAccessService,
private readonly logger: LoggingService,
) {}
async exportForUser(
teamId: number,
userId: number,
from: string,
to: string,
format: 'csv' | 'pdf',
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
if (from > to) {
throw new BadRequestException(
'Der Startzeitraum darf nicht nach dem Endzeitraum liegen.',
);
}
await this.access.assertAtLeast(
userId,
teamId,
'transaction_create_min_role',
TeamRolesEnum.scnd_treasurer,
);
const team = await this.teamRepository.findOne({
where: { id: teamId },
relations: ['players', 'players.transactions', 'transactions'],
});
if (!team) throw new NotFoundException('Team nicht gefunden.');
const rows = buildRows(team, from, to);
if (format === 'csv') {
const result = {
buffer: Buffer.from(buildCsv(rows), 'utf-8'),
contentType: 'text/csv; charset=utf-8',
filename: `kassenbuch_${team.alias}_${from}_${to}.csv`,
};
await this.logger.info({
event: 'cashbox_export_download',
details: `teamId=${teamId} format=${format} from=${from} to=${to}`,
userId,
});
return result;
}
const result = {
buffer: await buildPdf(team, rows, from, to),
contentType: 'application/pdf',
filename: `kassenbuch_${team.alias}_${from}_${to}.pdf`,
};
await this.logger.info({
event: 'cashbox_export_download',
details: `teamId=${teamId} format=${format} from=${from} to=${to}`,
userId,
});
return result;
}
}

View File

@@ -0,0 +1,192 @@
import { buildCsv, buildPdf, buildRows } from './cashbox-export.utils';
describe('buildRows', () => {
const team = (overrides: Partial<{ transactions: any[]; players: any[] }> = {}) => ({
id: 5,
name: 'Team A',
alias: 'team-a',
transactions: [],
players: [],
...overrides,
});
it('includes team-wallet credit and expense rows as "Teamkasse"', () => {
const rows = buildRows(
team({
transactions: [
{ date: '2026-08-05T00:00:00.000Z', amount: 100, note: 'Sponsoring', type: { name: 'credit' } },
{ date: '2026-08-10T00:00:00.000Z', amount: -20, note: 'Bälle', type: { name: 'expense' } },
],
}) as any,
'2026-08-01',
'2026-08-31',
);
expect(rows).toEqual([
{ date: '2026-08-05T00:00:00.000Z', type: 'credit', who: 'Teamkasse', note: 'Sponsoring', amount: 100, runningTotal: 100 },
{ date: '2026-08-10T00:00:00.000Z', type: 'expense', who: 'Teamkasse', note: 'Bälle', amount: -20, runningTotal: 80 },
]);
});
it('includes only "payment" player transactions, excluding fee/levy/fine', () => {
const rows = buildRows(
team({
players: [
{
firstName: 'Alex',
lastName: 'Muster',
transactions: [
{ date: '2026-08-03T00:00:00.000Z', amount: 10, note: 'Bar bezahlt', type: { name: 'payment' } },
{ date: '2026-08-04T00:00:00.000Z', amount: 15, note: 'Monatsbeitrag', type: { name: 'fee' } },
],
},
],
}) as any,
'2026-08-01',
'2026-08-31',
);
expect(rows).toEqual([
{ date: '2026-08-03T00:00:00.000Z', type: 'payment', who: 'Alex Muster', note: 'Bar bezahlt', amount: 10, runningTotal: 10 },
]);
});
it('excludes rows outside the [from, to] range and sorts the rest chronologically', () => {
const rows = buildRows(
team({
transactions: [
{ date: '2026-07-31T23:59:00.000Z', amount: 5, note: 'zu früh', type: { name: 'credit' } },
{ date: '2026-09-01T00:00:01.000Z', amount: 5, note: 'zu spät', type: { name: 'credit' } },
{ date: '2026-08-20T00:00:00.000Z', amount: 5, note: 'zweitens', type: { name: 'credit' } },
{ date: '2026-08-01T00:00:00.000Z', amount: 5, note: 'erstens', type: { name: 'credit' } },
],
}) as any,
'2026-08-01',
'2026-08-31',
);
expect(rows.map((row) => row.note)).toEqual(['erstens', 'zweitens']);
});
it('returns an empty array when nothing falls in range', () => {
const rows = buildRows(team() as any, '2026-08-01', '2026-08-31');
expect(rows).toEqual([]);
});
it('skips transactions with null type and includes valid ones', () => {
const rows = buildRows(
team({
transactions: [
{ date: '2026-08-05T00:00:00.000Z', amount: 100, note: 'Valid credit', type: { name: 'credit' } },
{ date: '2026-08-06T00:00:00.000Z', amount: 50, note: 'Null type team wallet', type: null },
],
players: [
{
firstName: 'Bob',
lastName: 'Smith',
transactions: [
{ date: '2026-08-07T00:00:00.000Z', amount: 20, note: 'Valid payment', type: { name: 'payment' } },
{ date: '2026-08-08T00:00:00.000Z', amount: 30, note: 'Null type player', type: null },
],
},
],
}) as any,
'2026-08-01',
'2026-08-31',
);
expect(rows).toEqual([
{ date: '2026-08-05T00:00:00.000Z', type: 'credit', who: 'Teamkasse', note: 'Valid credit', amount: 100, runningTotal: 100 },
{ date: '2026-08-07T00:00:00.000Z', type: 'payment', who: 'Bob Smith', note: 'Valid payment', amount: 20, runningTotal: 120 },
]);
});
});
describe('buildCsv', () => {
it('renders the header and formatted rows with German decimals', () => {
const csv = buildCsv([
{ date: '2026-08-05T00:00:00.000Z', type: 'payment', who: 'Alex Muster', note: 'Bar bezahlt', amount: 10, runningTotal: 10 },
{ date: '2026-08-10T00:00:00.000Z', type: 'expense', who: 'Teamkasse', note: 'Bälle', amount: -20.5, runningTotal: -10.5 },
]);
expect(csv).toBe(
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
'2026-08-05;Zahlung;Alex Muster;Bar bezahlt;10,00;10,00\r\n' +
'2026-08-10;Ausgabe;Teamkasse;Bälle;-20,50;-10,50',
);
});
it('quotes notes containing a semicolon and escapes embedded quotes', () => {
const csv = buildCsv([
{ date: '2026-08-05T00:00:00.000Z', type: 'credit', who: 'Teamkasse', note: 'Spende; "danke"', amount: 5, runningTotal: 5 },
]);
expect(csv).toBe(
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
'2026-08-05;Guthaben;Teamkasse;"Spende; ""danke""";5,00;5,00',
);
});
it('shows a placeholder row when there are no bookings', () => {
const csv = buildCsv([]);
expect(csv).toBe(
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\nKeine Buchungen im gewählten Zeitraum',
);
});
it('formats negative-zero as positive zero', () => {
const csv = buildCsv([
{ date: '2026-08-05T00:00:00.000Z', type: 'expense', who: 'Teamkasse', note: 'Test', amount: -0.001, runningTotal: 0 },
]);
expect(csv).toBe(
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
'2026-08-05;Ausgabe;Teamkasse;Test;0,00;0,00',
);
});
it('rounds half-cent boundaries correctly', () => {
const csv = buildCsv([
{ date: '2026-08-05T00:00:00.000Z', type: 'payment', who: 'Alex Muster', note: 'Test', amount: 1.005, runningTotal: 1.005 },
]);
expect(csv).toBe(
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
'2026-08-05;Zahlung;Alex Muster;Test;1,01;1,01',
);
});
it('rounds negative half-cent boundaries correctly', () => {
const csv = buildCsv([
{ date: '2026-08-05T00:00:00.000Z', type: 'expense', who: 'Teamkasse', note: 'Test', amount: -1.005, runningTotal: -1.005 },
]);
expect(csv).toBe(
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
'2026-08-05;Ausgabe;Teamkasse;Test;-1,01;-1,01',
);
});
});
describe('buildPdf', () => {
it('produces a non-empty valid PDF buffer', async () => {
const buffer = await buildPdf(
{ name: 'Team A' } as any,
[
{ date: '2026-08-05T00:00:00.000Z', type: 'payment', who: 'Alex Muster', note: 'Bar bezahlt', amount: 10, runningTotal: 10 },
],
'2026-08-01',
'2026-08-31',
);
expect(Buffer.isBuffer(buffer)).toBe(true);
expect(buffer.length).toBeGreaterThan(100);
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
});
it('still produces a valid PDF when there are no rows', async () => {
const buffer = await buildPdf({ name: 'Team A' } as any, [], '2026-08-01', '2026-08-31');
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
});
});

View File

@@ -0,0 +1,135 @@
import { Team } from 'src/teams/entities/team.entity';
import PDFDocument = require('pdfkit');
export interface CashboxExportRow {
date: string;
type: string;
who: string;
note: string;
amount: number;
runningTotal: number;
}
interface RawRow {
date: string;
type: string;
who: string;
note: string;
amount: number;
}
export function buildRows(team: Team, from: string, to: string): CashboxExportRow[] {
const fromTime = new Date(`${from}T00:00:00.000Z`).getTime();
const toTime = new Date(`${to}T23:59:59.999Z`).getTime();
const raw: RawRow[] = [];
for (const transaction of team.transactions ?? []) {
if (!transaction.type) continue;
raw.push({
date: transaction.date,
type: transaction.type.name,
who: 'Teamkasse',
note: transaction.note,
amount: Number(transaction.amount),
});
}
for (const player of team.players ?? []) {
for (const transaction of player.transactions ?? []) {
if (!transaction.type || transaction.type.name !== 'payment') continue;
raw.push({
date: transaction.date,
type: transaction.type.name,
who: `${player.firstName} ${player.lastName}`,
note: transaction.note,
amount: Number(transaction.amount),
});
}
}
const filtered = raw
.filter((row) => {
const time = new Date(row.date).getTime();
return time >= fromTime && time <= toTime;
})
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
let runningTotal = 0;
return filtered.map((row) => {
runningTotal += row.amount;
return { ...row, runningTotal };
});
}
const TYPE_LABELS: Record<string, string> = {
payment: 'Zahlung',
credit: 'Guthaben',
expense: 'Ausgabe',
};
function formatGermanAmount(value: number): string {
const rounded = Math.sign(value) * Math.round((Math.abs(value) + Number.EPSILON) * 100) / 100;
const normalized = rounded === 0 ? 0 : rounded;
return normalized.toFixed(2).replace('.', ',');
}
function escapeCsvField(value: string): string {
if (/[;"\n\r]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
export function buildCsv(rows: CashboxExportRow[]): string {
const lines = ['Datum;Typ;Wer;Notiz;Betrag;Periodensaldo'];
if (rows.length === 0) {
lines.push('Keine Buchungen im gewählten Zeitraum');
} else {
for (const row of rows) {
lines.push(
[
row.date.slice(0, 10),
TYPE_LABELS[row.type] ?? row.type,
escapeCsvField(row.who),
escapeCsvField(row.note),
formatGermanAmount(row.amount),
formatGermanAmount(row.runningTotal),
].join(';'),
);
}
}
return lines.join('\r\n');
}
export function buildPdf(
team: Pick<Team, 'name'>,
rows: CashboxExportRow[],
from: string,
to: string,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ margin: 40 });
const chunks: Buffer[] = [];
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
doc.fontSize(16).text(`Kassenbuch ${team.name}`);
doc.fontSize(10).text(`Zeitraum: ${from} bis ${to}`);
doc.moveDown();
if (rows.length === 0) {
doc.text('Keine Buchungen im gewählten Zeitraum.');
} else {
for (const row of rows) {
doc.text(
`${row.date.slice(0, 10)} ${TYPE_LABELS[row.type] ?? row.type} ${row.who} ${row.note} ` +
`${formatGermanAmount(row.amount)} € Saldo: ${formatGermanAmount(row.runningTotal)}`,
);
}
}
doc.end();
});
}

View File

@@ -0,0 +1,12 @@
import { IsIn, Matches } from 'class-validator';
export class CashboxExportQueryDto {
@Matches(/^\d{4}-\d{2}-\d{2}$/)
from: string;
@Matches(/^\d{4}-\d{2}-\d{2}$/)
to: string;
@IsIn(['csv', 'pdf'])
format: 'csv' | 'pdf';
}

View File

@@ -0,0 +1,8 @@
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
export class CashboxExportSubscriptionResponseDTO {
recipients: string[];
interval: RecurringTransactionIntervalEnum;
active: boolean;
nextRunDate: string | null;
}

View File

@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsEmail, IsIn } from 'class-validator';
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
export class UpsertCashboxExportSubscriptionDTO {
@ApiProperty({ example: ['vorstand@example.com'] })
@IsArray()
@IsEmail({}, { each: true })
recipients: string[];
@ApiProperty({ enum: RecurringTransactionIntervalEnum })
@IsIn(Object.values(RecurringTransactionIntervalEnum))
interval: RecurringTransactionIntervalEnum;
@ApiProperty({ example: true })
@IsBoolean()
active: boolean;
}

View File

@@ -0,0 +1,26 @@
import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
import { EntityHelper } from 'src/utils/entity-helper';
import { Team } from 'src/teams/entities/team.entity';
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
@Entity()
export class CashboxExportSubscription extends EntityHelper {
@PrimaryGeneratedColumn()
id: number;
@OneToOne(() => Team, { eager: false })
@JoinColumn()
team: Team;
@Column({ type: 'simple-array', default: '' })
recipients: string[];
@Column()
interval: RecurringTransactionIntervalEnum;
@Column({ default: false })
active: boolean;
@Column({ nullable: true })
nextRunDate: string | null;
}

View File

@@ -30,6 +30,10 @@ export type LOGEVENT =
| 'recurring_transaction_create'
| 'recurring_transaction_update'
| 'recurring_transaction_delete'
| 'recurring_transaction_run';
| 'recurring_transaction_run'
| 'cashbox_export_download'
| 'cashbox_export_subscription_update'
| 'cashbox_export_subscription_run'
| 'cashbox_export_subscription_run_fail';
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';

View File

@@ -0,0 +1,5 @@
{{#> layout}}
<p>Hallo,</p>
<p>im Anhang findest du den automatischen Kassenbuch-Export für <strong>{{teamName}}</strong> für den Zeitraum {{from}} bis {{to}}.</p>
<p>Diese E-Mail wurde automatisch von TeamWallet verschickt und benötigt keine weitere Aktion.</p>
{{/layout}}

View File

@@ -60,4 +60,20 @@ describe('mail templates rendering', () => {
expect(html).toContain('Hallo,');
expect(html).not.toContain('Hallo Max,');
});
it('renders cashbox-export.hbs with team name and period', () => {
const source = fs.readFileSync(path.join(templatesDir, 'cashbox-export.hbs'), 'utf-8');
const html = Handlebars.compile(source, { strict: true })({
title: 'Kassenbuch-Export Team A',
year: 2026,
teamName: 'Team A',
from: '2026-08-01',
to: '2026-08-31',
});
expect(html).toContain('TeamWallet');
expect(html).toContain('Team A');
expect(html).toContain('2026-08-01');
expect(html).toContain('2026-08-31');
});
});

View File

@@ -58,4 +58,28 @@ describe('MailService', () => {
const call = sendMail.mock.calls[0][0];
expect(call.context.firstName).toBeUndefined();
});
it('sends the cashbox export mail with the PDF attachment', async () => {
const attachment = Buffer.from('%PDF-1.4 fake');
await service.cashboxExport(
{
to: 'vorstand@example.com, kassier@example.com',
data: { teamName: 'Team A', from: '2026-08-01', to: '2026-08-31' },
},
attachment,
'kassenbuch_team-a_2026-08-01_2026-08-31.pdf',
);
expect(sendMail).toHaveBeenCalledTimes(1);
const call = sendMail.mock.calls[0][0];
expect(call.to).toBe('vorstand@example.com, kassier@example.com');
expect(call.template).toBe('cashbox-export');
expect(call.context.teamName).toBe('Team A');
expect(call.context.from).toBe('2026-08-01');
expect(call.context.to).toBe('2026-08-31');
expect(call.attachments).toEqual([
{ filename: 'kassenbuch_team-a_2026-08-01_2026-08-31.pdf', content: attachment },
]);
});
});

View File

@@ -55,4 +55,24 @@ export class MailService {
},
});
}
async cashboxExport(
mailData: MailData<{ teamName: string; from: string; to: string }>,
attachment: Buffer,
filename: string,
): Promise<void> {
await this.mailerService.sendMail({
to: mailData.to,
subject: `Kassenbuch-Export ${mailData.data.teamName}`,
template: 'cashbox-export',
context: {
title: `Kassenbuch-Export ${mailData.data.teamName}`,
year: new Date().getFullYear(),
teamName: mailData.data.teamName,
from: mailData.data.from,
to: mailData.data.to,
},
attachments: [{ filename, content: attachment }],
});
}
}

View File

@@ -0,0 +1,46 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { environment } from '../../../environments/environment';
import { CashboxExportApi } from './cashbox-export-api';
describe('CashboxExportApi', () => {
let api: CashboxExportApi;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
api = TestBed.inject(CashboxExportApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('downloads the cashbox export as a blob with query params', () => {
api.exportCashbox(5, '2026-08-01', '2026-08-31', 'csv').subscribe();
const request = httpMock.expectOne(
`${environment.apiUrl}cashbox-export/5?from=2026-08-01&to=2026-08-31&format=csv`,
);
expect(request.request.method).toBe('GET');
expect(request.request.responseType).toBe('blob');
request.flush(new Blob(['csv content']));
});
it('loads the subscription', () => {
api.getSubscription(5).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}cashbox-export/5/subscription`);
expect(request.request.method).toBe('GET');
request.flush({ recipients: [], interval: 'monthly', active: false, nextRunDate: null });
});
it('updates the subscription', () => {
const update = { recipients: ['a@example.com'], interval: 'monthly' as const, active: true };
api.updateSubscription(5, update).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}cashbox-export/5/subscription`);
expect(request.request.method).toBe('PUT');
expect(request.request.body).toEqual(update);
request.flush({ ...update, nextRunDate: '2026-09-01T00:00:00.000Z' });
});
});

View File

@@ -0,0 +1,36 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import {
CashboxExportFormat,
CashboxExportSubscription,
UpdateCashboxExportSubscription,
} from '../../models/cashbox-export.model';
@Injectable({ providedIn: 'root' })
export class CashboxExportApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}cashbox-export`;
exportCashbox(
teamId: number,
from: string,
to: string,
format: CashboxExportFormat,
): Observable<Blob> {
const params = new HttpParams().set('from', from).set('to', to).set('format', format);
return this.http.get(`${this.baseUrl}/${teamId}`, { params, responseType: 'blob' });
}
getSubscription(teamId: number): Observable<CashboxExportSubscription> {
return this.http.get<CashboxExportSubscription>(`${this.baseUrl}/${teamId}/subscription`);
}
updateSubscription(
teamId: number,
dto: UpdateCashboxExportSubscription,
): Observable<CashboxExportSubscription> {
return this.http.put<CashboxExportSubscription>(`${this.baseUrl}/${teamId}/subscription`, dto);
}
}

View File

@@ -0,0 +1,71 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { of, throwError } from 'rxjs';
import { CashboxExportApi } from '../../../../core/team/cashbox-export-api';
import { FileDownloadService } from '../../../../shared/file-download/file-download.service';
import { CashboxExportDialog } from './cashbox-export-dialog';
describe('CashboxExportDialog', () => {
let fixture: ComponentFixture<CashboxExportDialog>;
let exportCashbox: ReturnType<typeof vi.fn>;
let save: ReturnType<typeof vi.fn>;
let dialogRef: { close: ReturnType<typeof vi.fn> };
beforeEach(async () => {
exportCashbox = vi.fn(() => of(new Blob(['csv content'])));
save = vi.fn();
dialogRef = { close: vi.fn() };
await TestBed.configureTestingModule({
imports: [CashboxExportDialog],
providers: [
{ provide: MAT_DIALOG_DATA, useValue: { teamId: 5 } },
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: CashboxExportApi, useValue: { exportCashbox } },
{ provide: FileDownloadService, useValue: { save } },
],
}).compileComponents();
fixture = TestBed.createComponent(CashboxExportDialog);
fixture.detectChanges();
});
it('keeps the download disabled until from <= to', () => {
fixture.componentInstance['form'].setValue({ from: '2026-08-31', to: '2026-08-01', format: 'csv' });
expect(fixture.componentInstance['form'].invalid).toBe(true);
fixture.componentInstance['download']();
expect(exportCashbox).not.toHaveBeenCalled();
});
it('downloads the file and closes the dialog on success', () => {
fixture.componentInstance['form'].setValue({ from: '2026-08-01', to: '2026-08-31', format: 'csv' });
fixture.componentInstance['download']();
expect(exportCashbox).toHaveBeenCalledWith(5, '2026-08-01', '2026-08-31', 'csv');
expect(save).toHaveBeenCalledWith(expect.any(Blob), 'kassenbuch_2026-08-01_2026-08-31.csv');
expect(dialogRef.close).toHaveBeenCalled();
});
it('displays error message and does not close dialog on export failure', () => {
exportCashbox = vi.fn(() => throwError(() => new Error('network error')));
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [CashboxExportDialog],
providers: [
{ provide: MAT_DIALOG_DATA, useValue: { teamId: 5 } },
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: CashboxExportApi, useValue: { exportCashbox } },
{ provide: FileDownloadService, useValue: { save } },
],
});
fixture = TestBed.createComponent(CashboxExportDialog);
fixture.detectChanges();
fixture.componentInstance['form'].setValue({ from: '2026-08-01', to: '2026-08-31', format: 'csv' });
fixture.componentInstance['download']();
expect(dialogRef.close).not.toHaveBeenCalled();
expect(fixture.componentInstance['downloadError']()).toBe('Export konnte nicht heruntergeladen werden.');
});
});

View File

@@ -0,0 +1,98 @@
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, ValidationErrors, ValidatorFn, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { CashboxExportApi } from '../../../../core/team/cashbox-export-api';
import { CashboxExportFormat } from '../../../../models/cashbox-export.model';
import { FileDownloadService } from '../../../../shared/file-download/file-download.service';
const rangeValid: ValidatorFn = (group): ValidationErrors | null => {
const from = group.get('from')?.value;
const to = group.get('to')?.value;
return from && to && from > to ? { rangeInvalid: true } : null;
};
@Component({
selector: 'app-cashbox-export-dialog',
imports: [
ReactiveFormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
],
template: `
<h2 mat-dialog-title>Kassenbuch exportieren</h2>
<form [formGroup]="form" (ngSubmit)="download()">
<mat-dialog-content>
@if (downloadError()) {
<p class="error-message" role="alert">{{ downloadError() }}</p>
}
<mat-form-field appearance="outline">
<mat-label>Von</mat-label>
<input matInput formControlName="from" type="date" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Bis</mat-label>
<input matInput formControlName="to" type="date" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Format</mat-label>
<mat-select formControlName="format">
<mat-option value="csv">CSV</mat-option>
<mat-option value="pdf">PDF</mat-option>
</mat-select>
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="dialogRef.close()">Abbrechen</button>
<button mat-flat-button type="submit" [disabled]="form.invalid">Herunterladen</button>
</mat-dialog-actions>
</form>
`,
styles: `
.error-message {
padding: 12px 16px;
border-radius: 12px;
color: var(--mat-sys-error);
background: var(--mat-sys-error-container);
}
`,
})
export class CashboxExportDialog {
protected readonly dialogRef = inject(MatDialogRef<CashboxExportDialog>);
private readonly data = inject<{ teamId: number }>(MAT_DIALOG_DATA);
private readonly formBuilder = inject(FormBuilder);
private readonly api = inject(CashboxExportApi);
private readonly fileDownload = inject(FileDownloadService);
protected readonly downloadError = signal<string | null>(null);
protected readonly form = this.formBuilder.nonNullable.group(
{
from: ['', Validators.required],
to: ['', Validators.required],
format: ['csv' as CashboxExportFormat, Validators.required],
},
{ validators: rangeValid },
);
protected download(): void {
if (this.form.invalid) return;
this.downloadError.set(null);
const { from, to, format } = this.form.getRawValue();
this.api.exportCashbox(this.data.teamId, from, to, format).subscribe({
next: (blob) => {
this.fileDownload.save(blob, `kassenbuch_${from}_${to}.${format}`);
this.dialogRef.close();
},
error: () => {
this.downloadError.set('Export konnte nicht heruntergeladen werden.');
},
});
}
}

View File

@@ -0,0 +1,36 @@
<h2 mat-dialog-title>Automatischen Versand einrichten</h2>
<mat-dialog-content>
@if (loadError()) {
<p class="error-message" role="alert">{{ loadError() }}</p>
}
@if (saveError()) {
<p class="error-message" role="alert">{{ saveError() }}</p>
}
<mat-form-field appearance="outline">
<mat-label>E-Mail-Adresse hinzufügen</mat-label>
<input matInput #recipientInput (keydown.enter)="addRecipient(recipientInput.value); recipientInput.value = ''" />
</mat-form-field>
<mat-chip-set>
@for (recipient of recipients(); track recipient) {
<mat-chip (removed)="removeRecipient(recipient)">
{{ recipient }}
<button matChipRemove><mat-icon>cancel</mat-icon></button>
</mat-chip>
}
</mat-chip-set>
<form [formGroup]="form">
<mat-form-field appearance="outline">
<mat-label>Intervall</mat-label>
<mat-select formControlName="interval">
<mat-option value="monthly">Monatlich</mat-option>
<mat-option value="quarterly">Quartalsweise</mat-option>
<mat-option value="yearly">Jährlich</mat-option>
</mat-select>
</mat-form-field>
<mat-slide-toggle formControlName="active">Aktiv</mat-slide-toggle>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="dialogRef.close()">Abbrechen</button>
<button mat-flat-button type="button" (click)="save()">Speichern</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,6 @@
.error-message {
padding: 12px 16px;
border-radius: 12px;
color: var(--mat-sys-error);
background: var(--mat-sys-error-container);
}

View File

@@ -0,0 +1,92 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { of, throwError } from 'rxjs';
import { CashboxExportApi } from '../../../../core/team/cashbox-export-api';
import { CashboxExportSubscriptionDialog } from './cashbox-export-subscription-dialog';
describe('CashboxExportSubscriptionDialog', () => {
let fixture: ComponentFixture<CashboxExportSubscriptionDialog>;
let getSubscription: ReturnType<typeof vi.fn>;
let updateSubscription: ReturnType<typeof vi.fn>;
let dialogRef: { close: ReturnType<typeof vi.fn> };
beforeEach(async () => {
getSubscription = vi.fn(() =>
of({ recipients: ['a@example.com'], interval: 'monthly', active: true, nextRunDate: '2026-09-01T00:00:00.000Z' }),
);
updateSubscription = vi.fn(() =>
of({ recipients: ['a@example.com', 'b@example.com'], interval: 'monthly', active: true, nextRunDate: '2026-09-01T00:00:00.000Z' }),
);
dialogRef = { close: vi.fn() };
await TestBed.configureTestingModule({
imports: [CashboxExportSubscriptionDialog],
providers: [
{ provide: MAT_DIALOG_DATA, useValue: { teamId: 5 } },
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: CashboxExportApi, useValue: { getSubscription, updateSubscription } },
],
}).compileComponents();
fixture = TestBed.createComponent(CashboxExportSubscriptionDialog);
fixture.detectChanges();
});
it('loads the existing subscription into the form', () => {
expect(getSubscription).toHaveBeenCalledWith(5);
expect(fixture.componentInstance['recipients']()).toEqual(['a@example.com']);
});
it('rejects adding an invalid email', () => {
fixture.componentInstance['addRecipient']('not-an-email');
expect(fixture.componentInstance['recipients']()).toEqual(['a@example.com']);
});
it('adds a valid email and saves the updated recipient list', () => {
fixture.componentInstance['addRecipient']('b@example.com');
expect(fixture.componentInstance['recipients']()).toEqual(['a@example.com', 'b@example.com']);
fixture.componentInstance['save']();
expect(updateSubscription).toHaveBeenCalledWith(5, {
recipients: ['a@example.com', 'b@example.com'],
interval: 'monthly',
active: true,
});
expect(dialogRef.close).toHaveBeenCalled();
});
it('removes a recipient', () => {
fixture.componentInstance['removeRecipient']('a@example.com');
expect(fixture.componentInstance['recipients']()).toEqual([]);
});
it('handles getSubscription error by setting loadError', async () => {
getSubscription.mockReturnValueOnce(throwError(() => new Error('API error')));
await TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [CashboxExportSubscriptionDialog],
providers: [
{ provide: MAT_DIALOG_DATA, useValue: { teamId: 5 } },
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: CashboxExportApi, useValue: { getSubscription, updateSubscription } },
],
}).compileComponents();
fixture = TestBed.createComponent(CashboxExportSubscriptionDialog);
fixture.detectChanges();
expect(fixture.componentInstance['loadError']()).toBe('Einstellungen konnten nicht geladen werden.');
});
it('handles updateSubscription error by setting saveError and not closing dialog', () => {
updateSubscription.mockReturnValueOnce(throwError(() => new Error('API error')));
dialogRef.close.mockClear();
fixture.componentInstance['addRecipient']('b@example.com');
fixture.componentInstance['save']();
expect(fixture.componentInstance['saveError']()).toBe('Speichern fehlgeschlagen.');
expect(dialogRef.close).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,81 @@
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatChipsModule } from '@angular/material/chips';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatIconModule } from '@angular/material/icon';
import { CashboxExportApi } from '../../../../core/team/cashbox-export-api';
import { RecurringTransactionInterval } from '../../../../models/recurring-transaction.model';
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@Component({
selector: 'app-cashbox-export-subscription-dialog',
imports: [
ReactiveFormsModule,
MatButtonModule,
MatChipsModule,
MatDialogModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatSelectModule,
MatSlideToggleModule,
],
templateUrl: './cashbox-export-subscription-dialog.html',
styleUrl: './cashbox-export-subscription-dialog.scss',
})
export class CashboxExportSubscriptionDialog {
protected readonly dialogRef = inject(MatDialogRef<CashboxExportSubscriptionDialog>);
private readonly data = inject<{ teamId: number }>(MAT_DIALOG_DATA);
private readonly formBuilder = inject(FormBuilder);
private readonly api = inject(CashboxExportApi);
protected readonly recipients = signal<string[]>([]);
protected readonly loadError = signal<string | null>(null);
protected readonly saveError = signal<string | null>(null);
protected readonly form = this.formBuilder.nonNullable.group({
interval: ['monthly' as RecurringTransactionInterval, Validators.required],
active: [false],
});
constructor() {
this.api.getSubscription(this.data.teamId).subscribe({
next: (subscription) => {
this.recipients.set(subscription.recipients);
this.form.setValue({ interval: subscription.interval, active: subscription.active });
},
error: () => {
this.loadError.set('Einstellungen konnten nicht geladen werden.');
},
});
}
protected addRecipient(value: string): void {
const trimmed = value.trim();
if (!EMAIL_PATTERN.test(trimmed) || this.recipients().includes(trimmed)) return;
this.recipients.set([...this.recipients(), trimmed]);
}
protected removeRecipient(value: string): void {
this.recipients.set(this.recipients().filter((entry) => entry !== value));
}
protected save(): void {
if (this.form.invalid) return;
this.saveError.set(null);
const { interval, active } = this.form.getRawValue();
this.api
.updateSubscription(this.data.teamId, { recipients: this.recipients(), interval, active })
.subscribe({
next: () => this.dialogRef.close(),
error: () => {
this.saveError.set('Speichern fehlgeschlagen.');
},
});
}
}

View File

@@ -178,6 +178,25 @@
}
</mat-select>
</mat-form-field>
@if (canBook()) {
<button mat-stroked-button type="button" (click)="openExportDialog()">
<mat-icon>download</mat-icon>
Export
</button>
<button
mat-icon-button
type="button"
[matMenuTriggerFor]="exportMenu"
aria-label="Weitere Exportoptionen"
>
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #exportMenu="matMenu">
<button mat-menu-item (click)="openExportSubscriptionDialog()">
Automatischen Versand einrichten
</button>
</mat-menu>
}
</div>
<ag-grid-angular

View File

@@ -8,6 +8,7 @@ import { PenaltyApi } from '../../../core/team/penalty-api';
import { TeamStore } from '../../../core/team/team-store';
import { TransactionsApi } from '../../../core/team/transactions-api';
import { Cashbox } from './cashbox';
import { CashboxExportDialog } from './cashbox-export-dialog/cashbox-export-dialog';
describe('Cashbox', () => {
const team = {
@@ -281,4 +282,38 @@ describe('Cashbox', () => {
expect.objectContaining({ amount: 0, note: '', type: 11 }),
);
});
it('opens the cashbox export dialog when the treasurer clicks Export', async () => {
const { fixture, dialog } = await setup();
fixture.detectChanges();
const exportButton = [...(fixture.nativeElement as HTMLElement).querySelectorAll('button')].find(
(btn) => btn.textContent?.includes('Export'),
);
exportButton?.click();
expect(dialog.open).toHaveBeenCalledWith(
CashboxExportDialog,
expect.objectContaining({ data: { teamId: expect.any(Number) } }),
);
});
it('hides the Export button from a member without booking rights', async () => {
const { fixture } = await setup(1);
fixture.detectChanges();
const exportButton = [...(fixture.nativeElement as HTMLElement).querySelectorAll('button')].find(
(btn) => btn.textContent?.includes('Export'),
);
expect(exportButton).toBeUndefined();
});
it('does not open the export dialogs when invoked directly without booking rights', async () => {
const { component, dialog } = await setup(1);
component['openExportDialog']();
component['openExportSubscriptionDialog']();
expect(dialog.open).not.toHaveBeenCalled();
});
});

View File

@@ -10,6 +10,7 @@ import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { AgGridAngular } from 'ag-grid-angular';
@@ -38,6 +39,8 @@ import {
} from '../../../models/transaction.model';
import { ConfirmDialog, ConfirmDialogData } from '../../../shared/confirm-dialog/confirm-dialog';
import { ContextHelp } from '../../../shared/context-help/context-help';
import { CashboxExportDialog } from './cashbox-export-dialog/cashbox-export-dialog';
import { CashboxExportSubscriptionDialog } from './cashbox-export-subscription-dialog/cashbox-export-subscription-dialog';
import '../../../shared/ag-grid/ag-grid-modules';
import { teamwalletGridTheme } from '../../../shared/ag-grid/ag-grid-theme';
import { AmountCellRenderer } from '../../../shared/ag-grid/amount-cell-renderer';
@@ -72,6 +75,7 @@ const JOURNAL_TYPE_OPTIONS: { value: string; label: string }[] = [
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatMenuModule,
MatSelectModule,
MatSnackBarModule,
ContextHelp,
@@ -396,6 +400,18 @@ export class Cashbox {
});
}
protected openExportDialog(): void {
const teamId = this.team()?.id;
if (!this.canBook() || !teamId) return;
this.dialog.open(CashboxExportDialog, { data: { teamId } });
}
protected openExportSubscriptionDialog(): void {
const teamId = this.team()?.id;
if (!this.canBook() || !teamId) return;
this.dialog.open(CashboxExportSubscriptionDialog, { data: { teamId } });
}
protected typeLabel(type: string): string {
return (
{

View File

@@ -0,0 +1,16 @@
import { RecurringTransactionInterval } from './recurring-transaction.model';
export type CashboxExportFormat = 'csv' | 'pdf';
export interface CashboxExportSubscription {
recipients: string[];
interval: RecurringTransactionInterval;
active: boolean;
nextRunDate: string | null;
}
export interface UpdateCashboxExportSubscription {
recipients: string[];
interval: RecurringTransactionInterval;
active: boolean;
}

View File

@@ -0,0 +1,29 @@
import { TestBed } from '@angular/core/testing';
import { FileDownloadService } from './file-download.service';
describe('FileDownloadService', () => {
let service: FileDownloadService;
let clickSpy: ReturnType<typeof vi.fn>;
let createObjectURLSpy: ReturnType<typeof vi.fn>;
let revokeObjectURLSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
service = TestBed.inject(FileDownloadService);
clickSpy = vi.fn();
createObjectURLSpy = vi.fn(() => 'blob:mock-url');
revokeObjectURLSpy = vi.fn();
vi.spyOn(URL, 'createObjectURL').mockImplementation(createObjectURLSpy as (obj: Blob | MediaSource) => string);
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(revokeObjectURLSpy as (url: string) => void);
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickSpy as () => void);
});
it('creates an object URL, clicks a temporary anchor with the given filename, and revokes the URL', () => {
const blob = new Blob(['content'], { type: 'text/csv' });
service.save(blob, 'kassenbuch.csv');
expect(createObjectURLSpy).toHaveBeenCalledWith(blob);
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url');
});
});

View File

@@ -0,0 +1,13 @@
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class FileDownloadService {
save(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
}