feat: creates backend for the project

This commit is contained in:
vakabunga
2026-03-02 00:32:37 +03:00
parent 9d12702688
commit 4d67636633
24 changed files with 1735 additions and 0 deletions

12
backend/.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
});

View 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();
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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]);
}

View 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),
}));
}

View 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 };
}

View 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,
}));
}

View 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;
}

View 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
);
}

View 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
View 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
View 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"]
}