diff --git a/docs/superpowers/plans/2026-08-03-cashbox-export.md b/docs/superpowers/plans/2026-08-03-cashbox-export.md new file mode 100644 index 0000000..a99b10b --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-cashbox-export.md @@ -0,0 +1,2496 @@ +# Kassenbuch-Export (CSV/PDF + automatischer PDF-Versand) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a treasurer/captain/coach download a CSV/PDF cashbox export for a free date range, and optionally configure a recurring automatic PDF mailing (monthly/quarterly/yearly) to arbitrary email addresses. + +**Architecture:** New self-contained backend module `cashbox-export/` (NestJS, mirrors `recurring-transactions/`) with pure row-filtering/rendering functions shared between an on-demand HTTP download endpoint and a daily `@Cron` scheduler that emails a subscription's PDF via the existing `mail` module. New frontend API client + two `MatDialog` components wired into the existing Cashbox page toolbar. + +**Tech Stack:** NestJS 9, TypeORM 0.3, `pdfkit` (new dependency, PDF rendering), `@nestjs-modules/mailer`/nodemailer (existing, attachment support), Angular 21 standalone components, Angular Material, Jest (backend), Vitest (frontend). + +## Global Constraints + +- Only real cash-affecting transactions are exported: `Transaction` rows with `type.name === 'payment'`, and **all** `TeamWalletTransaction` rows (`credit`, `expense`) — see `Transaction.setBalance()` / `TeamWalletTransaction.setBalance()` in the existing codebase for why (spec: `docs/superpowers/specs/2026-08-03-cashbox-export-design.md`, section "Fachliche Einordnung"). +- Permission for every new endpoint (download, subscription read/write): `TeamAccessService.assertAtLeast(userId, teamId, 'transaction_create_min_role', TeamRolesEnum.scnd_treasurer, manager?)` — no new permission key, backend or frontend. +- CSV: semicolon-delimited, German comma decimal separator, RFC4180 quoting for `;`/`"`/newline in notes. +- PDF: built with `pdfkit`, no headless browser. +- Recurring PDF mailing period = the **elapsed** full interval ending the day before `nextRunDate` (not "since last send"). +- One `CashboxExportSubscription` row per team (enforced via `@OneToOne` + `@JoinColumn` unique FK). +- Every new file must have a colocated `.spec.ts` following TDD (Red → Green, no code before a failing test — see `superpowers:test-driven-development`). + +--- + +## File Structure + +``` +myteamwallet_backend/src/cashbox-export/ + cashbox-export.utils.ts # pure: buildRows, buildCsv, buildPdf + cashbox-export.utils.spec.ts + cashbox-export.service.ts # permission + team load + delegates to utils + cashbox-export.service.spec.ts + cashbox-export-subscription.service.ts # get/upsert subscription row + cashbox-export-subscription.service.spec.ts + cashbox-export.controller.ts # GET :teamId, GET/PUT :teamId/subscription + cashbox-export.http.spec.ts + cashbox-export.scheduler.ts # @Cron, period calc, mail dispatch + cashbox-export.scheduler.spec.ts + cashbox-export.module.ts + entities/cashbox-export-subscription.entity.ts + dto/cashbox-export-query.dto.ts + dto/upsert-cashbox-export-subscription.dto.ts + dto/cashbox-export-subscription-response.dto.ts + +myteamwallet_backend/src/mail/ + mail.service.ts # + cashboxExport() method (modify) + mail.service.spec.ts # + test (modify) + mail-templates/cashbox-export.hbs # new template + mail-templates/mail-templates.spec.ts # + test (modify) + +myteamwallet_backend/src/app.module.ts # register module (modify) +myteamwallet_backend/src/database/logging/model/logging-event.type.ts # new events (modify) + +myteamwallet_frontend_modern/src/app/ + models/cashbox-export.model.ts + shared/file-download/file-download.service.ts + shared/file-download/file-download.service.spec.ts + core/team/cashbox-export-api.ts + core/team/cashbox-export-api.spec.ts + features/team/cashbox/cashbox-export-dialog/cashbox-export-dialog.ts + features/team/cashbox/cashbox-export-dialog/cashbox-export-dialog.spec.ts + features/team/cashbox/cashbox-export-subscription-dialog/cashbox-export-subscription-dialog.ts + features/team/cashbox/cashbox-export-subscription-dialog/cashbox-export-subscription-dialog.spec.ts + features/team/cashbox/cashbox.ts # + open dialogs (modify) + features/team/cashbox/cashbox.html # + export button/menu (modify) +``` + +--- + +### Task 1: Add `pdfkit` dependency + +**Files:** +- Modify: `myteamwallet_backend/package.json` + +**Interfaces:** +- Produces: `pdfkit` importable as `import PDFDocument from 'pdfkit';` in later tasks. + +- [ ] **Step 1: Install the dependency** + +Run from `myteamwallet_backend/`: +```bash +npm install pdfkit@0.19.1 +npm install -D @types/pdfkit@0.17.6 +``` + +- [ ] **Step 2: Verify it resolves** + +Run: `node -e "console.log(require('pdfkit'))"` from `myteamwallet_backend/` +Expected: prints the PDFDocument class constructor (no error). + +- [ ] **Step 3: Commit** + +```bash +git add myteamwallet_backend/package.json myteamwallet_backend/package-lock.json +git commit -m "chore: add pdfkit for cashbox PDF export" +``` + +--- + +### Task 2: `buildRows` — filter and shape real cash movements + +**Files:** +- Create: `myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts` +- Test: `myteamwallet_backend/src/cashbox-export/cashbox-export.utils.spec.ts` + +**Interfaces:** +- Produces: `interface CashboxExportRow { date: string; type: string; who: string; note: string; amount: number; runningTotal: number }` and `function buildRows(team: Team, from: string, to: string): CashboxExportRow[]`. `from`/`to` are `YYYY-MM-DD` strings (date-only); `to` is inclusive through end-of-day UTC. +- Consumes: `Team` entity (`src/teams/entities/team.entity.ts`) with `players` (each with `transactions: Transaction[]`, `Transaction.type.name` eager-loaded) and `transactions: TeamWalletTransaction[]` (`TeamWalletTransaction.type.name` eager-loaded) relations already populated. + +- [ ] **Step 1: Write the failing test** + +```typescript +import { 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([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run (from `myteamwallet_backend/`): `npx jest src/cashbox-export/cashbox-export.utils.spec.ts` +Expected: FAIL — `Cannot find module './cashbox-export.utils'`. + +- [ ] **Step 3: Write minimal implementation** + +```typescript +import { Team } from 'src/teams/entities/team.entity'; + +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 ?? []) { + 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.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 }; + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx jest src/cashbox-export/cashbox-export.utils.spec.ts` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts myteamwallet_backend/src/cashbox-export/cashbox-export.utils.spec.ts +git commit -m "feat: add buildRows for cashbox export row filtering" +``` + +--- + +### Task 3: `buildCsv` — semicolon CSV with German decimals + +**Files:** +- Modify: `myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts` +- Modify: `myteamwallet_backend/src/cashbox-export/cashbox-export.utils.spec.ts` + +**Interfaces:** +- Consumes: `CashboxExportRow[]` from Task 2. +- Produces: `function buildCsv(rows: CashboxExportRow[]): string`. + +- [ ] **Step 1: Write the failing test** + +Append to `cashbox-export.utils.spec.ts`: + +```typescript +import { buildCsv } from './cashbox-export.utils'; + +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', + ); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest src/cashbox-export/cashbox-export.utils.spec.ts -t buildCsv` +Expected: FAIL — `buildCsv is not a function` / not exported. + +- [ ] **Step 3: Write minimal implementation** + +Add to `cashbox-export.utils.ts`: + +```typescript +const TYPE_LABELS: Record = { + payment: 'Zahlung', + credit: 'Guthaben', + expense: 'Ausgabe', +}; + +function formatGermanAmount(value: number): string { + return value.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'); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx jest src/cashbox-export/cashbox-export.utils.spec.ts` +Expected: PASS (7 tests total). + +- [ ] **Step 5: Commit** + +```bash +git add myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts myteamwallet_backend/src/cashbox-export/cashbox-export.utils.spec.ts +git commit -m "feat: add buildCsv for cashbox export" +``` + +--- + +### Task 4: `buildPdf` — table PDF via pdfkit + +**Files:** +- Modify: `myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts` +- Modify: `myteamwallet_backend/src/cashbox-export/cashbox-export.utils.spec.ts` + +**Interfaces:** +- Consumes: `CashboxExportRow[]`, `Team` (for `name`), `from`/`to` strings. +- Produces: `function buildPdf(team: Pick, rows: CashboxExportRow[], from: string, to: string): Promise`. + +- [ ] **Step 1: Write the failing test** + +Append to `cashbox-export.utils.spec.ts`: + +```typescript +import { buildPdf } from './cashbox-export.utils'; + +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-'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest src/cashbox-export/cashbox-export.utils.spec.ts -t buildPdf` +Expected: FAIL — `buildPdf is not a function`. + +- [ ] **Step 3: Write minimal implementation** + +Add to `cashbox-export.utils.ts`: + +```typescript +import PDFDocument from 'pdfkit'; + +export function buildPdf( + team: Pick, + rows: CashboxExportRow[], + from: string, + to: string, +): Promise { + 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(); + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx jest src/cashbox-export/cashbox-export.utils.spec.ts` +Expected: PASS (9 tests total). + +- [ ] **Step 5: Commit** + +```bash +git add myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts myteamwallet_backend/src/cashbox-export/cashbox-export.utils.spec.ts +git commit -m "feat: add buildPdf for cashbox export" +``` + +--- + +### Task 5: `CashboxExportQueryDto` + `CashboxExportService.exportForUser` + +**Files:** +- Create: `myteamwallet_backend/src/cashbox-export/dto/cashbox-export-query.dto.ts` +- Create: `myteamwallet_backend/src/cashbox-export/cashbox-export.service.ts` +- Test: `myteamwallet_backend/src/cashbox-export/cashbox-export.service.spec.ts` + +**Interfaces:** +- Consumes: `buildRows`, `buildCsv`, `buildPdf` from Tasks 2-4; `TeamAccessService.assertAtLeast` (`src/teams/team-access.service.ts`); `Team` repository. +- Produces: `class CashboxExportService { exportForUser(teamId: number, userId: number, from: string, to: string, format: 'csv' | 'pdf'): Promise<{ buffer: Buffer; contentType: string; filename: string }> }`. Consumed by Task 6 (controller) and reused pattern for Task 9 (scheduler uses `buildRows`/`buildPdf` directly, not this service, since it has no HTTP-bound user). + +- [ ] **Step 1: Write the failing test** + +```typescript +import { 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() }; + let service: CashboxExportService; + + 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(); + teamRepository.findOne.mockResolvedValue(team); + service = new CashboxExportService(teamRepository as any, access 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('throws NotFoundException for an unknown team', async () => { + teamRepository.findOne.mockResolvedValue(null); + + await expect( + service.exportForUser(999, 42, '2026-08-01', '2026-08-31', 'csv'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + 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-'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest src/cashbox-export/cashbox-export.service.spec.ts` +Expected: FAIL — `Cannot find module './cashbox-export.service'`. + +- [ ] **Step 3: Write minimal implementation** + +`dto/cashbox-export-query.dto.ts`: + +```typescript +import { IsDateString, IsIn } from 'class-validator'; + +export class CashboxExportQueryDto { + @IsDateString() + from: string; + + @IsDateString() + to: string; + + @IsIn(['csv', 'pdf']) + format: 'csv' | 'pdf'; +} +``` + +`cashbox-export.service.ts`: + +```typescript +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +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, + private readonly access: TeamAccessService, + ) {} + + async exportForUser( + teamId: number, + userId: number, + from: string, + to: string, + format: 'csv' | 'pdf', + ): Promise<{ buffer: Buffer; contentType: string; filename: string }> { + 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') { + return { + buffer: Buffer.from(buildCsv(rows), 'utf-8'), + contentType: 'text/csv; charset=utf-8', + filename: `kassenbuch_${team.alias}_${from}_${to}.csv`, + }; + } + return { + buffer: await buildPdf(team, rows, from, to), + contentType: 'application/pdf', + filename: `kassenbuch_${team.alias}_${from}_${to}.pdf`, + }; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx jest src/cashbox-export/cashbox-export.service.spec.ts` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add myteamwallet_backend/src/cashbox-export/cashbox-export.service.ts myteamwallet_backend/src/cashbox-export/cashbox-export.service.spec.ts myteamwallet_backend/src/cashbox-export/dto/cashbox-export-query.dto.ts +git commit -m "feat: add CashboxExportService.exportForUser" +``` + +--- + +### Task 6: `CashboxExportController` download endpoint + +**Files:** +- Create: `myteamwallet_backend/src/cashbox-export/cashbox-export.controller.ts` +- Test: `myteamwallet_backend/src/cashbox-export/cashbox-export.http.spec.ts` + +**Interfaces:** +- Consumes: `CashboxExportService.exportForUser` (Task 5), `CashboxExportQueryDto`. +- Produces: `GET /api/v1/cashbox-export/:teamId?from=&to=&format=` returning the raw file with `Content-Type`/`Content-Disposition` headers. Controller class `CashboxExportController` — Task 8 adds two more routes to this same class. + +- [ ] **Step 1: Write the failing test** + +```typescript +import { + INestApplication, + UnauthorizedException, + ValidationPipe, + VersioningType, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import validationOptions from '../utils/validation-options'; +import { CashboxExportController } from './cashbox-export.controller'; +import { CashboxExportService } from './cashbox-export.service'; + +describe('cashbox export HTTP boundary', () => { + let app: INestApplication; + const service = { + exportForUser: jest.fn(), + }; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + controllers: [CashboxExportController], + providers: [{ provide: CashboxExportService, useValue: service }], + }) + .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('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); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest src/cashbox-export/cashbox-export.http.spec.ts` +Expected: FAIL — `Cannot find module './cashbox-export.controller'`. + +- [ ] **Step 3: Write minimal implementation** + +```typescript +import { + Controller, + Get, + Param, + ParseIntPipe, + 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 { CashboxExportService } from './cashbox-export.service'; + +type AuthenticatedRequest = { user: { id: number } }; + +@ApiBearerAuth() +@UseGuards(AuthGuard('jwt')) +@Controller({ path: 'cashbox-export', version: '1' }) +export class CashboxExportController { + constructor(private readonly service: CashboxExportService) {} + + @Get(':teamId') + async exportCashbox( + @Request() request: AuthenticatedRequest, + @Param('teamId', ParseIntPipe) teamId: number, + @Query() query: CashboxExportQueryDto, + @Res({ passthrough: false }) res: Response, + ): Promise { + 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); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx jest src/cashbox-export/cashbox-export.http.spec.ts` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add myteamwallet_backend/src/cashbox-export/cashbox-export.controller.ts myteamwallet_backend/src/cashbox-export/cashbox-export.http.spec.ts +git commit -m "feat: add cashbox export download endpoint" +``` + +--- + +### Task 7: `CashboxExportSubscription` entity + `CashboxExportSubscriptionService` + +**Files:** +- Create: `myteamwallet_backend/src/cashbox-export/entities/cashbox-export-subscription.entity.ts` +- Create: `myteamwallet_backend/src/cashbox-export/dto/upsert-cashbox-export-subscription.dto.ts` +- Create: `myteamwallet_backend/src/cashbox-export/dto/cashbox-export-subscription-response.dto.ts` +- Create: `myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.ts` +- Test: `myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.spec.ts` + +**Interfaces:** +- Consumes: `RecurringTransactionIntervalEnum` from `src/recurring-transactions/recurring-transaction-interval.enum.ts` (reused, same monthly/quarterly/yearly concept); `TeamAccessService.assertAtLeast`. +- Produces: `class CashboxExportSubscriptionService { getSubscription(teamId: number, userId: number): Promise; upsertSubscription(teamId: number, userId: number, dto: UpsertCashboxExportSubscriptionDTO): Promise }`. Consumed by Task 8 (controller) and Task 10 (scheduler reads `CashboxExportSubscription` repository directly, not this service). + +- [ ] **Step 1: Write the failing test** + +```typescript +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() }; + let service: CashboxExportSubscriptionService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new CashboxExportSubscriptionService(repository as any, access 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', + }), + ); + 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(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest src/cashbox-export/cashbox-export-subscription.service.spec.ts` +Expected: FAIL — `Cannot find module './cashbox-export-subscription.service'`. + +- [ ] **Step 3: Write minimal implementation** + +`entities/cashbox-export-subscription.entity.ts`: + +```typescript +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; +} +``` + +`dto/upsert-cashbox-export-subscription.dto.ts`: + +```typescript +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; +} +``` + +`dto/cashbox-export-subscription-response.dto.ts`: + +```typescript +import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum'; + +export class CashboxExportSubscriptionResponseDTO { + recipients: string[]; + interval: RecurringTransactionIntervalEnum; + active: boolean; + nextRunDate: string | null; +} +``` + +`cashbox-export-subscription.service.ts`: + +```typescript +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +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.monthly]: 1, + [RecurringTransactionIntervalEnum.quarterly]: 3, + [RecurringTransactionIntervalEnum.yearly]: 12, +}; + +@Injectable() +export class CashboxExportSubscriptionService { + constructor( + @InjectRepository(CashboxExportSubscription) + private readonly repository: Repository, + private readonly access: TeamAccessService, + ) {} + + async getSubscription( + teamId: number, + userId: number, + ): Promise { + 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 { + 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); + return this.toResponse(saved); + } + + private async assertAccess(userId: number, teamId: number): Promise { + 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, + }; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx jest src/cashbox-export/cashbox-export-subscription.service.spec.ts` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add myteamwallet_backend/src/cashbox-export/entities/cashbox-export-subscription.entity.ts myteamwallet_backend/src/cashbox-export/dto/upsert-cashbox-export-subscription.dto.ts myteamwallet_backend/src/cashbox-export/dto/cashbox-export-subscription-response.dto.ts myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.ts myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.spec.ts +git commit -m "feat: add CashboxExportSubscription entity and service" +``` + +--- + +### Task 8: Subscription endpoints on `CashboxExportController` + +**Files:** +- Modify: `myteamwallet_backend/src/cashbox-export/cashbox-export.controller.ts` +- Modify: `myteamwallet_backend/src/cashbox-export/cashbox-export.http.spec.ts` + +**Interfaces:** +- Consumes: `CashboxExportSubscriptionService` (Task 7). +- Produces: `GET /api/v1/cashbox-export/:teamId/subscription`, `PUT /api/v1/cashbox-export/:teamId/subscription`. + +- [ ] **Step 1: Write the failing test** + +Append to `cashbox-export.http.spec.ts` (add a second service mock and extend `TestingModule` providers): + +```typescript +import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum'; +import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service'; + +// inside the same describe block, alongside `service`: +const subscriptionService = { + getSubscription: jest.fn(), + upsertSubscription: jest.fn(), +}; + +// update the Test.createTestingModule providers array to also include: +// { provide: CashboxExportSubscriptionService, useValue: subscriptionService }, +// and add `jest.clearAllMocks()` already covers subscriptionService too since it's in the same beforeEach. + +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, + }); +}); +``` + +Also update the `TestingModule` setup at the top of the file to register `CashboxExportSubscriptionService` alongside `CashboxExportService`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest src/cashbox-export/cashbox-export.http.spec.ts` +Expected: FAIL — 404s for the new routes (controller doesn't expose them yet). + +- [ ] **Step 3: Write minimal implementation** + +Update `cashbox-export.controller.ts`: + +```typescript +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 { + 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); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx jest src/cashbox-export/cashbox-export.http.spec.ts` +Expected: PASS (7 tests total). + +- [ ] **Step 5: Commit** + +```bash +git add myteamwallet_backend/src/cashbox-export/cashbox-export.controller.ts myteamwallet_backend/src/cashbox-export/cashbox-export.http.spec.ts +git commit -m "feat: add cashbox export subscription endpoints" +``` + +--- + +### Task 9: `MailService.cashboxExport` + email template + +**Files:** +- Modify: `myteamwallet_backend/src/mail/mail.service.ts` +- Modify: `myteamwallet_backend/src/mail/mail.service.spec.ts` +- Create: `myteamwallet_backend/src/mail/mail-templates/cashbox-export.hbs` +- Modify: `myteamwallet_backend/src/mail/mail-templates/mail-templates.spec.ts` + +**Interfaces:** +- Consumes: existing `MailData` interface (`src/mail/interfaces/mail-data.interface.ts`), existing `MailerService`. +- Produces: `MailService.cashboxExport(mailData: MailData<{ teamName: string; from: string; to: string }>, attachment: Buffer, filename: string): Promise`. Consumed by Task 10 (scheduler). + +- [ ] **Step 1: Write the failing tests** + +Append to `mail.service.spec.ts`: + +```typescript +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 }, + ]); +}); +``` + +Append to `mail-templates.spec.ts` (inside the same `describe` block, reusing the already-registered `layout` partial): + +```typescript +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'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest src/mail` +Expected: FAIL — `service.cashboxExport is not a function`; template test fails with `ENOENT` for `cashbox-export.hbs`. + +- [ ] **Step 3: Write minimal implementation** + +`mail-templates/cashbox-export.hbs`: + +```handlebars +{{#> layout}} +

Hallo,

+

im Anhang findest du den automatischen Kassenbuch-Export für {{teamName}} für den Zeitraum {{from}} bis {{to}}.

+

Diese E-Mail wurde automatisch von TeamWallet verschickt und benötigt keine weitere Aktion.

+{{/layout}} +``` + +Add to `mail.service.ts`: + +```typescript +async cashboxExport( + mailData: MailData<{ teamName: string; from: string; to: string }>, + attachment: Buffer, + filename: string, +): Promise { + 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 }], + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx jest src/mail` +Expected: PASS (all mail tests, including the 2 new ones). + +- [ ] **Step 5: Commit** + +```bash +git add myteamwallet_backend/src/mail +git commit -m "feat: add MailService.cashboxExport and email template" +``` + +--- + +### Task 10: `CashboxExportScheduler` + +**Files:** +- Create: `myteamwallet_backend/src/cashbox-export/cashbox-export.scheduler.ts` +- Test: `myteamwallet_backend/src/cashbox-export/cashbox-export.scheduler.spec.ts` + +**Interfaces:** +- Consumes: `buildRows`/`buildPdf` (Task 2, 4), `MailService.cashboxExport` (Task 9), `CashboxExportSubscription` repository, `Team` repository. +- Produces: `class CashboxExportScheduler { runDueSubscriptions(): Promise }` (`@Cron(CronExpression.EVERY_DAY_AT_4AM)`), registered in Task 11's module. + +- [ ] **Step 1: Write the failing test** + +```typescript +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() }; + 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); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest src/cashbox-export/cashbox-export.scheduler.spec.ts` +Expected: FAIL — `Cannot find module './cashbox-export.scheduler'`. + +- [ ] **Step 3: Write minimal implementation** + +```typescript +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.monthly]: 1, + [RecurringTransactionIntervalEnum.quarterly]: 3, + [RecurringTransactionIntervalEnum.yearly]: 12, +}; + +@Injectable() +export class CashboxExportScheduler { + constructor( + @InjectRepository(CashboxExportSubscription) + private readonly subscriptionRepository: Repository, + @InjectRepository(Team) + private readonly teamRepository: Repository, + private readonly mailService: MailService, + private readonly logger: LoggingService, + ) {} + + @Cron(CronExpression.EVERY_DAY_AT_4AM) + async runDueSubscriptions(): Promise { + const today = new Date().toISOString(); + const due = await this.subscriptionRepository.find({ + where: { active: true, nextRunDate: LessThanOrEqual(today) }, + relations: ['team'], + }); + + for (const subscription of due) { + await this.runOne(subscription); + } + } + + private async runOne(subscription: CashboxExportSubscription): Promise { + 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(); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx jest src/cashbox-export/cashbox-export.scheduler.spec.ts` +Expected: PASS (6 tests total). + +- [ ] **Step 5: Commit** + +```bash +git add myteamwallet_backend/src/cashbox-export/cashbox-export.scheduler.ts myteamwallet_backend/src/cashbox-export/cashbox-export.scheduler.spec.ts +git commit -m "feat: add CashboxExportScheduler for recurring PDF mailing" +``` + +--- + +### Task 11: Wire up `CashboxExportModule` + +**Files:** +- Create: `myteamwallet_backend/src/cashbox-export/cashbox-export.module.ts` +- Modify: `myteamwallet_backend/src/app.module.ts` +- Modify: `myteamwallet_backend/src/database/logging/model/logging-event.type.ts` + +**Interfaces:** +- Consumes: all providers/controller from Tasks 5-10; `TeamsModule` (exports `TeamAccessService`); `MailModule` (exports `MailService`). +- Produces: fully wired `CashboxExportModule`, registered in `AppModule`. + +- [ ] **Step 1: Write the failing test** + +This task wires existing, already-tested units together — there is no new unit to red/green here. Instead, verify wiring compiles and boots: + +Run (from `myteamwallet_backend/`): `npx nest build` +Expected: FAIL — `Cannot find module './cashbox-export/cashbox-export.module'` (referenced from `app.module.ts` after Step 3, or simply not yet importable before it exists — confirm by first adding the import in `app.module.ts` and observing the build fail before creating the module file). + +- [ ] **Step 2: Confirm the expected failure** + +Add the import line to `app.module.ts` (see Step 3) before creating `cashbox-export.module.ts`, then run `npx nest build` and confirm it fails with a module-not-found error. + +- [ ] **Step 3: Write the implementation** + +`cashbox-export.module.ts`: + +```typescript +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +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, + ], +}) +export class CashboxExportModule {} +``` + +In `app.module.ts`, add the import: + +```typescript +import { CashboxExportModule } from './cashbox-export/cashbox-export.module'; +``` + +and add `CashboxExportModule` to the `imports` array, after `RecurringTransactionsModule`. + +In `logging-event.type.ts`, add to the `LOGEVENT` union (after `'recurring_transaction_run'`): + +```typescript + | 'cashbox_export_download' + | 'cashbox_export_subscription_update' + | 'cashbox_export_subscription_run'; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx nest build` +Expected: SUCCESS, no TypeScript errors. + +Then run the full backend suite to confirm nothing broke: +Run: `npx jest` +Expected: all suites PASS. + +- [ ] **Step 5: Commit** + +```bash +git add myteamwallet_backend/src/cashbox-export/cashbox-export.module.ts myteamwallet_backend/src/app.module.ts myteamwallet_backend/src/database/logging/model/logging-event.type.ts +git commit -m "feat: register CashboxExportModule" +``` + +--- + +### Task 12: Frontend models + `FileDownloadService` + +**Files:** +- Create: `myteamwallet_frontend_modern/src/app/models/cashbox-export.model.ts` +- Create: `myteamwallet_frontend_modern/src/app/shared/file-download/file-download.service.ts` +- Test: `myteamwallet_frontend_modern/src/app/shared/file-download/file-download.service.spec.ts` + +**Interfaces:** +- Produces: `type CashboxExportFormat = 'csv' | 'pdf'`, `interface CashboxExportSubscription { recipients: string[]; interval: RecurringTransactionInterval; active: boolean; nextRunDate: string | null }`, `interface UpdateCashboxExportSubscription { recipients: string[]; interval: RecurringTransactionInterval; active: boolean }`; `class FileDownloadService { save(blob: Blob, filename: string): void }`. Consumed by Tasks 13-15. + +- [ ] **Step 1: Write the failing test** + +```typescript +import { TestBed } from '@angular/core/testing'; +import { FileDownloadService } from './file-download.service'; + +describe('FileDownloadService', () => { + let service: FileDownloadService; + let clickSpy: ReturnType; + let createObjectURLSpy: ReturnType; + let revokeObjectURLSpy: ReturnType; + + beforeEach(() => { + service = TestBed.inject(FileDownloadService); + clickSpy = vi.fn(); + createObjectURLSpy = vi.fn(() => 'blob:mock-url'); + revokeObjectURLSpy = vi.fn(); + vi.spyOn(URL, 'createObjectURL').mockImplementation(createObjectURLSpy); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(revokeObjectURLSpy); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickSpy); + }); + + 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'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run (from `myteamwallet_frontend_modern/`): `npx ng test --include='**/file-download.service.spec.ts'` +Expected: FAIL — `Cannot find module './file-download.service'`. + +- [ ] **Step 3: Write minimal implementation** + +`models/cashbox-export.model.ts`: + +```typescript +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; +} +``` + +`shared/file-download/file-download.service.ts`: + +```typescript +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); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx ng test --include='**/file-download.service.spec.ts'` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add myteamwallet_frontend_modern/src/app/models/cashbox-export.model.ts myteamwallet_frontend_modern/src/app/shared/file-download +git commit -m "feat: add cashbox export model and FileDownloadService" +``` + +--- + +### Task 13: `CashboxExportApi` + +**Files:** +- Create: `myteamwallet_frontend_modern/src/app/core/team/cashbox-export-api.ts` +- Test: `myteamwallet_frontend_modern/src/app/core/team/cashbox-export-api.spec.ts` + +**Interfaces:** +- Consumes: `CashboxExportSubscription`, `UpdateCashboxExportSubscription`, `CashboxExportFormat` (Task 12). +- Produces: `class CashboxExportApi { exportCashbox(teamId: number, from: string, to: string, format: CashboxExportFormat): Observable; getSubscription(teamId: number): Observable; updateSubscription(teamId: number, dto: UpdateCashboxExportSubscription): Observable }`. Consumed by Tasks 14-15. + +- [ ] **Step 1: Write the failing test** + +```typescript +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' }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run (from `myteamwallet_frontend_modern/`): `npx ng test --include='**/cashbox-export-api.spec.ts'` +Expected: FAIL — `Cannot find module './cashbox-export-api'`. + +- [ ] **Step 3: Write minimal implementation** + +```typescript +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 { + 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 { + return this.http.get(`${this.baseUrl}/${teamId}/subscription`); + } + + updateSubscription( + teamId: number, + dto: UpdateCashboxExportSubscription, + ): Observable { + return this.http.put(`${this.baseUrl}/${teamId}/subscription`, dto); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx ng test --include='**/cashbox-export-api.spec.ts'` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add myteamwallet_frontend_modern/src/app/core/team/cashbox-export-api.ts myteamwallet_frontend_modern/src/app/core/team/cashbox-export-api.spec.ts +git commit -m "feat: add CashboxExportApi" +``` + +--- + +### Task 14: `CashboxExportDialog` (on-demand download) + +**Files:** +- Create: `myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-dialog/cashbox-export-dialog.ts` +- Test: `myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-dialog/cashbox-export-dialog.spec.ts` + +**Interfaces:** +- Consumes: `CashboxExportApi.exportCashbox` (Task 13), `FileDownloadService.save` (Task 12), `MAT_DIALOG_DATA` providing `{ teamId: number }`. +- Produces: standalone `CashboxExportDialog` component, opened from `cashbox.ts` (Task 17). + +- [ ] **Step 1: Write the failing test** + +```typescript +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { of } 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; + let exportCashbox: ReturnType; + let save: ReturnType; + let dialogRef: { close: ReturnType }; + + 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(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run (from `myteamwallet_frontend_modern/`): `npx ng test --include='**/cashbox-export-dialog.spec.ts'` +Expected: FAIL — `Cannot find module './cashbox-export-dialog'`. + +- [ ] **Step 3: Write minimal implementation** + +```typescript +import { Component, inject } 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: ` +

Kassenbuch exportieren

+
+ + + Von + + + + Bis + + + + Format + + CSV + PDF + + + + + + + +
+ `, +}) +export class CashboxExportDialog { + protected readonly dialogRef = inject(MatDialogRef); + 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 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; + const { from, to, format } = this.form.getRawValue(); + this.api.exportCashbox(this.data.teamId, from, to, format).subscribe((blob) => { + this.fileDownload.save(blob, `kassenbuch_${from}_${to}.${format}`); + this.dialogRef.close(); + }); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx ng test --include='**/cashbox-export-dialog.spec.ts'` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-dialog +git commit -m "feat: add CashboxExportDialog" +``` + +--- + +### Task 15: `CashboxExportSubscriptionDialog` (recurring mailing config) + +**Files:** +- Create: `myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-subscription-dialog/cashbox-export-subscription-dialog.ts` +- Test: `myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-subscription-dialog/cashbox-export-subscription-dialog.spec.ts` + +**Interfaces:** +- Consumes: `CashboxExportApi.getSubscription`/`updateSubscription` (Task 13), `MAT_DIALOG_DATA` providing `{ teamId: number }`. +- Produces: standalone `CashboxExportSubscriptionDialog` component, opened from `cashbox.ts` (Task 17). + +- [ ] **Step 1: Write the failing test** + +```typescript +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { of } from 'rxjs'; +import { CashboxExportApi } from '../../../../core/team/cashbox-export-api'; +import { CashboxExportSubscriptionDialog } from './cashbox-export-subscription-dialog'; + +describe('CashboxExportSubscriptionDialog', () => { + let fixture: ComponentFixture; + let getSubscription: ReturnType; + let updateSubscription: ReturnType; + let dialogRef: { close: ReturnType }; + + 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([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run (from `myteamwallet_frontend_modern/`): `npx ng test --include='**/cashbox-export-subscription-dialog.spec.ts'` +Expected: FAIL — `Cannot find module './cashbox-export-subscription-dialog'`. + +- [ ] **Step 3: Write minimal implementation** + +```typescript +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 { 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, + MatInputModule, + MatSelectModule, + MatSlideToggleModule, + ], + templateUrl: './cashbox-export-subscription-dialog.html', +}) +export class CashboxExportSubscriptionDialog { + protected readonly dialogRef = inject(MatDialogRef); + private readonly data = inject<{ teamId: number }>(MAT_DIALOG_DATA); + private readonly formBuilder = inject(FormBuilder); + private readonly api = inject(CashboxExportApi); + + protected readonly recipients = signal([]); + protected readonly form = this.formBuilder.nonNullable.group({ + interval: ['monthly' as RecurringTransactionInterval, Validators.required], + active: [false], + }); + + constructor() { + this.api.getSubscription(this.data.teamId).subscribe((subscription) => { + this.recipients.set(subscription.recipients); + this.form.setValue({ interval: subscription.interval, active: subscription.active }); + }); + } + + 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; + const { interval, active } = this.form.getRawValue(); + this.api + .updateSubscription(this.data.teamId, { recipients: this.recipients(), interval, active }) + .subscribe(() => this.dialogRef.close()); + } +} +``` + +`cashbox-export-subscription-dialog.html`: + +```html +

Automatischen Versand einrichten

+ + + E-Mail-Adresse hinzufügen + + + + @for (recipient of recipients(); track recipient) { + + {{ recipient }} + + + } + +
+ + Intervall + + Monatlich + Quartalsweise + Jährlich + + + Aktiv +
+
+ + + + +``` + +Update the component's `@Component` decorator to reference this template via `templateUrl` (already shown above) and add `MatIconModule` to `imports` (used by `mat-icon` in the template). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx ng test --include='**/cashbox-export-subscription-dialog.spec.ts'` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-subscription-dialog +git commit -m "feat: add CashboxExportSubscriptionDialog" +``` + +--- + +### Task 16: Wire the Export button/menu into the Cashbox page + +**Files:** +- Modify: `myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.ts` +- Modify: `myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.html` +- Modify: `myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.spec.ts` + +**Interfaces:** +- Consumes: `CashboxExportDialog` (Task 14), `CashboxExportSubscriptionDialog` (Task 15), existing `canBook` computed signal (`cashbox.ts`, already gates `transactionCreate`). +- Produces: two new protected methods on `Cashbox`: `openExportDialog(): void`, `openExportSubscriptionDialog(): void`. + +- [ ] **Step 1: Write the failing test** + +Add to `cashbox.spec.ts` (find the existing `describe('Cashbox', ...)` block and the existing `dialog`/`MatDialog` test setup used for the delete-confirmation flow — reuse that same mocked `MatDialog` provider): + +```typescript +it('opens the cashbox export dialog when the treasurer clicks Export', () => { + const { fixture, dialog } = setup(); // use whichever existing setup() helper already provides a treasurer-permission team + mocked MatDialog + fixture.detectChanges(); + + const exportButton = [...(fixture.nativeElement as HTMLElement).querySelectorAll('button')].find((btn) => + btn.textContent?.includes('Export'), + ); + exportButton?.click(); + + expect(dialog.open).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ data: { teamId: expect.any(Number) } }), + ); +}); + +it('hides the Export button from a member without booking rights', () => { + const { fixture } = setup({ role: 2 }); // reuse existing helper for a non-manager role, matching how `canBook()` is already tested elsewhere in this file + fixture.detectChanges(); + + const exportButton = [...(fixture.nativeElement as HTMLElement).querySelectorAll('button')].find((btn) => + btn.textContent?.includes('Export'), + ); + expect(exportButton).toBeUndefined(); +}); +``` + +Adapt the exact `setup(...)` signature/helper names to whatever already exists earlier in `cashbox.spec.ts` (read the file first — it already has a treasurer-permission setup used for the existing booking-form tests, and a mocked `MatDialog` used for the existing reverse/delete-confirmation test). + +- [ ] **Step 2: Run test to verify it fails** + +Run (from `myteamwallet_frontend_modern/`): `npx ng test --include='**/cashbox.spec.ts'` +Expected: FAIL — no button with text "Export" exists yet. + +- [ ] **Step 3: Write minimal implementation** + +In `cashbox.ts`, add imports and two methods: + +```typescript +import { CashboxExportDialog } from './cashbox-export-dialog/cashbox-export-dialog'; +import { CashboxExportSubscriptionDialog } from './cashbox-export-subscription-dialog/cashbox-export-subscription-dialog'; +``` + +Add `MatMenuModule` to the component's `imports` array (alongside the existing Material imports). + +Add these two methods near the existing `confirmDelete`/dialog-opening logic: + +```typescript +protected openExportDialog(): void { + const teamId = this.team()?.id; + if (!teamId) return; + this.dialog.open(CashboxExportDialog, { data: { teamId } }); +} + +protected openExportSubscriptionDialog(): void { + const teamId = this.team()?.id; + if (!teamId) return; + this.dialog.open(CashboxExportSubscriptionDialog, { data: { teamId } }); +} +``` + +In `cashbox.html`, inside `.journal-toolbar` (after the existing search/type fields), add: + +```html +@if (canBook()) { + + + + + +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx ng test --include='**/cashbox.spec.ts'` +Expected: PASS (all existing + 2 new tests). + +- [ ] **Step 5: Run the full frontend and backend suites, then commit** + +Run: `npx ng test` (frontend, from `myteamwallet_frontend_modern/`) and `npx jest` (backend, from `myteamwallet_backend/`) +Expected: all suites PASS. + +```bash +git add myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.ts myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.html myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.spec.ts +git commit -m "feat: wire cashbox export and subscription dialogs into Cashbox page" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** manual CSV/PDF export (Tasks 2-6), recurring subscription config (Tasks 7-8), recurring PDF mailing (Tasks 9-10), module wiring (Task 11), frontend download UX (Tasks 12-14), frontend subscription UX (Task 15), Cashbox page entry point (Task 16) — every section of `docs/superpowers/specs/2026-08-03-cashbox-export-design.md` maps to a task. +- **Type consistency checked:** `CashboxExportRow` (Task 2) is the single shape threaded through `buildCsv`/`buildPdf` (Tasks 3-4), `CashboxExportService` (Task 5), and `CashboxExportScheduler` (Task 10) — no divergent field names. `RecurringTransactionIntervalEnum` is reused as-is (not duplicated) across Tasks 7, 9, 10. `CashboxExportSubscription`/`UpdateCashboxExportSubscription` frontend models (Task 12) match the backend response/DTO shapes (Task 7) field-for-field. +- **Task 16** intentionally references "whichever existing setup/dialog mock helper" in `cashbox.spec.ts` instead of duplicating it, since that file already has its own established test-setup pattern that must be read and matched, not re-invented.