feat(analytics): add net cashflow reporting

This commit is contained in:
2026-08-18 23:09:40 +03:00
parent ea70be8e5a
commit a79629a60d
11 changed files with 136 additions and 183 deletions

1
.gitignore vendored
View File

@@ -23,4 +23,5 @@ history.xlsx
match_analysis.py
match_report.txt
statements/
temp/
.cursor/

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/backend",
"version": "0.5.12",
"version": "0.6.0",
"private": true,
"scripts": {
"dev": "tsx watch src/app.ts",

View File

@@ -14,6 +14,31 @@ interface BaseParams {
onlyConfirmed?: boolean;
}
const effectiveAmount = `
CASE
WHEN t.amount_signed = 0 AND t.commission > 0
AND t.description ILIKE '%зачисление%'
THEN t.commission
WHEN t.amount_signed < 0 THEN t.amount_signed - t.commission
ELSE t.amount_signed + t.commission
END`;
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
FROM transactions t
LEFT JOIN categories c ON c.id = t.category_id
${where}
)`;
}
function buildBaseConditions(
params: BaseParams,
startIdx: number,
@@ -47,91 +72,37 @@ export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryRe
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
`${analyticsTransactions(where)},
category_net AS (
SELECT category_id, category_name, analytic_type, SUM(effective_amount)::bigint AS amount
FROM analytics_transactions
GROUP BY category_id, category_name, analytic_type
)
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}`,
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
FROM category_net`,
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 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 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, 'Без категории')
`${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,
@@ -148,8 +119,11 @@ export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryRe
totalExpense,
totalIncome,
net: totalIncome - totalExpense,
investmentOutflow,
investmentIncomeExcluded,
cashInflow,
cashOutflow,
transferInflow,
transferOutflow,
cashNet: cashInflow - cashOutflow,
topCategories,
};
}
@@ -158,58 +132,23 @@ 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
)
`${analyticsTransactions(where)}
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,
category_id::bigint,
category_name,
GREATEST(-SUM(effective_amount), 0)::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, 'Без категории')
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 ?? 'Без категории',
@@ -272,54 +211,31 @@ export async function getTimeseries(
${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
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
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`,
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,
);
@@ -328,6 +244,6 @@ export async function getTimeseries(
periodEnd: r.period_end.toISOString().slice(0, 10),
expenseAmount: Number(r.expense_amount),
incomeAmount: Number(r.income_amount),
investmentOutflow: Number(r.investment_outflow),
transferOutflow: Number(r.transfer_outflow),
}));
}

View File

@@ -37,6 +37,26 @@
- Чистый результат (`net = totalIncome - totalExpense`).
- Топ-35 категорий по расходам (сумма, доля).
#### Нетто-правила
Тип назначенной категории определяет экономический смысл операции:
- `expense`: отрицательная сумма увеличивает расход, положительная сумма
(возврат) уменьшает его; в отчёте категория не может стать отрицательным
расходом;
- `income`: положительная сумма увеличивает доход, отрицательная уменьшает
его;
- `transfer`: не учитывается в реальных доходах и расходах.
Если возврат не удалось определить правилом, пользователь вручную назначает
ему ту же расходную категорию, что у исходной покупки. Отдельная категория
«Возврат» и связывание двух операций не требуются.
Для контроля остатков сводка отдельно показывает движение денежных средств:
входящий и исходящий поток, внутренние переводы и чистое изменение денег.
Переводы между картой, накопительными и инвестиционными счетами остаются в
этом блоке, но не искажают доходы и расходы.
Реализуется через эндпоинт `GET /api/analytics/summary`.
Параметры:
@@ -52,6 +72,11 @@
"totalExpense": 12345600,
"totalIncome": 20000000,
"net": 7654400,
"cashInflow": 26000000,
"cashOutflow": 18345600,
"transferInflow": 5000000,
"transferOutflow": 5000000,
"cashNet": 7654400,
"topCategories": [
{ "categoryId": 1, "categoryName": "Продукты", "amount": 4500000, "share": 0.36 },
{ "categoryId": 2, "categoryName": "ЖКХ", "amount": 2500000, "share": 0.20 }

View File

@@ -141,6 +141,12 @@ accountNumber|operationAt|amountSigned|commission|normalizedDescription
Список ключевых фраз для `"transfer"` может расширяться; в MVP используется фиксированный набор.
`direction` — исходная банковская классификация. В аналитике окончательный
экономический тип определяется типом назначенной категории: операция,
отнесённая к расходной категории, является расходом или возвратом независимо
от исходного `direction`; категория типа `transfer` исключает операцию из
реальных доходов и расходов.
### Импорт транзакций
Для каждой транзакции из массива `transactions`:

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/frontend",
"version": "0.8.7",
"version": "0.9.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -34,13 +34,15 @@ export function SummaryCards({ summary }: Props) {
</div>
<div className="summary__card summary__card--investments">
<div className="summary__label">На инвестиции</div>
<div className="summary__label">Движение ДС</div>
<div className="summary__value">
{formatAmount(summary.investmentOutflow)}
{formatAmount(summary.cashNet)}
</div>
{summary.investmentIncomeExcluded > 0 && (
<div className="summary__subvalue">Поступило: {formatAmount(summary.cashInflow)}</div>
<div className="summary__subvalue">Списано: {formatAmount(summary.cashOutflow)}</div>
{(summary.transferInflow > 0 || summary.transferOutflow > 0) && (
<div className="summary__subvalue">
Исключено из доходов: {formatAmount(summary.investmentIncomeExcluded)}
Переводы: {formatAmount(summary.transferInflow)} / {formatAmount(summary.transferOutflow)}
</div>
)}
</div>

View File

@@ -33,7 +33,7 @@ export function TimeseriesChart({ data }: Props) {
period: item.periodStart,
Расходы: Math.abs(item.expenseAmount) / 100,
Доходы: item.incomeAmount / 100,
Инвестиции: Math.abs(item.investmentOutflow) / 100,
Переводы: item.transferOutflow / 100,
}));
return (
@@ -81,7 +81,7 @@ export function TimeseriesChart({ data }: Props) {
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="Инвестиции"
dataKey="Переводы"
fill="var(--color-warning)"
radius={[4, 4, 0, 0]}
/>

2
package-lock.json generated
View File

@@ -64,7 +64,7 @@
},
"frontend": {
"name": "@family-budget/frontend",
"version": "0.8.7",
"version": "0.9.0",
"dependencies": {
"@family-budget/shared": "*",
"react": "^19.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/shared",
"version": "0.1.1",
"version": "0.2.0",
"private": true,
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -18,8 +18,11 @@ export interface AnalyticsSummaryResponse {
totalExpense: number;
totalIncome: number;
net: number;
investmentOutflow: number;
investmentIncomeExcluded: number;
cashInflow: number;
cashOutflow: number;
transferInflow: number;
transferOutflow: number;
cashNet: number;
topCategories: TopCategory[];
}
@@ -52,5 +55,5 @@ export interface TimeseriesItem {
periodEnd: string;
expenseAmount: number;
incomeAmount: number;
investmentOutflow: number;
transferOutflow: number;
}