Compare commits
13 Commits
fix/vtb-sa
...
feature/br
| Author | SHA1 | Date | |
|---|---|---|---|
| ef7732285b | |||
| 12d096beeb | |||
| 000c77f162 | |||
| 4bd3436ef6 | |||
| 84d6044d98 | |||
| c83a6d8d81 | |||
| 361a07d4da | |||
| 1dc0e48348 | |||
| 027fbedb8c | |||
| a1e93a7c1f | |||
| 3f5681074e | |||
| 62519f80cd | |||
| 45f0561fd6 |
18
CHANGELOG.md
18
CHANGELOG.md
@@ -1,5 +1,23 @@
|
||||
# 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/backend",
|
||||
"version": "0.10.3",
|
||||
"version": "0.11.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/app.ts",
|
||||
@@ -16,6 +16,7 @@
|
||||
"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:direction": "tsx src/services/import.test.ts",
|
||||
"test:broker:xlsx": "tsx src/services/vtbBrokerXlsx.test.ts",
|
||||
"test:llm": "tsx src/scripts/testLlm.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import { pool } from '../db/pool';
|
||||
import { asyncHandler } from '../utils';
|
||||
import { importStatement, isValidationError } from '../services/import';
|
||||
import {
|
||||
convertPdfToStatement,
|
||||
isPdfConversionError,
|
||||
} from '../services/pdfToStatement';
|
||||
import { importPortfolio } from '../services/portfolio';
|
||||
import { convertVtbBrokerXlsx } from '../services/vtbBrokerXlsx';
|
||||
|
||||
const upload = multer({
|
||||
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();
|
||||
|
||||
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;
|
||||
|
||||
@@ -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)",
|
||||
[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(accountId, '2026-08-01T12:00:00+03:00', -7_000, categoryId.expense, 'analytics-test-7');
|
||||
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 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);
|
||||
assert.deepEqual({ expense: categorySummary.totalExpense, income: categorySummary.totalIncome }, { expense: 6_000, income: 0 });
|
||||
const uncategorized = await getSummary({ ...params, categoryId: 0, onlyConfirmed: false }, client);
|
||||
|
||||
@@ -33,7 +33,11 @@ function analyticsTransactions(where: string): string {
|
||||
CASE WHEN a.account_type = 'savings'
|
||||
AND ${effectiveAmount} > 0
|
||||
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
|
||||
LEFT JOIN categories c ON c.id = t.category_id
|
||||
LEFT JOIN accounts a ON a.id = t.account_id
|
||||
@@ -87,7 +91,8 @@ export async function getSummary(
|
||||
`${analyticsTransactions(where)},
|
||||
category_net AS (
|
||||
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
|
||||
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 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(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`,
|
||||
values,
|
||||
);
|
||||
@@ -110,6 +116,7 @@ export async function getSummary(
|
||||
const transferInflow = Number(totalsResult.rows[0].transfer_inflow);
|
||||
const transferOutflow = Number(totalsResult.rows[0].transfer_outflow);
|
||||
const interestIncome = Number(totalsResult.rows[0].interest_income);
|
||||
const cashbackIncome = Number(totalsResult.rows[0].cashback_income);
|
||||
|
||||
const topResult = await db.query(
|
||||
`${analyticsTransactions(where)}
|
||||
@@ -140,6 +147,7 @@ export async function getSummary(
|
||||
transferOutflow,
|
||||
cashNet: cashInflow - cashOutflow,
|
||||
interestIncome,
|
||||
cashbackIncome,
|
||||
topCategories,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 })),
|
||||
});
|
||||
|
||||
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> {
|
||||
try {
|
||||
await importStatement(makeStatement(['first', 'first-second']));
|
||||
@@ -18,11 +33,32 @@ async function run(): Promise<void> {
|
||||
await importStatement(makeStatement(['second']));
|
||||
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' });
|
||||
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');
|
||||
} 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 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 = '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 IN ('metadata-test', 'fingerprint-test', 'overlap-test'))");
|
||||
await pool.query("DELETE FROM accounts WHERE bank = 'TEST' AND account_number IN ('metadata-test', 'fingerprint-test', 'overlap-test')");
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { determineDirection } from './import';
|
||||
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');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from 'crypto';
|
||||
import type { PoolClient } from 'pg';
|
||||
import { pool } from '../db/pool';
|
||||
import { maskAccountNumber } from '../utils';
|
||||
import type { StatementFile, ImportStatementResponse } from '@family-budget/shared';
|
||||
@@ -14,9 +15,10 @@ const TRANSFER_PHRASES = [
|
||||
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;
|
||||
|
||||
function computeFingerprint(
|
||||
export function computeFingerprint(
|
||||
accountNumber: string,
|
||||
tx: { operationAt: string; amountSigned: number; commission: number; description: string; sourceId?: string },
|
||||
sourcePosition?: number,
|
||||
): string {
|
||||
if (tx.sourceId) {
|
||||
const raw = [accountNumber, tx.sourceId.trim()].join('|');
|
||||
@@ -29,6 +31,7 @@ function computeFingerprint(
|
||||
String(tx.amountSigned),
|
||||
String(tx.commission),
|
||||
tx.description.trim(),
|
||||
...(sourcePosition === undefined ? [] : [String(sourcePosition)]),
|
||||
].join('|');
|
||||
const hash = crypto.createHash('sha256').update(raw, 'utf-8').digest('hex');
|
||||
return `sha256:${hash}`;
|
||||
@@ -139,7 +142,7 @@ function validateSemantics(data: StatementFile): ValidationError | null {
|
||||
const operationIds = new Set<string>();
|
||||
for (let i = 0; i < data.transactions.length; 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}` };
|
||||
}
|
||||
fps.add(fp);
|
||||
@@ -158,6 +161,7 @@ function validateSemantics(data: StatementFile): ValidationError | null {
|
||||
export async function importStatement(
|
||||
body: unknown,
|
||||
db: Pick<typeof pool, 'connect'> = pool,
|
||||
transactionClient?: PoolClient,
|
||||
): Promise<ImportStatementResponse | ValidationError> {
|
||||
const structErr = validateStructure(body);
|
||||
if (structErr) return structErr;
|
||||
@@ -166,9 +170,10 @@ export async function importStatement(
|
||||
const semErr = validateSemantics(data);
|
||||
if (semErr) return semErr;
|
||||
|
||||
const client = await db.connect();
|
||||
const client = transactionClient ?? await db.connect();
|
||||
const ownsTransaction = transactionClient == null;
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
if (ownsTransaction) await client.query('BEGIN');
|
||||
|
||||
// Find or create account
|
||||
let accountId: number;
|
||||
@@ -221,9 +226,15 @@ export async function importStatement(
|
||||
|
||||
// Insert transactions
|
||||
const insertedIds: number[] = [];
|
||||
const fallbackFingerprintOccurrences = new Map<string, number>();
|
||||
|
||||
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 =
|
||||
tx.amountSigned === 0 &&
|
||||
tx.commission > 0 &&
|
||||
@@ -292,7 +303,7 @@ export async function importStatement(
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
if (ownsTransaction) await client.query('COMMIT');
|
||||
|
||||
return {
|
||||
accountId,
|
||||
@@ -303,10 +314,10 @@ export async function importStatement(
|
||||
totalInFile: data.transactions.length,
|
||||
};
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
if (ownsTransaction) await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
if (ownsTransaction) client.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from 'crypto';
|
||||
import type { PoolClient } from 'pg';
|
||||
import { pool } from '../db/pool';
|
||||
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);
|
||||
const data = body;
|
||||
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 {
|
||||
await client.query('BEGIN');
|
||||
if (ownsTransaction) await client.query('BEGIN');
|
||||
const accountResult = await client.query(
|
||||
`INSERT INTO accounts (bank, account_number, currency, account_type)
|
||||
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) {
|
||||
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 };
|
||||
}
|
||||
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;
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
if (ownsTransaction) await client.query('COMMIT');
|
||||
return { accountId, reportId, importedTrades, duplicateTrades: data.trades.length - importedTrades, positions: data.positions.length };
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
if (ownsTransaction) await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
if (ownsTransaction) client.release();
|
||||
}
|
||||
}
|
||||
|
||||
45
backend/src/services/vtbBrokerXlsx.test.ts
Normal file
45
backend/src/services/vtbBrokerXlsx.test.ts
Normal 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');
|
||||
167
backend/src/services/vtbBrokerXlsx.ts
Normal file
167
backend/src/services/vtbBrokerXlsx.ts
Normal 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(/&/g, '&').replace(/</g, '<').replace(/>/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 },
|
||||
};
|
||||
}
|
||||
@@ -91,7 +91,7 @@
|
||||
|
||||
- `statement.currency` соответствует допустимому коду валюты (MVP: `"RUB"`).
|
||||
- `operationAt` у всех транзакций — валидная дата (парсится без ошибок).
|
||||
- Отсутствуют дубликаты fingerprint внутри одного файла.
|
||||
- Повторяющиеся `sourceId` внутри одного файла отклоняются; одинаковые операции без `sourceId` различаются по позиции в массиве `transactions`.
|
||||
|
||||
Ответ при ошибке:
|
||||
|
||||
@@ -120,10 +120,11 @@
|
||||
Для каждой транзакции вычисляется SHA-256 от полей, соединённых разделителем `|`:
|
||||
|
||||
```text
|
||||
accountNumber|operationAt|amountSigned|commission|normalizedDescription
|
||||
accountNumber|operationAt|amountSigned|commission|normalizedDescription[|sourcePosition]
|
||||
```
|
||||
|
||||
- `normalizedDescription` — `description` после `trim`.
|
||||
- `sourcePosition` — порядковый номер повторяющейся операции в массиве `transactions`; добавляется, только если одинаковые операции без `sourceId` повторяются в одном файле.
|
||||
- Суммы подставляются в том виде, в котором пришли в JSON (числовое представление).
|
||||
- Разделитель `|` исключает коллизии при склейке полей разной длины.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/frontend",
|
||||
"version": "0.11.1",
|
||||
"version": "0.12.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
12
frontend/src/api/portfolio.ts
Normal file
12
frontend/src/api/portfolio.ts
Normal 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);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { importBrokerReport, importPortfolio } from '../api/portfolio';
|
||||
import { updateAccount } from '../api/accounts';
|
||||
|
||||
interface Props {
|
||||
@@ -9,7 +10,7 @@ interface 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 [loading, setLoading] = useState(false);
|
||||
const [alias, setAlias] = useState('');
|
||||
@@ -26,9 +27,10 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
const type = file.type;
|
||||
const isPdf = type === 'application/pdf' || name.endsWith('.pdf');
|
||||
const isJson = type === 'application/json' || name.endsWith('.json');
|
||||
const isXlsx = type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || name.endsWith('.xlsx');
|
||||
|
||||
if (!isPdf && !isJson) {
|
||||
setError('Допустимы только файлы PDF или JSON');
|
||||
if (!isPdf && !isJson && !isXlsx) {
|
||||
setError('Допустимы только файлы PDF, JSON или XLSX');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -37,7 +39,12 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
setResult(null);
|
||||
|
||||
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);
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
@@ -49,7 +56,7 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
};
|
||||
|
||||
const handleSaveAlias = async () => {
|
||||
if (!result || !alias.trim()) return;
|
||||
if (!result || 'reportId' in result || 'cash' in result || !alias.trim()) return;
|
||||
try {
|
||||
await updateAccount(result.accountId, { alias: alias.trim() });
|
||||
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 (
|
||||
<div
|
||||
className="modal"
|
||||
@@ -79,12 +89,12 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
{!result && (
|
||||
<div className="import-upload">
|
||||
<p className="import-upload__description">
|
||||
Выберите файл выписки (PDF или JSON, формат 1.0)
|
||||
Выберите PDF/JSON выписки, JSON портфеля или XLSX-отчёт ВТБ Брокер
|
||||
</p>
|
||||
<input
|
||||
ref={fileRef}
|
||||
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}
|
||||
className="import-upload__input"
|
||||
/>
|
||||
@@ -97,33 +107,69 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
{result && (
|
||||
<div className="import-result">
|
||||
<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">
|
||||
<tbody className="import-result__stats-body">
|
||||
<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>
|
||||
{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">
|
||||
<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>
|
||||
</table>
|
||||
|
||||
{result.isNewAccount && !aliasSaved && (
|
||||
{!isPortfolioResult && !isBrokerResult && result.isNewAccount && !aliasSaved && (
|
||||
<div className="import-result__alias">
|
||||
<label className="import-result__alias-label">Алиас для нового счёта</label>
|
||||
<div className="import-result__alias-row">
|
||||
|
||||
@@ -41,6 +41,7 @@ export function SummaryCards({ summary }: Props) {
|
||||
<div className="summary__subvalue">Поступило: {formatAmount(summary.cashInflow)}</div>
|
||||
<div className="summary__subvalue">Списано: {formatAmount(summary.cashOutflow)}</div>
|
||||
<div className="summary__subvalue">Доход от процентов: {formatAmount(summary.interestIncome)}</div>
|
||||
<div className="summary__subvalue">Кэшбек: {formatAmount(summary.cashbackIncome)}</div>
|
||||
{(summary.transferInflow > 0 || summary.transferOutflow > 0) && (
|
||||
<div className="summary__subvalue">
|
||||
Переводы: {formatAmount(summary.transferInflow)} / {formatAmount(summary.transferOutflow)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/shared",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface AnalyticsSummaryResponse {
|
||||
transferOutflow: number;
|
||||
cashNet: number;
|
||||
interestIncome: number;
|
||||
cashbackIncome: number;
|
||||
topCategories: TopCategory[];
|
||||
}
|
||||
|
||||
|
||||
@@ -93,3 +93,8 @@ export interface ImportPortfolioResponse {
|
||||
duplicateTrades: number;
|
||||
positions: number;
|
||||
}
|
||||
|
||||
export interface ImportBrokerReportResponse {
|
||||
cash: ImportStatementResponse;
|
||||
portfolio: ImportPortfolioResponse;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export type {
|
||||
PortfolioPosition,
|
||||
PortfolioTrade,
|
||||
ImportPortfolioResponse,
|
||||
ImportBrokerReportResponse,
|
||||
} from './import';
|
||||
|
||||
export type {
|
||||
|
||||
Reference in New Issue
Block a user