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>) => { const categoryRows = summary.byCategory .slice(0, 6) .map((item) => `${item.categoryName}${item.total.toFixed(2)}`) .join(''); const timelineRows = summary.timeline .slice(-6) .map((item) => `${item.label}${item.total.toFixed(2)}`) .join(''); const tagRows = (summary.byTag ?? []) .slice(0, 6) .map((item) => `${item.tag}${item.total.toFixed(2)}`) .join(''); return `

${title}

Total: ${summary.total.toFixed(2)} | Count: ${summary.count} | Average: ${summary.average.toFixed(2)}

Top categories

${categoryRows || ''}
No data

Timeline

${timelineRows || ''}
No data

Top tags

${tagRows || ''}
No data
`; }; 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>, 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 } }; 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); };