From 5d603183cfff262e85ef0ba30779c6f9705b92d9 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Mon, 27 Jul 2026 12:06:08 +0300 Subject: [PATCH 01/31] LED-122 docs: define terminal tombstone states --- ...-tombstone-and-reversible-entity-states.md | 144 ++++++++++++++++++ docs/architecture/adr/README.md | 1 + 2 files changed, 145 insertions(+) create mode 100644 docs/architecture/adr/0020-terminal-tombstone-and-reversible-entity-states.md 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 new file mode 100644 index 00000000..853bd2c1 --- /dev/null +++ b/docs/architecture/adr/0020-terminal-tombstone-and-reversible-entity-states.md @@ -0,0 +1,144 @@ +# ADR 0020: Terminal Tombstone and Reversible Entity States + +- Status: Proposed +- Date: 2026-07-27 +- Jira: https://gorushkin.atlassian.net/browse/LED-122 +- PR: TBD + +## Context + +`isTombstone` currently risks carrying two different meanings: + +- irreversible deletion for `Transaction` and `Operation`; +- reversible archive or close behavior for `Account` and `Commodity`. + +This makes use case, repository and HTTP handler naming harder to reason about. +For example, a `DELETE` route can end up invoking an archive or soft-delete +operation even though the domain operation is potentially reversible. + +The domain needs one stable meaning for tombstone state and separate business +states for reversible lifecycle changes. + +## Decision + +`isTombstone = true` means irreversible deletion without recovery. + +The transition: + +```text +isTombstone: false -> true +``` + +is terminal for every entity. Tombstoned entities must not have a general +restore method. + +Entity states are separated as follows: + +| Entity | Reversible state | Irreversible state | +| --- | --- | --- | +| `Transaction` | --- | `isTombstone` | +| `Operation` | --- | `isTombstone` | +| `Account` | `isClosed` | `isTombstone` | +| `Commodity` | `isArchived` | `isTombstone` | + +### Account + +A closed account: + +- keeps its full history; +- participates in reports and calculations; +- does not accept new operations; +- can be reopened. + +The domain API should express that lifecycle explicitly: + +```ts +account.close(); +account.reopen(); +``` + +### Commodity + +An archived commodity: + +- remains available for old operations; +- participates in historical calculations; +- is not offered for new operations and accounts; +- can be returned from archive. + +The domain API should express that lifecycle explicitly: + +```ts +commodity.archive(); +commodity.unarchive(); +``` + +### Deletion + +Terminal tombstone deletion should be represented explicitly: + +```ts +transaction.delete(); +operation.delete(); +account.delete(); +commodity.delete(); +``` + +Deletion of already used `Account` and `Commodity` may be forbidden: + +- an empty entity created by mistake can be deleted; +- an entity used in history can only be closed or archived. + +With this split, `isTombstone` remains a technical marker for irreversible +deletion, while `isClosed` and `isArchived` describe reversible business states. + +## Alternatives Considered + +1. Keep using `isTombstone` for both soft-delete and archive-like behavior + +- Pros: no schema migration and fewer state fields. +- Cons: keeps terminal deletion and reversible business states ambiguous, + making use case names, route handlers and query filters harder to understand. + +2. Rename tombstone operations to archive everywhere + +- Pros: improves wording for `Account` and `Commodity`. +- Cons: is incorrect for `Transaction` and `Operation`, where deletion should + remain terminal and non-reversible. + +3. Split terminal tombstone from reversible business states + +- Pros: gives each state one meaning, keeps `DELETE` aligned with terminal + deletion, and lets account and commodity lifecycle rules evolve separately. +- Cons: requires schema, domain, repository, use case, API and test updates. + +## Consequences + +- `Transaction` and `Operation` tombstone state is irreversible. +- `Account` needs `isClosed`, plus `close()` and `reopen()` behavior. +- `Commodity` needs `isArchived`, plus `archive()` and `unarchive()` behavior. +- `DELETE` routes should mean terminal deletion. If an account or commodity is + already used in history, terminal deletion can be rejected with guidance to + close or archive instead. +- Query filters for account and commodity reads should distinguish active, + closed or archived, and all non-tombstoned records. Tombstoned records should + remain hidden from normal reads. +- Tests must cover tombstone terminality and business-state invariants: + tombstoned entities cannot be restored or modified; closed accounts cannot + receive new operations; archived commodities cannot be selected for new + accounts or new operations according to the final policy. + +## Open Questions + +- Does "archived commodity is not offered for new operations" mean only direct + commodity selection is forbidden, or also operations through existing active + accounts that reference the archived commodity? +- Should `DELETE /accounts/:id` return an error for historically used accounts + with a hint to close instead of tombstoning? + +## Related + +- [LED-122: Define terminal tombstone and reversible archive/close states](https://gorushkin.atlassian.net/browse/LED-122) +- [ADR 0011: Domain Entity API Conventions](./0011-domain-entity-api-conventions.md) +- [ADR 0015: Domain Restoration Factory Naming](./0015-domain-restoration-factory-naming.md) +- [ADR 0019: Repository-Enforced Commodity Reference Validation](./0019-repository-enforced-commodity-reference-validation.md) diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index 0489a2c3..5b27cd10 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -50,3 +50,4 @@ - [ADR 0017: Backend HTTP adapter boundary](./0017-backend-http-adapter-boundary.md) - [ADR 0018: Backend HTTP error boundary](./0018-backend-http-error-boundary.md) - [ADR 0019: Repository-Enforced Commodity Reference Validation](./0019-repository-enforced-commodity-reference-validation.md) +- [ADR 0020: Terminal Tombstone and Reversible Entity States](./0020-terminal-tombstone-and-reversible-entity-states.md) From fba7d61d734f17b2eb4914785fbb037c0334390d Mon Sep 17 00:00:00 2001 From: gorushkin Date: Mon, 27 Jul 2026 14:52:01 +0300 Subject: [PATCH 02/31] LED-122 docs: update status to accepted and refine lifecycle terminology --- ...-tombstone-and-reversible-entity-states.md | 167 ++++++++++++++++-- 1 file changed, 148 insertions(+), 19 deletions(-) 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 853bd2c1..38238d25 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 @@ -1,6 +1,6 @@ # ADR 0020: Terminal Tombstone and Reversible Entity States -- Status: Proposed +- Status: Accepted - Date: 2026-07-27 - Jira: https://gorushkin.atlassian.net/browse/LED-122 - PR: TBD @@ -21,6 +21,10 @@ states for reversible lifecycle changes. ## Decision +Lifecycle terminology must reflect the business domain, not the internal +storage mechanism. API symmetry is not a goal when entities have different +business meanings. + `isTombstone = true` means irreversible deletion without recovery. The transition: @@ -57,13 +61,48 @@ account.close(); account.reopen(); ``` +The `close` term matches account lifecycle language: an account can stop +accepting new operations while keeping its history and later be reopened. + +`close()` and `reopen()` are idempotent. Calling `close()` for an already closed +account leaves it closed. Calling `reopen()` for an already active account +leaves it active. + +Closed accounts can still be edited, but only for descriptive fields: + +- `name`; +- `description`. + +`type` can be changed only while the account has no active operations. After an +account has participated in operations, its type is part of the historical +accounting model and must not be changed. + +Account list queries should support the same lifecycle filtering shape as +commodity list queries: + +- `active`; +- `closed`; +- `all`. + +Every normal account list filter excludes tombstoned accounts. + +Account close and reopen are exposed as explicit domain actions: + +```http +POST /accounts/:id/close +POST /accounts/:id/reopen +``` + +`PATCH /accounts/:id` remains for editable account attributes, not lifecycle +commands. + ### Commodity An archived commodity: - remains available for old operations; - participates in historical calculations; -- is not offered for new operations and accounts; +- is not offered for new accounts or active selection; - can be returned from archive. The domain API should express that lifecycle explicitly: @@ -73,6 +112,69 @@ commodity.archive(); commodity.unarchive(); ``` +The `archive` term matches commodity lifecycle language: a commodity is removed +from active choices without being deleted from historical records. A commodity +is not naturally "closed", and an account is not naturally "archived"; the +domain API should not force identical method names for unlike lifecycles. + +`archive()` and `unarchive()` are idempotent. Repeating either command keeps the +desired final state and must not: + +- throw a domain error; +- increment version; +- change `updatedAt`; +- create another journal record; +- publish another domain event. + +Archived commodity remains editable because archive is not an immutable state. +It is a reversible exclusion from active choice. Ordinary edits use +`PATCH /commodities/:id` and must not accept lifecycle fields such as +`isArchived`. + +Commodity lifecycle commands are exposed as explicit domain actions: + +```http +POST /commodities/:id/archive +POST /commodities/:id/unarchive +``` + +The expected HTTP responses are: + +```http +POST /commodities/:id/archive -> 204 No Content +POST /commodities/:id/unarchive -> 204 No Content +PATCH /commodities/:id -> 200 OK +DELETE /commodities/:id -> 204 No Content +``` + +`DELETE /commodities/:id` is idempotent at the HTTP level. Repeating it for an +already tombstoned commodity keeps the tombstone state and returns `204 No +Content`. The domain `commodity.delete()` operation may be a no-op in that +state, without changing version, `updatedAt`, journal records or domain events. + +Archived commodity means the commodity cannot be used to create new active +links. It is forbidden to: + +- create a new account with this commodity; +- change an existing account to this commodity; +- offer it as an active choice in UI. + +Existing accounts that already reference the commodity remain valid and keep +their history. + +Commodity list filters use `isArchived` and `isTombstone` as separate state +axes: + +| Filter | Predicate | +| --- | --- | +| `active` | `isArchived = false` and `isTombstone = false` | +| `archived` | `isArchived = true` and `isTombstone = false` | +| `all` | `isTombstone = false` | + +Tombstoned commodities are always hidden from normal commodity reads. + +`unarchive()` does not depend on the lifecycle state of related accounts. + ### Deletion Terminal tombstone deletion should be represented explicitly: @@ -84,14 +186,37 @@ account.delete(); commodity.delete(); ``` -Deletion of already used `Account` and `Commodity` may be forbidden: +`DELETE` means only terminal tombstone deletion. It must not be used for +reversible account close or commodity archive transitions. + +An account can be terminally deleted only when all related operations are either: + +- already tombstoned with `isTombstone = true`; or +- moved to other accounts. -- an empty entity created by mistake can be deleted; -- an entity used in history can only be closed or archived. +Otherwise the account keeps historical accounting meaning and should be closed +instead of deleted. + +Commodity can be terminally deleted only when no active domain entity references +it. Deletion is allowed when: + +- all related accounts are tombstoned; +- all related operations are tombstoned; +- or active references were moved to another commodity. + +The check is about active references, not historical rows. With this split, `isTombstone` remains a technical marker for irreversible deletion, while `isClosed` and `isArchived` describe reversible business states. +The intended public domain methods are: + +| Entity | Reversible state | Methods | +| --- | --- | --- | +| `Account` | `isClosed` | `close()` / `reopen()` | +| `Commodity` | `isArchived` | `archive()` / `unarchive()` | +| Any entity | `isTombstone` | `delete()` | + ## Alternatives Considered 1. Keep using `isTombstone` for both soft-delete and archive-like behavior @@ -104,7 +229,8 @@ deletion, while `isClosed` and `isArchived` describe reversible business states. - Pros: improves wording for `Account` and `Commodity`. - Cons: is incorrect for `Transaction` and `Operation`, where deletion should - remain terminal and non-reversible. + remain terminal and non-reversible. It also forces account terminology into + commodity terminology. 3. Split terminal tombstone from reversible business states @@ -112,33 +238,36 @@ deletion, while `isClosed` and `isArchived` describe reversible business states. deletion, and lets account and commodity lifecycle rules evolve separately. - Cons: requires schema, domain, repository, use case, API and test updates. +4. Use symmetric lifecycle methods for `Account` and `Commodity` + +- Pros: makes method names look uniform. +- Cons: hides the actual domain language. `account.archive()` and + `commodity.close()` are both less precise than `account.close()` and + `commodity.archive()`. + ## Consequences - `Transaction` and `Operation` tombstone state is irreversible. - `Account` needs `isClosed`, plus `close()` and `reopen()` behavior. - `Commodity` needs `isArchived`, plus `archive()` and `unarchive()` behavior. -- `DELETE` routes should mean terminal deletion. If an account or commodity is - already used in history, terminal deletion can be rejected with guidance to - close or archive instead. +- `DELETE` routes should mean terminal deletion. Account deletion must be + rejected while active operations still reference the account. Commodity + deletion must be rejected while any active domain entity still references the + commodity. - Query filters for account and commodity reads should distinguish active, closed or archived, and all non-tombstoned records. Tombstoned records should remain hidden from normal reads. - Tests must cover tombstone terminality and business-state invariants: tombstoned entities cannot be restored or modified; closed accounts cannot - receive new operations; archived commodities cannot be selected for new - accounts or new operations according to the final policy. - -## Open Questions - -- Does "archived commodity is not offered for new operations" mean only direct - commodity selection is forbidden, or also operations through existing active - accounts that reference the archived commodity? -- Should `DELETE /accounts/:id` return an error for historically used accounts - with a hint to close instead of tombstoning? + receive new operations; archived commodities cannot be selected for new active + links. ## Related +- [LED-123: Define entity lifecycle states](https://gorushkin.atlassian.net/browse/LED-123) - [LED-122: Define terminal tombstone and reversible archive/close states](https://gorushkin.atlassian.net/browse/LED-122) +- [LED-124: Implement Account close reopen and terminal delete](https://gorushkin.atlassian.net/browse/LED-124) +- [LED-125: Implement Commodity archive unarchive and terminal delete](https://gorushkin.atlassian.net/browse/LED-125) - [ADR 0011: Domain Entity API Conventions](./0011-domain-entity-api-conventions.md) - [ADR 0015: Domain Restoration Factory Naming](./0015-domain-restoration-factory-naming.md) - [ADR 0019: Repository-Enforced Commodity Reference Validation](./0019-repository-enforced-commodity-reference-validation.md) From 823f5d927c40992ecbab3f29aaa74a9143ff52dd Mon Sep 17 00:00:00 2001 From: gorushkin Date: Fri, 31 Jul 2026 12:45:41 +0300 Subject: [PATCH 03/31] LED-124 feat(accounts): implement close, reopen, and terminal delete --- .gitignore | 3 + ...reful_dust.sql => 0000_last_moonstone.sql} | 1 + apps/backend/drizzle/meta/0000_snapshot.json | 9 +- apps/backend/drizzle/meta/_journal.json | 4 +- .../application/application.errors.test.ts | 12 +- .../src/application/application.errors.ts | 15 + .../interfaces/AccountRepository.interface.ts | 37 +- .../CommodityRepository.interface.ts | 5 +- .../OperationRepository.interface.ts | 1 + .../src/application/interfaces/index.ts | 4 +- .../mappers/account.mapper.test.ts | 2 + .../src/application/mappers/account.mapper.ts | 1 + .../account-operation.policy.test.ts | 65 + .../account-operation.policy.ts | 18 + .../services/AccountOperationPolicy/index.ts | 1 + .../transaction.context-loader.test.ts | 34 +- .../transaction.context-loader.ts | 9 +- .../backend/src/application/services/index.ts | 1 + .../accounts/__tests__/archiveAccount.test.ts | 105 -- .../accounts/__tests__/closeAccount.test.ts | 156 +++ .../accounts/__tests__/createAccount.test.ts | 11 +- .../accounts/__tests__/deleteAccount.test.ts | 184 +++ .../accounts/__tests__/getAccountById.test.ts | 1 + .../accounts/__tests__/getAllAccounts.test.ts | 8 +- .../accounts/__tests__/openAccount.test.ts | 160 +++ .../accounts/__tests__/updateAccount.test.ts | 104 +- .../usecases/accounts/closeAccount.ts | 33 + .../usecases/accounts/createAccount.ts | 28 +- .../usecases/accounts/deleteAccount.ts | 42 + .../usecases/accounts/getAllAccounts.ts | 7 +- .../application/usecases/accounts/index.ts | 2 +- .../{archiveAccount.ts => openAccount.ts} | 12 +- .../usecases/accounts/updateAccount.ts | 28 +- .../__tests__/getAllCommodities.test.ts | 8 +- .../usecases/commodities/getAllCommodities.ts | 5 +- apps/backend/src/db/schemas/accounts.ts | 6 +- apps/backend/src/db/schemas/common.ts | 9 - apps/backend/src/db/test-db.ts | 70 +- apps/backend/src/di/container.ts | 27 +- apps/backend/src/di/types.ts | 6 +- ...account.test.ts => account.entity.test.ts} | 125 +- .../src/domain/accounts/account.entity.ts | 48 +- apps/backend/src/domain/accounts/types.ts | 1 + .../domain-core/behaviors/SoftDelete.ts | 16 +- .../behaviors/__tests__/SoftDelete.test.ts | 2 +- apps/backend/src/domain/domain.errors.test.ts | 10 + apps/backend/src/domain/domain.errors.ts | 6 +- .../src/infrastructure/db/BaseRepository.ts | 17 + .../account-persistence.mapper.test.ts | 3 + .../db/accounts/account-persistence.mapper.ts | 2 + .../db/accounts/account.repository.test.ts | 832 ++++++++++--- .../db/accounts/account.repository.ts | 180 ++- .../commodities/commodity.repository.test.ts | 53 +- .../db/commodities/commodity.repository.ts | 26 +- .../db/operations/operation.repository.ts | 29 + .../infrastructure/infrastructure.errors.ts | 13 + .../http/accounts/account.controller.test.ts | 30 +- .../http/accounts/account.controller.ts | 14 +- .../http/accounts/account.integration.test.ts | 1082 ++++++++++++++--- .../http/accounts/account.routes.ts | 4 +- .../commodities/commodity.controller.test.ts | 18 +- .../http/commodities/commodity.controller.ts | 6 +- .../presentation/http/error-handler.test.ts | 28 +- .../src/presentation/http/error-handler.ts | 40 + .../http/test-utils/http-test-client.ts | 42 + .../src/presentation/http/test-utils/index.ts | 1 + apps/backend/src/types/fastify.d.ts | 1 + packages/shared/src/constants/accounts.ts | 12 +- packages/shared/src/constants/commodities.ts | 5 + packages/shared/src/constants/index.ts | 1 + packages/shared/src/types/account.ts | 1 + packages/shared/src/types/commodity.ts | 19 +- packages/shared/src/types/error-contract.ts | 6 +- packages/shared/src/types/types.ts | 10 - .../shared/src/validation/accounts.test.ts | 18 +- packages/shared/src/validation/accounts.ts | 38 +- .../shared/src/validation/baseValidations.ts | 30 - .../shared/src/validation/commodities.test.ts | 15 + packages/shared/src/validation/commodities.ts | 43 +- 79 files changed, 3307 insertions(+), 754 deletions(-) rename apps/backend/drizzle/{0000_careful_dust.sql => 0000_last_moonstone.sql} (99%) create mode 100644 apps/backend/src/application/services/AccountOperationPolicy/__tests__/account-operation.policy.test.ts create mode 100644 apps/backend/src/application/services/AccountOperationPolicy/account-operation.policy.ts create mode 100644 apps/backend/src/application/services/AccountOperationPolicy/index.ts delete mode 100644 apps/backend/src/application/usecases/accounts/__tests__/archiveAccount.test.ts create mode 100644 apps/backend/src/application/usecases/accounts/__tests__/closeAccount.test.ts create mode 100644 apps/backend/src/application/usecases/accounts/__tests__/deleteAccount.test.ts create mode 100644 apps/backend/src/application/usecases/accounts/__tests__/openAccount.test.ts create mode 100644 apps/backend/src/application/usecases/accounts/closeAccount.ts create mode 100644 apps/backend/src/application/usecases/accounts/deleteAccount.ts rename apps/backend/src/application/usecases/accounts/{archiveAccount.ts => openAccount.ts} (69%) rename apps/backend/src/domain/accounts/{account.test.ts => account.entity.test.ts} (67%) create mode 100644 apps/backend/src/presentation/http/test-utils/http-test-client.ts create mode 100644 apps/backend/src/presentation/http/test-utils/index.ts create mode 100644 packages/shared/src/constants/commodities.ts create mode 100644 packages/shared/src/validation/commodities.test.ts diff --git a/.gitignore b/.gitignore index 54c75035..059f196e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ data/sqlite.db # TypeScript build info tsconfig.tsbuildinfo + + +coverage diff --git a/apps/backend/drizzle/0000_careful_dust.sql b/apps/backend/drizzle/0000_last_moonstone.sql similarity index 99% rename from apps/backend/drizzle/0000_careful_dust.sql rename to apps/backend/drizzle/0000_last_moonstone.sql index e14c575a..6a62fb91 100644 --- a/apps/backend/drizzle/0000_careful_dust.sql +++ b/apps/backend/drizzle/0000_last_moonstone.sql @@ -5,6 +5,7 @@ CREATE TABLE `accounts` ( `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, diff --git a/apps/backend/drizzle/meta/0000_snapshot.json b/apps/backend/drizzle/meta/0000_snapshot.json index 07cebc7c..0e5457e6 100644 --- a/apps/backend/drizzle/meta/0000_snapshot.json +++ b/apps/backend/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "59edadf8-a69b-4dbb-a24a-828a220d6559", + "id": "cadceef3-f143-4738-b7d1-873315093770", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "accounts": { @@ -49,6 +49,13 @@ "notNull": true, "autoincrement": false }, + "is_closed": { + "name": "is_closed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, "is_system": { "name": "is_system", "type": "integer", diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index 73cfb78c..477d247d 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1784803897112, - "tag": "0000_careful_dust", + "when": 1785157573050, + "tag": "0000_last_moonstone", "breakpoints": true } ] diff --git a/apps/backend/src/application/application.errors.test.ts b/apps/backend/src/application/application.errors.test.ts index fdbfb168..77edccbe 100644 --- a/apps/backend/src/application/application.errors.test.ts +++ b/apps/backend/src/application/application.errors.test.ts @@ -1,5 +1,6 @@ -import { apiErrorCodes } from '@ledgerly/shared/types'; +import { apiErrorCodes, type UUID } from '@ledgerly/shared/types'; import { + AccountHasActiveOperationsError, AuthenticationFailedError, InvalidPasswordError, ApplicationError, @@ -41,4 +42,13 @@ describe('coded application errors', () => { context: {}, }); }); + + it('uses a stable public code for an account with active operations', () => { + const accountId = '550e8400-e29b-41d4-a716-446655440001' as UUID; + + expect(new AccountHasActiveOperationsError(accountId)).toMatchObject({ + code: apiErrorCodes.accountHasActiveOperations, + context: { accountId }, + }); + }); }); diff --git a/apps/backend/src/application/application.errors.ts b/apps/backend/src/application/application.errors.ts index 8854fc12..fe8528c8 100644 --- a/apps/backend/src/application/application.errors.ts +++ b/apps/backend/src/application/application.errors.ts @@ -53,6 +53,21 @@ export class UnauthorizedAccessError extends CodedApplicationError<'UNAUTHORIZED } } +/** + * Thrown when an account cannot be deleted because it still has active operations. + */ +export class AccountHasActiveOperationsError extends CodedApplicationError<'ACCOUNT_HAS_ACTIVE_OPERATIONS'> { + constructor( + accountId: ErrorContextByCode['ACCOUNT_HAS_ACTIVE_OPERATIONS']['accountId'], + ) { + super( + `Cannot delete account ${accountId} because it has active operations.`, + apiErrorCodes.accountHasActiveOperations, + { accountId }, + ); + } +} + /** * A deliberately non-specific authentication failure. * diff --git a/apps/backend/src/application/interfaces/AccountRepository.interface.ts b/apps/backend/src/application/interfaces/AccountRepository.interface.ts index 8edc522a..b6b5ad0a 100644 --- a/apps/backend/src/application/interfaces/AccountRepository.interface.ts +++ b/apps/backend/src/application/interfaces/AccountRepository.interface.ts @@ -1,4 +1,5 @@ -import { UUID } from '@ledgerly/shared/types'; +import { IsoDatetimeString, UUID } from '@ledgerly/shared/types'; +import { AccountQuery } from '@ledgerly/shared/validation'; import { AccountSnapshot } from 'src/domain/accounts'; export type AccountRepositoryUpdateInput = Pick & @@ -6,25 +7,39 @@ export type AccountRepositoryUpdateInput = Pick & Pick >; -export type AccountRepositorySoftDeleteInput = Pick< - AccountSnapshot, - 'updatedAt' ->; +export type AccountRepositoryLifecycleInput = { updatedAt: IsoDatetimeString }; + +export type LifecycleAction = 'close' | 'open'; + +export type AccountLifecycleUpdateInput = AccountRepositoryLifecycleInput & { + action: LifecycleAction; +}; export type AccountRepositoryInterface = { - getAll(userId: UUID): Promise; - create(userId: UUID, data: AccountSnapshot): Promise; + getAll(userId: UUID, query: AccountQuery): Promise; + create(userId: UUID, data: AccountSnapshot): Promise; getById(userId: UUID, id: UUID): Promise; + getByIdForLifecycle(userId: UUID, id: UUID): Promise; update( userId: UUID, id: UUID, data: AccountRepositoryUpdateInput, - ): Promise; - softDelete( + ): Promise; + delete( userId: UUID, id: UUID, - data: AccountRepositorySoftDeleteInput, - ): Promise; + data: AccountRepositoryLifecycleInput, + ): Promise; + open( + userId: UUID, + id: UUID, + data: AccountRepositoryLifecycleInput, + ): Promise; + close( + userId: UUID, + id: UUID, + data: AccountRepositoryLifecycleInput, + ): Promise; ensureUserOwnsAccount( userId: UUID, accountId: UUID, diff --git a/apps/backend/src/application/interfaces/CommodityRepository.interface.ts b/apps/backend/src/application/interfaces/CommodityRepository.interface.ts index 7ca70de9..1ae730f4 100644 --- a/apps/backend/src/application/interfaces/CommodityRepository.interface.ts +++ b/apps/backend/src/application/interfaces/CommodityRepository.interface.ts @@ -1,4 +1,5 @@ -import { QueryStatus, UUID } from '@ledgerly/shared/types'; +import { UUID } from '@ledgerly/shared/types'; +import { CommodityQuery } from '@ledgerly/shared/validation'; import { CommoditySnapshot } from 'src/domain/commodities'; export type CommodityRepositoryUpdateInput = Pick< @@ -14,7 +15,7 @@ export type CommodityRepositorySoftDeleteInput = Pick< export type CommodityRepositoryInterface = { getById(userId: UUID, commodityId: UUID): Promise; - getAll(userId: UUID, status: QueryStatus): Promise; + getAll(userId: UUID, status: CommodityQuery): Promise; create( userId: UUID, commodity: CommoditySnapshot, diff --git a/apps/backend/src/application/interfaces/OperationRepository.interface.ts b/apps/backend/src/application/interfaces/OperationRepository.interface.ts index eb794c44..38ac9ee5 100644 --- a/apps/backend/src/application/interfaces/OperationRepository.interface.ts +++ b/apps/backend/src/application/interfaces/OperationRepository.interface.ts @@ -7,4 +7,5 @@ export type OperationRepositoryInterface = { operations: OperationSnapshot[], snapshots?: Map, ): Promise; + existsActiveByAccountId(userId: UUID, accountId: UUID): Promise; }; diff --git a/apps/backend/src/application/interfaces/index.ts b/apps/backend/src/application/interfaces/index.ts index 78dca4c3..6ad5099e 100644 --- a/apps/backend/src/application/interfaces/index.ts +++ b/apps/backend/src/application/interfaces/index.ts @@ -1,7 +1,9 @@ export type { + AccountLifecycleUpdateInput, AccountRepositoryInterface, - AccountRepositorySoftDeleteInput, + AccountRepositoryLifecycleInput, AccountRepositoryUpdateInput, + LifecycleAction, } from './AccountRepository.interface'; export type { UserRepositoryInterface } from './userRepository.interface'; export type { diff --git a/apps/backend/src/application/mappers/account.mapper.test.ts b/apps/backend/src/application/mappers/account.mapper.test.ts index 84d69d3e..a22b14d4 100644 --- a/apps/backend/src/application/mappers/account.mapper.test.ts +++ b/apps/backend/src/application/mappers/account.mapper.test.ts @@ -13,6 +13,7 @@ describe('AccountMapper', () => { description: 'Operating account', id: Id.create().valueOf(), initialBalance: Amount.create('1000').valueOf(), + isClosed: false, isSystem: true, isTombstone: false, name: 'Primary Checking', @@ -29,6 +30,7 @@ describe('AccountMapper', () => { description: snapshot.description, id: snapshot.id, initialBalance: snapshot.initialBalance, + isClosed: snapshot.isClosed, isSystem: snapshot.isSystem, isTombstone: snapshot.isTombstone, name: snapshot.name, diff --git a/apps/backend/src/application/mappers/account.mapper.ts b/apps/backend/src/application/mappers/account.mapper.ts index f2fb5e65..910e664a 100644 --- a/apps/backend/src/application/mappers/account.mapper.ts +++ b/apps/backend/src/application/mappers/account.mapper.ts @@ -22,6 +22,7 @@ export class AccountMapper { description: snapshot.description, id: snapshot.id, initialBalance: snapshot.initialBalance, + isClosed: snapshot.isClosed, isSystem: snapshot.isSystem, isTombstone: snapshot.isTombstone, name: snapshot.name, diff --git a/apps/backend/src/application/services/AccountOperationPolicy/__tests__/account-operation.policy.test.ts b/apps/backend/src/application/services/AccountOperationPolicy/__tests__/account-operation.policy.test.ts new file mode 100644 index 00000000..2e3c1cd4 --- /dev/null +++ b/apps/backend/src/application/services/AccountOperationPolicy/__tests__/account-operation.policy.test.ts @@ -0,0 +1,65 @@ +import { type UUID } from '@ledgerly/shared/types'; +import { AccountHasActiveOperationsError } from 'src/application/application.errors'; +import type { OperationRepositoryInterface } from 'src/application/interfaces'; +import { AccountOperationPolicy } from 'src/application/services/AccountOperationPolicy'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('AccountOperationPolicy', () => { + const userId = '550e8400-e29b-41d4-a716-446655440000' as UUID; + const accountId = '550e8400-e29b-41d4-a716-446655440001' as UUID; + + const operationRepository = { + existsActiveByAccountId: vi.fn(), + }; + + const policy = new AccountOperationPolicy( + operationRepository as unknown as OperationRepositoryInterface, + ); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('allows account operations when the account has no active operations', async () => { + operationRepository.existsActiveByAccountId.mockResolvedValueOnce(false); + + await expect( + policy.assertNoActiveOperations(userId, accountId), + ).resolves.toBeUndefined(); + + expect(operationRepository.existsActiveByAccountId).toHaveBeenCalledWith( + userId, + accountId, + ); + }); + + it('rejects account operations when the account has active operations', async () => { + operationRepository.existsActiveByAccountId.mockResolvedValueOnce(true); + + const assertion = policy.assertNoActiveOperations(userId, accountId); + + await expect(assertion).rejects.toBeInstanceOf( + AccountHasActiveOperationsError, + ); + await expect(assertion).rejects.toMatchObject({ + context: { accountId }, + }); + + expect(operationRepository.existsActiveByAccountId).toHaveBeenCalledWith( + userId, + accountId, + ); + }); + + it('propagates repository lookup errors', async () => { + const repositoryError = new Error('repository unavailable'); + + operationRepository.existsActiveByAccountId.mockRejectedValueOnce( + repositoryError, + ); + + await expect( + policy.assertNoActiveOperations(userId, accountId), + ).rejects.toBe(repositoryError); + }); +}); diff --git a/apps/backend/src/application/services/AccountOperationPolicy/account-operation.policy.ts b/apps/backend/src/application/services/AccountOperationPolicy/account-operation.policy.ts new file mode 100644 index 00000000..7dd600e3 --- /dev/null +++ b/apps/backend/src/application/services/AccountOperationPolicy/account-operation.policy.ts @@ -0,0 +1,18 @@ +import { UUID } from '@ledgerly/shared/types'; +import { AccountHasActiveOperationsError } from 'src/application/application.errors'; +import type { OperationRepositoryInterface } from 'src/application/interfaces'; + +export class AccountOperationPolicy { + constructor( + private readonly operationRepository: OperationRepositoryInterface, + ) {} + + async assertNoActiveOperations(userId: UUID, accountId: UUID): Promise { + const hasActiveOperations = + await this.operationRepository.existsActiveByAccountId(userId, accountId); + + if (hasActiveOperations) { + throw new AccountHasActiveOperationsError(accountId); + } + } +} diff --git a/apps/backend/src/application/services/AccountOperationPolicy/index.ts b/apps/backend/src/application/services/AccountOperationPolicy/index.ts new file mode 100644 index 00000000..f61d2daf --- /dev/null +++ b/apps/backend/src/application/services/AccountOperationPolicy/index.ts @@ -0,0 +1 @@ +export { AccountOperationPolicy } from './account-operation.policy'; diff --git a/apps/backend/src/application/services/TransactionService/__tests__/transaction.context-loader.test.ts b/apps/backend/src/application/services/TransactionService/__tests__/transaction.context-loader.test.ts index af478ed6..88c6bf1a 100644 --- a/apps/backend/src/application/services/TransactionService/__tests__/transaction.context-loader.test.ts +++ b/apps/backend/src/application/services/TransactionService/__tests__/transaction.context-loader.test.ts @@ -1,8 +1,9 @@ import { OperationRequestDTO } from 'src/application/dto'; import type { AccountRepositoryInterface } from 'src/application/interfaces'; import { createAccount } from 'src/db/createTestUser'; -import { User } from 'src/domain'; +import { Account, User } from 'src/domain'; import { Amount } from 'src/domain/domain-core'; +import { DeletedEntityOperationError } from 'src/domain/domain.errors'; import { createUser } from 'src/testing'; import { beforeAll, describe, expect, it, vi, beforeEach } from 'vitest'; @@ -146,6 +147,37 @@ describe('TransactionContextLoader', () => { ); }); + it('should throw a domain error when an account is tombstoned', async () => { + const account = createAccount(user); + const accountSnapshot = { + ...account.toSnapshot(), + isTombstone: true, + }; + + const rawOperations: OperationRequestDTO[] = [ + { + accountId: account.getId().valueOf(), + amount: Amount.create('100').valueOf(), + description: 'Op 1', + value: Amount.create('100').valueOf(), + }, + { + accountId: account.getId().valueOf(), + amount: Amount.create('-100').valueOf(), + description: 'Op 2', + value: Amount.create('-100').valueOf(), + }, + ]; + + mockAccountRepository.getByIds.mockResolvedValueOnce([accountSnapshot]); + + await expect( + transactionContextLoader.loadContext(user, rawOperations), + ).rejects.toThrowError( + new DeletedEntityOperationError(Account.entityType, 'use'), + ); + }); + it('should return empty maps when operations list is empty', async () => { mockAccountRepository.getByIds.mockResolvedValueOnce([]); diff --git a/apps/backend/src/application/services/TransactionService/transaction.context-loader.ts b/apps/backend/src/application/services/TransactionService/transaction.context-loader.ts index 8b40e50e..3f624817 100644 --- a/apps/backend/src/application/services/TransactionService/transaction.context-loader.ts +++ b/apps/backend/src/application/services/TransactionService/transaction.context-loader.ts @@ -1,5 +1,6 @@ import { UUID } from '@ledgerly/shared/types'; import { Account, User } from 'src/domain'; +import { DeletedEntityOperationError } from 'src/domain/domain.errors'; import { TransactionBuildContext } from 'src/domain/transactions/types'; import { OperationRequestDTO } from '../../dto'; @@ -29,7 +30,13 @@ export class TransactionContextLoader { const accountsMap = new Map(); for (const snapshot of accountRows) { - accountsMap.set(snapshot.id, Account.restore(snapshot)); + const account = Account.restore(snapshot); + + if (account.isDeleted()) { + throw DeletedEntityOperationError.forUse(Account.entityType); + } + + accountsMap.set(snapshot.id, account); } return { accountsMap }; diff --git a/apps/backend/src/application/services/index.ts b/apps/backend/src/application/services/index.ts index 3847868e..99aaa788 100644 --- a/apps/backend/src/application/services/index.ts +++ b/apps/backend/src/application/services/index.ts @@ -1 +1,2 @@ +export * from './AccountOperationPolicy'; export * from './TransactionService'; diff --git a/apps/backend/src/application/usecases/accounts/__tests__/archiveAccount.test.ts b/apps/backend/src/application/usecases/accounts/__tests__/archiveAccount.test.ts deleted file mode 100644 index 02a91271..00000000 --- a/apps/backend/src/application/usecases/accounts/__tests__/archiveAccount.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -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 { Id } from 'src/domain/domain-core/value-objects/Id'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { ArchiveAccountUseCase } from '../archiveAccount'; - -describe('ArchiveAccountUseCase', async () => { - let archiveAccountUseCase: ArchiveAccountUseCase; - - const user = await createUser(); - - let mockAccountRepository: { - getById: ReturnType; - softDelete: ReturnType; - }; - - let mockUserRepository: { getById: ReturnType }; - - const accountId = Id.restore( - '550e8400-e29b-41d4-a716-446655440001', - ).valueOf(); - - const accountName = 'Test Account'; - const description = 'Test account description'; - const initialBalance = Amount.create('1000').valueOf(); - const accountType = 'asset'; - - const mockUser = { - createdAt: new Date().toISOString(), - email: 'test@example.com', - id: user.getId().valueOf(), - name: 'Test User', - }; - - const mockAccountData: AccountSnapshot = { - commodityId: Id.create().valueOf(), - createdAt: Timestamp.create().valueOf(), - currentClearedBalanceLocal: initialBalance, - description, - id: Id.create().valueOf(), - initialBalance, - isSystem: false, - isTombstone: false, - name: accountName, - type: accountType, - updatedAt: Timestamp.create().valueOf(), - userId: user.getId().valueOf(), - }; - - const mockSavedAccountData = { - ...mockAccountData, - isTombstone: true, - }; - - beforeEach(() => { - mockAccountRepository = { - getById: vi.fn(), - softDelete: vi.fn(), - }; - - mockUserRepository = { - getById: vi.fn(), - }; - - archiveAccountUseCase = new ArchiveAccountUseCase( - mockAccountRepository as unknown as AccountRepositoryInterface, - ); - }); - - describe('execute', () => { - it('should mark account as archived', async () => { - mockUserRepository.getById.mockResolvedValue(mockUser); - mockAccountRepository.getById.mockResolvedValue(mockAccountData); - mockAccountRepository.softDelete.mockResolvedValue(mockSavedAccountData); - - const result = await archiveAccountUseCase.execute(user, accountId); - const { updatedAt: _updatedAt, ...expectedAccountData } = mockAccountData; - - expect(mockAccountRepository.softDelete).toHaveBeenCalledWith( - user.getId().valueOf(), - accountId, - expect.objectContaining({ - ...expectedAccountData, - isTombstone: true, - }), - ); - - expect(result).toEqual( - AccountMapper.toResponseDTOFromSnapshot(mockSavedAccountData), - ); - }); - - // TODO: Add missing tests based on account.service.test.ts: - // - should throw error when user does not exist - // - should throw error when account does not exist - // - should throw error when account does not belong to user - // - should verify account exists before archiving - // - should handle repository errors properly - // - should not allow archiving already archived accounts - }); -}); diff --git a/apps/backend/src/application/usecases/accounts/__tests__/closeAccount.test.ts b/apps/backend/src/application/usecases/accounts/__tests__/closeAccount.test.ts new file mode 100644 index 00000000..2dbca0d7 --- /dev/null +++ b/apps/backend/src/application/usecases/accounts/__tests__/closeAccount.test.ts @@ -0,0 +1,156 @@ +import { + EntityNotFoundError, + UnauthorizedAccessError, +} from 'src/application/application.errors'; +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 { Id } from 'src/domain/domain-core/value-objects/Id'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CloseAccountUseCase } from '../closeAccount'; + +describe('CloseAccountUseCase', async () => { + let closeAccountUseCase: CloseAccountUseCase; + + const user = await createUser(); + + const mockAccountRepository = { + close: vi.fn(), + getById: vi.fn(), + }; + + const mockUserRepository = { + getById: vi.fn(), + }; + + const accountId = Id.restore( + '550e8400-e29b-41d4-a716-446655440001', + ).valueOf(); + + const accountName = 'Test Account'; + const description = 'Test account description'; + const initialBalance = Amount.create('1000').valueOf(); + const accountType = 'asset'; + + const mockUser = { + createdAt: new Date().toISOString(), + email: 'test@example.com', + id: user.getId().valueOf(), + name: 'Test User', + }; + + 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, + updatedAt: Timestamp.create().valueOf(), + userId: user.getId().valueOf(), + }; + + beforeEach(() => { + mockAccountRepository.close.mockReset(); + mockAccountRepository.getById.mockReset(); + + mockUserRepository.getById.mockReset(); + + closeAccountUseCase = new CloseAccountUseCase( + mockAccountRepository as unknown as AccountRepositoryInterface, + ); + }); + + describe('execute', () => { + it('should close the account', async () => { + vi.useFakeTimers(); + + const updatedAt = Timestamp.restore('2025-01-01T00:00:01.000Z').valueOf(); + + mockUserRepository.getById.mockResolvedValue(mockUser); + + mockAccountRepository.getById.mockResolvedValue(mockAccountData); + + vi.setSystemTime(new Date(updatedAt)); + + const result = await closeAccountUseCase.execute(user, accountId); + + const { updatedAt: _updatedAt, ...expectedAccountData } = mockAccountData; + + expect(mockAccountRepository.close).toHaveBeenCalledWith( + user.getId().valueOf(), + accountId, + expect.objectContaining({ + ...expectedAccountData, + isClosed: true, + }), + ); + + expect(result).toEqual( + AccountMapper.toResponseDTOFromSnapshot({ + ...mockAccountData, + isClosed: true, + updatedAt, + }), + ); + }); + + it('should throw when account does not exist', async () => { + mockAccountRepository.getById.mockResolvedValue(null); + + await expect( + closeAccountUseCase.execute(user, accountId), + ).rejects.toThrow(EntityNotFoundError); + + expect(mockAccountRepository.close).not.toHaveBeenCalled(); + }); + + it('should throw when account does not belong to user', async () => { + const anotherUser = await createUser(); + + mockAccountRepository.getById.mockResolvedValue(mockAccountData); + + await expect( + closeAccountUseCase.execute(anotherUser, accountId), + ).rejects.toThrowError(UnauthorizedAccessError); + + expect(mockAccountRepository.getById).toHaveBeenCalledWith( + anotherUser.getId().valueOf(), + accountId, + ); + + expect(mockAccountRepository.close).not.toHaveBeenCalled(); + }); + + it('should not call repo saving when account is already open', async () => { + mockAccountRepository.getById.mockResolvedValue({ + ...mockAccountData, + isClosed: true, + }); + + const result = await closeAccountUseCase.execute(user, accountId); + + expect(mockAccountRepository.getById).toHaveBeenCalledWith( + user.getId().valueOf(), + accountId, + ); + + expect(mockAccountRepository.close).not.toHaveBeenCalled(); + + expect(result).toEqual( + AccountMapper.toResponseDTOFromSnapshot({ + ...mockAccountData, + isClosed: true, + }), + ); + }); + }); +}); 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 05e255b7..d44971d2 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/createAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/createAccount.test.ts @@ -1,4 +1,7 @@ -import { AccountRepositoryInterface } from 'src/application'; +import type { + TransactionManagerInterface, + AccountRepositoryInterface, +} from 'src/application'; import { createUser } from 'src/db/createTestUser'; import { Commodity } from 'src/domain'; import { Amount, CommodityCode } from 'src/domain/domain-core'; @@ -15,6 +18,10 @@ describe('CreateAccountUseCase', async () => { create: vi.fn(), }; + const transactionManager = { + run: vi.fn((cb: () => unknown) => cb()), + }; + const name = 'Test Account'; const description = 'Test account description'; const initialBalance = Amount.create('1000').valueOf(); @@ -31,6 +38,7 @@ describe('CreateAccountUseCase', async () => { beforeEach(() => { createAccountUseCase = new CreateAccountUseCase( accountRepository as unknown as AccountRepositoryInterface, + transactionManager as unknown as TransactionManagerInterface, ); }); @@ -58,6 +66,7 @@ describe('CreateAccountUseCase', async () => { description, id: result.id, initialBalance, + isClosed: false, isSystem: false, isTombstone: false, name, diff --git a/apps/backend/src/application/usecases/accounts/__tests__/deleteAccount.test.ts b/apps/backend/src/application/usecases/accounts/__tests__/deleteAccount.test.ts new file mode 100644 index 00000000..e0cc20ce --- /dev/null +++ b/apps/backend/src/application/usecases/accounts/__tests__/deleteAccount.test.ts @@ -0,0 +1,184 @@ +import { + AccountHasActiveOperationsError, + EntityNotFoundError, + UnauthorizedAccessError, +} from 'src/application/application.errors'; +import type { AccountRepositoryInterface } from 'src/application/interfaces'; +import { AccountMapper } from 'src/application/mappers'; +import { AccountOperationPolicy } from 'src/application/services/AccountOperationPolicy/account-operation.policy'; +import { createUser } from 'src/db/createTestUser'; +import { AccountSnapshot } from 'src/domain/accounts'; +import { Amount, 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'; + +import { DeleteAccountUseCase } from '../deleteAccount'; + +describe('DeleteAccountUseCase', async () => { + let deleteAccountUseCase: DeleteAccountUseCase; + + const user = await createUser(); + + const mockAccountRepository = { + delete: vi.fn(), + getById: vi.fn(), + }; + + const mockAccountOperationPolicy = { + assertNoActiveOperations: vi.fn(), + }; + + const accountId = Id.restore( + '550e8400-e29b-41d4-a716-446655440001', + ).valueOf(); + + 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, + updatedAt: Timestamp.create().valueOf(), + userId: user.getId().valueOf(), + }; + + const mockSavedAccountData = { + ...mockAccountData, + isTombstone: true, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + + deleteAccountUseCase = new DeleteAccountUseCase( + mockAccountRepository as unknown as AccountRepositoryInterface, + mockAccountOperationPolicy as unknown as AccountOperationPolicy, + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + + mockAccountOperationPolicy.assertNoActiveOperations.mockReset(); + mockAccountRepository.getById.mockReset(); + mockAccountRepository.delete.mockReset(); + }); + + describe('execute', () => { + it('should delete account when no active operations exist', async () => { + vi.useFakeTimers(); + const updatedAt = Timestamp.restore('2025-01-01T00:00:01.000Z').valueOf(); + + mockAccountOperationPolicy.assertNoActiveOperations.mockResolvedValue( + false, + ); + mockAccountRepository.getById.mockResolvedValue(mockAccountData); + mockAccountRepository.delete.mockResolvedValue(mockSavedAccountData); + + vi.setSystemTime(new Date(updatedAt)); + + const result = await deleteAccountUseCase.execute(user, accountId); + const { updatedAt: _updatedAt, ...expectedAccountData } = mockAccountData; + + expect(mockAccountRepository.delete).toHaveBeenCalledWith( + user.getId().valueOf(), + accountId, + expect.objectContaining({ + ...expectedAccountData, + isTombstone: true, + }), + ); + + expect(result).toEqual( + AccountMapper.toResponseDTOFromSnapshot({ + ...mockSavedAccountData, + updatedAt, + }), + ); + }); + + it('should throw when account has active operations', async () => { + mockAccountOperationPolicy.assertNoActiveOperations.mockRejectedValue( + new AccountHasActiveOperationsError(accountId), + ); + + await expect( + deleteAccountUseCase.execute(user, accountId), + ).rejects.toThrow(new AccountHasActiveOperationsError(accountId)); + + expect( + mockAccountOperationPolicy.assertNoActiveOperations, + ).toHaveBeenCalledWith(user.getId().valueOf(), accountId); + + expect(mockAccountRepository.delete).not.toHaveBeenCalled(); + }); + + it('should throw when account does not exist', async () => { + mockAccountRepository.getById.mockResolvedValue(null); + + await expect( + deleteAccountUseCase.execute(user, accountId), + ).rejects.toThrowError(EntityNotFoundError); + + expect(mockAccountRepository.getById).toHaveBeenCalledWith( + user.getId().valueOf(), + accountId, + ); + + expect(mockAccountRepository.delete).not.toHaveBeenCalled(); + }); + + it('should throw when account does not belong to user', async () => { + const anotherUser = await createUser(); + + mockAccountRepository.getById.mockResolvedValue(mockAccountData); + + await expect( + deleteAccountUseCase.execute(anotherUser, accountId), + ).rejects.toThrowError(UnauthorizedAccessError); + + expect(mockAccountRepository.getById).toHaveBeenCalledWith( + anotherUser.getId().valueOf(), + accountId, + ); + + expect(mockAccountRepository.delete).not.toHaveBeenCalled(); + }); + + it('should not call repo saving when account is already deleted', async () => { + mockAccountRepository.getById.mockResolvedValue({ + ...mockAccountData, + isTombstone: true, + }); + + const result = await deleteAccountUseCase.execute(user, accountId); + + expect(mockAccountRepository.getById).toHaveBeenCalledWith( + user.getId().valueOf(), + accountId, + ); + + expect(mockAccountRepository.delete).not.toHaveBeenCalled(); + + expect(result).toEqual( + AccountMapper.toResponseDTOFromSnapshot({ + ...mockAccountData, + isTombstone: true, + }), + ); + }); + }); +}); 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 624b701c..34ec28f1 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/getAccountById.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/getAccountById.test.ts @@ -47,6 +47,7 @@ describe('GetAccountByIdUseCase', async () => { description, id: accountId, initialBalance, + isClosed: false, isSystem: false, isTombstone: false, name: accountName, 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 3200089e..4c51406c 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/getAllAccounts.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/getAllAccounts.test.ts @@ -1,4 +1,5 @@ import { AccountTypeValue } from '@ledgerly/shared/types'; +import { AccountQuery } from '@ledgerly/shared/validation'; import type { AccountRepositoryInterface } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { createUser } from 'src/db/createTestUser'; @@ -19,6 +20,7 @@ describe('GetAllAccounts', async () => { const user = await createUser(); const accountName = 'Test Account'; + const description = 'Test account description'; const accountId = Id.restore( '550e8400-e29b-41d4-a716-446655440001', @@ -33,6 +35,7 @@ describe('GetAllAccounts', async () => { description, id: accountId, initialBalance, + isClosed: false, isSystem: false, isTombstone: false, name: accountName, @@ -55,10 +58,13 @@ describe('GetAllAccounts', async () => { it('should return accounts for the user', async () => { mockAccountRepository.getAll.mockResolvedValue([mockSavedAccountData]); - const result = await getAllAccounts.execute(user); + const query: AccountQuery = { status: 'all' }; + + const result = await getAllAccounts.execute(user, query); expect(mockAccountRepository.getAll).toHaveBeenCalledWith( user.getId().valueOf(), + query, ); expect(result).toEqual([ diff --git a/apps/backend/src/application/usecases/accounts/__tests__/openAccount.test.ts b/apps/backend/src/application/usecases/accounts/__tests__/openAccount.test.ts new file mode 100644 index 00000000..84a9c2ba --- /dev/null +++ b/apps/backend/src/application/usecases/accounts/__tests__/openAccount.test.ts @@ -0,0 +1,160 @@ +import { + EntityNotFoundError, + UnauthorizedAccessError, +} from 'src/application/application.errors'; +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 { Id } from 'src/domain/domain-core/value-objects/Id'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OpenAccountUseCase } from '../openAccount'; + +describe('OpenAccountUseCase', async () => { + let openAccountUseCase: OpenAccountUseCase; + + const user = await createUser(); + + const mockAccountRepository = { + getById: vi.fn(), + open: vi.fn(), + }; + + const mockUserRepository = { + getById: vi.fn(), + }; + + const accountId = Id.restore( + '550e8400-e29b-41d4-a716-446655440001', + ).valueOf(); + + const accountName = 'Test Account'; + const description = 'Test account description'; + const initialBalance = Amount.create('1000').valueOf(); + const accountType = 'asset'; + + const mockUser = { + createdAt: new Date().toISOString(), + email: 'test@example.com', + id: user.getId().valueOf(), + name: 'Test User', + }; + + 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, + updatedAt: Timestamp.create().valueOf(), + userId: user.getId().valueOf(), + }; + + beforeEach(() => { + mockAccountRepository.open.mockReset(); + mockAccountRepository.getById.mockReset(); + + mockUserRepository.getById.mockReset(); + + openAccountUseCase = new OpenAccountUseCase( + mockAccountRepository as unknown as AccountRepositoryInterface, + ); + }); + + describe('execute', () => { + it('should open the account', async () => { + vi.useFakeTimers(); + + const updatedAt = Timestamp.restore('2025-01-01T00:00:01.000Z').valueOf(); + + mockUserRepository.getById.mockResolvedValue(mockUser); + + mockAccountRepository.getById.mockResolvedValue(mockAccountData); + + vi.setSystemTime(new Date(updatedAt)); + + const result = await openAccountUseCase.execute(user, accountId); + + const { updatedAt: _updatedAt, ...expectedAccountData } = mockAccountData; + + expect(mockAccountRepository.open).toHaveBeenCalledWith( + user.getId().valueOf(), + accountId, + expect.objectContaining({ + ...expectedAccountData, + isClosed: false, + }), + ); + + expect(result).toEqual( + AccountMapper.toResponseDTOFromSnapshot({ + ...mockAccountData, + isClosed: false, + updatedAt, + }), + ); + }); + + it('should throw when account does not exist', async () => { + mockAccountRepository.getById.mockResolvedValue(null); + + await expect(openAccountUseCase.execute(user, accountId)).rejects.toThrow( + EntityNotFoundError, + ); + + expect(mockAccountRepository.getById).toHaveBeenCalledWith( + user.getId().valueOf(), + accountId, + ); + expect(mockAccountRepository.open).not.toHaveBeenCalled(); + }); + + it('should throw when account does not belong to user', async () => { + const anotherUser = await createUser(); + + mockAccountRepository.getById.mockResolvedValue(mockAccountData); + + await expect( + openAccountUseCase.execute(anotherUser, accountId), + ).rejects.toThrowError(UnauthorizedAccessError); + + expect(mockAccountRepository.getById).toHaveBeenCalledWith( + anotherUser.getId().valueOf(), + accountId, + ); + + expect(mockAccountRepository.open).not.toHaveBeenCalled(); + }); + + it('should not call repo saving when account is already open', async () => { + mockAccountRepository.getById.mockResolvedValue({ + ...mockAccountData, + isClosed: false, + }); + + const result = await openAccountUseCase.execute(user, accountId); + + expect(mockAccountRepository.getById).toHaveBeenCalledWith( + user.getId().valueOf(), + accountId, + ); + + expect(mockAccountRepository.open).not.toHaveBeenCalled(); + + expect(result).toEqual( + AccountMapper.toResponseDTOFromSnapshot({ + ...mockAccountData, + isClosed: false, + }), + ); + }); + }); +}); 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 f096d1ac..9c82726a 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts @@ -1,13 +1,17 @@ import { AccountTypeValue, apiErrorCodes } from '@ledgerly/shared/types'; -import { EntityNotFoundError } from 'src/application/application.errors'; +import { + AccountHasActiveOperationsError, + EntityNotFoundError, +} from 'src/application/application.errors'; import type { AccountRepositoryInterface } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; +import { AccountOperationPolicy } from 'src/application/services/AccountOperationPolicy/account-operation.policy'; import { createUser } from 'src/db/createTestUser'; import { Account } from 'src/domain/accounts/account.entity'; import { AccountSnapshot } from 'src/domain/accounts/types'; import { Amount, Timestamp } from 'src/domain/domain-core'; import { Id } from 'src/domain/domain-core/value-objects/Id'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { UpdateAccountUseCase } from '../updateAccount'; @@ -16,10 +20,14 @@ describe('UpdateAccount', async () => { const user = await createUser(); - let mockAccountRepository: { - create: ReturnType; - getById: ReturnType; - update: ReturnType; + const mockAccountRepository = { + create: vi.fn(), + getById: vi.fn(), + update: vi.fn(), + }; + + const mockAccountOperationPolicy = { + assertNoActiveOperations: vi.fn(), }; const accountId = Id.restore( @@ -38,6 +46,7 @@ describe('UpdateAccount', async () => { description, id: accountId, initialBalance, + isClosed: false, isSystem: false, isTombstone: false, name: accountName, @@ -52,22 +61,29 @@ describe('UpdateAccount', async () => { }; beforeEach(() => { - mockAccountRepository = { - create: vi.fn(), - getById: vi.fn(), - update: vi.fn(), - }; - updateAccountUseCase = new UpdateAccountUseCase( mockAccountRepository as unknown as AccountRepositoryInterface, + mockAccountOperationPolicy as unknown as AccountOperationPolicy, ); }); + afterEach(() => { + vi.clearAllMocks(); + mockAccountOperationPolicy.assertNoActiveOperations.mockReset(); + mockAccountRepository.getById.mockReset(); + mockAccountRepository.update.mockReset(); + }); + describe('execute', () => { it('should update account', async () => { + vi.useFakeTimers(); + const updatedAt = Timestamp.restore('2025-01-01T00:00:01.000Z').valueOf(); + mockAccountRepository.getById.mockResolvedValue(mockAccountData); mockAccountRepository.update.mockResolvedValue(mockAccountUpdatedData); + vi.setSystemTime(new Date(updatedAt)); + const result = await updateAccountUseCase.execute(user, accountId, { name: 'Updated Account', }); @@ -85,7 +101,10 @@ describe('UpdateAccount', async () => { expect(result.name).toBe('Updated Account'); expect(result).toEqual( - AccountMapper.toResponseDTOFromSnapshot(mockAccountUpdatedData), + AccountMapper.toResponseDTOFromSnapshot({ + ...mockAccountUpdatedData, + updatedAt, + }), ); }); @@ -97,6 +116,7 @@ describe('UpdateAccount', async () => { }); await expect(result).rejects.toThrow(EntityNotFoundError); + await expect(result).rejects.toMatchObject({ code: apiErrorCodes.entityNotFound, context: { @@ -112,6 +132,64 @@ describe('UpdateAccount', async () => { expect(mockAccountRepository.update).not.toHaveBeenCalled(); }); + it('should throw error when account type is changed and there are active operations', async () => { + mockAccountRepository.getById.mockResolvedValue(mockAccountData); + + mockAccountOperationPolicy.assertNoActiveOperations.mockRejectedValue( + new AccountHasActiveOperationsError(accountId), + ); + + const result = updateAccountUseCase.execute(user, accountId, { + type: 'liability' as AccountTypeValue, + }); + + await expect(result).rejects.toThrow(AccountHasActiveOperationsError); + + expect(mockAccountRepository.getById).toHaveBeenCalledWith( + user.getId().valueOf(), + accountId, + ); + + expect( + mockAccountOperationPolicy.assertNoActiveOperations, + ).toHaveBeenCalledWith(user.getId().valueOf(), accountId); + expect(mockAccountRepository.update).not.toHaveBeenCalled(); + }); + + it('should call assertNoActiveOperations when account type is changed', async () => { + mockAccountRepository.getById.mockResolvedValue(mockAccountData); + + await updateAccountUseCase.execute(user, accountId, { + type: 'liability' as AccountTypeValue, + }); + + expect(mockAccountRepository.getById).toHaveBeenCalledWith( + user.getId().valueOf(), + accountId, + ); + + expect( + mockAccountOperationPolicy.assertNoActiveOperations, + ).toHaveBeenCalledWith(user.getId().valueOf(), accountId); + }); + + it('should not call assertNoActiveOperations when account type is not changed', async () => { + mockAccountRepository.getById.mockResolvedValue(mockAccountData); + + await updateAccountUseCase.execute(user, accountId, { + name: 'Updated Account', + }); + + expect(mockAccountRepository.getById).toHaveBeenCalledWith( + user.getId().valueOf(), + accountId, + ); + + expect( + mockAccountOperationPolicy.assertNoActiveOperations, + ).not.toHaveBeenCalled(); + }); + // TODO: Add missing tests based on account.service.test.ts: // - should throw error when user does not exist // - should throw error when account does not exist diff --git a/apps/backend/src/application/usecases/accounts/closeAccount.ts b/apps/backend/src/application/usecases/accounts/closeAccount.ts new file mode 100644 index 00000000..ff328a76 --- /dev/null +++ b/apps/backend/src/application/usecases/accounts/closeAccount.ts @@ -0,0 +1,33 @@ +import { AccountResponseDTO, UUID } from '@ledgerly/shared/types'; +import { AccountMapper } from 'src/application/mappers'; +import { Account } from 'src/domain/accounts/account.entity'; +import { User } from 'src/domain/users/user.entity'; + +import { AccountRepositoryInterface } from '../../interfaces'; + +import { AccountUseCaseBase } from './accountBase'; + +export class CloseAccountUseCase extends AccountUseCaseBase { + constructor(accountRepository: AccountRepositoryInterface) { + super(accountRepository); + } + + async execute(user: User, accountId: UUID): Promise { + const accountData = await this.ensureAccountExistsAndOwned(user, accountId); + const account = Account.restore(accountData); + + const result = account.close(); + + if (result === 'unchanged') { + return AccountMapper.toResponseDTOFromSnapshot(account.toSnapshot()); + } + + await this.accountRepository.close( + user.getId().valueOf(), + accountId, + account.toSnapshot(), + ); + + return AccountMapper.toResponseDTOFromSnapshot(account.toSnapshot()); + } +} diff --git a/apps/backend/src/application/usecases/accounts/createAccount.ts b/apps/backend/src/application/usecases/accounts/createAccount.ts index ada7e145..88d147ee 100644 --- a/apps/backend/src/application/usecases/accounts/createAccount.ts +++ b/apps/backend/src/application/usecases/accounts/createAccount.ts @@ -1,27 +1,35 @@ import { AccountCreateDTO, AccountResponseDTO } from '@ledgerly/shared/types'; -import type { AccountRepositoryInterface } from 'src/application/interfaces'; +import type { + AccountRepositoryInterface, + TransactionManagerInterface, +} from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { Account } from 'src/domain/'; import { User } from 'src/domain/users/user.entity'; export class CreateAccountUseCase { constructor( protected readonly accountRepository: AccountRepositoryInterface, + protected readonly transactionManager: TransactionManagerInterface, ) {} async execute( user: User, data: AccountCreateDTO, ): Promise { - const account = Account.create( - user, - AccountMapper.toCreateAccountProps(data), - ); + const accountSnapshot = await this.transactionManager.run(async () => { + const account = Account.create( + user, + AccountMapper.toCreateAccountProps(data), + ); - const accountSnapshot = account.toSnapshot(); + const accountSnapshot = account.toSnapshot(); - await this.accountRepository.create( - user.getId().valueOf(), - accountSnapshot, - ); + await this.accountRepository.create( + user.getId().valueOf(), + accountSnapshot, + ); + + return accountSnapshot; + }); return AccountMapper.toResponseDTOFromSnapshot(accountSnapshot); } diff --git a/apps/backend/src/application/usecases/accounts/deleteAccount.ts b/apps/backend/src/application/usecases/accounts/deleteAccount.ts new file mode 100644 index 00000000..26d5df78 --- /dev/null +++ b/apps/backend/src/application/usecases/accounts/deleteAccount.ts @@ -0,0 +1,42 @@ +import { AccountResponseDTO, UUID } from '@ledgerly/shared/types'; +import { AccountMapper } from 'src/application/mappers'; +import { AccountOperationPolicy } from 'src/application/services/'; +import { Account } from 'src/domain/accounts/account.entity'; +import { User } from 'src/domain/users/user.entity'; + +import { AccountRepositoryInterface } from '../../interfaces'; + +import { AccountUseCaseBase } from './accountBase'; + +export class DeleteAccountUseCase extends AccountUseCaseBase { + constructor( + accountRepository: AccountRepositoryInterface, + private readonly accountOperationPolicy: AccountOperationPolicy, + ) { + super(accountRepository); + } + + async execute(user: User, accountId: UUID): Promise { + await this.accountOperationPolicy.assertNoActiveOperations( + user.getId().valueOf(), + accountId, + ); + + const accountData = await this.ensureAccountExistsAndOwned(user, accountId); + const account = Account.restore(accountData); + + const result = account.delete(); + + if (result === 'unchanged') { + return AccountMapper.toResponseDTOFromSnapshot(account.toSnapshot()); + } + + await this.accountRepository.delete( + user.getId().valueOf(), + accountId, + account.toSnapshot(), + ); + + return AccountMapper.toResponseDTOFromSnapshot(account.toSnapshot()); + } +} diff --git a/apps/backend/src/application/usecases/accounts/getAllAccounts.ts b/apps/backend/src/application/usecases/accounts/getAllAccounts.ts index b34d057e..5f5a813f 100644 --- a/apps/backend/src/application/usecases/accounts/getAllAccounts.ts +++ b/apps/backend/src/application/usecases/accounts/getAllAccounts.ts @@ -1,4 +1,5 @@ import { AccountResponseDTO } from '@ledgerly/shared/types'; +import { AccountQuery } from 'node_modules/@ledgerly/shared/src/validation/accounts'; import type { AccountRepositoryInterface } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { User } from 'src/domain/users/user.entity'; @@ -10,9 +11,13 @@ export class GetAllAccountsUseCase extends AccountUseCaseBase { super(accountRepository); } - async execute(user: User): Promise { + async execute( + user: User, + query: AccountQuery, + ): Promise { const accounts = await this.accountRepository.getAll( user.getId().valueOf(), + query, ); return accounts.map((account) => diff --git a/apps/backend/src/application/usecases/accounts/index.ts b/apps/backend/src/application/usecases/accounts/index.ts index abdee2d3..4f944e0f 100644 --- a/apps/backend/src/application/usecases/accounts/index.ts +++ b/apps/backend/src/application/usecases/accounts/index.ts @@ -1,4 +1,4 @@ -export { ArchiveAccountUseCase } from './archiveAccount'; +export { DeleteAccountUseCase } from './deleteAccount'; export { CreateAccountUseCase } from './createAccount'; export { GetAccountByIdUseCase } from './getAccountById'; export { GetAllAccountsUseCase } from './getAllAccounts'; diff --git a/apps/backend/src/application/usecases/accounts/archiveAccount.ts b/apps/backend/src/application/usecases/accounts/openAccount.ts similarity index 69% rename from apps/backend/src/application/usecases/accounts/archiveAccount.ts rename to apps/backend/src/application/usecases/accounts/openAccount.ts index 34bbd5e9..29598728 100644 --- a/apps/backend/src/application/usecases/accounts/archiveAccount.ts +++ b/apps/backend/src/application/usecases/accounts/openAccount.ts @@ -7,7 +7,7 @@ import { AccountRepositoryInterface } from '../../interfaces'; import { AccountUseCaseBase } from './accountBase'; -export class ArchiveAccountUseCase extends AccountUseCaseBase { +export class OpenAccountUseCase extends AccountUseCaseBase { constructor(accountRepository: AccountRepositoryInterface) { super(accountRepository); } @@ -16,14 +16,18 @@ export class ArchiveAccountUseCase extends AccountUseCaseBase { const accountData = await this.ensureAccountExistsAndOwned(user, accountId); const account = Account.restore(accountData); - account.markAsDeleted(); + const result = account.open(); - const deletedAccount = await this.accountRepository.softDelete( + if (result === 'unchanged') { + return AccountMapper.toResponseDTOFromSnapshot(account.toSnapshot()); + } + + await this.accountRepository.open( user.getId().valueOf(), accountId, account.toSnapshot(), ); - return AccountMapper.toResponseDTOFromSnapshot(deletedAccount); + return AccountMapper.toResponseDTOFromSnapshot(account.toSnapshot()); } } diff --git a/apps/backend/src/application/usecases/accounts/updateAccount.ts b/apps/backend/src/application/usecases/accounts/updateAccount.ts index ddedf430..6b0610e7 100644 --- a/apps/backend/src/application/usecases/accounts/updateAccount.ts +++ b/apps/backend/src/application/usecases/accounts/updateAccount.ts @@ -5,16 +5,34 @@ import { } from '@ledgerly/shared/types'; import type { AccountRepositoryInterface } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; -import { Account } from 'src/domain/accounts'; +import { AccountOperationPolicy } from 'src/application/services/'; +import { Account, AccountSnapshot } from 'src/domain/accounts'; import { User } from 'src/domain/users/user.entity'; import { AccountUseCaseBase } from './accountBase'; export class UpdateAccountUseCase extends AccountUseCaseBase { - constructor(accountRepository: AccountRepositoryInterface) { + constructor( + accountRepository: AccountRepositoryInterface, + private readonly accountOperationPolicy: AccountOperationPolicy, + ) { super(accountRepository); } + private async checkAccountUpdateValidity( + user: User, + accountId: UUID, + accountData: AccountSnapshot, + data: AccountUpdateDTO, + ): Promise { + if (data.type && data.type !== accountData.type) { + await this.accountOperationPolicy.assertNoActiveOperations( + user.getId().valueOf(), + accountId, + ); + } + } + async execute( user: User, accountId: UUID, @@ -22,16 +40,18 @@ export class UpdateAccountUseCase extends AccountUseCaseBase { ): Promise { const accountData = await this.ensureAccountExistsAndOwned(user, accountId); + await this.checkAccountUpdateValidity(user, accountId, accountData, data); + const account = Account.restore(accountData); account.update(AccountMapper.toUpdateProps(data)); - const updatedAccount = await this.accountRepository.update( + await this.accountRepository.update( user.getId().valueOf(), accountId, account.toSnapshot(), ); - return AccountMapper.toResponseDTOFromSnapshot(updatedAccount); + return AccountMapper.toResponseDTOFromSnapshot(account.toSnapshot()); } } diff --git a/apps/backend/src/application/usecases/commodities/__tests__/getAllCommodities.test.ts b/apps/backend/src/application/usecases/commodities/__tests__/getAllCommodities.test.ts index ee5c908b..5088d7ef 100644 --- a/apps/backend/src/application/usecases/commodities/__tests__/getAllCommodities.test.ts +++ b/apps/backend/src/application/usecases/commodities/__tests__/getAllCommodities.test.ts @@ -52,17 +52,19 @@ describe('GetAllCommoditiesUseCase', () => { }, ]; - const queryStatus = 'active'; + const status = 'active'; commodityRepository.getAll.mockResolvedValue(mockCommodities); // Act - const result = await getAllCommoditiesUseCase.execute(user, queryStatus); + const result = await getAllCommoditiesUseCase.execute(user, { + status, + }); // Assert expect(commodityRepository.getAll).toHaveBeenCalledWith( user.getId().valueOf(), - queryStatus, + { status }, ); expect(result).toEqual( diff --git a/apps/backend/src/application/usecases/commodities/getAllCommodities.ts b/apps/backend/src/application/usecases/commodities/getAllCommodities.ts index 7f8e6da2..5395cff4 100644 --- a/apps/backend/src/application/usecases/commodities/getAllCommodities.ts +++ b/apps/backend/src/application/usecases/commodities/getAllCommodities.ts @@ -1,4 +1,5 @@ -import { CommodityResponseDTO, QueryStatus } from '@ledgerly/shared/types'; +import { CommodityResponseDTO } from '@ledgerly/shared/types'; +import { CommodityQuery } from '@ledgerly/shared/validation'; import { CommodityMapper } from 'src/application'; import { CommodityRepositoryInterface } from 'src/application/interfaces'; import { User } from 'src/domain/users/user.entity'; @@ -10,7 +11,7 @@ export class GetAllCommoditiesUseCase { async execute( user: User, - status: QueryStatus, + status: CommodityQuery, ): Promise { const commoditiesSnapshots = await this.commodityRepository.getAll( user.getId().valueOf(), diff --git a/apps/backend/src/db/schemas/accounts.ts b/apps/backend/src/db/schemas/accounts.ts index 27163f8b..e36cc86a 100644 --- a/apps/backend/src/db/schemas/accounts.ts +++ b/apps/backend/src/db/schemas/accounts.ts @@ -1,4 +1,4 @@ -import { ACCOUNT_TYPE_VALUES } from '@ledgerly/shared/constants'; +import { ACCOUNT_TYPES } from '@ledgerly/shared/constants'; import { UUID } from '@ledgerly/shared/types'; import { InferInsertModel, InferSelectModel } from 'drizzle-orm'; import { sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; @@ -12,6 +12,7 @@ import { isTombstone, getAmountColumn, isSystem, + getBooleanColumn, } from './common'; import { usersTable } from './users'; @@ -29,11 +30,12 @@ export const accountsTable = sqliteTable( description, id, initialBalance: getAmountColumn('initial_balance'), + isClosed: getBooleanColumn('is_closed'), isSystem, isTombstone, name: text('name').notNull(), type: text('type', { - enum: ACCOUNT_TYPE_VALUES, + enum: ACCOUNT_TYPES, }).notNull(), updatedAt, userId: text('user_id') diff --git a/apps/backend/src/db/schemas/common.ts b/apps/backend/src/db/schemas/common.ts index 48d8ad0c..347cbeca 100644 --- a/apps/backend/src/db/schemas/common.ts +++ b/apps/backend/src/db/schemas/common.ts @@ -13,28 +13,19 @@ export const createdAt = text('created_at') export const updatedAt = text('updated_at') .notNull() .$type(); - export const description = text('description').notNull(); - export const hash = text('hash').notNull(); export const clientGeneratedId = text('id').primaryKey(); - export const id = text('id').notNull().primaryKey().$type(); - export const getBooleanColumn = (fieldName: string) => integer(fieldName, { mode: 'boolean' }).notNull(); - export const isTombstone = getBooleanColumn('is_tombstone'); - export const getNumericColumn = (fieldName: string) => { return text(fieldName).notNull().$type(); }; - 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/test-db.ts b/apps/backend/src/db/test-db.ts index 88f2f473..de15b145 100644 --- a/apps/backend/src/db/test-db.ts +++ b/apps/backend/src/db/test-db.ts @@ -103,6 +103,7 @@ export class TestDB { commodityCounter = new Counter('commodity'); operationCounter = new Counter('operation'); userCounter = new Counter('user'); + accountCounter = new Counter('account'); private testDbFile?: string; private client?: ReturnType; @@ -433,13 +434,17 @@ export class TestDB { initialBalance?: AmountString; description?: string; isSystem?: boolean; + isTombstone?: boolean; + isClosed?: boolean; }, ) => { const accountData = { description: '', initialBalance: Amount.create('0').valueOf(), + isClosed: false, isSystem: false, - name: 'Test Account', + isTombstone: false, + name: `Test Account ${this.accountCounter.getNextName()}`, type: ACCOUNT_TYPES[0], ...params, userId, @@ -452,7 +457,8 @@ export class TestDB { currentClearedBalanceLocal: accountData.initialBalance ?? 0, description: accountData.description || '', initialBalance: accountData.initialBalance ?? 0, - isTombstone: false, + isClosed: accountData.isClosed ?? false, + isTombstone: accountData.isTombstone ?? false, name: accountData.name, type: accountData.type, userId: accountData.userId, @@ -466,44 +472,16 @@ export class TestDB { return account; }; - // createCommodity = async ( - // userId: UUID, - // params?: { - // code?: CommodityCodeString; - // symbol?: CommoditySymbolString; - // name?: string; - // precision?: CommodityPrecisionNumber; - // isTombstone?: boolean; - // }, - // ) => { - // const nextName = this.commodityCounter.getNextName({ delimiter: '' }); - - // const commodityData = { - // code: params?.code ?? CommodityCode.create(`COM${nextName}`).valueOf(), - // isTombstone: params?.isTombstone ?? false, - // name: params?.name ?? `Commodity ${nextName}`, - // precision: params?.precision ?? 2, - // symbol: params?.symbol ?? `${nextName}`, - // userId, - // }; - - // const commodity = await this.db - // .insert(schema.commoditiesTable) - // .values({ - // code: commodityData.code, - // name: commodityData.name, - // precision: commodityData.precision, - // symbol: commodityData.symbol, - // userId: commodityData.userId, - // ...TestDB.createTimestamps, - // ...TestDB.uuid, - // isTombstone: commodityData.isTombstone, - // }) - // .returning() - // .get(); - - // return commodity; - // }; + getAccountById = async (accountId: UUID) => { + const account = await this.db + .select() + .from(accountsTable) + .where(sql`${accountsTable.id} = ${accountId} `) + + .get(); + + return account ?? null; + }; insertCommodity = async (commodityData: CommodityDbInsert) => { const insertedCommodity = await this.db @@ -754,4 +732,16 @@ export class TestDB { where: eq(commoditiesTable.userId, userId), }); }; + + deleteCommodityById = async (commodityId: UUID) => { + return await this.db + .delete(commoditiesTable) + .where(eq(commoditiesTable.id, commodityId)); + }; + + deleteAccountById = async (accountId: UUID) => { + return await this.db + .delete(accountsTable) + .where(eq(accountsTable.id, accountId)); + }; } diff --git a/apps/backend/src/di/container.ts b/apps/backend/src/di/container.ts index 0f401961..71e2ef22 100644 --- a/apps/backend/src/di/container.ts +++ b/apps/backend/src/di/container.ts @@ -4,7 +4,7 @@ import { LoginUserUseCase, RegisterUserUseCase, CreateAccountUseCase, - ArchiveAccountUseCase, + DeleteAccountUseCase, GetAccountByIdUseCase, GetAllAccountsUseCase, UpdateAccountUseCase, @@ -17,6 +17,7 @@ import { ArchiveCommodityUseCase, CreateCommodityUseCase, } from 'src/application'; +import { AccountOperationPolicy } from 'src/application/services/AccountOperationPolicy/account-operation.policy'; import { TransactionContextLoader } from 'src/application/services/TransactionService'; import { ensureEntityExistsAndOwned } from 'src/application/shared/ensureEntityExistsAndOwned'; import { ensureOwnedSnapshot } from 'src/application/shared/ensureOwnedSnapshot'; @@ -62,6 +63,7 @@ export const createContainer = (db: DataBase): AppContainer => { const repositories: AppContainer['repositories'] = { account: accountRepository, commodity: commodityRepository, + operation: operationRepository, transaction: transactionRepository, transactionQuery: transactionQueryRepository, user: userRepository, @@ -73,6 +75,10 @@ export const createContainer = (db: DataBase): AppContainer => { accountRepository, ); + const accountOperationPolicy = new AccountOperationPolicy( + operationRepository, + ); + const passwordManager = new PasswordManager(); const services: AppContainer['services'] = { @@ -80,11 +86,20 @@ export const createContainer = (db: DataBase): AppContainer => { }; // Create Account Use Cases - const createAccountUseCase = new CreateAccountUseCase(accountRepository); + const createAccountUseCase = new CreateAccountUseCase( + accountRepository, + transactionManager, + ); const getAllAccountsUseCase = new GetAllAccountsUseCase(accountRepository); const getAccountByIdUseCase = new GetAccountByIdUseCase(accountRepository); - const updateAccountUseCase = new UpdateAccountUseCase(accountRepository); - const archiveAccountUseCase = new ArchiveAccountUseCase(accountRepository); + const updateAccountUseCase = new UpdateAccountUseCase( + accountRepository, + accountOperationPolicy, + ); + const deleteAccountUseCase = new DeleteAccountUseCase( + accountRepository, + accountOperationPolicy, + ); const loginUserUseCase = new LoginUserUseCase(userRepository); @@ -150,8 +165,8 @@ export const createContainer = (db: DataBase): AppContainer => { const useCases: AppContainer['useCases'] = { account: { - archiveAccount: archiveAccountUseCase, createAccount: createAccountUseCase, + deleteAccount: deleteAccountUseCase, getAccountById: getAccountByIdUseCase, getAllAccounts: getAllAccountsUseCase, updateAccount: updateAccountUseCase, @@ -181,7 +196,7 @@ export const createContainer = (db: DataBase): AppContainer => { useCases.account.getAllAccounts, useCases.account.createAccount, useCases.account.updateAccount, - useCases.account.archiveAccount, + useCases.account.deleteAccount, ); const userController = new UserController(); diff --git a/apps/backend/src/di/types.ts b/apps/backend/src/di/types.ts index 472035b6..7c2d15e6 100644 --- a/apps/backend/src/di/types.ts +++ b/apps/backend/src/di/types.ts @@ -4,7 +4,7 @@ import { LoginUserUseCase, RegisterUserUseCase, CreateAccountUseCase, - ArchiveAccountUseCase, + DeleteAccountUseCase, GetAccountByIdUseCase, GetAllAccountsUseCase, UpdateAccountUseCase, @@ -25,6 +25,7 @@ import { TransactionRepository, CommodityRepository, UserRepository, + OperationRepository, } from 'src/infrastructure/db'; import { AccountController, @@ -40,6 +41,7 @@ type Repositories = { transactionQuery: TransactionQueryRepository; account: AccountRepository; user: UserRepository; + operation: OperationRepository; }; type Services = { @@ -51,7 +53,7 @@ type AccountUseCases = { getAllAccounts: GetAllAccountsUseCase; getAccountById: GetAccountByIdUseCase; updateAccount: UpdateAccountUseCase; - archiveAccount: ArchiveAccountUseCase; + deleteAccount: DeleteAccountUseCase; }; type AuthUseCases = { diff --git a/apps/backend/src/domain/accounts/account.test.ts b/apps/backend/src/domain/accounts/account.entity.test.ts similarity index 67% rename from apps/backend/src/domain/accounts/account.test.ts rename to apps/backend/src/domain/accounts/account.entity.test.ts index b4fd40a3..a1386193 100644 --- a/apps/backend/src/domain/accounts/account.test.ts +++ b/apps/backend/src/domain/accounts/account.entity.test.ts @@ -1,5 +1,10 @@ import { createUser } from 'src/db/createTestUser'; -import { AccountType, Commodity } from 'src/domain/'; +import { compareEntities } from 'src/db/test-utils'; +import { + AccountType, + Commodity, + DeletedEntityOperationError, +} from 'src/domain/'; import { Amount, Name, @@ -41,9 +46,11 @@ describe('Account Domain Entity', () => { describe('create method', () => { it('should create account with valid data', () => { + const description = 'account-description'; + const account = Account.create(user, { commodityId: commodity.getId(), - description: 'account-description', + description, initialBalance: Amount.create('0'), name, type: accountType, @@ -53,7 +60,7 @@ describe('Account Domain Entity', () => { expect(account.getId()).toBeDefined(); expect(account.belongsToUser(userId)).toBe(true); expect(account).toHaveProperty('name', name); - expect(account).toHaveProperty('description', 'account-description'); + 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); @@ -83,6 +90,7 @@ describe('Account Domain Entity', () => { description: 'restored-description', id: accountId.valueOf(), initialBalance: Amount.create('500').valueOf(), + isClosed: false, isSystem: false, isTombstone: false, name: 'restored-account', @@ -118,6 +126,22 @@ describe('Account Domain Entity', () => { expect(account).toHaveProperty('name', Name.create('updated-name')); }); + + it('should not allow update after deletion', () => { + const account = Account.create(user, { + commodityId: commodity.getId(), + description: 'description', + initialBalance: Amount.create('0'), + name, + type: accountType, + }); + + account.delete(); + + expect(() => account.update({ name: 'updated-name' })).toThrowError( + DeletedEntityOperationError, + ); + }); }); describe('softDelete method', () => { @@ -132,7 +156,7 @@ describe('Account Domain Entity', () => { expect(account.isDeleted()).toBe(false); - account.markAsDeleted(); + account.delete(); expect(account.isDeleted()).toBe(true); }); @@ -146,14 +170,14 @@ describe('Account Domain Entity', () => { type: accountType, }); - account.markAsDeleted(); + account.delete(); expect(() => account.update({ name: 'new-name' })).toThrowError( - 'Cannot update a deleted entity', + DeletedEntityOperationError, ); }); - it('should not allow deleting an already deleted account', () => { + it('should allow multiple delete calls without throwing error', () => { const account = Account.create(user, { commodityId: commodity.getId(), description: 'account-description', @@ -162,26 +186,19 @@ describe('Account Domain Entity', () => { type: accountType, }); - account.markAsDeleted(); - - const deletedSnapshot = account.toSnapshot(); + expect(account.isDeleted()).toBe(false); - let thrownError: unknown; + const firstDeleteResult = account.delete(); + const secondDeleteResult = account.delete(); - try { - account.markAsDeleted(); - } catch (error) { - thrownError = error; - } + expect(firstDeleteResult).toBe('changed'); + expect(secondDeleteResult).toBe('unchanged'); + expect(account.isDeleted()).toBe(true); - expect(thrownError).toMatchObject({ - code: 'DELETED_ENTITY_OPERATION', - context: { - entityType: Account.entityType, - operation: 'delete', - }, + compareEntities(account.toSnapshot(), { + ...account.toSnapshot(), + isTombstone: true, }); - expect(account.toSnapshot()).toEqual(deletedSnapshot); }); it('should only mark account as deleted and update timestamp during soft deletion', () => { @@ -203,7 +220,7 @@ describe('Account Domain Entity', () => { vi.setSystemTime(new Date('2026-01-01T00:00:01.000Z')); - account.markAsDeleted(); + account.delete(); expect(account.isDeleted()).toBe(true); @@ -220,5 +237,65 @@ describe('Account Domain Entity', () => { }); }); + it('should close and touch the account', () => { + const account = Account.create(user, { + commodityId: commodity.getId(), + description: 'account-description', + initialBalance: Amount.create('0'), + name, + type: accountType, + }); + + const updatedAtBeforeClose = account.getUpdatedAt(); + + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + account.close(); + + expect(account.closed).toBe(true); + expect(account.getUpdatedAt().toString()).not.toBe( + updatedAtBeforeClose.toString(), + ); + }); + + it('should reopen and touch the account', () => { + const account = Account.create(user, { + commodityId: commodity.getId(), + description: 'account-description', + initialBalance: Amount.create('0'), + name, + type: accountType, + }); + + account.close(); + + const updatedAtBeforeReopen = account.getUpdatedAt(); + + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + account.open(); + + expect(account.closed).toBe(false); + expect(account.getUpdatedAt().toString()).not.toBe( + updatedAtBeforeReopen.toString(), + ); + }); + + it('should not allow update isClosed and touch the account when it is deleted', () => { + const account = Account.create(user, { + commodityId: commodity.getId(), + description: 'account-description', + initialBalance: Amount.create('0'), + name, + type: accountType, + }); + + account.delete(); + + 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 070e853e..776f5786 100644 --- a/apps/backend/src/domain/accounts/account.entity.ts +++ b/apps/backend/src/domain/accounts/account.entity.ts @@ -19,9 +19,10 @@ import { CreateAccountProps, } from './types'; +type TransitionResult = 'changed' | 'unchanged'; + export class Account { static readonly entityType = 'account'; - private constructor( private readonly identity: EntityIdentity, private timestamps: EntityTimestamps, @@ -33,6 +34,7 @@ export class Account { private initialBalance: Amount, private currentClearedBalanceLocal: Amount, private type: AccountType, + private isClosed: boolean, public isSystem: boolean, ) {} @@ -64,6 +66,7 @@ export class Account { props.initialBalance, Amount.create('0'), props.type, + false, isSystem, ); } @@ -76,6 +79,7 @@ export class Account { description, id, initialBalance, + isClosed, isSystem, isTombstone, name, @@ -114,6 +118,7 @@ export class Account { Amount.restore(initialBalance), Amount.restore(currentClearedBalanceLocal), AccountType.restore(type), + isClosed, isSystem, ); } @@ -138,11 +143,17 @@ export class Account { } // Delegation methods for soft delete - markAsDeleted(): void { + delete(): TransitionResult { + if (this.isDeleted()) { + return 'unchanged'; + } + this.softDelete = this.softDelete.markAsDeleted( DeletedEntityOperationError.forDelete(Account.entityType), ); + this.touch(); + return 'changed'; } isDeleted(): boolean { @@ -150,7 +161,9 @@ export class Account { } private validateUpdateIsAllowed(): void { - this.softDelete.validateUpdateIsAllowed(); + this.softDelete.validateUpdateIsAllowed( + DeletedEntityOperationError.forUpdate(Account.entityType), + ); } // Delegation methods for ownership @@ -166,6 +179,7 @@ export class Account { 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(), @@ -194,4 +208,32 @@ export class Account { isCommoditySame(commodity: Commodity): boolean { return this.commodityRelation.getParentId().equals(commodity.getId()); } + + close(): TransitionResult { + this.validateUpdateIsAllowed(); + + if (this.isClosed) { + return 'unchanged'; + } + + this.isClosed = true; + this.touch(); + return 'changed'; + } + + open(): TransitionResult { + this.validateUpdateIsAllowed(); + + if (!this.isClosed) { + return 'unchanged'; + } + + this.isClosed = false; + this.touch(); + return 'changed'; + } + + get closed(): boolean { + return this.isClosed; + } } diff --git a/apps/backend/src/domain/accounts/types.ts b/apps/backend/src/domain/accounts/types.ts index 89112e0b..30c02a96 100644 --- a/apps/backend/src/domain/accounts/types.ts +++ b/apps/backend/src/domain/accounts/types.ts @@ -18,6 +18,7 @@ export type AccountSnapshot = { initialBalance: AmountString; isSystem: boolean; isTombstone: boolean; + isClosed: boolean; name: string; type: AccountTypeValue; updatedAt: IsoDatetimeString; diff --git a/apps/backend/src/domain/domain-core/behaviors/SoftDelete.ts b/apps/backend/src/domain/domain-core/behaviors/SoftDelete.ts index baac8991..efc36672 100644 --- a/apps/backend/src/domain/domain-core/behaviors/SoftDelete.ts +++ b/apps/backend/src/domain/domain-core/behaviors/SoftDelete.ts @@ -9,10 +9,8 @@ export class SoftDelete { /** * Marks the entity as deleted */ - markAsDeleted( - error: Error = new Error('Cannot delete a deleted entity'), - ): SoftDelete { - this.validateUpdateIsAllowed(error); + markAsDeleted(error?: Error): SoftDelete { + this.validateUpdateIsAllowed(error ?? SoftDelete.defaultUpdateError()); return new SoftDelete(true); } @@ -26,11 +24,9 @@ export class SoftDelete { /** * Checks if the entity can be updated */ - validateUpdateIsAllowed( - error: Error = new Error('Cannot update a deleted entity'), - ): void { + validateUpdateIsAllowed(error?: Error): void { if (this.isTombstone) { - throw error; + throw error ?? SoftDelete.defaultUpdateError(); } } @@ -41,6 +37,10 @@ export class SoftDelete { return this.isTombstone; } + static defaultUpdateError(): Error { + return new Error('Cannot update a deleted entity'); + } + /** * Creates a new instance of SoftDelete */ diff --git a/apps/backend/src/domain/domain-core/behaviors/__tests__/SoftDelete.test.ts b/apps/backend/src/domain/domain-core/behaviors/__tests__/SoftDelete.test.ts index e79202b6..33658339 100644 --- a/apps/backend/src/domain/domain-core/behaviors/__tests__/SoftDelete.test.ts +++ b/apps/backend/src/domain/domain-core/behaviors/__tests__/SoftDelete.test.ts @@ -62,7 +62,7 @@ describe('SoftDelete', () => { const deleted = SoftDelete.create().markAsDeleted(); expect(() => deleted.markAsDeleted()).toThrow( - 'Cannot delete a deleted entity', + SoftDelete.defaultUpdateError(), ); }); diff --git a/apps/backend/src/domain/domain.errors.test.ts b/apps/backend/src/domain/domain.errors.test.ts index d1791959..bfc03102 100644 --- a/apps/backend/src/domain/domain.errors.test.ts +++ b/apps/backend/src/domain/domain.errors.test.ts @@ -39,6 +39,16 @@ describe('coded domain errors', () => { }); }); + it('exposes DELETED_ENTITY_OPERATION context for use operations', () => { + expect(DeletedEntityOperationError.forUse('account')).toMatchObject({ + code: apiErrorCodes.deletedEntityOperation, + context: { + entityType: 'account', + operation: 'use', + }, + }); + }); + it('exposes INVALID_VERSION context', () => { expect(new InvalidVersionError(-1)).toMatchObject({ code: apiErrorCodes.invalidVersion, diff --git a/apps/backend/src/domain/domain.errors.ts b/apps/backend/src/domain/domain.errors.ts index 73f36d15..a9a137ba 100644 --- a/apps/backend/src/domain/domain.errors.ts +++ b/apps/backend/src/domain/domain.errors.ts @@ -275,7 +275,7 @@ export class ExcessiveOperationsError extends CodedDomainError<'EXCESSIVE_OPERAT /** * Thrown when attempting to perform operations on a deleted entity. */ -type DeletedEntityOperation = 'delete' | 'update'; +type DeletedEntityOperation = 'delete' | 'update' | 'use'; export class DeletedEntityOperationError extends CodedDomainError<'DELETED_ENTITY_OPERATION'> { constructor(entityType: string, operation: DeletedEntityOperation) { @@ -293,6 +293,10 @@ export class DeletedEntityOperationError extends CodedDomainError<'DELETED_ENTIT static forDelete(entityType: string): DeletedEntityOperationError { return new DeletedEntityOperationError(entityType, 'delete'); } + + static forUse(entityType: string): DeletedEntityOperationError { + return new DeletedEntityOperationError(entityType, 'use'); + } } /** diff --git a/apps/backend/src/infrastructure/db/BaseRepository.ts b/apps/backend/src/infrastructure/db/BaseRepository.ts index 00e26a17..3cad24bf 100644 --- a/apps/backend/src/infrastructure/db/BaseRepository.ts +++ b/apps/backend/src/infrastructure/db/BaseRepository.ts @@ -102,6 +102,23 @@ export class BaseRepository { return result; } + protected ensureWriteSucceeded( + result: T | undefined | null, + error: Error, + ): T { + if (!result) { + throw error; + } + + return result; + } + + protected ensureRowsAffected(rowsAffected: number, error: Error): void { + if (rowsAffected < 1) { + throw error; + } + } + /** * Builds the only public context allowed for repository not-found errors. * The result is serialized in the API response, so callers must not add 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 6fae78ce..3d56c755 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 @@ -12,6 +12,7 @@ describe('AccountPersistenceMapper', () => { description: 'Operating account', id: Id.create().valueOf(), initialBalance: Amount.create('1000').valueOf(), + isClosed: false, isSystem: true, isTombstone: false, name: 'Primary Checking', @@ -28,6 +29,7 @@ describe('AccountPersistenceMapper', () => { description: row.description, id: row.id, initialBalance: row.initialBalance, + isClosed: row.isClosed, isSystem: row.isSystem, isTombstone: row.isTombstone, name: row.name, @@ -57,6 +59,7 @@ describe('AccountPersistenceMapper', () => { description: row.description, id: row.id, initialBalance: row.initialBalance, + isClosed: row.isClosed, isSystem: row.isSystem, isTombstone: row.isTombstone, name: row.name, 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 4d90127c..a28f057e 100644 --- a/apps/backend/src/infrastructure/db/accounts/account-persistence.mapper.ts +++ b/apps/backend/src/infrastructure/db/accounts/account-persistence.mapper.ts @@ -14,6 +14,7 @@ export class AccountPersistenceMapper { description: row.description, id: row.id, initialBalance: row.initialBalance, + isClosed: row.isClosed, isSystem: row.isSystem, isTombstone: row.isTombstone, name: row.name, @@ -31,6 +32,7 @@ export class AccountPersistenceMapper { description: snapshot.description, id: snapshot.id, initialBalance: snapshot.initialBalance, + isClosed: snapshot.isClosed, isSystem: snapshot.isSystem, isTombstone: snapshot.isTombstone, name: snapshot.name, 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 d2cf1300..6a98d99a 100644 --- a/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts +++ b/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts @@ -1,4 +1,5 @@ -import { apiErrorCodes, AccountTypeValue, UUID } from '@ledgerly/shared/types'; +import { AccountTypeValue, UUID } from '@ledgerly/shared/types'; +import { AccountQuery } from '@ledgerly/shared/validation'; import dayjs from 'dayjs'; import { eq } from 'drizzle-orm'; import { @@ -8,10 +9,16 @@ import { UserDbRow, accountsTable, } from 'src/db/schema'; +import { + compareEntities, + compareEntityArrays, +} from 'src/db/test-utils/entityComparer'; import { Amount, CommodityCode, Timestamp } from 'src/domain/domain-core'; import { Id } from 'src/domain/domain-core/value-objects/Id'; import { AccountRepository } from 'src/infrastructure/db/'; import { + AccountPersistenceConflictError, + ForbiddenAccessError, RecordAlreadyExistsError, RepositoryInvariantError, RepositoryNotFoundError, @@ -21,9 +28,6 @@ import { describe, beforeEach, it, expect, vi } from 'vitest'; import { TestDB } from '../../../db/test-db'; import { TransactionManager } from '../TransactionManager'; -const firstUserAccounts = ['firstUserAccount1', 'firstUserAccount2']; -const secondUserAccounts = ['secondUserAccount1', 'secondUserAccount2']; - const getAccountData = (params: { userId: UUID; commodityId: UUID; @@ -35,6 +39,7 @@ const getAccountData = (params: { 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, @@ -74,6 +79,7 @@ describe('AccountRepository', () => { beforeEach(async () => { testDB = new TestDB(); + await testDB.setupTestDb(); user = await testDB.createUser(); @@ -88,7 +94,7 @@ describe('AccountRepository', () => { }); describe('create', () => { - it('should create a new account successfully', async () => { + it('creates an account', async () => { const newAccount = getAccountData({ commodityId: usdCommodity.id, name: 'New Account', @@ -96,17 +102,23 @@ describe('AccountRepository', () => { userId: user.id, }); - const account = await accountRepository.create(user.id, { + await accountRepository.create(user.id, { ...newAccount, }); - expect(account).toHaveProperty('id'); - expect(account.name).toBe(newAccount.name); - expect(account.type).toBe(newAccount.type); - expect(account.userId).toBe(newAccount.userId); + const retrievedAccount = await testDB.getAccountById(newAccount.id); + + if (!retrievedAccount) { + throw new Error('Account not found after creation'); + } + + expect(retrievedAccount).toHaveProperty('id'); + expect(retrievedAccount.name).toBe(newAccount.name); + expect(retrievedAccount.type).toBe(newAccount.type); + expect(retrievedAccount.userId).toBe(newAccount.userId); }); - it("should throw an error if commodity belongs to a different user than the account's user", async () => { + it("throws RepositoryNotFoundError when commodity belongs to a different user than the account's user", async () => { const secondUser = await testDB.createUser({ email: 'second-user@example.com', name: 'Second User', @@ -124,7 +136,7 @@ describe('AccountRepository', () => { ).rejects.toThrowError(RepositoryNotFoundError); }); - it('should throw an invariant error when create userId differs from account snapshot userId', async () => { + it('throws RepositoryInvariantError when create userId differs from account snapshot userId', async () => { const secondUser = await testDB.createUser({ email: 'second-user@example.com', name: 'Second User', @@ -142,7 +154,7 @@ describe('AccountRepository', () => { ).rejects.toThrowError(RepositoryInvariantError); }); - it('should not allow duplicate account names for the same user', async () => { + it('throws RecordAlreadyExistsError for duplicate account names owned by the same user', async () => { const newAccount = getAccountData({ commodityId: usdCommodity.id, name: 'New Account', @@ -165,7 +177,7 @@ describe('AccountRepository', () => { ); }); - it('should allow same account names for different users', async () => { + it('allows duplicate account names across different users', async () => { const accountName = 'Shared Account Name'; const secondUser = await testDB.createUser({ @@ -187,10 +199,15 @@ describe('AccountRepository', () => { userId: secondUser.id, }); - const account1 = await testDB.createAccount(user.id, usdCommodity.id, { - ...firstUserAccount, - }); - const account2 = await testDB.createAccount( + const retrievedAccount1 = await testDB.createAccount( + user.id, + usdCommodity.id, + { + ...firstUserAccount, + }, + ); + + const retrievedAccount2 = await testDB.createAccount( secondUser.id, usdCommodity.id, { @@ -198,19 +215,19 @@ describe('AccountRepository', () => { }, ); - expect(account1).toHaveProperty('id'); - expect(account1.name).toBe(accountName); - expect(account1.userId).toBe(user.id); + expect(retrievedAccount1).toHaveProperty('id'); + expect(retrievedAccount1.name).toBe(accountName); + expect(retrievedAccount1.userId).toBe(user.id); - expect(account2).toHaveProperty('id'); - expect(account2.name).toBe(accountName); - expect(account2.userId).toBe(secondUser.id); + expect(retrievedAccount2).toHaveProperty('id'); + expect(retrievedAccount2.name).toBe(accountName); + expect(retrievedAccount2.userId).toBe(secondUser.id); - expect(account1.id).not.toBe(account2.id); - expect(account1.userId).not.toBe(account2.userId); + expect(retrievedAccount1.id).not.toBe(retrievedAccount2.id); + expect(retrievedAccount1.userId).not.toBe(retrievedAccount2.userId); }); - it('should throw an error if commodity is tombstoned', async () => { + it('throws RepositoryNotFoundError when commodity is tombstoned', async () => { const tombstonedCommodity = await testDB.createCommodity(user.id, { code: CommodityCode.create('TOMBSTONED').valueOf(), isTombstone: true, @@ -230,80 +247,175 @@ describe('AccountRepository', () => { accountRepository.create(user.id, newAccount), ).rejects.toThrowError(RepositoryNotFoundError); }); + + it('throws AccountPersistenceConflictError when insert affects no rows', async () => { + const newAccount = getAccountData({ + commodityId: usdCommodity.id, + name: 'New Account', + type: 'asset', + userId: user.id, + }); + + const values = vi.fn().mockResolvedValue({ rowsAffected: 0 }); + + const insert = vi + .spyOn(testDB.db, 'insert') + .mockReturnValueOnce({ values } as never); + + try { + await expect( + accountRepository.create(user.id, newAccount), + ).rejects.toThrowError(AccountPersistenceConflictError); + + expect(insert).toHaveBeenCalledOnce(); + expect(values).toHaveBeenCalledWith( + expect.objectContaining({ + id: newAccount.id, + name: newAccount.name, + userId: newAccount.userId, + }), + ); + } finally { + insert.mockRestore(); + } + }); }); describe('getAll', () => { - beforeEach(async () => { - const secondUser = await testDB.createUser({ - email: 'second-user@example.com', - name: 'Second User', - }); + const accountsDataList = [ + { isClosed: false, isTombstone: true, name: 'firstUserAccount1' }, + { isClosed: false, isTombstone: false, name: 'firstUserAccount2' }, + { isClosed: true, isTombstone: false, name: 'firstUserAccount3' }, + ]; + + const allAccounts = accountsDataList.filter( + (account) => !account.isTombstone, + ); + + const activeAccounts = accountsDataList.filter( + (account) => !account.isClosed && !account.isTombstone, + ); + + const archivedAccounts = accountsDataList.filter( + (account) => account.isClosed && !account.isTombstone, + ); + + type TestData = { + isClosed: boolean; + isTombstone: boolean; + name: string; + }; + + const testData: [AccountQuery, TestData[]][] = [ + [{ status: 'all' }, allAccounts], + [{ status: 'open' }, activeAccounts], + [{ status: 'closed' }, archivedAccounts], + ]; - for (const name of firstUserAccounts) { + beforeEach(async () => { + for (const data of accountsDataList) { await testDB.createAccount(user.id, usdCommodity.id, { - name, + isClosed: data.isClosed, + isTombstone: data.isTombstone, + name: data.name, type: 'asset', }); } + }); - for (const name of secondUserAccounts) { - await testDB.createAccount(secondUser.id, usdCommodity.id, { - name, - type: 'asset', + it.each(testData)( + 'returns accounts matching query %s', + async ({ status }: AccountQuery, expectedAccounts: TestData[]) => { + const accounts = await accountRepository.getAll(user.id, { + status, }); - } - }); - it('should retrieve all accounts for a user', async () => { - const accounts = await accountRepository.getAll(user.id); + compareEntityArrays(accounts, expectedAccounts, { + fields: ['name', 'isClosed', 'isTombstone'], + }); - expect(accounts.length).toBe(firstUserAccounts.length); + expect(accounts.length).toBe(expectedAccounts.length); + }, + ); - accounts.forEach((account, index) => { - expect(account.name).toBe(firstUserAccounts[index]); - expect(account.userId).toBe(user.id); + it('returns an empty array when user does not exist', async () => { + const accounts = await accountRepository.getAll(Id.create().valueOf(), { + status: 'all', }); + + expect(accounts.length).toBe(0); }); - it('should not retrieve accounts for a different user', async () => { - const secondUser = await testDB.createUser({ - email: 'second-user2@example.com', - name: 'Second User', + it('returns an empty array when user has no accounts', async () => { + const newUser = await testDB.createUser({ + email: 'new-user@example.com', + name: 'New User', }); - const accounts = await accountRepository.getAll(secondUser.id); - + const accounts = await accountRepository.getAll(newUser.id, { + status: 'all', + }); expect(accounts.length).toBe(0); }); - it('should return an empty array if user does not exist', async () => { - const accounts = await accountRepository.getAll(Id.create().valueOf()); + it('should not include accounts owned by another user', async () => { + const secondUser = await testDB.createUser({ + email: 'second-user@example.com', + name: 'Second User', + }); + + const accountsForSecondUser = [ + { isClosed: false, isTombstone: false, name: 'secondUserAccount1' }, + { isClosed: false, isTombstone: false, name: 'secondUserAccount2' }, + { isClosed: false, isTombstone: false, name: 'secondUserAccount3' }, + ]; + + await Promise.all( + accountsForSecondUser.map((account) => + testDB.createAccount(secondUser.id, usdCommodity.id, { + isClosed: account.isClosed, + isTombstone: account.isTombstone, + name: account.name, + type: 'asset', + }), + ), + ); + + const accounts = await accountRepository.getAll(secondUser.id, { + status: 'all', + }); - expect(accounts.length).toBe(0); + expect( + accounts.every((account) => account.userId === secondUser.id), + ).toBe(true); }); }); describe('getById', () => { - let account: AccountDbRow; + let activeAccountRow: AccountDbRow; beforeEach(async () => { - account = await testDB.createAccount(user.id, usdCommodity.id, {}); + activeAccountRow = await testDB.createAccount( + user.id, + usdCommodity.id, + {}, + ); }); - it('should retrieve an account by ID', async () => { + it('retrieves an active account by id', async () => { const retrievedAccount = await accountRepository.getById( user.id, - account.id, + activeAccountRow.id, ); expect(retrievedAccount).toBeDefined(); - expect(retrievedAccount?.id).toBe(account.id); + expect(retrievedAccount?.id).toBe(activeAccountRow.id); }); - it('should return undefined if account does not exist', async () => { + it('throws RepositoryNotFoundError when account does not exist', async () => { const retrievedAccount = accountRepository.getById( - Id.create().valueOf(), user.id, + Id.create().valueOf(), ); await expect(retrievedAccount).rejects.toThrowError( @@ -311,26 +423,92 @@ describe('AccountRepository', () => { ); }); - it('returns an allowlisted error contract for a missing account', async () => { - const accountId = Id.create().valueOf(); + it('throws RepositoryNotFoundError when account is tombstoned', async () => { + const tombstonedAccountRow = await testDB.createAccount( + user.id, + usdCommodity.id, + { isTombstone: true }, + ); + const retrievedAccount = accountRepository.getById( + user.id, + tombstonedAccountRow.id, + ); - await expect( - accountRepository.getById(user.id, accountId), - ).rejects.toMatchObject({ - code: apiErrorCodes.entityNotFound, - context: { entityId: accountId, entityType: 'account' }, - }); + await expect(retrievedAccount).rejects.toThrowError( + RepositoryNotFoundError, + ); }); - it('should return undefined if user does not own the account', async () => { + it('throws RepositoryNotFoundError when user does not own the account', async () => { const secondUser = await testDB.createUser({ email: 'second-user@example.com', name: 'Second User', }); const retrievedAccount = accountRepository.getById( - account.id, secondUser.id, + activeAccountRow.id, + ); + + await expect(retrievedAccount).rejects.toThrowError( + RepositoryNotFoundError, + ); + }); + }); + + describe('getByIdForLifecycle', () => { + it('should retrieve an active account by id', async () => { + const activeAccountRow = await testDB.createAccount( + user.id, + usdCommodity.id, + {}, + ); + + const retrievedAccount = await accountRepository.getByIdForLifecycle( + user.id, + activeAccountRow.id, + ); + + compareEntities(retrievedAccount, activeAccountRow); + }); + + it('should retrieve a tombstoned account by id', async () => { + const tombstonedAccountRow = await testDB.createAccount( + user.id, + usdCommodity.id, + { isTombstone: true }, + ); + + const retrievedAccount = await accountRepository.getByIdForLifecycle( + user.id, + tombstonedAccountRow.id, + ); + + expect(tombstonedAccountRow.isTombstone).toBe(true); + + compareEntities(retrievedAccount, tombstonedAccountRow); + }); + + it('should throw RepositoryNotFoundError when account does not exist', async () => { + const retrievedAccount = accountRepository.getByIdForLifecycle( + user.id, + Id.create().valueOf(), + ); + + await expect(retrievedAccount).rejects.toThrowError( + RepositoryNotFoundError, + ); + }); + + it('should throw RepositoryNotFoundError when user does not own the account', async () => { + const secondUser = await testDB.createUser({ + email: 'second-user@example.com', + name: 'Second User', + }); + + const retrievedAccount = accountRepository.getByIdForLifecycle( + secondUser.id, + Id.create().valueOf(), ); await expect(retrievedAccount).rejects.toThrowError( @@ -346,7 +524,7 @@ describe('AccountRepository', () => { account = await testDB.createAccount(user.id, usdCommodity.id, {}); }); - it('should update account when it belongs to user', async () => { + it('updates an account owned by the user', async () => { const user2 = await testDB.createUser(); const updatedAccountData = getAccountData({ @@ -356,20 +534,18 @@ describe('AccountRepository', () => { userId: user2.id, }); - const updatedAccount = await accountRepository.update( - user.id, - account.id, - updatedAccountData, - ); + await accountRepository.update(user.id, account.id, updatedAccountData); - expect(updatedAccount).toBeDefined(); - expect(updatedAccount?.id).toBe(account.id); - expect(updatedAccount?.name).toBe(updatedAccountData.name); - expect(updatedAccount?.type).toBe(updatedAccountData.type); - expect(updatedAccount?.userId).toBe(user.id); + const retrievedAccount = await testDB.getAccountById(account.id); + + expect(retrievedAccount).toBeDefined(); + expect(retrievedAccount?.id).toBe(account.id); + expect(retrievedAccount?.name).toBe(updatedAccountData.name); + expect(retrievedAccount?.type).toBe(updatedAccountData.type); + expect(retrievedAccount?.userId).toBe(user.id); }); - it('should return undefined when account belongs to different user', async () => { + it('throws RepositoryNotFoundError when account belongs to another user', async () => { const updatedAccountData = getAccountData({ commodityId: eurCommodity.id, name: 'Updated Account', @@ -377,18 +553,16 @@ describe('AccountRepository', () => { userId: user.id, }); - const updatedAccount = accountRepository.update( + const promise = accountRepository.update( Id.create().valueOf(), account.id, updatedAccountData, ); - await expect(updatedAccount).rejects.toThrowError( - RepositoryNotFoundError, - ); + await expect(promise).rejects.toThrowError(RepositoryNotFoundError); }); - it('should not allow updating to duplicate name within same user', async () => { + it('throws RecordAlreadyExistsError when updating to a duplicate name owned by the same user', async () => { const updatedAccountData = getAccountData({ commodityId: usdCommodity.id, name: 'Updated Account', @@ -413,9 +587,15 @@ describe('AccountRepository', () => { }, }), ); + + const retrievedAccount = await testDB.getAccountById(account.id); + + expect(retrievedAccount).toBeDefined(); + expect(retrievedAccount?.id).toBe(account.id); + expect(retrievedAccount?.name).not.toBe(updatedAccountData.name); }); - it('should allow updating to name that exists for different user', async () => { + it('allows updating to a name used by another user', async () => { const secondUser = await testDB.createUser({ email: 'second-user@example.com', name: 'Second User', @@ -431,22 +611,22 @@ describe('AccountRepository', () => { }, ); - const updatedSecondUserAccount = await accountRepository.update( - secondUser.id, + await accountRepository.update(secondUser.id, secondUserAccount.id, { + name: accountData.name, + type: 'asset', + updatedAt: Timestamp.create().valueOf(), + }); + + const retrievedAccount = await testDB.getAccountById( secondUserAccount.id, - { - name: accountData.name, - type: 'asset', - updatedAt: Timestamp.create().valueOf(), - }, ); - expect(updatedSecondUserAccount).toBeDefined(); - expect(updatedSecondUserAccount?.id).toBe(secondUserAccount.id); - expect(updatedSecondUserAccount?.name).toBe(accountData.name); + expect(retrievedAccount).toBeDefined(); + expect(retrievedAccount?.id).toBe(secondUserAccount.id); + expect(retrievedAccount?.name).toBe(accountData.name); }); - it('should throw RepositoryNotFoundError when account is tombstoned', async () => { + it('throws RepositoryNotFoundError when account is tombstoned', async () => { const tombstonedAccount = await testDB.createAccount( user.id, usdCommodity.id, @@ -470,9 +650,40 @@ describe('AccountRepository', () => { ).rejects.toThrowError(RepositoryNotFoundError); }); - it.todo('should preserve commodityId when updating account fields'); + it('should preserve commodityId when updating allowed account fields', async () => { + const updatedAccountData = { + name: 'Updated Account', + type: 'expense' as const, + updatedAt: Timestamp.create().valueOf(), + }; + + await accountRepository.update(user.id, account.id, updatedAccountData); + + const retrievedAccount = await testDB.getAccountById(account.id); - it('should only update allowed fields', async () => { + expect(retrievedAccount).toBeDefined(); + expect(retrievedAccount?.id).toBe(account.id); + expect(retrievedAccount?.commodityId).toBe(account.commodityId); + }); + + it('should not persist isClosed through the regular update method', async () => { + const updatedAccountData = { + isClosed: true, + name: 'Updated Account', + type: 'expense' as const, + updatedAt: Timestamp.create().valueOf(), + }; + + await accountRepository.update(user.id, account.id, updatedAccountData); + + const retrievedAccount = await testDB.getAccountById(account.id); + + expect(retrievedAccount).toBeDefined(); + expect(retrievedAccount?.id).toBe(account.id); + expect(retrievedAccount?.isClosed).not.toBe(true); + }); + + it('updates only allowlisted fields', async () => { const maliciousData = { createdAt: Timestamp.create().valueOf(), id: 'malicious-id', @@ -482,45 +693,43 @@ describe('AccountRepository', () => { updatedAt: Timestamp.create().valueOf(), }; - const result = await accountRepository.update( - user.id, - account.id, - maliciousData, - ); + await accountRepository.update(user.id, account.id, maliciousData); - expect(result?.id).toBe(account.id); - expect(result?.userId).toBe(user.id); - expect(result?.name).toBe(maliciousData.name); + const updatedAccount = await testDB.getAccountById(account.id); + + expect(updatedAccount?.id).toBe(account.id); + expect(updatedAccount?.userId).toBe(user.id); + expect(updatedAccount?.name).toBe(maliciousData.name); }); }); - describe('softDelete', () => { + describe('delete', () => { let account: AccountDbRow; beforeEach(async () => { account = await testDB.createAccount(user.id, usdCommodity.id); }); - it('should soft delete account when it exists and belongs to user', async () => { - const deletedData = { + it('marks an active account as tombstoned', async () => { + const data = { isTombstone: true, updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), }; - const deleted = await accountRepository.softDelete( - user.id, - account.id, - deletedData, - ); + await accountRepository.delete(user.id, account.id, data); - expect(deleted).toBeDefined(); - expect(deleted.isTombstone).toBe(true); - expect(deleted.updatedAt).toBe(deletedData.updatedAt); - expect(deleted.updatedAt).not.toBe(account.updatedAt); + const deletedAccount = await testDB.getAccountById(account.id); + + expect(deletedAccount).toBeDefined(); + expect(deletedAccount?.isTombstone).toBe(true); + expect(deletedAccount?.updatedAt).toBe(data.updatedAt); + expect(deletedAccount?.updatedAt).not.toBe(account.updatedAt); const retrievedAccount = accountRepository.getById(user.id, account.id); - const userAccounts = await accountRepository.getAll(user.id); + const userAccounts = await accountRepository.getAll(user.id, { + status: 'all', + }); await expect(retrievedAccount).rejects.toThrowError( RepositoryNotFoundError, @@ -529,56 +738,366 @@ describe('AccountRepository', () => { expect(userAccounts.length).toBe(0); }); - it('should throw RepositoryNotFoundError when account belongs to different user', async () => { + it('throws RepositoryNotFoundError when account belongs to another user', async () => { const secondUser = await testDB.createUser({ email: 'second-user@example.com', name: 'Second User', }); - await testDB.createAccount(secondUser.id, usdCommodity.id, { - initialBalance: Amount.create('2000').valueOf(), - name: 'Shared Account Name', - type: 'asset', - }); + const createdAccount = await testDB.createAccount( + secondUser.id, + usdCommodity.id, + { + initialBalance: Amount.create('2000').valueOf(), + name: 'Shared Account Name', + type: 'asset', + }, + ); await expect( - accountRepository.softDelete(secondUser.id, account.id, { + accountRepository.delete(user.id, createdAccount.id, { updatedAt: Timestamp.create().valueOf(), }), ).rejects.toThrowError(RepositoryNotFoundError); - const secondUserAccounts = await accountRepository.getAll(secondUser.id); + const retrievedAccount = await testDB.getAccountById(createdAccount.id); - expect(secondUserAccounts.length).toBe(1); + compareEntities(retrievedAccount!, createdAccount); }); - it('should throw RepositoryNotFoundError when account does not exist', async () => { - const result = accountRepository.softDelete( + it('throws RepositoryNotFoundError when account does not exist', async () => { + const result = accountRepository.delete(user.id, Id.create().valueOf(), { + updatedAt: Timestamp.create().valueOf(), + }); + + await expect(result).rejects.toThrowError(RepositoryNotFoundError); + + const retrievedAccount = await testDB.getAccountById(account.id); + + compareEntities(retrievedAccount!, account); + }); + + it('throws RepositoryNotFoundError when account is already tombstoned', async () => { + await accountRepository.delete(user.id, account.id, { + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }); + + const retrievedAccountBeforeDeleting = await testDB.getAccountById( + account.id, + ); + + await expect( + accountRepository.delete(user.id, account.id, { + updatedAt: Timestamp.restore('2030-01-02T00:00:00.000Z').valueOf(), + }), + ).rejects.toThrowError(RepositoryNotFoundError); + + const retrievedAccountAfterDeleting = await testDB.getAccountById( + account.id, + ); + + compareEntities( + retrievedAccountAfterDeleting!, + retrievedAccountBeforeDeleting!, + ); + }); + + it('should preserve isClosed while marking an account as tombstoned', async () => { + const account = await testDB.createAccount(user.id, usdCommodity.id, { + isClosed: true, + }); + + await accountRepository.delete(user.id, account.id, { + updatedAt: Timestamp.restore('2030-01-02T00:00:00.000Z').valueOf(), + }); + + const retrievedAccount = await testDB.getAccountById(account.id); + + expect(retrievedAccount).toBeDefined(); + expect(retrievedAccount?.isClosed).toBe(account.isClosed); + expect(retrievedAccount?.isTombstone).toBe(true); + }); + + it('should not allow updating any other fields while marking an account as tombstoned', async () => { + const account = await testDB.createAccount(user.id, usdCommodity.id); + + const maliciousData = { + createdAt: Timestamp.create().valueOf(), + id: 'malicious-id', + initialBalance: Amount.create('2000').valueOf(), + isClosed: true, + name: 'Updated Account', + type: 'expense' as const, + updatedAt: Timestamp.create().valueOf(), + }; + + await accountRepository.delete(user.id, account.id, maliciousData); + const updatedAccount = await testDB.getAccountById(account.id); + + expect(updatedAccount?.isTombstone).toBe(true); + + compareEntities(updatedAccount!, account, ['isTombstone', 'updatedAt']); + }); + }); + + describe('close', () => { + let account: AccountDbRow; + + beforeEach(async () => { + account = await testDB.createAccount(user.id, usdCommodity.id); + }); + + it('closes an active account', async () => { + const closedData = { + action: 'close' as const, + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }; + + await accountRepository.close(user.id, account.id, closedData); + + const retrievedAccount = await accountRepository.getById( + user.id, + account.id, + ); + + expect(retrievedAccount).toBeDefined(); + expect(retrievedAccount.isClosed).toBe(true); + expect(retrievedAccount.updatedAt).toBe(closedData.updatedAt); + expect(retrievedAccount.updatedAt).not.toBe(account.updatedAt); + + expect(retrievedAccount?.isClosed).toBe(true); + }); + + it('updates only isClosed and updatedAt when closing an account', async () => { + const closedData = { + action: 'close' as const, + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }; + + await accountRepository.close(user.id, account.id, closedData); + + const retrievedAccount = await accountRepository.getById( + user.id, + account.id, + ); + + expect(retrievedAccount).toBeDefined(); + expect(retrievedAccount?.isClosed).toBe(true); + expect(retrievedAccount?.updatedAt).toBe(closedData.updatedAt); + expect(retrievedAccount?.updatedAt).not.toBe(account.updatedAt); + + compareEntities(retrievedAccount, account, ['isClosed', 'updatedAt']); + }); + + it('throws RepositoryNotFoundError when account does not exist', async () => { + const closedData = { + action: 'close' as const, + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }; + + const promise = accountRepository.close( user.id, Id.create().valueOf(), - { - updatedAt: Timestamp.create().valueOf(), - }, + closedData, ); - await expect(result).rejects.toThrowError(RepositoryNotFoundError); + await expect(promise).rejects.toThrowError(RepositoryNotFoundError); + }); + + it('throws RepositoryNotFoundError when account belongs to another user', async () => { + const secondUser = await testDB.createUser({ + email: 'seconduser@example.com', + }); + + const promise = accountRepository.close(secondUser.id, account.id, { + action: 'close', + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }); + + await expect(promise).rejects.toThrowError(RepositoryNotFoundError); }); - it('should throw RepositoryNotFoundError when account is already tombstoned', async () => { - await accountRepository.softDelete(user.id, account.id, { + it('is idempotent for an already closed account', async () => { + const closedData = { + action: 'close' as const, updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }; + + await accountRepository.close(user.id, account.id, closedData); + + const initialClosedAccount = await accountRepository.getById( + user.id, + account.id, + ); + + await accountRepository.close(user.id, account.id, closedData); + + const retrievedCloseAccount = await accountRepository.getById( + user.id, + account.id, + ); + + compareEntities(initialClosedAccount, retrievedCloseAccount); + }); + + it('throws RepositoryNotFoundError when account is tombstoned', async () => { + const account = await testDB.createAccount(user.id, usdCommodity.id, { + isTombstone: true, }); await expect( - accountRepository.softDelete(user.id, account.id, { - updatedAt: Timestamp.restore('2030-01-02T00:00:00.000Z').valueOf(), + accountRepository.close(user.id, account.id, { + action: 'close', + updatedAt: Timestamp.create().valueOf(), }), ).rejects.toThrowError(RepositoryNotFoundError); }); }); + describe('open', () => { + it('should open a closed account', async () => { + const account = await testDB.createAccount(user.id, usdCommodity.id, { + isClosed: true, + }); + + const updatedAt = Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(); + + await accountRepository.open(user.id, account.id, { + action: 'open', + updatedAt, + }); + + const retrievedAccount = await accountRepository.getById( + user.id, + account.id, + ); + + compareEntities(retrievedAccount, account, ['isClosed', 'updatedAt']); + expect(retrievedAccount?.isClosed).toBe(false); + expect(retrievedAccount?.updatedAt).toBe(updatedAt); + }); + + it('should be idempotent for an already open account', async () => { + const account = await testDB.createAccount(user.id, usdCommodity.id, { + isClosed: false, + }); + + const updatedAt = Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(); + + await accountRepository.open(user.id, account.id, { + action: 'open', + updatedAt, + }); + + const initialOpenAccount = await accountRepository.getById( + user.id, + account.id, + ); + + await accountRepository.open(user.id, account.id, { + action: 'open', + updatedAt, + }); + + const retrievedOpenAccount = await accountRepository.getById( + user.id, + account.id, + ); + + compareEntities(initialOpenAccount, retrievedOpenAccount); + }); + + it('should throw RepositoryNotFoundError when account is tombstoned', async () => { + const account = await testDB.createAccount(user.id, usdCommodity.id, { + isTombstone: true, + }); + + await expect( + accountRepository.open(user.id, account.id, { + action: 'open', + updatedAt: Timestamp.create().valueOf(), + }), + ).rejects.toThrowError(RepositoryNotFoundError); + }); + + it('should throw RepositoryNotFoundError when account belongs to another user', async () => { + const secondUser = await testDB.createUser({ + email: 'seconduser@example.com', + }); + + const account = await testDB.createAccount(user.id, usdCommodity.id); + + const promise = accountRepository.open(secondUser.id, account.id, { + action: 'open', + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }); + + await expect(promise).rejects.toThrowError(RepositoryNotFoundError); + }); + }); + + describe('ensureUserOwnsAccount', () => { + it('returns account snapshot when user owns the account', async () => { + const account = await testDB.createAccount(user.id, usdCommodity.id); + + const retrievedAccount = await accountRepository.ensureUserOwnsAccount( + user.id, + account.id, + ); + + compareEntities(retrievedAccount, account); + }); + + it('throws RepositoryNotFoundError when account does not exist', async () => { + await expect( + accountRepository.ensureUserOwnsAccount(user.id, Id.create().valueOf()), + ).rejects.toThrowError(RepositoryNotFoundError); + }); + + it('throws RepositoryNotFoundError when account is tombstoned', async () => { + const account = await testDB.createAccount(user.id, usdCommodity.id, { + isTombstone: true, + }); + + await expect( + accountRepository.ensureUserOwnsAccount(user.id, account.id), + ).rejects.toThrowError(RepositoryNotFoundError); + }); + + it('throws ForbiddenAccessError when account belongs to another user', async () => { + const secondUser = await testDB.createUser({ + email: 'second-user@example.com', + }); + + const account = await testDB.createAccount( + secondUser.id, + usdCommodity.id, + ); + + await expect( + accountRepository.ensureUserOwnsAccount(user.id, account.id), + ).rejects.toThrowError(ForbiddenAccessError); + }); + }); + + describe('getByIds', () => { + it.todo('should return all requested accounts owned by the user'); + + it.todo( + 'should throw RepositoryNotFoundError when any requested account does not exist', + ); + + it.todo( + 'should throw RepositoryNotFoundError when any requested account is tombstoned', + ); + + it.todo( + 'should throw RepositoryNotFoundError when any requested account belongs to another user', + ); + }); + describe('timestamp behavior', () => { - it('should set createdAt and updatedAt on creation', async () => { + it('sets createdAt and updatedAt on creation', async () => { const beforeCreate = dayjs(); const newAccount = getAccountData({ @@ -587,21 +1106,24 @@ describe('AccountRepository', () => { type: 'asset', userId: user.id, }); - const account = await accountRepository.create(user.id, newAccount); - const accountDate = dayjs(account.createdAt); + await accountRepository.create(user.id, newAccount); + + const account = await testDB.getAccountById(newAccount.id); + + const accountDate = dayjs(account?.createdAt); const afterCreate = dayjs(); - expect(account.createdAt).toBeDefined(); - expect(account.updatedAt).toBeDefined(); - expect(dayjs(account.createdAt)).toBeInstanceOf(dayjs); - expect(dayjs(account.updatedAt)).toBeInstanceOf(dayjs); + expect(account?.createdAt).toBeDefined(); + expect(account?.updatedAt).toBeDefined(); + expect(dayjs(account?.createdAt)).toBeInstanceOf(dayjs); + expect(dayjs(account?.updatedAt)).toBeInstanceOf(dayjs); expect(beforeCreate.unix()).toBeLessThanOrEqual(accountDate.unix()); expect(accountDate.unix()).toBeLessThanOrEqual(afterCreate.unix()); }); - it('should update updatedAt on account update', async () => { + it('updates updatedAt on account update', async () => { const account = await testDB.createAccount(user.id, usdCommodity.id); const originalUpdatedAt = dayjs(account?.updatedAt); @@ -614,11 +1136,9 @@ describe('AccountRepository', () => { userId: user.id, }); - const updatedAccount = await accountRepository.update( - user.id, - account.id, - updatedData, - ); + await accountRepository.update(user.id, account.id, updatedData); + + const updatedAccount = await testDB.getAccountById(account.id); const newUpdatedAt = dayjs(updatedAccount?.updatedAt); diff --git a/apps/backend/src/infrastructure/db/accounts/account.repository.ts b/apps/backend/src/infrastructure/db/accounts/account.repository.ts index c55f1e46..3f5048f5 100644 --- a/apps/backend/src/infrastructure/db/accounts/account.repository.ts +++ b/apps/backend/src/infrastructure/db/accounts/account.repository.ts @@ -1,34 +1,70 @@ import { UUID } from '@ledgerly/shared/types'; +import { AccountQuery } from '@ledgerly/shared/validation'; import { and, eq, inArray } from 'drizzle-orm'; import { + type AccountLifecycleUpdateInput, type AccountRepositoryInterface, - type AccountRepositorySoftDeleteInput, + type AccountRepositoryLifecycleInput, type AccountRepositoryUpdateInput, + type LifecycleAction, } from 'src/application'; import { accountsTable } from 'src/db/schemas/accounts'; import { commoditiesTable } from 'src/db/schemas/commodities'; import { AccountSnapshot } from 'src/domain/accounts'; -import { RepositoryInvariantError } from 'src/infrastructure/errors'; +import { + AccountPersistenceConflictError, + RepositoryInvariantError, +} from 'src/infrastructure/errors'; import { BaseRepository } from '../BaseRepository'; import { AccountPersistenceMapper } from './account-persistence.mapper'; +const getWhereClauseForGetAll = (userId: UUID, query: AccountQuery) => { + const { status } = query; + + if (status === 'all') { + return and( + eq(accountsTable.userId, userId), + eq(accountsTable.isTombstone, false), + ); + } + + if (status === 'open') { + return and( + eq(accountsTable.userId, userId), + eq(accountsTable.isClosed, false), + eq(accountsTable.isTombstone, false), + ); + } + + return and( + eq(accountsTable.userId, userId), + eq(accountsTable.isClosed, true), + eq(accountsTable.isTombstone, false), + ); +}; + +const lifeCycleMapper: Record< + LifecycleAction, + { name: string; params: Record } +> = { + close: { name: 'close', params: { isClosed: true } }, + open: { name: 'open', params: { isClosed: false } }, +}; + export class AccountRepository extends BaseRepository implements AccountRepositoryInterface { - async getAll(userId: UUID): Promise { + async getAll(userId: UUID, query: AccountQuery): Promise { return this.executeDatabaseOperation(async () => { + const whereClause = getWhereClauseForGetAll(userId, query); + const accounts = await this.db .select() .from(accountsTable) - .where( - and( - eq(accountsTable.userId, userId), - eq(accountsTable.isTombstone, false), - ), - ) + .where(whereClause) .all(); return accounts.map((account) => @@ -37,9 +73,10 @@ export class AccountRepository }, 'Failed to fetch accounts'); } - create(userId: UUID, data: AccountSnapshot): Promise { + create(userId: UUID, data: AccountSnapshot): Promise { return this.executeDatabaseOperation( async () => { + // TODO: consider moving to the base repository or a utility function to avoid duplication if (data.userId !== userId) { throw new RepositoryInvariantError( 'Account snapshot userId must match repository create userId', @@ -64,16 +101,17 @@ export class AccountRepository this.entityNotFoundContext('commodity', data.commodityId), ); - const account = await this.db - .insert(accountsTable) - .values({ - ...AccountPersistenceMapper.toDBRowFromSnapshot(data), - currentClearedBalanceLocal: data.currentClearedBalanceLocal ?? '0', - }) - .returning() - .get(); + const result = await this.db.insert(accountsTable).values({ + ...AccountPersistenceMapper.toDBRowFromSnapshot(data), + currentClearedBalanceLocal: data.currentClearedBalanceLocal ?? '0', + }); - return AccountPersistenceMapper.toSnapshot(account); + this.ensureRowsAffected( + result.rowsAffected, + new AccountPersistenceConflictError( + `Failed to create account with ID ${data.id}`, + ), + ); }, 'Failed to create account', { @@ -91,18 +129,24 @@ export class AccountRepository ); } - getById(userId: UUID, id: UUID): Promise { + private getByIdInternal( + userId: UUID, + id: UUID, + options: { includeTombstone: boolean }, + ): Promise { return this.executeDatabaseOperation(async () => { - const account = await this.db - .select() - .from(accountsTable) - .where( - and( + const whereClause = options.includeTombstone + ? and(eq(accountsTable.id, id), eq(accountsTable.userId, userId)) + : and( eq(accountsTable.id, id), eq(accountsTable.userId, userId), eq(accountsTable.isTombstone, false), - ), - ) + ); + + const account = await this.db + .select() + .from(accountsTable) + .where(whereClause) .get(); const existingAccount = this.ensureEntityExists( @@ -115,12 +159,20 @@ export class AccountRepository }, 'Failed to fetch account by ID'); } + getByIdForLifecycle(userId: UUID, id: UUID): Promise { + return this.getByIdInternal(userId, id, { includeTombstone: true }); + } + + getById(userId: UUID, id: UUID): Promise { + return this.getByIdInternal(userId, id, { includeTombstone: false }); + } + async update( userId: UUID, id: UUID, data: AccountRepositoryUpdateInput, - ): Promise { - return this.executeDatabaseOperation( + ): Promise { + await this.executeDatabaseOperation( async () => { const safeData = this.getSafeUpdate(data, [ 'description', @@ -143,13 +195,11 @@ export class AccountRepository .returning() .get(); - const existingAccount = this.ensureEntityExists( + this.ensureEntityExists( updatedAccount, `Account with ID ${id} not found`, this.entityNotFoundContext('account', id), ); - - return AccountPersistenceMapper.toSnapshot(existingAccount); }, `Failed to update account with ID ${id}`, { @@ -160,12 +210,65 @@ export class AccountRepository ); } - async softDelete( + async updateLifecycle( userId: UUID, id: UUID, - data: AccountRepositorySoftDeleteInput, - ): Promise { - return this.executeDatabaseOperation(async () => { + data: AccountLifecycleUpdateInput, + ): Promise { + const { action, updatedAt } = data; + const { name, params } = lifeCycleMapper[action]; + return this.executeDatabaseOperation(async () => { + const updatedAccount = await this.db + .update(accountsTable) + .set({ ...params, updatedAt }) + .where( + and( + eq(accountsTable.id, id), + eq(accountsTable.userId, userId), + eq(accountsTable.isTombstone, false), + ), + ) + .returning() + .get(); + + this.ensureEntityExists( + updatedAccount, + `Account with ID ${id} not found`, + this.entityNotFoundContext('account', id), + ); + + // No return value for lifecycle updates + }, `Failed to ${name} account with ID ${id}`); + } + + async open( + userId: UUID, + id: UUID, + data: AccountLifecycleUpdateInput, + ): Promise { + await this.updateLifecycle(userId, id, { + action: 'open', + updatedAt: data.updatedAt, + }); + } + + async close( + userId: UUID, + id: UUID, + data: AccountLifecycleUpdateInput, + ): Promise { + await this.updateLifecycle(userId, id, { + action: 'close', + updatedAt: data.updatedAt, + }); + } + + async delete( + userId: UUID, + id: UUID, + data: AccountRepositoryLifecycleInput, + ): Promise { + return this.executeDatabaseOperation(async () => { const deletedAccount = await this.db .update(accountsTable) .set({ isTombstone: true, updatedAt: data.updatedAt }) @@ -179,13 +282,11 @@ export class AccountRepository .returning() .get(); - const existingAccount = this.ensureEntityExists( + this.ensureEntityExists( deletedAccount, `Account with ID ${id} not found`, this.entityNotFoundContext('account', id), ); - - return AccountPersistenceMapper.toSnapshot(existingAccount); }, `Failed to soft delete account with ID ${id}`); } @@ -207,6 +308,7 @@ export class AccountRepository `Account with ID ${accountId} not found`, this.entityNotFoundContext('account', accountId), ); + this.ensureAccess( existingAccount.userId === userId, 'You do not have permission to access this account', 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 fd867075..e8abb30b 100644 --- a/apps/backend/src/infrastructure/db/commodities/commodity.repository.test.ts +++ b/apps/backend/src/infrastructure/db/commodities/commodity.repository.test.ts @@ -1,4 +1,5 @@ -import { apiErrorCodes, type QueryStatus } from '@ledgerly/shared/types'; +import { apiErrorCodes } from '@ledgerly/shared/types'; +import { CommodityQuery } from '@ledgerly/shared/validation'; import { type CommodityRepositoryUpdateInput } from 'src/application'; import type { CommodityDbRow } from 'src/db/schemas/commodities'; import { UserDbRow } from 'src/db/schemas/users'; @@ -84,18 +85,21 @@ describe('CommodityRepository', () => { }); describe('getAll', () => { - const queryStatusCases: { + const commodityStatusFilterCases: { expectedTombstoneValues: boolean[]; - status: QueryStatus; + query: CommodityQuery; }[] = [ - { expectedTombstoneValues: [false, false], status: 'active' }, - { expectedTombstoneValues: [true], status: 'archived' }, - { expectedTombstoneValues: [false, false, true], status: 'all' }, + { expectedTombstoneValues: [false, false], query: { status: 'active' } }, + { expectedTombstoneValues: [true], query: { status: 'archived' } }, + { + expectedTombstoneValues: [false, false, true], + query: { status: 'all' }, + }, ]; - it.each(queryStatusCases)( - 'should return $status commodities for the user', - async ({ expectedTombstoneValues, status }) => { + it.each(commodityStatusFilterCases)( + 'should return $status.status commodities for the user', + async ({ expectedTombstoneValues, query }) => { const activeCommodities = await Promise.all([ testDB.createCommodity(user.id), testDB.createCommodity(user.id), @@ -105,12 +109,12 @@ describe('CommodityRepository', () => { isTombstone: true, }); - const commodities = await commodityRepository.getAll(user.id, status); + const commodities = await commodityRepository.getAll(user.id, query); const expectedCommodities = - status === 'active' + query.status === 'active' ? activeCommodities - : status === 'archived' + : query.status === 'archived' ? [archivedCommodity] : [...activeCommodities, archivedCommodity]; @@ -129,10 +133,9 @@ describe('CommodityRepository', () => { it('should return an empty array when the user has no commodities', async () => { const anotherUser = await testDB.createUser(); - const commodities = await commodityRepository.getAll( - anotherUser.id, - 'active', - ); + const commodities = await commodityRepository.getAll(anotherUser.id, { + status: 'active', + }); expect(commodities).toEqual([]); }); @@ -145,15 +148,21 @@ describe('CommodityRepository', () => { updatedAt: Timestamp.create().valueOf(), }); - const commodities = await commodityRepository.getAll(user.id, 'active'); + const commodities = await commodityRepository.getAll(user.id, { + status: 'active', + }); expect(commodities).toEqual(expect.arrayContaining([commodity2])); expect(commodities).not.toEqual(expect.arrayContaining([commodity1])); }); - it.each(['active', 'archived', 'all'])( - 'should not return commodities belonging to other users for %s status', - async (status) => { + it.each([ + { status: 'active' }, + { status: 'archived' }, + { status: 'all' }, + ])( + 'should not return commodities belonging to other users for $status.status status', + async ({ status }) => { const commodity1 = await testDB.createCommodity(user.id); const anotherUser = await testDB.createUser(); const commodity2 = await testDB.createCommodity(anotherUser.id, { @@ -166,7 +175,9 @@ describe('CommodityRepository', () => { }); } - const commodities = await commodityRepository.getAll(user.id, status); + const commodities = await commodityRepository.getAll(user.id, { + status, + }); expect(commodities).not.toEqual(expect.arrayContaining([commodity2])); }, diff --git a/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts b/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts index 5587fe5d..e4faa8d9 100644 --- a/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts +++ b/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts @@ -1,4 +1,5 @@ -import { QueryStatus, UUID } from '@ledgerly/shared/types'; +import { UUID } from '@ledgerly/shared/types'; +import { CommodityQuery } from '@ledgerly/shared/validation'; import { and, eq } from 'drizzle-orm'; import { type CommodityRepositoryInterface, @@ -13,6 +14,19 @@ import { BaseRepository } from '../BaseRepository'; import { CommodityPersistenceMapper } from './commodity-persistence.mapper'; +const getWhereClauseForGetAll = (userId: UUID, query: CommodityQuery) => { + const { status } = query; + + if (status === 'all') { + return eq(commoditiesTable.userId, userId); + } + + return and( + eq(commoditiesTable.userId, userId), + eq(commoditiesTable.isTombstone, status === 'archived'), + ); +}; + export class CommodityRepository extends BaseRepository implements CommodityRepositoryInterface @@ -37,15 +51,9 @@ export class CommodityRepository }, 'Failed to fetch commodity'); } - getAll(userId: UUID, status: QueryStatus): Promise { + getAll(userId: UUID, query: CommodityQuery): Promise { return this.executeDatabaseOperation(async () => { - const whereClause = - status === 'all' - ? eq(commoditiesTable.userId, userId) - : and( - eq(commoditiesTable.userId, userId), - eq(commoditiesTable.isTombstone, status === 'archived'), - ); + const whereClause = getWhereClauseForGetAll(userId, query); const commodities = await this.db .select() diff --git a/apps/backend/src/infrastructure/db/operations/operation.repository.ts b/apps/backend/src/infrastructure/db/operations/operation.repository.ts index c77a7b56..5e8b6a68 100644 --- a/apps/backend/src/infrastructure/db/operations/operation.repository.ts +++ b/apps/backend/src/infrastructure/db/operations/operation.repository.ts @@ -189,4 +189,33 @@ export class OperationRepository }, ); } + + async existsActiveByAccountId( + userId: UUID, + accountId: UUID, + ): Promise { + return this.executeDatabaseOperation( + async () => { + const count = await this.db + .select({ count: sql`count(*)` }) + .from(operationsTable) + .where( + and( + eq(operationsTable.userId, userId), + eq(operationsTable.accountId, accountId), + eq(operationsTable.isTombstone, false), + ), + ) + .get(); + + return !!count && count.count > 0; + }, + 'OperationRepository.existsActiveByAccountId', + { + field: 'accountId', + tableName: 'operations', + value: accountId, + }, + ); + } } diff --git a/apps/backend/src/infrastructure/infrastructure.errors.ts b/apps/backend/src/infrastructure/infrastructure.errors.ts index 946d1b0d..8c95c755 100644 --- a/apps/backend/src/infrastructure/infrastructure.errors.ts +++ b/apps/backend/src/infrastructure/infrastructure.errors.ts @@ -63,6 +63,19 @@ export class ForbiddenAccessError extends CodedInfrastructureError<'UNAUTHORIZED } } +/** + * Thrown when the persistence layer fails to satisfy its write contract. + * + * A repository expected to persist a change to exactly one row but did not. + * This represents an infrastructure invariant violation rather than a + * business error. + */ +export class AccountPersistenceConflictError extends CodedInfrastructureError<'CONFLICT'> { + constructor(message = 'Account persistence conflict') { + super(message, apiErrorCodes.conflict, {}); + } +} + /** * Thrown when repository input violates expected persistence invariants. */ 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 3bdcd86a..c5ca837d 100644 --- a/apps/backend/src/presentation/http/accounts/account.controller.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.controller.test.ts @@ -1,6 +1,7 @@ import { AccountCreateDTO, AccountUpdateDTO } from '@ledgerly/shared/types'; +import { AccountQuery } from 'node_modules/@ledgerly/shared/src/validation/accounts'; import { - ArchiveAccountUseCase, + DeleteAccountUseCase, CreateAccountUseCase, GetAccountByIdUseCase, GetAllAccountsUseCase, @@ -47,7 +48,7 @@ describe('AccountController', () => { mockGetAllAccountsUseCase as unknown as GetAllAccountsUseCase, mockCreateAccountUseCase as unknown as CreateAccountUseCase, mockUpdateAccountUseCase as unknown as UpdateAccountUseCase, - mockArchiveAccountUseCase as unknown as ArchiveAccountUseCase, + mockArchiveAccountUseCase as unknown as DeleteAccountUseCase, ); beforeEach(async () => { @@ -57,21 +58,26 @@ describe('AccountController', () => { }); describe('getAll', () => { - it('should call accountService.getAll with correct userId', async () => { + it('should call accountController.getAll with correct userId', async () => { const mockAccounts = [{ id: '1', name: 'Test Account' }]; mockGetAllAccountsUseCase.execute.mockResolvedValue(mockAccounts); - const result = await accountController.getAll(user); + const query: AccountQuery = { status: 'all' }; - expect(mockGetAllAccountsUseCase.execute).toHaveBeenCalledWith(user); + const result = await accountController.getAll(user, query); + + expect(mockGetAllAccountsUseCase.execute).toHaveBeenCalledWith( + user, + query, + ); expect(mockGetAllAccountsUseCase.execute).toHaveBeenCalledTimes(1); expect(result).toEqual(mockAccounts); }); }); describe('getById', () => { - it('should call accountService.getById with correct userId and id', async () => { + it('should call accountController.getById with correct userId and id', async () => { const accountId = Id.create().valueOf(); const requestParams = { id: accountId }; const mockAccount = { id: accountId, name: 'Test Account' }; @@ -98,7 +104,7 @@ describe('AccountController', () => { }); describe('create', () => { - it('should call accountService.create with correct data', async () => { + it('should call accountController.create with correct data', async () => { const requestBody: AccountCreateDTO = { commodityId: Id.create().valueOf(), description: 'Test Account', @@ -142,7 +148,7 @@ describe('AccountController', () => { type: 'liability', }; - it('should call accountService.update with correct data', async () => { + it('should call accountController.update with correct data', async () => { await accountController.update(user, { id: accountId }, requestBody); const { ...mockAccountResponse } = { @@ -179,11 +185,11 @@ describe('AccountController', () => { }); }); - describe('archiveAccount', () => { - it('should call archive account use case with correct account id and user', async () => { + describe('deleteAccount', () => { + it('should call accountController.deleteAccount with correct account id and user', async () => { mockArchiveAccountUseCase.execute.mockResolvedValue(undefined); - await accountController.archiveAccount(user, { id: accountId }); + await accountController.deleteAccount(user, { id: accountId }); expect(mockArchiveAccountUseCase.execute).toHaveBeenCalledWith( user, @@ -194,7 +200,7 @@ describe('AccountController', () => { it('should throw ZodError for invalid request params', async () => { await expect( - accountController.archiveAccount(user, { id: 'not-a-uuid' }), + accountController.deleteAccount(user, { id: 'not-a-uuid' }), ).rejects.toThrow(ZodError); expect(mockArchiveAccountUseCase.execute).not.toHaveBeenCalled(); diff --git a/apps/backend/src/presentation/http/accounts/account.controller.ts b/apps/backend/src/presentation/http/accounts/account.controller.ts index 4387d60e..630bd5ac 100644 --- a/apps/backend/src/presentation/http/accounts/account.controller.ts +++ b/apps/backend/src/presentation/http/accounts/account.controller.ts @@ -1,10 +1,11 @@ import { accountCreateSchema, + accountQuerySchema, accountUpdateSchema, uniqueIdSchema, } from '@ledgerly/shared/validation'; import { - ArchiveAccountUseCase, + DeleteAccountUseCase, CreateAccountUseCase, GetAccountByIdUseCase, GetAllAccountsUseCase, @@ -18,11 +19,12 @@ export class AccountController { private readonly getAllAccountsUseCase: GetAllAccountsUseCase, private readonly createAccountUseCase: CreateAccountUseCase, private readonly updateAccountUseCase: UpdateAccountUseCase, - private readonly archiveAccountUseCase: ArchiveAccountUseCase, + private readonly deleteAccountUseCase: DeleteAccountUseCase, ) {} - async getAll(user: User) { - return this.getAllAccountsUseCase.execute(user); + async getAll(user: User, query: unknown) { + const parsedQuery = accountQuerySchema.parse(query); + return this.getAllAccountsUseCase.execute(user, parsedQuery); } async getById(user: User, requestParams: unknown) { @@ -44,9 +46,9 @@ export class AccountController { return this.updateAccountUseCase.execute(user, id, accountUpdateDto); } - async archiveAccount(user: User, requestParams: unknown) { + async deleteAccount(user: User, requestParams: unknown) { const { id } = uniqueIdSchema.parse(requestParams); - return this.archiveAccountUseCase.execute(user, id); + return this.deleteAccountUseCase.execute(user, id); } } 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 78ba19be..e907e088 100644 --- a/apps/backend/src/presentation/http/accounts/account.integration.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.integration.test.ts @@ -3,62 +3,133 @@ import { AccountCreateDTO, AccountResponseDTO, AccountTypeValue, + ApiErrorResponse, + apiErrorCodes, UUID, } from '@ledgerly/shared/types'; +import { CommodityDbRow } from 'src/db/schemas/commodities'; import { TestDB } from 'src/db/test-db'; +import { compareEntityArrays } from 'src/db/test-utils/entityComparer'; +import { AccountType } from 'src/domain'; import { Amount, CommodityCode } from 'src/domain/domain-core'; import { Id } from 'src/domain/domain-core/value-objects/Id'; import { createServer } from 'src/presentation/http'; +import { createHttpTestClient } from 'src/presentation/http/test-utils'; import { describe, beforeEach, it, expect } from 'vitest'; const url = `/api${ROUTES.accounts}`; -const firstUserAccounts = [ +const closedAccountsData = [ { commodityId: Id.create().valueOf(), - description: 'This is a test account', + description: 'Closed account 1 for testing purposes', initialBalance: Amount.create('1000').valueOf(), + isClosed: true, name: 'Test Account', - type: 'asset' as AccountTypeValue, + type: AccountType.create('asset').valueOf(), }, { commodityId: Id.create().valueOf(), - description: 'Savings account for future expenses', + description: 'Closed account 2 for testing purposes', initialBalance: Amount.create('1000').valueOf(), + isClosed: true, name: 'Savings Account', - type: 'asset' as AccountTypeValue, + type: AccountType.create('asset').valueOf(), }, ]; +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(), + }, + { + 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(), + }, +]; + +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(), + }, + { + 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(), + }, +]; + +const allAccountsData = [ + ...closedAccountsData, + ...openAccountsData, + ...deletedAccountsData, +]; + const getUserTestAccounts = (userId: UUID): AccountCreateDTO[] => { - return firstUserAccounts.map((account) => ({ + return allAccountsData.map((account) => ({ ...account, userId, })); }; +const testUserData = { + email: 'test@example.com', + name: 'Test User', + password: 'Password123!', +}; describe('Accounts Integration Tests', () => { let testDB: TestDB; - let server: ReturnType; let authToken: string; let userId: UUID; - let accounts: AccountResponseDTO[]; + let otherUserId: UUID; + let accounts: AccountResponseDTO[] = []; + let commodity: CommodityDbRow; + let injectAuthorized: ReturnType< + typeof createHttpTestClient + >['injectAuthorized']; - const testUser = { - email: 'test@example.com', - name: 'Test User', - password: 'Password123!', - }; + let injectWithToken: ReturnType< + typeof createHttpTestClient + >['injectWithToken']; beforeEach(async () => { testDB = new TestDB(); server = createServer(testDB.db); + + ({ injectAuthorized, injectWithToken } = createHttpTestClient( + server, + () => authToken, + )); + await testDB.setupTestDb(); await server.ready(); - const user = await testDB.createUser(testUser); + const user = await testDB.createUser(testUserData); + + const otherUser = await testDB.createUser({ + email: 'otheruser@example.com', + }); + + otherUserId = Id.restore(otherUser.id).valueOf(); const token = server.jwt.sign({ email: user.email, @@ -69,6 +140,7 @@ describe('Accounts Integration Tests', () => { const decoded = server.jwt.decode(token) as unknown as { userId: UUID }; userId = Id.restore(decoded.userId).valueOf(); + commodity = await testDB.createCommodity(userId); const testAccountsDTO = getUserTestAccounts( Id.restore(decoded.userId).valueOf(), @@ -83,29 +155,58 @@ describe('Accounts Integration Tests', () => { }); describe('GET /api/accounts', () => { - it('should return all accounts for the user', async () => { - const response = await server.inject({ - headers: { - Authorization: `Bearer ${authToken}`, - }, - method: 'GET', - url, - }); + const testCases = [ + { + description: + 'should return all active open accounts by default for the user', + expectedAccounts: (accounts: AccountResponseDTO[]) => + accounts.filter( + (account) => !account.isClosed && !account.isTombstone, + ), + query: {}, + }, + { + description: + 'should return all accounts including closed when query param status=all', + expectedAccounts: (accounts: AccountResponseDTO[]) => + accounts.filter((account) => !account.isTombstone), + query: { status: 'all' }, + }, + { + description: + 'should return all closed accounts when query param status=closed', + expectedAccounts: (accounts: AccountResponseDTO[]) => + accounts.filter( + (account) => account.isClosed && !account.isTombstone, + ), + query: { status: 'closed' }, + }, + ]; - firstUserAccounts.forEach((account) => { - expect(response.body).toContain(account.name); - }); + testCases.forEach(({ description, expectedAccounts, query }) => { + it(description, async () => { + const queryString = new URLSearchParams( + query as Record, + ).toString(); - expect(response.statusCode).toBe(200); + const response = await injectAuthorized({ + method: 'GET', + url: `${url}?${queryString}`, + }); + + const responseAccounts = JSON.parse( + response.body, + ) as AccountResponseDTO[]; + + expect(response.statusCode).toBe(200); + compareEntityArrays(responseAccounts, expectedAccounts(accounts)); + }); }); }); describe('GET /api/accounts/:id', () => { - it('should return account by ID', async () => { - const response = await server.inject({ - headers: { - Authorization: `Bearer ${authToken}`, - }, + it('should return 200 and the account by id', async () => { + const response = await injectAuthorized({ method: 'GET', url: `${url}/${accounts[0].id}`, }); @@ -118,7 +219,11 @@ describe('Accounts Integration Tests', () => { }); describe('POST /api/accounts', () => { - it('should create a new account', async () => { + it('should return 201 and create a new account', async () => { + const accountCountBeforeCreation = accounts.filter( + (account) => !account.isTombstone, + ).length; + const commodity = await testDB.createCommodity(userId, { code: CommodityCode.create('USD').valueOf(), name: 'US Dollar', @@ -132,10 +237,7 @@ describe('Accounts Integration Tests', () => { type: 'asset' as AccountTypeValue, }; - const response = await server.inject({ - headers: { - Authorization: `Bearer ${authToken}`, - }, + const response = await injectAuthorized({ method: 'POST', payload, url, @@ -148,66 +250,58 @@ describe('Accounts Integration Tests', () => { expect(createdAccount.type).toBe(payload.type); expect(createdAccount.description).toBe(payload.description); - const finalResponse = await server.inject({ - headers: { - Authorization: `Bearer ${authToken}`, - }, + const finalResponse = await injectAuthorized({ method: 'GET', - url, + url: `${url}?status=all`, }); const accountsAfterCreation = JSON.parse( finalResponse.body, ) as AccountResponseDTO[]; - expect(accountsAfterCreation.length).toBe(firstUserAccounts.length + 1); + expect(accountsAfterCreation.length).toBe(accountCountBeforeCreation + 1); expect(accountsAfterCreation).toContainEqual(createdAccount); }); }); describe('DELETE /api/accounts/:id', () => { - it('should delete an account by ID', async () => { + it('should return 204 and delete an account by id', async () => { + const accountCountBeforeDeletion = accounts.filter( + (account) => !account.isTombstone, + ).length; + const accountToDelete = accounts[0]; - const response = await server.inject({ - headers: { - Authorization: `Bearer ${authToken}`, - }, + const response = await injectAuthorized({ method: 'DELETE', url: `${url}/${accountToDelete.id}`, }); expect(response.statusCode).toBe(204); - const finalResponse = await server.inject({ - headers: { - Authorization: `Bearer ${authToken}`, - }, + const finalResponse = await injectAuthorized({ method: 'GET', - url, + url: `${url}/?status=all`, }); const accountsAfterDeletion = JSON.parse( finalResponse.body, ) as AccountResponseDTO[]; - expect(accountsAfterDeletion.length).toBe(firstUserAccounts.length - 1); + expect(accountsAfterDeletion.length).toBe(accountCountBeforeDeletion - 1); expect(accountsAfterDeletion).not.toContainEqual(accountToDelete); }); }); describe('PATCH /api/accounts/:id', () => { - it('should update an account by ID', async () => { + it('should return 200 and update an account by id', async () => { const accountToUpdate = accounts[0]; const updatedData = { name: 'Updated Account Name', }; - const response = await server.inject({ - headers: { - Authorization: `Bearer ${authToken}`, - }, + const response = await injectAuthorized({ method: 'PATCH', payload: updatedData, url: `${url}/${accountToUpdate.id}`, @@ -218,12 +312,9 @@ describe('Accounts Integration Tests', () => { expect(response.statusCode).toBe(200); expect(updatedAccount.name).toBe(updatedData.name); - const finalResponse = await server.inject({ - headers: { - Authorization: `Bearer ${authToken}`, - }, + const finalResponse = await injectAuthorized({ method: 'GET', - url, + url: `${url}?status=all`, }); const accountsAfterUpdate = JSON.parse( @@ -234,113 +325,786 @@ describe('Accounts Integration Tests', () => { }); }); - // Authentication & Authorization Tests - describe.todo('Authentication & Authorization', () => { - // - should return 401 when no auth token provided - // - should return 401 when invalid auth token provided - // - should return 401 when expired auth token provided - // - should return 403 when trying to access another user's account - // - should return 403 when trying to update another user's account - // - should return 403 when trying to delete another user's account - }); + describe('Authentication', () => { + it('should return 401 when no auth token is provided', async () => { + const response = await server.inject({ + method: 'GET', + url, + }); - // Validation Tests for POST /api/accounts - describe.todo('POST /api/accounts - Validation', () => { - // - should return 400 when name is empty - // - should return 400 when name is missing - // - should return 400 when name is not a string - // - should return 400 when commodityId is empty - // - should return 400 when commodityId is missing - // - should return 400 when commodityId is invalid UUID format - // - should return 404 when commodityId doesn't exist in database - // - should return 404 when commodityId belongs to another user - // - should return 404 when commodityId points to an archived Commodity - // - should return 400 when type is empty - // - should return 400 when type is invalid enum value - // - should return 400 when type is missing - // - should return 400 when userId is missing - // - should return 400 when userId is invalid UUID format - // - should return 409 when account name already exists for user - // - should return 400 when description is not a string - // - should return 400 when initialBalance is not a number - // - should return 400 when extra unexpected fields are provided - }); + expect(response.statusCode).toBe(401); + }); - // Validation Tests for PATCH /api/accounts/:id - describe.todo('PATCH /api/accounts/:id - Validation', () => { - // - should return 400 when name is empty string - // - should return 400 when name is not a string - // - should return 400 when type is invalid enum value - // - should return 400 when description is not a string - // - should return 404 when account ID doesn't exist - // - should return 400 when account ID is invalid UUID format - // - should return 409 when updating to duplicate name within same user - // - should return 400 when empty object is provided - // - should return 400 when extra unexpected fields are provided - // - should allow updating to name that exists for different user - }); + it('should return 401 when an invalid auth token is provided', async () => { + const response = await injectWithToken('invalidToken', { + method: 'GET', + url, + }); - // Edge Cases for GET /api/accounts/:id - describe.todo('GET /api/accounts/:id - Edge Cases', () => { - // - should return 404 when account ID doesn't exist - // - should return 400 when account ID is invalid UUID format - // - should return 404 when account belongs to different user - }); + expect(response.statusCode).toBe(401); + }); - // Edge Cases for DELETE /api/accounts/:id - describe.todo('DELETE /api/accounts/:id - Edge Cases', () => { - // - should return 404 when account ID doesn't exist - // - should return 400 when account ID is invalid UUID format - // - should return 404 when trying to delete another user's account - // - should handle deletion of account with transactions (cascade) - }); + it('should return 401 when an expired auth token is provided', async () => { + const expiredToken: string = server.jwt.sign({ + email: testUserData.email, + exp: Math.floor(Date.now() / 1000) - 3600, + userId, + }); - // Foreign Key Constraint Tests - describe.todo('Foreign Key Constraints', () => { - // - should handle user deletion cascading to accounts - // - should prevent creation with non-existent commodityId - // - should prevent creation with another user's commodityId - // - should prevent creation with archived commodityId - }); + const response = await injectWithToken(expiredToken, { + method: 'GET', + url, + }); - // Business Logic Tests - describe.todo('Business Logic', () => { - // - should allow multiple accounts with same name for different users - // - should preserve other fields when partially updating account - // - should handle account creation with all optional fields - // - should handle account update with only one field changed - // - should maintain data integrity during concurrent operations + expect(response.statusCode).toBe(401); + }); }); - // Database Consistency Tests - describe.todo('Database Consistency', () => { - // - should maintain unique constraint on (userId, name) - // - should properly set created_at and updated_at timestamps - // - should handle database connection errors gracefully - // - should rollback on transaction failures - }); + describe('GET /api/accounts - Validation', () => { + it('should return 400 when status is an invalid enum value', async () => { + const response = await injectAuthorized({ + method: 'GET', + url: `${url}?status=archived`, + }); - // Content-Type and Request Format Tests - describe.todo('Request Format', () => { - // - should return 400 when Content-Type is not application/json - // - should return 400 when request body is not valid JSON - // - should return 400 when request body is null - // - should return 400 when request body is array instead of object + expect(response.statusCode).toBe(400); + }); }); - // Response Format Tests - describe.todo('Response Format', () => { - // - should return proper error format for validation errors - // - should include all required fields in successful responses - // - should not include sensitive data in responses - // - should return consistent error structure across endpoints - }); + describe('POST /api/accounts - Validation', () => { + it('should return 400 when name is empty', async () => { + const payload: AccountCreateDTO = { + commodityId: commodity.id, + description: 'This is a new account', + initialBalance: Amount.create('1000').valueOf(), + name: '', + type: 'asset' as AccountTypeValue, + }; + + const response = await injectAuthorized({ + method: 'POST', + payload, + url, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 400 when name is missing', async () => { + 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 + + const response = await injectAuthorized({ + method: 'POST', + payload, + url, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 400 when name is not a string', async () => { + 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, + }; + + const response = await injectAuthorized({ + method: 'POST', + payload, + url, + }); + + expect(response.statusCode).toBe(400); + }); - // Performance and Limits Tests - describe.todo('Performance & Limits', () => { - // - should handle very long account names (within limits) - // - should handle maximum number of accounts per user - // - should handle special characters in account names - // - should handle unicode characters in account names + it('should return 400 when commodityId is empty', async () => { + 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, + }; + + const response = await injectAuthorized({ + method: 'POST', + payload, + url, + }); + + expect(response.statusCode).toBe(400); + }); + + 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 + + const response = await injectAuthorized({ + method: 'POST', + payload, + url, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 400 when commodityId has an invalid UUID format', async () => { + 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, + }; + + const response = await injectAuthorized({ + method: 'POST', + payload, + url, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 404 when commodityId does not exist', async () => { + 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, + }; + + const response = await injectAuthorized({ + method: 'POST', + payload, + url, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 404 when commodityId belongs to another user', async () => { + const otherUserCommodity = await testDB.createCommodity(otherUserId); + + 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(), + }; + + const response = await injectAuthorized({ + method: 'POST', + payload, + url, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 404 when commodityId points to a deleted commodity', async () => { + await testDB.deleteCommodityById(commodity.id); + + const payload: AccountCreateDTO = { + commodityId: commodity.id, + description: 'This is a new account', + initialBalance: Amount.create('1000').valueOf(), + name: 'New Account', + type: 'asset' as AccountTypeValue, + }; + + const response = await injectAuthorized({ + method: 'POST', + payload, + url, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 400 when type is empty', async () => { + 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, + }; + + const response = await injectAuthorized({ + method: 'POST', + payload, + url, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 400 when type is an invalid enum value', async () => { + 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, + }; + + const response = await injectAuthorized({ + method: 'POST', + payload, + url, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 400 when type is missing', async () => { + const payload: AccountCreateDTO = { + commodityId: commodity.id, + description: 'This is a new account', + initialBalance: Amount.create('1000').valueOf(), + name: 'New Account', + } as unknown as AccountCreateDTO; + + const response = await injectAuthorized({ + method: 'POST', + payload, + url, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 409 when account name already exists for user', async () => { + 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 + + const response = await injectAuthorized({ + method: 'POST', + payload, + url, + }); + + expect(response.statusCode).toBe(409); + }); + + it('should return 400 when description is not a string', async () => { + const payload: AccountCreateDTO = { + commodityId: commodity.id, + description: 123 as unknown as string, + initialBalance: Amount.create('1000').valueOf(), + name: 'New Account', + type: 'asset' as AccountTypeValue, + }; + + const response = await injectAuthorized({ + method: 'POST', + payload, + url, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 400 when extra unexpected fields are provided', async () => { + const payload: AccountCreateDTO & { extraField?: string } = { + commodityId: commodity.id, + description: 'This is a new account', + extraField: 'unexpected', + initialBalance: Amount.create('1000').valueOf(), + name: 'New Account', + type: 'asset' as AccountTypeValue, + }; + + const response = await injectAuthorized({ + method: 'POST', + payload, + url, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe('GET /api/accounts/:id - Edge Cases', () => { + it('should return 404 when account id does not exist', async () => { + const nonExistentId = Id.create().valueOf(); + + const response = await injectAuthorized({ + method: 'GET', + url: `/api/accounts/${nonExistentId}`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 400 when account id has an invalid UUID format', async () => { + const invalidId = 'invalid-uuid'; + + const response = await injectAuthorized({ + method: 'GET', + url: `/api/accounts/${invalidId}`, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 404 when account belongs to a different user', async () => { + const otherUserCommodity = await testDB.createCommodity(otherUserId); + + const otherUserAccount = await testDB.createAccount( + otherUserId, + otherUserCommodity.id, + { + description: 'Other user account', + initialBalance: Amount.create('1000').valueOf(), + name: 'Other User Account', + type: 'asset' as AccountTypeValue, + }, + ); + const response = await injectAuthorized({ + method: 'GET', + url: `/api/accounts/${otherUserAccount.id}`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 404 when account is deleted', async () => { + const deletedAccount = accounts.find((account) => account.isTombstone); + const response = await injectAuthorized({ + method: 'GET', + url: `/api/accounts/${deletedAccount?.id}`, + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe('PATCH /api/accounts/:id - Validation', () => { + it('should return 400 when name is empty', async () => { + const accountToUpdate = accounts[0]; + + const response = await injectAuthorized({ + method: 'PATCH', + payload: { + name: '', + }, + url: `/api/accounts/${accountToUpdate.id}`, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 400 when name is not a string', async () => { + const accountToUpdate = accounts[0]; + + const response = await injectAuthorized({ + method: 'PATCH', + payload: { + name: 123 as unknown as string, + }, + url: `/api/accounts/${accountToUpdate.id}`, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 400 when type is an invalid enum value', async () => { + const accountToUpdate = accounts[0]; + + const response = await injectAuthorized({ + method: 'PATCH', + payload: { + type: 'invalid-type' as AccountTypeValue, + }, + url: `/api/accounts/${accountToUpdate.id}`, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 400 when description is not a string', async () => { + const accountToUpdate = accounts[0]; + + const response = await injectAuthorized({ + method: 'PATCH', + payload: { + description: 123 as unknown as string, + }, + url: `/api/accounts/${accountToUpdate.id}`, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 404 when account id does not exist', async () => { + const nonExistentId = '00000000-0000-0000-0000-000000000000'; + + const response = await injectAuthorized({ + method: 'PATCH', + payload: { + name: 'Updated Name', + }, + url: `/api/accounts/${nonExistentId}`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 400 when account id has an invalid UUID format', async () => { + const invalidId = 'invalid-uuid'; + + const response = await injectAuthorized({ + method: 'PATCH', + payload: { + name: 'Updated Name', + }, + url: `/api/accounts/${invalidId}`, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 409 when updating to duplicate name within same user', async () => { + const accountToUpdate = accounts[0]; + const duplicateName = accounts[1].name; + + const response = await injectAuthorized({ + method: 'PATCH', + payload: { + name: duplicateName, + }, + url: `/api/accounts/${accountToUpdate.id}`, + }); + + expect(response.statusCode).toBe(409); + }); + + it('should return 400 when empty object is provided', async () => { + const accountToUpdate = accounts[0]; + + const response = await injectAuthorized({ + method: 'PATCH', + payload: {}, + url: `/api/accounts/${accountToUpdate.id}`, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 400 when extra unexpected fields are provided', async () => { + const accountToUpdate = accounts[0]; + + const response = await injectAuthorized({ + method: 'PATCH', + payload: { + name: 'Updated Name', + unexpectedField: 'unexpected', + }, + url: `/api/accounts/${accountToUpdate.id}`, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 404 when account belongs to a different user', async () => { + const otherUserCommodity = await testDB.createCommodity(otherUserId); + + const otherUserAccount = await testDB.createAccount( + otherUserId, + otherUserCommodity.id, + { + description: 'Other user account', + initialBalance: Amount.create('1000').valueOf(), + name: 'Other User Account', + type: 'asset' as AccountTypeValue, + }, + ); + + const response = await injectAuthorized({ + method: 'PATCH', + payload: { + name: 'Updated Name', + }, + url: `/api/accounts/${otherUserAccount.id}`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 404 when account is deleted', async () => { + const deletedAccount = accounts.find((account) => account.isTombstone); + const response = await injectAuthorized({ + method: 'PATCH', + payload: { + name: 'Updated Name', + }, + url: `/api/accounts/${deletedAccount?.id}`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 409 when changing type for an account with active operations', async () => { + const accountToUpdate = accounts[0]; + + await testDB.createTransactionWithOperations(userId, commodity.id, { + operations: [ + { + accountId: accountToUpdate.id, + amount: Amount.create('100').valueOf(), + description: 'Test Operation', + id: Id.create().valueOf(), + value: Amount.create('100').valueOf(), + }, + ], + }); + + const response = await injectAuthorized({ + method: 'PATCH', + payload: { + type: AccountType.create('liability').valueOf(), + }, + url: `/api/accounts/${accountToUpdate.id}`, + }); + + expect(response.statusCode).toBe(409); + }); + }); + + describe('DELETE /api/accounts/:id - Edge Cases', () => { + it('should return 404 when account id does not exist', async () => { + const nonExistentId = Id.create().valueOf(); + + const response = await injectAuthorized({ + method: 'DELETE', + url: `/api/accounts/${nonExistentId}`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 400 when account id has an invalid UUID format', async () => { + const invalidId = 'invalid-uuid'; + + const response = await injectAuthorized({ + method: 'DELETE', + url: `/api/accounts/${invalidId}`, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 404 when account belongs to a different user', async () => { + const otherUserAccount = await testDB.createAccount( + otherUserId, + commodity.id, + ); + + const response = await injectAuthorized({ + method: 'DELETE', + url: `/api/accounts/${otherUserAccount?.id}`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 404 when account is already deleted', async () => { + const deletedAccount = accounts.find((account) => account.isTombstone); + const response = await injectAuthorized({ + method: 'DELETE', + url: `/api/accounts/${deletedAccount?.id}`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 409 when account has active operations', async () => { + const accountToDelete = accounts[0]; + + await testDB.createTransactionWithOperations(userId, commodity.id, { + operations: [ + { + accountId: accountToDelete.id, + amount: Amount.create('100').valueOf(), + description: 'Test Operation', + id: Id.create().valueOf(), + value: Amount.create('100').valueOf(), + }, + ], + }); + + const response = await injectAuthorized({ + method: 'DELETE', + url: `/api/accounts/${accountToDelete.id}`, + }); + + expect(response.statusCode).toBe(409); + }); + }); + + describe('Database Consistency', () => { + it('should properly set createdAt and updatedAt timestamps', async () => { + 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(), + }; + + const response = await injectAuthorized({ + method: 'POST', + payload, + url, + }); + + expect(response.statusCode).toBe(201); + + const createdAccount = JSON.parse(response.body) as AccountResponseDTO; + + expect(createdAccount.name).toBe(payload.name); + expect(createdAccount.type).toBe(payload.type); + expect(createdAccount.description).toBe(payload.description); + expect(createdAccount.initialBalance).toBe(payload.initialBalance); + }); + }); + + describe('Request Format', () => { + it('should return 400 when request body is not valid JSON', async () => { + const response = await injectAuthorized({ + headers: { + 'content-type': 'application/json', + }, + method: 'POST', + payload: 'invalid-json', + url, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 400 when request body is null', async () => { + const response = await injectAuthorized({ + headers: { + 'content-type': 'application/json', + }, + method: 'POST', + // @ts-expect-error: Testing invalid payload + payload: null, + url, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 400 when request body is an array instead of object', async () => { + const response = await injectAuthorized({ + method: 'POST', + payload: [], + url, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe('Response Format', () => { + it('should return the standard error format for validation errors', async () => { + const response = await injectAuthorized({ + method: 'GET', + url: `${url}?status=archived`, + }); + + const errorResponse = JSON.parse(response.body) as ApiErrorResponse; + + expect(response.statusCode).toBe(400); + expect(errorResponse).toEqual({ + code: apiErrorCodes.validationFailed, + context: { + fields: [ + { + code: 'INVALID_VALUE', + path: 'status', + }, + ], + }, + error: true, + }); + }); + + it('should include all required fields in successful responses', async () => { + const response = await injectAuthorized({ + method: 'GET', + url: `${url}/${accounts[0].id}`, + }); + + const account = JSON.parse(response.body) as AccountResponseDTO; + + expect(response.statusCode).toBe(200); + + const requiredFields = [ + 'commodityId', + 'createdAt', + 'currentClearedBalanceLocal', + 'description', + 'id', + 'initialBalance', + 'isClosed', + 'isSystem', + 'isTombstone', + 'name', + 'type', + 'updatedAt', + 'userId', + ] satisfies (keyof AccountResponseDTO)[]; + + requiredFields.forEach((field) => { + expect(account).toHaveProperty(field); + }); + expect(typeof account.isClosed).toBe('boolean'); + expect(typeof account.isSystem).toBe('boolean'); + expect(typeof account.isTombstone).toBe('boolean'); + }); + }); + + describe('Limits', () => { + it('should return 400 when account name exceeds the maximum length', async () => { + 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, + }; + + const response = await injectAuthorized({ + method: 'POST', + payload, + url, + }); + + expect(response.statusCode).toBe(400); + }); }); }); diff --git a/apps/backend/src/presentation/http/accounts/account.routes.ts b/apps/backend/src/presentation/http/accounts/account.routes.ts index f7570eed..bfa93b65 100644 --- a/apps/backend/src/presentation/http/accounts/account.routes.ts +++ b/apps/backend/src/presentation/http/accounts/account.routes.ts @@ -6,7 +6,7 @@ export const accountsRoutes = (app: FastifyInstance) => { app.get('/', async (request) => { const user = request.user; - return await accountController.getAll(user); + return await accountController.getAll(user, request.query); }); app.get('/:id', async (request) => { @@ -25,7 +25,7 @@ export const accountsRoutes = (app: FastifyInstance) => { app.delete('/:id', async (request, reply) => { const user = request.user; - await accountController.archiveAccount(user, request.params); + await accountController.deleteAccount(user, request.params); reply.status(204).send(); }); diff --git a/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts b/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts index e3e00038..ad28ce95 100644 --- a/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts +++ b/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts @@ -1,4 +1,4 @@ -import { type QueryStatus } from '@ledgerly/shared/types'; +import { CommodityQuery } from '@ledgerly/shared/validation'; import { ArchiveCommodityUseCase, CreateCommodityUseCase, @@ -58,23 +58,23 @@ describe('CommodityController', () => { describe('getAll', () => { const validQueryParams: { - expectedStatus: QueryStatus; + query: CommodityQuery; queryParams: unknown; }[] = [ - { expectedStatus: 'active', queryParams: { status: 'active' } }, - { expectedStatus: 'archived', queryParams: { status: 'archived' } }, - { expectedStatus: 'all', queryParams: { status: 'all' } }, - { expectedStatus: 'active', queryParams: {} }, + { query: { status: 'active' }, queryParams: { status: 'active' } }, + { query: { status: 'archived' }, queryParams: { status: 'archived' } }, + { query: { status: 'all' }, queryParams: { status: 'all' } }, + { query: { status: 'active' }, queryParams: {} }, ]; it.each(validQueryParams)( - 'should call getAllCommoditiesUseCase.execute with $expectedStatus status', - async ({ expectedStatus, queryParams }) => { + 'should call getAllCommoditiesUseCase.execute with $query.status status', + async ({ query, queryParams }) => { await commodityController.getAll(user, queryParams); expect(mockGetAllCommoditiesUseCase.execute).toHaveBeenCalledWith( user, - expectedStatus, + query, ); expect(mockGetAllCommoditiesUseCase.execute).toHaveBeenCalledTimes(1); }, diff --git a/apps/backend/src/presentation/http/commodities/commodity.controller.ts b/apps/backend/src/presentation/http/commodities/commodity.controller.ts index cd1f60b6..633197a1 100644 --- a/apps/backend/src/presentation/http/commodities/commodity.controller.ts +++ b/apps/backend/src/presentation/http/commodities/commodity.controller.ts @@ -3,7 +3,7 @@ import { commodityCreateSchema, commodityUpdateSchema, uniqueIdSchema, - queryStatusSchema, + commodityQuerySchema, } from '@ledgerly/shared/validation'; import { GetCommodityByIdUseCase, @@ -27,9 +27,9 @@ export class CommodityController { user: User, queryParams: unknown, ): Promise { - const { status } = queryStatusSchema.parse(queryParams); + const parsedQuery = commodityQuerySchema.parse(queryParams); - return this.getAllCommoditiesUseCase.execute(user, status); + return this.getAllCommoditiesUseCase.execute(user, parsedQuery); } async getById( diff --git a/apps/backend/src/presentation/http/error-handler.test.ts b/apps/backend/src/presentation/http/error-handler.test.ts index c26549d2..897b5ebd 100644 --- a/apps/backend/src/presentation/http/error-handler.test.ts +++ b/apps/backend/src/presentation/http/error-handler.test.ts @@ -4,11 +4,14 @@ import { } from '@ledgerly/shared/types'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { + AccountHasActiveOperationsError, InvalidPasswordError, UserAlreadyExistsError, UserNotFoundError, } from 'src/application/application.errors'; +import { Id } from 'src/domain/domain-core'; import { + AccountPersistenceConflictError, DatabaseError, DatabaseOperationError, ForbiddenAccessError, @@ -87,6 +90,8 @@ describe('getValidationFieldErrorCode', () => { }); describe('errorHandler', () => { + const accountId = Id.create().valueOf(); + const handle = (error: Error) => { const { reply, response } = createReply(); @@ -114,26 +119,36 @@ describe('errorHandler', () => { new UserNotFoundError(), 401, apiErrorCodes.authenticationFailed, + {}, ], [ 'an invalid password', new InvalidPasswordError(), 401, apiErrorCodes.authenticationFailed, + {}, ], [ 'a duplicate registration', new UserAlreadyExistsError(), 409, apiErrorCodes.registrationConflict, + {}, + ], + [ + 'an account with active operations', + new AccountHasActiveOperationsError(accountId), + 409, + apiErrorCodes.accountHasActiveOperations, + { accountId }, ], ])( 'serializes %s through the generic coded-error path', - (_caseName, error, expectedStatus, expectedCode) => { + (_caseName, error, expectedStatus, expectedCode, expectedContext) => { expect(handle(error)).toEqual({ payload: { code: expectedCode, - context: {}, + context: expectedContext, error: true, }, statusCode: expectedStatus, @@ -222,6 +237,15 @@ describe('errorHandler', () => { }, statusCode: 403, }); + + expect(handle(new AccountPersistenceConflictError())).toEqual({ + payload: { + code: apiErrorCodes.conflict, + context: {}, + error: true, + }, + statusCode: 409, + }); }); it('maps repository invariants to the safe internal-server response', () => { diff --git a/apps/backend/src/presentation/http/error-handler.ts b/apps/backend/src/presentation/http/error-handler.ts index a4624c4e..3f60101e 100644 --- a/apps/backend/src/presentation/http/error-handler.ts +++ b/apps/backend/src/presentation/http/error-handler.ts @@ -16,6 +16,7 @@ import { ZodError, type ZodIssue } from 'zod'; import { HttpApiError } from './errors'; const statusByErrorCode = { + [apiErrorCodes.accountHasActiveOperations]: 409, [apiErrorCodes.accountNotFoundInContext]: 400, [apiErrorCodes.authenticationFailed]: 401, [apiErrorCodes.badRequest]: 400, @@ -83,11 +84,50 @@ const sendCodedError = ( error: true, }); +type InvalidJsonError = SyntaxError & { + statusCode: 400; +}; + +const isInvalidJsonError = (error: unknown): error is InvalidJsonError => { + return ( + error instanceof SyntaxError && + 'statusCode' in error && + error.statusCode === 400 + ); +}; + +const isFastifyError = (error: unknown): error is FastifyError => { + return ( + error instanceof Error && + 'code' in error && + typeof error.code === 'string' && + error.code.startsWith('FST_') + ); +}; + export function errorHandler( error: FastifyError | Error, _request: FastifyRequest, reply: FastifyReply, ) { + if (isInvalidJsonError(error)) { + return sendCodedError( + reply, + statusByErrorCode[apiErrorCodes.badRequest], + apiErrorCodes.badRequest, + {}, + ); + } + + if (isFastifyError(error)) { + return sendCodedError( + reply, + statusByErrorCode[apiErrorCodes.badRequest], + apiErrorCodes.badRequest, + {}, + ); + } + if (error instanceof ZodError) { const fields = error.issues.map((issue) => ({ code: getValidationFieldErrorCode(issue), diff --git a/apps/backend/src/presentation/http/test-utils/http-test-client.ts b/apps/backend/src/presentation/http/test-utils/http-test-client.ts new file mode 100644 index 00000000..001f7024 --- /dev/null +++ b/apps/backend/src/presentation/http/test-utils/http-test-client.ts @@ -0,0 +1,42 @@ +import type { IncomingHttpHeaders, OutgoingHttpHeaders } from 'node:http'; + +import type { createServer } from 'src/presentation/http/server'; + +type TestServer = ReturnType; +type HttpMethod = 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT'; +type InjectPayload = string | object | Buffer | NodeJS.ReadableStream; + +export type HttpTestInjectOptions = { + headers?: IncomingHttpHeaders | OutgoingHttpHeaders; + method: HttpMethod; + payload?: InjectPayload; + query?: string | Record; + url: string; +}; + +export const createHttpTestClient = ( + server: TestServer, + getAuthToken: () => string, +) => { + const injectWithToken = async ( + token: string, + options: HttpTestInjectOptions, + ) => { + return await server.inject({ + ...options, + headers: { + ...(options.headers ?? {}), + Authorization: `Bearer ${token}`, + }, + }); + }; + + const injectAuthorized = async (options: HttpTestInjectOptions) => { + return await injectWithToken(getAuthToken(), options); + }; + + return { + injectAuthorized, + injectWithToken, + }; +}; diff --git a/apps/backend/src/presentation/http/test-utils/index.ts b/apps/backend/src/presentation/http/test-utils/index.ts new file mode 100644 index 00000000..fd4ba9b1 --- /dev/null +++ b/apps/backend/src/presentation/http/test-utils/index.ts @@ -0,0 +1 @@ +export { createHttpTestClient } from './http-test-client'; diff --git a/apps/backend/src/types/fastify.d.ts b/apps/backend/src/types/fastify.d.ts index 3099cbce..96e02371 100644 --- a/apps/backend/src/types/fastify.d.ts +++ b/apps/backend/src/types/fastify.d.ts @@ -9,6 +9,7 @@ declare module '@fastify/jwt' { payload: { userId: UUID; email: string; + exp?: number; }; user: User; } diff --git a/packages/shared/src/constants/accounts.ts b/packages/shared/src/constants/accounts.ts index a9831495..c1ab75ad 100644 --- a/packages/shared/src/constants/accounts.ts +++ b/packages/shared/src/constants/accounts.ts @@ -1,14 +1,12 @@ -import { AccountTypeValue } from "../types"; -export const ACCOUNT_TYPES: AccountTypeValue[] = [ +import type { AccountTypeValue } from "../types"; + +export const ACCOUNT_TYPES = [ "asset", "liability", "equity", "income", "expense", "currencyTrading", -]; +] as const satisfies readonly AccountTypeValue[]; -export const ACCOUNT_TYPE_VALUES = ACCOUNT_TYPES.map((t) => t) as [ - AccountTypeValue, - ...AccountTypeValue[], -]; +export const ACCOUNT_STATUS_FILTER_VALUES = ["open", "closed", "all"] as const; diff --git a/packages/shared/src/constants/commodities.ts b/packages/shared/src/constants/commodities.ts new file mode 100644 index 00000000..9a427508 --- /dev/null +++ b/packages/shared/src/constants/commodities.ts @@ -0,0 +1,5 @@ +export const COMMODITY_STATUS_FILTER_VALUES = [ + "active", + "archived", + "all", +] as const; diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index a50c4a11..a2846388 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -1,4 +1,5 @@ export * from "./accounts"; +export * from "./commodities"; export * from "./currencies"; export * from "./operations"; export * from "./pagination"; diff --git a/packages/shared/src/types/account.ts b/packages/shared/src/types/account.ts index 28dbaea9..0e3f5deb 100644 --- a/packages/shared/src/types/account.ts +++ b/packages/shared/src/types/account.ts @@ -27,6 +27,7 @@ export type AccountResponseDTO = { description: string; id: UUID; initialBalance: AmountString; + isClosed: boolean; isSystem: boolean; isTombstone: boolean; name: RequiredText; diff --git a/packages/shared/src/types/commodity.ts b/packages/shared/src/types/commodity.ts index 66b745ca..8cea500a 100644 --- a/packages/shared/src/types/commodity.ts +++ b/packages/shared/src/types/commodity.ts @@ -1,11 +1,16 @@ +import type { z } from "zod"; + import { - CommodityCodeString, - CommodityPrecisionNumber, - CommoditySymbolString, - IsoDatetimeString, - RequiredText, - UUID, -} from "./types"; + commodityCode, + commodityPrecision, + commoditySymbol, +} from "../validation/commodities"; + +import { IsoDatetimeString, RequiredText, UUID } from "./types"; + +export type CommodityCodeString = z.infer; +export type CommodityPrecisionNumber = z.infer; +export type CommoditySymbolString = z.infer; export type CommodityCreateDTO = { code: CommodityCodeString; diff --git a/packages/shared/src/types/error-contract.ts b/packages/shared/src/types/error-contract.ts index f0db0b07..aed7a452 100644 --- a/packages/shared/src/types/error-contract.ts +++ b/packages/shared/src/types/error-contract.ts @@ -1,6 +1,7 @@ import type { UUID } from "./types"; export const apiErrorCodes = { + accountHasActiveOperations: "ACCOUNT_HAS_ACTIVE_OPERATIONS", accountNotFoundInContext: "ACCOUNT_NOT_FOUND_IN_CONTEXT", authenticationFailed: "AUTHENTICATION_FAILED", badRequest: "BAD_REQUEST", @@ -54,6 +55,9 @@ export type ValidationFieldError = { }; export type ErrorContextByCode = { + ACCOUNT_HAS_ACTIVE_OPERATIONS: { + accountId: UUID; + }; ACCOUNT_NOT_FOUND_IN_CONTEXT: { accountId: string; operationId: string; @@ -70,7 +74,7 @@ export type ErrorContextByCode = { }; DELETED_ENTITY_OPERATION: { entityType: string; - operation: "delete" | "update"; + operation: "delete" | "update" | "use"; }; EMPTY_OPERATIONS: Record; ENTITY_NOT_FOUND: { diff --git a/packages/shared/src/types/types.ts b/packages/shared/src/types/types.ts index ba2f7c0d..bd638936 100644 --- a/packages/shared/src/types/types.ts +++ b/packages/shared/src/types/types.ts @@ -6,11 +6,6 @@ import { sha256String, uuid, amountString, - commodityCode, - commodityPrecision, - commoditySymbol, - queryStatus, - queryStatusSchema, requiredText, } from "../validation/baseValidations"; @@ -19,9 +14,4 @@ export type IsoDateString = z.infer; export type Sha256String = z.infer; export type UUID = z.infer; export type AmountString = z.infer; -export type CommodityCodeString = z.infer; -export type CommodityPrecisionNumber = z.infer; -export type CommoditySymbolString = z.infer; export type RequiredText = z.infer; -export type QueryStatus = z.infer; -export type QueryStatusQuery = z.infer; diff --git a/packages/shared/src/validation/accounts.test.ts b/packages/shared/src/validation/accounts.test.ts index 5fced25c..620d0593 100644 --- a/packages/shared/src/validation/accounts.test.ts +++ b/packages/shared/src/validation/accounts.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { accountCreateSchema, accountUpdateSchema } from "./accounts"; +import { + accountCreateSchema, + accountQuerySchema, + accountUpdateSchema, +} from "./accounts"; const validCreateAccount = { commodityId: "00000000-0000-4000-8000-000000000000", @@ -31,3 +35,15 @@ describe("accountUpdateSchema", () => { }); }); }); + +describe("accountQuerySchema", () => { + it("defaults omitted closed filter to open accounts", () => { + expect(accountQuerySchema.parse({})).toEqual({ closed: "open" }); + }); + + it("allows querying all accounts", () => { + expect(accountQuerySchema.parse({ closed: "all" })).toEqual({ + closed: "all", + }); + }); +}); diff --git a/packages/shared/src/validation/accounts.ts b/packages/shared/src/validation/accounts.ts index 38de0b7c..45c14b42 100644 --- a/packages/shared/src/validation/accounts.ts +++ b/packages/shared/src/validation/accounts.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { ACCOUNT_TYPE_VALUES } from "../constants"; +import { ACCOUNT_STATUS_FILTER_VALUES, ACCOUNT_TYPES } from "../constants"; import { defaultText, @@ -10,18 +10,30 @@ import { amountString, } from "./baseValidations"; -const accountType = z.enum(ACCOUNT_TYPE_VALUES); +const accountType = z.enum(ACCOUNT_TYPES); +const accountName = requiredText.max(255); -export const accountCreateSchema = z.object({ - commodityId: uuid, - description: defaultText, - initialBalance: amountString, - name: requiredText, - type: accountType, -}); +export const accountCreateSchema = z + .object({ + commodityId: uuid, + description: defaultText, + initialBalance: amountString, + name: accountName, + type: accountType, + }) + .strict(); + +export const accountUpdateSchema = z + .object({ + description: optionalText, + name: accountName.optional(), + type: accountType.optional(), + }) + .strict() + .refine((data) => Object.keys(data).length > 0); -export const accountUpdateSchema = z.object({ - description: optionalText, - name: requiredText.optional(), - type: accountType.optional(), +export const accountQuerySchema = z.object({ + status: z.enum(ACCOUNT_STATUS_FILTER_VALUES).default("open"), }); + +export type AccountQuery = z.infer; diff --git a/packages/shared/src/validation/baseValidations.ts b/packages/shared/src/validation/baseValidations.ts index 7bf19222..23bff0df 100644 --- a/packages/shared/src/validation/baseValidations.ts +++ b/packages/shared/src/validation/baseValidations.ts @@ -74,33 +74,3 @@ export const getTransactionsQuerySchema = z }); } }); - -const COMMODITY_CODE_MIN_LENGTH = 3; -const COMMODITY_CODE_MAX_LENGTH = 16; -export const commodityCode = z - .string() - .trim() - .toUpperCase() - .min( - COMMODITY_CODE_MIN_LENGTH, - `Commodity code must be at least ${COMMODITY_CODE_MIN_LENGTH} characters long`, - ) - .max( - COMMODITY_CODE_MAX_LENGTH, - `Commodity code must be at most ${COMMODITY_CODE_MAX_LENGTH} characters long`, - ) - .regex( - /^[A-Z][A-Z0-9]*$/, - "Commodity code must start with a letter and contain only letters and digits", - ) - .brand<"CommodityCode">(); -export const commoditySymbol = z.string().trim().max(12).nullable(); -export const commodityPrecision = z.number().int().min(0).max(18); - -export const queryStatus = z - .enum(["active", "archived", "all"]) - .default("active"); - -export const queryStatusSchema = z.object({ - status: queryStatus, -}); diff --git a/packages/shared/src/validation/commodities.test.ts b/packages/shared/src/validation/commodities.test.ts new file mode 100644 index 00000000..2286299d --- /dev/null +++ b/packages/shared/src/validation/commodities.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { commodityQuerySchema } from "./commodities"; + +describe("commodityQuerySchema", () => { + it("defaults omitted status filter to active commodities", () => { + expect(commodityQuerySchema.parse({})).toEqual({ status: "active" }); + }); + + it("allows querying all commodities", () => { + expect(commodityQuerySchema.parse({ status: "all" })).toEqual({ + status: "all", + }); + }); +}); diff --git a/packages/shared/src/validation/commodities.ts b/packages/shared/src/validation/commodities.ts index 063b39a5..3a284e24 100644 --- a/packages/shared/src/validation/commodities.ts +++ b/packages/shared/src/validation/commodities.ts @@ -1,11 +1,32 @@ import { z } from "zod"; -import { - requiredText, - commodityPrecision, - commoditySymbol, - commodityCode, -} from "./baseValidations"; +import { COMMODITY_STATUS_FILTER_VALUES } from "../constants"; + +import { requiredText } from "./baseValidations"; + +const COMMODITY_CODE_MIN_LENGTH = 3; +const COMMODITY_CODE_MAX_LENGTH = 16; + +export const commodityCode = z + .string() + .trim() + .toUpperCase() + .min( + COMMODITY_CODE_MIN_LENGTH, + `Commodity code must be at least ${COMMODITY_CODE_MIN_LENGTH} characters long`, + ) + .max( + COMMODITY_CODE_MAX_LENGTH, + `Commodity code must be at most ${COMMODITY_CODE_MAX_LENGTH} characters long`, + ) + .regex( + /^[A-Z][A-Z0-9]*$/, + "Commodity code must start with a letter and contain only letters and digits", + ) + .brand<"CommodityCode">(); + +export const commoditySymbol = z.string().trim().max(12).nullable(); +export const commodityPrecision = z.number().int().min(0).max(18); export const commodityCreateSchema = z.object({ code: commodityCode, @@ -21,3 +42,13 @@ export const commodityUpdateSchema = commodityCreateSchema symbol: true, }) .partial(); + +export const commodityStatusFilter = z + .enum(COMMODITY_STATUS_FILTER_VALUES) + .default("active"); + +export const commodityQuerySchema = z.object({ + status: commodityStatusFilter, +}); + +export type CommodityQuery = z.infer; From 8be15fe6281d2c90b3a5a0b592ecda350d7de574 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Fri, 31 Jul 2026 13:14:49 +0300 Subject: [PATCH 04/31] LED-124 feat(accounts): add preventing updating closed accounts --- .../accounts/__tests__/updateAccount.test.ts | 32 +++++++++++++++++++ .../usecases/accounts/updateAccount.ts | 14 +++++--- apps/backend/src/domain/domain.errors.test.ts | 13 ++++++++ apps/backend/src/domain/domain.errors.ts | 23 +++++++++++++ .../http/accounts/account.integration.test.ts | 8 ++++- .../src/presentation/http/error-handler.ts | 1 + packages/shared/src/types/error-contract.ts | 5 +++ 7 files changed, 90 insertions(+), 6 deletions(-) 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 9c82726a..e0efe33d 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts @@ -11,6 +11,7 @@ import { Account } from 'src/domain/accounts/account.entity'; import { AccountSnapshot } from 'src/domain/accounts/types'; import { Amount, 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'; import { UpdateAccountUseCase } from '../updateAccount'; @@ -156,6 +157,37 @@ describe('UpdateAccount', async () => { expect(mockAccountRepository.update).not.toHaveBeenCalled(); }); + it('should throw error when account type is changed and account is closed', async () => { + mockAccountRepository.getById.mockResolvedValue({ + ...mockAccountData, + isClosed: true, + }); + + const result = updateAccountUseCase.execute(user, accountId, { + type: 'liability' as AccountTypeValue, + }); + + await expect(result).rejects.toThrow(ClosedAccountOperationError); + await expect(result).rejects.toMatchObject({ + code: apiErrorCodes.closedAccountOperation, + context: { + accountId, + operation: 'update', + }, + }); + + expect(mockAccountRepository.getById).toHaveBeenCalledWith( + user.getId().valueOf(), + accountId, + ); + + expect( + mockAccountOperationPolicy.assertNoActiveOperations, + ).not.toHaveBeenCalled(); + + expect(mockAccountRepository.update).not.toHaveBeenCalled(); + }); + it('should call assertNoActiveOperations when account type is changed', async () => { mockAccountRepository.getById.mockResolvedValue(mockAccountData); diff --git a/apps/backend/src/application/usecases/accounts/updateAccount.ts b/apps/backend/src/application/usecases/accounts/updateAccount.ts index 6b0610e7..5a19b584 100644 --- a/apps/backend/src/application/usecases/accounts/updateAccount.ts +++ b/apps/backend/src/application/usecases/accounts/updateAccount.ts @@ -6,7 +6,8 @@ import { import type { AccountRepositoryInterface } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { AccountOperationPolicy } from 'src/application/services/'; -import { Account, AccountSnapshot } from 'src/domain/accounts'; +import { Account } from 'src/domain/accounts'; +import { ClosedAccountOperationError } from 'src/domain/domain.errors'; import { User } from 'src/domain/users/user.entity'; import { AccountUseCaseBase } from './accountBase'; @@ -22,10 +23,14 @@ export class UpdateAccountUseCase extends AccountUseCaseBase { private async checkAccountUpdateValidity( user: User, accountId: UUID, - accountData: AccountSnapshot, + account: Account, data: AccountUpdateDTO, ): Promise { - if (data.type && data.type !== accountData.type) { + if (account.closed) { + throw ClosedAccountOperationError.forUpdate(accountId); + } + + if (data.type && data.type !== account.toSnapshot().type) { await this.accountOperationPolicy.assertNoActiveOperations( user.getId().valueOf(), accountId, @@ -40,9 +45,8 @@ export class UpdateAccountUseCase extends AccountUseCaseBase { ): Promise { const accountData = await this.ensureAccountExistsAndOwned(user, accountId); - await this.checkAccountUpdateValidity(user, accountId, accountData, data); - const account = Account.restore(accountData); + await this.checkAccountUpdateValidity(user, accountId, account, data); account.update(AccountMapper.toUpdateProps(data)); diff --git a/apps/backend/src/domain/domain.errors.test.ts b/apps/backend/src/domain/domain.errors.test.ts index bfc03102..5a507cd9 100644 --- a/apps/backend/src/domain/domain.errors.test.ts +++ b/apps/backend/src/domain/domain.errors.test.ts @@ -1,6 +1,7 @@ import { apiErrorCodes } from '@ledgerly/shared/types'; import { DomainError, + ClosedAccountOperationError, DeletedEntityOperationError, EmptyOperationsError, ExcessiveOperationsError, @@ -49,6 +50,18 @@ describe('coded domain errors', () => { }); }); + it('exposes CLOSED_ACCOUNT_OPERATION context for update operations', () => { + const accountId = '550e8400-e29b-41d4-a716-446655440001' as never; + + expect(ClosedAccountOperationError.forUpdate(accountId)).toMatchObject({ + code: apiErrorCodes.closedAccountOperation, + context: { + accountId, + operation: 'update', + }, + }); + }); + it('exposes INVALID_VERSION context', () => { expect(new InvalidVersionError(-1)).toMatchObject({ code: apiErrorCodes.invalidVersion, diff --git a/apps/backend/src/domain/domain.errors.ts b/apps/backend/src/domain/domain.errors.ts index a9a137ba..9e140e79 100644 --- a/apps/backend/src/domain/domain.errors.ts +++ b/apps/backend/src/domain/domain.errors.ts @@ -299,6 +299,29 @@ export class DeletedEntityOperationError extends CodedDomainError<'DELETED_ENTIT } } +type ClosedAccountOperation = 'update' | 'use'; + +export class ClosedAccountOperationError extends CodedDomainError<'CLOSED_ACCOUNT_OPERATION'> { + constructor( + public readonly accountId: UUID, + public readonly operation: ClosedAccountOperation, + ) { + super( + `cannot ${operation} closed account ${accountId}`, + apiErrorCodes.closedAccountOperation, + { accountId, operation }, + ); + } + + static forUpdate(accountId: UUID): ClosedAccountOperationError { + return new ClosedAccountOperationError(accountId, 'update'); + } + + static forUse(accountId: UUID): ClosedAccountOperationError { + return new ClosedAccountOperationError(accountId, 'use'); + } +} + /** * Thrown when the same operation ID appears in multiple patch arrays simultaneously. */ 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 e907e088..c00708ea 100644 --- a/apps/backend/src/presentation/http/accounts/account.integration.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.integration.test.ts @@ -295,12 +295,18 @@ describe('Accounts Integration Tests', () => { describe('PATCH /api/accounts/:id', () => { it('should return 200 and update an account by id', async () => { - const accountToUpdate = accounts[0]; + const accountToUpdate = accounts.find( + (account) => !account.isClosed && !account.isTombstone, + ); const updatedData = { name: 'Updated Account Name', }; + if (!accountToUpdate) { + throw new Error('No account available for update'); + } + const response = await injectAuthorized({ method: 'PATCH', payload: updatedData, diff --git a/apps/backend/src/presentation/http/error-handler.ts b/apps/backend/src/presentation/http/error-handler.ts index 3f60101e..7e19927d 100644 --- a/apps/backend/src/presentation/http/error-handler.ts +++ b/apps/backend/src/presentation/http/error-handler.ts @@ -20,6 +20,7 @@ const statusByErrorCode = { [apiErrorCodes.accountNotFoundInContext]: 400, [apiErrorCodes.authenticationFailed]: 401, [apiErrorCodes.badRequest]: 400, + [apiErrorCodes.closedAccountOperation]: 409, [apiErrorCodes.conflict]: 409, [apiErrorCodes.conflictingOperationIds]: 400, [apiErrorCodes.deletedEntityOperation]: 400, diff --git a/packages/shared/src/types/error-contract.ts b/packages/shared/src/types/error-contract.ts index aed7a452..7229eaf4 100644 --- a/packages/shared/src/types/error-contract.ts +++ b/packages/shared/src/types/error-contract.ts @@ -5,6 +5,7 @@ export const apiErrorCodes = { accountNotFoundInContext: "ACCOUNT_NOT_FOUND_IN_CONTEXT", authenticationFailed: "AUTHENTICATION_FAILED", badRequest: "BAD_REQUEST", + closedAccountOperation: "CLOSED_ACCOUNT_OPERATION", conflict: "CONFLICT", conflictingOperationIds: "CONFLICTING_OPERATION_IDS", deletedEntityOperation: "DELETED_ENTITY_OPERATION", @@ -64,6 +65,10 @@ export type ErrorContextByCode = { }; AUTHENTICATION_FAILED: Record; BAD_REQUEST: Record; + CLOSED_ACCOUNT_OPERATION: { + accountId: UUID; + operation: "update" | "use"; + }; CONFLICT: Record; CONFLICTING_OPERATION_IDS: { conflict: From 9e51b3c098ecf742b514c6eb18adf2874552c3d3 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Fri, 31 Jul 2026 13:22:34 +0300 Subject: [PATCH 05/31] LED-124 fix(transaction-service): prevent loading closed accounts --- .../skills/ledgerly-commit-message/SKILL.md | 87 +++++++++++++++++++ .../agents/openai.yaml | 4 + .../transaction.context-loader.test.ts | 31 +++++++ .../transaction.context-loader.ts | 2 +- 4 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/ledgerly-commit-message/SKILL.md create mode 100644 .agents/skills/ledgerly-commit-message/agents/openai.yaml diff --git a/.agents/skills/ledgerly-commit-message/SKILL.md b/.agents/skills/ledgerly-commit-message/SKILL.md new file mode 100644 index 00000000..f11949e8 --- /dev/null +++ b/.agents/skills/ledgerly-commit-message/SKILL.md @@ -0,0 +1,87 @@ +--- +name: ledgerly-commit-message +description: Create and validate Ledgerly commit messages. Use when committing Ledgerly changes, drafting commit messages, checking commit message format, or extracting the Jira ticket key from the current branch. +--- + +# Ledgerly Commit Message + +Use this skill whenever the user asks to commit changes, draft a commit message, +or validate a commit message in the Ledgerly repository. + +## Required Format + +Commit messages must follow: + +```text +LED-XXXXX type(scope): subject +``` + +Where: + +- `LED-XXXXX` is the Jira ticket key extracted from the current branch name. +- `type` is one of: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, + `build`, `ci`, `chore`, `revert`. +- `scope` is optional and must be concise kebab-case or a short module name, + such as `fullscreen`, `mobile`, or `platform-core`. +- `subject` must be imperative mood, lowercase, and must not end with a period. + +Valid examples: + +```text +LED-12345 fix(platform-core): handle empty ledger state +LED-12345 test: cover transaction context loading +``` + +Invalid examples: + +```text +fix: handle empty ledger state +LED-12345 fixed(platform-core): handled empty ledger state. +LED-12345 feature: Add platform core loading +``` + +## Ticket Extraction + +Before creating a commit, get the current branch: + +```sh +git branch --show-current +``` + +Extract the first `LED-` key from a branch matching: + +```text +LED-XXXXX-description +``` + +Use a practical regex: + +```text +\bLED-[0-9]+\b +``` + +If the branch does not contain a `LED-` key, stop and ask the user for +the ticket key before committing. + +## Drafting Rules + +1. Choose the narrowest accurate `type`. +2. Add a scope only when it clarifies the changed area. +3. Keep the subject short, specific, lowercase, imperative, and without a final + period. +4. Prefer concrete verbs: `add`, `fix`, `move`, `rename`, `remove`, `update`, + `cover`, `document`, `refactor`. +5. Do not invent a Jira ticket key. Use the branch key or ask the user. + +## Commit Workflow + +When the user asks to commit: + +1. Inspect `git status --short`. +2. Confirm the staged or intended files match the requested work. +3. Extract the Jira ticket key from the current branch. +4. Draft a valid commit message from the actual diff. +5. Run `git commit -m ""`. + +If unrelated user changes are present, do not stage or modify them unless the +user explicitly asks. diff --git a/.agents/skills/ledgerly-commit-message/agents/openai.yaml b/.agents/skills/ledgerly-commit-message/agents/openai.yaml new file mode 100644 index 00000000..1030235a --- /dev/null +++ b/.agents/skills/ledgerly-commit-message/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Ledgerly Commit Message" + short_description: "Format Ledgerly commits from branch tickets" + default_prompt: "Use $ledgerly-commit-message to create a valid Ledgerly commit message from the current branch." diff --git a/apps/backend/src/application/services/TransactionService/__tests__/transaction.context-loader.test.ts b/apps/backend/src/application/services/TransactionService/__tests__/transaction.context-loader.test.ts index 88c6bf1a..566124b7 100644 --- a/apps/backend/src/application/services/TransactionService/__tests__/transaction.context-loader.test.ts +++ b/apps/backend/src/application/services/TransactionService/__tests__/transaction.context-loader.test.ts @@ -178,6 +178,37 @@ describe('TransactionContextLoader', () => { ); }); + it('should throw a domain error when an account is closed', async () => { + const account = createAccount(user); + const accountSnapshot = { + ...account.toSnapshot(), + isClosed: true, + }; + + const rawOperations: OperationRequestDTO[] = [ + { + accountId: account.getId().valueOf(), + amount: Amount.create('100').valueOf(), + description: 'Op 1', + value: Amount.create('100').valueOf(), + }, + { + accountId: account.getId().valueOf(), + amount: Amount.create('-100').valueOf(), + description: 'Op 2', + value: Amount.create('-100').valueOf(), + }, + ]; + + mockAccountRepository.getByIds.mockResolvedValueOnce([accountSnapshot]); + + await expect( + transactionContextLoader.loadContext(user, rawOperations), + ).rejects.toThrowError( + new DeletedEntityOperationError(Account.entityType, 'use'), + ); + }); + it('should return empty maps when operations list is empty', async () => { mockAccountRepository.getByIds.mockResolvedValueOnce([]); diff --git a/apps/backend/src/application/services/TransactionService/transaction.context-loader.ts b/apps/backend/src/application/services/TransactionService/transaction.context-loader.ts index 3f624817..d184b1f4 100644 --- a/apps/backend/src/application/services/TransactionService/transaction.context-loader.ts +++ b/apps/backend/src/application/services/TransactionService/transaction.context-loader.ts @@ -32,7 +32,7 @@ export class TransactionContextLoader { for (const snapshot of accountRows) { const account = Account.restore(snapshot); - if (account.isDeleted()) { + if (account.isDeleted() || account.closed) { throw DeletedEntityOperationError.forUse(Account.entityType); } From 1397dcb0d7d65d963f71b229520115bf454df10e Mon Sep 17 00:00:00 2001 From: gorushkin Date: Fri, 31 Jul 2026 13:28:34 +0300 Subject: [PATCH 06/31] LED-124 docs(accounts): update lifecycle documentation --- docs/DATABASE_SCHEMA.md | 10 ++++- docs/DOMAIN.md | 11 ++++- docs/ERROR_ARCHITECTURE.md | 3 +- ...-tombstone-and-reversible-entity-states.md | 44 +++++++++++-------- 4 files changed, 47 insertions(+), 21 deletions(-) diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index fe6f02dc..421dd33d 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -179,8 +179,9 @@ User's financial accounts for tracking funds. | `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 | Soft delete 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 | | `updatedAt` | Timestamp | Last update date | NOT NULL | @@ -200,6 +201,13 @@ User's financial accounts for tracking funds. - Account Commodity is immutable after account creation. - Money amounts are stored as integer minor units to avoid floating-point issues. +- `isClosed` and `isTombstone` are separate lifecycle axes. Closed accounts are + historical, reversible records; tombstoned accounts are hidden from normal + reads and cannot be restored. +- `GET /accounts` filters use `status=open|closed|all` and always exclude + tombstoned accounts. +- Terminal account delete is allowed only when no active operations reference the + account. - System accounts are reserved for future trading-account support. --- diff --git a/docs/DOMAIN.md b/docs/DOMAIN.md index f6241392..0d61ae75 100644 --- a/docs/DOMAIN.md +++ b/docs/DOMAIN.md @@ -62,7 +62,16 @@ Represents different financial accounts with unified structure for all account t - Balance is calculated from operations - Has initial balance (`initialBalance`) - `isSystem = true` is reserved for future system trading accounts (currently unused) -- Has soft delete support (`isTombstone`) +- 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 +- 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 +- 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 ### Commodity diff --git a/docs/ERROR_ARCHITECTURE.md b/docs/ERROR_ARCHITECTURE.md index 70f5dfaf..ac5c952a 100644 --- a/docs/ERROR_ARCHITECTURE.md +++ b/docs/ERROR_ARCHITECTURE.md @@ -39,7 +39,8 @@ Domain errors represent invariant and value-object failures. They may expose a stable API code and context, but they do not decide how that code is transported. Examples include invalid amounts, invalid identifiers, transaction balance -violations and operation ownership mismatches. +violations, operation ownership mismatches and closed-account lifecycle +violations such as `ClosedAccountOperationError`. ### Application 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 38238d25..4bef1cb0 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 @@ -52,23 +52,25 @@ A closed account: - keeps its full history; - participates in reports and calculations; - does not accept new operations; -- can be reopened. +- can be opened again. The domain API should express that lifecycle explicitly: ```ts account.close(); -account.reopen(); +account.open(); ``` The `close` term matches account lifecycle language: an account can stop -accepting new operations while keeping its history and later be reopened. +accepting new operations while keeping its history and later be opened again. -`close()` and `reopen()` are idempotent. Calling `close()` for an already closed -account leaves it closed. Calling `reopen()` for an already active account -leaves it active. +`close()` and `open()` are idempotent. Calling `close()` for an already closed +account leaves it closed. Calling `open()` for an already open account leaves it +open. -Closed accounts can still be edited, but only for descriptive fields: +Closed accounts cannot be edited through the regular account update flow. The +only user-facing fields that may remain editable for a closed account are +descriptive fields: - `name`; - `description`. @@ -77,16 +79,21 @@ Closed accounts can still be edited, but only for descriptive fields: account has participated in operations, its type is part of the historical accounting model and must not be changed. -Account list queries should support the same lifecycle filtering shape as -commodity list queries: +Account list queries use account lifecycle language: -- `active`; +- `open`; - `closed`; - `all`. Every normal account list filter excludes tombstoned accounts. -Account close and reopen are exposed as explicit domain actions: +| Filter | Predicate | +| --- | --- | +| `open` | `isClosed = false` and `isTombstone = false` | +| `closed` | `isClosed = true` and `isTombstone = false` | +| `all` | `isTombstone = false` | + +Account close and reopen are exposed as explicit HTTP actions: ```http POST /accounts/:id/close @@ -213,7 +220,7 @@ The intended public domain methods are: | Entity | Reversible state | Methods | | --- | --- | --- | -| `Account` | `isClosed` | `close()` / `reopen()` | +| `Account` | `isClosed` | `close()` / `open()` | | `Commodity` | `isArchived` | `archive()` / `unarchive()` | | Any entity | `isTombstone` | `delete()` | @@ -248,19 +255,20 @@ The intended public domain methods are: ## Consequences - `Transaction` and `Operation` tombstone state is irreversible. -- `Account` needs `isClosed`, plus `close()` and `reopen()` behavior. +- `Account` needs `isClosed`, plus `close()` and `open()` behavior. - `Commodity` needs `isArchived`, plus `archive()` and `unarchive()` behavior. - `DELETE` routes should mean terminal deletion. Account deletion must be rejected while active operations still reference the account. Commodity deletion must be rejected while any active domain entity still references the commodity. -- Query filters for account and commodity reads should distinguish active, - closed or archived, and all non-tombstoned records. Tombstoned records should - remain hidden from normal reads. +- Query filters for account and commodity reads should distinguish open, + closed or archived, and all non-tombstoned records. Account filters use + `open|closed|all`; commodity filters use `active|archived|all`. Tombstoned + records should remain hidden from normal reads. - Tests must cover tombstone terminality and business-state invariants: tombstoned entities cannot be restored or modified; closed accounts cannot - receive new operations; archived commodities cannot be selected for new active - links. + receive new operations and cannot be updated through the regular update flow; + archived commodities cannot be selected for new active links. ## Related From c6d2c4d600a672800ee040e58e08343a79bded49 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Fri, 31 Jul 2026 16:51:18 +0300 Subject: [PATCH 07/31] LED-124 feat(accounts): add lifecycle endpoints --- .../accounts/__tests__/closeAccount.test.ts | 15 +- .../accounts/__tests__/deleteAccount.test.ts | 16 +- .../accounts/__tests__/openAccount.test.ts | 15 +- .../accounts/__tests__/updateAccount.test.ts | 17 +- .../usecases/accounts/closeAccount.ts | 48 +++-- .../usecases/accounts/deleteAccount.ts | 54 ++--- .../application/usecases/accounts/index.ts | 2 + .../usecases/accounts/openAccount.ts | 48 +++-- .../usecases/accounts/updateAccount.ts | 33 ++- apps/backend/src/di/container.ts | 16 ++ apps/backend/src/di/types.ts | 4 + .../domain/accounts/account.entity.test.ts | 6 +- .../http/accounts/account.controller.test.ts | 56 +++++ .../http/accounts/account.controller.ts | 16 ++ .../http/accounts/account.integration.test.ts | 191 ++++++++++++++++++ .../http/accounts/account.routes.ts | 16 ++ ...-tombstone-and-reversible-entity-states.md | 6 +- 17 files changed, 477 insertions(+), 82 deletions(-) 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 2dbca0d7..12ab603e 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/closeAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/closeAccount.test.ts @@ -2,7 +2,10 @@ import { EntityNotFoundError, UnauthorizedAccessError, } from 'src/application/application.errors'; -import type { AccountRepositoryInterface } from 'src/application/interfaces'; +import type { + AccountRepositoryInterface, + TransactionManagerInterface, +} from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { createUser } from 'src/db/createTestUser'; import { AccountSnapshot } from 'src/domain/accounts'; @@ -26,6 +29,12 @@ describe('CloseAccountUseCase', async () => { getById: vi.fn(), }; + const mockTransactionManager = { + run: vi.fn(), + }; + const runTransaction: TransactionManagerInterface['run'] = async (callback) => + callback(); + const accountId = Id.restore( '550e8400-e29b-41d4-a716-446655440001', ).valueOf(); @@ -63,9 +72,12 @@ describe('CloseAccountUseCase', async () => { mockAccountRepository.getById.mockReset(); mockUserRepository.getById.mockReset(); + mockTransactionManager.run.mockReset(); + mockTransactionManager.run.mockImplementation(runTransaction); closeAccountUseCase = new CloseAccountUseCase( mockAccountRepository as unknown as AccountRepositoryInterface, + mockTransactionManager as unknown as TransactionManagerInterface, ); }); @@ -93,6 +105,7 @@ describe('CloseAccountUseCase', async () => { isClosed: true, }), ); + expect(mockTransactionManager.run).toHaveBeenCalledTimes(1); expect(result).toEqual( AccountMapper.toResponseDTOFromSnapshot({ 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 e0cc20ce..1121ae8b 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/deleteAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/deleteAccount.test.ts @@ -3,7 +3,10 @@ import { EntityNotFoundError, UnauthorizedAccessError, } from 'src/application/application.errors'; -import type { AccountRepositoryInterface } from 'src/application/interfaces'; +import type { + AccountRepositoryInterface, + TransactionManagerInterface, +} from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { AccountOperationPolicy } from 'src/application/services/AccountOperationPolicy/account-operation.policy'; import { createUser } from 'src/db/createTestUser'; @@ -28,6 +31,12 @@ describe('DeleteAccountUseCase', async () => { assertNoActiveOperations: vi.fn(), }; + const mockTransactionManager = { + run: vi.fn(), + }; + const runTransaction: TransactionManagerInterface['run'] = async (callback) => + callback(); + const accountId = Id.restore( '550e8400-e29b-41d4-a716-446655440001', ).valueOf(); @@ -61,10 +70,12 @@ describe('DeleteAccountUseCase', async () => { beforeEach(() => { vi.clearAllMocks(); vi.useRealTimers(); + mockTransactionManager.run.mockImplementation(runTransaction); deleteAccountUseCase = new DeleteAccountUseCase( mockAccountRepository as unknown as AccountRepositoryInterface, mockAccountOperationPolicy as unknown as AccountOperationPolicy, + mockTransactionManager as unknown as TransactionManagerInterface, ); }); @@ -75,6 +86,7 @@ describe('DeleteAccountUseCase', async () => { mockAccountOperationPolicy.assertNoActiveOperations.mockReset(); mockAccountRepository.getById.mockReset(); mockAccountRepository.delete.mockReset(); + mockTransactionManager.run.mockReset(); }); describe('execute', () => { @@ -101,6 +113,7 @@ describe('DeleteAccountUseCase', async () => { isTombstone: true, }), ); + expect(mockTransactionManager.run).toHaveBeenCalledTimes(1); expect(result).toEqual( AccountMapper.toResponseDTOFromSnapshot({ @@ -122,6 +135,7 @@ describe('DeleteAccountUseCase', async () => { expect( mockAccountOperationPolicy.assertNoActiveOperations, ).toHaveBeenCalledWith(user.getId().valueOf(), accountId); + expect(mockTransactionManager.run).toHaveBeenCalledTimes(1); expect(mockAccountRepository.delete).not.toHaveBeenCalled(); }); 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 84a9c2ba..6f342683 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/openAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/openAccount.test.ts @@ -2,7 +2,10 @@ import { EntityNotFoundError, UnauthorizedAccessError, } from 'src/application/application.errors'; -import type { AccountRepositoryInterface } from 'src/application/interfaces'; +import type { + AccountRepositoryInterface, + TransactionManagerInterface, +} from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { createUser } from 'src/db/createTestUser'; import { AccountSnapshot } from 'src/domain/accounts'; @@ -26,6 +29,12 @@ describe('OpenAccountUseCase', async () => { getById: vi.fn(), }; + const mockTransactionManager = { + run: vi.fn(), + }; + const runTransaction: TransactionManagerInterface['run'] = async (callback) => + callback(); + const accountId = Id.restore( '550e8400-e29b-41d4-a716-446655440001', ).valueOf(); @@ -63,9 +72,12 @@ describe('OpenAccountUseCase', async () => { mockAccountRepository.getById.mockReset(); mockUserRepository.getById.mockReset(); + mockTransactionManager.run.mockReset(); + mockTransactionManager.run.mockImplementation(runTransaction); openAccountUseCase = new OpenAccountUseCase( mockAccountRepository as unknown as AccountRepositoryInterface, + mockTransactionManager as unknown as TransactionManagerInterface, ); }); @@ -93,6 +105,7 @@ describe('OpenAccountUseCase', async () => { isClosed: false, }), ); + expect(mockTransactionManager.run).toHaveBeenCalledTimes(1); expect(result).toEqual( AccountMapper.toResponseDTOFromSnapshot({ 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 e0efe33d..1def413a 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts @@ -3,7 +3,10 @@ import { AccountHasActiveOperationsError, EntityNotFoundError, } from 'src/application/application.errors'; -import type { AccountRepositoryInterface } from 'src/application/interfaces'; +import type { + AccountRepositoryInterface, + TransactionManagerInterface, +} from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { AccountOperationPolicy } from 'src/application/services/AccountOperationPolicy/account-operation.policy'; import { createUser } from 'src/db/createTestUser'; @@ -31,6 +34,12 @@ describe('UpdateAccount', async () => { assertNoActiveOperations: vi.fn(), }; + const mockTransactionManager = { + run: vi.fn(), + }; + const runTransaction: TransactionManagerInterface['run'] = async (callback) => + callback(); + const accountId = Id.restore( '550e8400-e29b-41d4-a716-446655440001', ).valueOf(); @@ -62,9 +71,12 @@ describe('UpdateAccount', async () => { }; beforeEach(() => { + mockTransactionManager.run.mockImplementation(runTransaction); + updateAccountUseCase = new UpdateAccountUseCase( mockAccountRepository as unknown as AccountRepositoryInterface, mockAccountOperationPolicy as unknown as AccountOperationPolicy, + mockTransactionManager as unknown as TransactionManagerInterface, ); }); @@ -73,6 +85,7 @@ describe('UpdateAccount', async () => { mockAccountOperationPolicy.assertNoActiveOperations.mockReset(); mockAccountRepository.getById.mockReset(); mockAccountRepository.update.mockReset(); + mockTransactionManager.run.mockReset(); }); describe('execute', () => { @@ -99,6 +112,7 @@ describe('UpdateAccount', async () => { accountId, expect.objectContaining({ name: 'Updated Account' }), ); + expect(mockTransactionManager.run).toHaveBeenCalledTimes(1); expect(result.name).toBe('Updated Account'); expect(result).toEqual( @@ -203,6 +217,7 @@ describe('UpdateAccount', async () => { expect( mockAccountOperationPolicy.assertNoActiveOperations, ).toHaveBeenCalledWith(user.getId().valueOf(), accountId); + expect(mockTransactionManager.run).toHaveBeenCalledTimes(1); }); it('should not call assertNoActiveOperations when account type is not changed', async () => { diff --git a/apps/backend/src/application/usecases/accounts/closeAccount.ts b/apps/backend/src/application/usecases/accounts/closeAccount.ts index ff328a76..135df761 100644 --- a/apps/backend/src/application/usecases/accounts/closeAccount.ts +++ b/apps/backend/src/application/usecases/accounts/closeAccount.ts @@ -1,33 +1,43 @@ import { AccountResponseDTO, UUID } from '@ledgerly/shared/types'; +import type { + AccountRepositoryInterface, + TransactionManagerInterface, +} from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { Account } from 'src/domain/accounts/account.entity'; import { User } from 'src/domain/users/user.entity'; -import { AccountRepositoryInterface } from '../../interfaces'; - import { AccountUseCaseBase } from './accountBase'; export class CloseAccountUseCase extends AccountUseCaseBase { - constructor(accountRepository: AccountRepositoryInterface) { + constructor( + accountRepository: AccountRepositoryInterface, + private readonly transactionManager: TransactionManagerInterface, + ) { super(accountRepository); } async execute(user: User, accountId: UUID): Promise { - const accountData = await this.ensureAccountExistsAndOwned(user, accountId); - const account = Account.restore(accountData); - - const result = account.close(); - - if (result === 'unchanged') { - return AccountMapper.toResponseDTOFromSnapshot(account.toSnapshot()); - } - - await this.accountRepository.close( - user.getId().valueOf(), - accountId, - account.toSnapshot(), - ); - - return AccountMapper.toResponseDTOFromSnapshot(account.toSnapshot()); + const accountSnapshot = await this.transactionManager.run(async () => { + const accountData = await this.ensureAccountExistsAndOwned( + user, + accountId, + ); + const account = Account.restore(accountData); + + const result = account.close(); + + if (result === 'changed') { + await this.accountRepository.close( + user.getId().valueOf(), + accountId, + account.toSnapshot(), + ); + } + + return account.toSnapshot(); + }); + + return AccountMapper.toResponseDTOFromSnapshot(accountSnapshot); } } diff --git a/apps/backend/src/application/usecases/accounts/deleteAccount.ts b/apps/backend/src/application/usecases/accounts/deleteAccount.ts index 26d5df78..c446139b 100644 --- a/apps/backend/src/application/usecases/accounts/deleteAccount.ts +++ b/apps/backend/src/application/usecases/accounts/deleteAccount.ts @@ -1,42 +1,50 @@ import { AccountResponseDTO, UUID } from '@ledgerly/shared/types'; +import type { + AccountRepositoryInterface, + TransactionManagerInterface, +} from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { AccountOperationPolicy } from 'src/application/services/'; import { Account } from 'src/domain/accounts/account.entity'; import { User } from 'src/domain/users/user.entity'; -import { AccountRepositoryInterface } from '../../interfaces'; - import { AccountUseCaseBase } from './accountBase'; export class DeleteAccountUseCase extends AccountUseCaseBase { constructor( accountRepository: AccountRepositoryInterface, private readonly accountOperationPolicy: AccountOperationPolicy, + private readonly transactionManager: TransactionManagerInterface, ) { super(accountRepository); } async execute(user: User, accountId: UUID): Promise { - await this.accountOperationPolicy.assertNoActiveOperations( - user.getId().valueOf(), - accountId, - ); - - const accountData = await this.ensureAccountExistsAndOwned(user, accountId); - const account = Account.restore(accountData); - - const result = account.delete(); - - if (result === 'unchanged') { - return AccountMapper.toResponseDTOFromSnapshot(account.toSnapshot()); - } - - await this.accountRepository.delete( - user.getId().valueOf(), - accountId, - account.toSnapshot(), - ); - - return AccountMapper.toResponseDTOFromSnapshot(account.toSnapshot()); + const accountSnapshot = await this.transactionManager.run(async () => { + await this.accountOperationPolicy.assertNoActiveOperations( + user.getId().valueOf(), + accountId, + ); + + const accountData = await this.ensureAccountExistsAndOwned( + user, + accountId, + ); + const account = Account.restore(accountData); + + const result = account.delete(); + + if (result === 'changed') { + await this.accountRepository.delete( + user.getId().valueOf(), + accountId, + account.toSnapshot(), + ); + } + + return account.toSnapshot(); + }); + + return AccountMapper.toResponseDTOFromSnapshot(accountSnapshot); } } diff --git a/apps/backend/src/application/usecases/accounts/index.ts b/apps/backend/src/application/usecases/accounts/index.ts index 4f944e0f..9de3b43d 100644 --- a/apps/backend/src/application/usecases/accounts/index.ts +++ b/apps/backend/src/application/usecases/accounts/index.ts @@ -1,5 +1,7 @@ +export { CloseAccountUseCase } from './closeAccount'; export { DeleteAccountUseCase } from './deleteAccount'; export { CreateAccountUseCase } from './createAccount'; export { GetAccountByIdUseCase } from './getAccountById'; export { GetAllAccountsUseCase } from './getAllAccounts'; +export { OpenAccountUseCase } from './openAccount'; export { UpdateAccountUseCase } from './updateAccount'; diff --git a/apps/backend/src/application/usecases/accounts/openAccount.ts b/apps/backend/src/application/usecases/accounts/openAccount.ts index 29598728..6d7de40e 100644 --- a/apps/backend/src/application/usecases/accounts/openAccount.ts +++ b/apps/backend/src/application/usecases/accounts/openAccount.ts @@ -1,33 +1,43 @@ import { AccountResponseDTO, UUID } from '@ledgerly/shared/types'; +import type { + AccountRepositoryInterface, + TransactionManagerInterface, +} from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { Account } from 'src/domain/accounts/account.entity'; import { User } from 'src/domain/users/user.entity'; -import { AccountRepositoryInterface } from '../../interfaces'; - import { AccountUseCaseBase } from './accountBase'; export class OpenAccountUseCase extends AccountUseCaseBase { - constructor(accountRepository: AccountRepositoryInterface) { + constructor( + accountRepository: AccountRepositoryInterface, + private readonly transactionManager: TransactionManagerInterface, + ) { super(accountRepository); } async execute(user: User, accountId: UUID): Promise { - const accountData = await this.ensureAccountExistsAndOwned(user, accountId); - const account = Account.restore(accountData); - - const result = account.open(); - - if (result === 'unchanged') { - return AccountMapper.toResponseDTOFromSnapshot(account.toSnapshot()); - } - - await this.accountRepository.open( - user.getId().valueOf(), - accountId, - account.toSnapshot(), - ); - - return AccountMapper.toResponseDTOFromSnapshot(account.toSnapshot()); + const accountSnapshot = await this.transactionManager.run(async () => { + const accountData = await this.ensureAccountExistsAndOwned( + user, + accountId, + ); + const account = Account.restore(accountData); + + const result = account.open(); + + if (result === 'changed') { + await this.accountRepository.open( + user.getId().valueOf(), + accountId, + account.toSnapshot(), + ); + } + + return account.toSnapshot(); + }); + + return AccountMapper.toResponseDTOFromSnapshot(accountSnapshot); } } diff --git a/apps/backend/src/application/usecases/accounts/updateAccount.ts b/apps/backend/src/application/usecases/accounts/updateAccount.ts index 5a19b584..6663ea3b 100644 --- a/apps/backend/src/application/usecases/accounts/updateAccount.ts +++ b/apps/backend/src/application/usecases/accounts/updateAccount.ts @@ -3,7 +3,10 @@ import { AccountUpdateDTO, UUID, } from '@ledgerly/shared/types'; -import type { AccountRepositoryInterface } from 'src/application/interfaces'; +import type { + AccountRepositoryInterface, + TransactionManagerInterface, +} from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { AccountOperationPolicy } from 'src/application/services/'; import { Account } from 'src/domain/accounts'; @@ -16,6 +19,7 @@ export class UpdateAccountUseCase extends AccountUseCaseBase { constructor( accountRepository: AccountRepositoryInterface, private readonly accountOperationPolicy: AccountOperationPolicy, + private readonly transactionManager: TransactionManagerInterface, ) { super(accountRepository); } @@ -43,19 +47,26 @@ export class UpdateAccountUseCase extends AccountUseCaseBase { accountId: UUID, data: AccountUpdateDTO, ): Promise { - const accountData = await this.ensureAccountExistsAndOwned(user, accountId); + const accountSnapshot = await this.transactionManager.run(async () => { + const accountData = await this.ensureAccountExistsAndOwned( + user, + accountId, + ); + + const account = Account.restore(accountData); + await this.checkAccountUpdateValidity(user, accountId, account, data); - const account = Account.restore(accountData); - await this.checkAccountUpdateValidity(user, accountId, account, data); + account.update(AccountMapper.toUpdateProps(data)); - account.update(AccountMapper.toUpdateProps(data)); + await this.accountRepository.update( + user.getId().valueOf(), + accountId, + account.toSnapshot(), + ); - await this.accountRepository.update( - user.getId().valueOf(), - accountId, - account.toSnapshot(), - ); + return account.toSnapshot(); + }); - return AccountMapper.toResponseDTOFromSnapshot(account.toSnapshot()); + return AccountMapper.toResponseDTOFromSnapshot(accountSnapshot); } } diff --git a/apps/backend/src/di/container.ts b/apps/backend/src/di/container.ts index 71e2ef22..f43b6846 100644 --- a/apps/backend/src/di/container.ts +++ b/apps/backend/src/di/container.ts @@ -3,10 +3,12 @@ import { GetCommodityByIdUseCase, LoginUserUseCase, RegisterUserUseCase, + CloseAccountUseCase, CreateAccountUseCase, DeleteAccountUseCase, GetAccountByIdUseCase, GetAllAccountsUseCase, + OpenAccountUseCase, UpdateAccountUseCase, DeleteTransactionUseCase, GetAllTransactionsUseCase, @@ -95,10 +97,20 @@ export const createContainer = (db: DataBase): AppContainer => { const updateAccountUseCase = new UpdateAccountUseCase( accountRepository, accountOperationPolicy, + transactionManager, ); const deleteAccountUseCase = new DeleteAccountUseCase( accountRepository, accountOperationPolicy, + transactionManager, + ); + const closeAccountUseCase = new CloseAccountUseCase( + accountRepository, + transactionManager, + ); + const openAccountUseCase = new OpenAccountUseCase( + accountRepository, + transactionManager, ); const loginUserUseCase = new LoginUserUseCase(userRepository); @@ -165,10 +177,12 @@ export const createContainer = (db: DataBase): AppContainer => { const useCases: AppContainer['useCases'] = { account: { + closeAccount: closeAccountUseCase, createAccount: createAccountUseCase, deleteAccount: deleteAccountUseCase, getAccountById: getAccountByIdUseCase, getAllAccounts: getAllAccountsUseCase, + openAccount: openAccountUseCase, updateAccount: updateAccountUseCase, }, auth: { @@ -197,6 +211,8 @@ export const createContainer = (db: DataBase): AppContainer => { useCases.account.createAccount, useCases.account.updateAccount, useCases.account.deleteAccount, + useCases.account.closeAccount, + useCases.account.openAccount, ); const userController = new UserController(); diff --git a/apps/backend/src/di/types.ts b/apps/backend/src/di/types.ts index 7c2d15e6..9543287f 100644 --- a/apps/backend/src/di/types.ts +++ b/apps/backend/src/di/types.ts @@ -4,9 +4,11 @@ import { LoginUserUseCase, RegisterUserUseCase, CreateAccountUseCase, + CloseAccountUseCase, DeleteAccountUseCase, GetAccountByIdUseCase, GetAllAccountsUseCase, + OpenAccountUseCase, UpdateAccountUseCase, DeleteTransactionUseCase, GetAllTransactionsUseCase, @@ -49,9 +51,11 @@ type Services = { }; type AccountUseCases = { + closeAccount: CloseAccountUseCase; createAccount: CreateAccountUseCase; getAllAccounts: GetAllAccountsUseCase; getAccountById: GetAccountByIdUseCase; + openAccount: OpenAccountUseCase; updateAccount: UpdateAccountUseCase; deleteAccount: DeleteAccountUseCase; }; diff --git a/apps/backend/src/domain/accounts/account.entity.test.ts b/apps/backend/src/domain/accounts/account.entity.test.ts index a1386193..d7fdddb4 100644 --- a/apps/backend/src/domain/accounts/account.entity.test.ts +++ b/apps/backend/src/domain/accounts/account.entity.test.ts @@ -259,7 +259,7 @@ describe('Account Domain Entity', () => { ); }); - it('should reopen and touch the account', () => { + it('should open and touch the account', () => { const account = Account.create(user, { commodityId: commodity.getId(), description: 'account-description', @@ -270,7 +270,7 @@ describe('Account Domain Entity', () => { account.close(); - const updatedAtBeforeReopen = account.getUpdatedAt(); + const updatedAtBeforeOpen = account.getUpdatedAt(); vi.useFakeTimers(); vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); @@ -279,7 +279,7 @@ describe('Account Domain Entity', () => { expect(account.closed).toBe(false); expect(account.getUpdatedAt().toString()).not.toBe( - updatedAtBeforeReopen.toString(), + updatedAtBeforeOpen.toString(), ); }); 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 c5ca837d..5cb5c854 100644 --- a/apps/backend/src/presentation/http/accounts/account.controller.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.controller.test.ts @@ -1,10 +1,12 @@ import { AccountCreateDTO, AccountUpdateDTO } from '@ledgerly/shared/types'; import { AccountQuery } from 'node_modules/@ledgerly/shared/src/validation/accounts'; import { + CloseAccountUseCase, DeleteAccountUseCase, CreateAccountUseCase, GetAccountByIdUseCase, GetAllAccountsUseCase, + OpenAccountUseCase, UpdateAccountUseCase, } from 'src/application/usecases/accounts'; import { Amount } from 'src/domain/domain-core'; @@ -43,12 +45,22 @@ describe('AccountController', () => { execute: vi.fn(), }; + const mockCloseAccountUseCase = { + execute: vi.fn(), + }; + + const mockOpenAccountUseCase = { + execute: vi.fn(), + }; + const accountController = new AccountController( mockGetAccountByIdUseCase as unknown as GetAccountByIdUseCase, mockGetAllAccountsUseCase as unknown as GetAllAccountsUseCase, mockCreateAccountUseCase as unknown as CreateAccountUseCase, mockUpdateAccountUseCase as unknown as UpdateAccountUseCase, mockArchiveAccountUseCase as unknown as DeleteAccountUseCase, + mockCloseAccountUseCase as unknown as CloseAccountUseCase, + mockOpenAccountUseCase as unknown as OpenAccountUseCase, ); beforeEach(async () => { @@ -206,4 +218,48 @@ describe('AccountController', () => { expect(mockArchiveAccountUseCase.execute).not.toHaveBeenCalled(); }); }); + + describe('closeAccount', () => { + it('should call closeAccount use case with correct account id and user', async () => { + mockCloseAccountUseCase.execute.mockResolvedValue(undefined); + + await accountController.closeAccount(user, { id: accountId }); + + expect(mockCloseAccountUseCase.execute).toHaveBeenCalledWith( + user, + accountId, + ); + expect(mockCloseAccountUseCase.execute).toHaveBeenCalledTimes(1); + }); + + it('should throw ZodError for invalid request params', async () => { + await expect( + accountController.closeAccount(user, { id: 'not-a-uuid' }), + ).rejects.toThrow(ZodError); + + expect(mockCloseAccountUseCase.execute).not.toHaveBeenCalled(); + }); + }); + + describe('openAccount', () => { + it('should call openAccount use case with correct account id and user', async () => { + mockOpenAccountUseCase.execute.mockResolvedValue(undefined); + + await accountController.openAccount(user, { id: accountId }); + + expect(mockOpenAccountUseCase.execute).toHaveBeenCalledWith( + user, + accountId, + ); + expect(mockOpenAccountUseCase.execute).toHaveBeenCalledTimes(1); + }); + + it('should throw ZodError for invalid request params', async () => { + await expect( + accountController.openAccount(user, { id: 'not-a-uuid' }), + ).rejects.toThrow(ZodError); + + expect(mockOpenAccountUseCase.execute).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/backend/src/presentation/http/accounts/account.controller.ts b/apps/backend/src/presentation/http/accounts/account.controller.ts index 630bd5ac..3d04b0c4 100644 --- a/apps/backend/src/presentation/http/accounts/account.controller.ts +++ b/apps/backend/src/presentation/http/accounts/account.controller.ts @@ -5,10 +5,12 @@ import { uniqueIdSchema, } from '@ledgerly/shared/validation'; import { + CloseAccountUseCase, DeleteAccountUseCase, CreateAccountUseCase, GetAccountByIdUseCase, GetAllAccountsUseCase, + OpenAccountUseCase, UpdateAccountUseCase, } from 'src/application/usecases/accounts'; import { User } from 'src/domain/users/user.entity'; @@ -20,6 +22,8 @@ export class AccountController { private readonly createAccountUseCase: CreateAccountUseCase, private readonly updateAccountUseCase: UpdateAccountUseCase, private readonly deleteAccountUseCase: DeleteAccountUseCase, + private readonly closeAccountUseCase: CloseAccountUseCase, + private readonly openAccountUseCase: OpenAccountUseCase, ) {} async getAll(user: User, query: unknown) { @@ -51,4 +55,16 @@ export class AccountController { return this.deleteAccountUseCase.execute(user, id); } + + async closeAccount(user: User, requestParams: unknown) { + const { id } = uniqueIdSchema.parse(requestParams); + + return this.closeAccountUseCase.execute(user, id); + } + + async openAccount(user: User, requestParams: unknown) { + const { id } = uniqueIdSchema.parse(requestParams); + + return this.openAccountUseCase.execute(user, id); + } } 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 c00708ea..dfcad477 100644 --- a/apps/backend/src/presentation/http/accounts/account.integration.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.integration.test.ts @@ -293,6 +293,197 @@ describe('Accounts Integration Tests', () => { }); }); + describe('POST /api/accounts/:id/close', () => { + it('should return 200 and close an open account by id', async () => { + const accountToClose = accounts.find( + (account) => !account.isClosed && !account.isTombstone, + ); + + if (!accountToClose) { + throw new Error('No account available to close'); + } + + const response = await injectAuthorized({ + method: 'POST', + url: `${url}/${accountToClose.id}/close`, + }); + + const closedAccount = JSON.parse(response.body) as AccountResponseDTO; + + expect(response.statusCode).toBe(200); + expect(closedAccount).toMatchObject({ + id: accountToClose.id, + isClosed: true, + }); + + const finalResponse = await injectAuthorized({ + method: 'GET', + url: `${url}/${accountToClose.id}`, + }); + + const retrievedAccount = JSON.parse( + finalResponse.body, + ) as AccountResponseDTO; + + expect(finalResponse.statusCode).toBe(200); + expect(retrievedAccount.isClosed).toBe(true); + }); + + it('should be idempotent for an already closed account', async () => { + const closedAccount = accounts.find( + (account) => account.isClosed && !account.isTombstone, + ); + + if (!closedAccount) { + throw new Error('No closed account available'); + } + + const response = await injectAuthorized({ + method: 'POST', + url: `${url}/${closedAccount.id}/close`, + }); + + const account = JSON.parse(response.body) as AccountResponseDTO; + + expect(response.statusCode).toBe(200); + expect(account).toMatchObject({ + id: closedAccount.id, + isClosed: true, + }); + }); + + it('should return 404 when account belongs to a different user', async () => { + const otherUserCommodity = await testDB.createCommodity(otherUserId); + const otherUserAccount = await testDB.createAccount( + otherUserId, + otherUserCommodity.id, + ); + + const response = await injectAuthorized({ + method: 'POST', + url: `${url}/${otherUserAccount.id}/close`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 404 when account is deleted', async () => { + const deletedAccount = accounts.find((account) => account.isTombstone); + + const response = await injectAuthorized({ + method: 'POST', + url: `${url}/${deletedAccount?.id}/close`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 400 when account id has an invalid UUID format', async () => { + const response = await injectAuthorized({ + method: 'POST', + url: `${url}/invalid-uuid/close`, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe('POST /api/accounts/:id/open', () => { + it('should return 200 and open a closed account by id', async () => { + const accountToOpen = accounts.find( + (account) => account.isClosed && !account.isTombstone, + ); + + if (!accountToOpen) { + throw new Error('No account available to open'); + } + + const response = await injectAuthorized({ + method: 'POST', + url: `${url}/${accountToOpen.id}/open`, + }); + + const openedAccount = JSON.parse(response.body) as AccountResponseDTO; + + expect(response.statusCode).toBe(200); + expect(openedAccount).toMatchObject({ + id: accountToOpen.id, + isClosed: false, + }); + + const finalResponse = await injectAuthorized({ + method: 'GET', + url: `${url}/${accountToOpen.id}`, + }); + + const retrievedAccount = JSON.parse( + finalResponse.body, + ) as AccountResponseDTO; + + expect(finalResponse.statusCode).toBe(200); + expect(retrievedAccount.isClosed).toBe(false); + }); + + it('should be idempotent for an already open account', async () => { + const openAccount = accounts.find( + (account) => !account.isClosed && !account.isTombstone, + ); + + if (!openAccount) { + throw new Error('No open account available'); + } + + const response = await injectAuthorized({ + method: 'POST', + url: `${url}/${openAccount.id}/open`, + }); + + const account = JSON.parse(response.body) as AccountResponseDTO; + + expect(response.statusCode).toBe(200); + expect(account).toMatchObject({ + id: openAccount.id, + isClosed: false, + }); + }); + + it('should return 404 when account belongs to a different user', async () => { + const otherUserCommodity = await testDB.createCommodity(otherUserId); + const otherUserAccount = await testDB.createAccount( + otherUserId, + otherUserCommodity.id, + { isClosed: true }, + ); + + const response = await injectAuthorized({ + method: 'POST', + url: `${url}/${otherUserAccount.id}/open`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 404 when account is deleted', async () => { + const deletedAccount = accounts.find((account) => account.isTombstone); + + const response = await injectAuthorized({ + method: 'POST', + url: `${url}/${deletedAccount?.id}/open`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 400 when account id has an invalid UUID format', async () => { + const response = await injectAuthorized({ + method: 'POST', + url: `${url}/invalid-uuid/open`, + }); + + expect(response.statusCode).toBe(400); + }); + }); + describe('PATCH /api/accounts/:id', () => { it('should return 200 and update an account by id', async () => { const accountToUpdate = accounts.find( diff --git a/apps/backend/src/presentation/http/accounts/account.routes.ts b/apps/backend/src/presentation/http/accounts/account.routes.ts index bfa93b65..d8b48cfb 100644 --- a/apps/backend/src/presentation/http/accounts/account.routes.ts +++ b/apps/backend/src/presentation/http/accounts/account.routes.ts @@ -30,6 +30,22 @@ export const accountsRoutes = (app: FastifyInstance) => { reply.status(204).send(); }); + app.post('/:id/close', async (request, reply) => { + const user = request.user; + + const account = await accountController.closeAccount(user, request.params); + + reply.status(200).send(account); + }); + + app.post('/:id/open', async (request, reply) => { + const user = request.user; + + const account = await accountController.openAccount(user, request.params); + + reply.status(200).send(account); + }); + app.patch('/:id', async (request, reply) => { const user = request.user; 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 4bef1cb0..27211887 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 @@ -93,11 +93,11 @@ Every normal account list filter excludes tombstoned accounts. | `closed` | `isClosed = true` and `isTombstone = false` | | `all` | `isTombstone = false` | -Account close and reopen are exposed as explicit HTTP actions: +Account close and open are exposed as explicit HTTP actions: ```http POST /accounts/:id/close -POST /accounts/:id/reopen +POST /accounts/:id/open ``` `PATCH /accounts/:id` remains for editable account attributes, not lifecycle @@ -274,7 +274,7 @@ The intended public domain methods are: - [LED-123: Define entity lifecycle states](https://gorushkin.atlassian.net/browse/LED-123) - [LED-122: Define terminal tombstone and reversible archive/close states](https://gorushkin.atlassian.net/browse/LED-122) -- [LED-124: Implement Account close reopen and terminal delete](https://gorushkin.atlassian.net/browse/LED-124) +- [LED-124: Implement Account close open and terminal delete](https://gorushkin.atlassian.net/browse/LED-124) - [LED-125: Implement Commodity archive unarchive and terminal delete](https://gorushkin.atlassian.net/browse/LED-125) - [ADR 0011: Domain Entity API Conventions](./0011-domain-entity-api-conventions.md) - [ADR 0015: Domain Restoration Factory Naming](./0015-domain-restoration-factory-naming.md) From 185ec3bf458c22263389e8f8b8326255a933367e Mon Sep 17 00:00:00 2001 From: gorushkin Date: Fri, 31 Jul 2026 17:01:44 +0300 Subject: [PATCH 08/31] LED-124 fix(accounts): align closed account update rules --- .../transaction.context-loader.test.ts | 7 ++- .../transaction.context-loader.ts | 11 ++++- .../accounts/__tests__/updateAccount.test.ts | 35 +++++++++++++ .../usecases/accounts/updateAccount.ts | 2 +- apps/backend/src/domain/domain.errors.test.ts | 12 +++++ .../http/accounts/account.integration.test.ts | 49 +++++++++++++++++++ ...-tombstone-and-reversible-entity-states.md | 2 +- .../shared/src/validation/accounts.test.ts | 8 +-- 8 files changed, 116 insertions(+), 10 deletions(-) diff --git a/apps/backend/src/application/services/TransactionService/__tests__/transaction.context-loader.test.ts b/apps/backend/src/application/services/TransactionService/__tests__/transaction.context-loader.test.ts index 566124b7..61de2cb0 100644 --- a/apps/backend/src/application/services/TransactionService/__tests__/transaction.context-loader.test.ts +++ b/apps/backend/src/application/services/TransactionService/__tests__/transaction.context-loader.test.ts @@ -3,7 +3,10 @@ import type { AccountRepositoryInterface } from 'src/application/interfaces'; import { createAccount } from 'src/db/createTestUser'; import { Account, User } from 'src/domain'; import { Amount } from 'src/domain/domain-core'; -import { DeletedEntityOperationError } from 'src/domain/domain.errors'; +import { + ClosedAccountOperationError, + DeletedEntityOperationError, +} from 'src/domain/domain.errors'; import { createUser } from 'src/testing'; import { beforeAll, describe, expect, it, vi, beforeEach } from 'vitest'; @@ -205,7 +208,7 @@ describe('TransactionContextLoader', () => { await expect( transactionContextLoader.loadContext(user, rawOperations), ).rejects.toThrowError( - new DeletedEntityOperationError(Account.entityType, 'use'), + ClosedAccountOperationError.forUse(account.getId().valueOf()), ); }); diff --git a/apps/backend/src/application/services/TransactionService/transaction.context-loader.ts b/apps/backend/src/application/services/TransactionService/transaction.context-loader.ts index d184b1f4..0fc814f1 100644 --- a/apps/backend/src/application/services/TransactionService/transaction.context-loader.ts +++ b/apps/backend/src/application/services/TransactionService/transaction.context-loader.ts @@ -1,6 +1,9 @@ import { UUID } from '@ledgerly/shared/types'; import { Account, User } from 'src/domain'; -import { DeletedEntityOperationError } from 'src/domain/domain.errors'; +import { + ClosedAccountOperationError, + DeletedEntityOperationError, +} from 'src/domain/domain.errors'; import { TransactionBuildContext } from 'src/domain/transactions/types'; import { OperationRequestDTO } from '../../dto'; @@ -32,10 +35,14 @@ export class TransactionContextLoader { for (const snapshot of accountRows) { const account = Account.restore(snapshot); - if (account.isDeleted() || account.closed) { + if (account.isDeleted()) { throw DeletedEntityOperationError.forUse(Account.entityType); } + if (account.closed) { + throw ClosedAccountOperationError.forUse(account.getId().valueOf()); + } + accountsMap.set(snapshot.id, account); } 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 1def413a..33623c41 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts @@ -202,6 +202,41 @@ describe('UpdateAccount', async () => { expect(mockAccountRepository.update).not.toHaveBeenCalled(); }); + it('should allow updating name and description when account is closed', async () => { + mockAccountRepository.getById.mockResolvedValue({ + ...mockAccountData, + isClosed: true, + }); + + const result = await updateAccountUseCase.execute(user, accountId, { + description: 'Updated description', + name: 'Updated Account', + }); + + expect(mockAccountRepository.getById).toHaveBeenCalledWith( + user.getId().valueOf(), + accountId, + ); + + expect( + mockAccountOperationPolicy.assertNoActiveOperations, + ).not.toHaveBeenCalled(); + + expect(mockAccountRepository.update).toHaveBeenCalledWith( + user.getId().valueOf(), + accountId, + expect.objectContaining({ + description: 'Updated description', + isClosed: true, + name: 'Updated Account', + }), + ); + + expect(result.name).toBe('Updated Account'); + expect(result.description).toBe('Updated description'); + expect(result.isClosed).toBe(true); + }); + it('should call assertNoActiveOperations when account type is changed', async () => { mockAccountRepository.getById.mockResolvedValue(mockAccountData); diff --git a/apps/backend/src/application/usecases/accounts/updateAccount.ts b/apps/backend/src/application/usecases/accounts/updateAccount.ts index 6663ea3b..d075b08d 100644 --- a/apps/backend/src/application/usecases/accounts/updateAccount.ts +++ b/apps/backend/src/application/usecases/accounts/updateAccount.ts @@ -30,7 +30,7 @@ export class UpdateAccountUseCase extends AccountUseCaseBase { account: Account, data: AccountUpdateDTO, ): Promise { - if (account.closed) { + if (account.closed && data.type !== undefined) { throw ClosedAccountOperationError.forUpdate(accountId); } diff --git a/apps/backend/src/domain/domain.errors.test.ts b/apps/backend/src/domain/domain.errors.test.ts index 5a507cd9..e8b914a5 100644 --- a/apps/backend/src/domain/domain.errors.test.ts +++ b/apps/backend/src/domain/domain.errors.test.ts @@ -62,6 +62,18 @@ describe('coded domain errors', () => { }); }); + it('exposes CLOSED_ACCOUNT_OPERATION context for use operations', () => { + const accountId = '550e8400-e29b-41d4-a716-446655440001' as never; + + expect(ClosedAccountOperationError.forUse(accountId)).toMatchObject({ + code: apiErrorCodes.closedAccountOperation, + context: { + accountId, + operation: 'use', + }, + }); + }); + it('exposes INVALID_VERSION context', () => { expect(new InvalidVersionError(-1)).toMatchObject({ code: apiErrorCodes.invalidVersion, 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 dfcad477..ca63218d 100644 --- a/apps/backend/src/presentation/http/accounts/account.integration.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.integration.test.ts @@ -520,6 +520,55 @@ describe('Accounts Integration Tests', () => { expect(accountsAfterUpdate).toContainEqual(updatedAccount); }); + + it('should return 200 and update name and description for a closed account', async () => { + const accountToUpdate = accounts.find( + (account) => account.isClosed && !account.isTombstone, + ); + + if (!accountToUpdate) { + throw new Error('No closed account available for update'); + } + + const updatedData = { + description: 'Updated closed account description', + name: 'Updated Closed Account', + }; + + const response = await injectAuthorized({ + method: 'PATCH', + payload: updatedData, + url: `${url}/${accountToUpdate.id}`, + }); + + const updatedAccount = JSON.parse(response.body) as AccountResponseDTO; + + expect(response.statusCode).toBe(200); + expect(updatedAccount).toMatchObject({ + description: updatedData.description, + id: accountToUpdate.id, + isClosed: true, + name: updatedData.name, + }); + }); + + it('should return 409 when updating type for a closed account', async () => { + const accountToUpdate = accounts.find( + (account) => account.isClosed && !account.isTombstone, + ); + + if (!accountToUpdate) { + throw new Error('No closed account available for update'); + } + + const response = await injectAuthorized({ + method: 'PATCH', + payload: { type: 'liability' as AccountTypeValue }, + url: `${url}/${accountToUpdate.id}`, + }); + + expect(response.statusCode).toBe(409); + }); }); describe('Authentication', () => { 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 27211887..fa5e8b5a 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 @@ -267,7 +267,7 @@ The intended public domain methods are: records should remain hidden from normal reads. - Tests must cover tombstone terminality and business-state invariants: tombstoned entities cannot be restored or modified; closed accounts cannot - receive new operations and cannot be updated through the regular update flow; + receive new operations and can only update descriptive account fields; archived commodities cannot be selected for new active links. ## Related diff --git a/packages/shared/src/validation/accounts.test.ts b/packages/shared/src/validation/accounts.test.ts index 620d0593..074c0eee 100644 --- a/packages/shared/src/validation/accounts.test.ts +++ b/packages/shared/src/validation/accounts.test.ts @@ -37,13 +37,13 @@ describe("accountUpdateSchema", () => { }); describe("accountQuerySchema", () => { - it("defaults omitted closed filter to open accounts", () => { - expect(accountQuerySchema.parse({})).toEqual({ closed: "open" }); + it("defaults omitted status filter to open accounts", () => { + expect(accountQuerySchema.parse({})).toEqual({ status: "open" }); }); it("allows querying all accounts", () => { - expect(accountQuerySchema.parse({ closed: "all" })).toEqual({ - closed: "all", + expect(accountQuerySchema.parse({ status: "all" })).toEqual({ + status: "all", }); }); }); From 3580ec606ab25bb83b7269a5dd0f76641ffa319e Mon Sep 17 00:00:00 2001 From: gorushkin Date: Fri, 31 Jul 2026 17:20:31 +0300 Subject: [PATCH 09/31] LED-124 fix(accounts): address lifecycle review feedback --- .../usecases/accounts/getAllAccounts.ts | 2 +- .../domain-core/behaviors/SoftDelete.ts | 6 ++- .../behaviors/__tests__/SoftDelete.test.ts | 2 +- .../db/accounts/account.repository.ts | 51 +++++++++---------- .../http/accounts/account.controller.test.ts | 2 +- .../http/accounts/account.integration.test.ts | 2 +- .../shared/src/validation/accounts.test.ts | 16 ++++++ packages/shared/src/validation/accounts.ts | 4 +- 8 files changed, 53 insertions(+), 32 deletions(-) diff --git a/apps/backend/src/application/usecases/accounts/getAllAccounts.ts b/apps/backend/src/application/usecases/accounts/getAllAccounts.ts index 5f5a813f..57c60a82 100644 --- a/apps/backend/src/application/usecases/accounts/getAllAccounts.ts +++ b/apps/backend/src/application/usecases/accounts/getAllAccounts.ts @@ -1,5 +1,5 @@ import { AccountResponseDTO } from '@ledgerly/shared/types'; -import { AccountQuery } from 'node_modules/@ledgerly/shared/src/validation/accounts'; +import { AccountQuery } from '@ledgerly/shared/validation'; import type { AccountRepositoryInterface } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { User } from 'src/domain/users/user.entity'; diff --git a/apps/backend/src/domain/domain-core/behaviors/SoftDelete.ts b/apps/backend/src/domain/domain-core/behaviors/SoftDelete.ts index efc36672..f7bdff37 100644 --- a/apps/backend/src/domain/domain-core/behaviors/SoftDelete.ts +++ b/apps/backend/src/domain/domain-core/behaviors/SoftDelete.ts @@ -10,7 +10,7 @@ export class SoftDelete { * Marks the entity as deleted */ markAsDeleted(error?: Error): SoftDelete { - this.validateUpdateIsAllowed(error ?? SoftDelete.defaultUpdateError()); + this.validateUpdateIsAllowed(error ?? SoftDelete.defaultDeleteError()); return new SoftDelete(true); } @@ -41,6 +41,10 @@ export class SoftDelete { return new Error('Cannot update a deleted entity'); } + static defaultDeleteError(): Error { + return new Error('Cannot delete a deleted entity'); + } + /** * Creates a new instance of SoftDelete */ diff --git a/apps/backend/src/domain/domain-core/behaviors/__tests__/SoftDelete.test.ts b/apps/backend/src/domain/domain-core/behaviors/__tests__/SoftDelete.test.ts index 33658339..eb80d730 100644 --- a/apps/backend/src/domain/domain-core/behaviors/__tests__/SoftDelete.test.ts +++ b/apps/backend/src/domain/domain-core/behaviors/__tests__/SoftDelete.test.ts @@ -62,7 +62,7 @@ describe('SoftDelete', () => { const deleted = SoftDelete.create().markAsDeleted(); expect(() => deleted.markAsDeleted()).toThrow( - SoftDelete.defaultUpdateError(), + SoftDelete.defaultDeleteError(), ); }); diff --git a/apps/backend/src/infrastructure/db/accounts/account.repository.ts b/apps/backend/src/infrastructure/db/accounts/account.repository.ts index 3f5048f5..3d0cff42 100644 --- a/apps/backend/src/infrastructure/db/accounts/account.repository.ts +++ b/apps/backend/src/infrastructure/db/accounts/account.repository.ts @@ -14,6 +14,7 @@ import { AccountSnapshot } from 'src/domain/accounts'; import { AccountPersistenceConflictError, RepositoryInvariantError, + RepositoryNotFoundError, } from 'src/infrastructure/errors'; import { BaseRepository } from '../BaseRepository'; @@ -45,7 +46,7 @@ const getWhereClauseForGetAll = (userId: UUID, query: AccountQuery) => { ); }; -const lifeCycleMapper: Record< +const lifecycleMapper: Record< LifecycleAction, { name: string; params: Record } > = { @@ -57,6 +58,13 @@ export class AccountRepository extends BaseRepository implements AccountRepositoryInterface { + private accountNotFoundError(id: UUID): RepositoryNotFoundError { + return new RepositoryNotFoundError( + `Account with ID ${id} not found`, + this.entityNotFoundContext('account', id), + ); + } + async getAll(userId: UUID, query: AccountQuery): Promise { return this.executeDatabaseOperation(async () => { const whereClause = getWhereClauseForGetAll(userId, query); @@ -182,7 +190,7 @@ export class AccountRepository 'updatedAt', ]); - const updatedAccount = await this.db + const result = await this.db .update(accountsTable) .set(safeData) .where( @@ -191,14 +199,11 @@ export class AccountRepository eq(accountsTable.userId, userId), eq(accountsTable.isTombstone, false), ), - ) - .returning() - .get(); + ); - this.ensureEntityExists( - updatedAccount, - `Account with ID ${id} not found`, - this.entityNotFoundContext('account', id), + this.ensureRowsAffected( + result.rowsAffected, + this.accountNotFoundError(id), ); }, `Failed to update account with ID ${id}`, @@ -216,9 +221,9 @@ export class AccountRepository data: AccountLifecycleUpdateInput, ): Promise { const { action, updatedAt } = data; - const { name, params } = lifeCycleMapper[action]; + const { name, params } = lifecycleMapper[action]; return this.executeDatabaseOperation(async () => { - const updatedAccount = await this.db + const result = await this.db .update(accountsTable) .set({ ...params, updatedAt }) .where( @@ -227,14 +232,11 @@ export class AccountRepository eq(accountsTable.userId, userId), eq(accountsTable.isTombstone, false), ), - ) - .returning() - .get(); + ); - this.ensureEntityExists( - updatedAccount, - `Account with ID ${id} not found`, - this.entityNotFoundContext('account', id), + this.ensureRowsAffected( + result.rowsAffected, + this.accountNotFoundError(id), ); // No return value for lifecycle updates @@ -269,7 +271,7 @@ export class AccountRepository data: AccountRepositoryLifecycleInput, ): Promise { return this.executeDatabaseOperation(async () => { - const deletedAccount = await this.db + const result = await this.db .update(accountsTable) .set({ isTombstone: true, updatedAt: data.updatedAt }) .where( @@ -278,14 +280,11 @@ export class AccountRepository eq(accountsTable.userId, userId), eq(accountsTable.isTombstone, false), ), - ) - .returning() - .get(); + ); - this.ensureEntityExists( - deletedAccount, - `Account with ID ${id} not found`, - this.entityNotFoundContext('account', id), + this.ensureRowsAffected( + result.rowsAffected, + this.accountNotFoundError(id), ); }, `Failed to soft delete account with ID ${id}`); } 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 5cb5c854..e8f155d2 100644 --- a/apps/backend/src/presentation/http/accounts/account.controller.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.controller.test.ts @@ -1,5 +1,5 @@ import { AccountCreateDTO, AccountUpdateDTO } from '@ledgerly/shared/types'; -import { AccountQuery } from 'node_modules/@ledgerly/shared/src/validation/accounts'; +import { AccountQuery } from '@ledgerly/shared/validation'; import { CloseAccountUseCase, DeleteAccountUseCase, 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 ca63218d..0d6e9edf 100644 --- a/apps/backend/src/presentation/http/accounts/account.integration.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.integration.test.ts @@ -281,7 +281,7 @@ describe('Accounts Integration Tests', () => { const finalResponse = await injectAuthorized({ method: 'GET', - url: `${url}/?status=all`, + url: `${url}?status=all`, }); const accountsAfterDeletion = JSON.parse( diff --git a/packages/shared/src/validation/accounts.test.ts b/packages/shared/src/validation/accounts.test.ts index 074c0eee..de754b7b 100644 --- a/packages/shared/src/validation/accounts.test.ts +++ b/packages/shared/src/validation/accounts.test.ts @@ -34,6 +34,22 @@ describe("accountUpdateSchema", () => { description: "", }); }); + + it("requires at least one field for update", () => { + const result = accountUpdateSchema.safeParse({}); + + expect(result.success).toBe(false); + if (result.success) { + throw new Error("Expected empty account update to fail validation"); + } + + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + message: "At least one field must be provided for update", + path: [], + }), + ); + }); }); describe("accountQuerySchema", () => { diff --git a/packages/shared/src/validation/accounts.ts b/packages/shared/src/validation/accounts.ts index 45c14b42..02c72897 100644 --- a/packages/shared/src/validation/accounts.ts +++ b/packages/shared/src/validation/accounts.ts @@ -30,7 +30,9 @@ export const accountUpdateSchema = z type: accountType.optional(), }) .strict() - .refine((data) => Object.keys(data).length > 0); + .refine((data) => Object.keys(data).length > 0, { + message: "At least one field must be provided for update", + }); export const accountQuerySchema = z.object({ status: z.enum(ACCOUNT_STATUS_FILTER_VALUES).default("open"), From 8a30d5c5173983c32c8ec0ac461f99485d92c6fe Mon Sep 17 00:00:00 2001 From: gorushkin Date: Fri, 31 Jul 2026 17:31:05 +0300 Subject: [PATCH 10/31] LED-124 fix(accounts): use public imports and refresh migration baseline --- ...0_last_moonstone.sql => 0000_clammy_invisible_woman.sql} | 0 apps/backend/drizzle/meta/0000_snapshot.json | 2 +- apps/backend/drizzle/meta/_journal.json | 4 ++-- apps/backend/src/application/mappers/account.mapper.ts | 2 +- .../usecases/accounts/__tests__/deleteAccount.test.ts | 2 +- .../usecases/accounts/__tests__/getAccountById.test.ts | 3 +-- .../usecases/accounts/__tests__/getAllAccounts.test.ts | 2 +- .../usecases/accounts/__tests__/updateAccount.test.ts | 5 ++--- .../src/application/usecases/accounts/closeAccount.ts | 2 +- .../src/application/usecases/accounts/deleteAccount.ts | 4 ++-- .../src/application/usecases/accounts/openAccount.ts | 2 +- .../src/application/usecases/accounts/updateAccount.ts | 2 +- apps/backend/src/di/container.ts | 6 ++++-- apps/backend/src/domain/accounts/index.ts | 6 +++++- 14 files changed, 23 insertions(+), 19 deletions(-) rename apps/backend/drizzle/{0000_last_moonstone.sql => 0000_clammy_invisible_woman.sql} (100%) diff --git a/apps/backend/drizzle/0000_last_moonstone.sql b/apps/backend/drizzle/0000_clammy_invisible_woman.sql similarity index 100% rename from apps/backend/drizzle/0000_last_moonstone.sql rename to apps/backend/drizzle/0000_clammy_invisible_woman.sql diff --git a/apps/backend/drizzle/meta/0000_snapshot.json b/apps/backend/drizzle/meta/0000_snapshot.json index 0e5457e6..ba900993 100644 --- a/apps/backend/drizzle/meta/0000_snapshot.json +++ b/apps/backend/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "cadceef3-f143-4738-b7d1-873315093770", + "id": "72b37ad6-4dd9-43c4-8017-7b838aee4e7b", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "accounts": { diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index 477d247d..addab667 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1785157573050, - "tag": "0000_last_moonstone", + "when": 1785508223175, + "tag": "0000_clammy_invisible_woman", "breakpoints": true } ] diff --git a/apps/backend/src/application/mappers/account.mapper.ts b/apps/backend/src/application/mappers/account.mapper.ts index 910e664a..68461296 100644 --- a/apps/backend/src/application/mappers/account.mapper.ts +++ b/apps/backend/src/application/mappers/account.mapper.ts @@ -7,8 +7,8 @@ import { AccountSnapshot, AccountType, AccountUpdateProps, + CreateAccountProps, } from 'src/domain/accounts'; -import { CreateAccountProps } from 'src/domain/accounts/types'; import { Amount, Id, Name } from 'src/domain/domain-core'; export class AccountMapper { 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 1121ae8b..5fedb83e 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/deleteAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/deleteAccount.test.ts @@ -8,7 +8,7 @@ import type { TransactionManagerInterface, } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; -import { AccountOperationPolicy } from 'src/application/services/AccountOperationPolicy/account-operation.policy'; +import { AccountOperationPolicy } from 'src/application/services'; import { createUser } from 'src/db/createTestUser'; import { AccountSnapshot } from 'src/domain/accounts'; import { Amount, Timestamp } from 'src/domain/domain-core'; 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 34ec28f1..3f914572 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/getAccountById.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/getAccountById.test.ts @@ -6,8 +6,7 @@ import { import type { AccountRepositoryInterface } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { createUser } from 'src/db/createTestUser'; -import { Account } from 'src/domain/accounts/account.entity'; -import { AccountSnapshot } from 'src/domain/accounts/types'; +import { Account, AccountSnapshot } from 'src/domain/accounts'; import { Amount, Timestamp } from 'src/domain/domain-core'; import { Id } from 'src/domain/domain-core/value-objects/Id'; import { beforeEach, describe, expect, it, vi } from 'vitest'; 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 4c51406c..9827daca 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/getAllAccounts.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/getAllAccounts.test.ts @@ -3,7 +3,7 @@ import { AccountQuery } from '@ledgerly/shared/validation'; 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/types'; +import { AccountSnapshot } from 'src/domain/accounts'; import { Amount, Timestamp } from 'src/domain/domain-core'; import { Id } from 'src/domain/domain-core/value-objects/Id'; import { beforeEach, describe, expect, it, vi } from 'vitest'; 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 33623c41..9e504e7f 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/updateAccount.test.ts @@ -8,10 +8,9 @@ import type { TransactionManagerInterface, } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; -import { AccountOperationPolicy } from 'src/application/services/AccountOperationPolicy/account-operation.policy'; +import { AccountOperationPolicy } from 'src/application/services'; import { createUser } from 'src/db/createTestUser'; -import { Account } from 'src/domain/accounts/account.entity'; -import { AccountSnapshot } from 'src/domain/accounts/types'; +import { Account, AccountSnapshot } from 'src/domain/accounts'; import { Amount, Timestamp } from 'src/domain/domain-core'; import { Id } from 'src/domain/domain-core/value-objects/Id'; import { ClosedAccountOperationError } from 'src/domain/domain.errors'; diff --git a/apps/backend/src/application/usecases/accounts/closeAccount.ts b/apps/backend/src/application/usecases/accounts/closeAccount.ts index 135df761..da21f26a 100644 --- a/apps/backend/src/application/usecases/accounts/closeAccount.ts +++ b/apps/backend/src/application/usecases/accounts/closeAccount.ts @@ -4,7 +4,7 @@ import type { TransactionManagerInterface, } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; -import { Account } from 'src/domain/accounts/account.entity'; +import { Account } from 'src/domain/accounts'; import { User } from 'src/domain/users/user.entity'; import { AccountUseCaseBase } from './accountBase'; diff --git a/apps/backend/src/application/usecases/accounts/deleteAccount.ts b/apps/backend/src/application/usecases/accounts/deleteAccount.ts index c446139b..86195bff 100644 --- a/apps/backend/src/application/usecases/accounts/deleteAccount.ts +++ b/apps/backend/src/application/usecases/accounts/deleteAccount.ts @@ -4,8 +4,8 @@ import type { TransactionManagerInterface, } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; -import { AccountOperationPolicy } from 'src/application/services/'; -import { Account } from 'src/domain/accounts/account.entity'; +import { AccountOperationPolicy } from 'src/application/services'; +import { Account } from 'src/domain/accounts'; import { User } from 'src/domain/users/user.entity'; import { AccountUseCaseBase } from './accountBase'; diff --git a/apps/backend/src/application/usecases/accounts/openAccount.ts b/apps/backend/src/application/usecases/accounts/openAccount.ts index 6d7de40e..83d06458 100644 --- a/apps/backend/src/application/usecases/accounts/openAccount.ts +++ b/apps/backend/src/application/usecases/accounts/openAccount.ts @@ -4,7 +4,7 @@ import type { TransactionManagerInterface, } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; -import { Account } from 'src/domain/accounts/account.entity'; +import { Account } from 'src/domain/accounts'; import { User } from 'src/domain/users/user.entity'; import { AccountUseCaseBase } from './accountBase'; diff --git a/apps/backend/src/application/usecases/accounts/updateAccount.ts b/apps/backend/src/application/usecases/accounts/updateAccount.ts index d075b08d..3e6db2c8 100644 --- a/apps/backend/src/application/usecases/accounts/updateAccount.ts +++ b/apps/backend/src/application/usecases/accounts/updateAccount.ts @@ -8,7 +8,7 @@ import type { TransactionManagerInterface, } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; -import { AccountOperationPolicy } from 'src/application/services/'; +import { AccountOperationPolicy } from 'src/application/services'; import { Account } from 'src/domain/accounts'; import { ClosedAccountOperationError } from 'src/domain/domain.errors'; import { User } from 'src/domain/users/user.entity'; diff --git a/apps/backend/src/di/container.ts b/apps/backend/src/di/container.ts index f43b6846..8f3ead4c 100644 --- a/apps/backend/src/di/container.ts +++ b/apps/backend/src/di/container.ts @@ -19,8 +19,10 @@ import { ArchiveCommodityUseCase, CreateCommodityUseCase, } from 'src/application'; -import { AccountOperationPolicy } from 'src/application/services/AccountOperationPolicy/account-operation.policy'; -import { TransactionContextLoader } from 'src/application/services/TransactionService'; +import { + AccountOperationPolicy, + TransactionContextLoader, +} from 'src/application/services'; import { ensureEntityExistsAndOwned } from 'src/application/shared/ensureEntityExistsAndOwned'; import { ensureOwnedSnapshot } from 'src/application/shared/ensureOwnedSnapshot'; import { DataBase } from 'src/db'; diff --git a/apps/backend/src/domain/accounts/index.ts b/apps/backend/src/domain/accounts/index.ts index 3e5e2888..52482916 100644 --- a/apps/backend/src/domain/accounts/index.ts +++ b/apps/backend/src/domain/accounts/index.ts @@ -1,3 +1,7 @@ export { Account } from './account.entity'; export { AccountType } from './account-type.enum'; -export type { AccountSnapshot, AccountUpdateProps } from './types'; +export type { + AccountSnapshot, + AccountUpdateProps, + CreateAccountProps, +} from './types'; From 4e38bc7f7faeefdbdbf9ad42544d1c639031ff42 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Fri, 31 Jul 2026 18:42:10 +0300 Subject: [PATCH 11/31] LED-124 fix(accounts): check account before delete policy --- .../usecases/accounts/__tests__/deleteAccount.test.ts | 4 ++++ .../src/application/usecases/accounts/deleteAccount.ts | 9 +++++---- 2 files changed, 9 insertions(+), 4 deletions(-) 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 5fedb83e..60b99869 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/deleteAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/deleteAccount.test.ts @@ -124,6 +124,7 @@ describe('DeleteAccountUseCase', async () => { }); it('should throw when account has active operations', async () => { + mockAccountRepository.getById.mockResolvedValue(mockAccountData); mockAccountOperationPolicy.assertNoActiveOperations.mockRejectedValue( new AccountHasActiveOperationsError(accountId), ); @@ -151,6 +152,9 @@ describe('DeleteAccountUseCase', async () => { user.getId().valueOf(), accountId, ); + expect( + mockAccountOperationPolicy.assertNoActiveOperations, + ).not.toHaveBeenCalled(); expect(mockAccountRepository.delete).not.toHaveBeenCalled(); }); diff --git a/apps/backend/src/application/usecases/accounts/deleteAccount.ts b/apps/backend/src/application/usecases/accounts/deleteAccount.ts index 86195bff..baab7d5c 100644 --- a/apps/backend/src/application/usecases/accounts/deleteAccount.ts +++ b/apps/backend/src/application/usecases/accounts/deleteAccount.ts @@ -21,15 +21,16 @@ export class DeleteAccountUseCase extends AccountUseCaseBase { async execute(user: User, accountId: UUID): Promise { const accountSnapshot = await this.transactionManager.run(async () => { - await this.accountOperationPolicy.assertNoActiveOperations( - user.getId().valueOf(), + const accountData = await this.ensureAccountExistsAndOwned( + user, accountId, ); - const accountData = await this.ensureAccountExistsAndOwned( - user, + await this.accountOperationPolicy.assertNoActiveOperations( + user.getId().valueOf(), accountId, ); + const account = Account.restore(accountData); const result = account.delete(); From bb263e1f43879bdd11a82ab7bfb586cfb1ca103c Mon Sep 17 00:00:00 2001 From: gorushkin Date: Tue, 4 Aug 2026 19:33:37 +0300 Subject: [PATCH 12/31] LED-123 feat(lifecycle): add entity lifecycle state fields --- .../drizzle/0001_adorable_star_brand.sql | 1 + apps/backend/drizzle/meta/0001_snapshot.json | 628 ++++++++++++++++++ apps/backend/drizzle/meta/_journal.json | 7 + .../application/mappers/commodity.mapper.ts | 1 + .../__tests__/createCommodity.test.ts | 1 + .../usecases/commodities/archiveCommodity.ts | 2 +- apps/backend/src/db/schemas/accounts.ts | 4 +- apps/backend/src/db/schemas/commodities.ts | 3 +- apps/backend/src/db/schemas/common.ts | 1 + apps/backend/src/db/test-db.ts | 3 + .../src/domain/accounts/account.entity.ts | 3 +- .../domain/commodities/commodity.entity.ts | 44 +- .../src/domain/commodities/commodity.test.ts | 110 ++- apps/backend/src/domain/commodities/types.ts | 1 + apps/backend/src/domain/domain-core/index.ts | 1 + .../src/domain/domain-core/transitions.ts | 1 + .../commodity-persistence.mapper.test.ts | 3 + .../commodity-persistence.mapper.ts | 2 + .../commodities/commodity.repository.test.ts | 3 + packages/shared/src/types/commodity.ts | 1 + 20 files changed, 789 insertions(+), 31 deletions(-) create mode 100644 apps/backend/drizzle/0001_adorable_star_brand.sql create mode 100644 apps/backend/drizzle/meta/0001_snapshot.json create mode 100644 apps/backend/src/domain/domain-core/transitions.ts diff --git a/apps/backend/drizzle/0001_adorable_star_brand.sql b/apps/backend/drizzle/0001_adorable_star_brand.sql new file mode 100644 index 00000000..60a3fb4d --- /dev/null +++ b/apps/backend/drizzle/0001_adorable_star_brand.sql @@ -0,0 +1 @@ +ALTER TABLE `commodities` ADD `is_closed` integer NOT NULL; \ No newline at end of file diff --git a/apps/backend/drizzle/meta/0001_snapshot.json b/apps/backend/drizzle/meta/0001_snapshot.json new file mode 100644 index 00000000..11b7f8f9 --- /dev/null +++ b/apps/backend/drizzle/meta/0001_snapshot.json @@ -0,0 +1,628 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e80e6eea-4e83-4ffe-a8d0-8d3a50183d1b", + "prevId": "72b37ad6-4dd9-43c4-8017-7b838aee4e7b", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "commodity_id": { + "name": "commodity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "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", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initial_balance": { + "name": "initial_balance", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_closed": { + "name": "is_closed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_tombstone": { + "name": "is_tombstone", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_name_unique_idx": { + "name": "user_id_name_unique_idx", + "columns": [ + "user_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "accounts_commodity_id_commodities_id_fk": { + "name": "accounts_commodity_id_commodities_id_fk", + "tableFrom": "accounts", + "tableTo": "commodities", + "columnsFrom": [ + "commodity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transactions": { + "name": "transactions", + "columns": { + "commodity_id": { + "name": "commodity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "is_tombstone": { + "name": "is_tombstone", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "posting_date": { + "name": "posting_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transaction_date": { + "name": "transaction_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_transactions_user_date": { + "name": "idx_transactions_user_date", + "columns": [ + "user_id", + "transaction_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "transactions_commodity_id_commodities_id_fk": { + "name": "transactions_commodity_id_commodities_id_fk", + "tableFrom": "transactions", + "tableTo": "commodities", + "columnsFrom": [ + "commodity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_user_id_users_id_fk": { + "name": "transactions_user_id_users_id_fk", + "tableFrom": "transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "operations": { + "name": "operations", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_tombstone": { + "name": "is_tombstone", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transaction_id": { + "name": "transaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_operations_transaction": { + "name": "idx_operations_transaction", + "columns": [ + "transaction_id" + ], + "isUnique": false + }, + "idx_operations_account": { + "name": "idx_operations_account", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "idx_operations_user": { + "name": "idx_operations_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "operations_account_id_accounts_id_fk": { + "name": "operations_account_id_accounts_id_fk", + "tableFrom": "operations", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "operations_transaction_id_transactions_id_fk": { + "name": "operations_transaction_id_transactions_id_fk", + "tableFrom": "operations", + "tableTo": "transactions", + "columnsFrom": [ + "transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "operations_user_id_users_id_fk": { + "name": "operations_user_id_users_id_fk", + "tableFrom": "operations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_users_id_fk": { + "name": "settings_user_id_users_id_fk", + "tableFrom": "settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commodities": { + "name": "commodities", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "is_closed": { + "name": "is_closed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_tombstone": { + "name": "is_tombstone", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "precision": { + "name": "precision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "symbol": { + "name": "symbol", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "commodities_user_id_code_unique_idx": { + "name": "commodities_user_id_code_unique_idx", + "columns": [ + "user_id", + "code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "commodities_user_id_users_id_fk": { + "name": "commodities_user_id_users_id_fk", + "tableFrom": "commodities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index addab667..a690704e 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1785508223175, "tag": "0000_clammy_invisible_woman", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1785859950401, + "tag": "0001_adorable_star_brand", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/backend/src/application/mappers/commodity.mapper.ts b/apps/backend/src/application/mappers/commodity.mapper.ts index 63bf9384..3a387f0b 100644 --- a/apps/backend/src/application/mappers/commodity.mapper.ts +++ b/apps/backend/src/application/mappers/commodity.mapper.ts @@ -17,6 +17,7 @@ export class CommodityMapper { code: snapshot.code, createdAt: snapshot.createdAt, id: snapshot.id, + isClosed: snapshot.isClosed, isTombstone: snapshot.isTombstone, name: snapshot.name, precision: snapshot.precision, 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 5f668610..5f4e14bd 100644 --- a/apps/backend/src/application/usecases/commodities/__tests__/createCommodity.test.ts +++ b/apps/backend/src/application/usecases/commodities/__tests__/createCommodity.test.ts @@ -46,6 +46,7 @@ describe('CreateCommodityUseCase', () => { code: result.code, createdAt: result.createdAt, id: result.id, + isClosed: false, isTombstone: false, name: result.name, precision: result.precision, diff --git a/apps/backend/src/application/usecases/commodities/archiveCommodity.ts b/apps/backend/src/application/usecases/commodities/archiveCommodity.ts index c17862f9..268b025a 100644 --- a/apps/backend/src/application/usecases/commodities/archiveCommodity.ts +++ b/apps/backend/src/application/usecases/commodities/archiveCommodity.ts @@ -30,7 +30,7 @@ export class ArchiveCommodityUseCase { const commodity = Commodity.restore(commodityData); - commodity.markAsDeleted(); + commodity.delete(); const updatedCommodity = await this.commodityRepository.softDelete( user.getId().valueOf(), diff --git a/apps/backend/src/db/schemas/accounts.ts b/apps/backend/src/db/schemas/accounts.ts index e36cc86a..5bf43f49 100644 --- a/apps/backend/src/db/schemas/accounts.ts +++ b/apps/backend/src/db/schemas/accounts.ts @@ -10,9 +10,9 @@ import { updatedAt, id, isTombstone, + isClosed, getAmountColumn, isSystem, - getBooleanColumn, } from './common'; import { usersTable } from './users'; @@ -30,7 +30,7 @@ export const accountsTable = sqliteTable( description, id, initialBalance: getAmountColumn('initial_balance'), - isClosed: getBooleanColumn('is_closed'), + isClosed, isSystem, isTombstone, name: text('name').notNull(), diff --git a/apps/backend/src/db/schemas/commodities.ts b/apps/backend/src/db/schemas/commodities.ts index 4a919a6e..a23e734b 100644 --- a/apps/backend/src/db/schemas/commodities.ts +++ b/apps/backend/src/db/schemas/commodities.ts @@ -12,7 +12,7 @@ import { uniqueIndex, } from 'drizzle-orm/sqlite-core'; -import { createdAt, updatedAt, id, isTombstone } from './common'; +import { createdAt, updatedAt, id, isClosed, isTombstone } from './common'; import { usersTable } from './users'; export const commoditiesTable = sqliteTable( @@ -21,6 +21,7 @@ export const commoditiesTable = sqliteTable( code: text('code').notNull().$type(), createdAt, id, + isClosed, isTombstone, name: text('name').notNull(), precision: integer('precision').notNull().$type(), diff --git a/apps/backend/src/db/schemas/common.ts b/apps/backend/src/db/schemas/common.ts index 347cbeca..03f5c8fe 100644 --- a/apps/backend/src/db/schemas/common.ts +++ b/apps/backend/src/db/schemas/common.ts @@ -20,6 +20,7 @@ export const id = text('id').notNull().primaryKey().$type(); export const getBooleanColumn = (fieldName: string) => integer(fieldName, { mode: 'boolean' }).notNull(); export const isTombstone = getBooleanColumn('is_tombstone'); +export const isClosed = getBooleanColumn('is_closed'); export const getNumericColumn = (fieldName: string) => { return text(fieldName).notNull().$type(); }; diff --git a/apps/backend/src/db/test-db.ts b/apps/backend/src/db/test-db.ts index de15b145..10a126d3 100644 --- a/apps/backend/src/db/test-db.ts +++ b/apps/backend/src/db/test-db.ts @@ -394,12 +394,14 @@ export class TestDB { name?: string; precision?: CommodityPrecisionNumber; isTombstone?: boolean; + isClosed?: boolean; }, ): Promise => { const nextName = this.commodityCounter.getNextName({ delimiter: '' }); const commodityData = { code: params?.code ?? CommodityCode.create(`COM${nextName}`).valueOf(), + isClosed: params?.isClosed ?? false, isTombstone: params?.isTombstone ?? false, name: params?.name ?? `Commodity ${nextName}`, precision: params?.precision ?? 2, @@ -417,6 +419,7 @@ export class TestDB { userId: commodityData.userId, ...TestDB.createTimestamps, ...TestDB.uuid, + isClosed: commodityData.isClosed, isTombstone: commodityData.isTombstone, }) .returning() diff --git a/apps/backend/src/domain/accounts/account.entity.ts b/apps/backend/src/domain/accounts/account.entity.ts index 776f5786..523ec7f3 100644 --- a/apps/backend/src/domain/accounts/account.entity.ts +++ b/apps/backend/src/domain/accounts/account.entity.ts @@ -8,6 +8,7 @@ import { EntityIdentity, EntityTimestamps, SoftDelete, + type TransitionResult, } from '../domain-core'; import { DeletedEntityOperationError } from '../domain.errors'; import { User } from '../users/user.entity'; @@ -19,8 +20,6 @@ import { CreateAccountProps, } from './types'; -type TransitionResult = 'changed' | 'unchanged'; - export class Account { static readonly entityType = 'account'; private constructor( diff --git a/apps/backend/src/domain/commodities/commodity.entity.ts b/apps/backend/src/domain/commodities/commodity.entity.ts index a567c481..6b6da257 100644 --- a/apps/backend/src/domain/commodities/commodity.entity.ts +++ b/apps/backend/src/domain/commodities/commodity.entity.ts @@ -12,6 +12,7 @@ import { ParentChildRelation, SoftDelete, Timestamp, + TransitionResult, } from '../domain-core'; import { DeletedEntityOperationError } from '../domain.errors'; import { User } from '../users/user.entity'; @@ -23,9 +24,6 @@ import type { CreateCommodityProps, } from './types'; -// const DEFAULT_PRECISION = 2; -// const DEFAULT_SYMBOL = null; - export class Commodity { static readonly entityType = 'commodity'; private constructor( @@ -37,6 +35,7 @@ export class Commodity { public symbol: CommoditySymbolString | null, public name: Name, private precision: CommodityPrecisionNumber, + private isClosed: boolean, ) {} static readonly DEFAULT_PRECISION = 2; @@ -68,6 +67,7 @@ export class Commodity { parsedCommoditySymbol, Name.create(props.name), parsedCommodityPrecision, // use the provided precision value + false, ); } @@ -76,6 +76,7 @@ export class Commodity { code, createdAt, id, + isClosed, isTombstone, precision, symbol, @@ -112,6 +113,7 @@ export class Commodity { parsedCommoditySymbol, commodityName, parsedCommodityPrecision, + isClosed, ); } @@ -132,11 +134,17 @@ export class Commodity { this.timestamps = this.timestamps.touch(now); } - markAsDeleted(): void { + delete(): TransitionResult { + if (this.isDeleted()) { + return 'unchanged'; + } + this.softDelete = this.softDelete.markAsDeleted( new DeletedEntityOperationError(Commodity.entityType, 'delete'), ); + this.touch(); + return 'changed'; } isDeleted(): boolean { @@ -162,6 +170,7 @@ export class Commodity { code: this.code.valueOf(), createdAt: this.getCreatedAt().valueOf(), id: this.getId().valueOf(), + isClosed: this.isClosed, isTombstone: this.softDelete.getIsTombstone(), name: this.name.valueOf(), precision: this.precision, @@ -195,4 +204,31 @@ export class Commodity { this.touch(); } } + close(): TransitionResult { + this.validateUpdateIsAllowed(); + + if (this.isClosed) { + return 'unchanged'; + } + + this.isClosed = true; + this.touch(); + return 'changed'; + } + + open(): TransitionResult { + this.validateUpdateIsAllowed(); + + if (!this.isClosed) { + return 'unchanged'; + } + + this.isClosed = false; + this.touch(); + return 'changed'; + } + + get closed(): boolean { + return this.isClosed; + } } diff --git a/apps/backend/src/domain/commodities/commodity.test.ts b/apps/backend/src/domain/commodities/commodity.test.ts index d46dbdee..e2e15d7c 100644 --- a/apps/backend/src/domain/commodities/commodity.test.ts +++ b/apps/backend/src/domain/commodities/commodity.test.ts @@ -1,3 +1,4 @@ +import { compareEntities } from 'src/db/test-utils'; import { createUser } from 'src/testing'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; @@ -117,6 +118,7 @@ describe('Commodity Domain Entity', () => { code: commodityCode.valueOf(), createdAt: createdAt.valueOf(), id: commodityId.valueOf(), + isClosed: false, isTombstone: false, name: commodityName.valueOf(), precision: commodityPrecision, @@ -144,6 +146,7 @@ describe('Commodity Domain Entity', () => { code: commodityCode.valueOf(), createdAt: Timestamp.create().valueOf(), id: Id.create().valueOf(), + isClosed: false, isTombstone: false, name: commodityName.valueOf(), precision: -1, // Invalid precision @@ -162,6 +165,7 @@ describe('Commodity Domain Entity', () => { code: commodityCode.valueOf(), createdAt: Timestamp.create().valueOf(), id: Id.create().valueOf(), + isClosed: false, isTombstone: false, name: commodityName.valueOf(), precision: commodityPrecision, @@ -183,7 +187,7 @@ describe('Commodity Domain Entity', () => { expect(commodity.isDeleted()).toBe(false); - commodity.markAsDeleted(); + commodity.delete(); expect(commodity.isDeleted()).toBe(true); }); @@ -191,7 +195,7 @@ describe('Commodity Domain Entity', () => { it('should not allow updates on a deleted commodity', () => { const commodity = Commodity.create(user, commodityCreateProps); - commodity.markAsDeleted(); + commodity.delete(); const newCode = CommodityCode.create('updatedCode'); @@ -200,30 +204,22 @@ describe('Commodity Domain Entity', () => { }).toThrow(DeletedEntityOperationError); }); - it('should not allow deleting an already deleted commodity', () => { + it('should allow multiple delete calls without throwing error', () => { const commodity = Commodity.create(user, commodityCreateProps); - commodity.markAsDeleted(); - - const deletedSnapshot = commodity.toSnapshot(); + expect(commodity.isDeleted()).toBe(false); - let thrownError: unknown; + const firstDeleteResult = commodity.delete(); + const secondDeleteResult = commodity.delete(); - try { - commodity.markAsDeleted(); - } catch (error) { - thrownError = error; - } + expect(firstDeleteResult).toBe('changed'); + expect(secondDeleteResult).toBe('unchanged'); + expect(commodity.isDeleted()).toBe(true); - expect(thrownError).toMatchObject({ - code: 'DELETED_ENTITY_OPERATION', - context: { - entityType: Commodity.entityType, - operation: 'delete', - }, + compareEntities(commodity.toSnapshot(), { + ...commodity.toSnapshot(), + isTombstone: true, }); - - expect(commodity.toSnapshot()).toEqual(deletedSnapshot); }); it('should only mark commodity as deleted and update timestamp during soft deletion', () => { @@ -242,7 +238,7 @@ describe('Commodity Domain Entity', () => { vi.setSystemTime(new Date(timestampDuringDeletionValue)); - commodity.markAsDeleted(); + commodity.delete(); const commoditySnapshotAfterDeletion = commodity.toSnapshot(); @@ -260,6 +256,78 @@ describe('Commodity Domain Entity', () => { }); }); + describe('close and open methods', () => { + it('should close and touch the commodity', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + + const commodity = Commodity.create(user, commodityCreateProps); + + const before = commodity.toSnapshot(); + + vi.setSystemTime(new Date('2025-01-01T00:00:01.000Z')); + + const closeResult = commodity.close(); + + const afterClose = commodity.toSnapshot(); + + expect(closeResult).toBe('changed'); + expect(afterClose.isClosed).toBe(true); + expect(afterClose.updatedAt).not.toBe(before.updatedAt); + }); + + it('should open and touch the commodity', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + + const commodity = Commodity.create(user, commodityCreateProps); + + commodity.close(); + + const before = commodity.toSnapshot(); + + vi.setSystemTime(new Date('2025-01-01T00:00:01.000Z')); + + const openResult = commodity.open(); + + const afterOpen = commodity.toSnapshot(); + + expect(openResult).toBe('changed'); + expect(afterOpen.isClosed).toBe(false); + expect(afterOpen.updatedAt).not.toBe(before.updatedAt); + }); + + it('should not update timestamp for unchanged close operation', () => { + const commodity = Commodity.create(user, commodityCreateProps); + + commodity.close(); + + const before = commodity.toSnapshot(); + + const closeResult = commodity.close(); + + const afterClose = commodity.toSnapshot(); + + expect(closeResult).toBe('unchanged'); + expect(afterClose.isClosed).toBe(true); + expect(afterClose.updatedAt).toBe(before.updatedAt); + }); + + it('should not update timestamp for unchanged open operation', () => { + const commodity = Commodity.create(user, commodityCreateProps); + + const before = commodity.toSnapshot(); + + const openResult = commodity.open(); + + const afterOpen = commodity.toSnapshot(); + + expect(openResult).toBe('unchanged'); + expect(afterOpen.isClosed).toBe(false); + expect(afterOpen.updatedAt).toBe(before.updatedAt); + }); + }); + describe('update method', () => { it('should update commodity name, code, and symbol and touch the timestamp', () => { vi.useFakeTimers(); diff --git a/apps/backend/src/domain/commodities/types.ts b/apps/backend/src/domain/commodities/types.ts index d63e6f27..d2e519a7 100644 --- a/apps/backend/src/domain/commodities/types.ts +++ b/apps/backend/src/domain/commodities/types.ts @@ -12,6 +12,7 @@ export type CommoditySnapshot = { id: UUID; isTombstone: boolean; code: CommodityCodeString; + isClosed: boolean; symbol: CommoditySymbolString | null; updatedAt: IsoDatetimeString; userId: UUID; diff --git a/apps/backend/src/domain/domain-core/index.ts b/apps/backend/src/domain/domain-core/index.ts index 05dad671..5d3f2fea 100644 --- a/apps/backend/src/domain/domain-core/index.ts +++ b/apps/backend/src/domain/domain-core/index.ts @@ -1,2 +1,3 @@ export * from './value-objects/'; export * from './behaviors/'; +export type { TransitionResult } from './transitions'; diff --git a/apps/backend/src/domain/domain-core/transitions.ts b/apps/backend/src/domain/domain-core/transitions.ts new file mode 100644 index 00000000..861513d3 --- /dev/null +++ b/apps/backend/src/domain/domain-core/transitions.ts @@ -0,0 +1 @@ +export type TransitionResult = 'changed' | 'unchanged'; diff --git a/apps/backend/src/infrastructure/db/commodities/commodity-persistence.mapper.test.ts b/apps/backend/src/infrastructure/db/commodities/commodity-persistence.mapper.test.ts index 7c18c909..3eaa1cb4 100644 --- a/apps/backend/src/infrastructure/db/commodities/commodity-persistence.mapper.test.ts +++ b/apps/backend/src/infrastructure/db/commodities/commodity-persistence.mapper.test.ts @@ -11,6 +11,7 @@ describe('CommodityPersistenceMapper', () => { code: CommodityCode.create('USD').valueOf(), createdAt: Timestamp.create().valueOf(), id: Id.create().valueOf(), + isClosed: false, isTombstone: false, name: 'US Dollar', precision: 2, @@ -24,6 +25,7 @@ describe('CommodityPersistenceMapper', () => { code: row.code, createdAt: row.createdAt, id: row.id, + isClosed: row.isClosed, isTombstone: row.isTombstone, name: row.name, precision: row.precision, @@ -50,6 +52,7 @@ describe('CommodityPersistenceMapper', () => { code: row.code, createdAt: row.createdAt, id: row.id, + isClosed: row.isClosed, isTombstone: row.isTombstone, name: row.name, precision: row.precision, diff --git a/apps/backend/src/infrastructure/db/commodities/commodity-persistence.mapper.ts b/apps/backend/src/infrastructure/db/commodities/commodity-persistence.mapper.ts index 21914b91..57ef32ab 100644 --- a/apps/backend/src/infrastructure/db/commodities/commodity-persistence.mapper.ts +++ b/apps/backend/src/infrastructure/db/commodities/commodity-persistence.mapper.ts @@ -12,6 +12,7 @@ export class CommodityPersistenceMapper { code: row.code, createdAt: row.createdAt, id: row.id, + isClosed: row.isClosed, isTombstone: row.isTombstone, name: row.name, precision: row.precision, @@ -26,6 +27,7 @@ export class CommodityPersistenceMapper { code: snapshot.code, createdAt: snapshot.createdAt, id: snapshot.id, + isClosed: snapshot.isClosed, isTombstone: snapshot.isTombstone, name: snapshot.name, precision: snapshot.precision, 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 e8abb30b..512ee407 100644 --- a/apps/backend/src/infrastructure/db/commodities/commodity.repository.test.ts +++ b/apps/backend/src/infrastructure/db/commodities/commodity.repository.test.ts @@ -190,6 +190,7 @@ describe('CommodityRepository', () => { code: CommodityCode.create('NEW').valueOf(), createdAt: Timestamp.create().valueOf(), id: Id.create().valueOf(), + isClosed: false, isTombstone: false, name: Name.create('New Commodity').valueOf(), precision: 2, @@ -220,6 +221,7 @@ describe('CommodityRepository', () => { code: createdCommodity.code, createdAt: Timestamp.create().valueOf(), id: Id.create().valueOf(), + isClosed: false, isTombstone: false, name: Name.create('Duplicate Commodity').valueOf(), precision: 2, @@ -250,6 +252,7 @@ describe('CommodityRepository', () => { code: createdCommodity.code, createdAt: Timestamp.create().valueOf(), id: Id.create().valueOf(), + isClosed: false, isTombstone: false, name: Name.create('Same Code Commodity').valueOf(), precision: 2, diff --git a/packages/shared/src/types/commodity.ts b/packages/shared/src/types/commodity.ts index 8cea500a..3c4dbeea 100644 --- a/packages/shared/src/types/commodity.ts +++ b/packages/shared/src/types/commodity.ts @@ -23,6 +23,7 @@ export type CommodityResponseDTO = { code: CommodityCodeString; createdAt: IsoDatetimeString; id: UUID; + isClosed: boolean; isTombstone: boolean; name: RequiredText; precision: CommodityPrecisionNumber; From fc1612dca65af9e924c429ed68e7bd55a1bd7050 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Wed, 5 Aug 2026 09:19:30 +0300 Subject: [PATCH 13/31] LED-125 refactor: update account and commodity lifecycle management - Refactored account repository to simplify status handling in `getWhereClauseForGetAll`. - Updated lifecycle methods in account and commodity repositories to ensure idempotency for open/close actions. - Changed commodity repository to use `delete` instead of `softDelete`, aligning with new deletion semantics. - Adjusted tests to reflect changes in lifecycle methods and status filters for commodities and accounts. - Updated validation schemas to default to "open" for commodities and adjusted related tests. - Enhanced integration tests for accounts and commodities to ensure correct filtering and lifecycle behavior. - Documented changes in domain and architecture ADRs regarding terminal tombstone behavior and idempotency. --- .vscode/settings.json | 8 +- .../CommodityRepository.interface.ts | 25 +- .../src/application/interfaces/index.ts | 3 + ...modity.test.ts => deleteCommodity.test.ts} | 27 +- .../__tests__/getAllCommodities.test.ts | 2 +- ...archiveCommodity.ts => deleteCommodity.ts} | 10 +- .../application/usecases/commodities/index.ts | 2 +- apps/backend/src/di/container.ts | 5 +- apps/backend/src/di/types.ts | 4 +- .../db/accounts/account.repository.test.ts | 57 +-- .../db/accounts/account.repository.ts | 41 +- .../commodities/commodity.repository.test.ts | 376 ++++++++++++++---- .../db/commodities/commodity.repository.ts | 98 ++++- .../http/accounts/account.integration.test.ts | 18 +- .../commodities/commodity.controller.test.ts | 23 +- .../http/commodities/commodity.controller.ts | 8 +- .../commodities/commodity.integration.test.ts | 167 ++++---- .../http/commodities/commodity.routes.ts | 2 +- docs/DOMAIN.md | 5 + ...-tombstone-and-reversible-entity-states.md | 12 + packages/shared/src/constants/accounts.ts | 3 + packages/shared/src/constants/commodities.ts | 7 +- .../shared/src/validation/commodities.test.ts | 10 +- packages/shared/src/validation/commodities.ts | 2 +- 24 files changed, 670 insertions(+), 245 deletions(-) rename apps/backend/src/application/usecases/commodities/__tests__/{archiveCommodity.test.ts => deleteCommodity.test.ts} (82%) rename apps/backend/src/application/usecases/commodities/{archiveCommodity.ts => deleteCommodity.ts} (82%) diff --git a/.vscode/settings.json b/.vscode/settings.json index d6d4599d..0967ef42 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1 @@ -{ - "github.copilot.chat.commitMessageGeneration.instructions": [ - { - "text": "Commit message must follow the format: 'LED-XXXXX type(scope): subject'. Extract the ticket number from the current branch name (pattern: LED-XXXXX-description). Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert. Scope is optional (e.g. fullscreen, mobile, platform-core). Subject must be in imperative mood, lowercase, no period at the end." - } - ] -} +{} diff --git a/apps/backend/src/application/interfaces/CommodityRepository.interface.ts b/apps/backend/src/application/interfaces/CommodityRepository.interface.ts index 1ae730f4..3b33f66d 100644 --- a/apps/backend/src/application/interfaces/CommodityRepository.interface.ts +++ b/apps/backend/src/application/interfaces/CommodityRepository.interface.ts @@ -1,4 +1,4 @@ -import { UUID } from '@ledgerly/shared/types'; +import { IsoDatetimeString, UUID } from '@ledgerly/shared/types'; import { CommodityQuery } from '@ledgerly/shared/validation'; import { CommoditySnapshot } from 'src/domain/commodities'; @@ -13,6 +13,17 @@ export type CommodityRepositorySoftDeleteInput = Pick< 'updatedAt' >; +export type CommodityRepositoryLifecycleInput = { + updatedAt: IsoDatetimeString; +}; + +export type CommodityLifecycleAction = 'close' | 'open'; + +export type CommodityLifecycleUpdateInput = + CommodityRepositoryLifecycleInput & { + action: CommodityLifecycleAction; + }; + export type CommodityRepositoryInterface = { getById(userId: UUID, commodityId: UUID): Promise; getAll(userId: UUID, status: CommodityQuery): Promise; @@ -25,9 +36,19 @@ export type CommodityRepositoryInterface = { commodityId: UUID, commodity: CommodityRepositoryUpdateInput, ): Promise; - softDelete( + delete( userId: UUID, commodityId: UUID, data: CommodityRepositorySoftDeleteInput, ): Promise; + open( + userId: UUID, + commodityId: UUID, + data: CommodityRepositoryLifecycleInput, + ): Promise; + close( + userId: UUID, + commodityId: UUID, + data: CommodityRepositoryLifecycleInput, + ): Promise; }; diff --git a/apps/backend/src/application/interfaces/index.ts b/apps/backend/src/application/interfaces/index.ts index 6ad5099e..2df64f8d 100644 --- a/apps/backend/src/application/interfaces/index.ts +++ b/apps/backend/src/application/interfaces/index.ts @@ -20,7 +20,10 @@ export type { } from './TransactionManager.interface'; export type { OperationRepositoryInterface } from './OperationRepository.interface'; export type { + CommodityLifecycleAction, + CommodityLifecycleUpdateInput, CommodityRepositoryInterface, + CommodityRepositoryLifecycleInput, CommodityRepositoryUpdateInput, CommodityRepositorySoftDeleteInput, } from './CommodityRepository.interface'; diff --git a/apps/backend/src/application/usecases/commodities/__tests__/archiveCommodity.test.ts b/apps/backend/src/application/usecases/commodities/__tests__/deleteCommodity.test.ts similarity index 82% rename from apps/backend/src/application/usecases/commodities/__tests__/archiveCommodity.test.ts rename to apps/backend/src/application/usecases/commodities/__tests__/deleteCommodity.test.ts index ed2d00d2..9ca86fd8 100644 --- a/apps/backend/src/application/usecases/commodities/__tests__/archiveCommodity.test.ts +++ b/apps/backend/src/application/usecases/commodities/__tests__/deleteCommodity.test.ts @@ -11,19 +11,24 @@ import { User } from 'src/domain/users/user.entity'; import { createUser } from 'src/testing/helpers'; import { describe, vi, beforeAll, beforeEach, expect, it } from 'vitest'; -import { ArchiveCommodityUseCase } from '../archiveCommodity'; +import { DeleteCommodityUseCase } from '../deleteCommodity'; -describe('ArchiveCommodityUseCase', () => { +describe('DeleteCommodityUseCase', () => { let user: User; const commodityRepository = { + delete: vi.fn(), getById: vi.fn(), - softDelete: vi.fn(), }; - const archiveCommodityUseCase = new ArchiveCommodityUseCase( + const mockTransactionManager = { + run: vi.fn(), + }; + + const deleteCommodityUseCase = new DeleteCommodityUseCase( commodityRepository as unknown as CommodityRepositoryInterface, ensureOwnedSnapshot, + mockTransactionManager, ); beforeAll(async () => { @@ -32,12 +37,12 @@ describe('ArchiveCommodityUseCase', () => { beforeEach(() => { vi.useRealTimers(); - commodityRepository.softDelete.mockClear(); + commodityRepository.delete.mockClear(); commodityRepository.getById.mockClear(); }); describe('execute', () => { - it('should archive a commodity successfully', async () => { + it('should delete a commodity successfully', async () => { vi.useFakeTimers(); const timestampBeforeUpdatingValue = '2024-01-01T00:00:00.000Z'; @@ -66,7 +71,7 @@ describe('ArchiveCommodityUseCase', () => { }; commodityRepository.getById.mockResolvedValue(commodity.toSnapshot()); - commodityRepository.softDelete.mockResolvedValue(archivedCommodityData); + commodityRepository.delete.mockResolvedValue(archivedCommodityData); vi.setSystemTime(new Date(timestampDuringUpdatingValue)); @@ -75,7 +80,7 @@ describe('ArchiveCommodityUseCase', () => { ); // Act - const result = await archiveCommodityUseCase.execute(user, commodityId); + const result = await deleteCommodityUseCase.execute(user, commodityId); // Assert expect(commodityRepository.getById).toHaveBeenCalledWith( @@ -83,7 +88,7 @@ describe('ArchiveCommodityUseCase', () => { commodityId, ); - expect(commodityRepository.softDelete).toHaveBeenCalledWith( + expect(commodityRepository.delete).toHaveBeenCalledWith( userid, commodityId, { updatedAt: archivedCommodityData.timestamps.updatedAt }, @@ -100,7 +105,7 @@ describe('ArchiveCommodityUseCase', () => { // Act & Assert await expect( - archiveCommodityUseCase.execute(user, nonExistentCommodityId), + deleteCommodityUseCase.execute(user, nonExistentCommodityId), ).rejects.toThrowError( new EntityNotFoundError({ entityId: nonExistentCommodityId, @@ -125,7 +130,7 @@ describe('ArchiveCommodityUseCase', () => { // Act & Assert await expect( - archiveCommodityUseCase.execute(user, commodityId), + deleteCommodityUseCase.execute(user, commodityId), ).rejects.toThrowError( new UnauthorizedAccessError({ entityId: commodityId, diff --git a/apps/backend/src/application/usecases/commodities/__tests__/getAllCommodities.test.ts b/apps/backend/src/application/usecases/commodities/__tests__/getAllCommodities.test.ts index 5088d7ef..72373d48 100644 --- a/apps/backend/src/application/usecases/commodities/__tests__/getAllCommodities.test.ts +++ b/apps/backend/src/application/usecases/commodities/__tests__/getAllCommodities.test.ts @@ -52,7 +52,7 @@ describe('GetAllCommoditiesUseCase', () => { }, ]; - const status = 'active'; + const status = 'open'; commodityRepository.getAll.mockResolvedValue(mockCommodities); diff --git a/apps/backend/src/application/usecases/commodities/archiveCommodity.ts b/apps/backend/src/application/usecases/commodities/deleteCommodity.ts similarity index 82% rename from apps/backend/src/application/usecases/commodities/archiveCommodity.ts rename to apps/backend/src/application/usecases/commodities/deleteCommodity.ts index 268b025a..94cd50da 100644 --- a/apps/backend/src/application/usecases/commodities/archiveCommodity.ts +++ b/apps/backend/src/application/usecases/commodities/deleteCommodity.ts @@ -1,15 +1,19 @@ import { CommodityResponseDTO, UUID } from '@ledgerly/shared/types'; import { CommodityMapper } from 'src/application'; import { EntityNotFoundError } from 'src/application/application.errors'; -import { CommodityRepositoryInterface } from 'src/application/interfaces'; +import { + CommodityRepositoryInterface, + TransactionManagerInterface, +} from 'src/application/interfaces'; import { EnsureOwnedSnapshotFn } from 'src/application/shared/ensureOwnedSnapshot'; import { Commodity, CommoditySnapshot } from 'src/domain/commodities'; import { User } from 'src/domain/users/user.entity'; -export class ArchiveCommodityUseCase { +export class DeleteCommodityUseCase { constructor( protected readonly commodityRepository: CommodityRepositoryInterface, protected readonly ensureOwnedSnapshot: EnsureOwnedSnapshotFn, + private readonly transactionManager: TransactionManagerInterface, ) {} async execute(user: User, commodityId: UUID): Promise { @@ -32,7 +36,7 @@ export class ArchiveCommodityUseCase { commodity.delete(); - const updatedCommodity = await this.commodityRepository.softDelete( + const updatedCommodity = await this.commodityRepository.delete( user.getId().valueOf(), commodityId, { updatedAt: commodity.getUpdatedAt().valueOf() }, diff --git a/apps/backend/src/application/usecases/commodities/index.ts b/apps/backend/src/application/usecases/commodities/index.ts index 68430416..e03680c6 100644 --- a/apps/backend/src/application/usecases/commodities/index.ts +++ b/apps/backend/src/application/usecases/commodities/index.ts @@ -2,4 +2,4 @@ export { CreateCommodityUseCase } from './createCommodity'; export { UpdateCommodityUseCase } from './updateCommodity'; export { GetAllCommoditiesUseCase } from './getAllCommodities'; export { GetCommodityByIdUseCase } from './getCommodityById'; -export { ArchiveCommodityUseCase } from './archiveCommodity'; +export { DeleteCommodityUseCase } from './deleteCommodity'; diff --git a/apps/backend/src/di/container.ts b/apps/backend/src/di/container.ts index 8f3ead4c..56cc360a 100644 --- a/apps/backend/src/di/container.ts +++ b/apps/backend/src/di/container.ts @@ -16,7 +16,7 @@ import { UpdateTransactionUseCase, CreateTransactionUseCase, UpdateCommodityUseCase, - ArchiveCommodityUseCase, + DeleteCommodityUseCase, CreateCommodityUseCase, } from 'src/application'; import { @@ -160,9 +160,10 @@ export const createContainer = (db: DataBase): AppContainer => { ensureOwnedSnapshot, ); - const archiveCommodityUseCase = new ArchiveCommodityUseCase( + const archiveCommodityUseCase = new DeleteCommodityUseCase( commodityRepository, ensureOwnedSnapshot, + transactionManager, ); const createCommodityUseCase = new CreateCommodityUseCase( diff --git a/apps/backend/src/di/types.ts b/apps/backend/src/di/types.ts index 9543287f..da0d7163 100644 --- a/apps/backend/src/di/types.ts +++ b/apps/backend/src/di/types.ts @@ -16,7 +16,7 @@ import { UpdateTransactionUseCase, CreateTransactionUseCase, UpdateCommodityUseCase, - ArchiveCommodityUseCase, + DeleteCommodityUseCase, CreateCommodityUseCase, } from 'src/application'; import { DataBase } from 'src/db'; @@ -77,7 +77,7 @@ type CommodityUseCases = { getCommodityById: GetCommodityByIdUseCase; getAllCommodities: GetAllCommoditiesUseCase; updateCommodity: UpdateCommodityUseCase; - archiveCommodity: ArchiveCommodityUseCase; + archiveCommodity: DeleteCommodityUseCase; createCommodity: CreateCommodityUseCase; }; 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 6a98d99a..76627700 100644 --- a/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts +++ b/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts @@ -890,16 +890,9 @@ describe('AccountRepository', () => { }); it('throws RepositoryNotFoundError when account does not exist', async () => { - const closedData = { - action: 'close' as const, + const promise = accountRepository.close(user.id, Id.create().valueOf(), { updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), - }; - - const promise = accountRepository.close( - user.id, - Id.create().valueOf(), - closedData, - ); + }); await expect(promise).rejects.toThrowError(RepositoryNotFoundError); }); @@ -910,7 +903,6 @@ describe('AccountRepository', () => { }); const promise = accountRepository.close(secondUser.id, account.id, { - action: 'close', updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), }); @@ -918,19 +910,25 @@ describe('AccountRepository', () => { }); it('is idempotent for an already closed account', async () => { - const closedData = { - action: 'close' as const, - updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), - }; - - await accountRepository.close(user.id, account.id, closedData); + const initialUpdatedAt = Timestamp.restore( + '2030-01-01T00:00:00.000Z', + ).valueOf(); + const ignoredUpdatedAt = Timestamp.restore( + '2031-01-01T00:00:00.000Z', + ).valueOf(); + + await accountRepository.close(user.id, account.id, { + updatedAt: initialUpdatedAt, + }); const initialClosedAccount = await accountRepository.getById( user.id, account.id, ); - await accountRepository.close(user.id, account.id, closedData); + await accountRepository.close(user.id, account.id, { + updatedAt: ignoredUpdatedAt, + }); const retrievedCloseAccount = await accountRepository.getById( user.id, @@ -947,7 +945,6 @@ describe('AccountRepository', () => { await expect( accountRepository.close(user.id, account.id, { - action: 'close', updatedAt: Timestamp.create().valueOf(), }), ).rejects.toThrowError(RepositoryNotFoundError); @@ -963,7 +960,6 @@ describe('AccountRepository', () => { const updatedAt = Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(); await accountRepository.open(user.id, account.id, { - action: 'open', updatedAt, }); @@ -982,11 +978,15 @@ describe('AccountRepository', () => { isClosed: false, }); - const updatedAt = Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(); + const initialUpdatedAt = Timestamp.restore( + '2030-01-01T00:00:00.000Z', + ).valueOf(); + const ignoredUpdatedAt = Timestamp.restore( + '2031-01-01T00:00:00.000Z', + ).valueOf(); await accountRepository.open(user.id, account.id, { - action: 'open', - updatedAt, + updatedAt: initialUpdatedAt, }); const initialOpenAccount = await accountRepository.getById( @@ -995,8 +995,7 @@ describe('AccountRepository', () => { ); await accountRepository.open(user.id, account.id, { - action: 'open', - updatedAt, + updatedAt: ignoredUpdatedAt, }); const retrievedOpenAccount = await accountRepository.getById( @@ -1007,6 +1006,14 @@ describe('AccountRepository', () => { compareEntities(initialOpenAccount, retrievedOpenAccount); }); + it('should throw RepositoryNotFoundError when account does not exist', async () => { + const promise = accountRepository.open(user.id, Id.create().valueOf(), { + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }); + + await expect(promise).rejects.toThrowError(RepositoryNotFoundError); + }); + it('should throw RepositoryNotFoundError when account is tombstoned', async () => { const account = await testDB.createAccount(user.id, usdCommodity.id, { isTombstone: true, @@ -1014,7 +1021,6 @@ describe('AccountRepository', () => { await expect( accountRepository.open(user.id, account.id, { - action: 'open', updatedAt: Timestamp.create().valueOf(), }), ).rejects.toThrowError(RepositoryNotFoundError); @@ -1028,7 +1034,6 @@ describe('AccountRepository', () => { const account = await testDB.createAccount(user.id, usdCommodity.id); const promise = accountRepository.open(secondUser.id, account.id, { - action: 'open', updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), }); diff --git a/apps/backend/src/infrastructure/db/accounts/account.repository.ts b/apps/backend/src/infrastructure/db/accounts/account.repository.ts index 3d0cff42..571c79a9 100644 --- a/apps/backend/src/infrastructure/db/accounts/account.repository.ts +++ b/apps/backend/src/infrastructure/db/accounts/account.repository.ts @@ -31,17 +31,9 @@ const getWhereClauseForGetAll = (userId: UUID, query: AccountQuery) => { ); } - if (status === 'open') { - return and( - eq(accountsTable.userId, userId), - eq(accountsTable.isClosed, false), - eq(accountsTable.isTombstone, false), - ); - } - return and( eq(accountsTable.userId, userId), - eq(accountsTable.isClosed, true), + eq(accountsTable.isClosed, status !== 'open'), eq(accountsTable.isTombstone, false), ); }; @@ -167,6 +159,7 @@ export class AccountRepository }, 'Failed to fetch account by ID'); } + // TODO: it should be used for idempotent deleting getByIdForLifecycle(userId: UUID, id: UUID): Promise { return this.getByIdInternal(userId, id, { includeTombstone: true }); } @@ -215,14 +208,38 @@ export class AccountRepository ); } - async updateLifecycle( + private async updateLifecycle( userId: UUID, id: UUID, data: AccountLifecycleUpdateInput, ): Promise { const { action, updatedAt } = data; const { name, params } = lifecycleMapper[action]; + const targetIsClosed = params.isClosed; + return this.executeDatabaseOperation(async () => { + const account = await this.db + .select() + .from(accountsTable) + .where( + and( + eq(accountsTable.id, id), + eq(accountsTable.userId, userId), + eq(accountsTable.isTombstone, false), + ), + ) + .get(); + + const existingAccount = this.ensureEntityExists( + account, + `Account with ID ${id} not found`, + this.entityNotFoundContext('account', id), + ); + + if (existingAccount.isClosed === targetIsClosed) { + return; + } + const result = await this.db .update(accountsTable) .set({ ...params, updatedAt }) @@ -246,7 +263,7 @@ export class AccountRepository async open( userId: UUID, id: UUID, - data: AccountLifecycleUpdateInput, + data: AccountRepositoryLifecycleInput, ): Promise { await this.updateLifecycle(userId, id, { action: 'open', @@ -257,7 +274,7 @@ export class AccountRepository async close( userId: UUID, id: UUID, - data: AccountLifecycleUpdateInput, + data: AccountRepositoryLifecycleInput, ): Promise { await this.updateLifecycle(userId, id, { action: 'close', 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 512ee407..634277b0 100644 --- a/apps/backend/src/infrastructure/db/commodities/commodity.repository.test.ts +++ b/apps/backend/src/infrastructure/db/commodities/commodity.repository.test.ts @@ -3,6 +3,7 @@ import { CommodityQuery } from '@ledgerly/shared/validation'; import { type CommodityRepositoryUpdateInput } from 'src/application'; import type { CommodityDbRow } from 'src/db/schemas/commodities'; import { UserDbRow } from 'src/db/schemas/users'; +import { compareEntities, compareEntityArrays } from 'src/db/test-utils'; import { CommoditySnapshot } from 'src/domain/commodities/types'; import { CommodityCode, Name, Timestamp } from 'src/domain/domain-core/'; import { Id } from 'src/domain/domain-core/value-objects/Id'; @@ -85,48 +86,75 @@ describe('CommodityRepository', () => { }); describe('getAll', () => { - const commodityStatusFilterCases: { - expectedTombstoneValues: boolean[]; - query: CommodityQuery; - }[] = [ - { expectedTombstoneValues: [false, false], query: { status: 'active' } }, - { expectedTombstoneValues: [true], query: { status: 'archived' } }, + const commoditiesDataList = [ { - expectedTombstoneValues: [false, false, true], - query: { status: 'all' }, + code: CommodityCode.create('TOMB').valueOf(), + isClosed: false, + isTombstone: true, + name: 'Tombstoned Commodity', + }, + { + code: CommodityCode.create('OPEN').valueOf(), + isClosed: false, + isTombstone: false, + name: 'Open Commodity', + }, + { + code: CommodityCode.create('CLOSED').valueOf(), + isClosed: true, + isTombstone: false, + name: 'Closed Commodity', }, ]; - it.each(commodityStatusFilterCases)( - 'should return $status.status commodities for the user', - async ({ expectedTombstoneValues, query }) => { - const activeCommodities = await Promise.all([ - testDB.createCommodity(user.id), - testDB.createCommodity(user.id), - ]); + const allCommodities = commoditiesDataList.filter( + (commodity) => !commodity.isTombstone, + ); - const archivedCommodity = await testDB.createCommodity(user.id, { - isTombstone: true, - }); + const openCommodities = commoditiesDataList.filter( + (commodity) => !commodity.isClosed && !commodity.isTombstone, + ); - const commodities = await commodityRepository.getAll(user.id, query); + const closedCommodities = commoditiesDataList.filter( + (commodity) => commodity.isClosed && !commodity.isTombstone, + ); - const expectedCommodities = - query.status === 'active' - ? activeCommodities - : query.status === 'archived' - ? [archivedCommodity] - : [...activeCommodities, archivedCommodity]; + type TestData = { + code: string; + isClosed: boolean; + isTombstone: boolean; + name: string; + }; + + const testData: [CommodityQuery, TestData[]][] = [ + [{ status: 'all' }, allCommodities], + [{ status: 'open' }, openCommodities], + [{ status: 'closed' }, closedCommodities], + ]; - expect(commodities).toEqual( - expect.arrayContaining(expectedCommodities), - ); + beforeEach(async () => { + for (const data of commoditiesDataList) { + await testDB.createCommodity(user.id, { + code: data.code, + isClosed: data.isClosed, + isTombstone: data.isTombstone, + name: data.name, + }); + } + }); - expect(commodities).toHaveLength(expectedCommodities.length); + it.each(testData)( + 'returns commodities matching query %s', + async ({ status }: CommodityQuery, expectedCommodities: TestData[]) => { + const commodities = await commodityRepository.getAll(user.id, { + status, + }); - expect( - commodities.map(({ isTombstone }) => isTombstone).sort(), - ).toEqual(expectedTombstoneValues.sort()); + compareEntityArrays(commodities, expectedCommodities, { + fields: ['code', 'name', 'isClosed', 'isTombstone'], + }); + + expect(commodities.length).toBe(expectedCommodities.length); }, ); @@ -134,52 +162,40 @@ describe('CommodityRepository', () => { const anotherUser = await testDB.createUser(); const commodities = await commodityRepository.getAll(anotherUser.id, { - status: 'active', + status: 'open', }); expect(commodities).toEqual([]); }); - it('should not return tombstoned commodities for active status', async () => { - const commodity1 = await testDB.createCommodity(user.id); - const commodity2 = await testDB.createCommodity(user.id); - - await commodityRepository.softDelete(user.id, commodity1.id, { - updatedAt: Timestamp.create().valueOf(), - }); - - const commodities = await commodityRepository.getAll(user.id, { - status: 'active', - }); - - expect(commodities).toEqual(expect.arrayContaining([commodity2])); - expect(commodities).not.toEqual(expect.arrayContaining([commodity1])); - }); - it.each([ - { status: 'active' }, - { status: 'archived' }, + { status: 'open' }, + { status: 'closed' }, { status: 'all' }, ])( 'should not return commodities belonging to other users for $status.status status', async ({ status }) => { - const commodity1 = await testDB.createCommodity(user.id); const anotherUser = await testDB.createUser(); - const commodity2 = await testDB.createCommodity(anotherUser.id, { - isTombstone: status === 'archived', + await testDB.createCommodity(anotherUser.id, { + code: CommodityCode.create('OTHER').valueOf(), + isClosed: status === 'closed', + name: 'Other User Commodity', }); - if (status === 'archived') { - await commodityRepository.softDelete(user.id, commodity1.id, { - updatedAt: Timestamp.create().valueOf(), - }); - } - const commodities = await commodityRepository.getAll(user.id, { status, }); - expect(commodities).not.toEqual(expect.arrayContaining([commodity2])); + const expectedCommodities = + status === 'all' + ? allCommodities + : status === 'open' + ? openCommodities + : closedCommodities; + + compareEntityArrays(commodities, expectedCommodities, { + fields: ['code', 'name', 'isClosed', 'isTombstone'], + }); }, ); }); @@ -387,7 +403,7 @@ describe('CommodityRepository', () => { }); it('should throw an error when trying to update tombstoned commodity', async () => { - await commodityRepository.softDelete(user.id, commodityDbRow.id, { + await commodityRepository.delete(user.id, commodityDbRow.id, { updatedAt: Timestamp.create().valueOf(), }); @@ -403,15 +419,15 @@ describe('CommodityRepository', () => { }); }); - describe('softDelete', () => { + describe('delete', () => { let commodityDbRow: CommodityDbRow; beforeEach(async () => { commodityDbRow = await testDB.createCommodity(user.id); }); - it('should soft delete the commodity when it exists', async () => { - await commodityRepository.softDelete(user.id, commodityDbRow.id, { + it('should delete the commodity when it exists', async () => { + await commodityRepository.delete(user.id, commodityDbRow.id, { updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), }); @@ -427,7 +443,7 @@ describe('CommodityRepository', () => { const nonExistentId = Id.create().valueOf(); await expect( - commodityRepository.softDelete(user.id, nonExistentId, { + commodityRepository.delete(user.id, nonExistentId, { updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), }), ).rejects.toThrowError(RepositoryNotFoundError); @@ -437,19 +453,239 @@ describe('CommodityRepository', () => { const anotherUser = await testDB.createUser(); await expect( - commodityRepository.softDelete(anotherUser.id, commodityDbRow.id, { + commodityRepository.delete(anotherUser.id, commodityDbRow.id, { updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), }), ).rejects.toThrowError(RepositoryNotFoundError); }); - it('should throw an error when trying to soft delete a commodity that is already tombstoned', async () => { - await commodityRepository.softDelete(user.id, commodityDbRow.id, { + it('should throw an error when trying to delete a commodity that is already tombstoned', async () => { + await commodityRepository.delete(user.id, commodityDbRow.id, { updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), }); await expect( - commodityRepository.softDelete(user.id, commodityDbRow.id, { + commodityRepository.delete(user.id, commodityDbRow.id, { + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }), + ).rejects.toThrowError(RepositoryNotFoundError); + }); + }); + + describe('close', () => { + let commodityDbRow: CommodityDbRow; + + beforeEach(async () => { + commodityDbRow = await testDB.createCommodity(user.id); + }); + + it("updates only the 'isClosed' and 'updatedAt' fields when closing a commodity", async () => { + const updatedAt = Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(); + + const closedCommodity = await commodityRepository.close( + user.id, + commodityDbRow.id, + { updatedAt }, + ); + + expect(closedCommodity).toMatchObject({ + id: commodityDbRow.id, + isClosed: true, + updatedAt, + }); + + const retrievedCommodity = await testDB.getCommodityById( + commodityDbRow.id, + ); + + expect(retrievedCommodity?.isClosed).toBe(true); + expect(retrievedCommodity?.updatedAt).toBe(updatedAt); + + if (!retrievedCommodity) { + throw new Error('Failed to retrieve commodity for comparison'); + } + + compareEntities(retrievedCommodity, closedCommodity, [ + 'isClosed', + 'updatedAt', + ]); + }); + + it("throws RepositoryNotFoundError when trying to close a commodity that doesn't exist", async () => { + await expect( + commodityRepository.close(user.id, Id.create().valueOf(), { + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }), + ).rejects.toThrowError(RepositoryNotFoundError); + }); + + it('throws RepositoryNotFoundError when trying to close a commodity that belongs to another user', async () => { + const anotherUser = await testDB.createUser(); + + await expect( + commodityRepository.close(anotherUser.id, commodityDbRow.id, { + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }), + ).rejects.toThrowError(RepositoryNotFoundError); + }); + + it('is idempotent for an already closed commodity', async () => { + const initialUpdatedAt = Timestamp.restore( + '2030-01-01T00:00:00.000Z', + ).valueOf(); + const ignoredUpdatedAt = Timestamp.restore( + '2031-01-01T00:00:00.000Z', + ).valueOf(); + + await commodityRepository.close(user.id, commodityDbRow.id, { + updatedAt: initialUpdatedAt, + }); + + const initialRetrievedCommodity = await testDB.getCommodityById( + commodityDbRow.id, + ); + + const secondCloseAttempt = await commodityRepository.close( + user.id, + commodityDbRow.id, + { updatedAt: ignoredUpdatedAt }, + ); + + expect(secondCloseAttempt).toMatchObject({ + id: commodityDbRow.id, + isClosed: true, + updatedAt: initialUpdatedAt, + }); + + const retrievedCommodity = await testDB.getCommodityById( + commodityDbRow.id, + ); + + if (!initialRetrievedCommodity || !retrievedCommodity) { + throw new Error('Failed to retrieve commodity for comparison'); + } + + compareEntities(initialRetrievedCommodity, retrievedCommodity); + }); + + it('throws RepositoryNotFoundError when trying to close a tombstoned commodity', async () => { + await commodityRepository.delete(user.id, commodityDbRow.id, { + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }); + + await expect( + commodityRepository.close(user.id, commodityDbRow.id, { + updatedAt: Timestamp.restore('2031-01-01T00:00:00.000Z').valueOf(), + }), + ).rejects.toThrowError(RepositoryNotFoundError); + }); + }); + + describe('open', () => { + let closedCommodityDbRow: CommodityDbRow; + + beforeEach(async () => { + closedCommodityDbRow = await testDB.createCommodity(user.id, { + isClosed: true, + }); + }); + + it('should open the closed commodity when it exists', async () => { + const updatedAt = Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(); + + const openedCommodity = await commodityRepository.open( + user.id, + closedCommodityDbRow.id, + { updatedAt }, + ); + + expect(openedCommodity).toMatchObject({ + id: closedCommodityDbRow.id, + isClosed: false, + updatedAt, + }); + + const retrievedCommodity = await testDB.getCommodityById( + closedCommodityDbRow.id, + ); + + expect(retrievedCommodity?.isClosed).toBe(false); + expect(retrievedCommodity?.updatedAt).toBe(updatedAt); + + if (!retrievedCommodity) { + throw new Error('Failed to retrieve commodity for comparison'); + } + + compareEntities(retrievedCommodity, openedCommodity, [ + 'isClosed', + 'updatedAt', + ]); + }); + + it('is idempotent for an already open commodity', async () => { + const initialUpdatedAt = Timestamp.restore( + '2030-01-01T00:00:00.000Z', + ).valueOf(); + const ignoredUpdatedAt = Timestamp.restore( + '2031-01-01T00:00:00.000Z', + ).valueOf(); + + await commodityRepository.open(user.id, closedCommodityDbRow.id, { + updatedAt: initialUpdatedAt, + }); + + const initialRetrievedCommodity = await testDB.getCommodityById( + closedCommodityDbRow.id, + ); + + const secondOpenAttempt = await commodityRepository.open( + user.id, + closedCommodityDbRow.id, + { updatedAt: ignoredUpdatedAt }, + ); + + expect(secondOpenAttempt).toMatchObject({ + id: closedCommodityDbRow.id, + isClosed: false, + updatedAt: initialUpdatedAt, + }); + + const retrievedCommodity = await testDB.getCommodityById( + closedCommodityDbRow.id, + ); + + if (!initialRetrievedCommodity || !retrievedCommodity) { + throw new Error('Failed to retrieve commodity for comparison'); + } + + compareEntities(initialRetrievedCommodity, retrievedCommodity); + }); + + it("throws RepositoryNotFoundError when trying to open a commodity that doesn't exist", async () => { + await expect( + commodityRepository.open(user.id, Id.create().valueOf(), { + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }), + ).rejects.toThrowError(RepositoryNotFoundError); + }); + + it('throws RepositoryNotFoundError when trying to open a tombstoned commodity', async () => { + await commodityRepository.delete(user.id, closedCommodityDbRow.id, { + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }); + + await expect( + commodityRepository.open(user.id, closedCommodityDbRow.id, { + updatedAt: Timestamp.restore('2031-01-01T00:00:00.000Z').valueOf(), + }), + ).rejects.toThrowError(RepositoryNotFoundError); + }); + + it('throws RepositoryNotFoundError when trying to open a commodity that belongs to another user', async () => { + const anotherUser = await testDB.createUser(); + + await expect( + commodityRepository.open(anotherUser.id, closedCommodityDbRow.id, { updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), }), ).rejects.toThrowError(RepositoryNotFoundError); diff --git a/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts b/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts index e4faa8d9..a461cc83 100644 --- a/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts +++ b/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts @@ -2,7 +2,10 @@ import { UUID } from '@ledgerly/shared/types'; import { CommodityQuery } from '@ledgerly/shared/validation'; import { and, eq } from 'drizzle-orm'; import { + type CommodityLifecycleAction, + type CommodityLifecycleUpdateInput, type CommodityRepositoryInterface, + type CommodityRepositoryLifecycleInput, type CommodityRepositoryUpdateInput, type CommodityRepositorySoftDeleteInput, } from 'src/application'; @@ -18,15 +21,27 @@ const getWhereClauseForGetAll = (userId: UUID, query: CommodityQuery) => { const { status } = query; if (status === 'all') { - return eq(commoditiesTable.userId, userId); + return and( + eq(commoditiesTable.userId, userId), + eq(commoditiesTable.isTombstone, false), + ); } return and( eq(commoditiesTable.userId, userId), - eq(commoditiesTable.isTombstone, status === 'archived'), + eq(commoditiesTable.isClosed, status !== 'open'), + eq(commoditiesTable.isTombstone, false), ); }; +const lifecycleMapper: Record< + CommodityLifecycleAction, + { name: string; params: Record } +> = { + close: { name: 'close', params: { isClosed: true } }, + open: { name: 'open', params: { isClosed: false } }, +}; + export class CommodityRepository extends BaseRepository implements CommodityRepositoryInterface @@ -147,7 +162,7 @@ export class CommodityRepository ); } - softDelete( + delete( userId: UUID, commodityId: UUID, data: CommodityRepositorySoftDeleteInput, @@ -175,4 +190,81 @@ export class CommodityRepository return CommodityPersistenceMapper.toSnapshot(existingCommodity); }, `Failed to soft delete commodity with ID ${commodityId}`); } + + private updateLifecycle( + userId: UUID, + commodityId: UUID, + data: CommodityLifecycleUpdateInput, + ): Promise { + const { action, updatedAt } = data; + const { name, params } = lifecycleMapper[action]; + const targetIsClosed = params.isClosed; + + return this.executeDatabaseOperation(async () => { + const commodity = await this.db + .select() + .from(commoditiesTable) + .where( + and( + eq(commoditiesTable.userId, userId), + eq(commoditiesTable.id, commodityId), + eq(commoditiesTable.isTombstone, false), + ), + ) + .get(); + + const existingCommodity = this.ensureEntityExists( + commodity, + `Commodity with ID ${commodityId} not found`, + this.entityNotFoundContext('commodity', commodityId), + ); + + if (existingCommodity.isClosed === targetIsClosed) { + return CommodityPersistenceMapper.toSnapshot(existingCommodity); + } + + const updatedCommodity = await this.db + .update(commoditiesTable) + .set({ ...params, updatedAt }) + .where( + and( + eq(commoditiesTable.userId, userId), + eq(commoditiesTable.id, commodityId), + eq(commoditiesTable.isTombstone, false), + ), + ) + .returning() + .get(); + + const updatedExistingCommodity = this.ensureEntityExists( + updatedCommodity, + `Commodity with ID ${commodityId} not found`, + this.entityNotFoundContext('commodity', commodityId), + ); + + return CommodityPersistenceMapper.toSnapshot(updatedExistingCommodity); + }, `Failed to ${name} commodity with ID ${commodityId}`); + } + + open( + userId: UUID, + commodityId: UUID, + data: CommodityRepositoryLifecycleInput, + ): Promise { + return this.updateLifecycle(userId, commodityId, { + action: 'open', + updatedAt: data.updatedAt, + }); + } + + close( + userId: UUID, + commodityId: UUID, + data: CommodityRepositoryLifecycleInput, + ): Promise { + return this.updateLifecycle(userId, commodityId, { + action: 'close', + updatedAt: data.updatedAt, + }); + } } 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 0d6e9edf..eec6eb1c 100644 --- a/apps/backend/src/presentation/http/accounts/account.integration.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.integration.test.ts @@ -7,6 +7,7 @@ import { apiErrorCodes, UUID, } from '@ledgerly/shared/types'; +import { AccountStatusFilterValue } from 'node_modules/@ledgerly/shared/src/constants/accounts'; import { CommodityDbRow } from 'src/db/schemas/commodities'; import { TestDB } from 'src/db/test-db'; import { compareEntityArrays } from 'src/db/test-utils/entityComparer'; @@ -19,6 +20,9 @@ import { describe, beforeEach, it, expect } from 'vitest'; const url = `/api${ROUTES.accounts}`; +const getWithQueryParamsUrl = (status: AccountStatusFilterValue) => + `${url}?status=${status}`; + const closedAccountsData = [ { commodityId: Id.create().valueOf(), @@ -252,7 +256,7 @@ describe('Accounts Integration Tests', () => { const finalResponse = await injectAuthorized({ method: 'GET', - url: `${url}?status=all`, + url: getWithQueryParamsUrl('all'), }); const accountsAfterCreation = JSON.parse( @@ -281,7 +285,7 @@ describe('Accounts Integration Tests', () => { const finalResponse = await injectAuthorized({ method: 'GET', - url: `${url}?status=all`, + url: getWithQueryParamsUrl('all'), }); const accountsAfterDeletion = JSON.parse( @@ -511,7 +515,7 @@ describe('Accounts Integration Tests', () => { const finalResponse = await injectAuthorized({ method: 'GET', - url: `${url}?status=all`, + url: getWithQueryParamsUrl('all'), }); const accountsAfterUpdate = JSON.parse( @@ -610,7 +614,9 @@ describe('Accounts Integration Tests', () => { it('should return 400 when status is an invalid enum value', async () => { const response = await injectAuthorized({ method: 'GET', - url: `${url}?status=archived`, + url: getWithQueryParamsUrl( + 'archived' as unknown as AccountStatusFilterValue, + ), }); expect(response.statusCode).toBe(400); @@ -1279,7 +1285,9 @@ describe('Accounts Integration Tests', () => { it('should return the standard error format for validation errors', async () => { const response = await injectAuthorized({ method: 'GET', - url: `${url}?status=archived`, + url: getWithQueryParamsUrl( + 'archived' as unknown as AccountStatusFilterValue, + ), }); const errorResponse = JSON.parse(response.body) as ApiErrorResponse; diff --git a/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts b/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts index ad28ce95..bf7f1e18 100644 --- a/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts +++ b/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts @@ -1,6 +1,6 @@ import { CommodityQuery } from '@ledgerly/shared/validation'; import { - ArchiveCommodityUseCase, + DeleteCommodityUseCase, CreateCommodityUseCase, GetAllCommoditiesUseCase, GetCommodityByIdUseCase, @@ -38,7 +38,7 @@ describe('CommodityController', () => { execute: vi.fn(), }; - const mockArchiveCommodityUseCase = { + const mockDeleteCommodityUseCase = { execute: vi.fn(), }; @@ -47,7 +47,7 @@ describe('CommodityController', () => { mockGetAllCommoditiesUseCase as unknown as GetAllCommoditiesUseCase, mockCreateCommodityUseCase as unknown as CreateCommodityUseCase, mockUpdateCommodityUseCase as unknown as UpdateCommodityUseCase, - mockArchiveCommodityUseCase as unknown as ArchiveCommodityUseCase, + mockDeleteCommodityUseCase as unknown as DeleteCommodityUseCase, ); beforeEach(async () => { @@ -61,10 +61,10 @@ describe('CommodityController', () => { query: CommodityQuery; queryParams: unknown; }[] = [ - { query: { status: 'active' }, queryParams: { status: 'active' } }, - { query: { status: 'archived' }, queryParams: { status: 'archived' } }, + { query: { status: 'open' }, queryParams: { status: 'open' } }, + { query: { status: 'closed' }, queryParams: { status: 'closed' } }, { query: { status: 'all' }, queryParams: { status: 'all' } }, - { query: { status: 'active' }, queryParams: {} }, + { query: { status: 'open' }, queryParams: {} }, ]; it.each(validQueryParams)( @@ -87,6 +87,7 @@ describe('CommodityController', () => { { status: 1 }, { status: true }, 'active', + 'archived', 'deleted', '', undefined, @@ -236,25 +237,25 @@ describe('CommodityController', () => { }); }); - describe('archiveCommodity', () => { + describe('deleteCommodity', () => { it('should call archiveCommodityUseCase.execute with correct user and id', async () => { const requestParams = { id: commodityId }; - await commodityController.archiveCommodity(user, requestParams); + await commodityController.delete(user, requestParams); - expect(mockArchiveCommodityUseCase.execute).toHaveBeenCalledWith( + expect(mockDeleteCommodityUseCase.execute).toHaveBeenCalledWith( user, requestParams.id, ); - expect(mockArchiveCommodityUseCase.execute).toHaveBeenCalledTimes(1); + expect(mockDeleteCommodityUseCase.execute).toHaveBeenCalledTimes(1); }); it('should throw ZodError if requestParams is invalid', async () => { const invalidRequestParams = { invalidId: 'commodity-id' }; await expect( - commodityController.archiveCommodity(user, invalidRequestParams), + commodityController.delete(user, invalidRequestParams), ).rejects.toThrow(ZodError); }); }); diff --git a/apps/backend/src/presentation/http/commodities/commodity.controller.ts b/apps/backend/src/presentation/http/commodities/commodity.controller.ts index 633197a1..d98e53e4 100644 --- a/apps/backend/src/presentation/http/commodities/commodity.controller.ts +++ b/apps/backend/src/presentation/http/commodities/commodity.controller.ts @@ -7,7 +7,7 @@ import { } from '@ledgerly/shared/validation'; import { GetCommodityByIdUseCase, - ArchiveCommodityUseCase, + DeleteCommodityUseCase, CreateCommodityUseCase, GetAllCommoditiesUseCase, UpdateCommodityUseCase, @@ -20,7 +20,7 @@ export class CommodityController { private readonly getAllCommoditiesUseCase: GetAllCommoditiesUseCase, private readonly createCommodityUseCase: CreateCommodityUseCase, private readonly updateCommodityUseCase: UpdateCommodityUseCase, - private readonly archiveCommodityUseCase: ArchiveCommodityUseCase, + private readonly deleteCommodityUseCase: DeleteCommodityUseCase, ) {} async getAll( @@ -61,12 +61,12 @@ export class CommodityController { return this.updateCommodityUseCase.execute(user, id, commodityUpdateDto); } - async archiveCommodity( + async delete( user: User, requestParams: unknown, ): Promise { const { id } = uniqueIdSchema.parse(requestParams); - return this.archiveCommodityUseCase.execute(user, id); + return this.deleteCommodityUseCase.execute(user, id); } } 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 95f4dc80..279a9f5e 100644 --- a/apps/backend/src/presentation/http/commodities/commodity.integration.test.ts +++ b/apps/backend/src/presentation/http/commodities/commodity.integration.test.ts @@ -1,3 +1,4 @@ +import { CommodityStatusFilterValue } from '@ledgerly/shared/constants'; import { ROUTES } from '@ledgerly/shared/routes'; import { CommodityResponseDTO, UUID } from '@ledgerly/shared/types'; import { CommodityDbRow } from 'src/db/schemas'; @@ -119,69 +120,87 @@ describe('Commodities Integration Tests', () => { }); describe('GET /api/commodities', () => { - it('should return all active commodities for the authenticated user when no status is provided', async () => { - const response = await injectAuthorized({ - method: 'GET', - url, - }); - - expect(response.statusCode).toBe(200); - - const parsedResponse = parseResponse(response); - - expect(parsedResponse).toHaveLength(activeCommoditiesData.length); - - compareEntityArrays(activeCommoditiesData, parsedResponse); - }); - - it('should return active commodities when status=active is provided', async () => { - const response = await injectAuthorized({ - method: 'GET', - url: `${url}?status=active`, + let listCommodities: CommodityDbRow[]; + + const getWithQueryParamsUrl = (status: CommodityStatusFilterValue) => + `${url}?status=${status}`; + + const testCases = [ + { + description: + 'should return all open commodities by default for the user', + expectedCommodities: (commodities: CommodityDbRow[]) => + commodities.filter( + (commodity) => !commodity.isClosed && !commodity.isTombstone, + ), + query: {}, + }, + { + description: + 'should return all open commodities when query param status=open', + expectedCommodities: (commodities: CommodityDbRow[]) => + commodities.filter( + (commodity) => !commodity.isClosed && !commodity.isTombstone, + ), + query: { status: 'open' }, + }, + { + description: + 'should return all commodities including closed when query param status=all', + expectedCommodities: (commodities: CommodityDbRow[]) => + commodities.filter((commodity) => !commodity.isTombstone), + query: { status: 'all' }, + }, + { + description: + 'should return all closed commodities when query param status=closed', + expectedCommodities: (commodities: CommodityDbRow[]) => + commodities.filter( + (commodity) => commodity.isClosed && !commodity.isTombstone, + ), + query: { status: 'closed' }, + }, + ] satisfies { + description: string; + expectedCommodities: (commodities: CommodityDbRow[]) => CommodityDbRow[]; + query: Partial<{ status: CommodityStatusFilterValue }>; + }[]; + + beforeEach(async () => { + const closedCommodity = await testDB.createCommodity(userId, { + code: CommodityCode.create('CHF').valueOf(), + isClosed: true, + isTombstone: false, + name: 'Swiss Franc', + precision: 2, + symbol: 'CHF', }); - expect(response.statusCode).toBe(200); - - const parsedResponse = parseResponse(response); - - expect(parsedResponse).toHaveLength(activeCommoditiesData.length); - - compareEntityArrays(activeCommoditiesData, parsedResponse); + listCommodities = [...commoditiesDbRows, closedCommodity]; }); - it('should return archived commodities when status=archived is provided', async () => { - const response = await injectAuthorized({ - method: 'GET', - url: `${url}?status=archived`, - }); - - expect(response.statusCode).toBe(200); + testCases.forEach(({ description, expectedCommodities, query }) => { + it(description, async () => { + const queryString = query.status + ? new URLSearchParams({ status: query.status }).toString() + : ''; + const requestUrl = queryString ? `${url}?${queryString}` : url; - const parsedResponse = parseResponse(response); + const response = await injectAuthorized({ + method: 'GET', + url: requestUrl, + }); - expect(parsedResponse).toHaveLength(1); + expect(response.statusCode).toBe(200); - compareEntityArrays([archivedCommodityData1], parsedResponse); - }); + const responseCommodities = + parseResponse(response); - it('should return all commodities when status=all is provided', async () => { - const response = await injectAuthorized({ - method: 'GET', - url: `${url}?status=all`, + compareEntityArrays( + expectedCommodities(listCommodities), + responseCommodities, + ); }); - - expect(response.statusCode).toBe(200); - - const parsedResponse = parseResponse(response); - - expect(parsedResponse).toHaveLength( - activeCommoditiesData.length + 1, // +1 for the archived commodity - ); - - compareEntityArrays( - [...activeCommoditiesData, archivedCommodityData1], - parsedResponse, - ); }); it('should return 400 for invalid status query parameter', async () => { @@ -204,7 +223,7 @@ describe('Commodities Integration Tests', () => { const response = await injectAuthorized({ method: 'GET', - url: `${url}?status=all`, + url: getWithQueryParamsUrl('all'), }); expect(response.statusCode).toBe(200); @@ -215,15 +234,16 @@ describe('Commodities Integration Tests', () => { expect(commodity.code).not.toBe(otherUserCommodity.code); }); - expect(parsedResponse).toHaveLength(allCommoditiesData.length); - - compareEntityArrays(allCommoditiesData, parsedResponse); + compareEntityArrays( + listCommodities.filter((commodity) => !commodity.isTombstone), + parsedResponse, + ); }); it('should return 401 without authorization', async () => { const response = await server.inject({ method: 'GET', - url: `${url}?status=all`, + url: getWithQueryParamsUrl('all'), }); expect(response.statusCode).toBe(401); @@ -708,19 +728,19 @@ describe('Commodities Integration Tests', () => { ); }); - it('should return the archived commodity in the list marked as archived', async () => { - const commodityToArchive = commoditiesDbRows[0]; + it('should not return the deleted commodity in the list', async () => { + const commodityToDelete = commoditiesDbRows[0]; const deleteResponse = await injectAuthorized({ method: 'DELETE', - url: `${url}/${commodityToArchive.id}`, + url: `${url}/${commodityToDelete.id}`, }); expect(deleteResponse.statusCode).toBe(204); const listResponse = await injectAuthorized({ method: 'GET', - url: `${url}?status=archived`, + url: `${url}?status=all`, }); expect(listResponse.statusCode).toBe(200); @@ -728,23 +748,12 @@ describe('Commodities Integration Tests', () => { const parsedListResponse = parseResponse(listResponse); - expect(parsedListResponse).toHaveLength( - archivedCommoditiesData.length + 1, - ); + expect(parsedListResponse).toHaveLength(activeCommoditiesData.length - 1); - compareEntityArrays( - [ - archivedCommodityData1, - { - code: commodityToArchive.code, - isTombstone: true, - name: commodityToArchive.name, - precision: commodityToArchive.precision, - symbol: commodityToArchive.symbol, - }, - ], - parsedListResponse, - ); + parsedListResponse.forEach((commodity) => { + expect(commodity.id).not.toBe(commodityToDelete.id); + expect(commodity.isTombstone).toBe(false); + }); }); it('should return 404 when archiving another user commodity', async () => { diff --git a/apps/backend/src/presentation/http/commodities/commodity.routes.ts b/apps/backend/src/presentation/http/commodities/commodity.routes.ts index feb76f25..badcf470 100644 --- a/apps/backend/src/presentation/http/commodities/commodity.routes.ts +++ b/apps/backend/src/presentation/http/commodities/commodity.routes.ts @@ -25,7 +25,7 @@ export const commodityRoutes = (app: FastifyInstance) => { app.delete('/:id', async (request, reply) => { const user = request.user; - await commodityController.archiveCommodity(user, request.params); + await commodityController.delete(user, request.params); reply.status(204).send(); }); diff --git a/docs/DOMAIN.md b/docs/DOMAIN.md index 0d61ae75..b6a5c742 100644 --- a/docs/DOMAIN.md +++ b/docs/DOMAIN.md @@ -72,6 +72,11 @@ Represents different financial accounts with unified structure for all account t - 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 +- `DELETE /accounts/:id` should be idempotent for an account that already + belongs to the user and already has `isTombstone = true`; current code has a + repository hook named `getByIdForLifecycle(...)` for that delete-specific + lookup, but it is not wired into the delete use case yet and should not be + used for normal reads, update, close or open flows ### Commodity 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 fa5e8b5a..3905d14a 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 @@ -103,6 +103,18 @@ POST /accounts/:id/open `PATCH /accounts/:id` remains for editable account attributes, not lifecycle commands. +Account terminal delete should be idempotent at the HTTP and use case level. +Repeating `DELETE /accounts/:id` for an account that already belongs to the user +and already has `isTombstone = true` should keep the tombstone state and return +the delete success response without changing `updatedAt` again. Normal account +reads, list filters, update, close and open still exclude tombstoned accounts. + +The current `AccountRepository.getByIdForLifecycle(...)` method is an unfinished +implementation hook for that delete-specific lookup. It intentionally reads +tombstoned accounts, but at the moment it is not wired into the account delete +use case, so it is redundant outside tests until the follow-up work is applied. +It must not become the general account read path. + ### Commodity An archived commodity: diff --git a/packages/shared/src/constants/accounts.ts b/packages/shared/src/constants/accounts.ts index c1ab75ad..7268127a 100644 --- a/packages/shared/src/constants/accounts.ts +++ b/packages/shared/src/constants/accounts.ts @@ -10,3 +10,6 @@ export const ACCOUNT_TYPES = [ ] as const satisfies readonly AccountTypeValue[]; export const ACCOUNT_STATUS_FILTER_VALUES = ["open", "closed", "all"] as const; + +export type AccountStatusFilterValue = + (typeof ACCOUNT_STATUS_FILTER_VALUES)[number]; diff --git a/packages/shared/src/constants/commodities.ts b/packages/shared/src/constants/commodities.ts index 9a427508..b239184e 100644 --- a/packages/shared/src/constants/commodities.ts +++ b/packages/shared/src/constants/commodities.ts @@ -1,5 +1,8 @@ export const COMMODITY_STATUS_FILTER_VALUES = [ - "active", - "archived", + "open", + "closed", "all", ] as const; + +export type CommodityStatusFilterValue = + (typeof COMMODITY_STATUS_FILTER_VALUES)[number]; diff --git a/packages/shared/src/validation/commodities.test.ts b/packages/shared/src/validation/commodities.test.ts index 2286299d..c78cff5f 100644 --- a/packages/shared/src/validation/commodities.test.ts +++ b/packages/shared/src/validation/commodities.test.ts @@ -3,8 +3,14 @@ import { describe, expect, it } from "vitest"; import { commodityQuerySchema } from "./commodities"; describe("commodityQuerySchema", () => { - it("defaults omitted status filter to active commodities", () => { - expect(commodityQuerySchema.parse({})).toEqual({ status: "active" }); + it("defaults omitted status filter to open commodities", () => { + expect(commodityQuerySchema.parse({})).toEqual({ status: "open" }); + }); + + it("allows querying closed commodities", () => { + expect(commodityQuerySchema.parse({ status: "closed" })).toEqual({ + status: "closed", + }); }); it("allows querying all commodities", () => { diff --git a/packages/shared/src/validation/commodities.ts b/packages/shared/src/validation/commodities.ts index 3a284e24..f91ba061 100644 --- a/packages/shared/src/validation/commodities.ts +++ b/packages/shared/src/validation/commodities.ts @@ -45,7 +45,7 @@ export const commodityUpdateSchema = commodityCreateSchema export const commodityStatusFilter = z .enum(COMMODITY_STATUS_FILTER_VALUES) - .default("active"); + .default("open"); export const commodityQuerySchema = z.object({ status: commodityStatusFilter, From 76e42d4959b4be81787d3283d0229e6c16e545b8 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Wed, 5 Aug 2026 10:05:29 +0300 Subject: [PATCH 14/31] LED-125 Refactor commodity lifecycle actions and repository methods - Renamed LifecycleAction to AccountLifecycleAction for clarity. - Updated AccountLifecycleUpdateInput to use the new action type. - Introduced getByIdForLifecycle method in CommodityRepository for lifecycle management. - Modified delete and update methods in CommodityRepository to return void instead of CommoditySnapshot. - Updated DeleteCommodityUseCase and UpdateCommodityUseCase to utilize TransactionManagerInterface for transaction handling. - Adjusted tests to reflect changes in repository methods and ensure proper lifecycle handling. - Updated integration tests to verify behavior when dealing with deleted commodities. --- .../interfaces/AccountRepository.interface.ts | 4 +- .../CommodityRepository.interface.ts | 15 +- .../src/application/interfaces/index.ts | 2 +- .../__tests__/deleteCommodity.test.ts | 75 ++++-- .../__tests__/updateCommodity.test.ts | 45 +++- .../usecases/commodities/deleteCommodity.ts | 45 ++-- .../usecases/commodities/updateCommodity.ts | 66 +++-- apps/backend/src/di/container.ts | 1 + .../db/accounts/account.repository.ts | 28 +- .../commodities/commodity.repository.test.ts | 241 +++++++++++++----- .../db/commodities/commodity.repository.ts | 119 +++++---- .../http/commodities/commodity.controller.ts | 7 +- .../commodities/commodity.integration.test.ts | 57 +---- 13 files changed, 446 insertions(+), 259 deletions(-) diff --git a/apps/backend/src/application/interfaces/AccountRepository.interface.ts b/apps/backend/src/application/interfaces/AccountRepository.interface.ts index b6b5ad0a..433e259f 100644 --- a/apps/backend/src/application/interfaces/AccountRepository.interface.ts +++ b/apps/backend/src/application/interfaces/AccountRepository.interface.ts @@ -9,10 +9,10 @@ export type AccountRepositoryUpdateInput = Pick & export type AccountRepositoryLifecycleInput = { updatedAt: IsoDatetimeString }; -export type LifecycleAction = 'close' | 'open'; +export type AccountLifecycleAction = 'close' | 'open'; export type AccountLifecycleUpdateInput = AccountRepositoryLifecycleInput & { - action: LifecycleAction; + action: AccountLifecycleAction; }; export type AccountRepositoryInterface = { diff --git a/apps/backend/src/application/interfaces/CommodityRepository.interface.ts b/apps/backend/src/application/interfaces/CommodityRepository.interface.ts index 3b33f66d..656addf7 100644 --- a/apps/backend/src/application/interfaces/CommodityRepository.interface.ts +++ b/apps/backend/src/application/interfaces/CommodityRepository.interface.ts @@ -26,29 +26,30 @@ export type CommodityLifecycleUpdateInput = export type CommodityRepositoryInterface = { getById(userId: UUID, commodityId: UUID): Promise; - getAll(userId: UUID, status: CommodityQuery): Promise; - create( + getByIdForLifecycle( userId: UUID, - commodity: CommoditySnapshot, + commodityId: UUID, ): Promise; + getAll(userId: UUID, status: CommodityQuery): Promise; + create(userId: UUID, commodity: CommoditySnapshot): Promise; update( userId: UUID, commodityId: UUID, commodity: CommodityRepositoryUpdateInput, - ): Promise; + ): Promise; delete( userId: UUID, commodityId: UUID, data: CommodityRepositorySoftDeleteInput, - ): Promise; + ): Promise; open( userId: UUID, commodityId: UUID, data: CommodityRepositoryLifecycleInput, - ): Promise; + ): Promise; close( userId: UUID, commodityId: UUID, data: CommodityRepositoryLifecycleInput, - ): Promise; + ): Promise; }; diff --git a/apps/backend/src/application/interfaces/index.ts b/apps/backend/src/application/interfaces/index.ts index 2df64f8d..3b09c72c 100644 --- a/apps/backend/src/application/interfaces/index.ts +++ b/apps/backend/src/application/interfaces/index.ts @@ -1,9 +1,9 @@ export type { + AccountLifecycleAction, AccountLifecycleUpdateInput, AccountRepositoryInterface, AccountRepositoryLifecycleInput, AccountRepositoryUpdateInput, - LifecycleAction, } from './AccountRepository.interface'; export type { UserRepositoryInterface } from './userRepository.interface'; export type { diff --git a/apps/backend/src/application/usecases/commodities/__tests__/deleteCommodity.test.ts b/apps/backend/src/application/usecases/commodities/__tests__/deleteCommodity.test.ts index 9ca86fd8..ed331fa1 100644 --- a/apps/backend/src/application/usecases/commodities/__tests__/deleteCommodity.test.ts +++ b/apps/backend/src/application/usecases/commodities/__tests__/deleteCommodity.test.ts @@ -1,4 +1,7 @@ -import { CommodityRepositoryInterface, CommodityMapper } from 'src/application'; +import { + CommodityRepositoryInterface, + TransactionManagerInterface, +} from 'src/application'; import { EntityNotFoundError, UnauthorizedAccessError, @@ -18,17 +21,19 @@ describe('DeleteCommodityUseCase', () => { const commodityRepository = { delete: vi.fn(), - getById: vi.fn(), + getByIdForLifecycle: vi.fn(), }; const mockTransactionManager = { run: vi.fn(), }; + const runTransaction: TransactionManagerInterface['run'] = async (callback) => + callback(); const deleteCommodityUseCase = new DeleteCommodityUseCase( commodityRepository as unknown as CommodityRepositoryInterface, ensureOwnedSnapshot, - mockTransactionManager, + mockTransactionManager as unknown as TransactionManagerInterface, ); beforeAll(async () => { @@ -37,8 +42,10 @@ describe('DeleteCommodityUseCase', () => { beforeEach(() => { vi.useRealTimers(); + mockTransactionManager.run.mockImplementation(runTransaction); commodityRepository.delete.mockClear(); - commodityRepository.getById.mockClear(); + commodityRepository.getByIdForLifecycle.mockClear(); + mockTransactionManager.run.mockClear(); }); describe('execute', () => { @@ -61,29 +68,22 @@ describe('DeleteCommodityUseCase', () => { const commodityId = commodity.getId().valueOf(); const userid = user.getId().valueOf(); - const archivedCommodityData = { - ...commodity.toSnapshot(), - isTombstone: true, - timestamps: { - createdAt: commodity.getCreatedAt().valueOf(), - updatedAt: Timestamp.restore(timestampDuringUpdatingValue).valueOf(), - }, - }; + const updatedAt = Timestamp.restore( + timestampDuringUpdatingValue, + ).valueOf(); - commodityRepository.getById.mockResolvedValue(commodity.toSnapshot()); - commodityRepository.delete.mockResolvedValue(archivedCommodityData); + commodityRepository.getByIdForLifecycle.mockResolvedValue( + commodity.toSnapshot(), + ); + commodityRepository.delete.mockResolvedValue(undefined); vi.setSystemTime(new Date(timestampDuringUpdatingValue)); - const expectedResult = CommodityMapper.toResponseDTOFromSnapshot( - archivedCommodityData, - ); - // Act const result = await deleteCommodityUseCase.execute(user, commodityId); // Assert - expect(commodityRepository.getById).toHaveBeenCalledWith( + expect(commodityRepository.getByIdForLifecycle).toHaveBeenCalledWith( userid, commodityId, ); @@ -91,17 +91,44 @@ describe('DeleteCommodityUseCase', () => { expect(commodityRepository.delete).toHaveBeenCalledWith( userid, commodityId, - { updatedAt: archivedCommodityData.timestamps.updatedAt }, + { updatedAt }, + ); + + expect(mockTransactionManager.run).toHaveBeenCalledTimes(1); + expect(result).toBeUndefined(); + }); + + it('should not call delete when commodity is already deleted', async () => { + const commodity = createCommodity(user, { + code: 'TEST', + name: 'Test Commodity', + precision: 2, + symbol: 'T', + }); + commodity.delete(); + + const commodityId = commodity.getId().valueOf(); + + commodityRepository.getByIdForLifecycle.mockResolvedValue( + commodity.toSnapshot(), ); - expect(result).toMatchObject(expectedResult); + const result = await deleteCommodityUseCase.execute(user, commodityId); + + expect(commodityRepository.getByIdForLifecycle).toHaveBeenCalledWith( + user.getId().valueOf(), + commodityId, + ); + expect(commodityRepository.delete).not.toHaveBeenCalled(); + expect(mockTransactionManager.run).toHaveBeenCalledTimes(1); + expect(result).toBeUndefined(); }); it('should throw an error if the commodity does not exist', async () => { // Arrange const nonExistentCommodityId = Id.create().valueOf(); - commodityRepository.getById.mockResolvedValue(null); + commodityRepository.getByIdForLifecycle.mockResolvedValue(null); // Act & Assert await expect( @@ -126,7 +153,9 @@ describe('DeleteCommodityUseCase', () => { const commodityId = commodity.getId().valueOf(); - commodityRepository.getById.mockResolvedValue(commodity.toSnapshot()); + commodityRepository.getByIdForLifecycle.mockResolvedValue( + commodity.toSnapshot(), + ); // Act & Assert await expect( 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 e4c9a8fa..ea00473d 100644 --- a/apps/backend/src/application/usecases/commodities/__tests__/updateCommodity.test.ts +++ b/apps/backend/src/application/usecases/commodities/__tests__/updateCommodity.test.ts @@ -1,5 +1,8 @@ import { CommodityResponseDTO } from '@ledgerly/shared/types'; -import { CommodityRepositoryInterface } from 'src/application'; +import { + CommodityRepositoryInterface, + TransactionManagerInterface, +} from 'src/application'; import { EntityNotFoundError, UnauthorizedAccessError, @@ -22,9 +25,16 @@ describe('UpdateCommodityUseCase', () => { update: vi.fn(), }; + const mockTransactionManager = { + run: vi.fn(), + }; + const runTransaction: TransactionManagerInterface['run'] = async (callback) => + callback(); + const updateCommodityUseCase = new UpdateCommodityUseCase( commodityRepository as unknown as CommodityRepositoryInterface, ensureOwnedSnapshot, + mockTransactionManager as unknown as TransactionManagerInterface, ); beforeAll(async () => { @@ -33,8 +43,10 @@ describe('UpdateCommodityUseCase', () => { beforeEach(() => { vi.useRealTimers(); + mockTransactionManager.run.mockImplementation(runTransaction); commodityRepository.update.mockClear(); commodityRepository.getById.mockClear(); + mockTransactionManager.run.mockClear(); }); describe('execute', () => { @@ -70,7 +82,7 @@ describe('UpdateCommodityUseCase', () => { commodityRepository.getById.mockResolvedValue(commodity.toSnapshot()); - commodityRepository.update.mockResolvedValue(updatedCommodityData); + commodityRepository.update.mockResolvedValue(undefined); vi.setSystemTime(new Date(timestampDuringUpdatingValue)); @@ -101,6 +113,7 @@ describe('UpdateCommodityUseCase', () => { }, ); + expect(mockTransactionManager.run).toHaveBeenCalledTimes(1); expect(result).toEqual(updatedCommoditySnapshot); }); @@ -124,6 +137,34 @@ describe('UpdateCommodityUseCase', () => { ); }); + it('should throw an error if the commodity is deleted', async () => { + const commodity = createCommodity(user, { + code: 'TEST', + name: 'Test Commodity', + precision: 2, + symbol: 'T', + }); + commodity.delete(); + + const commodityId = commodity.getId().valueOf(); + + commodityRepository.getById.mockResolvedValue(commodity.toSnapshot()); + + await expect( + updateCommodityUseCase.execute(user, commodityId, { + name: 'Updated Commodity', + symbol: 'U', + }), + ).rejects.toThrowError( + new EntityNotFoundError({ + entityId: commodityId, + entityType: Commodity.entityType, + }), + ); + + expect(commodityRepository.update).not.toHaveBeenCalled(); + }); + it('should throw an error if the commodity does not belong to the user', async () => { // Arrange const otherUser = await createUser(); diff --git a/apps/backend/src/application/usecases/commodities/deleteCommodity.ts b/apps/backend/src/application/usecases/commodities/deleteCommodity.ts index 94cd50da..8ed26a77 100644 --- a/apps/backend/src/application/usecases/commodities/deleteCommodity.ts +++ b/apps/backend/src/application/usecases/commodities/deleteCommodity.ts @@ -1,6 +1,4 @@ -import { CommodityResponseDTO, UUID } from '@ledgerly/shared/types'; -import { CommodityMapper } from 'src/application'; -import { EntityNotFoundError } from 'src/application/application.errors'; +import { UUID } from '@ledgerly/shared/types'; import { CommodityRepositoryInterface, TransactionManagerInterface, @@ -16,32 +14,31 @@ export class DeleteCommodityUseCase { private readonly transactionManager: TransactionManagerInterface, ) {} - async execute(user: User, commodityId: UUID): Promise { - const commodityData = await this.ensureOwnedSnapshot({ - entityId: commodityId, - entityType: Commodity.entityType, - getOwnerId: (commodity) => commodity.userId, - load: this.commodityRepository.getById.bind(this.commodityRepository), - user, - }); - - if (commodityData.isTombstone) { - throw new EntityNotFoundError({ + async execute(user: User, commodityId: UUID): Promise { + await this.transactionManager.run(async () => { + const commodityData = await this.ensureOwnedSnapshot({ entityId: commodityId, entityType: Commodity.entityType, + getOwnerId: (commodity) => commodity.userId, + load: this.commodityRepository.getByIdForLifecycle.bind( + this.commodityRepository, + ), + user, }); - } - const commodity = Commodity.restore(commodityData); + const commodity = Commodity.restore(commodityData); - commodity.delete(); + const result = commodity.delete(); - const updatedCommodity = await this.commodityRepository.delete( - user.getId().valueOf(), - commodityId, - { updatedAt: commodity.getUpdatedAt().valueOf() }, - ); - - return CommodityMapper.toResponseDTOFromSnapshot(updatedCommodity); + if (result === 'changed') { + await this.commodityRepository.delete( + user.getId().valueOf(), + commodityId, + { + updatedAt: commodity.getUpdatedAt().valueOf(), + }, + ); + } + }); } } diff --git a/apps/backend/src/application/usecases/commodities/updateCommodity.ts b/apps/backend/src/application/usecases/commodities/updateCommodity.ts index 0ef19de6..8b93fba0 100644 --- a/apps/backend/src/application/usecases/commodities/updateCommodity.ts +++ b/apps/backend/src/application/usecases/commodities/updateCommodity.ts @@ -5,7 +5,10 @@ import { } from '@ledgerly/shared/types'; import { CommodityMapper } from 'src/application'; import { EntityNotFoundError } from 'src/application/application.errors'; -import { CommodityRepositoryInterface } from 'src/application/interfaces'; +import { + CommodityRepositoryInterface, + TransactionManagerInterface, +} from 'src/application/interfaces'; import { EnsureOwnedSnapshotFn } from 'src/application/shared/ensureOwnedSnapshot'; import { Commodity, CommoditySnapshot } from 'src/domain/commodities'; import { User } from 'src/domain/users/user.entity'; @@ -14,6 +17,7 @@ export class UpdateCommodityUseCase { constructor( protected readonly commodityRepository: CommodityRepositoryInterface, protected readonly ensureOwnedSnapshot: EnsureOwnedSnapshotFn, + private readonly transactionManager: TransactionManagerInterface, ) {} async execute( @@ -21,31 +25,43 @@ export class UpdateCommodityUseCase { commodityId: UUID, data: CommodityUpdateDTO, ): Promise { - const commodityData = await this.ensureOwnedSnapshot({ - entityId: commodityId, - entityType: Commodity.entityType, - getOwnerId: (commodity) => commodity.userId, - load: this.commodityRepository.getById.bind(this.commodityRepository), - user, - }); - - if (commodityData.isTombstone) { - throw new EntityNotFoundError({ - entityId: commodityId, - entityType: Commodity.entityType, - }); - } - - const commodity = Commodity.restore(commodityData); - - commodity.update(CommodityMapper.toUpdateProps(data)); - - const updatedCommodity = await this.commodityRepository.update( - user.getId().valueOf(), - commodityId, - commodity.toSnapshot(), + const updatedCommoditySnapshot = await this.transactionManager.run( + async () => { + const commodityData = await this.ensureOwnedSnapshot( + { + entityId: commodityId, + entityType: Commodity.entityType, + getOwnerId: (commodity) => commodity.userId, + load: this.commodityRepository.getById.bind( + this.commodityRepository, + ), + user, + }, + ); + + const commodity = Commodity.restore(commodityData); + + if (commodity.isDeleted()) { + throw new EntityNotFoundError({ + entityId: commodityId, + entityType: Commodity.entityType, + }); + } + + commodity.update(CommodityMapper.toUpdateProps(data)); + + const updatedCommoditySnapshot = commodity.toSnapshot(); + + await this.commodityRepository.update( + user.getId().valueOf(), + commodityId, + updatedCommoditySnapshot, + ); + + return updatedCommoditySnapshot; + }, ); - return CommodityMapper.toResponseDTOFromSnapshot(updatedCommodity); + return CommodityMapper.toResponseDTOFromSnapshot(updatedCommoditySnapshot); } } diff --git a/apps/backend/src/di/container.ts b/apps/backend/src/di/container.ts index 56cc360a..fce5c6e9 100644 --- a/apps/backend/src/di/container.ts +++ b/apps/backend/src/di/container.ts @@ -158,6 +158,7 @@ export const createContainer = (db: DataBase): AppContainer => { const updateCommodityUseCase = new UpdateCommodityUseCase( commodityRepository, ensureOwnedSnapshot, + transactionManager, ); const archiveCommodityUseCase = new DeleteCommodityUseCase( diff --git a/apps/backend/src/infrastructure/db/accounts/account.repository.ts b/apps/backend/src/infrastructure/db/accounts/account.repository.ts index 571c79a9..fb4efd0e 100644 --- a/apps/backend/src/infrastructure/db/accounts/account.repository.ts +++ b/apps/backend/src/infrastructure/db/accounts/account.repository.ts @@ -6,7 +6,7 @@ import { type AccountRepositoryInterface, type AccountRepositoryLifecycleInput, type AccountRepositoryUpdateInput, - type LifecycleAction, + type AccountLifecycleAction, } from 'src/application'; import { accountsTable } from 'src/db/schemas/accounts'; import { commoditiesTable } from 'src/db/schemas/commodities'; @@ -38,8 +38,24 @@ const getWhereClauseForGetAll = (userId: UUID, query: AccountQuery) => { ); }; +const getWhereClauseForGetByIdInternal = ( + userId: UUID, + id: UUID, + options: { includeTombstone: boolean }, +) => { + if (options.includeTombstone) { + return and(eq(accountsTable.id, id), eq(accountsTable.userId, userId)); + } + + return and( + eq(accountsTable.id, id), + eq(accountsTable.userId, userId), + eq(accountsTable.isTombstone, false), + ); +}; + const lifecycleMapper: Record< - LifecycleAction, + AccountLifecycleAction, { name: string; params: Record } > = { close: { name: 'close', params: { isClosed: true } }, @@ -135,13 +151,7 @@ export class AccountRepository options: { includeTombstone: boolean }, ): Promise { return this.executeDatabaseOperation(async () => { - const whereClause = options.includeTombstone - ? and(eq(accountsTable.id, id), eq(accountsTable.userId, userId)) - : and( - eq(accountsTable.id, id), - eq(accountsTable.userId, userId), - eq(accountsTable.isTombstone, false), - ); + const whereClause = getWhereClauseForGetByIdInternal(userId, id, options); const account = await this.db .select() 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 634277b0..c64b925a 100644 --- a/apps/backend/src/infrastructure/db/commodities/commodity.repository.test.ts +++ b/apps/backend/src/infrastructure/db/commodities/commodity.repository.test.ts @@ -76,6 +76,16 @@ describe('CommodityRepository', () => { }); }); + it('should throw an error when the commodity is tombstoned', async () => { + const tombstonedCommodity = await testDB.createCommodity(user.id, { + isTombstone: true, + }); + + await expect( + commodityRepository.getById(user.id, tombstonedCommodity.id), + ).rejects.toThrowError(RepositoryNotFoundError); + }); + it('should throw an error when the commodity belongs to a different user', async () => { const anotherUser = await testDB.createUser(); @@ -85,6 +95,52 @@ describe('CommodityRepository', () => { }); }); + describe('getByIdForLifecycle', () => { + it('should retrieve an active commodity by id', async () => { + const commodityDbRow = await testDB.createCommodity(user.id); + + const retrievedCommodity = await commodityRepository.getByIdForLifecycle( + user.id, + commodityDbRow.id, + ); + + compareEntities(retrievedCommodity, commodityDbRow); + }); + + it('should retrieve a tombstoned commodity by id', async () => { + const tombstonedCommodity = await testDB.createCommodity(user.id, { + isTombstone: true, + }); + + const retrievedCommodity = await commodityRepository.getByIdForLifecycle( + user.id, + tombstonedCommodity.id, + ); + + expect(tombstonedCommodity.isTombstone).toBe(true); + + compareEntities(retrievedCommodity, tombstonedCommodity); + }); + + it('should throw RepositoryNotFoundError when commodity does not exist', async () => { + await expect( + commodityRepository.getByIdForLifecycle(user.id, Id.create().valueOf()), + ).rejects.toThrowError(RepositoryNotFoundError); + }); + + it('should throw RepositoryNotFoundError when user does not own the commodity', async () => { + const anotherUser = await testDB.createUser(); + const commodityDbRow = await testDB.createCommodity(user.id); + + await expect( + commodityRepository.getByIdForLifecycle( + anotherUser.id, + commodityDbRow.id, + ), + ).rejects.toThrowError(RepositoryNotFoundError); + }); + }); + describe('getAll', () => { const commoditiesDataList = [ { @@ -215,19 +271,14 @@ describe('CommodityRepository', () => { userId: user.id, }; - const createdCommodity = await commodityRepository.create( - user.id, - newCommodityData, - ); - - expect(createdCommodity).toMatchObject(newCommodityData); + await commodityRepository.create(user.id, newCommodityData); const retrievedCommodity = await commodityRepository.getById( user.id, - createdCommodity.id, + newCommodityData.id, ); - expect(retrievedCommodity).toEqual(createdCommodity); + expect(retrievedCommodity).toMatchObject(newCommodityData); }); it('should throw an error when trying to create a commodity with an existing CommodityCode', async () => { @@ -277,19 +328,14 @@ describe('CommodityRepository', () => { userId: anotherUser.id, }; - const createdCommodityForAnotherUser = await commodityRepository.create( - anotherUser.id, - newCommodityData, - ); - - expect(createdCommodityForAnotherUser).toMatchObject(newCommodityData); + await commodityRepository.create(anotherUser.id, newCommodityData); const retrievedCommodity = await commodityRepository.getById( anotherUser.id, - createdCommodityForAnotherUser.id, + newCommodityData.id, ); - expect(retrievedCommodity).toEqual(createdCommodityForAnotherUser); + expect(retrievedCommodity).toMatchObject(newCommodityData); }); }); @@ -307,22 +353,19 @@ describe('CommodityRepository', () => { updatedAt: Timestamp.create().valueOf(), }; - const updatedCommodity = await commodityRepository.update( - user.id, - commodityDbRow.id, - { ...commodityDbRow, ...updatedData }, - ); - - expect(updatedCommodity.name).toEqual(updatedData.name); - expect(updatedCommodity.code).toEqual(updatedData.code); - expect(updatedCommodity.updatedAt).toEqual(updatedData.updatedAt); + await commodityRepository.update(user.id, commodityDbRow.id, { + ...commodityDbRow, + ...updatedData, + }); const retrievedCommodity = await commodityRepository.getById( user.id, commodityDbRow.id, ); - expect(retrievedCommodity).toEqual(updatedCommodity); + expect(retrievedCommodity.name).toEqual(updatedData.name); + expect(retrievedCommodity.code).toEqual(updatedData.code); + expect(retrievedCommodity.updatedAt).toEqual(updatedData.updatedAt); }); it('should throw an error when the commodity does not exist', async () => { @@ -345,10 +388,11 @@ describe('CommodityRepository', () => { updatedAt: Timestamp.create().valueOf(), }; - const updatedCommodity = await commodityRepository.update( + await commodityRepository.update(user.id, commodityDbRow.id, updatedData); + + const updatedCommodity = await commodityRepository.getById( user.id, commodityDbRow.id, - updatedData, ); expect(updatedCommodity).toMatchObject({ @@ -417,6 +461,52 @@ describe('CommodityRepository', () => { commodityRepository.update(user.id, commodityDbRow.id, updatedData), ).rejects.toThrowError(RepositoryNotFoundError); }); + + it('should not persist isClosed through the regular update method', async () => { + await commodityRepository.update(user.id, commodityDbRow.id, { + isClosed: true, + name: Name.create('Updated Commodity').valueOf(), + updatedAt: Timestamp.create().valueOf(), + } as CommodityRepositoryUpdateInput); + + const retrievedCommodity = await commodityRepository.getById( + user.id, + commodityDbRow.id, + ); + + expect(retrievedCommodity.isClosed).toBe(commodityDbRow.isClosed); + }); + + it('updates only allowlisted fields', async () => { + const maliciousData = { + createdAt: Timestamp.create().valueOf(), + id: Id.create().valueOf(), + isClosed: true, + isTombstone: true, + name: Name.create('Updated Commodity').valueOf(), + precision: 8, + updatedAt: Timestamp.create().valueOf(), + userId: Id.create().valueOf(), + }; + + await commodityRepository.update( + user.id, + commodityDbRow.id, + maliciousData, + ); + + const updatedCommodity = await commodityRepository.getById( + user.id, + commodityDbRow.id, + ); + + expect(updatedCommodity.id).toBe(commodityDbRow.id); + expect(updatedCommodity.userId).toBe(user.id); + expect(updatedCommodity.name).toBe(maliciousData.name); + expect(updatedCommodity.precision).toBe(commodityDbRow.precision); + expect(updatedCommodity.isClosed).toBe(commodityDbRow.isClosed); + expect(updatedCommodity.isTombstone).toBe(false); + }); }); describe('delete', () => { @@ -470,6 +560,53 @@ describe('CommodityRepository', () => { }), ).rejects.toThrowError(RepositoryNotFoundError); }); + + it('should preserve isClosed while marking a commodity as tombstoned', async () => { + const closedCommodity = await testDB.createCommodity(user.id, { + isClosed: true, + }); + + await commodityRepository.delete(user.id, closedCommodity.id, { + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }); + + const retrievedCommodity = await testDB.getCommodityById( + closedCommodity.id, + ); + + expect(retrievedCommodity).toBeDefined(); + expect(retrievedCommodity?.isClosed).toBe(true); + expect(retrievedCommodity?.isTombstone).toBe(true); + }); + + it('should not allow updating any other fields while marking a commodity as tombstoned', async () => { + const maliciousData = { + code: CommodityCode.create('MAL').valueOf(), + createdAt: Timestamp.create().valueOf(), + id: Id.create().valueOf(), + isClosed: true, + name: Name.create('Malicious Commodity').valueOf(), + precision: 8, + symbol: 'M', + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + userId: Id.create().valueOf(), + }; + + await commodityRepository.delete( + user.id, + commodityDbRow.id, + maliciousData, + ); + + const updatedCommodity = await testDB.getCommodityById(commodityDbRow.id); + + expect(updatedCommodity?.isTombstone).toBe(true); + + compareEntities(updatedCommodity!, commodityDbRow, [ + 'isTombstone', + 'updatedAt', + ]); + }); }); describe('close', () => { @@ -482,15 +619,7 @@ describe('CommodityRepository', () => { it("updates only the 'isClosed' and 'updatedAt' fields when closing a commodity", async () => { const updatedAt = Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(); - const closedCommodity = await commodityRepository.close( - user.id, - commodityDbRow.id, - { updatedAt }, - ); - - expect(closedCommodity).toMatchObject({ - id: commodityDbRow.id, - isClosed: true, + await commodityRepository.close(user.id, commodityDbRow.id, { updatedAt, }); @@ -505,7 +634,7 @@ describe('CommodityRepository', () => { throw new Error('Failed to retrieve commodity for comparison'); } - compareEntities(retrievedCommodity, closedCommodity, [ + compareEntities(retrievedCommodity, commodityDbRow, [ 'isClosed', 'updatedAt', ]); @@ -545,16 +674,8 @@ describe('CommodityRepository', () => { commodityDbRow.id, ); - const secondCloseAttempt = await commodityRepository.close( - user.id, - commodityDbRow.id, - { updatedAt: ignoredUpdatedAt }, - ); - - expect(secondCloseAttempt).toMatchObject({ - id: commodityDbRow.id, - isClosed: true, - updatedAt: initialUpdatedAt, + await commodityRepository.close(user.id, commodityDbRow.id, { + updatedAt: ignoredUpdatedAt, }); const retrievedCommodity = await testDB.getCommodityById( @@ -593,15 +714,7 @@ describe('CommodityRepository', () => { it('should open the closed commodity when it exists', async () => { const updatedAt = Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(); - const openedCommodity = await commodityRepository.open( - user.id, - closedCommodityDbRow.id, - { updatedAt }, - ); - - expect(openedCommodity).toMatchObject({ - id: closedCommodityDbRow.id, - isClosed: false, + await commodityRepository.open(user.id, closedCommodityDbRow.id, { updatedAt, }); @@ -616,7 +729,7 @@ describe('CommodityRepository', () => { throw new Error('Failed to retrieve commodity for comparison'); } - compareEntities(retrievedCommodity, openedCommodity, [ + compareEntities(retrievedCommodity, closedCommodityDbRow, [ 'isClosed', 'updatedAt', ]); @@ -638,16 +751,8 @@ describe('CommodityRepository', () => { closedCommodityDbRow.id, ); - const secondOpenAttempt = await commodityRepository.open( - user.id, - closedCommodityDbRow.id, - { updatedAt: ignoredUpdatedAt }, - ); - - expect(secondOpenAttempt).toMatchObject({ - id: closedCommodityDbRow.id, - isClosed: false, - updatedAt: initialUpdatedAt, + await commodityRepository.open(user.id, closedCommodityDbRow.id, { + updatedAt: ignoredUpdatedAt, }); const retrievedCommodity = await testDB.getCommodityById( diff --git a/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts b/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts index a461cc83..4300150b 100644 --- a/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts +++ b/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts @@ -11,7 +11,10 @@ import { } from 'src/application'; import { commoditiesTable } from 'src/db/schemas/commodities'; import { CommoditySnapshot } from 'src/domain/commodities/types'; -import { RepositoryInvariantError } from 'src/infrastructure/errors'; +import { + RepositoryInvariantError, + RepositoryNotFoundError, +} from 'src/infrastructure/errors'; import { BaseRepository } from '../BaseRepository'; @@ -34,6 +37,25 @@ const getWhereClauseForGetAll = (userId: UUID, query: CommodityQuery) => { ); }; +const getWhereClauseForGetByIdInternal = ( + userId: UUID, + id: UUID, + options: { includeTombstone: boolean }, +) => { + if (options.includeTombstone) { + return and( + eq(commoditiesTable.userId, userId), + eq(commoditiesTable.id, id), + ); + } + + return and( + eq(commoditiesTable.userId, userId), + eq(commoditiesTable.id, id), + eq(commoditiesTable.isTombstone, false), + ); +}; + const lifecycleMapper: Record< CommodityLifecycleAction, { name: string; params: Record } @@ -46,14 +68,25 @@ export class CommodityRepository extends BaseRepository implements CommodityRepositoryInterface { - getById(userId: UUID, id: UUID): Promise { + private commodityNotFoundError(id: UUID): RepositoryNotFoundError { + return new RepositoryNotFoundError( + `Commodity with ID ${id} not found`, + this.entityNotFoundContext('commodity', id), + ); + } + + private getByIdInternal( + userId: UUID, + id: UUID, + options: { includeTombstone: boolean }, + ): Promise { return this.executeDatabaseOperation(async () => { + const whereClause = getWhereClauseForGetByIdInternal(userId, id, options); + const commodity = await this.db .select() .from(commoditiesTable) - .where( - and(eq(commoditiesTable.userId, userId), eq(commoditiesTable.id, id)), - ) + .where(whereClause) .get(); const existingCommodity = this.ensureEntityExists( @@ -66,6 +99,14 @@ export class CommodityRepository }, 'Failed to fetch commodity'); } + getByIdForLifecycle(userId: UUID, id: UUID): Promise { + return this.getByIdInternal(userId, id, { includeTombstone: true }); + } + + getById(userId: UUID, id: UUID): Promise { + return this.getByIdInternal(userId, id, { includeTombstone: false }); + } + getAll(userId: UUID, query: CommodityQuery): Promise { return this.executeDatabaseOperation(async () => { const whereClause = getWhereClauseForGetAll(userId, query); @@ -82,10 +123,7 @@ export class CommodityRepository }, 'Failed to fetch commodities'); } - create( - userId: UUID, - commodity: CommoditySnapshot, - ): Promise { + create(userId: UUID, commodity: CommoditySnapshot): Promise { return this.executeDatabaseOperation( async () => { if (commodity.userId !== userId) { @@ -97,13 +135,11 @@ export class CommodityRepository const commodityRow = CommodityPersistenceMapper.toDBRowFromSnapshot(commodity); - const createdCommodity = await this.db + await this.db .insert(commoditiesTable) .values(commodityRow) .returning() .get(); - - return CommodityPersistenceMapper.toSnapshot(createdCommodity); }, 'Failed to create commodity', { @@ -120,7 +156,7 @@ export class CommodityRepository userId: UUID, commodityId: UUID, commodity: CommodityRepositoryUpdateInput, - ): Promise { + ): Promise { return this.executeDatabaseOperation( async () => { const safeData = this.getSafeUpdate(commodity, [ @@ -130,7 +166,7 @@ export class CommodityRepository 'updatedAt', ]); - const updatedCommodity = await this.db + const result = await this.db .update(commoditiesTable) .set(safeData) .where( @@ -139,17 +175,12 @@ export class CommodityRepository eq(commoditiesTable.id, commodityId), eq(commoditiesTable.isTombstone, false), ), - ) - .returning() - .get(); + ); - const existingCommodity = this.ensureEntityExists( - updatedCommodity, - `Commodity with ID ${commodityId} not found`, - this.entityNotFoundContext('commodity', commodityId), + this.ensureRowsAffected( + result.rowsAffected, + this.commodityNotFoundError(commodityId), ); - - return CommodityPersistenceMapper.toSnapshot(existingCommodity); }, 'Failed to update commodity', { @@ -166,9 +197,9 @@ export class CommodityRepository userId: UUID, commodityId: UUID, data: CommodityRepositorySoftDeleteInput, - ): Promise { + ): Promise { return this.executeDatabaseOperation(async () => { - const deletedCommodity = await this.db + const result = await this.db .update(commoditiesTable) .set({ isTombstone: true, updatedAt: data.updatedAt }) .where( @@ -177,17 +208,12 @@ export class CommodityRepository eq(commoditiesTable.id, commodityId), eq(commoditiesTable.isTombstone, false), ), - ) - .returning() - .get(); + ); - const existingCommodity = this.ensureEntityExists( - deletedCommodity, - `Commodity with ID ${commodityId} not found after deletion`, - this.entityNotFoundContext('commodity', commodityId), + this.ensureRowsAffected( + result.rowsAffected, + this.commodityNotFoundError(commodityId), ); - - return CommodityPersistenceMapper.toSnapshot(existingCommodity); }, `Failed to soft delete commodity with ID ${commodityId}`); } @@ -195,12 +221,12 @@ export class CommodityRepository userId: UUID, commodityId: UUID, data: CommodityLifecycleUpdateInput, - ): Promise { + ): Promise { const { action, updatedAt } = data; const { name, params } = lifecycleMapper[action]; const targetIsClosed = params.isClosed; - return this.executeDatabaseOperation(async () => { + return this.executeDatabaseOperation(async () => { const commodity = await this.db .select() .from(commoditiesTable) @@ -220,10 +246,10 @@ export class CommodityRepository ); if (existingCommodity.isClosed === targetIsClosed) { - return CommodityPersistenceMapper.toSnapshot(existingCommodity); + return; } - const updatedCommodity = await this.db + const result = await this.db .update(commoditiesTable) .set({ ...params, updatedAt }) .where( @@ -232,17 +258,12 @@ export class CommodityRepository eq(commoditiesTable.id, commodityId), eq(commoditiesTable.isTombstone, false), ), - ) - .returning() - .get(); + ); - const updatedExistingCommodity = this.ensureEntityExists( - updatedCommodity, - `Commodity with ID ${commodityId} not found`, - this.entityNotFoundContext('commodity', commodityId), + this.ensureRowsAffected( + result.rowsAffected, + this.commodityNotFoundError(commodityId), ); - - return CommodityPersistenceMapper.toSnapshot(updatedExistingCommodity); }, `Failed to ${name} commodity with ID ${commodityId}`); } @@ -250,7 +271,7 @@ export class CommodityRepository userId: UUID, commodityId: UUID, data: CommodityRepositoryLifecycleInput, - ): Promise { + ): Promise { return this.updateLifecycle(userId, commodityId, { action: 'open', updatedAt: data.updatedAt, @@ -261,7 +282,7 @@ export class CommodityRepository userId: UUID, commodityId: UUID, data: CommodityRepositoryLifecycleInput, - ): Promise { + ): Promise { return this.updateLifecycle(userId, commodityId, { action: 'close', updatedAt: data.updatedAt, diff --git a/apps/backend/src/presentation/http/commodities/commodity.controller.ts b/apps/backend/src/presentation/http/commodities/commodity.controller.ts index d98e53e4..b1b27789 100644 --- a/apps/backend/src/presentation/http/commodities/commodity.controller.ts +++ b/apps/backend/src/presentation/http/commodities/commodity.controller.ts @@ -61,12 +61,9 @@ export class CommodityController { return this.updateCommodityUseCase.execute(user, id, commodityUpdateDto); } - async delete( - user: User, - requestParams: unknown, - ): Promise { + async delete(user: User, requestParams: unknown): Promise { const { id } = uniqueIdSchema.parse(requestParams); - return this.deleteCommodityUseCase.execute(user, id); + await this.deleteCommodityUseCase.execute(user, id); } } 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 279a9f5e..9d2c37c8 100644 --- a/apps/backend/src/presentation/http/commodities/commodity.integration.test.ts +++ b/apps/backend/src/presentation/http/commodities/commodity.integration.test.ts @@ -274,33 +274,21 @@ describe('Commodities Integration Tests', () => { ); }); - it('should return an archived commodity by id marked as archived', async () => { - const testArchivedCommodityDbRow = commoditiesDbRows.find( + it('should return 404 when the commodity is deleted', async () => { + const tombstoneCommodityRecord = commoditiesDbRows.find( (commodity) => commodity.isTombstone, ); - if (!testArchivedCommodityDbRow) { - throw new Error('No archived commodity found in the test data.'); + if (!tombstoneCommodityRecord) { + throw new Error('No deleted commodity found in the test data.'); } const response = await injectAuthorized({ method: 'GET', - url: `${url}/${testArchivedCommodityDbRow.id}`, + url: `${url}/${tombstoneCommodityRecord.id}`, }); - expect(response.statusCode).toBe(200); - - const parsedResponse = parseResponse(response); - - expect(parsedResponse).toEqual( - expect.objectContaining({ - code: testArchivedCommodityDbRow.code, - isTombstone: testArchivedCommodityDbRow.isTombstone, - name: testArchivedCommodityDbRow.name, - precision: testArchivedCommodityDbRow.precision, - symbol: testArchivedCommodityDbRow.symbol, - }), - ); + expect(response.statusCode).toBe(404); }); it('should return 404 when the commodity does not exist', async () => { @@ -656,7 +644,7 @@ describe('Commodities Integration Tests', () => { }); describe('DELETE /api/commodities/:id', () => { - it('should archive a commodity by id', async () => { + it('should delete a commodity by id', async () => { const commodityToArchive = commoditiesDbRows[0]; const response = await injectAuthorized({ @@ -671,17 +659,7 @@ describe('Commodities Integration Tests', () => { url: `${url}/${commodityToArchive.id}`, }); - expect(getResponse.statusCode).toBe(200); - - const parsedGetResponse = - parseResponse(getResponse); - - expect(parsedGetResponse).toEqual( - expect.objectContaining({ - id: commodityToArchive.id, - isTombstone: true, - }), - ); + expect(getResponse.statusCode).toBe(404); const allCommodities = await testDB.getAllCommoditiesByUserId(userId); @@ -702,7 +680,7 @@ describe('Commodities Integration Tests', () => { ); }); - it('should leave the archived commodity readable by id', async () => { + it('should return 404 when reading an deleted commodity by id', async () => { const archivedCommodity = commoditiesDbRows.find( (commodity) => commodity.isTombstone, ); @@ -716,16 +694,7 @@ describe('Commodities Integration Tests', () => { url: `${url}/${archivedCommodity.id}`, }); - expect(response.statusCode).toBe(200); - - const parsedResponse = parseResponse(response); - - expect(parsedResponse).toEqual( - expect.objectContaining({ - id: archivedCommodity.id, - isTombstone: true, - }), - ); + expect(response.statusCode).toBe(404); }); it('should not return the deleted commodity in the list', async () => { @@ -756,7 +725,7 @@ describe('Commodities Integration Tests', () => { }); }); - it('should return 404 when archiving another user commodity', async () => { + it('should return 404 when deleting another user commodity', async () => { const otherUserCommodity = await testDB.createCommodity(otherUserId, { code: CommodityCode.create('JPY').valueOf(), name: 'Japanese Yen', @@ -772,7 +741,7 @@ describe('Commodities Integration Tests', () => { expect(response.statusCode).toBe(404); }); - it('should return 404 when archiving an already archived commodity', async () => { + it('should return 204 when deleting an already deleted commodity', async () => { const archivedCommodity = commoditiesDbRows.find( (commodity) => commodity.isTombstone, ); @@ -786,7 +755,7 @@ describe('Commodities Integration Tests', () => { url: `${url}/${archivedCommodity.id}`, }); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(204); }); it('should return 400 when the commodity id is invalid', async () => { From 0fccd7b0696c1d1ce903fc74c9d3060c7d6dea6d Mon Sep 17 00:00:00 2001 From: gorushkin Date: Wed, 5 Aug 2026 10:12:30 +0300 Subject: [PATCH 15/31] LED-125 test(commodities): clarify deleted commodity wording --- .../commodities/commodity.repository.test.ts | 8 ++- .../commodities/commodity.controller.test.ts | 2 +- .../commodities/commodity.integration.test.ts | 50 +++++++++---------- 3 files changed, 29 insertions(+), 31 deletions(-) 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 c64b925a..f55f3741 100644 --- a/apps/backend/src/infrastructure/db/commodities/commodity.repository.test.ts +++ b/apps/backend/src/infrastructure/db/commodities/commodity.repository.test.ts @@ -521,12 +521,10 @@ describe('CommodityRepository', () => { updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), }); - const archivedCommodity = await testDB.getCommodityById( - commodityDbRow.id, - ); + const deletedCommodity = await testDB.getCommodityById(commodityDbRow.id); - expect(archivedCommodity).toBeDefined(); - expect(archivedCommodity?.isTombstone).toBe(true); + expect(deletedCommodity).toBeDefined(); + expect(deletedCommodity?.isTombstone).toBe(true); }); it('should throw an error when the commodity does not exist', async () => { diff --git a/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts b/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts index bf7f1e18..ef7cd9d3 100644 --- a/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts +++ b/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts @@ -238,7 +238,7 @@ describe('CommodityController', () => { }); describe('deleteCommodity', () => { - it('should call archiveCommodityUseCase.execute with correct user and id', async () => { + it('should call deleteCommodityUseCase.execute with correct user and id', async () => { const requestParams = { id: commodityId }; await commodityController.delete(user, requestParams); 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 9d2c37c8..2abac0b9 100644 --- a/apps/backend/src/presentation/http/commodities/commodity.integration.test.ts +++ b/apps/backend/src/presentation/http/commodities/commodity.integration.test.ts @@ -40,7 +40,7 @@ describe('Commodities Integration Tests', () => { symbol: '€', }; - const archivedCommodityData1 = { + const deletedCommodityData1 = { code: CommodityCode.create('GBP').valueOf(), isTombstone: true, name: 'British Pound', @@ -49,10 +49,10 @@ describe('Commodities Integration Tests', () => { }; const activeCommoditiesData = [activeCommodityData1, activeCommodityData2]; - const archivedCommoditiesData = [archivedCommodityData1]; + const deletedCommoditiesData = [deletedCommodityData1]; const allCommoditiesData = [ ...activeCommoditiesData, - ...archivedCommoditiesData, + ...deletedCommoditiesData, ]; let commoditiesDbRows: CommodityDbRow[]; @@ -563,13 +563,13 @@ describe('Commodities Integration Tests', () => { expect(response.statusCode).toBe(404); }); - it('should return 404 when updating an archived commodity', async () => { - const archivedCommodity = commoditiesDbRows.find( + it('should return 404 when updating a deleted commodity', async () => { + const deletedCommodity = commoditiesDbRows.find( (commodity) => commodity.isTombstone, ); - if (!archivedCommodity) { - throw new Error('No archived commodity found in the test data.'); + if (!deletedCommodity) { + throw new Error('No deleted commodity found in the test data.'); } const response = await injectAuthorized({ @@ -577,7 +577,7 @@ describe('Commodities Integration Tests', () => { name: 'Updated British Pound', }, method: 'PATCH', - url: `${url}/${archivedCommodity.id}`, + url: `${url}/${deletedCommodity.id}`, }); expect(response.statusCode).toBe(404); @@ -645,25 +645,25 @@ describe('Commodities Integration Tests', () => { describe('DELETE /api/commodities/:id', () => { it('should delete a commodity by id', async () => { - const commodityToArchive = commoditiesDbRows[0]; + const commodityToDelete = commoditiesDbRows[0]; const response = await injectAuthorized({ method: 'DELETE', - url: `${url}/${commodityToArchive.id}`, + url: `${url}/${commodityToDelete.id}`, }); expect(response.statusCode).toBe(204); const getResponse = await injectAuthorized({ method: 'GET', - url: `${url}/${commodityToArchive.id}`, + url: `${url}/${commodityToDelete.id}`, }); expect(getResponse.statusCode).toBe(404); const allCommodities = await testDB.getAllCommoditiesByUserId(userId); - const archivedCommoditiesDbRows = allCommodities.filter( + const deletedCommoditiesDbRows = allCommodities.filter( (commodity) => commodity.isTombstone, ); @@ -675,23 +675,23 @@ describe('Commodities Integration Tests', () => { activeCommoditiesData.length - 1, ); - expect(archivedCommoditiesDbRows).toHaveLength( - archivedCommoditiesData.length + 1, + expect(deletedCommoditiesDbRows).toHaveLength( + deletedCommoditiesData.length + 1, ); }); - it('should return 404 when reading an deleted commodity by id', async () => { - const archivedCommodity = commoditiesDbRows.find( + it('should return 404 when reading a deleted commodity by id', async () => { + const deletedCommodity = commoditiesDbRows.find( (commodity) => commodity.isTombstone, ); - if (!archivedCommodity) { - throw new Error('No archived commodity found in the test data.'); + if (!deletedCommodity) { + throw new Error('No deleted commodity found in the test data.'); } const response = await injectAuthorized({ method: 'GET', - url: `${url}/${archivedCommodity.id}`, + url: `${url}/${deletedCommodity.id}`, }); expect(response.statusCode).toBe(404); @@ -742,17 +742,17 @@ describe('Commodities Integration Tests', () => { }); it('should return 204 when deleting an already deleted commodity', async () => { - const archivedCommodity = commoditiesDbRows.find( + const deletedCommodity = commoditiesDbRows.find( (commodity) => commodity.isTombstone, ); - if (!archivedCommodity) { - throw new Error('No archived commodity found in the test data.'); + if (!deletedCommodity) { + throw new Error('No deleted commodity found in the test data.'); } const response = await injectAuthorized({ method: 'DELETE', - url: `${url}/${archivedCommodity.id}`, + url: `${url}/${deletedCommodity.id}`, }); expect(response.statusCode).toBe(204); @@ -768,11 +768,11 @@ describe('Commodities Integration Tests', () => { }); it('should return 401 without authorization', async () => { - const commodityToArchive = commoditiesDbRows[0]; + const commodityToDelete = commoditiesDbRows[0]; const response = await server.inject({ method: 'DELETE', - url: `${url}/${commodityToArchive.id}`, + url: `${url}/${commodityToDelete.id}`, }); expect(response.statusCode).toBe(401); From df89cab26ee7311491e88397eb1679739a9d90d1 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Wed, 5 Aug 2026 10:19:17 +0300 Subject: [PATCH 16/31] LED-125 refactor(commodities): rename delete use case binding --- apps/backend/src/di/container.ts | 6 +++--- apps/backend/src/di/types.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/di/container.ts b/apps/backend/src/di/container.ts index fce5c6e9..3156210d 100644 --- a/apps/backend/src/di/container.ts +++ b/apps/backend/src/di/container.ts @@ -161,7 +161,7 @@ export const createContainer = (db: DataBase): AppContainer => { transactionManager, ); - const archiveCommodityUseCase = new DeleteCommodityUseCase( + const deleteCommodityUseCase = new DeleteCommodityUseCase( commodityRepository, ensureOwnedSnapshot, transactionManager, @@ -176,7 +176,7 @@ export const createContainer = (db: DataBase): AppContainer => { getAllCommoditiesUseCase, createCommodityUseCase, updateCommodityUseCase, - archiveCommodityUseCase, + deleteCommodityUseCase, ); const useCases: AppContainer['useCases'] = { @@ -194,8 +194,8 @@ export const createContainer = (db: DataBase): AppContainer => { registerUser: registerUserUseCase, }, commodity: { - archiveCommodity: archiveCommodityUseCase, createCommodity: createCommodityUseCase, + deleteCommodity: deleteCommodityUseCase, getAllCommodities: getAllCommoditiesUseCase, getCommodityById: getCommodityByIdUseCase, updateCommodity: updateCommodityUseCase, diff --git a/apps/backend/src/di/types.ts b/apps/backend/src/di/types.ts index da0d7163..337ab678 100644 --- a/apps/backend/src/di/types.ts +++ b/apps/backend/src/di/types.ts @@ -77,7 +77,7 @@ type CommodityUseCases = { getCommodityById: GetCommodityByIdUseCase; getAllCommodities: GetAllCommoditiesUseCase; updateCommodity: UpdateCommodityUseCase; - archiveCommodity: DeleteCommodityUseCase; + deleteCommodity: DeleteCommodityUseCase; createCommodity: CreateCommodityUseCase; }; From f30c825ac454dcdeb7167ed480931e10f0710ce4 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Wed, 5 Aug 2026 10:25:03 +0300 Subject: [PATCH 17/31] LED-125 feat(commodities): add lifecycle use cases --- .../__tests__/closeCommodity.test.ts | 155 ++++++++++++++++++ .../__tests__/openCommodity.test.ts | 155 ++++++++++++++++++ .../usecases/commodities/closeCommodity.ts | 47 ++++++ .../application/usecases/commodities/index.ts | 2 + .../usecases/commodities/openCommodity.ts | 47 ++++++ 5 files changed, 406 insertions(+) create mode 100644 apps/backend/src/application/usecases/commodities/__tests__/closeCommodity.test.ts create mode 100644 apps/backend/src/application/usecases/commodities/__tests__/openCommodity.test.ts create mode 100644 apps/backend/src/application/usecases/commodities/closeCommodity.ts create mode 100644 apps/backend/src/application/usecases/commodities/openCommodity.ts diff --git a/apps/backend/src/application/usecases/commodities/__tests__/closeCommodity.test.ts b/apps/backend/src/application/usecases/commodities/__tests__/closeCommodity.test.ts new file mode 100644 index 00000000..9e1db6c8 --- /dev/null +++ b/apps/backend/src/application/usecases/commodities/__tests__/closeCommodity.test.ts @@ -0,0 +1,155 @@ +import { + CommodityRepositoryInterface, + TransactionManagerInterface, +} from 'src/application'; +import { + EntityNotFoundError, + UnauthorizedAccessError, +} from 'src/application/application.errors'; +import { CommodityMapper } from 'src/application/mappers'; +import { ensureOwnedSnapshot } from 'src/application/shared/ensureOwnedSnapshot'; +import { createCommodity } from 'src/db/createTestUser'; +import { Commodity } from 'src/domain/commodities'; +import { Id, Timestamp } from 'src/domain/domain-core'; +import { User } from 'src/domain/users/user.entity'; +import { createUser } from 'src/testing/helpers'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CloseCommodityUseCase } from '../closeCommodity'; + +describe('CloseCommodityUseCase', () => { + let user: User; + + const commodityRepository = { + close: vi.fn(), + getById: vi.fn(), + }; + + const mockTransactionManager = { + run: vi.fn(), + }; + const runTransaction: TransactionManagerInterface['run'] = async (callback) => + callback(); + + const closeCommodityUseCase = new CloseCommodityUseCase( + commodityRepository as unknown as CommodityRepositoryInterface, + ensureOwnedSnapshot, + mockTransactionManager as unknown as TransactionManagerInterface, + ); + + beforeAll(async () => { + user = await createUser(); + }); + + beforeEach(() => { + vi.useRealTimers(); + mockTransactionManager.run.mockImplementation(runTransaction); + commodityRepository.close.mockClear(); + commodityRepository.getById.mockClear(); + mockTransactionManager.run.mockClear(); + }); + + describe('execute', () => { + it('should close a commodity successfully', async () => { + vi.useFakeTimers(); + + const updatedAt = Timestamp.restore('2025-01-01T00:00:01.000Z').valueOf(); + const commodity = createCommodity(user, { + code: 'TEST', + name: 'Test Commodity', + precision: 2, + symbol: 'T', + }); + const commodityId = commodity.getId().valueOf(); + + commodityRepository.getById.mockResolvedValue(commodity.toSnapshot()); + commodityRepository.close.mockResolvedValue(undefined); + + vi.setSystemTime(new Date(updatedAt)); + + const result = await closeCommodityUseCase.execute(user, commodityId); + + expect(commodityRepository.getById).toHaveBeenCalledWith( + user.getId().valueOf(), + commodityId, + ); + expect(commodityRepository.close).toHaveBeenCalledWith( + user.getId().valueOf(), + commodityId, + { updatedAt }, + ); + expect(mockTransactionManager.run).toHaveBeenCalledTimes(1); + expect(result).toEqual( + CommodityMapper.toResponseDTOFromSnapshot({ + ...commodity.toSnapshot(), + isClosed: true, + updatedAt, + }), + ); + }); + + it('should not call close when commodity is already closed', async () => { + const commodity = createCommodity(user, { + code: 'TEST', + name: 'Test Commodity', + precision: 2, + symbol: 'T', + }); + commodity.close(); + const commodityId = commodity.getId().valueOf(); + + commodityRepository.getById.mockResolvedValue(commodity.toSnapshot()); + + const result = await closeCommodityUseCase.execute(user, commodityId); + + expect(commodityRepository.getById).toHaveBeenCalledWith( + user.getId().valueOf(), + commodityId, + ); + expect(commodityRepository.close).not.toHaveBeenCalled(); + expect(mockTransactionManager.run).toHaveBeenCalledTimes(1); + expect(result).toEqual( + CommodityMapper.toResponseDTOFromSnapshot(commodity.toSnapshot()), + ); + }); + + it('should throw an error if the commodity does not exist', async () => { + const nonExistentCommodityId = Id.create().valueOf(); + + commodityRepository.getById.mockResolvedValue(null); + + await expect( + closeCommodityUseCase.execute(user, nonExistentCommodityId), + ).rejects.toThrowError( + new EntityNotFoundError({ + entityId: nonExistentCommodityId, + entityType: Commodity.entityType, + }), + ); + expect(commodityRepository.close).not.toHaveBeenCalled(); + }); + + it('should throw an error if the commodity does not belong to the user', async () => { + const otherUser = await createUser(); + const commodity = createCommodity(otherUser, { + code: 'TEST', + name: 'Test Commodity', + precision: 2, + symbol: 'T', + }); + const commodityId = commodity.getId().valueOf(); + + commodityRepository.getById.mockResolvedValue(commodity.toSnapshot()); + + await expect( + closeCommodityUseCase.execute(user, commodityId), + ).rejects.toThrowError( + new UnauthorizedAccessError({ + entityId: commodityId, + entityType: Commodity.entityType, + }), + ); + expect(commodityRepository.close).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/backend/src/application/usecases/commodities/__tests__/openCommodity.test.ts b/apps/backend/src/application/usecases/commodities/__tests__/openCommodity.test.ts new file mode 100644 index 00000000..2cd3ba51 --- /dev/null +++ b/apps/backend/src/application/usecases/commodities/__tests__/openCommodity.test.ts @@ -0,0 +1,155 @@ +import { + CommodityRepositoryInterface, + TransactionManagerInterface, +} from 'src/application'; +import { + EntityNotFoundError, + UnauthorizedAccessError, +} from 'src/application/application.errors'; +import { CommodityMapper } from 'src/application/mappers'; +import { ensureOwnedSnapshot } from 'src/application/shared/ensureOwnedSnapshot'; +import { createCommodity } from 'src/db/createTestUser'; +import { Commodity } from 'src/domain/commodities'; +import { Id, Timestamp } from 'src/domain/domain-core'; +import { User } from 'src/domain/users/user.entity'; +import { createUser } from 'src/testing/helpers'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OpenCommodityUseCase } from '../openCommodity'; + +describe('OpenCommodityUseCase', () => { + let user: User; + + const commodityRepository = { + getById: vi.fn(), + open: vi.fn(), + }; + + const mockTransactionManager = { + run: vi.fn(), + }; + const runTransaction: TransactionManagerInterface['run'] = async (callback) => + callback(); + + const openCommodityUseCase = new OpenCommodityUseCase( + commodityRepository as unknown as CommodityRepositoryInterface, + ensureOwnedSnapshot, + mockTransactionManager as unknown as TransactionManagerInterface, + ); + + beforeAll(async () => { + user = await createUser(); + }); + + beforeEach(() => { + vi.useRealTimers(); + mockTransactionManager.run.mockImplementation(runTransaction); + commodityRepository.open.mockClear(); + commodityRepository.getById.mockClear(); + mockTransactionManager.run.mockClear(); + }); + + describe('execute', () => { + it('should open a commodity successfully', async () => { + vi.useFakeTimers(); + + const updatedAt = Timestamp.restore('2025-01-01T00:00:01.000Z').valueOf(); + const commodity = createCommodity(user, { + code: 'TEST', + name: 'Test Commodity', + precision: 2, + symbol: 'T', + }); + commodity.close(); + const commodityId = commodity.getId().valueOf(); + + commodityRepository.getById.mockResolvedValue(commodity.toSnapshot()); + commodityRepository.open.mockResolvedValue(undefined); + + vi.setSystemTime(new Date(updatedAt)); + + const result = await openCommodityUseCase.execute(user, commodityId); + + expect(commodityRepository.getById).toHaveBeenCalledWith( + user.getId().valueOf(), + commodityId, + ); + expect(commodityRepository.open).toHaveBeenCalledWith( + user.getId().valueOf(), + commodityId, + { updatedAt }, + ); + expect(mockTransactionManager.run).toHaveBeenCalledTimes(1); + expect(result).toEqual( + CommodityMapper.toResponseDTOFromSnapshot({ + ...commodity.toSnapshot(), + isClosed: false, + updatedAt, + }), + ); + }); + + it('should not call open when commodity is already open', async () => { + const commodity = createCommodity(user, { + code: 'TEST', + name: 'Test Commodity', + precision: 2, + symbol: 'T', + }); + const commodityId = commodity.getId().valueOf(); + + commodityRepository.getById.mockResolvedValue(commodity.toSnapshot()); + + const result = await openCommodityUseCase.execute(user, commodityId); + + expect(commodityRepository.getById).toHaveBeenCalledWith( + user.getId().valueOf(), + commodityId, + ); + expect(commodityRepository.open).not.toHaveBeenCalled(); + expect(mockTransactionManager.run).toHaveBeenCalledTimes(1); + expect(result).toEqual( + CommodityMapper.toResponseDTOFromSnapshot(commodity.toSnapshot()), + ); + }); + + it('should throw an error if the commodity does not exist', async () => { + const nonExistentCommodityId = Id.create().valueOf(); + + commodityRepository.getById.mockResolvedValue(null); + + await expect( + openCommodityUseCase.execute(user, nonExistentCommodityId), + ).rejects.toThrowError( + new EntityNotFoundError({ + entityId: nonExistentCommodityId, + entityType: Commodity.entityType, + }), + ); + expect(commodityRepository.open).not.toHaveBeenCalled(); + }); + + it('should throw an error if the commodity does not belong to the user', async () => { + const otherUser = await createUser(); + const commodity = createCommodity(otherUser, { + code: 'TEST', + name: 'Test Commodity', + precision: 2, + symbol: 'T', + }); + const commodityId = commodity.getId().valueOf(); + + commodityRepository.getById.mockResolvedValue(commodity.toSnapshot()); + + await expect( + openCommodityUseCase.execute(user, commodityId), + ).rejects.toThrowError( + new UnauthorizedAccessError({ + entityId: commodityId, + entityType: Commodity.entityType, + }), + ); + expect(commodityRepository.open).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/backend/src/application/usecases/commodities/closeCommodity.ts b/apps/backend/src/application/usecases/commodities/closeCommodity.ts new file mode 100644 index 00000000..55ca846d --- /dev/null +++ b/apps/backend/src/application/usecases/commodities/closeCommodity.ts @@ -0,0 +1,47 @@ +import { CommodityResponseDTO, UUID } from '@ledgerly/shared/types'; +import { CommodityMapper } from 'src/application'; +import { + CommodityRepositoryInterface, + TransactionManagerInterface, +} from 'src/application/interfaces'; +import { EnsureOwnedSnapshotFn } from 'src/application/shared/ensureOwnedSnapshot'; +import { Commodity, CommoditySnapshot } from 'src/domain/commodities'; +import { User } from 'src/domain/users/user.entity'; + +export class CloseCommodityUseCase { + constructor( + protected readonly commodityRepository: CommodityRepositoryInterface, + protected readonly ensureOwnedSnapshot: EnsureOwnedSnapshotFn, + private readonly transactionManager: TransactionManagerInterface, + ) {} + + async execute(user: User, commodityId: UUID): Promise { + const commoditySnapshot = await this.transactionManager.run(async () => { + const commodityData = await this.ensureOwnedSnapshot({ + entityId: commodityId, + entityType: Commodity.entityType, + getOwnerId: (commodity) => commodity.userId, + load: this.commodityRepository.getById.bind(this.commodityRepository), + user, + }); + + const commodity = Commodity.restore(commodityData); + + const result = commodity.close(); + + if (result === 'changed') { + await this.commodityRepository.close( + user.getId().valueOf(), + commodityId, + { + updatedAt: commodity.getUpdatedAt().valueOf(), + }, + ); + } + + return commodity.toSnapshot(); + }); + + return CommodityMapper.toResponseDTOFromSnapshot(commoditySnapshot); + } +} diff --git a/apps/backend/src/application/usecases/commodities/index.ts b/apps/backend/src/application/usecases/commodities/index.ts index e03680c6..b52564e6 100644 --- a/apps/backend/src/application/usecases/commodities/index.ts +++ b/apps/backend/src/application/usecases/commodities/index.ts @@ -3,3 +3,5 @@ export { UpdateCommodityUseCase } from './updateCommodity'; export { GetAllCommoditiesUseCase } from './getAllCommodities'; export { GetCommodityByIdUseCase } from './getCommodityById'; export { DeleteCommodityUseCase } from './deleteCommodity'; +export { CloseCommodityUseCase } from './closeCommodity'; +export { OpenCommodityUseCase } from './openCommodity'; diff --git a/apps/backend/src/application/usecases/commodities/openCommodity.ts b/apps/backend/src/application/usecases/commodities/openCommodity.ts new file mode 100644 index 00000000..7870b2a9 --- /dev/null +++ b/apps/backend/src/application/usecases/commodities/openCommodity.ts @@ -0,0 +1,47 @@ +import { CommodityResponseDTO, UUID } from '@ledgerly/shared/types'; +import { CommodityMapper } from 'src/application'; +import { + CommodityRepositoryInterface, + TransactionManagerInterface, +} from 'src/application/interfaces'; +import { EnsureOwnedSnapshotFn } from 'src/application/shared/ensureOwnedSnapshot'; +import { Commodity, CommoditySnapshot } from 'src/domain/commodities'; +import { User } from 'src/domain/users/user.entity'; + +export class OpenCommodityUseCase { + constructor( + protected readonly commodityRepository: CommodityRepositoryInterface, + protected readonly ensureOwnedSnapshot: EnsureOwnedSnapshotFn, + private readonly transactionManager: TransactionManagerInterface, + ) {} + + async execute(user: User, commodityId: UUID): Promise { + const commoditySnapshot = await this.transactionManager.run(async () => { + const commodityData = await this.ensureOwnedSnapshot({ + entityId: commodityId, + entityType: Commodity.entityType, + getOwnerId: (commodity) => commodity.userId, + load: this.commodityRepository.getById.bind(this.commodityRepository), + user, + }); + + const commodity = Commodity.restore(commodityData); + + const result = commodity.open(); + + if (result === 'changed') { + await this.commodityRepository.open( + user.getId().valueOf(), + commodityId, + { + updatedAt: commodity.getUpdatedAt().valueOf(), + }, + ); + } + + return commodity.toSnapshot(); + }); + + return CommodityMapper.toResponseDTOFromSnapshot(commoditySnapshot); + } +} From 603b9f26198ab51e2b686c48027ae6b05d0d3ae8 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Wed, 5 Aug 2026 10:29:13 +0300 Subject: [PATCH 18/31] LED-125 refactor(commodities): wire lifecycle use cases --- apps/backend/src/di/container.ts | 18 ++++++ apps/backend/src/di/types.ts | 8 ++- .../commodities/commodity.controller.test.ts | 58 +++++++++++++++++++ .../http/commodities/commodity.controller.ts | 22 +++++++ 4 files changed, 104 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/di/container.ts b/apps/backend/src/di/container.ts index 3156210d..6d9906ed 100644 --- a/apps/backend/src/di/container.ts +++ b/apps/backend/src/di/container.ts @@ -18,6 +18,8 @@ import { UpdateCommodityUseCase, DeleteCommodityUseCase, CreateCommodityUseCase, + CloseCommodityUseCase, + OpenCommodityUseCase, } from 'src/application'; import { AccountOperationPolicy, @@ -171,12 +173,26 @@ export const createContainer = (db: DataBase): AppContainer => { commodityRepository, ); + const closeCommodityUseCase = new CloseCommodityUseCase( + commodityRepository, + ensureOwnedSnapshot, + transactionManager, + ); + + const openCommodityUseCase = new OpenCommodityUseCase( + commodityRepository, + ensureOwnedSnapshot, + transactionManager, + ); + const commodityController = new CommodityController( getCommodityByIdUseCase, getAllCommoditiesUseCase, createCommodityUseCase, updateCommodityUseCase, deleteCommodityUseCase, + closeCommodityUseCase, + openCommodityUseCase, ); const useCases: AppContainer['useCases'] = { @@ -194,10 +210,12 @@ export const createContainer = (db: DataBase): AppContainer => { registerUser: registerUserUseCase, }, commodity: { + closeCommodity: closeCommodityUseCase, createCommodity: createCommodityUseCase, deleteCommodity: deleteCommodityUseCase, getAllCommodities: getAllCommoditiesUseCase, getCommodityById: getCommodityByIdUseCase, + openCommodity: openCommodityUseCase, updateCommodity: updateCommodityUseCase, }, transaction: { diff --git a/apps/backend/src/di/types.ts b/apps/backend/src/di/types.ts index 337ab678..31cb48d8 100644 --- a/apps/backend/src/di/types.ts +++ b/apps/backend/src/di/types.ts @@ -18,6 +18,8 @@ import { UpdateCommodityUseCase, DeleteCommodityUseCase, CreateCommodityUseCase, + CloseCommodityUseCase, + OpenCommodityUseCase, } from 'src/application'; import { DataBase } from 'src/db'; import { PasswordManager } from 'src/infrastructure/auth/PasswordManager'; @@ -74,11 +76,13 @@ type TransactionUseCases = { }; type CommodityUseCases = { + closeCommodity: CloseCommodityUseCase; + createCommodity: CreateCommodityUseCase; + deleteCommodity: DeleteCommodityUseCase; getCommodityById: GetCommodityByIdUseCase; getAllCommodities: GetAllCommoditiesUseCase; + openCommodity: OpenCommodityUseCase; updateCommodity: UpdateCommodityUseCase; - deleteCommodity: DeleteCommodityUseCase; - createCommodity: CreateCommodityUseCase; }; type Controllers = { diff --git a/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts b/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts index ef7cd9d3..a4d799c6 100644 --- a/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts +++ b/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts @@ -1,9 +1,11 @@ import { CommodityQuery } from '@ledgerly/shared/validation'; import { + CloseCommodityUseCase, DeleteCommodityUseCase, CreateCommodityUseCase, GetAllCommoditiesUseCase, GetCommodityByIdUseCase, + OpenCommodityUseCase, UpdateCommodityUseCase, } from 'src/application/usecases/commodities'; import { CommodityCode } from 'src/domain/domain-core'; @@ -42,12 +44,22 @@ describe('CommodityController', () => { execute: vi.fn(), }; + const mockCloseCommodityUseCase = { + execute: vi.fn(), + }; + + const mockOpenCommodityUseCase = { + execute: vi.fn(), + }; + const commodityController = new CommodityController( mockGetCommodityByIdUseCase as unknown as GetCommodityByIdUseCase, mockGetAllCommoditiesUseCase as unknown as GetAllCommoditiesUseCase, mockCreateCommodityUseCase as unknown as CreateCommodityUseCase, mockUpdateCommodityUseCase as unknown as UpdateCommodityUseCase, mockDeleteCommodityUseCase as unknown as DeleteCommodityUseCase, + mockCloseCommodityUseCase as unknown as CloseCommodityUseCase, + mockOpenCommodityUseCase as unknown as OpenCommodityUseCase, ); beforeEach(async () => { @@ -259,4 +271,50 @@ describe('CommodityController', () => { ).rejects.toThrow(ZodError); }); }); + + describe('closeCommodity', () => { + it('should call closeCommodityUseCase.execute with correct user and id', async () => { + const requestParams = { id: commodityId }; + + await commodityController.close(user, requestParams); + + expect(mockCloseCommodityUseCase.execute).toHaveBeenCalledWith( + user, + requestParams.id, + ); + + expect(mockCloseCommodityUseCase.execute).toHaveBeenCalledTimes(1); + }); + + it('should throw ZodError if requestParams is invalid', async () => { + const invalidRequestParams = { invalidId: 'commodity-id' }; + + await expect( + commodityController.close(user, invalidRequestParams), + ).rejects.toThrow(ZodError); + }); + }); + + describe('openCommodity', () => { + it('should call openCommodityUseCase.execute with correct user and id', async () => { + const requestParams = { id: commodityId }; + + await commodityController.open(user, requestParams); + + expect(mockOpenCommodityUseCase.execute).toHaveBeenCalledWith( + user, + requestParams.id, + ); + + expect(mockOpenCommodityUseCase.execute).toHaveBeenCalledTimes(1); + }); + + it('should throw ZodError if requestParams is invalid', async () => { + const invalidRequestParams = { invalidId: 'commodity-id' }; + + await expect( + commodityController.open(user, invalidRequestParams), + ).rejects.toThrow(ZodError); + }); + }); }); diff --git a/apps/backend/src/presentation/http/commodities/commodity.controller.ts b/apps/backend/src/presentation/http/commodities/commodity.controller.ts index b1b27789..72a9cfd0 100644 --- a/apps/backend/src/presentation/http/commodities/commodity.controller.ts +++ b/apps/backend/src/presentation/http/commodities/commodity.controller.ts @@ -6,10 +6,12 @@ import { commodityQuerySchema, } from '@ledgerly/shared/validation'; import { + CloseCommodityUseCase, GetCommodityByIdUseCase, DeleteCommodityUseCase, CreateCommodityUseCase, GetAllCommoditiesUseCase, + OpenCommodityUseCase, UpdateCommodityUseCase, } from 'src/application/usecases/commodities'; import { User } from 'src/domain/users/user.entity'; @@ -21,6 +23,8 @@ export class CommodityController { private readonly createCommodityUseCase: CreateCommodityUseCase, private readonly updateCommodityUseCase: UpdateCommodityUseCase, private readonly deleteCommodityUseCase: DeleteCommodityUseCase, + private readonly closeCommodityUseCase: CloseCommodityUseCase, + private readonly openCommodityUseCase: OpenCommodityUseCase, ) {} async getAll( @@ -66,4 +70,22 @@ export class CommodityController { await this.deleteCommodityUseCase.execute(user, id); } + + async close( + user: User, + requestParams: unknown, + ): Promise { + const { id } = uniqueIdSchema.parse(requestParams); + + return this.closeCommodityUseCase.execute(user, id); + } + + async open( + user: User, + requestParams: unknown, + ): Promise { + const { id } = uniqueIdSchema.parse(requestParams); + + return this.openCommodityUseCase.execute(user, id); + } } From e0ae5d4408bacbdfdb9d35979a25d15ce6d7f637 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Wed, 5 Aug 2026 10:31:38 +0300 Subject: [PATCH 19/31] LED-125 feat(commodities): add lifecycle routes --- .../http/commodities/commodity.routes.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/backend/src/presentation/http/commodities/commodity.routes.ts b/apps/backend/src/presentation/http/commodities/commodity.routes.ts index badcf470..a8d0770b 100644 --- a/apps/backend/src/presentation/http/commodities/commodity.routes.ts +++ b/apps/backend/src/presentation/http/commodities/commodity.routes.ts @@ -30,6 +30,22 @@ export const commodityRoutes = (app: FastifyInstance) => { reply.status(204).send(); }); + app.post('/:id/close', async (request, reply) => { + const user = request.user; + + await commodityController.close(user, request.params); + + reply.status(204).send(); + }); + + app.post('/:id/open', async (request, reply) => { + const user = request.user; + + await commodityController.open(user, request.params); + + reply.status(204).send(); + }); + app.patch('/:id', async (request, reply) => { const user = request.user; From a0f7280a69dc192e24bb65f3cbb33c411b7c7392 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Wed, 5 Aug 2026 10:34:41 +0300 Subject: [PATCH 20/31] LED-125 test(commodities): cover lifecycle routes --- .../commodities/commodity.integration.test.ts | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) 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 2abac0b9..c9f9544c 100644 --- a/apps/backend/src/presentation/http/commodities/commodity.integration.test.ts +++ b/apps/backend/src/presentation/http/commodities/commodity.integration.test.ts @@ -643,6 +643,211 @@ describe('Commodities Integration Tests', () => { }); }); + describe('POST /api/commodities/:id/close', () => { + it('should close an open commodity by id', async () => { + const commodityToClose = commoditiesDbRows.find( + (commodity) => !commodity.isClosed && !commodity.isTombstone, + ); + + if (!commodityToClose) { + throw new Error('No open commodity found in the test data.'); + } + + const response = await injectAuthorized({ + method: 'POST', + url: `${url}/${commodityToClose.id}/close`, + }); + + expect(response.statusCode).toBe(204); + + const closedCommodity = await testDB.getCommodityById( + commodityToClose.id, + ); + + expect(closedCommodity?.isClosed).toBe(true); + }); + + it('should be idempotent for an already closed commodity', async () => { + const closedCommodity = await testDB.createCommodity(userId, { + code: CommodityCode.create('CHF').valueOf(), + isClosed: true, + isTombstone: false, + name: 'Swiss Franc', + precision: 2, + symbol: 'CHF', + }); + + const response = await injectAuthorized({ + method: 'POST', + url: `${url}/${closedCommodity.id}/close`, + }); + + expect(response.statusCode).toBe(204); + + const retrievedCommodity = await testDB.getCommodityById( + closedCommodity.id, + ); + + expect(retrievedCommodity?.isClosed).toBe(true); + expect(retrievedCommodity?.updatedAt).toBe(closedCommodity.updatedAt); + }); + + it('should return 404 when closing another user commodity', async () => { + const otherUserCommodity = await testDB.createCommodity(otherUserId, { + code: CommodityCode.create('JPY').valueOf(), + name: 'Japanese Yen', + precision: 2, + symbol: '¥', + }); + + const response = await injectAuthorized({ + method: 'POST', + url: `${url}/${otherUserCommodity.id}/close`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 404 when closing a deleted commodity', async () => { + const deletedCommodity = commoditiesDbRows.find( + (commodity) => commodity.isTombstone, + ); + + if (!deletedCommodity) { + throw new Error('No deleted commodity found in the test data.'); + } + + const response = await injectAuthorized({ + method: 'POST', + url: `${url}/${deletedCommodity.id}/close`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 400 when the commodity id is invalid', async () => { + const response = await injectAuthorized({ + method: 'POST', + url: `${url}/invalid-id/close`, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 401 without authorization', async () => { + const commodityToClose = commoditiesDbRows[0]; + + const response = await server.inject({ + method: 'POST', + url: `${url}/${commodityToClose.id}/close`, + }); + + expect(response.statusCode).toBe(401); + }); + }); + + describe('POST /api/commodities/:id/open', () => { + it('should open a closed commodity by id', async () => { + const commodityToOpen = await testDB.createCommodity(userId, { + code: CommodityCode.create('CHF').valueOf(), + isClosed: true, + isTombstone: false, + name: 'Swiss Franc', + precision: 2, + symbol: 'CHF', + }); + + const response = await injectAuthorized({ + method: 'POST', + url: `${url}/${commodityToOpen.id}/open`, + }); + + expect(response.statusCode).toBe(204); + + const openedCommodity = await testDB.getCommodityById(commodityToOpen.id); + + expect(openedCommodity?.isClosed).toBe(false); + }); + + it('should be idempotent for an already open commodity', async () => { + const openCommodity = commoditiesDbRows.find( + (commodity) => !commodity.isClosed && !commodity.isTombstone, + ); + + if (!openCommodity) { + throw new Error('No open commodity found in the test data.'); + } + + const response = await injectAuthorized({ + method: 'POST', + url: `${url}/${openCommodity.id}/open`, + }); + + expect(response.statusCode).toBe(204); + + const retrievedCommodity = await testDB.getCommodityById( + openCommodity.id, + ); + + expect(retrievedCommodity?.isClosed).toBe(false); + expect(retrievedCommodity?.updatedAt).toBe(openCommodity.updatedAt); + }); + + it('should return 404 when opening another user commodity', async () => { + const otherUserCommodity = await testDB.createCommodity(otherUserId, { + code: CommodityCode.create('JPY').valueOf(), + isClosed: true, + name: 'Japanese Yen', + precision: 2, + symbol: '¥', + }); + + const response = await injectAuthorized({ + method: 'POST', + url: `${url}/${otherUserCommodity.id}/open`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 404 when opening a deleted commodity', async () => { + const deletedCommodity = commoditiesDbRows.find( + (commodity) => commodity.isTombstone, + ); + + if (!deletedCommodity) { + throw new Error('No deleted commodity found in the test data.'); + } + + const response = await injectAuthorized({ + method: 'POST', + url: `${url}/${deletedCommodity.id}/open`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 400 when the commodity id is invalid', async () => { + const response = await injectAuthorized({ + method: 'POST', + url: `${url}/invalid-id/open`, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 401 without authorization', async () => { + const commodityToOpen = commoditiesDbRows[0]; + + const response = await server.inject({ + method: 'POST', + url: `${url}/${commodityToOpen.id}/open`, + }); + + expect(response.statusCode).toBe(401); + }); + }); + describe('DELETE /api/commodities/:id', () => { it('should delete a commodity by id', async () => { const commodityToDelete = commoditiesDbRows[0]; From b7affea22525cb8afab50ebb61f471898e469d56 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Wed, 5 Aug 2026 15:07:53 +0300 Subject: [PATCH 21/31] LED-125 feat(accounts): reject closed commodity references --- .../application/application.errors.test.ts | 12 ++ .../src/application/application.errors.ts | 16 +++ .../commodity-reference.policy.test.ts | 105 ++++++++++++++++++ .../commodity-reference.policy.ts | 31 ++++++ .../CommodityReferencePolicy/index.ts | 1 + .../backend/src/application/services/index.ts | 1 + .../accounts/__tests__/createAccount.test.ts | 37 ++++++ .../usecases/accounts/createAccount.ts | 7 ++ apps/backend/src/di/container.ts | 6 + .../db/accounts/account.repository.test.ts | 62 +++++++++-- .../db/accounts/account.repository.ts | 20 ---- .../http/accounts/account.integration.test.ts | 38 +++++++ .../src/presentation/http/error-handler.ts | 1 + packages/shared/src/types/error-contract.ts | 5 + 14 files changed, 314 insertions(+), 28 deletions(-) create mode 100644 apps/backend/src/application/services/CommodityReferencePolicy/__tests__/commodity-reference.policy.test.ts create mode 100644 apps/backend/src/application/services/CommodityReferencePolicy/commodity-reference.policy.ts create mode 100644 apps/backend/src/application/services/CommodityReferencePolicy/index.ts diff --git a/apps/backend/src/application/application.errors.test.ts b/apps/backend/src/application/application.errors.test.ts index 77edccbe..215721bd 100644 --- a/apps/backend/src/application/application.errors.test.ts +++ b/apps/backend/src/application/application.errors.test.ts @@ -2,6 +2,7 @@ import { apiErrorCodes, type UUID } from '@ledgerly/shared/types'; import { AccountHasActiveOperationsError, AuthenticationFailedError, + CommodityClosedError, InvalidPasswordError, ApplicationError, EntityNotFoundError, @@ -51,4 +52,15 @@ describe('coded application errors', () => { context: { accountId }, }); }); + + it('uses a stable public code for a closed commodity reference', () => { + const commodityId = '550e8400-e29b-41d4-a716-446655440002' as UUID; + + expect( + new CommodityClosedError(commodityId, 'create_account'), + ).toMatchObject({ + code: apiErrorCodes.closedCommodityReference, + context: { commodityId, operation: 'create_account' }, + }); + }); }); diff --git a/apps/backend/src/application/application.errors.ts b/apps/backend/src/application/application.errors.ts index fe8528c8..b5feb03d 100644 --- a/apps/backend/src/application/application.errors.ts +++ b/apps/backend/src/application/application.errors.ts @@ -68,6 +68,22 @@ export class AccountHasActiveOperationsError extends CodedApplicationError<'ACCO } } +/** + * Thrown when a closed commodity is used for a new active reference. + */ +export class CommodityClosedError extends CodedApplicationError<'CLOSED_COMMODITY_REFERENCE'> { + constructor( + commodityId: ErrorContextByCode['CLOSED_COMMODITY_REFERENCE']['commodityId'], + operation: ErrorContextByCode['CLOSED_COMMODITY_REFERENCE']['operation'], + ) { + super( + `Cannot ${operation} with closed commodity ${commodityId}.`, + apiErrorCodes.closedCommodityReference, + { commodityId, operation }, + ); + } +} + /** * A deliberately non-specific authentication failure. * diff --git a/apps/backend/src/application/services/CommodityReferencePolicy/__tests__/commodity-reference.policy.test.ts b/apps/backend/src/application/services/CommodityReferencePolicy/__tests__/commodity-reference.policy.test.ts new file mode 100644 index 00000000..891a30ce --- /dev/null +++ b/apps/backend/src/application/services/CommodityReferencePolicy/__tests__/commodity-reference.policy.test.ts @@ -0,0 +1,105 @@ +import { apiErrorCodes } from '@ledgerly/shared/types'; +import { CommodityClosedError } from 'src/application/application.errors'; +import type { CommodityRepositoryInterface } from 'src/application/interfaces'; +import { CommodityReferencePolicy } from 'src/application/services/CommodityReferencePolicy'; +import { createUser } from 'src/db/createTestUser'; +import { Commodity } from 'src/domain/commodities'; +import { CommodityCode, Id } from 'src/domain/domain-core'; +import { DeletedEntityOperationError } from 'src/domain/domain.errors'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('CommodityReferencePolicy', async () => { + const user = await createUser(); + const userId = user.getId().valueOf(); + const commodityId = Id.create().valueOf(); + + const commodityRepository = { + getById: vi.fn(), + }; + + const policy = new CommodityReferencePolicy( + commodityRepository as unknown as CommodityRepositoryInterface, + ); + + const createCommodity = () => + Commodity.create(user, { + code: CommodityCode.create('USD').valueOf(), + name: 'US Dollar', + precision: 2, + symbol: '$', + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('allows open commodities for new account references', async () => { + const commodity = createCommodity(); + + commodityRepository.getById.mockResolvedValueOnce({ + ...commodity.toSnapshot(), + id: commodityId, + }); + + await expect( + policy.assertUsableForNewAccount(userId, commodityId), + ).resolves.toBeUndefined(); + + expect(commodityRepository.getById).toHaveBeenCalledWith( + userId, + commodityId, + ); + }); + + it('rejects closed commodities for new account references', async () => { + const commodity = createCommodity(); + commodity.close(); + + commodityRepository.getById.mockResolvedValueOnce({ + ...commodity.toSnapshot(), + id: commodityId, + }); + + const assertion = policy.assertUsableForNewAccount(userId, commodityId); + + await expect(assertion).rejects.toThrowError(CommodityClosedError); + await expect(assertion).rejects.toMatchObject({ + code: apiErrorCodes.closedCommodityReference, + context: { + commodityId, + operation: 'create_account', + }, + }); + }); + + it('rejects deleted commodities for new account references', async () => { + const commodity = createCommodity(); + commodity.delete(); + + commodityRepository.getById.mockResolvedValueOnce({ + ...commodity.toSnapshot(), + id: commodityId, + }); + + const assertion = policy.assertUsableForNewAccount(userId, commodityId); + + await expect(assertion).rejects.toThrowError(DeletedEntityOperationError); + await expect(assertion).rejects.toMatchObject({ + code: apiErrorCodes.deletedEntityOperation, + context: { + entityType: Commodity.entityType, + operation: 'use', + }, + }); + }); + + it('propagates repository lookup errors', async () => { + const repositoryError = new Error('repository unavailable'); + + commodityRepository.getById.mockRejectedValueOnce(repositoryError); + + await expect( + policy.assertUsableForNewAccount(userId, commodityId), + ).rejects.toBe(repositoryError); + }); +}); diff --git a/apps/backend/src/application/services/CommodityReferencePolicy/commodity-reference.policy.ts b/apps/backend/src/application/services/CommodityReferencePolicy/commodity-reference.policy.ts new file mode 100644 index 00000000..0626e2af --- /dev/null +++ b/apps/backend/src/application/services/CommodityReferencePolicy/commodity-reference.policy.ts @@ -0,0 +1,31 @@ +import { UUID } from '@ledgerly/shared/types'; +import { CommodityClosedError } from 'src/application/application.errors'; +import type { CommodityRepositoryInterface } from 'src/application/interfaces'; +import { Commodity } from 'src/domain/commodities'; +import { DeletedEntityOperationError } from 'src/domain/domain.errors'; + +export class CommodityReferencePolicy { + constructor( + private readonly commodityRepository: CommodityRepositoryInterface, + ) {} + + async assertUsableForNewAccount( + userId: UUID, + commodityId: UUID, + ): Promise { + const commoditySnapshot = await this.commodityRepository.getById( + userId, + commodityId, + ); + + const commodity = Commodity.restore(commoditySnapshot); + + if (commodity.isDeleted()) { + throw DeletedEntityOperationError.forUse(Commodity.entityType); + } + + if (commodity.closed) { + throw new CommodityClosedError(commodityId, 'create_account'); + } + } +} diff --git a/apps/backend/src/application/services/CommodityReferencePolicy/index.ts b/apps/backend/src/application/services/CommodityReferencePolicy/index.ts new file mode 100644 index 00000000..eff30d58 --- /dev/null +++ b/apps/backend/src/application/services/CommodityReferencePolicy/index.ts @@ -0,0 +1 @@ +export { CommodityReferencePolicy } from './commodity-reference.policy'; diff --git a/apps/backend/src/application/services/index.ts b/apps/backend/src/application/services/index.ts index 99aaa788..657f3452 100644 --- a/apps/backend/src/application/services/index.ts +++ b/apps/backend/src/application/services/index.ts @@ -1,2 +1,3 @@ export * from './AccountOperationPolicy'; +export * from './CommodityReferencePolicy'; export * from './TransactionService'; 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 d44971d2..1e03d6b8 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/createAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/createAccount.test.ts @@ -2,6 +2,7 @@ import type { TransactionManagerInterface, AccountRepositoryInterface, } from 'src/application'; +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'; @@ -18,6 +19,10 @@ describe('CreateAccountUseCase', async () => { create: vi.fn(), }; + const commodityReferencePolicy = { + assertUsableForNewAccount: vi.fn(), + }; + const transactionManager = { run: vi.fn((cb: () => unknown) => cb()), }; @@ -36,9 +41,13 @@ describe('CreateAccountUseCase', async () => { const type = 'asset'; beforeEach(() => { + accountRepository.create.mockReset(); + commodityReferencePolicy.assertUsableForNewAccount.mockReset(); + createAccountUseCase = new CreateAccountUseCase( accountRepository as unknown as AccountRepositoryInterface, transactionManager as unknown as TransactionManagerInterface, + commodityReferencePolicy as unknown as CommodityReferencePolicy, ); }); @@ -57,6 +66,13 @@ describe('CreateAccountUseCase', async () => { type, }); + expect( + commodityReferencePolicy.assertUsableForNewAccount, + ).toHaveBeenCalledWith( + user.getId().valueOf(), + mockedCommoditySnapshot.id, + ); + expect(accountRepository.create).toHaveBeenCalledWith( user.getId().valueOf(), { @@ -85,5 +101,26 @@ describe('CreateAccountUseCase', async () => { userId: user.getId().valueOf(), }); }); + + it('should not create an account when commodity reference policy rejects', async () => { + const error = new Error('closed commodity'); + const mockedCommoditySnapshot = commodity.toSnapshot(); + + commodityReferencePolicy.assertUsableForNewAccount.mockRejectedValueOnce( + error, + ); + + await expect( + createAccountUseCase.execute(user, { + commodityId: mockedCommoditySnapshot.id, + description, + initialBalance, + name, + type, + }), + ).rejects.toBe(error); + + expect(accountRepository.create).not.toHaveBeenCalled(); + }); }); }); diff --git a/apps/backend/src/application/usecases/accounts/createAccount.ts b/apps/backend/src/application/usecases/accounts/createAccount.ts index 88d147ee..2e7bbad0 100644 --- a/apps/backend/src/application/usecases/accounts/createAccount.ts +++ b/apps/backend/src/application/usecases/accounts/createAccount.ts @@ -4,18 +4,25 @@ import type { TransactionManagerInterface, } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; +import { CommodityReferencePolicy } from 'src/application/services'; import { Account } from 'src/domain/'; import { User } from 'src/domain/users/user.entity'; export class CreateAccountUseCase { constructor( protected readonly accountRepository: AccountRepositoryInterface, protected readonly transactionManager: TransactionManagerInterface, + private readonly commodityReferencePolicy: CommodityReferencePolicy, ) {} async execute( user: User, data: AccountCreateDTO, ): Promise { const accountSnapshot = await this.transactionManager.run(async () => { + await this.commodityReferencePolicy.assertUsableForNewAccount( + user.getId().valueOf(), + data.commodityId, + ); + const account = Account.create( user, AccountMapper.toCreateAccountProps(data), diff --git a/apps/backend/src/di/container.ts b/apps/backend/src/di/container.ts index 6d9906ed..6b511c30 100644 --- a/apps/backend/src/di/container.ts +++ b/apps/backend/src/di/container.ts @@ -23,6 +23,7 @@ import { } from 'src/application'; import { AccountOperationPolicy, + CommodityReferencePolicy, TransactionContextLoader, } from 'src/application/services'; import { ensureEntityExistsAndOwned } from 'src/application/shared/ensureEntityExistsAndOwned'; @@ -85,6 +86,10 @@ export const createContainer = (db: DataBase): AppContainer => { operationRepository, ); + const commodityReferencePolicy = new CommodityReferencePolicy( + commodityRepository, + ); + const passwordManager = new PasswordManager(); const services: AppContainer['services'] = { @@ -95,6 +100,7 @@ export const createContainer = (db: DataBase): AppContainer => { const createAccountUseCase = new CreateAccountUseCase( accountRepository, transactionManager, + commodityReferencePolicy, ); const getAllAccountsUseCase = new GetAllAccountsUseCase(accountRepository); const getAccountByIdUseCase = new GetAccountByIdUseCase(accountRepository); 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 76627700..ded2e5ec 100644 --- a/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts +++ b/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts @@ -118,7 +118,7 @@ describe('AccountRepository', () => { expect(retrievedAccount.userId).toBe(newAccount.userId); }); - it("throws RepositoryNotFoundError when commodity belongs to a different user than the account's user", async () => { + it('allows creating an account when commodity ownership was already validated by the application layer', async () => { const secondUser = await testDB.createUser({ email: 'second-user@example.com', name: 'Second User', @@ -131,9 +131,18 @@ describe('AccountRepository', () => { userId: secondUser.id, }); - await expect( - accountRepository.create(secondUser.id, newAccount), - ).rejects.toThrowError(RepositoryNotFoundError); + await accountRepository.create(secondUser.id, newAccount); + + const createdAccount = await accountRepository.getById( + secondUser.id, + newAccount.id, + ); + + expect(createdAccount).toMatchObject({ + commodityId: usdCommodity.id, + id: newAccount.id, + userId: secondUser.id, + }); }); it('throws RepositoryInvariantError when create userId differs from account snapshot userId', async () => { @@ -227,7 +236,7 @@ describe('AccountRepository', () => { expect(retrievedAccount1.userId).not.toBe(retrievedAccount2.userId); }); - it('throws RepositoryNotFoundError when commodity is tombstoned', async () => { + it('allows creating an account with a tombstoned commodity at persistence boundary', async () => { const tombstonedCommodity = await testDB.createCommodity(user.id, { code: CommodityCode.create('TOMBSTONED').valueOf(), isTombstone: true, @@ -243,9 +252,46 @@ describe('AccountRepository', () => { userId: user.id, }); - await expect( - accountRepository.create(user.id, newAccount), - ).rejects.toThrowError(RepositoryNotFoundError); + await accountRepository.create(user.id, newAccount); + + const createdAccount = await accountRepository.getById( + user.id, + newAccount.id, + ); + + expect(createdAccount).toMatchObject({ + commodityId: tombstonedCommodity.id, + id: newAccount.id, + }); + }); + + it('allows creating an account with a closed commodity at persistence boundary', async () => { + const closedCommodity = await testDB.createCommodity(user.id, { + code: CommodityCode.create('CLOSED').valueOf(), + isClosed: true, + name: 'Closed Commodity', + precision: 2, + symbol: 'C', + }); + + const newAccount = getAccountData({ + commodityId: closedCommodity.id, + name: 'New Account with Closed Commodity', + type: 'asset', + userId: user.id, + }); + + await accountRepository.create(user.id, newAccount); + + const createdAccount = await accountRepository.getById( + user.id, + newAccount.id, + ); + + expect(createdAccount).toMatchObject({ + commodityId: closedCommodity.id, + id: newAccount.id, + }); }); it('throws AccountPersistenceConflictError when insert affects no rows', async () => { diff --git a/apps/backend/src/infrastructure/db/accounts/account.repository.ts b/apps/backend/src/infrastructure/db/accounts/account.repository.ts index fb4efd0e..91e60614 100644 --- a/apps/backend/src/infrastructure/db/accounts/account.repository.ts +++ b/apps/backend/src/infrastructure/db/accounts/account.repository.ts @@ -9,7 +9,6 @@ import { type AccountLifecycleAction, } from 'src/application'; import { accountsTable } from 'src/db/schemas/accounts'; -import { commoditiesTable } from 'src/db/schemas/commodities'; import { AccountSnapshot } from 'src/domain/accounts'; import { AccountPersistenceConflictError, @@ -92,31 +91,12 @@ export class AccountRepository create(userId: UUID, data: AccountSnapshot): Promise { return this.executeDatabaseOperation( async () => { - // TODO: consider moving to the base repository or a utility function to avoid duplication if (data.userId !== userId) { throw new RepositoryInvariantError( 'Account snapshot userId must match repository create userId', ); } - const existingCommodity = await this.db - .select() - .from(commoditiesTable) - .where( - and( - eq(commoditiesTable.id, data.commodityId), - eq(commoditiesTable.userId, data.userId), - eq(commoditiesTable.isTombstone, false), - ), - ) - .get(); - - this.ensureEntityExists( - existingCommodity, - `Commodity with ID ${data.commodityId} not found`, - this.entityNotFoundContext('commodity', data.commodityId), - ); - const result = await this.db.insert(accountsTable).values({ ...AccountPersistenceMapper.toDBRowFromSnapshot(data), currentClearedBalanceLocal: data.currentClearedBalanceLocal ?? '0', 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 eec6eb1c..e060dc89 100644 --- a/apps/backend/src/presentation/http/accounts/account.integration.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.integration.test.ts @@ -788,6 +788,44 @@ describe('Accounts Integration Tests', () => { expect(response.statusCode).toBe(404); }); + it('should return 409 when commodityId points to a closed commodity', async () => { + const closedCommodity = await testDB.createCommodity(userId, { + code: CommodityCode.create('CHF').valueOf(), + isClosed: true, + name: 'Swiss Franc', + precision: 2, + symbol: 'CHF', + }); + + const payload: AccountCreateDTO = { + commodityId: closedCommodity.id, + description: 'This is a new account', + initialBalance: Amount.create('1000').valueOf(), + name: 'New Account', + type: 'asset' as AccountTypeValue, + }; + + const response = await injectAuthorized({ + method: 'POST', + payload, + url, + }); + + const parsedResponse = JSON.parse(response.body) as ApiErrorResponse & { + code: typeof apiErrorCodes.closedCommodityReference; + }; + + expect(response.statusCode).toBe(409); + expect(parsedResponse).toEqual({ + code: apiErrorCodes.closedCommodityReference, + context: { + commodityId: closedCommodity.id, + operation: 'create_account', + }, + error: true, + }); + }); + it('should return 400 when type is empty', async () => { const payload: AccountCreateDTO = { commodityId: commodity.id, diff --git a/apps/backend/src/presentation/http/error-handler.ts b/apps/backend/src/presentation/http/error-handler.ts index 7e19927d..bd7f7ee2 100644 --- a/apps/backend/src/presentation/http/error-handler.ts +++ b/apps/backend/src/presentation/http/error-handler.ts @@ -21,6 +21,7 @@ const statusByErrorCode = { [apiErrorCodes.authenticationFailed]: 401, [apiErrorCodes.badRequest]: 400, [apiErrorCodes.closedAccountOperation]: 409, + [apiErrorCodes.closedCommodityReference]: 409, [apiErrorCodes.conflict]: 409, [apiErrorCodes.conflictingOperationIds]: 400, [apiErrorCodes.deletedEntityOperation]: 400, diff --git a/packages/shared/src/types/error-contract.ts b/packages/shared/src/types/error-contract.ts index 7229eaf4..4b732a03 100644 --- a/packages/shared/src/types/error-contract.ts +++ b/packages/shared/src/types/error-contract.ts @@ -6,6 +6,7 @@ export const apiErrorCodes = { authenticationFailed: "AUTHENTICATION_FAILED", badRequest: "BAD_REQUEST", closedAccountOperation: "CLOSED_ACCOUNT_OPERATION", + closedCommodityReference: "CLOSED_COMMODITY_REFERENCE", conflict: "CONFLICT", conflictingOperationIds: "CONFLICTING_OPERATION_IDS", deletedEntityOperation: "DELETED_ENTITY_OPERATION", @@ -69,6 +70,10 @@ export type ErrorContextByCode = { accountId: UUID; operation: "update" | "use"; }; + CLOSED_COMMODITY_REFERENCE: { + commodityId: UUID; + operation: "create_account" | "update_account"; + }; CONFLICT: Record; CONFLICTING_OPERATION_IDS: { conflict: From 243124b0c8c032349cb6bfb1d5e0173ac35939e7 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Wed, 5 Aug 2026 15:16:19 +0300 Subject: [PATCH 22/31] LED-125 feat(accounts, commodities): add foreign key constraints and unique indexes for account and commodity relationships --- apps/backend/drizzle/0002_numerous_alice.sql | 26 + apps/backend/drizzle/meta/0002_snapshot.json | 651 ++++++++++++++++++ apps/backend/drizzle/meta/_journal.json | 7 + apps/backend/src/db/schemas/accounts.ts | 12 +- apps/backend/src/db/schemas/commodities.ts | 1 + .../db/accounts/account.repository.test.ts | 45 +- .../http/accounts/account.integration.test.ts | 3 +- 7 files changed, 724 insertions(+), 21 deletions(-) create mode 100644 apps/backend/drizzle/0002_numerous_alice.sql create mode 100644 apps/backend/drizzle/meta/0002_snapshot.json diff --git a/apps/backend/drizzle/0002_numerous_alice.sql b/apps/backend/drizzle/0002_numerous_alice.sql new file mode 100644 index 00000000..a62a25bc --- /dev/null +++ b/apps/backend/drizzle/0002_numerous_alice.sql @@ -0,0 +1,26 @@ +CREATE UNIQUE INDEX `commodities_user_id_id_unique_idx` ON `commodities` (`user_id`,`id`);--> statement-breakpoint +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_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, + `updated_at` text NOT NULL, + `user_id` text NOT NULL, + FOREIGN KEY (`commodity_id`) REFERENCES `commodities`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`,`commodity_id`) REFERENCES `commodities`(`user_id`,`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `__new_accounts`("commodity_id", "created_at", "current_cleared_balance_local", "description", "id", "initial_balance", "is_closed", "is_system", "is_tombstone", "name", "type", "updated_at", "user_id") SELECT "commodity_id", "created_at", "current_cleared_balance_local", "description", "id", "initial_balance", "is_closed", "is_system", "is_tombstone", "name", "type", "updated_at", "user_id" FROM `accounts`;--> statement-breakpoint +DROP TABLE `accounts`;--> statement-breakpoint +ALTER TABLE `__new_accounts` RENAME TO `accounts`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE UNIQUE INDEX `user_id_name_unique_idx` ON `accounts` (`user_id`,`name`); \ No newline at end of file diff --git a/apps/backend/drizzle/meta/0002_snapshot.json b/apps/backend/drizzle/meta/0002_snapshot.json new file mode 100644 index 00000000..2b7b6dbe --- /dev/null +++ b/apps/backend/drizzle/meta/0002_snapshot.json @@ -0,0 +1,651 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "765211f5-980b-4844-961e-490a5746c9d5", + "prevId": "e80e6eea-4e83-4ffe-a8d0-8d3a50183d1b", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "commodity_id": { + "name": "commodity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "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", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initial_balance": { + "name": "initial_balance", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_closed": { + "name": "is_closed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_tombstone": { + "name": "is_tombstone", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_name_unique_idx": { + "name": "user_id_name_unique_idx", + "columns": [ + "user_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "accounts_commodity_id_commodities_id_fk": { + "name": "accounts_commodity_id_commodities_id_fk", + "tableFrom": "accounts", + "tableTo": "commodities", + "columnsFrom": [ + "commodity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "accounts_user_id_commodity_id_commodities_user_id_id_fk": { + "name": "accounts_user_id_commodity_id_commodities_user_id_id_fk", + "tableFrom": "accounts", + "tableTo": "commodities", + "columnsFrom": [ + "user_id", + "commodity_id" + ], + "columnsTo": [ + "user_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transactions": { + "name": "transactions", + "columns": { + "commodity_id": { + "name": "commodity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "is_tombstone": { + "name": "is_tombstone", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "posting_date": { + "name": "posting_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transaction_date": { + "name": "transaction_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_transactions_user_date": { + "name": "idx_transactions_user_date", + "columns": [ + "user_id", + "transaction_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "transactions_commodity_id_commodities_id_fk": { + "name": "transactions_commodity_id_commodities_id_fk", + "tableFrom": "transactions", + "tableTo": "commodities", + "columnsFrom": [ + "commodity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_user_id_users_id_fk": { + "name": "transactions_user_id_users_id_fk", + "tableFrom": "transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "operations": { + "name": "operations", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_tombstone": { + "name": "is_tombstone", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transaction_id": { + "name": "transaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_operations_transaction": { + "name": "idx_operations_transaction", + "columns": [ + "transaction_id" + ], + "isUnique": false + }, + "idx_operations_account": { + "name": "idx_operations_account", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "idx_operations_user": { + "name": "idx_operations_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "operations_account_id_accounts_id_fk": { + "name": "operations_account_id_accounts_id_fk", + "tableFrom": "operations", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "operations_transaction_id_transactions_id_fk": { + "name": "operations_transaction_id_transactions_id_fk", + "tableFrom": "operations", + "tableTo": "transactions", + "columnsFrom": [ + "transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "operations_user_id_users_id_fk": { + "name": "operations_user_id_users_id_fk", + "tableFrom": "operations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_users_id_fk": { + "name": "settings_user_id_users_id_fk", + "tableFrom": "settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commodities": { + "name": "commodities", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "is_closed": { + "name": "is_closed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_tombstone": { + "name": "is_tombstone", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "precision": { + "name": "precision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "symbol": { + "name": "symbol", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "commodities_user_id_code_unique_idx": { + "name": "commodities_user_id_code_unique_idx", + "columns": [ + "user_id", + "code" + ], + "isUnique": true + }, + "commodities_user_id_id_unique_idx": { + "name": "commodities_user_id_id_unique_idx", + "columns": [ + "user_id", + "id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "commodities_user_id_users_id_fk": { + "name": "commodities_user_id_users_id_fk", + "tableFrom": "commodities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index a690704e..fc64350c 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1785859950401, "tag": "0001_adorable_star_brand", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1785931823375, + "tag": "0002_numerous_alice", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/backend/src/db/schemas/accounts.ts b/apps/backend/src/db/schemas/accounts.ts index 5bf43f49..6f2d8451 100644 --- a/apps/backend/src/db/schemas/accounts.ts +++ b/apps/backend/src/db/schemas/accounts.ts @@ -1,7 +1,12 @@ import { ACCOUNT_TYPES } from '@ledgerly/shared/constants'; import { UUID } from '@ledgerly/shared/types'; import { InferInsertModel, InferSelectModel } from 'drizzle-orm'; -import { sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; +import { + foreignKey, + sqliteTable, + text, + uniqueIndex, +} from 'drizzle-orm/sqlite-core'; import { commoditiesTable } from './commodities'; import { @@ -44,6 +49,11 @@ export const accountsTable = sqliteTable( .$type(), }, (table) => [ + foreignKey({ + columns: [table.userId, table.commodityId], + foreignColumns: [commoditiesTable.userId, commoditiesTable.id], + name: 'accounts_user_id_commodity_id_commodities_user_id_id_fk', + }), uniqueIndex('user_id_name_unique_idx').on(table.userId, table.name), ], ); diff --git a/apps/backend/src/db/schemas/commodities.ts b/apps/backend/src/db/schemas/commodities.ts index a23e734b..f499e34e 100644 --- a/apps/backend/src/db/schemas/commodities.ts +++ b/apps/backend/src/db/schemas/commodities.ts @@ -37,6 +37,7 @@ export const commoditiesTable = sqliteTable( table.userId, table.code, ), + uniqueIndex('commodities_user_id_id_unique_idx').on(table.userId, table.id), ], ); 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 ded2e5ec..dd6fb8fe 100644 --- a/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts +++ b/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts @@ -19,6 +19,7 @@ import { AccountRepository } from 'src/infrastructure/db/'; import { AccountPersistenceConflictError, ForbiddenAccessError, + ForeignKeyConstraintError, RecordAlreadyExistsError, RepositoryInvariantError, RepositoryNotFoundError, @@ -118,7 +119,7 @@ describe('AccountRepository', () => { expect(retrievedAccount.userId).toBe(newAccount.userId); }); - it('allows creating an account when commodity ownership was already validated by the application layer', async () => { + it("throws ForeignKeyConstraintError when commodity belongs to a different user than the account's user", async () => { const secondUser = await testDB.createUser({ email: 'second-user@example.com', name: 'Second User', @@ -131,18 +132,9 @@ describe('AccountRepository', () => { userId: secondUser.id, }); - await accountRepository.create(secondUser.id, newAccount); - - const createdAccount = await accountRepository.getById( - secondUser.id, - newAccount.id, - ); - - expect(createdAccount).toMatchObject({ - commodityId: usdCommodity.id, - id: newAccount.id, - userId: secondUser.id, - }); + await expect( + accountRepository.create(secondUser.id, newAccount), + ).rejects.toThrowError(ForeignKeyConstraintError); }); it('throws RepositoryInvariantError when create userId differs from account snapshot userId', async () => { @@ -193,6 +185,9 @@ describe('AccountRepository', () => { email: 'second-user@example.com', name: 'Second User', }); + const secondUserCommodity = await testDB.createCommodity(secondUser.id, { + code: CommodityCode.create('USD').valueOf(), + }); const firstUserAccount = getAccountData({ commodityId: usdCommodity.id, @@ -202,7 +197,7 @@ describe('AccountRepository', () => { }); const secondUserAccount = getAccountData({ - commodityId: usdCommodity.id, + commodityId: secondUserCommodity.id, name: accountName, type: 'asset', userId: secondUser.id, @@ -218,7 +213,7 @@ describe('AccountRepository', () => { const retrievedAccount2 = await testDB.createAccount( secondUser.id, - usdCommodity.id, + secondUserCommodity.id, { ...secondUserAccount, }, @@ -409,6 +404,9 @@ describe('AccountRepository', () => { email: 'second-user@example.com', name: 'Second User', }); + const secondUserCommodity = await testDB.createCommodity(secondUser.id, { + code: CommodityCode.create('USD').valueOf(), + }); const accountsForSecondUser = [ { isClosed: false, isTombstone: false, name: 'secondUserAccount1' }, @@ -418,7 +416,7 @@ describe('AccountRepository', () => { await Promise.all( accountsForSecondUser.map((account) => - testDB.createAccount(secondUser.id, usdCommodity.id, { + testDB.createAccount(secondUser.id, secondUserCommodity.id, { isClosed: account.isClosed, isTombstone: account.isTombstone, name: account.name, @@ -646,10 +644,13 @@ describe('AccountRepository', () => { email: 'second-user@example.com', name: 'Second User', }); + const secondUserCommodity = await testDB.createCommodity(secondUser.id, { + code: CommodityCode.create('USD').valueOf(), + }); const secondUserAccount = await testDB.createAccount( secondUser.id, - usdCommodity.id, + secondUserCommodity.id, { initialBalance: Amount.create('200').valueOf(), name: 'Shared Account Name', @@ -789,10 +790,13 @@ describe('AccountRepository', () => { email: 'second-user@example.com', name: 'Second User', }); + const secondUserCommodity = await testDB.createCommodity(secondUser.id, { + code: CommodityCode.create('USD').valueOf(), + }); const createdAccount = await testDB.createAccount( secondUser.id, - usdCommodity.id, + secondUserCommodity.id, { initialBalance: Amount.create('2000').valueOf(), name: 'Shared Account Name', @@ -1119,10 +1123,13 @@ describe('AccountRepository', () => { const secondUser = await testDB.createUser({ email: 'second-user@example.com', }); + const secondUserCommodity = await testDB.createCommodity(secondUser.id, { + code: CommodityCode.create('USD').valueOf(), + }); const account = await testDB.createAccount( secondUser.id, - usdCommodity.id, + secondUserCommodity.id, ); await expect( 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 e060dc89..4b8603f6 100644 --- a/apps/backend/src/presentation/http/accounts/account.integration.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.integration.test.ts @@ -1206,9 +1206,10 @@ describe('Accounts Integration Tests', () => { }); it('should return 404 when account belongs to a different user', async () => { + const otherUserCommodity = await testDB.createCommodity(otherUserId); const otherUserAccount = await testDB.createAccount( otherUserId, - commodity.id, + otherUserCommodity.id, ); const response = await injectAuthorized({ From 05a0c406cad23f0eac71df42f8a5fe2500a4c496 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Wed, 5 Aug 2026 15:43:50 +0300 Subject: [PATCH 23/31] LED-125 refactor(accounts): rename archivedAccounts to closedAccounts for clarity refactor(accounts): rename mockArchiveAccountUseCase to mockDeleteAccountUseCase refactor(accounts): update integration tests to use 'inactive' status instead of 'archived' refactor(commodities): update test cases to use 'inactive' status instead of 'archived' refactor(error): restrict operation for CLOSED_COMMODITY_REFERENCE to 'create_account' --- .../db/accounts/account.repository.test.ts | 4 ++-- .../http/accounts/account.controller.test.ts | 12 ++++++------ .../http/accounts/account.integration.test.ts | 4 ++-- .../http/commodities/commodity.controller.test.ts | 2 +- packages/shared/src/types/error-contract.ts | 2 +- 5 files changed, 12 insertions(+), 12 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 dd6fb8fe..eae283b3 100644 --- a/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts +++ b/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts @@ -337,7 +337,7 @@ describe('AccountRepository', () => { (account) => !account.isClosed && !account.isTombstone, ); - const archivedAccounts = accountsDataList.filter( + const closedAccounts = accountsDataList.filter( (account) => account.isClosed && !account.isTombstone, ); @@ -350,7 +350,7 @@ describe('AccountRepository', () => { const testData: [AccountQuery, TestData[]][] = [ [{ status: 'all' }, allAccounts], [{ status: 'open' }, activeAccounts], - [{ status: 'closed' }, archivedAccounts], + [{ status: 'closed' }, closedAccounts], ]; beforeEach(async () => { 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 e8f155d2..f633901b 100644 --- a/apps/backend/src/presentation/http/accounts/account.controller.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.controller.test.ts @@ -41,7 +41,7 @@ describe('AccountController', () => { execute: vi.fn(), }; - const mockArchiveAccountUseCase = { + const mockDeleteAccountUseCase = { execute: vi.fn(), }; @@ -58,7 +58,7 @@ describe('AccountController', () => { mockGetAllAccountsUseCase as unknown as GetAllAccountsUseCase, mockCreateAccountUseCase as unknown as CreateAccountUseCase, mockUpdateAccountUseCase as unknown as UpdateAccountUseCase, - mockArchiveAccountUseCase as unknown as DeleteAccountUseCase, + mockDeleteAccountUseCase as unknown as DeleteAccountUseCase, mockCloseAccountUseCase as unknown as CloseAccountUseCase, mockOpenAccountUseCase as unknown as OpenAccountUseCase, ); @@ -199,15 +199,15 @@ describe('AccountController', () => { describe('deleteAccount', () => { it('should call accountController.deleteAccount with correct account id and user', async () => { - mockArchiveAccountUseCase.execute.mockResolvedValue(undefined); + mockDeleteAccountUseCase.execute.mockResolvedValue(undefined); await accountController.deleteAccount(user, { id: accountId }); - expect(mockArchiveAccountUseCase.execute).toHaveBeenCalledWith( + expect(mockDeleteAccountUseCase.execute).toHaveBeenCalledWith( user, accountId, ); - expect(mockArchiveAccountUseCase.execute).toHaveBeenCalledTimes(1); + expect(mockDeleteAccountUseCase.execute).toHaveBeenCalledTimes(1); }); it('should throw ZodError for invalid request params', async () => { @@ -215,7 +215,7 @@ describe('AccountController', () => { accountController.deleteAccount(user, { id: 'not-a-uuid' }), ).rejects.toThrow(ZodError); - expect(mockArchiveAccountUseCase.execute).not.toHaveBeenCalled(); + expect(mockDeleteAccountUseCase.execute).not.toHaveBeenCalled(); }); }); 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 4b8603f6..ef5d466a 100644 --- a/apps/backend/src/presentation/http/accounts/account.integration.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.integration.test.ts @@ -615,7 +615,7 @@ describe('Accounts Integration Tests', () => { const response = await injectAuthorized({ method: 'GET', url: getWithQueryParamsUrl( - 'archived' as unknown as AccountStatusFilterValue, + 'inactive' as unknown as AccountStatusFilterValue, ), }); @@ -1325,7 +1325,7 @@ describe('Accounts Integration Tests', () => { const response = await injectAuthorized({ method: 'GET', url: getWithQueryParamsUrl( - 'archived' as unknown as AccountStatusFilterValue, + 'inactive' as unknown as AccountStatusFilterValue, ), }); diff --git a/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts b/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts index a4d799c6..132137f5 100644 --- a/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts +++ b/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts @@ -99,7 +99,7 @@ describe('CommodityController', () => { { status: 1 }, { status: true }, 'active', - 'archived', + 'inactive', 'deleted', '', undefined, diff --git a/packages/shared/src/types/error-contract.ts b/packages/shared/src/types/error-contract.ts index 4b732a03..529553fb 100644 --- a/packages/shared/src/types/error-contract.ts +++ b/packages/shared/src/types/error-contract.ts @@ -72,7 +72,7 @@ export type ErrorContextByCode = { }; CLOSED_COMMODITY_REFERENCE: { commodityId: UUID; - operation: "create_account" | "update_account"; + operation: "create_account"; }; CONFLICT: Record; CONFLICTING_OPERATION_IDS: { From f245bcc4f1f30ec4d18e5efc158595b032f8dc53 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Wed, 5 Aug 2026 15:48:26 +0300 Subject: [PATCH 24/31] LED-125 fix(commodities): load lifecycle snapshots --- .../__tests__/closeCommodity.test.ts | 47 +++++++++++++++---- .../__tests__/openCommodity.test.ts | 47 +++++++++++++++---- .../usecases/commodities/closeCommodity.ts | 12 ++++- .../usecases/commodities/openCommodity.ts | 12 ++++- 4 files changed, 100 insertions(+), 18 deletions(-) diff --git a/apps/backend/src/application/usecases/commodities/__tests__/closeCommodity.test.ts b/apps/backend/src/application/usecases/commodities/__tests__/closeCommodity.test.ts index 9e1db6c8..476c3ec9 100644 --- a/apps/backend/src/application/usecases/commodities/__tests__/closeCommodity.test.ts +++ b/apps/backend/src/application/usecases/commodities/__tests__/closeCommodity.test.ts @@ -22,7 +22,7 @@ describe('CloseCommodityUseCase', () => { const commodityRepository = { close: vi.fn(), - getById: vi.fn(), + getByIdForLifecycle: vi.fn(), }; const mockTransactionManager = { @@ -45,7 +45,7 @@ describe('CloseCommodityUseCase', () => { vi.useRealTimers(); mockTransactionManager.run.mockImplementation(runTransaction); commodityRepository.close.mockClear(); - commodityRepository.getById.mockClear(); + commodityRepository.getByIdForLifecycle.mockClear(); mockTransactionManager.run.mockClear(); }); @@ -62,14 +62,16 @@ describe('CloseCommodityUseCase', () => { }); const commodityId = commodity.getId().valueOf(); - commodityRepository.getById.mockResolvedValue(commodity.toSnapshot()); + commodityRepository.getByIdForLifecycle.mockResolvedValue( + commodity.toSnapshot(), + ); commodityRepository.close.mockResolvedValue(undefined); vi.setSystemTime(new Date(updatedAt)); const result = await closeCommodityUseCase.execute(user, commodityId); - expect(commodityRepository.getById).toHaveBeenCalledWith( + expect(commodityRepository.getByIdForLifecycle).toHaveBeenCalledWith( user.getId().valueOf(), commodityId, ); @@ -98,11 +100,13 @@ describe('CloseCommodityUseCase', () => { commodity.close(); const commodityId = commodity.getId().valueOf(); - commodityRepository.getById.mockResolvedValue(commodity.toSnapshot()); + commodityRepository.getByIdForLifecycle.mockResolvedValue( + commodity.toSnapshot(), + ); const result = await closeCommodityUseCase.execute(user, commodityId); - expect(commodityRepository.getById).toHaveBeenCalledWith( + expect(commodityRepository.getByIdForLifecycle).toHaveBeenCalledWith( user.getId().valueOf(), commodityId, ); @@ -116,7 +120,7 @@ describe('CloseCommodityUseCase', () => { it('should throw an error if the commodity does not exist', async () => { const nonExistentCommodityId = Id.create().valueOf(); - commodityRepository.getById.mockResolvedValue(null); + commodityRepository.getByIdForLifecycle.mockResolvedValue(null); await expect( closeCommodityUseCase.execute(user, nonExistentCommodityId), @@ -129,6 +133,31 @@ describe('CloseCommodityUseCase', () => { expect(commodityRepository.close).not.toHaveBeenCalled(); }); + it('should throw an error if the commodity is deleted', async () => { + const commodity = createCommodity(user, { + code: 'TEST', + name: 'Test Commodity', + precision: 2, + symbol: 'T', + }); + commodity.delete(); + const commodityId = commodity.getId().valueOf(); + + commodityRepository.getByIdForLifecycle.mockResolvedValue( + commodity.toSnapshot(), + ); + + await expect( + closeCommodityUseCase.execute(user, commodityId), + ).rejects.toThrowError( + new EntityNotFoundError({ + entityId: commodityId, + entityType: Commodity.entityType, + }), + ); + expect(commodityRepository.close).not.toHaveBeenCalled(); + }); + it('should throw an error if the commodity does not belong to the user', async () => { const otherUser = await createUser(); const commodity = createCommodity(otherUser, { @@ -139,7 +168,9 @@ describe('CloseCommodityUseCase', () => { }); const commodityId = commodity.getId().valueOf(); - commodityRepository.getById.mockResolvedValue(commodity.toSnapshot()); + commodityRepository.getByIdForLifecycle.mockResolvedValue( + commodity.toSnapshot(), + ); await expect( closeCommodityUseCase.execute(user, commodityId), diff --git a/apps/backend/src/application/usecases/commodities/__tests__/openCommodity.test.ts b/apps/backend/src/application/usecases/commodities/__tests__/openCommodity.test.ts index 2cd3ba51..58d2ee62 100644 --- a/apps/backend/src/application/usecases/commodities/__tests__/openCommodity.test.ts +++ b/apps/backend/src/application/usecases/commodities/__tests__/openCommodity.test.ts @@ -21,7 +21,7 @@ describe('OpenCommodityUseCase', () => { let user: User; const commodityRepository = { - getById: vi.fn(), + getByIdForLifecycle: vi.fn(), open: vi.fn(), }; @@ -45,7 +45,7 @@ describe('OpenCommodityUseCase', () => { vi.useRealTimers(); mockTransactionManager.run.mockImplementation(runTransaction); commodityRepository.open.mockClear(); - commodityRepository.getById.mockClear(); + commodityRepository.getByIdForLifecycle.mockClear(); mockTransactionManager.run.mockClear(); }); @@ -63,14 +63,16 @@ describe('OpenCommodityUseCase', () => { commodity.close(); const commodityId = commodity.getId().valueOf(); - commodityRepository.getById.mockResolvedValue(commodity.toSnapshot()); + commodityRepository.getByIdForLifecycle.mockResolvedValue( + commodity.toSnapshot(), + ); commodityRepository.open.mockResolvedValue(undefined); vi.setSystemTime(new Date(updatedAt)); const result = await openCommodityUseCase.execute(user, commodityId); - expect(commodityRepository.getById).toHaveBeenCalledWith( + expect(commodityRepository.getByIdForLifecycle).toHaveBeenCalledWith( user.getId().valueOf(), commodityId, ); @@ -98,11 +100,13 @@ describe('OpenCommodityUseCase', () => { }); const commodityId = commodity.getId().valueOf(); - commodityRepository.getById.mockResolvedValue(commodity.toSnapshot()); + commodityRepository.getByIdForLifecycle.mockResolvedValue( + commodity.toSnapshot(), + ); const result = await openCommodityUseCase.execute(user, commodityId); - expect(commodityRepository.getById).toHaveBeenCalledWith( + expect(commodityRepository.getByIdForLifecycle).toHaveBeenCalledWith( user.getId().valueOf(), commodityId, ); @@ -116,7 +120,7 @@ describe('OpenCommodityUseCase', () => { it('should throw an error if the commodity does not exist', async () => { const nonExistentCommodityId = Id.create().valueOf(); - commodityRepository.getById.mockResolvedValue(null); + commodityRepository.getByIdForLifecycle.mockResolvedValue(null); await expect( openCommodityUseCase.execute(user, nonExistentCommodityId), @@ -129,6 +133,31 @@ describe('OpenCommodityUseCase', () => { expect(commodityRepository.open).not.toHaveBeenCalled(); }); + it('should throw an error if the commodity is deleted', async () => { + const commodity = createCommodity(user, { + code: 'TEST', + name: 'Test Commodity', + precision: 2, + symbol: 'T', + }); + commodity.delete(); + const commodityId = commodity.getId().valueOf(); + + commodityRepository.getByIdForLifecycle.mockResolvedValue( + commodity.toSnapshot(), + ); + + await expect( + openCommodityUseCase.execute(user, commodityId), + ).rejects.toThrowError( + new EntityNotFoundError({ + entityId: commodityId, + entityType: Commodity.entityType, + }), + ); + expect(commodityRepository.open).not.toHaveBeenCalled(); + }); + it('should throw an error if the commodity does not belong to the user', async () => { const otherUser = await createUser(); const commodity = createCommodity(otherUser, { @@ -139,7 +168,9 @@ describe('OpenCommodityUseCase', () => { }); const commodityId = commodity.getId().valueOf(); - commodityRepository.getById.mockResolvedValue(commodity.toSnapshot()); + commodityRepository.getByIdForLifecycle.mockResolvedValue( + commodity.toSnapshot(), + ); await expect( openCommodityUseCase.execute(user, commodityId), diff --git a/apps/backend/src/application/usecases/commodities/closeCommodity.ts b/apps/backend/src/application/usecases/commodities/closeCommodity.ts index 55ca846d..5759b094 100644 --- a/apps/backend/src/application/usecases/commodities/closeCommodity.ts +++ b/apps/backend/src/application/usecases/commodities/closeCommodity.ts @@ -1,5 +1,6 @@ import { CommodityResponseDTO, UUID } from '@ledgerly/shared/types'; import { CommodityMapper } from 'src/application'; +import { EntityNotFoundError } from 'src/application/application.errors'; import { CommodityRepositoryInterface, TransactionManagerInterface, @@ -21,12 +22,21 @@ export class CloseCommodityUseCase { entityId: commodityId, entityType: Commodity.entityType, getOwnerId: (commodity) => commodity.userId, - load: this.commodityRepository.getById.bind(this.commodityRepository), + load: this.commodityRepository.getByIdForLifecycle.bind( + this.commodityRepository, + ), user, }); const commodity = Commodity.restore(commodityData); + if (commodity.isDeleted()) { + throw new EntityNotFoundError({ + entityId: commodityId, + entityType: Commodity.entityType, + }); + } + const result = commodity.close(); if (result === 'changed') { diff --git a/apps/backend/src/application/usecases/commodities/openCommodity.ts b/apps/backend/src/application/usecases/commodities/openCommodity.ts index 7870b2a9..c5a38352 100644 --- a/apps/backend/src/application/usecases/commodities/openCommodity.ts +++ b/apps/backend/src/application/usecases/commodities/openCommodity.ts @@ -1,5 +1,6 @@ import { CommodityResponseDTO, UUID } from '@ledgerly/shared/types'; import { CommodityMapper } from 'src/application'; +import { EntityNotFoundError } from 'src/application/application.errors'; import { CommodityRepositoryInterface, TransactionManagerInterface, @@ -21,12 +22,21 @@ export class OpenCommodityUseCase { entityId: commodityId, entityType: Commodity.entityType, getOwnerId: (commodity) => commodity.userId, - load: this.commodityRepository.getById.bind(this.commodityRepository), + load: this.commodityRepository.getByIdForLifecycle.bind( + this.commodityRepository, + ), user, }); const commodity = Commodity.restore(commodityData); + if (commodity.isDeleted()) { + throw new EntityNotFoundError({ + entityId: commodityId, + entityType: Commodity.entityType, + }); + } + const result = commodity.open(); if (result === 'changed') { From c8e742d91c444f49e7488b7eff291287652858d3 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Wed, 5 Aug 2026 16:13:11 +0300 Subject: [PATCH 25/31] LED-125 fix(commodities): align lifecycle contracts --- apps/backend/drizzle/0003_bored_daredevil.sql | 24 + apps/backend/drizzle/meta/0003_snapshot.json | 638 ++++++++++++++++++ apps/backend/drizzle/meta/_journal.json | 7 + .../CommodityRepository.interface.ts | 4 +- .../src/application/interfaces/index.ts | 2 +- apps/backend/src/db/schemas/accounts.ts | 5 +- .../commodities/commodity.repository.test.ts | 85 +++ .../db/commodities/commodity.repository.ts | 6 +- .../http/accounts/account.integration.test.ts | 2 +- .../commodities/commodity.integration.test.ts | 36 +- .../http/commodities/commodity.routes.ts | 8 +- docs/DATABASE_SCHEMA.md | 153 +++-- docs/DOMAIN.md | 33 +- docs/ERROR_ARCHITECTURE.md | 32 +- ...ity-registry-before-currency-validation.md | 25 +- .../adr/0016-backend-request-flow.md | 7 +- ...enforced-commodity-reference-validation.md | 5 +- ...-tombstone-and-reversible-entity-states.md | 117 ++-- ...iness-policy-from-persistence-integrity.md | 90 +++ docs/architecture/adr/README.md | 3 +- 20 files changed, 1091 insertions(+), 191 deletions(-) create mode 100644 apps/backend/drizzle/0003_bored_daredevil.sql create mode 100644 apps/backend/drizzle/meta/0003_snapshot.json create mode 100644 docs/architecture/adr/0021-split-commodity-reference-business-policy-from-persistence-integrity.md diff --git a/apps/backend/drizzle/0003_bored_daredevil.sql b/apps/backend/drizzle/0003_bored_daredevil.sql new file mode 100644 index 00000000..5c3172b7 --- /dev/null +++ b/apps/backend/drizzle/0003_bored_daredevil.sql @@ -0,0 +1,24 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_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, + `updated_at` text NOT NULL, + `user_id` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`,`commodity_id`) REFERENCES `commodities`(`user_id`,`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `__new_accounts`("commodity_id", "created_at", "current_cleared_balance_local", "description", "id", "initial_balance", "is_closed", "is_system", "is_tombstone", "name", "type", "updated_at", "user_id") SELECT "commodity_id", "created_at", "current_cleared_balance_local", "description", "id", "initial_balance", "is_closed", "is_system", "is_tombstone", "name", "type", "updated_at", "user_id" FROM `accounts`;--> statement-breakpoint +DROP TABLE `accounts`;--> statement-breakpoint +ALTER TABLE `__new_accounts` RENAME TO `accounts`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE UNIQUE INDEX `user_id_name_unique_idx` ON `accounts` (`user_id`,`name`); \ No newline at end of file diff --git a/apps/backend/drizzle/meta/0003_snapshot.json b/apps/backend/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000..cafcc749 --- /dev/null +++ b/apps/backend/drizzle/meta/0003_snapshot.json @@ -0,0 +1,638 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ad45c791-cf15-40f1-b7db-8c953a976d82", + "prevId": "765211f5-980b-4844-961e-490a5746c9d5", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "commodity_id": { + "name": "commodity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "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", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "initial_balance": { + "name": "initial_balance", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_closed": { + "name": "is_closed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_tombstone": { + "name": "is_tombstone", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_name_unique_idx": { + "name": "user_id_name_unique_idx", + "columns": [ + "user_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "accounts_user_id_commodity_id_commodities_user_id_id_fk": { + "name": "accounts_user_id_commodity_id_commodities_user_id_id_fk", + "tableFrom": "accounts", + "tableTo": "commodities", + "columnsFrom": [ + "user_id", + "commodity_id" + ], + "columnsTo": [ + "user_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transactions": { + "name": "transactions", + "columns": { + "commodity_id": { + "name": "commodity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "is_tombstone": { + "name": "is_tombstone", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "posting_date": { + "name": "posting_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transaction_date": { + "name": "transaction_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_transactions_user_date": { + "name": "idx_transactions_user_date", + "columns": [ + "user_id", + "transaction_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "transactions_commodity_id_commodities_id_fk": { + "name": "transactions_commodity_id_commodities_id_fk", + "tableFrom": "transactions", + "tableTo": "commodities", + "columnsFrom": [ + "commodity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_user_id_users_id_fk": { + "name": "transactions_user_id_users_id_fk", + "tableFrom": "transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "operations": { + "name": "operations", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_tombstone": { + "name": "is_tombstone", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transaction_id": { + "name": "transaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_operations_transaction": { + "name": "idx_operations_transaction", + "columns": [ + "transaction_id" + ], + "isUnique": false + }, + "idx_operations_account": { + "name": "idx_operations_account", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "idx_operations_user": { + "name": "idx_operations_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "operations_account_id_accounts_id_fk": { + "name": "operations_account_id_accounts_id_fk", + "tableFrom": "operations", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "operations_transaction_id_transactions_id_fk": { + "name": "operations_transaction_id_transactions_id_fk", + "tableFrom": "operations", + "tableTo": "transactions", + "columnsFrom": [ + "transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "operations_user_id_users_id_fk": { + "name": "operations_user_id_users_id_fk", + "tableFrom": "operations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_users_id_fk": { + "name": "settings_user_id_users_id_fk", + "tableFrom": "settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commodities": { + "name": "commodities", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "is_closed": { + "name": "is_closed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_tombstone": { + "name": "is_tombstone", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "precision": { + "name": "precision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "symbol": { + "name": "symbol", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "commodities_user_id_code_unique_idx": { + "name": "commodities_user_id_code_unique_idx", + "columns": [ + "user_id", + "code" + ], + "isUnique": true + }, + "commodities_user_id_id_unique_idx": { + "name": "commodities_user_id_id_unique_idx", + "columns": [ + "user_id", + "id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "commodities_user_id_users_id_fk": { + "name": "commodities_user_id_users_id_fk", + "tableFrom": "commodities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index fc64350c..e05cc786 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1785931823375, "tag": "0002_numerous_alice", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1785935002154, + "tag": "0003_bored_daredevil", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/backend/src/application/interfaces/CommodityRepository.interface.ts b/apps/backend/src/application/interfaces/CommodityRepository.interface.ts index 656addf7..6b5592e1 100644 --- a/apps/backend/src/application/interfaces/CommodityRepository.interface.ts +++ b/apps/backend/src/application/interfaces/CommodityRepository.interface.ts @@ -8,7 +8,7 @@ export type CommodityRepositoryUpdateInput = Pick< > & Partial>; -export type CommodityRepositorySoftDeleteInput = Pick< +export type CommodityRepositoryDeleteInput = Pick< CommoditySnapshot, 'updatedAt' >; @@ -40,7 +40,7 @@ export type CommodityRepositoryInterface = { delete( userId: UUID, commodityId: UUID, - data: CommodityRepositorySoftDeleteInput, + data: CommodityRepositoryDeleteInput, ): Promise; open( userId: UUID, diff --git a/apps/backend/src/application/interfaces/index.ts b/apps/backend/src/application/interfaces/index.ts index 3b09c72c..8571d150 100644 --- a/apps/backend/src/application/interfaces/index.ts +++ b/apps/backend/src/application/interfaces/index.ts @@ -25,5 +25,5 @@ export type { CommodityRepositoryInterface, CommodityRepositoryLifecycleInput, CommodityRepositoryUpdateInput, - CommodityRepositorySoftDeleteInput, + CommodityRepositoryDeleteInput, } from './CommodityRepository.interface'; diff --git a/apps/backend/src/db/schemas/accounts.ts b/apps/backend/src/db/schemas/accounts.ts index 6f2d8451..165a7a53 100644 --- a/apps/backend/src/db/schemas/accounts.ts +++ b/apps/backend/src/db/schemas/accounts.ts @@ -24,10 +24,7 @@ import { usersTable } from './users'; export const accountsTable = sqliteTable( 'accounts', { - commodityId: text('commodity_id') - .notNull() - .references(() => commoditiesTable.id) - .$type(), + commodityId: text('commodity_id').notNull().$type(), createdAt, currentClearedBalanceLocal: getAmountColumn( 'current_cleared_balance_local', 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 f55f3741..d58dc593 100644 --- a/apps/backend/src/infrastructure/db/commodities/commodity.repository.test.ts +++ b/apps/backend/src/infrastructure/db/commodities/commodity.repository.test.ts @@ -1,5 +1,6 @@ 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'; @@ -9,6 +10,7 @@ import { CommodityCode, Name, Timestamp } from 'src/domain/domain-core/'; import { Id } from 'src/domain/domain-core/value-objects/Id'; import { RecordAlreadyExistsError, + RepositoryInvariantError, RepositoryNotFoundError, } from 'src/infrastructure/errors'; import { describe, vi, beforeEach, it, expect } from 'vitest'; @@ -224,6 +226,17 @@ describe('CommodityRepository', () => { expect(commodities).toEqual([]); }); + it('should return an empty array when the user does not exist', async () => { + const commodities = await commodityRepository.getAll( + Id.create().valueOf(), + { + status: 'all', + }, + ); + + expect(commodities).toEqual([]); + }); + it.each([ { status: 'open' }, { status: 'closed' }, @@ -281,6 +294,27 @@ describe('CommodityRepository', () => { expect(retrievedCommodity).toMatchObject(newCommodityData); }); + it('should throw RepositoryInvariantError when create userId differs from commodity snapshot userId', async () => { + const anotherUser = await testDB.createUser(); + + const newCommodityData: CommoditySnapshot = { + code: CommodityCode.create('NEW').valueOf(), + createdAt: Timestamp.create().valueOf(), + id: Id.create().valueOf(), + isClosed: false, + isTombstone: false, + name: Name.create('New Commodity').valueOf(), + precision: 2, + symbol: 'N', + updatedAt: Timestamp.create().valueOf(), + userId: anotherUser.id, + }; + + await expect( + commodityRepository.create(user.id, newCommodityData), + ).rejects.toThrowError(RepositoryInvariantError); + }); + it('should throw an error when trying to create a commodity with an existing CommodityCode', async () => { const createdCommodity = await testDB.createCommodity(user.id); @@ -794,4 +828,55 @@ describe('CommodityRepository', () => { ).rejects.toThrowError(RepositoryNotFoundError); }); }); + + describe('timestamp behavior', () => { + it('sets createdAt and updatedAt on creation', async () => { + const beforeCreate = dayjs(); + + const newCommodityData: CommoditySnapshot = { + code: CommodityCode.create('NEW').valueOf(), + createdAt: Timestamp.create().valueOf(), + id: Id.create().valueOf(), + isClosed: false, + isTombstone: false, + name: Name.create('New Commodity').valueOf(), + precision: 2, + symbol: 'N', + updatedAt: Timestamp.create().valueOf(), + userId: user.id, + }; + + await commodityRepository.create(user.id, newCommodityData); + + const commodity = await testDB.getCommodityById(newCommodityData.id); + const commodityDate = dayjs(commodity?.createdAt); + const afterCreate = dayjs(); + + expect(commodity?.createdAt).toBeDefined(); + expect(commodity?.updatedAt).toBeDefined(); + expect(dayjs(commodity?.createdAt)).toBeInstanceOf(dayjs); + expect(dayjs(commodity?.updatedAt)).toBeInstanceOf(dayjs); + + expect(beforeCreate.unix()).toBeLessThanOrEqual(commodityDate.unix()); + expect(commodityDate.unix()).toBeLessThanOrEqual(afterCreate.unix()); + }); + + it('updates updatedAt on commodity update', async () => { + const commodity = await testDB.createCommodity(user.id); + const originalUpdatedAt = dayjs(commodity.updatedAt); + const updatedAt = Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(); + + await commodityRepository.update(user.id, commodity.id, { + name: Name.create('Updated Commodity').valueOf(), + updatedAt, + }); + + const updatedCommodity = await testDB.getCommodityById(commodity.id); + const newUpdatedAt = dayjs(updatedCommodity?.updatedAt); + + expect(updatedCommodity?.updatedAt).toBe(updatedAt); + expect(updatedCommodity?.updatedAt).not.toBe(commodity.updatedAt); + expect(originalUpdatedAt.unix()).toBeLessThanOrEqual(newUpdatedAt.unix()); + }); + }); }); diff --git a/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts b/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts index 4300150b..f918e565 100644 --- a/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts +++ b/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts @@ -7,7 +7,7 @@ import { type CommodityRepositoryInterface, type CommodityRepositoryLifecycleInput, type CommodityRepositoryUpdateInput, - type CommodityRepositorySoftDeleteInput, + type CommodityRepositoryDeleteInput, } from 'src/application'; import { commoditiesTable } from 'src/db/schemas/commodities'; import { CommoditySnapshot } from 'src/domain/commodities/types'; @@ -196,7 +196,7 @@ export class CommodityRepository delete( userId: UUID, commodityId: UUID, - data: CommodityRepositorySoftDeleteInput, + data: CommodityRepositoryDeleteInput, ): Promise { return this.executeDatabaseOperation(async () => { const result = await this.db @@ -214,7 +214,7 @@ export class CommodityRepository result.rowsAffected, this.commodityNotFoundError(commodityId), ); - }, `Failed to soft delete commodity with ID ${commodityId}`); + }, `Failed to delete commodity with ID ${commodityId}`); } private updateLifecycle( 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 ef5d466a..4a0b0f0e 100644 --- a/apps/backend/src/presentation/http/accounts/account.integration.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.integration.test.ts @@ -1,3 +1,4 @@ +import { AccountStatusFilterValue } from '@ledgerly/shared/constants'; import { ROUTES } from '@ledgerly/shared/routes'; import { AccountCreateDTO, @@ -7,7 +8,6 @@ import { apiErrorCodes, UUID, } from '@ledgerly/shared/types'; -import { AccountStatusFilterValue } from 'node_modules/@ledgerly/shared/src/constants/accounts'; import { CommodityDbRow } from 'src/db/schemas/commodities'; import { TestDB } from 'src/db/test-db'; import { compareEntityArrays } from 'src/db/test-utils/entityComparer'; 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 c9f9544c..97f4d549 100644 --- a/apps/backend/src/presentation/http/commodities/commodity.integration.test.ts +++ b/apps/backend/src/presentation/http/commodities/commodity.integration.test.ts @@ -644,7 +644,7 @@ describe('Commodities Integration Tests', () => { }); describe('POST /api/commodities/:id/close', () => { - it('should close an open commodity by id', async () => { + it('should return 200 and close an open commodity by id', async () => { const commodityToClose = commoditiesDbRows.find( (commodity) => !commodity.isClosed && !commodity.isTombstone, ); @@ -658,7 +658,13 @@ describe('Commodities Integration Tests', () => { url: `${url}/${commodityToClose.id}/close`, }); - expect(response.statusCode).toBe(204); + const responseBody = parseResponse(response); + + expect(response.statusCode).toBe(200); + expect(responseBody).toMatchObject({ + id: commodityToClose.id, + isClosed: true, + }); const closedCommodity = await testDB.getCommodityById( commodityToClose.id, @@ -682,7 +688,13 @@ describe('Commodities Integration Tests', () => { url: `${url}/${closedCommodity.id}/close`, }); - expect(response.statusCode).toBe(204); + const responseBody = parseResponse(response); + + expect(response.statusCode).toBe(200); + expect(responseBody).toMatchObject({ + id: closedCommodity.id, + isClosed: true, + }); const retrievedCommodity = await testDB.getCommodityById( closedCommodity.id, @@ -747,7 +759,7 @@ describe('Commodities Integration Tests', () => { }); describe('POST /api/commodities/:id/open', () => { - it('should open a closed commodity by id', async () => { + it('should return 200 and open a closed commodity by id', async () => { const commodityToOpen = await testDB.createCommodity(userId, { code: CommodityCode.create('CHF').valueOf(), isClosed: true, @@ -762,7 +774,13 @@ describe('Commodities Integration Tests', () => { url: `${url}/${commodityToOpen.id}/open`, }); - expect(response.statusCode).toBe(204); + const responseBody = parseResponse(response); + + expect(response.statusCode).toBe(200); + expect(responseBody).toMatchObject({ + id: commodityToOpen.id, + isClosed: false, + }); const openedCommodity = await testDB.getCommodityById(commodityToOpen.id); @@ -783,7 +801,13 @@ describe('Commodities Integration Tests', () => { url: `${url}/${openCommodity.id}/open`, }); - expect(response.statusCode).toBe(204); + const responseBody = parseResponse(response); + + expect(response.statusCode).toBe(200); + expect(responseBody).toMatchObject({ + id: openCommodity.id, + isClosed: false, + }); const retrievedCommodity = await testDB.getCommodityById( openCommodity.id, diff --git a/apps/backend/src/presentation/http/commodities/commodity.routes.ts b/apps/backend/src/presentation/http/commodities/commodity.routes.ts index a8d0770b..8d4ac9a1 100644 --- a/apps/backend/src/presentation/http/commodities/commodity.routes.ts +++ b/apps/backend/src/presentation/http/commodities/commodity.routes.ts @@ -33,17 +33,17 @@ export const commodityRoutes = (app: FastifyInstance) => { app.post('/:id/close', async (request, reply) => { const user = request.user; - await commodityController.close(user, request.params); + const commodity = await commodityController.close(user, request.params); - reply.status(204).send(); + reply.status(200).send(commodity); }); app.post('/:id/open', async (request, reply) => { const user = request.user; - await commodityController.open(user, request.params); + const commodity = await commodityController.open(user, request.params); - reply.status(204).send(); + reply.status(200).send(commodity); }); app.patch('/:id', async (request, reply) => { diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 421dd33d..4e373d9b 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -36,6 +36,7 @@ erDiagram string name string symbol "nullable" integer precision + boolean isClosed boolean isTombstone timestamp createdAt timestamp updatedAt @@ -49,6 +50,7 @@ erDiagram string description integer initialBalance integer currentClearedBalanceLocal + boolean isClosed boolean isSystem boolean isTombstone uuid userId FK @@ -111,14 +113,14 @@ erDiagram Base entity for authentication and authorization. -| Field | Type | Description | Constraints | -|------|-----|----------|-------------| -| `id` | UUID | Primary key | PK, NOT NULL | -| `email` | String | User email | UNIQUE, NOT NULL | -| `name` | String | User name | NOT NULL | -| `password` | String | Hashed password | NOT NULL | -| `createdAt` | Timestamp | Creation date | NOT NULL | -| `updatedAt` | Timestamp | Last update date | NOT NULL | +| Field | Type | Description | Constraints | +| ----------- | --------- | ---------------- | ---------------- | +| `id` | UUID | Primary key | PK, NOT NULL | +| `email` | String | User email | UNIQUE, NOT NULL | +| `name` | String | User name | NOT NULL | +| `password` | String | Hashed password | NOT NULL | +| `createdAt` | Timestamp | Creation date | NOT NULL | +| `updatedAt` | Timestamp | Last update date | NOT NULL | **Relations:** @@ -134,21 +136,24 @@ Base entity for authentication and authorization. User-owned monetary units used by accounts and transactions. -| Field | Type | Description | Constraints | -|------|-----|----------|-------------| -| `id` | UUID | Stable Commodity identity | PK, NOT NULL | -| `userId` | UUID | Commodity owner | FK -> `users.id`, NOT NULL, ON DELETE CASCADE | -| `code` | String | User-visible display/search code | NOT NULL, unique per user | -| `name` | String | Display name | NOT NULL | -| `symbol` | String | Optional display symbol | NULLABLE | -| `precision` | Integer | Minor-unit scale for integer amounts | NOT NULL, immutable after creation | -| `isTombstone` | Boolean | Technical soft delete flag | NOT NULL, default: false | -| `createdAt` | Timestamp | Creation date | NOT NULL | -| `updatedAt` | Timestamp | Last update date | NOT NULL | +| Field | Type | Description | Constraints | +| ------------- | --------- | ------------------------------------ | --------------------------------------------- | +| `id` | UUID | Stable Commodity identity | PK, NOT NULL | +| `userId` | UUID | Commodity owner | FK -> `users.id`, NOT NULL, ON DELETE CASCADE | +| `code` | String | User-visible display/search code | NOT NULL, unique per user | +| `name` | String | Display name | NOT NULL | +| `symbol` | String | Optional display symbol | NULLABLE | +| `precision` | Integer | Minor-unit scale for integer amounts | NOT NULL, immutable after creation | +| `isClosed` | Boolean | Reversible Commodity close state | NOT NULL, default: false | +| `isTombstone` | Boolean | Terminal tombstone delete flag | NOT NULL, default: false | +| `createdAt` | Timestamp | Creation date | NOT NULL | +| `updatedAt` | Timestamp | Last update date | NOT NULL | **Constraints:** - `UNIQUE(userId, code)` through `commodities_user_id_code_unique_idx`. +- `UNIQUE(userId, id)` through `commodities_user_id_id_unique_idx`; used by + same-user composite foreign keys. **Relations:** @@ -161,8 +166,15 @@ User-owned monetary units used by accounts and transactions. - Commodity identity is `id`, not `code`. - Predefined currencies such as `USD`, `EUR`, and `RUB` are ordinary per-user Commodity records when created. +- `isClosed` and `isTombstone` are separate lifecycle axes. Closed Commodities + are historical, reversible records; tombstoned Commodities are hidden from + normal reads and cannot be restored. +- `GET /commodities` filters use `status=open|closed|all` and always exclude + tombstoned Commodities. +- Closed Commodities cannot be selected for new account references until opened + again. - The MVP does not include global Commodity records, `ReferenceAsset`, market - rates, provider metadata, automatic conversion, or Commodity archiving. + rates, provider metadata or automatic conversion. --- @@ -170,25 +182,27 @@ User-owned monetary units used by accounts and transactions. User's financial accounts for tracking funds. -| Field | Type | Description | Constraints | -|------|-----|----------|-------------| -| `id` | UUID | Primary key | PK, NOT NULL | -| `commodityId` | UUID | Account Commodity | FK -> `commodities.id`, NOT NULL | -| `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 | -| `updatedAt` | Timestamp | Last update date | NOT NULL | +| Field | Type | Description | Constraints | +| ---------------------------- | --------- | ------------------------------------ | ----------------------------------------------------------------- | +| `id` | UUID | Primary key | PK, NOT NULL | +| `commodityId` | UUID | Account Commodity | Composite FK with `userId` -> `commodities(userId, id)`, NOT NULL | +| `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 | +| `updatedAt` | Timestamp | Last update date | NOT NULL | **Constraints:** - `UNIQUE(userId, name)` through `user_id_name_unique_idx`. +- `FOREIGN KEY(userId, commodityId)` references `commodities(userId, id)` to + prevent cross-user Commodity references. **Relations:** @@ -216,18 +230,18 @@ User's financial accounts for tracking funds. Top-level grouping of related financial events. -| Field | Type | Description | Constraints | -|------|-----|----------|-------------| -| `id` | UUID | Primary key | PK, NOT NULL | -| `commodityId` | UUID | Transaction Commodity that denominates operation `value` fields | FK -> `commodities.id`, NOT NULL | -| `description` | String | Transaction description | NOT NULL | -| `transactionDate` | Date | Transaction date | NOT NULL | -| `postingDate` | Date | Posting date | NOT NULL | -| `version` | Integer | Optimistic concurrency version | NOT NULL, default: 0 | -| `isTombstone` | Boolean | Soft delete flag | NOT NULL, default: false | -| `userId` | UUID | Transaction owner | FK -> `users.id`, NOT NULL, ON DELETE CASCADE | -| `createdAt` | Timestamp | Creation date | NOT NULL | -| `updatedAt` | Timestamp | Last update date | NOT NULL | +| Field | Type | Description | Constraints | +| ----------------- | --------- | --------------------------------------------------------------- | --------------------------------------------- | +| `id` | UUID | Primary key | PK, NOT NULL | +| `commodityId` | UUID | Transaction Commodity that denominates operation `value` fields | FK -> `commodities.id`, NOT NULL | +| `description` | String | Transaction description | NOT NULL | +| `transactionDate` | Date | Transaction date | NOT NULL | +| `postingDate` | Date | Posting date | NOT NULL | +| `version` | Integer | Optimistic concurrency version | NOT NULL, default: 0 | +| `isTombstone` | Boolean | Soft delete flag | NOT NULL, default: false | +| `userId` | UUID | Transaction owner | FK -> `users.id`, NOT NULL, ON DELETE CASCADE | +| `createdAt` | Timestamp | Creation date | NOT NULL | +| `updatedAt` | Timestamp | Last update date | NOT NULL | **Relations:** @@ -252,19 +266,19 @@ Top-level grouping of related financial events. Individual financial postings affecting accounts. -| Field | Type | Description | Constraints | -|------|-----|----------|-------------| -| `id` | UUID | Primary key | PK, NOT NULL | -| `transactionId` | UUID | Parent transaction | FK -> `transactions.id`, NOT NULL, ON DELETE CASCADE | -| `accountId` | UUID | Affected account | FK -> `accounts.id`, NOT NULL, ON DELETE RESTRICT | -| `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 | -| `updatedAt` | Timestamp | Last update date | NOT NULL | +| Field | Type | Description | Constraints | +| --------------- | --------- | ----------------------------------------------- | ---------------------------------------------------- | +| `id` | UUID | Primary key | PK, NOT NULL | +| `transactionId` | UUID | Parent transaction | FK -> `transactions.id`, NOT NULL, ON DELETE CASCADE | +| `accountId` | UUID | Affected account | FK -> `accounts.id`, NOT NULL, ON DELETE RESTRICT | +| `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 | +| `updatedAt` | Timestamp | Last update date | NOT NULL | **Relations:** @@ -292,11 +306,11 @@ Individual financial postings affecting accounts. User application settings. -| Field | Type | Description | Constraints | -|------|-----|----------|-------------| -| `userId` | UUID | User | FK -> `users.id`, NOT NULL | -| `createdAt` | Timestamp | Creation date | NOT NULL | -| `updatedAt` | Timestamp | Last update date | NOT NULL | +| Field | Type | Description | Constraints | +| ----------- | --------- | ---------------- | -------------------------- | +| `userId` | UUID | User | FK -> `users.id`, NOT NULL | +| `createdAt` | Timestamp | Creation date | NOT NULL | +| `updatedAt` | Timestamp | Last update date | NOT NULL | **Relations:** @@ -345,9 +359,10 @@ USERS (root entity) ### Data Integrity -1. **User ownership**: accounts and transactions may reference only active - Commodities owned by the same user. Write repositories enforce this check - immediately before persisting Commodity-backed references. +1. **User ownership**: user-owned records must not reference entities owned by + another user. Account-to-Commodity ownership is enforced with a composite + foreign key. Additional same-user composite foreign keys for transaction and + operation relations are tracked by LED-132. 2. **Required relations**: each operation must have a transaction and account. 3. **Soft deletes**: entities use `isTombstone` where the deletion must be retained in storage. @@ -379,4 +394,4 @@ All schemas are exported through `apps/backend/src/db/schema.ts`. --- -*Last updated: July 23, 2026* +_Last updated: July 23, 2026_ diff --git a/docs/DOMAIN.md b/docs/DOMAIN.md index b6a5c742..87f2152d 100644 --- a/docs/DOMAIN.md +++ b/docs/DOMAIN.md @@ -94,12 +94,18 @@ metadata owned by each user. See - Operations always store `amount` in the account's Commodity - Commodity `code`, `name`, and `symbol` are display metadata, not identity - Commodity `precision` defines integer minor-unit interpretation -- Commodity archiving is represented by technical `isTombstone` state -- `GET /commodities` returns active Commodities by default and supports - `status=active|archived|all` -- `GET /commodities/:id` returns active and archived Commodities owned by the +- Reversible Commodity close state is represented by `isClosed` +- Terminal Commodity deletion is represented by `isTombstone` +- `GET /commodities` returns open Commodities by default and supports + `status=open|closed|all`; all normal list filters exclude tombstoned + commodities +- `GET /commodities/:id` returns non-tombstoned Commodities owned by the authenticated user -- Archived Commodities remain readable but cannot be updated or archived again +- Closed Commodities remain readable and editable, but cannot be selected for + new account references until opened again +- `DELETE /commodities/:id` means terminal tombstone delete and is idempotent + for a Commodity that already belongs to the user and already has + `isTombstone = true` ## Entity API Conventions @@ -136,9 +142,10 @@ Soft-delete is a domain state transition, not an idempotent repository command. Calling `markAsDeleted()` on an already deleted entity must fail with `DELETED_ENTITY_OPERATION` and must not mutate `updatedAt` again. Repository APIs that persist this transition must use `softDelete(...)`. -Application/use case APIs may use business-facing `archive...` names, and HTTP -routes may still expose `DELETE /resource/:id` commands. Repository -`delete(...)` is reserved for physical row deletion. +Application/use case APIs may use business-facing lifecycle names such as +`close...` and `open...`, and HTTP routes may still expose +`DELETE /resource/:id` commands. Repository `delete(...)` is reserved for +physical row deletion. Snapshot types live next to the entity in `domain//types.ts`. They use primitive/domain-safe fields and must not be aliases for DB rows or response @@ -230,10 +237,10 @@ repository interfaces and transaction boundaries. Mappers own conversion between domain snapshots, read models, response DTOs and persistence shapes. Repositories own persistence access. -Commodity-backed account and transaction writes are a documented boundary -exception: repositories enforce Commodity existence, ownership and tombstone -state immediately before persistence. See -[ADR 0019](./architecture/adr/0019-repository-enforced-commodity-reference-validation.md). +Commodity-backed account and transaction writes split validation by concern: +application policies enforce business lifecycle rules, while repositories and +database constraints protect persistence integrity. See +[ADR 0021](./architecture/adr/0021-split-commodity-reference-business-policy-from-persistence-integrity.md). New endpoint operations should be implemented as application use cases. `apps/backend/src/application/services/*` is reserved for helper orchestration @@ -315,7 +322,7 @@ tasks. 4. Results are sorted by `transactionDate DESC` by default 5. Clients may sort by `transactionDate` or `postingDate` in ascending or descending order 6. `createdAt` and `id` are deterministic tie-breakers for pagination -7. Tombstone transactions and operations are always excluded; the list API does not support `includeArchived` +7. Tombstone transactions and operations are always excluded; the list API does not support `includeTombstone` ## Examples diff --git a/docs/ERROR_ARCHITECTURE.md b/docs/ERROR_ARCHITECTURE.md index ac5c952a..2d8c2daf 100644 --- a/docs/ERROR_ARCHITECTURE.md +++ b/docs/ERROR_ARCHITECTURE.md @@ -55,6 +55,7 @@ Examples include: - `InvalidPasswordError` - `UserAlreadyExistsError` - `VersionConflictError` +- `CommodityClosedError` Authentication failures intentionally map to the same public `AUTHENTICATION_FAILED` code with empty context. @@ -96,7 +97,11 @@ not import them. Public imports from outside the HTTP module should go through: ```typescript -import { HttpApiError, UnauthorizedError, errorHandler } from 'src/presentation/http'; +import { + HttpApiError, + UnauthorizedError, + errorHandler, +} from "src/presentation/http"; ``` ## HTTP Response Contract @@ -139,9 +144,9 @@ Use a coded error when the failure is expected and should be represented to API clients. ```typescript -export class VersionConflictError extends CodedApplicationError<'VERSION_CONFLICT'> { - constructor(context: ErrorContextByCode['VERSION_CONFLICT']) { - super('Version conflict', apiErrorCodes.versionConflict, context); +export class VersionConflictError extends CodedApplicationError<"VERSION_CONFLICT"> { + constructor(context: ErrorContextByCode["VERSION_CONFLICT"]) { + super("Version conflict", apiErrorCodes.versionConflict, context); } } ``` @@ -155,8 +160,11 @@ Use a coded infrastructure error only when the public response can safely expose allowlisted context. ```typescript -export class RepositoryNotFoundError extends CodedInfrastructureError<'ENTITY_NOT_FOUND'> { - constructor(message: string, context: ErrorContextByCode['ENTITY_NOT_FOUND']) { +export class RepositoryNotFoundError extends CodedInfrastructureError<"ENTITY_NOT_FOUND"> { + constructor( + message: string, + context: ErrorContextByCode["ENTITY_NOT_FOUND"], + ) { super(message, apiErrorCodes.entityNotFound, context); } } @@ -171,7 +179,7 @@ Use `HttpApiError` only inside the HTTP adapter. ```typescript export class UnauthorizedError extends HttpApiError { - constructor(message = 'Unauthorized') { + constructor(message = "Unauthorized") { super(message, 401); } } @@ -185,7 +193,7 @@ HTTP-specific errors live in `presentation/http/errors` and are exported through Correct: ```typescript -import { UserNotFoundError } from 'src/application/application.errors'; +import { UserNotFoundError } from "src/application/application.errors"; throw new UserNotFoundError(); ``` @@ -193,17 +201,17 @@ throw new UserNotFoundError(); Correct: ```typescript -import { RepositoryNotFoundError } from 'src/infrastructure/errors'; +import { RepositoryNotFoundError } from "src/infrastructure/errors"; -throw new RepositoryNotFoundError('Account not found', { - entityType: 'account', +throw new RepositoryNotFoundError("Account not found", { + entityType: "account", }); ``` Wrong: ```typescript -import { UnauthorizedError } from 'src/presentation/http'; +import { UnauthorizedError } from "src/presentation/http"; throw new UnauthorizedError(); ``` 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 cd2e855c..a1002e6c 100644 --- a/docs/architecture/adr/0006-commodity-registry-before-currency-validation.md +++ b/docs/architecture/adr/0006-commodity-registry-before-currency-validation.md @@ -42,8 +42,8 @@ The Commodity model is: - `code` is unique only within one user's Commodity registry; - `precision` defines integer minor-unit interpretation and is immutable after Commodity creation in the MVP; -- Commodity supports user-facing archiving through technical `isTombstone` - state. +- Commodity supports reversible closing through `isClosed` and terminal + tombstone deletion through `isTombstone`. Domain references should move from currency strings to Commodity ids: @@ -62,11 +62,11 @@ removed during the Commodity migration rather than implemented on top of the old model. The public HTTP API exposes Commodity terminology through `/commodities`. -`GET /commodities` defaults to active Commodities and accepts -`status=active|archived|all`. `GET /commodities/:id` returns both active and -archived Commodities owned by the authenticated user. Archived Commodities remain -readable, but `PATCH /commodities/:id` and `DELETE /commodities/:id` operate only -on active Commodities. +`GET /commodities` defaults to open Commodities and accepts +`status=open|closed|all`. `GET /commodities/:id` returns non-tombstoned +Commodities owned by the authenticated user. Closed Commodities remain readable +and editable, but cannot be selected for new account references until opened +again. The MVP explicitly excludes global reference assets, market data providers, exchange-rate history, automatic conversion, and network/contract metadata. @@ -110,14 +110,14 @@ Those can be introduced by later ADRs if needed. allowed after creation. - Transaction creation must require `commodityId`, the transaction Commodity. - The validation boundary for Commodity-backed account and transaction writes is - defined separately in ADR 0019. + defined separately in ADR 0021. - Existing API and DTO surfaces may need compatibility fields while data and clients migrate from currency strings to Commodity ids. - Amount validation should use Commodity precision rather than assuming all amounts have two decimal places. -- Commodity API clients can request active, archived, or all Commodities - explicitly. Archived Commodity records stay visible by id for audit and - reference continuity, but cannot be edited or archived again. +- Commodity API clients can request open, closed, or all non-tombstoned + Commodities explicitly. Closed Commodity records stay visible by id for audit + and reference continuity, but cannot be selected for new account references. ## Related @@ -130,10 +130,11 @@ Those can be introduced by later ADRs if needed. - [LED-68: Move Account to immutable Commodity reference](https://gorushkin.atlassian.net/browse/LED-68) - [LED-110: Add Commodity use cases and HTTP API](https://gorushkin.atlassian.net/browse/LED-110) - [LED-111: Move transaction valuation to Commodity reference](https://gorushkin.atlassian.net/browse/LED-111) -- [LED-69: Validate Commodity existence, ownership, and archive state](https://gorushkin.atlassian.net/browse/LED-69) +- [LED-69: Validate Commodity existence, ownership, and lifecycle state](https://gorushkin.atlassian.net/browse/LED-69) - [LED-105: Remove `/currencies` endpoint stub](https://gorushkin.atlassian.net/browse/LED-105) - [LED-70: Replace LED-47 validation with Commodity validation](https://gorushkin.atlassian.net/browse/LED-70) - [ADR 0019: Repository-Enforced Commodity Reference Validation](./0019-repository-enforced-commodity-reference-validation.md) +- [ADR 0021: Split Commodity Reference Business Policy From Persistence Integrity](./0021-split-commodity-reference-business-policy-from-persistence-integrity.md) - [Multicurrency Design](../../MULTICURRENCY_DESIGN.md) - [Domain Model](../../DOMAIN.md) - [Database Schema](../../DATABASE_SCHEMA.md) diff --git a/docs/architecture/adr/0016-backend-request-flow.md b/docs/architecture/adr/0016-backend-request-flow.md index 933a262c..bc426f1b 100644 --- a/docs/architecture/adr/0016-backend-request-flow.md +++ b/docs/architecture/adr/0016-backend-request-flow.md @@ -72,9 +72,10 @@ Responsibilities: - Owns persistence access and DB-specific query/update behavior. - Implements application repository interfaces where a boundary exists. - Does not create public API response contracts directly. -- May enforce persistence-reference policies that must be checked immediately - before writes, such as Commodity existence, ownership and tombstone state for - Commodity-backed account and transaction writes. See ADR 0019. +- May enforce persistence integrity that must hold for every write path, such + as authenticated `userId` consistency and database error mapping. Business + lifecycle policies for referenced entities belong in application/domain + orchestration. See ADR 0021. 6. Application services 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 311a6fc2..7e4ad9ae 100644 --- a/docs/architecture/adr/0019-repository-enforced-commodity-reference-validation.md +++ b/docs/architecture/adr/0019-repository-enforced-commodity-reference-validation.md @@ -1,6 +1,6 @@ # ADR 0019: Repository-Enforced Commodity Reference Validation -- Status: Accepted +- Status: Superseded - Date: 2026-07-23 - Jira: https://gorushkin.atlassian.net/browse/LED-69 - PR: TBD @@ -88,5 +88,6 @@ Commodity id has the expected UUID shape. - [ADR 0006: Commodity Registry Before Currency Validation](./0006-commodity-registry-before-currency-validation.md) - [ADR 0016: Backend Request Flow](./0016-backend-request-flow.md) +- Superseded by [ADR 0021: Split Commodity Reference Business Policy From Persistence Integrity](./0021-split-commodity-reference-business-policy-from-persistence-integrity.md) - [LED-64: Commodity Registry Epic](https://gorushkin.atlassian.net/browse/LED-64) -- [LED-69: Validate Commodity existence, ownership, and archive state](https://gorushkin.atlassian.net/browse/LED-69) +- [LED-69: Validate Commodity existence, ownership, and lifecycle state](https://gorushkin.atlassian.net/browse/LED-69) 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 3905d14a..c7c140cd 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 @@ -10,11 +10,12 @@ `isTombstone` currently risks carrying two different meanings: - irreversible deletion for `Transaction` and `Operation`; -- reversible archive or close behavior for `Account` and `Commodity`. +- reversible close behavior for `Account` and `Commodity`. This makes use case, repository and HTTP handler naming harder to reason about. -For example, a `DELETE` route can end up invoking an archive or soft-delete -operation even though the domain operation is potentially reversible. +For example, a `DELETE` route can end up invoking a reversible lifecycle or +soft-delete operation even though the domain operation is potentially +reversible. The domain needs one stable meaning for tombstone state and separate business states for reversible lifecycle changes. @@ -38,12 +39,12 @@ restore method. Entity states are separated as follows: -| Entity | Reversible state | Irreversible state | -| --- | --- | --- | -| `Transaction` | --- | `isTombstone` | -| `Operation` | --- | `isTombstone` | -| `Account` | `isClosed` | `isTombstone` | -| `Commodity` | `isArchived` | `isTombstone` | +| Entity | Reversible state | Irreversible state | +| ------------- | ---------------- | ------------------ | +| `Transaction` | --- | `isTombstone` | +| `Operation` | --- | `isTombstone` | +| `Account` | `isClosed` | `isTombstone` | +| `Commodity` | `isClosed` | `isTombstone` | ### Account @@ -87,11 +88,11 @@ Account list queries use account lifecycle language: Every normal account list filter excludes tombstoned accounts. -| Filter | Predicate | -| --- | --- | -| `open` | `isClosed = false` and `isTombstone = false` | -| `closed` | `isClosed = true` and `isTombstone = false` | -| `all` | `isTombstone = false` | +| Filter | Predicate | +| -------- | -------------------------------------------- | +| `open` | `isClosed = false` and `isTombstone = false` | +| `closed` | `isClosed = true` and `isTombstone = false` | +| `all` | `isTombstone = false` | Account close and open are exposed as explicit HTTP actions: @@ -117,26 +118,25 @@ It must not become the general account read path. ### Commodity -An archived commodity: +A closed commodity: - remains available for old operations; - participates in historical calculations; - is not offered for new accounts or active selection; -- can be returned from archive. +- can be opened again. The domain API should express that lifecycle explicitly: ```ts -commodity.archive(); -commodity.unarchive(); +commodity.close(); +commodity.open(); ``` -The `archive` term matches commodity lifecycle language: a commodity is removed -from active choices without being deleted from historical records. A commodity -is not naturally "closed", and an account is not naturally "archived"; the -domain API should not force identical method names for unlike lifecycles. +Commodity uses the same reversible lifecycle vocabulary as accounts. `close` +means the commodity remains a historical accounting unit, but it cannot be used +for new active references until it is opened again. -`archive()` and `unarchive()` are idempotent. Repeating either command keeps the +`close()` and `open()` are idempotent. Repeating either command keeps the desired final state and must not: - throw a domain error; @@ -145,23 +145,23 @@ desired final state and must not: - create another journal record; - publish another domain event. -Archived commodity remains editable because archive is not an immutable state. -It is a reversible exclusion from active choice. Ordinary edits use +Closed commodity remains editable because close is not an immutable state. It is +a reversible exclusion from active choice. Ordinary edits use `PATCH /commodities/:id` and must not accept lifecycle fields such as -`isArchived`. +`isClosed`. Commodity lifecycle commands are exposed as explicit domain actions: ```http -POST /commodities/:id/archive -POST /commodities/:id/unarchive +POST /commodities/:id/close +POST /commodities/:id/open ``` The expected HTTP responses are: ```http -POST /commodities/:id/archive -> 204 No Content -POST /commodities/:id/unarchive -> 204 No Content +POST /commodities/:id/close -> 200 OK +POST /commodities/:id/open -> 200 OK PATCH /commodities/:id -> 200 OK DELETE /commodities/:id -> 204 No Content ``` @@ -171,7 +171,7 @@ already tombstoned commodity keeps the tombstone state and returns `204 No Content`. The domain `commodity.delete()` operation may be a no-op in that state, without changing version, `updatedAt`, journal records or domain events. -Archived commodity means the commodity cannot be used to create new active +Closed commodity means the commodity cannot be used to create new active links. It is forbidden to: - create a new account with this commodity; @@ -181,18 +181,18 @@ links. It is forbidden to: Existing accounts that already reference the commodity remain valid and keep their history. -Commodity list filters use `isArchived` and `isTombstone` as separate state +Commodity list filters use `isClosed` and `isTombstone` as separate state axes: -| Filter | Predicate | -| --- | --- | -| `active` | `isArchived = false` and `isTombstone = false` | -| `archived` | `isArchived = true` and `isTombstone = false` | -| `all` | `isTombstone = false` | +| Filter | Predicate | +| -------- | -------------------------------------------- | +| `open` | `isClosed = false` and `isTombstone = false` | +| `closed` | `isClosed = true` and `isTombstone = false` | +| `all` | `isTombstone = false` | Tombstoned commodities are always hidden from normal commodity reads. -`unarchive()` does not depend on the lifecycle state of related accounts. +`open()` does not depend on the lifecycle state of related accounts. ### Deletion @@ -206,7 +206,7 @@ commodity.delete(); ``` `DELETE` means only terminal tombstone deletion. It must not be used for -reversible account close or commodity archive transitions. +reversible account or commodity close transitions. An account can be terminally deleted only when all related operations are either: @@ -226,15 +226,16 @@ it. Deletion is allowed when: The check is about active references, not historical rows. With this split, `isTombstone` remains a technical marker for irreversible -deletion, while `isClosed` and `isArchived` describe reversible business states. +deletion, while `isClosed` describes reversible business states for accounts +and commodities. The intended public domain methods are: -| Entity | Reversible state | Methods | -| --- | --- | --- | -| `Account` | `isClosed` | `close()` / `open()` | -| `Commodity` | `isArchived` | `archive()` / `unarchive()` | -| Any entity | `isTombstone` | `delete()` | +| Entity | Reversible state | Methods | +| ----------- | ---------------- | -------------------- | +| `Account` | `isClosed` | `close()` / `open()` | +| `Commodity` | `isClosed` | `close()` / `open()` | +| Any entity | `isTombstone` | `delete()` | ## Alternatives Considered @@ -257,37 +258,37 @@ The intended public domain methods are: deletion, and lets account and commodity lifecycle rules evolve separately. - Cons: requires schema, domain, repository, use case, API and test updates. -4. Use symmetric lifecycle methods for `Account` and `Commodity` +4. Use separate archive terminology for `Commodity` -- Pros: makes method names look uniform. -- Cons: hides the actual domain language. `account.archive()` and - `commodity.close()` are both less precise than `account.close()` and - `commodity.archive()`. +- Pros: can describe removal from active selection more literally. +- Cons: creates an unnecessary vocabulary split for two reversible states that + behave the same way in API and persistence. Symmetric `close()` / `open()` + operations are easier to use consistently across `Account` and `Commodity`. ## Consequences - `Transaction` and `Operation` tombstone state is irreversible. - `Account` needs `isClosed`, plus `close()` and `open()` behavior. -- `Commodity` needs `isArchived`, plus `archive()` and `unarchive()` behavior. +- `Commodity` needs `isClosed`, plus `close()` and `open()` behavior. - `DELETE` routes should mean terminal deletion. Account deletion must be rejected while active operations still reference the account. Commodity deletion must be rejected while any active domain entity still references the commodity. -- Query filters for account and commodity reads should distinguish open, - closed or archived, and all non-tombstoned records. Account filters use - `open|closed|all`; commodity filters use `active|archived|all`. Tombstoned - records should remain hidden from normal reads. +- Query filters for account and commodity reads should distinguish open, closed + and all non-tombstoned records. Account and commodity filters use + `open|closed|all`. Tombstoned records should remain hidden from normal reads. - Tests must cover tombstone terminality and business-state invariants: tombstoned entities cannot be restored or modified; closed accounts cannot - receive new operations and can only update descriptive account fields; - archived commodities cannot be selected for new active links. + receive new operations and can only update descriptive account fields; closed + commodities cannot be selected for new active links. ## Related - [LED-123: Define entity lifecycle states](https://gorushkin.atlassian.net/browse/LED-123) - [LED-122: Define terminal tombstone and reversible archive/close states](https://gorushkin.atlassian.net/browse/LED-122) - [LED-124: Implement Account close open and terminal delete](https://gorushkin.atlassian.net/browse/LED-124) -- [LED-125: Implement Commodity archive unarchive and terminal delete](https://gorushkin.atlassian.net/browse/LED-125) +- [LED-125: Implement Commodity close open and terminal delete](https://gorushkin.atlassian.net/browse/LED-125) - [ADR 0011: Domain Entity API Conventions](./0011-domain-entity-api-conventions.md) - [ADR 0015: Domain Restoration Factory Naming](./0015-domain-restoration-factory-naming.md) - [ADR 0019: Repository-Enforced Commodity Reference Validation](./0019-repository-enforced-commodity-reference-validation.md) +- [ADR 0021: Split Commodity Reference Business Policy From Persistence Integrity](./0021-split-commodity-reference-business-policy-from-persistence-integrity.md) 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 new file mode 100644 index 00000000..0496f688 --- /dev/null +++ b/docs/architecture/adr/0021-split-commodity-reference-business-policy-from-persistence-integrity.md @@ -0,0 +1,90 @@ +# ADR 0021: Split Commodity Reference Business Policy From Persistence Integrity + +- Status: Accepted +- Date: 2026-08-05 +- Jira: https://gorushkin.atlassian.net/browse/LED-125 +- PR: TBD + +## Context + +ADR 0019 put Commodity reference validation in repositories. That kept account +and transaction write paths symmetric, but it mixed two different concerns: + +- business policy, such as whether a closed Commodity can be used for a new + account; +- persistence integrity, such as whether an account row can reference a + Commodity owned by another user. + +Commodity lifecycle is now explicit. `isTombstone` means terminal deletion, and +`isClosed` means a reversible business state. A closed Commodity is still valid +history, but it must not be selected for new active links. + +## Decision + +Commodity reference validation is split by concern. + +Application use cases and application services enforce business-facing policy. +For account creation, `CreateAccountUseCase` loads the referenced Commodity +inside the transaction and delegates the check to `CommodityReferencePolicy`. +The policy restores the Commodity domain entity and rejects closed Commodities +with `CommodityClosedError`, mapped to `409 Conflict`. + +Repositories must keep persistence invariants close to the write: + +- the authenticated `userId` argument must match the snapshot `userId`; +- database constraints should prevent cross-user references where the child row + stores both `userId` and a reference id; +- database foreign-key failures are mapped to infrastructure errors. + +Same-user reference integrity should be represented with composite foreign keys, +for example: + +```text +accounts(user_id, commodity_id) + references commodities(user_id, id) +``` + +Lifecycle state must not be encoded as a database foreign key or check +constraint. `isClosed` and `isTombstone` remain domain/application policy +inputs because they have user-facing error semantics. + +## Alternatives Considered + +1. Keep all Commodity reference validation in repositories + +- Pros: one persistence boundary handles account and transaction writes. +- Cons: repository code must know business lifecycle rules and cannot return + specific application errors such as `CommodityClosedError`. + +2. Move all validation to use cases only + +- Pros: business rules are visible where application decisions are made. +- Cons: direct repository writes and future write paths can bypass same-user + reference integrity unless the database also enforces it. + +3. Split business policy from persistence integrity + +- Pros: keeps domain/application errors intentional, while the database protects + storage invariants that are true for every write path. +- Cons: requires schema constraints and tests in addition to use case policy + tests. + +## Consequences + +- Closed Commodity references for new accounts are rejected by application + policy with `CLOSED_COMMODITY_REFERENCE`. +- Missing, foreign or tombstoned Commodities are still hidden from normal lookup + paths and surface as not found where appropriate. +- Repository tests should focus on persistence invariants and DB error mapping. +- Use case and integration tests should cover user-visible business error + contracts. +- ADR 0019 is superseded by this decision. +- Additional same-user composite foreign keys for transactions and operations + are tracked separately by LED-132. + +## Related + +- [ADR 0019: Repository-Enforced Commodity Reference Validation](./0019-repository-enforced-commodity-reference-validation.md) +- [ADR 0020: Terminal Tombstone and Reversible Entity States](./0020-terminal-tombstone-and-reversible-entity-states.md) +- [LED-125: Implement Commodity close open and terminal delete](https://gorushkin.atlassian.net/browse/LED-125) +- [LED-132: Add user-owned composite foreign keys for ledger relations](https://gorushkin.atlassian.net/browse/LED-132) diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index 5b27cd10..27fac74f 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -49,5 +49,6 @@ - [ADR 0016: Backend request flow](./0016-backend-request-flow.md) - [ADR 0017: Backend HTTP adapter boundary](./0017-backend-http-adapter-boundary.md) - [ADR 0018: Backend HTTP error boundary](./0018-backend-http-error-boundary.md) -- [ADR 0019: Repository-Enforced Commodity Reference Validation](./0019-repository-enforced-commodity-reference-validation.md) +- [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) From 2781caa16b5dc821328a90480726f7f58074fc9f Mon Sep 17 00:00:00 2001 From: gorushkin Date: Wed, 5 Aug 2026 20:37:41 +0300 Subject: [PATCH 26/31] LED-125 feat(commodities): implement active references check for commodity deletion --- .../application/application.errors.test.ts | 10 +++ .../src/application/application.errors.ts | 15 ++++ .../interfaces/AccountRepository.interface.ts | 1 + .../TransactionRepository.interface.ts | 1 + .../commodity-reference.policy.test.ts | 66 +++++++++++++++++- .../commodity-reference.policy.ts | 38 ++++++++++- .../__tests__/deleteCommodity.test.ts | 43 +++++++++++- .../usecases/commodities/deleteCommodity.ts | 7 ++ apps/backend/src/di/container.ts | 3 + .../db/accounts/account.repository.test.ts | 38 +++++++++++ .../db/accounts/account.repository.ts | 31 ++++++++- .../transaction.repository.test.ts | 56 ++++++++++++++- .../db/transaction/transaction.repository.ts | 31 ++++++++- .../commodities/commodity.integration.test.ts | 68 ++++++++++++++++++- .../presentation/http/error-handler.test.ts | 9 +++ .../src/presentation/http/error-handler.ts | 1 + packages/shared/src/types/error-contract.ts | 4 ++ 17 files changed, 413 insertions(+), 9 deletions(-) diff --git a/apps/backend/src/application/application.errors.test.ts b/apps/backend/src/application/application.errors.test.ts index 215721bd..7e94043a 100644 --- a/apps/backend/src/application/application.errors.test.ts +++ b/apps/backend/src/application/application.errors.test.ts @@ -3,6 +3,7 @@ import { AccountHasActiveOperationsError, AuthenticationFailedError, CommodityClosedError, + CommodityHasActiveReferencesError, InvalidPasswordError, ApplicationError, EntityNotFoundError, @@ -63,4 +64,13 @@ describe('coded application errors', () => { context: { commodityId, operation: 'create_account' }, }); }); + + it('uses a stable public code for a commodity with active references', () => { + const commodityId = '550e8400-e29b-41d4-a716-446655440002' as UUID; + + expect(new CommodityHasActiveReferencesError(commodityId)).toMatchObject({ + code: apiErrorCodes.commodityHasActiveReferences, + context: { commodityId }, + }); + }); }); diff --git a/apps/backend/src/application/application.errors.ts b/apps/backend/src/application/application.errors.ts index b5feb03d..8063d801 100644 --- a/apps/backend/src/application/application.errors.ts +++ b/apps/backend/src/application/application.errors.ts @@ -84,6 +84,21 @@ export class CommodityClosedError extends CodedApplicationError<'CLOSED_COMMODIT } } +/** + * Thrown when a commodity cannot be deleted because it is still referenced. + */ +export class CommodityHasActiveReferencesError extends CodedApplicationError<'COMMODITY_HAS_ACTIVE_REFERENCES'> { + constructor( + commodityId: ErrorContextByCode['COMMODITY_HAS_ACTIVE_REFERENCES']['commodityId'], + ) { + super( + `Cannot delete commodity ${commodityId} because it has active references.`, + apiErrorCodes.commodityHasActiveReferences, + { commodityId }, + ); + } +} + /** * A deliberately non-specific authentication failure. * diff --git a/apps/backend/src/application/interfaces/AccountRepository.interface.ts b/apps/backend/src/application/interfaces/AccountRepository.interface.ts index 433e259f..49a66ce2 100644 --- a/apps/backend/src/application/interfaces/AccountRepository.interface.ts +++ b/apps/backend/src/application/interfaces/AccountRepository.interface.ts @@ -44,5 +44,6 @@ export type AccountRepositoryInterface = { userId: UUID, accountId: UUID, ): Promise; + existsActiveByCommodityId(userId: UUID, commodityId: UUID): Promise; getByIds(userId: UUID, accountIds: UUID[]): Promise; }; diff --git a/apps/backend/src/application/interfaces/TransactionRepository.interface.ts b/apps/backend/src/application/interfaces/TransactionRepository.interface.ts index 9409db8a..3c8ae8c7 100644 --- a/apps/backend/src/application/interfaces/TransactionRepository.interface.ts +++ b/apps/backend/src/application/interfaces/TransactionRepository.interface.ts @@ -18,4 +18,5 @@ export type TransactionRepositoryInterface = { create(userId: UUID, transaction: Transaction): Promise; getById(userId: UUID, transactionId: UUID): Promise; softDelete(userId: UUID, transaction: Transaction): Promise; + existsActiveByCommodityId(userId: UUID, commodityId: UUID): Promise; }; diff --git a/apps/backend/src/application/services/CommodityReferencePolicy/__tests__/commodity-reference.policy.test.ts b/apps/backend/src/application/services/CommodityReferencePolicy/__tests__/commodity-reference.policy.test.ts index 891a30ce..0fb79caf 100644 --- a/apps/backend/src/application/services/CommodityReferencePolicy/__tests__/commodity-reference.policy.test.ts +++ b/apps/backend/src/application/services/CommodityReferencePolicy/__tests__/commodity-reference.policy.test.ts @@ -1,6 +1,13 @@ import { apiErrorCodes } from '@ledgerly/shared/types'; -import { CommodityClosedError } from 'src/application/application.errors'; -import type { CommodityRepositoryInterface } from 'src/application/interfaces'; +import { + CommodityClosedError, + CommodityHasActiveReferencesError, +} from 'src/application/application.errors'; +import type { + AccountRepositoryInterface, + CommodityRepositoryInterface, + TransactionRepositoryInterface, +} from 'src/application/interfaces'; import { CommodityReferencePolicy } from 'src/application/services/CommodityReferencePolicy'; import { createUser } from 'src/db/createTestUser'; import { Commodity } from 'src/domain/commodities'; @@ -16,9 +23,17 @@ describe('CommodityReferencePolicy', async () => { const commodityRepository = { getById: vi.fn(), }; + const accountRepository = { + existsActiveByCommodityId: vi.fn(), + }; + const transactionRepository = { + existsActiveByCommodityId: vi.fn(), + }; const policy = new CommodityReferencePolicy( commodityRepository as unknown as CommodityRepositoryInterface, + accountRepository as unknown as AccountRepositoryInterface, + transactionRepository as unknown as TransactionRepositoryInterface, ); const createCommodity = () => @@ -31,6 +46,8 @@ describe('CommodityReferencePolicy', async () => { beforeEach(() => { vi.clearAllMocks(); + accountRepository.existsActiveByCommodityId.mockResolvedValue(false); + transactionRepository.existsActiveByCommodityId.mockResolvedValue(false); }); it('allows open commodities for new account references', async () => { @@ -102,4 +119,49 @@ describe('CommodityReferencePolicy', async () => { policy.assertUsableForNewAccount(userId, commodityId), ).rejects.toBe(repositoryError); }); + + it('allows deleting commodities without active references', async () => { + await expect( + policy.assertNoActiveReferences(userId, commodityId), + ).resolves.toBeUndefined(); + + expect(accountRepository.existsActiveByCommodityId).toHaveBeenCalledWith( + userId, + commodityId, + ); + expect( + transactionRepository.existsActiveByCommodityId, + ).toHaveBeenCalledWith(userId, commodityId); + }); + + it('rejects deleting commodities with active account references', async () => { + accountRepository.existsActiveByCommodityId.mockResolvedValueOnce(true); + + const assertion = policy.assertNoActiveReferences(userId, commodityId); + + await expect(assertion).rejects.toThrowError( + CommodityHasActiveReferencesError, + ); + await expect(assertion).rejects.toMatchObject({ + code: apiErrorCodes.commodityHasActiveReferences, + context: { commodityId }, + }); + expect( + transactionRepository.existsActiveByCommodityId, + ).not.toHaveBeenCalled(); + }); + + it('rejects deleting commodities with active transaction references', async () => { + transactionRepository.existsActiveByCommodityId.mockResolvedValueOnce(true); + + const assertion = policy.assertNoActiveReferences(userId, commodityId); + + await expect(assertion).rejects.toThrowError( + CommodityHasActiveReferencesError, + ); + await expect(assertion).rejects.toMatchObject({ + code: apiErrorCodes.commodityHasActiveReferences, + context: { commodityId }, + }); + }); }); diff --git a/apps/backend/src/application/services/CommodityReferencePolicy/commodity-reference.policy.ts b/apps/backend/src/application/services/CommodityReferencePolicy/commodity-reference.policy.ts index 0626e2af..8966e84e 100644 --- a/apps/backend/src/application/services/CommodityReferencePolicy/commodity-reference.policy.ts +++ b/apps/backend/src/application/services/CommodityReferencePolicy/commodity-reference.policy.ts @@ -1,12 +1,21 @@ import { UUID } from '@ledgerly/shared/types'; -import { CommodityClosedError } from 'src/application/application.errors'; -import type { CommodityRepositoryInterface } from 'src/application/interfaces'; +import { + CommodityClosedError, + CommodityHasActiveReferencesError, +} from 'src/application/application.errors'; +import type { + AccountRepositoryInterface, + CommodityRepositoryInterface, + TransactionRepositoryInterface, +} from 'src/application/interfaces'; import { Commodity } from 'src/domain/commodities'; import { DeletedEntityOperationError } from 'src/domain/domain.errors'; export class CommodityReferencePolicy { constructor( private readonly commodityRepository: CommodityRepositoryInterface, + private readonly accountRepository: AccountRepositoryInterface, + private readonly transactionRepository: TransactionRepositoryInterface, ) {} async assertUsableForNewAccount( @@ -28,4 +37,29 @@ export class CommodityReferencePolicy { throw new CommodityClosedError(commodityId, 'create_account'); } } + + async assertNoActiveReferences( + userId: UUID, + commodityId: UUID, + ): Promise { + const hasActiveAccountReferences = + await this.accountRepository.existsActiveByCommodityId( + userId, + commodityId, + ); + + if (hasActiveAccountReferences) { + throw new CommodityHasActiveReferencesError(commodityId); + } + + const hasActiveTransactionReferences = + await this.transactionRepository.existsActiveByCommodityId( + userId, + commodityId, + ); + + if (hasActiveTransactionReferences) { + throw new CommodityHasActiveReferencesError(commodityId); + } + } } diff --git a/apps/backend/src/application/usecases/commodities/__tests__/deleteCommodity.test.ts b/apps/backend/src/application/usecases/commodities/__tests__/deleteCommodity.test.ts index ed331fa1..b4fe177d 100644 --- a/apps/backend/src/application/usecases/commodities/__tests__/deleteCommodity.test.ts +++ b/apps/backend/src/application/usecases/commodities/__tests__/deleteCommodity.test.ts @@ -3,12 +3,14 @@ import { TransactionManagerInterface, } from 'src/application'; import { + CommodityHasActiveReferencesError, EntityNotFoundError, UnauthorizedAccessError, } from 'src/application/application.errors'; +import { CommodityReferencePolicy } from 'src/application/services'; import { ensureOwnedSnapshot } from 'src/application/shared/ensureOwnedSnapshot'; import { createCommodity } from 'src/db/createTestUser'; -import { Commodity } from 'src/domain/commodities/commodity.entity'; +import { Commodity } from 'src/domain/commodities'; import { Id, Timestamp } from 'src/domain/domain-core/'; import { User } from 'src/domain/users/user.entity'; import { createUser } from 'src/testing/helpers'; @@ -27,12 +29,16 @@ describe('DeleteCommodityUseCase', () => { const mockTransactionManager = { run: vi.fn(), }; + const commodityReferencePolicy = { + assertNoActiveReferences: vi.fn(), + }; const runTransaction: TransactionManagerInterface['run'] = async (callback) => callback(); const deleteCommodityUseCase = new DeleteCommodityUseCase( commodityRepository as unknown as CommodityRepositoryInterface, ensureOwnedSnapshot, + commodityReferencePolicy as unknown as CommodityReferencePolicy, mockTransactionManager as unknown as TransactionManagerInterface, ); @@ -43,8 +49,12 @@ describe('DeleteCommodityUseCase', () => { beforeEach(() => { vi.useRealTimers(); mockTransactionManager.run.mockImplementation(runTransaction); + commodityReferencePolicy.assertNoActiveReferences.mockResolvedValue( + undefined, + ); commodityRepository.delete.mockClear(); commodityRepository.getByIdForLifecycle.mockClear(); + commodityReferencePolicy.assertNoActiveReferences.mockClear(); mockTransactionManager.run.mockClear(); }); @@ -93,6 +103,9 @@ describe('DeleteCommodityUseCase', () => { commodityId, { updatedAt }, ); + expect( + commodityReferencePolicy.assertNoActiveReferences, + ).toHaveBeenCalledWith(userid, commodityId); expect(mockTransactionManager.run).toHaveBeenCalledTimes(1); expect(result).toBeUndefined(); @@ -119,11 +132,39 @@ describe('DeleteCommodityUseCase', () => { user.getId().valueOf(), commodityId, ); + expect( + commodityReferencePolicy.assertNoActiveReferences, + ).toHaveBeenCalledWith(user.getId().valueOf(), commodityId); expect(commodityRepository.delete).not.toHaveBeenCalled(); expect(mockTransactionManager.run).toHaveBeenCalledTimes(1); expect(result).toBeUndefined(); }); + it('should throw when commodity has active references', async () => { + const commodity = createCommodity(user, { + code: 'TEST', + name: 'Test Commodity', + precision: 2, + symbol: 'T', + }); + const commodityId = commodity.getId().valueOf(); + + const error = new CommodityHasActiveReferencesError(commodityId); + + commodityRepository.getByIdForLifecycle.mockResolvedValue( + commodity.toSnapshot(), + ); + commodityReferencePolicy.assertNoActiveReferences.mockRejectedValue( + error, + ); + + await expect( + deleteCommodityUseCase.execute(user, commodityId), + ).rejects.toThrowError(error); + + expect(commodityRepository.delete).not.toHaveBeenCalled(); + }); + 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/deleteCommodity.ts b/apps/backend/src/application/usecases/commodities/deleteCommodity.ts index 8ed26a77..a88b271d 100644 --- a/apps/backend/src/application/usecases/commodities/deleteCommodity.ts +++ b/apps/backend/src/application/usecases/commodities/deleteCommodity.ts @@ -3,6 +3,7 @@ import { CommodityRepositoryInterface, TransactionManagerInterface, } from 'src/application/interfaces'; +import { CommodityReferencePolicy } from 'src/application/services'; import { EnsureOwnedSnapshotFn } from 'src/application/shared/ensureOwnedSnapshot'; import { Commodity, CommoditySnapshot } from 'src/domain/commodities'; import { User } from 'src/domain/users/user.entity'; @@ -11,6 +12,7 @@ export class DeleteCommodityUseCase { constructor( protected readonly commodityRepository: CommodityRepositoryInterface, protected readonly ensureOwnedSnapshot: EnsureOwnedSnapshotFn, + private readonly commodityReferencePolicy: CommodityReferencePolicy, private readonly transactionManager: TransactionManagerInterface, ) {} @@ -28,6 +30,11 @@ export class DeleteCommodityUseCase { const commodity = Commodity.restore(commodityData); + await this.commodityReferencePolicy.assertNoActiveReferences( + user.getId().valueOf(), + commodityId, + ); + const result = commodity.delete(); if (result === 'changed') { diff --git a/apps/backend/src/di/container.ts b/apps/backend/src/di/container.ts index 6b511c30..7997b5bf 100644 --- a/apps/backend/src/di/container.ts +++ b/apps/backend/src/di/container.ts @@ -88,6 +88,8 @@ export const createContainer = (db: DataBase): AppContainer => { const commodityReferencePolicy = new CommodityReferencePolicy( commodityRepository, + accountRepository, + transactionRepository, ); const passwordManager = new PasswordManager(); @@ -172,6 +174,7 @@ export const createContainer = (db: DataBase): AppContainer => { const deleteCommodityUseCase = new DeleteCommodityUseCase( commodityRepository, ensureOwnedSnapshot, + commodityReferencePolicy, transactionManager, ); 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 eae283b3..db78095b 100644 --- a/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts +++ b/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts @@ -1138,6 +1138,44 @@ describe('AccountRepository', () => { }); }); + describe('existsActiveByCommodityId', () => { + it('returns true when a non-tombstoned account references the commodity', async () => { + await testDB.createAccount(user.id, usdCommodity.id); + + await expect( + accountRepository.existsActiveByCommodityId(user.id, usdCommodity.id), + ).resolves.toBe(true); + }); + + it('returns false when only tombstoned accounts reference the commodity', async () => { + await testDB.createAccount(user.id, usdCommodity.id, { + isTombstone: true, + }); + + await expect( + accountRepository.existsActiveByCommodityId(user.id, usdCommodity.id), + ).resolves.toBe(false); + }); + + it('returns false for accounts owned by another user', async () => { + const secondUser = await testDB.createUser({ + email: 'second-user@example.com', + }); + const secondUserCommodity = await testDB.createCommodity(secondUser.id, { + code: CommodityCode.create('USD').valueOf(), + }); + + await testDB.createAccount(secondUser.id, secondUserCommodity.id); + + await expect( + accountRepository.existsActiveByCommodityId( + user.id, + secondUserCommodity.id, + ), + ).resolves.toBe(false); + }); + }); + describe('getByIds', () => { it.todo('should return all requested accounts owned by the user'); diff --git a/apps/backend/src/infrastructure/db/accounts/account.repository.ts b/apps/backend/src/infrastructure/db/accounts/account.repository.ts index 91e60614..1b50f96a 100644 --- a/apps/backend/src/infrastructure/db/accounts/account.repository.ts +++ b/apps/backend/src/infrastructure/db/accounts/account.repository.ts @@ -1,6 +1,6 @@ import { UUID } from '@ledgerly/shared/types'; import { AccountQuery } from '@ledgerly/shared/validation'; -import { and, eq, inArray } from 'drizzle-orm'; +import { and, eq, inArray, sql } from 'drizzle-orm'; import { type AccountLifecycleUpdateInput, type AccountRepositoryInterface, @@ -325,6 +325,35 @@ export class AccountRepository }, 'Failed to verify account ownership'); } + async existsActiveByCommodityId( + userId: UUID, + commodityId: UUID, + ): Promise { + return this.executeDatabaseOperation( + async () => { + const count = await this.db + .select({ count: sql`count(*)` }) + .from(accountsTable) + .where( + and( + eq(accountsTable.userId, userId), + eq(accountsTable.commodityId, commodityId), + eq(accountsTable.isTombstone, false), + ), + ) + .get(); + + return !!count && count.count > 0; + }, + 'AccountRepository.existsActiveByCommodityId', + { + field: 'commodityId', + tableName: 'accounts', + value: commodityId, + }, + ); + } + async getByIds(userId: UUID, accountIds: UUID[]): Promise { return this.executeDatabaseOperation(async () => { const accounts = await this.db 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 e28ef813..78ab9025 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction.repository.test.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction.repository.test.ts @@ -8,7 +8,13 @@ import { TransactionPersistenceBuilderResult, } from 'src/db/test-utils'; import { Account } from 'src/domain'; -import { Amount, DateValue, Id, Version } from 'src/domain/domain-core'; +import { + Amount, + CommodityCode, + DateValue, + Id, + Version, +} from 'src/domain/domain-core'; import { OperationSnapshot } from 'src/domain/operations/types'; import { RepositoryNotFoundError } from 'src/infrastructure/errors'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -269,6 +275,54 @@ describe('TransactionRepository', () => { }); }); + describe('existsActiveByCommodityId', () => { + it('returns true when a non-tombstoned transaction references the commodity', async () => { + const commodity = data.getCommodityByKey('USD'); + + await testDB.createTransaction(user.id, commodity.getId().valueOf()); + + await expect( + transactionRepository.existsActiveByCommodityId( + user.id, + commodity.getId().valueOf(), + ), + ).resolves.toBe(true); + }); + + it('returns false when only tombstoned transactions reference the commodity', async () => { + const commodity = data.getCommodityByKey('USD'); + + await testDB.createTransaction(user.id, commodity.getId().valueOf(), { + isTombstone: true, + }); + + await expect( + transactionRepository.existsActiveByCommodityId( + user.id, + commodity.getId().valueOf(), + ), + ).resolves.toBe(false); + }); + + it('returns false for transactions owned by another user', async () => { + const secondUser = await testDB.createUser({ + email: 'second-user@example.com', + }); + const secondUserCommodity = await testDB.createCommodity(secondUser.id, { + code: CommodityCode.create('USD').valueOf(), + }); + + await testDB.createTransaction(secondUser.id, secondUserCommodity.id); + + await expect( + transactionRepository.existsActiveByCommodityId( + user.id, + secondUserCommodity.id, + ), + ).resolves.toBe(false); + }); + }); + describe('create', () => { it('should not create a transaction when user does not exist', async () => { const transaction = data.transaction; diff --git a/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts b/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts index d13dcd2b..a2102536 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts @@ -1,5 +1,5 @@ import { UUID } from '@ledgerly/shared/types'; -import { and, eq } from 'drizzle-orm'; +import { and, eq, sql } from 'drizzle-orm'; import { OperationRepositoryInterface, TransactionRepositoryInterface, @@ -57,6 +57,35 @@ export class TransactionRepository ); } + async existsActiveByCommodityId( + userId: UUID, + commodityId: UUID, + ): Promise { + return this.executeDatabaseOperation( + async () => { + const count = await this.db + .select({ count: sql`count(*)` }) + .from(transactionsTable) + .where( + and( + eq(transactionsTable.userId, userId), + eq(transactionsTable.commodityId, commodityId), + eq(transactionsTable.isTombstone, false), + ), + ) + .get(); + + return !!count && count.count > 0; + }, + 'TransactionRepository.existsActiveByCommodityId', + { + field: 'commodityId', + tableName: 'transactions', + value: commodityId, + }, + ); + } + private async insertTransactionRow( userId: UUID, transaction: Transaction, 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 97f4d549..f6df0061 100644 --- a/apps/backend/src/presentation/http/commodities/commodity.integration.test.ts +++ b/apps/backend/src/presentation/http/commodities/commodity.integration.test.ts @@ -1,6 +1,11 @@ import { CommodityStatusFilterValue } from '@ledgerly/shared/constants'; import { ROUTES } from '@ledgerly/shared/routes'; -import { CommodityResponseDTO, UUID } from '@ledgerly/shared/types'; +import { + apiErrorCodes, + ApiErrorResponse, + CommodityResponseDTO, + UUID, +} from '@ledgerly/shared/types'; import { CommodityDbRow } from 'src/db/schemas'; import { TestDB } from 'src/db/test-db'; import { compareEntityArrays, parseResponse } from 'src/db/test-utils'; @@ -970,6 +975,67 @@ describe('Commodities Integration Tests', () => { expect(response.statusCode).toBe(404); }); + it('should return 409 when deleting a commodity with active account references', async () => { + const referencedCommodity = await testDB.createCommodity(userId, { + code: CommodityCode.create('CHF').valueOf(), + name: 'Swiss Franc', + precision: 2, + symbol: 'CHF', + }); + await testDB.createAccount(userId, referencedCommodity.id); + + const response = await injectAuthorized({ + method: 'DELETE', + url: `${url}/${referencedCommodity.id}`, + }); + + const parsedResponse = parseResponse(response); + + expect(response.statusCode).toBe(409); + expect(parsedResponse).toEqual({ + code: apiErrorCodes.commodityHasActiveReferences, + context: { commodityId: referencedCommodity.id }, + error: true, + }); + + const retrievedCommodity = await testDB.getCommodityById( + referencedCommodity.id, + ); + + expect(retrievedCommodity?.isTombstone).toBe(false); + }); + + it('should return 409 when deleting a commodity with active transaction references', async () => { + const referencedCommodity = await testDB.createCommodity(userId, { + code: CommodityCode.create('CHF').valueOf(), + name: 'Swiss Franc', + precision: 2, + symbol: 'CHF', + }); + + await testDB.createTransaction(userId, referencedCommodity.id); + + const response = await injectAuthorized({ + method: 'DELETE', + url: `${url}/${referencedCommodity.id}`, + }); + + const parsedResponse = parseResponse(response); + + expect(response.statusCode).toBe(409); + expect(parsedResponse).toEqual({ + code: apiErrorCodes.commodityHasActiveReferences, + context: { commodityId: referencedCommodity.id }, + error: true, + }); + + const retrievedCommodity = await testDB.getCommodityById( + referencedCommodity.id, + ); + + expect(retrievedCommodity?.isTombstone).toBe(false); + }); + it('should return 204 when deleting an already deleted commodity', async () => { const deletedCommodity = commoditiesDbRows.find( (commodity) => commodity.isTombstone, diff --git a/apps/backend/src/presentation/http/error-handler.test.ts b/apps/backend/src/presentation/http/error-handler.test.ts index 897b5ebd..195892fb 100644 --- a/apps/backend/src/presentation/http/error-handler.test.ts +++ b/apps/backend/src/presentation/http/error-handler.test.ts @@ -5,6 +5,7 @@ import { import type { FastifyReply, FastifyRequest } from 'fastify'; import { AccountHasActiveOperationsError, + CommodityHasActiveReferencesError, InvalidPasswordError, UserAlreadyExistsError, UserNotFoundError, @@ -91,6 +92,7 @@ describe('getValidationFieldErrorCode', () => { describe('errorHandler', () => { const accountId = Id.create().valueOf(); + const commodityId = Id.create().valueOf(); const handle = (error: Error) => { const { reply, response } = createReply(); @@ -142,6 +144,13 @@ describe('errorHandler', () => { apiErrorCodes.accountHasActiveOperations, { accountId }, ], + [ + 'a commodity with active references', + new CommodityHasActiveReferencesError(commodityId), + 409, + apiErrorCodes.commodityHasActiveReferences, + { commodityId }, + ], ])( 'serializes %s through the generic coded-error path', (_caseName, error, expectedStatus, expectedCode, expectedContext) => { diff --git a/apps/backend/src/presentation/http/error-handler.ts b/apps/backend/src/presentation/http/error-handler.ts index bd7f7ee2..50c5e2ee 100644 --- a/apps/backend/src/presentation/http/error-handler.ts +++ b/apps/backend/src/presentation/http/error-handler.ts @@ -22,6 +22,7 @@ const statusByErrorCode = { [apiErrorCodes.badRequest]: 400, [apiErrorCodes.closedAccountOperation]: 409, [apiErrorCodes.closedCommodityReference]: 409, + [apiErrorCodes.commodityHasActiveReferences]: 409, [apiErrorCodes.conflict]: 409, [apiErrorCodes.conflictingOperationIds]: 400, [apiErrorCodes.deletedEntityOperation]: 400, diff --git a/packages/shared/src/types/error-contract.ts b/packages/shared/src/types/error-contract.ts index 529553fb..b4d520ab 100644 --- a/packages/shared/src/types/error-contract.ts +++ b/packages/shared/src/types/error-contract.ts @@ -7,6 +7,7 @@ export const apiErrorCodes = { badRequest: "BAD_REQUEST", closedAccountOperation: "CLOSED_ACCOUNT_OPERATION", closedCommodityReference: "CLOSED_COMMODITY_REFERENCE", + commodityHasActiveReferences: "COMMODITY_HAS_ACTIVE_REFERENCES", conflict: "CONFLICT", conflictingOperationIds: "CONFLICTING_OPERATION_IDS", deletedEntityOperation: "DELETED_ENTITY_OPERATION", @@ -74,6 +75,9 @@ export type ErrorContextByCode = { commodityId: UUID; operation: "create_account"; }; + COMMODITY_HAS_ACTIVE_REFERENCES: { + commodityId: UUID; + }; CONFLICT: Record; CONFLICTING_OPERATION_IDS: { conflict: From b15d5d0508416919209c388899b791be0af5c550 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Wed, 5 Aug 2026 21:37:13 +0300 Subject: [PATCH 27/31] LED-125 feat(commodities): enhance delete commodity use case to handle tombstoned commodities --- .../__tests__/deleteCommodity.test.ts | 29 ++++++++++++++++++- .../usecases/commodities/deleteCommodity.ts | 4 +++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/application/usecases/commodities/__tests__/deleteCommodity.test.ts b/apps/backend/src/application/usecases/commodities/__tests__/deleteCommodity.test.ts index b4fe177d..a200aa06 100644 --- a/apps/backend/src/application/usecases/commodities/__tests__/deleteCommodity.test.ts +++ b/apps/backend/src/application/usecases/commodities/__tests__/deleteCommodity.test.ts @@ -134,12 +134,39 @@ describe('DeleteCommodityUseCase', () => { ); expect( commodityReferencePolicy.assertNoActiveReferences, - ).toHaveBeenCalledWith(user.getId().valueOf(), commodityId); + ).not.toHaveBeenCalled(); expect(commodityRepository.delete).not.toHaveBeenCalled(); expect(mockTransactionManager.run).toHaveBeenCalledTimes(1); expect(result).toBeUndefined(); }); + it('should keep delete idempotent when tombstoned commodity has stale active references', async () => { + const commodity = createCommodity(user, { + code: 'TEST', + name: 'Test Commodity', + precision: 2, + symbol: 'T', + }); + commodity.delete(); + + const commodityId = commodity.getId().valueOf(); + + commodityRepository.getByIdForLifecycle.mockResolvedValue( + commodity.toSnapshot(), + ); + commodityReferencePolicy.assertNoActiveReferences.mockRejectedValue( + new CommodityHasActiveReferencesError(commodityId), + ); + + const result = await deleteCommodityUseCase.execute(user, commodityId); + + expect( + commodityReferencePolicy.assertNoActiveReferences, + ).not.toHaveBeenCalled(); + expect(commodityRepository.delete).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + it('should throw when commodity has active references', async () => { const commodity = createCommodity(user, { code: 'TEST', diff --git a/apps/backend/src/application/usecases/commodities/deleteCommodity.ts b/apps/backend/src/application/usecases/commodities/deleteCommodity.ts index a88b271d..444a78f1 100644 --- a/apps/backend/src/application/usecases/commodities/deleteCommodity.ts +++ b/apps/backend/src/application/usecases/commodities/deleteCommodity.ts @@ -30,6 +30,10 @@ export class DeleteCommodityUseCase { const commodity = Commodity.restore(commodityData); + if (commodity.isDeleted()) { + return; + } + await this.commodityReferencePolicy.assertNoActiveReferences( user.getId().valueOf(), commodityId, From 2a4ee8e46761aad8781271462aabdb94fc9a652a Mon Sep 17 00:00:00 2001 From: gorushkin Date: Wed, 5 Aug 2026 22:18:20 +0300 Subject: [PATCH 28/31] LED-130 feat(accounts): make account terminal delete idempotent using lifecycle-aware lookup --- .../accounts/__tests__/deleteAccount.test.ts | 81 +++++++++++++------ .../usecases/accounts/deleteAccount.ts | 47 ++++++----- apps/backend/src/di/container.ts | 1 + .../http/accounts/account.integration.test.ts | 17 +++- docs/DOMAIN.md | 29 ++++--- ...-tombstone-and-reversible-entity-states.md | 10 +-- 6 files changed, 120 insertions(+), 65 deletions(-) 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 60b99869..0c01ee1c 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/deleteAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/deleteAccount.test.ts @@ -7,8 +7,8 @@ import type { AccountRepositoryInterface, TransactionManagerInterface, } 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 { AccountSnapshot } from 'src/domain/accounts'; import { Amount, Timestamp } from 'src/domain/domain-core'; @@ -24,7 +24,7 @@ describe('DeleteAccountUseCase', async () => { const mockAccountRepository = { delete: vi.fn(), - getById: vi.fn(), + getByIdForLifecycle: vi.fn(), }; const mockAccountOperationPolicy = { @@ -76,6 +76,7 @@ describe('DeleteAccountUseCase', async () => { mockAccountRepository as unknown as AccountRepositoryInterface, mockAccountOperationPolicy as unknown as AccountOperationPolicy, mockTransactionManager as unknown as TransactionManagerInterface, + ensureOwnedSnapshot, ); }); @@ -84,7 +85,7 @@ describe('DeleteAccountUseCase', async () => { vi.useRealTimers(); mockAccountOperationPolicy.assertNoActiveOperations.mockReset(); - mockAccountRepository.getById.mockReset(); + mockAccountRepository.getByIdForLifecycle.mockReset(); mockAccountRepository.delete.mockReset(); mockTransactionManager.run.mockReset(); }); @@ -97,7 +98,9 @@ describe('DeleteAccountUseCase', async () => { mockAccountOperationPolicy.assertNoActiveOperations.mockResolvedValue( false, ); - mockAccountRepository.getById.mockResolvedValue(mockAccountData); + mockAccountRepository.getByIdForLifecycle.mockResolvedValue( + mockAccountData, + ); mockAccountRepository.delete.mockResolvedValue(mockSavedAccountData); vi.setSystemTime(new Date(updatedAt)); @@ -113,18 +116,24 @@ describe('DeleteAccountUseCase', async () => { isTombstone: true, }), ); - expect(mockTransactionManager.run).toHaveBeenCalledTimes(1); - expect(result).toEqual( - AccountMapper.toResponseDTOFromSnapshot({ - ...mockSavedAccountData, - updatedAt, - }), + expect(mockAccountRepository.getByIdForLifecycle).toHaveBeenCalledWith( + user.getId().valueOf(), + accountId, ); + expect( + mockAccountOperationPolicy.assertNoActiveOperations, + ).toHaveBeenCalledWith(user.getId().valueOf(), accountId); + + expect(mockTransactionManager.run).toHaveBeenCalledTimes(1); + + expect(result).toBeUndefined(); }); it('should throw when account has active operations', async () => { - mockAccountRepository.getById.mockResolvedValue(mockAccountData); + mockAccountRepository.getByIdForLifecycle.mockResolvedValue( + mockAccountData, + ); mockAccountOperationPolicy.assertNoActiveOperations.mockRejectedValue( new AccountHasActiveOperationsError(accountId), ); @@ -142,13 +151,13 @@ describe('DeleteAccountUseCase', async () => { }); it('should throw when account does not exist', async () => { - mockAccountRepository.getById.mockResolvedValue(null); + mockAccountRepository.getByIdForLifecycle.mockResolvedValue(null); await expect( deleteAccountUseCase.execute(user, accountId), ).rejects.toThrowError(EntityNotFoundError); - expect(mockAccountRepository.getById).toHaveBeenCalledWith( + expect(mockAccountRepository.getByIdForLifecycle).toHaveBeenCalledWith( user.getId().valueOf(), accountId, ); @@ -162,13 +171,15 @@ describe('DeleteAccountUseCase', async () => { it('should throw when account does not belong to user', async () => { const anotherUser = await createUser(); - mockAccountRepository.getById.mockResolvedValue(mockAccountData); + mockAccountRepository.getByIdForLifecycle.mockResolvedValue( + mockAccountData, + ); await expect( deleteAccountUseCase.execute(anotherUser, accountId), ).rejects.toThrowError(UnauthorizedAccessError); - expect(mockAccountRepository.getById).toHaveBeenCalledWith( + expect(mockAccountRepository.getByIdForLifecycle).toHaveBeenCalledWith( anotherUser.getId().valueOf(), accountId, ); @@ -176,27 +187,51 @@ describe('DeleteAccountUseCase', async () => { expect(mockAccountRepository.delete).not.toHaveBeenCalled(); }); - it('should not call repo saving when account is already deleted', async () => { - mockAccountRepository.getById.mockResolvedValue({ + it('should not call repo saving and assertNoActiveOperations when account is already deleted', async () => { + mockAccountRepository.getByIdForLifecycle.mockResolvedValue({ ...mockAccountData, isTombstone: true, }); const result = await deleteAccountUseCase.execute(user, accountId); - expect(mockAccountRepository.getById).toHaveBeenCalledWith( + expect(mockAccountRepository.getByIdForLifecycle).toHaveBeenCalledWith( user.getId().valueOf(), accountId, ); expect(mockAccountRepository.delete).not.toHaveBeenCalled(); - expect(result).toEqual( - AccountMapper.toResponseDTOFromSnapshot({ - ...mockAccountData, - isTombstone: true, - }), + expect( + mockAccountOperationPolicy.assertNoActiveOperations, + ).not.toHaveBeenCalled(); + + expect(result).toBeUndefined(); + }); + + it('should keep delete idempotent when tombstoned account has stale active operations', async () => { + mockAccountRepository.getByIdForLifecycle.mockResolvedValue({ + ...mockAccountData, + isTombstone: true, + }); + + mockAccountOperationPolicy.assertNoActiveOperations.mockRejectedValue( + new AccountHasActiveOperationsError(accountId), ); + + const result = await deleteAccountUseCase.execute(user, accountId); + + expect(mockAccountRepository.getByIdForLifecycle).toHaveBeenCalledWith( + user.getId().valueOf(), + accountId, + ); + + expect(mockAccountRepository.delete).not.toHaveBeenCalled(); + expect( + mockAccountOperationPolicy.assertNoActiveOperations, + ).not.toHaveBeenCalled(); + + expect(result).toBeUndefined(); }); }); }); diff --git a/apps/backend/src/application/usecases/accounts/deleteAccount.ts b/apps/backend/src/application/usecases/accounts/deleteAccount.ts index baab7d5c..0aef28fb 100644 --- a/apps/backend/src/application/usecases/accounts/deleteAccount.ts +++ b/apps/backend/src/application/usecases/accounts/deleteAccount.ts @@ -1,10 +1,11 @@ -import { AccountResponseDTO, UUID } from '@ledgerly/shared/types'; +import { UUID } from '@ledgerly/shared/types'; import type { AccountRepositoryInterface, TransactionManagerInterface, } from 'src/application/interfaces'; -import { AccountMapper } from 'src/application/mappers'; import { AccountOperationPolicy } from 'src/application/services'; +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'; @@ -15,37 +16,41 @@ export class DeleteAccountUseCase extends AccountUseCaseBase { accountRepository: AccountRepositoryInterface, private readonly accountOperationPolicy: AccountOperationPolicy, private readonly transactionManager: TransactionManagerInterface, + protected readonly ensureOwnedSnapshot: EnsureOwnedSnapshotFn, ) { super(accountRepository); } - async execute(user: User, accountId: UUID): Promise { - const accountSnapshot = await this.transactionManager.run(async () => { - const accountData = await this.ensureAccountExistsAndOwned( + async execute(user: User, accountId: UUID): Promise { + await this.transactionManager.run(async () => { + const accountData = await this.ensureOwnedSnapshot({ + entityId: accountId, + entityType: Account.entityType, + getOwnerId: (account) => account.userId, + load: this.accountRepository.getByIdForLifecycle.bind( + this.accountRepository, + ), user, - accountId, - ); - - await this.accountOperationPolicy.assertNoActiveOperations( - user.getId().valueOf(), - accountId, - ); + }); const account = Account.restore(accountData); const result = account.delete(); - if (result === 'changed') { - await this.accountRepository.delete( - user.getId().valueOf(), - accountId, - account.toSnapshot(), - ); + if (result !== 'changed') { + return; } - return account.toSnapshot(); - }); + await this.accountOperationPolicy.assertNoActiveOperations( + user.getId().valueOf(), + accountId, + ); - return AccountMapper.toResponseDTOFromSnapshot(accountSnapshot); + await this.accountRepository.delete( + user.getId().valueOf(), + accountId, + account.toSnapshot(), + ); + }); } } diff --git a/apps/backend/src/di/container.ts b/apps/backend/src/di/container.ts index 7997b5bf..b8f6d9c2 100644 --- a/apps/backend/src/di/container.ts +++ b/apps/backend/src/di/container.ts @@ -115,6 +115,7 @@ export const createContainer = (db: DataBase): AppContainer => { accountRepository, accountOperationPolicy, transactionManager, + ensureOwnedSnapshot, ); const closeAccountUseCase = new CloseAccountUseCase( accountRepository, 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 4a0b0f0e..965915ed 100644 --- a/apps/backend/src/presentation/http/accounts/account.integration.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.integration.test.ts @@ -1220,14 +1220,25 @@ describe('Accounts Integration Tests', () => { expect(response.statusCode).toBe(404); }); - it('should return 404 when account is already deleted', async () => { + it('should return 204 when account is already deleted', async () => { const deletedAccount = accounts.find((account) => account.isTombstone); + expect(deletedAccount).toBeDefined(); + + const accountBeforeDelete = await testDB.getAccountById( + deletedAccount!.id, + ); const response = await injectAuthorized({ method: 'DELETE', - url: `/api/accounts/${deletedAccount?.id}`, + url: `/api/accounts/${deletedAccount!.id}`, }); + const accountAfterDelete = await testDB.getAccountById( + deletedAccount!.id, + ); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(204); + expect(accountAfterDelete?.updatedAt).toBe( + accountBeforeDelete?.updatedAt, + ); }); it('should return 409 when account has active operations', async () => { diff --git a/docs/DOMAIN.md b/docs/DOMAIN.md index 87f2152d..f62aeef1 100644 --- a/docs/DOMAIN.md +++ b/docs/DOMAIN.md @@ -72,11 +72,11 @@ Represents different financial accounts with unified structure for all account t - 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 -- `DELETE /accounts/:id` should be idempotent for an account that already - belongs to the user and already has `isTombstone = true`; current code has a - repository hook named `getByIdForLifecycle(...)` for that delete-specific - lookup, but it is not wired into the delete use case yet and should not be - used for normal reads, update, close or open flows +- `DELETE /accounts/:id` is idempotent for an account that already belongs to + the user and already has `isTombstone = true`. Delete uses the + lifecycle-aware `getByIdForLifecycle(...)` lookup through `ensureOwnedSnapshot` + so repeated deletes can see tombstoned accounts. Normal reads, list filters, + update, close and open still exclude tombstoned accounts. ### Commodity @@ -134,18 +134,21 @@ with application response mappers and infrastructure persistence mappers. Entity timestamps are domain state. Repositories must not generate entity `id`, `createdAt` or `updatedAt` values. `create(...)` creates identity and -initial timestamps, and behavior methods such as `update(...)` or -`markAsDeleted()` update `updatedAt` when they change entity state. +initial timestamps, and behavior methods such as `update(...)` or `delete()` +update `updatedAt` when they change entity state. Repositories persist timestamps received through snapshots/mappers. -Soft-delete is a domain state transition, not an idempotent repository command. -Calling `markAsDeleted()` on an already deleted entity must fail with -`DELETED_ENTITY_OPERATION` and must not mutate `updatedAt` again. -Repository APIs that persist this transition must use `softDelete(...)`. +Soft-delete is a domain state transition, not an implicit repository command. +The shared `SoftDelete.markAsDeleted()` behavior rejects repeated low-level +deletion, while entity-level `delete()` methods may expose an idempotent +business command by returning `unchanged` without mutating `updatedAt` again. +Repository APIs that persist this transition must accept the domain timestamp +produced by the entity. Application/use case APIs may use business-facing lifecycle names such as `close...` and `open...`, and HTTP routes may still expose -`DELETE /resource/:id` commands. Repository `delete(...)` is reserved for -physical row deletion. +`DELETE /resource/:id` commands. Current account and commodity repositories use +`delete(...)` as the terminal tombstone persistence hook; it must not be used as +a normal read/update path and should not perform physical row deletion. Snapshot types live next to the entity in `domain//types.ts`. They use primitive/domain-safe fields and must not be aliases for DB rows or response 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 c7c140cd..6a143e04 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 @@ -110,11 +110,10 @@ and already has `isTombstone = true` should keep the tombstone state and return the delete success response without changing `updatedAt` again. Normal account reads, list filters, update, close and open still exclude tombstoned accounts. -The current `AccountRepository.getByIdForLifecycle(...)` method is an unfinished -implementation hook for that delete-specific lookup. It intentionally reads -tombstoned accounts, but at the moment it is not wired into the account delete -use case, so it is redundant outside tests until the follow-up work is applied. -It must not become the general account read path. +`AccountRepository.getByIdForLifecycle(...)` is the delete-specific lookup for +this contract. It intentionally reads tombstoned accounts and is wired through +`ensureOwnedSnapshot` in the account delete use case so ownership and not-found +handling remain centralized. It must not become the general account read path. ### Commodity @@ -288,6 +287,7 @@ The intended public domain methods are: - [LED-122: Define terminal tombstone and reversible archive/close states](https://gorushkin.atlassian.net/browse/LED-122) - [LED-124: Implement Account close open and terminal delete](https://gorushkin.atlassian.net/browse/LED-124) - [LED-125: Implement Commodity close open and terminal delete](https://gorushkin.atlassian.net/browse/LED-125) +- [LED-130: Make account terminal delete idempotent](https://gorushkin.atlassian.net/browse/LED-130) - [ADR 0011: Domain Entity API Conventions](./0011-domain-entity-api-conventions.md) - [ADR 0015: Domain Restoration Factory Naming](./0015-domain-restoration-factory-naming.md) - [ADR 0019: Repository-Enforced Commodity Reference Validation](./0019-repository-enforced-commodity-reference-validation.md) From b9a80b8cafc24c4f39b750d2cb52d46e3e5b7cb5 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Wed, 5 Aug 2026 22:30:59 +0300 Subject: [PATCH 29/31] LED-130 feat(accounts): enhance delete account use case for idempotency and stale operations --- .../usecases/accounts/deleteAccount.ts | 6 +-- .../usecases/commodities/deleteCommodity.ts | 18 ++++---- .../http/accounts/account.integration.test.ts | 41 ++++++++++++++++++- 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/apps/backend/src/application/usecases/accounts/deleteAccount.ts b/apps/backend/src/application/usecases/accounts/deleteAccount.ts index 0aef28fb..f091bb8f 100644 --- a/apps/backend/src/application/usecases/accounts/deleteAccount.ts +++ b/apps/backend/src/application/usecases/accounts/deleteAccount.ts @@ -35,9 +35,7 @@ export class DeleteAccountUseCase extends AccountUseCaseBase { const account = Account.restore(accountData); - const result = account.delete(); - - if (result !== 'changed') { + if (account.isDeleted()) { return; } @@ -46,6 +44,8 @@ export class DeleteAccountUseCase extends AccountUseCaseBase { accountId, ); + account.delete(); + await this.accountRepository.delete( user.getId().valueOf(), accountId, diff --git a/apps/backend/src/application/usecases/commodities/deleteCommodity.ts b/apps/backend/src/application/usecases/commodities/deleteCommodity.ts index 444a78f1..82d24896 100644 --- a/apps/backend/src/application/usecases/commodities/deleteCommodity.ts +++ b/apps/backend/src/application/usecases/commodities/deleteCommodity.ts @@ -39,17 +39,15 @@ export class DeleteCommodityUseCase { commodityId, ); - const result = commodity.delete(); + commodity.delete(); - if (result === 'changed') { - await this.commodityRepository.delete( - user.getId().valueOf(), - commodityId, - { - updatedAt: commodity.getUpdatedAt().valueOf(), - }, - ); - } + await this.commodityRepository.delete( + user.getId().valueOf(), + commodityId, + { + updatedAt: commodity.getUpdatedAt().valueOf(), + }, + ); }); } } 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 965915ed..775df133 100644 --- a/apps/backend/src/presentation/http/accounts/account.integration.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.integration.test.ts @@ -1235,9 +1235,46 @@ describe('Accounts Integration Tests', () => { deletedAccount!.id, ); + expect(accountBeforeDelete).toBeDefined(); + expect(accountAfterDelete).toBeDefined(); expect(response.statusCode).toBe(204); - expect(accountAfterDelete?.updatedAt).toBe( - accountBeforeDelete?.updatedAt, + expect(accountAfterDelete!.updatedAt).toEqual( + accountBeforeDelete!.updatedAt, + ); + }); + + it('should return 204 when an already deleted account has stale active operations', async () => { + const deletedAccount = accounts.find((account) => account.isTombstone); + expect(deletedAccount).toBeDefined(); + + await testDB.createTransactionWithOperations(userId, commodity.id, { + operations: [ + { + accountId: deletedAccount!.id, + amount: Amount.create('100').valueOf(), + description: 'Stale active operation', + id: Id.create().valueOf(), + value: Amount.create('100').valueOf(), + }, + ], + }); + + const accountBeforeDelete = await testDB.getAccountById( + deletedAccount!.id, + ); + const response = await injectAuthorized({ + method: 'DELETE', + url: `/api/accounts/${deletedAccount!.id}`, + }); + const accountAfterDelete = await testDB.getAccountById( + deletedAccount!.id, + ); + + expect(accountBeforeDelete).toBeDefined(); + expect(accountAfterDelete).toBeDefined(); + expect(response.statusCode).toBe(204); + expect(accountAfterDelete!.updatedAt).toEqual( + accountBeforeDelete!.updatedAt, ); }); From b92a4cdf63f80f5b9bd298f7b46a74ce53499f12 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Thu, 6 Aug 2026 09:14:41 +0300 Subject: [PATCH 30/31] LED-123 feat(operations): add tests for existsActiveByAccountId method to verify active operations --- .../operations/operation.repository.test.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/apps/backend/src/infrastructure/db/operations/operation.repository.test.ts b/apps/backend/src/infrastructure/db/operations/operation.repository.test.ts index 1a4b55f2..b98a3281 100644 --- a/apps/backend/src/infrastructure/db/operations/operation.repository.test.ts +++ b/apps/backend/src/infrastructure/db/operations/operation.repository.test.ts @@ -85,6 +85,71 @@ describe('OperationRepository', () => { }); }); + describe('existsActiveByAccountId', () => { + it('should return true when the account has an active operation', async () => { + const operation = OperationPersistenceMapper.toDBRow(data.operations[0]); + + await testDB.insertOperation(operation); + + await expect( + operationRepository.existsActiveByAccountId( + userSnapshot.id, + operation.accountId, + ), + ).resolves.toBe(true); + }); + + it('should return false when matching operations are tombstoned', async () => { + const operation = OperationPersistenceMapper.toDBRow(data.operations[0]); + + await testDB.insertOperation({ + ...operation, + isTombstone: true, + }); + + await expect( + operationRepository.existsActiveByAccountId( + userSnapshot.id, + operation.accountId, + ), + ).resolves.toBe(false); + }); + + it('should return false when only another user has an active operation', async () => { + const otherUser = await testDB.createUser({ + email: `other-${Date.now()}@example.com`, + }); + const otherCommodity = await testDB.createCommodity(otherUser.id); + const otherAccount = await testDB.createAccount( + otherUser.id, + otherCommodity.id, + ); + const otherTransaction = await testDB.createTransaction( + otherUser.id, + otherCommodity.id, + ); + + await testDB.createOperation(otherUser.id, { + accountId: otherAccount.id, + transactionId: otherTransaction.id, + }); + + await expect( + operationRepository.existsActiveByAccountId( + userSnapshot.id, + otherAccount.id, + ), + ).resolves.toBe(false); + + await expect( + operationRepository.existsActiveByAccountId( + otherUser.id, + otherAccount.id, + ), + ).resolves.toBe(true); + }); + }); + describe('save', () => { it('should insert operations successfully if snapshot is empty', async () => { const fetchedTransactionRelationsBeforeSaving = From 5b6f7269eb6e7a34da2d25ca5a18c58d65a80d96 Mon Sep 17 00:00:00 2001 From: gorushkin Date: Thu, 6 Aug 2026 09:16:52 +0300 Subject: [PATCH 31/31] LED-123 fix(types): correct import path for validation in commodity types --- packages/shared/src/types/commodity.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/types/commodity.ts b/packages/shared/src/types/commodity.ts index 3c4dbeea..4e1023f1 100644 --- a/packages/shared/src/types/commodity.ts +++ b/packages/shared/src/types/commodity.ts @@ -1,10 +1,10 @@ -import type { z } from "zod"; +import { z } from "zod"; import { commodityCode, commodityPrecision, commoditySymbol, -} from "../validation/commodities"; +} from "../validation"; import { IsoDatetimeString, RequiredText, UUID } from "./types";