From fccde4259d160e32a7b2fa13298613a39c62bdfe Mon Sep 17 00:00:00 2001 From: Anton Date: Tue, 14 Apr 2026 16:15:05 +0300 Subject: [PATCH] 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 --- backend/package.json | 2 +- backend/src/services/analytics.ts | 175 +++++++++++-- backend/src/services/auth.ts | 6 +- backend/src/services/import.ts | 42 +++- backend/src/services/pdfToStatement.ts | 3 +- frontend/package.json | 2 +- .../src/components/EditTransactionModal.tsx | 14 ++ frontend/src/components/Layout.tsx | 14 +- frontend/src/components/SummaryCards.tsx | 12 + frontend/src/components/TimeseriesChart.tsx | 6 + frontend/src/components/TransactionTable.tsx | 19 +- frontend/src/context/AuthContext.tsx | 8 +- frontend/src/styles/index.css | 39 ++- package.json | 2 +- pdf2json.md | 232 +++++++++++++++--- shared/package.json | 2 +- shared/src/types/analytics.ts | 3 + shared/src/types/auth.ts | 1 + 18 files changed, 502 insertions(+), 80 deletions(-) diff --git a/backend/package.json b/backend/package.json index dd3ab67..7bed1f3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/backend", - "version": "0.1.0", + "version": "0.1.1", "private": true, "scripts": { "dev": "tsx watch src/app.ts", diff --git a/backend/src/services/analytics.ts b/backend/src/services/analytics.ts index 2a7a773..1e0b47e 100644 --- a/backend/src/services/analytics.ts +++ b/backend/src/services/analytics.ts @@ -47,26 +47,91 @@ export async function getSummary(params: BaseParams): Promise 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 { } export async function me(sessionId: string): Promise { - return { login: config.appUserLogin }; + return { + login: config.appUserLogin, + backendVersion: backendPackage.version, + }; } diff --git a/backend/src/services/import.ts b/backend/src/services/import.ts index 34a1619..5f45166 100644 --- a/backend/src/services/import.ts +++ b/backend/src/services/import.ts @@ -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; @@ -164,21 +175,40 @@ export async function importStatement( [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, 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 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) { diff --git a/backend/src/services/pdfToStatement.ts b/backend/src/services/pdfToStatement.ts index 1205061..75a194f 100644 --- a/backend/src/services/pdfToStatement.ts +++ b/backend/src/services/pdfToStatement.ts @@ -19,7 +19,7 @@ const PDF2JSON_PROMPT = `Ты — конвертер банковских вып "transactions": [ { "operationAt": "<дата и время операции в формате ISO 8601 с offset>", - "amountSigned": <число: положительное для прихода, отрицательное для расхода; в копейках>, + "amountSigned": <число: положительное для прихода, отрицательное для расхода; 0 допустим только если сумма фактически в commission и в description есть «Зачисление»>, "commission": <число, целое, >= 0, в копейках>, "description": "<полное описание операции из выписки>" } @@ -30,6 +30,7 @@ const PDF2JSON_PROMPT = `Ты — конвертер банковских вып 1. Суммы — всегда в копейках (рубли × 100). Пример: 500,00 ₽ → 50000, -1234,56 ₽ → -123456. 2. amountSigned: приход — положительное, расход — отрицательное. + amountSigned = 0 допускается только для кейса кэшбэка/зачисления, когда сумма указана в commission и в description есть «Зачисление». 3. operationAt — дата и время, если не указано — 00:00:00, offset +03:00 для МСК. 4. commission — если не указана — 0. 5. description — полный текст операции как в выписке. diff --git a/frontend/package.json b/frontend/package.json index c399da6..d6e5829 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/frontend", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/EditTransactionModal.tsx b/frontend/src/components/EditTransactionModal.tsx index 5455b0c..0f903ab 100644 --- a/frontend/src/components/EditTransactionModal.tsx +++ b/frontend/src/components/EditTransactionModal.tsx @@ -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({ Сумма {formatAmount(transaction.amountSigned)} + {transaction.commission !== 0 && ( +
+ Комиссия + {formatAmount(getCommissionAmountSigned(transaction))} +
+ )}
Описание diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index fa38ca7..c04d937 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,7 @@ import { useState, type ReactNode } from 'react'; import { NavLink } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; +import frontendPackage from '../../package.json'; export function Layout({ children }: { children: ReactNode }) { const { user, logout } = useAuth(); @@ -86,10 +87,15 @@ export function Layout({ children }: { children: ReactNode }) {
- {user?.login} - +
+ {user?.login} + +
+
+ FE v{frontendPackage.version} · BE v{user?.backendVersion ?? '—'} +
diff --git a/frontend/src/components/SummaryCards.tsx b/frontend/src/components/SummaryCards.tsx index d3ee610..db59b36 100644 --- a/frontend/src/components/SummaryCards.tsx +++ b/frontend/src/components/SummaryCards.tsx @@ -31,6 +31,18 @@ export function SummaryCards({ summary }: Props) {
+
+
На инвестиции
+
+ {formatAmount(summary.investmentOutflow)} +
+ {summary.investmentIncomeExcluded > 0 && ( +
+ Исключено из доходов: {formatAmount(summary.investmentIncomeExcluded)} +
+ )} +
+ {summary.topCategories.length > 0 && (
Топ расходов
diff --git a/frontend/src/components/TimeseriesChart.tsx b/frontend/src/components/TimeseriesChart.tsx index 4249607..088fdea 100644 --- a/frontend/src/components/TimeseriesChart.tsx +++ b/frontend/src/components/TimeseriesChart.tsx @@ -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]} /> + ); diff --git a/frontend/src/components/TransactionTable.tsx b/frontend/src/components/TransactionTable.tsx index e054c70..6d6e3bf 100644 --- a/frontend/src/components/TransactionTable.tsx +++ b/frontend/src/components/TransactionTable.tsx @@ -13,6 +13,13 @@ const DIRECTION_CLASSES: Record = { 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)}
+ {tx.commission !== 0 && ( +
+ Комиссия: {formatAmount(getCommissionAmountSigned(tx))} +
+ )}
{tx.description} {tx.comment && ( @@ -117,7 +129,12 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) { - {formatAmount(tx.amountSigned)} +
{formatAmount(tx.amountSigned)}
+ {tx.commission !== 0 && ( +
+ Комиссия: {formatAmount(getCommissionAmountSigned(tx))} +
+ )} {tx.description} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index a1ff700..e32cfd5 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -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; @@ -20,7 +20,7 @@ interface AuthState { const AuthContext = createContext(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(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.' diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index c84fc3b..ba28a40 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -139,8 +139,9 @@ body { padding: 16px 20px; border-top: 1px solid rgba(255, 255, 255, 0.1); display: flex; - align-items: center; - justify-content: space-between; + flex-direction: column; + align-items: stretch; + gap: 10px; } .sidebar-user { @@ -148,6 +149,18 @@ body { color: var(--color-sidebar-text); } +.sidebar-footer-top { + display: flex; + align-items: center; + justify-content: space-between; +} + +.sidebar-version { + font-size: 11px; + line-height: 1.4; + color: var(--color-sidebar-text); +} + .btn-logout { background: none; border: none; @@ -645,6 +658,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; @@ -715,6 +734,12 @@ textarea { font-weight: 500; } +.td-commission { + font-size: 12px; + color: var(--color-text-secondary); + font-weight: 400; +} + .amount-income { color: var(--color-success); } @@ -1288,6 +1313,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; @@ -1303,6 +1332,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; diff --git a/package.json b/package.json index e9df812..a6c1c7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "family-budget", - "version": "0.1.0", + "version": "0.1.1", "private": true, "workspaces": [ "shared", diff --git a/pdf2json.md b/pdf2json.md index 648cd55..58e121e 100644 --- a/pdf2json.md +++ b/pdf2json.md @@ -1,75 +1,231 @@ -Ты — конвертер банковских выписок. Твоя задача: извлечь данные из прикреплённого PDF банковской выписки и вернуть строго один валидный JSON-объект в формате ниже. Никакого текста до или после JSON, только сам объект. +# Инструкция агента: конвертация банковской выписки ВТБ (PDF → JSON) -## Структура выходного JSON +## Цель + +Преобразовать PDF-выписку банка ВТБ в JSON-файл строго по схеме 1.0. +На выходе — валидный JSON-файл без потерь операций, с правильными суммами в копейках и корректной проверкой баланса. + +--- + +## Схема JSON (schema 1.0) ```json { "schemaVersion": "1.0", - "bank": "<название банка из выписки>", + "bank": "VTB", "statement": { - "accountNumber": "<номер счёта, только цифры, без пробелов>", + "accountNumber": "string", "currency": "RUB", - "openingBalance": <число в копейках, целое>, - "closingBalance": <число в копейках, целое>, - "exportedAt": "<дата экспорта в формате ISO 8601 с offset, например 2026-02-27T13:23:00+03:00>" + "openingBalance": integer, + "closingBalance": integer, + "exportedAt": "ISO 8601 string" }, "transactions": [ { - "operationAt": "<дата и время операции в формате ISO 8601 с offset>", - "amountSigned": <число: положительное для прихода, отрицательное для расхода; в копейках>, - "commission": <число, целое, >= 0, в копейках>, - "description": "<полное описание операции из выписки>" + "operationAt": "ISO 8601 string", + "amountSigned": integer, + "commission": integer, + "description": "string" } ] } ``` -## Правила конвертации +--- -1. **Суммы** — всегда в копейках (рубли × 100). Пример: 500,00 ₽ → 50000, -1234,56 ₽ → -123456. +## Шаг 1. Извлечение данных из PDF -2. **amountSigned**: - - Приход (зачисление, пополнение) — положительное число. - - Расход (списание, оплата) — отрицательное число. - - Переводы — знак в зависимости от направления движения на счёт. +### 1.1 Шапка выписки -3. **operationAt** — дата и время операции. Если время не указано, используй 00:00:00. Обязательно указывай offset (+03:00 для МСК). +Извлечь из заголовочного блока: -4. **commission** — комиссия по операции. Если не указана — 0. +| Поле PDF | Поле JSON | Примечание | +|---------------------------|----------------------------------|-----------------------------------| +| Номер счёта | `statement.accountNumber` | строка, без пробелов | +| Баланс на начало периода | `statement.openingBalance` | перевести в копейки (× 100) | +| Баланс на конец периода | `statement.closingBalance` | перевести в копейки (× 100) | +| Поступления | только для валидации | не записывать в JSON | +| Расходные операции | только для валидации | не записывать в JSON | +| Период выписки | используется для валидации | формат ДД.ММ.ГГГГ – ДД.ММ.ГГГГ | -5. **description** — полный текст операции как в выписке (назначение платежа, магазин, получатель и т.п.). Не сокращай и не меняй формулировки. +### 1.2 Поле `exportedAt` -6. **accountNumber** — только цифры, без пробелов и дефисов (например: 40817810825104025611). +Взять дату и время **первой строки таблицы операций** (самая поздняя по дате операции). +Формат: ISO 8601 с московским часовым поясом: `YYYY-MM-DDTHH:MM:SS+03:00` -7. **openingBalance / closingBalance** — начальный и конечный остаток по счёту в копейках. +--- -8. **bank** — краткое название банка (VTB, Sberbank, Тинькофф и т.п.). +## Шаг 2. Извлечение операций -9. **exportedAt** — дата формирования выписки. Если неизвестна — возьми дату последней операции в выписке. +### 2.1 Столбцы таблицы в PDF -10. **Порядок транзакций** — сохраняй хронологический порядок из выписки (обычно от старых к новым). +Каждая строка операции содержит: -## Требования +| Столбец PDF | Описание | +|-------------------------------------|------------------------------------------------| +| Дата и время операции | дата + время совершения операции | +| Дата обработки банком | дата, когда банк обработал операцию | +| Сумма операции в валюте операции | знаковая сумма (`-` = расход, без знака = приход) | +| Приход (в валюте счёта) | сумма, если поступление | +| Расход (в валюте счёта) | сумма, если списание | +| Комиссия | комиссия банка | +| Описание операции | текстовое описание | -- Массив `transactions` не должен быть пустым. -- Все числа — целые. -- Даты — строго в формате ISO 8601 с offset. -- currency всегда "RUB". -- schemaVersion всегда "1.0". +### 2.2 Правила маппинга -## Пример одной транзакции +**`operationAt`** — из столбца «Дата и время операции», формат ISO 8601 + московский TZ: +``` +29.03.2026 20:38:01 → 2026-03-29T20:38:01+03:00 +``` -Выписка: «26.02.2026 14:06 | -500,00 ₽ | 0,00 | Оплата товаров. PAVELETSKAYA по карте *8214» +**`amountSigned`** — сумма в **копейках** (целое число): +- Если операция — **приход**: значение **положительное** +- Если операция — **расход**: значение **отрицательное** +- Допустим специальный кейс: `amountSigned = 0`, **только если** `commission > 0` и в `description` есть подстрока **«Зачисление»** (без учёта регистра) +- Определять знак по столбцу «Приход»/«Расход», а не по знаку в PDF (там `-` стоит у расходов, но надёжнее смотреть, в какой колонке стоит сумма) +- Перевод в копейки: умножить на 100 и округлить до целого (`round(amount * 100)`) +- Разделитель дробной части в PDF — точка или запятая — оба варианта обрабатывать -→ +**`commission`** — комиссия в **копейках** (целое число): +- Если в PDF стоит `0.00` → записать `0` +- Если указана ненулевая комиссия → перевести в копейки аналогично `amountSigned` +- Комиссия всегда **неотрицательная** + +**`description`** — полный текст описания операции, строка: +- Убрать лишние пробелы и переносы строк (привести к одной строке) +- Не обрезать и не сокращать +- Сохранить кириллицу и спецсимволы как есть + +### 2.3 Порядок операций + +Операции записывать **в том же порядке, что в PDF** (от новых к старым, сверху вниз). + +--- + +## Шаг 3. Валидация перед записью + +### 3.1 Проверка баланса + +Вычислить: +``` +income_sum = сумма всех amountSigned > 0 (в копейках) +expense_sum = сумма всех amountSigned < 0 (в копейках, отрицательная) +``` + +Проверить, что: +``` +openingBalance + income_sum + expense_sum == closingBalance +``` + +**Если не совпадает** — это нормально и объясняется поведением ВТБ: + +> ВТБ формирует итоги в шапке по **дате обработки банком**, а не по дате операции. +> Операции с датой обработки **вне периода выписки** включаются в список транзакций, но **не учитываются в строках «Поступления» и «Расходные операции»** в шапке. + +Правильная проверка: +1. Определить границы периода из шапки PDF (`дата_начала`, `дата_конца`) +2. Из `Поступления` и `Расходные операции` вычислить ожидаемый net: + `expected_net = openingBalance_kopecks + income_pdf_kopecks - expense_pdf_kopecks` +3. Убедиться, что `expected_net == closingBalance` (это всегда верно если данные из PDF прочитаны правильно) +4. Разница между суммой всех транзакций и `(closingBalance - openingBalance)` — это сумма операций, обработанных банком вне периода. Это **не ошибка**. + +### 3.2 Проверка полноты + +- Подсчитать количество извлечённых транзакций +- Пройтись по всем страницам PDF и убедиться, что ни одна строка таблицы не пропущена +- Особое внимание: операции в конце страницы и в начале следующей (частые точки потери данных) + +### 3.3 Проверка типов + +Убедиться, что: +- `openingBalance`, `closingBalance` — целые числа (`int`) +- `amountSigned`, `commission` — целые числа (`int`) +- `operationAt`, `exportedAt` — строки в формате ISO 8601 с `+03:00` +- `description` — строка (не `null`, не пустая) + +--- + +## Шаг 4. Частые ошибки — предотвращение + +| Ошибка | Причина | Как избежать | +|--------|---------|--------------| +| Сумма в рублях вместо копеек | Забыли умножить на 100 | Всегда применять `round(x * 100)` | +| Дробные числа вместо int | Потеря точности float | Использовать `round()`, результат кастовать в `int` | +| Неверный знак `amountSigned` | Ориентировались на знак в PDF | Определять знак по столбцу «Приход»/«Расход» | +| Потеря операций на стыке страниц | Парсинг постраничный | Читать PDF целиком, не разбивать по страницам | +| Обрезанное описание | Перенос строки в ячейке PDF | Объединять строки одной ячейки в одну строку | +| Комиссия со знаком минус | Перепутали знак | Комиссия всегда `>= 0` | +| `amountSigned = 0` без условия | Импорт отклонит файл | Разрешать `amountSigned = 0` только при `commission > 0` и наличии «Зачисление» в `description` | +| Неверный `exportedAt` | Взяли дату начала периода | Брать дату и время **первой (самой новой) операции** в таблице | +| Расхождение баланса | Не учли операции вне периода | Это не ошибка — см. Шаг 3.1 | +| Лишние пробелы в описании | PDF добавляет пробелы при переносе | Применять `.strip()` и нормализацию пробелов | + +--- + +## Шаг 5. Запись файла + +- Имя файла: то же, что у исходного PDF, с заменой расширения `.pdf` → `.json` + Пример: `file-18.pdf` → `file-18.json` +- Кодировка: **UTF-8** +- Формат: JSON с отступами (indent = 2) +- `ensure_ascii = false` — кириллица записывается как есть, не экранируется + +--- + +## Пример корректной транзакции ```json { - "operationAt": "2026-02-26T14:06:00+03:00", - "amountSigned": -50000, + "operationAt": "2026-03-29T20:38:01+03:00", + "amountSigned": -500000, "commission": 0, - "description": "Оплата товаров. PAVELETSKAYA по карте *8214" + "description": "Оплата товаров и услуг. IP KOVTUN L.B.. по карте *9058" } ``` -Обработай прикреплённый PDF и верни один JSON-объект. +Расшифровка: списание 5 000.00 ₽ = -500 000 копеек, комиссии нет. + +--- + +## Пример корректного поступления + +```json +{ + "operationAt": "2026-03-24T15:09:41+03:00", + "amountSigned": 9537522, + "commission": 0, + "description": "Поступление заработной платы. 0726 НАЦИОНАЛЬНЫЙ ИССЛЕДОВАТЕЛЬСКИЙ УНИВЕРСИТЕТ \"ВЫСША Поступление заработной платы/иных выплат Salary по реестру Z_0000005862_20260323_001_01 от 23.03.2026. Без НДС.." +} +``` + +Расшифровка: поступление 95 375.22 ₽ = +9 537 522 копейки. + +--- + +## Пример корректного кэшбэка (сумма в commission) + +```json +{ + "operationAt": "2026-04-07T19:56:59+03:00", + "amountSigned": 0, + "commission": 592000, + "description": "Зачисление кешбэка по программе лояльности." +} +``` + +Расшифровка: для такого кейса `amountSigned = 0` допустим, потому что есть `commission > 0` и маркер «Зачисление» в описании. + +--- + +## Итоговый чеклист перед отдачей файла + +- [ ] `schemaVersion` = `"1.0"` +- [ ] `bank` = `"VTB"` +- [ ] `accountNumber` совпадает с PDF +- [ ] `openingBalance` и `closingBalance` в копейках, совпадают с PDF +- [ ] `exportedAt` = дата и время первой операции в таблице, формат ISO 8601 +03:00 +- [ ] Количество транзакций совпадает с количеством строк в PDF +- [ ] Все `amountSigned` и `commission` — целые числа +- [ ] Если `amountSigned = 0`, то `commission > 0` и в `description` есть «Зачисление» (без учёта регистра) +- [ ] Поступления и баланс проверены (Шаг 3.1) +- [ ] Файл сохранён в UTF-8, кириллица не экранирована diff --git a/shared/package.json b/shared/package.json index 44a32de..ba206ae 100644 --- a/shared/package.json +++ b/shared/package.json @@ -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", diff --git a/shared/src/types/analytics.ts b/shared/src/types/analytics.ts index 82b92cf..c4bd69b 100644 --- a/shared/src/types/analytics.ts +++ b/shared/src/types/analytics.ts @@ -18,6 +18,8 @@ export interface AnalyticsSummaryResponse { totalExpense: number; totalIncome: number; net: number; + investmentOutflow: number; + investmentIncomeExcluded: number; topCategories: TopCategory[]; } @@ -50,4 +52,5 @@ export interface TimeseriesItem { periodEnd: string; expenseAmount: number; incomeAmount: number; + investmentOutflow: number; } diff --git a/shared/src/types/auth.ts b/shared/src/types/auth.ts index e7a5d37..50b5f35 100644 --- a/shared/src/types/auth.ts +++ b/shared/src/types/auth.ts @@ -5,4 +5,5 @@ export interface LoginRequest { export interface MeResponse { login: string; + backendVersion: string; }