21 Commits

Author SHA1 Message Date
Anton
0e0186fdbb fix: close modal popups only on overlay mousedown 2026-04-15 17:02:15 +03:00
02ca34d088 Merge pull request 'feat(analytics): account commission and investment transfers' (#17) from feat/commission-analytics-investments into main
Reviewed-on: #17
2026-04-15 11:32:56 +00:00
Anton
f6d3196254 merge main into feat/commission-analytics-investments
Made-with: Cursor
2026-04-15 14:30:29 +03:00
Anton
fccde4259d feat(analytics): account commission and investment transfers
Handle cashback commission imports, include commissions in analytics with separate investment metrics, and expose commission/version details in the UI.

Made-with: Cursor
2026-04-14 16:15:05 +03:00
e97203c4ab Merge pull request 'chore: adds versions and copyright' (#16) from feat/sidebar-versions-copyright into main
Reviewed-on: #16
2026-03-17 03:44:11 +00:00
vakabunga
6dd8c01f5e chore: adds versions and copyright 2026-03-17 06:43:26 +03:00
495c1e89bb Merge pull request 'refactor: center main content layout' (#15) from feat/center-layout into main
Reviewed-on: #15
2026-03-16 14:56:05 +00:00
Anton
032a9f4e3b feat: center main content layout 2026-03-16 17:54:35 +03:00
d5f49fd86f Merge pull request 'feat(ui): simplify clear history confirmation modal: confirmation now relies on two checkboxes only.' (#14) from feat/simplify-clear-modal into main
Reviewed-on: #14
2026-03-16 14:52:22 +00:00
Anton
ab88a0553d feat(ui): simplify clear history confirmation modal: confirmation now relies on two checkboxes only. 2026-03-16 17:51:47 +03:00
0589da5005 Merge pull request 'feat(imports): import history and delete by import' (#13) from feat/import-history into main
Reviewed-on: #13
2026-03-16 14:48:47 +00:00
c50e48d564 Merge pull request 'chore(frontend): adds favicon' (#12) from feat/add-favicon into main
Reviewed-on: #12
2026-03-16 14:48:37 +00:00
Anton
01b1f26553 feat(imports): import history and delete by import
Track imports in DB, show history in Data section, allow deleting
transactions of a specific import instead of clearing all.
2026-03-16 17:46:15 +03:00
Anton
ba3105bbe5 chore(frontend): adds favicon 2026-03-16 17:44:04 +03:00
f32a21f87a Merge pull request 'Revert SSE streaming for PDF import, use synchronous flow' (#11) from revert/remove-sse-streaming into main
Reviewed-on: #11
2026-03-14 17:13:00 +00:00
vakabunga
8b57dd987e Revert SSE streaming for PDF import, use synchronous flow
SSE streaming added unnecessary complexity and latency due to
buffering issues across Node.js event loop, Nginx proxy, and
Docker layers. Reverted to a simple synchronous request/response
for PDF conversion. Kept extractLlmErrorMessage for user-friendly
LLM errors, lazy-loaded pdf-parse, and extended Nginx timeout.
2026-03-14 20:12:27 +03:00
ea234ea007 Merge pull request 'fix: yield to event loop after each SSE write to flush socket' (#10) from fix/sse-event-loop-flush into main
Reviewed-on: #10
2026-03-14 17:00:51 +00:00
vakabunga
db4d5e4d00 fix: yield to event loop after each SSE write to flush socket
The for-await loop over OpenAI stream chunks runs synchronously when
data is buffered, causing res.write() calls to queue without flushing.
Add setImmediate yield after each progress event so the event loop
reaches its I/O phase and pushes data to the network immediately.
2026-03-14 19:59:22 +03:00
358fcaeff5 Merge pull request 'fix: disable gzip and pad SSE events to prevent proxy buffering' (#9) from fix/sse-gzip-buffering into main
Reviewed-on: #9
2026-03-14 16:46:07 +00:00
vakabunga
67fed57118 fix: disable gzip and pad SSE events to prevent proxy buffering
Add gzip off to Nginx import location — the global gzip on was
buffering text/event-stream responses. Pad each SSE event to 4 KB
with comment lines to push past any remaining proxy buffer threshold.
2026-03-14 19:45:33 +03:00
45a6f3d374 Merge pull request 'fix: eliminate SSE buffering through Nginx proxy' (#8) from fix/sse-proxy-buffering into main
Reviewed-on: #8
2026-03-14 14:31:16 +00:00
43 changed files with 985 additions and 805 deletions

1
.gitignore vendored
View File

@@ -23,3 +23,4 @@ history.xlsx
match_analysis.py match_analysis.py
match_report.txt match_report.txt
statements/ statements/
.cursor/

View File

@@ -15,14 +15,16 @@ family_budget/
## Tech stack ## Tech stack
| Layer | Choice | Rationale | | Layer | Choice | Rationale |
|---------- |------------------------|--------------------------------------------------------| | ---------- | --------------------- | ---------------------------------------------------- |
| Backend | Express + TypeScript | Simple, well-known, sufficient for a small local app | | Backend | Express + TypeScript | Simple, well-known, sufficient for a small local app |
| Frontend | React + Vite + TS | Fast dev experience, modern tooling | | Frontend | React + Vite + TS | Fast dev experience, modern tooling |
| Database | PostgreSQL | Deployed on Synology NAS | | Database | PostgreSQL | Deployed on Synology NAS |
| Migrations | Knex | Lightweight, SQL-close, supports seeds | | Migrations | Knex | Lightweight, SQL-close, supports seeds |
| Shared | Pure TypeScript types | Zero-runtime, imported by both backend and frontend | | Shared | Pure TypeScript types | Zero-runtime, imported by both backend and frontend |
## Prerequisites ## Prerequisites
- Node.js >= 20 - Node.js >= 20

View File

@@ -1,6 +1,6 @@
{ {
"name": "@family-budget/backend", "name": "@family-budget/backend",
"version": "0.1.0", "version": "0.5.12",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "tsx watch src/app.ts", "dev": "tsx watch src/app.ts",

View File

@@ -1,3 +1,5 @@
import fs from 'fs';
import path from 'path';
import express from 'express'; import express from 'express';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import cors from 'cors'; import cors from 'cors';
@@ -5,8 +7,13 @@ import { config } from './config';
import { runMigrations } from './db/migrate'; import { runMigrations } from './db/migrate';
import { requireAuth } from './middleware/auth'; import { requireAuth } from './middleware/auth';
const pkg = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'),
) as { version: string };
import authRouter from './routes/auth'; import authRouter from './routes/auth';
import importRouter from './routes/import'; import importRouter from './routes/import';
import importsRouter from './routes/imports';
import transactionsRouter from './routes/transactions'; import transactionsRouter from './routes/transactions';
import accountsRouter from './routes/accounts'; import accountsRouter from './routes/accounts';
import categoriesRouter from './routes/categories'; import categoriesRouter from './routes/categories';
@@ -23,9 +30,14 @@ app.use(cors({ origin: true, credentials: true }));
// Auth routes (login is public; me/logout apply auth internally) // Auth routes (login is public; me/logout apply auth internally)
app.use('/api/auth', authRouter); app.use('/api/auth', authRouter);
app.get('/api/version', (_req, res) => {
res.json({ version: pkg.version });
});
// All remaining /api routes require authentication // All remaining /api routes require authentication
app.use('/api', requireAuth); app.use('/api', requireAuth);
app.use('/api/import', importRouter); app.use('/api/import', importRouter);
app.use('/api/imports', importsRouter);
app.use('/api/transactions', transactionsRouter); app.use('/api/transactions', transactionsRouter);
app.use('/api/accounts', accountsRouter); app.use('/api/accounts', accountsRouter);
app.use('/api/categories', categoriesRouter); app.use('/api/categories', categoriesRouter);

View File

@@ -133,6 +133,24 @@ const migrations: { name: string; sql: string }[] = [
AND NOT EXISTS (SELECT 1 FROM category_rules LIMIT 1); AND NOT EXISTS (SELECT 1 FROM category_rules LIMIT 1);
`, `,
}, },
{
name: '005_imports_table',
sql: `
CREATE TABLE IF NOT EXISTS imports (
id BIGSERIAL PRIMARY KEY,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
account_id BIGINT REFERENCES accounts(id),
bank TEXT NOT NULL,
account_number_masked TEXT NOT NULL,
imported_count INT NOT NULL,
duplicates_skipped INT NOT NULL,
total_in_file INT NOT NULL
);
ALTER TABLE transactions
ADD COLUMN IF NOT EXISTS import_id BIGINT REFERENCES imports(id);
`,
},
{ {
name: '004_seed_category_rules_extended', name: '004_seed_category_rules_extended',
sql: ` sql: `
@@ -176,6 +194,24 @@ const migrations: { name: string; sql: string }[] = [
AND EXISTS (SELECT 1 FROM categories LIMIT 1); AND EXISTS (SELECT 1 FROM categories LIMIT 1);
`, `,
}, },
{
name: '005_imports_table',
sql: `
CREATE TABLE IF NOT EXISTS imports (
id BIGSERIAL PRIMARY KEY,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
account_id BIGINT REFERENCES accounts(id),
bank TEXT NOT NULL,
account_number_masked TEXT NOT NULL,
imported_count INT NOT NULL,
duplicates_skipped INT NOT NULL,
total_in_file INT NOT NULL
);
ALTER TABLE transactions
ADD COLUMN IF NOT EXISTS import_id BIGINT REFERENCES imports(id);
`,
},
]; ];
export async function runMigrations(): Promise<void> { export async function runMigrations(): Promise<void> {

View File

@@ -3,7 +3,7 @@ import multer from 'multer';
import { asyncHandler } from '../utils'; import { asyncHandler } from '../utils';
import { importStatement, isValidationError } from '../services/import'; import { importStatement, isValidationError } from '../services/import';
import { import {
convertPdfToStatementStreaming, convertPdfToStatement,
isPdfConversionError, isPdfConversionError,
} from '../services/pdfToStatement'; } from '../services/pdfToStatement';
@@ -28,19 +28,6 @@ function isJsonFile(file: { mimetype: string; originalname: string }): boolean {
); );
} }
function sseWrite(res: import('express').Response, data: Record<string, unknown>) {
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
/**
* Send a 2 KB comment block to push past any proxy buffering threshold.
* Nginx and other reverse proxies often buffer the first few KB before
* starting to stream — this padding forces the initial flush.
*/
function ssePadding(res: import('express').Response) {
res.write(`: ${' '.repeat(2048)}\n\n`);
}
const router = Router(); const router = Router();
router.post( router.post(
@@ -64,61 +51,19 @@ router.post(
return; return;
} }
let body: unknown;
if (isPdfFile(file)) { if (isPdfFile(file)) {
res.setHeader('Content-Type', 'text/event-stream'); const converted = await convertPdfToStatement(file.buffer);
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.socket?.setNoDelay(true);
res.flushHeaders();
ssePadding(res);
try {
const converted = await convertPdfToStatementStreaming(
file.buffer,
(stage, progress, message) => {
sseWrite(res, { stage, progress, message });
},
);
if (isPdfConversionError(converted)) { if (isPdfConversionError(converted)) {
sseWrite(res, { res.status(converted.status).json({
stage: 'error', error: converted.error,
message: converted.message, message: converted.message,
}); });
res.end();
return; return;
} }
body = converted;
const result = await importStatement(converted); } else {
if (isValidationError(result)) {
sseWrite(res, {
stage: 'error',
message: (result as { message: string }).message,
});
res.end();
return;
}
sseWrite(res, {
stage: 'done',
progress: 100,
result,
});
} catch (err) {
console.error('SSE import error:', err);
sseWrite(res, {
stage: 'error',
message: 'Внутренняя ошибка сервера',
});
}
res.end();
return;
}
// JSON files — synchronous response as before
let body: unknown;
try { try {
body = JSON.parse(file.buffer.toString('utf-8')); body = JSON.parse(file.buffer.toString('utf-8'));
} catch { } catch {
@@ -128,6 +73,7 @@ router.post(
}); });
return; return;
} }
}
const result = await importStatement(body); const result = await importStatement(body);
if (isValidationError(result)) { if (isValidationError(result)) {

View File

@@ -0,0 +1,36 @@
import { Router } from 'express';
import { pool } from '../db/pool';
import { asyncHandler } from '../utils';
import * as importsService from '../services/imports';
const router = Router();
router.get(
'/',
asyncHandler(async (_req, res) => {
const imports = await importsService.getImports();
res.json(imports);
}),
);
router.delete(
'/:id',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (isNaN(id)) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'Invalid import id' });
return;
}
const { rows } = await pool.query('SELECT 1 FROM imports WHERE id = $1', [id]);
if (rows.length === 0) {
res.status(404).json({ error: 'NOT_FOUND', message: 'Import not found' });
return;
}
const result = await importsService.deleteImport(id);
res.json(result);
}),
);
export default router;

View File

@@ -47,26 +47,91 @@ export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryRe
const where = 'WHERE ' + conditions.join(' AND '); const where = 'WHERE ' + conditions.join(' AND ');
const totalsResult = await pool.query( const totalsResult = await pool.query(
`SELECT `WITH investment_ref AS (
COALESCE(SUM(CASE WHEN t.direction = 'expense' THEN ABS(t.amount_signed) ELSE 0 END), 0)::bigint AS total_expense, SELECT (
COALESCE(SUM(CASE WHEN t.direction = 'income' THEN t.amount_signed ELSE 0 END), 0)::bigint AS total_income SELECT id
FROM categories
WHERE name = 'Инвестиции' AND type = 'transfer'
ORDER BY id ASC
LIMIT 1
) AS investment_category_id
)
SELECT
COALESCE(SUM(
CASE
WHEN (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
AND (
ir.investment_category_id IS NULL
OR t.category_id IS DISTINCT FROM ir.investment_category_id
)
THEN ABS(t.amount_signed) + t.commission
ELSE 0
END
), 0)::bigint AS total_expense,
COALESCE(SUM(
CASE
WHEN t.direction = 'income'
AND (
ir.investment_category_id IS NULL
OR t.category_id IS DISTINCT FROM ir.investment_category_id
)
THEN t.amount_signed + t.commission
ELSE 0
END
), 0)::bigint AS total_income,
COALESCE(SUM(
CASE
WHEN t.amount_signed < 0
AND ir.investment_category_id IS NOT NULL
AND t.category_id = ir.investment_category_id
THEN ABS(t.amount_signed) + t.commission
ELSE 0
END
), 0)::bigint AS investment_outflow,
COALESCE(SUM(
CASE
WHEN t.amount_signed > 0
AND ir.investment_category_id IS NOT NULL
AND t.category_id = ir.investment_category_id
THEN t.amount_signed + t.commission
ELSE 0
END
), 0)::bigint AS investment_income_excluded
FROM transactions t FROM transactions t
CROSS JOIN investment_ref ir
${where}`, ${where}`,
values, values,
); );
const totalExpense = Number(totalsResult.rows[0].total_expense); const totalExpense = Number(totalsResult.rows[0].total_expense);
const totalIncome = Number(totalsResult.rows[0].total_income); const totalIncome = Number(totalsResult.rows[0].total_income);
const investmentOutflow = Number(totalsResult.rows[0].investment_outflow);
const investmentIncomeExcluded = Number(totalsResult.rows[0].investment_income_excluded);
const topResult = await pool.query( const topResult = await pool.query(
`SELECT `WITH investment_ref AS (
t.category_id, SELECT (
c.name AS category_name, SELECT id
SUM(ABS(t.amount_signed))::bigint AS amount FROM categories
WHERE name = 'Инвестиции' AND type = 'transfer'
ORDER BY id ASC
LIMIT 1
) AS investment_category_id
)
SELECT
COALESCE(t.category_id, 0)::bigint AS category_id,
COALESCE(c.name, 'Без категории') AS category_name,
SUM(ABS(t.amount_signed) + t.commission)::bigint AS amount
FROM transactions t FROM transactions t
CROSS JOIN investment_ref ir
LEFT JOIN categories c ON c.id = t.category_id LEFT JOIN categories c ON c.id = t.category_id
${where} AND t.direction = 'expense' AND t.category_id IS NOT NULL ${where}
GROUP BY t.category_id, c.name AND (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
AND (
ir.investment_category_id IS NULL
OR t.category_id IS DISTINCT FROM ir.investment_category_id
)
GROUP BY COALESCE(t.category_id, 0), COALESCE(c.name, 'Без категории')
ORDER BY amount DESC ORDER BY amount DESC
LIMIT 5`, LIMIT 5`,
values, values,
@@ -83,6 +148,8 @@ export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryRe
totalExpense, totalExpense,
totalIncome, totalIncome,
net: totalIncome - totalExpense, net: totalIncome - totalExpense,
investmentOutflow,
investmentIncomeExcluded,
topCategories, topCategories,
}; };
} }
@@ -92,23 +159,53 @@ export async function getByCategory(params: BaseParams): Promise<ByCategoryItem[
const where = 'WHERE ' + conditions.join(' AND '); const where = 'WHERE ' + conditions.join(' AND ');
const totalResult = await pool.query( const totalResult = await pool.query(
`SELECT COALESCE(SUM(ABS(t.amount_signed)), 0)::bigint AS total `WITH investment_ref AS (
SELECT (
SELECT id
FROM categories
WHERE name = 'Инвестиции' AND type = 'transfer'
ORDER BY id ASC
LIMIT 1
) AS investment_category_id
)
SELECT COALESCE(SUM(ABS(t.amount_signed) + t.commission), 0)::bigint AS total
FROM transactions t FROM transactions t
${where} AND t.direction = 'expense'`, CROSS JOIN investment_ref ir
${where}
AND (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
AND (
ir.investment_category_id IS NULL
OR t.category_id IS DISTINCT FROM ir.investment_category_id
)`,
values, values,
); );
const total = Number(totalResult.rows[0].total); const total = Number(totalResult.rows[0].total);
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT `WITH investment_ref AS (
t.category_id, SELECT (
c.name AS category_name, SELECT id
SUM(ABS(t.amount_signed))::bigint AS amount, FROM categories
WHERE name = 'Инвестиции' AND type = 'transfer'
ORDER BY id ASC
LIMIT 1
) AS investment_category_id
)
SELECT
COALESCE(t.category_id, 0)::bigint AS category_id,
COALESCE(c.name, 'Без категории') AS category_name,
SUM(ABS(t.amount_signed) + t.commission)::bigint AS amount,
COUNT(*)::int AS tx_count COUNT(*)::int AS tx_count
FROM transactions t FROM transactions t
CROSS JOIN investment_ref ir
LEFT JOIN categories c ON c.id = t.category_id LEFT JOIN categories c ON c.id = t.category_id
${where} AND t.direction = 'expense' ${where}
GROUP BY t.category_id, c.name AND (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
AND (
ir.investment_category_id IS NULL
OR t.category_id IS DISTINCT FROM ir.investment_category_id
)
GROUP BY COALESCE(t.category_id, 0), COALESCE(c.name, 'Без категории')
ORDER BY amount DESC`, ORDER BY amount DESC`,
values, values,
); );
@@ -174,13 +271,52 @@ export async function getTimeseries(
gs::date AS period_start, gs::date AS period_start,
${periodEndExpr} AS period_end ${periodEndExpr} AS period_end
FROM generate_series(${truncExpr}, $2::date, '${intervalStr}'::interval) gs FROM generate_series(${truncExpr}, $2::date, '${intervalStr}'::interval) gs
),
investment_ref AS (
SELECT (
SELECT id
FROM categories
WHERE name = 'Инвестиции' AND type = 'transfer'
ORDER BY id ASC
LIMIT 1
) AS investment_category_id
) )
SELECT SELECT
p.period_start, p.period_start,
p.period_end, p.period_end,
COALESCE(SUM(CASE WHEN t.direction = 'expense' THEN ABS(t.amount_signed) ELSE 0 END), 0)::bigint AS expense_amount, COALESCE(SUM(
COALESCE(SUM(CASE WHEN t.direction = 'income' THEN t.amount_signed ELSE 0 END), 0)::bigint AS income_amount CASE
WHEN (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
AND (
ir.investment_category_id IS NULL
OR t.category_id IS DISTINCT FROM ir.investment_category_id
)
THEN ABS(t.amount_signed) + t.commission
ELSE 0
END
), 0)::bigint AS expense_amount,
COALESCE(SUM(
CASE
WHEN t.direction = 'income'
AND (
ir.investment_category_id IS NULL
OR t.category_id IS DISTINCT FROM ir.investment_category_id
)
THEN t.amount_signed + t.commission
ELSE 0
END
), 0)::bigint AS income_amount,
COALESCE(SUM(
CASE
WHEN t.amount_signed < 0
AND ir.investment_category_id IS NOT NULL
AND t.category_id = ir.investment_category_id
THEN ABS(t.amount_signed) + t.commission
ELSE 0
END
), 0)::bigint AS investment_outflow
FROM periods p FROM periods p
CROSS JOIN investment_ref ir
LEFT JOIN transactions t ON ${txWhere} LEFT JOIN transactions t ON ${txWhere}
GROUP BY p.period_start, p.period_end GROUP BY p.period_start, p.period_end
ORDER BY p.period_start`, ORDER BY p.period_start`,
@@ -192,5 +328,6 @@ export async function getTimeseries(
periodEnd: r.period_end.toISOString().slice(0, 10), periodEnd: r.period_end.toISOString().slice(0, 10),
expenseAmount: Number(r.expense_amount), expenseAmount: Number(r.expense_amount),
incomeAmount: Number(r.income_amount), incomeAmount: Number(r.income_amount),
investmentOutflow: Number(r.investment_outflow),
})); }));
} }

View File

@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from 'uuid';
import { pool } from '../db/pool'; import { pool } from '../db/pool';
import { config } from '../config'; import { config } from '../config';
import type { LoginRequest, MeResponse } from '@family-budget/shared'; import type { LoginRequest, MeResponse } from '@family-budget/shared';
import backendPackage from '../../package.json';
export async function login( export async function login(
body: LoginRequest, body: LoginRequest,
@@ -26,5 +27,8 @@ export async function logout(sessionId: string): Promise<void> {
} }
export async function me(sessionId: string): Promise<MeResponse> { export async function me(sessionId: string): Promise<MeResponse> {
return { login: config.appUserLogin }; return {
login: config.appUserLogin,
backendVersion: backendPackage.version,
};
} }

View File

@@ -8,6 +8,7 @@ const TRANSFER_PHRASES = [
'перевод средств на счет', 'перевод средств на счет',
'внутри втб', 'внутри втб',
]; ];
const CASHBACK_KEYWORD = 'зачисление';
function computeFingerprint( function computeFingerprint(
accountNumber: string, accountNumber: string,
@@ -84,15 +85,25 @@ function validateStructure(body: unknown): ValidationError | null {
if (typeof t.operationAt !== 'string' || !ISO_WITH_OFFSET.test(t.operationAt)) { if (typeof t.operationAt !== 'string' || !ISO_WITH_OFFSET.test(t.operationAt)) {
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].operationAt must be ISO 8601 with offset` }; return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].operationAt must be ISO 8601 with offset` };
} }
if (typeof t.amountSigned !== 'number' || !Number.isInteger(t.amountSigned) || t.amountSigned === 0) {
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].amountSigned must be a non-zero integer` };
}
if (typeof t.commission !== 'number' || !Number.isInteger(t.commission) || t.commission < 0) { if (typeof t.commission !== 'number' || !Number.isInteger(t.commission) || t.commission < 0) {
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].commission must be a non-negative integer` }; return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].commission must be a non-negative integer` };
} }
if (typeof t.description !== 'string' || !t.description) { if (typeof t.description !== 'string' || !t.description) {
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].description must be a non-empty string` }; return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].description must be a non-empty string` };
} }
if (typeof t.amountSigned !== 'number' || !Number.isInteger(t.amountSigned)) {
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].amountSigned must be an integer` };
}
if (t.amountSigned === 0) {
const hasCashbackMarker = t.description.toLowerCase().includes(CASHBACK_KEYWORD);
if (t.commission <= 0 || !hasCashbackMarker) {
return {
status: 400,
error: 'BAD_REQUEST',
message: `transactions[${i}] with amountSigned=0 must have commission>0 and contain 'Зачисление' in description`,
};
}
}
} }
return null; return null;
@@ -155,20 +166,49 @@ export async function importStatement(
isNewAccount = true; isNewAccount = true;
} }
// Create import record (counts updated after loop)
const accountNumberMasked = maskAccountNumber(data.statement.accountNumber);
const importResult = await client.query(
`INSERT INTO imports (account_id, bank, account_number_masked, imported_count, duplicates_skipped, total_in_file)
VALUES ($1, $2, $3, 0, 0, $4)
RETURNING id`,
[accountId, data.bank, accountNumberMasked, data.transactions.length],
);
const importId = Number(importResult.rows[0].id);
const incomeCategoryResult = await client.query(
`SELECT id
FROM categories
WHERE name = 'Поступления' AND type = 'income' AND is_active = TRUE
ORDER BY id ASC
LIMIT 1`,
);
if (incomeCategoryResult.rows.length === 0) {
throw new Error("Category 'Поступления' is missing");
}
const incomeCategoryId = Number(incomeCategoryResult.rows[0].id);
// Insert transactions // Insert transactions
const insertedIds: number[] = []; const insertedIds: number[] = [];
for (const tx of data.transactions) { for (const tx of data.transactions) {
const fp = computeFingerprint(data.statement.accountNumber, tx); const fp = computeFingerprint(data.statement.accountNumber, tx);
const dir = determineDirection(tx.amountSigned, tx.description); const isCashbackCommissionImport =
tx.amountSigned === 0 &&
tx.commission > 0 &&
tx.description.toLowerCase().includes(CASHBACK_KEYWORD);
const dir = isCashbackCommissionImport
? 'income'
: determineDirection(tx.amountSigned, tx.description);
const categoryId = isCashbackCommissionImport ? incomeCategoryId : null;
const isCategoryConfirmed = isCashbackCommissionImport;
const result = await client.query( const result = await client.query(
`INSERT INTO transactions `INSERT INTO transactions
(account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed) (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, NULL, FALSE) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (account_id, fingerprint) DO NOTHING ON CONFLICT (account_id, fingerprint) DO NOTHING
RETURNING id`, RETURNING id`,
[accountId, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp], [accountId, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, categoryId, isCategoryConfirmed, importId],
); );
if (result.rows.length > 0) { if (result.rows.length > 0) {
@@ -176,6 +216,13 @@ export async function importStatement(
} }
} }
// Update import record with actual counts
const duplicatesSkipped = data.transactions.length - insertedIds.length;
await client.query(
`UPDATE imports SET imported_count = $1, duplicates_skipped = $2 WHERE id = $3`,
[insertedIds.length, duplicatesSkipped, importId],
);
// Auto-categorize newly inserted transactions // Auto-categorize newly inserted transactions
if (insertedIds.length > 0) { if (insertedIds.length > 0) {
await client.query( await client.query(
@@ -208,7 +255,7 @@ export async function importStatement(
return { return {
accountId, accountId,
isNewAccount, isNewAccount,
accountNumberMasked: maskAccountNumber(data.statement.accountNumber), accountNumberMasked,
imported: insertedIds.length, imported: insertedIds.length,
duplicatesSkipped: data.transactions.length - insertedIds.length, duplicatesSkipped: data.transactions.length - insertedIds.length,
totalInFile: data.transactions.length, totalInFile: data.transactions.length,

View File

@@ -0,0 +1,44 @@
import { pool } from '../db/pool';
import type { Import } from '@family-budget/shared';
export async function getImports(): Promise<Import[]> {
const result = await pool.query(
`SELECT
i.id,
i.imported_at,
i.account_id,
a.alias AS account_alias,
i.bank,
i.account_number_masked,
i.imported_count,
i.duplicates_skipped,
i.total_in_file
FROM imports i
LEFT JOIN accounts a ON a.id = i.account_id
ORDER BY i.imported_at DESC`,
);
return result.rows.map((r) => ({
id: Number(r.id),
importedAt: r.imported_at.toISOString(),
accountId: r.account_id != null ? Number(r.account_id) : null,
accountAlias: r.account_alias ?? null,
bank: r.bank,
accountNumberMasked: r.account_number_masked,
importedCount: Number(r.imported_count),
duplicatesSkipped: Number(r.duplicates_skipped),
totalInFile: Number(r.total_in_file),
}));
}
export async function deleteImport(id: number): Promise<{ deleted: number }> {
const result = await pool.query(
'DELETE FROM transactions WHERE import_id = $1 RETURNING id',
[id],
);
const deleted = result.rowCount ?? 0;
await pool.query('DELETE FROM imports WHERE id = $1', [id]);
return { deleted };
}

View File

@@ -19,7 +19,7 @@ const PDF2JSON_PROMPT = `Ты — конвертер банковских вып
"transactions": [ "transactions": [
{ {
"operationAt": "<дата и время операции в формате ISO 8601 с offset>", "operationAt": "<дата и время операции в формате ISO 8601 с offset>",
"amountSigned": <число: положительное для прихода, отрицательное для расхода; в копейках>, "amountSigned": <число: положительное для прихода, отрицательное для расхода; 0 допустим только если сумма фактически в commission и в description есть «Зачисление»>,
"commission": <число, целое, >= 0, в копейках>, "commission": <число, целое, >= 0, в копейках>,
"description": "<полное описание операции из выписки>" "description": "<полное описание операции из выписки>"
} }
@@ -30,6 +30,7 @@ const PDF2JSON_PROMPT = `Ты — конвертер банковских вып
1. Суммы — всегда в копейках (рубли × 100). Пример: 500,00 ₽ → 50000, -1234,56 ₽ → -123456. 1. Суммы — всегда в копейках (рубли × 100). Пример: 500,00 ₽ → 50000, -1234,56 ₽ → -123456.
2. amountSigned: приход — положительное, расход — отрицательное. 2. amountSigned: приход — положительное, расход — отрицательное.
amountSigned = 0 допускается только для кейса кэшбэка/зачисления, когда сумма указана в commission и в description есть «Зачисление».
3. operationAt — дата и время, если не указано — 00:00:00, offset +03:00 для МСК. 3. operationAt — дата и время, если не указано — 00:00:00, offset +03:00 для МСК.
4. commission — если не указана — 0. 4. commission — если не указана — 0.
5. description — полный текст операции как в выписке. 5. description — полный текст операции как в выписке.
@@ -131,143 +132,6 @@ export async function convertPdfToStatement(
}; };
} }
return parseConversionResult(content);
} catch (err) {
console.error('LLM conversion error:', err);
return {
status: 502,
error: 'BAD_GATEWAY',
message: extractLlmErrorMessage(err),
};
}
}
export type ProgressStage = 'pdf' | 'llm' | 'import';
export type OnProgress = (stage: ProgressStage, progress: number, message: string) => void;
const LLM_PROGRESS_MIN = 10;
const LLM_PROGRESS_MAX = 98;
const LLM_PROGRESS_RANGE = LLM_PROGRESS_MAX - LLM_PROGRESS_MIN;
const THROTTLE_MS = 300;
export async function convertPdfToStatementStreaming(
buffer: Buffer,
onProgress: OnProgress,
): Promise<StatementFile | PdfConversionError> {
if (!config.llmApiKey || config.llmApiKey.trim() === '') {
return {
status: 503,
error: 'SERVICE_UNAVAILABLE',
message: 'Конвертация PDF недоступна: не задан LLM_API_KEY',
};
}
onProgress('pdf', 2, 'Извлечение текста из PDF...');
let text: string;
try {
const result = await getPdfParse()(buffer);
text = result.text || '';
} catch (err) {
console.error('PDF extraction error:', err);
return {
status: 400,
error: 'BAD_REQUEST',
message: 'Не удалось обработать PDF-файл',
};
}
if (!text || text.trim().length === 0) {
return {
status: 400,
error: 'BAD_REQUEST',
message: 'Не удалось извлечь текст из PDF',
};
}
onProgress('pdf', 8, 'Текст извлечён, отправка в LLM...');
const openai = new OpenAI({
apiKey: config.llmApiKey,
...(config.llmApiBaseUrl && { baseURL: config.llmApiBaseUrl }),
timeout: 5 * 60 * 1000,
});
try {
const stream = await openai.chat.completions.create({
model: config.llmModel,
messages: [
{ role: 'system', content: PDF2JSON_PROMPT },
{ role: 'user', content: `Текст выписки:\n\n${text}` },
],
temperature: 0,
max_tokens: 32768,
stream: true,
});
// Estimate expected output size as ~2x the input PDF text length, clamped
const expectedChars = Math.max(2_000, Math.min(text.length * 2, 30_000));
let accumulated = '';
let charsReceived = 0;
let lastEmitTime = 0;
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (delta) {
accumulated += delta;
charsReceived += delta.length;
const now = Date.now();
if (now - lastEmitTime >= THROTTLE_MS) {
const ratio = Math.min(1, charsReceived / expectedChars);
const llmProgress = Math.min(
LLM_PROGRESS_MAX,
Math.round(ratio * LLM_PROGRESS_RANGE + LLM_PROGRESS_MIN),
);
onProgress('llm', llmProgress, 'Конвертация через LLM...');
lastEmitTime = now;
}
}
}
onProgress('llm', LLM_PROGRESS_MAX, 'LLM завершил, обработка результата...');
const content = accumulated.trim();
if (!content) {
return {
status: 422,
error: 'VALIDATION_ERROR',
message: 'Результат конвертации пуст',
};
}
return parseConversionResult(content);
} catch (err) {
console.error('LLM streaming error:', err);
return {
status: 502,
error: 'BAD_GATEWAY',
message: extractLlmErrorMessage(err),
};
}
}
function extractLlmErrorMessage(err: unknown): string {
const raw = String(
(err as Record<string, unknown>)?.message ??
(err as Record<string, Record<string, unknown>>)?.error?.message ?? '',
);
if (/context.length|n_ctx|too.many.tokens|maximum.context/i.test(raw)) {
return 'PDF-файл слишком большой для обработки. Попробуйте файл с меньшим количеством операций или используйте модель с большим контекстным окном.';
}
if (/timeout|timed?\s*out|ETIMEDOUT|ECONNREFUSED/i.test(raw)) {
return 'LLM-сервер не отвечает. Проверьте, что сервер запущен и доступен.';
}
return 'Временная ошибка конвертации';
}
function parseConversionResult(content: string): StatementFile | PdfConversionError {
const jsonMatch = content.match(/\{[\s\S]*\}/); const jsonMatch = content.match(/\{[\s\S]*\}/);
const jsonStr = jsonMatch ? jsonMatch[0] : content; const jsonStr = jsonMatch ? jsonMatch[0] : content;
let parsed: unknown; let parsed: unknown;
@@ -291,4 +155,26 @@ function parseConversionResult(content: string): StatementFile | PdfConversionEr
} }
return parsed as StatementFile; return parsed as StatementFile;
} catch (err) {
console.error('LLM conversion error:', err);
return {
status: 502,
error: 'BAD_GATEWAY',
message: extractLlmErrorMessage(err),
};
}
}
function extractLlmErrorMessage(err: unknown): string {
const raw = String(
(err as Record<string, unknown>)?.message ??
(err as Record<string, Record<string, unknown>>)?.error?.message ?? '',
);
if (/context.length|n_ctx|too.many.tokens|maximum.context/i.test(raw)) {
return 'PDF-файл слишком большой для обработки. Попробуйте файл с меньшим количеством операций или используйте модель с большим контекстным окном.';
}
if (/timeout|timed?\s*out|ETIMEDOUT|ECONNREFUSED/i.test(raw)) {
return 'LLM-сервер не отвечает. Проверьте, что сервер запущен и доступен.';
}
return 'Временная ошибка конвертации';
} }

View File

@@ -171,5 +171,6 @@ export async function updateTransaction(
export async function clearAllTransactions(): Promise<{ deleted: number }> { export async function clearAllTransactions(): Promise<{ deleted: number }> {
const result = await pool.query('DELETE FROM transactions RETURNING id'); const result = await pool.query('DELETE FROM transactions RETURNING id');
await pool.query('DELETE FROM imports');
return { deleted: result.rowCount ?? 0 }; return { deleted: result.rowCount ?? 0 };
} }

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f172a" /> <meta name="theme-color" content="#0f172a" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>Семейный бюджет</title> <title>Семейный бюджет</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View File

@@ -3,7 +3,7 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Import endpoint — SSE streaming, long timeout, no buffering # Import endpoint — long timeout for LLM processing
location /api/import { location /api/import {
proxy_pass http://family-budget-backend:3000; proxy_pass http://family-budget-backend:3000;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -14,7 +14,6 @@ server {
proxy_cookie_path / /; proxy_cookie_path / /;
proxy_connect_timeout 5s; proxy_connect_timeout 5s;
proxy_read_timeout 600s; proxy_read_timeout 600s;
proxy_buffering off;
client_max_body_size 15m; client_max_body_size 15m;
} }

View File

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

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#0f172a"/>
<text x="16" y="23" font-family="Georgia, serif" font-size="20" font-weight="bold" fill="#3b82f6" text-anchor="middle">&#8381;</text>
</svg>

After

Width:  |  Height:  |  Size: 271 B

View File

@@ -1,6 +1,5 @@
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './context/AuthContext'; import { useAuth } from './context/AuthContext';
import { ImportProvider } from './context/ImportContext';
import { Layout } from './components/Layout'; import { Layout } from './components/Layout';
import { LoginPage } from './pages/LoginPage'; import { LoginPage } from './pages/LoginPage';
import { HistoryPage } from './pages/HistoryPage'; import { HistoryPage } from './pages/HistoryPage';
@@ -19,7 +18,6 @@ export function App() {
} }
return ( return (
<ImportProvider>
<Layout> <Layout>
<Routes> <Routes>
<Route path="/" element={<Navigate to="/history" replace />} /> <Route path="/" element={<Navigate to="/history" replace />} />
@@ -29,6 +27,5 @@ export function App() {
<Route path="*" element={<Navigate to="/history" replace />} /> <Route path="*" element={<Navigate to="/history" replace />} />
</Routes> </Routes>
</Layout> </Layout>
</ImportProvider>
); );
} }

View File

@@ -1,4 +1,7 @@
import type { ImportStatementResponse } from '@family-budget/shared'; import type {
ImportStatementResponse,
Import,
} from '@family-budget/shared';
import { api } from './client'; import { api } from './client';
export async function importStatement( export async function importStatement(
@@ -12,73 +15,12 @@ export async function importStatement(
); );
} }
export interface SseProgressEvent { export async function getImports(): Promise<Import[]> {
stage: 'pdf' | 'llm' | 'import'; return api.get<Import[]>('/api/imports');
progress: number;
message: string;
} }
export interface SseDoneEvent { export async function deleteImport(
stage: 'done'; id: number,
progress: 100; ): Promise<{ deleted: number }> {
result: ImportStatementResponse; return api.delete<{ deleted: number }>(`/api/imports/${id}`);
}
export interface SseErrorEvent {
stage: 'error';
message: string;
}
export type SseEvent = SseProgressEvent | SseDoneEvent | SseErrorEvent;
export async function importStatementStream(
file: File,
onEvent: (event: SseEvent) => void,
): Promise<void> {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/import/statement', {
method: 'POST',
body: formData,
credentials: 'include',
});
if (!res.ok) {
let msg = 'Ошибка импорта';
try {
const body = await res.json();
if (body.message) msg = body.message;
} catch { /* use default */ }
onEvent({ stage: 'error', message: msg });
return;
}
const reader = res.body?.getReader();
if (!reader) {
onEvent({ stage: 'error', message: 'Streaming не поддерживается' });
return;
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data: ')) continue;
try {
const parsed = JSON.parse(trimmed.slice(6)) as SseEvent;
onEvent(parsed);
} catch { /* skip malformed lines */ }
}
}
} }

View File

@@ -0,0 +1,5 @@
import { api } from './client';
export async function getBackendVersion(): Promise<{ version: string }> {
return api.get<{ version: string }>('/api/version');
}

View File

@@ -1,8 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { clearAllTransactions } from '../api/transactions'; import { clearAllTransactions } from '../api/transactions';
const CONFIRM_WORD = 'УДАЛИТЬ';
interface Props { interface Props {
onClose: () => void; onClose: () => void;
onDone: () => void; onDone: () => void;
@@ -10,15 +8,11 @@ interface Props {
export function ClearHistoryModal({ onClose, onDone }: Props) { export function ClearHistoryModal({ onClose, onDone }: Props) {
const [check1, setCheck1] = useState(false); const [check1, setCheck1] = useState(false);
const [confirmInput, setConfirmInput] = useState('');
const [check2, setCheck2] = useState(false); const [check2, setCheck2] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const canConfirm = const canConfirm = check1 && check2;
check1 &&
confirmInput.trim().toUpperCase() === CONFIRM_WORD &&
check2;
const handleConfirm = async () => { const handleConfirm = async () => {
if (!canConfirm || loading) return; if (!canConfirm || loading) return;
@@ -37,7 +31,12 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
}; };
return ( return (
<div className="modal-overlay" onClick={onClose}> <div
className="modal-overlay"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal" onClick={(e) => e.stopPropagation()}> <div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header"> <div className="modal-header">
<h2>Очистить историю операций</h2> <h2>Очистить историю операций</h2>
@@ -65,20 +64,6 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
</label> </label>
</div> </div>
<div className="form-group">
<label>
Введите <strong>{CONFIRM_WORD}</strong> для подтверждения
</label>
<input
type="text"
value={confirmInput}
onChange={(e) => setConfirmInput(e.target.value)}
placeholder={CONFIRM_WORD}
className={confirmInput && confirmInput.trim().toUpperCase() !== CONFIRM_WORD ? 'input-error' : ''}
autoComplete="off"
/>
</div>
<div className="form-group form-group-checkbox clear-history-check"> <div className="form-group form-group-checkbox clear-history-check">
<label> <label>
<input <input

View File

@@ -1,13 +1,87 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ClearHistoryModal } from './ClearHistoryModal'; import { ClearHistoryModal } from './ClearHistoryModal';
import { DeleteImportModal } from './DeleteImportModal';
import { getImports } from '../api/import';
import type { Import } from '@family-budget/shared';
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function DataSection() { export function DataSection() {
const [showClearModal, setShowClearModal] = useState(false); const [showClearModal, setShowClearModal] = useState(false);
const [imports, setImports] = useState<Import[]>([]);
const [impToDelete, setImpToDelete] = useState<Import | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
getImports().then(setImports).catch(() => {});
}, []);
const handleImportDeleted = () => {
setImpToDelete(null);
getImports().then(setImports).catch(() => {});
navigate('/history');
};
return ( return (
<div className="data-section"> <div className="data-section">
<div className="section-block">
<h3>История импортов</h3>
<p className="section-desc">
Список импортов выписок. Можно удалить операции конкретного импорта.
</p>
{imports.length === 0 ? (
<p className="muted">Импортов пока нет.</p>
) : (
<div className="table-responsive">
<table className="table">
<thead>
<tr>
<th>Дата</th>
<th>Счёт</th>
<th>Банк</th>
<th>Импортировано</th>
<th>Дубликаты</th>
<th></th>
</tr>
</thead>
<tbody>
{imports.map((imp) => (
<tr key={imp.id}>
<td>{formatDate(imp.importedAt)}</td>
<td>
{imp.accountAlias || imp.accountNumberMasked || '—'}
</td>
<td>{imp.bank}</td>
<td>{imp.importedCount}</td>
<td>{imp.duplicatesSkipped}</td>
<td>
<button
type="button"
className="btn btn-sm btn-danger"
onClick={() => setImpToDelete(imp)}
disabled={imp.importedCount === 0}
>
Удалить
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="section-block"> <div className="section-block">
<h3>Очистка данных</h3> <h3>Очистка данных</h3>
<p className="section-desc"> <p className="section-desc">
@@ -32,6 +106,14 @@ export function DataSection() {
}} }}
/> />
)} )}
{impToDelete && (
<DeleteImportModal
imp={impToDelete}
onClose={() => setImpToDelete(null)}
onDone={handleImportDeleted}
/>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,75 @@
import { useState } from 'react';
import { deleteImport } from '../api/import';
import type { Import } from '@family-budget/shared';
interface Props {
imp: Import;
onClose: () => void;
onDone: () => void;
}
export function DeleteImportModal({ imp, onClose, onDone }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const accountLabel =
imp.accountAlias || imp.accountNumberMasked || `ID ${imp.accountId}`;
const handleConfirm = async () => {
setLoading(true);
setError('');
try {
await deleteImport(imp.id);
onDone();
} catch (e) {
setError(
e instanceof Error ? e.message : 'Ошибка при удалении импорта',
);
} finally {
setLoading(false);
}
};
return (
<div
className="modal-overlay"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Удалить импорт</h2>
<button className="btn-close" onClick={onClose}>
&times;
</button>
</div>
<div className="modal-body">
<p className="clear-history-warn">
Будут удалены все операции этого импорта ({imp.importedCount}{' '}
шт.): {imp.bank} / {accountLabel}
</p>
{error && <div className="alert alert-error">{error}</div>}
<p>Действие необратимо.</p>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-danger"
onClick={handleConfirm}
disabled={loading}
>
{loading ? 'Удаление…' : 'Удалить'}
</button>
<button type="button" className="btn btn-secondary" onClick={onClose}>
Отмена
</button>
</div>
</div>
</div>
);
}

View File

@@ -24,6 +24,14 @@ function extractPattern(description: string): string {
.slice(0, 50); .slice(0, 50);
} }
function getCommissionAmountSigned(transaction: Transaction): number {
if (transaction.commission === 0) return 0;
const isCashbackIncome =
transaction.amountSigned === 0 &&
transaction.description.toLowerCase().includes('зачисление');
return isCashbackIncome ? transaction.commission : -transaction.commission;
}
export function EditTransactionModal({ export function EditTransactionModal({
transaction, transaction,
categories, categories,
@@ -74,7 +82,12 @@ export function EditTransactionModal({
}; };
return ( return (
<div className="modal-overlay" onClick={onClose}> <div
className="modal-overlay"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal" onClick={(e) => e.stopPropagation()}> <div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header"> <div className="modal-header">
<h2>Редактирование операции</h2> <h2>Редактирование операции</h2>
@@ -96,6 +109,12 @@ export function EditTransactionModal({
<span className="modal-tx-label">Сумма</span> <span className="modal-tx-label">Сумма</span>
<span>{formatAmount(transaction.amountSigned)}</span> <span>{formatAmount(transaction.amountSigned)}</span>
</div> </div>
{transaction.commission !== 0 && (
<div className="modal-tx-row">
<span className="modal-tx-label">Комиссия</span>
<span>{formatAmount(getCommissionAmountSigned(transaction))}</span>
</div>
)}
<div className="modal-tx-row"> <div className="modal-tx-row">
<span className="modal-tx-label">Описание</span> <span className="modal-tx-label">Описание</span>
<span className="modal-tx-description"> <span className="modal-tx-description">

View File

@@ -2,7 +2,6 @@ import { useState, useRef } from 'react';
import type { ImportStatementResponse } from '@family-budget/shared'; import type { ImportStatementResponse } from '@family-budget/shared';
import { importStatement } from '../api/import'; import { importStatement } from '../api/import';
import { updateAccount } from '../api/accounts'; import { updateAccount } from '../api/accounts';
import { useImport } from '../context/ImportContext';
interface Props { interface Props {
onClose: () => void; onClose: () => void;
@@ -10,19 +9,13 @@ interface Props {
} }
export function ImportModal({ onClose, onDone }: Props) { export function ImportModal({ onClose, onDone }: Props) {
const { importState, startImport, clearImport } = useImport(); const [result, setResult] = useState<ImportStatementResponse | null>(null);
const [error, setError] = useState('');
const [jsonResult, setJsonResult] = useState<ImportStatementResponse | null>(null); const [loading, setLoading] = useState(false);
const [jsonError, setJsonError] = useState('');
const [jsonLoading, setJsonLoading] = useState(false);
const [alias, setAlias] = useState(''); const [alias, setAlias] = useState('');
const [aliasSaved, setAliasSaved] = useState(false); const [aliasSaved, setAliasSaved] = useState(false);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const result = importState.result ?? jsonResult;
const error = importState.error || jsonError;
const loading = importState.active || jsonLoading;
const handleFileChange = async ( const handleFileChange = async (
e: React.ChangeEvent<HTMLInputElement>, e: React.ChangeEvent<HTMLInputElement>,
) => { ) => {
@@ -35,45 +28,26 @@ export function ImportModal({ onClose, onDone }: Props) {
const isJson = type === 'application/json' || name.endsWith('.json'); const isJson = type === 'application/json' || name.endsWith('.json');
if (!isPdf && !isJson) { if (!isPdf && !isJson) {
setJsonError('Допустимы только файлы PDF или JSON'); setError('Допустимы только файлы PDF или JSON');
return; return;
} }
setJsonError(''); setLoading(true);
setJsonResult(null); setError('');
setResult(null);
if (isPdf) {
startImport(file);
return;
}
setJsonLoading(true);
try { try {
const resp = await importStatement(file); const resp = await importStatement(file);
setJsonResult(resp); setResult(resp);
} catch (err: unknown) { } catch (err: unknown) {
const msg = const msg =
err instanceof Error ? err.message : 'Ошибка импорта'; err instanceof Error ? err.message : 'Ошибка импорта';
setJsonError(msg); setError(msg);
} finally { } finally {
setJsonLoading(false); setLoading(false);
} }
}; };
const handleClose = () => {
if (importState.active) {
if (window.confirm('Импорт продолжится в фоне. Закрыть окно?')) {
onClose();
}
} else {
onClose();
}
};
const handleDone = () => {
onDone();
};
const handleSaveAlias = async () => { const handleSaveAlias = async () => {
if (!result || !alias.trim()) return; if (!result || !alias.trim()) return;
try { try {
@@ -84,21 +58,17 @@ export function ImportModal({ onClose, onDone }: Props) {
} }
}; };
const stageLabel = (stage: string) => {
switch (stage) {
case 'pdf': return 'Извлечение текста...';
case 'llm': return 'Конвертация через LLM...';
case 'import': return 'Сохранение в базу...';
default: return 'Обработка...';
}
};
return ( return (
<div className="modal-overlay" onClick={handleClose}> <div
className="modal-overlay"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal" onClick={(e) => e.stopPropagation()}> <div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header"> <div className="modal-header">
<h2>Импорт выписки</h2> <h2>Импорт выписки</h2>
<button className="btn-close" onClick={handleClose}> <button className="btn-close" onClick={onClose}>
&times; &times;
</button> </button>
</div> </div>
@@ -106,7 +76,7 @@ export function ImportModal({ onClose, onDone }: Props) {
<div className="modal-body"> <div className="modal-body">
{error && <div className="alert alert-error">{error}</div>} {error && <div className="alert alert-error">{error}</div>}
{!result && !importState.active && ( {!result && (
<div className="import-upload"> <div className="import-upload">
<p>Выберите файл выписки (PDF или JSON, формат 1.0)</p> <p>Выберите файл выписки (PDF или JSON, формат 1.0)</p>
<input <input
@@ -115,30 +85,13 @@ export function ImportModal({ onClose, onDone }: Props) {
accept=".pdf,.json,application/pdf,application/json" accept=".pdf,.json,application/pdf,application/json"
onChange={handleFileChange} onChange={handleFileChange}
className="file-input" className="file-input"
disabled={loading}
/> />
{jsonLoading && ( {loading && (
<div className="import-loading">Импорт...</div> <div className="import-loading">Импорт...</div>
)} )}
</div> </div>
)} )}
{importState.active && (
<div className="import-upload">
<div className="import-progress-modal">
<div className="import-progress-modal-bar">
<div
className="import-progress-modal-fill"
style={{ width: `${importState.progress}%` }}
/>
</div>
<p className="import-progress-modal-label">
{stageLabel(importState.stage)} {importState.progress}%
</p>
</div>
</div>
)}
{result && ( {result && (
<div className="import-result"> <div className="import-result">
<div className="import-result-icon" aria-hidden="true"></div> <div className="import-result-icon" aria-hidden="true"></div>
@@ -201,15 +154,16 @@ export function ImportModal({ onClose, onDone }: Props) {
<div className="modal-footer"> <div className="modal-footer">
{result ? ( {result ? (
<button className="btn btn-primary" onClick={handleDone}> <button className="btn btn-primary" onClick={onDone}>
Готово Готово
</button> </button>
) : ( ) : (
<button <button
className="btn btn-secondary" className="btn btn-secondary"
onClick={handleClose} onClick={onClose}
disabled={loading}
> >
{importState.active ? 'Свернуть' : 'Отмена'} Отмена
</button> </button>
)} )}
</div> </div>

View File

@@ -1,81 +1,23 @@
import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react'; import { useState, useEffect, type ReactNode } from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useImport } from '../context/ImportContext'; import { getBackendVersion } from '../api/version';
function ImportProgressBar() {
const { importState, clearImport, openModal } = useImport();
const [visible, setVisible] = useState(false);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const isActive = importState.active;
const isDone = importState.stage === 'done';
const isError = importState.stage === 'error';
const showBar = isActive || isDone || isError;
useEffect(() => {
if (showBar) {
setVisible(true);
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
}
if (isDone || isError) {
hideTimerRef.current = setTimeout(() => {
setVisible(false);
clearImport();
}, 10_000);
}
return () => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
};
}, [showBar, isDone, isError, clearImport]);
const handleClick = useCallback(() => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
openModal();
setVisible(false);
}, [openModal]);
if (!visible) return null;
const barClass = isError
? 'import-progress-bar import-progress-bar--error'
: isDone
? 'import-progress-bar import-progress-bar--done'
: 'import-progress-bar';
const labelText = isError
? `Ошибка импорта: ${importState.message}`
: isDone && importState.result
? `Импорт завершён — ${importState.result.imported} операций`
: `${importState.message} ${importState.progress}%`;
return (
<div className={barClass}>
<div
className="import-progress-bar__fill"
style={{ width: `${isDone ? 100 : importState.progress}%` }}
/>
<button
type="button"
className={`import-progress-label ${isDone || isError ? 'import-progress-label--clickable' : ''}`}
onClick={isDone || isError ? handleClick : undefined}
>
{labelText}
</button>
</div>
);
}
export function Layout({ children }: { children: ReactNode }) { export function Layout({ children }: { children: ReactNode }) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [beVersion, setBeVersion] = useState<string | null>(null);
const closeDrawer = () => setDrawerOpen(false); const closeDrawer = () => setDrawerOpen(false);
useEffect(() => {
getBackendVersion()
.then((r) => setBeVersion(r.version))
.catch(() => setBeVersion(null));
}, []);
return ( return (
<div className="layout"> <div className="layout">
<ImportProgressBar />
<button <button
type="button" type="button"
className="burger-btn" className="burger-btn"
@@ -152,11 +94,19 @@ export function Layout({ children }: { children: ReactNode }) {
</nav> </nav>
<div className="sidebar-footer"> <div className="sidebar-footer">
<div className="sidebar-footer-top">
<span className="sidebar-user">{user?.login}</span> <span className="sidebar-user">{user?.login}</span>
<button className="btn-logout" onClick={() => logout()}> <button className="btn-logout" onClick={() => logout()}>
Выход Выход
</button> </button>
</div> </div>
<div className="sidebar-footer-bottom">
<span className="sidebar-version">
FE {__FE_VERSION__} · BE {beVersion ?? '…'}
</span>
<span className="sidebar-copyright">© 2025 Семейный бюджет</span>
</div>
</div>
</aside> </aside>
<main className="main-content">{children}</main> <main className="main-content">{children}</main>

View File

@@ -31,6 +31,18 @@ export function SummaryCards({ summary }: Props) {
</div> </div>
</div> </div>
<div className="summary-card summary-card-investments">
<div className="summary-label">На инвестиции</div>
<div className="summary-value">
{formatAmount(summary.investmentOutflow)}
</div>
{summary.investmentIncomeExcluded > 0 && (
<div className="summary-subvalue">
Исключено из доходов: {formatAmount(summary.investmentIncomeExcluded)}
</div>
)}
</div>
{summary.topCategories.length > 0 && ( {summary.topCategories.length > 0 && (
<div className="summary-card summary-card-top"> <div className="summary-card summary-card-top">
<div className="summary-label">Топ расходов</div> <div className="summary-label">Топ расходов</div>

View File

@@ -33,6 +33,7 @@ export function TimeseriesChart({ data }: Props) {
period: item.periodStart, period: item.periodStart,
Расходы: Math.abs(item.expenseAmount) / 100, Расходы: Math.abs(item.expenseAmount) / 100,
Доходы: item.incomeAmount / 100, Доходы: item.incomeAmount / 100,
Инвестиции: Math.abs(item.investmentOutflow) / 100,
})); }));
return ( return (
@@ -69,6 +70,11 @@ export function TimeseriesChart({ data }: Props) {
fill="#10b981" fill="#10b981"
radius={[4, 4, 0, 0]} radius={[4, 4, 0, 0]}
/> />
<Bar
dataKey="Инвестиции"
fill="#f59e0b"
radius={[4, 4, 0, 0]}
/>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
); );

View File

@@ -13,6 +13,13 @@ const DIRECTION_CLASSES: Record<string, string> = {
transfer: 'amount-transfer', transfer: 'amount-transfer',
}; };
function getCommissionAmountSigned(tx: Transaction): number {
if (tx.commission === 0) return 0;
const isCashbackIncome =
tx.amountSigned === 0 && tx.description.toLowerCase().includes('зачисление');
return isCashbackIncome ? tx.commission : -tx.commission;
}
function TransactionCard({ function TransactionCard({
tx, tx,
onEdit, onEdit,
@@ -36,6 +43,11 @@ function TransactionCard({
{formatAmount(tx.amountSigned)} {formatAmount(tx.amountSigned)}
</span> </span>
</div> </div>
{tx.commission !== 0 && (
<div className="transaction-card-commission">
Комиссия: {formatAmount(getCommissionAmountSigned(tx))}
</div>
)}
<div className="transaction-card-body"> <div className="transaction-card-body">
<span className="description-text">{tx.description}</span> <span className="description-text">{tx.description}</span>
{tx.comment && ( {tx.comment && (
@@ -117,7 +129,12 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
<td <td
className={`td-nowrap td-amount ${DIRECTION_CLASSES[tx.direction] ?? ''}`} className={`td-nowrap td-amount ${DIRECTION_CLASSES[tx.direction] ?? ''}`}
> >
{formatAmount(tx.amountSigned)} <div>{formatAmount(tx.amountSigned)}</div>
{tx.commission !== 0 && (
<div className="td-commission">
Комиссия: {formatAmount(getCommissionAmountSigned(tx))}
</div>
)}
</td> </td>
<td className="td-description"> <td className="td-description">
<span className="description-text">{tx.description}</span> <span className="description-text">{tx.description}</span>

View File

@@ -10,7 +10,7 @@ import { getMe, login as apiLogin, logout as apiLogout } from '../api/auth';
import { setOnUnauthorized } from '../api/client'; import { setOnUnauthorized } from '../api/client';
interface AuthState { interface AuthState {
user: { login: string } | null; user: { login: string; backendVersion: string } | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
@@ -20,7 +20,7 @@ interface AuthState {
const AuthContext = createContext<AuthState | null>(null); const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<{ login: string } | null>(null); const [user, setUser] = useState<{ login: string; backendVersion: string } | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -31,7 +31,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
setOnUnauthorized(clearUser); setOnUnauthorized(clearUser);
getMe() getMe()
.then((me) => setUser({ login: me.login })) .then((me) => setUser({ login: me.login, backendVersion: me.backendVersion }))
.catch(() => setUser(null)) .catch(() => setUser(null))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [clearUser]); }, [clearUser]);
@@ -41,7 +41,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try { try {
await apiLogin({ login: username, password }); await apiLogin({ login: username, password });
const me = await getMe(); const me = await getMe();
setUser({ login: me.login }); setUser({ login: me.login, backendVersion: me.backendVersion });
} catch (e: unknown) { } catch (e: unknown) {
const msg = e instanceof Error && e.message === 'Failed to fetch' const msg = e instanceof Error && e.message === 'Failed to fetch'
? 'Сервер недоступен. Проверьте, что backend запущен и NPM проксирует /api на порт 3000.' ? 'Сервер недоступен. Проверьте, что backend запущен и NPM проксирует /api на порт 3000.'

View File

@@ -1,114 +0,0 @@
import {
createContext,
useContext,
useState,
useCallback,
useRef,
type ReactNode,
} from 'react';
import type { ImportStatementResponse } from '@family-budget/shared';
import { importStatementStream, type SseEvent } from '../api/import';
export interface ImportProgress {
active: boolean;
stage: string;
progress: number;
message: string;
result?: ImportStatementResponse;
error?: string;
}
interface ImportContextValue {
importState: ImportProgress;
showModal: boolean;
openModal: () => void;
closeModal: () => void;
startImport: (file: File) => void;
clearImport: () => void;
}
const INITIAL: ImportProgress = {
active: false,
stage: '',
progress: 0,
message: '',
};
const ImportContext = createContext<ImportContextValue | null>(null);
export function ImportProvider({ children }: { children: ReactNode }) {
const [importState, setImportState] = useState<ImportProgress>(INITIAL);
const [showModal, setShowModal] = useState(false);
const runningRef = useRef(false);
const openModal = useCallback(() => setShowModal(true), []);
const closeModal = useCallback(() => setShowModal(false), []);
const startImport = useCallback((file: File) => {
if (runningRef.current) return;
runningRef.current = true;
setImportState({
active: true,
stage: 'pdf',
progress: 0,
message: 'Загрузка файла...',
});
importStatementStream(file, (event: SseEvent) => {
if (event.stage === 'done') {
setImportState({
active: false,
stage: 'done',
progress: 100,
message: 'Импорт завершён',
result: event.result,
});
runningRef.current = false;
} else if (event.stage === 'error') {
setImportState({
active: false,
stage: 'error',
progress: 0,
message: event.message,
error: event.message,
});
runningRef.current = false;
} else {
setImportState({
active: true,
stage: event.stage,
progress: event.progress,
message: event.message,
});
}
}).catch((err) => {
setImportState({
active: false,
stage: 'error',
progress: 0,
message: err instanceof Error ? err.message : 'Ошибка импорта',
error: err instanceof Error ? err.message : 'Ошибка импорта',
});
runningRef.current = false;
});
}, []);
const clearImport = useCallback(() => {
setImportState(INITIAL);
}, []);
return (
<ImportContext.Provider value={{
importState, showModal, openModal, closeModal, startImport, clearImport,
}}>
{children}
</ImportContext.Provider>
);
}
export function useImport(): ImportContextValue {
const ctx = useContext(ImportContext);
if (!ctx) throw new Error('useImport must be used within ImportProvider');
return ctx;
}

View File

@@ -18,7 +18,6 @@ import { TransactionTable } from '../components/TransactionTable';
import { Pagination } from '../components/Pagination'; import { Pagination } from '../components/Pagination';
import { EditTransactionModal } from '../components/EditTransactionModal'; import { EditTransactionModal } from '../components/EditTransactionModal';
import { ImportModal } from '../components/ImportModal'; import { ImportModal } from '../components/ImportModal';
import { useImport } from '../context/ImportContext';
import { toISODate } from '../utils/format'; import { toISODate } from '../utils/format';
const PARAM_KEYS = [ const PARAM_KEYS = [
@@ -126,7 +125,7 @@ export function HistoryPage() {
const [accounts, setAccounts] = useState<Account[]>([]); const [accounts, setAccounts] = useState<Account[]>([]);
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [editingTx, setEditingTx] = useState<Transaction | null>(null); const [editingTx, setEditingTx] = useState<Transaction | null>(null);
const { showModal: showImport, openModal: openImport, closeModal: closeImport, clearImport } = useImport(); const [showImport, setShowImport] = useState(false);
useEffect(() => { useEffect(() => {
getAccounts().then(setAccounts).catch(() => {}); getAccounts().then(setAccounts).catch(() => {});
@@ -198,8 +197,7 @@ export function HistoryPage() {
}; };
const handleImportDone = () => { const handleImportDone = () => {
closeImport(); setShowImport(false);
clearImport();
fetchData(); fetchData();
getAccounts().then(setAccounts).catch(() => {}); getAccounts().then(setAccounts).catch(() => {});
}; };
@@ -210,7 +208,7 @@ export function HistoryPage() {
<h1>История операций</h1> <h1>История операций</h1>
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={openImport} onClick={() => setShowImport(true)}
> >
Импорт выписки Импорт выписки
</button> </button>
@@ -264,7 +262,7 @@ export function HistoryPage() {
{showImport && ( {showImport && (
<ImportModal <ImportModal
onClose={closeImport} onClose={() => setShowImport(false)}
onDone={handleImportDone} onDone={handleImportDone}
/> />
)} )}

View File

@@ -138,9 +138,26 @@ body {
.sidebar-footer { .sidebar-footer {
padding: 16px 20px; padding: 16px 20px;
border-top: 1px solid rgba(255, 255, 255, 0.1); border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
gap: 10px;
}
.sidebar-footer-bottom {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
font-size: 11px;
color: var(--color-sidebar-text);
opacity: 0.85;
}
.sidebar-version {
font-family: ui-monospace, monospace;
}
.sidebar-copyright {
font-size: 10px;
} }
.sidebar-user { .sidebar-user {
@@ -148,6 +165,12 @@ body {
color: var(--color-sidebar-text); color: var(--color-sidebar-text);
} }
.sidebar-footer-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.btn-logout { .btn-logout {
background: none; background: none;
border: none; border: none;
@@ -249,6 +272,7 @@ body {
.page { .page {
max-width: 1200px; max-width: 1200px;
margin: 0 auto;
} }
.page-header { .page-header {
@@ -644,6 +668,12 @@ textarea {
margin-bottom: 8px; margin-bottom: 8px;
} }
.transaction-card-commission {
font-size: 12px;
color: var(--color-text-secondary);
margin-bottom: 6px;
}
.transaction-card-footer { .transaction-card-footer {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -714,6 +744,12 @@ textarea {
font-weight: 500; font-weight: 500;
} }
.td-commission {
font-size: 12px;
color: var(--color-text-secondary);
font-weight: 400;
}
.amount-income { .amount-income {
color: var(--color-success); color: var(--color-success);
} }
@@ -1058,152 +1094,6 @@ textarea {
font-weight: 500; font-weight: 500;
} }
/* Import progress bar in modal */
.import-progress-modal {
padding: 20px 0;
}
.import-progress-modal-bar {
height: 8px;
background: var(--color-border);
border-radius: 4px;
overflow: hidden;
margin-bottom: 10px;
}
.import-progress-modal-fill {
height: 100%;
background: linear-gradient(90deg, var(--color-primary), #6366f1);
border-radius: 4px;
transition: width 0.3s ease;
position: relative;
}
.import-progress-modal-fill::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.3) 50%,
transparent 100%
);
animation: shimmer 1.5s infinite;
}
.import-progress-modal-label {
text-align: center;
color: var(--color-text-secondary);
font-size: 14px;
}
/* ================================================================
Import progress bar (fixed top, Layout)
================================================================ */
.import-progress-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 300;
height: 4px;
background: var(--color-border);
}
.import-progress-bar--done {
background: var(--color-success-light);
}
.import-progress-bar--error {
background: var(--color-danger-light);
}
.import-progress-bar__fill {
height: 100%;
border-radius: 0 2px 2px 0;
transition: width 0.3s ease;
position: relative;
background: linear-gradient(90deg, var(--color-primary), #6366f1);
}
.import-progress-bar__fill::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.4) 50%,
transparent 100%
);
animation: shimmer 1.5s infinite;
}
.import-progress-bar--done .import-progress-bar__fill {
background: var(--color-success);
}
.import-progress-bar--done .import-progress-bar__fill::after {
animation: none;
}
.import-progress-bar--error .import-progress-bar__fill {
background: var(--color-danger);
width: 100% !important;
}
.import-progress-bar--error .import-progress-bar__fill::after {
animation: none;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.import-progress-label {
position: fixed;
top: 6px;
right: 16px;
z-index: 301;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 16px;
padding: 4px 14px;
font-size: 12px;
font-weight: 500;
color: var(--color-text-secondary);
box-shadow: var(--shadow-sm);
white-space: nowrap;
cursor: default;
font-family: inherit;
}
.import-progress-label--clickable {
cursor: pointer;
}
.import-progress-label--clickable:hover {
background: var(--color-bg);
border-color: var(--color-border-hover);
}
.import-progress-bar--done .import-progress-label {
color: var(--color-success);
border-color: var(--color-success);
}
.import-progress-bar--error .import-progress-label {
color: var(--color-danger);
border-color: var(--color-danger);
}
/* ================================================================ /* ================================================================
Tabs Tabs
================================================================ */ ================================================================ */
@@ -1433,6 +1323,10 @@ textarea {
border-left-color: var(--color-primary); border-left-color: var(--color-primary);
} }
.summary-card-investments {
border-left-color: var(--color-warning);
}
.summary-label { .summary-label {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
@@ -1448,6 +1342,12 @@ textarea {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.summary-subvalue {
margin-top: 8px;
font-size: 12px;
color: var(--color-text-secondary);
}
.summary-top-list { .summary-top-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -1 +1,3 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare const __FE_VERSION__: string;

View File

@@ -1,8 +1,19 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(
readFileSync(join(__dirname, 'package.json'), 'utf-8'),
) as { version: string };
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
define: {
__FE_VERSION__: JSON.stringify(pkg.version),
},
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {

9
package-lock.json generated
View File

@@ -111,6 +111,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -1394,6 +1395,7 @@
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/body-parser": "*", "@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0", "@types/express-serve-static-core": "^5.0.0",
@@ -1472,6 +1474,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -1611,6 +1614,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -2734,6 +2738,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==", "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.11.0", "pg-connection-string": "^2.11.0",
"pg-pool": "^3.12.0", "pg-pool": "^3.12.0",
@@ -2831,6 +2836,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -2980,6 +2986,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -2989,6 +2996,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -4100,6 +4108,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",

View File

@@ -1,6 +1,6 @@
{ {
"name": "family-budget", "name": "family-budget",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"shared", "shared",

View File

@@ -1,75 +1,231 @@
Ты — конвертер банковских выписок. Твоя задача: извлечь данные из прикреплённого PDF банковской выписки и вернуть строго один валидный JSON-объект в формате ниже. Никакого текста до или после JSON, только сам объект. # Инструкция агента: конвертация банковской выписки ВТБ (PDF → JSON)
## Структура выходного JSON ## Цель
Преобразовать PDF-выписку банка ВТБ в JSON-файл строго по схеме 1.0.
На выходе — валидный JSON-файл без потерь операций, с правильными суммами в копейках и корректной проверкой баланса.
---
## Схема JSON (schema 1.0)
```json ```json
{ {
"schemaVersion": "1.0", "schemaVersion": "1.0",
"bank": "<название банка из выписки>", "bank": "VTB",
"statement": { "statement": {
"accountNumber": "<номер счёта, только цифры, без пробелов>", "accountNumber": "string",
"currency": "RUB", "currency": "RUB",
"openingBalance": <число в копейках, целое>, "openingBalance": integer,
"closingBalance": <число в копейках, целое>, "closingBalance": integer,
"exportedAt": "<дата экспорта в формате ISO 8601 с offset, например 2026-02-27T13:23:00+03:00>" "exportedAt": "ISO 8601 string"
}, },
"transactions": [ "transactions": [
{ {
"operationAt": "<дата и время операции в формате ISO 8601 с offset>", "operationAt": "ISO 8601 string",
"amountSigned": <число: положительное для прихода, отрицательное для расхода; в копейках>, "amountSigned": integer,
"commission": <число, целое, >= 0, в копейках>, "commission": integer,
"description": "<полное описание операции из выписки>" "description": "string"
} }
] ]
} }
``` ```
## Правила конвертации ---
1. **Суммы** — всегда в копейках (рубли × 100). Пример: 500,00 ₽ → 50000, -1234,56 ₽ → -123456. ## Шаг 1. Извлечение данных из PDF
2. **amountSigned**: ### 1.1 Шапка выписки
- Приход (зачисление, пополнение) — положительное число.
- Расход (списание, оплата) — отрицательное число.
- Переводы — знак в зависимости от направления движения на счёт.
3. **operationAt** — дата и время операции. Если время не указано, используй 00:00:00. Обязательно указывай offset (+03:00 для МСК). Извлечь из заголовочного блока:
4. **commission** — комиссия по операции. Если не указана — 0. | Поле PDF | Поле JSON | Примечание |
|---------------------------|----------------------------------|-----------------------------------|
| Номер счёта | `statement.accountNumber` | строка, без пробелов |
| Баланс на начало периода | `statement.openingBalance` | перевести в копейки (× 100) |
| Баланс на конец периода | `statement.closingBalance` | перевести в копейки (× 100) |
| Поступления | только для валидации | не записывать в JSON |
| Расходные операции | только для валидации | не записывать в JSON |
| Период выписки | используется для валидации | формат ДД.ММ.ГГГГ ДД.ММ.ГГГГ |
5. **description** — полный текст операции как в выписке (назначение платежа, магазин, получатель и т.п.). Не сокращай и не меняй формулировки. ### 1.2 Поле `exportedAt`
6. **accountNumber** — только цифры, без пробелов и дефисов (например: 40817810825104025611). Взять дату и время **первой строки таблицы операций** (самая поздняя по дате операции).
Формат: ISO 8601 с московским часовым поясом: `YYYY-MM-DDTHH:MM:SS+03:00`
7. **openingBalance / closingBalance** — начальный и конечный остаток по счёту в копейках. ---
8. **bank** — краткое название банка (VTB, Sberbank, Тинькофф и т.п.). ## Шаг 2. Извлечение операций
9. **exportedAt** — дата формирования выписки. Если неизвестна — возьми дату последней операции в выписке. ### 2.1 Столбцы таблицы в PDF
10. **Порядок транзакций** — сохраняй хронологический порядок из выписки (обычно от старых к новым). Каждая строка операции содержит:
## Требования | Столбец PDF | Описание |
|-------------------------------------|------------------------------------------------|
| Дата и время операции | дата + время совершения операции |
| Дата обработки банком | дата, когда банк обработал операцию |
| Сумма операции в валюте операции | знаковая сумма (`-` = расход, без знака = приход) |
| Приход (в валюте счёта) | сумма, если поступление |
| Расход (в валюте счёта) | сумма, если списание |
| Комиссия | комиссия банка |
| Описание операции | текстовое описание |
- Массив `transactions` не должен быть пустым. ### 2.2 Правила маппинга
- Все числа — целые.
- Даты — строго в формате ISO 8601 с offset.
- currency всегда "RUB".
- schemaVersion всегда "1.0".
## Пример одной транзакции **`operationAt`** — из столбца «Дата и время операции», формат ISO 8601 + московский TZ:
```
29.03.2026 20:38:01 → 2026-03-29T20:38:01+03:00
```
Выписка: «26.02.2026 14:06 | -500,00 ₽ | 0,00 | Оплата товаров. PAVELETSKAYA по карте *8214» **`amountSigned`** — сумма в **копейках** (целое число):
- Если операция — **приход**: значение **положительное**
- Если операция — **расход**: значение **отрицательное**
- Допустим специальный кейс: `amountSigned = 0`, **только если** `commission > 0` и в `description` есть подстрока **«Зачисление»** (без учёта регистра)
- Определять знак по столбцу «Приход»/«Расход», а не по знаку в PDF (там `-` стоит у расходов, но надёжнее смотреть, в какой колонке стоит сумма)
- Перевод в копейки: умножить на 100 и округлить до целого (`round(amount * 100)`)
- Разделитель дробной части в PDF — точка или запятая — оба варианта обрабатывать
**`commission`** — комиссия в **копейках** (целое число):
- Если в PDF стоит `0.00` → записать `0`
- Если указана ненулевая комиссия → перевести в копейки аналогично `amountSigned`
- Комиссия всегда **неотрицательная**
**`description`** — полный текст описания операции, строка:
- Убрать лишние пробелы и переносы строк (привести к одной строке)
- Не обрезать и не сокращать
- Сохранить кириллицу и спецсимволы как есть
### 2.3 Порядок операций
Операции записывать **в том же порядке, что в PDF** (от новых к старым, сверху вниз).
---
## Шаг 3. Валидация перед записью
### 3.1 Проверка баланса
Вычислить:
```
income_sum = сумма всех amountSigned > 0 (в копейках)
expense_sum = сумма всех amountSigned < 0 (в копейках, отрицательная)
```
Проверить, что:
```
openingBalance + income_sum + expense_sum == closingBalance
```
**Если не совпадает** — это нормально и объясняется поведением ВТБ:
> ВТБ формирует итоги в шапке по **дате обработки банком**, а не по дате операции.
> Операции с датой обработки **вне периода выписки** включаются в список транзакций, но **не учитываются в строках «Поступления» и «Расходные операции»** в шапке.
Правильная проверка:
1. Определить границы периода из шапки PDF (атаачала`, атаонца`)
2. Из `Поступления` и `Расходные операции` вычислить ожидаемый net:
`expected_net = openingBalance_kopecks + income_pdf_kopecks - expense_pdf_kopecks`
3. Убедиться, что `expected_net == closingBalance` (это всегда верно если данные из PDF прочитаны правильно)
4. Разница между суммой всех транзакций и `(closingBalance - openingBalance)` — это сумма операций, обработанных банком вне периода. Это **не ошибка**.
### 3.2 Проверка полноты
- Подсчитать количество извлечённых транзакций
- Пройтись по всем страницам PDF и убедиться, что ни одна строка таблицы не пропущена
- Особое внимание: операции в конце страницы и в начале следующей (частые точки потери данных)
### 3.3 Проверка типов
Убедиться, что:
- `openingBalance`, `closingBalance` — целые числа (`int`)
- `amountSigned`, `commission` — целые числа (`int`)
- `operationAt`, `exportedAt` — строки в формате ISO 8601 с `+03:00`
- `description` — строка (не `null`, не пустая)
---
## Шаг 4. Частые ошибки — предотвращение
| Ошибка | Причина | Как избежать |
|--------|---------|--------------|
| Сумма в рублях вместо копеек | Забыли умножить на 100 | Всегда применять `round(x * 100)` |
| Дробные числа вместо int | Потеря точности float | Использовать `round()`, результат кастовать в `int` |
| Неверный знак `amountSigned` | Ориентировались на знак в PDF | Определять знак по столбцу «Приход»/«Расход» |
| Потеря операций на стыке страниц | Парсинг постраничный | Читать PDF целиком, не разбивать по страницам |
| Обрезанное описание | Перенос строки в ячейке PDF | Объединять строки одной ячейки в одну строку |
| Комиссия со знаком минус | Перепутали знак | Комиссия всегда `>= 0` |
| `amountSigned = 0` без условия | Импорт отклонит файл | Разрешать `amountSigned = 0` только при `commission > 0` и наличии «Зачисление» в `description` |
| Неверный `exportedAt` | Взяли дату начала периода | Брать дату и время **первой (самой новой) операции** в таблице |
| Расхождение баланса | Не учли операции вне периода | Это не ошибка — см. Шаг 3.1 |
| Лишние пробелы в описании | PDF добавляет пробелы при переносе | Применять `.strip()` и нормализацию пробелов |
---
## Шаг 5. Запись файла
- Имя файла: то же, что у исходного PDF, с заменой расширения `.pdf``.json`
Пример: `file-18.pdf``file-18.json`
- Кодировка: **UTF-8**
- Формат: JSON с отступами (indent = 2)
- `ensure_ascii = false` — кириллица записывается как есть, не экранируется
---
## Пример корректной транзакции
```json ```json
{ {
"operationAt": "2026-02-26T14:06:00+03:00", "operationAt": "2026-03-29T20:38:01+03:00",
"amountSigned": -50000, "amountSigned": -500000,
"commission": 0, "commission": 0,
"description": "Оплата товаров. PAVELETSKAYA по карте *8214" "description": "Оплата товаров и услуг. IP KOVTUN L.B.. по карте *9058"
} }
``` ```
Обработай прикреплённый PDF и верни один JSON-объект. Расшифровка: списание 5 000.00 ₽ = -500 000 копеек, комиссии нет.
---
## Пример корректного поступления
```json
{
"operationAt": "2026-03-24T15:09:41+03:00",
"amountSigned": 9537522,
"commission": 0,
"description": "Поступление заработной платы. 0726 НАЦИОНАЛЬНЫЙ ИССЛЕДОВАТЕЛЬСКИЙ УНИВЕРСИТЕТ \"ВЫСША Поступление заработной платы/иных выплат Salary по реестру Z_0000005862_20260323_001_01 от 23.03.2026. Без НДС.."
}
```
Расшифровка: поступление 95 375.22 ₽ = +9 537 522 копейки.
---
## Пример корректного кэшбэка (сумма в commission)
```json
{
"operationAt": "2026-04-07T19:56:59+03:00",
"amountSigned": 0,
"commission": 592000,
"description": "Зачисление кешбэка по программе лояльности."
}
```
Расшифровка: для такого кейса `amountSigned = 0` допустим, потому что есть `commission > 0` и маркер «Зачисление» в описании.
---
## Итоговый чеклист перед отдачей файла
- [ ] `schemaVersion` = `"1.0"`
- [ ] `bank` = `"VTB"`
- [ ] `accountNumber` совпадает с PDF
- [ ] `openingBalance` и `closingBalance` в копейках, совпадают с PDF
- [ ] `exportedAt` = дата и время первой операции в таблице, формат ISO 8601 +03:00
- [ ] Количество транзакций совпадает с количеством строк в PDF
- [ ] Все `amountSigned` и `commission` — целые числа
- [ ] Если `amountSigned = 0`, то `commission > 0` и в `description` есть «Зачисление» (без учёта регистра)
- [ ] Поступления и баланс проверены (Шаг 3.1)
- [ ] Файл сохранён в UTF-8, кириллица не экранирована

View File

@@ -1,6 +1,6 @@
{ {
"name": "@family-budget/shared", "name": "@family-budget/shared",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@@ -18,6 +18,8 @@ export interface AnalyticsSummaryResponse {
totalExpense: number; totalExpense: number;
totalIncome: number; totalIncome: number;
net: number; net: number;
investmentOutflow: number;
investmentIncomeExcluded: number;
topCategories: TopCategory[]; topCategories: TopCategory[];
} }
@@ -50,4 +52,5 @@ export interface TimeseriesItem {
periodEnd: string; periodEnd: string;
expenseAmount: number; expenseAmount: number;
incomeAmount: number; incomeAmount: number;
investmentOutflow: number;
} }

View File

@@ -5,4 +5,5 @@ export interface LoginRequest {
export interface MeResponse { export interface MeResponse {
login: string; login: string;
backendVersion: string;
} }

View File

@@ -1,3 +1,16 @@
/** Import record from imports table (for import history) */
export interface Import {
id: number;
importedAt: string;
accountId: number | null;
accountAlias: string | null;
bank: string;
accountNumberMasked: string;
importedCount: number;
duplicatesSkipped: number;
totalInFile: number;
}
/** JSON 1.0 statement file — the shape accepted by POST /api/import/statement */ /** JSON 1.0 statement file — the shape accepted by POST /api/import/statement */
export interface StatementFile { export interface StatementFile {
schemaVersion: '1.0'; schemaVersion: '1.0';

View File

@@ -37,6 +37,7 @@ export type {
StatementHeader, StatementHeader,
StatementTransaction, StatementTransaction,
ImportStatementResponse, ImportStatementResponse,
Import,
} from './import'; } from './import';
export type { export type {