Compare commits
70 Commits
feat/simpl
...
feature/br
| Author | SHA1 | Date | |
|---|---|---|---|
| 84d6044d98 | |||
| c83a6d8d81 | |||
| 361a07d4da | |||
| 1dc0e48348 | |||
| 027fbedb8c | |||
| a1e93a7c1f | |||
| 3f5681074e | |||
| 62519f80cd | |||
| 45f0561fd6 | |||
| fab929fd68 | |||
| c4ce2b9d6b | |||
| c4f681c9b0 | |||
| dd21e20dc6 | |||
| 29c4acd0a9 | |||
| 8939843462 | |||
| ce162855f9 | |||
| 7671ab76b2 | |||
| cce2ddcf41 | |||
| 7154e8f2ea | |||
| d86624b9ef | |||
| 66c04b0618 | |||
| 24b8ed8261 | |||
| efc9854064 | |||
| 9755332204 | |||
| 669e54f6cb | |||
| ab42caae37 | |||
| 4172b0c8e4 | |||
| d2354a61ed | |||
| 6db1312fb1 | |||
| e0da7e86ad | |||
| 8d91bea7a9 | |||
| 0003d92583 | |||
| 6515e73ead | |||
| 5f282d199d | |||
| 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 | |||
|
|
ec62a0591e | ||
| 97b61de092 | |||
|
|
0e0186fdbb | ||
| 02ca34d088 | |||
|
|
f6d3196254 | ||
|
|
fccde4259d | ||
| e97203c4ab | |||
|
|
6dd8c01f5e | ||
| 495c1e89bb | |||
|
|
032a9f4e3b | ||
| d5f49fd86f |
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_report.txt
|
||||
statements/
|
||||
temp/
|
||||
.cursor/
|
||||
|
||||
172
CHANGELOG.md
Normal file
172
CHANGELOG.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# Changelog
|
||||
|
||||
## [Frontend 0.11.3] - 2026-08-26
|
||||
|
||||
### Added
|
||||
|
||||
- Import broker portfolio JSON files and show their trade and position results.
|
||||
|
||||
## [Frontend 0.11.2 / Backend 0.10.5 / Shared 0.5.1] - 2026-08-26
|
||||
|
||||
### Added
|
||||
|
||||
- Show cashback separately in the cash-flow summary alongside interest income.
|
||||
|
||||
## [Backend 0.10.3] - 2026-08-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- Treat VTB savings-account deposits and closures as internal transfers when importing PDF-to-JSON statements.
|
||||
|
||||
## [Backend 0.10.2] - 2026-08-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- Analytics endpoints now reject invalid account and category filter IDs with a validation error.
|
||||
|
||||
## [Frontend 0.11.1] - 2026-08-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- Preserve the explicit «Без категории» analytics filter value when requesting data.
|
||||
|
||||
## [Frontend 0.11.0 / Backend 0.10.1 / Shared 0.5.0] - 2026-08-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- Corrected analytics period boundaries for partial week/month ranges and added a usable «Без категории» filter with SQL coverage.
|
||||
|
||||
## [Backend 0.9.2] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
- Extended import integration coverage to verify source positions for same-date operations.
|
||||
|
||||
## [Backend 0.9.1] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
- Added runnable coverage for date and amount sorting tie-breakers.
|
||||
|
||||
## [Backend 0.9.0] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
- Preserved source-array transaction positions and used them as stable tie-breakers for same-date history sorting.
|
||||
|
||||
## [Backend 0.8.3] - 2026-08-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- Invalid non-string trade identifiers now return validation errors instead of server errors.
|
||||
|
||||
## [Backend 0.8.2] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
- Added a database integration test for repeated portfolio imports and trade deduplication.
|
||||
|
||||
## [Backend 0.8.1] - 2026-08-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- Hardened portfolio import validation, preserved decimal precision, added fallback trade keys, and covered validation with a runnable test.
|
||||
|
||||
## [Backend 0.8.0 / Shared 0.4.0] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
- Added portfolio JSON import for broker reports with idempotent trades, position snapshots, and separate portfolio tables.
|
||||
|
||||
## [Backend 0.7.5 / Shared 0.3.1] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
- Creating a category rule now applies it atomically to all matching unconfirmed transactions and returns the affected count.
|
||||
|
||||
## [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/node_modules ./node_modules
|
||||
COPY --from=build /app/backend/node_modules/ ./node_modules/
|
||||
COPY backend/.env .env
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node","dist/app.js"]
|
||||
|
||||
18
README.md
18
README.md
@@ -15,13 +15,15 @@ family_budget/
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Layer | Choice | Rationale |
|
||||
|---------- |------------------------|--------------------------------------------------------|
|
||||
| Backend | Express + TypeScript | Simple, well-known, sufficient for a small local app |
|
||||
| Frontend | React + Vite + TS | Fast dev experience, modern tooling |
|
||||
| Database | PostgreSQL | Deployed on Synology NAS |
|
||||
| Migrations | Knex | Lightweight, SQL-close, supports seeds |
|
||||
| Shared | Pure TypeScript types | Zero-runtime, imported by both backend and frontend |
|
||||
|
||||
| Layer | Choice | Rationale |
|
||||
| ---------- | --------------------- | ---------------------------------------------------- |
|
||||
| Backend | Express + TypeScript | Simple, well-known, sufficient for a small local app |
|
||||
| Frontend | React + Vite + TS | Fast dev experience, modern tooling |
|
||||
| Database | PostgreSQL | Deployed on Synology NAS |
|
||||
| Migrations | Knex | Lightweight, SQL-close, supports seeds |
|
||||
| Shared | Pure TypeScript types | Zero-runtime, imported by both backend and frontend |
|
||||
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -39,4 +41,4 @@ npm install
|
||||
npm run build -w shared
|
||||
```
|
||||
|
||||
See `backend/README.md` and `frontend/README.md` for per-package instructions.
|
||||
See `backend/README.md` and `frontend/README.md` for per-package instructions.
|
||||
@@ -21,6 +21,8 @@ createdb family_budget
|
||||
npm run dev -w backend
|
||||
```
|
||||
|
||||
Для Docker Compose production-переменные передаются из `backend/.env`. Файл не хранится в Git и не копируется в Docker image; перед запуском создайте его из `.env.example` и заполните секреты.
|
||||
|
||||
Сервер стартует на `http://localhost:3000` (или на порту из `PORT`).
|
||||
|
||||
## Скрипты
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/backend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.10.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/app.ts",
|
||||
@@ -8,6 +8,14 @@
|
||||
"start": "node dist/app.js",
|
||||
"migrate": "tsx src/db/migrate.ts",
|
||||
"migrate:prod": "node dist/db/migrate.js",
|
||||
"test:analytics": "tsx src/services/analyticsSemantics.test.ts",
|
||||
"test:analytics:query": "tsx src/routes/analytics.test.ts",
|
||||
"test:portfolio": "tsx src/services/portfolio.test.ts",
|
||||
"test:portfolio:db": "NODE_ENV=test tsx src/services/portfolio.integration.test.ts",
|
||||
"test:transactions": "tsx src/services/transactions.test.ts",
|
||||
"test:analytics:db": "NODE_ENV=test tsx src/services/analytics.integration.test.ts",
|
||||
"test:import:db": "NODE_ENV=test tsx src/services/import.integration.test.ts",
|
||||
"test:import:direction": "tsx src/services/import.test.ts",
|
||||
"test:llm": "tsx src/scripts/testLlm.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import express from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import cors from 'cors';
|
||||
@@ -5,6 +7,10 @@ import { config } from './config';
|
||||
import { runMigrations } from './db/migrate';
|
||||
import { requireAuth } from './middleware/auth';
|
||||
|
||||
const pkg = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'),
|
||||
) as { version: string };
|
||||
|
||||
import authRouter from './routes/auth';
|
||||
import importRouter from './routes/import';
|
||||
import importsRouter from './routes/imports';
|
||||
@@ -13,6 +19,7 @@ import accountsRouter from './routes/accounts';
|
||||
import categoriesRouter from './routes/categories';
|
||||
import categoryRulesRouter from './routes/categoryRules';
|
||||
import analyticsRouter from './routes/analytics';
|
||||
import portfolioRouter from './routes/portfolio';
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', 1);
|
||||
@@ -24,6 +31,10 @@ app.use(cors({ origin: true, credentials: true }));
|
||||
// Auth routes (login is public; me/logout apply auth internally)
|
||||
app.use('/api/auth', authRouter);
|
||||
|
||||
app.get('/api/version', (_req, res) => {
|
||||
res.json({ version: pkg.version });
|
||||
});
|
||||
|
||||
// All remaining /api routes require authentication
|
||||
app.use('/api', requireAuth);
|
||||
app.use('/api/import', importRouter);
|
||||
@@ -33,6 +44,7 @@ app.use('/api/accounts', accountsRouter);
|
||||
app.use('/api/categories', categoriesRouter);
|
||||
app.use('/api/category-rules', categoryRulesRouter);
|
||||
app.use('/api/analytics', analyticsRouter);
|
||||
app.use('/api/import/portfolio', portfolioRouter);
|
||||
|
||||
app.use(
|
||||
(
|
||||
|
||||
@@ -133,6 +133,16 @@ const migrations: { name: string; sql: string }[] = [
|
||||
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',
|
||||
sql: `
|
||||
@@ -212,6 +222,80 @@ const migrations: { name: string; sql: string }[] = [
|
||||
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'));
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: '008_portfolio_tables',
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS portfolio_reports (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
account_id BIGINT NOT NULL REFERENCES accounts(id),
|
||||
source_hash TEXT NOT NULL,
|
||||
report_period_from DATE NOT NULL,
|
||||
report_period_to DATE NOT NULL,
|
||||
reported_at TIMESTAMPTZ,
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (account_id, source_hash)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS portfolio_positions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
report_id BIGINT NOT NULL REFERENCES portfolio_reports(id) ON DELETE CASCADE,
|
||||
instrument TEXT NOT NULL,
|
||||
isin TEXT,
|
||||
quantity NUMERIC NOT NULL,
|
||||
price NUMERIC,
|
||||
valuation NUMERIC
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS portfolio_trades (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
account_id BIGINT NOT NULL REFERENCES accounts(id),
|
||||
source_id TEXT NOT NULL,
|
||||
operation_id UUID NOT NULL,
|
||||
instrument TEXT NOT NULL,
|
||||
isin TEXT,
|
||||
concluded_at TIMESTAMPTZ NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
quantity NUMERIC NOT NULL,
|
||||
price_currency TEXT,
|
||||
price NUMERIC,
|
||||
settlement_currency TEXT,
|
||||
settlement_amount NUMERIC,
|
||||
nkd NUMERIC,
|
||||
settlement_commission NUMERIC,
|
||||
trade_commission NUMERIC,
|
||||
order_id TEXT,
|
||||
trade_id TEXT,
|
||||
venue TEXT,
|
||||
comment TEXT,
|
||||
UNIQUE (account_id, source_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_portfolio_trades_account_date
|
||||
ON portfolio_trades(account_id, concluded_at DESC);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: '009_transaction_source_position',
|
||||
sql: `
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS source_position BIGINT NOT NULL DEFAULT 0;
|
||||
UPDATE transactions SET source_position = id WHERE source_position = 0;
|
||||
CREATE INDEX IF NOT EXISTS ix_transactions_date_position
|
||||
ON transactions(operation_at, source_position, id);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export async function runMigrations(): Promise<void> {
|
||||
|
||||
@@ -21,7 +21,7 @@ router.put(
|
||||
return;
|
||||
}
|
||||
|
||||
const { alias } = req.body;
|
||||
const { alias, accountType, status } = req.body;
|
||||
if (typeof alias !== 'string' || !alias.trim()) {
|
||||
res.status(400).json({ error: 'BAD_REQUEST', message: 'alias is required and must be non-empty' });
|
||||
return;
|
||||
@@ -30,8 +30,21 @@ router.put(
|
||||
res.status(400).json({ error: 'BAD_REQUEST', message: 'alias must be at most 50 characters' });
|
||||
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) {
|
||||
res.status(404).json({ error: 'NOT_FOUND', message: 'Account not found' });
|
||||
return;
|
||||
|
||||
12
backend/src/routes/analytics.test.ts
Normal file
12
backend/src/routes/analytics.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseOptionalId } from './analytics';
|
||||
|
||||
assert.equal(parseOptionalId(undefined, 0), undefined);
|
||||
assert.equal(parseOptionalId('0', 0), 0);
|
||||
assert.equal(parseOptionalId('1', 1), 1);
|
||||
assert.equal(parseOptionalId('0', 1), null);
|
||||
assert.equal(parseOptionalId('abc', 0), null);
|
||||
assert.equal(parseOptionalId('1.5', 0), null);
|
||||
assert.equal(parseOptionalId(['1'], 0), null);
|
||||
|
||||
console.log('analytics query validation: OK');
|
||||
@@ -5,10 +5,34 @@ import type { Granularity } from '@family-budget/shared';
|
||||
|
||||
const router = Router();
|
||||
|
||||
export function parseOptionalId(value: unknown, minimum: number): number | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== 'string' || !/^\d+$/.test(value)) return null;
|
||||
|
||||
const id = Number(value);
|
||||
return Number.isSafeInteger(id) && id >= minimum ? id : null;
|
||||
}
|
||||
|
||||
router.use((req, res, next) => {
|
||||
const accountId = parseOptionalId(req.query.accountId, 1);
|
||||
if (accountId === null) {
|
||||
res.status(400).json({ error: 'BAD_REQUEST', message: 'accountId must be a positive integer' });
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryId = parseOptionalId(req.query.categoryId, 0);
|
||||
if (categoryId === null) {
|
||||
res.status(400).json({ error: 'BAD_REQUEST', message: 'categoryId must be a non-negative integer' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/summary',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { from, to, accountId, onlyConfirmed } = req.query;
|
||||
const { from, to, accountId, categoryId, onlyConfirmed } = req.query;
|
||||
if (!from || !to) {
|
||||
res.status(400).json({ error: 'BAD_REQUEST', message: 'from and to are required' });
|
||||
return;
|
||||
@@ -18,6 +42,7 @@ router.get(
|
||||
from: from as string,
|
||||
to: to as string,
|
||||
accountId: accountId ? Number(accountId) : undefined,
|
||||
categoryId: categoryId !== undefined ? Number(categoryId) : undefined,
|
||||
onlyConfirmed: onlyConfirmed === 'true',
|
||||
});
|
||||
res.json(result);
|
||||
@@ -27,7 +52,7 @@ router.get(
|
||||
router.get(
|
||||
'/by-category',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { from, to, accountId, onlyConfirmed } = req.query;
|
||||
const { from, to, accountId, categoryId, onlyConfirmed } = req.query;
|
||||
if (!from || !to) {
|
||||
res.status(400).json({ error: 'BAD_REQUEST', message: 'from and to are required' });
|
||||
return;
|
||||
@@ -37,6 +62,7 @@ router.get(
|
||||
from: from as string,
|
||||
to: to as string,
|
||||
accountId: accountId ? Number(accountId) : undefined,
|
||||
categoryId: categoryId !== undefined ? Number(categoryId) : undefined,
|
||||
onlyConfirmed: onlyConfirmed === 'true',
|
||||
});
|
||||
res.json(result);
|
||||
@@ -62,7 +88,7 @@ router.get(
|
||||
from: from as string,
|
||||
to: to as string,
|
||||
accountId: accountId ? Number(accountId) : undefined,
|
||||
categoryId: categoryId ? Number(categoryId) : undefined,
|
||||
categoryId: categoryId !== undefined ? Number(categoryId) : undefined,
|
||||
onlyConfirmed: onlyConfirmed === 'true',
|
||||
granularity: granularity as Granularity,
|
||||
});
|
||||
|
||||
22
backend/src/routes/portfolio.ts
Normal file
22
backend/src/routes/portfolio.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Router } from 'express';
|
||||
import { asyncHandler } from '../utils';
|
||||
import * as portfolioService from '../services/portfolio';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
try {
|
||||
res.status(201).json(await portfolioService.importPortfolio(req.body));
|
||||
} catch (error) {
|
||||
if (error instanceof Error && /Invalid|Duplicate|must be|required|numeric/.test(error.message)) {
|
||||
res.status(422).json({ error: 'VALIDATION_ERROR', message: error.message });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { pool } from '../db/pool';
|
||||
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 {
|
||||
return {
|
||||
@@ -9,6 +9,8 @@ function toAccount(r: Record<string, unknown>): Account {
|
||||
accountNumberMasked: maskAccountNumber(r.account_number as string),
|
||||
currency: r.currency as string,
|
||||
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);
|
||||
}
|
||||
|
||||
export async function updateAccountAlias(
|
||||
export async function updateAccount(
|
||||
id: number,
|
||||
alias: string,
|
||||
accountType?: AccountType | null,
|
||||
status?: AccountStatus,
|
||||
): Promise<Account | null> {
|
||||
const { rows } = await pool.query(
|
||||
'UPDATE accounts SET alias = $1 WHERE id = $2 RETURNING *',
|
||||
[alias, id],
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
return toAccount(rows[0]);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const current = await client.query('SELECT account_type, status FROM accounts WHERE id = $1', [id]);
|
||||
if (current.rows.length === 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();
|
||||
}
|
||||
}
|
||||
|
||||
68
backend/src/services/analytics.integration.test.ts
Normal file
68
backend/src/services/analytics.integration.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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 client.query(
|
||||
"INSERT INTO transactions (account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed) VALUES ($1, '2026-07-06T12:00:00+03:00', 0, 200, 'Зачисление кэшбека', 'income', 'analytics-test-cashback', $2, TRUE)",
|
||||
[accountId, categoryId.income],
|
||||
);
|
||||
await insert(otherAccountId, '2026-07-01T12:00:00+03:00', -99_000, categoryId.expense, 'analytics-test-6');
|
||||
await insert(accountId, '2026-08-01T12:00:00+03:00', -7_000, categoryId.expense, 'analytics-test-7');
|
||||
await client.query(
|
||||
"INSERT INTO transactions (account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed) VALUES ($1, '2026-07-07T12:00:00+03:00', -2_500, 0, 'uncategorized', 'expense', 'analytics-test-uncategorized', NULL, FALSE)",
|
||||
[accountId],
|
||||
);
|
||||
|
||||
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, cashback: summary.cashbackIncome }, { expense: 6_000, income: 15_200, net: 9_200, transferOut: 3_000, interest: 1_500, cashback: 200 });
|
||||
const categorySummary = await getSummary({ ...params, categoryId: categoryId.expense }, client);
|
||||
assert.deepEqual({ expense: categorySummary.totalExpense, income: categorySummary.totalIncome }, { expense: 6_000, income: 0 });
|
||||
const uncategorized = await getSummary({ ...params, categoryId: 0, onlyConfirmed: false }, client);
|
||||
assert.equal(uncategorized.totalExpense, 2_500);
|
||||
assert.equal((await getSummary({ ...params, categoryId: 0, onlyConfirmed: true }, client)).totalExpense, 0);
|
||||
assert.deepEqual((await getByCategory({ ...params, categoryId: 0, onlyConfirmed: false }, client)).map((item) => item.amount), [2_500]);
|
||||
assert.deepEqual((await getByCategory({ ...params, categoryId: 0, onlyConfirmed: true }, client)), []);
|
||||
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);
|
||||
const partial = await getTimeseries({ ...params, from: '2026-07-02', to: '2026-07-03', granularity: 'month' }, client);
|
||||
assert.equal(partial[0].expenseAmount, 0);
|
||||
assert.equal(partial[0].incomeAmount, 20_000);
|
||||
const uncategorizedSeries = await getTimeseries({ ...params, categoryId: 0, onlyConfirmed: false, granularity: 'month' }, client);
|
||||
assert.equal(uncategorizedSeries[0].expenseAmount, 2_500);
|
||||
const confirmedUncategorizedSeries = await getTimeseries({ ...params, categoryId: 0, onlyConfirmed: true, granularity: 'month' }, client);
|
||||
assert.equal(confirmedUncategorizedSeries[0].expenseAmount, 0);
|
||||
} 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 { effectiveAmountCase } from './analyticsSemantics';
|
||||
import type {
|
||||
AnalyticsSummaryResponse,
|
||||
TopCategory,
|
||||
@@ -7,13 +8,43 @@ import type {
|
||||
Granularity,
|
||||
} from '@family-budget/shared';
|
||||
|
||||
type Queryable = Pick<typeof pool, 'query'>;
|
||||
|
||||
interface BaseParams {
|
||||
from: string;
|
||||
to: string;
|
||||
accountId?: number;
|
||||
categoryId?: number;
|
||||
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,
|
||||
CASE WHEN t.amount_signed = 0
|
||||
AND t.commission > 0
|
||||
AND t.description ILIKE '%зачисление%'
|
||||
THEN t.commission ELSE 0 END AS cashback_income
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON c.id = t.category_id
|
||||
LEFT JOIN accounts a ON a.id = t.account_id
|
||||
${where}
|
||||
)`;
|
||||
}
|
||||
|
||||
function buildBaseConditions(
|
||||
params: BaseParams,
|
||||
startIdx: number,
|
||||
@@ -35,6 +66,13 @@ function buildBaseConditions(
|
||||
values.push(params.accountId);
|
||||
idx++;
|
||||
}
|
||||
if (params.categoryId != null) {
|
||||
conditions.push(params.categoryId === 0 ? 't.category_id IS NULL' : `t.category_id = $${idx}`);
|
||||
if (params.categoryId !== 0) {
|
||||
values.push(params.categoryId);
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
if (params.onlyConfirmed) {
|
||||
conditions.push('t.is_category_confirmed = TRUE');
|
||||
}
|
||||
@@ -42,31 +80,51 @@ function buildBaseConditions(
|
||||
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 where = 'WHERE ' + conditions.join(' AND ');
|
||||
|
||||
const totalsResult = await pool.query(
|
||||
`SELECT
|
||||
COALESCE(SUM(CASE WHEN t.direction = 'expense' THEN ABS(t.amount_signed) ELSE 0 END), 0)::bigint AS total_expense,
|
||||
COALESCE(SUM(CASE WHEN t.direction = 'income' THEN t.amount_signed ELSE 0 END), 0)::bigint AS total_income
|
||||
FROM transactions t
|
||||
${where}`,
|
||||
const totalsResult = await db.query(
|
||||
`${analyticsTransactions(where)},
|
||||
category_net AS (
|
||||
SELECT category_id, category_name, analytic_type, SUM(effective_amount)::bigint AS amount,
|
||||
SUM(interest_income)::bigint AS interest_income,
|
||||
SUM(cashback_income)::bigint AS cashback_income
|
||||
FROM analytics_transactions
|
||||
GROUP BY category_id, category_name, analytic_type
|
||||
)
|
||||
SELECT
|
||||
COALESCE(SUM(GREATEST(-amount, 0)) FILTER (WHERE analytic_type = 'expense'), 0)::bigint AS total_expense,
|
||||
COALESCE(SUM(GREATEST(amount, 0)) FILTER (WHERE analytic_type = 'income'), 0)::bigint AS total_income,
|
||||
COALESCE((SELECT SUM(GREATEST(effective_amount, 0)) FROM analytics_transactions), 0)::bigint AS cash_inflow,
|
||||
COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions), 0)::bigint AS cash_outflow,
|
||||
COALESCE((SELECT SUM(GREATEST(effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_inflow,
|
||||
COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_outflow,
|
||||
COALESCE(SUM(interest_income), 0)::bigint AS interest_income,
|
||||
COALESCE(SUM(cashback_income), 0)::bigint AS cashback_income
|
||||
FROM category_net`,
|
||||
values,
|
||||
);
|
||||
|
||||
const totalExpense = Number(totalsResult.rows[0].total_expense);
|
||||
const totalIncome = Number(totalsResult.rows[0].total_income);
|
||||
const cashInflow = Number(totalsResult.rows[0].cash_inflow);
|
||||
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 cashbackIncome = Number(totalsResult.rows[0].cashback_income);
|
||||
|
||||
const topResult = await pool.query(
|
||||
`SELECT
|
||||
t.category_id,
|
||||
c.name AS category_name,
|
||||
SUM(ABS(t.amount_signed))::bigint AS amount
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON c.id = t.category_id
|
||||
${where} AND t.direction = 'expense' AND t.category_id IS NOT NULL
|
||||
GROUP BY t.category_id, c.name
|
||||
const topResult = await db.query(
|
||||
`${analyticsTransactions(where)}
|
||||
SELECT category_id::bigint, category_name, GREATEST(-SUM(effective_amount), 0)::bigint AS amount
|
||||
FROM analytics_transactions
|
||||
WHERE analytic_type = 'expense'
|
||||
GROUP BY category_id, category_name
|
||||
HAVING SUM(effective_amount) < 0
|
||||
ORDER BY amount DESC
|
||||
LIMIT 5`,
|
||||
values,
|
||||
@@ -83,36 +141,41 @@ export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryRe
|
||||
totalExpense,
|
||||
totalIncome,
|
||||
net: totalIncome - totalExpense,
|
||||
cashInflow,
|
||||
cashOutflow,
|
||||
transferInflow,
|
||||
transferOutflow,
|
||||
cashNet: cashInflow - cashOutflow,
|
||||
interestIncome,
|
||||
cashbackIncome,
|
||||
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 where = 'WHERE ' + conditions.join(' AND ');
|
||||
|
||||
const totalResult = await pool.query(
|
||||
`SELECT COALESCE(SUM(ABS(t.amount_signed)), 0)::bigint AS total
|
||||
FROM transactions t
|
||||
${where} AND t.direction = 'expense'`,
|
||||
values,
|
||||
);
|
||||
const total = Number(totalResult.rows[0].total);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT
|
||||
t.category_id,
|
||||
c.name AS category_name,
|
||||
SUM(ABS(t.amount_signed))::bigint AS amount,
|
||||
const { rows } = await db.query(
|
||||
`${analyticsTransactions(where)}
|
||||
SELECT
|
||||
category_id::bigint,
|
||||
category_name,
|
||||
GREATEST(-SUM(effective_amount), 0)::bigint AS amount,
|
||||
COUNT(*)::int AS tx_count
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON c.id = t.category_id
|
||||
${where} AND t.direction = 'expense'
|
||||
GROUP BY t.category_id, c.name
|
||||
FROM analytics_transactions
|
||||
WHERE analytic_type = 'expense'
|
||||
GROUP BY category_id, category_name
|
||||
HAVING SUM(effective_amount) < 0
|
||||
ORDER BY amount DESC`,
|
||||
values,
|
||||
);
|
||||
|
||||
const total = rows.reduce((sum, row) => sum + Number(row.amount), 0);
|
||||
|
||||
return rows.map((r) => ({
|
||||
categoryId: r.category_id != null ? Number(r.category_id) : 0,
|
||||
categoryName: r.category_name ?? 'Без категории',
|
||||
@@ -124,6 +187,7 @@ export async function getByCategory(params: BaseParams): Promise<ByCategoryItem[
|
||||
|
||||
export async function getTimeseries(
|
||||
params: BaseParams & { categoryId?: number; granularity: Granularity },
|
||||
db: Queryable = pool,
|
||||
): Promise<TimeseriesItem[]> {
|
||||
let truncExpr: string;
|
||||
let intervalStr: string;
|
||||
@@ -148,6 +212,8 @@ export async function getTimeseries(
|
||||
}
|
||||
|
||||
const txConditions: string[] = [
|
||||
't.operation_at::date >= $1::date',
|
||||
't.operation_at::date <= $2::date',
|
||||
't.operation_at::date >= p.period_start',
|
||||
't.operation_at::date <= p.period_end',
|
||||
];
|
||||
@@ -159,8 +225,11 @@ export async function getTimeseries(
|
||||
values.push(params.accountId);
|
||||
}
|
||||
if (params.categoryId != null) {
|
||||
txConditions.push(`t.category_id = $${idx++}`);
|
||||
values.push(params.categoryId);
|
||||
if (params.categoryId === 0) txConditions.push('t.category_id IS NULL');
|
||||
else {
|
||||
txConditions.push(`t.category_id = $${idx++}`);
|
||||
values.push(params.categoryId);
|
||||
}
|
||||
}
|
||||
if (params.onlyConfirmed) {
|
||||
txConditions.push('t.is_category_confirmed = TRUE');
|
||||
@@ -168,22 +237,38 @@ export async function getTimeseries(
|
||||
|
||||
const txWhere = txConditions.join(' AND ');
|
||||
|
||||
const { rows } = await pool.query(
|
||||
const { rows } = await db.query(
|
||||
`WITH periods AS (
|
||||
SELECT
|
||||
gs::date AS period_start,
|
||||
${periodEndExpr} AS period_end
|
||||
FROM generate_series(${truncExpr}, $2::date, '${intervalStr}'::interval) gs
|
||||
),
|
||||
period_transactions AS (
|
||||
SELECT
|
||||
p.period_start,
|
||||
p.period_end,
|
||||
COALESCE(c.type, t.direction) AS analytic_type,
|
||||
COALESCE(t.category_id, 0) AS category_id,
|
||||
${effectiveAmount} AS effective_amount
|
||||
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
|
||||
p.period_start,
|
||||
p.period_end,
|
||||
COALESCE(SUM(CASE WHEN t.direction = 'expense' THEN ABS(t.amount_signed) ELSE 0 END), 0)::bigint AS expense_amount,
|
||||
COALESCE(SUM(CASE WHEN t.direction = 'income' THEN t.amount_signed ELSE 0 END), 0)::bigint AS income_amount
|
||||
FROM periods p
|
||||
LEFT JOIN transactions t ON ${txWhere}
|
||||
GROUP BY p.period_start, p.period_end
|
||||
ORDER BY p.period_start`,
|
||||
period_start,
|
||||
period_end,
|
||||
COALESCE(SUM(GREATEST(-amount, 0)) FILTER (WHERE analytic_type = 'expense'), 0)::bigint AS expense_amount,
|
||||
COALESCE(SUM(GREATEST(amount, 0)) FILTER (WHERE analytic_type = 'income'), 0)::bigint AS income_amount,
|
||||
COALESCE(SUM(GREATEST(-amount, 0)) FILTER (WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_outflow
|
||||
FROM period_category_net
|
||||
GROUP BY period_start, period_end
|
||||
ORDER BY period_start`,
|
||||
values,
|
||||
);
|
||||
|
||||
@@ -192,5 +277,6 @@ export async function getTimeseries(
|
||||
periodEnd: r.period_end.toISOString().slice(0, 10),
|
||||
expenseAmount: Number(r.expense_amount),
|
||||
incomeAmount: Number(r.income_amount),
|
||||
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`;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { pool } from '../db/pool';
|
||||
import { config } from '../config';
|
||||
import type { LoginRequest, MeResponse } from '@family-budget/shared';
|
||||
import backendPackage from '../../package.json';
|
||||
|
||||
export async function login(
|
||||
body: LoginRequest,
|
||||
@@ -26,5 +27,8 @@ export async function logout(sessionId: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function me(sessionId: string): Promise<MeResponse> {
|
||||
return { login: config.appUserLogin };
|
||||
return {
|
||||
login: config.appUserLogin,
|
||||
backendVersion: backendPackage.version,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,22 +55,38 @@ export async function getRules(params: GetCategoryRulesParams): Promise<Category
|
||||
|
||||
export async function createRule(
|
||||
body: CreateCategoryRuleRequest,
|
||||
): Promise<CategoryRule> {
|
||||
): Promise<CategoryRule & { applied: number }> {
|
||||
const pattern = body.pattern.trim();
|
||||
const matchType = body.matchType ?? 'contains';
|
||||
const priority = body.priority ?? 100;
|
||||
const requiresConfirmation = body.requiresConfirmation ?? false;
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO category_rules (pattern, match_type, category_id, priority, requires_confirmation)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[pattern, matchType, body.categoryId, priority, requiresConfirmation],
|
||||
);
|
||||
|
||||
const catResult = await pool.query('SELECT name FROM categories WHERE id = $1', [body.categoryId]);
|
||||
rows[0].category_name = catResult.rows[0].name;
|
||||
return toRule(rows[0]);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO category_rules (pattern, match_type, category_id, priority, requires_confirmation)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[pattern, matchType, body.categoryId, priority, requiresConfirmation],
|
||||
);
|
||||
const match = matchType === 'starts_with'
|
||||
? `t.description ILIKE $1 || '%'`
|
||||
: `t.description ILIKE '%' || $1 || '%'`;
|
||||
const applied = await client.query(
|
||||
`UPDATE transactions t SET category_id = $2, is_category_confirmed = $3, updated_at = NOW()
|
||||
WHERE (t.category_id IS NULL OR t.is_category_confirmed = FALSE) AND ${match}`,
|
||||
[pattern, body.categoryId, !requiresConfirmation],
|
||||
);
|
||||
const catResult = await client.query('SELECT name FROM categories WHERE id = $1', [body.categoryId]);
|
||||
rows[0].category_name = catResult.rows[0].name;
|
||||
await client.query('COMMIT');
|
||||
return { ...toRule(rows[0]), applied: applied.rowCount ?? 0 };
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateRule(
|
||||
|
||||
66
backend/src/services/import.integration.test.ts
Normal file
66
backend/src/services/import.integration.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { pool } from '../db/pool';
|
||||
import { importStatement } from './import';
|
||||
|
||||
const makeStatement = (sourceIds: 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: sourceIds.map((sourceId) => ({ operationAt: '2026-08-20T10:00:00+03:00', amountSigned: 100, commission: 0, description: 'Пополнение', sourceId })),
|
||||
});
|
||||
|
||||
const duplicateStatement = {
|
||||
schemaVersion: '1.0', bank: 'TEST',
|
||||
statement: { accountNumber: 'fingerprint-test', currency: 'RUB', openingBalance: 0, closingBalance: 200, exportedAt: '2026-08-20T12:00:00+03:00' },
|
||||
transactions: Array.from({ length: 2 }, () => ({ operationAt: '2026-08-20T00:00:00+03:00', amountSigned: 100, commission: 0, description: 'Пополнение' })),
|
||||
};
|
||||
const overlapStatement = {
|
||||
...duplicateStatement,
|
||||
statement: { ...duplicateStatement.statement, accountNumber: 'overlap-test' },
|
||||
};
|
||||
const singleStatement = {
|
||||
...overlapStatement,
|
||||
statement: { ...overlapStatement.statement, closingBalance: 100 },
|
||||
transactions: overlapStatement.transactions.slice(0, 1),
|
||||
};
|
||||
|
||||
async function run(): Promise<void> {
|
||||
try {
|
||||
await importStatement(makeStatement(['first', 'first-second']));
|
||||
const positions = await pool.query("SELECT source_position FROM transactions t JOIN accounts a ON a.id = t.account_id WHERE a.bank = 'TEST' AND a.account_number = 'metadata-test' ORDER BY source_position");
|
||||
assert.deepEqual(positions.rows.map((row) => Number(row.source_position)), [0, 1]);
|
||||
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' });
|
||||
const duplicateResult = await importStatement(duplicateStatement);
|
||||
if ('status' in duplicateResult) throw new Error(duplicateResult.message);
|
||||
assert.deepEqual(duplicateResult, {
|
||||
accountId: duplicateResult.accountId,
|
||||
isNewAccount: true,
|
||||
accountNumberMasked: 'fingerp******test',
|
||||
imported: 2,
|
||||
duplicatesSkipped: 0,
|
||||
totalInFile: 2,
|
||||
});
|
||||
await importStatement(singleStatement);
|
||||
const overlapResult = await importStatement(overlapStatement);
|
||||
if ('status' in overlapResult) throw new Error(overlapResult.message);
|
||||
assert.deepEqual(overlapResult, {
|
||||
accountId: overlapResult.accountId,
|
||||
isNewAccount: false,
|
||||
accountNumberMasked: 'overla******test',
|
||||
imported: 1,
|
||||
duplicatesSkipped: 1,
|
||||
totalInFile: 2,
|
||||
});
|
||||
console.log('import metadata SQL: OK');
|
||||
} finally {
|
||||
await pool.query("DELETE FROM transactions WHERE account_id IN (SELECT id FROM accounts WHERE bank = 'TEST' AND account_number IN ('metadata-test', 'fingerprint-test', 'overlap-test'))");
|
||||
await pool.query("DELETE FROM imports WHERE account_id IN (SELECT id FROM accounts WHERE bank = 'TEST' AND account_number IN ('metadata-test', 'fingerprint-test', 'overlap-test'))");
|
||||
await pool.query("DELETE FROM accounts WHERE bank = 'TEST' AND account_number IN ('metadata-test', 'fingerprint-test', 'overlap-test')");
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
12
backend/src/services/import.test.ts
Normal file
12
backend/src/services/import.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { computeFingerprint, determineDirection } from './import';
|
||||
|
||||
assert.equal(determineDirection(1, 'Перечисление средств на счет N 123 со счета N 456'), 'transfer');
|
||||
assert.equal(determineDirection(1, 'Перечисление средств на вклад N 123'), 'transfer');
|
||||
assert.equal(determineDirection(-1, 'Перечисление суммы вклада при закрытии'), 'transfer');
|
||||
assert.equal(determineDirection(-1, 'Оплата покупки'), 'expense');
|
||||
assert.equal(determineDirection(1, 'Выплата процентов'), 'income');
|
||||
const duplicateTransaction = { operationAt: '2026-08-20T00:00:00+03:00', amountSigned: 100, commission: 0, description: 'Пополнение' };
|
||||
assert.notEqual(computeFingerprint('fingerprint-test', duplicateTransaction, 0), computeFingerprint('fingerprint-test', duplicateTransaction, 1));
|
||||
|
||||
console.log('import direction: OK');
|
||||
@@ -6,25 +6,37 @@ import type { StatementFile, ImportStatementResponse } from '@family-budget/shar
|
||||
const TRANSFER_PHRASES = [
|
||||
'перевод между своими счетами',
|
||||
'перевод средств на счет',
|
||||
'перечисление средств на счет',
|
||||
'перечисление средств на вклад',
|
||||
'перечисление суммы вклада при закрытии',
|
||||
'внутри втб',
|
||||
];
|
||||
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(
|
||||
export function computeFingerprint(
|
||||
accountNumber: string,
|
||||
tx: { operationAt: string; amountSigned: number; commission: number; description: string },
|
||||
tx: { operationAt: string; amountSigned: number; commission: number; description: string; sourceId?: string },
|
||||
sourcePosition?: number,
|
||||
): 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 = [
|
||||
accountNumber,
|
||||
tx.operationAt,
|
||||
String(tx.amountSigned),
|
||||
String(tx.commission),
|
||||
tx.description.trim(),
|
||||
...(sourcePosition === undefined ? [] : [String(sourcePosition)]),
|
||||
].join('|');
|
||||
const hash = crypto.createHash('sha256').update(raw, 'utf-8').digest('hex');
|
||||
return `sha256:${hash}`;
|
||||
}
|
||||
|
||||
function determineDirection(amountSigned: number, description: string): string {
|
||||
export function determineDirection(amountSigned: number, description: string): string {
|
||||
const lower = description.toLowerCase();
|
||||
for (const phrase of TRANSFER_PHRASES) {
|
||||
if (lower.includes(phrase)) return 'transfer';
|
||||
@@ -84,15 +96,31 @@ function validateStructure(body: unknown): ValidationError | null {
|
||||
if (typeof t.operationAt !== 'string' || !ISO_WITH_OFFSET.test(t.operationAt)) {
|
||||
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].operationAt must be ISO 8601 with offset` };
|
||||
}
|
||||
if (typeof t.amountSigned !== 'number' || !Number.isInteger(t.amountSigned) || t.amountSigned === 0) {
|
||||
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].amountSigned must be a non-zero integer` };
|
||||
}
|
||||
if (typeof t.commission !== 'number' || !Number.isInteger(t.commission) || t.commission < 0) {
|
||||
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].commission must be a non-negative integer` };
|
||||
}
|
||||
if (typeof t.description !== 'string' || !t.description) {
|
||||
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)) {
|
||||
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].amountSigned must be an integer` };
|
||||
}
|
||||
if (t.amountSigned === 0) {
|
||||
const hasCashbackMarker = t.description.toLowerCase().includes(CASHBACK_KEYWORD);
|
||||
if (t.commission <= 0 || !hasCashbackMarker) {
|
||||
return {
|
||||
status: 400,
|
||||
error: 'BAD_REQUEST',
|
||||
message: `transactions[${i}] with amountSigned=0 must have commission>0 and contain 'Зачисление' in description`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -110,12 +138,20 @@ function validateSemantics(data: StatementFile): ValidationError | null {
|
||||
}
|
||||
|
||||
const fps = new Set<string>();
|
||||
const operationIds = new Set<string>();
|
||||
for (let i = 0; i < data.transactions.length; i++) {
|
||||
const fp = computeFingerprint(data.statement.accountNumber, data.transactions[i]);
|
||||
if (fps.has(fp)) {
|
||||
if (data.transactions[i].sourceId && fps.has(fp)) {
|
||||
return { status: 422, error: 'VALIDATION_ERROR', message: `Duplicate fingerprint found within file at transaction index ${i}` };
|
||||
}
|
||||
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;
|
||||
@@ -123,6 +159,7 @@ function validateSemantics(data: StatementFile): ValidationError | null {
|
||||
|
||||
export async function importStatement(
|
||||
body: unknown,
|
||||
db: Pick<typeof pool, 'connect'> = pool,
|
||||
): Promise<ImportStatementResponse | ValidationError> {
|
||||
const structErr = validateStructure(body);
|
||||
if (structErr) return structErr;
|
||||
@@ -131,7 +168,7 @@ export async function importStatement(
|
||||
const semErr = validateSemantics(data);
|
||||
if (semErr) return semErr;
|
||||
|
||||
const client = await pool.connect();
|
||||
const client = await db.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
@@ -140,7 +177,7 @@ export async function importStatement(
|
||||
let isNewAccount = false;
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
@@ -164,21 +201,56 @@ export async function importStatement(
|
||||
[accountId, data.bank, accountNumberMasked, data.transactions.length],
|
||||
);
|
||||
const importId = Number(importResult.rows[0].id);
|
||||
const incomeCategoryResult = await client.query(
|
||||
`SELECT id
|
||||
FROM categories
|
||||
WHERE name = 'Поступления' AND type = 'income' AND is_active = TRUE
|
||||
ORDER BY id ASC
|
||||
LIMIT 1`,
|
||||
);
|
||||
if (incomeCategoryResult.rows.length === 0) {
|
||||
throw new Error("Category 'Поступления' is missing");
|
||||
}
|
||||
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
|
||||
const insertedIds: number[] = [];
|
||||
const fallbackFingerprintOccurrences = new Map<string, number>();
|
||||
|
||||
for (const tx of data.transactions) {
|
||||
const fp = computeFingerprint(data.statement.accountNumber, tx);
|
||||
const dir = determineDirection(tx.amountSigned, tx.description);
|
||||
for (const [sourcePosition, tx] of data.transactions.entries()) {
|
||||
const fallbackFingerprint = computeFingerprint(data.statement.accountNumber, tx);
|
||||
const occurrence = fallbackFingerprintOccurrences.get(fallbackFingerprint) ?? 0;
|
||||
fallbackFingerprintOccurrences.set(fallbackFingerprint, occurrence + 1);
|
||||
const fp = !tx.sourceId && occurrence > 0
|
||||
? computeFingerprint(data.statement.accountNumber, tx, occurrence)
|
||||
: fallbackFingerprint;
|
||||
const isCashbackCommissionImport =
|
||||
tx.amountSigned === 0 &&
|
||||
tx.commission > 0 &&
|
||||
tx.description.toLowerCase().includes(CASHBACK_KEYWORD);
|
||||
const dir = isCashbackCommissionImport
|
||||
? 'income'
|
||||
: isInvestmentAccount ? 'transfer' : determineDirection(tx.amountSigned, tx.description);
|
||||
const categoryId = isCashbackCommissionImport
|
||||
? incomeCategoryId
|
||||
: isInvestmentAccount ? investmentCategoryId : null;
|
||||
const isCategoryConfirmed = isCashbackCommissionImport;
|
||||
|
||||
const result = await client.query(
|
||||
`INSERT INTO transactions
|
||||
(account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed, import_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, FALSE, $8)
|
||||
ON CONFLICT (account_id, fingerprint) DO NOTHING
|
||||
(account_id, operation_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed, import_id, source_position)
|
||||
VALUES ($1, COALESCE($2::uuid, gen_random_uuid()), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id`,
|
||||
[accountId, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, importId],
|
||||
[accountId, tx.operationId ?? null, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, categoryId, isCategoryConfirmed, importId, sourcePosition],
|
||||
);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
@@ -213,11 +285,19 @@ export async function importStatement(
|
||||
OR (cr.match_type = 'starts_with' AND t2.description ILIKE cr.pattern || '%')
|
||||
)
|
||||
WHERE t2.id = ANY($1::bigint[])
|
||||
AND t2.is_category_confirmed = FALSE
|
||||
ORDER BY t2.id, cr.priority DESC, cr.id ASC
|
||||
) sub
|
||||
WHERE t.id = sub.tx_id`,
|
||||
[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');
|
||||
|
||||
@@ -19,7 +19,7 @@ const PDF2JSON_PROMPT = `Ты — конвертер банковских вып
|
||||
"transactions": [
|
||||
{
|
||||
"operationAt": "<дата и время операции в формате ISO 8601 с offset>",
|
||||
"amountSigned": <число: положительное для прихода, отрицательное для расхода; в копейках>,
|
||||
"amountSigned": <число: положительное для прихода, отрицательное для расхода; 0 допустим только если сумма фактически в commission и в description есть «Зачисление»>,
|
||||
"commission": <число, целое, >= 0, в копейках>,
|
||||
"description": "<полное описание операции из выписки>"
|
||||
}
|
||||
@@ -30,6 +30,7 @@ const PDF2JSON_PROMPT = `Ты — конвертер банковских вып
|
||||
|
||||
1. Суммы — всегда в копейках (рубли × 100). Пример: 500,00 ₽ → 50000, -1234,56 ₽ → -123456.
|
||||
2. amountSigned: приход — положительное, расход — отрицательное.
|
||||
amountSigned = 0 допускается только для кейса кэшбэка/зачисления, когда сумма указана в commission и в description есть «Зачисление».
|
||||
3. operationAt — дата и время, если не указано — 00:00:00, offset +03:00 для МСК.
|
||||
4. commission — если не указана — 0.
|
||||
5. description — полный текст операции как в выписке.
|
||||
|
||||
29
backend/src/services/portfolio.integration.test.ts
Normal file
29
backend/src/services/portfolio.integration.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { pool } from '../db/pool';
|
||||
import { importPortfolio } from './portfolio';
|
||||
|
||||
const payload = {
|
||||
schemaVersion: 'broker-portfolio-1.0', bank: 'PORTFOLIO_TEST', accountNumber: 'portfolio-test',
|
||||
reportPeriod: { from: '2026-08-01', to: '2026-08-20' }, reportedAt: '2026-08-20T00:00:00+03:00',
|
||||
positions: [{ instrument: 'Test Bond', isin: 'RU0000000001', quantity: '1.000', price: '100.123456', valuation: '100.123456' }],
|
||||
trades: [{ instrument: 'Test Bond', isin: 'RU0000000001', concludedAt: '2026-08-10T10:00:00+03:00', side: 'Покупка', quantity: '1.000', settlementAmount: '100.123456' }],
|
||||
};
|
||||
|
||||
async function run(): Promise<void> {
|
||||
try {
|
||||
const first = await importPortfolio(payload);
|
||||
const second = await importPortfolio(payload);
|
||||
assert.equal(first.importedTrades, 1);
|
||||
assert.equal(second.duplicateTrades, 1);
|
||||
const rows = await pool.query('SELECT COUNT(*)::int AS count FROM portfolio_trades WHERE account_id = $1', [first.accountId]);
|
||||
assert.equal(rows.rows[0].count, 1);
|
||||
console.log('portfolio import SQL: OK');
|
||||
} finally {
|
||||
await pool.query("DELETE FROM portfolio_trades WHERE account_id IN (SELECT id FROM accounts WHERE bank = 'PORTFOLIO_TEST' AND account_number = 'portfolio-test')");
|
||||
await pool.query("DELETE FROM portfolio_reports WHERE account_id IN (SELECT id FROM accounts WHERE bank = 'PORTFOLIO_TEST' AND account_number = 'portfolio-test')");
|
||||
await pool.query("DELETE FROM accounts WHERE bank = 'PORTFOLIO_TEST' AND account_number = 'portfolio-test'");
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
13
backend/src/services/portfolio.test.ts
Normal file
13
backend/src/services/portfolio.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { deriveTradeSourceId, validatePortfolio } from './portfolio';
|
||||
|
||||
const trade = {
|
||||
instrument: 'Bond', isin: 'RU0000000001', concludedAt: '2026-08-20T10:00:00+03:00',
|
||||
side: 'Покупка', quantity: '1.000', settlementAmount: '1234.50',
|
||||
};
|
||||
|
||||
assert.equal(deriveTradeSourceId(trade), 'RU0000000001|2026-08-20T10:00:00+03:00|Покупка|1.000|1234.50');
|
||||
assert.throws(() => validatePortfolio(null), /object/);
|
||||
assert.throws(() => validatePortfolio({ schemaVersion: 'broker-portfolio-1.0', bank: 'B', accountNumber: 'A', reportPeriod: { from: '2026-08-20', to: '2026-08-20' }, reportedAt: null, positions: [], trades: [{ ...trade, sourceId: 42 }] }), /sourceId/);
|
||||
assert.throws(() => validatePortfolio({ ...{ schemaVersion: 'broker-portfolio-1.0', bank: 'B', accountNumber: 'A', reportPeriod: { from: '2026-08-21', to: '2026-08-20' }, reportedAt: null, positions: [], trades: [] } }), /period/);
|
||||
console.log('portfolio validation: OK');
|
||||
103
backend/src/services/portfolio.ts
Normal file
103
backend/src/services/portfolio.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import crypto from 'crypto';
|
||||
import { pool } from '../db/pool';
|
||||
import type { ImportPortfolioResponse, PortfolioFile, PortfolioTrade } from '@family-budget/shared';
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function decimalValue(value: unknown, field: string, required = false): string | null {
|
||||
if (value == null || value === '') {
|
||||
if (required) throw new Error(`${field} is required`);
|
||||
return null;
|
||||
}
|
||||
const result = String(value).replace(/\s/g, '').replace(',', '.');
|
||||
if (!/^-?(?:\d+\.?\d*|\.\d+)$/.test(result)) throw new Error(`${field} must be numeric`);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function deriveTradeSourceId(trade: PortfolioTrade): string {
|
||||
if (trade.sourceId?.trim()) return trade.sourceId.trim();
|
||||
return [trade.isin || trade.instrument, trade.concludedAt, trade.side, decimalValue(trade.quantity, 'quantity', true), decimalValue(trade.settlementAmount, 'settlementAmount') || ''].join('|');
|
||||
}
|
||||
|
||||
export function validatePortfolio(body: unknown): asserts body is PortfolioFile {
|
||||
if (!body || typeof body !== 'object') throw new Error('Portfolio file must be an object');
|
||||
const data = body as PortfolioFile;
|
||||
if (data.schemaVersion !== 'broker-portfolio-1.0' || typeof data.bank !== 'string' || !data.bank.trim() || typeof data.accountNumber !== 'string' || !data.accountNumber.trim()) throw new Error('Invalid portfolio file');
|
||||
if (!data.reportPeriod?.from || !data.reportPeriod?.to || Number.isNaN(Date.parse(data.reportPeriod.from)) || Number.isNaN(Date.parse(data.reportPeriod.to)) || data.reportPeriod.from > data.reportPeriod.to) throw new Error('Invalid report period');
|
||||
if (data.reportedAt !== null && (typeof data.reportedAt !== 'string' || Number.isNaN(Date.parse(data.reportedAt)))) throw new Error('Invalid reportedAt');
|
||||
if (!Array.isArray(data.positions) || !Array.isArray(data.trades)) throw new Error('positions and trades must be arrays');
|
||||
const sourceIds = new Set<string>();
|
||||
for (const trade of data.trades) {
|
||||
if (!trade || typeof trade.instrument !== 'string' || !trade.instrument.trim() || typeof trade.concludedAt !== 'string' || Number.isNaN(Date.parse(trade.concludedAt)) || typeof trade.side !== 'string' || !trade.side.trim()) throw new Error('Invalid trade');
|
||||
if (trade.sourceId !== undefined && typeof trade.sourceId !== 'string') throw new Error('sourceId must be a string');
|
||||
if (trade.operationId !== undefined && typeof trade.operationId !== 'string') throw new Error('operationId must be a string');
|
||||
const sourceId = deriveTradeSourceId(trade);
|
||||
if (sourceIds.has(sourceId)) throw new Error(`Duplicate sourceId: ${sourceId}`);
|
||||
sourceIds.add(sourceId);
|
||||
if (trade.operationId && !UUID_RE.test(trade.operationId)) throw new Error(`Invalid operationId: ${trade.sourceId}`);
|
||||
decimalValue(trade.quantity, 'quantity', true);
|
||||
}
|
||||
for (const position of data.positions) {
|
||||
if (!position || typeof position.instrument !== 'string' || !position.instrument.trim()) throw new Error('Invalid position');
|
||||
decimalValue(position.quantity, 'position.quantity', true);
|
||||
decimalValue(position.price, 'position.price');
|
||||
decimalValue(position.valuation, 'position.valuation');
|
||||
}
|
||||
}
|
||||
|
||||
export async function importPortfolio(body: unknown, db: Pick<typeof pool, 'connect'> = pool): Promise<ImportPortfolioResponse> {
|
||||
validatePortfolio(body);
|
||||
const data = body;
|
||||
const sourceHash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex');
|
||||
const client = await db.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const accountResult = await client.query(
|
||||
`INSERT INTO accounts (bank, account_number, currency, account_type)
|
||||
VALUES ($1, $2, 'RUB', 'brokerage')
|
||||
ON CONFLICT (bank, account_number) DO UPDATE SET account_type = COALESCE(accounts.account_type, 'brokerage')
|
||||
RETURNING id`,
|
||||
[data.bank, data.accountNumber],
|
||||
);
|
||||
const accountId = Number(accountResult.rows[0].id);
|
||||
const reportResult = await client.query(
|
||||
`INSERT INTO portfolio_reports (account_id, source_hash, report_period_from, report_period_to, reported_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (account_id, source_hash) DO NOTHING
|
||||
RETURNING id`,
|
||||
[accountId, sourceHash, data.reportPeriod.from, data.reportPeriod.to, data.reportedAt],
|
||||
);
|
||||
if (reportResult.rows.length === 0) {
|
||||
const existing = await client.query('SELECT id FROM portfolio_reports WHERE account_id = $1 AND source_hash = $2', [accountId, sourceHash]);
|
||||
await client.query('COMMIT');
|
||||
return { accountId, reportId: Number(existing.rows[0].id), importedTrades: 0, duplicateTrades: data.trades.length, positions: 0 };
|
||||
}
|
||||
const reportId = Number(reportResult.rows[0].id);
|
||||
for (const position of data.positions) {
|
||||
await client.query(
|
||||
`INSERT INTO portfolio_positions (report_id, instrument, isin, quantity, price, valuation)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[reportId, position.instrument, position.isin ?? null, decimalValue(position.quantity, 'position.quantity', true), decimalValue(position.price, 'position.price'), decimalValue(position.valuation, 'position.valuation')],
|
||||
);
|
||||
}
|
||||
let importedTrades = 0;
|
||||
for (const trade of data.trades) {
|
||||
const sourceId = deriveTradeSourceId(trade);
|
||||
const result = await client.query(
|
||||
`INSERT INTO portfolio_trades
|
||||
(account_id, source_id, operation_id, instrument, isin, concluded_at, side, quantity, price_currency, price, settlement_currency, settlement_amount, nkd, settlement_commission, trade_commission, order_id, trade_id, venue, comment)
|
||||
VALUES ($1, $2, COALESCE($3::uuid, gen_random_uuid()), $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||
ON CONFLICT (account_id, source_id) DO NOTHING`,
|
||||
[accountId, sourceId, trade.operationId ?? null, trade.instrument, trade.isin ?? null, trade.concludedAt, trade.side, decimalValue(trade.quantity, 'quantity', true), trade.priceCurrency ?? null, decimalValue(trade.price, 'price'), trade.settlementCurrency ?? null, decimalValue(trade.settlementAmount, 'settlementAmount'), decimalValue(trade.nkd, 'nkd'), decimalValue(trade.settlementCommission, 'settlementCommission'), decimalValue(trade.tradeCommission, 'tradeCommission'), trade.orderId ?? null, trade.tradeId ?? null, trade.venue ?? null, trade.comment ?? null],
|
||||
);
|
||||
importedTrades += result.rowCount ?? 0;
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return { accountId, reportId, importedTrades, duplicateTrades: data.trades.length - importedTrades, positions: data.positions.length };
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
13
backend/src/services/transactions.test.ts
Normal file
13
backend/src/services/transactions.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { transactionOrderBy } from './transactions';
|
||||
|
||||
assert.equal(
|
||||
transactionOrderBy('date', 'desc'),
|
||||
't.operation_at DESC, t.source_position ASC, t.id ASC',
|
||||
);
|
||||
assert.equal(
|
||||
transactionOrderBy('date', 'asc'),
|
||||
't.operation_at ASC, t.source_position DESC, t.id DESC',
|
||||
);
|
||||
assert.match(transactionOrderBy('amount', 'desc'), /t\.amount_signed DESC.*t\.operation_at DESC.*t\.source_position ASC.*t\.id DESC/);
|
||||
console.log('transaction ordering: OK');
|
||||
@@ -7,13 +7,18 @@ import type {
|
||||
UpdateTransactionRequest,
|
||||
} from '@family-budget/shared';
|
||||
|
||||
export function transactionOrderBy(sortBy: 'date' | 'amount', sortOrder: 'asc' | 'desc'): string {
|
||||
const direction = sortOrder === 'asc' ? 'ASC' : 'DESC';
|
||||
if (sortBy === 'amount') return `t.amount_signed ${direction}, t.operation_at DESC, t.source_position ASC, t.id DESC`;
|
||||
return `t.operation_at ${direction}, t.source_position ${direction === 'ASC' ? 'DESC' : 'ASC'}, t.id ${direction === 'ASC' ? 'DESC' : 'ASC'}`;
|
||||
}
|
||||
|
||||
export async function getTransactions(
|
||||
params: GetTransactionsParams,
|
||||
): Promise<PaginatedResponse<Transaction>> {
|
||||
const page = params.page ?? 1;
|
||||
const pageSize = [10, 50, 100].includes(params.pageSize ?? 50) ? (params.pageSize ?? 50) : 50;
|
||||
const sortBy = params.sortBy === 'amount' ? 't.amount_signed' : 't.operation_at';
|
||||
const sortOrder = params.sortOrder === 'asc' ? 'ASC' : 'DESC';
|
||||
const orderBy = transactionOrderBy(params.sortBy === 'amount' ? 'amount' : 'date', params.sortOrder === 'asc' ? 'asc' : 'desc');
|
||||
|
||||
const conditions: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
@@ -84,7 +89,7 @@ export async function getTransactions(
|
||||
JOIN accounts a ON a.id = t.account_id
|
||||
LEFT JOIN categories c ON c.id = t.category_id
|
||||
${where}
|
||||
ORDER BY ${sortBy} ${sortOrder}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT $${idx++} OFFSET $${idx++}`,
|
||||
[...values, pageSize, offset],
|
||||
);
|
||||
|
||||
@@ -7,13 +7,8 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
container_name: family-budget-backend
|
||||
environment:
|
||||
# Имя контейнера/сервиса PostgreSQL — postgres или postgres_budget
|
||||
- DB_HOST=postgres_budget
|
||||
- DB_PORT=5432
|
||||
- DB_NAME=family_budget
|
||||
- DB_USER=budget_user
|
||||
- DB_PASSWORD=difficult_Paaaaaasword
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -37,6 +37,26 @@
|
||||
- Чистый результат (`net = totalIncome - totalExpense`).
|
||||
- Топ-3–5 категорий по расходам (сумма, доля).
|
||||
|
||||
#### Нетто-правила
|
||||
|
||||
Тип назначенной категории определяет экономический смысл операции:
|
||||
|
||||
- `expense`: отрицательная сумма увеличивает расход, положительная сумма
|
||||
(возврат) уменьшает его; в отчёте категория не может стать отрицательным
|
||||
расходом;
|
||||
- `income`: положительная сумма увеличивает доход, отрицательная уменьшает
|
||||
его;
|
||||
- `transfer`: не учитывается в реальных доходах и расходах.
|
||||
|
||||
Если возврат не удалось определить правилом, пользователь вручную назначает
|
||||
ему ту же расходную категорию, что у исходной покупки. Отдельная категория
|
||||
«Возврат» и связывание двух операций не требуются.
|
||||
|
||||
Для контроля остатков сводка отдельно показывает движение денежных средств:
|
||||
входящий и исходящий поток, внутренние переводы и чистое изменение денег.
|
||||
Переводы между картой, накопительными и инвестиционными счетами остаются в
|
||||
этом блоке, но не искажают доходы и расходы.
|
||||
|
||||
Реализуется через эндпоинт `GET /api/analytics/summary`.
|
||||
|
||||
Параметры:
|
||||
@@ -52,6 +72,11 @@
|
||||
"totalExpense": 12345600,
|
||||
"totalIncome": 20000000,
|
||||
"net": 7654400,
|
||||
"cashInflow": 26000000,
|
||||
"cashOutflow": 18345600,
|
||||
"transferInflow": 5000000,
|
||||
"transferOutflow": 5000000,
|
||||
"cashNet": 7654400,
|
||||
"topCategories": [
|
||||
{ "categoryId": 1, "categoryName": "Продукты", "amount": 4500000, "share": 0.36 },
|
||||
{ "categoryId": 2, "categoryName": "ЖКХ", "amount": 2500000, "share": 0.20 }
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
|
||||
- `statement.currency` соответствует допустимому коду валюты (MVP: `"RUB"`).
|
||||
- `operationAt` у всех транзакций — валидная дата (парсится без ошибок).
|
||||
- Отсутствуют дубликаты fingerprint внутри одного файла.
|
||||
- Повторяющиеся `sourceId` внутри одного файла отклоняются; одинаковые операции без `sourceId` различаются по позиции в массиве `transactions`.
|
||||
|
||||
Ответ при ошибке:
|
||||
|
||||
@@ -120,10 +120,11 @@
|
||||
Для каждой транзакции вычисляется SHA-256 от полей, соединённых разделителем `|`:
|
||||
|
||||
```text
|
||||
accountNumber|operationAt|amountSigned|commission|normalizedDescription
|
||||
accountNumber|operationAt|amountSigned|commission|normalizedDescription[|sourcePosition]
|
||||
```
|
||||
|
||||
- `normalizedDescription` — `description` после `trim`.
|
||||
- `sourcePosition` — порядковый номер повторяющейся операции в массиве `transactions`; добавляется, только если одинаковые операции без `sourceId` повторяются в одном файле.
|
||||
- Суммы подставляются в том виде, в котором пришли в JSON (числовое представление).
|
||||
- Разделитель `|` исключает коллизии при склейке полей разной длины.
|
||||
|
||||
@@ -141,6 +142,12 @@ accountNumber|operationAt|amountSigned|commission|normalizedDescription
|
||||
|
||||
Список ключевых фраз для `"transfer"` может расширяться; в MVP используется фиксированный набор.
|
||||
|
||||
`direction` — исходная банковская классификация. В аналитике окончательный
|
||||
экономический тип определяется типом назначенной категории: операция,
|
||||
отнесённая к расходной категории, является расходом или возвратом независимо
|
||||
от исходного `direction`; категория типа `transfer` исключает операцию из
|
||||
реальных доходов и расходов.
|
||||
|
||||
### Импорт транзакций
|
||||
|
||||
Для каждой транзакции из массива `transactions`:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.11.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -10,7 +10,7 @@ export function App() {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <div className="app-loading">Загрузка...</div>;
|
||||
return <div className="app-state app-state--loading">Загрузка...</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
|
||||
6
frontend/src/api/portfolio.ts
Normal file
6
frontend/src/api/portfolio.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { ImportPortfolioResponse, PortfolioFile } from '@family-budget/shared';
|
||||
import { api } from './client';
|
||||
|
||||
export function importPortfolio(data: PortfolioFile): Promise<ImportPortfolioResponse> {
|
||||
return api.post('/api/import/portfolio', data);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
CategoryRule,
|
||||
CreateCategoryRuleResponse,
|
||||
GetCategoryRulesParams,
|
||||
CreateCategoryRuleRequest,
|
||||
UpdateCategoryRuleRequest,
|
||||
@@ -24,8 +25,8 @@ export async function getCategoryRules(
|
||||
|
||||
export async function createCategoryRule(
|
||||
data: CreateCategoryRuleRequest,
|
||||
): Promise<CategoryRule> {
|
||||
return api.post<CategoryRule>('/api/category-rules', data);
|
||||
): Promise<CreateCategoryRuleResponse> {
|
||||
return api.post<CreateCategoryRuleResponse>('/api/category-rules', data);
|
||||
}
|
||||
|
||||
export async function updateCategoryRule(
|
||||
|
||||
5
frontend/src/api/version.ts
Normal file
5
frontend/src/api/version.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { api } from './client';
|
||||
|
||||
export async function getBackendVersion(): Promise<{ version: string }> {
|
||||
return api.get<{ version: string }>('/api/version');
|
||||
}
|
||||
@@ -7,6 +7,8 @@ export function AccountsList() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editAlias, setEditAlias] = useState('');
|
||||
const [editAccountType, setEditAccountType] = useState<Account['accountType']>(null);
|
||||
const [editStatus, setEditStatus] = useState<Account['status']>('active');
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
@@ -19,12 +21,16 @@ export function AccountsList() {
|
||||
const handleEdit = (account: Account) => {
|
||||
setEditingId(account.id);
|
||||
setEditAlias(account.alias || '');
|
||||
setEditAccountType(account.accountType);
|
||||
setEditStatus(account.status);
|
||||
};
|
||||
|
||||
const handleSave = async (id: number) => {
|
||||
try {
|
||||
const updated = await updateAccount(id, {
|
||||
alias: editAlias.trim(),
|
||||
accountType: editAccountType,
|
||||
status: editStatus,
|
||||
});
|
||||
setAccounts((prev) =>
|
||||
prev.map((a) => (a.id === id ? updated : a)),
|
||||
@@ -36,28 +42,30 @@ export function AccountsList() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="section-loading">Загрузка...</div>;
|
||||
return <div className="state state--loading">Загрузка...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Банк</th>
|
||||
<th>Номер счёта</th>
|
||||
<th>Валюта</th>
|
||||
<th>Алиас</th>
|
||||
<th></th>
|
||||
<thead className="data-table__head">
|
||||
<tr className="data-table__row">
|
||||
<th className="data-table__head-cell">Банк</th>
|
||||
<th className="data-table__head-cell">Номер счёта</th>
|
||||
<th className="data-table__head-cell">Валюта</th>
|
||||
<th className="data-table__head-cell">Алиас</th>
|
||||
<th className="data-table__head-cell">Тип</th>
|
||||
<th className="data-table__head-cell">Статус</th>
|
||||
<th className="data-table__head-cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="data-table__body">
|
||||
{accounts.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td>{a.bank}</td>
|
||||
<td>{a.accountNumberMasked}</td>
|
||||
<td>{a.currency}</td>
|
||||
<td>
|
||||
<tr className="data-table__row" key={a.id}>
|
||||
<td className="data-table__cell">{a.bank}</td>
|
||||
<td className="data-table__cell">{a.accountNumberMasked}</td>
|
||||
<td className="data-table__cell">{a.currency}</td>
|
||||
<td className="data-table__cell">
|
||||
{editingId === a.id ? (
|
||||
<input
|
||||
type="text"
|
||||
@@ -72,21 +80,40 @@ export function AccountsList() {
|
||||
/>
|
||||
) : (
|
||||
a.alias || (
|
||||
<span className="text-muted">не задан</span>
|
||||
<span className="text text--muted">не задан</span>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<td className="data-table__cell">
|
||||
{editingId === a.id ? (
|
||||
<div className="btn-group">
|
||||
<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 ? (
|
||||
<div className="button-group">
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
className="button button--primary button--small"
|
||||
onClick={() => handleSave(a.id)}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
className="button button--secondary button--small"
|
||||
onClick={() => setEditingId(null)}
|
||||
>
|
||||
Отмена
|
||||
@@ -94,7 +121,7 @@ export function AccountsList() {
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
className="button button--secondary button--small"
|
||||
onClick={() => handleEdit(a)}
|
||||
>
|
||||
Изменить
|
||||
@@ -104,8 +131,8 @@ export function AccountsList() {
|
||||
</tr>
|
||||
))}
|
||||
{accounts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="td-center text-muted">
|
||||
<tr className="data-table__row">
|
||||
<td colSpan={7} className="data-table__cell data-table__cell--center text text--muted">
|
||||
Нет счетов. Импортируйте выписку.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -21,24 +21,24 @@ export function CategoriesList() {
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="section-loading">Загрузка...</div>;
|
||||
return <div className="state state--loading">Загрузка...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Категория</th>
|
||||
<th>Тип</th>
|
||||
<thead className="data-table__head">
|
||||
<tr className="data-table__row">
|
||||
<th className="data-table__head-cell">Категория</th>
|
||||
<th className="data-table__head-cell">Тип</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="data-table__body">
|
||||
{categories.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>{c.name}</td>
|
||||
<td>
|
||||
<span className={`badge badge-${c.type}`}>
|
||||
<tr className="data-table__row" key={c.id}>
|
||||
<td className="data-table__cell">{c.name}</td>
|
||||
<td className="data-table__cell">
|
||||
<span className={`badge badge--${c.type}`}>
|
||||
{TYPE_LABELS[c.type] ?? c.type}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -14,9 +14,14 @@ interface Props {
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
|
||||
'#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1',
|
||||
'#14b8a6', '#e11d48', '#0ea5e9', '#a855f7', '#22c55e',
|
||||
'var(--color-chart-1)', 'var(--color-chart-2)',
|
||||
'var(--color-chart-3)', 'var(--color-chart-4)',
|
||||
'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', {
|
||||
@@ -30,7 +35,7 @@ export function CategoryChart({ data }: Props) {
|
||||
const chartHeight = isMobile ? 250 : 300;
|
||||
|
||||
if (data.length === 0) {
|
||||
return <div className="chart-empty">Нет данных за период</div>;
|
||||
return <div className="state state--empty">Нет данных за период</div>;
|
||||
}
|
||||
|
||||
const chartData = data.map((item) => ({
|
||||
@@ -41,7 +46,7 @@ export function CategoryChart({ data }: Props) {
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="category-chart-wrapper">
|
||||
<div className="category-chart">
|
||||
<ResponsiveContainer width="100%" height={chartHeight}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
@@ -58,7 +63,7 @@ export function CategoryChart({ data }: Props) {
|
||||
>
|
||||
{chartData.map((_, idx) => (
|
||||
<Cell
|
||||
key={idx}
|
||||
key={`${idx}-${COLORS[idx % COLORS.length]}`}
|
||||
fill={COLORS[idx % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
@@ -69,21 +74,21 @@ export function CategoryChart({ data }: Props) {
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
<table className="category-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Категория</th>
|
||||
<th>Сумма</th>
|
||||
<th className="th-center">Операций</th>
|
||||
<th className="th-center">Доля</th>
|
||||
<table className="category-chart__table">
|
||||
<thead className="category-chart__head">
|
||||
<tr className="category-chart__row">
|
||||
<th className="category-chart__head-cell">Категория</th>
|
||||
<th className="category-chart__head-cell">Сумма</th>
|
||||
<th className="category-chart__head-cell category-chart__head-cell--center">Операций</th>
|
||||
<th className="category-chart__head-cell category-chart__head-cell--center">Доля</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="category-chart__body">
|
||||
{data.map((item, idx) => (
|
||||
<tr key={item.categoryId}>
|
||||
<td>
|
||||
<tr className="category-chart__row" key={item.categoryId}>
|
||||
<td className="category-chart__cell">
|
||||
<span
|
||||
className="color-dot"
|
||||
className="category-chart__dot"
|
||||
style={{
|
||||
backgroundColor:
|
||||
COLORS[idx % COLORS.length],
|
||||
@@ -91,9 +96,9 @@ export function CategoryChart({ data }: Props) {
|
||||
/>
|
||||
{item.categoryName}
|
||||
</td>
|
||||
<td>{formatAmount(item.amount)}</td>
|
||||
<td className="td-center">{item.txCount}</td>
|
||||
<td className="td-center">
|
||||
<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.share * 100).toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -31,25 +31,30 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Очистить историю операций</h2>
|
||||
<button className="btn-close" onClick={onClose}>
|
||||
<div
|
||||
className="modal"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="modal__dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal__header">
|
||||
<h2 className="modal__title">Очистить историю операций</h2>
|
||||
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<p className="clear-history-warn">
|
||||
<div className="modal__body">
|
||||
<p className="danger-note">
|
||||
Все транзакции будут безвозвратно удалены. Счета и категории
|
||||
сохранятся.
|
||||
</p>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
{error && <div className="alert alert--error">{error}</div>}
|
||||
|
||||
<div className="form-group form-group-checkbox clear-history-check">
|
||||
<label>
|
||||
<div className="field field--checkbox field--flush">
|
||||
<label className="field__label field__label--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={check1}
|
||||
@@ -59,8 +64,8 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-group form-group-checkbox clear-history-check">
|
||||
<label>
|
||||
<div className="field field--checkbox field--flush">
|
||||
<label className="field__label field__label--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={check2}
|
||||
@@ -72,15 +77,15 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<div className="modal__footer">
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
className="button button--danger modal__action"
|
||||
onClick={handleConfirm}
|
||||
disabled={!canConfirm || loading}
|
||||
>
|
||||
{loading ? 'Удаление…' : 'Удалить всё'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onClose}>
|
||||
<button className="button button--secondary modal__action" onClick={onClose}>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -34,40 +34,40 @@ export function DataSection() {
|
||||
|
||||
return (
|
||||
<div className="data-section">
|
||||
<div className="section-block">
|
||||
<h3>История импортов</h3>
|
||||
<p className="section-desc">
|
||||
<div className="data-section__block">
|
||||
<h3 className="data-section__title">История импортов</h3>
|
||||
<p className="data-section__description">
|
||||
Список импортов выписок. Можно удалить операции конкретного импорта.
|
||||
</p>
|
||||
{imports.length === 0 ? (
|
||||
<p className="muted">Импортов пока нет.</p>
|
||||
<p className="text text--muted">Импортов пока нет.</p>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>Счёт</th>
|
||||
<th>Банк</th>
|
||||
<th>Импортировано</th>
|
||||
<th>Дубликаты</th>
|
||||
<th></th>
|
||||
<div className="table-shell">
|
||||
<table className="data-table">
|
||||
<thead className="data-table__head">
|
||||
<tr className="data-table__row">
|
||||
<th className="data-table__head-cell">Дата</th>
|
||||
<th className="data-table__head-cell">Счёт</th>
|
||||
<th className="data-table__head-cell">Банк</th>
|
||||
<th className="data-table__head-cell">Импортировано</th>
|
||||
<th className="data-table__head-cell">Дубликаты</th>
|
||||
<th className="data-table__head-cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="data-table__body">
|
||||
{imports.map((imp) => (
|
||||
<tr key={imp.id}>
|
||||
<td>{formatDate(imp.importedAt)}</td>
|
||||
<td>
|
||||
<tr className="data-table__row" key={imp.id}>
|
||||
<td className="data-table__cell">{formatDate(imp.importedAt)}</td>
|
||||
<td className="data-table__cell">
|
||||
{imp.accountAlias || imp.accountNumberMasked || '—'}
|
||||
</td>
|
||||
<td>{imp.bank}</td>
|
||||
<td>{imp.importedCount}</td>
|
||||
<td>{imp.duplicatesSkipped}</td>
|
||||
<td>
|
||||
<td className="data-table__cell">{imp.bank}</td>
|
||||
<td className="data-table__cell">{imp.importedCount}</td>
|
||||
<td className="data-table__cell">{imp.duplicatesSkipped}</td>
|
||||
<td className="data-table__cell">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger"
|
||||
className="button button--danger button--small"
|
||||
onClick={() => setImpToDelete(imp)}
|
||||
disabled={imp.importedCount === 0}
|
||||
>
|
||||
@@ -82,15 +82,15 @@ export function DataSection() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="section-block">
|
||||
<h3>Очистка данных</h3>
|
||||
<p className="section-desc">
|
||||
<div className="data-section__block">
|
||||
<h3 className="data-section__title">Очистка данных</h3>
|
||||
<p className="data-section__description">
|
||||
Очистить историю операций (все транзакции). Счета, категории и
|
||||
правила сохранятся.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
className="button button--danger"
|
||||
onClick={() => setShowClearModal(true)}
|
||||
>
|
||||
Очистить историю
|
||||
|
||||
@@ -31,36 +31,41 @@ export function DeleteImportModal({ imp, onClose, onDone }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Удалить импорт</h2>
|
||||
<button className="btn-close" onClick={onClose}>
|
||||
<div
|
||||
className="modal"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="modal__dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal__header">
|
||||
<h2 className="modal__title">Удалить импорт</h2>
|
||||
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<p className="clear-history-warn">
|
||||
<div className="modal__body">
|
||||
<p className="danger-note">
|
||||
Будут удалены все операции этого импорта ({imp.importedCount}{' '}
|
||||
шт.): {imp.bank} / {accountLabel}
|
||||
</p>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
{error && <div className="alert alert--error">{error}</div>}
|
||||
|
||||
<p>Действие необратимо.</p>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<div className="modal__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
className="button button--danger modal__action"
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Удаление…' : 'Удалить'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
<button type="button" className="button button--secondary modal__action" onClick={onClose}>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,14 @@ function extractPattern(description: string): string {
|
||||
.slice(0, 50);
|
||||
}
|
||||
|
||||
function getCommissionAmountSigned(transaction: Transaction): number {
|
||||
if (transaction.commission === 0) return 0;
|
||||
const isCashbackIncome =
|
||||
transaction.amountSigned === 0 &&
|
||||
transaction.description.toLowerCase().includes('зачисление');
|
||||
return isCashbackIncome ? transaction.commission : -transaction.commission;
|
||||
}
|
||||
|
||||
export function EditTransactionModal({
|
||||
transaction,
|
||||
categories,
|
||||
@@ -74,38 +82,49 @@ export function EditTransactionModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Редактирование операции</h2>
|
||||
<button className="btn-close" onClick={onClose}>
|
||||
<div
|
||||
className="modal"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="modal__dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal__header">
|
||||
<h2 className="modal__title">Редактирование операции</h2>
|
||||
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body">
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<form className="form" onSubmit={handleSubmit}>
|
||||
<div className="modal__body">
|
||||
{error && <div className="alert alert--error">{error}</div>}
|
||||
|
||||
<div className="modal-tx-info">
|
||||
<div className="modal-tx-row">
|
||||
<span className="modal-tx-label">Дата</span>
|
||||
<div className="transaction-preview">
|
||||
<div className="transaction-preview__row">
|
||||
<span className="transaction-preview__label">Дата</span>
|
||||
<span>{formatDateTime(transaction.operationAt)}</span>
|
||||
</div>
|
||||
<div className="modal-tx-row">
|
||||
<span className="modal-tx-label">Сумма</span>
|
||||
<div className="transaction-preview__row">
|
||||
<span className="transaction-preview__label">Сумма</span>
|
||||
<span>{formatAmount(transaction.amountSigned)}</span>
|
||||
</div>
|
||||
<div className="modal-tx-row">
|
||||
<span className="modal-tx-label">Описание</span>
|
||||
<span className="modal-tx-description">
|
||||
{transaction.commission !== 0 && (
|
||||
<div className="transaction-preview__row">
|
||||
<span className="transaction-preview__label">Комиссия</span>
|
||||
<span>{formatAmount(getCommissionAmountSigned(transaction))}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="transaction-preview__row">
|
||||
<span className="transaction-preview__label">Описание</span>
|
||||
<span className="transaction-preview__description">
|
||||
{transaction.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="edit-category">Категория</label>
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor="edit-category">Категория</label>
|
||||
<select
|
||||
id="edit-category"
|
||||
value={categoryId}
|
||||
@@ -120,8 +139,8 @@ export function EditTransactionModal({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="edit-comment">Комментарий</label>
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor="edit-comment">Комментарий</label>
|
||||
<textarea
|
||||
id="edit-comment"
|
||||
rows={2}
|
||||
@@ -131,10 +150,10 @@ export function EditTransactionModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-divider" />
|
||||
<div className="form__divider" />
|
||||
|
||||
<div className="form-group form-group-checkbox">
|
||||
<label>
|
||||
<div className="field field--checkbox">
|
||||
<label className="field__label field__label--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createRule}
|
||||
@@ -146,8 +165,8 @@ export function EditTransactionModal({
|
||||
|
||||
{createRule && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="edit-pattern">
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor="edit-pattern">
|
||||
Шаблон (ключевая строка)
|
||||
</label>
|
||||
<input
|
||||
@@ -158,8 +177,8 @@ export function EditTransactionModal({
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group form-group-checkbox">
|
||||
<label>
|
||||
<div className="field field--checkbox">
|
||||
<label className="field__label field__label--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={requiresConfirmation}
|
||||
@@ -174,17 +193,17 @@ export function EditTransactionModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<div className="modal__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
className="button button--secondary modal__action"
|
||||
onClick={onClose}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
className="button button--primary modal__action"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import type { ImportStatementResponse } from '@family-budget/shared';
|
||||
import type { ImportPortfolioResponse, ImportStatementResponse, PortfolioFile } from '@family-budget/shared';
|
||||
import { importStatement } from '../api/import';
|
||||
import { importPortfolio } from '../api/portfolio';
|
||||
import { updateAccount } from '../api/accounts';
|
||||
|
||||
interface Props {
|
||||
@@ -9,7 +10,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function ImportModal({ onClose, onDone }: Props) {
|
||||
const [result, setResult] = useState<ImportStatementResponse | null>(null);
|
||||
const [result, setResult] = useState<ImportStatementResponse | ImportPortfolioResponse | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [alias, setAlias] = useState('');
|
||||
@@ -37,7 +38,10 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const resp = await importStatement(file);
|
||||
const data = isJson ? JSON.parse(await file.text()) : null;
|
||||
const resp = data?.schemaVersion === 'broker-portfolio-1.0'
|
||||
? await importPortfolio(data as PortfolioFile)
|
||||
: await importStatement(file);
|
||||
setResult(resp);
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
@@ -49,7 +53,7 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
};
|
||||
|
||||
const handleSaveAlias = async () => {
|
||||
if (!result || !alias.trim()) return;
|
||||
if (!result || 'reportId' in result || !alias.trim()) return;
|
||||
try {
|
||||
await updateAccount(result.accountId, { alias: alias.trim() });
|
||||
setAliasSaved(true);
|
||||
@@ -58,68 +62,92 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const isPortfolioResult = result != null && 'reportId' in result;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Импорт выписки</h2>
|
||||
<button className="btn-close" onClick={onClose}>
|
||||
<div
|
||||
className="modal"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="modal__dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal__header">
|
||||
<h2 className="modal__title">Импорт выписки</h2>
|
||||
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<div className="modal__body">
|
||||
{error && <div className="alert alert--error">{error}</div>}
|
||||
|
||||
{!result && (
|
||||
<div className="import-upload">
|
||||
<p>Выберите файл выписки (PDF или JSON, формат 1.0)</p>
|
||||
<p className="import-upload__description">
|
||||
Выберите PDF/JSON выписки или JSON брокерского портфеля
|
||||
</p>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".pdf,.json,application/pdf,application/json"
|
||||
onChange={handleFileChange}
|
||||
className="file-input"
|
||||
className="import-upload__input"
|
||||
/>
|
||||
{loading && (
|
||||
<div className="import-loading">Импорт...</div>
|
||||
<div className="import-upload__loading">Импорт...</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="import-result">
|
||||
<div className="import-result-icon" aria-hidden="true">✓</div>
|
||||
<h3>Импорт завершён</h3>
|
||||
<table className="import-stats">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Счёт</td>
|
||||
<td>{result.accountNumberMasked}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Новый счёт</td>
|
||||
<td>{result.isNewAccount ? 'Да' : 'Нет'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Импортировано</td>
|
||||
<td>{result.imported}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Дубликатов пропущено</td>
|
||||
<td>{result.duplicatesSkipped}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Всего в файле</td>
|
||||
<td>{result.totalInFile}</td>
|
||||
</tr>
|
||||
<div className="import-result__icon" aria-hidden="true">✓</div>
|
||||
<h3 className="import-result__title">{isPortfolioResult ? 'Импорт портфеля завершён' : 'Импорт завершён'}</h3>
|
||||
<table className="import-result__stats">
|
||||
<tbody className="import-result__stats-body">
|
||||
{isPortfolioResult ? <>
|
||||
<tr className="import-result__stat-row">
|
||||
<td className="import-result__stat-cell import-result__stat-cell--label">Импортировано сделок</td>
|
||||
<td className="import-result__stat-cell import-result__stat-cell--value">{result.importedTrades}</td>
|
||||
</tr>
|
||||
<tr className="import-result__stat-row">
|
||||
<td className="import-result__stat-cell import-result__stat-cell--label">Дубликатов сделок</td>
|
||||
<td className="import-result__stat-cell import-result__stat-cell--value">{result.duplicateTrades}</td>
|
||||
</tr>
|
||||
<tr className="import-result__stat-row">
|
||||
<td className="import-result__stat-cell import-result__stat-cell--label">Позиций</td>
|
||||
<td className="import-result__stat-cell import-result__stat-cell--value">{result.positions}</td>
|
||||
</tr>
|
||||
</> : <>
|
||||
<tr className="import-result__stat-row">
|
||||
<td className="import-result__stat-cell import-result__stat-cell--label">Счёт</td>
|
||||
<td className="import-result__stat-cell import-result__stat-cell--value">{result.accountNumberMasked}</td>
|
||||
</tr>
|
||||
<tr className="import-result__stat-row">
|
||||
<td className="import-result__stat-cell import-result__stat-cell--label">Новый счёт</td>
|
||||
<td className="import-result__stat-cell import-result__stat-cell--value">{result.isNewAccount ? 'Да' : 'Нет'}</td>
|
||||
</tr>
|
||||
<tr className="import-result__stat-row">
|
||||
<td className="import-result__stat-cell import-result__stat-cell--label">Импортировано</td>
|
||||
<td className="import-result__stat-cell import-result__stat-cell--value">{result.imported}</td>
|
||||
</tr>
|
||||
<tr className="import-result__stat-row">
|
||||
<td className="import-result__stat-cell import-result__stat-cell--label">Дубликатов пропущено</td>
|
||||
<td className="import-result__stat-cell import-result__stat-cell--value">{result.duplicatesSkipped}</td>
|
||||
</tr>
|
||||
<tr className="import-result__stat-row">
|
||||
<td className="import-result__stat-cell import-result__stat-cell--label">Всего в файле</td>
|
||||
<td className="import-result__stat-cell import-result__stat-cell--value">{result.totalInFile}</td>
|
||||
</tr>
|
||||
</>}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{result.isNewAccount && !aliasSaved && (
|
||||
<div className="import-alias">
|
||||
<label>Алиас для нового счёта</label>
|
||||
<div className="import-alias-row">
|
||||
{!isPortfolioResult && result.isNewAccount && !aliasSaved && (
|
||||
<div className="import-result__alias">
|
||||
<label className="import-result__alias-label">Алиас для нового счёта</label>
|
||||
<div className="import-result__alias-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Напр.: Текущий, Накопительный"
|
||||
@@ -128,7 +156,7 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
maxLength={50}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
className="button button--primary button--small"
|
||||
onClick={handleSaveAlias}
|
||||
disabled={!alias.trim()}
|
||||
>
|
||||
@@ -139,7 +167,7 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
)}
|
||||
|
||||
{aliasSaved && (
|
||||
<div className="import-alias-saved">
|
||||
<div className="import-result__alias-saved">
|
||||
Алиас сохранён
|
||||
</div>
|
||||
)}
|
||||
@@ -147,14 +175,14 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<div className="modal__footer">
|
||||
{result ? (
|
||||
<button className="btn btn-primary" onClick={onDone}>
|
||||
<button className="button button--primary modal__action" onClick={onDone}>
|
||||
Готово
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
className="button button--secondary modal__action"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useState, useEffect, type ReactNode } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { getBackendVersion } from '../api/version';
|
||||
|
||||
export function Layout({ children }: { children: ReactNode }) {
|
||||
const { user, logout } = useAuth();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [beVersion, setBeVersion] = useState<string | null>(null);
|
||||
|
||||
const closeDrawer = () => setDrawerOpen(false);
|
||||
|
||||
useEffect(() => {
|
||||
getBackendVersion()
|
||||
.then((r) => setBeVersion(r.version))
|
||||
.catch(() => setBeVersion(null));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<div className="app-shell">
|
||||
<button
|
||||
type="button"
|
||||
className="burger-btn"
|
||||
className="menu-button"
|
||||
aria-label="Открыть меню"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
>
|
||||
@@ -25,27 +33,27 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
|
||||
{drawerOpen && (
|
||||
<div
|
||||
className="sidebar-overlay"
|
||||
className="app-shell__overlay"
|
||||
aria-hidden="true"
|
||||
onClick={closeDrawer}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside className={`sidebar ${drawerOpen ? 'sidebar-open' : ''}`}>
|
||||
<div className="sidebar-brand">
|
||||
<span className="sidebar-brand-icon">₽</span>
|
||||
<span className="sidebar-brand-text">Семейный бюджет</span>
|
||||
<aside className={`sidebar ${drawerOpen ? 'sidebar--open' : ''}`}>
|
||||
<div className="sidebar__brand">
|
||||
<span className="sidebar__brand-icon">₽</span>
|
||||
<span className="sidebar__brand-text">Семейный бюджет</span>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
<nav className="sidebar__nav" aria-label="Основная навигация">
|
||||
<NavLink
|
||||
to="/history"
|
||||
className={({ isActive }) =>
|
||||
`nav-link${isActive ? ' active' : ''}`
|
||||
`sidebar__nav-link${isActive ? ' sidebar__nav-link--active' : ''}`
|
||||
}
|
||||
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" />
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
@@ -58,11 +66,11 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
<NavLink
|
||||
to="/analytics"
|
||||
className={({ isActive }) =>
|
||||
`nav-link${isActive ? ' active' : ''}`
|
||||
`sidebar__nav-link${isActive ? ' sidebar__nav-link--active' : ''}`
|
||||
}
|
||||
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="12" y1="20" x2="12" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="14" />
|
||||
@@ -73,11 +81,11 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) =>
|
||||
`nav-link${isActive ? ' active' : ''}`
|
||||
`sidebar__nav-link${isActive ? ' sidebar__nav-link--active' : ''}`
|
||||
}
|
||||
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" />
|
||||
<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>
|
||||
@@ -85,15 +93,23 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<span className="sidebar-user">{user?.login}</span>
|
||||
<button className="btn-logout" onClick={() => logout()}>
|
||||
Выход
|
||||
</button>
|
||||
<div className="sidebar__footer">
|
||||
<div className="sidebar__user-row">
|
||||
<span className="sidebar__user">{user?.login}</span>
|
||||
<button className="sidebar__logout" onClick={() => logout()}>
|
||||
Выход
|
||||
</button>
|
||||
</div>
|
||||
<div className="sidebar__meta">
|
||||
<span className="sidebar__version">
|
||||
FE {__FE_VERSION__} · BE {beVersion ?? '…'}
|
||||
</span>
|
||||
<span className="sidebar__copyright">© 2025–2026 Семейный бюджет</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="main-content">{children}</main>
|
||||
<main className="app-shell__main">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,35 +20,38 @@ export function Pagination({
|
||||
|
||||
return (
|
||||
<div className="pagination">
|
||||
<div className="pagination-info">
|
||||
<div className="pagination__info">
|
||||
{totalItems > 0
|
||||
? `Показано ${from}–${to} из ${totalItems}`
|
||||
: 'Нет записей'}
|
||||
</div>
|
||||
<div className="pagination-controls">
|
||||
<div className="pagination__controls">
|
||||
<select
|
||||
className="pagination-size"
|
||||
className="pagination__size"
|
||||
value={pageSize}
|
||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
aria-label="Количество операций на странице"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn-page"
|
||||
className="icon-button"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
aria-label="Предыдущая страница"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span className="pagination-current">
|
||||
<span className="pagination__current">
|
||||
{page} / {totalPages || 1}
|
||||
</span>
|
||||
<button
|
||||
className="btn-page"
|
||||
className="icon-button"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
aria-label="Следующая страница"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
|
||||
@@ -85,13 +85,13 @@ export function PeriodSelector({ period, onChange }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="period-selector">
|
||||
<div className="period-modes">
|
||||
<div className="period-picker">
|
||||
<div className="segmented-control">
|
||||
{(['week', 'month', 'year', 'custom'] as PeriodMode[]).map(
|
||||
(m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`btn-preset ${period.mode === m ? 'active' : ''}`}
|
||||
className={`segmented-control__button ${period.mode === m ? 'segmented-control__button--active' : ''}`}
|
||||
onClick={() => setMode(m)}
|
||||
>
|
||||
{MODE_LABELS[m]}
|
||||
@@ -100,13 +100,18 @@ export function PeriodSelector({ period, onChange }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="period-nav">
|
||||
<div className="period-picker__nav">
|
||||
{period.mode !== 'custom' && (
|
||||
<button className="btn-page" onClick={() => navigate(-1)}>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={() => navigate(-1)}
|
||||
aria-label="Предыдущий период"
|
||||
title="Предыдущий период"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
)}
|
||||
<div className="period-dates">
|
||||
<div className="period-picker__dates">
|
||||
<input
|
||||
type="date"
|
||||
value={period.from}
|
||||
@@ -114,7 +119,7 @@ export function PeriodSelector({ period, onChange }: Props) {
|
||||
onChange({ ...period, mode: 'custom', from: e.target.value })
|
||||
}
|
||||
/>
|
||||
<span className="filter-separator">—</span>
|
||||
<span className="period-picker__separator">—</span>
|
||||
<input
|
||||
type="date"
|
||||
value={period.to}
|
||||
@@ -124,7 +129,12 @@ export function PeriodSelector({ period, onChange }: Props) {
|
||||
/>
|
||||
</div>
|
||||
{period.mode !== 'custom' && (
|
||||
<button className="btn-page" onClick={() => navigate(1)}>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={() => navigate(1)}
|
||||
aria-label="Следующий период"
|
||||
title="Следующий период"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -51,56 +51,59 @@ export function RulesList() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="section-loading">Загрузка...</div>;
|
||||
return <div className="state state--loading">Загрузка...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<div className="rules-list settings-section">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Шаблон</th>
|
||||
<th>Категория</th>
|
||||
<th className="th-center">Приоритет</th>
|
||||
<th className="th-center">Подтверждение</th>
|
||||
<th>Создано</th>
|
||||
<th className="th-center">Активно</th>
|
||||
<th></th>
|
||||
<thead className="data-table__head">
|
||||
<tr className="data-table__row">
|
||||
<th className="data-table__head-cell">Шаблон</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">Создано</th>
|
||||
<th className="data-table__head-cell data-table__head-cell--center">Активно</th>
|
||||
<th className="data-table__head-cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="data-table__body">
|
||||
{rules.map((r) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className={!r.isActive ? 'row-inactive' : ''}
|
||||
className={`data-table__row ${!r.isActive ? 'data-table__row--inactive' : ''}`}
|
||||
>
|
||||
<td>
|
||||
<td className="data-table__cell">
|
||||
<code>{r.pattern}</code>
|
||||
</td>
|
||||
<td>{r.categoryName}</td>
|
||||
<td className="td-center">{r.priority}</td>
|
||||
<td className="td-center">
|
||||
<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.requiresConfirmation ? 'Да' : 'Нет'}
|
||||
</td>
|
||||
<td className="td-nowrap">
|
||||
<td className="data-table__cell data-table__cell--nowrap">
|
||||
{formatDate(r.createdAt)}
|
||||
</td>
|
||||
<td className="td-center">
|
||||
<td className="data-table__cell data-table__cell--center">
|
||||
<button
|
||||
className={`toggle ${r.isActive ? 'toggle-on' : 'toggle-off'}`}
|
||||
className={`switch-button ${r.isActive ? 'switch-button--on' : 'switch-button--off'}`}
|
||||
onClick={() => handleToggle(r)}
|
||||
title={
|
||||
r.isActive ? 'Деактивировать' : 'Активировать'
|
||||
}
|
||||
aria-label={
|
||||
r.isActive ? 'Деактивировать правило' : 'Активировать правило'
|
||||
}
|
||||
>
|
||||
{r.isActive ? 'Вкл' : 'Выкл'}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<div className="rules-actions">
|
||||
<td className="data-table__cell">
|
||||
<div className="rules-list__actions">
|
||||
{r.isActive && (
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
className="button button--secondary button--small"
|
||||
onClick={() => handleApply(r.id)}
|
||||
disabled={applyingId === r.id}
|
||||
>
|
||||
@@ -108,7 +111,7 @@ export function RulesList() {
|
||||
</button>
|
||||
)}
|
||||
{applyResult?.id === r.id && (
|
||||
<span className="apply-result">
|
||||
<span className="rules-list__result">
|
||||
Применено: {applyResult.count}
|
||||
</span>
|
||||
)}
|
||||
@@ -117,8 +120,8 @@ export function RulesList() {
|
||||
</tr>
|
||||
))}
|
||||
{rules.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="td-center text-muted">
|
||||
<tr className="data-table__row">
|
||||
<td colSpan={7} className="data-table__cell data-table__cell--center text text--muted">
|
||||
Нет правил
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -6,47 +6,65 @@ interface Props {
|
||||
}
|
||||
|
||||
export function SummaryCards({ summary }: Props) {
|
||||
const balanceModifier = summary.net >= 0 ? 'positive' : 'negative';
|
||||
|
||||
return (
|
||||
<div className="summary-cards">
|
||||
<div className="summary-card summary-card-income">
|
||||
<div className="summary-label">Доходы</div>
|
||||
<div className="summary-value">
|
||||
<div className="summary">
|
||||
<div className="summary__card summary__card--income">
|
||||
<div className="summary__label">Доходы</div>
|
||||
<div className="summary__value">
|
||||
{formatAmount(summary.totalIncome)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="summary-card summary-card-expense">
|
||||
<div className="summary-label">Расходы</div>
|
||||
<div className="summary-value">
|
||||
<div className="summary__card summary__card--expense">
|
||||
<div className="summary__label">Расходы</div>
|
||||
<div className="summary__value">
|
||||
{formatAmount(summary.totalExpense)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`summary-card ${summary.net >= 0 ? 'summary-card-positive' : 'summary-card-negative'}`}
|
||||
className={`summary__card summary__card--${balanceModifier}`}
|
||||
>
|
||||
<div className="summary-label">Баланс</div>
|
||||
<div className="summary-value">
|
||||
<div className="summary__label">Баланс</div>
|
||||
<div className="summary__value">
|
||||
{formatAmount(summary.net)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="summary__card summary__card--investments">
|
||||
<div className="summary__label">Движение ДС</div>
|
||||
<div className="summary__value">
|
||||
{formatAmount(summary.cashNet)}
|
||||
</div>
|
||||
<div className="summary__subvalue">Поступило: {formatAmount(summary.cashInflow)}</div>
|
||||
<div className="summary__subvalue">Списано: {formatAmount(summary.cashOutflow)}</div>
|
||||
<div className="summary__subvalue">Доход от процентов: {formatAmount(summary.interestIncome)}</div>
|
||||
<div className="summary__subvalue">Кэшбек: {formatAmount(summary.cashbackIncome)}</div>
|
||||
{(summary.transferInflow > 0 || summary.transferOutflow > 0) && (
|
||||
<div className="summary__subvalue">
|
||||
Переводы: {formatAmount(summary.transferInflow)} / {formatAmount(summary.transferOutflow)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{summary.topCategories.length > 0 && (
|
||||
<div className="summary-card summary-card-top">
|
||||
<div className="summary-label">Топ расходов</div>
|
||||
<div className="summary-top-list">
|
||||
<div className="summary__card summary__card--top">
|
||||
<div className="summary__label">Топ расходов</div>
|
||||
<div className="summary__top-list">
|
||||
{summary.topCategories.map((cat) => (
|
||||
<div
|
||||
key={cat.categoryId}
|
||||
className="top-category-item"
|
||||
className="summary__top-item"
|
||||
>
|
||||
<span className="top-category-name">
|
||||
<span className="summary__top-name">
|
||||
{cat.categoryName}
|
||||
</span>
|
||||
<span className="top-category-amount">
|
||||
<span className="summary__top-amount">
|
||||
{formatAmount(cat.amount)}
|
||||
</span>
|
||||
<span className="top-category-share">
|
||||
<span className="summary__top-share">
|
||||
{(cat.share * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -26,19 +26,20 @@ export function TimeseriesChart({ data }: Props) {
|
||||
const chartHeight = isMobile ? 250 : 300;
|
||||
|
||||
if (data.length === 0) {
|
||||
return <div className="chart-empty">Нет данных за период</div>;
|
||||
return <div className="state state--empty">Нет данных за период</div>;
|
||||
}
|
||||
|
||||
const chartData = data.map((item) => ({
|
||||
period: item.periodStart,
|
||||
Расходы: Math.abs(item.expenseAmount) / 100,
|
||||
Доходы: item.incomeAmount / 100,
|
||||
Переводы: item.transferOutflow / 100,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={chartHeight}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="period"
|
||||
tickFormatter={(v: string) => {
|
||||
@@ -46,27 +47,42 @@ export function TimeseriesChart({ data }: Props) {
|
||||
return `${d.getDate()}.${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
}}
|
||||
fontSize={12}
|
||||
stroke="#64748b"
|
||||
stroke="var(--color-text-secondary)"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v: number) =>
|
||||
v >= 1000 ? `${(v / 1000).toFixed(0)}к` : String(v)
|
||||
}
|
||||
fontSize={12}
|
||||
stroke="#64748b"
|
||||
stroke="var(--color-text-secondary)"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => rubFormatter.format(value)}
|
||||
cursor={{ fill: 'var(--color-chart-cursor)' }}
|
||||
contentStyle={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 8,
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="Расходы"
|
||||
fill="#ef4444"
|
||||
fill="var(--color-danger)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="Доходы"
|
||||
fill="#10b981"
|
||||
fill="var(--color-success)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="Переводы"
|
||||
fill="var(--color-warning)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
|
||||
@@ -115,28 +115,29 @@ export function TransactionFilters({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="filters-panel">
|
||||
<div className="filters-row">
|
||||
<div className="filter-group">
|
||||
<label>Период</label>
|
||||
<div className="filter-dates-wrap">
|
||||
<div className="filters">
|
||||
<div className="filters__row">
|
||||
<div className="field field--period">
|
||||
<label className="field__label">Период</label>
|
||||
<div className="filters__date-control">
|
||||
{filters.periodMode !== 'custom' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-page"
|
||||
className="icon-button"
|
||||
onClick={() => navigate(-1)}
|
||||
aria-label="Предыдущий период"
|
||||
title="Предыдущий период"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
)}
|
||||
<div className="filter-dates">
|
||||
<div className="filters__dates">
|
||||
<input
|
||||
type="date"
|
||||
value={filters.from}
|
||||
onChange={(e) => handleDateChange('from', e.target.value)}
|
||||
/>
|
||||
<span className="filter-separator">—</span>
|
||||
<span className="filters__separator">—</span>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.to}
|
||||
@@ -146,29 +147,30 @@ export function TransactionFilters({
|
||||
{filters.periodMode !== 'custom' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-page"
|
||||
className="icon-button"
|
||||
onClick={() => navigate(1)}
|
||||
aria-label="Следующий период"
|
||||
title="Следующий период"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="filter-presets">
|
||||
<div className="segmented-control segmented-control--compact">
|
||||
<button
|
||||
className={`btn-preset ${filters.periodMode === 'week' ? 'active' : ''}`}
|
||||
className={`segmented-control__button ${filters.periodMode === 'week' ? 'segmented-control__button--active' : ''}`}
|
||||
onClick={() => applyPreset('week')}
|
||||
>
|
||||
Неделя
|
||||
</button>
|
||||
<button
|
||||
className={`btn-preset ${filters.periodMode === 'month' ? 'active' : ''}`}
|
||||
className={`segmented-control__button ${filters.periodMode === 'month' ? 'segmented-control__button--active' : ''}`}
|
||||
onClick={() => applyPreset('month')}
|
||||
>
|
||||
Месяц
|
||||
</button>
|
||||
<button
|
||||
className={`btn-preset ${filters.periodMode === 'year' ? 'active' : ''}`}
|
||||
className={`segmented-control__button ${filters.periodMode === 'year' ? 'segmented-control__button--active' : ''}`}
|
||||
onClick={() => applyPreset('year')}
|
||||
>
|
||||
Год
|
||||
@@ -176,8 +178,8 @@ export function TransactionFilters({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>Счёт</label>
|
||||
<div className="field">
|
||||
<label className="field__label">Счёт</label>
|
||||
<select
|
||||
value={filters.accountId}
|
||||
onChange={(e) => set('accountId', e.target.value)}
|
||||
@@ -185,14 +187,14 @@ export function TransactionFilters({
|
||||
<option value="">Все счета</option>
|
||||
{accounts.map((a) => (
|
||||
<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>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>Тип</label>
|
||||
<div className="field">
|
||||
<label className="field__label">Тип</label>
|
||||
<select
|
||||
value={filters.direction}
|
||||
onChange={(e) => set('direction', e.target.value)}
|
||||
@@ -204,8 +206,8 @@ export function TransactionFilters({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>Категория</label>
|
||||
<div className="field">
|
||||
<label className="field__label">Категория</label>
|
||||
<select
|
||||
value={filters.categoryId}
|
||||
onChange={(e) => set('categoryId', e.target.value)}
|
||||
@@ -220,9 +222,9 @@ export function TransactionFilters({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filters-row">
|
||||
<div className="filter-group filter-group-wide">
|
||||
<label>Поиск</label>
|
||||
<div className="filters__row">
|
||||
<div className="field field--wide">
|
||||
<label className="field__label">Поиск</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по описанию..."
|
||||
@@ -231,8 +233,8 @@ export function TransactionFilters({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>Сумма от (₽)</label>
|
||||
<div className="field">
|
||||
<label className="field__label">Сумма от (₽)</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="мин"
|
||||
@@ -241,8 +243,8 @@ export function TransactionFilters({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>Сумма до (₽)</label>
|
||||
<div className="field">
|
||||
<label className="field__label">Сумма до (₽)</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="макс"
|
||||
@@ -251,8 +253,8 @@ export function TransactionFilters({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="filter-group filter-group-checkbox">
|
||||
<label>
|
||||
<div className="field field--checkbox">
|
||||
<label className="field__label field__label--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.onlyUnconfirmed}
|
||||
@@ -262,9 +264,9 @@ export function TransactionFilters({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>Сортировка</label>
|
||||
<div className="filter-sort">
|
||||
<div className="field">
|
||||
<label className="field__label">Сортировка</label>
|
||||
<div className="filters__sort">
|
||||
<select
|
||||
value={filters.sortBy}
|
||||
onChange={(e) =>
|
||||
@@ -275,7 +277,7 @@ export function TransactionFilters({
|
||||
<option value="amount">По сумме</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn-sort-order"
|
||||
className="icon-button"
|
||||
onClick={() =>
|
||||
set(
|
||||
'sortOrder',
|
||||
@@ -287,6 +289,11 @@ export function TransactionFilters({
|
||||
? 'По возрастанию'
|
||||
: 'По убыванию'
|
||||
}
|
||||
aria-label={
|
||||
filters.sortOrder === 'asc'
|
||||
? 'Сортировать по убыванию'
|
||||
: 'Сортировать по возрастанию'
|
||||
}
|
||||
>
|
||||
{filters.sortOrder === 'asc' ? '↑' : '↓'}
|
||||
</button>
|
||||
|
||||
@@ -8,11 +8,18 @@ interface Props {
|
||||
}
|
||||
|
||||
const DIRECTION_CLASSES: Record<string, string> = {
|
||||
income: 'amount-income',
|
||||
expense: 'amount-expense',
|
||||
transfer: 'amount-transfer',
|
||||
income: 'money-amount--income',
|
||||
expense: 'money-amount--expense',
|
||||
transfer: 'money-amount--transfer',
|
||||
};
|
||||
|
||||
function getCommissionAmountSigned(tx: Transaction): number {
|
||||
if (tx.commission === 0) return 0;
|
||||
const isCashbackIncome =
|
||||
tx.amountSigned === 0 && tx.description.toLowerCase().includes('зачисление');
|
||||
return isCashbackIncome ? tx.commission : -tx.commission;
|
||||
}
|
||||
|
||||
function TransactionCard({
|
||||
tx,
|
||||
onEdit,
|
||||
@@ -26,34 +33,39 @@ function TransactionCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`transaction-card ${isUnconfirmed ? 'row-unconfirmed' : ''}`}
|
||||
className={`transaction-card ${isUnconfirmed ? 'transaction-card--unconfirmed' : ''}`}
|
||||
>
|
||||
<div className="transaction-card-header">
|
||||
<span className="transaction-card-date">
|
||||
<div className="transaction-card__header">
|
||||
<span className="transaction-card__date">
|
||||
{formatDateTime(tx.operationAt)}
|
||||
</span>
|
||||
<span className={`transaction-card-amount ${directionClass}`}>
|
||||
<span className={`money-amount transaction-card__amount ${directionClass}`}>
|
||||
{formatAmount(tx.amountSigned)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="transaction-card-body">
|
||||
<span className="description-text">{tx.description}</span>
|
||||
{tx.commission !== 0 && (
|
||||
<div className="transaction-card__commission">
|
||||
Комиссия: {formatAmount(getCommissionAmountSigned(tx))}
|
||||
</div>
|
||||
)}
|
||||
<div className="transaction-card__body">
|
||||
<span className="transaction-card__description">{tx.description}</span>
|
||||
{tx.comment && (
|
||||
<span className="comment-badge" title={tx.comment}>
|
||||
<span className="comment-indicator" title={tx.comment}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="transaction-card-footer">
|
||||
<span className="transaction-card-meta">
|
||||
<div className="transaction-card__footer">
|
||||
<span className="transaction-card__meta">
|
||||
{tx.accountAlias || '—'} · {tx.categoryName || '—'}
|
||||
</span>
|
||||
<div className="transaction-card-actions">
|
||||
<div className="transaction-card__actions">
|
||||
{tx.categoryId != null && !tx.isCategoryConfirmed && (
|
||||
<span
|
||||
className="badge badge-warning"
|
||||
className="badge badge--warning"
|
||||
title="Категория не подтверждена"
|
||||
>
|
||||
?
|
||||
@@ -61,8 +73,9 @@ function TransactionCard({
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon btn-icon-touch"
|
||||
className="icon-button icon-button--touch"
|
||||
onClick={() => onEdit(tx)}
|
||||
aria-label="Редактировать операцию"
|
||||
title="Редактировать"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -78,76 +91,82 @@ function TransactionCard({
|
||||
|
||||
export function TransactionTable({ transactions, loading, onEdit }: Props) {
|
||||
if (loading) {
|
||||
return <div className="table-loading">Загрузка операций...</div>;
|
||||
return <div className="state state--loading">Загрузка операций...</div>;
|
||||
}
|
||||
|
||||
if (transactions.length === 0) {
|
||||
return <div className="table-empty">Операции не найдены</div>;
|
||||
return <div className="state state--empty">Операции не найдены</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="table-wrapper table-desktop">
|
||||
<div className="table-shell table-shell--desktop">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>Счёт</th>
|
||||
<th>Сумма</th>
|
||||
<th>Описание</th>
|
||||
<th>Категория</th>
|
||||
<th className="th-center">Статус</th>
|
||||
<th></th>
|
||||
<thead className="data-table__head">
|
||||
<tr className="data-table__row">
|
||||
<th className="data-table__head-cell">Дата</th>
|
||||
<th className="data-table__head-cell">Счёт</th>
|
||||
<th className="data-table__head-cell">Сумма</th>
|
||||
<th className="data-table__head-cell">Описание</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"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="data-table__body">
|
||||
{transactions.map((tx) => (
|
||||
<tr
|
||||
key={tx.id}
|
||||
className={
|
||||
!tx.isCategoryConfirmed && tx.categoryId
|
||||
? 'row-unconfirmed'
|
||||
: ''
|
||||
? 'data-table__row data-table__row--unconfirmed'
|
||||
: 'data-table__row'
|
||||
}
|
||||
>
|
||||
<td className="td-nowrap">
|
||||
<td className="data-table__cell data-table__cell--nowrap">
|
||||
{formatDateTime(tx.operationAt)}
|
||||
</td>
|
||||
<td className="td-nowrap">{tx.accountAlias || '—'}</td>
|
||||
<td className="data-table__cell data-table__cell--nowrap">{tx.accountAlias || '—'}</td>
|
||||
<td
|
||||
className={`td-nowrap td-amount ${DIRECTION_CLASSES[tx.direction] ?? ''}`}
|
||||
className={`data-table__cell data-table__cell--nowrap money-amount ${DIRECTION_CLASSES[tx.direction] ?? ''}`}
|
||||
>
|
||||
{formatAmount(tx.amountSigned)}
|
||||
<div>{formatAmount(tx.amountSigned)}</div>
|
||||
{tx.commission !== 0 && (
|
||||
<div className="data-table__subtext">
|
||||
Комиссия: {formatAmount(getCommissionAmountSigned(tx))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="td-description">
|
||||
<span className="description-text">{tx.description}</span>
|
||||
<td className="data-table__cell data-table__cell--description">
|
||||
<span className="data-table__description">{tx.description}</span>
|
||||
{tx.comment && (
|
||||
<span className="comment-badge" title={tx.comment}>
|
||||
<span className="comment-indicator" title={tx.comment}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="td-nowrap">
|
||||
<td className="data-table__cell data-table__cell--nowrap">
|
||||
{tx.categoryName || (
|
||||
<span className="text-muted">—</span>
|
||||
<span className="text text--muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="td-center">
|
||||
<td className="data-table__cell data-table__cell--center">
|
||||
{tx.categoryId != null && !tx.isCategoryConfirmed && (
|
||||
<span
|
||||
className="badge badge-warning"
|
||||
className="badge badge--warning"
|
||||
title="Категория не подтверждена"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<td className="data-table__cell">
|
||||
<button
|
||||
className="btn-icon"
|
||||
className="icon-button"
|
||||
onClick={() => onEdit(tx)}
|
||||
aria-label="Редактировать операцию"
|
||||
title="Редактировать"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -162,7 +181,7 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="transaction-cards transaction-mobile">
|
||||
<div className="transaction-list transaction-list--mobile">
|
||||
{transactions.map((tx) => (
|
||||
<TransactionCard key={tx.id} tx={tx} onEdit={onEdit} />
|
||||
))}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { getMe, login as apiLogin, logout as apiLogout } from '../api/auth';
|
||||
import { setOnUnauthorized } from '../api/client';
|
||||
|
||||
interface AuthState {
|
||||
user: { login: string } | null;
|
||||
user: { login: string; backendVersion: string } | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
@@ -20,7 +20,7 @@ interface AuthState {
|
||||
const AuthContext = createContext<AuthState | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<{ login: string } | null>(null);
|
||||
const [user, setUser] = useState<{ login: string; backendVersion: string } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -31,7 +31,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
setOnUnauthorized(clearUser);
|
||||
getMe()
|
||||
.then((me) => setUser({ login: me.login }))
|
||||
.then((me) => setUser({ login: me.login, backendVersion: me.backendVersion }))
|
||||
.catch(() => setUser(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [clearUser]);
|
||||
@@ -41,7 +41,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
await apiLogin({ login: username, password });
|
||||
const me = await getMe();
|
||||
setUser({ login: me.login });
|
||||
setUser({ login: me.login, backendVersion: me.backendVersion });
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error && e.message === 'Failed to fetch'
|
||||
? 'Сервер недоступен. Проверьте, что backend запущен и NPM проксирует /api на порт 3000.'
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type {
|
||||
Account,
|
||||
Category,
|
||||
AnalyticsSummaryResponse,
|
||||
ByCategoryItem,
|
||||
TimeseriesItem,
|
||||
Granularity,
|
||||
} from '@family-budget/shared';
|
||||
import { getAccounts } from '../api/accounts';
|
||||
import { getCategories } from '../api/categories';
|
||||
import { getSummary, getByCategory, getTimeseries } from '../api/analytics';
|
||||
import {
|
||||
PeriodSelector,
|
||||
@@ -29,8 +31,10 @@ function getDefaultPeriod(): PeriodState {
|
||||
export function AnalyticsPage() {
|
||||
const [period, setPeriod] = useState<PeriodState>(getDefaultPeriod);
|
||||
const [accountId, setAccountId] = useState<string>('');
|
||||
const [categoryId, setCategoryId] = useState<string>('');
|
||||
const [onlyConfirmed, setOnlyConfirmed] = useState(false);
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [summary, setSummary] = useState<AnalyticsSummaryResponse | null>(
|
||||
null,
|
||||
);
|
||||
@@ -40,6 +44,7 @@ export function AnalyticsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
getAccounts().then(setAccounts).catch(() => {});
|
||||
getCategories({ isActive: true }).then(setCategories).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
@@ -63,6 +68,7 @@ export function AnalyticsPage() {
|
||||
from: period.from,
|
||||
to: period.to,
|
||||
...(accountId ? { accountId: Number(accountId) } : {}),
|
||||
...(categoryId !== '' ? { categoryId: Number(categoryId) } : {}),
|
||||
...(onlyConfirmed ? { onlyConfirmed: true } : {}),
|
||||
};
|
||||
|
||||
@@ -80,7 +86,7 @@ export function AnalyticsPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [period, accountId, onlyConfirmed]);
|
||||
}, [period, accountId, categoryId, onlyConfirmed]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
@@ -88,15 +94,18 @@ export function AnalyticsPage() {
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Аналитика</h1>
|
||||
<div className="page__header">
|
||||
<div>
|
||||
<p className="page__eyebrow">Обзор периода</p>
|
||||
<h1 className="page__title">Аналитика</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="analytics-controls">
|
||||
<div className="analytics-panel">
|
||||
<PeriodSelector period={period} onChange={setPeriod} />
|
||||
<div className="analytics-filters">
|
||||
<div className="filter-group">
|
||||
<label>Счёт</label>
|
||||
<div className="analytics-panel__filters">
|
||||
<div className="field">
|
||||
<label className="field__label">Счёт</label>
|
||||
<select
|
||||
value={accountId}
|
||||
onChange={(e) => setAccountId(e.target.value)}
|
||||
@@ -104,13 +113,13 @@ export function AnalyticsPage() {
|
||||
<option value="">Все счета</option>
|
||||
{accounts.map((a) => (
|
||||
<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>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="filter-group filter-group-checkbox">
|
||||
<label>
|
||||
<div className="field field--checkbox">
|
||||
<label className="field__label field__label--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyConfirmed}
|
||||
@@ -119,21 +128,29 @@ export function AnalyticsPage() {
|
||||
Только подтверждённые
|
||||
</label>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field__label">Категория</label>
|
||||
<select value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
|
||||
<option value="">Все категории</option>
|
||||
<option value="0">Без категории</option>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="section-loading">Загрузка данных...</div>
|
||||
<div className="state state--loading">Загрузка данных...</div>
|
||||
) : (
|
||||
<>
|
||||
{summary && <SummaryCards summary={summary} />}
|
||||
<div className="analytics-charts">
|
||||
<div className="analytics-grid">
|
||||
<div className="chart-card">
|
||||
<h3>Динамика</h3>
|
||||
<h3 className="chart-card__title">Динамика</h3>
|
||||
<TimeseriesChart data={timeseries} />
|
||||
</div>
|
||||
<div className="chart-card">
|
||||
<h3>По категориям</h3>
|
||||
<h3 className="chart-card__title">По категориям</h3>
|
||||
<CategoryChart data={byCategory} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -204,12 +204,20 @@ export function HistoryPage() {
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>История операций</h1>
|
||||
<div className="page__header">
|
||||
<div>
|
||||
<p className="page__eyebrow">Операции</p>
|
||||
<h1 className="page__title">История операций</h1>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
className="button button--primary page__action"
|
||||
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">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7,10 12,15 17,10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
Импорт выписки
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -20,17 +20,17 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<span className="login-icon">₽</span>
|
||||
<h1>Семейный бюджет</h1>
|
||||
<p>Войдите для продолжения</p>
|
||||
<div className="login">
|
||||
<div className="login__panel">
|
||||
<div className="login__header">
|
||||
<span className="login__icon">₽</span>
|
||||
<h1 className="login__title">Семейный бюджет</h1>
|
||||
<p className="login__subtitle">Войдите для продолжения</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<div className="form-group">
|
||||
<label htmlFor="login">Логин</label>
|
||||
<form onSubmit={handleSubmit} className="form login__form">
|
||||
{error && <div className="alert alert--error">{error}</div>}
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor="login">Логин</label>
|
||||
<input
|
||||
id="login"
|
||||
type="text"
|
||||
@@ -41,8 +41,8 @@ export function LoginPage() {
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Пароль</label>
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor="password">Пароль</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
@@ -54,7 +54,7 @@ export function LoginPage() {
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-block"
|
||||
className="button button--primary button--block"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Вход...' : 'Войти'}
|
||||
|
||||
@@ -11,38 +11,41 @@ export function SettingsPage() {
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Настройки</h1>
|
||||
<div className="page__header">
|
||||
<div>
|
||||
<p className="page__eyebrow">Справочники</p>
|
||||
<h1 className="page__title">Настройки</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabs">
|
||||
<button
|
||||
className={`tab ${tab === 'accounts' ? 'active' : ''}`}
|
||||
className={`tabs__button ${tab === 'accounts' ? 'tabs__button--active' : ''}`}
|
||||
onClick={() => setTab('accounts')}
|
||||
>
|
||||
Счета
|
||||
</button>
|
||||
<button
|
||||
className={`tab ${tab === 'categories' ? 'active' : ''}`}
|
||||
className={`tabs__button ${tab === 'categories' ? 'tabs__button--active' : ''}`}
|
||||
onClick={() => setTab('categories')}
|
||||
>
|
||||
Категории
|
||||
</button>
|
||||
<button
|
||||
className={`tab ${tab === 'rules' ? 'active' : ''}`}
|
||||
className={`tabs__button ${tab === 'rules' ? 'tabs__button--active' : ''}`}
|
||||
onClick={() => setTab('rules')}
|
||||
>
|
||||
Правила
|
||||
</button>
|
||||
<button
|
||||
className={`tab ${tab === 'data' ? 'active' : ''}`}
|
||||
className={`tabs__button ${tab === 'data' ? 'tabs__button--active' : ''}`}
|
||||
onClick={() => setTab('data')}
|
||||
>
|
||||
Данные
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tab-content">
|
||||
<div className="tabs__content">
|
||||
{tab === 'accounts' && <AccountsList />}
|
||||
{tab === 'categories' && <CategoriesList />}
|
||||
{tab === 'rules' && <RulesList />}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
2
frontend/src/vite-env.d.ts
vendored
2
frontend/src/vite-env.d.ts
vendored
@@ -1 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __FE_VERSION__: string;
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { readFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, 'package.json'), 'utf-8'),
|
||||
) as { version: string };
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
__FE_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -64,7 +64,7 @@
|
||||
},
|
||||
"frontend": {
|
||||
"name": "@family-budget/frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.9.0",
|
||||
"dependencies": {
|
||||
"@family-budget/shared": "*",
|
||||
"react": "^19.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "family-budget",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"shared",
|
||||
|
||||
232
pdf2json.md
232
pdf2json.md
@@ -1,75 +1,231 @@
|
||||
Ты — конвертер банковских выписок. Твоя задача: извлечь данные из прикреплённого PDF банковской выписки и вернуть строго один валидный JSON-объект в формате ниже. Никакого текста до или после JSON, только сам объект.
|
||||
# Инструкция агента: конвертация банковской выписки ВТБ (PDF → JSON)
|
||||
|
||||
## Структура выходного JSON
|
||||
## Цель
|
||||
|
||||
Преобразовать PDF-выписку банка ВТБ в JSON-файл строго по схеме 1.0.
|
||||
На выходе — валидный JSON-файл без потерь операций, с правильными суммами в копейках и корректной проверкой баланса.
|
||||
|
||||
---
|
||||
|
||||
## Схема JSON (schema 1.0)
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"bank": "<название банка из выписки>",
|
||||
"bank": "VTB",
|
||||
"statement": {
|
||||
"accountNumber": "<номер счёта, только цифры, без пробелов>",
|
||||
"accountNumber": "string",
|
||||
"currency": "RUB",
|
||||
"openingBalance": <число в копейках, целое>,
|
||||
"closingBalance": <число в копейках, целое>,
|
||||
"exportedAt": "<дата экспорта в формате ISO 8601 с offset, например 2026-02-27T13:23:00+03:00>"
|
||||
"openingBalance": integer,
|
||||
"closingBalance": integer,
|
||||
"exportedAt": "ISO 8601 string"
|
||||
},
|
||||
"transactions": [
|
||||
{
|
||||
"operationAt": "<дата и время операции в формате ISO 8601 с offset>",
|
||||
"amountSigned": <число: положительное для прихода, отрицательное для расхода; в копейках>,
|
||||
"commission": <число, целое, >= 0, в копейках>,
|
||||
"description": "<полное описание операции из выписки>"
|
||||
"operationAt": "ISO 8601 string",
|
||||
"amountSigned": integer,
|
||||
"commission": integer,
|
||||
"description": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Правила конвертации
|
||||
---
|
||||
|
||||
1. **Суммы** — всегда в копейках (рубли × 100). Пример: 500,00 ₽ → 50000, -1234,56 ₽ → -123456.
|
||||
## Шаг 1. Извлечение данных из PDF
|
||||
|
||||
2. **amountSigned**:
|
||||
- Приход (зачисление, пополнение) — положительное число.
|
||||
- Расход (списание, оплата) — отрицательное число.
|
||||
- Переводы — знак в зависимости от направления движения на счёт.
|
||||
### 1.1 Шапка выписки
|
||||
|
||||
3. **operationAt** — дата и время операции. Если время не указано, используй 00:00:00. Обязательно указывай offset (+03:00 для МСК).
|
||||
Извлечь из заголовочного блока:
|
||||
|
||||
4. **commission** — комиссия по операции. Если не указана — 0.
|
||||
| Поле PDF | Поле JSON | Примечание |
|
||||
|---------------------------|----------------------------------|-----------------------------------|
|
||||
| Номер счёта | `statement.accountNumber` | строка, без пробелов |
|
||||
| Баланс на начало периода | `statement.openingBalance` | перевести в копейки (× 100) |
|
||||
| Баланс на конец периода | `statement.closingBalance` | перевести в копейки (× 100) |
|
||||
| Поступления | только для валидации | не записывать в JSON |
|
||||
| Расходные операции | только для валидации | не записывать в JSON |
|
||||
| Период выписки | используется для валидации | формат ДД.ММ.ГГГГ – ДД.ММ.ГГГГ |
|
||||
|
||||
5. **description** — полный текст операции как в выписке (назначение платежа, магазин, получатель и т.п.). Не сокращай и не меняй формулировки.
|
||||
### 1.2 Поле `exportedAt`
|
||||
|
||||
6. **accountNumber** — только цифры, без пробелов и дефисов (например: 40817810825104025611).
|
||||
Взять дату и время **первой строки таблицы операций** (самая поздняя по дате операции).
|
||||
Формат: ISO 8601 с московским часовым поясом: `YYYY-MM-DDTHH:MM:SS+03:00`
|
||||
|
||||
7. **openingBalance / closingBalance** — начальный и конечный остаток по счёту в копейках.
|
||||
---
|
||||
|
||||
8. **bank** — краткое название банка (VTB, Sberbank, Тинькофф и т.п.).
|
||||
## Шаг 2. Извлечение операций
|
||||
|
||||
9. **exportedAt** — дата формирования выписки. Если неизвестна — возьми дату последней операции в выписке.
|
||||
### 2.1 Столбцы таблицы в PDF
|
||||
|
||||
10. **Порядок транзакций** — сохраняй хронологический порядок из выписки (обычно от старых к новым).
|
||||
Каждая строка операции содержит:
|
||||
|
||||
## Требования
|
||||
| Столбец PDF | Описание |
|
||||
|-------------------------------------|------------------------------------------------|
|
||||
| Дата и время операции | дата + время совершения операции |
|
||||
| Дата обработки банком | дата, когда банк обработал операцию |
|
||||
| Сумма операции в валюте операции | знаковая сумма (`-` = расход, без знака = приход) |
|
||||
| Приход (в валюте счёта) | сумма, если поступление |
|
||||
| Расход (в валюте счёта) | сумма, если списание |
|
||||
| Комиссия | комиссия банка |
|
||||
| Описание операции | текстовое описание |
|
||||
|
||||
- Массив `transactions` не должен быть пустым.
|
||||
- Все числа — целые.
|
||||
- Даты — строго в формате ISO 8601 с offset.
|
||||
- currency всегда "RUB".
|
||||
- schemaVersion всегда "1.0".
|
||||
### 2.2 Правила маппинга
|
||||
|
||||
## Пример одной транзакции
|
||||
**`operationAt`** — из столбца «Дата и время операции», формат ISO 8601 + московский TZ:
|
||||
```
|
||||
29.03.2026 20:38:01 → 2026-03-29T20:38:01+03:00
|
||||
```
|
||||
|
||||
Выписка: «26.02.2026 14:06 | -500,00 ₽ | 0,00 | Оплата товаров. PAVELETSKAYA по карте *8214»
|
||||
**`amountSigned`** — сумма в **копейках** (целое число):
|
||||
- Если операция — **приход**: значение **положительное**
|
||||
- Если операция — **расход**: значение **отрицательное**
|
||||
- Допустим специальный кейс: `amountSigned = 0`, **только если** `commission > 0` и в `description` есть подстрока **«Зачисление»** (без учёта регистра)
|
||||
- Определять знак по столбцу «Приход»/«Расход», а не по знаку в PDF (там `-` стоит у расходов, но надёжнее смотреть, в какой колонке стоит сумма)
|
||||
- Перевод в копейки: умножить на 100 и округлить до целого (`round(amount * 100)`)
|
||||
- Разделитель дробной части в PDF — точка или запятая — оба варианта обрабатывать
|
||||
|
||||
→
|
||||
**`commission`** — комиссия в **копейках** (целое число):
|
||||
- Если в PDF стоит `0.00` → записать `0`
|
||||
- Если указана ненулевая комиссия → перевести в копейки аналогично `amountSigned`
|
||||
- Комиссия всегда **неотрицательная**
|
||||
|
||||
**`description`** — полный текст описания операции, строка:
|
||||
- Убрать лишние пробелы и переносы строк (привести к одной строке)
|
||||
- Не обрезать и не сокращать
|
||||
- Сохранить кириллицу и спецсимволы как есть
|
||||
|
||||
### 2.3 Порядок операций
|
||||
|
||||
Операции записывать **в том же порядке, что в PDF** (от новых к старым, сверху вниз).
|
||||
|
||||
---
|
||||
|
||||
## Шаг 3. Валидация перед записью
|
||||
|
||||
### 3.1 Проверка баланса
|
||||
|
||||
Вычислить:
|
||||
```
|
||||
income_sum = сумма всех amountSigned > 0 (в копейках)
|
||||
expense_sum = сумма всех amountSigned < 0 (в копейках, отрицательная)
|
||||
```
|
||||
|
||||
Проверить, что:
|
||||
```
|
||||
openingBalance + income_sum + expense_sum == closingBalance
|
||||
```
|
||||
|
||||
**Если не совпадает** — это нормально и объясняется поведением ВТБ:
|
||||
|
||||
> ВТБ формирует итоги в шапке по **дате обработки банком**, а не по дате операции.
|
||||
> Операции с датой обработки **вне периода выписки** включаются в список транзакций, но **не учитываются в строках «Поступления» и «Расходные операции»** в шапке.
|
||||
|
||||
Правильная проверка:
|
||||
1. Определить границы периода из шапки PDF (`дата_начала`, `дата_конца`)
|
||||
2. Из `Поступления` и `Расходные операции` вычислить ожидаемый net:
|
||||
`expected_net = openingBalance_kopecks + income_pdf_kopecks - expense_pdf_kopecks`
|
||||
3. Убедиться, что `expected_net == closingBalance` (это всегда верно если данные из PDF прочитаны правильно)
|
||||
4. Разница между суммой всех транзакций и `(closingBalance - openingBalance)` — это сумма операций, обработанных банком вне периода. Это **не ошибка**.
|
||||
|
||||
### 3.2 Проверка полноты
|
||||
|
||||
- Подсчитать количество извлечённых транзакций
|
||||
- Пройтись по всем страницам PDF и убедиться, что ни одна строка таблицы не пропущена
|
||||
- Особое внимание: операции в конце страницы и в начале следующей (частые точки потери данных)
|
||||
|
||||
### 3.3 Проверка типов
|
||||
|
||||
Убедиться, что:
|
||||
- `openingBalance`, `closingBalance` — целые числа (`int`)
|
||||
- `amountSigned`, `commission` — целые числа (`int`)
|
||||
- `operationAt`, `exportedAt` — строки в формате ISO 8601 с `+03:00`
|
||||
- `description` — строка (не `null`, не пустая)
|
||||
|
||||
---
|
||||
|
||||
## Шаг 4. Частые ошибки — предотвращение
|
||||
|
||||
| Ошибка | Причина | Как избежать |
|
||||
|--------|---------|--------------|
|
||||
| Сумма в рублях вместо копеек | Забыли умножить на 100 | Всегда применять `round(x * 100)` |
|
||||
| Дробные числа вместо int | Потеря точности float | Использовать `round()`, результат кастовать в `int` |
|
||||
| Неверный знак `amountSigned` | Ориентировались на знак в PDF | Определять знак по столбцу «Приход»/«Расход» |
|
||||
| Потеря операций на стыке страниц | Парсинг постраничный | Читать PDF целиком, не разбивать по страницам |
|
||||
| Обрезанное описание | Перенос строки в ячейке PDF | Объединять строки одной ячейки в одну строку |
|
||||
| Комиссия со знаком минус | Перепутали знак | Комиссия всегда `>= 0` |
|
||||
| `amountSigned = 0` без условия | Импорт отклонит файл | Разрешать `amountSigned = 0` только при `commission > 0` и наличии «Зачисление» в `description` |
|
||||
| Неверный `exportedAt` | Взяли дату начала периода | Брать дату и время **первой (самой новой) операции** в таблице |
|
||||
| Расхождение баланса | Не учли операции вне периода | Это не ошибка — см. Шаг 3.1 |
|
||||
| Лишние пробелы в описании | PDF добавляет пробелы при переносе | Применять `.strip()` и нормализацию пробелов |
|
||||
|
||||
---
|
||||
|
||||
## Шаг 5. Запись файла
|
||||
|
||||
- Имя файла: то же, что у исходного PDF, с заменой расширения `.pdf` → `.json`
|
||||
Пример: `file-18.pdf` → `file-18.json`
|
||||
- Кодировка: **UTF-8**
|
||||
- Формат: JSON с отступами (indent = 2)
|
||||
- `ensure_ascii = false` — кириллица записывается как есть, не экранируется
|
||||
|
||||
---
|
||||
|
||||
## Пример корректной транзакции
|
||||
|
||||
```json
|
||||
{
|
||||
"operationAt": "2026-02-26T14:06:00+03:00",
|
||||
"amountSigned": -50000,
|
||||
"operationAt": "2026-03-29T20:38:01+03:00",
|
||||
"amountSigned": -500000,
|
||||
"commission": 0,
|
||||
"description": "Оплата товаров. PAVELETSKAYA по карте *8214"
|
||||
"description": "Оплата товаров и услуг. IP KOVTUN L.B.. по карте *9058"
|
||||
}
|
||||
```
|
||||
|
||||
Обработай прикреплённый PDF и верни один JSON-объект.
|
||||
Расшифровка: списание 5 000.00 ₽ = -500 000 копеек, комиссии нет.
|
||||
|
||||
---
|
||||
|
||||
## Пример корректного поступления
|
||||
|
||||
```json
|
||||
{
|
||||
"operationAt": "2026-03-24T15:09:41+03:00",
|
||||
"amountSigned": 9537522,
|
||||
"commission": 0,
|
||||
"description": "Поступление заработной платы. 0726 НАЦИОНАЛЬНЫЙ ИССЛЕДОВАТЕЛЬСКИЙ УНИВЕРСИТЕТ \"ВЫСША Поступление заработной платы/иных выплат Salary по реестру Z_0000005862_20260323_001_01 от 23.03.2026. Без НДС.."
|
||||
}
|
||||
```
|
||||
|
||||
Расшифровка: поступление 95 375.22 ₽ = +9 537 522 копейки.
|
||||
|
||||
---
|
||||
|
||||
## Пример корректного кэшбэка (сумма в commission)
|
||||
|
||||
```json
|
||||
{
|
||||
"operationAt": "2026-04-07T19:56:59+03:00",
|
||||
"amountSigned": 0,
|
||||
"commission": 592000,
|
||||
"description": "Зачисление кешбэка по программе лояльности."
|
||||
}
|
||||
```
|
||||
|
||||
Расшифровка: для такого кейса `amountSigned = 0` допустим, потому что есть `commission > 0` и маркер «Зачисление» в описании.
|
||||
|
||||
---
|
||||
|
||||
## Итоговый чеклист перед отдачей файла
|
||||
|
||||
- [ ] `schemaVersion` = `"1.0"`
|
||||
- [ ] `bank` = `"VTB"`
|
||||
- [ ] `accountNumber` совпадает с PDF
|
||||
- [ ] `openingBalance` и `closingBalance` в копейках, совпадают с PDF
|
||||
- [ ] `exportedAt` = дата и время первой операции в таблице, формат ISO 8601 +03:00
|
||||
- [ ] Количество транзакций совпадает с количеством строк в PDF
|
||||
- [ ] Все `amountSigned` и `commission` — целые числа
|
||||
- [ ] Если `amountSigned = 0`, то `commission > 0` и в `description` есть «Зачисление» (без учёта регистра)
|
||||
- [ ] Поступления и баланс проверены (Шаг 3.1)
|
||||
- [ ] Файл сохранён в UTF-8, кириллица не экранирована
|
||||
|
||||
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",
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
export type AccountType = 'brokerage' | 'iis' | 'savings' | 'current';
|
||||
export type AccountStatus = 'active' | 'closed';
|
||||
|
||||
export interface Account {
|
||||
id: number;
|
||||
bank: string;
|
||||
accountNumberMasked: string;
|
||||
currency: string;
|
||||
alias: string | null;
|
||||
accountType: AccountType | null;
|
||||
status: AccountStatus;
|
||||
}
|
||||
|
||||
export interface UpdateAccountRequest {
|
||||
alias: string;
|
||||
accountType?: AccountType | null;
|
||||
status?: AccountStatus;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface AnalyticsSummaryParams {
|
||||
from: string;
|
||||
to: string;
|
||||
accountId?: number;
|
||||
categoryId?: number;
|
||||
onlyConfirmed?: boolean;
|
||||
}
|
||||
|
||||
@@ -18,6 +19,13 @@ export interface AnalyticsSummaryResponse {
|
||||
totalExpense: number;
|
||||
totalIncome: number;
|
||||
net: number;
|
||||
cashInflow: number;
|
||||
cashOutflow: number;
|
||||
transferInflow: number;
|
||||
transferOutflow: number;
|
||||
cashNet: number;
|
||||
interestIncome: number;
|
||||
cashbackIncome: number;
|
||||
topCategories: TopCategory[];
|
||||
}
|
||||
|
||||
@@ -25,6 +33,7 @@ export interface ByCategoryParams {
|
||||
from: string;
|
||||
to: string;
|
||||
accountId?: number;
|
||||
categoryId?: number;
|
||||
onlyConfirmed?: boolean;
|
||||
}
|
||||
|
||||
@@ -50,4 +59,5 @@ export interface TimeseriesItem {
|
||||
periodEnd: string;
|
||||
expenseAmount: number;
|
||||
incomeAmount: number;
|
||||
transferOutflow: number;
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ export interface LoginRequest {
|
||||
|
||||
export interface MeResponse {
|
||||
login: string;
|
||||
backendVersion: string;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ export interface CreateCategoryRuleRequest {
|
||||
requiresConfirmation?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateCategoryRuleResponse extends CategoryRule {
|
||||
applied: number;
|
||||
}
|
||||
|
||||
export interface UpdateCategoryRuleRequest {
|
||||
pattern?: string;
|
||||
categoryId?: number;
|
||||
|
||||
@@ -32,6 +32,10 @@ export interface StatementTransaction {
|
||||
amountSigned: number;
|
||||
commission: number;
|
||||
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 {
|
||||
@@ -42,3 +46,50 @@ export interface ImportStatementResponse {
|
||||
duplicatesSkipped: number;
|
||||
totalInFile: number;
|
||||
}
|
||||
|
||||
export interface PortfolioFile {
|
||||
schemaVersion: 'broker-portfolio-1.0';
|
||||
bank: string;
|
||||
accountNumber: string;
|
||||
reportPeriod: { from: string; to: string };
|
||||
reportedAt: string | null;
|
||||
positions: PortfolioPosition[];
|
||||
trades: PortfolioTrade[];
|
||||
}
|
||||
|
||||
export interface PortfolioPosition {
|
||||
instrument: string;
|
||||
isin?: string | null;
|
||||
quantity: string | number;
|
||||
price?: string | number | null;
|
||||
valuation?: string | number | null;
|
||||
}
|
||||
|
||||
export interface PortfolioTrade {
|
||||
sourceId?: string;
|
||||
operationId?: string;
|
||||
instrument: string;
|
||||
isin?: string | null;
|
||||
concludedAt: string;
|
||||
side: string;
|
||||
quantity: string | number;
|
||||
priceCurrency?: string | null;
|
||||
price?: string | number | null;
|
||||
settlementCurrency?: string | null;
|
||||
settlementAmount?: string | number | null;
|
||||
nkd?: string | number | null;
|
||||
settlementCommission?: string | number | null;
|
||||
tradeCommission?: string | number | null;
|
||||
orderId?: string | null;
|
||||
tradeId?: string | null;
|
||||
venue?: string | null;
|
||||
comment?: string | null;
|
||||
}
|
||||
|
||||
export interface ImportPortfolioResponse {
|
||||
accountId: number;
|
||||
reportId: number;
|
||||
importedTrades: number;
|
||||
duplicateTrades: number;
|
||||
positions: number;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export type {
|
||||
ApiError,
|
||||
} from './common';
|
||||
|
||||
export type { Account, UpdateAccountRequest } from './account';
|
||||
export type { Account, AccountType, AccountStatus, UpdateAccountRequest } from './account';
|
||||
|
||||
export type {
|
||||
Category,
|
||||
@@ -24,6 +24,7 @@ export type {
|
||||
|
||||
export type {
|
||||
CategoryRule,
|
||||
CreateCategoryRuleResponse,
|
||||
GetCategoryRulesParams,
|
||||
CreateCategoryRuleRequest,
|
||||
UpdateCategoryRuleRequest,
|
||||
@@ -38,6 +39,10 @@ export type {
|
||||
StatementTransaction,
|
||||
ImportStatementResponse,
|
||||
Import,
|
||||
PortfolioFile,
|
||||
PortfolioPosition,
|
||||
PortfolioTrade,
|
||||
ImportPortfolioResponse,
|
||||
} from './import';
|
||||
|
||||
export type {
|
||||
|
||||
Reference in New Issue
Block a user