feat: creates backend for the project
This commit is contained in:
12
backend/.env.example
Normal file
12
backend/.env.example
Normal file
@@ -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
|
||||||
135
backend/README.md
Normal file
135
backend/README.md
Normal file
@@ -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** — серверные сессии с таймаутом по бездействию
|
||||||
30
backend/package.json
Normal file
30
backend/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
56
backend/src/app.ts
Normal file
56
backend/src/app.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
21
backend/src/config.ts
Normal file
21
backend/src/config.ts
Normal file
@@ -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),
|
||||||
|
};
|
||||||
142
backend/src/db/migrate.ts
Normal file
142
backend/src/db/migrate.ts
Normal file
@@ -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<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
10
backend/src/db/pool.ts
Normal file
10
backend/src/db/pool.ts
Normal file
@@ -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,
|
||||||
|
});
|
||||||
50
backend/src/middleware/auth.ts
Normal file
50
backend/src/middleware/auth.ts
Normal file
@@ -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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
43
backend/src/routes/accounts.ts
Normal file
43
backend/src/routes/accounts.ts
Normal file
@@ -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;
|
||||||
73
backend/src/routes/analytics.ts
Normal file
73
backend/src/routes/analytics.ts
Normal file
@@ -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;
|
||||||
51
backend/src/routes/auth.ts
Normal file
51
backend/src/routes/auth.ts
Normal file
@@ -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;
|
||||||
19
backend/src/routes/categories.ts
Normal file
19
backend/src/routes/categories.ts
Normal file
@@ -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;
|
||||||
147
backend/src/routes/categoryRules.ts
Normal file
147
backend/src/routes/categoryRules.ts
Normal file
@@ -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;
|
||||||
22
backend/src/routes/import.ts
Normal file
22
backend/src/routes/import.ts
Normal file
@@ -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;
|
||||||
54
backend/src/routes/transactions.ts
Normal file
54
backend/src/routes/transactions.ts
Normal file
@@ -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;
|
||||||
32
backend/src/services/accounts.ts
Normal file
32
backend/src/services/accounts.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { pool } from '../db/pool';
|
||||||
|
import { maskAccountNumber } from '../utils';
|
||||||
|
import type { Account } from '@family-budget/shared';
|
||||||
|
|
||||||
|
function toAccount(r: Record<string, unknown>): 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<Account[]> {
|
||||||
|
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<Account | null> {
|
||||||
|
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]);
|
||||||
|
}
|
||||||
196
backend/src/services/analytics.ts
Normal file
196
backend/src/services/analytics.ts
Normal file
@@ -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<AnalyticsSummaryResponse> {
|
||||||
|
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<ByCategoryItem[]> {
|
||||||
|
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<TimeseriesItem[]> {
|
||||||
|
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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
30
backend/src/services/auth.ts
Normal file
30
backend/src/services/auth.ts
Normal file
@@ -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<void> {
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE sessions SET is_active = FALSE WHERE id = $1',
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function me(sessionId: string): Promise<MeResponse> {
|
||||||
|
return { login: config.appUserLogin };
|
||||||
|
}
|
||||||
23
backend/src/services/categories.ts
Normal file
23
backend/src/services/categories.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { pool } from '../db/pool';
|
||||||
|
import type { Category } from '@family-budget/shared';
|
||||||
|
|
||||||
|
export async function getCategories(isActive?: boolean): Promise<Category[]> {
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
152
backend/src/services/categoryRules.ts
Normal file
152
backend/src/services/categoryRules.ts
Normal file
@@ -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<string, unknown>): 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<CategoryRule[]> {
|
||||||
|
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<CategoryRule> {
|
||||||
|
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<CategoryRule | null> {
|
||||||
|
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<ApplyRuleResponse | { error: string; status: number; message: string }> {
|
||||||
|
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<boolean> {
|
||||||
|
const { rows } = await pool.query('SELECT 1 FROM category_rules WHERE id = $1', [id]);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
232
backend/src/services/import.ts
Normal file
232
backend/src/services/import.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<string, unknown> | 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<string>();
|
||||||
|
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<ImportStatementResponse | ValidationError> {
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
170
backend/src/services/transactions.ts
Normal file
170
backend/src/services/transactions.ts
Normal file
@@ -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<PaginatedResponse<Transaction>> {
|
||||||
|
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<Transaction | null> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
18
backend/src/utils.ts
Normal file
18
backend/src/utils.ts
Normal file
@@ -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<void>;
|
||||||
|
|
||||||
|
export function asyncHandler(fn: AsyncHandler) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
fn(req, res, next).catch(next);
|
||||||
|
};
|
||||||
|
}
|
||||||
17
backend/tsconfig.json
Normal file
17
backend/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user