Task-by-task TDD plan covering manual CSV/PDF export, the recurring PDF-mailing subscription, and the frontend wiring, grounded in the existing recurring-transactions module and mail service as precedent. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
89 KiB
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:
Transactionrows withtype.name === 'payment', and allTeamWalletTransactionrows (credit,expense) — seeTransaction.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
CashboxExportSubscriptionrow per team (enforced via@OneToOne+@JoinColumnunique FK). - Every new file must have a colocated
.spec.tsfollowing TDD (Red → Green, no code before a failing test — seesuperpowers: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:
pdfkitimportable asimport PDFDocument from 'pdfkit';in later tasks. -
Step 1: Install the dependency
Run from myteamwallet_backend/:
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
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 }andfunction buildRows(team: Team, from: string, to: string): CashboxExportRow[].from/toareYYYY-MM-DDstrings (date-only);tois inclusive through end-of-day UTC. -
Consumes:
Teamentity (src/teams/entities/team.entity.ts) withplayers(each withtransactions: Transaction[],Transaction.type.nameeager-loaded) andtransactions: TeamWalletTransaction[](TeamWalletTransaction.type.nameeager-loaded) relations already populated. -
Step 1: Write the failing test
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
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
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:
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:
const TYPE_LABELS: Record<string, string> = {
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
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(forname),from/tostrings. -
Produces:
function buildPdf(team: Pick<Team, 'name'>, rows: CashboxExportRow[], from: string, to: string): Promise<Buffer>. -
Step 1: Write the failing test
Append to cashbox-export.utils.spec.ts:
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:
import PDFDocument from 'pdfkit';
export function buildPdf(
team: Pick<Team, 'name'>,
rows: CashboxExportRow[],
from: string,
to: string,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ margin: 40 });
const chunks: Buffer[] = [];
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
doc.fontSize(16).text(`Kassenbuch ${team.name}`);
doc.fontSize(10).text(`Zeitraum: ${from} bis ${to}`);
doc.moveDown();
if (rows.length === 0) {
doc.text('Keine Buchungen im gewählten Zeitraum.');
} else {
for (const row of rows) {
doc.text(
`${row.date.slice(0, 10)} ${TYPE_LABELS[row.type] ?? row.type} ${row.who} ${row.note} ` +
`${formatGermanAmount(row.amount)} € Saldo: ${formatGermanAmount(row.runningTotal)} €`,
);
}
}
doc.end();
});
}
- 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
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,buildPdffrom Tasks 2-4;TeamAccessService.assertAtLeast(src/teams/team-access.service.ts);Teamrepository. -
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 usesbuildRows/buildPdfdirectly, not this service, since it has no HTTP-bound user). -
Step 1: Write the failing test
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:
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:
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<Team>,
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
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 withContent-Type/Content-Dispositionheaders. Controller classCashboxExportController— Task 8 adds two more routes to this same class. -
Step 1: Write the failing test
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
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<void> {
const { buffer, contentType, filename } = await this.service.exportForUser(
teamId,
request.user.id,
query.from,
query.to,
query.format,
);
res.set({
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${filename}"`,
});
res.send(buffer);
}
}
- 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
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:
RecurringTransactionIntervalEnumfromsrc/recurring-transactions/recurring-transaction-interval.enum.ts(reused, same monthly/quarterly/yearly concept);TeamAccessService.assertAtLeast. -
Produces:
class CashboxExportSubscriptionService { getSubscription(teamId: number, userId: number): Promise<CashboxExportSubscriptionResponseDTO>; upsertSubscription(teamId: number, userId: number, dto: UpsertCashboxExportSubscriptionDTO): Promise<CashboxExportSubscriptionResponseDTO> }. Consumed by Task 8 (controller) and Task 10 (scheduler readsCashboxExportSubscriptionrepository directly, not this service). -
Step 1: Write the failing test
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:
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:
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:
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:
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, number> = {
[RecurringTransactionIntervalEnum.monthly]: 1,
[RecurringTransactionIntervalEnum.quarterly]: 3,
[RecurringTransactionIntervalEnum.yearly]: 12,
};
@Injectable()
export class CashboxExportSubscriptionService {
constructor(
@InjectRepository(CashboxExportSubscription)
private readonly repository: Repository<CashboxExportSubscription>,
private readonly access: TeamAccessService,
) {}
async getSubscription(
teamId: number,
userId: number,
): Promise<CashboxExportSubscriptionResponseDTO> {
await this.assertAccess(userId, teamId);
const existing = await this.repository.findOne({ where: { team: { id: teamId } } });
if (!existing) {
return {
recipients: [],
interval: RecurringTransactionIntervalEnum.monthly,
active: false,
nextRunDate: null,
};
}
return this.toResponse(existing);
}
async upsertSubscription(
teamId: number,
userId: number,
dto: UpsertCashboxExportSubscriptionDTO,
): Promise<CashboxExportSubscriptionResponseDTO> {
await this.assertAccess(userId, teamId);
if (dto.active && dto.recipients.length === 0) {
throw new BadRequestException(
'Ein aktivierter automatischer Versand benötigt mindestens eine Empfängeradresse.',
);
}
const existing = await this.repository.findOne({ where: { team: { id: teamId } } });
const needsNewSchedule =
!existing || (dto.active && (!existing.active || existing.interval !== dto.interval));
const entity =
existing ??
this.repository.create({ team: { id: teamId } as any, nextRunDate: null, active: false });
entity.recipients = dto.recipients;
entity.interval = dto.interval;
entity.active = dto.active;
if (needsNewSchedule) {
entity.nextRunDate = this.nextBoundary(dto.interval);
}
const saved = await this.repository.save(entity);
return this.toResponse(saved);
}
private async assertAccess(userId: number, teamId: number): Promise<void> {
await this.access.assertAtLeast(
userId,
teamId,
'transaction_create_min_role',
TeamRolesEnum.scnd_treasurer,
);
}
private nextBoundary(interval: RecurringTransactionIntervalEnum): string {
const now = new Date();
const date = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
date.setUTCMonth(date.getUTCMonth() + INTERVAL_MONTHS[interval]);
return date.toISOString();
}
private toResponse(
entity: CashboxExportSubscription,
): CashboxExportSubscriptionResponseDTO {
return {
recipients: entity.recipients,
interval: entity.interval,
active: entity.active,
nextRunDate: entity.nextRunDate,
};
}
}
- 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
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):
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:
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Put,
Query,
Request,
Res,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth } from '@nestjs/swagger';
import type { Response } from 'express';
import { CashboxExportQueryDto } from './dto/cashbox-export-query.dto';
import { UpsertCashboxExportSubscriptionDTO } from './dto/upsert-cashbox-export-subscription.dto';
import { CashboxExportService } from './cashbox-export.service';
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
type AuthenticatedRequest = { user: { id: number } };
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'))
@Controller({ path: 'cashbox-export', version: '1' })
export class CashboxExportController {
constructor(
private readonly service: CashboxExportService,
private readonly subscriptionService: CashboxExportSubscriptionService,
) {}
@Get(':teamId')
async exportCashbox(
@Request() request: AuthenticatedRequest,
@Param('teamId', ParseIntPipe) teamId: number,
@Query() query: CashboxExportQueryDto,
@Res({ passthrough: false }) res: Response,
): Promise<void> {
const { buffer, contentType, filename } = await this.service.exportForUser(
teamId,
request.user.id,
query.from,
query.to,
query.format,
);
res.set({
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${filename}"`,
});
res.send(buffer);
}
@Get(':teamId/subscription')
getSubscription(
@Request() request: AuthenticatedRequest,
@Param('teamId', ParseIntPipe) teamId: number,
) {
return this.subscriptionService.getSubscription(teamId, request.user.id);
}
@Put(':teamId/subscription')
upsertSubscription(
@Request() request: AuthenticatedRequest,
@Param('teamId', ParseIntPipe) teamId: number,
@Body() dto: UpsertCashboxExportSubscriptionDTO,
) {
return this.subscriptionService.upsertSubscription(teamId, request.user.id, dto);
}
}
- 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
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<T>interface (src/mail/interfaces/mail-data.interface.ts), existingMailerService. -
Produces:
MailService.cashboxExport(mailData: MailData<{ teamName: string; from: string; to: string }>, attachment: Buffer, filename: string): Promise<void>. Consumed by Task 10 (scheduler). -
Step 1: Write the failing tests
Append to mail.service.spec.ts:
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):
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:
{{#> layout}}
<p>Hallo,</p>
<p>im Anhang findest du den automatischen Kassenbuch-Export für <strong>{{teamName}}</strong> für den Zeitraum {{from}} bis {{to}}.</p>
<p>Diese E-Mail wurde automatisch von TeamWallet verschickt und benötigt keine weitere Aktion.</p>
{{/layout}}
Add to mail.service.ts:
async cashboxExport(
mailData: MailData<{ teamName: string; from: string; to: string }>,
attachment: Buffer,
filename: string,
): Promise<void> {
await this.mailerService.sendMail({
to: mailData.to,
subject: `Kassenbuch-Export ${mailData.data.teamName}`,
template: 'cashbox-export',
context: {
title: `Kassenbuch-Export ${mailData.data.teamName}`,
year: new Date().getFullYear(),
teamName: mailData.data.teamName,
from: mailData.data.from,
to: mailData.data.to,
},
attachments: [{ filename, content: attachment }],
});
}
- 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
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),CashboxExportSubscriptionrepository,Teamrepository. -
Produces:
class CashboxExportScheduler { runDueSubscriptions(): Promise<void> }(@Cron(CronExpression.EVERY_DAY_AT_4AM)), registered in Task 11's module. -
Step 1: Write the failing test
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
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { LoggingService } from 'src/database/logging/logging.service';
import { Team } from 'src/teams/entities/team.entity';
import { MailService } from 'src/mail/mail.service';
import { LessThanOrEqual, Repository } from 'typeorm';
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
import { buildPdf, buildRows } from './cashbox-export.utils';
import { CashboxExportSubscription } from './entities/cashbox-export-subscription.entity';
const INTERVAL_MONTHS: Record<RecurringTransactionIntervalEnum, number> = {
[RecurringTransactionIntervalEnum.monthly]: 1,
[RecurringTransactionIntervalEnum.quarterly]: 3,
[RecurringTransactionIntervalEnum.yearly]: 12,
};
@Injectable()
export class CashboxExportScheduler {
constructor(
@InjectRepository(CashboxExportSubscription)
private readonly subscriptionRepository: Repository<CashboxExportSubscription>,
@InjectRepository(Team)
private readonly teamRepository: Repository<Team>,
private readonly mailService: MailService,
private readonly logger: LoggingService,
) {}
@Cron(CronExpression.EVERY_DAY_AT_4AM)
async runDueSubscriptions(): Promise<void> {
const today = new Date().toISOString();
const due = await this.subscriptionRepository.find({
where: { active: true, nextRunDate: LessThanOrEqual(today) },
relations: ['team'],
});
for (const subscription of due) {
await this.runOne(subscription);
}
}
private async runOne(subscription: CashboxExportSubscription): Promise<void> {
const team = await this.teamRepository.findOne({
where: { id: subscription.team.id },
relations: ['players', 'players.transactions', 'transactions'],
});
if (!team) return;
const { from, to } = this.periodBounds(subscription.nextRunDate, subscription.interval);
const rows = buildRows(team, from, to);
const pdf = await buildPdf(team, rows, from, to);
const filename = `kassenbuch_${team.alias}_${from}_${to}.pdf`;
await this.mailService.cashboxExport(
{ to: subscription.recipients.join(', '), data: { teamName: team.name, from, to } },
pdf,
filename,
);
subscription.nextRunDate = this.advance(subscription.nextRunDate, subscription.interval);
await this.subscriptionRepository.save(subscription);
await this.logger.info({
event: 'cashbox_export_subscription_run',
details: `teamId=${team.id} recipients=${subscription.recipients.length} from=${from} to=${to}`,
userId: -1,
});
}
private periodBounds(
nextRunDate: string,
interval: RecurringTransactionIntervalEnum,
): { from: string; to: string } {
const end = new Date(nextRunDate);
end.setUTCDate(end.getUTCDate() - 1);
const start = new Date(nextRunDate);
start.setUTCMonth(start.getUTCMonth() - INTERVAL_MONTHS[interval]);
return { from: start.toISOString().slice(0, 10), to: end.toISOString().slice(0, 10) };
}
private advance(nextRunDate: string, interval: RecurringTransactionIntervalEnum): string {
const date = new Date(nextRunDate);
date.setUTCMonth(date.getUTCMonth() + INTERVAL_MONTHS[interval]);
return date.toISOString();
}
}
- 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
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(exportsTeamAccessService);MailModule(exportsMailService). -
Produces: fully wired
CashboxExportModule, registered inAppModule. -
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:
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:
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'):
| '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
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
import { TestBed } from '@angular/core/testing';
import { FileDownloadService } from './file-download.service';
describe('FileDownloadService', () => {
let service: FileDownloadService;
let clickSpy: ReturnType<typeof vi.fn>;
let createObjectURLSpy: ReturnType<typeof vi.fn>;
let revokeObjectURLSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
service = TestBed.inject(FileDownloadService);
clickSpy = vi.fn();
createObjectURLSpy = vi.fn(() => 'blob:mock-url');
revokeObjectURLSpy = vi.fn();
vi.spyOn(URL, 'createObjectURL').mockImplementation(createObjectURLSpy);
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:
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:
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
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<Blob>; getSubscription(teamId: number): Observable<CashboxExportSubscription>; updateSubscription(teamId: number, dto: UpdateCashboxExportSubscription): Observable<CashboxExportSubscription> }. Consumed by Tasks 14-15. -
Step 1: Write the failing test
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
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import {
CashboxExportFormat,
CashboxExportSubscription,
UpdateCashboxExportSubscription,
} from '../../models/cashbox-export.model';
@Injectable({ providedIn: 'root' })
export class CashboxExportApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}cashbox-export`;
exportCashbox(
teamId: number,
from: string,
to: string,
format: CashboxExportFormat,
): Observable<Blob> {
const params = new HttpParams().set('from', from).set('to', to).set('format', format);
return this.http.get(`${this.baseUrl}/${teamId}`, { params, responseType: 'blob' });
}
getSubscription(teamId: number): Observable<CashboxExportSubscription> {
return this.http.get<CashboxExportSubscription>(`${this.baseUrl}/${teamId}/subscription`);
}
updateSubscription(
teamId: number,
dto: UpdateCashboxExportSubscription,
): Observable<CashboxExportSubscription> {
return this.http.put<CashboxExportSubscription>(`${this.baseUrl}/${teamId}/subscription`, dto);
}
}
- 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
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_DATAproviding{ teamId: number }. -
Produces: standalone
CashboxExportDialogcomponent, opened fromcashbox.ts(Task 17). -
Step 1: Write the failing test
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<CashboxExportDialog>;
let exportCashbox: ReturnType<typeof vi.fn>;
let save: ReturnType<typeof vi.fn>;
let dialogRef: { close: ReturnType<typeof vi.fn> };
beforeEach(async () => {
exportCashbox = vi.fn(() => of(new Blob(['csv content'])));
save = vi.fn();
dialogRef = { close: vi.fn() };
await TestBed.configureTestingModule({
imports: [CashboxExportDialog],
providers: [
{ provide: MAT_DIALOG_DATA, useValue: { teamId: 5 } },
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: CashboxExportApi, useValue: { exportCashbox } },
{ provide: FileDownloadService, useValue: { save } },
],
}).compileComponents();
fixture = TestBed.createComponent(CashboxExportDialog);
fixture.detectChanges();
});
it('keeps the download disabled until from <= to', () => {
fixture.componentInstance['form'].setValue({ from: '2026-08-31', to: '2026-08-01', format: 'csv' });
expect(fixture.componentInstance['form'].invalid).toBe(true);
fixture.componentInstance['download']();
expect(exportCashbox).not.toHaveBeenCalled();
});
it('downloads the file and closes the dialog on success', () => {
fixture.componentInstance['form'].setValue({ from: '2026-08-01', to: '2026-08-31', format: 'csv' });
fixture.componentInstance['download']();
expect(exportCashbox).toHaveBeenCalledWith(5, '2026-08-01', '2026-08-31', 'csv');
expect(save).toHaveBeenCalledWith(expect.any(Blob), 'kassenbuch_2026-08-01_2026-08-31.csv');
expect(dialogRef.close).toHaveBeenCalled();
});
});
- 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
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: `
<h2 mat-dialog-title>Kassenbuch exportieren</h2>
<form [formGroup]="form" (ngSubmit)="download()">
<mat-dialog-content>
<mat-form-field appearance="outline">
<mat-label>Von</mat-label>
<input matInput formControlName="from" type="date" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Bis</mat-label>
<input matInput formControlName="to" type="date" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Format</mat-label>
<mat-select formControlName="format">
<mat-option value="csv">CSV</mat-option>
<mat-option value="pdf">PDF</mat-option>
</mat-select>
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="dialogRef.close()">Abbrechen</button>
<button mat-flat-button type="submit" [disabled]="form.invalid">Herunterladen</button>
</mat-dialog-actions>
</form>
`,
})
export class CashboxExportDialog {
protected readonly dialogRef = inject(MatDialogRef<CashboxExportDialog>);
private readonly data = inject<{ teamId: number }>(MAT_DIALOG_DATA);
private readonly formBuilder = inject(FormBuilder);
private readonly api = inject(CashboxExportApi);
private readonly fileDownload = inject(FileDownloadService);
protected readonly 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
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_DATAproviding{ teamId: number }. -
Produces: standalone
CashboxExportSubscriptionDialogcomponent, opened fromcashbox.ts(Task 17). -
Step 1: Write the failing test
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<CashboxExportSubscriptionDialog>;
let getSubscription: ReturnType<typeof vi.fn>;
let updateSubscription: ReturnType<typeof vi.fn>;
let dialogRef: { close: ReturnType<typeof vi.fn> };
beforeEach(async () => {
getSubscription = vi.fn(() =>
of({ recipients: ['a@example.com'], interval: 'monthly', active: true, nextRunDate: '2026-09-01T00:00:00.000Z' }),
);
updateSubscription = vi.fn(() =>
of({ recipients: ['a@example.com', 'b@example.com'], interval: 'monthly', active: true, nextRunDate: '2026-09-01T00:00:00.000Z' }),
);
dialogRef = { close: vi.fn() };
await TestBed.configureTestingModule({
imports: [CashboxExportSubscriptionDialog],
providers: [
{ provide: MAT_DIALOG_DATA, useValue: { teamId: 5 } },
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: CashboxExportApi, useValue: { getSubscription, updateSubscription } },
],
}).compileComponents();
fixture = TestBed.createComponent(CashboxExportSubscriptionDialog);
fixture.detectChanges();
});
it('loads the existing subscription into the form', () => {
expect(getSubscription).toHaveBeenCalledWith(5);
expect(fixture.componentInstance['recipients']()).toEqual(['a@example.com']);
});
it('rejects adding an invalid email', () => {
fixture.componentInstance['addRecipient']('not-an-email');
expect(fixture.componentInstance['recipients']()).toEqual(['a@example.com']);
});
it('adds a valid email and saves the updated recipient list', () => {
fixture.componentInstance['addRecipient']('b@example.com');
expect(fixture.componentInstance['recipients']()).toEqual(['a@example.com', 'b@example.com']);
fixture.componentInstance['save']();
expect(updateSubscription).toHaveBeenCalledWith(5, {
recipients: ['a@example.com', 'b@example.com'],
interval: 'monthly',
active: true,
});
expect(dialogRef.close).toHaveBeenCalled();
});
it('removes a recipient', () => {
fixture.componentInstance['removeRecipient']('a@example.com');
expect(fixture.componentInstance['recipients']()).toEqual([]);
});
});
- 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
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<CashboxExportSubscriptionDialog>);
private readonly data = inject<{ teamId: number }>(MAT_DIALOG_DATA);
private readonly formBuilder = inject(FormBuilder);
private readonly api = inject(CashboxExportApi);
protected readonly recipients = signal<string[]>([]);
protected readonly 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:
<h2 mat-dialog-title>Automatischen Versand einrichten</h2>
<mat-dialog-content>
<mat-form-field appearance="outline">
<mat-label>E-Mail-Adresse hinzufügen</mat-label>
<input matInput #recipientInput (keydown.enter)="addRecipient(recipientInput.value); recipientInput.value = ''" />
</mat-form-field>
<mat-chip-set>
@for (recipient of recipients(); track recipient) {
<mat-chip (removed)="removeRecipient(recipient)">
{{ recipient }}
<button matChipRemove><mat-icon>cancel</mat-icon></button>
</mat-chip>
}
</mat-chip-set>
<form [formGroup]="form">
<mat-form-field appearance="outline">
<mat-label>Intervall</mat-label>
<mat-select formControlName="interval">
<mat-option value="monthly">Monatlich</mat-option>
<mat-option value="quarterly">Quartalsweise</mat-option>
<mat-option value="yearly">Jährlich</mat-option>
</mat-select>
</mat-form-field>
<mat-slide-toggle formControlName="active">Aktiv</mat-slide-toggle>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="dialogRef.close()">Abbrechen</button>
<button mat-flat-button type="button" (click)="save()">Speichern</button>
</mat-dialog-actions>
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
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), existingcanBookcomputed signal (cashbox.ts, already gatestransactionCreate). -
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):
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:
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:
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:
@if (canBook()) {
<button mat-stroked-button type="button" [matMenuTriggerFor]="exportMenu">
<mat-icon>download</mat-icon>
Export
</button>
<mat-menu #exportMenu="matMenu">
<button mat-menu-item (click)="openExportDialog()">Jetzt exportieren</button>
<button mat-menu-item (click)="openExportSubscriptionDialog()">Automatischen Versand einrichten</button>
</mat-menu>
}
- 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.
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.mdmaps to a task. - Type consistency checked:
CashboxExportRow(Task 2) is the single shape threaded throughbuildCsv/buildPdf(Tasks 3-4),CashboxExportService(Task 5), andCashboxExportScheduler(Task 10) — no divergent field names.RecurringTransactionIntervalEnumis reused as-is (not duplicated) across Tasks 7, 9, 10.CashboxExportSubscription/UpdateCashboxExportSubscriptionfrontend 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.tsinstead of duplicating it, since that file already has its own established test-setup pattern that must be read and matched, not re-invented.