Compare commits
17 Commits
feature/ne
...
feature/ac
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d7d0e639c | |||
| 1b20b76704 | |||
| c6b79c1d35 | |||
| dd2990357a | |||
| 6550e2057c | |||
| f8e4f86a6d | |||
| e1043bed36 | |||
| a090ce37ad | |||
| 1142a9755a | |||
| bb3789c01c | |||
| 2422ea88fd | |||
| 3af9e9dbfe | |||
| b99555ca23 | |||
| 5c059cbc62 | |||
| 3d08c9e5c6 | |||
| 076ab7b055 | |||
| e78445888f |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.gitignore
|
||||
**/.env
|
||||
**/.env.*
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/__pycache__
|
||||
temp
|
||||
*.log
|
||||
73
CHANGELOG.md
73
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -21,6 +21,8 @@ createdb family_budget
|
||||
npm run dev -w backend
|
||||
```
|
||||
|
||||
Для Docker Compose production-переменные передаются из `backend/.env`. Файл не хранится в Git и не копируется в Docker image; перед запуском создайте его из `.env.example` и заполните секреты.
|
||||
|
||||
Сервер стартует на `http://localhost:3000` (или на порту из `PORT`).
|
||||
|
||||
## Скрипты
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/backend",
|
||||
"version": "0.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": {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
28
backend/src/services/import.integration.test.ts
Normal file
28
backend/src/services/import.integration.test.ts
Normal 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();
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/frontend",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">© 2025–2026 Семейный бюджет</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
210
scripts/convert_vtb_broker_xlsx.py
Normal file
210
scripts/convert_vtb_broker_xlsx.py
Normal file
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert a VTB broker XLSX report to the import JSON formats.
|
||||
|
||||
Only the cash-movement section is sent to the existing statement importer.
|
||||
Securities are written to a sidecar file for the future portfolio importer.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from pathlib import Path
|
||||
from zipfile import ZipFile
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
NS = {'m': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
||||
EPOCH = datetime(1899, 12, 30)
|
||||
COLS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
OPERATION_NAMESPACE = uuid.UUID('7a4a1c7d-7d3c-4f53-9f45-5e7dbb2c6f31')
|
||||
|
||||
|
||||
def excel_date(value):
|
||||
if value in (None, ''):
|
||||
return None
|
||||
number = float(value)
|
||||
date = EPOCH + timedelta(days=number)
|
||||
return date.isoformat(timespec='seconds') + '+03:00'
|
||||
|
||||
|
||||
def excel_day(value):
|
||||
return excel_date(value)[:10] if excel_date(value) else None
|
||||
|
||||
|
||||
def kopecks(value):
|
||||
if value in (None, ''):
|
||||
return 0
|
||||
return int((Decimal(str(value)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)) * 100)
|
||||
|
||||
|
||||
def text(value):
|
||||
return re.sub(r'\s+', ' ', str(value or '')).strip()
|
||||
|
||||
|
||||
def read_rows(path):
|
||||
with ZipFile(path) as book:
|
||||
shared_root = ET.fromstring(book.read('xl/sharedStrings.xml'))
|
||||
shared = [text(''.join(node.itertext())) for node in shared_root.findall('m:si', NS)]
|
||||
root = ET.fromstring(book.read('xl/worksheets/sheet1.xml'))
|
||||
rows = []
|
||||
for row in root.findall('.//m:sheetData/m:row', NS):
|
||||
cells = {}
|
||||
for cell in row.findall('m:c', NS):
|
||||
ref = cell.attrib.get('r', '')
|
||||
col = re.match(r'[A-Z]+', ref).group(0)
|
||||
value = cell.find('m:v', NS)
|
||||
raw = value.text if value is not None else ''
|
||||
if cell.attrib.get('t') == 's' and raw:
|
||||
raw = shared[int(raw)]
|
||||
cells[col] = raw
|
||||
rows.append((int(row.attrib['r']), cells))
|
||||
return rows
|
||||
|
||||
|
||||
def row_text(cells):
|
||||
return text(' '.join(str(value) for value in cells.values()))
|
||||
|
||||
|
||||
def find_row(rows, phrase, start=0):
|
||||
for index in range(start, len(rows)):
|
||||
if phrase.lower() in row_text(rows[index][1]).lower():
|
||||
return index
|
||||
raise ValueError(f'Не найден раздел: {phrase}')
|
||||
|
||||
|
||||
def metadata(rows):
|
||||
account = None
|
||||
period = re.search(r'период с (\d{2}\.\d{2}\.\d{4}) по (\d{2}\.\d{2}\.\d{4})', row_text(dict(rows)))
|
||||
report_date = None
|
||||
for _, cells in rows[:35]:
|
||||
joined = row_text(cells)
|
||||
match = re.search(r'(\d{20})\s*\(RUR\)', joined)
|
||||
account = account or (match.group(1) if match else None)
|
||||
if 'Дата формирования отчета' in joined:
|
||||
for value in cells.values():
|
||||
if value and re.fullmatch(r'\d+(?:\.\d+)?', str(value)):
|
||||
report_date = excel_day(value)
|
||||
if not account or not period:
|
||||
raise ValueError('Не удалось определить счёт или период отчёта')
|
||||
return account, period.groups(), report_date
|
||||
|
||||
|
||||
def cash_transactions(rows, start, end):
|
||||
transactions = []
|
||||
occurrences = defaultdict(int)
|
||||
for number, cells in rows[start + 1:end]:
|
||||
if not cells.get('B') or not cells.get('C') or not cells.get('J'):
|
||||
continue
|
||||
try:
|
||||
operation_at = excel_date(cells['B'])
|
||||
amount = kopecks(cells['C'])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
operation = text(cells.get('J'))
|
||||
comment = text(cells.get('P'))
|
||||
description = text(f'{operation}. {comment}'.strip('. '))
|
||||
digest = hashlib.sha256(f'{operation_at}|{amount}|{description}'.encode()).hexdigest()[:16]
|
||||
occurrences[digest] += 1
|
||||
source_id = f'vtb-broker-cash:{digest}:{occurrences[digest]}'
|
||||
transactions.append({
|
||||
'operationAt': operation_at,
|
||||
'amountSigned': amount,
|
||||
'commission': 0,
|
||||
'description': description,
|
||||
'sourceId': source_id,
|
||||
'operationId': str(uuid.uuid5(OPERATION_NAMESPACE, source_id)),
|
||||
'_sourceRow': number,
|
||||
})
|
||||
if not transactions:
|
||||
raise ValueError('Операции движения денежных средств не найдены')
|
||||
return transactions
|
||||
|
||||
|
||||
def portfolio(rows, holdings_start, movement_start, trades_start, trades_end, account, period, report_date):
|
||||
positions = []
|
||||
for number, cells in rows[holdings_start + 1:movement_start]:
|
||||
instrument = text(cells.get('B'))
|
||||
if not instrument or instrument.lower().startswith('итого') or not re.search(r'RU[A-Z0-9]{10}', instrument):
|
||||
continue
|
||||
positions.append({
|
||||
'sourceRow': number,
|
||||
'instrument': instrument,
|
||||
'isin': next((part for part in instrument.split(', ') if re.fullmatch(r'RU[A-Z0-9]{10}', part)), None),
|
||||
'quantity': cells.get('L') or cells.get('M') or cells.get('I') or cells.get('J'),
|
||||
'price': cells.get('P'),
|
||||
'valuation': cells.get('AF') or cells.get('AJ'),
|
||||
})
|
||||
trades = []
|
||||
columns = {'instrument': 'B', 'concludedAt': 'C', 'side': 'F', 'quantity': 'H',
|
||||
'priceCurrency': 'I', 'price': 'J', 'settlementCurrency': 'L',
|
||||
'settlementAmount': 'M', 'nkd': 'O', 'settlementCommission': 'P',
|
||||
'tradeCommission': 'R', 'plannedDeliveryDate': 'S', 'plannedPaymentDate': 'T',
|
||||
'orderId': 'W', 'tradeId': 'Z', 'organizerTradeId': 'AC', 'venue': 'AK',
|
||||
'comment': 'AN'}
|
||||
for number, cells in rows[trades_start + 1:trades_end]:
|
||||
if not cells.get('B') or not cells.get('C') or not cells.get('F'):
|
||||
continue
|
||||
try:
|
||||
concluded_at = excel_date(cells['C'])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
item = {key: text(cells.get(col)) for key, col in columns.items()}
|
||||
item['sourceRow'] = number
|
||||
item['concludedAt'] = concluded_at
|
||||
item['sourceId'] = f"vtb-broker-trade:{item['tradeId'] or number}"
|
||||
item['operationId'] = str(uuid.uuid5(OPERATION_NAMESPACE, item['sourceId']))
|
||||
trades.append(item)
|
||||
return {
|
||||
'schemaVersion': 'broker-portfolio-1.0',
|
||||
'bank': 'VTB_BROKER',
|
||||
'accountNumber': account,
|
||||
'reportPeriod': {'from': datetime.strptime(period[0], '%d.%m.%Y').date().isoformat(),
|
||||
'to': datetime.strptime(period[1], '%d.%m.%Y').date().isoformat()},
|
||||
'reportedAt': report_date + 'T00:00:00+03:00' if report_date else None,
|
||||
'positions': positions,
|
||||
'trades': trades,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) not in (3, 4):
|
||||
raise SystemExit('usage: convert_vtb_broker_xlsx.py INPUT_XLSX CASH_JSON [PORTFOLIO_JSON]')
|
||||
input_path, cash_path = map(Path, sys.argv[1:3])
|
||||
portfolio_path = Path(sys.argv[3]) if len(sys.argv) == 4 else None
|
||||
rows = read_rows(input_path)
|
||||
account, period, report_date = metadata(rows)
|
||||
holdings_start = find_row(rows, 'Отчёт об остатках ценных бумаг')
|
||||
movement_start = find_row(rows, 'Движение ценных бумаг', holdings_start)
|
||||
cash_start = find_row(rows, 'Движение денежных средств')
|
||||
trades_start = find_row(rows, 'Заключенные в отчетном периоде сделки с ценными бумагами')
|
||||
trades_end = find_row(rows, 'Завершенные в отчетном периоде сделки с ценными бумагами', trades_start + 1)
|
||||
transactions = cash_transactions(rows, cash_start, holdings_start)
|
||||
assert len({item['sourceId'] for item in transactions}) == len(transactions), 'sourceId операции не уникальны'
|
||||
opening = kopecks(rows[find_row(rows, 'Отчёт об остатках денежных средств') + 3][1].get('L'))
|
||||
balance_row = rows[find_row(rows, 'Отчёт об остатках денежных средств') + 3][1]
|
||||
closing = kopecks(balance_row.get('AF'))
|
||||
cash = {'schemaVersion': '1.0', 'bank': 'VTB_BROKER',
|
||||
'statement': {'accountNumber': account, 'currency': 'RUB',
|
||||
'openingBalance': opening, 'closingBalance': closing,
|
||||
'exportedAt': (report_date or transactions[-1]['operationAt'][:10]) + 'T00:00:00+03:00'},
|
||||
'transactions': [{key: value for key, value in item.items() if not key.startswith('_')}
|
||||
for item in transactions]}
|
||||
calculated_closing = opening + sum(item['amountSigned'] for item in transactions)
|
||||
if calculated_closing != closing:
|
||||
raise ValueError(f'Баланс не сходится: операции дают {calculated_closing}, XLSX содержит {closing}')
|
||||
cash_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cash_path.write_text(json.dumps(cash, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
duplicate_candidates = len(transactions) - len({item['sourceId'].rsplit(':', 1)[0] for item in transactions})
|
||||
if portfolio_path:
|
||||
portfolio_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
portfolio_path.write_text(json.dumps(portfolio(rows, holdings_start, movement_start, trades_start, trades_end, account, period, report_date), ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
print(json.dumps({'transactions': len(transactions), 'cashNet': sum(item['amountSigned'] for item in transactions),
|
||||
'openingBalance': opening, 'closingBalance': closing, 'duplicateCandidates': duplicate_candidates,
|
||||
'cashJson': str(cash_path), 'portfolioJson': str(portfolio_path) if portfolio_path else None}, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/shared",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
export type AccountType = 'brokerage' | 'iis' | 'savings' | 'current';
|
||||
export type AccountStatus = 'active' | 'closed';
|
||||
|
||||
export interface Account {
|
||||
id: number;
|
||||
bank: string;
|
||||
accountNumberMasked: string;
|
||||
currency: string;
|
||||
alias: string | null;
|
||||
accountType: AccountType | null;
|
||||
status: AccountStatus;
|
||||
}
|
||||
|
||||
export interface UpdateAccountRequest {
|
||||
alias: string;
|
||||
accountType?: AccountType | null;
|
||||
status?: AccountStatus;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface AnalyticsSummaryResponse {
|
||||
transferInflow: number;
|
||||
transferOutflow: number;
|
||||
cashNet: number;
|
||||
interestIncome: number;
|
||||
topCategories: TopCategory[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user