33 Commits

Author SHA1 Message Date
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
vakabunga
aaf8cacf75 fix: eliminate SSE buffering through Nginx proxy
Add 2 KB padding comment after headers to push past proxy buffer
threshold, enable TCP_NODELAY on the socket, and remove erroneous
chunked_transfer_encoding off from Nginx that caused full response
buffering.
2026-03-14 17:30:52 +03:00
vakabunga
e28d0f46d0 fix: reopen result modal from progress bar, faster progress, handle LLM context error
- Move import modal visibility into ImportContext so the Layout
  progress pill can reopen the result dialog after the modal was closed.
- Raise LLM progress cap from 85% to 98% and drop the intermediate
  -import 88%- SSE event to eliminate the visual stall after LLM finishes.
- Detect LLM context-length errors (n_keep >= n_ctx) and surface a
  clear message instead of generic -Временная ошибка конвертации-.
2026-03-14 17:05:55 +03:00
22be09c101 Merge pull request 'fix: adaptive LLM progress estimation and emit 85% on stream end' (#6) from fix/adaptive-llm-progress into main
Reviewed-on: #6
2026-03-14 13:43:27 +00:00
vakabunga
78c4730196 fix: adaptive LLM progress estimation and emit 85% on stream end
Hardcoded EXPECTED_CHARS (15k) caused progress to stall at ~20-25% for
short statements. Now expected size is derived from input text length.
Also emit an explicit 85% event when the LLM stream finishes, and
throttle SSE events to 300ms to reduce browser overhead.
2026-03-14 16:41:12 +03:00
f2d0c91488 Merge pull request 'feat: stream PDF import progress via SSE with global progress bar' (#5) from feature/pdf-import-sse-streaming into main
Reviewed-on: #5
2026-03-14 13:18:57 +00:00
vakabunga
1d7fbea9ef feat: stream PDF import progress via SSE with global progress bar
Replace the synchronous PDF import with Server-Sent Events streaming
between the backend (LLM) and the browser. The user can now close the
import modal and continue working while the conversion runs — a fixed
progress bar in the Layout shows real-time stage and percentage.
2026-03-14 16:18:31 +03:00
vakabunga
627706228b fix: remove response_format incompatible with LM Studio API 2026-03-14 15:23:33 +03:00
vakabunga
a5f2294440 fix: adds copy of node modules from backend folder 2026-03-14 15:14:04 +03:00
25ddd6b7ed Merge pull request 'fix: lazy-load pdf-parse to prevent startup crash if module is missing' (#4) from fix-lazyload-pdfparse-crash into main
Reviewed-on: #4
2026-03-14 11:18:55 +00:00
vakabunga
3e481d5a55 fix: lazy-load pdf-parse to prevent startup crash if module is missing 2026-03-14 14:18:25 +03:00
cf24d5dc26 Merge pull request 'fix: downgrade pdf-parse to 1.1.1 to eliminate native dependency' (#3) from fix-pdfparse-crash into main
Reviewed-on: #3
2026-03-14 11:03:48 +00:00
vakabunga
723df494ca fix: downgrade pdf-parse to 1.1.1 to eliminate native dependency
pdf-parse@2.4.5 depends on @napi-rs/canvas (native Skia binary compiled
with AVX instructions) which crashes with SIGILL on the Synology NAS CPU.
Version 1.1.1 is pure JavaScript and works on any architecture.
2026-03-14 14:03:14 +03:00
5fa6b921d8 Merge pull request 'fix-pdf-parser-crash' (#2) from fix-pdf-parser-crash into main
Reviewed-on: #2
2026-03-14 10:43:21 +00:00
vakabunga
feb756cfe2 fix(docker): prevent backend crash loop caused by pdf-parse native deps
pdf-parse@2.4.5 pulls in @napi-rs/canvas (native Skia binary) which
crashes on import in Alpine containers. Moved to lazy require() so the
app starts normally and pdf-parse loads only when PDF conversion is
actually requested.
- Lazy-load pdf-parse in pdfToStatement to avoid startup crash
- Add libc6-compat, fontconfig, freetype to Alpine runner stage
- Increase npm fetch timeouts in both Dockerfiles for slow networks
- Add connectionTimeoutMillis to pg Pool for faster failure detection
2026-03-14 13:37:34 +03:00
41 changed files with 969 additions and 329 deletions

1
.gitignore vendored
View File

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

View File

@@ -5,6 +5,11 @@ COPY package.json package-lock.json* ./
COPY shared ./shared
COPY backend ./backend
# Увеличенные таймауты для нестабильной сети
RUN npm config set fetch-retry-mintimeout 20000 && \
npm config set fetch-retry-maxtimeout 120000 && \
npm config set fetch-timeout 300000
RUN npm install
FROM node:20-alpine AS build
@@ -25,6 +30,7 @@ ENV NODE_ENV=production
COPY --from=build /app/backend/dist ./dist
COPY --from=build /app/backend/package.json ./package.json
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/backend/node_modules/ ./node_modules/
COPY backend/.env .env
EXPOSE 3000

View File

@@ -9,6 +9,11 @@ COPY package.json package-lock.json* tsconfig.json* ./
COPY shared ./shared
COPY frontend ./frontend
# Увеличиваем таймауты и повторы для нестабильной сети
RUN npm config set fetch-retry-mintimeout 20000 && \
npm config set fetch-retry-maxtimeout 120000 && \
npm config set fetch-timeout 300000
# Устанавливаем зависимости из корня
RUN npm install

View File

@@ -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",
@@ -18,7 +18,7 @@
"express": "^5.2.1",
"multer": "^2.1.1",
"openai": "^6.27.0",
"pdf-parse": "^2.4.5",
"pdf-parse": "1.1.1",
"pg": "^8.19.0",
"uuid": "^13.0.0"
},

View File

@@ -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);

View File

@@ -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> {

View File

@@ -7,4 +7,5 @@ export const pool = new Pool({
database: config.db.database,
user: config.db.user,
password: config.db.password,
connectionTimeoutMillis: 5000,
});

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 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),
}));
}

View File

@@ -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,
};
}

View File

@@ -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,

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

@@ -1,4 +1,3 @@
import { PDFParse } from 'pdf-parse';
import OpenAI from 'openai';
import { config } from '../config';
import type { StatementFile } from '@family-budget/shared';
@@ -20,7 +19,7 @@ const PDF2JSON_PROMPT = `Ты — конвертер банковских вып
"transactions": [
{
"operationAt": "<дата и время операции в формате ISO 8601 с offset>",
"amountSigned": <число: положительное для прихода, отрицательное для расхода; в копейках>,
"amountSigned": <число: положительное для прихода, отрицательное для расхода; 0 допустим только если сумма фактически в commission и в description есть «Зачисление»>,
"commission": <число, целое, >= 0, в копейках>,
"description": "<полное описание операции из выписки>"
}
@@ -31,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 — полный текст операции как в выписке.
@@ -64,6 +64,16 @@ export function isPdfConversionError(r: unknown): r is PdfConversionError {
);
}
// Lazy-loaded so the app starts even if pdf-parse is missing from node_modules.
let _pdfParse: ((buf: Buffer) => Promise<{ text: string }>) | undefined;
function getPdfParse() {
if (!_pdfParse) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
_pdfParse = require('pdf-parse') as (buf: Buffer) => Promise<{ text: string }>;
}
return _pdfParse;
}
export async function convertPdfToStatement(
buffer: Buffer,
): Promise<StatementFile | PdfConversionError> {
@@ -77,10 +87,8 @@ export async function convertPdfToStatement(
let text: string;
try {
const parser = new PDFParse({ data: buffer });
const result = await parser.getText();
const result = await getPdfParse()(buffer);
text = result.text || '';
await parser.destroy();
} catch (err) {
console.error('PDF extraction error:', err);
return {
@@ -101,6 +109,7 @@ export async function convertPdfToStatement(
const openai = new OpenAI({
apiKey: config.llmApiKey,
...(config.llmApiBaseUrl && { baseURL: config.llmApiBaseUrl }),
timeout: 5 * 60 * 1000,
});
try {
@@ -112,7 +121,6 @@ export async function convertPdfToStatement(
],
temperature: 0,
max_tokens: 32768,
response_format: { type: 'json_object' },
});
const content = completion.choices[0]?.message?.content?.trim();
@@ -152,7 +160,21 @@ export async function convertPdfToStatement(
return {
status: 502,
error: 'BAD_GATEWAY',
message: 'Временная ошибка конвертации',
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 }> {
const result = await pool.query('DELETE FROM transactions RETURNING id');
await pool.query('DELETE FROM imports');
return { deleted: result.rowCount ?? 0 };
}

View File

@@ -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 />

View File

@@ -3,6 +3,20 @@ server {
root /usr/share/nginx/html;
index index.html;
# Import endpoint — long timeout for LLM processing
location /api/import {
proxy_pass http://family-budget-backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cookie_path / /;
proxy_connect_timeout 5s;
proxy_read_timeout 600s;
client_max_body_size 15m;
}
# API — проксируем на backend (сервис из docker-compose)
location /api {
proxy_pass http://family-budget-backend:3000;

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/frontend",
"version": "0.1.0",
"version": "0.8.5",
"private": true,
"type": "module",
"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,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(
@@ -11,3 +14,13 @@ export async function importStatement(
formData,
);
}
export async function getImports(): Promise<Import[]> {
return api.get<Import[]>('/api/imports');
}
export async function deleteImport(
id: number,
): Promise<{ deleted: number }> {
return api.delete<{ deleted: number }>(`/api/imports/${id}`);
}

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 { 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;
@@ -65,20 +59,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

View File

@@ -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>
);
}

View File

@@ -0,0 +1,70 @@
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" onClick={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);
}
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,
@@ -96,6 +104,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">

View File

@@ -89,7 +89,7 @@ export function ImportModal({ onClose, onDone }: Props) {
{result && (
<div className="import-result">
<div className="import-result-icon">&check;</div>
<div className="import-result-icon" aria-hidden="true"></div>
<h3>Импорт завершён</h3>
<table className="import-stats">
<tbody>

View File

@@ -1,13 +1,21 @@
import { useState, type ReactNode } from 'react';
import { useState, useEffect, type ReactNode } from 'react';
import { NavLink } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
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">
<button
@@ -86,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>

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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.'

View File

@@ -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);
}
@@ -1287,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;
@@ -1302,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;

View File

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

View File

@@ -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: {

255
package-lock.json generated
View File

@@ -24,7 +24,7 @@
"express": "^5.2.1",
"multer": "^2.1.1",
"openai": "^6.27.0",
"pdf-parse": "^2.4.5",
"pdf-parse": "1.1.1",
"pg": "^8.19.0",
"uuid": "^13.0.0"
},
@@ -40,6 +40,28 @@
"typescript": "^5.9.3"
}
},
"backend/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.1"
}
},
"backend/node_modules/pdf-parse": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.1.tgz",
"integrity": "sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==",
"license": "MIT",
"dependencies": {
"debug": "^3.1.0",
"node-ensure": "^0.0.0"
},
"engines": {
"node": ">=6.8.1"
}
},
"frontend": {
"name": "@family-budget/frontend",
"version": "0.1.0",
@@ -89,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",
@@ -853,190 +876,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
"license": "MIT",
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.80",
"@napi-rs/canvas-darwin-arm64": "0.1.80",
"@napi-rs/canvas-darwin-x64": "0.1.80",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1556,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",
@@ -1634,6 +1474,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1773,6 +1614,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2796,6 +2638,12 @@
"node": ">= 0.6"
}
},
"node_modules/node-ensure": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
"integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==",
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -2885,43 +2733,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/pdf-parse": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
"license": "Apache-2.0",
"dependencies": {
"@napi-rs/canvas": "0.1.80",
"pdfjs-dist": "5.4.296"
},
"bin": {
"pdf-parse": "bin/cli.mjs"
},
"engines": {
"node": ">=20.16.0 <21 || >=22.3.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.80"
}
},
"node_modules/pg": {
"version": "8.19.0",
"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",
@@ -3019,6 +2836,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3168,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"
}
@@ -3177,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"
},
@@ -4288,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",

View File

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

View File

@@ -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, кириллица не экранирована

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -5,4 +5,5 @@ export interface LoginRequest {
export interface MeResponse {
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 */
export interface StatementFile {
schemaVersion: '1.0';

View File

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