Files
family_budget/backend/src/services/analytics.ts
Anton fccde4259d feat(analytics): account commission and investment transfers
Handle cashback commission imports, include commissions in analytics with separate investment metrics, and expose commission/version details in the UI.

Made-with: Cursor
2026-04-14 16:15:05 +03:00

334 lines
10 KiB
TypeScript

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<AnalyticsSummaryResponse> {
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<ByCategoryItem[]> {
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<TimeseriesItem[]> {
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),
}));
}