import { pool } from '../db/pool'; import type { AnalyticsSummaryResponse, TopCategory, ByCategoryItem, TimeseriesItem, Granularity, } from '@family-budget/shared'; interface BaseParams { from: string; to: string; accountId?: number; onlyConfirmed?: boolean; } function buildBaseConditions( params: BaseParams, startIdx: number, ): { conditions: string[]; values: unknown[]; nextIdx: number } { const conditions: string[] = []; const values: unknown[] = []; let idx = startIdx; conditions.push(`t.operation_at >= $${idx}::date`); values.push(params.from); idx++; conditions.push(`t.operation_at < ($${idx}::date + 1)`); values.push(params.to); idx++; if (params.accountId != null) { conditions.push(`t.account_id = $${idx}`); values.push(params.accountId); idx++; } if (params.onlyConfirmed) { conditions.push('t.is_category_confirmed = TRUE'); } return { conditions, values, nextIdx: idx }; } export async function getSummary(params: BaseParams): Promise { const { conditions, values } = buildBaseConditions(params, 1); const where = 'WHERE ' + conditions.join(' AND '); const totalsResult = await pool.query( `SELECT COALESCE(SUM(CASE WHEN t.direction = 'expense' THEN ABS(t.amount_signed) ELSE 0 END), 0)::bigint AS total_expense, COALESCE(SUM(CASE WHEN t.direction = 'income' THEN t.amount_signed ELSE 0 END), 0)::bigint AS total_income FROM transactions t ${where}`, values, ); const totalExpense = Number(totalsResult.rows[0].total_expense); const totalIncome = Number(totalsResult.rows[0].total_income); const topResult = await pool.query( `SELECT t.category_id, c.name AS category_name, SUM(ABS(t.amount_signed))::bigint AS amount FROM transactions t LEFT JOIN categories c ON c.id = t.category_id ${where} AND t.direction = 'expense' AND t.category_id IS NOT NULL GROUP BY t.category_id, c.name ORDER BY amount DESC LIMIT 5`, values, ); const topCategories: TopCategory[] = topResult.rows.map((r) => ({ categoryId: Number(r.category_id), categoryName: r.category_name, amount: Number(r.amount), share: totalExpense > 0 ? Number(r.amount) / totalExpense : 0, })); return { totalExpense, totalIncome, net: totalIncome - totalExpense, topCategories, }; } export async function getByCategory(params: BaseParams): Promise { const { conditions, values } = buildBaseConditions(params, 1); const where = 'WHERE ' + conditions.join(' AND '); const totalResult = await pool.query( `SELECT COALESCE(SUM(ABS(t.amount_signed)), 0)::bigint AS total FROM transactions t ${where} AND t.direction = 'expense'`, values, ); const total = Number(totalResult.rows[0].total); const { rows } = await pool.query( `SELECT t.category_id, c.name AS category_name, SUM(ABS(t.amount_signed))::bigint AS amount, COUNT(*)::int AS tx_count FROM transactions t LEFT JOIN categories c ON c.id = t.category_id ${where} AND t.direction = 'expense' GROUP BY t.category_id, c.name ORDER BY amount DESC`, values, ); return rows.map((r) => ({ categoryId: r.category_id != null ? Number(r.category_id) : 0, categoryName: r.category_name ?? 'Без категории', amount: Number(r.amount), txCount: r.tx_count, share: total > 0 ? Number(r.amount) / total : 0, })); } export async function getTimeseries( params: BaseParams & { categoryId?: number; granularity: Granularity }, ): Promise { let truncExpr: string; let intervalStr: string; let periodEndExpr: string; switch (params.granularity) { case 'day': truncExpr = `$1::date`; intervalStr = '1 day'; periodEndExpr = 'gs::date'; break; case 'week': truncExpr = `date_trunc('week', $1::date)::date`; intervalStr = '1 week'; periodEndExpr = "(gs + interval '6 days')::date"; break; case 'month': truncExpr = `date_trunc('month', $1::date)::date`; intervalStr = '1 month'; periodEndExpr = "(gs + interval '1 month' - interval '1 day')::date"; break; } const txConditions: string[] = [ 't.operation_at::date >= p.period_start', 't.operation_at::date <= p.period_end', ]; const values: unknown[] = [params.from, params.to]; let idx = 3; if (params.accountId != null) { txConditions.push(`t.account_id = $${idx++}`); values.push(params.accountId); } if (params.categoryId != null) { txConditions.push(`t.category_id = $${idx++}`); values.push(params.categoryId); } if (params.onlyConfirmed) { txConditions.push('t.is_category_confirmed = TRUE'); } const txWhere = txConditions.join(' AND '); const { rows } = await pool.query( `WITH periods AS ( SELECT gs::date AS period_start, ${periodEndExpr} AS period_end FROM generate_series(${truncExpr}, $2::date, '${intervalStr}'::interval) gs ) SELECT p.period_start, p.period_end, COALESCE(SUM(CASE WHEN t.direction = 'expense' THEN ABS(t.amount_signed) ELSE 0 END), 0)::bigint AS expense_amount, COALESCE(SUM(CASE WHEN t.direction = 'income' THEN t.amount_signed ELSE 0 END), 0)::bigint AS income_amount FROM periods p LEFT JOIN transactions t ON ${txWhere} GROUP BY p.period_start, p.period_end ORDER BY p.period_start`, values, ); return rows.map((r) => ({ periodStart: r.period_start.toISOString().slice(0, 10), periodEnd: r.period_end.toISOString().slice(0, 10), expenseAmount: Number(r.expense_amount), incomeAmount: Number(r.income_amount), })); }