Merge pull request 'feat(analytics): net cashflow reporting' (#26) from feature/net-analytics-cashflow into main

Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
admin committed 2026-08-19 11:24:35 +00:00
commit e78445888f
15 files changed
+254 -189

No files matched your search

+1
View File
@@ -23,4 +23,5 @@ history.xlsx
match_analysis.py
match_report.txt
statements/
temp/
.cursor/
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## [Frontend 0.9.0 / Backend 0.6.2] - 2026-08-19
### Changed
- Analytics now calculate net income and expenses by category type: refunds reduce the original expense category and internal transfers no longer affect P&L.
- Added a separate cash-flow summary with incoming, outgoing, and transfer amounts.
- Added unit and transactional SQL checks for analytics calculations.
## [0.8.7] - 2026-08-16
### Changed
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@family-budget/backend",
"version": "0.5.12",
"version": "0.6.2",
"private": true,
"scripts": {
"dev": "tsx watch src/app.ts",
@@ -8,6 +8,8 @@
"start": "node dist/app.js",
"migrate": "tsx src/db/migrate.ts",
"migrate:prod": "node dist/db/migrate.js",
"test:analytics": "tsx src/services/analyticsSemantics.test.ts",
"test:analytics:db": "NODE_ENV=test tsx src/services/analytics.integration.test.ts",
"test:llm": "tsx src/scripts/testLlm.ts"
},
"dependencies": {
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { pool } from '../db/pool';
import { getByCategory, getSummary, getTimeseries } from './analytics';
async function testQueries(): Promise<void> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const account = await client.query("INSERT INTO accounts (bank, account_number, currency) VALUES ('TEST', 'analytics-test-1', 'RUB') RETURNING id");
const otherAccount = await client.query("INSERT INTO accounts (bank, account_number, currency) VALUES ('TEST', 'analytics-test-2', 'RUB') RETURNING id");
const categories = await client.query("INSERT INTO categories (name, type) VALUES ('Тест расход', 'expense'), ('Тест доход', 'income'), ('Тест перевод', 'transfer') RETURNING id, type");
const accountId = Number(account.rows[0].id);
const otherAccountId = Number(otherAccount.rows[0].id);
const categoryId = Object.fromEntries(categories.rows.map((row) => [row.type, Number(row.id)]));
const insert = (account: number, at: string, amount: number, category: number, fingerprint: string) => client.query(
"INSERT INTO transactions (account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed) VALUES ($1, $2, $3, 0, 'test', CASE WHEN $3 < 0 THEN 'expense' ELSE 'income' END, $4, $5, TRUE)",
[account, at, amount, fingerprint, category],
);
await insert(accountId, '2026-07-01T12:00:00+03:00', -10_000, categoryId.expense, 'analytics-test-1');
await insert(accountId, '2026-07-02T12:00:00+03:00', 4_000, categoryId.expense, 'analytics-test-2');
await insert(accountId, '2026-07-03T12:00:00+03:00', 20_000, categoryId.income, 'analytics-test-3');
await insert(accountId, '2026-07-04T12:00:00+03:00', -5_000, categoryId.income, 'analytics-test-4');
await insert(accountId, '2026-07-05T12:00:00+03:00', -3_000, categoryId.transfer, 'analytics-test-5');
await insert(otherAccountId, '2026-07-01T12:00:00+03:00', -99_000, categoryId.expense, 'analytics-test-6');
await insert(accountId, '2026-08-01T12:00:00+03:00', -7_000, categoryId.expense, 'analytics-test-7');
const params = { from: '2026-07-01', to: '2026-07-31', accountId, onlyConfirmed: true };
const summary = await getSummary(params, client);
assert.deepEqual({ expense: summary.totalExpense, income: summary.totalIncome, net: summary.net, transferOut: summary.transferOutflow }, { expense: 6_000, income: 15_000, net: 9_000, transferOut: 3_000 });
assert.equal((await getSummary({ ...params, from: '2026-08-01', to: '2026-08-31' }, client)).totalExpense, 7_000);
assert.deepEqual((await getByCategory(params, client)).map((item) => item.amount), [6_000]);
const timeseries = await getTimeseries({ ...params, granularity: 'month' }, client);
assert.equal(timeseries[0].expenseAmount, 6_000);
assert.equal(timeseries[0].incomeAmount, 15_000);
} finally {
await client.query('ROLLBACK');
client.release();
}
}
testQueries().then(() => console.log('analytics SQL: OK')).finally(() => pool.end());
+95 -176
View File
@@ -1,4 +1,5 @@
import { pool } from '../db/pool';
import { effectiveAmountCase } from './analyticsSemantics';
import type {
AnalyticsSummaryResponse,
TopCategory,
@@ -7,6 +8,8 @@ import type {
Granularity,
} from '@family-budget/shared';
type Queryable = Pick<typeof pool, 'query'>;
interface BaseParams {
from: string;
to: string;
@@ -14,6 +17,24 @@ interface BaseParams {
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
FROM transactions t
LEFT JOIN categories c ON c.id = t.category_id
${where}
)`;
}
function buildBaseConditions(
params: BaseParams,
startIdx: number,
@@ -42,96 +63,45 @@ function buildBaseConditions(
return { conditions, values, nextIdx: idx };
}
export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryResponse> {
export async function getSummary(
params: BaseParams,
db: Queryable = pool,
): 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
const totalsResult = await db.query(
`${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, 'Без категории')
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,
@@ -148,68 +118,39 @@ export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryRe
totalExpense,
totalIncome,
net: totalIncome - totalExpense,
investmentOutflow,
investmentIncomeExcluded,
cashInflow,
cashOutflow,
transferInflow,
transferOutflow,
cashNet: cashInflow - cashOutflow,
topCategories,
};
}
export async function getByCategory(params: BaseParams): Promise<ByCategoryItem[]> {
export async function getByCategory(
params: BaseParams,
db: Queryable = pool,
): 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
)
const { rows } = await db.query(
`${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 ?? 'Без категории',
@@ -221,6 +162,7 @@ export async function getByCategory(params: BaseParams): Promise<ByCategoryItem[
export async function getTimeseries(
params: BaseParams & { categoryId?: number; granularity: Granularity },
db: Queryable = pool,
): Promise<TimeseriesItem[]> {
let truncExpr: string;
let intervalStr: string;
@@ -265,61 +207,38 @@ export async function getTimeseries(
const txWhere = txConditions.join(' AND ');
const { rows } = await pool.query(
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
),
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 +247,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),
}));
}
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import { getEffectiveAmount, getNetAmount } from './analyticsSemantics';
assert.equal(getNetAmount('expense', [
{ amountSigned: -10_000, commission: 0 },
{ amountSigned: 4_000, commission: 0 },
]), 6_000, 'возврат уменьшает расход своей категории');
assert.equal(getNetAmount('income', [
{ amountSigned: 20_000, commission: 0 },
{ amountSigned: -5_000, commission: 0 },
]), 15_000, 'корректировка уменьшает доход');
assert.equal(getNetAmount('transfer', [
{ amountSigned: -10_000, commission: 0 },
{ amountSigned: 10_000, commission: 0 },
]), 0, 'перевод не является доходом или расходом');
assert.equal(
getEffectiveAmount({ amountSigned: 0, commission: 500, description: 'Зачисление кэшбэка' }),
500,
'кэшбэк учитывается как поступление',
);
console.log('analytics semantics: OK');
@@ -0,0 +1,32 @@
export type AnalyticType = 'expense' | 'income' | 'transfer';
interface AmountInput {
amountSigned: number;
commission: number;
description?: string;
}
export function getEffectiveAmount({ amountSigned, commission, description = '' }: AmountInput): number {
if (amountSigned === 0 && commission > 0 && description.toLowerCase().includes('зачисление')) {
return commission;
}
return amountSigned < 0 ? amountSigned - commission : amountSigned + commission;
}
export function getNetAmount(type: AnalyticType, amounts: AmountInput[]): number {
const amount = amounts.reduce((sum, item) => sum + getEffectiveAmount(item), 0);
if (type === 'expense') return Math.max(-amount, 0);
if (type === 'income') return Math.max(amount, 0);
return 0;
}
export function effectiveAmountCase(alias: string): string {
return `
CASE
WHEN ${alias}.amount_signed = 0 AND ${alias}.commission > 0
AND ${alias}.description ILIKE '%зачисление%'
THEN ${alias}.commission
WHEN ${alias}.amount_signed < 0 THEN ${alias}.amount_signed - ${alias}.commission
ELSE ${alias}.amount_signed + ${alias}.commission
END`;
}
+25
View File
@@ -37,6 +37,26 @@
- Чистый результат (`net = totalIncome - totalExpense`).
- Топ-3–5 категорий по расходам (сумма, доля).
#### Нетто-правила
Тип назначенной категории определяет экономический смысл операции:
- `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 }
+6
View File
@@ -141,6 +141,12 @@ accountNumber|operationAt|amountSigned|commission|normalizedDescription
Список ключевых фраз для `"transfer"` может расширяться; в MVP используется фиксированный набор.
`direction` — исходная банковская классификация. В аналитике окончательный
экономический тип определяется типом назначенной категории: операция,
отнесённая к расходной категории, является расходом или возвратом независимо
от исходного `direction`; категория типа `transfer` исключает операцию из
реальных доходов и расходов.
### Импорт транзакций
Для каждой транзакции из массива `transactions`:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@family-budget/frontend",
"version": "0.8.7",
"version": "0.9.0",
"private": true,
"type": "module",
"scripts": {
+6 -4
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>
+2 -2
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]}
/>
+1 -1
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",
+1 -1
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",
+6 -3
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;
}