Files
teamwallet/docs/superpowers/plans/2026-08-03-cashbox-export.md
Bastian Wagner 15d5f1d4e4 docs: add implementation plan for cashbox export feature
Task-by-task TDD plan covering manual CSV/PDF export, the recurring
PDF-mailing subscription, and the frontend wiring, grounded in the
existing recurring-transactions module and mail service as precedent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 20:46:42 +02:00

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: 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/:

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 } 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

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 (for name), from/to strings.

  • 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, 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

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

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: 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<CashboxExportSubscriptionResponseDTO>; upsertSubscription(teamId: number, userId: number, dto: UpsertCashboxExportSubscriptionDTO): Promise<CashboxExportSubscriptionResponseDTO> }. Consumed by Task 8 (controller) and Task 10 (scheduler reads CashboxExportSubscription repository 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), existing MailerService.

  • 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), CashboxExportSubscription repository, Team repository.

  • 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 (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:

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_DATA providing { teamId: number }.

  • Produces: standalone CashboxExportDialog component, opened from cashbox.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_DATA providing { teamId: number }.

  • Produces: standalone CashboxExportSubscriptionDialog component, opened from cashbox.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), 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):

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