feat: add broker portfolio import
This commit is contained in:
@@ -19,6 +19,7 @@ import accountsRouter from './routes/accounts';
|
||||
import categoriesRouter from './routes/categories';
|
||||
import categoryRulesRouter from './routes/categoryRules';
|
||||
import analyticsRouter from './routes/analytics';
|
||||
import portfolioRouter from './routes/portfolio';
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', 1);
|
||||
@@ -43,6 +44,7 @@ app.use('/api/accounts', accountsRouter);
|
||||
app.use('/api/categories', categoriesRouter);
|
||||
app.use('/api/category-rules', categoryRulesRouter);
|
||||
app.use('/api/analytics', analyticsRouter);
|
||||
app.use('/api/import/portfolio', portfolioRouter);
|
||||
|
||||
app.use(
|
||||
(
|
||||
|
||||
@@ -238,6 +238,55 @@ const migrations: { name: string; sql: string }[] = [
|
||||
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> {
|
||||
|
||||
22
backend/src/routes/portfolio.ts
Normal file
22
backend/src/routes/portfolio.ts
Normal 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;
|
||||
86
backend/src/services/portfolio.ts
Normal file
86
backend/src/services/portfolio.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user