feat: assign deterministic operation UUIDs

This commit is contained in:
2026-08-19 17:46:50 +03:00
parent 3d08c9e5c6
commit 5c059cbc62
8 changed files with 34 additions and 7 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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: `

View File

@@ -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) {

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/frontend",
"version": "0.9.1",
"version": "0.9.2",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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',

View File

@@ -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",

View File

@@ -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 {