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>
2497 lines
89 KiB
Markdown
2497 lines
89 KiB
Markdown
# Kassenbuch-Export (CSV/PDF + automatischer PDF-Versand) Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Let a treasurer/captain/coach download a CSV/PDF cashbox export for a free date range, and optionally configure a recurring automatic PDF mailing (monthly/quarterly/yearly) to arbitrary email addresses.
|
|
|
|
**Architecture:** New self-contained backend module `cashbox-export/` (NestJS, mirrors `recurring-transactions/`) with pure row-filtering/rendering functions shared between an on-demand HTTP download endpoint and a daily `@Cron` scheduler that emails a subscription's PDF via the existing `mail` module. New frontend API client + two `MatDialog` components wired into the existing Cashbox page toolbar.
|
|
|
|
**Tech Stack:** NestJS 9, TypeORM 0.3, `pdfkit` (new dependency, PDF rendering), `@nestjs-modules/mailer`/nodemailer (existing, attachment support), Angular 21 standalone components, Angular Material, Jest (backend), Vitest (frontend).
|
|
|
|
## Global Constraints
|
|
|
|
- Only real cash-affecting transactions are exported: `Transaction` rows with `type.name === 'payment'`, and **all** `TeamWalletTransaction` rows (`credit`, `expense`) — see `Transaction.setBalance()` / `TeamWalletTransaction.setBalance()` in the existing codebase for why (spec: `docs/superpowers/specs/2026-08-03-cashbox-export-design.md`, section "Fachliche Einordnung").
|
|
- Permission for every new endpoint (download, subscription read/write): `TeamAccessService.assertAtLeast(userId, teamId, 'transaction_create_min_role', TeamRolesEnum.scnd_treasurer, manager?)` — no new permission key, backend or frontend.
|
|
- CSV: semicolon-delimited, German comma decimal separator, RFC4180 quoting for `;`/`"`/newline in notes.
|
|
- PDF: built with `pdfkit`, no headless browser.
|
|
- Recurring PDF mailing period = the **elapsed** full interval ending the day before `nextRunDate` (not "since last send").
|
|
- One `CashboxExportSubscription` row per team (enforced via `@OneToOne` + `@JoinColumn` unique FK).
|
|
- Every new file must have a colocated `.spec.ts` following TDD (Red → Green, no code before a failing test — see `superpowers:test-driven-development`).
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
```
|
|
myteamwallet_backend/src/cashbox-export/
|
|
cashbox-export.utils.ts # pure: buildRows, buildCsv, buildPdf
|
|
cashbox-export.utils.spec.ts
|
|
cashbox-export.service.ts # permission + team load + delegates to utils
|
|
cashbox-export.service.spec.ts
|
|
cashbox-export-subscription.service.ts # get/upsert subscription row
|
|
cashbox-export-subscription.service.spec.ts
|
|
cashbox-export.controller.ts # GET :teamId, GET/PUT :teamId/subscription
|
|
cashbox-export.http.spec.ts
|
|
cashbox-export.scheduler.ts # @Cron, period calc, mail dispatch
|
|
cashbox-export.scheduler.spec.ts
|
|
cashbox-export.module.ts
|
|
entities/cashbox-export-subscription.entity.ts
|
|
dto/cashbox-export-query.dto.ts
|
|
dto/upsert-cashbox-export-subscription.dto.ts
|
|
dto/cashbox-export-subscription-response.dto.ts
|
|
|
|
myteamwallet_backend/src/mail/
|
|
mail.service.ts # + cashboxExport() method (modify)
|
|
mail.service.spec.ts # + test (modify)
|
|
mail-templates/cashbox-export.hbs # new template
|
|
mail-templates/mail-templates.spec.ts # + test (modify)
|
|
|
|
myteamwallet_backend/src/app.module.ts # register module (modify)
|
|
myteamwallet_backend/src/database/logging/model/logging-event.type.ts # new events (modify)
|
|
|
|
myteamwallet_frontend_modern/src/app/
|
|
models/cashbox-export.model.ts
|
|
shared/file-download/file-download.service.ts
|
|
shared/file-download/file-download.service.spec.ts
|
|
core/team/cashbox-export-api.ts
|
|
core/team/cashbox-export-api.spec.ts
|
|
features/team/cashbox/cashbox-export-dialog/cashbox-export-dialog.ts
|
|
features/team/cashbox/cashbox-export-dialog/cashbox-export-dialog.spec.ts
|
|
features/team/cashbox/cashbox-export-subscription-dialog/cashbox-export-subscription-dialog.ts
|
|
features/team/cashbox/cashbox-export-subscription-dialog/cashbox-export-subscription-dialog.spec.ts
|
|
features/team/cashbox/cashbox.ts # + open dialogs (modify)
|
|
features/team/cashbox/cashbox.html # + export button/menu (modify)
|
|
```
|
|
|
|
---
|
|
|
|
### Task 1: Add `pdfkit` dependency
|
|
|
|
**Files:**
|
|
- Modify: `myteamwallet_backend/package.json`
|
|
|
|
**Interfaces:**
|
|
- Produces: `pdfkit` importable as `import PDFDocument from 'pdfkit';` in later tasks.
|
|
|
|
- [ ] **Step 1: Install the dependency**
|
|
|
|
Run from `myteamwallet_backend/`:
|
|
```bash
|
|
npm install pdfkit@0.19.1
|
|
npm install -D @types/pdfkit@0.17.6
|
|
```
|
|
|
|
- [ ] **Step 2: Verify it resolves**
|
|
|
|
Run: `node -e "console.log(require('pdfkit'))"` from `myteamwallet_backend/`
|
|
Expected: prints the PDFDocument class constructor (no error).
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add myteamwallet_backend/package.json myteamwallet_backend/package-lock.json
|
|
git commit -m "chore: add pdfkit for cashbox PDF export"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: `buildRows` — filter and shape real cash movements
|
|
|
|
**Files:**
|
|
- Create: `myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts`
|
|
- Test: `myteamwallet_backend/src/cashbox-export/cashbox-export.utils.spec.ts`
|
|
|
|
**Interfaces:**
|
|
- Produces: `interface CashboxExportRow { date: string; type: string; who: string; note: string; amount: number; runningTotal: number }` and `function buildRows(team: Team, from: string, to: string): CashboxExportRow[]`. `from`/`to` are `YYYY-MM-DD` strings (date-only); `to` is inclusive through end-of-day UTC.
|
|
- Consumes: `Team` entity (`src/teams/entities/team.entity.ts`) with `players` (each with `transactions: Transaction[]`, `Transaction.type.name` eager-loaded) and `transactions: TeamWalletTransaction[]` (`TeamWalletTransaction.type.name` eager-loaded) relations already populated.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```typescript
|
|
import { buildRows } from './cashbox-export.utils';
|
|
|
|
describe('buildRows', () => {
|
|
const team = (overrides: Partial<{ transactions: any[]; players: any[] }> = {}) => ({
|
|
id: 5,
|
|
name: 'Team A',
|
|
alias: 'team-a',
|
|
transactions: [],
|
|
players: [],
|
|
...overrides,
|
|
});
|
|
|
|
it('includes team-wallet credit and expense rows as "Teamkasse"', () => {
|
|
const rows = buildRows(
|
|
team({
|
|
transactions: [
|
|
{ date: '2026-08-05T00:00:00.000Z', amount: 100, note: 'Sponsoring', type: { name: 'credit' } },
|
|
{ date: '2026-08-10T00:00:00.000Z', amount: -20, note: 'Bälle', type: { name: 'expense' } },
|
|
],
|
|
}) as any,
|
|
'2026-08-01',
|
|
'2026-08-31',
|
|
);
|
|
|
|
expect(rows).toEqual([
|
|
{ date: '2026-08-05T00:00:00.000Z', type: 'credit', who: 'Teamkasse', note: 'Sponsoring', amount: 100, runningTotal: 100 },
|
|
{ date: '2026-08-10T00:00:00.000Z', type: 'expense', who: 'Teamkasse', note: 'Bälle', amount: -20, runningTotal: 80 },
|
|
]);
|
|
});
|
|
|
|
it('includes only "payment" player transactions, excluding fee/levy/fine', () => {
|
|
const rows = buildRows(
|
|
team({
|
|
players: [
|
|
{
|
|
firstName: 'Alex',
|
|
lastName: 'Muster',
|
|
transactions: [
|
|
{ date: '2026-08-03T00:00:00.000Z', amount: 10, note: 'Bar bezahlt', type: { name: 'payment' } },
|
|
{ date: '2026-08-04T00:00:00.000Z', amount: 15, note: 'Monatsbeitrag', type: { name: 'fee' } },
|
|
],
|
|
},
|
|
],
|
|
}) as any,
|
|
'2026-08-01',
|
|
'2026-08-31',
|
|
);
|
|
|
|
expect(rows).toEqual([
|
|
{ date: '2026-08-03T00:00:00.000Z', type: 'payment', who: 'Alex Muster', note: 'Bar bezahlt', amount: 10, runningTotal: 10 },
|
|
]);
|
|
});
|
|
|
|
it('excludes rows outside the [from, to] range and sorts the rest chronologically', () => {
|
|
const rows = buildRows(
|
|
team({
|
|
transactions: [
|
|
{ date: '2026-07-31T23:59:00.000Z', amount: 5, note: 'zu früh', type: { name: 'credit' } },
|
|
{ date: '2026-09-01T00:00:01.000Z', amount: 5, note: 'zu spät', type: { name: 'credit' } },
|
|
{ date: '2026-08-20T00:00:00.000Z', amount: 5, note: 'zweitens', type: { name: 'credit' } },
|
|
{ date: '2026-08-01T00:00:00.000Z', amount: 5, note: 'erstens', type: { name: 'credit' } },
|
|
],
|
|
}) as any,
|
|
'2026-08-01',
|
|
'2026-08-31',
|
|
);
|
|
|
|
expect(rows.map((row) => row.note)).toEqual(['erstens', 'zweitens']);
|
|
});
|
|
|
|
it('returns an empty array when nothing falls in range', () => {
|
|
const rows = buildRows(team() as any, '2026-08-01', '2026-08-31');
|
|
expect(rows).toEqual([]);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run (from `myteamwallet_backend/`): `npx jest src/cashbox-export/cashbox-export.utils.spec.ts`
|
|
Expected: FAIL — `Cannot find module './cashbox-export.utils'`.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```typescript
|
|
import { Team } from 'src/teams/entities/team.entity';
|
|
|
|
export interface CashboxExportRow {
|
|
date: string;
|
|
type: string;
|
|
who: string;
|
|
note: string;
|
|
amount: number;
|
|
runningTotal: number;
|
|
}
|
|
|
|
interface RawRow {
|
|
date: string;
|
|
type: string;
|
|
who: string;
|
|
note: string;
|
|
amount: number;
|
|
}
|
|
|
|
export function buildRows(team: Team, from: string, to: string): CashboxExportRow[] {
|
|
const fromTime = new Date(`${from}T00:00:00.000Z`).getTime();
|
|
const toTime = new Date(`${to}T23:59:59.999Z`).getTime();
|
|
|
|
const raw: RawRow[] = [];
|
|
|
|
for (const transaction of team.transactions ?? []) {
|
|
raw.push({
|
|
date: transaction.date,
|
|
type: transaction.type.name,
|
|
who: 'Teamkasse',
|
|
note: transaction.note,
|
|
amount: Number(transaction.amount),
|
|
});
|
|
}
|
|
|
|
for (const player of team.players ?? []) {
|
|
for (const transaction of player.transactions ?? []) {
|
|
if (transaction.type.name !== 'payment') continue;
|
|
raw.push({
|
|
date: transaction.date,
|
|
type: transaction.type.name,
|
|
who: `${player.firstName} ${player.lastName}`,
|
|
note: transaction.note,
|
|
amount: Number(transaction.amount),
|
|
});
|
|
}
|
|
}
|
|
|
|
const filtered = raw
|
|
.filter((row) => {
|
|
const time = new Date(row.date).getTime();
|
|
return time >= fromTime && time <= toTime;
|
|
})
|
|
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
|
|
|
let runningTotal = 0;
|
|
return filtered.map((row) => {
|
|
runningTotal += row.amount;
|
|
return { ...row, runningTotal };
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `npx jest src/cashbox-export/cashbox-export.utils.spec.ts`
|
|
Expected: PASS (4 tests).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts myteamwallet_backend/src/cashbox-export/cashbox-export.utils.spec.ts
|
|
git commit -m "feat: add buildRows for cashbox export row filtering"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: `buildCsv` — semicolon CSV with German decimals
|
|
|
|
**Files:**
|
|
- Modify: `myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts`
|
|
- Modify: `myteamwallet_backend/src/cashbox-export/cashbox-export.utils.spec.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `CashboxExportRow[]` from Task 2.
|
|
- Produces: `function buildCsv(rows: CashboxExportRow[]): string`.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Append to `cashbox-export.utils.spec.ts`:
|
|
|
|
```typescript
|
|
import { buildCsv } from './cashbox-export.utils';
|
|
|
|
describe('buildCsv', () => {
|
|
it('renders the header and formatted rows with German decimals', () => {
|
|
const csv = buildCsv([
|
|
{ date: '2026-08-05T00:00:00.000Z', type: 'payment', who: 'Alex Muster', note: 'Bar bezahlt', amount: 10, runningTotal: 10 },
|
|
{ date: '2026-08-10T00:00:00.000Z', type: 'expense', who: 'Teamkasse', note: 'Bälle', amount: -20.5, runningTotal: -10.5 },
|
|
]);
|
|
|
|
expect(csv).toBe(
|
|
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
|
|
'2026-08-05;Zahlung;Alex Muster;Bar bezahlt;10,00;10,00\r\n' +
|
|
'2026-08-10;Ausgabe;Teamkasse;Bälle;-20,50;-10,50',
|
|
);
|
|
});
|
|
|
|
it('quotes notes containing a semicolon and escapes embedded quotes', () => {
|
|
const csv = buildCsv([
|
|
{ date: '2026-08-05T00:00:00.000Z', type: 'credit', who: 'Teamkasse', note: 'Spende; "danke"', amount: 5, runningTotal: 5 },
|
|
]);
|
|
|
|
expect(csv).toBe(
|
|
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\n' +
|
|
'2026-08-05;Guthaben;Teamkasse;"Spende; ""danke""";5,00;5,00',
|
|
);
|
|
});
|
|
|
|
it('shows a placeholder row when there are no bookings', () => {
|
|
const csv = buildCsv([]);
|
|
expect(csv).toBe(
|
|
'Datum;Typ;Wer;Notiz;Betrag;Periodensaldo\r\nKeine Buchungen im gewählten Zeitraum',
|
|
);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npx jest src/cashbox-export/cashbox-export.utils.spec.ts -t buildCsv`
|
|
Expected: FAIL — `buildCsv is not a function` / not exported.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
Add to `cashbox-export.utils.ts`:
|
|
|
|
```typescript
|
|
const TYPE_LABELS: Record<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**
|
|
|
|
```bash
|
|
git add myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts myteamwallet_backend/src/cashbox-export/cashbox-export.utils.spec.ts
|
|
git commit -m "feat: add buildCsv for cashbox export"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: `buildPdf` — table PDF via pdfkit
|
|
|
|
**Files:**
|
|
- Modify: `myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts`
|
|
- Modify: `myteamwallet_backend/src/cashbox-export/cashbox-export.utils.spec.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `CashboxExportRow[]`, `Team` (for `name`), `from`/`to` strings.
|
|
- Produces: `function buildPdf(team: Pick<Team, 'name'>, rows: CashboxExportRow[], from: string, to: string): Promise<Buffer>`.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Append to `cashbox-export.utils.spec.ts`:
|
|
|
|
```typescript
|
|
import { buildPdf } from './cashbox-export.utils';
|
|
|
|
describe('buildPdf', () => {
|
|
it('produces a non-empty valid PDF buffer', async () => {
|
|
const buffer = await buildPdf(
|
|
{ name: 'Team A' } as any,
|
|
[
|
|
{ date: '2026-08-05T00:00:00.000Z', type: 'payment', who: 'Alex Muster', note: 'Bar bezahlt', amount: 10, runningTotal: 10 },
|
|
],
|
|
'2026-08-01',
|
|
'2026-08-31',
|
|
);
|
|
|
|
expect(Buffer.isBuffer(buffer)).toBe(true);
|
|
expect(buffer.length).toBeGreaterThan(100);
|
|
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
|
});
|
|
|
|
it('still produces a valid PDF when there are no rows', async () => {
|
|
const buffer = await buildPdf({ name: 'Team A' } as any, [], '2026-08-01', '2026-08-31');
|
|
|
|
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npx jest src/cashbox-export/cashbox-export.utils.spec.ts -t buildPdf`
|
|
Expected: FAIL — `buildPdf is not a function`.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
Add to `cashbox-export.utils.ts`:
|
|
|
|
```typescript
|
|
import PDFDocument from 'pdfkit';
|
|
|
|
export function buildPdf(
|
|
team: Pick<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**
|
|
|
|
```bash
|
|
git add myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts myteamwallet_backend/src/cashbox-export/cashbox-export.utils.spec.ts
|
|
git commit -m "feat: add buildPdf for cashbox export"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: `CashboxExportQueryDto` + `CashboxExportService.exportForUser`
|
|
|
|
**Files:**
|
|
- Create: `myteamwallet_backend/src/cashbox-export/dto/cashbox-export-query.dto.ts`
|
|
- Create: `myteamwallet_backend/src/cashbox-export/cashbox-export.service.ts`
|
|
- Test: `myteamwallet_backend/src/cashbox-export/cashbox-export.service.spec.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `buildRows`, `buildCsv`, `buildPdf` from Tasks 2-4; `TeamAccessService.assertAtLeast` (`src/teams/team-access.service.ts`); `Team` repository.
|
|
- Produces: `class CashboxExportService { exportForUser(teamId: number, userId: number, from: string, to: string, format: 'csv' | 'pdf'): Promise<{ buffer: Buffer; contentType: string; filename: string }> }`. Consumed by Task 6 (controller) and reused pattern for Task 9 (scheduler uses `buildRows`/`buildPdf` directly, not this service, since it has no HTTP-bound user).
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```typescript
|
|
import { NotFoundException } from '@nestjs/common';
|
|
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
|
|
import { CashboxExportService } from './cashbox-export.service';
|
|
|
|
describe('CashboxExportService', () => {
|
|
const teamRepository = { findOne: jest.fn() };
|
|
const access = { assertAtLeast: jest.fn() };
|
|
let service: CashboxExportService;
|
|
|
|
const team = {
|
|
id: 5,
|
|
name: 'Team A',
|
|
alias: 'team-a',
|
|
transactions: [
|
|
{ date: '2026-08-05T00:00:00.000Z', amount: 10, note: 'Sponsoring', type: { name: 'credit' } },
|
|
],
|
|
players: [],
|
|
};
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
teamRepository.findOne.mockResolvedValue(team);
|
|
service = new CashboxExportService(teamRepository as any, access as any);
|
|
});
|
|
|
|
it('checks permission before loading data', async () => {
|
|
await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv');
|
|
|
|
expect(access.assertAtLeast).toHaveBeenCalledWith(
|
|
42,
|
|
5,
|
|
'transaction_create_min_role',
|
|
TeamRolesEnum.scnd_treasurer,
|
|
);
|
|
});
|
|
|
|
it('throws NotFoundException for an unknown team', async () => {
|
|
teamRepository.findOne.mockResolvedValue(null);
|
|
|
|
await expect(
|
|
service.exportForUser(999, 42, '2026-08-01', '2026-08-31', 'csv'),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
|
|
it('builds a CSV buffer with the correct content type and filename', async () => {
|
|
const result = await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'csv');
|
|
|
|
expect(result.contentType).toBe('text/csv; charset=utf-8');
|
|
expect(result.filename).toBe('kassenbuch_team-a_2026-08-01_2026-08-31.csv');
|
|
expect(result.buffer.toString('utf-8')).toContain('Sponsoring');
|
|
});
|
|
|
|
it('builds a PDF buffer with the correct content type and filename', async () => {
|
|
const result = await service.exportForUser(5, 42, '2026-08-01', '2026-08-31', 'pdf');
|
|
|
|
expect(result.contentType).toBe('application/pdf');
|
|
expect(result.filename).toBe('kassenbuch_team-a_2026-08-01_2026-08-31.pdf');
|
|
expect(result.buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npx jest src/cashbox-export/cashbox-export.service.spec.ts`
|
|
Expected: FAIL — `Cannot find module './cashbox-export.service'`.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
`dto/cashbox-export-query.dto.ts`:
|
|
|
|
```typescript
|
|
import { IsDateString, IsIn } from 'class-validator';
|
|
|
|
export class CashboxExportQueryDto {
|
|
@IsDateString()
|
|
from: string;
|
|
|
|
@IsDateString()
|
|
to: string;
|
|
|
|
@IsIn(['csv', 'pdf'])
|
|
format: 'csv' | 'pdf';
|
|
}
|
|
```
|
|
|
|
`cashbox-export.service.ts`:
|
|
|
|
```typescript
|
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
|
|
import { Team } from 'src/teams/entities/team.entity';
|
|
import { TeamAccessService } from 'src/teams/team-access.service';
|
|
import { Repository } from 'typeorm';
|
|
import { buildCsv, buildPdf, buildRows } from './cashbox-export.utils';
|
|
|
|
@Injectable()
|
|
export class CashboxExportService {
|
|
constructor(
|
|
@InjectRepository(Team)
|
|
private readonly teamRepository: Repository<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**
|
|
|
|
```bash
|
|
git add myteamwallet_backend/src/cashbox-export/cashbox-export.service.ts myteamwallet_backend/src/cashbox-export/cashbox-export.service.spec.ts myteamwallet_backend/src/cashbox-export/dto/cashbox-export-query.dto.ts
|
|
git commit -m "feat: add CashboxExportService.exportForUser"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: `CashboxExportController` download endpoint
|
|
|
|
**Files:**
|
|
- Create: `myteamwallet_backend/src/cashbox-export/cashbox-export.controller.ts`
|
|
- Test: `myteamwallet_backend/src/cashbox-export/cashbox-export.http.spec.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `CashboxExportService.exportForUser` (Task 5), `CashboxExportQueryDto`.
|
|
- Produces: `GET /api/v1/cashbox-export/:teamId?from=&to=&format=` returning the raw file with `Content-Type`/`Content-Disposition` headers. Controller class `CashboxExportController` — Task 8 adds two more routes to this same class.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```typescript
|
|
import {
|
|
INestApplication,
|
|
UnauthorizedException,
|
|
ValidationPipe,
|
|
VersioningType,
|
|
} from '@nestjs/common';
|
|
import { AuthGuard } from '@nestjs/passport';
|
|
import { Test } from '@nestjs/testing';
|
|
import * as request from 'supertest';
|
|
import validationOptions from '../utils/validation-options';
|
|
import { CashboxExportController } from './cashbox-export.controller';
|
|
import { CashboxExportService } from './cashbox-export.service';
|
|
|
|
describe('cashbox export HTTP boundary', () => {
|
|
let app: INestApplication;
|
|
const service = {
|
|
exportForUser: jest.fn(),
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
const module = await Test.createTestingModule({
|
|
controllers: [CashboxExportController],
|
|
providers: [{ provide: CashboxExportService, useValue: service }],
|
|
})
|
|
.overrideGuard(AuthGuard('jwt'))
|
|
.useValue({
|
|
canActivate(context) {
|
|
const httpRequest = context.switchToHttp().getRequest();
|
|
if (httpRequest.headers.authorization !== 'Bearer user') {
|
|
throw new UnauthorizedException();
|
|
}
|
|
httpRequest.user = { id: 42, role: { id: 2 } };
|
|
return true;
|
|
},
|
|
})
|
|
.compile();
|
|
app = module.createNestApplication();
|
|
app.setGlobalPrefix('api');
|
|
app.enableVersioning({ type: VersioningType.URI });
|
|
app.useGlobalPipes(new ValidationPipe(validationOptions));
|
|
await app.init();
|
|
});
|
|
|
|
afterAll(() => app.close());
|
|
beforeEach(() => jest.clearAllMocks());
|
|
|
|
it('requires authentication', async () => {
|
|
await request(app.getHttpServer())
|
|
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=csv')
|
|
.expect(401);
|
|
});
|
|
|
|
it('rejects an invalid format', async () => {
|
|
await request(app.getHttpServer())
|
|
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=xls')
|
|
.set('Authorization', 'Bearer user')
|
|
.expect(422);
|
|
expect(service.exportForUser).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns a CSV file with correct headers and content', async () => {
|
|
const csvBuffer = Buffer.from('Datum;Typ;Wer;Notiz;Betrag;Periodensaldo', 'utf-8');
|
|
service.exportForUser.mockResolvedValue({
|
|
buffer: csvBuffer,
|
|
contentType: 'text/csv; charset=utf-8',
|
|
filename: 'kassenbuch_team-a_2026-08-01_2026-08-31.csv',
|
|
});
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=csv')
|
|
.set('Authorization', 'Bearer user')
|
|
.expect(200);
|
|
|
|
expect(response.headers['content-type']).toContain('text/csv');
|
|
expect(response.headers['content-disposition']).toContain(
|
|
'kassenbuch_team-a_2026-08-01_2026-08-31.csv',
|
|
);
|
|
expect(response.text).toBe(csvBuffer.toString('utf-8'));
|
|
expect(service.exportForUser).toHaveBeenCalledWith(5, 42, '2026-08-01', '2026-08-31', 'csv');
|
|
});
|
|
|
|
it('returns a PDF file with correct headers and binary content', async () => {
|
|
const pdfBuffer = Buffer.from('%PDF-1.4 fake content');
|
|
service.exportForUser.mockResolvedValue({
|
|
buffer: pdfBuffer,
|
|
contentType: 'application/pdf',
|
|
filename: 'kassenbuch_team-a_2026-08-01_2026-08-31.pdf',
|
|
});
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/cashbox-export/5?from=2026-08-01&to=2026-08-31&format=pdf')
|
|
.set('Authorization', 'Bearer user')
|
|
.expect(200);
|
|
|
|
expect(response.headers['content-type']).toBe('application/pdf');
|
|
expect(Buffer.from(response.body).equals(pdfBuffer)).toBe(true);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npx jest src/cashbox-export/cashbox-export.http.spec.ts`
|
|
Expected: FAIL — `Cannot find module './cashbox-export.controller'`.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```typescript
|
|
import {
|
|
Controller,
|
|
Get,
|
|
Param,
|
|
ParseIntPipe,
|
|
Query,
|
|
Request,
|
|
Res,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
import { AuthGuard } from '@nestjs/passport';
|
|
import { ApiBearerAuth } from '@nestjs/swagger';
|
|
import type { Response } from 'express';
|
|
import { CashboxExportQueryDto } from './dto/cashbox-export-query.dto';
|
|
import { CashboxExportService } from './cashbox-export.service';
|
|
|
|
type AuthenticatedRequest = { user: { id: number } };
|
|
|
|
@ApiBearerAuth()
|
|
@UseGuards(AuthGuard('jwt'))
|
|
@Controller({ path: 'cashbox-export', version: '1' })
|
|
export class CashboxExportController {
|
|
constructor(private readonly service: CashboxExportService) {}
|
|
|
|
@Get(':teamId')
|
|
async exportCashbox(
|
|
@Request() request: AuthenticatedRequest,
|
|
@Param('teamId', ParseIntPipe) teamId: number,
|
|
@Query() query: CashboxExportQueryDto,
|
|
@Res({ passthrough: false }) res: Response,
|
|
): Promise<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**
|
|
|
|
```bash
|
|
git add myteamwallet_backend/src/cashbox-export/cashbox-export.controller.ts myteamwallet_backend/src/cashbox-export/cashbox-export.http.spec.ts
|
|
git commit -m "feat: add cashbox export download endpoint"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: `CashboxExportSubscription` entity + `CashboxExportSubscriptionService`
|
|
|
|
**Files:**
|
|
- Create: `myteamwallet_backend/src/cashbox-export/entities/cashbox-export-subscription.entity.ts`
|
|
- Create: `myteamwallet_backend/src/cashbox-export/dto/upsert-cashbox-export-subscription.dto.ts`
|
|
- Create: `myteamwallet_backend/src/cashbox-export/dto/cashbox-export-subscription-response.dto.ts`
|
|
- Create: `myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.ts`
|
|
- Test: `myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.spec.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `RecurringTransactionIntervalEnum` from `src/recurring-transactions/recurring-transaction-interval.enum.ts` (reused, same monthly/quarterly/yearly concept); `TeamAccessService.assertAtLeast`.
|
|
- Produces: `class CashboxExportSubscriptionService { getSubscription(teamId: number, userId: number): Promise<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**
|
|
|
|
```typescript
|
|
import { BadRequestException } from '@nestjs/common';
|
|
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
|
|
import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum';
|
|
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
|
|
|
|
describe('CashboxExportSubscriptionService', () => {
|
|
const repository = { findOne: jest.fn(), create: jest.fn((v) => v), save: jest.fn(async (v) => v) };
|
|
const access = { assertAtLeast: jest.fn() };
|
|
let service: CashboxExportSubscriptionService;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
service = new CashboxExportSubscriptionService(repository as any, access as any);
|
|
});
|
|
|
|
it('returns a paused default when no subscription exists yet', async () => {
|
|
repository.findOne.mockResolvedValue(null);
|
|
|
|
await expect(service.getSubscription(5, 42)).resolves.toEqual({
|
|
recipients: [],
|
|
interval: RecurringTransactionIntervalEnum.monthly,
|
|
active: false,
|
|
nextRunDate: null,
|
|
});
|
|
expect(access.assertAtLeast).toHaveBeenCalledWith(
|
|
42,
|
|
5,
|
|
'transaction_create_min_role',
|
|
TeamRolesEnum.scnd_treasurer,
|
|
);
|
|
});
|
|
|
|
it('returns the existing subscription', async () => {
|
|
repository.findOne.mockResolvedValue({
|
|
recipients: ['vorstand@example.com'],
|
|
interval: RecurringTransactionIntervalEnum.yearly,
|
|
active: true,
|
|
nextRunDate: '2027-01-01T00:00:00.000Z',
|
|
});
|
|
|
|
await expect(service.getSubscription(5, 42)).resolves.toEqual({
|
|
recipients: ['vorstand@example.com'],
|
|
interval: RecurringTransactionIntervalEnum.yearly,
|
|
active: true,
|
|
nextRunDate: '2027-01-01T00:00:00.000Z',
|
|
});
|
|
});
|
|
|
|
it('rejects activating with an empty recipient list', async () => {
|
|
repository.findOne.mockResolvedValue(null);
|
|
|
|
await expect(
|
|
service.upsertSubscription(5, 42, {
|
|
recipients: [],
|
|
interval: RecurringTransactionIntervalEnum.monthly,
|
|
active: true,
|
|
}),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repository.save).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('creates a new subscription and computes the next period boundary on first activation', async () => {
|
|
repository.findOne.mockResolvedValue(null);
|
|
jest.useFakeTimers().setSystemTime(new Date('2026-08-15T10:00:00.000Z'));
|
|
|
|
const result = await service.upsertSubscription(5, 42, {
|
|
recipients: ['vorstand@example.com'],
|
|
interval: RecurringTransactionIntervalEnum.monthly,
|
|
active: true,
|
|
});
|
|
|
|
expect(result.nextRunDate).toBe('2026-09-01T00:00:00.000Z');
|
|
expect(repository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
recipients: ['vorstand@example.com'],
|
|
interval: RecurringTransactionIntervalEnum.monthly,
|
|
active: true,
|
|
nextRunDate: '2026-09-01T00:00:00.000Z',
|
|
}),
|
|
);
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
it('keeps the existing nextRunDate when editing recipients without changing interval or activation state', async () => {
|
|
repository.findOne.mockResolvedValue({
|
|
recipients: ['old@example.com'],
|
|
interval: RecurringTransactionIntervalEnum.monthly,
|
|
active: true,
|
|
nextRunDate: '2026-09-01T00:00:00.000Z',
|
|
});
|
|
|
|
const result = await service.upsertSubscription(5, 42, {
|
|
recipients: ['new@example.com'],
|
|
interval: RecurringTransactionIntervalEnum.monthly,
|
|
active: true,
|
|
});
|
|
|
|
expect(result.nextRunDate).toBe('2026-09-01T00:00:00.000Z');
|
|
});
|
|
|
|
it('recomputes nextRunDate when the interval changes', async () => {
|
|
repository.findOne.mockResolvedValue({
|
|
recipients: ['a@example.com'],
|
|
interval: RecurringTransactionIntervalEnum.monthly,
|
|
active: true,
|
|
nextRunDate: '2026-09-01T00:00:00.000Z',
|
|
});
|
|
jest.useFakeTimers().setSystemTime(new Date('2026-08-15T10:00:00.000Z'));
|
|
|
|
const result = await service.upsertSubscription(5, 42, {
|
|
recipients: ['a@example.com'],
|
|
interval: RecurringTransactionIntervalEnum.yearly,
|
|
active: true,
|
|
});
|
|
|
|
expect(result.nextRunDate).toBe('2027-08-01T00:00:00.000Z');
|
|
jest.useRealTimers();
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npx jest src/cashbox-export/cashbox-export-subscription.service.spec.ts`
|
|
Expected: FAIL — `Cannot find module './cashbox-export-subscription.service'`.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
`entities/cashbox-export-subscription.entity.ts`:
|
|
|
|
```typescript
|
|
import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
|
|
import { EntityHelper } from 'src/utils/entity-helper';
|
|
import { Team } from 'src/teams/entities/team.entity';
|
|
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
|
|
|
|
@Entity()
|
|
export class CashboxExportSubscription extends EntityHelper {
|
|
@PrimaryGeneratedColumn()
|
|
id: number;
|
|
|
|
@OneToOne(() => Team, { eager: false })
|
|
@JoinColumn()
|
|
team: Team;
|
|
|
|
@Column({ type: 'simple-array', default: '' })
|
|
recipients: string[];
|
|
|
|
@Column()
|
|
interval: RecurringTransactionIntervalEnum;
|
|
|
|
@Column({ default: false })
|
|
active: boolean;
|
|
|
|
@Column({ nullable: true })
|
|
nextRunDate: string | null;
|
|
}
|
|
```
|
|
|
|
`dto/upsert-cashbox-export-subscription.dto.ts`:
|
|
|
|
```typescript
|
|
import { ApiProperty } from '@nestjs/swagger';
|
|
import { IsArray, IsBoolean, IsEmail, IsIn } from 'class-validator';
|
|
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
|
|
|
|
export class UpsertCashboxExportSubscriptionDTO {
|
|
@ApiProperty({ example: ['vorstand@example.com'] })
|
|
@IsArray()
|
|
@IsEmail({}, { each: true })
|
|
recipients: string[];
|
|
|
|
@ApiProperty({ enum: RecurringTransactionIntervalEnum })
|
|
@IsIn(Object.values(RecurringTransactionIntervalEnum))
|
|
interval: RecurringTransactionIntervalEnum;
|
|
|
|
@ApiProperty({ example: true })
|
|
@IsBoolean()
|
|
active: boolean;
|
|
}
|
|
```
|
|
|
|
`dto/cashbox-export-subscription-response.dto.ts`:
|
|
|
|
```typescript
|
|
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
|
|
|
|
export class CashboxExportSubscriptionResponseDTO {
|
|
recipients: string[];
|
|
interval: RecurringTransactionIntervalEnum;
|
|
active: boolean;
|
|
nextRunDate: string | null;
|
|
}
|
|
```
|
|
|
|
`cashbox-export-subscription.service.ts`:
|
|
|
|
```typescript
|
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
|
|
import { TeamAccessService } from 'src/teams/team-access.service';
|
|
import { Repository } from 'typeorm';
|
|
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
|
|
import { CashboxExportSubscriptionResponseDTO } from './dto/cashbox-export-subscription-response.dto';
|
|
import { UpsertCashboxExportSubscriptionDTO } from './dto/upsert-cashbox-export-subscription.dto';
|
|
import { CashboxExportSubscription } from './entities/cashbox-export-subscription.entity';
|
|
|
|
const INTERVAL_MONTHS: Record<RecurringTransactionIntervalEnum, 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**
|
|
|
|
```bash
|
|
git add myteamwallet_backend/src/cashbox-export/entities/cashbox-export-subscription.entity.ts myteamwallet_backend/src/cashbox-export/dto/upsert-cashbox-export-subscription.dto.ts myteamwallet_backend/src/cashbox-export/dto/cashbox-export-subscription-response.dto.ts myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.ts myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.spec.ts
|
|
git commit -m "feat: add CashboxExportSubscription entity and service"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Subscription endpoints on `CashboxExportController`
|
|
|
|
**Files:**
|
|
- Modify: `myteamwallet_backend/src/cashbox-export/cashbox-export.controller.ts`
|
|
- Modify: `myteamwallet_backend/src/cashbox-export/cashbox-export.http.spec.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `CashboxExportSubscriptionService` (Task 7).
|
|
- Produces: `GET /api/v1/cashbox-export/:teamId/subscription`, `PUT /api/v1/cashbox-export/:teamId/subscription`.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Append to `cashbox-export.http.spec.ts` (add a second service mock and extend `TestingModule` providers):
|
|
|
|
```typescript
|
|
import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum';
|
|
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
|
|
|
|
// inside the same describe block, alongside `service`:
|
|
const subscriptionService = {
|
|
getSubscription: jest.fn(),
|
|
upsertSubscription: jest.fn(),
|
|
};
|
|
|
|
// update the Test.createTestingModule providers array to also include:
|
|
// { provide: CashboxExportSubscriptionService, useValue: subscriptionService },
|
|
// and add `jest.clearAllMocks()` already covers subscriptionService too since it's in the same beforeEach.
|
|
|
|
it('reads the current subscription', async () => {
|
|
const subscription = {
|
|
recipients: ['vorstand@example.com'],
|
|
interval: RecurringTransactionIntervalEnum.monthly,
|
|
active: true,
|
|
nextRunDate: '2026-09-01T00:00:00.000Z',
|
|
};
|
|
subscriptionService.getSubscription.mockResolvedValue(subscription);
|
|
|
|
await request(app.getHttpServer())
|
|
.get('/api/v1/cashbox-export/5/subscription')
|
|
.set('Authorization', 'Bearer user')
|
|
.expect(200)
|
|
.expect(subscription);
|
|
expect(subscriptionService.getSubscription).toHaveBeenCalledWith(5, 42);
|
|
});
|
|
|
|
it('rejects an invalid recipient email on upsert', async () => {
|
|
await request(app.getHttpServer())
|
|
.put('/api/v1/cashbox-export/5/subscription')
|
|
.set('Authorization', 'Bearer user')
|
|
.send({ recipients: ['not-an-email'], interval: 'monthly', active: true })
|
|
.expect(422);
|
|
expect(subscriptionService.upsertSubscription).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('accepts a valid subscription upsert', async () => {
|
|
const subscription = {
|
|
recipients: ['vorstand@example.com'],
|
|
interval: RecurringTransactionIntervalEnum.monthly,
|
|
active: true,
|
|
nextRunDate: '2026-09-01T00:00:00.000Z',
|
|
};
|
|
subscriptionService.upsertSubscription.mockResolvedValue(subscription);
|
|
|
|
await request(app.getHttpServer())
|
|
.put('/api/v1/cashbox-export/5/subscription')
|
|
.set('Authorization', 'Bearer user')
|
|
.send({ recipients: ['vorstand@example.com'], interval: 'monthly', active: true })
|
|
.expect(200)
|
|
.expect(subscription);
|
|
expect(subscriptionService.upsertSubscription).toHaveBeenCalledWith(5, 42, {
|
|
recipients: ['vorstand@example.com'],
|
|
interval: 'monthly',
|
|
active: true,
|
|
});
|
|
});
|
|
```
|
|
|
|
Also update the `TestingModule` setup at the top of the file to register `CashboxExportSubscriptionService` alongside `CashboxExportService`.
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npx jest src/cashbox-export/cashbox-export.http.spec.ts`
|
|
Expected: FAIL — 404s for the new routes (controller doesn't expose them yet).
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
Update `cashbox-export.controller.ts`:
|
|
|
|
```typescript
|
|
import {
|
|
Body,
|
|
Controller,
|
|
Get,
|
|
Param,
|
|
ParseIntPipe,
|
|
Put,
|
|
Query,
|
|
Request,
|
|
Res,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
import { AuthGuard } from '@nestjs/passport';
|
|
import { ApiBearerAuth } from '@nestjs/swagger';
|
|
import type { Response } from 'express';
|
|
import { CashboxExportQueryDto } from './dto/cashbox-export-query.dto';
|
|
import { UpsertCashboxExportSubscriptionDTO } from './dto/upsert-cashbox-export-subscription.dto';
|
|
import { CashboxExportService } from './cashbox-export.service';
|
|
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
|
|
|
|
type AuthenticatedRequest = { user: { id: number } };
|
|
|
|
@ApiBearerAuth()
|
|
@UseGuards(AuthGuard('jwt'))
|
|
@Controller({ path: 'cashbox-export', version: '1' })
|
|
export class CashboxExportController {
|
|
constructor(
|
|
private readonly service: CashboxExportService,
|
|
private readonly subscriptionService: CashboxExportSubscriptionService,
|
|
) {}
|
|
|
|
@Get(':teamId')
|
|
async exportCashbox(
|
|
@Request() request: AuthenticatedRequest,
|
|
@Param('teamId', ParseIntPipe) teamId: number,
|
|
@Query() query: CashboxExportQueryDto,
|
|
@Res({ passthrough: false }) res: Response,
|
|
): Promise<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**
|
|
|
|
```bash
|
|
git add myteamwallet_backend/src/cashbox-export/cashbox-export.controller.ts myteamwallet_backend/src/cashbox-export/cashbox-export.http.spec.ts
|
|
git commit -m "feat: add cashbox export subscription endpoints"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: `MailService.cashboxExport` + email template
|
|
|
|
**Files:**
|
|
- Modify: `myteamwallet_backend/src/mail/mail.service.ts`
|
|
- Modify: `myteamwallet_backend/src/mail/mail.service.spec.ts`
|
|
- Create: `myteamwallet_backend/src/mail/mail-templates/cashbox-export.hbs`
|
|
- Modify: `myteamwallet_backend/src/mail/mail-templates/mail-templates.spec.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: existing `MailData<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`:
|
|
|
|
```typescript
|
|
it('sends the cashbox export mail with the PDF attachment', async () => {
|
|
const attachment = Buffer.from('%PDF-1.4 fake');
|
|
|
|
await service.cashboxExport(
|
|
{
|
|
to: 'vorstand@example.com, kassier@example.com',
|
|
data: { teamName: 'Team A', from: '2026-08-01', to: '2026-08-31' },
|
|
},
|
|
attachment,
|
|
'kassenbuch_team-a_2026-08-01_2026-08-31.pdf',
|
|
);
|
|
|
|
expect(sendMail).toHaveBeenCalledTimes(1);
|
|
const call = sendMail.mock.calls[0][0];
|
|
expect(call.to).toBe('vorstand@example.com, kassier@example.com');
|
|
expect(call.template).toBe('cashbox-export');
|
|
expect(call.context.teamName).toBe('Team A');
|
|
expect(call.context.from).toBe('2026-08-01');
|
|
expect(call.context.to).toBe('2026-08-31');
|
|
expect(call.attachments).toEqual([
|
|
{ filename: 'kassenbuch_team-a_2026-08-01_2026-08-31.pdf', content: attachment },
|
|
]);
|
|
});
|
|
```
|
|
|
|
Append to `mail-templates.spec.ts` (inside the same `describe` block, reusing the already-registered `layout` partial):
|
|
|
|
```typescript
|
|
it('renders cashbox-export.hbs with team name and period', () => {
|
|
const source = fs.readFileSync(path.join(templatesDir, 'cashbox-export.hbs'), 'utf-8');
|
|
const html = Handlebars.compile(source, { strict: true })({
|
|
title: 'Kassenbuch-Export Team A',
|
|
year: 2026,
|
|
teamName: 'Team A',
|
|
from: '2026-08-01',
|
|
to: '2026-08-31',
|
|
});
|
|
|
|
expect(html).toContain('TeamWallet');
|
|
expect(html).toContain('Team A');
|
|
expect(html).toContain('2026-08-01');
|
|
expect(html).toContain('2026-08-31');
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npx jest src/mail`
|
|
Expected: FAIL — `service.cashboxExport is not a function`; template test fails with `ENOENT` for `cashbox-export.hbs`.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
`mail-templates/cashbox-export.hbs`:
|
|
|
|
```handlebars
|
|
{{#> layout}}
|
|
<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`:
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
git add myteamwallet_backend/src/mail
|
|
git commit -m "feat: add MailService.cashboxExport and email template"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: `CashboxExportScheduler`
|
|
|
|
**Files:**
|
|
- Create: `myteamwallet_backend/src/cashbox-export/cashbox-export.scheduler.ts`
|
|
- Test: `myteamwallet_backend/src/cashbox-export/cashbox-export.scheduler.spec.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `buildRows`/`buildPdf` (Task 2, 4), `MailService.cashboxExport` (Task 9), `CashboxExportSubscription` repository, `Team` repository.
|
|
- Produces: `class CashboxExportScheduler { runDueSubscriptions(): Promise<void> }` (`@Cron(CronExpression.EVERY_DAY_AT_4AM)`), registered in Task 11's module.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```typescript
|
|
import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum';
|
|
import { CashboxExportScheduler } from './cashbox-export.scheduler';
|
|
|
|
describe('CashboxExportScheduler', () => {
|
|
const subscriptionRepository = { find: jest.fn(), save: jest.fn((v) => v) };
|
|
const teamRepository = { findOne: jest.fn() };
|
|
const mailService = { cashboxExport: jest.fn() };
|
|
const logger = { info: jest.fn() };
|
|
let scheduler: CashboxExportScheduler;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
scheduler = new CashboxExportScheduler(
|
|
subscriptionRepository as any,
|
|
teamRepository as any,
|
|
mailService as any,
|
|
logger as any,
|
|
);
|
|
});
|
|
|
|
it('does nothing when no subscription is due', async () => {
|
|
subscriptionRepository.find.mockResolvedValue([]);
|
|
|
|
await scheduler.runDueSubscriptions();
|
|
|
|
expect(teamRepository.findOne).not.toHaveBeenCalled();
|
|
expect(mailService.cashboxExport).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('emails the elapsed monthly period and advances nextRunDate', async () => {
|
|
subscriptionRepository.find.mockResolvedValue([
|
|
{
|
|
id: 1,
|
|
team: { id: 5 },
|
|
recipients: ['vorstand@example.com'],
|
|
interval: RecurringTransactionIntervalEnum.monthly,
|
|
nextRunDate: '2026-09-01T00:00:00.000Z',
|
|
active: true,
|
|
},
|
|
]);
|
|
teamRepository.findOne.mockResolvedValue({
|
|
id: 5,
|
|
name: 'Team A',
|
|
alias: 'team-a',
|
|
transactions: [
|
|
{ date: '2026-08-15T00:00:00.000Z', amount: 10, note: 'Sponsoring', type: { name: 'credit' } },
|
|
],
|
|
players: [],
|
|
});
|
|
|
|
await scheduler.runDueSubscriptions();
|
|
|
|
expect(teamRepository.findOne).toHaveBeenCalledWith({
|
|
where: { id: 5 },
|
|
relations: ['players', 'players.transactions', 'transactions'],
|
|
});
|
|
expect(mailService.cashboxExport).toHaveBeenCalledTimes(1);
|
|
const [mailData, attachment, filename] = mailService.cashboxExport.mock.calls[0];
|
|
expect(mailData).toEqual({
|
|
to: 'vorstand@example.com',
|
|
data: { teamName: 'Team A', from: '2026-08-01', to: '2026-08-31' },
|
|
});
|
|
expect(Buffer.isBuffer(attachment)).toBe(true);
|
|
expect(filename).toBe('kassenbuch_team-a_2026-08-01_2026-08-31.pdf');
|
|
expect(subscriptionRepository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({ nextRunDate: '2026-10-01T00:00:00.000Z' }),
|
|
);
|
|
expect(logger.info).toHaveBeenCalledWith({
|
|
event: 'cashbox_export_subscription_run',
|
|
details: 'teamId=5 recipients=1 from=2026-08-01 to=2026-08-31',
|
|
userId: -1,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
[RecurringTransactionIntervalEnum.monthly, '2026-09-01T00:00:00.000Z', '2026-08-01', '2026-08-31', '2026-10-01T00:00:00.000Z'],
|
|
[RecurringTransactionIntervalEnum.quarterly, '2026-09-01T00:00:00.000Z', '2026-06-01', '2026-08-31', '2026-12-01T00:00:00.000Z'],
|
|
[RecurringTransactionIntervalEnum.yearly, '2027-01-01T00:00:00.000Z', '2026-01-01', '2026-12-31', '2028-01-01T00:00:00.000Z'],
|
|
])(
|
|
'computes the elapsed period and next run date for %s',
|
|
async (interval, nextRunDate, expectedFrom, expectedTo, expectedNext) => {
|
|
subscriptionRepository.find.mockResolvedValue([
|
|
{ id: 1, team: { id: 5 }, recipients: ['a@example.com'], interval, nextRunDate, active: true },
|
|
]);
|
|
teamRepository.findOne.mockResolvedValue({
|
|
id: 5,
|
|
name: 'Team A',
|
|
alias: 'team-a',
|
|
transactions: [],
|
|
players: [],
|
|
});
|
|
|
|
await scheduler.runDueSubscriptions();
|
|
|
|
const [mailData] = mailService.cashboxExport.mock.calls[0];
|
|
expect(mailData.data.from).toBe(expectedFrom);
|
|
expect(mailData.data.to).toBe(expectedTo);
|
|
expect(subscriptionRepository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({ nextRunDate: expectedNext }),
|
|
);
|
|
},
|
|
);
|
|
|
|
it('processes multiple due subscriptions independently', async () => {
|
|
subscriptionRepository.find.mockResolvedValue([
|
|
{ id: 1, team: { id: 5 }, recipients: ['a@example.com'], interval: RecurringTransactionIntervalEnum.monthly, nextRunDate: '2026-09-01T00:00:00.000Z', active: true },
|
|
{ id: 2, team: { id: 6 }, recipients: ['b@example.com'], interval: RecurringTransactionIntervalEnum.monthly, nextRunDate: '2026-09-01T00:00:00.000Z', active: true },
|
|
]);
|
|
teamRepository.findOne.mockResolvedValue({
|
|
id: 5,
|
|
name: 'Team A',
|
|
alias: 'team-a',
|
|
transactions: [],
|
|
players: [],
|
|
});
|
|
|
|
await scheduler.runDueSubscriptions();
|
|
|
|
expect(mailService.cashboxExport).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npx jest src/cashbox-export/cashbox-export.scheduler.spec.ts`
|
|
Expected: FAIL — `Cannot find module './cashbox-export.scheduler'`.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```typescript
|
|
import { Injectable } from '@nestjs/common';
|
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { LoggingService } from 'src/database/logging/logging.service';
|
|
import { Team } from 'src/teams/entities/team.entity';
|
|
import { MailService } from 'src/mail/mail.service';
|
|
import { LessThanOrEqual, Repository } from 'typeorm';
|
|
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
|
|
import { buildPdf, buildRows } from './cashbox-export.utils';
|
|
import { CashboxExportSubscription } from './entities/cashbox-export-subscription.entity';
|
|
|
|
const INTERVAL_MONTHS: Record<RecurringTransactionIntervalEnum, 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**
|
|
|
|
```bash
|
|
git add myteamwallet_backend/src/cashbox-export/cashbox-export.scheduler.ts myteamwallet_backend/src/cashbox-export/cashbox-export.scheduler.spec.ts
|
|
git commit -m "feat: add CashboxExportScheduler for recurring PDF mailing"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: Wire up `CashboxExportModule`
|
|
|
|
**Files:**
|
|
- Create: `myteamwallet_backend/src/cashbox-export/cashbox-export.module.ts`
|
|
- Modify: `myteamwallet_backend/src/app.module.ts`
|
|
- Modify: `myteamwallet_backend/src/database/logging/model/logging-event.type.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: all providers/controller from Tasks 5-10; `TeamsModule` (exports `TeamAccessService`); `MailModule` (exports `MailService`).
|
|
- Produces: fully wired `CashboxExportModule`, registered in `AppModule`.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
This task wires existing, already-tested units together — there is no new unit to red/green here. Instead, verify wiring compiles and boots:
|
|
|
|
Run (from `myteamwallet_backend/`): `npx nest build`
|
|
Expected: FAIL — `Cannot find module './cashbox-export/cashbox-export.module'` (referenced from `app.module.ts` after Step 3, or simply not yet importable before it exists — confirm by first adding the import in `app.module.ts` and observing the build fail before creating the module file).
|
|
|
|
- [ ] **Step 2: Confirm the expected failure**
|
|
|
|
Add the import line to `app.module.ts` (see Step 3) before creating `cashbox-export.module.ts`, then run `npx nest build` and confirm it fails with a module-not-found error.
|
|
|
|
- [ ] **Step 3: Write the implementation**
|
|
|
|
`cashbox-export.module.ts`:
|
|
|
|
```typescript
|
|
import { Module } from '@nestjs/common';
|
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
import { MailModule } from 'src/mail/mail.module';
|
|
import { Team } from 'src/teams/entities/team.entity';
|
|
import { TeamsModule } from 'src/teams/teams.module';
|
|
import { CashboxExportSubscription } from './entities/cashbox-export-subscription.entity';
|
|
import { CashboxExportController } from './cashbox-export.controller';
|
|
import { CashboxExportService } from './cashbox-export.service';
|
|
import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service';
|
|
import { CashboxExportScheduler } from './cashbox-export.scheduler';
|
|
|
|
@Module({
|
|
controllers: [CashboxExportController],
|
|
providers: [CashboxExportService, CashboxExportSubscriptionService, CashboxExportScheduler],
|
|
imports: [
|
|
TypeOrmModule.forFeature([Team, CashboxExportSubscription]),
|
|
TeamsModule,
|
|
MailModule,
|
|
],
|
|
})
|
|
export class CashboxExportModule {}
|
|
```
|
|
|
|
In `app.module.ts`, add the import:
|
|
|
|
```typescript
|
|
import { CashboxExportModule } from './cashbox-export/cashbox-export.module';
|
|
```
|
|
|
|
and add `CashboxExportModule` to the `imports` array, after `RecurringTransactionsModule`.
|
|
|
|
In `logging-event.type.ts`, add to the `LOGEVENT` union (after `'recurring_transaction_run'`):
|
|
|
|
```typescript
|
|
| 'cashbox_export_download'
|
|
| 'cashbox_export_subscription_update'
|
|
| 'cashbox_export_subscription_run';
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `npx nest build`
|
|
Expected: SUCCESS, no TypeScript errors.
|
|
|
|
Then run the full backend suite to confirm nothing broke:
|
|
Run: `npx jest`
|
|
Expected: all suites PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add myteamwallet_backend/src/cashbox-export/cashbox-export.module.ts myteamwallet_backend/src/app.module.ts myteamwallet_backend/src/database/logging/model/logging-event.type.ts
|
|
git commit -m "feat: register CashboxExportModule"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Frontend models + `FileDownloadService`
|
|
|
|
**Files:**
|
|
- Create: `myteamwallet_frontend_modern/src/app/models/cashbox-export.model.ts`
|
|
- Create: `myteamwallet_frontend_modern/src/app/shared/file-download/file-download.service.ts`
|
|
- Test: `myteamwallet_frontend_modern/src/app/shared/file-download/file-download.service.spec.ts`
|
|
|
|
**Interfaces:**
|
|
- Produces: `type CashboxExportFormat = 'csv' | 'pdf'`, `interface CashboxExportSubscription { recipients: string[]; interval: RecurringTransactionInterval; active: boolean; nextRunDate: string | null }`, `interface UpdateCashboxExportSubscription { recipients: string[]; interval: RecurringTransactionInterval; active: boolean }`; `class FileDownloadService { save(blob: Blob, filename: string): void }`. Consumed by Tasks 13-15.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```typescript
|
|
import { TestBed } from '@angular/core/testing';
|
|
import { FileDownloadService } from './file-download.service';
|
|
|
|
describe('FileDownloadService', () => {
|
|
let service: FileDownloadService;
|
|
let clickSpy: ReturnType<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`:
|
|
|
|
```typescript
|
|
import { RecurringTransactionInterval } from './recurring-transaction.model';
|
|
|
|
export type CashboxExportFormat = 'csv' | 'pdf';
|
|
|
|
export interface CashboxExportSubscription {
|
|
recipients: string[];
|
|
interval: RecurringTransactionInterval;
|
|
active: boolean;
|
|
nextRunDate: string | null;
|
|
}
|
|
|
|
export interface UpdateCashboxExportSubscription {
|
|
recipients: string[];
|
|
interval: RecurringTransactionInterval;
|
|
active: boolean;
|
|
}
|
|
```
|
|
|
|
`shared/file-download/file-download.service.ts`:
|
|
|
|
```typescript
|
|
import { Injectable } from '@angular/core';
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
export class FileDownloadService {
|
|
save(blob: Blob, filename: string): void {
|
|
const url = URL.createObjectURL(blob);
|
|
const anchor = document.createElement('a');
|
|
anchor.href = url;
|
|
anchor.download = filename;
|
|
anchor.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `npx ng test --include='**/file-download.service.spec.ts'`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add myteamwallet_frontend_modern/src/app/models/cashbox-export.model.ts myteamwallet_frontend_modern/src/app/shared/file-download
|
|
git commit -m "feat: add cashbox export model and FileDownloadService"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 13: `CashboxExportApi`
|
|
|
|
**Files:**
|
|
- Create: `myteamwallet_frontend_modern/src/app/core/team/cashbox-export-api.ts`
|
|
- Test: `myteamwallet_frontend_modern/src/app/core/team/cashbox-export-api.spec.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `CashboxExportSubscription`, `UpdateCashboxExportSubscription`, `CashboxExportFormat` (Task 12).
|
|
- Produces: `class CashboxExportApi { exportCashbox(teamId: number, from: string, to: string, format: CashboxExportFormat): Observable<Blob>; getSubscription(teamId: number): Observable<CashboxExportSubscription>; updateSubscription(teamId: number, dto: UpdateCashboxExportSubscription): Observable<CashboxExportSubscription> }`. Consumed by Tasks 14-15.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```typescript
|
|
import { provideHttpClient } from '@angular/common/http';
|
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
|
import { TestBed } from '@angular/core/testing';
|
|
import { environment } from '../../../environments/environment';
|
|
import { CashboxExportApi } from './cashbox-export-api';
|
|
|
|
describe('CashboxExportApi', () => {
|
|
let api: CashboxExportApi;
|
|
let httpMock: HttpTestingController;
|
|
|
|
beforeEach(() => {
|
|
TestBed.configureTestingModule({
|
|
providers: [provideHttpClient(), provideHttpClientTesting()],
|
|
});
|
|
api = TestBed.inject(CashboxExportApi);
|
|
httpMock = TestBed.inject(HttpTestingController);
|
|
});
|
|
|
|
afterEach(() => httpMock.verify());
|
|
|
|
it('downloads the cashbox export as a blob with query params', () => {
|
|
api.exportCashbox(5, '2026-08-01', '2026-08-31', 'csv').subscribe();
|
|
const request = httpMock.expectOne(
|
|
`${environment.apiUrl}cashbox-export/5?from=2026-08-01&to=2026-08-31&format=csv`,
|
|
);
|
|
expect(request.request.method).toBe('GET');
|
|
expect(request.request.responseType).toBe('blob');
|
|
request.flush(new Blob(['csv content']));
|
|
});
|
|
|
|
it('loads the subscription', () => {
|
|
api.getSubscription(5).subscribe();
|
|
const request = httpMock.expectOne(`${environment.apiUrl}cashbox-export/5/subscription`);
|
|
expect(request.request.method).toBe('GET');
|
|
request.flush({ recipients: [], interval: 'monthly', active: false, nextRunDate: null });
|
|
});
|
|
|
|
it('updates the subscription', () => {
|
|
const update = { recipients: ['a@example.com'], interval: 'monthly' as const, active: true };
|
|
api.updateSubscription(5, update).subscribe();
|
|
const request = httpMock.expectOne(`${environment.apiUrl}cashbox-export/5/subscription`);
|
|
expect(request.request.method).toBe('PUT');
|
|
expect(request.request.body).toEqual(update);
|
|
request.flush({ ...update, nextRunDate: '2026-09-01T00:00:00.000Z' });
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run (from `myteamwallet_frontend_modern/`): `npx ng test --include='**/cashbox-export-api.spec.ts'`
|
|
Expected: FAIL — `Cannot find module './cashbox-export-api'`.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```typescript
|
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
|
import { Injectable, inject } from '@angular/core';
|
|
import { Observable } from 'rxjs';
|
|
import { environment } from '../../../environments/environment';
|
|
import {
|
|
CashboxExportFormat,
|
|
CashboxExportSubscription,
|
|
UpdateCashboxExportSubscription,
|
|
} from '../../models/cashbox-export.model';
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
export class CashboxExportApi {
|
|
private readonly http = inject(HttpClient);
|
|
private readonly baseUrl = `${environment.apiUrl}cashbox-export`;
|
|
|
|
exportCashbox(
|
|
teamId: number,
|
|
from: string,
|
|
to: string,
|
|
format: CashboxExportFormat,
|
|
): Observable<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**
|
|
|
|
```bash
|
|
git add myteamwallet_frontend_modern/src/app/core/team/cashbox-export-api.ts myteamwallet_frontend_modern/src/app/core/team/cashbox-export-api.spec.ts
|
|
git commit -m "feat: add CashboxExportApi"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 14: `CashboxExportDialog` (on-demand download)
|
|
|
|
**Files:**
|
|
- Create: `myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-dialog/cashbox-export-dialog.ts`
|
|
- Test: `myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-dialog/cashbox-export-dialog.spec.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `CashboxExportApi.exportCashbox` (Task 13), `FileDownloadService.save` (Task 12), `MAT_DIALOG_DATA` providing `{ teamId: number }`.
|
|
- Produces: standalone `CashboxExportDialog` component, opened from `cashbox.ts` (Task 17).
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```typescript
|
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
|
import { of } from 'rxjs';
|
|
import { CashboxExportApi } from '../../../../core/team/cashbox-export-api';
|
|
import { FileDownloadService } from '../../../../shared/file-download/file-download.service';
|
|
import { CashboxExportDialog } from './cashbox-export-dialog';
|
|
|
|
describe('CashboxExportDialog', () => {
|
|
let fixture: ComponentFixture<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**
|
|
|
|
```typescript
|
|
import { Component, inject } from '@angular/core';
|
|
import { FormBuilder, ReactiveFormsModule, ValidationErrors, ValidatorFn, Validators } from '@angular/forms';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
import { MatInputModule } from '@angular/material/input';
|
|
import { MatSelectModule } from '@angular/material/select';
|
|
import { CashboxExportApi } from '../../../../core/team/cashbox-export-api';
|
|
import { CashboxExportFormat } from '../../../../models/cashbox-export.model';
|
|
import { FileDownloadService } from '../../../../shared/file-download/file-download.service';
|
|
|
|
const rangeValid: ValidatorFn = (group): ValidationErrors | null => {
|
|
const from = group.get('from')?.value;
|
|
const to = group.get('to')?.value;
|
|
return from && to && from > to ? { rangeInvalid: true } : null;
|
|
};
|
|
|
|
@Component({
|
|
selector: 'app-cashbox-export-dialog',
|
|
imports: [
|
|
ReactiveFormsModule,
|
|
MatButtonModule,
|
|
MatDialogModule,
|
|
MatFormFieldModule,
|
|
MatInputModule,
|
|
MatSelectModule,
|
|
],
|
|
template: `
|
|
<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**
|
|
|
|
```bash
|
|
git add myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-dialog
|
|
git commit -m "feat: add CashboxExportDialog"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 15: `CashboxExportSubscriptionDialog` (recurring mailing config)
|
|
|
|
**Files:**
|
|
- Create: `myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-subscription-dialog/cashbox-export-subscription-dialog.ts`
|
|
- Test: `myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-subscription-dialog/cashbox-export-subscription-dialog.spec.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `CashboxExportApi.getSubscription`/`updateSubscription` (Task 13), `MAT_DIALOG_DATA` providing `{ teamId: number }`.
|
|
- Produces: standalone `CashboxExportSubscriptionDialog` component, opened from `cashbox.ts` (Task 17).
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```typescript
|
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
|
import { of } from 'rxjs';
|
|
import { CashboxExportApi } from '../../../../core/team/cashbox-export-api';
|
|
import { CashboxExportSubscriptionDialog } from './cashbox-export-subscription-dialog';
|
|
|
|
describe('CashboxExportSubscriptionDialog', () => {
|
|
let fixture: ComponentFixture<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**
|
|
|
|
```typescript
|
|
import { Component, inject, signal } from '@angular/core';
|
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
|
import { MatChipsModule } from '@angular/material/chips';
|
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
import { MatInputModule } from '@angular/material/input';
|
|
import { MatSelectModule } from '@angular/material/select';
|
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
|
import { CashboxExportApi } from '../../../../core/team/cashbox-export-api';
|
|
import { RecurringTransactionInterval } from '../../../../models/recurring-transaction.model';
|
|
|
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
|
@Component({
|
|
selector: 'app-cashbox-export-subscription-dialog',
|
|
imports: [
|
|
ReactiveFormsModule,
|
|
MatButtonModule,
|
|
MatChipsModule,
|
|
MatDialogModule,
|
|
MatFormFieldModule,
|
|
MatInputModule,
|
|
MatSelectModule,
|
|
MatSlideToggleModule,
|
|
],
|
|
templateUrl: './cashbox-export-subscription-dialog.html',
|
|
})
|
|
export class CashboxExportSubscriptionDialog {
|
|
protected readonly dialogRef = inject(MatDialogRef<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`:
|
|
|
|
```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**
|
|
|
|
```bash
|
|
git add myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox-export-subscription-dialog
|
|
git commit -m "feat: add CashboxExportSubscriptionDialog"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 16: Wire the Export button/menu into the Cashbox page
|
|
|
|
**Files:**
|
|
- Modify: `myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.ts`
|
|
- Modify: `myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.html`
|
|
- Modify: `myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.spec.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `CashboxExportDialog` (Task 14), `CashboxExportSubscriptionDialog` (Task 15), existing `canBook` computed signal (`cashbox.ts`, already gates `transactionCreate`).
|
|
- Produces: two new protected methods on `Cashbox`: `openExportDialog(): void`, `openExportSubscriptionDialog(): void`.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Add to `cashbox.spec.ts` (find the existing `describe('Cashbox', ...)` block and the existing `dialog`/`MatDialog` test setup used for the delete-confirmation flow — reuse that same mocked `MatDialog` provider):
|
|
|
|
```typescript
|
|
it('opens the cashbox export dialog when the treasurer clicks Export', () => {
|
|
const { fixture, dialog } = setup(); // use whichever existing setup() helper already provides a treasurer-permission team + mocked MatDialog
|
|
fixture.detectChanges();
|
|
|
|
const exportButton = [...(fixture.nativeElement as HTMLElement).querySelectorAll('button')].find((btn) =>
|
|
btn.textContent?.includes('Export'),
|
|
);
|
|
exportButton?.click();
|
|
|
|
expect(dialog.open).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ data: { teamId: expect.any(Number) } }),
|
|
);
|
|
});
|
|
|
|
it('hides the Export button from a member without booking rights', () => {
|
|
const { fixture } = setup({ role: 2 }); // reuse existing helper for a non-manager role, matching how `canBook()` is already tested elsewhere in this file
|
|
fixture.detectChanges();
|
|
|
|
const exportButton = [...(fixture.nativeElement as HTMLElement).querySelectorAll('button')].find((btn) =>
|
|
btn.textContent?.includes('Export'),
|
|
);
|
|
expect(exportButton).toBeUndefined();
|
|
});
|
|
```
|
|
|
|
Adapt the exact `setup(...)` signature/helper names to whatever already exists earlier in `cashbox.spec.ts` (read the file first — it already has a treasurer-permission setup used for the existing booking-form tests, and a mocked `MatDialog` used for the existing reverse/delete-confirmation test).
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run (from `myteamwallet_frontend_modern/`): `npx ng test --include='**/cashbox.spec.ts'`
|
|
Expected: FAIL — no button with text "Export" exists yet.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
In `cashbox.ts`, add imports and two methods:
|
|
|
|
```typescript
|
|
import { CashboxExportDialog } from './cashbox-export-dialog/cashbox-export-dialog';
|
|
import { CashboxExportSubscriptionDialog } from './cashbox-export-subscription-dialog/cashbox-export-subscription-dialog';
|
|
```
|
|
|
|
Add `MatMenuModule` to the component's `imports` array (alongside the existing Material imports).
|
|
|
|
Add these two methods near the existing `confirmDelete`/dialog-opening logic:
|
|
|
|
```typescript
|
|
protected openExportDialog(): void {
|
|
const teamId = this.team()?.id;
|
|
if (!teamId) return;
|
|
this.dialog.open(CashboxExportDialog, { data: { teamId } });
|
|
}
|
|
|
|
protected openExportSubscriptionDialog(): void {
|
|
const teamId = this.team()?.id;
|
|
if (!teamId) return;
|
|
this.dialog.open(CashboxExportSubscriptionDialog, { data: { teamId } });
|
|
}
|
|
```
|
|
|
|
In `cashbox.html`, inside `.journal-toolbar` (after the existing search/type fields), add:
|
|
|
|
```html
|
|
@if (canBook()) {
|
|
<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.
|
|
|
|
```bash
|
|
git add myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.ts myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.html myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.spec.ts
|
|
git commit -m "feat: wire cashbox export and subscription dialogs into Cashbox page"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review Notes
|
|
|
|
- **Spec coverage:** manual CSV/PDF export (Tasks 2-6), recurring subscription config (Tasks 7-8), recurring PDF mailing (Tasks 9-10), module wiring (Task 11), frontend download UX (Tasks 12-14), frontend subscription UX (Task 15), Cashbox page entry point (Task 16) — every section of `docs/superpowers/specs/2026-08-03-cashbox-export-design.md` maps to a task.
|
|
- **Type consistency checked:** `CashboxExportRow` (Task 2) is the single shape threaded through `buildCsv`/`buildPdf` (Tasks 3-4), `CashboxExportService` (Task 5), and `CashboxExportScheduler` (Task 10) — no divergent field names. `RecurringTransactionIntervalEnum` is reused as-is (not duplicated) across Tasks 7, 9, 10. `CashboxExportSubscription`/`UpdateCashboxExportSubscription` frontend models (Task 12) match the backend response/DTO shapes (Task 7) field-for-field.
|
|
- **Task 16** intentionally references "whichever existing setup/dialog mock helper" in `cashbox.spec.ts` instead of duplicating it, since that file already has its own established test-setup pattern that must be read and matched, not re-invented.
|