From a895bb4b2f2044a9b7089726876cc3f4ee2480ca Mon Sep 17 00:00:00 2001 From: vakabunga Date: Tue, 10 Mar 2026 06:53:56 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20404=20=D0=BF=D1=80=D0=B8=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B8,=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B5=D0=BB=D0=BA=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B8?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0,=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80?= =?UTF-8?q?=D1=8B=20=D0=B2=20URL,=20=D0=B0=D0=B2=D1=82=D0=BE-=D0=BA=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=B3=D0=BE=D1=80=D0=B8=D0=B8=20=D0=B8=20=D0=BE?= =?UTF-8?q?=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B0=20=D0=B8=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nginx: проксирование /api на backend (единая точка входа) - История: стрелки ← → для переключения недель/месяцев/годов - История: сохранение фильтров и пагинации в URL при F5 - Импорт: миграция 003 — дефолтные правила категорий (PYATEROCHK, AUCHAN и др.) - Настройки: вкладка «Данные» с кнопкой «Очистить историю» - Backend: DELETE /api/transactions для удаления всех транзакций - ClearHistoryModal: подтверждение чекбоксами и вводом «УДАЛИТЬ» --- .gitignore | 2 + Dockerfile.backend | 31 +++++ Dockerfile.frontend | 25 ++++ backend/Dockerfile | 20 --- backend/src/app.ts | 1 + backend/src/db/migrate.ts | 37 ++++++ backend/src/routes/auth.ts | 3 +- backend/src/routes/transactions.ts | 8 ++ backend/src/services/transactions.ts | 5 + docker-compose.yml | 31 ++++- frontend/dist-node/vite.config.d.ts | 2 + frontend/dist-node/vite.config.js | 14 +++ frontend/nginx.conf | 23 ++++ frontend/src/api/client.ts | 15 ++- frontend/src/api/transactions.ts | 4 + frontend/src/components/ClearHistoryModal.tsx | 110 +++++++++++++++++ frontend/src/components/DataSection.tsx | 37 ++++++ .../src/components/TransactionFilters.tsx | 107 +++++++++++++--- frontend/src/context/AuthContext.tsx | 7 +- frontend/src/pages/HistoryPage.tsx | 115 +++++++++++++++++- frontend/src/pages/SettingsPage.tsx | 10 +- frontend/src/styles/index.css | 59 +++++++++ pdf2json.md | 77 ++++++++++++ 23 files changed, 691 insertions(+), 52 deletions(-) create mode 100644 Dockerfile.backend create mode 100644 Dockerfile.frontend delete mode 100644 backend/Dockerfile create mode 100644 frontend/dist-node/vite.config.d.ts create mode 100644 frontend/dist-node/vite.config.js create mode 100644 frontend/nginx.conf create mode 100644 frontend/src/components/ClearHistoryModal.tsx create mode 100644 frontend/src/components/DataSection.tsx create mode 100644 pdf2json.md diff --git a/.gitignore b/.gitignore index e06ee53..343f7ff 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ npm-debug.log* Thumbs.db coverage/ + +jan_feb.json diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..6229d9a --- /dev/null +++ b/Dockerfile.backend @@ -0,0 +1,31 @@ +FROM node:20-alpine AS deps +WORKDIR /app + +COPY package.json package-lock.json* ./ +COPY shared ./shared +COPY backend ./backend + +RUN npm install + +FROM node:20-alpine AS build +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/package.json ./ +COPY --from=deps /app/shared ./shared +COPY --from=deps /app/backend ./backend + +RUN npm run build -w @family-budget/shared +RUN npm run build -w @family-budget/backend + +FROM node:20-alpine AS runner +WORKDIR /app +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 backend/.env .env + +EXPOSE 3000 +CMD ["node","dist/app.js"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..0b3f1b1 --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,25 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +# Копируем корень и конфигурации +COPY package.json package-lock.json* tsconfig.json* ./ + +# Копируем исходники воркспэйсов +COPY shared ./shared +COPY frontend ./frontend + +# Устанавливаем зависимости из корня +RUN npm install + +# Сначала собираем shared (если там есть скрипт build) +RUN npm run build -w @family-budget/shared || echo "No build script in shared, skipping..." + +# Теперь собираем frontend +RUN npm run build -w @family-budget/frontend + +# Финальный образ — nginx для раздачи статики +FROM nginx:alpine +COPY --from=builder /app/frontend/dist /usr/share/nginx/html +COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index e5d627a..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -FROM node:20-alpine AS deps -WORKDIR /app -COPY package.json package-lock.json* ./ -RUN npm ci - -FROM node:20-alpine AS build -WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules -COPY tsconfig*.json ./ -COPY src ./src -RUN npm run build - -FROM node:20-alpine AS runner -WORKDIR /app -ENV NODE_ENV=production -COPY --from=deps /app/node_modules ./node_modules -COPY --from=build /app/dist ./dist -COPY package.json ./ -EXPOSE 3000 -CMD ["npm","run","start"] diff --git a/backend/src/app.ts b/backend/src/app.ts index 74e27a6..e134947 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -14,6 +14,7 @@ import categoryRulesRouter from './routes/categoryRules'; import analyticsRouter from './routes/analytics'; const app = express(); +app.set('trust proxy', 1); app.use(express.json({ limit: '10mb' })); app.use(cookieParser()); diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 7759668..9ba7ca1 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -96,6 +96,43 @@ const migrations: { name: string; sql: string }[] = [ ON CONFLICT DO NOTHING; `, }, + { + name: '003_seed_category_rules', + sql: ` + INSERT INTO category_rules (pattern, match_type, category_id, priority, requires_confirmation) + SELECT pattern, match_type, category_id, priority, requires_confirmation + FROM (VALUES + ('PYATEROCHK', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false), + ('ПЯТЕРОЧКА', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false), + ('AUCHAN', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false), + ('DOSTAVKA IZ PYATEROCHK', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 25, false), + ('МЕТРО', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false), + ('PEREKRESTOK', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false), + ('MAGNIT', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false), + ('LENTA', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false), + ('SPORTMASTER', 'contains', (SELECT id FROM categories WHERE name = 'Спорт' AND type = 'expense' LIMIT 1), 20, false), + ('DECATHLON', 'contains', (SELECT id FROM categories WHERE name = 'Спорт' AND type = 'expense' LIMIT 1), 20, false), + ('PAYPARKING', 'contains', (SELECT id FROM categories WHERE name = 'Авто' AND type = 'expense' LIMIT 1), 20, false), + ('PARKING', 'contains', (SELECT id FROM categories WHERE name = 'Авто' AND type = 'expense' LIMIT 1), 15, false), + ('AZS', 'contains', (SELECT id FROM categories WHERE name = 'Авто' AND type = 'expense' LIMIT 1), 20, false), + ('ЯНДЕКС.ЕДА', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' AND type = 'expense' LIMIT 1), 25, false), + ('MCDONALD', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' AND type = 'expense' LIMIT 1), 20, false), + ('KFC', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' AND type = 'expense' LIMIT 1), 20, false), + ('STARBUCKS', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' AND type = 'expense' LIMIT 1), 20, false), + ('tapper.ru', 'contains', (SELECT id FROM categories WHERE name = 'Развлечения' AND type = 'expense' LIMIT 1), 20, false), + ('АПТЕКА', 'contains', (SELECT id FROM categories WHERE name = 'Здоровье' AND type = 'expense' LIMIT 1), 20, false), + ('РИГЛА', 'contains', (SELECT id FROM categories WHERE name = 'Здоровье' AND type = 'expense' LIMIT 1), 20, false), + ('OZON', 'contains', (SELECT id FROM categories WHERE name = 'Дом' AND type = 'expense' LIMIT 1), 15, false), + ('WILDBERRIES', 'contains', (SELECT id FROM categories WHERE name = 'Дом' AND type = 'expense' LIMIT 1), 15, false), + ('ЯНДЕКС ПЛЮС', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' AND type = 'expense' LIMIT 1), 20, false), + ('SPOTIFY', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' AND type = 'expense' LIMIT 1), 20, false), + ('НЕТФЛИКС', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' AND type = 'expense' LIMIT 1), 20, false) + ) AS v(pattern, match_type, category_id, priority, requires_confirmation) + WHERE category_id IS NOT NULL + AND EXISTS (SELECT 1 FROM categories LIMIT 1) + AND NOT EXISTS (SELECT 1 FROM category_rules LIMIT 1); + `, + }, ]; export async function runMigrations(): Promise { diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 37c23de..8c71bbc 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -16,12 +16,13 @@ router.post( const result = await authService.login({ login, password }); if (!result) { - res.status(401).json({ error: 'UNAUTHORIZED', message: 'Invalid credentials' }); + res.status(401).json({ error: 'UNAUTHORIZED', message: 'Неверный логин или пароль' }); return; } res.cookie('sid', result.sessionId, { httpOnly: true, + secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', }); diff --git a/backend/src/routes/transactions.ts b/backend/src/routes/transactions.ts index 7983025..8a12414 100644 --- a/backend/src/routes/transactions.ts +++ b/backend/src/routes/transactions.ts @@ -4,6 +4,14 @@ import * as transactionService from '../services/transactions'; const router = Router(); +router.delete( + '/', + asyncHandler(async (_req, res) => { + const result = await transactionService.clearAllTransactions(); + res.json(result); + }), +); + router.get( '/', asyncHandler(async (req, res) => { diff --git a/backend/src/services/transactions.ts b/backend/src/services/transactions.ts index 072c308..b758930 100644 --- a/backend/src/services/transactions.ts +++ b/backend/src/services/transactions.ts @@ -168,3 +168,8 @@ export async function updateTransaction( comment: r.comment ?? null, }; } + +export async function clearAllTransactions(): Promise<{ deleted: number }> { + const result = await pool.query('DELETE FROM transactions RETURNING id'); + return { deleted: result.rowCount ?? 0 }; +} diff --git a/docker-compose.yml b/docker-compose.yml index d023411..befdfb3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,17 +1,36 @@ +# NPM: указывайте Forward на порт 3002 (frontend). Frontend проксирует /api на backend. +# Backend, frontend и PostgreSQL — в одной сети (postgres_default). + services: backend: build: - context: ./backend - dockerfile: Dockerfile + context: . + dockerfile: Dockerfile.backend container_name: family-budget-backend - env_file: - - ./backend/.env + environment: + # Имя контейнера/сервиса PostgreSQL — postgres или postgres_budget + - DB_HOST=postgres_budget + - DB_PORT=5432 + - DB_NAME=family_budget + - DB_USER=budget_user + - DB_PASSWORD=difficult_Paaaaaasword ports: - "3000:3000" restart: unless-stopped networks: - - postgre_buget_default + - postgres_default + + frontend: + build: + context: . + dockerfile: Dockerfile.frontend + container_name: family-budget-frontend + ports: + - "3002:80" + restart: unless-stopped + networks: + - postgres_default networks: - postgre_buget_default: + postgres_default: external: true diff --git a/frontend/dist-node/vite.config.d.ts b/frontend/dist-node/vite.config.d.ts new file mode 100644 index 0000000..340562a --- /dev/null +++ b/frontend/dist-node/vite.config.d.ts @@ -0,0 +1,2 @@ +declare const _default: import("vite").UserConfig; +export default _default; diff --git a/frontend/dist-node/vite.config.js b/frontend/dist-node/vite.config.js new file mode 100644 index 0000000..bc5c35e --- /dev/null +++ b/frontend/dist-node/vite.config.js @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + }, + }, +}); diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..4423f5b --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + # API — проксируем на backend (сервис из docker-compose) + location /api { + 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 30s; + } + + # SPA — все остальные пути отдаём index.html + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ccfeede..4c4a3ed 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -26,8 +26,16 @@ async function request(url: string, options: RequestInit = {}): Promise { }); if (res.status === 401) { - onUnauthorized?.(); - throw new ApiException(401, { error: 'UNAUTHORIZED', message: 'Сессия истекла' }); + let body: ApiError; + try { + body = await res.json(); + } catch { + body = { error: 'UNAUTHORIZED', message: 'Сессия истекла' }; + } + if (!url.includes('/api/auth/login')) { + onUnauthorized?.(); + } + throw new ApiException(401, body); } if (!res.ok) { @@ -59,4 +67,7 @@ export const api = { patch: (url: string, body: unknown) => request(url, { method: 'PATCH', body: JSON.stringify(body) }), + + delete: (url: string) => + request(url, { method: 'DELETE' }), }; diff --git a/frontend/src/api/transactions.ts b/frontend/src/api/transactions.ts index c36980c..2d8be0d 100644 --- a/frontend/src/api/transactions.ts +++ b/frontend/src/api/transactions.ts @@ -25,3 +25,7 @@ export async function updateTransaction( ): Promise { return api.put(`/api/transactions/${id}`, data); } + +export async function clearAllTransactions(): Promise<{ deleted: number }> { + return api.delete<{ deleted: number }>('/api/transactions'); +} diff --git a/frontend/src/components/ClearHistoryModal.tsx b/frontend/src/components/ClearHistoryModal.tsx new file mode 100644 index 0000000..74f5ceb --- /dev/null +++ b/frontend/src/components/ClearHistoryModal.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react'; +import { clearAllTransactions } from '../api/transactions'; + +const CONFIRM_WORD = 'УДАЛИТЬ'; + +interface Props { + onClose: () => void; + onDone: () => void; +} + +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 handleConfirm = async () => { + if (!canConfirm || loading) return; + setLoading(true); + setError(''); + try { + await clearAllTransactions(); + onDone(); + } catch (e) { + setError( + e instanceof Error ? e.message : 'Ошибка при очистке истории', + ); + } finally { + setLoading(false); + } + }; + + return ( +
+
e.stopPropagation()}> +
+

Очистить историю операций

+ +
+ +
+

+ Все транзакции будут безвозвратно удалены. Счета и категории + сохранятся. +

+ + {error &&
{error}
} + +
+ +
+ +
+ + setConfirmInput(e.target.value)} + placeholder={CONFIRM_WORD} + className={confirmInput && confirmInput.trim().toUpperCase() !== CONFIRM_WORD ? 'input-error' : ''} + autoComplete="off" + /> +
+ +
+ +
+
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/DataSection.tsx b/frontend/src/components/DataSection.tsx new file mode 100644 index 0000000..c3738cc --- /dev/null +++ b/frontend/src/components/DataSection.tsx @@ -0,0 +1,37 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ClearHistoryModal } from './ClearHistoryModal'; + +export function DataSection() { + const [showClearModal, setShowClearModal] = useState(false); + const navigate = useNavigate(); + + return ( +
+
+

Очистка данных

+

+ Очистить историю операций (все транзакции). Счета, категории и + правила сохранятся. +

+ +
+ + {showClearModal && ( + setShowClearModal(false)} + onDone={() => { + setShowClearModal(false); + navigate('/history'); + }} + /> + )} +
+ ); +} diff --git a/frontend/src/components/TransactionFilters.tsx b/frontend/src/components/TransactionFilters.tsx index 703916d..a87d736 100644 --- a/frontend/src/components/TransactionFilters.tsx +++ b/frontend/src/components/TransactionFilters.tsx @@ -6,7 +6,10 @@ import type { } from '@family-budget/shared'; import { toISODate } from '../utils/format'; +export type PeriodMode = 'week' | 'month' | 'year' | 'custom'; + export interface FiltersState { + periodMode: PeriodMode; from: string; to: string; accountId: string; @@ -58,7 +61,57 @@ export function TransactionFilters({ from = new Date(now.getFullYear(), 0, 1); break; } - onChange({ ...filters, from: toISODate(from), to: toISODate(now) }); + onChange({ + ...filters, + periodMode: preset, + from: toISODate(from), + to: toISODate(now), + }); + }; + + const navigate = (direction: -1 | 1) => { + const fromDate = new Date(filters.from); + let newFrom: Date; + let newTo: Date; + switch (filters.periodMode) { + case 'week': + newFrom = new Date(fromDate); + newFrom.setDate(fromDate.getDate() + 7 * direction); + newTo = new Date(newFrom); + newTo.setDate(newFrom.getDate() + 6); + break; + case 'month': + newFrom = new Date( + fromDate.getFullYear(), + fromDate.getMonth() + direction, + 1, + ); + newTo = new Date( + newFrom.getFullYear(), + newFrom.getMonth() + 1, + 0, + ); + break; + case 'year': + newFrom = new Date(fromDate.getFullYear() + direction, 0, 1); + newTo = new Date(newFrom.getFullYear(), 11, 31); + break; + default: + return; + } + onChange({ + ...filters, + from: toISODate(newFrom), + to: toISODate(newTo), + }); + }; + + const handleDateChange = (field: 'from' | 'to', value: string) => { + onChange({ + ...filters, + periodMode: 'custom', + [field]: value, + }); }; return ( @@ -66,34 +119,56 @@ export function TransactionFilters({
-
- set('from', e.target.value)} - /> - - set('to', e.target.value)} - /> +
+ {filters.periodMode !== 'custom' && ( + + )} +
+ handleDateChange('from', e.target.value)} + /> + + handleDateChange('to', e.target.value)} + /> +
+ {filters.periodMode !== 'custom' && ( + + )}
+
{tab === 'accounts' && } {tab === 'categories' && } {tab === 'rules' && } + {tab === 'data' && }
); diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index dbc87a7..5556166 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -390,6 +390,15 @@ textarea { border-color: var(--color-border-hover); } +.btn-danger { + background: var(--color-danger); + color: #fff; +} + +.btn-danger:hover:not(:disabled) { + background: #dc2626; +} + .btn-block { width: 100%; } @@ -497,6 +506,16 @@ textarea { white-space: nowrap; } +.filter-dates-wrap { + display: flex; + align-items: center; + gap: 6px; +} + +.filter-dates-wrap .btn-page { + flex-shrink: 0; +} + .filter-dates { display: flex; align-items: center; @@ -1003,6 +1022,46 @@ textarea { padding: 0; } +.data-section { + padding: 20px 24px; +} + +.section-block { + margin-bottom: 24px; +} + +.section-block:last-child { + margin-bottom: 0; +} + +.section-block h3 { + font-size: 16px; + font-weight: 600; + margin-bottom: 8px; +} + +.section-desc { + color: var(--color-text-secondary); + font-size: 14px; + margin-bottom: 12px; + max-width: 480px; +} + +.clear-history-warn { + color: var(--color-danger); + font-weight: 500; + margin-bottom: 16px; +} + +.clear-history-check { + margin-bottom: 12px; +} + +.input-error { + border-color: var(--color-danger) !important; + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.15); +} + /* ================================================================ Toggle button ================================================================ */ diff --git a/pdf2json.md b/pdf2json.md new file mode 100644 index 0000000..026e164 --- /dev/null +++ b/pdf2json.md @@ -0,0 +1,77 @@ +# Промпт для конвертации PDF банковской выписки в JSON + +Ты — конвертер банковских выписок. Твоя задача: извлечь данные из прикреплённого PDF банковской выписки и вернуть строго один валидный JSON-объект в формате ниже. Никакого текста до или после JSON, только сам объект. + +## Структура выходного JSON + +```json +{ + "schemaVersion": "1.0", + "bank": "<название банка из выписки>", + "statement": { + "accountNumber": "<номер счёта, только цифры, без пробелов>", + "currency": "RUB", + "openingBalance": <число в копейках, целое>, + "closingBalance": <число в копейках, целое>, + "exportedAt": "<дата экспорта в формате ISO 8601 с offset, например 2026-02-27T13:23:00+03:00>" + }, + "transactions": [ + { + "operationAt": "<дата и время операции в формате ISO 8601 с offset>", + "amountSigned": <число: положительное для прихода, отрицательное для расхода; в копейках>, + "commission": <число, целое, >= 0, в копейках>, + "description": "<полное описание операции из выписки>" + } + ] +} +``` + +## Правила конвертации + +1. **Суммы** — всегда в копейках (рубли × 100). Пример: 500,00 ₽ → 50000, -1234,56 ₽ → -123456. + +2. **amountSigned**: + - Приход (зачисление, пополнение) — положительное число. + - Расход (списание, оплата) — отрицательное число. + - Переводы — знак в зависимости от направления движения на счёт. + +3. **operationAt** — дата и время операции. Если время не указано, используй 00:00:00. Обязательно указывай offset (+03:00 для МСК). + +4. **commission** — комиссия по операции. Если не указана — 0. + +5. **description** — полный текст операции как в выписке (назначение платежа, магазин, получатель и т.п.). Не сокращай и не меняй формулировки. + +6. **accountNumber** — только цифры, без пробелов и дефисов (например: 40817810825104025611). + +7. **openingBalance / closingBalance** — начальный и конечный остаток по счёту в копейках. + +8. **bank** — краткое название банка (VTB, Sberbank, Тинькофф и т.п.). + +9. **exportedAt** — дата формирования выписки. Если неизвестна — возьми дату последней операции в выписке. + +10. **Порядок транзакций** — сохраняй хронологический порядок из выписки (обычно от старых к новым). + +## Требования + +- Массив `transactions` не должен быть пустым. +- Все числа — целые. +- Даты — строго в формате ISO 8601 с offset. +- currency всегда "RUB". +- schemaVersion всегда "1.0". + +## Пример одной транзакции + +Выписка: «26.02.2026 14:06 | -500,00 ₽ | 0,00 | Оплата товаров. PAVELETSKAYA по карте *8214» + +→ + +```json +{ + "operationAt": "2026-02-26T14:06:00+03:00", + "amountSigned": -50000, + "commission": 0, + "description": "Оплата товаров. PAVELETSKAYA по карте *8214" +} +``` + +Обработай прикреплённый PDF и верни один JSON-объект.