Compare commits

..

21 Commits

Author SHA1 Message Date
ef7732285b fix: limit broker XLSX extraction 2026-08-26 23:45:09 +03:00
12d096beeb fix: make broker XLSX import atomic 2026-08-26 23:42:26 +03:00
000c77f162 feat: import VTB broker XLSX reports 2026-08-26 23:36:32 +03:00
4bd3436ef6 Merge pull request 'Добавить импорт JSON брокерского портфеля' (#45) from feature/broker-report-import into main
Reviewed-on: #45
2026-08-26 20:30:14 +00:00
84d6044d98 docs: add broker portfolio import changelog 2026-08-26 23:28:36 +03:00
c83a6d8d81 feat: import broker portfolio JSON 2026-08-26 23:26:15 +03:00
361a07d4da Merge pull request 'Выделить кэшбек в движении денежных средств' (#44) from feature/analytics-cashback-income into main
Reviewed-on: #44
2026-08-26 20:23:15 +00:00
1dc0e48348 docs: add cashback summary changelog 2026-08-26 07:29:12 +03:00
027fbedb8c feat: show cashback in cash flow summary 2026-08-26 07:26:14 +03:00
a1e93a7c1f Merge pull request 'Исправить импорт одинаковых операций из JSON' (#42) from fix/json-import-duplicates into main
Reviewed-on: #42
2026-08-25 21:49:31 +00:00
3f5681074e fix: preserve overlapping import deduplication 2026-08-26 00:43:43 +03:00
62519f80cd fix: import identical JSON transactions 2026-08-26 00:39:55 +03:00
45f0561fd6 Merge pull request 'Исправить переводы из закрытых накопительных счетов ВТБ' (#40) from fix/vtb-savings-transfers into main
Reviewed-on: #40
2026-08-24 20:32:52 +00:00
fab929fd68 docs: update VTB transfer changelog 2026-08-24 23:31:15 +03:00
c4ce2b9d6b fix: classify VTB savings transfers 2026-08-24 23:25:43 +03:00
c4f681c9b0 Merge pull request 'Дополнить changelog валидации аналитики' (#39) from fix/analytics-validation-changelog into main
Reviewed-on: #39
2026-08-24 19:17:36 +00:00
dd21e20dc6 docs: update analytics validation changelog 2026-08-24 22:15:56 +03:00
29c4acd0a9 Merge pull request 'Исправить валидацию фильтров аналитики' (#38) from fix/analytics-query-validation into main
Reviewed-on: #38
2026-08-24 19:09:48 +00:00
8939843462 fix: validate analytics filter ids 2026-08-24 22:06:24 +03:00
ce162855f9 Merge pull request 'Исправить блокеры аналитики после PR #35' (#37) from feature/analytics-fixes into main
Reviewed-on: #37
2026-08-24 18:59:50 +00:00
cce2ddcf41 Merge pull request 'Откатить PR #35 до исправления замечаний' (#36) from revert/analytics-category-filter into main
Reviewed-on: #36
2026-08-21 04:21:23 +00:00
22 changed files with 526 additions and 58 deletions

View File

@@ -1,5 +1,35 @@
# Changelog # Changelog
## [Frontend 0.12.0 / Backend 0.11.0 / Shared 0.6.0] - 2026-08-26
### Added
- Import VTB Broker XLSX reports with cash movements, trades, and positions in one operation.
## [Frontend 0.11.3] - 2026-08-26
### Added
- Import broker portfolio JSON files and show their trade and position results.
## [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
### Fixed
- Analytics endpoints now reject invalid account and category filter IDs with a validation error.
## [Frontend 0.11.1] - 2026-08-21 ## [Frontend 0.11.1] - 2026-08-21
### Fixed ### Fixed

View File

@@ -1,6 +1,6 @@
{ {
"name": "@family-budget/backend", "name": "@family-budget/backend",
"version": "0.10.1", "version": "0.11.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "tsx watch src/app.ts", "dev": "tsx watch src/app.ts",
@@ -9,11 +9,14 @@
"migrate": "tsx src/db/migrate.ts", "migrate": "tsx src/db/migrate.ts",
"migrate:prod": "node dist/db/migrate.js", "migrate:prod": "node dist/db/migrate.js",
"test:analytics": "tsx src/services/analyticsSemantics.test.ts", "test:analytics": "tsx src/services/analyticsSemantics.test.ts",
"test:analytics:query": "tsx src/routes/analytics.test.ts",
"test:portfolio": "tsx src/services/portfolio.test.ts", "test:portfolio": "tsx src/services/portfolio.test.ts",
"test:portfolio:db": "NODE_ENV=test tsx src/services/portfolio.integration.test.ts", "test:portfolio:db": "NODE_ENV=test tsx src/services/portfolio.integration.test.ts",
"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:broker:xlsx": "tsx src/services/vtbBrokerXlsx.test.ts",
"test:llm": "tsx src/scripts/testLlm.ts" "test:llm": "tsx src/scripts/testLlm.ts"
}, },
"dependencies": { "dependencies": {

View File

@@ -0,0 +1,12 @@
import assert from 'node:assert/strict';
import { parseOptionalId } from './analytics';
assert.equal(parseOptionalId(undefined, 0), undefined);
assert.equal(parseOptionalId('0', 0), 0);
assert.equal(parseOptionalId('1', 1), 1);
assert.equal(parseOptionalId('0', 1), null);
assert.equal(parseOptionalId('abc', 0), null);
assert.equal(parseOptionalId('1.5', 0), null);
assert.equal(parseOptionalId(['1'], 0), null);
console.log('analytics query validation: OK');

View File

@@ -5,6 +5,30 @@ import type { Granularity } from '@family-budget/shared';
const router = Router(); const router = Router();
export function parseOptionalId(value: unknown, minimum: number): number | null | undefined {
if (value === undefined) return undefined;
if (typeof value !== 'string' || !/^\d+$/.test(value)) return null;
const id = Number(value);
return Number.isSafeInteger(id) && id >= minimum ? id : null;
}
router.use((req, res, next) => {
const accountId = parseOptionalId(req.query.accountId, 1);
if (accountId === null) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'accountId must be a positive integer' });
return;
}
const categoryId = parseOptionalId(req.query.categoryId, 0);
if (categoryId === null) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'categoryId must be a non-negative integer' });
return;
}
next();
});
router.get( router.get(
'/summary', '/summary',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {

View File

@@ -1,11 +1,14 @@
import { Router } from 'express'; import { Router } from 'express';
import multer from 'multer'; import multer from 'multer';
import { pool } from '../db/pool';
import { asyncHandler } from '../utils'; import { asyncHandler } from '../utils';
import { importStatement, isValidationError } from '../services/import'; import { importStatement, isValidationError } from '../services/import';
import { import {
convertPdfToStatement, convertPdfToStatement,
isPdfConversionError, isPdfConversionError,
} from '../services/pdfToStatement'; } from '../services/pdfToStatement';
import { importPortfolio } from '../services/portfolio';
import { convertVtbBrokerXlsx } from '../services/vtbBrokerXlsx';
const upload = multer({ const upload = multer({
storage: multer.memoryStorage(), storage: multer.memoryStorage(),
@@ -28,6 +31,10 @@ function isJsonFile(file: { mimetype: string; originalname: string }): boolean {
); );
} }
function isXlsxFile(file: { mimetype: string; originalname: string }): boolean {
return file.mimetype === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || file.originalname.toLowerCase().endsWith('.xlsx');
}
const router = Router(); const router = Router();
router.post( router.post(
@@ -87,4 +94,41 @@ router.post(
}), }),
); );
router.post(
'/broker',
upload.single('file'),
asyncHandler(async (req, res) => {
const file = req.file;
if (!file || !isXlsxFile(file)) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'Допустим только XLSX-отчёт ВТБ Брокер' });
return;
}
let converted;
try {
converted = convertVtbBrokerXlsx(file.buffer);
} catch (error) {
res.status(422).json({ error: 'VALIDATION_ERROR', message: error instanceof Error ? error.message : 'Не удалось обработать XLSX-отчёт' });
return;
}
const client = await pool.connect();
try {
await client.query('BEGIN');
const portfolio = await importPortfolio(converted.portfolio, pool, client);
const cash = await importStatement(converted.cash, pool, client);
if (isValidationError(cash)) {
await client.query('ROLLBACK');
res.status(cash.status).json({ error: cash.error, message: cash.message });
return;
}
await client.query('COMMIT');
res.json({ portfolio, cash });
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}),
);
export default router; export default router;

View File

@@ -26,6 +26,10 @@ 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(
@@ -35,7 +39,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 }, { expense: 6_000, income: 15_000, net: 9_000, transferOut: 3_000, interest: 1_500 }); 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 });
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,7 +33,11 @@ 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
@@ -87,7 +91,8 @@ 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
) )
@@ -98,7 +103,8 @@ 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,
); );
@@ -110,6 +116,7 @@ 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)}
@@ -140,6 +147,7 @@ export async function getSummary(
transferOutflow, transferOutflow,
cashNet: cashInflow - cashOutflow, cashNet: cashInflow - cashOutflow,
interestIncome, interestIncome,
cashbackIncome,
topCategories, topCategories,
}; };
} }

View File

@@ -8,6 +8,21 @@ 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']));
@@ -18,11 +33,32 @@ 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 = \'metadata-test\')'); 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 imports 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 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.end(); await pool.end();
} }
} }

View File

@@ -0,0 +1,12 @@
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

@@ -1,4 +1,5 @@
import crypto from 'crypto'; import crypto from 'crypto';
import type { PoolClient } from 'pg';
import { pool } from '../db/pool'; import { pool } from '../db/pool';
import { maskAccountNumber } from '../utils'; import { maskAccountNumber } from '../utils';
import type { StatementFile, ImportStatementResponse } from '@family-budget/shared'; import type { StatementFile, ImportStatementResponse } from '@family-budget/shared';
@@ -6,14 +7,18 @@ 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;
function computeFingerprint( export 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('|');
@@ -26,12 +31,13 @@ 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}`;
} }
function determineDirection(amountSigned: number, description: string): string { export 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';
@@ -136,7 +142,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 (fps.has(fp)) { if (data.transactions[i].sourceId && 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);
@@ -155,6 +161,7 @@ function validateSemantics(data: StatementFile): ValidationError | null {
export async function importStatement( export async function importStatement(
body: unknown, body: unknown,
db: Pick<typeof pool, 'connect'> = pool, db: Pick<typeof pool, 'connect'> = pool,
transactionClient?: PoolClient,
): Promise<ImportStatementResponse | ValidationError> { ): Promise<ImportStatementResponse | ValidationError> {
const structErr = validateStructure(body); const structErr = validateStructure(body);
if (structErr) return structErr; if (structErr) return structErr;
@@ -163,9 +170,10 @@ export async function importStatement(
const semErr = validateSemantics(data); const semErr = validateSemantics(data);
if (semErr) return semErr; if (semErr) return semErr;
const client = await db.connect(); const client = transactionClient ?? await db.connect();
const ownsTransaction = transactionClient == null;
try { try {
await client.query('BEGIN'); if (ownsTransaction) await client.query('BEGIN');
// Find or create account // Find or create account
let accountId: number; let accountId: number;
@@ -218,9 +226,15 @@ 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 fp = computeFingerprint(data.statement.accountNumber, tx); const fallbackFingerprint = 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 &&
@@ -289,7 +303,7 @@ export async function importStatement(
} }
} }
await client.query('COMMIT'); if (ownsTransaction) await client.query('COMMIT');
return { return {
accountId, accountId,
@@ -300,10 +314,10 @@ export async function importStatement(
totalInFile: data.transactions.length, totalInFile: data.transactions.length,
}; };
} catch (err) { } catch (err) {
await client.query('ROLLBACK'); if (ownsTransaction) await client.query('ROLLBACK');
throw err; throw err;
} finally { } finally {
client.release(); if (ownsTransaction) client.release();
} }
} }

View File

@@ -1,4 +1,5 @@
import crypto from 'crypto'; import crypto from 'crypto';
import type { PoolClient } from 'pg';
import { pool } from '../db/pool'; import { pool } from '../db/pool';
import type { ImportPortfolioResponse, PortfolioFile, PortfolioTrade } from '@family-budget/shared'; import type { ImportPortfolioResponse, PortfolioFile, PortfolioTrade } from '@family-budget/shared';
@@ -45,13 +46,14 @@ export function validatePortfolio(body: unknown): asserts body is PortfolioFile
} }
} }
export async function importPortfolio(body: unknown, db: Pick<typeof pool, 'connect'> = pool): Promise<ImportPortfolioResponse> { export async function importPortfolio(body: unknown, db: Pick<typeof pool, 'connect'> = pool, transactionClient?: PoolClient): Promise<ImportPortfolioResponse> {
validatePortfolio(body); validatePortfolio(body);
const data = body; const data = body;
const sourceHash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex'); const sourceHash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex');
const client = await db.connect(); const client = transactionClient ?? await db.connect();
const ownsTransaction = transactionClient == null;
try { try {
await client.query('BEGIN'); if (ownsTransaction) await client.query('BEGIN');
const accountResult = await client.query( const accountResult = await client.query(
`INSERT INTO accounts (bank, account_number, currency, account_type) `INSERT INTO accounts (bank, account_number, currency, account_type)
VALUES ($1, $2, 'RUB', 'brokerage') VALUES ($1, $2, 'RUB', 'brokerage')
@@ -69,7 +71,7 @@ export async function importPortfolio(body: unknown, db: Pick<typeof pool, 'conn
); );
if (reportResult.rows.length === 0) { if (reportResult.rows.length === 0) {
const existing = await client.query('SELECT id FROM portfolio_reports WHERE account_id = $1 AND source_hash = $2', [accountId, sourceHash]); const existing = await client.query('SELECT id FROM portfolio_reports WHERE account_id = $1 AND source_hash = $2', [accountId, sourceHash]);
await client.query('COMMIT'); if (ownsTransaction) await client.query('COMMIT');
return { accountId, reportId: Number(existing.rows[0].id), importedTrades: 0, duplicateTrades: data.trades.length, positions: 0 }; return { accountId, reportId: Number(existing.rows[0].id), importedTrades: 0, duplicateTrades: data.trades.length, positions: 0 };
} }
const reportId = Number(reportResult.rows[0].id); const reportId = Number(reportResult.rows[0].id);
@@ -92,12 +94,12 @@ export async function importPortfolio(body: unknown, db: Pick<typeof pool, 'conn
); );
importedTrades += result.rowCount ?? 0; importedTrades += result.rowCount ?? 0;
} }
await client.query('COMMIT'); if (ownsTransaction) await client.query('COMMIT');
return { accountId, reportId, importedTrades, duplicateTrades: data.trades.length - importedTrades, positions: data.positions.length }; return { accountId, reportId, importedTrades, duplicateTrades: data.trades.length - importedTrades, positions: data.positions.length };
} catch (error) { } catch (error) {
await client.query('ROLLBACK'); if (ownsTransaction) await client.query('ROLLBACK');
throw error; throw error;
} finally { } finally {
client.release(); if (ownsTransaction) client.release();
} }
} }

View File

@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import zlib from 'zlib';
import { convertVtbBrokerXlsx } from './vtbBrokerXlsx';
function zip(files: Record<string, string>): Buffer {
let offset = 0;
const locals: Buffer[] = [];
const central: Buffer[] = [];
for (const [name, value] of Object.entries(files)) {
const nameBuffer = Buffer.from(name);
const data = zlib.deflateRawSync(Buffer.from(value));
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(8, 8);
local.writeUInt32LE(data.length, 18); local.writeUInt32LE(Buffer.byteLength(value), 22);
local.writeUInt16LE(nameBuffer.length, 26);
const header = Buffer.alloc(46);
header.writeUInt32LE(0x02014b50, 0); header.writeUInt16LE(20, 4); header.writeUInt16LE(20, 6); header.writeUInt16LE(8, 10);
header.writeUInt32LE(data.length, 20); header.writeUInt32LE(Buffer.byteLength(value), 24); header.writeUInt16LE(nameBuffer.length, 28); header.writeUInt32LE(offset, 42);
locals.push(local, nameBuffer, data);
central.push(header, nameBuffer);
offset += local.length + nameBuffer.length + data.length;
}
const centralBuffer = Buffer.concat(central);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(Object.keys(files).length, 8); end.writeUInt16LE(Object.keys(files).length, 10); end.writeUInt32LE(centralBuffer.length, 12); end.writeUInt32LE(offset, 16);
return Buffer.concat([...locals, centralBuffer, end]);
}
const strings = ['Период с 01.08.2026 по 31.08.2026', '12345678901234567890 (RUR)', 'Дата формирования отчета', 'Движение денежных средств', 'Пополнение', 'Отчёт об остатках ценных бумаг', 'Движение ценных бумаг', 'Отчёт об остатках денежных средств', 'Заключенные в отчетном периоде сделки с ценными бумагами', 'Bond, RU0000000001', 'Покупка', 'Завершенные в отчетном периоде сделки с ценными бумагами'];
const shared = `<sst>${strings.map((value) => `<si><t>${value}</t></si>`).join('')}</sst>`;
const cell = (ref: string, value: string | number, sharedString = false) => `<c r="${ref}"${sharedString ? ' t="s"' : ''}><v>${value}</v></c>`;
const row = (number: number, cells: string[]) => `<row r="${number}">${cells.join('')}</row>`;
const sheet = `<worksheet><sheetData>${[
row(1, [cell('A1', 0, true)]), row(2, [cell('A2', 1, true)]), row(3, [cell('A3', 2, true), cell('B3', 45900)]),
row(10, [cell('A10', 3, true)]), row(11, [cell('B11', 45900), cell('C11', 1), cell('J11', 4, true)]),
row(12, [cell('A12', 5, true)]), row(13, [cell('A13', 6, true)]), row(15, [cell('A15', 7, true)]), row(16, []), row(17, []), row(18, [cell('L18', 0), cell('AF18', 1)]),
row(20, [cell('A20', 8, true)]), row(21, [cell('B21', 9, true), cell('C21', 45900), cell('F21', 10, true), cell('H21', 1)]), row(22, [cell('A22', 11, true)]),
].join('')}</sheetData></worksheet>`;
const result = convertVtbBrokerXlsx(zip({ 'xl/sharedStrings.xml': shared, 'xl/worksheets/sheet1.xml': sheet }));
assert.equal(result.cash.transactions.length, 1);
assert.equal(result.cash.statement.closingBalance, 100);
assert.equal(result.portfolio.trades.length, 1);
assert.throws(() => convertVtbBrokerXlsx(Buffer.from('not a zip')), /XLSX/);
console.log('VTB broker XLSX: OK');

View File

@@ -0,0 +1,167 @@
import crypto from 'crypto';
import zlib from 'zlib';
import type { PortfolioFile, StatementFile } from '@family-budget/shared';
type Row = [number, Record<string, string>];
const MAX_XLSX_UNCOMPRESSED_BYTES = 30 * 1024 * 1024;
function xmlText(value: string): string {
return value.replace(/<[^>]+>/g, '').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&#(d+);/g, (_, code) => String.fromCharCode(Number(code))).replace(/\s+/g, ' ').trim();
}
function zipFiles(buffer: Buffer): Map<string, Buffer> {
const end = buffer.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
if (end < 0) throw new Error('Файл не является XLSX-архивом');
const files = new Map<string, Buffer>();
let offset = buffer.readUInt32LE(end + 16);
const count = buffer.readUInt16LE(end + 10);
let totalSize = 0;
for (let i = 0; i < count; i++) {
if (buffer.readUInt32LE(offset) !== 0x02014b50) throw new Error('Повреждён XLSX-архив');
const method = buffer.readUInt16LE(offset + 10);
const compressedSize = buffer.readUInt32LE(offset + 20);
const nameLength = buffer.readUInt16LE(offset + 28);
const extraLength = buffer.readUInt16LE(offset + 30);
const commentLength = buffer.readUInt16LE(offset + 32);
const localOffset = buffer.readUInt32LE(offset + 42);
const name = buffer.subarray(offset + 46, offset + 46 + nameLength).toString('utf8');
if (buffer.readUInt32LE(localOffset) !== 0x04034b50) throw new Error('Повреждён XLSX-архив');
const localNameLength = buffer.readUInt16LE(localOffset + 26);
const localExtraLength = buffer.readUInt16LE(localOffset + 28);
const data = buffer.subarray(localOffset + 30 + localNameLength + localExtraLength, localOffset + 30 + localNameLength + localExtraLength + compressedSize);
let content: Buffer;
try {
content = method === 0 ? data : method === 8 ? zlib.inflateRawSync(data, { maxOutputLength: MAX_XLSX_UNCOMPRESSED_BYTES - totalSize }) : (() => { throw new Error('Неподдерживаемое сжатие XLSX'); })();
} catch (error) {
if (error instanceof Error && /maxOutputLength|larger than/i.test(error.message)) throw new Error('XLSX-отчёт слишком большой после распаковки');
throw error;
}
totalSize += content.length;
if (totalSize > MAX_XLSX_UNCOMPRESSED_BYTES) throw new Error('XLSX-отчёт слишком большой после распаковки');
files.set(name, content);
offset += 46 + nameLength + extraLength + commentLength;
}
return files;
}
function readRows(buffer: Buffer): Row[] {
const files = zipFiles(buffer);
const sharedXml = files.get('xl/sharedStrings.xml')?.toString('utf8');
const sheetXml = files.get('xl/worksheets/sheet1.xml')?.toString('utf8');
if (!sharedXml || !sheetXml) throw new Error('В XLSX не найдены данные отчёта');
const shared = [...sharedXml.matchAll(/<si>([\s\S]*?)<\/si>/g)].map((match) => xmlText(match[1]));
return [...sheetXml.matchAll(/<row[^>]*\br="(\d+)"[^>]*>([\s\S]*?)<\/row>/g)].map((row) => {
const cells: Record<string, string> = {};
for (const cell of row[2].matchAll(/<c[^>]*\br="([A-Z]+)\d+"([^>]*)>([\s\S]*?)<\/c>/g)) {
const value = cell[3].match(/<v>([\s\S]*?)<\/v>/)?.[1] ?? '';
cells[cell[1]] = /\bt="s"/.test(cell[2]) && value ? shared[Number(value)] : xmlText(value);
}
return [Number(row[1]), cells];
});
}
function text(value: unknown): string {
return String(value ?? '').replace(/\s+/g, ' ').trim();
}
function rowText(cells: Record<string, string>): string {
return text(Object.values(cells).join(' '));
}
function findRow(rows: Row[], phrase: string, start = 0): number {
const index = rows.findIndex(([, cells], i) => i >= start && rowText(cells).toLowerCase().includes(phrase.toLowerCase()));
if (index < 0) throw new Error(`Не найден раздел: ${phrase}`);
return index;
}
function excelDate(value: string | undefined): string | null {
if (!value) return null;
const date = new Date(Date.UTC(1899, 11, 30) + Number(value) * 86_400_000);
if (Number.isNaN(date.valueOf())) return null;
return `${date.toISOString().slice(0, 19)}+03:00`;
}
function kopecks(value: string | undefined): number {
return value ? Math.round(Number(value.replace(',', '.')) * 100) : 0;
}
function metadata(rows: Row[]): { account: string; period: [string, string]; reportedAt: string | null } {
const period = rows.map(([, cells]) => rowText(cells)).join(' ').match(/период с (\d{2}\.\d{2}\.\d{4}) по (\d{2}\.\d{2}\.\d{4})/i);
let account: string | null = null;
let reportedAt: string | null = null;
for (const [, cells] of rows.slice(0, 35)) {
const joined = rowText(cells);
account ??= joined.match(/(\d{20})\s*\(RUR\)/)?.[1] ?? null;
if (joined.includes('Дата формирования отчета')) {
reportedAt ??= Object.values(cells).map(excelDate).find(Boolean) ?? null;
}
}
if (!account || !period) throw new Error('Не удалось определить счёт или период отчёта');
return { account, period: [period[1], period[2]], reportedAt };
}
function isoDate(value: string): string {
const [day, month, year] = value.split('.');
return `${year}-${month}-${day}`;
}
export function convertVtbBrokerXlsx(buffer: Buffer): { cash: StatementFile; portfolio: PortfolioFile } {
const rows = readRows(buffer);
const { account, period, reportedAt } = metadata(rows);
const holdingsStart = findRow(rows, 'Отчёт об остатках ценных бумаг');
const movementStart = findRow(rows, 'Движение ценных бумаг', holdingsStart);
const cashStart = findRow(rows, 'Движение денежных средств');
const tradesStart = findRow(rows, 'Заключенные в отчетном периоде сделки с ценными бумагами');
const tradesEnd = findRow(rows, 'Завершенные в отчетном периоде сделки с ценными бумагами', tradesStart + 1);
const occurrences = new Map<string, number>();
const transactions = rows.slice(cashStart + 1, holdingsStart).flatMap(([, cells]) => {
if (!cells.B || !cells.C || !cells.J) return [];
const operationAt = excelDate(cells.B);
if (!operationAt) return [];
const amountSigned = kopecks(cells.C);
const description = text(`${text(cells.J)}. ${text(cells.P)}`.replace(/\. $/, ''));
const digest = crypto.createHash('sha256').update(`${operationAt}|${amountSigned}|${description}`).digest('hex').slice(0, 16);
const occurrence = (occurrences.get(digest) ?? 0) + 1;
occurrences.set(digest, occurrence);
return [{ operationAt, amountSigned, commission: 0, description, sourceId: `vtb-broker-cash:${digest}:${occurrence}` }];
});
if (!transactions.length) throw new Error('Операции движения денежных средств не найдены');
const balanceStart = findRow(rows, 'Отчёт об остатках денежных средств');
const openingBalance = kopecks(rows[balanceStart + 3]?.[1].L);
const closingBalance = kopecks(rows[balanceStart + 3]?.[1].AF);
if (openingBalance + transactions.reduce((sum, tx) => sum + tx.amountSigned, 0) !== closingBalance) throw new Error('Баланс cash-операций не сходится с отчётом');
const positions = rows.slice(holdingsStart + 1, movementStart).flatMap(([, cells]) => {
const instrument = text(cells.B);
if (!instrument || instrument.toLowerCase().startsWith('итого') || !/RU[A-Z0-9]{10}/.test(instrument)) return [];
return [{ instrument, isin: instrument.split(', ').find((part) => /^RU[A-Z0-9]{10}$/.test(part)) ?? null, quantity: cells.L || cells.M || cells.I || cells.J, price: cells.P || null, valuation: cells.AF || cells.AJ || null }];
});
const trades = rows.slice(tradesStart + 1, tradesEnd).flatMap(([number, cells]) => {
if (!cells.B || !cells.C || !cells.F) return [];
const concludedAt = excelDate(cells.C);
if (!concludedAt) return [];
const tradeId = text(cells.Z);
return [{
sourceId: `vtb-broker-trade:${tradeId || number}`,
instrument: text(cells.B),
isin: text(cells.B).split(', ').find((part) => /^RU[A-Z0-9]{10}$/.test(part)) ?? null,
concludedAt,
side: text(cells.F),
quantity: text(cells.H),
priceCurrency: text(cells.I) || null,
price: text(cells.J) || null,
settlementCurrency: text(cells.L) || null,
settlementAmount: text(cells.M) || null,
nkd: text(cells.O) || null,
settlementCommission: text(cells.P) || null,
tradeCommission: text(cells.R) || null,
orderId: text(cells.W) || null,
tradeId: tradeId || null,
venue: text(cells.AK) || null,
comment: text(cells.AN) || null,
}];
});
return {
cash: { schemaVersion: '1.0', bank: 'VTB_BROKER', statement: { accountNumber: account, currency: 'RUB', openingBalance, closingBalance, exportedAt: reportedAt ?? transactions[transactions.length - 1].operationAt }, transactions },
portfolio: { schemaVersion: 'broker-portfolio-1.0', bank: 'VTB_BROKER', accountNumber: account, reportPeriod: { from: isoDate(period[0]), to: isoDate(period[1]) }, reportedAt, positions, trades },
};
}

View File

@@ -91,7 +91,7 @@
- `statement.currency` соответствует допустимому коду валюты (MVP: `"RUB"`). - `statement.currency` соответствует допустимому коду валюты (MVP: `"RUB"`).
- `operationAt` у всех транзакций — валидная дата (парсится без ошибок). - `operationAt` у всех транзакций — валидная дата (парсится без ошибок).
- Отсутствуют дубликаты fingerprint внутри одного файла. - Повторяющиеся `sourceId` внутри одного файла отклоняются; одинаковые операции без `sourceId` различаются по позиции в массиве `transactions`.
Ответ при ошибке: Ответ при ошибке:
@@ -120,10 +120,11 @@
Для каждой транзакции вычисляется SHA-256 от полей, соединённых разделителем `|`: Для каждой транзакции вычисляется SHA-256 от полей, соединённых разделителем `|`:
```text ```text
accountNumber|operationAt|amountSigned|commission|normalizedDescription accountNumber|operationAt|amountSigned|commission|normalizedDescription[|sourcePosition]
``` ```
- `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.1", "version": "0.12.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -0,0 +1,12 @@
import type { ImportBrokerReportResponse, ImportPortfolioResponse, PortfolioFile } from '@family-budget/shared';
import { api } from './client';
export function importPortfolio(data: PortfolioFile): Promise<ImportPortfolioResponse> {
return api.post('/api/import/portfolio', data);
}
export function importBrokerReport(file: File): Promise<ImportBrokerReportResponse> {
const formData = new FormData();
formData.append('file', file);
return api.postFormData('/api/import/broker', formData);
}

View File

@@ -1,6 +1,7 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import type { ImportStatementResponse } from '@family-budget/shared'; import type { ImportBrokerReportResponse, ImportPortfolioResponse, ImportStatementResponse, PortfolioFile } from '@family-budget/shared';
import { importStatement } from '../api/import'; import { importStatement } from '../api/import';
import { importBrokerReport, importPortfolio } from '../api/portfolio';
import { updateAccount } from '../api/accounts'; import { updateAccount } from '../api/accounts';
interface Props { interface Props {
@@ -9,7 +10,7 @@ interface Props {
} }
export function ImportModal({ onClose, onDone }: Props) { export function ImportModal({ onClose, onDone }: Props) {
const [result, setResult] = useState<ImportStatementResponse | null>(null); const [result, setResult] = useState<ImportStatementResponse | ImportPortfolioResponse | ImportBrokerReportResponse | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [alias, setAlias] = useState(''); const [alias, setAlias] = useState('');
@@ -26,9 +27,10 @@ export function ImportModal({ onClose, onDone }: Props) {
const type = file.type; const type = file.type;
const isPdf = type === 'application/pdf' || name.endsWith('.pdf'); const isPdf = type === 'application/pdf' || name.endsWith('.pdf');
const isJson = type === 'application/json' || name.endsWith('.json'); const isJson = type === 'application/json' || name.endsWith('.json');
const isXlsx = type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || name.endsWith('.xlsx');
if (!isPdf && !isJson) { if (!isPdf && !isJson && !isXlsx) {
setError('Допустимы только файлы PDF или JSON'); setError('Допустимы только файлы PDF, JSON или XLSX');
return; return;
} }
@@ -37,7 +39,12 @@ export function ImportModal({ onClose, onDone }: Props) {
setResult(null); setResult(null);
try { try {
const resp = await importStatement(file); const data = isJson ? JSON.parse(await file.text()) : null;
const resp = isXlsx
? await importBrokerReport(file)
: data?.schemaVersion === 'broker-portfolio-1.0'
? await importPortfolio(data as PortfolioFile)
: await importStatement(file);
setResult(resp); setResult(resp);
} catch (err: unknown) { } catch (err: unknown) {
const msg = const msg =
@@ -49,7 +56,7 @@ export function ImportModal({ onClose, onDone }: Props) {
}; };
const handleSaveAlias = async () => { const handleSaveAlias = async () => {
if (!result || !alias.trim()) return; if (!result || 'reportId' in result || 'cash' in result || !alias.trim()) return;
try { try {
await updateAccount(result.accountId, { alias: alias.trim() }); await updateAccount(result.accountId, { alias: alias.trim() });
setAliasSaved(true); setAliasSaved(true);
@@ -58,6 +65,9 @@ export function ImportModal({ onClose, onDone }: Props) {
} }
}; };
const isPortfolioResult = result != null && 'reportId' in result;
const isBrokerResult = result != null && 'cash' in result;
return ( return (
<div <div
className="modal" className="modal"
@@ -79,12 +89,12 @@ export function ImportModal({ onClose, onDone }: Props) {
{!result && ( {!result && (
<div className="import-upload"> <div className="import-upload">
<p className="import-upload__description"> <p className="import-upload__description">
Выберите файл выписки (PDF или JSON, формат 1.0) Выберите PDF/JSON выписки, JSON портфеля или XLSX-отчёт ВТБ Брокер
</p> </p>
<input <input
ref={fileRef} ref={fileRef}
type="file" type="file"
accept=".pdf,.json,application/pdf,application/json" accept=".pdf,.json,.xlsx,application/pdf,application/json,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
onChange={handleFileChange} onChange={handleFileChange}
className="import-upload__input" className="import-upload__input"
/> />
@@ -97,33 +107,69 @@ export function ImportModal({ onClose, onDone }: Props) {
{result && ( {result && (
<div className="import-result"> <div className="import-result">
<div className="import-result__icon" aria-hidden="true"></div> <div className="import-result__icon" aria-hidden="true"></div>
<h3 className="import-result__title">Импорт завершён</h3> <h3 className="import-result__title">{isBrokerResult ? 'Импорт брокерского отчёта завершён' : isPortfolioResult ? 'Импорт портфеля завершён' : 'Импорт завершён'}</h3>
<table className="import-result__stats"> <table className="import-result__stats">
<tbody className="import-result__stats-body"> <tbody className="import-result__stats-body">
<tr className="import-result__stat-row"> {isBrokerResult ? <>
<td className="import-result__stat-cell import-result__stat-cell--label">Счёт</td> <tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--value">{result.accountNumberMasked}</td> <td className="import-result__stat-cell import-result__stat-cell--label">Импортировано cash-операций</td>
</tr> <td className="import-result__stat-cell import-result__stat-cell--value">{result.cash.imported}</td>
<tr className="import-result__stat-row"> </tr>
<td className="import-result__stat-cell import-result__stat-cell--label">Новый счёт</td> <tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--value">{result.isNewAccount ? 'Да' : 'Нет'}</td> <td className="import-result__stat-cell import-result__stat-cell--label">Дубликатов cash-операций</td>
</tr> <td className="import-result__stat-cell import-result__stat-cell--value">{result.cash.duplicatesSkipped}</td>
<tr className="import-result__stat-row"> </tr>
<td className="import-result__stat-cell import-result__stat-cell--label">Импортировано</td> <tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--value">{result.imported}</td> <td className="import-result__stat-cell import-result__stat-cell--label">Импортировано сделок</td>
</tr> <td className="import-result__stat-cell import-result__stat-cell--value">{result.portfolio.importedTrades}</td>
<tr className="import-result__stat-row"> </tr>
<td className="import-result__stat-cell import-result__stat-cell--label">Дубликатов пропущено</td> <tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--value">{result.duplicatesSkipped}</td> <td className="import-result__stat-cell import-result__stat-cell--label">Дубликатов сделок</td>
</tr> <td className="import-result__stat-cell import-result__stat-cell--value">{result.portfolio.duplicateTrades}</td>
<tr className="import-result__stat-row"> </tr>
<td className="import-result__stat-cell import-result__stat-cell--label">Всего в файле</td> <tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--value">{result.totalInFile}</td> <td className="import-result__stat-cell import-result__stat-cell--label">Позиций</td>
</tr> <td className="import-result__stat-cell import-result__stat-cell--value">{result.portfolio.positions}</td>
</tr>
</> : isPortfolioResult ? <>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Импортировано сделок</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.importedTrades}</td>
</tr>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Дубликатов сделок</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.duplicateTrades}</td>
</tr>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Позиций</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.positions}</td>
</tr>
</> : <>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Счёт</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.accountNumberMasked}</td>
</tr>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Новый счёт</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.isNewAccount ? 'Да' : 'Нет'}</td>
</tr>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Импортировано</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.imported}</td>
</tr>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Дубликатов пропущено</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.duplicatesSkipped}</td>
</tr>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Всего в файле</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.totalInFile}</td>
</tr>
</>}
</tbody> </tbody>
</table> </table>
{result.isNewAccount && !aliasSaved && ( {!isPortfolioResult && !isBrokerResult && result.isNewAccount && !aliasSaved && (
<div className="import-result__alias"> <div className="import-result__alias">
<label className="import-result__alias-label">Алиас для нового счёта</label> <label className="import-result__alias-label">Алиас для нового счёта</label>
<div className="import-result__alias-row"> <div className="import-result__alias-row">

View File

@@ -41,6 +41,7 @@ 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.0", "version": "0.6.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,6 +25,7 @@ export interface AnalyticsSummaryResponse {
transferOutflow: number; transferOutflow: number;
cashNet: number; cashNet: number;
interestIncome: number; interestIncome: number;
cashbackIncome: number;
topCategories: TopCategory[]; topCategories: TopCategory[];
} }

View File

@@ -93,3 +93,8 @@ export interface ImportPortfolioResponse {
duplicateTrades: number; duplicateTrades: number;
positions: number; positions: number;
} }
export interface ImportBrokerReportResponse {
cash: ImportStatementResponse;
portfolio: ImportPortfolioResponse;
}

View File

@@ -43,6 +43,7 @@ export type {
PortfolioPosition, PortfolioPosition,
PortfolioTrade, PortfolioTrade,
ImportPortfolioResponse, ImportPortfolioResponse,
ImportBrokerReportResponse,
} from './import'; } from './import';
export type { export type {