diff --git a/CHANGELOG.md b/CHANGELOG.md index aae3e54..0bb82a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [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 diff --git a/backend/package.json b/backend/package.json index 18c9b49..411fa4e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/backend", - "version": "0.6.8", + "version": "0.7.0", "private": true, "scripts": { "dev": "tsx watch src/app.ts", diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index b89b4d6..f4abed1 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -222,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 { diff --git a/backend/src/routes/accounts.ts b/backend/src/routes/accounts.ts index 7a6a53a..0fa9036 100644 --- a/backend/src/routes/accounts.ts +++ b/backend/src/routes/accounts.ts @@ -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; diff --git a/backend/src/services/accounts.ts b/backend/src/services/accounts.ts index 90ddf62..25ad165 100644 --- a/backend/src/services/accounts.ts +++ b/backend/src/services/accounts.ts @@ -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): Account { return { @@ -9,6 +9,8 @@ function toAccount(r: Record): 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 { return rows.map(toAccount); } -export async function updateAccountAlias( +export async function updateAccount( id: number, alias: string, + accountType?: AccountType | null, + status?: AccountStatus, ): Promise { - 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(); + } } diff --git a/backend/src/services/analytics.integration.test.ts b/backend/src/services/analytics.integration.test.ts index 4e04d72..cfbd548 100644 --- a/backend/src/services/analytics.integration.test.ts +++ b/backend/src/services/analytics.integration.test.ts @@ -6,7 +6,7 @@ async function testQueries(): Promise { 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 { 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); diff --git a/backend/src/services/analytics.ts b/backend/src/services/analytics.ts index 660e3e8..fdbf0e2 100644 --- a/backend/src/services/analytics.ts +++ b/backend/src/services/analytics.ts @@ -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, }; } diff --git a/backend/src/services/import.ts b/backend/src/services/import.ts index 1f7a1fe..0241eff 100644 --- a/backend/src/services/import.ts +++ b/backend/src/services/import.ts @@ -171,7 +171,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], ); @@ -206,6 +206,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[] = []; @@ -218,9 +226,11 @@ export async function importStatement( tx.description.toLowerCase().includes(CASHBACK_KEYWORD); const dir = isCashbackCommissionImport ? 'income' - : determineDirection(tx.amountSigned, tx.description); - const categoryId = isCashbackCommissionImport ? incomeCategoryId : null; - const isCategoryConfirmed = isCashbackCommissionImport; + : isInvestmentAccount ? 'transfer' : determineDirection(tx.amountSigned, tx.description); + const categoryId = isCashbackCommissionImport + ? incomeCategoryId + : isInvestmentAccount ? investmentCategoryId : null; + const isCategoryConfirmed = isCashbackCommissionImport || isInvestmentAccount; const result = await client.query( `INSERT INTO transactions @@ -263,6 +273,7 @@ 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`, diff --git a/frontend/package.json b/frontend/package.json index 3179a24..16b5f8c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/frontend", - "version": "0.9.3", + "version": "0.10.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/AccountsList.tsx b/frontend/src/components/AccountsList.tsx index 27ee12d..1ec441a 100644 --- a/frontend/src/components/AccountsList.tsx +++ b/frontend/src/components/AccountsList.tsx @@ -7,6 +7,8 @@ export function AccountsList() { const [loading, setLoading] = useState(true); const [editingId, setEditingId] = useState(null); const [editAlias, setEditAlias] = useState(''); + const [editAccountType, setEditAccountType] = useState(null); + const [editStatus, setEditStatus] = useState('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() { Номер счёта Валюта Алиас + Тип + Статус @@ -76,6 +84,25 @@ export function AccountsList() { ) )} + + {editingId === a.id ? ( + + ) : (({ brokerage: 'Брокерский', iis: 'ИИС', savings: 'Накопительный', current: 'Текущий' } as Record)[a.accountType ?? ''] || 'не указан')} + + + {editingId === a.id ? ( + + ) : (a.status === 'closed' ? 'Закрытый' : 'Действующий')} + {editingId === a.id ? (
@@ -105,7 +132,7 @@ export function AccountsList() { ))} {accounts.length === 0 && ( - + Нет счетов. Импортируйте выписку. diff --git a/frontend/src/components/SummaryCards.tsx b/frontend/src/components/SummaryCards.tsx index 7b23fb7..8bb9b2f 100644 --- a/frontend/src/components/SummaryCards.tsx +++ b/frontend/src/components/SummaryCards.tsx @@ -40,6 +40,7 @@ export function SummaryCards({ summary }: Props) {
Поступило: {formatAmount(summary.cashInflow)}
Списано: {formatAmount(summary.cashOutflow)}
+
Доход от процентов: {formatAmount(summary.interestIncome)}
{(summary.transferInflow > 0 || summary.transferOutflow > 0) && (
Переводы: {formatAmount(summary.transferInflow)} / {formatAmount(summary.transferOutflow)} diff --git a/shared/package.json b/shared/package.json index 1ad3644..44f6336 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/shared", - "version": "0.2.2", + "version": "0.3.0", "private": true, "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/shared/src/types/account.ts b/shared/src/types/account.ts index 723f204..93a4265 100644 --- a/shared/src/types/account.ts +++ b/shared/src/types/account.ts @@ -1,11 +1,18 @@ +export type AccountType = 'brokerage' | 'iis' | 'savings' | 'current'; +export type AccountStatus = 'active' | 'closed'; + export interface Account { id: number; bank: string; accountNumberMasked: string; currency: string; alias: string | null; + accountType: AccountType | null; + status: AccountStatus; } export interface UpdateAccountRequest { alias: string; + accountType?: AccountType | null; + status?: AccountStatus; } diff --git a/shared/src/types/analytics.ts b/shared/src/types/analytics.ts index 42ea653..1b03b35 100644 --- a/shared/src/types/analytics.ts +++ b/shared/src/types/analytics.ts @@ -23,6 +23,7 @@ export interface AnalyticsSummaryResponse { transferInflow: number; transferOutflow: number; cashNet: number; + interestIncome: number; topCategories: TopCategory[]; } diff --git a/shared/src/types/index.ts b/shared/src/types/index.ts index 9d41913..0cb5aca 100644 --- a/shared/src/types/index.ts +++ b/shared/src/types/index.ts @@ -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,