From d1be2d1a9256cabf1bb26464bdd340350441c442 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Sun, 7 Dec 2025 09:42:49 +0300 Subject: [PATCH 1/3] feat: 146 move description field from operations to entries; update related tests and database schema --- ...ed_red_wolf.sql => 0000_gifted_professor_monster.sql} | 1 + apps/backend/drizzle/meta/0000_snapshot.json | 9 ++++++++- apps/backend/drizzle/meta/_journal.json | 4 ++-- .../transaction/__tests__/getTransactionById.test.ts | 1 + apps/backend/src/db/createTestUser.ts | 2 +- apps/backend/src/db/schemas/entries.ts | 3 ++- apps/backend/src/db/test-db.ts | 6 ++++++ apps/backend/src/domain/entries/entry.entity.ts | 4 ++++ .../infrastructure/db/entries/entry.repository.test.ts | 1 + 9 files changed, 26 insertions(+), 5 deletions(-) rename apps/backend/drizzle/{0000_red_red_wolf.sql => 0000_gifted_professor_monster.sql} (99%) diff --git a/apps/backend/drizzle/0000_red_red_wolf.sql b/apps/backend/drizzle/0000_gifted_professor_monster.sql similarity index 99% rename from apps/backend/drizzle/0000_red_red_wolf.sql rename to apps/backend/drizzle/0000_gifted_professor_monster.sql index 447b49ba..c1574e3f 100644 --- a/apps/backend/drizzle/0000_red_red_wolf.sql +++ b/apps/backend/drizzle/0000_gifted_professor_monster.sql @@ -74,6 +74,7 @@ CREATE TABLE `settings` ( --> statement-breakpoint CREATE TABLE `entries` ( `created_at` text NOT NULL, + `description` text NOT NULL, `id` text PRIMARY KEY NOT NULL, `is_tombstone` integer NOT NULL, `transaction_id` text NOT NULL, diff --git a/apps/backend/drizzle/meta/0000_snapshot.json b/apps/backend/drizzle/meta/0000_snapshot.json index e8e0eaba..d2aea3a1 100644 --- a/apps/backend/drizzle/meta/0000_snapshot.json +++ b/apps/backend/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "ee45f3c0-58ae-4a7e-8165-c14751e8a2d7", + "id": "ea399c7d-e2ca-41c9-8660-10a765e99c05", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "accounts": { @@ -519,6 +519,13 @@ "notNull": true, "autoincrement": false }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, "id": { "name": "id", "type": "text", diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index c25ac3e0..5d104c45 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1764699481666, - "tag": "0000_red_red_wolf", + "when": 1765089228039, + "tag": "0000_gifted_professor_monster", "breakpoints": true } ] diff --git a/apps/backend/src/application/usecases/transaction/__tests__/getTransactionById.test.ts b/apps/backend/src/application/usecases/transaction/__tests__/getTransactionById.test.ts index aeb92586..3a0bb20d 100644 --- a/apps/backend/src/application/usecases/transaction/__tests__/getTransactionById.test.ts +++ b/apps/backend/src/application/usecases/transaction/__tests__/getTransactionById.test.ts @@ -53,6 +53,7 @@ describe('GetTransactionByIdUseCase', () => { const mockEntry1: EntryWithOperations = { createdAt: Timestamp.create().valueOf(), + description: 'Test Entry', id: entryId, isTombstone: false, operations: [mockOperation1, mockOperation2], diff --git a/apps/backend/src/db/createTestUser.ts b/apps/backend/src/db/createTestUser.ts index 13823489..9a0a50c1 100644 --- a/apps/backend/src/db/createTestUser.ts +++ b/apps/backend/src/db/createTestUser.ts @@ -72,7 +72,7 @@ export const createEntry = ( transaction: Transaction, operations: Operation[], ) => { - return Entry.create(user, transaction, operations); + return Entry.create(user, transaction, 'Test entry', operations); }; export const createOperation = ( diff --git a/apps/backend/src/db/schemas/entries.ts b/apps/backend/src/db/schemas/entries.ts index 29c7ee2c..132b0563 100644 --- a/apps/backend/src/db/schemas/entries.ts +++ b/apps/backend/src/db/schemas/entries.ts @@ -4,7 +4,7 @@ import { index, sqliteTable, text } from 'drizzle-orm/sqlite-core'; import { transactionsTable, usersTable } from '../schema'; -import { id, createdAt, updatedAt, isTombstone } from './common'; +import { id, createdAt, updatedAt, isTombstone, description } from './common'; import { operationsTable } from './operations'; import type { OperationDbRow } from './operations'; @@ -12,6 +12,7 @@ export const entriesTable = sqliteTable( 'entries', { createdAt, + description, id, isTombstone, transactionId: text('transaction_id') diff --git a/apps/backend/src/db/test-db.ts b/apps/backend/src/db/test-db.ts index 1eb4386b..4179838c 100644 --- a/apps/backend/src/db/test-db.ts +++ b/apps/backend/src/db/test-db.ts @@ -181,6 +181,7 @@ export class TestDB { params?: { transactionId?: UUID; date?: IsoDateString; + description?: string; }, ): Promise => { const transactionId = @@ -193,10 +194,15 @@ export class TestDB { }) ).id; + const description = + params?.description ?? + `Test Entry ${this.transactionCounter.getNextName()}`; + const entryData = { ...TestDB.uuid, ...TestDB.createTimestamps, date: params?.date ?? TestDB.isoDateString, + description, userId, ...params, isTombstone: false, diff --git a/apps/backend/src/domain/entries/entry.entity.ts b/apps/backend/src/domain/entries/entry.entity.ts index 10909b52..f997c6c9 100644 --- a/apps/backend/src/domain/entries/entry.entity.ts +++ b/apps/backend/src/domain/entries/entry.entity.ts @@ -31,6 +31,7 @@ export class Entry { private constructor( identity: EntityIdentity, timestamps: EntityTimestamps, + public description: string, softDelete: SoftDelete, ownership: ParentChildRelation, transactionRelation: ParentChildRelation, @@ -47,6 +48,7 @@ export class Entry { static create( user: User, transaction: Transaction, + description = '', operations?: Operation[], ): Entry { const identity = EntityIdentity.create(); @@ -65,6 +67,7 @@ export class Entry { const entry = new Entry( identity, timestamps, + description, softDelete, ownership, transactionRelation, @@ -117,6 +120,7 @@ export class Entry { toPersistence(): EntryDbRow { return { createdAt: this.getCreatedAt().valueOf(), + description: this.description, id: this.identity.getId().valueOf(), isTombstone: this.isDeleted(), transactionId: this.getTransactionId().valueOf(), 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 b524e280..1b07fa57 100644 --- a/apps/backend/src/infrastructure/db/entries/entry.repository.test.ts +++ b/apps/backend/src/infrastructure/db/entries/entry.repository.test.ts @@ -43,6 +43,7 @@ describe('EntryRepository', () => { const entryInput: EntryDbRow = { createdAt, + description: 'Test Entry', id, isTombstone: false, transactionId, From af97a17156d83c4aab6f86e13ba0b9269cbfa4a6 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Sun, 7 Dec 2025 17:11:04 +0300 Subject: [PATCH 2/3] feat: 146 move description field from operations to entries; update related DTOs, mappers, factories, and tests --- apps/backend/src/application/dto/entry.dto.ts | 9 +- .../src/application/mappers/entry.mapper.ts | 1 + .../mappers/transaction-view.mapper.ts | 3 + .../application/mappers/transaction.mapper.ts | 1 + .../services/__tests__/entry.factory.test.ts | 69 +++++++++--- .../__tests__/operation.factory.test.ts | 103 ++++++++++-------- .../src/application/services/entry.factory.ts | 10 +- .../application/services/operation.factory.ts | 10 +- .../__tests__/createTransaction.test.ts | 27 +++-- .../__tests__/updateTransaction.test.ts | 28 +++-- .../src/domain/entries/entry.entity.ts | 2 +- apps/backend/src/domain/entries/entry.test.ts | 40 +++---- .../transaction.controller.test.ts | 28 ++--- .../transaction.integration.test.ts | 55 ++++++---- .../shared/src/validation/transactions.ts | 11 +- 15 files changed, 239 insertions(+), 158 deletions(-) diff --git a/apps/backend/src/application/dto/entry.dto.ts b/apps/backend/src/application/dto/entry.dto.ts index fa52446b..0209062d 100644 --- a/apps/backend/src/application/dto/entry.dto.ts +++ b/apps/backend/src/application/dto/entry.dto.ts @@ -8,10 +8,10 @@ import { // Request DTOs for creation -export type CreateEntryRequestDTO = [ - CreateOperationRequestDTO, - CreateOperationRequestDTO, -]; +export type CreateEntryRequestDTO = { + operations: [CreateOperationRequestDTO, CreateOperationRequestDTO]; + description: string; +}; export type EntryOperationsResponseDTO = [ OperationResponseDTO, @@ -34,6 +34,7 @@ export type EntryResponseDTO = { updatedAt: IsoDatetimeString; operations: EntryOperationsResponseDTO; isTombstone: boolean; + description: string; userId: UUID; }; diff --git a/apps/backend/src/application/mappers/entry.mapper.ts b/apps/backend/src/application/mappers/entry.mapper.ts index 6e82587d..f810c514 100644 --- a/apps/backend/src/application/mappers/entry.mapper.ts +++ b/apps/backend/src/application/mappers/entry.mapper.ts @@ -11,6 +11,7 @@ export class EntryMapper { return { createdAt: entry.getCreatedAt().valueOf(), + description: entry.description, id: entry.getId().valueOf(), isTombstone: entry.isDeleted(), operations, diff --git a/apps/backend/src/application/mappers/transaction-view.mapper.ts b/apps/backend/src/application/mappers/transaction-view.mapper.ts index aacffc2c..a40e252c 100644 --- a/apps/backend/src/application/mappers/transaction-view.mapper.ts +++ b/apps/backend/src/application/mappers/transaction-view.mapper.ts @@ -12,6 +12,8 @@ import { TransactionResponseDTO, } from '../dto'; +// TODO: This mapper is similar to TransactionMapper but works with DB rows directly. +// Consider refactoring to reduce code duplication. export class TransactionViewMapper { static toView(transaction: TransactionWithRelations): TransactionResponseDTO { return { @@ -55,6 +57,7 @@ export class TransactionViewMapper { return { createdAt: entry.createdAt, + description: entry.description, id: entry.id, isTombstone: entry.isTombstone, operations, diff --git a/apps/backend/src/application/mappers/transaction.mapper.ts b/apps/backend/src/application/mappers/transaction.mapper.ts index 93bc8776..8281306f 100644 --- a/apps/backend/src/application/mappers/transaction.mapper.ts +++ b/apps/backend/src/application/mappers/transaction.mapper.ts @@ -64,6 +64,7 @@ export class TransactionMapper implements TransactionMapperInterface { return { createdAt: entryData.createdAt, + description: entryData.description, id: entryData.id, isTombstone: entryData.isTombstone, operations: [userOperations[0], userOperations[1]], diff --git a/apps/backend/src/application/services/__tests__/entry.factory.test.ts b/apps/backend/src/application/services/__tests__/entry.factory.test.ts index b6f8a3e1..4d0dc3ea 100644 --- a/apps/backend/src/application/services/__tests__/entry.factory.test.ts +++ b/apps/backend/src/application/services/__tests__/entry.factory.test.ts @@ -84,7 +84,7 @@ describe('EntryFactory', () => { AccountType.create('asset'), ); - mockEntry = Entry.create(user, transaction); + mockEntry = Entry.create(user, transaction, 'Mock Entry 1'); operationFrom = Operation.create( user, @@ -111,18 +111,21 @@ describe('EntryFactory', () => { const mockOperations = [operationFrom, operationTo]; const rawEntries: CreateEntryRequestDTO[] = [ - [ - { - accountId: account1.getId().valueOf(), - amount: Amount.create('100').valueOf(), - description: 'Operation 1', - }, - { - accountId: account2.getId().valueOf(), - amount: Amount.create('-100').valueOf(), - description: 'Operation 2', - }, - ], + { + description: mockEntry.description, + operations: [ + { + accountId: operationFrom.getAccountId().valueOf(), + amount: operationFrom.amount.valueOf(), + description: operationFrom.description, + }, + { + accountId: operationTo.getAccountId().valueOf(), + amount: operationTo.amount.valueOf(), + description: operationTo.description, + }, + ], + }, ]; mockAccountRepository.getByIds.mockResolvedValueOnce([ @@ -144,7 +147,45 @@ describe('EntryFactory', () => { rawEntries, ); - expect(result).toHaveLength(1); + expect(result).toHaveLength(rawEntries.length); + + const checkedEntries = new Set(rawEntries.map((e) => e.description)); + + result.forEach((entry) => { + const rawEntry = rawEntries.find((e) => { + return e.description === entry.description; + }); + + if (rawEntry) { + checkedEntries.delete(rawEntry.description); + expect(entry.description).toBe(rawEntry.description); + } + + expect(entry).toBeInstanceOf(Entry); + + const checkedOperations = new Set( + rawEntry?.operations.map((op) => op.accountId), + ); + + entry.getOperations().forEach((operation) => { + const rawOperation = rawEntry?.operations.find((op) => { + return op.accountId === operation.getAccountId().valueOf(); + }); + + if (rawOperation) { + checkedOperations.delete(rawOperation.accountId); + + expect(operation.getAccountId().valueOf()).toBe( + rawOperation.accountId, + ); + expect(operation.amount.valueOf()).toBe(rawOperation.amount); + expect(operation.description).toBe(rawOperation.description); + } + }); + expect(checkedOperations.size).toBe(0); + }); + + expect(checkedEntries.size).toBe(0); const entry = result[0]; diff --git a/apps/backend/src/application/services/__tests__/operation.factory.test.ts b/apps/backend/src/application/services/__tests__/operation.factory.test.ts index 181f1dd9..ec0d10d6 100644 --- a/apps/backend/src/application/services/__tests__/operation.factory.test.ts +++ b/apps/backend/src/application/services/__tests__/operation.factory.test.ts @@ -104,10 +104,10 @@ describe('OperationFactory', () => { description: 'Operation to', }; - const operationsRaw: CreateEntryRequestDTO = [ - operationFromDTO, - operationToDTO, - ]; + const entriesRaw: CreateEntryRequestDTO = { + description: 'Test Entry', + operations: [operationFromDTO, operationToDTO], + }; const mockedOperationFrom = Operation.create( user, @@ -142,20 +142,20 @@ describe('OperationFactory', () => { const operations = await operationFactory.createOperationsForEntry( user, entry, - operationsRaw, + entriesRaw, accountsByIdMap, currencySystemAccountsMap, ); expect(mockedSaveWithIdRetry).toHaveBeenCalledTimes( - Object.keys(operationsRaw).length, + Object.keys(entriesRaw).length, ); operations.forEach((operation, index) => { expect(operation).toBeInstanceOf(Operation); expect(operation.getId()).toBeDefined(); expect(operation.belongsToUser(user.getId())).toBe(true); - const expectedData = operationsRaw[index]; + const expectedData = entriesRaw.operations[index]; expect(operation.amount.valueOf()).toBe(expectedData.amount); expect(operation.description).toBe(expectedData.description); }); @@ -170,22 +170,25 @@ describe('OperationFactory', () => { expect(operation).toBeInstanceOf(Operation); }); - expect(operations).toHaveLength(Object.keys(operationsRaw).length); + expect(operations).toHaveLength(Object.keys(entriesRaw).length); }); it('should throw an error if account not found', async () => { - const operationsRaw: CreateEntryRequestDTO = [ - { - accountId: usdAccount1.getId().valueOf(), - amount: Amount.create('100').valueOf(), - description: 'Operation 1', - }, - { - accountId: usdAccount2.getId().valueOf(), - amount: Amount.create('100').valueOf(), - description: 'Operation 1', - }, - ]; + const entriesRaw: CreateEntryRequestDTO = { + description: 'Test Entry', + operations: [ + { + accountId: usdAccount1.getId().valueOf(), + amount: Amount.create('100').valueOf(), + description: 'Operation 1', + }, + { + accountId: usdAccount2.getId().valueOf(), + amount: Amount.create('100').valueOf(), + description: 'Operation 1', + }, + ], + }; // Create accountsMap with missing account const accountsMap = new Map([ @@ -202,12 +205,12 @@ describe('OperationFactory', () => { operationFactory.createOperationsForEntry( user, entry, - operationsRaw, + entriesRaw, accountsMap, currencySystemAccountsMap, ), ).rejects.toThrowError( - `Account not found in map: ${operationsRaw[0].accountId}`, + `Account not found in map: ${entriesRaw.operations[0].accountId}`, ); }); @@ -240,16 +243,19 @@ describe('OperationFactory', () => { }, }; - const operationsRaw: CreateEntryRequestDTO = [ - { - ...testData.from.operation, - amount: testData.from.amount.valueOf(), - }, - { - ...testData.to.operation, - amount: testData.to.amount.valueOf(), - }, - ]; + const entryOperationsInput: CreateEntryRequestDTO = { + description: 'Test Entry', + operations: [ + { + ...testData.from.operation, + amount: testData.from.amount.valueOf(), + }, + { + ...testData.to.operation, + amount: testData.to.amount.valueOf(), + }, + ], + }; // Create accountsMap for the new signature (only user accounts, system accounts will be fetched separately) const accountsMap = new Map([ @@ -303,7 +309,7 @@ describe('OperationFactory', () => { const operations = await operationFactory.createOperationsForEntry( user, entry, - operationsRaw, + entryOperationsInput, accountsMap, currencySystemAccountsMap, ); @@ -344,7 +350,7 @@ describe('OperationFactory', () => { expect(operation.currency.valueOf()).toBe(match.currency.valueOf()); }); - for (let i = 0; i < operationsRaw.length; i++) { + for (let i = 0; i < entryOperationsInput.operations.length; i++) { const resultOperation = operations[i]; const systemOperation = operations[i + 2]; const systemAmount = Amount.fromPersistence( @@ -358,18 +364,21 @@ describe('OperationFactory', () => { }); it('should throw an error if the balances of operations do not equal zero', async () => { - const operationsRaw: CreateEntryRequestDTO = [ - { - accountId: usdAccount1.getId().valueOf(), - amount: Amount.create('100').valueOf(), - description: 'Operation 1', - }, - { - accountId: usdAccount2.getId().valueOf(), - amount: Amount.create('100').valueOf(), - description: 'Operation 1', - }, - ]; + const entriesRaw: CreateEntryRequestDTO = { + description: 'Test Entry', + operations: [ + { + accountId: usdAccount1.getId().valueOf(), + amount: Amount.create('100').valueOf(), + description: 'Operation 1', + }, + { + accountId: usdAccount2.getId().valueOf(), + amount: Amount.create('100').valueOf(), + description: 'Operation 1', + }, + ], + }; // Create accountsMap for the new signature const accountsMap = new Map([ @@ -385,7 +394,7 @@ describe('OperationFactory', () => { operationFactory.createOperationsForEntry( user, entry, - operationsRaw, + entriesRaw, accountsMap, currencySystemAccountsMap, ), diff --git a/apps/backend/src/application/services/entry.factory.ts b/apps/backend/src/application/services/entry.factory.ts index da22a718..cbe86274 100644 --- a/apps/backend/src/application/services/entry.factory.ts +++ b/apps/backend/src/application/services/entry.factory.ts @@ -31,9 +31,10 @@ export class EntryFactory { const accountIds = new Set(); const currenciesSet = new Set(); - for (const [from, to] of entries) { - accountIds.add(from.accountId); - accountIds.add(to.accountId); + for (const entry of entries) { + for (const operation of entry.operations) { + accountIds.add(operation.accountId); + } } const accountRows = await this.accountRepository.getByIds( @@ -104,7 +105,8 @@ export class EntryFactory { accountsMap: Map, systemAccountsMap: Map, ): Promise { - const createEntry = () => Entry.create(user, transaction); + const createEntry = () => + Entry.create(user, transaction, entryData.description); const entry = await this.saveEntry(createEntry); diff --git a/apps/backend/src/application/services/operation.factory.ts b/apps/backend/src/application/services/operation.factory.ts index a9ca0f40..216e53c9 100644 --- a/apps/backend/src/application/services/operation.factory.ts +++ b/apps/backend/src/application/services/operation.factory.ts @@ -37,7 +37,7 @@ export class OperationFactory { private addTradingOperations( user: User, entry: Entry, - [fromOperationDTO, toOperationDTO]: CreateEntryRequestDTO, + entryData: CreateEntryRequestDTO, [fromCurrency, toCurrency]: [Currency, Currency], systemAccountsMap: Map, ): [TradingOperationDTO, TradingOperationDTO] | [] { @@ -45,6 +45,8 @@ export class OperationFactory { return []; } + const [fromOperationDTO, toOperationDTO] = entryData.operations; + const oppositeFromAmount = Amount.create(fromOperationDTO.amount).negate(); const oppositeToAmount = Amount.create(toOperationDTO.amount).negate(); @@ -102,10 +104,12 @@ export class OperationFactory { async createOperationsForEntry( user: User, entry: Entry, - [fromOperationDTO, toOperationDTO]: CreateEntryRequestDTO, + entryData: CreateEntryRequestDTO, accountsByIdMap: Map, currencySystemAccountsMap: Map, ) { + const [fromOperationDTO, toOperationDTO] = entryData.operations; + const fromAccount = this.getAccountFromMap( fromOperationDTO.accountId, accountsByIdMap, @@ -119,7 +123,7 @@ export class OperationFactory { const tradingOperationsDTO = this.addTradingOperations( user, entry, - [fromOperationDTO, toOperationDTO], + entryData, [fromAccount.currency, toAccount.currency], currencySystemAccountsMap, ); diff --git a/apps/backend/src/application/usecases/transaction/__tests__/createTransaction.test.ts b/apps/backend/src/application/usecases/transaction/__tests__/createTransaction.test.ts index d649dd39..e847c153 100644 --- a/apps/backend/src/application/usecases/transaction/__tests__/createTransaction.test.ts +++ b/apps/backend/src/application/usecases/transaction/__tests__/createTransaction.test.ts @@ -122,18 +122,21 @@ describe('CreateTransactionUseCase', () => { mockedSaveWithIdRetry.mockResolvedValue(transaction); const entries: CreateEntryRequestDTO[] = [ - [ - { - accountId: usdAccount.getId().valueOf(), - amount: Amount.create('100').valueOf(), - description: 'Operation 1', - }, - { - accountId: eurAccount.getId().valueOf(), - amount: Amount.create('100').valueOf(), - description: 'Operation 1', - }, - ], + { + description: 'Entry 1', + operations: [ + { + accountId: usdAccount.getId().valueOf(), + amount: Amount.create('100').valueOf(), + description: 'Operation 1', + }, + { + accountId: eurAccount.getId().valueOf(), + amount: Amount.create('100').valueOf(), + description: 'Operation 1', + }, + ], + }, ]; const data: CreateTransactionRequestDTO = { 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 655af924..e8660074 100644 --- a/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts +++ b/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts @@ -190,18 +190,21 @@ describe('UpdateTransactionUseCase', () => { const updateData: UpdateTransactionRequestDTO = { description: 'Updated Transaction Description with Entries', entries: [ - [ - { - accountId: newOperationFrom.getAccountId().valueOf(), - amount: newOperationFrom.amount.valueOf(), - description: newOperationFrom.description, - }, - { - accountId: newOperationTo.getAccountId().valueOf(), - amount: newOperationTo.amount.valueOf(), - description: newOperationTo.description, - }, - ], + { + description: newEntry.description, + operations: [ + { + accountId: newOperationFrom.getAccountId().valueOf(), + amount: newOperationFrom.amount.valueOf(), + description: newOperationFrom.description, + }, + { + accountId: newOperationTo.getAccountId().valueOf(), + amount: newOperationTo.amount.valueOf(), + description: newOperationTo.description, + }, + ], + }, ], postingDate: transactionDBRow.postingDate, transactionDate: transactionDBRow.transactionDate, @@ -238,6 +241,7 @@ describe('UpdateTransactionUseCase', () => { const entryResponseDTO: EntryResponseDTO = { createdAt: newEntry.getCreatedAt().valueOf(), + description: newEntry.description, id: newEntry.getId().valueOf(), isTombstone: newEntry.isDeleted(), operations, diff --git a/apps/backend/src/domain/entries/entry.entity.ts b/apps/backend/src/domain/entries/entry.entity.ts index f997c6c9..bbf2cb9e 100644 --- a/apps/backend/src/domain/entries/entry.entity.ts +++ b/apps/backend/src/domain/entries/entry.entity.ts @@ -48,7 +48,7 @@ export class Entry { static create( user: User, transaction: Transaction, - description = '', + description: string, operations?: Operation[], ): Entry { const identity = EntityIdentity.create(); diff --git a/apps/backend/src/domain/entries/entry.test.ts b/apps/backend/src/domain/entries/entry.test.ts index 8a95d1dd..5a35839a 100644 --- a/apps/backend/src/domain/entries/entry.test.ts +++ b/apps/backend/src/domain/entries/entry.test.ts @@ -45,7 +45,7 @@ describe('Entry Domain Entity', async () => { ); it('should create a valid entry successfully', () => { - const entry = Entry.create(user, transaction); + const entry = Entry.create(user, transaction, 'Test Entry'); expect(entry).toBeInstanceOf(Entry); expect(entry.belongsToTransaction(transaction.getId())).toBe(true); @@ -54,8 +54,8 @@ describe('Entry Domain Entity', async () => { }); it('should have a unique ID when created', () => { - const entry1 = Entry.create(user, transaction); - const entry2 = Entry.create(user, transaction); + const entry1 = Entry.create(user, transaction, 'Test Entry'); + const entry2 = Entry.create(user, transaction, 'Test Entry'); expect(entry1.getId()).toBeDefined(); expect(entry2.getId()).toBeDefined(); @@ -63,20 +63,20 @@ describe('Entry Domain Entity', async () => { }); it('should return correct transaction ID', () => { - const entry = Entry.create(user, transaction); + const entry = Entry.create(user, transaction, 'Test Entry'); expect(entry.getTransactionId().isEqualTo(transaction.getId())).toBe(true); }); it('should correctly identify ownership by user', () => { - const entry = Entry.create(user, transaction); + const entry = Entry.create(user, transaction, 'Test Entry'); expect(entry.belongsToUser(user.getId())).toBe(true); expect(entry.belongsToUser(anotherUser.getId())).toBe(false); }); it('should correctly identify relationship to transaction', () => { - const entry = Entry.create(user, transaction); + const entry = Entry.create(user, transaction, 'Test Entry'); const anotherTransaction = Transaction.create( user.getId(), @@ -90,13 +90,13 @@ describe('Entry Domain Entity', async () => { }); it('should not be deleted when created', () => { - const entry = Entry.create(user, transaction); + const entry = Entry.create(user, transaction, 'Test Entry'); expect(entry.isDeleted()).toBe(false); }); it('should be marked as deleted after markAsDeleted call', () => { - const entry = Entry.create(user, transaction); + const entry = Entry.create(user, transaction, 'Test Entry'); expect(entry.isDeleted()).toBe(false); @@ -106,7 +106,7 @@ describe('Entry Domain Entity', async () => { }); it('should remain deleted after multiple markAsDeleted calls', () => { - const entry = Entry.create(user, transaction); + const entry = Entry.create(user, transaction, 'Test Entry'); entry.markAsDeleted(); entry.markAsDeleted(); @@ -115,7 +115,7 @@ describe('Entry Domain Entity', async () => { }); it('should maintain transaction relationship after being marked as deleted', () => { - const entry = Entry.create(user, transaction); + const entry = Entry.create(user, transaction, 'Test Entry'); entry.markAsDeleted(); @@ -124,7 +124,7 @@ describe('Entry Domain Entity', async () => { }); it('should maintain user ownership after being marked as deleted', () => { - const entry = Entry.create(user, transaction); + const entry = Entry.create(user, transaction, 'Test Entry'); entry.markAsDeleted(); @@ -132,7 +132,7 @@ describe('Entry Domain Entity', async () => { }); it('should add operations properly', () => { - const entry = Entry.create(user, transaction); + const entry = Entry.create(user, transaction, 'Test Entry'); const fromUsdOperation = Operation.create( user, @@ -163,7 +163,7 @@ describe('Entry Domain Entity', async () => { }); it('should throw EmptyOperationsError when adding empty operations array', () => { - const entry = Entry.create(user, transaction); + const entry = Entry.create(user, transaction, 'Test Entry'); expect(() => entry.addOperations([])).toThrow(EmptyOperationsError); expect(() => entry.addOperations([])).toThrow( @@ -172,7 +172,7 @@ describe('Entry Domain Entity', async () => { }); it('should throw DeletedEntityOperationError when adding operations to deleted entry', () => { - const entry = Entry.create(user, transaction); + const entry = Entry.create(user, transaction, 'Test Entry'); entry.markAsDeleted(); const operation = Operation.create( @@ -192,8 +192,8 @@ describe('Entry Domain Entity', async () => { }); it('should throw OperationOwnershipError when operation does not belong to entry', () => { - const entry1 = Entry.create(user, transaction); - const entry2 = Entry.create(user, transaction); + const entry1 = Entry.create(user, transaction, 'Test Entry'); + const entry2 = Entry.create(user, transaction, 'Test Entry'); const operationForEntry2 = Operation.create( user, @@ -212,7 +212,7 @@ describe('Entry Domain Entity', async () => { }); it('should throw MissingOperationsError when validating entry without operations', () => { - const entry = Entry.create(user, transaction); + const entry = Entry.create(user, transaction, 'Test Entry'); expect(() => entry.validateBalance()).toThrow(MissingOperationsError); expect(() => entry.validateBalance()).toThrow( @@ -221,7 +221,7 @@ describe('Entry Domain Entity', async () => { }); it('should throw DeletedEntityOperationError when validating deleted entry', () => { - const entry = Entry.create(user, transaction); + const entry = Entry.create(user, transaction, 'Test Entry'); const operation1 = Operation.create( user, @@ -248,7 +248,7 @@ describe('Entry Domain Entity', async () => { }); it('should throw UnbalancedEntryError when operations do not balance', () => { - const entry = Entry.create(user, transaction); + const entry = Entry.create(user, transaction, 'Test Entry'); const operation1 = Operation.create( user, @@ -271,7 +271,7 @@ describe('Entry Domain Entity', async () => { }); it('should return a copy of operations array (immutability)', () => { - const entry = Entry.create(user, transaction); + const entry = Entry.create(user, transaction, 'Test Entry'); const operation = Operation.create( user, diff --git a/apps/backend/src/interfaces/transactions/transaction.controller.test.ts b/apps/backend/src/interfaces/transactions/transaction.controller.test.ts index 4771eb6a..e4fc263a 100644 --- a/apps/backend/src/interfaces/transactions/transaction.controller.test.ts +++ b/apps/backend/src/interfaces/transactions/transaction.controller.test.ts @@ -1,13 +1,14 @@ import { - CreateTransactionRequestDTO, - UpdateTransactionRequestDTO, -} from 'src/application'; + EntryCreateInput, + TransactionCreateInput, +} from '@ledgerly/shared/validation'; +import { UpdateTransactionRequestDTO } from 'src/application'; import { CreateTransactionUseCase } from 'src/application/usecases/transaction/CreateTransaction'; import { GetAllTransactionsUseCase } from 'src/application/usecases/transaction/GetAllTransactions'; import { GetTransactionByIdUseCase } from 'src/application/usecases/transaction/GetTransactionById'; import { UpdateTransactionUseCase } from 'src/application/usecases/transaction/UpdateTransaction'; import { User } from 'src/domain'; -import { Id } from 'src/domain/domain-core'; +import { Amount, DateValue, Id } from 'src/domain/domain-core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ZodError } from 'zod'; @@ -39,17 +40,19 @@ describe('TransactionController', () => { const operationFrom = { accountId: Id.create().valueOf(), - amount: '-100', + amount: Amount.create('-100').valueOf(), description: 'Test Operation From', }; const operationTo = { accountId: Id.create().valueOf(), - amount: '100', + amount: Amount.create('100').valueOf(), description: 'Test Operation To', }; - const entries = [[operationFrom, operationTo]]; + const entries: EntryCreateInput[] = [ + { description: 'Test Entry', operations: [operationFrom, operationTo] }, + ]; const transactionController = new TransactionController( mockCreateTransactionUseCase as unknown as CreateTransactionUseCase, @@ -64,17 +67,14 @@ describe('TransactionController', () => { describe('create', () => { it('should call CreateTransactionUseCase with correct parameters', async () => { - const requestBody = { + const requestBody: TransactionCreateInput = { description: 'Test Transaction', entries, - postingDate: '2024-01-01', - transactionDate: '2024-01-02', + postingDate: DateValue.restore('2024-01-01').valueOf(), + transactionDate: DateValue.restore('2024-01-02').valueOf(), }; - const result = await transactionController.create( - user, - requestBody as unknown as CreateTransactionRequestDTO, - ); + const result = await transactionController.create(user, requestBody); expect(mockCreateTransactionUseCase.execute).toHaveBeenCalledWith( user, diff --git a/apps/backend/src/interfaces/transactions/transaction.integration.test.ts b/apps/backend/src/interfaces/transactions/transaction.integration.test.ts index 42a6d316..d576f4f8 100644 --- a/apps/backend/src/interfaces/transactions/transaction.integration.test.ts +++ b/apps/backend/src/interfaces/transactions/transaction.integration.test.ts @@ -1,6 +1,9 @@ import { ROUTES } from '@ledgerly/shared/routes'; import { UUID } from '@ledgerly/shared/types'; -import { TransactionUpdateInput } from '@ledgerly/shared/validation'; +import { + TransactionCreateInput, + TransactionUpdateInput, +} from '@ledgerly/shared/validation'; import { TransactionResponseDTO } from 'src/application'; import { EntryDbRow, @@ -64,21 +67,26 @@ describe('Transactions Integration Tests', () => { const fromOperation = { accountId: account1.id, - amount: '-100', + amount: Amount.create('-100').valueOf(), description: 'Transfer from checking', }; const toOperation = { accountId: account2.id, - amount: '100', + amount: Amount.create('100').valueOf(), description: 'Transfer to savings', }; - const payload = { + const payload: TransactionCreateInput = { description: 'some transaction', - entries: [[fromOperation, toOperation]], - postingDate: '2025-11-07', - transactionDate: '2025-11-07', + entries: [ + { + description: 'Transfer between accounts', + operations: [fromOperation, toOperation], + }, + ], + postingDate: DateValue.restore('2025-11-07').valueOf(), + transactionDate: DateValue.restore('2025-11-07').valueOf(), }; const response = await server.inject({ @@ -657,18 +665,21 @@ describe('Transactions Integration Tests', () => { const payload: TransactionUpdateInput = { description: 'Updated description', 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', - }, - ], + { + description: 'Updated Entry 1', + operations: [ + { + 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(), @@ -703,10 +714,12 @@ describe('Transactions Integration Tests', () => { expect(updatedTransaction.entries.length).toBe(payload.entries.length); updatedTransaction.entries.forEach((entry, index) => { - expect(entry.operations.length).toBe(payload.entries[index].length); + expect(entry.operations.length).toBe( + payload.entries[index].operations.length, + ); entry.operations.forEach((op, opIndex) => { - const payloadOp = payload.entries[index][opIndex]; + const payloadOp = payload.entries[index].operations[opIndex]; expect(op.accountId).toBe(payloadOp.accountId); expect(op.amount).toBe(payloadOp.amount); expect(op.description).toBe(payloadOp.description); diff --git a/packages/shared/src/validation/transactions.ts b/packages/shared/src/validation/transactions.ts index 33708cbe..0374b9ee 100644 --- a/packages/shared/src/validation/transactions.ts +++ b/packages/shared/src/validation/transactions.ts @@ -14,10 +14,10 @@ export const operationCreateSchema = z.object({ description: requiredText, }); -export const entryCreateSchema = z.tuple([ - operationCreateSchema, - operationCreateSchema, -]); +export const entryCreateSchema = z.object({ + description: requiredText, + operations: z.tuple([operationCreateSchema, operationCreateSchema]), +}); export const transactionCreateSchema = z.object({ description: requiredText, @@ -28,8 +28,6 @@ export const transactionCreateSchema = z.object({ export const transactionUpdateSchema = z.object({ description: requiredText, - // Note: Including the `entries` field here allows for complete replacement of all entries during an update. - // When updating, old entries are soft-deleted and new ones are created based on the provided array. entries: z.array(entryCreateSchema), postingDate: isoDate, transactionDate: isoDate, @@ -49,3 +47,4 @@ export const transactionResponseSchema = z.object({ export type TransactionCreateInput = z.infer; export type TransactionUpdateInput = z.infer; export type TransactionResponse = z.infer; +export type EntryCreateInput = z.infer; From a4e7ef6fa98b8ecfd4e54a2535049f9d5da05341 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Sun, 7 Dec 2025 17:41:29 +0300 Subject: [PATCH 3/3] fix: update operation tests to use entriesRaw.operations for accurate length checks --- .../application/services/__tests__/operation.factory.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/application/services/__tests__/operation.factory.test.ts b/apps/backend/src/application/services/__tests__/operation.factory.test.ts index ec0d10d6..d2a83733 100644 --- a/apps/backend/src/application/services/__tests__/operation.factory.test.ts +++ b/apps/backend/src/application/services/__tests__/operation.factory.test.ts @@ -148,7 +148,7 @@ describe('OperationFactory', () => { ); expect(mockedSaveWithIdRetry).toHaveBeenCalledTimes( - Object.keys(entriesRaw).length, + entriesRaw.operations.length, ); operations.forEach((operation, index) => { @@ -170,7 +170,7 @@ describe('OperationFactory', () => { expect(operation).toBeInstanceOf(Operation); }); - expect(operations).toHaveLength(Object.keys(entriesRaw).length); + expect(operations).toHaveLength(entriesRaw.operations.length); }); it('should throw an error if account not found', async () => {