From 4d67636633afe98d8909cff006141b31c7960b36 Mon Sep 17 00:00:00 2001 From: vakabunga Date: Mon, 2 Mar 2026 00:32:37 +0300 Subject: [PATCH] feat: creates backend for the project --- backend/.env.example | 12 ++ backend/README.md | 135 +++++++++++++++ backend/package.json | 30 ++++ backend/src/app.ts | 56 +++++++ backend/src/config.ts | 21 +++ backend/src/db/migrate.ts | 142 ++++++++++++++++ backend/src/db/pool.ts | 10 ++ backend/src/middleware/auth.ts | 50 ++++++ backend/src/routes/accounts.ts | 43 +++++ backend/src/routes/analytics.ts | 73 ++++++++ backend/src/routes/auth.ts | 51 ++++++ backend/src/routes/categories.ts | 19 +++ backend/src/routes/categoryRules.ts | 147 ++++++++++++++++ backend/src/routes/import.ts | 22 +++ backend/src/routes/transactions.ts | 54 ++++++ backend/src/services/accounts.ts | 32 ++++ backend/src/services/analytics.ts | 196 ++++++++++++++++++++++ backend/src/services/auth.ts | 30 ++++ backend/src/services/categories.ts | 23 +++ backend/src/services/categoryRules.ts | 152 +++++++++++++++++ backend/src/services/import.ts | 232 ++++++++++++++++++++++++++ backend/src/services/transactions.ts | 170 +++++++++++++++++++ backend/src/utils.ts | 18 ++ backend/tsconfig.json | 17 ++ 24 files changed, 1735 insertions(+) create mode 100644 backend/.env.example create mode 100644 backend/README.md create mode 100644 backend/package.json create mode 100644 backend/src/app.ts create mode 100644 backend/src/config.ts create mode 100644 backend/src/db/migrate.ts create mode 100644 backend/src/db/pool.ts create mode 100644 backend/src/middleware/auth.ts create mode 100644 backend/src/routes/accounts.ts create mode 100644 backend/src/routes/analytics.ts create mode 100644 backend/src/routes/auth.ts create mode 100644 backend/src/routes/categories.ts create mode 100644 backend/src/routes/categoryRules.ts create mode 100644 backend/src/routes/import.ts create mode 100644 backend/src/routes/transactions.ts create mode 100644 backend/src/services/accounts.ts create mode 100644 backend/src/services/analytics.ts create mode 100644 backend/src/services/auth.ts create mode 100644 backend/src/services/categories.ts create mode 100644 backend/src/services/categoryRules.ts create mode 100644 backend/src/services/import.ts create mode 100644 backend/src/services/transactions.ts create mode 100644 backend/src/utils.ts create mode 100644 backend/tsconfig.json diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..49a3e5e --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,12 @@ +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=family_budget +DB_USER=postgres +DB_PASSWORD=postgres + +APP_USER_LOGIN=admin +APP_USER_PASSWORD=changeme + +SESSION_TIMEOUT_MS=10800000 + +PORT=3000 diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..e41f890 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,135 @@ +# Family Budget — Backend + +API-сервер на Express + TypeScript + PostgreSQL. + +## Запуск + +```bash +# 1. Установить зависимости (из корня монорепо) +npm install + +# 2. Собрать shared-типы +npm run build -w shared + +# 3. Скопировать и заполнить .env +cp .env.example .env + +# 4. Создать БД PostgreSQL +createdb family_budget + +# 5. Запустить в dev-режиме (миграции применяются автоматически при старте) +npm run dev -w backend +``` + +Сервер стартует на `http://localhost:3000` (или на порту из `PORT`). + +## Скрипты + +| Скрипт | Команда | Описание | +|--------------|--------------------------------|--------------------------------------------| +| `dev` | `npm run dev -w backend` | Запуск с hot-reload (tsx watch) | +| `build` | `npm run build -w backend` | Компиляция TypeScript → `dist/` | +| `start` | `npm run start -w backend` | Запуск скомпилированного сервера | +| `migrate` | `npm run migrate -w backend` | Применить миграции вручную (без старта) | + +## Переменные окружения + +| Переменная | По умолчанию | Описание | +|----------------------|----------------|---------------------------------------------| +| `DB_HOST` | `localhost` | Хост PostgreSQL | +| `DB_PORT` | `5432` | Порт PostgreSQL | +| `DB_NAME` | `family_budget`| Имя базы данных | +| `DB_USER` | `postgres` | Пользователь БД | +| `DB_PASSWORD` | `postgres` | Пароль БД | +| `APP_USER_LOGIN` | `admin` | Логин для входа в приложение | +| `APP_USER_PASSWORD` | `changeme` | Пароль для входа в приложение | +| `SESSION_TIMEOUT_MS` | `10800000` | Таймаут сессии по бездействию (3 часа) | +| `PORT` | `3000` | Порт HTTP-сервера | + +## Структура проекта + +``` +backend/src/ +├── app.ts — точка входа: Express, миграции, монтирование роутов +├── config.ts — чтение переменных окружения +├── utils.ts — утилиты (maskAccountNumber, asyncHandler) +├── db/ +│ ├── pool.ts — пул подключений pg +│ └── migrate.ts — миграции (встроенный SQL) + seed 23 категорий +├── middleware/ +│ └── auth.ts — проверка сессии, таймаут 3 ч, обновление last_activity_at +├── services/ +│ ├── auth.ts — login / logout / me +│ ├── import.ts — валидация, fingerprint, direction, атомарный импорт +│ ├── transactions.ts — список с фильтрами + обновление (categoryId, comment) +│ ├── accounts.ts — список счетов, обновление алиаса +│ ├── categories.ts — список категорий (фильтр isActive) +│ ├── categoryRules.ts — CRUD правил + apply к прошлым транзакциям +│ └── analytics.ts — summary, by-category, timeseries +└── routes/ + ├── auth.ts — POST login/logout, GET me + ├── import.ts — POST /api/import/statement + ├── transactions.ts — GET /api/transactions, PUT /api/transactions/:id + ├── accounts.ts — GET /api/accounts, PUT /api/accounts/:id + ├── categories.ts — GET /api/categories + ├── categoryRules.ts — GET/POST/PATCH /api/category-rules, POST :id/apply + └── analytics.ts — GET summary, by-category, timeseries +``` + +## API-эндпоинты + +### Авторизация + +| Метод | URL | Описание | +|--------|----------------------|------------------------------| +| POST | `/api/auth/login` | Вход (login + password) | +| POST | `/api/auth/logout` | Выход (инвалидация сессии) | +| GET | `/api/auth/me` | Текущий пользователь | + +### Импорт + +| Метод | URL | Описание | +|--------|----------------------------|-----------------------------------------| +| POST | `/api/import/statement` | Импорт банковской выписки (JSON 1.0) | + +### Транзакции + +| Метод | URL | Описание | +|--------|----------------------------|-----------------------------------------| +| GET | `/api/transactions` | Список с фильтрами и пагинацией | +| PUT | `/api/transactions/:id` | Обновить категорию / комментарий | + +### Справочники + +| Метод | URL | Описание | +|--------|----------------------------|-----------------------------------------| +| GET | `/api/accounts` | Список счетов | +| PUT | `/api/accounts/:id` | Обновить алиас счёта | +| GET | `/api/categories` | Список категорий | + +### Правила категоризации + +| Метод | URL | Описание | +|--------|----------------------------------|--------------------------------------| +| GET | `/api/category-rules` | Список правил | +| POST | `/api/category-rules` | Создать правило | +| PATCH | `/api/category-rules/:id` | Частичное обновление / деактивация | +| POST | `/api/category-rules/:id/apply` | Применить к прошлым транзакциям | + +### Аналитика + +| Метод | URL | Описание | +|--------|----------------------------------|-------------------------------------| +| GET | `/api/analytics/summary` | Сводка: расходы, доходы, топ-5 | +| GET | `/api/analytics/by-category` | Расходы по категориям | +| GET | `/api/analytics/timeseries` | Динамика (day / week / month) | + +## База данных + +Миграции применяются автоматически при старте сервера. Таблицы: + +- **accounts** — банковские счета (bank, account_number, currency, alias) +- **categories** — 23 категории с типами expense / income / transfer (seed) +- **transactions** — операции с fingerprint-дедупликацией, привязкой к счёту и категории +- **category_rules** — правила авто-категоризации (pattern, match_type, priority) +- **sessions** — серверные сессии с таймаутом по бездействию diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..bc6def6 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,30 @@ +{ + "name": "@family-budget/backend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "tsx watch src/app.ts", + "build": "tsc", + "start": "node dist/app.js", + "migrate": "tsx src/db/migrate.ts" + }, + "dependencies": { + "@family-budget/shared": "*", + "cookie-parser": "^1.4.7", + "cors": "^2.8.6", + "dotenv": "^17.3.1", + "express": "^5.2.1", + "pg": "^8.19.0", + "uuid": "^13.0.0" + }, + "devDependencies": { + "@types/cookie-parser": "^1.4.10", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/node": "^25.3.3", + "@types/pg": "^8.18.0", + "@types/uuid": "^10.0.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..74e27a6 --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,56 @@ +import express from 'express'; +import cookieParser from 'cookie-parser'; +import cors from 'cors'; +import { config } from './config'; +import { runMigrations } from './db/migrate'; +import { requireAuth } from './middleware/auth'; + +import authRouter from './routes/auth'; +import importRouter from './routes/import'; +import transactionsRouter from './routes/transactions'; +import accountsRouter from './routes/accounts'; +import categoriesRouter from './routes/categories'; +import categoryRulesRouter from './routes/categoryRules'; +import analyticsRouter from './routes/analytics'; + +const app = express(); + +app.use(express.json({ limit: '10mb' })); +app.use(cookieParser()); +app.use(cors({ origin: true, credentials: true })); + +// Auth routes (login is public; me/logout apply auth internally) +app.use('/api/auth', authRouter); + +// All remaining /api routes require authentication +app.use('/api', requireAuth); +app.use('/api/import', importRouter); +app.use('/api/transactions', transactionsRouter); +app.use('/api/accounts', accountsRouter); +app.use('/api/categories', categoriesRouter); +app.use('/api/category-rules', categoryRulesRouter); +app.use('/api/analytics', analyticsRouter); + +app.use( + ( + err: Error, + _req: express.Request, + res: express.Response, + _next: express.NextFunction, + ) => { + console.error(err); + res.status(500).json({ error: 'INTERNAL_ERROR', message: 'Internal server error' }); + }, +); + +async function start() { + await runMigrations(); + app.listen(config.port, () => { + console.log(`Backend listening on port ${config.port}`); + }); +} + +start().catch((err) => { + console.error('Failed to start:', err); + process.exit(1); +}); diff --git a/backend/src/config.ts b/backend/src/config.ts new file mode 100644 index 0000000..bacb89f --- /dev/null +++ b/backend/src/config.ts @@ -0,0 +1,21 @@ +import dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '..', '.env') }); + +export const config = { + port: parseInt(process.env.PORT || '3000', 10), + + db: { + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432', 10), + database: process.env.DB_NAME || 'family_budget', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', + }, + + appUserLogin: process.env.APP_USER_LOGIN || 'admin', + appUserPassword: process.env.APP_USER_PASSWORD || 'changeme', + + sessionTimeoutMs: parseInt(process.env.SESSION_TIMEOUT_MS || '10800000', 10), +}; diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts new file mode 100644 index 0000000..7759668 --- /dev/null +++ b/backend/src/db/migrate.ts @@ -0,0 +1,142 @@ +import { pool } from './pool'; + +const migrations: { name: string; sql: string }[] = [ + { + name: '001_create_tables', + sql: ` + CREATE TABLE IF NOT EXISTS accounts ( + id BIGSERIAL PRIMARY KEY, + bank TEXT NOT NULL, + account_number TEXT NOT NULL, + currency TEXT NOT NULL, + alias TEXT + ); + CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_bank_number + ON accounts(bank, account_number); + + CREATE TABLE IF NOT EXISTS categories ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE + ); + ALTER TABLE categories DROP CONSTRAINT IF EXISTS chk_categories_type; + ALTER TABLE categories + ADD CONSTRAINT chk_categories_type + CHECK (type IN ('expense', 'income', 'transfer')); + + CREATE TABLE IF NOT EXISTS transactions ( + id BIGSERIAL PRIMARY KEY, + account_id BIGINT NOT NULL REFERENCES accounts(id), + operation_at TIMESTAMPTZ NOT NULL, + amount_signed BIGINT NOT NULL, + commission BIGINT NOT NULL, + description TEXT NOT NULL, + direction TEXT NOT NULL, + fingerprint TEXT NOT NULL, + category_id BIGINT REFERENCES categories(id), + is_category_confirmed BOOLEAN NOT NULL DEFAULT FALSE, + comment TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE UNIQUE INDEX IF NOT EXISTS ux_transactions_account_fingerprint + ON transactions(account_id, fingerprint); + ALTER TABLE transactions DROP CONSTRAINT IF EXISTS chk_transactions_direction; + ALTER TABLE transactions + ADD CONSTRAINT chk_transactions_direction + CHECK (direction IN ('income', 'expense', 'transfer')); + + CREATE TABLE IF NOT EXISTS category_rules ( + id BIGSERIAL PRIMARY KEY, + pattern TEXT NOT NULL, + match_type TEXT NOT NULL, + category_id BIGINT NOT NULL REFERENCES categories(id), + priority INT NOT NULL DEFAULT 0, + requires_confirmation BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS 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 + ); + `, + }, + { + name: '002_seed_categories', + sql: ` + INSERT INTO categories (name, type) VALUES + ('Продукты', 'expense'), + ('Авто', 'expense'), + ('Здоровье', 'expense'), + ('Арчи', 'expense'), + ('ЖКХ', 'expense'), + ('Дом', 'expense'), + ('Проезд', 'expense'), + ('Одежда', 'expense'), + ('Химия', 'expense'), + ('Косметика', 'expense'), + ('Инвестиции', 'transfer'), + ('Развлечения', 'expense'), + ('Общепит', 'expense'), + ('Штрафы', 'expense'), + ('Налоги', 'expense'), + ('Подписки', 'expense'), + ('Перевод', 'transfer'), + ('Наличные', 'expense'), + ('Подарки', 'expense'), + ('Спорт', 'expense'), + ('Отпуск', 'expense'), + ('Техника', 'expense'), + ('Поступления', 'income') + ON CONFLICT DO NOTHING; + `, + }, +]; + +export async function runMigrations(): Promise { + await pool.query(` + CREATE TABLE IF NOT EXISTS _migrations ( + name TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + + for (const m of migrations) { + const { rows } = await pool.query( + 'SELECT 1 FROM _migrations WHERE name = $1', + [m.name], + ); + if (rows.length > 0) continue; + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query(m.sql); + await client.query('INSERT INTO _migrations (name) VALUES ($1)', [m.name]); + await client.query('COMMIT'); + console.log(`Migration applied: ${m.name}`); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + } +} + +if (require.main === module) { + runMigrations() + .then(() => { + console.log('All migrations applied'); + process.exit(0); + }) + .catch((err) => { + console.error('Migration failed:', err); + process.exit(1); + }); +} diff --git a/backend/src/db/pool.ts b/backend/src/db/pool.ts new file mode 100644 index 0000000..2d5895c --- /dev/null +++ b/backend/src/db/pool.ts @@ -0,0 +1,10 @@ +import { Pool } from 'pg'; +import { config } from '../config'; + +export const pool = new Pool({ + host: config.db.host, + port: config.db.port, + database: config.db.database, + user: config.db.user, + password: config.db.password, +}); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..1afa853 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,50 @@ +import { Request, Response, NextFunction } from 'express'; +import { pool } from '../db/pool'; +import { config } from '../config'; + +declare global { + namespace Express { + interface Request { + sessionId?: string; + } + } +} + +export async function requireAuth( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const sid = req.cookies?.sid; + if (!sid) { + res.status(401).json({ error: 'UNAUTHORIZED', message: 'No session' }); + return; + } + + const { rows } = await pool.query( + 'SELECT id, last_activity_at, is_active FROM sessions WHERE id = $1', + [sid], + ); + + if (rows.length === 0 || !rows[0].is_active) { + res.clearCookie('sid'); + res.status(401).json({ error: 'UNAUTHORIZED', message: 'Invalid session' }); + return; + } + + const lastActivity = new Date(rows[0].last_activity_at).getTime(); + if (Date.now() - lastActivity > config.sessionTimeoutMs) { + await pool.query('UPDATE sessions SET is_active = FALSE WHERE id = $1', [sid]); + res.clearCookie('sid'); + res.status(401).json({ error: 'UNAUTHORIZED', message: 'Session expired' }); + return; + } + + await pool.query( + 'UPDATE sessions SET last_activity_at = NOW() WHERE id = $1', + [sid], + ); + + req.sessionId = sid; + next(); +} diff --git a/backend/src/routes/accounts.ts b/backend/src/routes/accounts.ts new file mode 100644 index 0000000..7a6a53a --- /dev/null +++ b/backend/src/routes/accounts.ts @@ -0,0 +1,43 @@ +import { Router } from 'express'; +import { asyncHandler } from '../utils'; +import * as accountService from '../services/accounts'; + +const router = Router(); + +router.get( + '/', + asyncHandler(async (_req, res) => { + const accounts = await accountService.getAccounts(); + res.json(accounts); + }), +); + +router.put( + '/:id', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + if (isNaN(id)) { + res.status(400).json({ error: 'BAD_REQUEST', message: 'Invalid account id' }); + return; + } + + const { alias } = req.body; + if (typeof alias !== 'string' || !alias.trim()) { + res.status(400).json({ error: 'BAD_REQUEST', message: 'alias is required and must be non-empty' }); + return; + } + if (alias.trim().length > 50) { + res.status(400).json({ error: 'BAD_REQUEST', message: 'alias must be at most 50 characters' }); + return; + } + + const result = await accountService.updateAccountAlias(id, alias.trim()); + if (!result) { + res.status(404).json({ error: 'NOT_FOUND', message: 'Account not found' }); + return; + } + res.json(result); + }), +); + +export default router; diff --git a/backend/src/routes/analytics.ts b/backend/src/routes/analytics.ts new file mode 100644 index 0000000..c5608ed --- /dev/null +++ b/backend/src/routes/analytics.ts @@ -0,0 +1,73 @@ +import { Router } from 'express'; +import { asyncHandler } from '../utils'; +import * as analyticsService from '../services/analytics'; +import type { Granularity } from '@family-budget/shared'; + +const router = Router(); + +router.get( + '/summary', + asyncHandler(async (req, res) => { + const { from, to, accountId, onlyConfirmed } = req.query; + if (!from || !to) { + res.status(400).json({ error: 'BAD_REQUEST', message: 'from and to are required' }); + return; + } + + const result = await analyticsService.getSummary({ + from: from as string, + to: to as string, + accountId: accountId ? Number(accountId) : undefined, + onlyConfirmed: onlyConfirmed === 'true', + }); + res.json(result); + }), +); + +router.get( + '/by-category', + asyncHandler(async (req, res) => { + const { from, to, accountId, onlyConfirmed } = req.query; + if (!from || !to) { + res.status(400).json({ error: 'BAD_REQUEST', message: 'from and to are required' }); + return; + } + + const result = await analyticsService.getByCategory({ + from: from as string, + to: to as string, + accountId: accountId ? Number(accountId) : undefined, + onlyConfirmed: onlyConfirmed === 'true', + }); + res.json(result); + }), +); + +router.get( + '/timeseries', + asyncHandler(async (req, res) => { + const { from, to, accountId, categoryId, onlyConfirmed, granularity } = req.query; + if (!from || !to || !granularity) { + res.status(400).json({ error: 'BAD_REQUEST', message: 'from, to, and granularity are required' }); + return; + } + + const validGranularities: Granularity[] = ['day', 'week', 'month']; + if (!validGranularities.includes(granularity as Granularity)) { + res.status(400).json({ error: 'BAD_REQUEST', message: 'granularity must be day, week, or month' }); + return; + } + + const result = await analyticsService.getTimeseries({ + from: from as string, + to: to as string, + accountId: accountId ? Number(accountId) : undefined, + categoryId: categoryId ? Number(categoryId) : undefined, + onlyConfirmed: onlyConfirmed === 'true', + granularity: granularity as Granularity, + }); + res.json(result); + }), +); + +export default router; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..37c23de --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,51 @@ +import { Router } from 'express'; +import { asyncHandler } from '../utils'; +import { requireAuth } from '../middleware/auth'; +import * as authService from '../services/auth'; + +const router = Router(); + +router.post( + '/login', + asyncHandler(async (req, res) => { + const { login, password } = req.body; + if (!login || !password) { + res.status(400).json({ error: 'BAD_REQUEST', message: 'login and password are required' }); + return; + } + + const result = await authService.login({ login, password }); + if (!result) { + res.status(401).json({ error: 'UNAUTHORIZED', message: 'Invalid credentials' }); + return; + } + + res.cookie('sid', result.sessionId, { + httpOnly: true, + sameSite: 'lax', + path: '/', + }); + res.json({ ok: true }); + }), +); + +router.post( + '/logout', + requireAuth, + asyncHandler(async (req, res) => { + await authService.logout(req.sessionId!); + res.clearCookie('sid'); + res.json({ ok: true }); + }), +); + +router.get( + '/me', + requireAuth, + asyncHandler(async (req, res) => { + const result = await authService.me(req.sessionId!); + res.json(result); + }), +); + +export default router; diff --git a/backend/src/routes/categories.ts b/backend/src/routes/categories.ts new file mode 100644 index 0000000..28862fa --- /dev/null +++ b/backend/src/routes/categories.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { asyncHandler } from '../utils'; +import * as categoryService from '../services/categories'; + +const router = Router(); + +router.get( + '/', + asyncHandler(async (req, res) => { + let isActive: boolean | undefined; + if (req.query.isActive === 'true') isActive = true; + else if (req.query.isActive === 'false') isActive = false; + + const categories = await categoryService.getCategories(isActive); + res.json(categories); + }), +); + +export default router; diff --git a/backend/src/routes/categoryRules.ts b/backend/src/routes/categoryRules.ts new file mode 100644 index 0000000..c797dad --- /dev/null +++ b/backend/src/routes/categoryRules.ts @@ -0,0 +1,147 @@ +import { Router } from 'express'; +import { asyncHandler } from '../utils'; +import { pool } from '../db/pool'; +import * as ruleService from '../services/categoryRules'; + +const router = Router(); + +router.get( + '/', + asyncHandler(async (req, res) => { + const q = req.query; + const rules = await ruleService.getRules({ + isActive: q.isActive === 'true' ? true : q.isActive === 'false' ? false : undefined, + categoryId: q.categoryId ? Number(q.categoryId) : undefined, + search: q.search as string | undefined, + }); + res.json(rules); + }), +); + +router.post( + '/', + asyncHandler(async (req, res) => { + const { pattern, matchType, categoryId, priority, requiresConfirmation } = req.body; + + if (typeof pattern !== 'string' || !pattern.trim()) { + res.status(400).json({ error: 'BAD_REQUEST', message: 'pattern is required and must be non-empty' }); + return; + } + if (pattern.trim().length > 200) { + res.status(400).json({ error: 'BAD_REQUEST', message: 'pattern must be at most 200 characters' }); + return; + } + if (matchType !== undefined && matchType !== 'contains') { + res.status(422).json({ error: 'VALIDATION_ERROR', message: 'Only matchType "contains" is supported in MVP' }); + return; + } + if (categoryId == null) { + res.status(400).json({ error: 'BAD_REQUEST', message: 'categoryId is required' }); + return; + } + if (priority !== undefined && (typeof priority !== 'number' || priority < 0 || priority > 1000)) { + res.status(400).json({ error: 'BAD_REQUEST', message: 'priority must be an integer between 0 and 1000' }); + return; + } + + const catResult = await pool.query( + 'SELECT id, is_active FROM categories WHERE id = $1', + [categoryId], + ); + if (catResult.rows.length === 0 || !catResult.rows[0].is_active) { + res.status(422).json({ error: 'VALIDATION_ERROR', message: 'Category not found or inactive' }); + return; + } + + const rule = await ruleService.createRule({ pattern, matchType, categoryId, priority, requiresConfirmation }); + res.status(201).json(rule); + }), +); + +router.patch( + '/:id', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + if (isNaN(id)) { + res.status(400).json({ error: 'BAD_REQUEST', message: 'Invalid rule id' }); + return; + } + + if (req.body.matchType !== undefined) { + res.status(422).json({ error: 'VALIDATION_ERROR', message: 'matchType update is not supported in MVP' }); + return; + } + + const { pattern, categoryId, priority, requiresConfirmation, isActive } = req.body; + + if (pattern !== undefined && (typeof pattern !== 'string' || !pattern.trim())) { + res.status(400).json({ error: 'BAD_REQUEST', message: 'pattern must be non-empty' }); + return; + } + if (pattern !== undefined && pattern.trim().length > 200) { + res.status(400).json({ error: 'BAD_REQUEST', message: 'pattern must be at most 200 characters' }); + return; + } + if (priority !== undefined && (typeof priority !== 'number' || priority < 0 || priority > 1000)) { + res.status(400).json({ error: 'BAD_REQUEST', message: 'priority must be between 0 and 1000' }); + return; + } + + if (categoryId !== undefined) { + const catResult = await pool.query( + 'SELECT id, is_active FROM categories WHERE id = $1', + [categoryId], + ); + if (catResult.rows.length === 0 || !catResult.rows[0].is_active) { + res.status(422).json({ error: 'VALIDATION_ERROR', message: 'Category not found or inactive' }); + return; + } + } + + const hasUpdatableFields = + pattern !== undefined || + categoryId !== undefined || + priority !== undefined || + requiresConfirmation !== undefined || + isActive !== undefined; + + if (!hasUpdatableFields) { + res.status(400).json({ error: 'BAD_REQUEST', message: 'No updatable fields provided' }); + return; + } + + const result = await ruleService.updateRule(id, { + pattern, + categoryId, + priority, + requiresConfirmation, + isActive, + }); + + if (!result) { + res.status(404).json({ error: 'NOT_FOUND', message: 'Rule not found' }); + return; + } + res.json(result); + }), +); + +router.post( + '/:id/apply', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + if (isNaN(id)) { + res.status(400).json({ error: 'BAD_REQUEST', message: 'Invalid rule id' }); + return; + } + + const result = await ruleService.applyRule(id); + if ('status' in result) { + res.status(result.status).json({ error: result.error, message: result.message }); + return; + } + res.json(result); + }), +); + +export default router; diff --git a/backend/src/routes/import.ts b/backend/src/routes/import.ts new file mode 100644 index 0000000..d92d615 --- /dev/null +++ b/backend/src/routes/import.ts @@ -0,0 +1,22 @@ +import { Router } from 'express'; +import { asyncHandler } from '../utils'; +import { importStatement, isValidationError } from '../services/import'; + +const router = Router(); + +router.post( + '/statement', + asyncHandler(async (req, res) => { + const result = await importStatement(req.body); + if (isValidationError(result)) { + res.status((result as { status: number }).status).json({ + error: (result as { error: string }).error, + message: (result as { message: string }).message, + }); + return; + } + res.json(result); + }), +); + +export default router; diff --git a/backend/src/routes/transactions.ts b/backend/src/routes/transactions.ts new file mode 100644 index 0000000..7983025 --- /dev/null +++ b/backend/src/routes/transactions.ts @@ -0,0 +1,54 @@ +import { Router } from 'express'; +import { asyncHandler } from '../utils'; +import * as transactionService from '../services/transactions'; + +const router = Router(); + +router.get( + '/', + asyncHandler(async (req, res) => { + const q = req.query; + const result = await transactionService.getTransactions({ + accountId: q.accountId ? Number(q.accountId) : undefined, + from: q.from as string | undefined, + to: q.to as string | undefined, + direction: q.direction as string | undefined, + categoryId: q.categoryId ? Number(q.categoryId) : undefined, + search: q.search as string | undefined, + amountMin: q.amountMin ? Number(q.amountMin) : undefined, + amountMax: q.amountMax ? Number(q.amountMax) : undefined, + onlyUnconfirmed: q.onlyUnconfirmed === 'true', + sortBy: q.sortBy as 'date' | 'amount' | undefined, + sortOrder: q.sortOrder as 'asc' | 'desc' | undefined, + page: q.page ? Number(q.page) : undefined, + pageSize: q.pageSize ? Number(q.pageSize) : undefined, + }); + res.json(result); + }), +); + +router.put( + '/:id', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + if (isNaN(id)) { + res.status(400).json({ error: 'BAD_REQUEST', message: 'Invalid transaction id' }); + return; + } + + const { categoryId, comment } = req.body; + if (categoryId === undefined && comment === undefined) { + res.status(400).json({ error: 'BAD_REQUEST', message: 'No fields to update' }); + return; + } + + const result = await transactionService.updateTransaction(id, { categoryId, comment }); + if (!result) { + res.status(404).json({ error: 'NOT_FOUND', message: 'Transaction not found' }); + return; + } + res.json(result); + }), +); + +export default router; diff --git a/backend/src/services/accounts.ts b/backend/src/services/accounts.ts new file mode 100644 index 0000000..90ddf62 --- /dev/null +++ b/backend/src/services/accounts.ts @@ -0,0 +1,32 @@ +import { pool } from '../db/pool'; +import { maskAccountNumber } from '../utils'; +import type { Account } from '@family-budget/shared'; + +function toAccount(r: Record): Account { + return { + id: Number(r.id), + bank: r.bank as string, + accountNumberMasked: maskAccountNumber(r.account_number as string), + currency: r.currency as string, + alias: (r.alias as string) ?? null, + }; +} + +export async function getAccounts(): Promise { + const { rows } = await pool.query( + 'SELECT * FROM accounts ORDER BY bank ASC, id ASC', + ); + return rows.map(toAccount); +} + +export async function updateAccountAlias( + id: number, + alias: string, +): Promise { + const { rows } = await pool.query( + 'UPDATE accounts SET alias = $1 WHERE id = $2 RETURNING *', + [alias, id], + ); + if (rows.length === 0) return null; + return toAccount(rows[0]); +} diff --git a/backend/src/services/analytics.ts b/backend/src/services/analytics.ts new file mode 100644 index 0000000..2a7a773 --- /dev/null +++ b/backend/src/services/analytics.ts @@ -0,0 +1,196 @@ +import { pool } from '../db/pool'; +import type { + AnalyticsSummaryResponse, + TopCategory, + ByCategoryItem, + TimeseriesItem, + Granularity, +} from '@family-budget/shared'; + +interface BaseParams { + from: string; + to: string; + accountId?: number; + onlyConfirmed?: boolean; +} + +function buildBaseConditions( + params: BaseParams, + startIdx: number, +): { conditions: string[]; values: unknown[]; nextIdx: number } { + const conditions: string[] = []; + const values: unknown[] = []; + let idx = startIdx; + + conditions.push(`t.operation_at >= $${idx}::date`); + values.push(params.from); + idx++; + + conditions.push(`t.operation_at < ($${idx}::date + 1)`); + values.push(params.to); + idx++; + + if (params.accountId != null) { + conditions.push(`t.account_id = $${idx}`); + values.push(params.accountId); + idx++; + } + if (params.onlyConfirmed) { + conditions.push('t.is_category_confirmed = TRUE'); + } + + return { conditions, values, nextIdx: idx }; +} + +export async function getSummary(params: BaseParams): Promise { + const { conditions, values } = buildBaseConditions(params, 1); + const where = 'WHERE ' + conditions.join(' AND '); + + const totalsResult = await pool.query( + `SELECT + COALESCE(SUM(CASE WHEN t.direction = 'expense' THEN ABS(t.amount_signed) ELSE 0 END), 0)::bigint AS total_expense, + COALESCE(SUM(CASE WHEN t.direction = 'income' THEN t.amount_signed ELSE 0 END), 0)::bigint AS total_income + FROM transactions t + ${where}`, + values, + ); + + const totalExpense = Number(totalsResult.rows[0].total_expense); + const totalIncome = Number(totalsResult.rows[0].total_income); + + const topResult = await pool.query( + `SELECT + t.category_id, + c.name AS category_name, + SUM(ABS(t.amount_signed))::bigint AS amount + FROM transactions t + LEFT JOIN categories c ON c.id = t.category_id + ${where} AND t.direction = 'expense' AND t.category_id IS NOT NULL + GROUP BY t.category_id, c.name + ORDER BY amount DESC + LIMIT 5`, + values, + ); + + const topCategories: TopCategory[] = topResult.rows.map((r) => ({ + categoryId: Number(r.category_id), + categoryName: r.category_name, + amount: Number(r.amount), + share: totalExpense > 0 ? Number(r.amount) / totalExpense : 0, + })); + + return { + totalExpense, + totalIncome, + net: totalIncome - totalExpense, + topCategories, + }; +} + +export async function getByCategory(params: BaseParams): Promise { + const { conditions, values } = buildBaseConditions(params, 1); + const where = 'WHERE ' + conditions.join(' AND '); + + const totalResult = await pool.query( + `SELECT COALESCE(SUM(ABS(t.amount_signed)), 0)::bigint AS total + FROM transactions t + ${where} AND t.direction = 'expense'`, + values, + ); + const total = Number(totalResult.rows[0].total); + + const { rows } = await pool.query( + `SELECT + t.category_id, + c.name AS category_name, + SUM(ABS(t.amount_signed))::bigint AS amount, + COUNT(*)::int AS tx_count + FROM transactions t + LEFT JOIN categories c ON c.id = t.category_id + ${where} AND t.direction = 'expense' + GROUP BY t.category_id, c.name + ORDER BY amount DESC`, + values, + ); + + return rows.map((r) => ({ + categoryId: r.category_id != null ? Number(r.category_id) : 0, + categoryName: r.category_name ?? 'Без категории', + amount: Number(r.amount), + txCount: r.tx_count, + share: total > 0 ? Number(r.amount) / total : 0, + })); +} + +export async function getTimeseries( + params: BaseParams & { categoryId?: number; granularity: Granularity }, +): Promise { + let truncExpr: string; + let intervalStr: string; + let periodEndExpr: string; + + switch (params.granularity) { + case 'day': + truncExpr = `$1::date`; + intervalStr = '1 day'; + periodEndExpr = 'gs::date'; + break; + case 'week': + truncExpr = `date_trunc('week', $1::date)::date`; + intervalStr = '1 week'; + periodEndExpr = "(gs + interval '6 days')::date"; + break; + case 'month': + truncExpr = `date_trunc('month', $1::date)::date`; + intervalStr = '1 month'; + periodEndExpr = "(gs + interval '1 month' - interval '1 day')::date"; + break; + } + + const txConditions: string[] = [ + 't.operation_at::date >= p.period_start', + 't.operation_at::date <= p.period_end', + ]; + const values: unknown[] = [params.from, params.to]; + let idx = 3; + + if (params.accountId != null) { + txConditions.push(`t.account_id = $${idx++}`); + values.push(params.accountId); + } + if (params.categoryId != null) { + txConditions.push(`t.category_id = $${idx++}`); + values.push(params.categoryId); + } + if (params.onlyConfirmed) { + txConditions.push('t.is_category_confirmed = TRUE'); + } + + const txWhere = txConditions.join(' AND '); + + const { rows } = await pool.query( + `WITH periods AS ( + SELECT + gs::date AS period_start, + ${periodEndExpr} AS period_end + FROM generate_series(${truncExpr}, $2::date, '${intervalStr}'::interval) gs + ) + SELECT + p.period_start, + p.period_end, + COALESCE(SUM(CASE WHEN t.direction = 'expense' THEN ABS(t.amount_signed) ELSE 0 END), 0)::bigint AS expense_amount, + COALESCE(SUM(CASE WHEN t.direction = 'income' THEN t.amount_signed ELSE 0 END), 0)::bigint AS income_amount + FROM periods p + LEFT JOIN transactions t ON ${txWhere} + GROUP BY p.period_start, p.period_end + ORDER BY p.period_start`, + values, + ); + + return rows.map((r) => ({ + periodStart: r.period_start.toISOString().slice(0, 10), + periodEnd: r.period_end.toISOString().slice(0, 10), + expenseAmount: Number(r.expense_amount), + incomeAmount: Number(r.income_amount), + })); +} diff --git a/backend/src/services/auth.ts b/backend/src/services/auth.ts new file mode 100644 index 0000000..7e4de6d --- /dev/null +++ b/backend/src/services/auth.ts @@ -0,0 +1,30 @@ +import { v4 as uuidv4 } from 'uuid'; +import { pool } from '../db/pool'; +import { config } from '../config'; +import type { LoginRequest, MeResponse } from '@family-budget/shared'; + +export async function login( + body: LoginRequest, +): Promise<{ sessionId: string } | null> { + if (body.login !== config.appUserLogin || body.password !== config.appUserPassword) { + return null; + } + + const sid = uuidv4(); + await pool.query( + 'INSERT INTO sessions (id) VALUES ($1)', + [sid], + ); + return { sessionId: sid }; +} + +export async function logout(sessionId: string): Promise { + await pool.query( + 'UPDATE sessions SET is_active = FALSE WHERE id = $1', + [sessionId], + ); +} + +export async function me(sessionId: string): Promise { + return { login: config.appUserLogin }; +} diff --git a/backend/src/services/categories.ts b/backend/src/services/categories.ts new file mode 100644 index 0000000..820b47a --- /dev/null +++ b/backend/src/services/categories.ts @@ -0,0 +1,23 @@ +import { pool } from '../db/pool'; +import type { Category } from '@family-budget/shared'; + +export async function getCategories(isActive?: boolean): Promise { + let query = 'SELECT * FROM categories'; + const values: unknown[] = []; + + if (isActive === undefined || isActive === true) { + query += ' WHERE is_active = TRUE'; + } else { + query += ' WHERE is_active = FALSE'; + } + + query += ' ORDER BY id ASC'; + + const { rows } = await pool.query(query, values); + return rows.map((r) => ({ + id: Number(r.id), + name: r.name, + type: r.type, + isActive: r.is_active, + })); +} diff --git a/backend/src/services/categoryRules.ts b/backend/src/services/categoryRules.ts new file mode 100644 index 0000000..870a37b --- /dev/null +++ b/backend/src/services/categoryRules.ts @@ -0,0 +1,152 @@ +import { pool } from '../db/pool'; +import { escapeLike } from '../utils'; +import type { + CategoryRule, + GetCategoryRulesParams, + CreateCategoryRuleRequest, + UpdateCategoryRuleRequest, + ApplyRuleResponse, +} from '@family-budget/shared'; + +function toRule(r: Record): CategoryRule { + return { + id: Number(r.id), + pattern: r.pattern as string, + matchType: r.match_type as CategoryRule['matchType'], + categoryId: Number(r.category_id), + categoryName: r.category_name as string, + priority: Number(r.priority), + requiresConfirmation: r.requires_confirmation as boolean, + isActive: r.is_active as boolean, + createdAt: (r.created_at as Date).toISOString(), + }; +} + +export async function getRules(params: GetCategoryRulesParams): Promise { + const conditions: string[] = []; + const values: unknown[] = []; + let idx = 1; + + if (params.isActive !== undefined) { + conditions.push(`cr.is_active = $${idx++}`); + values.push(params.isActive); + } + if (params.categoryId != null) { + conditions.push(`cr.category_id = $${idx++}`); + values.push(params.categoryId); + } + if (params.search) { + conditions.push(`cr.pattern ILIKE '%' || $${idx++} || '%'`); + values.push(escapeLike(params.search)); + } + + const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : ''; + + const { rows } = await pool.query( + `SELECT cr.*, c.name AS category_name + FROM category_rules cr + JOIN categories c ON c.id = cr.category_id + ${where} + ORDER BY cr.priority DESC, cr.created_at DESC`, + values, + ); + return rows.map(toRule); +} + +export async function createRule( + body: CreateCategoryRuleRequest, +): Promise { + const pattern = body.pattern.trim(); + const matchType = body.matchType ?? 'contains'; + const priority = body.priority ?? 100; + const requiresConfirmation = body.requiresConfirmation ?? false; + + const { rows } = await pool.query( + `INSERT INTO category_rules (pattern, match_type, category_id, priority, requires_confirmation) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [pattern, matchType, body.categoryId, priority, requiresConfirmation], + ); + + const catResult = await pool.query('SELECT name FROM categories WHERE id = $1', [body.categoryId]); + rows[0].category_name = catResult.rows[0].name; + return toRule(rows[0]); +} + +export async function updateRule( + id: number, + body: UpdateCategoryRuleRequest, +): Promise { + const fields: string[] = []; + const values: unknown[] = []; + let idx = 1; + + if (body.pattern !== undefined) { + fields.push(`pattern = $${idx++}`); + values.push(body.pattern.trim()); + } + if (body.categoryId !== undefined) { + fields.push(`category_id = $${idx++}`); + values.push(body.categoryId); + } + if (body.priority !== undefined) { + fields.push(`priority = $${idx++}`); + values.push(body.priority); + } + if (body.requiresConfirmation !== undefined) { + fields.push(`requires_confirmation = $${idx++}`); + values.push(body.requiresConfirmation); + } + if (body.isActive !== undefined) { + fields.push(`is_active = $${idx++}`); + values.push(body.isActive); + } + + if (fields.length === 0) return null; + + values.push(id); + const { rows } = await pool.query( + `UPDATE category_rules SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, + values, + ); + if (rows.length === 0) return null; + + const catResult = await pool.query('SELECT name FROM categories WHERE id = $1', [rows[0].category_id]); + rows[0].category_name = catResult.rows[0].name; + return toRule(rows[0]); +} + +export async function applyRule(id: number): Promise { + const { rows } = await pool.query('SELECT * FROM category_rules WHERE id = $1', [id]); + if (rows.length === 0) { + return { status: 404, error: 'NOT_FOUND', message: 'Rule not found' }; + } + const rule = rows[0]; + if (!rule.is_active) { + return { status: 422, error: 'VALIDATION_ERROR', message: 'Rule is inactive' }; + } + + let matchCondition: string; + if (rule.match_type === 'starts_with') { + matchCondition = `t.description ILIKE $1 || '%'`; + } else { + matchCondition = `t.description ILIKE '%' || $1 || '%'`; + } + + const result = await pool.query( + `UPDATE transactions t + SET category_id = $2, + is_category_confirmed = FALSE, + updated_at = NOW() + WHERE (t.category_id IS NULL OR t.is_category_confirmed = FALSE) + AND ${matchCondition}`, + [rule.pattern, rule.category_id], + ); + + return { applied: result.rowCount ?? 0 }; +} + +export async function ruleExists(id: number): Promise { + const { rows } = await pool.query('SELECT 1 FROM category_rules WHERE id = $1', [id]); + return rows.length > 0; +} diff --git a/backend/src/services/import.ts b/backend/src/services/import.ts new file mode 100644 index 0000000..1b25fe6 --- /dev/null +++ b/backend/src/services/import.ts @@ -0,0 +1,232 @@ +import crypto from 'crypto'; +import { pool } from '../db/pool'; +import { maskAccountNumber } from '../utils'; +import type { StatementFile, ImportStatementResponse } from '@family-budget/shared'; + +const TRANSFER_PHRASES = [ + 'перевод между своими счетами', + 'перевод средств на счет', + 'внутри втб', +]; + +function computeFingerprint( + accountNumber: string, + tx: { operationAt: string; amountSigned: number; commission: number; description: string }, +): string { + const raw = [ + accountNumber, + tx.operationAt, + String(tx.amountSigned), + String(tx.commission), + tx.description.trim(), + ].join('|'); + const hash = crypto.createHash('sha256').update(raw, 'utf-8').digest('hex'); + return `sha256:${hash}`; +} + +function determineDirection(amountSigned: number, description: string): string { + const lower = description.toLowerCase(); + for (const phrase of TRANSFER_PHRASES) { + if (lower.includes(phrase)) return 'transfer'; + } + return amountSigned > 0 ? 'income' : 'expense'; +} + +interface ValidationError { + status: number; + error: string; + message: string; +} + +const ISO_WITH_OFFSET = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}([+-]\d{2}:\d{2}|Z)/; + +function validateStructure(body: unknown): ValidationError | null { + const b = body as Record; + if (!b || typeof b !== 'object') { + return { status: 400, error: 'BAD_REQUEST', message: 'Body must be a JSON object' }; + } + + if (b.schemaVersion !== '1.0') { + return { status: 400, error: 'BAD_REQUEST', message: "schemaVersion must be '1.0'" }; + } + if (typeof b.bank !== 'string' || !b.bank) { + return { status: 400, error: 'BAD_REQUEST', message: 'bank is required and must be a non-empty string' }; + } + + const st = b.statement as Record | undefined; + if (!st || typeof st !== 'object') { + return { status: 400, error: 'BAD_REQUEST', message: 'statement is required' }; + } + for (const f of ['accountNumber', 'currency'] as const) { + if (typeof st[f] !== 'string' || !(st[f] as string)) { + return { status: 400, error: 'BAD_REQUEST', message: `statement.${f} is required and must be a non-empty string` }; + } + } + for (const f of ['openingBalance', 'closingBalance'] as const) { + if (typeof st[f] !== 'number' || !Number.isInteger(st[f])) { + return { status: 400, error: 'BAD_REQUEST', message: `statement.${f} must be an integer` }; + } + } + if (typeof st.exportedAt !== 'string' || !ISO_WITH_OFFSET.test(st.exportedAt as string)) { + return { status: 400, error: 'BAD_REQUEST', message: 'statement.exportedAt must be ISO 8601 with offset' }; + } + + const txs = b.transactions; + if (!Array.isArray(txs) || txs.length === 0) { + return { status: 400, error: 'BAD_REQUEST', message: 'transactions must be a non-empty array' }; + } + + for (let i = 0; i < txs.length; i++) { + const t = txs[i]; + if (!t || typeof t !== 'object') { + return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}] must be an object` }; + } + if (typeof t.operationAt !== 'string' || !ISO_WITH_OFFSET.test(t.operationAt)) { + return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].operationAt must be ISO 8601 with offset` }; + } + if (typeof t.amountSigned !== 'number' || !Number.isInteger(t.amountSigned) || t.amountSigned === 0) { + return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].amountSigned must be a non-zero integer` }; + } + if (typeof t.commission !== 'number' || !Number.isInteger(t.commission) || t.commission < 0) { + return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].commission must be a non-negative integer` }; + } + if (typeof t.description !== 'string' || !t.description) { + return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].description must be a non-empty string` }; + } + } + + return null; +} + +function validateSemantics(data: StatementFile): ValidationError | null { + if (data.statement.currency !== 'RUB') { + return { status: 422, error: 'VALIDATION_ERROR', message: `Unsupported currency: ${data.statement.currency}` }; + } + + for (let i = 0; i < data.transactions.length; i++) { + if (isNaN(Date.parse(data.transactions[i].operationAt))) { + return { status: 422, error: 'VALIDATION_ERROR', message: `Invalid date at transaction index ${i}` }; + } + } + + const fps = new Set(); + for (let i = 0; i < data.transactions.length; i++) { + const fp = computeFingerprint(data.statement.accountNumber, data.transactions[i]); + if (fps.has(fp)) { + return { status: 422, error: 'VALIDATION_ERROR', message: `Duplicate fingerprint found within file at transaction index ${i}` }; + } + fps.add(fp); + } + + return null; +} + +export async function importStatement( + body: unknown, +): Promise { + const structErr = validateStructure(body); + if (structErr) return structErr; + + const data = body as StatementFile; + const semErr = validateSemantics(data); + if (semErr) return semErr; + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // Find or create account + let accountId: number; + let isNewAccount = false; + + const accResult = await client.query( + 'SELECT id FROM accounts WHERE bank = $1 AND account_number = $2', + [data.bank, data.statement.accountNumber], + ); + + if (accResult.rows.length > 0) { + accountId = Number(accResult.rows[0].id); + } else { + const ins = await client.query( + 'INSERT INTO accounts (bank, account_number, currency) VALUES ($1, $2, $3) RETURNING id', + [data.bank, data.statement.accountNumber, data.statement.currency], + ); + accountId = Number(ins.rows[0].id); + isNewAccount = true; + } + + // Insert transactions + const insertedIds: number[] = []; + + for (const tx of data.transactions) { + const fp = computeFingerprint(data.statement.accountNumber, tx); + const dir = determineDirection(tx.amountSigned, tx.description); + + const result = await client.query( + `INSERT INTO transactions + (account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed) + VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, FALSE) + ON CONFLICT (account_id, fingerprint) DO NOTHING + RETURNING id`, + [accountId, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp], + ); + + if (result.rows.length > 0) { + insertedIds.push(Number(result.rows[0].id)); + } + } + + // Auto-categorize newly inserted transactions + if (insertedIds.length > 0) { + await client.query( + `UPDATE transactions t + SET category_id = sub.category_id, + is_category_confirmed = NOT sub.requires_confirmation, + updated_at = NOW() + FROM ( + SELECT DISTINCT ON (t2.id) + t2.id AS tx_id, + cr.category_id, + cr.requires_confirmation + FROM transactions t2 + JOIN category_rules cr + ON cr.is_active = TRUE + AND ( + (cr.match_type = 'contains' AND t2.description ILIKE '%' || cr.pattern || '%') + OR (cr.match_type = 'starts_with' AND t2.description ILIKE cr.pattern || '%') + ) + WHERE t2.id = ANY($1::bigint[]) + ORDER BY t2.id, cr.priority DESC, cr.id ASC + ) sub + WHERE t.id = sub.tx_id`, + [insertedIds], + ); + } + + await client.query('COMMIT'); + + return { + accountId, + isNewAccount, + accountNumberMasked: maskAccountNumber(data.statement.accountNumber), + imported: insertedIds.length, + duplicatesSkipped: data.transactions.length - insertedIds.length, + totalInFile: data.transactions.length, + }; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +} + +export function isValidationError(r: unknown): r is ValidationError { + return ( + typeof r === 'object' && + r !== null && + 'status' in r && + 'error' in r && + 'message' in r + ); +} diff --git a/backend/src/services/transactions.ts b/backend/src/services/transactions.ts new file mode 100644 index 0000000..072c308 --- /dev/null +++ b/backend/src/services/transactions.ts @@ -0,0 +1,170 @@ +import { pool } from '../db/pool'; +import { escapeLike } from '../utils'; +import type { + Transaction, + GetTransactionsParams, + PaginatedResponse, + UpdateTransactionRequest, +} from '@family-budget/shared'; + +export async function getTransactions( + params: GetTransactionsParams, +): Promise> { + const page = params.page ?? 1; + const pageSize = [10, 50, 100].includes(params.pageSize ?? 50) ? (params.pageSize ?? 50) : 50; + const sortBy = params.sortBy === 'amount' ? 't.amount_signed' : 't.operation_at'; + const sortOrder = params.sortOrder === 'asc' ? 'ASC' : 'DESC'; + + const conditions: string[] = []; + const values: unknown[] = []; + let idx = 1; + + if (params.accountId != null) { + conditions.push(`t.account_id = $${idx++}`); + values.push(params.accountId); + } + if (params.from) { + conditions.push(`t.operation_at >= $${idx++}::date`); + values.push(params.from); + } + if (params.to) { + conditions.push(`t.operation_at < ($${idx++}::date + 1)`); + values.push(params.to); + } + if (params.direction) { + const dirs = params.direction.split(',').map((d) => d.trim()); + conditions.push(`t.direction = ANY($${idx++}::text[])`); + values.push(dirs); + } + if (params.categoryId != null) { + conditions.push(`t.category_id = $${idx++}`); + values.push(params.categoryId); + } + if (params.search) { + conditions.push(`t.description ILIKE '%' || $${idx++} || '%'`); + values.push(escapeLike(params.search)); + } + if (params.amountMin != null) { + conditions.push(`t.amount_signed >= $${idx++}`); + values.push(params.amountMin); + } + if (params.amountMax != null) { + conditions.push(`t.amount_signed <= $${idx++}`); + values.push(params.amountMax); + } + if (params.onlyUnconfirmed) { + conditions.push('t.is_category_confirmed = FALSE'); + } + + const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : ''; + + const countResult = await pool.query( + `SELECT COUNT(*)::int AS total + FROM transactions t ${where}`, + values, + ); + const totalItems: number = countResult.rows[0].total; + + const offset = (page - 1) * pageSize; + const dataResult = await pool.query( + `SELECT + t.id, + t.operation_at, + t.account_id, + a.alias AS account_alias, + t.amount_signed, + t.commission, + t.description, + t.direction, + t.category_id, + c.name AS category_name, + t.is_category_confirmed, + t.comment + FROM transactions t + JOIN accounts a ON a.id = t.account_id + LEFT JOIN categories c ON c.id = t.category_id + ${where} + ORDER BY ${sortBy} ${sortOrder} + LIMIT $${idx++} OFFSET $${idx++}`, + [...values, pageSize, offset], + ); + + const items: Transaction[] = dataResult.rows.map((r) => ({ + id: Number(r.id), + operationAt: r.operation_at.toISOString(), + accountId: Number(r.account_id), + accountAlias: r.account_alias ?? null, + amountSigned: Number(r.amount_signed), + commission: Number(r.commission), + description: r.description, + direction: r.direction, + categoryId: r.category_id != null ? Number(r.category_id) : null, + categoryName: r.category_name ?? null, + isCategoryConfirmed: r.is_category_confirmed, + comment: r.comment ?? null, + })); + + return { + items, + page, + pageSize, + totalItems, + totalPages: Math.ceil(totalItems / pageSize) || 1, + }; +} + +export async function updateTransaction( + id: number, + body: UpdateTransactionRequest, +): Promise { + const fields: string[] = []; + const values: unknown[] = []; + let idx = 1; + + if (body.categoryId !== undefined) { + fields.push(`category_id = $${idx++}`); + values.push(body.categoryId); + } + if (body.comment !== undefined) { + fields.push(`comment = $${idx++}`); + values.push(body.comment); + } + + if (fields.length === 0) { + return null; + } + + fields.push('is_category_confirmed = TRUE'); + fields.push('updated_at = NOW()'); + + values.push(id); + + const result = await pool.query( + `UPDATE transactions SET ${fields.join(', ')} WHERE id = $${idx} + RETURNING *`, + values, + ); + + if (result.rows.length === 0) return null; + const r = result.rows[0]; + + const accResult = await pool.query('SELECT alias FROM accounts WHERE id = $1', [r.account_id]); + const catResult = r.category_id + ? await pool.query('SELECT name FROM categories WHERE id = $1', [r.category_id]) + : { rows: [] }; + + return { + id: Number(r.id), + operationAt: r.operation_at.toISOString(), + accountId: Number(r.account_id), + accountAlias: accResult.rows[0]?.alias ?? null, + amountSigned: Number(r.amount_signed), + commission: Number(r.commission), + description: r.description, + direction: r.direction, + categoryId: r.category_id != null ? Number(r.category_id) : null, + categoryName: catResult.rows[0]?.name ?? null, + isCategoryConfirmed: r.is_category_confirmed, + comment: r.comment ?? null, + }; +} diff --git a/backend/src/utils.ts b/backend/src/utils.ts new file mode 100644 index 0000000..aa90ea1 --- /dev/null +++ b/backend/src/utils.ts @@ -0,0 +1,18 @@ +import { Request, Response, NextFunction } from 'express'; + +export function maskAccountNumber(num: string): string { + if (num.length <= 10) return num; + return num.slice(0, 6) + '*'.repeat(num.length - 10) + num.slice(-4); +} + +export function escapeLike(input: string): string { + return input.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_'); +} + +type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise; + +export function asyncHandler(fn: AsyncHandler) { + return (req: Request, res: Response, next: NextFunction) => { + fn(req, res, next).catch(next); + }; +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..08d6945 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +}