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 }; }); } 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 = { 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, 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, align: 'right', lineBreak: false, }); } } export function buildPdf( team: Pick, rows: CashboxExportRow[], receivableRows: CashboxReceivableRow[], from: string, to: string, ): Promise { 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(); }); }