Files
teamwallet/myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts
Bastian Wagner da5998487a fix: address cashbox-export whole-branch review findings
- Reject dates that are not strict YYYY-MM-DD (was accepting full ISO
  datetimes, which silently produced empty exports instead of a 400)
  and reject from > to with a 400 before touching the team/DB.
- Emit the cashbox_export_download and cashbox_export_subscription_update
  audit log events that were declared but never fired, matching the
  audit trail every sibling feature already has.
- Restore full type checking on the pdfkit import via `import = require()`
  instead of an untyped require() with an eslint-disable.
- Tighten a cashbox.spec.ts assertion to check the exact dialog class
  instead of expect.anything(), so it can't pass with the wrong dialog
  wired to the Export button.
- Style and announce the export dialogs' error messages using this
  codebase's established error-message/role=alert pattern.
2026-08-04 09:20:44 +02:00

136 lines
3.6 KiB
TypeScript

import { Team } from 'src/teams/entities/team.entity';
import PDFDocument = require('pdfkit');
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 ?? []) {
if (!transaction.type) continue;
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 || 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 };
});
}
const TYPE_LABELS: Record<string, string> = {
payment: 'Zahlung',
credit: 'Guthaben',
expense: 'Ausgabe',
};
function formatGermanAmount(value: number): string {
const rounded = Math.sign(value) * Math.round((Math.abs(value) + Number.EPSILON) * 100) / 100;
const normalized = rounded === 0 ? 0 : rounded;
return normalized.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');
}
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();
});
}