Compare commits
4 Commits
feature/po
...
feature/tr
| Author | SHA1 | Date | |
|---|---|---|---|
| 9755332204 | |||
| 669e54f6cb | |||
| ab42caae37 | |||
| 4172b0c8e4 |
18
CHANGELOG.md
18
CHANGELOG.md
@@ -1,5 +1,23 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [Backend 0.9.2] - 2026-08-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended import integration coverage to verify source positions for same-date operations.
|
||||||
|
|
||||||
|
## [Backend 0.9.1] - 2026-08-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added runnable coverage for date and amount sorting tie-breakers.
|
||||||
|
|
||||||
|
## [Backend 0.9.0] - 2026-08-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Preserved source-array transaction positions and used them as stable tie-breakers for same-date history sorting.
|
||||||
|
|
||||||
## [Backend 0.8.3] - 2026-08-20
|
## [Backend 0.8.3] - 2026-08-20
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/backend",
|
"name": "@family-budget/backend",
|
||||||
"version": "0.8.3",
|
"version": "0.9.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/app.ts",
|
"dev": "tsx watch src/app.ts",
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"test:analytics": "tsx src/services/analyticsSemantics.test.ts",
|
"test:analytics": "tsx src/services/analyticsSemantics.test.ts",
|
||||||
"test:portfolio": "tsx src/services/portfolio.test.ts",
|
"test:portfolio": "tsx src/services/portfolio.test.ts",
|
||||||
"test:portfolio:db": "NODE_ENV=test tsx src/services/portfolio.integration.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: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:db": "NODE_ENV=test tsx src/services/import.integration.test.ts",
|
||||||
"test:llm": "tsx src/scripts/testLlm.ts"
|
"test:llm": "tsx src/scripts/testLlm.ts"
|
||||||
|
|||||||
@@ -287,6 +287,15 @@ const migrations: { name: string; sql: string }[] = [
|
|||||||
ON portfolio_trades(account_id, concluded_at DESC);
|
ON portfolio_trades(account_id, concluded_at DESC);
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: '009_transaction_source_position',
|
||||||
|
sql: `
|
||||||
|
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS source_position BIGINT NOT NULL DEFAULT 0;
|
||||||
|
UPDATE transactions SET source_position = id WHERE source_position = 0;
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_transactions_date_position
|
||||||
|
ON transactions(operation_at, source_position, id);
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function runMigrations(): Promise<void> {
|
export async function runMigrations(): Promise<void> {
|
||||||
|
|||||||
@@ -2,18 +2,20 @@ import assert from 'node:assert/strict';
|
|||||||
import { pool } from '../db/pool';
|
import { pool } from '../db/pool';
|
||||||
import { importStatement } from './import';
|
import { importStatement } from './import';
|
||||||
|
|
||||||
const makeStatement = (sourceId: string) => ({
|
const makeStatement = (sourceIds: string[]) => ({
|
||||||
schemaVersion: '1.0', bank: 'TEST',
|
schemaVersion: '1.0', bank: 'TEST',
|
||||||
statement: { accountNumber: 'metadata-test', currency: 'RUB', openingBalance: 0, closingBalance: 100, exportedAt: '2026-08-20T12:00:00+03:00' },
|
statement: { accountNumber: 'metadata-test', currency: 'RUB', openingBalance: 0, closingBalance: 100, exportedAt: '2026-08-20T12:00:00+03:00' },
|
||||||
transactions: [{ 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 })),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function run(): Promise<void> {
|
async function run(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await importStatement(makeStatement('first'));
|
await importStatement(makeStatement(['first', 'first-second']));
|
||||||
|
const positions = await pool.query("SELECT source_position FROM transactions t JOIN accounts a ON a.id = t.account_id WHERE a.bank = 'TEST' AND a.account_number = 'metadata-test' ORDER BY source_position");
|
||||||
|
assert.deepEqual(positions.rows.map((row) => Number(row.source_position)), [0, 1]);
|
||||||
const account = await pool.query("UPDATE accounts SET account_type = 'savings', status = 'closed' WHERE bank = 'TEST' AND account_number = 'metadata-test' RETURNING id");
|
const account = await pool.query("UPDATE accounts SET account_type = 'savings', status = 'closed' WHERE bank = 'TEST' AND account_number = 'metadata-test' RETURNING id");
|
||||||
assert.equal(account.rows.length, 1);
|
assert.equal(account.rows.length, 1);
|
||||||
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' });
|
||||||
console.log('import metadata SQL: OK');
|
console.log('import metadata SQL: OK');
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ export async function importStatement(
|
|||||||
// Insert transactions
|
// Insert transactions
|
||||||
const insertedIds: number[] = [];
|
const insertedIds: number[] = [];
|
||||||
|
|
||||||
for (const tx of data.transactions) {
|
for (const [sourcePosition, tx] of data.transactions.entries()) {
|
||||||
const fp = computeFingerprint(data.statement.accountNumber, tx);
|
const fp = computeFingerprint(data.statement.accountNumber, tx);
|
||||||
const isCashbackCommissionImport =
|
const isCashbackCommissionImport =
|
||||||
tx.amountSigned === 0 &&
|
tx.amountSigned === 0 &&
|
||||||
@@ -235,11 +235,11 @@ export async function importStatement(
|
|||||||
|
|
||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
`INSERT INTO transactions
|
`INSERT INTO transactions
|
||||||
(account_id, operation_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed, import_id)
|
(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)
|
VALUES ($1, COALESCE($2::uuid, gen_random_uuid()), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[accountId, tx.operationId ?? null, 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, sourcePosition],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.rows.length > 0) {
|
if (result.rows.length > 0) {
|
||||||
|
|||||||
13
backend/src/services/transactions.test.ts
Normal file
13
backend/src/services/transactions.test.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { transactionOrderBy } from './transactions';
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
transactionOrderBy('date', 'desc'),
|
||||||
|
't.operation_at DESC, t.source_position ASC, t.id ASC',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
transactionOrderBy('date', 'asc'),
|
||||||
|
't.operation_at ASC, t.source_position DESC, t.id DESC',
|
||||||
|
);
|
||||||
|
assert.match(transactionOrderBy('amount', 'desc'), /t\.amount_signed DESC.*t\.operation_at DESC.*t\.source_position ASC.*t\.id DESC/);
|
||||||
|
console.log('transaction ordering: OK');
|
||||||
@@ -7,13 +7,18 @@ import type {
|
|||||||
UpdateTransactionRequest,
|
UpdateTransactionRequest,
|
||||||
} from '@family-budget/shared';
|
} from '@family-budget/shared';
|
||||||
|
|
||||||
|
export function transactionOrderBy(sortBy: 'date' | 'amount', sortOrder: 'asc' | 'desc'): string {
|
||||||
|
const direction = sortOrder === 'asc' ? 'ASC' : 'DESC';
|
||||||
|
if (sortBy === 'amount') return `t.amount_signed ${direction}, t.operation_at DESC, t.source_position ASC, t.id DESC`;
|
||||||
|
return `t.operation_at ${direction}, t.source_position ${direction === 'ASC' ? 'DESC' : 'ASC'}, t.id ${direction === 'ASC' ? 'DESC' : 'ASC'}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTransactions(
|
export async function getTransactions(
|
||||||
params: GetTransactionsParams,
|
params: GetTransactionsParams,
|
||||||
): Promise<PaginatedResponse<Transaction>> {
|
): Promise<PaginatedResponse<Transaction>> {
|
||||||
const page = params.page ?? 1;
|
const page = params.page ?? 1;
|
||||||
const pageSize = [10, 50, 100].includes(params.pageSize ?? 50) ? (params.pageSize ?? 50) : 50;
|
const pageSize = [10, 50, 100].includes(params.pageSize ?? 50) ? (params.pageSize ?? 50) : 50;
|
||||||
const sortBy = params.sortBy === 'amount' ? 't.amount_signed' : 't.operation_at';
|
const orderBy = transactionOrderBy(params.sortBy === 'amount' ? 'amount' : 'date', params.sortOrder === 'asc' ? 'asc' : 'desc');
|
||||||
const sortOrder = params.sortOrder === 'asc' ? 'ASC' : 'DESC';
|
|
||||||
|
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
const values: unknown[] = [];
|
const values: unknown[] = [];
|
||||||
@@ -84,7 +89,7 @@ export async function getTransactions(
|
|||||||
JOIN accounts a ON a.id = t.account_id
|
JOIN accounts a ON a.id = t.account_id
|
||||||
LEFT JOIN categories c ON c.id = t.category_id
|
LEFT JOIN categories c ON c.id = t.category_id
|
||||||
${where}
|
${where}
|
||||||
ORDER BY ${sortBy} ${sortOrder}
|
ORDER BY ${orderBy}
|
||||||
LIMIT $${idx++} OFFSET $${idx++}`,
|
LIMIT $${idx++} OFFSET $${idx++}`,
|
||||||
[...values, pageSize, offset],
|
[...values, pageSize, offset],
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user