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