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 { 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(
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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<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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user