Compare commits
11 Commits
feature/an
...
fix/json-i
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f5681074e | |||
| 62519f80cd | |||
| 45f0561fd6 | |||
| fab929fd68 | |||
| c4ce2b9d6b | |||
| c4f681c9b0 | |||
| dd21e20dc6 | |||
| 29c4acd0a9 | |||
| 8939843462 | |||
| ce162855f9 | |||
| cce2ddcf41 |
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## [Backend 0.10.3] - 2026-08-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- Treat VTB savings-account deposits and closures as internal transfers when importing PDF-to-JSON statements.
|
||||
|
||||
## [Backend 0.10.2] - 2026-08-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- Analytics endpoints now reject invalid account and category filter IDs with a validation error.
|
||||
|
||||
## [Frontend 0.11.1] - 2026-08-21
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/backend",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/app.ts",
|
||||
@@ -9,11 +9,13 @@
|
||||
"migrate": "tsx src/db/migrate.ts",
|
||||
"migrate:prod": "node dist/db/migrate.js",
|
||||
"test:analytics": "tsx src/services/analyticsSemantics.test.ts",
|
||||
"test:analytics:query": "tsx src/routes/analytics.test.ts",
|
||||
"test:portfolio": "tsx src/services/portfolio.test.ts",
|
||||
"test:portfolio:db": "NODE_ENV=test tsx src/services/portfolio.integration.test.ts",
|
||||
"test:transactions": "tsx src/services/transactions.test.ts",
|
||||
"test:analytics:db": "NODE_ENV=test tsx src/services/analytics.integration.test.ts",
|
||||
"test:import:db": "NODE_ENV=test tsx src/services/import.integration.test.ts",
|
||||
"test:import:direction": "tsx src/services/import.test.ts",
|
||||
"test:llm": "tsx src/scripts/testLlm.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
12
backend/src/routes/analytics.test.ts
Normal file
12
backend/src/routes/analytics.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseOptionalId } from './analytics';
|
||||
|
||||
assert.equal(parseOptionalId(undefined, 0), undefined);
|
||||
assert.equal(parseOptionalId('0', 0), 0);
|
||||
assert.equal(parseOptionalId('1', 1), 1);
|
||||
assert.equal(parseOptionalId('0', 1), null);
|
||||
assert.equal(parseOptionalId('abc', 0), null);
|
||||
assert.equal(parseOptionalId('1.5', 0), null);
|
||||
assert.equal(parseOptionalId(['1'], 0), null);
|
||||
|
||||
console.log('analytics query validation: OK');
|
||||
@@ -5,6 +5,30 @@ import type { Granularity } from '@family-budget/shared';
|
||||
|
||||
const router = Router();
|
||||
|
||||
export function parseOptionalId(value: unknown, minimum: number): number | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== 'string' || !/^\d+$/.test(value)) return null;
|
||||
|
||||
const id = Number(value);
|
||||
return Number.isSafeInteger(id) && id >= minimum ? id : null;
|
||||
}
|
||||
|
||||
router.use((req, res, next) => {
|
||||
const accountId = parseOptionalId(req.query.accountId, 1);
|
||||
if (accountId === null) {
|
||||
res.status(400).json({ error: 'BAD_REQUEST', message: 'accountId must be a positive integer' });
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryId = parseOptionalId(req.query.categoryId, 0);
|
||||
if (categoryId === null) {
|
||||
res.status(400).json({ error: 'BAD_REQUEST', message: 'categoryId must be a non-negative integer' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/summary',
|
||||
asyncHandler(async (req, res) => {
|
||||
|
||||
@@ -8,6 +8,21 @@ const makeStatement = (sourceIds: string[]) => ({
|
||||
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: 'Пополнение' })),
|
||||
};
|
||||
const overlapStatement = {
|
||||
...duplicateStatement,
|
||||
statement: { ...duplicateStatement.statement, accountNumber: 'overlap-test' },
|
||||
};
|
||||
const singleStatement = {
|
||||
...overlapStatement,
|
||||
statement: { ...overlapStatement.statement, closingBalance: 100 },
|
||||
transactions: overlapStatement.transactions.slice(0, 1),
|
||||
};
|
||||
|
||||
async function run(): Promise<void> {
|
||||
try {
|
||||
await importStatement(makeStatement(['first', 'first-second']));
|
||||
@@ -18,11 +33,32 @@ async function run(): Promise<void> {
|
||||
await importStatement(makeStatement(['second']));
|
||||
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' });
|
||||
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,
|
||||
});
|
||||
await importStatement(singleStatement);
|
||||
const overlapResult = await importStatement(overlapStatement);
|
||||
if ('status' in overlapResult) throw new Error(overlapResult.message);
|
||||
assert.deepEqual(overlapResult, {
|
||||
accountId: overlapResult.accountId,
|
||||
isNewAccount: false,
|
||||
accountNumberMasked: 'overla******test',
|
||||
imported: 1,
|
||||
duplicatesSkipped: 1,
|
||||
totalInFile: 2,
|
||||
});
|
||||
console.log('import metadata SQL: OK');
|
||||
} 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 imports WHERE account_id IN (SELECT id FROM accounts WHERE bank = 'TEST' AND account_number = 'metadata-test')");
|
||||
await pool.query("DELETE 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', 'overlap-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', 'overlap-test'))");
|
||||
await pool.query("DELETE FROM accounts WHERE bank = 'TEST' AND account_number IN ('metadata-test', 'fingerprint-test', 'overlap-test')");
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
12
backend/src/services/import.test.ts
Normal file
12
backend/src/services/import.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { computeFingerprint, determineDirection } from './import';
|
||||
|
||||
assert.equal(determineDirection(1, 'Перечисление средств на счет N 123 со счета N 456'), 'transfer');
|
||||
assert.equal(determineDirection(1, 'Перечисление средств на вклад N 123'), 'transfer');
|
||||
assert.equal(determineDirection(-1, 'Перечисление суммы вклада при закрытии'), 'transfer');
|
||||
assert.equal(determineDirection(-1, 'Оплата покупки'), 'expense');
|
||||
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');
|
||||
@@ -6,14 +6,18 @@ import type { StatementFile, ImportStatementResponse } from '@family-budget/shar
|
||||
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(
|
||||
export function computeFingerprint(
|
||||
accountNumber: string,
|
||||
tx: { operationAt: string; amountSigned: number; commission: number; description: string; sourceId?: string },
|
||||
sourcePosition?: number,
|
||||
): string {
|
||||
if (tx.sourceId) {
|
||||
const raw = [accountNumber, tx.sourceId.trim()].join('|');
|
||||
@@ -26,12 +30,13 @@ function computeFingerprint(
|
||||
String(tx.amountSigned),
|
||||
String(tx.commission),
|
||||
tx.description.trim(),
|
||||
...(sourcePosition === undefined ? [] : [String(sourcePosition)]),
|
||||
].join('|');
|
||||
const hash = crypto.createHash('sha256').update(raw, 'utf-8').digest('hex');
|
||||
return `sha256:${hash}`;
|
||||
}
|
||||
|
||||
function determineDirection(amountSigned: number, description: string): string {
|
||||
export function determineDirection(amountSigned: number, description: string): string {
|
||||
const lower = description.toLowerCase();
|
||||
for (const phrase of TRANSFER_PHRASES) {
|
||||
if (lower.includes(phrase)) return 'transfer';
|
||||
@@ -136,7 +141,7 @@ function validateSemantics(data: StatementFile): ValidationError | null {
|
||||
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)) {
|
||||
if (data.transactions[i].sourceId && fps.has(fp)) {
|
||||
return { status: 422, error: 'VALIDATION_ERROR', message: `Duplicate fingerprint found within file at transaction index ${i}` };
|
||||
}
|
||||
fps.add(fp);
|
||||
@@ -218,9 +223,15 @@ export async function importStatement(
|
||||
|
||||
// Insert transactions
|
||||
const insertedIds: number[] = [];
|
||||
const fallbackFingerprintOccurrences = new Map<string, number>();
|
||||
|
||||
for (const [sourcePosition, tx] of data.transactions.entries()) {
|
||||
const fp = computeFingerprint(data.statement.accountNumber, tx);
|
||||
const fallbackFingerprint = computeFingerprint(data.statement.accountNumber, tx);
|
||||
const occurrence = fallbackFingerprintOccurrences.get(fallbackFingerprint) ?? 0;
|
||||
fallbackFingerprintOccurrences.set(fallbackFingerprint, occurrence + 1);
|
||||
const fp = !tx.sourceId && occurrence > 0
|
||||
? computeFingerprint(data.statement.accountNumber, tx, occurrence)
|
||||
: fallbackFingerprint;
|
||||
const isCashbackCommissionImport =
|
||||
tx.amountSigned === 0 &&
|
||||
tx.commission > 0 &&
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
|
||||
- `statement.currency` соответствует допустимому коду валюты (MVP: `"RUB"`).
|
||||
- `operationAt` у всех транзакций — валидная дата (парсится без ошибок).
|
||||
- Отсутствуют дубликаты fingerprint внутри одного файла.
|
||||
- Повторяющиеся `sourceId` внутри одного файла отклоняются; одинаковые операции без `sourceId` различаются по позиции в массиве `transactions`.
|
||||
|
||||
Ответ при ошибке:
|
||||
|
||||
@@ -120,10 +120,11 @@
|
||||
Для каждой транзакции вычисляется SHA-256 от полей, соединённых разделителем `|`:
|
||||
|
||||
```text
|
||||
accountNumber|operationAt|amountSigned|commission|normalizedDescription
|
||||
accountNumber|operationAt|amountSigned|commission|normalizedDescription[|sourcePosition]
|
||||
```
|
||||
|
||||
- `normalizedDescription` — `description` после `trim`.
|
||||
- `sourcePosition` — порядковый номер повторяющейся операции в массиве `transactions`; добавляется, только если одинаковые операции без `sourceId` повторяются в одном файле.
|
||||
- Суммы подставляются в том виде, в котором пришли в JSON (числовое представление).
|
||||
- Разделитель `|` исключает коллизии при склейке полей разной длины.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user