From 00243bce20d3f82362ff726bdaafd8593bf04be7 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Wed, 8 Apr 2026 21:37:27 +0300 Subject: [PATCH 1/5] refactor: LED-9 remove entry DTO and related operations, update transaction repository methods - Deleted entry DTO file and removed its exports from index. - Updated OperationRepositoryInterface to make snapshots optional in save method. - Renamed rootSave to save in TransactionRepositoryInterface and updated its implementation. - Refactored CreateTransaction and UpdateTransaction use cases to use the new save method. - Enhanced TestDB with createTransactionWithOperations and createOperation methods for better testing. - Updated transaction repository to handle soft deletes and save operations correctly. - Improved transaction repository tests to cover new functionality and ensure correct behavior. --- apps/backend/src/application/dto/entry.dto.ts | 45 -- apps/backend/src/application/dto/index.ts | 1 - .../OperationRepository.interface.ts | 2 +- .../TransactionRepository.interface.ts | 5 +- .../usecases/transaction/CreateTransaction.ts | 2 +- .../usecases/transaction/UpdateTransaction.ts | 2 +- .../__tests__/createTransaction.test.ts | 10 +- .../__tests__/updateTransaction.test.ts | 21 +- apps/backend/src/db/test-db.ts | 122 ++- .../src/db/test-utils/testEntityBuilder.ts | 28 + .../domain/transactions/transaction.entity.ts | 2 +- .../db/operations/operation.repository.ts | 10 +- .../transaction.repository.test.ts | 761 +++++++++--------- .../db/transaction/transaction.repository.ts | 285 +++---- 14 files changed, 656 insertions(+), 640 deletions(-) delete mode 100644 apps/backend/src/application/dto/entry.dto.ts diff --git a/apps/backend/src/application/dto/entry.dto.ts b/apps/backend/src/application/dto/entry.dto.ts deleted file mode 100644 index 72ae5ba8..00000000 --- a/apps/backend/src/application/dto/entry.dto.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { IsoDatetimeString, UUID } from '@ledgerly/shared/types'; - -import { - CreateOperationRequestDTO, - OperationResponseDTO, -} from './operation.dto'; - -// Request DTOs for creation - -export type CreateEntryRequestDTO = { - operations: [CreateOperationRequestDTO, CreateOperationRequestDTO]; - description: string; -}; - -export type EntryOperationsResponseDTO = [ - OperationResponseDTO, - OperationResponseDTO, -]; - -// Request DTOs for updating - -export type UpdateEntryRequestDTO = { - id: UUID; - operations?: [CreateOperationRequestDTO, CreateOperationRequestDTO]; - description: string; -}; - -// Response DTOs - -export type EntryResponseDTO = { - id: UUID; - transactionId: UUID; - createdAt: IsoDatetimeString; - updatedAt: IsoDatetimeString; - operations: EntryOperationsResponseDTO; - isTombstone: boolean; - description: string; - userId: UUID; -}; - -// Query DTOs - -export type GetEntriesQueryDTO = { - transactionId: UUID; -}; diff --git a/apps/backend/src/application/dto/index.ts b/apps/backend/src/application/dto/index.ts index af0df643..aa46f60f 100644 --- a/apps/backend/src/application/dto/index.ts +++ b/apps/backend/src/application/dto/index.ts @@ -1,5 +1,4 @@ export * from './transaction.dto'; -export * from './entry.dto'; export * from './operation.dto'; export * from './currency.dto'; export * from './user.dto'; diff --git a/apps/backend/src/application/interfaces/OperationRepository.interface.ts b/apps/backend/src/application/interfaces/OperationRepository.interface.ts index b4f33783..ce93e9c2 100644 --- a/apps/backend/src/application/interfaces/OperationRepository.interface.ts +++ b/apps/backend/src/application/interfaces/OperationRepository.interface.ts @@ -6,6 +6,6 @@ export type OperationRepositoryInterface = { save( userId: UUID, operations: OperationDbRow[], - snapshots: Map, + snapshots?: Map, ): Promise; }; diff --git a/apps/backend/src/application/interfaces/TransactionRepository.interface.ts b/apps/backend/src/application/interfaces/TransactionRepository.interface.ts index d85b7d98..495a1e9f 100644 --- a/apps/backend/src/application/interfaces/TransactionRepository.interface.ts +++ b/apps/backend/src/application/interfaces/TransactionRepository.interface.ts @@ -4,8 +4,9 @@ import { Transaction } from 'src/domain'; import { OperationRepositoryInterface } from './OperationRepository.interface'; export type TransactionRepositoryInterface = { - rootSave(userId: UUID, transaction: Transaction): Promise; + save(userId: UUID, transaction: Transaction): Promise; + create(userId: UUID, transaction: Transaction): Promise; getById(userId: UUID, transactionId: UUID): Promise; - delete(userId: UUID, transactionId: UUID): Promise; + softDelete(userId: UUID, transaction: Transaction): Promise; readonly operationsRepository: OperationRepositoryInterface; }; diff --git a/apps/backend/src/application/usecases/transaction/CreateTransaction.ts b/apps/backend/src/application/usecases/transaction/CreateTransaction.ts index e2f44f69..0edb2ce6 100644 --- a/apps/backend/src/application/usecases/transaction/CreateTransaction.ts +++ b/apps/backend/src/application/usecases/transaction/CreateTransaction.ts @@ -38,7 +38,7 @@ export class CreateTransactionUseCase { createTransactionProps, ); - await this.transactionRepository.rootSave( + await this.transactionRepository.create( user.getId().valueOf(), transaction, ); diff --git a/apps/backend/src/application/usecases/transaction/UpdateTransaction.ts b/apps/backend/src/application/usecases/transaction/UpdateTransaction.ts index e101f9e6..99e37245 100644 --- a/apps/backend/src/application/usecases/transaction/UpdateTransaction.ts +++ b/apps/backend/src/application/usecases/transaction/UpdateTransaction.ts @@ -63,7 +63,7 @@ export class UpdateTransactionUseCase { ); if (isUpdated) { - await this.transactionRepository.rootSave( + await this.transactionRepository.save( user.getId().valueOf(), transaction, ); 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 64ae8eec..cb861eb5 100644 --- a/apps/backend/src/application/usecases/transaction/__tests__/createTransaction.test.ts +++ b/apps/backend/src/application/usecases/transaction/__tests__/createTransaction.test.ts @@ -14,7 +14,7 @@ describe('CreateTransactionUseCase', () => { let user: User; const mockTransactionRepository = { - rootSave: vi.fn(), + create: vi.fn(), }; const transactionManager = { @@ -83,12 +83,12 @@ describe('CreateTransactionUseCase', () => { expect(transactionManager.run).toHaveBeenCalled(); - expect(mockTransactionRepository.rootSave).toHaveBeenCalled(); + expect(mockTransactionRepository.create).toHaveBeenCalled(); - const savedTransaction = mockTransactionRepository.rootSave.mock + const savedTransaction = mockTransactionRepository.create.mock .calls[0][1] as unknown as Transaction; - const savedUserId = mockTransactionRepository.rootSave.mock + const savedUserId = mockTransactionRepository.create.mock .calls[0][0] as unknown as User; expect(savedUserId).toBe(user.getId().valueOf()); @@ -143,7 +143,7 @@ describe('CreateTransactionUseCase', () => { it('should propagate error when rootSave fails', async () => { const dbError = new Error('Database error'); - mockTransactionRepository.rootSave.mockRejectedValue(dbError); + mockTransactionRepository.create.mockRejectedValue(dbError); const { transactionContext, transactionDTO } = TransactionBuilder.create( user, 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 ae8233d7..52faa5b9 100644 --- a/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts +++ b/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts @@ -34,15 +34,8 @@ describe('UpdateTransactionUseCase', () => { const mockTransactionRepository = { getById: vi.fn(), - getDB: vi.fn().mockReturnValue({ - transaction: (cb: (trx: unknown) => T): T => cb({}), - }), - rootSave: vi.fn(), - }; - - const mockEntriesService = { - updateEntriesWithOperations: vi.fn(), - }; + save: vi.fn(), + } satisfies Partial; const mockEnsureEntityExistsAndOwned = vi.fn(); @@ -133,14 +126,10 @@ describe('UpdateTransactionUseCase', () => { expect(updatedTransaction.postingDate).toBe(data.postingDate); expect(updatedTransaction.transactionDate).toBe(data.transactionDate); - expect(mockTransactionRepository.rootSave).toHaveBeenCalledWith( + expect(mockTransactionRepository.save).toHaveBeenCalledWith( user.getId().valueOf(), transaction, ); - - expect( - mockEntriesService.updateEntriesWithOperations, - ).not.toHaveBeenCalled(); }); it('should update transaction and its entries correctly', async () => { @@ -234,7 +223,7 @@ describe('UpdateTransactionUseCase', () => { ...data.operations.update, ]); - expect(mockTransactionRepository.rootSave).toHaveBeenCalledWith( + expect(mockTransactionRepository.save).toHaveBeenCalledWith( user.getId().valueOf(), transaction, ); @@ -280,7 +269,7 @@ describe('UpdateTransactionUseCase', () => { mockTransactionContextLoader.loadContext, ).toHaveBeenCalledExactlyOnceWith(user, []); - expect(mockTransactionRepository.rootSave).not.toHaveBeenCalled(); + expect(mockTransactionRepository.save).not.toHaveBeenCalled(); initialTransactionResponse.operations.forEach((opDTO) => { const updatedOpDTO = updatedTransaction.operations.find( diff --git a/apps/backend/src/db/test-db.ts b/apps/backend/src/db/test-db.ts index 2d0077ca..818ab96f 100644 --- a/apps/backend/src/db/test-db.ts +++ b/apps/backend/src/db/test-db.ts @@ -31,6 +31,8 @@ import { UserDbRow, TransactionWithRelations, AccountDbInsert, + OperationDbInsert, + OperationDbRow, } from './schema'; import * as schema from './schemas'; @@ -211,6 +213,78 @@ export class TestDB { return transaction; }; + createTransactionWithOperations = async ( + userId: UUID, + params?: { + description: string; + postingDate: IsoDateString; + transactionDate: IsoDateString; + currencyCode: CurrencyCode; + operations: { + accountId: UUID; + description: string; + transactionId: UUID; + amount: MoneyString; + value: MoneyString; + isSystem?: boolean; + id: UUID; + }[]; + }, + ): Promise => { + const transaction = await this.createTransaction(userId, params); + + const operations: OperationDbRow[] = []; + + for (const operationParams of params?.operations ?? []) { + const operation = await this.createOperation(userId, { + ...operationParams, + transactionId: transaction.id, + }); + + operations.push(operation); + } + + return { ...transaction, operations }; + }; + + createOperation = async ( + userId: UUID, + params?: { + accountId: UUID; + description: string; + transactionId: UUID; + id: UUID; + amount: MoneyString; + value: MoneyString; + isSystem?: boolean; + }, + ) => { + const operationData: OperationDbInsert = { + isSystem: params?.isSystem ?? false, + ...TestDB.uuid, + ...TestDB.createTimestamps, + amount: params?.amount ?? Amount.create('1000').valueOf(), + ...params, + accountId: params?.accountId ?? (crypto.randomUUID() as UUID), + description: + params?.description ?? + `Test Operation ${this.operationCounter.getNextName()}`, + id: params?.id ?? (crypto.randomUUID() as UUID), + isTombstone: false, + transactionId: params?.transactionId ?? (crypto.randomUUID() as UUID), + userId, + value: params?.value ?? Amount.create('1000').valueOf(), + }; + + const operation = await this.db + .insert(schema.operationsTable) + .values(operationData) + .returning() + .get(); + + return operation; + }; + getTransactionById = async ( transactionId: UUID, ): Promise => { @@ -344,43 +418,25 @@ export class TestDB { return operation; }; - insertTransaction = async (transactionData: TransactionSnapshot) => { + insertTransaction = async ( + transactionData: TransactionSnapshot, + ): Promise => { const transaction = await this.db .insert(transactionsTable) .values(transactionData) .returning() .get(); - return transaction; - }; - - createOperation = async ( - userId: UUID, - params: { - accountId: UUID; - description: string; - transactionId: UUID; - amount: MoneyString; - value: MoneyString; - isSystem?: boolean; - }, - ) => { - const operationData = { - isSystem: params.isSystem ?? false, - ...TestDB.uuid, - ...TestDB.createTimestamps, - ...params, - isTombstone: false, - userId, - }; - - const operation = await this.db - .insert(schema.operationsTable) - .values(operationData) - .returning() - .get(); + const operations = await Promise.all( + transactionData.operations.map((operation) => + this.insertOperation({ + ...operation, + transactionId: transaction.id, + }), + ), + ); - return operation; + return { ...transaction, operations }; }; getOperationsByAccountId = async (userId: UUID, accountId: UUID) => { @@ -452,6 +508,7 @@ export class TestDB { accountId: accountUSD1.id, amount: Amount.create('10000').valueOf(), description: 'Initial Deposit', + id: transaction1.id, // Using transaction ID as operation ID for simplicity transactionId: transaction1.id, value: Amount.create('10000').valueOf(), }); @@ -460,6 +517,7 @@ export class TestDB { accountId: accountUSD2.id, amount: Amount.create('-5000').valueOf(), description: 'Grocery Shopping', + id: transaction1.id, // Using transaction ID as operation ID for simplicity transactionId: transaction1.id, value: Amount.create('-5000').valueOf(), }); @@ -468,6 +526,7 @@ export class TestDB { accountId: accountEUR.id, amount: Amount.create('2000').valueOf(), description: 'Credit Card Payment', + id: transaction1.id, // Using transaction ID as operation ID for simplicity transactionId: transaction1.id, value: Amount.create('2000').valueOf(), }); @@ -476,6 +535,7 @@ export class TestDB { accountId: accountUSD1.id, amount: Amount.create('-2000').valueOf(), description: 'Utility Bill', + id: transaction1.id, // Using transaction ID as operation ID for simplicity transactionId: transaction1.id, value: Amount.create('-2000').valueOf(), }); @@ -484,6 +544,7 @@ export class TestDB { accountId: accountUSD1.id, amount: Amount.create('15000').valueOf(), description: 'Salary', + id: transaction2.id, // Using transaction ID as operation ID for simplicity transactionId: transaction2.id, value: Amount.create('15000').valueOf(), }); @@ -492,6 +553,7 @@ export class TestDB { accountId: accountUSD2.id, amount: Amount.create('-15000').valueOf(), description: 'Rent Payment', + id: transaction2.id, // Using transaction ID as operation ID for simplicity transactionId: transaction2.id, value: Amount.create('-15000').valueOf(), }); diff --git a/apps/backend/src/db/test-utils/testEntityBuilder.ts b/apps/backend/src/db/test-utils/testEntityBuilder.ts index afe54592..ef08b7a9 100644 --- a/apps/backend/src/db/test-utils/testEntityBuilder.ts +++ b/apps/backend/src/db/test-utils/testEntityBuilder.ts @@ -8,6 +8,8 @@ import { Account, AccountType, Operation, Transaction, User } from 'src/domain'; import { Amount, Currency, DateValue, Name } from 'src/domain/domain-core'; import { TransactionBuildContext } from 'src/domain/transactions/types'; +import { TransactionWithRelations } from '../schema'; + type OperationDataForTransaction = { accountKey: string; amount: string; @@ -41,6 +43,7 @@ export type TransactionBuilderResult = { }; operationsData: OperationDataForTransaction[]; operations: Operation[]; + transactionWithRelations: TransactionWithRelations; }; export class TransactionBuilder { @@ -264,6 +267,31 @@ export class TransactionBuilder { postingDate: this.postingDate, transactionDate: this.transactionDate, }, + transactionWithRelations: { + createdAt: this.transaction.getCreatedAt().valueOf(), + currency: this.transactionCurrency.valueOf(), + description: this.transaction.description, + id: this.transaction.getId().valueOf(), + isTombstone: this.transaction.isDeleted(), + operations: this.operations.map((operation) => ({ + accountId: operation.getAccountId().valueOf(), + amount: operation.amount.valueOf(), + createdAt: operation.getCreatedAt().valueOf(), + description: operation.description, + id: operation.getId().valueOf(), + isSystem: operation.isSystem, + isTombstone: operation.isDeleted(), + transactionId: operation.transactionId.valueOf(), + updatedAt: operation.getUpdatedAt().valueOf(), + userId: operation.getUserId().valueOf(), + value: operation.value.valueOf(), + })), + postingDate: this.transaction.getPostingDate().valueOf(), + transactionDate: this.transaction.getTransactionDate().valueOf(), + updatedAt: this.transaction.getUpdatedAt().valueOf(), + userId: this.transaction.getUserId().valueOf(), + version: this.transaction.version, + }, user: this.user, }; } diff --git a/apps/backend/src/domain/transactions/transaction.entity.ts b/apps/backend/src/domain/transactions/transaction.entity.ts index 52fb5bd9..9a148929 100644 --- a/apps/backend/src/domain/transactions/transaction.entity.ts +++ b/apps/backend/src/domain/transactions/transaction.entity.ts @@ -43,7 +43,7 @@ export class Transaction { private transactionDate: DateValue, public currency: Currency, public description: string, - private version = 0, + public version = 0, ) {} static create(userId: Id, dto: CreateTransactionProps): Transaction { diff --git a/apps/backend/src/infrastructure/db/operations/operation.repository.ts b/apps/backend/src/infrastructure/db/operations/operation.repository.ts index ed511d94..b5f39366 100644 --- a/apps/backend/src/infrastructure/db/operations/operation.repository.ts +++ b/apps/backend/src/infrastructure/db/operations/operation.repository.ts @@ -14,7 +14,7 @@ export class OperationRepository extends BaseRepository implements OperationRepositoryInterface { - insert( + private insert( userId: UUID, operations: OperationDbInsert[], ): Promise { @@ -40,7 +40,7 @@ export class OperationRepository ); } - update(userId: UUID, operations: OperationDbRow[]): Promise { + private update(userId: UUID, operations: OperationDbRow[]): Promise { return this.executeDatabaseOperation( async () => { for (const operation of operations) { @@ -64,7 +64,7 @@ export class OperationRepository ); } - softDelete(userId: UUID, operationIds: UUID[]): Promise { + private softDelete(userId: UUID, operationIds: UUID[]): Promise { return this.executeDatabaseOperation( async () => { await this.db @@ -89,7 +89,7 @@ export class OperationRepository async save( userId: UUID, operations: OperationDbRow[], - snapshots: Map, + snapshots?: Map, ): Promise { return this.executeDatabaseOperation( async () => { @@ -98,7 +98,7 @@ export class OperationRepository const operationsToDelete: UUID[] = []; operations.forEach((operation) => { - const matchedOperationSnapshot = snapshots.get(operation.id); + const matchedOperationSnapshot = snapshots?.get(operation.id); if (!matchedOperationSnapshot) { operationsToInsert.push(operation); diff --git a/apps/backend/src/infrastructure/db/transaction/transaction.repository.test.ts b/apps/backend/src/infrastructure/db/transaction/transaction.repository.test.ts index cc94e1d7..ddb2e405 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction.repository.test.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction.repository.test.ts @@ -1,383 +1,384 @@ -// import { UUID } from '@ledgerly/shared/types'; -// import { -// CreateEntryRequestDTO, -// EntryMapper, -// OperationMapper, -// } from 'src/application'; -// import { EntryDbInsert, UserDbRow } from 'src/db/schema'; -// import { TestDB } from 'src/db/test-db'; -// import { TransactionBuilder } from 'src/db/test-utils'; -// import { Account, Entry, Operation, Transaction, User } from 'src/domain'; -// import { Amount, DateValue } from 'src/domain/domain-core'; -// import { Id } from 'src/domain/domain-core/value-objects/Id'; -// import { EntrySnapshot } from 'src/domain/entries/types'; -// import { OperationSnapshot } from 'src/domain/operations/types'; -// import { TransactionBuildContext } from 'src/domain/transactions/types'; -// import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { describe, it } from 'vitest'; - -// import { -// OperationRepository, -// EntryRepository, -// TransactionManager, -// TransactionRepository, -// } from '../'; - -// describe('TransactionRepository', () => { -// let testDB: TestDB; -// let transactionRepository: TransactionRepository; -// let user: UserDbRow; -// let entryContext: TransactionBuildContext; -// let entry1: CreateEntryRequestDTO; -// let entry2: CreateEntryRequestDTO; -// let usdAccount: Account; -// let eurAccount: Account; - -// const postingDate = DateValue.create().valueOf(); -// const transactionDate = DateValue.create().valueOf(); -// const description = 'Test transaction'; - -// const mockEntriesRepository = { -// save: vi.fn(), -// }; - -// const mockOperationsRepository = { -// save: vi.fn(), -// }; - -// const transactionManager = { -// getCurrentTransaction: () => testDB.db, -// run: vi.fn((cb: () => unknown) => { -// return cb(); -// }), -// }; - -// beforeEach(async () => { -// testDB = new TestDB(); - -// await testDB.setupTestDb(); - -// user = await testDB.createUser(); - -// const transactionBuilder = TransactionBuilder.create( -// User.fromPersistence(user), -// ); - -// const data = transactionBuilder -// .withAccounts(['USD', 'EUR']) -// .withSystemAccounts() -// .build(); - -// usdAccount = data.getAccountByKey('USD'); -// eurAccount = data.getAccountByKey('EUR'); - -// const usdSystemAccount = data.getSystemAccountByCurrency('USD'); -// const eurSystemAccount = data.getSystemAccountByCurrency('EUR'); - -// await testDB.insertAccount(usdAccount.toPersistence()); -// await testDB.insertAccount(eurAccount.toPersistence()); -// await testDB.insertAccount(usdSystemAccount.toPersistence()); -// await testDB.insertAccount(eurSystemAccount.toPersistence()); - -// transactionRepository = new TransactionRepository( -// mockEntriesRepository as unknown as EntryRepository, -// mockOperationsRepository as unknown as OperationRepository, -// transactionManager as unknown as TransactionManager, -// ); - -// entryContext = data.transactionContext; - -// entry1 = { -// description: 'Sample Entry 1', -// operations: [ -// { -// accountId: usdAccount.getId().valueOf(), -// amount: Amount.create('-200').valueOf(), -// description: 'Credit operation 1', -// }, -// { -// accountId: eurAccount.getId().valueOf(), -// amount: Amount.create('100').valueOf(), -// description: 'Debit operation 2', -// }, -// ], -// }; - -// entry2 = { -// description: 'Sample Entry 2', -// operations: [ -// { -// accountId: usdAccount.getId().valueOf(), -// amount: Amount.create('-200').valueOf(), -// description: 'Credit operation 3', -// }, -// { -// accountId: usdAccount.getId().valueOf(), -// amount: Amount.create('200').valueOf(), -// description: 'Debit operation 4', -// }, -// ], -// }; -// }); - -// describe('create', () => { -// it('should create a transaction and trigger entriesRepository.save and operationsRepository.save', async () => { -// const userDomain = User.fromPersistence(user); - -// const transaction = Transaction.create( -// userDomain.getId(), -// { -// description, -// entries: [entry1, entry2], -// postingDate, -// transactionDate, -// }, -// entryContext, -// ); - -// const entriesInsert: EntryDbInsert[] = transaction -// .getEntries() -// .map((entry) => { -// return EntryMapper.toDBRow(entry); -// }); - -// const operationsToInsert = transaction -// .getEntries() -// .flatMap((entry) => -// entry -// .getOperations() -// .map((operation) => OperationMapper.toDBRow(operation)), -// ); - -// await transactionRepository.rootSave(user.id, transaction); - -// const row = await testDB.getTransactionWithRelations( -// transaction.getId().valueOf(), -// ); - -// expect(mockEntriesRepository.save).toHaveBeenCalledTimes(1); - -// expect(mockEntriesRepository.save).toHaveBeenCalledWith( -// user.id, -// entriesInsert, -// new Map(), -// ); - -// expect(mockOperationsRepository.save).toHaveBeenCalledTimes(1); -// expect(mockOperationsRepository.save).toHaveBeenCalledWith( -// user.id, -// operationsToInsert, -// new Map(), -// ); - -// expect(row).not.toBeNull(); -// expect(row?.description).toBe(description); -// expect(row?.postingDate).toBe(postingDate); -// expect(row?.transactionDate).toBe(transactionDate); -// }); -// }); - -// describe('getById', () => { -// it('should retrieve a transaction by ID', async () => { -// const transaction = await testDB.createTransaction(user.id); - -// const fetchedTransaction = await transactionRepository.getById( -// user.id, -// transaction.id, -// ); - -// expect(fetchedTransaction?.description).toEqual(transaction.description); -// expect(fetchedTransaction?.getPostingDate().valueOf()).toEqual( -// transaction.postingDate, -// ); -// expect(fetchedTransaction?.getTransactionDate().valueOf()).toEqual( -// transaction.transactionDate, -// ); - -// expect(fetchedTransaction?.getEntries().length).toBe(0); -// }); - -// it('should return null if transaction not found', async () => { -// const fetchedTransaction = await transactionRepository.getById( -// user.id, -// Id.create().valueOf(), -// ); - -// expect(fetchedTransaction).toBeNull(); -// }); -// }); - -// describe('Update transaction', () => { -// let transactionId: UUID; - -// let restoredTransaction: Transaction; - -// let addedEntries: Entry[]; -// let addedOperations: Operation[]; - -// let entriesSnapshots: Map; -// let operationsSnapshots: Map; - -// beforeEach(async () => { -// const userDomain = User.fromPersistence(user); - -// const transaction = Transaction.create( -// userDomain.getId(), -// { -// description, -// entries: [entry1], -// postingDate, -// transactionDate, -// }, -// entryContext, -// ); - -// const snapshot = transaction.toSnapshot(); - -// entriesSnapshots = new Map(); -// operationsSnapshots = new Map(); - -// snapshot?.entries.forEach((entrySnapshot) => { -// entriesSnapshots.set(entrySnapshot.id, entrySnapshot); - -// entrySnapshot.operations.forEach((operationSnapshot) => { -// operationsSnapshots.set(operationSnapshot.id, operationSnapshot); -// }); -// }); - -// addedEntries = transaction.getEntries(); - -// addedOperations = transaction -// .getEntries() -// .flatMap((entry) => entry.getOperations()); - -// const transactionDBRow = await testDB.insertTransaction( -// transaction.toSnapshot(), -// ); - -// transactionId = transactionDBRow.id; - -// const transactionWithRelations = -// await testDB.getTransactionWithRelations(transactionId); - -// if (!transactionWithRelations) { -// throw new Error('Failed to retrieve transaction with relations'); -// } - -// restoredTransaction = Transaction.restore({ -// createdAt: transactionWithRelations.createdAt, -// description: transactionWithRelations.description, -// entries: transactionWithRelations.entries, -// id: transactionWithRelations.id, -// isTombstone: transactionWithRelations.isTombstone, -// postingDate: transactionWithRelations.postingDate, -// transactionDate: transactionWithRelations.transactionDate, -// updatedAt: transactionWithRelations.updatedAt, -// userId: transactionWithRelations.userId, -// version: transactionWithRelations.version, -// }); -// }); - -// it('should update transaction description and dates and trigger entriesRepository.save and operationsRepository.save with empty arrays', async () => { -// const transactionDBRowBeforeUpdate = -// await testDB.getTransactionWithRelations(transactionId); - -// expect(transactionDBRowBeforeUpdate).not.toBeNull(); -// expect(transactionDBRowBeforeUpdate?.description).toBe(description); - -// restoredTransaction.applyUpdate({ -// entries: { -// create: [], -// delete: [], -// update: [], -// }, -// metadata: { -// description: 'Updated Description', -// postingDate: DateValue.restore('2024-01-01').valueOf(), -// transactionDate: DateValue.restore('2024-01-02').valueOf(), -// }, -// }); - -// await transactionRepository.rootSave(user.id, restoredTransaction); - -// const transactionDBRowAfterUpdate = -// await testDB.getTransactionWithRelations(transactionId); - -// expect(transactionDBRowAfterUpdate).not.toBeNull(); -// expect(transactionDBRowAfterUpdate?.description).toBe( -// 'Updated Description', -// ); -// expect(transactionDBRowAfterUpdate?.postingDate).toBe( -// DateValue.restore('2024-01-01').valueOf(), -// ); -// expect(transactionDBRowAfterUpdate?.transactionDate).toBe( -// DateValue.restore('2024-01-02').valueOf(), -// ); - -// expect(mockEntriesRepository.save).toHaveBeenCalledWith( -// user.id, -// [], -// new Map(), -// ); - -// expect(mockOperationsRepository.save).toHaveBeenCalledWith( -// user.id, -// [], -// new Map(), -// ); -// }); - -// it('Should trigger entriesRepository.save and operationsRepository.save with deletions', async () => { -// await Promise.all( -// addedEntries.map(async (entry) => { -// const entrySnapshot = entry.toSnapshot(); -// await testDB.insertEntry(entrySnapshot); - -// await Promise.all( -// entry.getOperations().map(async (operation) => { -// const operationSnapshot = operation.toSnapshot(); -// await testDB.insertOperation(operationSnapshot); -// }), -// ); -// }), -// ); - -// const entriesToDeleteId = addedEntries.map((entry) => -// entry.getId().valueOf(), -// ); - -// restoredTransaction.attachEntries(addedEntries); - -// restoredTransaction.applyUpdate( -// { -// entries: { -// create: [], -// delete: entriesToDeleteId, -// update: [], -// }, -// metadata: {}, -// }, -// entryContext, -// ); - -// await transactionRepository.rootSave(user.id, restoredTransaction); - -// expect(mockEntriesRepository.save).toHaveBeenCalledWith( -// user.id, -// addedEntries.map((entry) => EntryMapper.toDBRow(entry)), -// entriesSnapshots, -// ); - -// expect(mockOperationsRepository.save).toHaveBeenCalledWith( -// user.id, -// addedOperations.map((operation) => OperationMapper.toDBRow(operation)), -// operationsSnapshots, -// ); -// }); -// }); -// }); +import { UUID } from '@ledgerly/shared/types'; +import { OperationMapper } from 'src/application/mappers/operation.mapper'; +import { OperationDbRow, UserDbRow } from 'src/db/schema'; +import { TestDB } from 'src/db/test-db'; +import { + TransactionBuilder, + TransactionBuilderResult, +} from 'src/db/test-utils'; +import { Account, Operation, User } from 'src/domain'; +import { Amount, DateValue } from 'src/domain/domain-core'; +import { OperationSnapshot } from 'src/domain/operations/types'; +import { TransactionBuildContext } from 'src/domain/transactions/types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + OperationRepository, + TransactionManager, + TransactionRepository, +} from '../'; describe('TransactionRepository', () => { - it.todo('should have tests implemented'); + let testDB: TestDB; + let transactionRepository: TransactionRepository; + let user: UserDbRow; + let transactionContext: TransactionBuildContext; + let usdAccount: Account; + let eurAccount: Account; + let data: TransactionBuilderResult; + const description = 'Test transaction'; + + const mockOperationsRepository = { + save: vi.fn(), + }; + + const transactionManager = { + getCurrentTransaction: () => testDB.db, + run: vi.fn((cb: () => unknown) => { + return cb(); + }), + }; + + beforeEach(async () => { + testDB = new TestDB(); + + await testDB.setupTestDb(); + + user = await testDB.createUser(); + + const transactionBuilder = TransactionBuilder.create( + User.fromPersistence(user), + ); + + data = transactionBuilder + .withAccounts(['USD', 'EUR']) + .withOperations([ + { + accountKey: 'USD', + amount: '-200', + description: 'Credit operation 1', + }, + { + accountKey: 'USD', + amount: '200', + description: 'Debit operation 1', + }, + { + accountKey: 'USD', + amount: '-100', + description: 'Credit operation 2', + }, + { + accountKey: 'USD', + amount: '100', + description: 'Debit operation 2', + }, + ]) + .withDescription(description) + .attachOperations() + .build(); + + usdAccount = data.getAccountByKey('USD'); + eurAccount = data.getAccountByKey('EUR'); + + const usdSystemAccount = data.getSystemAccountByCurrency('USD'); + const eurSystemAccount = data.getSystemAccountByCurrency('EUR'); + + await testDB.insertAccount(usdAccount.toPersistence()); + await testDB.insertAccount(eurAccount.toPersistence()); + await testDB.insertAccount(usdSystemAccount.toPersistence()); + await testDB.insertAccount(eurSystemAccount.toPersistence()); + + transactionRepository = new TransactionRepository( + mockOperationsRepository as unknown as OperationRepository, + transactionManager as unknown as TransactionManager, + ); + + transactionContext = data.transactionContext; + }); + + describe('getTransactionSnapshot', () => { + it('should retrieve a transaction snapshot with operations', async () => { + const transaction = data.transaction; + + const operationsDataToInsert = transaction + .getOperations() + .map((operation) => { + return { + accountId: operation.accountRelation.getParentId().valueOf(), + amount: operation.amount.valueOf(), + description: operation.description, + id: operation.getId().valueOf(), + isSystem: operation.isSystem, + transactionId: operation.transactionId.valueOf(), + value: operation.value.valueOf(), + }; + }); + + const insertedTransaction = await testDB.createTransactionWithOperations( + user.id, + { + currencyCode: transaction.currency.valueOf(), + description: transaction.description, + operations: operationsDataToInsert, + postingDate: transaction.getPostingDate().valueOf(), + transactionDate: transaction.getTransactionDate().valueOf(), + }, + ); + + const snapshot = await transactionRepository.getTransactionSnapshot( + user.id, + insertedTransaction.id, + ); + + expect(snapshot).not.toBeNull(); + expect(snapshot?.description).toBe(transaction.description); + + expect(snapshot?.postingDate).toBe( + transaction.getPostingDate().valueOf(), + ); + expect(snapshot?.transactionDate).toBe( + transaction.getTransactionDate().valueOf(), + ); + + const retrievedOperations = snapshot?.operations ?? []; + + expect(retrievedOperations.length).toBe(operationsDataToInsert.length); + + retrievedOperations.forEach((operation) => { + const matchingOperation = insertedTransaction.operations.find( + (op) => op.id === operation.id, + ); + + expect(matchingOperation).not.toBeUndefined(); + expect(operation.description).toBe(matchingOperation?.description); + expect(operation.amount).toBe(matchingOperation?.amount); + expect(operation.isSystem).toBe(matchingOperation?.isSystem); + expect(operation.value).toBe(matchingOperation?.value); + }); + }); + }); + + describe('create', () => { + it('should create a new transaction', async () => { + const transaction = data.transaction; + + await transactionRepository.create(user.id, transaction); + + const createdTransaction = await testDB.getTransactionWithRelations( + transaction.getId().valueOf(), + ); + + expect(createdTransaction).not.toBeNull(); + + expect(createdTransaction?.description).toBe(transaction.description); + + expect(createdTransaction?.postingDate).toBe( + transaction.getPostingDate().valueOf(), + ); + + expect(createdTransaction?.transactionDate).toBe( + transaction.getTransactionDate().valueOf(), + ); + + const expectedSnapshots = transaction + .getOperations() + .map((op) => op.toSnapshot()); + + expect(mockOperationsRepository.save).toHaveBeenCalledWith( + user.id, + expectedSnapshots, + ); + }); + }); + + describe('save', () => { + it('should update transaction metadata and trigger save', async () => { + const transaction = data.transaction; + + const initDeletedOperations: Operation[] = []; + const initExistedOperations: Operation[] = []; + + transaction.getOperations().forEach((operation) => { + if (operation.isDeleted()) { + initDeletedOperations.push(operation); + } else { + initExistedOperations.push(operation); + } + }); + + const operations = transaction.getOperations(); + + const newOperationDescription = 'New operation description'; + const updateOperationDescription = 'Updated operation description'; + + const operationsToCreate = [ + { + account: usdAccount, + amount: Amount.create('30000'), + description: newOperationDescription, + value: Amount.create('30000'), + }, + { + account: usdAccount, + amount: Amount.create('-30000'), + description: newOperationDescription, + value: Amount.create('-30000'), + }, + ]; + + const operationsToUpdate = [ + { + account: usdAccount, + amount: Amount.create('-300'), + description: updateOperationDescription, + id: operations[0].getId(), + value: Amount.create('-300'), + }, + { + account: usdAccount, + amount: Amount.create('300'), + description: updateOperationDescription, + id: operations[1].getId(), + value: Amount.create('300'), + }, + ]; + + const operationsToDelete = [operations[2].getId(), operations[3].getId()]; + + const metadata = { + description: 'Updated description', + postingDate: DateValue.create().valueOf(), + transactionDate: DateValue.create().valueOf(), + }; + + await testDB.insertTransaction(transaction.toSnapshot()); + + transaction.applyUpdate( + { + metadata, + operations: { + create: operationsToCreate, + delete: operationsToDelete, + update: operationsToUpdate, + }, + }, + transactionContext, + ); + + const updatedSnapshot = transaction.toSnapshot(); + + vi.spyOn( + transactionRepository, + 'getTransactionSnapshot', + ).mockResolvedValue(data.transactionWithRelations); + + await transactionRepository.save(user.id, transaction); + + const updatedTransaction = await testDB.getTransactionWithRelations( + transaction.getId().valueOf(), + ); + + expect(updatedTransaction).not.toBeNull(); + + expect(updatedTransaction?.description).toBe(updatedSnapshot.description); + + expect(updatedTransaction?.postingDate).toBe(updatedSnapshot.postingDate); + + expect(updatedTransaction?.transactionDate).toBe( + updatedSnapshot.transactionDate, + ); + + const expectedOperationsSnapshots = new Map(); + + data.transactionWithRelations?.operations.forEach((operationSnapshot) => { + expectedOperationsSnapshots.set( + operationSnapshot.id, + operationSnapshot, + ); + }); + + const expectedOperations: OperationDbRow[] = []; + + transaction.getOperations().forEach((operation) => { + expectedOperations.push(OperationMapper.toDBRow(operation)); + }); + + expect(mockOperationsRepository.save).toHaveBeenCalledWith( + user.id, + expectedOperations, + expectedOperationsSnapshots, + ); + }); + + it('should not update isTombstone field when saving', async () => { + const transaction = data.transaction; + + const isDeleted = transaction.isDeleted(); + + await testDB.insertTransaction(transaction.toSnapshot()); + transaction.markAsDeleted(); + + await transactionRepository.save(user.id, transaction); + + const updatedTransaction = await testDB.getTransactionWithRelations( + transaction.getId().valueOf(), + ); + + expect(updatedTransaction).not.toBeNull(); + expect(updatedTransaction?.isTombstone).toBe(isDeleted); + }); + }); + + describe('softDelete', () => { + it('should soft delete the transaction and its operations', async () => { + const transaction = data.transaction; + + await testDB.insertTransaction(transaction.toSnapshot()); + + transaction.markAsDeleted(); + + await transactionRepository.softDelete(user.id, transaction); + + const deletedTransaction = await testDB.getTransactionWithRelations( + transaction.getId().valueOf(), + ); + + expect(deletedTransaction).not.toBeNull(); + expect(deletedTransaction?.isTombstone).toBe(true); + + expect(transaction.description).toBe(deletedTransaction?.description); + + expect(transaction.getPostingDate().valueOf()).toBe( + deletedTransaction?.postingDate, + ); + + expect(transaction.getTransactionDate().valueOf()).toBe( + deletedTransaction?.transactionDate, + ); + + expect(deletedTransaction?.currency).toBe(transaction.currency.valueOf()); + + const expectedOperations: OperationDbRow[] = []; + + transaction.getOperations().forEach((operation) => { + expectedOperations.push(OperationMapper.toDBRow(operation)); + }); + + const expectedOperationsSnapshots = new Map(); + + data.transactionWithRelations?.operations.forEach((operationSnapshot) => { + expectedOperationsSnapshots.set( + operationSnapshot.id, + operationSnapshot, + ); + }); + + expect(mockOperationsRepository.save).toHaveBeenCalledWith( + user.id, + expectedOperations, + expectedOperationsSnapshots, + ); + }); + }); }); diff --git a/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts b/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts index 9f53a666..dad038b4 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts @@ -1,15 +1,19 @@ import { UUID } from '@ledgerly/shared/types'; +import { and, eq } from 'drizzle-orm'; import { + OperationMapper, OperationRepositoryInterface, TransactionMapper, TransactionRepositoryInterface, } from 'src/application'; import { + OperationDbRow, TransactionDbRow, TransactionWithRelations, transactionsTable, } from 'src/db/schema'; import { Transaction } from 'src/domain'; +import { OperationSnapshot } from 'src/domain/operations/types'; import { BaseRepository } from '../BaseRepository'; @@ -23,8 +27,29 @@ export class TransactionRepository ) { super(transactionManager); } - delete(_userId: UUID, _transactionId: UUID): Promise { - throw new Error('Method not implemented.'); + + async softDelete(userId: UUID, transaction: Transaction): Promise { + return this.executeDatabaseOperation( + async () => { + await this.db + .update(transactionsTable) + .set({ isTombstone: true, ...this.updateTimestamp }) + .where( + and( + eq(transactionsTable.id, transaction.getId().valueOf()), + eq(transactionsTable.userId, userId), + ), + ); + + await this.saveOperations(transaction); + }, + 'TransactionRepository.softDelete', + { + field: 'transactionId', + tableName: 'transactions', + value: transaction.getId().valueOf(), + }, + ); } private insert( @@ -50,134 +75,107 @@ export class TransactionRepository ); } - private update( - _userId: UUID, - _transaction: Transaction, + private async update( + userId: UUID, + transaction: Transaction, ): Promise { - throw new Error('Method not implemented.'); - // return this.executeDatabaseOperation( - // async () => { - // const transactionData = TransactionMapper.toDBRow(transaction); + return this.executeDatabaseOperation( + async () => { + const transactionData = TransactionMapper.toDBRow(transaction); - // return this.db - // .update(transactionsTable) - // .set({ ...transactionData, userId }) - // .returning() - // .get(); - // }, - // 'TransactionRepository.update', - // { - // field: 'transactionId', - // tableName: 'transactions', - // value: transaction.getId().valueOf(), - // }, - // ); - } + const safeData = this.getSafeUpdate(transactionData, [ + 'description', + 'postingDate', + 'transactionDate', + 'updatedAt', + 'currency', + ]); - private save( - _userId: UUID, - _transaction: Transaction, - _snapshot: TransactionWithRelations | null, - ): Promise { - throw new Error('Method not implemented.'); - // return this.executeDatabaseOperation( - // async () => { - // if (snapshot) { - // return this.update(userId, transaction); - // } - - // return this.insert(userId, transaction); - // }, - // 'TransactionRepository.save', - // { - // field: 'transactionId', - // tableName: 'transactions', - // value: transaction.getId().valueOf(), - // }, - // ); + return this.db + .update(transactionsTable) + .set({ ...safeData, userId }) + .returning() + .get(); + }, + 'TransactionRepository.update', + { + field: 'transactionId', + tableName: 'transactions', + value: transaction.getId().valueOf(), + }, + ); } - rootSave(_userId: UUID, _transaction: Transaction): Promise { - throw new Error('Method not implemented.'); - // await this.executeDatabaseOperation( - // async () => { - // const entries: EntryDbRow[] = []; - // const operations: OperationDbRow[] = []; - - // const entriesSnapshots = new Map(); - // const operationsSnapshots = new Map(); - - // transaction.getEntries().forEach((entry) => { - // entries.push(EntryMapper.toDBRow(entry)); + private async saveOperations(transaction: Transaction): Promise { + const operations: OperationDbRow[] = []; - // entry.getOperations().forEach((operation) => { - // operations.push(OperationMapper.toDBRow(operation)); - // }); - // }); - - // const snapshot = await this.getTransactionSnapshot( - // transaction.getUserId().valueOf(), - // transaction.getId().valueOf(), - // ); + const operationsSnapshots = new Map(); - // snapshot?.entries.forEach((entrySnapshot) => { - // entriesSnapshots.set(entrySnapshot.id, entrySnapshot); + transaction.getOperations().forEach((operation) => { + operations.push(OperationMapper.toDBRow(operation)); + }); - // entrySnapshot.operations.forEach((operationSnapshot) => { - // operationsSnapshots.set(operationSnapshot.id, operationSnapshot); - // }); - // }); + const snapshot = await this.getTransactionSnapshot( + transaction.getUserId().valueOf(), + transaction.getId().valueOf(), + ); - // await this.save(userId, transaction, snapshot); + snapshot?.operations.forEach((operationSnapshot) => { + operationsSnapshots.set(operationSnapshot.id, operationSnapshot); + }); - // await this.entriesRepository.save(userId, entries, entriesSnapshots); + await this.operationsRepository.save( + transaction.getUserId().valueOf(), + operations, + operationsSnapshots, + ); + } - // await this.operationsRepository.save( - // userId, - // operations, - // operationsSnapshots, - // ); - // }, - // 'TransactionRepository.save', - // { - // field: 'transactionId', - // tableName: 'transactions', - // value: transaction.getId().valueOf(), - // }, - // ); + async save(userId: UUID, transaction: Transaction): Promise { + await this.executeDatabaseOperation( + async () => { + await this.update(userId, transaction); + await this.saveOperations(transaction); + }, + 'TransactionRepository.save', + { + field: 'transactionId', + tableName: 'transactions', + value: transaction.getId().valueOf(), + }, + ); } - getTransactionSnapshot( - _userId: UUID, - _transactionId: UUID, + async getTransactionSnapshot( + userId: UUID, + transactionId: UUID, ): Promise { - throw new Error('Method not implemented.'); - // return this.executeDatabaseOperation( - // async () => { - // const transactionDbRow: TransactionWithRelations | undefined = - // await this.db.query.transactionsTable.findFirst({ - // where: and( - // eq(transactionsTable.id, transactionId), - // eq(transactionsTable.userId, userId), - // ), - // with: { - // entries: { with: { operations: true } }, - // }, - // }); - - // if (!transactionDbRow) { - // return null; - // } - - // return transactionDbRow; - // }, - // 'TransactionRepository.getTransactionSnapshot', - // { - // field: 'transactionId', - // tableName: 'transactions', - // value: transactionId, - // }, - // ); + return this.executeDatabaseOperation( + async () => { + const transactionDbRow: TransactionWithRelations | undefined = + await this.db.query.transactionsTable.findFirst({ + where: and( + eq(transactionsTable.id, transactionId), + eq(transactionsTable.userId, userId), + ), + with: { + operations: true, + }, + }); + + if (!transactionDbRow) { + return null; + } + + return transactionDbRow; + }, + 'TransactionRepository.getTransactionSnapshot', + { + field: 'transactionId', + tableName: 'transactions', + value: transactionId, + }, + ); } getById(_userId: UUID, _transactionId: UUID): Promise { @@ -212,40 +210,23 @@ export class TransactionRepository // ); } - // update(_transaction: Transaction): Promise { - // throw new Error('Method not implemented.'); - // return this.executeDatabaseOperation( - // async () => { - // const safeData = this.getSafeUpdate(transaction, [ - // 'description', - // 'postingDate', - // 'transactionDate', - // 'updatedAt', - // ]); - - // const updated = await this.db - // .update(transactionsTable) - // .set(safeData) - // .where( - // and( - // eq(transactionsTable.id, transactionId), - // eq(transactionsTable.userId, userId), - // ), - // ) - // .returning() - // .get(); - - // return this.ensureEntityExists( - // updated, - // `Transaction with ID ${transactionId} not found`, - // ); - // }, - // 'TransactionRepository.update', - // { - // field: 'transactionId', - // tableName: 'transactions', - // value: transactionId, - // }, - // ); - // } + async create(userId: UUID, transaction: Transaction): Promise { + return await this.executeDatabaseOperation( + async () => { + const operationsDataToInsert = transaction + .getOperations() + .map((operation) => OperationMapper.toDBRow(operation)); + + await this.operationsRepository.save(userId, operationsDataToInsert); + + await this.insert(userId, transaction); + }, + 'TransactionRepository.create', + { + field: 'transactionId', + tableName: 'transactions', + value: transaction.getId().valueOf(), + }, + ); + } } From 22b13e221b32f11bd5807bf6d745de581bcee69f Mon Sep 17 00:00:00 2001 From: gorushkin Date: Wed, 8 Apr 2026 22:15:29 +0300 Subject: [PATCH 2/5] refactor: LED-9 update transaction repository methods and tests to use 'update' instead of 'save', enhance transaction retrieval with operations --- .../TransactionRepository.interface.ts | 7 +- .../usecases/transaction/UpdateTransaction.ts | 2 +- .../__tests__/updateTransaction.test.ts | 8 +- .../domain/transactions/transaction.entity.ts | 4 +- apps/backend/src/domain/transactions/types.ts | 2 +- .../transaction.repository.test.ts | 70 +++++++- .../db/transaction/transaction.repository.ts | 158 ++++++++---------- 7 files changed, 153 insertions(+), 98 deletions(-) diff --git a/apps/backend/src/application/interfaces/TransactionRepository.interface.ts b/apps/backend/src/application/interfaces/TransactionRepository.interface.ts index 495a1e9f..f2b8d0bb 100644 --- a/apps/backend/src/application/interfaces/TransactionRepository.interface.ts +++ b/apps/backend/src/application/interfaces/TransactionRepository.interface.ts @@ -1,12 +1,17 @@ import { UUID } from '@ledgerly/shared/types'; +import { TransactionWithRelations } from 'src/db/schema'; import { Transaction } from 'src/domain'; import { OperationRepositoryInterface } from './OperationRepository.interface'; export type TransactionRepositoryInterface = { - save(userId: UUID, transaction: Transaction): Promise; + update(userId: UUID, transaction: Transaction): Promise; create(userId: UUID, transaction: Transaction): Promise; getById(userId: UUID, transactionId: UUID): Promise; softDelete(userId: UUID, transaction: Transaction): Promise; + getTransactionSnapshot( + userId: UUID, + transactionId: UUID, + ): Promise; readonly operationsRepository: OperationRepositoryInterface; }; diff --git a/apps/backend/src/application/usecases/transaction/UpdateTransaction.ts b/apps/backend/src/application/usecases/transaction/UpdateTransaction.ts index 99e37245..77d07af7 100644 --- a/apps/backend/src/application/usecases/transaction/UpdateTransaction.ts +++ b/apps/backend/src/application/usecases/transaction/UpdateTransaction.ts @@ -63,7 +63,7 @@ export class UpdateTransactionUseCase { ); if (isUpdated) { - await this.transactionRepository.save( + await this.transactionRepository.update( user.getId().valueOf(), transaction, ); 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 52faa5b9..d2d70e11 100644 --- a/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts +++ b/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts @@ -34,7 +34,7 @@ describe('UpdateTransactionUseCase', () => { const mockTransactionRepository = { getById: vi.fn(), - save: vi.fn(), + update: vi.fn(), } satisfies Partial; const mockEnsureEntityExistsAndOwned = vi.fn(); @@ -126,7 +126,7 @@ describe('UpdateTransactionUseCase', () => { expect(updatedTransaction.postingDate).toBe(data.postingDate); expect(updatedTransaction.transactionDate).toBe(data.transactionDate); - expect(mockTransactionRepository.save).toHaveBeenCalledWith( + expect(mockTransactionRepository.update).toHaveBeenCalledWith( user.getId().valueOf(), transaction, ); @@ -223,7 +223,7 @@ describe('UpdateTransactionUseCase', () => { ...data.operations.update, ]); - expect(mockTransactionRepository.save).toHaveBeenCalledWith( + expect(mockTransactionRepository.update).toHaveBeenCalledWith( user.getId().valueOf(), transaction, ); @@ -269,7 +269,7 @@ describe('UpdateTransactionUseCase', () => { mockTransactionContextLoader.loadContext, ).toHaveBeenCalledExactlyOnceWith(user, []); - expect(mockTransactionRepository.save).not.toHaveBeenCalled(); + expect(mockTransactionRepository.update).not.toHaveBeenCalled(); initialTransactionResponse.operations.forEach((opDTO) => { const updatedOpDTO = updatedTransaction.operations.find( diff --git a/apps/backend/src/domain/transactions/transaction.entity.ts b/apps/backend/src/domain/transactions/transaction.entity.ts index 9a148929..506acff4 100644 --- a/apps/backend/src/domain/transactions/transaction.entity.ts +++ b/apps/backend/src/domain/transactions/transaction.entity.ts @@ -25,7 +25,7 @@ import { CreateTransactionProps, TransactionBuildContext, TransactionUpdateData, - TransactionWithEntriesAndOperations, + TransactionSnapshotWithDetails, TransactionSnapshot, UpdateTransactionProps, OperationsPatch, @@ -110,7 +110,7 @@ export class Transaction { return operations; } - static restore(data: TransactionWithEntriesAndOperations): Transaction { + static restore(data: TransactionSnapshotWithDetails): Transaction { const { createdAt, currency, diff --git a/apps/backend/src/domain/transactions/types.ts b/apps/backend/src/domain/transactions/types.ts index 6cb97c3b..93de31f6 100644 --- a/apps/backend/src/domain/transactions/types.ts +++ b/apps/backend/src/domain/transactions/types.ts @@ -58,6 +58,6 @@ export type TransactionSnapshot = { currency: CurrencyCode; }; -export type TransactionWithEntriesAndOperations = TransactionSnapshot & { +export type TransactionSnapshotWithDetails = TransactionSnapshot & { operations: OperationSnapshot[]; }; diff --git a/apps/backend/src/infrastructure/db/transaction/transaction.repository.test.ts b/apps/backend/src/infrastructure/db/transaction/transaction.repository.test.ts index ddb2e405..9a969e4c 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction.repository.test.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction.repository.test.ts @@ -159,6 +159,72 @@ describe('TransactionRepository', () => { }); }); + describe('getById', () => { + it('should retrieve a transaction by ID with operations', async () => { + const transaction = data.transaction; + + const operationsDataToInsert = transaction + .getOperations() + .map((operation) => { + return { + accountId: operation.accountRelation.getParentId().valueOf(), + amount: operation.amount.valueOf(), + description: operation.description, + id: operation.getId().valueOf(), + isSystem: operation.isSystem, + transactionId: operation.transactionId.valueOf(), + value: operation.value.valueOf(), + }; + }); + + const insertedTransaction = await testDB.createTransactionWithOperations( + user.id, + { + currencyCode: transaction.currency.valueOf(), + description: transaction.description, + operations: operationsDataToInsert, + postingDate: transaction.getPostingDate().valueOf(), + transactionDate: transaction.getTransactionDate().valueOf(), + }, + ); + + const retrievedTransaction = await transactionRepository.getById( + user.id, + insertedTransaction.id, + ); + + expect(retrievedTransaction).not.toBeNull(); + expect(retrievedTransaction?.description).toBe(transaction.description); + + expect(retrievedTransaction?.getPostingDate().valueOf()).toBe( + transaction.getPostingDate().valueOf(), + ); + expect(retrievedTransaction?.getTransactionDate().valueOf()).toBe( + transaction.getTransactionDate().valueOf(), + ); + + const retrievedOperations = retrievedTransaction?.getOperations() ?? []; + + expect(retrievedOperations.length).toBe(operationsDataToInsert.length); + + retrievedOperations.forEach((operation) => { + const matchingOperation = insertedTransaction.operations.find( + (op) => op.id === operation.getId().valueOf(), + ); + + expect(matchingOperation).not.toBeUndefined(); + expect(operation.description).toBe(matchingOperation?.description); + expect(operation.amount.valueOf()).toBe( + matchingOperation?.amount.valueOf(), + ); + expect(operation.isSystem).toBe(matchingOperation?.isSystem); + expect(operation.value.valueOf()).toBe( + matchingOperation?.value.valueOf(), + ); + }); + }); + }); + describe('create', () => { it('should create a new transaction', async () => { const transaction = data.transaction; @@ -273,7 +339,7 @@ describe('TransactionRepository', () => { 'getTransactionSnapshot', ).mockResolvedValue(data.transactionWithRelations); - await transactionRepository.save(user.id, transaction); + await transactionRepository.update(user.id, transaction); const updatedTransaction = await testDB.getTransactionWithRelations( transaction.getId().valueOf(), @@ -319,7 +385,7 @@ describe('TransactionRepository', () => { await testDB.insertTransaction(transaction.toSnapshot()); transaction.markAsDeleted(); - await transactionRepository.save(user.id, transaction); + await transactionRepository.update(user.id, transaction); const updatedTransaction = await testDB.getTransactionWithRelations( transaction.getId().valueOf(), diff --git a/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts b/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts index dad038b4..f5784dd9 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts @@ -8,7 +8,6 @@ import { } from 'src/application'; import { OperationDbRow, - TransactionDbRow, TransactionWithRelations, transactionsTable, } from 'src/db/schema'; @@ -41,7 +40,7 @@ export class TransactionRepository ), ); - await this.saveOperations(transaction); + await this.saveOperations(userId, transaction); }, 'TransactionRepository.softDelete', { @@ -52,61 +51,46 @@ export class TransactionRepository ); } - private insert( + private async insertTransactionRow( userId: UUID, transaction: Transaction, - ): Promise { - return this.executeDatabaseOperation( - async () => { - const transactionData = TransactionMapper.toDBRow(transaction); + ): Promise { + const transactionData = TransactionMapper.toDBRow(transaction); - return this.db - .insert(transactionsTable) - .values({ ...transactionData, userId }) - .returning() - .get(); - }, - 'TransactionRepository.insert', - { - field: 'transactionId', - tableName: 'transactions', - value: transaction.getId().valueOf(), - }, - ); + await this.db + .insert(transactionsTable) + .values({ ...transactionData, userId }); } - private async update( + private async updateTransactionRow( userId: UUID, transaction: Transaction, - ): Promise { - return this.executeDatabaseOperation( - async () => { - const transactionData = TransactionMapper.toDBRow(transaction); - - const safeData = this.getSafeUpdate(transactionData, [ - 'description', - 'postingDate', - 'transactionDate', - 'updatedAt', - 'currency', - ]); - - return this.db - .update(transactionsTable) - .set({ ...safeData, userId }) - .returning() - .get(); - }, - 'TransactionRepository.update', - { - field: 'transactionId', - tableName: 'transactions', - value: transaction.getId().valueOf(), - }, - ); + ): Promise { + const transactionData = TransactionMapper.toDBRow(transaction); + + const safeData = this.getSafeUpdate(transactionData, [ + 'description', + 'postingDate', + 'transactionDate', + 'updatedAt', + 'currency', + ]); + + await this.db + .update(transactionsTable) + .set({ ...safeData }) + .where( + and( + eq(transactionsTable.id, transaction.getId().valueOf()), + eq(transactionsTable.userId, userId), + ), + ); } - private async saveOperations(transaction: Transaction): Promise { + private async saveOperations( + userId: UUID, + transaction: Transaction, + ): Promise { const operations: OperationDbRow[] = []; const operationsSnapshots = new Map(); @@ -116,7 +100,7 @@ export class TransactionRepository }); const snapshot = await this.getTransactionSnapshot( - transaction.getUserId().valueOf(), + userId, transaction.getId().valueOf(), ); @@ -125,19 +109,19 @@ export class TransactionRepository }); await this.operationsRepository.save( - transaction.getUserId().valueOf(), + userId, operations, operationsSnapshots, ); } - async save(userId: UUID, transaction: Transaction): Promise { + async update(userId: UUID, transaction: Transaction): Promise { await this.executeDatabaseOperation( async () => { - await this.update(userId, transaction); - await this.saveOperations(transaction); + await this.updateTransactionRow(userId, transaction); + await this.saveOperations(userId, transaction); }, - 'TransactionRepository.save', + 'TransactionRepository.update', { field: 'transactionId', tableName: 'transactions', @@ -178,48 +162,48 @@ export class TransactionRepository ); } - getById(_userId: UUID, _transactionId: UUID): Promise { - throw new Error('Method not implemented.'); - // return this.executeDatabaseOperation( - // async () => { - // const transactionDbRow: TransactionWithRelations | undefined = - // await this.db.query.transactionsTable.findFirst({ - // where: and( - // eq(transactionsTable.id, transactionId), - // eq(transactionsTable.userId, userId), - // ), - // with: { - // entries: { with: { operations: true } }, - // }, - // }); - - // if (!transactionDbRow) { - // return null; - // } - - // const transaction = Transaction.restore(transactionDbRow); - - // return transaction; - // }, - // 'TransactionRepository.getById', - // { - // field: 'transactionId', - // tableName: 'transactions', - // value: transactionId, - // }, - // ); + async getById( + userId: UUID, + transactionId: UUID, + ): Promise { + return this.executeDatabaseOperation( + async () => { + const transactionDbRow: TransactionWithRelations | undefined = + await this.db.query.transactionsTable.findFirst({ + where: and( + eq(transactionsTable.id, transactionId), + eq(transactionsTable.userId, userId), + ), + with: { + operations: true, + }, + }); + + if (!transactionDbRow) { + return null; + } + + return Transaction.restore(transactionDbRow); + }, + 'TransactionRepository.getById', + { + field: 'transactionId', + tableName: 'transactions', + value: transactionId, + }, + ); } async create(userId: UUID, transaction: Transaction): Promise { - return await this.executeDatabaseOperation( + await this.executeDatabaseOperation( async () => { const operationsDataToInsert = transaction .getOperations() .map((operation) => OperationMapper.toDBRow(operation)); - await this.operationsRepository.save(userId, operationsDataToInsert); + await this.insertTransactionRow(userId, transaction); - await this.insert(userId, transaction); + await this.operationsRepository.save(userId, operationsDataToInsert); }, 'TransactionRepository.create', { From aad67ad803ed303a6f4410f824df97d00acc4312 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Tue, 14 Apr 2026 20:35:39 +0300 Subject: [PATCH 3/5] refactor: update database schema and repository methods for transactions and accounts, enhance transaction retrieval with operations --- ...rian.sql => 0000_keen_gertrude_yorkes.sql} | 0 apps/backend/drizzle/meta/0000_snapshot.json | 2 +- apps/backend/drizzle/meta/_journal.json | 4 +- apps/backend/package.json | 15 +- .../TransactionRepository.interface.ts | 5 - apps/backend/src/db/test-db.ts | 20 +- .../transaction-query.repository.test.ts | 279 ++++++++++++++++++ .../transaction-query.repository.ts | 141 ++++----- .../transaction.repository.test.ts | 67 ----- .../db/transaction/transaction.repository.ts | 3 +- 10 files changed, 368 insertions(+), 168 deletions(-) rename apps/backend/drizzle/{0000_overconfident_glorian.sql => 0000_keen_gertrude_yorkes.sql} (100%) create mode 100644 apps/backend/src/infrastructure/db/transaction/transaction-query.repository.test.ts diff --git a/apps/backend/drizzle/0000_overconfident_glorian.sql b/apps/backend/drizzle/0000_keen_gertrude_yorkes.sql similarity index 100% rename from apps/backend/drizzle/0000_overconfident_glorian.sql rename to apps/backend/drizzle/0000_keen_gertrude_yorkes.sql diff --git a/apps/backend/drizzle/meta/0000_snapshot.json b/apps/backend/drizzle/meta/0000_snapshot.json index d7ce45bd..a1a0db25 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": "e239fb7b-54dd-48a9-bad4-fb13d27d0fa1", + "id": "b3626041-a73e-4294-b580-3c53764e0f43", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "accounts": { diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index d04bc676..f3fff04a 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1773222725992, - "tag": "0000_overconfident_glorian", + "when": 1776091943739, + "tag": "0000_keen_gertrude_yorkes", "breakpoints": true } ] diff --git a/apps/backend/package.json b/apps/backend/package.json index f386a08b..a0c3484d 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -13,22 +13,23 @@ "build": "pnpm clean && tsc", "start": "node --experimental-specifier-resolution=node dist/index.js", "migrateOld": "drizzle-kit push --config=drizzle.config.ts", - "migrate": "drizzle-kit migrate --config=drizzle.config.ts", - "db:seed": "tsx src/db/scripts/seed.ts", "studio": "pnpm drizzle-kit studio", - "db:push": "npx drizzle-kit push", - "db:generate": "npx drizzle-kit generate", "generateCustom": "drizzle-kit --config=drizzle.config.ts generate --custom --name", "drop:tables": "tsx src/db/scripts/drop-tables.ts", "ts-check": "tsc -b --noEmit", + "db:seed": "tsx src/db/scripts/seed.ts", + "db:reseed": "pnpm db:reset && pnpm db:seed", + "db:push": "npx drizzle-kit push", + "db:generate": "npx drizzle-kit generate", + "db:migrate": "drizzle-kit migrate --config=drizzle.config.ts", "db:delete": "rm -rf ./data/* && mkdir -p ./data", "db:migrations:delete": "rm -rf ./drizzle", "db:regenerate": "pnpm db:migrations:delete && pnpm db:generate", - "db:delete:all": "pnpm delete:db && pnpm delete:migrations", - "db:full-reset": "pnpm delete:all && pnpm generate && pnpm migrate", + "db:delete:all": "pnpm db:delete && pnpm db:migrations:delete", + "db:full-reset": "pnpm db:delete:all && pnpm db:generate && pnpm db:migrate", "testDB": "tsx src/db/test-db.ts", "seed:currencies": "tsx src/db/scripts/currenciesSeed.ts", - "db:reset": "pnpm delete:db && pnpm migrate", + "db:reset": "pnpm db:delete && pnpm db:migrate", "lint": "eslint .", "lint:fix": "eslint . --fix", "check": "pnpm ts-check && pnpm lint:fix", diff --git a/apps/backend/src/application/interfaces/TransactionRepository.interface.ts b/apps/backend/src/application/interfaces/TransactionRepository.interface.ts index f2b8d0bb..f641cf5e 100644 --- a/apps/backend/src/application/interfaces/TransactionRepository.interface.ts +++ b/apps/backend/src/application/interfaces/TransactionRepository.interface.ts @@ -1,5 +1,4 @@ import { UUID } from '@ledgerly/shared/types'; -import { TransactionWithRelations } from 'src/db/schema'; import { Transaction } from 'src/domain'; import { OperationRepositoryInterface } from './OperationRepository.interface'; @@ -9,9 +8,5 @@ export type TransactionRepositoryInterface = { create(userId: UUID, transaction: Transaction): Promise; getById(userId: UUID, transactionId: UUID): Promise; softDelete(userId: UUID, transaction: Transaction): Promise; - getTransactionSnapshot( - userId: UUID, - transactionId: UUID, - ): Promise; readonly operationsRepository: OperationRepositoryInterface; }; diff --git a/apps/backend/src/db/test-db.ts b/apps/backend/src/db/test-db.ts index 818ab96f..d9376cb1 100644 --- a/apps/backend/src/db/test-db.ts +++ b/apps/backend/src/db/test-db.ts @@ -12,7 +12,7 @@ import { } from '@ledgerly/shared/types'; import { isoDate, isoDatetime } from '@ledgerly/shared/validation'; import { createClient } from '@libsql/client'; -import { sql } from 'drizzle-orm'; +import { eq, sql } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/libsql'; import { migrate } from 'drizzle-orm/libsql/migrator'; import { DataBase } from 'src/db'; @@ -253,7 +253,7 @@ export class TestDB { accountId: UUID; description: string; transactionId: UUID; - id: UUID; + id?: UUID; amount: MoneyString; value: MoneyString; isSystem?: boolean; @@ -517,7 +517,6 @@ export class TestDB { accountId: accountUSD2.id, amount: Amount.create('-5000').valueOf(), description: 'Grocery Shopping', - id: transaction1.id, // Using transaction ID as operation ID for simplicity transactionId: transaction1.id, value: Amount.create('-5000').valueOf(), }); @@ -526,7 +525,6 @@ export class TestDB { accountId: accountEUR.id, amount: Amount.create('2000').valueOf(), description: 'Credit Card Payment', - id: transaction1.id, // Using transaction ID as operation ID for simplicity transactionId: transaction1.id, value: Amount.create('2000').valueOf(), }); @@ -535,7 +533,6 @@ export class TestDB { accountId: accountUSD1.id, amount: Amount.create('-2000').valueOf(), description: 'Utility Bill', - id: transaction1.id, // Using transaction ID as operation ID for simplicity transactionId: transaction1.id, value: Amount.create('-2000').valueOf(), }); @@ -544,7 +541,6 @@ export class TestDB { accountId: accountUSD1.id, amount: Amount.create('15000').valueOf(), description: 'Salary', - id: transaction2.id, // Using transaction ID as operation ID for simplicity transactionId: transaction2.id, value: Amount.create('15000').valueOf(), }); @@ -553,7 +549,6 @@ export class TestDB { accountId: accountUSD2.id, amount: Amount.create('-15000').valueOf(), description: 'Rent Payment', - id: transaction2.id, // Using transaction ID as operation ID for simplicity transactionId: transaction2.id, value: Amount.create('-15000').valueOf(), }); @@ -593,4 +588,15 @@ export class TestDB { getAllOperations = async () => { return this.db.select().from(schema.operationsTable); }; + + getAllTransactionByUserId = async ( + userId: UUID, + ): Promise => { + return await this.db.query.transactionsTable.findMany({ + where: eq(transactionsTable.userId, userId), + with: { + operations: true, + }, + }); + }; } diff --git a/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.test.ts b/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.test.ts new file mode 100644 index 00000000..4b28c251 --- /dev/null +++ b/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.test.ts @@ -0,0 +1,279 @@ +import { + CurrencyCode, + IsoDateString, + MoneyString, + UUID, +} from '@ledgerly/shared/types'; +import { UserDbRow } from 'src/db/schema'; +import { TestDB } from 'src/db/test-db'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TransactionManager, TransactionQueryRepository } from '../'; + +type TestAccount = { + id: UUID; + currency: CurrencyCode; +}; + +type OperationSeed = { + account: TestAccount; + amount: MoneyString; + description: string; + value?: MoneyString; +}; + +type TransactionSeed = { + currencyCode?: CurrencyCode; + description: string; + operations: OperationSeed[]; + postingDate?: IsoDateString; + transactionDate?: IsoDateString; +}; + +describe('TransactionQueryRepository', () => { + let testDB: TestDB; + let transactionQueryRepo: TransactionQueryRepository; + let user: UserDbRow; + let usdAccount: TestAccount; + let eurAccount: TestAccount; + + const transactionManager = { + getCurrentTransaction: () => testDB.db, + run: vi.fn((cb: () => unknown) => { + return cb(); + }), + }; + + const createAccount = async ( + currency: CurrencyCode, + name: string, + ): Promise => { + const account = await testDB.createAccount(user.id, { + currency, + name, + }); + + return { + currency: account.currency, + id: account.id, + }; + }; + + const createTransaction = async ({ + currencyCode = 'USD' as CurrencyCode, + description, + operations, + postingDate = '2023-01-01' as IsoDateString, + transactionDate = '2023-01-01' as IsoDateString, + }: { + currencyCode?: CurrencyCode; + description: string; + operations: OperationSeed[]; + postingDate?: IsoDateString; + transactionDate?: IsoDateString; + }) => { + return testDB.createTransactionWithOperations(user.id, { + currencyCode, + description, + operations: operations.map((operation) => ({ + accountId: operation.account.id, + amount: operation.amount, + description: operation.description, + id: crypto.randomUUID() as UUID, + isSystem: false, + transactionId: crypto.randomUUID() as UUID, + value: operation.value ?? operation.amount, + })), + postingDate, + transactionDate, + }); + }; + + const expectTransactionToMatchSeed = ( + transaction: Awaited>, + seed: TransactionSeed, + ) => { + expect(transaction).not.toBeNull(); + + if (!transaction) { + return; + } + + expect(transaction.currency).toBe(seed.currencyCode ?? 'USD'); + expect(transaction.description).toBe(seed.description); + expect(transaction.postingDate).toBe(seed.postingDate ?? '2023-01-01'); + expect(transaction.transactionDate).toBe( + seed.transactionDate ?? '2023-01-01', + ); + expect(transaction.userId).toBe(user.id); + expect(transaction.operations).toHaveLength(seed.operations.length); + + transaction.operations.forEach((operation, operationIndex) => { + const operationSeed = seed.operations[operationIndex]; + + expect(operation.accountId).toBe(operationSeed.account.id); + expect(operation.amount).toBe(operationSeed.amount); + expect(operation.value).toBe(operationSeed.value ?? operationSeed.amount); + expect(operation.description).toBe(operationSeed.description); + expect(operation.userId).toBe(user.id); + expect(operation.transactionId).toBe(transaction.id); + expect(operation.isSystem).toBe(false); + }); + }; + + beforeEach(async () => { + testDB = new TestDB(); + await testDB.setupTestDb(); + + user = await testDB.createUser(); + usdAccount = await createAccount('USD' as CurrencyCode, 'Cash USD'); + eurAccount = await createAccount('EUR' as CurrencyCode, 'Cash EUR'); + + transactionQueryRepo = new TransactionQueryRepository( + transactionManager as unknown as TransactionManager, + ); + }); + + describe('findAll', () => { + it('should return all user transactions with operations', async () => { + const transactionSeeds: TransactionSeed[] = [ + { + description: 'Salary', + operations: [ + { + account: usdAccount, + amount: '-2000' as MoneyString, + description: 'Salary debit', + }, + { + account: usdAccount, + amount: '2000' as MoneyString, + description: 'Salary credit', + }, + ], + }, + { + description: 'Groceries', + operations: [ + { + account: usdAccount, + amount: '-150' as MoneyString, + description: 'Groceries expense', + }, + { + account: eurAccount, + amount: '150' as MoneyString, + description: 'Groceries offset', + }, + ], + postingDate: '2023-01-02' as IsoDateString, + transactionDate: '2023-01-02' as IsoDateString, + }, + { + currencyCode: 'EUR' as CurrencyCode, + description: 'Transfer', + operations: [ + { + account: eurAccount, + amount: '-500' as MoneyString, + description: 'Transfer out', + }, + { + account: eurAccount, + amount: '500' as MoneyString, + description: 'Transfer in', + }, + ], + postingDate: '2023-01-03' as IsoDateString, + transactionDate: '2023-01-03' as IsoDateString, + }, + ]; + + await Promise.all( + transactionSeeds.map((transactionSeed) => + createTransaction(transactionSeed), + ), + ); + + const transactions = await transactionQueryRepo.findAll(user.id); + + expect(transactions).toHaveLength(transactionSeeds.length); + + transactions.forEach((transaction, index) => { + const seed = transactionSeeds[index]; + expectTransactionToMatchSeed(transaction, seed); + }); + }); + }); + + describe('findById', () => { + it('should return transaction by id with operations', async () => { + const transactionSeed: TransactionSeed = { + currencyCode: 'EUR' as CurrencyCode, + description: 'Exchange', + operations: [ + { + account: usdAccount, + amount: '-1000' as MoneyString, + description: 'Exchange USD out', + }, + { + account: eurAccount, + amount: '920' as MoneyString, + description: 'Exchange EUR in', + value: '920' as MoneyString, + }, + ], + postingDate: '2023-02-10' as IsoDateString, + transactionDate: '2023-02-10' as IsoDateString, + }; + + const insertedTransaction = await createTransaction(transactionSeed); + + const transaction = await transactionQueryRepo.findById( + user.id, + insertedTransaction.id, + ); + + expectTransactionToMatchSeed(transaction, transactionSeed); + expect(transaction?.id).toBe(insertedTransaction.id); + }); + + it('should return null when transaction does not exist', async () => { + const transaction = await transactionQueryRepo.findById( + user.id, + crypto.randomUUID() as UUID, + ); + + expect(transaction).toBeNull(); + }); + + it('should return null when transaction belongs to another user', async () => { + const transactionSeed: TransactionSeed = { + description: 'Private transfer', + operations: [ + { + account: usdAccount, + amount: '-300' as MoneyString, + description: 'Private transfer out', + }, + { + account: usdAccount, + amount: '300' as MoneyString, + description: 'Private transfer in', + }, + ], + }; + + const insertedTransaction = await createTransaction(transactionSeed); + const anotherUser = await testDB.createUser(); + + const transaction = await transactionQueryRepo.findById( + anotherUser.id, + insertedTransaction.id, + ); + + expect(transaction).toBeNull(); + }); + }); +}); diff --git a/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.ts b/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.ts index 521baca2..67549da2 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.ts @@ -1,6 +1,11 @@ import { TransactionQueryParams, UUID } from '@ledgerly/shared/types'; +import { and, eq } from 'drizzle-orm'; import { TransactionQueryRepositoryInterface } from 'src/application/interfaces/TransactionQueryRepository.interface'; -import { TransactionWithRelations } from 'src/db/schema'; +import { + operationsTable, + transactionsTable, + TransactionWithRelations, +} from 'src/db/schema'; import { BaseRepository } from '../BaseRepository'; @@ -12,88 +17,70 @@ export class TransactionQueryRepository extends BaseRepository implements TransactionQueryRepositoryInterface { - findById( - _userId: UUID, - _transactionId: UUID, + constructor( + readonly transactionManager: BaseRepository['transactionManager'], + ) { + super(transactionManager); + } + + async findById( + userId: UUID, + transactionId: UUID, ): Promise { - throw new Error( - 'Method not implemented. Use findAll with filtering instead.', - ); - // return this.executeDatabaseOperation( - // async () => { - // const transactionDbRow: TransactionWithRelations | undefined = - // await this.db.query.transactionsTable.findFirst({ - // where: and( - // eq(transactionsTable.id, transactionId), - // eq(transactionsTable.userId, userId), - // ), - // with: { - // entries: { with: { operations: true } }, - // }, - // }); + return this.executeDatabaseOperation( + async () => { + const transaction = await this.db.query.transactionsTable.findFirst({ + where: and( + eq(transactionsTable.id, transactionId), + eq(transactionsTable.userId, userId), + ), + with: { + operations: true, + }, + }); - // return transactionDbRow ?? null; - // }, - // 'TransactionQueryRepository.findById', - // { - // field: 'transactionId', - // tableName: 'transactions', - // value: transactionId, - // }, - // ); + return transaction ?? null; + }, + 'TransactionQueryRepository.findById', + { + field: 'transactionId', + tableName: 'transactions', + value: transactionId, + }, + ); } - findAll( - _userId: UUID, - _query?: TransactionQueryParams, + async findAll( + userId: UUID, + query?: TransactionQueryParams, ): Promise { - throw new Error( - 'Method not implemented. Use findById for specific transaction retrieval.', - ); - // return this.executeDatabaseOperation( - // async () => { - // const accountFilter = query?.accountId - // ? eq(operationsTable.accountId, query.accountId) - // : undefined; - - // const transactionRows = await this.db - // .select({ id: transactionsTable.id }) - // .from(transactionsTable) - // .innerJoin( - // entriesTable, - // and( - // eq(entriesTable.transactionId, transactionsTable.id), - // eq(entriesTable.userId, userId), - // ), - // ) - // .innerJoin( - // operationsTable, - // and( - // eq(operationsTable.entryId, entriesTable.id), - // eq(operationsTable.userId, userId), - // ...(accountFilter ? [accountFilter] : []), - // ), - // ) - // .groupBy(transactionsTable.id) - // .orderBy(transactionsTable.createdAt); + return this.executeDatabaseOperation( + async () => { + const transactions = await this.db.query.transactionsTable.findMany({ + where: eq(transactionsTable.userId, userId), + with: { + operations: query?.accountId + ? { + where: eq(operationsTable.accountId, query.accountId), + } + : true, + }, + }); - // const transactionIds = transactionRows.map((r) => r.id); + if (!query?.accountId) { + return transactions; + } - // if (transactionIds.length === 0) return []; - - // return await this.db.query.transactionsTable.findMany({ - // where: inArray(transactionsTable.id, transactionIds), - // with: { - // entries: { with: { operations: true } }, - // }, - // }); - // }, - // 'TransactionQueryRepository.findAll', - // { - // field: 'transactions', - // tableName: 'transactions', - // value: JSON.stringify(query), - // }, - // ); + return transactions.filter( + (transaction) => transaction.operations.length, + ); + }, + 'TransactionQueryRepository.findAll', + { + field: 'transactions', + tableName: 'transactions', + value: JSON.stringify(query), + }, + ); } } diff --git a/apps/backend/src/infrastructure/db/transaction/transaction.repository.test.ts b/apps/backend/src/infrastructure/db/transaction/transaction.repository.test.ts index 9a969e4c..16e7ea6e 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction.repository.test.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction.repository.test.ts @@ -97,68 +97,6 @@ describe('TransactionRepository', () => { transactionContext = data.transactionContext; }); - describe('getTransactionSnapshot', () => { - it('should retrieve a transaction snapshot with operations', async () => { - const transaction = data.transaction; - - const operationsDataToInsert = transaction - .getOperations() - .map((operation) => { - return { - accountId: operation.accountRelation.getParentId().valueOf(), - amount: operation.amount.valueOf(), - description: operation.description, - id: operation.getId().valueOf(), - isSystem: operation.isSystem, - transactionId: operation.transactionId.valueOf(), - value: operation.value.valueOf(), - }; - }); - - const insertedTransaction = await testDB.createTransactionWithOperations( - user.id, - { - currencyCode: transaction.currency.valueOf(), - description: transaction.description, - operations: operationsDataToInsert, - postingDate: transaction.getPostingDate().valueOf(), - transactionDate: transaction.getTransactionDate().valueOf(), - }, - ); - - const snapshot = await transactionRepository.getTransactionSnapshot( - user.id, - insertedTransaction.id, - ); - - expect(snapshot).not.toBeNull(); - expect(snapshot?.description).toBe(transaction.description); - - expect(snapshot?.postingDate).toBe( - transaction.getPostingDate().valueOf(), - ); - expect(snapshot?.transactionDate).toBe( - transaction.getTransactionDate().valueOf(), - ); - - const retrievedOperations = snapshot?.operations ?? []; - - expect(retrievedOperations.length).toBe(operationsDataToInsert.length); - - retrievedOperations.forEach((operation) => { - const matchingOperation = insertedTransaction.operations.find( - (op) => op.id === operation.id, - ); - - expect(matchingOperation).not.toBeUndefined(); - expect(operation.description).toBe(matchingOperation?.description); - expect(operation.amount).toBe(matchingOperation?.amount); - expect(operation.isSystem).toBe(matchingOperation?.isSystem); - expect(operation.value).toBe(matchingOperation?.value); - }); - }); - }); - describe('getById', () => { it('should retrieve a transaction by ID with operations', async () => { const transaction = data.transaction; @@ -334,11 +272,6 @@ describe('TransactionRepository', () => { const updatedSnapshot = transaction.toSnapshot(); - vi.spyOn( - transactionRepository, - 'getTransactionSnapshot', - ).mockResolvedValue(data.transactionWithRelations); - await transactionRepository.update(user.id, transaction); const updatedTransaction = await testDB.getTransactionWithRelations( diff --git a/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts b/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts index f5784dd9..4f8fe936 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts @@ -129,8 +129,7 @@ export class TransactionRepository }, ); } - - async getTransactionSnapshot( + private async getTransactionSnapshot( userId: UUID, transactionId: UUID, ): Promise { From c4d00704aa0d7991d7ae9805c5fd833032cd78d1 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Wed, 15 Apr 2026 19:34:28 +0300 Subject: [PATCH 4/5] refactor: update transaction handling to filter out soft deleted operations and enhance query repository for tombstone management --- .../mappers/transaction-view.mapper.ts | 4 +- apps/backend/src/db/test-db.ts | 12 +- .../transactions/transaction.entity.test.ts | 69 ++++---- .../domain/transactions/transaction.entity.ts | 6 +- .../transaction-query.repository.test.ts | 167 ++++++++++++++++++ .../transaction-query.repository.ts | 15 +- 6 files changed, 233 insertions(+), 40 deletions(-) diff --git a/apps/backend/src/application/mappers/transaction-view.mapper.ts b/apps/backend/src/application/mappers/transaction-view.mapper.ts index 077d152c..08b23943 100644 --- a/apps/backend/src/application/mappers/transaction-view.mapper.ts +++ b/apps/backend/src/application/mappers/transaction-view.mapper.ts @@ -11,7 +11,9 @@ export class TransactionViewMapper { currency: transaction.currency, description: transaction.description, id: transaction.id, - operations: transaction.operations.map(this.mapOperation.bind(this)), + operations: transaction.operations + .filter((op) => !op.isTombstone) + .map(this.mapOperation.bind(this)), postingDate: transaction.postingDate, transactionDate: transaction.transactionDate, updatedAt: transaction.updatedAt, diff --git a/apps/backend/src/db/test-db.ts b/apps/backend/src/db/test-db.ts index d9376cb1..dbd029ea 100644 --- a/apps/backend/src/db/test-db.ts +++ b/apps/backend/src/db/test-db.ts @@ -187,6 +187,7 @@ export class TestDB { postingDate: IsoDateString; transactionDate: IsoDateString; currencyCode: CurrencyCode; + isTombstone?: boolean; }, ): Promise => { const transactionData: TransactionDbInsert = { @@ -196,9 +197,9 @@ export class TestDB { description: params?.description ?? `Test Transaction ${this.transactionCounter.getNextName()}`, - isTombstone: false, - postingDate: DateValue.create().valueOf(), - transactionDate: DateValue.create().valueOf(), + isTombstone: params?.isTombstone ?? false, + postingDate: params?.postingDate ?? DateValue.create().valueOf(), + transactionDate: params?.transactionDate ?? DateValue.create().valueOf(), ...params, userId, version: 0, @@ -220,6 +221,7 @@ export class TestDB { postingDate: IsoDateString; transactionDate: IsoDateString; currencyCode: CurrencyCode; + isTombstone?: boolean; operations: { accountId: UUID; description: string; @@ -227,6 +229,7 @@ export class TestDB { amount: MoneyString; value: MoneyString; isSystem?: boolean; + isTombstone?: boolean; id: UUID; }[]; }, @@ -257,6 +260,7 @@ export class TestDB { amount: MoneyString; value: MoneyString; isSystem?: boolean; + isTombstone?: boolean; }, ) => { const operationData: OperationDbInsert = { @@ -270,7 +274,7 @@ export class TestDB { params?.description ?? `Test Operation ${this.operationCounter.getNextName()}`, id: params?.id ?? (crypto.randomUUID() as UUID), - isTombstone: false, + isTombstone: params?.isTombstone ?? false, transactionId: params?.transactionId ?? (crypto.randomUUID() as UUID), userId, value: params?.value ?? Amount.create('1000').valueOf(), diff --git a/apps/backend/src/domain/transactions/transaction.entity.test.ts b/apps/backend/src/domain/transactions/transaction.entity.test.ts index 52d8f17b..35341f2c 100644 --- a/apps/backend/src/domain/transactions/transaction.entity.test.ts +++ b/apps/backend/src/domain/transactions/transaction.entity.test.ts @@ -69,7 +69,17 @@ describe('Transaction Domain Entity', () => { { accountKey: 'USD', amount: '-10000', - description: 'Wallet adjustment', + description: 'USD Wallet adjustment', + }, + { + accountKey: 'EUR', + amount: '50000', + description: 'fuel Account', + }, + { + accountKey: 'EUR', + amount: '-50000', + description: 'EUR Wallet adjustment', }, ]; @@ -375,7 +385,7 @@ describe('Transaction Domain Entity', () => { }); }); - it('Should update an existing operation and increase version', () => { + it.skip('Should update an existing operation and increase version', () => { const transaction = Transaction.create(user.getId(), transactionData); const originalSnapshot = transaction.toSnapshot(); @@ -450,14 +460,23 @@ describe('Transaction Domain Entity', () => { }); }); - it('Should delete an existing operation and increase version', () => { + it('Should delete an existing operation, filter deleted operations from snapshot and increase version', () => { const transaction = Transaction.create(user.getId(), transactionData); const originalSnapshot = transaction.toSnapshot(); - const operationToDelete = transaction.getOperations()[0]; + const operationsToDelete = [ + transaction.getOperations()[0], + transaction.getOperations()[1], + ].map((op) => op.getId()); - const operationToDeleteSnapshot = operationToDelete.toSnapshot(); + const operationsToDeleteIds = operationsToDelete.map((op) => + op.valueOf(), + ); + + const operationsSnapshotFiltered = originalSnapshot.operations.filter( + (op) => !operationsToDeleteIds.includes(op.id), + ); vi.advanceTimersByTime(5000); @@ -465,7 +484,7 @@ describe('Transaction Domain Entity', () => { { operations: { create: [], - delete: [operationToDelete.getId()], + delete: operationsToDelete, update: [], }, }, @@ -478,41 +497,27 @@ describe('Transaction Domain Entity', () => { new Date(updatedSnapshot.updatedAt).getTime(), ); - const operationsAfterDelete = transaction.getOperations(); - - const deletedOperation = operationsAfterDelete.find( - (op) => op.getId().valueOf() === operationToDelete.getId().valueOf(), + const deletedOperation = operationsSnapshotFiltered.find((op) => + operationsToDeleteIds.includes(op.id), ); - expect(deletedOperation).toBeDefined(); + expect(deletedOperation).not.toBeDefined(); - expect(deletedOperation?.isDeleted()).toBe(true); - - const deletedOperationSnapshot = updatedSnapshot.operations.find( - (op) => op.id === operationToDelete.getId().valueOf(), + const deletedOperationSnapshot = updatedSnapshot.operations.filter( + (op) => op.id === operationsToDeleteIds[0], ); - expect(deletedOperationSnapshot).toBeDefined(); + expect(deletedOperationSnapshot).toHaveLength(0); - compareEntities(deletedOperationSnapshot!, operationToDeleteSnapshot, [ - 'isTombstone', - 'updatedAt', - ]); + operationsSnapshotFiltered.forEach((op) => { + expect(operationsToDeleteIds.includes(op.id)).toBe(false); - updatedSnapshot.operations.forEach((op) => { - const matchedPrevOp = originalSnapshot.operations.find( + const matchedPrevOp = updatedSnapshot.operations.find( (prevOp) => op.id === prevOp.id, ); expect(matchedPrevOp).toBeDefined(); - - if (matchedPrevOp?.id === operationToDeleteSnapshot.id) { - expect(op.isTombstone).toBe(true); - compareEntities(matchedPrevOp, op, ['isTombstone', 'updatedAt']); - return; - } - - compareEntities(op, matchedPrevOp!); + compareEntities(matchedPrevOp!, op); }); }); }); @@ -593,4 +598,8 @@ describe('Transaction Domain Entity', () => { }).toThrowError(UnbalancedTransactionError); }); }); + + describe('Operations', () => { + it.todo('should not return soft deleted operations'); + }); }); diff --git a/apps/backend/src/domain/transactions/transaction.entity.ts b/apps/backend/src/domain/transactions/transaction.entity.ts index 506acff4..a16c021e 100644 --- a/apps/backend/src/domain/transactions/transaction.entity.ts +++ b/apps/backend/src/domain/transactions/transaction.entity.ts @@ -198,7 +198,9 @@ export class Transaction { description: this.description, id: this.getId().valueOf(), isTombstone: this.isDeleted(), - operations: this.operations.map((operation) => operation.toSnapshot()), + operations: this.operations + .filter((operation) => !operation.isDeleted()) + .map((operation) => operation.toSnapshot()), postingDate: this.postingDate.valueOf(), transactionDate: this.transactionDate.valueOf(), updatedAt: this.getUpdatedAt().valueOf(), @@ -433,7 +435,7 @@ export class Transaction { } getOperations(): Operation[] { - return this.operations; + return this.operations.filter((operation) => !operation.isDeleted()); } markAsDeleted(): void { diff --git a/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.test.ts b/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.test.ts index 4b28c251..a74d1c27 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.test.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.test.ts @@ -20,6 +20,7 @@ type OperationSeed = { amount: MoneyString; description: string; value?: MoneyString; + isTombstone?: boolean; }; type TransactionSeed = { @@ -28,6 +29,7 @@ type TransactionSeed = { operations: OperationSeed[]; postingDate?: IsoDateString; transactionDate?: IsoDateString; + isTombstone?: boolean; }; describe('TransactionQueryRepository', () => { @@ -62,6 +64,7 @@ describe('TransactionQueryRepository', () => { const createTransaction = async ({ currencyCode = 'USD' as CurrencyCode, description, + isTombstone = false, operations, postingDate = '2023-01-01' as IsoDateString, transactionDate = '2023-01-01' as IsoDateString, @@ -71,16 +74,19 @@ describe('TransactionQueryRepository', () => { operations: OperationSeed[]; postingDate?: IsoDateString; transactionDate?: IsoDateString; + isTombstone?: boolean; }) => { return testDB.createTransactionWithOperations(user.id, { currencyCode, description, + isTombstone, operations: operations.map((operation) => ({ accountId: operation.account.id, amount: operation.amount, description: operation.description, id: crypto.randomUUID() as UUID, isSystem: false, + isTombstone: operation.isTombstone ?? false, transactionId: crypto.randomUUID() as UUID, value: operation.value ?? operation.amount, })), @@ -204,6 +210,84 @@ describe('TransactionQueryRepository', () => { expectTransactionToMatchSeed(transaction, seed); }); }); + + it('should filter transactions if they are deleted', async () => { + const deletedTransactionSeeds: TransactionSeed[] = [ + { + description: 'Salary', + isTombstone: true, + operations: [ + { + account: usdAccount, + amount: '-2000' as MoneyString, + description: 'Salary debit', + isTombstone: true, + }, + { + account: usdAccount, + amount: '2000' as MoneyString, + description: 'Salary credit', + isTombstone: true, + }, + ], + }, + ]; + + const transactionSeeds: TransactionSeed[] = [ + ...deletedTransactionSeeds, + { + description: 'Groceries', + operations: [ + { + account: usdAccount, + amount: '-150' as MoneyString, + description: 'Groceries expense', + }, + { + account: eurAccount, + amount: '150' as MoneyString, + description: 'Groceries offset', + }, + ], + postingDate: '2023-01-02' as IsoDateString, + transactionDate: '2023-01-02' as IsoDateString, + }, + { + currencyCode: 'EUR' as CurrencyCode, + description: 'Transfer', + operations: [ + { + account: eurAccount, + amount: '-500' as MoneyString, + description: 'Transfer out', + }, + { + account: eurAccount, + amount: '500' as MoneyString, + description: 'Transfer in', + }, + ], + postingDate: '2023-01-03' as IsoDateString, + transactionDate: '2023-01-03' as IsoDateString, + }, + ]; + + await Promise.all( + transactionSeeds.map((transactionSeed) => + createTransaction(transactionSeed), + ), + ); + + const transactions = await transactionQueryRepo.findAll(user.id); + + expect(transactions).toHaveLength( + transactionSeeds.length - deletedTransactionSeeds.length, + ); + + transactions.forEach((transaction) => { + expect(transaction.isTombstone).toBeFalsy(); + }); + }); }); describe('findById', () => { @@ -275,5 +359,88 @@ describe('TransactionQueryRepository', () => { expect(transaction).toBeNull(); }); + + it('should return null when transaction is soft deleted', async () => { + const transactionSeed: TransactionSeed = { + description: 'Soft deleted transaction', + operations: [ + { + account: usdAccount, + amount: '-300' as MoneyString, + description: 'Soft deleted transaction out', + }, + { + account: usdAccount, + amount: '300' as MoneyString, + description: 'Soft deleted transaction in', + }, + ], + }; + + const insertedTransaction = await createTransaction(transactionSeed); + await testDB.softDeleteTransaction(insertedTransaction.id); + + const transaction = await transactionQueryRepo.findById( + user.id, + insertedTransaction.id, + ); + + expect(transaction).toBeNull(); + }); + + it('should return transaction without soft deleted operations', async () => { + const softDeletedOperations: TransactionSeed['operations'] = [ + { + account: usdAccount, + amount: '-500' as MoneyString, + description: 'Soft deleted operation out', + isTombstone: true, + }, + { + account: usdAccount, + amount: '500' as MoneyString, + description: 'Soft deleted operation in', + isTombstone: true, + }, + ]; + + const activeOperations: TransactionSeed['operations'] = [ + { + account: usdAccount, + amount: '-300' as MoneyString, + description: 'Active operation out', + }, + { + account: usdAccount, + amount: '300' as MoneyString, + description: 'Active operation in', + }, + ]; + + const operations = [...softDeletedOperations, ...activeOperations]; + + const transactionSeed: TransactionSeed = { + description: 'Transaction with soft deleted operation', + operations, + }; + + const insertedTransaction = await createTransaction(transactionSeed); + + const transaction = await transactionQueryRepo.findById( + user.id, + insertedTransaction.id, + ); + + expect(transaction).not.toBeNull(); + + expect(transaction?.operations).toHaveLength(activeOperations.length); + expect(transaction?.operations.map((op) => op.description)).toEqual( + activeOperations.map((op) => op.description), + ); + + transaction?.operations.forEach((op) => { + expect(op.isTombstone).toBe(false); + }); + }); }); }); diff --git a/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.ts b/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.ts index 67549da2..2c9551ea 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.ts @@ -33,9 +33,12 @@ export class TransactionQueryRepository where: and( eq(transactionsTable.id, transactionId), eq(transactionsTable.userId, userId), + eq(transactionsTable.isTombstone, false), ), with: { - operations: true, + operations: { + where: eq(operationsTable.isTombstone, false), + }, }, }); @@ -57,11 +60,17 @@ export class TransactionQueryRepository return this.executeDatabaseOperation( async () => { const transactions = await this.db.query.transactionsTable.findMany({ - where: eq(transactionsTable.userId, userId), + where: and( + eq(transactionsTable.userId, userId), + eq(transactionsTable.isTombstone, false), + ), with: { operations: query?.accountId ? { - where: eq(operationsTable.accountId, query.accountId), + where: and( + eq(operationsTable.accountId, query.accountId), + eq(operationsTable.isTombstone, false), + ), } : true, }, From 92cc0c87ba99b8dd7479aa81da1c9c31ef5d64a6 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Wed, 15 Apr 2026 19:54:47 +0300 Subject: [PATCH 5/5] refactor: update transaction repository and query methods to streamline operation handling and enhance query logic --- apps/backend/src/db/test-db.ts | 1 - .../transaction/transaction-query.repository.ts | 16 ++++++++-------- .../db/transaction/transaction.repository.ts | 4 ++-- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/backend/src/db/test-db.ts b/apps/backend/src/db/test-db.ts index dbd029ea..d0d78f7c 100644 --- a/apps/backend/src/db/test-db.ts +++ b/apps/backend/src/db/test-db.ts @@ -512,7 +512,6 @@ export class TestDB { accountId: accountUSD1.id, amount: Amount.create('10000').valueOf(), description: 'Initial Deposit', - id: transaction1.id, // Using transaction ID as operation ID for simplicity transactionId: transaction1.id, value: Amount.create('10000').valueOf(), }); diff --git a/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.ts b/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.ts index 2c9551ea..f9345a05 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.ts @@ -65,14 +65,14 @@ export class TransactionQueryRepository eq(transactionsTable.isTombstone, false), ), with: { - operations: query?.accountId - ? { - where: and( - eq(operationsTable.accountId, query.accountId), - eq(operationsTable.isTombstone, false), - ), - } - : true, + operations: { + where: and( + ...(query?.accountId + ? [eq(operationsTable.accountId, query.accountId)] + : []), + eq(operationsTable.isTombstone, false), + ), + }, }, }); diff --git a/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts b/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts index 4f8fe936..fa231414 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts @@ -93,8 +93,6 @@ export class TransactionRepository ): Promise { const operations: OperationDbRow[] = []; - const operationsSnapshots = new Map(); - transaction.getOperations().forEach((operation) => { operations.push(OperationMapper.toDBRow(operation)); }); @@ -104,6 +102,8 @@ export class TransactionRepository transaction.getId().valueOf(), ); + const operationsSnapshots = new Map(); + snapshot?.operations.forEach((operationSnapshot) => { operationsSnapshots.set(operationSnapshot.id, operationSnapshot); });