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:
commit
e78445888f
15 files changed
+254
-189
No files matched your search
@@ -23,4 +23,5 @@ history.xlsx
|
||||
match_analysis.py
|
||||
match_report.txt
|
||||
statements/
|
||||
temp/
|
||||
.cursor/
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -141,6 +141,12 @@ accountNumber|operationAt|amountSigned|commission|normalizedDescription
|
||||
|
||||
Список ключевых фраз для `"transfer"` может расширяться; в MVP используется фиксированный набор.
|
||||
|
||||
`direction` — исходная банковская классификация. В аналитике окончательный
|
||||
экономический тип определяется типом назначенной категории: операция,
|
||||
отнесённая к расходной категории, является расходом или возвратом независимо
|
||||
от исходного `direction`; категория типа `transfer` исключает операцию из
|
||||
реальных доходов и расходов.
|
||||
|
||||
### Импорт транзакций
|
||||
|
||||
Для каждой транзакции из массива `transactions`:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/frontend",
|
||||
"version": "0.8.7",
|
||||
"version": "0.9.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]}
|
||||
/>
|
||||
|
||||
Generated
+1
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user