Compare commits

...

4 Commits

Author SHA1 Message Date
Bastian Wagner
cd9d7b165f pdf export 2026-08-04 13:11:14 +02:00
Bastian Wagner
6fceee5a07 feat: wire receivables into manual and recurring cashbox PDF export
Both the manual download endpoint and the recurring email subscription
now pass buildReceivableRows() output into buildPdf, so every PDF
report includes the Forderungen section regardless of how it was
generated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 12:26:23 +02:00
Bastian Wagner
02a4d2e59d feat: redesign cashbox PDF report with styled tables and receivables section
The PDF export was an unformatted list of doc.text() lines and only
showed real cash movements (payment type). Rebuilds it as a proper
two-section report: a branded header band, a bordered/zebra-striped
table with colored amounts and bold running balance for cash
movements, and a second "Forderungen" section listing fine/levy/fee
entries created in the period with their own total. Tables paginate
across pages and every page gets a footer with page numbers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 12:20:33 +02:00
Bastian Wagner
7b499b361f feat: add buildReceivableRows for fine/levy/fee entries
Cashbox export previously only saw payment transactions. This adds
the query for fine/levy/fee entries (Forderungen) that the redesigned
PDF report will show in a separate section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 10:22:59 +02:00
6 changed files with 518 additions and 28 deletions

View File

@@ -6,7 +6,7 @@ import { Team } from 'src/teams/entities/team.entity';
import { MailService } from 'src/mail/mail.service';
import { LessThanOrEqual, Repository } from 'typeorm';
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
import { buildPdf, buildRows } from './cashbox-export.utils';
import { buildPdf, buildReceivableRows, buildRows } from './cashbox-export.utils';
import { CashboxExportSubscription } from './entities/cashbox-export-subscription.entity';
const INTERVAL_MONTHS: Record<RecurringTransactionIntervalEnum, number> = {
@@ -57,7 +57,8 @@ export class CashboxExportScheduler {
const { from, to } = this.periodBounds(subscription.nextRunDate, subscription.interval);
const rows = buildRows(team, from, to);
const pdf = await buildPdf(team, rows, from, to);
const receivableRows = buildReceivableRows(team, from, to);
const pdf = await buildPdf(team, rows, receivableRows, from, to);
const filename = `kassenbuch_${team.alias}_${from}_${to}.pdf`;
await this.mailService.cashboxExport(

View File

@@ -5,7 +5,7 @@ import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
import { Team } from 'src/teams/entities/team.entity';
import { TeamAccessService } from 'src/teams/team-access.service';
import { Repository } from 'typeorm';
import { buildCsv, buildPdf, buildRows } from './cashbox-export.utils';
import { buildCsv, buildPdf, buildReceivableRows, buildRows } from './cashbox-export.utils';
@Injectable()
export class CashboxExportService {
@@ -56,7 +56,7 @@ export class CashboxExportService {
return result;
}
const result = {
buffer: await buildPdf(team, rows, from, to),
buffer: await buildPdf(team, rows, buildReceivableRows(team, from, to), from, to),
contentType: 'application/pdf',
filename: `kassenbuch_${team.alias}_${from}_${to}.pdf`,
};

View File

@@ -1,4 +1,4 @@
import { buildCsv, buildPdf, buildRows } from './cashbox-export.utils';
import { buildCsv, buildPdf, buildReceivableRows, buildRows } from './cashbox-export.utils';
describe('buildRows', () => {
const team = (overrides: Partial<{ transactions: any[]; players: any[] }> = {}) => ({
@@ -102,6 +102,87 @@ describe('buildRows', () => {
});
});
describe('buildReceivableRows', () => {
const team = (overrides: Partial<{ players: any[] }> = {}) => ({
id: 5,
name: 'Team A',
alias: 'team-a',
players: [],
...overrides,
});
it('includes fine, levy and fee player transactions, excluding payment and credit', () => {
const rows = buildReceivableRows(
team({
players: [
{
firstName: 'Alex',
lastName: 'Muster',
transactions: [
{ date: '2026-08-03T00:00:00.000Z', amount: 10, note: 'Bar bezahlt', type: { name: 'payment' } },
{ date: '2026-08-04T00:00:00.000Z', amount: 15, note: 'Monatsbeitrag', type: { name: 'fee' } },
{ date: '2026-08-05T00:00:00.000Z', amount: 5, note: 'Zu spät', type: { name: 'fine' } },
{ date: '2026-08-06T00:00:00.000Z', amount: 20, note: 'Umlage Trikots', type: { name: 'levy' } },
],
},
],
}) as any,
'2026-08-01',
'2026-08-31',
);
expect(rows).toEqual([
{ date: '2026-08-04T00:00:00.000Z', type: 'fee', who: 'Alex Muster', note: 'Monatsbeitrag', amount: 15 },
{ date: '2026-08-05T00:00:00.000Z', type: 'fine', who: 'Alex Muster', note: 'Zu spät', amount: 5 },
{ date: '2026-08-06T00:00:00.000Z', type: 'levy', who: 'Alex Muster', note: 'Umlage Trikots', amount: 20 },
]);
});
it('excludes rows outside the [from, to] range and sorts the rest chronologically', () => {
const rows = buildReceivableRows(
team({
players: [
{
firstName: 'Bob',
lastName: 'Smith',
transactions: [
{ date: '2026-07-31T23:59:00.000Z', amount: 5, note: 'zu früh', type: { name: 'fine' } },
{ date: '2026-09-01T00:00:01.000Z', amount: 5, note: 'zu spät', type: { name: 'fine' } },
{ date: '2026-08-20T00:00:00.000Z', amount: 5, note: 'zweitens', type: { name: 'fee' } },
{ date: '2026-08-01T00:00:00.000Z', amount: 5, note: 'erstens', type: { name: 'levy' } },
],
},
],
}) as any,
'2026-08-01',
'2026-08-31',
);
expect(rows.map((row) => row.note)).toEqual(['erstens', 'zweitens']);
});
it('skips transactions with null type and returns an empty array when nothing matches', () => {
const rows = buildReceivableRows(
team({
players: [
{
firstName: 'Carla',
lastName: 'Beispiel',
transactions: [
{ date: '2026-08-05T00:00:00.000Z', amount: 10, note: 'Null type', type: null },
{ date: '2026-08-06T00:00:00.000Z', amount: 10, note: 'Zahlung', type: { name: 'payment' } },
],
},
],
}) as any,
'2026-08-01',
'2026-08-31',
);
expect(rows).toEqual([]);
});
});
describe('buildCsv', () => {
it('renders the header and formatted rows with German decimals', () => {
const csv = buildCsv([
@@ -169,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);
});
});

View File

@@ -62,10 +62,49 @@ export function buildRows(team: Team, from: string, to: string): CashboxExportRo
});
}
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 {
@@ -102,33 +141,329 @@ 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<Team, 'name'>,
rows: CashboxExportRow[],
receivableRows: CashboxReceivableRow[],
from: string,
to: string,
): Promise<Buffer> {
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();
});

View File

@@ -27,6 +27,8 @@ export type LOGEVENT =
| 'penalty_catalog_delete'
| 'team_create'
| 'team_permissions_update'
| 'scheduled_recurring_transaction_check_start'
| 'scheduled_recurring_transaction_check_finished'
| 'recurring_transaction_create'
| 'recurring_transaction_update'
| 'recurring_transaction_delete'

View File

@@ -23,8 +23,18 @@ export class RecurringTransactionsScheduler {
private readonly logger: LoggingService,
) {}
@Cron(CronExpression.EVERY_DAY_AT_3AM)
@Cron(CronExpression.EVERY_DAY_AT_8AM)
async runDueRecurringTransactions(): Promise<void> {
const start = Date.now();
await this.logger.info(
{
event: 'scheduled_recurring_transaction_check_start',
details: `Starting sheduled recurring Transaction check`,
userId: -1,
},
);
const today = new Date().toISOString();
const due = await this.repository.find({
where: { active: true, nextRunDate: LessThanOrEqual(today) },
@@ -34,6 +44,14 @@ export class RecurringTransactionsScheduler {
for (const definition of due) {
await this.runOne(definition);
}
await this.logger.info(
{
event: 'scheduled_recurring_transaction_check_finished',
details: `Finished sheduled recurring Transaction check, durationMS=${Date.now() - start}`,
userId: -1,
},
);
}
private async runOne(definition: RecurringTransaction): Promise<void> {