Compare commits
37 Commits
4d67636633
...
revert/rem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b57dd987e | ||
| ea234ea007 | |||
|
|
db4d5e4d00 | ||
| 358fcaeff5 | |||
|
|
67fed57118 | ||
| 45a6f3d374 | |||
|
|
aaf8cacf75 | ||
|
|
e28d0f46d0 | ||
| 22be09c101 | |||
|
|
78c4730196 | ||
| f2d0c91488 | |||
|
|
1d7fbea9ef | ||
|
|
627706228b | ||
|
|
a5f2294440 | ||
| 25ddd6b7ed | |||
|
|
3e481d5a55 | ||
| cf24d5dc26 | |||
|
|
723df494ca | ||
| 5fa6b921d8 | |||
|
|
feb756cfe2 | ||
|
|
b598216d24 | ||
| 0638885812 | |||
|
|
975f2c4fd2 | ||
|
|
50154f304c | ||
|
|
8625f7f6cf | ||
|
|
d1536b8872 | ||
|
|
20d2a2b497 | ||
|
|
56b5c81ec5 | ||
|
|
a895bb4b2f | ||
|
|
792b4ca4ad | ||
|
|
b990149bc3 | ||
|
|
6b6ce5cba1 | ||
|
|
a50252d920 | ||
|
|
172246db0b | ||
|
|
b0e557885c | ||
|
|
ad4ad55e35 | ||
|
|
cd56e2bf9d |
6
.gitattributes
vendored
Normal file
6
.gitattributes
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Единые правила переносов строк для всех машин
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Windows-скрипты — исключение, им нужен CRLF
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dist-node/
|
||||||
|
build/
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
jan_feb.json
|
||||||
|
jan-feb.json
|
||||||
|
history.xlsx
|
||||||
|
match_analysis.py
|
||||||
|
match_report.txt
|
||||||
|
statements/
|
||||||
37
Dockerfile.backend
Normal file
37
Dockerfile.backend
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
COPY shared ./shared
|
||||||
|
COPY backend ./backend
|
||||||
|
|
||||||
|
# Увеличенные таймауты для нестабильной сети
|
||||||
|
RUN npm config set fetch-retry-mintimeout 20000 && \
|
||||||
|
npm config set fetch-retry-maxtimeout 120000 && \
|
||||||
|
npm config set fetch-timeout 300000
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
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 --from=build /app/backend/node_modules/ ./node_modules/
|
||||||
|
COPY backend/.env .env
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node","dist/app.js"]
|
||||||
30
Dockerfile.frontend
Normal file
30
Dockerfile.frontend
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Копируем корень и конфигурации
|
||||||
|
COPY package.json package-lock.json* tsconfig.json* ./
|
||||||
|
|
||||||
|
# Копируем исходники воркспэйсов
|
||||||
|
COPY shared ./shared
|
||||||
|
COPY frontend ./frontend
|
||||||
|
|
||||||
|
# Увеличиваем таймауты и повторы для нестабильной сети
|
||||||
|
RUN npm config set fetch-retry-mintimeout 20000 && \
|
||||||
|
npm config set fetch-retry-maxtimeout 120000 && \
|
||||||
|
npm config set fetch-timeout 300000
|
||||||
|
|
||||||
|
# Устанавливаем зависимости из корня
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Сначала собираем 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
|
||||||
42
README.md
Normal file
42
README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Family Budget
|
||||||
|
|
||||||
|
Local SPA for family budget tracking: import bank statements, categorize transactions, view analytics.
|
||||||
|
|
||||||
|
## Monorepo structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
family_budget/
|
||||||
|
├── backend/ — Node.js API server (Express + TypeScript)
|
||||||
|
├── frontend/ — React SPA (Vite + TypeScript)
|
||||||
|
├── shared/ — Shared TypeScript types (API contracts, entities)
|
||||||
|
├── docs/ — Specifications and backlog
|
||||||
|
└── package.json — npm workspaces root
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
| Layer | Choice | Rationale |
|
||||||
|
|---------- |------------------------|--------------------------------------------------------|
|
||||||
|
| Backend | Express + TypeScript | Simple, well-known, sufficient for a small local app |
|
||||||
|
| Frontend | React + Vite + TS | Fast dev experience, modern tooling |
|
||||||
|
| Database | PostgreSQL | Deployed on Synology NAS |
|
||||||
|
| Migrations | Knex | Lightweight, SQL-close, supports seeds |
|
||||||
|
| Shared | Pure TypeScript types | Zero-runtime, imported by both backend and frontend |
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js >= 20
|
||||||
|
- PostgreSQL >= 15
|
||||||
|
- npm >= 10
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install all workspace dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Build shared types (must be done before backend/frontend)
|
||||||
|
npm run build -w shared
|
||||||
|
```
|
||||||
|
|
||||||
|
See `backend/README.md` and `frontend/README.md` for per-package instructions.
|
||||||
@@ -10,3 +10,14 @@ APP_USER_PASSWORD=changeme
|
|||||||
SESSION_TIMEOUT_MS=10800000
|
SESSION_TIMEOUT_MS=10800000
|
||||||
|
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
# Ключ OpenAI API для конвертации PDF-выписок в JSON (опционально)
|
||||||
|
# Без него импорт PDF будет недоступен (503)
|
||||||
|
LLM_API_KEY=
|
||||||
|
|
||||||
|
# Базовый URL API LLM (опционально). По умолчанию https://api.openai.com
|
||||||
|
# Примеры: Ollama — http://localhost:11434/v1, Azure — https://YOUR_RESOURCE.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT
|
||||||
|
LLM_API_BASE_URL=
|
||||||
|
|
||||||
|
# Имя модели LLM (опционально). Для OpenAI: gpt-4o-mini. Для Ollama: qwen2.5:7b, qwen3:7b
|
||||||
|
LLM_MODEL=
|
||||||
|
|||||||
@@ -45,10 +45,12 @@ npm run dev -w backend
|
|||||||
| `APP_USER_PASSWORD` | `changeme` | Пароль для входа в приложение |
|
| `APP_USER_PASSWORD` | `changeme` | Пароль для входа в приложение |
|
||||||
| `SESSION_TIMEOUT_MS` | `10800000` | Таймаут сессии по бездействию (3 часа) |
|
| `SESSION_TIMEOUT_MS` | `10800000` | Таймаут сессии по бездействию (3 часа) |
|
||||||
| `PORT` | `3000` | Порт HTTP-сервера |
|
| `PORT` | `3000` | Порт HTTP-сервера |
|
||||||
|
| `LLM_API_KEY` | — | Ключ OpenAI API для конвертации PDF в JSON; без него импорт PDF возвращает 503 |
|
||||||
|
| `LLM_API_BASE_URL` | `https://api.openai.com/v1` | Адрес LLM API (OpenAI-совместимый); для локальной модели, напр. Ollama: `http://localhost:11434/v1` |
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
|
|
||||||
```
|
```text
|
||||||
backend/src/
|
backend/src/
|
||||||
├── app.ts — точка входа: Express, миграции, монтирование роутов
|
├── app.ts — точка входа: Express, миграции, монтирование роутов
|
||||||
├── config.ts — чтение переменных окружения
|
├── config.ts — чтение переменных окружения
|
||||||
@@ -61,6 +63,7 @@ backend/src/
|
|||||||
├── services/
|
├── services/
|
||||||
│ ├── auth.ts — login / logout / me
|
│ ├── auth.ts — login / logout / me
|
||||||
│ ├── import.ts — валидация, fingerprint, direction, атомарный импорт
|
│ ├── import.ts — валидация, fingerprint, direction, атомарный импорт
|
||||||
|
│ ├── pdfToStatement.ts — конвертация PDF → JSON 1.0 через LLM (OpenAI)
|
||||||
│ ├── transactions.ts — список с фильтрами + обновление (categoryId, comment)
|
│ ├── transactions.ts — список с фильтрами + обновление (categoryId, comment)
|
||||||
│ ├── accounts.ts — список счетов, обновление алиаса
|
│ ├── accounts.ts — список счетов, обновление алиаса
|
||||||
│ ├── categories.ts — список категорий (фильтр isActive)
|
│ ├── categories.ts — список категорий (фильтр isActive)
|
||||||
@@ -90,7 +93,7 @@ backend/src/
|
|||||||
|
|
||||||
| Метод | URL | Описание |
|
| Метод | URL | Описание |
|
||||||
|--------|----------------------------|-----------------------------------------|
|
|--------|----------------------------|-----------------------------------------|
|
||||||
| POST | `/api/import/statement` | Импорт банковской выписки (JSON 1.0) |
|
| POST | `/api/import/statement` | Импорт банковской выписки (PDF или JSON 1.0; PDF конвертируется через LLM) |
|
||||||
|
|
||||||
### Транзакции
|
### Транзакции
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
"dev": "tsx watch src/app.ts",
|
"dev": "tsx watch src/app.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/app.js",
|
"start": "node dist/app.js",
|
||||||
"migrate": "tsx src/db/migrate.ts"
|
"migrate": "tsx src/db/migrate.ts",
|
||||||
|
"migrate:prod": "node dist/db/migrate.js",
|
||||||
|
"test:llm": "tsx src/scripts/testLlm.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@family-budget/shared": "*",
|
"@family-budget/shared": "*",
|
||||||
@@ -14,6 +16,9 @@
|
|||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"multer": "^2.1.1",
|
||||||
|
"openai": "^6.27.0",
|
||||||
|
"pdf-parse": "1.1.1",
|
||||||
"pg": "^8.19.0",
|
"pg": "^8.19.0",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
@@ -21,6 +26,7 @@
|
|||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^25.3.3",
|
"@types/node": "^25.3.3",
|
||||||
"@types/pg": "^8.18.0",
|
"@types/pg": "^8.18.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import categoryRulesRouter from './routes/categoryRules';
|
|||||||
import analyticsRouter from './routes/analytics';
|
import analyticsRouter from './routes/analytics';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: '10mb' }));
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|||||||
@@ -18,4 +18,13 @@ export const config = {
|
|||||||
appUserPassword: process.env.APP_USER_PASSWORD || 'changeme',
|
appUserPassword: process.env.APP_USER_PASSWORD || 'changeme',
|
||||||
|
|
||||||
sessionTimeoutMs: parseInt(process.env.SESSION_TIMEOUT_MS || '10800000', 10),
|
sessionTimeoutMs: parseInt(process.env.SESSION_TIMEOUT_MS || '10800000', 10),
|
||||||
|
|
||||||
|
/** API-ключ для LLM (OpenAI или совместимый). Обязателен для конвертации PDF. */
|
||||||
|
llmApiKey: process.env.LLM_API_KEY || '',
|
||||||
|
|
||||||
|
/** Базовый URL API LLM. По умолчанию https://api.openai.com. Для Ollama: http://localhost:11434/v1 */
|
||||||
|
llmApiBaseUrl: process.env.LLM_API_BASE_URL || undefined,
|
||||||
|
|
||||||
|
/** Имя модели LLM. Для OpenAI: gpt-4o-mini. Для Ollama: qwen2.5:7b, qwen3:7b и т.п. */
|
||||||
|
llmModel: process.env.LLM_MODEL || 'gpt-4o-mini',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -96,6 +96,86 @@ const migrations: { name: string; sql: string }[] = [
|
|||||||
ON CONFLICT DO NOTHING;
|
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);
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '004_seed_category_rules_extended',
|
||||||
|
sql: `
|
||||||
|
INSERT INTO category_rules (pattern, match_type, category_id, priority, requires_confirmation)
|
||||||
|
SELECT pattern, match_type, category_id, priority, requires_confirmation
|
||||||
|
FROM (VALUES
|
||||||
|
('STOLOVAYA', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' LIMIT 1), 25, false),
|
||||||
|
('KOFEJNYA', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' LIMIT 1), 25, false),
|
||||||
|
('JEFFREY S COFFEESHOP', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' LIMIT 1), 25, false),
|
||||||
|
('TA TORRRO GRIL', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' LIMIT 1), 25, false),
|
||||||
|
('OUSHEN FRENDS', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' LIMIT 1), 25, false),
|
||||||
|
('mkad', 'contains', (SELECT id FROM categories WHERE name = 'Авто' LIMIT 1), 20, false),
|
||||||
|
('RNAZK', 'contains', (SELECT id FROM categories WHERE name = 'Авто' LIMIT 1), 20, false),
|
||||||
|
('IP SHEVELEV', 'contains', (SELECT id FROM categories WHERE name = 'Авто' LIMIT 1), 20, false),
|
||||||
|
('PAVELETSKAYA', 'contains', (SELECT id FROM categories WHERE name = 'Проезд' LIMIT 1), 25, false),
|
||||||
|
('CPPK-', 'contains', (SELECT id FROM categories WHERE name = 'Проезд' LIMIT 1), 25, false),
|
||||||
|
('MOS.TRANSPORT', 'contains', (SELECT id FROM categories WHERE name = 'Проезд' LIMIT 1), 25, false),
|
||||||
|
('Lab4uru', 'contains', (SELECT id FROM categories WHERE name = 'Здоровье' LIMIT 1), 25, false),
|
||||||
|
('APTEKA', 'contains', (SELECT id FROM categories WHERE name = 'Здоровье' LIMIT 1), 20, false),
|
||||||
|
('IP SHARAFETDINOV', 'contains', (SELECT id FROM categories WHERE name = 'Арчи' LIMIT 1), 25, false),
|
||||||
|
('ZOOMAGAZIN CHETYRE LAP', 'contains', (SELECT id FROM categories WHERE name = 'Арчи' LIMIT 1), 25, false),
|
||||||
|
('VKUSVILL', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' LIMIT 1), 20, false),
|
||||||
|
('GLOBUS', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' LIMIT 1), 20, false),
|
||||||
|
('VERNYJ', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' LIMIT 1), 20, false),
|
||||||
|
('GOLD APPLE', 'contains', (SELECT id FROM categories WHERE name = 'Косметика' LIMIT 1), 25, false),
|
||||||
|
('SPIRITFIT', 'contains', (SELECT id FROM categories WHERE name = 'Спорт' LIMIT 1), 25, false),
|
||||||
|
('insanity', 'contains', (SELECT id FROM categories WHERE name = 'Спорт' LIMIT 1), 25, false),
|
||||||
|
('anta-sport', 'contains', (SELECT id FROM categories WHERE name = 'Спорт' LIMIT 1), 25, false),
|
||||||
|
('VANYAVPN', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' LIMIT 1), 25, false),
|
||||||
|
('ГРАНЛАЙН', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' LIMIT 1), 20, false),
|
||||||
|
('Мобильная связь', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' LIMIT 1), 25, false),
|
||||||
|
('OSTROVOK', 'contains', (SELECT id FROM categories WHERE name = 'Отпуск' LIMIT 1), 25, false),
|
||||||
|
('sutochno', 'contains', (SELECT id FROM categories WHERE name = 'Отпуск' LIMIT 1), 25, false),
|
||||||
|
('УФК', 'contains', (SELECT id FROM categories WHERE name = 'Штрафы' LIMIT 1), 30, false),
|
||||||
|
('ГИБДД', 'contains', (SELECT id FROM categories WHERE name = 'Штрафы' LIMIT 1), 30, false),
|
||||||
|
('Поступление заработной платы', 'contains', (SELECT id FROM categories WHERE name = 'Поступления' LIMIT 1), 30, false),
|
||||||
|
('avito', 'contains', (SELECT id FROM categories WHERE name = 'Поступления' LIMIT 1), 25, false),
|
||||||
|
('Init payout', 'contains', (SELECT id FROM categories WHERE name = 'Поступления' LIMIT 1), 25, 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);
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function runMigrations(): Promise<void> {
|
export async function runMigrations(): Promise<void> {
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ export const pool = new Pool({
|
|||||||
database: config.db.database,
|
database: config.db.database,
|
||||||
user: config.db.user,
|
user: config.db.user,
|
||||||
password: config.db.password,
|
password: config.db.password,
|
||||||
|
connectionTimeoutMillis: 5000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ router.post(
|
|||||||
|
|
||||||
const result = await authService.login({ login, password });
|
const result = await authService.login({ login, password });
|
||||||
if (!result) {
|
if (!result) {
|
||||||
res.status(401).json({ error: 'UNAUTHORIZED', message: 'Invalid credentials' });
|
res.status(401).json({ error: 'UNAUTHORIZED', message: 'Неверный логин или пароль' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.cookie('sid', result.sessionId, {
|
res.cookie('sid', result.sessionId, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
path: '/',
|
path: '/',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,81 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
import { asyncHandler } from '../utils';
|
import { asyncHandler } from '../utils';
|
||||||
import { importStatement, isValidationError } from '../services/import';
|
import { importStatement, isValidationError } from '../services/import';
|
||||||
|
import {
|
||||||
|
convertPdfToStatement,
|
||||||
|
isPdfConversionError,
|
||||||
|
} from '../services/pdfToStatement';
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 15 * 1024 * 1024 },
|
||||||
|
});
|
||||||
|
|
||||||
|
function isPdfFile(file: { mimetype: string; originalname: string }): boolean {
|
||||||
|
const name = file.originalname.toLowerCase();
|
||||||
|
return (
|
||||||
|
file.mimetype === 'application/pdf' ||
|
||||||
|
name.endsWith('.pdf')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJsonFile(file: { mimetype: string; originalname: string }): boolean {
|
||||||
|
const name = file.originalname.toLowerCase();
|
||||||
|
return (
|
||||||
|
file.mimetype === 'application/json' ||
|
||||||
|
name.endsWith('.json')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/statement',
|
'/statement',
|
||||||
|
upload.single('file'),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const result = await importStatement(req.body);
|
const file = req.file;
|
||||||
|
if (!file) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'BAD_REQUEST',
|
||||||
|
message: 'Файл не загружен',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPdfFile(file) && !isJsonFile(file)) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'BAD_REQUEST',
|
||||||
|
message: 'Допустимы только файлы PDF или JSON',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
|
||||||
|
if (isPdfFile(file)) {
|
||||||
|
const converted = await convertPdfToStatement(file.buffer);
|
||||||
|
if (isPdfConversionError(converted)) {
|
||||||
|
res.status(converted.status).json({
|
||||||
|
error: converted.error,
|
||||||
|
message: converted.message,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body = converted;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
body = JSON.parse(file.buffer.toString('utf-8'));
|
||||||
|
} catch {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'BAD_REQUEST',
|
||||||
|
message: 'Некорректный JSON-файл',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await importStatement(body);
|
||||||
if (isValidationError(result)) {
|
if (isValidationError(result)) {
|
||||||
res.status((result as { status: number }).status).json({
|
res.status((result as { status: number }).status).json({
|
||||||
error: (result as { error: string }).error,
|
error: (result as { error: string }).error,
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import * as transactionService from '../services/transactions';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (_req, res) => {
|
||||||
|
const result = await transactionService.clearAllTransactions();
|
||||||
|
res.json(result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
|||||||
42
backend/src/scripts/testLlm.ts
Normal file
42
backend/src/scripts/testLlm.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Тестовый запрос к LLM серверу.
|
||||||
|
* Запуск: npm run test:llm
|
||||||
|
*/
|
||||||
|
import OpenAI from 'openai';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!config.llmApiKey || config.llmApiKey.trim() === '') {
|
||||||
|
console.error('Ошибка: LLM_API_KEY не задан в .env');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: config.llmApiKey,
|
||||||
|
...(config.llmApiBaseUrl && { baseURL: config.llmApiBaseUrl }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = config.llmApiBaseUrl || 'https://api.openai.com/v1';
|
||||||
|
console.log('LLM сервер:', url);
|
||||||
|
console.log('Модель:', config.llmModel);
|
||||||
|
console.log('---');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const completion = await openai.chat.completions.create({
|
||||||
|
model: config.llmModel,
|
||||||
|
messages: [{ role: 'user', content: 'Ответь одним словом: какой цвет у неба?' }],
|
||||||
|
temperature: 0,
|
||||||
|
max_tokens: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = completion.choices[0]?.message?.content;
|
||||||
|
console.log('Ответ:', content || '(пусто)');
|
||||||
|
console.log('---');
|
||||||
|
console.log('OK');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка:', err instanceof Error ? err.message : err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
179
backend/src/services/pdfToStatement.ts
Normal file
179
backend/src/services/pdfToStatement.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import OpenAI from 'openai';
|
||||||
|
import { config } from '../config';
|
||||||
|
import type { StatementFile } from '@family-budget/shared';
|
||||||
|
|
||||||
|
const PDF2JSON_PROMPT = `Ты — конвертер банковских выписок. Твоя задача: извлечь данные из текста банковской выписки ниже и вернуть строго один валидный 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 — только цифры, без пробелов и дефисов.
|
||||||
|
7. openingBalance / closingBalance — в копейках.
|
||||||
|
8. bank — краткое название (VTB, Sberbank, Тинькофф).
|
||||||
|
9. exportedAt — дата формирования выписки.
|
||||||
|
10. transactions — хронологический порядок.
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- transactions не должен быть пустым.
|
||||||
|
- Все числа — целые.
|
||||||
|
- Даты — ISO 8601 с offset.
|
||||||
|
- currency всегда "RUB".
|
||||||
|
- schemaVersion всегда "1.0".`;
|
||||||
|
|
||||||
|
export interface PdfConversionError {
|
||||||
|
status: number;
|
||||||
|
error: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPdfConversionError(r: unknown): r is PdfConversionError {
|
||||||
|
return (
|
||||||
|
typeof r === 'object' &&
|
||||||
|
r !== null &&
|
||||||
|
'status' in r &&
|
||||||
|
'error' in r &&
|
||||||
|
'message' in r
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy-loaded so the app starts even if pdf-parse is missing from node_modules.
|
||||||
|
let _pdfParse: ((buf: Buffer) => Promise<{ text: string }>) | undefined;
|
||||||
|
function getPdfParse() {
|
||||||
|
if (!_pdfParse) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
_pdfParse = require('pdf-parse') as (buf: Buffer) => Promise<{ text: string }>;
|
||||||
|
}
|
||||||
|
return _pdfParse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convertPdfToStatement(
|
||||||
|
buffer: Buffer,
|
||||||
|
): Promise<StatementFile | PdfConversionError> {
|
||||||
|
if (!config.llmApiKey || config.llmApiKey.trim() === '') {
|
||||||
|
return {
|
||||||
|
status: 503,
|
||||||
|
error: 'SERVICE_UNAVAILABLE',
|
||||||
|
message: 'Конвертация PDF недоступна: не задан LLM_API_KEY',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
const result = await getPdfParse()(buffer);
|
||||||
|
text = result.text || '';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PDF extraction error:', err);
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: 'BAD_REQUEST',
|
||||||
|
message: 'Не удалось обработать PDF-файл',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text || text.trim().length === 0) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: 'BAD_REQUEST',
|
||||||
|
message: 'Не удалось извлечь текст из PDF',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: config.llmApiKey,
|
||||||
|
...(config.llmApiBaseUrl && { baseURL: config.llmApiBaseUrl }),
|
||||||
|
timeout: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const completion = await openai.chat.completions.create({
|
||||||
|
model: config.llmModel,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: PDF2JSON_PROMPT },
|
||||||
|
{ role: 'user', content: `Текст выписки:\n\n${text}` },
|
||||||
|
],
|
||||||
|
temperature: 0,
|
||||||
|
max_tokens: 32768,
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = completion.choices[0]?.message?.content?.trim();
|
||||||
|
if (!content) {
|
||||||
|
return {
|
||||||
|
status: 422,
|
||||||
|
error: 'VALIDATION_ERROR',
|
||||||
|
message: 'Результат конвертации пуст',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||||
|
const jsonStr = jsonMatch ? jsonMatch[0] : content;
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(jsonStr);
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
status: 422,
|
||||||
|
error: 'VALIDATION_ERROR',
|
||||||
|
message: 'Результат конвертации не является валидным JSON',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsed as Record<string, unknown>;
|
||||||
|
if (data.schemaVersion !== '1.0') {
|
||||||
|
return {
|
||||||
|
status: 422,
|
||||||
|
error: 'VALIDATION_ERROR',
|
||||||
|
message: 'Результат конвертации не соответствует схеме 1.0',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed as StatementFile;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('LLM conversion error:', err);
|
||||||
|
return {
|
||||||
|
status: 502,
|
||||||
|
error: 'BAD_GATEWAY',
|
||||||
|
message: extractLlmErrorMessage(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLlmErrorMessage(err: unknown): string {
|
||||||
|
const raw = String(
|
||||||
|
(err as Record<string, unknown>)?.message ??
|
||||||
|
(err as Record<string, Record<string, unknown>>)?.error?.message ?? '',
|
||||||
|
);
|
||||||
|
if (/context.length|n_ctx|too.many.tokens|maximum.context/i.test(raw)) {
|
||||||
|
return 'PDF-файл слишком большой для обработки. Попробуйте файл с меньшим количеством операций или используйте модель с большим контекстным окном.';
|
||||||
|
}
|
||||||
|
if (/timeout|timed?\s*out|ETIMEDOUT|ECONNREFUSED/i.test(raw)) {
|
||||||
|
return 'LLM-сервер не отвечает. Проверьте, что сервер запущен и доступен.';
|
||||||
|
}
|
||||||
|
return 'Временная ошибка конвертации';
|
||||||
|
}
|
||||||
@@ -168,3 +168,8 @@ export async function updateTransaction(
|
|||||||
comment: r.comment ?? null,
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from 'express';
|
|||||||
|
|
||||||
export function maskAccountNumber(num: string): string {
|
export function maskAccountNumber(num: string): string {
|
||||||
if (num.length <= 10) return num;
|
if (num.length <= 10) return num;
|
||||||
return num.slice(0, 6) + '*'.repeat(num.length - 10) + num.slice(-4);
|
return num.slice(0, 6) + '******' + num.slice(-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function escapeLike(input: string): string {
|
export function escapeLike(input: string): string {
|
||||||
|
|||||||
36
docker-compose.yml
Normal file
36
docker-compose.yml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# NPM: указывайте Forward на порт 3002 (frontend). Frontend проксирует /api на backend.
|
||||||
|
# Backend, frontend и PostgreSQL — в одной сети (postgres_default).
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.backend
|
||||||
|
container_name: family-budget-backend
|
||||||
|
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:
|
||||||
|
- postgres_default
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.frontend
|
||||||
|
container_name: family-budget-frontend
|
||||||
|
ports:
|
||||||
|
- "3002:80"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- postgres_default
|
||||||
|
|
||||||
|
networks:
|
||||||
|
postgres_default:
|
||||||
|
external: true
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
2 Миграции БД
|
2 Миграции БД
|
||||||
|
|
||||||
- Реализовать таблицы и поля строго по `db.md`, `category.md`, `edit_and_rules.md`, `analytics.md`.
|
- Реализовать таблицы и поля строго по `db.md`, `category.md`, `edit_and_rules.md`, `analytics.md`.
|
||||||
- Включить все описанные CHECK/UNIQUE/FOREIGN KEY/дополнительные поля (`is_category_confirmed`, `comment`, `alias` для accounts, `budgets` и т.д.).
|
- Включить все описанные CHECK/UNIQUE/FOREIGN KEY/дополнительные поля (`is_category_confirmed`, `comment`, `alias` для accounts и т.д.).
|
||||||
|
|
||||||
3 Авторизация и сессии
|
3 Авторизация и сессии
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,8 @@
|
|||||||
|
|
||||||
## 5.3. Задел под бюджеты (лимиты)
|
## 5.3. Задел под бюджеты (лимиты)
|
||||||
|
|
||||||
|
> **Не входит в MVP.** Этот раздел описывает будущую функциональность; таблица `budgets` и связанная логика реализуются после MVP.
|
||||||
|
|
||||||
Для поддержки бюджетов по категориям на будущих этапах вводится сущность `budgets`.
|
Для поддержки бюджетов по категориям на будущих этапах вводится сущность `budgets`.
|
||||||
|
|
||||||
### Таблица `budgets` (идея)
|
### Таблица `budgets` (идея)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Назначение
|
## Назначение
|
||||||
|
|
||||||
Эндпоинт принимает банковскую выписку в формате JSON 1.0 (см. `format.md`) и атомарно импортирует транзакции в БД.
|
Эндпоинт принимает банковскую выписку (PDF или JSON) и атомарно импортирует транзакции в БД.
|
||||||
|
|
||||||
## Метод и URL
|
## Метод и URL
|
||||||
|
|
||||||
@@ -16,7 +16,15 @@
|
|||||||
|
|
||||||
## Тело запроса
|
## Тело запроса
|
||||||
|
|
||||||
JSON строго по схеме 1.0 (`format.md`). Content-Type: `application/json`.
|
**Content-Type:** `multipart/form-data`. Поле: `file`.
|
||||||
|
|
||||||
|
Допустимые типы файлов:
|
||||||
|
- **PDF** — банковская выписка. Конвертируется в JSON 1.0 через LLM (требуется `LLM_API_KEY`).
|
||||||
|
- **JSON** — файл по схеме 1.0 (см. `format.md`).
|
||||||
|
|
||||||
|
При другом типе файла — `400 Bad Request`: «Допустимы только файлы PDF или JSON».
|
||||||
|
|
||||||
|
Пример структуры JSON 1.0:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -175,7 +183,7 @@ accountNumber|operationAt|amountSigned|commission|normalizedDescription
|
|||||||
{
|
{
|
||||||
"accountId": 1,
|
"accountId": 1,
|
||||||
"isNewAccount": true,
|
"isNewAccount": true,
|
||||||
"accountNumberMasked": "408178**********5611",
|
"accountNumberMasked": "408178******5611",
|
||||||
"imported": 28,
|
"imported": 28,
|
||||||
"duplicatesSkipped": 3,
|
"duplicatesSkipped": 3,
|
||||||
"totalInFile": 31
|
"totalInFile": 31
|
||||||
@@ -197,8 +205,8 @@ accountNumber|operationAt|amountSigned|commission|normalizedDescription
|
|||||||
|
|
||||||
- Первые 6 символов остаются открытыми.
|
- Первые 6 символов остаются открытыми.
|
||||||
- Последние 4 символа остаются открытыми.
|
- Последние 4 символа остаются открытыми.
|
||||||
- Промежуточные символы заменяются на `*`.
|
- Промежуточные символы заменяются фиксированным набором из 6 символов `******` (количество звёздочек не раскрывает длину номера).
|
||||||
- Пример: `40817810825104025611` → `408178**********5611`.
|
- Пример: `40817810825104025611` → `408178******5611`.
|
||||||
|
|
||||||
Маскирование применяется в ответе этого эндпоинта и в `GET /api/accounts`.
|
Маскирование применяется в ответе этого эндпоинта и в `GET /api/accounts`.
|
||||||
|
|
||||||
@@ -207,6 +215,8 @@ accountNumber|operationAt|amountSigned|commission|normalizedDescription
|
|||||||
| Код | Ситуация |
|
| Код | Ситуация |
|
||||||
|-----|--------------------------------------------------------------------------|
|
|-----|--------------------------------------------------------------------------|
|
||||||
| 200 | Импорт выполнен успешно |
|
| 200 | Импорт выполнен успешно |
|
||||||
| 400 | Невалидный JSON или нарушение структуры схемы 1.0 |
|
| 400 | Файл не загружен; неверный тип (не PDF и не JSON); некорректный JSON; ошибка извлечения текста из PDF |
|
||||||
| 401 | Нет действующей сессии |
|
| 401 | Нет действующей сессии |
|
||||||
| 422 | Семантическая ошибка валидации (некорректные данные, ошибка при вставке) |
|
| 422 | Семантическая ошибка валидации; результат конвертации PDF не соответствует схеме 1.0 |
|
||||||
|
| 502 | Ошибка LLM при конвертации PDF |
|
||||||
|
| 503 | Конвертация PDF недоступна (не задан LLM_API_KEY) |
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
- Используется PostgreSQL, развёрнутый локально (например, на Synology).
|
- Используется PostgreSQL, развёрнутый локально (например, на Synology).
|
||||||
- Основные сущности:
|
- Основные сущности:
|
||||||
- `accounts` — банковские счета пользователя;
|
- `accounts` — банковские счета пользователя;
|
||||||
|
- `categories` — категории расходов, доходов и переводов;
|
||||||
- `transactions` — движения средств по счетам;
|
- `transactions` — движения средств по счетам;
|
||||||
- `category_rules` — правила автоматической категоризации транзакций (подготовка к SPA-редактору правил).
|
- `category_rules` — правила автоматической категоризации транзакций;
|
||||||
|
- `sessions` — серверные сессии авторизации.
|
||||||
- Все суммы хранятся в минорных единицах (копейки) как `BIGINT`.
|
- Все суммы хранятся в минорных единицах (копейки) как `BIGINT`.
|
||||||
- Время хранится в `TIMESTAMPTZ` (временная зона сохраняется).
|
- Время хранится в `TIMESTAMPTZ` (временная зона сохраняется).
|
||||||
|
|
||||||
@@ -20,6 +22,8 @@
|
|||||||
- `bank TEXT NOT NULL` — код/имя банка (например, `"VTB"`).
|
- `bank TEXT NOT NULL` — код/имя банка (например, `"VTB"`).
|
||||||
- `account_number TEXT NOT NULL` — номер счёта в банке (как в выписке).
|
- `account_number TEXT NOT NULL` — номер счёта в банке (как в выписке).
|
||||||
- `currency TEXT NOT NULL` — код валюты счёта (например, `"RUB"`).
|
- `currency TEXT NOT NULL` — код валюты счёта (например, `"RUB"`).
|
||||||
|
- `alias TEXT` — человекочитаемый алиас счёта (например, `"Текущий"`, `"Накопительный"`).
|
||||||
|
При создании счёта через импорт `alias = NULL`; пользователь задаёт его позже через SPA.
|
||||||
|
|
||||||
Ограничения и индексы:
|
Ограничения и индексы:
|
||||||
|
|
||||||
@@ -32,21 +36,57 @@ CREATE TABLE accounts (
|
|||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
bank TEXT NOT NULL,
|
bank TEXT NOT NULL,
|
||||||
account_number TEXT NOT NULL,
|
account_number TEXT NOT NULL,
|
||||||
currency TEXT NOT NULL
|
currency TEXT NOT NULL,
|
||||||
|
alias TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE UNIQUE INDEX ux_accounts_bank_number
|
CREATE UNIQUE INDEX ux_accounts_bank_number
|
||||||
ON accounts(bank, account_number);
|
ON accounts(bank, account_number);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Таблица `categories`
|
||||||
|
|
||||||
|
Предназначение: хранение категорий для классификации транзакций.
|
||||||
|
|
||||||
|
Структура:
|
||||||
|
|
||||||
|
- `id BIGSERIAL PRIMARY KEY` — идентификатор категории.
|
||||||
|
- `name TEXT NOT NULL` — отображаемое имя категории (например, `"Продукты"`, `"ЖКХ"`).
|
||||||
|
- `type TEXT NOT NULL` — тип категории:
|
||||||
|
- `"expense"` — расходная категория;
|
||||||
|
- `"income"` — доходная категория;
|
||||||
|
- `"transfer"` — переводы между собственными счетами.
|
||||||
|
- `is_active BOOLEAN NOT NULL DEFAULT TRUE` — используется ли категория.
|
||||||
|
|
||||||
|
Ограничения:
|
||||||
|
|
||||||
|
- CHECK-ограничение: `type IN ('expense', 'income', 'transfer')`.
|
||||||
|
|
||||||
|
Рекомендуемый DDL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE categories (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE categories
|
||||||
|
ADD CONSTRAINT chk_categories_type
|
||||||
|
CHECK (type IN ('expense', 'income', 'transfer'));
|
||||||
|
```
|
||||||
|
|
||||||
|
Начальный набор из 23 категорий заполняется seed-миграцией (см. `category.md`).
|
||||||
|
|
||||||
## Таблица `transactions`
|
## Таблица `transactions`
|
||||||
|
|
||||||
Предназначение: хранение всех операций по счетам с привязкой к `accounts`.
|
Предназначение: хранение всех операций по счетам с привязкой к `accounts` и `categories`.
|
||||||
|
|
||||||
Структура:
|
Структура:
|
||||||
|
|
||||||
- `id BIGSERIAL PRIMARY KEY` — внутренний идентификатор транзакции.
|
- `id BIGSERIAL PRIMARY KEY` — внутренний идентификатор транзакции.
|
||||||
- `account_id BIGINT NOT NULL` — внешний ключ (FK) на `accounts(id)`;
|
- `account_id BIGINT NOT NULL REFERENCES accounts(id)` —
|
||||||
каждая транзакция жёстко привязана к одному счёту.
|
каждая транзакция жёстко привязана к одному счёту.
|
||||||
- `operation_at TIMESTAMPTZ NOT NULL` — дата и время операции.
|
- `operation_at TIMESTAMPTZ NOT NULL` — дата и время операции.
|
||||||
- `amount_signed BIGINT NOT NULL` — сумма операции в копейках; знак отражает тип движения (приход/расход).
|
- `amount_signed BIGINT NOT NULL` — сумма операции в копейках; знак отражает тип движения (приход/расход).
|
||||||
@@ -57,15 +97,19 @@ CREATE UNIQUE INDEX ux_accounts_bank_number
|
|||||||
- `"expense"` — расход;
|
- `"expense"` — расход;
|
||||||
- `"transfer"` — перевод между своими счетами / на другие свои счета.
|
- `"transfer"` — перевод между своими счетами / на другие свои счета.
|
||||||
- `fingerprint TEXT NOT NULL` — вычисляемый хэш для обеспечения идемпотентности импорта.
|
- `fingerprint TEXT NOT NULL` — вычисляемый хэш для обеспечения идемпотентности импорта.
|
||||||
- `category_id BIGINT` — ссылка на таблицу категорий (будет определена позже).
|
- `category_id BIGINT REFERENCES categories(id)` — ссылка на категорию; `NULL` для некатегоризированных транзакций.
|
||||||
|
- `is_category_confirmed BOOLEAN NOT NULL DEFAULT FALSE` —
|
||||||
|
признак того, что категория подтверждена пользователем (явно или неявно через правило без `requires_confirmation`).
|
||||||
|
- `comment TEXT` — пользовательский комментарий к транзакции.
|
||||||
- `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` — время создания записи в БД.
|
- `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` — время создания записи в БД.
|
||||||
- `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` — время последнего обновления записи.
|
- `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` — время последнего обновления записи.
|
||||||
|
|
||||||
Ограничения и индексы:
|
Ограничения и индексы:
|
||||||
|
|
||||||
- Внешний ключ `account_id` ссылается на `accounts(id)` и обеспечивает целостность (нельзя создать транзакцию для несуществующего счёта).
|
- Внешний ключ `account_id` ссылается на `accounts(id)`.
|
||||||
|
- Внешний ключ `category_id` ссылается на `categories(id)`.
|
||||||
- Уникальный индекс `(account_id, fingerprint)` обеспечивает идемпотентность: одна и та же операция не может быть загружена дважды.
|
- Уникальный индекс `(account_id, fingerprint)` обеспечивает идемпотентность: одна и та же операция не может быть загружена дважды.
|
||||||
- Опционально — CHECK-ограничение на поле `direction`.
|
- CHECK-ограничение: `direction IN ('income', 'expense', 'transfer')`.
|
||||||
|
|
||||||
Рекомендуемый DDL:
|
Рекомендуемый DDL:
|
||||||
|
|
||||||
@@ -79,7 +123,9 @@ CREATE TABLE transactions (
|
|||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
direction TEXT NOT NULL,
|
direction TEXT NOT NULL,
|
||||||
fingerprint TEXT NOT NULL,
|
fingerprint TEXT NOT NULL,
|
||||||
category_id BIGINT,
|
category_id BIGINT REFERENCES categories(id),
|
||||||
|
is_category_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
comment TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
@@ -105,7 +151,7 @@ ALTER TABLE transactions
|
|||||||
- `"starts_with"` — строка начинается с шаблона;
|
- `"starts_with"` — строка начинается с шаблона;
|
||||||
- `"regex"` — регулярное выражение (формируется и/или проверяется в коде на основе пользовательского ввода).
|
- `"regex"` — регулярное выражение (формируется и/или проверяется в коде на основе пользовательского ввода).
|
||||||
- MVP: при создании правила принимается только `"contains"`. Расширение до `"starts_with"` | `"regex"` запланировано.
|
- MVP: при создании правила принимается только `"contains"`. Расширение до `"starts_with"` | `"regex"` запланировано.
|
||||||
- `category_id BIGINT NOT NULL` — ссылка на категорию (таблица категорий будет описана отдельно).
|
- `category_id BIGINT NOT NULL REFERENCES categories(id)` — ссылка на категорию.
|
||||||
- `priority INT NOT NULL DEFAULT 0` — приоритет правила; чем выше число, тем раньше правило применяется при конфликте.
|
- `priority INT NOT NULL DEFAULT 0` — приоритет правила; чем выше число, тем раньше правило применяется при конфликте.
|
||||||
- `requires_confirmation BOOLEAN NOT NULL DEFAULT FALSE` —
|
- `requires_confirmation BOOLEAN NOT NULL DEFAULT FALSE` —
|
||||||
если `TRUE`, транзакции, категоризированные этим правилом, получают `is_category_confirmed = FALSE`
|
если `TRUE`, транзакции, категоризированные этим правилом, получают `is_category_confirmed = FALSE`
|
||||||
@@ -127,7 +173,7 @@ CREATE TABLE category_rules (
|
|||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
pattern TEXT NOT NULL,
|
pattern TEXT NOT NULL,
|
||||||
match_type TEXT NOT NULL,
|
match_type TEXT NOT NULL,
|
||||||
category_id BIGINT NOT NULL,
|
category_id BIGINT NOT NULL REFERENCES categories(id),
|
||||||
priority INT NOT NULL DEFAULT 0,
|
priority INT NOT NULL DEFAULT 0,
|
||||||
requires_confirmation BOOLEAN NOT NULL DEFAULT FALSE,
|
requires_confirmation BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
@@ -135,19 +181,46 @@ CREATE TABLE category_rules (
|
|||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Таблица `sessions`
|
||||||
|
|
||||||
|
Предназначение: хранение серверных сессий авторизации с поддержкой таймаута по бездействию.
|
||||||
|
|
||||||
|
Структура:
|
||||||
|
|
||||||
|
- `id TEXT PRIMARY KEY` — идентификатор сессии (UUID).
|
||||||
|
- `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` — время создания сессии.
|
||||||
|
- `last_activity_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` — время последней активности; обновляется при каждом запросе с действительной сессией.
|
||||||
|
- `is_active BOOLEAN NOT NULL DEFAULT TRUE` — флаг активности; устанавливается в `FALSE` при логауте или истечении таймаута.
|
||||||
|
|
||||||
|
Рекомендуемый DDL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_activity_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Таблица `budgets` (после MVP)
|
||||||
|
|
||||||
|
> **Не входит в MVP.** Таблица запланирована для поддержки бюджетов/лимитов по категориям
|
||||||
|
> на будущих этапах. Подробности см. в `analytics.md`, секция 5.3.
|
||||||
|
|
||||||
## Взаимосвязь JSON → БД при импорте
|
## Взаимосвязь JSON → БД при импорте
|
||||||
|
|
||||||
1. По полям `bank` и `statement.accountNumber` ищется или создаётся запись в `accounts`.
|
1. По полям `bank` и `statement.accountNumber` ищется или создаётся запись в `accounts`.
|
||||||
Если счёт создан впервые, `alias = NULL`.
|
Если счёт создан впервые, `alias = NULL`.
|
||||||
2. Для каждой транзакции из `transactions`:
|
2. Для каждой транзакции из `transactions`:
|
||||||
- преобразуются суммы в копейки (`amountSigned`, `commission` → `BIGINT`);
|
- суммы в JSON 1.0 уже в копейках — записываются как есть;
|
||||||
- вычисляется `fingerprint` на основе комбинации полей (например,
|
- вычисляется `fingerprint` на основе комбинации полей (например,
|
||||||
`accountNumber + operationAt + amountSigned + commission + normalizedDescription`);
|
`accountNumber + operationAt + amountSigned + commission + normalizedDescription`);
|
||||||
- определяется `direction`:
|
- определяется `direction`:
|
||||||
- `amountSigned > 0` → `"income"`;
|
- `amountSigned > 0` → `"income"`;
|
||||||
- `amountSigned < 0` → `"expense"`;
|
- `amountSigned < 0` → `"expense"`;
|
||||||
- `"transfer"` — определяется по фиксированным ключевым фразам банка в `description`
|
- `"transfer"` — определяется по фиксированным ключевым фразам банка в `description`
|
||||||
(например, для ВТБ: "Перевод", "между счетами" и т.п.).
|
(например, для ВТБ: "Перевод между своими счетами", "Внутри ВТБ" и т.п.).
|
||||||
Если ключевые фразы не сработали — остаётся `"income"` / `"expense"` по знаку суммы;
|
Если ключевые фразы не сработали — остаётся `"income"` / `"expense"` по знаку суммы;
|
||||||
- выполняется попытка вставки в `transactions`;
|
- выполняется попытка вставки в `transactions`;
|
||||||
- при срабатывании уникального ограничения `(account_id, fingerprint)` запись считается дубликатом и пропускается;
|
- при срабатывании уникального ограничения `(account_id, fingerprint)` запись считается дубликатом и пропускается;
|
||||||
|
|||||||
@@ -1,205 +0,0 @@
|
|||||||
"{""id"": 11, ""rawSms"": ""Оплата 4606.11р Карта*4215 RNAZK MJ056 ROS Баланс 15942.54р 11:59"", ""rawJson"": {""action"": ""payment"", ""amount"": 4606.11, ""balance"": 15942.54, ""raw_sms"": ""Оплата 4606.11р Карта*4215 RNAZK MJ056 ROS Баланс 15942.54р 11:59"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 4606.11р Карта*4215 RNAZK MJ056 ROS Баланс 15942.54р 11:59"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-02T08:59:25.474Z"", ""sms_contact"": """", ""signed_amount"": -4606.11}, ""smsText"": ""Оплата 4606.11р Карта*4215 RNAZK MJ056 ROS Баланс 15942.54р 11:59"", ""category"": ""Авто"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-02T08:59:25.474+00:00"", ""counterparty"": ""Роснефть"", ""signedAmount"": -4606.11, ""humanVerified"": false, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 12, ""rawSms"": ""Оплата 1250р Карта*2249 mkad Баланс 14692.54р 12:08"", ""rawJson"": {""action"": ""payment"", ""amount"": 1250, ""balance"": 14692.54, ""raw_sms"": ""Оплата 1250р Карта*2249 mkad Баланс 14692.54р 12:08"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 1250р Карта*2249 mkad Баланс 14692.54р 12:08"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-02T09:08:35.206Z"", ""sms_contact"": """", ""signed_amount"": -1250}, ""smsText"": ""Оплата 1250р Карта*2249 mkad Баланс 14692.54р 12:08"", ""category"": ""Авто"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-02T09:08:35.206+00:00"", ""counterparty"": ""Автомойка"", ""signedAmount"": -1250.00, ""humanVerified"": false, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 13, ""rawSms"": ""Оплата 1194.55р Карта*4215 MAGNIT MM AVIAD Баланс 13497.99р 13:20"", ""rawJson"": {""action"": ""payment"", ""amount"": 1194.55, ""balance"": 13497.99, ""raw_sms"": ""Оплата 1194.55р Карта*4215 MAGNIT MM AVIAD Баланс 13497.99р 13:20"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 1194.55р Карта*4215 MAGNIT MM AVIAD Баланс 13497.99р 13:20"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-02T10:20:56.565Z"", ""sms_contact"": """", ""signed_amount"": -1194.55}, ""smsText"": ""Оплата 1194.55р Карта*4215 MAGNIT MM AVIAD Баланс 13497.99р 13:20"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-02T10:20:56.565+00:00"", ""counterparty"": ""Магнит"", ""signedAmount"": -1194.55, ""humanVerified"": false, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 14, ""rawSms"": ""Оплата 610.94р Карта*2249 DOSTAVKA IZ PYA Баланс 12887.05р 16:41"", ""rawJson"": {""action"": ""payment"", ""amount"": 610.94, ""balance"": 12887.05, ""raw_sms"": ""Оплата 610.94р Карта*2249 DOSTAVKA IZ PYA Баланс 12887.05р 16:41"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 610.94р Карта*2249 DOSTAVKA IZ PYA Баланс 12887.05р 16:41"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-02T13:41:40.860Z"", ""sms_contact"": """", ""signed_amount"": -610.94}, ""smsText"": ""Оплата 610.94р Карта*2249 DOSTAVKA IZ PYA Баланс 12887.05р 16:41"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-02T13:41:40.86+00:00"", ""counterparty"": ""Пятёрочка Доставка"", ""signedAmount"": -610.94, ""humanVerified"": false, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 15, ""rawSms"": ""Отмена: Оплата 610.94р Карта*2249 DOSTAVKA IZ PYA Баланс 13497.99р 16:41"", ""rawJson"": {""action"": ""reversal"", ""amount"": 610.94, ""balance"": 13497.99, ""raw_sms"": ""Отмена: Оплата 610.94р Карта*2249 DOSTAVKA IZ PYA Баланс 13497.99р 16:41"", ""currency"": ""RUB"", ""sms_text"": ""Отмена: Оплата 610.94р Карта*2249 DOSTAVKA IZ PYA Баланс 13497.99р 16:41"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-02T13:51:46.634Z"", ""sms_contact"": """", ""signed_amount"": 610.94}, ""smsText"": ""Отмена: Оплата 610.94р Карта*2249 DOSTAVKA IZ PYA Баланс 13497.99р 16:41"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-02T13:51:46.634+00:00"", ""counterparty"": ""Пятёрочка Доставка"", ""signedAmount"": 610.94, ""humanVerified"": false, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 16, ""rawSms"": ""Оплата 342р Карта*4215 IP SHVETSOV A.A Баланс 13155.99р 18:28"", ""rawJson"": {""action"": ""payment"", ""amount"": 342, ""balance"": 13155.99, ""raw_sms"": ""Оплата 342р Карта*4215 IP SHVETSOV A.A Баланс 13155.99р 18:28"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 342р Карта*4215 IP SHVETSOV A.A Баланс 13155.99р 18:28"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-02T15:28:47.636Z"", ""sms_contact"": """", ""signed_amount"": -342}, ""smsText"": ""Оплата 342р Карта*4215 IP SHVETSOV A.A Баланс 13155.99р 18:28"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-02T15:28:47.636+00:00"", ""counterparty"": ""ИП Швецов"", ""signedAmount"": -342.00, ""humanVerified"": false, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 17, ""rawSms"": ""Оплата 429.95р Карта*4215 PYATEROCHKA Баланс 12726.04р 18:33"", ""rawJson"": {""action"": ""payment"", ""amount"": 429.95, ""balance"": 12726.04, ""raw_sms"": ""Оплата 429.95р Карта*4215 PYATEROCHKA Баланс 12726.04р 18:33"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 429.95р Карта*4215 PYATEROCHKA Баланс 12726.04р 18:33"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-02T15:33:53.557Z"", ""sms_contact"": """", ""signed_amount"": -429.95}, ""smsText"": ""Оплата 429.95р Карта*4215 PYATEROCHKA Баланс 12726.04р 18:33"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-02T15:33:53.557+00:00"", ""counterparty"": ""Пятёрочка"", ""signedAmount"": -429.95, ""humanVerified"": false, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 21, ""rawSms"": ""Снятие 5000р Карта*4215 D. 37/1, MKR. B Баланс 7726.04р 14:45"", ""rawJson"": {""action"": ""unknown"", ""amount"": 5000, ""balance"": 7726.04, ""raw_sms"": ""Снятие 5000р Карта*4215 D. 37/1, MKR. B Баланс 7726.04р 14:45"", ""currency"": ""RUB"", ""sms_text"": ""Снятие 5000р Карта*4215 D. 37/1, MKR. B Баланс 7726.04р 14:45"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-03T11:45:36.294Z"", ""sms_contact"": """", ""signed_amount"": 5000}, ""smsText"": ""Снятие 5000р Карта*4215 D. 37/1, MKR. B Баланс 7726.04р 14:45"", ""category"": ""Наличные"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-03T11:45:36.294+00:00"", ""counterparty"": ""Банкомат"", ""signedAmount"": 5000.00, ""humanVerified"": false, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 22, ""rawSms"": ""Перевод 650р Счет*5611 МегаФон. Баланс 7076.04р 21:36"", ""rawJson"": {""action"": ""transfer"", ""amount"": 650, ""balance"": 7076.04, ""raw_sms"": ""Перевод 650р Счет*5611 МегаФон. Баланс 7076.04р 21:36"", ""currency"": ""RUB"", ""sms_text"": ""Перевод 650р Счет*5611 МегаФон. Баланс 7076.04р 21:36"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-03T18:36:07.839Z"", ""sms_contact"": """", ""signed_amount"": -650}, ""smsText"": ""Перевод 650р Счет*5611 МегаФон. Баланс 7076.04р 21:36"", ""category"": ""Подписки"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-03T18:36:07.839+00:00"", ""counterparty"": ""МегаФон"", ""signedAmount"": -650.00, ""humanVerified"": false, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 23, ""rawSms"": ""Оплата 1010р Карта*4215 HaNoi Deli Баланс 5270.70р 11:51"", ""rawJson"": {""action"": ""payment"", ""amount"": 1010, ""balance"": 5270.7, ""raw_sms"": ""Оплата 1010р Карта*4215 HaNoi Deli Баланс 5270.70р 11:51"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 1010р Карта*4215 HaNoi Deli Баланс 5270.70р 11:51"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-04T08:51:41.825Z"", ""sms_contact"": """", ""signed_amount"": -1010}, ""smsText"": ""Оплата 1010р Карта*4215 HaNoi Deli Баланс 5270.70р 11:51"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-04T08:51:41.825+00:00"", ""counterparty"": ""HaNoi Deli"", ""signedAmount"": -1010.00, ""humanVerified"": false, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 24, ""rawSms"": ""Оплата 700р Карта*4215 Vetnamskoe kafe Баланс 4570.70р 11:54"", ""rawJson"": {""action"": ""payment"", ""amount"": 700, ""balance"": 4570.7, ""raw_sms"": ""Оплата 700р Карта*4215 Vetnamskoe kafe Баланс 4570.70р 11:54"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 700р Карта*4215 Vetnamskoe kafe Баланс 4570.70р 11:54"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-04T08:55:02.801Z"", ""sms_contact"": """", ""signed_amount"": -700}, ""smsText"": ""Оплата 700р Карта*4215 Vetnamskoe kafe Баланс 4570.70р 11:54"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-04T08:55:02.801+00:00"", ""counterparty"": ""Вьетнамское кафе"", ""signedAmount"": -700.00, ""humanVerified"": false, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 25, ""rawSms"": ""Оплата 100р Карта*8214 w_games Баланс 4470.70р 09:36"", ""rawJson"": {""action"": ""payment"", ""amount"": 100, ""balance"": 4470.7, ""raw_sms"": ""Оплата 100р Карта*8214 w_games Баланс 4470.70р 09:36"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 100р Карта*8214 w_games Баланс 4470.70р 09:36"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-05T06:37:00.782Z"", ""sms_contact"": """", ""signed_amount"": -100}, ""smsText"": ""Оплата 100р Карта*8214 w_games Баланс 4470.70р 09:36"", ""category"": ""Развлечения"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-05T06:37:00.782+00:00"", ""counterparty"": ""W Games"", ""signedAmount"": -100.00, ""humanVerified"": false, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 54, ""rawSms"": ""Оплата 670р Карта*2249 OZON Баланс 3800.70р 09:33"", ""rawJson"": {""action"": ""payment"", ""amount"": 670, ""balance"": 3800.7, ""raw_sms"": ""Оплата 670р Карта*2249 OZON Баланс 3800.70р 09:33"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 670р Карта*2249 OZON Баланс 3800.70р 09:33"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-06T06:33:34.637Z"", ""sms_contact"": """", ""signed_amount"": -670}, ""smsText"": ""Оплата 670р Карта*2249 OZON Баланс 3800.70р 09:33"", ""category"": ""Здоровье"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-06T06:33:34.637+00:00"", ""counterparty"": ""Озон"", ""signedAmount"": -670.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 55, ""rawSms"": ""Оплата 1265.95р Карта*4215 PYATEROCHKA Баланс 7534.75р 10:23"", ""rawJson"": {""action"": ""payment"", ""amount"": 1265.95, ""balance"": 7534.75, ""raw_sms"": ""Оплата 1265.95р Карта*4215 PYATEROCHKA Баланс 7534.75р 10:23"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 1265.95р Карта*4215 PYATEROCHKA Баланс 7534.75р 10:23"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-06T07:23:31.475Z"", ""sms_contact"": """", ""signed_amount"": -1265.95}, ""smsText"": ""Оплата 1265.95р Карта*4215 PYATEROCHKA Баланс 7534.75р 10:23"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-06T07:23:31.475+00:00"", ""counterparty"": ""Пятерочка"", ""signedAmount"": -1265.95, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 56, ""rawSms"": ""Оплата 1835р Карта*4215 IP SHVETSOV A.A Баланс 5699.75р 10:28"", ""rawJson"": {""action"": ""payment"", ""amount"": 1835, ""balance"": 5699.75, ""raw_sms"": ""Оплата 1835р Карта*4215 IP SHVETSOV A.A Баланс 5699.75р 10:28"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 1835р Карта*4215 IP SHVETSOV A.A Баланс 5699.75р 10:28"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-06T07:28:24.491Z"", ""sms_contact"": """", ""signed_amount"": -1835}, ""smsText"": ""Оплата 1835р Карта*4215 IP SHVETSOV A.A Баланс 5699.75р 10:28"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-06T07:28:24.491+00:00"", ""counterparty"": ""Рынок"", ""signedAmount"": -1835.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 57, ""rawSms"": ""Оплата 951.60р Карта*8214 ZOOMAGAZIN CHET Баланс 4748.15р 11:04"", ""rawJson"": {""action"": ""payment"", ""amount"": 951.6, ""balance"": 4748.15, ""raw_sms"": ""Оплата 951.60р Карта*8214 ZOOMAGAZIN CHET Баланс 4748.15р 11:04"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 951.60р Карта*8214 ZOOMAGAZIN CHET Баланс 4748.15р 11:04"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-06T08:05:02.145Z"", ""sms_contact"": """", ""signed_amount"": -951.6}, ""smsText"": ""Оплата 951.60р Карта*8214 ZOOMAGAZIN CHET Баланс 4748.15р 11:04"", ""category"": ""Зоотовары"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-06T08:05:02.145+00:00"", ""counterparty"": ""Четыре лапы"", ""signedAmount"": -951.60, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 58, ""rawSms"": ""Оплата 1293.80р Карта*4215 MAGNIT MM OSHMY Баланс 3454.35р 11:29"", ""rawJson"": {""action"": ""payment"", ""amount"": 1293.8, ""balance"": 3454.35, ""raw_sms"": ""Оплата 1293.80р Карта*4215 MAGNIT MM OSHMY Баланс 3454.35р 11:29"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 1293.80р Карта*4215 MAGNIT MM OSHMY Баланс 3454.35р 11:29"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-06T08:29:32.171Z"", ""sms_contact"": """", ""signed_amount"": -1293.8}, ""smsText"": ""Оплата 1293.80р Карта*4215 MAGNIT MM OSHMY Баланс 3454.35р 11:29"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-06T08:29:32.171+00:00"", ""counterparty"": ""Магнит"", ""signedAmount"": -1293.80, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 59, ""rawSms"": ""Перевод 500р Счет*5611 Ивченко Н. Баланс 2954.35р 19:08"", ""rawJson"": {""action"": ""transfer"", ""amount"": 500, ""balance"": 2954.35, ""raw_sms"": ""Перевод 500р Счет*5611 Ивченко Н. Баланс 2954.35р 19:08"", ""currency"": ""RUB"", ""sms_text"": ""Перевод 500р Счет*5611 Ивченко Н. Баланс 2954.35р 19:08"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-06T16:08:43.385Z"", ""sms_contact"": """", ""signed_amount"": -500}, ""smsText"": ""Перевод 500р Счет*5611 Ивченко Н. Баланс 2954.35р 19:08"", ""category"": ""Спорт"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-06T16:08:43.385+00:00"", ""counterparty"": ""Буран"", ""signedAmount"": -500.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 61, ""rawSms"": ""Оплата 2539р Карта*2249 OTO*Telegram Баланс 415.35р 23:23"", ""rawJson"": {""action"": ""payment"", ""amount"": 2539, ""balance"": 415.35, ""raw_sms"": ""Оплата 2539р Карта*2249 OTO*Telegram Баланс 415.35р 23:23"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 2539р Карта*2249 OTO*Telegram Баланс 415.35р 23:23"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-06T20:23:42.223Z"", ""sms_contact"": """", ""signed_amount"": -2539}, ""smsText"": ""Оплата 2539р Карта*2249 OTO*Telegram Баланс 415.35р 23:23"", ""category"": ""Подписка"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-06T20:23:42.223+00:00"", ""counterparty"": ""Не определён"", ""signedAmount"": -2539.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 73, ""rawSms"": ""Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 5414.35р 10:47"", ""rawJson"": {""action"": ""payment"", ""amount"": 1, ""balance"": 5414.35, ""raw_sms"": ""Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 5414.35р 10:47"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 5414.35р 10:47"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-07T07:48:01.526Z"", ""sms_contact"": """", ""signed_amount"": -1}, ""smsText"": ""Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 5414.35р 10:47"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-07T07:48:01.526+00:00"", ""counterparty"": ""Перекрёсток Доставка"", ""signedAmount"": -1.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-07T10:48:00+00:00""}"
|
|
||||||
"{""id"": 74, ""rawSms"": ""Отмена: Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 5415.35р 10:47"", ""rawJson"": {""action"": ""reversal"", ""amount"": 1, ""balance"": 5415.35, ""raw_sms"": ""Отмена: Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 5415.35р 10:47"", ""currency"": ""RUB"", ""sms_text"": ""Отмена: Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 5415.35р 10:47"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-07T07:48:02.711Z"", ""sms_contact"": """", ""signed_amount"": 1}, ""smsText"": ""Отмена: Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 5415.35р 10:47"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-07T07:48:02.711+00:00"", ""counterparty"": ""Перекрёсток Доставка"", ""signedAmount"": 1.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-07T10:48:00+00:00""}"
|
|
||||||
"{""id"": 75, ""rawSms"": ""Оплата 595.67р Карта*2249 DOSTAVKA PEREKR Баланс 4819.68р 10:53"", ""rawJson"": {""action"": ""payment"", ""amount"": 595.67, ""balance"": 4819.68, ""raw_sms"": ""Оплата 595.67р Карта*2249 DOSTAVKA PEREKR Баланс 4819.68р 10:53"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 595.67р Карта*2249 DOSTAVKA PEREKR Баланс 4819.68р 10:53"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-07T07:53:09.728Z"", ""sms_contact"": """", ""signed_amount"": -595.67}, ""smsText"": ""Оплата 595.67р Карта*2249 DOSTAVKA PEREKR Баланс 4819.68р 10:53"", ""category"": ""продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-07T07:53:09.728+00:00"", ""counterparty"": ""перекресток"", ""signedAmount"": -595.67, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 76, ""rawSms"": ""Оплата 132.98р Карта*4215 PYATEROCHKA Баланс 4686.70р 10:56"", ""rawJson"": {""action"": ""payment"", ""amount"": 132.98, ""balance"": 4686.7, ""raw_sms"": ""Оплата 132.98р Карта*4215 PYATEROCHKA Баланс 4686.70р 10:56"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 132.98р Карта*4215 PYATEROCHKA Баланс 4686.70р 10:56"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-07T07:57:05.422Z"", ""sms_contact"": """", ""signed_amount"": -132.98}, ""smsText"": ""Оплата 132.98р Карта*4215 PYATEROCHKA Баланс 4686.70р 10:56"", ""category"": ""продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-07T07:57:05.422+00:00"", ""counterparty"": ""пятерочка"", ""signedAmount"": -132.98, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 77, ""rawSms"": ""Списание 550р Счет*5611 МИХАИЛ К. Баланс 4136.70р 10:00"", ""rawJson"": {""action"": ""writeoff"", ""amount"": 550, ""balance"": 4136.7, ""raw_sms"": ""Списание 550р Счет*5611 МИХАИЛ К. Баланс 4136.70р 10:00"", ""currency"": ""RUB"", ""sms_text"": ""Списание 550р Счет*5611 МИХАИЛ К. Баланс 4136.70р 10:00"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-08T07:00:54.503Z"", ""sms_contact"": """", ""signed_amount"": -550}, ""smsText"": ""Списание 550р Счет*5611 МИХАИЛ К. Баланс 4136.70р 10:00"", ""category"": ""Авто"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-08T07:00:54.503+00:00"", ""counterparty"": ""Михаил К."", ""signedAmount"": -550.00, ""humanVerified"": false, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 78, ""rawSms"": ""Оплата 89.99р Карта*8214 PEREKRESTOKVALP Баланс 4046.71р 11:28"", ""rawJson"": {""action"": ""payment"", ""amount"": 89.99, ""balance"": 4046.71, ""raw_sms"": ""Оплата 89.99р Карта*8214 PEREKRESTOKVALP Баланс 4046.71р 11:28"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 89.99р Карта*8214 PEREKRESTOKVALP Баланс 4046.71р 11:28"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-08T08:29:03.706Z"", ""sms_contact"": """", ""signed_amount"": -89.99}, ""smsText"": ""Оплата 89.99р Карта*8214 PEREKRESTOKVALP Баланс 4046.71р 11:28"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-08T08:29:03.706+00:00"", ""counterparty"": ""Перекресток"", ""signedAmount"": -89.99, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 79, ""rawSms"": ""Списание 500р Счет*5611 Рубен А. Баланс 3546.71р 11:52"", ""rawJson"": {""action"": ""writeoff"", ""amount"": 500, ""balance"": 3546.71, ""raw_sms"": ""Списание 500р Счет*5611 Рубен А. Баланс 3546.71р 11:52"", ""currency"": ""RUB"", ""sms_text"": ""Списание 500р Счет*5611 Рубен А. Баланс 3546.71р 11:52"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-08T08:52:25.687Z"", ""sms_contact"": """", ""signed_amount"": -500}, ""smsText"": ""Списание 500р Счет*5611 Рубен А. Баланс 3546.71р 11:52"", ""category"": ""Спорт"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-08T08:52:25.687+00:00"", ""counterparty"": ""Буран"", ""signedAmount"": -500.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 80, ""rawSms"": ""Оплата 205.98р Карта*4215 \"" VP 32536 Баланс 3340.73р 12:11"", ""rawJson"": {""action"": ""payment"", ""amount"": 205.98, ""balance"": 3340.73, ""raw_sms"": ""Оплата 205.98р Карта*4215 \"" VP 32536 Баланс 3340.73р 12:11"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 205.98р Карта*4215 \"" VP 32536 Баланс 3340.73р 12:11"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-08T09:12:01.930Z"", ""sms_contact"": """", ""signed_amount"": -205.98}, ""smsText"": ""Оплата 205.98р Карта*4215 \"" VP 32536 Баланс 3340.73р 12:11"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-08T09:12:01.93+00:00"", ""counterparty"": ""Пятерочка"", ""signedAmount"": -205.98, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 81, ""rawSms"": ""Оплата 3441.60р Карта*4215 CHETYRE LAPY Баланс 4899.13р 14:08"", ""rawJson"": {""action"": ""payment"", ""amount"": 3441.6, ""balance"": 4899.13, ""raw_sms"": ""Оплата 3441.60р Карта*4215 CHETYRE LAPY Баланс 4899.13р 14:08"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 3441.60р Карта*4215 CHETYRE LAPY Баланс 4899.13р 14:08"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-08T11:08:21.442Z"", ""sms_contact"": """", ""signed_amount"": -3441.6}, ""smsText"": ""Оплата 3441.60р Карта*4215 CHETYRE LAPY Баланс 4899.13р 14:08"", ""category"": ""Животные"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-08T11:08:21.442+00:00"", ""counterparty"": ""Четыре лапы"", ""signedAmount"": -3441.60, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 82, ""rawSms"": ""Оплата 295р Карта*4215 MASTERKOM 1 Баланс 4604.13р 14:23"", ""rawJson"": {""action"": ""payment"", ""amount"": 295, ""balance"": 4604.13, ""raw_sms"": ""Оплата 295р Карта*4215 MASTERKOM 1 Баланс 4604.13р 14:23"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 295р Карта*4215 MASTERKOM 1 Баланс 4604.13р 14:23"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-08T11:23:13.541Z"", ""sms_contact"": """", ""signed_amount"": -295}, ""smsText"": ""Оплата 295р Карта*4215 MASTERKOM 1 Баланс 4604.13р 14:23"", ""category"": ""Ремонт"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-08T11:23:13.541+00:00"", ""counterparty"": ""Стройматериалы"", ""signedAmount"": -295.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 84, ""rawSms"": ""Оплата 3480р Карта*2249 OZON Баланс 1124.13р 20:15"", ""rawJson"": {""action"": ""payment"", ""amount"": 3480, ""balance"": 1124.13, ""raw_sms"": ""Оплата 3480р Карта*2249 OZON Баланс 1124.13р 20:15"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 3480р Карта*2249 OZON Баланс 1124.13р 20:15"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-08T17:15:26.647Z"", ""sms_contact"": """", ""signed_amount"": -3480}, ""smsText"": ""Оплата 3480р Карта*2249 OZON Баланс 1124.13р 20:15"", ""category"": ""Дом"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-08T17:15:26.647+00:00"", ""counterparty"": ""Озон"", ""signedAmount"": -3480.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 85, ""rawSms"": ""Оплата 209р Карта*2249 OZON Баланс 915.13р 20:56"", ""rawJson"": {""action"": ""payment"", ""amount"": 209, ""balance"": 915.13, ""raw_sms"": ""Оплата 209р Карта*2249 OZON Баланс 915.13р 20:56"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 209р Карта*2249 OZON Баланс 915.13р 20:56"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-08T17:56:32.852Z"", ""sms_contact"": """", ""signed_amount"": -209}, ""smsText"": ""Оплата 209р Карта*2249 OZON Баланс 915.13р 20:56"", ""category"": ""Ремонт"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-08T17:56:32.852+00:00"", ""counterparty"": ""Озон"", ""signedAmount"": -209.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 87, ""rawSms"": ""Оплата 446р Карта*8214 MSKAPT 708 Баланс 469.13р 20:30"", ""rawJson"": {""action"": ""payment"", ""amount"": 446, ""balance"": 469.13, ""raw_sms"": ""Оплата 446р Карта*8214 MSKAPT 708 Баланс 469.13р 20:30"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 446р Карта*8214 MSKAPT 708 Баланс 469.13р 20:30"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-09T17:31:04.549Z"", ""sms_contact"": """", ""signed_amount"": -446}, ""smsText"": ""Оплата 446р Карта*8214 MSKAPT 708 Баланс 469.13р 20:30"", ""category"": ""Здоровье"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-09T17:31:04.549+00:00"", ""counterparty"": ""Аптека"", ""signedAmount"": -446.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 88, ""rawSms"": ""Оплата 271.97р Карта*8214 VERNYJ 1473 Баланс 197.16р 20:34"", ""rawJson"": {""action"": ""payment"", ""amount"": 271.97, ""balance"": 197.16, ""raw_sms"": ""Оплата 271.97р Карта*8214 VERNYJ 1473 Баланс 197.16р 20:34"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 271.97р Карта*8214 VERNYJ 1473 Баланс 197.16р 20:34"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-09T17:34:35.096Z"", ""sms_contact"": """", ""signed_amount"": -271.97}, ""smsText"": ""Оплата 271.97р Карта*8214 VERNYJ 1473 Баланс 197.16р 20:34"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-09T17:34:35.096+00:00"", ""counterparty"": ""Верный"", ""signedAmount"": -271.97, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 90, ""rawSms"": ""Перевод 500р Счет*5611 МТС Mobile. Баланс 4697.16р 10:06"", ""rawJson"": {""action"": ""transfer"", ""amount"": 500, ""balance"": 4697.16, ""raw_sms"": ""Перевод 500р Счет*5611 МТС Mobile. Баланс 4697.16р 10:06"", ""currency"": ""RUB"", ""sms_text"": ""Перевод 500р Счет*5611 МТС Mobile. Баланс 4697.16р 10:06"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-10T07:06:31.816Z"", ""sms_contact"": """", ""signed_amount"": -500}, ""smsText"": ""Перевод 500р Счет*5611 МТС Mobile. Баланс 4697.16р 10:06"", ""category"": ""Подписки"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-10T07:06:31.816+00:00"", ""counterparty"": ""МТС"", ""signedAmount"": -500.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 91, ""rawSms"": ""Оплата 4650р Карта*4215 IP SHARAFETDINO Баланс 5047.16р 12:19"", ""rawJson"": {""action"": ""payment"", ""amount"": 4650, ""balance"": 5047.16, ""raw_sms"": ""Оплата 4650р Карта*4215 IP SHARAFETDINO Баланс 5047.16р 12:19"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 4650р Карта*4215 IP SHARAFETDINO Баланс 5047.16р 12:19"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-10T09:19:18.679Z"", ""sms_contact"": """", ""signed_amount"": -4650}, ""smsText"": ""Оплата 4650р Карта*4215 IP SHARAFETDINO Баланс 5047.16р 12:19"", ""category"": ""Арчи"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-10T09:19:18.679+00:00"", ""counterparty"": ""IP SHARAFETDINO"", ""signedAmount"": -4650.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 92, ""rawSms"": ""Оплата 500р Карта*2249 VANYAVPN Баланс 4547.16р 10:28"", ""rawJson"": {""action"": ""payment"", ""amount"": 500, ""balance"": 4547.16, ""raw_sms"": ""Оплата 500р Карта*2249 VANYAVPN Баланс 4547.16р 10:28"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 500р Карта*2249 VANYAVPN Баланс 4547.16р 10:28"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-11T07:28:41.837Z"", ""sms_contact"": """", ""signed_amount"": -500}, ""smsText"": ""Оплата 500р Карта*2249 VANYAVPN Баланс 4547.16р 10:28"", ""category"": ""Подписки"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-11T07:28:41.837+00:00"", ""counterparty"": ""Ванивпн"", ""signedAmount"": -500.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-11T10:29:00+00:00""}"
|
|
||||||
"{""id"": 93, ""rawSms"": ""Оплата 2075р Карта*2249 YM*sutochno.ru Баланс 2472.16р 11:30"", ""rawJson"": {""action"": ""payment"", ""amount"": 2075, ""balance"": 2472.16, ""raw_sms"": ""Оплата 2075р Карта*2249 YM*sutochno.ru Баланс 2472.16р 11:30"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 2075р Карта*2249 YM*sutochno.ru Баланс 2472.16р 11:30"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-11T08:30:52.559Z"", ""sms_contact"": """", ""signed_amount"": -2075}, ""smsText"": ""Оплата 2075р Карта*2249 YM*sutochno.ru Баланс 2472.16р 11:30"", ""category"": ""Развлечения"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-11T08:30:52.559+00:00"", ""counterparty"": ""Сутонь"", ""signedAmount"": -2075.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 94, ""rawSms"": ""Перевод 1000р Счет*5611 ООО ГРАНЛАЙН - . Баланс 3120.16р 19:14"", ""rawJson"": {""action"": ""transfer"", ""amount"": 1000, ""balance"": 3120.16, ""raw_sms"": ""Перевод 1000р Счет*5611 ООО ГРАНЛАЙН - . Баланс 3120.16р 19:14"", ""currency"": ""RUB"", ""sms_text"": ""Перевод 1000р Счет*5611 ООО ГРАНЛАЙН - . Баланс 3120.16р 19:14"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-11T16:14:25.818Z"", ""sms_contact"": """", ""signed_amount"": -1000}, ""smsText"": ""Перевод 1000р Счет*5611 ООО ГРАНЛАЙН - . Баланс 3120.16р 19:14"", ""category"": ""Подписки"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-11T16:14:25.818+00:00"", ""counterparty"": ""ООО Гранлайн"", ""signedAmount"": -1000.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 95, ""rawSms"": ""Зачисление кешбэка по программе лояльности 1648р Счет*5611 Баланс 3120.16р 19:07"", ""rawJson"": {""action"": ""income"", ""amount"": 1648, ""balance"": 3120.16, ""raw_sms"": ""Зачисление кешбэка по программе лояльности 1648р Счет*5611 Баланс 3120.16р 19:07"", ""currency"": ""RUB"", ""sms_text"": ""Зачисление кешбэка по программе лояльности 1648р Счет*5611 Баланс 3120.16р 19:07"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-11T16:35:10.521Z"", ""sms_contact"": """", ""signed_amount"": 1648}, ""smsText"": ""Зачисление кешбэка по программе лояльности 1648р Счет*5611 Баланс 3120.16р 19:07"", ""category"": ""Поступления"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-11T16:35:10.521+00:00"", ""counterparty"": ""ВТБ"", ""signedAmount"": 1648.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 96, ""rawSms"": ""Оплата 163р Карта*2249 OZON Баланс 2957.16р 20:46"", ""rawJson"": {""action"": ""payment"", ""amount"": 163, ""balance"": 2957.16, ""raw_sms"": ""Оплата 163р Карта*2249 OZON Баланс 2957.16р 20:46"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 163р Карта*2249 OZON Баланс 2957.16р 20:46"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-11T17:46:26.933Z"", ""sms_contact"": """", ""signed_amount"": -163}, ""smsText"": ""Оплата 163р Карта*2249 OZON Баланс 2957.16р 20:46"", ""category"": ""Дом"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-11T17:46:26.933+00:00"", ""counterparty"": ""OZON"", ""signedAmount"": -163.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-11T21:49:00+00:00""}"
|
|
||||||
"{""id"": 97, ""rawSms"": ""Оплата 601.94р Карта*4215 PYATEROCHKA Баланс 2355.22р 20:56"", ""rawJson"": {""action"": ""payment"", ""amount"": 601.94, ""balance"": 2355.22, ""raw_sms"": ""Оплата 601.94р Карта*4215 PYATEROCHKA Баланс 2355.22р 20:56"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 601.94р Карта*4215 PYATEROCHKA Баланс 2355.22р 20:56"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-11T17:56:14.202Z"", ""sms_contact"": """", ""signed_amount"": -601.94}, ""smsText"": ""Оплата 601.94р Карта*4215 PYATEROCHKA Баланс 2355.22р 20:56"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-11T17:56:14.202+00:00"", ""counterparty"": ""Пятёрочка"", ""signedAmount"": -601.94, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-11T21:49:00+00:00""}"
|
|
||||||
"{""id"": 98, ""rawSms"": ""Оплата 100р Карта*4215 IP SHEVELEV S.A Баланс 2255.22р 08:12"", ""rawJson"": {""action"": ""payment"", ""amount"": 100, ""balance"": 2255.22, ""raw_sms"": ""Оплата 100р Карта*4215 IP SHEVELEV S.A Баланс 2255.22р 08:12"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 100р Карта*4215 IP SHEVELEV S.A Баланс 2255.22р 08:12"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-12T05:12:14.999Z"", ""sms_contact"": """", ""signed_amount"": -100}, ""smsText"": ""Оплата 100р Карта*4215 IP SHEVELEV S.A Баланс 2255.22р 08:12"", ""category"": ""Авто"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-12T05:12:14.999+00:00"", ""counterparty"": ""IP SHEVELEV S.A"", ""signedAmount"": -100.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 99, ""rawSms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 1975.22р 13:33"", ""rawJson"": {""action"": ""payment"", ""amount"": 280, ""balance"": 1975.22, ""raw_sms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 1975.22р 13:33"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 1975.22р 13:33"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-12T10:33:25.113Z"", ""sms_contact"": """", ""signed_amount"": -280}, ""smsText"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 1975.22р 13:33"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-12T10:33:25.113+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -280.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-12T14:00:00+00:00""}"
|
|
||||||
"{""id"": 100, ""rawSms"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 1750.22р 17:02"", ""rawJson"": {""action"": ""payment"", ""amount"": 225, ""balance"": 1750.22, ""raw_sms"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 1750.22р 17:02"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 1750.22р 17:02"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-12T14:02:17.343Z"", ""sms_contact"": """", ""signed_amount"": -225}, ""smsText"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 1750.22р 17:02"", ""category"": ""Спорт"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-12T14:02:17.343+00:00"", ""counterparty"": ""Анта-спорт"", ""signedAmount"": -225.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-12T17:02:00+00:00""}"
|
|
||||||
"{""id"": 101, ""rawSms"": ""Оплата 256р Карта*4215 STOLOVAYA 2 Баланс 1494.22р 17:53"", ""rawJson"": {""action"": ""payment"", ""amount"": 256, ""balance"": 1494.22, ""raw_sms"": ""Оплата 256р Карта*4215 STOLOVAYA 2 Баланс 1494.22р 17:53"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 256р Карта*4215 STOLOVAYA 2 Баланс 1494.22р 17:53"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-12T14:53:29.555Z"", ""sms_contact"": """", ""signed_amount"": -256}, ""smsText"": ""Оплата 256р Карта*4215 STOLOVAYA 2 Баланс 1494.22р 17:53"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-12T14:53:29.555+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -256.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-12T17:53:00+00:00""}"
|
|
||||||
"{""id"": 102, ""rawSms"": ""Оплата 389р Карта*2249 OZON.RU Баланс 1105.22р 18:35"", ""rawJson"": {""action"": ""payment"", ""amount"": 389, ""balance"": 1105.22, ""raw_sms"": ""Оплата 389р Карта*2249 OZON.RU Баланс 1105.22р 18:35"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 389р Карта*2249 OZON.RU Баланс 1105.22р 18:35"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-12T15:35:18.793Z"", ""sms_contact"": """", ""signed_amount"": -389}, ""smsText"": ""Оплата 389р Карта*2249 OZON.RU Баланс 1105.22р 18:35"", ""category"": ""Дом"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-12T15:35:18.793+00:00"", ""counterparty"": ""OZON"", ""signedAmount"": -389.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-12T19:18:00+00:00""}"
|
|
||||||
"{""id"": 103, ""rawSms"": ""Оплата 507р Карта*2249 OZON Баланс 598.22р 18:59"", ""rawJson"": {""action"": ""payment"", ""amount"": 507, ""balance"": 598.22, ""raw_sms"": ""Оплата 507р Карта*2249 OZON Баланс 598.22р 18:59"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 507р Карта*2249 OZON Баланс 598.22р 18:59"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-12T16:00:07.127Z"", ""sms_contact"": """", ""signed_amount"": -507}, ""smsText"": ""Оплата 507р Карта*2249 OZON Баланс 598.22р 18:59"", ""category"": ""Дом"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-12T16:00:07.127+00:00"", ""counterparty"": ""OZON"", ""signedAmount"": -507.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-12T19:18:00+00:00""}"
|
|
||||||
"{""id"": 104, ""rawSms"": ""Оплата 1250р Карта*2249 mkad Баланс 14348.22р 19:17"", ""rawJson"": {""action"": ""payment"", ""amount"": 1250, ""balance"": 14348.22, ""raw_sms"": ""Оплата 1250р Карта*2249 mkad Баланс 14348.22р 19:17"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 1250р Карта*2249 mkad Баланс 14348.22р 19:17"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-12T16:17:28.942Z"", ""sms_contact"": """", ""signed_amount"": -1250}, ""smsText"": ""Оплата 1250р Карта*2249 mkad Баланс 14348.22р 19:17"", ""category"": ""Авто"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-12T16:17:28.942+00:00"", ""counterparty"": ""Автомойка"", ""signedAmount"": -1250.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 105, ""rawSms"": ""Оплата 139р Карта*2249 OZON Баланс 4628.22р 20:11"", ""rawJson"": {""action"": ""payment"", ""amount"": 139, ""balance"": 4628.22, ""raw_sms"": ""Оплата 139р Карта*2249 OZON Баланс 4628.22р 20:11"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 139р Карта*2249 OZON Баланс 4628.22р 20:11"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-12T17:12:07.406Z"", ""sms_contact"": """", ""signed_amount"": -139}, ""smsText"": ""Оплата 139р Карта*2249 OZON Баланс 4628.22р 20:11"", ""category"": ""Дом"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-12T17:12:07.406+00:00"", ""counterparty"": ""OZON"", ""signedAmount"": -139.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-12T22:09:00+00:00""}"
|
|
||||||
"{""id"": 106, ""rawSms"": ""Оплата 194р Карта*4215 STOLOVAYA 2 Баланс 4434.22р 09:05"", ""rawJson"": {""action"": ""payment"", ""amount"": 194, ""balance"": 4434.22, ""raw_sms"": ""Оплата 194р Карта*4215 STOLOVAYA 2 Баланс 4434.22р 09:05"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 194р Карта*4215 STOLOVAYA 2 Баланс 4434.22р 09:05"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-13T06:06:04.027Z"", ""sms_contact"": """", ""signed_amount"": -194}, ""smsText"": ""Оплата 194р Карта*4215 STOLOVAYA 2 Баланс 4434.22р 09:05"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-13T06:06:04.027+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -194.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-13T09:07:00+00:00""}"
|
|
||||||
"{""id"": 107, ""rawSms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 4154.22р 13:09"", ""rawJson"": {""action"": ""payment"", ""amount"": 280, ""balance"": 4154.22, ""raw_sms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 4154.22р 13:09"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 4154.22р 13:09"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-13T10:09:35.273Z"", ""sms_contact"": """", ""signed_amount"": -280}, ""smsText"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 4154.22р 13:09"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-13T10:09:35.273+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -280.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-13T13:34:00+00:00""}"
|
|
||||||
"{""id"": 108, ""rawSms"": ""Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 4153.22р 13:48"", ""rawJson"": {""action"": ""payment"", ""amount"": 1, ""balance"": 4153.22, ""raw_sms"": ""Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 4153.22р 13:48"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 4153.22р 13:48"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-13T10:48:40.602Z"", ""sms_contact"": """", ""signed_amount"": -1}, ""smsText"": ""Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 4153.22р 13:48"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-13T10:48:40.602+00:00"", ""counterparty"": ""Перекрёсток"", ""signedAmount"": -1.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-13T13:48:00+00:00""}"
|
|
||||||
"{""id"": 109, ""rawSms"": ""Отмена: Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 4154.22р 13:48"", ""rawJson"": {""action"": ""reversal"", ""amount"": 1, ""balance"": 4154.22, ""raw_sms"": ""Отмена: Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 4154.22р 13:48"", ""currency"": ""RUB"", ""sms_text"": ""Отмена: Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 4154.22р 13:48"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-13T10:48:44.636Z"", ""sms_contact"": """", ""signed_amount"": 1}, ""smsText"": ""Отмена: Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 4154.22р 13:48"", ""category"": ""Подписки"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-13T10:48:44.636+00:00"", ""counterparty"": ""Перекрёсток"", ""signedAmount"": 1.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-13T13:49:00+00:00""}"
|
|
||||||
"{""id"": 110, ""rawSms"": ""Оплата 1962.11р Карта*2249 DOSTAVKA PEREKR Баланс 2192.11р 14:21"", ""rawJson"": {""action"": ""payment"", ""amount"": 1962.11, ""balance"": 2192.11, ""raw_sms"": ""Оплата 1962.11р Карта*2249 DOSTAVKA PEREKR Баланс 2192.11р 14:21"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 1962.11р Карта*2249 DOSTAVKA PEREKR Баланс 2192.11р 14:21"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-13T11:21:44.898Z"", ""sms_contact"": """", ""signed_amount"": -1962.11}, ""smsText"": ""Оплата 1962.11р Карта*2249 DOSTAVKA PEREKR Баланс 2192.11р 14:21"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-13T11:21:44.898+00:00"", ""counterparty"": ""Перекрёсток"", ""signedAmount"": -1962.11, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-13T14:37:00+00:00""}"
|
|
||||||
"{""id"": 111, ""rawSms"": ""Оплата 203р Карта*4215 STOLOVAYA 2 Баланс 1989.11р 17:02"", ""rawJson"": {""action"": ""payment"", ""amount"": 203, ""balance"": 1989.11, ""raw_sms"": ""Оплата 203р Карта*4215 STOLOVAYA 2 Баланс 1989.11р 17:02"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 203р Карта*4215 STOLOVAYA 2 Баланс 1989.11р 17:02"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-13T14:02:48.094Z"", ""sms_contact"": """", ""signed_amount"": -203}, ""smsText"": ""Оплата 203р Карта*4215 STOLOVAYA 2 Баланс 1989.11р 17:02"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-13T14:02:48.094+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -203.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-13T17:13:00+00:00""}"
|
|
||||||
"{""id"": 112, ""rawSms"": ""Списание 4000р Счет*5611 Дарья М. Баланс 1989.11р 17:04"", ""rawJson"": {""action"": ""writeoff"", ""amount"": 4000, ""balance"": 1989.11, ""raw_sms"": ""Списание 4000р Счет*5611 Дарья М. Баланс 1989.11р 17:04"", ""currency"": ""RUB"", ""sms_text"": ""Списание 4000р Счет*5611 Дарья М. Баланс 1989.11р 17:04"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-13T14:04:53.729Z"", ""sms_contact"": """", ""signed_amount"": -4000}, ""smsText"": ""Списание 4000р Счет*5611 Дарья М. Баланс 1989.11р 17:04"", ""category"": ""Арчи"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-13T14:04:53.729+00:00"", ""counterparty"": ""Даша Кинолог"", ""signedAmount"": -4000.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 113, ""rawSms"": ""Оплата 269р Карта*2249 OZON Баланс 6720.11р 18:00"", ""rawJson"": {""action"": ""payment"", ""amount"": 269, ""balance"": 6720.11, ""raw_sms"": ""Оплата 269р Карта*2249 OZON Баланс 6720.11р 18:00"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 269р Карта*2249 OZON Баланс 6720.11р 18:00"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-13T15:00:36.237Z"", ""sms_contact"": """", ""signed_amount"": -269}, ""smsText"": ""Оплата 269р Карта*2249 OZON Баланс 6720.11р 18:00"", ""category"": ""Дом"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-13T15:00:36.237+00:00"", ""counterparty"": ""OZON"", ""signedAmount"": -269.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-13T18:04:00+00:00""}"
|
|
||||||
"{""id"": 114, ""rawSms"": ""Оплата 329.97р Карта*8214 PYATEROCHKA Баланс 6390.14р 18:03"", ""rawJson"": {""action"": ""payment"", ""amount"": 329.97, ""balance"": 6390.14, ""raw_sms"": ""Оплата 329.97р Карта*8214 PYATEROCHKA Баланс 6390.14р 18:03"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 329.97р Карта*8214 PYATEROCHKA Баланс 6390.14р 18:03"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-13T15:03:55.122Z"", ""sms_contact"": """", ""signed_amount"": -329.97}, ""smsText"": ""Оплата 329.97р Карта*8214 PYATEROCHKA Баланс 6390.14р 18:03"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-13T15:03:55.122+00:00"", ""counterparty"": ""Пятёрочка"", ""signedAmount"": -329.97, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-13T18:04:00+00:00""}"
|
|
||||||
"{""id"": 115, ""rawSms"": ""Оплата 5280р Карта*4215 NEFTMAGISTRAL 2 Баланс 1110.14р 21:24"", ""rawJson"": {""action"": ""payment"", ""amount"": 5280, ""balance"": 1110.14, ""raw_sms"": ""Оплата 5280р Карта*4215 NEFTMAGISTRAL 2 Баланс 1110.14р 21:24"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 5280р Карта*4215 NEFTMAGISTRAL 2 Баланс 1110.14р 21:24"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-13T18:24:20.370Z"", ""sms_contact"": """", ""signed_amount"": -5280}, ""smsText"": ""Оплата 5280р Карта*4215 NEFTMAGISTRAL 2 Баланс 1110.14р 21:24"", ""category"": ""Авто"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-13T18:24:20.37+00:00"", ""counterparty"": ""Нефтьмагистраль"", ""signedAmount"": -5280.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 116, ""rawSms"": ""Оплата 280р Карта*4215 JEFFREY S COFFE Баланс 637.14р 12:00"", ""rawJson"": {""action"": ""payment"", ""amount"": 280, ""balance"": 637.14, ""raw_sms"": ""Оплата 280р Карта*4215 JEFFREY S COFFE Баланс 637.14р 12:00"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 280р Карта*4215 JEFFREY S COFFE Баланс 637.14р 12:00"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-14T09:00:14.054Z"", ""sms_contact"": """", ""signed_amount"": -280}, ""smsText"": ""Оплата 280р Карта*4215 JEFFREY S COFFE Баланс 637.14р 12:00"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-14T09:00:14.054+00:00"", ""counterparty"": ""Jeffrey S Coffee"", ""signedAmount"": -280.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-14T12:00:00+00:00""}"
|
|
||||||
"{""id"": 117, ""rawSms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 357.14р 13:12"", ""rawJson"": {""action"": ""payment"", ""amount"": 280, ""balance"": 357.14, ""raw_sms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 357.14р 13:12"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 357.14р 13:12"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-14T10:12:51.510Z"", ""sms_contact"": """", ""signed_amount"": -280}, ""smsText"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 357.14р 13:12"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-14T10:12:51.51+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -280.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-14T13:36:00+00:00""}"
|
|
||||||
"{""id"": 118, ""rawSms"": ""Списание 342р Счет*5611 Антон Ш. Баланс 15.14р 17:30"", ""rawJson"": {""action"": ""writeoff"", ""amount"": 342, ""balance"": 15.14, ""raw_sms"": ""Списание 342р Счет*5611 Антон Ш. Баланс 15.14р 17:30"", ""currency"": ""RUB"", ""sms_text"": ""Списание 342р Счет*5611 Антон Ш. Баланс 15.14р 17:30"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-14T14:30:32.365Z"", ""sms_contact"": """", ""signed_amount"": -342}, ""smsText"": ""Списание 342р Счет*5611 Антон Ш. Баланс 15.14р 17:30"", ""category"": ""Перевод"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-14T14:30:32.365+00:00"", ""counterparty"": ""Шестеров Альфа"", ""signedAmount"": -342.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 119, ""rawSms"": ""Недостаточно средств 1250р Карта*2249 mkad Баланс 15.14р 19:26"", ""rawJson"": {""action"": ""unknown"", ""amount"": 1250, ""balance"": 15.14, ""raw_sms"": ""Недостаточно средств 1250р Карта*2249 mkad Баланс 15.14р 19:26"", ""currency"": ""RUB"", ""sms_text"": ""Недостаточно средств 1250р Карта*2249 mkad Баланс 15.14р 19:26"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-14T16:26:15.369Z"", ""sms_contact"": """", ""signed_amount"": 1250}, ""smsText"": ""Недостаточно средств 1250р Карта*2249 mkad Баланс 15.14р 19:26"", ""category"": ""Проезд"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-14T16:26:15.369+00:00"", ""counterparty"": ""MKAD"", ""signedAmount"": 1250.00, ""humanVerified"": false, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 120, ""rawSms"": ""Оплата 1250р Карта*2249 mkad Баланс 3765.14р 19:26"", ""rawJson"": {""action"": ""payment"", ""amount"": 1250, ""balance"": 3765.14, ""raw_sms"": ""Оплата 1250р Карта*2249 mkad Баланс 3765.14р 19:26"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 1250р Карта*2249 mkad Баланс 3765.14р 19:26"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-14T16:27:06.566Z"", ""sms_contact"": """", ""signed_amount"": -1250}, ""smsText"": ""Оплата 1250р Карта*2249 mkad Баланс 3765.14р 19:26"", ""category"": ""Авто"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-14T16:27:06.566+00:00"", ""counterparty"": ""Автомойка"", ""signedAmount"": -1250.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 121, ""rawSms"": ""Оплата 203р Карта*4215 STOLOVAYA 2 Баланс 3562.14р 09:21"", ""rawJson"": {""action"": ""payment"", ""amount"": 203, ""balance"": 3562.14, ""raw_sms"": ""Оплата 203р Карта*4215 STOLOVAYA 2 Баланс 3562.14р 09:21"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 203р Карта*4215 STOLOVAYA 2 Баланс 3562.14р 09:21"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-15T06:21:59.175Z"", ""sms_contact"": """", ""signed_amount"": -203}, ""smsText"": ""Оплата 203р Карта*4215 STOLOVAYA 2 Баланс 3562.14р 09:21"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-15T06:21:59.175+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -203.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-15T09:56:00+00:00""}"
|
|
||||||
"{""id"": 122, ""rawSms"": ""Оплата 290р Карта*2249 OZON Баланс 3272.14р 10:04"", ""rawJson"": {""action"": ""payment"", ""amount"": 290, ""balance"": 3272.14, ""raw_sms"": ""Оплата 290р Карта*2249 OZON Баланс 3272.14р 10:04"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 290р Карта*2249 OZON Баланс 3272.14р 10:04"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-15T07:04:52.492Z"", ""sms_contact"": """", ""signed_amount"": -290}, ""smsText"": ""Оплата 290р Карта*2249 OZON Баланс 3272.14р 10:04"", ""category"": ""Дом"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-15T07:04:52.492+00:00"", ""counterparty"": ""OZON"", ""signedAmount"": -290.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-15T10:05:00+00:00""}"
|
|
||||||
"{""id"": 123, ""rawSms"": ""Оплата 65р Карта*4215 STOLOVAYA 2 Баланс 3207.14р 11:16"", ""rawJson"": {""action"": ""payment"", ""amount"": 65, ""balance"": 3207.14, ""raw_sms"": ""Оплата 65р Карта*4215 STOLOVAYA 2 Баланс 3207.14р 11:16"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 65р Карта*4215 STOLOVAYA 2 Баланс 3207.14р 11:16"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-15T08:16:29.745Z"", ""sms_contact"": """", ""signed_amount"": -65}, ""smsText"": ""Оплата 65р Карта*4215 STOLOVAYA 2 Баланс 3207.14р 11:16"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-15T08:16:29.745+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -65.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-15T11:31:00+00:00""}"
|
|
||||||
"{""id"": 124, ""rawSms"": ""Оплата 989.66р Карта*8214 PYATEROCHKA Баланс 529.98р 11:27"", ""rawJson"": {""action"": ""payment"", ""amount"": 989.66, ""balance"": 529.98, ""raw_sms"": ""Оплата 989.66р Карта*8214 PYATEROCHKA Баланс 529.98р 11:27"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 989.66р Карта*8214 PYATEROCHKA Баланс 529.98р 11:27"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-15T08:27:25.005Z"", ""sms_contact"": """", ""signed_amount"": -989.66}, ""smsText"": ""Оплата 989.66р Карта*8214 PYATEROCHKA Баланс 529.98р 11:27"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-15T08:27:25.005+00:00"", ""counterparty"": ""Пятёрочка"", ""signedAmount"": -989.66, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-15T11:31:00+00:00""}"
|
|
||||||
"{""id"": 125, ""rawSms"": ""Недостаточно средств 542.98р Карта*8214 MAGNIT MK STARO Баланс 529.98р 11:33"", ""rawJson"": {""action"": ""unknown"", ""amount"": 542.98, ""balance"": 529.98, ""raw_sms"": ""Недостаточно средств 542.98р Карта*8214 MAGNIT MK STARO Баланс 529.98р 11:33"", ""currency"": ""RUB"", ""sms_text"": ""Недостаточно средств 542.98р Карта*8214 MAGNIT MK STARO Баланс 529.98р 11:33"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-15T08:33:11.490Z"", ""sms_contact"": """", ""signed_amount"": 542.98}, ""smsText"": ""Недостаточно средств 542.98р Карта*8214 MAGNIT MK STARO Баланс 529.98р 11:33"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-15T08:33:11.49+00:00"", ""counterparty"": ""Магнит"", ""signedAmount"": 542.98, ""humanVerified"": false, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 126, ""rawSms"": ""Оплата 542.98р Карта*8214 MAGNIT MK STARO Баланс 4987р 11:33"", ""rawJson"": {""action"": ""payment"", ""amount"": 542.98, ""balance"": 4987, ""raw_sms"": ""Оплата 542.98р Карта*8214 MAGNIT MK STARO Баланс 4987р 11:33"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 542.98р Карта*8214 MAGNIT MK STARO Баланс 4987р 11:33"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-15T08:34:00.145Z"", ""sms_contact"": """", ""signed_amount"": -542.98}, ""smsText"": ""Оплата 542.98р Карта*8214 MAGNIT MK STARO Баланс 4987р 11:33"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-15T08:34:00.145+00:00"", ""counterparty"": ""Магнит"", ""signedAmount"": -542.98, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-15T11:34:00+00:00""}"
|
|
||||||
"{""id"": 127, ""rawSms"": ""Оплата 280р Карта*4215 STOLOVAYA. Баланс 4707р 13:49"", ""rawJson"": {""action"": ""payment"", ""amount"": 280, ""balance"": 4707, ""raw_sms"": ""Оплата 280р Карта*4215 STOLOVAYA. Баланс 4707р 13:49"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 280р Карта*4215 STOLOVAYA. Баланс 4707р 13:49"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-15T10:49:52.999Z"", ""sms_contact"": """", ""signed_amount"": -280}, ""smsText"": ""Оплата 280р Карта*4215 STOLOVAYA. Баланс 4707р 13:49"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-15T10:49:52.999+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -280.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-15T13:56:00+00:00""}"
|
|
||||||
"{""id"": 128, ""rawSms"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 4482р 20:45"", ""rawJson"": {""action"": ""payment"", ""amount"": 225, ""balance"": 4482, ""raw_sms"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 4482р 20:45"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 4482р 20:45"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-15T17:45:47.563Z"", ""sms_contact"": """", ""signed_amount"": -225}, ""smsText"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 4482р 20:45"", ""category"": ""Спорт"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-15T17:45:47.563+00:00"", ""counterparty"": ""Анта-спорт"", ""signedAmount"": -225.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-15T22:28:00+00:00""}"
|
|
||||||
"{""id"": 129, ""rawSms"": ""Оплата 9240р Карта*4215 IP SHARAFETDINO Баланс 10556.80р 12:36"", ""rawJson"": {""action"": ""payment"", ""amount"": 9240, ""balance"": 10556.8, ""raw_sms"": ""Оплата 9240р Карта*4215 IP SHARAFETDINO Баланс 10556.80р 12:36"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 9240р Карта*4215 IP SHARAFETDINO Баланс 10556.80р 12:36"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-16T09:37:13.189Z"", ""sms_contact"": """", ""signed_amount"": -9240}, ""smsText"": ""Оплата 9240р Карта*4215 IP SHARAFETDINO Баланс 10556.80р 12:36"", ""category"": ""Арчи"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-16T09:37:13.189+00:00"", ""counterparty"": ""IP SHARAFETDINO"", ""signedAmount"": -9240.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 130, ""rawSms"": ""Оплата 859.95р Карта*4215 PYATEROCHKA Баланс 9696.85р 13:31"", ""rawJson"": {""action"": ""payment"", ""amount"": 859.95, ""balance"": 9696.85, ""raw_sms"": ""Оплата 859.95р Карта*4215 PYATEROCHKA Баланс 9696.85р 13:31"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 859.95р Карта*4215 PYATEROCHKA Баланс 9696.85р 13:31"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-16T10:31:45.727Z"", ""sms_contact"": """", ""signed_amount"": -859.95}, ""smsText"": ""Оплата 859.95р Карта*4215 PYATEROCHKA Баланс 9696.85р 13:31"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-16T10:31:45.727+00:00"", ""counterparty"": ""Пятёрочка"", ""signedAmount"": -859.95, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-16T13:43:00+00:00""}"
|
|
||||||
"{""id"": 132, ""rawSms"": ""Зачисление 8929р Карта*2249 OZON Баланс 17625.85р 00:00"", ""rawJson"": {""action"": ""income"", ""amount"": 8929, ""balance"": 17625.85, ""raw_sms"": ""Зачисление 8929р Карта*2249 OZON Баланс 17625.85р 00:00"", ""currency"": ""RUB"", ""sms_text"": ""Зачисление 8929р Карта*2249 OZON Баланс 17625.85р 00:00"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-17T11:13:54.135Z"", ""sms_contact"": """", ""signed_amount"": 8929}, ""smsText"": ""Зачисление 8929р Карта*2249 OZON Баланс 17625.85р 00:00"", ""category"": ""Поступления"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-17T11:13:54.135+00:00"", ""counterparty"": ""OZON"", ""signedAmount"": 8929.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-17T15:18:00+00:00""}"
|
|
||||||
"{""id"": 133, ""rawSms"": ""Оплата 2000р Карта*4215 SP__NORD & NILS Баланс 15625.85р 15:20"", ""rawJson"": {""action"": ""payment"", ""amount"": 2000, ""balance"": 15625.85, ""raw_sms"": ""Оплата 2000р Карта*4215 SP__NORD & NILS Баланс 15625.85р 15:20"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 2000р Карта*4215 SP__NORD & NILS Баланс 15625.85р 15:20"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-17T12:20:33.189Z"", ""sms_contact"": """", ""signed_amount"": -2000}, ""smsText"": ""Оплата 2000р Карта*4215 SP__NORD & NILS Баланс 15625.85р 15:20"", ""category"": ""Перевод"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-17T12:20:33.189+00:00"", ""counterparty"": ""СП_НОРД & НИЛС"", ""signedAmount"": -2000.00, ""humanVerified"": false, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 134, ""rawSms"": ""Оплата 140.98р Карта*4215 PYATEROCHKA Баланс 15309.42р 17:44"", ""rawJson"": {""action"": ""payment"", ""amount"": 140.98, ""balance"": 15309.42, ""raw_sms"": ""Оплата 140.98р Карта*4215 PYATEROCHKA Баланс 15309.42р 17:44"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 140.98р Карта*4215 PYATEROCHKA Баланс 15309.42р 17:44"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-18T14:45:08.717Z"", ""sms_contact"": """", ""signed_amount"": -140.98}, ""smsText"": ""Оплата 140.98р Карта*4215 PYATEROCHKA Баланс 15309.42р 17:44"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-18T14:45:08.717+00:00"", ""counterparty"": ""Пятёрочка"", ""signedAmount"": -140.98, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-18T18:02:00+00:00""}"
|
|
||||||
"{""id"": 135, ""rawSms"": ""Оплата 1219р Карта*2249 PT*Aliexpress Баланс 14090.42р 18:02"", ""rawJson"": {""action"": ""payment"", ""amount"": 1219, ""balance"": 14090.42, ""raw_sms"": ""Оплата 1219р Карта*2249 PT*Aliexpress Баланс 14090.42р 18:02"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 1219р Карта*2249 PT*Aliexpress Баланс 14090.42р 18:02"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-18T15:02:39.286Z"", ""sms_contact"": """", ""signed_amount"": -1219}, ""smsText"": ""Оплата 1219р Карта*2249 PT*Aliexpress Баланс 14090.42р 18:02"", ""category"": ""Подарки"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-18T15:02:39.286+00:00"", ""counterparty"": ""Алиэкспресс"", ""signedAmount"": -1219.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 136, ""rawSms"": ""Оплата 1021р Карта*2249 OZON.RU Баланс 13069.42р 19:31"", ""rawJson"": {""action"": ""payment"", ""amount"": 1021, ""balance"": 13069.42, ""raw_sms"": ""Оплата 1021р Карта*2249 OZON.RU Баланс 13069.42р 19:31"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 1021р Карта*2249 OZON.RU Баланс 13069.42р 19:31"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-18T16:31:41.474Z"", ""sms_contact"": """", ""signed_amount"": -1021}, ""smsText"": ""Оплата 1021р Карта*2249 OZON.RU Баланс 13069.42р 19:31"", ""category"": ""Дом"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-18T16:31:41.474+00:00"", ""counterparty"": ""OZON"", ""signedAmount"": -1021.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-18T19:32:00+00:00""}"
|
|
||||||
"{""id"": 137, ""rawSms"": ""Оплата 170р Карта*4215 STOLOVAYA 2 Баланс 12899.42р 10:10"", ""rawJson"": {""action"": ""payment"", ""amount"": 170, ""balance"": 12899.42, ""raw_sms"": ""Оплата 170р Карта*4215 STOLOVAYA 2 Баланс 12899.42р 10:10"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 170р Карта*4215 STOLOVAYA 2 Баланс 12899.42р 10:10"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-19T07:10:19.605Z"", ""sms_contact"": """", ""signed_amount"": -170}, ""smsText"": ""Оплата 170р Карта*4215 STOLOVAYA 2 Баланс 12899.42р 10:10"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-19T07:10:19.605+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -170.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-19T10:12:00+00:00""}"
|
|
||||||
"{""id"": 138, ""rawSms"": ""Оплата 9546.35р Карта*8214 Bethowen Баланс 3353.07р 11:23"", ""rawJson"": {""action"": ""payment"", ""amount"": 9546.35, ""balance"": 3353.07, ""raw_sms"": ""Оплата 9546.35р Карта*8214 Bethowen Баланс 3353.07р 11:23"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 9546.35р Карта*8214 Bethowen Баланс 3353.07р 11:23"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-19T08:24:04.563Z"", ""sms_contact"": """", ""signed_amount"": -9546.35}, ""smsText"": ""Оплата 9546.35р Карта*8214 Bethowen Баланс 3353.07р 11:23"", ""category"": ""Арчи"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-19T08:24:04.563+00:00"", ""counterparty"": ""Бетховен"", ""signedAmount"": -9546.35, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 139, ""rawSms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 3073.07р 13:54"", ""rawJson"": {""action"": ""payment"", ""amount"": 280, ""balance"": 3073.07, ""raw_sms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 3073.07р 13:54"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 3073.07р 13:54"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-19T10:54:50.856Z"", ""sms_contact"": """", ""signed_amount"": -280}, ""smsText"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 3073.07р 13:54"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-19T10:54:50.856+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -280.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-19T13:55:00+00:00""}"
|
|
||||||
"{""id"": 140, ""rawSms"": ""Оплата 4315.94р Карта*4215 RNAZK MJ056 ROS Баланс 4757.13р 08:11"", ""rawJson"": {""action"": ""payment"", ""amount"": 4315.94, ""balance"": 4757.13, ""raw_sms"": ""Оплата 4315.94р Карта*4215 RNAZK MJ056 ROS Баланс 4757.13р 08:11"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 4315.94р Карта*4215 RNAZK MJ056 ROS Баланс 4757.13р 08:11"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-20T05:11:41.879Z"", ""sms_contact"": """", ""signed_amount"": -4315.94}, ""smsText"": ""Оплата 4315.94р Карта*4215 RNAZK MJ056 ROS Баланс 4757.13р 08:11"", ""category"": ""Авто"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-20T05:11:41.879+00:00"", ""counterparty"": ""Транснефть"", ""signedAmount"": -4315.94, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 141, ""rawSms"": ""Оплата 153р Карта*4215 STOLOVAYA 2 Баланс 4604.13р 09:32"", ""rawJson"": {""action"": ""payment"", ""amount"": 153, ""balance"": 4604.13, ""raw_sms"": ""Оплата 153р Карта*4215 STOLOVAYA 2 Баланс 4604.13р 09:32"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 153р Карта*4215 STOLOVAYA 2 Баланс 4604.13р 09:32"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-20T06:32:51.794Z"", ""sms_contact"": """", ""signed_amount"": -153}, ""smsText"": ""Оплата 153р Карта*4215 STOLOVAYA 2 Баланс 4604.13р 09:32"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-20T06:32:51.794+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -153.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-20T09:53:00+00:00""}"
|
|
||||||
"{""id"": 142, ""rawSms"": ""Списание 4000р Счет*5611 Дарья М. Баланс 604.13р 12:34"", ""rawJson"": {""action"": ""writeoff"", ""amount"": 4000, ""balance"": 604.13, ""raw_sms"": ""Списание 4000р Счет*5611 Дарья М. Баланс 604.13р 12:34"", ""currency"": ""RUB"", ""sms_text"": ""Списание 4000р Счет*5611 Дарья М. Баланс 604.13р 12:34"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-20T09:34:18.739Z"", ""sms_contact"": """", ""signed_amount"": -4000}, ""smsText"": ""Списание 4000р Счет*5611 Дарья М. Баланс 604.13р 12:34"", ""category"": ""Арчи"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-20T09:34:18.739+00:00"", ""counterparty"": ""Кинолог"", ""signedAmount"": -4000.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 143, ""rawSms"": ""Оплата 472р Карта*4215 STOLOVAYA 2 Баланс 1132.13р 13:14"", ""rawJson"": {""action"": ""payment"", ""amount"": 472, ""balance"": 1132.13, ""raw_sms"": ""Оплата 472р Карта*4215 STOLOVAYA 2 Баланс 1132.13р 13:14"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 472р Карта*4215 STOLOVAYA 2 Баланс 1132.13р 13:14"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-20T10:14:35.169Z"", ""sms_contact"": """", ""signed_amount"": -472}, ""smsText"": ""Оплата 472р Карта*4215 STOLOVAYA 2 Баланс 1132.13р 13:14"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-20T10:14:35.169+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -472.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-20T13:37:00+00:00""}"
|
|
||||||
"{""id"": 144, ""rawSms"": ""Оплата 719.01р Карта*8214 PYATEROCHKA Баланс 413.12р 13:17"", ""rawJson"": {""action"": ""payment"", ""amount"": 719.01, ""balance"": 413.12, ""raw_sms"": ""Оплата 719.01р Карта*8214 PYATEROCHKA Баланс 413.12р 13:17"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 719.01р Карта*8214 PYATEROCHKA Баланс 413.12р 13:17"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-20T10:17:24.734Z"", ""sms_contact"": """", ""signed_amount"": -719.01}, ""smsText"": ""Оплата 719.01р Карта*8214 PYATEROCHKA Баланс 413.12р 13:17"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-20T10:17:24.734+00:00"", ""counterparty"": ""Пятёрочка"", ""signedAmount"": -719.01, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-20T13:37:00+00:00""}"
|
|
||||||
"{""id"": 145, ""rawSms"": ""Оплата 252р Карта*4215 JEFFREY S COFFE Баланс 161.12р 17:10"", ""rawJson"": {""action"": ""payment"", ""amount"": 252, ""balance"": 161.12, ""raw_sms"": ""Оплата 252р Карта*4215 JEFFREY S COFFE Баланс 161.12р 17:10"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 252р Карта*4215 JEFFREY S COFFE Баланс 161.12р 17:10"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-20T14:10:47.286Z"", ""sms_contact"": """", ""signed_amount"": -252}, ""smsText"": ""Оплата 252р Карта*4215 JEFFREY S COFFE Баланс 161.12р 17:10"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-20T14:10:47.286+00:00"", ""counterparty"": ""Jeffrey S Coffee"", ""signedAmount"": -252.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-20T17:12:00+00:00""}"
|
|
||||||
"{""id"": 146, ""rawSms"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 3936.12р 17:17"", ""rawJson"": {""action"": ""payment"", ""amount"": 225, ""balance"": 3936.12, ""raw_sms"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 3936.12р 17:17"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 3936.12р 17:17"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-20T14:17:51.925Z"", ""sms_contact"": """", ""signed_amount"": -225}, ""smsText"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 3936.12р 17:17"", ""category"": ""Спорт"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-20T14:17:51.925+00:00"", ""counterparty"": ""Анта-спорт"", ""signedAmount"": -225.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-20T17:19:00+00:00""}"
|
|
||||||
"{""id"": 147, ""rawSms"": ""Оплата 1496р Карта*4215 3234 ZOO Баланс 2440.12р 18:48"", ""rawJson"": {""action"": ""payment"", ""amount"": 1496, ""balance"": 2440.12, ""raw_sms"": ""Оплата 1496р Карта*4215 3234 ZOO Баланс 2440.12р 18:48"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 1496р Карта*4215 3234 ZOO Баланс 2440.12р 18:48"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-20T15:48:57.515Z"", ""sms_contact"": """", ""signed_amount"": -1496}, ""smsText"": ""Оплата 1496р Карта*4215 3234 ZOO Баланс 2440.12р 18:48"", ""category"": ""Арчи"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-20T15:48:57.515+00:00"", ""counterparty"": ""Зоопарк"", ""signedAmount"": -1496.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 148, ""rawSms"": ""Оплата 200р Карта*4215 IP Kibickaja Баланс 2240.12р 21:59"", ""rawJson"": {""action"": ""payment"", ""amount"": 200, ""balance"": 2240.12, ""raw_sms"": ""Оплата 200р Карта*4215 IP Kibickaja Баланс 2240.12р 21:59"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 200р Карта*4215 IP Kibickaja Баланс 2240.12р 21:59"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-20T19:00:07.700Z"", ""sms_contact"": """", ""signed_amount"": -200}, ""smsText"": ""Оплата 200р Карта*4215 IP Kibickaja Баланс 2240.12р 21:59"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-20T19:00:07.7+00:00"", ""counterparty"": ""Кибикя"", ""signedAmount"": -200.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 149, ""rawSms"": ""Оплата 10р Карта*2249 YM*AMPP Баланс 2230.12р 22:13"", ""rawJson"": {""action"": ""payment"", ""amount"": 10, ""balance"": 2230.12, ""raw_sms"": ""Оплата 10р Карта*2249 YM*AMPP Баланс 2230.12р 22:13"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 10р Карта*2249 YM*AMPP Баланс 2230.12р 22:13"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-20T19:13:43.445Z"", ""sms_contact"": """", ""signed_amount"": -10}, ""smsText"": ""Оплата 10р Карта*2249 YM*AMPP Баланс 2230.12р 22:13"", ""category"": ""Авто"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-20T19:13:43.445+00:00"", ""counterparty"": ""Парковка"", ""signedAmount"": -10.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 150, ""rawSms"": ""Оплата 158р Карта*4215 STOLOVAYA 2 Баланс 2072.12р 10:20"", ""rawJson"": {""action"": ""payment"", ""amount"": 158, ""balance"": 2072.12, ""raw_sms"": ""Оплата 158р Карта*4215 STOLOVAYA 2 Баланс 2072.12р 10:20"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 158р Карта*4215 STOLOVAYA 2 Баланс 2072.12р 10:20"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-21T07:21:00.675Z"", ""sms_contact"": """", ""signed_amount"": -158}, ""smsText"": ""Оплата 158р Карта*4215 STOLOVAYA 2 Баланс 2072.12р 10:20"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-21T07:21:00.675+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -158.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-21T10:21:00+00:00""}"
|
|
||||||
"{""id"": 151, ""rawSms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 1792.12р 13:45"", ""rawJson"": {""action"": ""payment"", ""amount"": 280, ""balance"": 1792.12, ""raw_sms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 1792.12р 13:45"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 1792.12р 13:45"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-21T10:45:38.266Z"", ""sms_contact"": """", ""signed_amount"": -280}, ""smsText"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 1792.12р 13:45"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-21T10:45:38.266+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -280.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-21T14:08:00+00:00""}"
|
|
||||||
"{""id"": 152, ""rawSms"": ""Перевод 1000р Счет*5611 Милана К. Баланс 792.12р 14:17"", ""rawJson"": {""action"": ""transfer"", ""amount"": 1000, ""balance"": 792.12, ""raw_sms"": ""Перевод 1000р Счет*5611 Милана К. Баланс 792.12р 14:17"", ""currency"": ""RUB"", ""sms_text"": ""Перевод 1000р Счет*5611 Милана К. Баланс 792.12р 14:17"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-21T11:17:51.249Z"", ""sms_contact"": """", ""signed_amount"": -1000}, ""smsText"": ""Перевод 1000р Счет*5611 Милана К. Баланс 792.12р 14:17"", ""category"": ""Перевод"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-21T11:17:51.249+00:00"", ""counterparty"": ""Милана К."", ""signedAmount"": -1000.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-21T14:18:00+00:00""}"
|
|
||||||
"{""id"": 153, ""rawSms"": ""Оплата 354.97р Карта*8214 PYATEROCHKA Баланс 437.15р 17:29"", ""rawJson"": {""action"": ""payment"", ""amount"": 354.97, ""balance"": 437.15, ""raw_sms"": ""Оплата 354.97р Карта*8214 PYATEROCHKA Баланс 437.15р 17:29"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 354.97р Карта*8214 PYATEROCHKA Баланс 437.15р 17:29"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-21T14:29:19.469Z"", ""sms_contact"": """", ""signed_amount"": -354.97}, ""smsText"": ""Оплата 354.97р Карта*8214 PYATEROCHKA Баланс 437.15р 17:29"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-21T14:29:19.469+00:00"", ""counterparty"": ""Пятёрочка"", ""signedAmount"": -354.97, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-21T17:36:00+00:00""}"
|
|
||||||
"{""id"": 154, ""rawSms"": ""Оплата 154р Карта*2249 OZON Баланс 283.15р 20:09"", ""rawJson"": {""action"": ""payment"", ""amount"": 154, ""balance"": 283.15, ""raw_sms"": ""Оплата 154р Карта*2249 OZON Баланс 283.15р 20:09"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 154р Карта*2249 OZON Баланс 283.15р 20:09"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-21T17:09:32.249Z"", ""sms_contact"": """", ""signed_amount"": -154}, ""smsText"": ""Оплата 154р Карта*2249 OZON Баланс 283.15р 20:09"", ""category"": ""Дом"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-21T17:09:32.249+00:00"", ""counterparty"": ""OZON"", ""signedAmount"": -154.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-21T20:52:00+00:00""}"
|
|
||||||
"{""id"": 155, ""rawSms"": ""Недостаточно средств 943.91р Карта*4215 PYATEROCHKA Баланс 283.15р 20:23"", ""rawJson"": {""action"": ""unknown"", ""amount"": 943.91, ""balance"": 283.15, ""raw_sms"": ""Недостаточно средств 943.91р Карта*4215 PYATEROCHKA Баланс 283.15р 20:23"", ""currency"": ""RUB"", ""sms_text"": ""Недостаточно средств 943.91р Карта*4215 PYATEROCHKA Баланс 283.15р 20:23"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-21T17:23:48.134Z"", ""sms_contact"": """", ""signed_amount"": 943.91}, ""smsText"": ""Недостаточно средств 943.91р Карта*4215 PYATEROCHKA Баланс 283.15р 20:23"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-21T17:23:48.134+00:00"", ""counterparty"": ""Пятёрочка"", ""signedAmount"": 943.91, ""humanVerified"": false, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 156, ""rawSms"": ""Оплата 943.91р Карта*4215 PYATEROCHKA Баланс 4339.24р 20:24"", ""rawJson"": {""action"": ""payment"", ""amount"": 943.91, ""balance"": 4339.24, ""raw_sms"": ""Оплата 943.91р Карта*4215 PYATEROCHKA Баланс 4339.24р 20:24"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 943.91р Карта*4215 PYATEROCHKA Баланс 4339.24р 20:24"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-21T17:24:35.143Z"", ""sms_contact"": """", ""signed_amount"": -943.91}, ""smsText"": ""Оплата 943.91р Карта*4215 PYATEROCHKA Баланс 4339.24р 20:24"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-21T17:24:35.143+00:00"", ""counterparty"": ""Пятёрочка"", ""signedAmount"": -943.91, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-21T20:52:00+00:00""}"
|
|
||||||
"{""id"": 157, ""rawSms"": ""Оплата 200р Карта*8214 CPPK-2000012-BP Баланс 4139.24р 10:02"", ""rawJson"": {""action"": ""payment"", ""amount"": 200, ""balance"": 4139.24, ""raw_sms"": ""Оплата 200р Карта*8214 CPPK-2000012-BP Баланс 4139.24р 10:02"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 200р Карта*8214 CPPK-2000012-BP Баланс 4139.24р 10:02"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-22T07:02:13.974Z"", ""sms_contact"": """", ""signed_amount"": -200}, ""smsText"": ""Оплата 200р Карта*8214 CPPK-2000012-BP Баланс 4139.24р 10:02"", ""category"": ""Проезд"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-22T07:02:13.974+00:00"", ""counterparty"": ""РЖД"", ""signedAmount"": -200.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 158, ""rawSms"": ""Оплата 268р Карта*4215 STOLOVAYA 2 Баланс 3871.24р 10:58"", ""rawJson"": {""action"": ""payment"", ""amount"": 268, ""balance"": 3871.24, ""raw_sms"": ""Оплата 268р Карта*4215 STOLOVAYA 2 Баланс 3871.24р 10:58"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 268р Карта*4215 STOLOVAYA 2 Баланс 3871.24р 10:58"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-22T07:58:47.245Z"", ""sms_contact"": """", ""signed_amount"": -268}, ""smsText"": ""Оплата 268р Карта*4215 STOLOVAYA 2 Баланс 3871.24р 10:58"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-22T07:58:47.245+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -268.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-22T11:11:00+00:00""}"
|
|
||||||
"{""id"": 159, ""rawSms"": ""Оплата 1090р Карта*8214 TA TORRRO GRIL Баланс 2781.24р 14:08"", ""rawJson"": {""action"": ""payment"", ""amount"": 1090, ""balance"": 2781.24, ""raw_sms"": ""Оплата 1090р Карта*8214 TA TORRRO GRIL Баланс 2781.24р 14:08"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 1090р Карта*8214 TA TORRRO GRIL Баланс 2781.24р 14:08"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-22T11:09:03.392Z"", ""sms_contact"": """", ""signed_amount"": -1090}, ""smsText"": ""Оплата 1090р Карта*8214 TA TORRRO GRIL Баланс 2781.24р 14:08"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-22T11:09:03.392+00:00"", ""counterparty"": ""Торрро Гриль"", ""signedAmount"": -1090.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-22T14:09:00+00:00""}"
|
|
||||||
"{""id"": 160, ""rawSms"": ""Оплата 370р Карта*4215 KOFEPOINT Баланс 12411.24р 16:06"", ""rawJson"": {""action"": ""payment"", ""amount"": 370, ""balance"": 12411.24, ""raw_sms"": ""Оплата 370р Карта*4215 KOFEPOINT Баланс 12411.24р 16:06"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 370р Карта*4215 KOFEPOINT Баланс 12411.24р 16:06"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-22T13:03:18.615Z"", ""sms_contact"": """", ""signed_amount"": -370}, ""smsText"": ""Оплата 370р Карта*4215 KOFEPOINT Баланс 12411.24р 16:06"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-22T13:03:18.615+00:00"", ""counterparty"": ""Кофепоинт"", ""signedAmount"": -370.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-22T16:03:00+00:00""}"
|
|
||||||
"{""id"": 161, ""rawSms"": ""Зарплата 96023.86р Счет *5611 Баланс 108435.10р 16:07"", ""rawJson"": {""action"": ""salary"", ""amount"": 96023.86, ""balance"": 108435.1, ""raw_sms"": ""Зарплата 96023.86р Счет *5611 Баланс 108435.10р 16:07"", ""currency"": ""RUB"", ""sms_text"": ""Зарплата 96023.86р Счет *5611 Баланс 108435.10р 16:07"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-22T13:07:27.728Z"", ""sms_contact"": """", ""signed_amount"": 96023.86}, ""smsText"": ""Зарплата 96023.86р Счет *5611 Баланс 108435.10р 16:07"", ""category"": ""Поступления"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-22T13:07:27.728+00:00"", ""counterparty"": ""НИУ ВШЭ"", ""signedAmount"": 96023.86, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 162, ""rawSms"": ""Оплата 6250р Карта*4215 KLINIKA DEKABRI Баланс 102185.10р 16:11"", ""rawJson"": {""action"": ""payment"", ""amount"": 6250, ""balance"": 102185.1, ""raw_sms"": ""Оплата 6250р Карта*4215 KLINIKA DEKABRI Баланс 102185.10р 16:11"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 6250р Карта*4215 KLINIKA DEKABRI Баланс 102185.10р 16:11"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-22T13:11:55.710Z"", ""sms_contact"": """", ""signed_amount"": -6250}, ""smsText"": ""Оплата 6250р Карта*4215 KLINIKA DEKABRI Баланс 102185.10р 16:11"", ""category"": ""Здоровье"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-22T13:11:55.71+00:00"", ""counterparty"": ""КЛИНИКА ДЕКАБРЬ"", ""signedAmount"": -6250.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-22T16:17:00+00:00""}"
|
|
||||||
"{""id"": 163, ""rawSms"": ""Оплата 599р Карта*8214 OSTIN Баланс 101586.10р 16:58"", ""rawJson"": {""action"": ""payment"", ""amount"": 599, ""balance"": 101586.1, ""raw_sms"": ""Оплата 599р Карта*8214 OSTIN Баланс 101586.10р 16:58"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 599р Карта*8214 OSTIN Баланс 101586.10р 16:58"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-22T13:59:12.257Z"", ""sms_contact"": """", ""signed_amount"": -599}, ""smsText"": ""Оплата 599р Карта*8214 OSTIN Баланс 101586.10р 16:58"", ""category"": ""Одежда"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-22T13:59:12.257+00:00"", ""counterparty"": ""Остин"", ""signedAmount"": -599.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 164, ""rawSms"": ""Оплата 605.16р Карта*8214 PYATEROCHKA Баланс 100980.94р 18:34"", ""rawJson"": {""action"": ""payment"", ""amount"": 605.16, ""balance"": 100980.94, ""raw_sms"": ""Оплата 605.16р Карта*8214 PYATEROCHKA Баланс 100980.94р 18:34"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 605.16р Карта*8214 PYATEROCHKA Баланс 100980.94р 18:34"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-22T15:34:27.692Z"", ""sms_contact"": """", ""signed_amount"": -605.16}, ""smsText"": ""Оплата 605.16р Карта*8214 PYATEROCHKA Баланс 100980.94р 18:34"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-22T15:34:27.692+00:00"", ""counterparty"": ""Пятёрочка"", ""signedAmount"": -605.16, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-22T19:18:00+00:00""}"
|
|
||||||
"{""id"": 165, ""rawSms"": ""Оплата 434р Карта*4215 STOLOVAYA 2 Баланс 100546.94р 10:34"", ""rawJson"": {""action"": ""payment"", ""amount"": 434, ""balance"": 100546.94, ""raw_sms"": ""Оплата 434р Карта*4215 STOLOVAYA 2 Баланс 100546.94р 10:34"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 434р Карта*4215 STOLOVAYA 2 Баланс 100546.94р 10:34"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-23T07:34:31.792Z"", ""sms_contact"": """", ""signed_amount"": -434}, ""smsText"": ""Оплата 434р Карта*4215 STOLOVAYA 2 Баланс 100546.94р 10:34"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-23T07:34:31.792+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -434.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-23T10:36:00+00:00""}"
|
|
||||||
"{""id"": 166, ""rawSms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 100266.94р 13:32"", ""rawJson"": {""action"": ""payment"", ""amount"": 280, ""balance"": 100266.94, ""raw_sms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 100266.94р 13:32"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 100266.94р 13:32"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-23T10:32:17.939Z"", ""sms_contact"": """", ""signed_amount"": -280}, ""smsText"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 100266.94р 13:32"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-23T10:32:17.939+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -280.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-23T13:44:00+00:00""}"
|
|
||||||
"{""id"": 167, ""rawSms"": ""Оплата 273р Карта*8214 ULYBKA RADUGI Баланс 99993.94р 18:31"", ""rawJson"": {""action"": ""payment"", ""amount"": 273, ""balance"": 99993.94, ""raw_sms"": ""Оплата 273р Карта*8214 ULYBKA RADUGI Баланс 99993.94р 18:31"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 273р Карта*8214 ULYBKA RADUGI Баланс 99993.94р 18:31"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-23T15:31:12.438Z"", ""sms_contact"": """", ""signed_amount"": -273}, ""smsText"": ""Оплата 273р Карта*8214 ULYBKA RADUGI Баланс 99993.94р 18:31"", ""category"": ""Косметика"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-23T15:31:12.438+00:00"", ""counterparty"": ""УЛЫБКА РАДУГИ"", ""signedAmount"": -273.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 168, ""rawSms"": ""Оплата 459.50р Карта*8214 FIXPRICE 3082 Баланс 99534.44р 18:35"", ""rawJson"": {""action"": ""payment"", ""amount"": 459.5, ""balance"": 99534.44, ""raw_sms"": ""Оплата 459.50р Карта*8214 FIXPRICE 3082 Баланс 99534.44р 18:35"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 459.50р Карта*8214 FIXPRICE 3082 Баланс 99534.44р 18:35"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-23T15:35:56.366Z"", ""sms_contact"": """", ""signed_amount"": -459.5}, ""smsText"": ""Оплата 459.50р Карта*8214 FIXPRICE 3082 Баланс 99534.44р 18:35"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-23T15:35:56.366+00:00"", ""counterparty"": ""Фикс Прайс"", ""signedAmount"": -459.50, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-23T19:25:00+00:00""}"
|
|
||||||
"{""id"": 169, ""rawSms"": ""Оплата 269.70р Карта*8214 ZOOMAGAZIN CHET Баланс 99264.74р 18:40"", ""rawJson"": {""action"": ""payment"", ""amount"": 269.7, ""balance"": 99264.74, ""raw_sms"": ""Оплата 269.70р Карта*8214 ZOOMAGAZIN CHET Баланс 99264.74р 18:40"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 269.70р Карта*8214 ZOOMAGAZIN CHET Баланс 99264.74р 18:40"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-23T15:40:19.349Z"", ""sms_contact"": """", ""signed_amount"": -269.7}, ""smsText"": ""Оплата 269.70р Карта*8214 ZOOMAGAZIN CHET Баланс 99264.74р 18:40"", ""category"": ""Арчи"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-23T15:40:19.349+00:00"", ""counterparty"": ""Чет"", ""signedAmount"": -269.70, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 170, ""rawSms"": ""Оплата 4852.64р Карта*4215 RNAZK MC099 Баланс 94412.10р 19:04"", ""rawJson"": {""action"": ""payment"", ""amount"": 4852.64, ""balance"": 94412.1, ""raw_sms"": ""Оплата 4852.64р Карта*4215 RNAZK MC099 Баланс 94412.10р 19:04"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 4852.64р Карта*4215 RNAZK MC099 Баланс 94412.10р 19:04"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-24T16:04:32.788Z"", ""sms_contact"": """", ""signed_amount"": -4852.64}, ""smsText"": ""Оплата 4852.64р Карта*4215 RNAZK MC099 Баланс 94412.10р 19:04"", ""category"": ""Авто"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-24T16:04:32.788+00:00"", ""counterparty"": ""Транснефть"", ""signedAmount"": -4852.64, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 171, ""rawSms"": ""Оплата 811.94р Карта*4215 PYATEROCHKA Баланс 93600.16р 19:45"", ""rawJson"": {""action"": ""payment"", ""amount"": 811.94, ""balance"": 93600.16, ""raw_sms"": ""Оплата 811.94р Карта*4215 PYATEROCHKA Баланс 93600.16р 19:45"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 811.94р Карта*4215 PYATEROCHKA Баланс 93600.16р 19:45"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-24T16:45:24.658Z"", ""sms_contact"": """", ""signed_amount"": -811.94}, ""smsText"": ""Оплата 811.94р Карта*4215 PYATEROCHKA Баланс 93600.16р 19:45"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-24T16:45:24.658+00:00"", ""counterparty"": ""Пятёрочка"", ""signedAmount"": -811.94, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-24T19:57:00+00:00""}"
|
|
||||||
"{""id"": 172, ""rawSms"": ""Оплата 15600р Карта*4215 OOO Vetradiolog Баланс 78000.16р 18:43"", ""rawJson"": {""action"": ""payment"", ""amount"": 15600, ""balance"": 78000.16, ""raw_sms"": ""Оплата 15600р Карта*4215 OOO Vetradiolog Баланс 78000.16р 18:43"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 15600р Карта*4215 OOO Vetradiolog Баланс 78000.16р 18:43"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-25T15:43:59.036Z"", ""sms_contact"": """", ""signed_amount"": -15600}, ""smsText"": ""Оплата 15600р Карта*4215 OOO Vetradiolog Баланс 78000.16р 18:43"", ""category"": ""Арчи"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-25T15:43:59.036+00:00"", ""counterparty"": ""Ветрдиагностика"", ""signedAmount"": -15600.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 173, ""rawSms"": ""Оплата 1040р Карта*4215 EVO_BUBBLES TEA Баланс 76960.16р 20:04"", ""rawJson"": {""action"": ""payment"", ""amount"": 1040, ""balance"": 76960.16, ""raw_sms"": ""Оплата 1040р Карта*4215 EVO_BUBBLES TEA Баланс 76960.16р 20:04"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 1040р Карта*4215 EVO_BUBBLES TEA Баланс 76960.16р 20:04"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-25T17:04:13.766Z"", ""sms_contact"": """", ""signed_amount"": -1040}, ""smsText"": ""Оплата 1040р Карта*4215 EVO_BUBBLES TEA Баланс 76960.16р 20:04"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-25T17:04:13.766+00:00"", ""counterparty"": ""EVO БУБЛИКИ ТЕА"", ""signedAmount"": -1040.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 174, ""rawSms"": ""Оплата 600р Карта*4215 kofeinya kondit Баланс 76360.16р 20:10"", ""rawJson"": {""action"": ""payment"", ""amount"": 600, ""balance"": 76360.16, ""raw_sms"": ""Оплата 600р Карта*4215 kofeinya kondit Баланс 76360.16р 20:10"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 600р Карта*4215 kofeinya kondit Баланс 76360.16р 20:10"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-25T17:10:43.889Z"", ""sms_contact"": """", ""signed_amount"": -600}, ""smsText"": ""Оплата 600р Карта*4215 kofeinya kondit Баланс 76360.16р 20:10"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-25T17:10:43.889+00:00"", ""counterparty"": ""Кофейня Кондит"", ""signedAmount"": -600.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-25T20:14:00+00:00""}"
|
|
||||||
"{""id"": 175, ""rawSms"": ""Оплата 7540р Карта*4215 STOCKMANN KUSTY Баланс 68820.16р 20:36"", ""rawJson"": {""action"": ""payment"", ""amount"": 7540, ""balance"": 68820.16, ""raw_sms"": ""Оплата 7540р Карта*4215 STOCKMANN KUSTY Баланс 68820.16р 20:36"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 7540р Карта*4215 STOCKMANN KUSTY Баланс 68820.16р 20:36"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-25T17:36:20.285Z"", ""sms_contact"": """", ""signed_amount"": -7540}, ""smsText"": ""Оплата 7540р Карта*4215 STOCKMANN KUSTY Баланс 68820.16р 20:36"", ""category"": ""Одежда"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-25T17:36:20.285+00:00"", ""counterparty"": ""Стокманн"", ""signedAmount"": -7540.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-25T20:54:00+00:00""}"
|
|
||||||
"{""id"": 176, ""rawSms"": ""Оплата 4830р Карта*4215 OOO Vetradiolog Баланс 63990.16р 20:53"", ""rawJson"": {""action"": ""payment"", ""amount"": 4830, ""balance"": 63990.16, ""raw_sms"": ""Оплата 4830р Карта*4215 OOO Vetradiolog Баланс 63990.16р 20:53"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 4830р Карта*4215 OOO Vetradiolog Баланс 63990.16р 20:53"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-25T17:53:53.743Z"", ""sms_contact"": """", ""signed_amount"": -4830}, ""smsText"": ""Оплата 4830р Карта*4215 OOO Vetradiolog Баланс 63990.16р 20:53"", ""category"": ""Арчи"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-25T17:53:53.743+00:00"", ""counterparty"": ""Ветра diolog"", ""signedAmount"": -4830.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 177, ""rawSms"": ""Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 63989.16р 09:19"", ""rawJson"": {""action"": ""payment"", ""amount"": 1, ""balance"": 63989.16, ""raw_sms"": ""Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 63989.16р 09:19"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 63989.16р 09:19"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-26T06:19:33.226Z"", ""sms_contact"": """", ""signed_amount"": -1}, ""smsText"": ""Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 63989.16р 09:19"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-26T06:19:33.226+00:00"", ""counterparty"": ""Перекрёсток"", ""signedAmount"": -1.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-26T09:19:00+00:00""}"
|
|
||||||
"{""id"": 178, ""rawSms"": ""Отмена: Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 63990.16р 09:19"", ""rawJson"": {""action"": ""reversal"", ""amount"": 1, ""balance"": 63990.16, ""raw_sms"": ""Отмена: Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 63990.16р 09:19"", ""currency"": ""RUB"", ""sms_text"": ""Отмена: Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 63990.16р 09:19"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-26T06:19:37.735Z"", ""sms_contact"": """", ""signed_amount"": 1}, ""smsText"": ""Отмена: Оплата 1р Карта*2249 DOSTAVKA PEREKR Баланс 63990.16р 09:19"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-26T06:19:37.735+00:00"", ""counterparty"": ""Доставка Перекрёсток"", ""signedAmount"": 1.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-26T09:19:00+00:00""}"
|
|
||||||
"{""id"": 179, ""rawSms"": ""Оплата 120р Карта*4215 STOLOVAYA 2 Баланс 63870.16р 09:41"", ""rawJson"": {""action"": ""payment"", ""amount"": 120, ""balance"": 63870.16, ""raw_sms"": ""Оплата 120р Карта*4215 STOLOVAYA 2 Баланс 63870.16р 09:41"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 120р Карта*4215 STOLOVAYA 2 Баланс 63870.16р 09:41"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-26T06:41:47.293Z"", ""sms_contact"": """", ""signed_amount"": -120}, ""smsText"": ""Оплата 120р Карта*4215 STOLOVAYA 2 Баланс 63870.16р 09:41"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-26T06:41:47.293+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -120.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-26T09:42:00+00:00""}"
|
|
||||||
"{""id"": 180, ""rawSms"": ""Оплата 3732.39р Карта*2249 DOSTAVKA PEREKR Баланс 60137.77р 10:03"", ""rawJson"": {""action"": ""payment"", ""amount"": 3732.39, ""balance"": 60137.77, ""raw_sms"": ""Оплата 3732.39р Карта*2249 DOSTAVKA PEREKR Баланс 60137.77р 10:03"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 3732.39р Карта*2249 DOSTAVKA PEREKR Баланс 60137.77р 10:03"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-26T07:03:15.917Z"", ""sms_contact"": """", ""signed_amount"": -3732.39}, ""smsText"": ""Оплата 3732.39р Карта*2249 DOSTAVKA PEREKR Баланс 60137.77р 10:03"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-26T07:03:15.917+00:00"", ""counterparty"": ""Перекрёсток"", ""signedAmount"": -3732.39, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-26T10:03:00+00:00""}"
|
|
||||||
"{""id"": 181, ""rawSms"": ""Оплата 280р Карта*4215 JEFFREY S COFFE Баланс 59857.77р 11:42"", ""rawJson"": {""action"": ""payment"", ""amount"": 280, ""balance"": 59857.77, ""raw_sms"": ""Оплата 280р Карта*4215 JEFFREY S COFFE Баланс 59857.77р 11:42"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 280р Карта*4215 JEFFREY S COFFE Баланс 59857.77р 11:42"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-26T08:42:15.405Z"", ""sms_contact"": """", ""signed_amount"": -280}, ""smsText"": ""Оплата 280р Карта*4215 JEFFREY S COFFE Баланс 59857.77р 11:42"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-26T08:42:15.405+00:00"", ""counterparty"": ""Джейфри С Кофе"", ""signedAmount"": -280.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-26T12:24:00+00:00""}"
|
|
||||||
"{""id"": 182, ""rawSms"": ""Оплата 310р Карта*2249 OZON.RU Баланс 59547.77р 13:57"", ""rawJson"": {""action"": ""payment"", ""amount"": 310, ""balance"": 59547.77, ""raw_sms"": ""Оплата 310р Карта*2249 OZON.RU Баланс 59547.77р 13:57"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 310р Карта*2249 OZON.RU Баланс 59547.77р 13:57"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-26T10:57:18.774Z"", ""sms_contact"": """", ""signed_amount"": -310}, ""smsText"": ""Оплата 310р Карта*2249 OZON.RU Баланс 59547.77р 13:57"", ""category"": ""Дом"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-26T10:57:18.774+00:00"", ""counterparty"": ""OZON"", ""signedAmount"": -310.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-26T13:57:00+00:00""}"
|
|
||||||
"{""id"": 183, ""rawSms"": ""Оплата 5000р Карта*8214 IP KOVTUN L.B. Баланс 54547.77р 14:45"", ""rawJson"": {""action"": ""payment"", ""amount"": 5000, ""balance"": 54547.77, ""raw_sms"": ""Оплата 5000р Карта*8214 IP KOVTUN L.B. Баланс 54547.77р 14:45"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 5000р Карта*8214 IP KOVTUN L.B. Баланс 54547.77р 14:45"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-26T11:45:17.764Z"", ""sms_contact"": """", ""signed_amount"": -5000}, ""smsText"": ""Оплата 5000р Карта*8214 IP KOVTUN L.B. Баланс 54547.77р 14:45"", ""category"": ""Авто"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-26T11:45:17.764+00:00"", ""counterparty"": ""Парковка"", ""signedAmount"": -5000.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 184, ""rawSms"": ""Оплата 571.84р Карта*8214 PYATEROCHKA Баланс 53975.93р 10:50"", ""rawJson"": {""action"": ""payment"", ""amount"": 571.84, ""balance"": 53975.93, ""raw_sms"": ""Оплата 571.84р Карта*8214 PYATEROCHKA Баланс 53975.93р 10:50"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 571.84р Карта*8214 PYATEROCHKA Баланс 53975.93р 10:50"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-27T07:51:08.212Z"", ""sms_contact"": """", ""signed_amount"": -571.84}, ""smsText"": ""Оплата 571.84р Карта*8214 PYATEROCHKA Баланс 53975.93р 10:50"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-27T07:51:08.212+00:00"", ""counterparty"": ""Пятёрочка"", ""signedAmount"": -571.84, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-27T12:27:00+00:00""}"
|
|
||||||
"{""id"": 185, ""rawSms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 53695.93р 13:34"", ""rawJson"": {""action"": ""payment"", ""amount"": 280, ""balance"": 53695.93, ""raw_sms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 53695.93р 13:34"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 53695.93р 13:34"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-27T10:35:03.934Z"", ""sms_contact"": """", ""signed_amount"": -280}, ""smsText"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 53695.93р 13:34"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-27T10:35:03.934+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -280.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-27T13:58:00+00:00""}"
|
|
||||||
"{""id"": 186, ""rawSms"": ""Оплата 240р Карта*4215 STOLOVAYA 2 Баланс 53455.93р 17:16"", ""rawJson"": {""action"": ""payment"", ""amount"": 240, ""balance"": 53455.93, ""raw_sms"": ""Оплата 240р Карта*4215 STOLOVAYA 2 Баланс 53455.93р 17:16"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 240р Карта*4215 STOLOVAYA 2 Баланс 53455.93р 17:16"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-27T14:16:54.198Z"", ""sms_contact"": """", ""signed_amount"": -240}, ""smsText"": ""Оплата 240р Карта*4215 STOLOVAYA 2 Баланс 53455.93р 17:16"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-27T14:16:54.198+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -240.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-27T18:06:00+00:00""}"
|
|
||||||
"{""id"": 187, ""rawSms"": ""Оплата 266р Карта*2249 OZON.ru Баланс 53189.93р 17:18"", ""rawJson"": {""action"": ""payment"", ""amount"": 266, ""balance"": 53189.93, ""raw_sms"": ""Оплата 266р Карта*2249 OZON.ru Баланс 53189.93р 17:18"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 266р Карта*2249 OZON.ru Баланс 53189.93р 17:18"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-27T14:18:58.621Z"", ""sms_contact"": """", ""signed_amount"": -266}, ""smsText"": ""Оплата 266р Карта*2249 OZON.ru Баланс 53189.93р 17:18"", ""category"": ""Дом"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-27T14:18:58.621+00:00"", ""counterparty"": ""OZON"", ""signedAmount"": -266.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-27T18:06:00+00:00""}"
|
|
||||||
"{""id"": 188, ""rawSms"": ""Перевод 562.50р Счет*5611 УФК по г. Москв. Баланс 52064.93р 05:50"", ""rawJson"": {""action"": ""transfer"", ""amount"": 562.5, ""balance"": 52064.93, ""raw_sms"": ""Перевод 562.50р Счет*5611 УФК по г. Москв. Баланс 52064.93р 05:50"", ""currency"": ""RUB"", ""sms_text"": ""Перевод 562.50р Счет*5611 УФК по г. Москв. Баланс 52064.93р 05:50"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-28T02:50:31.856Z"", ""sms_contact"": """", ""signed_amount"": -562.5}, ""smsText"": ""Перевод 562.50р Счет*5611 УФК по г. Москв. Баланс 52064.93р 05:50"", ""category"": ""Штрафы"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-28T02:50:31.856+00:00"", ""counterparty"": ""УФК по г. Москве"", ""signedAmount"": -562.50, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 189, ""rawSms"": ""Оплата 205р Карта*4215 STOLOVAYA 2 Баланс 51859.93р 09:50"", ""rawJson"": {""action"": ""payment"", ""amount"": 205, ""balance"": 51859.93, ""raw_sms"": ""Оплата 205р Карта*4215 STOLOVAYA 2 Баланс 51859.93р 09:50"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 205р Карта*4215 STOLOVAYA 2 Баланс 51859.93р 09:50"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-28T06:50:20.636Z"", ""sms_contact"": """", ""signed_amount"": -205}, ""smsText"": ""Оплата 205р Карта*4215 STOLOVAYA 2 Баланс 51859.93р 09:50"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-28T06:50:20.636+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -205.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-28T09:51:00+00:00""}"
|
|
||||||
"{""id"": 190, ""rawSms"": ""Оплата 999р Карта*2249 OZON Баланс 50860.93р 11:03"", ""rawJson"": {""action"": ""payment"", ""amount"": 999, ""balance"": 50860.93, ""raw_sms"": ""Оплата 999р Карта*2249 OZON Баланс 50860.93р 11:03"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 999р Карта*2249 OZON Баланс 50860.93р 11:03"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-28T08:03:40.125Z"", ""sms_contact"": """", ""signed_amount"": -999}, ""smsText"": ""Оплата 999р Карта*2249 OZON Баланс 50860.93р 11:03"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-28T08:03:40.125+00:00"", ""counterparty"": ""OZON"", ""signedAmount"": -999.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 191, ""rawSms"": ""Оплата 394р Карта*4215 STOLOVAYA 2 Баланс 50466.93р 13:16"", ""rawJson"": {""action"": ""payment"", ""amount"": 394, ""balance"": 50466.93, ""raw_sms"": ""Оплата 394р Карта*4215 STOLOVAYA 2 Баланс 50466.93р 13:16"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 394р Карта*4215 STOLOVAYA 2 Баланс 50466.93р 13:16"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-28T10:16:44.623Z"", ""sms_contact"": """", ""signed_amount"": -394}, ""smsText"": ""Оплата 394р Карта*4215 STOLOVAYA 2 Баланс 50466.93р 13:16"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-28T10:16:44.623+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -394.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-28T15:45:00+00:00""}"
|
|
||||||
"{""id"": 192, ""rawSms"": ""Оплата 185.77р Карта*8214 PYATEROCHKA Баланс 50281.16р 15:34"", ""rawJson"": {""action"": ""payment"", ""amount"": 185.77, ""balance"": 50281.16, ""raw_sms"": ""Оплата 185.77р Карта*8214 PYATEROCHKA Баланс 50281.16р 15:34"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 185.77р Карта*8214 PYATEROCHKA Баланс 50281.16р 15:34"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-28T12:34:37.686Z"", ""sms_contact"": """", ""signed_amount"": -185.77}, ""smsText"": ""Оплата 185.77р Карта*8214 PYATEROCHKA Баланс 50281.16р 15:34"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-28T12:34:37.686+00:00"", ""counterparty"": ""Пятёрочка"", ""signedAmount"": -185.77, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-28T15:44:00+00:00""}"
|
|
||||||
"{""id"": 193, ""rawSms"": ""Оплата 573р Карта*2249 YANDEX*5399*MAR Баланс 49708.16р 15:36"", ""rawJson"": {""action"": ""payment"", ""amount"": 573, ""balance"": 49708.16, ""raw_sms"": ""Оплата 573р Карта*2249 YANDEX*5399*MAR Баланс 49708.16р 15:36"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 573р Карта*2249 YANDEX*5399*MAR Баланс 49708.16р 15:36"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-28T12:36:35.628Z"", ""sms_contact"": """", ""signed_amount"": -573}, ""smsText"": ""Оплата 573р Карта*2249 YANDEX*5399*MAR Баланс 49708.16р 15:36"", ""category"": ""Подарки"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-28T12:36:35.628+00:00"", ""counterparty"": ""Яндекс"", ""signedAmount"": -573.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 194, ""rawSms"": ""Оплата 160р Карта*4215 STOLOVAYA 2 Баланс 49548.16р 09:04"", ""rawJson"": {""action"": ""payment"", ""amount"": 160, ""balance"": 49548.16, ""raw_sms"": ""Оплата 160р Карта*4215 STOLOVAYA 2 Баланс 49548.16р 09:04"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 160р Карта*4215 STOLOVAYA 2 Баланс 49548.16р 09:04"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-29T06:05:04.411Z"", ""sms_contact"": """", ""signed_amount"": -160}, ""smsText"": ""Оплата 160р Карта*4215 STOLOVAYA 2 Баланс 49548.16р 09:04"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-29T06:05:04.411+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -160.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-29T09:06:00+00:00""}"
|
|
||||||
"{""id"": 195, ""rawSms"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 49323.16р 09:43"", ""rawJson"": {""action"": ""payment"", ""amount"": 225, ""balance"": 49323.16, ""raw_sms"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 49323.16р 09:43"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 49323.16р 09:43"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-29T06:43:26.735Z"", ""sms_contact"": """", ""signed_amount"": -225}, ""smsText"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 49323.16р 09:43"", ""category"": ""Спорт"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-29T06:43:26.735+00:00"", ""counterparty"": ""Анта-спорт"", ""signedAmount"": -225.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-29T09:43:00+00:00""}"
|
|
||||||
"{""id"": 196, ""rawSms"": ""Оплата 12832р Карта*2249 SANTEKHNIKA ONL Баланс 36491.16р 09:49"", ""rawJson"": {""action"": ""payment"", ""amount"": 12832, ""balance"": 36491.16, ""raw_sms"": ""Оплата 12832р Карта*2249 SANTEKHNIKA ONL Баланс 36491.16р 09:49"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 12832р Карта*2249 SANTEKHNIKA ONL Баланс 36491.16р 09:49"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-29T06:49:54.523Z"", ""sms_contact"": """", ""signed_amount"": -12832}, ""smsText"": ""Оплата 12832р Карта*2249 SANTEKHNIKA ONL Баланс 36491.16р 09:49"", ""category"": ""Дом"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-29T06:49:54.523+00:00"", ""counterparty"": ""Сантехника Онлайн"", ""signedAmount"": -12832.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-29T09:50:00+00:00""}"
|
|
||||||
"{""id"": 197, ""rawSms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 36211.16р 12:48"", ""rawJson"": {""action"": ""payment"", ""amount"": 280, ""balance"": 36211.16, ""raw_sms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 36211.16р 12:48"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 36211.16р 12:48"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-29T09:48:25.045Z"", ""sms_contact"": """", ""signed_amount"": -280}, ""smsText"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 36211.16р 12:48"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-29T09:48:25.045+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -280.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-29T13:13:00+00:00""}"
|
|
||||||
"{""id"": 198, ""rawSms"": ""Оплата 200р Карта*8214 CPPK-2000012-BP Баланс 36011.16р 12:58"", ""rawJson"": {""action"": ""payment"", ""amount"": 200, ""balance"": 36011.16, ""raw_sms"": ""Оплата 200р Карта*8214 CPPK-2000012-BP Баланс 36011.16р 12:58"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 200р Карта*8214 CPPK-2000012-BP Баланс 36011.16р 12:58"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-29T09:59:01.642Z"", ""sms_contact"": """", ""signed_amount"": -200}, ""smsText"": ""Оплата 200р Карта*8214 CPPK-2000012-BP Баланс 36011.16р 12:58"", ""category"": ""Проезд"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-29T09:59:01.642+00:00"", ""counterparty"": ""ЦППК"", ""signedAmount"": -200.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 199, ""rawSms"": ""Оплата 83р Карта*8214 Mos.Transport Баланс 35928.16р 13:34"", ""rawJson"": {""action"": ""payment"", ""amount"": 83, ""balance"": 35928.16, ""raw_sms"": ""Оплата 83р Карта*8214 Mos.Transport Баланс 35928.16р 13:34"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 83р Карта*8214 Mos.Transport Баланс 35928.16р 13:34"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-29T11:12:21.107Z"", ""sms_contact"": """", ""signed_amount"": -83}, ""smsText"": ""Оплата 83р Карта*8214 Mos.Transport Баланс 35928.16р 13:34"", ""category"": ""Проезд"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-29T11:12:21.107+00:00"", ""counterparty"": ""Московские Транспортные Системы"", ""signedAmount"": -83.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-29T14:44:00+00:00""}"
|
|
||||||
"{""id"": 200, ""rawSms"": ""Оплата 258р Карта*8214 ULYBKA RADUGI Баланс 35670.16р 14:41"", ""rawJson"": {""action"": ""payment"", ""amount"": 258, ""balance"": 35670.16, ""raw_sms"": ""Оплата 258р Карта*8214 ULYBKA RADUGI Баланс 35670.16р 14:41"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 258р Карта*8214 ULYBKA RADUGI Баланс 35670.16р 14:41"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-29T11:41:31.862Z"", ""sms_contact"": """", ""signed_amount"": -258}, ""smsText"": ""Оплата 258р Карта*8214 ULYBKA RADUGI Баланс 35670.16р 14:41"", ""category"": ""Здоровье"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-29T11:41:31.862+00:00"", ""counterparty"": ""УЛЫБКА РАДУГИ"", ""signedAmount"": -258.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 201, ""rawSms"": ""Оплата 2500р Карта*2249 card-runc Баланс 33170.16р 14:54"", ""rawJson"": {""action"": ""payment"", ""amount"": 2500, ""balance"": 33170.16, ""raw_sms"": ""Оплата 2500р Карта*2249 card-runc Баланс 33170.16р 14:54"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 2500р Карта*2249 card-runc Баланс 33170.16р 14:54"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-29T11:54:28.137Z"", ""sms_contact"": """", ""signed_amount"": -2500}, ""smsText"": ""Оплата 2500р Карта*2249 card-runc Баланс 33170.16р 14:54"", ""category"": ""Спорт"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-29T11:54:28.137+00:00"", ""counterparty"": ""RUNC.RUN"", ""signedAmount"": -2500.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 202, ""rawSms"": ""Оплата 300р Карта*8214 Moskva Metro Баланс 32870.16р 15:12"", ""rawJson"": {""action"": ""payment"", ""amount"": 300, ""balance"": 32870.16, ""raw_sms"": ""Оплата 300р Карта*8214 Moskva Metro Баланс 32870.16р 15:12"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 300р Карта*8214 Moskva Metro Баланс 32870.16р 15:12"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-29T12:12:39.451Z"", ""sms_contact"": """", ""signed_amount"": -300}, ""smsText"": ""Оплата 300р Карта*8214 Moskva Metro Баланс 32870.16р 15:12"", ""category"": ""Проезд"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-29T12:12:39.451+00:00"", ""counterparty"": ""Московский метрополитен"", ""signedAmount"": -300.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-29T15:18:00+00:00""}"
|
|
||||||
"{""id"": 203, ""rawSms"": ""Оплата 293р Карта*4215 STOLOVAYA 2 Баланс 32577.16р 17:42"", ""rawJson"": {""action"": ""payment"", ""amount"": 293, ""balance"": 32577.16, ""raw_sms"": ""Оплата 293р Карта*4215 STOLOVAYA 2 Баланс 32577.16р 17:42"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 293р Карта*4215 STOLOVAYA 2 Баланс 32577.16р 17:42"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-29T14:42:58.043Z"", ""sms_contact"": """", ""signed_amount"": -293}, ""smsText"": ""Оплата 293р Карта*4215 STOLOVAYA 2 Баланс 32577.16р 17:42"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-29T14:42:58.043+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -293.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-29T17:57:00+00:00""}"
|
|
||||||
"{""id"": 204, ""rawSms"": ""Перевод 6400р Счет*5611 Милана К. Баланс 26177.16р 18:40"", ""rawJson"": {""action"": ""transfer"", ""amount"": 6400, ""balance"": 26177.16, ""raw_sms"": ""Перевод 6400р Счет*5611 Милана К. Баланс 26177.16р 18:40"", ""currency"": ""RUB"", ""sms_text"": ""Перевод 6400р Счет*5611 Милана К. Баланс 26177.16р 18:40"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-29T15:40:16.808Z"", ""sms_contact"": """", ""signed_amount"": -6400}, ""smsText"": ""Перевод 6400р Счет*5611 Милана К. Баланс 26177.16р 18:40"", ""category"": ""Спорт"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-29T15:40:16.808+00:00"", ""counterparty"": ""Милана К."", ""signedAmount"": -6400.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 205, ""rawSms"": ""Оплата 83р Карта*4215 Mos.Transport Баланс 26094.16р 19:20"", ""rawJson"": {""action"": ""payment"", ""amount"": 83, ""balance"": 26094.16, ""raw_sms"": ""Оплата 83р Карта*4215 Mos.Transport Баланс 26094.16р 19:20"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 83р Карта*4215 Mos.Transport Баланс 26094.16р 19:20"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-29T17:03:33.778Z"", ""sms_contact"": """", ""signed_amount"": -83}, ""smsText"": ""Оплата 83р Карта*4215 Mos.Transport Баланс 26094.16р 19:20"", ""category"": ""Проезд"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-29T17:03:33.778+00:00"", ""counterparty"": ""Московские Транспортные Системы"", ""signedAmount"": -83.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-30T00:18:00+00:00""}"
|
|
||||||
"{""id"": 206, ""rawSms"": ""Оплата 83р Карта*4215 Mos.Transport Баланс 26011.16р 22:11"", ""rawJson"": {""action"": ""payment"", ""amount"": 83, ""balance"": 26011.16, ""raw_sms"": ""Оплата 83р Карта*4215 Mos.Transport Баланс 26011.16р 22:11"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 83р Карта*4215 Mos.Transport Баланс 26011.16р 22:11"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-29T19:51:56.328Z"", ""sms_contact"": """", ""signed_amount"": -83}, ""smsText"": ""Оплата 83р Карта*4215 Mos.Transport Баланс 26011.16р 22:11"", ""category"": ""Проезд"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-29T19:51:56.328+00:00"", ""counterparty"": ""Московские Транспортные Системы"", ""signedAmount"": -83.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-30T00:18:00+00:00""}"
|
|
||||||
"{""id"": 207, ""rawSms"": ""Оплата 200р Карта*4215 SKBDSS403159 Баланс 25811.16р 22:52"", ""rawJson"": {""action"": ""payment"", ""amount"": 200, ""balance"": 25811.16, ""raw_sms"": ""Оплата 200р Карта*4215 SKBDSS403159 Баланс 25811.16р 22:52"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 200р Карта*4215 SKBDSS403159 Баланс 25811.16р 22:52"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-29T19:52:10.152Z"", ""sms_contact"": """", ""signed_amount"": -200}, ""smsText"": ""Оплата 200р Карта*4215 SKBDSS403159 Баланс 25811.16р 22:52"", ""category"": ""Авто"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-29T19:52:10.152+00:00"", ""counterparty"": ""Парковка"", ""signedAmount"": -200.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 208, ""rawSms"": ""Оплата 203р Карта*2249 OZON.ru Баланс 25608.16р 14:48"", ""rawJson"": {""action"": ""payment"", ""amount"": 203, ""balance"": 25608.16, ""raw_sms"": ""Оплата 203р Карта*2249 OZON.ru Баланс 25608.16р 14:48"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 203р Карта*2249 OZON.ru Баланс 25608.16р 14:48"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-30T11:48:31.965Z"", ""sms_contact"": """", ""signed_amount"": -203}, ""smsText"": ""Оплата 203р Карта*2249 OZON.ru Баланс 25608.16р 14:48"", ""category"": ""Дом"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-30T11:48:31.965+00:00"", ""counterparty"": ""OZON"", ""signedAmount"": -203.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-30T14:58:00+00:00""}"
|
|
||||||
"{""id"": 209, ""rawSms"": ""Оплата 4765.66р Карта*4215 RNAZK MC013 Баланс 20842.50р 16:16"", ""rawJson"": {""action"": ""payment"", ""amount"": 4765.66, ""balance"": 20842.5, ""raw_sms"": ""Оплата 4765.66р Карта*4215 RNAZK MC013 Баланс 20842.50р 16:16"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 4765.66р Карта*4215 RNAZK MC013 Баланс 20842.50р 16:16"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-30T13:16:16.509Z"", ""sms_contact"": """", ""signed_amount"": -4765.66}, ""smsText"": ""Оплата 4765.66р Карта*4215 RNAZK MC013 Баланс 20842.50р 16:16"", ""category"": ""Авто"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-30T13:16:16.509+00:00"", ""counterparty"": ""Транснефть"", ""signedAmount"": -4765.66, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 210, ""rawSms"": ""Оплата 348р Карта*4215 STOLOVAYA 2 Баланс 20494.50р 18:04"", ""rawJson"": {""action"": ""payment"", ""amount"": 348, ""balance"": 20494.5, ""raw_sms"": ""Оплата 348р Карта*4215 STOLOVAYA 2 Баланс 20494.50р 18:04"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 348р Карта*4215 STOLOVAYA 2 Баланс 20494.50р 18:04"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-30T15:04:35.373Z"", ""sms_contact"": """", ""signed_amount"": -348}, ""smsText"": ""Оплата 348р Карта*4215 STOLOVAYA 2 Баланс 20494.50р 18:04"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-30T15:04:35.373+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -348.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-01-30T18:16:00+00:00""}"
|
|
||||||
"{""id"": 211, ""rawSms"": ""Оплата 3500р Карта*4215 RESTORAN Баланс 16994.50р 19:15"", ""rawJson"": {""action"": ""payment"", ""amount"": 3500, ""balance"": 16994.5, ""raw_sms"": ""Оплата 3500р Карта*4215 RESTORAN Баланс 16994.50р 19:15"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 3500р Карта*4215 RESTORAN Баланс 16994.50р 19:15"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-30T16:15:27.642Z"", ""sms_contact"": """", ""signed_amount"": -3500}, ""smsText"": ""Оплата 3500р Карта*4215 RESTORAN Баланс 16994.50р 19:15"", ""category"": ""Развлечения"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-30T16:15:27.642+00:00"", ""counterparty"": ""Рублевские бани"", ""signedAmount"": -3500.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 212, ""rawSms"": ""Оплата 13860р Карта*4215 RUBLEVSKIE BANI Баланс 3134.50р 22:46"", ""rawJson"": {""action"": ""payment"", ""amount"": 13860, ""balance"": 3134.5, ""raw_sms"": ""Оплата 13860р Карта*4215 RUBLEVSKIE BANI Баланс 3134.50р 22:46"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 13860р Карта*4215 RUBLEVSKIE BANI Баланс 3134.50р 22:46"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-30T19:46:29.341Z"", ""sms_contact"": """", ""signed_amount"": -13860}, ""smsText"": ""Оплата 13860р Карта*4215 RUBLEVSKIE BANI Баланс 3134.50р 22:46"", ""category"": ""Развлечения"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-30T19:46:29.341+00:00"", ""counterparty"": ""Бани Русские"", ""signedAmount"": -13860.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 215, ""rawSms"": ""Поступление 3300р Счет*5611 от АНДРЕЙ С. Баланс 11934.50р 23:00"", ""rawJson"": {""action"": ""income"", ""amount"": 3300, ""balance"": 11934.5, ""raw_sms"": ""Поступление 3300р Счет*5611 от АНДРЕЙ С. Баланс 11934.50р 23:00"", ""currency"": ""RUB"", ""sms_text"": ""Поступление 3300р Счет*5611 от АНДРЕЙ С. Баланс 11934.50р 23:00"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-01-30T20:01:03.803Z"", ""sms_contact"": """", ""signed_amount"": 3300}, ""smsText"": ""Поступление 3300р Счет*5611 от АНДРЕЙ С. Баланс 11934.50р 23:00"", ""category"": ""Развлечения"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-01-30T20:01:03.803+00:00"", ""counterparty"": ""Соколов"", ""signedAmount"": 3300.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 216, ""rawSms"": ""Оплата 5050р Карта*4215 Deli Баланс 527000.70р 11:51"", ""rawJson"": {""action"": ""payment"", ""amount"": 5050, ""balance"": 527000.7, ""raw_sms"": ""Оплата 5050р Карта*4215 Deli Баланс 527000.70р 11:51"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 5050р Карта*4215 Deli Баланс 527000.70р 11:51"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-02-02T20:12:39.119Z"", ""sms_contact"": null, ""signed_amount"": -5050}, ""smsText"": ""Оплата 5050р Карта*4215 Deli Баланс 527000.70р 11:51"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-02-02T20:12:39.119+00:00"", ""counterparty"": ""Deli"", ""signedAmount"": -5050.00, ""humanVerified"": false, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 217, ""rawSms"": ""Оплата 265р Карта*4215 STOLOVAYA 2 Баланс 4223.31р 09:51"", ""rawJson"": {""action"": ""payment"", ""amount"": 265, ""balance"": 4223.31, ""raw_sms"": ""Оплата 265р Карта*4215 STOLOVAYA 2 Баланс 4223.31р 09:51"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 265р Карта*4215 STOLOVAYA 2 Баланс 4223.31р 09:51"", ""sms_sender"": ""VTB"", ""received_at"": ""2026-02-03T06:51:51.879Z"", ""sms_contact"": """", ""signed_amount"": -265}, ""smsText"": ""Оплата 265р Карта*4215 STOLOVAYA 2 Баланс 4223.31р 09:51"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": ""VTB"", ""receivedAt"": ""2026-02-03T06:51:51.879+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -265.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-03T09:52:00+00:00""}"
|
|
||||||
"{""id"": 218, ""rawSms"": ""Оплата 618р Карта*2249 PT*Aliexpress Баланс 3605.31р 13:01"", ""rawJson"": {""action"": ""payment"", ""amount"": 618, ""balance"": 3605.31, ""raw_sms"": ""Оплата 618р Карта*2249 PT*Aliexpress Баланс 3605.31р 13:01"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 618р Карта*2249 PT*Aliexpress Баланс 3605.31р 13:01"", ""sms_sender"": null, ""received_at"": ""2026-02-03T10:01:13.838Z"", ""sms_contact"": null, ""signed_amount"": -618}, ""smsText"": ""Оплата 618р Карта*2249 PT*Aliexpress Баланс 3605.31р 13:01"", ""category"": ""Подарки"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-03T10:01:13.838+00:00"", ""counterparty"": ""Алиэкспресс"", ""signedAmount"": -618.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 219, ""rawSms"": ""Оплата 1289р Карта*2249 PT*Aliexpress Баланс 2316.31р 13:01"", ""rawJson"": {""action"": ""payment"", ""amount"": 1289, ""balance"": 2316.31, ""raw_sms"": ""Оплата 1289р Карта*2249 PT*Aliexpress Баланс 2316.31р 13:01"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 1289р Карта*2249 PT*Aliexpress Баланс 2316.31р 13:01"", ""sms_sender"": null, ""received_at"": ""2026-02-03T10:01:55.496Z"", ""sms_contact"": null, ""signed_amount"": -1289}, ""smsText"": ""Оплата 1289р Карта*2249 PT*Aliexpress Баланс 2316.31р 13:01"", ""category"": ""Одежда"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-03T10:01:55.496+00:00"", ""counterparty"": ""Алиэкспресс"", ""signedAmount"": -1289.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 220, ""rawSms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 2036.31р 14:04"", ""rawJson"": {""action"": ""payment"", ""amount"": 280, ""balance"": 2036.31, ""raw_sms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 2036.31р 14:04"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 2036.31р 14:04"", ""sms_sender"": null, ""received_at"": ""2026-02-03T11:04:16.430Z"", ""sms_contact"": null, ""signed_amount"": -280}, ""smsText"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 2036.31р 14:04"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-03T11:04:16.43+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -280.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-03T21:36:00+00:00""}"
|
|
||||||
"{""id"": 221, ""rawSms"": ""Перевод 650р Счет*5611 МегаФон. Баланс 1386.31р 21:35"", ""rawJson"": {""action"": ""transfer"", ""amount"": 650, ""balance"": 1386.31, ""raw_sms"": ""Перевод 650р Счет*5611 МегаФон. Баланс 1386.31р 21:35"", ""currency"": ""RUB"", ""sms_text"": ""Перевод 650р Счет*5611 МегаФон. Баланс 1386.31р 21:35"", ""sms_sender"": null, ""received_at"": ""2026-02-03T18:35:42.070Z"", ""sms_contact"": null, ""signed_amount"": -650}, ""smsText"": ""Перевод 650р Счет*5611 МегаФон. Баланс 1386.31р 21:35"", ""category"": ""Подписки"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-03T18:35:42.07+00:00"", ""counterparty"": ""МегаФон"", ""signedAmount"": -650.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-03T21:36:00+00:00""}"
|
|
||||||
"{""id"": 222, ""rawSms"": ""Оплата 120р Карта*4215 STOLOVAYA 2 Баланс 1266.31р 09:34"", ""rawJson"": {""action"": ""payment"", ""amount"": 120, ""balance"": 1266.31, ""raw_sms"": ""Оплата 120р Карта*4215 STOLOVAYA 2 Баланс 1266.31р 09:34"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 120р Карта*4215 STOLOVAYA 2 Баланс 1266.31р 09:34"", ""sms_sender"": null, ""received_at"": ""2026-02-04T06:34:22.819Z"", ""sms_contact"": null, ""signed_amount"": -120}, ""smsText"": ""Оплата 120р Карта*4215 STOLOVAYA 2 Баланс 1266.31р 09:34"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-04T06:34:22.819+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -120.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-04T09:35:00+00:00""}"
|
|
||||||
"{""id"": 223, ""rawSms"": ""Оплата 145р Карта*4215 STOLOVAYA 2 Баланс 1121.31р 09:45"", ""rawJson"": {""action"": ""payment"", ""amount"": 145, ""balance"": 1121.31, ""raw_sms"": ""Оплата 145р Карта*4215 STOLOVAYA 2 Баланс 1121.31р 09:45"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 145р Карта*4215 STOLOVAYA 2 Баланс 1121.31р 09:45"", ""sms_sender"": null, ""received_at"": ""2026-02-04T06:45:21.347Z"", ""sms_contact"": null, ""signed_amount"": -145}, ""smsText"": ""Оплата 145р Карта*4215 STOLOVAYA 2 Баланс 1121.31р 09:45"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-04T06:45:21.347+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -145.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-04T09:45:00+00:00""}"
|
|
||||||
"{""id"": 224, ""rawSms"": ""Оплата 614.41р Карта*8214 PYATEROCHKA Баланс 506.90р 11:22"", ""rawJson"": {""action"": ""payment"", ""amount"": 614.41, ""balance"": 506.9, ""raw_sms"": ""Оплата 614.41р Карта*8214 PYATEROCHKA Баланс 506.90р 11:22"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 614.41р Карта*8214 PYATEROCHKA Баланс 506.90р 11:22"", ""sms_sender"": null, ""received_at"": ""2026-02-04T08:22:17.990Z"", ""sms_contact"": null, ""signed_amount"": -614.41}, ""smsText"": ""Оплата 614.41р Карта*8214 PYATEROCHKA Баланс 506.90р 11:22"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-04T08:22:17.99+00:00"", ""counterparty"": ""Пятёрочка"", ""signedAmount"": -614.41, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-04T11:28:00+00:00""}"
|
|
||||||
"{""id"": 225, ""rawSms"": ""Оплата 280р Карта*4215 STOLOVAYA. Баланс 226.90р 12:44"", ""rawJson"": {""action"": ""payment"", ""amount"": 280, ""balance"": 226.9, ""raw_sms"": ""Оплата 280р Карта*4215 STOLOVAYA. Баланс 226.90р 12:44"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 280р Карта*4215 STOLOVAYA. Баланс 226.90р 12:44"", ""sms_sender"": null, ""received_at"": ""2026-02-04T09:44:32.838Z"", ""sms_contact"": null, ""signed_amount"": -280}, ""smsText"": ""Оплата 280р Карта*4215 STOLOVAYA. Баланс 226.90р 12:44"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-04T09:44:32.838+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -280.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-04T13:12:00+00:00""}"
|
|
||||||
"{""id"": 226, ""rawSms"": ""Недостаточно средств 500р Карта*4215 STARS COFFEE 2F Баланс 226.90р 15:33"", ""rawJson"": {""action"": ""unknown"", ""amount"": 500, ""balance"": 226.9, ""raw_sms"": ""Недостаточно средств 500р Карта*4215 STARS COFFEE 2F Баланс 226.90р 15:33"", ""currency"": ""RUB"", ""sms_text"": ""Недостаточно средств 500р Карта*4215 STARS COFFEE 2F Баланс 226.90р 15:33"", ""sms_sender"": null, ""received_at"": ""2026-02-04T12:33:18.491Z"", ""sms_contact"": null, ""signed_amount"": 500}, ""smsText"": ""Недостаточно средств 500р Карта*4215 STARS COFFEE 2F Баланс 226.90р 15:33"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-04T12:33:18.491+00:00"", ""counterparty"": ""STARS COFFEE"", ""signedAmount"": 500.00, ""humanVerified"": false, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 227, ""rawSms"": ""Оплата 500р Карта*4215 STARS COFFEE 2F Баланс 3440.32р 15:34"", ""rawJson"": {""action"": ""payment"", ""amount"": 500, ""balance"": 3440.32, ""raw_sms"": ""Оплата 500р Карта*4215 STARS COFFEE 2F Баланс 3440.32р 15:34"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 500р Карта*4215 STARS COFFEE 2F Баланс 3440.32р 15:34"", ""sms_sender"": null, ""received_at"": ""2026-02-04T12:34:35.465Z"", ""sms_contact"": null, ""signed_amount"": -500}, ""smsText"": ""Оплата 500р Карта*4215 STARS COFFEE 2F Баланс 3440.32р 15:34"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-04T12:34:35.465+00:00"", ""counterparty"": ""СтарБакс"", ""signedAmount"": -500.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-04T16:10:00+00:00""}"
|
|
||||||
"{""id"": 228, ""rawSms"": ""Оплата 264р Карта*4215 STOLOVAYA 2 Баланс 3176.32р 17:46"", ""rawJson"": {""action"": ""payment"", ""amount"": 264, ""balance"": 3176.32, ""raw_sms"": ""Оплата 264р Карта*4215 STOLOVAYA 2 Баланс 3176.32р 17:46"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 264р Карта*4215 STOLOVAYA 2 Баланс 3176.32р 17:46"", ""sms_sender"": null, ""received_at"": ""2026-02-04T14:46:58.449Z"", ""sms_contact"": null, ""signed_amount"": -264}, ""smsText"": ""Оплата 264р Карта*4215 STOLOVAYA 2 Баланс 3176.32р 17:46"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-04T14:46:58.449+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -264.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-04T18:08:00+00:00""}"
|
|
||||||
"{""id"": 229, ""rawSms"": ""Оплата 605.97р Карта*4215 PYATEROCHKA Баланс 2570.35р 20:41"", ""rawJson"": {""action"": ""payment"", ""amount"": 605.97, ""balance"": 2570.35, ""raw_sms"": ""Оплата 605.97р Карта*4215 PYATEROCHKA Баланс 2570.35р 20:41"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 605.97р Карта*4215 PYATEROCHKA Баланс 2570.35р 20:41"", ""sms_sender"": null, ""received_at"": ""2026-02-04T17:41:34.567Z"", ""sms_contact"": null, ""signed_amount"": -605.97}, ""smsText"": ""Оплата 605.97р Карта*4215 PYATEROCHKA Баланс 2570.35р 20:41"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-04T17:41:34.567+00:00"", ""counterparty"": ""Пятёрочка"", ""signedAmount"": -605.97, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-04T22:00:00+00:00""}"
|
|
||||||
"{""id"": 230, ""rawSms"": ""Оплата 4545.24р Карта*4215 RNAZK MC013 Баланс 3025.11р 09:16"", ""rawJson"": {""action"": ""payment"", ""amount"": 4545.24, ""balance"": 3025.11, ""raw_sms"": ""Оплата 4545.24р Карта*4215 RNAZK MC013 Баланс 3025.11р 09:16"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 4545.24р Карта*4215 RNAZK MC013 Баланс 3025.11р 09:16"", ""sms_sender"": null, ""received_at"": ""2026-02-05T06:16:31.300Z"", ""sms_contact"": null, ""signed_amount"": -4545.24}, ""smsText"": ""Оплата 4545.24р Карта*4215 RNAZK MC013 Баланс 3025.11р 09:16"", ""category"": ""Авто"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-05T06:16:31.3+00:00"", ""counterparty"": ""Роснефть"", ""signedAmount"": -4545.24, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 231, ""rawSms"": ""Перевод 800р Счет*5611 Али А. Баланс 2225.11р 09:20"", ""rawJson"": {""action"": ""transfer"", ""amount"": 800, ""balance"": 2225.11, ""raw_sms"": ""Перевод 800р Счет*5611 Али А. Баланс 2225.11р 09:20"", ""currency"": ""RUB"", ""sms_text"": ""Перевод 800р Счет*5611 Али А. Баланс 2225.11р 09:20"", ""sms_sender"": null, ""received_at"": ""2026-02-05T06:20:28.634Z"", ""sms_contact"": null, ""signed_amount"": -800}, ""smsText"": ""Перевод 800р Счет*5611 Али А. Баланс 2225.11р 09:20"", ""category"": ""Авто"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-05T06:20:28.634+00:00"", ""counterparty"": ""Али А."", ""signedAmount"": -800.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 232, ""rawSms"": ""Оплата 290р Карта*4215 STOLOVAYA 2 Баланс 1935.11р 09:50"", ""rawJson"": {""action"": ""payment"", ""amount"": 290, ""balance"": 1935.11, ""raw_sms"": ""Оплата 290р Карта*4215 STOLOVAYA 2 Баланс 1935.11р 09:50"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 290р Карта*4215 STOLOVAYA 2 Баланс 1935.11р 09:50"", ""sms_sender"": null, ""received_at"": ""2026-02-05T06:50:13.019Z"", ""sms_contact"": null, ""signed_amount"": -290}, ""smsText"": ""Оплата 290р Карта*4215 STOLOVAYA 2 Баланс 1935.11р 09:50"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-05T06:50:13.019+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -290.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-05T09:51:00+00:00""}"
|
|
||||||
"{""id"": 233, ""rawSms"": ""Оплата 178р Карта*8214 CPPK-2000012-BP Баланс 1757.11р 10:00"", ""rawJson"": {""action"": ""payment"", ""amount"": 178, ""balance"": 1757.11, ""raw_sms"": ""Оплата 178р Карта*8214 CPPK-2000012-BP Баланс 1757.11р 10:00"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 178р Карта*8214 CPPK-2000012-BP Баланс 1757.11р 10:00"", ""sms_sender"": null, ""received_at"": ""2026-02-05T07:01:02.889Z"", ""sms_contact"": null, ""signed_amount"": -178}, ""smsText"": ""Оплата 178р Карта*8214 CPPK-2000012-BP Баланс 1757.11р 10:00"", ""category"": ""Проезд"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-05T07:01:02.889+00:00"", ""counterparty"": ""ЦППК"", ""signedAmount"": -178.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 234, ""rawSms"": ""Оплата 72р Карта*4215 STOLOVAYA 2 Баланс 1685.11р 11:32"", ""rawJson"": {""action"": ""payment"", ""amount"": 72, ""balance"": 1685.11, ""raw_sms"": ""Оплата 72р Карта*4215 STOLOVAYA 2 Баланс 1685.11р 11:32"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 72р Карта*4215 STOLOVAYA 2 Баланс 1685.11р 11:32"", ""sms_sender"": null, ""received_at"": ""2026-02-05T08:32:37.643Z"", ""sms_contact"": null, ""signed_amount"": -72}, ""smsText"": ""Оплата 72р Карта*4215 STOLOVAYA 2 Баланс 1685.11р 11:32"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-05T08:32:37.643+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -72.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-05T11:32:00+00:00""}"
|
|
||||||
"{""id"": 235, ""rawSms"": ""Оплата 36р Карта*4215 STOLOVAYA 2 Баланс 1649.11р 11:33"", ""rawJson"": {""action"": ""payment"", ""amount"": 36, ""balance"": 1649.11, ""raw_sms"": ""Оплата 36р Карта*4215 STOLOVAYA 2 Баланс 1649.11р 11:33"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 36р Карта*4215 STOLOVAYA 2 Баланс 1649.11р 11:33"", ""sms_sender"": null, ""received_at"": ""2026-02-05T08:33:16.463Z"", ""sms_contact"": null, ""signed_amount"": -36}, ""smsText"": ""Оплата 36р Карта*4215 STOLOVAYA 2 Баланс 1649.11р 11:33"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-05T08:33:16.463+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -36.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-05T11:40:00+00:00""}"
|
|
||||||
"{""id"": 236, ""rawSms"": ""Оплата 300р Карта*8214 OKHOTNY RYAD SE Баланс 1349.11р 12:22"", ""rawJson"": {""action"": ""payment"", ""amount"": 300, ""balance"": 1349.11, ""raw_sms"": ""Оплата 300р Карта*8214 OKHOTNY RYAD SE Баланс 1349.11р 12:22"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 300р Карта*8214 OKHOTNY RYAD SE Баланс 1349.11р 12:22"", ""sms_sender"": null, ""received_at"": ""2026-02-05T09:22:23.515Z"", ""sms_contact"": null, ""signed_amount"": -300}, ""smsText"": ""Оплата 300р Карта*8214 OKHOTNY RYAD SE Баланс 1349.11р 12:22"", ""category"": ""Проезд"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-05T09:22:23.515+00:00"", ""counterparty"": ""Охотный ряд"", ""signedAmount"": -300.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 237, ""rawSms"": ""Оплата 1130р Карта*8214 TA TORRRO GRIL Баланс 219.11р 13:56"", ""rawJson"": {""action"": ""payment"", ""amount"": 1130, ""balance"": 219.11, ""raw_sms"": ""Оплата 1130р Карта*8214 TA TORRRO GRIL Баланс 219.11р 13:56"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 1130р Карта*8214 TA TORRRO GRIL Баланс 219.11р 13:56"", ""sms_sender"": null, ""received_at"": ""2026-02-05T10:57:06.037Z"", ""sms_contact"": null, ""signed_amount"": -1130}, ""smsText"": ""Оплата 1130р Карта*8214 TA TORRRO GRIL Баланс 219.11р 13:56"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-05T10:57:06.037+00:00"", ""counterparty"": ""Торрро Гриль"", ""signedAmount"": -1130.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-05T14:47:00+00:00""}"
|
|
||||||
"{""id"": 238, ""rawSms"": ""Списание 700р Счет*5611 АНАСТАСИЯ Ш. Баланс 4519.11р 16:40"", ""rawJson"": {""action"": ""writeoff"", ""amount"": 700, ""balance"": 4519.11, ""raw_sms"": ""Списание 700р Счет*5611 АНАСТАСИЯ Ш. Баланс 4519.11р 16:40"", ""currency"": ""RUB"", ""sms_text"": ""Списание 700р Счет*5611 АНАСТАСИЯ Ш. Баланс 4519.11р 16:40"", ""sms_sender"": null, ""received_at"": ""2026-02-05T13:40:48.922Z"", ""sms_contact"": null, ""signed_amount"": -700}, ""smsText"": ""Списание 700р Счет*5611 АНАСТАСИЯ Ш. Баланс 4519.11р 16:40"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-05T13:40:48.922+00:00"", ""counterparty"": ""Шумилова А."", ""signedAmount"": -700.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 239, ""rawSms"": ""Оплата 112.66р Карта*2249 YM*AMPP Баланс 4406.45р 19:07"", ""rawJson"": {""action"": ""payment"", ""amount"": 112.66, ""balance"": 4406.45, ""raw_sms"": ""Оплата 112.66р Карта*2249 YM*AMPP Баланс 4406.45р 19:07"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 112.66р Карта*2249 YM*AMPP Баланс 4406.45р 19:07"", ""sms_sender"": null, ""received_at"": ""2026-02-05T16:07:23.979Z"", ""sms_contact"": null, ""signed_amount"": -112.66}, ""smsText"": ""Оплата 112.66р Карта*2249 YM*AMPP Баланс 4406.45р 19:07"", ""category"": ""Авто"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-05T16:07:23.979+00:00"", ""counterparty"": ""АМПП"", ""signedAmount"": -112.66, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 240, ""rawSms"": ""Оплата 51.33р Карта*2249 YM*AMPP Баланс 4355.12р 10:29"", ""rawJson"": {""action"": ""payment"", ""amount"": 51.33, ""balance"": 4355.12, ""raw_sms"": ""Оплата 51.33р Карта*2249 YM*AMPP Баланс 4355.12р 10:29"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 51.33р Карта*2249 YM*AMPP Баланс 4355.12р 10:29"", ""sms_sender"": null, ""received_at"": ""2026-02-06T07:29:40.207Z"", ""sms_contact"": null, ""signed_amount"": -51.33}, ""smsText"": ""Оплата 51.33р Карта*2249 YM*AMPP Баланс 4355.12р 10:29"", ""category"": ""Авто"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-06T07:29:40.207+00:00"", ""counterparty"": ""АМПП"", ""signedAmount"": -51.33, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 241, ""rawSms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 4075.12р 13:01"", ""rawJson"": {""action"": ""payment"", ""amount"": 280, ""balance"": 4075.12, ""raw_sms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 4075.12р 13:01"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 4075.12р 13:01"", ""sms_sender"": null, ""received_at"": ""2026-02-06T10:01:28.002Z"", ""sms_contact"": null, ""signed_amount"": -280}, ""smsText"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 4075.12р 13:01"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-06T10:01:28.002+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -280.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-06T13:26:00+00:00""}"
|
|
||||||
"{""id"": 242, ""rawSms"": ""Оплата 10250.50р Карта*8214 Russian Railway Баланс 3824.62р 13:13"", ""rawJson"": {""action"": ""payment"", ""amount"": 10250.5, ""balance"": 3824.62, ""raw_sms"": ""Оплата 10250.50р Карта*8214 Russian Railway Баланс 3824.62р 13:13"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 10250.50р Карта*8214 Russian Railway Баланс 3824.62р 13:13"", ""sms_sender"": null, ""received_at"": ""2026-02-06T10:13:45.206Z"", ""sms_contact"": null, ""signed_amount"": -10250.5}, ""smsText"": ""Оплата 10250.50р Карта*8214 Russian Railway Баланс 3824.62р 13:13"", ""category"": ""Проезд"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-06T10:13:45.206+00:00"", ""counterparty"": ""Российские Рельсы"", ""signedAmount"": -10250.50, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-06T13:27:00+00:00""}"
|
|
||||||
"{""id"": 243, ""rawSms"": ""Оплата 2307.86р Карта*2249 DOSTAVKA IZ PYA Баланс 1516.76р 15:23"", ""rawJson"": {""action"": ""payment"", ""amount"": 2307.86, ""balance"": 1516.76, ""raw_sms"": ""Оплата 2307.86р Карта*2249 DOSTAVKA IZ PYA Баланс 1516.76р 15:23"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 2307.86р Карта*2249 DOSTAVKA IZ PYA Баланс 1516.76р 15:23"", ""sms_sender"": null, ""received_at"": ""2026-02-06T12:23:50.765Z"", ""sms_contact"": null, ""signed_amount"": -2307.86}, ""smsText"": ""Оплата 2307.86р Карта*2249 DOSTAVKA IZ PYA Баланс 1516.76р 15:23"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-06T12:23:50.765+00:00"", ""counterparty"": ""Доставка из Пятерочки"", ""signedAmount"": -2307.86, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-06T15:44:00+00:00""}"
|
|
||||||
"{""id"": 244, ""rawSms"": ""Оплата 259.99р Карта*2249 DOSTAVKA IZ PYA Баланс 6881.77р 16:01"", ""rawJson"": {""action"": ""payment"", ""amount"": 259.99, ""balance"": 6881.77, ""raw_sms"": ""Оплата 259.99р Карта*2249 DOSTAVKA IZ PYA Баланс 6881.77р 16:01"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 259.99р Карта*2249 DOSTAVKA IZ PYA Баланс 6881.77р 16:01"", ""sms_sender"": null, ""received_at"": ""2026-02-06T13:01:35.378Z"", ""sms_contact"": null, ""signed_amount"": -259.99}, ""smsText"": ""Оплата 259.99р Карта*2249 DOSTAVKA IZ PYA Баланс 6881.77р 16:01"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-06T13:01:35.378+00:00"", ""counterparty"": ""Доставка из Пятерочки"", ""signedAmount"": -259.99, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-06T16:03:00+00:00""}"
|
|
||||||
"{""id"": 245, ""rawSms"": ""Зачисление кешбэка по программе лояльности 5625р Счет*5611 Баланс 6881.77р 15:38"", ""rawJson"": {""action"": ""income"", ""amount"": 5625, ""balance"": 6881.77, ""raw_sms"": ""Зачисление кешбэка по программе лояльности 5625р Счет*5611 Баланс 6881.77р 15:38"", ""currency"": ""RUB"", ""sms_text"": ""Зачисление кешбэка по программе лояльности 5625р Счет*5611 Баланс 6881.77р 15:38"", ""sms_sender"": null, ""received_at"": ""2026-02-06T13:39:06.646Z"", ""sms_contact"": null, ""signed_amount"": 5625}, ""smsText"": ""Зачисление кешбэка по программе лояльности 5625р Счет*5611 Баланс 6881.77р 15:38"", ""category"": ""Поступления"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-06T13:39:06.646+00:00"", ""counterparty"": ""ВТБ кэшбэк"", ""signedAmount"": 5625.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 246, ""rawSms"": ""Оплата 500р Карта*4215 SKULPTOR TELA Баланс 6381.77р 10:01"", ""rawJson"": {""action"": ""payment"", ""amount"": 500, ""balance"": 6381.77, ""raw_sms"": ""Оплата 500р Карта*4215 SKULPTOR TELA Баланс 6381.77р 10:01"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 500р Карта*4215 SKULPTOR TELA Баланс 6381.77р 10:01"", ""sms_sender"": null, ""received_at"": ""2026-02-07T07:01:22.891Z"", ""sms_contact"": null, ""signed_amount"": -500}, ""smsText"": ""Оплата 500р Карта*4215 SKULPTOR TELA Баланс 6381.77р 10:01"", ""category"": ""Спорт"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-07T07:01:22.891+00:00"", ""counterparty"": ""Скультор Тела"", ""signedAmount"": -500.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 247, ""rawSms"": ""Оплата 500р Карта*2249 VANYAVPN Баланс 5881.77р 10:29"", ""rawJson"": {""action"": ""payment"", ""amount"": 500, ""balance"": 5881.77, ""raw_sms"": ""Оплата 500р Карта*2249 VANYAVPN Баланс 5881.77р 10:29"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 500р Карта*2249 VANYAVPN Баланс 5881.77р 10:29"", ""sms_sender"": null, ""received_at"": ""2026-02-07T07:29:36.281Z"", ""sms_contact"": null, ""signed_amount"": -500}, ""smsText"": ""Оплата 500р Карта*2249 VANYAVPN Баланс 5881.77р 10:29"", ""category"": ""Подписки"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-07T07:29:36.281+00:00"", ""counterparty"": ""VANYAVPN"", ""signedAmount"": -500.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-07T11:55:00+00:00""}"
|
|
||||||
"{""id"": 248, ""rawSms"": ""Оплата 200р Карта*2249 YM*avito Баланс 252.18р 18:39"", ""rawJson"": {""action"": ""payment"", ""amount"": 200, ""balance"": 252.18, ""raw_sms"": ""Оплата 200р Карта*2249 YM*avito Баланс 252.18р 18:39"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 200р Карта*2249 YM*avito Баланс 252.18р 18:39"", ""sms_sender"": null, ""received_at"": ""2026-02-08T15:39:44.989Z"", ""sms_contact"": null, ""signed_amount"": -200}, ""smsText"": ""Оплата 200р Карта*2249 YM*avito Баланс 252.18р 18:39"", ""category"": ""Подписки"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-08T15:39:44.989+00:00"", ""counterparty"": ""Авито"", ""signedAmount"": -200.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-08T18:39:00+00:00""}"
|
|
||||||
"{""id"": 249, ""rawSms"": ""Зарплата 399871.64р Счет *5611 Баланс 400123.82р 15:13"", ""rawJson"": {""action"": ""salary"", ""amount"": 399871.64, ""balance"": 400123.82, ""raw_sms"": ""Зарплата 399871.64р Счет *5611 Баланс 400123.82р 15:13"", ""currency"": ""RUB"", ""sms_text"": ""Зарплата 399871.64р Счет *5611 Баланс 400123.82р 15:13"", ""sms_sender"": null, ""received_at"": ""2026-02-09T12:13:16.793Z"", ""sms_contact"": null, ""signed_amount"": 399871.64}, ""smsText"": ""Зарплата 399871.64р Счет *5611 Баланс 400123.82р 15:13"", ""category"": ""Поступления"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-09T12:13:16.793+00:00"", ""counterparty"": ""НИУ ВШЭ"", ""signedAmount"": 399871.64, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 250, ""rawSms"": ""Оплата 2502.85р Карта*2249 DOSTAVKA IZ PYA Баланс 397620.97р 17:12"", ""rawJson"": {""action"": ""payment"", ""amount"": 2502.85, ""balance"": 397620.97, ""raw_sms"": ""Оплата 2502.85р Карта*2249 DOSTAVKA IZ PYA Баланс 397620.97р 17:12"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 2502.85р Карта*2249 DOSTAVKA IZ PYA Баланс 397620.97р 17:12"", ""sms_sender"": null, ""received_at"": ""2026-02-09T14:12:26.668Z"", ""sms_contact"": null, ""signed_amount"": -2502.85}, ""smsText"": ""Оплата 2502.85р Карта*2249 DOSTAVKA IZ PYA Баланс 397620.97р 17:12"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-09T14:12:26.668+00:00"", ""counterparty"": ""Доставка из Пятерочки"", ""signedAmount"": -2502.85, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-09T17:52:00+00:00""}"
|
|
||||||
"{""id"": 251, ""rawSms"": ""Оплата 56.72р Карта*2249 DOSTAVKA IZ PYA Баланс 397564.25р 18:12"", ""rawJson"": {""action"": ""payment"", ""amount"": 56.72, ""balance"": 397564.25, ""raw_sms"": ""Оплата 56.72р Карта*2249 DOSTAVKA IZ PYA Баланс 397564.25р 18:12"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 56.72р Карта*2249 DOSTAVKA IZ PYA Баланс 397564.25р 18:12"", ""sms_sender"": null, ""received_at"": ""2026-02-09T15:12:25.842Z"", ""sms_contact"": null, ""signed_amount"": -56.72}, ""smsText"": ""Оплата 56.72р Карта*2249 DOSTAVKA IZ PYA Баланс 397564.25р 18:12"", ""category"": ""Продукты"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-09T15:12:25.842+00:00"", ""counterparty"": ""Доставка из Пятерочки"", ""signedAmount"": -56.72, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-09T18:25:00+00:00""}"
|
|
||||||
"{""id"": 252, ""rawSms"": ""Перевод 12768.40р Счет*5611 МосОблЕИРЦ. Баланс 374695.85р 18:28"", ""rawJson"": {""action"": ""transfer"", ""amount"": 12768.4, ""balance"": 374695.85, ""raw_sms"": ""Перевод 12768.40р Счет*5611 МосОблЕИРЦ. Баланс 374695.85р 18:28"", ""currency"": ""RUB"", ""sms_text"": ""Перевод 12768.40р Счет*5611 МосОблЕИРЦ. Баланс 374695.85р 18:28"", ""sms_sender"": null, ""received_at"": ""2026-02-09T15:28:54.421Z"", ""sms_contact"": null, ""signed_amount"": -12768.4}, ""smsText"": ""Перевод 12768.40р Счет*5611 МосОблЕИРЦ. Баланс 374695.85р 18:28"", ""category"": ""ЖКХ"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-09T15:28:54.421+00:00"", ""counterparty"": ""МосОблЕИРЦ"", ""signedAmount"": -12768.40, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 253, ""rawSms"": ""Перевод 10000р, комиссия 100р Счет*5611 ANTON S. Баланс 374695.85р 18:28"", ""rawJson"": {""action"": ""transfer"", ""amount"": 10000, ""balance"": 374695.85, ""raw_sms"": ""Перевод 10000р, комиссия 100р Счет*5611 ANTON S. Баланс 374695.85р 18:28"", ""currency"": ""RUB"", ""sms_text"": ""Перевод 10000р, комиссия 100р Счет*5611 ANTON S. Баланс 374695.85р 18:28"", ""sms_sender"": null, ""received_at"": ""2026-02-09T15:29:49.057Z"", ""sms_contact"": null, ""signed_amount"": -10000}, ""smsText"": ""Перевод 10000р, комиссия 100р Счет*5611 ANTON S. Баланс 374695.85р 18:28"", ""category"": ""Перевод"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-09T15:29:49.057+00:00"", ""counterparty"": ""Бакай Банк"", ""signedAmount"": -10000.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 254, ""rawSms"": ""Оплата 120р Карта*4215 STOLOVAYA 2 Баланс 224575.85р 09:37"", ""rawJson"": {""action"": ""payment"", ""amount"": 120, ""balance"": 224575.85, ""raw_sms"": ""Оплата 120р Карта*4215 STOLOVAYA 2 Баланс 224575.85р 09:37"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 120р Карта*4215 STOLOVAYA 2 Баланс 224575.85р 09:37"", ""sms_sender"": null, ""received_at"": ""2026-02-10T06:37:37.395Z"", ""sms_contact"": null, ""signed_amount"": -120}, ""smsText"": ""Оплата 120р Карта*4215 STOLOVAYA 2 Баланс 224575.85р 09:37"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-10T06:37:37.395+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -120.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-10T10:37:00+00:00""}"
|
|
||||||
"{""id"": 255, ""rawSms"": ""Перевод 500р Счет*5611 МТС Mobile. Баланс 224075.85р 10:05"", ""rawJson"": {""action"": ""transfer"", ""amount"": 500, ""balance"": 224075.85, ""raw_sms"": ""Перевод 500р Счет*5611 МТС Mobile. Баланс 224075.85р 10:05"", ""currency"": ""RUB"", ""sms_text"": ""Перевод 500р Счет*5611 МТС Mobile. Баланс 224075.85р 10:05"", ""sms_sender"": null, ""received_at"": ""2026-02-10T07:05:30.553Z"", ""sms_contact"": null, ""signed_amount"": -500}, ""smsText"": ""Перевод 500р Счет*5611 МТС Mobile. Баланс 224075.85р 10:05"", ""category"": ""Подписки"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-10T07:05:30.553+00:00"", ""counterparty"": ""МТС"", ""signedAmount"": -500.00, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 256, ""rawSms"": ""Оплата 171р Карта*4215 STOLOVAYA 2 Баланс 223904.85р 10:47"", ""rawJson"": {""action"": ""payment"", ""amount"": 171, ""balance"": 223904.85, ""raw_sms"": ""Оплата 171р Карта*4215 STOLOVAYA 2 Баланс 223904.85р 10:47"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 171р Карта*4215 STOLOVAYA 2 Баланс 223904.85р 10:47"", ""sms_sender"": null, ""received_at"": ""2026-02-10T07:47:27.873Z"", ""sms_contact"": null, ""signed_amount"": -171}, ""smsText"": ""Оплата 171р Карта*4215 STOLOVAYA 2 Баланс 223904.85р 10:47"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-10T07:47:27.873+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -171.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-10T11:01:00+00:00""}"
|
|
||||||
"{""id"": 257, ""rawSms"": ""Оплата 611р Карта*2249 OZON.RU Баланс 223293.85р 13:33"", ""rawJson"": {""action"": ""payment"", ""amount"": 611, ""balance"": 223293.85, ""raw_sms"": ""Оплата 611р Карта*2249 OZON.RU Баланс 223293.85р 13:33"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 611р Карта*2249 OZON.RU Баланс 223293.85р 13:33"", ""sms_sender"": null, ""received_at"": ""2026-02-10T10:33:52.816Z"", ""sms_contact"": null, ""signed_amount"": -611}, ""smsText"": ""Оплата 611р Карта*2249 OZON.RU Баланс 223293.85р 13:33"", ""category"": ""Дом"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-10T10:33:52.816+00:00"", ""counterparty"": ""OZON"", ""signedAmount"": -611.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-10T14:15:00+00:00""}"
|
|
||||||
"{""id"": 258, ""rawSms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 223013.85р 13:49"", ""rawJson"": {""action"": ""payment"", ""amount"": 280, ""balance"": 223013.85, ""raw_sms"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 223013.85р 13:49"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 223013.85р 13:49"", ""sms_sender"": null, ""received_at"": ""2026-02-10T10:49:57.633Z"", ""sms_contact"": null, ""signed_amount"": -280}, ""smsText"": ""Оплата 280р Карта*4215 STOLOVAYA 2 Баланс 223013.85р 13:49"", ""category"": ""Общепит"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-10T10:49:57.633+00:00"", ""counterparty"": ""Столовая"", ""signedAmount"": -280.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-10T14:14:00+00:00""}"
|
|
||||||
"{""id"": 259, ""rawSms"": ""Оплата 1183р Карта*2249 OZON.ru Баланс 221830.85р 15:20"", ""rawJson"": {""action"": ""payment"", ""amount"": 1183, ""balance"": 221830.85, ""raw_sms"": ""Оплата 1183р Карта*2249 OZON.ru Баланс 221830.85р 15:20"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 1183р Карта*2249 OZON.ru Баланс 221830.85р 15:20"", ""sms_sender"": null, ""received_at"": ""2026-02-10T12:20:34.466Z"", ""sms_contact"": null, ""signed_amount"": -1183}, ""smsText"": ""Оплата 1183р Карта*2249 OZON.ru Баланс 221830.85р 15:20"", ""category"": ""Дом"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-10T12:20:34.466+00:00"", ""counterparty"": ""OZON"", ""signedAmount"": -1183.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-10T16:02:00+00:00""}"
|
|
||||||
"{""id"": 260, ""rawSms"": ""Счёт карты MIR-3846 16:07 зачисление 15 000р Т-Bank Баланс: 939 988.16р"", ""rawJson"": {""action"": ""income"", ""amount"": 15000, ""balance"": 939988.16, ""raw_sms"": ""Счёт карты MIR-3846 16:07 зачисление 15 000р Т-Bank Баланс: 939 988.16р"", ""currency"": ""RUB"", ""sms_text"": ""Счёт карты MIR-3846 16:07 зачисление 15 000р Т-Bank Баланс: 939 988.16р"", ""sms_sender"": null, ""received_at"": ""2026-02-10T13:07:43.911Z"", ""sms_contact"": null, ""signed_amount"": 15000}, ""smsText"": ""Счёт карты MIR-3846 16:07 зачисление 15 000р Т-Bank Баланс: 939 988.16р"", ""category"": ""Перевод"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-10T13:07:43.911+00:00"", ""counterparty"": ""Т-Bank"", ""signedAmount"": 15000.00, ""humanVerified"": false, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 261, ""rawSms"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 221605.85р 17:15"", ""rawJson"": {""action"": ""payment"", ""amount"": 225, ""balance"": 221605.85, ""raw_sms"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 221605.85р 17:15"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 221605.85р 17:15"", ""sms_sender"": null, ""received_at"": ""2026-02-10T14:15:52.091Z"", ""sms_contact"": null, ""signed_amount"": -225}, ""smsText"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 221605.85р 17:15"", ""category"": ""Спорт"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-10T14:15:52.091+00:00"", ""counterparty"": ""Анта-спорт"", ""signedAmount"": -225.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-10T17:16:00+00:00""}"
|
|
||||||
"{""id"": 262, ""rawSms"": ""Оплата 3991.80р Карта*4215 APTEKA 77-1409 Баланс 217614.05р 21:08"", ""rawJson"": {""action"": ""payment"", ""amount"": 3991.8, ""balance"": 217614.05, ""raw_sms"": ""Оплата 3991.80р Карта*4215 APTEKA 77-1409 Баланс 217614.05р 21:08"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 3991.80р Карта*4215 APTEKA 77-1409 Баланс 217614.05р 21:08"", ""sms_sender"": null, ""received_at"": ""2026-02-10T18:08:57.030Z"", ""sms_contact"": null, ""signed_amount"": -3991.8}, ""smsText"": ""Оплата 3991.80р Карта*4215 APTEKA 77-1409 Баланс 217614.05р 21:08"", ""category"": ""Здоровье"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-10T18:08:57.03+00:00"", ""counterparty"": ""Аптека"", ""signedAmount"": -3991.80, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-10T21:53:00+00:00""}"
|
|
||||||
"{""id"": 264, ""rawSms"": ""Оплата 4388.94р Карта*4215 RNAZK MC099 Баланс 214525.11р 21:36"", ""rawJson"": {""action"": ""payment"", ""amount"": 4388.94, ""balance"": 214525.11, ""raw_sms"": ""Оплата 4388.94р Карта*4215 RNAZK MC099 Баланс 214525.11р 21:36"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 4388.94р Карта*4215 RNAZK MC099 Баланс 214525.11р 21:36"", ""sms_sender"": null, ""received_at"": ""2026-02-10T18:36:19.870Z"", ""sms_contact"": null, ""signed_amount"": -4388.94}, ""smsText"": ""Оплата 4388.94р Карта*4215 RNAZK MC099 Баланс 214525.11р 21:36"", ""category"": ""Авто"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-10T18:36:19.87+00:00"", ""counterparty"": ""Транснефть"", ""signedAmount"": -4388.94, ""humanVerified"": true, ""humanVerifiedAt"": null}"
|
|
||||||
"{""id"": 265, ""rawSms"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 214300.11р 23:41"", ""rawJson"": {""action"": ""payment"", ""amount"": 225, ""balance"": 214300.11, ""raw_sms"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 214300.11р 23:41"", ""currency"": ""RUB"", ""sms_text"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 214300.11р 23:41"", ""sms_sender"": null, ""received_at"": ""2026-02-10T20:42:00.495Z"", ""sms_contact"": null, ""signed_amount"": -225}, ""smsText"": ""Оплата 225р Карта*2249 YP*anta-sport.r Баланс 214300.11р 23:41"", ""category"": ""Спорт"", ""currency"": ""RUB"", ""smsSender"": null, ""receivedAt"": ""2026-02-10T20:42:00.495+00:00"", ""counterparty"": ""Анта-спорт"", ""signedAmount"": -225.00, ""humanVerified"": true, ""humanVerifiedAt"": ""2026-02-10T23:58:00+00:00""}"
|
|
||||||
@@ -4,227 +4,196 @@
|
|||||||
"statement": {
|
"statement": {
|
||||||
"accountNumber": "40817810825104025611",
|
"accountNumber": "40817810825104025611",
|
||||||
"currency": "RUB",
|
"currency": "RUB",
|
||||||
"openingBalance": 42561.67,
|
"openingBalance": 4256167,
|
||||||
"closingBalance": 88459.38,
|
"closingBalance": 8845938,
|
||||||
"exportedAt": "2026-02-27T13:23:00+03:00"
|
"exportedAt": "2026-02-27T13:23:00+03:00"
|
||||||
},
|
},
|
||||||
"transactions": [
|
"transactions": [
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-26T14:06:57+03:00",
|
"operationAt": "2026-02-26T14:06:57+03:00",
|
||||||
"amountSigned": -500.0,
|
"amountSigned": -50000,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. PAVELETSKAYA. по карте *8214",
|
"description": "Оплата товаров и услуг. PAVELETSKAYA. по карте *8214"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-26T14:06:57+03:00-500.000.00Оплата товаров и услуг. PAVELETSKAYA. по карте *8214"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-26T11:46:14+03:00",
|
"operationAt": "2026-02-26T11:46:14+03:00",
|
||||||
"amountSigned": -83.0,
|
"amountSigned": -8300,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. MOS.TRANSPORT. по карте *8214",
|
"description": "Оплата товаров и услуг. MOS.TRANSPORT. по карте *8214"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-26T11:46:14+03:00-83.000.00Оплата товаров и услуг. MOS.TRANSPORT. по карте *8214"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-25T23:12:10+03:00",
|
"operationAt": "2026-02-25T23:12:10+03:00",
|
||||||
"amountSigned": 477.0,
|
"amountSigned": 47700,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Зачисление кешбэка за покупки у партнеров. Перечисление бонусных рублей на счета получателя за покупки у партнеров согласно реестру Z_3507_20260225_1 согласно Договору МБ-14Х/25.",
|
"description": "Зачисление кешбэка за покупки у партнеров. Перечисление бонусных рублей на счета получателя за покупки у партнеров согласно реестру Z_3507_20260225_1 согласно Договору МБ-14Х/25."
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-25T23:12:10+03:00477.000.00Зачисление кешбэка за покупки у партнеров. Перечисление бонусных рублей на счета получателя за покупки у партнеров согласно реестру Z_3507_20260225_1 согласно Договору МБ-14Х/25."
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-25T16:37:47+03:00",
|
"operationAt": "2026-02-25T16:37:47+03:00",
|
||||||
"amountSigned": -449.0,
|
"amountSigned": -44900,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. OZON.RU. по карте *2249",
|
"description": "Оплата товаров и услуг. OZON.RU. по карте *2249"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-25T16:37:47+03:00-449.000.00Оплата товаров и услуг. OZON.RU. по карте *2249"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-25T12:09:51+03:00",
|
"operationAt": "2026-02-25T12:09:51+03:00",
|
||||||
"amountSigned": -3700.0,
|
"amountSigned": -370000,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. Lab4uru App. по карте *2249",
|
"description": "Оплата товаров и услуг. Lab4uru App. по карте *2249"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-25T12:09:51+03:00-3700.000.00Оплата товаров и услуг. Lab4uru App. по карте *2249"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-25T11:18:38+03:00",
|
"operationAt": "2026-02-25T11:18:38+03:00",
|
||||||
"amountSigned": -1821.0,
|
"amountSigned": -182100,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. WILDBERRIES. по карте *2249",
|
"description": "Оплата товаров и услуг. WILDBERRIES. по карте *2249"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-25T11:18:38+03:00-1821.000.00Оплата товаров и услуг. WILDBERRIES. по карте *2249"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-24T21:49:31+03:00",
|
"operationAt": "2026-02-24T21:49:31+03:00",
|
||||||
"amountSigned": -100000.0,
|
"amountSigned": -10000000,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Перевод между своими счетами. Перечисление ДС для приобретения ценных бумаг. Основной рынок. Субпозиция №460827 (НДС не обл.) Канал - ВТБО. Шестеров Антон Владимирович.",
|
"description": "Перевод между своими счетами. Перечисление ДС для приобретения ценных бумаг. Основной рынок. Субпозиция №460827 (НДС не обл.) Канал - ВТБО. Шестеров Антон Владимирович."
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-24T21:49:31+03:00-100000.000.00Перевод между своими счетами. Перечисление ДС для приобретения ценных бумаг. Основной рынок. Субпозиция №460827 (НДС не обл.) Канал - ВТБО. Шестеров Антон Владимирович."
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-24T17:31:00+03:00",
|
"operationAt": "2026-02-24T17:31:00+03:00",
|
||||||
"amountSigned": -214.0,
|
"amountSigned": -21400,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. STOLOVAYA 2. по карте *9058",
|
"description": "Оплата товаров и услуг. STOLOVAYA 2. по карте *9058"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-24T17:31:00+03:00-214.000.00Оплата товаров и услуг. STOLOVAYA 2. по карте *9058"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-24T17:03:39+03:00",
|
"operationAt": "2026-02-24T17:03:39+03:00",
|
||||||
"amountSigned": -26000.0,
|
"amountSigned": -2600000,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Перевод между своими счетами. Перечисление средств на счет 2631",
|
"description": "Перевод между своими счетами. Перечисление средств на счет 2631"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-24T17:03:39+03:00-26000.000.00Перевод между своими счетами. Перечисление средств на счет 2631"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-24T17:03:24+03:00",
|
"operationAt": "2026-02-24T17:03:24+03:00",
|
||||||
"amountSigned": -50000.0,
|
"amountSigned": -5000000,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Перевод между своими счетами. Перечисление средств на счет 0292",
|
"description": "Перевод между своими счетами. Перечисление средств на счет 0292"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-24T17:03:24+03:00-50000.000.00Перевод между своими счетами. Перечисление средств на счет 0292"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-24T16:11:21+03:00",
|
"operationAt": "2026-02-24T16:11:21+03:00",
|
||||||
"amountSigned": 189520.22,
|
"amountSigned": 18952022,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Поступление заработной платы. 0726 НАЦИОНАЛЬНЫЙ ИССЛЕДОВАТЕЛЬСКИЙ УНИВЕРСИТЕТ \"ВЫСША Поступление заработной платы/иных выплат Salary по реестру Z_0000005862_20260220_001_01 от 20.02.2026. Без НДС.",
|
"description": "Поступление заработной платы. 0726 НАЦИОНАЛЬНЫЙ ИССЛЕДОВАТЕЛЬСКИЙ УНИВЕРСИТЕТ \"ВЫСША Поступление заработной платы/иных выплат Salary по реестру Z_0000005862_20260220_001_01 от 20.02.2026. Без НДС."
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-24T16:11:21+03:00189520.220.00Поступление заработной платы. 0726 НАЦИОНАЛЬНЫЙ ИССЛЕДОВАТЕЛЬСКИЙ УНИВЕРСИТЕТ \"ВЫСША Поступление заработной платы/иных выплат Salary по реестру Z_0000005862_20260220_001_01 от 20.02.2026. Без НДС."
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-24T16:09:15+03:00",
|
"operationAt": "2026-02-24T16:09:15+03:00",
|
||||||
"amountSigned": 72393.53,
|
"amountSigned": 7239353,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Поступление заработной платы. 0726 НАЦИОНАЛЬНЫЙ ИССЛЕДОВАТЕЛЬСКИЙ УНИВЕРСИТЕТ \"ВЫСША Поступление заработной платы/иных выплат Salary по реестру Z_0000005862_20260220_010_01 от 20.02.2026. Без НДС.",
|
"description": "Поступление заработной платы. 0726 НАЦИОНАЛЬНЫЙ ИССЛЕДОВАТЕЛЬСКИЙ УНИВЕРСИТЕТ \"ВЫСША Поступление заработной платы/иных выплат Salary по реестру Z_0000005862_20260220_010_01 от 20.02.2026. Без НДС."
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-24T16:09:15+03:0072393.530.00Поступление заработной платы. 0726 НАЦИОНАЛЬНЫЙ ИССЛЕДОВАТЕЛЬСКИЙ УНИВЕРСИТЕТ \"ВЫСША Поступление заработной платы/иных выплат Salary по реестру Z_0000005862_20260220_010_01 от 20.02.2026. Без НДС."
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-24T14:29:53+03:00",
|
"operationAt": "2026-02-24T14:29:53+03:00",
|
||||||
"amountSigned": -778.0,
|
"amountSigned": -77800,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. VKUSVILL_665_2. по карте *9058",
|
"description": "Оплата товаров и услуг. VKUSVILL_665_2. по карте *9058"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-24T14:29:53+03:00-778.000.00Оплата товаров и услуг. VKUSVILL_665_2. по карте *9058"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-24T14:20:59+03:00",
|
"operationAt": "2026-02-24T14:20:59+03:00",
|
||||||
"amountSigned": -6180.0,
|
"amountSigned": -618000,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. Lab4uru App. по карте *2249",
|
"description": "Оплата товаров и услуг. Lab4uru App. по карте *2249"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-24T14:20:59+03:00-6180.000.00Оплата товаров и услуг. Lab4uru App. по карте *2249"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-24T13:05:47+03:00",
|
"operationAt": "2026-02-24T13:05:47+03:00",
|
||||||
"amountSigned": -280.0,
|
"amountSigned": -28000,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. STOLOVAYA.. по карте *9058",
|
"description": "Оплата товаров и услуг. STOLOVAYA.. по карте *9058"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-24T13:05:47+03:00-280.000.00Оплата товаров и услуг. STOLOVAYA.. по карте *9058"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-24T11:35:33+03:00",
|
"operationAt": "2026-02-24T11:35:33+03:00",
|
||||||
"amountSigned": -9824.3,
|
"amountSigned": -982430,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. SKLAD MSK. по карте *8214",
|
"description": "Оплата товаров и услуг. SKLAD MSK. по карте *8214"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-24T11:35:33+03:00-9824.300.00Оплата товаров и услуг. SKLAD MSK. по карте *8214"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-24T09:32:11+03:00",
|
"operationAt": "2026-02-24T09:32:11+03:00",
|
||||||
"amountSigned": -800.0,
|
"amountSigned": -80000,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Внутри ВТБ. Ахмедов Али Эльдар оглы.",
|
"description": "Внутри ВТБ. Ахмедов Али Эльдар оглы."
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-24T09:32:11+03:00-800.000.00Внутри ВТБ. Ахмедов Али Эльдар оглы."
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-24T09:29:30+03:00",
|
"operationAt": "2026-02-24T09:29:30+03:00",
|
||||||
"amountSigned": -5132.72,
|
"amountSigned": -513272,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. RNAZK MC013. по карте *9058",
|
"description": "Оплата товаров и услуг. RNAZK MC013. по карте *9058"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-24T09:29:30+03:00-5132.720.00Оплата товаров и услуг. RNAZK MC013. по карте *9058"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-23T20:58:02+03:00",
|
"operationAt": "2026-02-23T20:58:02+03:00",
|
||||||
"amountSigned": -515.0,
|
"amountSigned": -51500,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. OZON. по карте *2249",
|
"description": "Оплата товаров и услуг. OZON. по карте *2249"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-23T20:58:02+03:00-515.000.00Оплата товаров и услуг. OZON. по карте *2249"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-23T15:31:37+03:00",
|
"operationAt": "2026-02-23T15:31:37+03:00",
|
||||||
"amountSigned": -1105.0,
|
"amountSigned": -110500,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. ZOOMAGAZIN CHETYRE LAP. по карте *8214",
|
"description": "Оплата товаров и услуг. ZOOMAGAZIN CHETYRE LAP. по карте *8214"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-23T15:31:37+03:00-1105.000.00Оплата товаров и услуг. ZOOMAGAZIN CHETYRE LAP. по карте *8214"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-23T15:25:53+03:00",
|
"operationAt": "2026-02-23T15:25:53+03:00",
|
||||||
"amountSigned": -225.97,
|
"amountSigned": -22597,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. PYATEROCHKA 6993. по карте *8214",
|
"description": "Оплата товаров и услуг. PYATEROCHKA 6993. по карте *8214"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-23T15:25:53+03:00-225.970.00Оплата товаров и услуг. PYATEROCHKA 6993. по карте *8214"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-23T13:34:47+03:00",
|
"operationAt": "2026-02-23T13:34:47+03:00",
|
||||||
"amountSigned": -3926.33,
|
"amountSigned": -392633,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. DOSTAVKA PEREKRESTKA_S. по карте *2249",
|
"description": "Оплата товаров и услуг. DOSTAVKA PEREKRESTKA_S. по карте *2249"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-23T13:34:47+03:00-3926.330.00Оплата товаров и услуг. DOSTAVKA PEREKRESTKA_S. по карте *2249"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-22T22:32:47+03:00",
|
"operationAt": "2026-02-22T22:32:47+03:00",
|
||||||
"amountSigned": -79.0,
|
"amountSigned": -7900,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. OZON.RU. по карте *2249",
|
"description": "Оплата товаров и услуг. OZON.RU. по карте *2249"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-22T22:32:47+03:00-79.000.00Оплата товаров и услуг. OZON.RU. по карте *2249"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-22T19:59:48+03:00",
|
"operationAt": "2026-02-22T19:59:48+03:00",
|
||||||
"amountSigned": -493.0,
|
"amountSigned": -49300,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. OZON.RU. по карте *2249",
|
"description": "Оплата товаров и услуг. OZON.RU. по карте *2249"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-22T19:59:48+03:00-493.000.00Оплата товаров и услуг. OZON.RU. по карте *2249"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-22T19:55:28+03:00",
|
"operationAt": "2026-02-22T19:55:28+03:00",
|
||||||
"amountSigned": -536.0,
|
"amountSigned": -53600,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. OZON. по карте *2249",
|
"description": "Оплата товаров и услуг. OZON. по карте *2249"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-22T19:55:28+03:00-536.000.00Оплата товаров и услуг. OZON. по карте *2249"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-22T11:13:21+03:00",
|
"operationAt": "2026-02-22T11:13:21+03:00",
|
||||||
"amountSigned": -61.0,
|
"amountSigned": -6100,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. CPPK-2000012-BPA20. по карте *8214",
|
"description": "Оплата товаров и услуг. CPPK-2000012-BPA20. по карте *8214"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-22T11:13:21+03:00-61.000.00Оплата товаров и услуг. CPPK-2000012-BPA20. по карте *8214"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-21T18:40:37+03:00",
|
"operationAt": "2026-02-21T18:40:37+03:00",
|
||||||
"amountSigned": -1380.0,
|
"amountSigned": -138000,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. mkad. по карте *2249",
|
"description": "Оплата товаров и услуг. mkad. по карте *2249"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-21T18:40:37+03:00-1380.000.00Оплата товаров и услуг. mkad. по карте *2249"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-21T11:29:26+03:00",
|
"operationAt": "2026-02-21T11:29:26+03:00",
|
||||||
"amountSigned": -1715.87,
|
"amountSigned": -171587,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. MAGNIT MK STAROPOTAPOV. по карте *8214",
|
"description": "Оплата товаров и услуг. MAGNIT MK STAROPOTAPOV. по карте *8214"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-21T11:29:26+03:00-1715.870.00Оплата товаров и услуг. MAGNIT MK STAROPOTAPOV. по карте *8214"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-21T10:03:16+03:00",
|
"operationAt": "2026-02-21T10:03:16+03:00",
|
||||||
"amountSigned": -3700.0,
|
"amountSigned": -370000,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. CP* SPIRITFIT.RU. по карте *2249",
|
"description": "Оплата товаров и услуг. CP* SPIRITFIT.RU. по карте *2249"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-21T10:03:16+03:00-3700.000.00Оплата товаров и услуг. CP* SPIRITFIT.RU. по карте *2249"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-21T09:55:24+03:00",
|
"operationAt": "2026-02-21T09:55:24+03:00",
|
||||||
"amountSigned": -32.99,
|
"amountSigned": -3299,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. GLOBUS KOROLEV. по карте *9058",
|
"description": "Оплата товаров и услуг. GLOBUS KOROLEV. по карте *9058"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-21T09:55:24+03:00-32.990.00Оплата товаров и услуг. GLOBUS KOROLEV. по карте *9058"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operationAt": "2026-02-20T15:23:19+03:00",
|
"operationAt": "2026-02-20T15:23:19+03:00",
|
||||||
"amountSigned": -1438.86,
|
"amountSigned": -143886,
|
||||||
"commission": 0.0,
|
"commission": 0,
|
||||||
"description": "Оплата товаров и услуг. DOSTAVKA IZ PYATEROCHK. по карте *2249",
|
"description": "Оплата товаров и услуг. DOSTAVKA IZ PYATEROCHK. по карте *2249"
|
||||||
"fingerprint": "sha256:408178108251040256112026-02-20T15:23:19+03:00-1438.860.00Оплата товаров и услуг. DOSTAVKA IZ PYATEROCHK. по карте *2249"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
114
frontend/README.md
Normal file
114
frontend/README.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# Frontend — Family Budget SPA
|
||||||
|
|
||||||
|
React SPA для учёта семейного бюджета: импорт банковских выписок, категоризация операций, аналитика.
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
- **React 19** + **TypeScript**
|
||||||
|
- **Vite** — сборка и dev-сервер
|
||||||
|
- **React Router 7** — маршрутизация
|
||||||
|
- **Recharts** — графики (столбиковые, круговые)
|
||||||
|
- **@family-budget/shared** — общие TypeScript-типы (API-контракты)
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```text
|
||||||
|
frontend/
|
||||||
|
├── index.html — точка входа
|
||||||
|
├── vite.config.ts — конфигурация Vite (прокси на backend)
|
||||||
|
├── tsconfig.json
|
||||||
|
└── src/
|
||||||
|
├── main.tsx — рендер корневого компонента
|
||||||
|
├── App.tsx — маршруты и проверка авторизации
|
||||||
|
├── styles/
|
||||||
|
│ └── index.css — глобальные стили, CSS-переменные
|
||||||
|
├── api/ — модули запросов к backend
|
||||||
|
│ ├── client.ts — fetch-обёртка с обработкой 401
|
||||||
|
│ ├── auth.ts — login, logout, me
|
||||||
|
│ ├── transactions.ts — GET/PUT /api/transactions
|
||||||
|
│ ├── accounts.ts — GET/PUT /api/accounts
|
||||||
|
│ ├── categories.ts — GET /api/categories
|
||||||
|
│ ├── rules.ts — CRUD /api/category-rules
|
||||||
|
│ ├── analytics.ts — summary, by-category, timeseries
|
||||||
|
│ └── import.ts — POST /api/import/statement
|
||||||
|
├── context/
|
||||||
|
│ └── AuthContext.tsx — провайдер авторизации
|
||||||
|
├── pages/
|
||||||
|
│ ├── LoginPage.tsx — форма входа
|
||||||
|
│ ├── HistoryPage.tsx — таблица операций с фильтрами
|
||||||
|
│ ├── AnalyticsPage.tsx — сводка, графики, категории
|
||||||
|
│ └── SettingsPage.tsx — счета, категории, правила
|
||||||
|
├── components/
|
||||||
|
│ ├── Layout.tsx — боковое меню, обёртка
|
||||||
|
│ ├── TransactionFilters.tsx
|
||||||
|
│ ├── TransactionTable.tsx
|
||||||
|
│ ├── Pagination.tsx
|
||||||
|
│ ├── EditTransactionModal.tsx
|
||||||
|
│ ├── ImportModal.tsx
|
||||||
|
│ ├── PeriodSelector.tsx
|
||||||
|
│ ├── SummaryCards.tsx
|
||||||
|
│ ├── TimeseriesChart.tsx
|
||||||
|
│ ├── CategoryChart.tsx
|
||||||
|
│ ├── AccountsList.tsx
|
||||||
|
│ ├── CategoriesList.tsx
|
||||||
|
│ └── RulesList.tsx
|
||||||
|
└── utils/
|
||||||
|
└── format.ts — форматирование сумм и дат
|
||||||
|
```
|
||||||
|
|
||||||
|
## Экраны
|
||||||
|
|
||||||
|
| Экран | Маршрут | Описание |
|
||||||
|
| ------------| -------------| -------------------------------------------------------|
|
||||||
|
| Логин | — | Отображается при отсутствии сессии |
|
||||||
|
| Операции | `/history` | Таблица транзакций, фильтры, импорт, редактирование |
|
||||||
|
| Аналитика | `/analytics` | Сводка, динамика (bar chart), расходы по категориям |
|
||||||
|
| Настройки | `/settings` | Счета (алиасы), категории (просмотр), правила |
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- Node.js >= 20
|
||||||
|
- Собранный пакет `@family-budget/shared` (см. корневой README)
|
||||||
|
- Запущенный backend на `http://localhost:3000`
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Установка зависимостей (из корня монорепо)
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Сборка shared-типов (если ещё не собраны)
|
||||||
|
npm run build -w shared
|
||||||
|
|
||||||
|
# Запуск dev-сервера (порт 5173, прокси /api → localhost:3000)
|
||||||
|
npm run dev -w frontend
|
||||||
|
|
||||||
|
# Production-сборка
|
||||||
|
npm run build -w frontend
|
||||||
|
|
||||||
|
# Предпросмотр production-сборки
|
||||||
|
npm run preview -w frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Прокси
|
||||||
|
|
||||||
|
В dev-режиме Vite проксирует все запросы `/api/*` на `http://localhost:3000` (см. `vite.config.ts`). В production фронтенд отдаётся как статика, а проксирование настраивается на уровне reverse proxy (nginx и т.п.).
|
||||||
|
|
||||||
|
## Авторизация
|
||||||
|
|
||||||
|
- При загрузке SPA выполняется `GET /api/auth/me`.
|
||||||
|
- Если сессия не активна — отображается форма логина.
|
||||||
|
- При получении `401` от любого API-запроса — автоматический сброс состояния и возврат к форме логина.
|
||||||
|
- Таймаут сессии — 3 часа бездействия (управляется backend).
|
||||||
|
|
||||||
|
## API-контракты
|
||||||
|
|
||||||
|
Типы запросов и ответов определены в `@family-budget/shared` и описаны в спецификациях:
|
||||||
|
|
||||||
|
- `docs/backlog/auth.md` — авторизация
|
||||||
|
- `docs/backlog/api_history.md` — история операций
|
||||||
|
- `docs/backlog/api_import.md` — импорт выписок
|
||||||
|
- `docs/backlog/api_reference_accounts_categories.md` — справочники
|
||||||
|
- `docs/backlog/api_rules.md` — правила категоризации
|
||||||
|
- `docs/backlog/analytics.md` — аналитика
|
||||||
|
- `docs/backlog/edit_and_rules.md` — редактирование транзакций
|
||||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
<title>Семейный бюджет</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
37
frontend/nginx.conf
Normal file
37
frontend/nginx.conf
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Import endpoint — long timeout for LLM processing
|
||||||
|
location /api/import {
|
||||||
|
proxy_pass http://family-budget-backend:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cookie_path / /;
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
client_max_body_size 15m;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API — проксируем на backend (сервис из docker-compose)
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://family-budget-backend:3000;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@family-budget/frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@family-budget/shared": "*",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.2.0",
|
||||||
|
"recharts": "^2.15.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
31
frontend/src/App.tsx
Normal file
31
frontend/src/App.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from './context/AuthContext';
|
||||||
|
import { Layout } from './components/Layout';
|
||||||
|
import { LoginPage } from './pages/LoginPage';
|
||||||
|
import { HistoryPage } from './pages/HistoryPage';
|
||||||
|
import { AnalyticsPage } from './pages/AnalyticsPage';
|
||||||
|
import { SettingsPage } from './pages/SettingsPage';
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="app-loading">Загрузка...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <LoginPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/history" replace />} />
|
||||||
|
<Route path="/history" element={<HistoryPage />} />
|
||||||
|
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/history" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
frontend/src/api/accounts.ts
Normal file
13
frontend/src/api/accounts.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Account, UpdateAccountRequest } from '@family-budget/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
export async function getAccounts(): Promise<Account[]> {
|
||||||
|
return api.get<Account[]>('/api/accounts');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAccount(
|
||||||
|
id: number,
|
||||||
|
data: UpdateAccountRequest,
|
||||||
|
): Promise<Account> {
|
||||||
|
return api.put<Account>(`/api/accounts/${id}`, data);
|
||||||
|
}
|
||||||
38
frontend/src/api/analytics.ts
Normal file
38
frontend/src/api/analytics.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type {
|
||||||
|
AnalyticsSummaryParams,
|
||||||
|
AnalyticsSummaryResponse,
|
||||||
|
ByCategoryParams,
|
||||||
|
ByCategoryItem,
|
||||||
|
TimeseriesParams,
|
||||||
|
TimeseriesItem,
|
||||||
|
} from '@family-budget/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
function toQuery(params: object): string {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(params) as [string, unknown][]) {
|
||||||
|
if (value != null && value !== '') {
|
||||||
|
sp.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const qs = sp.toString();
|
||||||
|
return qs ? `?${qs}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSummary(
|
||||||
|
params: AnalyticsSummaryParams,
|
||||||
|
): Promise<AnalyticsSummaryResponse> {
|
||||||
|
return api.get(`/api/analytics/summary${toQuery(params)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getByCategory(
|
||||||
|
params: ByCategoryParams,
|
||||||
|
): Promise<ByCategoryItem[]> {
|
||||||
|
return api.get(`/api/analytics/by-category${toQuery(params)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTimeseries(
|
||||||
|
params: TimeseriesParams,
|
||||||
|
): Promise<TimeseriesItem[]> {
|
||||||
|
return api.get(`/api/analytics/timeseries${toQuery(params)}`);
|
||||||
|
}
|
||||||
14
frontend/src/api/auth.ts
Normal file
14
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { LoginRequest, MeResponse } from '@family-budget/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
export async function login(data: LoginRequest): Promise<void> {
|
||||||
|
await api.post('/api/auth/login', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
await api.post('/api/auth/logout');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMe(): Promise<MeResponse> {
|
||||||
|
return api.get<MeResponse>('/api/auth/me');
|
||||||
|
}
|
||||||
13
frontend/src/api/categories.ts
Normal file
13
frontend/src/api/categories.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Category, GetCategoriesParams } from '@family-budget/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
export async function getCategories(
|
||||||
|
params?: GetCategoriesParams,
|
||||||
|
): Promise<Category[]> {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
if (params?.isActive != null) {
|
||||||
|
sp.set('isActive', String(params.isActive));
|
||||||
|
}
|
||||||
|
const qs = sp.toString();
|
||||||
|
return api.get(`/api/categories${qs ? `?${qs}` : ''}`);
|
||||||
|
}
|
||||||
110
frontend/src/api/client.ts
Normal file
110
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import type { ApiError } from '@family-budget/shared';
|
||||||
|
|
||||||
|
let onUnauthorized: (() => void) | null = null;
|
||||||
|
|
||||||
|
export function setOnUnauthorized(cb: () => void) {
|
||||||
|
onUnauthorized = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiException extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
public body: ApiError,
|
||||||
|
) {
|
||||||
|
super(body.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers as Record<string, string>),
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
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) {
|
||||||
|
let body: ApiError;
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
body = { error: 'UNKNOWN', message: res.statusText };
|
||||||
|
}
|
||||||
|
throw new ApiException(res.status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestFormData<T>(url: string, formData: FormData): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include',
|
||||||
|
// Do not set Content-Type — browser sets multipart/form-data with boundary
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
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) {
|
||||||
|
let body: ApiError;
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
body = { error: 'UNKNOWN', message: res.statusText };
|
||||||
|
}
|
||||||
|
throw new ApiException(res.status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T>(url: string) => request<T>(url),
|
||||||
|
|
||||||
|
post: <T>(url: string, body?: unknown) =>
|
||||||
|
request<T>(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: body != null ? JSON.stringify(body) : undefined,
|
||||||
|
}),
|
||||||
|
|
||||||
|
postFormData: <T>(url: string, formData: FormData) =>
|
||||||
|
requestFormData<T>(url, formData),
|
||||||
|
|
||||||
|
put: <T>(url: string, body: unknown) =>
|
||||||
|
request<T>(url, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
|
|
||||||
|
patch: <T>(url: string, body: unknown) =>
|
||||||
|
request<T>(url, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||||
|
|
||||||
|
delete: <T>(url: string) =>
|
||||||
|
request<T>(url, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
13
frontend/src/api/import.ts
Normal file
13
frontend/src/api/import.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ImportStatementResponse } from '@family-budget/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
export async function importStatement(
|
||||||
|
file: File,
|
||||||
|
): Promise<ImportStatementResponse> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
return api.postFormData<ImportStatementResponse>(
|
||||||
|
'/api/import/statement',
|
||||||
|
formData,
|
||||||
|
);
|
||||||
|
}
|
||||||
40
frontend/src/api/rules.ts
Normal file
40
frontend/src/api/rules.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type {
|
||||||
|
CategoryRule,
|
||||||
|
GetCategoryRulesParams,
|
||||||
|
CreateCategoryRuleRequest,
|
||||||
|
UpdateCategoryRuleRequest,
|
||||||
|
ApplyRuleResponse,
|
||||||
|
} from '@family-budget/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
export async function getCategoryRules(
|
||||||
|
params?: GetCategoryRulesParams,
|
||||||
|
): Promise<CategoryRule[]> {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
if (params) {
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value != null && value !== '') {
|
||||||
|
sp.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const qs = sp.toString();
|
||||||
|
return api.get(`/api/category-rules${qs ? `?${qs}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCategoryRule(
|
||||||
|
data: CreateCategoryRuleRequest,
|
||||||
|
): Promise<CategoryRule> {
|
||||||
|
return api.post<CategoryRule>('/api/category-rules', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCategoryRule(
|
||||||
|
id: number,
|
||||||
|
data: UpdateCategoryRuleRequest,
|
||||||
|
): Promise<CategoryRule> {
|
||||||
|
return api.patch<CategoryRule>(`/api/category-rules/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyRule(id: number): Promise<ApplyRuleResponse> {
|
||||||
|
return api.post<ApplyRuleResponse>(`/api/category-rules/${id}/apply`);
|
||||||
|
}
|
||||||
31
frontend/src/api/transactions.ts
Normal file
31
frontend/src/api/transactions.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type {
|
||||||
|
Transaction,
|
||||||
|
GetTransactionsParams,
|
||||||
|
PaginatedResponse,
|
||||||
|
UpdateTransactionRequest,
|
||||||
|
} from '@family-budget/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
export async function getTransactions(
|
||||||
|
params: GetTransactionsParams,
|
||||||
|
): Promise<PaginatedResponse<Transaction>> {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value != null && value !== '') {
|
||||||
|
sp.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const qs = sp.toString();
|
||||||
|
return api.get(`/api/transactions${qs ? `?${qs}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTransaction(
|
||||||
|
id: number,
|
||||||
|
data: UpdateTransactionRequest,
|
||||||
|
): Promise<Transaction> {
|
||||||
|
return api.put<Transaction>(`/api/transactions/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAllTransactions(): Promise<{ deleted: number }> {
|
||||||
|
return api.delete<{ deleted: number }>('/api/transactions');
|
||||||
|
}
|
||||||
117
frontend/src/components/AccountsList.tsx
Normal file
117
frontend/src/components/AccountsList.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { Account } from '@family-budget/shared';
|
||||||
|
import { getAccounts, updateAccount } from '../api/accounts';
|
||||||
|
|
||||||
|
export function AccountsList() {
|
||||||
|
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [editAlias, setEditAlias] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
getAccounts()
|
||||||
|
.then(setAccounts)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEdit = (account: Account) => {
|
||||||
|
setEditingId(account.id);
|
||||||
|
setEditAlias(account.alias || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (id: number) => {
|
||||||
|
try {
|
||||||
|
const updated = await updateAccount(id, {
|
||||||
|
alias: editAlias.trim(),
|
||||||
|
});
|
||||||
|
setAccounts((prev) =>
|
||||||
|
prev.map((a) => (a.id === id ? updated : a)),
|
||||||
|
);
|
||||||
|
setEditingId(null);
|
||||||
|
} catch {
|
||||||
|
// error handled globally
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="section-loading">Загрузка...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Банк</th>
|
||||||
|
<th>Номер счёта</th>
|
||||||
|
<th>Валюта</th>
|
||||||
|
<th>Алиас</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td>{a.bank}</td>
|
||||||
|
<td>{a.accountNumberMasked}</td>
|
||||||
|
<td>{a.currency}</td>
|
||||||
|
<td>
|
||||||
|
{editingId === a.id ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editAlias}
|
||||||
|
onChange={(e) => setEditAlias(e.target.value)}
|
||||||
|
maxLength={50}
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSave(a.id);
|
||||||
|
if (e.key === 'Escape') setEditingId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
a.alias || (
|
||||||
|
<span className="text-muted">не задан</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{editingId === a.id ? (
|
||||||
|
<div className="btn-group">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={() => handleSave(a.id)}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-secondary"
|
||||||
|
onClick={() => setEditingId(null)}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-secondary"
|
||||||
|
onClick={() => handleEdit(a)}
|
||||||
|
>
|
||||||
|
Изменить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{accounts.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="td-center text-muted">
|
||||||
|
Нет счетов. Импортируйте выписку.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/components/CategoriesList.tsx
Normal file
51
frontend/src/components/CategoriesList.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { Category } from '@family-budget/shared';
|
||||||
|
import { getCategories } from '../api/categories';
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
|
expense: 'Расход',
|
||||||
|
income: 'Доход',
|
||||||
|
transfer: 'Перевод',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CategoriesList() {
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
getCategories({ isActive: true })
|
||||||
|
.then(setCategories)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="section-loading">Загрузка...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Категория</th>
|
||||||
|
<th>Тип</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td>{c.name}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge badge-${c.type}`}>
|
||||||
|
{TYPE_LABELS[c.type] ?? c.type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
frontend/src/components/CategoryChart.tsx
Normal file
105
frontend/src/components/CategoryChart.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import type { ByCategoryItem } from '@family-budget/shared';
|
||||||
|
import { formatAmount } from '../utils/format';
|
||||||
|
import { useMediaQuery } from '../hooks/useMediaQuery';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: ByCategoryItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
|
||||||
|
'#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1',
|
||||||
|
'#14b8a6', '#e11d48', '#0ea5e9', '#a855f7', '#22c55e',
|
||||||
|
];
|
||||||
|
|
||||||
|
const rubFormatter = new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'RUB',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function CategoryChart({ data }: Props) {
|
||||||
|
const isMobile = useMediaQuery('(max-width: 600px)');
|
||||||
|
const chartHeight = isMobile ? 250 : 300;
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return <div className="chart-empty">Нет данных за период</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = data.map((item) => ({
|
||||||
|
name: item.categoryName,
|
||||||
|
value: Math.abs(item.amount) / 100,
|
||||||
|
txCount: item.txCount,
|
||||||
|
share: item.share,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="category-chart-wrapper">
|
||||||
|
<ResponsiveContainer width="100%" height={chartHeight}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={chartData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={100}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
label={({ name, share }: { name: string; share: number }) =>
|
||||||
|
`${name} ${(share * 100).toFixed(0)}%`
|
||||||
|
}
|
||||||
|
labelLine
|
||||||
|
>
|
||||||
|
{chartData.map((_, idx) => (
|
||||||
|
<Cell
|
||||||
|
key={idx}
|
||||||
|
fill={COLORS[idx % COLORS.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => rubFormatter.format(value)}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
|
||||||
|
<table className="category-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Категория</th>
|
||||||
|
<th>Сумма</th>
|
||||||
|
<th className="th-center">Операций</th>
|
||||||
|
<th className="th-center">Доля</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((item, idx) => (
|
||||||
|
<tr key={item.categoryId}>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className="color-dot"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
COLORS[idx % COLORS.length],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{item.categoryName}
|
||||||
|
</td>
|
||||||
|
<td>{formatAmount(item.amount)}</td>
|
||||||
|
<td className="td-center">{item.txCount}</td>
|
||||||
|
<td className="td-center">
|
||||||
|
{(item.share * 100).toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
frontend/src/components/ClearHistoryModal.tsx
Normal file
110
frontend/src/components/ClearHistoryModal.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>Очистить историю операций</h2>
|
||||||
|
<button className="btn-close" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body">
|
||||||
|
<p className="clear-history-warn">
|
||||||
|
Все транзакции будут безвозвратно удалены. Счета и категории
|
||||||
|
сохранятся.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="form-group form-group-checkbox clear-history-check">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={check1}
|
||||||
|
onChange={(e) => setCheck1(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Я хочу очистить историю операций
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>
|
||||||
|
Введите <strong>{CONFIRM_WORD}</strong> для подтверждения
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={confirmInput}
|
||||||
|
onChange={(e) => setConfirmInput(e.target.value)}
|
||||||
|
placeholder={CONFIRM_WORD}
|
||||||
|
className={confirmInput && confirmInput.trim().toUpperCase() !== CONFIRM_WORD ? 'input-error' : ''}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group form-group-checkbox clear-history-check">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={check2}
|
||||||
|
onChange={(e) => setCheck2(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Я понимаю, что действие необратимо и все данные об операциях
|
||||||
|
будут потеряны навсегда
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!canConfirm || loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Удаление…' : 'Удалить всё'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={onClose}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
frontend/src/components/DataSection.tsx
Normal file
37
frontend/src/components/DataSection.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="data-section">
|
||||||
|
<div className="section-block">
|
||||||
|
<h3>Очистка данных</h3>
|
||||||
|
<p className="section-desc">
|
||||||
|
Очистить историю операций (все транзакции). Счета, категории и
|
||||||
|
правила сохранятся.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={() => setShowClearModal(true)}
|
||||||
|
>
|
||||||
|
Очистить историю
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showClearModal && (
|
||||||
|
<ClearHistoryModal
|
||||||
|
onClose={() => setShowClearModal(false)}
|
||||||
|
onDone={() => {
|
||||||
|
setShowClearModal(false);
|
||||||
|
navigate('/history');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
frontend/src/components/EditTransactionModal.tsx
Normal file
197
frontend/src/components/EditTransactionModal.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import type {
|
||||||
|
Transaction,
|
||||||
|
Category,
|
||||||
|
CreateCategoryRuleRequest,
|
||||||
|
} from '@family-budget/shared';
|
||||||
|
import { updateTransaction } from '../api/transactions';
|
||||||
|
import { createCategoryRule } from '../api/rules';
|
||||||
|
import { formatAmount, formatDateTime } from '../utils/format';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
transaction: Transaction;
|
||||||
|
categories: Category[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPattern(description: string): string {
|
||||||
|
return description
|
||||||
|
.replace(/Оплата товаров и услуг\.\s*/i, '')
|
||||||
|
.replace(/\s*по карте\s*\*\d+.*/i, '')
|
||||||
|
.replace(/\s*Перевод средств.*/i, '')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditTransactionModal({
|
||||||
|
transaction,
|
||||||
|
categories,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: Props) {
|
||||||
|
const [categoryId, setCategoryId] = useState<string>(
|
||||||
|
transaction.categoryId != null ? String(transaction.categoryId) : '',
|
||||||
|
);
|
||||||
|
const [comment, setComment] = useState(transaction.comment || '');
|
||||||
|
const [createRule, setCreateRule] = useState(true);
|
||||||
|
const [pattern, setPattern] = useState(
|
||||||
|
extractPattern(transaction.description),
|
||||||
|
);
|
||||||
|
const [requiresConfirmation, setRequiresConfirmation] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateTransaction(transaction.id, {
|
||||||
|
categoryId: categoryId ? Number(categoryId) : null,
|
||||||
|
comment: comment || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (createRule && categoryId && pattern.trim()) {
|
||||||
|
const ruleData: CreateCategoryRuleRequest = {
|
||||||
|
pattern: pattern.trim(),
|
||||||
|
matchType: 'contains',
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
priority: 100,
|
||||||
|
requiresConfirmation,
|
||||||
|
};
|
||||||
|
await createCategoryRule(ruleData);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Ошибка сохранения';
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>Редактирование операции</h2>
|
||||||
|
<button className="btn-close" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="modal-body">
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="modal-tx-info">
|
||||||
|
<div className="modal-tx-row">
|
||||||
|
<span className="modal-tx-label">Дата</span>
|
||||||
|
<span>{formatDateTime(transaction.operationAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="modal-tx-row">
|
||||||
|
<span className="modal-tx-label">Сумма</span>
|
||||||
|
<span>{formatAmount(transaction.amountSigned)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="modal-tx-row">
|
||||||
|
<span className="modal-tx-label">Описание</span>
|
||||||
|
<span className="modal-tx-description">
|
||||||
|
{transaction.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="edit-category">Категория</label>
|
||||||
|
<select
|
||||||
|
id="edit-category"
|
||||||
|
value={categoryId}
|
||||||
|
onChange={(e) => setCategoryId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">— Без категории —</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="edit-comment">Комментарий</label>
|
||||||
|
<textarea
|
||||||
|
id="edit-comment"
|
||||||
|
rows={2}
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="Комментарий к операции..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-divider" />
|
||||||
|
|
||||||
|
<div className="form-group form-group-checkbox">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={createRule}
|
||||||
|
onChange={(e) => setCreateRule(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Создать правило для похожих транзакций
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createRule && (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="edit-pattern">
|
||||||
|
Шаблон (ключевая строка)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="edit-pattern"
|
||||||
|
type="text"
|
||||||
|
value={pattern}
|
||||||
|
onChange={(e) => setPattern(e.target.value)}
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group form-group-checkbox">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={requiresConfirmation}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRequiresConfirmation(e.target.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Требовать подтверждения категории
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
frontend/src/components/ImportModal.tsx
Normal file
168
frontend/src/components/ImportModal.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import type { ImportStatementResponse } from '@family-budget/shared';
|
||||||
|
import { importStatement } from '../api/import';
|
||||||
|
import { updateAccount } from '../api/accounts';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportModal({ onClose, onDone }: Props) {
|
||||||
|
const [result, setResult] = useState<ImportStatementResponse | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [alias, setAlias] = useState('');
|
||||||
|
const [aliasSaved, setAliasSaved] = useState(false);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileChange = async (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
const type = file.type;
|
||||||
|
const isPdf = type === 'application/pdf' || name.endsWith('.pdf');
|
||||||
|
const isJson = type === 'application/json' || name.endsWith('.json');
|
||||||
|
|
||||||
|
if (!isPdf && !isJson) {
|
||||||
|
setError('Допустимы только файлы PDF или JSON');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await importStatement(file);
|
||||||
|
setResult(resp);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg =
|
||||||
|
err instanceof Error ? err.message : 'Ошибка импорта';
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAlias = async () => {
|
||||||
|
if (!result || !alias.trim()) return;
|
||||||
|
try {
|
||||||
|
await updateAccount(result.accountId, { alias: alias.trim() });
|
||||||
|
setAliasSaved(true);
|
||||||
|
} catch {
|
||||||
|
// handled globally
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>Импорт выписки</h2>
|
||||||
|
<button className="btn-close" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body">
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
|
{!result && (
|
||||||
|
<div className="import-upload">
|
||||||
|
<p>Выберите файл выписки (PDF или JSON, формат 1.0)</p>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.json,application/pdf,application/json"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="file-input"
|
||||||
|
/>
|
||||||
|
{loading && (
|
||||||
|
<div className="import-loading">Импорт...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="import-result">
|
||||||
|
<div className="import-result-icon" aria-hidden="true">✓</div>
|
||||||
|
<h3>Импорт завершён</h3>
|
||||||
|
<table className="import-stats">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Счёт</td>
|
||||||
|
<td>{result.accountNumberMasked}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Новый счёт</td>
|
||||||
|
<td>{result.isNewAccount ? 'Да' : 'Нет'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Импортировано</td>
|
||||||
|
<td>{result.imported}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Дубликатов пропущено</td>
|
||||||
|
<td>{result.duplicatesSkipped}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Всего в файле</td>
|
||||||
|
<td>{result.totalInFile}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{result.isNewAccount && !aliasSaved && (
|
||||||
|
<div className="import-alias">
|
||||||
|
<label>Алиас для нового счёта</label>
|
||||||
|
<div className="import-alias-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Напр.: Текущий, Накопительный"
|
||||||
|
value={alias}
|
||||||
|
onChange={(e) => setAlias(e.target.value)}
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={handleSaveAlias}
|
||||||
|
disabled={!alias.trim()}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{aliasSaved && (
|
||||||
|
<div className="import-alias-saved">
|
||||||
|
Алиас сохранён
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer">
|
||||||
|
{result ? (
|
||||||
|
<button className="btn btn-primary" onClick={onDone}>
|
||||||
|
Готово
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
frontend/src/components/Layout.tsx
Normal file
99
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
export function Layout({ children }: { children: ReactNode }) {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
const closeDrawer = () => setDrawerOpen(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="layout">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="burger-btn"
|
||||||
|
aria-label="Открыть меню"
|
||||||
|
onClick={() => setDrawerOpen(true)}
|
||||||
|
>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{drawerOpen && (
|
||||||
|
<div
|
||||||
|
className="sidebar-overlay"
|
||||||
|
aria-hidden="true"
|
||||||
|
onClick={closeDrawer}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<aside className={`sidebar ${drawerOpen ? 'sidebar-open' : ''}`}>
|
||||||
|
<div className="sidebar-brand">
|
||||||
|
<span className="sidebar-brand-icon">₽</span>
|
||||||
|
<span className="sidebar-brand-text">Семейный бюджет</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="sidebar-nav">
|
||||||
|
<NavLink
|
||||||
|
to="/history"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`nav-link${isActive ? ' active' : ''}`
|
||||||
|
}
|
||||||
|
onClick={closeDrawer}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||||
|
<polyline points="14,2 14,8 20,8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
<polyline points="10,9 9,9 8,9" />
|
||||||
|
</svg>
|
||||||
|
Операции
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
to="/analytics"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`nav-link${isActive ? ' active' : ''}`
|
||||||
|
}
|
||||||
|
onClick={closeDrawer}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="18" y1="20" x2="18" y2="10" />
|
||||||
|
<line x1="12" y1="20" x2="12" y2="4" />
|
||||||
|
<line x1="6" y1="20" x2="6" y2="14" />
|
||||||
|
</svg>
|
||||||
|
Аналитика
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
to="/settings"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`nav-link${isActive ? ' active' : ''}`
|
||||||
|
}
|
||||||
|
onClick={closeDrawer}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
|
||||||
|
</svg>
|
||||||
|
Настройки
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<span className="sidebar-user">{user?.login}</span>
|
||||||
|
<button className="btn-logout" onClick={() => logout()}>
|
||||||
|
Выход
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="main-content">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
frontend/src/components/Pagination.tsx
Normal file
58
frontend/src/components/Pagination.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
interface Props {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange: (size: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pagination({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalItems,
|
||||||
|
totalPages,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}: Props) {
|
||||||
|
const from = (page - 1) * pageSize + 1;
|
||||||
|
const to = Math.min(page * pageSize, totalItems);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pagination">
|
||||||
|
<div className="pagination-info">
|
||||||
|
{totalItems > 0
|
||||||
|
? `Показано ${from}–${to} из ${totalItems}`
|
||||||
|
: 'Нет записей'}
|
||||||
|
</div>
|
||||||
|
<div className="pagination-controls">
|
||||||
|
<select
|
||||||
|
className="pagination-size"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value={10}>10</option>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
<option value={100}>100</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="btn-page"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<span className="pagination-current">
|
||||||
|
{page} / {totalPages || 1}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn-page"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
frontend/src/components/PeriodSelector.tsx
Normal file
134
frontend/src/components/PeriodSelector.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { toISODate } from '../utils/format';
|
||||||
|
|
||||||
|
export type PeriodMode = 'week' | 'month' | 'year' | 'custom';
|
||||||
|
|
||||||
|
export interface PeriodState {
|
||||||
|
mode: PeriodMode;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
period: PeriodState;
|
||||||
|
onChange: (period: PeriodState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODE_LABELS: Record<PeriodMode, string> = {
|
||||||
|
week: 'Неделя',
|
||||||
|
month: 'Месяц',
|
||||||
|
year: 'Год',
|
||||||
|
custom: 'Период',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PeriodSelector({ period, onChange }: Props) {
|
||||||
|
const setMode = (mode: PeriodMode) => {
|
||||||
|
const now = new Date();
|
||||||
|
let from: Date;
|
||||||
|
switch (mode) {
|
||||||
|
case 'week': {
|
||||||
|
const day = now.getDay();
|
||||||
|
const diff = day === 0 ? 6 : day - 1;
|
||||||
|
from = new Date(now);
|
||||||
|
from.setDate(now.getDate() - diff);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'month':
|
||||||
|
from = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
from = new Date(now.getFullYear(), 0, 1);
|
||||||
|
break;
|
||||||
|
case 'custom':
|
||||||
|
onChange({ mode, from: period.from, to: period.to });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange({ mode, from: toISODate(from), to: toISODate(now) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigate = (direction: -1 | 1) => {
|
||||||
|
const fromDate = new Date(period.from);
|
||||||
|
let newFrom: Date;
|
||||||
|
let newTo: Date;
|
||||||
|
|
||||||
|
switch (period.mode) {
|
||||||
|
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({
|
||||||
|
mode: period.mode,
|
||||||
|
from: toISODate(newFrom),
|
||||||
|
to: toISODate(newTo),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="period-selector">
|
||||||
|
<div className="period-modes">
|
||||||
|
{(['week', 'month', 'year', 'custom'] as PeriodMode[]).map(
|
||||||
|
(m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
className={`btn-preset ${period.mode === m ? 'active' : ''}`}
|
||||||
|
onClick={() => setMode(m)}
|
||||||
|
>
|
||||||
|
{MODE_LABELS[m]}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="period-nav">
|
||||||
|
{period.mode !== 'custom' && (
|
||||||
|
<button className="btn-page" onClick={() => navigate(-1)}>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="period-dates">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={period.from}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...period, mode: 'custom', from: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="filter-separator">—</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={period.to}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...period, mode: 'custom', to: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{period.mode !== 'custom' && (
|
||||||
|
<button className="btn-page" onClick={() => navigate(1)}>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
frontend/src/components/RulesList.tsx
Normal file
130
frontend/src/components/RulesList.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { CategoryRule } from '@family-budget/shared';
|
||||||
|
import {
|
||||||
|
getCategoryRules,
|
||||||
|
updateCategoryRule,
|
||||||
|
applyRule,
|
||||||
|
} from '../api/rules';
|
||||||
|
import { formatDate } from '../utils/format';
|
||||||
|
|
||||||
|
export function RulesList() {
|
||||||
|
const [rules, setRules] = useState<CategoryRule[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [applyingId, setApplyingId] = useState<number | null>(null);
|
||||||
|
const [applyResult, setApplyResult] = useState<{
|
||||||
|
id: number;
|
||||||
|
count: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
getCategoryRules()
|
||||||
|
.then(setRules)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggle = async (rule: CategoryRule) => {
|
||||||
|
try {
|
||||||
|
const updated = await updateCategoryRule(rule.id, {
|
||||||
|
isActive: !rule.isActive,
|
||||||
|
});
|
||||||
|
setRules((prev) =>
|
||||||
|
prev.map((r) => (r.id === rule.id ? updated : r)),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// error handled globally
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = async (id: number) => {
|
||||||
|
setApplyingId(id);
|
||||||
|
try {
|
||||||
|
const resp = await applyRule(id);
|
||||||
|
setApplyResult({ id, count: resp.applied });
|
||||||
|
setTimeout(() => setApplyResult(null), 4000);
|
||||||
|
} catch {
|
||||||
|
// error handled globally
|
||||||
|
} finally {
|
||||||
|
setApplyingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="section-loading">Загрузка...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Шаблон</th>
|
||||||
|
<th>Категория</th>
|
||||||
|
<th className="th-center">Приоритет</th>
|
||||||
|
<th className="th-center">Подтверждение</th>
|
||||||
|
<th>Создано</th>
|
||||||
|
<th className="th-center">Активно</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rules.map((r) => (
|
||||||
|
<tr
|
||||||
|
key={r.id}
|
||||||
|
className={!r.isActive ? 'row-inactive' : ''}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<code>{r.pattern}</code>
|
||||||
|
</td>
|
||||||
|
<td>{r.categoryName}</td>
|
||||||
|
<td className="td-center">{r.priority}</td>
|
||||||
|
<td className="td-center">
|
||||||
|
{r.requiresConfirmation ? 'Да' : 'Нет'}
|
||||||
|
</td>
|
||||||
|
<td className="td-nowrap">
|
||||||
|
{formatDate(r.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="td-center">
|
||||||
|
<button
|
||||||
|
className={`toggle ${r.isActive ? 'toggle-on' : 'toggle-off'}`}
|
||||||
|
onClick={() => handleToggle(r)}
|
||||||
|
title={
|
||||||
|
r.isActive ? 'Деактивировать' : 'Активировать'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{r.isActive ? 'Вкл' : 'Выкл'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="rules-actions">
|
||||||
|
{r.isActive && (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-secondary"
|
||||||
|
onClick={() => handleApply(r.id)}
|
||||||
|
disabled={applyingId === r.id}
|
||||||
|
>
|
||||||
|
{applyingId === r.id ? '...' : 'Применить'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{applyResult?.id === r.id && (
|
||||||
|
<span className="apply-result">
|
||||||
|
Применено: {applyResult.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{rules.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="td-center text-muted">
|
||||||
|
Нет правил
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
frontend/src/components/SummaryCards.tsx
Normal file
59
frontend/src/components/SummaryCards.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type { AnalyticsSummaryResponse } from '@family-budget/shared';
|
||||||
|
import { formatAmount } from '../utils/format';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
summary: AnalyticsSummaryResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SummaryCards({ summary }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="summary-cards">
|
||||||
|
<div className="summary-card summary-card-income">
|
||||||
|
<div className="summary-label">Доходы</div>
|
||||||
|
<div className="summary-value">
|
||||||
|
{formatAmount(summary.totalIncome)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="summary-card summary-card-expense">
|
||||||
|
<div className="summary-label">Расходы</div>
|
||||||
|
<div className="summary-value">
|
||||||
|
{formatAmount(summary.totalExpense)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`summary-card ${summary.net >= 0 ? 'summary-card-positive' : 'summary-card-negative'}`}
|
||||||
|
>
|
||||||
|
<div className="summary-label">Баланс</div>
|
||||||
|
<div className="summary-value">
|
||||||
|
{formatAmount(summary.net)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summary.topCategories.length > 0 && (
|
||||||
|
<div className="summary-card summary-card-top">
|
||||||
|
<div className="summary-label">Топ расходов</div>
|
||||||
|
<div className="summary-top-list">
|
||||||
|
{summary.topCategories.map((cat) => (
|
||||||
|
<div
|
||||||
|
key={cat.categoryId}
|
||||||
|
className="top-category-item"
|
||||||
|
>
|
||||||
|
<span className="top-category-name">
|
||||||
|
{cat.categoryName}
|
||||||
|
</span>
|
||||||
|
<span className="top-category-amount">
|
||||||
|
{formatAmount(cat.amount)}
|
||||||
|
</span>
|
||||||
|
<span className="top-category-share">
|
||||||
|
{(cat.share * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
frontend/src/components/TimeseriesChart.tsx
Normal file
75
frontend/src/components/TimeseriesChart.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import type { TimeseriesItem } from '@family-budget/shared';
|
||||||
|
import { useMediaQuery } from '../hooks/useMediaQuery';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: TimeseriesItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rubFormatter = new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'RUB',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function TimeseriesChart({ data }: Props) {
|
||||||
|
const isMobile = useMediaQuery('(max-width: 600px)');
|
||||||
|
const chartHeight = isMobile ? 250 : 300;
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return <div className="chart-empty">Нет данных за период</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = data.map((item) => ({
|
||||||
|
period: item.periodStart,
|
||||||
|
Расходы: Math.abs(item.expenseAmount) / 100,
|
||||||
|
Доходы: item.incomeAmount / 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={chartHeight}>
|
||||||
|
<BarChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="period"
|
||||||
|
tickFormatter={(v: string) => {
|
||||||
|
const d = new Date(v);
|
||||||
|
return `${d.getDate()}.${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
}}
|
||||||
|
fontSize={12}
|
||||||
|
stroke="#64748b"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={(v: number) =>
|
||||||
|
v >= 1000 ? `${(v / 1000).toFixed(0)}к` : String(v)
|
||||||
|
}
|
||||||
|
fontSize={12}
|
||||||
|
stroke="#64748b"
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => rubFormatter.format(value)}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
<Bar
|
||||||
|
dataKey="Расходы"
|
||||||
|
fill="#ef4444"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="Доходы"
|
||||||
|
fill="#10b981"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
298
frontend/src/components/TransactionFilters.tsx
Normal file
298
frontend/src/components/TransactionFilters.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import type {
|
||||||
|
Account,
|
||||||
|
Category,
|
||||||
|
SortOrder,
|
||||||
|
TransactionSortBy,
|
||||||
|
} 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;
|
||||||
|
direction: string;
|
||||||
|
categoryId: string;
|
||||||
|
search: string;
|
||||||
|
amountMin: string;
|
||||||
|
amountMax: string;
|
||||||
|
onlyUnconfirmed: boolean;
|
||||||
|
sortBy: TransactionSortBy;
|
||||||
|
sortOrder: SortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
filters: FiltersState;
|
||||||
|
onChange: (filters: FiltersState) => void;
|
||||||
|
accounts: Account[];
|
||||||
|
categories: Category[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransactionFilters({
|
||||||
|
filters,
|
||||||
|
onChange,
|
||||||
|
accounts,
|
||||||
|
categories,
|
||||||
|
}: Props) {
|
||||||
|
const set = <K extends keyof FiltersState>(
|
||||||
|
key: K,
|
||||||
|
value: FiltersState[K],
|
||||||
|
) => {
|
||||||
|
onChange({ ...filters, [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPreset = (preset: 'week' | 'month' | 'year') => {
|
||||||
|
const now = new Date();
|
||||||
|
let from: Date;
|
||||||
|
switch (preset) {
|
||||||
|
case 'week': {
|
||||||
|
const day = now.getDay();
|
||||||
|
const diff = day === 0 ? 6 : day - 1;
|
||||||
|
from = new Date(now);
|
||||||
|
from.setDate(now.getDate() - diff);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'month':
|
||||||
|
from = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
from = new Date(now.getFullYear(), 0, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<div className="filters-panel">
|
||||||
|
<div className="filters-row">
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>Период</label>
|
||||||
|
<div className="filter-dates-wrap">
|
||||||
|
{filters.periodMode !== 'custom' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-page"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
title="Предыдущий период"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="filter-dates">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.from}
|
||||||
|
onChange={(e) => handleDateChange('from', e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="filter-separator">—</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.to}
|
||||||
|
onChange={(e) => handleDateChange('to', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{filters.periodMode !== 'custom' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-page"
|
||||||
|
onClick={() => navigate(1)}
|
||||||
|
title="Следующий период"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="filter-presets">
|
||||||
|
<button
|
||||||
|
className={`btn-preset ${filters.periodMode === 'week' ? 'active' : ''}`}
|
||||||
|
onClick={() => applyPreset('week')}
|
||||||
|
>
|
||||||
|
Неделя
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn-preset ${filters.periodMode === 'month' ? 'active' : ''}`}
|
||||||
|
onClick={() => applyPreset('month')}
|
||||||
|
>
|
||||||
|
Месяц
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn-preset ${filters.periodMode === 'year' ? 'active' : ''}`}
|
||||||
|
onClick={() => applyPreset('year')}
|
||||||
|
>
|
||||||
|
Год
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>Счёт</label>
|
||||||
|
<select
|
||||||
|
value={filters.accountId}
|
||||||
|
onChange={(e) => set('accountId', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Все счета</option>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.alias || a.accountNumberMasked}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>Тип</label>
|
||||||
|
<select
|
||||||
|
value={filters.direction}
|
||||||
|
onChange={(e) => set('direction', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Все</option>
|
||||||
|
<option value="expense">Расход</option>
|
||||||
|
<option value="income">Приход</option>
|
||||||
|
<option value="transfer">Перевод</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>Категория</label>
|
||||||
|
<select
|
||||||
|
value={filters.categoryId}
|
||||||
|
onChange={(e) => set('categoryId', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Все категории</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filters-row">
|
||||||
|
<div className="filter-group filter-group-wide">
|
||||||
|
<label>Поиск</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск по описанию..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => set('search', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>Сумма от (₽)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="мин"
|
||||||
|
value={filters.amountMin}
|
||||||
|
onChange={(e) => set('amountMin', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>Сумма до (₽)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="макс"
|
||||||
|
value={filters.amountMax}
|
||||||
|
onChange={(e) => set('amountMax', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-group filter-group-checkbox">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.onlyUnconfirmed}
|
||||||
|
onChange={(e) => set('onlyUnconfirmed', e.target.checked)}
|
||||||
|
/>
|
||||||
|
Только неподтверждённые
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>Сортировка</label>
|
||||||
|
<div className="filter-sort">
|
||||||
|
<select
|
||||||
|
value={filters.sortBy}
|
||||||
|
onChange={(e) =>
|
||||||
|
set('sortBy', e.target.value as TransactionSortBy)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="date">По дате</option>
|
||||||
|
<option value="amount">По сумме</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="btn-sort-order"
|
||||||
|
onClick={() =>
|
||||||
|
set(
|
||||||
|
'sortOrder',
|
||||||
|
filters.sortOrder === 'asc' ? 'desc' : 'asc',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
filters.sortOrder === 'asc'
|
||||||
|
? 'По возрастанию'
|
||||||
|
: 'По убыванию'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{filters.sortOrder === 'asc' ? '↑' : '↓'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
frontend/src/components/TransactionTable.tsx
Normal file
172
frontend/src/components/TransactionTable.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import type { Transaction } from '@family-budget/shared';
|
||||||
|
import { formatAmount, formatDateTime } from '../utils/format';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
transactions: Transaction[];
|
||||||
|
loading: boolean;
|
||||||
|
onEdit: (tx: Transaction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIRECTION_CLASSES: Record<string, string> = {
|
||||||
|
income: 'amount-income',
|
||||||
|
expense: 'amount-expense',
|
||||||
|
transfer: 'amount-transfer',
|
||||||
|
};
|
||||||
|
|
||||||
|
function TransactionCard({
|
||||||
|
tx,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
tx: Transaction;
|
||||||
|
onEdit: (tx: Transaction) => void;
|
||||||
|
}) {
|
||||||
|
const directionClass = DIRECTION_CLASSES[tx.direction] ?? '';
|
||||||
|
const isUnconfirmed =
|
||||||
|
!tx.isCategoryConfirmed && tx.categoryId != null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`transaction-card ${isUnconfirmed ? 'row-unconfirmed' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="transaction-card-header">
|
||||||
|
<span className="transaction-card-date">
|
||||||
|
{formatDateTime(tx.operationAt)}
|
||||||
|
</span>
|
||||||
|
<span className={`transaction-card-amount ${directionClass}`}>
|
||||||
|
{formatAmount(tx.amountSigned)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="transaction-card-body">
|
||||||
|
<span className="description-text">{tx.description}</span>
|
||||||
|
{tx.comment && (
|
||||||
|
<span className="comment-badge" title={tx.comment}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="transaction-card-footer">
|
||||||
|
<span className="transaction-card-meta">
|
||||||
|
{tx.accountAlias || '—'} · {tx.categoryName || '—'}
|
||||||
|
</span>
|
||||||
|
<div className="transaction-card-actions">
|
||||||
|
{tx.categoryId != null && !tx.isCategoryConfirmed && (
|
||||||
|
<span
|
||||||
|
className="badge badge-warning"
|
||||||
|
title="Категория не подтверждена"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon btn-icon-touch"
|
||||||
|
onClick={() => onEdit(tx)}
|
||||||
|
title="Редактировать"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransactionTable({ transactions, loading, onEdit }: Props) {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="table-loading">Загрузка операций...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transactions.length === 0) {
|
||||||
|
return <div className="table-empty">Операции не найдены</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="table-wrapper table-desktop">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Счёт</th>
|
||||||
|
<th>Сумма</th>
|
||||||
|
<th>Описание</th>
|
||||||
|
<th>Категория</th>
|
||||||
|
<th className="th-center">Статус</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{transactions.map((tx) => (
|
||||||
|
<tr
|
||||||
|
key={tx.id}
|
||||||
|
className={
|
||||||
|
!tx.isCategoryConfirmed && tx.categoryId
|
||||||
|
? 'row-unconfirmed'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td className="td-nowrap">
|
||||||
|
{formatDateTime(tx.operationAt)}
|
||||||
|
</td>
|
||||||
|
<td className="td-nowrap">{tx.accountAlias || '—'}</td>
|
||||||
|
<td
|
||||||
|
className={`td-nowrap td-amount ${DIRECTION_CLASSES[tx.direction] ?? ''}`}
|
||||||
|
>
|
||||||
|
{formatAmount(tx.amountSigned)}
|
||||||
|
</td>
|
||||||
|
<td className="td-description">
|
||||||
|
<span className="description-text">{tx.description}</span>
|
||||||
|
{tx.comment && (
|
||||||
|
<span className="comment-badge" title={tx.comment}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="td-nowrap">
|
||||||
|
{tx.categoryName || (
|
||||||
|
<span className="text-muted">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="td-center">
|
||||||
|
{tx.categoryId != null && !tx.isCategoryConfirmed && (
|
||||||
|
<span
|
||||||
|
className="badge badge-warning"
|
||||||
|
title="Категория не подтверждена"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={() => onEdit(tx)}
|
||||||
|
title="Редактировать"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="transaction-cards transaction-mobile">
|
||||||
|
{transactions.map((tx) => (
|
||||||
|
<TransactionCard key={tx.id} tx={tx} onEdit={onEdit} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
frontend/src/context/AuthContext.tsx
Normal file
75
frontend/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { getMe, login as apiLogin, logout as apiLogout } from '../api/auth';
|
||||||
|
import { setOnUnauthorized } from '../api/client';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: { login: string } | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthState | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<{ login: string } | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const clearUser = useCallback(() => {
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOnUnauthorized(clearUser);
|
||||||
|
getMe()
|
||||||
|
.then((me) => setUser({ login: me.login }))
|
||||||
|
.catch(() => setUser(null))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [clearUser]);
|
||||||
|
|
||||||
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await apiLogin({ login: username, password });
|
||||||
|
const me = await getMe();
|
||||||
|
setUser({ login: me.login });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error && e.message === 'Failed to fetch'
|
||||||
|
? 'Сервер недоступен. Проверьте, что backend запущен и NPM проксирует /api на порт 3000.'
|
||||||
|
: e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: 'Ошибка входа';
|
||||||
|
setError(msg);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await apiLogout();
|
||||||
|
} finally {
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, error, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthState {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
18
frontend/src/hooks/useMediaQuery.ts
Normal file
18
frontend/src/hooks/useMediaQuery.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useMediaQuery(query: string): boolean {
|
||||||
|
const [matches, setMatches] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return window.matchMedia(query).matches;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia(query);
|
||||||
|
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||||
|
setMatches(mql.matches);
|
||||||
|
mql.addEventListener('change', handler);
|
||||||
|
return () => mql.removeEventListener('change', handler);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
16
frontend/src/main.tsx
Normal file
16
frontend/src/main.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { AuthProvider } from './context/AuthContext';
|
||||||
|
import { App } from './App';
|
||||||
|
import './styles/index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
144
frontend/src/pages/AnalyticsPage.tsx
Normal file
144
frontend/src/pages/AnalyticsPage.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import type {
|
||||||
|
Account,
|
||||||
|
AnalyticsSummaryResponse,
|
||||||
|
ByCategoryItem,
|
||||||
|
TimeseriesItem,
|
||||||
|
Granularity,
|
||||||
|
} from '@family-budget/shared';
|
||||||
|
import { getAccounts } from '../api/accounts';
|
||||||
|
import { getSummary, getByCategory, getTimeseries } from '../api/analytics';
|
||||||
|
import {
|
||||||
|
PeriodSelector,
|
||||||
|
type PeriodState,
|
||||||
|
} from '../components/PeriodSelector';
|
||||||
|
import { SummaryCards } from '../components/SummaryCards';
|
||||||
|
import { TimeseriesChart } from '../components/TimeseriesChart';
|
||||||
|
import { CategoryChart } from '../components/CategoryChart';
|
||||||
|
import { toISODate } from '../utils/format';
|
||||||
|
|
||||||
|
function getDefaultPeriod(): PeriodState {
|
||||||
|
const now = new Date();
|
||||||
|
return {
|
||||||
|
mode: 'month',
|
||||||
|
from: toISODate(new Date(now.getFullYear(), now.getMonth(), 1)),
|
||||||
|
to: toISODate(now),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalyticsPage() {
|
||||||
|
const [period, setPeriod] = useState<PeriodState>(getDefaultPeriod);
|
||||||
|
const [accountId, setAccountId] = useState<string>('');
|
||||||
|
const [onlyConfirmed, setOnlyConfirmed] = useState(false);
|
||||||
|
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||||
|
const [summary, setSummary] = useState<AnalyticsSummaryResponse | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [byCategory, setByCategory] = useState<ByCategoryItem[]>([]);
|
||||||
|
const [timeseries, setTimeseries] = useState<TimeseriesItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAccounts().then(setAccounts).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
if (!period.from || !period.to) return;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
let gran: Granularity;
|
||||||
|
switch (period.mode) {
|
||||||
|
case 'week':
|
||||||
|
gran = 'day';
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
gran = 'week';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
gran = 'month';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
from: period.from,
|
||||||
|
to: period.to,
|
||||||
|
...(accountId ? { accountId: Number(accountId) } : {}),
|
||||||
|
...(onlyConfirmed ? { onlyConfirmed: true } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [s, bc, ts] = await Promise.all([
|
||||||
|
getSummary(base),
|
||||||
|
getByCategory(base),
|
||||||
|
getTimeseries({ ...base, granularity: gran }),
|
||||||
|
]);
|
||||||
|
setSummary(s);
|
||||||
|
setByCategory(bc);
|
||||||
|
setTimeseries(ts);
|
||||||
|
} catch {
|
||||||
|
// 401 handled by interceptor
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [period, accountId, onlyConfirmed]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAll();
|
||||||
|
}, [fetchAll]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Аналитика</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="analytics-controls">
|
||||||
|
<PeriodSelector period={period} onChange={setPeriod} />
|
||||||
|
<div className="analytics-filters">
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>Счёт</label>
|
||||||
|
<select
|
||||||
|
value={accountId}
|
||||||
|
onChange={(e) => setAccountId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Все счета</option>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.alias || a.accountNumberMasked}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="filter-group filter-group-checkbox">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={onlyConfirmed}
|
||||||
|
onChange={(e) => setOnlyConfirmed(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Только подтверждённые
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="section-loading">Загрузка данных...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{summary && <SummaryCards summary={summary} />}
|
||||||
|
<div className="analytics-charts">
|
||||||
|
<div className="chart-card">
|
||||||
|
<h3>Динамика</h3>
|
||||||
|
<TimeseriesChart data={timeseries} />
|
||||||
|
</div>
|
||||||
|
<div className="chart-card">
|
||||||
|
<h3>По категориям</h3>
|
||||||
|
<CategoryChart data={byCategory} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
frontend/src/pages/HistoryPage.tsx
Normal file
271
frontend/src/pages/HistoryPage.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import type {
|
||||||
|
Transaction,
|
||||||
|
Account,
|
||||||
|
Category,
|
||||||
|
PaginatedResponse,
|
||||||
|
GetTransactionsParams,
|
||||||
|
} from '@family-budget/shared';
|
||||||
|
import { getTransactions } from '../api/transactions';
|
||||||
|
import { getAccounts } from '../api/accounts';
|
||||||
|
import { getCategories } from '../api/categories';
|
||||||
|
import {
|
||||||
|
TransactionFilters,
|
||||||
|
type FiltersState,
|
||||||
|
} from '../components/TransactionFilters';
|
||||||
|
import { TransactionTable } from '../components/TransactionTable';
|
||||||
|
import { Pagination } from '../components/Pagination';
|
||||||
|
import { EditTransactionModal } from '../components/EditTransactionModal';
|
||||||
|
import { ImportModal } from '../components/ImportModal';
|
||||||
|
import { toISODate } from '../utils/format';
|
||||||
|
|
||||||
|
const PARAM_KEYS = [
|
||||||
|
'from',
|
||||||
|
'to',
|
||||||
|
'accountId',
|
||||||
|
'direction',
|
||||||
|
'categoryId',
|
||||||
|
'search',
|
||||||
|
'amountMin',
|
||||||
|
'amountMax',
|
||||||
|
'onlyUnconfirmed',
|
||||||
|
'sortBy',
|
||||||
|
'sortOrder',
|
||||||
|
'periodMode',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function getDefaultFilters(): FiltersState {
|
||||||
|
const now = new Date();
|
||||||
|
const from = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
return {
|
||||||
|
periodMode: 'month',
|
||||||
|
from: toISODate(from),
|
||||||
|
to: toISODate(now),
|
||||||
|
accountId: '',
|
||||||
|
direction: '',
|
||||||
|
categoryId: '',
|
||||||
|
search: '',
|
||||||
|
amountMin: '',
|
||||||
|
amountMax: '',
|
||||||
|
onlyUnconfirmed: false,
|
||||||
|
sortBy: 'date',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function filtersFromParams(params: URLSearchParams): Partial<FiltersState> {
|
||||||
|
const out: Partial<FiltersState> = {};
|
||||||
|
for (const key of PARAM_KEYS) {
|
||||||
|
const v = params.get(key);
|
||||||
|
if (v !== null) {
|
||||||
|
if (key === 'onlyUnconfirmed') {
|
||||||
|
out[key] = v === '1' || v === 'true';
|
||||||
|
} else {
|
||||||
|
(out as Record<string, unknown>)[key] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paramsFromUrl(params: URLSearchParams): {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
} {
|
||||||
|
const p = params.get('page');
|
||||||
|
const ps = params.get('pageSize');
|
||||||
|
return {
|
||||||
|
page: p ? Math.max(1, parseInt(p, 10) || 1) : 1,
|
||||||
|
pageSize: ps ? parseInt(ps, 10) || 50 : 50,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function filtersToParams(
|
||||||
|
f: FiltersState,
|
||||||
|
pageNum: number,
|
||||||
|
pageSizeNum: number,
|
||||||
|
): URLSearchParams {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (f.from) p.set('from', f.from);
|
||||||
|
if (f.to) p.set('to', f.to);
|
||||||
|
if (f.accountId) p.set('accountId', f.accountId);
|
||||||
|
if (f.direction) p.set('direction', f.direction);
|
||||||
|
if (f.categoryId) p.set('categoryId', f.categoryId);
|
||||||
|
if (f.search) p.set('search', f.search);
|
||||||
|
if (f.amountMin) p.set('amountMin', f.amountMin);
|
||||||
|
if (f.amountMax) p.set('amountMax', f.amountMax);
|
||||||
|
if (f.onlyUnconfirmed) p.set('onlyUnconfirmed', '1');
|
||||||
|
if (f.sortBy) p.set('sortBy', f.sortBy);
|
||||||
|
if (f.sortOrder) p.set('sortOrder', f.sortOrder);
|
||||||
|
if (f.periodMode) p.set('periodMode', f.periodMode);
|
||||||
|
if (pageNum > 1) p.set('page', String(pageNum));
|
||||||
|
if (pageSizeNum !== 50) p.set('pageSize', String(pageSizeNum));
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const urlFilters = filtersFromParams(searchParams);
|
||||||
|
const { page: urlPage, pageSize: urlPageSize } = paramsFromUrl(searchParams);
|
||||||
|
const defaultFilters = getDefaultFilters();
|
||||||
|
const [filters, setFilters] = useState<FiltersState>(() => ({
|
||||||
|
...defaultFilters,
|
||||||
|
...urlFilters,
|
||||||
|
periodMode:
|
||||||
|
(urlFilters.periodMode as FiltersState['periodMode']) ??
|
||||||
|
defaultFilters.periodMode,
|
||||||
|
}));
|
||||||
|
const [page, setPage] = useState(urlPage);
|
||||||
|
const [pageSize, setPageSize] = useState(urlPageSize);
|
||||||
|
const [data, setData] = useState<PaginatedResponse<Transaction> | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [editingTx, setEditingTx] = useState<Transaction | null>(null);
|
||||||
|
const [showImport, setShowImport] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAccounts().then(setAccounts).catch(() => {});
|
||||||
|
getCategories().then(setCategories).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urlFilters = filtersFromParams(searchParams);
|
||||||
|
const { page: p, pageSize: ps } = paramsFromUrl(searchParams);
|
||||||
|
if (Object.keys(urlFilters).length > 0) {
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...getDefaultFilters(),
|
||||||
|
...urlFilters,
|
||||||
|
periodMode:
|
||||||
|
(urlFilters.periodMode as FiltersState['periodMode']) ??
|
||||||
|
prev.periodMode,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: GetTransactionsParams = {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sortBy: filters.sortBy,
|
||||||
|
sortOrder: filters.sortOrder,
|
||||||
|
};
|
||||||
|
if (filters.from) params.from = filters.from;
|
||||||
|
if (filters.to) params.to = filters.to;
|
||||||
|
if (filters.accountId) params.accountId = Number(filters.accountId);
|
||||||
|
if (filters.direction) params.direction = filters.direction;
|
||||||
|
if (filters.categoryId) params.categoryId = Number(filters.categoryId);
|
||||||
|
if (filters.search) params.search = filters.search;
|
||||||
|
if (filters.amountMin)
|
||||||
|
params.amountMin = Math.round(Number(filters.amountMin) * 100);
|
||||||
|
if (filters.amountMax)
|
||||||
|
params.amountMax = Math.round(Number(filters.amountMax) * 100);
|
||||||
|
if (filters.onlyUnconfirmed) params.onlyUnconfirmed = true;
|
||||||
|
|
||||||
|
const result = await getTransactions(params);
|
||||||
|
setData(result);
|
||||||
|
} catch {
|
||||||
|
// 401 handled by interceptor
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filters, page, pageSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const handleFiltersChange = (newFilters: FiltersState) => {
|
||||||
|
setFilters(newFilters);
|
||||||
|
setPage(1);
|
||||||
|
setSearchParams(filtersToParams(newFilters, 1, pageSize), {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleEditSave = () => {
|
||||||
|
setEditingTx(null);
|
||||||
|
fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportDone = () => {
|
||||||
|
setShowImport(false);
|
||||||
|
fetchData();
|
||||||
|
getAccounts().then(setAccounts).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>История операций</h1>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setShowImport(true)}
|
||||||
|
>
|
||||||
|
Импорт выписки
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TransactionFilters
|
||||||
|
filters={filters}
|
||||||
|
onChange={handleFiltersChange}
|
||||||
|
accounts={accounts}
|
||||||
|
categories={categories}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TransactionTable
|
||||||
|
transactions={data?.items ?? []}
|
||||||
|
loading={loading}
|
||||||
|
onEdit={setEditingTx}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<Pagination
|
||||||
|
page={data.page}
|
||||||
|
pageSize={data.pageSize}
|
||||||
|
totalItems={data.totalItems}
|
||||||
|
totalPages={data.totalPages}
|
||||||
|
onPageChange={(p) => {
|
||||||
|
setPage(p);
|
||||||
|
setSearchParams(
|
||||||
|
filtersToParams(filters, p, pageSize),
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setPage(1);
|
||||||
|
setSearchParams(
|
||||||
|
filtersToParams(filters, 1, size),
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingTx && (
|
||||||
|
<EditTransactionModal
|
||||||
|
transaction={editingTx}
|
||||||
|
categories={categories}
|
||||||
|
onClose={() => setEditingTx(null)}
|
||||||
|
onSave={handleEditSave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showImport && (
|
||||||
|
<ImportModal
|
||||||
|
onClose={() => setShowImport(false)}
|
||||||
|
onDone={handleImportDone}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
frontend/src/pages/LoginPage.tsx
Normal file
66
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { login, error } = useAuth();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
} catch {
|
||||||
|
// error state managed by AuthContext
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<div className="login-header">
|
||||||
|
<span className="login-icon">₽</span>
|
||||||
|
<h1>Семейный бюджет</h1>
|
||||||
|
<p>Войдите для продолжения</p>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="login-form">
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="login">Логин</label>
|
||||||
|
<input
|
||||||
|
id="login"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="password">Пароль</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary btn-block"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? 'Вход...' : 'Войти'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
frontend/src/pages/SettingsPage.tsx
Normal file
53
frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { AccountsList } from '../components/AccountsList';
|
||||||
|
import { CategoriesList } from '../components/CategoriesList';
|
||||||
|
import { RulesList } from '../components/RulesList';
|
||||||
|
import { DataSection } from '../components/DataSection';
|
||||||
|
|
||||||
|
type Tab = 'accounts' | 'categories' | 'rules' | 'data';
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const [tab, setTab] = useState<Tab>('accounts');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Настройки</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tabs">
|
||||||
|
<button
|
||||||
|
className={`tab ${tab === 'accounts' ? 'active' : ''}`}
|
||||||
|
onClick={() => setTab('accounts')}
|
||||||
|
>
|
||||||
|
Счета
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tab ${tab === 'categories' ? 'active' : ''}`}
|
||||||
|
onClick={() => setTab('categories')}
|
||||||
|
>
|
||||||
|
Категории
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tab ${tab === 'rules' ? 'active' : ''}`}
|
||||||
|
onClick={() => setTab('rules')}
|
||||||
|
>
|
||||||
|
Правила
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tab ${tab === 'data' ? 'active' : ''}`}
|
||||||
|
onClick={() => setTab('data')}
|
||||||
|
>
|
||||||
|
Данные
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tab-content">
|
||||||
|
{tab === 'accounts' && <AccountsList />}
|
||||||
|
{tab === 'categories' && <CategoriesList />}
|
||||||
|
{tab === 'rules' && <RulesList />}
|
||||||
|
{tab === 'data' && <DataSection />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1616
frontend/src/styles/index.css
Normal file
1616
frontend/src/styles/index.css
Normal file
File diff suppressed because it is too large
Load Diff
38
frontend/src/utils/format.ts
Normal file
38
frontend/src/utils/format.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const amountFormatter = new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'RUB',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function formatAmount(kopecks: number): string {
|
||||||
|
return amountFormatter.format(kopecks / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dateTimeFormatter = new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
export function formatDate(iso: string): string {
|
||||||
|
return dateFormatter.format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(iso: string): string {
|
||||||
|
return dateTimeFormatter.format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toISODate(date: Date): string {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
18
frontend/tsconfig.json
Normal file
18
frontend/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
13
frontend/tsconfig.node.json
Normal file
13
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"composite": true,
|
||||||
|
"outDir": "./dist-node"
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
4202
package-lock.json
generated
Normal file
4202
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
package.json
Normal file
10
package.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "family-budget",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": [
|
||||||
|
"shared",
|
||||||
|
"backend",
|
||||||
|
"frontend"
|
||||||
|
]
|
||||||
|
}
|
||||||
75
pdf2json.md
Normal file
75
pdf2json.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
Ты — конвертер банковских выписок. Твоя задача: извлечь данные из прикреплённого 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-объект.
|
||||||
14
shared/package.json
Normal file
14
shared/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@family-budget/shared",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsc --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
shared/src/index.ts
Normal file
1
shared/src/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './types';
|
||||||
11
shared/src/types/account.ts
Normal file
11
shared/src/types/account.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export interface Account {
|
||||||
|
id: number;
|
||||||
|
bank: string;
|
||||||
|
accountNumberMasked: string;
|
||||||
|
currency: string;
|
||||||
|
alias: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAccountRequest {
|
||||||
|
alias: string;
|
||||||
|
}
|
||||||
53
shared/src/types/analytics.ts
Normal file
53
shared/src/types/analytics.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Granularity } from './common';
|
||||||
|
|
||||||
|
export interface AnalyticsSummaryParams {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
accountId?: number;
|
||||||
|
onlyConfirmed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopCategory {
|
||||||
|
categoryId: number;
|
||||||
|
categoryName: string;
|
||||||
|
amount: number;
|
||||||
|
share: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsSummaryResponse {
|
||||||
|
totalExpense: number;
|
||||||
|
totalIncome: number;
|
||||||
|
net: number;
|
||||||
|
topCategories: TopCategory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ByCategoryParams {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
accountId?: number;
|
||||||
|
onlyConfirmed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ByCategoryItem {
|
||||||
|
categoryId: number;
|
||||||
|
categoryName: string;
|
||||||
|
amount: number;
|
||||||
|
txCount: number;
|
||||||
|
share: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeseriesParams {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
accountId?: number;
|
||||||
|
categoryId?: number;
|
||||||
|
onlyConfirmed?: boolean;
|
||||||
|
granularity: Granularity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeseriesItem {
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
expenseAmount: number;
|
||||||
|
incomeAmount: number;
|
||||||
|
}
|
||||||
8
shared/src/types/auth.ts
Normal file
8
shared/src/types/auth.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export interface LoginRequest {
|
||||||
|
login: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeResponse {
|
||||||
|
login: string;
|
||||||
|
}
|
||||||
39
shared/src/types/category-rule.ts
Normal file
39
shared/src/types/category-rule.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { MatchType } from './common';
|
||||||
|
|
||||||
|
export interface CategoryRule {
|
||||||
|
id: number;
|
||||||
|
pattern: string;
|
||||||
|
matchType: MatchType;
|
||||||
|
categoryId: number;
|
||||||
|
categoryName: string;
|
||||||
|
priority: number;
|
||||||
|
requiresConfirmation: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetCategoryRulesParams {
|
||||||
|
isActive?: boolean;
|
||||||
|
categoryId?: number;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCategoryRuleRequest {
|
||||||
|
pattern: string;
|
||||||
|
matchType?: MatchType;
|
||||||
|
categoryId: number;
|
||||||
|
priority?: number;
|
||||||
|
requiresConfirmation?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCategoryRuleRequest {
|
||||||
|
pattern?: string;
|
||||||
|
categoryId?: number;
|
||||||
|
priority?: number;
|
||||||
|
requiresConfirmation?: boolean;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyRuleResponse {
|
||||||
|
applied: number;
|
||||||
|
}
|
||||||
12
shared/src/types/category.ts
Normal file
12
shared/src/types/category.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { CategoryType } from './common';
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: CategoryType;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetCategoriesParams {
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
24
shared/src/types/common.ts
Normal file
24
shared/src/types/common.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export type Direction = 'income' | 'expense' | 'transfer';
|
||||||
|
|
||||||
|
export type CategoryType = 'expense' | 'income' | 'transfer';
|
||||||
|
|
||||||
|
export type MatchType = 'contains' | 'starts_with' | 'regex';
|
||||||
|
|
||||||
|
export type SortOrder = 'asc' | 'desc';
|
||||||
|
|
||||||
|
export type TransactionSortBy = 'date' | 'amount';
|
||||||
|
|
||||||
|
export type Granularity = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
items: T[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
error: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
31
shared/src/types/import.ts
Normal file
31
shared/src/types/import.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/** JSON 1.0 statement file — the shape accepted by POST /api/import/statement */
|
||||||
|
export interface StatementFile {
|
||||||
|
schemaVersion: '1.0';
|
||||||
|
bank: string;
|
||||||
|
statement: StatementHeader;
|
||||||
|
transactions: StatementTransaction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatementHeader {
|
||||||
|
accountNumber: string;
|
||||||
|
currency: string;
|
||||||
|
openingBalance: number;
|
||||||
|
closingBalance: number;
|
||||||
|
exportedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatementTransaction {
|
||||||
|
operationAt: string;
|
||||||
|
amountSigned: number;
|
||||||
|
commission: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportStatementResponse {
|
||||||
|
accountId: number;
|
||||||
|
isNewAccount: boolean;
|
||||||
|
accountNumberMasked: string;
|
||||||
|
imported: number;
|
||||||
|
duplicatesSkipped: number;
|
||||||
|
totalInFile: number;
|
||||||
|
}
|
||||||
50
shared/src/types/index.ts
Normal file
50
shared/src/types/index.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
export type {
|
||||||
|
Direction,
|
||||||
|
CategoryType,
|
||||||
|
MatchType,
|
||||||
|
SortOrder,
|
||||||
|
TransactionSortBy,
|
||||||
|
Granularity,
|
||||||
|
PaginatedResponse,
|
||||||
|
ApiError,
|
||||||
|
} from './common';
|
||||||
|
|
||||||
|
export type { Account, UpdateAccountRequest } from './account';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
Category,
|
||||||
|
GetCategoriesParams,
|
||||||
|
} from './category';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
Transaction,
|
||||||
|
GetTransactionsParams,
|
||||||
|
UpdateTransactionRequest,
|
||||||
|
} from './transaction';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
CategoryRule,
|
||||||
|
GetCategoryRulesParams,
|
||||||
|
CreateCategoryRuleRequest,
|
||||||
|
UpdateCategoryRuleRequest,
|
||||||
|
ApplyRuleResponse,
|
||||||
|
} from './category-rule';
|
||||||
|
|
||||||
|
export type { LoginRequest, MeResponse } from './auth';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
StatementFile,
|
||||||
|
StatementHeader,
|
||||||
|
StatementTransaction,
|
||||||
|
ImportStatementResponse,
|
||||||
|
} from './import';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
AnalyticsSummaryParams,
|
||||||
|
TopCategory,
|
||||||
|
AnalyticsSummaryResponse,
|
||||||
|
ByCategoryParams,
|
||||||
|
ByCategoryItem,
|
||||||
|
TimeseriesParams,
|
||||||
|
TimeseriesItem,
|
||||||
|
} from './analytics';
|
||||||
37
shared/src/types/transaction.ts
Normal file
37
shared/src/types/transaction.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Direction, SortOrder, TransactionSortBy } from './common';
|
||||||
|
|
||||||
|
export interface Transaction {
|
||||||
|
id: number;
|
||||||
|
operationAt: string;
|
||||||
|
accountId: number;
|
||||||
|
accountAlias: string | null;
|
||||||
|
amountSigned: number;
|
||||||
|
commission: number;
|
||||||
|
description: string;
|
||||||
|
direction: Direction;
|
||||||
|
categoryId: number | null;
|
||||||
|
categoryName: string | null;
|
||||||
|
isCategoryConfirmed: boolean;
|
||||||
|
comment: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetTransactionsParams {
|
||||||
|
accountId?: number;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
direction?: string;
|
||||||
|
categoryId?: number;
|
||||||
|
search?: string;
|
||||||
|
amountMin?: number;
|
||||||
|
amountMax?: number;
|
||||||
|
onlyUnconfirmed?: boolean;
|
||||||
|
sortBy?: TransactionSortBy;
|
||||||
|
sortOrder?: SortOrder;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTransactionRequest {
|
||||||
|
categoryId?: number | null;
|
||||||
|
comment?: string | null;
|
||||||
|
}
|
||||||
19
shared/tsconfig.json
Normal file
19
shared/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user