Compare commits
19 Commits
fix/sse-gz
...
fix/modal-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e0186fdbb | ||
| 02ca34d088 | |||
|
|
f6d3196254 | ||
|
|
fccde4259d | ||
| e97203c4ab | |||
|
|
6dd8c01f5e | ||
| 495c1e89bb | |||
|
|
032a9f4e3b | ||
| d5f49fd86f | |||
|
|
ab88a0553d | ||
| 0589da5005 | |||
| c50e48d564 | |||
|
|
01b1f26553 | ||
|
|
ba3105bbe5 | ||
| f32a21f87a | |||
|
|
8b57dd987e | ||
| ea234ea007 | |||
|
|
db4d5e4d00 | ||
| 358fcaeff5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,3 +23,4 @@ history.xlsx
|
||||
match_analysis.py
|
||||
match_report.txt
|
||||
statements/
|
||||
.cursor/
|
||||
|
||||
16
README.md
16
README.md
@@ -15,13 +15,15 @@ family_budget/
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Layer | Choice | Rationale |
|
||||
|---------- |------------------------|--------------------------------------------------------|
|
||||
| Backend | Express + TypeScript | Simple, well-known, sufficient for a small local app |
|
||||
| Frontend | React + Vite + TS | Fast dev experience, modern tooling |
|
||||
| Database | PostgreSQL | Deployed on Synology NAS |
|
||||
| Migrations | Knex | Lightweight, SQL-close, supports seeds |
|
||||
| Shared | Pure TypeScript types | Zero-runtime, imported by both backend and frontend |
|
||||
|
||||
| Layer | Choice | Rationale |
|
||||
| ---------- | --------------------- | ---------------------------------------------------- |
|
||||
| Backend | Express + TypeScript | Simple, well-known, sufficient for a small local app |
|
||||
| Frontend | React + Vite + TS | Fast dev experience, modern tooling |
|
||||
| Database | PostgreSQL | Deployed on Synology NAS |
|
||||
| Migrations | Knex | Lightweight, SQL-close, supports seeds |
|
||||
| Shared | Pure TypeScript types | Zero-runtime, imported by both backend and frontend |
|
||||
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/backend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.12",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/app.ts",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import express from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import cors from 'cors';
|
||||
@@ -5,8 +7,13 @@ import { config } from './config';
|
||||
import { runMigrations } from './db/migrate';
|
||||
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 importRouter from './routes/import';
|
||||
import importsRouter from './routes/imports';
|
||||
import transactionsRouter from './routes/transactions';
|
||||
import accountsRouter from './routes/accounts';
|
||||
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)
|
||||
app.use('/api/auth', authRouter);
|
||||
|
||||
app.get('/api/version', (_req, res) => {
|
||||
res.json({ version: pkg.version });
|
||||
});
|
||||
|
||||
// All remaining /api routes require authentication
|
||||
app.use('/api', requireAuth);
|
||||
app.use('/api/import', importRouter);
|
||||
app.use('/api/imports', importsRouter);
|
||||
app.use('/api/transactions', transactionsRouter);
|
||||
app.use('/api/accounts', accountsRouter);
|
||||
app.use('/api/categories', categoriesRouter);
|
||||
|
||||
@@ -133,6 +133,24 @@ const migrations: { name: string; sql: string }[] = [
|
||||
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',
|
||||
sql: `
|
||||
@@ -176,6 +194,24 @@ const migrations: { name: string; sql: string }[] = [
|
||||
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> {
|
||||
|
||||
@@ -3,7 +3,7 @@ import multer from 'multer';
|
||||
import { asyncHandler } from '../utils';
|
||||
import { importStatement, isValidationError } from '../services/import';
|
||||
import {
|
||||
convertPdfToStatementStreaming,
|
||||
convertPdfToStatement,
|
||||
isPdfConversionError,
|
||||
} from '../services/pdfToStatement';
|
||||
|
||||
@@ -28,16 +28,6 @@ function isJsonFile(file: { mimetype: string; originalname: string }): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
const SSE_PAD_TARGET = 4096;
|
||||
|
||||
function sseWrite(res: import('express').Response, data: Record<string, unknown>) {
|
||||
const payload = `data: ${JSON.stringify(data)}\n\n`;
|
||||
const pad = Math.max(0, SSE_PAD_TARGET - payload.length);
|
||||
// SSE comment lines (": ...") are ignored by the browser but push
|
||||
// data past proxy buffer thresholds so each event is delivered immediately.
|
||||
res.write(pad > 0 ? `: ${' '.repeat(pad)}\n${payload}` : payload);
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
@@ -61,68 +51,28 @@ router.post(
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPdfFile(file)) {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.socket?.setNoDelay(true);
|
||||
res.flushHeaders();
|
||||
|
||||
try {
|
||||
const converted = await convertPdfToStatementStreaming(
|
||||
file.buffer,
|
||||
(stage, progress, message) => {
|
||||
sseWrite(res, { stage, progress, message });
|
||||
},
|
||||
);
|
||||
|
||||
if (isPdfConversionError(converted)) {
|
||||
sseWrite(res, {
|
||||
stage: 'error',
|
||||
message: converted.message,
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await importStatement(converted);
|
||||
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 {
|
||||
body = JSON.parse(file.buffer.toString('utf-8'));
|
||||
} catch {
|
||||
res.status(400).json({
|
||||
error: 'BAD_REQUEST',
|
||||
message: 'Некорректный JSON-файл',
|
||||
});
|
||||
return;
|
||||
|
||||
if (isPdfFile(file)) {
|
||||
const converted = await convertPdfToStatement(file.buffer);
|
||||
if (isPdfConversionError(converted)) {
|
||||
res.status(converted.status).json({
|
||||
error: converted.error,
|
||||
message: converted.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
body = converted;
|
||||
} else {
|
||||
try {
|
||||
body = JSON.parse(file.buffer.toString('utf-8'));
|
||||
} catch {
|
||||
res.status(400).json({
|
||||
error: 'BAD_REQUEST',
|
||||
message: 'Некорректный JSON-файл',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await importStatement(body);
|
||||
|
||||
36
backend/src/routes/imports.ts
Normal file
36
backend/src/routes/imports.ts
Normal 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;
|
||||
@@ -47,26 +47,91 @@ export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryRe
|
||||
const where = 'WHERE ' + conditions.join(' AND ');
|
||||
|
||||
const totalsResult = await pool.query(
|
||||
`SELECT
|
||||
COALESCE(SUM(CASE WHEN t.direction = 'expense' THEN ABS(t.amount_signed) ELSE 0 END), 0)::bigint AS total_expense,
|
||||
COALESCE(SUM(CASE WHEN t.direction = 'income' THEN t.amount_signed ELSE 0 END), 0)::bigint AS total_income
|
||||
`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(
|
||||
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
|
||||
CROSS JOIN investment_ref ir
|
||||
${where}`,
|
||||
values,
|
||||
);
|
||||
|
||||
const totalExpense = Number(totalsResult.rows[0].total_expense);
|
||||
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(
|
||||
`SELECT
|
||||
t.category_id,
|
||||
c.name AS category_name,
|
||||
SUM(ABS(t.amount_signed))::bigint AS amount
|
||||
`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(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
|
||||
CROSS JOIN investment_ref ir
|
||||
LEFT JOIN categories c ON c.id = t.category_id
|
||||
${where} AND t.direction = 'expense' AND t.category_id IS NOT NULL
|
||||
GROUP BY t.category_id, c.name
|
||||
${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
|
||||
)
|
||||
GROUP BY COALESCE(t.category_id, 0), COALESCE(c.name, 'Без категории')
|
||||
ORDER BY amount DESC
|
||||
LIMIT 5`,
|
||||
values,
|
||||
@@ -83,6 +148,8 @@ export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryRe
|
||||
totalExpense,
|
||||
totalIncome,
|
||||
net: totalIncome - totalExpense,
|
||||
investmentOutflow,
|
||||
investmentIncomeExcluded,
|
||||
topCategories,
|
||||
};
|
||||
}
|
||||
@@ -92,23 +159,53 @@ export async function getByCategory(params: BaseParams): Promise<ByCategoryItem[
|
||||
const where = 'WHERE ' + conditions.join(' AND ');
|
||||
|
||||
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
|
||||
${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,
|
||||
);
|
||||
const total = Number(totalResult.rows[0].total);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT
|
||||
t.category_id,
|
||||
c.name AS category_name,
|
||||
SUM(ABS(t.amount_signed))::bigint AS amount,
|
||||
`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(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
|
||||
FROM transactions t
|
||||
CROSS JOIN investment_ref ir
|
||||
LEFT JOIN categories c ON c.id = t.category_id
|
||||
${where} AND t.direction = 'expense'
|
||||
GROUP BY t.category_id, c.name
|
||||
${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
|
||||
)
|
||||
GROUP BY COALESCE(t.category_id, 0), COALESCE(c.name, 'Без категории')
|
||||
ORDER BY amount DESC`,
|
||||
values,
|
||||
);
|
||||
@@ -174,13 +271,52 @@ export async function getTimeseries(
|
||||
gs::date AS period_start,
|
||||
${periodEndExpr} AS period_end
|
||||
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
|
||||
p.period_start,
|
||||
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(CASE WHEN t.direction = 'income' THEN t.amount_signed ELSE 0 END), 0)::bigint AS income_amount
|
||||
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 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
|
||||
CROSS JOIN investment_ref ir
|
||||
LEFT JOIN transactions t ON ${txWhere}
|
||||
GROUP BY p.period_start, p.period_end
|
||||
ORDER BY p.period_start`,
|
||||
@@ -192,5 +328,6 @@ export async function getTimeseries(
|
||||
periodEnd: r.period_end.toISOString().slice(0, 10),
|
||||
expenseAmount: Number(r.expense_amount),
|
||||
incomeAmount: Number(r.income_amount),
|
||||
investmentOutflow: Number(r.investment_outflow),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { pool } from '../db/pool';
|
||||
import { config } from '../config';
|
||||
import type { LoginRequest, MeResponse } from '@family-budget/shared';
|
||||
import backendPackage from '../../package.json';
|
||||
|
||||
export async function login(
|
||||
body: LoginRequest,
|
||||
@@ -26,5 +27,8 @@ export async function logout(sessionId: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function me(sessionId: string): Promise<MeResponse> {
|
||||
return { login: config.appUserLogin };
|
||||
return {
|
||||
login: config.appUserLogin,
|
||||
backendVersion: backendPackage.version,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const TRANSFER_PHRASES = [
|
||||
'перевод средств на счет',
|
||||
'внутри втб',
|
||||
];
|
||||
const CASHBACK_KEYWORD = 'зачисление';
|
||||
|
||||
function computeFingerprint(
|
||||
accountNumber: string,
|
||||
@@ -84,15 +85,25 @@ function validateStructure(body: unknown): ValidationError | null {
|
||||
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` };
|
||||
}
|
||||
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) {
|
||||
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].commission must be a non-negative integer` };
|
||||
}
|
||||
if (typeof t.description !== 'string' || !t.description) {
|
||||
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;
|
||||
@@ -155,20 +166,49 @@ export async function importStatement(
|
||||
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
|
||||
const insertedIds: number[] = [];
|
||||
|
||||
for (const tx of data.transactions) {
|
||||
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(
|
||||
`INSERT INTO transactions
|
||||
(account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, FALSE)
|
||||
(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
|
||||
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) {
|
||||
@@ -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
|
||||
if (insertedIds.length > 0) {
|
||||
await client.query(
|
||||
@@ -208,7 +255,7 @@ export async function importStatement(
|
||||
return {
|
||||
accountId,
|
||||
isNewAccount,
|
||||
accountNumberMasked: maskAccountNumber(data.statement.accountNumber),
|
||||
accountNumberMasked,
|
||||
imported: insertedIds.length,
|
||||
duplicatesSkipped: data.transactions.length - insertedIds.length,
|
||||
totalInFile: data.transactions.length,
|
||||
|
||||
44
backend/src/services/imports.ts
Normal file
44
backend/src/services/imports.ts
Normal 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 };
|
||||
}
|
||||
@@ -19,7 +19,7 @@ const PDF2JSON_PROMPT = `Ты — конвертер банковских вып
|
||||
"transactions": [
|
||||
{
|
||||
"operationAt": "<дата и время операции в формате ISO 8601 с offset>",
|
||||
"amountSigned": <число: положительное для прихода, отрицательное для расхода; в копейках>,
|
||||
"amountSigned": <число: положительное для прихода, отрицательное для расхода; 0 допустим только если сумма фактически в commission и в description есть «Зачисление»>,
|
||||
"commission": <число, целое, >= 0, в копейках>,
|
||||
"description": "<полное описание операции из выписки>"
|
||||
}
|
||||
@@ -30,6 +30,7 @@ const PDF2JSON_PROMPT = `Ты — конвертер банковских вып
|
||||
|
||||
1. Суммы — всегда в копейках (рубли × 100). Пример: 500,00 ₽ → 50000, -1234,56 ₽ → -123456.
|
||||
2. amountSigned: приход — положительное, расход — отрицательное.
|
||||
amountSigned = 0 допускается только для кейса кэшбэка/зачисления, когда сумма указана в commission и в description есть «Зачисление».
|
||||
3. operationAt — дата и время, если не указано — 00:00:00, offset +03:00 для МСК.
|
||||
4. commission — если не указана — 0.
|
||||
5. description — полный текст операции как в выписке.
|
||||
@@ -131,120 +132,31 @@ 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) {
|
||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||
const jsonStr = jsonMatch ? jsonMatch[0] : content;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(jsonStr);
|
||||
} catch {
|
||||
return {
|
||||
status: 422,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: 'Результат конвертации пуст',
|
||||
message: 'Результат конвертации не является валидным JSON',
|
||||
};
|
||||
}
|
||||
|
||||
return parseConversionResult(content);
|
||||
const data = parsed as Record<string, unknown>;
|
||||
if (data.schemaVersion !== '1.0') {
|
||||
return {
|
||||
status: 422,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: 'Результат конвертации не соответствует схеме 1.0',
|
||||
};
|
||||
}
|
||||
|
||||
return parsed as StatementFile;
|
||||
} catch (err) {
|
||||
console.error('LLM streaming error:', err);
|
||||
console.error('LLM conversion error:', err);
|
||||
return {
|
||||
status: 502,
|
||||
error: 'BAD_GATEWAY',
|
||||
@@ -266,29 +178,3 @@ function extractLlmErrorMessage(err: unknown): string {
|
||||
}
|
||||
return 'Временная ошибка конвертации';
|
||||
}
|
||||
|
||||
function parseConversionResult(content: string): StatementFile | PdfConversionError {
|
||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||
const jsonStr = jsonMatch ? jsonMatch[0] : content;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(jsonStr);
|
||||
} catch {
|
||||
return {
|
||||
status: 422,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: 'Результат конвертации не является валидным JSON',
|
||||
};
|
||||
}
|
||||
|
||||
const data = parsed as Record<string, unknown>;
|
||||
if (data.schemaVersion !== '1.0') {
|
||||
return {
|
||||
status: 422,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: 'Результат конвертации не соответствует схеме 1.0',
|
||||
};
|
||||
}
|
||||
|
||||
return parsed as StatementFile;
|
||||
}
|
||||
|
||||
@@ -171,5 +171,6 @@ export async function updateTransaction(
|
||||
|
||||
export async function clearAllTransactions(): Promise<{ deleted: number }> {
|
||||
const result = await pool.query('DELETE FROM transactions RETURNING id');
|
||||
await pool.query('DELETE FROM imports');
|
||||
return { deleted: result.rowCount ?? 0 };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>Семейный бюджет</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
@@ -3,7 +3,7 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Import endpoint — SSE streaming, long timeout, no buffering
|
||||
# Import endpoint — long timeout for LLM processing
|
||||
location /api/import {
|
||||
proxy_pass http://family-budget-backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
@@ -14,8 +14,6 @@ server {
|
||||
proxy_cookie_path / /;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_buffering off;
|
||||
gzip off;
|
||||
client_max_body_size 15m;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.8.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
4
frontend/public/favicon.svg
Normal file
4
frontend/public/favicon.svg
Normal 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">₽</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 271 B |
@@ -1,6 +1,5 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from './context/AuthContext';
|
||||
import { ImportProvider } from './context/ImportContext';
|
||||
import { Layout } from './components/Layout';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { HistoryPage } from './pages/HistoryPage';
|
||||
@@ -19,16 +18,14 @@ export function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<ImportProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/history" replace />} />
|
||||
<Route path="/history" element={<HistoryPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/history" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</ImportProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/history" replace />} />
|
||||
<Route path="/history" element={<HistoryPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/history" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { ImportStatementResponse } from '@family-budget/shared';
|
||||
import type {
|
||||
ImportStatementResponse,
|
||||
Import,
|
||||
} from '@family-budget/shared';
|
||||
import { api } from './client';
|
||||
|
||||
export async function importStatement(
|
||||
@@ -12,73 +15,12 @@ export async function importStatement(
|
||||
);
|
||||
}
|
||||
|
||||
export interface SseProgressEvent {
|
||||
stage: 'pdf' | 'llm' | 'import';
|
||||
progress: number;
|
||||
message: string;
|
||||
export async function getImports(): Promise<Import[]> {
|
||||
return api.get<Import[]>('/api/imports');
|
||||
}
|
||||
|
||||
export interface SseDoneEvent {
|
||||
stage: 'done';
|
||||
progress: 100;
|
||||
result: ImportStatementResponse;
|
||||
}
|
||||
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
export async function deleteImport(
|
||||
id: number,
|
||||
): Promise<{ deleted: number }> {
|
||||
return api.delete<{ deleted: number }>(`/api/imports/${id}`);
|
||||
}
|
||||
|
||||
5
frontend/src/api/version.ts
Normal file
5
frontend/src/api/version.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { api } from './client';
|
||||
|
||||
export async function getBackendVersion(): Promise<{ version: string }> {
|
||||
return api.get<{ version: string }>('/api/version');
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { clearAllTransactions } from '../api/transactions';
|
||||
|
||||
const CONFIRM_WORD = 'УДАЛИТЬ';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
@@ -10,15 +8,11 @@ interface Props {
|
||||
|
||||
export function ClearHistoryModal({ onClose, onDone }: Props) {
|
||||
const [check1, setCheck1] = useState(false);
|
||||
const [confirmInput, setConfirmInput] = useState('');
|
||||
const [check2, setCheck2] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const canConfirm =
|
||||
check1 &&
|
||||
confirmInput.trim().toUpperCase() === CONFIRM_WORD &&
|
||||
check2;
|
||||
const canConfirm = check1 && check2;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!canConfirm || loading) return;
|
||||
@@ -37,7 +31,12 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
|
||||
};
|
||||
|
||||
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-header">
|
||||
<h2>Очистить историю операций</h2>
|
||||
@@ -65,20 +64,6 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
|
||||
</label>
|
||||
</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">
|
||||
<label>
|
||||
<input
|
||||
|
||||
@@ -1,13 +1,87 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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() {
|
||||
const [showClearModal, setShowClearModal] = useState(false);
|
||||
const [imports, setImports] = useState<Import[]>([]);
|
||||
const [impToDelete, setImpToDelete] = useState<Import | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
getImports().then(setImports).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleImportDeleted = () => {
|
||||
setImpToDelete(null);
|
||||
getImports().then(setImports).catch(() => {});
|
||||
navigate('/history');
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h3>Очистка данных</h3>
|
||||
<p className="section-desc">
|
||||
@@ -32,6 +106,14 @@ export function DataSection() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{impToDelete && (
|
||||
<DeleteImportModal
|
||||
imp={impToDelete}
|
||||
onClose={() => setImpToDelete(null)}
|
||||
onDone={handleImportDeleted}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
75
frontend/src/components/DeleteImportModal.tsx
Normal file
75
frontend/src/components/DeleteImportModal.tsx
Normal 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}>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,14 @@ function extractPattern(description: string): string {
|
||||
.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({
|
||||
transaction,
|
||||
categories,
|
||||
@@ -74,7 +82,12 @@ export function EditTransactionModal({
|
||||
};
|
||||
|
||||
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-header">
|
||||
<h2>Редактирование операции</h2>
|
||||
@@ -96,6 +109,12 @@ export function EditTransactionModal({
|
||||
<span className="modal-tx-label">Сумма</span>
|
||||
<span>{formatAmount(transaction.amountSigned)}</span>
|
||||
</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">
|
||||
<span className="modal-tx-label">Описание</span>
|
||||
<span className="modal-tx-description">
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState, useRef } from 'react';
|
||||
import type { ImportStatementResponse } from '@family-budget/shared';
|
||||
import { importStatement } from '../api/import';
|
||||
import { updateAccount } from '../api/accounts';
|
||||
import { useImport } from '../context/ImportContext';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
@@ -10,19 +9,13 @@ interface Props {
|
||||
}
|
||||
|
||||
export function ImportModal({ onClose, onDone }: Props) {
|
||||
const { importState, startImport, clearImport } = useImport();
|
||||
|
||||
const [jsonResult, setJsonResult] = useState<ImportStatementResponse | null>(null);
|
||||
const [jsonError, setJsonError] = useState('');
|
||||
const [jsonLoading, setJsonLoading] = useState(false);
|
||||
const [result, setResult] = useState<ImportStatementResponse | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [alias, setAlias] = useState('');
|
||||
const [aliasSaved, setAliasSaved] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const result = importState.result ?? jsonResult;
|
||||
const error = importState.error || jsonError;
|
||||
const loading = importState.active || jsonLoading;
|
||||
|
||||
const handleFileChange = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
@@ -35,45 +28,26 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
const isJson = type === 'application/json' || name.endsWith('.json');
|
||||
|
||||
if (!isPdf && !isJson) {
|
||||
setJsonError('Допустимы только файлы PDF или JSON');
|
||||
setError('Допустимы только файлы PDF или JSON');
|
||||
return;
|
||||
}
|
||||
|
||||
setJsonError('');
|
||||
setJsonResult(null);
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
|
||||
if (isPdf) {
|
||||
startImport(file);
|
||||
return;
|
||||
}
|
||||
|
||||
setJsonLoading(true);
|
||||
try {
|
||||
const resp = await importStatement(file);
|
||||
setJsonResult(resp);
|
||||
setResult(resp);
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
err instanceof Error ? err.message : 'Ошибка импорта';
|
||||
setJsonError(msg);
|
||||
setError(msg);
|
||||
} finally {
|
||||
setJsonLoading(false);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (importState.active) {
|
||||
if (window.confirm('Импорт продолжится в фоне. Закрыть окно?')) {
|
||||
onClose();
|
||||
}
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
onDone();
|
||||
};
|
||||
|
||||
const handleSaveAlias = async () => {
|
||||
if (!result || !alias.trim()) return;
|
||||
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 (
|
||||
<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-header">
|
||||
<h2>Импорт выписки</h2>
|
||||
<button className="btn-close" onClick={handleClose}>
|
||||
<button className="btn-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -106,7 +76,7 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
<div className="modal-body">
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
{!result && !importState.active && (
|
||||
{!result && (
|
||||
<div className="import-upload">
|
||||
<p>Выберите файл выписки (PDF или JSON, формат 1.0)</p>
|
||||
<input
|
||||
@@ -115,30 +85,13 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
accept=".pdf,.json,application/pdf,application/json"
|
||||
onChange={handleFileChange}
|
||||
className="file-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
{jsonLoading && (
|
||||
{loading && (
|
||||
<div className="import-loading">Импорт...</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 && (
|
||||
<div className="import-result">
|
||||
<div className="import-result-icon" aria-hidden="true">✓</div>
|
||||
@@ -201,15 +154,16 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
|
||||
<div className="modal-footer">
|
||||
{result ? (
|
||||
<button className="btn btn-primary" onClick={handleDone}>
|
||||
<button className="btn btn-primary" onClick={onDone}>
|
||||
Готово
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleClose}
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
{importState.active ? 'Свернуть' : 'Отмена'}
|
||||
Отмена
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 { useAuth } from '../context/AuthContext';
|
||||
import { useImport } from '../context/ImportContext';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
import { getBackendVersion } from '../api/version';
|
||||
|
||||
export function Layout({ children }: { children: ReactNode }) {
|
||||
const { user, logout } = useAuth();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [beVersion, setBeVersion] = useState<string | null>(null);
|
||||
|
||||
const closeDrawer = () => setDrawerOpen(false);
|
||||
|
||||
useEffect(() => {
|
||||
getBackendVersion()
|
||||
.then((r) => setBeVersion(r.version))
|
||||
.catch(() => setBeVersion(null));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<ImportProgressBar />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="burger-btn"
|
||||
@@ -152,10 +94,18 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<span className="sidebar-user">{user?.login}</span>
|
||||
<button className="btn-logout" onClick={() => logout()}>
|
||||
Выход
|
||||
</button>
|
||||
<div className="sidebar-footer-top">
|
||||
<span className="sidebar-user">{user?.login}</span>
|
||||
<button className="btn-logout" onClick={() => logout()}>
|
||||
Выход
|
||||
</button>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -31,6 +31,18 @@ export function SummaryCards({ summary }: Props) {
|
||||
</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 && (
|
||||
<div className="summary-card summary-card-top">
|
||||
<div className="summary-label">Топ расходов</div>
|
||||
|
||||
@@ -33,6 +33,7 @@ export function TimeseriesChart({ data }: Props) {
|
||||
period: item.periodStart,
|
||||
Расходы: Math.abs(item.expenseAmount) / 100,
|
||||
Доходы: item.incomeAmount / 100,
|
||||
Инвестиции: Math.abs(item.investmentOutflow) / 100,
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -69,6 +70,11 @@ export function TimeseriesChart({ data }: Props) {
|
||||
fill="#10b981"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="Инвестиции"
|
||||
fill="#f59e0b"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,13 @@ const DIRECTION_CLASSES: Record<string, string> = {
|
||||
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({
|
||||
tx,
|
||||
onEdit,
|
||||
@@ -36,6 +43,11 @@ function TransactionCard({
|
||||
{formatAmount(tx.amountSigned)}
|
||||
</span>
|
||||
</div>
|
||||
{tx.commission !== 0 && (
|
||||
<div className="transaction-card-commission">
|
||||
Комиссия: {formatAmount(getCommissionAmountSigned(tx))}
|
||||
</div>
|
||||
)}
|
||||
<div className="transaction-card-body">
|
||||
<span className="description-text">{tx.description}</span>
|
||||
{tx.comment && (
|
||||
@@ -117,7 +129,12 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
|
||||
<td
|
||||
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 className="td-description">
|
||||
<span className="description-text">{tx.description}</span>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { getMe, login as apiLogin, logout as apiLogout } from '../api/auth';
|
||||
import { setOnUnauthorized } from '../api/client';
|
||||
|
||||
interface AuthState {
|
||||
user: { login: string } | null;
|
||||
user: { login: string; backendVersion: string } | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
@@ -20,7 +20,7 @@ interface AuthState {
|
||||
const AuthContext = createContext<AuthState | null>(null);
|
||||
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -31,7 +31,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
setOnUnauthorized(clearUser);
|
||||
getMe()
|
||||
.then((me) => setUser({ login: me.login }))
|
||||
.then((me) => setUser({ login: me.login, backendVersion: me.backendVersion }))
|
||||
.catch(() => setUser(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [clearUser]);
|
||||
@@ -41,7 +41,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
await apiLogin({ login: username, password });
|
||||
const me = await getMe();
|
||||
setUser({ login: me.login });
|
||||
setUser({ login: me.login, backendVersion: me.backendVersion });
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error && e.message === 'Failed to fetch'
|
||||
? 'Сервер недоступен. Проверьте, что backend запущен и NPM проксирует /api на порт 3000.'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import { TransactionTable } from '../components/TransactionTable';
|
||||
import { Pagination } from '../components/Pagination';
|
||||
import { EditTransactionModal } from '../components/EditTransactionModal';
|
||||
import { ImportModal } from '../components/ImportModal';
|
||||
import { useImport } from '../context/ImportContext';
|
||||
import { toISODate } from '../utils/format';
|
||||
|
||||
const PARAM_KEYS = [
|
||||
@@ -126,7 +125,7 @@ export function HistoryPage() {
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [editingTx, setEditingTx] = useState<Transaction | null>(null);
|
||||
const { showModal: showImport, openModal: openImport, closeModal: closeImport, clearImport } = useImport();
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getAccounts().then(setAccounts).catch(() => {});
|
||||
@@ -198,8 +197,7 @@ export function HistoryPage() {
|
||||
};
|
||||
|
||||
const handleImportDone = () => {
|
||||
closeImport();
|
||||
clearImport();
|
||||
setShowImport(false);
|
||||
fetchData();
|
||||
getAccounts().then(setAccounts).catch(() => {});
|
||||
};
|
||||
@@ -210,7 +208,7 @@ export function HistoryPage() {
|
||||
<h1>История операций</h1>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={openImport}
|
||||
onClick={() => setShowImport(true)}
|
||||
>
|
||||
Импорт выписки
|
||||
</button>
|
||||
@@ -264,7 +262,7 @@ export function HistoryPage() {
|
||||
|
||||
{showImport && (
|
||||
<ImportModal
|
||||
onClose={closeImport}
|
||||
onClose={() => setShowImport(false)}
|
||||
onDone={handleImportDone}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -138,9 +138,26 @@ body {
|
||||
.sidebar-footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sidebar-footer-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
@@ -148,6 +165,12 @@ body {
|
||||
color: var(--color-sidebar-text);
|
||||
}
|
||||
|
||||
.sidebar-footer-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -249,6 +272,7 @@ body {
|
||||
|
||||
.page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
@@ -644,6 +668,12 @@ textarea {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.transaction-card-commission {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.transaction-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -714,6 +744,12 @@ textarea {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.td-commission {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.amount-income {
|
||||
color: var(--color-success);
|
||||
}
|
||||
@@ -1058,152 +1094,6 @@ textarea {
|
||||
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
|
||||
================================================================ */
|
||||
@@ -1433,6 +1323,10 @@ textarea {
|
||||
border-left-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.summary-card-investments {
|
||||
border-left-color: var(--color-warning);
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -1448,6 +1342,12 @@ textarea {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.summary-subvalue {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.summary-top-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
2
frontend/src/vite-env.d.ts
vendored
2
frontend/src/vite-env.d.ts
vendored
@@ -1 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __FE_VERSION__: string;
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { defineConfig } from 'vite';
|
||||
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({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
__FE_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -111,6 +111,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1394,6 +1395,7 @@
|
||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
@@ -1472,6 +1474,7 @@
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1611,6 +1614,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2734,6 +2738,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
|
||||
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.11.0",
|
||||
"pg-pool": "^3.12.0",
|
||||
@@ -2831,6 +2836,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -2980,6 +2986,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -2989,6 +2996,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -4100,6 +4108,7 @@
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "family-budget",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"shared",
|
||||
|
||||
232
pdf2json.md
232
pdf2json.md
@@ -1,75 +1,231 @@
|
||||
Ты — конвертер банковских выписок. Твоя задача: извлечь данные из прикреплённого PDF банковской выписки и вернуть строго один валидный JSON-объект в формате ниже. Никакого текста до или после JSON, только сам объект.
|
||||
# Инструкция агента: конвертация банковской выписки ВТБ (PDF → JSON)
|
||||
|
||||
## Структура выходного JSON
|
||||
## Цель
|
||||
|
||||
Преобразовать PDF-выписку банка ВТБ в JSON-файл строго по схеме 1.0.
|
||||
На выходе — валидный JSON-файл без потерь операций, с правильными суммами в копейках и корректной проверкой баланса.
|
||||
|
||||
---
|
||||
|
||||
## Схема JSON (schema 1.0)
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"bank": "<название банка из выписки>",
|
||||
"bank": "VTB",
|
||||
"statement": {
|
||||
"accountNumber": "<номер счёта, только цифры, без пробелов>",
|
||||
"accountNumber": "string",
|
||||
"currency": "RUB",
|
||||
"openingBalance": <число в копейках, целое>,
|
||||
"closingBalance": <число в копейках, целое>,
|
||||
"exportedAt": "<дата экспорта в формате ISO 8601 с offset, например 2026-02-27T13:23:00+03:00>"
|
||||
"openingBalance": integer,
|
||||
"closingBalance": integer,
|
||||
"exportedAt": "ISO 8601 string"
|
||||
},
|
||||
"transactions": [
|
||||
{
|
||||
"operationAt": "<дата и время операции в формате ISO 8601 с offset>",
|
||||
"amountSigned": <число: положительное для прихода, отрицательное для расхода; в копейках>,
|
||||
"commission": <число, целое, >= 0, в копейках>,
|
||||
"description": "<полное описание операции из выписки>"
|
||||
"operationAt": "ISO 8601 string",
|
||||
"amountSigned": integer,
|
||||
"commission": integer,
|
||||
"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` не должен быть пустым.
|
||||
- Все числа — целые.
|
||||
- Даты — строго в формате ISO 8601 с offset.
|
||||
- currency всегда "RUB".
|
||||
- schemaVersion всегда "1.0".
|
||||
### 2.2 Правила маппинга
|
||||
|
||||
## Пример одной транзакции
|
||||
**`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
|
||||
{
|
||||
"operationAt": "2026-02-26T14:06:00+03:00",
|
||||
"amountSigned": -50000,
|
||||
"operationAt": "2026-03-29T20:38:01+03:00",
|
||||
"amountSigned": -500000,
|
||||
"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, кириллица не экранирована
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/shared",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface AnalyticsSummaryResponse {
|
||||
totalExpense: number;
|
||||
totalIncome: number;
|
||||
net: number;
|
||||
investmentOutflow: number;
|
||||
investmentIncomeExcluded: number;
|
||||
topCategories: TopCategory[];
|
||||
}
|
||||
|
||||
@@ -50,4 +52,5 @@ export interface TimeseriesItem {
|
||||
periodEnd: string;
|
||||
expenseAmount: number;
|
||||
incomeAmount: number;
|
||||
investmentOutflow: number;
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ export interface LoginRequest {
|
||||
|
||||
export interface MeResponse {
|
||||
login: string;
|
||||
backendVersion: string;
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
export interface StatementFile {
|
||||
schemaVersion: '1.0';
|
||||
|
||||
@@ -37,6 +37,7 @@ export type {
|
||||
StatementHeader,
|
||||
StatementTransaction,
|
||||
ImportStatementResponse,
|
||||
Import,
|
||||
} from './import';
|
||||
|
||||
export type {
|
||||
|
||||
Reference in New Issue
Block a user