Compare commits

..

1 Commits

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

View File

@@ -1,29 +1,5 @@
# 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 ## [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.11.0", "version": "0.10.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "tsx watch src/app.ts", "dev": "tsx watch src/app.ts",
@@ -15,8 +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: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

@@ -1,14 +1,11 @@
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(),
@@ -31,10 +28,6 @@ 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(
@@ -94,41 +87,4 @@ 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,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

@@ -1,5 +1,4 @@
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';
@@ -7,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('|');
@@ -31,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';
@@ -142,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);
@@ -161,7 +155,6 @@ 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;
@@ -170,10 +163,9 @@ export async function importStatement(
const semErr = validateSemantics(data); const semErr = validateSemantics(data);
if (semErr) return semErr; if (semErr) return semErr;
const client = transactionClient ?? await db.connect(); const client = await db.connect();
const ownsTransaction = transactionClient == null;
try { try {
if (ownsTransaction) await client.query('BEGIN'); await client.query('BEGIN');
// Find or create account // Find or create account
let accountId: number; let accountId: number;
@@ -226,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 &&
@@ -303,7 +289,7 @@ export async function importStatement(
} }
} }
if (ownsTransaction) await client.query('COMMIT'); await client.query('COMMIT');
return { return {
accountId, accountId,
@@ -314,10 +300,10 @@ export async function importStatement(
totalInFile: data.transactions.length, totalInFile: data.transactions.length,
}; };
} catch (err) { } catch (err) {
if (ownsTransaction) await client.query('ROLLBACK'); await client.query('ROLLBACK');
throw err; throw err;
} finally { } finally {
if (ownsTransaction) client.release(); client.release();
} }
} }

View File

@@ -1,5 +1,4 @@
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';
@@ -46,14 +45,13 @@ export function validatePortfolio(body: unknown): asserts body is PortfolioFile
} }
} }
export async function importPortfolio(body: unknown, db: Pick<typeof pool, 'connect'> = pool, transactionClient?: PoolClient): Promise<ImportPortfolioResponse> { export async function importPortfolio(body: unknown, db: Pick<typeof pool, 'connect'> = pool): 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 = transactionClient ?? await db.connect(); const client = await db.connect();
const ownsTransaction = transactionClient == null;
try { try {
if (ownsTransaction) await client.query('BEGIN'); 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')
@@ -71,7 +69,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]);
if (ownsTransaction) await client.query('COMMIT'); 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);
@@ -94,12 +92,12 @@ export async function importPortfolio(body: unknown, db: Pick<typeof pool, 'conn
); );
importedTrades += result.rowCount ?? 0; importedTrades += result.rowCount ?? 0;
} }
if (ownsTransaction) await client.query('COMMIT'); 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) {
if (ownsTransaction) await client.query('ROLLBACK'); await client.query('ROLLBACK');
throw error; throw error;
} finally { } finally {
if (ownsTransaction) client.release(); client.release();
} }
} }

View File

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

@@ -1,167 +0,0 @@
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` у всех транзакций — валидная дата (парсится без ошибок).
- Повторяющиеся `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.12.0", "version": "0.11.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1,12 +0,0 @@
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,7 +1,6 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import type { ImportBrokerReportResponse, ImportPortfolioResponse, ImportStatementResponse, PortfolioFile } from '@family-budget/shared'; import type { ImportStatementResponse } 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 {
@@ -10,7 +9,7 @@ interface Props {
} }
export function ImportModal({ onClose, onDone }: Props) { export function ImportModal({ onClose, onDone }: Props) {
const [result, setResult] = useState<ImportStatementResponse | ImportPortfolioResponse | ImportBrokerReportResponse | null>(null); const [result, setResult] = useState<ImportStatementResponse | 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('');
@@ -27,10 +26,9 @@ 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 && !isXlsx) { if (!isPdf && !isJson) {
setError('Допустимы только файлы PDF, JSON или XLSX'); setError('Допустимы только файлы PDF или JSON');
return; return;
} }
@@ -39,12 +37,7 @@ export function ImportModal({ onClose, onDone }: Props) {
setResult(null); setResult(null);
try { try {
const data = isJson ? JSON.parse(await file.text()) : null; const resp = await importStatement(file);
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 =
@@ -56,7 +49,7 @@ export function ImportModal({ onClose, onDone }: Props) {
}; };
const handleSaveAlias = async () => { const handleSaveAlias = async () => {
if (!result || 'reportId' in result || 'cash' in result || !alias.trim()) return; if (!result || !alias.trim()) return;
try { try {
await updateAccount(result.accountId, { alias: alias.trim() }); await updateAccount(result.accountId, { alias: alias.trim() });
setAliasSaved(true); setAliasSaved(true);
@@ -65,9 +58,6 @@ 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"
@@ -89,12 +79,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 выписки, JSON портфеля или XLSX-отчёт ВТБ Брокер Выберите файл выписки (PDF или JSON, формат 1.0)
</p> </p>
<input <input
ref={fileRef} ref={fileRef}
type="file" type="file"
accept=".pdf,.json,.xlsx,application/pdf,application/json,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" accept=".pdf,.json,application/pdf,application/json"
onChange={handleFileChange} onChange={handleFileChange}
className="import-upload__input" className="import-upload__input"
/> />
@@ -107,44 +97,9 @@ 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">{isBrokerResult ? 'Импорт брокерского отчёта завершён' : isPortfolioResult ? 'Импорт портфеля завершён' : 'Импорт завершён'}</h3> <h3 className="import-result__title">Импорт завершён</h3>
<table className="import-result__stats"> <table className="import-result__stats">
<tbody className="import-result__stats-body"> <tbody className="import-result__stats-body">
{isBrokerResult ? <>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Импортировано cash-операций</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.cash.imported}</td>
</tr>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Дубликатов cash-операций</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.cash.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.portfolio.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.portfolio.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.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"> <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--label">Счёт</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.accountNumberMasked}</td> <td className="import-result__stat-cell import-result__stat-cell--value">{result.accountNumberMasked}</td>
@@ -165,11 +120,10 @@ export function ImportModal({ onClose, onDone }: Props) {
<td className="import-result__stat-cell import-result__stat-cell--label">Всего в файле</td> <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> <td className="import-result__stat-cell import-result__stat-cell--value">{result.totalInFile}</td>
</tr> </tr>
</>}
</tbody> </tbody>
</table> </table>
{!isPortfolioResult && !isBrokerResult && result.isNewAccount && !aliasSaved && ( {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,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.6.0", "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[];
} }

View File

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

View File

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