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