Expenses were stored as positive amounts (DB convention) and buildRows() never negated them, so they were added to the running budget total instead of subtracted. Negate expense amounts for team-wallet transactions, mirroring the existing signedFlowAmount() convention in teams.service.ts. Separately, addFooters() placed footer text inside the reserved bottom margin without an explicit height option, which made pdfkit's LineWrapper treat every footer draw as overflowing the page and call continueOnNewPage() twice per page - inflating page counts 3x with blank trailing pages. Bounding the footer text to its own small height box prevents pdfkit's automatic pagination from firing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
474 lines
13 KiB
TypeScript
474 lines
13 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;
|
||
const amount = Number(transaction.amount);
|
||
raw.push({
|
||
date: transaction.date,
|
||
type: transaction.type.name,
|
||
who: 'Teamkasse',
|
||
note: transaction.note,
|
||
amount: transaction.type.name === 'expense' ? -amount : 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 };
|
||
});
|
||
}
|
||
|
||
export interface CashboxReceivableRow {
|
||
date: string;
|
||
type: string;
|
||
who: string;
|
||
note: string;
|
||
amount: number;
|
||
}
|
||
|
||
const RECEIVABLE_TYPES = new Set(['fine', 'levy', 'fee']);
|
||
|
||
export function buildReceivableRows(team: Team, from: string, to: string): CashboxReceivableRow[] {
|
||
const fromTime = new Date(`${from}T00:00:00.000Z`).getTime();
|
||
const toTime = new Date(`${to}T23:59:59.999Z`).getTime();
|
||
|
||
const raw: CashboxReceivableRow[] = [];
|
||
for (const player of team.players ?? []) {
|
||
for (const transaction of player.transactions ?? []) {
|
||
if (!transaction.type || !RECEIVABLE_TYPES.has(transaction.type.name)) continue;
|
||
raw.push({
|
||
date: transaction.date,
|
||
type: transaction.type.name,
|
||
who: `${player.firstName} ${player.lastName}`,
|
||
note: transaction.note,
|
||
amount: Number(transaction.amount),
|
||
});
|
||
}
|
||
}
|
||
|
||
return 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));
|
||
}
|
||
|
||
const TYPE_LABELS: Record<string, string> = {
|
||
payment: 'Zahlung',
|
||
credit: 'Guthaben',
|
||
expense: 'Ausgabe',
|
||
fine: 'Strafe',
|
||
levy: 'Umlage',
|
||
fee: 'Gebühr',
|
||
};
|
||
|
||
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');
|
||
}
|
||
|
||
const PAGE_MARGIN = 40;
|
||
|
||
const COLORS = {
|
||
headerBand: '#4f8f46',
|
||
headerText: '#ffffff',
|
||
sectionTitle: '#3f7f3c',
|
||
tableHeaderBg: '#e8f2e4',
|
||
tableHeaderText: '#20251f',
|
||
zebra: '#f7f8f2',
|
||
border: '#dde3d8',
|
||
text: '#20251f',
|
||
muted: '#5b6357',
|
||
positive: '#2e7d32',
|
||
negative: '#c1121f',
|
||
receivable: '#9e9e9e',
|
||
footerText: '#8a9186',
|
||
};
|
||
|
||
interface Column {
|
||
label: string;
|
||
width: number;
|
||
align?: 'left' | 'right';
|
||
}
|
||
|
||
const CASH_COLUMNS: Column[] = [
|
||
{ label: 'Datum', width: 60 },
|
||
{ label: 'Typ', width: 60 },
|
||
{ label: 'Wer', width: 110 },
|
||
{ label: 'Notiz', width: 150 },
|
||
{ label: 'Betrag', width: 65, align: 'right' },
|
||
{ label: 'Saldo', width: 65, align: 'right' },
|
||
];
|
||
|
||
const RECEIVABLE_COLUMNS: Column[] = [
|
||
{ label: 'Datum', width: 60 },
|
||
{ label: 'Typ', width: 70 },
|
||
{ label: 'Wer', width: 130 },
|
||
{ label: 'Notiz', width: 190 },
|
||
{ label: 'Betrag', width: 65, align: 'right' },
|
||
];
|
||
|
||
const ROW_HEIGHT = 20;
|
||
const HEADER_ROW_HEIGHT = 22;
|
||
const CELL_PADDING = 5;
|
||
|
||
interface TableRow {
|
||
cells: string[];
|
||
cellColors?: (string | undefined)[];
|
||
boldCells?: boolean[];
|
||
}
|
||
|
||
function tableWidth(columns: Column[]): number {
|
||
return columns.reduce((sum, col) => sum + col.width, 0);
|
||
}
|
||
|
||
function formatAmount(value: number): string {
|
||
return `${formatGermanAmount(value)} €`;
|
||
}
|
||
|
||
// Character-count heuristic instead of doc.widthOfString: keeps row height
|
||
// fixed at one line without coupling truncation to the exact font metrics
|
||
// used at draw time.
|
||
function truncate(text: string, maxLength: number): string {
|
||
if (text.length <= maxLength) return text;
|
||
return `${text.slice(0, maxLength - 1)}…`;
|
||
}
|
||
|
||
function drawTableHeaderRow(doc: PDFKit.PDFDocument, columns: Column[], y: number): void {
|
||
const width = tableWidth(columns);
|
||
doc.rect(PAGE_MARGIN, y, width, HEADER_ROW_HEIGHT).fill(COLORS.tableHeaderBg);
|
||
let colX = PAGE_MARGIN;
|
||
for (const column of columns) {
|
||
doc
|
||
.fillColor(COLORS.tableHeaderText)
|
||
.font('Helvetica-Bold')
|
||
.fontSize(9)
|
||
.text(column.label, colX + CELL_PADDING, y + 6, {
|
||
width: column.width - CELL_PADDING * 2,
|
||
align: column.align ?? 'left',
|
||
lineBreak: false,
|
||
});
|
||
colX += column.width;
|
||
}
|
||
doc.rect(PAGE_MARGIN, y, width, HEADER_ROW_HEIGHT).stroke(COLORS.border);
|
||
}
|
||
|
||
function drawTable(
|
||
doc: PDFKit.PDFDocument,
|
||
columns: Column[],
|
||
rows: TableRow[],
|
||
startY: number,
|
||
pageBottom: number,
|
||
): number {
|
||
const width = tableWidth(columns);
|
||
let y = startY;
|
||
drawTableHeaderRow(doc, columns, y);
|
||
y += HEADER_ROW_HEIGHT;
|
||
|
||
rows.forEach((row, index) => {
|
||
if (y + ROW_HEIGHT > pageBottom) {
|
||
doc.addPage();
|
||
y = PAGE_MARGIN;
|
||
drawTableHeaderRow(doc, columns, y);
|
||
y += HEADER_ROW_HEIGHT;
|
||
}
|
||
if (index % 2 === 1) {
|
||
doc.rect(PAGE_MARGIN, y, width, ROW_HEIGHT).fill(COLORS.zebra);
|
||
}
|
||
let colX = PAGE_MARGIN;
|
||
row.cells.forEach((cellText, colIndex) => {
|
||
const column = columns[colIndex];
|
||
doc
|
||
.fillColor(row.cellColors?.[colIndex] ?? COLORS.text)
|
||
.font(row.boldCells?.[colIndex] ? 'Helvetica-Bold' : 'Helvetica')
|
||
.fontSize(9)
|
||
.text(cellText, colX + CELL_PADDING, y + 5, {
|
||
width: column.width - CELL_PADDING * 2,
|
||
align: column.align ?? 'left',
|
||
lineBreak: false,
|
||
});
|
||
colX += column.width;
|
||
});
|
||
doc.rect(PAGE_MARGIN, y, width, ROW_HEIGHT).stroke(COLORS.border);
|
||
y += ROW_HEIGHT;
|
||
});
|
||
|
||
return y;
|
||
}
|
||
|
||
function drawSummaryRow(
|
||
doc: PDFKit.PDFDocument,
|
||
columns: Column[],
|
||
label: string,
|
||
value: string,
|
||
startY: number,
|
||
pageBottom: number,
|
||
valueColor: string,
|
||
): number {
|
||
let y = startY;
|
||
if (y + ROW_HEIGHT > pageBottom) {
|
||
doc.addPage();
|
||
y = PAGE_MARGIN;
|
||
}
|
||
const width = tableWidth(columns);
|
||
const valueColumnWidth = columns[columns.length - 1].width;
|
||
const labelWidth = width - valueColumnWidth - CELL_PADDING * 2;
|
||
doc.rect(PAGE_MARGIN, y, width, ROW_HEIGHT).fill(COLORS.tableHeaderBg);
|
||
doc
|
||
.fillColor(COLORS.tableHeaderText)
|
||
.font('Helvetica-Bold')
|
||
.fontSize(9)
|
||
.text(label, PAGE_MARGIN + CELL_PADDING, y + 5, { width: labelWidth, lineBreak: false });
|
||
doc
|
||
.fillColor(valueColor)
|
||
.font('Helvetica-Bold')
|
||
.fontSize(9)
|
||
.text(value, PAGE_MARGIN + width - valueColumnWidth + CELL_PADDING, y + 5, {
|
||
width: valueColumnWidth - CELL_PADDING * 2,
|
||
align: 'right',
|
||
lineBreak: false,
|
||
});
|
||
doc.rect(PAGE_MARGIN, y, width, ROW_HEIGHT).stroke(COLORS.border);
|
||
return y + ROW_HEIGHT;
|
||
}
|
||
|
||
function addFooters(doc: PDFKit.PDFDocument, teamName: string): void {
|
||
const range = doc.bufferedPageRange();
|
||
const generatedAt = new Date().toLocaleDateString('de-DE');
|
||
for (let i = range.start; i < range.start + range.count; i++) {
|
||
doc.switchToPage(i);
|
||
const footerY = doc.page.height - 25;
|
||
doc
|
||
.fontSize(8)
|
||
.font('Helvetica')
|
||
.fillColor(COLORS.footerText)
|
||
.text(`${teamName} – Kassenbuch-Report, erstellt am ${generatedAt}`, PAGE_MARGIN, footerY, {
|
||
width: doc.page.width - PAGE_MARGIN * 2 - 60,
|
||
height: 20,
|
||
lineBreak: false,
|
||
});
|
||
doc
|
||
.fontSize(8)
|
||
.fillColor(COLORS.footerText)
|
||
.text(`Seite ${i - range.start + 1} von ${range.count}`, doc.page.width - PAGE_MARGIN - 60, footerY, {
|
||
width: 60,
|
||
height: 20,
|
||
align: 'right',
|
||
lineBreak: false,
|
||
});
|
||
}
|
||
}
|
||
|
||
export function buildPdf(
|
||
team: Pick<Team, 'name'>,
|
||
rows: CashboxExportRow[],
|
||
receivableRows: CashboxReceivableRow[],
|
||
from: string,
|
||
to: string,
|
||
): Promise<Buffer> {
|
||
return new Promise((resolve, reject) => {
|
||
const doc = new PDFDocument({ margin: PAGE_MARGIN, bufferPages: true, size: 'A4' });
|
||
const chunks: Buffer[] = [];
|
||
doc.on('data', (chunk) => chunks.push(chunk));
|
||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||
doc.on('error', reject);
|
||
|
||
const pageWidth = doc.page.width;
|
||
const pageBottom = doc.page.height - PAGE_MARGIN - 30;
|
||
|
||
doc.rect(0, 0, pageWidth, 90).fill(COLORS.headerBand);
|
||
doc
|
||
.fillColor(COLORS.headerText)
|
||
.font('Helvetica-Bold')
|
||
.fontSize(20)
|
||
.text(team.name, PAGE_MARGIN, 28, { width: pageWidth - PAGE_MARGIN * 2, lineBreak: false });
|
||
doc.font('Helvetica').fontSize(11).text('Kassenbuch-Report', PAGE_MARGIN, 55);
|
||
doc.fontSize(10).text(`Zeitraum: ${from} bis ${to}`, PAGE_MARGIN, 70);
|
||
|
||
let y = 110;
|
||
|
||
doc.fontSize(13).font('Helvetica-Bold').fillColor(COLORS.sectionTitle);
|
||
doc.text('Kassenbewegungen', PAGE_MARGIN, y);
|
||
y += 20;
|
||
doc
|
||
.fontSize(9)
|
||
.font('Helvetica')
|
||
.fillColor(COLORS.muted)
|
||
.text('Buchungen, die den tatsächlichen Kassenstand verändern.', PAGE_MARGIN, y);
|
||
y += 18;
|
||
|
||
if (rows.length === 0) {
|
||
doc
|
||
.fontSize(10)
|
||
.font('Helvetica-Oblique')
|
||
.fillColor(COLORS.muted)
|
||
.text('Keine Buchungen im gewählten Zeitraum.', PAGE_MARGIN, y);
|
||
y += 24;
|
||
} else {
|
||
const cashTableRows: TableRow[] = rows.map((row) => ({
|
||
cells: [
|
||
row.date.slice(0, 10),
|
||
TYPE_LABELS[row.type] ?? row.type,
|
||
truncate(row.who, 20),
|
||
truncate(row.note, 26),
|
||
formatAmount(row.amount),
|
||
formatAmount(row.runningTotal),
|
||
],
|
||
cellColors: [
|
||
undefined,
|
||
undefined,
|
||
undefined,
|
||
undefined,
|
||
row.amount < 0 ? COLORS.negative : COLORS.positive,
|
||
undefined,
|
||
],
|
||
boldCells: [false, false, false, false, false, true],
|
||
}));
|
||
y = drawTable(doc, CASH_COLUMNS, cashTableRows, y, pageBottom);
|
||
|
||
const endBalance = rows[rows.length - 1].runningTotal;
|
||
y = drawSummaryRow(
|
||
doc,
|
||
CASH_COLUMNS,
|
||
'Endsaldo Kassenbewegungen',
|
||
formatAmount(endBalance),
|
||
y,
|
||
pageBottom,
|
||
endBalance < 0 ? COLORS.negative : COLORS.positive,
|
||
);
|
||
y += 20;
|
||
}
|
||
|
||
y += 10;
|
||
if (y + 70 > pageBottom) {
|
||
doc.addPage();
|
||
y = PAGE_MARGIN;
|
||
}
|
||
doc.fontSize(13).font('Helvetica-Bold').fillColor(COLORS.sectionTitle);
|
||
doc.text('Forderungen (Strafen, Beiträge, Umlagen)', PAGE_MARGIN, y);
|
||
y += 20;
|
||
doc
|
||
.fontSize(9)
|
||
.font('Helvetica')
|
||
.fillColor(COLORS.muted)
|
||
.text(
|
||
'Im Zeitraum angelegte Forderungen gegen Mitglieder. Diese verändern den tatsächlichen Kassenstand nicht, solange sie nicht bezahlt wurden.',
|
||
PAGE_MARGIN,
|
||
y,
|
||
{ width: tableWidth(RECEIVABLE_COLUMNS) },
|
||
);
|
||
y += 28;
|
||
|
||
if (receivableRows.length === 0) {
|
||
doc
|
||
.fontSize(10)
|
||
.font('Helvetica-Oblique')
|
||
.fillColor(COLORS.muted)
|
||
.text('Keine Forderungen im gewählten Zeitraum.', PAGE_MARGIN, y);
|
||
y += 24;
|
||
} else {
|
||
const receivableTableRows: TableRow[] = receivableRows.map((row) => ({
|
||
cells: [
|
||
row.date.slice(0, 10),
|
||
TYPE_LABELS[row.type] ?? row.type,
|
||
truncate(row.who, 24),
|
||
truncate(row.note, 34),
|
||
formatAmount(row.amount),
|
||
],
|
||
cellColors: [undefined, undefined, undefined, undefined, COLORS.receivable],
|
||
}));
|
||
y = drawTable(doc, RECEIVABLE_COLUMNS, receivableTableRows, y, pageBottom);
|
||
|
||
const total = receivableRows.reduce((sum, row) => sum + row.amount, 0);
|
||
y = drawSummaryRow(
|
||
doc,
|
||
RECEIVABLE_COLUMNS,
|
||
'Summe Forderungen',
|
||
formatAmount(total),
|
||
y,
|
||
pageBottom,
|
||
COLORS.receivable,
|
||
);
|
||
}
|
||
|
||
addFooters(doc, team.name);
|
||
|
||
doc.end();
|
||
});
|
||
}
|