This commit is contained in:
Mateusz Gruszczyński
2026-04-06 14:37:42 +02:00
parent 237596bd52
commit 80e181ea3f
41 changed files with 14959 additions and 1023 deletions

View File

@@ -1,25 +1,36 @@
import type { Response } from 'express';
import { createRequire } from 'node:module';
import type { Response } from 'express';
import { z } from 'zod';
import { AppDataSource } from '../config/data-source.js';
import { AppSetting } from '../entities/AppSetting.js';
import { Expense } from '../entities/Expense.js';
import { User } from '../entities/User.js';
import { getStatistics } from '../services/statistics.service.js';
import { processDueRecurringExpenses } from '../services/recurring.service.js';
import type { AuthenticatedRequest } from '../types/express.js';
const preferencesSchema = z.object({
enabled: z.boolean(),
frequency: z.enum(['monthly', 'yearly', 'threshold']),
thresholdAmount: z.number().min(0).default(0),
sendToEmail: z.email().nullable().optional(),
sendToEmail: z.string().email().nullable().optional(),
categoryIds: z.array(z.string().uuid()).default([])
});
const previewOverrideSchema = preferencesSchema.partial();
const exportQuerySchema = z.object({
format: z.enum(['csv', 'json', 'html', 'pdf']).default('csv'),
startDate: z.string().optional(),
endDate: z.string().optional(),
categoryIds: z.string().optional(),
status: z.enum(['DRAFT', 'PENDING', 'APPROVED', 'REJECTED']).optional(),
tag: z.string().optional()
});
const userRepo = () => AppDataSource.getRepository(User);
const require = createRequire(import.meta.url);
const settingsRepo = () => AppDataSource.getRepository(AppSetting);
const expenseRepo = () => AppDataSource.getRepository(Expense);
const defaultPrefs = (email: string) => ({
enabled: false,
@@ -59,6 +70,11 @@ const buildReportHtml = (title: string, summary: Awaited<ReturnType<typeof getSt
.map((item) => `<tr><td style="padding:8px 0;border-bottom:1px solid #e5e7eb">${item.label}</td><td style="padding:8px 0;border-bottom:1px solid #e5e7eb;text-align:right">${item.total.toFixed(2)}</td></tr>`)
.join('');
const tagRows = (summary.byTag ?? [])
.slice(0, 6)
.map((item) => `<tr><td style="padding:8px 0;border-bottom:1px solid #e5e7eb">${item.tag}</td><td style="padding:8px 0;border-bottom:1px solid #e5e7eb;text-align:right">${item.total.toFixed(2)}</td></tr>`)
.join('');
return `
<div style="font-family:Arial,sans-serif;color:#111827;max-width:760px;margin:0 auto">
<h1 style="margin-bottom:8px">${title}</h1>
@@ -67,10 +83,112 @@ const buildReportHtml = (title: string, summary: Awaited<ReturnType<typeof getSt
<table style="width:100%;border-collapse:collapse">${categoryRows || '<tr><td>No data</td></tr>'}</table>
<h2 style="margin-top:32px">Timeline</h2>
<table style="width:100%;border-collapse:collapse">${timelineRows || '<tr><td>No data</td></tr>'}</table>
<h2 style="margin-top:32px">Top tags</h2>
<table style="width:100%;border-collapse:collapse">${tagRows || '<tr><td>No data</td></tr>'}</table>
</div>
`;
};
const collectExportItems = async (userId: string, filters: { startDate?: string; endDate?: string; categoryIds?: string[]; status?: string; tag?: string }) => {
const items = await expenseRepo().find({ where: { user: { id: userId } }, relations: { category: true, proofs: true }, order: { expenseDate: 'DESC', createdAt: 'DESC' } });
return items.filter((item) => {
if (filters.startDate && item.expenseDate < filters.startDate) return false;
if (filters.endDate && item.expenseDate > filters.endDate) return false;
if (filters.categoryIds?.length && !filters.categoryIds.includes(item.category.id)) return false;
if (filters.status && item.status !== filters.status) return false;
if (filters.tag && !(item.tags ?? []).some((tag) => tag.toLowerCase() === filters.tag!.toLowerCase())) return false;
return true;
});
};
const escapeCsv = (value: unknown) => {
const text = String(value ?? '');
if (/[",\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
return text;
};
const escapePdfText = (value: string) => value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)').replace(/\r/g, '').replace(/\n/g, ' ');
const buildPdfBuffer = (title: string, summary: Awaited<ReturnType<typeof getStatistics>>, items: Expense[]) => {
const lines = [
title,
`Generated: ${new Date().toISOString().slice(0, 19).replace('T', ' ')}`,
`Total: ${summary.total.toFixed(2)} | Count: ${summary.count} | Average: ${summary.average.toFixed(2)}`,
'',
'Top categories:'
];
if (summary.byCategory.length) {
summary.byCategory.slice(0, 8).forEach((item) => lines.push(`- ${item.categoryName}: ${item.total.toFixed(2)} (${item.count})`));
} else {
lines.push('- No data');
}
lines.push('', 'Timeline:');
if (summary.timeline.length) {
summary.timeline.slice(-8).forEach((item) => lines.push(`- ${item.label}: ${item.total.toFixed(2)}`));
} else {
lines.push('- No data');
}
lines.push('', 'Expenses:');
if (items.length) {
items.slice(0, 30).forEach((item) => lines.push(`- ${item.expenseDate} | ${item.title} | ${item.category.name} | ${item.amount.toFixed(2)} ${item.currency} | ${item.status}`));
} else {
lines.push('- No data');
}
const pageHeight = 792;
const lineHeight = 16;
const startY = 750;
const linesPerPage = 40;
const chunks = [] as string[][];
for (let index = 0; index < lines.length; index += linesPerPage) chunks.push(lines.slice(index, index + linesPerPage));
const objects: string[] = [];
const kids: string[] = [];
const fontObjectNumber = 3 + chunks.length * 2;
objects[1] = '<< /Type /Catalog /Pages 2 0 R >>';
chunks.forEach((chunk, pageIndex) => {
const pageObjectNumber = 3 + pageIndex * 2;
const contentObjectNumber = pageObjectNumber + 1;
kids.push(`${pageObjectNumber} 0 R`);
const contentLines = ['BT', '/F1 12 Tf', `50 ${startY} Td`];
chunk.forEach((line, index) => {
if (index > 0) contentLines.push(`0 -${lineHeight} Td`);
contentLines.push(`(${escapePdfText(line)}) Tj`);
});
contentLines.push('ET');
const stream = contentLines.join('\n');
objects[pageObjectNumber] = `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 ${pageHeight}] /Resources << /Font << /F1 ${fontObjectNumber} 0 R >> >> /Contents ${contentObjectNumber} 0 R >>`;
objects[contentObjectNumber] = `<< /Length ${Buffer.byteLength(stream, 'utf8')} >>\nstream\n${stream}\nendstream`;
});
objects[2] = `<< /Type /Pages /Count ${kids.length} /Kids [${kids.join(' ')}] >>`;
objects[fontObjectNumber] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>';
let pdf = '%PDF-1.4\n';
const offsets: number[] = [0];
for (let index = 1; index < objects.length; index += 1) {
if (!objects[index]) continue;
offsets[index] = Buffer.byteLength(pdf, 'utf8');
pdf += `${index} 0 obj\n${objects[index]}\nendobj\n`;
}
const xrefOffset = Buffer.byteLength(pdf, 'utf8');
pdf += `xref\n0 ${objects.length}\n`;
pdf += '0000000000 65535 f \n';
for (let index = 1; index < objects.length; index += 1) {
pdf += `${String(offsets[index] ?? 0).padStart(10, '0')} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${objects.length} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
return Buffer.from(pdf, 'utf8');
};
export const getPreferences = async (req: AuthenticatedRequest, res: Response) => {
const user = await userRepo().findOne({ where: { id: req.user!.id } });
if (!user) return res.status(404).json({ message: 'User not found' });
@@ -92,6 +210,7 @@ export const updatePreferences = async (req: AuthenticatedRequest, res: Response
};
export const previewReport = async (req: AuthenticatedRequest, res: Response) => {
await processDueRecurringExpenses(req.user!.id);
const user = await userRepo().findOne({ where: { id: req.user!.id } });
if (!user) return res.status(404).json({ message: 'User not found' });
@@ -120,6 +239,7 @@ export const previewReport = async (req: AuthenticatedRequest, res: Response) =>
};
export const sendReport = async (req: AuthenticatedRequest, res: Response) => {
await processDueRecurringExpenses(req.user!.id);
const user = await userRepo().findOne({ where: { id: req.user!.id } });
if (!user) return res.status(404).json({ message: 'User not found' });
@@ -151,9 +271,7 @@ export const sendReport = async (req: AuthenticatedRequest, res: Response) => {
const to = prefs.sendToEmail || user.email;
await transport.sendMail({
from: appSettings.smtpFromName
? `"${appSettings.smtpFromName}" <${appSettings.smtpFromEmail}>`
: appSettings.smtpFromEmail,
from: appSettings.smtpFromName ? `"${appSettings.smtpFromName}" <${appSettings.smtpFromEmail}>` : appSettings.smtpFromEmail,
to,
subject: `${appSettings.appName} - ${range.label} report`,
html: buildReportHtml(`${range.label} report`, summary)
@@ -161,3 +279,92 @@ export const sendReport = async (req: AuthenticatedRequest, res: Response) => {
return res.json({ message: 'Report email was sent', sentTo: to });
};
export const exportReport = async (req: AuthenticatedRequest, res: Response) => {
await processDueRecurringExpenses(req.user!.id);
const parsed = exportQuerySchema.safeParse(req.query);
if (!parsed.success) return res.status(400).json({ message: 'Invalid report export filters', issues: parsed.error.issues });
const categoryIds = parsed.data.categoryIds?.split(',').filter(Boolean) ?? [];
const items = await collectExportItems(req.user!.id, {
startDate: parsed.data.startDate,
endDate: parsed.data.endDate,
categoryIds,
status: parsed.data.status,
tag: parsed.data.tag
});
const summary = await getStatistics(
{
userId: req.user!.id,
startDate: parsed.data.startDate,
endDate: parsed.data.endDate,
categoryIds,
status: parsed.data.status,
tag: parsed.data.tag
},
'month'
);
const stamp = new Date().toISOString().slice(0, 10);
if (parsed.data.format === 'json') {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="expense-report-${stamp}.json"`);
return res.send(
JSON.stringify(
{
filters: parsed.data,
summary,
items: items.map((item) => ({
id: item.id,
title: item.title,
date: item.expenseDate,
amount: item.amount,
currency: item.currency,
status: item.status,
category: item.category.name,
merchant: item.merchant,
tags: item.tags ?? [],
customFields: item.customFields ?? {},
attachments: item.proofs.map((proof) => proof.originalName || proof.label || 'Attachment')
}))
},
null,
2
)
);
}
if (parsed.data.format === 'html') {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="expense-report-${stamp}.html"`);
return res.send(buildReportHtml('Expense export', summary));
}
if (parsed.data.format === 'pdf') {
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="expense-report-${stamp}.pdf"`);
return res.send(buildPdfBuffer('Expense export', summary, items));
}
const rows = [
['Date', 'Title', 'Category', 'Merchant', 'Amount', 'Currency', 'Status', 'Tags', 'Custom fields', 'Attachments'],
...items.map((item) => [
item.expenseDate,
item.title,
item.category.name,
item.merchant ?? '',
item.amount.toFixed(2),
item.currency,
item.status,
(item.tags ?? []).join('|'),
Object.entries(item.customFields ?? {})
.map(([key, value]) => `${key}: ${value}`)
.join(' | '),
item.proofs.map((proof) => proof.originalName || proof.label || 'Attachment').join(' | ')
])
];
const csv = rows.map((row) => row.map(escapeCsv).join(',')).join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="expense-report-${stamp}.csv"`);
return res.send(csv);
};