Compare commits
11 Commits
feat/impor
...
feat/commi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6d3196254 | ||
|
|
fccde4259d | ||
| e97203c4ab | |||
|
|
6dd8c01f5e | ||
| 495c1e89bb | |||
|
|
032a9f4e3b | ||
| d5f49fd86f | |||
|
|
ab88a0553d | ||
| 0589da5005 | |||
| c50e48d564 | |||
|
|
ba3105bbe5 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/backend",
|
"name": "@family-budget/backend",
|
||||||
"version": "0.1.0",
|
"version": "0.5.12",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/app.ts",
|
"dev": "tsx watch src/app.ts",
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
@@ -5,6 +7,10 @@ import { config } from './config';
|
|||||||
import { runMigrations } from './db/migrate';
|
import { runMigrations } from './db/migrate';
|
||||||
import { requireAuth } from './middleware/auth';
|
import { requireAuth } from './middleware/auth';
|
||||||
|
|
||||||
|
const pkg = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'),
|
||||||
|
) as { version: string };
|
||||||
|
|
||||||
import authRouter from './routes/auth';
|
import authRouter from './routes/auth';
|
||||||
import importRouter from './routes/import';
|
import importRouter from './routes/import';
|
||||||
import importsRouter from './routes/imports';
|
import importsRouter from './routes/imports';
|
||||||
@@ -24,6 +30,10 @@ app.use(cors({ origin: true, credentials: true }));
|
|||||||
// Auth routes (login is public; me/logout apply auth internally)
|
// Auth routes (login is public; me/logout apply auth internally)
|
||||||
app.use('/api/auth', authRouter);
|
app.use('/api/auth', authRouter);
|
||||||
|
|
||||||
|
app.get('/api/version', (_req, res) => {
|
||||||
|
res.json({ version: pkg.version });
|
||||||
|
});
|
||||||
|
|
||||||
// All remaining /api routes require authentication
|
// All remaining /api routes require authentication
|
||||||
app.use('/api', requireAuth);
|
app.use('/api', requireAuth);
|
||||||
app.use('/api/import', importRouter);
|
app.use('/api/import', importRouter);
|
||||||
|
|||||||
@@ -47,26 +47,91 @@ export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryRe
|
|||||||
const where = 'WHERE ' + conditions.join(' AND ');
|
const where = 'WHERE ' + conditions.join(' AND ');
|
||||||
|
|
||||||
const totalsResult = await pool.query(
|
const totalsResult = await pool.query(
|
||||||
`SELECT
|
`WITH investment_ref AS (
|
||||||
COALESCE(SUM(CASE WHEN t.direction = 'expense' THEN ABS(t.amount_signed) ELSE 0 END), 0)::bigint AS total_expense,
|
SELECT (
|
||||||
COALESCE(SUM(CASE WHEN t.direction = 'income' THEN t.amount_signed ELSE 0 END), 0)::bigint AS total_income
|
SELECT id
|
||||||
|
FROM categories
|
||||||
|
WHERE name = 'Инвестиции' AND type = 'transfer'
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1
|
||||||
|
) AS investment_category_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
WHEN (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
|
||||||
|
AND (
|
||||||
|
ir.investment_category_id IS NULL
|
||||||
|
OR t.category_id IS DISTINCT FROM ir.investment_category_id
|
||||||
|
)
|
||||||
|
THEN ABS(t.amount_signed) + t.commission
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
), 0)::bigint AS total_expense,
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
WHEN t.direction = 'income'
|
||||||
|
AND (
|
||||||
|
ir.investment_category_id IS NULL
|
||||||
|
OR t.category_id IS DISTINCT FROM ir.investment_category_id
|
||||||
|
)
|
||||||
|
THEN t.amount_signed + t.commission
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
), 0)::bigint AS total_income,
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
WHEN t.amount_signed < 0
|
||||||
|
AND ir.investment_category_id IS NOT NULL
|
||||||
|
AND t.category_id = ir.investment_category_id
|
||||||
|
THEN ABS(t.amount_signed) + t.commission
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
), 0)::bigint AS investment_outflow,
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
WHEN t.amount_signed > 0
|
||||||
|
AND ir.investment_category_id IS NOT NULL
|
||||||
|
AND t.category_id = ir.investment_category_id
|
||||||
|
THEN t.amount_signed + t.commission
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
), 0)::bigint AS investment_income_excluded
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
|
CROSS JOIN investment_ref ir
|
||||||
${where}`,
|
${where}`,
|
||||||
values,
|
values,
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalExpense = Number(totalsResult.rows[0].total_expense);
|
const totalExpense = Number(totalsResult.rows[0].total_expense);
|
||||||
const totalIncome = Number(totalsResult.rows[0].total_income);
|
const totalIncome = Number(totalsResult.rows[0].total_income);
|
||||||
|
const investmentOutflow = Number(totalsResult.rows[0].investment_outflow);
|
||||||
|
const investmentIncomeExcluded = Number(totalsResult.rows[0].investment_income_excluded);
|
||||||
|
|
||||||
const topResult = await pool.query(
|
const topResult = await pool.query(
|
||||||
`SELECT
|
`WITH investment_ref AS (
|
||||||
t.category_id,
|
SELECT (
|
||||||
c.name AS category_name,
|
SELECT id
|
||||||
SUM(ABS(t.amount_signed))::bigint AS amount
|
FROM categories
|
||||||
|
WHERE name = 'Инвестиции' AND type = 'transfer'
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1
|
||||||
|
) AS investment_category_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COALESCE(t.category_id, 0)::bigint AS category_id,
|
||||||
|
COALESCE(c.name, 'Без категории') AS category_name,
|
||||||
|
SUM(ABS(t.amount_signed) + t.commission)::bigint AS amount
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
|
CROSS JOIN investment_ref ir
|
||||||
LEFT JOIN categories c ON c.id = t.category_id
|
LEFT JOIN categories c ON c.id = t.category_id
|
||||||
${where} AND t.direction = 'expense' AND t.category_id IS NOT NULL
|
${where}
|
||||||
GROUP BY t.category_id, c.name
|
AND (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
|
||||||
|
AND (
|
||||||
|
ir.investment_category_id IS NULL
|
||||||
|
OR t.category_id IS DISTINCT FROM ir.investment_category_id
|
||||||
|
)
|
||||||
|
GROUP BY COALESCE(t.category_id, 0), COALESCE(c.name, 'Без категории')
|
||||||
ORDER BY amount DESC
|
ORDER BY amount DESC
|
||||||
LIMIT 5`,
|
LIMIT 5`,
|
||||||
values,
|
values,
|
||||||
@@ -83,6 +148,8 @@ export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryRe
|
|||||||
totalExpense,
|
totalExpense,
|
||||||
totalIncome,
|
totalIncome,
|
||||||
net: totalIncome - totalExpense,
|
net: totalIncome - totalExpense,
|
||||||
|
investmentOutflow,
|
||||||
|
investmentIncomeExcluded,
|
||||||
topCategories,
|
topCategories,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -92,23 +159,53 @@ export async function getByCategory(params: BaseParams): Promise<ByCategoryItem[
|
|||||||
const where = 'WHERE ' + conditions.join(' AND ');
|
const where = 'WHERE ' + conditions.join(' AND ');
|
||||||
|
|
||||||
const totalResult = await pool.query(
|
const totalResult = await pool.query(
|
||||||
`SELECT COALESCE(SUM(ABS(t.amount_signed)), 0)::bigint AS total
|
`WITH investment_ref AS (
|
||||||
|
SELECT (
|
||||||
|
SELECT id
|
||||||
|
FROM categories
|
||||||
|
WHERE name = 'Инвестиции' AND type = 'transfer'
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1
|
||||||
|
) AS investment_category_id
|
||||||
|
)
|
||||||
|
SELECT COALESCE(SUM(ABS(t.amount_signed) + t.commission), 0)::bigint AS total
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
${where} AND t.direction = 'expense'`,
|
CROSS JOIN investment_ref ir
|
||||||
|
${where}
|
||||||
|
AND (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
|
||||||
|
AND (
|
||||||
|
ir.investment_category_id IS NULL
|
||||||
|
OR t.category_id IS DISTINCT FROM ir.investment_category_id
|
||||||
|
)`,
|
||||||
values,
|
values,
|
||||||
);
|
);
|
||||||
const total = Number(totalResult.rows[0].total);
|
const total = Number(totalResult.rows[0].total);
|
||||||
|
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT
|
`WITH investment_ref AS (
|
||||||
t.category_id,
|
SELECT (
|
||||||
c.name AS category_name,
|
SELECT id
|
||||||
SUM(ABS(t.amount_signed))::bigint AS amount,
|
FROM categories
|
||||||
|
WHERE name = 'Инвестиции' AND type = 'transfer'
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1
|
||||||
|
) AS investment_category_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COALESCE(t.category_id, 0)::bigint AS category_id,
|
||||||
|
COALESCE(c.name, 'Без категории') AS category_name,
|
||||||
|
SUM(ABS(t.amount_signed) + t.commission)::bigint AS amount,
|
||||||
COUNT(*)::int AS tx_count
|
COUNT(*)::int AS tx_count
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
|
CROSS JOIN investment_ref ir
|
||||||
LEFT JOIN categories c ON c.id = t.category_id
|
LEFT JOIN categories c ON c.id = t.category_id
|
||||||
${where} AND t.direction = 'expense'
|
${where}
|
||||||
GROUP BY t.category_id, c.name
|
AND (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
|
||||||
|
AND (
|
||||||
|
ir.investment_category_id IS NULL
|
||||||
|
OR t.category_id IS DISTINCT FROM ir.investment_category_id
|
||||||
|
)
|
||||||
|
GROUP BY COALESCE(t.category_id, 0), COALESCE(c.name, 'Без категории')
|
||||||
ORDER BY amount DESC`,
|
ORDER BY amount DESC`,
|
||||||
values,
|
values,
|
||||||
);
|
);
|
||||||
@@ -174,13 +271,52 @@ export async function getTimeseries(
|
|||||||
gs::date AS period_start,
|
gs::date AS period_start,
|
||||||
${periodEndExpr} AS period_end
|
${periodEndExpr} AS period_end
|
||||||
FROM generate_series(${truncExpr}, $2::date, '${intervalStr}'::interval) gs
|
FROM generate_series(${truncExpr}, $2::date, '${intervalStr}'::interval) gs
|
||||||
|
),
|
||||||
|
investment_ref AS (
|
||||||
|
SELECT (
|
||||||
|
SELECT id
|
||||||
|
FROM categories
|
||||||
|
WHERE name = 'Инвестиции' AND type = 'transfer'
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1
|
||||||
|
) AS investment_category_id
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
p.period_start,
|
p.period_start,
|
||||||
p.period_end,
|
p.period_end,
|
||||||
COALESCE(SUM(CASE WHEN t.direction = 'expense' THEN ABS(t.amount_signed) ELSE 0 END), 0)::bigint AS expense_amount,
|
COALESCE(SUM(
|
||||||
COALESCE(SUM(CASE WHEN t.direction = 'income' THEN t.amount_signed ELSE 0 END), 0)::bigint AS income_amount
|
CASE
|
||||||
|
WHEN (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
|
||||||
|
AND (
|
||||||
|
ir.investment_category_id IS NULL
|
||||||
|
OR t.category_id IS DISTINCT FROM ir.investment_category_id
|
||||||
|
)
|
||||||
|
THEN ABS(t.amount_signed) + t.commission
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
), 0)::bigint AS expense_amount,
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
WHEN t.direction = 'income'
|
||||||
|
AND (
|
||||||
|
ir.investment_category_id IS NULL
|
||||||
|
OR t.category_id IS DISTINCT FROM ir.investment_category_id
|
||||||
|
)
|
||||||
|
THEN t.amount_signed + t.commission
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
), 0)::bigint AS income_amount,
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
WHEN t.amount_signed < 0
|
||||||
|
AND ir.investment_category_id IS NOT NULL
|
||||||
|
AND t.category_id = ir.investment_category_id
|
||||||
|
THEN ABS(t.amount_signed) + t.commission
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
), 0)::bigint AS investment_outflow
|
||||||
FROM periods p
|
FROM periods p
|
||||||
|
CROSS JOIN investment_ref ir
|
||||||
LEFT JOIN transactions t ON ${txWhere}
|
LEFT JOIN transactions t ON ${txWhere}
|
||||||
GROUP BY p.period_start, p.period_end
|
GROUP BY p.period_start, p.period_end
|
||||||
ORDER BY p.period_start`,
|
ORDER BY p.period_start`,
|
||||||
@@ -192,5 +328,6 @@ export async function getTimeseries(
|
|||||||
periodEnd: r.period_end.toISOString().slice(0, 10),
|
periodEnd: r.period_end.toISOString().slice(0, 10),
|
||||||
expenseAmount: Number(r.expense_amount),
|
expenseAmount: Number(r.expense_amount),
|
||||||
incomeAmount: Number(r.income_amount),
|
incomeAmount: Number(r.income_amount),
|
||||||
|
investmentOutflow: Number(r.investment_outflow),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import { pool } from '../db/pool';
|
import { pool } from '../db/pool';
|
||||||
import { config } from '../config';
|
import { config } from '../config';
|
||||||
import type { LoginRequest, MeResponse } from '@family-budget/shared';
|
import type { LoginRequest, MeResponse } from '@family-budget/shared';
|
||||||
|
import backendPackage from '../../package.json';
|
||||||
|
|
||||||
export async function login(
|
export async function login(
|
||||||
body: LoginRequest,
|
body: LoginRequest,
|
||||||
@@ -26,5 +27,8 @@ export async function logout(sessionId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function me(sessionId: string): Promise<MeResponse> {
|
export async function me(sessionId: string): Promise<MeResponse> {
|
||||||
return { login: config.appUserLogin };
|
return {
|
||||||
|
login: config.appUserLogin,
|
||||||
|
backendVersion: backendPackage.version,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const TRANSFER_PHRASES = [
|
|||||||
'перевод средств на счет',
|
'перевод средств на счет',
|
||||||
'внутри втб',
|
'внутри втб',
|
||||||
];
|
];
|
||||||
|
const CASHBACK_KEYWORD = 'зачисление';
|
||||||
|
|
||||||
function computeFingerprint(
|
function computeFingerprint(
|
||||||
accountNumber: string,
|
accountNumber: string,
|
||||||
@@ -84,15 +85,25 @@ function validateStructure(body: unknown): ValidationError | null {
|
|||||||
if (typeof t.operationAt !== 'string' || !ISO_WITH_OFFSET.test(t.operationAt)) {
|
if (typeof t.operationAt !== 'string' || !ISO_WITH_OFFSET.test(t.operationAt)) {
|
||||||
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].operationAt must be ISO 8601 with offset` };
|
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].operationAt must be ISO 8601 with offset` };
|
||||||
}
|
}
|
||||||
if (typeof t.amountSigned !== 'number' || !Number.isInteger(t.amountSigned) || t.amountSigned === 0) {
|
|
||||||
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].amountSigned must be a non-zero integer` };
|
|
||||||
}
|
|
||||||
if (typeof t.commission !== 'number' || !Number.isInteger(t.commission) || t.commission < 0) {
|
if (typeof t.commission !== 'number' || !Number.isInteger(t.commission) || t.commission < 0) {
|
||||||
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].commission must be a non-negative integer` };
|
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].commission must be a non-negative integer` };
|
||||||
}
|
}
|
||||||
if (typeof t.description !== 'string' || !t.description) {
|
if (typeof t.description !== 'string' || !t.description) {
|
||||||
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].description must be a non-empty string` };
|
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].description must be a non-empty string` };
|
||||||
}
|
}
|
||||||
|
if (typeof t.amountSigned !== 'number' || !Number.isInteger(t.amountSigned)) {
|
||||||
|
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].amountSigned must be an integer` };
|
||||||
|
}
|
||||||
|
if (t.amountSigned === 0) {
|
||||||
|
const hasCashbackMarker = t.description.toLowerCase().includes(CASHBACK_KEYWORD);
|
||||||
|
if (t.commission <= 0 || !hasCashbackMarker) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: 'BAD_REQUEST',
|
||||||
|
message: `transactions[${i}] with amountSigned=0 must have commission>0 and contain 'Зачисление' in description`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -164,21 +175,40 @@ export async function importStatement(
|
|||||||
[accountId, data.bank, accountNumberMasked, data.transactions.length],
|
[accountId, data.bank, accountNumberMasked, data.transactions.length],
|
||||||
);
|
);
|
||||||
const importId = Number(importResult.rows[0].id);
|
const importId = Number(importResult.rows[0].id);
|
||||||
|
const incomeCategoryResult = await client.query(
|
||||||
|
`SELECT id
|
||||||
|
FROM categories
|
||||||
|
WHERE name = 'Поступления' AND type = 'income' AND is_active = TRUE
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1`,
|
||||||
|
);
|
||||||
|
if (incomeCategoryResult.rows.length === 0) {
|
||||||
|
throw new Error("Category 'Поступления' is missing");
|
||||||
|
}
|
||||||
|
const incomeCategoryId = Number(incomeCategoryResult.rows[0].id);
|
||||||
|
|
||||||
// Insert transactions
|
// Insert transactions
|
||||||
const insertedIds: number[] = [];
|
const insertedIds: number[] = [];
|
||||||
|
|
||||||
for (const tx of data.transactions) {
|
for (const tx of data.transactions) {
|
||||||
const fp = computeFingerprint(data.statement.accountNumber, tx);
|
const fp = computeFingerprint(data.statement.accountNumber, tx);
|
||||||
const dir = determineDirection(tx.amountSigned, tx.description);
|
const isCashbackCommissionImport =
|
||||||
|
tx.amountSigned === 0 &&
|
||||||
|
tx.commission > 0 &&
|
||||||
|
tx.description.toLowerCase().includes(CASHBACK_KEYWORD);
|
||||||
|
const dir = isCashbackCommissionImport
|
||||||
|
? 'income'
|
||||||
|
: determineDirection(tx.amountSigned, tx.description);
|
||||||
|
const categoryId = isCashbackCommissionImport ? incomeCategoryId : null;
|
||||||
|
const isCategoryConfirmed = isCashbackCommissionImport;
|
||||||
|
|
||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
`INSERT INTO transactions
|
`INSERT INTO transactions
|
||||||
(account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed, import_id)
|
(account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed, import_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, FALSE, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
ON CONFLICT (account_id, fingerprint) DO NOTHING
|
ON CONFLICT (account_id, fingerprint) DO NOTHING
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[accountId, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, importId],
|
[accountId, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, categoryId, isCategoryConfirmed, importId],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.rows.length > 0) {
|
if (result.rows.length > 0) {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const PDF2JSON_PROMPT = `Ты — конвертер банковских вып
|
|||||||
"transactions": [
|
"transactions": [
|
||||||
{
|
{
|
||||||
"operationAt": "<дата и время операции в формате ISO 8601 с offset>",
|
"operationAt": "<дата и время операции в формате ISO 8601 с offset>",
|
||||||
"amountSigned": <число: положительное для прихода, отрицательное для расхода; в копейках>,
|
"amountSigned": <число: положительное для прихода, отрицательное для расхода; 0 допустим только если сумма фактически в commission и в description есть «Зачисление»>,
|
||||||
"commission": <число, целое, >= 0, в копейках>,
|
"commission": <число, целое, >= 0, в копейках>,
|
||||||
"description": "<полное описание операции из выписки>"
|
"description": "<полное описание операции из выписки>"
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,7 @@ const PDF2JSON_PROMPT = `Ты — конвертер банковских вып
|
|||||||
|
|
||||||
1. Суммы — всегда в копейках (рубли × 100). Пример: 500,00 ₽ → 50000, -1234,56 ₽ → -123456.
|
1. Суммы — всегда в копейках (рубли × 100). Пример: 500,00 ₽ → 50000, -1234,56 ₽ → -123456.
|
||||||
2. amountSigned: приход — положительное, расход — отрицательное.
|
2. amountSigned: приход — положительное, расход — отрицательное.
|
||||||
|
amountSigned = 0 допускается только для кейса кэшбэка/зачисления, когда сумма указана в commission и в description есть «Зачисление».
|
||||||
3. operationAt — дата и время, если не указано — 00:00:00, offset +03:00 для МСК.
|
3. operationAt — дата и время, если не указано — 00:00:00, offset +03:00 для МСК.
|
||||||
4. commission — если не указана — 0.
|
4. commission — если не указана — 0.
|
||||||
5. description — полный текст операции как в выписке.
|
5. description — полный текст операции как в выписке.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0f172a" />
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<title>Семейный бюджет</title>
|
<title>Семейный бюджет</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/frontend",
|
"name": "@family-budget/frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.8.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
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 |
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 { useState } from 'react';
|
||||||
import { clearAllTransactions } from '../api/transactions';
|
import { clearAllTransactions } from '../api/transactions';
|
||||||
|
|
||||||
const CONFIRM_WORD = 'УДАЛИТЬ';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
@@ -10,15 +8,11 @@ interface Props {
|
|||||||
|
|
||||||
export function ClearHistoryModal({ onClose, onDone }: Props) {
|
export function ClearHistoryModal({ onClose, onDone }: Props) {
|
||||||
const [check1, setCheck1] = useState(false);
|
const [check1, setCheck1] = useState(false);
|
||||||
const [confirmInput, setConfirmInput] = useState('');
|
|
||||||
const [check2, setCheck2] = useState(false);
|
const [check2, setCheck2] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const canConfirm =
|
const canConfirm = check1 && check2;
|
||||||
check1 &&
|
|
||||||
confirmInput.trim().toUpperCase() === CONFIRM_WORD &&
|
|
||||||
check2;
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
if (!canConfirm || loading) return;
|
if (!canConfirm || loading) return;
|
||||||
@@ -65,20 +59,6 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>
|
|
||||||
Введите <strong>{CONFIRM_WORD}</strong> для подтверждения
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={confirmInput}
|
|
||||||
onChange={(e) => setConfirmInput(e.target.value)}
|
|
||||||
placeholder={CONFIRM_WORD}
|
|
||||||
className={confirmInput && confirmInput.trim().toUpperCase() !== CONFIRM_WORD ? 'input-error' : ''}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group form-group-checkbox clear-history-check">
|
<div className="form-group form-group-checkbox clear-history-check">
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ function extractPattern(description: string): string {
|
|||||||
.slice(0, 50);
|
.slice(0, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCommissionAmountSigned(transaction: Transaction): number {
|
||||||
|
if (transaction.commission === 0) return 0;
|
||||||
|
const isCashbackIncome =
|
||||||
|
transaction.amountSigned === 0 &&
|
||||||
|
transaction.description.toLowerCase().includes('зачисление');
|
||||||
|
return isCashbackIncome ? transaction.commission : -transaction.commission;
|
||||||
|
}
|
||||||
|
|
||||||
export function EditTransactionModal({
|
export function EditTransactionModal({
|
||||||
transaction,
|
transaction,
|
||||||
categories,
|
categories,
|
||||||
@@ -96,6 +104,12 @@ export function EditTransactionModal({
|
|||||||
<span className="modal-tx-label">Сумма</span>
|
<span className="modal-tx-label">Сумма</span>
|
||||||
<span>{formatAmount(transaction.amountSigned)}</span>
|
<span>{formatAmount(transaction.amountSigned)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{transaction.commission !== 0 && (
|
||||||
|
<div className="modal-tx-row">
|
||||||
|
<span className="modal-tx-label">Комиссия</span>
|
||||||
|
<span>{formatAmount(getCommissionAmountSigned(transaction))}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="modal-tx-row">
|
<div className="modal-tx-row">
|
||||||
<span className="modal-tx-label">Описание</span>
|
<span className="modal-tx-label">Описание</span>
|
||||||
<span className="modal-tx-description">
|
<span className="modal-tx-description">
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
import { useState, useEffect, type ReactNode } from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { getBackendVersion } from '../api/version';
|
||||||
|
|
||||||
export function Layout({ children }: { children: ReactNode }) {
|
export function Layout({ children }: { children: ReactNode }) {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [beVersion, setBeVersion] = useState<string | null>(null);
|
||||||
|
|
||||||
const closeDrawer = () => setDrawerOpen(false);
|
const closeDrawer = () => setDrawerOpen(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getBackendVersion()
|
||||||
|
.then((r) => setBeVersion(r.version))
|
||||||
|
.catch(() => setBeVersion(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="layout">
|
<div className="layout">
|
||||||
<button
|
<button
|
||||||
@@ -86,11 +94,19 @@ export function Layout({ children }: { children: ReactNode }) {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
|
<div className="sidebar-footer-top">
|
||||||
<span className="sidebar-user">{user?.login}</span>
|
<span className="sidebar-user">{user?.login}</span>
|
||||||
<button className="btn-logout" onClick={() => logout()}>
|
<button className="btn-logout" onClick={() => logout()}>
|
||||||
Выход
|
Выход
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="sidebar-footer-bottom">
|
||||||
|
<span className="sidebar-version">
|
||||||
|
FE {__FE_VERSION__} · BE {beVersion ?? '…'}
|
||||||
|
</span>
|
||||||
|
<span className="sidebar-copyright">© 2025 Семейный бюджет</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="main-content">{children}</main>
|
<main className="main-content">{children}</main>
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ export function SummaryCards({ summary }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="summary-card summary-card-investments">
|
||||||
|
<div className="summary-label">На инвестиции</div>
|
||||||
|
<div className="summary-value">
|
||||||
|
{formatAmount(summary.investmentOutflow)}
|
||||||
|
</div>
|
||||||
|
{summary.investmentIncomeExcluded > 0 && (
|
||||||
|
<div className="summary-subvalue">
|
||||||
|
Исключено из доходов: {formatAmount(summary.investmentIncomeExcluded)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{summary.topCategories.length > 0 && (
|
{summary.topCategories.length > 0 && (
|
||||||
<div className="summary-card summary-card-top">
|
<div className="summary-card summary-card-top">
|
||||||
<div className="summary-label">Топ расходов</div>
|
<div className="summary-label">Топ расходов</div>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function TimeseriesChart({ data }: Props) {
|
|||||||
period: item.periodStart,
|
period: item.periodStart,
|
||||||
Расходы: Math.abs(item.expenseAmount) / 100,
|
Расходы: Math.abs(item.expenseAmount) / 100,
|
||||||
Доходы: item.incomeAmount / 100,
|
Доходы: item.incomeAmount / 100,
|
||||||
|
Инвестиции: Math.abs(item.investmentOutflow) / 100,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -69,6 +70,11 @@ export function TimeseriesChart({ data }: Props) {
|
|||||||
fill="#10b981"
|
fill="#10b981"
|
||||||
radius={[4, 4, 0, 0]}
|
radius={[4, 4, 0, 0]}
|
||||||
/>
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="Инвестиции"
|
||||||
|
fill="#f59e0b"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ const DIRECTION_CLASSES: Record<string, string> = {
|
|||||||
transfer: 'amount-transfer',
|
transfer: 'amount-transfer',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getCommissionAmountSigned(tx: Transaction): number {
|
||||||
|
if (tx.commission === 0) return 0;
|
||||||
|
const isCashbackIncome =
|
||||||
|
tx.amountSigned === 0 && tx.description.toLowerCase().includes('зачисление');
|
||||||
|
return isCashbackIncome ? tx.commission : -tx.commission;
|
||||||
|
}
|
||||||
|
|
||||||
function TransactionCard({
|
function TransactionCard({
|
||||||
tx,
|
tx,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -36,6 +43,11 @@ function TransactionCard({
|
|||||||
{formatAmount(tx.amountSigned)}
|
{formatAmount(tx.amountSigned)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{tx.commission !== 0 && (
|
||||||
|
<div className="transaction-card-commission">
|
||||||
|
Комиссия: {formatAmount(getCommissionAmountSigned(tx))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="transaction-card-body">
|
<div className="transaction-card-body">
|
||||||
<span className="description-text">{tx.description}</span>
|
<span className="description-text">{tx.description}</span>
|
||||||
{tx.comment && (
|
{tx.comment && (
|
||||||
@@ -117,7 +129,12 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
|
|||||||
<td
|
<td
|
||||||
className={`td-nowrap td-amount ${DIRECTION_CLASSES[tx.direction] ?? ''}`}
|
className={`td-nowrap td-amount ${DIRECTION_CLASSES[tx.direction] ?? ''}`}
|
||||||
>
|
>
|
||||||
{formatAmount(tx.amountSigned)}
|
<div>{formatAmount(tx.amountSigned)}</div>
|
||||||
|
{tx.commission !== 0 && (
|
||||||
|
<div className="td-commission">
|
||||||
|
Комиссия: {formatAmount(getCommissionAmountSigned(tx))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="td-description">
|
<td className="td-description">
|
||||||
<span className="description-text">{tx.description}</span>
|
<span className="description-text">{tx.description}</span>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { getMe, login as apiLogin, logout as apiLogout } from '../api/auth';
|
|||||||
import { setOnUnauthorized } from '../api/client';
|
import { setOnUnauthorized } from '../api/client';
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: { login: string } | null;
|
user: { login: string; backendVersion: string } | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
login: (username: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
@@ -20,7 +20,7 @@ interface AuthState {
|
|||||||
const AuthContext = createContext<AuthState | null>(null);
|
const AuthContext = createContext<AuthState | null>(null);
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<{ login: string } | null>(null);
|
const [user, setUser] = useState<{ login: string; backendVersion: string } | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOnUnauthorized(clearUser);
|
setOnUnauthorized(clearUser);
|
||||||
getMe()
|
getMe()
|
||||||
.then((me) => setUser({ login: me.login }))
|
.then((me) => setUser({ login: me.login, backendVersion: me.backendVersion }))
|
||||||
.catch(() => setUser(null))
|
.catch(() => setUser(null))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [clearUser]);
|
}, [clearUser]);
|
||||||
@@ -41,7 +41,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
await apiLogin({ login: username, password });
|
await apiLogin({ login: username, password });
|
||||||
const me = await getMe();
|
const me = await getMe();
|
||||||
setUser({ login: me.login });
|
setUser({ login: me.login, backendVersion: me.backendVersion });
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg = e instanceof Error && e.message === 'Failed to fetch'
|
const msg = e instanceof Error && e.message === 'Failed to fetch'
|
||||||
? 'Сервер недоступен. Проверьте, что backend запущен и NPM проксирует /api на порт 3000.'
|
? 'Сервер недоступен. Проверьте, что backend запущен и NPM проксирует /api на порт 3000.'
|
||||||
|
|||||||
@@ -138,9 +138,26 @@ body {
|
|||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer-bottom {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-sidebar-text);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-version {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-copyright {
|
||||||
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-user {
|
.sidebar-user {
|
||||||
@@ -148,6 +165,12 @@ body {
|
|||||||
color: var(--color-sidebar-text);
|
color: var(--color-sidebar-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-footer-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-logout {
|
.btn-logout {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -249,6 +272,7 @@ body {
|
|||||||
|
|
||||||
.page {
|
.page {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
@@ -644,6 +668,12 @@ textarea {
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.transaction-card-commission {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.transaction-card-footer {
|
.transaction-card-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -714,6 +744,12 @@ textarea {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.td-commission {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
.amount-income {
|
.amount-income {
|
||||||
color: var(--color-success);
|
color: var(--color-success);
|
||||||
}
|
}
|
||||||
@@ -1287,6 +1323,10 @@ textarea {
|
|||||||
border-left-color: var(--color-primary);
|
border-left-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-card-investments {
|
||||||
|
border-left-color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
.summary-label {
|
.summary-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1302,6 +1342,12 @@ textarea {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-subvalue {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.summary-top-list {
|
.summary-top-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
2
frontend/src/vite-env.d.ts
vendored
2
frontend/src/vite-env.d.ts
vendored
@@ -1 +1,3 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare const __FE_VERSION__: string;
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const pkg = JSON.parse(
|
||||||
|
readFileSync(join(__dirname, 'package.json'), 'utf-8'),
|
||||||
|
) as { version: string };
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
define: {
|
||||||
|
__FE_VERSION__: JSON.stringify(pkg.version),
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "family-budget",
|
"name": "family-budget",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"shared",
|
"shared",
|
||||||
|
|||||||
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
|
```json
|
||||||
{
|
{
|
||||||
"schemaVersion": "1.0",
|
"schemaVersion": "1.0",
|
||||||
"bank": "<название банка из выписки>",
|
"bank": "VTB",
|
||||||
"statement": {
|
"statement": {
|
||||||
"accountNumber": "<номер счёта, только цифры, без пробелов>",
|
"accountNumber": "string",
|
||||||
"currency": "RUB",
|
"currency": "RUB",
|
||||||
"openingBalance": <число в копейках, целое>,
|
"openingBalance": integer,
|
||||||
"closingBalance": <число в копейках, целое>,
|
"closingBalance": integer,
|
||||||
"exportedAt": "<дата экспорта в формате ISO 8601 с offset, например 2026-02-27T13:23:00+03:00>"
|
"exportedAt": "ISO 8601 string"
|
||||||
},
|
},
|
||||||
"transactions": [
|
"transactions": [
|
||||||
{
|
{
|
||||||
"operationAt": "<дата и время операции в формате ISO 8601 с offset>",
|
"operationAt": "ISO 8601 string",
|
||||||
"amountSigned": <число: положительное для прихода, отрицательное для расхода; в копейках>,
|
"amountSigned": integer,
|
||||||
"commission": <число, целое, >= 0, в копейках>,
|
"commission": integer,
|
||||||
"description": "<полное описание операции из выписки>"
|
"description": "string"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Правила конвертации
|
---
|
||||||
|
|
||||||
1. **Суммы** — всегда в копейках (рубли × 100). Пример: 500,00 ₽ → 50000, -1234,56 ₽ → -123456.
|
## Шаг 1. Извлечение данных из PDF
|
||||||
|
|
||||||
2. **amountSigned**:
|
### 1.1 Шапка выписки
|
||||||
- Приход (зачисление, пополнение) — положительное число.
|
|
||||||
- Расход (списание, оплата) — отрицательное число.
|
|
||||||
- Переводы — знак в зависимости от направления движения на счёт.
|
|
||||||
|
|
||||||
3. **operationAt** — дата и время операции. Если время не указано, используй 00:00:00. Обязательно указывай offset (+03:00 для МСК).
|
Извлечь из заголовочного блока:
|
||||||
|
|
||||||
4. **commission** — комиссия по операции. Если не указана — 0.
|
| Поле PDF | Поле JSON | Примечание |
|
||||||
|
|---------------------------|----------------------------------|-----------------------------------|
|
||||||
|
| Номер счёта | `statement.accountNumber` | строка, без пробелов |
|
||||||
|
| Баланс на начало периода | `statement.openingBalance` | перевести в копейки (× 100) |
|
||||||
|
| Баланс на конец периода | `statement.closingBalance` | перевести в копейки (× 100) |
|
||||||
|
| Поступления | только для валидации | не записывать в JSON |
|
||||||
|
| Расходные операции | только для валидации | не записывать в JSON |
|
||||||
|
| Период выписки | используется для валидации | формат ДД.ММ.ГГГГ – ДД.ММ.ГГГГ |
|
||||||
|
|
||||||
5. **description** — полный текст операции как в выписке (назначение платежа, магазин, получатель и т.п.). Не сокращай и не меняй формулировки.
|
### 1.2 Поле `exportedAt`
|
||||||
|
|
||||||
6. **accountNumber** — только цифры, без пробелов и дефисов (например: 40817810825104025611).
|
Взять дату и время **первой строки таблицы операций** (самая поздняя по дате операции).
|
||||||
|
Формат: ISO 8601 с московским часовым поясом: `YYYY-MM-DDTHH:MM:SS+03:00`
|
||||||
|
|
||||||
7. **openingBalance / closingBalance** — начальный и конечный остаток по счёту в копейках.
|
---
|
||||||
|
|
||||||
8. **bank** — краткое название банка (VTB, Sberbank, Тинькофф и т.п.).
|
## Шаг 2. Извлечение операций
|
||||||
|
|
||||||
9. **exportedAt** — дата формирования выписки. Если неизвестна — возьми дату последней операции в выписке.
|
### 2.1 Столбцы таблицы в PDF
|
||||||
|
|
||||||
10. **Порядок транзакций** — сохраняй хронологический порядок из выписки (обычно от старых к новым).
|
Каждая строка операции содержит:
|
||||||
|
|
||||||
## Требования
|
| Столбец PDF | Описание |
|
||||||
|
|-------------------------------------|------------------------------------------------|
|
||||||
|
| Дата и время операции | дата + время совершения операции |
|
||||||
|
| Дата обработки банком | дата, когда банк обработал операцию |
|
||||||
|
| Сумма операции в валюте операции | знаковая сумма (`-` = расход, без знака = приход) |
|
||||||
|
| Приход (в валюте счёта) | сумма, если поступление |
|
||||||
|
| Расход (в валюте счёта) | сумма, если списание |
|
||||||
|
| Комиссия | комиссия банка |
|
||||||
|
| Описание операции | текстовое описание |
|
||||||
|
|
||||||
- Массив `transactions` не должен быть пустым.
|
### 2.2 Правила маппинга
|
||||||
- Все числа — целые.
|
|
||||||
- Даты — строго в формате ISO 8601 с offset.
|
|
||||||
- currency всегда "RUB".
|
|
||||||
- schemaVersion всегда "1.0".
|
|
||||||
|
|
||||||
## Пример одной транзакции
|
**`operationAt`** — из столбца «Дата и время операции», формат ISO 8601 + московский TZ:
|
||||||
|
```
|
||||||
|
29.03.2026 20:38:01 → 2026-03-29T20:38:01+03:00
|
||||||
|
```
|
||||||
|
|
||||||
Выписка: «26.02.2026 14:06 | -500,00 ₽ | 0,00 | Оплата товаров. PAVELETSKAYA по карте *8214»
|
**`amountSigned`** — сумма в **копейках** (целое число):
|
||||||
|
- Если операция — **приход**: значение **положительное**
|
||||||
|
- Если операция — **расход**: значение **отрицательное**
|
||||||
|
- Допустим специальный кейс: `amountSigned = 0`, **только если** `commission > 0` и в `description` есть подстрока **«Зачисление»** (без учёта регистра)
|
||||||
|
- Определять знак по столбцу «Приход»/«Расход», а не по знаку в PDF (там `-` стоит у расходов, но надёжнее смотреть, в какой колонке стоит сумма)
|
||||||
|
- Перевод в копейки: умножить на 100 и округлить до целого (`round(amount * 100)`)
|
||||||
|
- Разделитель дробной части в PDF — точка или запятая — оба варианта обрабатывать
|
||||||
|
|
||||||
→
|
**`commission`** — комиссия в **копейках** (целое число):
|
||||||
|
- Если в PDF стоит `0.00` → записать `0`
|
||||||
|
- Если указана ненулевая комиссия → перевести в копейки аналогично `amountSigned`
|
||||||
|
- Комиссия всегда **неотрицательная**
|
||||||
|
|
||||||
|
**`description`** — полный текст описания операции, строка:
|
||||||
|
- Убрать лишние пробелы и переносы строк (привести к одной строке)
|
||||||
|
- Не обрезать и не сокращать
|
||||||
|
- Сохранить кириллицу и спецсимволы как есть
|
||||||
|
|
||||||
|
### 2.3 Порядок операций
|
||||||
|
|
||||||
|
Операции записывать **в том же порядке, что в PDF** (от новых к старым, сверху вниз).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 3. Валидация перед записью
|
||||||
|
|
||||||
|
### 3.1 Проверка баланса
|
||||||
|
|
||||||
|
Вычислить:
|
||||||
|
```
|
||||||
|
income_sum = сумма всех amountSigned > 0 (в копейках)
|
||||||
|
expense_sum = сумма всех amountSigned < 0 (в копейках, отрицательная)
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверить, что:
|
||||||
|
```
|
||||||
|
openingBalance + income_sum + expense_sum == closingBalance
|
||||||
|
```
|
||||||
|
|
||||||
|
**Если не совпадает** — это нормально и объясняется поведением ВТБ:
|
||||||
|
|
||||||
|
> ВТБ формирует итоги в шапке по **дате обработки банком**, а не по дате операции.
|
||||||
|
> Операции с датой обработки **вне периода выписки** включаются в список транзакций, но **не учитываются в строках «Поступления» и «Расходные операции»** в шапке.
|
||||||
|
|
||||||
|
Правильная проверка:
|
||||||
|
1. Определить границы периода из шапки PDF (`дата_начала`, `дата_конца`)
|
||||||
|
2. Из `Поступления` и `Расходные операции` вычислить ожидаемый net:
|
||||||
|
`expected_net = openingBalance_kopecks + income_pdf_kopecks - expense_pdf_kopecks`
|
||||||
|
3. Убедиться, что `expected_net == closingBalance` (это всегда верно если данные из PDF прочитаны правильно)
|
||||||
|
4. Разница между суммой всех транзакций и `(closingBalance - openingBalance)` — это сумма операций, обработанных банком вне периода. Это **не ошибка**.
|
||||||
|
|
||||||
|
### 3.2 Проверка полноты
|
||||||
|
|
||||||
|
- Подсчитать количество извлечённых транзакций
|
||||||
|
- Пройтись по всем страницам PDF и убедиться, что ни одна строка таблицы не пропущена
|
||||||
|
- Особое внимание: операции в конце страницы и в начале следующей (частые точки потери данных)
|
||||||
|
|
||||||
|
### 3.3 Проверка типов
|
||||||
|
|
||||||
|
Убедиться, что:
|
||||||
|
- `openingBalance`, `closingBalance` — целые числа (`int`)
|
||||||
|
- `amountSigned`, `commission` — целые числа (`int`)
|
||||||
|
- `operationAt`, `exportedAt` — строки в формате ISO 8601 с `+03:00`
|
||||||
|
- `description` — строка (не `null`, не пустая)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 4. Частые ошибки — предотвращение
|
||||||
|
|
||||||
|
| Ошибка | Причина | Как избежать |
|
||||||
|
|--------|---------|--------------|
|
||||||
|
| Сумма в рублях вместо копеек | Забыли умножить на 100 | Всегда применять `round(x * 100)` |
|
||||||
|
| Дробные числа вместо int | Потеря точности float | Использовать `round()`, результат кастовать в `int` |
|
||||||
|
| Неверный знак `amountSigned` | Ориентировались на знак в PDF | Определять знак по столбцу «Приход»/«Расход» |
|
||||||
|
| Потеря операций на стыке страниц | Парсинг постраничный | Читать PDF целиком, не разбивать по страницам |
|
||||||
|
| Обрезанное описание | Перенос строки в ячейке PDF | Объединять строки одной ячейки в одну строку |
|
||||||
|
| Комиссия со знаком минус | Перепутали знак | Комиссия всегда `>= 0` |
|
||||||
|
| `amountSigned = 0` без условия | Импорт отклонит файл | Разрешать `amountSigned = 0` только при `commission > 0` и наличии «Зачисление» в `description` |
|
||||||
|
| Неверный `exportedAt` | Взяли дату начала периода | Брать дату и время **первой (самой новой) операции** в таблице |
|
||||||
|
| Расхождение баланса | Не учли операции вне периода | Это не ошибка — см. Шаг 3.1 |
|
||||||
|
| Лишние пробелы в описании | PDF добавляет пробелы при переносе | Применять `.strip()` и нормализацию пробелов |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 5. Запись файла
|
||||||
|
|
||||||
|
- Имя файла: то же, что у исходного PDF, с заменой расширения `.pdf` → `.json`
|
||||||
|
Пример: `file-18.pdf` → `file-18.json`
|
||||||
|
- Кодировка: **UTF-8**
|
||||||
|
- Формат: JSON с отступами (indent = 2)
|
||||||
|
- `ensure_ascii = false` — кириллица записывается как есть, не экранируется
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Пример корректной транзакции
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-26T14:06:00+03:00",
|
"operationAt": "2026-03-29T20:38:01+03:00",
|
||||||
"amountSigned": -50000,
|
"amountSigned": -500000,
|
||||||
"commission": 0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров. PAVELETSKAYA по карте *8214"
|
"description": "Оплата товаров и услуг. IP KOVTUN L.B.. по карте *9058"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Обработай прикреплённый PDF и верни один JSON-объект.
|
Расшифровка: списание 5 000.00 ₽ = -500 000 копеек, комиссии нет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Пример корректного поступления
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operationAt": "2026-03-24T15:09:41+03:00",
|
||||||
|
"amountSigned": 9537522,
|
||||||
|
"commission": 0,
|
||||||
|
"description": "Поступление заработной платы. 0726 НАЦИОНАЛЬНЫЙ ИССЛЕДОВАТЕЛЬСКИЙ УНИВЕРСИТЕТ \"ВЫСША Поступление заработной платы/иных выплат Salary по реестру Z_0000005862_20260323_001_01 от 23.03.2026. Без НДС.."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Расшифровка: поступление 95 375.22 ₽ = +9 537 522 копейки.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Пример корректного кэшбэка (сумма в commission)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operationAt": "2026-04-07T19:56:59+03:00",
|
||||||
|
"amountSigned": 0,
|
||||||
|
"commission": 592000,
|
||||||
|
"description": "Зачисление кешбэка по программе лояльности."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Расшифровка: для такого кейса `amountSigned = 0` допустим, потому что есть `commission > 0` и маркер «Зачисление» в описании.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итоговый чеклист перед отдачей файла
|
||||||
|
|
||||||
|
- [ ] `schemaVersion` = `"1.0"`
|
||||||
|
- [ ] `bank` = `"VTB"`
|
||||||
|
- [ ] `accountNumber` совпадает с PDF
|
||||||
|
- [ ] `openingBalance` и `closingBalance` в копейках, совпадают с PDF
|
||||||
|
- [ ] `exportedAt` = дата и время первой операции в таблице, формат ISO 8601 +03:00
|
||||||
|
- [ ] Количество транзакций совпадает с количеством строк в PDF
|
||||||
|
- [ ] Все `amountSigned` и `commission` — целые числа
|
||||||
|
- [ ] Если `amountSigned = 0`, то `commission > 0` и в `description` есть «Зачисление» (без учёта регистра)
|
||||||
|
- [ ] Поступления и баланс проверены (Шаг 3.1)
|
||||||
|
- [ ] Файл сохранён в UTF-8, кириллица не экранирована
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/shared",
|
"name": "@family-budget/shared",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface AnalyticsSummaryResponse {
|
|||||||
totalExpense: number;
|
totalExpense: number;
|
||||||
totalIncome: number;
|
totalIncome: number;
|
||||||
net: number;
|
net: number;
|
||||||
|
investmentOutflow: number;
|
||||||
|
investmentIncomeExcluded: number;
|
||||||
topCategories: TopCategory[];
|
topCategories: TopCategory[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,4 +52,5 @@ export interface TimeseriesItem {
|
|||||||
periodEnd: string;
|
periodEnd: string;
|
||||||
expenseAmount: number;
|
expenseAmount: number;
|
||||||
incomeAmount: number;
|
incomeAmount: number;
|
||||||
|
investmentOutflow: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ export interface LoginRequest {
|
|||||||
|
|
||||||
export interface MeResponse {
|
export interface MeResponse {
|
||||||
login: string;
|
login: string;
|
||||||
|
backendVersion: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user