Compare commits
45 Commits
fix/analyt
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 353face40e | |||
| a6a46e0d84 | |||
| a6250f1592 | |||
| 22e9656e92 | |||
| 19a924647e | |||
| 8202d292cd | |||
| 2e1fc5976a | |||
| 52221dfdc1 | |||
| 7d2ccf9301 | |||
| 62ac3c387d | |||
| 58a4663aaf | |||
| c2e515ba8d | |||
| 71a18e8656 | |||
| be1c2cc701 | |||
| b94340823b | |||
| c69e55e045 | |||
| 94506e38c2 | |||
| 19d3c36c9e | |||
| 491a51cfb8 | |||
| 5adbcbd7bd | |||
| c82b26acf9 | |||
| ba2ed6a049 | |||
| 4d9663a906 | |||
| e0723b3ab6 | |||
| 29ba82f4bf | |||
| 5d478ff06c | |||
| e7233d85ee | |||
| b38346dd19 | |||
| 1e25a0152a | |||
| ef7732285b | |||
| 12d096beeb | |||
| 000c77f162 | |||
| 4bd3436ef6 | |||
| 84d6044d98 | |||
| c83a6d8d81 | |||
| 361a07d4da | |||
| 1dc0e48348 | |||
| 027fbedb8c | |||
| a1e93a7c1f | |||
| 3f5681074e | |||
| 62519f80cd | |||
| 45f0561fd6 | |||
| fab929fd68 | |||
| c4ce2b9d6b | |||
| c4f681c9b0 |
103
CHANGELOG.md
103
CHANGELOG.md
@@ -1,5 +1,108 @@
|
||||
# Changelog
|
||||
|
||||
## [Backend 0.15.6] - 2026-08-29
|
||||
|
||||
### Fixed
|
||||
|
||||
- Count broker withdrawals from explicit positive transfers to family accounts, linked by the broker account number in the operation description.
|
||||
|
||||
## [Backend 0.15.5] - 2026-08-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Trust a matched family-account transfer even when the broker uses an unexpected operation description.
|
||||
|
||||
## [Backend 0.15.4] - 2026-08-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reconcile split broker withdrawals with one aggregated transfer to a family account.
|
||||
|
||||
## [Backend 0.15.3] - 2026-08-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Recognize confirmed broker withdrawals even when the broker description does not contain the word «вывод».
|
||||
|
||||
## [Backend 0.15.2] - 2026-08-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Count broker deposits and withdrawals only when matched to a transfer on another family account, while retaining real explicit top-ups and withdrawals.
|
||||
|
||||
## [Backend 0.15.1] - 2026-08-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Excluded internal transfers for pending securities purchases from broker contributions.
|
||||
|
||||
## [Frontend 0.16.0 / Backend 0.15.0 / Shared 0.10.0] - 2026-08-28
|
||||
|
||||
### Added
|
||||
|
||||
- Revalued open portfolio positions from current MOEX quotes with a fallback to the latest broker report.
|
||||
- Broadened broker withdrawal detection in investment performance.
|
||||
|
||||
## [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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/backend",
|
||||
"version": "0.10.2",
|
||||
"version": "0.15.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/app.ts",
|
||||
@@ -11,10 +11,15 @@
|
||||
"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:moex": "tsx src/services/moex.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": {
|
||||
|
||||
@@ -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(
|
||||
(
|
||||
|
||||
@@ -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;
|
||||
|
||||
14
backend/src/routes/portfolioHistory.ts
Normal file
14
backend/src/routes/portfolioHistory.ts
Normal 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;
|
||||
14
backend/src/routes/portfolioOverview.ts
Normal file
14
backend/src/routes/portfolioOverview.ts
Normal 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;
|
||||
9
backend/src/routes/portfolioPerformance.ts
Normal file
9
backend/src/routes/portfolioPerformance.ts
Normal 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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
12
backend/src/services/import.test.ts
Normal file
12
backend/src/services/import.test.ts
Normal 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');
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
23
backend/src/services/moex.test.ts
Normal file
23
backend/src/services/moex.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { getMoexQuote } from './moex';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/securities/RU0000000002.json')) return new Response('{}', { status: 404 });
|
||||
if (url.includes('/securities.json') && url.includes('RU0000000002')) return new Response(JSON.stringify({ securities: { columns: ['secid', 'isin'], data: [['TEST2', 'RU0000000002']] } }), { status: 200 });
|
||||
if (url.includes('/securities/TEST2.json') && url.includes('iss.only=boards')) return new Response(JSON.stringify({ boards: { columns: ['engine', 'market', 'boardid', 'secid'], data: [['stock', 'shares', 'TQBR', 'TEST2']] } }), { status: 200 });
|
||||
if (url.includes('/securities/RU0000000001.json')) return new Response(JSON.stringify({ boards: { columns: ['engine', 'market', 'boardid', 'secid'], data: [['stock', 'shares', 'TQBR', 'TEST']] } }), { status: 200 });
|
||||
return new Response(JSON.stringify({ marketdata: { columns: ['LAST', 'LCURRENTPRICE', 'WAPRICE', 'UPDATETIME'], data: [[123.45, null, null, '2026-08-28 12:00:00']] } }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const quote = await getMoexQuote('RU0000000001');
|
||||
assert.deepEqual(quote, { price: 123.45, at: '2026-08-28 12:00:00' });
|
||||
assert.deepEqual(await getMoexQuote('RU0000000002'), { price: 123.45, at: '2026-08-28 12:00:00' });
|
||||
assert.equal(await getMoexQuote('bad-isin'), null);
|
||||
globalThis.fetch = originalFetch;
|
||||
console.log('MOEX quote: OK');
|
||||
}
|
||||
|
||||
run();
|
||||
54
backend/src/services/moex.ts
Normal file
54
backend/src/services/moex.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
type IssBlock = { columns?: string[]; data?: unknown[][] };
|
||||
|
||||
export type MarketQuote = { price: number; at: string | null };
|
||||
|
||||
const BASE_URL = 'https://iss.moex.com/iss';
|
||||
const timeoutMs = 5000;
|
||||
|
||||
function rows(block: IssBlock | undefined): Record<string, unknown>[] {
|
||||
if (!block?.columns || !block.data) return [];
|
||||
return block.data.map((row) => Object.fromEntries(block.columns!.map((column, index) => [column.toLowerCase(), row[index]])));
|
||||
}
|
||||
|
||||
async function getJson(url: string): Promise<Record<string, IssBlock>> {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), headers: { accept: 'application/json' } });
|
||||
if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`);
|
||||
return await response.json() as Record<string, IssBlock>;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number | null {
|
||||
const number = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(number) && number > 0 ? number : null;
|
||||
}
|
||||
|
||||
export async function getMoexQuote(isin: string): Promise<MarketQuote | null> {
|
||||
if (!/^RU[A-Z0-9]{10}$/.test(isin)) return null;
|
||||
try {
|
||||
let info: Record<string, IssBlock> = {};
|
||||
try {
|
||||
info = await getJson(`${BASE_URL}/securities/${encodeURIComponent(isin)}.json?iss.meta=off&iss.only=boards&boards.columns=engine,market,boardid,secid`);
|
||||
} catch { /* search by ISIN below */ }
|
||||
let boards = rows(info.boards);
|
||||
if (boards.length === 0) {
|
||||
const search = await getJson(`${BASE_URL}/securities.json?q=${encodeURIComponent(isin)}&iss.meta=off&iss.only=securities&securities.columns=secid,isin`);
|
||||
const match = rows(search.securities).find((item) => String(item.isin ?? '').toUpperCase() === isin);
|
||||
if (typeof match?.secid === 'string') {
|
||||
info = await getJson(`${BASE_URL}/securities/${encodeURIComponent(match.secid)}.json?iss.meta=off&iss.only=boards&boards.columns=engine,market,boardid,secid`);
|
||||
boards = rows(info.boards);
|
||||
}
|
||||
}
|
||||
boards = boards.filter((item) => typeof item.engine === 'string' && typeof item.market === 'string' && typeof item.boardid === 'string' && typeof item.secid === 'string');
|
||||
for (const board of boards) {
|
||||
try {
|
||||
const quote = await getJson(`${BASE_URL}/engines/${board.engine}/markets/${board.market}/boards/${board.boardid}/securities/${encodeURIComponent(String(board.secid))}.json?iss.meta=off&iss.only=marketdata&marketdata.columns=LAST,LCURRENTPRICE,WAPRICE,UPDATETIME`);
|
||||
const market = rows(quote.marketdata)[0];
|
||||
if (!market) continue;
|
||||
const price = numberValue(market.last) ?? numberValue(market.lcurrentprice) ?? numberValue(market.waprice);
|
||||
if (price !== null) return { price, at: typeof market.updatetime === 'string' ? market.updatetime : null };
|
||||
} catch { /* try the next trading board */ }
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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')");
|
||||
|
||||
@@ -1,9 +1,84 @@
|
||||
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 { getMoexQuote } from './moex';
|
||||
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;
|
||||
};
|
||||
|
||||
export type PerformanceCashRow = { id: number | string; account_id: number | string; alias: string | null; bank: string; account_number: string; account_type: string | null; operation_at: 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 };
|
||||
|
||||
export type PortfolioCashKind = 'income' | 'contribution' | 'withdrawal' | 'other';
|
||||
|
||||
export function extractBrokerWithdrawalAccount(description: string): string | null {
|
||||
return description.toLowerCase().replace(/ё/g, 'е').match(/вывод денежных средств с брокерского счета\s+([0-9]+)/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
export function classifyPortfolioCash(amount: number, description: string, matchedExternalTransfer = false): PortfolioCashKind {
|
||||
const text = description.toLowerCase().replace(/ё/g, 'е');
|
||||
if (amount > 0 && /дивиденд|купон|процент/.test(text)) return 'income';
|
||||
if (amount > 0 && (matchedExternalTransfer || /пополнение/.test(text)) && !text.includes('под нерассчитанные сделки')) return 'contribution';
|
||||
if (amount < 0 && matchedExternalTransfer) return 'withdrawal';
|
||||
if (amount < 0 && /вывод|перевод денежных средств|перечисление денежных средств|возврат денежных средств/.test(text) && !text.includes('под нерассчитанные сделки')) return 'withdrawal';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export function sumKnownPortfolioValuations(values: Array<number | null>): number | null {
|
||||
const known = values.filter((value): value is number => value !== null && Number.isFinite(value));
|
||||
return known.length > 0 ? known.reduce((sum, value) => sum + value, 0) : null;
|
||||
}
|
||||
|
||||
export function matchExternalCashTransfers(rows: PerformanceCashRow[]): Set<number | string> {
|
||||
const broker = rows.filter((row) => ['brokerage', 'iis'].includes(row.account_type ?? ''));
|
||||
const external = rows.filter((row) => !['brokerage', 'iis'].includes(row.account_type ?? ''));
|
||||
const matchedBroker = new Set<number | string>();
|
||||
const matchedExternal = new Set<number | string>();
|
||||
for (const target of external) {
|
||||
const targetAmount = Math.round(Number(target.amount_signed));
|
||||
if (!targetAmount || matchedExternal.has(target.id)) continue;
|
||||
const candidates = broker.filter((row) => !matchedBroker.has(row.id) && Math.sign(Number(row.amount_signed)) === -Math.sign(targetAmount) && Math.abs(Math.round(Number(row.amount_signed))) <= Math.abs(targetAmount) && Math.abs(Date.parse(row.operation_at) - Date.parse(target.operation_at)) <= 3 * 24 * 60 * 60 * 1000);
|
||||
const exact = candidates.find((row) => Math.round(Number(row.amount_signed)) === -targetAmount);
|
||||
let group: PerformanceCashRow[] = exact ? [exact] : [];
|
||||
if (group.length === 0) {
|
||||
for (let index = 0; index < candidates.length && group.length === 0; index += 1) {
|
||||
for (const second of candidates.slice(index + 1)) {
|
||||
if (Math.round(Number(candidates[index].amount_signed)) + Math.round(Number(second.amount_signed)) === -targetAmount) { group = [candidates[index], second]; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (group.length === 0) continue;
|
||||
matchedExternal.add(target.id);
|
||||
group.forEach((row) => matchedBroker.add(row.id));
|
||||
}
|
||||
return matchedBroker;
|
||||
}
|
||||
|
||||
function decimalValue(value: unknown, field: string, required = false): string | null {
|
||||
if (value == null || value === '') {
|
||||
if (required) throw new Error(`${field} is required`);
|
||||
@@ -45,13 +120,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 +145,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 +168,232 @@ 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`,
|
||||
);
|
||||
const overview = toPortfolioOverview(rows);
|
||||
const quoteCache = new Map<string, Awaited<ReturnType<typeof getMoexQuote>>>();
|
||||
for (const account of overview.accounts) {
|
||||
const currentValuations: Array<number | null> = [];
|
||||
let hasMarket = false;
|
||||
let hasFallback = false;
|
||||
let valuationAsOf: string | null = null;
|
||||
for (const position of account.positions) {
|
||||
if (!position.isin) { hasFallback = true; currentValuations.push(position.valuation === null ? null : Number(position.valuation)); continue; }
|
||||
if (!quoteCache.has(position.isin)) quoteCache.set(position.isin, await getMoexQuote(position.isin));
|
||||
const quote = quoteCache.get(position.isin);
|
||||
if (!quote) { hasFallback = true; currentValuations.push(position.valuation === null ? null : Number(position.valuation)); continue; }
|
||||
const reportPrice = position.price === null ? null : Number(position.price);
|
||||
const reportValuation = position.valuation === null ? null : Number(position.valuation);
|
||||
const currentValuationForPosition = reportPrice && reportValuation !== null
|
||||
? reportValuation * quote.price / reportPrice
|
||||
: Number(position.quantity) * quote.price;
|
||||
if (!Number.isFinite(currentValuationForPosition)) continue;
|
||||
position.currentPrice = String(quote.price);
|
||||
position.currentValuation = String(currentValuationForPosition);
|
||||
position.quoteAt = quote.at;
|
||||
hasMarket = true;
|
||||
currentValuations.push(currentValuationForPosition);
|
||||
if (quote.at && (!valuationAsOf || quote.at > valuationAsOf)) valuationAsOf = quote.at;
|
||||
}
|
||||
const currentValuation = sumKnownPortfolioValuations(currentValuations);
|
||||
if (currentValuation !== null) {
|
||||
account.currentValuation = String(currentValuation);
|
||||
account.valuationAsOf = valuationAsOf;
|
||||
account.valuationSource = hasMarket ? (hasFallback ? 'mixed' : 'market') : 'report';
|
||||
}
|
||||
}
|
||||
return overview;
|
||||
}
|
||||
|
||||
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 t.id, a.id AS account_id, a.alias, a.bank, a.account_number, a.account_type, t.operation_at, t.amount_signed, t.description FROM accounts a JOIN transactions t ON t.account_id = a.id 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]>();
|
||||
const brokerRows = cashResult.rows.filter((item) => ['brokerage', 'iis'].includes(item.account_type ?? ''));
|
||||
const matchedBrokerTransfers = matchExternalCashTransfers(cashResult.rows);
|
||||
const accountByReference = new Map(brokerRows.map((row) => [Number(row.account_id), row.account_number.replace(/\D/g, '')]));
|
||||
for (const row of brokerRows) {
|
||||
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 kind = classifyPortfolioCash(amount, row.description, amount > 0 && matchedBrokerTransfers.has(row.id));
|
||||
if (kind === 'income') account.income += amount;
|
||||
else if (kind === 'contribution') account.contributions += amount;
|
||||
accounts.set(accountId, account);
|
||||
}
|
||||
for (const row of cashResult.rows.filter((item) => !['brokerage', 'iis'].includes(item.account_type ?? '') && Number(item.amount_signed) > 0)) {
|
||||
const reference = extractBrokerWithdrawalAccount(row.description);
|
||||
if (!reference) continue;
|
||||
const accountEntry = [...accountByReference.entries()].find(([, accountNumber]) => accountNumber.endsWith(reference) || reference.endsWith(accountNumber));
|
||||
if (!accountEntry) continue;
|
||||
const accountId = accountEntry[0];
|
||||
const source = brokerRows.find((item) => Number(item.account_id) === accountId);
|
||||
const account = accounts.get(accountId) ?? { accountId, accountName: source?.alias || `${source?.bank ?? 'Брокерский счёт'} · ${maskAccountNumber(source?.account_number ?? reference)}`, contributions: 0, withdrawals: 0, income: 0, fees: 0, realizedResult: 0, unrealizedResult: null };
|
||||
account.withdrawals += Number(row.amount_signed);
|
||||
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()] };
|
||||
}
|
||||
|
||||
46
backend/src/services/portfolioOverview.test.ts
Normal file
46
backend/src/services/portfolioOverview.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { calculatePortfolioTradeResults, classifyPortfolioCash, extractBrokerWithdrawalAccount, matchExternalCashTransfers, sumKnownPortfolioValuations, 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');
|
||||
|
||||
assert.equal(classifyPortfolioCash(-12500, 'Перечисление денежных средств со счёта'), 'withdrawal');
|
||||
assert.equal(classifyPortfolioCash(-12500, 'Перевод денежных средств под нерассчитанные сделки'), 'other');
|
||||
assert.equal(classifyPortfolioCash(52000, 'Зачисление денежных средств для приобретения ценных бумаг'), 'other');
|
||||
assert.equal(classifyPortfolioCash(52000, 'Зачисление денежных средств для приобретения ценных бумаг', true), 'contribution');
|
||||
assert.equal(classifyPortfolioCash(52000, 'Пополнение брокерского счёта'), 'contribution');
|
||||
assert.equal(classifyPortfolioCash(-129000, 'Вывод ДС под нерассчитанные сделки', true), 'withdrawal');
|
||||
assert.equal(classifyPortfolioCash(-129000, 'Сальдо расчетов по сделкам с ценными бумагами', true), 'withdrawal');
|
||||
assert.equal(extractBrokerWithdrawalAccount('Перевод между своими счетами. Вывод денежных средств с брокерского счета 30601 по распоряжению от 2026-05-12.'), '30601');
|
||||
assert.equal(extractBrokerWithdrawalAccount('Вывод денежных средств с брокерского счёта 30601'), '30601');
|
||||
assert.equal(sumKnownPortfolioValuations([100, null, 25]), 125);
|
||||
const matched = matchExternalCashTransfers([
|
||||
{ id: 1, account_id: 1, alias: null, bank: 'VTB', account_number: 'broker', account_type: 'brokerage', operation_at: '2026-05-12T00:00:00+03:00', amount_signed: '-5102321', description: 'Нестандартное описание операции' },
|
||||
{ id: 2, account_id: 1, alias: null, bank: 'VTB', account_number: 'broker', account_type: 'brokerage', operation_at: '2026-05-13T00:00:00+03:00', amount_signed: '-4885479', description: 'Вывод ДС под нерассчитанные сделки' },
|
||||
{ id: 3, account_id: 2, alias: null, bank: 'VTB', account_number: 'current', account_type: 'current', operation_at: '2026-05-12T00:00:00+03:00', amount_signed: '9987800', description: 'Вывод денежных средств с брокерского счета' },
|
||||
]);
|
||||
assert.deepEqual([...matched], [1, 2]);
|
||||
console.log('portfolio cash classification: OK');
|
||||
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,11 +1,12 @@
|
||||
{
|
||||
"name": "@family-budget/frontend",
|
||||
"version": "0.11.1",
|
||||
"version": "0.16.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"test:portfolio": "tsx src/api/portfolio.test.ts",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
17
frontend/src/api/portfolio.test.ts
Normal file
17
frontend/src/api/portfolio.test.ts
Normal 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;
|
||||
}
|
||||
24
frontend/src/api/portfolio.ts
Normal file
24
frontend/src/api/portfolio.ts
Normal 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');
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
96
frontend/src/pages/PortfolioPage.tsx
Normal file
96
frontend/src/pages/PortfolioPage.tsx
Normal 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">{account.valuationSource === 'mixed' ? 'Часть позиций по последнему отчёту' : account.valuationSource === 'report' ? `Стоимость по отчёту на ${formatDate(account.reportPeriodTo)}` : `Котировки на ${formatDate(account.valuationAsOf ?? account.reportPeriodTo)}`}</p>
|
||||
</div>
|
||||
{(account.currentValuation ?? account.totalValuation) !== null && <strong className="portfolio__total">{money.format(Number(account.currentValuation ?? 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.currentPrice ?? position.price) === null ? '—' : money.format(Number(position.currentPrice ?? position.price))}</td>
|
||||
<td className="data-table__cell data-table__cell--nowrap money-amount">{(position.currentValuation ?? position.valuation) === null ? '—' : money.format(Number(position.currentValuation ?? 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
45
package-lock.json
generated
45
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "family-budget",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "family-budget",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"workspaces": [
|
||||
"shared",
|
||||
"backend",
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"backend": {
|
||||
"name": "@family-budget/backend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.15.6",
|
||||
"dependencies": {
|
||||
"@family-budget/shared": "*",
|
||||
"cookie-parser": "^1.4.7",
|
||||
@@ -64,7 +64,7 @@
|
||||
},
|
||||
"frontend": {
|
||||
"name": "@family-budget/frontend",
|
||||
"version": "0.9.0",
|
||||
"version": "0.16.0",
|
||||
"dependencies": {
|
||||
"@family-budget/shared": "*",
|
||||
"react": "^19.0.0",
|
||||
@@ -111,7 +111,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1395,7 +1394,6 @@
|
||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
@@ -1474,7 +1472,6 @@
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1614,7 +1611,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2738,7 +2734,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
|
||||
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.11.0",
|
||||
"pg-pool": "^3.12.0",
|
||||
@@ -2836,7 +2831,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -2986,7 +2980,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -2996,7 +2989,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -3500,6 +3492,7 @@
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3517,6 +3510,7 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3534,6 +3528,7 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3551,6 +3546,7 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3568,6 +3564,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3585,6 +3582,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3602,6 +3600,7 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3619,6 +3618,7 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3636,6 +3636,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3653,6 +3654,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3670,6 +3672,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3687,6 +3690,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3704,6 +3708,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3721,6 +3726,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3738,6 +3744,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3755,6 +3762,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3772,6 +3780,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3789,6 +3798,7 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3806,6 +3816,7 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3823,6 +3834,7 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3840,6 +3852,7 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3857,6 +3870,7 @@
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3874,6 +3888,7 @@
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3891,6 +3906,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3908,6 +3924,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3925,6 +3942,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -4108,7 +4126,6 @@
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -4202,7 +4219,7 @@
|
||||
},
|
||||
"shared": {
|
||||
"name": "@family-budget/shared",
|
||||
"version": "0.1.0",
|
||||
"version": "0.10.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/shared",
|
||||
"version": "0.5.0",
|
||||
"version": "0.10.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,64 @@ 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;
|
||||
currentPrice?: string | null;
|
||||
currentValuation?: string | null;
|
||||
quoteAt?: string | null;
|
||||
}
|
||||
|
||||
export interface PortfolioOverviewAccount {
|
||||
accountId: number;
|
||||
accountName: string;
|
||||
reportPeriodTo: string;
|
||||
totalValuation: string | null;
|
||||
currentValuation?: string | null;
|
||||
valuationAsOf?: string | null;
|
||||
valuationSource?: 'market' | 'mixed' | 'report';
|
||||
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[];
|
||||
}
|
||||
|
||||
@@ -43,6 +43,15 @@ export type {
|
||||
PortfolioPosition,
|
||||
PortfolioTrade,
|
||||
ImportPortfolioResponse,
|
||||
ImportBrokerReportResponse,
|
||||
PortfolioOverviewPosition,
|
||||
PortfolioOverviewAccount,
|
||||
PortfolioOverviewResponse,
|
||||
PortfolioHistoryPoint,
|
||||
PortfolioHistoryAccount,
|
||||
PortfolioHistoryResponse,
|
||||
PortfolioPerformanceAccount,
|
||||
PortfolioPerformanceResponse,
|
||||
} from './import';
|
||||
|
||||
export type {
|
||||
|
||||
Reference in New Issue
Block a user