Compare commits
28 Commits
9e55c0549b
...
da5998487a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da5998487a | ||
|
|
ce0b500d7a | ||
|
|
1c214c5f06 | ||
|
|
148269da11 | ||
|
|
db061a7ef7 | ||
|
|
11b70b9337 | ||
|
|
5c3ff52689 | ||
|
|
a65c7b35b3 | ||
|
|
72fa1d4331 | ||
|
|
d628d5e4d7 | ||
|
|
57869d5fc1 | ||
|
|
eed8266da2 | ||
|
|
5779483b21 | ||
|
|
196898b993 | ||
|
|
42e2bbc4ff | ||
|
|
a11eb73ce9 | ||
|
|
009aae1f2b | ||
|
|
18df224386 | ||
|
|
396dc29cf5 | ||
|
|
f8857f71e1 | ||
|
|
0b408f7d73 | ||
|
|
c6df6a6d38 | ||
|
|
cab04c5869 | ||
|
|
cf7c3efb0f | ||
|
|
a9df62a249 | ||
|
|
d8883d4687 | ||
|
|
15d5f1d4e4 | ||
|
|
b8bcf329c5 |
2496
docs/superpowers/plans/2026-08-03-cashbox-export.md
Normal file
2496
docs/superpowers/plans/2026-08-03-cashbox-export.md
Normal file
File diff suppressed because it is too large
Load Diff
185
docs/superpowers/specs/2026-08-03-cashbox-export-design.md
Normal file
185
docs/superpowers/specs/2026-08-03-cashbox-export-design.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# Kassenbuch-Export (manuell + automatischer PDF-Versand)
|
||||||
|
|
||||||
|
Status: approved
|
||||||
|
Datum: 2026-08-03
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
TeamWallet bietet mit dem Kassenjournal (`teams/:id/transactions/journal`, Cashbox-Seite im
|
||||||
|
Frontend) bereits eine paginierte, filterbare Ansicht aller Buchungen. Es gibt aber keine
|
||||||
|
Möglichkeit, diese Daten für die Vereinsbuchhaltung oder eine Kassenprüfung zu exportieren — weder
|
||||||
|
manuell (CSV/PDF-Download) noch automatisiert (regelmäßiger Versand an Vorstand/Kassenprüfer). Beide
|
||||||
|
Fähigkeiten fehlen komplett (kein Export-Code, `mail`-Modul aktuell nur für Login/Passwort-Reset
|
||||||
|
genutzt).
|
||||||
|
|
||||||
|
Ziel: Ein Kassenwart/Captain/Coach kann (a) für einen frei wählbaren Zeitraum einen Kassenbuch-Export
|
||||||
|
als CSV und/oder PDF herunterladen, und (b) optional einen wiederkehrenden automatischen PDF-Versand
|
||||||
|
an beliebige E-Mail-Adressen einrichten (z.B. monatlich an den Vereinsvorstand).
|
||||||
|
|
||||||
|
Das Feature wurde im Brainstorming aus mehreren Optionen ausgewählt (Alternativen: Saldo-
|
||||||
|
Erinnerungen, server-seitige Journal-Filterung, Belege-Anhang, Vier-Augen-Prinzip — diese sind nicht
|
||||||
|
Teil dieses Plans). Ausdrücklich nicht Teil dieses oder eines zukünftigen Plans: Team
|
||||||
|
verlassen/löschen.
|
||||||
|
|
||||||
|
## Fachliche Einordnung
|
||||||
|
|
||||||
|
Ein "Kassenbuch" bildet nur **echte Kassenbewegungen** ab — Buchungen, die laut `setBalance()`
|
||||||
|
(`Transaction`- und `TeamWalletTransaction`-Entity) tatsächlich `team.balance` verändern:
|
||||||
|
|
||||||
|
- `Transaction` mit `type.id === 0` (`payment`) — Spieler zahlt echtes Geld ein.
|
||||||
|
- **Alle** `TeamWalletTransaction`-Einträge (`credit` und `expense`) — direkte Kassenbewegungen ohne
|
||||||
|
Spielerbezug.
|
||||||
|
|
||||||
|
Spieler-Fälligkeiten (`fee`/`levy`/`fine`, `type.id > 10`) verändern nur die Spielerschuld, nie den
|
||||||
|
Kassenbestand, und werden **bewusst ausgeschlossen** (entspricht der gewählten Option "Nur
|
||||||
|
Kassenjournal (Team-Saldo)").
|
||||||
|
|
||||||
|
Der Export zeigt einen **Periodensaldo** (laufende Summe ab 0, beginnend am gewählten Startdatum),
|
||||||
|
keinen historischen Kontostand — eine Rekonstruktion des absoluten Kontostands zu einem beliebigen
|
||||||
|
Vergangenheitszeitpunkt wäre für den ersten Wurf YAGNI.
|
||||||
|
|
||||||
|
## Entscheidungen aus dem Brainstorming
|
||||||
|
|
||||||
|
- **Format**: CSV und PDF, beide.
|
||||||
|
- **Zeitraum (manueller Export)**: frei wählbares Von/Bis-Datum.
|
||||||
|
- **Inhalt**: nur Kassenjournal (Team-Saldo), keine Spieler-Fälligkeiten.
|
||||||
|
- **Berechtigung**: wie Buchungen anlegen (`transaction_create_min_role`) — sowohl für den manuellen
|
||||||
|
Export als auch für die Konfiguration des automatischen Versands.
|
||||||
|
- **Automatischer Versand — Intervalle**: monatlich, quartalsweise, jährlich (identisch zu den
|
||||||
|
wiederkehrenden Buchungen).
|
||||||
|
- **Automatischer Versand — Zeitraum**: immer der jeweils **abgelaufene volle Zeitraum** (z.B. bei
|
||||||
|
monatlichem Versand am 1. des Monats immer genau der komplette Vormonat), nicht "seit letztem
|
||||||
|
Versand" (das wäre bei verpassten Läufen mehrdeutig).
|
||||||
|
- **Automatischer Versand — Umfang**: **eine** Konfiguration pro Team (eine Empfängerliste, ein
|
||||||
|
Intervall, pausierbar) statt mehrerer unabhängiger Abos.
|
||||||
|
- **Out of Scope**: Team verlassen/löschen (bereits an anderer Stelle ausgeschlossen).
|
||||||
|
|
||||||
|
## Architektur / Komponenten
|
||||||
|
|
||||||
|
### 1. Backend: neues Modul `cashbox-export/`
|
||||||
|
|
||||||
|
Struktur analog zu `recurring-transactions/` (eigenständiges Modul statt Erweiterung von
|
||||||
|
`teams.service.ts`, das bereits die Journal- und Statistik-Logik trägt).
|
||||||
|
|
||||||
|
**`cashbox-export.service.ts`**:
|
||||||
|
- `buildRows(team: Team, from: string, to: string): CashboxExportRow[]` — reine Funktion auf einer
|
||||||
|
bereits geladenen `Team`-Entity (inkl. `players.transactions.type`, `transactions.type`). Filtert
|
||||||
|
auf echte Kassenbewegungen (s.o.), grenzt auf `[from, to]` ein (Ende inklusiv, Tagesende), sortiert
|
||||||
|
chronologisch aufsteigend, berechnet laufenden `runningTotal`. Wird sowohl vom HTTP-Pfad als auch
|
||||||
|
vom Scheduler verwendet — keine Duplikation der Filterlogik.
|
||||||
|
- `getExportRowsForUser(teamId, userId, from, to)` — HTTP-Pfad: prüft `transaction_create_min_role`
|
||||||
|
via `TeamAccessService.assertAtLeast`, lädt das Team, ruft `buildRows` auf.
|
||||||
|
- `buildCsv(team, rows, from, to): string` — Semikolon-getrennt, deutsches Komma als
|
||||||
|
Dezimaltrennzeichen (Excel-DE-Standard), RFC4180-Escaping für Notizen mit Semikolon/Anführungszeichen/
|
||||||
|
Zeilenumbruch. Spalten: Datum, Typ, Wer (Spielername oder "Teamkasse"), Notiz, Betrag, Periodensaldo.
|
||||||
|
Bei leerem Zeitraum: nur Kopfzeile + Hinweiszeile "Keine Buchungen im gewählten Zeitraum".
|
||||||
|
- `buildPdf(team, rows, from, to): Buffer` — einfache Tabellen-PDF via neuer Abhängigkeit **`pdfkit`**
|
||||||
|
(kein Chromium/Puppeteer nötig): Kopf mit Teamname + Zeitraum + Erstellungsdatum, Tabelle, Fußzeile
|
||||||
|
mit Periodensaldo. Gleiche Leerzeitraum-Behandlung wie CSV.
|
||||||
|
|
||||||
|
**`cashbox-export.controller.ts`** (Pfad `cashbox-export`, `version: '1'`, nur `AuthGuard('jwt')`,
|
||||||
|
Berechtigung im Service):
|
||||||
|
- `GET cashbox-export/:teamId?from=&to=&format=csv|pdf` — liefert Datei über `@Res({passthrough:
|
||||||
|
false})` mit manuell gesetzten Headern (`Content-Type`, `Content-Disposition: attachment;
|
||||||
|
filename="kassenbuch_<teamAlias>_<from>_<to>.<ext>"`), kein globaler Response-Interceptor im
|
||||||
|
Projekt vorhanden, der das stören würde.
|
||||||
|
- `GET cashbox-export/:teamId/subscription` — aktuelle Versand-Konfiguration (oder Default:
|
||||||
|
`{ recipients: [], interval: 'monthly', active: false }`).
|
||||||
|
- `PUT cashbox-export/:teamId/subscription` — Upsert (Empfänger/Intervall/Aktiv-Status).
|
||||||
|
|
||||||
|
**Neue Entity `entities/cashbox-export-subscription.entity.ts`**: `id`, `team` (ManyToOne, in der
|
||||||
|
Praxis 1:1 durch Anwendungslogik im Service erzwungen — nur eine Subscription pro Team wird gepflegt/
|
||||||
|
aktualisiert statt neu angelegt), `recipients` (`simple-array`-Spalte, Liste von E-Mail-Strings),
|
||||||
|
`interval` (`RecurringTransactionIntervalEnum`, wiederverwendet aus dem `recurring-transactions`-
|
||||||
|
Modul — fachlich identisches Konzept), `active` (default `false`), `nextRunDate` (string, ISO-Datum),
|
||||||
|
`createdAt`.
|
||||||
|
|
||||||
|
**DTO `UpsertCashboxExportSubscriptionDto`**: `recipients: string[]` (`@IsEmail({}, {each:true})`),
|
||||||
|
`interval`, `active`. Validierung: `active === true` mit leerer `recipients`-Liste wird mit 400
|
||||||
|
abgelehnt (ergibt keinen Sinn, nichts zu versenden aber "aktiv").
|
||||||
|
|
||||||
|
**`cashbox-export.scheduler.ts`** (`@Cron`, zeitlich versetzt zum bestehenden
|
||||||
|
Recurring-Transactions-Job, z.B. `04:00 Uhr` statt `03:00 Uhr`, um DB-Last zu entzerren):
|
||||||
|
1. Lädt alle `active: true`-Subscriptions mit `nextRunDate <= heute` (inkl. `team`).
|
||||||
|
2. Pro fälliger Subscription: bestimmt den **abgelaufenen** Zeitraum passend zum `interval`
|
||||||
|
ausgehend von `nextRunDate` (z.B. `nextRunDate = 2026-09-01`, `interval = monthly` → Zeitraum
|
||||||
|
`2026-08-01`–`2026-08-31`), lädt das Team (inkl. Relationen), ruft `buildRows` + `buildPdf` auf
|
||||||
|
(Wiederverwendung derselben Logik wie der manuelle Export), verschickt das PDF per
|
||||||
|
`MailService`/`MailerService`-Attachment an alle `recipients` (neues Template
|
||||||
|
`mail-templates/cashbox-export.hbs`, analog Aufbau zu `reset-password.hbs`), rückt `nextRunDate`
|
||||||
|
um das Intervall vor (gleiche `setUTCMonth`-Arithmetik wie im Recurring-Transactions-Scheduler:
|
||||||
|
`+1`/`+3`/`+12` Monate) und speichert.
|
||||||
|
3. Ein verpasster Tag (Server-Downtime) wird beim nächsten Lauf automatisch nachgeholt (rein
|
||||||
|
datumsbasierter Check wie beim Recurring-Transactions-Scheduler).
|
||||||
|
|
||||||
|
**Registrierung**: `CashboxExportModule` in `src/app.module.ts` ergänzen (analog `PenaltyModule`/
|
||||||
|
`RecurringTransactionsModule`); `MailModule` importieren für den Versand.
|
||||||
|
|
||||||
|
**Logging-Events**: `cashbox_export_download`, `cashbox_export_subscription_update`,
|
||||||
|
`cashbox_export_subscription_run` in `logging-event.type.ts` ergänzen.
|
||||||
|
|
||||||
|
### 2. Frontend
|
||||||
|
|
||||||
|
**Cashbox-Toolbar**: neuer "Export"-Button (sichtbar nur mit `canDo(team(), 'transactionCreate')`,
|
||||||
|
kein neuer Permission-Key) öffnet einen Dialog mit Von/Bis-Datumsfeldern und Format-Auswahl
|
||||||
|
(CSV/PDF), löst über `CashboxExportApi.exportCashbox(teamId, from, to, format)`
|
||||||
|
(`responseType: 'blob'`) den Download aus. Ein kleiner `FileDownloadService.save(blob, filename)`
|
||||||
|
kapselt den Anchor-Click-Mechanismus, damit die Dialog-Komponente ohne echte DOM-Downloads getestet
|
||||||
|
werden kann (Service wird im Test gemockt).
|
||||||
|
|
||||||
|
Im selben Export-Bereich zusätzlich ein Zahnrad/Link "Automatischen Versand einrichten" → eigener
|
||||||
|
Dialog: Chip-Liste für E-Mail-Adressen (hinzufügen/entfernen, clientseitige Format-Validierung vor
|
||||||
|
dem Speichern), Intervall-Dropdown, Aktiv/Pausiert-Toggle, Speichern-Button. Neue Methoden
|
||||||
|
`CashboxExportApi.getSubscription(teamId)` / `updateSubscription(teamId, dto)`.
|
||||||
|
|
||||||
|
Neues Model `models/cashbox-export.model.ts` (`CashboxExportFormat`, `CashboxExportSubscription`,
|
||||||
|
`UpdateCashboxExportSubscription`).
|
||||||
|
|
||||||
|
## Fehlerbehandlung
|
||||||
|
|
||||||
|
- `from > to` → 400 (Backend), Submit-Button im Dialog zusätzlich clientseitig deaktiviert.
|
||||||
|
- Keine Buchungen im Zeitraum → Datei wird trotzdem erzeugt (Kopfzeile + Hinweistext), kein Fehler.
|
||||||
|
- Ungültige E-Mail-Adresse in der Empfängerliste → 400 (DTO-Validierung), Inline-Fehler im Dialog.
|
||||||
|
- `active: true` mit leerer Empfängerliste → 400.
|
||||||
|
- Fehlende Berechtigung → bestehender `assertAtLeast`-Wurf (403), keine neue Behandlung nötig.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**Backend**:
|
||||||
|
- `cashbox-export.service.spec.ts` — Filterlogik (Ausschluss fee/levy/fine, Einschluss payment +
|
||||||
|
alle TeamWallet-Typen), Datumsgrenzen (inklusive Tagesende), laufender Saldo, leerer Zeitraum,
|
||||||
|
Berechtigungsdurchsetzung.
|
||||||
|
- `cashbox-export.http.spec.ts` — Auth erforderlich, korrekte Header/Content-Type je Format, CSV-
|
||||||
|
Inhalt exakt geprüft (String-Vergleich), PDF nur auf `%PDF-`-Signatur + Non-Empty geprüft (kein
|
||||||
|
Byte-Vergleich).
|
||||||
|
- `cashbox-export-subscription.service.spec.ts` — Upsert, Validierung (aktiv + leere Liste),
|
||||||
|
Berechtigung.
|
||||||
|
- `cashbox-export.scheduler.spec.ts` — Perioden-Berechnung je Intervall (`it.each`), PDF+Mail-
|
||||||
|
Dispatch mit gemocktem `MailerService` (Attachment vorhanden, korrekte Empfänger/Betreff),
|
||||||
|
`nextRunDate`-Vorrücken, überspringt inaktive/nicht-fällige Subscriptions, Downtime-Nachholung.
|
||||||
|
|
||||||
|
**Frontend**:
|
||||||
|
- `cashbox-export-api.spec.ts` — korrekte HTTP-Calls (Query-Params, `responseType: 'blob'`,
|
||||||
|
Subscription-GET/PUT).
|
||||||
|
- Export-Dialog-Spec — Formvalidierung (`from <= to`), Permission-Gating, ruft
|
||||||
|
`FileDownloadService.save` mit korrekten Argumenten auf.
|
||||||
|
- Subscription-Dialog-Spec — Laden/Speichern, Chip-Validierung, Permission-Gating.
|
||||||
|
|
||||||
|
## Bewusst nicht enthalten (YAGNI)
|
||||||
|
|
||||||
|
- Kein historischer Anfangssaldo (nur Periodensaldo ab 0 innerhalb des Exportzeitraums).
|
||||||
|
- Kein Export der Spieler-Fälligkeiten (fee/levy/fine).
|
||||||
|
- Kein Excel-(.xlsx)-Format, nur CSV+PDF.
|
||||||
|
- Keine mehreren Versand-Konfigurationen pro Team.
|
||||||
|
- Kein CSV im automatischen Versand, nur PDF.
|
||||||
|
- Keine Empfänger-Verifizierung (Double-Opt-In) für frei eingetragene Adressen.
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
|
||||||
|
- **Backend-Unit-Tests**: siehe oben, alle grün, `nest build` sauber.
|
||||||
|
- **Frontend-Unit-Tests**: siehe oben, alle grün, `tsc --noEmit` + `ng build` sauber.
|
||||||
|
- **Manuell**: Backend lokal starten, über die neue UI einen CSV- und einen PDF-Export für einen
|
||||||
|
Zeitraum mit bekannten Testbuchungen herunterladen und Inhalt/Saldo stichprobenartig prüfen; eine
|
||||||
|
Subscription mit `nextRunDate` = heute anlegen, Scheduler-Methode einmalig manuell aufrufen, prüfen
|
||||||
|
dass eine E-Mail mit PDF-Anhang an alle konfigurierten Adressen geht und `nextRunDate` korrekt
|
||||||
|
vorrückt.
|
||||||
358
myteamwallet_backend/package-lock.json
generated
358
myteamwallet_backend/package-lock.json
generated
@@ -30,6 +30,7 @@
|
|||||||
"passport": "0.6.0",
|
"passport": "0.6.0",
|
||||||
"passport-anonymous": "1.0.1",
|
"passport-anonymous": "1.0.1",
|
||||||
"passport-jwt": "4.0.0",
|
"passport-jwt": "4.0.0",
|
||||||
|
"pdfkit": "^0.19.1",
|
||||||
"pg": "8.8.0",
|
"pg": "8.8.0",
|
||||||
"reflect-metadata": "0.1.13",
|
"reflect-metadata": "0.1.13",
|
||||||
"rimraf": "3.0.2",
|
"rimraf": "3.0.2",
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"@types/node": "16.18.3",
|
"@types/node": "16.18.3",
|
||||||
"@types/passport-anonymous": "1.0.3",
|
"@types/passport-anonymous": "1.0.3",
|
||||||
"@types/passport-jwt": "3.0.7",
|
"@types/passport-jwt": "3.0.7",
|
||||||
|
"@types/pdfkit": "^0.17.6",
|
||||||
"@types/supertest": "2.0.12",
|
"@types/supertest": "2.0.12",
|
||||||
"@typescript-eslint/eslint-plugin": "5.43.0",
|
"@typescript-eslint/eslint-plugin": "5.43.0",
|
||||||
"@typescript-eslint/parser": "5.43.0",
|
"@typescript-eslint/parser": "5.43.0",
|
||||||
@@ -3559,6 +3561,30 @@
|
|||||||
"typeorm": "^0.3.0"
|
"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": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.3.tgz",
|
||||||
"integrity": "sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg=="
|
"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": {
|
"node_modules/@tootallnate/once": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
|
||||||
@@ -4008,6 +4049,16 @@
|
|||||||
"@types/passport": "*"
|
"@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": {
|
"node_modules/@types/prettier": {
|
||||||
"version": "2.7.1",
|
"version": "2.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz",
|
||||||
@@ -5212,6 +5263,24 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.21.4",
|
"version": "4.21.4",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz",
|
||||||
@@ -6186,6 +6255,12 @@
|
|||||||
"wrappy": "1"
|
"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": {
|
"node_modules/diff": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
||||||
@@ -7601,8 +7676,7 @@
|
|||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/fast-diff": {
|
"node_modules/fast-diff": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
@@ -7797,6 +7871,32 @@
|
|||||||
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
|
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/fork-ts-checker-webpack-plugin": {
|
||||||
"version": "7.2.13",
|
"version": "7.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-7.2.13.tgz",
|
"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": ">=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": {
|
"node_modules/js-sdsl": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.1.5.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/libqp/-/libqp-1.1.0.tgz",
|
||||||
"integrity": "sha512-4Rgfa0hZpG++t1Vi2IiqXG9Ad1ig4QTmtuZF946QJP4bPqOYC78ixUXgz5TW/wE7lNaNKlplSYTxQ+fR2KZ0EA=="
|
"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": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
|
||||||
"integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="
|
"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": {
|
"node_modules/param-case": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/param-case/-/param-case-2.1.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||||
"integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10="
|
"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": {
|
"node_modules/pg": {
|
||||||
"version": "8.8.0",
|
"version": "8.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz",
|
||||||
@@ -14471,6 +14616,14 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/postgres-array": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
@@ -15074,6 +15227,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/ret": {
|
||||||
"version": "0.1.15",
|
"version": "0.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
|
||||||
@@ -16097,6 +16256,12 @@
|
|||||||
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
|
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/tlds": {
|
||||||
"version": "1.231.0",
|
"version": "1.231.0",
|
||||||
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.231.0.tgz",
|
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.231.0.tgz",
|
||||||
@@ -16850,6 +17015,32 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/universalify": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
|
||||||
@@ -20151,6 +20342,16 @@
|
|||||||
"uuid": "8.3.2"
|
"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": {
|
"@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.3.tgz",
|
||||||
"integrity": "sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg=="
|
"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": {
|
"@tootallnate/once": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
|
||||||
@@ -20555,6 +20771,15 @@
|
|||||||
"@types/passport": "*"
|
"@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": {
|
"@types/prettier": {
|
||||||
"version": "2.7.1",
|
"version": "2.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz",
|
||||||
@@ -21474,6 +21699,22 @@
|
|||||||
"fill-range": "^7.0.1"
|
"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": {
|
"browserslist": {
|
||||||
"version": "4.21.4",
|
"version": "4.21.4",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz",
|
||||||
@@ -22200,6 +22441,11 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"dfa": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="
|
||||||
|
},
|
||||||
"diff": {
|
"diff": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
||||||
@@ -23240,8 +23486,7 @@
|
|||||||
"fast-deep-equal": {
|
"fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"fast-diff": {
|
"fast-diff": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
@@ -23410,6 +23655,29 @@
|
|||||||
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
|
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
|
||||||
"dev": true
|
"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": {
|
"fork-ts-checker-webpack-plugin": {
|
||||||
"version": "7.2.13",
|
"version": "7.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-7.2.13.tgz",
|
"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": {
|
"js-sdsl": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.1.5.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/libqp/-/libqp-1.1.0.tgz",
|
||||||
"integrity": "sha512-4Rgfa0hZpG++t1Vi2IiqXG9Ad1ig4QTmtuZF946QJP4bPqOYC78ixUXgz5TW/wE7lNaNKlplSYTxQ+fR2KZ0EA=="
|
"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": {
|
"lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
|
||||||
"integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="
|
"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": {
|
"param-case": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/param-case/-/param-case-2.1.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||||
"integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10="
|
"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": {
|
"pg": {
|
||||||
"version": "8.8.0",
|
"version": "8.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz",
|
||||||
@@ -28459,6 +28766,14 @@
|
|||||||
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
|
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
|
||||||
"dev": true
|
"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": {
|
"postgres-array": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
@@ -28939,6 +29254,11 @@
|
|||||||
"signal-exit": "^3.0.2"
|
"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": {
|
"ret": {
|
||||||
"version": "0.1.15",
|
"version": "0.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
|
||||||
@@ -29705,6 +30025,11 @@
|
|||||||
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
|
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
|
||||||
"dev": true
|
"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": {
|
"tlds": {
|
||||||
"version": "1.231.0",
|
"version": "1.231.0",
|
||||||
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.231.0.tgz",
|
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.231.0.tgz",
|
||||||
@@ -30156,6 +30481,31 @@
|
|||||||
"which-boxed-primitive": "^1.0.2"
|
"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": {
|
"universalify": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
"passport": "0.6.0",
|
"passport": "0.6.0",
|
||||||
"passport-anonymous": "1.0.1",
|
"passport-anonymous": "1.0.1",
|
||||||
"passport-jwt": "4.0.0",
|
"passport-jwt": "4.0.0",
|
||||||
|
"pdfkit": "^0.19.1",
|
||||||
"pg": "8.8.0",
|
"pg": "8.8.0",
|
||||||
"reflect-metadata": "0.1.13",
|
"reflect-metadata": "0.1.13",
|
||||||
"rimraf": "3.0.2",
|
"rimraf": "3.0.2",
|
||||||
@@ -68,6 +69,7 @@
|
|||||||
"@types/node": "16.18.3",
|
"@types/node": "16.18.3",
|
||||||
"@types/passport-anonymous": "1.0.3",
|
"@types/passport-anonymous": "1.0.3",
|
||||||
"@types/passport-jwt": "3.0.7",
|
"@types/passport-jwt": "3.0.7",
|
||||||
|
"@types/pdfkit": "^0.17.6",
|
||||||
"@types/supertest": "2.0.12",
|
"@types/supertest": "2.0.12",
|
||||||
"@typescript-eslint/eslint-plugin": "5.43.0",
|
"@typescript-eslint/eslint-plugin": "5.43.0",
|
||||||
"@typescript-eslint/parser": "5.43.0",
|
"@typescript-eslint/parser": "5.43.0",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { LoggingModule } from './database/logging/logging.module';
|
|||||||
import { TranslateModule } from './translate/translate.module';
|
import { TranslateModule } from './translate/translate.module';
|
||||||
import { PenaltyModule } from './penalty/penalty.module';
|
import { PenaltyModule } from './penalty/penalty.module';
|
||||||
import { RecurringTransactionsModule } from './recurring-transactions/recurring-transactions.module';
|
import { RecurringTransactionsModule } from './recurring-transactions/recurring-transactions.module';
|
||||||
|
import { CashboxExportModule } from './cashbox-export/cashbox-export.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -59,6 +60,7 @@ import { RecurringTransactionsModule } from './recurring-transactions/recurring-
|
|||||||
TranslateModule,
|
TranslateModule,
|
||||||
PenaltyModule,
|
PenaltyModule,
|
||||||
RecurringTransactionsModule,
|
RecurringTransactionsModule,
|
||||||
|
CashboxExportModule,
|
||||||
],
|
],
|
||||||
providers: [],
|
providers: [],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-');
|
||||||
|
});
|
||||||
|
});
|
||||||
135
myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts
Normal file
135
myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -30,6 +30,10 @@ export type LOGEVENT =
|
|||||||
| 'recurring_transaction_create'
|
| 'recurring_transaction_create'
|
||||||
| 'recurring_transaction_update'
|
| 'recurring_transaction_update'
|
||||||
| 'recurring_transaction_delete'
|
| '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';
|
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -60,4 +60,20 @@ describe('mail templates rendering', () => {
|
|||||||
expect(html).toContain('Hallo,');
|
expect(html).toContain('Hallo,');
|
||||||
expect(html).not.toContain('Hallo Max,');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,4 +58,28 @@ describe('MailService', () => {
|
|||||||
const call = sendMail.mock.calls[0][0];
|
const call = sendMail.mock.calls[0][0];
|
||||||
expect(call.context.firstName).toBeUndefined();
|
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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.error-message {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--mat-sys-error);
|
||||||
|
background: var(--mat-sys-error-container);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -178,6 +178,25 @@
|
|||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</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>
|
</div>
|
||||||
|
|
||||||
<ag-grid-angular
|
<ag-grid-angular
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { PenaltyApi } from '../../../core/team/penalty-api';
|
|||||||
import { TeamStore } from '../../../core/team/team-store';
|
import { TeamStore } from '../../../core/team/team-store';
|
||||||
import { TransactionsApi } from '../../../core/team/transactions-api';
|
import { TransactionsApi } from '../../../core/team/transactions-api';
|
||||||
import { Cashbox } from './cashbox';
|
import { Cashbox } from './cashbox';
|
||||||
|
import { CashboxExportDialog } from './cashbox-export-dialog/cashbox-export-dialog';
|
||||||
|
|
||||||
describe('Cashbox', () => {
|
describe('Cashbox', () => {
|
||||||
const team = {
|
const team = {
|
||||||
@@ -281,4 +282,38 @@ describe('Cashbox', () => {
|
|||||||
expect.objectContaining({ amount: 0, note: '', type: 11 }),
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { MatDialog } from '@angular/material/dialog';
|
|||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
import { AgGridAngular } from 'ag-grid-angular';
|
import { AgGridAngular } from 'ag-grid-angular';
|
||||||
@@ -38,6 +39,8 @@ import {
|
|||||||
} from '../../../models/transaction.model';
|
} from '../../../models/transaction.model';
|
||||||
import { ConfirmDialog, ConfirmDialogData } from '../../../shared/confirm-dialog/confirm-dialog';
|
import { ConfirmDialog, ConfirmDialogData } from '../../../shared/confirm-dialog/confirm-dialog';
|
||||||
import { ContextHelp } from '../../../shared/context-help/context-help';
|
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 '../../../shared/ag-grid/ag-grid-modules';
|
||||||
import { teamwalletGridTheme } from '../../../shared/ag-grid/ag-grid-theme';
|
import { teamwalletGridTheme } from '../../../shared/ag-grid/ag-grid-theme';
|
||||||
import { AmountCellRenderer } from '../../../shared/ag-grid/amount-cell-renderer';
|
import { AmountCellRenderer } from '../../../shared/ag-grid/amount-cell-renderer';
|
||||||
@@ -72,6 +75,7 @@ const JOURNAL_TYPE_OPTIONS: { value: string; label: string }[] = [
|
|||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
|
MatMenuModule,
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
MatSnackBarModule,
|
MatSnackBarModule,
|
||||||
ContextHelp,
|
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 {
|
protected typeLabel(type: string): string {
|
||||||
return (
|
return (
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user