Compare commits

...

37 Commits

Author SHA1 Message Date
be1c2cc701 docs: note broker income label 2026-08-27 08:43:25 +03:00
b94340823b fix: label broker income metrics 2026-08-27 08:42:47 +03:00
c69e55e045 docs: note portfolio API test 2026-08-27 08:40:13 +03:00
94506e38c2 test: cover portfolio UI API contract 2026-08-27 08:39:20 +03:00
19d3c36c9e docs: note portfolio overlap coverage 2026-08-27 08:36:23 +03:00
491a51cfb8 test: cover overlapping portfolio imports 2026-08-27 08:34:49 +03:00
5adbcbd7bd docs: note broker performance 2026-08-27 08:31:04 +03:00
c82b26acf9 fix: avoid incomplete unrealized result 2026-08-27 08:29:22 +03:00
ba2ed6a049 feat: add broker investment performance 2026-08-27 08:27:40 +03:00
4d9663a906 Merge pull request 'feat: добавить динамику стоимости портфеля' (#48) from feature/portfolio-history into main
Reviewed-on: #48
2026-08-27 05:22:35 +00:00
e0723b3ab6 docs: note portfolio history 2026-08-27 08:19:55 +03:00
29ba82f4bf feat: add portfolio valuation history 2026-08-27 08:17:33 +03:00
5d478ff06c Merge pull request 'feat: обзор последнего брокерского портфеля' (#47) from feature/portfolio-overview into main
Reviewed-on: #47
2026-08-27 05:12:18 +00:00
e7233d85ee docs: note portfolio overview 2026-08-27 08:10:21 +03:00
b38346dd19 feat: add portfolio overview 2026-08-27 00:07:25 +03:00
1e25a0152a Merge pull request 'Добавить загрузку XLSX-отчёта ВТБ Брокер' (#46) from feature/broker-xlsx-upload into main
Reviewed-on: #46
2026-08-26 20:59:15 +00:00
ef7732285b fix: limit broker XLSX extraction 2026-08-26 23:45:09 +03:00
12d096beeb fix: make broker XLSX import atomic 2026-08-26 23:42:26 +03:00
000c77f162 feat: import VTB broker XLSX reports 2026-08-26 23:36:32 +03:00
4bd3436ef6 Merge pull request 'Добавить импорт JSON брокерского портфеля' (#45) from feature/broker-report-import into main
Reviewed-on: #45
2026-08-26 20:30:14 +00:00
84d6044d98 docs: add broker portfolio import changelog 2026-08-26 23:28:36 +03:00
c83a6d8d81 feat: import broker portfolio JSON 2026-08-26 23:26:15 +03:00
361a07d4da Merge pull request 'Выделить кэшбек в движении денежных средств' (#44) from feature/analytics-cashback-income into main
Reviewed-on: #44
2026-08-26 20:23:15 +00:00
1dc0e48348 docs: add cashback summary changelog 2026-08-26 07:29:12 +03:00
027fbedb8c feat: show cashback in cash flow summary 2026-08-26 07:26:14 +03:00
a1e93a7c1f Merge pull request 'Исправить импорт одинаковых операций из JSON' (#42) from fix/json-import-duplicates into main
Reviewed-on: #42
2026-08-25 21:49:31 +00:00
3f5681074e fix: preserve overlapping import deduplication 2026-08-26 00:43:43 +03:00
62519f80cd fix: import identical JSON transactions 2026-08-26 00:39:55 +03:00
45f0561fd6 Merge pull request 'Исправить переводы из закрытых накопительных счетов ВТБ' (#40) from fix/vtb-savings-transfers into main
Reviewed-on: #40
2026-08-24 20:32:52 +00:00
fab929fd68 docs: update VTB transfer changelog 2026-08-24 23:31:15 +03:00
c4ce2b9d6b fix: classify VTB savings transfers 2026-08-24 23:25:43 +03:00
c4f681c9b0 Merge pull request 'Дополнить changelog валидации аналитики' (#39) from fix/analytics-validation-changelog into main
Reviewed-on: #39
2026-08-24 19:17:36 +00:00
dd21e20dc6 docs: update analytics validation changelog 2026-08-24 22:15:56 +03:00
29c4acd0a9 Merge pull request 'Исправить валидацию фильтров аналитики' (#38) from fix/analytics-query-validation into main
Reviewed-on: #38
2026-08-24 19:09:48 +00:00
8939843462 fix: validate analytics filter ids 2026-08-24 22:06:24 +03:00
ce162855f9 Merge pull request 'Исправить блокеры аналитики после PR #35' (#37) from feature/analytics-fixes into main
Reviewed-on: #37
2026-08-24 18:59:50 +00:00
cce2ddcf41 Merge pull request 'Откатить PR #35 до исправления замечаний' (#36) from revert/analytics-category-filter into main
Reviewed-on: #36
2026-08-21 04:21:23 +00:00
33 changed files with 1089 additions and 59 deletions

View File

@@ -1,5 +1,71 @@
# Changelog
## [Frontend 0.15.2] - 2026-08-27
### Fixed
- Clarified that the broker income metric includes coupons, dividends, and interest.
## [Frontend 0.15.1] - 2026-08-27
### Added
- Added a frontend API contract check for portfolio overview, history, and performance requests.
## [Backend 0.14.1] - 2026-08-27
### Added
- Added SQL coverage for repeated and overlapping broker portfolio report imports.
## [Frontend 0.15.0 / Backend 0.14.0 / Shared 0.9.0] - 2026-08-27
### Added
- Added separate broker investment performance metrics: contributions, withdrawals, income, fees, realized and unrealized results.
## [Frontend 0.14.0 / Backend 0.13.0 / Shared 0.8.0] - 2026-08-27
### Added
- Added portfolio valuation history by imported broker report dates.
## [Frontend 0.13.0 / Backend 0.12.0 / Shared 0.7.0] - 2026-08-27
### Added
- Added a separate portfolio view with the latest broker and IIS positions and total valuation.
## [Frontend 0.12.0 / Backend 0.11.0 / Shared 0.6.0] - 2026-08-26
### Added
- Import VTB Broker XLSX reports with cash movements, trades, and positions in one operation.
## [Frontend 0.11.3] - 2026-08-26
### Added
- Import broker portfolio JSON files and show their trade and position results.
## [Frontend 0.11.2 / Backend 0.10.5 / Shared 0.5.1] - 2026-08-26
### Added
- Show cashback separately in the cash-flow summary alongside interest income.
## [Backend 0.10.3] - 2026-08-24
### Fixed
- Treat VTB savings-account deposits and closures as internal transfers when importing PDF-to-JSON statements.
## [Backend 0.10.2] - 2026-08-24
### Fixed
- Analytics endpoints now reject invalid account and category filter IDs with a validation error.
## [Frontend 0.11.1] - 2026-08-21
### Fixed

View File

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

View File

@@ -20,6 +20,9 @@ import categoriesRouter from './routes/categories';
import categoryRulesRouter from './routes/categoryRules';
import analyticsRouter from './routes/analytics';
import portfolioRouter from './routes/portfolio';
import portfolioOverviewRouter from './routes/portfolioOverview';
import portfolioHistoryRouter from './routes/portfolioHistory';
import portfolioPerformanceRouter from './routes/portfolioPerformance';
const app = express();
app.set('trust proxy', 1);
@@ -45,6 +48,9 @@ app.use('/api/categories', categoriesRouter);
app.use('/api/category-rules', categoryRulesRouter);
app.use('/api/analytics', analyticsRouter);
app.use('/api/import/portfolio', portfolioRouter);
app.use('/api/portfolio', portfolioOverviewRouter);
app.use('/api/portfolio/history', portfolioHistoryRouter);
app.use('/api/portfolio/performance', portfolioPerformanceRouter);
app.use(
(

View File

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

View File

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

View File

@@ -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;

View File

@@ -0,0 +1,14 @@
import { Router } from 'express';
import { asyncHandler } from '../utils';
import { getPortfolioHistory } from '../services/portfolio';
const router = Router();
router.get(
'/',
asyncHandler(async (_req, res) => {
res.json(await getPortfolioHistory());
}),
);
export default router;

View File

@@ -0,0 +1,14 @@
import { Router } from 'express';
import { asyncHandler } from '../utils';
import { getPortfolioOverview } from '../services/portfolio';
const router = Router();
router.get(
'/',
asyncHandler(async (_req, res) => {
res.json(await getPortfolioOverview());
}),
);
export default router;

View File

@@ -0,0 +1,9 @@
import { Router } from 'express';
import { asyncHandler } from '../utils';
import { getPortfolioPerformance } from '../services/portfolio';
const router = Router();
router.get('/', asyncHandler(async (_req, res) => {
res.json(await getPortfolioPerformance());
}));
export default router;

View File

@@ -26,6 +26,10 @@ async function testQueries(): Promise<void> {
"INSERT INTO transactions (account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed) VALUES ($1, '2026-07-06T12:00:00+03:00', 1_500, 0, 'Начисление процентов', 'transfer', 'analytics-test-interest', $2, TRUE)",
[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);

View File

@@ -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,
};
}

View File

@@ -8,6 +8,21 @@ const makeStatement = (sourceIds: string[]) => ({
transactions: sourceIds.map((sourceId) => ({ operationAt: '2026-08-20T10:00:00+03:00', amountSigned: 100, commission: 0, description: 'Пополнение', sourceId })),
});
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();
}
}

View File

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

View File

@@ -1,4 +1,5 @@
import crypto from 'crypto';
import type { PoolClient } from 'pg';
import { pool } from '../db/pool';
import { maskAccountNumber } from '../utils';
import type { StatementFile, ImportStatementResponse } from '@family-budget/shared';
@@ -6,14 +7,18 @@ import type { StatementFile, ImportStatementResponse } from '@family-budget/shar
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('|');
@@ -26,12 +31,13 @@ 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}`;
}
function determineDirection(amountSigned: number, description: string): string {
export function determineDirection(amountSigned: number, description: string): string {
const lower = description.toLowerCase();
for (const phrase of TRANSFER_PHRASES) {
if (lower.includes(phrase)) return 'transfer';
@@ -136,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);
@@ -155,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;
@@ -163,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;
@@ -218,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 &&
@@ -289,7 +303,7 @@ export async function importStatement(
}
}
await client.query('COMMIT');
if (ownsTransaction) await client.query('COMMIT');
return {
accountId,
@@ -300,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();
}
}

View File

@@ -8,6 +8,12 @@ const payload = {
positions: [{ instrument: 'Test Bond', isin: 'RU0000000001', quantity: '1.000', price: '100.123456', valuation: '100.123456' }],
trades: [{ instrument: 'Test Bond', isin: 'RU0000000001', concludedAt: '2026-08-10T10:00:00+03:00', side: 'Покупка', quantity: '1.000', settlementAmount: '100.123456' }],
};
const overlappingPayload = {
...payload,
reportPeriod: { from: '2026-08-10', to: '2026-08-25' },
positions: [{ ...payload.positions[0], valuation: '120.000000' }],
trades: [{ ...payload.trades[0], sourceId: 'second-trade', concludedAt: '2026-08-15T10:00:00+03:00', side: 'Продажа', quantity: '0.500', settlementAmount: '60.000000' }],
};
async function run(): Promise<void> {
try {
@@ -17,6 +23,12 @@ async function run(): Promise<void> {
assert.equal(second.duplicateTrades, 1);
const rows = await pool.query('SELECT COUNT(*)::int AS count FROM portfolio_trades WHERE account_id = $1', [first.accountId]);
assert.equal(rows.rows[0].count, 1);
const overlapping = await importPortfolio(overlappingPayload);
assert.equal(overlapping.importedTrades, 1);
const reports = await pool.query('SELECT COUNT(*)::int AS count FROM portfolio_reports WHERE account_id = $1', [first.accountId]);
assert.equal(reports.rows[0].count, 2);
const trades = await pool.query('SELECT COUNT(*)::int AS count FROM portfolio_trades WHERE account_id = $1', [first.accountId]);
assert.equal(trades.rows[0].count, 2);
console.log('portfolio import SQL: OK');
} finally {
await pool.query("DELETE FROM portfolio_trades WHERE account_id IN (SELECT id FROM accounts WHERE bank = 'PORTFOLIO_TEST' AND account_number = 'portfolio-test')");

View File

@@ -1,9 +1,38 @@
import crypto from 'crypto';
import type { PoolClient } from 'pg';
import { pool } from '../db/pool';
import type { ImportPortfolioResponse, PortfolioFile, PortfolioTrade } from '@family-budget/shared';
import { maskAccountNumber } from '../utils';
import type { ImportPortfolioResponse, PortfolioFile, PortfolioHistoryResponse, PortfolioOverviewResponse, PortfolioPerformanceResponse, PortfolioTrade } from '@family-budget/shared';
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;
type PortfolioOverviewRow = {
account_id: number | string;
alias: string | null;
bank: string;
account_number: string;
report_period_to: string;
total_valuation: string | number | null;
instrument: string | null;
isin: string | null;
quantity: string | number | null;
price: string | number | null;
valuation: string | number | null;
};
type PortfolioHistoryRow = {
account_id: number | string;
alias: string | null;
bank: string;
account_number: string;
report_period_to: string;
total_valuation: string | number | null;
};
type PerformanceCashRow = { account_id: number | string; alias: string | null; bank: string; account_number: string; amount_signed: number | string; description: string };
type PerformanceTradeRow = { account_id: number | string; side: string; quantity: string | number; settlement_amount: string | number | null; settlement_commission: string | number | null; trade_commission: string | number | null; isin: string | null; instrument: string };
type PerformancePositionRow = { account_id: number | string; quantity: string | number; valuation: string | number | null; isin: string | null; instrument: string };
function decimalValue(value: unknown, field: string, required = false): string | null {
if (value == null || value === '') {
if (required) throw new Error(`${field} is required`);
@@ -45,13 +74,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 +99,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 +122,187 @@ 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();
}
}
export function toPortfolioOverview(rows: PortfolioOverviewRow[]): PortfolioOverviewResponse {
const accounts = new Map<number, PortfolioOverviewResponse['accounts'][number]>();
for (const row of rows) {
const accountId = Number(row.account_id);
let account = accounts.get(accountId);
if (!account) {
account = {
accountId,
accountName: row.alias || `${row.bank} · ${maskAccountNumber(row.account_number)}`,
reportPeriodTo: row.report_period_to,
totalValuation: row.total_valuation === null ? null : String(row.total_valuation),
positions: [],
};
accounts.set(accountId, account);
}
if (row.instrument !== null) {
const valuation = row.valuation === null ? null : String(row.valuation);
account.positions.push({
instrument: row.instrument,
isin: row.isin,
quantity: String(row.quantity),
price: row.price === null ? null : String(row.price),
valuation,
});
}
}
return { accounts: [...accounts.values()] };
}
export async function getPortfolioOverview(): Promise<PortfolioOverviewResponse> {
const { rows } = await pool.query<PortfolioOverviewRow>(
`SELECT a.id AS account_id, a.alias, a.bank, a.account_number,
r.report_period_to,
SUM(p.valuation) OVER (PARTITION BY a.id) AS total_valuation,
p.instrument, p.isin, p.quantity, p.price, p.valuation
FROM accounts a
JOIN LATERAL (
SELECT id, report_period_to, reported_at, imported_at
FROM portfolio_reports
WHERE account_id = a.id
ORDER BY report_period_to DESC, imported_at DESC, id DESC
LIMIT 1
) r ON TRUE
LEFT JOIN portfolio_positions p ON p.report_id = r.id
WHERE a.account_type IN ('brokerage', 'iis')
ORDER BY a.id, p.valuation DESC NULLS LAST, p.id`,
);
return toPortfolioOverview(rows);
}
export function toPortfolioHistory(rows: PortfolioHistoryRow[]): PortfolioHistoryResponse {
const accounts = new Map<number, PortfolioHistoryResponse['accounts'][number]>();
for (const row of rows) {
const accountId = Number(row.account_id);
let account = accounts.get(accountId);
if (!account) {
account = {
accountId,
accountName: row.alias || `${row.bank} · ${maskAccountNumber(row.account_number)}`,
points: [],
};
accounts.set(accountId, account);
}
account.points.push({
reportPeriodTo: row.report_period_to,
totalValuation: row.total_valuation === null ? null : String(row.total_valuation),
});
}
return { accounts: [...accounts.values()] };
}
export async function getPortfolioHistory(): Promise<PortfolioHistoryResponse> {
const { rows } = await pool.query<PortfolioHistoryRow>(
`SELECT a.id AS account_id, a.alias, a.bank, a.account_number,
r.report_period_to, SUM(p.valuation) AS total_valuation
FROM accounts a
JOIN portfolio_reports r ON r.account_id = a.id
LEFT JOIN portfolio_positions p ON p.report_id = r.id
WHERE a.account_type IN ('brokerage', 'iis')
GROUP BY a.id, a.alias, a.bank, a.account_number, r.id, r.report_period_to, r.reported_at, r.imported_at
ORDER BY a.id, r.report_period_to, r.reported_at, r.imported_at, r.id`,
);
return toPortfolioHistory(rows);
}
type Lot = { quantity: number; cost: number };
export function calculatePortfolioTradeResults(trades: PerformanceTradeRow[], positions: PerformancePositionRow[]): Map<number, { fees: number; realized: number; unrealized: number | null }> {
const result = new Map<number, { fees: number; realized: number; unrealized: number | null }>();
const lots = new Map<string, Lot[]>();
for (const trade of trades) {
const accountId = Number(trade.account_id);
const current = result.get(accountId) ?? { fees: 0, realized: 0, unrealized: null };
const quantity = Number(trade.quantity);
const amount = Number(trade.settlement_amount ?? 0);
const fees = Number(trade.settlement_commission ?? 0) + Number(trade.trade_commission ?? 0);
current.fees += fees;
const key = `${accountId}:${trade.isin ?? trade.instrument}`;
const queue = lots.get(key) ?? [];
if (/продаж/i.test(trade.side)) {
let remaining = quantity;
let matchedCost = 0;
while (remaining > 0 && queue.length > 0) {
const lot = queue[0];
const used = Math.min(remaining, lot.quantity);
const unitCost = lot.cost / lot.quantity;
matchedCost += used * unitCost;
lot.cost -= used * unitCost;
lot.quantity -= used;
remaining -= used;
if (lot.quantity <= 0) queue.shift();
}
const matched = quantity - remaining;
current.realized += matched > 0 ? (amount - fees) * (matched / quantity) - matchedCost : 0;
} else if (/покуп/i.test(trade.side) && quantity > 0) {
queue.push({ quantity, cost: amount + fees });
}
lots.set(key, queue);
result.set(accountId, current);
}
const incomplete = new Set<number>();
for (const position of positions) {
const accountId = Number(position.account_id);
const current = result.get(accountId) ?? { fees: 0, realized: 0, unrealized: 0 };
if (position.valuation !== null) {
const key = `${accountId}:${position.isin ?? position.instrument}`;
const queue = lots.get(key) ?? [];
let remaining = Number(position.quantity);
let cost = 0;
for (const lot of queue) {
if (remaining <= 0) break;
const used = Math.min(remaining, lot.quantity);
cost += used * (lot.cost / lot.quantity);
remaining -= used;
}
if (remaining > 0.0000001) incomplete.add(accountId);
else if (!incomplete.has(accountId)) current.unrealized = (current.unrealized ?? 0) + Number(position.valuation) - cost;
}
result.set(accountId, current);
}
for (const accountId of incomplete) {
const current = result.get(accountId);
if (current) current.unrealized = null;
}
return result;
}
export async function getPortfolioPerformance(): Promise<PortfolioPerformanceResponse> {
const [cashResult, tradeResult, positionResult] = await Promise.all([
pool.query<PerformanceCashRow>(`SELECT a.id AS account_id, a.alias, a.bank, a.account_number, t.amount_signed, t.description FROM accounts a JOIN transactions t ON t.account_id = a.id WHERE a.account_type IN ('brokerage', 'iis') ORDER BY a.id, t.operation_at, t.id`),
pool.query<PerformanceTradeRow>(`SELECT account_id, side, quantity, settlement_amount, settlement_commission, trade_commission, isin, instrument FROM portfolio_trades ORDER BY account_id, concluded_at, id`),
pool.query<PerformancePositionRow>(`SELECT r.account_id, p.quantity, p.valuation, p.isin, p.instrument FROM portfolio_reports r JOIN portfolio_positions p ON p.report_id = r.id JOIN LATERAL (SELECT id FROM portfolio_reports WHERE account_id = r.account_id ORDER BY report_period_to DESC, imported_at DESC, id DESC LIMIT 1) latest ON latest.id = r.id`),
]);
const tradeMap = calculatePortfolioTradeResults(tradeResult.rows, positionResult.rows);
const accounts = new Map<number, PortfolioPerformanceResponse['accounts'][number]>();
for (const row of cashResult.rows) {
const accountId = Number(row.account_id);
const account = accounts.get(accountId) ?? { accountId, accountName: row.alias || `${row.bank} · ${maskAccountNumber(row.account_number)}`, contributions: 0, withdrawals: 0, income: 0, fees: 0, realizedResult: 0, unrealizedResult: null };
const amount = Number(row.amount_signed);
const description = row.description.toLowerCase();
if (amount > 0 && /дивиденд|купон|процент/.test(description)) account.income += amount;
else if (amount > 0 && description.includes('зачисление денежных средств')) account.contributions += amount;
else if (amount < 0 && /вывод денежных средств|вывод дс/.test(description) && !description.includes('под нерассчитанные сделки')) account.withdrawals += -amount;
accounts.set(accountId, account);
}
for (const [accountId, values] of tradeMap) {
const account = accounts.get(accountId) ?? { accountId, accountName: `Счёт ${accountId}`, contributions: 0, withdrawals: 0, income: 0, fees: 0, realizedResult: 0, unrealizedResult: null };
account.fees = Math.round(values.fees * 100);
account.realizedResult = Math.round(values.realized * 100);
account.unrealizedResult = values.unrealized === null ? null : Math.round(values.unrealized * 100);
accounts.set(accountId, account);
}
return { accounts: [...accounts.values()] };
}

View File

@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import { calculatePortfolioTradeResults, toPortfolioHistory, toPortfolioOverview } from './portfolio';
const result = toPortfolioOverview([
{ account_id: '1', alias: 'ИИС', bank: 'ВТБ', account_number: '123456', report_period_to: '2026-08-26', total_valuation: '150.50', instrument: 'Облигация', isin: 'RU0000000001', quantity: '1', price: '100', valuation: '100' },
{ account_id: '1', alias: 'ИИС', bank: 'ВТБ', account_number: '123456', report_period_to: '2026-08-26', total_valuation: '150.50', instrument: 'Фонд', isin: null, quantity: '2', price: '25.25', valuation: '50.50' },
]);
assert.equal(result.accounts[0].accountName, 'ИИС');
assert.equal(result.accounts[0].totalValuation, '150.50');
assert.equal(result.accounts[0].positions.length, 2);
console.log('portfolio overview: OK');
const history = toPortfolioHistory([
{ account_id: '1', alias: 'ИИС', bank: 'ВТБ', account_number: '123456', report_period_to: '2026-08-20', total_valuation: '100' },
{ account_id: '1', alias: 'ИИС', bank: 'ВТБ', account_number: '123456', report_period_to: '2026-08-26', total_valuation: '150.50' },
]);
assert.deepEqual(history.accounts[0].points.map((point) => point.totalValuation), ['100', '150.50']);
console.log('portfolio history: OK');
const tradeResults = calculatePortfolioTradeResults([
{ account_id: 1, side: 'Покупка', quantity: '2', settlement_amount: '200', settlement_commission: '1', trade_commission: '1', isin: 'RU1', instrument: 'Фонд' },
{ account_id: 1, side: 'Продажа', quantity: '1', settlement_amount: '150', settlement_commission: '1', trade_commission: '0', isin: 'RU1', instrument: 'Фонд' },
], [{ account_id: 1, quantity: '1', valuation: '130', isin: 'RU1', instrument: 'Фонд' }]);
assert.equal(tradeResults.get(1)?.fees, 3);
assert.equal(tradeResults.get(1)?.realized, 48);
assert.equal(tradeResults.get(1)?.unrealized, 29);
console.log('portfolio performance: OK');

View File

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

View File

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

View File

@@ -91,7 +91,7 @@
- `statement.currency` соответствует допустимому коду валюты (MVP: `"RUB"`).
- `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 (числовое представление).
- Разделитель `|` исключает коллизии при склейке полей разной длины.

View File

@@ -1,11 +1,12 @@
{
"name": "@family-budget/frontend",
"version": "0.11.1",
"version": "0.15.2",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test:portfolio": "tsx src/api/portfolio.test.ts",
"preview": "vite preview"
},
"dependencies": {

View File

@@ -5,6 +5,7 @@ import { LoginPage } from './pages/LoginPage';
import { HistoryPage } from './pages/HistoryPage';
import { AnalyticsPage } from './pages/AnalyticsPage';
import { SettingsPage } from './pages/SettingsPage';
import { PortfolioPage } from './pages/PortfolioPage';
export function App() {
const { user, loading } = useAuth();
@@ -23,6 +24,7 @@ export function App() {
<Route path="/" element={<Navigate to="/history" replace />} />
<Route path="/history" element={<HistoryPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/portfolio" element={<PortfolioPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/history" replace />} />
</Routes>

View File

@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import { getPortfolioHistory, getPortfolioOverview, getPortfolioPerformance } from './portfolio';
const calls: string[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input) => {
calls.push(String(input));
return new Response(JSON.stringify({ accounts: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
try {
await Promise.all([getPortfolioOverview(), getPortfolioHistory(), getPortfolioPerformance()]);
assert.deepEqual(calls.sort(), ['/api/portfolio', '/api/portfolio/history', '/api/portfolio/performance']);
console.log('portfolio API contract: OK');
} finally {
globalThis.fetch = originalFetch;
}

View File

@@ -0,0 +1,24 @@
import type { ImportBrokerReportResponse, ImportPortfolioResponse, PortfolioFile, PortfolioHistoryResponse, PortfolioOverviewResponse, PortfolioPerformanceResponse } 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);
}
export function getPortfolioOverview(): Promise<PortfolioOverviewResponse> {
return api.get('/api/portfolio');
}
export function getPortfolioHistory(): Promise<PortfolioHistoryResponse> {
return api.get('/api/portfolio/history');
}
export function getPortfolioPerformance(): Promise<PortfolioPerformanceResponse> {
return api.get('/api/portfolio/performance');
}

View File

@@ -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">

View File

@@ -63,6 +63,21 @@ export function Layout({ children }: { children: ReactNode }) {
Операции
</NavLink>
<NavLink
to="/portfolio"
className={({ isActive }) =>
`sidebar__nav-link${isActive ? ' sidebar__nav-link--active' : ''}`
}
onClick={closeDrawer}
>
<svg className="sidebar__nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 21h18" />
<path d="M5 21V10l7-5 7 5v11" />
<path d="M9 21v-6h6v6" />
</svg>
Портфель
</NavLink>
<NavLink
to="/analytics"
className={({ isActive }) =>

View File

@@ -41,6 +41,7 @@ export function SummaryCards({ summary }: Props) {
<div className="summary__subvalue">Поступило: {formatAmount(summary.cashInflow)}</div>
<div className="summary__subvalue">Списано: {formatAmount(summary.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)}

View File

@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import type { PortfolioHistoryResponse, PortfolioOverviewResponse, PortfolioPerformanceResponse } from '@family-budget/shared';
import { getPortfolioHistory, getPortfolioOverview, getPortfolioPerformance } from '../api/portfolio';
import { formatDate } from '../utils/format';
const money = new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', minimumFractionDigits: 2 });
const number = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 6 });
export function PortfolioPage() {
const [data, setData] = useState<PortfolioOverviewResponse | null>(null);
const [history, setHistory] = useState<PortfolioHistoryResponse | null>(null);
const [performance, setPerformance] = useState<PortfolioPerformanceResponse | null>(null);
useEffect(() => {
getPortfolioOverview().then(setData).catch(() => {});
getPortfolioHistory().then(setHistory).catch(() => {});
getPortfolioPerformance().then(setPerformance).catch(() => {});
}, []);
return (
<div className="page">
<div className="page__header">
<div>
<p className="page__eyebrow">Инвестиции</p>
<h1 className="page__title">Портфель</h1>
</div>
</div>
{!data ? <div className="state">Загрузка...</div> : data.accounts.length === 0 ? (
<div className="state state--empty">Портфель пока пуст. Загрузите XLSX-отчёт брокера в разделе «Операции».</div>
) : data.accounts.map((account) => (
<section className="portfolio" key={account.accountId} aria-labelledby={`portfolio-${account.accountId}`}>
<div className="portfolio__header">
<div>
<h2 id={`portfolio-${account.accountId}`} className="portfolio__title">{account.accountName}</h2>
<p className="portfolio__meta">Состояние на {formatDate(account.reportPeriodTo)}</p>
</div>
{account.totalValuation !== null && <strong className="portfolio__total">{money.format(Number(account.totalValuation))}</strong>}
</div>
{account.positions.length === 0 ? <div className="state state--empty">В последнем отчёте нет открытых позиций.</div> : (
<div className="table-shell">
<table className="data-table">
<thead><tr><th className="data-table__head-cell" scope="col">Инструмент</th><th className="data-table__head-cell" scope="col">Количество</th><th className="data-table__head-cell" scope="col">Цена</th><th className="data-table__head-cell" scope="col">Стоимость</th></tr></thead>
<tbody className="data-table__body">
{account.positions.map((position, index) => <tr className="data-table__row" key={`${position.isin ?? position.instrument}-${index}`}>
<td className="data-table__cell"><div className="data-table__description">{position.instrument}</div>{position.isin && <div className="data-table__subtext">{position.isin}</div>}</td>
<td className="data-table__cell data-table__cell--nowrap">{number.format(Number(position.quantity))}</td>
<td className="data-table__cell data-table__cell--nowrap">{position.price === null ? '—' : money.format(Number(position.price))}</td>
<td className="data-table__cell data-table__cell--nowrap money-amount">{position.valuation === null ? '—' : money.format(Number(position.valuation))}</td>
</tr>)}
</tbody>
</table>
</div>
)}
</section>
))}
{history?.accounts.map((account) => account.points.length > 1 && (
<section className="portfolio" key={`history-${account.accountId}`} aria-labelledby={`history-${account.accountId}`}>
<div className="portfolio__header">
<div>
<h2 id={`history-${account.accountId}`} className="portfolio__title">Динамика · {account.accountName}</h2>
<p className="portfolio__meta">Стоимость по загруженным отчётам</p>
</div>
</div>
<div className="chart-card portfolio__chart">
<ResponsiveContainer width="100%" height={280}>
<LineChart data={account.points.map((point) => ({ date: point.reportPeriodTo, value: point.totalValuation === null ? null : Number(point.totalValuation) }))}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" vertical={false} />
<XAxis dataKey="date" tickFormatter={(value: string) => formatDate(value)} fontSize={12} stroke="var(--color-text-secondary)" tickLine={false} axisLine={false} />
<YAxis tickFormatter={(value: number) => `${Math.round(value / 1000)}к`} fontSize={12} stroke="var(--color-text-secondary)" tickLine={false} axisLine={false} />
<Tooltip labelFormatter={(value) => formatDate(String(value))} formatter={(value) => value == null ? '—' : money.format(Number(value))} />
<Line type="monotone" dataKey="value" name="Стоимость" stroke="var(--color-primary)" strokeWidth={2} dot={{ r: 3 }} connectNulls />
</LineChart>
</ResponsiveContainer>
</div>
</section>
))}
{performance?.accounts.map((account) => (
<section className="portfolio" key={`performance-${account.accountId}`} aria-labelledby={`performance-${account.accountId}`}>
<h2 id={`performance-${account.accountId}`} className="portfolio__title">Результат · {account.accountName}</h2>
<div className="summary portfolio__performance">
<div className="summary__card summary__card--investments"><div className="summary__label">Пополнения</div><div className="summary__value">{money.format(account.contributions / 100)}</div></div>
<div className="summary__card summary__card--investments"><div className="summary__label">Выводы</div><div className="summary__value">{money.format(account.withdrawals / 100)}</div></div>
<div className="summary__card summary__card--income"><div className="summary__label">Купоны, дивиденды и проценты</div><div className="summary__value">{money.format(account.income / 100)}</div></div>
<div className="summary__card summary__card--expense"><div className="summary__label">Комиссии</div><div className="summary__value">{money.format(account.fees / 100)}</div></div>
<div className="summary__card summary__card--positive"><div className="summary__label">Результат продаж</div><div className="summary__value">{money.format(account.realizedResult / 100)}</div></div>
{account.unrealizedResult !== null && <div className="summary__card summary__card--positive"><div className="summary__label">Нереализованный результат</div><div className="summary__value">{money.format(account.unrealizedResult / 100)}</div></div>}
</div>
</section>
))}
</div>
);
}

View File

@@ -341,6 +341,38 @@ button {
color: var(--color-text-muted);
}
.portfolio {
margin-bottom: 20px;
}
.portfolio__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.portfolio__title {
font-size: 18px;
font-weight: 850;
}
.portfolio__meta {
margin-top: 3px;
color: var(--color-text-secondary);
}
.portfolio__total {
font-size: 20px;
font-variant-numeric: tabular-nums;
}
.portfolio__chart {
min-height: 280px;
padding: 16px;
}
/* ================================================================
Forms, buttons, badges
================================================================ */
@@ -1462,6 +1494,11 @@ input[type="checkbox"] {
width: 100%;
}
.portfolio__header {
align-items: flex-start;
flex-direction: column;
}
.filters__row,
.analytics-panel__filters {
align-items: stretch;

View File

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

View File

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

View File

@@ -93,3 +93,58 @@ export interface ImportPortfolioResponse {
duplicateTrades: number;
positions: number;
}
export interface ImportBrokerReportResponse {
cash: ImportStatementResponse;
portfolio: ImportPortfolioResponse;
}
export interface PortfolioOverviewPosition {
instrument: string;
isin: string | null;
quantity: string;
price: string | null;
valuation: string | null;
}
export interface PortfolioOverviewAccount {
accountId: number;
accountName: string;
reportPeriodTo: string;
totalValuation: string | null;
positions: PortfolioOverviewPosition[];
}
export interface PortfolioOverviewResponse {
accounts: PortfolioOverviewAccount[];
}
export interface PortfolioHistoryPoint {
reportPeriodTo: string;
totalValuation: string | null;
}
export interface PortfolioHistoryAccount {
accountId: number;
accountName: string;
points: PortfolioHistoryPoint[];
}
export interface PortfolioHistoryResponse {
accounts: PortfolioHistoryAccount[];
}
export interface PortfolioPerformanceAccount {
accountId: number;
accountName: string;
contributions: number;
withdrawals: number;
income: number;
fees: number;
realizedResult: number;
unrealizedResult: number | null;
}
export interface PortfolioPerformanceResponse {
accounts: PortfolioPerformanceAccount[];
}

View File

@@ -43,6 +43,15 @@ export type {
PortfolioPosition,
PortfolioTrade,
ImportPortfolioResponse,
ImportBrokerReportResponse,
PortfolioOverviewPosition,
PortfolioOverviewAccount,
PortfolioOverviewResponse,
PortfolioHistoryPoint,
PortfolioHistoryAccount,
PortfolioHistoryResponse,
PortfolioPerformanceAccount,
PortfolioPerformanceResponse,
} from './import';
export type {