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

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