fix: harden portfolio import
This commit is contained in:
12
backend/src/services/portfolio.test.ts
Normal file
12
backend/src/services/portfolio.test.ts
Normal 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');
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user