diff --git a/.gitignore b/.gitignore index 585724f..c225609 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ history.xlsx match_analysis.py match_report.txt statements/ +temp/ .cursor/ diff --git a/backend/package.json b/backend/package.json index 1560ef5..7353b00 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/services/analytics.ts b/backend/src/services/analytics.ts index 1e0b47e..1567cc4 100644 --- a/backend/src/services/analytics.ts +++ b/backend/src/services/analytics.ts @@ -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 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 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), })); } diff --git a/docs/backlog/analytics.md b/docs/backlog/analytics.md index 4fb9b21..2e776ca 100644 --- a/docs/backlog/analytics.md +++ b/docs/backlog/analytics.md @@ -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 } diff --git a/docs/backlog/api_import.md b/docs/backlog/api_import.md index 47acd41..9c21536 100644 --- a/docs/backlog/api_import.md +++ b/docs/backlog/api_import.md @@ -141,6 +141,12 @@ accountNumber|operationAt|amountSigned|commission|normalizedDescription Список ключевых фраз для `"transfer"` может расширяться; в MVP используется фиксированный набор. +`direction` — исходная банковская классификация. В аналитике окончательный +экономический тип определяется типом назначенной категории: операция, +отнесённая к расходной категории, является расходом или возвратом независимо +от исходного `direction`; категория типа `transfer` исключает операцию из +реальных доходов и расходов. + ### Импорт транзакций Для каждой транзакции из массива `transactions`: diff --git a/frontend/package.json b/frontend/package.json index fc07db7..68b40eb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/frontend", - "version": "0.8.7", + "version": "0.9.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/SummaryCards.tsx b/frontend/src/components/SummaryCards.tsx index 50ffb81..7b23fb7 100644 --- a/frontend/src/components/SummaryCards.tsx +++ b/frontend/src/components/SummaryCards.tsx @@ -34,13 +34,15 @@ export function SummaryCards({ summary }: Props) {
-
На инвестиции
+
Движение ДС
- {formatAmount(summary.investmentOutflow)} + {formatAmount(summary.cashNet)}
- {summary.investmentIncomeExcluded > 0 && ( +
Поступило: {formatAmount(summary.cashInflow)}
+
Списано: {formatAmount(summary.cashOutflow)}
+ {(summary.transferInflow > 0 || summary.transferOutflow > 0) && (
- Исключено из доходов: {formatAmount(summary.investmentIncomeExcluded)} + Переводы: {formatAmount(summary.transferInflow)} / {formatAmount(summary.transferOutflow)}
)}
diff --git a/frontend/src/components/TimeseriesChart.tsx b/frontend/src/components/TimeseriesChart.tsx index 8449822..49585a2 100644 --- a/frontend/src/components/TimeseriesChart.tsx +++ b/frontend/src/components/TimeseriesChart.tsx @@ -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]} /> diff --git a/package-lock.json b/package-lock.json index 5310acc..4b83152 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/shared/package.json b/shared/package.json index ba206ae..e35f7c7 100644 --- a/shared/package.json +++ b/shared/package.json @@ -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", diff --git a/shared/src/types/analytics.ts b/shared/src/types/analytics.ts index c4bd69b..42ea653 100644 --- a/shared/src/types/analytics.ts +++ b/shared/src/types/analytics.ts @@ -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; }