From f7e9a67d4297da1648a9b25cdde3f4b3ec2f16b5 Mon Sep 17 00:00:00 2001 From: Artyom Gorushkin Date: Thu, 4 Dec 2025 18:25:15 +0300 Subject: [PATCH 1/7] feat: 139 replace softDeleteByEntryIds with deleteByEntryIds in OperationRepository and update related tests (#145) * feat: 139 implement deleteByEntryIds method and corresponding tests in OperationRepository * feat: 139 replace softDeleteByEntryIds with deleteByEntryIds in OperationRepository and update related tests * refactor: update operation descriptions in deleteByEntryIds tests to reflect deletion instead of soft deletion --- .../OperationRepository.interface.ts | 1 + .../usecases/transaction/UpdateTransaction.ts | 3 +- .../__tests__/updateTransaction.test.ts | 8 ++--- apps/backend/src/db/schemas/transactions.ts | 2 +- .../operations/operation.repository.test.ts | 36 +++++++++++++++++++ .../db/operations/operation.repository.ts | 21 +++++++++++ .../db/transaction/transaction.repository.ts | 1 + 7 files changed, 65 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/application/interfaces/OperationRepository.interface.ts b/apps/backend/src/application/interfaces/OperationRepository.interface.ts index 47a1cddb..a45dbb89 100644 --- a/apps/backend/src/application/interfaces/OperationRepository.interface.ts +++ b/apps/backend/src/application/interfaces/OperationRepository.interface.ts @@ -8,4 +8,5 @@ export type OperationRepositoryInterface = { userId: UUID, entryIds: UUID[], ): Promise; + deleteByEntryIds(userId: UUID, entryIds: UUID[]): Promise; }; diff --git a/apps/backend/src/application/usecases/transaction/UpdateTransaction.ts b/apps/backend/src/application/usecases/transaction/UpdateTransaction.ts index 7c57e013..5482989f 100644 --- a/apps/backend/src/application/usecases/transaction/UpdateTransaction.ts +++ b/apps/backend/src/application/usecases/transaction/UpdateTransaction.ts @@ -54,6 +54,7 @@ export class UpdateTransactionUseCase { // For now, we will just delete all existing entries and create new ones // let's think about a better approach later // If entries are undefined, treat as 'no update to entries' + // we should compare with existing entries and update accordingly if (data.entries === undefined) { return this.transactionMapper.toResponseDTO( transaction, @@ -98,6 +99,6 @@ export class UpdateTransactionUseCase { return; } - await this.operationRepository.softDeleteByEntryIds(userId, entryIds); + await this.operationRepository.deleteByEntryIds(userId, entryIds); } } diff --git a/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts b/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts index 757912e7..c31478b5 100644 --- a/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts +++ b/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts @@ -58,7 +58,7 @@ describe('UpdateTransactionUseCase', () => { }; const mockOperationRepository = { - softDeleteByEntryIds: vi.fn(), + deleteByEntryIds: vi.fn(), }; const mockEnsureEntityExistsAndOwned = vi.fn(); @@ -294,11 +294,9 @@ describe('UpdateTransactionUseCase', () => { transactionDBRow.id, ); - expect(mockOperationRepository.softDeleteByEntryIds).toHaveBeenCalledTimes( - 1, - ); + expect(mockOperationRepository.deleteByEntryIds).toHaveBeenCalledTimes(1); - expect(mockOperationRepository.softDeleteByEntryIds).toHaveBeenCalledWith( + expect(mockOperationRepository.deleteByEntryIds).toHaveBeenCalledWith( user.getId().valueOf(), softDeletedEntries.map((e) => e.id), ); diff --git a/apps/backend/src/db/schemas/transactions.ts b/apps/backend/src/db/schemas/transactions.ts index ea6649fd..e78cd220 100644 --- a/apps/backend/src/db/schemas/transactions.ts +++ b/apps/backend/src/db/schemas/transactions.ts @@ -46,7 +46,7 @@ export type TransactionDbInsert = InferInsertModel; export type TransactionRepoInsert = TransactionDbInsert; export type TransactionDbUpdate = Partial< - Omit + Omit >; // Type for transaction with nested relations (operations as array) diff --git a/apps/backend/src/infrastructure/db/operations/operation.repository.test.ts b/apps/backend/src/infrastructure/db/operations/operation.repository.test.ts index 6a954b10..9805fe66 100644 --- a/apps/backend/src/infrastructure/db/operations/operation.repository.test.ts +++ b/apps/backend/src/infrastructure/db/operations/operation.repository.test.ts @@ -195,4 +195,40 @@ describe('OperationRepository', () => { expect(softDeletedOperations).toHaveLength(0); }); }); + + describe('deleteByEntryIds', () => { + it('should delete operations by entry IDs', async () => { + const operations = await Promise.all([ + testDB.createOperation(user.id, { + accountId: account1.id, + amount: Amount.create('700').valueOf(), + description: 'Operation to be deleted', + entryId: entry.id, + }), + + testDB.createOperation(user.id, { + accountId: account2.id, + amount: Amount.create('300').valueOf(), + description: 'Another operation to be deleted', + entryId: entry.id, + }), + ]); + + const fetchedOperationsBefore = await operationRepository.getByEntryId( + user.id, + entry.id, + ); + + expect(fetchedOperationsBefore).toHaveLength(operations.length); + + await operationRepository.deleteByEntryIds(user.id, [entry.id]); + + const fetchedOperationsAfter = await operationRepository.getByEntryId( + user.id, + entry.id, + ); + + expect(fetchedOperationsAfter).toHaveLength(0); + }); + }); }); diff --git a/apps/backend/src/infrastructure/db/operations/operation.repository.ts b/apps/backend/src/infrastructure/db/operations/operation.repository.ts index 7a4beb3b..d16d19e2 100644 --- a/apps/backend/src/infrastructure/db/operations/operation.repository.ts +++ b/apps/backend/src/infrastructure/db/operations/operation.repository.ts @@ -43,6 +43,27 @@ export class OperationRepository ); } + async deleteByEntryIds(userId: UUID, entryIds: UUID[]): Promise { + return this.executeDatabaseOperation( + async () => { + await this.db + .delete(operationsTable) + .where( + and( + inArray(operationsTable.entryId, entryIds), + eq(operationsTable.userId, userId), + ), + ); + }, + 'OperationRepository.deleteByEntryIds', + { + field: 'entryId', + tableName: 'operations', + value: entryIds.join(', '), + }, + ); + } + async softDeleteByEntryIds( userId: UUID, entryIds: UUID[], diff --git a/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts b/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts index c4c1cbcc..4636be91 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts @@ -123,6 +123,7 @@ export class TransactionRepository 'description', 'postingDate', 'transactionDate', + 'updatedAt', ]); const updated = await this.db From c55b16c5b2d17e484ae60af5303af5567e88be28 Mon Sep 17 00:00:00 2001 From: Artyom Gorushkin Date: Thu, 4 Dec 2025 21:29:00 +0300 Subject: [PATCH 2/7] feat: 140 replace softDeleteByTransactionId with deleteByTransactionId in EntryRepository and add corresponding tests --- .../interfaces/EntryRepository.interface.ts | 1 + .../db/entries/entry.repository.test.ts | 27 +++++++++++++++++++ .../db/entries/entry.repository.ts | 25 +++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/apps/backend/src/application/interfaces/EntryRepository.interface.ts b/apps/backend/src/application/interfaces/EntryRepository.interface.ts index c366e2f4..23e3c387 100644 --- a/apps/backend/src/application/interfaces/EntryRepository.interface.ts +++ b/apps/backend/src/application/interfaces/EntryRepository.interface.ts @@ -8,4 +8,5 @@ export type EntryRepositoryInterface = { userId: UUID, transactionId: UUID, ): Promise; + deleteByTransactionId(userId: UUID, transactionId: UUID): Promise; }; diff --git a/apps/backend/src/infrastructure/db/entries/entry.repository.test.ts b/apps/backend/src/infrastructure/db/entries/entry.repository.test.ts index bad537dd..b524e280 100644 --- a/apps/backend/src/infrastructure/db/entries/entry.repository.test.ts +++ b/apps/backend/src/infrastructure/db/entries/entry.repository.test.ts @@ -174,4 +174,31 @@ describe('EntryRepository', () => { expect(entries).toEqual([]); }); }); + + describe('deleteByTransactionId', () => { + it('should delete entries by transaction ID', async () => { + const createdEntries = await Promise.all([ + testDB.createEntry(user.id, { + transactionId: transaction.id, + }), + testDB.createEntry(user.id, { + transactionId: transaction.id, + }), + ]); + + await entryRepository.deleteByTransactionId(user.id, transaction.id); + + const entries = await entryRepository.getByTransactionId( + user.id, + transaction.id, + ); + + expect(entries).toHaveLength(0); + + for (const createdEntry of createdEntries) { + const deletedEntry = await testDB.getEntryById(createdEntry.id); + expect(deletedEntry).toBeNull(); + } + }); + }); }); diff --git a/apps/backend/src/infrastructure/db/entries/entry.repository.ts b/apps/backend/src/infrastructure/db/entries/entry.repository.ts index ac9b182a..5254fbce 100644 --- a/apps/backend/src/infrastructure/db/entries/entry.repository.ts +++ b/apps/backend/src/infrastructure/db/entries/entry.repository.ts @@ -61,4 +61,29 @@ export class EntryRepository }, ); } + + async deleteByTransactionId( + userId: UUID, + transactionId: UUID, + ): Promise { + return this.executeDatabaseOperation( + async () => { + await this.db + .delete(entriesTable) + .where( + and( + eq(entriesTable.transactionId, transactionId), + eq(entriesTable.userId, userId), + ), + ) + .run(); + }, + 'EntryRepository.deleteByTransactionId', + { + field: 'transactionId', + tableName: 'entries', + value: transactionId, + }, + ); + } } From a2fce225615bc78169126c19a5ba08b3fc099ee7 Mon Sep 17 00:00:00 2001 From: Artyom Gorushkin Date: Thu, 4 Dec 2025 21:38:00 +0300 Subject: [PATCH 3/7] feat: 140 replace softDeleteByTransactionId with deleteByTransactionId in EntryRepository and UpdateTransaction use case --- .../interfaces/EntryRepository.interface.ts | 5 ++++- .../usecases/transaction/UpdateTransaction.ts | 15 +++++++-------- .../__tests__/updateTransaction.test.ts | 6 +++--- .../infrastructure/db/entries/entry.repository.ts | 11 ++++++----- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/apps/backend/src/application/interfaces/EntryRepository.interface.ts b/apps/backend/src/application/interfaces/EntryRepository.interface.ts index 23e3c387..74f37825 100644 --- a/apps/backend/src/application/interfaces/EntryRepository.interface.ts +++ b/apps/backend/src/application/interfaces/EntryRepository.interface.ts @@ -8,5 +8,8 @@ export type EntryRepositoryInterface = { userId: UUID, transactionId: UUID, ): Promise; - deleteByTransactionId(userId: UUID, transactionId: UUID): Promise; + deleteByTransactionId( + userId: UUID, + transactionId: UUID, + ): Promise; }; diff --git a/apps/backend/src/application/usecases/transaction/UpdateTransaction.ts b/apps/backend/src/application/usecases/transaction/UpdateTransaction.ts index 5482989f..f50700ae 100644 --- a/apps/backend/src/application/usecases/transaction/UpdateTransaction.ts +++ b/apps/backend/src/application/usecases/transaction/UpdateTransaction.ts @@ -62,7 +62,7 @@ export class UpdateTransactionUseCase { ); } - await this.softDeleteEntriesByTransactionId( + await this.deleteEntriesByTransactionId( user.getId().valueOf(), transactionId, ); @@ -83,17 +83,16 @@ export class UpdateTransactionUseCase { }); } - private async softDeleteEntriesByTransactionId( + private async deleteEntriesByTransactionId( userId: UUID, transactionId: UUID, ): Promise { - const softDeletedEntries = - await this.entryRepository.softDeleteByTransactionId( - userId, - transactionId, - ); + const deletedEntries = await this.entryRepository.deleteByTransactionId( + userId, + transactionId, + ); - const entryIds = softDeletedEntries.map((entry) => entry.id); + const entryIds = deletedEntries.map((entry) => entry.id); if (entryIds.length === 0) { return; diff --git a/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts b/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts index c31478b5..655af924 100644 --- a/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts +++ b/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts @@ -54,7 +54,7 @@ describe('UpdateTransactionUseCase', () => { }; const mockEntryRepository = { - softDeleteByTransactionId: vi.fn(), + deleteByTransactionId: vi.fn(), }; const mockOperationRepository = { @@ -264,7 +264,7 @@ describe('UpdateTransactionUseCase', () => { const softDeletedEntries = entries.map((e) => e.toPersistence()); - mockEntryRepository.softDeleteByTransactionId.mockResolvedValue( + mockEntryRepository.deleteByTransactionId.mockResolvedValue( softDeletedEntries, ); @@ -289,7 +289,7 @@ describe('UpdateTransactionUseCase', () => { }), ); - expect(mockEntryRepository.softDeleteByTransactionId).toHaveBeenCalledWith( + expect(mockEntryRepository.deleteByTransactionId).toHaveBeenCalledWith( user.getId().valueOf(), transactionDBRow.id, ); diff --git a/apps/backend/src/infrastructure/db/entries/entry.repository.ts b/apps/backend/src/infrastructure/db/entries/entry.repository.ts index 5254fbce..0b02d588 100644 --- a/apps/backend/src/infrastructure/db/entries/entry.repository.ts +++ b/apps/backend/src/infrastructure/db/entries/entry.repository.ts @@ -65,10 +65,10 @@ export class EntryRepository async deleteByTransactionId( userId: UUID, transactionId: UUID, - ): Promise { - return this.executeDatabaseOperation( - async () => { - await this.db + ): Promise { + return this.executeDatabaseOperation( + () => { + return this.db .delete(entriesTable) .where( and( @@ -76,7 +76,8 @@ export class EntryRepository eq(entriesTable.userId, userId), ), ) - .run(); + .returning() + .all(); }, 'EntryRepository.deleteByTransactionId', { From d399945959e88b3d20c1773d1e9909465f8b4e68 Mon Sep 17 00:00:00 2001 From: Artyom Gorushkin Date: Sun, 7 Dec 2025 08:07:05 +0300 Subject: [PATCH 4/7] feat: update database connection logic and enhance test database cleanup; add transaction retrieval methods and amount formatting utilities --- apps/backend/src/db/index.ts | 22 +-- apps/backend/src/db/test-db.ts | 182 ++++++++++++++++-- .../domain-core/value-objects/Amount.ts | 8 + .../transaction.integration.test.ts | 108 ++++++++++- .../formatters/AmountFormatter.ts | 49 +++++ .../src/presentation/formatters/index.ts | 1 + restAPI/transactions/getById.bru | 2 +- 7 files changed, 330 insertions(+), 42 deletions(-) create mode 100644 apps/backend/src/presentation/formatters/AmountFormatter.ts create mode 100644 apps/backend/src/presentation/formatters/index.ts diff --git a/apps/backend/src/db/index.ts b/apps/backend/src/db/index.ts index 01282417..798da57b 100644 --- a/apps/backend/src/db/index.ts +++ b/apps/backend/src/db/index.ts @@ -8,27 +8,17 @@ import * as schemas from './schemas'; dotenv.config(); -const isTestEnvironment = - process.env.NODE_ENV === 'test' || process.env.VITEST === 'true'; - export type DataBase = ReturnType>; export type TxType = Parameters[0]>[0]; -let db: DataBase; - -if (isTestEnvironment) { - const client = createClient({ url: 'file::memory:' }); - db = drizzle(client, { schema: schemas }); -} else { - const dbUrl = config.dbUrl; +const dbUrl = config.dbUrl; - if (!dbUrl) { - throw new Error('Database URL is not defined'); - } - - const client = createClient({ url: dbUrl }); - db = drizzle(client, { schema: schemas }); +if (!dbUrl) { + throw new Error('Database URL is not defined'); } +const client = createClient({ url: dbUrl }); +const db = drizzle(client, { schema: schemas }); + export { db }; diff --git a/apps/backend/src/db/test-db.ts b/apps/backend/src/db/test-db.ts index 420d2ebb..09415d1b 100644 --- a/apps/backend/src/db/test-db.ts +++ b/apps/backend/src/db/test-db.ts @@ -18,6 +18,7 @@ import { migrate } from 'drizzle-orm/libsql/migrator'; import { DataBase } from 'src/db'; import { Amount, Currency, DateValue } from 'src/domain/domain-core'; import { PasswordManager } from 'src/infrastructure/auth/PasswordManager'; +import { AmountFormatter } from 'src/presentation/formatters'; import { EntryDbRow, @@ -27,6 +28,7 @@ import { accountsTable, usersTable, UserDbRow, + TransactionWithRelations, } from './schema'; import * as schema from './schemas'; @@ -62,6 +64,8 @@ export class TestDB { transactionCounter = new Counter('transaction'); operationCounter = new Counter('operation'); userCounter = new Counter('user'); + private testDbFile?: string; + private client?: ReturnType; constructor(db?: DataBase) { if (db) { @@ -69,11 +73,14 @@ export class TestDB { return; } - const client = createClient({ - url: 'file::memory:', + this.testDbFile = `file:/tmp/test-${Date.now()}-${Math.random()}.db`; + + this.client = createClient({ + url: this.testDbFile, + // url: 'file::memory:', }); - this.db = drizzle(client, { schema }); + this.db = drizzle(this.client, { schema }); } static get createTimestamps() { @@ -117,24 +124,23 @@ export class TestDB { } async cleanupTestDb() { - console.info('Cleaning up test database...'); - await this.db.run(sql`PRAGMA foreign_keys = OFF;`); - - const tables = [ - 'entries', - 'operations', - 'transactions', - 'settings', - 'accounts', - 'currencies', - 'users', - ]; - - for (const table of tables) { - await this.db.run(sql.raw(`DELETE FROM ${table};`)); + if (this.client) { + try { + this.client.close(); + } catch { + /* empty */ + } } - await this.db.run(sql`PRAGMA foreign_keys = ON;`); + if (this.testDbFile?.startsWith('file:/tmp/test-')) { + try { + const fs = await import('fs/promises'); + const filePath = this.testDbFile.replace('file:', ''); + await fs.unlink(filePath); + } catch { + /* empty */ + } + } } createUser = async (params?: { @@ -243,6 +249,106 @@ export class TestDB { return transaction; }; + getTransactionById = async ( + transactionId: UUID, + ): Promise => { + const transaction = await this.db + .select() + .from(schema.transactionsTable) + .where(sql`${schema.transactionsTable.id} = ${transactionId}`) + .get(); + + return transaction ?? null; + }; + + getTransactionWithRelations = async ( + transactionId: UUID, + ): Promise => { + const transaction = await this.getTransactionById(transactionId); + if (!transaction) return null; + + const entries = await this.db + .select() + .from(schema.entriesTable) + .where(sql`${schema.entriesTable.transactionId} = ${transactionId}`); + + const entriesWithOperations = await Promise.all( + entries.map(async (entry) => { + const operations = await this.db + .select() + .from(schema.operationsTable) + .where(sql`${schema.operationsTable.entryId} = ${entry.id}`); + + return { + ...entry, + operations, + }; + }), + ); + + return { entries: entriesWithOperations, ...transaction }; + }; + + getTransactionInPTAFormat = async ( + transactionId: UUID, + ): Promise => { + const transaction = await this.getTransactionById(transactionId); + if (!transaction) return null; + + const entries = await this.db + .select() + .from(schema.entriesTable) + .where(sql`${schema.entriesTable.transactionId} = ${transactionId}`); + + const entriesWithOperations = await Promise.all( + entries.map(async (entry) => { + const operations = await this.db + .select() + .from(schema.operationsTable) + .where(sql`${schema.operationsTable.entryId} = ${entry.id}`); + + const operationsWithAccounts = await Promise.all( + operations.map(async (operation) => { + const account = await this.db + .select() + .from(schema.accountsTable) + .where(sql`${schema.accountsTable.id} = ${operation.accountId}`) + .get(); + + return { ...operation, account }; + }), + ); + + return { + ...entry, + operations: operationsWithAccounts, + }; + }), + ); + + // Format in PTA (Plain Text Accounting) style + let output = `${transaction.transactionDate} ${transaction.description}\n`; + + for (const entry of entriesWithOperations) { + output += ` Entry ID: ${entry.id}\n`; + for (const operation of entry.operations) { + if (!operation.account) continue; + + const formatter = new AmountFormatter(); + + const amount = Amount.fromPersistence(operation.amount); + const userFriendlyAmount = formatter.formatForTable(amount, 'en-US'); + const accountName = operation.account.name; + const currency = operation.account.currency; + const systemMarker = operation.isSystem ? ' [system]' : ''; + + output += ` ${accountName.padEnd(40)} ${userFriendlyAmount} ${currency}${systemMarker}\n`; + } + } + + return output; + }; + softDeleteTransaction = async (transactionId: UUID) => { return await this.db .update(schema.transactionsTable) @@ -334,6 +440,16 @@ export class TestDB { return operations; }; + getOperationById = async (operationId: UUID) => { + const operation = await this.db + .select() + .from(schema.operationsTable) + .where(sql`${schema.operationsTable.id} = ${operationId}`) + .get(); + + return operation ?? null; + }; + deleteData = async () => { await this.db.delete(transactionsTable); await this.db.delete(accountsTable); @@ -430,4 +546,32 @@ export class TestDB { user, }; }; + + getDatabaseInfo = async () => { + const tables = await this.db.all<{ name: string }>( + sql`SELECT name + FROM sqlite_schema + WHERE type = 'table' AND name NOT LIKE 'sqlite_%';`, + ); + + const info: Record = {}; + + for (const table of tables) { + const countResult = await this.db + .select({ count: sql`count(*)` }) + .from(sql.raw(table.name)) + .get(); + + if (countResult && table.name) { + info[table.name] = Number(countResult.count); + } + } + console.info(info); + }; + + getAllOperations = async () => { + const operations = await this.db.select().from(schema.operationsTable); + // const operations = await this.db.select().from(schema.operationsTable); + return operations; + }; } diff --git a/apps/backend/src/domain/domain-core/value-objects/Amount.ts b/apps/backend/src/domain/domain-core/value-objects/Amount.ts index ee5fea56..a164bb23 100644 --- a/apps/backend/src/domain/domain-core/value-objects/Amount.ts +++ b/apps/backend/src/domain/domain-core/value-objects/Amount.ts @@ -56,4 +56,12 @@ export class Amount { isZero(): boolean { return this.minor === BigInt(0); } + + isPositive(): boolean { + return this.minor > BigInt(0); + } + + isNegative(): boolean { + return this.minor < BigInt(0); + } } diff --git a/apps/backend/src/interfaces/transactions/transaction.integration.test.ts b/apps/backend/src/interfaces/transactions/transaction.integration.test.ts index 35806285..c9ee3750 100644 --- a/apps/backend/src/interfaces/transactions/transaction.integration.test.ts +++ b/apps/backend/src/interfaces/transactions/transaction.integration.test.ts @@ -1,5 +1,6 @@ import { ROUTES } from '@ledgerly/shared/routes'; import { UUID } from '@ledgerly/shared/types'; +import { TransactionUpdateInput } from '@ledgerly/shared/validation'; import { TransactionResponseDTO } from 'src/application'; import { EntryDbRow, @@ -10,7 +11,7 @@ import { import { TestDB } from 'src/db/test-db'; import { Amount, Currency, DateValue, Id } from 'src/domain/domain-core'; import { createServer } from 'src/presentation/server'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; const testUser = { email: 'test@example.com', @@ -47,6 +48,10 @@ describe('Transactions Integration Tests', () => { userId = Id.fromPersistence(decoded.userId).valueOf(); }); + afterEach(async () => { + await testDB.cleanupTestDb(); + }); + describe('POST /api/transactions', () => { it('should create a new transaction', async () => { const account1 = await testDB.createAccount(userId, { @@ -583,18 +588,90 @@ describe('Transactions Integration Tests', () => { }); describe('PUT /api/transactions/:id', () => { - it('should update an existing transaction', async () => { + it('should update an existing transaction and all related entries and operations', async () => { + const accounts = await Promise.all([ + testDB.createAccount(userId, { + currency: Currency.create('USD').valueOf(), + name: 'Account 1 USD', + }), + testDB.createAccount(userId, { + currency: Currency.create('EUR').valueOf(), + name: 'Account 2 EUR', + }), + testDB.createAccount(userId, { + currency: Currency.create('USD').valueOf(), + name: 'Account 3 USD', + }), + testDB.createAccount(userId, { + currency: Currency.create('EUR').valueOf(), + name: 'Account 4 EUR', + }), + ]); + const transaction = await testDB.createTransaction(userId, { description: 'Initial description', postingDate: DateValue.restore('2025-11-01').valueOf(), transactionDate: DateValue.restore('2025-11-01').valueOf(), }); - const payload = { + const entries = await Promise.all([ + testDB.createEntry(userId, { + transactionId: transaction.id, + }), + testDB.createEntry(userId, { + transactionId: transaction.id, + }), + ]); + + const operations = await Promise.all([ + testDB.createOperation(userId, { + accountId: accounts[0].id, + amount: Amount.create('-10000').valueOf(), + description: 'Initial operation 1 USD', + entryId: entries[0].id, + isSystem: false, + }), + testDB.createOperation(userId, { + accountId: accounts[1].id, + amount: Amount.create('10000').valueOf(), + description: 'Initial operation 2 EUR', + entryId: entries[0].id, + isSystem: false, + }), + testDB.createOperation(userId, { + accountId: accounts[2].id, + amount: Amount.create('5000').valueOf(), + description: 'Initial operation 3 USD', + entryId: entries[1].id, + isSystem: false, + }), + testDB.createOperation(userId, { + accountId: accounts[3].id, + amount: Amount.create('-5000').valueOf(), + description: 'Initial operation 4 EUR', + entryId: entries[1].id, + isSystem: false, + }), + ]); + + const payload: TransactionUpdateInput = { description: 'Updated description', - entries: [], - postingDate: '2025-11-10', - transactionDate: '2025-11-10', + entries: [ + [ + { + accountId: Id.fromPersistence(accounts[0].id).valueOf(), + amount: Amount.create('-70000').valueOf(), + description: 'Updated operation 1', + }, + { + accountId: Id.fromPersistence(accounts[1].id).valueOf(), + amount: Amount.create('10000').valueOf(), + description: 'Updated operation 2', + }, + ], + ], + postingDate: DateValue.restore('2025-11-10').valueOf(), + transactionDate: DateValue.restore('2025-11-10').valueOf(), }; const response = await server.inject({ @@ -614,6 +691,25 @@ describe('Transactions Integration Tests', () => { expect(updatedTransaction.description).toBe(payload.description); expect(updatedTransaction.postingDate).toBe(payload.postingDate); expect(updatedTransaction.transactionDate).toBe(payload.transactionDate); + + operations.forEach((op) => { + void testDB.getOperationById(op.id).then((fetchedOp) => { + expect(fetchedOp).toBeNull(); + }); + }); + + expect(updatedTransaction.entries.length).toBe(payload.entries.length); + + updatedTransaction.entries.forEach((entry, index) => { + expect(entry.operations.length).toBe(payload.entries[index].length); + + entry.operations.forEach((op, opIndex) => { + const payloadOp = payload.entries[index][opIndex]; + expect(op.accountId).toBe(payloadOp.accountId); + expect(op.amount).toBe(payloadOp.amount); + expect(op.description).toBe(payloadOp.description); + }); + }); }); it('should return 404 when updating non-existent transaction', async () => { diff --git a/apps/backend/src/presentation/formatters/AmountFormatter.ts b/apps/backend/src/presentation/formatters/AmountFormatter.ts new file mode 100644 index 00000000..c0f91f23 --- /dev/null +++ b/apps/backend/src/presentation/formatters/AmountFormatter.ts @@ -0,0 +1,49 @@ +import { Amount } from '../../domain/domain-core/value-objects/Amount'; + +export class AmountFormatter { + format(amount: Amount, locale = 'en-US'): string { + const minorUnits = BigInt(amount.valueOf()); + const major = Number(minorUnits) / 100; + + return new Intl.NumberFormat(locale, { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + }).format(major); + } + + formatWithCurrency( + amount: Amount, + currencyCode: string, + locale = 'en-US', + ): string { + const minorUnits = BigInt(amount.valueOf()); + const major = Number(minorUnits) / 100; + + return new Intl.NumberFormat(locale, { + currency: currencyCode, + style: 'currency', + }).format(major); + } + + formatCompact(amount: Amount, locale = 'en-US'): string { + const minorUnits = BigInt(amount.valueOf()); + const major = Number(minorUnits) / 100; + + return new Intl.NumberFormat(locale, { + maximumFractionDigits: 1, + minimumFractionDigits: 0, + notation: 'compact', + }).format(major); + } + + formatForTable(amount: Amount, locale = 'en-US'): string { + const formatted = this.formatWithSign(amount, locale); + return formatted.padStart(15); + } + + formatWithSign(amount: Amount, locale = 'en-US'): string { + const formatted = this.format(amount, locale); + const sign = amount.isPositive() ? ' ' : ''; + return `${sign}${formatted}`; + } +} diff --git a/apps/backend/src/presentation/formatters/index.ts b/apps/backend/src/presentation/formatters/index.ts new file mode 100644 index 00000000..cec31da7 --- /dev/null +++ b/apps/backend/src/presentation/formatters/index.ts @@ -0,0 +1 @@ +export { AmountFormatter } from './AmountFormatter'; diff --git a/restAPI/transactions/getById.bru b/restAPI/transactions/getById.bru index b6ab4ed0..240c0484 100644 --- a/restAPI/transactions/getById.bru +++ b/restAPI/transactions/getById.bru @@ -5,7 +5,7 @@ meta { } get { - url: {{baseUrl}}/transactions + url: {{baseUrl}}/transactions/cd6ee47a-7d47-4ec7-824d-3f94b064d668 body: none auth: bearer } From 00bba4de2defe91c98f23e6b32e052ed69747fd0 Mon Sep 17 00:00:00 2001 From: Artyom Gorushkin Date: Sun, 7 Dec 2025 08:49:55 +0300 Subject: [PATCH 5/7] feat: 141 enhance AmountFormatter to support minor-to-major conversion and improve formatting; update transaction integration tests for better async handling --- apps/backend/src/db/test-db.ts | 9 ++-- .../transaction.integration.test.ts | 12 +++-- .../formatters/AmountFormatter.ts | 52 ++++++++++++++----- 3 files changed, 49 insertions(+), 24 deletions(-) diff --git a/apps/backend/src/db/test-db.ts b/apps/backend/src/db/test-db.ts index 09415d1b..7e42c5c3 100644 --- a/apps/backend/src/db/test-db.ts +++ b/apps/backend/src/db/test-db.ts @@ -75,9 +75,10 @@ export class TestDB { this.testDbFile = `file:/tmp/test-${Date.now()}-${Math.random()}.db`; + // To use an in-memory database for faster, ephemeral tests, set url to 'file::memory:'. + // The default below uses a file-based database, which persists data across test runs and can aid debugging. this.client = createClient({ url: this.testDbFile, - // url: 'file::memory:', }); this.db = drizzle(this.client, { schema }); @@ -342,7 +343,7 @@ export class TestDB { const currency = operation.account.currency; const systemMarker = operation.isSystem ? ' [system]' : ''; - output += ` ${accountName.padEnd(40)} ${userFriendlyAmount} ${currency}${systemMarker}\n`; + output += ` ${accountName.padEnd(40)} ${operation.description} ${userFriendlyAmount} ${currency}${systemMarker}\n`; } } @@ -570,8 +571,6 @@ export class TestDB { }; getAllOperations = async () => { - const operations = await this.db.select().from(schema.operationsTable); - // const operations = await this.db.select().from(schema.operationsTable); - return operations; + return this.db.select().from(schema.operationsTable); }; } diff --git a/apps/backend/src/interfaces/transactions/transaction.integration.test.ts b/apps/backend/src/interfaces/transactions/transaction.integration.test.ts index c9ee3750..42a6d316 100644 --- a/apps/backend/src/interfaces/transactions/transaction.integration.test.ts +++ b/apps/backend/src/interfaces/transactions/transaction.integration.test.ts @@ -692,11 +692,13 @@ describe('Transactions Integration Tests', () => { expect(updatedTransaction.postingDate).toBe(payload.postingDate); expect(updatedTransaction.transactionDate).toBe(payload.transactionDate); - operations.forEach((op) => { - void testDB.getOperationById(op.id).then((fetchedOp) => { - expect(fetchedOp).toBeNull(); - }); - }); + await Promise.all( + operations.map((op) => + testDB.getOperationById(op.id).then((fetchedOp) => { + expect(fetchedOp).toBeNull(); + }), + ), + ); expect(updatedTransaction.entries.length).toBe(payload.entries.length); diff --git a/apps/backend/src/presentation/formatters/AmountFormatter.ts b/apps/backend/src/presentation/formatters/AmountFormatter.ts index c0f91f23..76a3dd54 100644 --- a/apps/backend/src/presentation/formatters/AmountFormatter.ts +++ b/apps/backend/src/presentation/formatters/AmountFormatter.ts @@ -1,14 +1,36 @@ import { Amount } from '../../domain/domain-core/value-objects/Amount'; export class AmountFormatter { + private minorToMajor(minorUnits: string): { major: string; minor: string } { + const isNegative = minorUnits.startsWith('-'); + const absoluteValue = isNegative ? minorUnits.slice(1) : minorUnits; + + const paddedValue = absoluteValue.padStart(3, '0'); + + const majorPart = paddedValue.slice(0, -2) || '0'; + const minorPart = paddedValue.slice(-2); + + return { + major: isNegative ? `-${majorPart}` : majorPart, + minor: minorPart, + }; + } + format(amount: Amount, locale = 'en-US'): string { - const minorUnits = BigInt(amount.valueOf()); - const major = Number(minorUnits) / 100; + const minorUnits = amount.valueOf(); + const { major, minor } = this.minorToMajor(minorUnits); - return new Intl.NumberFormat(locale, { - maximumFractionDigits: 2, - minimumFractionDigits: 2, - }).format(major); + const formattedMajor = new Intl.NumberFormat(locale, { + maximumFractionDigits: 0, + minimumFractionDigits: 0, + }).format(Number(major)); + + const decimalSeparator = + new Intl.NumberFormat(locale) + .formatToParts(1.1) + .find((part) => part.type === 'decimal')?.value ?? '.'; + + return `${formattedMajor}${decimalSeparator}${minor}`; } formatWithCurrency( @@ -16,24 +38,26 @@ export class AmountFormatter { currencyCode: string, locale = 'en-US', ): string { - const minorUnits = BigInt(amount.valueOf()); - const major = Number(minorUnits) / 100; + const minorUnits = amount.valueOf(); + const { major, minor } = this.minorToMajor(minorUnits); - return new Intl.NumberFormat(locale, { + const formatted = new Intl.NumberFormat(locale, { currency: currencyCode, style: 'currency', - }).format(major); + }).format(Number(`${major}.${minor}`)); + + return formatted; } formatCompact(amount: Amount, locale = 'en-US'): string { - const minorUnits = BigInt(amount.valueOf()); - const major = Number(minorUnits) / 100; + const minorUnits = amount.valueOf(); + const { major, minor } = this.minorToMajor(minorUnits); return new Intl.NumberFormat(locale, { maximumFractionDigits: 1, minimumFractionDigits: 0, notation: 'compact', - }).format(major); + }).format(Number(`${major}.${minor}`)); } formatForTable(amount: Amount, locale = 'en-US'): string { @@ -43,7 +67,7 @@ export class AmountFormatter { formatWithSign(amount: Amount, locale = 'en-US'): string { const formatted = this.format(amount, locale); - const sign = amount.isPositive() ? ' ' : ''; + const sign = amount.isPositive() ? '+' : ''; return `${sign}${formatted}`; } } From 24cd095afc537b1b193cd87e223065377e0d6d84 Mon Sep 17 00:00:00 2001 From: Artyom Gorushkin Date: Sun, 7 Dec 2025 08:59:00 +0300 Subject: [PATCH 6/7] feat: refactor TestDB to use a constant FILE_PREFIX for test database file naming --- apps/backend/src/db/test-db.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/db/test-db.ts b/apps/backend/src/db/test-db.ts index 7e42c5c3..1eb4386b 100644 --- a/apps/backend/src/db/test-db.ts +++ b/apps/backend/src/db/test-db.ts @@ -35,6 +35,8 @@ import * as schema from './schemas'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +const FILE_PREFIX = 'file:/tmp/test-'; + class Counter { private count = 0; @@ -73,7 +75,7 @@ export class TestDB { return; } - this.testDbFile = `file:/tmp/test-${Date.now()}-${Math.random()}.db`; + this.testDbFile = `${FILE_PREFIX}${Date.now()}-${crypto.randomUUID()}.db`; // To use an in-memory database for faster, ephemeral tests, set url to 'file::memory:'. // The default below uses a file-based database, which persists data across test runs and can aid debugging. @@ -133,7 +135,7 @@ export class TestDB { } } - if (this.testDbFile?.startsWith('file:/tmp/test-')) { + if (this.testDbFile?.startsWith(FILE_PREFIX)) { try { const fs = await import('fs/promises'); const filePath = this.testDbFile.replace('file:', ''); From a8c36ea84e8337f81b21c4bdfb1bcceffb128f97 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Sun, 7 Dec 2025 09:26:54 +0300 Subject: [PATCH 7/7] feat: update AmountFormatter to use MoneyString type for minorToMajor method; enhance CI workflow with SQLite data directory creation --- .github/workflows/ci.yml | 3 +++ .../backend/src/presentation/formatters/AmountFormatter.ts | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 645448bd..ee69c45b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,9 @@ jobs: echo "Contents of .env file:" cat .env + - name: Create data directory for SQLite + run: mkdir -p apps/backend/data + - name: TypeScript type check (backend only) run: pnpm --filter backend run type-check diff --git a/apps/backend/src/presentation/formatters/AmountFormatter.ts b/apps/backend/src/presentation/formatters/AmountFormatter.ts index 76a3dd54..926e3a53 100644 --- a/apps/backend/src/presentation/formatters/AmountFormatter.ts +++ b/apps/backend/src/presentation/formatters/AmountFormatter.ts @@ -1,7 +1,12 @@ +import { MoneyString } from '@ledgerly/shared/types'; + import { Amount } from '../../domain/domain-core/value-objects/Amount'; export class AmountFormatter { - private minorToMajor(minorUnits: string): { major: string; minor: string } { + private minorToMajor(minorUnits: MoneyString): { + major: string; + minor: string; + } { const isNegative = minorUnits.startsWith('-'); const absoluteValue = isNegative ? minorUnits.slice(1) : minorUnits;