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 # 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 ## [Backend 0.8.0 / Shared 0.4.0] - 2026-08-20
### Added ### Added

View File

@@ -1,6 +1,6 @@
{ {
"name": "@family-budget/backend", "name": "@family-budget/backend",
"version": "0.8.0", "version": "0.8.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "tsx watch src/app.ts", "dev": "tsx watch src/app.ts",
@@ -9,6 +9,7 @@
"migrate": "tsx src/db/migrate.ts", "migrate": "tsx src/db/migrate.ts",
"migrate:prod": "node dist/db/migrate.js", "migrate:prod": "node dist/db/migrate.js",
"test:analytics": "tsx src/services/analyticsSemantics.test.ts", "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: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:db": "NODE_ENV=test tsx src/services/import.integration.test.ts",
"test:llm": "tsx src/scripts/testLlm.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; 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 (value == null || value === '') {
if (required) throw new Error(`${field} is required`); if (required) throw new Error(`${field} is required`);
return null; return null;
} }
const result = typeof value === 'number' ? value : Number(String(value).replace(/\s/g, '').replace(',', '.')); const result = String(value).replace(/\s/g, '').replace(',', '.');
if (!Number.isFinite(result)) throw new Error(`${field} must be numeric`); if (!/^-?(?:\d+\.?\d*|\.\d+)$/.test(result)) throw new Error(`${field} must be numeric`);
return result; return result;
} }
function validate(data: PortfolioFile): void { export function deriveTradeSourceId(trade: PortfolioTrade): string {
if (data.schemaVersion !== 'broker-portfolio-1.0' || !data.bank || !data.accountNumber) throw new Error('Invalid portfolio file'); if (trade.sourceId?.trim()) return trade.sourceId.trim();
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'); 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'); if (!Array.isArray(data.positions) || !Array.isArray(data.trades)) throw new Error('positions and trades must be arrays');
const sourceIds = new Set<string>(); const sourceIds = new Set<string>();
for (const trade of data.trades) { for (const trade of data.trades) {
if (!trade.sourceId || !trade.instrument || !trade.concludedAt || !trade.side) throw new Error('Invalid trade'); 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 (sourceIds.has(trade.sourceId)) throw new Error(`Duplicate sourceId: ${trade.sourceId}`); const sourceId = deriveTradeSourceId(trade);
sourceIds.add(trade.sourceId); 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}`); 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> { export async function importPortfolio(body: unknown): Promise<ImportPortfolioResponse> {
const data = body as PortfolioFile; validatePortfolio(body);
validate(data); const data = body;
const sourceHash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex'); const sourceHash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex');
const client = await pool.connect(); const client = await pool.connect();
try { try {
@@ -61,17 +75,18 @@ export async function importPortfolio(body: unknown): Promise<ImportPortfolioRes
await client.query( await client.query(
`INSERT INTO portfolio_positions (report_id, instrument, isin, quantity, price, valuation) `INSERT INTO portfolio_positions (report_id, instrument, isin, quantity, price, valuation)
VALUES ($1, $2, $3, $4, $5, $6)`, 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; let importedTrades = 0;
for (const trade of data.trades) { for (const trade of data.trades) {
const sourceId = deriveTradeSourceId(trade);
const result = await client.query( const result = await client.query(
`INSERT INTO portfolio_trades `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) (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) 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`, 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; importedTrades += result.rowCount ?? 0;
} }

View File

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