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( `WITH investment_ref AS ( SELECT ( SELECT id FROM categories WHERE name = 'Инвестиции' AND type = 'transfer' ORDER BY id ASC LIMIT 1 ) AS investment_category_id ) SELECT COALESCE(SUM( CASE WHEN (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0)) AND ( ir.investment_category_id IS NULL OR t.category_id IS DISTINCT FROM ir.investment_category_id ) THEN ABS(t.amount_signed) + t.commission ELSE 0 END ), 0)::bigint AS total_expense, COALESCE(SUM( CASE WHEN t.direction = 'income' AND ( ir.investment_category_id IS NULL OR t.category_id IS DISTINCT FROM ir.investment_category_id ) THEN t.amount_signed + t.commission ELSE 0 END ), 0)::bigint AS total_income, COALESCE(SUM( CASE WHEN t.amount_signed < 0 AND ir.investment_category_id IS NOT NULL AND t.category_id = ir.investment_category_id THEN ABS(t.amount_signed) + t.commission ELSE 0 END ), 0)::bigint AS investment_outflow, COALESCE(SUM( CASE WHEN t.amount_signed > 0 AND ir.investment_category_id IS NOT NULL AND t.category_id = ir.investment_category_id THEN t.amount_signed + t.commission ELSE 0 END ), 0)::bigint AS investment_income_excluded FROM transactions t CROSS JOIN investment_ref ir ${where}`, values, ); const totalExpense = Number(totalsResult.rows[0].total_expense); const totalIncome = Number(totalsResult.rows[0].total_income); const investmentOutflow = Number(totalsResult.rows[0].investment_outflow); const investmentIncomeExcluded = Number(totalsResult.rows[0].investment_income_excluded); const topResult = await pool.query( `WITH investment_ref AS ( SELECT ( SELECT id FROM categories WHERE name = 'Инвестиции' AND type = 'transfer' ORDER BY id ASC LIMIT 1 ) AS investment_category_id ) SELECT COALESCE(t.category_id, 0)::bigint AS category_id, COALESCE(c.name, 'Без категории') AS category_name, SUM(ABS(t.amount_signed) + t.commission)::bigint AS amount FROM transactions t CROSS JOIN investment_ref ir LEFT JOIN categories c ON c.id = t.category_id ${where} AND (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0)) AND ( ir.investment_category_id IS NULL OR t.category_id IS DISTINCT FROM ir.investment_category_id ) GROUP BY COALESCE(t.category_id, 0), COALESCE(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, investmentOutflow, investmentIncomeExcluded, 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( `WITH investment_ref AS ( SELECT ( SELECT id FROM categories WHERE name = 'Инвестиции' AND type = 'transfer' ORDER BY id ASC LIMIT 1 ) AS investment_category_id ) SELECT COALESCE(SUM(ABS(t.amount_signed) + t.commission), 0)::bigint AS total FROM transactions t CROSS JOIN investment_ref ir ${where} AND (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0)) AND ( ir.investment_category_id IS NULL OR t.category_id IS DISTINCT FROM ir.investment_category_id )`, values, ); const total = Number(totalResult.rows[0].total); const { rows } = await pool.query( `WITH investment_ref AS ( SELECT ( SELECT id FROM categories WHERE name = 'Инвестиции' AND type = 'transfer' ORDER BY id ASC LIMIT 1 ) AS investment_category_id ) SELECT COALESCE(t.category_id, 0)::bigint AS category_id, COALESCE(c.name, 'Без категории') AS category_name, SUM(ABS(t.amount_signed) + t.commission)::bigint AS amount, COUNT(*)::int AS tx_count FROM transactions t CROSS JOIN investment_ref ir LEFT JOIN categories c ON c.id = t.category_id ${where} AND (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0)) AND ( ir.investment_category_id IS NULL OR t.category_id IS DISTINCT FROM ir.investment_category_id ) GROUP BY COALESCE(t.category_id, 0), COALESCE(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 ), investment_ref AS ( SELECT ( SELECT id FROM categories WHERE name = 'Инвестиции' AND type = 'transfer' ORDER BY id ASC LIMIT 1 ) AS investment_category_id ) SELECT p.period_start, p.period_end, COALESCE(SUM( CASE WHEN (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0)) AND ( ir.investment_category_id IS NULL OR t.category_id IS DISTINCT FROM ir.investment_category_id ) THEN ABS(t.amount_signed) + t.commission ELSE 0 END ), 0)::bigint AS expense_amount, COALESCE(SUM( CASE WHEN t.direction = 'income' AND ( ir.investment_category_id IS NULL OR t.category_id IS DISTINCT FROM ir.investment_category_id ) THEN t.amount_signed + t.commission ELSE 0 END ), 0)::bigint AS income_amount, COALESCE(SUM( CASE WHEN t.amount_signed < 0 AND ir.investment_category_id IS NOT NULL AND t.category_id = ir.investment_category_id THEN ABS(t.amount_signed) + t.commission ELSE 0 END ), 0)::bigint AS investment_outflow FROM periods p CROSS JOIN investment_ref ir 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), investmentOutflow: Number(r.investment_outflow), })); }