Compare commits

..

6 Commits

10 changed files with 87 additions and 34 deletions

View File

@@ -1,5 +1,17 @@
# Changelog
## [Frontend 0.11.3] - 2026-08-26
### Added
- Import broker portfolio JSON files and show their trade and position results.
## [Frontend 0.11.2 / Backend 0.10.5 / Shared 0.5.1] - 2026-08-26
### Added
- Show cashback separately in the cash-flow summary alongside interest income.
## [Backend 0.10.3] - 2026-08-24
### Fixed

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/backend",
"version": "0.10.4",
"version": "0.10.5",
"private": true,
"scripts": {
"dev": "tsx watch src/app.ts",

View File

@@ -26,6 +26,10 @@ async function testQueries(): Promise<void> {
"INSERT INTO transactions (account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed) VALUES ($1, '2026-07-06T12:00:00+03:00', 1_500, 0, 'Начисление процентов', 'transfer', 'analytics-test-interest', $2, TRUE)",
[accountId, categoryId.transfer],
);
await client.query(
"INSERT INTO transactions (account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed) VALUES ($1, '2026-07-06T12:00:00+03:00', 0, 200, 'Зачисление кэшбека', 'income', 'analytics-test-cashback', $2, TRUE)",
[accountId, categoryId.income],
);
await insert(otherAccountId, '2026-07-01T12:00:00+03:00', -99_000, categoryId.expense, 'analytics-test-6');
await insert(accountId, '2026-08-01T12:00:00+03:00', -7_000, categoryId.expense, 'analytics-test-7');
await client.query(
@@ -35,7 +39,7 @@ async function testQueries(): Promise<void> {
const params = { from: '2026-07-01', to: '2026-07-31', accountId, onlyConfirmed: true };
const summary = await getSummary(params, client);
assert.deepEqual({ expense: summary.totalExpense, income: summary.totalIncome, net: summary.net, transferOut: summary.transferOutflow, interest: summary.interestIncome }, { expense: 6_000, income: 15_000, net: 9_000, transferOut: 3_000, interest: 1_500 });
assert.deepEqual({ expense: summary.totalExpense, income: summary.totalIncome, net: summary.net, transferOut: summary.transferOutflow, interest: summary.interestIncome, cashback: summary.cashbackIncome }, { expense: 6_000, income: 15_200, net: 9_200, transferOut: 3_000, interest: 1_500, cashback: 200 });
const categorySummary = await getSummary({ ...params, categoryId: categoryId.expense }, client);
assert.deepEqual({ expense: categorySummary.totalExpense, income: categorySummary.totalIncome }, { expense: 6_000, income: 0 });
const uncategorized = await getSummary({ ...params, categoryId: 0, onlyConfirmed: false }, client);

View File

@@ -33,7 +33,11 @@ function analyticsTransactions(where: string): string {
CASE WHEN a.account_type = 'savings'
AND ${effectiveAmount} > 0
AND (t.description ILIKE '%процент%' OR t.description ILIKE '%выплата %' OR t.description LIKE '%\%%' ESCAPE '\\')
THEN ${effectiveAmount} ELSE 0 END AS interest_income
THEN ${effectiveAmount} ELSE 0 END AS interest_income,
CASE WHEN t.amount_signed = 0
AND t.commission > 0
AND t.description ILIKE '%зачисление%'
THEN t.commission ELSE 0 END AS cashback_income
FROM transactions t
LEFT JOIN categories c ON c.id = t.category_id
LEFT JOIN accounts a ON a.id = t.account_id
@@ -87,7 +91,8 @@ export async function getSummary(
`${analyticsTransactions(where)},
category_net AS (
SELECT category_id, category_name, analytic_type, SUM(effective_amount)::bigint AS amount,
SUM(interest_income)::bigint AS interest_income
SUM(interest_income)::bigint AS interest_income,
SUM(cashback_income)::bigint AS cashback_income
FROM analytics_transactions
GROUP BY category_id, category_name, analytic_type
)
@@ -98,7 +103,8 @@ export async function getSummary(
COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions), 0)::bigint AS cash_outflow,
COALESCE((SELECT SUM(GREATEST(effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_inflow,
COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_outflow,
COALESCE(SUM(interest_income), 0)::bigint AS interest_income
COALESCE(SUM(interest_income), 0)::bigint AS interest_income,
COALESCE(SUM(cashback_income), 0)::bigint AS cashback_income
FROM category_net`,
values,
);
@@ -110,6 +116,7 @@ export async function getSummary(
const transferInflow = Number(totalsResult.rows[0].transfer_inflow);
const transferOutflow = Number(totalsResult.rows[0].transfer_outflow);
const interestIncome = Number(totalsResult.rows[0].interest_income);
const cashbackIncome = Number(totalsResult.rows[0].cashback_income);
const topResult = await db.query(
`${analyticsTransactions(where)}
@@ -140,6 +147,7 @@ export async function getSummary(
transferOutflow,
cashNet: cashInflow - cashOutflow,
interestIncome,
cashbackIncome,
topCategories,
};
}

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/frontend",
"version": "0.11.1",
"version": "0.11.3",
"private": true,
"type": "module",
"scripts": {

View File

@@ -0,0 +1,6 @@
import type { ImportPortfolioResponse, PortfolioFile } from '@family-budget/shared';
import { api } from './client';
export function importPortfolio(data: PortfolioFile): Promise<ImportPortfolioResponse> {
return api.post('/api/import/portfolio', data);
}

View File

@@ -1,6 +1,7 @@
import { useState, useRef } from 'react';
import type { ImportStatementResponse } from '@family-budget/shared';
import type { ImportPortfolioResponse, ImportStatementResponse, PortfolioFile } from '@family-budget/shared';
import { importStatement } from '../api/import';
import { importPortfolio } from '../api/portfolio';
import { updateAccount } from '../api/accounts';
interface Props {
@@ -9,7 +10,7 @@ interface Props {
}
export function ImportModal({ onClose, onDone }: Props) {
const [result, setResult] = useState<ImportStatementResponse | null>(null);
const [result, setResult] = useState<ImportStatementResponse | ImportPortfolioResponse | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [alias, setAlias] = useState('');
@@ -37,7 +38,10 @@ export function ImportModal({ onClose, onDone }: Props) {
setResult(null);
try {
const resp = await importStatement(file);
const data = isJson ? JSON.parse(await file.text()) : null;
const resp = data?.schemaVersion === 'broker-portfolio-1.0'
? await importPortfolio(data as PortfolioFile)
: await importStatement(file);
setResult(resp);
} catch (err: unknown) {
const msg =
@@ -49,7 +53,7 @@ export function ImportModal({ onClose, onDone }: Props) {
};
const handleSaveAlias = async () => {
if (!result || !alias.trim()) return;
if (!result || 'reportId' in result || !alias.trim()) return;
try {
await updateAccount(result.accountId, { alias: alias.trim() });
setAliasSaved(true);
@@ -58,6 +62,8 @@ export function ImportModal({ onClose, onDone }: Props) {
}
};
const isPortfolioResult = result != null && 'reportId' in result;
return (
<div
className="modal"
@@ -79,7 +85,7 @@ export function ImportModal({ onClose, onDone }: Props) {
{!result && (
<div className="import-upload">
<p className="import-upload__description">
Выберите файл выписки (PDF или JSON, формат 1.0)
Выберите PDF/JSON выписки или JSON брокерского портфеля
</p>
<input
ref={fileRef}
@@ -97,33 +103,48 @@ export function ImportModal({ onClose, onDone }: Props) {
{result && (
<div className="import-result">
<div className="import-result__icon" aria-hidden="true"></div>
<h3 className="import-result__title">Импорт завершён</h3>
<h3 className="import-result__title">{isPortfolioResult ? 'Импорт портфеля завершён' : 'Импорт завершён'}</h3>
<table className="import-result__stats">
<tbody className="import-result__stats-body">
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Счёт</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.accountNumberMasked}</td>
</tr>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Новый счёт</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.isNewAccount ? 'Да' : 'Нет'}</td>
</tr>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Импортировано</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.imported}</td>
</tr>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Дубликатов пропущено</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.duplicatesSkipped}</td>
</tr>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Всего в файле</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.totalInFile}</td>
</tr>
{isPortfolioResult ? <>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Импортировано сделок</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.importedTrades}</td>
</tr>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Дубликатов сделок</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.duplicateTrades}</td>
</tr>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Позиций</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.positions}</td>
</tr>
</> : <>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Счёт</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.accountNumberMasked}</td>
</tr>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Новый счёт</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.isNewAccount ? 'Да' : 'Нет'}</td>
</tr>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Импортировано</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.imported}</td>
</tr>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Дубликатов пропущено</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.duplicatesSkipped}</td>
</tr>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Всего в файле</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.totalInFile}</td>
</tr>
</>}
</tbody>
</table>
{result.isNewAccount && !aliasSaved && (
{!isPortfolioResult && result.isNewAccount && !aliasSaved && (
<div className="import-result__alias">
<label className="import-result__alias-label">Алиас для нового счёта</label>
<div className="import-result__alias-row">

View File

@@ -41,6 +41,7 @@ export function SummaryCards({ summary }: Props) {
<div className="summary__subvalue">Поступило: {formatAmount(summary.cashInflow)}</div>
<div className="summary__subvalue">Списано: {formatAmount(summary.cashOutflow)}</div>
<div className="summary__subvalue">Доход от процентов: {formatAmount(summary.interestIncome)}</div>
<div className="summary__subvalue">Кэшбек: {formatAmount(summary.cashbackIncome)}</div>
{(summary.transferInflow > 0 || summary.transferOutflow > 0) && (
<div className="summary__subvalue">
Переводы: {formatAmount(summary.transferInflow)} / {formatAmount(summary.transferOutflow)}

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/shared",
"version": "0.5.0",
"version": "0.5.1",
"private": true,
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -25,6 +25,7 @@ export interface AnalyticsSummaryResponse {
transferOutflow: number;
cashNet: number;
interestIncome: number;
cashbackIncome: number;
topCategories: TopCategory[];
}