Compare commits
6 Commits
fix/json-i
...
feature/br
| Author | SHA1 | Date | |
|---|---|---|---|
| 84d6044d98 | |||
| c83a6d8d81 | |||
| 361a07d4da | |||
| 1dc0e48348 | |||
| 027fbedb8c | |||
| a1e93a7c1f |
12
CHANGELOG.md
12
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/frontend",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
6
frontend/src/api/portfolio.ts
Normal file
6
frontend/src/api/portfolio.ts
Normal 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);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface AnalyticsSummaryResponse {
|
||||
transferOutflow: number;
|
||||
cashNet: number;
|
||||
interestIncome: number;
|
||||
cashbackIncome: number;
|
||||
topCategories: TopCategory[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user