Files
expense-control/api/src/controllers/report.controller.ts
Mateusz Gruszczyński ca9c78d88d changes
2026-04-07 10:06:48 +02:00

383 lines
16 KiB
TypeScript

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.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 normalizeOptionalString = (value: unknown) => {
if (value === null || value === undefined) return undefined;
const normalized = String(value).trim();
return normalized ? normalized : undefined;
};
const normalizeNullableString = (value: unknown) => {
if (value === null || value === undefined) return undefined;
const normalized = String(value).trim();
return normalized ? normalized : null;
};
const defaultPrefs = (email: string) => ({
enabled: false,
frequency: 'monthly' as const,
thresholdAmount: 0,
sendToEmail: email,
categoryIds: [] as string[]
});
const formatLocalDate = (date: Date) => {
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
return `${year}-${month}-${day}`;
};
const periodRange = (frequency: 'monthly' | 'yearly' | 'threshold') => {
const now = new Date();
const endDate = formatLocalDate(now);
if (frequency === 'yearly') {
return { startDate: `${now.getFullYear()}-01-01`, endDate, bucket: 'month' as const, label: 'Year to date' };
}
const start = new Date(now.getFullYear(), now.getMonth(), 1);
return { startDate: formatLocalDate(start), endDate, bucket: 'month' as const, label: 'Current month' };
};
const buildReportHtml = (title: string, summary: Awaited<ReturnType<typeof getStatistics>>) => {
const categoryRows = summary.byCategory
.slice(0, 6)
.map((item) => `<tr><td style="padding:8px 0;border-bottom:1px solid #e5e7eb">${item.categoryName}</td><td style="padding:8px 0;border-bottom:1px solid #e5e7eb;text-align:right">${item.total.toFixed(2)}</td></tr>`)
.join('');
const timelineRows = summary.timeline
.slice(-6)
.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>
<p style="color:#4b5563;margin-top:0">Total: <strong>${summary.total.toFixed(2)}</strong> | Count: <strong>${summary.count}</strong> | Average: <strong>${summary.average.toFixed(2)}</strong></p>
<h2 style="margin-top:32px">Top categories</h2>
<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' });
return res.json({ item: user.reportPreferences ?? defaultPrefs(user.email) });
};
export const updatePreferences = async (req: AuthenticatedRequest, res: Response) => {
const parsed = preferencesSchema.safeParse({ ...req.body, sendToEmail: normalizeNullableString(req.body?.sendToEmail) });
if (!parsed.success) {
return res.status(400).json({ message: 'Invalid report preferences payload', issues: parsed.error.issues });
}
const user = await userRepo().findOne({ where: { id: req.user!.id } });
if (!user) return res.status(404).json({ message: 'User not found' });
user.reportPreferences = parsed.data;
await userRepo().save(user);
return res.json({ item: user.reportPreferences });
};
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' });
const overridesParsed = previewOverrideSchema.safeParse(req.body ?? {});
if (!overridesParsed.success) {
return res.status(400).json({ message: 'Invalid report preview payload', issues: overridesParsed.error.issues });
}
const prefs = { ...defaultPrefs(user.email), ...(user.reportPreferences ?? {}), ...overridesParsed.data };
const range = periodRange(prefs.frequency ?? 'monthly');
const summary = await getStatistics(
{
userId: user.id,
startDate: range.startDate,
endDate: range.endDate,
categoryIds: prefs.categoryIds?.length ? prefs.categoryIds : undefined
},
range.bucket
);
return res.json({
range,
summary,
html: buildReportHtml(`${range.label} report`, summary)
});
};
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' });
const settings = await settingsRepo().find({ take: 1, order: { createdAt: 'ASC' } });
const appSettings = settings[0];
if (!appSettings || !appSettings.smtpEnabled || !appSettings.smtpHost || !appSettings.smtpFromEmail) {
return res.status(400).json({ message: 'SMTP is not configured' });
}
const prefs = user.reportPreferences ?? defaultPrefs(user.email);
const range = periodRange(prefs.frequency ?? 'monthly');
const summary = await getStatistics(
{
userId: user.id,
startDate: range.startDate,
endDate: range.endDate,
categoryIds: prefs.categoryIds?.length ? prefs.categoryIds : undefined
},
range.bucket
);
const nodemailer = require('nodemailer') as { createTransport: (options: unknown) => { sendMail: (message: unknown) => Promise<unknown> } };
const transport = nodemailer.createTransport({
host: appSettings.smtpHost,
port: appSettings.smtpPort,
secure: appSettings.smtpSecure,
auth: appSettings.smtpUser ? { user: appSettings.smtpUser, pass: appSettings.smtpPassword ?? '' } : undefined
});
const to = prefs.sendToEmail || user.email;
await transport.sendMail({
from: appSettings.smtpFromName ? `"${appSettings.smtpFromName}" <${appSettings.smtpFromEmail}>` : appSettings.smtpFromEmail,
to,
subject: `${appSettings.appName} - ${range.label} report`,
html: buildReportHtml(`${range.label} report`, summary)
});
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, format: normalizeOptionalString(req.query.format), startDate: normalizeOptionalString(req.query.startDate), endDate: normalizeOptionalString(req.query.endDate), categoryIds: normalizeOptionalString(req.query.categoryIds), status: normalizeOptionalString(req.query.status), tag: normalizeOptionalString(req.query.tag) });
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);
};