import { pool } from '../db/pool'; import { effectiveAmountCase } from './analyticsSemantics'; import type { AnalyticsSummaryResponse, TopCategory, ByCategoryItem, TimeseriesItem, Granularity, } from '@family-budget/shared'; type Queryable = Pick; interface BaseParams { from: string; to: string; accountId?: number; categoryId?: number; onlyConfirmed?: boolean; } const effectiveAmount = effectiveAmountCase('t'); function analyticsTransactions(where: string): string { return ` WITH analytics_transactions AS ( SELECT t.id, t.operation_at, COALESCE(c.type, t.direction) AS analytic_type, COALESCE(t.category_id, 0) AS category_id, COALESCE(c.name, 'Без категории') AS category_name, ${effectiveAmount} AS effective_amount, CASE WHEN a.account_type = 'savings' AND ${effectiveAmount} > 0 AND (t.description ILIKE '%процент%' OR t.description ILIKE '%выплата %' OR t.description LIKE '%\%%' ESCAPE '\\') THEN ${effectiveAmount} ELSE 0 END AS interest_income, CASE WHEN t.amount_signed = 0 AND t.commission > 0 AND t.description ILIKE '%зачисление%' THEN t.commission ELSE 0 END AS cashback_income FROM transactions t LEFT JOIN categories c ON c.id = t.category_id LEFT JOIN accounts a ON a.id = t.account_id ${where} )`; } 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.categoryId != null) { conditions.push(params.categoryId === 0 ? 't.category_id IS NULL' : `t.category_id = $${idx}`); if (params.categoryId !== 0) { values.push(params.categoryId); idx++; } } if (params.onlyConfirmed) { conditions.push('t.is_category_confirmed = TRUE'); } return { conditions, values, nextIdx: idx }; } export async function getSummary( params: BaseParams, db: Queryable = pool, ): Promise { const { conditions, values } = buildBaseConditions(params, 1); const where = 'WHERE ' + conditions.join(' AND '); const totalsResult = await db.query( `${analyticsTransactions(where)}, category_net AS ( SELECT category_id, category_name, analytic_type, SUM(effective_amount)::bigint AS amount, SUM(interest_income)::bigint AS interest_income, SUM(cashback_income)::bigint AS cashback_income FROM analytics_transactions GROUP BY category_id, category_name, analytic_type ) SELECT COALESCE(SUM(GREATEST(-amount, 0)) FILTER (WHERE analytic_type = 'expense'), 0)::bigint AS total_expense, COALESCE(SUM(GREATEST(amount, 0)) FILTER (WHERE analytic_type = 'income'), 0)::bigint AS total_income, COALESCE((SELECT SUM(GREATEST(effective_amount, 0)) FROM analytics_transactions), 0)::bigint AS cash_inflow, COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions), 0)::bigint AS cash_outflow, COALESCE((SELECT SUM(GREATEST(effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_inflow, COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_outflow, COALESCE(SUM(interest_income), 0)::bigint AS interest_income, COALESCE(SUM(cashback_income), 0)::bigint AS cashback_income FROM category_net`, values, ); const totalExpense = Number(totalsResult.rows[0].total_expense); const totalIncome = Number(totalsResult.rows[0].total_income); const cashInflow = Number(totalsResult.rows[0].cash_inflow); const cashOutflow = Number(totalsResult.rows[0].cash_outflow); const transferInflow = Number(totalsResult.rows[0].transfer_inflow); const transferOutflow = Number(totalsResult.rows[0].transfer_outflow); const interestIncome = Number(totalsResult.rows[0].interest_income); const cashbackIncome = Number(totalsResult.rows[0].cashback_income); const topResult = await db.query( `${analyticsTransactions(where)} SELECT category_id::bigint, category_name, GREATEST(-SUM(effective_amount), 0)::bigint AS amount FROM analytics_transactions WHERE analytic_type = 'expense' GROUP BY category_id, category_name HAVING SUM(effective_amount) < 0 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, cashInflow, cashOutflow, transferInflow, transferOutflow, cashNet: cashInflow - cashOutflow, interestIncome, cashbackIncome, topCategories, }; } export async function getByCategory( params: BaseParams, db: Queryable = pool, ): Promise { const { conditions, values } = buildBaseConditions(params, 1); const where = 'WHERE ' + conditions.join(' AND '); const { rows } = await db.query( `${analyticsTransactions(where)} SELECT category_id::bigint, category_name, GREATEST(-SUM(effective_amount), 0)::bigint AS amount, COUNT(*)::int AS tx_count FROM analytics_transactions WHERE analytic_type = 'expense' GROUP BY category_id, category_name HAVING SUM(effective_amount) < 0 ORDER BY amount DESC`, values, ); const total = rows.reduce((sum, row) => sum + Number(row.amount), 0); 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 }, db: Queryable = pool, ): 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 >= $1::date', 't.operation_at::date <= $2::date', '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) { if (params.categoryId === 0) txConditions.push('t.category_id IS NULL'); else { 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 db.query( `WITH periods AS ( SELECT gs::date AS period_start, ${periodEndExpr} AS period_end FROM generate_series(${truncExpr}, $2::date, '${intervalStr}'::interval) gs ), period_transactions AS ( SELECT p.period_start, p.period_end, COALESCE(c.type, t.direction) AS analytic_type, COALESCE(t.category_id, 0) AS category_id, ${effectiveAmount} AS effective_amount FROM periods p LEFT JOIN transactions t ON ${txWhere} LEFT JOIN categories c ON c.id = t.category_id ), period_category_net AS ( SELECT period_start, period_end, analytic_type, category_id, SUM(effective_amount)::bigint AS amount FROM period_transactions GROUP BY period_start, period_end, analytic_type, category_id ) SELECT period_start, period_end, COALESCE(SUM(GREATEST(-amount, 0)) FILTER (WHERE analytic_type = 'expense'), 0)::bigint AS expense_amount, COALESCE(SUM(GREATEST(amount, 0)) FILTER (WHERE analytic_type = 'income'), 0)::bigint AS income_amount, COALESCE(SUM(GREATEST(-amount, 0)) FILTER (WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_outflow FROM period_category_net GROUP BY period_start, period_end ORDER BY 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), transferOutflow: Number(r.transfer_outflow), })); }