fix: import identical JSON transactions
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/backend",
|
"name": "@family-budget/backend",
|
||||||
"version": "0.10.3",
|
"version": "0.10.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/app.ts",
|
"dev": "tsx watch src/app.ts",
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ const makeStatement = (sourceIds: string[]) => ({
|
|||||||
transactions: sourceIds.map((sourceId) => ({ operationAt: '2026-08-20T10:00:00+03:00', amountSigned: 100, commission: 0, description: 'Пополнение', sourceId })),
|
transactions: sourceIds.map((sourceId) => ({ operationAt: '2026-08-20T10:00:00+03:00', amountSigned: 100, commission: 0, description: 'Пополнение', sourceId })),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const duplicateStatement = {
|
||||||
|
schemaVersion: '1.0', bank: 'TEST',
|
||||||
|
statement: { accountNumber: 'fingerprint-test', currency: 'RUB', openingBalance: 0, closingBalance: 200, exportedAt: '2026-08-20T12:00:00+03:00' },
|
||||||
|
transactions: Array.from({ length: 2 }, () => ({ operationAt: '2026-08-20T00:00:00+03:00', amountSigned: 100, commission: 0, description: 'Пополнение' })),
|
||||||
|
};
|
||||||
|
|
||||||
async function run(): Promise<void> {
|
async function run(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await importStatement(makeStatement(['first', 'first-second']));
|
await importStatement(makeStatement(['first', 'first-second']));
|
||||||
@@ -18,11 +24,21 @@ async function run(): Promise<void> {
|
|||||||
await importStatement(makeStatement(['second']));
|
await importStatement(makeStatement(['second']));
|
||||||
const result = await pool.query('SELECT account_type, status FROM accounts WHERE id = $1', [account.rows[0].id]);
|
const result = await pool.query('SELECT account_type, status FROM accounts WHERE id = $1', [account.rows[0].id]);
|
||||||
assert.deepEqual(result.rows[0], { account_type: 'savings', status: 'closed' });
|
assert.deepEqual(result.rows[0], { account_type: 'savings', status: 'closed' });
|
||||||
|
const duplicateResult = await importStatement(duplicateStatement);
|
||||||
|
if ('status' in duplicateResult) throw new Error(duplicateResult.message);
|
||||||
|
assert.deepEqual(duplicateResult, {
|
||||||
|
accountId: duplicateResult.accountId,
|
||||||
|
isNewAccount: true,
|
||||||
|
accountNumberMasked: 'fingerp******test',
|
||||||
|
imported: 2,
|
||||||
|
duplicatesSkipped: 0,
|
||||||
|
totalInFile: 2,
|
||||||
|
});
|
||||||
console.log('import metadata SQL: OK');
|
console.log('import metadata SQL: OK');
|
||||||
} finally {
|
} finally {
|
||||||
await pool.query('DELETE FROM transactions WHERE account_id IN (SELECT id FROM accounts WHERE bank = \'TEST\' AND account_number = \'metadata-test\')');
|
await pool.query("DELETE FROM transactions WHERE account_id IN (SELECT id FROM accounts WHERE bank = 'TEST' AND account_number IN ('metadata-test', 'fingerprint-test'))");
|
||||||
await pool.query("DELETE FROM imports WHERE account_id IN (SELECT id FROM accounts WHERE bank = 'TEST' AND account_number = 'metadata-test')");
|
await pool.query("DELETE FROM imports WHERE account_id IN (SELECT id FROM accounts WHERE bank = 'TEST' AND account_number IN ('metadata-test', 'fingerprint-test'))");
|
||||||
await pool.query("DELETE FROM accounts WHERE bank = 'TEST' AND account_number = 'metadata-test'");
|
await pool.query("DELETE FROM accounts WHERE bank = 'TEST' AND account_number IN ('metadata-test', 'fingerprint-test')");
|
||||||
await pool.end();
|
await pool.end();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { determineDirection } from './import';
|
import { computeFingerprint, determineDirection } from './import';
|
||||||
|
|
||||||
assert.equal(determineDirection(1, 'Перечисление средств на счет N 123 со счета N 456'), 'transfer');
|
assert.equal(determineDirection(1, 'Перечисление средств на счет N 123 со счета N 456'), 'transfer');
|
||||||
assert.equal(determineDirection(1, 'Перечисление средств на вклад N 123'), 'transfer');
|
assert.equal(determineDirection(1, 'Перечисление средств на вклад N 123'), 'transfer');
|
||||||
assert.equal(determineDirection(-1, 'Перечисление суммы вклада при закрытии'), 'transfer');
|
assert.equal(determineDirection(-1, 'Перечисление суммы вклада при закрытии'), 'transfer');
|
||||||
assert.equal(determineDirection(-1, 'Оплата покупки'), 'expense');
|
assert.equal(determineDirection(-1, 'Оплата покупки'), 'expense');
|
||||||
assert.equal(determineDirection(1, 'Выплата процентов'), 'income');
|
assert.equal(determineDirection(1, 'Выплата процентов'), 'income');
|
||||||
|
const duplicateTransaction = { operationAt: '2026-08-20T00:00:00+03:00', amountSigned: 100, commission: 0, description: 'Пополнение' };
|
||||||
|
assert.notEqual(computeFingerprint('fingerprint-test', duplicateTransaction, 0), computeFingerprint('fingerprint-test', duplicateTransaction, 1));
|
||||||
|
|
||||||
console.log('import direction: OK');
|
console.log('import direction: OK');
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ const TRANSFER_PHRASES = [
|
|||||||
const CASHBACK_KEYWORD = 'зачисление';
|
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;
|
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(
|
export function computeFingerprint(
|
||||||
accountNumber: string,
|
accountNumber: string,
|
||||||
tx: { operationAt: string; amountSigned: number; commission: number; description: string; sourceId?: string },
|
tx: { operationAt: string; amountSigned: number; commission: number; description: string; sourceId?: string },
|
||||||
|
sourcePosition?: number,
|
||||||
): string {
|
): string {
|
||||||
if (tx.sourceId) {
|
if (tx.sourceId) {
|
||||||
const raw = [accountNumber, tx.sourceId.trim()].join('|');
|
const raw = [accountNumber, tx.sourceId.trim()].join('|');
|
||||||
@@ -29,6 +30,7 @@ function computeFingerprint(
|
|||||||
String(tx.amountSigned),
|
String(tx.amountSigned),
|
||||||
String(tx.commission),
|
String(tx.commission),
|
||||||
tx.description.trim(),
|
tx.description.trim(),
|
||||||
|
...(sourcePosition === undefined ? [] : [String(sourcePosition)]),
|
||||||
].join('|');
|
].join('|');
|
||||||
const hash = crypto.createHash('sha256').update(raw, 'utf-8').digest('hex');
|
const hash = crypto.createHash('sha256').update(raw, 'utf-8').digest('hex');
|
||||||
return `sha256:${hash}`;
|
return `sha256:${hash}`;
|
||||||
@@ -139,7 +141,7 @@ function validateSemantics(data: StatementFile): ValidationError | null {
|
|||||||
const operationIds = new Set<string>();
|
const operationIds = new Set<string>();
|
||||||
for (let i = 0; i < data.transactions.length; i++) {
|
for (let i = 0; i < data.transactions.length; i++) {
|
||||||
const fp = computeFingerprint(data.statement.accountNumber, data.transactions[i]);
|
const fp = computeFingerprint(data.statement.accountNumber, data.transactions[i]);
|
||||||
if (fps.has(fp)) {
|
if (data.transactions[i].sourceId && fps.has(fp)) {
|
||||||
return { status: 422, error: 'VALIDATION_ERROR', message: `Duplicate fingerprint found within file at transaction index ${i}` };
|
return { status: 422, error: 'VALIDATION_ERROR', message: `Duplicate fingerprint found within file at transaction index ${i}` };
|
||||||
}
|
}
|
||||||
fps.add(fp);
|
fps.add(fp);
|
||||||
@@ -165,6 +167,15 @@ export async function importStatement(
|
|||||||
const data = body as StatementFile;
|
const data = body as StatementFile;
|
||||||
const semErr = validateSemantics(data);
|
const semErr = validateSemantics(data);
|
||||||
if (semErr) return semErr;
|
if (semErr) return semErr;
|
||||||
|
const fallbackFingerprints = new Set<string>();
|
||||||
|
const duplicateFallbackFingerprints = new Set<string>();
|
||||||
|
for (const tx of data.transactions) {
|
||||||
|
if (!tx.sourceId) {
|
||||||
|
const fp = computeFingerprint(data.statement.accountNumber, tx);
|
||||||
|
if (fallbackFingerprints.has(fp)) duplicateFallbackFingerprints.add(fp);
|
||||||
|
fallbackFingerprints.add(fp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const client = await db.connect();
|
const client = await db.connect();
|
||||||
try {
|
try {
|
||||||
@@ -223,7 +234,10 @@ export async function importStatement(
|
|||||||
const insertedIds: number[] = [];
|
const insertedIds: number[] = [];
|
||||||
|
|
||||||
for (const [sourcePosition, tx] of data.transactions.entries()) {
|
for (const [sourcePosition, tx] of data.transactions.entries()) {
|
||||||
const fp = computeFingerprint(data.statement.accountNumber, tx);
|
const fallbackFingerprint = computeFingerprint(data.statement.accountNumber, tx);
|
||||||
|
const fp = !tx.sourceId && duplicateFallbackFingerprints.has(fallbackFingerprint)
|
||||||
|
? computeFingerprint(data.statement.accountNumber, tx, sourcePosition)
|
||||||
|
: fallbackFingerprint;
|
||||||
const isCashbackCommissionImport =
|
const isCashbackCommissionImport =
|
||||||
tx.amountSigned === 0 &&
|
tx.amountSigned === 0 &&
|
||||||
tx.commission > 0 &&
|
tx.commission > 0 &&
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
|
|
||||||
- `statement.currency` соответствует допустимому коду валюты (MVP: `"RUB"`).
|
- `statement.currency` соответствует допустимому коду валюты (MVP: `"RUB"`).
|
||||||
- `operationAt` у всех транзакций — валидная дата (парсится без ошибок).
|
- `operationAt` у всех транзакций — валидная дата (парсится без ошибок).
|
||||||
- Отсутствуют дубликаты fingerprint внутри одного файла.
|
- Повторяющиеся `sourceId` внутри одного файла отклоняются; одинаковые операции без `sourceId` различаются по позиции в массиве `transactions`.
|
||||||
|
|
||||||
Ответ при ошибке:
|
Ответ при ошибке:
|
||||||
|
|
||||||
@@ -120,10 +120,11 @@
|
|||||||
Для каждой транзакции вычисляется SHA-256 от полей, соединённых разделителем `|`:
|
Для каждой транзакции вычисляется SHA-256 от полей, соединённых разделителем `|`:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
accountNumber|operationAt|amountSigned|commission|normalizedDescription
|
accountNumber|operationAt|amountSigned|commission|normalizedDescription[|sourcePosition]
|
||||||
```
|
```
|
||||||
|
|
||||||
- `normalizedDescription` — `description` после `trim`.
|
- `normalizedDescription` — `description` после `trim`.
|
||||||
|
- `sourcePosition` — индекс операции в массиве `transactions`; добавляется, только если одинаковые операции без `sourceId` повторяются в одном файле.
|
||||||
- Суммы подставляются в том виде, в котором пришли в JSON (числовое представление).
|
- Суммы подставляются в том виде, в котором пришли в JSON (числовое представление).
|
||||||
- Разделитель `|` исключает коллизии при склейке полей разной длины.
|
- Разделитель `|` исключает коллизии при склейке полей разной длины.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user