383 lines
16 KiB
TypeScript
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);
|
|
};
|