319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
import crypto from 'crypto';
|
|
import { pool } from '../db/pool';
|
|
import { maskAccountNumber } from '../utils';
|
|
import type { StatementFile, ImportStatementResponse } from '@family-budget/shared';
|
|
|
|
const TRANSFER_PHRASES = [
|
|
'перевод между своими счетами',
|
|
'перевод средств на счет',
|
|
'внутри втб',
|
|
];
|
|
const CASHBACK_KEYWORD = 'зачисление';
|
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
|
|
function computeFingerprint(
|
|
accountNumber: string,
|
|
tx: { operationAt: string; amountSigned: number; commission: number; description: string; sourceId?: string },
|
|
): string {
|
|
if (tx.sourceId) {
|
|
const raw = [accountNumber, tx.sourceId.trim()].join('|');
|
|
const hash = crypto.createHash('sha256').update(raw, 'utf-8').digest('hex');
|
|
return `sha256:${hash}`;
|
|
}
|
|
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.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` };
|
|
}
|
|
if (t.sourceId !== undefined && (typeof t.sourceId !== 'string' || !t.sourceId.trim())) {
|
|
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].sourceId must be a non-empty string when provided` };
|
|
}
|
|
if (t.operationId !== undefined && (typeof t.operationId !== 'string' || !UUID_RE.test(t.operationId))) {
|
|
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].operationId must be a valid UUID when provided` };
|
|
}
|
|
if (typeof t.amountSigned !== 'number' || !Number.isInteger(t.amountSigned)) {
|
|
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].amountSigned must be an integer` };
|
|
}
|
|
if (t.amountSigned === 0) {
|
|
const hasCashbackMarker = t.description.toLowerCase().includes(CASHBACK_KEYWORD);
|
|
if (t.commission <= 0 || !hasCashbackMarker) {
|
|
return {
|
|
status: 400,
|
|
error: 'BAD_REQUEST',
|
|
message: `transactions[${i}] with amountSigned=0 must have commission>0 and contain 'Зачисление' in description`,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
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>();
|
|
const operationIds = 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);
|
|
const operationId = data.transactions[i].operationId;
|
|
if (operationId) {
|
|
if (operationIds.has(operationId.toLowerCase())) {
|
|
return { status: 422, error: 'VALIDATION_ERROR', message: `Duplicate operationId found within file at transaction index ${i}` };
|
|
}
|
|
operationIds.add(operationId.toLowerCase());
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export async function importStatement(
|
|
body: unknown,
|
|
db: Pick<typeof pool, 'connect'> = pool,
|
|
): 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 db.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Find or create account
|
|
let accountId: number;
|
|
let isNewAccount = false;
|
|
|
|
const accResult = await client.query(
|
|
'SELECT id, account_type 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;
|
|
}
|
|
|
|
// Create import record (counts updated after loop)
|
|
const accountNumberMasked = maskAccountNumber(data.statement.accountNumber);
|
|
const importResult = await client.query(
|
|
`INSERT INTO imports (account_id, bank, account_number_masked, imported_count, duplicates_skipped, total_in_file)
|
|
VALUES ($1, $2, $3, 0, 0, $4)
|
|
RETURNING id`,
|
|
[accountId, data.bank, accountNumberMasked, data.transactions.length],
|
|
);
|
|
const importId = Number(importResult.rows[0].id);
|
|
const incomeCategoryResult = await client.query(
|
|
`SELECT id
|
|
FROM categories
|
|
WHERE name = 'Поступления' AND type = 'income' AND is_active = TRUE
|
|
ORDER BY id ASC
|
|
LIMIT 1`,
|
|
);
|
|
if (incomeCategoryResult.rows.length === 0) {
|
|
throw new Error("Category 'Поступления' is missing");
|
|
}
|
|
const incomeCategoryId = Number(incomeCategoryResult.rows[0].id);
|
|
const accountType = accResult.rows[0]?.account_type ?? null;
|
|
const investmentCategoryResult = await client.query(
|
|
`SELECT id FROM categories WHERE name = 'Инвестиции' AND type = 'transfer' AND is_active = TRUE LIMIT 1`,
|
|
);
|
|
const investmentCategoryId = investmentCategoryResult.rows[0]
|
|
? Number(investmentCategoryResult.rows[0].id)
|
|
: null;
|
|
const isInvestmentAccount = ['brokerage', 'iis', 'savings'].includes(accountType);
|
|
|
|
// Insert transactions
|
|
const insertedIds: number[] = [];
|
|
|
|
for (const [sourcePosition, tx] of data.transactions.entries()) {
|
|
const fp = computeFingerprint(data.statement.accountNumber, tx);
|
|
const isCashbackCommissionImport =
|
|
tx.amountSigned === 0 &&
|
|
tx.commission > 0 &&
|
|
tx.description.toLowerCase().includes(CASHBACK_KEYWORD);
|
|
const dir = isCashbackCommissionImport
|
|
? 'income'
|
|
: isInvestmentAccount ? 'transfer' : determineDirection(tx.amountSigned, tx.description);
|
|
const categoryId = isCashbackCommissionImport
|
|
? incomeCategoryId
|
|
: isInvestmentAccount ? investmentCategoryId : null;
|
|
const isCategoryConfirmed = isCashbackCommissionImport;
|
|
|
|
const result = await client.query(
|
|
`INSERT INTO transactions
|
|
(account_id, operation_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed, import_id, source_position)
|
|
VALUES ($1, COALESCE($2::uuid, gen_random_uuid()), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
ON CONFLICT DO NOTHING
|
|
RETURNING id`,
|
|
[accountId, tx.operationId ?? null, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, categoryId, isCategoryConfirmed, importId, sourcePosition],
|
|
);
|
|
|
|
if (result.rows.length > 0) {
|
|
insertedIds.push(Number(result.rows[0].id));
|
|
}
|
|
}
|
|
|
|
// Update import record with actual counts
|
|
const duplicatesSkipped = data.transactions.length - insertedIds.length;
|
|
await client.query(
|
|
`UPDATE imports SET imported_count = $1, duplicates_skipped = $2 WHERE id = $3`,
|
|
[insertedIds.length, duplicatesSkipped, importId],
|
|
);
|
|
|
|
// 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[])
|
|
AND t2.is_category_confirmed = FALSE
|
|
ORDER BY t2.id, cr.priority DESC, cr.id ASC
|
|
) sub
|
|
WHERE t.id = sub.tx_id`,
|
|
[insertedIds],
|
|
);
|
|
if (isInvestmentAccount && investmentCategoryId != null) {
|
|
await client.query(
|
|
`UPDATE transactions SET is_category_confirmed = TRUE, updated_at = NOW()
|
|
WHERE id = ANY($1::bigint[]) AND category_id = $2 AND is_category_confirmed = FALSE`,
|
|
[insertedIds, investmentCategoryId],
|
|
);
|
|
}
|
|
}
|
|
|
|
await client.query('COMMIT');
|
|
|
|
return {
|
|
accountId,
|
|
isNewAccount,
|
|
accountNumberMasked,
|
|
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
|
|
);
|
|
}
|