Compare commits
4 Commits
0bad154971
...
cd9d7b165f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd9d7b165f | ||
|
|
6fceee5a07 | ||
|
|
02a4d2e59d | ||
|
|
7b499b361f |
@@ -6,7 +6,7 @@ import { Team } from 'src/teams/entities/team.entity';
|
|||||||
import { MailService } from 'src/mail/mail.service';
|
import { MailService } from 'src/mail/mail.service';
|
||||||
import { LessThanOrEqual, Repository } from 'typeorm';
|
import { LessThanOrEqual, Repository } from 'typeorm';
|
||||||
import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum';
|
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';
|
import { CashboxExportSubscription } from './entities/cashbox-export-subscription.entity';
|
||||||
|
|
||||||
const INTERVAL_MONTHS: Record<RecurringTransactionIntervalEnum, number> = {
|
const INTERVAL_MONTHS: Record<RecurringTransactionIntervalEnum, number> = {
|
||||||
@@ -57,7 +57,8 @@ export class CashboxExportScheduler {
|
|||||||
|
|
||||||
const { from, to } = this.periodBounds(subscription.nextRunDate, subscription.interval);
|
const { from, to } = this.periodBounds(subscription.nextRunDate, subscription.interval);
|
||||||
const rows = buildRows(team, from, to);
|
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`;
|
const filename = `kassenbuch_${team.alias}_${from}_${to}.pdf`;
|
||||||
|
|
||||||
await this.mailService.cashboxExport(
|
await this.mailService.cashboxExport(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
|
|||||||
import { Team } from 'src/teams/entities/team.entity';
|
import { Team } from 'src/teams/entities/team.entity';
|
||||||
import { TeamAccessService } from 'src/teams/team-access.service';
|
import { TeamAccessService } from 'src/teams/team-access.service';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { buildCsv, buildPdf, buildRows } from './cashbox-export.utils';
|
import { buildCsv, buildPdf, buildReceivableRows, buildRows } from './cashbox-export.utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CashboxExportService {
|
export class CashboxExportService {
|
||||||
@@ -56,7 +56,7 @@ export class CashboxExportService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
const result = {
|
const result = {
|
||||||
buffer: await buildPdf(team, rows, from, to),
|
buffer: await buildPdf(team, rows, buildReceivableRows(team, from, to), from, to),
|
||||||
contentType: 'application/pdf',
|
contentType: 'application/pdf',
|
||||||
filename: `kassenbuch_${team.alias}_${from}_${to}.pdf`,
|
filename: `kassenbuch_${team.alias}_${from}_${to}.pdf`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { buildCsv, buildPdf, buildRows } from './cashbox-export.utils';
|
import { buildCsv, buildPdf, buildReceivableRows, buildRows } from './cashbox-export.utils';
|
||||||
|
|
||||||
describe('buildRows', () => {
|
describe('buildRows', () => {
|
||||||
const team = (overrides: Partial<{ transactions: any[]; players: any[] }> = {}) => ({
|
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', () => {
|
describe('buildCsv', () => {
|
||||||
it('renders the header and formatted rows with German decimals', () => {
|
it('renders the header and formatted rows with German decimals', () => {
|
||||||
const csv = buildCsv([
|
const csv = buildCsv([
|
||||||
@@ -169,24 +250,77 @@ describe('buildCsv', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('buildPdf', () => {
|
describe('buildPdf', () => {
|
||||||
it('produces a non-empty valid PDF buffer', async () => {
|
const cashRow = {
|
||||||
const buffer = await buildPdf(
|
date: '2026-08-05T00:00:00.000Z',
|
||||||
{ name: 'Team A' } as any,
|
type: 'payment',
|
||||||
[
|
who: 'Alex Muster',
|
||||||
{ date: '2026-08-05T00:00:00.000Z', type: 'payment', who: 'Alex Muster', note: 'Bar bezahlt', amount: 10, runningTotal: 10 },
|
note: 'Bar bezahlt',
|
||||||
],
|
amount: 10,
|
||||||
'2026-08-01',
|
runningTotal: 10,
|
||||||
'2026-08-31',
|
};
|
||||||
);
|
|
||||||
|
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.isBuffer(buffer)).toBe(true);
|
||||||
expect(buffer.length).toBeGreaterThan(100);
|
expect(buffer.length).toBeGreaterThan(100);
|
||||||
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
expect(buffer.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still produces a valid PDF when there are no rows', async () => {
|
it('produces a valid PDF when there are only receivable rows', async () => {
|
||||||
const buffer = await buildPdf({ name: 'Team A' } as any, [], '2026-08-01', '2026-08-31');
|
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-');
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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> = {
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
payment: 'Zahlung',
|
payment: 'Zahlung',
|
||||||
credit: 'Guthaben',
|
credit: 'Guthaben',
|
||||||
expense: 'Ausgabe',
|
expense: 'Ausgabe',
|
||||||
|
fine: 'Strafe',
|
||||||
|
levy: 'Umlage',
|
||||||
|
fee: 'Gebühr',
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatGermanAmount(value: number): string {
|
function formatGermanAmount(value: number): string {
|
||||||
@@ -102,34 +141,330 @@ export function buildCsv(rows: CashboxExportRow[]): string {
|
|||||||
return lines.join('\r\n');
|
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(
|
export function buildPdf(
|
||||||
team: Pick<Team, 'name'>,
|
team: Pick<Team, 'name'>,
|
||||||
rows: CashboxExportRow[],
|
rows: CashboxExportRow[],
|
||||||
|
receivableRows: CashboxReceivableRow[],
|
||||||
from: string,
|
from: string,
|
||||||
to: string,
|
to: string,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
return new Promise((resolve, reject) => {
|
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[] = [];
|
const chunks: Buffer[] = [];
|
||||||
doc.on('data', (chunk) => chunks.push(chunk));
|
doc.on('data', (chunk) => chunks.push(chunk));
|
||||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
doc.on('error', reject);
|
doc.on('error', reject);
|
||||||
|
|
||||||
doc.fontSize(16).text(`Kassenbuch ${team.name}`);
|
const pageWidth = doc.page.width;
|
||||||
doc.fontSize(10).text(`Zeitraum: ${from} bis ${to}`);
|
const pageBottom = doc.page.height - PAGE_MARGIN - 30;
|
||||||
doc.moveDown();
|
|
||||||
|
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) {
|
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 {
|
} else {
|
||||||
for (const row of rows) {
|
const cashTableRows: TableRow[] = rows.map((row) => ({
|
||||||
doc.text(
|
cells: [
|
||||||
`${row.date.slice(0, 10)} ${TYPE_LABELS[row.type] ?? row.type} ${row.who} ${row.note} ` +
|
row.date.slice(0, 10),
|
||||||
`${formatGermanAmount(row.amount)} € Saldo: ${formatGermanAmount(row.runningTotal)} €`,
|
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();
|
doc.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export type LOGEVENT =
|
|||||||
| 'penalty_catalog_delete'
|
| 'penalty_catalog_delete'
|
||||||
| 'team_create'
|
| 'team_create'
|
||||||
| 'team_permissions_update'
|
| 'team_permissions_update'
|
||||||
|
| 'scheduled_recurring_transaction_check_start'
|
||||||
|
| 'scheduled_recurring_transaction_check_finished'
|
||||||
| 'recurring_transaction_create'
|
| 'recurring_transaction_create'
|
||||||
| 'recurring_transaction_update'
|
| 'recurring_transaction_update'
|
||||||
| 'recurring_transaction_delete'
|
| 'recurring_transaction_delete'
|
||||||
|
|||||||
@@ -23,8 +23,18 @@ export class RecurringTransactionsScheduler {
|
|||||||
private readonly logger: LoggingService,
|
private readonly logger: LoggingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_DAY_AT_3AM)
|
@Cron(CronExpression.EVERY_DAY_AT_8AM)
|
||||||
async runDueRecurringTransactions(): Promise<void> {
|
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 today = new Date().toISOString();
|
||||||
const due = await this.repository.find({
|
const due = await this.repository.find({
|
||||||
where: { active: true, nextRunDate: LessThanOrEqual(today) },
|
where: { active: true, nextRunDate: LessThanOrEqual(today) },
|
||||||
@@ -34,6 +44,14 @@ export class RecurringTransactionsScheduler {
|
|||||||
for (const definition of due) {
|
for (const definition of due) {
|
||||||
await this.runOne(definition);
|
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> {
|
private async runOne(definition: RecurringTransaction): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user