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