Compare commits

..

2 Commits

Author SHA1 Message Date
Bastian Wagner
24c509c0d5 address code review: assert page count in multi-page test, document fixes
- Lock in the exact expected page count (4) for the 60+60-row pagination
  test, which previously only checked the buffer was non-trivial. This is
  the scenario most likely to expose a footer/pagination regression.
- Add short comments explaining the footerY height-bound fix and the
  pdfPageCount() regex's coupling to pdfkit's serialization format.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 13:33:48 +02:00
Bastian Wagner
4185efb83a fix: negate expense amounts and stop blank trailing pages in cashbox PDF export
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>
2026-08-04 13:26:45 +02:00
2 changed files with 40 additions and 3 deletions

View File

@@ -1,5 +1,14 @@
import { buildCsv, buildPdf, buildReceivableRows, buildRows } from './cashbox-export.utils';
// Reads the page count directly out of the raw PDF bytes instead of pulling in
// a parser dependency. Coupled to pdfkit's current /Pages dict serialization -
// a pdfkit upgrade that reorders/reflows it could require adjusting this regex.
function pdfPageCount(buffer: Buffer): number {
const match = buffer.toString('latin1').match(/\/Type\s*\/Pages[\s\S]{0,80}?\/Count\s+(\d+)/);
if (!match) throw new Error('Could not find page count in PDF buffer');
return Number(match[1]);
}
describe('buildRows', () => {
const team = (overrides: Partial<{ transactions: any[]; players: any[] }> = {}) => ({
id: 5,
@@ -10,12 +19,15 @@ describe('buildRows', () => {
...overrides,
});
it('includes team-wallet credit and expense rows as "Teamkasse"', () => {
it('includes team-wallet credit rows as-is and negates expense rows so they reduce the budget', () => {
// DB stores TeamWalletTransaction.amount as a positive number even for
// expenses (see team-wallet-transaction.entity.ts setBalance()); buildRows
// must negate expenses itself so they subtract from the running total.
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' } },
{ date: '2026-08-10T00:00:00.000Z', amount: 20, note: 'Bälle', type: { name: 'expense' } },
],
}) as any,
'2026-08-01',
@@ -297,6 +309,19 @@ describe('buildPdf', () => {
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-');
expect(pdfPageCount(buffer)).toBe(1);
});
it('does not append blank trailing pages when content fits on a single page', async () => {
const buffer = await buildPdf(
{ name: 'Team A' } as any,
[cashRow],
[receivableRow],
'2026-08-01',
'2026-08-31',
);
expect(pdfPageCount(buffer)).toBe(1);
});
it('paginates correctly and stays a valid PDF for many rows', async () => {
@@ -322,5 +347,8 @@ describe('buildPdf', () => {
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
expect(buffer.length).toBeGreaterThan(2000);
// Guards against the footer loop reintroducing blank trailing pages: with
// the bug, this dataset produced 12 pages (3x the real content pages).
expect(pdfPageCount(buffer)).toBe(4);
});
});

View File

@@ -26,12 +26,13 @@ export function buildRows(team: Team, from: string, to: string): CashboxExportRo
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: Number(transaction.amount),
amount: transaction.type.name === 'expense' ? -amount : amount,
});
}
@@ -312,12 +313,19 @@ function addFooters(doc: PDFKit.PDFDocument, teamName: string): void {
for (let i = range.start; i < range.start + range.count; i++) {
doc.switchToPage(i);
const footerY = doc.page.height - 25;
// footerY sits inside the reserved bottom margin (below pdfkit's page
// maxY()). Without an explicit `height`, pdfkit's LineWrapper measures
// overflow against the full-page maxY() and calls addPage() here on every
// iteration - silently appending blank trailing pages. Bounding the text
// to its own small box (well over the 8pt single-line height needed)
// keeps the overflow check local and stops that auto-pagination.
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
@@ -325,6 +333,7 @@ function addFooters(doc: PDFKit.PDFDocument, teamName: string): void {
.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,
});