fix: harden portfolio import

This commit is contained in:
2026-08-20 15:01:54 +03:00
parent 8d91bea7a9
commit e0da7e86ad
5 changed files with 51 additions and 17 deletions

View File

@@ -1,5 +1,11 @@
# Changelog
## [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

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/backend",
"version": "0.8.0",
"version": "0.8.1",
"private": true,
"scripts": {
"dev": "tsx watch src/app.ts",
@@ -9,6 +9,7 @@
"migrate": "tsx src/db/migrate.ts",
"migrate:prod": "node dist/db/migrate.js",
"test:analytics": "tsx src/services/analyticsSemantics.test.ts",
"test:portfolio": "tsx src/services/portfolio.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:llm": "tsx src/scripts/testLlm.ts"

View File

@@ -0,0 +1,12 @@
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-21', to: '2026-08-20' }, reportedAt: null, positions: [], trades: [] } }), /period/);
console.log('portfolio validation: OK');

View File

@@ -4,34 +4,48 @@ import type { ImportPortfolioResponse, PortfolioFile, PortfolioTrade } from '@fa
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 {
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 = typeof value === 'number' ? value : Number(String(value).replace(/\s/g, '').replace(',', '.'));
if (!Number.isFinite(result)) throw new Error(`${field} must be numeric`);
const result = String(value).replace(/\s/g, '').replace(',', '.');
if (!/^-?(?:\d+\.?\d*|\.\d+)$/.test(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');
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.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 || 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');
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}`);
numberValue(trade.quantity, 'quantity', true);
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');
}
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);
validatePortfolio(body);
const data = body;
const sourceHash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex');
const client = await pool.connect();
try {
@@ -61,17 +75,18 @@ export async function importPortfolio(body: unknown): Promise<ImportPortfolioRes
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')],
[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, 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],
[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;
}

View File

@@ -66,7 +66,7 @@ export interface PortfolioPosition {
}
export interface PortfolioTrade {
sourceId: string;
sourceId?: string;
operationId?: string;
instrument: string;
isin?: string | null;