diff --git a/myteamwallet_backend/src/cashbox-export/cashbox-export.utils.spec.ts b/myteamwallet_backend/src/cashbox-export/cashbox-export.utils.spec.ts index 097d559..af2fbe6 100644 --- a/myteamwallet_backend/src/cashbox-export/cashbox-export.utils.spec.ts +++ b/myteamwallet_backend/src/cashbox-export/cashbox-export.utils.spec.ts @@ -250,24 +250,77 @@ describe('buildCsv', () => { }); 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', - ); + const cashRow = { + date: '2026-08-05T00:00:00.000Z', + type: 'payment', + who: 'Alex Muster', + note: 'Bar bezahlt', + amount: 10, + runningTotal: 10, + }; + + const receivableRow = { + date: '2026-08-06T00:00:00.000Z', + type: 'fine', + who: 'Bob Smith', + note: 'Zu spät', + amount: 5, + }; + + it('produces a non-empty valid PDF buffer with cash rows only', async () => { + const buffer = await buildPdf({ name: 'Team A' } as any, [cashRow], [], '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'); + it('produces a valid PDF when there are only receivable rows', async () => { + const buffer = await buildPdf({ name: 'Team A' } as any, [], [receivableRow], '2026-08-01', '2026-08-31'); expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-'); }); + + it('produces a valid PDF with both cash and receivable rows', async () => { + const buffer = await buildPdf( + { name: 'Team A' } as any, + [cashRow], + [receivableRow], + '2026-08-01', + '2026-08-31', + ); + + expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-'); + }); + + it('still produces a valid PDF when both sections are empty', 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-'); + }); + + it('paginates correctly and stays a valid PDF for many rows', async () => { + const manyRows = Array.from({ length: 60 }, (_, i) => ({ + ...cashRow, + date: `2026-08-${String((i % 28) + 1).padStart(2, '0')}T00:00:00.000Z`, + note: `Buchung ${i}`, + runningTotal: 10 * (i + 1), + })); + const manyReceivables = Array.from({ length: 60 }, (_, i) => ({ + ...receivableRow, + date: `2026-08-${String((i % 28) + 1).padStart(2, '0')}T00:00:00.000Z`, + note: `Forderung ${i}`, + })); + + const buffer = await buildPdf( + { name: 'Team A' } as any, + manyRows, + manyReceivables, + '2026-08-01', + '2026-08-31', + ); + + expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-'); + expect(buffer.length).toBeGreaterThan(2000); + }); }); diff --git a/myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts b/myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts index 6382d25..0b8d872 100644 --- a/myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts +++ b/myteamwallet_backend/src/cashbox-export/cashbox-export.utils.ts @@ -141,34 +141,330 @@ export function buildCsv(rows: CashboxExportRow[]): string { 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: 40 }); + 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); - doc.fontSize(16).text(`Kassenbuch ${team.name}`); - doc.fontSize(10).text(`Zeitraum: ${from} bis ${to}`); - doc.moveDown(); + 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.text('Keine Buchungen im gewählten Zeitraum.'); + doc + .fontSize(10) + .font('Helvetica-Oblique') + .fillColor(COLORS.muted) + .text('Keine Buchungen im gewählten Zeitraum.', PAGE_MARGIN, y); + y += 24; } 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)} €`, - ); - } + 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(); }); }