From 8bc67d55a557f13ad6e84dcc20864937c87e3b4d Mon Sep 17 00:00:00 2001 From: gorushkin Date: Fri, 7 Aug 2026 15:12:36 +0300 Subject: [PATCH 01/13] LED-92 feat: remove system flags from account and operation models - Removed `isSystem` flag from Account and Operation entities, snapshots, and persistence layers. - Updated related tests and documentation to reflect the removal of system flags. - Adjusted database schema to eliminate `is_system` columns from accounts and operations tables. - Updated API contracts and validation schemas to remove references to initial balances and system flags. - Enhanced documentation regarding future multi-currency reconciliation plans without reliance on deprecated flags. --- .../drizzle/0000_nostalgic_ben_parker.sql | 6 +- apps/backend/drizzle/meta/0000_snapshot.json | 30 +------- .../interfaces/AccountRepository.interface.ts | 4 +- .../mappers/account.mapper.test.ts | 8 +- .../src/application/mappers/account.mapper.ts | 6 +- .../application/mappers/operation.mapper.ts | 1 - .../transaction-read-model-response.mapper.ts | 1 - .../mappers/transaction.mapper.test.ts | 1 - .../read-models/transaction.read-model.ts | 1 - .../accounts/__tests__/closeAccount.test.ts | 6 +- .../accounts/__tests__/createAccount.test.ts | 11 +-- .../accounts/__tests__/deleteAccount.test.ts | 6 +- .../accounts/__tests__/getAccountById.test.ts | 6 +- .../accounts/__tests__/getAllAccounts.test.ts | 6 +- .../accounts/__tests__/openAccount.test.ts | 6 +- .../accounts/__tests__/updateAccount.test.ts | 6 +- .../__tests__/getAllTransactions.test.ts | 1 - .../__tests__/getTransactionById.test.ts | 3 - apps/backend/src/db/createTestUser.ts | 9 +-- apps/backend/src/db/schemas/accounts.ts | 7 -- apps/backend/src/db/schemas/common.ts | 1 - apps/backend/src/db/schemas/operations.ts | 2 - apps/backend/src/db/test-db.ts | 11 --- .../src/db/test-utils/testEntityBuilder.ts | 1 - .../src/domain/accounts/account-type.enum.ts | 11 --- .../domain/accounts/account.entity.test.ts | 25 +------ .../src/domain/accounts/account.entity.ts | 18 ----- apps/backend/src/domain/accounts/types.ts | 7 +- .../src/domain/operations/operation.entity.ts | 5 -- apps/backend/src/domain/operations/types.ts | 1 - .../account-persistence.mapper.test.ts | 11 +-- .../db/accounts/account-persistence.mapper.ts | 6 -- .../db/accounts/account.repository.test.ts | 10 +-- .../db/accounts/account.repository.ts | 2 - .../operation-persistence.mapper.test.ts | 1 - .../operation-persistence.mapper.ts | 1 - .../transaction-query.repository.test.ts | 2 - .../transaction-read-model.mapper.ts | 1 - .../transaction.repository.test.ts | 2 - .../http/accounts/account.controller.test.ts | 2 - .../http/accounts/account.integration.test.ts | 32 -------- docs/DATABASE_SCHEMA.md | 15 ++-- docs/DOMAIN.md | 26 +++---- docs/MULTICURRENCY_DESIGN.md | 23 +++--- ...gs-from-account-and-operation-contracts.md | 74 +++++++++++++++++++ docs/architecture/adr/README.md | 1 + packages/shared/src/types/account.ts | 6 +- packages/shared/src/types/transaction.ts | 1 - .../shared/src/validation/accounts.test.ts | 1 - packages/shared/src/validation/accounts.ts | 2 - restAPI/accounts/create.bru | 5 +- 51 files changed, 119 insertions(+), 312 deletions(-) create mode 100644 docs/architecture/adr/0022-remove-system-flags-from-account-and-operation-contracts.md diff --git a/apps/backend/drizzle/0000_nostalgic_ben_parker.sql b/apps/backend/drizzle/0000_nostalgic_ben_parker.sql index 98e606fe..81f0ea18 100644 --- a/apps/backend/drizzle/0000_nostalgic_ben_parker.sql +++ b/apps/backend/drizzle/0000_nostalgic_ben_parker.sql @@ -1,12 +1,9 @@ CREATE TABLE `accounts` ( `commodity_id` text NOT NULL, `created_at` text NOT NULL, - `current_cleared_balance_local` text NOT NULL, `description` text NOT NULL, `id` text PRIMARY KEY NOT NULL, - `initial_balance` text NOT NULL, `is_closed` integer NOT NULL, - `is_system` integer NOT NULL, `is_tombstone` integer NOT NULL, `name` text NOT NULL, `type` text NOT NULL, @@ -41,7 +38,6 @@ CREATE TABLE `operations` ( `created_at` text NOT NULL, `description` text NOT NULL, `id` text PRIMARY KEY NOT NULL, - `is_system` integer NOT NULL, `is_tombstone` integer NOT NULL, `transaction_id` text NOT NULL, `updated_at` text NOT NULL, @@ -87,4 +83,4 @@ CREATE TABLE `commodities` ( ); --> statement-breakpoint CREATE UNIQUE INDEX `commodities_user_id_code_unique_idx` ON `commodities` (`user_id`,`code`);--> statement-breakpoint -CREATE UNIQUE INDEX `commodities_user_id_id_unique_idx` ON `commodities` (`user_id`,`id`); \ No newline at end of file +CREATE UNIQUE INDEX `commodities_user_id_id_unique_idx` ON `commodities` (`user_id`,`id`); diff --git a/apps/backend/drizzle/meta/0000_snapshot.json b/apps/backend/drizzle/meta/0000_snapshot.json index 6919cde4..6ee07a4a 100644 --- a/apps/backend/drizzle/meta/0000_snapshot.json +++ b/apps/backend/drizzle/meta/0000_snapshot.json @@ -21,13 +21,6 @@ "notNull": true, "autoincrement": false }, - "current_cleared_balance_local": { - "name": "current_cleared_balance_local", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, "description": { "name": "description", "type": "text", @@ -42,13 +35,6 @@ "notNull": true, "autoincrement": false }, - "initial_balance": { - "name": "initial_balance", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, "is_closed": { "name": "is_closed", "type": "integer", @@ -56,13 +42,6 @@ "notNull": true, "autoincrement": false }, - "is_system": { - "name": "is_system", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, "is_tombstone": { "name": "is_tombstone", "type": "integer", @@ -315,13 +294,6 @@ "notNull": true, "autoincrement": false }, - "is_system": { - "name": "is_system", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, "is_tombstone": { "name": "is_tombstone", "type": "integer", @@ -657,4 +629,4 @@ "internal": { "indexes": {} } -} \ No newline at end of file +} diff --git a/apps/backend/src/application/interfaces/AccountRepository.interface.ts b/apps/backend/src/application/interfaces/AccountRepository.interface.ts index 49a66ce2..679cfc42 100644 --- a/apps/backend/src/application/interfaces/AccountRepository.interface.ts +++ b/apps/backend/src/application/interfaces/AccountRepository.interface.ts @@ -3,9 +3,7 @@ import { AccountQuery } from '@ledgerly/shared/validation'; import { AccountSnapshot } from 'src/domain/accounts'; export type AccountRepositoryUpdateInput = Pick & - Partial< - Pick - >; + Partial>; export type AccountRepositoryLifecycleInput = { updatedAt: IsoDatetimeString }; diff --git a/apps/backend/src/application/mappers/account.mapper.test.ts b/apps/backend/src/application/mappers/account.mapper.test.ts index a22b14d4..61a5b9d1 100644 --- a/apps/backend/src/application/mappers/account.mapper.test.ts +++ b/apps/backend/src/application/mappers/account.mapper.test.ts @@ -1,6 +1,6 @@ import { AccountUpdateDTO } from '@ledgerly/shared/types'; import { AccountSnapshot } from 'src/domain/accounts'; -import { Amount, Id, Timestamp } from 'src/domain/domain-core'; +import { Id, Timestamp } from 'src/domain/domain-core'; import { describe, expect, it } from 'vitest'; import { AccountMapper } from './account.mapper'; @@ -9,12 +9,9 @@ describe('AccountMapper', () => { const snapshot: AccountSnapshot = { commodityId: Id.create().valueOf(), createdAt: Timestamp.create().valueOf(), - currentClearedBalanceLocal: Amount.create('2500').valueOf(), description: 'Operating account', id: Id.create().valueOf(), - initialBalance: Amount.create('1000').valueOf(), isClosed: false, - isSystem: true, isTombstone: false, name: 'Primary Checking', type: 'asset', @@ -26,12 +23,9 @@ describe('AccountMapper', () => { expect(AccountMapper.toResponseDTOFromSnapshot(snapshot)).toEqual({ commodityId: snapshot.commodityId, createdAt: snapshot.createdAt, - currentClearedBalanceLocal: snapshot.currentClearedBalanceLocal, description: snapshot.description, id: snapshot.id, - initialBalance: snapshot.initialBalance, isClosed: snapshot.isClosed, - isSystem: snapshot.isSystem, isTombstone: snapshot.isTombstone, name: snapshot.name, type: snapshot.type, diff --git a/apps/backend/src/application/mappers/account.mapper.ts b/apps/backend/src/application/mappers/account.mapper.ts index 68461296..4c1284e5 100644 --- a/apps/backend/src/application/mappers/account.mapper.ts +++ b/apps/backend/src/application/mappers/account.mapper.ts @@ -9,7 +9,7 @@ import { AccountUpdateProps, CreateAccountProps, } from 'src/domain/accounts'; -import { Amount, Id, Name } from 'src/domain/domain-core'; +import { Id, Name } from 'src/domain/domain-core'; export class AccountMapper { static toResponseDTOFromSnapshot( @@ -18,12 +18,9 @@ export class AccountMapper { return { commodityId: snapshot.commodityId, createdAt: snapshot.createdAt, - currentClearedBalanceLocal: snapshot.currentClearedBalanceLocal, description: snapshot.description, id: snapshot.id, - initialBalance: snapshot.initialBalance, isClosed: snapshot.isClosed, - isSystem: snapshot.isSystem, isTombstone: snapshot.isTombstone, name: snapshot.name, type: snapshot.type, @@ -35,7 +32,6 @@ export class AccountMapper { return { commodityId: Id.restore(dto.commodityId), description: dto.description, - initialBalance: Amount.create(dto.initialBalance), name: Name.create(dto.name), type: AccountType.create(dto.type), }; diff --git a/apps/backend/src/application/mappers/operation.mapper.ts b/apps/backend/src/application/mappers/operation.mapper.ts index 39ce00f5..906f64f9 100644 --- a/apps/backend/src/application/mappers/operation.mapper.ts +++ b/apps/backend/src/application/mappers/operation.mapper.ts @@ -35,7 +35,6 @@ export class OperationMapper { createdAt: snapshot.createdAt, description: snapshot.description, id: snapshot.id, - isSystem: snapshot.isSystem, transactionId: snapshot.transactionId, updatedAt: snapshot.updatedAt, userId: snapshot.userId, diff --git a/apps/backend/src/application/mappers/transaction-read-model-response.mapper.ts b/apps/backend/src/application/mappers/transaction-read-model-response.mapper.ts index 17734c11..a874450a 100644 --- a/apps/backend/src/application/mappers/transaction-read-model-response.mapper.ts +++ b/apps/backend/src/application/mappers/transaction-read-model-response.mapper.ts @@ -30,7 +30,6 @@ export class TransactionReadModelResponseMapper { createdAt: operation.createdAt, description: operation.description, id: operation.id, - isSystem: operation.isSystem, transactionId: operation.transactionId, updatedAt: operation.updatedAt, userId: operation.userId, diff --git a/apps/backend/src/application/mappers/transaction.mapper.test.ts b/apps/backend/src/application/mappers/transaction.mapper.test.ts index b86f0bc3..45aa0bdc 100644 --- a/apps/backend/src/application/mappers/transaction.mapper.test.ts +++ b/apps/backend/src/application/mappers/transaction.mapper.test.ts @@ -74,7 +74,6 @@ describe('TransactionMapper', () => { createdAt: activeOperation.createdAt, description: activeOperation.description, id: activeOperation.id, - isSystem: activeOperation.isSystem, transactionId: activeOperation.transactionId, updatedAt: activeOperation.updatedAt, userId: activeOperation.userId, diff --git a/apps/backend/src/application/read-models/transaction.read-model.ts b/apps/backend/src/application/read-models/transaction.read-model.ts index 61e3e422..e67666eb 100644 --- a/apps/backend/src/application/read-models/transaction.read-model.ts +++ b/apps/backend/src/application/read-models/transaction.read-model.ts @@ -11,7 +11,6 @@ export type OperationReadModel = { createdAt: IsoDatetimeString; description: string; id: UUID; - isSystem: boolean; transactionId: UUID; updatedAt: IsoDatetimeString; userId: UUID; diff --git a/apps/backend/src/application/usecases/accounts/__tests__/closeAccount.test.ts b/apps/backend/src/application/usecases/accounts/__tests__/closeAccount.test.ts index 12ab603e..64ece421 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/closeAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/closeAccount.test.ts @@ -9,7 +9,7 @@ import type { import { AccountMapper } from 'src/application/mappers'; import { createUser } from 'src/db/createTestUser'; import { AccountSnapshot } from 'src/domain/accounts'; -import { Amount, Timestamp } from 'src/domain/domain-core'; +import { Timestamp } from 'src/domain/domain-core'; import { Id } from 'src/domain/domain-core/value-objects/Id'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -41,7 +41,6 @@ describe('CloseAccountUseCase', async () => { const accountName = 'Test Account'; const description = 'Test account description'; - const initialBalance = Amount.create('1000').valueOf(); const accountType = 'asset'; const mockUser = { @@ -54,12 +53,9 @@ describe('CloseAccountUseCase', async () => { const mockAccountData: AccountSnapshot = { commodityId: Id.create().valueOf(), createdAt: Timestamp.create().valueOf(), - currentClearedBalanceLocal: initialBalance, description, id: Id.create().valueOf(), - initialBalance, isClosed: false, - isSystem: false, isTombstone: false, name: accountName, type: accountType, diff --git a/apps/backend/src/application/usecases/accounts/__tests__/createAccount.test.ts b/apps/backend/src/application/usecases/accounts/__tests__/createAccount.test.ts index 1e03d6b8..db8ca866 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/createAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/createAccount.test.ts @@ -5,7 +5,7 @@ import type { import { CommodityReferencePolicy } from 'src/application/services'; import { createUser } from 'src/db/createTestUser'; import { Commodity } from 'src/domain'; -import { Amount, CommodityCode } from 'src/domain/domain-core'; +import { CommodityCode } from 'src/domain/domain-core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CreateAccountUseCase } from '../createAccount'; @@ -29,8 +29,6 @@ describe('CreateAccountUseCase', async () => { const name = 'Test Account'; const description = 'Test account description'; - const initialBalance = Amount.create('1000').valueOf(); - const currentClearedBalanceLocal = Amount.create('0').valueOf(); const commodity = Commodity.create(user, { code: CommodityCode.create('USD').valueOf(), @@ -61,7 +59,6 @@ describe('CreateAccountUseCase', async () => { const result = await createAccountUseCase.execute(user, { commodityId: mockedCommoditySnapshot.id, description, - initialBalance, name, type, }); @@ -78,12 +75,9 @@ describe('CreateAccountUseCase', async () => { { commodityId: result.commodityId, createdAt: result.createdAt, - currentClearedBalanceLocal, description, id: result.id, - initialBalance, isClosed: false, - isSystem: false, isTombstone: false, name, type, @@ -94,8 +88,6 @@ describe('CreateAccountUseCase', async () => { expect(result).toMatchObject({ description, - initialBalance, - isSystem: false, name, type, userId: user.getId().valueOf(), @@ -114,7 +106,6 @@ describe('CreateAccountUseCase', async () => { createAccountUseCase.execute(user, { commodityId: mockedCommoditySnapshot.id, description, - initialBalance, name, type, }), diff --git a/apps/backend/src/application/usecases/accounts/__tests__/deleteAccount.test.ts b/apps/backend/src/application/usecases/accounts/__tests__/deleteAccount.test.ts index 0c01ee1c..3a252818 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/deleteAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/deleteAccount.test.ts @@ -11,7 +11,7 @@ import { AccountOperationPolicy } from 'src/application/services'; import { ensureOwnedSnapshot } from 'src/application/shared/ensureOwnedSnapshot'; import { createUser } from 'src/db/createTestUser'; import { AccountSnapshot } from 'src/domain/accounts'; -import { Amount, Timestamp } from 'src/domain/domain-core'; +import { Timestamp } from 'src/domain/domain-core'; import { Id } from 'src/domain/domain-core/value-objects/Id'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -43,18 +43,14 @@ describe('DeleteAccountUseCase', async () => { const accountName = 'Test Account'; const description = 'Test account description'; - const initialBalance = Amount.create('1000').valueOf(); const accountType = 'asset'; const mockAccountData: AccountSnapshot = { commodityId: Id.create().valueOf(), createdAt: Timestamp.create().valueOf(), - currentClearedBalanceLocal: initialBalance, description, id: Id.create().valueOf(), - initialBalance, isClosed: false, - isSystem: false, isTombstone: false, name: accountName, type: accountType, diff --git a/apps/backend/src/application/usecases/accounts/__tests__/getAccountById.test.ts b/apps/backend/src/application/usecases/accounts/__tests__/getAccountById.test.ts index 3f914572..fb296df8 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/getAccountById.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/getAccountById.test.ts @@ -7,7 +7,7 @@ import type { AccountRepositoryInterface } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { createUser } from 'src/db/createTestUser'; import { Account, AccountSnapshot } from 'src/domain/accounts'; -import { Amount, Timestamp } from 'src/domain/domain-core'; +import { Timestamp } from 'src/domain/domain-core'; import { Id } from 'src/domain/domain-core/value-objects/Id'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -29,7 +29,6 @@ describe('GetAccountByIdUseCase', async () => { ).valueOf(); const accountName = 'Test Account'; const description = 'Test account description'; - const initialBalance = Amount.create('1000').valueOf(); const accountType = 'asset' as AccountTypeValue; const mockUser = { @@ -42,12 +41,9 @@ describe('GetAccountByIdUseCase', async () => { const mockSavedAccountData: AccountSnapshot = { commodityId: Id.create().valueOf(), createdAt: Timestamp.create().valueOf(), - currentClearedBalanceLocal: initialBalance, description, id: accountId, - initialBalance, isClosed: false, - isSystem: false, isTombstone: false, name: accountName, type: accountType, diff --git a/apps/backend/src/application/usecases/accounts/__tests__/getAllAccounts.test.ts b/apps/backend/src/application/usecases/accounts/__tests__/getAllAccounts.test.ts index 9827daca..f07518d8 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/getAllAccounts.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/getAllAccounts.test.ts @@ -4,7 +4,7 @@ import type { AccountRepositoryInterface } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { createUser } from 'src/db/createTestUser'; import { AccountSnapshot } from 'src/domain/accounts'; -import { Amount, Timestamp } from 'src/domain/domain-core'; +import { Timestamp } from 'src/domain/domain-core'; import { Id } from 'src/domain/domain-core/value-objects/Id'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -25,18 +25,14 @@ describe('GetAllAccounts', async () => { const accountId = Id.restore( '550e8400-e29b-41d4-a716-446655440001', ).valueOf(); - const initialBalance = Amount.create('1000').valueOf(); const accountType = 'asset' as AccountTypeValue; const mockSavedAccountData: AccountSnapshot = { commodityId: Id.create().valueOf(), createdAt: Timestamp.create().valueOf(), - currentClearedBalanceLocal: initialBalance, description, id: accountId, - initialBalance, isClosed: false, - isSystem: false, isTombstone: false, name: accountName, type: accountType, diff --git a/apps/backend/src/application/usecases/accounts/__tests__/openAccount.test.ts b/apps/backend/src/application/usecases/accounts/__tests__/openAccount.test.ts index 6f342683..92ee3bf4 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/openAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/openAccount.test.ts @@ -9,7 +9,7 @@ import type { import { AccountMapper } from 'src/application/mappers'; import { createUser } from 'src/db/createTestUser'; import { AccountSnapshot } from 'src/domain/accounts'; -import { Amount, Timestamp } from 'src/domain/domain-core'; +import { Timestamp } from 'src/domain/domain-core'; import { Id } from 'src/domain/domain-core/value-objects/Id'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -41,7 +41,6 @@ describe('OpenAccountUseCase', async () => { const accountName = 'Test Account'; const description = 'Test account description'; - const initialBalance = Amount.create('1000').valueOf(); const accountType = 'asset'; const mockUser = { @@ -54,12 +53,9 @@ describe('OpenAccountUseCase', async () => { const mockAccountData: AccountSnapshot = { commodityId: Id.create().valueOf(), createdAt: Timestamp.create().valueOf(), - currentClearedBalanceLocal: initialBalance, description, id: Id.create().valueOf(), - initialBalance, isClosed: true, - isSystem: false, isTombstone: false, name: accountName, type: accountType, diff --git a/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts b/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts index 9e504e7f..2ee5f154 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts @@ -11,7 +11,7 @@ import { AccountMapper } from 'src/application/mappers'; import { AccountOperationPolicy } from 'src/application/services'; import { createUser } from 'src/db/createTestUser'; import { Account, AccountSnapshot } from 'src/domain/accounts'; -import { Amount, Timestamp } from 'src/domain/domain-core'; +import { Timestamp } from 'src/domain/domain-core'; import { Id } from 'src/domain/domain-core/value-objects/Id'; import { ClosedAccountOperationError } from 'src/domain/domain.errors'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -45,18 +45,14 @@ describe('UpdateAccount', async () => { const accountName = 'Test Account'; const description = 'Test account description'; - const initialBalance = Amount.create('1000').valueOf(); const accountType = 'asset' as AccountTypeValue; const mockAccountData: AccountSnapshot = { commodityId: Id.create().valueOf(), createdAt: Timestamp.create().valueOf(), - currentClearedBalanceLocal: initialBalance, description, id: accountId, - initialBalance, isClosed: false, - isSystem: false, isTombstone: false, name: accountName, type: accountType, diff --git a/apps/backend/src/application/usecases/transaction/__tests__/getAllTransactions.test.ts b/apps/backend/src/application/usecases/transaction/__tests__/getAllTransactions.test.ts index 8903aa97..517dd22f 100644 --- a/apps/backend/src/application/usecases/transaction/__tests__/getAllTransactions.test.ts +++ b/apps/backend/src/application/usecases/transaction/__tests__/getAllTransactions.test.ts @@ -45,7 +45,6 @@ describe('GetAllTransactionsUseCase', () => { createdAt: Timestamp.create().valueOf(), description: 'Operation', id: Id.create().valueOf(), - isSystem: false, transactionId, updatedAt: Timestamp.create().valueOf(), userId, diff --git a/apps/backend/src/application/usecases/transaction/__tests__/getTransactionById.test.ts b/apps/backend/src/application/usecases/transaction/__tests__/getTransactionById.test.ts index f286e251..8cbf0db8 100644 --- a/apps/backend/src/application/usecases/transaction/__tests__/getTransactionById.test.ts +++ b/apps/backend/src/application/usecases/transaction/__tests__/getTransactionById.test.ts @@ -37,7 +37,6 @@ describe('GetTransactionByIdUseCase', () => { createdAt: Timestamp.create().valueOf(), description: 'Operation 1', id: Id.create().valueOf(), - isSystem: false, transactionId, updatedAt: Timestamp.create().valueOf(), userId, @@ -50,7 +49,6 @@ describe('GetTransactionByIdUseCase', () => { createdAt: Timestamp.create().valueOf(), description: 'Operation 2', id: Id.create().valueOf(), - isSystem: false, transactionId, updatedAt: Timestamp.create().valueOf(), userId, @@ -111,7 +109,6 @@ describe('GetTransactionByIdUseCase', () => { 'createdAt', 'description', 'id', - 'isSystem', 'transactionId', 'updatedAt', 'userId', diff --git a/apps/backend/src/db/createTestUser.ts b/apps/backend/src/db/createTestUser.ts index 95d0db01..ee87cf26 100644 --- a/apps/backend/src/db/createTestUser.ts +++ b/apps/backend/src/db/createTestUser.ts @@ -1,11 +1,5 @@ import { Account, AccountType, Commodity } from 'src/domain'; -import { - Name, - Email, - Password, - Amount, - CommodityCode, -} from 'src/domain/domain-core'; +import { Name, Email, Password, CommodityCode } from 'src/domain/domain-core'; import { User } from 'src/domain/users/user.entity'; export const createUser = async ( @@ -65,7 +59,6 @@ export const createAccount = ( return Account.create(user, { commodityId: commodity.getId(), description: params.description ?? 'Account for testing', - initialBalance: Amount.create('0'), name: Name.create(params.name ?? 'Test Account'), type: AccountType.create('asset'), }); diff --git a/apps/backend/src/db/schemas/accounts.ts b/apps/backend/src/db/schemas/accounts.ts index 7a39375f..669a390a 100644 --- a/apps/backend/src/db/schemas/accounts.ts +++ b/apps/backend/src/db/schemas/accounts.ts @@ -16,8 +16,6 @@ import { id, isTombstone, isClosed, - getAmountColumn, - isSystem, } from './common'; import { usersTable } from './users'; @@ -26,14 +24,9 @@ export const accountsTable = sqliteTable( { commodityId: text('commodity_id').notNull().$type(), createdAt, - currentClearedBalanceLocal: getAmountColumn( - 'current_cleared_balance_local', - ), description, id, - initialBalance: getAmountColumn('initial_balance'), isClosed, - isSystem, isTombstone, name: text('name').notNull(), type: text('type', { diff --git a/apps/backend/src/db/schemas/common.ts b/apps/backend/src/db/schemas/common.ts index 03f5c8fe..3e4375ba 100644 --- a/apps/backend/src/db/schemas/common.ts +++ b/apps/backend/src/db/schemas/common.ts @@ -28,5 +28,4 @@ export const getAmountColumn = getNumericColumn; export const getIsoDateString = (column: string) => text(column).notNull().$type(); -export const isSystem = getBooleanColumn('is_system'); export const version = integer('version').notNull(); diff --git a/apps/backend/src/db/schemas/operations.ts b/apps/backend/src/db/schemas/operations.ts index 5f987d48..f5b4891f 100644 --- a/apps/backend/src/db/schemas/operations.ts +++ b/apps/backend/src/db/schemas/operations.ts @@ -10,7 +10,6 @@ import { isTombstone, id, getAmountColumn, - isSystem, } from './common'; import { transactionsTable } from './transactions'; import { usersTable } from './users'; @@ -23,7 +22,6 @@ export const operationsTable = sqliteTable( createdAt, description, id, - isSystem, isTombstone, transactionId: text('transaction_id').notNull().$type(), updatedAt, diff --git a/apps/backend/src/db/test-db.ts b/apps/backend/src/db/test-db.ts index ea35d300..b59bedbe 100644 --- a/apps/backend/src/db/test-db.ts +++ b/apps/backend/src/db/test-db.ts @@ -257,7 +257,6 @@ export class TestDB { transactionId?: UUID; amount: AmountString; value: AmountString; - isSystem?: boolean; isTombstone?: boolean; id: UUID; }[]; @@ -302,7 +301,6 @@ export class TestDB { amount: Amount.create(operation.amount).valueOf(), description: operation.description, id: crypto.randomUUID() as UUID, - isSystem: false, isTombstone: operation.isTombstone ?? false, value: Amount.create(operation.value ?? operation.amount).valueOf(), })), @@ -320,12 +318,10 @@ export class TestDB { id?: UUID; amount?: AmountString; value?: AmountString; - isSystem?: boolean; isTombstone?: boolean; }, ) => { const operationData: OperationDbInsert = { - isSystem: params?.isSystem ?? false, ...TestDB.uuid, ...TestDB.createTimestamps, amount: params?.amount ?? Amount.create('1000').valueOf(), @@ -434,18 +430,14 @@ export class TestDB { params?: { name?: string; type?: AccountTypeValue; - initialBalance?: AmountString; description?: string; - isSystem?: boolean; isTombstone?: boolean; isClosed?: boolean; }, ) => { const accountData = { description: '', - initialBalance: Amount.create('0').valueOf(), isClosed: false, - isSystem: false, isTombstone: false, name: `Test Account ${this.accountCounter.getNextName()}`, type: ACCOUNT_TYPES[0], @@ -457,9 +449,7 @@ export class TestDB { .insert(accountsTable) .values({ commodityId, - currentClearedBalanceLocal: accountData.initialBalance ?? 0, description: accountData.description || '', - initialBalance: accountData.initialBalance ?? 0, isClosed: accountData.isClosed ?? false, isTombstone: accountData.isTombstone ?? false, name: accountData.name, @@ -467,7 +457,6 @@ export class TestDB { userId: accountData.userId, ...TestDB.createTimestamps, ...TestDB.uuid, - isSystem: accountData.isSystem, }) .returning() .get(); diff --git a/apps/backend/src/db/test-utils/testEntityBuilder.ts b/apps/backend/src/db/test-utils/testEntityBuilder.ts index b331d63e..3e511ea0 100644 --- a/apps/backend/src/db/test-utils/testEntityBuilder.ts +++ b/apps/backend/src/db/test-utils/testEntityBuilder.ts @@ -162,7 +162,6 @@ export class TransactionBuilder { const account = Account.create(this.user, { commodityId: commodity.getId(), description: `Account ${commodityCode}`, - initialBalance: Amount.create('0'), name: Name.create(`Account ${commodityCode}`), type: AccountType.create('asset'), }); diff --git a/apps/backend/src/domain/accounts/account-type.enum.ts b/apps/backend/src/domain/accounts/account-type.enum.ts index e7d04b7e..47472067 100644 --- a/apps/backend/src/domain/accounts/account-type.enum.ts +++ b/apps/backend/src/domain/accounts/account-type.enum.ts @@ -17,18 +17,11 @@ const ACCOUNT_TYPES: AccountTypeValue[] = [ 'currencyTrading', ]; -const SYSTEM_ACCOUNT_TYPES: AccountTypeValue[] = ['currencyTrading']; - const ACCOUNT_TYPE_VALUES = ACCOUNT_TYPES as [ AccountTypeValue, ...AccountTypeValue[], ]; -const SYSTEM_ACCOUNT_TYPE_VALUES = SYSTEM_ACCOUNT_TYPES as [ - AccountTypeValue, - ...AccountTypeValue[], -]; - export class AccountType { private constructor(private readonly value: AccountTypeValue) { Object.freeze(this); @@ -57,8 +50,4 @@ export class AccountType { valueOf(): AccountTypeValue { return this.value; } - - isSystemType(): boolean { - return SYSTEM_ACCOUNT_TYPE_VALUES.includes(this.value); - } } diff --git a/apps/backend/src/domain/accounts/account.entity.test.ts b/apps/backend/src/domain/accounts/account.entity.test.ts index d7fdddb4..38acf283 100644 --- a/apps/backend/src/domain/accounts/account.entity.test.ts +++ b/apps/backend/src/domain/accounts/account.entity.test.ts @@ -5,13 +5,7 @@ import { Commodity, DeletedEntityOperationError, } from 'src/domain/'; -import { - Amount, - Name, - Id, - Timestamp, - CommodityCode, -} from 'src/domain/domain-core/'; +import { Name, Id, Timestamp, CommodityCode } from 'src/domain/domain-core/'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { User } from '../users/user.entity'; @@ -51,7 +45,6 @@ describe('Account Domain Entity', () => { const account = Account.create(user, { commodityId: commodity.getId(), description, - initialBalance: Amount.create('0'), name, type: accountType, }); @@ -61,7 +54,6 @@ describe('Account Domain Entity', () => { expect(account.belongsToUser(userId)).toBe(true); expect(account).toHaveProperty('name', name); expect(account).toHaveProperty('description', description); - expect(account).toHaveProperty('initialBalance', Amount.create('0')); expect(account.getType().valueOf()).toBe(accountType.valueOf()); expect(account.belongsToUser(userId)).toBe(true); expect(account.getType().equals(accountType)).toBe(true); @@ -86,12 +78,9 @@ describe('Account Domain Entity', () => { const account = Account.restore({ commodityId: commodity.getId().valueOf(), createdAt: createdAt.valueOf(), - currentClearedBalanceLocal: Amount.create('500').valueOf(), description: 'restored-description', id: accountId.valueOf(), - initialBalance: Amount.create('500').valueOf(), isClosed: false, - isSystem: false, isTombstone: false, name: 'restored-account', type: userTypeValue, @@ -104,7 +93,6 @@ describe('Account Domain Entity', () => { expect(account.belongsToUser(userId)).toBe(true); expect(account).toHaveProperty('name', Name.create('restored-account')); expect(account).toHaveProperty('description', 'restored-description'); - expect(account).toHaveProperty('initialBalance', Amount.create('500')); expect(account).toHaveProperty( 'commodityRelation', expect.objectContaining({ parentId: commodity.getId() }), @@ -117,7 +105,6 @@ describe('Account Domain Entity', () => { const account = Account.create(user, { commodityId: commodity.getId(), description: 'description', - initialBalance: Amount.create('0'), name, type: accountType, }); @@ -131,7 +118,6 @@ describe('Account Domain Entity', () => { const account = Account.create(user, { commodityId: commodity.getId(), description: 'description', - initialBalance: Amount.create('0'), name, type: accountType, }); @@ -149,7 +135,6 @@ describe('Account Domain Entity', () => { const account = Account.create(user, { commodityId: commodity.getId(), description: 'account-description', - initialBalance: Amount.create('0'), name, type: accountType, }); @@ -165,7 +150,6 @@ describe('Account Domain Entity', () => { const account = Account.create(user, { commodityId: commodity.getId(), description: 'account-description', - initialBalance: Amount.create('0'), name, type: accountType, }); @@ -181,7 +165,6 @@ describe('Account Domain Entity', () => { const account = Account.create(user, { commodityId: commodity.getId(), description: 'account-description', - initialBalance: Amount.create('0'), name, type: accountType, }); @@ -209,7 +192,6 @@ describe('Account Domain Entity', () => { const account = Account.create(user, { commodityId: commodity.getId(), description: 'account-description', - initialBalance: Amount.create('0'), name, type: accountType, }); @@ -241,7 +223,6 @@ describe('Account Domain Entity', () => { const account = Account.create(user, { commodityId: commodity.getId(), description: 'account-description', - initialBalance: Amount.create('0'), name, type: accountType, }); @@ -263,7 +244,6 @@ describe('Account Domain Entity', () => { const account = Account.create(user, { commodityId: commodity.getId(), description: 'account-description', - initialBalance: Amount.create('0'), name, type: accountType, }); @@ -287,7 +267,6 @@ describe('Account Domain Entity', () => { const account = Account.create(user, { commodityId: commodity.getId(), description: 'account-description', - initialBalance: Amount.create('0'), name, type: accountType, }); @@ -296,6 +275,4 @@ describe('Account Domain Entity', () => { expect(() => account.close()).toThrowError(DeletedEntityOperationError); }); - - it.todo('add test to cover isSystem property'); }); diff --git a/apps/backend/src/domain/accounts/account.entity.ts b/apps/backend/src/domain/accounts/account.entity.ts index 523ec7f3..2d4b3659 100644 --- a/apps/backend/src/domain/accounts/account.entity.ts +++ b/apps/backend/src/domain/accounts/account.entity.ts @@ -1,6 +1,5 @@ import { Commodity } from '../commodities'; import { - Amount, Id, Timestamp, Name, @@ -30,11 +29,8 @@ export class Account { private readonly commodityRelation: ParentChildRelation, public name: Name, public description: string, - private initialBalance: Amount, - private currentClearedBalanceLocal: Amount, private type: AccountType, private isClosed: boolean, - public isSystem: boolean, ) {} static create(user: User, props: CreateAccountProps): Account { @@ -52,8 +48,6 @@ export class Account { identity.getId(), ); - const isSystem = props.type.isSystemType(); - return new Account( identity, timestamps, @@ -62,11 +56,8 @@ export class Account { commodityRelation, props.name, props.description, - props.initialBalance, - Amount.create('0'), props.type, false, - isSystem, ); } @@ -74,12 +65,9 @@ export class Account { const { commodityId, createdAt, - currentClearedBalanceLocal, description, id, - initialBalance, isClosed, - isSystem, isTombstone, name, type, @@ -114,11 +102,8 @@ export class Account { commodityRelation, Name.restore(name), description, - Amount.restore(initialBalance), - Amount.restore(currentClearedBalanceLocal), AccountType.restore(type), isClosed, - isSystem, ); } @@ -174,12 +159,9 @@ export class Account { return { commodityId: this.commodityRelation.getParentId().valueOf(), createdAt: this.getCreatedAt().valueOf(), - currentClearedBalanceLocal: this.currentClearedBalanceLocal.valueOf(), description: this.description, id: this.getId().valueOf(), - initialBalance: this.initialBalance.valueOf(), isClosed: this.isClosed, - isSystem: this.isSystem, isTombstone: this.softDelete.getIsTombstone(), name: this.name.valueOf(), type: this.type.valueOf(), diff --git a/apps/backend/src/domain/accounts/types.ts b/apps/backend/src/domain/accounts/types.ts index 30c02a96..f76180dd 100644 --- a/apps/backend/src/domain/accounts/types.ts +++ b/apps/backend/src/domain/accounts/types.ts @@ -1,22 +1,18 @@ import { AccountTypeValue, IsoDatetimeString, - AmountString, UUID, } from '@ledgerly/shared/types'; -import { Amount, Id, Name } from '../domain-core'; +import { Id, Name } from '../domain-core'; import { AccountType } from './account-type.enum'; export type AccountSnapshot = { createdAt: IsoDatetimeString; - currentClearedBalanceLocal: AmountString; commodityId: UUID; description: string; id: UUID; - initialBalance: AmountString; - isSystem: boolean; isTombstone: boolean; isClosed: boolean; name: string; @@ -34,7 +30,6 @@ export type AccountUpdateProps = Partial<{ export type CreateAccountProps = { commodityId: Id; description: string; - initialBalance: Amount; name: Name; type: AccountType; }; diff --git a/apps/backend/src/domain/operations/operation.entity.ts b/apps/backend/src/domain/operations/operation.entity.ts index 00b475f3..f0a22727 100644 --- a/apps/backend/src/domain/operations/operation.entity.ts +++ b/apps/backend/src/domain/operations/operation.entity.ts @@ -29,7 +29,6 @@ export class Operation { public amount: Amount, public value: Amount, public description: string, - public readonly isSystem: boolean, ) {} static create( @@ -66,7 +65,6 @@ export class Operation { amount, value, description, - account.isSystem, ); } @@ -77,7 +75,6 @@ export class Operation { createdAt, description, id, - isSystem, isTombstone, transactionId, updatedAt, @@ -118,7 +115,6 @@ export class Operation { Amount.restore(amount), Amount.restore(value), description, - isSystem, ); } @@ -168,7 +164,6 @@ export class Operation { createdAt: this.getCreatedAt().valueOf(), description: this.description, id: this.getId().valueOf(), - isSystem: this.isSystem, isTombstone: this.softDelete.getIsTombstone(), transactionId: this.transactionRelation.getParentId().valueOf(), updatedAt: this.getUpdatedAt().valueOf(), diff --git a/apps/backend/src/domain/operations/types.ts b/apps/backend/src/domain/operations/types.ts index bbdd57d2..ae4cce1f 100644 --- a/apps/backend/src/domain/operations/types.ts +++ b/apps/backend/src/domain/operations/types.ts @@ -10,7 +10,6 @@ export type OperationSnapshot = { isTombstone: boolean; updatedAt: IsoDatetimeString; userId: UUID; - isSystem: boolean; accountId: UUID; amount: AmountString; value: AmountString; diff --git a/apps/backend/src/infrastructure/db/accounts/account-persistence.mapper.test.ts b/apps/backend/src/infrastructure/db/accounts/account-persistence.mapper.test.ts index 3d56c755..543e0d6b 100644 --- a/apps/backend/src/infrastructure/db/accounts/account-persistence.mapper.test.ts +++ b/apps/backend/src/infrastructure/db/accounts/account-persistence.mapper.test.ts @@ -1,5 +1,5 @@ import { AccountDbRow } from 'src/db/schema'; -import { Amount, Id, Timestamp } from 'src/domain/domain-core'; +import { Id, Timestamp } from 'src/domain/domain-core'; import { describe, expect, it } from 'vitest'; import { AccountPersistenceMapper } from './account-persistence.mapper'; @@ -8,12 +8,9 @@ describe('AccountPersistenceMapper', () => { const row: AccountDbRow = { commodityId: Id.create().valueOf(), createdAt: Timestamp.create().valueOf(), - currentClearedBalanceLocal: Amount.create('2500').valueOf(), description: 'Operating account', id: Id.create().valueOf(), - initialBalance: Amount.create('1000').valueOf(), isClosed: false, - isSystem: true, isTombstone: false, name: 'Primary Checking', type: 'asset', @@ -25,12 +22,9 @@ describe('AccountPersistenceMapper', () => { expect(AccountPersistenceMapper.toSnapshot(row)).toEqual({ commodityId: row.commodityId, createdAt: row.createdAt, - currentClearedBalanceLocal: row.currentClearedBalanceLocal, description: row.description, id: row.id, - initialBalance: row.initialBalance, isClosed: row.isClosed, - isSystem: row.isSystem, isTombstone: row.isTombstone, name: row.name, type: row.type, @@ -55,12 +49,9 @@ describe('AccountPersistenceMapper', () => { ).toEqual({ commodityId: row.commodityId, createdAt: row.createdAt, - currentClearedBalanceLocal: row.currentClearedBalanceLocal, description: row.description, id: row.id, - initialBalance: row.initialBalance, isClosed: row.isClosed, - isSystem: row.isSystem, isTombstone: row.isTombstone, name: row.name, type: row.type, diff --git a/apps/backend/src/infrastructure/db/accounts/account-persistence.mapper.ts b/apps/backend/src/infrastructure/db/accounts/account-persistence.mapper.ts index a28f057e..464ca22d 100644 --- a/apps/backend/src/infrastructure/db/accounts/account-persistence.mapper.ts +++ b/apps/backend/src/infrastructure/db/accounts/account-persistence.mapper.ts @@ -10,12 +10,9 @@ export class AccountPersistenceMapper { return { commodityId: row.commodityId, createdAt: row.createdAt, - currentClearedBalanceLocal: row.currentClearedBalanceLocal, description: row.description, id: row.id, - initialBalance: row.initialBalance, isClosed: row.isClosed, - isSystem: row.isSystem, isTombstone: row.isTombstone, name: row.name, type: row.type, @@ -28,12 +25,9 @@ export class AccountPersistenceMapper { return { commodityId: snapshot.commodityId, createdAt: snapshot.createdAt, - currentClearedBalanceLocal: snapshot.currentClearedBalanceLocal, description: snapshot.description, id: snapshot.id, - initialBalance: snapshot.initialBalance, isClosed: snapshot.isClosed, - isSystem: snapshot.isSystem, isTombstone: snapshot.isTombstone, name: snapshot.name, type: snapshot.type, diff --git a/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts b/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts index db78095b..0f172cfc 100644 --- a/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts +++ b/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts @@ -13,7 +13,7 @@ import { compareEntities, compareEntityArrays, } from 'src/db/test-utils/entityComparer'; -import { Amount, CommodityCode, Timestamp } from 'src/domain/domain-core'; +import { CommodityCode, Timestamp } from 'src/domain/domain-core'; import { Id } from 'src/domain/domain-core/value-objects/Id'; import { AccountRepository } from 'src/infrastructure/db/'; import { @@ -37,11 +37,8 @@ const getAccountData = (params: { }): AccountDbInsert => { return { commodityId: params.commodityId, - currentClearedBalanceLocal: Amount.create('0').valueOf(), description: 'This is a test account', - initialBalance: Amount.create('100').valueOf(), isClosed: false, - isSystem: false, isTombstone: false, name: params.name, type: params.type, @@ -615,7 +612,6 @@ describe('AccountRepository', () => { }); await testDB.createAccount(user.id, usdCommodity.id, { - initialBalance: Amount.create('200').valueOf(), name: updatedAccountData.name, type: 'asset', }); @@ -652,7 +648,6 @@ describe('AccountRepository', () => { secondUser.id, secondUserCommodity.id, { - initialBalance: Amount.create('200').valueOf(), name: 'Shared Account Name', type: 'asset', }, @@ -734,7 +729,6 @@ describe('AccountRepository', () => { const maliciousData = { createdAt: Timestamp.create().valueOf(), id: 'malicious-id', - initialBalance: Amount.create('2000').valueOf(), name: 'Updated Account', type: 'expense' as const, updatedAt: Timestamp.create().valueOf(), @@ -798,7 +792,6 @@ describe('AccountRepository', () => { secondUser.id, secondUserCommodity.id, { - initialBalance: Amount.create('2000').valueOf(), name: 'Shared Account Name', type: 'asset', }, @@ -874,7 +867,6 @@ describe('AccountRepository', () => { const maliciousData = { createdAt: Timestamp.create().valueOf(), id: 'malicious-id', - initialBalance: Amount.create('2000').valueOf(), isClosed: true, name: 'Updated Account', type: 'expense' as const, diff --git a/apps/backend/src/infrastructure/db/accounts/account.repository.ts b/apps/backend/src/infrastructure/db/accounts/account.repository.ts index 1b50f96a..b93db48b 100644 --- a/apps/backend/src/infrastructure/db/accounts/account.repository.ts +++ b/apps/backend/src/infrastructure/db/accounts/account.repository.ts @@ -99,7 +99,6 @@ export class AccountRepository const result = await this.db.insert(accountsTable).values({ ...AccountPersistenceMapper.toDBRowFromSnapshot(data), - currentClearedBalanceLocal: data.currentClearedBalanceLocal ?? '0', }); this.ensureRowsAffected( @@ -167,7 +166,6 @@ export class AccountRepository async () => { const safeData = this.getSafeUpdate(data, [ 'description', - 'initialBalance', 'name', 'type', 'updatedAt', diff --git a/apps/backend/src/infrastructure/db/operations/operation-persistence.mapper.test.ts b/apps/backend/src/infrastructure/db/operations/operation-persistence.mapper.test.ts index 6128e08c..13fb5f5b 100644 --- a/apps/backend/src/infrastructure/db/operations/operation-persistence.mapper.test.ts +++ b/apps/backend/src/infrastructure/db/operations/operation-persistence.mapper.test.ts @@ -29,7 +29,6 @@ describe('OperationPersistenceMapper', () => { createdAt: snapshot.createdAt, description: snapshot.description, id: snapshot.id, - isSystem: snapshot.isSystem, isTombstone: snapshot.isTombstone, transactionId: snapshot.transactionId, updatedAt: snapshot.updatedAt, diff --git a/apps/backend/src/infrastructure/db/operations/operation-persistence.mapper.ts b/apps/backend/src/infrastructure/db/operations/operation-persistence.mapper.ts index b4b51b9a..5adc6381 100644 --- a/apps/backend/src/infrastructure/db/operations/operation-persistence.mapper.ts +++ b/apps/backend/src/infrastructure/db/operations/operation-persistence.mapper.ts @@ -16,7 +16,6 @@ export class OperationPersistenceMapper { createdAt: snapshot.createdAt, description: snapshot.description, id: snapshot.id, - isSystem: snapshot.isSystem, isTombstone: snapshot.isTombstone, transactionId: snapshot.transactionId, updatedAt: snapshot.updatedAt, 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 3ae96822..b95201db 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 @@ -70,7 +70,6 @@ describe('TransactionQueryRepository', () => { expect(operation.description).toBe(operationSeed.description); expect(operation.userId).toBe(user.id); expect(operation.transactionId).toBe(transaction.id); - expect(operation.isSystem).toBe(false); }); }; @@ -507,7 +506,6 @@ describe('TransactionQueryRepository', () => { 'createdAt', 'description', 'id', - 'isSystem', 'transactionId', 'updatedAt', 'userId', diff --git a/apps/backend/src/infrastructure/db/transaction/transaction-read-model.mapper.ts b/apps/backend/src/infrastructure/db/transaction/transaction-read-model.mapper.ts index 0ed86d79..724a2ea7 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction-read-model.mapper.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction-read-model.mapper.ts @@ -31,7 +31,6 @@ export class TransactionReadModelMapper { createdAt: operation.createdAt, description: operation.description, id: operation.id, - isSystem: operation.isSystem, transactionId: operation.transactionId, updatedAt: operation.updatedAt, userId: operation.userId, 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 f145cc65..6bfc175a 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction.repository.test.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction.repository.test.ts @@ -130,7 +130,6 @@ describe('TransactionRepository', () => { amount: operation.amount.valueOf(), description: operation.description, id: operation.getId().valueOf(), - isSystem: operation.isSystem, transactionId: operation.transactionId.valueOf(), value: operation.value.valueOf(), }; @@ -186,7 +185,6 @@ describe('TransactionRepository', () => { expect(operation.amount.valueOf()).toBe( matchingOperation?.amount.valueOf(), ); - expect(operation.isSystem).toBe(matchingOperation?.isSystem); expect(operation.value.valueOf()).toBe( matchingOperation?.value.valueOf(), ); diff --git a/apps/backend/src/presentation/http/accounts/account.controller.test.ts b/apps/backend/src/presentation/http/accounts/account.controller.test.ts index f633901b..8dcc9892 100644 --- a/apps/backend/src/presentation/http/accounts/account.controller.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.controller.test.ts @@ -9,7 +9,6 @@ import { OpenAccountUseCase, UpdateAccountUseCase, } from 'src/application/usecases/accounts'; -import { Amount } from 'src/domain/domain-core'; import { Id } from 'src/domain/domain-core/value-objects/Id'; import { User } from 'src/domain/users/user.entity'; import { createUser } from 'src/testing'; @@ -120,7 +119,6 @@ describe('AccountController', () => { const requestBody: AccountCreateDTO = { commodityId: Id.create().valueOf(), description: 'Test Account', - initialBalance: Amount.create('1000').valueOf(), name: 'New Account', type: 'liability', }; diff --git a/apps/backend/src/presentation/http/accounts/account.integration.test.ts b/apps/backend/src/presentation/http/accounts/account.integration.test.ts index 775df133..21d3ce5f 100644 --- a/apps/backend/src/presentation/http/accounts/account.integration.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.integration.test.ts @@ -27,7 +27,6 @@ const closedAccountsData = [ { commodityId: Id.create().valueOf(), description: 'Closed account 1 for testing purposes', - initialBalance: Amount.create('1000').valueOf(), isClosed: true, name: 'Test Account', type: AccountType.create('asset').valueOf(), @@ -35,7 +34,6 @@ const closedAccountsData = [ { commodityId: Id.create().valueOf(), description: 'Closed account 2 for testing purposes', - initialBalance: Amount.create('1000').valueOf(), isClosed: true, name: 'Savings Account', type: AccountType.create('asset').valueOf(), @@ -46,7 +44,6 @@ const openAccountsData = [ { commodityId: Id.create().valueOf(), description: 'Open account 1 for testing purposes', - initialBalance: Amount.create('1000').valueOf(), isClosed: false, name: 'Open Account', type: AccountType.create('asset').valueOf(), @@ -54,7 +51,6 @@ const openAccountsData = [ { commodityId: Id.create().valueOf(), description: 'Open account 2 for testing purposes', - initialBalance: Amount.create('1000').valueOf(), isClosed: false, name: 'Open Savings Account', type: AccountType.create('asset').valueOf(), @@ -65,7 +61,6 @@ const deletedAccountsData = [ { commodityId: Id.create().valueOf(), description: 'Deleted account 1 for testing purposes', - initialBalance: Amount.create('1000').valueOf(), isTombstone: true, name: 'Deleted Account', type: AccountType.create('asset').valueOf(), @@ -73,7 +68,6 @@ const deletedAccountsData = [ { commodityId: Id.create().valueOf(), description: 'Deleted account 2 for testing purposes', - initialBalance: Amount.create('1000').valueOf(), isTombstone: true, name: 'Deleted Savings Account', type: AccountType.create('asset').valueOf(), @@ -236,7 +230,6 @@ describe('Accounts Integration Tests', () => { const payload: AccountCreateDTO = { commodityId: commodity.id, description: 'This is a new account', - initialBalance: Amount.create('1000').valueOf(), name: 'New Account', type: 'asset' as AccountTypeValue, }; @@ -628,7 +621,6 @@ describe('Accounts Integration Tests', () => { const payload: AccountCreateDTO = { commodityId: commodity.id, description: 'This is a new account', - initialBalance: Amount.create('1000').valueOf(), name: '', type: 'asset' as AccountTypeValue, }; @@ -646,7 +638,6 @@ describe('Accounts Integration Tests', () => { const payload: AccountCreateDTO = { commodityId: commodity.id, description: 'This is a new account', - initialBalance: Amount.create('1000').valueOf(), type: 'asset' as AccountTypeValue, } as unknown as AccountCreateDTO; // Type assertion to bypass TypeScript checks for testing purposes @@ -663,7 +654,6 @@ describe('Accounts Integration Tests', () => { const payload: AccountCreateDTO = { commodityId: commodity.id, description: 'This is a new account', - initialBalance: Amount.create('1000').valueOf(), name: 123 as unknown as string, type: 'asset' as AccountTypeValue, }; @@ -681,7 +671,6 @@ describe('Accounts Integration Tests', () => { const payload: AccountCreateDTO = { commodityId: '' as unknown as UUID, description: 'This is a new account', - initialBalance: Amount.create('1000').valueOf(), name: 'New Account', type: 'asset' as AccountTypeValue, }; @@ -698,7 +687,6 @@ describe('Accounts Integration Tests', () => { it('should return 400 when commodityId is missing', async () => { const payload: AccountCreateDTO = { description: 'This is a new account', - initialBalance: Amount.create('1000').valueOf(), name: 'New Account', type: 'asset' as AccountTypeValue, } as unknown as AccountCreateDTO; // Type assertion to bypass TypeScript checks for testing purposes @@ -716,7 +704,6 @@ describe('Accounts Integration Tests', () => { const payload: AccountCreateDTO = { commodityId: 'invalid-uuid' as unknown as UUID, description: 'This is a new account', - initialBalance: Amount.create('1000').valueOf(), name: 'New Account', type: 'asset' as AccountTypeValue, }; @@ -734,7 +721,6 @@ describe('Accounts Integration Tests', () => { const payload: AccountCreateDTO = { commodityId: Id.create().valueOf(), description: 'This is a new account', - initialBalance: Amount.create('1000').valueOf(), name: 'New Account', type: 'asset' as AccountTypeValue, }; @@ -754,7 +740,6 @@ describe('Accounts Integration Tests', () => { const payload: AccountCreateDTO = { commodityId: otherUserCommodity.id, description: 'This is a new account', - initialBalance: Amount.create('1000').valueOf(), name: 'New Account', type: AccountType.create('asset').valueOf(), }; @@ -774,7 +759,6 @@ describe('Accounts Integration Tests', () => { const payload: AccountCreateDTO = { commodityId: commodity.id, description: 'This is a new account', - initialBalance: Amount.create('1000').valueOf(), name: 'New Account', type: 'asset' as AccountTypeValue, }; @@ -800,7 +784,6 @@ describe('Accounts Integration Tests', () => { const payload: AccountCreateDTO = { commodityId: closedCommodity.id, description: 'This is a new account', - initialBalance: Amount.create('1000').valueOf(), name: 'New Account', type: 'asset' as AccountTypeValue, }; @@ -830,7 +813,6 @@ describe('Accounts Integration Tests', () => { const payload: AccountCreateDTO = { commodityId: commodity.id, description: 'This is a new account', - initialBalance: Amount.create('1000').valueOf(), name: 'New Account', type: '' as unknown as AccountTypeValue, }; @@ -848,7 +830,6 @@ describe('Accounts Integration Tests', () => { const payload: AccountCreateDTO = { commodityId: commodity.id, description: 'This is a new account', - initialBalance: Amount.create('1000').valueOf(), name: 'New Account', type: 'invalid-type' as unknown as AccountTypeValue, }; @@ -866,7 +847,6 @@ describe('Accounts Integration Tests', () => { const payload: AccountCreateDTO = { commodityId: commodity.id, description: 'This is a new account', - initialBalance: Amount.create('1000').valueOf(), name: 'New Account', } as unknown as AccountCreateDTO; @@ -883,7 +863,6 @@ describe('Accounts Integration Tests', () => { const payload: AccountCreateDTO = { commodityId: commodity.id, description: 'This is a new account', - initialBalance: Amount.create('1000').valueOf(), name: accounts[0].name, type: 'asset' as AccountTypeValue, } as unknown as AccountCreateDTO; // Type assertion to bypass TypeScript checks for testing purposes @@ -901,7 +880,6 @@ describe('Accounts Integration Tests', () => { const payload: AccountCreateDTO = { commodityId: commodity.id, description: 123 as unknown as string, - initialBalance: Amount.create('1000').valueOf(), name: 'New Account', type: 'asset' as AccountTypeValue, }; @@ -920,7 +898,6 @@ describe('Accounts Integration Tests', () => { commodityId: commodity.id, description: 'This is a new account', extraField: 'unexpected', - initialBalance: Amount.create('1000').valueOf(), name: 'New Account', type: 'asset' as AccountTypeValue, }; @@ -966,7 +943,6 @@ describe('Accounts Integration Tests', () => { otherUserCommodity.id, { description: 'Other user account', - initialBalance: Amount.create('1000').valueOf(), name: 'Other User Account', type: 'asset' as AccountTypeValue, }, @@ -1125,7 +1101,6 @@ describe('Accounts Integration Tests', () => { otherUserCommodity.id, { description: 'Other user account', - initialBalance: Amount.create('1000').valueOf(), name: 'Other User Account', type: 'asset' as AccountTypeValue, }, @@ -1307,7 +1282,6 @@ describe('Accounts Integration Tests', () => { const payload: AccountCreateDTO = { commodityId: commodity.id, description: 'This is a new account', - initialBalance: Amount.create('1000').valueOf(), name: 'New Account for Timestamp Test', type: AccountType.create('asset').valueOf(), }; @@ -1325,7 +1299,6 @@ describe('Accounts Integration Tests', () => { expect(createdAccount.name).toBe(payload.name); expect(createdAccount.type).toBe(payload.type); expect(createdAccount.description).toBe(payload.description); - expect(createdAccount.initialBalance).toBe(payload.initialBalance); }); }); @@ -1407,12 +1380,9 @@ describe('Accounts Integration Tests', () => { const requiredFields = [ 'commodityId', 'createdAt', - 'currentClearedBalanceLocal', 'description', 'id', - 'initialBalance', 'isClosed', - 'isSystem', 'isTombstone', 'name', 'type', @@ -1424,7 +1394,6 @@ describe('Accounts Integration Tests', () => { expect(account).toHaveProperty(field); }); expect(typeof account.isClosed).toBe('boolean'); - expect(typeof account.isSystem).toBe('boolean'); expect(typeof account.isTombstone).toBe('boolean'); }); }); @@ -1434,7 +1403,6 @@ describe('Accounts Integration Tests', () => { const payload: AccountCreateDTO = { commodityId: commodity.id, description: 'This is a new account', - initialBalance: Amount.create('1000').valueOf(), name: 'a'.repeat(256), type: 'asset' as AccountTypeValue, }; diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index cb5e6234..a78d348e 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -48,10 +48,7 @@ erDiagram string name string type "Asset|Liability|Income|Expense" string description - integer initialBalance - integer currentClearedBalanceLocal boolean isClosed - boolean isSystem boolean isTombstone uuid userId FK timestamp createdAt @@ -78,7 +75,6 @@ erDiagram integer amount integer value string description - boolean isSystem boolean isTombstone uuid userId FK timestamp createdAt @@ -189,10 +185,7 @@ User's financial accounts for tracking funds. | `name` | String | Account name | NOT NULL | | `type` | Enum | Account type | `Asset\|Liability\|Income\|Expense` | | `description` | String | Account description | NOT NULL | -| `initialBalance` | Integer | Initial balance in minor units | NOT NULL, default: 0 | -| `currentClearedBalanceLocal` | Integer | Current local balance in minor units | NOT NULL, default: 0 | | `isClosed` | Boolean | Reversible account close state | NOT NULL, default: false | -| `isSystem` | Boolean | Reserved system account flag | NOT NULL, default: false | | `isTombstone` | Boolean | Terminal tombstone delete flag | NOT NULL, default: false | | `userId` | UUID | Account owner | FK -> `users.id`, NOT NULL, ON DELETE CASCADE | | `createdAt` | Timestamp | Creation date | NOT NULL | @@ -222,7 +215,8 @@ User's financial accounts for tracking funds. tombstoned accounts. - Terminal account delete is allowed only when no active operations reference the account. -- System accounts are reserved for future trading-account support. +- Trading accounts are reserved for future multi-currency reconciliation + support. The current account schema has no system-account flag. --- @@ -274,7 +268,6 @@ Individual financial postings affecting accounts. | `amount` | Integer | Amount in the account's Commodity minor units | NOT NULL | | `value` | Integer | Amount in the transaction Commodity minor units | NOT NULL | | `description` | String | Operation description | NOT NULL | -| `isSystem` | Boolean | Reserved system operation flag | NOT NULL, default: false | | `isTombstone` | Boolean | Soft delete flag | NOT NULL, default: false | | `userId` | UUID | Operation owner | FK -> `users.id`, NOT NULL, ON DELETE CASCADE | | `createdAt` | Timestamp | Creation date | NOT NULL | @@ -292,7 +285,9 @@ Individual financial postings affecting accounts. - `value` is denominated by the parent transaction's Commodity. - Balance check: `sum(value)` across active operations in a transaction must equal 0. -- System operations are reserved for future trading-account support. +- Automated trading operations are reserved for future multi-currency + reconciliation support. The current operation schema has no system-operation + flag. **Indexes:** diff --git a/docs/DOMAIN.md b/docs/DOMAIN.md index f62aeef1..5624522d 100644 --- a/docs/DOMAIN.md +++ b/docs/DOMAIN.md @@ -40,9 +40,8 @@ Represents a single financial posting affecting an account. - May be soft-deleted in persistence via `isTombstone` - Tombstone operations remain part of the raw transaction aggregate state for persistence, but are excluded from active domain accessors and read API responses - Has optional description field -- `isSystem` — reserved for future trading operations (see below) -> **Temporarily deprecated:** `isSystem = true` trading operations and system trading accounts are not created at this time. The system currently validates balance by summing `value` across all operations of a transaction (must equal 0). Trading accounts may be introduced later for full multi-currency reconciliation. +> **Temporarily deprecated:** Trading operations are not created at this time. The system currently validates balance by summing `value` across all operations of a transaction (must equal 0). Automated reconciliation postings may be introduced later for full multi-currency reconciliation. ### Account @@ -57,11 +56,9 @@ Represents different financial accounts with unified structure for all account t - **Expense**: Spending categories - Has a designated Commodity - **Balance tracking**: - - For Asset/Liability: real balance stored in `currentClearedBalanceLocal`, must match reality + - For Asset/Liability: real balance comes from ledger operations/read models - For Income/Expense: reporting metric (sum over period), calculated from operations - Balance is calculated from operations -- Has initial balance (`initialBalance`) -- `isSystem = true` is reserved for future system trading accounts (currently unused) - Reversible account close state is represented by `isClosed` - Terminal account deletion is represented by `isTombstone` - `GET /accounts` returns open accounts by default and supports @@ -277,7 +274,7 @@ tasks. 1. Each operation carries both `amount` (account Commodity) and `value` (transaction Commodity) 2. For same-currency operations `amount === value` -3. **Trading operations** (`isSystem = true`) and system trading accounts are **not currently implemented**; they are reserved for a future multi-currency reconciliation phase +3. **Trading operations** are **not currently implemented**; they are reserved for a future multi-currency reconciliation phase ### Account Balance @@ -355,12 +352,12 @@ Balance: `sum(value) = -10000 + 10000 = 0` ✓ When trading accounts are introduced, the transaction will look like: **Operations** -| id | transactionId | accountId | account | amount (cents) | value (USD cents) | isSystem | -|----|---------------|-----------|---------|----------------|-------------------|----------| -| O3 | T2 | A3 | Asset:Cash USD | -1000 | -1000 | false | -| O4 | T2 | A4 | System:Trading:USD | +1000 | +1000 | true | -| O5 | T2 | A5 | System:Trading:EUR | -900 | +900 | true | -| O6 | T2 | A6 | Expense:Goods EUR | +900 | -900 | false | +| id | transactionId | accountId | account | amount (cents) | value (USD cents) | +|----|---------------|-----------|---------|----------------|-------------------| +| O3 | T2 | A3 | Asset:Cash USD | -1000 | -1000 | +| O4 | T2 | A4 | System:Trading:USD | +1000 | +1000 | +| O5 | T2 | A5 | System:Trading:EUR | -900 | +900 | +| O6 | T2 | A6 | Expense:Goods EUR | +900 | -900 | Balance: `sum(value) = 0` ✓ — currently this phase is not implemented. @@ -397,7 +394,6 @@ Operation - amount: integer -- in account Commodity minor units - value: integer -- in transaction Commodity minor units; used for balance validation - description: string (optional) -- isSystem: boolean -- reserved for future trading operations (currently always false) - isTombstone: boolean - userId: UUID (FK) - createdAt: timestamp @@ -409,9 +405,6 @@ Account - type: enum (Asset, Liability, Income, Expense) - commodityId: UUID (FK) - description: string -- initialBalance: integer (cents) -- currentClearedBalanceLocal: integer (cents) -- isSystem: boolean (for trading accounts) - isTombstone: boolean - userId: UUID (FK) - createdAt: timestamp @@ -442,7 +435,6 @@ Settings - **IDs**: UUIDs for all entities - **Soft deletion**: Uses `isTombstone` flag instead of hard deletes - **MVP operation deletion rule**: deleted operations may remain in raw aggregate and storage state, but are excluded from active domain/read behavior -- **System entities**: System accounts and operations marked with `isSystem = true` ## Future Improvements diff --git a/docs/MULTICURRENCY_DESIGN.md b/docs/MULTICURRENCY_DESIGN.md index 07f72333..4f243201 100644 --- a/docs/MULTICURRENCY_DESIGN.md +++ b/docs/MULTICURRENCY_DESIGN.md @@ -55,7 +55,7 @@ These accounts: - exist solely to maintain correct double-entry, - cannot be used manually. -**Current state:** Trading accounts and `isSystem = true` operations are **temporarily deprecated**. They will be introduced in a dedicated multi-currency reconciliation phase. +**Current state:** Trading accounts and automated trading operations are **temporarily deprecated**. They will be introduced in a dedicated multi-currency reconciliation phase. ### 2.5 ~~Entry Wrapper~~ *(removed)* ~~Operations are grouped within **Entries**.~~ @@ -91,10 +91,10 @@ When trading accounts are introduced, Ledgerly will generate **balancing posting ``` Transaction T2: "Exchange USD to RUB" Operations: - Assets:WalletUSD amount=-100 USD value=-100 isSystem=false - System:Trading:USD amount=+100 USD value=+100 isSystem=true - System:Trading:RUB amount=-1000 RUB value=+100 isSystem=true - Assets:WalletRUB amount=+1000 RUB value=-100 isSystem=false + Assets:WalletUSD amount=-100 USD value=-100 + System:Trading:USD amount=+100 USD value=+100 + System:Trading:RUB amount=-1000 RUB value=+100 + Assets:WalletRUB amount=+1000 RUB value=-100 Balance: sum(value) = 0 ✓ ``` @@ -147,7 +147,6 @@ Individual account posting: - `amount` (signed integer, in the **account's Commodity**) - `value` (signed integer, in the **transaction Commodity**) - `description` (optional) -- `isSystem` (true for trading postings) - `createdAt`, `updatedAt`, `isTombstone` #### `amount` vs `value` — GnuCash convention @@ -175,8 +174,6 @@ Transaction Commodity: USD Notes: - Account denomination is determined by the account's Commodity. - Operations are immutable. -- Normal operations have `isSystem = false`. -- Trading operations have `isSystem = true`. --- @@ -196,10 +193,10 @@ Balance: sum(value) = +100 - 100 = 0 ✓ **Transaction T2 (future):** ``` - Assets:WalletUSD amount=-100 USD value=-100 isSystem=false - System:Trading:USD amount=+100 USD value=+100 isSystem=true - System:Trading:RUB amount=-1000 RUB value=+100 isSystem=true - Assets:WalletRUB amount=+1000 RUB value=-100 isSystem=false + Assets:WalletUSD amount=-100 USD value=-100 + System:Trading:USD amount=+100 USD value=+100 + System:Trading:RUB amount=-1000 RUB value=+100 + Assets:WalletRUB amount=+1000 RUB value=-100 ``` Balance: sum(value) = 0 ✓ @@ -258,7 +255,7 @@ Currently, Ledgerly stays closer to GnuCash than to PTA. ### Planned - GnuCash-style trading accounts (`System:Trading:*`) -- `isSystem = true` operations for automatic currency reconciliation +- Automatic currency reconciliation operations - Per-currency double-entry balancing This foundation ensures correctness, extensibility, and simplicity for all future Ledgerly features. diff --git a/docs/architecture/adr/0022-remove-system-flags-from-account-and-operation-contracts.md b/docs/architecture/adr/0022-remove-system-flags-from-account-and-operation-contracts.md new file mode 100644 index 00000000..f06a7f48 --- /dev/null +++ b/docs/architecture/adr/0022-remove-system-flags-from-account-and-operation-contracts.md @@ -0,0 +1,74 @@ +# ADR 0022: Remove system flags from account and operation contracts + +- Status: Accepted +- Date: 2026-08-07 +- Jira: https://gorushkin.atlassian.net/browse/LED-92 +- PR: TBD + +## Context + +The account model previously carried balance-derived fields and a system flag, +while operations also exposed an `isSystem` flag reserved for future trading +postings. These fields leaked planned multi-currency implementation details into +the current domain, database schema, and response DTOs. + +The current product flow does not create trading accounts or automated trading +operations. Transaction correctness is enforced by summing operation `value` +fields to zero within the `Transaction` aggregate. + +## Decision + +Remove system flags from current account and operation contracts: + +1. `Account` and account response DTOs do not expose `isSystem`. +2. `Operation`, operation snapshots, operation persistence rows, read models, + and response DTOs do not expose `isSystem`. +3. The `accounts` and `operations` tables do not contain `is_system` columns. +4. Future trading-account support must introduce explicit behavior and schema + when it is implemented, instead of relying on placeholder flags in the + current model. + +## Alternatives Considered + +1. Keep `isSystem` as a reserved flag + +- Pros: future trading postings could reuse an existing field. +- Cons: preserves unused API and database surface, creates unclear semantics, + and requires clients/tests to handle a value that is always false today. + +2. Keep operation-level `isSystem` but remove account-level `isSystem` + +- Pros: narrower change and closer to the earlier planned trading-posting + design. +- Cons: still leaks unimplemented behavior into transaction responses and keeps + a persistence column without current behavior. + +3. Remove system flags now + +- Pros: current contracts match implemented behavior, future reconciliation can + be designed with concrete requirements, and domain snapshots stay focused on + active invariants. +- Cons: future trading-account work will need an explicit migration and API + decision if it needs to distinguish generated postings. + +## Consequences + +Positive: + +- Account and operation DTOs no longer expose unused system flags. +- Database schema no longer stores placeholder system-state columns. +- Multi-currency documentation can describe trading accounts as future behavior + without committing the current API to a flag-based design. + +Neutral/Cost: + +- Any future automated reconciliation feature will need a new ADR or an update + to this decision before adding generated-posting metadata. +- Existing consumers expecting `operations[].isSystem` must be updated. + +## Related + +- [ADR 0002: Operation application boundary](./0002-operation-application-boundary.md) +- [Domain documentation](../../DOMAIN.md) +- [Database schema documentation](../../DATABASE_SCHEMA.md) +- [Multi-currency design](../../MULTICURRENCY_DESIGN.md) diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index 27fac74f..6edcb825 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -52,3 +52,4 @@ - [ADR 0019: Repository-Enforced Commodity Reference Validation (Superseded)](./0019-repository-enforced-commodity-reference-validation.md) - [ADR 0020: Terminal Tombstone and Reversible Entity States](./0020-terminal-tombstone-and-reversible-entity-states.md) - [ADR 0021: Split Commodity Reference Business Policy From Persistence Integrity](./0021-split-commodity-reference-business-policy-from-persistence-integrity.md) +- [ADR 0022: Remove system flags from account and operation contracts](./0022-remove-system-flags-from-account-and-operation-contracts.md) diff --git a/packages/shared/src/types/account.ts b/packages/shared/src/types/account.ts index 0e3f5deb..289f2e97 100644 --- a/packages/shared/src/types/account.ts +++ b/packages/shared/src/types/account.ts @@ -1,4 +1,4 @@ -import { IsoDatetimeString, AmountString, UUID, RequiredText } from "./types"; +import { IsoDatetimeString, UUID, RequiredText } from "./types"; export type AccountTypeValue = | "asset" @@ -11,7 +11,6 @@ export type AccountTypeValue = export type AccountCreateDTO = { commodityId: UUID; description: string; - initialBalance: AmountString; name: string; type: AccountTypeValue; }; @@ -23,12 +22,9 @@ export type AccountUpdateDTO = Partial< export type AccountResponseDTO = { commodityId: UUID; createdAt: IsoDatetimeString; - currentClearedBalanceLocal: AmountString; description: string; id: UUID; - initialBalance: AmountString; isClosed: boolean; - isSystem: boolean; isTombstone: boolean; name: RequiredText; type: AccountTypeValue; diff --git a/packages/shared/src/types/transaction.ts b/packages/shared/src/types/transaction.ts index 38a4f6fa..e12b0467 100644 --- a/packages/shared/src/types/transaction.ts +++ b/packages/shared/src/types/transaction.ts @@ -34,7 +34,6 @@ export type OperationResponseDTO = { createdAt: IsoDatetimeString; description?: string; id: UUID; - isSystem: boolean; transactionId: UUID; updatedAt: IsoDatetimeString; userId: UUID; diff --git a/packages/shared/src/validation/accounts.test.ts b/packages/shared/src/validation/accounts.test.ts index 2f97f307..44653ba7 100644 --- a/packages/shared/src/validation/accounts.test.ts +++ b/packages/shared/src/validation/accounts.test.ts @@ -8,7 +8,6 @@ import { const validCreateAccount = { commodityId: "00000000-0000-4000-8000-000000000000", - initialBalance: "0", name: "Checking", type: "asset", }; diff --git a/packages/shared/src/validation/accounts.ts b/packages/shared/src/validation/accounts.ts index 02c72897..c3ae78b3 100644 --- a/packages/shared/src/validation/accounts.ts +++ b/packages/shared/src/validation/accounts.ts @@ -7,7 +7,6 @@ import { optionalText, requiredText, uuid, - amountString, } from "./baseValidations"; const accountType = z.enum(ACCOUNT_TYPES); @@ -17,7 +16,6 @@ export const accountCreateSchema = z .object({ commodityId: uuid, description: defaultText, - initialBalance: amountString, name: accountName, type: accountType, }) diff --git a/restAPI/accounts/create.bru b/restAPI/accounts/create.bru index 424762f4..9f57c051 100644 --- a/restAPI/accounts/create.bru +++ b/restAPI/accounts/create.bru @@ -17,8 +17,7 @@ auth:bearer { body:json { { "name": "new Account2222", - "currency": "USD", - "type": "income", - "initialBalance": "0" + "commodityId": "{{commodityId}}", + "type": "income" } } From bf0488dd35a15d2e13c7fb30fe594e7292ec7e4b Mon Sep 17 00:00:00 2001 From: gorushkin Date: Sun, 16 Aug 2026 19:37:16 +0300 Subject: [PATCH 02/13] LED-92 feat: add description field to account creation request --- docs/architecture/adr/0001-transaction-repository-boundaries.md | 1 - docs/architecture/adr/0002-operation-application-boundary.md | 1 - .../adr/0003-no-zero-net-account-effect-invariant.md | 1 - .../adr/0004-no-minimum-distinct-accounts-invariant.md | 1 - docs/architecture/adr/0005-no-non-zero-operation-invariant.md | 1 - .../adr/0006-commodity-registry-before-currency-validation.md | 1 - docs/architecture/adr/0007-no-operation-amount-sign-invariant.md | 1 - docs/architecture/adr/0008-structured-api-error-contract.md | 1 - docs/architecture/adr/0009-repository-error-code-migration.md | 1 - docs/architecture/adr/0010-entity-not-found-api-code.md | 1 - docs/architecture/adr/0011-domain-entity-api-conventions.md | 1 - .../adr/0012-user-repository-application-boundary.md | 1 - docs/architecture/adr/0013-no-domain-entity-base-class.md | 1 - docs/architecture/adr/0014-keep-amount-value-object-name.md | 1 - docs/architecture/adr/0015-domain-restoration-factory-naming.md | 1 - docs/architecture/adr/0016-backend-request-flow.md | 1 - docs/architecture/adr/0017-backend-http-adapter-boundary.md | 1 - .../0019-repository-enforced-commodity-reference-validation.md | 1 - .../adr/0020-terminal-tombstone-and-reversible-entity-states.md | 1 - ...odity-reference-business-policy-from-persistence-integrity.md | 1 - ...2-remove-system-flags-from-account-and-operation-contracts.md | 1 - restAPI/accounts/create.bru | 1 + 22 files changed, 1 insertion(+), 21 deletions(-) diff --git a/docs/architecture/adr/0001-transaction-repository-boundaries.md b/docs/architecture/adr/0001-transaction-repository-boundaries.md index 10ebcdad..1177a6a0 100644 --- a/docs/architecture/adr/0001-transaction-repository-boundaries.md +++ b/docs/architecture/adr/0001-transaction-repository-boundaries.md @@ -3,7 +3,6 @@ - Status: Accepted - Date: 2026-06-14 - Jira: https://gorushkin.atlassian.net/browse/LED-37 -- PR: https://github.com/gorushkin/ledgerly/pull/196 ## Context diff --git a/docs/architecture/adr/0002-operation-application-boundary.md b/docs/architecture/adr/0002-operation-application-boundary.md index b70ccd3b..efeb060a 100644 --- a/docs/architecture/adr/0002-operation-application-boundary.md +++ b/docs/architecture/adr/0002-operation-application-boundary.md @@ -3,7 +3,6 @@ - Status: Accepted - Date: 2026-06-14 - Jira: https://gorushkin.atlassian.net/browse/LED-39 -- PR: TBD ## Context diff --git a/docs/architecture/adr/0003-no-zero-net-account-effect-invariant.md b/docs/architecture/adr/0003-no-zero-net-account-effect-invariant.md index 6a8a1283..3928bb8d 100644 --- a/docs/architecture/adr/0003-no-zero-net-account-effect-invariant.md +++ b/docs/architecture/adr/0003-no-zero-net-account-effect-invariant.md @@ -3,7 +3,6 @@ - Status: Accepted - Date: 2026-06-17 - Jira: https://gorushkin.atlassian.net/browse/LED-43 -- PR: TBD ## Context diff --git a/docs/architecture/adr/0004-no-minimum-distinct-accounts-invariant.md b/docs/architecture/adr/0004-no-minimum-distinct-accounts-invariant.md index 2f59f8f4..7b67d042 100644 --- a/docs/architecture/adr/0004-no-minimum-distinct-accounts-invariant.md +++ b/docs/architecture/adr/0004-no-minimum-distinct-accounts-invariant.md @@ -3,7 +3,6 @@ - Status: Accepted - Date: 2026-06-17 - Jira: https://gorushkin.atlassian.net/browse/LED-44 -- PR: TBD ## Context diff --git a/docs/architecture/adr/0005-no-non-zero-operation-invariant.md b/docs/architecture/adr/0005-no-non-zero-operation-invariant.md index 48361839..ea6b5fc8 100644 --- a/docs/architecture/adr/0005-no-non-zero-operation-invariant.md +++ b/docs/architecture/adr/0005-no-non-zero-operation-invariant.md @@ -3,7 +3,6 @@ - Status: Accepted - Date: 2026-06-19 - Jira: https://gorushkin.atlassian.net/browse/LED-45 -- PR: TBD ## Context diff --git a/docs/architecture/adr/0006-commodity-registry-before-currency-validation.md b/docs/architecture/adr/0006-commodity-registry-before-currency-validation.md index a1002e6c..bd73070e 100644 --- a/docs/architecture/adr/0006-commodity-registry-before-currency-validation.md +++ b/docs/architecture/adr/0006-commodity-registry-before-currency-validation.md @@ -3,7 +3,6 @@ - Status: Accepted - Date: 2026-07-15 - Jira: https://gorushkin.atlassian.net/browse/LED-65 -- PR: TBD ## Context diff --git a/docs/architecture/adr/0007-no-operation-amount-sign-invariant.md b/docs/architecture/adr/0007-no-operation-amount-sign-invariant.md index 278aa8ff..c0675637 100644 --- a/docs/architecture/adr/0007-no-operation-amount-sign-invariant.md +++ b/docs/architecture/adr/0007-no-operation-amount-sign-invariant.md @@ -3,7 +3,6 @@ - Status: Accepted - Date: 2026-06-21 - Jira: https://gorushkin.atlassian.net/browse/LED-53 -- PR: TBD ## Context diff --git a/docs/architecture/adr/0008-structured-api-error-contract.md b/docs/architecture/adr/0008-structured-api-error-contract.md index aad44be7..81b81cac 100644 --- a/docs/architecture/adr/0008-structured-api-error-contract.md +++ b/docs/architecture/adr/0008-structured-api-error-contract.md @@ -3,7 +3,6 @@ - Status: Accepted - Date: 2026-06-21 - Jira: https://gorushkin.atlassian.net/browse/LED-72 -- PR: TBD ## Context diff --git a/docs/architecture/adr/0009-repository-error-code-migration.md b/docs/architecture/adr/0009-repository-error-code-migration.md index 522883b4..68126d2a 100644 --- a/docs/architecture/adr/0009-repository-error-code-migration.md +++ b/docs/architecture/adr/0009-repository-error-code-migration.md @@ -3,7 +3,6 @@ - Status: Accepted - Date: 2026-06-24 - Jira: https://gorushkin.atlassian.net/browse/LED-75 -- PR: TBD ## Context diff --git a/docs/architecture/adr/0010-entity-not-found-api-code.md b/docs/architecture/adr/0010-entity-not-found-api-code.md index 0f40ee87..dcfc735f 100644 --- a/docs/architecture/adr/0010-entity-not-found-api-code.md +++ b/docs/architecture/adr/0010-entity-not-found-api-code.md @@ -3,7 +3,6 @@ - Status: Accepted - Date: 2026-06-24 - Jira: N/A -- PR: TBD ## Context diff --git a/docs/architecture/adr/0011-domain-entity-api-conventions.md b/docs/architecture/adr/0011-domain-entity-api-conventions.md index e99f94af..d383e628 100644 --- a/docs/architecture/adr/0011-domain-entity-api-conventions.md +++ b/docs/architecture/adr/0011-domain-entity-api-conventions.md @@ -3,7 +3,6 @@ - Status: Accepted - Date: 2026-06-25 - Jira: https://gorushkin.atlassian.net/browse/LED-57 -- PR: TBD ## Context diff --git a/docs/architecture/adr/0012-user-repository-application-boundary.md b/docs/architecture/adr/0012-user-repository-application-boundary.md index 3ee567fd..fefdeff3 100644 --- a/docs/architecture/adr/0012-user-repository-application-boundary.md +++ b/docs/architecture/adr/0012-user-repository-application-boundary.md @@ -3,7 +3,6 @@ - Status: Accepted - Date: 2026-07-10 - Jira: https://gorushkin.atlassian.net/browse/LED-91 -- PR: TBD ## Context diff --git a/docs/architecture/adr/0013-no-domain-entity-base-class.md b/docs/architecture/adr/0013-no-domain-entity-base-class.md index b4535c91..c34c7c9d 100644 --- a/docs/architecture/adr/0013-no-domain-entity-base-class.md +++ b/docs/architecture/adr/0013-no-domain-entity-base-class.md @@ -3,7 +3,6 @@ - Status: Accepted - Date: 2026-07-10 - Jira: https://gorushkin.atlassian.net/browse/LED-10 -- PR: TBD ## Context diff --git a/docs/architecture/adr/0014-keep-amount-value-object-name.md b/docs/architecture/adr/0014-keep-amount-value-object-name.md index 64e0f082..fceeffa7 100644 --- a/docs/architecture/adr/0014-keep-amount-value-object-name.md +++ b/docs/architecture/adr/0014-keep-amount-value-object-name.md @@ -3,7 +3,6 @@ - Status: Accepted - Date: 2026-07-11 - Jira: https://gorushkin.atlassian.net/browse/LED-63 -- PR: TBD ## Context diff --git a/docs/architecture/adr/0015-domain-restoration-factory-naming.md b/docs/architecture/adr/0015-domain-restoration-factory-naming.md index 8ddebb26..96b5d313 100644 --- a/docs/architecture/adr/0015-domain-restoration-factory-naming.md +++ b/docs/architecture/adr/0015-domain-restoration-factory-naming.md @@ -3,7 +3,6 @@ - Status: Accepted - Date: 2026-07-11 - Jira: https://gorushkin.atlassian.net/browse/LED-81 -- PR: TBD ## Context diff --git a/docs/architecture/adr/0016-backend-request-flow.md b/docs/architecture/adr/0016-backend-request-flow.md index bc426f1b..6f235648 100644 --- a/docs/architecture/adr/0016-backend-request-flow.md +++ b/docs/architecture/adr/0016-backend-request-flow.md @@ -3,7 +3,6 @@ - Status: Accepted - Date: 2026-07-12 - Jira: https://gorushkin.atlassian.net/browse/LED-84 -- PR: TBD ## Context diff --git a/docs/architecture/adr/0017-backend-http-adapter-boundary.md b/docs/architecture/adr/0017-backend-http-adapter-boundary.md index 867d7e98..ac4e77db 100644 --- a/docs/architecture/adr/0017-backend-http-adapter-boundary.md +++ b/docs/architecture/adr/0017-backend-http-adapter-boundary.md @@ -3,7 +3,6 @@ - Status: Accepted - Date: 2026-07-12 - Jira: https://gorushkin.atlassian.net/browse/LED-86 -- PR: TBD ## Context diff --git a/docs/architecture/adr/0019-repository-enforced-commodity-reference-validation.md b/docs/architecture/adr/0019-repository-enforced-commodity-reference-validation.md index 7e4ad9ae..a3760a0c 100644 --- a/docs/architecture/adr/0019-repository-enforced-commodity-reference-validation.md +++ b/docs/architecture/adr/0019-repository-enforced-commodity-reference-validation.md @@ -3,7 +3,6 @@ - Status: Superseded - Date: 2026-07-23 - Jira: https://gorushkin.atlassian.net/browse/LED-69 -- PR: TBD ## Context diff --git a/docs/architecture/adr/0020-terminal-tombstone-and-reversible-entity-states.md b/docs/architecture/adr/0020-terminal-tombstone-and-reversible-entity-states.md index 6a143e04..e2d4aadc 100644 --- a/docs/architecture/adr/0020-terminal-tombstone-and-reversible-entity-states.md +++ b/docs/architecture/adr/0020-terminal-tombstone-and-reversible-entity-states.md @@ -3,7 +3,6 @@ - Status: Accepted - Date: 2026-07-27 - Jira: https://gorushkin.atlassian.net/browse/LED-122 -- PR: TBD ## Context diff --git a/docs/architecture/adr/0021-split-commodity-reference-business-policy-from-persistence-integrity.md b/docs/architecture/adr/0021-split-commodity-reference-business-policy-from-persistence-integrity.md index 8b90d74d..c1648de5 100644 --- a/docs/architecture/adr/0021-split-commodity-reference-business-policy-from-persistence-integrity.md +++ b/docs/architecture/adr/0021-split-commodity-reference-business-policy-from-persistence-integrity.md @@ -3,7 +3,6 @@ - Status: Accepted - Date: 2026-08-05 - Jira: https://gorushkin.atlassian.net/browse/LED-125 -- PR: TBD ## Context diff --git a/docs/architecture/adr/0022-remove-system-flags-from-account-and-operation-contracts.md b/docs/architecture/adr/0022-remove-system-flags-from-account-and-operation-contracts.md index f06a7f48..a4249fd5 100644 --- a/docs/architecture/adr/0022-remove-system-flags-from-account-and-operation-contracts.md +++ b/docs/architecture/adr/0022-remove-system-flags-from-account-and-operation-contracts.md @@ -3,7 +3,6 @@ - Status: Accepted - Date: 2026-08-07 - Jira: https://gorushkin.atlassian.net/browse/LED-92 -- PR: TBD ## Context diff --git a/restAPI/accounts/create.bru b/restAPI/accounts/create.bru index 9f57c051..0ef2c9a5 100644 --- a/restAPI/accounts/create.bru +++ b/restAPI/accounts/create.bru @@ -18,6 +18,7 @@ body:json { { "name": "new Account2222", "commodityId": "{{commodityId}}", + "description": "New account", "type": "income" } } From b3ea54da1a0cedcef4c56063defe5848d2e3937e Mon Sep 17 00:00:00 2001 From: gorushkin Date: Sun, 16 Aug 2026 19:39:26 +0300 Subject: [PATCH 03/13] LED-92 feat: update documentation to clarify Jira and PR linking guidelines --- .../ledgerly-architecture-docs/SKILL.md | 19 ++++++++++--------- docs/architecture/adr/README.md | 3 ++- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.agents/skills/ledgerly-architecture-docs/SKILL.md b/.agents/skills/ledgerly-architecture-docs/SKILL.md index d336e03b..b490eae6 100644 --- a/.agents/skills/ledgerly-architecture-docs/SKILL.md +++ b/.agents/skills/ledgerly-architecture-docs/SKILL.md @@ -14,7 +14,7 @@ Use this skill when the user asks to: - create or update ADRs; - add architectural decision rationale to task docs; -- link Jira/PR to architecture docs; +- link Jira tasks to architecture docs; - archive completed plan documents; - replace obsolete decisions and mark superseded status. @@ -52,7 +52,10 @@ Every ADR should include: - `Status: Proposed|Accepted|Superseded|Deprecated` - `Date: YYYY-MM-DD` - `Jira: ` -- `PR: ` + +ADR files should not include PR metadata. If a branch reference is useful while +work is in progress, add it under `Related` instead of making it a required +metadata field. ### Minimum ADR sections @@ -71,7 +74,7 @@ Keep each section concise and decision-focused. 3. Preserve historical facts; do not rewrite accepted history to match new intent. 4. If direction changed, create new ADR and mark old one as `Superseded`. 5. Keep links stable; when moving plan docs to archive, update ADR links. -6. Prefer explicit links to Jira issue and PR once available. +6. Prefer explicit links to Jira issues; avoid PR metadata in ADR front matter. 7. When updating docs alongside code, keep the doc change scoped to the same Jira task and explain the code/docs relationship in the final response. @@ -93,8 +96,8 @@ For task documents in `docs/plans/`: 2. Create `NNNN-*.md` with required metadata and sections. 3. Add concise rationale and rejected alternatives. 4. Update ADR index in `docs/architecture/adr/README.md`. -5. Add Jira immediately; fill PR as `TBD` if not created. -6. After PR creation, replace `PR: TBD` with actual link. +5. Add Jira immediately. Optionally mention the implementation branch in + `Related` while work is in progress. ### Supersede an ADR @@ -107,7 +110,7 @@ For task documents in `docs/plans/`: ### Finalize completed task docs 1. Verify all checklist items are complete. -2. Add/verify Jira and PR references. +2. Add/verify Jira references. 3. Move completed plan to archive if team follows archive policy. 4. Update links in ADR and any docs that referenced old path. @@ -132,7 +135,6 @@ Before finalizing documentation changes, confirm: - Status: Proposed|Accepted|Superseded|Deprecated - Date: YYYY-MM-DD - Jira: -- PR: ## Context @@ -160,7 +162,6 @@ Before finalizing documentation changes, confirm: ```md Статус документа: выполнено. -- [x] Связать этот документ с Jira-задачей и итоговым pull request. +- [x] Связать этот документ с Jira-задачей. Jira: - PR: ``` diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index 6edcb825..30a8a054 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -7,7 +7,8 @@ - Один файл = одно решение. - Именование: `NNNN-short-kebab-case-title.md`. - Статусы: `Proposed`, `Accepted`, `Superseded`, `Deprecated`. -- ADR должен ссылаться на Jira. PR указываем только если ссылка уже есть. +- ADR должен ссылаться на Jira. Ветку можно указать в `Related`, если это + помогает найти незавершенную работу. ## Рекомендуемый шаблон From 04e2db3d5ce35178ed99ebb211940db2b8ea1b60 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Mon, 17 Aug 2026 09:20:18 +0300 Subject: [PATCH 04/13] LED-96 test: reset transaction manager mock and verify call count in CreateAccountUseCase tests --- .../usecases/accounts/__tests__/createAccount.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/backend/src/application/usecases/accounts/__tests__/createAccount.test.ts b/apps/backend/src/application/usecases/accounts/__tests__/createAccount.test.ts index db8ca866..74cc26cd 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/createAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/createAccount.test.ts @@ -41,6 +41,8 @@ describe('CreateAccountUseCase', async () => { beforeEach(() => { accountRepository.create.mockReset(); commodityReferencePolicy.assertUsableForNewAccount.mockReset(); + transactionManager.run.mockReset(); + transactionManager.run.mockImplementation((cb: () => unknown) => cb()); createAccountUseCase = new CreateAccountUseCase( accountRepository as unknown as AccountRepositoryInterface, @@ -70,6 +72,8 @@ describe('CreateAccountUseCase', async () => { mockedCommoditySnapshot.id, ); + expect(transactionManager.run).toHaveBeenCalledTimes(1); + expect(accountRepository.create).toHaveBeenCalledWith( user.getId().valueOf(), { From ca7f6eb1aebaad108281151fa92b3225ec79d67e Mon Sep 17 00:00:00 2001 From: gorushkin Date: Mon, 17 Aug 2026 09:37:59 +0300 Subject: [PATCH 05/13] LED-98 refactor: decouple account use case repositories by using interfaces --- apps/backend/src/di/types.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/di/types.ts b/apps/backend/src/di/types.ts index 31cb48d8..f599211a 100644 --- a/apps/backend/src/di/types.ts +++ b/apps/backend/src/di/types.ts @@ -21,16 +21,16 @@ import { CloseCommodityUseCase, OpenCommodityUseCase, } from 'src/application'; +import type { + AccountRepositoryInterface, + CommodityRepositoryInterface, + OperationRepositoryInterface, + TransactionQueryRepositoryInterface, + TransactionRepositoryInterface, + UserRepositoryInterface, +} from 'src/application/interfaces'; import { DataBase } from 'src/db'; import { PasswordManager } from 'src/infrastructure/auth/PasswordManager'; -import { - TransactionQueryRepository, - AccountRepository, - TransactionRepository, - CommodityRepository, - UserRepository, - OperationRepository, -} from 'src/infrastructure/db'; import { AccountController, AuthController, @@ -40,12 +40,12 @@ import { } from 'src/presentation/http'; type Repositories = { - transaction: TransactionRepository; - commodity: CommodityRepository; - transactionQuery: TransactionQueryRepository; - account: AccountRepository; - user: UserRepository; - operation: OperationRepository; + transaction: TransactionRepositoryInterface; + commodity: CommodityRepositoryInterface; + transactionQuery: TransactionQueryRepositoryInterface; + account: AccountRepositoryInterface; + user: UserRepositoryInterface; + operation: OperationRepositoryInterface; }; type Services = { From 9f992452fa7aca8b1893e027c2027663e1275baf Mon Sep 17 00:00:00 2001 From: gorushkin Date: Mon, 17 Aug 2026 09:47:30 +0300 Subject: [PATCH 06/13] LED-112 refactor: unify database schema exports and clean up imports across modules --- apps/backend/src/db/schema.ts | 7 +------ apps/backend/src/db/schemas/accounts.ts | 2 -- apps/backend/src/db/schemas/index.ts | 16 +++++++++++++--- apps/backend/src/db/schemas/operations.ts | 2 -- apps/backend/src/db/schemas/transactions.ts | 10 ++-------- .../db/accounts/account-persistence.mapper.ts | 4 ++-- .../db/accounts/account.repository.ts | 2 +- .../db/commodities/commodity.repository.test.ts | 3 +-- .../db/commodities/commodity.repository.ts | 2 +- .../operations/operation-persistence.mapper.ts | 6 +++--- .../http/accounts/account.integration.test.ts | 2 +- docs/DATABASE_SCHEMA.md | 7 +++++-- 12 files changed, 30 insertions(+), 33 deletions(-) diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 9dd52368..e84d06c3 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -1,6 +1 @@ -export * from './schemas/accounts'; -export * from './schemas/transactions'; -export * from './schemas/operations'; -export * from './schemas/users'; -export * from './schemas/settings'; -export * from './schemas/commodities'; +export * from './schemas'; diff --git a/apps/backend/src/db/schemas/accounts.ts b/apps/backend/src/db/schemas/accounts.ts index 669a390a..fd28679d 100644 --- a/apps/backend/src/db/schemas/accounts.ts +++ b/apps/backend/src/db/schemas/accounts.ts @@ -51,5 +51,3 @@ export const accountsTable = sqliteTable( export type AccountDbRow = InferSelectModel; export type AccountDbInsert = InferInsertModel; - -export type AccountRepoInsert = AccountDbInsert; diff --git a/apps/backend/src/db/schemas/index.ts b/apps/backend/src/db/schemas/index.ts index 5b3ed92b..c5a25875 100644 --- a/apps/backend/src/db/schemas/index.ts +++ b/apps/backend/src/db/schemas/index.ts @@ -1,6 +1,16 @@ export { accountsTable } from './accounts'; -export { operationsTable, operationsRelations } from './operations'; -export { transactionsTable, transactionsRelations } from './transactions'; +export type { AccountDbInsert, AccountDbRow } from './accounts'; +export { commoditiesTable } from './commodities'; +export type { CommodityDbInsert, CommodityDbRow } from './commodities'; +export { operationsRelations, operationsTable } from './operations'; +export type { OperationDbInsert, OperationDbRow } from './operations'; export { settingsTable } from './settings'; +export { transactionsRelations, transactionsTable } from './transactions'; +export type { + TransactionDbInsert, + TransactionDbRow, + TransactionWithRelations, + TransactionWithTwoOperationsPerEntry, +} from './transactions'; export { usersTable } from './users'; -export { commoditiesTable, CommodityDbRow } from './commodities'; +export type { UserDbInsert, UserDbRow } from './users'; diff --git a/apps/backend/src/db/schemas/operations.ts b/apps/backend/src/db/schemas/operations.ts index f5b4891f..cfdddf17 100644 --- a/apps/backend/src/db/schemas/operations.ts +++ b/apps/backend/src/db/schemas/operations.ts @@ -58,5 +58,3 @@ export const operationsRelations = relations(operationsTable, ({ one }) => ({ export type OperationDbRow = InferSelectModel; export type OperationDbInsert = InferInsertModel; - -export type OperationRepoInsert = OperationDbInsert; diff --git a/apps/backend/src/db/schemas/transactions.ts b/apps/backend/src/db/schemas/transactions.ts index c12b7662..25cdbd2d 100644 --- a/apps/backend/src/db/schemas/transactions.ts +++ b/apps/backend/src/db/schemas/transactions.ts @@ -8,8 +8,7 @@ import { uniqueIndex, } from 'drizzle-orm/sqlite-core'; -import { commoditiesTable, OperationDbRow, operationsTable } from '../schema'; - +import { commoditiesTable } from './commodities'; import { createdAt, description, @@ -19,6 +18,7 @@ import { getIsoDateString, version, } from './common'; +import { OperationDbRow, operationsTable } from './operations'; import { usersTable } from './users'; export const transactionsTable = sqliteTable( @@ -63,12 +63,6 @@ export const transactionsRelations = relations( export type TransactionDbRow = InferSelectModel; export type TransactionDbInsert = InferInsertModel; -export type TransactionRepoInsert = TransactionDbInsert; - -export type TransactionDbUpdate = Partial< - Omit ->; - // Type for transaction with nested relations (operations as array) export type TransactionWithRelations = TransactionDbRow & { operations: OperationDbRow[]; diff --git a/apps/backend/src/infrastructure/db/accounts/account-persistence.mapper.ts b/apps/backend/src/infrastructure/db/accounts/account-persistence.mapper.ts index 464ca22d..a18f677d 100644 --- a/apps/backend/src/infrastructure/db/accounts/account-persistence.mapper.ts +++ b/apps/backend/src/infrastructure/db/accounts/account-persistence.mapper.ts @@ -1,4 +1,4 @@ -import { AccountDbRow, AccountRepoInsert } from 'src/db/schema'; +import { AccountDbInsert, AccountDbRow } from 'src/db/schema'; import { Account, AccountSnapshot } from 'src/domain/accounts'; export class AccountPersistenceMapper { @@ -21,7 +21,7 @@ export class AccountPersistenceMapper { }; } - static toDBRowFromSnapshot(snapshot: AccountSnapshot): AccountRepoInsert { + static toDBRowFromSnapshot(snapshot: AccountSnapshot): AccountDbInsert { return { commodityId: snapshot.commodityId, createdAt: snapshot.createdAt, diff --git a/apps/backend/src/infrastructure/db/accounts/account.repository.ts b/apps/backend/src/infrastructure/db/accounts/account.repository.ts index b93db48b..20ce91aa 100644 --- a/apps/backend/src/infrastructure/db/accounts/account.repository.ts +++ b/apps/backend/src/infrastructure/db/accounts/account.repository.ts @@ -8,7 +8,7 @@ import { type AccountRepositoryUpdateInput, type AccountLifecycleAction, } from 'src/application'; -import { accountsTable } from 'src/db/schemas/accounts'; +import { accountsTable } from 'src/db/schemas'; import { AccountSnapshot } from 'src/domain/accounts'; import { AccountPersistenceConflictError, diff --git a/apps/backend/src/infrastructure/db/commodities/commodity.repository.test.ts b/apps/backend/src/infrastructure/db/commodities/commodity.repository.test.ts index d58dc593..542df6b4 100644 --- a/apps/backend/src/infrastructure/db/commodities/commodity.repository.test.ts +++ b/apps/backend/src/infrastructure/db/commodities/commodity.repository.test.ts @@ -2,8 +2,7 @@ import { apiErrorCodes } from '@ledgerly/shared/types'; import { CommodityQuery } from '@ledgerly/shared/validation'; import dayjs from 'dayjs'; import { type CommodityRepositoryUpdateInput } from 'src/application'; -import type { CommodityDbRow } from 'src/db/schemas/commodities'; -import { UserDbRow } from 'src/db/schemas/users'; +import { type CommodityDbRow, UserDbRow } from 'src/db/schemas'; import { compareEntities, compareEntityArrays } from 'src/db/test-utils'; import { CommoditySnapshot } from 'src/domain/commodities/types'; import { CommodityCode, Name, Timestamp } from 'src/domain/domain-core/'; diff --git a/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts b/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts index f918e565..ac198b29 100644 --- a/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts +++ b/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts @@ -9,7 +9,7 @@ import { type CommodityRepositoryUpdateInput, type CommodityRepositoryDeleteInput, } from 'src/application'; -import { commoditiesTable } from 'src/db/schemas/commodities'; +import { commoditiesTable } from 'src/db/schemas'; import { CommoditySnapshot } from 'src/domain/commodities/types'; import { RepositoryInvariantError, diff --git a/apps/backend/src/infrastructure/db/operations/operation-persistence.mapper.ts b/apps/backend/src/infrastructure/db/operations/operation-persistence.mapper.ts index 5adc6381..058d1adc 100644 --- a/apps/backend/src/infrastructure/db/operations/operation-persistence.mapper.ts +++ b/apps/backend/src/infrastructure/db/operations/operation-persistence.mapper.ts @@ -1,15 +1,15 @@ -import { OperationRepoInsert } from 'src/db/schema'; +import { OperationDbInsert } from 'src/db/schema'; import { Operation } from 'src/domain/operations/operation.entity'; import { OperationSnapshot } from 'src/domain/operations/types'; export class OperationPersistenceMapper { - static toDBRow(operation: Operation): OperationRepoInsert { + static toDBRow(operation: Operation): OperationDbInsert { return OperationPersistenceMapper.toDBRowFromSnapshot( operation.toSnapshot(), ); } - static toDBRowFromSnapshot(snapshot: OperationSnapshot): OperationRepoInsert { + static toDBRowFromSnapshot(snapshot: OperationSnapshot): OperationDbInsert { return { accountId: snapshot.accountId, amount: snapshot.amount, diff --git a/apps/backend/src/presentation/http/accounts/account.integration.test.ts b/apps/backend/src/presentation/http/accounts/account.integration.test.ts index 21d3ce5f..f631d3aa 100644 --- a/apps/backend/src/presentation/http/accounts/account.integration.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.integration.test.ts @@ -8,7 +8,7 @@ import { apiErrorCodes, UUID, } from '@ledgerly/shared/types'; -import { CommodityDbRow } from 'src/db/schemas/commodities'; +import { CommodityDbRow } from 'src/db/schemas'; import { TestDB } from 'src/db/test-db'; import { compareEntityArrays } from 'src/db/test-utils/entityComparer'; import { AccountType } from 'src/domain'; diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index a78d348e..5093784a 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -385,8 +385,11 @@ Schemas are defined in the following files: - `apps/backend/src/db/schemas/operations.ts` - Operations - `apps/backend/src/db/schemas/settings.ts` - Settings -All schemas are exported through `apps/backend/src/db/schema.ts`. +The canonical schema module public API is +`apps/backend/src/db/schemas/index.ts`. The +`apps/backend/src/db/schema.ts` file remains as a compatibility entrypoint and +re-exports the schema public API. --- -_Last updated: July 23, 2026_ +_Last updated: August 17, 2026_ From 166dbec1d2c157cc4fda3cced2f9b77905eddae6 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Mon, 17 Aug 2026 09:51:26 +0300 Subject: [PATCH 07/13] LED-112 fix: update import statement for CommodityDbRow to use type import --- .../src/presentation/http/accounts/account.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/presentation/http/accounts/account.integration.test.ts b/apps/backend/src/presentation/http/accounts/account.integration.test.ts index f631d3aa..3af4eeb4 100644 --- a/apps/backend/src/presentation/http/accounts/account.integration.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.integration.test.ts @@ -8,7 +8,7 @@ import { apiErrorCodes, UUID, } from '@ledgerly/shared/types'; -import { CommodityDbRow } from 'src/db/schemas'; +import type { CommodityDbRow } from 'src/db/schemas'; import { TestDB } from 'src/db/test-db'; import { compareEntityArrays } from 'src/db/test-utils/entityComparer'; import { AccountType } from 'src/domain'; From a6c9ee46f12767f9a5c51025df07aceac1e01b40 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Mon, 17 Aug 2026 10:01:11 +0300 Subject: [PATCH 08/13] LED-120 refactor: rename variables for clarity in account filtering tests --- .../db/accounts/account.repository.test.ts | 4 +-- .../http/accounts/account.integration.test.ts | 32 +++++++++++++++++-- docs/DOMAIN.md | 7 ++-- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts b/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts index 0f172cfc..de245c46 100644 --- a/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts +++ b/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts @@ -330,7 +330,7 @@ describe('AccountRepository', () => { (account) => !account.isTombstone, ); - const activeAccounts = accountsDataList.filter( + const openAccounts = accountsDataList.filter( (account) => !account.isClosed && !account.isTombstone, ); @@ -346,7 +346,7 @@ describe('AccountRepository', () => { const testData: [AccountQuery, TestData[]][] = [ [{ status: 'all' }, allAccounts], - [{ status: 'open' }, activeAccounts], + [{ status: 'open' }, openAccounts], [{ status: 'closed' }, closedAccounts], ]; diff --git a/apps/backend/src/presentation/http/accounts/account.integration.test.ts b/apps/backend/src/presentation/http/accounts/account.integration.test.ts index 3af4eeb4..f596c704 100644 --- a/apps/backend/src/presentation/http/accounts/account.integration.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.integration.test.ts @@ -155,8 +155,7 @@ describe('Accounts Integration Tests', () => { describe('GET /api/accounts', () => { const testCases = [ { - description: - 'should return all active open accounts by default for the user', + description: 'should return all open accounts by default for the user', expectedAccounts: (accounts: AccountResponseDTO[]) => accounts.filter( (account) => !account.isClosed && !account.isTombstone, @@ -165,7 +164,7 @@ describe('Accounts Integration Tests', () => { }, { description: - 'should return all accounts including closed when query param status=all', + 'should return all non-deleted accounts when query param status=all', expectedAccounts: (accounts: AccountResponseDTO[]) => accounts.filter((account) => !account.isTombstone), query: { status: 'all' }, @@ -200,6 +199,33 @@ describe('Accounts Integration Tests', () => { compareEntityArrays(responseAccounts, expectedAccounts(accounts)); }); }); + + it.each(['open', 'closed', 'all'] as const)( + 'should not include accounts owned by another user when status=%s', + async (status) => { + const otherUserCommodity = await testDB.createCommodity(otherUserId); + + await testDB.createAccount(otherUserId, otherUserCommodity.id, { + isClosed: status === 'closed', + name: `Other User ${status} Account`, + type: 'asset', + }); + + const response = await injectAuthorized({ + method: 'GET', + url: getWithQueryParamsUrl(status), + }); + + const responseAccounts = JSON.parse( + response.body, + ) as AccountResponseDTO[]; + + expect(response.statusCode).toBe(200); + expect( + responseAccounts.every((account) => account.userId === userId), + ).toBe(true); + }, + ); }); describe('GET /api/accounts/:id', () => { diff --git a/docs/DOMAIN.md b/docs/DOMAIN.md index 5624522d..16c625cf 100644 --- a/docs/DOMAIN.md +++ b/docs/DOMAIN.md @@ -62,10 +62,11 @@ Represents different financial accounts with unified structure for all account t - Reversible account close state is represented by `isClosed` - Terminal account deletion is represented by `isTombstone` - `GET /accounts` returns open accounts by default and supports - `status=open|closed|all`; all normal list filters exclude tombstoned accounts + `status=open|closed|all`; `all` includes open and closed non-tombstoned + accounts, and all normal list filters exclude tombstoned accounts - Closed accounts keep history and reports but cannot receive new operations -- Closed accounts cannot be updated through the regular account update flow; - descriptive-field editing must be handled as an explicit lifecycle exception +- Closed accounts can still have descriptive fields edited through the regular + account update flow, but account `type` cannot be changed while closed - Account `type` can be changed only while the account has no active operations - `DELETE /accounts/:id` means terminal tombstone delete and is rejected while active operations still reference the account From fa283d16243331307c6df8bc90b95bfa89f61b75 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Mon, 17 Aug 2026 10:51:03 +0300 Subject: [PATCH 09/13] LED-127 test: add TransactionManager tests for nested transaction context reuse feat: throw RecordAlreadyExistsError for duplicate email in user creation --- .../db/TransactionManager.test.ts | 33 +++++++++++++++++++ .../infrastructure/db/TransactionManager.ts | 10 +++--- .../db/user/user.repository.test.ts | 19 ++++++++++- .../infrastructure/db/user/user.repository.ts | 7 ++++ 4 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 apps/backend/src/infrastructure/db/TransactionManager.test.ts diff --git a/apps/backend/src/infrastructure/db/TransactionManager.test.ts b/apps/backend/src/infrastructure/db/TransactionManager.test.ts new file mode 100644 index 00000000..abd623cb --- /dev/null +++ b/apps/backend/src/infrastructure/db/TransactionManager.test.ts @@ -0,0 +1,33 @@ +import type { DataBase, TxType } from 'src/db'; +import { describe, expect, it, vi } from 'vitest'; + +import { TransactionManager } from './TransactionManager'; + +describe('TransactionManager', () => { + it('reuses the current transaction context for nested run calls', async () => { + const tx = { id: 'tx' } as unknown as TxType; + const transaction = vi.fn((callback: (tx: TxType) => Promise) => + callback(tx), + ); + const db = { + transaction, + } as unknown as DataBase; + const transactionManager = new TransactionManager(db); + + const result = await transactionManager.run(async () => { + const outerTransaction = transactionManager.getCurrentTransaction(); + + return await transactionManager.run(() => { + expect(transactionManager.getCurrentTransaction()).toBe( + outerTransaction, + ); + + return Promise.resolve('nested result'); + }); + }); + + expect(result).toBe('nested result'); + expect(transaction).toHaveBeenCalledTimes(1); + expect(transactionManager.getCurrentTransaction()).toBe(db); + }); +}); diff --git a/apps/backend/src/infrastructure/db/TransactionManager.ts b/apps/backend/src/infrastructure/db/TransactionManager.ts index d68d2a90..4f59c55c 100644 --- a/apps/backend/src/infrastructure/db/TransactionManager.ts +++ b/apps/backend/src/infrastructure/db/TransactionManager.ts @@ -17,12 +17,12 @@ export class TransactionManager implements TransactionManagerInterface { async run( callback: (context?: TransactionContext) => Promise, ): Promise { - return await this.db.transaction(async (tx: TxType) => { - const existingStore = this.storage.getStore(); - if (existingStore) { - return await callback(); - } + const existingStore = this.storage.getStore(); + if (existingStore) { + return await callback(); + } + return await this.db.transaction(async (tx: TxType) => { return this.storage.run({ tx }, async () => { try { return await callback({}); diff --git a/apps/backend/src/infrastructure/db/user/user.repository.test.ts b/apps/backend/src/infrastructure/db/user/user.repository.test.ts index 19e871ca..2df9f849 100644 --- a/apps/backend/src/infrastructure/db/user/user.repository.test.ts +++ b/apps/backend/src/infrastructure/db/user/user.repository.test.ts @@ -2,7 +2,10 @@ import { UserRepositoryInterface } from 'src/application'; import { Email, Id, Name, Password } from 'src/domain/domain-core'; import { User } from 'src/domain/users/user.entity'; import { TransactionManager } from 'src/infrastructure/db'; -import { RepositoryNotFoundError } from 'src/infrastructure/errors'; +import { + RecordAlreadyExistsError, + RepositoryNotFoundError, +} from 'src/infrastructure/errors'; import { describe, beforeEach, it, expect, vi } from 'vitest'; import { UserRepository } from '../'; @@ -197,6 +200,20 @@ describe('UsersRepository', () => { expect(user1.id).not.toBe(user2.id); }); + + it('throws RecordAlreadyExistsError for duplicate email', async () => { + await userRepository.create(await createUser()); + + await expect(userRepository.create(await createUser())).rejects.toThrow( + new RecordAlreadyExistsError({ + context: { + field: 'email', + tableName: 'users', + value: email, + }, + }), + ); + }); }); describe('findByEmail', () => { diff --git a/apps/backend/src/infrastructure/db/user/user.repository.ts b/apps/backend/src/infrastructure/db/user/user.repository.ts index 033dc07e..17bc8fca 100644 --- a/apps/backend/src/infrastructure/db/user/user.repository.ts +++ b/apps/backend/src/infrastructure/db/user/user.repository.ts @@ -136,6 +136,13 @@ export class UserRepository async () => this.db.insert(usersTable).values(data).returning(userSelect).get(), 'Failed to create user', + { + unique: { + field: 'email', + tableName: 'users', + value: data.email, + }, + }, ); } From 9b5a9f7821bd2ec4de2537bb5b5c1165895a1add Mon Sep 17 00:00:00 2001 From: gorushkin Date: Mon, 17 Aug 2026 11:08:01 +0300 Subject: [PATCH 10/13] LED-127 refactor: enhance TransactionManager to support transaction context in nested runs --- .../src/infrastructure/db/TransactionManager.test.ts | 10 ++++++++-- .../src/infrastructure/db/TransactionManager.ts | 9 ++++++--- .../src/infrastructure/db/user/user.repository.test.ts | 4 +++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/infrastructure/db/TransactionManager.test.ts b/apps/backend/src/infrastructure/db/TransactionManager.test.ts index abd623cb..f2614152 100644 --- a/apps/backend/src/infrastructure/db/TransactionManager.test.ts +++ b/apps/backend/src/infrastructure/db/TransactionManager.test.ts @@ -1,3 +1,4 @@ +import type { TransactionContext } from 'src/application/interfaces'; import type { DataBase, TxType } from 'src/db'; import { describe, expect, it, vi } from 'vitest'; @@ -14,19 +15,24 @@ describe('TransactionManager', () => { } as unknown as DataBase; const transactionManager = new TransactionManager(db); - const result = await transactionManager.run(async () => { + let outerContext: TransactionContext | undefined; + + const result = await transactionManager.run(async (context) => { const outerTransaction = transactionManager.getCurrentTransaction(); + outerContext = context; - return await transactionManager.run(() => { + return await transactionManager.run((nestedContext) => { expect(transactionManager.getCurrentTransaction()).toBe( outerTransaction, ); + expect(nestedContext).toBe(outerContext); return Promise.resolve('nested result'); }); }); expect(result).toBe('nested result'); + expect(outerContext).toEqual({}); expect(transaction).toHaveBeenCalledTimes(1); expect(transactionManager.getCurrentTransaction()).toBe(db); }); diff --git a/apps/backend/src/infrastructure/db/TransactionManager.ts b/apps/backend/src/infrastructure/db/TransactionManager.ts index 4f59c55c..41f2c89a 100644 --- a/apps/backend/src/infrastructure/db/TransactionManager.ts +++ b/apps/backend/src/infrastructure/db/TransactionManager.ts @@ -7,6 +7,7 @@ import type { import { DataBase, TxType } from 'src/db'; type Store = { + context: TransactionContext; tx: TxType; }; @@ -19,13 +20,15 @@ export class TransactionManager implements TransactionManagerInterface { ): Promise { const existingStore = this.storage.getStore(); if (existingStore) { - return await callback(); + return await callback(existingStore.context); } return await this.db.transaction(async (tx: TxType) => { - return this.storage.run({ tx }, async () => { + const context: TransactionContext = {}; + + return this.storage.run({ context, tx }, async () => { try { - return await callback({}); + return await callback(context); } catch (error) { if (process.env.NODE_ENV !== 'test') { console.error('Transaction error, rolling back'); diff --git a/apps/backend/src/infrastructure/db/user/user.repository.test.ts b/apps/backend/src/infrastructure/db/user/user.repository.test.ts index 2df9f849..00f043a4 100644 --- a/apps/backend/src/infrastructure/db/user/user.repository.test.ts +++ b/apps/backend/src/infrastructure/db/user/user.repository.test.ts @@ -204,7 +204,9 @@ describe('UsersRepository', () => { it('throws RecordAlreadyExistsError for duplicate email', async () => { await userRepository.create(await createUser()); - await expect(userRepository.create(await createUser())).rejects.toThrow( + await expect( + userRepository.create(await createUser()), + ).rejects.toThrowError( new RecordAlreadyExistsError({ context: { field: 'email', From 521b60a09b79cac28288d9022b2c479d33d7320a Mon Sep 17 00:00:00 2001 From: gorushkin Date: Mon, 17 Aug 2026 11:21:52 +0300 Subject: [PATCH 11/13] LED-131 refactor: unify account ownership checks by integrating ensureOwnedSnapshot across use cases --- .../accounts/__tests__/closeAccount.test.ts | 2 + .../accounts/__tests__/getAccountById.test.ts | 2 + .../accounts/__tests__/openAccount.test.ts | 2 + .../accounts/__tests__/updateAccount.test.ts | 2 + .../usecases/accounts/accountBase.ts | 41 ------------------- .../usecases/accounts/closeAccount.ts | 23 ++++++----- .../usecases/accounts/deleteAccount.ts | 10 ++--- .../usecases/accounts/getAccountById.ts | 22 ++++++---- .../usecases/accounts/getAllAccounts.ts | 10 ++--- .../usecases/accounts/openAccount.ts | 23 ++++++----- .../usecases/accounts/updateAccount.ts | 23 ++++++----- apps/backend/src/di/container.ts | 8 +++- 12 files changed, 73 insertions(+), 95 deletions(-) delete mode 100644 apps/backend/src/application/usecases/accounts/accountBase.ts diff --git a/apps/backend/src/application/usecases/accounts/__tests__/closeAccount.test.ts b/apps/backend/src/application/usecases/accounts/__tests__/closeAccount.test.ts index 64ece421..66b7d236 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/closeAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/closeAccount.test.ts @@ -7,6 +7,7 @@ import type { TransactionManagerInterface, } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; +import { ensureOwnedSnapshot } from 'src/application/shared/ensureOwnedSnapshot'; import { createUser } from 'src/db/createTestUser'; import { AccountSnapshot } from 'src/domain/accounts'; import { Timestamp } from 'src/domain/domain-core'; @@ -74,6 +75,7 @@ describe('CloseAccountUseCase', async () => { closeAccountUseCase = new CloseAccountUseCase( mockAccountRepository as unknown as AccountRepositoryInterface, mockTransactionManager as unknown as TransactionManagerInterface, + ensureOwnedSnapshot, ); }); diff --git a/apps/backend/src/application/usecases/accounts/__tests__/getAccountById.test.ts b/apps/backend/src/application/usecases/accounts/__tests__/getAccountById.test.ts index fb296df8..a133fd7f 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/getAccountById.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/getAccountById.test.ts @@ -5,6 +5,7 @@ import { } from 'src/application/application.errors'; import type { AccountRepositoryInterface } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; +import { ensureOwnedSnapshot } from 'src/application/shared/ensureOwnedSnapshot'; import { createUser } from 'src/db/createTestUser'; import { Account, AccountSnapshot } from 'src/domain/accounts'; import { Timestamp } from 'src/domain/domain-core'; @@ -63,6 +64,7 @@ describe('GetAccountByIdUseCase', async () => { getAccountByIdUseCase = new GetAccountByIdUseCase( mockAccountRepository as unknown as AccountRepositoryInterface, + ensureOwnedSnapshot, ); }); diff --git a/apps/backend/src/application/usecases/accounts/__tests__/openAccount.test.ts b/apps/backend/src/application/usecases/accounts/__tests__/openAccount.test.ts index 92ee3bf4..bb2ff836 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/openAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/openAccount.test.ts @@ -7,6 +7,7 @@ import type { TransactionManagerInterface, } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; +import { ensureOwnedSnapshot } from 'src/application/shared/ensureOwnedSnapshot'; import { createUser } from 'src/db/createTestUser'; import { AccountSnapshot } from 'src/domain/accounts'; import { Timestamp } from 'src/domain/domain-core'; @@ -74,6 +75,7 @@ describe('OpenAccountUseCase', async () => { openAccountUseCase = new OpenAccountUseCase( mockAccountRepository as unknown as AccountRepositoryInterface, mockTransactionManager as unknown as TransactionManagerInterface, + ensureOwnedSnapshot, ); }); diff --git a/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts b/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts index 2ee5f154..7d160650 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts @@ -9,6 +9,7 @@ import type { } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { AccountOperationPolicy } from 'src/application/services'; +import { ensureOwnedSnapshot } from 'src/application/shared/ensureOwnedSnapshot'; import { createUser } from 'src/db/createTestUser'; import { Account, AccountSnapshot } from 'src/domain/accounts'; import { Timestamp } from 'src/domain/domain-core'; @@ -72,6 +73,7 @@ describe('UpdateAccount', async () => { mockAccountRepository as unknown as AccountRepositoryInterface, mockAccountOperationPolicy as unknown as AccountOperationPolicy, mockTransactionManager as unknown as TransactionManagerInterface, + ensureOwnedSnapshot, ); }); diff --git a/apps/backend/src/application/usecases/accounts/accountBase.ts b/apps/backend/src/application/usecases/accounts/accountBase.ts deleted file mode 100644 index a7ff32d7..00000000 --- a/apps/backend/src/application/usecases/accounts/accountBase.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { UUID } from '@ledgerly/shared/types'; -import { - EntityNotFoundError, - UnauthorizedAccessError, -} from 'src/application/application.errors'; -import { Account, AccountSnapshot } from 'src/domain/accounts'; -import { User } from 'src/domain/users/user.entity'; - -import { AccountRepositoryInterface } from '../../interfaces'; - -export class AccountUseCaseBase { - constructor( - protected readonly accountRepository: AccountRepositoryInterface, - ) {} - - protected async ensureAccountExistsAndOwned( - user: User, - accountId: UUID, - ): Promise { - const account = await this.accountRepository.getById( - user.getId().valueOf(), - accountId, - ); - - if (!account) { - throw new EntityNotFoundError({ - entityId: accountId, - entityType: Account.entityType, - }); - } - - if (!user.verifyOwnership(account.userId)) { - throw new UnauthorizedAccessError({ - entityId: accountId, - entityType: Account.entityType, - }); - } - - return account; - } -} diff --git a/apps/backend/src/application/usecases/accounts/closeAccount.ts b/apps/backend/src/application/usecases/accounts/closeAccount.ts index da21f26a..ad669c8c 100644 --- a/apps/backend/src/application/usecases/accounts/closeAccount.ts +++ b/apps/backend/src/application/usecases/accounts/closeAccount.ts @@ -4,25 +4,26 @@ import type { TransactionManagerInterface, } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; -import { Account } from 'src/domain/accounts'; +import { EnsureOwnedSnapshotFn } from 'src/application/shared/ensureOwnedSnapshot'; +import { Account, AccountSnapshot } from 'src/domain/accounts'; import { User } from 'src/domain/users/user.entity'; -import { AccountUseCaseBase } from './accountBase'; - -export class CloseAccountUseCase extends AccountUseCaseBase { +export class CloseAccountUseCase { constructor( - accountRepository: AccountRepositoryInterface, + protected readonly accountRepository: AccountRepositoryInterface, private readonly transactionManager: TransactionManagerInterface, - ) { - super(accountRepository); - } + protected readonly ensureOwnedSnapshot: EnsureOwnedSnapshotFn, + ) {} async execute(user: User, accountId: UUID): Promise { const accountSnapshot = await this.transactionManager.run(async () => { - const accountData = await this.ensureAccountExistsAndOwned( + const accountData = await this.ensureOwnedSnapshot({ + entityId: accountId, + entityType: Account.entityType, + getOwnerId: (account) => account.userId, + load: this.accountRepository.getById.bind(this.accountRepository), user, - accountId, - ); + }); const account = Account.restore(accountData); const result = account.close(); diff --git a/apps/backend/src/application/usecases/accounts/deleteAccount.ts b/apps/backend/src/application/usecases/accounts/deleteAccount.ts index f091bb8f..027f06c0 100644 --- a/apps/backend/src/application/usecases/accounts/deleteAccount.ts +++ b/apps/backend/src/application/usecases/accounts/deleteAccount.ts @@ -9,17 +9,13 @@ import type { AccountSnapshot } from 'src/domain/accounts'; import { Account } from 'src/domain/accounts'; import { User } from 'src/domain/users/user.entity'; -import { AccountUseCaseBase } from './accountBase'; - -export class DeleteAccountUseCase extends AccountUseCaseBase { +export class DeleteAccountUseCase { constructor( - accountRepository: AccountRepositoryInterface, + protected readonly accountRepository: AccountRepositoryInterface, private readonly accountOperationPolicy: AccountOperationPolicy, private readonly transactionManager: TransactionManagerInterface, protected readonly ensureOwnedSnapshot: EnsureOwnedSnapshotFn, - ) { - super(accountRepository); - } + ) {} async execute(user: User, accountId: UUID): Promise { await this.transactionManager.run(async () => { diff --git a/apps/backend/src/application/usecases/accounts/getAccountById.ts b/apps/backend/src/application/usecases/accounts/getAccountById.ts index 90b9749e..094ba7a1 100644 --- a/apps/backend/src/application/usecases/accounts/getAccountById.ts +++ b/apps/backend/src/application/usecases/accounts/getAccountById.ts @@ -1,17 +1,25 @@ import { AccountResponseDTO, UUID } from '@ledgerly/shared/types'; import type { AccountRepositoryInterface } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; +import { EnsureOwnedSnapshotFn } from 'src/application/shared/ensureOwnedSnapshot'; +import type { AccountSnapshot } from 'src/domain/accounts'; +import { Account } from 'src/domain/accounts'; import { User } from 'src/domain/users/user.entity'; -import { AccountUseCaseBase } from './accountBase'; - -export class GetAccountByIdUseCase extends AccountUseCaseBase { - constructor(accountRepository: AccountRepositoryInterface) { - super(accountRepository); - } +export class GetAccountByIdUseCase { + constructor( + protected readonly accountRepository: AccountRepositoryInterface, + protected readonly ensureOwnedSnapshot: EnsureOwnedSnapshotFn, + ) {} async execute(user: User, id: UUID): Promise { - const account = await this.ensureAccountExistsAndOwned(user, id); + const account = await this.ensureOwnedSnapshot({ + entityId: id, + entityType: Account.entityType, + getOwnerId: (account) => account.userId, + load: this.accountRepository.getById.bind(this.accountRepository), + user, + }); return AccountMapper.toResponseDTOFromSnapshot(account); } diff --git a/apps/backend/src/application/usecases/accounts/getAllAccounts.ts b/apps/backend/src/application/usecases/accounts/getAllAccounts.ts index 57c60a82..df6537a0 100644 --- a/apps/backend/src/application/usecases/accounts/getAllAccounts.ts +++ b/apps/backend/src/application/usecases/accounts/getAllAccounts.ts @@ -4,12 +4,10 @@ import type { AccountRepositoryInterface } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { User } from 'src/domain/users/user.entity'; -import { AccountUseCaseBase } from './accountBase'; - -export class GetAllAccountsUseCase extends AccountUseCaseBase { - constructor(accountRepository: AccountRepositoryInterface) { - super(accountRepository); - } +export class GetAllAccountsUseCase { + constructor( + protected readonly accountRepository: AccountRepositoryInterface, + ) {} async execute( user: User, diff --git a/apps/backend/src/application/usecases/accounts/openAccount.ts b/apps/backend/src/application/usecases/accounts/openAccount.ts index 83d06458..ac133de1 100644 --- a/apps/backend/src/application/usecases/accounts/openAccount.ts +++ b/apps/backend/src/application/usecases/accounts/openAccount.ts @@ -4,25 +4,26 @@ import type { TransactionManagerInterface, } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; -import { Account } from 'src/domain/accounts'; +import { EnsureOwnedSnapshotFn } from 'src/application/shared/ensureOwnedSnapshot'; +import { Account, AccountSnapshot } from 'src/domain/accounts'; import { User } from 'src/domain/users/user.entity'; -import { AccountUseCaseBase } from './accountBase'; - -export class OpenAccountUseCase extends AccountUseCaseBase { +export class OpenAccountUseCase { constructor( - accountRepository: AccountRepositoryInterface, + protected readonly accountRepository: AccountRepositoryInterface, private readonly transactionManager: TransactionManagerInterface, - ) { - super(accountRepository); - } + protected readonly ensureOwnedSnapshot: EnsureOwnedSnapshotFn, + ) {} async execute(user: User, accountId: UUID): Promise { const accountSnapshot = await this.transactionManager.run(async () => { - const accountData = await this.ensureAccountExistsAndOwned( + const accountData = await this.ensureOwnedSnapshot({ + entityId: accountId, + entityType: Account.entityType, + getOwnerId: (account) => account.userId, + load: this.accountRepository.getById.bind(this.accountRepository), user, - accountId, - ); + }); const account = Account.restore(accountData); const result = account.open(); diff --git a/apps/backend/src/application/usecases/accounts/updateAccount.ts b/apps/backend/src/application/usecases/accounts/updateAccount.ts index 3e6db2c8..4328b7e2 100644 --- a/apps/backend/src/application/usecases/accounts/updateAccount.ts +++ b/apps/backend/src/application/usecases/accounts/updateAccount.ts @@ -9,20 +9,18 @@ import type { } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { AccountOperationPolicy } from 'src/application/services'; -import { Account } from 'src/domain/accounts'; +import { EnsureOwnedSnapshotFn } from 'src/application/shared/ensureOwnedSnapshot'; +import { Account, AccountSnapshot } from 'src/domain/accounts'; import { ClosedAccountOperationError } from 'src/domain/domain.errors'; import { User } from 'src/domain/users/user.entity'; -import { AccountUseCaseBase } from './accountBase'; - -export class UpdateAccountUseCase extends AccountUseCaseBase { +export class UpdateAccountUseCase { constructor( - accountRepository: AccountRepositoryInterface, + protected readonly accountRepository: AccountRepositoryInterface, private readonly accountOperationPolicy: AccountOperationPolicy, private readonly transactionManager: TransactionManagerInterface, - ) { - super(accountRepository); - } + protected readonly ensureOwnedSnapshot: EnsureOwnedSnapshotFn, + ) {} private async checkAccountUpdateValidity( user: User, @@ -48,10 +46,13 @@ export class UpdateAccountUseCase extends AccountUseCaseBase { data: AccountUpdateDTO, ): Promise { const accountSnapshot = await this.transactionManager.run(async () => { - const accountData = await this.ensureAccountExistsAndOwned( + const accountData = await this.ensureOwnedSnapshot({ + entityId: accountId, + entityType: Account.entityType, + getOwnerId: (account) => account.userId, + load: this.accountRepository.getById.bind(this.accountRepository), user, - accountId, - ); + }); const account = Account.restore(accountData); await this.checkAccountUpdateValidity(user, accountId, account, data); diff --git a/apps/backend/src/di/container.ts b/apps/backend/src/di/container.ts index b8f6d9c2..19a85d78 100644 --- a/apps/backend/src/di/container.ts +++ b/apps/backend/src/di/container.ts @@ -105,11 +105,15 @@ export const createContainer = (db: DataBase): AppContainer => { commodityReferencePolicy, ); const getAllAccountsUseCase = new GetAllAccountsUseCase(accountRepository); - const getAccountByIdUseCase = new GetAccountByIdUseCase(accountRepository); + const getAccountByIdUseCase = new GetAccountByIdUseCase( + accountRepository, + ensureOwnedSnapshot, + ); const updateAccountUseCase = new UpdateAccountUseCase( accountRepository, accountOperationPolicy, transactionManager, + ensureOwnedSnapshot, ); const deleteAccountUseCase = new DeleteAccountUseCase( accountRepository, @@ -120,10 +124,12 @@ export const createContainer = (db: DataBase): AppContainer => { const closeAccountUseCase = new CloseAccountUseCase( accountRepository, transactionManager, + ensureOwnedSnapshot, ); const openAccountUseCase = new OpenAccountUseCase( accountRepository, transactionManager, + ensureOwnedSnapshot, ); const loginUserUseCase = new LoginUserUseCase(userRepository); From 2211a3a72ae78e85546448dc2c893eab51c00503 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Mon, 17 Aug 2026 13:56:46 +0300 Subject: [PATCH 12/13] LED-135 refactor: replace UserAlreadyExistsError with EntityAlreadyExistsError and update related error handling across services and tests --- .../src/__tests__/user/user.service.test.ts | 4 +- .../application/application.errors.test.ts | 18 ++-- .../src/application/application.errors.ts | 22 +++-- .../shared/repositoryConflictMapper.ts | 78 ++++++++++++++++ .../accounts/__tests__/createAccount.test.ts | 33 ++++++- .../accounts/__tests__/updateAccount.test.ts | 26 ++++++ .../usecases/accounts/createAccount.ts | 17 +++- .../usecases/accounts/updateAccount.ts | 19 +++- .../auth/__tests__/registerUser.test.ts | 43 ++++----- .../application/usecases/auth/registerUser.ts | 19 ++-- .../__tests__/createCommodity.test.ts | 32 ++++++- .../__tests__/updateCommodity.test.ts | 38 +++++++- .../usecases/commodities/createCommodity.ts | 17 +++- .../usecases/commodities/updateCommodity.ts | 19 +++- .../http/accounts/account.integration.test.ts | 20 +++++ .../http/auth/auth.integration.test.ts | 9 +- .../commodities/commodity.integration.test.ts | 20 +++++ .../presentation/http/error-handler.test.ts | 16 ++-- .../src/presentation/http/error-handler.ts | 2 +- apps/backend/src/services/user.service.ts | 14 ++- docs/ERROR_ARCHITECTURE.md | 5 +- .../adr/0008-structured-api-error-contract.md | 6 +- ...23-entity-already-exists-error-contract.md | 88 +++++++++++++++++++ docs/architecture/adr/README.md | 1 + packages/shared/src/types/error-contract.ts | 16 +++- 25 files changed, 497 insertions(+), 85 deletions(-) create mode 100644 apps/backend/src/application/shared/repositoryConflictMapper.ts create mode 100644 docs/architecture/adr/0023-entity-already-exists-error-contract.md diff --git a/apps/backend/src/__tests__/user/user.service.test.ts b/apps/backend/src/__tests__/user/user.service.test.ts index 07c1901a..c0724604 100644 --- a/apps/backend/src/__tests__/user/user.service.test.ts +++ b/apps/backend/src/__tests__/user/user.service.test.ts @@ -1,6 +1,6 @@ import { + EntityAlreadyExistsError, InvalidPasswordError, - UserAlreadyExistsError, UserNotFoundError, } from 'src/application/application.errors'; import { Id } from 'src/domain/domain-core'; @@ -114,7 +114,7 @@ describe.skip('UserService', () => { mockUsersRepository.findByEmail.mockResolvedValue({ id: '1' }); await expect(service.update(nextId, userData)).rejects.toThrowError( - UserAlreadyExistsError, + EntityAlreadyExistsError, ); }); diff --git a/apps/backend/src/application/application.errors.test.ts b/apps/backend/src/application/application.errors.test.ts index 7e94043a..6be090c4 100644 --- a/apps/backend/src/application/application.errors.test.ts +++ b/apps/backend/src/application/application.errors.test.ts @@ -4,10 +4,10 @@ import { AuthenticationFailedError, CommodityClosedError, CommodityHasActiveReferencesError, + EntityAlreadyExistsError, InvalidPasswordError, ApplicationError, EntityNotFoundError, - UserAlreadyExistsError, UserNotFoundError, } from 'src/application/application.errors'; import { describe, expect, it } from 'vitest'; @@ -38,10 +38,18 @@ describe('coded application errors', () => { }, ); - it('uses a stable public code for a registration conflict', () => { - expect(new UserAlreadyExistsError()).toMatchObject({ - code: apiErrorCodes.registrationConflict, - context: {}, + it('uses a stable public code for an entity that already exists', () => { + expect( + new EntityAlreadyExistsError({ + entityType: 'user', + field: 'email', + }), + ).toMatchObject({ + code: apiErrorCodes.entityAlreadyExists, + context: { + entityType: 'user', + field: 'email', + }, }); }); diff --git a/apps/backend/src/application/application.errors.ts b/apps/backend/src/application/application.errors.ts index 8063d801..bfee76cb 100644 --- a/apps/backend/src/application/application.errors.ts +++ b/apps/backend/src/application/application.errors.ts @@ -40,6 +40,19 @@ export class EntityNotFoundError extends CodedApplicationError<'ENTITY_NOT_FOUND } } +/** + * Thrown when an entity violates a public uniqueness contract. + */ +export class EntityAlreadyExistsError extends CodedApplicationError<'ENTITY_ALREADY_EXISTS'> { + constructor(context: ErrorContextByCode['ENTITY_ALREADY_EXISTS']) { + super( + `${context.entityType} already exists for ${context.field}`, + apiErrorCodes.entityAlreadyExists, + context, + ); + } +} + /** * Thrown when a user attempts to access an entity they don't own. */ @@ -131,15 +144,6 @@ export class InvalidPasswordError extends AuthenticationFailedError { } } -/** - * Thrown when trying to register a user that already exists. - */ -export class UserAlreadyExistsError extends CodedApplicationError<'REGISTRATION_CONFLICT'> { - constructor(message = 'User already exists') { - super(message, apiErrorCodes.registrationConflict, {}); - } -} - /** * Thrown when an update cannot be applied because aggregate versions differ. */ diff --git a/apps/backend/src/application/shared/repositoryConflictMapper.ts b/apps/backend/src/application/shared/repositoryConflictMapper.ts new file mode 100644 index 00000000..de3a457f --- /dev/null +++ b/apps/backend/src/application/shared/repositoryConflictMapper.ts @@ -0,0 +1,78 @@ +import type { ErrorContextByCode } from '@ledgerly/shared/types'; +import { EntityAlreadyExistsError } from 'src/application/application.errors'; +import { RecordAlreadyExistsError } from 'src/infrastructure/errors'; + +type EntityAlreadyExistsContext = ErrorContextByCode['ENTITY_ALREADY_EXISTS']; + +type RepositoryConflictMapping = EntityAlreadyExistsContext & { + tableName: string; +}; + +type PublicAlreadyExistsField = EntityAlreadyExistsContext['field']; +type PublicAlreadyExistsEntityType = EntityAlreadyExistsContext['entityType']; +type PublicContextByEntityType = { + [EntityType in PublicAlreadyExistsEntityType]: Extract< + EntityAlreadyExistsContext, + { entityType: EntityType } + >; +}; + +const publicFieldByDiagnosticField: Partial< + Record +> = { + accountName: 'name', + code: 'code', + email: 'email', + name: 'name', +}; + +const normalizeField = (field: string): PublicAlreadyExistsField | undefined => + publicFieldByDiagnosticField[field]; + +const publicContextByEntityType = { + account: { entityType: 'account', field: 'name' }, + commodity: { entityType: 'commodity', field: 'code' }, + user: { entityType: 'user', field: 'email' }, +} satisfies PublicContextByEntityType; + +const toPublicContext = ( + mapping: RepositoryConflictMapping, +): EntityAlreadyExistsContext => publicContextByEntityType[mapping.entityType]; + +const findMapping = ( + error: RecordAlreadyExistsError, + mappings: readonly RepositoryConflictMapping[], +): RepositoryConflictMapping | undefined => { + const diagnostic = error.context?.unique ?? error.context; + const tableName = diagnostic?.tableName; + const field = diagnostic?.field; + + if (!tableName || !field) return undefined; + + const normalizedField = normalizeField(field); + if (!normalizedField) return undefined; + + return mappings.find( + (mapping) => + mapping.tableName === tableName && mapping.field === normalizedField, + ); +}; + +export const mapRepositoryAlreadyExists = async ( + operation: () => Promise, + mappings: readonly RepositoryConflictMapping[], +): Promise => { + try { + return await operation(); + } catch (error) { + if (error instanceof RecordAlreadyExistsError) { + const mapping = findMapping(error, mappings); + + if (mapping) { + throw new EntityAlreadyExistsError(toPublicContext(mapping)); + } + } + + throw error; + } +}; diff --git a/apps/backend/src/application/usecases/accounts/__tests__/createAccount.test.ts b/apps/backend/src/application/usecases/accounts/__tests__/createAccount.test.ts index 74cc26cd..a6b98a3b 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/createAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/createAccount.test.ts @@ -1,11 +1,13 @@ +import { apiErrorCodes } from '@ledgerly/shared/types'; import type { - TransactionManagerInterface, AccountRepositoryInterface, + TransactionManagerInterface, } from 'src/application'; import { CommodityReferencePolicy } from 'src/application/services'; import { createUser } from 'src/db/createTestUser'; import { Commodity } from 'src/domain'; import { CommodityCode } from 'src/domain/domain-core'; +import { RecordAlreadyExistsError } from 'src/infrastructure/errors'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CreateAccountUseCase } from '../createAccount'; @@ -117,5 +119,34 @@ describe('CreateAccountUseCase', async () => { expect(accountRepository.create).not.toHaveBeenCalled(); }); + + it('should map duplicate account names to ENTITY_ALREADY_EXISTS', async () => { + const mockedCommoditySnapshot = commodity.toSnapshot(); + + accountRepository.create.mockRejectedValue( + new RecordAlreadyExistsError({ + context: { + field: 'accountName', + tableName: 'accounts', + value: name, + }, + }), + ); + + await expect( + createAccountUseCase.execute(user, { + commodityId: mockedCommoditySnapshot.id, + description, + name, + type, + }), + ).rejects.toMatchObject({ + code: apiErrorCodes.entityAlreadyExists, + context: { + entityType: 'account', + field: 'name', + }, + }); + }); }); }); diff --git a/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts b/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts index 7d160650..2e529069 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts @@ -15,6 +15,7 @@ import { Account, AccountSnapshot } from 'src/domain/accounts'; import { Timestamp } from 'src/domain/domain-core'; import { Id } from 'src/domain/domain-core/value-objects/Id'; import { ClosedAccountOperationError } from 'src/domain/domain.errors'; +import { RecordAlreadyExistsError } from 'src/infrastructure/errors'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { UpdateAccountUseCase } from '../updateAccount'; @@ -120,6 +121,31 @@ describe('UpdateAccount', async () => { ); }); + it('maps duplicate account names to ENTITY_ALREADY_EXISTS', async () => { + mockAccountRepository.getById.mockResolvedValue(mockAccountData); + mockAccountRepository.update.mockRejectedValue( + new RecordAlreadyExistsError({ + context: { + field: 'accountName', + tableName: 'accounts', + value: mockAccountUpdatedData.name, + }, + }), + ); + + await expect( + updateAccountUseCase.execute(user, accountId, { + name: mockAccountUpdatedData.name, + }), + ).rejects.toMatchObject({ + code: apiErrorCodes.entityAlreadyExists, + context: { + entityType: 'account', + field: 'name', + }, + }); + }); + it('should throw error when account does not exist', async () => { mockAccountRepository.getById.mockResolvedValue(null); diff --git a/apps/backend/src/application/usecases/accounts/createAccount.ts b/apps/backend/src/application/usecases/accounts/createAccount.ts index 2e7bbad0..cefb926a 100644 --- a/apps/backend/src/application/usecases/accounts/createAccount.ts +++ b/apps/backend/src/application/usecases/accounts/createAccount.ts @@ -5,6 +5,7 @@ import type { } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { CommodityReferencePolicy } from 'src/application/services'; +import { mapRepositoryAlreadyExists } from 'src/application/shared/repositoryConflictMapper'; import { Account } from 'src/domain/'; import { User } from 'src/domain/users/user.entity'; export class CreateAccountUseCase { @@ -30,9 +31,19 @@ export class CreateAccountUseCase { const accountSnapshot = account.toSnapshot(); - await this.accountRepository.create( - user.getId().valueOf(), - accountSnapshot, + await mapRepositoryAlreadyExists( + () => + this.accountRepository.create( + user.getId().valueOf(), + accountSnapshot, + ), + [ + { + entityType: 'account', + field: 'name', + tableName: 'accounts', + }, + ], ); return accountSnapshot; diff --git a/apps/backend/src/application/usecases/accounts/updateAccount.ts b/apps/backend/src/application/usecases/accounts/updateAccount.ts index 4328b7e2..eb86d1b5 100644 --- a/apps/backend/src/application/usecases/accounts/updateAccount.ts +++ b/apps/backend/src/application/usecases/accounts/updateAccount.ts @@ -10,6 +10,7 @@ import type { import { AccountMapper } from 'src/application/mappers'; import { AccountOperationPolicy } from 'src/application/services'; import { EnsureOwnedSnapshotFn } from 'src/application/shared/ensureOwnedSnapshot'; +import { mapRepositoryAlreadyExists } from 'src/application/shared/repositoryConflictMapper'; import { Account, AccountSnapshot } from 'src/domain/accounts'; import { ClosedAccountOperationError } from 'src/domain/domain.errors'; import { User } from 'src/domain/users/user.entity'; @@ -59,10 +60,20 @@ export class UpdateAccountUseCase { account.update(AccountMapper.toUpdateProps(data)); - await this.accountRepository.update( - user.getId().valueOf(), - accountId, - account.toSnapshot(), + await mapRepositoryAlreadyExists( + () => + this.accountRepository.update( + user.getId().valueOf(), + accountId, + account.toSnapshot(), + ), + [ + { + entityType: 'account', + field: 'name', + tableName: 'accounts', + }, + ], ); return account.toSnapshot(); diff --git a/apps/backend/src/application/usecases/auth/__tests__/registerUser.test.ts b/apps/backend/src/application/usecases/auth/__tests__/registerUser.test.ts index 9ee44dc8..5c539103 100644 --- a/apps/backend/src/application/usecases/auth/__tests__/registerUser.test.ts +++ b/apps/backend/src/application/usecases/auth/__tests__/registerUser.test.ts @@ -1,8 +1,9 @@ -import { UserAlreadyExistsError } from 'src/application/application.errors'; +import { apiErrorCodes } from '@ledgerly/shared/types'; import { CreateUserRequestDTO } from 'src/application/dto'; import type { UserRepositoryInterface } from 'src/application/interfaces'; import { Id } from 'src/domain/domain-core/value-objects/Id'; import { User } from 'src/domain/users/user.entity'; +import { RecordAlreadyExistsError } from 'src/infrastructure/errors'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { RegisterUserUseCase } from '../registerUser'; @@ -39,14 +40,13 @@ describe('RegisterUserUseCase', () => { name, }; - mockUserRepository.getByEmail.mockResolvedValue(null); mockUserRepository.create.mockResolvedValue(persistedUser); const result = await registerUserUseCase.execute(validRequest); expect(result).toBe(persistedUser); - expect(mockUserRepository.getByEmail).toHaveBeenCalledWith(email); + expect(mockUserRepository.getByEmail).not.toHaveBeenCalled(); expect(mockUserRepository.create).toHaveBeenCalledOnce(); @@ -57,31 +57,34 @@ describe('RegisterUserUseCase', () => { expect(typeof createPayload.getId().valueOf()).toBe('string'); }); - it('should throw error if user with email already exists', async () => { - // Arrange - mockUserRepository.getByEmail.mockResolvedValue({ - email: 'test@example.com', - id: Id.create().valueOf(), - }); - - // Act & Assert - await expect(registerUserUseCase.execute(validRequest)).rejects.toThrow( - UserAlreadyExistsError, + it('should map duplicate email repository errors to ENTITY_ALREADY_EXISTS', async () => { + mockUserRepository.create.mockRejectedValue( + new RecordAlreadyExistsError({ + context: { + field: 'email', + tableName: 'users', + value: email, + }, + }), ); - expect(mockUserRepository.create).not.toHaveBeenCalled(); - }); + await expect( + registerUserUseCase.execute(validRequest), + ).rejects.toMatchObject({ + code: apiErrorCodes.entityAlreadyExists, + context: { + entityType: 'user', + field: 'email', + }, + }); - it.todo('should call findByEmail with correct email'); + expect(mockUserRepository.getByEmail).not.toHaveBeenCalled(); + }); it.todo('should hash password before storing'); it.todo('should pass all data to repository create method'); - it.todo('should not call create method if user already exists'); - - it.todo('should handle repository errors during user lookup'); - it.todo('should handle password hashing errors'); it.todo('should handle repository errors during user creation'); diff --git a/apps/backend/src/application/usecases/auth/registerUser.ts b/apps/backend/src/application/usecases/auth/registerUser.ts index 5aa944ce..a0c52973 100644 --- a/apps/backend/src/application/usecases/auth/registerUser.ts +++ b/apps/backend/src/application/usecases/auth/registerUser.ts @@ -1,6 +1,6 @@ -import { UserAlreadyExistsError } from 'src/application/application.errors'; import { CreateUserRequestDTO, UserResponseDTO } from 'src/application/dto'; import type { UserRepositoryInterface } from 'src/application/interfaces'; +import { mapRepositoryAlreadyExists } from 'src/application/shared/repositoryConflictMapper'; import { Email, Name, Password } from 'src/domain/domain-core'; import { User } from 'src/domain/users/user.entity'; @@ -10,18 +10,21 @@ export class RegisterUserUseCase { async execute(request: CreateUserRequestDTO): Promise { const { email, name, password } = request; - const existingUser = await this.userRepository.getByEmail(email); - - if (existingUser) { - throw new UserAlreadyExistsError(); - } - const nameVO = Name.create(name); const emailVO = Email.create(email); const passwordVO = await Password.create(password); const user = User.create(nameVO, emailVO, passwordVO); - return this.userRepository.create(user); + return mapRepositoryAlreadyExists( + () => this.userRepository.create(user), + [ + { + entityType: 'user', + field: 'email', + tableName: 'users', + }, + ], + ); } } diff --git a/apps/backend/src/application/usecases/commodities/__tests__/createCommodity.test.ts b/apps/backend/src/application/usecases/commodities/__tests__/createCommodity.test.ts index 5f4e14bd..f32afe56 100644 --- a/apps/backend/src/application/usecases/commodities/__tests__/createCommodity.test.ts +++ b/apps/backend/src/application/usecases/commodities/__tests__/createCommodity.test.ts @@ -1,8 +1,9 @@ -import { CommodityCreateDTO } from '@ledgerly/shared/types'; +import { CommodityCreateDTO, apiErrorCodes } from '@ledgerly/shared/types'; import { CommodityRepositoryInterface } from 'src/application'; import { createUser } from 'src/db/createTestUser'; import { User } from 'src/domain'; import { Name, CommodityCode } from 'src/domain/domain-core/'; +import { RecordAlreadyExistsError } from 'src/infrastructure/errors'; import { beforeEach, describe, expect, it, vi, beforeAll } from 'vitest'; import { CreateCommodityUseCase } from '../createCommodity'; @@ -65,5 +66,34 @@ describe('CreateCommodityUseCase', () => { userId: user.getId().valueOf(), }); }); + + it('should map duplicate commodity codes to ENTITY_ALREADY_EXISTS', async () => { + const data: CommodityCreateDTO = { + code: CommodityCode.create('TEST').valueOf(), + name: Name.create('Test Commodity').valueOf(), + precision: 2, + symbol: null, + }; + + commodityRepository.create.mockRejectedValue( + new RecordAlreadyExistsError({ + context: { + field: 'code', + tableName: 'commodities', + value: data.code, + }, + }), + ); + + await expect( + createCommodityUseCase.execute(user, data), + ).rejects.toMatchObject({ + code: apiErrorCodes.entityAlreadyExists, + context: { + entityType: 'commodity', + field: 'code', + }, + }); + }); }); }); diff --git a/apps/backend/src/application/usecases/commodities/__tests__/updateCommodity.test.ts b/apps/backend/src/application/usecases/commodities/__tests__/updateCommodity.test.ts index ea00473d..d4350be3 100644 --- a/apps/backend/src/application/usecases/commodities/__tests__/updateCommodity.test.ts +++ b/apps/backend/src/application/usecases/commodities/__tests__/updateCommodity.test.ts @@ -1,4 +1,4 @@ -import { CommodityResponseDTO } from '@ledgerly/shared/types'; +import { CommodityResponseDTO, apiErrorCodes } from '@ledgerly/shared/types'; import { CommodityRepositoryInterface, TransactionManagerInterface, @@ -10,8 +10,9 @@ import { import { ensureOwnedSnapshot } from 'src/application/shared/ensureOwnedSnapshot'; import { createCommodity } from 'src/db/createTestUser'; import { Commodity } from 'src/domain/commodities/commodity.entity'; -import { Id, Timestamp } from 'src/domain/domain-core/'; +import { CommodityCode, Id, Timestamp } from 'src/domain/domain-core/'; import { User } from 'src/domain/users/user.entity'; +import { RecordAlreadyExistsError } from 'src/infrastructure/errors'; import { createUser } from 'src/testing/helpers'; import { describe, vi, beforeAll, beforeEach, expect, it } from 'vitest'; @@ -117,6 +118,39 @@ describe('UpdateCommodityUseCase', () => { expect(result).toEqual(updatedCommoditySnapshot); }); + it('should map duplicate commodity codes to ENTITY_ALREADY_EXISTS', async () => { + const commodity = createCommodity(user, { + code: 'TEST', + name: 'Test Commodity', + precision: 2, + symbol: 'T', + }); + const commodityId = commodity.getId().valueOf(); + + commodityRepository.getById.mockResolvedValue(commodity.toSnapshot()); + commodityRepository.update.mockRejectedValue( + new RecordAlreadyExistsError({ + context: { + field: 'code', + tableName: 'commodities', + value: 'DUP', + }, + }), + ); + + await expect( + updateCommodityUseCase.execute(user, commodityId, { + code: CommodityCode.create('DUP').valueOf(), + }), + ).rejects.toMatchObject({ + code: apiErrorCodes.entityAlreadyExists, + context: { + entityType: 'commodity', + field: 'code', + }, + }); + }); + it('should throw an error if the commodity does not exist', async () => { // Arrange const nonExistentCommodityId = Id.create().valueOf(); diff --git a/apps/backend/src/application/usecases/commodities/createCommodity.ts b/apps/backend/src/application/usecases/commodities/createCommodity.ts index bd512d88..40e2393f 100644 --- a/apps/backend/src/application/usecases/commodities/createCommodity.ts +++ b/apps/backend/src/application/usecases/commodities/createCommodity.ts @@ -4,6 +4,7 @@ import { } from '@ledgerly/shared/types'; import { CommodityRepositoryInterface } from 'src/application/interfaces'; import { CommodityMapper } from 'src/application/mappers/commodity.mapper'; +import { mapRepositoryAlreadyExists } from 'src/application/shared/repositoryConflictMapper'; import { Commodity } from 'src/domain/commodities'; import { User } from 'src/domain/users/user.entity'; @@ -22,9 +23,19 @@ export class CreateCommodityUseCase { const commoditySnapshot = commodity.toSnapshot(); - await this.commodityRepository.create( - user.getId().valueOf(), - commoditySnapshot, + await mapRepositoryAlreadyExists( + () => + this.commodityRepository.create( + user.getId().valueOf(), + commoditySnapshot, + ), + [ + { + entityType: 'commodity', + field: 'code', + tableName: 'commodities', + }, + ], ); return CommodityMapper.toResponseDTOFromSnapshot(commoditySnapshot); diff --git a/apps/backend/src/application/usecases/commodities/updateCommodity.ts b/apps/backend/src/application/usecases/commodities/updateCommodity.ts index 8b93fba0..36e17dab 100644 --- a/apps/backend/src/application/usecases/commodities/updateCommodity.ts +++ b/apps/backend/src/application/usecases/commodities/updateCommodity.ts @@ -10,6 +10,7 @@ import { TransactionManagerInterface, } from 'src/application/interfaces'; import { EnsureOwnedSnapshotFn } from 'src/application/shared/ensureOwnedSnapshot'; +import { mapRepositoryAlreadyExists } from 'src/application/shared/repositoryConflictMapper'; import { Commodity, CommoditySnapshot } from 'src/domain/commodities'; import { User } from 'src/domain/users/user.entity'; @@ -52,10 +53,20 @@ export class UpdateCommodityUseCase { const updatedCommoditySnapshot = commodity.toSnapshot(); - await this.commodityRepository.update( - user.getId().valueOf(), - commodityId, - updatedCommoditySnapshot, + await mapRepositoryAlreadyExists( + () => + this.commodityRepository.update( + user.getId().valueOf(), + commodityId, + updatedCommoditySnapshot, + ), + [ + { + entityType: 'commodity', + field: 'code', + tableName: 'commodities', + }, + ], ); return updatedCommoditySnapshot; diff --git a/apps/backend/src/presentation/http/accounts/account.integration.test.ts b/apps/backend/src/presentation/http/accounts/account.integration.test.ts index f596c704..a0c5afa9 100644 --- a/apps/backend/src/presentation/http/accounts/account.integration.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.integration.test.ts @@ -899,7 +899,17 @@ describe('Accounts Integration Tests', () => { url, }); + const parsedResponse = JSON.parse(response.body) as ApiErrorResponse; + expect(response.statusCode).toBe(409); + expect(parsedResponse).toEqual({ + code: apiErrorCodes.entityAlreadyExists, + context: { + entityType: 'account', + field: 'name', + }, + error: true, + }); }); it('should return 400 when description is not a string', async () => { @@ -1089,7 +1099,17 @@ describe('Accounts Integration Tests', () => { url: `/api/accounts/${accountToUpdate.id}`, }); + const parsedResponse = JSON.parse(response.body) as ApiErrorResponse; + expect(response.statusCode).toBe(409); + expect(parsedResponse).toEqual({ + code: apiErrorCodes.entityAlreadyExists, + context: { + entityType: 'account', + field: 'name', + }, + error: true, + }); }); it('should return 400 when empty object is provided', async () => { diff --git a/apps/backend/src/presentation/http/auth/auth.integration.test.ts b/apps/backend/src/presentation/http/auth/auth.integration.test.ts index 84d54c80..465c0ddd 100644 --- a/apps/backend/src/presentation/http/auth/auth.integration.test.ts +++ b/apps/backend/src/presentation/http/auth/auth.integration.test.ts @@ -55,12 +55,15 @@ describe('Auth Integration Tests', () => { expect(response.statusCode).toBe(409); const body = JSON.parse(response.body) as Extract< ApiErrorResponse, - { code: typeof apiErrorCodes.registrationConflict } + { code: typeof apiErrorCodes.entityAlreadyExists } >; expect(body).toEqual({ - code: apiErrorCodes.registrationConflict, - context: {}, + code: apiErrorCodes.entityAlreadyExists, + context: { + entityType: 'user', + field: 'email', + }, error: true, }); }); diff --git a/apps/backend/src/presentation/http/commodities/commodity.integration.test.ts b/apps/backend/src/presentation/http/commodities/commodity.integration.test.ts index f6df0061..4adc3c02 100644 --- a/apps/backend/src/presentation/http/commodities/commodity.integration.test.ts +++ b/apps/backend/src/presentation/http/commodities/commodity.integration.test.ts @@ -410,7 +410,17 @@ describe('Commodities Integration Tests', () => { url, }); + const parsedResponse = parseResponse(response); + expect(response.statusCode).toBe(409); + expect(parsedResponse).toEqual({ + code: apiErrorCodes.entityAlreadyExists, + context: { + entityType: 'commodity', + field: 'code', + }, + error: true, + }); }); it('should allow the same code for different users', async () => { @@ -515,7 +525,17 @@ describe('Commodities Integration Tests', () => { url: `${url}/${commodityToUpdate.id}`, }); + const parsedResponse = parseResponse(response); + expect(response.statusCode).toBe(409); + expect(parsedResponse).toEqual({ + code: apiErrorCodes.entityAlreadyExists, + context: { + entityType: 'commodity', + field: 'code', + }, + error: true, + }); }); it('should allow updating to a code used by another user', async () => { diff --git a/apps/backend/src/presentation/http/error-handler.test.ts b/apps/backend/src/presentation/http/error-handler.test.ts index b5745799..2ae8d125 100644 --- a/apps/backend/src/presentation/http/error-handler.test.ts +++ b/apps/backend/src/presentation/http/error-handler.test.ts @@ -6,8 +6,8 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import { AccountHasActiveOperationsError, CommodityHasActiveReferencesError, + EntityAlreadyExistsError, InvalidPasswordError, - UserAlreadyExistsError, UserNotFoundError, } from 'src/application/application.errors'; import { Id } from 'src/domain/domain-core'; @@ -131,11 +131,17 @@ describe('errorHandler', () => { {}, ], [ - 'a duplicate registration', - new UserAlreadyExistsError(), + 'an entity that already exists', + new EntityAlreadyExistsError({ + entityType: 'user', + field: 'email', + }), 409, - apiErrorCodes.registrationConflict, - {}, + apiErrorCodes.entityAlreadyExists, + { + entityType: 'user', + field: 'email', + }, ], [ 'an account with active operations', diff --git a/apps/backend/src/presentation/http/error-handler.ts b/apps/backend/src/presentation/http/error-handler.ts index c1df187c..ff500383 100644 --- a/apps/backend/src/presentation/http/error-handler.ts +++ b/apps/backend/src/presentation/http/error-handler.ts @@ -27,6 +27,7 @@ const statusByErrorCode = { [apiErrorCodes.conflictingOperationIds]: 400, [apiErrorCodes.deletedEntityOperation]: 400, [apiErrorCodes.emptyOperations]: 400, + [apiErrorCodes.entityAlreadyExists]: 409, [apiErrorCodes.entityNotFound]: 404, [apiErrorCodes.excessiveOperations]: 400, [apiErrorCodes.insufficientOperations]: 400, @@ -48,7 +49,6 @@ const statusByErrorCode = { [apiErrorCodes.operationNotFoundInTransaction]: 400, [apiErrorCodes.operationTransactionMismatch]: 400, [apiErrorCodes.operationUserMismatch]: 400, - [apiErrorCodes.registrationConflict]: 409, [apiErrorCodes.transactionUnbalanced]: 400, [apiErrorCodes.unauthorized]: 401, [apiErrorCodes.unauthorizedAccess]: 403, diff --git a/apps/backend/src/services/user.service.ts b/apps/backend/src/services/user.service.ts index 4eac2a9c..3164d8c1 100644 --- a/apps/backend/src/services/user.service.ts +++ b/apps/backend/src/services/user.service.ts @@ -3,7 +3,7 @@ import { UserChangePasswordDTO, UUID, } from '@ledgerly/shared/types'; -import { UserAlreadyExistsError } from 'src/application/application.errors'; +import { EntityAlreadyExistsError } from 'src/application/application.errors'; import { PasswordManager } from 'src/infrastructure/auth/PasswordManager'; import { UserRepository } from 'src/infrastructure/db/'; @@ -18,13 +18,6 @@ export class UserService { } validateUser(_id: UUID): Promise { - // const existingUser = await this.usersRepository.getUserById(id); - - // if (!existingUser) { - // throw new UserNotFoundError(); - // } - - // return existingUser; throw new Error('Not implemented'); } @@ -37,7 +30,10 @@ export class UserService { ); if (existingUser && existingUser.id !== id) { - throw new UserAlreadyExistsError('Email already exists'); + throw new EntityAlreadyExistsError({ + entityType: 'user', + field: 'email', + }); } } diff --git a/docs/ERROR_ARCHITECTURE.md b/docs/ERROR_ARCHITECTURE.md index 2d8c2daf..adcaebab 100644 --- a/docs/ERROR_ARCHITECTURE.md +++ b/docs/ERROR_ARCHITECTURE.md @@ -50,16 +50,19 @@ the orchestration boundary. Examples include: - `EntityNotFoundError` +- `EntityAlreadyExistsError` - `UnauthorizedAccessError` - `UserNotFoundError` - `InvalidPasswordError` -- `UserAlreadyExistsError` - `VersionConflictError` - `CommodityClosedError` Authentication failures intentionally map to the same public `AUTHENTICATION_FAILED` code with empty context. +Known duplicate entity conflicts use `EntityAlreadyExistsError` with +allowlisted context such as `{ entityType: "account", field: "name" }`. + ### Infrastructure Infrastructure errors represent persistence, database and external-system diff --git a/docs/architecture/adr/0008-structured-api-error-contract.md b/docs/architecture/adr/0008-structured-api-error-contract.md index 81b81cac..3853be0a 100644 --- a/docs/architecture/adr/0008-structured-api-error-contract.md +++ b/docs/architecture/adr/0008-structured-api-error-contract.md @@ -35,8 +35,9 @@ presentation layer needs a uniform, safe response format. 8. Authentication failures use one `AUTHENTICATION_FAILED` response with an empty context and HTTP 401 whether the account is absent or the password is invalid. Internal error messages may retain the cause for diagnostics, but - are never serialized. Registration conflicts use the stable - `REGISTRATION_CONFLICT` code with an empty context and HTTP 409. + are never serialized. Registration conflicts originally used + `REGISTRATION_CONFLICT`; duplicate entity conflicts are now covered by + `ENTITY_ALREADY_EXISTS` as described in ADR 0023. ## Alternatives Considered @@ -82,3 +83,4 @@ Neutral/cost: - [LED-72: Unify entity identifiers in domain errors](https://gorushkin.atlassian.net/browse/LED-72) - [LED-74: Define secure auth error API contract](https://gorushkin.atlassian.net/browse/LED-74) - [ADR 0002: Operation application boundary](./0002-operation-application-boundary.md) +- [ADR 0023: Entity already exists error contract](./0023-entity-already-exists-error-contract.md) diff --git a/docs/architecture/adr/0023-entity-already-exists-error-contract.md b/docs/architecture/adr/0023-entity-already-exists-error-contract.md new file mode 100644 index 00000000..f28a68f9 --- /dev/null +++ b/docs/architecture/adr/0023-entity-already-exists-error-contract.md @@ -0,0 +1,88 @@ +# ADR 0023: Entity already exists error contract + +- Status: Accepted +- Date: 2026-08-17 +- Jira: https://gorushkin.atlassian.net/browse/LED-135 + +## Context + +The API previously used several 409 conflict shapes for duplicate entities: +`REGISTRATION_CONFLICT` for duplicate registration and generic `CONFLICT` for +repository-level unique constraint failures such as duplicate account names or +commodity codes. + +That forced clients to infer the cause from endpoint context and left race +conditions inconsistent. For example, registration could return +`REGISTRATION_CONFLICT` when the duplicate email was found before insert, but a +generic `CONFLICT` when the unique index rejected a concurrent insert. + +Database diagnostics include table names, fields and values. They are useful +for logging, but they are not a safe public API response. + +## Decision + +Use `ENTITY_ALREADY_EXISTS` for known duplicate entity conflicts. + +The public context is an allowlisted shape: + +```json +{ + "entityType": "user", + "field": "email" +} +``` + +Allowed entity and field pairs are: + +- `user.email` +- `account.name` +- `commodity.code` + +`REGISTRATION_CONFLICT` is removed from the public error contract. Duplicate +registration now returns `ENTITY_ALREADY_EXISTS` with `user.email`. + +Repository unique-constraint diagnostics remain infrastructure details. Known +duplicates are translated at the application boundary through an allowlist +mapper. Unknown `RecordAlreadyExistsError` cases still fall back to generic +`CONFLICT` at the HTTP boundary. + +## Alternatives Considered + +1. Keep `REGISTRATION_CONFLICT` for registration + +- Pros: avoids a response migration for `/auth/register`. +- Cons: keeps duplicate user conflicts different from other duplicate entities + and does not cover registration race conditions consistently. + +2. Return `ENTITY_ALREADY_EXISTS` with generic string fields + +- Pros: fewer shared type updates when adding new entities or fields. +- Cons: weakens the public contract and makes it easier to expose persistence + names accidentally. + +3. Map database diagnostics directly in the HTTP error handler + +- Pros: centralizes translation in one runtime boundary. +- Cons: makes the HTTP adapter understand persistence tables and fields, and + risks exposing database diagnostics as API context. + +## Consequences + +Positive: + +- Clients can handle duplicate entities by explicit code and context. +- Registration pre-checks and unique-index races return the same public error. +- Database `value`, table names and internal field names remain private. + +Neutral/cost: + +- Adding a new public duplicate scenario requires updating the shared contract, + application allowlist mapper usage and tests. +- Existing clients that expect `REGISTRATION_CONFLICT` must migrate to + `ENTITY_ALREADY_EXISTS`. + +## Related + +- [ADR 0008: Structured API error contract](./0008-structured-api-error-contract.md) +- [ADR 0018: Backend HTTP error boundary](./0018-backend-http-error-boundary.md) +- Jira: https://gorushkin.atlassian.net/browse/LED-135 diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index 30a8a054..920598c9 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -54,3 +54,4 @@ - [ADR 0020: Terminal Tombstone and Reversible Entity States](./0020-terminal-tombstone-and-reversible-entity-states.md) - [ADR 0021: Split Commodity Reference Business Policy From Persistence Integrity](./0021-split-commodity-reference-business-policy-from-persistence-integrity.md) - [ADR 0022: Remove system flags from account and operation contracts](./0022-remove-system-flags-from-account-and-operation-contracts.md) +- [ADR 0023: Entity already exists error contract](./0023-entity-already-exists-error-contract.md) diff --git a/packages/shared/src/types/error-contract.ts b/packages/shared/src/types/error-contract.ts index b4d520ab..f47e1912 100644 --- a/packages/shared/src/types/error-contract.ts +++ b/packages/shared/src/types/error-contract.ts @@ -12,6 +12,7 @@ export const apiErrorCodes = { conflictingOperationIds: "CONFLICTING_OPERATION_IDS", deletedEntityOperation: "DELETED_ENTITY_OPERATION", emptyOperations: "EMPTY_OPERATIONS", + entityAlreadyExists: "ENTITY_ALREADY_EXISTS", entityNotFound: "ENTITY_NOT_FOUND", excessiveOperations: "EXCESSIVE_OPERATIONS", insufficientOperations: "INSUFFICIENT_OPERATIONS", @@ -34,7 +35,6 @@ export const apiErrorCodes = { operationNotFoundInTransaction: "OPERATION_NOT_FOUND_IN_TRANSACTION", operationTransactionMismatch: "OPERATION_TRANSACTION_MISMATCH", operationUserMismatch: "OPERATION_USER_MISMATCH", - registrationConflict: "REGISTRATION_CONFLICT", transactionUnbalanced: "TRANSACTION_UNBALANCED", unauthorized: "UNAUTHORIZED", unauthorizedAccess: "UNAUTHORIZED_ACCESS", @@ -91,6 +91,19 @@ export type ErrorContextByCode = { operation: "delete" | "update" | "use"; }; EMPTY_OPERATIONS: Record; + ENTITY_ALREADY_EXISTS: + | { + entityType: "account"; + field: "name"; + } + | { + entityType: "commodity"; + field: "code"; + } + | { + entityType: "user"; + field: "email"; + }; ENTITY_NOT_FOUND: { entityId?: UUID; entityType: string; @@ -169,7 +182,6 @@ export type ErrorContextByCode = { operationId: UUID; transactionId: UUID; }; - REGISTRATION_CONFLICT: Record; TRANSACTION_UNBALANCED: { difference: string; entityType: string; From c11115ceed0e706b2d9037e9bfe363a9b2705de6 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Mon, 17 Aug 2026 15:09:49 +0300 Subject: [PATCH 13/13] LED-135 refactor: update import paths for error handling in user service and tests --- apps/backend/src/__tests__/user/user.service.test.ts | 2 +- apps/backend/src/presentation/http/error-handler.test.ts | 2 +- apps/backend/src/services/user.service.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/__tests__/user/user.service.test.ts b/apps/backend/src/__tests__/user/user.service.test.ts index c0724604..5300e200 100644 --- a/apps/backend/src/__tests__/user/user.service.test.ts +++ b/apps/backend/src/__tests__/user/user.service.test.ts @@ -2,7 +2,7 @@ import { EntityAlreadyExistsError, InvalidPasswordError, UserNotFoundError, -} from 'src/application/application.errors'; +} from 'src/application'; import { Id } from 'src/domain/domain-core'; import { PasswordManager } from 'src/infrastructure/auth/PasswordManager'; import { UserRepository } from 'src/infrastructure/db/'; diff --git a/apps/backend/src/presentation/http/error-handler.test.ts b/apps/backend/src/presentation/http/error-handler.test.ts index 2ae8d125..3e32d22e 100644 --- a/apps/backend/src/presentation/http/error-handler.test.ts +++ b/apps/backend/src/presentation/http/error-handler.test.ts @@ -9,7 +9,7 @@ import { EntityAlreadyExistsError, InvalidPasswordError, UserNotFoundError, -} from 'src/application/application.errors'; +} from 'src/application'; import { Id } from 'src/domain/domain-core'; import { AccountPersistenceConflictError, diff --git a/apps/backend/src/services/user.service.ts b/apps/backend/src/services/user.service.ts index 3164d8c1..f0627bce 100644 --- a/apps/backend/src/services/user.service.ts +++ b/apps/backend/src/services/user.service.ts @@ -3,7 +3,7 @@ import { UserChangePasswordDTO, UUID, } from '@ledgerly/shared/types'; -import { EntityAlreadyExistsError } from 'src/application/application.errors'; +import { EntityAlreadyExistsError } from 'src/application'; import { PasswordManager } from 'src/infrastructure/auth/PasswordManager'; import { UserRepository } from 'src/infrastructure/db/';