Compare commits

...

73 Commits

Author SHA1 Message Date
353face40e Merge pull request 'Учитывать подтверждённый вывод с брокерского счёта' (#52) from fix/confirmed-broker-withdrawals into main
Reviewed-on: #52
2026-08-28 22:09:10 +00:00
a6a46e0d84 fix: count confirmed broker withdrawals by account reference 2026-08-29 01:02:32 +03:00
a6250f1592 fix: match broker transfers regardless of description 2026-08-28 23:54:10 +03:00
22e9656e92 fix: reconcile split broker withdrawals 2026-08-28 23:52:53 +03:00
19a924647e fix: count matched broker cash withdrawals 2026-08-28 23:41:32 +03:00
8202d292cd Merge pull request 'Исправить классификацию денежных движений брокера' (#51) from fix/broker-cash-flow-classification into main
Reviewed-on: #51
2026-08-28 20:09:18 +00:00
2e1fc5976a fix: reconcile broker transfers with family accounts 2026-08-28 08:59:43 +03:00
52221dfdc1 fix: exclude internal broker cash transfers 2026-08-28 08:56:02 +03:00
7d2ccf9301 Merge pull request 'Актуальная оценка брокерского портфеля' (#50) from fix/portfolio-market-valuation into main
Reviewed-on: #50
2026-08-28 05:49:19 +00:00
62ac3c387d fix: handle quote fallbacks safely 2026-08-28 08:27:36 +03:00
58a4663aaf fix: resolve MOEX quotes by ISIN search 2026-08-28 08:25:06 +03:00
c2e515ba8d feat: refresh portfolio valuation from market quotes 2026-08-28 08:23:56 +03:00
71a18e8656 Merge pull request 'feat: добавить инвестиционный результат брокера' (#49) from feature/portfolio-performance into main
Reviewed-on: #49
2026-08-27 07:02:26 +00:00
be1c2cc701 docs: note broker income label 2026-08-27 08:43:25 +03:00
b94340823b fix: label broker income metrics 2026-08-27 08:42:47 +03:00
c69e55e045 docs: note portfolio API test 2026-08-27 08:40:13 +03:00
94506e38c2 test: cover portfolio UI API contract 2026-08-27 08:39:20 +03:00
19d3c36c9e docs: note portfolio overlap coverage 2026-08-27 08:36:23 +03:00
491a51cfb8 test: cover overlapping portfolio imports 2026-08-27 08:34:49 +03:00
5adbcbd7bd docs: note broker performance 2026-08-27 08:31:04 +03:00
c82b26acf9 fix: avoid incomplete unrealized result 2026-08-27 08:29:22 +03:00
ba2ed6a049 feat: add broker investment performance 2026-08-27 08:27:40 +03:00
4d9663a906 Merge pull request 'feat: добавить динамику стоимости портфеля' (#48) from feature/portfolio-history into main
Reviewed-on: #48
2026-08-27 05:22:35 +00:00
e0723b3ab6 docs: note portfolio history 2026-08-27 08:19:55 +03:00
29ba82f4bf feat: add portfolio valuation history 2026-08-27 08:17:33 +03:00
5d478ff06c Merge pull request 'feat: обзор последнего брокерского портфеля' (#47) from feature/portfolio-overview into main
Reviewed-on: #47
2026-08-27 05:12:18 +00:00
e7233d85ee docs: note portfolio overview 2026-08-27 08:10:21 +03:00
b38346dd19 feat: add portfolio overview 2026-08-27 00:07:25 +03:00
1e25a0152a Merge pull request 'Добавить загрузку XLSX-отчёта ВТБ Брокер' (#46) from feature/broker-xlsx-upload into main
Reviewed-on: #46
2026-08-26 20:59:15 +00:00
ef7732285b fix: limit broker XLSX extraction 2026-08-26 23:45:09 +03:00
12d096beeb fix: make broker XLSX import atomic 2026-08-26 23:42:26 +03:00
000c77f162 feat: import VTB broker XLSX reports 2026-08-26 23:36:32 +03:00
4bd3436ef6 Merge pull request 'Добавить импорт JSON брокерского портфеля' (#45) from feature/broker-report-import into main
Reviewed-on: #45
2026-08-26 20:30:14 +00:00
84d6044d98 docs: add broker portfolio import changelog 2026-08-26 23:28:36 +03:00
c83a6d8d81 feat: import broker portfolio JSON 2026-08-26 23:26:15 +03:00
361a07d4da Merge pull request 'Выделить кэшбек в движении денежных средств' (#44) from feature/analytics-cashback-income into main
Reviewed-on: #44
2026-08-26 20:23:15 +00:00
1dc0e48348 docs: add cashback summary changelog 2026-08-26 07:29:12 +03:00
027fbedb8c feat: show cashback in cash flow summary 2026-08-26 07:26:14 +03:00
a1e93a7c1f Merge pull request 'Исправить импорт одинаковых операций из JSON' (#42) from fix/json-import-duplicates into main
Reviewed-on: #42
2026-08-25 21:49:31 +00:00
3f5681074e fix: preserve overlapping import deduplication 2026-08-26 00:43:43 +03:00
62519f80cd fix: import identical JSON transactions 2026-08-26 00:39:55 +03:00
45f0561fd6 Merge pull request 'Исправить переводы из закрытых накопительных счетов ВТБ' (#40) from fix/vtb-savings-transfers into main
Reviewed-on: #40
2026-08-24 20:32:52 +00:00
fab929fd68 docs: update VTB transfer changelog 2026-08-24 23:31:15 +03:00
c4ce2b9d6b fix: classify VTB savings transfers 2026-08-24 23:25:43 +03:00
c4f681c9b0 Merge pull request 'Дополнить changelog валидации аналитики' (#39) from fix/analytics-validation-changelog into main
Reviewed-on: #39
2026-08-24 19:17:36 +00:00
dd21e20dc6 docs: update analytics validation changelog 2026-08-24 22:15:56 +03:00
29c4acd0a9 Merge pull request 'Исправить валидацию фильтров аналитики' (#38) from fix/analytics-query-validation into main
Reviewed-on: #38
2026-08-24 19:09:48 +00:00
8939843462 fix: validate analytics filter ids 2026-08-24 22:06:24 +03:00
ce162855f9 Merge pull request 'Исправить блокеры аналитики после PR #35' (#37) from feature/analytics-fixes into main
Reviewed-on: #37
2026-08-24 18:59:50 +00:00
7671ab76b2 fix: close analytics review findings 2026-08-21 07:23:57 +03:00
cce2ddcf41 Merge pull request 'Откатить PR #35 до исправления замечаний' (#36) from revert/analytics-category-filter into main
Reviewed-on: #36
2026-08-21 04:21:23 +00:00
7154e8f2ea fix: complete analytics edge cases 2026-08-21 06:13:23 +03:00
d86624b9ef Revert "Merge pull request 'Завершить аналитику расходов и доходов' (#35) from feature/analytics-completion into main"
This reverts commit 66c04b0618, reversing
changes made to efc9854064.
2026-08-21 06:10:20 +03:00
66c04b0618 Merge pull request 'Завершить аналитику расходов и доходов' (#35) from feature/analytics-completion into main
Reviewed-on: #35
2026-08-21 03:08:00 +00:00
24b8ed8261 feat: complete analytics category filtering 2026-08-21 00:08:59 +03:00
efc9854064 Merge pull request 'Сохранять порядок операций из выписки' (#34) from feature/transaction-source-order into main
Reviewed-on: #34
2026-08-20 20:58:26 +00:00
9755332204 test: verify imported source positions 2026-08-20 23:25:32 +03:00
669e54f6cb test: cover transaction ordering 2026-08-20 23:24:24 +03:00
ab42caae37 feat: preserve transaction source order 2026-08-20 23:20:56 +03:00
4172b0c8e4 Merge pull request 'Добавить импорт портфеля брокерского отчёта' (#33) from feature/portfolio-holdings into main
Reviewed-on: #33
2026-08-20 20:15:12 +00:00
d2354a61ed fix: validate portfolio trade identifiers 2026-08-20 15:04:04 +03:00
6db1312fb1 test: cover portfolio import idempotency 2026-08-20 15:02:47 +03:00
e0da7e86ad fix: harden portfolio import 2026-08-20 15:01:54 +03:00
8d91bea7a9 feat: add broker portfolio import 2026-08-20 14:55:53 +03:00
0003d92583 Merge pull request 'Применять правило ко всем похожим операциям' (#32) from feature/apply-category-rule into main
Reviewed-on: #32
2026-08-20 06:25:49 +00:00
6515e73ead feat: apply category rules on creation 2026-08-20 09:18:17 +03:00
5f282d199d Merge pull request 'Добавить типы счетов и доход от процентов' (#31) from feature/account-metadata-interest into main
Reviewed-on: #31
2026-08-20 06:15:31 +00:00
3d7d0e639c test: fix import integration cleanup 2026-08-20 08:47:53 +03:00
1b20b76704 test: cover account metadata reimport 2026-08-20 08:46:49 +03:00
c6b79c1d35 fix: finalize account labels and confirmation 2026-08-20 08:45:22 +03:00
dd2990357a fix: allow investment rules and label accounts 2026-08-20 08:43:29 +03:00
6550e2057c feat: add account metadata and interest analytics 2026-08-20 08:40:03 +03:00
f8e4f86a6d Merge pull request 'Исправить строки футера боковой панели' (#30) from fix/sidebar-footer-layout into main
Reviewed-on: #30
2026-08-20 04:51:51 +00:00
50 changed files with 2010 additions and 106 deletions

View File

@@ -1,5 +1,204 @@
# 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
- Analytics endpoints now reject invalid account and category filter IDs with a validation error.
## [Frontend 0.11.1] - 2026-08-21
### Fixed
- Preserve the explicit «Без категории» analytics filter value when requesting data.
## [Frontend 0.11.0 / Backend 0.10.1 / Shared 0.5.0] - 2026-08-21
### Fixed
- Corrected analytics period boundaries for partial week/month ranges and added a usable «Без категории» filter with SQL coverage.
## [Backend 0.9.2] - 2026-08-20
### Added
- Extended import integration coverage to verify source positions for same-date operations.
## [Backend 0.9.1] - 2026-08-20
### Added
- Added runnable coverage for date and amount sorting tie-breakers.
## [Backend 0.9.0] - 2026-08-20
### Added
- Preserved source-array transaction positions and used them as stable tie-breakers for same-date history sorting.
## [Backend 0.8.3] - 2026-08-20
### Fixed
- Invalid non-string trade identifiers now return validation errors instead of server errors.
## [Backend 0.8.2] - 2026-08-20
### Added
- Added a database integration test for repeated portfolio imports and trade deduplication.
## [Backend 0.8.1] - 2026-08-20
### Fixed
- Hardened portfolio import validation, preserved decimal precision, added fallback trade keys, and covered validation with a runnable test.
## [Backend 0.8.0 / Shared 0.4.0] - 2026-08-20
### Added
- Added portfolio JSON import for broker reports with idempotent trades, position snapshots, and separate portfolio tables.
## [Backend 0.7.5 / Shared 0.3.1] - 2026-08-20
### Added
- Creating a category rule now applies it atomically to all matching unconfirmed transactions and returns the affected count.
## [Backend 0.7.4] - 2026-08-20
### Fixed
- Corrected database integration test connection ownership during repeated imports.
## [Backend 0.7.3] - 2026-08-20
### Added
- Added a runnable database integration test for preserving account metadata during re-import.
## [Frontend 0.10.2 / Backend 0.7.2] - 2026-08-20
### Fixed
- Existing uncategorized operations become confirmed investments when an investment account is labeled; account selectors use Russian labels.
## [Frontend 0.10.1 / Backend 0.7.1] - 2026-08-20
### Fixed
- Account labels are shown in analytics filters, and account defaults now remain overridable by matching category rules.
## [Frontend 0.10.0 / Backend 0.7.0 / Shared 0.3.0] - 2026-08-20
### Added
- Added account type/status labels, automatic investment classification for brokerage, IIS, and savings accounts, and a separate interest-income metric.
## [Frontend 0.9.3] - 2026-08-20
### Fixed

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/backend",
"version": "0.6.8",
"version": "0.15.6",
"private": true,
"scripts": {
"dev": "tsx watch src/app.ts",
@@ -9,7 +9,17 @@
"migrate": "tsx src/db/migrate.ts",
"migrate:prod": "node dist/db/migrate.js",
"test:analytics": "tsx src/services/analyticsSemantics.test.ts",
"test:analytics:query": "tsx src/routes/analytics.test.ts",
"test:portfolio": "tsx src/services/portfolio.test.ts",
"test:portfolio:overview": "tsx src/services/portfolioOverview.test.ts",
"test:portfolio:performance": "tsx src/services/portfolioOverview.test.ts",
"test:portfolio: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": {

View File

@@ -19,6 +19,10 @@ import accountsRouter from './routes/accounts';
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);
@@ -43,6 +47,10 @@ app.use('/api/accounts', accountsRouter);
app.use('/api/categories', categoriesRouter);
app.use('/api/category-rules', categoryRulesRouter);
app.use('/api/analytics', analyticsRouter);
app.use('/api/import/portfolio', portfolioRouter);
app.use('/api/portfolio', portfolioOverviewRouter);
app.use('/api/portfolio/history', portfolioHistoryRouter);
app.use('/api/portfolio/performance', portfolioPerformanceRouter);
app.use(
(

View File

@@ -222,6 +222,80 @@ const migrations: { name: string; sql: string }[] = [
ADD COLUMN IF NOT EXISTS import_id BIGINT REFERENCES imports(id);
`,
},
{
name: '007_account_metadata',
sql: `
ALTER TABLE accounts
ADD COLUMN IF NOT EXISTS account_type TEXT,
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active';
ALTER TABLE accounts DROP CONSTRAINT IF EXISTS chk_accounts_type;
ALTER TABLE accounts
ADD CONSTRAINT chk_accounts_type
CHECK (account_type IS NULL OR account_type IN ('brokerage', 'iis', 'savings', 'current'));
ALTER TABLE accounts DROP CONSTRAINT IF EXISTS chk_accounts_status;
ALTER TABLE accounts
ADD CONSTRAINT chk_accounts_status
CHECK (status IN ('active', 'closed'));
`,
},
{
name: '008_portfolio_tables',
sql: `
CREATE TABLE IF NOT EXISTS portfolio_reports (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL REFERENCES accounts(id),
source_hash TEXT NOT NULL,
report_period_from DATE NOT NULL,
report_period_to DATE NOT NULL,
reported_at TIMESTAMPTZ,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (account_id, source_hash)
);
CREATE TABLE IF NOT EXISTS portfolio_positions (
id BIGSERIAL PRIMARY KEY,
report_id BIGINT NOT NULL REFERENCES portfolio_reports(id) ON DELETE CASCADE,
instrument TEXT NOT NULL,
isin TEXT,
quantity NUMERIC NOT NULL,
price NUMERIC,
valuation NUMERIC
);
CREATE TABLE IF NOT EXISTS portfolio_trades (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL REFERENCES accounts(id),
source_id TEXT NOT NULL,
operation_id UUID NOT NULL,
instrument TEXT NOT NULL,
isin TEXT,
concluded_at TIMESTAMPTZ NOT NULL,
side TEXT NOT NULL,
quantity NUMERIC NOT NULL,
price_currency TEXT,
price NUMERIC,
settlement_currency TEXT,
settlement_amount NUMERIC,
nkd NUMERIC,
settlement_commission NUMERIC,
trade_commission NUMERIC,
order_id TEXT,
trade_id TEXT,
venue TEXT,
comment TEXT,
UNIQUE (account_id, source_id)
);
CREATE INDEX IF NOT EXISTS ix_portfolio_trades_account_date
ON portfolio_trades(account_id, concluded_at DESC);
`,
},
{
name: '009_transaction_source_position',
sql: `
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS source_position BIGINT NOT NULL DEFAULT 0;
UPDATE transactions SET source_position = id WHERE source_position = 0;
CREATE INDEX IF NOT EXISTS ix_transactions_date_position
ON transactions(operation_at, source_position, id);
`,
},
];
export async function runMigrations(): Promise<void> {

View File

@@ -21,7 +21,7 @@ router.put(
return;
}
const { alias } = req.body;
const { alias, accountType, status } = req.body;
if (typeof alias !== 'string' || !alias.trim()) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'alias is required and must be non-empty' });
return;
@@ -30,8 +30,21 @@ router.put(
res.status(400).json({ error: 'BAD_REQUEST', message: 'alias must be at most 50 characters' });
return;
}
if (accountType !== undefined && accountType !== null && !['brokerage', 'iis', 'savings', 'current'].includes(accountType)) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'Invalid accountType' });
return;
}
if (status !== undefined && !['active', 'closed'].includes(status)) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'Invalid status' });
return;
}
const result = await accountService.updateAccountAlias(id, alias.trim());
const result = await accountService.updateAccount(
id,
alias.trim(),
accountType,
status,
);
if (!result) {
res.status(404).json({ error: 'NOT_FOUND', message: 'Account not found' });
return;

View File

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

View File

@@ -5,10 +5,34 @@ import type { Granularity } from '@family-budget/shared';
const router = Router();
export function parseOptionalId(value: unknown, minimum: number): number | null | undefined {
if (value === undefined) return undefined;
if (typeof value !== 'string' || !/^\d+$/.test(value)) return null;
const id = Number(value);
return Number.isSafeInteger(id) && id >= minimum ? id : null;
}
router.use((req, res, next) => {
const accountId = parseOptionalId(req.query.accountId, 1);
if (accountId === null) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'accountId must be a positive integer' });
return;
}
const categoryId = parseOptionalId(req.query.categoryId, 0);
if (categoryId === null) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'categoryId must be a non-negative integer' });
return;
}
next();
});
router.get(
'/summary',
asyncHandler(async (req, res) => {
const { from, to, accountId, onlyConfirmed } = req.query;
const { from, to, accountId, categoryId, onlyConfirmed } = req.query;
if (!from || !to) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'from and to are required' });
return;
@@ -18,6 +42,7 @@ router.get(
from: from as string,
to: to as string,
accountId: accountId ? Number(accountId) : undefined,
categoryId: categoryId !== undefined ? Number(categoryId) : undefined,
onlyConfirmed: onlyConfirmed === 'true',
});
res.json(result);
@@ -27,7 +52,7 @@ router.get(
router.get(
'/by-category',
asyncHandler(async (req, res) => {
const { from, to, accountId, onlyConfirmed } = req.query;
const { from, to, accountId, categoryId, onlyConfirmed } = req.query;
if (!from || !to) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'from and to are required' });
return;
@@ -37,6 +62,7 @@ router.get(
from: from as string,
to: to as string,
accountId: accountId ? Number(accountId) : undefined,
categoryId: categoryId !== undefined ? Number(categoryId) : undefined,
onlyConfirmed: onlyConfirmed === 'true',
});
res.json(result);
@@ -62,7 +88,7 @@ router.get(
from: from as string,
to: to as string,
accountId: accountId ? Number(accountId) : undefined,
categoryId: categoryId ? Number(categoryId) : undefined,
categoryId: categoryId !== undefined ? Number(categoryId) : undefined,
onlyConfirmed: onlyConfirmed === 'true',
granularity: granularity as Granularity,
});

View File

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

View File

@@ -0,0 +1,22 @@
import { Router } from 'express';
import { asyncHandler } from '../utils';
import * as portfolioService from '../services/portfolio';
const router = Router();
router.post(
'/',
asyncHandler(async (req, res) => {
try {
res.status(201).json(await portfolioService.importPortfolio(req.body));
} catch (error) {
if (error instanceof Error && /Invalid|Duplicate|must be|required|numeric/.test(error.message)) {
res.status(422).json({ error: 'VALIDATION_ERROR', message: error.message });
return;
}
throw error;
}
}),
);
export default router;

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { pool } from '../db/pool';
import { maskAccountNumber } from '../utils';
import type { Account } from '@family-budget/shared';
import type { Account, AccountStatus, AccountType } from '@family-budget/shared';
function toAccount(r: Record<string, unknown>): Account {
return {
@@ -9,6 +9,8 @@ function toAccount(r: Record<string, unknown>): Account {
accountNumberMasked: maskAccountNumber(r.account_number as string),
currency: r.currency as string,
alias: (r.alias as string) ?? null,
accountType: (r.account_type as AccountType) ?? null,
status: (r.status as AccountStatus) ?? 'active',
};
}
@@ -19,14 +21,45 @@ export async function getAccounts(): Promise<Account[]> {
return rows.map(toAccount);
}
export async function updateAccountAlias(
export async function updateAccount(
id: number,
alias: string,
accountType?: AccountType | null,
status?: AccountStatus,
): Promise<Account | null> {
const { rows } = await pool.query(
'UPDATE accounts SET alias = $1 WHERE id = $2 RETURNING *',
[alias, id],
);
if (rows.length === 0) return null;
return toAccount(rows[0]);
const client = await pool.connect();
try {
await client.query('BEGIN');
const current = await client.query('SELECT account_type, status FROM accounts WHERE id = $1', [id]);
if (current.rows.length === 0) {
await client.query('ROLLBACK');
return null;
}
const nextType = accountType === undefined ? current.rows[0].account_type : accountType;
const nextStatus = status ?? current.rows[0].status ?? 'active';
const { rows } = await client.query(
'UPDATE accounts SET alias = $1, account_type = $2, status = $3 WHERE id = $4 RETURNING *',
[alias, nextType, nextStatus, id],
);
if (rows.length === 0) {
await client.query('ROLLBACK');
return null;
}
if (['brokerage', 'iis', 'savings'].includes(nextType ?? '')) {
await client.query(
`UPDATE transactions
SET category_id = (SELECT id FROM categories WHERE name = 'Инвестиции' AND type = 'transfer' LIMIT 1),
direction = 'transfer', is_category_confirmed = TRUE, updated_at = NOW()
WHERE account_id = $1 AND category_id IS NULL`,
[id],
);
}
await client.query('COMMIT');
return toAccount(rows[0]);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}

View File

@@ -6,7 +6,7 @@ async function testQueries(): Promise<void> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const account = await client.query("INSERT INTO accounts (bank, account_number, currency) VALUES ('TEST', 'analytics-test-1', 'RUB') RETURNING id");
const account = await client.query("INSERT INTO accounts (bank, account_number, currency, account_type) VALUES ('TEST', 'analytics-test-1', 'RUB', 'savings') RETURNING id");
const otherAccount = await client.query("INSERT INTO accounts (bank, account_number, currency) VALUES ('TEST', 'analytics-test-2', 'RUB') RETURNING id");
const categories = await client.query("INSERT INTO categories (name, type) VALUES ('Тест расход', 'expense'), ('Тест доход', 'income'), ('Тест перевод', 'transfer') RETURNING id, type");
const accountId = Number(account.rows[0].id);
@@ -22,17 +22,43 @@ async function testQueries(): Promise<void> {
await insert(accountId, '2026-07-03T12:00:00+03:00', 20_000, categoryId.income, 'analytics-test-3');
await insert(accountId, '2026-07-04T12:00:00+03:00', -5_000, categoryId.income, 'analytics-test-4');
await insert(accountId, '2026-07-05T12:00:00+03:00', -3_000, categoryId.transfer, 'analytics-test-5');
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', 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(
"INSERT INTO transactions (account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed) VALUES ($1, '2026-07-07T12:00:00+03:00', -2_500, 0, 'uncategorized', 'expense', 'analytics-test-uncategorized', NULL, FALSE)",
[accountId],
);
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 }, { expense: 6_000, income: 15_000, net: 9_000, transferOut: 3_000 });
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);
assert.equal(uncategorized.totalExpense, 2_500);
assert.equal((await getSummary({ ...params, categoryId: 0, onlyConfirmed: true }, client)).totalExpense, 0);
assert.deepEqual((await getByCategory({ ...params, categoryId: 0, onlyConfirmed: false }, client)).map((item) => item.amount), [2_500]);
assert.deepEqual((await getByCategory({ ...params, categoryId: 0, onlyConfirmed: true }, client)), []);
assert.equal((await getSummary({ ...params, from: '2026-08-01', to: '2026-08-31' }, client)).totalExpense, 7_000);
assert.deepEqual((await getByCategory(params, client)).map((item) => item.amount), [6_000]);
const timeseries = await getTimeseries({ ...params, granularity: 'month' }, client);
assert.equal(timeseries[0].expenseAmount, 6_000);
assert.equal(timeseries[0].incomeAmount, 15_000);
const partial = await getTimeseries({ ...params, from: '2026-07-02', to: '2026-07-03', granularity: 'month' }, client);
assert.equal(partial[0].expenseAmount, 0);
assert.equal(partial[0].incomeAmount, 20_000);
const uncategorizedSeries = await getTimeseries({ ...params, categoryId: 0, onlyConfirmed: false, granularity: 'month' }, client);
assert.equal(uncategorizedSeries[0].expenseAmount, 2_500);
const confirmedUncategorizedSeries = await getTimeseries({ ...params, categoryId: 0, onlyConfirmed: true, granularity: 'month' }, client);
assert.equal(confirmedUncategorizedSeries[0].expenseAmount, 0);
} finally {
await client.query('ROLLBACK');
client.release();

View File

@@ -14,6 +14,7 @@ interface BaseParams {
from: string;
to: string;
accountId?: number;
categoryId?: number;
onlyConfirmed?: boolean;
}
@@ -28,9 +29,18 @@ function analyticsTransactions(where: string): string {
COALESCE(c.type, t.direction) AS analytic_type,
COALESCE(t.category_id, 0) AS category_id,
COALESCE(c.name, 'Без категории') AS category_name,
${effectiveAmount} AS effective_amount
${effectiveAmount} AS effective_amount,
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,
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
${where}
)`;
}
@@ -56,6 +66,13 @@ function buildBaseConditions(
values.push(params.accountId);
idx++;
}
if (params.categoryId != null) {
conditions.push(params.categoryId === 0 ? 't.category_id IS NULL' : `t.category_id = $${idx}`);
if (params.categoryId !== 0) {
values.push(params.categoryId);
idx++;
}
}
if (params.onlyConfirmed) {
conditions.push('t.is_category_confirmed = TRUE');
}
@@ -73,7 +90,9 @@ export async function getSummary(
const totalsResult = await db.query(
`${analyticsTransactions(where)},
category_net AS (
SELECT category_id, category_name, analytic_type, SUM(effective_amount)::bigint AS amount
SELECT category_id, category_name, analytic_type, SUM(effective_amount)::bigint AS amount,
SUM(interest_income)::bigint AS interest_income,
SUM(cashback_income)::bigint AS cashback_income
FROM analytics_transactions
GROUP BY category_id, category_name, analytic_type
)
@@ -83,7 +102,9 @@ export async function getSummary(
COALESCE((SELECT SUM(GREATEST(effective_amount, 0)) FROM analytics_transactions), 0)::bigint AS cash_inflow,
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((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(cashback_income), 0)::bigint AS cashback_income
FROM category_net`,
values,
);
@@ -94,6 +115,8 @@ export async function getSummary(
const cashOutflow = Number(totalsResult.rows[0].cash_outflow);
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)}
@@ -123,6 +146,8 @@ export async function getSummary(
transferInflow,
transferOutflow,
cashNet: cashInflow - cashOutflow,
interestIncome,
cashbackIncome,
topCategories,
};
}
@@ -187,6 +212,8 @@ export async function getTimeseries(
}
const txConditions: string[] = [
't.operation_at::date >= $1::date',
't.operation_at::date <= $2::date',
't.operation_at::date >= p.period_start',
't.operation_at::date <= p.period_end',
];
@@ -198,8 +225,11 @@ export async function getTimeseries(
values.push(params.accountId);
}
if (params.categoryId != null) {
txConditions.push(`t.category_id = $${idx++}`);
values.push(params.categoryId);
if (params.categoryId === 0) txConditions.push('t.category_id IS NULL');
else {
txConditions.push(`t.category_id = $${idx++}`);
values.push(params.categoryId);
}
}
if (params.onlyConfirmed) {
txConditions.push('t.is_category_confirmed = TRUE');

View File

@@ -55,22 +55,38 @@ export async function getRules(params: GetCategoryRulesParams): Promise<Category
export async function createRule(
body: CreateCategoryRuleRequest,
): Promise<CategoryRule> {
): Promise<CategoryRule & { applied: number }> {
const pattern = body.pattern.trim();
const matchType = body.matchType ?? 'contains';
const priority = body.priority ?? 100;
const requiresConfirmation = body.requiresConfirmation ?? false;
const { rows } = await pool.query(
`INSERT INTO category_rules (pattern, match_type, category_id, priority, requires_confirmation)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[pattern, matchType, body.categoryId, priority, requiresConfirmation],
);
const catResult = await pool.query('SELECT name FROM categories WHERE id = $1', [body.categoryId]);
rows[0].category_name = catResult.rows[0].name;
return toRule(rows[0]);
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
`INSERT INTO category_rules (pattern, match_type, category_id, priority, requires_confirmation)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[pattern, matchType, body.categoryId, priority, requiresConfirmation],
);
const match = matchType === 'starts_with'
? `t.description ILIKE $1 || '%'`
: `t.description ILIKE '%' || $1 || '%'`;
const applied = await client.query(
`UPDATE transactions t SET category_id = $2, is_category_confirmed = $3, updated_at = NOW()
WHERE (t.category_id IS NULL OR t.is_category_confirmed = FALSE) AND ${match}`,
[pattern, body.categoryId, !requiresConfirmation],
);
const catResult = await client.query('SELECT name FROM categories WHERE id = $1', [body.categoryId]);
rows[0].category_name = catResult.rows[0].name;
await client.query('COMMIT');
return { ...toRule(rows[0]), applied: applied.rowCount ?? 0 };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
export async function updateRule(

View File

@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import { pool } from '../db/pool';
import { importStatement } from './import';
const makeStatement = (sourceIds: string[]) => ({
schemaVersion: '1.0', bank: 'TEST',
statement: { accountNumber: 'metadata-test', currency: 'RUB', openingBalance: 0, closingBalance: 100, exportedAt: '2026-08-20T12:00:00+03:00' },
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']));
const positions = await pool.query("SELECT source_position FROM transactions t JOIN accounts a ON a.id = t.account_id WHERE a.bank = 'TEST' AND a.account_number = 'metadata-test' ORDER BY source_position");
assert.deepEqual(positions.rows.map((row) => Number(row.source_position)), [0, 1]);
const account = await pool.query("UPDATE accounts SET account_type = 'savings', status = 'closed' WHERE bank = 'TEST' AND account_number = 'metadata-test' RETURNING id");
assert.equal(account.rows.length, 1);
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 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();
}
}
run();

View File

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

View File

@@ -1,4 +1,5 @@
import crypto from 'crypto';
import type { PoolClient } from 'pg';
import { pool } from '../db/pool';
import { maskAccountNumber } from '../utils';
import type { StatementFile, ImportStatementResponse } from '@family-budget/shared';
@@ -6,14 +7,18 @@ import type { StatementFile, ImportStatementResponse } from '@family-budget/shar
const TRANSFER_PHRASES = [
'перевод между своими счетами',
'перевод средств на счет',
'перечисление средств на счет',
'перечисление средств на вклад',
'перечисление суммы вклада при закрытии',
'внутри втб',
];
const CASHBACK_KEYWORD = 'зачисление';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function computeFingerprint(
export function computeFingerprint(
accountNumber: string,
tx: { operationAt: string; amountSigned: number; commission: number; description: string; sourceId?: string },
sourcePosition?: number,
): string {
if (tx.sourceId) {
const raw = [accountNumber, tx.sourceId.trim()].join('|');
@@ -26,12 +31,13 @@ function computeFingerprint(
String(tx.amountSigned),
String(tx.commission),
tx.description.trim(),
...(sourcePosition === undefined ? [] : [String(sourcePosition)]),
].join('|');
const hash = crypto.createHash('sha256').update(raw, 'utf-8').digest('hex');
return `sha256:${hash}`;
}
function determineDirection(amountSigned: number, description: string): string {
export function determineDirection(amountSigned: number, description: string): string {
const lower = description.toLowerCase();
for (const phrase of TRANSFER_PHRASES) {
if (lower.includes(phrase)) return 'transfer';
@@ -136,7 +142,7 @@ function validateSemantics(data: StatementFile): ValidationError | null {
const operationIds = new Set<string>();
for (let i = 0; i < data.transactions.length; i++) {
const fp = computeFingerprint(data.statement.accountNumber, data.transactions[i]);
if (fps.has(fp)) {
if (data.transactions[i].sourceId && fps.has(fp)) {
return { status: 422, error: 'VALIDATION_ERROR', message: `Duplicate fingerprint found within file at transaction index ${i}` };
}
fps.add(fp);
@@ -154,6 +160,8 @@ 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;
@@ -162,16 +170,17 @@ export async function importStatement(
const semErr = validateSemantics(data);
if (semErr) return semErr;
const client = await pool.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;
let isNewAccount = false;
const accResult = await client.query(
'SELECT id FROM accounts WHERE bank = $1 AND account_number = $2',
'SELECT id, account_type FROM accounts WHERE bank = $1 AND account_number = $2',
[data.bank, data.statement.accountNumber],
);
@@ -206,29 +215,45 @@ export async function importStatement(
throw new Error("Category 'Поступления' is missing");
}
const incomeCategoryId = Number(incomeCategoryResult.rows[0].id);
const accountType = accResult.rows[0]?.account_type ?? null;
const investmentCategoryResult = await client.query(
`SELECT id FROM categories WHERE name = 'Инвестиции' AND type = 'transfer' AND is_active = TRUE LIMIT 1`,
);
const investmentCategoryId = investmentCategoryResult.rows[0]
? Number(investmentCategoryResult.rows[0].id)
: null;
const isInvestmentAccount = ['brokerage', 'iis', 'savings'].includes(accountType);
// Insert transactions
const insertedIds: number[] = [];
const fallbackFingerprintOccurrences = new Map<string, number>();
for (const tx of data.transactions) {
const fp = computeFingerprint(data.statement.accountNumber, tx);
for (const [sourcePosition, tx] of data.transactions.entries()) {
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 &&
tx.description.toLowerCase().includes(CASHBACK_KEYWORD);
const dir = isCashbackCommissionImport
? 'income'
: determineDirection(tx.amountSigned, tx.description);
const categoryId = isCashbackCommissionImport ? incomeCategoryId : null;
: isInvestmentAccount ? 'transfer' : determineDirection(tx.amountSigned, tx.description);
const categoryId = isCashbackCommissionImport
? incomeCategoryId
: isInvestmentAccount ? investmentCategoryId : null;
const isCategoryConfirmed = isCashbackCommissionImport;
const result = await client.query(
`INSERT INTO transactions
(account_id, operation_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed, import_id)
VALUES ($1, COALESCE($2::uuid, gen_random_uuid()), $3, $4, $5, $6, $7, $8, $9, $10, $11)
(account_id, operation_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed, import_id, source_position)
VALUES ($1, COALESCE($2::uuid, gen_random_uuid()), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT DO NOTHING
RETURNING id`,
[accountId, tx.operationId ?? null, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, categoryId, isCategoryConfirmed, importId],
[accountId, tx.operationId ?? null, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, categoryId, isCategoryConfirmed, importId, sourcePosition],
);
if (result.rows.length > 0) {
@@ -263,14 +288,22 @@ export async function importStatement(
OR (cr.match_type = 'starts_with' AND t2.description ILIKE cr.pattern || '%')
)
WHERE t2.id = ANY($1::bigint[])
AND t2.is_category_confirmed = FALSE
ORDER BY t2.id, cr.priority DESC, cr.id ASC
) sub
WHERE t.id = sub.tx_id`,
[insertedIds],
);
if (isInvestmentAccount && investmentCategoryId != null) {
await client.query(
`UPDATE transactions SET is_category_confirmed = TRUE, updated_at = NOW()
WHERE id = ANY($1::bigint[]) AND category_id = $2 AND is_category_confirmed = FALSE`,
[insertedIds, investmentCategoryId],
);
}
}
await client.query('COMMIT');
if (ownsTransaction) await client.query('COMMIT');
return {
accountId,
@@ -281,10 +314,10 @@ export async function importStatement(
totalInFile: data.transactions.length,
};
} catch (err) {
await client.query('ROLLBACK');
if (ownsTransaction) await client.query('ROLLBACK');
throw err;
} finally {
client.release();
if (ownsTransaction) client.release();
}
}

View File

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

View 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;
}
}

View File

@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import { pool } from '../db/pool';
import { importPortfolio } from './portfolio';
const payload = {
schemaVersion: 'broker-portfolio-1.0', bank: 'PORTFOLIO_TEST', accountNumber: 'portfolio-test',
reportPeriod: { from: '2026-08-01', to: '2026-08-20' }, reportedAt: '2026-08-20T00:00:00+03:00',
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 {
const first = await importPortfolio(payload);
const second = await importPortfolio(payload);
assert.equal(first.importedTrades, 1);
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')");
await pool.query("DELETE FROM portfolio_reports WHERE account_id IN (SELECT id FROM accounts WHERE bank = 'PORTFOLIO_TEST' AND account_number = 'portfolio-test')");
await pool.query("DELETE FROM accounts WHERE bank = 'PORTFOLIO_TEST' AND account_number = 'portfolio-test'");
await pool.end();
}
}
run();

View File

@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import { deriveTradeSourceId, validatePortfolio } from './portfolio';
const trade = {
instrument: 'Bond', isin: 'RU0000000001', concludedAt: '2026-08-20T10:00:00+03:00',
side: 'Покупка', quantity: '1.000', settlementAmount: '1234.50',
};
assert.equal(deriveTradeSourceId(trade), 'RU0000000001|2026-08-20T10:00:00+03:00|Покупка|1.000|1234.50');
assert.throws(() => validatePortfolio(null), /object/);
assert.throws(() => validatePortfolio({ schemaVersion: 'broker-portfolio-1.0', bank: 'B', accountNumber: 'A', reportPeriod: { from: '2026-08-20', to: '2026-08-20' }, reportedAt: null, positions: [], trades: [{ ...trade, sourceId: 42 }] }), /sourceId/);
assert.throws(() => validatePortfolio({ ...{ schemaVersion: 'broker-portfolio-1.0', bank: 'B', accountNumber: 'A', reportPeriod: { from: '2026-08-21', to: '2026-08-20' }, reportedAt: null, positions: [], trades: [] } }), /period/);
console.log('portfolio validation: OK');

View File

@@ -0,0 +1,399 @@
import crypto from 'crypto';
import type { PoolClient } from 'pg';
import { pool } from '../db/pool';
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`);
return null;
}
const result = String(value).replace(/\s/g, '').replace(',', '.');
if (!/^-?(?:\d+\.?\d*|\.\d+)$/.test(result)) throw new Error(`${field} must be numeric`);
return result;
}
export function deriveTradeSourceId(trade: PortfolioTrade): string {
if (trade.sourceId?.trim()) return trade.sourceId.trim();
return [trade.isin || trade.instrument, trade.concludedAt, trade.side, decimalValue(trade.quantity, 'quantity', true), decimalValue(trade.settlementAmount, 'settlementAmount') || ''].join('|');
}
export function validatePortfolio(body: unknown): asserts body is PortfolioFile {
if (!body || typeof body !== 'object') throw new Error('Portfolio file must be an object');
const data = body as PortfolioFile;
if (data.schemaVersion !== 'broker-portfolio-1.0' || typeof data.bank !== 'string' || !data.bank.trim() || typeof data.accountNumber !== 'string' || !data.accountNumber.trim()) throw new Error('Invalid portfolio file');
if (!data.reportPeriod?.from || !data.reportPeriod?.to || Number.isNaN(Date.parse(data.reportPeriod.from)) || Number.isNaN(Date.parse(data.reportPeriod.to)) || data.reportPeriod.from > data.reportPeriod.to) throw new Error('Invalid report period');
if (data.reportedAt !== null && (typeof data.reportedAt !== 'string' || Number.isNaN(Date.parse(data.reportedAt)))) throw new Error('Invalid reportedAt');
if (!Array.isArray(data.positions) || !Array.isArray(data.trades)) throw new Error('positions and trades must be arrays');
const sourceIds = new Set<string>();
for (const trade of data.trades) {
if (!trade || typeof trade.instrument !== 'string' || !trade.instrument.trim() || typeof trade.concludedAt !== 'string' || Number.isNaN(Date.parse(trade.concludedAt)) || typeof trade.side !== 'string' || !trade.side.trim()) throw new Error('Invalid trade');
if (trade.sourceId !== undefined && typeof trade.sourceId !== 'string') throw new Error('sourceId must be a string');
if (trade.operationId !== undefined && typeof trade.operationId !== 'string') throw new Error('operationId must be a string');
const sourceId = deriveTradeSourceId(trade);
if (sourceIds.has(sourceId)) throw new Error(`Duplicate sourceId: ${sourceId}`);
sourceIds.add(sourceId);
if (trade.operationId && !UUID_RE.test(trade.operationId)) throw new Error(`Invalid operationId: ${trade.sourceId}`);
decimalValue(trade.quantity, 'quantity', true);
}
for (const position of data.positions) {
if (!position || typeof position.instrument !== 'string' || !position.instrument.trim()) throw new Error('Invalid position');
decimalValue(position.quantity, 'position.quantity', true);
decimalValue(position.price, 'position.price');
decimalValue(position.valuation, 'position.valuation');
}
}
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 = transactionClient ?? await db.connect();
const ownsTransaction = transactionClient == null;
try {
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')
ON CONFLICT (bank, account_number) DO UPDATE SET account_type = COALESCE(accounts.account_type, 'brokerage')
RETURNING id`,
[data.bank, data.accountNumber],
);
const accountId = Number(accountResult.rows[0].id);
const reportResult = await client.query(
`INSERT INTO portfolio_reports (account_id, source_hash, report_period_from, report_period_to, reported_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (account_id, source_hash) DO NOTHING
RETURNING id`,
[accountId, sourceHash, data.reportPeriod.from, data.reportPeriod.to, data.reportedAt],
);
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]);
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);
for (const position of data.positions) {
await client.query(
`INSERT INTO portfolio_positions (report_id, instrument, isin, quantity, price, valuation)
VALUES ($1, $2, $3, $4, $5, $6)`,
[reportId, position.instrument, position.isin ?? null, decimalValue(position.quantity, 'position.quantity', true), decimalValue(position.price, 'position.price'), decimalValue(position.valuation, 'position.valuation')],
);
}
let importedTrades = 0;
for (const trade of data.trades) {
const sourceId = deriveTradeSourceId(trade);
const result = await client.query(
`INSERT INTO portfolio_trades
(account_id, source_id, operation_id, instrument, isin, concluded_at, side, quantity, price_currency, price, settlement_currency, settlement_amount, nkd, settlement_commission, trade_commission, order_id, trade_id, venue, comment)
VALUES ($1, $2, COALESCE($3::uuid, gen_random_uuid()), $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
ON CONFLICT (account_id, source_id) DO NOTHING`,
[accountId, sourceId, trade.operationId ?? null, trade.instrument, trade.isin ?? null, trade.concludedAt, trade.side, decimalValue(trade.quantity, 'quantity', true), trade.priceCurrency ?? null, decimalValue(trade.price, 'price'), trade.settlementCurrency ?? null, decimalValue(trade.settlementAmount, 'settlementAmount'), decimalValue(trade.nkd, 'nkd'), decimalValue(trade.settlementCommission, 'settlementCommission'), decimalValue(trade.tradeCommission, 'tradeCommission'), trade.orderId ?? null, trade.tradeId ?? null, trade.venue ?? null, trade.comment ?? null],
);
importedTrades += result.rowCount ?? 0;
}
if (ownsTransaction) await client.query('COMMIT');
return { accountId, reportId, importedTrades, duplicateTrades: data.trades.length - importedTrades, positions: data.positions.length };
} catch (error) {
if (ownsTransaction) await client.query('ROLLBACK');
throw error;
} finally {
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()] };
}

View 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');

View File

@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import { transactionOrderBy } from './transactions';
assert.equal(
transactionOrderBy('date', 'desc'),
't.operation_at DESC, t.source_position ASC, t.id ASC',
);
assert.equal(
transactionOrderBy('date', 'asc'),
't.operation_at ASC, t.source_position DESC, t.id DESC',
);
assert.match(transactionOrderBy('amount', 'desc'), /t\.amount_signed DESC.*t\.operation_at DESC.*t\.source_position ASC.*t\.id DESC/);
console.log('transaction ordering: OK');

View File

@@ -7,13 +7,18 @@ import type {
UpdateTransactionRequest,
} from '@family-budget/shared';
export function transactionOrderBy(sortBy: 'date' | 'amount', sortOrder: 'asc' | 'desc'): string {
const direction = sortOrder === 'asc' ? 'ASC' : 'DESC';
if (sortBy === 'amount') return `t.amount_signed ${direction}, t.operation_at DESC, t.source_position ASC, t.id DESC`;
return `t.operation_at ${direction}, t.source_position ${direction === 'ASC' ? 'DESC' : 'ASC'}, t.id ${direction === 'ASC' ? 'DESC' : 'ASC'}`;
}
export async function getTransactions(
params: GetTransactionsParams,
): Promise<PaginatedResponse<Transaction>> {
const page = params.page ?? 1;
const pageSize = [10, 50, 100].includes(params.pageSize ?? 50) ? (params.pageSize ?? 50) : 50;
const sortBy = params.sortBy === 'amount' ? 't.amount_signed' : 't.operation_at';
const sortOrder = params.sortOrder === 'asc' ? 'ASC' : 'DESC';
const orderBy = transactionOrderBy(params.sortBy === 'amount' ? 'amount' : 'date', params.sortOrder === 'asc' ? 'asc' : 'desc');
const conditions: string[] = [];
const values: unknown[] = [];
@@ -84,7 +89,7 @@ export async function getTransactions(
JOIN accounts a ON a.id = t.account_id
LEFT JOIN categories c ON c.id = t.category_id
${where}
ORDER BY ${sortBy} ${sortOrder}
ORDER BY ${orderBy}
LIMIT $${idx++} OFFSET $${idx++}`,
[...values, pageSize, offset],
);

View File

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

View File

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

View File

@@ -91,7 +91,7 @@
- `statement.currency` соответствует допустимому коду валюты (MVP: `"RUB"`).
- `operationAt` у всех транзакций — валидная дата (парсится без ошибок).
- Отсутствуют дубликаты fingerprint внутри одного файла.
- Повторяющиеся `sourceId` внутри одного файла отклоняются; одинаковые операции без `sourceId` различаются по позиции в массиве `transactions`.
Ответ при ошибке:
@@ -120,10 +120,11 @@
Для каждой транзакции вычисляется SHA-256 от полей, соединённых разделителем `|`:
```text
accountNumber|operationAt|amountSigned|commission|normalizedDescription
accountNumber|operationAt|amountSigned|commission|normalizedDescription[|sourcePosition]
```
- `normalizedDescription``description` после `trim`.
- `sourcePosition` — порядковый номер повторяющейся операции в массиве `transactions`; добавляется, только если одинаковые операции без `sourceId` повторяются в одном файле.
- Суммы подставляются в том виде, в котором пришли в JSON (числовое представление).
- Разделитель `|` исключает коллизии при склейке полей разной длины.

View File

@@ -1,11 +1,12 @@
{
"name": "@family-budget/frontend",
"version": "0.9.3",
"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": {

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import type {
CategoryRule,
CreateCategoryRuleResponse,
GetCategoryRulesParams,
CreateCategoryRuleRequest,
UpdateCategoryRuleRequest,
@@ -24,8 +25,8 @@ export async function getCategoryRules(
export async function createCategoryRule(
data: CreateCategoryRuleRequest,
): Promise<CategoryRule> {
return api.post<CategoryRule>('/api/category-rules', data);
): Promise<CreateCategoryRuleResponse> {
return api.post<CreateCategoryRuleResponse>('/api/category-rules', data);
}
export async function updateCategoryRule(

View File

@@ -7,6 +7,8 @@ export function AccountsList() {
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<number | null>(null);
const [editAlias, setEditAlias] = useState('');
const [editAccountType, setEditAccountType] = useState<Account['accountType']>(null);
const [editStatus, setEditStatus] = useState<Account['status']>('active');
useEffect(() => {
setLoading(true);
@@ -19,12 +21,16 @@ export function AccountsList() {
const handleEdit = (account: Account) => {
setEditingId(account.id);
setEditAlias(account.alias || '');
setEditAccountType(account.accountType);
setEditStatus(account.status);
};
const handleSave = async (id: number) => {
try {
const updated = await updateAccount(id, {
alias: editAlias.trim(),
accountType: editAccountType,
status: editStatus,
});
setAccounts((prev) =>
prev.map((a) => (a.id === id ? updated : a)),
@@ -48,6 +54,8 @@ export function AccountsList() {
<th className="data-table__head-cell">Номер счёта</th>
<th className="data-table__head-cell">Валюта</th>
<th className="data-table__head-cell">Алиас</th>
<th className="data-table__head-cell">Тип</th>
<th className="data-table__head-cell">Статус</th>
<th className="data-table__head-cell"></th>
</tr>
</thead>
@@ -76,6 +84,25 @@ export function AccountsList() {
)
)}
</td>
<td className="data-table__cell">
{editingId === a.id ? (
<select value={editAccountType ?? ''} onChange={(e) => setEditAccountType((e.target.value || null) as Account['accountType'])}>
<option value="">Не указан</option>
<option value="brokerage">Брокерский</option>
<option value="iis">ИИС</option>
<option value="savings">Накопительный</option>
<option value="current">Текущий</option>
</select>
) : (({ brokerage: 'Брокерский', iis: 'ИИС', savings: 'Накопительный', current: 'Текущий' } as Record<string, string>)[a.accountType ?? ''] || 'не указан')}
</td>
<td className="data-table__cell">
{editingId === a.id ? (
<select value={editStatus} onChange={(e) => setEditStatus(e.target.value as Account['status'])}>
<option value="active">Действующий</option>
<option value="closed">Закрытый</option>
</select>
) : (a.status === 'closed' ? 'Закрытый' : 'Действующий')}
</td>
<td className="data-table__cell">
{editingId === a.id ? (
<div className="button-group">
@@ -105,7 +132,7 @@ export function AccountsList() {
))}
{accounts.length === 0 && (
<tr className="data-table__row">
<td colSpan={5} className="data-table__cell data-table__cell--center text text--muted">
<td colSpan={7} className="data-table__cell data-table__cell--center text text--muted">
Нет счетов. Импортируйте выписку.
</td>
</tr>

View File

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

View File

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

View File

@@ -40,6 +40,8 @@ export function SummaryCards({ summary }: Props) {
</div>
<div className="summary__subvalue">Поступило: {formatAmount(summary.cashInflow)}</div>
<div className="summary__subvalue">Списано: {formatAmount(summary.cashOutflow)}</div>
<div className="summary__subvalue">Доход от процентов: {formatAmount(summary.interestIncome)}</div>
<div className="summary__subvalue">Кэшбек: {formatAmount(summary.cashbackIncome)}</div>
{(summary.transferInflow > 0 || summary.transferOutflow > 0) && (
<div className="summary__subvalue">
Переводы: {formatAmount(summary.transferInflow)} / {formatAmount(summary.transferOutflow)}

View File

@@ -187,7 +187,7 @@ export function TransactionFilters({
<option value="">Все счета</option>
{accounts.map((a) => (
<option key={a.id} value={a.id}>
{a.alias || a.accountNumberMasked}
{[a.alias || a.accountNumberMasked, ({ brokerage: 'Брокерский', iis: 'ИИС', savings: 'Накопительный', current: 'Текущий' } as Record<string, string>)[a.accountType ?? ''], a.status === 'closed' ? 'закрытый' : 'действующий'].filter(Boolean).join(' · ')}
</option>
))}
</select>

View File

@@ -1,12 +1,14 @@
import { useState, useEffect, useCallback } from 'react';
import type {
Account,
Category,
AnalyticsSummaryResponse,
ByCategoryItem,
TimeseriesItem,
Granularity,
} from '@family-budget/shared';
import { getAccounts } from '../api/accounts';
import { getCategories } from '../api/categories';
import { getSummary, getByCategory, getTimeseries } from '../api/analytics';
import {
PeriodSelector,
@@ -29,8 +31,10 @@ function getDefaultPeriod(): PeriodState {
export function AnalyticsPage() {
const [period, setPeriod] = useState<PeriodState>(getDefaultPeriod);
const [accountId, setAccountId] = useState<string>('');
const [categoryId, setCategoryId] = useState<string>('');
const [onlyConfirmed, setOnlyConfirmed] = useState(false);
const [accounts, setAccounts] = useState<Account[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [summary, setSummary] = useState<AnalyticsSummaryResponse | null>(
null,
);
@@ -40,6 +44,7 @@ export function AnalyticsPage() {
useEffect(() => {
getAccounts().then(setAccounts).catch(() => {});
getCategories({ isActive: true }).then(setCategories).catch(() => {});
}, []);
const fetchAll = useCallback(async () => {
@@ -63,6 +68,7 @@ export function AnalyticsPage() {
from: period.from,
to: period.to,
...(accountId ? { accountId: Number(accountId) } : {}),
...(categoryId !== '' ? { categoryId: Number(categoryId) } : {}),
...(onlyConfirmed ? { onlyConfirmed: true } : {}),
};
@@ -80,7 +86,7 @@ export function AnalyticsPage() {
} finally {
setLoading(false);
}
}, [period, accountId, onlyConfirmed]);
}, [period, accountId, categoryId, onlyConfirmed]);
useEffect(() => {
fetchAll();
@@ -107,7 +113,7 @@ export function AnalyticsPage() {
<option value="">Все счета</option>
{accounts.map((a) => (
<option key={a.id} value={a.id}>
{a.alias || a.accountNumberMasked}
{[a.alias || a.accountNumberMasked, ({ brokerage: 'Брокерский', iis: 'ИИС', savings: 'Накопительный', current: 'Текущий' } as Record<string, string>)[a.accountType ?? ''], a.status === 'closed' ? 'закрытый' : 'действующий'].filter(Boolean).join(' · ')}
</option>
))}
</select>
@@ -122,6 +128,14 @@ export function AnalyticsPage() {
Только подтверждённые
</label>
</div>
<div className="field">
<label className="field__label">Категория</label>
<select value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
<option value="">Все категории</option>
<option value="0">Без категории</option>
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
</div>
</div>

View File

@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import type { PortfolioHistoryResponse, PortfolioOverviewResponse, PortfolioPerformanceResponse } from '@family-budget/shared';
import { getPortfolioHistory, getPortfolioOverview, getPortfolioPerformance } from '../api/portfolio';
import { formatDate } from '../utils/format';
const money = new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', minimumFractionDigits: 2 });
const number = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 6 });
export function PortfolioPage() {
const [data, setData] = useState<PortfolioOverviewResponse | null>(null);
const [history, setHistory] = useState<PortfolioHistoryResponse | null>(null);
const [performance, setPerformance] = useState<PortfolioPerformanceResponse | null>(null);
useEffect(() => {
getPortfolioOverview().then(setData).catch(() => {});
getPortfolioHistory().then(setHistory).catch(() => {});
getPortfolioPerformance().then(setPerformance).catch(() => {});
}, []);
return (
<div className="page">
<div className="page__header">
<div>
<p className="page__eyebrow">Инвестиции</p>
<h1 className="page__title">Портфель</h1>
</div>
</div>
{!data ? <div className="state">Загрузка...</div> : data.accounts.length === 0 ? (
<div className="state state--empty">Портфель пока пуст. Загрузите XLSX-отчёт брокера в разделе «Операции».</div>
) : data.accounts.map((account) => (
<section className="portfolio" key={account.accountId} aria-labelledby={`portfolio-${account.accountId}`}>
<div className="portfolio__header">
<div>
<h2 id={`portfolio-${account.accountId}`} className="portfolio__title">{account.accountName}</h2>
<p className="portfolio__meta">{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>
);
}

View File

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

45
package-lock.json generated
View File

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

View File

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

View File

@@ -1,11 +1,18 @@
export type AccountType = 'brokerage' | 'iis' | 'savings' | 'current';
export type AccountStatus = 'active' | 'closed';
export interface Account {
id: number;
bank: string;
accountNumberMasked: string;
currency: string;
alias: string | null;
accountType: AccountType | null;
status: AccountStatus;
}
export interface UpdateAccountRequest {
alias: string;
accountType?: AccountType | null;
status?: AccountStatus;
}

View File

@@ -4,6 +4,7 @@ export interface AnalyticsSummaryParams {
from: string;
to: string;
accountId?: number;
categoryId?: number;
onlyConfirmed?: boolean;
}
@@ -23,6 +24,8 @@ export interface AnalyticsSummaryResponse {
transferInflow: number;
transferOutflow: number;
cashNet: number;
interestIncome: number;
cashbackIncome: number;
topCategories: TopCategory[];
}
@@ -30,6 +33,7 @@ export interface ByCategoryParams {
from: string;
to: string;
accountId?: number;
categoryId?: number;
onlyConfirmed?: boolean;
}

View File

@@ -26,6 +26,10 @@ export interface CreateCategoryRuleRequest {
requiresConfirmation?: boolean;
}
export interface CreateCategoryRuleResponse extends CategoryRule {
applied: number;
}
export interface UpdateCategoryRuleRequest {
pattern?: string;
categoryId?: number;

View File

@@ -46,3 +46,111 @@ export interface ImportStatementResponse {
duplicatesSkipped: number;
totalInFile: number;
}
export interface PortfolioFile {
schemaVersion: 'broker-portfolio-1.0';
bank: string;
accountNumber: string;
reportPeriod: { from: string; to: string };
reportedAt: string | null;
positions: PortfolioPosition[];
trades: PortfolioTrade[];
}
export interface PortfolioPosition {
instrument: string;
isin?: string | null;
quantity: string | number;
price?: string | number | null;
valuation?: string | number | null;
}
export interface PortfolioTrade {
sourceId?: string;
operationId?: string;
instrument: string;
isin?: string | null;
concludedAt: string;
side: string;
quantity: string | number;
priceCurrency?: string | null;
price?: string | number | null;
settlementCurrency?: string | null;
settlementAmount?: string | number | null;
nkd?: string | number | null;
settlementCommission?: string | number | null;
tradeCommission?: string | number | null;
orderId?: string | null;
tradeId?: string | null;
venue?: string | null;
comment?: string | null;
}
export interface ImportPortfolioResponse {
accountId: number;
reportId: number;
importedTrades: number;
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[];
}

View File

@@ -9,7 +9,7 @@ export type {
ApiError,
} from './common';
export type { Account, UpdateAccountRequest } from './account';
export type { Account, AccountType, AccountStatus, UpdateAccountRequest } from './account';
export type {
Category,
@@ -24,6 +24,7 @@ export type {
export type {
CategoryRule,
CreateCategoryRuleResponse,
GetCategoryRulesParams,
CreateCategoryRuleRequest,
UpdateCategoryRuleRequest,
@@ -38,6 +39,19 @@ export type {
StatementTransaction,
ImportStatementResponse,
Import,
PortfolioFile,
PortfolioPosition,
PortfolioTrade,
ImportPortfolioResponse,
ImportBrokerReportResponse,
PortfolioOverviewPosition,
PortfolioOverviewAccount,
PortfolioOverviewResponse,
PortfolioHistoryPoint,
PortfolioHistoryAccount,
PortfolioHistoryResponse,
PortfolioPerformanceAccount,
PortfolioPerformanceResponse,
} from './import';
export type {