forked from admin/family_budget
Compare commits
25 Commits
feature/re
...
feature/ac
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d7d0e639c | |||
| 1b20b76704 | |||
| c6b79c1d35 | |||
| dd2990357a | |||
| 6550e2057c | |||
| f8e4f86a6d | |||
| e1043bed36 | |||
| a090ce37ad | |||
| 1142a9755a | |||
| bb3789c01c | |||
| 2422ea88fd | |||
| 3af9e9dbfe | |||
| b99555ca23 | |||
| 5c059cbc62 | |||
| 3d08c9e5c6 | |||
| 076ab7b055 | |||
| e78445888f | |||
| 36562f8d78 | |||
| 1cbef453b0 | |||
| 019714a8cf | |||
| a79629a60d | |||
| ea70be8e5a | |||
| 2c98d9c534 | |||
| 2422ca44b0 | |||
| 6610e43200 |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/__pycache__
|
||||||
|
temp
|
||||||
|
*.log
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,4 +23,5 @@ history.xlsx
|
|||||||
match_analysis.py
|
match_analysis.py
|
||||||
match_report.txt
|
match_report.txt
|
||||||
statements/
|
statements/
|
||||||
|
temp/
|
||||||
.cursor/
|
.cursor/
|
||||||
|
|||||||
88
CHANGELOG.md
Normal file
88
CHANGELOG.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [Backend 0.7.4] - 2026-08-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Corrected database integration test connection ownership during repeated imports.
|
||||||
|
|
||||||
|
## [Backend 0.7.3] - 2026-08-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a runnable database integration test for preserving account metadata during re-import.
|
||||||
|
|
||||||
|
## [Frontend 0.10.2 / Backend 0.7.2] - 2026-08-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Existing uncategorized operations become confirmed investments when an investment account is labeled; account selectors use Russian labels.
|
||||||
|
|
||||||
|
## [Frontend 0.10.1 / Backend 0.7.1] - 2026-08-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Account labels are shown in analytics filters, and account defaults now remain overridable by matching category rules.
|
||||||
|
|
||||||
|
## [Frontend 0.10.0 / Backend 0.7.0 / Shared 0.3.0] - 2026-08-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added account type/status labels, automatic investment classification for brokerage, IIS, and savings accounts, and a separate interest-income metric.
|
||||||
|
|
||||||
|
## [Frontend 0.9.3] - 2026-08-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Aligned sidebar versions and copyright as separate footer lines and updated the copyright period.
|
||||||
|
|
||||||
|
## [Backend 0.6.8] - 2026-08-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Docker build context now excludes environment files and other local secret-bearing artifacts.
|
||||||
|
|
||||||
|
## [Backend 0.6.7] - 2026-08-20
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Docker Compose now injects backend configuration from the untracked `backend/.env`; secrets are no longer hard-coded or copied into the image.
|
||||||
|
|
||||||
|
## [Backend 0.6.6] - 2026-08-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Re-importing an existing operation UUID now safely skips the row even if other source fields changed.
|
||||||
|
|
||||||
|
## [Backend 0.6.5] - 2026-08-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Duplicate operation UUIDs in one import file now return a validation error instead of a database failure.
|
||||||
|
|
||||||
|
## [Frontend 0.9.2 / Backend 0.6.4 / Shared 0.2.2] - 2026-08-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added deterministic UUIDs for imported operations and a unique database constraint per account.
|
||||||
|
|
||||||
|
## [Frontend 0.9.1 / Backend 0.6.3 / Shared 0.2.1] - 2026-08-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a standard-library VTB broker XLSX converter for cash movements and a portfolio/trades sidecar JSON.
|
||||||
|
- Added optional source operation IDs so repeated statement imports remain idempotent without collapsing legitimate identical operations.
|
||||||
|
|
||||||
|
## [Frontend 0.9.0 / Backend 0.6.2] - 2026-08-19
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Analytics now calculate net income and expenses by category type: refunds reduce the original expense category and internal transfers no longer affect P&L.
|
||||||
|
- Added a separate cash-flow summary with incoming, outgoing, and transfer amounts.
|
||||||
|
- Added unit and transactional SQL checks for analytics calculations.
|
||||||
|
|
||||||
|
## [0.8.7] - 2026-08-16
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Completed the frontend CSS class migration to BEM naming and aligned component styles with the design system.
|
||||||
@@ -31,7 +31,6 @@ COPY --from=build /app/backend/dist ./dist
|
|||||||
COPY --from=build /app/backend/package.json ./package.json
|
COPY --from=build /app/backend/package.json ./package.json
|
||||||
COPY --from=build /app/node_modules ./node_modules
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
COPY --from=build /app/backend/node_modules/ ./node_modules/
|
COPY --from=build /app/backend/node_modules/ ./node_modules/
|
||||||
COPY backend/.env .env
|
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node","dist/app.js"]
|
CMD ["node","dist/app.js"]
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ createdb family_budget
|
|||||||
npm run dev -w backend
|
npm run dev -w backend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Для Docker Compose production-переменные передаются из `backend/.env`. Файл не хранится в Git и не копируется в Docker image; перед запуском создайте его из `.env.example` и заполните секреты.
|
||||||
|
|
||||||
Сервер стартует на `http://localhost:3000` (или на порту из `PORT`).
|
Сервер стартует на `http://localhost:3000` (или на порту из `PORT`).
|
||||||
|
|
||||||
## Скрипты
|
## Скрипты
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/backend",
|
"name": "@family-budget/backend",
|
||||||
"version": "0.5.12",
|
"version": "0.7.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/app.ts",
|
"dev": "tsx watch src/app.ts",
|
||||||
@@ -8,6 +8,9 @@
|
|||||||
"start": "node dist/app.js",
|
"start": "node dist/app.js",
|
||||||
"migrate": "tsx src/db/migrate.ts",
|
"migrate": "tsx src/db/migrate.ts",
|
||||||
"migrate:prod": "node dist/db/migrate.js",
|
"migrate:prod": "node dist/db/migrate.js",
|
||||||
|
"test:analytics": "tsx src/services/analyticsSemantics.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:llm": "tsx src/scripts/testLlm.ts"
|
"test:llm": "tsx src/scripts/testLlm.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -133,6 +133,16 @@ const migrations: { name: string; sql: string }[] = [
|
|||||||
AND NOT EXISTS (SELECT 1 FROM category_rules LIMIT 1);
|
AND NOT EXISTS (SELECT 1 FROM category_rules LIMIT 1);
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: '006_transaction_operation_uuid',
|
||||||
|
sql: `
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
ALTER TABLE transactions
|
||||||
|
ADD COLUMN IF NOT EXISTS operation_id UUID NOT NULL DEFAULT gen_random_uuid();
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_transactions_account_operation_id
|
||||||
|
ON transactions(account_id, operation_id);
|
||||||
|
`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: '005_imports_table',
|
name: '005_imports_table',
|
||||||
sql: `
|
sql: `
|
||||||
@@ -212,6 +222,22 @@ const migrations: { name: string; sql: string }[] = [
|
|||||||
ADD COLUMN IF NOT EXISTS import_id BIGINT REFERENCES imports(id);
|
ADD COLUMN IF NOT EXISTS import_id BIGINT REFERENCES imports(id);
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: '007_account_metadata',
|
||||||
|
sql: `
|
||||||
|
ALTER TABLE accounts
|
||||||
|
ADD COLUMN IF NOT EXISTS account_type TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active';
|
||||||
|
ALTER TABLE accounts DROP CONSTRAINT IF EXISTS chk_accounts_type;
|
||||||
|
ALTER TABLE accounts
|
||||||
|
ADD CONSTRAINT chk_accounts_type
|
||||||
|
CHECK (account_type IS NULL OR account_type IN ('brokerage', 'iis', 'savings', 'current'));
|
||||||
|
ALTER TABLE accounts DROP CONSTRAINT IF EXISTS chk_accounts_status;
|
||||||
|
ALTER TABLE accounts
|
||||||
|
ADD CONSTRAINT chk_accounts_status
|
||||||
|
CHECK (status IN ('active', 'closed'));
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function runMigrations(): Promise<void> {
|
export async function runMigrations(): Promise<void> {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ router.put(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { alias } = req.body;
|
const { alias, accountType, status } = req.body;
|
||||||
if (typeof alias !== 'string' || !alias.trim()) {
|
if (typeof alias !== 'string' || !alias.trim()) {
|
||||||
res.status(400).json({ error: 'BAD_REQUEST', message: 'alias is required and must be non-empty' });
|
res.status(400).json({ error: 'BAD_REQUEST', message: 'alias is required and must be non-empty' });
|
||||||
return;
|
return;
|
||||||
@@ -30,8 +30,21 @@ router.put(
|
|||||||
res.status(400).json({ error: 'BAD_REQUEST', message: 'alias must be at most 50 characters' });
|
res.status(400).json({ error: 'BAD_REQUEST', message: 'alias must be at most 50 characters' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (accountType !== undefined && accountType !== null && !['brokerage', 'iis', 'savings', 'current'].includes(accountType)) {
|
||||||
|
res.status(400).json({ error: 'BAD_REQUEST', message: 'Invalid accountType' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status !== undefined && !['active', 'closed'].includes(status)) {
|
||||||
|
res.status(400).json({ error: 'BAD_REQUEST', message: 'Invalid status' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await accountService.updateAccountAlias(id, alias.trim());
|
const result = await accountService.updateAccount(
|
||||||
|
id,
|
||||||
|
alias.trim(),
|
||||||
|
accountType,
|
||||||
|
status,
|
||||||
|
);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
res.status(404).json({ error: 'NOT_FOUND', message: 'Account not found' });
|
res.status(404).json({ error: 'NOT_FOUND', message: 'Account not found' });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { pool } from '../db/pool';
|
import { pool } from '../db/pool';
|
||||||
import { maskAccountNumber } from '../utils';
|
import { maskAccountNumber } from '../utils';
|
||||||
import type { Account } from '@family-budget/shared';
|
import type { Account, AccountStatus, AccountType } from '@family-budget/shared';
|
||||||
|
|
||||||
function toAccount(r: Record<string, unknown>): Account {
|
function toAccount(r: Record<string, unknown>): Account {
|
||||||
return {
|
return {
|
||||||
@@ -9,6 +9,8 @@ function toAccount(r: Record<string, unknown>): Account {
|
|||||||
accountNumberMasked: maskAccountNumber(r.account_number as string),
|
accountNumberMasked: maskAccountNumber(r.account_number as string),
|
||||||
currency: r.currency as string,
|
currency: r.currency as string,
|
||||||
alias: (r.alias as string) ?? null,
|
alias: (r.alias as string) ?? null,
|
||||||
|
accountType: (r.account_type as AccountType) ?? null,
|
||||||
|
status: (r.status as AccountStatus) ?? 'active',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,14 +21,45 @@ export async function getAccounts(): Promise<Account[]> {
|
|||||||
return rows.map(toAccount);
|
return rows.map(toAccount);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAccountAlias(
|
export async function updateAccount(
|
||||||
id: number,
|
id: number,
|
||||||
alias: string,
|
alias: string,
|
||||||
|
accountType?: AccountType | null,
|
||||||
|
status?: AccountStatus,
|
||||||
): Promise<Account | null> {
|
): Promise<Account | null> {
|
||||||
const { rows } = await pool.query(
|
const client = await pool.connect();
|
||||||
'UPDATE accounts SET alias = $1 WHERE id = $2 RETURNING *',
|
try {
|
||||||
[alias, id],
|
await client.query('BEGIN');
|
||||||
);
|
const current = await client.query('SELECT account_type, status FROM accounts WHERE id = $1', [id]);
|
||||||
if (rows.length === 0) return null;
|
if (current.rows.length === 0) {
|
||||||
return toAccount(rows[0]);
|
await client.query('ROLLBACK');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const nextType = accountType === undefined ? current.rows[0].account_type : accountType;
|
||||||
|
const nextStatus = status ?? current.rows[0].status ?? 'active';
|
||||||
|
const { rows } = await client.query(
|
||||||
|
'UPDATE accounts SET alias = $1, account_type = $2, status = $3 WHERE id = $4 RETURNING *',
|
||||||
|
[alias, nextType, nextStatus, id],
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (['brokerage', 'iis', 'savings'].includes(nextType ?? '')) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE transactions
|
||||||
|
SET category_id = (SELECT id FROM categories WHERE name = 'Инвестиции' AND type = 'transfer' LIMIT 1),
|
||||||
|
direction = 'transfer', is_category_confirmed = TRUE, updated_at = NOW()
|
||||||
|
WHERE account_id = $1 AND category_id IS NULL`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return toAccount(rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
backend/src/services/analytics.integration.test.ts
Normal file
46
backend/src/services/analytics.integration.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { pool } from '../db/pool';
|
||||||
|
import { getByCategory, getSummary, getTimeseries } from './analytics';
|
||||||
|
|
||||||
|
async function testQueries(): Promise<void> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const account = await client.query("INSERT INTO accounts (bank, account_number, currency, account_type) VALUES ('TEST', 'analytics-test-1', 'RUB', 'savings') RETURNING id");
|
||||||
|
const otherAccount = await client.query("INSERT INTO accounts (bank, account_number, currency) VALUES ('TEST', 'analytics-test-2', 'RUB') RETURNING id");
|
||||||
|
const categories = await client.query("INSERT INTO categories (name, type) VALUES ('Тест расход', 'expense'), ('Тест доход', 'income'), ('Тест перевод', 'transfer') RETURNING id, type");
|
||||||
|
const accountId = Number(account.rows[0].id);
|
||||||
|
const otherAccountId = Number(otherAccount.rows[0].id);
|
||||||
|
const categoryId = Object.fromEntries(categories.rows.map((row) => [row.type, Number(row.id)]));
|
||||||
|
const insert = (account: number, at: string, amount: number, category: number, fingerprint: string) => client.query(
|
||||||
|
"INSERT INTO transactions (account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed) VALUES ($1, $2, $3, 0, 'test', CASE WHEN $3 < 0 THEN 'expense' ELSE 'income' END, $4, $5, TRUE)",
|
||||||
|
[account, at, amount, fingerprint, category],
|
||||||
|
);
|
||||||
|
|
||||||
|
await insert(accountId, '2026-07-01T12:00:00+03:00', -10_000, categoryId.expense, 'analytics-test-1');
|
||||||
|
await insert(accountId, '2026-07-02T12:00:00+03:00', 4_000, categoryId.expense, 'analytics-test-2');
|
||||||
|
await insert(accountId, '2026-07-03T12:00:00+03:00', 20_000, categoryId.income, 'analytics-test-3');
|
||||||
|
await insert(accountId, '2026-07-04T12:00:00+03:00', -5_000, categoryId.income, 'analytics-test-4');
|
||||||
|
await insert(accountId, '2026-07-05T12:00:00+03:00', -3_000, categoryId.transfer, 'analytics-test-5');
|
||||||
|
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', 1_500, 0, 'Начисление процентов', 'transfer', 'analytics-test-interest', $2, TRUE)",
|
||||||
|
[accountId, categoryId.transfer],
|
||||||
|
);
|
||||||
|
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');
|
||||||
|
|
||||||
|
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.equal((await getSummary({ ...params, from: '2026-08-01', to: '2026-08-31' }, client)).totalExpense, 7_000);
|
||||||
|
assert.deepEqual((await getByCategory(params, client)).map((item) => item.amount), [6_000]);
|
||||||
|
const timeseries = await getTimeseries({ ...params, granularity: 'month' }, client);
|
||||||
|
assert.equal(timeseries[0].expenseAmount, 6_000);
|
||||||
|
assert.equal(timeseries[0].incomeAmount, 15_000);
|
||||||
|
} finally {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testQueries().then(() => console.log('analytics SQL: OK')).finally(() => pool.end());
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { pool } from '../db/pool';
|
import { pool } from '../db/pool';
|
||||||
|
import { effectiveAmountCase } from './analyticsSemantics';
|
||||||
import type {
|
import type {
|
||||||
AnalyticsSummaryResponse,
|
AnalyticsSummaryResponse,
|
||||||
TopCategory,
|
TopCategory,
|
||||||
@@ -7,6 +8,8 @@ import type {
|
|||||||
Granularity,
|
Granularity,
|
||||||
} from '@family-budget/shared';
|
} from '@family-budget/shared';
|
||||||
|
|
||||||
|
type Queryable = Pick<typeof pool, 'query'>;
|
||||||
|
|
||||||
interface BaseParams {
|
interface BaseParams {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
@@ -14,6 +17,29 @@ interface BaseParams {
|
|||||||
onlyConfirmed?: boolean;
|
onlyConfirmed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const effectiveAmount = effectiveAmountCase('t');
|
||||||
|
|
||||||
|
function analyticsTransactions(where: string): string {
|
||||||
|
return `
|
||||||
|
WITH analytics_transactions AS (
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.operation_at,
|
||||||
|
COALESCE(c.type, t.direction) AS analytic_type,
|
||||||
|
COALESCE(t.category_id, 0) AS category_id,
|
||||||
|
COALESCE(c.name, 'Без категории') AS category_name,
|
||||||
|
${effectiveAmount} AS effective_amount,
|
||||||
|
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
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN categories c ON c.id = t.category_id
|
||||||
|
LEFT JOIN accounts a ON a.id = t.account_id
|
||||||
|
${where}
|
||||||
|
)`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildBaseConditions(
|
function buildBaseConditions(
|
||||||
params: BaseParams,
|
params: BaseParams,
|
||||||
startIdx: number,
|
startIdx: number,
|
||||||
@@ -42,96 +68,48 @@ function buildBaseConditions(
|
|||||||
return { conditions, values, nextIdx: idx };
|
return { conditions, values, nextIdx: idx };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryResponse> {
|
export async function getSummary(
|
||||||
|
params: BaseParams,
|
||||||
|
db: Queryable = pool,
|
||||||
|
): Promise<AnalyticsSummaryResponse> {
|
||||||
const { conditions, values } = buildBaseConditions(params, 1);
|
const { conditions, values } = buildBaseConditions(params, 1);
|
||||||
const where = 'WHERE ' + conditions.join(' AND ');
|
const where = 'WHERE ' + conditions.join(' AND ');
|
||||||
|
|
||||||
const totalsResult = await pool.query(
|
const totalsResult = await db.query(
|
||||||
`WITH investment_ref AS (
|
`${analyticsTransactions(where)},
|
||||||
SELECT (
|
category_net AS (
|
||||||
SELECT id
|
SELECT category_id, category_name, analytic_type, SUM(effective_amount)::bigint AS amount,
|
||||||
FROM categories
|
SUM(interest_income)::bigint AS interest_income
|
||||||
WHERE name = 'Инвестиции' AND type = 'transfer'
|
FROM analytics_transactions
|
||||||
ORDER BY id ASC
|
GROUP BY category_id, category_name, analytic_type
|
||||||
LIMIT 1
|
|
||||||
) AS investment_category_id
|
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(SUM(
|
COALESCE(SUM(GREATEST(-amount, 0)) FILTER (WHERE analytic_type = 'expense'), 0)::bigint AS total_expense,
|
||||||
CASE
|
COALESCE(SUM(GREATEST(amount, 0)) FILTER (WHERE analytic_type = 'income'), 0)::bigint AS total_income,
|
||||||
WHEN (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
|
COALESCE((SELECT SUM(GREATEST(effective_amount, 0)) FROM analytics_transactions), 0)::bigint AS cash_inflow,
|
||||||
AND (
|
COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions), 0)::bigint AS cash_outflow,
|
||||||
ir.investment_category_id IS NULL
|
COALESCE((SELECT SUM(GREATEST(effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_inflow,
|
||||||
OR t.category_id IS DISTINCT FROM ir.investment_category_id
|
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
|
||||||
THEN ABS(t.amount_signed) + t.commission
|
FROM category_net`,
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
), 0)::bigint AS total_expense,
|
|
||||||
COALESCE(SUM(
|
|
||||||
CASE
|
|
||||||
WHEN t.direction = 'income'
|
|
||||||
AND (
|
|
||||||
ir.investment_category_id IS NULL
|
|
||||||
OR t.category_id IS DISTINCT FROM ir.investment_category_id
|
|
||||||
)
|
|
||||||
THEN t.amount_signed + t.commission
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
), 0)::bigint AS total_income,
|
|
||||||
COALESCE(SUM(
|
|
||||||
CASE
|
|
||||||
WHEN t.amount_signed < 0
|
|
||||||
AND ir.investment_category_id IS NOT NULL
|
|
||||||
AND t.category_id = ir.investment_category_id
|
|
||||||
THEN ABS(t.amount_signed) + t.commission
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
), 0)::bigint AS investment_outflow,
|
|
||||||
COALESCE(SUM(
|
|
||||||
CASE
|
|
||||||
WHEN t.amount_signed > 0
|
|
||||||
AND ir.investment_category_id IS NOT NULL
|
|
||||||
AND t.category_id = ir.investment_category_id
|
|
||||||
THEN t.amount_signed + t.commission
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
), 0)::bigint AS investment_income_excluded
|
|
||||||
FROM transactions t
|
|
||||||
CROSS JOIN investment_ref ir
|
|
||||||
${where}`,
|
|
||||||
values,
|
values,
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalExpense = Number(totalsResult.rows[0].total_expense);
|
const totalExpense = Number(totalsResult.rows[0].total_expense);
|
||||||
const totalIncome = Number(totalsResult.rows[0].total_income);
|
const totalIncome = Number(totalsResult.rows[0].total_income);
|
||||||
const investmentOutflow = Number(totalsResult.rows[0].investment_outflow);
|
const cashInflow = Number(totalsResult.rows[0].cash_inflow);
|
||||||
const investmentIncomeExcluded = Number(totalsResult.rows[0].investment_income_excluded);
|
const cashOutflow = Number(totalsResult.rows[0].cash_outflow);
|
||||||
|
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 topResult = await pool.query(
|
const topResult = await db.query(
|
||||||
`WITH investment_ref AS (
|
`${analyticsTransactions(where)}
|
||||||
SELECT (
|
SELECT category_id::bigint, category_name, GREATEST(-SUM(effective_amount), 0)::bigint AS amount
|
||||||
SELECT id
|
FROM analytics_transactions
|
||||||
FROM categories
|
WHERE analytic_type = 'expense'
|
||||||
WHERE name = 'Инвестиции' AND type = 'transfer'
|
GROUP BY category_id, category_name
|
||||||
ORDER BY id ASC
|
HAVING SUM(effective_amount) < 0
|
||||||
LIMIT 1
|
|
||||||
) AS investment_category_id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
COALESCE(t.category_id, 0)::bigint AS category_id,
|
|
||||||
COALESCE(c.name, 'Без категории') AS category_name,
|
|
||||||
SUM(ABS(t.amount_signed) + t.commission)::bigint AS amount
|
|
||||||
FROM transactions t
|
|
||||||
CROSS JOIN investment_ref ir
|
|
||||||
LEFT JOIN categories c ON c.id = t.category_id
|
|
||||||
${where}
|
|
||||||
AND (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
|
|
||||||
AND (
|
|
||||||
ir.investment_category_id IS NULL
|
|
||||||
OR t.category_id IS DISTINCT FROM ir.investment_category_id
|
|
||||||
)
|
|
||||||
GROUP BY COALESCE(t.category_id, 0), COALESCE(c.name, 'Без категории')
|
|
||||||
ORDER BY amount DESC
|
ORDER BY amount DESC
|
||||||
LIMIT 5`,
|
LIMIT 5`,
|
||||||
values,
|
values,
|
||||||
@@ -148,68 +126,40 @@ export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryRe
|
|||||||
totalExpense,
|
totalExpense,
|
||||||
totalIncome,
|
totalIncome,
|
||||||
net: totalIncome - totalExpense,
|
net: totalIncome - totalExpense,
|
||||||
investmentOutflow,
|
cashInflow,
|
||||||
investmentIncomeExcluded,
|
cashOutflow,
|
||||||
|
transferInflow,
|
||||||
|
transferOutflow,
|
||||||
|
cashNet: cashInflow - cashOutflow,
|
||||||
|
interestIncome,
|
||||||
topCategories,
|
topCategories,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getByCategory(params: BaseParams): Promise<ByCategoryItem[]> {
|
export async function getByCategory(
|
||||||
|
params: BaseParams,
|
||||||
|
db: Queryable = pool,
|
||||||
|
): Promise<ByCategoryItem[]> {
|
||||||
const { conditions, values } = buildBaseConditions(params, 1);
|
const { conditions, values } = buildBaseConditions(params, 1);
|
||||||
const where = 'WHERE ' + conditions.join(' AND ');
|
const where = 'WHERE ' + conditions.join(' AND ');
|
||||||
|
|
||||||
const totalResult = await pool.query(
|
const { rows } = await db.query(
|
||||||
`WITH investment_ref AS (
|
`${analyticsTransactions(where)}
|
||||||
SELECT (
|
|
||||||
SELECT id
|
|
||||||
FROM categories
|
|
||||||
WHERE name = 'Инвестиции' AND type = 'transfer'
|
|
||||||
ORDER BY id ASC
|
|
||||||
LIMIT 1
|
|
||||||
) AS investment_category_id
|
|
||||||
)
|
|
||||||
SELECT COALESCE(SUM(ABS(t.amount_signed) + t.commission), 0)::bigint AS total
|
|
||||||
FROM transactions t
|
|
||||||
CROSS JOIN investment_ref ir
|
|
||||||
${where}
|
|
||||||
AND (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
|
|
||||||
AND (
|
|
||||||
ir.investment_category_id IS NULL
|
|
||||||
OR t.category_id IS DISTINCT FROM ir.investment_category_id
|
|
||||||
)`,
|
|
||||||
values,
|
|
||||||
);
|
|
||||||
const total = Number(totalResult.rows[0].total);
|
|
||||||
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`WITH investment_ref AS (
|
|
||||||
SELECT (
|
|
||||||
SELECT id
|
|
||||||
FROM categories
|
|
||||||
WHERE name = 'Инвестиции' AND type = 'transfer'
|
|
||||||
ORDER BY id ASC
|
|
||||||
LIMIT 1
|
|
||||||
) AS investment_category_id
|
|
||||||
)
|
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(t.category_id, 0)::bigint AS category_id,
|
category_id::bigint,
|
||||||
COALESCE(c.name, 'Без категории') AS category_name,
|
category_name,
|
||||||
SUM(ABS(t.amount_signed) + t.commission)::bigint AS amount,
|
GREATEST(-SUM(effective_amount), 0)::bigint AS amount,
|
||||||
COUNT(*)::int AS tx_count
|
COUNT(*)::int AS tx_count
|
||||||
FROM transactions t
|
FROM analytics_transactions
|
||||||
CROSS JOIN investment_ref ir
|
WHERE analytic_type = 'expense'
|
||||||
LEFT JOIN categories c ON c.id = t.category_id
|
GROUP BY category_id, category_name
|
||||||
${where}
|
HAVING SUM(effective_amount) < 0
|
||||||
AND (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
|
|
||||||
AND (
|
|
||||||
ir.investment_category_id IS NULL
|
|
||||||
OR t.category_id IS DISTINCT FROM ir.investment_category_id
|
|
||||||
)
|
|
||||||
GROUP BY COALESCE(t.category_id, 0), COALESCE(c.name, 'Без категории')
|
|
||||||
ORDER BY amount DESC`,
|
ORDER BY amount DESC`,
|
||||||
values,
|
values,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const total = rows.reduce((sum, row) => sum + Number(row.amount), 0);
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
categoryId: r.category_id != null ? Number(r.category_id) : 0,
|
categoryId: r.category_id != null ? Number(r.category_id) : 0,
|
||||||
categoryName: r.category_name ?? 'Без категории',
|
categoryName: r.category_name ?? 'Без категории',
|
||||||
@@ -221,6 +171,7 @@ export async function getByCategory(params: BaseParams): Promise<ByCategoryItem[
|
|||||||
|
|
||||||
export async function getTimeseries(
|
export async function getTimeseries(
|
||||||
params: BaseParams & { categoryId?: number; granularity: Granularity },
|
params: BaseParams & { categoryId?: number; granularity: Granularity },
|
||||||
|
db: Queryable = pool,
|
||||||
): Promise<TimeseriesItem[]> {
|
): Promise<TimeseriesItem[]> {
|
||||||
let truncExpr: string;
|
let truncExpr: string;
|
||||||
let intervalStr: string;
|
let intervalStr: string;
|
||||||
@@ -265,61 +216,38 @@ export async function getTimeseries(
|
|||||||
|
|
||||||
const txWhere = txConditions.join(' AND ');
|
const txWhere = txConditions.join(' AND ');
|
||||||
|
|
||||||
const { rows } = await pool.query(
|
const { rows } = await db.query(
|
||||||
`WITH periods AS (
|
`WITH periods AS (
|
||||||
SELECT
|
SELECT
|
||||||
gs::date AS period_start,
|
gs::date AS period_start,
|
||||||
${periodEndExpr} AS period_end
|
${periodEndExpr} AS period_end
|
||||||
FROM generate_series(${truncExpr}, $2::date, '${intervalStr}'::interval) gs
|
FROM generate_series(${truncExpr}, $2::date, '${intervalStr}'::interval) gs
|
||||||
),
|
),
|
||||||
investment_ref AS (
|
period_transactions AS (
|
||||||
SELECT (
|
SELECT
|
||||||
SELECT id
|
p.period_start,
|
||||||
FROM categories
|
p.period_end,
|
||||||
WHERE name = 'Инвестиции' AND type = 'transfer'
|
COALESCE(c.type, t.direction) AS analytic_type,
|
||||||
ORDER BY id ASC
|
COALESCE(t.category_id, 0) AS category_id,
|
||||||
LIMIT 1
|
${effectiveAmount} AS effective_amount
|
||||||
) AS investment_category_id
|
FROM periods p
|
||||||
|
LEFT JOIN transactions t ON ${txWhere}
|
||||||
|
LEFT JOIN categories c ON c.id = t.category_id
|
||||||
|
),
|
||||||
|
period_category_net AS (
|
||||||
|
SELECT period_start, period_end, analytic_type, category_id, SUM(effective_amount)::bigint AS amount
|
||||||
|
FROM period_transactions
|
||||||
|
GROUP BY period_start, period_end, analytic_type, category_id
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
p.period_start,
|
period_start,
|
||||||
p.period_end,
|
period_end,
|
||||||
COALESCE(SUM(
|
COALESCE(SUM(GREATEST(-amount, 0)) FILTER (WHERE analytic_type = 'expense'), 0)::bigint AS expense_amount,
|
||||||
CASE
|
COALESCE(SUM(GREATEST(amount, 0)) FILTER (WHERE analytic_type = 'income'), 0)::bigint AS income_amount,
|
||||||
WHEN (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
|
COALESCE(SUM(GREATEST(-amount, 0)) FILTER (WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_outflow
|
||||||
AND (
|
FROM period_category_net
|
||||||
ir.investment_category_id IS NULL
|
GROUP BY period_start, period_end
|
||||||
OR t.category_id IS DISTINCT FROM ir.investment_category_id
|
ORDER BY period_start`,
|
||||||
)
|
|
||||||
THEN ABS(t.amount_signed) + t.commission
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
), 0)::bigint AS expense_amount,
|
|
||||||
COALESCE(SUM(
|
|
||||||
CASE
|
|
||||||
WHEN t.direction = 'income'
|
|
||||||
AND (
|
|
||||||
ir.investment_category_id IS NULL
|
|
||||||
OR t.category_id IS DISTINCT FROM ir.investment_category_id
|
|
||||||
)
|
|
||||||
THEN t.amount_signed + t.commission
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
), 0)::bigint AS income_amount,
|
|
||||||
COALESCE(SUM(
|
|
||||||
CASE
|
|
||||||
WHEN t.amount_signed < 0
|
|
||||||
AND ir.investment_category_id IS NOT NULL
|
|
||||||
AND t.category_id = ir.investment_category_id
|
|
||||||
THEN ABS(t.amount_signed) + t.commission
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
), 0)::bigint AS investment_outflow
|
|
||||||
FROM periods p
|
|
||||||
CROSS JOIN investment_ref ir
|
|
||||||
LEFT JOIN transactions t ON ${txWhere}
|
|
||||||
GROUP BY p.period_start, p.period_end
|
|
||||||
ORDER BY p.period_start`,
|
|
||||||
values,
|
values,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -328,6 +256,6 @@ export async function getTimeseries(
|
|||||||
periodEnd: r.period_end.toISOString().slice(0, 10),
|
periodEnd: r.period_end.toISOString().slice(0, 10),
|
||||||
expenseAmount: Number(r.expense_amount),
|
expenseAmount: Number(r.expense_amount),
|
||||||
incomeAmount: Number(r.income_amount),
|
incomeAmount: Number(r.income_amount),
|
||||||
investmentOutflow: Number(r.investment_outflow),
|
transferOutflow: Number(r.transfer_outflow),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
25
backend/src/services/analyticsSemantics.test.ts
Normal file
25
backend/src/services/analyticsSemantics.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { getEffectiveAmount, getNetAmount } from './analyticsSemantics';
|
||||||
|
|
||||||
|
assert.equal(getNetAmount('expense', [
|
||||||
|
{ amountSigned: -10_000, commission: 0 },
|
||||||
|
{ amountSigned: 4_000, commission: 0 },
|
||||||
|
]), 6_000, 'возврат уменьшает расход своей категории');
|
||||||
|
|
||||||
|
assert.equal(getNetAmount('income', [
|
||||||
|
{ amountSigned: 20_000, commission: 0 },
|
||||||
|
{ amountSigned: -5_000, commission: 0 },
|
||||||
|
]), 15_000, 'корректировка уменьшает доход');
|
||||||
|
|
||||||
|
assert.equal(getNetAmount('transfer', [
|
||||||
|
{ amountSigned: -10_000, commission: 0 },
|
||||||
|
{ amountSigned: 10_000, commission: 0 },
|
||||||
|
]), 0, 'перевод не является доходом или расходом');
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
getEffectiveAmount({ amountSigned: 0, commission: 500, description: 'Зачисление кэшбэка' }),
|
||||||
|
500,
|
||||||
|
'кэшбэк учитывается как поступление',
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('analytics semantics: OK');
|
||||||
32
backend/src/services/analyticsSemantics.ts
Normal file
32
backend/src/services/analyticsSemantics.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export type AnalyticType = 'expense' | 'income' | 'transfer';
|
||||||
|
|
||||||
|
interface AmountInput {
|
||||||
|
amountSigned: number;
|
||||||
|
commission: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEffectiveAmount({ amountSigned, commission, description = '' }: AmountInput): number {
|
||||||
|
if (amountSigned === 0 && commission > 0 && description.toLowerCase().includes('зачисление')) {
|
||||||
|
return commission;
|
||||||
|
}
|
||||||
|
return amountSigned < 0 ? amountSigned - commission : amountSigned + commission;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNetAmount(type: AnalyticType, amounts: AmountInput[]): number {
|
||||||
|
const amount = amounts.reduce((sum, item) => sum + getEffectiveAmount(item), 0);
|
||||||
|
if (type === 'expense') return Math.max(-amount, 0);
|
||||||
|
if (type === 'income') return Math.max(amount, 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveAmountCase(alias: string): string {
|
||||||
|
return `
|
||||||
|
CASE
|
||||||
|
WHEN ${alias}.amount_signed = 0 AND ${alias}.commission > 0
|
||||||
|
AND ${alias}.description ILIKE '%зачисление%'
|
||||||
|
THEN ${alias}.commission
|
||||||
|
WHEN ${alias}.amount_signed < 0 THEN ${alias}.amount_signed - ${alias}.commission
|
||||||
|
ELSE ${alias}.amount_signed + ${alias}.commission
|
||||||
|
END`;
|
||||||
|
}
|
||||||
28
backend/src/services/import.integration.test.ts
Normal file
28
backend/src/services/import.integration.test.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { pool } from '../db/pool';
|
||||||
|
import { importStatement } from './import';
|
||||||
|
|
||||||
|
const makeStatement = (sourceId: string) => ({
|
||||||
|
schemaVersion: '1.0', bank: 'TEST',
|
||||||
|
statement: { accountNumber: 'metadata-test', currency: 'RUB', openingBalance: 0, closingBalance: 100, exportedAt: '2026-08-20T12:00:00+03:00' },
|
||||||
|
transactions: [{ operationAt: '2026-08-20T10:00:00+03:00', amountSigned: 100, commission: 0, description: 'Пополнение', sourceId }],
|
||||||
|
});
|
||||||
|
|
||||||
|
async function run(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await importStatement(makeStatement('first'));
|
||||||
|
const account = await pool.query("UPDATE accounts SET account_type = 'savings', status = 'closed' WHERE bank = 'TEST' AND account_number = 'metadata-test' RETURNING id");
|
||||||
|
assert.equal(account.rows.length, 1);
|
||||||
|
await importStatement(makeStatement('second'));
|
||||||
|
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' });
|
||||||
|
console.log('import metadata SQL: OK');
|
||||||
|
} 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 imports WHERE account_id IN (SELECT id FROM accounts WHERE bank = 'TEST' AND account_number = 'metadata-test')");
|
||||||
|
await pool.query("DELETE FROM accounts WHERE bank = 'TEST' AND account_number = 'metadata-test'");
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
@@ -9,11 +9,17 @@ 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;
|
||||||
|
|
||||||
function computeFingerprint(
|
function computeFingerprint(
|
||||||
accountNumber: string,
|
accountNumber: string,
|
||||||
tx: { operationAt: string; amountSigned: number; commission: number; description: string },
|
tx: { operationAt: string; amountSigned: number; commission: number; description: string; sourceId?: string },
|
||||||
): string {
|
): string {
|
||||||
|
if (tx.sourceId) {
|
||||||
|
const raw = [accountNumber, tx.sourceId.trim()].join('|');
|
||||||
|
const hash = crypto.createHash('sha256').update(raw, 'utf-8').digest('hex');
|
||||||
|
return `sha256:${hash}`;
|
||||||
|
}
|
||||||
const raw = [
|
const raw = [
|
||||||
accountNumber,
|
accountNumber,
|
||||||
tx.operationAt,
|
tx.operationAt,
|
||||||
@@ -91,6 +97,12 @@ function validateStructure(body: unknown): ValidationError | null {
|
|||||||
if (typeof t.description !== 'string' || !t.description) {
|
if (typeof t.description !== 'string' || !t.description) {
|
||||||
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].description must be a non-empty string` };
|
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].description must be a non-empty string` };
|
||||||
}
|
}
|
||||||
|
if (t.sourceId !== undefined && (typeof t.sourceId !== 'string' || !t.sourceId.trim())) {
|
||||||
|
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].sourceId must be a non-empty string when provided` };
|
||||||
|
}
|
||||||
|
if (t.operationId !== undefined && (typeof t.operationId !== 'string' || !UUID_RE.test(t.operationId))) {
|
||||||
|
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].operationId must be a valid UUID when provided` };
|
||||||
|
}
|
||||||
if (typeof t.amountSigned !== 'number' || !Number.isInteger(t.amountSigned)) {
|
if (typeof t.amountSigned !== 'number' || !Number.isInteger(t.amountSigned)) {
|
||||||
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].amountSigned must be an integer` };
|
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].amountSigned must be an integer` };
|
||||||
}
|
}
|
||||||
@@ -121,12 +133,20 @@ function validateSemantics(data: StatementFile): ValidationError | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fps = new Set<string>();
|
const fps = 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 (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);
|
||||||
|
const operationId = data.transactions[i].operationId;
|
||||||
|
if (operationId) {
|
||||||
|
if (operationIds.has(operationId.toLowerCase())) {
|
||||||
|
return { status: 422, error: 'VALIDATION_ERROR', message: `Duplicate operationId found within file at transaction index ${i}` };
|
||||||
|
}
|
||||||
|
operationIds.add(operationId.toLowerCase());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -134,6 +154,7 @@ function validateSemantics(data: StatementFile): ValidationError | null {
|
|||||||
|
|
||||||
export async function importStatement(
|
export async function importStatement(
|
||||||
body: unknown,
|
body: unknown,
|
||||||
|
db: Pick<typeof pool, 'connect'> = pool,
|
||||||
): Promise<ImportStatementResponse | ValidationError> {
|
): Promise<ImportStatementResponse | ValidationError> {
|
||||||
const structErr = validateStructure(body);
|
const structErr = validateStructure(body);
|
||||||
if (structErr) return structErr;
|
if (structErr) return structErr;
|
||||||
@@ -142,7 +163,7 @@ export async function importStatement(
|
|||||||
const semErr = validateSemantics(data);
|
const semErr = validateSemantics(data);
|
||||||
if (semErr) return semErr;
|
if (semErr) return semErr;
|
||||||
|
|
||||||
const client = await pool.connect();
|
const client = await db.connect();
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
@@ -151,7 +172,7 @@ export async function importStatement(
|
|||||||
let isNewAccount = false;
|
let isNewAccount = false;
|
||||||
|
|
||||||
const accResult = await client.query(
|
const accResult = await client.query(
|
||||||
'SELECT id FROM accounts WHERE bank = $1 AND account_number = $2',
|
'SELECT id, account_type FROM accounts WHERE bank = $1 AND account_number = $2',
|
||||||
[data.bank, data.statement.accountNumber],
|
[data.bank, data.statement.accountNumber],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -186,6 +207,14 @@ export async function importStatement(
|
|||||||
throw new Error("Category 'Поступления' is missing");
|
throw new Error("Category 'Поступления' is missing");
|
||||||
}
|
}
|
||||||
const incomeCategoryId = Number(incomeCategoryResult.rows[0].id);
|
const incomeCategoryId = Number(incomeCategoryResult.rows[0].id);
|
||||||
|
const accountType = accResult.rows[0]?.account_type ?? null;
|
||||||
|
const investmentCategoryResult = await client.query(
|
||||||
|
`SELECT id FROM categories WHERE name = 'Инвестиции' AND type = 'transfer' AND is_active = TRUE LIMIT 1`,
|
||||||
|
);
|
||||||
|
const investmentCategoryId = investmentCategoryResult.rows[0]
|
||||||
|
? Number(investmentCategoryResult.rows[0].id)
|
||||||
|
: null;
|
||||||
|
const isInvestmentAccount = ['brokerage', 'iis', 'savings'].includes(accountType);
|
||||||
|
|
||||||
// Insert transactions
|
// Insert transactions
|
||||||
const insertedIds: number[] = [];
|
const insertedIds: number[] = [];
|
||||||
@@ -198,17 +227,19 @@ export async function importStatement(
|
|||||||
tx.description.toLowerCase().includes(CASHBACK_KEYWORD);
|
tx.description.toLowerCase().includes(CASHBACK_KEYWORD);
|
||||||
const dir = isCashbackCommissionImport
|
const dir = isCashbackCommissionImport
|
||||||
? 'income'
|
? 'income'
|
||||||
: determineDirection(tx.amountSigned, tx.description);
|
: isInvestmentAccount ? 'transfer' : determineDirection(tx.amountSigned, tx.description);
|
||||||
const categoryId = isCashbackCommissionImport ? incomeCategoryId : null;
|
const categoryId = isCashbackCommissionImport
|
||||||
|
? incomeCategoryId
|
||||||
|
: isInvestmentAccount ? investmentCategoryId : null;
|
||||||
const isCategoryConfirmed = isCashbackCommissionImport;
|
const isCategoryConfirmed = isCashbackCommissionImport;
|
||||||
|
|
||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
`INSERT INTO transactions
|
`INSERT INTO transactions
|
||||||
(account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed, import_id)
|
(account_id, operation_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed, import_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
VALUES ($1, COALESCE($2::uuid, gen_random_uuid()), $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
ON CONFLICT (account_id, fingerprint) DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[accountId, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, categoryId, isCategoryConfirmed, importId],
|
[accountId, tx.operationId ?? null, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, categoryId, isCategoryConfirmed, importId],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.rows.length > 0) {
|
if (result.rows.length > 0) {
|
||||||
@@ -243,11 +274,19 @@ export async function importStatement(
|
|||||||
OR (cr.match_type = 'starts_with' AND t2.description ILIKE cr.pattern || '%')
|
OR (cr.match_type = 'starts_with' AND t2.description ILIKE cr.pattern || '%')
|
||||||
)
|
)
|
||||||
WHERE t2.id = ANY($1::bigint[])
|
WHERE t2.id = ANY($1::bigint[])
|
||||||
|
AND t2.is_category_confirmed = FALSE
|
||||||
ORDER BY t2.id, cr.priority DESC, cr.id ASC
|
ORDER BY t2.id, cr.priority DESC, cr.id ASC
|
||||||
) sub
|
) sub
|
||||||
WHERE t.id = sub.tx_id`,
|
WHERE t.id = sub.tx_id`,
|
||||||
[insertedIds],
|
[insertedIds],
|
||||||
);
|
);
|
||||||
|
if (isInvestmentAccount && investmentCategoryId != null) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE transactions SET is_category_confirmed = TRUE, updated_at = NOW()
|
||||||
|
WHERE id = ANY($1::bigint[]) AND category_id = $2 AND is_category_confirmed = FALSE`,
|
||||||
|
[insertedIds, investmentCategoryId],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
|
|||||||
@@ -7,13 +7,8 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.backend
|
dockerfile: Dockerfile.backend
|
||||||
container_name: family-budget-backend
|
container_name: family-budget-backend
|
||||||
environment:
|
env_file:
|
||||||
# Имя контейнера/сервиса PostgreSQL — postgres или postgres_budget
|
- ./backend/.env
|
||||||
- DB_HOST=postgres_budget
|
|
||||||
- DB_PORT=5432
|
|
||||||
- DB_NAME=family_budget
|
|
||||||
- DB_USER=budget_user
|
|
||||||
- DB_PASSWORD=difficult_Paaaaaasword
|
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -37,6 +37,26 @@
|
|||||||
- Чистый результат (`net = totalIncome - totalExpense`).
|
- Чистый результат (`net = totalIncome - totalExpense`).
|
||||||
- Топ-3–5 категорий по расходам (сумма, доля).
|
- Топ-3–5 категорий по расходам (сумма, доля).
|
||||||
|
|
||||||
|
#### Нетто-правила
|
||||||
|
|
||||||
|
Тип назначенной категории определяет экономический смысл операции:
|
||||||
|
|
||||||
|
- `expense`: отрицательная сумма увеличивает расход, положительная сумма
|
||||||
|
(возврат) уменьшает его; в отчёте категория не может стать отрицательным
|
||||||
|
расходом;
|
||||||
|
- `income`: положительная сумма увеличивает доход, отрицательная уменьшает
|
||||||
|
его;
|
||||||
|
- `transfer`: не учитывается в реальных доходах и расходах.
|
||||||
|
|
||||||
|
Если возврат не удалось определить правилом, пользователь вручную назначает
|
||||||
|
ему ту же расходную категорию, что у исходной покупки. Отдельная категория
|
||||||
|
«Возврат» и связывание двух операций не требуются.
|
||||||
|
|
||||||
|
Для контроля остатков сводка отдельно показывает движение денежных средств:
|
||||||
|
входящий и исходящий поток, внутренние переводы и чистое изменение денег.
|
||||||
|
Переводы между картой, накопительными и инвестиционными счетами остаются в
|
||||||
|
этом блоке, но не искажают доходы и расходы.
|
||||||
|
|
||||||
Реализуется через эндпоинт `GET /api/analytics/summary`.
|
Реализуется через эндпоинт `GET /api/analytics/summary`.
|
||||||
|
|
||||||
Параметры:
|
Параметры:
|
||||||
@@ -52,6 +72,11 @@
|
|||||||
"totalExpense": 12345600,
|
"totalExpense": 12345600,
|
||||||
"totalIncome": 20000000,
|
"totalIncome": 20000000,
|
||||||
"net": 7654400,
|
"net": 7654400,
|
||||||
|
"cashInflow": 26000000,
|
||||||
|
"cashOutflow": 18345600,
|
||||||
|
"transferInflow": 5000000,
|
||||||
|
"transferOutflow": 5000000,
|
||||||
|
"cashNet": 7654400,
|
||||||
"topCategories": [
|
"topCategories": [
|
||||||
{ "categoryId": 1, "categoryName": "Продукты", "amount": 4500000, "share": 0.36 },
|
{ "categoryId": 1, "categoryName": "Продукты", "amount": 4500000, "share": 0.36 },
|
||||||
{ "categoryId": 2, "categoryName": "ЖКХ", "amount": 2500000, "share": 0.20 }
|
{ "categoryId": 2, "categoryName": "ЖКХ", "amount": 2500000, "share": 0.20 }
|
||||||
|
|||||||
@@ -141,6 +141,12 @@ accountNumber|operationAt|amountSigned|commission|normalizedDescription
|
|||||||
|
|
||||||
Список ключевых фраз для `"transfer"` может расширяться; в MVP используется фиксированный набор.
|
Список ключевых фраз для `"transfer"` может расширяться; в MVP используется фиксированный набор.
|
||||||
|
|
||||||
|
`direction` — исходная банковская классификация. В аналитике окончательный
|
||||||
|
экономический тип определяется типом назначенной категории: операция,
|
||||||
|
отнесённая к расходной категории, является расходом или возвратом независимо
|
||||||
|
от исходного `direction`; категория типа `transfer` исключает операцию из
|
||||||
|
реальных доходов и расходов.
|
||||||
|
|
||||||
### Импорт транзакций
|
### Импорт транзакций
|
||||||
|
|
||||||
Для каждой транзакции из массива `transactions`:
|
Для каждой транзакции из массива `transactions`:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/frontend",
|
"name": "@family-budget/frontend",
|
||||||
"version": "0.8.6",
|
"version": "0.10.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export function AccountsList() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [editAlias, setEditAlias] = useState('');
|
const [editAlias, setEditAlias] = useState('');
|
||||||
|
const [editAccountType, setEditAccountType] = useState<Account['accountType']>(null);
|
||||||
|
const [editStatus, setEditStatus] = useState<Account['status']>('active');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -19,12 +21,16 @@ export function AccountsList() {
|
|||||||
const handleEdit = (account: Account) => {
|
const handleEdit = (account: Account) => {
|
||||||
setEditingId(account.id);
|
setEditingId(account.id);
|
||||||
setEditAlias(account.alias || '');
|
setEditAlias(account.alias || '');
|
||||||
|
setEditAccountType(account.accountType);
|
||||||
|
setEditStatus(account.status);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async (id: number) => {
|
const handleSave = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
const updated = await updateAccount(id, {
|
const updated = await updateAccount(id, {
|
||||||
alias: editAlias.trim(),
|
alias: editAlias.trim(),
|
||||||
|
accountType: editAccountType,
|
||||||
|
status: editStatus,
|
||||||
});
|
});
|
||||||
setAccounts((prev) =>
|
setAccounts((prev) =>
|
||||||
prev.map((a) => (a.id === id ? updated : a)),
|
prev.map((a) => (a.id === id ? updated : a)),
|
||||||
@@ -42,22 +48,24 @@ export function AccountsList() {
|
|||||||
return (
|
return (
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
<thead>
|
<thead className="data-table__head">
|
||||||
<tr>
|
<tr className="data-table__row">
|
||||||
<th>Банк</th>
|
<th className="data-table__head-cell">Банк</th>
|
||||||
<th>Номер счёта</th>
|
<th className="data-table__head-cell">Номер счёта</th>
|
||||||
<th>Валюта</th>
|
<th className="data-table__head-cell">Валюта</th>
|
||||||
<th>Алиас</th>
|
<th className="data-table__head-cell">Алиас</th>
|
||||||
<th></th>
|
<th className="data-table__head-cell">Тип</th>
|
||||||
|
<th className="data-table__head-cell">Статус</th>
|
||||||
|
<th className="data-table__head-cell"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="data-table__body">
|
||||||
{accounts.map((a) => (
|
{accounts.map((a) => (
|
||||||
<tr key={a.id}>
|
<tr className="data-table__row" key={a.id}>
|
||||||
<td>{a.bank}</td>
|
<td className="data-table__cell">{a.bank}</td>
|
||||||
<td>{a.accountNumberMasked}</td>
|
<td className="data-table__cell">{a.accountNumberMasked}</td>
|
||||||
<td>{a.currency}</td>
|
<td className="data-table__cell">{a.currency}</td>
|
||||||
<td>
|
<td className="data-table__cell">
|
||||||
{editingId === a.id ? (
|
{editingId === a.id ? (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -72,11 +80,30 @@ export function AccountsList() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
a.alias || (
|
a.alias || (
|
||||||
<span className="muted-text">не задан</span>
|
<span className="text text--muted">не задан</span>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="data-table__cell">
|
||||||
|
{editingId === a.id ? (
|
||||||
|
<select value={editAccountType ?? ''} onChange={(e) => setEditAccountType((e.target.value || null) as Account['accountType'])}>
|
||||||
|
<option value="">Не указан</option>
|
||||||
|
<option value="brokerage">Брокерский</option>
|
||||||
|
<option value="iis">ИИС</option>
|
||||||
|
<option value="savings">Накопительный</option>
|
||||||
|
<option value="current">Текущий</option>
|
||||||
|
</select>
|
||||||
|
) : (({ brokerage: 'Брокерский', iis: 'ИИС', savings: 'Накопительный', current: 'Текущий' } as Record<string, string>)[a.accountType ?? ''] || 'не указан')}
|
||||||
|
</td>
|
||||||
|
<td className="data-table__cell">
|
||||||
|
{editingId === a.id ? (
|
||||||
|
<select value={editStatus} onChange={(e) => setEditStatus(e.target.value as Account['status'])}>
|
||||||
|
<option value="active">Действующий</option>
|
||||||
|
<option value="closed">Закрытый</option>
|
||||||
|
</select>
|
||||||
|
) : (a.status === 'closed' ? 'Закрытый' : 'Действующий')}
|
||||||
|
</td>
|
||||||
|
<td className="data-table__cell">
|
||||||
{editingId === a.id ? (
|
{editingId === a.id ? (
|
||||||
<div className="button-group">
|
<div className="button-group">
|
||||||
<button
|
<button
|
||||||
@@ -104,8 +131,8 @@ export function AccountsList() {
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{accounts.length === 0 && (
|
{accounts.length === 0 && (
|
||||||
<tr>
|
<tr className="data-table__row">
|
||||||
<td colSpan={5} className="data-table__cell data-table__cell--center muted-text">
|
<td colSpan={7} className="data-table__cell data-table__cell--center text text--muted">
|
||||||
Нет счетов. Импортируйте выписку.
|
Нет счетов. Импортируйте выписку.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -27,17 +27,17 @@ export function CategoriesList() {
|
|||||||
return (
|
return (
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
<thead>
|
<thead className="data-table__head">
|
||||||
<tr>
|
<tr className="data-table__row">
|
||||||
<th>Категория</th>
|
<th className="data-table__head-cell">Категория</th>
|
||||||
<th>Тип</th>
|
<th className="data-table__head-cell">Тип</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="data-table__body">
|
||||||
{categories.map((c) => (
|
{categories.map((c) => (
|
||||||
<tr key={c.id}>
|
<tr className="data-table__row" key={c.id}>
|
||||||
<td>{c.name}</td>
|
<td className="data-table__cell">{c.name}</td>
|
||||||
<td>
|
<td className="data-table__cell">
|
||||||
<span className={`badge badge--${c.type}`}>
|
<span className={`badge badge--${c.type}`}>
|
||||||
{TYPE_LABELS[c.type] ?? c.type}
|
{TYPE_LABELS[c.type] ?? c.type}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -14,9 +14,14 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
'#2563eb', '#e85d3f', '#0f9f7f', '#d89b17', '#7c5cdb',
|
'var(--color-chart-1)', 'var(--color-chart-2)',
|
||||||
'#c8558f', '#1495a3', '#79a92f', '#e06d2f', '#4f6fd7',
|
'var(--color-chart-3)', 'var(--color-chart-4)',
|
||||||
'#1c9a8a', '#d43d5c', '#277bbd', '#9b59b6', '#2f9f63',
|
'var(--color-chart-5)', 'var(--color-chart-6)',
|
||||||
|
'var(--color-chart-7)', 'var(--color-chart-8)',
|
||||||
|
'var(--color-chart-9)', 'var(--color-chart-10)',
|
||||||
|
'var(--color-chart-11)', 'var(--color-chart-12)',
|
||||||
|
'var(--color-chart-13)', 'var(--color-chart-14)',
|
||||||
|
'var(--color-chart-15)',
|
||||||
];
|
];
|
||||||
|
|
||||||
const rubFormatter = new Intl.NumberFormat('ru-RU', {
|
const rubFormatter = new Intl.NumberFormat('ru-RU', {
|
||||||
@@ -70,18 +75,18 @@ export function CategoryChart({ data }: Props) {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|
||||||
<table className="category-chart__table">
|
<table className="category-chart__table">
|
||||||
<thead>
|
<thead className="category-chart__head">
|
||||||
<tr>
|
<tr className="category-chart__row">
|
||||||
<th>Категория</th>
|
<th className="category-chart__head-cell">Категория</th>
|
||||||
<th>Сумма</th>
|
<th className="category-chart__head-cell">Сумма</th>
|
||||||
<th className="category-chart__cell category-chart__cell--center">Операций</th>
|
<th className="category-chart__head-cell category-chart__head-cell--center">Операций</th>
|
||||||
<th className="category-chart__cell category-chart__cell--center">Доля</th>
|
<th className="category-chart__head-cell category-chart__head-cell--center">Доля</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="category-chart__body">
|
||||||
{data.map((item, idx) => (
|
{data.map((item, idx) => (
|
||||||
<tr key={item.categoryId}>
|
<tr className="category-chart__row" key={item.categoryId}>
|
||||||
<td>
|
<td className="category-chart__cell">
|
||||||
<span
|
<span
|
||||||
className="category-chart__dot"
|
className="category-chart__dot"
|
||||||
style={{
|
style={{
|
||||||
@@ -91,7 +96,7 @@ export function CategoryChart({ data }: Props) {
|
|||||||
/>
|
/>
|
||||||
{item.categoryName}
|
{item.categoryName}
|
||||||
</td>
|
</td>
|
||||||
<td>{formatAmount(item.amount)}</td>
|
<td className="category-chart__cell">{formatAmount(item.amount)}</td>
|
||||||
<td className="category-chart__cell category-chart__cell--center">{item.txCount}</td>
|
<td className="category-chart__cell category-chart__cell--center">{item.txCount}</td>
|
||||||
<td className="category-chart__cell category-chart__cell--center">
|
<td className="category-chart__cell category-chart__cell--center">
|
||||||
{(item.share * 100).toFixed(1)}%
|
{(item.share * 100).toFixed(1)}%
|
||||||
|
|||||||
@@ -32,14 +32,14 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="modal-backdrop"
|
className="modal"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
<div className="modal__dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal__header">
|
<div className="modal__header">
|
||||||
<h2>Очистить историю операций</h2>
|
<h2 className="modal__title">Очистить историю операций</h2>
|
||||||
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -53,8 +53,8 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
|
|||||||
|
|
||||||
{error && <div className="alert alert--error">{error}</div>}
|
{error && <div className="alert alert--error">{error}</div>}
|
||||||
|
|
||||||
<div className="field field--checkbox danger-note__check">
|
<div className="field field--checkbox field--flush">
|
||||||
<label>
|
<label className="field__label field__label--checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={check1}
|
checked={check1}
|
||||||
@@ -64,8 +64,8 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field field--checkbox danger-note__check">
|
<div className="field field--checkbox field--flush">
|
||||||
<label>
|
<label className="field__label field__label--checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={check2}
|
checked={check2}
|
||||||
@@ -79,13 +79,13 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
|
|||||||
|
|
||||||
<div className="modal__footer">
|
<div className="modal__footer">
|
||||||
<button
|
<button
|
||||||
className="button button--danger"
|
className="button button--danger modal__action"
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={!canConfirm || loading}
|
disabled={!canConfirm || loading}
|
||||||
>
|
>
|
||||||
{loading ? 'Удаление…' : 'Удалить всё'}
|
{loading ? 'Удаление…' : 'Удалить всё'}
|
||||||
</button>
|
</button>
|
||||||
<button className="button button--secondary" onClick={onClose}>
|
<button className="button button--secondary modal__action" onClick={onClose}>
|
||||||
Отмена
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,36 +35,36 @@ export function DataSection() {
|
|||||||
return (
|
return (
|
||||||
<div className="data-section">
|
<div className="data-section">
|
||||||
<div className="data-section__block">
|
<div className="data-section__block">
|
||||||
<h3>История импортов</h3>
|
<h3 className="data-section__title">История импортов</h3>
|
||||||
<p className="data-section__description">
|
<p className="data-section__description">
|
||||||
Список импортов выписок. Можно удалить операции конкретного импорта.
|
Список импортов выписок. Можно удалить операции конкретного импорта.
|
||||||
</p>
|
</p>
|
||||||
{imports.length === 0 ? (
|
{imports.length === 0 ? (
|
||||||
<p className="muted-text">Импортов пока нет.</p>
|
<p className="text text--muted">Импортов пока нет.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-shell">
|
<div className="table-shell">
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
<thead>
|
<thead className="data-table__head">
|
||||||
<tr>
|
<tr className="data-table__row">
|
||||||
<th>Дата</th>
|
<th className="data-table__head-cell">Дата</th>
|
||||||
<th>Счёт</th>
|
<th className="data-table__head-cell">Счёт</th>
|
||||||
<th>Банк</th>
|
<th className="data-table__head-cell">Банк</th>
|
||||||
<th>Импортировано</th>
|
<th className="data-table__head-cell">Импортировано</th>
|
||||||
<th>Дубликаты</th>
|
<th className="data-table__head-cell">Дубликаты</th>
|
||||||
<th></th>
|
<th className="data-table__head-cell"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="data-table__body">
|
||||||
{imports.map((imp) => (
|
{imports.map((imp) => (
|
||||||
<tr key={imp.id}>
|
<tr className="data-table__row" key={imp.id}>
|
||||||
<td>{formatDate(imp.importedAt)}</td>
|
<td className="data-table__cell">{formatDate(imp.importedAt)}</td>
|
||||||
<td>
|
<td className="data-table__cell">
|
||||||
{imp.accountAlias || imp.accountNumberMasked || '—'}
|
{imp.accountAlias || imp.accountNumberMasked || '—'}
|
||||||
</td>
|
</td>
|
||||||
<td>{imp.bank}</td>
|
<td className="data-table__cell">{imp.bank}</td>
|
||||||
<td>{imp.importedCount}</td>
|
<td className="data-table__cell">{imp.importedCount}</td>
|
||||||
<td>{imp.duplicatesSkipped}</td>
|
<td className="data-table__cell">{imp.duplicatesSkipped}</td>
|
||||||
<td>
|
<td className="data-table__cell">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button button--danger button--small"
|
className="button button--danger button--small"
|
||||||
@@ -83,7 +83,7 @@ export function DataSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="data-section__block">
|
<div className="data-section__block">
|
||||||
<h3>Очистка данных</h3>
|
<h3 className="data-section__title">Очистка данных</h3>
|
||||||
<p className="data-section__description">
|
<p className="data-section__description">
|
||||||
Очистить историю операций (все транзакции). Счета, категории и
|
Очистить историю операций (все транзакции). Счета, категории и
|
||||||
правила сохранятся.
|
правила сохранятся.
|
||||||
|
|||||||
@@ -32,14 +32,14 @@ export function DeleteImportModal({ imp, onClose, onDone }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="modal-backdrop"
|
className="modal"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
<div className="modal__dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal__header">
|
<div className="modal__header">
|
||||||
<h2>Удалить импорт</h2>
|
<h2 className="modal__title">Удалить импорт</h2>
|
||||||
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -59,13 +59,13 @@ export function DeleteImportModal({ imp, onClose, onDone }: Props) {
|
|||||||
<div className="modal__footer">
|
<div className="modal__footer">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button button--danger"
|
className="button button--danger modal__action"
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? 'Удаление…' : 'Удалить'}
|
{loading ? 'Удаление…' : 'Удалить'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
<button type="button" className="button button--secondary modal__action" onClick={onClose}>
|
||||||
Отмена
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -83,20 +83,20 @@ export function EditTransactionModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="modal-backdrop"
|
className="modal"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
<div className="modal__dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal__header">
|
<div className="modal__header">
|
||||||
<h2>Редактирование операции</h2>
|
<h2 className="modal__title">Редактирование операции</h2>
|
||||||
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form className="form" onSubmit={handleSubmit}>
|
||||||
<div className="modal__body">
|
<div className="modal__body">
|
||||||
{error && <div className="alert alert--error">{error}</div>}
|
{error && <div className="alert alert--error">{error}</div>}
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ export function EditTransactionModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="edit-category">Категория</label>
|
<label className="field__label" htmlFor="edit-category">Категория</label>
|
||||||
<select
|
<select
|
||||||
id="edit-category"
|
id="edit-category"
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
@@ -140,7 +140,7 @@ export function EditTransactionModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="edit-comment">Комментарий</label>
|
<label className="field__label" htmlFor="edit-comment">Комментарий</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="edit-comment"
|
id="edit-comment"
|
||||||
rows={2}
|
rows={2}
|
||||||
@@ -150,10 +150,10 @@ export function EditTransactionModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-divider" />
|
<div className="form__divider" />
|
||||||
|
|
||||||
<div className="field field--checkbox">
|
<div className="field field--checkbox">
|
||||||
<label>
|
<label className="field__label field__label--checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={createRule}
|
checked={createRule}
|
||||||
@@ -166,7 +166,7 @@ export function EditTransactionModal({
|
|||||||
{createRule && (
|
{createRule && (
|
||||||
<>
|
<>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="edit-pattern">
|
<label className="field__label" htmlFor="edit-pattern">
|
||||||
Шаблон (ключевая строка)
|
Шаблон (ключевая строка)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -178,7 +178,7 @@ export function EditTransactionModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field field--checkbox">
|
<div className="field field--checkbox">
|
||||||
<label>
|
<label className="field__label field__label--checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={requiresConfirmation}
|
checked={requiresConfirmation}
|
||||||
@@ -196,14 +196,14 @@ export function EditTransactionModal({
|
|||||||
<div className="modal__footer">
|
<div className="modal__footer">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button button--secondary"
|
className="button button--secondary modal__action"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
Отмена
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="button button--primary"
|
className="button button--primary modal__action"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
|||||||
@@ -60,14 +60,14 @@ export function ImportModal({ onClose, onDone }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="modal-backdrop"
|
className="modal"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
<div className="modal__dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal__header">
|
<div className="modal__header">
|
||||||
<h2>Импорт выписки</h2>
|
<h2 className="modal__title">Импорт выписки</h2>
|
||||||
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -78,7 +78,9 @@ export function ImportModal({ onClose, onDone }: Props) {
|
|||||||
|
|
||||||
{!result && (
|
{!result && (
|
||||||
<div className="import-upload">
|
<div className="import-upload">
|
||||||
<p>Выберите файл выписки (PDF или JSON, формат 1.0)</p>
|
<p className="import-upload__description">
|
||||||
|
Выберите файл выписки (PDF или JSON, формат 1.0)
|
||||||
|
</p>
|
||||||
<input
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -95,35 +97,35 @@ 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>Импорт завершён</h3>
|
<h3 className="import-result__title">Импорт завершён</h3>
|
||||||
<table className="import-result__stats">
|
<table className="import-result__stats">
|
||||||
<tbody>
|
<tbody className="import-result__stats-body">
|
||||||
<tr>
|
<tr className="import-result__stat-row">
|
||||||
<td>Счёт</td>
|
<td className="import-result__stat-cell import-result__stat-cell--label">Счёт</td>
|
||||||
<td>{result.accountNumberMasked}</td>
|
<td className="import-result__stat-cell import-result__stat-cell--value">{result.accountNumberMasked}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr className="import-result__stat-row">
|
||||||
<td>Новый счёт</td>
|
<td className="import-result__stat-cell import-result__stat-cell--label">Новый счёт</td>
|
||||||
<td>{result.isNewAccount ? 'Да' : 'Нет'}</td>
|
<td className="import-result__stat-cell import-result__stat-cell--value">{result.isNewAccount ? 'Да' : 'Нет'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr className="import-result__stat-row">
|
||||||
<td>Импортировано</td>
|
<td className="import-result__stat-cell import-result__stat-cell--label">Импортировано</td>
|
||||||
<td>{result.imported}</td>
|
<td className="import-result__stat-cell import-result__stat-cell--value">{result.imported}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr className="import-result__stat-row">
|
||||||
<td>Дубликатов пропущено</td>
|
<td className="import-result__stat-cell import-result__stat-cell--label">Дубликатов пропущено</td>
|
||||||
<td>{result.duplicatesSkipped}</td>
|
<td className="import-result__stat-cell import-result__stat-cell--value">{result.duplicatesSkipped}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr className="import-result__stat-row">
|
||||||
<td>Всего в файле</td>
|
<td className="import-result__stat-cell import-result__stat-cell--label">Всего в файле</td>
|
||||||
<td>{result.totalInFile}</td>
|
<td className="import-result__stat-cell import-result__stat-cell--value">{result.totalInFile}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{result.isNewAccount && !aliasSaved && (
|
{result.isNewAccount && !aliasSaved && (
|
||||||
<div className="import-result__alias">
|
<div 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">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -154,12 +156,12 @@ export function ImportModal({ onClose, onDone }: Props) {
|
|||||||
|
|
||||||
<div className="modal__footer">
|
<div className="modal__footer">
|
||||||
{result ? (
|
{result ? (
|
||||||
<button className="button button--primary" onClick={onDone}>
|
<button className="button button--primary modal__action" onClick={onDone}>
|
||||||
Готово
|
Готово
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
className="button button--secondary"
|
className="button button--secondary modal__action"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function Layout({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
onClick={closeDrawer}
|
onClick={closeDrawer}
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg className="sidebar__nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||||
<polyline points="14,2 14,8 20,8" />
|
<polyline points="14,2 14,8 20,8" />
|
||||||
<line x1="16" y1="13" x2="8" y2="13" />
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
@@ -70,7 +70,7 @@ export function Layout({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
onClick={closeDrawer}
|
onClick={closeDrawer}
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg className="sidebar__nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<line x1="18" y1="20" x2="18" y2="10" />
|
<line x1="18" y1="20" x2="18" y2="10" />
|
||||||
<line x1="12" y1="20" x2="12" y2="4" />
|
<line x1="12" y1="20" x2="12" y2="4" />
|
||||||
<line x1="6" y1="20" x2="6" y2="14" />
|
<line x1="6" y1="20" x2="6" y2="14" />
|
||||||
@@ -85,7 +85,7 @@ export function Layout({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
onClick={closeDrawer}
|
onClick={closeDrawer}
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg className="sidebar__nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<circle cx="12" cy="12" r="3" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
|
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -104,7 +104,7 @@ export function Layout({ children }: { children: ReactNode }) {
|
|||||||
<span className="sidebar__version">
|
<span className="sidebar__version">
|
||||||
FE {__FE_VERSION__} · BE {beVersion ?? '…'}
|
FE {__FE_VERSION__} · BE {beVersion ?? '…'}
|
||||||
</span>
|
</span>
|
||||||
<span className="sidebar__copyright">© 2025 Семейный бюджет</span>
|
<span className="sidebar__copyright">© 2025–2026 Семейный бюджет</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -55,29 +55,29 @@ export function RulesList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-section">
|
<div className="rules-list settings-section">
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
<thead>
|
<thead className="data-table__head">
|
||||||
<tr>
|
<tr className="data-table__row">
|
||||||
<th>Шаблон</th>
|
<th className="data-table__head-cell">Шаблон</th>
|
||||||
<th>Категория</th>
|
<th className="data-table__head-cell">Категория</th>
|
||||||
<th className="data-table__head-cell data-table__head-cell--center">Приоритет</th>
|
<th className="data-table__head-cell data-table__head-cell--center">Приоритет</th>
|
||||||
<th className="data-table__head-cell data-table__head-cell--center">Подтверждение</th>
|
<th className="data-table__head-cell data-table__head-cell--center">Подтверждение</th>
|
||||||
<th>Создано</th>
|
<th className="data-table__head-cell">Создано</th>
|
||||||
<th className="data-table__head-cell data-table__head-cell--center">Активно</th>
|
<th className="data-table__head-cell data-table__head-cell--center">Активно</th>
|
||||||
<th></th>
|
<th className="data-table__head-cell"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="data-table__body">
|
||||||
{rules.map((r) => (
|
{rules.map((r) => (
|
||||||
<tr
|
<tr
|
||||||
key={r.id}
|
key={r.id}
|
||||||
className={!r.isActive ? 'data-table__row--inactive' : ''}
|
className={`data-table__row ${!r.isActive ? 'data-table__row--inactive' : ''}`}
|
||||||
>
|
>
|
||||||
<td>
|
<td className="data-table__cell">
|
||||||
<code>{r.pattern}</code>
|
<code>{r.pattern}</code>
|
||||||
</td>
|
</td>
|
||||||
<td>{r.categoryName}</td>
|
<td className="data-table__cell">{r.categoryName}</td>
|
||||||
<td className="data-table__cell data-table__cell--center">{r.priority}</td>
|
<td className="data-table__cell data-table__cell--center">{r.priority}</td>
|
||||||
<td className="data-table__cell data-table__cell--center">
|
<td className="data-table__cell data-table__cell--center">
|
||||||
{r.requiresConfirmation ? 'Да' : 'Нет'}
|
{r.requiresConfirmation ? 'Да' : 'Нет'}
|
||||||
@@ -99,7 +99,7 @@ export function RulesList() {
|
|||||||
{r.isActive ? 'Вкл' : 'Выкл'}
|
{r.isActive ? 'Вкл' : 'Выкл'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="data-table__cell">
|
||||||
<div className="rules-list__actions">
|
<div className="rules-list__actions">
|
||||||
{r.isActive && (
|
{r.isActive && (
|
||||||
<button
|
<button
|
||||||
@@ -120,8 +120,8 @@ export function RulesList() {
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{rules.length === 0 && (
|
{rules.length === 0 && (
|
||||||
<tr>
|
<tr className="data-table__row">
|
||||||
<td colSpan={7} className="data-table__cell data-table__cell--center muted-text">
|
<td colSpan={7} className="data-table__cell data-table__cell--center text text--muted">
|
||||||
Нет правил
|
Нет правил
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -34,13 +34,16 @@ export function SummaryCards({ summary }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="summary__card summary__card--investments">
|
<div className="summary__card summary__card--investments">
|
||||||
<div className="summary__label">На инвестиции</div>
|
<div className="summary__label">Движение ДС</div>
|
||||||
<div className="summary__value">
|
<div className="summary__value">
|
||||||
{formatAmount(summary.investmentOutflow)}
|
{formatAmount(summary.cashNet)}
|
||||||
</div>
|
</div>
|
||||||
{summary.investmentIncomeExcluded > 0 && (
|
<div className="summary__subvalue">Поступило: {formatAmount(summary.cashInflow)}</div>
|
||||||
|
<div className="summary__subvalue">Списано: {formatAmount(summary.cashOutflow)}</div>
|
||||||
|
<div className="summary__subvalue">Доход от процентов: {formatAmount(summary.interestIncome)}</div>
|
||||||
|
{(summary.transferInflow > 0 || summary.transferOutflow > 0) && (
|
||||||
<div className="summary__subvalue">
|
<div className="summary__subvalue">
|
||||||
Исключено из доходов: {formatAmount(summary.investmentIncomeExcluded)}
|
Переводы: {formatAmount(summary.transferInflow)} / {formatAmount(summary.transferOutflow)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ export function TimeseriesChart({ data }: Props) {
|
|||||||
period: item.periodStart,
|
period: item.periodStart,
|
||||||
Расходы: Math.abs(item.expenseAmount) / 100,
|
Расходы: Math.abs(item.expenseAmount) / 100,
|
||||||
Доходы: item.incomeAmount / 100,
|
Доходы: item.incomeAmount / 100,
|
||||||
Инвестиции: Math.abs(item.investmentOutflow) / 100,
|
Переводы: item.transferOutflow / 100,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={chartHeight}>
|
<ResponsiveContainer width="100%" height={chartHeight}>
|
||||||
<BarChart data={chartData}>
|
<BarChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e7ded2" vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="period"
|
dataKey="period"
|
||||||
tickFormatter={(v: string) => {
|
tickFormatter={(v: string) => {
|
||||||
@@ -47,7 +47,7 @@ export function TimeseriesChart({ data }: Props) {
|
|||||||
return `${d.getDate()}.${String(d.getMonth() + 1).padStart(2, '0')}`;
|
return `${d.getDate()}.${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||||
}}
|
}}
|
||||||
fontSize={12}
|
fontSize={12}
|
||||||
stroke="#7d7164"
|
stroke="var(--color-text-secondary)"
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
@@ -56,33 +56,33 @@ export function TimeseriesChart({ data }: Props) {
|
|||||||
v >= 1000 ? `${(v / 1000).toFixed(0)}к` : String(v)
|
v >= 1000 ? `${(v / 1000).toFixed(0)}к` : String(v)
|
||||||
}
|
}
|
||||||
fontSize={12}
|
fontSize={12}
|
||||||
stroke="#7d7164"
|
stroke="var(--color-text-secondary)"
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: number) => rubFormatter.format(value)}
|
formatter={(value: number) => rubFormatter.format(value)}
|
||||||
cursor={{ fill: 'rgba(129, 93, 58, 0.08)' }}
|
cursor={{ fill: 'var(--color-chart-cursor)' }}
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
border: '1px solid #e7ded2',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
boxShadow: '0 16px 36px rgba(54, 42, 30, 0.14)',
|
boxShadow: 'var(--shadow-lg)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Legend />
|
<Legend />
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="Расходы"
|
dataKey="Расходы"
|
||||||
fill="#e85d3f"
|
fill="var(--color-danger)"
|
||||||
radius={[4, 4, 0, 0]}
|
radius={[4, 4, 0, 0]}
|
||||||
/>
|
/>
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="Доходы"
|
dataKey="Доходы"
|
||||||
fill="#0f9f7f"
|
fill="var(--color-success)"
|
||||||
radius={[4, 4, 0, 0]}
|
radius={[4, 4, 0, 0]}
|
||||||
/>
|
/>
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="Инвестиции"
|
dataKey="Переводы"
|
||||||
fill="#d89b17"
|
fill="var(--color-warning)"
|
||||||
radius={[4, 4, 0, 0]}
|
radius={[4, 4, 0, 0]}
|
||||||
/>
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function TransactionFilters({
|
|||||||
<div className="filters">
|
<div className="filters">
|
||||||
<div className="filters__row">
|
<div className="filters__row">
|
||||||
<div className="field field--period">
|
<div className="field field--period">
|
||||||
<label>Период</label>
|
<label className="field__label">Период</label>
|
||||||
<div className="filters__date-control">
|
<div className="filters__date-control">
|
||||||
{filters.periodMode !== 'custom' && (
|
{filters.periodMode !== 'custom' && (
|
||||||
<button
|
<button
|
||||||
@@ -179,7 +179,7 @@ export function TransactionFilters({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Счёт</label>
|
<label className="field__label">Счёт</label>
|
||||||
<select
|
<select
|
||||||
value={filters.accountId}
|
value={filters.accountId}
|
||||||
onChange={(e) => set('accountId', e.target.value)}
|
onChange={(e) => set('accountId', e.target.value)}
|
||||||
@@ -187,14 +187,14 @@ export function TransactionFilters({
|
|||||||
<option value="">Все счета</option>
|
<option value="">Все счета</option>
|
||||||
{accounts.map((a) => (
|
{accounts.map((a) => (
|
||||||
<option key={a.id} value={a.id}>
|
<option key={a.id} value={a.id}>
|
||||||
{a.alias || a.accountNumberMasked}
|
{[a.alias || a.accountNumberMasked, ({ brokerage: 'Брокерский', iis: 'ИИС', savings: 'Накопительный', current: 'Текущий' } as Record<string, string>)[a.accountType ?? ''], a.status === 'closed' ? 'закрытый' : 'действующий'].filter(Boolean).join(' · ')}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Тип</label>
|
<label className="field__label">Тип</label>
|
||||||
<select
|
<select
|
||||||
value={filters.direction}
|
value={filters.direction}
|
||||||
onChange={(e) => set('direction', e.target.value)}
|
onChange={(e) => set('direction', e.target.value)}
|
||||||
@@ -207,7 +207,7 @@ export function TransactionFilters({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Категория</label>
|
<label className="field__label">Категория</label>
|
||||||
<select
|
<select
|
||||||
value={filters.categoryId}
|
value={filters.categoryId}
|
||||||
onChange={(e) => set('categoryId', e.target.value)}
|
onChange={(e) => set('categoryId', e.target.value)}
|
||||||
@@ -224,7 +224,7 @@ export function TransactionFilters({
|
|||||||
|
|
||||||
<div className="filters__row">
|
<div className="filters__row">
|
||||||
<div className="field field--wide">
|
<div className="field field--wide">
|
||||||
<label>Поиск</label>
|
<label className="field__label">Поиск</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Поиск по описанию..."
|
placeholder="Поиск по описанию..."
|
||||||
@@ -234,7 +234,7 @@ export function TransactionFilters({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Сумма от (₽)</label>
|
<label className="field__label">Сумма от (₽)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="мин"
|
placeholder="мин"
|
||||||
@@ -244,7 +244,7 @@ export function TransactionFilters({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Сумма до (₽)</label>
|
<label className="field__label">Сумма до (₽)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="макс"
|
placeholder="макс"
|
||||||
@@ -254,7 +254,7 @@ export function TransactionFilters({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field field--checkbox">
|
<div className="field field--checkbox">
|
||||||
<label>
|
<label className="field__label field__label--checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={filters.onlyUnconfirmed}
|
checked={filters.onlyUnconfirmed}
|
||||||
@@ -265,7 +265,7 @@ export function TransactionFilters({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Сортировка</label>
|
<label className="field__label">Сортировка</label>
|
||||||
<div className="filters__sort">
|
<div className="filters__sort">
|
||||||
<select
|
<select
|
||||||
value={filters.sortBy}
|
value={filters.sortBy}
|
||||||
|
|||||||
@@ -102,25 +102,25 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
|
|||||||
<>
|
<>
|
||||||
<div className="table-shell table-shell--desktop">
|
<div className="table-shell table-shell--desktop">
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
<thead>
|
<thead className="data-table__head">
|
||||||
<tr>
|
<tr className="data-table__row">
|
||||||
<th>Дата</th>
|
<th className="data-table__head-cell">Дата</th>
|
||||||
<th>Счёт</th>
|
<th className="data-table__head-cell">Счёт</th>
|
||||||
<th>Сумма</th>
|
<th className="data-table__head-cell">Сумма</th>
|
||||||
<th>Описание</th>
|
<th className="data-table__head-cell">Описание</th>
|
||||||
<th>Категория</th>
|
<th className="data-table__head-cell">Категория</th>
|
||||||
<th className="data-table__head-cell data-table__head-cell--center">Статус</th>
|
<th className="data-table__head-cell data-table__head-cell--center">Статус</th>
|
||||||
<th></th>
|
<th className="data-table__head-cell"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="data-table__body">
|
||||||
{transactions.map((tx) => (
|
{transactions.map((tx) => (
|
||||||
<tr
|
<tr
|
||||||
key={tx.id}
|
key={tx.id}
|
||||||
className={
|
className={
|
||||||
!tx.isCategoryConfirmed && tx.categoryId
|
!tx.isCategoryConfirmed && tx.categoryId
|
||||||
? 'data-table__row--unconfirmed'
|
? 'data-table__row data-table__row--unconfirmed'
|
||||||
: ''
|
: 'data-table__row'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<td className="data-table__cell data-table__cell--nowrap">
|
<td className="data-table__cell data-table__cell--nowrap">
|
||||||
@@ -149,7 +149,7 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="data-table__cell data-table__cell--nowrap">
|
<td className="data-table__cell data-table__cell--nowrap">
|
||||||
{tx.categoryName || (
|
{tx.categoryName || (
|
||||||
<span className="muted-text">—</span>
|
<span className="text text--muted">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="data-table__cell data-table__cell--center">
|
<td className="data-table__cell data-table__cell--center">
|
||||||
@@ -162,7 +162,7 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="data-table__cell">
|
||||||
<button
|
<button
|
||||||
className="icon-button"
|
className="icon-button"
|
||||||
onClick={() => onEdit(tx)}
|
onClick={() => onEdit(tx)}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export function AnalyticsPage() {
|
|||||||
<PeriodSelector period={period} onChange={setPeriod} />
|
<PeriodSelector period={period} onChange={setPeriod} />
|
||||||
<div className="analytics-panel__filters">
|
<div className="analytics-panel__filters">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Счёт</label>
|
<label className="field__label">Счёт</label>
|
||||||
<select
|
<select
|
||||||
value={accountId}
|
value={accountId}
|
||||||
onChange={(e) => setAccountId(e.target.value)}
|
onChange={(e) => setAccountId(e.target.value)}
|
||||||
@@ -107,13 +107,13 @@ export function AnalyticsPage() {
|
|||||||
<option value="">Все счета</option>
|
<option value="">Все счета</option>
|
||||||
{accounts.map((a) => (
|
{accounts.map((a) => (
|
||||||
<option key={a.id} value={a.id}>
|
<option key={a.id} value={a.id}>
|
||||||
{a.alias || a.accountNumberMasked}
|
{[a.alias || a.accountNumberMasked, ({ brokerage: 'Брокерский', iis: 'ИИС', savings: 'Накопительный', current: 'Текущий' } as Record<string, string>)[a.accountType ?? ''], a.status === 'closed' ? 'закрытый' : 'действующий'].filter(Boolean).join(' · ')}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="field field--checkbox">
|
<div className="field field--checkbox">
|
||||||
<label>
|
<label className="field__label field__label--checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={onlyConfirmed}
|
checked={onlyConfirmed}
|
||||||
@@ -132,11 +132,11 @@ export function AnalyticsPage() {
|
|||||||
{summary && <SummaryCards summary={summary} />}
|
{summary && <SummaryCards summary={summary} />}
|
||||||
<div className="analytics-grid">
|
<div className="analytics-grid">
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<h3>Динамика</h3>
|
<h3 className="chart-card__title">Динамика</h3>
|
||||||
<TimeseriesChart data={timeseries} />
|
<TimeseriesChart data={timeseries} />
|
||||||
</div>
|
</div>
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<h3>По категориям</h3>
|
<h3 className="chart-card__title">По категориям</h3>
|
||||||
<CategoryChart data={byCategory} />
|
<CategoryChart data={byCategory} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export function HistoryPage() {
|
|||||||
<h1 className="page__title">История операций</h1>
|
<h1 className="page__title">История операций</h1>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="button button--primary"
|
className="button button--primary page__action"
|
||||||
onClick={() => setShowImport(true)}
|
onClick={() => setShowImport(true)}
|
||||||
>
|
>
|
||||||
<svg className="button__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
<svg className="button__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ export function LoginPage() {
|
|||||||
<div className="login__panel">
|
<div className="login__panel">
|
||||||
<div className="login__header">
|
<div className="login__header">
|
||||||
<span className="login__icon">₽</span>
|
<span className="login__icon">₽</span>
|
||||||
<h1>Семейный бюджет</h1>
|
<h1 className="login__title">Семейный бюджет</h1>
|
||||||
<p>Войдите для продолжения</p>
|
<p className="login__subtitle">Войдите для продолжения</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="login__form">
|
<form onSubmit={handleSubmit} className="form login__form">
|
||||||
{error && <div className="alert alert--error">{error}</div>}
|
{error && <div className="alert alert--error">{error}</div>}
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="login">Логин</label>
|
<label className="field__label" htmlFor="login">Логин</label>
|
||||||
<input
|
<input
|
||||||
id="login"
|
id="login"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -42,7 +42,7 @@ export function LoginPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="password">Пароль</label>
|
<label className="field__label" htmlFor="password">Пароль</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
|
|||||||
@@ -31,6 +31,22 @@
|
|||||||
--color-warning-soft: #fff6da;
|
--color-warning-soft: #fff6da;
|
||||||
--color-transfer: #7c5cdb;
|
--color-transfer: #7c5cdb;
|
||||||
--color-transfer-soft: #f0ebff;
|
--color-transfer-soft: #f0ebff;
|
||||||
|
--color-chart-1: var(--color-primary);
|
||||||
|
--color-chart-2: var(--color-danger);
|
||||||
|
--color-chart-3: var(--color-success);
|
||||||
|
--color-chart-4: var(--color-warning);
|
||||||
|
--color-chart-5: var(--color-transfer);
|
||||||
|
--color-chart-6: #c8558f;
|
||||||
|
--color-chart-7: #1495a3;
|
||||||
|
--color-chart-8: #79a92f;
|
||||||
|
--color-chart-9: #e06d2f;
|
||||||
|
--color-chart-10: #4f6fd7;
|
||||||
|
--color-chart-11: #1c9a8a;
|
||||||
|
--color-chart-12: #d43d5c;
|
||||||
|
--color-chart-13: #277bbd;
|
||||||
|
--color-chart-14: #9b59b6;
|
||||||
|
--color-chart-15: #2f9f63;
|
||||||
|
--color-chart-cursor: rgba(129, 93, 58, 0.08);
|
||||||
--color-sidebar: #fff9f0;
|
--color-sidebar: #fff9f0;
|
||||||
--radius-sm: 4px;
|
--radius-sm: 4px;
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
@@ -182,7 +198,7 @@ button {
|
|||||||
box-shadow var(--transition);
|
box-shadow var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__nav-link svg {
|
.sidebar__nav-icon {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +242,12 @@ button {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar__meta {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar__user {
|
.sidebar__user {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -315,7 +337,7 @@ button {
|
|||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
.muted-text {
|
.text--muted {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,13 +366,13 @@ button {
|
|||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field label {
|
.field__label {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field--checkbox label {
|
.field__label--checkbox {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -594,7 +616,7 @@ input[type="checkbox"] {
|
|||||||
color: #b8321f;
|
color: #b8321f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-divider {
|
.form__divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--color-border);
|
background: var(--color-border);
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
@@ -644,12 +666,12 @@ input[type="checkbox"] {
|
|||||||
box-shadow: 0 18px 34px rgba(232, 93, 63, 0.22);
|
box-shadow: 0 18px 34px rgba(232, 93, 63, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login__header h1 {
|
.login__title {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login__header p {
|
.login__subtitle {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
@@ -836,7 +858,7 @@ input[type="checkbox"] {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-card h3 {
|
.chart-card__title {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
@@ -853,19 +875,20 @@ input[type="checkbox"] {
|
|||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-chart__table th,
|
.category-chart__head-cell,
|
||||||
.category-chart__table td {
|
.category-chart__cell {
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
padding: 7px 9px;
|
padding: 7px 9px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-chart__table th {
|
.category-chart__head-cell {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.category-chart__head-cell--center,
|
||||||
.category-chart__cell--center {
|
.category-chart__cell--center {
|
||||||
text-align: center !important;
|
text-align: center !important;
|
||||||
}
|
}
|
||||||
@@ -897,15 +920,14 @@ input[type="checkbox"] {
|
|||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-table th,
|
.data-table__head-cell,
|
||||||
.data-table td {
|
.data-table__cell {
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
padding: 11px 14px;
|
padding: 11px 14px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-table th,
|
|
||||||
.data-table__head-cell {
|
.data-table__head-cell {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -915,15 +937,15 @@ input[type="checkbox"] {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-table tbody tr {
|
.data-table__row {
|
||||||
transition: background var(--transition);
|
transition: background var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-table tbody tr:hover {
|
.data-table__body .data-table__row:hover {
|
||||||
background: rgba(247, 242, 235, 0.72);
|
background: rgba(247, 242, 235, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-table tbody tr:last-child td {
|
.data-table__body .data-table__row:last-child .data-table__cell {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1128,7 +1150,7 @@ input[type="checkbox"] {
|
|||||||
padding: 22px;
|
padding: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-section__block h3 {
|
.data-section__title {
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
@@ -1177,7 +1199,7 @@ input[type="checkbox"] {
|
|||||||
Modals and import
|
Modals and import
|
||||||
================================================================ */
|
================================================================ */
|
||||||
|
|
||||||
.modal-backdrop {
|
.modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
@@ -1188,7 +1210,7 @@ input[type="checkbox"] {
|
|||||||
background: rgba(35, 27, 21, 0.44);
|
background: rgba(35, 27, 21, 0.44);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal__dialog {
|
||||||
width: min(100%, 540px);
|
width: min(100%, 540px);
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -1206,7 +1228,7 @@ input[type="checkbox"] {
|
|||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal__header h2 {
|
.modal__title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
@@ -1281,7 +1303,7 @@ input[type="checkbox"] {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-upload p {
|
.import-upload__description {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1314,7 +1336,7 @@ input[type="checkbox"] {
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-result h3 {
|
.import-result__title {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
@@ -1326,16 +1348,16 @@ input[type="checkbox"] {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-result__stats td {
|
.import-result__stat-cell {
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-result__stats td:first-child {
|
.import-result__stat-cell--label {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-result__stats td:last-child {
|
.import-result__stat-cell--value {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -1346,7 +1368,7 @@ input[type="checkbox"] {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-result__alias label {
|
.import-result__alias-label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
@@ -1374,7 +1396,7 @@ input[type="checkbox"] {
|
|||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-note__check {
|
.field--flush {
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1436,7 +1458,7 @@ input[type="checkbox"] {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page__header .button {
|
.page__action {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1504,12 +1526,12 @@ input[type="checkbox"] {
|
|||||||
padding: 28px 22px;
|
padding: 28px 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-backdrop {
|
.modal {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal__dialog {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
@@ -1527,7 +1549,7 @@ input[type="checkbox"] {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal__footer .button {
|
.modal__action {
|
||||||
flex: 1 1 150px;
|
flex: 1 1 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -64,7 +64,7 @@
|
|||||||
},
|
},
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"name": "@family-budget/frontend",
|
"name": "@family-budget/frontend",
|
||||||
"version": "0.8.6",
|
"version": "0.9.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@family-budget/shared": "*",
|
"@family-budget/shared": "*",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
210
scripts/convert_vtb_broker_xlsx.py
Normal file
210
scripts/convert_vtb_broker_xlsx.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Convert a VTB broker XLSX report to the import JSON formats.
|
||||||
|
|
||||||
|
Only the cash-movement section is sent to the existing statement importer.
|
||||||
|
Securities are written to a sidecar file for the future portfolio importer.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from pathlib import Path
|
||||||
|
from zipfile import ZipFile
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
NS = {'m': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
||||||
|
EPOCH = datetime(1899, 12, 30)
|
||||||
|
COLS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||||
|
OPERATION_NAMESPACE = uuid.UUID('7a4a1c7d-7d3c-4f53-9f45-5e7dbb2c6f31')
|
||||||
|
|
||||||
|
|
||||||
|
def excel_date(value):
|
||||||
|
if value in (None, ''):
|
||||||
|
return None
|
||||||
|
number = float(value)
|
||||||
|
date = EPOCH + timedelta(days=number)
|
||||||
|
return date.isoformat(timespec='seconds') + '+03:00'
|
||||||
|
|
||||||
|
|
||||||
|
def excel_day(value):
|
||||||
|
return excel_date(value)[:10] if excel_date(value) else None
|
||||||
|
|
||||||
|
|
||||||
|
def kopecks(value):
|
||||||
|
if value in (None, ''):
|
||||||
|
return 0
|
||||||
|
return int((Decimal(str(value)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)) * 100)
|
||||||
|
|
||||||
|
|
||||||
|
def text(value):
|
||||||
|
return re.sub(r'\s+', ' ', str(value or '')).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def read_rows(path):
|
||||||
|
with ZipFile(path) as book:
|
||||||
|
shared_root = ET.fromstring(book.read('xl/sharedStrings.xml'))
|
||||||
|
shared = [text(''.join(node.itertext())) for node in shared_root.findall('m:si', NS)]
|
||||||
|
root = ET.fromstring(book.read('xl/worksheets/sheet1.xml'))
|
||||||
|
rows = []
|
||||||
|
for row in root.findall('.//m:sheetData/m:row', NS):
|
||||||
|
cells = {}
|
||||||
|
for cell in row.findall('m:c', NS):
|
||||||
|
ref = cell.attrib.get('r', '')
|
||||||
|
col = re.match(r'[A-Z]+', ref).group(0)
|
||||||
|
value = cell.find('m:v', NS)
|
||||||
|
raw = value.text if value is not None else ''
|
||||||
|
if cell.attrib.get('t') == 's' and raw:
|
||||||
|
raw = shared[int(raw)]
|
||||||
|
cells[col] = raw
|
||||||
|
rows.append((int(row.attrib['r']), cells))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def row_text(cells):
|
||||||
|
return text(' '.join(str(value) for value in cells.values()))
|
||||||
|
|
||||||
|
|
||||||
|
def find_row(rows, phrase, start=0):
|
||||||
|
for index in range(start, len(rows)):
|
||||||
|
if phrase.lower() in row_text(rows[index][1]).lower():
|
||||||
|
return index
|
||||||
|
raise ValueError(f'Не найден раздел: {phrase}')
|
||||||
|
|
||||||
|
|
||||||
|
def metadata(rows):
|
||||||
|
account = None
|
||||||
|
period = re.search(r'период с (\d{2}\.\d{2}\.\d{4}) по (\d{2}\.\d{2}\.\d{4})', row_text(dict(rows)))
|
||||||
|
report_date = None
|
||||||
|
for _, cells in rows[:35]:
|
||||||
|
joined = row_text(cells)
|
||||||
|
match = re.search(r'(\d{20})\s*\(RUR\)', joined)
|
||||||
|
account = account or (match.group(1) if match else None)
|
||||||
|
if 'Дата формирования отчета' in joined:
|
||||||
|
for value in cells.values():
|
||||||
|
if value and re.fullmatch(r'\d+(?:\.\d+)?', str(value)):
|
||||||
|
report_date = excel_day(value)
|
||||||
|
if not account or not period:
|
||||||
|
raise ValueError('Не удалось определить счёт или период отчёта')
|
||||||
|
return account, period.groups(), report_date
|
||||||
|
|
||||||
|
|
||||||
|
def cash_transactions(rows, start, end):
|
||||||
|
transactions = []
|
||||||
|
occurrences = defaultdict(int)
|
||||||
|
for number, cells in rows[start + 1:end]:
|
||||||
|
if not cells.get('B') or not cells.get('C') or not cells.get('J'):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
operation_at = excel_date(cells['B'])
|
||||||
|
amount = kopecks(cells['C'])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
operation = text(cells.get('J'))
|
||||||
|
comment = text(cells.get('P'))
|
||||||
|
description = text(f'{operation}. {comment}'.strip('. '))
|
||||||
|
digest = hashlib.sha256(f'{operation_at}|{amount}|{description}'.encode()).hexdigest()[:16]
|
||||||
|
occurrences[digest] += 1
|
||||||
|
source_id = f'vtb-broker-cash:{digest}:{occurrences[digest]}'
|
||||||
|
transactions.append({
|
||||||
|
'operationAt': operation_at,
|
||||||
|
'amountSigned': amount,
|
||||||
|
'commission': 0,
|
||||||
|
'description': description,
|
||||||
|
'sourceId': source_id,
|
||||||
|
'operationId': str(uuid.uuid5(OPERATION_NAMESPACE, source_id)),
|
||||||
|
'_sourceRow': number,
|
||||||
|
})
|
||||||
|
if not transactions:
|
||||||
|
raise ValueError('Операции движения денежных средств не найдены')
|
||||||
|
return transactions
|
||||||
|
|
||||||
|
|
||||||
|
def portfolio(rows, holdings_start, movement_start, trades_start, trades_end, account, period, report_date):
|
||||||
|
positions = []
|
||||||
|
for number, cells in rows[holdings_start + 1:movement_start]:
|
||||||
|
instrument = text(cells.get('B'))
|
||||||
|
if not instrument or instrument.lower().startswith('итого') or not re.search(r'RU[A-Z0-9]{10}', instrument):
|
||||||
|
continue
|
||||||
|
positions.append({
|
||||||
|
'sourceRow': number,
|
||||||
|
'instrument': instrument,
|
||||||
|
'isin': next((part for part in instrument.split(', ') if re.fullmatch(r'RU[A-Z0-9]{10}', part)), None),
|
||||||
|
'quantity': cells.get('L') or cells.get('M') or cells.get('I') or cells.get('J'),
|
||||||
|
'price': cells.get('P'),
|
||||||
|
'valuation': cells.get('AF') or cells.get('AJ'),
|
||||||
|
})
|
||||||
|
trades = []
|
||||||
|
columns = {'instrument': 'B', 'concludedAt': 'C', 'side': 'F', 'quantity': 'H',
|
||||||
|
'priceCurrency': 'I', 'price': 'J', 'settlementCurrency': 'L',
|
||||||
|
'settlementAmount': 'M', 'nkd': 'O', 'settlementCommission': 'P',
|
||||||
|
'tradeCommission': 'R', 'plannedDeliveryDate': 'S', 'plannedPaymentDate': 'T',
|
||||||
|
'orderId': 'W', 'tradeId': 'Z', 'organizerTradeId': 'AC', 'venue': 'AK',
|
||||||
|
'comment': 'AN'}
|
||||||
|
for number, cells in rows[trades_start + 1:trades_end]:
|
||||||
|
if not cells.get('B') or not cells.get('C') or not cells.get('F'):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
concluded_at = excel_date(cells['C'])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
item = {key: text(cells.get(col)) for key, col in columns.items()}
|
||||||
|
item['sourceRow'] = number
|
||||||
|
item['concludedAt'] = concluded_at
|
||||||
|
item['sourceId'] = f"vtb-broker-trade:{item['tradeId'] or number}"
|
||||||
|
item['operationId'] = str(uuid.uuid5(OPERATION_NAMESPACE, item['sourceId']))
|
||||||
|
trades.append(item)
|
||||||
|
return {
|
||||||
|
'schemaVersion': 'broker-portfolio-1.0',
|
||||||
|
'bank': 'VTB_BROKER',
|
||||||
|
'accountNumber': account,
|
||||||
|
'reportPeriod': {'from': datetime.strptime(period[0], '%d.%m.%Y').date().isoformat(),
|
||||||
|
'to': datetime.strptime(period[1], '%d.%m.%Y').date().isoformat()},
|
||||||
|
'reportedAt': report_date + 'T00:00:00+03:00' if report_date else None,
|
||||||
|
'positions': positions,
|
||||||
|
'trades': trades,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) not in (3, 4):
|
||||||
|
raise SystemExit('usage: convert_vtb_broker_xlsx.py INPUT_XLSX CASH_JSON [PORTFOLIO_JSON]')
|
||||||
|
input_path, cash_path = map(Path, sys.argv[1:3])
|
||||||
|
portfolio_path = Path(sys.argv[3]) if len(sys.argv) == 4 else None
|
||||||
|
rows = read_rows(input_path)
|
||||||
|
account, period, report_date = metadata(rows)
|
||||||
|
holdings_start = find_row(rows, 'Отчёт об остатках ценных бумаг')
|
||||||
|
movement_start = find_row(rows, 'Движение ценных бумаг', holdings_start)
|
||||||
|
cash_start = find_row(rows, 'Движение денежных средств')
|
||||||
|
trades_start = find_row(rows, 'Заключенные в отчетном периоде сделки с ценными бумагами')
|
||||||
|
trades_end = find_row(rows, 'Завершенные в отчетном периоде сделки с ценными бумагами', trades_start + 1)
|
||||||
|
transactions = cash_transactions(rows, cash_start, holdings_start)
|
||||||
|
assert len({item['sourceId'] for item in transactions}) == len(transactions), 'sourceId операции не уникальны'
|
||||||
|
opening = kopecks(rows[find_row(rows, 'Отчёт об остатках денежных средств') + 3][1].get('L'))
|
||||||
|
balance_row = rows[find_row(rows, 'Отчёт об остатках денежных средств') + 3][1]
|
||||||
|
closing = kopecks(balance_row.get('AF'))
|
||||||
|
cash = {'schemaVersion': '1.0', 'bank': 'VTB_BROKER',
|
||||||
|
'statement': {'accountNumber': account, 'currency': 'RUB',
|
||||||
|
'openingBalance': opening, 'closingBalance': closing,
|
||||||
|
'exportedAt': (report_date or transactions[-1]['operationAt'][:10]) + 'T00:00:00+03:00'},
|
||||||
|
'transactions': [{key: value for key, value in item.items() if not key.startswith('_')}
|
||||||
|
for item in transactions]}
|
||||||
|
calculated_closing = opening + sum(item['amountSigned'] for item in transactions)
|
||||||
|
if calculated_closing != closing:
|
||||||
|
raise ValueError(f'Баланс не сходится: операции дают {calculated_closing}, XLSX содержит {closing}')
|
||||||
|
cash_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cash_path.write_text(json.dumps(cash, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||||
|
duplicate_candidates = len(transactions) - len({item['sourceId'].rsplit(':', 1)[0] for item in transactions})
|
||||||
|
if portfolio_path:
|
||||||
|
portfolio_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
portfolio_path.write_text(json.dumps(portfolio(rows, holdings_start, movement_start, trades_start, trades_end, account, period, report_date), ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||||
|
print(json.dumps({'transactions': len(transactions), 'cashNet': sum(item['amountSigned'] for item in transactions),
|
||||||
|
'openingBalance': opening, 'closingBalance': closing, 'duplicateCandidates': duplicate_candidates,
|
||||||
|
'cashJson': str(cash_path), 'portfolioJson': str(portfolio_path) if portfolio_path else None}, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/shared",
|
"name": "@family-budget/shared",
|
||||||
"version": "0.1.1",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
|
export type AccountType = 'brokerage' | 'iis' | 'savings' | 'current';
|
||||||
|
export type AccountStatus = 'active' | 'closed';
|
||||||
|
|
||||||
export interface Account {
|
export interface Account {
|
||||||
id: number;
|
id: number;
|
||||||
bank: string;
|
bank: string;
|
||||||
accountNumberMasked: string;
|
accountNumberMasked: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
alias: string | null;
|
alias: string | null;
|
||||||
|
accountType: AccountType | null;
|
||||||
|
status: AccountStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateAccountRequest {
|
export interface UpdateAccountRequest {
|
||||||
alias: string;
|
alias: string;
|
||||||
|
accountType?: AccountType | null;
|
||||||
|
status?: AccountStatus;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ export interface AnalyticsSummaryResponse {
|
|||||||
totalExpense: number;
|
totalExpense: number;
|
||||||
totalIncome: number;
|
totalIncome: number;
|
||||||
net: number;
|
net: number;
|
||||||
investmentOutflow: number;
|
cashInflow: number;
|
||||||
investmentIncomeExcluded: number;
|
cashOutflow: number;
|
||||||
|
transferInflow: number;
|
||||||
|
transferOutflow: number;
|
||||||
|
cashNet: number;
|
||||||
|
interestIncome: number;
|
||||||
topCategories: TopCategory[];
|
topCategories: TopCategory[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,5 +56,5 @@ export interface TimeseriesItem {
|
|||||||
periodEnd: string;
|
periodEnd: string;
|
||||||
expenseAmount: number;
|
expenseAmount: number;
|
||||||
incomeAmount: number;
|
incomeAmount: number;
|
||||||
investmentOutflow: number;
|
transferOutflow: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export interface StatementTransaction {
|
|||||||
amountSigned: number;
|
amountSigned: number;
|
||||||
commission: number;
|
commission: number;
|
||||||
description: string;
|
description: string;
|
||||||
|
/** Stable source-row identity used to make repeated imports idempotent. */
|
||||||
|
sourceId?: string;
|
||||||
|
/** Stable operation UUID emitted by source-specific converters. */
|
||||||
|
operationId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportStatementResponse {
|
export interface ImportStatementResponse {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export type {
|
|||||||
ApiError,
|
ApiError,
|
||||||
} from './common';
|
} from './common';
|
||||||
|
|
||||||
export type { Account, UpdateAccountRequest } from './account';
|
export type { Account, AccountType, AccountStatus, UpdateAccountRequest } from './account';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
Category,
|
Category,
|
||||||
|
|||||||
Reference in New Issue
Block a user