From 5c059cbc62d80396bd41fdd6db3e5b08de21f002 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 19 Aug 2026 17:46:50 +0300 Subject: [PATCH] feat: assign deterministic operation UUIDs --- CHANGELOG.md | 6 ++++++ backend/package.json | 2 +- backend/src/db/migrate.ts | 10 ++++++++++ backend/src/services/import.ts | 10 +++++++--- frontend/package.json | 2 +- scripts/convert_vtb_broker_xlsx.py | 7 ++++++- shared/package.json | 2 +- shared/src/types/import.ts | 2 ++ 8 files changed, 34 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 213825e..27ad980 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Frontend 0.9.2 / Backend 0.6.4 / Shared 0.2.2] - 2026-08-19 + +### Added + +- Added deterministic UUIDs for imported operations and a unique database constraint per account. + ## [Frontend 0.9.1 / Backend 0.6.3 / Shared 0.2.1] - 2026-08-19 ### Added diff --git a/backend/package.json b/backend/package.json index 9529392..327826d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/backend", - "version": "0.6.3", + "version": "0.6.4", "private": true, "scripts": { "dev": "tsx watch src/app.ts", diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 092e5a2..b89b4d6 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -133,6 +133,16 @@ const migrations: { name: string; sql: string }[] = [ AND NOT EXISTS (SELECT 1 FROM category_rules LIMIT 1); `, }, + { + name: '006_transaction_operation_uuid', + sql: ` + CREATE EXTENSION IF NOT EXISTS pgcrypto; + ALTER TABLE transactions + ADD COLUMN IF NOT EXISTS operation_id UUID NOT NULL DEFAULT gen_random_uuid(); + CREATE UNIQUE INDEX IF NOT EXISTS ux_transactions_account_operation_id + ON transactions(account_id, operation_id); + `, + }, { name: '005_imports_table', sql: ` diff --git a/backend/src/services/import.ts b/backend/src/services/import.ts index 9d906f2..37deedb 100644 --- a/backend/src/services/import.ts +++ b/backend/src/services/import.ts @@ -9,6 +9,7 @@ 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, @@ -99,6 +100,9 @@ function validateStructure(body: unknown): ValidationError | null { 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` }; } @@ -212,11 +216,11 @@ export async function importStatement( const result = await client.query( `INSERT INTO transactions - (account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed, import_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + (account_id, operation_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed, import_id) + VALUES ($1, COALESCE($2::uuid, gen_random_uuid()), $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (account_id, fingerprint) DO NOTHING RETURNING id`, - [accountId, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, categoryId, isCategoryConfirmed, importId], + [accountId, tx.operationId ?? null, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, categoryId, isCategoryConfirmed, importId], ); if (result.rows.length > 0) { diff --git a/frontend/package.json b/frontend/package.json index e8c688b..8f8488a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/frontend", - "version": "0.9.1", + "version": "0.9.2", "private": true, "type": "module", "scripts": { diff --git a/scripts/convert_vtb_broker_xlsx.py b/scripts/convert_vtb_broker_xlsx.py index 152bc80..717a4cd 100644 --- a/scripts/convert_vtb_broker_xlsx.py +++ b/scripts/convert_vtb_broker_xlsx.py @@ -8,6 +8,7 @@ import hashlib import json import re import sys +import uuid from collections import defaultdict from datetime import datetime, timedelta from decimal import Decimal, ROUND_HALF_UP @@ -18,6 +19,7 @@ from xml.etree import ElementTree as ET NS = {'m': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'} EPOCH = datetime(1899, 12, 30) COLS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' +OPERATION_NAMESPACE = uuid.UUID('7a4a1c7d-7d3c-4f53-9f45-5e7dbb2c6f31') def excel_date(value): @@ -106,12 +108,14 @@ def cash_transactions(rows, start, end): description = text(f'{operation}. {comment}'.strip('. ')) digest = hashlib.sha256(f'{operation_at}|{amount}|{description}'.encode()).hexdigest()[:16] occurrences[digest] += 1 + source_id = f'vtb-broker-cash:{digest}:{occurrences[digest]}' transactions.append({ 'operationAt': operation_at, 'amountSigned': amount, 'commission': 0, 'description': description, - 'sourceId': f'vtb-broker-cash:{digest}:{occurrences[digest]}', + 'sourceId': source_id, + 'operationId': str(uuid.uuid5(OPERATION_NAMESPACE, source_id)), '_sourceRow': number, }) if not transactions: @@ -151,6 +155,7 @@ def portfolio(rows, holdings_start, movement_start, trades_start, trades_end, ac item['sourceRow'] = number item['concludedAt'] = concluded_at item['sourceId'] = f"vtb-broker-trade:{item['tradeId'] or number}" + item['operationId'] = str(uuid.uuid5(OPERATION_NAMESPACE, item['sourceId'])) trades.append(item) return { 'schemaVersion': 'broker-portfolio-1.0', diff --git a/shared/package.json b/shared/package.json index 7d3746c..1ad3644 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/shared", - "version": "0.2.1", + "version": "0.2.2", "private": true, "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/shared/src/types/import.ts b/shared/src/types/import.ts index 5baa7e5..d477af7 100644 --- a/shared/src/types/import.ts +++ b/shared/src/types/import.ts @@ -34,6 +34,8 @@ export interface StatementTransaction { description: string; /** Stable source-row identity used to make repeated imports idempotent. */ sourceId?: string; + /** Stable operation UUID emitted by source-specific converters. */ + operationId?: string; } export interface ImportStatementResponse {