Compare commits

..

33 Commits

Author SHA1 Message Date
dd21e20dc6 docs: update analytics validation changelog 2026-08-24 22:15:56 +03:00
29c4acd0a9 Merge pull request 'Исправить валидацию фильтров аналитики' (#38) from fix/analytics-query-validation into main
Reviewed-on: #38
2026-08-24 19:09:48 +00:00
8939843462 fix: validate analytics filter ids 2026-08-24 22:06:24 +03:00
ce162855f9 Merge pull request 'Исправить блокеры аналитики после PR #35' (#37) from feature/analytics-fixes into main
Reviewed-on: #37
2026-08-24 18:59:50 +00:00
7671ab76b2 fix: close analytics review findings 2026-08-21 07:23:57 +03:00
cce2ddcf41 Merge pull request 'Откатить PR #35 до исправления замечаний' (#36) from revert/analytics-category-filter into main
Reviewed-on: #36
2026-08-21 04:21:23 +00:00
7154e8f2ea fix: complete analytics edge cases 2026-08-21 06:13:23 +03:00
d86624b9ef Revert "Merge pull request 'Завершить аналитику расходов и доходов' (#35) from feature/analytics-completion into main"
This reverts commit 66c04b0618, reversing
changes made to efc9854064.
2026-08-21 06:10:20 +03:00
66c04b0618 Merge pull request 'Завершить аналитику расходов и доходов' (#35) from feature/analytics-completion into main
Reviewed-on: #35
2026-08-21 03:08:00 +00:00
24b8ed8261 feat: complete analytics category filtering 2026-08-21 00:08:59 +03:00
efc9854064 Merge pull request 'Сохранять порядок операций из выписки' (#34) from feature/transaction-source-order into main
Reviewed-on: #34
2026-08-20 20:58:26 +00:00
9755332204 test: verify imported source positions 2026-08-20 23:25:32 +03:00
669e54f6cb test: cover transaction ordering 2026-08-20 23:24:24 +03:00
ab42caae37 feat: preserve transaction source order 2026-08-20 23:20:56 +03:00
4172b0c8e4 Merge pull request 'Добавить импорт портфеля брокерского отчёта' (#33) from feature/portfolio-holdings into main
Reviewed-on: #33
2026-08-20 20:15:12 +00:00
d2354a61ed fix: validate portfolio trade identifiers 2026-08-20 15:04:04 +03:00
6db1312fb1 test: cover portfolio import idempotency 2026-08-20 15:02:47 +03:00
e0da7e86ad fix: harden portfolio import 2026-08-20 15:01:54 +03:00
8d91bea7a9 feat: add broker portfolio import 2026-08-20 14:55:53 +03:00
0003d92583 Merge pull request 'Применять правило ко всем похожим операциям' (#32) from feature/apply-category-rule into main
Reviewed-on: #32
2026-08-20 06:25:49 +00:00
6515e73ead feat: apply category rules on creation 2026-08-20 09:18:17 +03:00
5f282d199d Merge pull request 'Добавить типы счетов и доход от процентов' (#31) from feature/account-metadata-interest into main
Reviewed-on: #31
2026-08-20 06:15:31 +00:00
3d7d0e639c test: fix import integration cleanup 2026-08-20 08:47:53 +03:00
1b20b76704 test: cover account metadata reimport 2026-08-20 08:46:49 +03:00
c6b79c1d35 fix: finalize account labels and confirmation 2026-08-20 08:45:22 +03:00
dd2990357a fix: allow investment rules and label accounts 2026-08-20 08:43:29 +03:00
6550e2057c feat: add account metadata and interest analytics 2026-08-20 08:40:03 +03:00
f8e4f86a6d Merge pull request 'Исправить строки футера боковой панели' (#30) from fix/sidebar-footer-layout into main
Reviewed-on: #30
2026-08-20 04:51:51 +00:00
e1043bed36 fix: align sidebar footer metadata 2026-08-20 07:49:12 +03:00
a090ce37ad Merge pull request 'Спрятать production-секреты Docker в env' (#29) from fix/production-env-secrets into main
Reviewed-on: #29
2026-08-19 21:22:10 +00:00
1142a9755a fix: exclude env files from Docker context 2026-08-20 00:20:59 +03:00
bb3789c01c fix: inject production secrets via env file 2026-08-20 00:18:58 +03:00
2422ea88fd Merge pull request 'Добавить конвертацию брокерского XLSX' (#28) from feature/broker-xlsx-converter into main
Reviewed-on: #28
2026-08-19 15:01:35 +00:00
37 changed files with 754 additions and 61 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.git
.gitignore
**/.env
**/.env.*
**/node_modules
**/dist
**/__pycache__
temp
*.log

View File

@@ -1,5 +1,119 @@
# Changelog # Changelog
## [Backend 0.10.2] - 2026-08-24
### Fixed
- Analytics endpoints now reject invalid account and category filter IDs with a validation error.
## [Frontend 0.11.1] - 2026-08-21
### Fixed
- Preserve the explicit «Без категории» analytics filter value when requesting data.
## [Frontend 0.11.0 / Backend 0.10.1 / Shared 0.5.0] - 2026-08-21
### Fixed
- Corrected analytics period boundaries for partial week/month ranges and added a usable «Без категории» filter with SQL coverage.
## [Backend 0.9.2] - 2026-08-20
### Added
- Extended import integration coverage to verify source positions for same-date operations.
## [Backend 0.9.1] - 2026-08-20
### Added
- Added runnable coverage for date and amount sorting tie-breakers.
## [Backend 0.9.0] - 2026-08-20
### Added
- Preserved source-array transaction positions and used them as stable tie-breakers for same-date history sorting.
## [Backend 0.8.3] - 2026-08-20
### Fixed
- Invalid non-string trade identifiers now return validation errors instead of server errors.
## [Backend 0.8.2] - 2026-08-20
### Added
- Added a database integration test for repeated portfolio imports and trade deduplication.
## [Backend 0.8.1] - 2026-08-20
### Fixed
- Hardened portfolio import validation, preserved decimal precision, added fallback trade keys, and covered validation with a runnable test.
## [Backend 0.8.0 / Shared 0.4.0] - 2026-08-20
### Added
- Added portfolio JSON import for broker reports with idempotent trades, position snapshots, and separate portfolio tables.
## [Backend 0.7.5 / Shared 0.3.1] - 2026-08-20
### Added
- Creating a category rule now applies it atomically to all matching unconfirmed transactions and returns the affected count.
## [Backend 0.7.4] - 2026-08-20
### Fixed
- Corrected database integration test connection ownership during repeated imports.
## [Backend 0.7.3] - 2026-08-20
### Added
- Added a runnable database integration test for preserving account metadata during re-import.
## [Frontend 0.10.2 / Backend 0.7.2] - 2026-08-20
### Fixed
- Existing uncategorized operations become confirmed investments when an investment account is labeled; account selectors use Russian labels.
## [Frontend 0.10.1 / Backend 0.7.1] - 2026-08-20
### Fixed
- Account labels are shown in analytics filters, and account defaults now remain overridable by matching category rules.
## [Frontend 0.10.0 / Backend 0.7.0 / Shared 0.3.0] - 2026-08-20
### Added
- Added account type/status labels, automatic investment classification for brokerage, IIS, and savings accounts, and a separate interest-income metric.
## [Frontend 0.9.3] - 2026-08-20
### Fixed
- Aligned sidebar versions and copyright as separate footer lines and updated the copyright period.
## [Backend 0.6.8] - 2026-08-20
### Fixed
- Docker build context now excludes environment files and other local secret-bearing artifacts.
## [Backend 0.6.7] - 2026-08-20
### Changed
- Docker Compose now injects backend configuration from the untracked `backend/.env`; secrets are no longer hard-coded or copied into the image.
## [Backend 0.6.6] - 2026-08-19 ## [Backend 0.6.6] - 2026-08-19
### Fixed ### Fixed

View File

@@ -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"]

View File

@@ -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`).
## Скрипты ## Скрипты

View File

@@ -1,6 +1,6 @@
{ {
"name": "@family-budget/backend", "name": "@family-budget/backend",
"version": "0.6.6", "version": "0.10.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "tsx watch src/app.ts", "dev": "tsx watch src/app.ts",
@@ -9,7 +9,12 @@
"migrate": "tsx src/db/migrate.ts", "migrate": "tsx src/db/migrate.ts",
"migrate:prod": "node dist/db/migrate.js", "migrate:prod": "node dist/db/migrate.js",
"test:analytics": "tsx src/services/analyticsSemantics.test.ts", "test:analytics": "tsx src/services/analyticsSemantics.test.ts",
"test:analytics:query": "tsx src/routes/analytics.test.ts",
"test:portfolio": "tsx src/services/portfolio.test.ts",
"test:portfolio:db": "NODE_ENV=test tsx src/services/portfolio.integration.test.ts",
"test:transactions": "tsx src/services/transactions.test.ts",
"test:analytics:db": "NODE_ENV=test tsx src/services/analytics.integration.test.ts", "test: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": {

View File

@@ -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(
( (

View File

@@ -222,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> {

View File

@@ -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;

View File

@@ -0,0 +1,12 @@
import assert from 'node:assert/strict';
import { parseOptionalId } from './analytics';
assert.equal(parseOptionalId(undefined, 0), undefined);
assert.equal(parseOptionalId('0', 0), 0);
assert.equal(parseOptionalId('1', 1), 1);
assert.equal(parseOptionalId('0', 1), null);
assert.equal(parseOptionalId('abc', 0), null);
assert.equal(parseOptionalId('1.5', 0), null);
assert.equal(parseOptionalId(['1'], 0), null);
console.log('analytics query validation: OK');

View File

@@ -5,10 +5,34 @@ import type { Granularity } from '@family-budget/shared';
const router = Router(); const router = Router();
export function parseOptionalId(value: unknown, minimum: number): number | null | undefined {
if (value === undefined) return undefined;
if (typeof value !== 'string' || !/^\d+$/.test(value)) return null;
const id = Number(value);
return Number.isSafeInteger(id) && id >= minimum ? id : null;
}
router.use((req, res, next) => {
const accountId = parseOptionalId(req.query.accountId, 1);
if (accountId === null) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'accountId must be a positive integer' });
return;
}
const categoryId = parseOptionalId(req.query.categoryId, 0);
if (categoryId === null) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'categoryId must be a non-negative integer' });
return;
}
next();
});
router.get( 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 +42,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 +52,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 +62,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 +88,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,
}); });

View 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;

View File

@@ -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();
}
} }

View File

@@ -6,7 +6,7 @@ async function testQueries(): Promise<void> {
const client = await pool.connect(); const client = await pool.connect();
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
const account = await client.query("INSERT INTO accounts (bank, account_number, currency) VALUES ('TEST', 'analytics-test-1', 'RUB') RETURNING id"); 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 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 categories = await client.query("INSERT INTO categories (name, type) VALUES ('Тест расход', 'expense'), ('Тест доход', 'income'), ('Тест перевод', 'transfer') RETURNING id, type");
const accountId = Number(account.rows[0].id); const accountId = Number(account.rows[0].id);
@@ -22,17 +22,39 @@ async function testQueries(): Promise<void> {
await insert(accountId, '2026-07-03T12:00:00+03:00', 20_000, categoryId.income, 'analytics-test-3'); 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-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 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(otherAccountId, '2026-07-01T12:00:00+03:00', -99_000, categoryId.expense, 'analytics-test-6');
await insert(accountId, '2026-08-01T12:00:00+03:00', -7_000, categoryId.expense, 'analytics-test-7'); await insert(accountId, '2026-08-01T12:00:00+03:00', -7_000, categoryId.expense, 'analytics-test-7');
await client.query(
"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 params = { from: '2026-07-01', to: '2026-07-31', accountId, onlyConfirmed: true };
const summary = await getSummary(params, client); const summary = await getSummary(params, client);
assert.deepEqual({ expense: summary.totalExpense, income: summary.totalIncome, net: summary.net, transferOut: summary.transferOutflow }, { expense: 6_000, income: 15_000, net: 9_000, transferOut: 3_000 }); 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.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]); assert.deepEqual((await getByCategory(params, client)).map((item) => item.amount), [6_000]);
const timeseries = await getTimeseries({ ...params, granularity: 'month' }, client); const timeseries = await getTimeseries({ ...params, granularity: 'month' }, client);
assert.equal(timeseries[0].expenseAmount, 6_000); assert.equal(timeseries[0].expenseAmount, 6_000);
assert.equal(timeseries[0].incomeAmount, 15_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 { } finally {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
client.release(); client.release();

View File

@@ -14,6 +14,7 @@ interface BaseParams {
from: string; from: string;
to: string; to: string;
accountId?: number; accountId?: number;
categoryId?: number;
onlyConfirmed?: boolean; onlyConfirmed?: boolean;
} }
@@ -28,9 +29,14 @@ function analyticsTransactions(where: string): string {
COALESCE(c.type, t.direction) AS analytic_type, COALESCE(c.type, t.direction) AS analytic_type,
COALESCE(t.category_id, 0) AS category_id, COALESCE(t.category_id, 0) AS category_id,
COALESCE(c.name, 'Без категории') AS category_name, COALESCE(c.name, 'Без категории') AS category_name,
${effectiveAmount} AS effective_amount ${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 FROM transactions t
LEFT JOIN categories c ON c.id = t.category_id LEFT JOIN categories c ON c.id = t.category_id
LEFT JOIN accounts a ON a.id = t.account_id
${where} ${where}
)`; )`;
} }
@@ -56,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');
} }
@@ -73,7 +86,8 @@ export async function getSummary(
const totalsResult = await db.query( const totalsResult = await db.query(
`${analyticsTransactions(where)}, `${analyticsTransactions(where)},
category_net AS ( category_net AS (
SELECT category_id, category_name, analytic_type, SUM(effective_amount)::bigint AS amount SELECT category_id, category_name, analytic_type, SUM(effective_amount)::bigint AS amount,
SUM(interest_income)::bigint AS interest_income
FROM analytics_transactions FROM analytics_transactions
GROUP BY category_id, category_name, analytic_type GROUP BY category_id, category_name, analytic_type
) )
@@ -83,7 +97,8 @@ export async function getSummary(
COALESCE((SELECT SUM(GREATEST(effective_amount, 0)) FROM analytics_transactions), 0)::bigint AS cash_inflow, COALESCE((SELECT SUM(GREATEST(effective_amount, 0)) FROM analytics_transactions), 0)::bigint AS cash_inflow,
COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions), 0)::bigint AS cash_outflow, COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions), 0)::bigint AS cash_outflow,
COALESCE((SELECT SUM(GREATEST(effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_inflow, COALESCE((SELECT SUM(GREATEST(effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_inflow,
COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_outflow COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_outflow,
COALESCE(SUM(interest_income), 0)::bigint AS interest_income
FROM category_net`, FROM category_net`,
values, values,
); );
@@ -94,6 +109,7 @@ export async function getSummary(
const cashOutflow = Number(totalsResult.rows[0].cash_outflow); const cashOutflow = Number(totalsResult.rows[0].cash_outflow);
const transferInflow = Number(totalsResult.rows[0].transfer_inflow); const transferInflow = Number(totalsResult.rows[0].transfer_inflow);
const transferOutflow = Number(totalsResult.rows[0].transfer_outflow); const transferOutflow = Number(totalsResult.rows[0].transfer_outflow);
const interestIncome = Number(totalsResult.rows[0].interest_income);
const topResult = await db.query( const topResult = await db.query(
`${analyticsTransactions(where)} `${analyticsTransactions(where)}
@@ -123,6 +139,7 @@ export async function getSummary(
transferInflow, transferInflow,
transferOutflow, transferOutflow,
cashNet: cashInflow - cashOutflow, cashNet: cashInflow - cashOutflow,
interestIncome,
topCategories, topCategories,
}; };
} }
@@ -187,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',
]; ];
@@ -198,9 +217,12 @@ export async function getTimeseries(
values.push(params.accountId); values.push(params.accountId);
} }
if (params.categoryId != null) { if (params.categoryId != null) {
if (params.categoryId === 0) txConditions.push('t.category_id IS NULL');
else {
txConditions.push(`t.category_id = $${idx++}`); txConditions.push(`t.category_id = $${idx++}`);
values.push(params.categoryId); values.push(params.categoryId);
} }
}
if (params.onlyConfirmed) { if (params.onlyConfirmed) {
txConditions.push('t.is_category_confirmed = TRUE'); txConditions.push('t.is_category_confirmed = TRUE');
} }

View File

@@ -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();
try {
await client.query('BEGIN');
const { rows } = await client.query(
`INSERT INTO category_rules (pattern, match_type, category_id, priority, requires_confirmation) `INSERT INTO category_rules (pattern, match_type, category_id, priority, requires_confirmation)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5) RETURNING *`,
RETURNING *`,
[pattern, matchType, body.categoryId, priority, requiresConfirmation], [pattern, matchType, body.categoryId, priority, requiresConfirmation],
); );
const match = matchType === 'starts_with'
const catResult = await pool.query('SELECT name FROM categories WHERE id = $1', [body.categoryId]); ? `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; rows[0].category_name = catResult.rows[0].name;
return toRule(rows[0]); 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(

View 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();

View File

@@ -154,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;
@@ -162,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');
@@ -171,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],
); );
@@ -206,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 &&
@@ -218,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_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, COALESCE($2::uuid, gen_random_uuid()), $3, $4, $5, $6, $7, $8, $9, $10, $11) VALUES ($1, COALESCE($2::uuid, gen_random_uuid()), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
RETURNING id`, RETURNING id`,
[accountId, tx.operationId ?? null, 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) {
@@ -263,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');

View 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();

View 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');

View 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();
}
}

View 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');

View File

@@ -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],
); );

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@family-budget/frontend", "name": "@family-budget/frontend",
"version": "0.9.2", "version": "0.11.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -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(

View File

@@ -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)),
@@ -48,6 +54,8 @@ export function AccountsList() {
<th className="data-table__head-cell">Номер счёта</th> <th className="data-table__head-cell">Номер счёта</th>
<th className="data-table__head-cell">Валюта</th> <th className="data-table__head-cell">Валюта</th>
<th className="data-table__head-cell">Алиас</th> <th className="data-table__head-cell">Алиас</th>
<th className="data-table__head-cell">Тип</th>
<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>
@@ -76,6 +84,25 @@ export function AccountsList() {
) )
)} )}
</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"> <td className="data-table__cell">
{editingId === a.id ? ( {editingId === a.id ? (
<div className="button-group"> <div className="button-group">
@@ -105,7 +132,7 @@ export function AccountsList() {
))} ))}
{accounts.length === 0 && ( {accounts.length === 0 && (
<tr className="data-table__row"> <tr className="data-table__row">
<td colSpan={5} className="data-table__cell data-table__cell--center text text--muted"> <td colSpan={7} className="data-table__cell data-table__cell--center text text--muted">
Нет счетов. Импортируйте выписку. Нет счетов. Импортируйте выписку.
</td> </td>
</tr> </tr>

View File

@@ -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">© 20252026 Семейный бюджет</span>
</div> </div>
</div> </div>
</aside> </aside>

View File

@@ -40,6 +40,7 @@ export function SummaryCards({ summary }: Props) {
</div> </div>
<div className="summary__subvalue">Поступило: {formatAmount(summary.cashInflow)}</div> <div className="summary__subvalue">Поступило: {formatAmount(summary.cashInflow)}</div>
<div className="summary__subvalue">Списано: {formatAmount(summary.cashOutflow)}</div> <div className="summary__subvalue">Списано: {formatAmount(summary.cashOutflow)}</div>
<div className="summary__subvalue">Доход от процентов: {formatAmount(summary.interestIncome)}</div>
{(summary.transferInflow > 0 || summary.transferOutflow > 0) && ( {(summary.transferInflow > 0 || summary.transferOutflow > 0) && (
<div className="summary__subvalue"> <div className="summary__subvalue">
Переводы: {formatAmount(summary.transferInflow)} / {formatAmount(summary.transferOutflow)} Переводы: {formatAmount(summary.transferInflow)} / {formatAmount(summary.transferOutflow)}

View File

@@ -187,7 +187,7 @@ 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>

View File

@@ -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();
@@ -107,7 +113,7 @@ 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>
@@ -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>

View File

@@ -242,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;

View File

@@ -1,6 +1,6 @@
{ {
"name": "@family-budget/shared", "name": "@family-budget/shared",
"version": "0.2.2", "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",

View File

@@ -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;
} }

View File

@@ -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;
} }
@@ -23,6 +24,7 @@ export interface AnalyticsSummaryResponse {
transferInflow: number; transferInflow: number;
transferOutflow: number; transferOutflow: number;
cashNet: number; cashNet: number;
interestIncome: number;
topCategories: TopCategory[]; topCategories: TopCategory[];
} }
@@ -30,6 +32,7 @@ export interface ByCategoryParams {
from: string; from: string;
to: string; to: string;
accountId?: number; accountId?: number;
categoryId?: number;
onlyConfirmed?: boolean; onlyConfirmed?: boolean;
} }

View File

@@ -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;

View File

@@ -46,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;
}

View File

@@ -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 {