feat: add broker portfolio import

This commit is contained in:
2026-08-20 14:55:53 +03:00
parent 0003d92583
commit 8d91bea7a9
9 changed files with 218 additions and 2 deletions

View File

@@ -1,5 +1,11 @@
# Changelog # Changelog
## [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 ## [Backend 0.7.5 / Shared 0.3.1] - 2026-08-20
### Added ### Added

View File

@@ -1,6 +1,6 @@
{ {
"name": "@family-budget/backend", "name": "@family-budget/backend",
"version": "0.7.5", "version": "0.8.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "tsx watch src/app.ts", "dev": "tsx watch src/app.ts",

View File

@@ -19,6 +19,7 @@ import accountsRouter from './routes/accounts';
import categoriesRouter from './routes/categories'; import categoriesRouter from './routes/categories';
import categoryRulesRouter from './routes/categoryRules'; import categoryRulesRouter from './routes/categoryRules';
import analyticsRouter from './routes/analytics'; import analyticsRouter from './routes/analytics';
import portfolioRouter from './routes/portfolio';
const app = express(); const app = express();
app.set('trust proxy', 1); app.set('trust proxy', 1);
@@ -43,6 +44,7 @@ app.use('/api/accounts', accountsRouter);
app.use('/api/categories', categoriesRouter); app.use('/api/categories', categoriesRouter);
app.use('/api/category-rules', categoryRulesRouter); app.use('/api/category-rules', categoryRulesRouter);
app.use('/api/analytics', analyticsRouter); app.use('/api/analytics', analyticsRouter);
app.use('/api/import/portfolio', portfolioRouter);
app.use( app.use(
( (

View File

@@ -238,6 +238,55 @@ const migrations: { name: string; sql: string }[] = [
CHECK (status IN ('active', 'closed')); 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);
`,
},
]; ];
export async function runMigrations(): Promise<void> { export async function runMigrations(): Promise<void> {

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,86 @@
import crypto from 'crypto';
import { pool } from '../db/pool';
import type { ImportPortfolioResponse, PortfolioFile, 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;
function numberValue(value: unknown, field: string, required = false): number | null {
if (value == null || value === '') {
if (required) throw new Error(`${field} is required`);
return null;
}
const result = typeof value === 'number' ? value : Number(String(value).replace(/\s/g, '').replace(',', '.'));
if (!Number.isFinite(result)) throw new Error(`${field} must be numeric`);
return result;
}
function validate(data: PortfolioFile): void {
if (data.schemaVersion !== 'broker-portfolio-1.0' || !data.bank || !data.accountNumber) 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))) throw new Error('Invalid report period');
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.sourceId || !trade.instrument || !trade.concludedAt || !trade.side) throw new Error('Invalid trade');
if (sourceIds.has(trade.sourceId)) throw new Error(`Duplicate sourceId: ${trade.sourceId}`);
sourceIds.add(trade.sourceId);
if (trade.operationId && !UUID_RE.test(trade.operationId)) throw new Error(`Invalid operationId: ${trade.sourceId}`);
numberValue(trade.quantity, 'quantity', true);
}
for (const position of data.positions) numberValue(position.quantity, 'position.quantity', true);
}
export async function importPortfolio(body: unknown): Promise<ImportPortfolioResponse> {
const data = body as PortfolioFile;
validate(data);
const sourceHash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex');
const client = await pool.connect();
try {
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]);
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, numberValue(position.quantity, 'position.quantity', true), numberValue(position.price, 'position.price'), numberValue(position.valuation, 'position.valuation')],
);
}
let importedTrades = 0;
for (const trade of data.trades) {
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, trade.sourceId, trade.operationId ?? null, trade.instrument, trade.isin ?? null, trade.concludedAt, trade.side, numberValue(trade.quantity, 'quantity', true), trade.priceCurrency ?? null, numberValue(trade.price, 'price'), trade.settlementCurrency ?? null, numberValue(trade.settlementAmount, 'settlementAmount'), numberValue(trade.nkd, 'nkd'), numberValue(trade.settlementCommission, 'settlementCommission'), numberValue(trade.tradeCommission, 'tradeCommission'), trade.orderId ?? null, trade.tradeId ?? null, trade.venue ?? null, trade.comment ?? null],
);
importedTrades += result.rowCount ?? 0;
}
await client.query('COMMIT');
return { accountId, reportId, importedTrades, duplicateTrades: data.trades.length - importedTrades, positions: data.positions.length };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}

View File

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

View File

@@ -46,3 +46,50 @@ export interface ImportStatementResponse {
duplicatesSkipped: number; duplicatesSkipped: number;
totalInFile: 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;
}

View File

@@ -39,6 +39,10 @@ export type {
StatementTransaction, StatementTransaction,
ImportStatementResponse, ImportStatementResponse,
Import, Import,
PortfolioFile,
PortfolioPosition,
PortfolioTrade,
ImportPortfolioResponse,
} from './import'; } from './import';
export type { export type {