import { Between, In } from 'typeorm'; import { AppDataSource } from '../config/data-source.js'; import { Budget } from '../entities/Budget.js'; import { Expense } from '../entities/Expense.js'; import { RecurringExpense } from '../entities/RecurringExpense.js'; export type StatsFilters = { userId?: string; startDate?: string; endDate?: string; categoryIds?: string[]; tag?: string; status?: string }; export type FlatExpense = { id: string; amount: number; expenseDate: string; categoryId: string; categoryName: string; tags?: string[]; status?: string; }; const labelMonth = (date: string) => date.slice(0, 7); const labelQuarter = (date: string) => { const [year, month] = date.split('-').map(Number); return `${year}-Q${Math.ceil(month / 3)}`; }; const labelYear = (date: string) => date.slice(0, 4); export const buildBucketLabel = (date: string, bucket: 'month' | 'quarter' | 'year') => bucket === 'year' ? labelYear(date) : bucket === 'quarter' ? labelQuarter(date) : labelMonth(date); export const aggregateStatistics = (expenses: FlatExpense[], bucket: 'month' | 'quarter' | 'year' = 'month') => { const total = expenses.reduce((sum, item) => sum + item.amount, 0); const byCategoryMap = new Map(); const timelineMap = new Map(); const byTagMap = new Map(); const byStatusMap = new Map(); for (const expense of expenses) { const existing = byCategoryMap.get(expense.categoryId) ?? { categoryId: expense.categoryId, categoryName: expense.categoryName, total: 0, count: 0 }; existing.total += expense.amount; existing.count += 1; byCategoryMap.set(expense.categoryId, existing); const bucketLabel = buildBucketLabel(expense.expenseDate, bucket); timelineMap.set(bucketLabel, (timelineMap.get(bucketLabel) ?? 0) + expense.amount); for (const tag of expense.tags ?? []) { byTagMap.set(tag, (byTagMap.get(tag) ?? 0) + expense.amount); } if (expense.status) byStatusMap.set(expense.status, (byStatusMap.get(expense.status) ?? 0) + 1); } const byCategory = [...byCategoryMap.values()] .sort((a, b) => b.total - a.total) .map((item) => ({ ...item, total: Number(item.total.toFixed(2)) })); const timeline = [...timelineMap.entries()] .map(([label, totalValue]) => ({ label, total: Number(totalValue.toFixed(2)) })) .sort((a, b) => a.label.localeCompare(b.label)); const byTag = [...byTagMap.entries()] .map(([tag, totalValue]) => ({ tag, total: Number(totalValue.toFixed(2)) })) .sort((a, b) => b.total - a.total) .slice(0, 10); const byStatus = [...byStatusMap.entries()] .map(([status, count]) => ({ status, count })) .sort((a, b) => b.count - a.count); return { total: Number(total.toFixed(2)), count: expenses.length, average: expenses.length ? Number((total / expenses.length).toFixed(2)) : 0, byCategory, timeline, byTag, byStatus, topCategory: byCategory[0] ?? null }; }; export const getStatistics = async (filters: StatsFilters, bucket: 'month' | 'quarter' | 'year' = 'month') => { const repo = AppDataSource.getRepository(Expense); const where: Record = {}; if (filters.userId) where.user = { id: filters.userId }; if (filters.startDate && filters.endDate) where.expenseDate = Between(filters.startDate, filters.endDate); else if (filters.startDate) where.expenseDate = Between(filters.startDate, '2999-12-31'); else if (filters.endDate) where.expenseDate = Between('1900-01-01', filters.endDate); if (filters.categoryIds?.length) where.category = { id: In(filters.categoryIds) }; if (filters.status) where.status = filters.status; const expenses = await repo.find({ where, relations: { category: true }, order: { expenseDate: 'DESC' } }); const filteredByTag = filters.tag ? expenses.filter((expense) => (expense.tags ?? []).some((tag) => tag.toLowerCase() === filters.tag!.toLowerCase())) : expenses; return aggregateStatistics( filteredByTag.map((expense) => ({ id: expense.id, amount: expense.amount, expenseDate: expense.expenseDate, categoryId: expense.category.id, categoryName: expense.category.name, tags: expense.tags ?? [], status: expense.status })), bucket ); }; const currentMonthKey = () => { const date = new Date(); return `${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, '0')}`; }; const monthRange = (monthKey: string) => { const [yearText, monthText] = monthKey.split('-'); const year = Number(yearText); const month = Number(monthText); const lastDay = new Date(year, month, 0).getDate(); return { startDate: `${monthKey}-01`, endDate: `${monthKey}-${String(lastDay).padStart(2, '0')}` }; }; export const getCashflowSummary = async (userId: string) => { const expenseRepo = AppDataSource.getRepository(Expense); const budgetRepo = AppDataSource.getRepository(Budget); const recurringRepo = AppDataSource.getRepository(RecurringExpense); const currentMonth = currentMonthKey(); const currentRange = monthRange(currentMonth); const startWindow = new Date(); startWindow.setMonth(startWindow.getMonth() - 5, 1); const startDate = `${startWindow.getFullYear()}-${`${startWindow.getMonth() + 1}`.padStart(2, '0')}-01`; const expenses = await expenseRepo.find({ where: { user: { id: userId }, expenseDate: Between(startDate, currentRange.endDate) }, relations: { category: true }, order: { expenseDate: 'ASC' } }); const budgets = await budgetRepo.find({ where: { user: { id: userId }, month: currentMonth }, relations: { category: true } }); const recurring = await recurringRepo.find({ where: { user: { id: userId }, isActive: true }, relations: { category: true }, order: { nextRunDate: 'ASC' } }); const months = new Map(); for (let offset = 5; offset >= 0; offset -= 1) { const date = new Date(); date.setMonth(date.getMonth() - offset, 1); const key = `${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, '0')}`; months.set(key, { label: key, actual: 0, budget: 0 }); } for (const expense of expenses) { const month = expense.expenseDate.slice(0, 7); const entry = months.get(month); if (entry && expense.status !== 'REJECTED' && expense.status !== 'DRAFT') entry.actual += expense.amount; } for (const budget of budgets) { const entry = months.get(budget.month); if (entry) entry.budget += budget.amount; } const duplicateCount = expenses.filter((expense) => expense.possibleDuplicate && expense.duplicateStatus === 'OPEN' && expense.expenseDate.startsWith(currentMonth)).length; const currentMonthExpenses = expenses.filter((expense) => expense.expenseDate.startsWith(currentMonth)); const actualCurrent = currentMonthExpenses .filter((expense) => expense.status !== 'REJECTED' && expense.status !== 'DRAFT') .reduce((sum, expense) => sum + expense.amount, 0); const pendingApproval = currentMonthExpenses.filter((expense) => expense.status === 'PENDING').length; const upcomingRecurring = recurring .filter((item) => item.nextRunDate >= currentRange.startDate && item.nextRunDate <= currentRange.endDate) .map((item) => ({ id: item.id, title: item.title, amount: item.amount, nextRunDate: item.nextRunDate, frequency: item.frequency })); const totalBudget = budgets.reduce((sum, item) => sum + item.amount, 0); const budgetUsagePercent = totalBudget ? Number(((actualCurrent / totalBudget) * 100).toFixed(1)) : 0; const alerts = budgets .map((budget) => { const spent = currentMonthExpenses .filter((expense) => expense.status !== 'REJECTED' && expense.status !== 'DRAFT') .filter((expense) => !budget.category || expense.category.id === budget.category.id) .reduce((sum, expense) => sum + expense.amount, 0); const usagePercent = budget.amount ? Number(((spent / budget.amount) * 100).toFixed(1)) : 0; return { id: budget.id, name: budget.name || budget.category?.name || 'Monthly budget', usagePercent, spent: Number(spent.toFixed(2)), amount: budget.amount }; }) .filter((item) => item.usagePercent >= 80) .sort((a, b) => b.usagePercent - a.usagePercent); const statusSummary = aggregateStatistics( currentMonthExpenses.map((expense) => ({ id: expense.id, amount: expense.amount, expenseDate: expense.expenseDate, categoryId: expense.category.id, categoryName: expense.category.name, tags: expense.tags ?? [], status: expense.status })) ).byStatus; return { currentMonth, actualCurrent: Number(actualCurrent.toFixed(2)), totalBudget: Number(totalBudget.toFixed(2)), budgetUsagePercent, duplicateCount, pendingApproval, forecastCurrentMonth: Number((actualCurrent + upcomingRecurring.reduce((sum, item) => sum + item.amount, 0)).toFixed(2)), trend: [...months.values()].map((item) => ({ label: item.label, actual: Number(item.actual.toFixed(2)), budget: Number(item.budget.toFixed(2)) })), alerts, upcomingRecurring, statusSummary }; };