feat: add portfolio overview

This commit is contained in:
2026-08-27 00:07:25 +03:00
parent 1e25a0152a
commit b38346dd19
14 changed files with 230 additions and 5 deletions

View File

@@ -20,6 +20,7 @@ import categoriesRouter from './routes/categories';
import categoryRulesRouter from './routes/categoryRules';
import analyticsRouter from './routes/analytics';
import portfolioRouter from './routes/portfolio';
import portfolioOverviewRouter from './routes/portfolioOverview';
const app = express();
app.set('trust proxy', 1);
@@ -45,6 +46,7 @@ app.use('/api/categories', categoriesRouter);
app.use('/api/category-rules', categoryRulesRouter);
app.use('/api/analytics', analyticsRouter);
app.use('/api/import/portfolio', portfolioRouter);
app.use('/api/portfolio', portfolioOverviewRouter);
app.use(
(

View File

@@ -0,0 +1,14 @@
import { Router } from 'express';
import { asyncHandler } from '../utils';
import { getPortfolioOverview } from '../services/portfolio';
const router = Router();
router.get(
'/',
asyncHandler(async (_req, res) => {
res.json(await getPortfolioOverview());
}),
);
export default router;

View File

@@ -1,10 +1,25 @@
import crypto from 'crypto';
import type { PoolClient } from 'pg';
import { pool } from '../db/pool';
import type { ImportPortfolioResponse, PortfolioFile, PortfolioTrade } from '@family-budget/shared';
import { maskAccountNumber } from '../utils';
import type { ImportPortfolioResponse, PortfolioFile, PortfolioOverviewResponse, 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;
type PortfolioOverviewRow = {
account_id: number | string;
alias: string | null;
bank: string;
account_number: string;
report_period_to: string;
total_valuation: string | number | null;
instrument: string | null;
isin: string | null;
quantity: string | number | null;
price: string | number | null;
valuation: string | number | null;
};
function decimalValue(value: unknown, field: string, required = false): string | null {
if (value == null || value === '') {
if (required) throw new Error(`${field} is required`);
@@ -103,3 +118,53 @@ export async function importPortfolio(body: unknown, db: Pick<typeof pool, 'conn
if (ownsTransaction) client.release();
}
}
export function toPortfolioOverview(rows: PortfolioOverviewRow[]): PortfolioOverviewResponse {
const accounts = new Map<number, PortfolioOverviewResponse['accounts'][number]>();
for (const row of rows) {
const accountId = Number(row.account_id);
let account = accounts.get(accountId);
if (!account) {
account = {
accountId,
accountName: row.alias || `${row.bank} · ${maskAccountNumber(row.account_number)}`,
reportPeriodTo: row.report_period_to,
totalValuation: row.total_valuation === null ? null : String(row.total_valuation),
positions: [],
};
accounts.set(accountId, account);
}
if (row.instrument !== null) {
const valuation = row.valuation === null ? null : String(row.valuation);
account.positions.push({
instrument: row.instrument,
isin: row.isin,
quantity: String(row.quantity),
price: row.price === null ? null : String(row.price),
valuation,
});
}
}
return { accounts: [...accounts.values()] };
}
export async function getPortfolioOverview(): Promise<PortfolioOverviewResponse> {
const { rows } = await pool.query<PortfolioOverviewRow>(
`SELECT a.id AS account_id, a.alias, a.bank, a.account_number,
r.report_period_to,
SUM(p.valuation) OVER (PARTITION BY a.id) AS total_valuation,
p.instrument, p.isin, p.quantity, p.price, p.valuation
FROM accounts a
JOIN LATERAL (
SELECT id, report_period_to, reported_at, imported_at
FROM portfolio_reports
WHERE account_id = a.id
ORDER BY report_period_to DESC, imported_at DESC, id DESC
LIMIT 1
) r ON TRUE
LEFT JOIN portfolio_positions p ON p.report_id = r.id
WHERE a.account_type IN ('brokerage', 'iis')
ORDER BY a.id, p.valuation DESC NULLS LAST, p.id`,
);
return toPortfolioOverview(rows);
}

View File

@@ -0,0 +1,12 @@
import assert from 'node:assert/strict';
import { toPortfolioOverview } from './portfolio';
const result = toPortfolioOverview([
{ account_id: '1', alias: 'ИИС', bank: 'ВТБ', account_number: '123456', report_period_to: '2026-08-26', total_valuation: '150.50', instrument: 'Облигация', isin: 'RU0000000001', quantity: '1', price: '100', valuation: '100' },
{ account_id: '1', alias: 'ИИС', bank: 'ВТБ', account_number: '123456', report_period_to: '2026-08-26', total_valuation: '150.50', instrument: 'Фонд', isin: null, quantity: '2', price: '25.25', valuation: '50.50' },
]);
assert.equal(result.accounts[0].accountName, 'ИИС');
assert.equal(result.accounts[0].totalValuation, '150.50');
assert.equal(result.accounts[0].positions.length, 2);
console.log('portfolio overview: OK');