From e0da7e86ad19f3f58a915f22561c54e761c74bd4 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 20 Aug 2026 15:01:54 +0300 Subject: [PATCH] fix: harden portfolio import --- CHANGELOG.md | 6 ++++ backend/package.json | 3 +- backend/src/services/portfolio.test.ts | 12 +++++++ backend/src/services/portfolio.ts | 45 +++++++++++++++++--------- shared/src/types/import.ts | 2 +- 5 files changed, 51 insertions(+), 17 deletions(-) create mode 100644 backend/src/services/portfolio.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ccad5d6..c1232a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/package.json b/backend/package.json index 011f346..3a3aab9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" diff --git a/backend/src/services/portfolio.test.ts b/backend/src/services/portfolio.test.ts new file mode 100644 index 0000000..3ad6c11 --- /dev/null +++ b/backend/src/services/portfolio.test.ts @@ -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'); diff --git a/backend/src/services/portfolio.ts b/backend/src/services/portfolio.ts index 98c0c9e..bdecec2 100644 --- a/backend/src/services/portfolio.ts +++ b/backend/src/services/portfolio.ts @@ -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(); 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 { - 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