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/.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/.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/drizzle/0000_careful_dust.sql b/apps/backend/drizzle/0000_clammy_invisible_woman.sql similarity index 99% rename from apps/backend/drizzle/0000_careful_dust.sql rename to apps/backend/drizzle/0000_clammy_invisible_woman.sql index e14c575a..6a62fb91 100644 --- a/apps/backend/drizzle/0000_careful_dust.sql +++ b/apps/backend/drizzle/0000_clammy_invisible_woman.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/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/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/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/0000_snapshot.json b/apps/backend/drizzle/meta/0000_snapshot.json index 07cebc7c..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": "59edadf8-a69b-4dbb-a24a-828a220d6559", + "id": "72b37ad6-4dd9-43c4-8017-7b838aee4e7b", "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/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/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/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 73cfb78c..e05cc786 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -5,8 +5,29 @@ { "idx": 0, "version": "6", - "when": 1784803897112, - "tag": "0000_careful_dust", + "when": 1785508223175, + "tag": "0000_clammy_invisible_woman", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1785859950401, + "tag": "0001_adorable_star_brand", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1785931823375, + "tag": "0002_numerous_alice", + "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1785935002154, + "tag": "0003_bored_daredevil", "breakpoints": true } ] diff --git a/apps/backend/src/application/application.errors.test.ts b/apps/backend/src/application/application.errors.test.ts index fdbfb168..7e94043a 100644 --- a/apps/backend/src/application/application.errors.test.ts +++ b/apps/backend/src/application/application.errors.test.ts @@ -1,6 +1,9 @@ -import { apiErrorCodes } from '@ledgerly/shared/types'; +import { apiErrorCodes, type UUID } from '@ledgerly/shared/types'; import { + AccountHasActiveOperationsError, AuthenticationFailedError, + CommodityClosedError, + CommodityHasActiveReferencesError, InvalidPasswordError, ApplicationError, EntityNotFoundError, @@ -41,4 +44,33 @@ 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 }, + }); + }); + + 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' }, + }); + }); + + 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 8854fc12..8063d801 100644 --- a/apps/backend/src/application/application.errors.ts +++ b/apps/backend/src/application/application.errors.ts @@ -53,6 +53,52 @@ 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 }, + ); + } +} + +/** + * 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 }, + ); + } +} + +/** + * 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 8edc522a..49a66ce2 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,28 +7,43 @@ export type AccountRepositoryUpdateInput = Pick & Pick >; -export type AccountRepositorySoftDeleteInput = Pick< - AccountSnapshot, - 'updatedAt' ->; +export type AccountRepositoryLifecycleInput = { updatedAt: IsoDatetimeString }; + +export type AccountLifecycleAction = 'close' | 'open'; + +export type AccountLifecycleUpdateInput = AccountRepositoryLifecycleInput & { + action: AccountLifecycleAction; +}; 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, ): Promise; + existsActiveByCommodityId(userId: UUID, commodityId: UUID): Promise; getByIds(userId: UUID, accountIds: UUID[]): Promise; }; diff --git a/apps/backend/src/application/interfaces/CommodityRepository.interface.ts b/apps/backend/src/application/interfaces/CommodityRepository.interface.ts index 7ca70de9..6b5592e1 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 { IsoDatetimeString, UUID } from '@ledgerly/shared/types'; +import { CommodityQuery } from '@ledgerly/shared/validation'; import { CommoditySnapshot } from 'src/domain/commodities'; export type CommodityRepositoryUpdateInput = Pick< @@ -7,26 +8,48 @@ export type CommodityRepositoryUpdateInput = Pick< > & Partial>; -export type CommodityRepositorySoftDeleteInput = Pick< +export type CommodityRepositoryDeleteInput = Pick< CommoditySnapshot, '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: QueryStatus): 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; - softDelete( + ): Promise; + delete( userId: UUID, commodityId: UUID, - data: CommodityRepositorySoftDeleteInput, - ): Promise; + data: CommodityRepositoryDeleteInput, + ): 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/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/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/interfaces/index.ts b/apps/backend/src/application/interfaces/index.ts index 78dca4c3..8571d150 100644 --- a/apps/backend/src/application/interfaces/index.ts +++ b/apps/backend/src/application/interfaces/index.ts @@ -1,6 +1,8 @@ export type { + AccountLifecycleAction, + AccountLifecycleUpdateInput, AccountRepositoryInterface, - AccountRepositorySoftDeleteInput, + AccountRepositoryLifecycleInput, AccountRepositoryUpdateInput, } from './AccountRepository.interface'; export type { UserRepositoryInterface } from './userRepository.interface'; @@ -18,7 +20,10 @@ export type { } from './TransactionManager.interface'; export type { OperationRepositoryInterface } from './OperationRepository.interface'; export type { + CommodityLifecycleAction, + CommodityLifecycleUpdateInput, CommodityRepositoryInterface, + CommodityRepositoryLifecycleInput, CommodityRepositoryUpdateInput, - CommodityRepositorySoftDeleteInput, + CommodityRepositoryDeleteInput, } from './CommodityRepository.interface'; 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..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 { @@ -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/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/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/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..0fb79caf --- /dev/null +++ b/apps/backend/src/application/services/CommodityReferencePolicy/__tests__/commodity-reference.policy.test.ts @@ -0,0 +1,167 @@ +import { apiErrorCodes } from '@ledgerly/shared/types'; +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'; +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 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 = () => + Commodity.create(user, { + code: CommodityCode.create('USD').valueOf(), + name: 'US Dollar', + precision: 2, + symbol: '$', + }); + + beforeEach(() => { + vi.clearAllMocks(); + accountRepository.existsActiveByCommodityId.mockResolvedValue(false); + transactionRepository.existsActiveByCommodityId.mockResolvedValue(false); + }); + + 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); + }); + + 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 new file mode 100644 index 00000000..8966e84e --- /dev/null +++ b/apps/backend/src/application/services/CommodityReferencePolicy/commodity-reference.policy.ts @@ -0,0 +1,65 @@ +import { UUID } from '@ledgerly/shared/types'; +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( + 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'); + } + } + + 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/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/TransactionService/__tests__/transaction.context-loader.test.ts b/apps/backend/src/application/services/TransactionService/__tests__/transaction.context-loader.test.ts index af478ed6..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 @@ -1,8 +1,12 @@ 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 { + ClosedAccountOperationError, + DeletedEntityOperationError, +} from 'src/domain/domain.errors'; import { createUser } from 'src/testing'; import { beforeAll, describe, expect, it, vi, beforeEach } from 'vitest'; @@ -146,6 +150,68 @@ 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 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( + ClosedAccountOperationError.forUse(account.getId().valueOf()), + ); + }); + 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..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,5 +1,9 @@ import { UUID } from '@ledgerly/shared/types'; import { Account, User } from 'src/domain'; +import { + ClosedAccountOperationError, + DeletedEntityOperationError, +} from 'src/domain/domain.errors'; import { TransactionBuildContext } from 'src/domain/transactions/types'; import { OperationRequestDTO } from '../../dto'; @@ -29,7 +33,17 @@ 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); + } + + if (account.closed) { + throw ClosedAccountOperationError.forUse(account.getId().valueOf()); + } + + 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..657f3452 100644 --- a/apps/backend/src/application/services/index.ts +++ b/apps/backend/src/application/services/index.ts @@ -1 +1,3 @@ +export * from './AccountOperationPolicy'; +export * from './CommodityReferencePolicy'; 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..12ab603e --- /dev/null +++ b/apps/backend/src/application/usecases/accounts/__tests__/closeAccount.test.ts @@ -0,0 +1,169 @@ +import { + EntityNotFoundError, + UnauthorizedAccessError, +} from 'src/application/application.errors'; +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'; +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 mockTransactionManager = { + run: vi.fn(), + }; + const runTransaction: TransactionManagerInterface['run'] = async (callback) => + callback(); + + 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(); + mockTransactionManager.run.mockReset(); + mockTransactionManager.run.mockImplementation(runTransaction); + + closeAccountUseCase = new CloseAccountUseCase( + mockAccountRepository as unknown as AccountRepositoryInterface, + mockTransactionManager as unknown as TransactionManagerInterface, + ); + }); + + 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(mockTransactionManager.run).toHaveBeenCalledTimes(1); + + 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..1e03d6b8 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,8 @@ -import { AccountRepositoryInterface } from 'src/application'; +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'; @@ -15,6 +19,14 @@ describe('CreateAccountUseCase', async () => { create: vi.fn(), }; + const commodityReferencePolicy = { + assertUsableForNewAccount: 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(); @@ -29,8 +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, ); }); @@ -49,6 +66,13 @@ describe('CreateAccountUseCase', async () => { type, }); + expect( + commodityReferencePolicy.assertUsableForNewAccount, + ).toHaveBeenCalledWith( + user.getId().valueOf(), + mockedCommoditySnapshot.id, + ); + expect(accountRepository.create).toHaveBeenCalledWith( user.getId().valueOf(), { @@ -58,6 +82,7 @@ describe('CreateAccountUseCase', async () => { description, id: result.id, initialBalance, + isClosed: false, isSystem: false, isTombstone: false, name, @@ -76,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/__tests__/deleteAccount.test.ts b/apps/backend/src/application/usecases/accounts/__tests__/deleteAccount.test.ts new file mode 100644 index 00000000..0c01ee1c --- /dev/null +++ b/apps/backend/src/application/usecases/accounts/__tests__/deleteAccount.test.ts @@ -0,0 +1,237 @@ +import { + AccountHasActiveOperationsError, + EntityNotFoundError, + UnauthorizedAccessError, +} from 'src/application/application.errors'; +import type { + AccountRepositoryInterface, + TransactionManagerInterface, +} from 'src/application/interfaces'; +import { AccountOperationPolicy } from 'src/application/services'; +import { ensureOwnedSnapshot } from 'src/application/shared/ensureOwnedSnapshot'; +import { createUser } from 'src/db/createTestUser'; +import { AccountSnapshot } from 'src/domain/accounts'; +import { Amount, Timestamp } from 'src/domain/domain-core'; +import { 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(), + getByIdForLifecycle: vi.fn(), + }; + + const mockAccountOperationPolicy = { + 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(); + + 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(); + mockTransactionManager.run.mockImplementation(runTransaction); + + deleteAccountUseCase = new DeleteAccountUseCase( + mockAccountRepository as unknown as AccountRepositoryInterface, + mockAccountOperationPolicy as unknown as AccountOperationPolicy, + mockTransactionManager as unknown as TransactionManagerInterface, + ensureOwnedSnapshot, + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + + mockAccountOperationPolicy.assertNoActiveOperations.mockReset(); + mockAccountRepository.getByIdForLifecycle.mockReset(); + mockAccountRepository.delete.mockReset(); + mockTransactionManager.run.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.getByIdForLifecycle.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(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.getByIdForLifecycle.mockResolvedValue( + mockAccountData, + ); + 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(mockTransactionManager.run).toHaveBeenCalledTimes(1); + + expect(mockAccountRepository.delete).not.toHaveBeenCalled(); + }); + + it('should throw when account does not exist', async () => { + mockAccountRepository.getByIdForLifecycle.mockResolvedValue(null); + + await expect( + deleteAccountUseCase.execute(user, accountId), + ).rejects.toThrowError(EntityNotFoundError); + + expect(mockAccountRepository.getByIdForLifecycle).toHaveBeenCalledWith( + user.getId().valueOf(), + accountId, + ); + expect( + mockAccountOperationPolicy.assertNoActiveOperations, + ).not.toHaveBeenCalled(); + + expect(mockAccountRepository.delete).not.toHaveBeenCalled(); + }); + + it('should throw when account does not belong to user', async () => { + const anotherUser = await createUser(); + + mockAccountRepository.getByIdForLifecycle.mockResolvedValue( + mockAccountData, + ); + + await expect( + deleteAccountUseCase.execute(anotherUser, accountId), + ).rejects.toThrowError(UnauthorizedAccessError); + + expect(mockAccountRepository.getByIdForLifecycle).toHaveBeenCalledWith( + anotherUser.getId().valueOf(), + accountId, + ); + + expect(mockAccountRepository.delete).not.toHaveBeenCalled(); + }); + + 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.getByIdForLifecycle).toHaveBeenCalledWith( + user.getId().valueOf(), + accountId, + ); + + expect(mockAccountRepository.delete).not.toHaveBeenCalled(); + + 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/__tests__/getAccountById.test.ts b/apps/backend/src/application/usecases/accounts/__tests__/getAccountById.test.ts index 624b701c..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'; @@ -47,6 +46,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..9827daca 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/getAllAccounts.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/getAllAccounts.test.ts @@ -1,8 +1,9 @@ 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'; -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'; @@ -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..6f342683 --- /dev/null +++ b/apps/backend/src/application/usecases/accounts/__tests__/openAccount.test.ts @@ -0,0 +1,173 @@ +import { + EntityNotFoundError, + UnauthorizedAccessError, +} from 'src/application/application.errors'; +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'; +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 mockTransactionManager = { + run: vi.fn(), + }; + const runTransaction: TransactionManagerInterface['run'] = async (callback) => + callback(); + + 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(); + mockTransactionManager.run.mockReset(); + mockTransactionManager.run.mockImplementation(runTransaction); + + openAccountUseCase = new OpenAccountUseCase( + mockAccountRepository as unknown as AccountRepositoryInterface, + mockTransactionManager as unknown as TransactionManagerInterface, + ); + }); + + 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(mockTransactionManager.run).toHaveBeenCalledTimes(1); + + 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..9e504e7f 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,20 @@ import { AccountTypeValue, apiErrorCodes } from '@ledgerly/shared/types'; -import { EntityNotFoundError } from 'src/application/application.errors'; -import type { AccountRepositoryInterface } from 'src/application/interfaces'; +import { + AccountHasActiveOperationsError, + EntityNotFoundError, +} from 'src/application/application.errors'; +import type { + AccountRepositoryInterface, + TransactionManagerInterface, +} from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; +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 { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ClosedAccountOperationError } from 'src/domain/domain.errors'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { UpdateAccountUseCase } from '../updateAccount'; @@ -16,12 +23,22 @@ 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 mockTransactionManager = { + run: vi.fn(), + }; + const runTransaction: TransactionManagerInterface['run'] = async (callback) => + callback(); + const accountId = Id.restore( '550e8400-e29b-41d4-a716-446655440001', ).valueOf(); @@ -38,6 +55,7 @@ describe('UpdateAccount', async () => { description, id: accountId, initialBalance, + isClosed: false, isSystem: false, isTombstone: false, name: accountName, @@ -52,22 +70,33 @@ describe('UpdateAccount', async () => { }; beforeEach(() => { - mockAccountRepository = { - create: vi.fn(), - getById: vi.fn(), - update: vi.fn(), - }; + mockTransactionManager.run.mockImplementation(runTransaction); updateAccountUseCase = new UpdateAccountUseCase( mockAccountRepository as unknown as AccountRepositoryInterface, + mockAccountOperationPolicy as unknown as AccountOperationPolicy, + mockTransactionManager as unknown as TransactionManagerInterface, ); }); + afterEach(() => { + vi.clearAllMocks(); + mockAccountOperationPolicy.assertNoActiveOperations.mockReset(); + mockAccountRepository.getById.mockReset(); + mockAccountRepository.update.mockReset(); + mockTransactionManager.run.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', }); @@ -82,10 +111,14 @@ describe('UpdateAccount', async () => { accountId, expect.objectContaining({ name: 'Updated Account' }), ); + expect(mockTransactionManager.run).toHaveBeenCalledTimes(1); expect(result.name).toBe('Updated Account'); expect(result).toEqual( - AccountMapper.toResponseDTOFromSnapshot(mockAccountUpdatedData), + AccountMapper.toResponseDTOFromSnapshot({ + ...mockAccountUpdatedData, + updatedAt, + }), ); }); @@ -97,6 +130,7 @@ describe('UpdateAccount', async () => { }); await expect(result).rejects.toThrow(EntityNotFoundError); + await expect(result).rejects.toMatchObject({ code: apiErrorCodes.entityNotFound, context: { @@ -112,6 +146,131 @@ 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 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 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); + + 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); + expect(mockTransactionManager.run).toHaveBeenCalledTimes(1); + }); + + 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/archiveAccount.ts b/apps/backend/src/application/usecases/accounts/archiveAccount.ts deleted file mode 100644 index 34bbd5e9..00000000 --- a/apps/backend/src/application/usecases/accounts/archiveAccount.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 ArchiveAccountUseCase 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); - - account.markAsDeleted(); - - const deletedAccount = await this.accountRepository.softDelete( - user.getId().valueOf(), - accountId, - account.toSnapshot(), - ); - - return AccountMapper.toResponseDTOFromSnapshot(deletedAccount); - } -} 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..da21f26a --- /dev/null +++ b/apps/backend/src/application/usecases/accounts/closeAccount.ts @@ -0,0 +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'; +import { User } from 'src/domain/users/user.entity'; + +import { AccountUseCaseBase } from './accountBase'; + +export class CloseAccountUseCase extends AccountUseCaseBase { + constructor( + accountRepository: AccountRepositoryInterface, + private readonly transactionManager: TransactionManagerInterface, + ) { + super(accountRepository); + } + + async execute(user: User, accountId: UUID): Promise { + 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/createAccount.ts b/apps/backend/src/application/usecases/accounts/createAccount.ts index ada7e145..2e7bbad0 100644 --- a/apps/backend/src/application/usecases/accounts/createAccount.ts +++ b/apps/backend/src/application/usecases/accounts/createAccount.ts @@ -1,27 +1,42 @@ 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 { 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 account = Account.create( - user, - AccountMapper.toCreateAccountProps(data), - ); + const accountSnapshot = await this.transactionManager.run(async () => { + await this.commodityReferencePolicy.assertUsableForNewAccount( + user.getId().valueOf(), + data.commodityId, + ); - const accountSnapshot = account.toSnapshot(); + const account = Account.create( + user, + AccountMapper.toCreateAccountProps(data), + ); - await this.accountRepository.create( - user.getId().valueOf(), - accountSnapshot, - ); + const accountSnapshot = account.toSnapshot(); + + 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..f091bb8f --- /dev/null +++ b/apps/backend/src/application/usecases/accounts/deleteAccount.ts @@ -0,0 +1,56 @@ +import { UUID } from '@ledgerly/shared/types'; +import type { + AccountRepositoryInterface, + TransactionManagerInterface, +} from 'src/application/interfaces'; +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'; + +import { AccountUseCaseBase } from './accountBase'; + +export class DeleteAccountUseCase extends AccountUseCaseBase { + constructor( + accountRepository: AccountRepositoryInterface, + private readonly accountOperationPolicy: AccountOperationPolicy, + private readonly transactionManager: TransactionManagerInterface, + protected readonly ensureOwnedSnapshot: EnsureOwnedSnapshotFn, + ) { + super(accountRepository); + } + + async execute(user: User, accountId: UUID): Promise { + await this.transactionManager.run(async () => { + const accountData = await this.ensureOwnedSnapshot({ + entityId: accountId, + entityType: Account.entityType, + getOwnerId: (account) => account.userId, + load: this.accountRepository.getByIdForLifecycle.bind( + this.accountRepository, + ), + user, + }); + + const account = Account.restore(accountData); + + if (account.isDeleted()) { + return; + } + + await this.accountOperationPolicy.assertNoActiveOperations( + user.getId().valueOf(), + accountId, + ); + + account.delete(); + + await this.accountRepository.delete( + user.getId().valueOf(), + accountId, + account.toSnapshot(), + ); + }); + } +} diff --git a/apps/backend/src/application/usecases/accounts/getAllAccounts.ts b/apps/backend/src/application/usecases/accounts/getAllAccounts.ts index b34d057e..57c60a82 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 '@ledgerly/shared/validation'; 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..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 { ArchiveAccountUseCase } from './archiveAccount'; +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 new file mode 100644 index 00000000..83d06458 --- /dev/null +++ b/apps/backend/src/application/usecases/accounts/openAccount.ts @@ -0,0 +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'; +import { User } from 'src/domain/users/user.entity'; + +import { AccountUseCaseBase } from './accountBase'; + +export class OpenAccountUseCase extends AccountUseCaseBase { + constructor( + accountRepository: AccountRepositoryInterface, + private readonly transactionManager: TransactionManagerInterface, + ) { + super(accountRepository); + } + + async execute(user: User, accountId: UUID): Promise { + 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 ddedf430..3e6db2c8 100644 --- a/apps/backend/src/application/usecases/accounts/updateAccount.ts +++ b/apps/backend/src/application/usecases/accounts/updateAccount.ts @@ -3,35 +3,70 @@ 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'; +import { ClosedAccountOperationError } from 'src/domain/domain.errors'; 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, + private readonly transactionManager: TransactionManagerInterface, + ) { super(accountRepository); } + private async checkAccountUpdateValidity( + user: User, + accountId: UUID, + account: Account, + data: AccountUpdateDTO, + ): Promise { + if (account.closed && data.type !== undefined) { + throw ClosedAccountOperationError.forUpdate(accountId); + } + + if (data.type && data.type !== account.toSnapshot().type) { + await this.accountOperationPolicy.assertNoActiveOperations( + user.getId().valueOf(), + accountId, + ); + } + } + async execute( user: User, 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); + account.update(AccountMapper.toUpdateProps(data)); - account.update(AccountMapper.toUpdateProps(data)); + await this.accountRepository.update( + user.getId().valueOf(), + accountId, + account.toSnapshot(), + ); - const updatedAccount = await this.accountRepository.update( - user.getId().valueOf(), - accountId, - account.toSnapshot(), - ); + return account.toSnapshot(); + }); - return AccountMapper.toResponseDTOFromSnapshot(updatedAccount); + return AccountMapper.toResponseDTOFromSnapshot(accountSnapshot); } } diff --git a/apps/backend/src/application/usecases/commodities/__tests__/archiveCommodity.test.ts b/apps/backend/src/application/usecases/commodities/__tests__/archiveCommodity.test.ts deleted file mode 100644 index ed2d00d2..00000000 --- a/apps/backend/src/application/usecases/commodities/__tests__/archiveCommodity.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { CommodityRepositoryInterface, CommodityMapper } from 'src/application'; -import { - EntityNotFoundError, - UnauthorizedAccessError, -} from 'src/application/application.errors'; -import { ensureOwnedSnapshot } from 'src/application/shared/ensureOwnedSnapshot'; -import { createCommodity } from 'src/db/createTestUser'; -import { Commodity } from 'src/domain/commodities/commodity.entity'; -import { Id, Timestamp } from 'src/domain/domain-core/'; -import { 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'; - -describe('ArchiveCommodityUseCase', () => { - let user: User; - - const commodityRepository = { - getById: vi.fn(), - softDelete: vi.fn(), - }; - - const archiveCommodityUseCase = new ArchiveCommodityUseCase( - commodityRepository as unknown as CommodityRepositoryInterface, - ensureOwnedSnapshot, - ); - - beforeAll(async () => { - user = await createUser(); - }); - - beforeEach(() => { - vi.useRealTimers(); - commodityRepository.softDelete.mockClear(); - commodityRepository.getById.mockClear(); - }); - - describe('execute', () => { - it('should archive a commodity successfully', async () => { - vi.useFakeTimers(); - - const timestampBeforeUpdatingValue = '2024-01-01T00:00:00.000Z'; - const timestampDuringUpdatingValue = '2025-01-01T00:00:01.000Z'; - - vi.setSystemTime(new Date(timestampBeforeUpdatingValue)); - - // Arrange - const commodity = createCommodity(user, { - code: 'TEST', - name: 'Test Commodity', - precision: 2, - symbol: 'T', - }); - - 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(), - }, - }; - - commodityRepository.getById.mockResolvedValue(commodity.toSnapshot()); - commodityRepository.softDelete.mockResolvedValue(archivedCommodityData); - - vi.setSystemTime(new Date(timestampDuringUpdatingValue)); - - const expectedResult = CommodityMapper.toResponseDTOFromSnapshot( - archivedCommodityData, - ); - - // Act - const result = await archiveCommodityUseCase.execute(user, commodityId); - - // Assert - expect(commodityRepository.getById).toHaveBeenCalledWith( - userid, - commodityId, - ); - - expect(commodityRepository.softDelete).toHaveBeenCalledWith( - userid, - commodityId, - { updatedAt: archivedCommodityData.timestamps.updatedAt }, - ); - - expect(result).toMatchObject(expectedResult); - }); - - it('should throw an error if the commodity does not exist', async () => { - // Arrange - const nonExistentCommodityId = Id.create().valueOf(); - - commodityRepository.getById.mockResolvedValue(null); - - // Act & Assert - await expect( - archiveCommodityUseCase.execute(user, nonExistentCommodityId), - ).rejects.toThrowError( - new EntityNotFoundError({ - entityId: nonExistentCommodityId, - entityType: Commodity.entityType, - }), - ); - }); - - it('should throw an error if the commodity does not belong to the user', async () => { - // Arrange - 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()); - - // Act & Assert - await expect( - archiveCommodityUseCase.execute(user, commodityId), - ).rejects.toThrowError( - new UnauthorizedAccessError({ - entityId: commodityId, - entityType: Commodity.entityType, - }), - ); - }); - }); -}); 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..476c3ec9 --- /dev/null +++ b/apps/backend/src/application/usecases/commodities/__tests__/closeCommodity.test.ts @@ -0,0 +1,186 @@ +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(), + getByIdForLifecycle: 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.getByIdForLifecycle.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.getByIdForLifecycle.mockResolvedValue( + commodity.toSnapshot(), + ); + commodityRepository.close.mockResolvedValue(undefined); + + vi.setSystemTime(new Date(updatedAt)); + + const result = await closeCommodityUseCase.execute(user, commodityId); + + expect(commodityRepository.getByIdForLifecycle).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.getByIdForLifecycle.mockResolvedValue( + commodity.toSnapshot(), + ); + + const result = await closeCommodityUseCase.execute(user, commodityId); + + expect(commodityRepository.getByIdForLifecycle).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.getByIdForLifecycle.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 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, { + code: 'TEST', + name: 'Test Commodity', + precision: 2, + symbol: 'T', + }); + const commodityId = commodity.getId().valueOf(); + + commodityRepository.getByIdForLifecycle.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__/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/__tests__/deleteCommodity.test.ts b/apps/backend/src/application/usecases/commodities/__tests__/deleteCommodity.test.ts new file mode 100644 index 00000000..a200aa06 --- /dev/null +++ b/apps/backend/src/application/usecases/commodities/__tests__/deleteCommodity.test.ts @@ -0,0 +1,239 @@ +import { + CommodityRepositoryInterface, + 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'; +import { Id, Timestamp } from 'src/domain/domain-core/'; +import { User } from 'src/domain/users/user.entity'; +import { createUser } from 'src/testing/helpers'; +import { describe, vi, beforeAll, beforeEach, expect, it } from 'vitest'; + +import { DeleteCommodityUseCase } from '../deleteCommodity'; + +describe('DeleteCommodityUseCase', () => { + let user: User; + + const commodityRepository = { + delete: vi.fn(), + getByIdForLifecycle: vi.fn(), + }; + + 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, + ); + + beforeAll(async () => { + user = await createUser(); + }); + + beforeEach(() => { + vi.useRealTimers(); + mockTransactionManager.run.mockImplementation(runTransaction); + commodityReferencePolicy.assertNoActiveReferences.mockResolvedValue( + undefined, + ); + commodityRepository.delete.mockClear(); + commodityRepository.getByIdForLifecycle.mockClear(); + commodityReferencePolicy.assertNoActiveReferences.mockClear(); + mockTransactionManager.run.mockClear(); + }); + + describe('execute', () => { + it('should delete a commodity successfully', async () => { + vi.useFakeTimers(); + + const timestampBeforeUpdatingValue = '2024-01-01T00:00:00.000Z'; + const timestampDuringUpdatingValue = '2025-01-01T00:00:01.000Z'; + + vi.setSystemTime(new Date(timestampBeforeUpdatingValue)); + + // Arrange + const commodity = createCommodity(user, { + code: 'TEST', + name: 'Test Commodity', + precision: 2, + symbol: 'T', + }); + + const commodityId = commodity.getId().valueOf(); + const userid = user.getId().valueOf(); + + const updatedAt = Timestamp.restore( + timestampDuringUpdatingValue, + ).valueOf(); + + commodityRepository.getByIdForLifecycle.mockResolvedValue( + commodity.toSnapshot(), + ); + commodityRepository.delete.mockResolvedValue(undefined); + + vi.setSystemTime(new Date(timestampDuringUpdatingValue)); + + // Act + const result = await deleteCommodityUseCase.execute(user, commodityId); + + // Assert + expect(commodityRepository.getByIdForLifecycle).toHaveBeenCalledWith( + userid, + commodityId, + ); + + expect(commodityRepository.delete).toHaveBeenCalledWith( + userid, + commodityId, + { updatedAt }, + ); + expect( + commodityReferencePolicy.assertNoActiveReferences, + ).toHaveBeenCalledWith(userid, commodityId); + + 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(), + ); + + const result = await deleteCommodityUseCase.execute(user, commodityId); + + expect(commodityRepository.getByIdForLifecycle).toHaveBeenCalledWith( + user.getId().valueOf(), + commodityId, + ); + expect( + commodityReferencePolicy.assertNoActiveReferences, + ).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', + 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(); + + commodityRepository.getByIdForLifecycle.mockResolvedValue(null); + + // Act & Assert + await expect( + deleteCommodityUseCase.execute(user, nonExistentCommodityId), + ).rejects.toThrowError( + new EntityNotFoundError({ + entityId: nonExistentCommodityId, + entityType: Commodity.entityType, + }), + ); + }); + + it('should throw an error if the commodity does not belong to the user', async () => { + // Arrange + const otherUser = await createUser(); + const commodity = createCommodity(otherUser, { + code: 'TEST', + name: 'Test Commodity', + precision: 2, + symbol: 'T', + }); + + const commodityId = commodity.getId().valueOf(); + + commodityRepository.getByIdForLifecycle.mockResolvedValue( + commodity.toSnapshot(), + ); + + // Act & Assert + await expect( + deleteCommodityUseCase.execute(user, commodityId), + ).rejects.toThrowError( + new UnauthorizedAccessError({ + entityId: commodityId, + entityType: Commodity.entityType, + }), + ); + }); + }); +}); 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..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,17 +52,19 @@ describe('GetAllCommoditiesUseCase', () => { }, ]; - const queryStatus = 'active'; + const status = 'open'; 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/__tests__/openCommodity.test.ts b/apps/backend/src/application/usecases/commodities/__tests__/openCommodity.test.ts new file mode 100644 index 00000000..58d2ee62 --- /dev/null +++ b/apps/backend/src/application/usecases/commodities/__tests__/openCommodity.test.ts @@ -0,0 +1,186 @@ +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 = { + getByIdForLifecycle: 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.getByIdForLifecycle.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.getByIdForLifecycle.mockResolvedValue( + commodity.toSnapshot(), + ); + commodityRepository.open.mockResolvedValue(undefined); + + vi.setSystemTime(new Date(updatedAt)); + + const result = await openCommodityUseCase.execute(user, commodityId); + + expect(commodityRepository.getByIdForLifecycle).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.getByIdForLifecycle.mockResolvedValue( + commodity.toSnapshot(), + ); + + const result = await openCommodityUseCase.execute(user, commodityId); + + expect(commodityRepository.getByIdForLifecycle).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.getByIdForLifecycle.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 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, { + code: 'TEST', + name: 'Test Commodity', + precision: 2, + symbol: 'T', + }); + const commodityId = commodity.getId().valueOf(); + + commodityRepository.getByIdForLifecycle.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/__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/archiveCommodity.ts b/apps/backend/src/application/usecases/commodities/archiveCommodity.ts deleted file mode 100644 index c17862f9..00000000 --- a/apps/backend/src/application/usecases/commodities/archiveCommodity.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 { EnsureOwnedSnapshotFn } from 'src/application/shared/ensureOwnedSnapshot'; -import { Commodity, CommoditySnapshot } from 'src/domain/commodities'; -import { User } from 'src/domain/users/user.entity'; - -export class ArchiveCommodityUseCase { - constructor( - protected readonly commodityRepository: CommodityRepositoryInterface, - protected readonly ensureOwnedSnapshot: EnsureOwnedSnapshotFn, - ) {} - - 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({ - entityId: commodityId, - entityType: Commodity.entityType, - }); - } - - const commodity = Commodity.restore(commodityData); - - commodity.markAsDeleted(); - - const updatedCommodity = await this.commodityRepository.softDelete( - user.getId().valueOf(), - commodityId, - { updatedAt: commodity.getUpdatedAt().valueOf() }, - ); - - return CommodityMapper.toResponseDTOFromSnapshot(updatedCommodity); - } -} 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..5759b094 --- /dev/null +++ b/apps/backend/src/application/usecases/commodities/closeCommodity.ts @@ -0,0 +1,57 @@ +import { CommodityResponseDTO, UUID } from '@ledgerly/shared/types'; +import { CommodityMapper } from 'src/application'; +import { EntityNotFoundError } from 'src/application/application.errors'; +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.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') { + 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/deleteCommodity.ts b/apps/backend/src/application/usecases/commodities/deleteCommodity.ts new file mode 100644 index 00000000..82d24896 --- /dev/null +++ b/apps/backend/src/application/usecases/commodities/deleteCommodity.ts @@ -0,0 +1,53 @@ +import { UUID } from '@ledgerly/shared/types'; +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'; + +export class DeleteCommodityUseCase { + constructor( + protected readonly commodityRepository: CommodityRepositoryInterface, + protected readonly ensureOwnedSnapshot: EnsureOwnedSnapshotFn, + private readonly commodityReferencePolicy: CommodityReferencePolicy, + private readonly transactionManager: TransactionManagerInterface, + ) {} + + 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); + + if (commodity.isDeleted()) { + return; + } + + await this.commodityReferencePolicy.assertNoActiveReferences( + user.getId().valueOf(), + commodityId, + ); + + commodity.delete(); + + await this.commodityRepository.delete( + user.getId().valueOf(), + commodityId, + { + updatedAt: commodity.getUpdatedAt().valueOf(), + }, + ); + }); + } +} 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/application/usecases/commodities/index.ts b/apps/backend/src/application/usecases/commodities/index.ts index 68430416..b52564e6 100644 --- a/apps/backend/src/application/usecases/commodities/index.ts +++ b/apps/backend/src/application/usecases/commodities/index.ts @@ -2,4 +2,6 @@ export { CreateCommodityUseCase } from './createCommodity'; export { UpdateCommodityUseCase } from './updateCommodity'; export { GetAllCommoditiesUseCase } from './getAllCommodities'; export { GetCommodityByIdUseCase } from './getCommodityById'; -export { ArchiveCommodityUseCase } from './archiveCommodity'; +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..c5a38352 --- /dev/null +++ b/apps/backend/src/application/usecases/commodities/openCommodity.ts @@ -0,0 +1,57 @@ +import { CommodityResponseDTO, UUID } from '@ledgerly/shared/types'; +import { CommodityMapper } from 'src/application'; +import { EntityNotFoundError } from 'src/application/application.errors'; +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.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') { + await this.commodityRepository.open( + user.getId().valueOf(), + commodityId, + { + updatedAt: commodity.getUpdatedAt().valueOf(), + }, + ); + } + + return commodity.toSnapshot(); + }); + + return CommodityMapper.toResponseDTOFromSnapshot(commoditySnapshot); + } +} diff --git a/apps/backend/src/application/usecases/commodities/updateCommodity.ts b/apps/backend/src/application/usecases/commodities/updateCommodity.ts index 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/db/schemas/accounts.ts b/apps/backend/src/db/schemas/accounts.ts index 27163f8b..165a7a53 100644 --- a/apps/backend/src/db/schemas/accounts.ts +++ b/apps/backend/src/db/schemas/accounts.ts @@ -1,7 +1,12 @@ -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'; +import { + foreignKey, + sqliteTable, + text, + uniqueIndex, +} from 'drizzle-orm/sqlite-core'; import { commoditiesTable } from './commodities'; import { @@ -10,6 +15,7 @@ import { updatedAt, id, isTombstone, + isClosed, getAmountColumn, isSystem, } from './common'; @@ -18,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', @@ -29,11 +32,12 @@ export const accountsTable = sqliteTable( description, id, initialBalance: getAmountColumn('initial_balance'), + isClosed, isSystem, isTombstone, name: text('name').notNull(), type: text('type', { - enum: ACCOUNT_TYPE_VALUES, + enum: ACCOUNT_TYPES, }).notNull(), updatedAt, userId: text('user_id') @@ -42,6 +46,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 4a919a6e..f499e34e 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(), @@ -36,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/db/schemas/common.ts b/apps/backend/src/db/schemas/common.ts index 48d8ad0c..03f5c8fe 100644 --- a/apps/backend/src/db/schemas/common.ts +++ b/apps/backend/src/db/schemas/common.ts @@ -13,28 +13,20 @@ 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 isClosed = getBooleanColumn('is_closed'); 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..10a126d3 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; @@ -393,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, @@ -416,6 +419,7 @@ export class TestDB { userId: commodityData.userId, ...TestDB.createTimestamps, ...TestDB.uuid, + isClosed: commodityData.isClosed, isTombstone: commodityData.isTombstone, }) .returning() @@ -433,13 +437,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 +460,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 +475,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 +735,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..b8f6d9c2 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, - ArchiveAccountUseCase, + DeleteAccountUseCase, GetAccountByIdUseCase, GetAllAccountsUseCase, + OpenAccountUseCase, UpdateAccountUseCase, DeleteTransactionUseCase, GetAllTransactionsUseCase, @@ -14,10 +16,16 @@ import { UpdateTransactionUseCase, CreateTransactionUseCase, UpdateCommodityUseCase, - ArchiveCommodityUseCase, + DeleteCommodityUseCase, CreateCommodityUseCase, + CloseCommodityUseCase, + OpenCommodityUseCase, } from 'src/application'; -import { TransactionContextLoader } from 'src/application/services/TransactionService'; +import { + AccountOperationPolicy, + CommodityReferencePolicy, + TransactionContextLoader, +} from 'src/application/services'; import { ensureEntityExistsAndOwned } from 'src/application/shared/ensureEntityExistsAndOwned'; import { ensureOwnedSnapshot } from 'src/application/shared/ensureOwnedSnapshot'; import { DataBase } from 'src/db'; @@ -62,6 +70,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 +82,16 @@ export const createContainer = (db: DataBase): AppContainer => { accountRepository, ); + const accountOperationPolicy = new AccountOperationPolicy( + operationRepository, + ); + + const commodityReferencePolicy = new CommodityReferencePolicy( + commodityRepository, + accountRepository, + transactionRepository, + ); + const passwordManager = new PasswordManager(); const services: AppContainer['services'] = { @@ -80,11 +99,32 @@ export const createContainer = (db: DataBase): AppContainer => { }; // Create Account Use Cases - const createAccountUseCase = new CreateAccountUseCase(accountRepository); + const createAccountUseCase = new CreateAccountUseCase( + accountRepository, + transactionManager, + commodityReferencePolicy, + ); 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, + transactionManager, + ); + const deleteAccountUseCase = new DeleteAccountUseCase( + accountRepository, + accountOperationPolicy, + transactionManager, + ensureOwnedSnapshot, + ); + const closeAccountUseCase = new CloseAccountUseCase( + accountRepository, + transactionManager, + ); + const openAccountUseCase = new OpenAccountUseCase( + accountRepository, + transactionManager, + ); const loginUserUseCase = new LoginUserUseCase(userRepository); @@ -129,31 +169,50 @@ export const createContainer = (db: DataBase): AppContainer => { const updateCommodityUseCase = new UpdateCommodityUseCase( commodityRepository, ensureOwnedSnapshot, + transactionManager, ); - const archiveCommodityUseCase = new ArchiveCommodityUseCase( + const deleteCommodityUseCase = new DeleteCommodityUseCase( commodityRepository, ensureOwnedSnapshot, + commodityReferencePolicy, + transactionManager, ); const createCommodityUseCase = new CreateCommodityUseCase( commodityRepository, ); + const closeCommodityUseCase = new CloseCommodityUseCase( + commodityRepository, + ensureOwnedSnapshot, + transactionManager, + ); + + const openCommodityUseCase = new OpenCommodityUseCase( + commodityRepository, + ensureOwnedSnapshot, + transactionManager, + ); + const commodityController = new CommodityController( getCommodityByIdUseCase, getAllCommoditiesUseCase, createCommodityUseCase, updateCommodityUseCase, - archiveCommodityUseCase, + deleteCommodityUseCase, + closeCommodityUseCase, + openCommodityUseCase, ); const useCases: AppContainer['useCases'] = { account: { - archiveAccount: archiveAccountUseCase, + closeAccount: closeAccountUseCase, createAccount: createAccountUseCase, + deleteAccount: deleteAccountUseCase, getAccountById: getAccountByIdUseCase, getAllAccounts: getAllAccountsUseCase, + openAccount: openAccountUseCase, updateAccount: updateAccountUseCase, }, auth: { @@ -161,10 +220,12 @@ export const createContainer = (db: DataBase): AppContainer => { registerUser: registerUserUseCase, }, commodity: { - archiveCommodity: archiveCommodityUseCase, + closeCommodity: closeCommodityUseCase, createCommodity: createCommodityUseCase, + deleteCommodity: deleteCommodityUseCase, getAllCommodities: getAllCommoditiesUseCase, getCommodityById: getCommodityByIdUseCase, + openCommodity: openCommodityUseCase, updateCommodity: updateCommodityUseCase, }, transaction: { @@ -181,7 +242,9 @@ export const createContainer = (db: DataBase): AppContainer => { useCases.account.getAllAccounts, useCases.account.createAccount, useCases.account.updateAccount, - useCases.account.archiveAccount, + 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 472035b6..31cb48d8 100644 --- a/apps/backend/src/di/types.ts +++ b/apps/backend/src/di/types.ts @@ -4,9 +4,11 @@ import { LoginUserUseCase, RegisterUserUseCase, CreateAccountUseCase, - ArchiveAccountUseCase, + CloseAccountUseCase, + DeleteAccountUseCase, GetAccountByIdUseCase, GetAllAccountsUseCase, + OpenAccountUseCase, UpdateAccountUseCase, DeleteTransactionUseCase, GetAllTransactionsUseCase, @@ -14,8 +16,10 @@ import { UpdateTransactionUseCase, CreateTransactionUseCase, UpdateCommodityUseCase, - ArchiveCommodityUseCase, + DeleteCommodityUseCase, CreateCommodityUseCase, + CloseCommodityUseCase, + OpenCommodityUseCase, } from 'src/application'; import { DataBase } from 'src/db'; import { PasswordManager } from 'src/infrastructure/auth/PasswordManager'; @@ -25,6 +29,7 @@ import { TransactionRepository, CommodityRepository, UserRepository, + OperationRepository, } from 'src/infrastructure/db'; import { AccountController, @@ -40,6 +45,7 @@ type Repositories = { transactionQuery: TransactionQueryRepository; account: AccountRepository; user: UserRepository; + operation: OperationRepository; }; type Services = { @@ -47,11 +53,13 @@ type Services = { }; type AccountUseCases = { + closeAccount: CloseAccountUseCase; createAccount: CreateAccountUseCase; getAllAccounts: GetAllAccountsUseCase; getAccountById: GetAccountByIdUseCase; + openAccount: OpenAccountUseCase; updateAccount: UpdateAccountUseCase; - archiveAccount: ArchiveAccountUseCase; + deleteAccount: DeleteAccountUseCase; }; type AuthUseCases = { @@ -68,11 +76,13 @@ type TransactionUseCases = { }; type CommodityUseCases = { + closeCommodity: CloseCommodityUseCase; + createCommodity: CreateCommodityUseCase; + deleteCommodity: DeleteCommodityUseCase; getCommodityById: GetCommodityByIdUseCase; getAllCommodities: GetAllCommoditiesUseCase; + openCommodity: OpenCommodityUseCase; updateCommodity: UpdateCommodityUseCase; - archiveCommodity: ArchiveCommodityUseCase; - createCommodity: CreateCommodityUseCase; }; type Controllers = { 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..d7fdddb4 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 open 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 updatedAtBeforeOpen = 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( + updatedAtBeforeOpen.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..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'; @@ -21,7 +22,6 @@ import { export class Account { static readonly entityType = 'account'; - private constructor( private readonly identity: EntityIdentity, private timestamps: EntityTimestamps, @@ -33,6 +33,7 @@ export class Account { private initialBalance: Amount, private currentClearedBalanceLocal: Amount, private type: AccountType, + private isClosed: boolean, public isSystem: boolean, ) {} @@ -64,6 +65,7 @@ export class Account { props.initialBalance, Amount.create('0'), props.type, + false, isSystem, ); } @@ -76,6 +78,7 @@ export class Account { description, id, initialBalance, + isClosed, isSystem, isTombstone, name, @@ -114,6 +117,7 @@ export class Account { Amount.restore(initialBalance), Amount.restore(currentClearedBalanceLocal), AccountType.restore(type), + isClosed, isSystem, ); } @@ -138,11 +142,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 +160,9 @@ export class Account { } private validateUpdateIsAllowed(): void { - this.softDelete.validateUpdateIsAllowed(); + this.softDelete.validateUpdateIsAllowed( + DeletedEntityOperationError.forUpdate(Account.entityType), + ); } // Delegation methods for ownership @@ -166,6 +178,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 +207,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/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'; 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/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/behaviors/SoftDelete.ts b/apps/backend/src/domain/domain-core/behaviors/SoftDelete.ts index baac8991..f7bdff37 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.defaultDeleteError()); 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,14 @@ export class SoftDelete { return this.isTombstone; } + static defaultUpdateError(): Error { + 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 e79202b6..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( - 'Cannot delete a deleted entity', + SoftDelete.defaultDeleteError(), ); }); 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/domain/domain.errors.test.ts b/apps/backend/src/domain/domain.errors.test.ts index d1791959..e8b914a5 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, @@ -39,6 +40,40 @@ 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 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 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/domain/domain.errors.ts b/apps/backend/src/domain/domain.errors.ts index 73f36d15..9e140e79 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,33 @@ 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'); + } +} + +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'); + } } /** 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..db78095b 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,17 @@ 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, + ForeignKeyConstraintError, RecordAlreadyExistsError, RepositoryInvariantError, RepositoryNotFoundError, @@ -21,9 +29,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 +40,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 +80,7 @@ describe('AccountRepository', () => { beforeEach(async () => { testDB = new TestDB(); + await testDB.setupTestDb(); user = await testDB.createUser(); @@ -88,7 +95,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 +103,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 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', @@ -121,10 +134,10 @@ describe('AccountRepository', () => { await expect( accountRepository.create(secondUser.id, newAccount), - ).rejects.toThrowError(RepositoryNotFoundError); + ).rejects.toThrowError(ForeignKeyConstraintError); }); - 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 +155,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,13 +178,16 @@ 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({ 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, @@ -181,36 +197,41 @@ describe('AccountRepository', () => { }); const secondUserAccount = getAccountData({ - commodityId: usdCommodity.id, + commodityId: secondUserCommodity.id, name: accountName, type: 'asset', userId: secondUser.id, }); - const account1 = await testDB.createAccount(user.id, usdCommodity.id, { - ...firstUserAccount, - }); - const account2 = await testDB.createAccount( - secondUser.id, + const retrievedAccount1 = await testDB.createAccount( + user.id, usdCommodity.id, + { + ...firstUserAccount, + }, + ); + + const retrievedAccount2 = await testDB.createAccount( + secondUser.id, + secondUserCommodity.id, { ...secondUserAccount, }, ); - 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('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, @@ -226,84 +247,219 @@ 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 () => { + 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 closedAccounts = 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' }, closedAccounts], + ]; - 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 secondUserCommodity = await testDB.createCommodity(secondUser.id, { + code: CommodityCode.create('USD').valueOf(), + }); + + 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, secondUserCommodity.id, { + isClosed: account.isClosed, + isTombstone: account.isTombstone, + name: account.name, + type: 'asset', + }), + ), + ); - expect(accounts.length).toBe(0); + const accounts = await accountRepository.getAll(secondUser.id, { + status: 'all', + }); + + 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 +467,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 +568,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 +578,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 +597,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,17 +631,26 @@ 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', }); + 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', @@ -431,22 +658,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 +697,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); + + 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); - it('should only update allowed fields', async () => { + 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 +740,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); + + const deletedAccount = await testDB.getAccountById(account.id); - expect(deleted).toBeDefined(); - expect(deleted.isTombstone).toBe(true); - expect(deleted.updatedAt).toBe(deletedData.updatedAt); - expect(deleted.updatedAt).not.toBe(account.updatedAt); + 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 +785,415 @@ 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', }); + const secondUserCommodity = await testDB.createCommodity(secondUser.id, { + code: CommodityCode.create('USD').valueOf(), + }); + + const createdAccount = await testDB.createAccount( + secondUser.id, + secondUserCommodity.id, + { + initialBalance: Amount.create('2000').valueOf(), + name: 'Shared Account Name', + type: 'asset', + }, + ); + + await expect( + accountRepository.delete(user.id, createdAccount.id, { + updatedAt: Timestamp.create().valueOf(), + }), + ).rejects.toThrowError(RepositoryNotFoundError); + + const retrievedAccount = await testDB.getAccountById(createdAccount.id); + + compareEntities(retrievedAccount!, createdAccount); + }); + + 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(), + }); - await testDB.createAccount(secondUser.id, usdCommodity.id, { + 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(), - name: 'Shared Account Name', - type: 'asset', + 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 promise = accountRepository.close(user.id, Id.create().valueOf(), { + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }); + + 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, { + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }); + + await expect(promise).rejects.toThrowError(RepositoryNotFoundError); + }); + + it('is idempotent for an already closed account', async () => { + 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, { + updatedAt: ignoredUpdatedAt, + }); + + 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(secondUser.id, account.id, { + accountRepository.close(user.id, account.id, { 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(); - const secondUserAccounts = await accountRepository.getAll(secondUser.id); + await accountRepository.open(user.id, account.id, { + updatedAt, + }); + + const retrievedAccount = await accountRepository.getById( + user.id, + account.id, + ); - expect(secondUserAccounts.length).toBe(1); + compareEntities(retrievedAccount, account, ['isClosed', 'updatedAt']); + expect(retrievedAccount?.isClosed).toBe(false); + expect(retrievedAccount?.updatedAt).toBe(updatedAt); }); - it('should throw RepositoryNotFoundError when account does not exist', async () => { - const result = accountRepository.softDelete( + it('should be idempotent for an already open account', async () => { + const account = await testDB.createAccount(user.id, usdCommodity.id, { + isClosed: false, + }); + + 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, { + updatedAt: initialUpdatedAt, + }); + + const initialOpenAccount = await accountRepository.getById( user.id, - Id.create().valueOf(), - { - updatedAt: Timestamp.create().valueOf(), - }, + account.id, ); - await expect(result).rejects.toThrowError(RepositoryNotFoundError); + await accountRepository.open(user.id, account.id, { + updatedAt: ignoredUpdatedAt, + }); + + const retrievedOpenAccount = await accountRepository.getById( + user.id, + account.id, + ); + + compareEntities(initialOpenAccount, retrievedOpenAccount); }); - it('should throw RepositoryNotFoundError when account is already tombstoned', async () => { - await accountRepository.softDelete(user.id, account.id, { + 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, + }); + await expect( - accountRepository.softDelete(user.id, account.id, { - updatedAt: Timestamp.restore('2030-01-02T00:00:00.000Z').valueOf(), + accountRepository.open(user.id, account.id, { + 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, { + 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 secondUserCommodity = await testDB.createCommodity(secondUser.id, { + code: CommodityCode.create('USD').valueOf(), + }); + + const account = await testDB.createAccount( + secondUser.id, + secondUserCommodity.id, + ); + + await expect( + accountRepository.ensureUserOwnsAccount(user.id, account.id), + ).rejects.toThrowError(ForbiddenAccessError); + }); + }); + + 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'); + + 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 +1202,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 +1232,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..1b50f96a 100644 --- a/apps/backend/src/infrastructure/db/accounts/account.repository.ts +++ b/apps/backend/src/infrastructure/db/accounts/account.repository.ts @@ -1,34 +1,85 @@ import { UUID } from '@ledgerly/shared/types'; -import { and, eq, inArray } from 'drizzle-orm'; +import { AccountQuery } from '@ledgerly/shared/validation'; +import { and, eq, inArray, sql } from 'drizzle-orm'; import { + type AccountLifecycleUpdateInput, type AccountRepositoryInterface, - type AccountRepositorySoftDeleteInput, + type AccountRepositoryLifecycleInput, type AccountRepositoryUpdateInput, + 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 { RepositoryInvariantError } from 'src/infrastructure/errors'; +import { + AccountPersistenceConflictError, + RepositoryInvariantError, + RepositoryNotFoundError, +} 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), + ); + } + + return and( + eq(accountsTable.userId, userId), + eq(accountsTable.isClosed, status !== 'open'), + eq(accountsTable.isTombstone, false), + ); +}; + +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< + AccountLifecycleAction, + { 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 { + 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); + 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,7 +88,7 @@ export class AccountRepository }, 'Failed to fetch accounts'); } - create(userId: UUID, data: AccountSnapshot): Promise { + create(userId: UUID, data: AccountSnapshot): Promise { return this.executeDatabaseOperation( async () => { if (data.userId !== userId) { @@ -46,34 +97,17 @@ export class AccountRepository ); } - 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(); + const result = await this.db.insert(accountsTable).values({ + ...AccountPersistenceMapper.toDBRowFromSnapshot(data), + currentClearedBalanceLocal: data.currentClearedBalanceLocal ?? '0', + }); - this.ensureEntityExists( - existingCommodity, - `Commodity with ID ${data.commodityId} not found`, - this.entityNotFoundContext('commodity', data.commodityId), + this.ensureRowsAffected( + result.rowsAffected, + new AccountPersistenceConflictError( + `Failed to create account with ID ${data.id}`, + ), ); - - const account = await this.db - .insert(accountsTable) - .values({ - ...AccountPersistenceMapper.toDBRowFromSnapshot(data), - currentClearedBalanceLocal: data.currentClearedBalanceLocal ?? '0', - }) - .returning() - .get(); - - return AccountPersistenceMapper.toSnapshot(account); }, 'Failed to create account', { @@ -91,18 +125,18 @@ export class AccountRepository ); } - getById(userId: UUID, id: UUID): Promise { + private getByIdInternal( + userId: UUID, + id: UUID, + options: { includeTombstone: boolean }, + ): Promise { return this.executeDatabaseOperation(async () => { + const whereClause = getWhereClauseForGetByIdInternal(userId, id, options); + const account = await this.db .select() .from(accountsTable) - .where( - and( - eq(accountsTable.id, id), - eq(accountsTable.userId, userId), - eq(accountsTable.isTombstone, false), - ), - ) + .where(whereClause) .get(); const existingAccount = this.ensureEntityExists( @@ -115,12 +149,21 @@ 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 }); + } + + 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', @@ -130,7 +173,7 @@ export class AccountRepository 'updatedAt', ]); - const updatedAccount = await this.db + const result = await this.db .update(accountsTable) .set(safeData) .where( @@ -139,17 +182,12 @@ export class AccountRepository eq(accountsTable.userId, userId), eq(accountsTable.isTombstone, false), ), - ) - .returning() - .get(); + ); - const existingAccount = this.ensureEntityExists( - updatedAccount, - `Account with ID ${id} not found`, - this.entityNotFoundContext('account', id), + this.ensureRowsAffected( + result.rowsAffected, + this.accountNotFoundError(id), ); - - return AccountPersistenceMapper.toSnapshot(existingAccount); }, `Failed to update account with ID ${id}`, { @@ -160,15 +198,19 @@ export class AccountRepository ); } - async softDelete( + private async updateLifecycle( userId: UUID, id: UUID, - data: AccountRepositorySoftDeleteInput, - ): Promise { - return this.executeDatabaseOperation(async () => { - const deletedAccount = await this.db - .update(accountsTable) - .set({ isTombstone: true, updatedAt: data.updatedAt }) + 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), @@ -176,16 +218,81 @@ export class AccountRepository eq(accountsTable.isTombstone, false), ), ) - .returning() .get(); const existingAccount = this.ensureEntityExists( - deletedAccount, + account, `Account with ID ${id} not found`, this.entityNotFoundContext('account', id), ); - return AccountPersistenceMapper.toSnapshot(existingAccount); + if (existingAccount.isClosed === targetIsClosed) { + return; + } + + const result = await this.db + .update(accountsTable) + .set({ ...params, updatedAt }) + .where( + and( + eq(accountsTable.id, id), + eq(accountsTable.userId, userId), + eq(accountsTable.isTombstone, false), + ), + ); + + this.ensureRowsAffected( + result.rowsAffected, + this.accountNotFoundError(id), + ); + + // No return value for lifecycle updates + }, `Failed to ${name} account with ID ${id}`); + } + + async open( + userId: UUID, + id: UUID, + data: AccountRepositoryLifecycleInput, + ): Promise { + await this.updateLifecycle(userId, id, { + action: 'open', + updatedAt: data.updatedAt, + }); + } + + async close( + userId: UUID, + id: UUID, + data: AccountRepositoryLifecycleInput, + ): 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 result = await this.db + .update(accountsTable) + .set({ isTombstone: true, updatedAt: data.updatedAt }) + .where( + and( + eq(accountsTable.id, id), + eq(accountsTable.userId, userId), + eq(accountsTable.isTombstone, false), + ), + ); + + this.ensureRowsAffected( + result.rowsAffected, + this.accountNotFoundError(id), + ); }, `Failed to soft delete account with ID ${id}`); } @@ -207,6 +314,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', @@ -217,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/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 fd867075..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,12 +1,16 @@ -import { apiErrorCodes, type QueryStatus } from '@ledgerly/shared/types'; +import { apiErrorCodes } from '@ledgerly/shared/types'; +import { CommodityQuery } from '@ledgerly/shared/validation'; +import dayjs from 'dayjs'; import { type CommodityRepositoryUpdateInput } from 'src/application'; import type { CommodityDbRow } from 'src/db/schemas/commodities'; import { UserDbRow } from 'src/db/schemas/users'; +import { 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'; import { RecordAlreadyExistsError, + RepositoryInvariantError, RepositoryNotFoundError, } from 'src/infrastructure/errors'; import { describe, vi, beforeEach, it, expect } from 'vitest'; @@ -74,6 +78,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(); @@ -83,92 +97,174 @@ 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 queryStatusCases: { - expectedTombstoneValues: boolean[]; - status: QueryStatus; - }[] = [ - { expectedTombstoneValues: [false, false], status: 'active' }, - { expectedTombstoneValues: [true], status: 'archived' }, - { expectedTombstoneValues: [false, false, true], status: 'all' }, + const commoditiesDataList = [ + { + 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(queryStatusCases)( - 'should return $status commodities for the user', - async ({ expectedTombstoneValues, status }) => { - 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, status); + const closedCommodities = commoditiesDataList.filter( + (commodity) => commodity.isClosed && !commodity.isTombstone, + ); - const expectedCommodities = - status === 'active' - ? activeCommodities - : 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], + ]; + + 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).toEqual( - expect.arrayContaining(expectedCommodities), - ); + it.each(testData)( + 'returns commodities matching query %s', + async ({ status }: CommodityQuery, expectedCommodities: TestData[]) => { + const commodities = await commodityRepository.getAll(user.id, { + status, + }); - expect(commodities).toHaveLength(expectedCommodities.length); + compareEntityArrays(commodities, expectedCommodities, { + fields: ['code', 'name', 'isClosed', 'isTombstone'], + }); - expect( - commodities.map(({ isTombstone }) => isTombstone).sort(), - ).toEqual(expectedTombstoneValues.sort()); + expect(commodities.length).toBe(expectedCommodities.length); }, ); 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: '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, 'active'); + 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(expect.arrayContaining([commodity2])); - expect(commodities).not.toEqual(expect.arrayContaining([commodity1])); + expect(commodities).toEqual([]); }); - it.each(['active', 'archived', 'all'])( - 'should not return commodities belonging to other users for %s status', - async (status) => { - const commodity1 = await testDB.createCommodity(user.id); + it.each([ + { status: 'open' }, + { status: 'closed' }, + { status: 'all' }, + ])( + 'should not return commodities belonging to other users for $status.status status', + async ({ status }) => { 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); + 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'], + }); }, ); }); @@ -179,6 +275,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, @@ -187,19 +284,35 @@ 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 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 () => { @@ -209,6 +322,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, @@ -239,6 +353,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, @@ -247,19 +362,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); }); }); @@ -277,22 +387,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 () => { @@ -315,10 +422,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({ @@ -373,7 +481,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(), }); @@ -387,33 +495,77 @@ 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('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(), }); - 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 () => { 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); @@ -423,22 +575,308 @@ 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); }); + + 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', () => { + 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(); + + await commodityRepository.close(user.id, commodityDbRow.id, { + 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, commodityDbRow, [ + '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, + ); + + await commodityRepository.close(user.id, commodityDbRow.id, { + updatedAt: ignoredUpdatedAt, + }); + + 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(); + + await commodityRepository.open(user.id, closedCommodityDbRow.id, { + 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, closedCommodityDbRow, [ + '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, + ); + + await commodityRepository.open(user.id, closedCommodityDbRow.id, { + updatedAt: ignoredUpdatedAt, + }); + + 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); + }); + }); + + 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 5587fe5d..f918e565 100644 --- a/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts +++ b/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts @@ -1,30 +1,92 @@ -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 CommodityLifecycleAction, + type CommodityLifecycleUpdateInput, 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'; -import { RepositoryInvariantError } from 'src/infrastructure/errors'; +import { + RepositoryInvariantError, + RepositoryNotFoundError, +} from 'src/infrastructure/errors'; import { BaseRepository } from '../BaseRepository'; import { CommodityPersistenceMapper } from './commodity-persistence.mapper'; +const getWhereClauseForGetAll = (userId: UUID, query: CommodityQuery) => { + const { status } = query; + + if (status === 'all') { + return and( + eq(commoditiesTable.userId, userId), + eq(commoditiesTable.isTombstone, false), + ); + } + + return and( + eq(commoditiesTable.userId, userId), + eq(commoditiesTable.isClosed, status !== 'open'), + eq(commoditiesTable.isTombstone, false), + ); +}; + +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 } +> = { + close: { name: 'close', params: { isClosed: true } }, + open: { name: 'open', params: { isClosed: false } }, +}; + 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( @@ -37,15 +99,17 @@ export class CommodityRepository }, 'Failed to fetch commodity'); } - getAll(userId: UUID, status: QueryStatus): Promise { + 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 = - 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() @@ -59,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) { @@ -74,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', { @@ -97,7 +156,7 @@ export class CommodityRepository userId: UUID, commodityId: UUID, commodity: CommodityRepositoryUpdateInput, - ): Promise { + ): Promise { return this.executeDatabaseOperation( async () => { const safeData = this.getSafeUpdate(commodity, [ @@ -107,7 +166,7 @@ export class CommodityRepository 'updatedAt', ]); - const updatedCommodity = await this.db + const result = await this.db .update(commoditiesTable) .set(safeData) .where( @@ -116,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', { @@ -139,15 +193,43 @@ export class CommodityRepository ); } - softDelete( + delete( userId: UUID, commodityId: UUID, - data: CommodityRepositorySoftDeleteInput, - ): Promise { + data: CommodityRepositoryDeleteInput, + ): Promise { return this.executeDatabaseOperation(async () => { - const deletedCommodity = await this.db + const result = await this.db .update(commoditiesTable) .set({ isTombstone: true, updatedAt: data.updatedAt }) + .where( + and( + eq(commoditiesTable.userId, userId), + eq(commoditiesTable.id, commodityId), + eq(commoditiesTable.isTombstone, false), + ), + ); + + this.ensureRowsAffected( + result.rowsAffected, + this.commodityNotFoundError(commodityId), + ); + }, `Failed to 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), @@ -155,16 +237,55 @@ export class CommodityRepository eq(commoditiesTable.isTombstone, false), ), ) - .returning() .get(); const existingCommodity = this.ensureEntityExists( - deletedCommodity, - `Commodity with ID ${commodityId} not found after deletion`, + commodity, + `Commodity with ID ${commodityId} not found`, this.entityNotFoundContext('commodity', commodityId), ); - return CommodityPersistenceMapper.toSnapshot(existingCommodity); - }, `Failed to soft delete commodity with ID ${commodityId}`); + if (existingCommodity.isClosed === targetIsClosed) { + return; + } + + const result = await this.db + .update(commoditiesTable) + .set({ ...params, updatedAt }) + .where( + and( + eq(commoditiesTable.userId, userId), + eq(commoditiesTable.id, commodityId), + eq(commoditiesTable.isTombstone, false), + ), + ); + + this.ensureRowsAffected( + result.rowsAffected, + this.commodityNotFoundError(commodityId), + ); + }, `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/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 = 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/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/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..f633901b 100644 --- a/apps/backend/src/presentation/http/accounts/account.controller.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.controller.test.ts @@ -1,9 +1,12 @@ import { AccountCreateDTO, AccountUpdateDTO } from '@ledgerly/shared/types'; +import { AccountQuery } from '@ledgerly/shared/validation'; import { - ArchiveAccountUseCase, + CloseAccountUseCase, + DeleteAccountUseCase, CreateAccountUseCase, GetAccountByIdUseCase, GetAllAccountsUseCase, + OpenAccountUseCase, UpdateAccountUseCase, } from 'src/application/usecases/accounts'; import { Amount } from 'src/domain/domain-core'; @@ -38,7 +41,15 @@ describe('AccountController', () => { execute: vi.fn(), }; - const mockArchiveAccountUseCase = { + const mockDeleteAccountUseCase = { + execute: vi.fn(), + }; + + const mockCloseAccountUseCase = { + execute: vi.fn(), + }; + + const mockOpenAccountUseCase = { execute: vi.fn(), }; @@ -47,7 +58,9 @@ describe('AccountController', () => { mockGetAllAccountsUseCase as unknown as GetAllAccountsUseCase, mockCreateAccountUseCase as unknown as CreateAccountUseCase, mockUpdateAccountUseCase as unknown as UpdateAccountUseCase, - mockArchiveAccountUseCase as unknown as ArchiveAccountUseCase, + mockDeleteAccountUseCase as unknown as DeleteAccountUseCase, + mockCloseAccountUseCase as unknown as CloseAccountUseCase, + mockOpenAccountUseCase as unknown as OpenAccountUseCase, ); beforeEach(async () => { @@ -57,21 +70,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' }; + + const result = await accountController.getAll(user, query); - expect(mockGetAllAccountsUseCase.execute).toHaveBeenCalledWith(user); + 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 +116,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 +160,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,25 +197,69 @@ describe('AccountController', () => { }); }); - describe('archiveAccount', () => { - it('should call archive account use case with correct account id and user', async () => { - mockArchiveAccountUseCase.execute.mockResolvedValue(undefined); + describe('deleteAccount', () => { + it('should call accountController.deleteAccount with correct account id and user', async () => { + mockDeleteAccountUseCase.execute.mockResolvedValue(undefined); + + await accountController.deleteAccount(user, { id: accountId }); + + expect(mockDeleteAccountUseCase.execute).toHaveBeenCalledWith( + user, + accountId, + ); + expect(mockDeleteAccountUseCase.execute).toHaveBeenCalledTimes(1); + }); + + it('should throw ZodError for invalid request params', async () => { + await expect( + accountController.deleteAccount(user, { id: 'not-a-uuid' }), + ).rejects.toThrow(ZodError); + + expect(mockDeleteAccountUseCase.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.archiveAccount(user, { id: accountId }); + await accountController.openAccount(user, { id: accountId }); - expect(mockArchiveAccountUseCase.execute).toHaveBeenCalledWith( + expect(mockOpenAccountUseCase.execute).toHaveBeenCalledWith( user, accountId, ); - expect(mockArchiveAccountUseCase.execute).toHaveBeenCalledTimes(1); + expect(mockOpenAccountUseCase.execute).toHaveBeenCalledTimes(1); }); it('should throw ZodError for invalid request params', async () => { await expect( - accountController.archiveAccount(user, { id: 'not-a-uuid' }), + accountController.openAccount(user, { id: 'not-a-uuid' }), ).rejects.toThrow(ZodError); - expect(mockArchiveAccountUseCase.execute).not.toHaveBeenCalled(); + 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 4387d60e..3d04b0c4 100644 --- a/apps/backend/src/presentation/http/accounts/account.controller.ts +++ b/apps/backend/src/presentation/http/accounts/account.controller.ts @@ -1,13 +1,16 @@ import { accountCreateSchema, + accountQuerySchema, accountUpdateSchema, uniqueIdSchema, } from '@ledgerly/shared/validation'; import { - ArchiveAccountUseCase, + CloseAccountUseCase, + DeleteAccountUseCase, CreateAccountUseCase, GetAccountByIdUseCase, GetAllAccountsUseCase, + OpenAccountUseCase, UpdateAccountUseCase, } from 'src/application/usecases/accounts'; import { User } from 'src/domain/users/user.entity'; @@ -18,11 +21,14 @@ export class AccountController { private readonly getAllAccountsUseCase: GetAllAccountsUseCase, private readonly createAccountUseCase: CreateAccountUseCase, private readonly updateAccountUseCase: UpdateAccountUseCase, - private readonly archiveAccountUseCase: ArchiveAccountUseCase, + private readonly deleteAccountUseCase: DeleteAccountUseCase, + private readonly closeAccountUseCase: CloseAccountUseCase, + private readonly openAccountUseCase: OpenAccountUseCase, ) {} - 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 +50,21 @@ 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); + } + + 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 78ba19be..775df133 100644 --- a/apps/backend/src/presentation/http/accounts/account.integration.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.integration.test.ts @@ -1,64 +1,139 @@ +import { AccountStatusFilterValue } from '@ledgerly/shared/constants'; import { ROUTES } from '@ledgerly/shared/routes'; 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 getWithQueryParamsUrl = (status: AccountStatusFilterValue) => + `${url}?status=${status}`; + +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 +144,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 +159,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 +223,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 +241,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 +254,255 @@ 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: getWithQueryParamsUrl('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: getWithQueryParamsUrl('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('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 update an account by ID', async () => { - const accountToUpdate = accounts[0]; + it('should return 200 and update an account by id', async () => { + const accountToUpdate = accounts.find( + (account) => !account.isClosed && !account.isTombstone, + ); const updatedData = { name: 'Updated Account Name', }; - const response = await server.inject({ - headers: { - Authorization: `Bearer ${authToken}`, - }, + if (!accountToUpdate) { + throw new Error('No account available for update'); + } + + const response = await injectAuthorized({ method: 'PATCH', payload: updatedData, url: `${url}/${accountToUpdate.id}`, @@ -218,12 +513,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: getWithQueryParamsUrl('all'), }); const accountsAfterUpdate = JSON.parse( @@ -232,115 +524,928 @@ describe('Accounts Integration Tests', () => { expect(accountsAfterUpdate).toContainEqual(updatedAccount); }); - }); - // 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 - }); + it('should return 200 and update name and description for a closed account', async () => { + const accountToUpdate = accounts.find( + (account) => account.isClosed && !account.isTombstone, + ); - // 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 - }); + if (!accountToUpdate) { + throw new Error('No closed account available for update'); + } - // 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 - }); + const updatedData = { + description: 'Updated closed account description', + name: 'Updated Closed Account', + }; - // 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 - }); + const response = await injectAuthorized({ + method: 'PATCH', + payload: updatedData, + url: `${url}/${accountToUpdate.id}`, + }); - // 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) - }); + const updatedAccount = JSON.parse(response.body) as AccountResponseDTO; - // 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 - }); + expect(response.statusCode).toBe(200); + expect(updatedAccount).toMatchObject({ + description: updatedData.description, + id: accountToUpdate.id, + isClosed: true, + name: updatedData.name, + }); + }); - // 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 - }); + 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}`, + }); - // 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 + expect(response.statusCode).toBe(409); + }); }); - // 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 + describe('Authentication', () => { + it('should return 401 when no auth token is provided', async () => { + const response = await server.inject({ + method: 'GET', + url, + }); + + expect(response.statusCode).toBe(401); + }); + + it('should return 401 when an invalid auth token is provided', async () => { + const response = await injectWithToken('invalidToken', { + method: 'GET', + url, + }); + + expect(response.statusCode).toBe(401); + }); + + 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, + }); + + const response = await injectWithToken(expiredToken, { + method: 'GET', + url, + }); + + expect(response.statusCode).toBe(401); + }); }); - // 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('GET /api/accounts - Validation', () => { + it('should return 400 when status is an invalid enum value', async () => { + const response = await injectAuthorized({ + method: 'GET', + url: getWithQueryParamsUrl( + 'inactive' as unknown as AccountStatusFilterValue, + ), + }); + + 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 + 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); + }); + + 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 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, + 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 otherUserCommodity = await testDB.createCommodity(otherUserId); + const otherUserAccount = await testDB.createAccount( + otherUserId, + otherUserCommodity.id, + ); + + const response = await injectAuthorized({ + method: 'DELETE', + url: `/api/accounts/${otherUserAccount?.id}`, + }); + + expect(response.statusCode).toBe(404); + }); + + 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}`, + }); + const accountAfterDelete = await testDB.getAccountById( + deletedAccount!.id, + ); + + expect(accountBeforeDelete).toBeDefined(); + expect(accountAfterDelete).toBeDefined(); + expect(response.statusCode).toBe(204); + 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, + ); + }); + + 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: getWithQueryParamsUrl( + 'inactive' as unknown as AccountStatusFilterValue, + ), + }); + + 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..d8b48cfb 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,11 +25,27 @@ 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(); }); + 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/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts b/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts index e3e00038..132137f5 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 { type QueryStatus } from '@ledgerly/shared/types'; +import { CommodityQuery } from '@ledgerly/shared/validation'; import { - ArchiveCommodityUseCase, + CloseCommodityUseCase, + DeleteCommodityUseCase, CreateCommodityUseCase, GetAllCommoditiesUseCase, GetCommodityByIdUseCase, + OpenCommodityUseCase, UpdateCommodityUseCase, } from 'src/application/usecases/commodities'; import { CommodityCode } from 'src/domain/domain-core'; @@ -38,7 +40,15 @@ describe('CommodityController', () => { execute: vi.fn(), }; - const mockArchiveCommodityUseCase = { + const mockDeleteCommodityUseCase = { + execute: vi.fn(), + }; + + const mockCloseCommodityUseCase = { + execute: vi.fn(), + }; + + const mockOpenCommodityUseCase = { execute: vi.fn(), }; @@ -47,7 +57,9 @@ 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, + mockCloseCommodityUseCase as unknown as CloseCommodityUseCase, + mockOpenCommodityUseCase as unknown as OpenCommodityUseCase, ); beforeEach(async () => { @@ -58,23 +70,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: 'open' }, queryParams: { status: 'open' } }, + { query: { status: 'closed' }, queryParams: { status: 'closed' } }, + { query: { status: 'all' }, queryParams: { status: 'all' } }, + { query: { status: 'open' }, 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); }, @@ -87,6 +99,7 @@ describe('CommodityController', () => { { status: 1 }, { status: true }, 'active', + 'inactive', 'deleted', '', undefined, @@ -236,25 +249,71 @@ describe('CommodityController', () => { }); }); - describe('archiveCommodity', () => { - it('should call archiveCommodityUseCase.execute with correct user and id', async () => { + describe('deleteCommodity', () => { + it('should call deleteCommodityUseCase.execute with correct user and id', async () => { + const requestParams = { id: commodityId }; + + await commodityController.delete(user, requestParams); + + expect(mockDeleteCommodityUseCase.execute).toHaveBeenCalledWith( + user, + requestParams.id, + ); + + expect(mockDeleteCommodityUseCase.execute).toHaveBeenCalledTimes(1); + }); + + it('should throw ZodError if requestParams is invalid', async () => { + const invalidRequestParams = { invalidId: 'commodity-id' }; + + await expect( + commodityController.delete(user, invalidRequestParams), + ).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.archiveCommodity(user, requestParams); + await commodityController.open(user, requestParams); - expect(mockArchiveCommodityUseCase.execute).toHaveBeenCalledWith( + expect(mockOpenCommodityUseCase.execute).toHaveBeenCalledWith( user, requestParams.id, ); - expect(mockArchiveCommodityUseCase.execute).toHaveBeenCalledTimes(1); + expect(mockOpenCommodityUseCase.execute).toHaveBeenCalledTimes(1); }); it('should throw ZodError if requestParams is invalid', async () => { const invalidRequestParams = { invalidId: 'commodity-id' }; await expect( - commodityController.archiveCommodity(user, invalidRequestParams), + 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 cd1f60b6..72a9cfd0 100644 --- a/apps/backend/src/presentation/http/commodities/commodity.controller.ts +++ b/apps/backend/src/presentation/http/commodities/commodity.controller.ts @@ -3,13 +3,15 @@ import { commodityCreateSchema, commodityUpdateSchema, uniqueIdSchema, - queryStatusSchema, + commodityQuerySchema, } from '@ledgerly/shared/validation'; import { + CloseCommodityUseCase, GetCommodityByIdUseCase, - ArchiveCommodityUseCase, + DeleteCommodityUseCase, CreateCommodityUseCase, GetAllCommoditiesUseCase, + OpenCommodityUseCase, UpdateCommodityUseCase, } from 'src/application/usecases/commodities'; import { User } from 'src/domain/users/user.entity'; @@ -20,16 +22,18 @@ export class CommodityController { private readonly getAllCommoditiesUseCase: GetAllCommoditiesUseCase, private readonly createCommodityUseCase: CreateCommodityUseCase, private readonly updateCommodityUseCase: UpdateCommodityUseCase, - private readonly archiveCommodityUseCase: ArchiveCommodityUseCase, + private readonly deleteCommodityUseCase: DeleteCommodityUseCase, + private readonly closeCommodityUseCase: CloseCommodityUseCase, + private readonly openCommodityUseCase: OpenCommodityUseCase, ) {} async getAll( 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( @@ -61,12 +65,27 @@ export class CommodityController { return this.updateCommodityUseCase.execute(user, id, commodityUpdateDto); } - async archiveCommodity( + async delete(user: User, requestParams: unknown): Promise { + const { id } = uniqueIdSchema.parse(requestParams); + + 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.archiveCommodityUseCase.execute(user, id); + return this.openCommodityUseCase.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..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,5 +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'; @@ -39,7 +45,7 @@ describe('Commodities Integration Tests', () => { symbol: '€', }; - const archivedCommodityData1 = { + const deletedCommodityData1 = { code: CommodityCode.create('GBP').valueOf(), isTombstone: true, name: 'British Pound', @@ -48,10 +54,10 @@ describe('Commodities Integration Tests', () => { }; const activeCommoditiesData = [activeCommodityData1, activeCommodityData2]; - const archivedCommoditiesData = [archivedCommodityData1]; + const deletedCommoditiesData = [deletedCommodityData1]; const allCommoditiesData = [ ...activeCommoditiesData, - ...archivedCommoditiesData, + ...deletedCommoditiesData, ]; let commoditiesDbRows: CommodityDbRow[]; @@ -119,69 +125,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 +228,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 +239,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); @@ -254,33 +279,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 () => { @@ -555,13 +568,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({ @@ -569,7 +582,7 @@ describe('Commodities Integration Tests', () => { name: 'Updated British Pound', }, method: 'PATCH', - url: `${url}/${archivedCommodity.id}`, + url: `${url}/${deletedCommodity.id}`, }); expect(response.statusCode).toBe(404); @@ -635,37 +648,256 @@ describe('Commodities Integration Tests', () => { }); }); + describe('POST /api/commodities/:id/close', () => { + it('should return 200 and 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`, + }); + + const responseBody = parseResponse(response); + + expect(response.statusCode).toBe(200); + expect(responseBody).toMatchObject({ + id: commodityToClose.id, + isClosed: true, + }); + + 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`, + }); + + const responseBody = parseResponse(response); + + expect(response.statusCode).toBe(200); + expect(responseBody).toMatchObject({ + id: closedCommodity.id, + isClosed: true, + }); + + 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 return 200 and 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`, + }); + + const responseBody = parseResponse(response); + + expect(response.statusCode).toBe(200); + expect(responseBody).toMatchObject({ + id: commodityToOpen.id, + isClosed: false, + }); + + 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`, + }); + + const responseBody = parseResponse(response); + + expect(response.statusCode).toBe(200); + expect(responseBody).toMatchObject({ + id: openCommodity.id, + isClosed: false, + }); + + 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 archive a commodity by id', async () => { - const commodityToArchive = commoditiesDbRows[0]; + it('should delete a commodity by id', async () => { + 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(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); - const archivedCommoditiesDbRows = allCommodities.filter( + const deletedCommoditiesDbRows = allCommodities.filter( (commodity) => commodity.isTombstone, ); @@ -677,50 +909,41 @@ describe('Commodities Integration Tests', () => { activeCommoditiesData.length - 1, ); - expect(archivedCommoditiesDbRows).toHaveLength( - archivedCommoditiesData.length + 1, + expect(deletedCommoditiesDbRows).toHaveLength( + deletedCommoditiesData.length + 1, ); }); - it('should leave the archived commodity readable 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(200); - - const parsedResponse = parseResponse(response); - - expect(parsedResponse).toEqual( - expect.objectContaining({ - id: archivedCommodity.id, - isTombstone: true, - }), - ); + expect(response.statusCode).toBe(404); }); - 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,26 +951,15 @@ 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 () => { + it('should return 404 when deleting another user commodity', async () => { const otherUserCommodity = await testDB.createCommodity(otherUserId, { code: CommodityCode.create('JPY').valueOf(), name: 'Japanese Yen', @@ -763,21 +975,82 @@ describe('Commodities Integration Tests', () => { expect(response.statusCode).toBe(404); }); - it('should return 404 when archiving an already archived commodity', async () => { - const archivedCommodity = commoditiesDbRows.find( + 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, ); - 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(404); + expect(response.statusCode).toBe(204); }); it('should return 400 when the commodity id is invalid', async () => { @@ -790,11 +1063,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); diff --git a/apps/backend/src/presentation/http/commodities/commodity.routes.ts b/apps/backend/src/presentation/http/commodities/commodity.routes.ts index feb76f25..8d4ac9a1 100644 --- a/apps/backend/src/presentation/http/commodities/commodity.routes.ts +++ b/apps/backend/src/presentation/http/commodities/commodity.routes.ts @@ -25,11 +25,27 @@ 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(); }); + app.post('/:id/close', async (request, reply) => { + const user = request.user; + + const commodity = await commodityController.close(user, request.params); + + reply.status(200).send(commodity); + }); + + app.post('/:id/open', async (request, reply) => { + const user = request.user; + + const commodity = await commodityController.open(user, request.params); + + reply.status(200).send(commodity); + }); + app.patch('/:id', async (request, reply) => { const user = request.user; diff --git a/apps/backend/src/presentation/http/error-handler.test.ts b/apps/backend/src/presentation/http/error-handler.test.ts index c26549d2..195892fb 100644 --- a/apps/backend/src/presentation/http/error-handler.test.ts +++ b/apps/backend/src/presentation/http/error-handler.test.ts @@ -4,11 +4,15 @@ import { } from '@ledgerly/shared/types'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { + AccountHasActiveOperationsError, + CommodityHasActiveReferencesError, InvalidPasswordError, UserAlreadyExistsError, UserNotFoundError, } from 'src/application/application.errors'; +import { Id } from 'src/domain/domain-core'; import { + AccountPersistenceConflictError, DatabaseError, DatabaseOperationError, ForbiddenAccessError, @@ -87,6 +91,9 @@ describe('getValidationFieldErrorCode', () => { }); describe('errorHandler', () => { + const accountId = Id.create().valueOf(); + const commodityId = Id.create().valueOf(); + const handle = (error: Error) => { const { reply, response } = createReply(); @@ -114,26 +121,43 @@ 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 }, + ], + [ + 'a commodity with active references', + new CommodityHasActiveReferencesError(commodityId), + 409, + apiErrorCodes.commodityHasActiveReferences, + { commodityId }, ], ])( '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 +246,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..50c5e2ee 100644 --- a/apps/backend/src/presentation/http/error-handler.ts +++ b/apps/backend/src/presentation/http/error-handler.ts @@ -16,9 +16,13 @@ import { ZodError, type ZodIssue } from 'zod'; import { HttpApiError } from './errors'; const statusByErrorCode = { + [apiErrorCodes.accountHasActiveOperations]: 409, [apiErrorCodes.accountNotFoundInContext]: 400, [apiErrorCodes.authenticationFailed]: 401, [apiErrorCodes.badRequest]: 400, + [apiErrorCodes.closedAccountOperation]: 409, + [apiErrorCodes.closedCommodityReference]: 409, + [apiErrorCodes.commodityHasActiveReferences]: 409, [apiErrorCodes.conflict]: 409, [apiErrorCodes.conflictingOperationIds]: 400, [apiErrorCodes.deletedEntityOperation]: 400, @@ -83,11 +87,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/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index fe6f02dc..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,24 +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 | -| `isSystem` | Boolean | Reserved system account flag | NOT NULL, default: false | -| `isTombstone` | Boolean | Soft 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:** @@ -200,6 +215,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. --- @@ -208,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:** @@ -244,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:** @@ -284,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:** @@ -337,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. @@ -371,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 f6241392..f62aeef1 100644 --- a/docs/DOMAIN.md +++ b/docs/DOMAIN.md @@ -62,7 +62,21 @@ 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 +- `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 @@ -80,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 @@ -114,17 +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(...)`. -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. +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. 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 @@ -216,10 +240,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 @@ -301,7 +325,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 70f5dfaf..2d8c2daf 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 @@ -54,6 +55,7 @@ Examples include: - `InvalidPasswordError` - `UserAlreadyExistsError` - `VersionConflictError` +- `CommodityClosedError` Authentication failures intentionally map to the same public `AUTHENTICATION_FAILED` code with empty context. @@ -95,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 @@ -138,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); } } ``` @@ -154,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); } } @@ -170,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); } } @@ -184,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(); ``` @@ -192,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 new file mode 100644 index 00000000..6a143e04 --- /dev/null +++ b/docs/architecture/adr/0020-terminal-tombstone-and-reversible-entity-states.md @@ -0,0 +1,294 @@ +# ADR 0020: Terminal Tombstone and Reversible Entity States + +- Status: Accepted +- 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 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 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. + +## 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: + +```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` | `isClosed` | `isTombstone` | + +### Account + +A closed account: + +- keeps its full history; +- participates in reports and calculations; +- does not accept new operations; +- can be opened again. + +The domain API should express that lifecycle explicitly: + +```ts +account.close(); +account.open(); +``` + +The `close` term matches account lifecycle language: an account can stop +accepting new operations while keeping its history and later be opened again. + +`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 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`. + +`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 use account lifecycle language: + +- `open`; +- `closed`; +- `all`. + +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` | + +Account close and open are exposed as explicit HTTP actions: + +```http +POST /accounts/:id/close +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. + +`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 + +A closed commodity: + +- remains available for old operations; +- participates in historical calculations; +- is not offered for new accounts or active selection; +- can be opened again. + +The domain API should express that lifecycle explicitly: + +```ts +commodity.close(); +commodity.open(); +``` + +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. + +`close()` and `open()` 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. + +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 +`isClosed`. + +Commodity lifecycle commands are exposed as explicit domain actions: + +```http +POST /commodities/:id/close +POST /commodities/:id/open +``` + +The expected HTTP responses are: + +```http +POST /commodities/:id/close -> 200 OK +POST /commodities/:id/open -> 200 OK +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. + +Closed 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 `isClosed` and `isTombstone` as separate state +axes: + +| 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. + +`open()` does not depend on the lifecycle state of related accounts. + +### Deletion + +Terminal tombstone deletion should be represented explicitly: + +```ts +transaction.delete(); +operation.delete(); +account.delete(); +commodity.delete(); +``` + +`DELETE` means only terminal tombstone deletion. It must not be used for +reversible account or commodity close transitions. + +An account can be terminally deleted only when all related operations are either: + +- already tombstoned with `isTombstone = true`; or +- moved to other accounts. + +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` describes reversible business states for accounts +and commodities. + +The intended public domain methods are: + +| Entity | Reversible state | Methods | +| ----------- | ---------------- | -------------------- | +| `Account` | `isClosed` | `close()` / `open()` | +| `Commodity` | `isClosed` | `close()` / `open()` | +| Any entity | `isTombstone` | `delete()` | + +## 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. It also forces account terminology into + commodity terminology. + +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. + +4. Use separate archive terminology for `Commodity` + +- 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 `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 + 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; 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 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) +- [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 0489a2c3..27fac74f 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -49,4 +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) diff --git a/packages/shared/src/constants/accounts.ts b/packages/shared/src/constants/accounts.ts index a9831495..7268127a 100644 --- a/packages/shared/src/constants/accounts.ts +++ b/packages/shared/src/constants/accounts.ts @@ -1,14 +1,15 @@ -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_STATUS_FILTER_VALUES = ["open", "closed", "all"] as const; -export const ACCOUNT_TYPE_VALUES = ACCOUNT_TYPES.map((t) => t) as [ - AccountTypeValue, - ...AccountTypeValue[], -]; +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 new file mode 100644 index 00000000..b239184e --- /dev/null +++ b/packages/shared/src/constants/commodities.ts @@ -0,0 +1,8 @@ +export const COMMODITY_STATUS_FILTER_VALUES = [ + "open", + "closed", + "all", +] as const; + +export type CommodityStatusFilterValue = + (typeof COMMODITY_STATUS_FILTER_VALUES)[number]; 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..4e1023f1 100644 --- a/packages/shared/src/types/commodity.ts +++ b/packages/shared/src/types/commodity.ts @@ -1,11 +1,16 @@ +import { z } from "zod"; + import { - CommodityCodeString, - CommodityPrecisionNumber, - CommoditySymbolString, - IsoDatetimeString, - RequiredText, - UUID, -} from "./types"; + commodityCode, + commodityPrecision, + commoditySymbol, +} from "../validation"; + +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; @@ -18,6 +23,7 @@ export type CommodityResponseDTO = { code: CommodityCodeString; createdAt: IsoDatetimeString; id: UUID; + isClosed: boolean; isTombstone: boolean; name: RequiredText; precision: CommodityPrecisionNumber; diff --git a/packages/shared/src/types/error-contract.ts b/packages/shared/src/types/error-contract.ts index f0db0b07..b4d520ab 100644 --- a/packages/shared/src/types/error-contract.ts +++ b/packages/shared/src/types/error-contract.ts @@ -1,9 +1,13 @@ 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", + closedAccountOperation: "CLOSED_ACCOUNT_OPERATION", + closedCommodityReference: "CLOSED_COMMODITY_REFERENCE", + commodityHasActiveReferences: "COMMODITY_HAS_ACTIVE_REFERENCES", conflict: "CONFLICT", conflictingOperationIds: "CONFLICTING_OPERATION_IDS", deletedEntityOperation: "DELETED_ENTITY_OPERATION", @@ -54,12 +58,26 @@ export type ValidationFieldError = { }; export type ErrorContextByCode = { + ACCOUNT_HAS_ACTIVE_OPERATIONS: { + accountId: UUID; + }; ACCOUNT_NOT_FOUND_IN_CONTEXT: { accountId: string; operationId: string; }; AUTHENTICATION_FAILED: Record; BAD_REQUEST: Record; + CLOSED_ACCOUNT_OPERATION: { + accountId: UUID; + operation: "update" | "use"; + }; + CLOSED_COMMODITY_REFERENCE: { + commodityId: UUID; + operation: "create_account"; + }; + COMMODITY_HAS_ACTIVE_REFERENCES: { + commodityId: UUID; + }; CONFLICT: Record; CONFLICTING_OPERATION_IDS: { conflict: @@ -70,7 +88,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..de754b7b 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", @@ -30,4 +34,32 @@ 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", () => { + it("defaults omitted status filter to open accounts", () => { + expect(accountQuerySchema.parse({})).toEqual({ status: "open" }); + }); + + it("allows querying all accounts", () => { + expect(accountQuerySchema.parse({ status: "all" })).toEqual({ + status: "all", + }); + }); }); diff --git a/packages/shared/src/validation/accounts.ts b/packages/shared/src/validation/accounts.ts index 38de0b7c..02c72897 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,32 @@ 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, { + message: "At least one field must be provided for update", + }); -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..c78cff5f --- /dev/null +++ b/packages/shared/src/validation/commodities.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { commodityQuerySchema } from "./commodities"; + +describe("commodityQuerySchema", () => { + 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", () => { + 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..f91ba061 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("open"); + +export const commodityQuerySchema = z.object({ + status: commodityStatusFilter, +}); + +export type CommodityQuery = z.infer;