Compare commits

..

1 Commits

Author SHA1 Message Date
69f2cc8bd6 docs: update analytics validation changelog 2026-08-24 22:14:19 +03:00
12 changed files with 16 additions and 103 deletions

View File

@@ -1,17 +1,5 @@
# Changelog # Changelog
## [Frontend 0.11.2 / Backend 0.10.5 / Shared 0.5.1] - 2026-08-26
### Added
- Show cashback separately in the cash-flow summary alongside interest income.
## [Backend 0.10.3] - 2026-08-24
### Fixed
- Treat VTB savings-account deposits and closures as internal transfers when importing PDF-to-JSON statements.
## [Backend 0.10.2] - 2026-08-24 ## [Backend 0.10.2] - 2026-08-24
### Fixed ### Fixed

View File

@@ -1,6 +1,6 @@
{ {
"name": "@family-budget/backend", "name": "@family-budget/backend",
"version": "0.10.5", "version": "0.10.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "tsx watch src/app.ts", "dev": "tsx watch src/app.ts",
@@ -15,7 +15,6 @@
"test:transactions": "tsx src/services/transactions.test.ts", "test:transactions": "tsx src/services/transactions.test.ts",
"test:analytics:db": "NODE_ENV=test tsx src/services/analytics.integration.test.ts", "test:analytics:db": "NODE_ENV=test tsx src/services/analytics.integration.test.ts",
"test:import:db": "NODE_ENV=test tsx src/services/import.integration.test.ts", "test:import:db": "NODE_ENV=test tsx src/services/import.integration.test.ts",
"test:import:direction": "tsx src/services/import.test.ts",
"test:llm": "tsx src/scripts/testLlm.ts" "test:llm": "tsx src/scripts/testLlm.ts"
}, },
"dependencies": { "dependencies": {

View File

@@ -26,10 +26,6 @@ async function testQueries(): Promise<void> {
"INSERT INTO transactions (account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed) VALUES ($1, '2026-07-06T12:00:00+03:00', 1_500, 0, 'Начисление процентов', 'transfer', 'analytics-test-interest', $2, TRUE)", "INSERT INTO transactions (account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed) VALUES ($1, '2026-07-06T12:00:00+03:00', 1_500, 0, 'Начисление процентов', 'transfer', 'analytics-test-interest', $2, TRUE)",
[accountId, categoryId.transfer], [accountId, categoryId.transfer],
); );
await client.query(
"INSERT INTO transactions (account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed) VALUES ($1, '2026-07-06T12:00:00+03:00', 0, 200, 'Зачисление кэшбека', 'income', 'analytics-test-cashback', $2, TRUE)",
[accountId, categoryId.income],
);
await insert(otherAccountId, '2026-07-01T12:00:00+03:00', -99_000, categoryId.expense, 'analytics-test-6'); 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'); await insert(accountId, '2026-08-01T12:00:00+03:00', -7_000, categoryId.expense, 'analytics-test-7');
await client.query( await client.query(
@@ -39,7 +35,7 @@ async function testQueries(): Promise<void> {
const params = { from: '2026-07-01', to: '2026-07-31', accountId, onlyConfirmed: true }; const params = { from: '2026-07-01', to: '2026-07-31', accountId, onlyConfirmed: true };
const summary = await getSummary(params, client); const summary = await getSummary(params, client);
assert.deepEqual({ expense: summary.totalExpense, income: summary.totalIncome, net: summary.net, transferOut: summary.transferOutflow, interest: summary.interestIncome, cashback: summary.cashbackIncome }, { expense: 6_000, income: 15_200, net: 9_200, transferOut: 3_000, interest: 1_500, cashback: 200 }); assert.deepEqual({ expense: summary.totalExpense, income: summary.totalIncome, net: summary.net, transferOut: summary.transferOutflow, interest: summary.interestIncome }, { expense: 6_000, income: 15_000, net: 9_000, transferOut: 3_000, interest: 1_500 });
const categorySummary = await getSummary({ ...params, categoryId: categoryId.expense }, client); const categorySummary = await getSummary({ ...params, categoryId: categoryId.expense }, client);
assert.deepEqual({ expense: categorySummary.totalExpense, income: categorySummary.totalIncome }, { expense: 6_000, income: 0 }); assert.deepEqual({ expense: categorySummary.totalExpense, income: categorySummary.totalIncome }, { expense: 6_000, income: 0 });
const uncategorized = await getSummary({ ...params, categoryId: 0, onlyConfirmed: false }, client); const uncategorized = await getSummary({ ...params, categoryId: 0, onlyConfirmed: false }, client);

View File

@@ -33,11 +33,7 @@ function analyticsTransactions(where: string): string {
CASE WHEN a.account_type = 'savings' CASE WHEN a.account_type = 'savings'
AND ${effectiveAmount} > 0 AND ${effectiveAmount} > 0
AND (t.description ILIKE '%процент%' OR t.description ILIKE '%выплата %' OR t.description LIKE '%\%%' ESCAPE '\\') AND (t.description ILIKE '%процент%' OR t.description ILIKE '%выплата %' OR t.description LIKE '%\%%' ESCAPE '\\')
THEN ${effectiveAmount} ELSE 0 END AS interest_income, THEN ${effectiveAmount} ELSE 0 END AS interest_income
CASE WHEN t.amount_signed = 0
AND t.commission > 0
AND t.description ILIKE '%зачисление%'
THEN t.commission ELSE 0 END AS cashback_income
FROM transactions t FROM transactions t
LEFT JOIN categories c ON c.id = t.category_id LEFT JOIN categories c ON c.id = t.category_id
LEFT JOIN accounts a ON a.id = t.account_id LEFT JOIN accounts a ON a.id = t.account_id
@@ -91,8 +87,7 @@ export async function getSummary(
`${analyticsTransactions(where)}, `${analyticsTransactions(where)},
category_net AS ( category_net AS (
SELECT category_id, category_name, analytic_type, SUM(effective_amount)::bigint AS amount, SELECT category_id, category_name, analytic_type, SUM(effective_amount)::bigint AS amount,
SUM(interest_income)::bigint AS interest_income, SUM(interest_income)::bigint AS interest_income
SUM(cashback_income)::bigint AS cashback_income
FROM analytics_transactions FROM analytics_transactions
GROUP BY category_id, category_name, analytic_type GROUP BY category_id, category_name, analytic_type
) )
@@ -103,8 +98,7 @@ export async function getSummary(
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), 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_inflow,
COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_outflow, COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_outflow,
COALESCE(SUM(interest_income), 0)::bigint AS interest_income, COALESCE(SUM(interest_income), 0)::bigint AS interest_income
COALESCE(SUM(cashback_income), 0)::bigint AS cashback_income
FROM category_net`, FROM category_net`,
values, values,
); );
@@ -116,7 +110,6 @@ export async function getSummary(
const transferInflow = Number(totalsResult.rows[0].transfer_inflow); const transferInflow = Number(totalsResult.rows[0].transfer_inflow);
const transferOutflow = Number(totalsResult.rows[0].transfer_outflow); const transferOutflow = Number(totalsResult.rows[0].transfer_outflow);
const interestIncome = Number(totalsResult.rows[0].interest_income); const interestIncome = Number(totalsResult.rows[0].interest_income);
const cashbackIncome = Number(totalsResult.rows[0].cashback_income);
const topResult = await db.query( const topResult = await db.query(
`${analyticsTransactions(where)} `${analyticsTransactions(where)}
@@ -147,7 +140,6 @@ export async function getSummary(
transferOutflow, transferOutflow,
cashNet: cashInflow - cashOutflow, cashNet: cashInflow - cashOutflow,
interestIncome, interestIncome,
cashbackIncome,
topCategories, topCategories,
}; };
} }

View File

@@ -8,21 +8,6 @@ const makeStatement = (sourceIds: string[]) => ({
transactions: sourceIds.map((sourceId) => ({ operationAt: '2026-08-20T10:00:00+03:00', amountSigned: 100, commission: 0, description: 'Пополнение', sourceId })), transactions: sourceIds.map((sourceId) => ({ operationAt: '2026-08-20T10:00:00+03:00', amountSigned: 100, commission: 0, description: 'Пополнение', sourceId })),
}); });
const duplicateStatement = {
schemaVersion: '1.0', bank: 'TEST',
statement: { accountNumber: 'fingerprint-test', currency: 'RUB', openingBalance: 0, closingBalance: 200, exportedAt: '2026-08-20T12:00:00+03:00' },
transactions: Array.from({ length: 2 }, () => ({ operationAt: '2026-08-20T00:00:00+03:00', amountSigned: 100, commission: 0, description: 'Пополнение' })),
};
const overlapStatement = {
...duplicateStatement,
statement: { ...duplicateStatement.statement, accountNumber: 'overlap-test' },
};
const singleStatement = {
...overlapStatement,
statement: { ...overlapStatement.statement, closingBalance: 100 },
transactions: overlapStatement.transactions.slice(0, 1),
};
async function run(): Promise<void> { async function run(): Promise<void> {
try { try {
await importStatement(makeStatement(['first', 'first-second'])); await importStatement(makeStatement(['first', 'first-second']));
@@ -33,32 +18,11 @@ async function run(): Promise<void> {
await importStatement(makeStatement(['second'])); await importStatement(makeStatement(['second']));
const result = await pool.query('SELECT account_type, status FROM accounts WHERE id = $1', [account.rows[0].id]); const result = await pool.query('SELECT account_type, status FROM accounts WHERE id = $1', [account.rows[0].id]);
assert.deepEqual(result.rows[0], { account_type: 'savings', status: 'closed' }); assert.deepEqual(result.rows[0], { account_type: 'savings', status: 'closed' });
const duplicateResult = await importStatement(duplicateStatement);
if ('status' in duplicateResult) throw new Error(duplicateResult.message);
assert.deepEqual(duplicateResult, {
accountId: duplicateResult.accountId,
isNewAccount: true,
accountNumberMasked: 'fingerp******test',
imported: 2,
duplicatesSkipped: 0,
totalInFile: 2,
});
await importStatement(singleStatement);
const overlapResult = await importStatement(overlapStatement);
if ('status' in overlapResult) throw new Error(overlapResult.message);
assert.deepEqual(overlapResult, {
accountId: overlapResult.accountId,
isNewAccount: false,
accountNumberMasked: 'overla******test',
imported: 1,
duplicatesSkipped: 1,
totalInFile: 2,
});
console.log('import metadata SQL: OK'); console.log('import metadata SQL: OK');
} finally { } finally {
await pool.query("DELETE FROM transactions WHERE account_id IN (SELECT id FROM accounts WHERE bank = 'TEST' AND account_number IN ('metadata-test', 'fingerprint-test', 'overlap-test'))"); await pool.query('DELETE FROM transactions WHERE account_id IN (SELECT id FROM accounts WHERE bank = \'TEST\' AND account_number = \'metadata-test\')');
await pool.query("DELETE FROM imports WHERE account_id IN (SELECT id FROM accounts WHERE bank = 'TEST' AND account_number IN ('metadata-test', 'fingerprint-test', 'overlap-test'))"); await pool.query("DELETE FROM imports WHERE account_id IN (SELECT id FROM accounts WHERE bank = 'TEST' AND account_number = 'metadata-test')");
await pool.query("DELETE FROM accounts WHERE bank = 'TEST' AND account_number IN ('metadata-test', 'fingerprint-test', 'overlap-test')"); await pool.query("DELETE FROM accounts WHERE bank = 'TEST' AND account_number = 'metadata-test'");
await pool.end(); await pool.end();
} }
} }

View File

@@ -1,12 +0,0 @@
import assert from 'node:assert/strict';
import { computeFingerprint, determineDirection } from './import';
assert.equal(determineDirection(1, 'Перечисление средств на счет N 123 со счета N 456'), 'transfer');
assert.equal(determineDirection(1, 'Перечисление средств на вклад N 123'), 'transfer');
assert.equal(determineDirection(-1, 'Перечисление суммы вклада при закрытии'), 'transfer');
assert.equal(determineDirection(-1, 'Оплата покупки'), 'expense');
assert.equal(determineDirection(1, 'Выплата процентов'), 'income');
const duplicateTransaction = { operationAt: '2026-08-20T00:00:00+03:00', amountSigned: 100, commission: 0, description: 'Пополнение' };
assert.notEqual(computeFingerprint('fingerprint-test', duplicateTransaction, 0), computeFingerprint('fingerprint-test', duplicateTransaction, 1));
console.log('import direction: OK');

View File

@@ -6,18 +6,14 @@ import type { StatementFile, ImportStatementResponse } from '@family-budget/shar
const TRANSFER_PHRASES = [ const TRANSFER_PHRASES = [
'перевод между своими счетами', 'перевод между своими счетами',
'перевод средств на счет', 'перевод средств на счет',
'перечисление средств на счет',
'перечисление средств на вклад',
'перечисление суммы вклада при закрытии',
'внутри втб', 'внутри втб',
]; ];
const CASHBACK_KEYWORD = 'зачисление'; const CASHBACK_KEYWORD = 'зачисление';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function computeFingerprint( function computeFingerprint(
accountNumber: string, accountNumber: string,
tx: { operationAt: string; amountSigned: number; commission: number; description: string; sourceId?: string }, tx: { operationAt: string; amountSigned: number; commission: number; description: string; sourceId?: string },
sourcePosition?: number,
): string { ): string {
if (tx.sourceId) { if (tx.sourceId) {
const raw = [accountNumber, tx.sourceId.trim()].join('|'); const raw = [accountNumber, tx.sourceId.trim()].join('|');
@@ -30,13 +26,12 @@ export function computeFingerprint(
String(tx.amountSigned), String(tx.amountSigned),
String(tx.commission), String(tx.commission),
tx.description.trim(), tx.description.trim(),
...(sourcePosition === undefined ? [] : [String(sourcePosition)]),
].join('|'); ].join('|');
const hash = crypto.createHash('sha256').update(raw, 'utf-8').digest('hex'); const hash = crypto.createHash('sha256').update(raw, 'utf-8').digest('hex');
return `sha256:${hash}`; return `sha256:${hash}`;
} }
export function determineDirection(amountSigned: number, description: string): string { function determineDirection(amountSigned: number, description: string): string {
const lower = description.toLowerCase(); const lower = description.toLowerCase();
for (const phrase of TRANSFER_PHRASES) { for (const phrase of TRANSFER_PHRASES) {
if (lower.includes(phrase)) return 'transfer'; if (lower.includes(phrase)) return 'transfer';
@@ -141,7 +136,7 @@ function validateSemantics(data: StatementFile): ValidationError | null {
const operationIds = new Set<string>(); const operationIds = new Set<string>();
for (let i = 0; i < data.transactions.length; i++) { for (let i = 0; i < data.transactions.length; i++) {
const fp = computeFingerprint(data.statement.accountNumber, data.transactions[i]); const fp = computeFingerprint(data.statement.accountNumber, data.transactions[i]);
if (data.transactions[i].sourceId && fps.has(fp)) { if (fps.has(fp)) {
return { status: 422, error: 'VALIDATION_ERROR', message: `Duplicate fingerprint found within file at transaction index ${i}` }; return { status: 422, error: 'VALIDATION_ERROR', message: `Duplicate fingerprint found within file at transaction index ${i}` };
} }
fps.add(fp); fps.add(fp);
@@ -223,15 +218,9 @@ export async function importStatement(
// Insert transactions // Insert transactions
const insertedIds: number[] = []; const insertedIds: number[] = [];
const fallbackFingerprintOccurrences = new Map<string, number>();
for (const [sourcePosition, tx] of data.transactions.entries()) { for (const [sourcePosition, tx] of data.transactions.entries()) {
const fallbackFingerprint = computeFingerprint(data.statement.accountNumber, tx); const fp = computeFingerprint(data.statement.accountNumber, tx);
const occurrence = fallbackFingerprintOccurrences.get(fallbackFingerprint) ?? 0;
fallbackFingerprintOccurrences.set(fallbackFingerprint, occurrence + 1);
const fp = !tx.sourceId && occurrence > 0
? computeFingerprint(data.statement.accountNumber, tx, occurrence)
: fallbackFingerprint;
const isCashbackCommissionImport = const isCashbackCommissionImport =
tx.amountSigned === 0 && tx.amountSigned === 0 &&
tx.commission > 0 && tx.commission > 0 &&

View File

@@ -91,7 +91,7 @@
- `statement.currency` соответствует допустимому коду валюты (MVP: `"RUB"`). - `statement.currency` соответствует допустимому коду валюты (MVP: `"RUB"`).
- `operationAt` у всех транзакций — валидная дата (парсится без ошибок). - `operationAt` у всех транзакций — валидная дата (парсится без ошибок).
- Повторяющиеся `sourceId` внутри одного файла отклоняются; одинаковые операции без `sourceId` различаются по позиции в массиве `transactions`. - Отсутствуют дубликаты fingerprint внутри одного файла.
Ответ при ошибке: Ответ при ошибке:
@@ -120,11 +120,10 @@
Для каждой транзакции вычисляется SHA-256 от полей, соединённых разделителем `|`: Для каждой транзакции вычисляется SHA-256 от полей, соединённых разделителем `|`:
```text ```text
accountNumber|operationAt|amountSigned|commission|normalizedDescription[|sourcePosition] accountNumber|operationAt|amountSigned|commission|normalizedDescription
``` ```
- `normalizedDescription``description` после `trim`. - `normalizedDescription``description` после `trim`.
- `sourcePosition` — порядковый номер повторяющейся операции в массиве `transactions`; добавляется, только если одинаковые операции без `sourceId` повторяются в одном файле.
- Суммы подставляются в том виде, в котором пришли в JSON (числовое представление). - Суммы подставляются в том виде, в котором пришли в JSON (числовое представление).
- Разделитель `|` исключает коллизии при склейке полей разной длины. - Разделитель `|` исключает коллизии при склейке полей разной длины.

View File

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

View File

@@ -41,7 +41,6 @@ export function SummaryCards({ summary }: Props) {
<div className="summary__subvalue">Поступило: {formatAmount(summary.cashInflow)}</div> <div className="summary__subvalue">Поступило: {formatAmount(summary.cashInflow)}</div>
<div className="summary__subvalue">Списано: {formatAmount(summary.cashOutflow)}</div> <div className="summary__subvalue">Списано: {formatAmount(summary.cashOutflow)}</div>
<div className="summary__subvalue">Доход от процентов: {formatAmount(summary.interestIncome)}</div> <div className="summary__subvalue">Доход от процентов: {formatAmount(summary.interestIncome)}</div>
<div className="summary__subvalue">Кэшбек: {formatAmount(summary.cashbackIncome)}</div>
{(summary.transferInflow > 0 || summary.transferOutflow > 0) && ( {(summary.transferInflow > 0 || summary.transferOutflow > 0) && (
<div className="summary__subvalue"> <div className="summary__subvalue">
Переводы: {formatAmount(summary.transferInflow)} / {formatAmount(summary.transferOutflow)} Переводы: {formatAmount(summary.transferInflow)} / {formatAmount(summary.transferOutflow)}

View File

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

View File

@@ -25,7 +25,6 @@ export interface AnalyticsSummaryResponse {
transferOutflow: number; transferOutflow: number;
cashNet: number; cashNet: number;
interestIncome: number; interestIncome: number;
cashbackIncome: number;
topCategories: TopCategory[]; topCategories: TopCategory[];
} }