Compare commits
14 Commits
fix/analyt
...
feature/br
| Author | SHA1 | Date | |
|---|---|---|---|
| 84d6044d98 | |||
| c83a6d8d81 | |||
| 361a07d4da | |||
| 1dc0e48348 | |||
| 027fbedb8c | |||
| a1e93a7c1f | |||
| 3f5681074e | |||
| 62519f80cd | |||
| 45f0561fd6 | |||
| fab929fd68 | |||
| c4ce2b9d6b | |||
| c4f681c9b0 | |||
| dd21e20dc6 | |||
| 29c4acd0a9 |
24
CHANGELOG.md
24
CHANGELOG.md
@@ -1,5 +1,29 @@
|
|||||||
# Changelog
|
# 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
|
||||||
|
|
||||||
|
- Treat VTB savings-account deposits and closures as internal transfers when importing PDF-to-JSON statements.
|
||||||
|
|
||||||
|
## [Backend 0.10.2] - 2026-08-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Analytics endpoints now reject invalid account and category filter IDs with a validation error.
|
||||||
|
|
||||||
## [Frontend 0.11.1] - 2026-08-21
|
## [Frontend 0.11.1] - 2026-08-21
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/backend",
|
"name": "@family-budget/backend",
|
||||||
"version": "0.10.2",
|
"version": "0.10.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/app.ts",
|
"dev": "tsx watch src/app.ts",
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"test:transactions": "tsx src/services/transactions.test.ts",
|
"test:transactions": "tsx src/services/transactions.test.ts",
|
||||||
"test:analytics:db": "NODE_ENV=test tsx src/services/analytics.integration.test.ts",
|
"test:analytics:db": "NODE_ENV=test tsx src/services/analytics.integration.test.ts",
|
||||||
"test:import:db": "NODE_ENV=test tsx src/services/import.integration.test.ts",
|
"test:import:db": "NODE_ENV=test tsx src/services/import.integration.test.ts",
|
||||||
|
"test:import:direction": "tsx src/services/import.test.ts",
|
||||||
"test:llm": "tsx src/scripts/testLlm.ts"
|
"test:llm": "tsx src/scripts/testLlm.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -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)",
|
"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],
|
[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(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 insert(accountId, '2026-08-01T12:00:00+03:00', -7_000, categoryId.expense, 'analytics-test-7');
|
||||||
await client.query(
|
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 params = { from: '2026-07-01', to: '2026-07-31', accountId, onlyConfirmed: true };
|
||||||
const summary = await getSummary(params, client);
|
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);
|
const categorySummary = await getSummary({ ...params, categoryId: categoryId.expense }, client);
|
||||||
assert.deepEqual({ expense: categorySummary.totalExpense, income: categorySummary.totalIncome }, { expense: 6_000, income: 0 });
|
assert.deepEqual({ expense: categorySummary.totalExpense, income: categorySummary.totalIncome }, { expense: 6_000, income: 0 });
|
||||||
const uncategorized = await getSummary({ ...params, categoryId: 0, onlyConfirmed: false }, client);
|
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'
|
CASE WHEN a.account_type = 'savings'
|
||||||
AND ${effectiveAmount} > 0
|
AND ${effectiveAmount} > 0
|
||||||
AND (t.description ILIKE '%процент%' OR t.description ILIKE '%выплата %' OR t.description LIKE '%\%%' ESCAPE '\\')
|
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
|
FROM transactions t
|
||||||
LEFT JOIN categories c ON c.id = t.category_id
|
LEFT JOIN categories c ON c.id = t.category_id
|
||||||
LEFT JOIN accounts a ON a.id = t.account_id
|
LEFT JOIN accounts a ON a.id = t.account_id
|
||||||
@@ -87,7 +91,8 @@ export async function getSummary(
|
|||||||
`${analyticsTransactions(where)},
|
`${analyticsTransactions(where)},
|
||||||
category_net AS (
|
category_net AS (
|
||||||
SELECT category_id, category_name, analytic_type, SUM(effective_amount)::bigint AS amount,
|
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
|
FROM analytics_transactions
|
||||||
GROUP BY category_id, category_name, analytic_type
|
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), 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_inflow,
|
||||||
COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_outflow,
|
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`,
|
FROM category_net`,
|
||||||
values,
|
values,
|
||||||
);
|
);
|
||||||
@@ -110,6 +116,7 @@ export async function getSummary(
|
|||||||
const transferInflow = Number(totalsResult.rows[0].transfer_inflow);
|
const transferInflow = Number(totalsResult.rows[0].transfer_inflow);
|
||||||
const transferOutflow = Number(totalsResult.rows[0].transfer_outflow);
|
const transferOutflow = Number(totalsResult.rows[0].transfer_outflow);
|
||||||
const interestIncome = Number(totalsResult.rows[0].interest_income);
|
const interestIncome = Number(totalsResult.rows[0].interest_income);
|
||||||
|
const cashbackIncome = Number(totalsResult.rows[0].cashback_income);
|
||||||
|
|
||||||
const topResult = await db.query(
|
const topResult = await db.query(
|
||||||
`${analyticsTransactions(where)}
|
`${analyticsTransactions(where)}
|
||||||
@@ -140,6 +147,7 @@ export async function getSummary(
|
|||||||
transferOutflow,
|
transferOutflow,
|
||||||
cashNet: cashInflow - cashOutflow,
|
cashNet: cashInflow - cashOutflow,
|
||||||
interestIncome,
|
interestIncome,
|
||||||
|
cashbackIncome,
|
||||||
topCategories,
|
topCategories,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,21 @@ const makeStatement = (sourceIds: string[]) => ({
|
|||||||
transactions: sourceIds.map((sourceId) => ({ operationAt: '2026-08-20T10:00:00+03:00', amountSigned: 100, commission: 0, description: 'Пополнение', sourceId })),
|
transactions: sourceIds.map((sourceId) => ({ operationAt: '2026-08-20T10:00:00+03:00', amountSigned: 100, commission: 0, description: 'Пополнение', sourceId })),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const duplicateStatement = {
|
||||||
|
schemaVersion: '1.0', bank: 'TEST',
|
||||||
|
statement: { accountNumber: 'fingerprint-test', currency: 'RUB', openingBalance: 0, closingBalance: 200, exportedAt: '2026-08-20T12:00:00+03:00' },
|
||||||
|
transactions: Array.from({ length: 2 }, () => ({ operationAt: '2026-08-20T00:00:00+03:00', amountSigned: 100, commission: 0, description: 'Пополнение' })),
|
||||||
|
};
|
||||||
|
const overlapStatement = {
|
||||||
|
...duplicateStatement,
|
||||||
|
statement: { ...duplicateStatement.statement, accountNumber: 'overlap-test' },
|
||||||
|
};
|
||||||
|
const singleStatement = {
|
||||||
|
...overlapStatement,
|
||||||
|
statement: { ...overlapStatement.statement, closingBalance: 100 },
|
||||||
|
transactions: overlapStatement.transactions.slice(0, 1),
|
||||||
|
};
|
||||||
|
|
||||||
async function run(): Promise<void> {
|
async function run(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await importStatement(makeStatement(['first', 'first-second']));
|
await importStatement(makeStatement(['first', 'first-second']));
|
||||||
@@ -18,11 +33,32 @@ async function run(): Promise<void> {
|
|||||||
await importStatement(makeStatement(['second']));
|
await importStatement(makeStatement(['second']));
|
||||||
const result = await pool.query('SELECT account_type, status FROM accounts WHERE id = $1', [account.rows[0].id]);
|
const result = await pool.query('SELECT account_type, status FROM accounts WHERE id = $1', [account.rows[0].id]);
|
||||||
assert.deepEqual(result.rows[0], { account_type: 'savings', status: 'closed' });
|
assert.deepEqual(result.rows[0], { account_type: 'savings', status: 'closed' });
|
||||||
|
const duplicateResult = await importStatement(duplicateStatement);
|
||||||
|
if ('status' in duplicateResult) throw new Error(duplicateResult.message);
|
||||||
|
assert.deepEqual(duplicateResult, {
|
||||||
|
accountId: duplicateResult.accountId,
|
||||||
|
isNewAccount: true,
|
||||||
|
accountNumberMasked: 'fingerp******test',
|
||||||
|
imported: 2,
|
||||||
|
duplicatesSkipped: 0,
|
||||||
|
totalInFile: 2,
|
||||||
|
});
|
||||||
|
await importStatement(singleStatement);
|
||||||
|
const overlapResult = await importStatement(overlapStatement);
|
||||||
|
if ('status' in overlapResult) throw new Error(overlapResult.message);
|
||||||
|
assert.deepEqual(overlapResult, {
|
||||||
|
accountId: overlapResult.accountId,
|
||||||
|
isNewAccount: false,
|
||||||
|
accountNumberMasked: 'overla******test',
|
||||||
|
imported: 1,
|
||||||
|
duplicatesSkipped: 1,
|
||||||
|
totalInFile: 2,
|
||||||
|
});
|
||||||
console.log('import metadata SQL: OK');
|
console.log('import metadata SQL: OK');
|
||||||
} finally {
|
} finally {
|
||||||
await pool.query('DELETE FROM transactions WHERE account_id IN (SELECT id FROM accounts WHERE bank = \'TEST\' AND account_number = \'metadata-test\')');
|
await pool.query("DELETE FROM transactions WHERE account_id IN (SELECT id FROM accounts WHERE bank = 'TEST' AND account_number IN ('metadata-test', 'fingerprint-test', 'overlap-test'))");
|
||||||
await pool.query("DELETE FROM imports WHERE account_id IN (SELECT id FROM accounts WHERE bank = 'TEST' AND account_number = 'metadata-test')");
|
await pool.query("DELETE FROM imports WHERE account_id IN (SELECT id FROM accounts WHERE bank = 'TEST' AND account_number IN ('metadata-test', 'fingerprint-test', 'overlap-test'))");
|
||||||
await pool.query("DELETE FROM accounts WHERE bank = 'TEST' AND account_number = 'metadata-test'");
|
await pool.query("DELETE FROM accounts WHERE bank = 'TEST' AND account_number IN ('metadata-test', 'fingerprint-test', 'overlap-test')");
|
||||||
await pool.end();
|
await pool.end();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
backend/src/services/import.test.ts
Normal file
12
backend/src/services/import.test.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { computeFingerprint, determineDirection } from './import';
|
||||||
|
|
||||||
|
assert.equal(determineDirection(1, 'Перечисление средств на счет N 123 со счета N 456'), 'transfer');
|
||||||
|
assert.equal(determineDirection(1, 'Перечисление средств на вклад N 123'), 'transfer');
|
||||||
|
assert.equal(determineDirection(-1, 'Перечисление суммы вклада при закрытии'), 'transfer');
|
||||||
|
assert.equal(determineDirection(-1, 'Оплата покупки'), 'expense');
|
||||||
|
assert.equal(determineDirection(1, 'Выплата процентов'), 'income');
|
||||||
|
const duplicateTransaction = { operationAt: '2026-08-20T00:00:00+03:00', amountSigned: 100, commission: 0, description: 'Пополнение' };
|
||||||
|
assert.notEqual(computeFingerprint('fingerprint-test', duplicateTransaction, 0), computeFingerprint('fingerprint-test', duplicateTransaction, 1));
|
||||||
|
|
||||||
|
console.log('import direction: OK');
|
||||||
@@ -6,14 +6,18 @@ import type { StatementFile, ImportStatementResponse } from '@family-budget/shar
|
|||||||
const TRANSFER_PHRASES = [
|
const TRANSFER_PHRASES = [
|
||||||
'перевод между своими счетами',
|
'перевод между своими счетами',
|
||||||
'перевод средств на счет',
|
'перевод средств на счет',
|
||||||
|
'перечисление средств на счет',
|
||||||
|
'перечисление средств на вклад',
|
||||||
|
'перечисление суммы вклада при закрытии',
|
||||||
'внутри втб',
|
'внутри втб',
|
||||||
];
|
];
|
||||||
const CASHBACK_KEYWORD = 'зачисление';
|
const CASHBACK_KEYWORD = 'зачисление';
|
||||||
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;
|
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 computeFingerprint(
|
export function computeFingerprint(
|
||||||
accountNumber: string,
|
accountNumber: string,
|
||||||
tx: { operationAt: string; amountSigned: number; commission: number; description: string; sourceId?: string },
|
tx: { operationAt: string; amountSigned: number; commission: number; description: string; sourceId?: string },
|
||||||
|
sourcePosition?: number,
|
||||||
): string {
|
): string {
|
||||||
if (tx.sourceId) {
|
if (tx.sourceId) {
|
||||||
const raw = [accountNumber, tx.sourceId.trim()].join('|');
|
const raw = [accountNumber, tx.sourceId.trim()].join('|');
|
||||||
@@ -26,12 +30,13 @@ function computeFingerprint(
|
|||||||
String(tx.amountSigned),
|
String(tx.amountSigned),
|
||||||
String(tx.commission),
|
String(tx.commission),
|
||||||
tx.description.trim(),
|
tx.description.trim(),
|
||||||
|
...(sourcePosition === undefined ? [] : [String(sourcePosition)]),
|
||||||
].join('|');
|
].join('|');
|
||||||
const hash = crypto.createHash('sha256').update(raw, 'utf-8').digest('hex');
|
const hash = crypto.createHash('sha256').update(raw, 'utf-8').digest('hex');
|
||||||
return `sha256:${hash}`;
|
return `sha256:${hash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function determineDirection(amountSigned: number, description: string): string {
|
export function determineDirection(amountSigned: number, description: string): string {
|
||||||
const lower = description.toLowerCase();
|
const lower = description.toLowerCase();
|
||||||
for (const phrase of TRANSFER_PHRASES) {
|
for (const phrase of TRANSFER_PHRASES) {
|
||||||
if (lower.includes(phrase)) return 'transfer';
|
if (lower.includes(phrase)) return 'transfer';
|
||||||
@@ -136,7 +141,7 @@ function validateSemantics(data: StatementFile): ValidationError | null {
|
|||||||
const operationIds = new Set<string>();
|
const operationIds = new Set<string>();
|
||||||
for (let i = 0; i < data.transactions.length; i++) {
|
for (let i = 0; i < data.transactions.length; i++) {
|
||||||
const fp = computeFingerprint(data.statement.accountNumber, data.transactions[i]);
|
const fp = computeFingerprint(data.statement.accountNumber, data.transactions[i]);
|
||||||
if (fps.has(fp)) {
|
if (data.transactions[i].sourceId && fps.has(fp)) {
|
||||||
return { status: 422, error: 'VALIDATION_ERROR', message: `Duplicate fingerprint found within file at transaction index ${i}` };
|
return { status: 422, error: 'VALIDATION_ERROR', message: `Duplicate fingerprint found within file at transaction index ${i}` };
|
||||||
}
|
}
|
||||||
fps.add(fp);
|
fps.add(fp);
|
||||||
@@ -218,9 +223,15 @@ export async function importStatement(
|
|||||||
|
|
||||||
// Insert transactions
|
// Insert transactions
|
||||||
const insertedIds: number[] = [];
|
const insertedIds: number[] = [];
|
||||||
|
const fallbackFingerprintOccurrences = new Map<string, number>();
|
||||||
|
|
||||||
for (const [sourcePosition, tx] of data.transactions.entries()) {
|
for (const [sourcePosition, tx] of data.transactions.entries()) {
|
||||||
const fp = computeFingerprint(data.statement.accountNumber, tx);
|
const fallbackFingerprint = computeFingerprint(data.statement.accountNumber, tx);
|
||||||
|
const occurrence = fallbackFingerprintOccurrences.get(fallbackFingerprint) ?? 0;
|
||||||
|
fallbackFingerprintOccurrences.set(fallbackFingerprint, occurrence + 1);
|
||||||
|
const fp = !tx.sourceId && occurrence > 0
|
||||||
|
? computeFingerprint(data.statement.accountNumber, tx, occurrence)
|
||||||
|
: fallbackFingerprint;
|
||||||
const isCashbackCommissionImport =
|
const isCashbackCommissionImport =
|
||||||
tx.amountSigned === 0 &&
|
tx.amountSigned === 0 &&
|
||||||
tx.commission > 0 &&
|
tx.commission > 0 &&
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
|
|
||||||
- `statement.currency` соответствует допустимому коду валюты (MVP: `"RUB"`).
|
- `statement.currency` соответствует допустимому коду валюты (MVP: `"RUB"`).
|
||||||
- `operationAt` у всех транзакций — валидная дата (парсится без ошибок).
|
- `operationAt` у всех транзакций — валидная дата (парсится без ошибок).
|
||||||
- Отсутствуют дубликаты fingerprint внутри одного файла.
|
- Повторяющиеся `sourceId` внутри одного файла отклоняются; одинаковые операции без `sourceId` различаются по позиции в массиве `transactions`.
|
||||||
|
|
||||||
Ответ при ошибке:
|
Ответ при ошибке:
|
||||||
|
|
||||||
@@ -120,10 +120,11 @@
|
|||||||
Для каждой транзакции вычисляется SHA-256 от полей, соединённых разделителем `|`:
|
Для каждой транзакции вычисляется SHA-256 от полей, соединённых разделителем `|`:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
accountNumber|operationAt|amountSigned|commission|normalizedDescription
|
accountNumber|operationAt|amountSigned|commission|normalizedDescription[|sourcePosition]
|
||||||
```
|
```
|
||||||
|
|
||||||
- `normalizedDescription` — `description` после `trim`.
|
- `normalizedDescription` — `description` после `trim`.
|
||||||
|
- `sourcePosition` — порядковый номер повторяющейся операции в массиве `transactions`; добавляется, только если одинаковые операции без `sourceId` повторяются в одном файле.
|
||||||
- Суммы подставляются в том виде, в котором пришли в JSON (числовое представление).
|
- Суммы подставляются в том виде, в котором пришли в JSON (числовое представление).
|
||||||
- Разделитель `|` исключает коллизии при склейке полей разной длины.
|
- Разделитель `|` исключает коллизии при склейке полей разной длины.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/frontend",
|
"name": "@family-budget/frontend",
|
||||||
"version": "0.11.1",
|
"version": "0.11.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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 { 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 { importStatement } from '../api/import';
|
||||||
|
import { importPortfolio } from '../api/portfolio';
|
||||||
import { updateAccount } from '../api/accounts';
|
import { updateAccount } from '../api/accounts';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -9,7 +10,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ImportModal({ onClose, onDone }: 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 [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [alias, setAlias] = useState('');
|
const [alias, setAlias] = useState('');
|
||||||
@@ -37,7 +38,10 @@ export function ImportModal({ onClose, onDone }: Props) {
|
|||||||
setResult(null);
|
setResult(null);
|
||||||
|
|
||||||
try {
|
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);
|
setResult(resp);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg =
|
const msg =
|
||||||
@@ -49,7 +53,7 @@ export function ImportModal({ onClose, onDone }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveAlias = async () => {
|
const handleSaveAlias = async () => {
|
||||||
if (!result || !alias.trim()) return;
|
if (!result || 'reportId' in result || !alias.trim()) return;
|
||||||
try {
|
try {
|
||||||
await updateAccount(result.accountId, { alias: alias.trim() });
|
await updateAccount(result.accountId, { alias: alias.trim() });
|
||||||
setAliasSaved(true);
|
setAliasSaved(true);
|
||||||
@@ -58,6 +62,8 @@ export function ImportModal({ onClose, onDone }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isPortfolioResult = result != null && 'reportId' in result;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="modal"
|
className="modal"
|
||||||
@@ -79,7 +85,7 @@ export function ImportModal({ onClose, onDone }: Props) {
|
|||||||
{!result && (
|
{!result && (
|
||||||
<div className="import-upload">
|
<div className="import-upload">
|
||||||
<p className="import-upload__description">
|
<p className="import-upload__description">
|
||||||
Выберите файл выписки (PDF или JSON, формат 1.0)
|
Выберите PDF/JSON выписки или JSON брокерского портфеля
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
@@ -97,33 +103,48 @@ export function ImportModal({ onClose, onDone }: Props) {
|
|||||||
{result && (
|
{result && (
|
||||||
<div className="import-result">
|
<div className="import-result">
|
||||||
<div className="import-result__icon" aria-hidden="true">✓</div>
|
<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">
|
<table className="import-result__stats">
|
||||||
<tbody className="import-result__stats-body">
|
<tbody className="import-result__stats-body">
|
||||||
<tr className="import-result__stat-row">
|
{isPortfolioResult ? <>
|
||||||
<td className="import-result__stat-cell import-result__stat-cell--label">Счёт</td>
|
<tr className="import-result__stat-row">
|
||||||
<td className="import-result__stat-cell import-result__stat-cell--value">{result.accountNumberMasked}</td>
|
<td className="import-result__stat-cell import-result__stat-cell--label">Импортировано сделок</td>
|
||||||
</tr>
|
<td className="import-result__stat-cell import-result__stat-cell--value">{result.importedTrades}</td>
|
||||||
<tr className="import-result__stat-row">
|
</tr>
|
||||||
<td className="import-result__stat-cell import-result__stat-cell--label">Новый счёт</td>
|
<tr className="import-result__stat-row">
|
||||||
<td className="import-result__stat-cell import-result__stat-cell--value">{result.isNewAccount ? 'Да' : 'Нет'}</td>
|
<td className="import-result__stat-cell import-result__stat-cell--label">Дубликатов сделок</td>
|
||||||
</tr>
|
<td className="import-result__stat-cell import-result__stat-cell--value">{result.duplicateTrades}</td>
|
||||||
<tr className="import-result__stat-row">
|
</tr>
|
||||||
<td className="import-result__stat-cell import-result__stat-cell--label">Импортировано</td>
|
<tr className="import-result__stat-row">
|
||||||
<td className="import-result__stat-cell import-result__stat-cell--value">{result.imported}</td>
|
<td className="import-result__stat-cell import-result__stat-cell--label">Позиций</td>
|
||||||
</tr>
|
<td className="import-result__stat-cell import-result__stat-cell--value">{result.positions}</td>
|
||||||
<tr className="import-result__stat-row">
|
</tr>
|
||||||
<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 className="import-result__stat-row">
|
||||||
</tr>
|
<td className="import-result__stat-cell import-result__stat-cell--label">Счёт</td>
|
||||||
<tr className="import-result__stat-row">
|
<td className="import-result__stat-cell import-result__stat-cell--value">{result.accountNumberMasked}</td>
|
||||||
<td className="import-result__stat-cell import-result__stat-cell--label">Всего в файле</td>
|
</tr>
|
||||||
<td className="import-result__stat-cell import-result__stat-cell--value">{result.totalInFile}</td>
|
<tr className="import-result__stat-row">
|
||||||
</tr>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{result.isNewAccount && !aliasSaved && (
|
{!isPortfolioResult && result.isNewAccount && !aliasSaved && (
|
||||||
<div className="import-result__alias">
|
<div className="import-result__alias">
|
||||||
<label className="import-result__alias-label">Алиас для нового счёта</label>
|
<label className="import-result__alias-label">Алиас для нового счёта</label>
|
||||||
<div className="import-result__alias-row">
|
<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.cashInflow)}</div>
|
||||||
<div className="summary__subvalue">Списано: {formatAmount(summary.cashOutflow)}</div>
|
<div className="summary__subvalue">Списано: {formatAmount(summary.cashOutflow)}</div>
|
||||||
<div className="summary__subvalue">Доход от процентов: {formatAmount(summary.interestIncome)}</div>
|
<div className="summary__subvalue">Доход от процентов: {formatAmount(summary.interestIncome)}</div>
|
||||||
|
<div className="summary__subvalue">Кэшбек: {formatAmount(summary.cashbackIncome)}</div>
|
||||||
{(summary.transferInflow > 0 || summary.transferOutflow > 0) && (
|
{(summary.transferInflow > 0 || summary.transferOutflow > 0) && (
|
||||||
<div className="summary__subvalue">
|
<div className="summary__subvalue">
|
||||||
Переводы: {formatAmount(summary.transferInflow)} / {formatAmount(summary.transferOutflow)}
|
Переводы: {formatAmount(summary.transferInflow)} / {formatAmount(summary.transferOutflow)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/shared",
|
"name": "@family-budget/shared",
|
||||||
"version": "0.5.0",
|
"version": "0.5.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface AnalyticsSummaryResponse {
|
|||||||
transferOutflow: number;
|
transferOutflow: number;
|
||||||
cashNet: number;
|
cashNet: number;
|
||||||
interestIncome: number;
|
interestIncome: number;
|
||||||
|
cashbackIncome: number;
|
||||||
topCategories: TopCategory[];
|
topCategories: TopCategory[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user