feat: creates backend for the project
This commit is contained in:
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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user