Compare commits

...

17 Commits

Author SHA1 Message Date
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
3af9e9dbfe fix: skip operation UUID conflicts on import 2026-08-19 17:50:07 +03:00
b99555ca23 fix: validate duplicate operation UUIDs 2026-08-19 17:48:39 +03:00
5c059cbc62 feat: assign deterministic operation UUIDs 2026-08-19 17:46:50 +03:00
3d08c9e5c6 fix: validate broker XLSX closing balance 2026-08-19 15:56:37 +03:00
076ab7b055 feat: add VTB broker XLSX converter 2026-08-19 15:53:58 +03:00
e78445888f Merge pull request 'feat(analytics): net cashflow reporting' (#26) from feature/net-analytics-cashflow into main
Reviewed-on: #26
2026-08-19 11:24:35 +00:00
26 changed files with 527 additions and 40 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,78 @@
# Changelog
## [Backend 0.7.4] - 2026-08-20
### Fixed
- Corrected database integration test connection ownership during repeated imports.
## [Backend 0.7.3] - 2026-08-20
### Added
- Added a runnable database integration test for preserving account metadata during re-import.
## [Frontend 0.10.2 / Backend 0.7.2] - 2026-08-20
### Fixed
- Existing uncategorized operations become confirmed investments when an investment account is labeled; account selectors use Russian labels.
## [Frontend 0.10.1 / Backend 0.7.1] - 2026-08-20
### Fixed
- Account labels are shown in analytics filters, and account defaults now remain overridable by matching category rules.
## [Frontend 0.10.0 / Backend 0.7.0 / Shared 0.3.0] - 2026-08-20
### Added
- Added account type/status labels, automatic investment classification for brokerage, IIS, and savings accounts, and a separate interest-income metric.
## [Frontend 0.9.3] - 2026-08-20
### Fixed
- Aligned sidebar versions and copyright as separate footer lines and updated the copyright period.
## [Backend 0.6.8] - 2026-08-20
### Fixed
- Docker build context now excludes environment files and other local secret-bearing artifacts.
## [Backend 0.6.7] - 2026-08-20
### Changed
- Docker Compose now injects backend configuration from the untracked `backend/.env`; secrets are no longer hard-coded or copied into the image.
## [Backend 0.6.6] - 2026-08-19
### Fixed
- Re-importing an existing operation UUID now safely skips the row even if other source fields changed.
## [Backend 0.6.5] - 2026-08-19
### Fixed
- Duplicate operation UUIDs in one import file now return a validation error instead of a database failure.
## [Frontend 0.9.2 / Backend 0.6.4 / Shared 0.2.2] - 2026-08-19
### Added
- Added deterministic UUIDs for imported operations and a unique database constraint per account.
## [Frontend 0.9.1 / Backend 0.6.3 / Shared 0.2.1] - 2026-08-19
### Added
- Added a standard-library VTB broker XLSX converter for cash movements and a portfolio/trades sidecar JSON.
- Added optional source operation IDs so repeated statement imports remain idempotent without collapsing legitimate identical operations.
## [Frontend 0.9.0 / Backend 0.6.2] - 2026-08-19
### Changed

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/node_modules ./node_modules
COPY --from=build /app/backend/node_modules/ ./node_modules/
COPY backend/.env .env
EXPOSE 3000
CMD ["node","dist/app.js"]

View File

@@ -21,6 +21,8 @@ createdb family_budget
npm run dev -w backend
```
Для Docker Compose production-переменные передаются из `backend/.env`. Файл не хранится в Git и не копируется в Docker image; перед запуском создайте его из `.env.example` и заполните секреты.
Сервер стартует на `http://localhost:3000` (или на порту из `PORT`).
## Скрипты

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/backend",
"version": "0.6.2",
"version": "0.7.4",
"private": true,
"scripts": {
"dev": "tsx watch src/app.ts",
@@ -10,6 +10,7 @@
"migrate:prod": "node dist/db/migrate.js",
"test:analytics": "tsx src/services/analyticsSemantics.test.ts",
"test:analytics:db": "NODE_ENV=test tsx src/services/analytics.integration.test.ts",
"test:import:db": "NODE_ENV=test tsx src/services/import.integration.test.ts",
"test:llm": "tsx src/scripts/testLlm.ts"
},
"dependencies": {

View File

@@ -133,6 +133,16 @@ const migrations: { name: string; sql: string }[] = [
AND NOT EXISTS (SELECT 1 FROM category_rules LIMIT 1);
`,
},
{
name: '006_transaction_operation_uuid',
sql: `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
ALTER TABLE transactions
ADD COLUMN IF NOT EXISTS operation_id UUID NOT NULL DEFAULT gen_random_uuid();
CREATE UNIQUE INDEX IF NOT EXISTS ux_transactions_account_operation_id
ON transactions(account_id, operation_id);
`,
},
{
name: '005_imports_table',
sql: `
@@ -212,6 +222,22 @@ const migrations: { name: string; sql: string }[] = [
ADD COLUMN IF NOT EXISTS import_id BIGINT REFERENCES imports(id);
`,
},
{
name: '007_account_metadata',
sql: `
ALTER TABLE accounts
ADD COLUMN IF NOT EXISTS account_type TEXT,
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active';
ALTER TABLE accounts DROP CONSTRAINT IF EXISTS chk_accounts_type;
ALTER TABLE accounts
ADD CONSTRAINT chk_accounts_type
CHECK (account_type IS NULL OR account_type IN ('brokerage', 'iis', 'savings', 'current'));
ALTER TABLE accounts DROP CONSTRAINT IF EXISTS chk_accounts_status;
ALTER TABLE accounts
ADD CONSTRAINT chk_accounts_status
CHECK (status IN ('active', 'closed'));
`,
},
];
export async function runMigrations(): Promise<void> {

View File

@@ -21,7 +21,7 @@ router.put(
return;
}
const { alias } = req.body;
const { alias, accountType, status } = req.body;
if (typeof alias !== 'string' || !alias.trim()) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'alias is required and must be non-empty' });
return;
@@ -30,8 +30,21 @@ router.put(
res.status(400).json({ error: 'BAD_REQUEST', message: 'alias must be at most 50 characters' });
return;
}
if (accountType !== undefined && accountType !== null && !['brokerage', 'iis', 'savings', 'current'].includes(accountType)) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'Invalid accountType' });
return;
}
if (status !== undefined && !['active', 'closed'].includes(status)) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'Invalid status' });
return;
}
const result = await accountService.updateAccountAlias(id, alias.trim());
const result = await accountService.updateAccount(
id,
alias.trim(),
accountType,
status,
);
if (!result) {
res.status(404).json({ error: 'NOT_FOUND', message: 'Account not found' });
return;

View File

@@ -1,6 +1,6 @@
import { pool } from '../db/pool';
import { maskAccountNumber } from '../utils';
import type { Account } from '@family-budget/shared';
import type { Account, AccountStatus, AccountType } from '@family-budget/shared';
function toAccount(r: Record<string, unknown>): Account {
return {
@@ -9,6 +9,8 @@ function toAccount(r: Record<string, unknown>): Account {
accountNumberMasked: maskAccountNumber(r.account_number as string),
currency: r.currency as string,
alias: (r.alias as string) ?? null,
accountType: (r.account_type as AccountType) ?? null,
status: (r.status as AccountStatus) ?? 'active',
};
}
@@ -19,14 +21,45 @@ export async function getAccounts(): Promise<Account[]> {
return rows.map(toAccount);
}
export async function updateAccountAlias(
export async function updateAccount(
id: number,
alias: string,
accountType?: AccountType | null,
status?: AccountStatus,
): Promise<Account | null> {
const { rows } = await pool.query(
'UPDATE accounts SET alias = $1 WHERE id = $2 RETURNING *',
[alias, id],
);
if (rows.length === 0) return null;
return toAccount(rows[0]);
const client = await pool.connect();
try {
await client.query('BEGIN');
const current = await client.query('SELECT account_type, status FROM accounts WHERE id = $1', [id]);
if (current.rows.length === 0) {
await client.query('ROLLBACK');
return null;
}
const nextType = accountType === undefined ? current.rows[0].account_type : accountType;
const nextStatus = status ?? current.rows[0].status ?? 'active';
const { rows } = await client.query(
'UPDATE accounts SET alias = $1, account_type = $2, status = $3 WHERE id = $4 RETURNING *',
[alias, nextType, nextStatus, id],
);
if (rows.length === 0) {
await client.query('ROLLBACK');
return null;
}
if (['brokerage', 'iis', 'savings'].includes(nextType ?? '')) {
await client.query(
`UPDATE transactions
SET category_id = (SELECT id FROM categories WHERE name = 'Инвестиции' AND type = 'transfer' LIMIT 1),
direction = 'transfer', is_category_confirmed = TRUE, updated_at = NOW()
WHERE account_id = $1 AND category_id IS NULL`,
[id],
);
}
await client.query('COMMIT');
return toAccount(rows[0]);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}

View File

@@ -6,7 +6,7 @@ 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) 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 categories = await client.query("INSERT INTO categories (name, type) VALUES ('Тест расход', 'expense'), ('Тест доход', 'income'), ('Тест перевод', 'transfer') RETURNING id, type");
const accountId = Number(account.rows[0].id);
@@ -22,12 +22,16 @@ 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-04T12:00:00+03:00', -5_000, categoryId.income, 'analytics-test-4');
await insert(accountId, '2026-07-05T12:00:00+03:00', -3_000, categoryId.transfer, 'analytics-test-5');
await client.query(
"INSERT INTO transactions (account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed) VALUES ($1, '2026-07-06T12:00:00+03:00', 1_500, 0, 'Начисление процентов', 'transfer', 'analytics-test-interest', $2, TRUE)",
[accountId, categoryId.transfer],
);
await insert(otherAccountId, '2026-07-01T12:00:00+03:00', -99_000, categoryId.expense, 'analytics-test-6');
await insert(accountId, '2026-08-01T12:00:00+03:00', -7_000, categoryId.expense, 'analytics-test-7');
const params = { from: '2026-07-01', to: '2026-07-31', accountId, onlyConfirmed: true };
const summary = await getSummary(params, client);
assert.deepEqual({ expense: summary.totalExpense, income: summary.totalIncome, net: summary.net, transferOut: summary.transferOutflow }, { 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 });
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);

View File

@@ -28,9 +28,14 @@ function analyticsTransactions(where: string): string {
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
${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}
)`;
}
@@ -73,7 +78,8 @@ export async function getSummary(
const totalsResult = await db.query(
`${analyticsTransactions(where)},
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
GROUP BY category_id, category_name, analytic_type
)
@@ -83,7 +89,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_outflow,
COALESCE((SELECT SUM(GREATEST(effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_inflow,
COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_outflow
COALESCE((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`,
values,
);
@@ -94,6 +101,7 @@ export async function getSummary(
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 db.query(
`${analyticsTransactions(where)}
@@ -123,6 +131,7 @@ export async function getSummary(
transferInflow,
transferOutflow,
cashNet: cashInflow - cashOutflow,
interestIncome,
topCategories,
};
}

View File

@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import { pool } from '../db/pool';
import { importStatement } from './import';
const makeStatement = (sourceId: string) => ({
schemaVersion: '1.0', bank: 'TEST',
statement: { accountNumber: 'metadata-test', currency: 'RUB', openingBalance: 0, closingBalance: 100, exportedAt: '2026-08-20T12:00:00+03:00' },
transactions: [{ operationAt: '2026-08-20T10:00:00+03:00', amountSigned: 100, commission: 0, description: 'Пополнение', sourceId }],
});
async function run(): Promise<void> {
try {
await importStatement(makeStatement('first'));
const account = await pool.query("UPDATE accounts SET account_type = 'savings', status = 'closed' WHERE bank = 'TEST' AND account_number = 'metadata-test' RETURNING id");
assert.equal(account.rows.length, 1);
await importStatement(makeStatement('second'));
const result = await pool.query('SELECT account_type, status FROM accounts WHERE id = $1', [account.rows[0].id]);
assert.deepEqual(result.rows[0], { account_type: 'savings', status: 'closed' });
console.log('import metadata SQL: OK');
} finally {
await pool.query('DELETE FROM transactions WHERE account_id IN (SELECT id FROM accounts WHERE bank = \'TEST\' AND account_number = \'metadata-test\')');
await pool.query("DELETE FROM imports WHERE account_id IN (SELECT id FROM accounts WHERE bank = 'TEST' AND account_number = 'metadata-test')");
await pool.query("DELETE FROM accounts WHERE bank = 'TEST' AND account_number = 'metadata-test'");
await pool.end();
}
}
run();

View File

@@ -9,11 +9,17 @@ const TRANSFER_PHRASES = [
'внутри втб',
];
const CASHBACK_KEYWORD = 'зачисление';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function computeFingerprint(
accountNumber: string,
tx: { operationAt: string; amountSigned: number; commission: number; description: string },
tx: { operationAt: string; amountSigned: number; commission: number; description: string; sourceId?: 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 = [
accountNumber,
tx.operationAt,
@@ -91,6 +97,12 @@ function validateStructure(body: unknown): ValidationError | null {
if (typeof t.description !== 'string' || !t.description) {
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].description must be a non-empty string` };
}
if (t.sourceId !== undefined && (typeof t.sourceId !== 'string' || !t.sourceId.trim())) {
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].sourceId must be a non-empty string when provided` };
}
if (t.operationId !== undefined && (typeof t.operationId !== 'string' || !UUID_RE.test(t.operationId))) {
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].operationId must be a valid UUID when provided` };
}
if (typeof t.amountSigned !== 'number' || !Number.isInteger(t.amountSigned)) {
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].amountSigned must be an integer` };
}
@@ -121,12 +133,20 @@ function validateSemantics(data: StatementFile): ValidationError | null {
}
const fps = new Set<string>();
const operationIds = new Set<string>();
for (let i = 0; i < data.transactions.length; i++) {
const fp = computeFingerprint(data.statement.accountNumber, data.transactions[i]);
if (fps.has(fp)) {
return { status: 422, error: 'VALIDATION_ERROR', message: `Duplicate fingerprint found within file at transaction index ${i}` };
}
fps.add(fp);
const operationId = data.transactions[i].operationId;
if (operationId) {
if (operationIds.has(operationId.toLowerCase())) {
return { status: 422, error: 'VALIDATION_ERROR', message: `Duplicate operationId found within file at transaction index ${i}` };
}
operationIds.add(operationId.toLowerCase());
}
}
return null;
@@ -134,6 +154,7 @@ function validateSemantics(data: StatementFile): ValidationError | null {
export async function importStatement(
body: unknown,
db: Pick<typeof pool, 'connect'> = pool,
): Promise<ImportStatementResponse | ValidationError> {
const structErr = validateStructure(body);
if (structErr) return structErr;
@@ -142,7 +163,7 @@ export async function importStatement(
const semErr = validateSemantics(data);
if (semErr) return semErr;
const client = await pool.connect();
const client = await db.connect();
try {
await client.query('BEGIN');
@@ -151,7 +172,7 @@ export async function importStatement(
let isNewAccount = false;
const accResult = await client.query(
'SELECT id FROM accounts WHERE bank = $1 AND account_number = $2',
'SELECT id, account_type FROM accounts WHERE bank = $1 AND account_number = $2',
[data.bank, data.statement.accountNumber],
);
@@ -186,6 +207,14 @@ export async function importStatement(
throw new Error("Category 'Поступления' is missing");
}
const incomeCategoryId = Number(incomeCategoryResult.rows[0].id);
const accountType = accResult.rows[0]?.account_type ?? null;
const investmentCategoryResult = await client.query(
`SELECT id FROM categories WHERE name = 'Инвестиции' AND type = 'transfer' AND is_active = TRUE LIMIT 1`,
);
const investmentCategoryId = investmentCategoryResult.rows[0]
? Number(investmentCategoryResult.rows[0].id)
: null;
const isInvestmentAccount = ['brokerage', 'iis', 'savings'].includes(accountType);
// Insert transactions
const insertedIds: number[] = [];
@@ -198,17 +227,19 @@ export async function importStatement(
tx.description.toLowerCase().includes(CASHBACK_KEYWORD);
const dir = isCashbackCommissionImport
? 'income'
: determineDirection(tx.amountSigned, tx.description);
const categoryId = isCashbackCommissionImport ? incomeCategoryId : null;
: isInvestmentAccount ? 'transfer' : determineDirection(tx.amountSigned, tx.description);
const categoryId = isCashbackCommissionImport
? incomeCategoryId
: isInvestmentAccount ? investmentCategoryId : null;
const isCategoryConfirmed = isCashbackCommissionImport;
const result = await client.query(
`INSERT INTO transactions
(account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed, import_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (account_id, fingerprint) DO NOTHING
(account_id, operation_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed, import_id)
VALUES ($1, COALESCE($2::uuid, gen_random_uuid()), $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT DO NOTHING
RETURNING id`,
[accountId, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, categoryId, isCategoryConfirmed, importId],
[accountId, tx.operationId ?? null, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, categoryId, isCategoryConfirmed, importId],
);
if (result.rows.length > 0) {
@@ -243,11 +274,19 @@ export async function importStatement(
OR (cr.match_type = 'starts_with' AND t2.description ILIKE cr.pattern || '%')
)
WHERE t2.id = ANY($1::bigint[])
AND t2.is_category_confirmed = FALSE
ORDER BY t2.id, cr.priority DESC, cr.id ASC
) sub
WHERE t.id = sub.tx_id`,
[insertedIds],
);
if (isInvestmentAccount && investmentCategoryId != null) {
await client.query(
`UPDATE transactions SET is_category_confirmed = TRUE, updated_at = NOW()
WHERE id = ANY($1::bigint[]) AND category_id = $2 AND is_category_confirmed = FALSE`,
[insertedIds, investmentCategoryId],
);
}
}
await client.query('COMMIT');

View File

@@ -7,13 +7,8 @@ services:
context: .
dockerfile: Dockerfile.backend
container_name: family-budget-backend
environment:
# Имя контейнера/сервиса PostgreSQL — postgres или postgres_budget
- DB_HOST=postgres_budget
- DB_PORT=5432
- DB_NAME=family_budget
- DB_USER=budget_user
- DB_PASSWORD=difficult_Paaaaaasword
env_file:
- ./backend/.env
ports:
- "3000:3000"
restart: unless-stopped

View File

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

View File

@@ -7,6 +7,8 @@ export function AccountsList() {
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<number | null>(null);
const [editAlias, setEditAlias] = useState('');
const [editAccountType, setEditAccountType] = useState<Account['accountType']>(null);
const [editStatus, setEditStatus] = useState<Account['status']>('active');
useEffect(() => {
setLoading(true);
@@ -19,12 +21,16 @@ export function AccountsList() {
const handleEdit = (account: Account) => {
setEditingId(account.id);
setEditAlias(account.alias || '');
setEditAccountType(account.accountType);
setEditStatus(account.status);
};
const handleSave = async (id: number) => {
try {
const updated = await updateAccount(id, {
alias: editAlias.trim(),
accountType: editAccountType,
status: editStatus,
});
setAccounts((prev) =>
prev.map((a) => (a.id === id ? updated : a)),
@@ -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>
</tr>
</thead>
@@ -76,6 +84,25 @@ export function AccountsList() {
)
)}
</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 ? (
<div className="button-group">
@@ -105,7 +132,7 @@ export function AccountsList() {
))}
{accounts.length === 0 && (
<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>
</tr>

View File

@@ -104,7 +104,7 @@ export function Layout({ children }: { children: ReactNode }) {
<span className="sidebar__version">
FE {__FE_VERSION__} · BE {beVersion ?? '…'}
</span>
<span className="sidebar__copyright">© 2025 Семейный бюджет</span>
<span className="sidebar__copyright">© 20252026 Семейный бюджет</span>
</div>
</div>
</aside>

View File

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

View File

@@ -187,7 +187,7 @@ export function TransactionFilters({
<option value="">Все счета</option>
{accounts.map((a) => (
<option key={a.id} value={a.id}>
{a.alias || a.accountNumberMasked}
{[a.alias || a.accountNumberMasked, ({ brokerage: 'Брокерский', iis: 'ИИС', savings: 'Накопительный', current: 'Текущий' } as Record<string, string>)[a.accountType ?? ''], a.status === 'closed' ? 'закрытый' : 'действующий'].filter(Boolean).join(' · ')}
</option>
))}
</select>

View File

@@ -107,7 +107,7 @@ export function AnalyticsPage() {
<option value="">Все счета</option>
{accounts.map((a) => (
<option key={a.id} value={a.id}>
{a.alias || a.accountNumberMasked}
{[a.alias || a.accountNumberMasked, ({ brokerage: 'Брокерский', iis: 'ИИС', savings: 'Накопительный', current: 'Текущий' } as Record<string, string>)[a.accountType ?? ''], a.status === 'closed' ? 'закрытый' : 'действующий'].filter(Boolean).join(' · ')}
</option>
))}
</select>

View File

@@ -242,6 +242,12 @@ button {
gap: 10px;
}
.sidebar__meta {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.sidebar__user {
min-width: 0;
overflow: hidden;

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

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/shared",
"version": "0.2.0",
"version": "0.3.0",
"private": true,
"main": "dist/index.js",
"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 {
id: number;
bank: string;
accountNumberMasked: string;
currency: string;
alias: string | null;
accountType: AccountType | null;
status: AccountStatus;
}
export interface UpdateAccountRequest {
alias: string;
accountType?: AccountType | null;
status?: AccountStatus;
}

View File

@@ -23,6 +23,7 @@ export interface AnalyticsSummaryResponse {
transferInflow: number;
transferOutflow: number;
cashNet: number;
interestIncome: number;
topCategories: TopCategory[];
}

View File

@@ -32,6 +32,10 @@ export interface StatementTransaction {
amountSigned: number;
commission: number;
description: string;
/** Stable source-row identity used to make repeated imports idempotent. */
sourceId?: string;
/** Stable operation UUID emitted by source-specific converters. */
operationId?: string;
}
export interface ImportStatementResponse {

View File

@@ -9,7 +9,7 @@ export type {
ApiError,
} from './common';
export type { Account, UpdateAccountRequest } from './account';
export type { Account, AccountType, AccountStatus, UpdateAccountRequest } from './account';
export type {
Category,