diff --git a/.husky/pre-commit b/.husky/pre-commit index c095f3e5..38b5793c 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -pnpm --filter backend check +pnpm --filter backend check && pnpm --filter backend test \ No newline at end of file diff --git a/apps/backend/drizzle/0000_keen_gertrude_yorkes.sql b/apps/backend/drizzle/0000_careful_dust.sql similarity index 78% rename from apps/backend/drizzle/0000_keen_gertrude_yorkes.sql rename to apps/backend/drizzle/0000_careful_dust.sql index 2af9b67c..e14c575a 100644 --- a/apps/backend/drizzle/0000_keen_gertrude_yorkes.sql +++ b/apps/backend/drizzle/0000_careful_dust.sql @@ -1,6 +1,6 @@ CREATE TABLE `accounts` ( + `commodity_id` text NOT NULL, `created_at` text NOT NULL, - `currency` text NOT NULL, `current_cleared_balance_local` text NOT NULL, `description` text NOT NULL, `id` text PRIMARY KEY NOT NULL, @@ -11,13 +11,14 @@ CREATE TABLE `accounts` ( `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 ); --> statement-breakpoint CREATE UNIQUE INDEX `user_id_name_unique_idx` ON `accounts` (`user_id`,`name`);--> statement-breakpoint CREATE TABLE `transactions` ( + `commodity_id` text NOT NULL, `created_at` text NOT NULL, - `currency` text NOT NULL, `description` text NOT NULL, `id` text PRIMARY KEY NOT NULL, `is_tombstone` integer NOT NULL, @@ -26,16 +27,11 @@ CREATE TABLE `transactions` ( `updated_at` text NOT NULL, `user_id` text NOT NULL, `version` integer 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 ); --> statement-breakpoint CREATE INDEX `idx_transactions_user_date` ON `transactions` (`user_id`,`transaction_date`);--> statement-breakpoint -CREATE TABLE `currencies` ( - `code` text PRIMARY KEY NOT NULL, - `name` text NOT NULL, - `symbol` text NOT NULL -); ---> statement-breakpoint CREATE TABLE `operations` ( `account_id` text NOT NULL, `amount` text NOT NULL, @@ -67,10 +63,23 @@ CREATE TABLE `users` ( --> statement-breakpoint CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint CREATE TABLE `settings` ( - `base_currency` text DEFAULT 'RUB' NOT NULL, `created_at` text NOT NULL, `updated_at` text NOT NULL, `user_id` text NOT NULL, - FOREIGN KEY (`base_currency`) REFERENCES `currencies`(`code`) ON UPDATE no action ON DELETE no action, FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action ); +--> statement-breakpoint +CREATE TABLE `commodities` ( + `code` text NOT NULL, + `created_at` text NOT NULL, + `id` text PRIMARY KEY NOT NULL, + `is_tombstone` integer NOT NULL, + `name` text NOT NULL, + `precision` integer NOT NULL, + `symbol` text, + `updated_at` text NOT NULL, + `user_id` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `commodities_user_id_code_unique_idx` ON `commodities` (`user_id`,`code`); \ 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 a1a0db25..07cebc7c 100644 --- a/apps/backend/drizzle/meta/0000_snapshot.json +++ b/apps/backend/drizzle/meta/0000_snapshot.json @@ -1,21 +1,21 @@ { "version": "6", "dialect": "sqlite", - "id": "b3626041-a73e-4294-b580-3c53764e0f43", + "id": "59edadf8-a69b-4dbb-a24a-828a220d6559", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "accounts": { "name": "accounts", "columns": { - "created_at": { - "name": "created_at", + "commodity_id": { + "name": "commodity_id", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "currency": { - "name": "currency", + "created_at": { + "name": "created_at", "type": "text", "primaryKey": false, "notNull": true, @@ -103,6 +103,19 @@ } }, "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", @@ -124,15 +137,15 @@ "transactions": { "name": "transactions", "columns": { - "created_at": { - "name": "created_at", + "commodity_id": { + "name": "commodity_id", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "currency": { - "name": "currency", + "created_at": { + "name": "created_at", "type": "text", "primaryKey": false, "notNull": true, @@ -206,6 +219,19 @@ } }, "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", @@ -224,37 +250,6 @@ "uniqueConstraints": {}, "checkConstraints": {} }, - "currencies": { - "name": "currencies", - "columns": { - "code": { - "name": "code", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "symbol": { - "name": "symbol", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, "operations": { "name": "operations", "columns": { @@ -467,14 +462,6 @@ "settings": { "name": "settings", "columns": { - "base_currency": { - "name": "base_currency", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'RUB'" - }, "created_at": { "name": "created_at", "type": "text", @@ -499,22 +486,105 @@ }, "indexes": {}, "foreignKeys": { - "settings_base_currency_currencies_code_fk": { - "name": "settings_base_currency_currencies_code_fk", + "settings_user_id_users_id_fk": { + "name": "settings_user_id_users_id_fk", "tableFrom": "settings", - "tableTo": "currencies", + "tableTo": "users", "columnsFrom": [ - "base_currency" + "user_id" ], "columnsTo": [ - "code" + "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 }, - "settings_user_id_users_id_fk": { - "name": "settings_user_id_users_id_fk", - "tableFrom": "settings", + "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_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" @@ -522,7 +592,7 @@ "columnsTo": [ "id" ], - "onDelete": "no action", + "onDelete": "cascade", "onUpdate": "no action" } }, diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index f3fff04a..73cfb78c 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1776091943739, - "tag": "0000_keen_gertrude_yorkes", + "when": 1784803897112, + "tag": "0000_careful_dust", "breakpoints": true } ] diff --git a/apps/backend/package.json b/apps/backend/package.json index a0c3484d..82486d9c 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -28,7 +28,6 @@ "db:delete:all": "pnpm db:delete && pnpm db:migrations:delete", "db:full-reset": "pnpm db:delete:all && pnpm db:generate && pnpm db:migrate", "testDB": "tsx src/db/test-db.ts", - "seed:currencies": "tsx src/db/scripts/currenciesSeed.ts", "db:reset": "pnpm db:delete && pnpm db:migrate", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/apps/backend/src/application/dto/currency.dto.ts b/apps/backend/src/application/dto/currency.dto.ts deleted file mode 100644 index aa9d3600..00000000 --- a/apps/backend/src/application/dto/currency.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { CurrencyCode } from '@ledgerly/shared/types'; - -// Response DTOs -export type CurrencyResponseDTO = { - code: CurrencyCode; - name: string; - symbol: string; -}; - -// Query DTOs -export type GetCurrenciesQueryDTO = { - codes?: CurrencyCode[]; -}; diff --git a/apps/backend/src/application/dto/index.ts b/apps/backend/src/application/dto/index.ts index aa46f60f..4e643cb6 100644 --- a/apps/backend/src/application/dto/index.ts +++ b/apps/backend/src/application/dto/index.ts @@ -1,4 +1,3 @@ export * from './transaction.dto'; export * from './operation.dto'; -export * from './currency.dto'; export * from './user.dto'; diff --git a/apps/backend/src/application/interfaces/AccountRepository.interface.ts b/apps/backend/src/application/interfaces/AccountRepository.interface.ts index 001c0213..8edc522a 100644 --- a/apps/backend/src/application/interfaces/AccountRepository.interface.ts +++ b/apps/backend/src/application/interfaces/AccountRepository.interface.ts @@ -1,12 +1,9 @@ -import { CurrencyCode, UUID } from '@ledgerly/shared/types'; +import { UUID } from '@ledgerly/shared/types'; import { AccountSnapshot } from 'src/domain/accounts'; export type AccountRepositoryUpdateInput = Pick & Partial< - Pick< - AccountSnapshot, - 'currency' | 'description' | 'initialBalance' | 'name' | 'type' - > + Pick >; export type AccountRepositorySoftDeleteInput = Pick< @@ -16,22 +13,18 @@ export type AccountRepositorySoftDeleteInput = Pick< export type AccountRepositoryInterface = { getAll(userId: UUID): Promise; - create(data: AccountSnapshot): Promise; + create(userId: UUID, data: AccountSnapshot): Promise; getById(userId: UUID, id: UUID): Promise; update( userId: UUID, id: UUID, data: AccountRepositoryUpdateInput, ): Promise; - delete( + softDelete( userId: UUID, id: UUID, data: AccountRepositorySoftDeleteInput, ): Promise; - findSystemAccount( - userId: UUID, - currency: CurrencyCode, - ): Promise; ensureUserOwnsAccount( userId: UUID, accountId: UUID, diff --git a/apps/backend/src/application/interfaces/CommodityRepository.interface.ts b/apps/backend/src/application/interfaces/CommodityRepository.interface.ts new file mode 100644 index 00000000..7ca70de9 --- /dev/null +++ b/apps/backend/src/application/interfaces/CommodityRepository.interface.ts @@ -0,0 +1,32 @@ +import { QueryStatus, UUID } from '@ledgerly/shared/types'; +import { CommoditySnapshot } from 'src/domain/commodities'; + +export type CommodityRepositoryUpdateInput = Pick< + CommoditySnapshot, + 'updatedAt' +> & + Partial>; + +export type CommodityRepositorySoftDeleteInput = Pick< + CommoditySnapshot, + 'updatedAt' +>; + +export type CommodityRepositoryInterface = { + getById(userId: UUID, commodityId: UUID): Promise; + getAll(userId: UUID, status: QueryStatus): Promise; + create( + userId: UUID, + commodity: CommoditySnapshot, + ): Promise; + update( + userId: UUID, + commodityId: UUID, + commodity: CommodityRepositoryUpdateInput, + ): Promise; + softDelete( + userId: UUID, + commodityId: UUID, + data: CommodityRepositorySoftDeleteInput, + ): Promise; +}; diff --git a/apps/backend/src/application/interfaces/index.ts b/apps/backend/src/application/interfaces/index.ts index 0853ca8a..78dca4c3 100644 --- a/apps/backend/src/application/interfaces/index.ts +++ b/apps/backend/src/application/interfaces/index.ts @@ -17,3 +17,8 @@ export type { TransactionContext, } from './TransactionManager.interface'; export type { OperationRepositoryInterface } from './OperationRepository.interface'; +export type { + CommodityRepositoryInterface, + CommodityRepositoryUpdateInput, + CommodityRepositorySoftDeleteInput, +} 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 dff6a06b..84d69d3e 100644 --- a/apps/backend/src/application/mappers/account.mapper.test.ts +++ b/apps/backend/src/application/mappers/account.mapper.test.ts @@ -1,14 +1,14 @@ import { AccountUpdateDTO } from '@ledgerly/shared/types'; import { AccountSnapshot } from 'src/domain/accounts'; -import { Amount, Currency, Id, Timestamp } from 'src/domain/domain-core'; +import { Amount, Id, Timestamp } from 'src/domain/domain-core'; import { describe, expect, it } from 'vitest'; import { AccountMapper } from './account.mapper'; describe('AccountMapper', () => { const snapshot: AccountSnapshot = { + commodityId: Id.create().valueOf(), createdAt: Timestamp.create().valueOf(), - currency: Currency.create('USD').valueOf(), currentClearedBalanceLocal: Amount.create('2500').valueOf(), description: 'Operating account', id: Id.create().valueOf(), @@ -23,8 +23,8 @@ describe('AccountMapper', () => { it('maps an account snapshot to a response DTO', () => { expect(AccountMapper.toResponseDTOFromSnapshot(snapshot)).toEqual({ + commodityId: snapshot.commodityId, createdAt: snapshot.createdAt, - currency: snapshot.currency, currentClearedBalanceLocal: snapshot.currentClearedBalanceLocal, description: snapshot.description, id: snapshot.id, @@ -40,15 +40,12 @@ describe('AccountMapper', () => { it('maps a full update DTO to domain update props', () => { const dto: AccountUpdateDTO = { - currency: Currency.create('EUR').valueOf(), description: 'Updated description', - isSystem: false, name: 'Updated Account', type: 'liability', }; expect(AccountMapper.toUpdateProps(dto)).toEqual({ - currency: dto.currency, description: dto.description, name: dto.name, type: dto.type, @@ -61,7 +58,6 @@ describe('AccountMapper', () => { }; expect(AccountMapper.toUpdateProps(dto)).toEqual({ - currency: undefined, description: undefined, name: dto.name, type: undefined, diff --git a/apps/backend/src/application/mappers/account.mapper.ts b/apps/backend/src/application/mappers/account.mapper.ts index 162c1191..f2fb5e65 100644 --- a/apps/backend/src/application/mappers/account.mapper.ts +++ b/apps/backend/src/application/mappers/account.mapper.ts @@ -1,13 +1,23 @@ -import { AccountResponseDTO, AccountUpdateDTO } from '@ledgerly/shared/types'; -import { AccountSnapshot, AccountUpdateProps } from 'src/domain/accounts'; +import { + AccountCreateDTO, + AccountResponseDTO, + AccountUpdateDTO, +} from '@ledgerly/shared/types'; +import { + AccountSnapshot, + AccountType, + AccountUpdateProps, +} from 'src/domain/accounts'; +import { CreateAccountProps } from 'src/domain/accounts/types'; +import { Amount, Id, Name } from 'src/domain/domain-core'; export class AccountMapper { static toResponseDTOFromSnapshot( snapshot: AccountSnapshot, ): AccountResponseDTO { return { + commodityId: snapshot.commodityId, createdAt: snapshot.createdAt, - currency: snapshot.currency, currentClearedBalanceLocal: snapshot.currentClearedBalanceLocal, description: snapshot.description, id: snapshot.id, @@ -20,10 +30,18 @@ export class AccountMapper { userId: snapshot.userId, }; } + static toCreateAccountProps(dto: AccountCreateDTO): CreateAccountProps { + return { + commodityId: Id.restore(dto.commodityId), + description: dto.description, + initialBalance: Amount.create(dto.initialBalance), + name: Name.create(dto.name), + type: AccountType.create(dto.type), + }; + } static toUpdateProps(dto: AccountUpdateDTO): AccountUpdateProps { return { - currency: dto.currency, description: dto.description, name: dto.name, type: dto.type, diff --git a/apps/backend/src/application/mappers/commodity.mapper.ts b/apps/backend/src/application/mappers/commodity.mapper.ts new file mode 100644 index 00000000..63bf9384 --- /dev/null +++ b/apps/backend/src/application/mappers/commodity.mapper.ts @@ -0,0 +1,47 @@ +import { + CommodityCreateDTO, + CommodityResponseDTO, + CommodityUpdateDTO, +} from '@ledgerly/shared/types'; +import { + CommoditySnapshot, + CommodityUpdateProps, + CreateCommodityProps, +} from 'src/domain/commodities'; + +export class CommodityMapper { + static toResponseDTOFromSnapshot( + snapshot: CommoditySnapshot, + ): CommodityResponseDTO { + return { + code: snapshot.code, + createdAt: snapshot.createdAt, + id: snapshot.id, + isTombstone: snapshot.isTombstone, + name: snapshot.name, + precision: snapshot.precision, + symbol: snapshot.symbol, + updatedAt: snapshot.updatedAt, + userId: snapshot.userId, + }; + } + + static toCreateCommodityProps( + props: CommodityCreateDTO, + ): CreateCommodityProps { + return { + code: props.code, + name: props.name, + precision: props.precision, + symbol: props.symbol, + }; + } + + static toUpdateProps(props: CommodityUpdateDTO): CommodityUpdateProps { + return { + code: props.code, + name: props.name, + symbol: props.symbol, + }; + } +} diff --git a/apps/backend/src/application/mappers/index.ts b/apps/backend/src/application/mappers/index.ts index e0a3c286..f19cddd9 100644 --- a/apps/backend/src/application/mappers/index.ts +++ b/apps/backend/src/application/mappers/index.ts @@ -3,3 +3,4 @@ export { OperationMapper } from './operation.mapper'; export { TransactionReadModelResponseMapper } from './transaction-read-model-response.mapper'; export { TransactionMapper } from './transaction.mapper'; export { UserMapper } from './user.mapper'; +export { CommodityMapper } from './commodity.mapper'; diff --git a/apps/backend/src/application/mappers/operation.mapper.test.ts b/apps/backend/src/application/mappers/operation.mapper.test.ts index 2ef690c9..0f32663d 100644 --- a/apps/backend/src/application/mappers/operation.mapper.test.ts +++ b/apps/backend/src/application/mappers/operation.mapper.test.ts @@ -26,7 +26,7 @@ describe('OperationMapper', () => { user = await createUser(); const fixture = TransactionBuilder.request({ - accounts: ['USD'], + currencies: ['USD'], operations: [ { accountKey: 'USD', amount: '100', description: 'Debit' }, { accountKey: 'USD', amount: '-100', description: 'Credit' }, diff --git a/apps/backend/src/application/mappers/transaction-read-model-response.mapper.ts b/apps/backend/src/application/mappers/transaction-read-model-response.mapper.ts index b4b20302..17734c11 100644 --- a/apps/backend/src/application/mappers/transaction-read-model-response.mapper.ts +++ b/apps/backend/src/application/mappers/transaction-read-model-response.mapper.ts @@ -6,8 +6,8 @@ export class TransactionReadModelResponseMapper { transaction: TransactionReadModel, ): TransactionResponseDTO { return { + commodityId: transaction.commodityId, createdAt: transaction.createdAt, - currency: transaction.currency, description: transaction.description, id: transaction.id, // mapOperation does not access this, so passing it unbound is safe. diff --git a/apps/backend/src/application/mappers/transaction.mapper.test.ts b/apps/backend/src/application/mappers/transaction.mapper.test.ts index 47f1d79b..b86f0bc3 100644 --- a/apps/backend/src/application/mappers/transaction.mapper.test.ts +++ b/apps/backend/src/application/mappers/transaction.mapper.test.ts @@ -12,7 +12,7 @@ describe('TransactionMapper', () => { const user = await createUser(); fixture = TransactionBuilder.transaction({ - accounts: ['USD'], + currencies: ['USD'], operations: [ { accountKey: 'USD', @@ -26,7 +26,6 @@ describe('TransactionMapper', () => { }, ], settings: { - currencyCode: 'USD', description: 'Transaction with tombstone operation', postingDate: '2026-07-10', transactionDate: '2026-07-09', @@ -64,8 +63,8 @@ describe('TransactionMapper', () => { const response = TransactionMapper.toResponseDTO(transaction); expect(response).toEqual({ + commodityId: transactionSnapshot.commodityId, createdAt: transactionSnapshot.createdAt, - currency: transactionSnapshot.currency, description: transactionSnapshot.description, id: transactionSnapshot.id, operations: [ diff --git a/apps/backend/src/application/mappers/transaction.mapper.ts b/apps/backend/src/application/mappers/transaction.mapper.ts index 9e9ddfec..48a5ef77 100644 --- a/apps/backend/src/application/mappers/transaction.mapper.ts +++ b/apps/backend/src/application/mappers/transaction.mapper.ts @@ -1,6 +1,6 @@ import { Transaction } from 'src/domain'; -import { Currency } from 'src/domain/domain-core/value-objects/Currency'; import { DateValue } from 'src/domain/domain-core/value-objects/DateValue'; +import { Id } from 'src/domain/domain-core/value-objects/Id'; import { CreateTransactionProps, TransactionBuildContext, @@ -16,8 +16,8 @@ export class TransactionMapper { const snapshot = transaction.toActiveSnapshot(); return { + commodityId: snapshot.commodityId, createdAt: snapshot.createdAt, - currency: snapshot.currency, description: snapshot.description, id: snapshot.id, operations: snapshot.operations.map((operation) => @@ -40,7 +40,7 @@ export class TransactionMapper { ); return { - currency: Currency.create(dto.currencyCode), + commodityId: Id.restore(dto.commodityId), description: dto.description, operations, postingDate: DateValue.restore(dto.postingDate), diff --git a/apps/backend/src/application/read-models/transaction.read-model.ts b/apps/backend/src/application/read-models/transaction.read-model.ts index 4bf09ef1..61e3e422 100644 --- a/apps/backend/src/application/read-models/transaction.read-model.ts +++ b/apps/backend/src/application/read-models/transaction.read-model.ts @@ -1,5 +1,4 @@ import { - CurrencyCode, IsoDateString, IsoDatetimeString, AmountString, @@ -21,7 +20,6 @@ export type OperationReadModel = { export type TransactionReadModel = { createdAt: IsoDatetimeString; - currency: CurrencyCode; description: string; id: UUID; operations: OperationReadModel[]; @@ -29,5 +27,6 @@ export type TransactionReadModel = { transactionDate: IsoDateString; updatedAt: IsoDatetimeString; userId: UUID; + commodityId: UUID; version: number; }; 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 d7f6562f..af478ed6 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,9 +1,8 @@ -import { CurrencyCode } from '@ledgerly/shared/types'; 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 { Amount, Currency } from 'src/domain/domain-core'; +import { Amount } from 'src/domain/domain-core'; import { createUser } from 'src/testing'; import { beforeAll, describe, expect, it, vi, beforeEach } from 'vitest'; @@ -32,16 +31,10 @@ describe('TransactionContextLoader', () => { const account1 = createAccount(user); const account2 = createAccount(user); const account3 = createAccount(user); - const account4 = createAccount(user, { currency: Currency.create('EUR') }); + const account4 = createAccount(user); const accounts = [account1, account2, account3, account4]; - const currenciesSet = new Set(); - - for (const acc of accounts) { - currenciesSet.add(acc.currency.valueOf()); - } - const rawOperations: OperationRequestDTO[] = [ { accountId: account1.getId().valueOf(), diff --git a/apps/backend/src/application/services/TransactionService/transaction.context-loader.ts b/apps/backend/src/application/services/TransactionService/transaction.context-loader.ts index 1e2abd5a..8b40e50e 100644 --- a/apps/backend/src/application/services/TransactionService/transaction.context-loader.ts +++ b/apps/backend/src/application/services/TransactionService/transaction.context-loader.ts @@ -1,4 +1,4 @@ -import { CurrencyCode, UUID } from '@ledgerly/shared/types'; +import { UUID } from '@ledgerly/shared/types'; import { Account, User } from 'src/domain'; import { TransactionBuildContext } from 'src/domain/transactions/types'; @@ -14,10 +14,8 @@ export class TransactionContextLoader { operations: OperationRequestDTO[], ): Promise<{ accountsMap: Map; - currenciesSet: Set; }> { const accountIds = new Set(); - const currenciesSet = new Set(); for (const operation of operations) { accountIds.add(operation.accountId); @@ -31,41 +29,16 @@ export class TransactionContextLoader { const accountsMap = new Map(); for (const snapshot of accountRows) { - currenciesSet.add(snapshot.currency); accountsMap.set(snapshot.id, Account.restore(snapshot)); } - return { accountsMap, currenciesSet }; + return { accountsMap }; } - // TODO: return this method when system accounts are needed for some operation type (e.g. currency trading) and implement findOrCreateSystemAccount in AccountFactory - // private async preloadSystemAccounts( - // user: User, - // currenciesSet: Set, - // ): Promise> { - // const systemAccountsMap = new Map(); - - // for (const currency of currenciesSet) { - // const systemAccount = await this.accountFactory.findOrCreateSystemAccount( - // user, - // currency, - // ); - - // systemAccountsMap.set(currency, systemAccount); - // } - - // return systemAccountsMap; - // } - - async loadContext( + loadContext( user: User, operations: OperationRequestDTO[], ): Promise { - const { accountsMap } = await this.preloadAccounts(user, operations); - - // TODO: Return system account when it is needed for some operation type (e.g. currency trading) and implement findOrCreateSystemAccount in AccountFactory - const systemAccountsMap = new Map(); - - return { accountsMap, systemAccountsMap }; + return this.preloadAccounts(user, operations); } } diff --git a/apps/backend/src/application/services/__tests__/account.factory.test.ts b/apps/backend/src/application/services/__tests__/account.factory.test.ts deleted file mode 100644 index f705e379..00000000 --- a/apps/backend/src/application/services/__tests__/account.factory.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { CurrencyCode } from '@ledgerly/shared/types'; -import type { AccountRepositoryInterface } from 'src/application/interfaces'; -import { createUser } from 'src/db/createTestUser'; -import { User } from 'src/domain'; -import { Account, AccountType } from 'src/domain/'; -import { Amount, Currency, Name } from 'src/domain/domain-core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { AccountFactory } from '../account.factory'; - -describe('CreateAccountUseCase', () => { - let user: User; - - const mockAccountRepository = { - create: vi.fn(), - findSystemAccount: vi.fn(), - }; - - const accountFactory = new AccountFactory( - mockAccountRepository as unknown as AccountRepositoryInterface, - ); - - const accountName = 'Test Account'; - const description = 'Test account description'; - const initialBalance = Amount.create('1000').valueOf(); - const currency = 'USD' as CurrencyCode; - const accountType = 'asset'; - - beforeEach(async () => { - user = await createUser(); - }); - - describe('createAccount', () => { - it('should create an account with correct properties', async () => { - const expectedAccount = Account.create( - user, - Name.create(accountName), - description, - Amount.create(initialBalance), - Currency.create(currency), - AccountType.create(accountType), - ); - - mockAccountRepository.create.mockResolvedValue( - expectedAccount.toSnapshot(), - ); - - const account = await accountFactory.createAccount(user, { - currency, - description, - initialBalance, - name: accountName, - type: accountType, - }); - - expect(account).toBeInstanceOf(Account); - - const accountSnapshot = account.toSnapshot(); - - expect(accountSnapshot.name).toBe(accountName); - expect(accountSnapshot.description).toBe(description); - expect(accountSnapshot.initialBalance).toBe(initialBalance); - expect(accountSnapshot.currency).toBe(currency); - expect(accountSnapshot.type).toBe(accountType); - expect(accountSnapshot.isSystem).toBe(false); - - expect(mockAccountRepository.create).toHaveBeenCalledWith( - accountSnapshot, - ); - }); - }); -}); diff --git a/apps/backend/src/application/services/account.factory.ts b/apps/backend/src/application/services/account.factory.ts deleted file mode 100644 index e75cc3a0..00000000 --- a/apps/backend/src/application/services/account.factory.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { AccountCreateDTO } from '@ledgerly/shared/types'; -import { AccountType, Account } from 'src/domain/'; -import { Amount, Currency, Name } from 'src/domain/domain-core'; -import { User } from 'src/domain/users/user.entity'; - -import { AccountRepositoryInterface } from '../interfaces'; - -export class AccountFactory { - constructor( - protected readonly accountRepository: AccountRepositoryInterface, - ) {} - - async createAccount(user: User, data: AccountCreateDTO): Promise { - const { currency, description, initialBalance, name, type } = data; - - const account = Account.create( - user, - Name.create(name), - description, - Amount.create(initialBalance), - Currency.create(currency), - AccountType.create(type), - ); - - await this.accountRepository.create(account.toSnapshot()); - - return account; - } -} diff --git a/apps/backend/src/application/services/index.ts b/apps/backend/src/application/services/index.ts index 60f316b9..3847868e 100644 --- a/apps/backend/src/application/services/index.ts +++ b/apps/backend/src/application/services/index.ts @@ -1,2 +1 @@ -export * from './account.factory'; export * from './TransactionService'; diff --git a/apps/backend/src/application/shared/ensureOwnedSnapshot.ts b/apps/backend/src/application/shared/ensureOwnedSnapshot.ts new file mode 100644 index 00000000..15bd0f5c --- /dev/null +++ b/apps/backend/src/application/shared/ensureOwnedSnapshot.ts @@ -0,0 +1,38 @@ +import { UUID } from '@ledgerly/shared/types'; +import { + EntityNotFoundError, + UnauthorizedAccessError, +} from 'src/application/application.errors'; +import { User } from 'src/domain'; + +export type EnsureOwnedSnapshotOptions = { + entityId: UUID; + entityType: string; + getOwnerId: (entity: T) => UUID; + load: (userId: UUID, entityId: UUID) => Promise; + user: User; +}; + +export type EnsureOwnedSnapshotFn = ( + options: EnsureOwnedSnapshotOptions, +) => Promise; + +export const ensureOwnedSnapshot: EnsureOwnedSnapshotFn = async ({ + entityId, + entityType, + getOwnerId, + load, + user, +}: EnsureOwnedSnapshotOptions): Promise => { + const entity = await load(user.getId().valueOf(), entityId); + + if (!entity) { + throw new EntityNotFoundError({ entityId, entityType }); + } + + if (!user.verifyOwnership(getOwnerId(entity))) { + throw new UnauthorizedAccessError({ entityId, entityType }); + } + + return entity; +}; diff --git a/apps/backend/src/application/usecases/accounts/__tests__/deleteAccount.test.ts b/apps/backend/src/application/usecases/accounts/__tests__/archiveAccount.test.ts similarity index 81% rename from apps/backend/src/application/usecases/accounts/__tests__/deleteAccount.test.ts rename to apps/backend/src/application/usecases/accounts/__tests__/archiveAccount.test.ts index 46f8aaca..02a91271 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/deleteAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/archiveAccount.test.ts @@ -1,4 +1,3 @@ -import { CurrencyCode } from '@ledgerly/shared/types'; import type { AccountRepositoryInterface } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { createUser } from 'src/db/createTestUser'; @@ -7,16 +6,16 @@ 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 { DeleteAccountUseCase } from '../deleteAccount'; +import { ArchiveAccountUseCase } from '../archiveAccount'; -describe('DeleteAccountUseCase', async () => { - let deleteAccountUseCase: DeleteAccountUseCase; +describe('ArchiveAccountUseCase', async () => { + let archiveAccountUseCase: ArchiveAccountUseCase; const user = await createUser(); let mockAccountRepository: { - delete: ReturnType; getById: ReturnType; + softDelete: ReturnType; }; let mockUserRepository: { getById: ReturnType }; @@ -28,7 +27,6 @@ describe('DeleteAccountUseCase', async () => { const accountName = 'Test Account'; const description = 'Test account description'; const initialBalance = Amount.create('1000').valueOf(); - const currencyCode = 'USD' as CurrencyCode; const accountType = 'asset'; const mockUser = { @@ -39,8 +37,8 @@ describe('DeleteAccountUseCase', async () => { }; const mockAccountData: AccountSnapshot = { + commodityId: Id.create().valueOf(), createdAt: Timestamp.create().valueOf(), - currency: currencyCode, currentClearedBalanceLocal: initialBalance, description, id: Id.create().valueOf(), @@ -60,15 +58,15 @@ describe('DeleteAccountUseCase', async () => { beforeEach(() => { mockAccountRepository = { - delete: vi.fn(), getById: vi.fn(), + softDelete: vi.fn(), }; mockUserRepository = { getById: vi.fn(), }; - deleteAccountUseCase = new DeleteAccountUseCase( + archiveAccountUseCase = new ArchiveAccountUseCase( mockAccountRepository as unknown as AccountRepositoryInterface, ); }); @@ -77,12 +75,12 @@ describe('DeleteAccountUseCase', async () => { it('should mark account as archived', async () => { mockUserRepository.getById.mockResolvedValue(mockUser); mockAccountRepository.getById.mockResolvedValue(mockAccountData); - mockAccountRepository.delete.mockResolvedValue(mockSavedAccountData); + mockAccountRepository.softDelete.mockResolvedValue(mockSavedAccountData); - const result = await deleteAccountUseCase.execute(user, accountId); + const result = await archiveAccountUseCase.execute(user, accountId); const { updatedAt: _updatedAt, ...expectedAccountData } = mockAccountData; - expect(mockAccountRepository.delete).toHaveBeenCalledWith( + expect(mockAccountRepository.softDelete).toHaveBeenCalledWith( user.getId().valueOf(), accountId, expect.objectContaining({ 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 facb8d7d..05e255b7 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/createAccount.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/createAccount.test.ts @@ -1,9 +1,7 @@ -import { CurrencyCode } from '@ledgerly/shared/types'; -import { AccountFactory } from 'src/application/services/account.factory'; +import { AccountRepositoryInterface } from 'src/application'; import { createUser } from 'src/db/createTestUser'; -import { AccountType } from 'src/domain'; -import { Account } from 'src/domain/accounts/account.entity'; -import { Amount, Currency, Name } from 'src/domain/domain-core'; +import { Commodity } from 'src/domain'; +import { Amount, CommodityCode } from 'src/domain/domain-core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CreateAccountUseCase } from '../createAccount'; @@ -13,21 +11,26 @@ describe('CreateAccountUseCase', async () => { let createAccountUseCase: CreateAccountUseCase; - let accountFactory: { createAccount: ReturnType }; + const accountRepository = { + create: vi.fn(), + }; const name = 'Test Account'; const description = 'Test account description'; const initialBalance = Amount.create('1000').valueOf(); - const currency = 'USD' as CurrencyCode; + const currentClearedBalanceLocal = Amount.create('0').valueOf(); + + const commodity = Commodity.create(user, { + code: CommodityCode.create('USD').valueOf(), + name: 'Test Commodity', + precision: 2, + symbol: null, + }); const type = 'asset'; beforeEach(() => { - accountFactory = { - createAccount: vi.fn(), - }; - createAccountUseCase = new CreateAccountUseCase( - accountFactory as unknown as AccountFactory, + accountRepository as unknown as AccountRepositoryInterface, ); }); @@ -35,36 +38,36 @@ describe('CreateAccountUseCase', async () => { it('should create a new account successfully', async () => { // Arrange - const mockedAccount = Account.create( - user, - Name.create(name), - description, - Amount.create(initialBalance), - Currency.create(currency), - AccountType.create(type), - ); - - accountFactory.createAccount.mockResolvedValue(mockedAccount); + const mockedCommoditySnapshot = commodity.toSnapshot(); // Act const result = await createAccountUseCase.execute(user, { - currency: currency, + commodityId: mockedCommoditySnapshot.id, description, initialBalance, name, type, }); - expect(accountFactory.createAccount).toHaveBeenCalledWith(user, { - currency: currency, - description, - initialBalance, - name, - type, - }); + expect(accountRepository.create).toHaveBeenCalledWith( + user.getId().valueOf(), + { + commodityId: result.commodityId, + createdAt: result.createdAt, + currentClearedBalanceLocal, + description, + id: result.id, + initialBalance, + isSystem: false, + isTombstone: false, + name, + type, + updatedAt: result.updatedAt, + userId: result.userId, + }, + ); expect(result).toMatchObject({ - currency, description, initialBalance, isSystem: false, 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 3afa62bb..624b701c 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/getAccountById.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/getAccountById.test.ts @@ -1,8 +1,4 @@ -import { - AccountTypeValue, - apiErrorCodes, - CurrencyCode, -} from '@ledgerly/shared/types'; +import { AccountTypeValue, apiErrorCodes } from '@ledgerly/shared/types'; import { EntityNotFoundError, UnauthorizedAccessError, @@ -11,6 +7,7 @@ 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 { 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'; @@ -34,7 +31,6 @@ describe('GetAccountByIdUseCase', async () => { const accountName = 'Test Account'; const description = 'Test account description'; const initialBalance = Amount.create('1000').valueOf(); - const currency = 'USD' as CurrencyCode; const accountType = 'asset' as AccountTypeValue; const mockUser = { @@ -44,9 +40,9 @@ describe('GetAccountByIdUseCase', async () => { name: 'Test User', }; - const mockSavedAccountData = { + const mockSavedAccountData: AccountSnapshot = { + commodityId: Id.create().valueOf(), createdAt: Timestamp.create().valueOf(), - currency, currentClearedBalanceLocal: initialBalance, description, id: accountId, 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 08321e04..3200089e 100644 --- a/apps/backend/src/application/usecases/accounts/__tests__/getAllAccounts.test.ts +++ b/apps/backend/src/application/usecases/accounts/__tests__/getAllAccounts.test.ts @@ -1,7 +1,8 @@ -import { AccountTypeValue, CurrencyCode } from '@ledgerly/shared/types'; +import { AccountTypeValue } from '@ledgerly/shared/types'; 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 { 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'; @@ -23,12 +24,11 @@ describe('GetAllAccounts', async () => { '550e8400-e29b-41d4-a716-446655440001', ).valueOf(); const initialBalance = Amount.create('1000').valueOf(); - const currency = 'USD' as CurrencyCode; const accountType = 'asset' as AccountTypeValue; - const mockSavedAccountData = { + const mockSavedAccountData: AccountSnapshot = { + commodityId: Id.create().valueOf(), createdAt: Timestamp.create().valueOf(), - currency, currentClearedBalanceLocal: initialBalance, description, id: accountId, 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 209671b5..f096d1ac 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,10 @@ -import { - AccountTypeValue, - apiErrorCodes, - CurrencyCode, -} from '@ledgerly/shared/types'; +import { AccountTypeValue, apiErrorCodes } from '@ledgerly/shared/types'; import { EntityNotFoundError } from 'src/application/application.errors'; import type { AccountRepositoryInterface } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; import { createUser } from 'src/db/createTestUser'; import { Account } from 'src/domain/accounts/account.entity'; +import { AccountSnapshot } from 'src/domain/accounts/types'; import { Amount, Timestamp } from 'src/domain/domain-core'; import { Id } from 'src/domain/domain-core/value-objects/Id'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -32,12 +29,11 @@ describe('UpdateAccount', async () => { const accountName = 'Test Account'; const description = 'Test account description'; const initialBalance = Amount.create('1000').valueOf(); - const currency = 'USD' as CurrencyCode; const accountType = 'asset' as AccountTypeValue; - const mockAccountData = { + const mockAccountData: AccountSnapshot = { + commodityId: Id.create().valueOf(), createdAt: Timestamp.create().valueOf(), - currency, currentClearedBalanceLocal: initialBalance, description, id: accountId, @@ -120,7 +116,7 @@ describe('UpdateAccount', async () => { // - 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 validate and update different fields (name, description, type, originalCurrency) + // - should validate and update different fields (name, description, type, commodityId) // - should handle Account domain validation errors // - should handle repository errors properly }); diff --git a/apps/backend/src/application/usecases/accounts/deleteAccount.ts b/apps/backend/src/application/usecases/accounts/archiveAccount.ts similarity index 86% rename from apps/backend/src/application/usecases/accounts/deleteAccount.ts rename to apps/backend/src/application/usecases/accounts/archiveAccount.ts index aece64fa..34bbd5e9 100644 --- a/apps/backend/src/application/usecases/accounts/deleteAccount.ts +++ b/apps/backend/src/application/usecases/accounts/archiveAccount.ts @@ -7,7 +7,7 @@ import { AccountRepositoryInterface } from '../../interfaces'; import { AccountUseCaseBase } from './accountBase'; -export class DeleteAccountUseCase extends AccountUseCaseBase { +export class ArchiveAccountUseCase extends AccountUseCaseBase { constructor(accountRepository: AccountRepositoryInterface) { super(accountRepository); } @@ -18,7 +18,7 @@ export class DeleteAccountUseCase extends AccountUseCaseBase { account.markAsDeleted(); - const deletedAccount = await this.accountRepository.delete( + const deletedAccount = await this.accountRepository.softDelete( user.getId().valueOf(), accountId, account.toSnapshot(), diff --git a/apps/backend/src/application/usecases/accounts/createAccount.ts b/apps/backend/src/application/usecases/accounts/createAccount.ts index 560ac22a..ada7e145 100644 --- a/apps/backend/src/application/usecases/accounts/createAccount.ts +++ b/apps/backend/src/application/usecases/accounts/createAccount.ts @@ -1,25 +1,28 @@ import { AccountCreateDTO, AccountResponseDTO } from '@ledgerly/shared/types'; +import type { AccountRepositoryInterface } from 'src/application/interfaces'; import { AccountMapper } from 'src/application/mappers'; -import { AccountFactory } from 'src/application/services'; +import { Account } from 'src/domain/'; import { User } from 'src/domain/users/user.entity'; - export class CreateAccountUseCase { - constructor(protected readonly accountFactory: AccountFactory) {} - + constructor( + protected readonly accountRepository: AccountRepositoryInterface, + ) {} async execute( user: User, data: AccountCreateDTO, ): Promise { - const { currency, description, initialBalance, name, type } = data; + const account = Account.create( + user, + AccountMapper.toCreateAccountProps(data), + ); + + const accountSnapshot = account.toSnapshot(); - const account = await this.accountFactory.createAccount(user, { - currency, - description, - initialBalance, - name, - type, - }); + await this.accountRepository.create( + user.getId().valueOf(), + accountSnapshot, + ); - return AccountMapper.toResponseDTOFromSnapshot(account.toSnapshot()); + return AccountMapper.toResponseDTOFromSnapshot(accountSnapshot); } } diff --git a/apps/backend/src/application/usecases/accounts/index.ts b/apps/backend/src/application/usecases/accounts/index.ts index 4f944e0f..abdee2d3 100644 --- a/apps/backend/src/application/usecases/accounts/index.ts +++ b/apps/backend/src/application/usecases/accounts/index.ts @@ -1,4 +1,4 @@ -export { DeleteAccountUseCase } from './deleteAccount'; +export { ArchiveAccountUseCase } from './archiveAccount'; export { CreateAccountUseCase } from './createAccount'; export { GetAccountByIdUseCase } from './getAccountById'; export { GetAllAccountsUseCase } from './getAllAccounts'; diff --git a/apps/backend/src/application/usecases/commodities/__tests__/archiveCommodity.test.ts b/apps/backend/src/application/usecases/commodities/__tests__/archiveCommodity.test.ts new file mode 100644 index 00000000..ed2d00d2 --- /dev/null +++ b/apps/backend/src/application/usecases/commodities/__tests__/archiveCommodity.test.ts @@ -0,0 +1,137 @@ +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__/createCommodity.test.ts b/apps/backend/src/application/usecases/commodities/__tests__/createCommodity.test.ts new file mode 100644 index 00000000..5f668610 --- /dev/null +++ b/apps/backend/src/application/usecases/commodities/__tests__/createCommodity.test.ts @@ -0,0 +1,68 @@ +import { CommodityCreateDTO } from '@ledgerly/shared/types'; +import { CommodityRepositoryInterface } from 'src/application'; +import { createUser } from 'src/db/createTestUser'; +import { User } from 'src/domain'; +import { Name, CommodityCode } from 'src/domain/domain-core/'; +import { beforeEach, describe, expect, it, vi, beforeAll } from 'vitest'; + +import { CreateCommodityUseCase } from '../createCommodity'; + +describe('CreateCommodityUseCase', () => { + let user: User; + let createCommodityUseCase: CreateCommodityUseCase; + + const commodityRepository = { + create: vi.fn(), + }; + + beforeAll(async () => { + user = await createUser(); + }); + + beforeEach(() => { + createCommodityUseCase = new CreateCommodityUseCase( + commodityRepository as unknown as CommodityRepositoryInterface, + ); + }); + + describe('execute', () => { + it('should create a new commodity successfully', async () => { + // Arrange + + const data: CommodityCreateDTO = { + code: CommodityCode.create('TEST').valueOf(), + name: Name.create('Test Commodity').valueOf(), + precision: 2, + symbol: null, + }; + + // Act + const result = await createCommodityUseCase.execute(user, data); + + // Assert + expect(commodityRepository.create).toHaveBeenCalledWith( + user.getId().valueOf(), + { + code: result.code, + createdAt: result.createdAt, + id: result.id, + isTombstone: false, + name: result.name, + precision: result.precision, + symbol: result.symbol, + updatedAt: result.updatedAt, + userId: result.userId, + }, + ); + + expect(result).toMatchObject({ + code: data.code, + isTombstone: false, + name: data.name, + precision: data.precision, + symbol: data.symbol, + userId: user.getId().valueOf(), + }); + }); + }); +}); diff --git a/apps/backend/src/application/usecases/commodities/__tests__/getAllCommodities.test.ts b/apps/backend/src/application/usecases/commodities/__tests__/getAllCommodities.test.ts new file mode 100644 index 00000000..ee5c908b --- /dev/null +++ b/apps/backend/src/application/usecases/commodities/__tests__/getAllCommodities.test.ts @@ -0,0 +1,83 @@ +import { CommodityRepositoryInterface } from 'src/application'; +import { User } from 'src/domain/users/user.entity'; +import { createUser } from 'src/testing/helpers'; +import { describe, vi, beforeAll, beforeEach, expect, it } from 'vitest'; + +import { GetAllCommoditiesUseCase } from '../getAllCommodities'; + +describe('GetAllCommoditiesUseCase', () => { + let user: User; + + const commodityRepository = { + getAll: vi.fn(), + }; + + const getAllCommoditiesUseCase = new GetAllCommoditiesUseCase( + commodityRepository as unknown as CommodityRepositoryInterface, + ); + + beforeAll(async () => { + user = await createUser(); + }); + + beforeEach(() => { + commodityRepository.getAll.mockClear(); + }); + + describe('execute', () => { + it('should return an array of commodities for the user', async () => { + // Arrange + const mockCommodities = [ + { + code: 'USD', + createdAt: new Date(), + id: '1', + isTombstone: false, + name: 'US Dollar', + precision: 2, + symbol: '$', + updatedAt: new Date(), + userId: user.getId().valueOf(), + }, + { + code: 'EUR', + createdAt: new Date(), + id: '2', + isTombstone: false, + name: 'Euro', + precision: 2, + symbol: '€', + updatedAt: new Date(), + userId: user.getId().valueOf(), + }, + ]; + + const queryStatus = 'active'; + + commodityRepository.getAll.mockResolvedValue(mockCommodities); + + // Act + const result = await getAllCommoditiesUseCase.execute(user, queryStatus); + + // Assert + expect(commodityRepository.getAll).toHaveBeenCalledWith( + user.getId().valueOf(), + queryStatus, + ); + + expect(result).toEqual( + mockCommodities.map((commodity) => ({ + code: commodity.code, + createdAt: commodity.createdAt, + id: commodity.id, + isTombstone: commodity.isTombstone, + name: commodity.name, + precision: commodity.precision, + symbol: commodity.symbol, + updatedAt: commodity.updatedAt, + userId: commodity.userId, + })), + ); + }); + }); +}); diff --git a/apps/backend/src/application/usecases/commodities/__tests__/getCommodityById.test.ts b/apps/backend/src/application/usecases/commodities/__tests__/getCommodityById.test.ts new file mode 100644 index 00000000..4407e750 --- /dev/null +++ b/apps/backend/src/application/usecases/commodities/__tests__/getCommodityById.test.ts @@ -0,0 +1,65 @@ +import { CommodityRepositoryInterface } from 'src/application'; +import { Id } 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 { GetCommodityByIdUseCase } from '../getCommodityById'; + +describe('GetCommodityByIdUseCase', () => { + let user: User; + + const commodityRepository = { + getById: vi.fn(), + }; + + const getCommodityByIdUseCase = new GetCommodityByIdUseCase( + commodityRepository as unknown as CommodityRepositoryInterface, + ); + + beforeAll(async () => { + user = await createUser(); + }); + + beforeEach(() => { + commodityRepository.getById.mockClear(); + }); + + describe('execute', () => { + it('should return a commodity by its ID for the user', async () => { + // Arrange + const commodityId = Id.create().valueOf(); + + const mockCommodity = { + code: 'USD', + createdAt: new Date(), + id: commodityId, + isTombstone: false, + name: 'US Dollar', + precision: 2, + symbol: '$', + updatedAt: new Date(), + userId: user.getId().valueOf(), + }; + + commodityRepository.getById.mockResolvedValue(mockCommodity); + + // Act + const result = await getCommodityByIdUseCase.execute(user, commodityId); + + // Assert + expect(commodityRepository.getById).toHaveBeenCalledWith( + user.getId().valueOf(), + commodityId, + ); + expect(result).toMatchObject({ + code: mockCommodity.code, + isTombstone: mockCommodity.isTombstone, + name: mockCommodity.name, + precision: mockCommodity.precision, + symbol: mockCommodity.symbol, + userId: mockCommodity.userId, + }); + }); + }); +}); diff --git a/apps/backend/src/application/usecases/commodities/__tests__/updateCommodity.test.ts b/apps/backend/src/application/usecases/commodities/__tests__/updateCommodity.test.ts new file mode 100644 index 00000000..e4c9a8fa --- /dev/null +++ b/apps/backend/src/application/usecases/commodities/__tests__/updateCommodity.test.ts @@ -0,0 +1,155 @@ +import { CommodityResponseDTO } from '@ledgerly/shared/types'; +import { CommodityRepositoryInterface } 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 { UpdateCommodityUseCase } from '../updateCommodity'; + +describe('UpdateCommodityUseCase', () => { + let user: User; + + const commodityRepository = { + getById: vi.fn(), + update: vi.fn(), + }; + + const updateCommodityUseCase = new UpdateCommodityUseCase( + commodityRepository as unknown as CommodityRepositoryInterface, + ensureOwnedSnapshot, + ); + + beforeAll(async () => { + user = await createUser(); + }); + + beforeEach(() => { + vi.useRealTimers(); + commodityRepository.update.mockClear(); + commodityRepository.getById.mockClear(); + }); + + describe('execute', () => { + it('should update 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 updateData = { + name: 'Updated Commodity', + symbol: 'U', + }; + + const updatedCommodityData = { + ...commodity.toSnapshot(), + ...updateData, + updatedAt: Timestamp.restore(timestampDuringUpdatingValue).valueOf(), + }; + + commodityRepository.getById.mockResolvedValue(commodity.toSnapshot()); + + commodityRepository.update.mockResolvedValue(updatedCommodityData); + + vi.setSystemTime(new Date(timestampDuringUpdatingValue)); + + const updatedCommoditySnapshot: CommodityResponseDTO = { + ...updatedCommodityData, + updatedAt: Timestamp.restore(timestampDuringUpdatingValue).valueOf(), + }; + + // Act + const result = await updateCommodityUseCase.execute( + user, + commodityId, + updateData, + ); + + // Assert + expect(commodityRepository.getById).toHaveBeenCalledWith( + userid, + commodityId, + ); + + expect(commodityRepository.update).toHaveBeenCalledWith( + userid, + commodityId, + { + ...updatedCommodityData, + updatedAt: Timestamp.restore(timestampDuringUpdatingValue).valueOf(), + }, + ); + + expect(result).toEqual(updatedCommoditySnapshot); + }); + + 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( + updateCommodityUseCase.execute(user, nonExistentCommodityId, { + name: 'Updated Commodity', + symbol: 'U', + }), + ).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( + updateCommodityUseCase.execute(user, commodityId, { + name: 'Updated Commodity', + symbol: 'U', + }), + ).rejects.toThrowError( + new UnauthorizedAccessError({ + entityId: commodityId, + entityType: Commodity.entityType, + }), + ); + }); + }); +}); diff --git a/apps/backend/src/application/usecases/commodities/archiveCommodity.ts b/apps/backend/src/application/usecases/commodities/archiveCommodity.ts new file mode 100644 index 00000000..c17862f9 --- /dev/null +++ b/apps/backend/src/application/usecases/commodities/archiveCommodity.ts @@ -0,0 +1,43 @@ +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/createCommodity.ts b/apps/backend/src/application/usecases/commodities/createCommodity.ts new file mode 100644 index 00000000..bd512d88 --- /dev/null +++ b/apps/backend/src/application/usecases/commodities/createCommodity.ts @@ -0,0 +1,32 @@ +import { + CommodityCreateDTO, + CommodityResponseDTO, +} from '@ledgerly/shared/types'; +import { CommodityRepositoryInterface } from 'src/application/interfaces'; +import { CommodityMapper } from 'src/application/mappers/commodity.mapper'; +import { Commodity } from 'src/domain/commodities'; +import { User } from 'src/domain/users/user.entity'; + +export class CreateCommodityUseCase { + constructor( + protected readonly commodityRepository: CommodityRepositoryInterface, + ) {} + async execute( + user: User, + data: CommodityCreateDTO, + ): Promise { + const commodity = Commodity.create( + user, + CommodityMapper.toCreateCommodityProps(data), + ); + + const commoditySnapshot = commodity.toSnapshot(); + + await this.commodityRepository.create( + user.getId().valueOf(), + commoditySnapshot, + ); + + return CommodityMapper.toResponseDTOFromSnapshot(commoditySnapshot); + } +} diff --git a/apps/backend/src/application/usecases/commodities/getAllCommodities.ts b/apps/backend/src/application/usecases/commodities/getAllCommodities.ts new file mode 100644 index 00000000..7f8e6da2 --- /dev/null +++ b/apps/backend/src/application/usecases/commodities/getAllCommodities.ts @@ -0,0 +1,24 @@ +import { CommodityResponseDTO, QueryStatus } from '@ledgerly/shared/types'; +import { CommodityMapper } from 'src/application'; +import { CommodityRepositoryInterface } from 'src/application/interfaces'; +import { User } from 'src/domain/users/user.entity'; + +export class GetAllCommoditiesUseCase { + constructor( + protected readonly commodityRepository: CommodityRepositoryInterface, + ) {} + + async execute( + user: User, + status: QueryStatus, + ): Promise { + const commoditiesSnapshots = await this.commodityRepository.getAll( + user.getId().valueOf(), + status, + ); + + return commoditiesSnapshots.map((commoditySnapshot) => + CommodityMapper.toResponseDTOFromSnapshot(commoditySnapshot), + ); + } +} diff --git a/apps/backend/src/application/usecases/commodities/getCommodityById.ts b/apps/backend/src/application/usecases/commodities/getCommodityById.ts new file mode 100644 index 00000000..7c02f65e --- /dev/null +++ b/apps/backend/src/application/usecases/commodities/getCommodityById.ts @@ -0,0 +1,19 @@ +import { CommodityResponseDTO, UUID } from '@ledgerly/shared/types'; +import { CommodityMapper } from 'src/application'; +import { CommodityRepositoryInterface } from 'src/application/interfaces'; +import { User } from 'src/domain/users/user.entity'; + +export class GetCommodityByIdUseCase { + constructor( + protected readonly commodityRepository: CommodityRepositoryInterface, + ) {} + + async execute(user: User, commodityId: UUID): Promise { + const commodity = await this.commodityRepository.getById( + user.getId().valueOf(), + commodityId, + ); + + return CommodityMapper.toResponseDTOFromSnapshot(commodity); + } +} diff --git a/apps/backend/src/application/usecases/commodities/index.ts b/apps/backend/src/application/usecases/commodities/index.ts new file mode 100644 index 00000000..68430416 --- /dev/null +++ b/apps/backend/src/application/usecases/commodities/index.ts @@ -0,0 +1,5 @@ +export { CreateCommodityUseCase } from './createCommodity'; +export { UpdateCommodityUseCase } from './updateCommodity'; +export { GetAllCommoditiesUseCase } from './getAllCommodities'; +export { GetCommodityByIdUseCase } from './getCommodityById'; +export { ArchiveCommodityUseCase } from './archiveCommodity'; diff --git a/apps/backend/src/application/usecases/commodities/updateCommodity.ts b/apps/backend/src/application/usecases/commodities/updateCommodity.ts new file mode 100644 index 00000000..0ef19de6 --- /dev/null +++ b/apps/backend/src/application/usecases/commodities/updateCommodity.ts @@ -0,0 +1,51 @@ +import { + CommodityResponseDTO, + UUID, + CommodityUpdateDTO, +} 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 UpdateCommodityUseCase { + constructor( + protected readonly commodityRepository: CommodityRepositoryInterface, + protected readonly ensureOwnedSnapshot: EnsureOwnedSnapshotFn, + ) {} + + async execute( + user: User, + 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(), + ); + + return CommodityMapper.toResponseDTOFromSnapshot(updatedCommodity); + } +} diff --git a/apps/backend/src/application/usecases/index.ts b/apps/backend/src/application/usecases/index.ts index ad3786b0..b1b71415 100644 --- a/apps/backend/src/application/usecases/index.ts +++ b/apps/backend/src/application/usecases/index.ts @@ -1,2 +1,4 @@ export * from './accounts'; export * from './auth'; +export * from './commodities'; +export * from './transaction'; diff --git a/apps/backend/src/application/usecases/transaction/__tests__/createTransaction.test.ts b/apps/backend/src/application/usecases/transaction/__tests__/createTransaction.test.ts index 22cca41a..86361224 100644 --- a/apps/backend/src/application/usecases/transaction/__tests__/createTransaction.test.ts +++ b/apps/backend/src/application/usecases/transaction/__tests__/createTransaction.test.ts @@ -32,8 +32,7 @@ describe('CreateTransactionUseCase', () => { transactionContextLoader as unknown as TransactionContextLoader, ); - const transactionData = { - currencyCode: 'USD', + const transactionRawData = { description: 'Test transaction', postingDate: '2024-01-01', transactionDate: '2024-01-01', @@ -49,16 +48,18 @@ describe('CreateTransactionUseCase', () => { }); beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); + + transactionManager.run.mockImplementation((cb: () => unknown) => cb()); }); describe('execute', () => { it('should create a new transaction with entries successfully', async () => { const { transactionContext, transactionDTO } = TransactionBuilder.request( { - accounts: ['USD'], + currencies: ['USD'], operations: operationsData, - settings: transactionData, + settings: transactionRawData, user, }, ); @@ -72,8 +73,8 @@ describe('CreateTransactionUseCase', () => { transactionDTO, ); - expect(result.postingDate).toBe(transactionData.postingDate); - expect(result.transactionDate).toBe(transactionData.transactionDate); + expect(result.postingDate).toBe(transactionRawData.postingDate); + expect(result.transactionDate).toBe(transactionRawData.transactionDate); transactionDTO.operations.forEach((operation, index) => { const matchedOperation = result.operations[index]; @@ -96,18 +97,18 @@ describe('CreateTransactionUseCase', () => { expect(savedUserId).toBe(user.getId().valueOf()); expect(savedTransaction.getPostingDate().valueOf()).toBe( - transactionData.postingDate, + transactionRawData.postingDate, ); expect(savedTransaction.getTransactionDate().valueOf()).toBe( - transactionData.transactionDate, + transactionRawData.transactionDate, ); expect(savedTransaction.getOperations().length).toBe( transactionDTO.operations.length, ); - expect(savedTransaction.description).toBe(transactionData.description); + expect(savedTransaction.description).toBe(transactionRawData.description); savedTransaction.getOperations().forEach((operation, index) => { const dtoOperation = transactionDTO.operations[index]; @@ -126,7 +127,6 @@ describe('CreateTransactionUseCase', () => { expect(result.id).toBe(savedTransaction.getId().valueOf()); expect(result.userId).toBe(user.getId().valueOf()); - expect(result.currency).toBe(transactionData.currencyCode); }); it('should propagate error when loadContext fails', async () => { @@ -134,9 +134,9 @@ describe('CreateTransactionUseCase', () => { transactionContextLoader.loadContext.mockRejectedValue(error); const { transactionDTO } = TransactionBuilder.request({ - accounts: ['USD'], + currencies: ['USD'], operations: [], - settings: transactionData, + settings: transactionRawData, user, }); @@ -151,9 +151,9 @@ describe('CreateTransactionUseCase', () => { const { transactionContext, transactionDTO } = TransactionBuilder.request( { - accounts: ['USD'], + currencies: ['USD'], operations: operationsData, - settings: transactionData, + settings: transactionRawData, user, }, ); @@ -175,9 +175,9 @@ describe('CreateTransactionUseCase', () => { const { transactionContext, transactionDTO } = TransactionBuilder.request( { - accounts: ['USD'], + currencies: ['USD'], operations: unbalancedOperations, - settings: transactionData, + settings: transactionRawData, user, }, ); diff --git a/apps/backend/src/application/usecases/transaction/__tests__/deleteTransaction.test.ts b/apps/backend/src/application/usecases/transaction/__tests__/deleteTransaction.test.ts index ab30211c..6a50995e 100644 --- a/apps/backend/src/application/usecases/transaction/__tests__/deleteTransaction.test.ts +++ b/apps/backend/src/application/usecases/transaction/__tests__/deleteTransaction.test.ts @@ -43,7 +43,7 @@ describe('DeleteTransactionUseCase', () => { vi.clearAllMocks(); const data = TransactionBuilder.transaction({ - accounts: ['USD', 'EUR'], + currencies: ['USD', 'EUR'], operations: [ { accountKey: 'USD', diff --git a/apps/backend/src/application/usecases/transaction/__tests__/getAllTransactions.test.ts b/apps/backend/src/application/usecases/transaction/__tests__/getAllTransactions.test.ts index 3976c2c8..8903aa97 100644 --- a/apps/backend/src/application/usecases/transaction/__tests__/getAllTransactions.test.ts +++ b/apps/backend/src/application/usecases/transaction/__tests__/getAllTransactions.test.ts @@ -9,13 +9,7 @@ import { OperationReadModel, TransactionReadModel, } from 'src/application/read-models'; -import { - Amount, - Currency, - DateValue, - Id, - Timestamp, -} from 'src/domain/domain-core'; +import { Amount, DateValue, Id, Timestamp } from 'src/domain/domain-core'; import { ForbiddenAccessError } from 'src/infrastructure/errors'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -63,8 +57,8 @@ describe('GetAllTransactionsUseCase', () => { const transactionId = Id.create().valueOf(); return { + commodityId: Id.create().valueOf(), createdAt: Timestamp.create().valueOf(), - currency: Currency.create('USD').valueOf(), description: 'Test transaction', id: transactionId, operations: [buildOperation(transactionId, userId)], diff --git a/apps/backend/src/application/usecases/transaction/__tests__/getTransactionById.test.ts b/apps/backend/src/application/usecases/transaction/__tests__/getTransactionById.test.ts index fe313ce3..f286e251 100644 --- a/apps/backend/src/application/usecases/transaction/__tests__/getTransactionById.test.ts +++ b/apps/backend/src/application/usecases/transaction/__tests__/getTransactionById.test.ts @@ -6,13 +6,7 @@ import { TransactionResponseDTO, } from 'src/application'; import { EntityNotFoundError } from 'src/application/application.errors'; -import { - Amount, - Currency, - DateValue, - Id, - Timestamp, -} from 'src/domain/domain-core'; +import { Amount, DateValue, Id, Timestamp } from 'src/domain/domain-core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { GetTransactionByIdUseCase } from '../GetTransactionById'; @@ -35,6 +29,7 @@ describe('GetTransactionByIdUseCase', () => { const userId = Id.create().valueOf(); const createdAt = Timestamp.create().valueOf(); const updatedAt = Timestamp.create().valueOf(); + const commodityId = Id.create().valueOf(); const mockOperation1: OperationReadModel = { accountId: Id.create().valueOf(), @@ -65,8 +60,8 @@ describe('GetTransactionByIdUseCase', () => { const operations = [mockOperation1, mockOperation2]; const mockTransactionData: TransactionReadModel = { + commodityId, createdAt, - currency: Currency.create('USD').valueOf(), description: 'Test Transaction', id: transactionId, operations, @@ -92,8 +87,8 @@ describe('GetTransactionByIdUseCase', () => { expect(transaction.version).toBe(mockTransactionData.version); expect(Object.keys(transaction).sort()).toEqual( [ + 'commodityId', 'createdAt', - 'currency', 'description', 'id', 'operations', diff --git a/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts b/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts index 3737a969..f789c571 100644 --- a/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts +++ b/apps/backend/src/application/usecases/transaction/__tests__/updateTransaction.test.ts @@ -147,7 +147,7 @@ describe('UpdateTransactionUseCase', () => { vi.clearAllMocks(); const data = TransactionBuilder.transaction({ - accounts: ['USD', 'EUR'], + currencies: ['USD', 'EUR'], operations: [ { accountKey: 'USD', diff --git a/apps/backend/src/application/usecases/transaction/index.ts b/apps/backend/src/application/usecases/transaction/index.ts index cf55c680..ab5e53aa 100644 --- a/apps/backend/src/application/usecases/transaction/index.ts +++ b/apps/backend/src/application/usecases/transaction/index.ts @@ -2,3 +2,4 @@ export { CreateTransactionUseCase } from './CreateTransaction'; export { DeleteTransactionUseCase } from './DeleteTransaction'; export { GetTransactionByIdUseCase } from './GetTransactionById'; export { GetAllTransactionsUseCase } from './GetAllTransactions'; +export { UpdateTransactionUseCase } from './UpdateTransaction'; diff --git a/apps/backend/src/db/createTestUser.ts b/apps/backend/src/db/createTestUser.ts index 829ff026..95d0db01 100644 --- a/apps/backend/src/db/createTestUser.ts +++ b/apps/backend/src/db/createTestUser.ts @@ -1,14 +1,13 @@ -import { Account, AccountType } from 'src/domain'; +import { Account, AccountType, Commodity } from 'src/domain'; import { Name, Email, Password, Amount, - Currency, + CommodityCode, } from 'src/domain/domain-core'; import { User } from 'src/domain/users/user.entity'; -// TODO: move to the test builder export const createUser = async ( params: { name?: string; @@ -28,63 +27,46 @@ export const createUser = async ( return User.create(userName, userEmail, userPassword); }; -export const createAccount = ( +export const createCommodity = ( user: User, - params: { currency?: Currency; description?: string; name?: string } = {}, -) => { - return Account.create( - user, - Name.create(params.name ?? 'Test Account'), - params.description ?? 'Account for testing', - Amount.create('0'), - params.currency ?? Currency.create('USD'), - AccountType.create('asset'), - ); -}; - -// export const createTransaction = ( -// user: User, -// params: { -// description?: string; -// postingDate?: IsoDateString; -// transactionDate?: IsoDateString; -// } = {}, -// ) => { -// const { -// description = 'Test Transaction', -// postingDate = '2023-01-01' as IsoDateString, -// transactionDate = '2023-01-01' as IsoDateString, -// } = params; + params: { + name?: string; + code?: string; + precision?: number; + symbol?: string | null; + } = {}, +): Commodity => { + const { + code = 'TEST', + name = 'Test Commodity', + precision = 2, + symbol = null, + } = params; -// // const postingDate = DateValue.restore(postingDate) + return Commodity.create(user, { + code: CommodityCode.create(code).valueOf(), + name: Name.create(name).valueOf(), + precision, + symbol, + }); +}; -// return Transaction.create( -// user, -// { -// description, -// entries: [], -// postingDate, -// transactionDate, -// }, -// {}, // EntryContext placeholder -// // DateValue.restore(transactionDate), -// ); -// }; +export const createAccount = ( + user: User, -// export const createEntry = ( -// user: User, -// transaction: Transaction, -// operations: Operation[], -// ) => { -// return Entry.create(user, transaction, 'Test entry', operations); -// }; + params: { + description?: string; + name?: string; + commodity?: Commodity; + } = {}, +) => { + const commodity = params.commodity ?? createCommodity(user); -// export const createOperation = ( -// user: User, -// account: Account, -// entry: Entry, -// amount: Amount, -// description: string, -// ) => { -// return Operation.create(user, account, entry, amount, description); -// }; + return Account.create(user, { + commodityId: commodity.getId(), + description: params.description ?? 'Account for testing', + initialBalance: Amount.create('0'), + name: Name.create(params.name ?? 'Test Account'), + type: AccountType.create('asset'), + }); +}; diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 70649edc..9dd52368 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -1,6 +1,6 @@ export * from './schemas/accounts'; export * from './schemas/transactions'; -export * from './schemas/currencies'; export * from './schemas/operations'; export * from './schemas/users'; export * from './schemas/settings'; +export * from './schemas/commodities'; diff --git a/apps/backend/src/db/schemas/accounts.ts b/apps/backend/src/db/schemas/accounts.ts index 3dc6ddf2..27163f8b 100644 --- a/apps/backend/src/db/schemas/accounts.ts +++ b/apps/backend/src/db/schemas/accounts.ts @@ -1,8 +1,9 @@ import { ACCOUNT_TYPE_VALUES } from '@ledgerly/shared/constants'; -import { CurrencyCode, UUID } from '@ledgerly/shared/types'; +import { UUID } from '@ledgerly/shared/types'; import { InferInsertModel, InferSelectModel } from 'drizzle-orm'; import { sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; +import { commoditiesTable } from './commodities'; import { createdAt, description, @@ -17,12 +18,11 @@ import { usersTable } from './users'; export const accountsTable = sqliteTable( 'accounts', { + commodityId: text('commodity_id') + .notNull() + .references(() => commoditiesTable.id) + .$type(), createdAt, - // Foreign key constraint to the currencies table has been removed. - // This allows invalid currency codes to be inserted, which may improve test performance, - // but creates a risk of data inconsistency in production. - // Consider implementing application-level validation for currency codes. - currency: text('currency').notNull().$type(), currentClearedBalanceLocal: getAmountColumn( 'current_cleared_balance_local', ), @@ -50,10 +50,3 @@ export type AccountDbRow = InferSelectModel; export type AccountDbInsert = InferInsertModel; export type AccountRepoInsert = AccountDbInsert; - -export type AccountDbUpdate = Partial< - Omit< - AccountDbRow, - 'id' | 'userId' | 'createdAt' | 'updatedAt' | 'isTombstone' - > ->; diff --git a/apps/backend/src/db/schemas/commodities.ts b/apps/backend/src/db/schemas/commodities.ts new file mode 100644 index 00000000..4a919a6e --- /dev/null +++ b/apps/backend/src/db/schemas/commodities.ts @@ -0,0 +1,43 @@ +import { + CommodityCodeString, + CommodityPrecisionNumber, + UUID, + CommoditySymbolString, +} from '@ledgerly/shared/types'; +import { InferInsertModel, InferSelectModel } from 'drizzle-orm'; +import { + integer, + sqliteTable, + text, + uniqueIndex, +} from 'drizzle-orm/sqlite-core'; + +import { createdAt, updatedAt, id, isTombstone } from './common'; +import { usersTable } from './users'; + +export const commoditiesTable = sqliteTable( + 'commodities', + { + code: text('code').notNull().$type(), + createdAt, + id, + isTombstone, + name: text('name').notNull(), + precision: integer('precision').notNull().$type(), + symbol: text('symbol').$type(), + updatedAt, + userId: text('user_id') + .notNull() + .references(() => usersTable.id, { onDelete: 'cascade' }) + .$type(), + }, + (table) => [ + uniqueIndex('commodities_user_id_code_unique_idx').on( + table.userId, + table.code, + ), + ], +); + +export type CommodityDbRow = InferSelectModel; +export type CommodityDbInsert = InferInsertModel; diff --git a/apps/backend/src/db/schemas/currencies.ts b/apps/backend/src/db/schemas/currencies.ts deleted file mode 100644 index 8886eeda..00000000 --- a/apps/backend/src/db/schemas/currencies.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { CurrencyCode } from '@ledgerly/shared/types'; -import { sqliteTable, text } from 'drizzle-orm/sqlite-core'; - -export const currenciesTable = sqliteTable('currencies', { - code: text('code').notNull().primaryKey().$type(), - name: text('name').notNull(), - symbol: text('symbol').notNull(), -}); diff --git a/apps/backend/src/db/schemas/index.ts b/apps/backend/src/db/schemas/index.ts index 8521abfd..5b3ed92b 100644 --- a/apps/backend/src/db/schemas/index.ts +++ b/apps/backend/src/db/schemas/index.ts @@ -1,6 +1,6 @@ export { accountsTable } from './accounts'; -export { currenciesTable } from './currencies'; export { operationsTable, operationsRelations } from './operations'; export { transactionsTable, transactionsRelations } from './transactions'; export { settingsTable } from './settings'; export { usersTable } from './users'; +export { commoditiesTable, CommodityDbRow } from './commodities'; diff --git a/apps/backend/src/db/schemas/settings.ts b/apps/backend/src/db/schemas/settings.ts index 898fe897..86d6c6fa 100644 --- a/apps/backend/src/db/schemas/settings.ts +++ b/apps/backend/src/db/schemas/settings.ts @@ -1,14 +1,9 @@ import { sqliteTable, text } from 'drizzle-orm/sqlite-core'; import { createdAt, updatedAt } from './common'; -import { currenciesTable } from './currencies'; import { usersTable } from './users'; export const settingsTable = sqliteTable('settings', { - baseCurrency: text('base_currency') - .notNull() - .default('RUB') - .references(() => currenciesTable.code), createdAt, updatedAt, userId: text('user_id') diff --git a/apps/backend/src/db/schemas/transactions.ts b/apps/backend/src/db/schemas/transactions.ts index b4a06b98..7269b673 100644 --- a/apps/backend/src/db/schemas/transactions.ts +++ b/apps/backend/src/db/schemas/transactions.ts @@ -1,8 +1,8 @@ -import { CurrencyCode, UUID } from '@ledgerly/shared/types'; +import { UUID } from '@ledgerly/shared/types'; import { InferInsertModel, InferSelectModel, relations } from 'drizzle-orm'; import { index, sqliteTable, text } from 'drizzle-orm/sqlite-core'; -import { OperationDbRow, operationsTable } from '../schema'; +import { commoditiesTable, OperationDbRow, operationsTable } from '../schema'; import { createdAt, @@ -18,8 +18,11 @@ import { usersTable } from './users'; export const transactionsTable = sqliteTable( 'transactions', { + commodityId: text('commodity_id') + .notNull() + .references(() => commoditiesTable.id) + .$type(), createdAt, - currency: text('currency').notNull().$type(), description, id, isTombstone, diff --git a/apps/backend/src/db/scripts/currenciesSeed.ts b/apps/backend/src/db/scripts/currenciesSeed.ts deleted file mode 100644 index 4e058330..00000000 --- a/apps/backend/src/db/scripts/currenciesSeed.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { CurrencyCode } from '@ledgerly/shared/types'; -import { DataBase } from 'src/db'; - -import { db } from '../index'; -import { currenciesTable } from '../schemas'; - -type Currency = { - code: CurrencyCode; - name: string; - symbol: string; -}; - -const defaultCurrencies: Currency[] = [ - { code: 'USD' as CurrencyCode, name: 'US Dollar', symbol: '$' }, - { code: 'EUR' as CurrencyCode, name: 'Euro', symbol: '€' }, - { code: 'GBP' as CurrencyCode, name: 'British Pound', symbol: '£' }, - { code: 'JPY' as CurrencyCode, name: 'Japanese Yen', symbol: '¥' }, - { code: 'CHF' as CurrencyCode, name: 'Swiss Franc', symbol: 'CHF' }, - { code: 'CAD' as CurrencyCode, name: 'Canadian Dollar', symbol: 'C$' }, -]; - -// TODO: fix dataBase: DataBase = db as unknown as DataBase -export const seedCurrencies = async ( - dataBase: DataBase = db as unknown as DataBase, -) => { - // console.info('🌱 Starting currency seeding...'); - - try { - // ✅ Используем Drizzle ORM с ON CONFLICT - for (const currency of defaultCurrencies) { - await dataBase.insert(currenciesTable).values(currency); - - // console.info(`✅ Currency ${currency.code} (${currency.name}) seeded`); - } - - // console.info('🎉 Currency seeding completed successfully!'); - } catch (error) { - console.error('❌ Error seeding currencies:', error); - throw error; - } -}; - -// ✅ Для прямого запуска скрипта -// if (import.meta.url === `file://${process.argv[1]}`) { -// const databaseUrl = process.env.DATABASE_URL || 'file:./dev.db'; - -// seedCurrencies(databaseUrl) -// .then(() => { -// console.info('🏁 Script completed'); -// process.exit(0); -// }) -// .catch((error) => { -// console.error('💥 Script failed:', error); -// process.exit(1); -// }); -// } diff --git a/apps/backend/src/db/test-db.ts b/apps/backend/src/db/test-db.ts index cc3206e3..88f2f473 100644 --- a/apps/backend/src/db/test-db.ts +++ b/apps/backend/src/db/test-db.ts @@ -5,10 +5,12 @@ import { ACCOUNT_TYPES } from '@ledgerly/shared/constants'; import { dateInIsoFormat } from '@ledgerly/shared/libs'; import { AccountTypeValue, - CurrencyCode, IsoDateString, AmountString, UUID, + CommodityCodeString, + CommodityPrecisionNumber, + CommoditySymbolString, } from '@ledgerly/shared/types'; import { isoDate, isoDatetime } from '@ledgerly/shared/validation'; import { createClient } from '@libsql/client'; @@ -16,11 +18,11 @@ import { eq, sql } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/libsql'; import { migrate } from 'drizzle-orm/libsql/migrator'; import { DataBase } from 'src/db'; -import { Amount, Currency, DateValue } from 'src/domain/domain-core'; +import { Amount, CommodityCode, DateValue } from 'src/domain/domain-core'; import { OperationSnapshot } from 'src/domain/operations/types'; import { TransactionSnapshot } from 'src/domain/transactions/types'; import { PasswordManager } from 'src/infrastructure/auth/PasswordManager'; -import { AmountFormatter } from 'src/presentation/formatters'; +import { CommodityPersistenceMapper } from 'src/infrastructure/db/commodities/commodity-persistence.mapper'; import { TransactionDbInsert, @@ -33,6 +35,9 @@ import { AccountDbInsert, OperationDbInsert, OperationDbRow, + CommodityDbInsert, + commoditiesTable, + CommodityDbRow, } from './schema'; import * as schema from './schemas'; @@ -55,10 +60,13 @@ class Counter { return this.count; } - getNextName(suffix = ''): string { + getNextName({ + delimiter = '-', + suffix = '', + }: { suffix?: string; delimiter?: string } = {}): string { this.increment(); if (this.name) { - return `${this.name}-${this.count}${suffix ? `-${suffix}` : ''}`; + return `${this.name}${delimiter}${this.count}${suffix ? `${delimiter}${suffix}` : ''}`; } return this.count.toString(); @@ -68,7 +76,6 @@ export type CreateTransactionProps = { description?: string; postingDate?: IsoDateString; transactionDate?: IsoDateString; - currencyCode?: CurrencyCode; isTombstone?: boolean; }; @@ -83,7 +90,6 @@ export type TransactionOperationSeed = { }; export type TransactionSeed = { - currencyCode?: CurrencyCode; description: string; operations: TransactionOperationSeed[]; postingDate?: IsoDateString; @@ -94,6 +100,7 @@ export type TransactionSeed = { export class TestDB { db: DataBase; transactionCounter = new Counter('transaction'); + commodityCounter = new Counter('commodity'); operationCounter = new Counter('operation'); userCounter = new Counter('user'); private testDbFile?: string; @@ -208,12 +215,12 @@ export class TestDB { createTransaction = async ( userId: UUID, + commodityId: UUID, params?: CreateTransactionProps, ): Promise => { const transactionData: TransactionDbInsert = { ...TestDB.uuid, ...TestDB.createTimestamps, - currency: params?.currencyCode ?? Currency.create('USD').valueOf(), description: params?.description ?? `Test Transaction ${this.transactionCounter.getNextName()}`, @@ -221,6 +228,7 @@ export class TestDB { postingDate: params?.postingDate ?? DateValue.create().valueOf(), transactionDate: params?.transactionDate ?? DateValue.create().valueOf(), ...params, + commodityId, userId, version: 0, }; @@ -236,11 +244,11 @@ export class TestDB { createTransactionWithOperations = async ( userId: UUID, + commodityId: UUID, params?: { description?: string; postingDate?: IsoDateString; transactionDate?: IsoDateString; - currencyCode?: CurrencyCode; isTombstone?: boolean; operations: { accountId: UUID; @@ -254,7 +262,11 @@ export class TestDB { }[]; }, ): Promise => { - const transaction = await this.createTransaction(userId, params); + const transaction = await this.createTransaction( + userId, + commodityId, + params, + ); const operations: OperationDbRow[] = []; @@ -272,8 +284,8 @@ export class TestDB { createTransactionFromSeed = async ( userId: UUID, + commodityId: UUID, { - currencyCode = 'USD' as CurrencyCode, description, isTombstone = false, operations, @@ -281,8 +293,7 @@ export class TestDB { transactionDate = '2023-01-01' as IsoDateString, }: TransactionSeed, ): Promise => { - return this.createTransactionWithOperations(userId, { - currencyCode, + return this.createTransactionWithOperations(userId, commodityId, { description, isTombstone, operations: operations.map((operation) => ({ @@ -365,40 +376,6 @@ export class TestDB { return { operations, ...transaction }; }; - getTransactionInPTAFormat = async ( - transactionId: UUID, - ): Promise => { - const transaction = await this.getTransactionById(transactionId); - if (!transaction) return null; - - const operations = await this.db.select().from(schema.operationsTable); - - // Format in PTA (Plain Text Accounting) style - let output = `${transaction.transactionDate} ${transaction.description}\n`; - - for (const operation of operations) { - const account = await this.db - .select() - .from(schema.accountsTable) - .where(sql`${schema.accountsTable.id} = ${operation.accountId}`) - .get(); - - if (!account) continue; - - const formatter = new AmountFormatter(); - - const amount = Amount.restore(operation.amount); - const userFriendlyAmount = formatter.formatForTable(amount, 'en-US'); - const accountName = account.name; - const currency = account.currency; - const systemMarker = operation.isSystem ? ' [system]' : ''; - - output += ` ${accountName.padEnd(40)} ${operation.description} ${userFriendlyAmount} ${currency}${systemMarker}\n`; - } - - return output; - }; - softDeleteTransaction = async (transactionId: UUID) => { return await this.db .update(schema.transactionsTable) @@ -408,11 +385,50 @@ export class TestDB { .get(); }; + createCommodity = async ( + userId: UUID, + params?: { + code?: CommodityCodeString; + symbol?: CommoditySymbolString; + name?: string; + precision?: CommodityPrecisionNumber; + isTombstone?: boolean; + }, + ): Promise => { + 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; + }; + createAccount = async ( userId: UUID, + commodityId: UUID, params?: { name?: string; - currency?: CurrencyCode; type?: AccountTypeValue; initialBalance?: AmountString; description?: string; @@ -420,7 +436,6 @@ export class TestDB { }, ) => { const accountData = { - currency: 'USD' as unknown as CurrencyCode, description: '', initialBalance: Amount.create('0').valueOf(), isSystem: false, @@ -433,7 +448,7 @@ export class TestDB { const account = await this.db .insert(accountsTable) .values({ - currency: accountData.currency, + commodityId, currentClearedBalanceLocal: accountData.initialBalance ?? 0, description: accountData.description || '', initialBalance: accountData.initialBalance ?? 0, @@ -451,6 +466,55 @@ 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; + // }; + + insertCommodity = async (commodityData: CommodityDbInsert) => { + const insertedCommodity = await this.db + .insert(commoditiesTable) + .values(commodityData) + .returning() + .get(); + + return insertedCommodity; + }; + insertAccount = async (accountData: AccountDbInsert) => { const account = await this.db .insert(accountsTable) @@ -524,6 +588,16 @@ export class TestDB { return operations; }; + getCommodityById = async (commodityId: UUID) => { + const commodity = await this.db + .select() + .from(schema.commoditiesTable) + .where(sql`${schema.commoditiesTable.id} = ${commodityId}`) + .get(); + + return commodity ?? null; + }; + deleteData = async () => { await this.db.delete(transactionsTable); await this.db.delete(accountsTable); @@ -539,23 +613,44 @@ export class TestDB { password: 'hashed_password', })); - const accountUSD1 = await this.createAccount(user.id, { - currency: Currency.create('USD').valueOf(), + const transactionCommodity = CommodityPersistenceMapper.toDomain( + await this.createCommodity(user.id), + ); + + const usdCommodity = await this.createCommodity(user.id, { + code: CommodityCode.create('USD').valueOf(), + name: 'US Dollar', + precision: 2, + symbol: '$', + }); + + const eurCommodity = await this.createCommodity(user.id, { + code: CommodityCode.create('EUR').valueOf(), + name: 'Euro', + precision: 2, + symbol: '€', + }); + + const accountUSD1 = await this.createAccount(user.id, usdCommodity.id, { name: 'Savings Account USD', }); - const accountUSD2 = await this.createAccount(user.id, { - currency: Currency.create('USD').valueOf(), + const accountUSD2 = await this.createAccount(user.id, usdCommodity.id, { name: 'Checking Account USD', }); - const accountEUR = await this.createAccount(user.id, { - currency: Currency.create('EUR').valueOf(), + const accountEUR = await this.createAccount(user.id, eurCommodity.id, { name: 'Credit Card EUR', }); - const transaction1 = await this.createTransaction(user.id); - const transaction2 = await this.createTransaction(user.id); + const transaction1 = await this.createTransaction( + user.id, + transactionCommodity.getId().valueOf(), + ); + const transaction2 = await this.createTransaction( + user.id, + transactionCommodity.getId().valueOf(), + ); await this.createOperation(user.id, { accountId: accountUSD1.id, @@ -651,4 +746,12 @@ export class TestDB { }, }); }; + + getAllCommoditiesByUserId = async ( + userId: UUID, + ): Promise => { + return await this.db.query.commoditiesTable.findMany({ + where: eq(commoditiesTable.userId, userId), + }); + }; } diff --git a/apps/backend/src/db/test-utils/builder.test.ts b/apps/backend/src/db/test-utils/builder.test.ts index 6ed47b29..315f6fe2 100644 --- a/apps/backend/src/db/test-utils/builder.test.ts +++ b/apps/backend/src/db/test-utils/builder.test.ts @@ -1,14 +1,22 @@ import { createUser } from 'src/db/createTestUser'; +import { User } from 'src/domain'; import { InsufficientOperationsError } from 'src/domain/domain.errors'; -import { describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import { EntityNotFoundError } from '../../application/application.errors'; import { TransactionBuilder } from './testEntityBuilder'; describe('TransactionBuilder', () => { - it('should be implemented', async () => { - const user = await createUser(); + let user: User; + + beforeAll(async () => { + user = await createUser(); + }); + + it('should be implemented', () => { TransactionBuilder.transaction({ - accounts: ['USD', 'EUR'], + currencies: ['USD', 'EUR'], operations: [ { accountKey: 'USD', amount: '10000', description: '1' }, { @@ -21,10 +29,9 @@ describe('TransactionBuilder', () => { }); }); - it('builds an invalid request fixture without creating a domain transaction', async () => { - const user = await createUser(); + it('builds an invalid request fixture without creating a domain transaction', () => { const fixture = TransactionBuilder.request({ - accounts: ['USD'], + currencies: ['USD'], operations: [{ accountKey: 'USD', amount: '0' }], user, }); @@ -32,15 +39,26 @@ describe('TransactionBuilder', () => { expect(fixture.transactionDTO.operations).toHaveLength(1); }); - it('applies domain invariants when building a transaction', async () => { - const user = await createUser(); - + it('applies domain invariants when building a transaction', () => { expect(() => TransactionBuilder.transaction({ - accounts: ['USD'], + currencies: ['USD'], operations: [{ accountKey: 'USD', amount: '0' }], user, }), ).toThrow(InsufficientOperationsError); }); + + it('throws an error if an operation references a non-existent account', () => { + expect(() => + TransactionBuilder.transaction({ + currencies: ['USD'], + operations: [ + { accountKey: 'USD', amount: '100', description: 'Debit' }, + { accountKey: 'EUR', amount: '-100', description: 'Credit' }, + ], + user, + }), + ).toThrow(EntityNotFoundError); + }); }); diff --git a/apps/backend/src/db/test-utils/entityComparer.test.ts b/apps/backend/src/db/test-utils/entityComparer.test.ts new file mode 100644 index 00000000..7e6a881a --- /dev/null +++ b/apps/backend/src/db/test-utils/entityComparer.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; + +import { compareEntityArrays } from './entityComparer'; + +describe('compareEntityArrays', () => { + it('compares arrays by common fields and ignores actual extra fields', () => { + const expected = [ + { code: 'USD', name: 'US Dollar' }, + { code: 'EUR', name: 'Euro' }, + ]; + + const actual = [ + { code: 'EUR', id: 'eur-id', name: 'Euro' }, + { code: 'USD', id: 'usd-id', name: 'US Dollar' }, + ]; + + compareEntityArrays(expected, actual); + }); + + it('compares only specified fields', () => { + const expected = [ + { code: 'USD', name: 'Expected name' }, + { code: 'EUR', name: 'Expected name' }, + ]; + + const actual = [ + { code: 'EUR', name: 'Actual name' }, + { code: 'USD', name: 'Actual name' }, + ]; + + compareEntityArrays(expected, actual, { fields: ['code'] }); + }); + + it('excludes selected fields from comparison', () => { + const expected = [ + { code: 'USD', name: 'US Dollar', updatedAt: 'before' }, + { code: 'EUR', name: 'Euro', updatedAt: 'before' }, + ]; + + const actual = [ + { code: 'EUR', name: 'Euro', updatedAt: 'after' }, + { code: 'USD', name: 'US Dollar', updatedAt: 'after' }, + ]; + + compareEntityArrays(expected, actual, { exclude: ['updatedAt'] }); + }); + + it('compares different object types by shared fields', () => { + type ExpectedCommodity = { + code: string; + name: string; + }; + + type ActualCommodity = { + code: string; + id: string; + name: string; + userId: string; + }; + + const expected: ExpectedCommodity[] = [{ code: 'USD', name: 'US Dollar' }]; + const actual: ActualCommodity[] = [ + { + code: 'USD', + id: 'commodity-id', + name: 'US Dollar', + userId: 'user-id', + }, + ]; + + compareEntityArrays(expected, actual); + }); + + it('does not reuse the same actual item for multiple expected items', () => { + const expected = [ + { code: 'USD', name: 'US Dollar' }, + { code: 'USD', name: 'US Dollar' }, + ]; + + const actual = [ + { code: 'USD', name: 'US Dollar' }, + { code: 'EUR', name: 'Euro' }, + ]; + + expect(() => compareEntityArrays(expected, actual)).toThrow(); + }); +}); diff --git a/apps/backend/src/db/test-utils/entityComparer.ts b/apps/backend/src/db/test-utils/entityComparer.ts index b37de0bc..3c6ffc97 100644 --- a/apps/backend/src/db/test-utils/entityComparer.ts +++ b/apps/backend/src/db/test-utils/entityComparer.ts @@ -1,5 +1,17 @@ +import { isDeepStrictEqual } from 'node:util'; + import { expect } from 'vitest'; +type CommonKey = Extract; + +type CompareEntityArraysOptions< + TExpected extends object, + TActual extends object, +> = { + exclude?: CommonKey[]; + fields?: CommonKey[]; +}; + const omitKeys = ( obj: T, keysToOmit: (keyof T)[], @@ -11,6 +23,41 @@ const omitKeys = ( return result; }; +const pickKeys = ( + obj: T, + keys: TKey[], +): Pick => { + const result = {} as Pick; + + keys.forEach((key) => { + result[key] = obj[key]; + }); + + return result; +}; + +const getCommonKeys = ( + expected: TExpected, + actual: TActual, +): CommonKey[] => { + return (Object.keys(expected) as CommonKey[]).filter( + (key) => key in actual, + ); +}; + +const getComparableKeys = ( + expected: TExpected, + actual: TActual, + options: CompareEntityArraysOptions, +): CommonKey[] => { + const keys = options.fields ?? getCommonKeys(expected, actual); + const excludedKeys = new Set>( + options.exclude ?? [], + ); + + return keys.filter((key) => !excludedKeys.has(key)); +}; + export const compareEntities = ( before: T, after: T, @@ -36,6 +83,45 @@ export const compareCommonEntities = ( }); }; +export const compareEntityArrays = < + TExpected extends object, + TActual extends object, +>( + expectedItems: TExpected[], + actualItems: TActual[], + options: CompareEntityArraysOptions = {}, +) => { + expect(actualItems).toHaveLength(expectedItems.length); + + const matchedActualIndexes = new Set(); + + expectedItems.forEach((expectedItem) => { + const matchedIndex = actualItems.findIndex((actualItem, actualIndex) => { + if (matchedActualIndexes.has(actualIndex)) { + return false; + } + + const comparableKeys = getComparableKeys( + expectedItem, + actualItem, + options, + ); + + if (comparableKeys.length === 0) { + return false; + } + + return isDeepStrictEqual( + pickKeys(expectedItem, comparableKeys), + pickKeys(actualItem, comparableKeys), + ); + }); + + expect(matchedIndex).not.toBe(-1); + matchedActualIndexes.add(matchedIndex); + }); +}; + export const getEntityDiff = ( before: T, after: T, diff --git a/apps/backend/src/db/test-utils/index.ts b/apps/backend/src/db/test-utils/index.ts index 572fcfc0..cb791f8a 100644 --- a/apps/backend/src/db/test-utils/index.ts +++ b/apps/backend/src/db/test-utils/index.ts @@ -1,4 +1,5 @@ export { prettyPrint } from './prettyPrint'; +export { parseResponse } from './parseResponse'; export { OperationDataForTransaction, TransactionBuilder, @@ -7,4 +8,4 @@ export { TransactionPersistenceBuilderResult, TransactionRequestBuilderResult, } from './testEntityBuilder'; -export { compareEntities } from './entityComparer'; +export { compareEntities, compareEntityArrays } from './entityComparer'; diff --git a/apps/backend/src/db/test-utils/parseResponse.ts b/apps/backend/src/db/test-utils/parseResponse.ts new file mode 100644 index 00000000..6c32d9b5 --- /dev/null +++ b/apps/backend/src/db/test-utils/parseResponse.ts @@ -0,0 +1,3 @@ +export const parseResponse = (response: { body: string }): T => { + return JSON.parse(response.body) as T; +}; diff --git a/apps/backend/src/db/test-utils/prettyPrint.test.ts b/apps/backend/src/db/test-utils/prettyPrint.test.ts index ca161798..14826775 100644 --- a/apps/backend/src/db/test-utils/prettyPrint.test.ts +++ b/apps/backend/src/db/test-utils/prettyPrint.test.ts @@ -1,18 +1,24 @@ import { createUser } from 'src/db/createTestUser'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { User } from 'src/domain'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { prettyPrint } from './prettyPrint'; import { TransactionBuilder } from './testEntityBuilder'; describe('prettyPrint', () => { + let user: User; + afterEach(() => { vi.restoreAllMocks(); }); - it('prints an operation in PTA format', async () => { - const user = await createUser(); + beforeAll(async () => { + user = await createUser(); + }); + + it('prints an operation in PTA format', () => { const data = TransactionBuilder.transaction({ - accounts: ['USD'], + currencies: ['USD'], operations: [ { accountKey: 'USD', @@ -34,6 +40,7 @@ describe('prettyPrint', () => { const result = prettyPrint.operationPTA( data.operations[0], data.accountsMap, + data.commoditiesMapById, ); expect(result).toBeUndefined(); @@ -43,10 +50,9 @@ describe('prettyPrint', () => { ); }); - it('prints a transaction in PTA format', async () => { - const user = await createUser(); + it('prints a transaction in PTA format', () => { const data = TransactionBuilder.transaction({ - accounts: ['USD', 'EUR'], + currencies: ['USD', 'EUR'], operations: [ { accountKey: 'USD', @@ -68,12 +74,13 @@ describe('prettyPrint', () => { const result = prettyPrint.transactionPTA( data.transaction, data.accountsMap, + data.commoditiesMapById, ); expect(result).toBeUndefined(); expect(consoleInfo).toHaveBeenCalledOnce(); expect(consoleInfo).toHaveBeenCalledWith( - expect.stringContaining('Currency'), + expect.stringContaining('Commodity'), ); expect(consoleInfo).toHaveBeenCalledWith(expect.stringContaining('Debit')); expect(consoleInfo).toHaveBeenCalledWith(expect.stringContaining('Credit')); diff --git a/apps/backend/src/db/test-utils/prettyPrint.ts b/apps/backend/src/db/test-utils/prettyPrint.ts index 166b064c..b80dbab8 100644 --- a/apps/backend/src/db/test-utils/prettyPrint.ts +++ b/apps/backend/src/db/test-utils/prettyPrint.ts @@ -1,6 +1,6 @@ // Format and print transactions in PTA format import { UUID } from '@ledgerly/shared/types'; -import { Account, Operation, Transaction } from 'src/domain'; +import { Account, Commodity, Operation, Transaction } from 'src/domain'; import { Amount } from 'src/domain/domain-core'; import { AmountFormatter } from 'src/presentation/formatters'; const formatter = new AmountFormatter(); @@ -21,28 +21,37 @@ const formatDebitCredit = ( const getAccountInfo = ( accountId: UUID, accountMap?: Map, -): { currency: string; name: string } => { + commoditiesMapById?: Map, +): { commodity: string; name: string } => { if (!accountMap) { - return { currency: 'N/A', name: 'Unknown Account' }; + return { commodity: 'N/A', name: 'Unknown Account' }; } const account = accountMap.get(accountId); if (!account) { - return { currency: 'N/A', name: 'Unknown Account' }; + return { commodity: 'N/A', name: 'Unknown Account' }; } - return { currency: account.currency.valueOf(), name: account.name.valueOf() }; + + const commodity = commoditiesMapById?.get(account.toSnapshot().commodityId); + + return { + commodity: commodity?.toSnapshot().code ?? 'N/A', + name: account.name.valueOf(), + }; }; function formatOperationPTA( operation: Operation, accountMap?: Map, + commoditiesMapById?: Map, ): string { - const { currency, name } = getAccountInfo( + const { commodity, name } = getAccountInfo( operation.getAccountId().valueOf(), accountMap, + commoditiesMapById, ); const { credit, debit } = formatDebitCredit(operation.amount); - return ` ${name.padEnd(20)} ${operation.description.padEnd(50)} ${currency.padEnd(8)} ${debit} ${credit}`; + return ` ${name.padEnd(20)} ${operation.description.padEnd(50)} ${commodity.padEnd(8)} ${debit} ${credit}`; } /** @@ -57,6 +66,7 @@ function formatOperationPTA( function formatTransactionPTA( transaction: Transaction, accountMap?: Map, + commoditiesMapById?: Map, ): string { const { credit, debit } = transaction.getOperations().reduce( (totals, operation) => { @@ -73,18 +83,23 @@ function formatTransactionPTA( debit: Amount.create('0'), }, ); + + const commodity = + commoditiesMapById?.get(transaction.toSnapshot().commodityId)?.toSnapshot() + .code ?? 'N/A'; + const lines: string[] = []; lines.push( `${transaction.getTransactionDate().valueOf()} ${transaction.description}`, ); lines.push( - ` ${'Account'.padEnd(20)} ${'Description'.padEnd(50)} ${'Currency'.padEnd(8)} ${'Debit'.padStart(amountColumnWidth)} ${'Credit'.padStart(amountColumnWidth)}`, + ` ${'Account'.padEnd(20)} ${'Description'.padEnd(50)} ${'Commodity'.padEnd(8)} ${'Debit'.padStart(amountColumnWidth)} ${'Credit'.padStart(amountColumnWidth)}`, ); transaction.getOperations().forEach((operation) => { - lines.push(formatOperationPTA(operation, accountMap)); + lines.push(formatOperationPTA(operation, accountMap, commoditiesMapById)); }); lines.push( - ` ${'Total'.padEnd(20)} ${''.padEnd(50)} ${transaction.currency.valueOf().padEnd(8)} ${formatAmountColumn(debit)} ${formatAmountColumn(credit)}`, + ` ${'Total'.padEnd(20)} ${''.padEnd(50)} ${commodity.padEnd(8)} ${formatAmountColumn(debit)} ${formatAmountColumn(credit)}`, ); return lines.join('\n'); } @@ -92,15 +107,19 @@ function formatTransactionPTA( function printOperationPTA( operation: Operation, accountMap?: Map, + commoditiesMapById?: Map, ): void { - console.info(formatOperationPTA(operation, accountMap)); + console.info(formatOperationPTA(operation, accountMap, commoditiesMapById)); } function printTransactionPTA( transaction: Transaction, accountMap?: Map, + commoditiesMapById?: Map, ): void { - console.info(formatTransactionPTA(transaction, accountMap)); + console.info( + formatTransactionPTA(transaction, accountMap, commoditiesMapById), + ); } export const prettyPrint = { diff --git a/apps/backend/src/db/test-utils/testEntityBuilder.ts b/apps/backend/src/db/test-utils/testEntityBuilder.ts index 55bf919a..b331d63e 100644 --- a/apps/backend/src/db/test-utils/testEntityBuilder.ts +++ b/apps/backend/src/db/test-utils/testEntityBuilder.ts @@ -1,11 +1,22 @@ -import { UUID, CurrencyCode, IsoDateString } from '@ledgerly/shared/types'; +import { + UUID, + IsoDateString, + CommodityCodeString, +} from '@ledgerly/shared/types'; import { CreateOperationRequestDTO, CreateTransactionRequestDTO, EntityNotFoundError, } from 'src/application'; -import { Account, AccountType, Operation, Transaction, User } from 'src/domain'; -import { Amount, Currency, DateValue, Name } from 'src/domain/domain-core'; +import { + Account, + AccountType, + Commodity, + Operation, + Transaction, + User, +} from 'src/domain'; +import { Amount, CommodityCode, DateValue, Name } from 'src/domain/domain-core'; import { CreateTransactionProps, TransactionBuildContext, @@ -24,23 +35,25 @@ export type TransactionProps = { postingDate?: string; transactionDate?: string; description?: string; - currencyCode: string; + currency: string; }; export type TransactionBuilderOptions = { user: User; - accounts: string[]; operations: OperationDataForTransaction[]; settings?: Partial; + currencies: string[]; }; export type TransactionRequestBuilderResult = { accountsMap: Map; + commoditiesMap: Map; + commoditiesMapById: Map; accounts: Account[]; + commodities: Commodity[]; transactionContext: TransactionBuildContext; getAccountByKey: (key: string) => Account; - getSystemAccountByCurrency: (currency: string) => Account; - systemAccounts: Map; + getCommodityByKey: (code: string) => Commodity; user: User; transactionDTO: CreateTransactionRequestDTO; transactionData: { @@ -48,6 +61,8 @@ export type TransactionRequestBuilderResult = { postingDate: IsoDateString; transactionDate: IsoDateString; userId: UUID; + commodityId: UUID; + transactionCommodity: Commodity; }; operationsData: OperationDataForTransaction[]; }; @@ -65,28 +80,46 @@ export class TransactionBuilder { private readonly user: User; private readonly accounts = new Map(); private readonly accountsMap = new Map(); - private readonly systemAccounts = new Map(); + private readonly commoditiesMapByCommodityCode = new Map< + CommodityCodeString, + Commodity + >(); + private readonly commoditiesMapById = new Map(); private readonly operationsData: OperationDataForTransaction[]; private readonly postingDate: IsoDateString; private readonly transactionDate: IsoDateString; private readonly description: string; - private readonly transactionCurrency: Currency; + private readonly transactionCommodity: Commodity; private constructor(options: TransactionBuilderOptions) { this.user = options.user; + this.operationsData = [...options.operations]; + this.postingDate = DateValue.restore( options.settings?.postingDate ?? '2023-01-01', ).valueOf(); + this.transactionDate = DateValue.restore( options.settings?.transactionDate ?? '2023-01-01', ).valueOf(); + this.description = options.settings?.description ?? 'Test Transaction'; - this.transactionCurrency = Currency.create( - options.settings?.currencyCode ?? 'USD', + + this.createAccounts(options.currencies); + this.validateAccountKeys(); + + const transactionCommodityCode = options.settings?.currency ?? 'USD'; + + const transactionCommodity = this.commoditiesMapByCommodityCode.get( + transactionCommodityCode as CommodityCodeString, ); - this.createAccounts(options.accounts); + if (!transactionCommodity) { + throw new EntityNotFoundError({ entityType: Commodity.entityType }); + } + + this.transactionCommodity = transactionCommodity; } static request( @@ -107,39 +140,48 @@ export class TransactionBuilder { return new TransactionBuilder(options).buildPersistence(); } + private validateAccountKeys(): void { + const invalidKeys = this.operationsData.filter( + (operation) => !this.accounts.has(operation.accountKey), + ); + + if (invalidKeys.length > 0) { + throw new EntityNotFoundError({ entityType: Account.entityType }); + } + } + private createAccounts(currencyCodes: string[]): void { - currencyCodes.forEach((currencyCode) => { - const account = Account.create( - this.user, - Name.create(`Account ${currencyCode}`), - `Account ${currencyCode}`, - Amount.create('0'), - Currency.create(currencyCode), - AccountType.create('asset'), - ); - - this.accounts.set(currencyCode, account); + currencyCodes.forEach((commodityCode) => { + const code = CommodityCode.create(commodityCode); + + const commodity = Commodity.create(this.user, { + code: code.valueOf(), + name: `Commodity ${commodityCode}`, + }); + + const account = Account.create(this.user, { + commodityId: commodity.getId(), + description: `Account ${commodityCode}`, + initialBalance: Amount.create('0'), + name: Name.create(`Account ${commodityCode}`), + type: AccountType.create('asset'), + }); + + this.accounts.set(commodityCode, account); this.accountsMap.set(account.getId().valueOf(), account); + this.commoditiesMapByCommodityCode.set(code.valueOf(), commodity); + this.commoditiesMapById.set(commodity.getId().valueOf(), commodity); }); - - this.createSystemAccounts(); } - private createSystemAccounts(): void { - this.accounts.forEach((account) => { - const currencyCode = account.currency.valueOf(); - const systemAccount = Account.create( - this.user, - Name.create(`System Account ${currencyCode}`), - `System Account for ${currencyCode}`, - Amount.create('0'), - Currency.create(currencyCode), - AccountType.create('currencyTrading'), - ); - - this.systemAccounts.set(currencyCode, systemAccount); - this.accountsMap.set(systemAccount.getId().valueOf(), systemAccount); - }); + private getCommodityById(commodityId: UUID): Commodity { + const commodity = this.commoditiesMapById.get(commodityId); + + if (!commodity) { + throw new EntityNotFoundError({ entityType: Commodity.entityType }); + } + + return commodity; } getAccountByKey(key: string): Account { @@ -152,14 +194,16 @@ export class TransactionBuilder { return account; } - getSystemAccountByCurrency(currency: string): Account { - const account = this.systemAccounts.get(currency as CurrencyCode); + getCommodityByKey(code: string): Commodity { + const commodity = this.commoditiesMapByCommodityCode.get( + code as CommodityCodeString, + ); - if (!account) { - throw new EntityNotFoundError({ entityType: Account.entityType }); + if (!commodity) { + throw new EntityNotFoundError({ entityType: Commodity.entityType }); } - return account; + return commodity; } private buildOperationsDTO(): CreateOperationRequestDTO[] { @@ -180,7 +224,7 @@ export class TransactionBuilder { private buildTransactionProps(): CreateTransactionProps { return { - currency: this.transactionCurrency, + commodityId: this.transactionCommodity.getId(), description: this.description, operations: this.operationsData.map((operation) => { const amount = Amount.create(operation.amount); @@ -201,22 +245,30 @@ export class TransactionBuilder { return { accounts: Array.from(this.accountsMap.values()), accountsMap: this.accountsMap, + commodities: Array.from(this.commoditiesMapByCommodityCode.values()), + commoditiesMap: this.commoditiesMapByCommodityCode, + commoditiesMapById: this.commoditiesMapById, getAccountByKey: this.getAccountByKey.bind(this), - getSystemAccountByCurrency: this.getSystemAccountByCurrency.bind(this), + getCommodityByKey: this.getCommodityByKey.bind(this), operationsData: this.operationsData, - systemAccounts: this.systemAccounts, transactionContext: { accountsMap: this.accountsMap, - systemAccountsMap: this.systemAccounts, }, + transactionData: { + commodityId: this.transactionCommodity.getId().valueOf(), description: this.description, postingDate: this.postingDate, + transactionCommodity: this.transactionCommodity, transactionDate: this.transactionDate, userId: this.user.getId().valueOf(), }, transactionDTO: { - currencyCode: this.transactionCurrency.valueOf(), + commodityId: this.getCommodityById( + this.transactionCommodity.getId().valueOf(), + ) + .getId() + .valueOf(), description: this.description, operations: this.buildOperationsDTO(), postingDate: this.postingDate, @@ -228,6 +280,7 @@ export class TransactionBuilder { private buildTransaction(): TransactionBuilderResult { const requestFixture = this.buildRequest(); + const transaction = Transaction.create( this.user.getId(), this.buildTransactionProps(), diff --git a/apps/backend/src/di/container.ts b/apps/backend/src/di/container.ts index b8741a9b..0f401961 100644 --- a/apps/backend/src/di/container.ts +++ b/apps/backend/src/di/container.ts @@ -1,31 +1,40 @@ -import { LoginUserUseCase, RegisterUserUseCase } from 'src/application'; -import { AccountFactory } from 'src/application/services'; +import { + GetAllCommoditiesUseCase, + GetCommodityByIdUseCase, + LoginUserUseCase, + RegisterUserUseCase, + CreateAccountUseCase, + ArchiveAccountUseCase, + GetAccountByIdUseCase, + GetAllAccountsUseCase, + UpdateAccountUseCase, + DeleteTransactionUseCase, + GetAllTransactionsUseCase, + GetTransactionByIdUseCase, + UpdateTransactionUseCase, + CreateTransactionUseCase, + UpdateCommodityUseCase, + ArchiveCommodityUseCase, + CreateCommodityUseCase, +} from 'src/application'; import { TransactionContextLoader } from 'src/application/services/TransactionService'; import { ensureEntityExistsAndOwned } from 'src/application/shared/ensureEntityExistsAndOwned'; -import { CreateAccountUseCase } from 'src/application/usecases/accounts/createAccount'; -import { DeleteAccountUseCase } from 'src/application/usecases/accounts/deleteAccount'; -import { GetAccountByIdUseCase } from 'src/application/usecases/accounts/getAccountById'; -import { GetAllAccountsUseCase } from 'src/application/usecases/accounts/getAllAccounts'; -import { UpdateAccountUseCase } from 'src/application/usecases/accounts/updateAccount'; -import { CreateTransactionUseCase } from 'src/application/usecases/transaction/CreateTransaction'; -import { DeleteTransactionUseCase } from 'src/application/usecases/transaction/DeleteTransaction'; -import { GetAllTransactionsUseCase } from 'src/application/usecases/transaction/GetAllTransactions'; -import { GetTransactionByIdUseCase } from 'src/application/usecases/transaction/GetTransactionById'; -import { UpdateTransactionUseCase } from 'src/application/usecases/transaction/UpdateTransaction'; +import { ensureOwnedSnapshot } from 'src/application/shared/ensureOwnedSnapshot'; import { DataBase } from 'src/db'; import { PasswordManager } from 'src/infrastructure/auth/PasswordManager'; import { TransactionRepository, TransactionQueryRepository, OperationRepository, - CurrencyRepository, TransactionManager, AccountRepository, UserRepository, } from 'src/infrastructure/db'; +import { CommodityRepository } from 'src/infrastructure/db'; import { AccountController, AuthController, + CommodityController, TransactionController, UserController, } from 'src/presentation/http'; @@ -37,7 +46,7 @@ export const createContainer = (db: DataBase): AppContainer => { const transactionManager = new TransactionManager(db); const accountRepository = new AccountRepository(transactionManager); - const currencyRepository = new CurrencyRepository(transactionManager); + const commodityRepository = new CommodityRepository(transactionManager); const operationRepository = new OperationRepository(transactionManager); const transactionRepository = new TransactionRepository( operationRepository, @@ -52,7 +61,7 @@ export const createContainer = (db: DataBase): AppContainer => { const repositories: AppContainer['repositories'] = { account: accountRepository, - currency: currencyRepository, + commodity: commodityRepository, transaction: transactionRepository, transactionQuery: transactionQueryRepository, user: userRepository, @@ -60,8 +69,6 @@ export const createContainer = (db: DataBase): AppContainer => { // Services and Factories - const accountFactory = new AccountFactory(accountRepository); - const transactionContextLoader = new TransactionContextLoader( accountRepository, ); @@ -73,11 +80,11 @@ export const createContainer = (db: DataBase): AppContainer => { }; // Create Account Use Cases - const createAccountUseCase = new CreateAccountUseCase(accountFactory); + const createAccountUseCase = new CreateAccountUseCase(accountRepository); const getAllAccountsUseCase = new GetAllAccountsUseCase(accountRepository); const getAccountByIdUseCase = new GetAccountByIdUseCase(accountRepository); const updateAccountUseCase = new UpdateAccountUseCase(accountRepository); - const deleteAccountUseCase = new DeleteAccountUseCase(accountRepository); + const archiveAccountUseCase = new ArchiveAccountUseCase(accountRepository); const loginUserUseCase = new LoginUserUseCase(userRepository); @@ -111,9 +118,39 @@ export const createContainer = (db: DataBase): AppContainer => { ensureEntityExistsAndOwned, ); + const getCommodityByIdUseCase = new GetCommodityByIdUseCase( + commodityRepository, + ); + + const getAllCommoditiesUseCase = new GetAllCommoditiesUseCase( + commodityRepository, + ); + + const updateCommodityUseCase = new UpdateCommodityUseCase( + commodityRepository, + ensureOwnedSnapshot, + ); + + const archiveCommodityUseCase = new ArchiveCommodityUseCase( + commodityRepository, + ensureOwnedSnapshot, + ); + + const createCommodityUseCase = new CreateCommodityUseCase( + commodityRepository, + ); + + const commodityController = new CommodityController( + getCommodityByIdUseCase, + getAllCommoditiesUseCase, + createCommodityUseCase, + updateCommodityUseCase, + archiveCommodityUseCase, + ); + const useCases: AppContainer['useCases'] = { account: { - archiveAccount: deleteAccountUseCase, + archiveAccount: archiveAccountUseCase, createAccount: createAccountUseCase, getAccountById: getAccountByIdUseCase, getAllAccounts: getAllAccountsUseCase, @@ -123,6 +160,13 @@ export const createContainer = (db: DataBase): AppContainer => { loginUser: loginUserUseCase, registerUser: registerUserUseCase, }, + commodity: { + archiveCommodity: archiveCommodityUseCase, + createCommodity: createCommodityUseCase, + getAllCommodities: getAllCommoditiesUseCase, + getCommodityById: getCommodityByIdUseCase, + updateCommodity: updateCommodityUseCase, + }, transaction: { createTransaction: createTransactionUseCase, deleteTransaction: deleteTransactionUseCase, @@ -157,18 +201,14 @@ export const createContainer = (db: DataBase): AppContainer => { const controllers: AppContainer['controllers'] = { account: accountController, auth: authController, + commodity: commodityController, transaction: transactionController, user: userController, }; - const factories: AppContainer['factories'] = { - account: accountFactory, - }; - return { controllers, db, - factories, repositories, services, useCases, diff --git a/apps/backend/src/di/types.ts b/apps/backend/src/di/types.ts index d54febc5..472035b6 100644 --- a/apps/backend/src/di/types.ts +++ b/apps/backend/src/di/types.ts @@ -1,36 +1,42 @@ -import { LoginUserUseCase, RegisterUserUseCase } from 'src/application'; -import { AccountFactory } from 'src/application/services'; -import { CreateAccountUseCase } from 'src/application/usecases/accounts/createAccount'; -import { DeleteAccountUseCase } from 'src/application/usecases/accounts/deleteAccount'; -import { GetAccountByIdUseCase } from 'src/application/usecases/accounts/getAccountById'; -import { GetAllAccountsUseCase } from 'src/application/usecases/accounts/getAllAccounts'; -import { UpdateAccountUseCase } from 'src/application/usecases/accounts/updateAccount'; import { + GetAllCommoditiesUseCase, + GetCommodityByIdUseCase, + LoginUserUseCase, + RegisterUserUseCase, + CreateAccountUseCase, + ArchiveAccountUseCase, + GetAccountByIdUseCase, + GetAllAccountsUseCase, + UpdateAccountUseCase, DeleteTransactionUseCase, GetAllTransactionsUseCase, -} from 'src/application/usecases/transaction'; -import { CreateTransactionUseCase } from 'src/application/usecases/transaction/CreateTransaction'; -import { GetTransactionByIdUseCase } from 'src/application/usecases/transaction/GetTransactionById'; -import { UpdateTransactionUseCase } from 'src/application/usecases/transaction/UpdateTransaction'; + GetTransactionByIdUseCase, + UpdateTransactionUseCase, + CreateTransactionUseCase, + UpdateCommodityUseCase, + ArchiveCommodityUseCase, + CreateCommodityUseCase, +} from 'src/application'; import { DataBase } from 'src/db'; import { PasswordManager } from 'src/infrastructure/auth/PasswordManager'; import { TransactionQueryRepository, AccountRepository, - CurrencyRepository, TransactionRepository, + CommodityRepository, UserRepository, } from 'src/infrastructure/db'; import { AccountController, AuthController, TransactionController, + CommodityController, UserController, } from 'src/presentation/http'; type Repositories = { - currency: CurrencyRepository; transaction: TransactionRepository; + commodity: CommodityRepository; transactionQuery: TransactionQueryRepository; account: AccountRepository; user: UserRepository; @@ -45,7 +51,7 @@ type AccountUseCases = { getAllAccounts: GetAllAccountsUseCase; getAccountById: GetAccountByIdUseCase; updateAccount: UpdateAccountUseCase; - archiveAccount: DeleteAccountUseCase; + archiveAccount: ArchiveAccountUseCase; }; type AuthUseCases = { @@ -61,15 +67,20 @@ type TransactionUseCases = { deleteTransaction: DeleteTransactionUseCase; }; +type CommodityUseCases = { + getCommodityById: GetCommodityByIdUseCase; + getAllCommodities: GetAllCommoditiesUseCase; + updateCommodity: UpdateCommodityUseCase; + archiveCommodity: ArchiveCommodityUseCase; + createCommodity: CreateCommodityUseCase; +}; + type Controllers = { account: AccountController; user: UserController; auth: AuthController; transaction: TransactionController; -}; - -type Factories = { - account: AccountFactory; + commodity: CommodityController; }; export type AppContainer = { @@ -80,7 +91,7 @@ export type AppContainer = { account: AccountUseCases; auth: AuthUseCases; transaction: TransactionUseCases; + commodity: CommodityUseCases; }; controllers: Controllers; - factories: Factories; }; diff --git a/apps/backend/src/domain/accounts/account.entity.ts b/apps/backend/src/domain/accounts/account.entity.ts index ed57f471..070e853e 100644 --- a/apps/backend/src/domain/accounts/account.entity.ts +++ b/apps/backend/src/domain/accounts/account.entity.ts @@ -1,6 +1,6 @@ +import { Commodity } from '../commodities'; import { Amount, - Currency, Id, Timestamp, Name, @@ -13,44 +13,30 @@ import { DeletedEntityOperationError } from '../domain.errors'; import { User } from '../users/user.entity'; import { AccountType } from './account-type.enum'; -import { AccountSnapshot, AccountUpdateProps } from './types'; +import { + AccountSnapshot, + AccountUpdateProps, + CreateAccountProps, +} from './types'; export class Account { static readonly entityType = 'account'; - private readonly identity: EntityIdentity; - private timestamps: EntityTimestamps; - private softDelete: SoftDelete; - private readonly ownership: ParentChildRelation; - private constructor( - identity: EntityIdentity, - timestamps: EntityTimestamps, - softDelete: SoftDelete, - ownership: ParentChildRelation, + private readonly identity: EntityIdentity, + private timestamps: EntityTimestamps, + private softDelete: SoftDelete, + private readonly ownership: ParentChildRelation, + private readonly commodityRelation: ParentChildRelation, public name: Name, public description: string, private initialBalance: Amount, - // remove currentClearedBalanceLocal from entity and schemas later private currentClearedBalanceLocal: Amount, - public currency: Currency, private type: AccountType, public isSystem: boolean, - ) { - this.identity = identity; - this.timestamps = timestamps; - this.softDelete = softDelete; - this.ownership = ownership; - } + ) {} - static create( - user: User, - name: Name, - description: string, - initialBalance: Amount, - currency: Currency, - type: AccountType, - ): Account { + static create(user: User, props: CreateAccountProps): Account { const identity = EntityIdentity.create(); const timestamps = EntityTimestamps.create(); const softDelete = SoftDelete.create(); @@ -60,27 +46,32 @@ export class Account { identity.getId(), ); - const isSystem = type.isSystemType(); + const commodityRelation = ParentChildRelation.create( + props.commodityId, + identity.getId(), + ); + + const isSystem = props.type.isSystemType(); return new Account( identity, timestamps, softDelete, ownership, - name, - description, - initialBalance, + commodityRelation, + props.name, + props.description, + props.initialBalance, Amount.create('0'), - currency, - type, + props.type, isSystem, ); } static restore(data: AccountSnapshot): Account { const { + commodityId, createdAt, - currency, currentClearedBalanceLocal, description, id, @@ -107,16 +98,21 @@ export class Account { identity.getId(), ); + const commodityRelation = ParentChildRelation.create( + Id.restore(commodityId), + identity.getId(), + ); + return new Account( identity, timestamps, softDelete, ownership, + commodityRelation, Name.restore(name), description, Amount.restore(initialBalance), Amount.restore(currentClearedBalanceLocal), - Currency.restore(currency), AccountType.restore(type), isSystem, ); @@ -162,14 +158,10 @@ export class Account { return this.ownership.belongsToParent(userId); } - getUserId(): Id { - return this.ownership.getParentId(); - } - toSnapshot(): AccountSnapshot { return { + commodityId: this.commodityRelation.getParentId().valueOf(), createdAt: this.getCreatedAt().valueOf(), - currency: this.currency.valueOf(), currentClearedBalanceLocal: this.currentClearedBalanceLocal.valueOf(), description: this.description, id: this.getId().valueOf(), @@ -190,25 +182,16 @@ export class Account { update(data: AccountUpdateProps): void { this.validateUpdateIsAllowed(); - const currency = data.currency - ? Currency.create(data.currency) - : this.currency; - const name = data.name ? Name.create(data.name) : this.name; this.description = data.description ?? this.description; this.type = data.type ? AccountType.create(data.type) : this.type; - this.currency = currency; this.name = name; this.touch(); } - isCurrencySame(currency: Currency): boolean { - return this.currency.valueOf() === currency.valueOf(); - } - - getCurrency(): Currency { - return this.currency; + isCommoditySame(commodity: Commodity): boolean { + return this.commodityRelation.getParentId().equals(commodity.getId()); } } diff --git a/apps/backend/src/domain/accounts/account.test.ts b/apps/backend/src/domain/accounts/account.test.ts index c929250a..b4fd40a3 100644 --- a/apps/backend/src/domain/accounts/account.test.ts +++ b/apps/backend/src/domain/accounts/account.test.ts @@ -1,32 +1,37 @@ import { createUser } from 'src/db/createTestUser'; -import { AccountType } from 'src/domain/'; -import { Amount, Name, Currency, Id, Timestamp } from 'src/domain/domain-core/'; +import { AccountType, Commodity } from 'src/domain/'; +import { + Amount, + Name, + Id, + Timestamp, + CommodityCode, +} from 'src/domain/domain-core/'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { User } from '../users/user.entity'; import { Account } from './account.entity'; -const userIdValue = Id.restore( - '123e4567-e89b-12d3-a456-426614174000', -).valueOf(); const userTypeValue = 'asset'; -const currencyUSD = Currency.create('USD'); -const currencyEUR = Currency.create('EUR'); - -const currencyCodeEUR = currencyEUR.valueOf(); - const name = Name.create('account-name'); describe('Account Domain Entity', () => { const accountType = AccountType.create(userTypeValue); let user: User; + let commodity: Commodity; let userId: ReturnType; beforeAll(async () => { user = await createUser(); + commodity = Commodity.create(user, { + code: CommodityCode.create('COM').valueOf(), + name: 'commodity-name', + precision: 2, + symbol: 'C', + }); userId = user.getId(); }); @@ -36,26 +41,28 @@ describe('Account Domain Entity', () => { describe('create method', () => { it('should create account with valid data', () => { - const account = Account.create( - user, + const account = Account.create(user, { + commodityId: commodity.getId(), + description: 'account-description', + initialBalance: Amount.create('0'), name, - 'account-description', - Amount.create('0'), - currencyUSD, - accountType, - ); + type: accountType, + }); expect(account).toBeInstanceOf(Account); expect(account.getId()).toBeDefined(); - expect(account.getUserId()).toBe(userId); + expect(account.belongsToUser(userId)).toBe(true); expect(account).toHaveProperty('name', name); expect(account).toHaveProperty('description', 'account-description'); expect(account).toHaveProperty('initialBalance', Amount.create('0')); - expect(account).toHaveProperty('currency', currencyUSD); - expect(account.getType().valueOf()).toBe(userTypeValue); - expect(account.getUserId().equals(userId)).toBe(true); + expect(account.getType().valueOf()).toBe(accountType.valueOf()); expect(account.belongsToUser(userId)).toBe(true); expect(account.getType().equals(accountType)).toBe(true); + expect(account.isCommoditySame(commodity)).toBe(true); + expect(account).toHaveProperty( + 'commodityRelation', + expect.objectContaining({ parentId: commodity.getId() }), + ); }); }); @@ -70,8 +77,8 @@ describe('Account Domain Entity', () => { const updatedAt = Timestamp.restore(updatedAtValue); const account = Account.restore({ + commodityId: commodity.getId().valueOf(), createdAt: createdAt.valueOf(), - currency: currencyCodeEUR, currentClearedBalanceLocal: Amount.create('500').valueOf(), description: 'restored-description', id: accountId.valueOf(), @@ -81,29 +88,31 @@ describe('Account Domain Entity', () => { name: 'restored-account', type: userTypeValue, updatedAt: updatedAt.valueOf(), - userId: userIdValue, + userId: userId.valueOf(), }); expect(account).toBeInstanceOf(Account); expect(account.getId().toString()).toBe(accountIdValue); - expect(account.getUserId().toString()).toBe(userIdValue); + expect(account.belongsToUser(userId)).toBe(true); expect(account).toHaveProperty('name', Name.create('restored-account')); expect(account).toHaveProperty('description', 'restored-description'); expect(account).toHaveProperty('initialBalance', Amount.create('500')); - expect(account).toHaveProperty('currency', currencyEUR); + expect(account).toHaveProperty( + 'commodityRelation', + expect.objectContaining({ parentId: commodity.getId() }), + ); }); }); describe('account management', () => { it('should update account with valid value', () => { - const account = Account.create( - user, - Name.create('initial-name'), - 'description', - Amount.create('0'), - currencyUSD, - accountType, - ); + const account = Account.create(user, { + commodityId: commodity.getId(), + description: 'description', + initialBalance: Amount.create('0'), + name, + type: accountType, + }); account.update({ name: 'updated-name' }); @@ -113,14 +122,13 @@ describe('Account Domain Entity', () => { describe('softDelete method', () => { it('should mark account as tombstone', () => { - const account = Account.create( - user, + const account = Account.create(user, { + commodityId: commodity.getId(), + description: 'account-description', + initialBalance: Amount.create('0'), name, - 'account-description', - Amount.create('0'), - currencyUSD, - accountType, - ); + type: accountType, + }); expect(account.isDeleted()).toBe(false); @@ -130,14 +138,13 @@ describe('Account Domain Entity', () => { }); it('should not allow updates after soft deletion', () => { - const account = Account.create( - user, + const account = Account.create(user, { + commodityId: commodity.getId(), + description: 'account-description', + initialBalance: Amount.create('0'), name, - 'account-description', - Amount.create('0'), - currencyUSD, - accountType, - ); + type: accountType, + }); account.markAsDeleted(); @@ -147,14 +154,13 @@ describe('Account Domain Entity', () => { }); it('should not allow deleting an already deleted account', () => { - const account = Account.create( - user, + const account = Account.create(user, { + commodityId: commodity.getId(), + description: 'account-description', + initialBalance: Amount.create('0'), name, - 'account-description', - Amount.create('0'), - currencyUSD, - accountType, - ); + type: accountType, + }); account.markAsDeleted(); @@ -183,14 +189,13 @@ describe('Account Domain Entity', () => { vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); - const account = Account.create( - user, + const account = Account.create(user, { + commodityId: commodity.getId(), + description: 'account-description', + initialBalance: Amount.create('0'), name, - 'account-description', - Amount.create('0'), - currencyUSD, - accountType, - ); + type: accountType, + }); expect(account.isDeleted()).toBe(false); diff --git a/apps/backend/src/domain/accounts/types.ts b/apps/backend/src/domain/accounts/types.ts index 211b9a82..89112e0b 100644 --- a/apps/backend/src/domain/accounts/types.ts +++ b/apps/backend/src/domain/accounts/types.ts @@ -1,15 +1,18 @@ import { AccountTypeValue, - CurrencyCode, IsoDatetimeString, AmountString, UUID, } from '@ledgerly/shared/types'; +import { Amount, Id, Name } from '../domain-core'; + +import { AccountType } from './account-type.enum'; + export type AccountSnapshot = { createdAt: IsoDatetimeString; - currency: CurrencyCode; currentClearedBalanceLocal: AmountString; + commodityId: UUID; description: string; id: UUID; initialBalance: AmountString; @@ -22,8 +25,15 @@ export type AccountSnapshot = { }; export type AccountUpdateProps = Partial<{ - currency: CurrencyCode; description: string; name: string; type: AccountTypeValue; }>; + +export type CreateAccountProps = { + commodityId: Id; + description: string; + initialBalance: Amount; + name: Name; + type: AccountType; +}; diff --git a/apps/backend/src/domain/commodities/commodity.entity.ts b/apps/backend/src/domain/commodities/commodity.entity.ts new file mode 100644 index 00000000..a567c481 --- /dev/null +++ b/apps/backend/src/domain/commodities/commodity.entity.ts @@ -0,0 +1,198 @@ +import { + CommodityPrecisionNumber, + CommoditySymbolString, +} from '@ledgerly/shared/types'; + +import { + CommodityCode, + EntityIdentity, + EntityTimestamps, + Id, + Name, + ParentChildRelation, + SoftDelete, + Timestamp, +} from '../domain-core'; +import { DeletedEntityOperationError } from '../domain.errors'; +import { User } from '../users/user.entity'; + +import { parseCommodityPrecision, parseCommoditySymbol } from './helpers'; +import type { + CommoditySnapshot, + CommodityUpdateProps, + CreateCommodityProps, +} from './types'; + +// const DEFAULT_PRECISION = 2; +// const DEFAULT_SYMBOL = null; + +export class Commodity { + static readonly entityType = 'commodity'; + private constructor( + private readonly identity: EntityIdentity, + private timestamps: EntityTimestamps, + private softDelete: SoftDelete, + private readonly ownership: ParentChildRelation, + private code: CommodityCode, + public symbol: CommoditySymbolString | null, + public name: Name, + private precision: CommodityPrecisionNumber, + ) {} + + static readonly DEFAULT_PRECISION = 2; + static readonly DEFAULT_SYMBOL = null; + + static create(user: User, props: CreateCommodityProps): Commodity { + const identity = EntityIdentity.create(); + const softDelete = SoftDelete.create(); + const timestamps = EntityTimestamps.create(); + + const ownership = ParentChildRelation.create( + user.getId(), + identity.getId(), + ); + + const parsedCommoditySymbol = parseCommoditySymbol( + props.symbol ?? Commodity.DEFAULT_SYMBOL, + ); + const parsedCommodityPrecision = parseCommodityPrecision( + props.precision ?? Commodity.DEFAULT_PRECISION, + ); + + return new Commodity( + identity, + timestamps, + softDelete, + ownership, + CommodityCode.create(props.code), + parsedCommoditySymbol, + Name.create(props.name), + parsedCommodityPrecision, // use the provided precision value + ); + } + + static restore(data: CommoditySnapshot): Commodity { + const { + code, + createdAt, + id, + isTombstone, + precision, + symbol, + updatedAt, + userId, + } = data; + + const identity = EntityIdentity.restore(Id.restore(id)); + + const timestamps = EntityTimestamps.restore( + Timestamp.restore(updatedAt), + Timestamp.restore(createdAt), + ); + + const softDelete = SoftDelete.restore(isTombstone); + + const ownership = ParentChildRelation.create( + Id.restore(userId), + identity.getId(), + ); + + const commodityCode = CommodityCode.restore(code); + const commodityName = Name.restore(data.name); + + const parsedCommoditySymbol = parseCommoditySymbol(symbol); + const parsedCommodityPrecision = parseCommodityPrecision(precision); + + return new Commodity( + identity, + timestamps, + softDelete, + ownership, + commodityCode, + parsedCommoditySymbol, + commodityName, + parsedCommodityPrecision, + ); + } + + getId(): Id { + return this.identity.getId(); + } + + // Delegation methods for timestamps + getUpdatedAt(): Timestamp { + return this.timestamps.getUpdatedAt(); + } + + getCreatedAt(): Timestamp { + return this.timestamps.getCreatedAt(); + } + + private touch(now?: Timestamp): void { + this.timestamps = this.timestamps.touch(now); + } + + markAsDeleted(): void { + this.softDelete = this.softDelete.markAsDeleted( + new DeletedEntityOperationError(Commodity.entityType, 'delete'), + ); + this.touch(); + } + + isDeleted(): boolean { + return this.softDelete.isDeleted(); + } + + private validateUpdateIsAllowed(): void { + this.softDelete.validateUpdateIsAllowed( + DeletedEntityOperationError.forUpdate(Commodity.entityType), + ); + } + + belongsToUser(userId: Id): boolean { + return this.ownership.belongsToParent(userId); + } + + getUserId(): Id { + return this.ownership.getParentId(); + } + + toSnapshot(): CommoditySnapshot { + return { + code: this.code.valueOf(), + createdAt: this.getCreatedAt().valueOf(), + id: this.getId().valueOf(), + isTombstone: this.softDelete.getIsTombstone(), + name: this.name.valueOf(), + precision: this.precision, + symbol: this.symbol, + updatedAt: this.getUpdatedAt().valueOf(), + userId: this.getUserId().valueOf(), + }; + } + + update(data: CommodityUpdateProps): void { + this.validateUpdateIsAllowed(); + + let isUpdated = false; + + if (data.code !== undefined) { + this.code = CommodityCode.create(data.code); + isUpdated = true; + } + + if (data.symbol !== undefined) { + this.symbol = parseCommoditySymbol(data.symbol); + isUpdated = true; + } + + if (data.name !== undefined) { + this.name = Name.create(data.name); + isUpdated = true; + } + + if (isUpdated) { + this.touch(); + } + } +} diff --git a/apps/backend/src/domain/commodities/commodity.test.ts b/apps/backend/src/domain/commodities/commodity.test.ts new file mode 100644 index 00000000..d46dbdee --- /dev/null +++ b/apps/backend/src/domain/commodities/commodity.test.ts @@ -0,0 +1,312 @@ +import { createUser } from 'src/testing'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { CommodityCode, Id, Name, Timestamp } from '../domain-core'; +import { + DeletedEntityOperationError, + InvalidCommodityPrecisionError, + InvalidCommoditySymbolError, +} from '../domain.errors'; +import { User } from '../users/user.entity'; + +import { Commodity } from './commodity.entity'; +import { CommoditySnapshot, CreateCommodityProps } from './types'; + +describe('Commodity Domain Entity', () => { + let user: User; + let userId: ReturnType; + + const commodityCode = CommodityCode.create('commodityCode'); + const commoditySymbol = 'CMD'; + const commodityName = Name.create('Commodity Name'); + const commodityPrecision = 2; + + const commodityCreateProps: CreateCommodityProps = { + code: commodityCode.valueOf(), + name: commodityName.valueOf(), + precision: commodityPrecision, + symbol: commoditySymbol, + }; + + beforeAll(async () => { + user = await createUser(); + userId = user.getId(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('create method', () => { + it('should create commodity with valid data', () => { + const commodity = Commodity.create(user, commodityCreateProps); + + expect(commodity).toBeInstanceOf(Commodity); + expect(commodity.getId()).toBeDefined(); + expect(commodity.getUserId().valueOf()).toBe(userId.valueOf()); + expect(commodity).toHaveProperty('code', commodityCode); + expect(commodity).toHaveProperty('symbol', commoditySymbol); + expect(commodity.getUserId().equals(userId)).toBe(true); + }); + + it('should create commodity with default precision and null symbol', () => { + const commodity = Commodity.create(user, { + code: commodityCode.valueOf(), + name: commodityName.valueOf(), + }); + + expect(commodity).toBeInstanceOf(Commodity); + expect(commodity.getId()).toBeDefined(); + expect(commodity.getUserId().valueOf()).toBe(userId.valueOf()); + expect(commodity).toHaveProperty('code', commodityCode); + expect(commodity).toHaveProperty('symbol', null); + expect(commodity.getUserId().equals(userId)).toBe(true); + }); + + it('should create commodity with specified precision and null symbol', () => { + const specifiedPrecision = 4; + const commodity = Commodity.create(user, { + code: commodityCode.valueOf(), + name: commodityName.valueOf(), + precision: specifiedPrecision, + }); + + expect(commodity).toBeInstanceOf(Commodity); + expect(commodity.getId()).toBeDefined(); + expect(commodity.getUserId().valueOf()).toBe(userId.valueOf()); + expect(commodity).toHaveProperty('code', commodityCode); + expect(commodity).toHaveProperty('symbol', null); + expect(commodity.getUserId().equals(userId)).toBe(true); + }); + + it('should throw error when creating commodity with invalid precision', () => { + const invalidPrecision = -1; + + expect(() => { + Commodity.create(user, { + ...commodityCreateProps, + precision: invalidPrecision, + }); + }).toThrowError(InvalidCommodityPrecisionError); + }); + + it('should throw error when creating commodity with invalid symbol', () => { + const invalidSymbol = 'INVALID_SYMBOL'; + + expect(() => { + Commodity.create(user, { + ...commodityCreateProps, + symbol: invalidSymbol, + }); + }).toThrowError(InvalidCommoditySymbolError); + }); + }); + + describe('restore method', () => { + it('should restore commodity from snapshot', () => { + const createdAtValue = '2023-10-01T12:00:00.000Z'; + const updatedAtValue = '2023-10-02T12:00:00.000Z'; + const commodityIdValue = '223e4567-e89b-12d3-a456-426614174000'; + + const createdAt = Timestamp.restore(createdAtValue); + const updatedAt = Timestamp.restore(updatedAtValue); + + const commodityId = Id.restore(commodityIdValue); + + const snapshot: CommoditySnapshot = { + code: commodityCode.valueOf(), + createdAt: createdAt.valueOf(), + id: commodityId.valueOf(), + isTombstone: false, + name: commodityName.valueOf(), + precision: commodityPrecision, + symbol: 'RCMD', + updatedAt: updatedAt.valueOf(), + userId: userId.valueOf(), + }; + + const restoredCommodity = Commodity.restore(snapshot); + + expect(restoredCommodity).toBeInstanceOf(Commodity); + expect(restoredCommodity.getId().valueOf()).toBe(snapshot.id); + expect(restoredCommodity.getUserId().valueOf()).toBe(snapshot.userId); + expect(restoredCommodity).toHaveProperty( + 'code', + CommodityCode.create(snapshot.code), + ); + + expect(restoredCommodity.symbol).toBe(snapshot.symbol); + expect(restoredCommodity.belongsToUser(userId)).toBe(true); + }); + + it('should throw error when restoring commodity with invalid precision', () => { + const snapshot: CommoditySnapshot = { + code: commodityCode.valueOf(), + createdAt: Timestamp.create().valueOf(), + id: Id.create().valueOf(), + isTombstone: false, + name: commodityName.valueOf(), + precision: -1, // Invalid precision + symbol: 'RCMD', + updatedAt: Timestamp.create().valueOf(), + userId: userId.valueOf(), + }; + + expect(() => { + Commodity.restore(snapshot); + }).toThrowError(InvalidCommodityPrecisionError); + }); + + it('should throw error when restoring commodity with invalid symbol', () => { + const snapshot: CommoditySnapshot = { + code: commodityCode.valueOf(), + createdAt: Timestamp.create().valueOf(), + id: Id.create().valueOf(), + isTombstone: false, + name: commodityName.valueOf(), + precision: commodityPrecision, + symbol: 'INVALID_SYMBOL', // Invalid symbol + updatedAt: Timestamp.create().valueOf(), + userId: userId.valueOf(), + }; + + expect(() => { + Commodity.restore(snapshot); + }).toThrowError(InvalidCommoditySymbolError); + }); + }); + + describe('commodity management', () => { + describe('markAsDeleted and isDeleted methods', () => { + it('should mark commodity as deleted and check deletion status', () => { + const commodity = Commodity.create(user, commodityCreateProps); + + expect(commodity.isDeleted()).toBe(false); + + commodity.markAsDeleted(); + + expect(commodity.isDeleted()).toBe(true); + }); + + it('should not allow updates on a deleted commodity', () => { + const commodity = Commodity.create(user, commodityCreateProps); + + commodity.markAsDeleted(); + + const newCode = CommodityCode.create('updatedCode'); + + expect(() => { + commodity.update({ code: newCode.valueOf(), symbol: 'NEW' }); + }).toThrow(DeletedEntityOperationError); + }); + + it('should not allow deleting an already deleted commodity', () => { + const commodity = Commodity.create(user, commodityCreateProps); + + commodity.markAsDeleted(); + + const deletedSnapshot = commodity.toSnapshot(); + + let thrownError: unknown; + + try { + commodity.markAsDeleted(); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toMatchObject({ + code: 'DELETED_ENTITY_OPERATION', + context: { + entityType: Commodity.entityType, + operation: 'delete', + }, + }); + + expect(commodity.toSnapshot()).toEqual(deletedSnapshot); + }); + + it('should only mark commodity as deleted and update timestamp during soft deletion', () => { + vi.useFakeTimers(); + + const timestampBeforeDeletionValue = '2025-01-01T00:00:00.000Z'; + const timestampDuringDeletionValue = '2025-01-01T00:00:01.000Z'; + + vi.setSystemTime(new Date(timestampBeforeDeletionValue)); + + const commodity = Commodity.create(user, commodityCreateProps); + + const commoditySnapshotBeforeDeletion = commodity.toSnapshot(); + + expect(commodity.isDeleted()).toBe(false); + + vi.setSystemTime(new Date(timestampDuringDeletionValue)); + + commodity.markAsDeleted(); + + const commoditySnapshotAfterDeletion = commodity.toSnapshot(); + + expect(commodity.isDeleted()).toBe(true); + + expect(commoditySnapshotAfterDeletion).toEqual({ + ...commoditySnapshotBeforeDeletion, + isTombstone: true, + updatedAt: commoditySnapshotAfterDeletion.updatedAt, + }); + + expect(commoditySnapshotAfterDeletion.updatedAt).not.toBe( + commoditySnapshotBeforeDeletion.updatedAt, + ); + }); + }); + + describe('update method', () => { + it('should update commodity name, code, and symbol and touch the timestamp', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + + const commodity = Commodity.create(user, commodityCreateProps); + + const before = commodity.toSnapshot(); + + const newName = Name.create('Updated Commodity Name'); + const newCode = CommodityCode.create('updatedCode'); + const newSymbol = 'UCMD'; + + vi.setSystemTime(new Date('2025-01-01T00:00:01.000Z')); + + commodity.update({ + code: newCode.valueOf(), + name: newName.valueOf(), + symbol: newSymbol, + }); + + const updatedSnapshot = commodity.toSnapshot(); + + expect(updatedSnapshot.name).toBe(newName.valueOf()); + expect(updatedSnapshot.symbol).toBe(newSymbol); + expect(updatedSnapshot.code).toBe(newCode.valueOf()); + expect(updatedSnapshot.updatedAt).not.toBe(before.updatedAt); + }); + }); + + it('should throw error when updating commodity with invalid symbol', () => { + const commodity = Commodity.create(user, commodityCreateProps); + + expect(() => { + commodity.update({ + symbol: 'INVALID_SYMBOL', + }); + }).toThrowError(); + }); + + it('should not update timestamp for empty update', () => { + const commodity = Commodity.create(user, commodityCreateProps); + const snapshot = commodity.toSnapshot(); + + commodity.update({}); + + expect(commodity.toSnapshot()).toEqual(snapshot); + }); + }); +}); diff --git a/apps/backend/src/domain/commodities/helpers.ts b/apps/backend/src/domain/commodities/helpers.ts new file mode 100644 index 00000000..211880aa --- /dev/null +++ b/apps/backend/src/domain/commodities/helpers.ts @@ -0,0 +1,34 @@ +import { + CommoditySymbolString, + CommodityPrecisionNumber, +} from '@ledgerly/shared/types'; +import { + commoditySymbol, + commodityPrecision, +} from '@ledgerly/shared/validation'; + +import { parseValueObject } from '../domain-core'; +import { + InvalidCommoditySymbolError, + InvalidCommodityPrecisionError, +} from '../domain.errors'; + +export const parseCommoditySymbol = ( + symbol: string | null, +): CommoditySymbolString | null => { + return parseValueObject(symbol, commoditySymbol, (cause, invalidValue) => { + throw new InvalidCommoditySymbolError(invalidValue, cause); + }); +}; + +export const parseCommodityPrecision = ( + precision: number, +): CommodityPrecisionNumber => { + return parseValueObject( + precision, + commodityPrecision, + (cause, invalidValue) => { + throw new InvalidCommodityPrecisionError(invalidValue, cause); + }, + ); +}; diff --git a/apps/backend/src/domain/commodities/index.ts b/apps/backend/src/domain/commodities/index.ts new file mode 100644 index 00000000..366221c8 --- /dev/null +++ b/apps/backend/src/domain/commodities/index.ts @@ -0,0 +1,6 @@ +export { Commodity } from './commodity.entity'; +export { + CommoditySnapshot, + CommodityUpdateProps, + CreateCommodityProps, +} from './types'; diff --git a/apps/backend/src/domain/commodities/types.ts b/apps/backend/src/domain/commodities/types.ts new file mode 100644 index 00000000..d63e6f27 --- /dev/null +++ b/apps/backend/src/domain/commodities/types.ts @@ -0,0 +1,33 @@ +import { + CommodityCodeString, + CommoditySymbolString, + IsoDatetimeString, + UUID, + CommodityPrecisionNumber, + RequiredText, +} from '@ledgerly/shared/types'; + +export type CommoditySnapshot = { + createdAt: IsoDatetimeString; + id: UUID; + isTombstone: boolean; + code: CommodityCodeString; + symbol: CommoditySymbolString | null; + updatedAt: IsoDatetimeString; + userId: UUID; + name: RequiredText; + precision: CommodityPrecisionNumber; +}; + +export type CommodityUpdateProps = Partial<{ + code: CommodityCodeString; + symbol: CommoditySymbolString | null; + name: RequiredText; +}>; + +export type CreateCommodityProps = { + code: CommodityCodeString; + name: RequiredText; + precision?: CommodityPrecisionNumber; + symbol?: CommoditySymbolString | null; +}; diff --git a/apps/backend/src/domain/domain-core/README.md b/apps/backend/src/domain/domain-core/README.md index 5e5a053a..a1fcb971 100644 --- a/apps/backend/src/domain/domain-core/README.md +++ b/apps/backend/src/domain/domain-core/README.md @@ -17,7 +17,7 @@ Value Objects - immutable objects that are described by their attributes, not by - **`Id`** - unique identifier - **`Timestamp`** - timestamp - **`Amount`** - signed integer minor-unit monetary amount without currency -- **`Currency`** - currency +- **`CommodityCode`** - display/search code for a user-owned Commodity - **`DateValue`** - date value - **`Name`** - name/title - **`Email`** - email address with validation @@ -156,7 +156,7 @@ console.log(name.valueOf()); // 'John Doe' // Amount for monetary operations const amount = Amount.create('100.50'); -const currency = Currency.create('USD'); +const commodityCode = CommodityCode.create('USD'); // Value Objects comparison const email1 = Email.create('TEST@example.com'); diff --git a/apps/backend/src/domain/domain-core/value-objects/CommodityCode.test.ts b/apps/backend/src/domain/domain-core/value-objects/CommodityCode.test.ts new file mode 100644 index 00000000..2c0444a3 --- /dev/null +++ b/apps/backend/src/domain/domain-core/value-objects/CommodityCode.test.ts @@ -0,0 +1,89 @@ +import { InvalidCommodityCodeError } from 'src/domain/domain.errors'; +import { describe, expect, it } from 'vitest'; + +import { CommodityCode } from './CommodityCode'; + +describe('CommodityCode Value Object', () => { + describe('create method', () => { + it('should create valid commodity code', () => { + const commodityCode = CommodityCode.create('CMD'); + + expect(commodityCode.valueOf()).toBe('CMD'); + }); + + it('should normalize commodity code to uppercase', () => { + const commodityCode = CommodityCode.create('cmd'); + + expect(commodityCode.valueOf()).toBe('CMD'); + }); + + it('should trim whitespace', () => { + const commodityCode = CommodityCode.create(' CMD '); + + expect(commodityCode.valueOf()).toBe('CMD'); + }); + + it('should throw error for invalid commodity codes', () => { + const invalidCodes = [ + '', + ' ', + ' ', + 'US', + '123', + '1USD', + 'USD EUR', + ' USD EUR ', + 'USD!', + 'USD$', + 'USD.', + 'USD,', + 'USD/', + 'USD\\', + 'USD@', + 'USD#', + '_USD', + '-USD', + 'РУБ', + '€', + 'ドル', + 'QWERTYUIOPASDFGHJ', + ]; + + invalidCodes.forEach((invalidCode) => { + expect(() => CommodityCode.create(invalidCode)).toThrow( + InvalidCommodityCodeError, + ); + }); + }); + + it('should accept valid commodity codes', () => { + const validCodes = [ + 'CMD', + 'USD', + 'EUR', + 'JPY', + 'GBP', + 'AUD', + 'CAD', + 'CHF', + 'CNY', + 'SEK', + 'RUB', + 'BTC', + 'ETH', + 'XAU', + 'BONUS', + 'WORKHOURS', + 'USD1', + 'ABC123', + 'usd', + ' UsD ', + 'QWERTYUIOPASDFGH', + ]; + + validCodes.forEach((validCode) => { + expect(() => CommodityCode.create(validCode)).not.toThrow(); + }); + }); + }); +}); diff --git a/apps/backend/src/domain/domain-core/value-objects/CommodityCode.ts b/apps/backend/src/domain/domain-core/value-objects/CommodityCode.ts new file mode 100644 index 00000000..fcdd05af --- /dev/null +++ b/apps/backend/src/domain/domain-core/value-objects/CommodityCode.ts @@ -0,0 +1,34 @@ +import { CommodityCodeString } from '@ledgerly/shared/types'; +import { commodityCode } from '@ledgerly/shared/validation'; +import { InvalidCommodityCodeError } from 'src/domain/domain.errors'; + +import { parseValueObject } from './parseValueObject'; + +const parseCommodityCode = (code: string): CommodityCodeString => { + return parseValueObject(code, commodityCode, (cause, invalidValue) => { + throw new InvalidCommodityCodeError(invalidValue, cause); + }); +}; + +export class CommodityCode { + private constructor(private readonly code: CommodityCodeString) { + Object.freeze(this); + } + + static create(code: string): CommodityCode { + const normalizedCode = parseCommodityCode(code); + return new CommodityCode(normalizedCode); + } + + static restore(code: string): CommodityCode { + return new CommodityCode(parseCommodityCode(code)); + } + + equals(other: CommodityCode): boolean { + return this.code === other.code; + } + + valueOf(): CommodityCodeString { + return this.code; + } +} diff --git a/apps/backend/src/domain/domain-core/value-objects/Currency.ts b/apps/backend/src/domain/domain-core/value-objects/Currency.ts deleted file mode 100644 index cd602b2a..00000000 --- a/apps/backend/src/domain/domain-core/value-objects/Currency.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { CurrencyCode } from '@ledgerly/shared/types'; -import { currencyCode } from '@ledgerly/shared/validation'; - -const SUPPORTED_CODES = new Set([ - 'USD' as CurrencyCode, - 'EUR' as CurrencyCode, - 'GBP' as CurrencyCode, - 'RUB' as CurrencyCode, -]); - -export class Currency { - private readonly code: CurrencyCode; - - private constructor(code: CurrencyCode) { - this.code = code; - Object.freeze(this); - } - - static create(raw: string): Currency { - const normalized = raw.trim().toUpperCase(); - const parsed = currencyCode.parse(normalized); // throws on invalid format - return new Currency(parsed); - } - - static restore(codeStr: string): Currency { - const parsed = currencyCode.parse(codeStr); - return new Currency(parsed); - } - - equals(other: Currency): boolean { - return this.code === other.code; - } - - valueOf(): CurrencyCode { - return this.code; - } - - toPersistence(): CurrencyCode { - return this.code; - } - - static isSupported(codeOrCurrency: string | Currency): boolean { - const code = - typeof codeOrCurrency === 'string' - ? codeOrCurrency.toUpperCase() - : codeOrCurrency.valueOf(); - return SUPPORTED_CODES.has(code as CurrencyCode); - } -} diff --git a/apps/backend/src/domain/domain-core/value-objects/index.ts b/apps/backend/src/domain/domain-core/value-objects/index.ts index 039c697e..6276c8cc 100644 --- a/apps/backend/src/domain/domain-core/value-objects/index.ts +++ b/apps/backend/src/domain/domain-core/value-objects/index.ts @@ -1,7 +1,6 @@ export { Id } from './Id'; export { Timestamp } from './Timestamp'; export { Amount } from './Amount'; -export { Currency } from './Currency'; export { Name } from './Name'; export { Email } from './Email'; export { ParentChildRelation } from './ParentChildRelation'; @@ -9,3 +8,4 @@ export { Password } from './Password'; export { DateValue } from './DateValue'; export { Version } from './Version'; export { parseValueObject } from './parseValueObject'; +export { CommodityCode } from './CommodityCode'; diff --git a/apps/backend/src/domain/domain-core/value-objects/value-object-api-conventions.test.ts b/apps/backend/src/domain/domain-core/value-objects/value-object-api-conventions.test.ts index c7bbedf3..d9e30273 100644 --- a/apps/backend/src/domain/domain-core/value-objects/value-object-api-conventions.test.ts +++ b/apps/backend/src/domain/domain-core/value-objects/value-object-api-conventions.test.ts @@ -4,7 +4,6 @@ import { AccountType } from '../../accounts'; import { EntityIdentity, EntityTimestamps, SoftDelete } from '../behaviors'; import { Amount } from './Amount'; -import { Currency } from './Currency'; import { DateValue } from './DateValue'; import { Email } from './Email'; import { Id } from './Id'; @@ -19,7 +18,6 @@ describe('value object API conventions', () => { const id = '11111111-1111-4111-8111-111111111111'; expect(Amount.restore('1200').valueOf()).toBe('1200'); - expect(Currency.restore('USD').valueOf()).toBe('USD'); expect(DateValue.restore('2026-07-10').valueOf()).toBe('2026-07-10'); expect(Email.restore('user@example.com').valueOf()).toBe( 'user@example.com', @@ -41,7 +39,6 @@ describe('value object API conventions', () => { const childId = Id.restore('22222222-2222-4222-8222-222222222222'); expect(Amount.create('1200').equals(Amount.restore('1200'))).toBe(true); - expect(Currency.create('usd').equals(Currency.restore('USD'))).toBe(true); expect( DateValue.restore('2026-07-10').equals(DateValue.restore('2026-07-10')), ).toBe(true); @@ -73,7 +70,6 @@ describe('value object API conventions', () => { it('does not expose domain fromPersistence restoration aliases', () => { const constructors = [ Amount, - Currency, Email, Id, Name, @@ -95,8 +91,6 @@ describe('value object API conventions', () => { const valueObjects = [ Amount.create('1200'), Amount.restore('1200'), - Currency.create('usd'), - Currency.restore('USD'), DateValue.create(), DateValue.restore('2026-07-10'), Email.create('USER@example.com'), diff --git a/apps/backend/src/domain/domain-entity-identity-api.test.ts b/apps/backend/src/domain/domain-entity-identity-api.test.ts index 06894812..8c53a8db 100644 --- a/apps/backend/src/domain/domain-entity-identity-api.test.ts +++ b/apps/backend/src/domain/domain-entity-identity-api.test.ts @@ -2,7 +2,6 @@ import { createAccount, createUser } from 'src/db/createTestUser'; import { Account, Operation, Transaction, User } from 'src/domain'; import { Amount, - Currency, DateValue, Id, Timestamp, @@ -17,8 +16,8 @@ describe('domain entity identity API conventions', () => { const user = await createUser(); const account = createAccount(user); const transaction = Transaction.restore({ + commodityId: Id.create().valueOf(), createdAt: Timestamp.create().valueOf(), - currency: Currency.create('USD').valueOf(), description: 'Restored transaction', id: Id.create().valueOf(), isTombstone: false, diff --git a/apps/backend/src/domain/domain.errors.ts b/apps/backend/src/domain/domain.errors.ts index 78e6de93..73f36d15 100644 --- a/apps/backend/src/domain/domain.errors.ts +++ b/apps/backend/src/domain/domain.errors.ts @@ -75,6 +75,57 @@ export class InvalidAmountError extends CodedDomainError<'INVALID_AMOUNT'> { } } +export class InvalidCommodityCodeError extends CodedDomainError<'INVALID_COMMODITY_CODE'> { + constructor( + public readonly value: unknown, + cause?: Error, + ) { + super( + 'commodity code is invalid', + apiErrorCodes.invalidCommodityCode, + { + reason: 'INVALID_FORMAT', + received: String(value), + }, + cause, + ); + } +} + +export class InvalidCommodityPrecisionError extends CodedDomainError<'INVALID_COMMODITY_PRECISION'> { + constructor( + public readonly value: unknown, + cause?: Error, + ) { + super( + 'commodity precision is invalid', + apiErrorCodes.invalidCommodityPrecision, + { + reason: 'INVALID_FORMAT', + received: String(value), + }, + cause, + ); + } +} + +export class InvalidCommoditySymbolError extends CodedDomainError<'INVALID_COMMODITY_SYMBOL'> { + constructor( + public readonly value: unknown, + cause?: Error, + ) { + super( + 'commodity symbol is invalid', + apiErrorCodes.invalidCommoditySymbol, + { + reason: 'INVALID_FORMAT', + received: String(value), + }, + cause, + ); + } +} + export class InvalidDateError extends CodedDomainError<'INVALID_DATE'> { constructor(cause?: Error) { super( diff --git a/apps/backend/src/domain/index.ts b/apps/backend/src/domain/index.ts index a8666551..c6be0b0c 100644 --- a/apps/backend/src/domain/index.ts +++ b/apps/backend/src/domain/index.ts @@ -2,5 +2,6 @@ export { User } from './users/user.entity'; export { Account, AccountType } from './accounts/'; export { Transaction } from './transactions/'; export { Operation } from './operations'; +export { Commodity } from './commodities'; export * from './domain.errors'; diff --git a/apps/backend/src/domain/operations/operation.entity.test.ts b/apps/backend/src/domain/operations/operation.entity.test.ts index 0dc33ec5..f8011e6f 100644 --- a/apps/backend/src/domain/operations/operation.entity.test.ts +++ b/apps/backend/src/domain/operations/operation.entity.test.ts @@ -31,7 +31,7 @@ describe('Operation Domain Entity', () => { let eurAccount: Account; const transactionData: TransactionProps = { - currencyCode: 'USD', + currency: 'USD', description: 'Test transaction', postingDate: '2024-01-01', transactionDate: '2024-01-01', @@ -50,7 +50,7 @@ describe('Operation Domain Entity', () => { userId = user.getId(); const data = TransactionBuilder.transaction({ - accounts: ['USD', 'EUR'], + currencies: ['USD', 'EUR'], operations: [ { accountKey: 'USD', amount: '100', description: 'Debit' }, { accountKey: 'EUR', amount: '-100', description: 'Credit' }, diff --git a/apps/backend/src/domain/transactions/transaction.entity.test.ts b/apps/backend/src/domain/transactions/transaction.entity.test.ts index 95bb3947..d56ce0b4 100644 --- a/apps/backend/src/domain/transactions/transaction.entity.test.ts +++ b/apps/backend/src/domain/transactions/transaction.entity.test.ts @@ -31,7 +31,7 @@ import { } from 'vitest'; import { Account } from '../accounts'; -import { Amount, Currency, DateValue, Id, Version } from '../domain-core'; +import { Amount, DateValue, Id, Version } from '../domain-core'; import { CreateOperationProps, OperationSnapshot, @@ -91,7 +91,7 @@ const toCreateTransactionProps = ( 'getAccountByKey' | 'operationsData' | 'transactionDTO' | 'transactionData' >, ): CreateTransactionProps => ({ - currency: Currency.create(fixture.transactionDTO.currencyCode), + commodityId: Id.restore(fixture.transactionData.commodityId), description: fixture.transactionData.description, operations: toCreateOperationProps( fixture.operationsData, @@ -121,7 +121,7 @@ describe('Transaction Domain Entity', () => { let transactionData: CreateTransactionProps; const transactionRawData: TransactionProps = { - currencyCode: 'USD', + currency: 'USD', description: 'Buy groceries', postingDate: '2024-01-01', transactionDate: '2024-01-01', @@ -192,7 +192,7 @@ describe('Transaction Domain Entity', () => { user = await createUser(); data = TransactionBuilder.transaction({ - accounts: ['USD', 'EUR', 'RUB', 'TRY'], + currencies: ['USD', 'EUR', 'RUB', 'TRY'], operations: operationsData, settings: transactionRawData, user, @@ -227,8 +227,6 @@ describe('Transaction Domain Entity', () => { expect(transaction.getVersion().valueOf()).toBe(0); expect(transaction.isDeleted()).toBe(false); - expect(transaction.currency).toBe(transactionData.currency); - const transactionOperations = transaction.getOperations(); expect(transactionOperations).toHaveLength(operationsData.length); @@ -1036,7 +1034,7 @@ describe('Transaction Domain Entity', () => { describe('Transaction Domain Invariants coverage', () => { it('should accept a multi-currency transaction balanced by value even when amounts do not sum to zero', () => { const data = TransactionBuilder.request({ - accounts: ['USD', 'RUB'], + currencies: ['USD', 'RUB'], operations: [ { accountKey: 'USD', @@ -1062,7 +1060,7 @@ describe('Transaction Domain Entity', () => { it('should reject a multi-currency transaction with balanced amounts but unbalanced values', () => { const data = TransactionBuilder.request({ - accounts: ['USD', 'RUB'], + currencies: ['USD', 'RUB'], operations: [ { accountKey: 'USD', @@ -1088,7 +1086,7 @@ describe('Transaction Domain Entity', () => { it('should validate a multi-currency operation patch by value rather than amount', () => { const data = TransactionBuilder.request({ - accounts: ['USD', 'RUB'], + currencies: ['USD', 'RUB'], operations: [ { accountKey: 'USD', @@ -1145,7 +1143,7 @@ describe('Transaction Domain Entity', () => { ]; const data = TransactionBuilder.request({ - accounts: ['USD', 'EUR', 'RUB', 'TRY'], + currencies: ['USD', 'EUR', 'RUB', 'TRY'], operations: operationsData, settings: transactionRawData, user, @@ -1183,7 +1181,7 @@ describe('Transaction Domain Entity', () => { ]; const data = TransactionBuilder.request({ - accounts: ['USD', 'EUR', 'RUB', 'TRY'], + currencies: ['USD', 'EUR', 'RUB', 'TRY'], operations: operationsData, settings: transactionRawData, user, @@ -1224,7 +1222,7 @@ describe('Transaction Domain Entity', () => { it('should reject creation with zero operations', () => { const data = TransactionBuilder.request({ - accounts: ['USD', 'EUR', 'RUB', 'TRY'], + currencies: ['USD', 'EUR', 'RUB', 'TRY'], operations: [], settings: transactionRawData, user, @@ -1256,7 +1254,7 @@ describe('Transaction Domain Entity', () => { ]; const data = TransactionBuilder.request({ - accounts: ['USD', 'EUR', 'RUB', 'TRY'], + currencies: ['USD', 'EUR', 'RUB', 'TRY'], operations: operationsData, settings: transactionRawData, user, @@ -1313,7 +1311,7 @@ describe('Transaction Domain Entity', () => { ]; const data = TransactionBuilder.request({ - accounts: ['USD', 'EUR', 'RUB', 'TRY'], + currencies: ['USD', 'EUR', 'RUB', 'TRY'], operations: operationsData, settings: transactionRawData, user, @@ -1403,7 +1401,7 @@ describe('Transaction Domain Entity', () => { ]; const data = TransactionBuilder.request({ - accounts: ['USD', 'EUR', 'RUB', 'TRY'], + currencies: ['USD', 'EUR', 'RUB', 'TRY'], operations: operationsData, settings: transactionRawData, user, diff --git a/apps/backend/src/domain/transactions/transaction.entity.ts b/apps/backend/src/domain/transactions/transaction.entity.ts index cf54a946..00597617 100644 --- a/apps/backend/src/domain/transactions/transaction.entity.ts +++ b/apps/backend/src/domain/transactions/transaction.entity.ts @@ -20,7 +20,6 @@ import { ParentChildRelation, DateValue, Amount, - Currency, Version, } from '../domain-core'; import { Operation } from '../operations'; @@ -66,7 +65,8 @@ export class Transaction { private readonly ownership: ParentChildRelation, private postingDate: DateValue, private transactionDate: DateValue, - public currency: Currency, + private readonly commodityRelation: ParentChildRelation, + public description: string, private version: Version, ) {} @@ -77,6 +77,11 @@ export class Transaction { const softDelete = SoftDelete.create(); const ownership = ParentChildRelation.create(userId, identity.getId()); + const commodityRelation = ParentChildRelation.create( + dto.commodityId, + identity.getId(), + ); + const transaction = new Transaction( identity, timestamps, @@ -84,7 +89,7 @@ export class Transaction { ownership, dto.postingDate, dto.transactionDate, - dto.currency, + commodityRelation, dto.description, Version.create(0), ); @@ -147,8 +152,8 @@ export class Transaction { static restore(data: TransactionSnapshotWithDetails): Transaction { const { + commodityId, createdAt, - currency, description, id, isTombstone, @@ -172,6 +177,11 @@ export class Transaction { identity.getId(), ); + const commodityRelation = ParentChildRelation.create( + Id.restore(commodityId), + identity.getId(), + ); + const transaction = new Transaction( identity, timestamps, @@ -179,7 +189,7 @@ export class Transaction { ownership, DateValue.restore(postingDate), DateValue.restore(transactionDate), - Currency.restore(currency), + commodityRelation, description, Version.restore(version), ); @@ -230,14 +240,18 @@ export class Transaction { return this.ownership.getParentId(); } + getCommodityId(): Id { + return this.commodityRelation.getParentId(); + } + canBeUpdated(): boolean { return !this.isDeleted(); } private buildSnapshot(operations: Operation[]): TransactionSnapshot { return { + commodityId: this.commodityRelation.getParentId().valueOf(), createdAt: this.getCreatedAt().valueOf(), - currency: this.currency.valueOf(), description: this.description, id: this.getId().valueOf(), isTombstone: this.isDeleted(), diff --git a/apps/backend/src/domain/transactions/types.ts b/apps/backend/src/domain/transactions/types.ts index 93de31f6..73fee402 100644 --- a/apps/backend/src/domain/transactions/types.ts +++ b/apps/backend/src/domain/transactions/types.ts @@ -1,12 +1,7 @@ -import { - IsoDateString, - UUID, - CurrencyCode, - IsoDatetimeString, -} from '@ledgerly/shared/types'; +import { IsoDateString, UUID, IsoDatetimeString } from '@ledgerly/shared/types'; import { Account } from '../accounts'; -import { Currency, DateValue, Id } from '../domain-core'; +import { DateValue, Id } from '../domain-core'; import { CreateOperationProps, OperationProps, @@ -22,7 +17,6 @@ export type TransactionUpdateData = { export type TransactionBuildContext = { accountsMap: Map; - systemAccountsMap: Map; }; export type CreateTransactionProps = { @@ -30,7 +24,7 @@ export type CreateTransactionProps = { postingDate: DateValue; transactionDate: DateValue; operations: OperationProps[]; - currency: Currency; + commodityId: Id; }; export type OperationsPatch = { @@ -53,9 +47,9 @@ export type TransactionSnapshot = { transactionDate: IsoDateString; updatedAt: IsoDatetimeString; userId: UUID; + commodityId: UUID; operations: OperationSnapshot[]; version: number; - currency: CurrencyCode; }; export type TransactionSnapshotWithDetails = TransactionSnapshot & { 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 08939b8d..6fae78ce 100644 --- a/apps/backend/src/infrastructure/db/accounts/account-persistence.mapper.test.ts +++ b/apps/backend/src/infrastructure/db/accounts/account-persistence.mapper.test.ts @@ -1,13 +1,13 @@ import { AccountDbRow } from 'src/db/schema'; -import { Amount, Currency, Id, Timestamp } from 'src/domain/domain-core'; +import { Amount, Id, Timestamp } from 'src/domain/domain-core'; import { describe, expect, it } from 'vitest'; import { AccountPersistenceMapper } from './account-persistence.mapper'; describe('AccountPersistenceMapper', () => { const row: AccountDbRow = { + commodityId: Id.create().valueOf(), createdAt: Timestamp.create().valueOf(), - currency: Currency.create('USD').valueOf(), currentClearedBalanceLocal: Amount.create('2500').valueOf(), description: 'Operating account', id: Id.create().valueOf(), @@ -22,8 +22,8 @@ describe('AccountPersistenceMapper', () => { it('maps a persistence row to an account snapshot explicitly', () => { expect(AccountPersistenceMapper.toSnapshot(row)).toEqual({ + commodityId: row.commodityId, createdAt: row.createdAt, - currency: row.currency, currentClearedBalanceLocal: row.currentClearedBalanceLocal, description: row.description, id: row.id, @@ -51,8 +51,8 @@ describe('AccountPersistenceMapper', () => { expect( AccountPersistenceMapper.toDBRowFromSnapshot(account.toSnapshot()), ).toEqual({ + commodityId: row.commodityId, createdAt: row.createdAt, - currency: row.currency, currentClearedBalanceLocal: row.currentClearedBalanceLocal, description: row.description, id: row.id, 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 f3db5a78..4d90127c 100644 --- a/apps/backend/src/infrastructure/db/accounts/account-persistence.mapper.ts +++ b/apps/backend/src/infrastructure/db/accounts/account-persistence.mapper.ts @@ -8,8 +8,8 @@ export class AccountPersistenceMapper { static toSnapshot(row: AccountDbRow): AccountSnapshot { return { + commodityId: row.commodityId, createdAt: row.createdAt, - currency: row.currency, currentClearedBalanceLocal: row.currentClearedBalanceLocal, description: row.description, id: row.id, @@ -25,8 +25,8 @@ export class AccountPersistenceMapper { static toDBRowFromSnapshot(snapshot: AccountSnapshot): AccountRepoInsert { return { + commodityId: snapshot.commodityId, createdAt: snapshot.createdAt, - currency: snapshot.currency, currentClearedBalanceLocal: snapshot.currentClearedBalanceLocal, description: snapshot.description, id: snapshot.id, diff --git a/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts b/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts index 2ceaadf1..d2cf1300 100644 --- a/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts +++ b/apps/backend/src/infrastructure/db/accounts/account.repository.test.ts @@ -1,18 +1,19 @@ -import { - apiErrorCodes, - AccountTypeValue, - CurrencyCode, - UUID, -} from '@ledgerly/shared/types'; +import { apiErrorCodes, AccountTypeValue, UUID } from '@ledgerly/shared/types'; import dayjs from 'dayjs'; -import { AccountDbInsert, AccountDbRow, UserDbRow } from 'src/db/schema'; -import { Amount, Timestamp } from 'src/domain/domain-core'; -import { Currency } from 'src/domain/domain-core/value-objects/Currency'; +import { eq } from 'drizzle-orm'; +import { + AccountDbInsert, + AccountDbRow, + CommodityDbRow, + UserDbRow, + accountsTable, +} from 'src/db/schema'; +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 { - ForeignKeyConstraintError, RecordAlreadyExistsError, + RepositoryInvariantError, RepositoryNotFoundError, } from 'src/infrastructure/errors'; import { describe, beforeEach, it, expect, vi } from 'vitest'; @@ -23,16 +24,14 @@ import { TransactionManager } from '../TransactionManager'; const firstUserAccounts = ['firstUserAccount1', 'firstUserAccount2']; const secondUserAccounts = ['secondUserAccount1', 'secondUserAccount2']; -const USD: CurrencyCode = 'USD' as CurrencyCode; - const getAccountData = (params: { userId: UUID; + commodityId: UUID; name: string; - currency: CurrencyCode; type: AccountTypeValue; }): AccountDbInsert => { return { - currency: params.currency, + commodityId: params.commodityId, currentClearedBalanceLocal: Amount.create('0').valueOf(), description: 'This is a test account', initialBalance: Amount.create('100').valueOf(), @@ -47,7 +46,7 @@ const getAccountData = (params: { }; const accountDataRaw = { - currency: USD, + commodityId: 'non-existent-commodity-id' as UUID, name: 'Test Account', type: 'asset' as const, userId: 'non-existent-user-id' as UUID, @@ -70,45 +69,92 @@ describe('AccountRepository', () => { ); let user: UserDbRow; + let usdCommodity: CommodityDbRow; + let eurCommodity: CommodityDbRow; beforeEach(async () => { testDB = new TestDB(); await testDB.setupTestDb(); user = await testDB.createUser(); + + usdCommodity = await testDB.createCommodity(user.id, { + code: CommodityCode.create('USD').valueOf(), + }); + + eurCommodity = await testDB.createCommodity(user.id, { + code: CommodityCode.create('EUR').valueOf(), + }); }); describe('create', () => { it('should create a new account successfully', async () => { const newAccount = getAccountData({ - currency: USD, + commodityId: usdCommodity.id, name: 'New Account', type: 'asset', userId: user.id, }); - const account = await accountRepository.create({ + const account = await accountRepository.create(user.id, { ...newAccount, }); expect(account).toHaveProperty('id'); expect(account.name).toBe(newAccount.name); - expect(account.currency).toBe(newAccount.currency); expect(account.type).toBe(newAccount.type); expect(account.userId).toBe(newAccount.userId); }); + it("should throw an error if 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', + }); + + const newAccount = getAccountData({ + commodityId: usdCommodity.id, + name: 'New Account', + type: 'asset', + userId: secondUser.id, + }); + + await expect( + accountRepository.create(secondUser.id, newAccount), + ).rejects.toThrowError(RepositoryNotFoundError); + }); + + it('should throw an invariant error when create userId differs from account snapshot userId', async () => { + const secondUser = await testDB.createUser({ + email: 'second-user@example.com', + name: 'Second User', + }); + + const newAccount = getAccountData({ + commodityId: usdCommodity.id, + name: 'New Account', + type: 'asset', + userId: secondUser.id, + }); + + await expect( + accountRepository.create(user.id, newAccount), + ).rejects.toThrowError(RepositoryInvariantError); + }); + it('should not allow duplicate account names for the same user', async () => { const newAccount = getAccountData({ - currency: USD, + commodityId: usdCommodity.id, name: 'New Account', type: 'asset', userId: user.id, }); - await testDB.createAccount(user.id, newAccount); + await testDB.createAccount(user.id, usdCommodity.id, newAccount); - await expect(accountRepository.create(newAccount)).rejects.toThrowError( + await expect( + accountRepository.create(user.id, newAccount), + ).rejects.toThrowError( new RecordAlreadyExistsError({ context: { field: 'accountName', @@ -128,25 +174,29 @@ describe('AccountRepository', () => { }); const firstUserAccount = getAccountData({ - currency: USD, + commodityId: usdCommodity.id, name: accountName, type: 'asset', userId: user.id, }); const secondUserAccount = getAccountData({ - currency: USD, + commodityId: usdCommodity.id, name: accountName, type: 'asset', userId: secondUser.id, }); - const account1 = await testDB.createAccount(user.id, { + const account1 = await testDB.createAccount(user.id, usdCommodity.id, { ...firstUserAccount, }); - const account2 = await testDB.createAccount(secondUser.id, { - ...secondUserAccount, - }); + const account2 = await testDB.createAccount( + secondUser.id, + usdCommodity.id, + { + ...secondUserAccount, + }, + ); expect(account1).toHaveProperty('id'); expect(account1.name).toBe(accountName); @@ -160,28 +210,26 @@ describe('AccountRepository', () => { expect(account1.userId).not.toBe(account2.userId); }); - it.todo('should throw an error if the original currency does not exist'); + it('should throw an error if commodity is tombstoned', async () => { + const tombstonedCommodity = await testDB.createCommodity(user.id, { + code: CommodityCode.create('TOMBSTONED').valueOf(), + isTombstone: true, + name: 'Tombstoned Commodity', + precision: 2, + symbol: 'T', + }); - it('should throw an error if the user does not exist', async () => { const newAccount = getAccountData({ - currency: USD, - name: 'New Account', + commodityId: tombstonedCommodity.id, + name: 'New Account with Tombstoned Commodity', type: 'asset', - userId: Id.create().valueOf(), + userId: user.id, }); - await expect(accountRepository.create(newAccount)).rejects.toThrowError( - new ForeignKeyConstraintError({ - context: { - field: 'userId', - tableName: 'accounts', - value: newAccount.userId, - }, - }), - ); + await expect( + accountRepository.create(user.id, newAccount), + ).rejects.toThrowError(RepositoryNotFoundError); }); - - it.todo('should handle UUID collision gracefully'); }); describe('getAll', () => { @@ -192,16 +240,14 @@ describe('AccountRepository', () => { }); for (const name of firstUserAccounts) { - await testDB.createAccount(user.id, { - currency: USD, + await testDB.createAccount(user.id, usdCommodity.id, { name, type: 'asset', }); } for (const name of secondUserAccounts) { - await testDB.createAccount(secondUser.id, { - currency: USD, + await testDB.createAccount(secondUser.id, usdCommodity.id, { name, type: 'asset', }); @@ -241,7 +287,7 @@ describe('AccountRepository', () => { let account: AccountDbRow; beforeEach(async () => { - account = await testDB.createAccount(user.id); + account = await testDB.createAccount(user.id, usdCommodity.id, {}); }); it('should retrieve an account by ID', async () => { @@ -297,14 +343,14 @@ describe('AccountRepository', () => { let account: AccountDbRow; beforeEach(async () => { - account = await testDB.createAccount(user.id); + account = await testDB.createAccount(user.id, usdCommodity.id, {}); }); it('should update account when it belongs to user', async () => { const user2 = await testDB.createUser(); const updatedAccountData = getAccountData({ - currency: 'EUR' as CurrencyCode, + commodityId: eurCommodity.id, name: 'Updated Account', type: 'expense', userId: user2.id, @@ -319,14 +365,13 @@ describe('AccountRepository', () => { expect(updatedAccount).toBeDefined(); expect(updatedAccount?.id).toBe(account.id); expect(updatedAccount?.name).toBe(updatedAccountData.name); - expect(updatedAccount?.currency).toBe(updatedAccountData.currency); expect(updatedAccount?.type).toBe(updatedAccountData.type); expect(updatedAccount?.userId).toBe(user.id); }); it('should return undefined when account belongs to different user', async () => { const updatedAccountData = getAccountData({ - currency: 'EUR' as CurrencyCode, + commodityId: eurCommodity.id, name: 'Updated Account', type: 'expense', userId: user.id, @@ -345,14 +390,13 @@ describe('AccountRepository', () => { it('should not allow updating to duplicate name within same user', async () => { const updatedAccountData = getAccountData({ - currency: USD, + commodityId: usdCommodity.id, name: 'Updated Account', type: 'asset', userId: user.id, }); - await testDB.createAccount(user.id, { - currency: USD, + await testDB.createAccount(user.id, usdCommodity.id, { initialBalance: Amount.create('200').valueOf(), name: updatedAccountData.name, type: 'asset', @@ -377,18 +421,20 @@ describe('AccountRepository', () => { name: 'Second User', }); - const secondUserAccount = await testDB.createAccount(secondUser.id, { - currency: USD, - initialBalance: Amount.create('200').valueOf(), - name: 'Shared Account Name', - type: 'asset', - }); + const secondUserAccount = await testDB.createAccount( + secondUser.id, + usdCommodity.id, + { + initialBalance: Amount.create('200').valueOf(), + name: 'Shared Account Name', + type: 'asset', + }, + ); const updatedSecondUserAccount = await accountRepository.update( secondUser.id, secondUserAccount.id, { - currency: USD, name: accountData.name, type: 'asset', updatedAt: Timestamp.create().valueOf(), @@ -400,12 +446,35 @@ describe('AccountRepository', () => { expect(updatedSecondUserAccount?.name).toBe(accountData.name); }); - it.todo('should validate currency when updating'); + it('should throw RepositoryNotFoundError when account is tombstoned', async () => { + const tombstonedAccount = await testDB.createAccount( + user.id, + usdCommodity.id, + { + name: 'Tombstoned Account', + type: 'asset', + }, + ); + + await testDB.db + .update(accountsTable) + .set({ isTombstone: true }) + .where(eq(accountsTable.id, tombstonedAccount.id)); + + await expect( + accountRepository.update(user.id, tombstonedAccount.id, { + name: 'Updated Tombstoned Account', + type: 'expense', + updatedAt: Timestamp.create().valueOf(), + }), + ).rejects.toThrowError(RepositoryNotFoundError); + }); + + it.todo('should preserve commodityId when updating account fields'); it('should only update allowed fields', async () => { const maliciousData = { createdAt: Timestamp.create().valueOf(), - currency: Currency.create('EUR').valueOf(), id: 'malicious-id', initialBalance: Amount.create('2000').valueOf(), name: 'Updated Account', @@ -422,24 +491,23 @@ describe('AccountRepository', () => { expect(result?.id).toBe(account.id); expect(result?.userId).toBe(user.id); expect(result?.name).toBe(maliciousData.name); - expect(result?.currency).toBe(maliciousData.currency); }); }); - describe('delete', () => { + describe('softDelete', () => { let account: AccountDbRow; beforeEach(async () => { - account = await testDB.createAccount(user.id); + account = await testDB.createAccount(user.id, usdCommodity.id); }); - it('should delete account when it exists and belongs to user', async () => { + it('should soft delete account when it exists and belongs to user', async () => { const deletedData = { isTombstone: true, updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), }; - const deleted = await accountRepository.delete( + const deleted = await accountRepository.softDelete( user.id, account.id, deletedData, @@ -467,15 +535,14 @@ describe('AccountRepository', () => { name: 'Second User', }); - await testDB.createAccount(secondUser.id, { - currency: USD, + await testDB.createAccount(secondUser.id, usdCommodity.id, { initialBalance: Amount.create('2000').valueOf(), name: 'Shared Account Name', type: 'asset', }); await expect( - accountRepository.delete(secondUser.id, account.id, { + accountRepository.softDelete(secondUser.id, account.id, { updatedAt: Timestamp.create().valueOf(), }), ).rejects.toThrowError(RepositoryNotFoundError); @@ -486,12 +553,28 @@ describe('AccountRepository', () => { }); it('should throw RepositoryNotFoundError when account does not exist', async () => { - const result = accountRepository.delete(user.id, Id.create().valueOf(), { - updatedAt: Timestamp.create().valueOf(), - }); + const result = accountRepository.softDelete( + user.id, + Id.create().valueOf(), + { + updatedAt: Timestamp.create().valueOf(), + }, + ); await expect(result).rejects.toThrowError(RepositoryNotFoundError); }); + + it('should throw RepositoryNotFoundError when account is already tombstoned', async () => { + await accountRepository.softDelete(user.id, account.id, { + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }); + + await expect( + accountRepository.softDelete(user.id, account.id, { + updatedAt: Timestamp.restore('2030-01-02T00:00:00.000Z').valueOf(), + }), + ).rejects.toThrowError(RepositoryNotFoundError); + }); }); describe('timestamp behavior', () => { @@ -499,12 +582,12 @@ describe('AccountRepository', () => { const beforeCreate = dayjs(); const newAccount = getAccountData({ - currency: 'USD' as CurrencyCode, + commodityId: usdCommodity.id, name: 'This is a test account', type: 'asset', userId: user.id, }); - const account = await accountRepository.create(newAccount); + const account = await accountRepository.create(user.id, newAccount); const accountDate = dayjs(account.createdAt); const afterCreate = dayjs(); @@ -519,13 +602,13 @@ describe('AccountRepository', () => { }); it('should update updatedAt on account update', async () => { - const account = await testDB.createAccount(user.id); + const account = await testDB.createAccount(user.id, usdCommodity.id); const originalUpdatedAt = dayjs(account?.updatedAt); await new Promise((resolve) => setTimeout(resolve, 1000)); const updatedData = getAccountData({ - currency: 'EUR' as CurrencyCode, + commodityId: eurCommodity.id, name: 'Updated Account', type: 'expense', userId: user.id, @@ -544,36 +627,4 @@ describe('AccountRepository', () => { expect(originalUpdatedAt.unix()).toBeLessThanOrEqual(newUpdatedAt.unix()); }); }); - - describe('findSystemAccount', () => { - it('should retrieve system account by currency for a user', async () => { - const systemAccountData = getAccountData({ - currency: USD, - name: 'System Account for USD', - type: 'asset', - userId: user.id, - }); - - const systemAccount = await testDB.createAccount(user.id, { - ...systemAccountData, - isSystem: true, - }); - - const retrievedAccount = await accountRepository.findSystemAccount( - user.id, - USD, - ); - - expect(retrievedAccount).toBeDefined(); - expect(retrievedAccount.id).toBe(systemAccount.id); - expect(retrievedAccount.currency).toBe(USD); - expect(retrievedAccount.isSystem).toBe(true); - }); - - it('should throw RepositoryNotFoundError if system account does not exist for user and currency', async () => { - await expect( - accountRepository.findSystemAccount(user.id, USD), - ).rejects.toThrowError(RepositoryNotFoundError); - }); - }); }); diff --git a/apps/backend/src/infrastructure/db/accounts/account.repository.ts b/apps/backend/src/infrastructure/db/accounts/account.repository.ts index 1f171dab..c55f1e46 100644 --- a/apps/backend/src/infrastructure/db/accounts/account.repository.ts +++ b/apps/backend/src/infrastructure/db/accounts/account.repository.ts @@ -1,4 +1,4 @@ -import { CurrencyCode, UUID } from '@ledgerly/shared/types'; +import { UUID } from '@ledgerly/shared/types'; import { and, eq, inArray } from 'drizzle-orm'; import { type AccountRepositoryInterface, @@ -6,7 +6,9 @@ import { type AccountRepositoryUpdateInput, } 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 { BaseRepository } from '../BaseRepository'; @@ -35,9 +37,33 @@ export class AccountRepository }, 'Failed to fetch accounts'); } - create(data: AccountSnapshot): Promise { + create(userId: UUID, data: AccountSnapshot): Promise { return this.executeDatabaseOperation( async () => { + if (data.userId !== userId) { + throw new RepositoryInvariantError( + 'Account snapshot userId must match repository create userId', + ); + } + + const existingCommodity = await this.db + .select() + .from(commoditiesTable) + .where( + and( + eq(commoditiesTable.id, data.commodityId), + eq(commoditiesTable.userId, data.userId), + eq(commoditiesTable.isTombstone, false), + ), + ) + .get(); + + this.ensureEntityExists( + existingCommodity, + `Commodity with ID ${data.commodityId} not found`, + this.entityNotFoundContext('commodity', data.commodityId), + ); + const account = await this.db .insert(accountsTable) .values({ @@ -100,7 +126,6 @@ export class AccountRepository 'description', 'initialBalance', 'name', - 'currency', 'type', 'updatedAt', ]); @@ -109,7 +134,11 @@ export class AccountRepository .update(accountsTable) .set(safeData) .where( - and(eq(accountsTable.id, id), eq(accountsTable.userId, userId)), + and( + eq(accountsTable.id, id), + eq(accountsTable.userId, userId), + eq(accountsTable.isTombstone, false), + ), ) .returning() .get(); @@ -131,7 +160,7 @@ export class AccountRepository ); } - async delete( + async softDelete( userId: UUID, id: UUID, data: AccountRepositorySoftDeleteInput, @@ -140,46 +169,24 @@ export class AccountRepository const deletedAccount = await this.db .update(accountsTable) .set({ isTombstone: true, updatedAt: data.updatedAt }) - .where(and(eq(accountsTable.id, id), eq(accountsTable.userId, userId))) - .returning() - .get(); - - const existingAccount = this.ensureEntityExists( - deletedAccount, - `Account with ID ${id} not found`, - this.entityNotFoundContext('account', id), - ); - - return AccountPersistenceMapper.toSnapshot(existingAccount); - }, `Failed to delete account with ID ${id}`); - } - - async findSystemAccount( - userId: UUID, - currency: CurrencyCode, - ): Promise { - return this.executeDatabaseOperation(async () => { - const account = await this.db - .select() - .from(accountsTable) .where( and( + eq(accountsTable.id, id), eq(accountsTable.userId, userId), - eq(accountsTable.currency, currency), - eq(accountsTable.isSystem, true), eq(accountsTable.isTombstone, false), ), ) + .returning() .get(); const existingAccount = this.ensureEntityExists( - account, - `System account not found for currency: ${currency}`, - this.entityNotFoundContext('account'), + deletedAccount, + `Account with ID ${id} not found`, + this.entityNotFoundContext('account', id), ); return AccountPersistenceMapper.toSnapshot(existingAccount); - }, 'Failed to fetch system account'); + }, `Failed to soft delete account with ID ${id}`); } async ensureUserOwnsAccount(userId: UUID, accountId: UUID) { 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 new file mode 100644 index 00000000..7c18c909 --- /dev/null +++ b/apps/backend/src/infrastructure/db/commodities/commodity-persistence.mapper.test.ts @@ -0,0 +1,61 @@ +import { CommodityDbRow } from 'src/db/schema'; +import { CommodityCode } from 'src/domain/domain-core'; +import { Id } from 'src/domain/domain-core/value-objects/Id'; +import { Timestamp } from 'src/domain/domain-core/value-objects/Timestamp'; +import { describe, expect, it } from 'vitest'; + +import { CommodityPersistenceMapper } from './commodity-persistence.mapper'; + +describe('CommodityPersistenceMapper', () => { + const row: CommodityDbRow = { + code: CommodityCode.create('USD').valueOf(), + createdAt: Timestamp.create().valueOf(), + id: Id.create().valueOf(), + isTombstone: false, + name: 'US Dollar', + precision: 2, + symbol: '$', + updatedAt: Timestamp.create().valueOf(), + userId: Id.create().valueOf(), + }; + + it('maps a persistence row to a commodity snapshot explicitly', () => { + expect(CommodityPersistenceMapper.toSnapshot(row)).toEqual({ + code: row.code, + createdAt: row.createdAt, + id: row.id, + isTombstone: row.isTombstone, + name: row.name, + precision: row.precision, + symbol: row.symbol, + updatedAt: row.updatedAt, + userId: row.userId, + }); + }); + + it('maps a persistence row to a domain commodity snapshot', () => { + const commodity = CommodityPersistenceMapper.toDomain(row); + + expect(commodity.toSnapshot()).toEqual( + CommodityPersistenceMapper.toSnapshot(row), + ); + }); + + it('maps a domain commodity snapshot to a persistence row', () => { + const commodity = CommodityPersistenceMapper.toDomain(row); + + expect( + CommodityPersistenceMapper.toDBRowFromSnapshot(commodity.toSnapshot()), + ).toEqual({ + code: row.code, + createdAt: row.createdAt, + id: row.id, + isTombstone: row.isTombstone, + name: row.name, + precision: row.precision, + symbol: row.symbol, + updatedAt: row.updatedAt, + userId: row.userId, + }); + }); +}); diff --git a/apps/backend/src/infrastructure/db/commodities/commodity-persistence.mapper.ts b/apps/backend/src/infrastructure/db/commodities/commodity-persistence.mapper.ts new file mode 100644 index 00000000..21914b91 --- /dev/null +++ b/apps/backend/src/infrastructure/db/commodities/commodity-persistence.mapper.ts @@ -0,0 +1,37 @@ +import { CommodityDbInsert, CommodityDbRow } from 'src/db/schema'; +import { Commodity } from 'src/domain'; +import { CommoditySnapshot } from 'src/domain/commodities/types'; + +export class CommodityPersistenceMapper { + static toDomain(row: CommodityDbRow): Commodity { + return Commodity.restore(CommodityPersistenceMapper.toSnapshot(row)); + } + + static toSnapshot(row: CommodityDbRow): CommoditySnapshot { + return { + code: row.code, + createdAt: row.createdAt, + id: row.id, + isTombstone: row.isTombstone, + name: row.name, + precision: row.precision, + symbol: row.symbol, + updatedAt: row.updatedAt, + userId: row.userId, + }; + } + + static toDBRowFromSnapshot(snapshot: CommoditySnapshot): CommodityDbInsert { + return { + code: snapshot.code, + createdAt: snapshot.createdAt, + id: snapshot.id, + isTombstone: snapshot.isTombstone, + name: snapshot.name, + precision: snapshot.precision, + symbol: snapshot.symbol, + updatedAt: snapshot.updatedAt, + userId: snapshot.userId, + }; + } +} diff --git a/apps/backend/src/infrastructure/db/commodities/commodity.repository.test.ts b/apps/backend/src/infrastructure/db/commodities/commodity.repository.test.ts new file mode 100644 index 00000000..fd867075 --- /dev/null +++ b/apps/backend/src/infrastructure/db/commodities/commodity.repository.test.ts @@ -0,0 +1,444 @@ +import { apiErrorCodes, type QueryStatus } from '@ledgerly/shared/types'; +import { type CommodityRepositoryUpdateInput } from 'src/application'; +import type { CommodityDbRow } from 'src/db/schemas/commodities'; +import { UserDbRow } from 'src/db/schemas/users'; +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, + RepositoryNotFoundError, +} from 'src/infrastructure/errors'; +import { describe, vi, beforeEach, it, expect } from 'vitest'; + +import { TestDB } from '../../../db/test-db'; +import { TransactionManager } from '../TransactionManager'; + +import { CommodityRepository } from './commodity.repository'; + +describe('CommodityRepository', () => { + let testDB: TestDB; + + const transactionManager = { + getCurrentTransaction: () => testDB.db, + run: vi.fn((cb: () => unknown) => { + return cb(); + }), + }; + + const commodityRepository = new CommodityRepository( + transactionManager as unknown as TransactionManager, + ); + + let user: UserDbRow; + + beforeEach(async () => { + testDB = new TestDB(); + await testDB.setupTestDb(); + + user = await testDB.createUser(); + }); + + describe('getById', () => { + let commodityDbRow: CommodityDbRow; + + beforeEach(async () => { + commodityDbRow = await testDB.createCommodity(user.id); + }); + + it('should return the commodity when it exists', async () => { + const retrievedCommodity = await commodityRepository.getById( + user.id, + commodityDbRow.id, + ); + + expect(retrievedCommodity).toEqual(commodityDbRow); + }); + + it('should throw an error when the commodity does not exist', async () => { + const nonExistentId = Id.create().valueOf(); + + await expect( + commodityRepository.getById(user.id, nonExistentId), + ).rejects.toThrowError(RepositoryNotFoundError); + }); + + it('returns an allowlisted error contract for a missing commodity', async () => { + const nonExistentId = Id.create().valueOf(); + + await expect( + commodityRepository.getById(user.id, nonExistentId), + ).rejects.toMatchObject({ + code: apiErrorCodes.entityNotFound, + context: { entityId: nonExistentId, entityType: 'commodity' }, + }); + }); + + it('should throw an error when the commodity belongs to a different user', async () => { + const anotherUser = await testDB.createUser(); + + await expect( + commodityRepository.getById(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' }, + ]; + + 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 archivedCommodity = await testDB.createCommodity(user.id, { + isTombstone: true, + }); + + const commodities = await commodityRepository.getAll(user.id, status); + + const expectedCommodities = + status === 'active' + ? activeCommodities + : status === 'archived' + ? [archivedCommodity] + : [...activeCommodities, archivedCommodity]; + + expect(commodities).toEqual( + expect.arrayContaining(expectedCommodities), + ); + + expect(commodities).toHaveLength(expectedCommodities.length); + + expect( + commodities.map(({ isTombstone }) => isTombstone).sort(), + ).toEqual(expectedTombstoneValues.sort()); + }, + ); + + 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', + ); + + 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'); + + expect(commodities).toEqual(expect.arrayContaining([commodity2])); + expect(commodities).not.toEqual(expect.arrayContaining([commodity1])); + }); + + it.each(['active', 'archived', 'all'])( + 'should not return commodities belonging to other users for %s status', + async (status) => { + const commodity1 = await testDB.createCommodity(user.id); + const anotherUser = await testDB.createUser(); + const commodity2 = await testDB.createCommodity(anotherUser.id, { + isTombstone: status === 'archived', + }); + + if (status === 'archived') { + await commodityRepository.softDelete(user.id, commodity1.id, { + updatedAt: Timestamp.create().valueOf(), + }); + } + + const commodities = await commodityRepository.getAll(user.id, status); + + expect(commodities).not.toEqual(expect.arrayContaining([commodity2])); + }, + ); + }); + + describe('create', () => { + it('should create a new commodity for the user', async () => { + const newCommodityData: CommoditySnapshot = { + code: CommodityCode.create('NEW').valueOf(), + createdAt: Timestamp.create().valueOf(), + id: Id.create().valueOf(), + isTombstone: false, + name: Name.create('New Commodity').valueOf(), + precision: 2, + symbol: 'N', + updatedAt: Timestamp.create().valueOf(), + userId: user.id, + }; + + const createdCommodity = await commodityRepository.create( + user.id, + newCommodityData, + ); + + expect(createdCommodity).toMatchObject(newCommodityData); + + const retrievedCommodity = await commodityRepository.getById( + user.id, + createdCommodity.id, + ); + + expect(retrievedCommodity).toEqual(createdCommodity); + }); + + it('should throw an error when trying to create a commodity with an existing CommodityCode', async () => { + const createdCommodity = await testDB.createCommodity(user.id); + + const newCommodityData: CommoditySnapshot = { + code: createdCommodity.code, + createdAt: Timestamp.create().valueOf(), + id: Id.create().valueOf(), + isTombstone: false, + name: Name.create('Duplicate Commodity').valueOf(), + precision: 2, + symbol: 'D', + updatedAt: Timestamp.create().valueOf(), + userId: user.id, + }; + + await expect( + commodityRepository.create(user.id, newCommodityData), + ).rejects.toThrowError( + new RecordAlreadyExistsError({ + context: { + field: 'code', + tableName: 'commodities', + value: newCommodityData.code, + }, + }), + ); + }); + + it('should allow creating commodities with the same CommodityCode for different users', async () => { + const createdCommodity = await testDB.createCommodity(user.id); + + const anotherUser = await testDB.createUser(); + + const newCommodityData: CommoditySnapshot = { + code: createdCommodity.code, + createdAt: Timestamp.create().valueOf(), + id: Id.create().valueOf(), + isTombstone: false, + name: Name.create('Same Code Commodity').valueOf(), + precision: 2, + symbol: 'S', + updatedAt: Timestamp.create().valueOf(), + userId: anotherUser.id, + }; + + const createdCommodityForAnotherUser = await commodityRepository.create( + anotherUser.id, + newCommodityData, + ); + + expect(createdCommodityForAnotherUser).toMatchObject(newCommodityData); + + const retrievedCommodity = await commodityRepository.getById( + anotherUser.id, + createdCommodityForAnotherUser.id, + ); + + expect(retrievedCommodity).toEqual(createdCommodityForAnotherUser); + }); + }); + + describe('update', () => { + let commodityDbRow: CommodityDbRow; + + beforeEach(async () => { + commodityDbRow = await testDB.createCommodity(user.id); + }); + + it('should update the commodity when it exists', async () => { + const updatedData: Partial = { + code: CommodityCode.create('UPD').valueOf(), + name: Name.create('Updated Commodity').valueOf(), + 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); + + const retrievedCommodity = await commodityRepository.getById( + user.id, + commodityDbRow.id, + ); + + expect(retrievedCommodity).toEqual(updatedCommodity); + }); + + it('should throw an error when the commodity does not exist', async () => { + const nonExistentId = Id.create().valueOf(); + + const updatedData: CommodityRepositoryUpdateInput = { + code: CommodityCode.create('UPD').valueOf(), + name: Name.create('Updated Commodity').valueOf(), + updatedAt: Timestamp.create().valueOf(), + }; + + await expect( + commodityRepository.update(user.id, nonExistentId, updatedData), + ).rejects.toThrowError(RepositoryNotFoundError); + }); + + it('should partially update only the provided fields', async () => { + const updatedData: CommodityRepositoryUpdateInput = { + name: Name.create('Renamed Commodity').valueOf(), + updatedAt: Timestamp.create().valueOf(), + }; + + const updatedCommodity = await commodityRepository.update( + user.id, + commodityDbRow.id, + updatedData, + ); + + expect(updatedCommodity).toMatchObject({ + code: commodityDbRow.code, + id: commodityDbRow.id, + name: updatedData.name, + precision: commodityDbRow.precision, + symbol: commodityDbRow.symbol, + updatedAt: updatedData.updatedAt, + userId: commodityDbRow.userId, + }); + }); + + it('should throw an error when trying to update a commodity with an existing CommodityCode', async () => { + const existingCommodity = await testDB.createCommodity(user.id); + + const updatedData: CommodityRepositoryUpdateInput = { + code: existingCommodity.code, + name: Name.create('Updated Commodity').valueOf(), + updatedAt: Timestamp.create().valueOf(), + }; + + await expect( + commodityRepository.update(user.id, commodityDbRow.id, updatedData), + ).rejects.toThrowError( + new RecordAlreadyExistsError({ + context: { + field: 'code', + tableName: 'commodities', + value: updatedData.code, + }, + }), + ); + }); + + it('should throw an error when the commodity belongs to a different user', async () => { + const anotherUser = await testDB.createUser(); + + const updatedData: CommodityRepositoryUpdateInput = { + code: CommodityCode.create('UPD').valueOf(), + name: Name.create('Updated Commodity').valueOf(), + updatedAt: Timestamp.create().valueOf(), + }; + + await expect( + commodityRepository.update( + anotherUser.id, + commodityDbRow.id, + updatedData, + ), + ).rejects.toThrowError(RepositoryNotFoundError); + }); + + it('should throw an error when trying to update tombstoned commodity', async () => { + await commodityRepository.softDelete(user.id, commodityDbRow.id, { + updatedAt: Timestamp.create().valueOf(), + }); + + const updatedData: CommodityRepositoryUpdateInput = { + code: CommodityCode.create('UPD').valueOf(), + name: Name.create('Updated Commodity').valueOf(), + updatedAt: Timestamp.create().valueOf(), + }; + + await expect( + commodityRepository.update(user.id, commodityDbRow.id, updatedData), + ).rejects.toThrowError(RepositoryNotFoundError); + }); + }); + + describe('softDelete', () => { + 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, { + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }); + + const archivedCommodity = await testDB.getCommodityById( + commodityDbRow.id, + ); + + expect(archivedCommodity).toBeDefined(); + expect(archivedCommodity?.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, { + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }), + ).rejects.toThrowError(RepositoryNotFoundError); + }); + + it('should throw an error when the commodity belongs to a different user', async () => { + const anotherUser = await testDB.createUser(); + + await expect( + commodityRepository.softDelete(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, { + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }); + + await expect( + commodityRepository.softDelete(user.id, commodityDbRow.id, { + updatedAt: Timestamp.restore('2030-01-01T00:00:00.000Z').valueOf(), + }), + ).rejects.toThrowError(RepositoryNotFoundError); + }); + }); +}); diff --git a/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts b/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts new file mode 100644 index 00000000..5587fe5d --- /dev/null +++ b/apps/backend/src/infrastructure/db/commodities/commodity.repository.ts @@ -0,0 +1,170 @@ +import { QueryStatus, UUID } from '@ledgerly/shared/types'; +import { and, eq } from 'drizzle-orm'; +import { + type CommodityRepositoryInterface, + type CommodityRepositoryUpdateInput, + type CommodityRepositorySoftDeleteInput, +} from 'src/application'; +import { commoditiesTable } from 'src/db/schemas/commodities'; +import { CommoditySnapshot } from 'src/domain/commodities/types'; +import { RepositoryInvariantError } from 'src/infrastructure/errors'; + +import { BaseRepository } from '../BaseRepository'; + +import { CommodityPersistenceMapper } from './commodity-persistence.mapper'; + +export class CommodityRepository + extends BaseRepository + implements CommodityRepositoryInterface +{ + getById(userId: UUID, id: UUID): Promise { + return this.executeDatabaseOperation(async () => { + const commodity = await this.db + .select() + .from(commoditiesTable) + .where( + and(eq(commoditiesTable.userId, userId), eq(commoditiesTable.id, id)), + ) + .get(); + + const existingCommodity = this.ensureEntityExists( + commodity, + `Commodity with ID ${id} not found`, + this.entityNotFoundContext('commodity', id), + ); + + return CommodityPersistenceMapper.toSnapshot(existingCommodity); + }, 'Failed to fetch commodity'); + } + + getAll(userId: UUID, status: QueryStatus): Promise { + return this.executeDatabaseOperation(async () => { + const whereClause = + status === 'all' + ? eq(commoditiesTable.userId, userId) + : and( + eq(commoditiesTable.userId, userId), + eq(commoditiesTable.isTombstone, status === 'archived'), + ); + + const commodities = await this.db + .select() + .from(commoditiesTable) + .where(whereClause) + .all(); + + return commodities.map((commodity) => + CommodityPersistenceMapper.toSnapshot(commodity), + ); + }, 'Failed to fetch commodities'); + } + + create( + userId: UUID, + commodity: CommoditySnapshot, + ): Promise { + return this.executeDatabaseOperation( + async () => { + if (commodity.userId !== userId) { + throw new RepositoryInvariantError( + 'Commodity snapshot userId must match repository create userId', + ); + } + + const commodityRow = + CommodityPersistenceMapper.toDBRowFromSnapshot(commodity); + + const createdCommodity = await this.db + .insert(commoditiesTable) + .values(commodityRow) + .returning() + .get(); + + return CommodityPersistenceMapper.toSnapshot(createdCommodity); + }, + 'Failed to create commodity', + { + unique: { + field: 'code', + tableName: 'commodities', + value: commodity.code, + }, + }, + ); + } + + update( + userId: UUID, + commodityId: UUID, + commodity: CommodityRepositoryUpdateInput, + ): Promise { + return this.executeDatabaseOperation( + async () => { + const safeData = this.getSafeUpdate(commodity, [ + 'code', + 'symbol', + 'name', + 'updatedAt', + ]); + + const updatedCommodity = await this.db + .update(commoditiesTable) + .set(safeData) + .where( + and( + eq(commoditiesTable.userId, userId), + 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), + ); + + return CommodityPersistenceMapper.toSnapshot(existingCommodity); + }, + 'Failed to update commodity', + { + unique: { + field: 'code', + tableName: 'commodities', + value: commodity.code, + }, + }, + ); + } + + softDelete( + userId: UUID, + commodityId: UUID, + data: CommodityRepositorySoftDeleteInput, + ): Promise { + return this.executeDatabaseOperation(async () => { + const deletedCommodity = 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), + ), + ) + .returning() + .get(); + + const existingCommodity = this.ensureEntityExists( + deletedCommodity, + `Commodity with ID ${commodityId} not found after deletion`, + this.entityNotFoundContext('commodity', commodityId), + ); + + return CommodityPersistenceMapper.toSnapshot(existingCommodity); + }, `Failed to soft delete commodity with ID ${commodityId}`); + } +} diff --git a/apps/backend/src/infrastructure/db/commodities/index.ts b/apps/backend/src/infrastructure/db/commodities/index.ts new file mode 100644 index 00000000..872136e3 --- /dev/null +++ b/apps/backend/src/infrastructure/db/commodities/index.ts @@ -0,0 +1,2 @@ +export { CommodityPersistenceMapper } from './commodity-persistence.mapper'; +export { CommodityRepository } from './commodity.repository'; diff --git a/apps/backend/src/infrastructure/db/currency/currency.repository.ts b/apps/backend/src/infrastructure/db/currency/currency.repository.ts deleted file mode 100644 index 1735e4d8..00000000 --- a/apps/backend/src/infrastructure/db/currency/currency.repository.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { CurrencyResponseDTO } from 'src/application'; -import { currenciesTable } from 'src/db/schema'; - -import { BaseRepository } from '../BaseRepository'; - -export class CurrencyRepository extends BaseRepository { - getAll(): Promise { - return this.executeDatabaseOperation( - () => this.db.select().from(currenciesTable).all(), - 'Failed to fetch currencies', - ); - } -} diff --git a/apps/backend/src/infrastructure/db/currency/index.ts b/apps/backend/src/infrastructure/db/currency/index.ts deleted file mode 100644 index e3212852..00000000 --- a/apps/backend/src/infrastructure/db/currency/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CurrencyRepository } from './currency.repository'; diff --git a/apps/backend/src/infrastructure/db/index.ts b/apps/backend/src/infrastructure/db/index.ts index a4433289..03babd8c 100644 --- a/apps/backend/src/infrastructure/db/index.ts +++ b/apps/backend/src/infrastructure/db/index.ts @@ -6,5 +6,5 @@ export { } from './transaction/'; export { OperationRepository } from './operations'; export { AccountRepository } from './accounts'; -export { CurrencyRepository } from './currency'; export { UserRepository } from './user'; +export { CommodityRepository } from './commodities'; diff --git a/apps/backend/src/infrastructure/db/operations/operation-persistence.mapper.test.ts b/apps/backend/src/infrastructure/db/operations/operation-persistence.mapper.test.ts index bc36b4f5..6128e08c 100644 --- a/apps/backend/src/infrastructure/db/operations/operation-persistence.mapper.test.ts +++ b/apps/backend/src/infrastructure/db/operations/operation-persistence.mapper.test.ts @@ -14,7 +14,7 @@ describe('OperationPersistenceMapper', () => { it('maps an operation snapshot to a persistence row', () => { const { operations } = TransactionBuilder.transaction({ - accounts: ['USD'], + currencies: ['USD'], operations: [ { accountKey: 'USD', amount: '100', description: 'Debit' }, { accountKey: 'USD', amount: '-100', description: 'Credit' }, 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 4e41e90c..1a4b55f2 100644 --- a/apps/backend/src/infrastructure/db/operations/operation.repository.test.ts +++ b/apps/backend/src/infrastructure/db/operations/operation.repository.test.ts @@ -13,6 +13,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TestDB } from '../../../db/test-db'; import { AccountPersistenceMapper } from '../accounts'; +import { CommodityPersistenceMapper } from '../commodities'; import { TransactionManager } from '../TransactionManager'; import { UserPersistenceMapper } from '../user'; @@ -21,7 +22,7 @@ import { OperationRepository } from './operation.repository'; describe('OperationRepository', () => { let testDB: TestDB; - let user: UserDbRow; + let userSnapshot: UserDbRow; let transaction: Transaction; @@ -41,7 +42,7 @@ describe('OperationRepository', () => { beforeEach(async () => { testDB = new TestDB(); await testDB.setupTestDb(); - user = await testDB.createUser(); + userSnapshot = await testDB.createUser(); const operationsData = [ { accountKey: 'USD', amount: '10000', description: '1' }, @@ -53,13 +54,23 @@ describe('OperationRepository', () => { ]; data = TransactionBuilder.transaction({ - accounts: ['USD', 'EUR'], + currencies: ['USD', 'EUR'], operations: operationsData, - user: UserPersistenceMapper.toDomain(user), + user: UserPersistenceMapper.toDomain(userSnapshot), }); transaction = data.transaction; + await Promise.all( + data.commodities.map((commodity) => + testDB.insertCommodity( + CommodityPersistenceMapper.toDBRowFromSnapshot( + commodity.toSnapshot(), + ), + ), + ), + ); + await Promise.all( data.accounts.map((account) => testDB.insertAccount( @@ -88,7 +99,7 @@ describe('OperationRepository', () => { OperationPersistenceMapper.toDBRow(operation), ); - await operationRepository.save(user.id, operations, new Map()); + await operationRepository.save(userSnapshot.id, operations, new Map()); const fetchedTransactionRelationsAfterSaving = await testDB.getTransactionWithRelations(transaction.getId().valueOf()); @@ -168,7 +179,7 @@ describe('OperationRepository', () => { ]; await operationRepository.save( - user.id, + userSnapshot.id, [...operationsToUpdateData, ...operationsToDeleteData], operationsSnapshot, ); @@ -296,7 +307,7 @@ describe('OperationRepository', () => { try { await operationRepository.save( - user.id, + userSnapshot.id, operationsToDeleteData, operationsSnapshot, ); @@ -355,7 +366,7 @@ describe('OperationRepository', () => { const operationToDelete = operations[0]; await operationRepository.save( - user.id, + userSnapshot.id, [ { ...operationToDelete, @@ -380,7 +391,7 @@ describe('OperationRepository', () => { await expect( operationRepository.save( - user.id, + userSnapshot.id, [ { ...operationToDelete, diff --git a/apps/backend/src/infrastructure/db/transaction/transaction-persistence.mapper.test.ts b/apps/backend/src/infrastructure/db/transaction/transaction-persistence.mapper.test.ts index 502e2fe8..0d39caa0 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction-persistence.mapper.test.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction-persistence.mapper.test.ts @@ -7,8 +7,9 @@ import { TransactionPersistenceMapper } from './transaction-persistence.mapper'; describe('TransactionPersistenceMapper', () => { it('maps a transaction aggregate to a persistence row', async () => { const user = await createUser(); + const { transaction } = TransactionBuilder.transaction({ - accounts: ['USD'], + currencies: ['USD'], operations: [ { accountKey: 'USD', amount: '100', description: 'Debit' }, { accountKey: 'USD', amount: '-100', description: 'Credit' }, @@ -18,8 +19,8 @@ describe('TransactionPersistenceMapper', () => { const snapshot = transaction.toSnapshot(); expect(TransactionPersistenceMapper.toDBRow(transaction)).toEqual({ + commodityId: snapshot.commodityId, createdAt: snapshot.createdAt, - currency: snapshot.currency, description: snapshot.description, id: snapshot.id, isTombstone: snapshot.isTombstone, diff --git a/apps/backend/src/infrastructure/db/transaction/transaction-persistence.mapper.ts b/apps/backend/src/infrastructure/db/transaction/transaction-persistence.mapper.ts index ec49b0f6..322b1c9d 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction-persistence.mapper.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction-persistence.mapper.ts @@ -6,8 +6,8 @@ export class TransactionPersistenceMapper { const snapshot = transaction.toSnapshot(); return { + commodityId: snapshot.commodityId, createdAt: snapshot.createdAt, - currency: snapshot.currency, description: snapshot.description, id: snapshot.id, isTombstone: snapshot.isTombstone, diff --git a/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.test.ts b/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.test.ts index 6c9558d6..3ae96822 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.test.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction-query.repository.test.ts @@ -1,20 +1,22 @@ import { DEFAULT_TRANSACTION_QUERY } from '@ledgerly/shared/constants'; -import { CurrencyCode, IsoDateString, UUID } from '@ledgerly/shared/types'; +import { IsoDateString, UUID } from '@ledgerly/shared/types'; import { UserDbRow } from 'src/db/schema'; import { TestDB, TransactionSeed } from 'src/db/test-db'; +import { CommodityCode, Name } from 'src/domain/domain-core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TransactionManager, TransactionQueryRepository } from '../'; type TestAccount = { id: UUID; - currency: CurrencyCode; }; describe('TransactionQueryRepository', () => { let testDB: TestDB; let transactionQueryRepo: TransactionQueryRepository; let user: UserDbRow; + let valuationCommodityId: UUID; + let usdAccount: TestAccount; let eurAccount: TestAccount; @@ -26,22 +28,20 @@ describe('TransactionQueryRepository', () => { }; const createAccount = async ( - currency: CurrencyCode, name: string, + commodityId: UUID, ): Promise => { - const account = await testDB.createAccount(user.id, { - currency, + const account = await testDB.createAccount(user.id, commodityId, { name, }); return { - currency: account.currency, id: account.id, }; }; const createTransaction = (seed: TransactionSeed) => - testDB.createTransactionFromSeed(user.id, seed); + testDB.createTransactionFromSeed(user.id, valuationCommodityId, seed); const expectTransactionToMatchSeed = ( transaction: Awaited>, @@ -53,7 +53,6 @@ describe('TransactionQueryRepository', () => { return; } - expect(transaction.currency).toBe(seed.currencyCode ?? 'USD'); expect(transaction.description).toBe(seed.description); expect(transaction.postingDate).toBe(seed.postingDate ?? '2023-01-01'); expect(transaction.transactionDate).toBe( @@ -80,8 +79,33 @@ describe('TransactionQueryRepository', () => { await testDB.setupTestDb(); user = await testDB.createUser(); - usdAccount = await createAccount('USD' as CurrencyCode, 'Cash USD'); - eurAccount = await createAccount('EUR' as CurrencyCode, 'Cash EUR'); + + const commodity = await testDB.createCommodity(user.id, { + code: CommodityCode.create('RUB').valueOf(), + }); + + valuationCommodityId = commodity.id; + + const usdCommodityId = ( + await testDB.createCommodity(user.id, { + code: CommodityCode.create('USD').valueOf(), + }) + ).id; + + const eurCommodityId = ( + await testDB.createCommodity(user.id, { + code: CommodityCode.create('EUR').valueOf(), + }) + ).id; + + usdAccount = await createAccount( + Name.create('USD').valueOf(), + usdCommodityId, + ); + eurAccount = await createAccount( + Name.create('EUR').valueOf(), + eurCommodityId, + ); transactionQueryRepo = new TransactionQueryRepository( transactionManager as unknown as TransactionManager, @@ -124,7 +148,6 @@ describe('TransactionQueryRepository', () => { transactionDate: '2023-01-02' as IsoDateString, }, { - currencyCode: 'EUR' as CurrencyCode, description: 'Transfer', operations: [ { @@ -393,7 +416,6 @@ describe('TransactionQueryRepository', () => { transactionDate: '2023-01-02' as IsoDateString, }, { - currencyCode: 'EUR' as CurrencyCode, description: 'Transfer', operations: [ { @@ -463,8 +485,8 @@ describe('TransactionQueryRepository', () => { expect(Object.keys(transaction).sort()).toEqual( [ + 'commodityId', 'createdAt', - 'currency', 'description', 'id', 'operations', @@ -500,7 +522,6 @@ describe('TransactionQueryRepository', () => { describe('findById', () => { it('should return transaction by id with operations', async () => { const transactionSeed: TransactionSeed = { - currencyCode: 'EUR' as CurrencyCode, description: 'Exchange', operations: [ { diff --git a/apps/backend/src/infrastructure/db/transaction/transaction-read-model.mapper.ts b/apps/backend/src/infrastructure/db/transaction/transaction-read-model.mapper.ts index 792c4464..0ed86d79 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction-read-model.mapper.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction-read-model.mapper.ts @@ -9,8 +9,8 @@ export class TransactionReadModelMapper { transaction: TransactionWithRelations, ): TransactionReadModel { return { + commodityId: transaction.commodityId, createdAt: transaction.createdAt, - currency: transaction.currency, description: transaction.description, id: transaction.id, // mapOperation does not access this, so passing it unbound is safe. 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 999771ba..e28ef813 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction.repository.test.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction.repository.test.ts @@ -1,5 +1,6 @@ import { UUID } from '@ledgerly/shared/types'; -import { OperationDbRow, UserDbRow } from 'src/db/schema'; +import { eq } from 'drizzle-orm'; +import { commoditiesTable, OperationDbRow, UserDbRow } from 'src/db/schema'; import { TestDB } from 'src/db/test-db'; import { compareEntities, @@ -9,10 +10,7 @@ import { import { Account } from 'src/domain'; import { Amount, DateValue, Id, Version } from 'src/domain/domain-core'; import { OperationSnapshot } from 'src/domain/operations/types'; -import { - ForeignKeyConstraintError, - RepositoryNotFoundError, -} from 'src/infrastructure/errors'; +import { RepositoryNotFoundError } from 'src/infrastructure/errors'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -21,6 +19,7 @@ import { TransactionRepository, } from '../'; import { AccountPersistenceMapper } from '../accounts'; +import { CommodityPersistenceMapper } from '../commodities'; import { OperationPersistenceMapper } from '../operations'; import { UserPersistenceMapper } from '../user'; @@ -58,7 +57,7 @@ describe('TransactionRepository', () => { user = await testDB.createUser(); data = TransactionBuilder.persistence({ - accounts: ['USD', 'EUR'], + currencies: ['USD', 'EUR'], operations: [ { accountKey: 'USD', @@ -85,28 +84,27 @@ describe('TransactionRepository', () => { user: UserPersistenceMapper.toDomain(user), }); + const usdCommodity = data.getCommodityByKey('USD'); + const eurCommodity = data.getCommodityByKey('EUR'); + + await testDB.insertCommodity( + CommodityPersistenceMapper.toDBRowFromSnapshot(usdCommodity.toSnapshot()), + ); + + await testDB.insertCommodity( + CommodityPersistenceMapper.toDBRowFromSnapshot(eurCommodity.toSnapshot()), + ); + usdAccount = data.getAccountByKey('USD'); eurAccount = data.getAccountByKey('EUR'); - const usdSystemAccount = data.getSystemAccountByCurrency('USD'); - const eurSystemAccount = data.getSystemAccountByCurrency('EUR'); - await testDB.insertAccount( AccountPersistenceMapper.toDBRowFromSnapshot(usdAccount.toSnapshot()), ); + await testDB.insertAccount( AccountPersistenceMapper.toDBRowFromSnapshot(eurAccount.toSnapshot()), ); - await testDB.insertAccount( - AccountPersistenceMapper.toDBRowFromSnapshot( - usdSystemAccount.toSnapshot(), - ), - ); - await testDB.insertAccount( - AccountPersistenceMapper.toDBRowFromSnapshot( - eurSystemAccount.toSnapshot(), - ), - ); transactionRepository = new TransactionRepository( mockOperationsRepository as unknown as OperationRepository, @@ -134,8 +132,8 @@ describe('TransactionRepository', () => { const insertedTransaction = await testDB.createTransactionWithOperations( user.id, + transaction.toSnapshot().commodityId, { - currencyCode: transaction.currency.valueOf(), description: transaction.description, operations: operationsDataToInsert, postingDate: transaction.getPostingDate().valueOf(), @@ -148,9 +146,19 @@ describe('TransactionRepository', () => { insertedTransaction.id, ); + const retrievedTransactionSnapshot = retrievedTransaction?.toSnapshot(); + + expect(retrievedTransactionSnapshot?.commodityId).toEqual( + transaction.toSnapshot().commodityId, + ); + expect(retrievedTransaction).not.toBeNull(); expect(retrievedTransaction?.description).toBe(transaction.description); + expect(retrievedTransaction?.getId().valueOf()).toBe( + insertedTransaction.id, + ); + expect(retrievedTransaction?.getPostingDate().valueOf()).toBe( transaction.getPostingDate().valueOf(), ); @@ -269,7 +277,54 @@ describe('TransactionRepository', () => { await expect( transactionRepository.create(nonExistentUserId, transaction), - ).rejects.toThrow(ForeignKeyConstraintError); + ).rejects.toThrow(RepositoryNotFoundError); + }); + + it('should not create a transaction when commodity does not exist', async () => { + const transaction = TransactionBuilder.persistence({ + currencies: ['USD'], + operations: [ + { + accountKey: 'USD', + amount: '-200', + description: 'Credit operation', + }, + { + accountKey: 'USD', + amount: '200', + description: 'Debit operation', + }, + ], + settings: { description }, + user: UserPersistenceMapper.toDomain(user), + }).transaction; + + await expect( + transactionRepository.create(user.id, transaction), + ).rejects.toThrow(RepositoryNotFoundError); + }); + + it('should not create a transaction when commodity belongs to another user', async () => { + const otherUser = await testDB.createUser(); + const transaction = data.transaction; + + await expect( + transactionRepository.create(otherUser.id, transaction), + ).rejects.toThrow(RepositoryNotFoundError); + }); + + it('should not create a transaction when commodity is tombstoned', async () => { + const transaction = data.transaction; + const commodityId = transaction.toSnapshot().commodityId; + + await testDB.db + .update(commoditiesTable) + .set({ isTombstone: true }) + .where(eq(commoditiesTable.id, commodityId)); + + await expect( + transactionRepository.create(user.id, transaction), + ).rejects.toThrow(RepositoryNotFoundError); }); it('should create a new transaction', async () => { @@ -684,8 +739,6 @@ describe('TransactionRepository', () => { deletedTransaction?.transactionDate, ); - expect(deletedTransaction?.currency).toBe(transaction.currency.valueOf()); - const expectedOperations: OperationDbRow[] = []; transaction.getAllOperations().forEach((operation) => { diff --git a/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts b/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts index 06335faa..d13dcd2b 100644 --- a/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts +++ b/apps/backend/src/infrastructure/db/transaction/transaction.repository.ts @@ -5,7 +5,11 @@ import { TransactionRepositoryInterface, TransactionUpdateResult, } from 'src/application'; -import { TransactionWithRelations, transactionsTable } from 'src/db/schema'; +import { + TransactionWithRelations, + commoditiesTable, + transactionsTable, +} from 'src/db/schema'; import { Transaction } from 'src/domain'; import { Version } from 'src/domain/domain-core'; import { OperationSnapshot } from 'src/domain/operations/types'; @@ -59,6 +63,27 @@ export class TransactionRepository ): Promise { const transactionData = TransactionPersistenceMapper.toDBRow(transaction); + const existingCommodity = await this.db + .select() + .from(commoditiesTable) + .where( + and( + eq(commoditiesTable.id, transactionData.commodityId), + eq(commoditiesTable.userId, userId), + eq(commoditiesTable.isTombstone, false), + ), + ) + .get(); + + this.ensureEntityExists( + existingCommodity, + `Commodity with ID ${transaction.toSnapshot().commodityId} not found`, + this.entityNotFoundContext( + 'commodity', + transaction.toSnapshot().commodityId, + ), + ); + await this.db .insert(transactionsTable) .values({ ...transactionData, userId }); @@ -76,7 +101,6 @@ export class TransactionRepository 'postingDate', 'transactionDate', 'updatedAt', - 'currency', 'version', ]); diff --git a/apps/backend/src/presentation/formatters/AmountFormatter.ts b/apps/backend/src/presentation/formatters/AmountFormatter.ts index 6779f283..f9ce15fe 100644 --- a/apps/backend/src/presentation/formatters/AmountFormatter.ts +++ b/apps/backend/src/presentation/formatters/AmountFormatter.ts @@ -38,42 +38,4 @@ export class AmountFormatter { return `${formattedMajor}${decimalSeparator}${minor}`; } - - formatWithCurrency( - amount: Amount, - currencyCode: string, - locale = 'en-US', - ): string { - const minorUnits = amount.valueOf(); - const { major, minor } = this.minorToMajor(minorUnits); - - const formatted = new Intl.NumberFormat(locale, { - currency: currencyCode, - style: 'currency', - }).format(Number(`${major}.${minor}`)); - - return formatted; - } - - formatCompact(amount: Amount, locale = 'en-US'): string { - const minorUnits = amount.valueOf(); - const { major, minor } = this.minorToMajor(minorUnits); - - return new Intl.NumberFormat(locale, { - maximumFractionDigits: 1, - minimumFractionDigits: 0, - notation: 'compact', - }).format(Number(`${major}.${minor}`)); - } - - formatForTable(amount: Amount, locale = 'en-US'): string { - const formatted = this.formatWithSign(amount, locale); - return formatted.padStart(15); - } - - formatWithSign(amount: Amount, locale = 'en-US'): string { - const formatted = this.format(amount, locale); - const sign = amount.isPositive() ? '+' : ''; - return `${sign}${formatted}`; - } } 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 f75f5e07..3bdcd86a 100644 --- a/apps/backend/src/presentation/http/accounts/account.controller.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.controller.test.ts @@ -1,13 +1,12 @@ import { AccountCreateDTO, AccountUpdateDTO } from '@ledgerly/shared/types'; import { - DeleteAccountUseCase, + ArchiveAccountUseCase, CreateAccountUseCase, GetAccountByIdUseCase, GetAllAccountsUseCase, UpdateAccountUseCase, } from 'src/application/usecases/accounts'; import { Amount } from 'src/domain/domain-core'; -import { Currency } from 'src/domain/domain-core/value-objects/Currency'; import { Id } from 'src/domain/domain-core/value-objects/Id'; import { User } from 'src/domain/users/user.entity'; import { createUser } from 'src/testing'; @@ -39,7 +38,7 @@ describe('AccountController', () => { execute: vi.fn(), }; - const mockDeleteAccountUseCase = { + const mockArchiveAccountUseCase = { execute: vi.fn(), }; @@ -48,7 +47,7 @@ describe('AccountController', () => { mockGetAllAccountsUseCase as unknown as GetAllAccountsUseCase, mockCreateAccountUseCase as unknown as CreateAccountUseCase, mockUpdateAccountUseCase as unknown as UpdateAccountUseCase, - mockDeleteAccountUseCase as unknown as DeleteAccountUseCase, + mockArchiveAccountUseCase as unknown as ArchiveAccountUseCase, ); beforeEach(async () => { @@ -101,7 +100,7 @@ describe('AccountController', () => { describe('create', () => { it('should call accountService.create with correct data', async () => { const requestBody: AccountCreateDTO = { - currency: Currency.create('USD').valueOf(), + commodityId: Id.create().valueOf(), description: 'Test Account', initialBalance: Amount.create('1000').valueOf(), name: 'New Account', @@ -125,7 +124,7 @@ describe('AccountController', () => { it('should handle invalid requestBody gracefully', async () => { const invalidRequestBody = { - currency: null, + commodityId: null, name: 123, type: 'invalid-type', }; @@ -138,7 +137,6 @@ describe('AccountController', () => { describe('update', () => { const requestBody: AccountUpdateDTO = { - currency: Currency.create('USD').valueOf(), description: 'Test Account', name: 'New Account', type: 'liability', @@ -162,8 +160,8 @@ describe('AccountController', () => { it('should handle invalid requestBody gracefully', async () => { const invalidRequestBody = { + commodityId: null, name: 123, - originalCurrency: null, type: 'invalid-type', }; @@ -181,25 +179,25 @@ describe('AccountController', () => { }); }); - describe('delete', () => { - it('should call accountService.delete with correct id and userId', async () => { - mockDeleteAccountUseCase.execute.mockResolvedValue(undefined); + describe('archiveAccount', () => { + it('should call archive account use case with correct account id and user', async () => { + mockArchiveAccountUseCase.execute.mockResolvedValue(undefined); - await accountController.deleteAccount(user, { id: accountId }); + await accountController.archiveAccount(user, { id: accountId }); - expect(mockDeleteAccountUseCase.execute).toHaveBeenCalledWith( + expect(mockArchiveAccountUseCase.execute).toHaveBeenCalledWith( user, accountId, ); - expect(mockDeleteAccountUseCase.execute).toHaveBeenCalledTimes(1); + expect(mockArchiveAccountUseCase.execute).toHaveBeenCalledTimes(1); }); it('should throw ZodError for invalid request params', async () => { await expect( - accountController.deleteAccount(user, { id: 'not-a-uuid' }), + accountController.archiveAccount(user, { id: 'not-a-uuid' }), ).rejects.toThrow(ZodError); - expect(mockDeleteAccountUseCase.execute).not.toHaveBeenCalled(); + expect(mockArchiveAccountUseCase.execute).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/backend/src/presentation/http/accounts/account.controller.ts b/apps/backend/src/presentation/http/accounts/account.controller.ts index 7d36f3b8..4387d60e 100644 --- a/apps/backend/src/presentation/http/accounts/account.controller.ts +++ b/apps/backend/src/presentation/http/accounts/account.controller.ts @@ -4,7 +4,7 @@ import { uniqueIdSchema, } from '@ledgerly/shared/validation'; import { - DeleteAccountUseCase, + ArchiveAccountUseCase, CreateAccountUseCase, GetAccountByIdUseCase, GetAllAccountsUseCase, @@ -18,7 +18,7 @@ export class AccountController { private readonly getAllAccountsUseCase: GetAllAccountsUseCase, private readonly createAccountUseCase: CreateAccountUseCase, private readonly updateAccountUseCase: UpdateAccountUseCase, - private readonly deleteAccountUseCase: DeleteAccountUseCase, + private readonly archiveAccountUseCase: ArchiveAccountUseCase, ) {} async getAll(user: User) { @@ -44,9 +44,9 @@ export class AccountController { return this.updateAccountUseCase.execute(user, id, accountUpdateDto); } - async deleteAccount(user: User, requestParams: unknown) { + async archiveAccount(user: User, requestParams: unknown) { const { id } = uniqueIdSchema.parse(requestParams); - return this.deleteAccountUseCase.execute(user, id); + return this.archiveAccountUseCase.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 c22763d2..78ba19be 100644 --- a/apps/backend/src/presentation/http/accounts/account.integration.test.ts +++ b/apps/backend/src/presentation/http/accounts/account.integration.test.ts @@ -6,8 +6,7 @@ import { UUID, } from '@ledgerly/shared/types'; import { TestDB } from 'src/db/test-db'; -import { Amount } from 'src/domain/domain-core'; -import { Currency } from 'src/domain/domain-core/value-objects/Currency'; +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 { describe, beforeEach, it, expect } from 'vitest'; @@ -16,14 +15,14 @@ const url = `/api${ROUTES.accounts}`; const firstUserAccounts = [ { - currency: Currency.create('USD').valueOf(), + commodityId: Id.create().valueOf(), description: 'This is a test account', initialBalance: Amount.create('1000').valueOf(), name: 'Test Account', type: 'asset' as AccountTypeValue, }, { - currency: Currency.create('USD').valueOf(), + commodityId: Id.create().valueOf(), description: 'Savings account for future expenses', initialBalance: Amount.create('1000').valueOf(), name: 'Savings Account', @@ -71,13 +70,14 @@ describe('Accounts Integration Tests', () => { const decoded = server.jwt.decode(token) as unknown as { userId: UUID }; userId = Id.restore(decoded.userId).valueOf(); - const testAccounts = getUserTestAccounts( + const testAccountsDTO = getUserTestAccounts( Id.restore(decoded.userId).valueOf(), ); - const promises = testAccounts.map((account) => - testDB.createAccount(userId, account), - ); + const promises = testAccountsDTO.map(async (account) => { + const commodity = await testDB.createCommodity(userId); + return testDB.createAccount(userId, commodity.id, account); + }); accounts = await Promise.all(promises); }); @@ -119,9 +119,13 @@ describe('Accounts Integration Tests', () => { describe('POST /api/accounts', () => { it('should create a new account', async () => { - //TODO: create entity with Account.create - const newAccount = { - currency: 'USD', + const commodity = await testDB.createCommodity(userId, { + code: CommodityCode.create('USD').valueOf(), + name: 'US Dollar', + }); + + const payload: AccountCreateDTO = { + commodityId: commodity.id, description: 'This is a new account', initialBalance: Amount.create('1000').valueOf(), name: 'New Account', @@ -133,17 +137,16 @@ describe('Accounts Integration Tests', () => { Authorization: `Bearer ${authToken}`, }, method: 'POST', - payload: newAccount, + payload, url, }); const createdAccount = JSON.parse(response.body) as AccountResponseDTO; expect(response.statusCode).toBe(201); - expect(createdAccount.name).toBe(newAccount.name); - expect(createdAccount.currency).toBe(newAccount.currency); - expect(createdAccount.type).toBe(newAccount.type); - expect(createdAccount.description).toBe(newAccount.description); + expect(createdAccount.name).toBe(payload.name); + expect(createdAccount.type).toBe(payload.type); + expect(createdAccount.description).toBe(payload.description); const finalResponse = await server.inject({ headers: { @@ -193,12 +196,11 @@ describe('Accounts Integration Tests', () => { }); }); - describe('PUT /api/accounts/:id', () => { + describe('PATCH /api/accounts/:id', () => { it('should update an account by ID', async () => { const accountToUpdate = accounts[0]; const updatedData = { - currency: 'EUR', name: 'Updated Account Name', }; @@ -206,7 +208,7 @@ describe('Accounts Integration Tests', () => { headers: { Authorization: `Bearer ${authToken}`, }, - method: 'PUT', + method: 'PATCH', payload: updatedData, url: `${url}/${accountToUpdate.id}`, }); @@ -215,7 +217,6 @@ describe('Accounts Integration Tests', () => { expect(response.statusCode).toBe(200); expect(updatedAccount.name).toBe(updatedData.name); - expect(updatedAccount.currency).toBe(updatedData.currency); const finalResponse = await server.inject({ headers: { @@ -248,10 +249,12 @@ describe('Accounts Integration Tests', () => { // - 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 currency is empty - // - should return 400 when currency is missing - // - should return 400 when currency is invalid format - // - should return 400 when currency doesn't exist in database + // - 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 @@ -263,12 +266,10 @@ describe('Accounts Integration Tests', () => { // - should return 400 when extra unexpected fields are provided }); - // Validation Tests for PUT /api/accounts/:id - describe.todo('PUT /api/accounts/:id - Validation', () => { + // 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 currency is empty - // - should return 400 when currency doesn't exist in database // - 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 @@ -297,8 +298,9 @@ describe('Accounts Integration Tests', () => { // Foreign Key Constraint Tests describe.todo('Foreign Key Constraints', () => { // - should handle user deletion cascading to accounts - // - should prevent creation with non-existent currency - // - should prevent update with non-existent currency + // - should prevent creation with non-existent commodityId + // - should prevent creation with another user's commodityId + // - should prevent creation with archived commodityId }); // Business Logic Tests diff --git a/apps/backend/src/presentation/http/accounts/accounts.routes.ts b/apps/backend/src/presentation/http/accounts/account.routes.ts similarity index 88% rename from apps/backend/src/presentation/http/accounts/accounts.routes.ts rename to apps/backend/src/presentation/http/accounts/account.routes.ts index 57c34ae2..f7570eed 100644 --- a/apps/backend/src/presentation/http/accounts/accounts.routes.ts +++ b/apps/backend/src/presentation/http/accounts/account.routes.ts @@ -25,13 +25,12 @@ export const accountsRoutes = (app: FastifyInstance) => { app.delete('/:id', async (request, reply) => { const user = request.user; - await accountController.deleteAccount(user, request.params); + await accountController.archiveAccount(user, request.params); reply.status(204).send(); }); - // or patch - app.put('/:id', async (request, reply) => { + app.patch('/:id', async (request, reply) => { const user = request.user; const updatedAccount = await accountController.update( diff --git a/apps/backend/src/presentation/http/accounts/index.ts b/apps/backend/src/presentation/http/accounts/index.ts index 8ba7709e..c5db09e0 100644 --- a/apps/backend/src/presentation/http/accounts/index.ts +++ b/apps/backend/src/presentation/http/accounts/index.ts @@ -1,2 +1,2 @@ export { AccountController } from './account.controller'; -export { accountsRoutes } from './accounts.routes'; +export { accountsRoutes } from './account.routes'; diff --git a/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts b/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts new file mode 100644 index 00000000..e3e00038 --- /dev/null +++ b/apps/backend/src/presentation/http/commodities/commodity.controller.test.ts @@ -0,0 +1,261 @@ +import { type QueryStatus } from '@ledgerly/shared/types'; +import { + ArchiveCommodityUseCase, + CreateCommodityUseCase, + GetAllCommoditiesUseCase, + GetCommodityByIdUseCase, + UpdateCommodityUseCase, +} from 'src/application/usecases/commodities'; +import { CommodityCode } from 'src/domain/domain-core'; +import { Id } from 'src/domain/domain-core/value-objects/Id'; +import { User } from 'src/domain/users/user.entity'; +import { createUser } from 'src/testing'; +import { describe, vi, beforeEach, it, expect } from 'vitest'; +import { ZodError } from 'zod'; + +import { CommodityController } from './commodity.controller'; + +describe('CommodityController', () => { + let user: User; + + const commodityId = Id.restore( + 'b2035d76-f6b1-4546-8637-6f034f4ade50', + ).valueOf(); + + const mockGetCommodityByIdUseCase = { + execute: vi.fn(), + }; + + const mockGetAllCommoditiesUseCase = { + execute: vi.fn(), + }; + + const mockCreateCommodityUseCase = { + execute: vi.fn(), + }; + + const mockUpdateCommodityUseCase = { + execute: vi.fn(), + }; + + const mockArchiveCommodityUseCase = { + execute: vi.fn(), + }; + + const commodityController = new CommodityController( + mockGetCommodityByIdUseCase as unknown as GetCommodityByIdUseCase, + mockGetAllCommoditiesUseCase as unknown as GetAllCommoditiesUseCase, + mockCreateCommodityUseCase as unknown as CreateCommodityUseCase, + mockUpdateCommodityUseCase as unknown as UpdateCommodityUseCase, + mockArchiveCommodityUseCase as unknown as ArchiveCommodityUseCase, + ); + + beforeEach(async () => { + user = await createUser(); + + vi.clearAllMocks(); + }); + + describe('getAll', () => { + const validQueryParams: { + expectedStatus: QueryStatus; + queryParams: unknown; + }[] = [ + { expectedStatus: 'active', queryParams: { status: 'active' } }, + { expectedStatus: 'archived', queryParams: { status: 'archived' } }, + { expectedStatus: 'all', queryParams: { status: 'all' } }, + { expectedStatus: 'active', queryParams: {} }, + ]; + + it.each(validQueryParams)( + 'should call getAllCommoditiesUseCase.execute with $expectedStatus status', + async ({ expectedStatus, queryParams }) => { + await commodityController.getAll(user, queryParams); + + expect(mockGetAllCommoditiesUseCase.execute).toHaveBeenCalledWith( + user, + expectedStatus, + ); + expect(mockGetAllCommoditiesUseCase.execute).toHaveBeenCalledTimes(1); + }, + ); + + const invalidQueryParams = [ + { status: 'deleted' }, + { status: '' }, + { status: null }, + { status: 1 }, + { status: true }, + 'active', + 'deleted', + '', + undefined, + null, + 1, + true, + ]; + + it.each(invalidQueryParams)( + 'should throw ZodError if queryParams is invalid: %s', + async (queryParams) => { + await expect( + commodityController.getAll(user, queryParams), + ).rejects.toThrow(ZodError); + + expect(mockGetAllCommoditiesUseCase.execute).not.toHaveBeenCalled(); + }, + ); + }); + + describe('getById', () => { + it('should call getCommodityByIdUseCase.execute with correct user and id', async () => { + const requestParams = { id: commodityId }; + + await commodityController.getById(user, requestParams); + + expect(mockGetCommodityByIdUseCase.execute).toHaveBeenCalledWith( + user, + requestParams.id, + ); + expect(mockGetCommodityByIdUseCase.execute).toHaveBeenCalledTimes(1); + }); + + it('should throw ZodError if requestParams is invalid', async () => { + const invalidRequestParams = { invalidId: 'commodity-id' }; + + await expect( + commodityController.getById(user, invalidRequestParams), + ).rejects.toThrow(ZodError); + }); + }); + + describe('create', () => { + it('should call createCommodityUseCase.execute with correct user and data', async () => { + const requestBody = { + code: CommodityCode.create('CommodityCode').valueOf(), + name: 'commodityName', + precision: 2, + symbol: null, + }; + + await commodityController.create(user, requestBody); + + expect(mockCreateCommodityUseCase.execute).toHaveBeenCalledWith( + user, + requestBody, + ); + + expect(mockCreateCommodityUseCase.execute).toHaveBeenCalledTimes(1); + }); + + it('should throw ZodError if requestBody is invalid', async () => { + const invalidRequestBody = { invalidField: 'invalidValue' }; + + await expect( + commodityController.create(user, invalidRequestBody), + ).rejects.toThrow(ZodError); + }); + + it('should allow to create commodity without precision', async () => { + const requestBody = { + code: CommodityCode.create('UpdatedCode').valueOf(), + name: 'Updated Name', + }; + + await commodityController.create(user, requestBody); + + expect(mockCreateCommodityUseCase.execute).toHaveBeenCalledWith( + user, + requestBody, + ); + }); + }); + + describe('update', () => { + const requestBody = { + code: CommodityCode.create('UpdatedCode').valueOf(), + name: 'Updated Name', + symbol: '$', + }; + + it('should call updateCommodityUseCase.execute with correct user, id and data', async () => { + const requestParams = { id: commodityId }; + + await commodityController.update(user, requestParams, requestBody); + + expect(mockUpdateCommodityUseCase.execute).toHaveBeenCalledWith( + user, + requestParams.id, + requestBody, + ); + + expect(mockUpdateCommodityUseCase.execute).toHaveBeenCalledTimes(1); + }); + + it('should throw ZodError if requestBody is invalid', async () => { + const requestParams = { id: commodityId }; + const invalidRequestBody = { code: 'invalid-value' }; + + await expect( + commodityController.update(user, requestParams, invalidRequestBody), + ).rejects.toThrow(ZodError); + }); + + it('should ignore precision in update', async () => { + const requestParams = { id: commodityId }; + const requestBody = { + code: 'USD', + name: 'US Dollar', + precision: 3, + symbol: '$', + }; + + const expectedData = { + code: requestBody.code, + name: requestBody.name, + symbol: requestBody.symbol, + }; + + await commodityController.update(user, requestParams, requestBody); + + expect(mockUpdateCommodityUseCase.execute).toHaveBeenCalledWith( + user, + requestParams.id, + expectedData, + ); + + expect(mockUpdateCommodityUseCase.execute).toHaveBeenCalledTimes(1); + }); + + it('should throw ZodError if requestParams is invalid', async () => { + const invalidRequestParams = { invalidId: 'commodity-id' }; + + await expect( + commodityController.update(user, invalidRequestParams, requestBody), + ).rejects.toThrow(ZodError); + }); + }); + + describe('archiveCommodity', () => { + it('should call archiveCommodityUseCase.execute with correct user and id', async () => { + const requestParams = { id: commodityId }; + + await commodityController.archiveCommodity(user, requestParams); + + expect(mockArchiveCommodityUseCase.execute).toHaveBeenCalledWith( + user, + requestParams.id, + ); + + expect(mockArchiveCommodityUseCase.execute).toHaveBeenCalledTimes(1); + }); + + it('should throw ZodError if requestParams is invalid', async () => { + const invalidRequestParams = { invalidId: 'commodity-id' }; + + await expect( + commodityController.archiveCommodity(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 new file mode 100644 index 00000000..cd1f60b6 --- /dev/null +++ b/apps/backend/src/presentation/http/commodities/commodity.controller.ts @@ -0,0 +1,72 @@ +import { CommodityResponseDTO } from '@ledgerly/shared/types'; +import { + commodityCreateSchema, + commodityUpdateSchema, + uniqueIdSchema, + queryStatusSchema, +} from '@ledgerly/shared/validation'; +import { + GetCommodityByIdUseCase, + ArchiveCommodityUseCase, + CreateCommodityUseCase, + GetAllCommoditiesUseCase, + UpdateCommodityUseCase, +} from 'src/application/usecases/commodities'; +import { User } from 'src/domain/users/user.entity'; + +export class CommodityController { + constructor( + private readonly getCommodityByIdUseCase: GetCommodityByIdUseCase, + private readonly getAllCommoditiesUseCase: GetAllCommoditiesUseCase, + private readonly createCommodityUseCase: CreateCommodityUseCase, + private readonly updateCommodityUseCase: UpdateCommodityUseCase, + private readonly archiveCommodityUseCase: ArchiveCommodityUseCase, + ) {} + + async getAll( + user: User, + queryParams: unknown, + ): Promise { + const { status } = queryStatusSchema.parse(queryParams); + + return this.getAllCommoditiesUseCase.execute(user, status); + } + + async getById( + user: User, + requestParams: unknown, + ): Promise { + const { id } = uniqueIdSchema.parse(requestParams); + + return this.getCommodityByIdUseCase.execute(user, id); + } + + async create( + user: User, + requestBody: unknown, + ): Promise { + const commodityCreateDto = commodityCreateSchema.parse(requestBody); + + return this.createCommodityUseCase.execute(user, commodityCreateDto); + } + + async update( + user: User, + requestParams: unknown, + requestBody: unknown, + ): Promise { + const { id } = uniqueIdSchema.parse(requestParams); + const commodityUpdateDto = commodityUpdateSchema.parse(requestBody); + + return this.updateCommodityUseCase.execute(user, id, commodityUpdateDto); + } + + async archiveCommodity( + user: User, + requestParams: unknown, + ): Promise { + const { id } = uniqueIdSchema.parse(requestParams); + + return this.archiveCommodityUseCase.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 new file mode 100644 index 00000000..95f4dc80 --- /dev/null +++ b/apps/backend/src/presentation/http/commodities/commodity.integration.test.ts @@ -0,0 +1,803 @@ +import { ROUTES } from '@ledgerly/shared/routes'; +import { 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'; +import { Commodity } from 'src/domain'; +import { CommodityCode, Id } from 'src/domain/domain-core'; +import { createServer } from 'src/presentation/http'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const url = `/api${ROUTES.commodities}`; + +const testUser = { + email: 'test@example.com', + name: 'Test User', + password: 'Password123!', +}; + +describe('Commodities Integration Tests', () => { + let testDB: TestDB; + let server: ReturnType; + let authToken: string; + let userId: UUID; + let otherUserId: UUID; + + const activeCommodityData1 = { + code: CommodityCode.create('USD').valueOf(), + isTombstone: false, + name: 'US Dollar', + precision: 2, + symbol: '$', + }; + + const activeCommodityData2 = { + code: CommodityCode.create('EUR').valueOf(), + isTombstone: false, + name: 'Euro', + precision: 2, + symbol: '€', + }; + + const archivedCommodityData1 = { + code: CommodityCode.create('GBP').valueOf(), + isTombstone: true, + name: 'British Pound', + precision: 2, + symbol: '£', + }; + + const activeCommoditiesData = [activeCommodityData1, activeCommodityData2]; + const archivedCommoditiesData = [archivedCommodityData1]; + const allCommoditiesData = [ + ...activeCommoditiesData, + ...archivedCommoditiesData, + ]; + + let commoditiesDbRows: CommodityDbRow[]; + + type AuthorizedRequest = { + method: 'DELETE' | 'GET' | 'PATCH' | 'POST'; + url: string; + body?: Record; + }; + + const injectWithToken = async (token: string, options: AuthorizedRequest) => { + return await server.inject({ + ...options, + headers: { + Authorization: `Bearer ${token}`, + }, + }); + }; + + const injectAuthorized = async (options: AuthorizedRequest) => { + return await injectWithToken(authToken, options); + }; + + beforeEach(async () => { + testDB = new TestDB(); + server = createServer(testDB.db); + await testDB.setupTestDb(); + + await server.ready(); + + const user = await testDB.createUser(testUser); + + const token = server.jwt.sign({ + email: user.email, + userId: user.id, + }); + + authToken = token; + + const decoded = server.jwt.decode(token) as unknown as { userId: UUID }; + userId = Id.restore(decoded.userId).valueOf(); + + const otherUser = await testDB.createUser({ + email: 'other@example.com', + name: 'Other User', + password: 'Password123!', + }); + otherUserId = Id.restore(otherUser.id).valueOf(); + + commoditiesDbRows = await Promise.all([ + ...allCommoditiesData.map((commodity) => + testDB.createCommodity(userId, { + code: commodity.code, + isTombstone: commodity.isTombstone, + name: commodity.name, + precision: commodity.precision, + symbol: commodity.symbol, + }), + ), + ]); + }); + + afterEach(async () => { + await testDB.cleanupTestDb(); + }); + + 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`, + }); + + expect(response.statusCode).toBe(200); + + const parsedResponse = parseResponse(response); + + expect(parsedResponse).toHaveLength(activeCommoditiesData.length); + + compareEntityArrays(activeCommoditiesData, parsedResponse); + }); + + 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); + + const parsedResponse = parseResponse(response); + + expect(parsedResponse).toHaveLength(1); + + compareEntityArrays([archivedCommodityData1], parsedResponse); + }); + + it('should return all commodities when status=all is provided', async () => { + const response = await injectAuthorized({ + method: 'GET', + url: `${url}?status=all`, + }); + + 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 () => { + const response = await injectAuthorized({ + method: 'GET', + url: `${url}?status=invalidStatus`, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should not return commodities belonging to another user', async () => { + const otherUserCommodity = await testDB.createCommodity(otherUserId, { + code: CommodityCode.create('JPY').valueOf(), + isTombstone: false, + name: 'Japanese Yen', + precision: 2, + symbol: '¥', + }); + + const response = await injectAuthorized({ + method: 'GET', + url: `${url}?status=all`, + }); + + expect(response.statusCode).toBe(200); + + const parsedResponse = parseResponse(response); + + parsedResponse.forEach((commodity) => { + expect(commodity.code).not.toBe(otherUserCommodity.code); + }); + + expect(parsedResponse).toHaveLength(allCommoditiesData.length); + + compareEntityArrays(allCommoditiesData, parsedResponse); + }); + + it('should return 401 without authorization', async () => { + const response = await server.inject({ + method: 'GET', + url: `${url}?status=all`, + }); + + expect(response.statusCode).toBe(401); + }); + }); + + describe('GET /api/commodities/:id', () => { + it('should return a commodity by id for the authenticated user', async () => { + const testActiveCommodityDbRow = commoditiesDbRows[0]; + + const response = await injectAuthorized({ + method: 'GET', + url: `${url}/${testActiveCommodityDbRow.id}`, + }); + + expect(response.statusCode).toBe(200); + + const parsedResponse = parseResponse(response); + + expect(parsedResponse).toEqual( + expect.objectContaining({ + code: testActiveCommodityDbRow.code, + isTombstone: testActiveCommodityDbRow.isTombstone, + name: testActiveCommodityDbRow.name, + precision: testActiveCommodityDbRow.precision, + symbol: testActiveCommodityDbRow.symbol, + }), + ); + }); + + it('should return an archived commodity by id marked as archived', async () => { + const testArchivedCommodityDbRow = commoditiesDbRows.find( + (commodity) => commodity.isTombstone, + ); + + if (!testArchivedCommodityDbRow) { + throw new Error('No archived commodity found in the test data.'); + } + + const response = await injectAuthorized({ + method: 'GET', + url: `${url}/${testArchivedCommodityDbRow.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, + }), + ); + }); + + it('should return 404 when the commodity does not exist', async () => { + const response = await injectAuthorized({ + method: 'GET', + url: `${url}/${Id.create().valueOf()}`, // Random UUID that doesn't exist + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 404 when the commodity belongs to another user', async () => { + const otherUserCommodity = await testDB.createCommodity(otherUserId, { + code: CommodityCode.create('JPY').valueOf(), + isTombstone: false, + name: 'Japanese Yen', + precision: 2, + symbol: '¥', + }); + + const response = await injectAuthorized({ + method: 'GET', + url: `${url}/${otherUserCommodity.id}`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 400 when the commodity id is invalid', async () => { + const response = await injectAuthorized({ + method: 'GET', + url: `${url}/invalid-id`, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 401 without authorization', async () => { + const testActiveCommodityDbRow = commoditiesDbRows[0]; + + const response = await server.inject({ + method: 'GET', + url: `${url}/${testActiveCommodityDbRow.id}`, + }); + + expect(response.statusCode).toBe(401); + }); + }); + + describe('POST /api/commodities', () => { + it('should create a commodity for the authenticated user', async () => { + const createCommodityData = { + code: CommodityCode.create('CHF').valueOf(), + name: 'Swiss Franc', + precision: 2, + symbol: 'CHF', + }; + + const response = await injectAuthorized({ + body: createCommodityData, + method: 'POST', + url, + }); + + expect(response.statusCode).toBe(201); + + const parsedResponse = parseResponse(response); + + expect(parsedResponse).toEqual( + expect.objectContaining({ + ...createCommodityData, + isTombstone: false, + userId, + }), + ); + }); + + it('should create a commodity with default precision and null symbol', async () => { + const createCommodityData = { + code: CommodityCode.create('CAD').valueOf(), + name: 'Canadian Dollar', + }; + + const response = await injectAuthorized({ + body: createCommodityData, + method: 'POST', + url, + }); + + expect(response.statusCode).toBe(201); + + const parsedResponse = parseResponse(response); + + expect(parsedResponse).toEqual( + expect.objectContaining({ + ...createCommodityData, + isTombstone: false, + precision: Commodity.DEFAULT_PRECISION, + symbol: Commodity.DEFAULT_SYMBOL, + userId, + }), + ); + }); + + it('should reject duplicate code for the same user', async () => { + const response = await injectAuthorized({ + body: { + code: activeCommodityData1.code, + name: 'Duplicate US Dollar', + precision: 2, + symbol: '$', + }, + method: 'POST', + url, + }); + + expect(response.statusCode).toBe(409); + }); + + it('should allow the same code for different users', async () => { + const otherUserToken = server.jwt.sign({ + email: 'other@example.com', + userId: otherUserId, + }); + + const response = await injectWithToken(otherUserToken, { + body: { + code: activeCommodityData1.code, + name: 'Other User Dollar', + precision: 2, + symbol: '$', + }, + method: 'POST', + url, + }); + + expect(response.statusCode).toBe(201); + + const parsedResponse = parseResponse(response); + + expect(parsedResponse).toEqual( + expect.objectContaining({ + code: activeCommodityData1.code, + userId: otherUserId, + }), + ); + }); + + it('should return 400 for invalid payload', async () => { + const response = await injectAuthorized({ + body: { + code: '1', + precision: 19, + symbol: 'symbol-is-too-long', + }, + method: 'POST', + url, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 401 without authorization', async () => { + const response = await server.inject({ + body: { + code: CommodityCode.create('AUD').valueOf(), + name: 'Australian Dollar', + }, + method: 'POST', + url, + }); + + expect(response.statusCode).toBe(401); + }); + }); + + describe('PATCH /api/commodities/:id', () => { + it('should update editable commodity metadata', async () => { + const commodityToUpdate = commoditiesDbRows[0]; + + const updateData = { + code: CommodityCode.create('USDX').valueOf(), + name: 'Updated US Dollar', + symbol: 'US$', + }; + + const response = await injectAuthorized({ + body: updateData, + method: 'PATCH', + url: `${url}/${commodityToUpdate.id}`, + }); + + expect(response.statusCode).toBe(200); + + const parsedResponse = parseResponse(response); + + expect(typeof parsedResponse.updatedAt).toBe('string'); + + expect(parsedResponse).toEqual( + expect.objectContaining({ + ...updateData, + createdAt: commodityToUpdate.createdAt, + id: commodityToUpdate.id, + isTombstone: false, + precision: commodityToUpdate.precision, + userId, + }), + ); + }); + + it('should reject duplicate code for the same user', async () => { + const commodityToUpdate = commoditiesDbRows[0]; + + const response = await injectAuthorized({ + body: { + code: activeCommodityData2.code, + }, + method: 'PATCH', + url: `${url}/${commodityToUpdate.id}`, + }); + + expect(response.statusCode).toBe(409); + }); + + it('should allow updating to a code used by another user', async () => { + await testDB.createCommodity(otherUserId, { + code: CommodityCode.create('JPY').valueOf(), + name: 'Japanese Yen', + precision: 2, + symbol: '¥', + }); + + const commodityToUpdate = commoditiesDbRows[0]; + + const response = await injectAuthorized({ + body: { + code: CommodityCode.create('JPY').valueOf(), + }, + method: 'PATCH', + url: `${url}/${commodityToUpdate.id}`, + }); + + expect(response.statusCode).toBe(200); + + const parsedResponse = parseResponse(response); + + expect(parsedResponse).toEqual( + expect.objectContaining({ + code: CommodityCode.create('JPY').valueOf(), + id: commodityToUpdate.id, + userId, + }), + ); + }); + + it('should return 404 when updating 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({ + body: { + name: 'Updated Japanese Yen', + }, + method: 'PATCH', + url: `${url}/${otherUserCommodity.id}`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 404 when updating an archived commodity', async () => { + const archivedCommodity = commoditiesDbRows.find( + (commodity) => commodity.isTombstone, + ); + + if (!archivedCommodity) { + throw new Error('No archived commodity found in the test data.'); + } + + const response = await injectAuthorized({ + body: { + name: 'Updated British Pound', + }, + method: 'PATCH', + url: `${url}/${archivedCommodity.id}`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 400 for invalid params', async () => { + const response = await injectAuthorized({ + body: { + name: 'Updated Commodity', + }, + method: 'PATCH', + url: `${url}/invalid-id`, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 400 for invalid payload', async () => { + const commodityToUpdate = commoditiesDbRows[0]; + + const response = await injectAuthorized({ + body: { + code: '1', + precision: 3, + }, + method: 'PATCH', + url: `${url}/${commodityToUpdate.id}`, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 401 without authorization', async () => { + const commodityToUpdate = commoditiesDbRows[0]; + + const response = await server.inject({ + body: { + name: 'Updated Commodity', + }, + method: 'PATCH', + url: `${url}/${commodityToUpdate.id}`, + }); + + expect(response.statusCode).toBe(401); + }); + + it("should not allow updating the commodity's precision", async () => { + const commodityToUpdate = commoditiesDbRows[0]; + + const response = await injectAuthorized({ + body: { + precision: 5, + }, + method: 'PATCH', + url: `${url}/${commodityToUpdate.id}`, + }); + + expect(response.statusCode).toBe(200); + + const parsedResponse = parseResponse(response); + + expect(parsedResponse.precision).toBe(commodityToUpdate.precision); + }); + }); + + describe('DELETE /api/commodities/:id', () => { + it('should archive a commodity by id', async () => { + const commodityToArchive = commoditiesDbRows[0]; + + const response = await injectAuthorized({ + method: 'DELETE', + url: `${url}/${commodityToArchive.id}`, + }); + + expect(response.statusCode).toBe(204); + + const getResponse = await injectAuthorized({ + method: 'GET', + url: `${url}/${commodityToArchive.id}`, + }); + + expect(getResponse.statusCode).toBe(200); + + const parsedGetResponse = + parseResponse(getResponse); + + expect(parsedGetResponse).toEqual( + expect.objectContaining({ + id: commodityToArchive.id, + isTombstone: true, + }), + ); + + const allCommodities = await testDB.getAllCommoditiesByUserId(userId); + + const archivedCommoditiesDbRows = allCommodities.filter( + (commodity) => commodity.isTombstone, + ); + + const allActiveCommodities = allCommodities.filter( + (commodity) => !commodity.isTombstone, + ); + + expect(allActiveCommodities).toHaveLength( + activeCommoditiesData.length - 1, + ); + + expect(archivedCommoditiesDbRows).toHaveLength( + archivedCommoditiesData.length + 1, + ); + }); + + it('should leave the archived commodity readable by id', async () => { + const archivedCommodity = commoditiesDbRows.find( + (commodity) => commodity.isTombstone, + ); + + if (!archivedCommodity) { + throw new Error('No archived commodity found in the test data.'); + } + + const response = await injectAuthorized({ + method: 'GET', + url: `${url}/${archivedCommodity.id}`, + }); + + expect(response.statusCode).toBe(200); + + const parsedResponse = parseResponse(response); + + expect(parsedResponse).toEqual( + expect.objectContaining({ + id: archivedCommodity.id, + isTombstone: true, + }), + ); + }); + + it('should return the archived commodity in the list marked as archived', async () => { + const commodityToArchive = commoditiesDbRows[0]; + + const deleteResponse = await injectAuthorized({ + method: 'DELETE', + url: `${url}/${commodityToArchive.id}`, + }); + + expect(deleteResponse.statusCode).toBe(204); + + const listResponse = await injectAuthorized({ + method: 'GET', + url: `${url}?status=archived`, + }); + + expect(listResponse.statusCode).toBe(200); + + const parsedListResponse = + parseResponse(listResponse); + + expect(parsedListResponse).toHaveLength( + archivedCommoditiesData.length + 1, + ); + + compareEntityArrays( + [ + archivedCommodityData1, + { + code: commodityToArchive.code, + isTombstone: true, + name: commodityToArchive.name, + precision: commodityToArchive.precision, + symbol: commodityToArchive.symbol, + }, + ], + parsedListResponse, + ); + }); + + it('should return 404 when archiving 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: 'DELETE', + url: `${url}/${otherUserCommodity.id}`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 404 when archiving an already archived commodity', async () => { + const archivedCommodity = commoditiesDbRows.find( + (commodity) => commodity.isTombstone, + ); + + if (!archivedCommodity) { + throw new Error('No archived commodity found in the test data.'); + } + + const response = await injectAuthorized({ + method: 'DELETE', + url: `${url}/${archivedCommodity.id}`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 400 when the commodity id is invalid', async () => { + const response = await injectAuthorized({ + method: 'DELETE', + url: `${url}/invalid-id`, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 401 without authorization', async () => { + const commodityToArchive = commoditiesDbRows[0]; + + const response = await server.inject({ + method: 'DELETE', + url: `${url}/${commodityToArchive.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 new file mode 100644 index 00000000..feb76f25 --- /dev/null +++ b/apps/backend/src/presentation/http/commodities/commodity.routes.ts @@ -0,0 +1,43 @@ +import type { FastifyInstance } from 'fastify'; + +export const commodityRoutes = (app: FastifyInstance) => { + const commodityController = app.container.controllers.commodity; + + app.get('/', async (request) => { + const user = request.user; + + return await commodityController.getAll(user, request.query); + }); + + app.get('/:id', async (request) => { + const user = request.user; + + return commodityController.getById(user, request.params); + }); + + app.post('/', async (request, reply) => { + const user = request.user; + + const commodity = await commodityController.create(user, request.body); + reply.status(201).send(commodity); + }); + + app.delete('/:id', async (request, reply) => { + const user = request.user; + + await commodityController.archiveCommodity(user, request.params); + + reply.status(204).send(); + }); + + app.patch('/:id', async (request, reply) => { + const user = request.user; + + const updatedCommodity = await commodityController.update( + user, + request.params, + request.body, + ); + reply.status(200).send(updatedCommodity); + }); +}; diff --git a/apps/backend/src/presentation/http/commodities/index.ts b/apps/backend/src/presentation/http/commodities/index.ts new file mode 100644 index 00000000..e8e3f582 --- /dev/null +++ b/apps/backend/src/presentation/http/commodities/index.ts @@ -0,0 +1,2 @@ +export { CommodityController } from './commodity.controller'; +export { commodityRoutes } from './commodity.routes'; diff --git a/apps/backend/src/presentation/http/currencies/currencies.routes.ts b/apps/backend/src/presentation/http/currencies/currencies.routes.ts deleted file mode 100644 index 535631e4..00000000 --- a/apps/backend/src/presentation/http/currencies/currencies.routes.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { FastifyInstance } from 'fastify'; - -export const registerCurrenciesRoutes = (app: FastifyInstance) => { - app.get('/', () => { - throw new Error('Not implemented'); - }); -}; diff --git a/apps/backend/src/presentation/http/currencies/index.ts b/apps/backend/src/presentation/http/currencies/index.ts deleted file mode 100644 index b8f9d4a6..00000000 --- a/apps/backend/src/presentation/http/currencies/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { registerCurrenciesRoutes } from './currencies.routes'; diff --git a/apps/backend/src/presentation/http/error-handler.ts b/apps/backend/src/presentation/http/error-handler.ts index 608a14dd..a4624c4e 100644 --- a/apps/backend/src/presentation/http/error-handler.ts +++ b/apps/backend/src/presentation/http/error-handler.ts @@ -5,7 +5,10 @@ import { type ValidationFieldErrorCode, } from '@ledgerly/shared/types'; import { FastifyError, FastifyReply, FastifyRequest } from 'fastify'; -import { DatabaseError } from 'src/infrastructure/errors'; +import { + DatabaseError, + RecordAlreadyExistsError, +} from 'src/infrastructure/errors'; import { isCodedError } from 'src/shared/errors'; import { reportDatabaseError } from 'src/shared/errors/reportDatabaseError'; import { ZodError, type ZodIssue } from 'zod'; @@ -26,6 +29,9 @@ const statusByErrorCode = { [apiErrorCodes.internalServerError]: 500, [apiErrorCodes.invalidAccountType]: 400, [apiErrorCodes.invalidAmount]: 400, + [apiErrorCodes.invalidCommodityCode]: 400, + [apiErrorCodes.invalidCommodityPrecision]: 400, + [apiErrorCodes.invalidCommoditySymbol]: 400, [apiErrorCodes.invalidDate]: 400, [apiErrorCodes.invalidEmail]: 400, [apiErrorCodes.invalidIdentifier]: 400, @@ -105,6 +111,15 @@ export function errorHandler( ); } + if (error instanceof RecordAlreadyExistsError) { + return sendCodedError( + reply, + statusByErrorCode[apiErrorCodes.conflict], + apiErrorCodes.conflict, + {}, + ); + } + if (error instanceof DatabaseError) { reportDatabaseError(error); return sendCodedError( diff --git a/apps/backend/src/presentation/http/index.ts b/apps/backend/src/presentation/http/index.ts index 490a0c48..a7d91b13 100644 --- a/apps/backend/src/presentation/http/index.ts +++ b/apps/backend/src/presentation/http/index.ts @@ -1,9 +1,9 @@ export { AccountController, accountsRoutes } from './accounts'; export { AuthController, authRoutes } from './auth'; -export { registerCurrenciesRoutes } from './currencies'; export { errorHandler, getValidationFieldErrorCode } from './error-handler'; export { HttpApiError, UnauthorizedError } from './errors'; export { registerRoutes } from './register-routes'; export { createServer } from './server'; export { TransactionController, transactionsRoutes } from './transactions'; export { registerUserRoutes, UserController } from './users'; +export { CommodityController, commodityRoutes } from './commodities'; diff --git a/apps/backend/src/presentation/http/register-routes.ts b/apps/backend/src/presentation/http/register-routes.ts index 8c466abf..f275141f 100644 --- a/apps/backend/src/presentation/http/register-routes.ts +++ b/apps/backend/src/presentation/http/register-routes.ts @@ -3,7 +3,7 @@ import type { FastifyInstance } from 'fastify'; import { accountsRoutes } from './accounts'; import { authRoutes } from './auth'; -import { registerCurrenciesRoutes } from './currencies'; +import { commodityRoutes } from './commodities'; import { authMiddleware } from './middleware'; import { transactionsRoutes } from './transactions'; import { registerUserRoutes } from './users'; @@ -16,10 +16,6 @@ export const registerRoutes = (fastify: FastifyInstance) => { fastify.register(authRoutes, { prefix: ROUTES.auth }); - fastify.register(registerCurrenciesRoutes, { - prefix: ROUTES.currencies, - }); - // Protected routes fastify.register((protectedApp) => { protectedApp.addHook('onRequest', authMiddleware); @@ -35,5 +31,9 @@ export const registerRoutes = (fastify: FastifyInstance) => { protectedApp.register(registerUserRoutes, { prefix: ROUTES.user, }); + + protectedApp.register(commodityRoutes, { + prefix: ROUTES.commodities, + }); }); }; diff --git a/apps/backend/src/presentation/http/transactions/transaction.controller.test.ts b/apps/backend/src/presentation/http/transactions/transaction.controller.test.ts index ce7c247e..75463b55 100644 --- a/apps/backend/src/presentation/http/transactions/transaction.controller.test.ts +++ b/apps/backend/src/presentation/http/transactions/transaction.controller.test.ts @@ -13,7 +13,7 @@ import { GetAllTransactionsUseCase } from 'src/application/usecases/transaction/ import { GetTransactionByIdUseCase } from 'src/application/usecases/transaction/GetTransactionById'; import { UpdateTransactionUseCase } from 'src/application/usecases/transaction/UpdateTransaction'; import { User } from 'src/domain'; -import { Amount, Currency, DateValue, Id } from 'src/domain/domain-core'; +import { Amount, DateValue, Id } from 'src/domain/domain-core'; import { createUser } from 'src/testing'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { ZodError } from 'zod'; @@ -92,7 +92,7 @@ describe('TransactionController', () => { describe('create', () => { it('should call CreateTransactionUseCase with correct parameters', async () => { const requestBody: TransactionCreateInput = { - currencyCode: Currency.create('USD').valueOf(), + commodityId: Id.create().valueOf(), description: 'Test Transaction', operations, postingDate: DateValue.restore('2024-01-01').valueOf(), @@ -104,7 +104,6 @@ describe('TransactionController', () => { expect(mockCreateTransactionUseCase.execute).toHaveBeenCalledWith( user, expect.objectContaining({ - currencyCode: requestBody.currencyCode, description: requestBody.description, operations: requestBody.operations, postingDate: requestBody.postingDate, @@ -119,7 +118,6 @@ describe('TransactionController', () => { it('should throw ZodError for invalid request body', async () => { const invalidRequestBody = { - currencyCode: 123, description: 'Test Transaction', operations: [], postingDate: 'invalid-date', @@ -138,7 +136,6 @@ describe('TransactionController', () => { 'should reject a create request without %s', async (missingDateField) => { const requestBody = { - currencyCode: Currency.create('USD').valueOf(), description: 'Test Transaction', operations, postingDate: DateValue.restore('2024-01-01').valueOf(), diff --git a/apps/backend/src/presentation/http/transactions/transaction.integration.test.ts b/apps/backend/src/presentation/http/transactions/transaction.integration.test.ts index 87ac3609..581e850b 100644 --- a/apps/backend/src/presentation/http/transactions/transaction.integration.test.ts +++ b/apps/backend/src/presentation/http/transactions/transaction.integration.test.ts @@ -16,21 +16,18 @@ import { } from 'src/application'; import { AccountDbRow, + CommodityDbRow, OperationDbRow, TransactionDbRow, UserDbRow, } from 'src/db/schema'; import { CreateTransactionProps, TestDB } from 'src/db/test-db'; -import { compareEntities } from 'src/db/test-utils'; +import { compareEntities, parseResponse } from 'src/db/test-utils'; import { compareCommonEntities } from 'src/db/test-utils/entityComparer'; -import { Amount, Currency, DateValue, Id } from 'src/domain/domain-core'; +import { Amount, DateValue, Id } from 'src/domain/domain-core'; import { createServer } from 'src/presentation/http'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -const parseResponse = (response: { body: string }): T => { - return JSON.parse(response.body) as T; -}; - const testUser = { email: 'test@example.com', name: 'Test User', @@ -45,6 +42,8 @@ describe('Transactions Integration Tests', () => { let authToken: string; let userId: UUID; let user: UserDbRow; + let commodity: CommodityDbRow; + let commodityId: UUID; beforeEach(async () => { testDB = new TestDB(); @@ -55,6 +54,9 @@ describe('Transactions Integration Tests', () => { user = await testDB.createUser(testUser); + commodity = await testDB.createCommodity(user.id); + commodityId = commodity.id; + const token = server.jwt.sign({ email: user.email, userId: user.id, @@ -72,12 +74,10 @@ describe('Transactions Integration Tests', () => { const createAccounts = async () => { return Promise.all([ - testDB.createAccount(userId, { - currency: Currency.create('USD').valueOf(), + testDB.createAccount(userId, commodityId, { name: 'Checking USD', }), - testDB.createAccount(userId, { - currency: Currency.create('USD').valueOf(), + testDB.createAccount(userId, commodityId, { name: 'Savings USD', }), ]); @@ -109,7 +109,7 @@ describe('Transactions Integration Tests', () => { it('should create a new transaction', async () => { const payload: TransactionCreateInput = { - currencyCode: Currency.create('USD').valueOf(), + commodityId, description: 'some transaction', operations: [operation1, operation2], postingDate: DateValue.restore('2025-11-07').valueOf(), @@ -146,13 +146,12 @@ describe('Transactions Integration Tests', () => { }); it('should create a multi-currency transaction balanced by value when amounts do not sum to zero', async () => { - const rubAccount = await testDB.createAccount(userId, { - currency: Currency.create('RUB').valueOf(), + const rubAccount = await testDB.createAccount(userId, commodityId, { name: 'Cash RUB', }); const payload: TransactionCreateInput = { - currencyCode: Currency.create('USD').valueOf(), + commodityId, description: 'USD to RUB exchange', operations: [ { @@ -214,7 +213,7 @@ describe('Transactions Integration Tests', () => { expect.arrayContaining([ { code: 'REQUIRED', - path: 'currencyCode', + path: 'commodityId', }, { code: 'REQUIRED', @@ -226,7 +225,7 @@ describe('Transactions Integration Tests', () => { it('should fail with invalid amounts', async () => { const payload: TransactionCreateInput = { - currencyCode: Currency.create('USD').valueOf(), + commodityId, description: 'invalid amount', operations: [ operation1, @@ -268,7 +267,7 @@ describe('Transactions Integration Tests', () => { ['missing value', { value: undefined as unknown as AmountString }], ])('should fail with %s', async (_, invalidOperationPatch) => { const payload: TransactionCreateInput = { - currencyCode: Currency.create('USD').valueOf(), + commodityId, description: 'invalid finite amount', operations: [ operation1, @@ -298,7 +297,7 @@ describe('Transactions Integration Tests', () => { it('should fail for unauthorized access', async () => { const payload: TransactionCreateInput = { - currencyCode: Currency.create('USD').valueOf(), + commodityId, description: 'unauthorized access', operations: [operation1, operation2], postingDate: DateValue.restore('2025-11-07').valueOf(), @@ -316,7 +315,7 @@ describe('Transactions Integration Tests', () => { it('should fail for non-existent accounts', async () => { const payload: TransactionCreateInput = { - currencyCode: Currency.create('USD').valueOf(), + commodityId, description: 'non-existent account', operations: [ { ...operation1, accountId: Id.create().valueOf() }, @@ -340,7 +339,7 @@ describe('Transactions Integration Tests', () => { it('should fail when operations do not sum to zero (unbalanced transaction)', async () => { const payload: TransactionCreateInput = { - currencyCode: Currency.create('USD').valueOf(), + commodityId, description: 'unbalanced transaction', operations: [ operation1, @@ -371,14 +370,18 @@ describe('Transactions Integration Tests', () => { email: 'otheruser@example.com', password: 'password123', }); + const otherUserCommodity = await testDB.createCommodity(otherUser.id); - const otherUserAccount = await testDB.createAccount(otherUser.id, { - currency: Currency.create('USD').valueOf(), - name: 'Other User Account', - }); + const otherUserAccount = await testDB.createAccount( + otherUser.id, + otherUserCommodity.id, + { + name: 'Other User Account', + }, + ); const payload: TransactionCreateInput = { - currencyCode: Currency.create('USD').valueOf(), + commodityId, description: 'unauthorized access', operations: [ { ...operation1, accountId: otherUserAccount.id }, @@ -410,7 +413,7 @@ describe('Transactions Integration Tests', () => { beforeEach(async () => { const accounts = await createAccounts(); - transaction = await testDB.createTransaction(userId); + transaction = await testDB.createTransaction(userId, commodityId); const transaction1Operations = [ { @@ -514,7 +517,10 @@ describe('Transactions Integration Tests', () => { }); it('should not return soft-deleted (tombstone) operations', async () => { - const createdTransaction = await testDB.createTransaction(userId); + const createdTransaction = await testDB.createTransaction( + userId, + commodityId, + ); const deletedOperationsData = [ { @@ -608,7 +614,10 @@ describe('Transactions Integration Tests', () => { it("should return 404 when accessing another user's transaction", async () => { const otherUser = await testDB.createUser(); - const otherUserTransaction = await testDB.createTransaction(otherUser.id); + const otherUserTransaction = await testDB.createTransaction( + otherUser.id, + commodityId, + ); const response = await server.inject({ headers: { @@ -640,7 +649,6 @@ describe('Transactions Integration Tests', () => { it('should retrieve all transactions for the user', async () => { const transaction1Params: CreateTransactionProps = { - currencyCode: accounts[0].currency, description: 'transaction one', isTombstone: false, postingDate: DateValue.create().valueOf(), @@ -648,7 +656,6 @@ describe('Transactions Integration Tests', () => { }; const transaction2Params: CreateTransactionProps = { - currencyCode: accounts[1].currency, description: 'transaction two', isTombstone: false, postingDate: DateValue.create().valueOf(), @@ -659,7 +666,7 @@ describe('Transactions Integration Tests', () => { await Promise.all( transactionData.map((tr) => { - return testDB.createTransaction(userId, tr); + return testDB.createTransaction(userId, commodityId, tr); }), ); @@ -718,9 +725,15 @@ describe('Transactions Integration Tests', () => { }); it('should not return soft-deleted (tombstone) transactions', async () => { - const transactionToBeDeleted = await testDB.createTransaction(userId); + const transactionToBeDeleted = await testDB.createTransaction( + userId, + commodityId, + ); - const otherTransaction = await testDB.createTransaction(userId); + const otherTransaction = await testDB.createTransaction( + userId, + commodityId, + ); await testDB.softDeleteTransaction(transactionToBeDeleted.id); @@ -751,7 +764,10 @@ describe('Transactions Integration Tests', () => { }); it('should not return soft-deleted (tombstone) operations in transactions', async () => { - const createdTransaction = await testDB.createTransaction(userId); + const createdTransaction = await testDB.createTransaction( + userId, + commodityId, + ); const deletedOperationsData = [ { @@ -850,8 +866,14 @@ describe('Transactions Integration Tests', () => { }); it('should return transactions filtered by accountId with all active operations', async () => { - const matchingTransaction = await testDB.createTransaction(userId); - const otherTransaction = await testDB.createTransaction(userId); + const matchingTransaction = await testDB.createTransaction( + userId, + commodityId, + ); + const otherTransaction = await testDB.createTransaction( + userId, + commodityId, + ); const matchingOperations = [ { @@ -922,7 +944,7 @@ describe('Transactions Integration Tests', () => { await Promise.all( transactionDates.map(([description, transactionDate]) => - testDB.createTransaction(userId, { + testDB.createTransaction(userId, commodityId, { description, transactionDate: DateValue.restore(transactionDate).valueOf(), }), @@ -956,7 +978,7 @@ describe('Transactions Integration Tests', () => { await Promise.all( transactionDates.map(([description, transactionDate]) => - testDB.createTransaction(userId, { + testDB.createTransaction(userId, commodityId, { description, transactionDate: DateValue.restore(transactionDate).valueOf(), }), @@ -1007,7 +1029,7 @@ describe('Transactions Integration Tests', () => { describe('DELETE /api/transactions/:id', () => { it('should delete an existing transaction and return 204', async () => { const [account1, account2] = await createAccounts(); - const transaction = await testDB.createTransaction(userId); + const transaction = await testDB.createTransaction(userId, commodityId); const operations = await Promise.all([ testDB.createOperation(userId, { accountId: account1.id, @@ -1071,7 +1093,7 @@ describe('Transactions Integration Tests', () => { }); it('should return 404 when deleting an already deleted transaction', async () => { - const transaction = await testDB.createTransaction(userId); + const transaction = await testDB.createTransaction(userId, commodityId); await testDB.softDeleteTransaction(transaction.id); @@ -1087,7 +1109,7 @@ describe('Transactions Integration Tests', () => { }); it('should return 401 when deleting without authorization', async () => { - const transaction = await testDB.createTransaction(userId); + const transaction = await testDB.createTransaction(userId, commodityId); const response = await server.inject({ method: 'DELETE', @@ -1098,7 +1120,7 @@ describe('Transactions Integration Tests', () => { }); it('should not return deleted transaction in GET /api/transactions', async () => { - const transaction = await testDB.createTransaction(userId); + const transaction = await testDB.createTransaction(userId, commodityId); await testDB.softDeleteTransaction(transaction.id); @@ -1128,7 +1150,10 @@ describe('Transactions Integration Tests', () => { password: 'password123', }); - const otherUserTransaction = await testDB.createTransaction(otherUser.id); + const otherUserTransaction = await testDB.createTransaction( + otherUser.id, + commodityId, + ); const response = await server.inject({ headers: { @@ -1195,7 +1220,10 @@ describe('Transactions Integration Tests', () => { }); it('should update metadata without changing operations', async () => { - const transaction = await testDB.createTransactionWithOperations(userId); + const transaction = await testDB.createTransactionWithOperations( + userId, + commodityId, + ); await testDB.createOperation(userId, { ...operation1Data, @@ -1248,7 +1276,10 @@ describe('Transactions Integration Tests', () => { }); it('should create operations without changing existing operations', async () => { - const transaction = await testDB.createTransactionWithOperations(userId); + const transaction = await testDB.createTransactionWithOperations( + userId, + commodityId, + ); const createdOperations = await Promise.all([ testDB.createOperation(userId, { @@ -1325,7 +1356,10 @@ describe('Transactions Integration Tests', () => { }); it('should update existing operations and persist their changes', async () => { - const transaction = await testDB.createTransactionWithOperations(userId); + const transaction = await testDB.createTransactionWithOperations( + userId, + commodityId, + ); const createdOperations = await Promise.all([ testDB.createOperation(userId, { @@ -1399,7 +1433,10 @@ describe('Transactions Integration Tests', () => { }); it('should apply create, update, and delete operations in one request', async () => { - const transaction = await testDB.createTransactionWithOperations(userId); + const transaction = await testDB.createTransactionWithOperations( + userId, + commodityId, + ); const createdOperations = await Promise.all([ testDB.createOperation(userId, { @@ -1508,7 +1545,10 @@ describe('Transactions Integration Tests', () => { }); it('should delete operations from the active response', async () => { - const transaction = await testDB.createTransactionWithOperations(userId); + const transaction = await testDB.createTransactionWithOperations( + userId, + commodityId, + ); const createdOperations = await Promise.all([ testDB.createOperation(userId, { @@ -1641,8 +1681,11 @@ describe('Transactions Integration Tests', () => { ])( 'should reject $name without persisting partial changes', async ({ buildOperations }) => { - const transaction = - await testDB.createTransactionWithOperations(userId); + const transaction = await testDB.createTransactionWithOperations( + userId, + commodityId, + ); + const createdOperations = await Promise.all([ testDB.createOperation(userId, { ...operation1Data, @@ -1707,7 +1750,7 @@ describe('Transactions Integration Tests', () => { }); it('should return 404 when updating a soft-deleted transaction', async () => { - const transaction = await testDB.createTransaction(userId); + const transaction = await testDB.createTransaction(userId, commodityId); await testDB.softDeleteTransaction(transaction.id); @@ -1720,7 +1763,10 @@ describe('Transactions Integration Tests', () => { it("should return 404 when updating another user's transaction", async () => { const another = await testDB.createUser(); - const transaction = await testDB.createTransaction(another.id); + const transaction = await testDB.createTransaction( + another.id, + commodityId, + ); const updatedData = createUpdateRequest(); @@ -1730,26 +1776,34 @@ describe('Transactions Integration Tests', () => { }); it("should return 404 when creating operations with another user's account", async () => { - const transaction = await testDB.createTransactionWithOperations(userId, { - operations: [ - { - ...operation1Data, - id: Id.create().valueOf(), - }, - { - ...operation2Data, - id: Id.create().valueOf(), - }, - ], - }); + const transaction = await testDB.createTransactionWithOperations( + userId, + commodityId, + { + operations: [ + { + ...operation1Data, + id: Id.create().valueOf(), + }, + { + ...operation2Data, + id: Id.create().valueOf(), + }, + ], + }, + ); const createdOperations = transaction.operations; const otherUser = await testDB.createUser(); + const otherUserCommodity = await testDB.createCommodity(otherUser.id); - const otherUserAccount = await testDB.createAccount(otherUser.id, { - currency: Currency.create('USD').valueOf(), - name: 'Other User Account', - }); + const otherUserAccount = await testDB.createAccount( + otherUser.id, + otherUserCommodity.id, + { + name: 'Other User Account', + }, + ); const updatedData = createUpdateRequest({ description: 'Should not be persisted', @@ -1809,26 +1863,34 @@ describe('Transactions Integration Tests', () => { }); it("should return 404 when updating operations to another user's account", async () => { - const transaction = await testDB.createTransactionWithOperations(userId, { - operations: [ - { - ...operation1Data, - id: Id.create().valueOf(), - }, - { - ...operation2Data, - id: Id.create().valueOf(), - }, - ], - }); + const transaction = await testDB.createTransactionWithOperations( + userId, + commodityId, + { + operations: [ + { + ...operation1Data, + id: Id.create().valueOf(), + }, + { + ...operation2Data, + id: Id.create().valueOf(), + }, + ], + }, + ); const createdOperations = transaction.operations; const otherUser = await testDB.createUser(); + const otherUserCommodity = await testDB.createCommodity(otherUser.id); - const otherUserAccount = await testDB.createAccount(otherUser.id, { - currency: Currency.create('USD').valueOf(), - name: 'Other User Account', - }); + const otherUserAccount = await testDB.createAccount( + otherUser.id, + otherUserCommodity.id, + { + name: 'Other User Account', + }, + ); const updatedData = createUpdateRequest({ description: 'Should not be persisted', @@ -1883,7 +1945,10 @@ describe('Transactions Integration Tests', () => { }); it('should return 400 for invalid payload (missing required fields)', async () => { - const transaction = await testDB.createTransactionWithOperations(userId); + const transaction = await testDB.createTransactionWithOperations( + userId, + commodityId, + ); const invalidPayload = { description: 'Updated description', @@ -1908,7 +1973,10 @@ describe('Transactions Integration Tests', () => { }); it('should return 401 when not authorized', async () => { - const transaction = await testDB.createTransactionWithOperations(userId); + const transaction = await testDB.createTransactionWithOperations( + userId, + commodityId, + ); const updatedData = createUpdateRequest(); @@ -1922,23 +1990,27 @@ describe('Transactions Integration Tests', () => { }); it('should return 409 on optimistic locking conflict with a stale version', async () => { - const transaction = await testDB.createTransactionFromSeed(userId, { - description: 'Transaction for optimistic locking', - operations: [ - { - account: account1Data, - amount: '-100', - description: 'Transfer from checking', - value: '-100', - }, - { - account: account2Data, - amount: '100', - description: 'Transfer to savings', - value: '100', - }, - ], - }); + const transaction = await testDB.createTransactionFromSeed( + userId, + commodityId, + { + description: 'Transaction for optimistic locking', + operations: [ + { + account: account1Data, + amount: '-100', + description: 'Transfer from checking', + value: '-100', + }, + { + account: account2Data, + amount: '100', + description: 'Transfer to savings', + value: '100', + }, + ], + }, + ); const firstUpdate = createUpdateRequest({ description: 'First successful update', @@ -1993,7 +2065,10 @@ describe('Transactions Integration Tests', () => { }); it('should return 400 when resulting operations are unbalanced (sum != 0)', async () => { - const transaction = await testDB.createTransactionWithOperations(userId); + const transaction = await testDB.createTransactionWithOperations( + userId, + commodityId, + ); const operationToUpdate = await testDB.createOperation(userId, { ...operation1Data, diff --git a/apps/frontend/README.md b/apps/frontend/README.md index 14478299..16b0be9f 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -1,56 +1,16 @@ # Ledgerly Frontend -React-based frontend application for personal finance management. +This package is intentionally reduced to a placeholder React app. -## Tech Stack - -- **React 19** with TypeScript -- **TanStack Router** for routing -- **MobX** for state management -- **React Hook Form** for form handling -- **Tailwind CSS + DaisyUI** for styling -- **Vite** for build tooling - -## Architecture - -### Project Structure -``` -src/ - entities/ # Business entities and models - features/ # Feature-specific components - pages/ # Page components - routes/ # Route definitions (TanStack Router) - shared/ # Shared utilities and components - widgets/ # Complex reusable components -``` - -### State Management -- **MobX** stores for reactive state management -- **mobx-react-lite** for React integration -- Stores organized by domain entities - -### Routing -- File-based routing with TanStack Router -- Type-safe navigation -- Automatic route generation +The previous frontend was removed because it drifted from the current backend +domain model. New frontend work should start from `src/App.tsx` and add +frameworks, routing, state management, and API clients back only when they are +needed. ## Development ```bash -# Install dependencies -pnpm install - -# Start development server -pnpm --filter frontend dev - -# Build for production -pnpm --filter frontend build +pnpm --filter @ledgerly/frontend dev +pnpm --filter @ledgerly/frontend build +pnpm --filter @ledgerly/frontend ts-check ``` - -## Key Features - -- Account management (Asset, Liability, Income, Expense) -- Transaction tracking with split entries -- Multi-currency support -- Income/Expense account-based classification -- User profile management diff --git a/apps/frontend/eslint.config.js b/apps/frontend/eslint.config.js index fe92d685..357ba227 100644 --- a/apps/frontend/eslint.config.js +++ b/apps/frontend/eslint.config.js @@ -4,7 +4,6 @@ import reactHooks from 'eslint-plugin-react-hooks'; import reactRefresh from 'eslint-plugin-react-refresh'; import tseslint from 'typescript-eslint'; import react from 'eslint-plugin-react'; -import pluginRouter from '@tanstack/eslint-plugin-router'; import { baseConfig, commonIgnores } from '../../eslint.base.config.js'; export default tseslint.config( @@ -15,7 +14,6 @@ export default tseslint.config( js.configs.recommended, ...tseslint.configs.recommendedTypeChecked, ...tseslint.configs.stylisticTypeChecked, - ...pluginRouter.configs['flat/recommended'], ], settings: { react: { diff --git a/apps/frontend/package.json b/apps/frontend/package.json index b9a60c4a..cefcfc4b 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -12,28 +12,13 @@ "ts-check": "tsc -b --noEmit" }, "dependencies": { - "@hookform/resolvers": "^5.0.1", - "@ledgerly/shared": "workspace:*", - "@tailwindcss/vite": "^4.1.5", - "@tanstack/react-router": "^1.119.0", - "@tanstack/react-router-devtools": "^1.119.1", - "clsx": "^2.1.1", - "lucide-react": "^0.507.0", - "mobx": "^6.13.7", - "mobx-react-lite": "^4.1.0", "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-hook-form": "^7.56.2", - "tailwind-merge": "^3.3.0", - "tailwindcss": "^4.1.5" + "react-dom": "^19.0.0" }, "devDependencies": { - "@tanstack/eslint-plugin-router": "^1.115.0", - "@tanstack/router-plugin": "^1.119.0", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", - "daisyui": "^5.0.35", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx new file mode 100644 index 00000000..7a470b0e --- /dev/null +++ b/apps/frontend/src/App.tsx @@ -0,0 +1,12 @@ +export const App = () => ( +
+
+

Ledgerly frontend

+

Frontend rebuild in progress

+

+ The previous UI was removed because it no longer matches the backend domain model. A new frontend will be built + here from scratch. +

+
+
+); diff --git a/apps/frontend/src/entities/accounts/api/accountActions.ts b/apps/frontend/src/entities/accounts/api/accountActions.ts deleted file mode 100644 index e5ffcae8..00000000 --- a/apps/frontend/src/entities/accounts/api/accountActions.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ROUTES } from '@ledgerly/shared/routes'; -import { AccountCreateDTO, AccountResponseDTO } from '@ledgerly/shared/types'; -import { baseActions } from 'src/shared/api'; - -const URL = ROUTES.accounts; - -export const accountActions = baseActions(URL); diff --git a/apps/frontend/src/entities/accounts/model/accountsState.ts b/apps/frontend/src/entities/accounts/model/accountsState.ts deleted file mode 100644 index e8d2725b..00000000 --- a/apps/frontend/src/entities/accounts/model/accountsState.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { AccountResponseDTO, AccountCreateDTO } from '@ledgerly/shared/types'; -import { makeAutoObservable, runInAction } from 'mobx'; -import { ModalState } from 'src/shared/lib/modalState'; - -import { accountActions } from '../api/accountActions'; - -class AccountsState { - data: AccountResponseDTO[] = []; - currentAccount: AccountResponseDTO | null = null; - - modalState = new ModalState(); - constructor() { - makeAutoObservable(this); - } - - resetCurrentAccount = () => { - this.currentAccount = null; - }; - - getAll = async () => { - const response = await accountActions.read(); - - runInAction(() => { - if (response.ok) { - this.data = response.data; - } else { - console.error(response.error); - } - }); - }; - - create = async (account: AccountCreateDTO) => { - const response = await accountActions.create(account); - - runInAction(() => { - if (response.ok) { - this.data.push(response.data); - } else { - console.error(response.error); - } - }); - }; - - update = async (id: string, account: AccountCreateDTO) => { - const response = await accountActions.update(id, account); - - runInAction(() => { - if (response.ok) { - const index = this.data.findIndex((a) => a.id === id); - if (index !== -1) { - this.data[index] = response.data; - } - } else { - console.error(response.error); - } - }); - }; - - delete = async (id: string) => { - const response = await accountActions.delete(id); - - runInAction(() => { - if (response.ok) { - this.data = this.data.filter((a) => a.id !== id); - } else { - console.error(response.error); - } - }); - }; - - getById = (id: string) => { - const account = this.data.find((account) => account.id === id); - if (account) { - this.currentAccount = account; - return; - } - }; -} - -export const accountsState = new AccountsState(); diff --git a/apps/frontend/src/entities/accounts/ui/AccountsList/AccountsList.tsx b/apps/frontend/src/entities/accounts/ui/AccountsList/AccountsList.tsx deleted file mode 100644 index cf941a55..00000000 --- a/apps/frontend/src/entities/accounts/ui/AccountsList/AccountsList.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { JSX, useEffect } from 'react'; - -import { AccountResponseDTO } from '@ledgerly/shared/types'; -import { Link } from '@tanstack/react-router'; -import { Wallet, Banknote, CreditCard, Pencil } from 'lucide-react'; -import { observer } from 'mobx-react-lite'; -import { Button } from 'src/shared/ui/Button'; - -import { accountsState } from '../../model/accountsState'; - -const iconClassName = 'w-5 h-5'; - -const accountIcons: Record = { - bank: , - cash: , - credit: , - default: , -}; - -const getAccountIcon = (type: string) => accountIcons[type] || accountIcons.default; - -type AccountListItemProps = { - account: AccountResponseDTO; -}; - -const AccountListItem = ({ account }: AccountListItemProps) => { - const handleClick = () => { - accountsState.getById(account.id); - accountsState.modalState.open(); - }; - - return ( -
  • - - {getAccountIcon(account.type)} - {account.name} - - -
  • - ); -}; - -export const AccountsList = observer(() => { - useEffect(() => { - void accountsState.getAll(); - }, []); - - return ( -
    -

    Accounts List

    -
      - {accountsState.data.map((account) => ( - - ))} -
    -
    - ); -}); diff --git a/apps/frontend/src/entities/accounts/ui/AccountsList/index.ts b/apps/frontend/src/entities/accounts/ui/AccountsList/index.ts deleted file mode 100644 index 5b177876..00000000 --- a/apps/frontend/src/entities/accounts/ui/AccountsList/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AccountsList } from './AccountsList'; diff --git a/apps/frontend/src/entities/currencies/api/currenciesActions.ts b/apps/frontend/src/entities/currencies/api/currenciesActions.ts deleted file mode 100644 index 63206ad1..00000000 --- a/apps/frontend/src/entities/currencies/api/currenciesActions.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ROUTES } from '@ledgerly/shared/routes'; -import { Currency } from '@ledgerly/shared/types'; -import { baseActions } from 'src/shared/api'; - -const URL = ROUTES.currencies; - -export const currencyActions = baseActions(URL); diff --git a/apps/frontend/src/entities/currencies/index.ts b/apps/frontend/src/entities/currencies/index.ts deleted file mode 100644 index 046ed878..00000000 --- a/apps/frontend/src/entities/currencies/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { currencyState } from './model/currencyState'; diff --git a/apps/frontend/src/entities/currencies/model/currencyState.ts b/apps/frontend/src/entities/currencies/model/currencyState.ts deleted file mode 100644 index eafc113d..00000000 --- a/apps/frontend/src/entities/currencies/model/currencyState.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Currency } from '@ledgerly/shared/types'; -import { runInAction } from 'mobx'; - -import { currencyActions } from '../api/currenciesActions'; - -class CurrencyState { - currencies: Currency[] = []; - - getAll = async () => { - const response = await currencyActions.read(); - - runInAction(() => { - if (response.ok) { - this.currencies = response.data; - } else { - console.error(response.error); - } - }); - }; -} - -export const currencyState = new CurrencyState(); diff --git a/apps/frontend/src/entities/transactions/api/transactionsActions.ts b/apps/frontend/src/entities/transactions/api/transactionsActions.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/frontend/src/entities/transactions/model/transactionsState.ts b/apps/frontend/src/entities/transactions/model/transactionsState.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/frontend/src/features/ConfirmDialog/index.ts b/apps/frontend/src/features/ConfirmDialog/index.ts deleted file mode 100644 index 5f4e7848..00000000 --- a/apps/frontend/src/features/ConfirmDialog/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { ConfirmDialog } from './ui/ConfirmDialog'; -export { confirmDialogState } from './model/confirmDialogState'; diff --git a/apps/frontend/src/features/ConfirmDialog/model/confirmDialogState.ts b/apps/frontend/src/features/ConfirmDialog/model/confirmDialogState.ts deleted file mode 100644 index bbe86c84..00000000 --- a/apps/frontend/src/features/ConfirmDialog/model/confirmDialogState.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { makeAutoObservable } from 'mobx'; - -type ConfirmDialogConfig = { - title: string; - message: string; - onConfirm: () => void; - cancelText?: string; - confirmText?: string; -}; - -class ConfirmDialogState { - isOpen = false; - config: ConfirmDialogConfig | null = null; - - constructor() { - makeAutoObservable(this); - } - - open = (config: ConfirmDialogConfig) => { - this.config = config; - this.isOpen = true; - }; - - close = () => { - this.isOpen = false; - this.config = null; - }; - - confirm = () => { - if (this.config?.onConfirm) { - this.config.onConfirm(); - } - this.close(); - }; -} - -export const confirmDialogState = new ConfirmDialogState(); diff --git a/apps/frontend/src/features/ConfirmDialog/ui/ConfirmDialog.tsx b/apps/frontend/src/features/ConfirmDialog/ui/ConfirmDialog.tsx deleted file mode 100644 index d541cedd..00000000 --- a/apps/frontend/src/features/ConfirmDialog/ui/ConfirmDialog.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { AlertTriangle } from 'lucide-react'; -import { observer } from 'mobx-react-lite'; -import { Button } from 'src/shared/ui/Button'; -import { Modal } from 'src/shared/ui/Modal/Modal'; - -import { confirmDialogState } from '../model/confirmDialogState'; - -export const ConfirmDialog = observer(() => { - const { close, config, confirm, isOpen } = confirmDialogState; - - if (!config) return null; - - const { cancelText = 'Отмена', confirmText = 'Удалить', message, title } = config; - - return ( - - -
    - -

    {title}

    -
    -
    - -

    {message}

    -
    - - - - -
    - ); -}); diff --git a/apps/frontend/src/features/ManageAccount/ui/ManageAccountForm.tsx b/apps/frontend/src/features/ManageAccount/ui/ManageAccountForm.tsx deleted file mode 100644 index 7e6dd822..00000000 --- a/apps/frontend/src/features/ManageAccount/ui/ManageAccountForm.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { zodResolver } from '@hookform/resolvers/zod'; -import { ACCOUNT_TYPES } from '@ledgerly/shared/constants'; -import { AccountType, Currency } from '@ledgerly/shared/types'; -import { AccountCreateDTO } from '@ledgerly/shared/types'; -import { accountCreateSchema } from '@ledgerly/shared/validation'; -import { observer } from 'mobx-react-lite'; -import { useForm } from 'react-hook-form'; -import { accountsState } from 'src/entities/accounts/model/accountsState'; -import { currencyState } from 'src/entities/currencies'; - -type ManageAccountFormProps = { - formId: string; -}; - -// TODO: Move to constants -const optionMapping: Record = { - cash: 'Наличные', - credit: 'Кредитная карта', - debit: 'Дебетовая карта', - investment: 'Инвестиционный счет', - savings: 'Сберегательный счет', -}; - -// TODO: Move to constants -const currencyMapping: Record = { - AUD: 'Австралийский доллар', - CAD: 'Канадский доллар', - CHF: 'Швейцарский франк', - CNY: 'Китайский юань', - EUR: 'Евро', - GBP: 'Британский фунт стерлингов', - INR: 'Индийская рупия', - JPY: 'Японская иена', - RUB: 'Российский рубль', - USD: 'Доллар США', -}; - -const options = Object.entries(optionMapping).map(([value, label]) => ({ - label, - value, -})); - -const CURRENCIES = Object.entries(currencyMapping).map(([code, name]) => ({ - code, - name, -})); - -export const ManageAccountForm = observer((props: ManageAccountFormProps) => { - const { formId } = props; - const { currentAccount, modalState } = accountsState; - - const CURRENCY_TYPES = currencyState.currencies; - - const isEditMode = Boolean(currentAccount); - - const { - formState: { errors }, - handleSubmit, - register, - } = useForm({ - resolver: zodResolver(accountCreateSchema), - values: { - currencyCode: currentAccount?.currencyCode ?? CURRENCY_TYPES[0].code, - description: currentAccount?.description ?? '', - initialBalance: 0, - name: currentAccount?.name ?? '', - type: currentAccount?.type ?? ACCOUNT_TYPES[0], - }, - }); - - const handleClose = () => { - accountsState.resetCurrentAccount(); - modalState.close(); - }; - - const handleFormSubmit = (data: AccountCreateDTO) => { - try { - if (currentAccount) { - void accountsState.update(currentAccount.id, data); - } else { - void accountsState.create(data); - } - handleClose(); - } catch (error) { - console.error('Failed to save account:', error); - } - }; - - const onSubmit = (e: React.FormEvent) => { - e.preventDefault(); - void handleSubmit(handleFormSubmit)(e); - }; - - return ( -
    -
    - - - {errors.name && {errors.name.message}} -
    - -
    - - - {errors.description && {errors.description.message}} -
    - -
    - - - {errors.type && {errors.type.message}} -
    - -
    - - - {errors.currencyCode && {errors.currencyCode.message}} -
    - - {!isEditMode && ( -
    - - - {errors.initialBalance && {errors.initialBalance.message}} -
    - )} -
    - ); -}); diff --git a/apps/frontend/src/features/ManageAccount/ui/ManageAccountModal.tsx b/apps/frontend/src/features/ManageAccount/ui/ManageAccountModal.tsx deleted file mode 100644 index 8359605e..00000000 --- a/apps/frontend/src/features/ManageAccount/ui/ManageAccountModal.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { observer } from 'mobx-react-lite'; -import { accountsState } from 'src/entities/accounts/model/accountsState'; -import { confirmDialogState } from 'src/features/ConfirmDialog'; -import { Button } from 'src/shared/ui/Button'; -import { Modal } from 'src/shared/ui/Modal/Modal'; - -import { ManageAccountForm } from './ManageAccountForm'; - -export const ManageAccountModal = observer(() => { - const { currentAccount, modalState } = accountsState; - const isEditMode = Boolean(currentAccount); - - const FORM_ID = 'manage-account-form'; - - if (!modalState.isOpen) { - return null; - } - - const title = currentAccount ? 'Редактировать счет' : 'Добавить новый счет'; - - const handleClose = () => { - accountsState.resetCurrentAccount(); - modalState.close(); - }; - - const onDeleteClick = () => { - if (!currentAccount) return; - - confirmDialogState.open({ - message: `Вы уверены, что хотите удалить счет "${currentAccount.name}"?`, - onConfirm: () => { - void accountsState.delete(currentAccount.id); - handleClose(); - }, - title: 'Удалить счет', - }); - }; - - return ( - - - - - - - {isEditMode && ( - - )} -
    - - -
    -
    -
    - ); -}); diff --git a/apps/frontend/src/features/ManageAccount/ui/index.ts b/apps/frontend/src/features/ManageAccount/ui/index.ts deleted file mode 100644 index 98c83de2..00000000 --- a/apps/frontend/src/features/ManageAccount/ui/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ManageAccountModal } from './ManageAccountModal'; diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css index 36205b34..c38f457b 100644 --- a/apps/frontend/src/index.css +++ b/apps/frontend/src/index.css @@ -1,52 +1,63 @@ -@import 'tailwindcss'; - -@plugin "daisyui" { - themes: corporate --default; -} - -@source inline('btn btn-neutral btn-primary btn-secondary btn-accent btn-info btn-success btn-warning btn-error btn-outline btn-dash btn-soft btn-ghost btn-link btn-active btn-disabled btn-xs btn-sm btn-md btn-lg btn-xl btn-wide btn-block btn-square btn-circle'); - -/* DaisyUI plugin should be configured in the Tailwind CSS configuration file */ - -@layer components { - .g { - outline: 1px solid green; - } - - .r { - outline: 1px solid red; - } - - .b { - outline: 1px solid blue; - } - - .y { - outline: 1px solid yellow; - } -} - :root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; + color: #17202a; + background: #f6f7f9; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", sans-serif; font-weight: 400; - color: black; - - background-color: #242424; - + line-height: 1.5; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; +} - width: 100%; +* { + box-sizing: border-box; } body { margin: 0; - display: flex; - place-items: center; min-width: 320px; min-height: 100vh; - position: relative; +} + +button, +input, +textarea, +select { + font: inherit; +} + +.app-shell { + display: grid; + min-height: 100vh; + place-items: center; + padding: 24px; +} + +.placeholder { + max-width: 560px; + width: 100%; +} + +.eyebrow { + color: #4f46e5; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + margin: 0 0 12px; + text-transform: uppercase; +} + +h1 { + font-size: clamp(2rem, 4vw, 3.5rem); + line-height: 1.05; + margin: 0 0 16px; +} + +.placeholder p:last-child { + color: #4b5563; + font-size: 1.0625rem; + margin: 0; } diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index 183c8cdb..c80cf205 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -1,24 +1,9 @@ import { StrictMode } from 'react'; import ReactDOM from 'react-dom/client'; -import { RouterProvider, createRouter } from '@tanstack/react-router'; - import './index.css'; +import { App } from './App'; -// Import the generated route tree -import { routeTree } from './routeTree.gen'; - -// Create a new router instance -const router = createRouter({ routeTree }); - -// Register the router instance for type safety -declare module '@tanstack/react-router' { - interface Register { - router: typeof router; - } -} - -// Render the app const rootElement = document.getElementById('root')!; if (!rootElement.innerHTML) { @@ -26,7 +11,7 @@ if (!rootElement.innerHTML) { root.render( - + ); } diff --git a/apps/frontend/src/pages/accountPage/accountPage.tsx b/apps/frontend/src/pages/accountPage/accountPage.tsx deleted file mode 100644 index 9fad848c..00000000 --- a/apps/frontend/src/pages/accountPage/accountPage.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useParams } from '@tanstack/react-router'; - -export const AccountPage = () => { - const { accountId } = useParams({ strict: false }); - - return ( -
    -

    Welcome to the Account Page

    -

    This is the account page of the application.

    -

    Account ID: {accountId}

    -
    - ); -}; diff --git a/apps/frontend/src/pages/accountPage/index.ts b/apps/frontend/src/pages/accountPage/index.ts deleted file mode 100644 index f9fb654b..00000000 --- a/apps/frontend/src/pages/accountPage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AccountPage } from './accountPage'; diff --git a/apps/frontend/src/pages/accountsPage/accountsPage.tsx b/apps/frontend/src/pages/accountsPage/accountsPage.tsx deleted file mode 100644 index 8e1f3406..00000000 --- a/apps/frontend/src/pages/accountsPage/accountsPage.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export const AccountsPage = () => { - return ( -
    -

    Accounts

    -

    List of accounts will be displayed here.

    -
    - ); -}; diff --git a/apps/frontend/src/pages/accountsPage/index.ts b/apps/frontend/src/pages/accountsPage/index.ts deleted file mode 100644 index c3680ccd..00000000 --- a/apps/frontend/src/pages/accountsPage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AccountsPage } from './accountsPage'; diff --git a/apps/frontend/src/pages/indexPage/index.ts b/apps/frontend/src/pages/indexPage/index.ts deleted file mode 100644 index b7d9ae89..00000000 --- a/apps/frontend/src/pages/indexPage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { IndexPage } from './indexPage'; diff --git a/apps/frontend/src/pages/indexPage/indexPage.tsx b/apps/frontend/src/pages/indexPage/indexPage.tsx deleted file mode 100644 index 3ccf7fd9..00000000 --- a/apps/frontend/src/pages/indexPage/indexPage.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export const IndexPage = () => { - return ( -
    -

    Welcome to the Index Page

    -

    This is the main page of the application.

    -
    - ); -}; diff --git a/apps/frontend/src/routeTree.gen.ts b/apps/frontend/src/routeTree.gen.ts deleted file mode 100644 index 090c36db..00000000 --- a/apps/frontend/src/routeTree.gen.ts +++ /dev/null @@ -1,222 +0,0 @@ -/* eslint-disable */ - -// @ts-nocheck - -// noinspection JSUnusedGlobalSymbols - -// This file was automatically generated by TanStack Router. -// You should NOT make any changes in this file as it will be overwritten. -// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. - -// Import Routes - -import { Route as rootRoute } from './routes/__root' -import { Route as TransactionsImport } from './routes/transactions' -import { Route as BudgetImport } from './routes/budget' -import { Route as AboutImport } from './routes/about' -import { Route as IndexImport } from './routes/index' -import { Route as AccountsIndexImport } from './routes/accounts/index' -import { Route as AccountsAccountIdImport } from './routes/accounts/$accountId' - -// Create/Update Routes - -const TransactionsRoute = TransactionsImport.update({ - id: '/transactions', - path: '/transactions', - getParentRoute: () => rootRoute, -} as any) - -const BudgetRoute = BudgetImport.update({ - id: '/budget', - path: '/budget', - getParentRoute: () => rootRoute, -} as any) - -const AboutRoute = AboutImport.update({ - id: '/about', - path: '/about', - getParentRoute: () => rootRoute, -} as any) - -const IndexRoute = IndexImport.update({ - id: '/', - path: '/', - getParentRoute: () => rootRoute, -} as any) - -const AccountsIndexRoute = AccountsIndexImport.update({ - id: '/accounts/', - path: '/accounts/', - getParentRoute: () => rootRoute, -} as any) - -const AccountsAccountIdRoute = AccountsAccountIdImport.update({ - id: '/accounts/$accountId', - path: '/accounts/$accountId', - getParentRoute: () => rootRoute, -} as any) - -// Populate the FileRoutesByPath interface - -declare module '@tanstack/react-router' { - interface FileRoutesByPath { - '/': { - id: '/' - path: '/' - fullPath: '/' - preLoaderRoute: typeof IndexImport - parentRoute: typeof rootRoute - } - '/about': { - id: '/about' - path: '/about' - fullPath: '/about' - preLoaderRoute: typeof AboutImport - parentRoute: typeof rootRoute - } - '/budget': { - id: '/budget' - path: '/budget' - fullPath: '/budget' - preLoaderRoute: typeof BudgetImport - parentRoute: typeof rootRoute - } - '/transactions': { - id: '/transactions' - path: '/transactions' - fullPath: '/transactions' - preLoaderRoute: typeof TransactionsImport - parentRoute: typeof rootRoute - } - '/accounts/$accountId': { - id: '/accounts/$accountId' - path: '/accounts/$accountId' - fullPath: '/accounts/$accountId' - preLoaderRoute: typeof AccountsAccountIdImport - parentRoute: typeof rootRoute - } - '/accounts/': { - id: '/accounts/' - path: '/accounts' - fullPath: '/accounts' - preLoaderRoute: typeof AccountsIndexImport - parentRoute: typeof rootRoute - } - } -} - -// Create and export the route tree - -export interface FileRoutesByFullPath { - '/': typeof IndexRoute - '/about': typeof AboutRoute - '/budget': typeof BudgetRoute - '/transactions': typeof TransactionsRoute - '/accounts/$accountId': typeof AccountsAccountIdRoute - '/accounts': typeof AccountsIndexRoute -} - -export interface FileRoutesByTo { - '/': typeof IndexRoute - '/about': typeof AboutRoute - '/budget': typeof BudgetRoute - '/transactions': typeof TransactionsRoute - '/accounts/$accountId': typeof AccountsAccountIdRoute - '/accounts': typeof AccountsIndexRoute -} - -export interface FileRoutesById { - __root__: typeof rootRoute - '/': typeof IndexRoute - '/about': typeof AboutRoute - '/budget': typeof BudgetRoute - '/transactions': typeof TransactionsRoute - '/accounts/$accountId': typeof AccountsAccountIdRoute - '/accounts/': typeof AccountsIndexRoute -} - -export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: - | '/' - | '/about' - | '/budget' - | '/transactions' - | '/accounts/$accountId' - | '/accounts' - fileRoutesByTo: FileRoutesByTo - to: - | '/' - | '/about' - | '/budget' - | '/transactions' - | '/accounts/$accountId' - | '/accounts' - id: - | '__root__' - | '/' - | '/about' - | '/budget' - | '/transactions' - | '/accounts/$accountId' - | '/accounts/' - fileRoutesById: FileRoutesById -} - -export interface RootRouteChildren { - IndexRoute: typeof IndexRoute - AboutRoute: typeof AboutRoute - BudgetRoute: typeof BudgetRoute - TransactionsRoute: typeof TransactionsRoute - AccountsAccountIdRoute: typeof AccountsAccountIdRoute - AccountsIndexRoute: typeof AccountsIndexRoute -} - -const rootRouteChildren: RootRouteChildren = { - IndexRoute: IndexRoute, - AboutRoute: AboutRoute, - BudgetRoute: BudgetRoute, - TransactionsRoute: TransactionsRoute, - AccountsAccountIdRoute: AccountsAccountIdRoute, - AccountsIndexRoute: AccountsIndexRoute, -} - -export const routeTree = rootRoute - ._addFileChildren(rootRouteChildren) - ._addFileTypes() - -/* ROUTE_MANIFEST_START -{ - "routes": { - "__root__": { - "filePath": "__root.tsx", - "children": [ - "/", - "/about", - "/budget", - "/transactions", - "/accounts/$accountId", - "/accounts/" - ] - }, - "/": { - "filePath": "index.tsx" - }, - "/about": { - "filePath": "about.tsx" - }, - "/budget": { - "filePath": "budget.tsx" - }, - "/transactions": { - "filePath": "transactions.tsx" - }, - "/accounts/$accountId": { - "filePath": "accounts/$accountId.tsx" - }, - "/accounts/": { - "filePath": "accounts/index.tsx" - } - } -} -ROUTE_MANIFEST_END */ diff --git a/apps/frontend/src/routes/__root.tsx b/apps/frontend/src/routes/__root.tsx deleted file mode 100644 index 16d2c26d..00000000 --- a/apps/frontend/src/routes/__root.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { createRootRoute } from '@tanstack/react-router'; - -import { Layout } from '../shared/ui/Layout'; - -export const Route = createRootRoute({ - component: Layout, -}); diff --git a/apps/frontend/src/routes/about.tsx b/apps/frontend/src/routes/about.tsx deleted file mode 100644 index f11d6b84..00000000 --- a/apps/frontend/src/routes/about.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router'; - -export const Route = createFileRoute('/about')({ - component: About, -}); - -function About() { - return
    Hello from About!
    ; -} diff --git a/apps/frontend/src/routes/accounts/$accountId.tsx b/apps/frontend/src/routes/accounts/$accountId.tsx deleted file mode 100644 index a30632a2..00000000 --- a/apps/frontend/src/routes/accounts/$accountId.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router'; -import { AccountPage } from 'src/pages/accountPage'; - -export const Route = createFileRoute('/accounts/$accountId')({ - component: AccountPage, -}); diff --git a/apps/frontend/src/routes/accounts/index.tsx b/apps/frontend/src/routes/accounts/index.tsx deleted file mode 100644 index 16e1d689..00000000 --- a/apps/frontend/src/routes/accounts/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router'; -import { AccountsPage } from 'src/pages/accountsPage'; -export const Route = createFileRoute('/accounts/')({ - component: AccountsPage, -}); diff --git a/apps/frontend/src/routes/budget.tsx b/apps/frontend/src/routes/budget.tsx deleted file mode 100644 index fce1993e..00000000 --- a/apps/frontend/src/routes/budget.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router'; - -export const Route = createFileRoute('/budget')({ - component: RouteComponent, -}); - -function RouteComponent() { - return
    Hello budget!
    ; -} diff --git a/apps/frontend/src/routes/index.tsx b/apps/frontend/src/routes/index.tsx deleted file mode 100644 index 502300e2..00000000 --- a/apps/frontend/src/routes/index.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router'; -import { IndexPage } from 'src/pages/indexPage'; - -export const Route = createFileRoute('/')({ - component: IndexPage, -}); diff --git a/apps/frontend/src/routes/transactions.tsx b/apps/frontend/src/routes/transactions.tsx deleted file mode 100644 index 604fe03d..00000000 --- a/apps/frontend/src/routes/transactions.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router'; - -export const Route = createFileRoute('/transactions')({ - component: RouteComponent, -}); - -function RouteComponent() { - return
    Hello transactions!
    ; -} diff --git a/apps/frontend/src/shared/api/config.ts b/apps/frontend/src/shared/api/config.ts deleted file mode 100644 index 3b639e06..00000000 --- a/apps/frontend/src/shared/api/config.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { envConfig } from '../config/config'; - -export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; - -const path = '/api'; - -const apiConfig = { - baseUrl: (url: string) => `${path}${url}`, - createAbortController: () => new AbortController(), - headers: { - Accept: 'application/json', - Authorization: `Bearer ${envConfig.API_TOKEN}`, - } as Record, - method: 'GET' as HttpMethod, - timeout: 5000, -}; - -type Response = { data: T; ok: true } | { error: string; ok: false }; - -export const fetchWrapper = async ( - path: string, - options: RequestInit = {}, - controller?: AbortController -): Promise> => { - const url = apiConfig.baseUrl(path); - - const abortController = controller ?? apiConfig.createAbortController(); - const timeoutId = setTimeout(() => abortController.abort(), apiConfig.timeout); - - try { - const headers: Record = { - ...apiConfig.headers, - ...(options.headers as Record), - }; - - const response = await fetch(url, { - ...options, - headers, - signal: abortController.signal, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return { data: (await response.json()) as T, ok: true }; - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - throw new Error('Request timed out'); - } - throw error; - } finally { - clearTimeout(timeoutId); - } -}; - -export type BaseActions = { - create: (data: D) => Promise>; - read: () => Promise>; - update: (id: string, data: D) => Promise>; - delete: (id: string) => Promise>; -}; - -export const baseActions = (url: string): BaseActions => ({ - create: async (data) => { - return await fetchWrapper(url, { - body: JSON.stringify(data), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - }); - }, - delete: async (id: string) => { - return await fetchWrapper(`${url}/${id}`, { - method: 'DELETE', - }); - }, - read: async () => { - return await fetchWrapper(url); - }, - update: async (id: string, data) => { - return await fetchWrapper(`${url}/${id}`, { - body: JSON.stringify(data), - headers: { - 'Content-Type': 'application/json', - }, - method: 'PUT', - }); - }, -}); diff --git a/apps/frontend/src/shared/api/index.ts b/apps/frontend/src/shared/api/index.ts deleted file mode 100644 index 5f6f7c28..00000000 --- a/apps/frontend/src/shared/api/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { baseActions } from './config'; diff --git a/apps/frontend/src/shared/config/config.ts b/apps/frontend/src/shared/config/config.ts deleted file mode 100644 index 27405e17..00000000 --- a/apps/frontend/src/shared/config/config.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const envConfig = { - API_TOKEN: import.meta.env.VITE_API_TOKEN, - API_URL: import.meta.env.VITE_API_URL ?? 'http://localhost:3000', - NODE_ENV: import.meta.env.MODE || 'development', -}; diff --git a/apps/frontend/src/shared/lib/modalState/index.ts b/apps/frontend/src/shared/lib/modalState/index.ts deleted file mode 100644 index b96cb3a6..00000000 --- a/apps/frontend/src/shared/lib/modalState/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ModalState } from './modalState'; diff --git a/apps/frontend/src/shared/lib/modalState/modalState.ts b/apps/frontend/src/shared/lib/modalState/modalState.ts deleted file mode 100644 index 1a619198..00000000 --- a/apps/frontend/src/shared/lib/modalState/modalState.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { makeAutoObservable } from 'mobx'; - -export class ModalState { - isOpen = false; - - constructor() { - makeAutoObservable(this); - } - - open = () => { - this.isOpen = true; - }; - - close = () => { - this.isOpen = false; - }; -} diff --git a/apps/frontend/src/shared/types/index.ts b/apps/frontend/src/shared/types/index.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/frontend/src/shared/ui/Button/Button.tsx b/apps/frontend/src/shared/ui/Button/Button.tsx deleted file mode 100644 index fdbb1430..00000000 --- a/apps/frontend/src/shared/ui/Button/Button.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; - -import clsx from 'clsx'; - -interface ButtonProps extends React.ButtonHTMLAttributes { - variant?: 'primary' | 'secondary' | 'accent' | 'info' | 'success' | 'warning' | 'error' | 'neutral'; - size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; - ghost?: boolean; - link?: boolean; - outline?: boolean; - soft?: boolean; - wide?: boolean; -} - -const buttonClasses = ({ ghost, link, outline, size, soft, variant, wide }: ButtonProps) => { - const baseClass = 'btn'; - const variantClass = variant ? `btn-${variant}` : 'btn-primary'; - const sizeClass = size ? `btn-${size}` : 'btn-md'; - const ghostClass = ghost ? 'btn-ghost' : ''; - const linkClass = link ? 'btn-link' : ''; - const outlineClass = outline ? 'btn-outline' : ''; - const softClass = soft ? 'btn-soft' : ''; - const wideClass = wide ? 'btn-wide' : ''; - - return [baseClass, variantClass, sizeClass, ghostClass, linkClass, outlineClass, softClass, wideClass] - .join(' ') - .trim(); -}; - -export const Button: React.FC = ({ children, className, size = 'md', variant = 'primary', ...props }) => { - const buttonClass = buttonClasses({ ...props, size, variant }); - - return ( - - ); -}; diff --git a/apps/frontend/src/shared/ui/Button/index.ts b/apps/frontend/src/shared/ui/Button/index.ts deleted file mode 100644 index fe9c53c5..00000000 --- a/apps/frontend/src/shared/ui/Button/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Button } from './Button'; diff --git a/apps/frontend/src/shared/ui/CloseButton/CloseButton.tsx b/apps/frontend/src/shared/ui/CloseButton/CloseButton.tsx deleted file mode 100644 index 607da7e8..00000000 --- a/apps/frontend/src/shared/ui/CloseButton/CloseButton.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { X } from 'lucide-react'; - -type CloseButtonProps = { - onClick?: () => void; -}; - -export const CloseButton = ({ onClick }: CloseButtonProps) => { - return ( - - ); -}; diff --git a/apps/frontend/src/shared/ui/CloseButton/index.ts b/apps/frontend/src/shared/ui/CloseButton/index.ts deleted file mode 100644 index 5b054621..00000000 --- a/apps/frontend/src/shared/ui/CloseButton/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CloseButton } from './CloseButton'; diff --git a/apps/frontend/src/shared/ui/Layout/Footer/Footer.tsx b/apps/frontend/src/shared/ui/Layout/Footer/Footer.tsx deleted file mode 100644 index 056b43cc..00000000 --- a/apps/frontend/src/shared/ui/Layout/Footer/Footer.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export const Footer = () => { - return ( -
    -

    © 2025 FinTracker

    -
    - ); -}; diff --git a/apps/frontend/src/shared/ui/Layout/Footer/index.ts b/apps/frontend/src/shared/ui/Layout/Footer/index.ts deleted file mode 100644 index 65e2506f..00000000 --- a/apps/frontend/src/shared/ui/Layout/Footer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Footer } from './Footer'; diff --git a/apps/frontend/src/shared/ui/Layout/Header/Header.tsx b/apps/frontend/src/shared/ui/Layout/Header/Header.tsx deleted file mode 100644 index e2329702..00000000 --- a/apps/frontend/src/shared/ui/Layout/Header/Header.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Link } from '@tanstack/react-router'; - -const links: { to: string; label: string }[] = [ - { label: 'Home', to: '/' }, - { label: 'About', to: '/about' }, -]; - -export const Header = () => { - return ( -
    -

    ФинТрекер

    -
    - {links.map((link) => ( - - {link.label} - - ))} -
    -
    - ); -}; diff --git a/apps/frontend/src/shared/ui/Layout/Header/index.ts b/apps/frontend/src/shared/ui/Layout/Header/index.ts deleted file mode 100644 index 29429dc9..00000000 --- a/apps/frontend/src/shared/ui/Layout/Header/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Header } from './Header'; diff --git a/apps/frontend/src/shared/ui/Layout/Layout.tsx b/apps/frontend/src/shared/ui/Layout/Layout.tsx deleted file mode 100644 index e1a75d46..00000000 --- a/apps/frontend/src/shared/ui/Layout/Layout.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useEffect } from 'react'; - -import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'; -import { currencyState } from 'src/entities/currencies'; -import { ConfirmDialog } from 'src/features/ConfirmDialog'; - -import { Footer } from './Footer'; -import { Header } from './Header'; -import { Main } from './Main'; -import { Sidebar } from './Sidebar/Sidebar'; - -export const Layout = () => { - useEffect(() => { - void currencyState.getAll(); - }, []); - - return ( -
    -
    - -
    -
    - -
    - ); -}; - -export const Layout2 = () => { - return ( -
    -
    -
    - -
    -
    -
    - -
    - ); -}; diff --git a/apps/frontend/src/shared/ui/Layout/Main/Main.tsx b/apps/frontend/src/shared/ui/Layout/Main/Main.tsx deleted file mode 100644 index c2026763..00000000 --- a/apps/frontend/src/shared/ui/Layout/Main/Main.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Outlet } from '@tanstack/react-router'; - -export const Main = () => { - return ( -
    -
    - -
    -
    - ); -}; diff --git a/apps/frontend/src/shared/ui/Layout/Main/index.ts b/apps/frontend/src/shared/ui/Layout/Main/index.ts deleted file mode 100644 index a1a31000..00000000 --- a/apps/frontend/src/shared/ui/Layout/Main/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Main } from './Main'; diff --git a/apps/frontend/src/shared/ui/Layout/Sidebar/Sidebar.tsx b/apps/frontend/src/shared/ui/Layout/Sidebar/Sidebar.tsx deleted file mode 100644 index a401ac99..00000000 --- a/apps/frontend/src/shared/ui/Layout/Sidebar/Sidebar.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { accountsState } from 'src/entities/accounts/model/accountsState'; -import { AccountsList } from 'src/entities/accounts/ui/AccountsList'; -import { ManageAccountModal } from 'src/features/ManageAccount/ui'; - -import { Button } from '../../Button'; - -export const Sidebar = () => { - return ( -
    - - - -
    - ); -}; diff --git a/apps/frontend/src/shared/ui/Layout/Sidebar/index.ts b/apps/frontend/src/shared/ui/Layout/Sidebar/index.ts deleted file mode 100644 index d440597f..00000000 --- a/apps/frontend/src/shared/ui/Layout/Sidebar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Sidebar } from './Sidebar'; diff --git a/apps/frontend/src/shared/ui/Layout/index.ts b/apps/frontend/src/shared/ui/Layout/index.ts deleted file mode 100644 index 9fc685e2..00000000 --- a/apps/frontend/src/shared/ui/Layout/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Layout } from './Layout'; diff --git a/apps/frontend/src/shared/ui/Modal/Modal.tsx b/apps/frontend/src/shared/ui/Modal/Modal.tsx deleted file mode 100644 index 6a0be380..00000000 --- a/apps/frontend/src/shared/ui/Modal/Modal.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { createContext, useContext, type ReactNode } from 'react'; - -import clsx from 'clsx'; -import { CloseButton } from 'src/shared/ui/CloseButton'; - -type MaxWidth = - | 'max-w-sm' - | 'max-w-md' - | 'max-w-lg' - | 'max-w-xl' - | 'max-w-2xl' - | 'max-w-3xl' - | 'max-w-4xl' - | 'max-w-5xl'; - -type ModalContext = { - onClose: () => void | null; -}; - -const ModalContext = createContext(null); - -const useModalContext = () => { - const context = useContext(ModalContext); - - if (!context) { - throw new Error('useModalContext must be used within a ModalProvider'); - } - - return context; -}; - -const ModalProvider = ({ children, onClose }: { children: ReactNode; onClose: () => void | null }) => { - return {children}; -}; - -type ModalHeaderProps = { - children?: ReactNode; - title?: string; - withCloseButton?: boolean; - className?: string; -}; - -const ModalHeader = (props: ModalHeaderProps) => { - const { onClose } = useModalContext(); - const { children, className, title, withCloseButton } = props; - - if (!(title || children)) { - throw new Error('ModalHeader requires either a title or children prop'); - } - - return ( -
    - {title &&

    {title}

    } - {children &&
    {children}
    } - {withCloseButton && } -
    - ); -}; - -type ModalFooterProps = { - children: ReactNode; - className?: string; -}; - -const ModalFooter = (props: ModalFooterProps) => { - const { children, className } = props; - return
    {children}
    ; -}; - -type ModalProps = { - children: ReactNode; - isOpen?: boolean; - onClose: () => void; - zIndex?: 'high' | 'low'; - maxWidth?: MaxWidth; - className?: string; -}; - -const Modal = (props: ModalProps) => { - const { children, className, isOpen, maxWidth = 'max-w-lg', onClose, zIndex } = props; - - if (!isOpen) { - return null; - } - - const getModalClass = () => { - const baseClass = 'modal modal-open'; - if (zIndex === 'high') return `${baseClass} z-[1000]`; - if (zIndex === 'low') return `${baseClass} z-[50]`; - return baseClass; - }; - - // const getBackdropClass = () => { - // const baseClass = 'modal-backdrop'; - // if (zIndex === 'high') return `${baseClass} z-[999]`; - // if (zIndex === 'low') return `${baseClass} z-[49]`; - // return baseClass; - // }; - - return ( - -
    -
    {children}
    - {/*
    -
    - ); -}; - -const ModalBody = ({ children }: { children: ReactNode }) => { - return
    {children}
    ; -}; - -Modal.Header = ModalHeader; -Modal.Body = ModalBody; -Modal.Footer = ModalFooter; -Modal.displayName = 'Modal'; -ModalHeader.displayName = 'ModalHeader'; -ModalFooter.displayName = 'ModalFooter'; - -export { Modal, ModalHeader, ModalBody, ModalFooter }; diff --git a/apps/frontend/src/shared/ui/Modal/index.ts b/apps/frontend/src/shared/ui/Modal/index.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/frontend/src/shared/ui/Modal/modal.css b/apps/frontend/src/shared/ui/Modal/modal.css deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/frontend/src/shared/ui/Navbar/Navbar.tsx b/apps/frontend/src/shared/ui/Navbar/Navbar.tsx deleted file mode 100644 index 61a1de5a..00000000 --- a/apps/frontend/src/shared/ui/Navbar/Navbar.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Link } from '@tanstack/react-router'; - -import { sidebarItems } from './navbarConfig'; - -export const Navbar = () => { - return ( - - ); -}; diff --git a/apps/frontend/src/shared/ui/Navbar/index.ts b/apps/frontend/src/shared/ui/Navbar/index.ts deleted file mode 100644 index ec1cfd76..00000000 --- a/apps/frontend/src/shared/ui/Navbar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Navbar } from './Navbar'; diff --git a/apps/frontend/src/shared/ui/Navbar/navbarConfig.ts b/apps/frontend/src/shared/ui/Navbar/navbarConfig.ts deleted file mode 100644 index 93ba8896..00000000 --- a/apps/frontend/src/shared/ui/Navbar/navbarConfig.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { BarChart3, CreditCard, DollarSign, Home, LineChart, List, PieChart, Settings } from 'lucide-react'; - -export const sidebarItems: { - title: string; - items: { to: string; icon: React.ElementType; label: string }[]; -}[] = [ - { - items: [ - { icon: Home, label: 'Обзор', to: '/' }, - { icon: List, label: 'Транзакции', to: '/transactions' }, - { icon: CreditCard, label: 'Счета', to: '/accounts' }, - { icon: DollarSign, label: 'Бюджет', to: '/budget' }, - ], - title: 'Основное', - }, - { - items: [ - { icon: BarChart3, label: 'Отчеты', to: '#' }, - { icon: LineChart, label: 'Тренды', to: '#' }, - { icon: PieChart, label: 'Категории', to: '#' }, - ], - title: 'Аналитика', - }, - { - items: [{ icon: Settings, label: 'Настройки', to: '#' }], - title: 'Настройки', - }, -]; diff --git a/apps/frontend/src/widgets/Dashboard/Dashboard.tsx b/apps/frontend/src/widgets/Dashboard/Dashboard.tsx deleted file mode 100644 index 9a6d6ac2..00000000 --- a/apps/frontend/src/widgets/Dashboard/Dashboard.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export const Dashboard = () => { - return ( -
    -

    Dashboard

    -

    Welcome to the dashboard!

    -
    - ); -}; diff --git a/apps/frontend/src/widgets/Dashboard/index.ts b/apps/frontend/src/widgets/Dashboard/index.ts deleted file mode 100644 index 41a54b59..00000000 --- a/apps/frontend/src/widgets/Dashboard/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Dashboard } from './Dashboard'; diff --git a/apps/frontend/tsconfig.app.tsbuildinfo b/apps/frontend/tsconfig.app.tsbuildinfo index cd28048f..0c4a1ae8 100644 --- a/apps/frontend/tsconfig.app.tsbuildinfo +++ b/apps/frontend/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"fileNames":["../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.decorators.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../node_modules/.pnpm/@types+react@19.1.4/node_modules/@types/react/global.d.ts","../../node_modules/.pnpm/csstype@3.1.3/node_modules/csstype/index.d.ts","../../node_modules/.pnpm/@types+react@19.1.4/node_modules/@types/react/index.d.ts","../../node_modules/.pnpm/@types+react@19.1.4/node_modules/@types/react/jsx-runtime.d.ts","../../node_modules/.pnpm/@types+react-dom@19.1.5_@types+react@19.1.4/node_modules/@types/react-dom/client.d.ts","../../node_modules/.pnpm/tiny-invariant@1.3.3/node_modules/tiny-invariant/dist/esm/tiny-invariant.d.ts","../../node_modules/.pnpm/tiny-warning@1.0.3/node_modules/tiny-warning/src/index.d.ts","../../node_modules/.pnpm/@tanstack+store@0.7.0/node_modules/@tanstack/store/dist/esm/types.d.ts","../../node_modules/.pnpm/@tanstack+store@0.7.0/node_modules/@tanstack/store/dist/esm/store.d.ts","../../node_modules/.pnpm/@tanstack+store@0.7.0/node_modules/@tanstack/store/dist/esm/derived.d.ts","../../node_modules/.pnpm/@tanstack+store@0.7.0/node_modules/@tanstack/store/dist/esm/effect.d.ts","../../node_modules/.pnpm/@tanstack+store@0.7.0/node_modules/@tanstack/store/dist/esm/scheduler.d.ts","../../node_modules/.pnpm/@tanstack+store@0.7.0/node_modules/@tanstack/store/dist/esm/index.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/fileroute.d.ts","../../node_modules/.pnpm/@tanstack+history@1.115.0/node_modules/@tanstack/history/dist/esm/index.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/utils.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/location.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/link.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/routeinfo.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/not-found.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/matches.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/root.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/routerprovider.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/route.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/validators.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/searchparams.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/redirect.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/manifest.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/serializer.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/router.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/defer.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/path.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/qss.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/searchmiddleware.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/structuralsharing.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/useroutecontext.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/usesearch.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/useparams.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/usenavigate.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/useloaderdeps.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/useloaderdata.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/scroll-restoration.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/typeprimitives.d.ts","../../node_modules/.pnpm/@tanstack+router-core@1.120.5/node_modules/@tanstack/router-core/dist/esm/index.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/serializer.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/awaited.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/structuralsharing.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/useloaderdata.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/usematch.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/useloaderdeps.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/useparams.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/usesearch.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/useroutecontext.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/typeprimitives.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/link.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/route.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/catchboundary.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/clientonly.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/fileroute.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/history.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/lazyroutecomponent.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/matches.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/matchcontext.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/match.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/router.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/routerprovider.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/scrollrestoration.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/useblocker.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/usenavigate.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/routercontext.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/userouter.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/userouterstate.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/uselocation.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/usecangoback.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/utils.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/not-found.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/scriptonce.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/asset.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/headcontent.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/scripts.d.ts","../../node_modules/.pnpm/@tanstack+react-router@1.120.5_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/@tanstack/react-router/dist/esm/index.d.ts","../../node_modules/.pnpm/solid-js@1.9.7/node_modules/solid-js/types/reactive/scheduler.d.ts","../../node_modules/.pnpm/solid-js@1.9.7/node_modules/solid-js/types/jsx.d.ts","../../node_modules/.pnpm/solid-js@1.9.7/node_modules/solid-js/types/render/component.d.ts","../../node_modules/.pnpm/solid-js@1.9.7/node_modules/solid-js/types/render/flow.d.ts","../../node_modules/.pnpm/solid-js@1.9.7/node_modules/solid-js/types/render/suspense.d.ts","../../node_modules/.pnpm/solid-js@1.9.7/node_modules/solid-js/types/render/hydration.d.ts","../../node_modules/.pnpm/solid-js@1.9.7/node_modules/solid-js/types/render/index.d.ts","../../node_modules/.pnpm/solid-js@1.9.7/node_modules/solid-js/types/reactive/signal.d.ts","../../node_modules/.pnpm/solid-js@1.9.7/node_modules/solid-js/types/reactive/observable.d.ts","../../node_modules/.pnpm/solid-js@1.9.7/node_modules/solid-js/types/reactive/array.d.ts","../../node_modules/.pnpm/solid-js@1.9.7/node_modules/solid-js/types/index.d.ts","../../node_modules/.pnpm/@tanstack+react-router-devtools@1.120.5_@tanstack+react-router@1.120.5_react-dom@19.1.0_cf56cc0117e73b14ba68cd3aaf107914/node_modules/@tanstack/react-router-devtools/dist/esm/tanstackrouterdevtools.d.ts","../../node_modules/.pnpm/@tanstack+react-router-devtools@1.120.5_@tanstack+react-router@1.120.5_react-dom@19.1.0_cf56cc0117e73b14ba68cd3aaf107914/node_modules/@tanstack/react-router-devtools/dist/esm/tanstackrouterdevtoolspanel.d.ts","../../node_modules/.pnpm/@tanstack+react-router-devtools@1.120.5_@tanstack+react-router@1.120.5_react-dom@19.1.0_cf56cc0117e73b14ba68cd3aaf107914/node_modules/@tanstack/react-router-devtools/dist/esm/index.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/helpers/typealiases.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/helpers/util.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/zoderror.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/locales/en.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/errors.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/helpers/parseutil.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/helpers/enumutil.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/helpers/errorutil.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/helpers/partialutil.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/standard-schema.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/types.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/external.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/index.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/index.d.ts","../../packages/shared/src/validation/basevalidations.ts","../../packages/shared/src/types/types.ts","../../packages/shared/src/types/account.ts","../../packages/shared/src/types/user.ts","../../packages/shared/src/constants/accounts.ts","../../packages/shared/src/constants/currencies.ts","../../packages/shared/src/constants/operations.ts","../../packages/shared/src/constants/index.ts","../../packages/shared/src/validation/accounts.ts","../../packages/shared/src/validation/transactions.ts","../../packages/shared/src/validation/users.ts","../../packages/shared/src/validation/auth.ts","../../packages/shared/src/validation/index.ts","../../packages/shared/src/types/auth.ts","../../packages/shared/src/types/index.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/utils/global.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/errors.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/utils/utils.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/types/decorator_fills.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/decorators.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/core/atom.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/utils/comparer.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/types/modifiers.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/types/overrideannotation.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/types/actionannotation.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/types/flowannotation.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/types/computedannotation.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/types/observableannotation.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/types/autoannotation.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/types/generic-abort-signal.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/observable.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/computed.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/core/action.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/types/observablevalue.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/core/computedvalue.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/core/derivation.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/core/globalstate.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/core/observable.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/core/reaction.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/types/observableobject.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/types/observablearray.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/core/spy.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/action.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/autorun.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/become-observed.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/configure.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/extendobservable.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/extras.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/flow.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/intercept-read.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/intercept.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/iscomputed.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/isobservable.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/object-api.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/observe.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/tojs.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/trace.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/transaction.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/when.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/types/dynamicobject.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/types/intercept-utils.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/types/listen-utils.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/makeobservable.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/types/observablemap.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/types/observableset.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/types/legacyobservablearray.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/types/type-utils.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/utils/eq.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/utils/iterable.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/api/annotation.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/internal.d.ts","../../node_modules/.pnpm/mobx@6.13.7/node_modules/mobx/dist/mobx.d.ts","../../packages/shared/src/routes/index.ts","./src/shared/config/config.ts","./src/shared/api/config.ts","./src/shared/api/index.ts","./src/entities/currencies/api/currenciesactions.ts","./src/entities/currencies/model/currencystate.ts","./src/entities/currencies/index.ts","../../node_modules/.pnpm/lucide-react@0.507.0_react@19.1.0/node_modules/lucide-react/dist/lucide-react.d.ts","../../node_modules/.pnpm/mobx-react-lite@4.1.0_mobx@6.13.7_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/mobx-react-lite/dist/utils/assertenvironment.d.ts","../../node_modules/.pnpm/mobx-react-lite@4.1.0_mobx@6.13.7_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/mobx-react-lite/dist/utils/universalfinalizationregistry.d.ts","../../node_modules/.pnpm/mobx-react-lite@4.1.0_mobx@6.13.7_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/mobx-react-lite/dist/utils/observerfinalizationregistry.d.ts","../../node_modules/.pnpm/mobx-react-lite@4.1.0_mobx@6.13.7_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/mobx-react-lite/dist/staticrendering.d.ts","../../node_modules/.pnpm/mobx-react-lite@4.1.0_mobx@6.13.7_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/mobx-react-lite/dist/observer.d.ts","../../node_modules/.pnpm/mobx-react-lite@4.1.0_mobx@6.13.7_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/mobx-react-lite/dist/observercomponent.d.ts","../../node_modules/.pnpm/mobx-react-lite@4.1.0_mobx@6.13.7_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/mobx-react-lite/dist/uselocalobservable.d.ts","../../node_modules/.pnpm/mobx-react-lite@4.1.0_mobx@6.13.7_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/mobx-react-lite/dist/uselocalstore.d.ts","../../node_modules/.pnpm/mobx-react-lite@4.1.0_mobx@6.13.7_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/mobx-react-lite/dist/useasobservablesource.d.ts","../../node_modules/.pnpm/mobx-react-lite@4.1.0_mobx@6.13.7_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/mobx-react-lite/dist/utils/observerbatching.d.ts","../../node_modules/.pnpm/mobx-react-lite@4.1.0_mobx@6.13.7_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/mobx-react-lite/dist/index.d.ts","../../node_modules/.pnpm/clsx@2.1.1/node_modules/clsx/clsx.d.mts","./src/shared/ui/button/button.tsx","./src/shared/ui/button/index.ts","./src/shared/ui/closebutton/closebutton.tsx","./src/shared/ui/closebutton/index.ts","./src/shared/ui/modal/modal.tsx","./src/features/confirmdialog/model/confirmdialogstate.ts","./src/features/confirmdialog/ui/confirmdialog.tsx","./src/features/confirmdialog/index.ts","./src/shared/ui/layout/footer/footer.tsx","./src/shared/ui/layout/footer/index.ts","./src/shared/ui/layout/header/header.tsx","./src/shared/ui/layout/header/index.ts","./src/shared/ui/layout/main/main.tsx","./src/shared/ui/layout/main/index.ts","./src/shared/lib/modalstate/modalstate.ts","./src/shared/lib/modalstate/index.ts","./src/entities/accounts/api/accountactions.ts","./src/entities/accounts/model/accountsstate.ts","./src/entities/accounts/ui/accountslist/accountslist.tsx","./src/entities/accounts/ui/accountslist/index.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/constants.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/utils/createsubject.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/types/events.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/types/path/common.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/types/path/eager.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/types/path/index.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/types/fieldarray.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/types/resolvers.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/types/form.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/types/utils.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/types/fields.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/types/errors.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/types/validator.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/types/controller.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/types/index.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/controller.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/form.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/logic/appenderrors.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/logic/createformcontrol.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/logic/index.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/usecontroller.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/usefieldarray.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/useform.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/useformcontext.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/useformstate.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/usewatch.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/utils/get.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/utils/set.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/utils/index.d.ts","../../node_modules/.pnpm/react-hook-form@7.56.4_react@19.1.0/node_modules/react-hook-form/dist/index.d.ts","../../node_modules/.pnpm/@hookform+resolvers@5.0.1_react-hook-form@7.56.4_react@19.1.0_/node_modules/@hookform/resolvers/zod/dist/zod.d.ts","../../node_modules/.pnpm/@hookform+resolvers@5.0.1_react-hook-form@7.56.4_react@19.1.0_/node_modules/@hookform/resolvers/zod/dist/index.d.ts","./src/features/manageaccount/ui/manageaccountform.tsx","./src/features/manageaccount/ui/manageaccountmodal.tsx","./src/features/manageaccount/ui/index.ts","./src/shared/ui/layout/sidebar/sidebar.tsx","./src/shared/ui/layout/layout.tsx","./src/shared/ui/layout/index.ts","./src/routes/__root.tsx","./src/routes/transactions.tsx","./src/routes/budget.tsx","./src/routes/about.tsx","./src/pages/indexpage/indexpage.tsx","./src/pages/indexpage/index.ts","./src/routes/index.tsx","./src/pages/accountspage/accountspage.tsx","./src/pages/accountspage/index.ts","./src/routes/accounts/index.tsx","./src/pages/accountpage/accountpage.tsx","./src/pages/accountpage/index.ts","./src/routes/accounts/$accountid.tsx","./src/routetree.gen.ts","./src/main.tsx","../../node_modules/.pnpm/vite@6.3.5_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4/node_modules/vite/types/hmrpayload.d.ts","../../node_modules/.pnpm/vite@6.3.5_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4/node_modules/vite/types/customevent.d.ts","../../node_modules/.pnpm/vite@6.3.5_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4/node_modules/vite/types/hot.d.ts","../../node_modules/.pnpm/vite@6.3.5_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4/node_modules/vite/types/importglob.d.ts","../../node_modules/.pnpm/vite@6.3.5_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4/node_modules/vite/types/importmeta.d.ts","../../node_modules/.pnpm/vite@6.3.5_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4/node_modules/vite/client.d.ts","./src/vite-env.d.ts","./src/entities/transactions/api/transactionsactions.ts","./src/entities/transactions/model/transactionsstate.ts","./src/shared/types/index.ts","./src/shared/ui/layout/sidebar/index.ts","./src/shared/ui/modal/index.ts","./src/shared/ui/navbar/navbarconfig.ts","./src/shared/ui/navbar/navbar.tsx","./src/shared/ui/navbar/index.ts","./src/widgets/dashboard/dashboard.tsx","./src/widgets/dashboard/index.ts","../../node_modules/.pnpm/dayjs@1.11.13/node_modules/dayjs/locale/types.d.ts","../../node_modules/.pnpm/dayjs@1.11.13/node_modules/dayjs/locale/index.d.ts","../../node_modules/.pnpm/dayjs@1.11.13/node_modules/dayjs/index.d.ts","../../packages/shared/src/libs/formatters.ts","../../packages/shared/src/libs/index.ts","../../node_modules/.pnpm/@types+react-dom@19.1.5_@types+react@19.1.4/node_modules/@types/react-dom/index.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/compatibility/disposable.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/compatibility/indexable.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/compatibility/iterators.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/compatibility/index.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/globals.typedarray.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/buffer.buffer.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/header.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/readable.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/file.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/fetch.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/formdata.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/connector.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/client.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/errors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/dispatcher.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/global-dispatcher.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/global-origin.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/pool-stats.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/handlers.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/balanced-pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-interceptor.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-client.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-errors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/proxy-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/env-http-proxy-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/retry-handler.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/retry-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/api.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/interceptors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/util.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/cookies.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/patch.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/websocket.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/eventsource.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/filereader.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/diagnostics-channel.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/content-type.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/cache.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/index.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/globals.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/assert.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/assert/strict.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/async_hooks.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/buffer.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/child_process.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/cluster.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/console.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/constants.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/crypto.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/dgram.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/diagnostics_channel.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/dns.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/dns/promises.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/domain.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/dom-events.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/events.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/fs.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/fs/promises.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/http.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/http2.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/https.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/inspector.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/module.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/net.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/os.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/path.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/perf_hooks.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/process.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/punycode.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/querystring.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/readline.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/readline/promises.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/repl.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/sea.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/sqlite.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/stream.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/stream/promises.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/stream/consumers.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/stream/web.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/string_decoder.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/test.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/timers.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/timers/promises.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/tls.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/trace_events.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/tty.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/url.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/util.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/v8.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/vm.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/wasi.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/worker_threads.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/zlib.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/index.d.ts"],"fileIdsList":[[51,171,229,232,350,392],[51,171,228,264,265,350,392],[50,51,128,171,236,247,250,266,320,321,350,392],[51,267,350,392],[51,234,350,392],[51,171,228,233,350,392],[51,350,392],[51,254,255,350,392],[51,228,350,392],[51,236,247,250,253,254,350,392],[51,302,350,392],[51,164,169,171,235,247,266,298,300,350,392],[51,247,250,253,256,266,301,350,392],[50,51,52,128,320,321,327,350,392],[51,128,320,321,350,392],[51,317,350,392],[51,314,350,392],[51,311,350,392],[51,128,306,320,321,350,392],[51,128,318,320,321,350,392],[51,128,315,320,321,350,392],[51,128,312,320,321,350,392],[51,128,307,308,309,310,313,316,319,321,350,392],[51,230,350,392],[51,231,350,392],[51,263,350,392],[50,51,248,350,392],[51,249,350,392],[51,236,350,392],[51,251,350,392],[51,257,350,392],[51,259,350,392],[51,305,350,392],[50,51,142,235,256,258,260,262,304,350,392],[51,261,350,392],[51,304,350,392],[51,250,266,268,303,350,392],[50,51,248,252,350,392],[51,335,350,392],[51,128,320,321,334,350,392],[327,350,392],[51,337,350,392],[299,350,392],[156,298,350,392],[350,392],[140,141,350,392],[50,128,139,320,321,350,392],[50,128,320,321,350,392],[91,92,103,109,112,350,392],[50,51,91,92,103,109,112,350,392],[50,51,103,350,392],[50,51,350,392],[91,92,95,96,97,98,99,100,103,109,112,350,392],[51,91,92,103,109,112,350,392],[62,350,392],[53,54,62,91,92,93,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,350,392],[103,350,392],[50,91,92,101,103,109,112,350,392],[50,350,392],[50,51,91,92,94,103,109,112,350,392],[50,91,92,95,96,97,98,99,100,102,103,109,112,350,392],[62,91,92,103,107,109,112,350,392],[50,91,92,103,109,112,350,392],[50,91,103,109,112,350,392],[91,92,98,99,102,103,109,112,350,392],[50,62,91,92,103,107,109,112,350,392],[91,92,94,103,109,112,350,392],[77,350,392],[71,72,350,392],[61,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,350,392],[62,63,64,66,77,107,350,392],[62,72,107,350,392],[63,66,71,77,350,392],[66,77,350,392],[70,71,350,392],[63,65,77,350,392],[61,63,64,65,66,67,68,69,70,72,77,350,392],[61,63,65,71,77,350,392],[60,62,63,64,65,66,67,68,70,71,72,73,74,75,76,78,107,350,392],[64,65,66,77,350,392],[63,64,77,350,392],[63,65,71,350,392],[72,350,392],[63,350,392],[63,65,66,74,77,84,85,350,392],[63,66,77,350,392],[65,77,350,392],[71,350,392],[55,56,350,392],[57,350,392],[55,56,57,58,59,350,392],[56,57,350,392],[55,350,392],[350,389,392],[350,391,392],[392],[350,392,397,427],[350,392,393,398,404,405,412,424,435],[350,392,393,394,404,412],[345,346,347,350,392],[350,392,395,436],[350,392,396,397,405,413],[350,392,397,424,432],[350,392,398,400,404,412],[350,391,392,399],[350,392,400,401],[350,392,404],[350,392,402,404],[350,391,392,404],[350,392,404,405,406,424,435],[350,392,404,405,406,419,424,427],[350,387,392,440],[350,387,392,400,404,407,412,424,435],[350,392,404,405,407,408,412,424,432,435],[350,392,407,409,424,432,435],[348,349,350,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441],[350,392,404,410],[350,392,411,435],[350,392,400,404,412,424],[350,392,413],[350,392,414],[350,391,392,415],[350,389,390,391,392,393,394,395,396,397,398,399,400,401,402,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441],[350,392,417],[350,392,418],[350,392,404,419,420],[350,392,419,421,436,438],[350,392,404,424,425,427],[350,392,426,427],[350,392,424,425],[350,392,427],[350,392,428],[350,389,392,424],[350,392,404,430,431],[350,392,430,431],[350,392,397,412,424,432],[350,392,433],[350,392,412,434],[350,392,407,418,435],[350,392,397,436],[350,392,424,437],[350,392,411,438],[350,392,439],[350,392,397,404,406,415,424,435,438,440],[350,392,424,441],[48,49,350,392],[340,350,392],[339,350,392],[237,239,240,241,242,243,244,245,246,350,392],[228,350,392],[228,238,350,392],[175,227,350,392],[227,350,392],[191,227,350,392],[190,191,196,197,227,350,392],[172,173,174,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,350,392],[50,283,350,392],[283,284,285,288,289,290,291,292,293,294,297,350,392],[283,350,392],[286,287,350,392],[50,281,283,350,392],[278,279,281,350,392],[274,277,279,281,350,392],[278,281,350,392],[50,269,270,271,274,275,276,278,279,280,281,350,392],[271,274,275,276,277,278,279,280,281,282,350,392],[278,350,392],[272,278,279,350,392],[272,273,350,392],[277,279,280,350,392],[277,350,392],[269,274,279,280,350,392],[295,296,350,392],[129,130,135,136,137,138,350,392],[49,350,392],[136,350,392],[129,130,135,350,392],[130,350,392],[130,136,350,392],[131,132,133,134,350,392],[350,359,363,392,435],[350,359,392,424,435],[350,354,392],[350,356,359,392,432,435],[350,392,412,432],[350,392,442],[350,354,392,442],[350,356,359,392,412,435],[350,351,352,355,358,392,404,424,435],[350,359,366,392],[350,351,357,392],[350,359,380,381,392],[350,355,359,392,427,435,442],[350,380,392,442],[350,353,354,392,442],[350,359,392],[350,353,354,355,356,357,358,359,360,361,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,381,382,383,384,385,386,392],[350,359,374,392],[350,359,366,367,392],[350,357,359,367,368,392],[350,358,392],[350,351,354,359,392],[350,359,363,367,368,392],[350,363,392],[350,357,359,362,392,435],[350,351,356,359,366,392],[350,392,424],[350,354,359,380,392,440,442],[326,350,392],[322,350,392],[323,350,392],[324,325,350,392],[155,350,392],[145,146,350,392],[143,144,145,147,148,153,350,392],[144,145,350,392],[154,350,392],[145,350,392],[143,144,145,148,149,150,151,152,350,392],[143,144,155,350,392],[171,350,392],[161,162,163,350,392],[341,350,392],[342,350,392],[158,350,392],[156,169,350,392],[158,159,160,170,350,392],[156,157,350,392],[156,157,164,350,392],[156,350,392],[157,165,166,167,168,350,392]],"fileInfos":[{"version":"69684132aeb9b5642cbcd9e22dff7818ff0ee1aa831728af0ecf97d3364d5546","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"092c2bfe125ce69dbb1223c85d68d4d2397d7d8411867b5cc03cec902c233763","affectsGlobalScope":true,"impliedFormat":1},{"version":"07f073f19d67f74d732b1adea08e1dc66b1b58d77cb5b43931dee3d798a2fd53","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"936e80ad36a2ee83fc3caf008e7c4c5afe45b3cf3d5c24408f039c1d47bdc1df","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"fef8cfad2e2dc5f5b3d97a6f4f2e92848eb1b88e897bb7318cef0e2820bceaab","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"742d4b7b02ffc3ba3c4258a3d196457da2b3fec0125872fd0776c50302a11b9d","affectsGlobalScope":true,"impliedFormat":1},{"version":"8a8eb4ebffd85e589a1cc7c178e291626c359543403d58c9cd22b81fab5b1fb9","impliedFormat":1},{"version":"03566a51ebc848dec449a4ed69518e9f20caa6ac123fa32676aaaabe64adae8e","impliedFormat":1},{"version":"42c169fb8c2d42f4f668c624a9a11e719d5d07dacbebb63cbcf7ef365b0a75b3","impliedFormat":1},{"version":"88e9caa9c5d2ba629240b5913842e7c57c5c0315383b8dc9d436ef2b60f1c391","impliedFormat":1},{"version":"b8903e4347ba78328892f8c30b45a8a0071e06c7bae0b0a621035fbf36f75bcb","impliedFormat":99},{"version":"5771ec76e11a5e8303d8945e8050daee051436150d5285dc818c8df3239f45bb","impliedFormat":1},{"version":"7f9ef84dcfff2dbd0c8ed9f4ea18216619527f5b79df115a430114ea5a77c3f6","impliedFormat":99},{"version":"368514ed24d7fa3dbbd3360ee9aac4a1e629969268276e92b86c0f672f3fbbb3","impliedFormat":99},{"version":"c2845147e33e886027684d887ec5ddfccf5bc16bc5fd2ec0f244883d5cee4318","impliedFormat":99},{"version":"ead2432b3eb851e40625006ccba0cb39325fa7f72fdbaedd19825bff05722f8e","impliedFormat":99},{"version":"a1ae11570949b69e4a4e0ee1adcd109f3258df90ec3c5d4345d276e629d38366","impliedFormat":99},{"version":"c7c33f7321ee10856267aaedfd6b61cf249e608a6a37ea7204ec84d48f9a1e4b","impliedFormat":99},{"version":"648db4b8b239ed5b5e8c93618bbe4384d69d659ddf040ad200ee4915aee2157e","impliedFormat":99},{"version":"b39c758625e62b1cf23c7cc38bbe561ba53463237a44291486179bc8c4ec138a","impliedFormat":99},{"version":"33686400541314fa6a0757685b354778628768e2b0ef75310027b2d805cbb3aa","impliedFormat":99},{"version":"c630d607581ce676da4f470e1439fa7127bd763a3608a811122a6a1b33264a61","impliedFormat":99},{"version":"d33c779e38298df4fa486c3190beb16194ca42a41947abcb4fc491e6f9944a8c","impliedFormat":99},{"version":"bdfa3b05b56953affb355a81976e6bfb35961ce63b746d6382ce3412c8e0be10","impliedFormat":99},{"version":"169ef5a18d968eefdcdbad749ded93d1127eb40dd78ce0a0e2ecd9d42eea1a1a","impliedFormat":99},{"version":"55a3b2901bf0b3e5c482ea6ae5e34469d9e7242a30a7bf4783c9ffce3cfc8aa3","impliedFormat":99},{"version":"8a30efabf741de93c2884ffdc23f16602042449e28729eb9874953b3ea8dc705","impliedFormat":99},{"version":"ae2845848a8a72c5e211230fc6bdb1b001056836c4ccbed881441ecece9ab23c","impliedFormat":99},{"version":"dc6b45c8b0599be8ca11a2e7e96745072b9f12850fb0eeec7a70486dc9f99712","impliedFormat":99},{"version":"28c6caf88a757b504f8a36901e4cb7571098f92eaf971aaf8ff77101e47a72ac","impliedFormat":99},{"version":"35b4c072ee1aaddc8352e5a40f6d5c7dc15865ff0681cf312fef258362cedfe4","impliedFormat":99},{"version":"88879ddcc4dd4d80d7580d09db58d64e01bdf93def80c4a3eeeb8886f7c3500c","impliedFormat":99},{"version":"0923187df6538b37c811a365ef1d7efa9f1b23c0116fe9255841548311e7b18d","impliedFormat":99},{"version":"5aef5866733347f6fbdd9391e8c2a3c58002472b57c7188912d995e90274a24c","impliedFormat":99},{"version":"19c9ef1751b20765eeb092adbc9f77bf0f5674ef6083bace3598859e629611d8","affectsGlobalScope":true,"impliedFormat":99},{"version":"dcd742a716cc29789bc177f1b3fbf3c8bc2c164b31104635e3e9f84881bf4c2e","impliedFormat":99},{"version":"22568395a672835b8f58b51097b55d914e0939b71b9a8a54754865b590e9ac29","impliedFormat":99},{"version":"b6783f68397f6edf1c6bed6c2d7c0d89c6d443df1f3a6453ced1d4e184e7014c","impliedFormat":99},{"version":"2ee61832845cf9fd22b581a4cf2bb53794fe4043bd1f1c130a5568e6e3e21368","impliedFormat":99},{"version":"aed243366f9c480cd45623a8f2266c2c8868773cdb13db8f46acb2173cd407f1","impliedFormat":99},{"version":"9c372493f8a4e1c6276f1ba3e6521122b65d9de25d34dc8e43effc97fd64be54","impliedFormat":99},{"version":"d90b155a976d4312c36a3dd442bcd52e9326e599c523faae4cfb8942468f5298","impliedFormat":99},{"version":"13876cda8b941c4bff97ae9ddf89f2a04c8e4ff59bf9141f72f892815bf30244","impliedFormat":99},{"version":"f4cc088c0853a0bc7b5c65fcb53422898cb4a412ba46ed6c62716d465a351922","impliedFormat":99},{"version":"65657868876a64f14656e233a5f2e73d354fb824794f934e9543aa7c3aebf51d","impliedFormat":99},{"version":"b220e3999b3cbae6d40e997f4227bc4006ab04f511e5d4636dfd63badeffd025","impliedFormat":99},{"version":"3dab370414f0c44d2e2e0acfd10fff6a57de0ad770c6c26456f31cbf21204f16","impliedFormat":99},{"version":"9b02e7d7ab9800f9c65547f697875e45ea533fd31da0ce06e52798032d3bf2c2","impliedFormat":99},{"version":"65735c7848f6f0cebe410726ec8f647859df9667ccad5972674646f79527d684","impliedFormat":99},{"version":"fb0b313c8baca67889b6048356af082e02de1df625f0e9f8962f397ea3ed560e","impliedFormat":99},{"version":"57eb2b3764a445ce26575a813ca9b8999c7239f70ae435140ab662be599a60f4","impliedFormat":99},{"version":"91a93ec2ea237ab400834525faed8b2214852ee6b080dfc14de4b52d765248b7","impliedFormat":99},{"version":"32485636a4ae3491437b850a2f8ed523c63d59da3ec48375601458c4e59819e4","impliedFormat":99},{"version":"a61b89063f853e9a1e16a029b67588dde050af09a3c3427097f21e9bd5bdd158","impliedFormat":99},{"version":"048375d7ff42f93ed5a29318ac1642a83c02ae0c2fd658f345d1c46e0c54a67f","impliedFormat":99},{"version":"f7a3ce9cc643a4b47429ec47de60555f6ae6877523a2d3f43dd9ef7b0e7556cb","impliedFormat":99},{"version":"eb2e6827dc100235cc062144f983d5851b13a0531a8965a42e71bd5218355141","impliedFormat":99},{"version":"d2e4f966279a90171e6351b1bf6706d14054a4e5bcc8d46948fed172cfe11371","impliedFormat":99},{"version":"32515c5c844173bf500057ab01e2c246cf914d92cd10d0fd4767b0f9591627ae","impliedFormat":99},{"version":"5271013c661bf776d575ce29b8a18565c4674434c70af21f542dffd15d29581f","impliedFormat":99},{"version":"e14172688f53d888134dd0d2c90fd9f0205179db8ab99430d5b1309e50e0cdb8","impliedFormat":99},{"version":"6946f7e29e3bc5cb0da6df5b664d628fb47593f4dc6bdfb141ad5e7d7a4e777b","impliedFormat":99},{"version":"396fb90417ba85a0b114a2b8f7887d68a208293ab7f29b08ae7b7010d2f4de47","impliedFormat":99},{"version":"b9abf61560dc97324a08cdeecbed287440b70c9821d025d483f8ce0ff6907f98","impliedFormat":99},{"version":"d59755c3b9f1ce4bc1379ba54846eaf464b9f4db9026e3cbcd25603cae7562e2","impliedFormat":99},{"version":"a610f9cf754bf9513b44ecb4c50d79928d2d8289b7ef31c67a89b105b3492702","impliedFormat":99},{"version":"7d318d2247741b3cc384060c60e77295ffa7210b1df48eb3e2d1533017af3182","impliedFormat":99},{"version":"f11d572e430c0ea5e957ec85608629c7433b0ad6e0b1450f3e9d91667625fb58","impliedFormat":99},{"version":"d41befd3d4185f93a5ccc1bb099891415b7157d0ede46ae88a20210d4e772bbc","impliedFormat":99},{"version":"c8bbb9ce1c891efe0e45fd16fb5c4f0c06e9ad60ec1e025bb0b161ef71f04371","impliedFormat":99},{"version":"e9124172677261d101c54c55f9025727c0eed6e5c071db507f977b9cfece01ac","impliedFormat":99},{"version":"556a85bb9982f7f12101fbb9a96262f1d7a4ad6bd5cb50db62153438ccf373b5","impliedFormat":99},{"version":"e5af5fb67c619f0f050cea978ed642da92bbbad4d24b9b0373432e7ed3199431","impliedFormat":99},{"version":"2163deee5fbd66077b4fbd4d9cf84c8f06918113eff425a62dc257b051eccd02","impliedFormat":99},{"version":"e8ef79b944df8cbaeed8bcf01062709c91853ea06474571748f65e3a03439656","affectsGlobalScope":true,"impliedFormat":99},{"version":"16db9485a97fb736ec9098a6d1277e7b95fc489c8e8608d83824728f47219d94","impliedFormat":99},{"version":"9e1ad280ec936e7789a3901f29b6ec2b68578ca030ef7c4b51e970d6308aa6e2","impliedFormat":99},{"version":"81f50c2e9a72a15dcfd77a3aff3b676bbf2f270265c3e5ab8d64ba07575417ee","impliedFormat":99},{"version":"038780a3ac1630c2398d24aa98283d23216feebc22a265caa5f747d85b9d0b68","impliedFormat":99},{"version":"3562836bbc8f85daa391c5bac66cc6c85be806b7d1a0bf25bc6897481e555868","impliedFormat":99},{"version":"5dda66d705930bbea2e79e0f0a5b74c5642481c73487e9f48b6e6e2a7535321c","impliedFormat":99},{"version":"fcb569cff31c9ffc80018fe31585a7733d6297977b6d5303a7b0c9c87856e41b","impliedFormat":99},{"version":"616e09e0cc203f41e18848c4e246d248d94518b080e2b16cf28bc705fb9b35b5","impliedFormat":99},{"version":"518dbebaadc0fc21aa92177d30b043613471869c7fc16502b170c184e60fc17a","impliedFormat":99},{"version":"027b6effcb25477fb182104e60037753a7861782db46001ae54d50b574d0f74b","impliedFormat":99},{"version":"4cad4e814e224a5f6cc40c0b5dfd3c60f6715147a94c27568324439325578c35","impliedFormat":99},{"version":"a6d2eb77f390448a229ca63b4adadbcc549d203b8c038f1f7627a118f732969d","impliedFormat":99},{"version":"1ddd07729c458dd68c47ea2ac1f46f33f64f603f53fe43d74c9bcd65487923fd","impliedFormat":99},{"version":"b886cc0d907b31ae7484a85da375bc8ac42f9986ec1e49c9c7c854956b0a928b","impliedFormat":99},{"version":"69029230a28323de141ad3b08350b45468248d1f8cce9fe67e4c9aed7785fb2d","impliedFormat":99},{"version":"8ab288ca80e5f2a9dab19955fcd9e81a244432cd7d256fdb5ea198ed5a44f0a4","impliedFormat":99},{"version":"0caadd121b8965f24d5c647a2ed01f5fc352772e4cea4bbca2ab78adedfdd921","impliedFormat":99},{"version":"b642b6a4b9b670338bc50281a6dd192fb7cc0b2f9d63d38c250bb1f02e828bbc","impliedFormat":99},{"version":"3c52dce9553224361c062550a63e7ea0c60a26352e87e80ce847134ae2e4d782","impliedFormat":99},{"version":"c2e65a549ac9d227a9002d077af67bb1c77557a72bf39a85c4354831879078ed","affectsGlobalScope":true,"impliedFormat":99},{"version":"38f11ad59dfc38750b789f9f250742d0ce0da44e28f790a032cd15665b70f239","impliedFormat":99},{"version":"c86b1f5777f36dd334a630de065b339f04e4ba672ade417039740847c11246a5","affectsGlobalScope":true,"impliedFormat":99},{"version":"38d2308df413ce823f4764af63dc7e9aa64223f6ba71dbbd5559a6695ca06a08","impliedFormat":99},{"version":"01b1fb6368b7f9128e594c8ad67cbfbfeaed998ba9a59091764f37dc13c867d1","impliedFormat":99},{"version":"a5908d37bbe4df084fa9527e9d672f5d69859684c722e465ca18a6b11f666d7b","impliedFormat":99},{"version":"d3cfde44f8089768ebb08098c96d01ca260b88bccf238d55eee93f1c620ff5a5","impliedFormat":1},{"version":"950f2cd81e30d0ecdf70ab78fcfd85fc5bb28b45ebb08c860daff059feea412e","impliedFormat":1},{"version":"3a5af4fba7b27b815bb40f52715aedebaa4b371da3e5a664e7e0798c9b638825","impliedFormat":1},{"version":"8485b6da53ec35637d072e516631d25dae53984500de70a6989058f24354666f","impliedFormat":1},{"version":"ebe80346928736532e4a822154eb77f57ef3389dbe2b3ba4e571366a15448ef2","impliedFormat":1},{"version":"49c632082dc8a916353288d3d8b2dc82b3471794249a381d090d960c8ceac908","impliedFormat":1},{"version":"f672c876c1a04a223cf2023b3d91e8a52bb1544c576b81bf64a8fec82be9969c","impliedFormat":1},{"version":"71addb585c2db7b8e53dc1b0bcfa58c6c67c6e4fa2b968942046749d66f82e7e","impliedFormat":1},{"version":"c76b0c5727302341d0bdfa2cc2cee4b19ff185b554edb6e8543f0661d8487116","impliedFormat":1},{"version":"25b3f581e12ede11e5739f57a86e8668fbc0124f6649506def306cad2c59d262","impliedFormat":1},{"version":"e703cfacb9965c4d4155346c65a0091ecded90ea98874ed6b3f36286577c4dde","impliedFormat":1},{"version":"f5ef066942e4f0bd98200aa6a6694b831e73200c9b3ade77ad0aa2409e8fe1b1","impliedFormat":1},{"version":"b9e99cd94f4166a245f5158f7286c05406e2a4c694619bceb7a4f3519d1d768e","impliedFormat":1},{"version":"5568d7c32e5cf5f35e092649f4e5e168c3114c800b1d7545b7ae5e0415704802","impliedFormat":1},{"version":"c2c423d1a7d5ee0adaf63e2dc534da01fb1a2ba81b58881e360a7ea1f9c0eeb3","signature":"cc5d57f8575ea2b8977eb191f98847ee210ee2c0c935b1dee029c7435a7f5dd1"},{"version":"98a7074dc846467ca62f2dfd3ab5936b8df76215fde87478740b662fb7c6f58b","signature":"d1aec72fd37c38ca436f5a85f7d330b65b8a754315275f272cbf1e372b100ba4"},{"version":"a4b1ad27e5c15c398bca5bed7120a349d09773f3e936d88f6ad834732216b80f","signature":"f1269d7c09e026cb7ab11c7b664f6b19125eff7ff3af5e5bd54ac9cc8c0352a4"},{"version":"5c043033f251a8c5bd7108914393af575650fa9c78a78e548f81d7cfc4014e6f","signature":"74555895068502b65799c3c9928e42f4e56d6f69b021e6f13f294c1a8c354260"},{"version":"b9b5488f9e8043ff017f648745a75b1c2e030a8efa16c6310fa6e6f9e52bcef5","signature":"58969267c23c27443642dec465082e37d7e210fa71c2cf13f516d876ff97b6fa"},{"version":"5275b12537eadeeac78e852fd6ddebb4882770947c64f06bf1bdaf29ecb61303","signature":"c4a90e38375dfd22ab91d9a420cfa703a5dca4145108e0d1106e9873bc96797b"},{"version":"1af38648bd7056b360f5c3607b305f558a09d1d320fa253a7333c169ea9529b1","signature":"08f9b11447950c4f59141f328520c6e31b5a166924336777995a0187f7a4bde9"},"94a80d8194a2bd89860166870c73abf27aa21cfd5c94a5cb3dedd68f2a431705",{"version":"77bd92a03b913c17a61923ff79652463ea45225d08b0f9b56ee1fae0c25b49d3","signature":"78954cc7b3694f51cf8831576a85948046c93a76ee7d5affc9148bb8b84d6d7a"},{"version":"77f61c89583cd0eb3fc0602b89e4a7bf06c1b85b776a52668bc9145aec09de5d","signature":"a245278a75e5e94dd10da11c606b5b8792797fecde390dbb95b1c59974ee7798"},{"version":"b065b977696599b488831c0189811f198445b31d2684e1017c0dd5a486d7c3ac","signature":"a6785ff8e286cc4b64221c4d9c6a1084f328837d1e3225db9045130dcc8a0ca8"},{"version":"c4a595c3f6c279163c73fde607c056c813ef53f47537bc91b2623dd95f45f2fc","signature":"0dacf73b071d41f5506c1858dbc30a73b85482307cb5e15a63ba4fe654906ce4"},"bb0b00be806f71a00f4a806b9331cc7b919d652d35a53ed9e2774b017dcf1dbd",{"version":"3b96187464c836feb9457f746b1ed44dc91ad76aa65ee8cdd64283b46fda9234","signature":"ec7adc98a93755b7c1ecb861e78423a549a0d8b30751571340bac9c0c974c66a"},"07f93368ac703e3104c9ca84fa0a5ce8f917768b3004712bf9e07465018c215c",{"version":"8c72ef1bb77e1dbfe10f37d22327de08b839246785253756944b94c8a328eece","impliedFormat":1},{"version":"26efb34ac3797956919cecf67aa2c229b875620bb7a4eb16e3ab97e2fafe0c76","impliedFormat":1},{"version":"4b90c2e95674469fb79ad5c7f2fd946304acc2910bf578f7b1929fead92a1d27","impliedFormat":1},{"version":"6ba3836f4607d0e8a2da4d7a47cd9a5750703d69dd07281997c39831a2edbade","impliedFormat":1},{"version":"56024065be958aac94c30535a04a6bd8db908b2924a21011009291353fcd2478","impliedFormat":1},{"version":"ac339e7b2a6953534dc34b2c76b5128c84eb33b947e9c14b0c6f819e8974842f","impliedFormat":1},{"version":"33c337de15324843b658d00995db674f9c618fe9ad6ad6b43f0fe6835984fa0b","impliedFormat":1},{"version":"27254b3eb6ba4c0630413a63892534ecbac47746bed81f72d996dca1662936d4","impliedFormat":1},{"version":"7e687be116d78dd903a427dacc3cbad3ff9f7ace60dffa8c83b7102ded92de47","impliedFormat":1},{"version":"3da682a8d8b2cf020828199e06d40f4b8f617d8582ab466354b804602e492b60","impliedFormat":1},{"version":"cca10549035d257a89fc46649190b96534af65715aa85b57f0d2a5c61fb98938","impliedFormat":1},{"version":"5e04be59bf10e4d8adf988610f7e80864f33e7e6dd5072ce4af354305581dec9","impliedFormat":1},{"version":"6cf6a4290ee17a9104da299a794d39fb02ef19e054ab14f2ec1d2753514f131b","impliedFormat":1},{"version":"06648b76a5d56f77a1443626ced0fe0fa0dc58827df8ad6195e331915a46e347","impliedFormat":1},{"version":"abd934f6207c5954c02630d92160c4d866f0664047dae0a939f9bdcdb8302e98","impliedFormat":1},{"version":"66c15e0c870a2928dc58a7b19762e94a32ff4ebe4a64b42e9419d212e51cf2c8","impliedFormat":1},{"version":"e5d037491332f872acd558856a11b23c4d5bf726ca3cb9208c7ab8051c4b0faa","impliedFormat":1},{"version":"157227cf6b14eee7ee92a96862902be7d64a6bda2befecf6ebf80e97079e1545","impliedFormat":1},{"version":"a4bd311189ce445fa39d86b23905683a6cd6d9eb884bf8a100329b3dbfb1db43","impliedFormat":1},{"version":"f4d06ad1b70434339a4fdf464ac4d804154c71314c3e2fb928bead12b620cf1a","impliedFormat":1},{"version":"c3878889468470079a7a3f91ab157b2c15da1191d9253a3149eedf4b7c7e9712","impliedFormat":1},{"version":"b2ef09a1454cd38bec48a17316abe8c6b1d56711b1071e5974ed5c377741131b","impliedFormat":1},{"version":"56290b22f633dc5afd255ed0e897d1e8e6567fa5abf38773c16ba7b89de4c56a","impliedFormat":1},{"version":"698b7a228b992346acdd678b918804f4e68824fbcb5bfa551603aa6d4217548d","impliedFormat":1},{"version":"dd1a80df85e075e40a7edd6d9a549a2a954956bfbdd61556609394275cc78895","impliedFormat":1},{"version":"e26c74e599a55bda4bcdb20a9d5caa28728073451944e358f9d5de19c77fe408","impliedFormat":1},{"version":"89d717a0978df19ddbcdc62e43b2972e8c374de52518c2e8ac8a43992b76533a","impliedFormat":1},{"version":"67d4363beaca9c0ba210bbfc33fbef91d1c22c77d5717aca547dd8b650fd0fbc","impliedFormat":1},{"version":"47175f6d1984ee2663d867225c642bc2645d2b37508f0a2b0693cbcd38bf1b8f","impliedFormat":1},{"version":"e5bfab23943623a63a9612d1efd4b9cbae9abe99d88286246fc4ad9564b8078f","impliedFormat":1},{"version":"192d1e66dfb3f57d196fd9db6576d43a8a57af0ec7995eae139bbbc8270427ec","impliedFormat":1},{"version":"ee03033435713344042f5ee1b2f5573f988462c3c6c7dabd4a62df9830bcdd23","impliedFormat":1},{"version":"bfca9c2cc975dd04310c02d200c61baac194b8ebb52d26ea83c50092ae005f88","impliedFormat":1},{"version":"bcd9b17745bc73031b9c23b834dfdc411dec8f6309c5c3ed5dfc05ad9271f2b3","impliedFormat":1},{"version":"a8114e7ae77ce8038227e4b5ffe064b12d2f228e42bf090bbc8a43e4b36774fa","impliedFormat":1},{"version":"123cd45999e04cbd809b33dd14b83a7cdf5bd42ef5419b9dd912f13408fa72be","impliedFormat":1},{"version":"6db08c20642e5c3b7b395d29c53380265aa22a5e9409df7e67b27194f5068369","impliedFormat":1},{"version":"06944bcadb20a137ec5cccefa6d02beb73f5d2889a3dd6409e704acdcd05030f","impliedFormat":1},{"version":"9e21f2c882a1ee090d1d72e7d48b0f4c338ebcc643f684786f8f71ea317d0761","impliedFormat":1},{"version":"4af5e1d11bed0c5594ada90b8169a367bb0622692d904d04e7b0e0fb51b4563a","impliedFormat":1},{"version":"ab428a4142c089f84f277bb4aa7d514f454b69b67a47b9226804178c50ea9b88","impliedFormat":1},{"version":"f2ecf41bea9d32be3e27c71b7d1d9e3f58083b45fd36bd25980899a1168ee109","impliedFormat":1},{"version":"0adcb5db0aa0de1425495b135dacaf6f2240e3ad48b905cd8139f730fedaa4ca","impliedFormat":1},{"version":"116fd7b3ef6684531a1590b36a3ea2d0cd5383230d8509b928e567c08697a3ee","impliedFormat":1},{"version":"ee9d7a75e75cc2a61d2c781df3864e5a0d4f3dbd149ed22c65749bd733375d88","impliedFormat":1},{"version":"35161f13181515b62e1d83bbf6c4062dd64df50b943b11b512bde6e3b4c7f4c9","impliedFormat":1},{"version":"7827df7348614d14296887c07549eab5c11dba6f201519eae67ac811d63a24fb","impliedFormat":1},{"version":"5199dc536c144915831cdf5569baab87db25e7926e82af048b28adc2044f1e5c","impliedFormat":1},{"version":"ea9a572684e0f6bdf35740966b2aaaf341a49ff0eaa0d25f3113f518741d0eff","impliedFormat":1},{"version":"1ee3efb6da370bd447ba467b5edb6f79ecf4082eeda41e201112b4bba48cd9e7","impliedFormat":1},{"version":"f6650f06155f0147cb49d8230ac3d07ac7fc5d38fb5f5db23ca94aebf9a2b240","impliedFormat":1},{"version":"76eb98cfa62a7c06c358c566646ad1bb030b35df08fc20f77f63d733621a9b1d","impliedFormat":1},{"version":"118d80f9540cfa2b110c2835648059d35660b02250e6ac64b7d81791ccb5a5e1","impliedFormat":1},{"version":"5aeb1520ee864150fd128807768ecff927b5b716be83da72869936a8ca723ae7","impliedFormat":1},{"version":"2ad51cec69437b45a5135e5cafbd8e0bbf361e352c65c02ba57ab190e5cf111f","impliedFormat":1},{"version":"375a1e03a1d0c742157b80591ebabab92929be9d3329eac73269a364dcec37be","impliedFormat":1},{"version":"7a91879080f4028bd9a3e76a299f356fa4b1440a36953489c1c0de570529a19b","impliedFormat":1},{"version":"060d93a9be6f0b10b925959577614918a73318293b6f75392987ba5dc32821dd","signature":"17fb5750368eeec430e05d02350094a3d87709ba924d622223eea720c3eadf60"},{"version":"2940a44d9c23110d9dc2fa75a81c5312a9f52116b429b9c0f9d9e5e6f0ff74a9","signature":"d0c08cc38b8d1f405d3972d750f4bd01394345a4f1f549a4bb9e421a480e613c"},{"version":"d95953d2fb0c059266530bf031b958b9ac518c0cb4812e1b0c7baf16a739853f","signature":"d934a3c076ebaef0f56281484d7b88d333cf787ca2e4403192003fe746897b11"},"1affef5e5a5f57615fb044f0a33077838cccb4f8382a311bb037084b0a091f77","8079971ea09988a65bd425cbd4e81b875e0129f39d5e3dc37a6ad982d66d256b","bae24a77dc33ee3a80cbaf2dfbd7f7c6acb6edde4bff39163a3058c93c14e0d2","df2a52a6abb404f784afb39222e74db1074e040bdb1a62813b841d4f9d695438",{"version":"e5259ff908cd94f870b3aca05a2e2b5331da3b4d4b833e0e44a0051ac438b7ee","impliedFormat":1},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","impliedFormat":1},{"version":"79e16f645be25b58b2aba1a34c0cccf312eee59e99a37a2e1c32ff2f5b5f9fa0","impliedFormat":1},{"version":"2aee4866aaadd28a511b8ccbbf5de09b05d3fef4c3d4e2b925df67bd56379e32","impliedFormat":1},{"version":"567e539302fcf0cff85868ba28b1d377e577b7df2f9bd8f15ff294db6eab8f41","impliedFormat":1},{"version":"092fe093266abdd6a1d66bf0b7a5d6abbdcf7e860dfc0c8e0de6d4e262b147b6","impliedFormat":1},{"version":"d00cd46c963aa004e009458cae42d93d98266f82842f08836701b0abc7eaffa2","impliedFormat":1},{"version":"9d6edb8d1eef6ecbb7d5c031fd810b6fbd20f938ca36d620e1ff6f715ea7bdea","impliedFormat":1},{"version":"bd19825b929ed02095afc0d3353f270ca7ce5143055753174153d59a2cf76da2","impliedFormat":1},{"version":"95c11dddc8f61bfbf8e041e657d3f23d60db75fca921ad12ec86e479783f3734","impliedFormat":1},{"version":"0034b70dbb922bb3eb6102957948969d28a824256b48f5645df2ae7ef0f4fd3e","impliedFormat":1},{"version":"395225adaef9e8588113a04f07b6b387068cd8e7a0230cb059750653808669f2","impliedFormat":1},{"version":"c57b441e0c0a9cbdfa7d850dae1f8a387d6f81cbffbc3cd0465d530084c2417d","impliedFormat":99},{"version":"b406a701b85ba51c1e228606dc0e24fbe552df32935965bf34abe698554c0027","signature":"8b1f3922430b4de1ea290dc7219afc7023b5a8f4b5126c0bc2fb67d72239e667"},"ee083b355085d100be23d1decf29d53d4ddefdda6ae215abc7cbbf131735ceef",{"version":"571c9e6d4ec3635ef131f0767347d20e4c7868a2cd3ce56c6fb5f1c15f0f9c35","signature":"99a775f82d53af53fbd4340ea421d16d13698ce4108a268748ca2ba9c4ef5df3"},"4adacdd10660b791915a8c65ed0d10e65b3efe8efcc6adad409ee58408c27e29",{"version":"5d3e73857630bf788ead3d85f3ce9cbffd58501b7ec0df5a091f36dd7a5dff35","signature":"fcfa54765a6fc0a8f6131817215f0c174f2e4cc12ca05cd5a30980f1bd10453e"},{"version":"d51ce83f7eea5362f73585a4ec7ebf6f5774ad87dee6e880e17c0edd8d5d7a11","signature":"1a83d67b08524d4f1719c1e76cd5a6fd1ed58f3104e662dd9c0fd7569b30bdf7"},{"version":"ba82079753d7e454cf3b2a466960f90e2d645f54fb842a5d2bf8582d6398fb59","signature":"6801339821a88f89e3da45a07c2788c796f39159ab4a218a5c5622ec347aac60"},"4854ae5d9afcc0b47241b403a52eb2d9604755cd360d765eee87eebda2cab336",{"version":"b8d099fb63190d0c37a7000a2b8071311a3283e586fab8b716276ad7ca114320","signature":"ba40efe2d4b63d17837dfa911cc0a780c7ff1525a753edb7dd475a44acd3b74a"},"09862b74511910b955a0fb69a1dbb5f8906fe6f194a6079e4d61f8b77dd00ea0","5603469ac3dece937750f0813773a621ee6bbdb157f920b90252edb63efd6709","dd5daf84e0584133c7bcbb7d4952ea2d003d39d06ae3df29903c121004199212","73b47eba1b89d8932c9c1386ececd401508ba7a57c64faea6f702891e8030c59","24e78319c562dbcb524ab159ba98032eb5469d15077c95ec4844eb0c06b8e3d0",{"version":"aefac0e44e7040835309e120bf4e93a5955a3ca552ab5440c2db3d96b1dc90a9","signature":"0946a3931a4f5318dccd7dfe7393b40292a0fd4464b61c6276233c5dd9cbeb9a"},"8e50584d81029a0f06d91becc711ecef78f147a6e6f42397551aff560457f6b1","bd0e4be8ab627f0dfba25c7cafe5917faf502a874bf15e85d438101e7a6d94dd","8b7f1430b8036843e961d0a8e8d404acfb8de20ad9804b5ba249bae7c812d5da","91a09454a027d96924f4f3c3cf6468b33660236f9c2da44221f14dbbfd23df52","3b5660e245775469d77072ab4d9dfc1dd55184b8d883af4d2a9d89c5053855b5",{"version":"91b4ce96f6ad631a0a6920eb0ab928159ff01a439ae0e266ecdc9ea83126a195","impliedFormat":1},{"version":"e3448881d526bfca052d5f9224cc772f61d9fc84d0c52eb7154b13bd4db9d8b2","impliedFormat":1},{"version":"e348f128032c4807ad9359a1fff29fcbc5f551c81be807bfa86db5a45649b7ba","impliedFormat":1},{"version":"42f4d7040a48e5b9c9b20b5f17a04c381676211bdb0b5a580a183cf5908664be","impliedFormat":1},{"version":"d4e4fbb20d20cc5b9f4c85f2357f27cb233cd01f8ca6d85dcca905ec15143e06","impliedFormat":1},{"version":"c2fc483dea0580d1266c1500f17e49a739ca6cfe408691da638ddc211dfffad0","impliedFormat":1},{"version":"dfc8ab0e4a452b8361ccf895ab998bbf27d1f7608fae372ac6aa7f089ef7f68d","impliedFormat":1},{"version":"cca630c92b5382a0677d2dedca95e4e08a0cae660181d6d0dd8fd8bdb104d745","impliedFormat":1},{"version":"2ba3b0d5d868d292abf3e0101500dcbd8812fb7f536c73b581102686fdd621b4","impliedFormat":1},{"version":"c16c3b97930e8fbf05022024f049d51c998dd5eb6509047e1f841777968e85c1","impliedFormat":1},{"version":"cce15e7530c8062dea0666a174f31c1fe445a97357885480748b072778fc6f36","impliedFormat":1},{"version":"535b2fc8c89091c20124fe144699bb4a96d5db4418a1594a9a0a6a863b2195ae","impliedFormat":1},{"version":"dd5165bf834f6e784b4aad9fae6d84307c19f140829e4c6c4123b2d1a707d8bd","impliedFormat":1},{"version":"7ccf260729e19eed74c34046b38b6957bcfe4784d94f76eb830a70fc5d59cb43","impliedFormat":1},{"version":"21575cdeaca6a2c2a0beb8c2ecbc981d9deb95f879f82dc7d6e325fe8737b5ba","impliedFormat":1},{"version":"00343c2c578a0e32ecc384ed779ff39bc7ec6778ef84dc48106b602eb5598a6c","impliedFormat":1},{"version":"c333b496e7676a8b84c720bdece6c34621e3945b7d1710d6ed85d8b742852825","impliedFormat":1},{"version":"3eb7d541136cd8b66020417086e4f481fb1ae0e2b916846d43cbf0b540371954","impliedFormat":1},{"version":"b6fed756be83482969cd037fb707285d46cbb03a19dc576cff8179dc55540727","impliedFormat":1},{"version":"26602933b613e4df3868a6c82e14fffa2393a08531cb333ed27b151923462981","impliedFormat":1},{"version":"8fc19c7114cfd352ff9fb615028e6062cb9fa3cd59c4850bc6c5634b9f57ea27","impliedFormat":1},{"version":"05942150b4d7e0eb991776b1905487ecd94e7299847bb251419c99658363ff84","impliedFormat":1},{"version":"073c43eff28f369a05973364a5c466859867661670eb28e1b6f3dd0654dd0f0e","impliedFormat":1},{"version":"4a7c3274af9c78f7b4328f1e673dec81f48dd75da3bc159780fb4a13238b6684","impliedFormat":1},{"version":"1134991f69fff6f08bd44144518ae14bc294d6076dba8a09574ae918088c5737","impliedFormat":1},{"version":"259a3d89235d858b3d495dc2d1d610d6ce4ac0e91da1ae6a293f250d895d45dd","impliedFormat":1},{"version":"369b7270eeeb37982203b2cb18c7302947b89bf5818c1d3d2e95a0418f02b74e","impliedFormat":1},{"version":"f4c772371ce8ceaab394e1f8af9a6e502f0c02cbf184632dd6e64a00b8aeaf74","impliedFormat":1},{"version":"039bd8d1e0d151570b66e75ee152877fb0e2f42eca43718632ac195e6884be34","impliedFormat":1},{"version":"89fb1e22c3c98cbb86dc3e5949012bdae217f2b5d768a2cc74e1c4b413c25ad2","impliedFormat":1},{"version":"855b9b5ec5cc534fcf9c2799a2c7909b623fcb4ed1a39b51d7c9f6c38adec214","impliedFormat":1},{"version":"58da08d1fe876c79c47dcf88be37c5c3fab55d97b34c8c09a666599a2191208d","impliedFormat":1},{"version":"7e541f14d37444b3f3c3580ed807c36733f9c9137fe7297f46732a459b7e2739","signature":"754cfa02470aa619c32f0bfcd45bd79448307dc2084d91faba186b22614a2bc5"},"ae3da770eeee627cdae660f56b9caddd0461aa044180b71c647d530c8eeb8403","bbab50527f325d4e80133ce651f64ea3dc1f27399b5735c52f08129b78c8bf09","38269b55458b54b8a6d555c68861332f7b77ff117142150ff8826e66603098fa","df40cc3cbe86197d5e37cc4587c577e061e43dfb43ae6e008778bae4335540d6","7f02d3dd69e1ac0b5bc3273cf85bff845aded8c19bf9941fa81543aaf1aa514e","d3fda1b56c7504b55de0ce37d2ad5650281df47a2c9f2ca7cd0f2c579a8f2e43","cbae4ef4a9000af7e553d0a4fc79b191df7c0b9aad7a7db8771a3aa8cb1c00e5","28df56ca2d784df73ac68181c83aeadafd6ded9cdae6847f53f4359e6e9baea2","b2cd408850ad9df8a41aa93085c6fbf47ed5aec4154dc59b8837f8b4d700ef24",{"version":"e4aef6fa7bad1326f8edc066ad796a1634aaec954a97b878d46a7804c73bfdb5","signature":"ce5775cb39c31f310b1abcade60187859a376af581aeb9cb644ce7bd2fc158c8"},"d381452bb935754c0c86bf185f1cecd58800e3cf9147748c8ec80a92fc852e6e","2971e28479774838c8f06bc7fa913924106d45877c58da7f2a4b97d6bd76516d",{"version":"92ec74a116d454015264ec3a1238416c800b352c423b0773deee6f5ed6ddce8b","signature":"4536241a2b6b50c093c96b45b6249d9bf51ad460b8a5f5b67b7e012e0d03c05d"},"5b583a7f8b2fc3c7f29e8aebc73c99cbe8df5f6e59a0c2427f90e89f8bdfb13d","a2eb6498e964b2418d0d178da23c426dbd28eaa8392aab8d7efaacddea9a5b9f","590cdcbea0c9d5bfa62a9b76fc1ad7a2fba8b14a43c08ea8cc3bde29911cbe62","c060799d029416006ea24e10c86c8f1cdf2bb32b6fa2478cb4a941798bbb15f7","616a980aca196d3779b5b483ddba67c2ce2f7ae0a3c344dd13b400de2e601d7e","a3e4d5b357b8059bbe77f1ba4e1b5ac418c887391ac17f752c06fb45750de0e4","b8d3773a936faaccb054f7ab53a3794816556df48fc7ff443749e32eba63b94a",{"version":"a7ca8df4f2931bef2aa4118078584d84a0b16539598eaadf7dce9104dfaa381c","impliedFormat":1},{"version":"11443a1dcfaaa404c68d53368b5b818712b95dd19f188cab1669c39bee8b84b3","impliedFormat":1},{"version":"36977c14a7f7bfc8c0426ae4343875689949fb699f3f84ecbe5b300ebf9a2c55","impliedFormat":1},{"version":"7f698624bbbb060ece7c0e51b7236520ebada74b747d7523c7df376453ed6fea","impliedFormat":1},{"version":"19efad8495a7a6b064483fccd1d2b427403dd84e67819f86d1c6ee3d7abf749c","affectsGlobalScope":true,"impliedFormat":1},{"version":"1eef826bc4a19de22155487984e345a34c9cd511dd1170edc7a447cb8231dd4a","affectsGlobalScope":true,"impliedFormat":99},{"version":"6bfdbd285cb25907f31ba9db446e61eb2aa55cf46220ebe5fceaa18b2a0b26e8","affectsGlobalScope":true},"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","9e5985581e4bf2d7d7ebebc5464404da39c4eb552ee07bcbceb669c108352a99","e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",{"version":"0b026968c66026a85c16aab5581ca03688da142dbc2bc5afcbaec968a8d27350","signature":"1c57ca6c8cb5921c3246069f61d6479fcb659bb2de7406c009aad991aa1c3b9b"},"c536f8088dfa642e704423872fd8017c3049f6ee2046465ea879494cae0650f1","3ffc7b76098f0c4688a57b9a2948efff274e65571b871f3f6dcc43786a0eddee",{"version":"55aec2fd68e2abfa451013955bd6cac5618f5bc814cb0131d9c3d0e9283dfafa","signature":"8f42eae87de9268f64d1e20cb3359677a1ca99dc0205c993ca00747667674489"},"e3f335a00c1b2165f20e125cb29536b75b4fcc95c1760f2803b95feb2ded15bd",{"version":"73a0ee6395819b063df4b148211985f2e1442945c1a057204cf4cf6281760dc3","affectsGlobalScope":true,"impliedFormat":1},{"version":"d05d8c67116dceafc62e691c47ac89f8f10cf7313cd1b2fb4fe801c2bf1bb1a7","impliedFormat":1},{"version":"3c5bb5207df7095882400323d692957e90ec17323ccff5fd5f29a1ecf3b165d0","impliedFormat":1},{"version":"7959556e748fee75a1ed10822bed4e92edae99ed75e428b650376bacb20ce257","signature":"5a81de12417101e8964d1370944563204a470760be39079613c910b549a0b53a"},"4ca18ab8cd76fb5fb9f7013772f05cdf7b17cdd2f65c39a20399c01c04952a9e",{"version":"a0acca63c9e39580f32a10945df231815f0fe554c074da96ba6564010ffbd2d8","impliedFormat":1},{"version":"70521b6ab0dcba37539e5303104f29b721bfb2940b2776da4cc818c07e1fefc1","affectsGlobalScope":true,"impliedFormat":1},{"version":"030e350db2525514580ed054f712ffb22d273e6bc7eddc1bb7eda1e0ba5d395e","affectsGlobalScope":true,"impliedFormat":1},{"version":"d153a11543fd884b596587ccd97aebbeed950b26933ee000f94009f1ab142848","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"a79e62f1e20467e11a904399b8b18b18c0c6eea6b50c1168bf215356d5bebfaf","affectsGlobalScope":true,"impliedFormat":1},{"version":"d802f0e6b5188646d307f070d83512e8eb94651858de8a82d1e47f60fb6da4e2","affectsGlobalScope":true,"impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"3b724a66c071d616203133f8d099a0cb881b0b43fd42e8621e611243c5f30cd6","affectsGlobalScope":true,"impliedFormat":1},{"version":"a38efe83ff77c34e0f418a806a01ca3910c02ee7d64212a59d59bca6c2c38fa1","impliedFormat":1},{"version":"7394959e5a741b185456e1ef5d64599c36c60a323207450991e7a42e08911419","impliedFormat":1},{"version":"3fe4022ba1e738034e38ad9afacbf0f1f16b458ed516326f5bf9e4a31e9be1dc","impliedFormat":1},{"version":"a957197054b074bcdf5555d26286e8461680c7c878040d0f4e2d5509a7524944","affectsGlobalScope":true,"impliedFormat":1},{"version":"4314c7a11517e221f7296b46547dbc4df047115b182f544d072bdccffa57fc72","impliedFormat":1},{"version":"e9b97d69510658d2f4199b7d384326b7c4053b9e6645f5c19e1c2a54ede427fc","impliedFormat":1},{"version":"c2510f124c0293ab80b1777c44d80f812b75612f297b9857406468c0f4dafe29","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"f478f6f5902dc144c0d6d7bdc919c5177cac4d17a8ca8653c2daf6d7dc94317f","affectsGlobalScope":true,"impliedFormat":1},{"version":"19d5f8d3930e9f99aa2c36258bf95abbe5adf7e889e6181872d1cdba7c9a7dd5","impliedFormat":1},{"version":"b200675fd112ffef97c166d0341fb33f6e29e9f27660adde7868e95c5bc98beb","impliedFormat":1},{"version":"a6bf63d17324010ca1fbf0389cab83f93389bb0b9a01dc8a346d092f65b3605f","impliedFormat":1},{"version":"e009777bef4b023a999b2e5b9a136ff2cde37dc3f77c744a02840f05b18be8ff","impliedFormat":1},{"version":"1e0d1f8b0adfa0b0330e028c7941b5a98c08b600efe7f14d2d2a00854fb2f393","impliedFormat":1},{"version":"ee1ee365d88c4c6c0c0a5a5701d66ebc27ccd0bcfcfaa482c6e2e7fe7b98edf7","affectsGlobalScope":true,"impliedFormat":1},{"version":"88bc59b32d0d5b4e5d9632ac38edea23454057e643684c3c0b94511296f2998c","affectsGlobalScope":true,"impliedFormat":1},{"version":"a0a1dda070290b92da5a50113b73ecc4dd6bcbffad66e3c86503d483eafbadcf","impliedFormat":1},{"version":"59dcad36c4549175a25998f6a8b33c1df8e18df9c12ebad1dfb25af13fd4b1ce","impliedFormat":1},{"version":"9ba5b6a30cb7961b68ad4fb18dca148db151c2c23b8d0a260fc18b83399d19d3","impliedFormat":1},{"version":"3f3edb8e44e3b9df3b7ca3219ab539710b6a7f4fe16bd884d441af207e03cd57","impliedFormat":1},{"version":"528b62e4272e3ddfb50e8eed9e359dedea0a4d171c3eb8f337f4892aac37b24b","impliedFormat":1},{"version":"d71535813e39c23baa113bc4a29a0e187b87d1105ccc8c5a6ebaca38d9a9bff2","impliedFormat":1},{"version":"8cf7e92bdb2862c2d28ba4535c43dc599cfbc0025db5ed9973d9b708dcbe3d98","affectsGlobalScope":true,"impliedFormat":1},{"version":"278e70975bd456bba5874eaee17692355432e8d379b809a97f6af0eee2b702d8","impliedFormat":1},{"version":"b1b6ee0d012aeebe11d776a155d8979730440082797695fc8e2a5c326285678f","impliedFormat":1},{"version":"45875bcae57270aeb3ebc73a5e3fb4c7b9d91d6b045f107c1d8513c28ece71c0","impliedFormat":1},{"version":"1dc73f8854e5c4506131c4d95b3a6c24d0c80336d3758e95110f4c7b5cb16397","affectsGlobalScope":true,"impliedFormat":1},{"version":"636302a00dfd1f9fe6e8e91e4e9350c6518dcc8d51a474e4fc3a9ba07135100b","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f16a7e4deafa527ed9995a772bb380eb7d3c2c0fd4ae178c5263ed18394db2c","impliedFormat":1},{"version":"933921f0bb0ec12ef45d1062a1fc0f27635318f4d294e4d99de9a5493e618ca2","impliedFormat":1},{"version":"71a0f3ad612c123b57239a7749770017ecfe6b66411488000aba83e4546fde25","impliedFormat":1},{"version":"8145e07aad6da5f23f2fcd8c8e4c5c13fb26ee986a79d03b0829b8fce152d8b2","impliedFormat":1},{"version":"e1120271ebbc9952fdc7b2dd3e145560e52e06956345e6fdf91d70ca4886464f","impliedFormat":1},{"version":"814118df420c4e38fe5ae1b9a3bafb6e9c2aa40838e528cde908381867be6466","impliedFormat":1},{"version":"e1ce1d622f1e561f6cdf246372ead3bbc07ce0342024d0e9c7caf3136f712698","impliedFormat":1},{"version":"c878f74b6d10b267f6075c51ac1d8becd15b4aa6a58f79c0cfe3b24908357f60","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"125d792ec6c0c0f657d758055c494301cc5fdb327d9d9d5960b3f129aff76093","impliedFormat":1},{"version":"27e4532aaaa1665d0dd19023321e4dc12a35a741d6b8e1ca3517fcc2544e0efe","affectsGlobalScope":true,"impliedFormat":1},{"version":"2754d8221d77c7b382096651925eb476f1066b3348da4b73fe71ced7801edada","impliedFormat":1},{"version":"8c2ad42d5d1a2e8e6112625767f8794d9537f1247907378543106f7ba6c7df90","affectsGlobalScope":true,"impliedFormat":1},{"version":"f0be1b8078cd549d91f37c30c222c2a187ac1cf981d994fb476a1adc61387b14","affectsGlobalScope":true,"impliedFormat":1},{"version":"0aaed1d72199b01234152f7a60046bc947f1f37d78d182e9ae09c4289e06a592","impliedFormat":1},{"version":"98ffdf93dfdd206516971d28e3e473f417a5cfd41172e46b4ce45008f640588e","impliedFormat":1},{"version":"66ba1b2c3e3a3644a1011cd530fb444a96b1b2dfe2f5e837a002d41a1a799e60","impliedFormat":1},{"version":"7e514f5b852fdbc166b539fdd1f4e9114f29911592a5eb10a94bb3a13ccac3c4","impliedFormat":1},{"version":"7d6ff413e198d25639f9f01f16673e7df4e4bd2875a42455afd4ecc02ef156da","affectsGlobalScope":true,"impliedFormat":1},{"version":"12e8ce658dd17662d82fb0509d2057afc5e6ee30369a2e9e0957eff725b1f11d","affectsGlobalScope":true,"impliedFormat":1},{"version":"74736930d108365d7bbe740c7154706ccfb1b2a3855a897963ab3e5c07ecbf19","impliedFormat":1},{"version":"858f999b3e4a45a4e74766d43030941466460bf8768361d254234d5870480a53","impliedFormat":1},{"version":"ac5ed35e649cdd8143131964336ab9076937fa91802ec760b3ea63b59175c10a","impliedFormat":1},{"version":"63b05afa6121657f25e99e1519596b0826cda026f09372c9100dfe21417f4bd6","affectsGlobalScope":true,"impliedFormat":1},{"version":"3797dd6f4ea3dc15f356f8cdd3128bfa18122213b38a80d6c1f05d8e13cbdad8","impliedFormat":1},{"version":"ad90122e1cb599b3bc06a11710eb5489101be678f2920f2322b0ac3e195af78d","impliedFormat":1}],"root":[[157,171],[229,235],[249,268],[301,321],[328,338],342,343],"options":{"allowImportingTsExtensions":true,"allowSyntheticDefaultImports":true,"composite":true,"declaration":true,"esModuleInterop":true,"jsx":4,"module":99,"noFallthroughCasesInSwitch":true,"noUnusedLocals":true,"noUnusedParameters":true,"skipLibCheck":true,"strict":true,"target":7,"useDefineForClassFields":true},"referencedMap":[[265,1],[266,2],[267,3],[268,4],[233,1],[235,5],[234,6],[329,7],[330,7],[256,8],[254,9],[255,10],[303,11],[301,12],[302,13],[321,14],[317,15],[318,16],[314,7],[315,17],[312,18],[311,7],[307,19],[310,15],[319,20],[316,21],[309,15],[313,22],[308,15],[320,23],[231,24],[232,25],[230,7],[264,26],[263,9],[331,7],[249,27],[250,28],[251,29],[252,30],[257,7],[258,31],[259,15],[260,32],[306,33],[305,34],[262,35],[261,15],[332,36],[304,37],[333,7],[253,38],[336,39],[335,40],[334,29],[328,41],[337,7],[338,42],[300,43],[299,44],[62,45],[142,46],[140,47],[141,48],[125,49],[93,50],[104,51],[105,52],[106,53],[126,54],[107,55],[128,56],[108,57],[102,58],[111,59],[110,59],[109,60],[123,50],[103,61],[112,62],[117,63],[113,50],[124,7],[127,7],[114,49],[92,64],[94,49],[101,65],[115,66],[121,45],[95,67],[97,67],[120,67],[96,67],[116,49],[98,67],[100,49],[118,49],[119,67],[99,67],[122,59],[78,68],[61,69],[91,70],[65,71],[64,72],[75,45],[68,73],[67,74],[79,75],[80,45],[74,76],[69,45],[71,77],[66,78],[77,79],[70,80],[89,81],[81,82],[73,83],[76,45],[82,84],[90,85],[88,86],[87,86],[86,87],[85,86],[83,86],[84,86],[63,74],[72,88],[57,89],[58,90],[60,91],[59,92],[56,93],[55,45],[389,94],[390,94],[391,95],[350,96],[392,97],[393,98],[394,99],[345,45],[348,100],[346,45],[347,45],[395,101],[396,102],[397,103],[398,104],[399,105],[400,106],[401,106],[403,107],[402,108],[404,109],[405,110],[406,111],[388,112],[349,45],[407,113],[408,114],[409,115],[442,116],[410,117],[411,118],[412,119],[413,120],[414,121],[415,122],[416,123],[417,124],[418,125],[419,126],[420,126],[421,127],[422,45],[423,45],[424,128],[426,129],[425,130],[427,131],[428,132],[429,133],[430,134],[431,135],[432,136],[433,137],[434,138],[435,139],[436,140],[437,141],[438,142],[439,143],[440,144],[441,145],[52,59],[344,59],[48,45],[50,146],[51,59],[248,45],[49,45],[341,147],[340,148],[339,45],[236,59],[247,149],[241,45],[242,59],[240,45],[245,45],[243,150],[244,45],[237,45],[246,45],[239,151],[238,45],[199,152],[226,153],[200,153],[201,153],[188,152],[202,45],[176,152],[203,153],[204,45],[205,152],[206,153],[207,153],[208,45],[209,45],[219,153],[210,153],[187,152],[211,153],[212,45],[213,45],[214,45],[215,153],[189,153],[177,153],[191,153],[192,153],[193,154],[194,153],[195,153],[198,155],[173,45],[227,156],[228,153],[181,153],[185,153],[183,153],[175,45],[216,153],[182,153],[186,45],[217,153],[222,153],[218,153],[179,45],[184,153],[197,153],[220,153],[196,153],[221,153],[190,153],[180,152],[223,153],[178,45],[224,45],[172,45],[225,45],[174,45],[269,45],[284,157],[285,157],[298,158],[286,159],[287,159],[288,160],[282,161],[280,162],[271,45],[275,163],[279,164],[277,165],[283,166],[272,167],[273,168],[274,169],[276,170],[278,171],[281,172],[289,159],[290,159],[291,159],[292,157],[293,159],[294,159],[270,159],[295,45],[297,173],[296,159],[139,174],[130,175],[138,176],[137,176],[129,45],[136,177],[131,178],[132,179],[134,176],[135,180],[133,178],[53,45],[54,45],[46,45],[47,45],[8,45],[9,45],[11,45],[10,45],[2,45],[12,45],[13,45],[14,45],[15,45],[16,45],[17,45],[18,45],[19,45],[3,45],[20,45],[21,45],[4,45],[22,45],[26,45],[23,45],[24,45],[25,45],[27,45],[28,45],[29,45],[5,45],[30,45],[31,45],[32,45],[33,45],[6,45],[37,45],[34,45],[35,45],[36,45],[38,45],[7,45],[39,45],[44,45],[45,45],[40,45],[41,45],[42,45],[43,45],[1,45],[366,181],[376,182],[365,181],[386,183],[357,184],[356,185],[385,186],[379,187],[384,188],[359,189],[373,190],[358,191],[382,192],[354,193],[353,186],[383,194],[355,195],[360,196],[361,45],[364,196],[351,45],[387,197],[377,198],[368,199],[369,200],[371,201],[367,202],[370,203],[380,186],[362,204],[363,205],[372,206],[352,207],[375,198],[374,196],[378,45],[381,208],[327,209],[323,210],[322,45],[324,211],[325,45],[326,212],[156,213],[147,214],[154,215],[149,45],[150,45],[148,216],[151,213],[143,45],[144,45],[155,217],[146,218],[152,45],[153,219],[145,220],[161,221],[162,45],[164,222],[163,45],[342,223],[343,224],[229,45],[159,225],[170,226],[171,227],[158,228],[160,225],[165,229],[168,230],[157,230],[169,231],[166,228],[167,228]],"semanticDiagnosticsPerFile":[[233,[{"start":59,"length":8,"messageText":"Module '\"@ledgerly/shared/types\"' has no exported member 'Currency'.","category":1,"code":2305}]],[234,[{"start":9,"length":8,"messageText":"Module '\"@ledgerly/shared/types\"' has no exported member 'Currency'.","category":1,"code":2305}]],[301,[{"start":124,"length":11,"messageText":"Module '\"@ledgerly/shared/types\"' has no exported member 'AccountType'.","category":1,"code":2305},{"start":137,"length":8,"messageText":"Module '\"@ledgerly/shared/types\"' has no exported member 'Currency'.","category":1,"code":2305},{"start":1728,"length":8,"code":2322,"category":1,"messageText":{"messageText":"Type 'Resolver<{ type: AccountTypeValue; name: string; currency: string; initialBalance: string; description?: string | undefined; }, any, { type: AccountTypeValue; name: string; description: string; currency: string & BRAND<...>; initialBalance: string & BRAND<...>; }>' is not assignable to type 'Resolver'.","category":1,"code":2322,"next":[{"messageText":"Types of parameters 'options' and 'options' are incompatible.","category":1,"code":2328,"next":[{"messageText":"Type 'ResolverOptions' is not assignable to type 'ResolverOptions<{ type: AccountTypeValue; name: string; currency: string; initialBalance: string; description?: string | undefined; }>'.","category":1,"code":2322,"next":[{"messageText":"Types of property 'names' are incompatible.","category":1,"code":2326,"next":[{"messageText":"Type 'string[] | undefined' is not assignable to type '(\"description\" | requiredKeys<{ currency: string; description: string | undefined; initialBalance: string; name: string; type: AccountTypeValue; }>)[] | undefined'.","category":1,"code":2322,"next":[{"messageText":"Type 'string[]' is not assignable to type '(\"description\" | requiredKeys<{ currency: string; description: string | undefined; initialBalance: string; name: string; type: AccountTypeValue; }>)[]'.","category":1,"code":2322,"next":[{"messageText":"Type 'string' is not assignable to type '\"description\" | requiredKeys<{ currency: string; description: string | undefined; initialBalance: string; name: string; type: AccountTypeValue; }>'.","category":1,"code":2322}]}],"canonicalHead":{"code":2322,"messageText":"Type 'ResolverOptions' is not assignable to type 'ResolverOptions<{ type: AccountTypeValue; name: string; currency: string; initialBalance: string; description?: string | undefined; }>'."}}]}]}]}]}},{"start":1822,"length":12,"code":2551,"category":1,"messageText":"Property 'currencyCode' does not exist on type 'AccountDomain'. Did you mean 'currency'?","relatedInformation":[{"file":"../../packages/shared/src/types/account.ts","start":266,"length":8,"messageText":"'currency' is declared here.","category":3,"code":2728}]},{"start":1922,"length":14,"code":2322,"category":1,"messageText":{"messageText":"Type 'number' is not assignable to type 'string & BRAND<\"MoneyString\">'.","category":1,"code":2322,"next":[{"messageText":"Type 'number' is not assignable to type 'string'.","category":1,"code":2322}]}},{"start":2587,"length":16,"code":2345,"category":1,"messageText":{"messageText":"Argument of type '(data: AccountCreateDTO) => void' is not assignable to parameter of type 'SubmitHandler'.","category":1,"code":2345,"next":[{"messageText":"Types of parameters 'data' and 'data' are incompatible.","category":1,"code":2328,"next":[{"messageText":"Type 'TFieldValues' is not assignable to type 'AccountCreateDTO'.","category":1,"code":2322,"next":[{"messageText":"Type 'FieldValues' is missing the following properties from type 'AccountCreateDTO': currency, description, initialBalance, name, type","category":1,"code":2739,"canonicalHead":{"code":2322,"messageText":"Type 'FieldValues' is not assignable to type 'AccountCreateDTO'."}}]}]}]}},{"start":4205,"length":15,"code":2345,"category":1,"messageText":"Argument of type '\"currency_code\"' is not assignable to parameter of type '\"type\" | \"name\" | \"description\" | \"currency\" | \"isSystem\" | \"initialBalance\"'."},{"start":4499,"length":12,"code":2551,"category":1,"messageText":"Property 'currencyCode' does not exist on type 'FieldErrors'. Did you mean 'currency'?"},{"start":4565,"length":12,"code":2551,"category":1,"messageText":"Property 'currencyCode' does not exist on type 'FieldErrors'. Did you mean 'currency'?"}]]],"affectedFilesPendingEmit":[265,266,267,268,233,235,234,329,330,256,254,255,303,301,302,321,317,318,314,315,312,311,307,310,319,316,309,313,308,320,231,232,230,264,263,331,249,250,251,252,257,258,259,260,306,305,262,261,332,304,333,253,336,335,334,337,338,161,162,164,163,342,343,229,159,170,171,158,160,165,168,157,169,166,167],"emitSignatures":[157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,229,230,231,232,233,234,235,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,329,330,331,332,333,334,335,336,337,338,342,343],"version":"5.8.3"} \ No newline at end of file +{"fileNames":["../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.decorators.d.ts","../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../node_modules/.pnpm/@types+react@19.1.4/node_modules/@types/react/global.d.ts","../../node_modules/.pnpm/csstype@3.1.3/node_modules/csstype/index.d.ts","../../node_modules/.pnpm/@types+react@19.1.4/node_modules/@types/react/index.d.ts","../../node_modules/.pnpm/@types+react@19.1.4/node_modules/@types/react/jsx-runtime.d.ts","./src/app.tsx","../../node_modules/.pnpm/@types+react-dom@19.1.5_@types+react@19.1.4/node_modules/@types/react-dom/client.d.ts","./src/main.tsx","../../node_modules/.pnpm/vite@6.3.5_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vite/types/hmrpayload.d.ts","../../node_modules/.pnpm/vite@6.3.5_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vite/types/customevent.d.ts","../../node_modules/.pnpm/vite@6.3.5_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vite/types/hot.d.ts","../../node_modules/.pnpm/vite@6.3.5_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vite/types/importglob.d.ts","../../node_modules/.pnpm/vite@6.3.5_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vite/types/importmeta.d.ts","../../node_modules/.pnpm/vite@6.3.5_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vite/client.d.ts","./src/vite-env.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/helpers/typealiases.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/helpers/util.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/zoderror.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/locales/en.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/errors.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/helpers/parseutil.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/helpers/enumutil.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/helpers/errorutil.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/helpers/partialutil.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/standard-schema.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/types.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/external.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/lib/index.d.ts","../../node_modules/.pnpm/zod@3.24.4/node_modules/zod/index.d.ts","../../packages/shared/src/constants/pagination.ts","../../packages/shared/src/constants/transactions.ts","../../packages/shared/src/validation/basevalidations.ts","../../packages/shared/src/types/types.ts","../../packages/shared/src/types/account.ts","../../packages/shared/src/types/user.ts","../../packages/shared/src/constants/currencies.ts","../../packages/shared/src/constants/operations.ts","../../packages/shared/src/constants/index.ts","../../packages/shared/src/validation/accounts.ts","../../packages/shared/src/validation/transactions.ts","../../packages/shared/src/validation/users.ts","../../packages/shared/src/validation/auth.ts","../../packages/shared/src/validation/commodities.ts","../../packages/shared/src/validation/index.ts","../../packages/shared/src/types/auth.ts","../../packages/shared/src/types/error-contract.ts","../../packages/shared/src/types/pagination.ts","../../packages/shared/src/types/transaction.ts","../../packages/shared/src/types/commodity.ts","../../packages/shared/src/types/index.ts","../../packages/shared/src/constants/accounts.ts","../../node_modules/.pnpm/dayjs@1.11.13/node_modules/dayjs/locale/types.d.ts","../../node_modules/.pnpm/dayjs@1.11.13/node_modules/dayjs/locale/index.d.ts","../../node_modules/.pnpm/dayjs@1.11.13/node_modules/dayjs/index.d.ts","../../packages/shared/src/libs/formatters.ts","../../packages/shared/src/libs/index.ts","../../packages/shared/src/routes/index.ts","../../node_modules/.pnpm/@vitest+pretty-format@3.2.4/node_modules/@vitest/pretty-format/dist/index.d.ts","../../node_modules/.pnpm/@vitest+utils@3.2.4/node_modules/@vitest/utils/dist/types.d.ts","../../node_modules/.pnpm/@vitest+utils@3.2.4/node_modules/@vitest/utils/dist/helpers.d.ts","../../node_modules/.pnpm/tinyrainbow@2.0.0/node_modules/tinyrainbow/dist/index-8b61d5bc.d.ts","../../node_modules/.pnpm/tinyrainbow@2.0.0/node_modules/tinyrainbow/dist/node.d.ts","../../node_modules/.pnpm/@vitest+utils@3.2.4/node_modules/@vitest/utils/dist/index.d.ts","../../node_modules/.pnpm/@vitest+runner@3.2.4/node_modules/@vitest/runner/dist/tasks.d-cksck4of.d.ts","../../node_modules/.pnpm/@vitest+utils@3.2.4/node_modules/@vitest/utils/dist/types.d-bcelap-c.d.ts","../../node_modules/.pnpm/@vitest+utils@3.2.4/node_modules/@vitest/utils/dist/diff.d.ts","../../node_modules/.pnpm/@vitest+runner@3.2.4/node_modules/@vitest/runner/dist/types.d.ts","../../node_modules/.pnpm/@vitest+utils@3.2.4/node_modules/@vitest/utils/dist/error.d.ts","../../node_modules/.pnpm/@vitest+runner@3.2.4/node_modules/@vitest/runner/dist/index.d.ts","../../node_modules/.pnpm/vitest@3.2.4_@types+node@22.15.18_@vitest+ui@3.2.4_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vitest/optional-types.d.ts","../../node_modules/.pnpm/vitest@3.2.4_@types+node@22.15.18_@vitest+ui@3.2.4_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vitest/dist/chunks/environment.d.cl3nlxbe.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/compatibility/disposable.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/compatibility/indexable.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/compatibility/iterators.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/compatibility/index.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/globals.typedarray.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/buffer.buffer.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/header.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/readable.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/file.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/fetch.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/formdata.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/connector.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/client.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/errors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/dispatcher.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/global-dispatcher.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/global-origin.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/pool-stats.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/handlers.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/balanced-pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-interceptor.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-client.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-errors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/proxy-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/env-http-proxy-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/retry-handler.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/retry-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/api.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/interceptors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/util.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/cookies.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/patch.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/websocket.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/eventsource.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/filereader.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/diagnostics-channel.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/content-type.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/cache.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/index.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/globals.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/assert.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/assert/strict.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/async_hooks.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/buffer.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/child_process.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/cluster.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/console.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/constants.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/crypto.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/dgram.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/diagnostics_channel.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/dns.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/dns/promises.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/domain.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/dom-events.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/events.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/fs.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/fs/promises.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/http.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/http2.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/https.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/inspector.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/module.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/net.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/os.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/path.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/perf_hooks.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/process.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/punycode.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/querystring.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/readline.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/readline/promises.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/repl.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/sea.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/sqlite.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/stream.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/stream/promises.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/stream/consumers.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/stream/web.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/string_decoder.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/test.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/timers.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/timers/promises.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/tls.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/trace_events.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/tty.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/url.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/util.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/v8.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/vm.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/wasi.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/worker_threads.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/zlib.d.ts","../../node_modules/.pnpm/@types+node@22.15.18/node_modules/@types/node/index.d.ts","../../node_modules/.pnpm/@types+estree@1.0.7/node_modules/@types/estree/index.d.ts","../../node_modules/.pnpm/rollup@4.40.2/node_modules/rollup/dist/rollup.d.ts","../../node_modules/.pnpm/rollup@4.40.2/node_modules/rollup/dist/parseast.d.ts","../../node_modules/.pnpm/vite@6.3.5_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vite/dist/node/modulerunnertransport.d-dj_me5sf.d.ts","../../node_modules/.pnpm/vite@6.3.5_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vite/dist/node/module-runner.d.ts","../../node_modules/.pnpm/esbuild@0.25.4/node_modules/esbuild/lib/main.d.ts","../../node_modules/.pnpm/source-map-js@1.2.1/node_modules/source-map-js/source-map.d.ts","../../node_modules/.pnpm/postcss@8.5.3/node_modules/postcss/lib/previous-map.d.ts","../../node_modules/.pnpm/postcss@8.5.3/node_modules/postcss/lib/input.d.ts","../../node_modules/.pnpm/postcss@8.5.3/node_modules/postcss/lib/css-syntax-error.d.ts","../../node_modules/.pnpm/postcss@8.5.3/node_modules/postcss/lib/declaration.d.ts","../../node_modules/.pnpm/postcss@8.5.3/node_modules/postcss/lib/root.d.ts","../../node_modules/.pnpm/postcss@8.5.3/node_modules/postcss/lib/warning.d.ts","../../node_modules/.pnpm/postcss@8.5.3/node_modules/postcss/lib/lazy-result.d.ts","../../node_modules/.pnpm/postcss@8.5.3/node_modules/postcss/lib/no-work-result.d.ts","../../node_modules/.pnpm/postcss@8.5.3/node_modules/postcss/lib/processor.d.ts","../../node_modules/.pnpm/postcss@8.5.3/node_modules/postcss/lib/result.d.ts","../../node_modules/.pnpm/postcss@8.5.3/node_modules/postcss/lib/document.d.ts","../../node_modules/.pnpm/postcss@8.5.3/node_modules/postcss/lib/rule.d.ts","../../node_modules/.pnpm/postcss@8.5.3/node_modules/postcss/lib/node.d.ts","../../node_modules/.pnpm/postcss@8.5.3/node_modules/postcss/lib/comment.d.ts","../../node_modules/.pnpm/postcss@8.5.3/node_modules/postcss/lib/container.d.ts","../../node_modules/.pnpm/postcss@8.5.3/node_modules/postcss/lib/at-rule.d.ts","../../node_modules/.pnpm/postcss@8.5.3/node_modules/postcss/lib/list.d.ts","../../node_modules/.pnpm/postcss@8.5.3/node_modules/postcss/lib/postcss.d.ts","../../node_modules/.pnpm/postcss@8.5.3/node_modules/postcss/lib/postcss.d.mts","../../node_modules/.pnpm/lightningcss@1.30.1/node_modules/lightningcss/node/ast.d.ts","../../node_modules/.pnpm/lightningcss@1.30.1/node_modules/lightningcss/node/targets.d.ts","../../node_modules/.pnpm/lightningcss@1.30.1/node_modules/lightningcss/node/index.d.ts","../../node_modules/.pnpm/vite@6.3.5_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vite/types/internal/lightningcssoptions.d.ts","../../node_modules/.pnpm/vite@6.3.5_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vite/types/internal/csspreprocessoroptions.d.ts","../../node_modules/.pnpm/vite@6.3.5_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vite/types/metadata.d.ts","../../node_modules/.pnpm/vite@6.3.5_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vite/dist/node/index.d.ts","../../node_modules/.pnpm/@vitest+mocker@3.2.4_vite@6.3.5_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1_/node_modules/@vitest/mocker/dist/registry.d-d765pazg.d.ts","../../node_modules/.pnpm/@vitest+mocker@3.2.4_vite@6.3.5_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1_/node_modules/@vitest/mocker/dist/types.d-d_arzrdy.d.ts","../../node_modules/.pnpm/@vitest+mocker@3.2.4_vite@6.3.5_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1_/node_modules/@vitest/mocker/dist/index.d.ts","../../node_modules/.pnpm/@vitest+utils@3.2.4/node_modules/@vitest/utils/dist/source-map.d.ts","../../node_modules/.pnpm/vite-node@3.2.4_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vite-node/dist/trace-mapping.d-dlvdeqop.d.ts","../../node_modules/.pnpm/vite-node@3.2.4_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vite-node/dist/index.d-dgmxd2u7.d.ts","../../node_modules/.pnpm/vite-node@3.2.4_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vite-node/dist/index.d.ts","../../node_modules/.pnpm/@vitest+snapshot@3.2.4/node_modules/@vitest/snapshot/dist/environment.d-dhdq1csl.d.ts","../../node_modules/.pnpm/@vitest+snapshot@3.2.4/node_modules/@vitest/snapshot/dist/rawsnapshot.d-lfsmjfud.d.ts","../../node_modules/.pnpm/@vitest+snapshot@3.2.4/node_modules/@vitest/snapshot/dist/index.d.ts","../../node_modules/.pnpm/@vitest+snapshot@3.2.4/node_modules/@vitest/snapshot/dist/environment.d.ts","../../node_modules/.pnpm/vitest@3.2.4_@types+node@22.15.18_@vitest+ui@3.2.4_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vitest/dist/chunks/config.d.d2roskhv.d.ts","../../node_modules/.pnpm/vitest@3.2.4_@types+node@22.15.18_@vitest+ui@3.2.4_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vitest/dist/chunks/worker.d.1gmbbd7g.d.ts","../../node_modules/.pnpm/@types+deep-eql@4.0.2/node_modules/@types/deep-eql/index.d.ts","../../node_modules/.pnpm/@types+chai@5.2.2/node_modules/@types/chai/index.d.ts","../../node_modules/.pnpm/@vitest+runner@3.2.4/node_modules/@vitest/runner/dist/utils.d.ts","../../node_modules/.pnpm/tinybench@2.9.0/node_modules/tinybench/dist/index.d.ts","../../node_modules/.pnpm/vitest@3.2.4_@types+node@22.15.18_@vitest+ui@3.2.4_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vitest/dist/chunks/benchmark.d.bwvbvtda.d.ts","../../node_modules/.pnpm/vite-node@3.2.4_@types+node@22.15.18_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vite-node/dist/client.d.ts","../../node_modules/.pnpm/vitest@3.2.4_@types+node@22.15.18_@vitest+ui@3.2.4_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vitest/dist/chunks/coverage.d.s9rmnxie.d.ts","../../node_modules/.pnpm/@vitest+snapshot@3.2.4/node_modules/@vitest/snapshot/dist/manager.d.ts","../../node_modules/.pnpm/vitest@3.2.4_@types+node@22.15.18_@vitest+ui@3.2.4_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vitest/dist/chunks/reporters.d.bflkqcl6.d.ts","../../node_modules/.pnpm/vitest@3.2.4_@types+node@22.15.18_@vitest+ui@3.2.4_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vitest/dist/chunks/worker.d.ckwwzbsj.d.ts","../../node_modules/.pnpm/@vitest+spy@3.2.4/node_modules/@vitest/spy/dist/index.d.ts","../../node_modules/.pnpm/@vitest+expect@3.2.4/node_modules/@vitest/expect/dist/index.d.ts","../../node_modules/.pnpm/vitest@3.2.4_@types+node@22.15.18_@vitest+ui@3.2.4_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vitest/dist/chunks/global.d.mamajcmj.d.ts","../../node_modules/.pnpm/vitest@3.2.4_@types+node@22.15.18_@vitest+ui@3.2.4_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vitest/dist/chunks/vite.d.cmlllifp.d.ts","../../node_modules/.pnpm/vitest@3.2.4_@types+node@22.15.18_@vitest+ui@3.2.4_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vitest/dist/chunks/mocker.d.be_2ls6u.d.ts","../../node_modules/.pnpm/vitest@3.2.4_@types+node@22.15.18_@vitest+ui@3.2.4_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vitest/dist/chunks/suite.d.fvehnv49.d.ts","../../node_modules/.pnpm/expect-type@1.2.2/node_modules/expect-type/dist/utils.d.ts","../../node_modules/.pnpm/expect-type@1.2.2/node_modules/expect-type/dist/overloads.d.ts","../../node_modules/.pnpm/expect-type@1.2.2/node_modules/expect-type/dist/branding.d.ts","../../node_modules/.pnpm/expect-type@1.2.2/node_modules/expect-type/dist/messages.d.ts","../../node_modules/.pnpm/expect-type@1.2.2/node_modules/expect-type/dist/index.d.ts","../../node_modules/.pnpm/vitest@3.2.4_@types+node@22.15.18_@vitest+ui@3.2.4_jiti@2.4.2_lightningcss@1.30.1_tsx@4.19.4_yaml@2.8.1/node_modules/vitest/dist/index.d.ts","../../packages/shared/src/validation/accounts.test.ts","../../packages/shared/src/validation/basevalidations.test.ts","../../packages/shared/src/validation/transactions.test.ts","../../node_modules/.pnpm/@types+react-dom@19.1.5_@types+react@19.1.4/node_modules/@types/react-dom/index.d.ts"],"fileIdsList":[[51,123,165],[50,51,52,53,60,123,165],[60,123,165],[123,165,262],[123,165],[123,162,165],[123,164,165],[165],[123,165,170,200],[123,165,166,171,177,178,185,197,208],[123,165,166,167,177,185],[118,119,120,123,165],[123,165,168,209],[123,165,169,170,178,186],[123,165,170,197,205],[123,165,171,173,177,185],[123,164,165,172],[123,165,173,174],[123,165,177],[123,165,175,177],[123,164,165,177],[123,165,177,178,179,197,208],[123,165,177,178,179,192,197,200],[123,160,165,213],[123,160,165,173,177,180,185,197,208],[123,165,177,178,180,181,185,197,205,208],[123,165,180,182,197,205,208],[121,122,123,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214],[123,165,177,183],[123,165,184,208],[123,165,173,177,185,197],[123,165,186],[123,165,187],[123,164,165,188],[123,162,163,164,165,166,167,168,169,170,171,172,173,174,175,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214],[123,165,190],[123,165,191],[123,165,177,192,193],[123,165,192,194,209,211],[123,165,177,197,198,200],[123,165,199,200],[123,165,197,198],[123,165,200],[123,165,201],[123,162,165,197],[123,165,177,203,204],[123,165,203,204],[123,165,170,185,197,205],[123,165,206],[123,165,185,207],[123,165,180,191,208],[123,165,170,209],[123,165,197,210],[123,165,184,211],[123,165,212],[123,165,170,177,179,188,197,208,211,213],[123,165,197,214],[50,123,165],[48,49,123,165],[108,109,112,123,165,272],[123,165,249,250],[109,110,112,113,114,123,165],[109,123,165],[109,110,112,123,165],[109,110,123,165],[123,165,256],[104,123,165,256,257],[104,123,165,256],[104,111,123,165],[105,123,165],[104,105,106,108,123,165],[104,123,165],[99,123,165],[98,123,165],[123,165,278,279],[123,165,278,279,280,281],[123,165,278,280],[123,165,278],[123,165,242,243],[123,165,237],[123,165,235,237],[123,165,226,234,235,236,238],[123,165,224],[123,165,227,232,237,240],[123,165,223,240],[123,165,227,228,231,232,233,240],[123,165,227,228,229,231,232,240],[123,165,224,225,226,227,228,232,233,234,236,237,238,240],[123,165,240],[123,165,222,224,225,226,227,228,229,231,232,233,234,235,236,237,238,239],[123,165,222,240],[123,165,227,229,230,232,233,240],[123,165,231,240],[123,165,232,233,237,240],[123,165,225,235],[123,165,217,247,248],[123,165,216,217],[107,123,165],[123,132,136,165,208],[123,132,165,197,208],[123,127,165],[123,129,132,165,205,208],[123,165,185,205],[123,165,215],[123,127,165,215],[123,129,132,165,185,208],[123,124,125,128,131,165,177,197,208],[123,132,139,165],[123,124,130,165],[123,132,153,154,165],[123,128,132,165,200,208,215],[123,153,165,215],[123,126,127,165,215],[123,132,165],[123,126,127,128,129,130,131,132,133,134,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,154,155,156,157,158,159,165],[123,132,147,165],[123,132,139,140,165],[123,130,132,140,141,165],[123,131,165],[123,124,127,132,165],[123,132,136,140,141,165],[123,136,165],[123,130,132,135,165,208],[123,124,129,132,139,165],[123,165,197],[123,127,132,153,165,213,215],[123,165,253,254],[123,165,253],[59,123,165],[55,56,58,123,165,177,178,180,181,182,185,197,205,208,214,215,217,218,219,220,221,241,245,246,247,248],[55,56,57,123,165,219],[55,123,165],[56,123,165],[57,58,123,165],[123,165,244],[123,165,217,248],[115,123,165,264,265,274],[104,112,115,123,165,258,259,274],[123,165,267],[116,123,165],[104,115,117,123,165,258,266,273,274],[123,165,251],[104,109,112,115,117,123,165,168,178,197,248,251,252,255,258,260,261,263,266,268,269,274,275],[115,123,165,264,265,266,274],[123,165,248,270,275],[115,117,123,165,255,258,260,274],[123,165,213,261],[104,109,112,115,116,117,123,165,168,178,197,213,248,251,252,255,258,259,260,261,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,282],[74,123,165],[64,65,123,165],[62,63,64,66,67,72,123,165],[63,64,123,165],[73,123,165],[64,123,165],[62,63,64,67,68,69,70,71,123,165],[62,63,74,123,165],[96,123,165],[76,77,82,83,97,123,165],[76,123,165],[100,123,165],[101,123,165],[79,123,165],[75,90,123,165],[79,80,81,91,92,93,94,95,123,165],[75,78,79,93,123,165],[75,78,123,165],[85,123,165,283],[75,78,84,123,165],[75,123,165],[78,123,165,283],[75,76,77,123,165],[78,85,86,87,88,89,123,165],[86,123,165,283],[75,77,78,123,165]],"fileInfos":[{"version":"69684132aeb9b5642cbcd9e22dff7818ff0ee1aa831728af0ecf97d3364d5546","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"092c2bfe125ce69dbb1223c85d68d4d2397d7d8411867b5cc03cec902c233763","affectsGlobalScope":true,"impliedFormat":1},{"version":"07f073f19d67f74d732b1adea08e1dc66b1b58d77cb5b43931dee3d798a2fd53","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"936e80ad36a2ee83fc3caf008e7c4c5afe45b3cf3d5c24408f039c1d47bdc1df","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"fef8cfad2e2dc5f5b3d97a6f4f2e92848eb1b88e897bb7318cef0e2820bceaab","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"742d4b7b02ffc3ba3c4258a3d196457da2b3fec0125872fd0776c50302a11b9d","affectsGlobalScope":true,"impliedFormat":1},{"version":"8a8eb4ebffd85e589a1cc7c178e291626c359543403d58c9cd22b81fab5b1fb9","impliedFormat":1},{"version":"03566a51ebc848dec449a4ed69518e9f20caa6ac123fa32676aaaabe64adae8e","impliedFormat":1},{"version":"42c169fb8c2d42f4f668c624a9a11e719d5d07dacbebb63cbcf7ef365b0a75b3","impliedFormat":1},{"version":"9376f28e07595fa672e0dc353d78cae77d8df36d6a1652f2e65980bb8a18269c","signature":"4a02a6e3d810cdea6609e913c5177d935862a694c961c0d0807fee153598d211"},{"version":"88e9caa9c5d2ba629240b5913842e7c57c5c0315383b8dc9d436ef2b60f1c391","impliedFormat":1},{"version":"76925507beff62d7fe6794d554a86a45823bc0bd109a01eda4aaea2b5796b4fd","signature":"5e27e58e235a551ef56cb47dbd7c7f4686e88886a9cf1d8b6226833f84013fff"},{"version":"a7ca8df4f2931bef2aa4118078584d84a0b16539598eaadf7dce9104dfaa381c","impliedFormat":1},{"version":"11443a1dcfaaa404c68d53368b5b818712b95dd19f188cab1669c39bee8b84b3","impliedFormat":1},{"version":"36977c14a7f7bfc8c0426ae4343875689949fb699f3f84ecbe5b300ebf9a2c55","impliedFormat":1},{"version":"7f698624bbbb060ece7c0e51b7236520ebada74b747d7523c7df376453ed6fea","impliedFormat":1},{"version":"19efad8495a7a6b064483fccd1d2b427403dd84e67819f86d1c6ee3d7abf749c","affectsGlobalScope":true,"impliedFormat":1},{"version":"1eef826bc4a19de22155487984e345a34c9cd511dd1170edc7a447cb8231dd4a","affectsGlobalScope":true,"impliedFormat":99},{"version":"6bfdbd285cb25907f31ba9db446e61eb2aa55cf46220ebe5fceaa18b2a0b26e8","affectsGlobalScope":true},{"version":"d3cfde44f8089768ebb08098c96d01ca260b88bccf238d55eee93f1c620ff5a5","impliedFormat":1},{"version":"950f2cd81e30d0ecdf70ab78fcfd85fc5bb28b45ebb08c860daff059feea412e","impliedFormat":1},{"version":"3a5af4fba7b27b815bb40f52715aedebaa4b371da3e5a664e7e0798c9b638825","impliedFormat":1},{"version":"8485b6da53ec35637d072e516631d25dae53984500de70a6989058f24354666f","impliedFormat":1},{"version":"ebe80346928736532e4a822154eb77f57ef3389dbe2b3ba4e571366a15448ef2","impliedFormat":1},{"version":"49c632082dc8a916353288d3d8b2dc82b3471794249a381d090d960c8ceac908","impliedFormat":1},{"version":"f672c876c1a04a223cf2023b3d91e8a52bb1544c576b81bf64a8fec82be9969c","impliedFormat":1},{"version":"71addb585c2db7b8e53dc1b0bcfa58c6c67c6e4fa2b968942046749d66f82e7e","impliedFormat":1},{"version":"c76b0c5727302341d0bdfa2cc2cee4b19ff185b554edb6e8543f0661d8487116","impliedFormat":1},{"version":"25b3f581e12ede11e5739f57a86e8668fbc0124f6649506def306cad2c59d262","impliedFormat":1},{"version":"e703cfacb9965c4d4155346c65a0091ecded90ea98874ed6b3f36286577c4dde","impliedFormat":1},{"version":"f5ef066942e4f0bd98200aa6a6694b831e73200c9b3ade77ad0aa2409e8fe1b1","impliedFormat":1},{"version":"b9e99cd94f4166a245f5158f7286c05406e2a4c694619bceb7a4f3519d1d768e","impliedFormat":1},{"version":"5568d7c32e5cf5f35e092649f4e5e168c3114c800b1d7545b7ae5e0415704802","impliedFormat":1},{"version":"9563572d81b4aa8b7cf3e24384262341bd53a7a1e5e55034f5d446654b824e8a","signature":"bce0cc5be1903519d3a629f49137b213c22e1d4380a6b4cd409fe5bc38a7dd09"},{"version":"10e44a443fcc02629276b3068a04090ef67456c7b9f49a767e566575625852b7","signature":"2966ffb8b17167488deb293aaa49f5da898066d32ce422afbbf98fc516a42fc1"},{"version":"b16588ab6baa1e644b52a61a7aab09944b97d551e63d41737511ca0fdaa48240","signature":"7de13694dfd164dafb704864c2b605059ca1b9da2ecce783a52640b0601d0deb"},{"version":"588729217e9d3bdb0b58d99e9c53a26d5910f1528581fc6e382be3be503224f3","signature":"cee9bde02183941aad2a2703006d188a784d74c6125a1a6e761c22fc8e4ee605"},{"version":"6a10203ade5f669e7d050ada19875acffd324212115228bc8725949a22cc76bf","signature":"77b59af0be64b7a6b99016d2f5c4398c94f4c40744b12dd5cd61f966b14c4c76"},{"version":"5c043033f251a8c5bd7108914393af575650fa9c78a78e548f81d7cfc4014e6f","signature":"74555895068502b65799c3c9928e42f4e56d6f69b021e6f13f294c1a8c354260"},{"version":"5275b12537eadeeac78e852fd6ddebb4882770947c64f06bf1bdaf29ecb61303","signature":"c4a90e38375dfd22ab91d9a420cfa703a5dca4145108e0d1106e9873bc96797b"},{"version":"1af38648bd7056b360f5c3607b305f558a09d1d320fa253a7333c169ea9529b1","signature":"08f9b11447950c4f59141f328520c6e31b5a166924336777995a0187f7a4bde9"},"9b1f5337bffec91b0d8559a08d004f2f35357247a6581f8e0650200f344aa1de",{"version":"b08fcbc6e69b649f9a551c8e3b037745297dafcb5e9b2ff535ba68094391828d","signature":"4ff06802f14e5bbb4a23ef7b00c856778b62cc011f06c99b72245ab8ad84010b"},{"version":"8e739002a2c44490f6dfb15ef1d9a064f00995a60a548003520da2c258de2daf","signature":"65f000f52bfedacb31a6db1f6d73da660498679396c5c524e38581922978be44"},{"version":"475aecf30db5bec8a62e0ffd4b9924cd8693b814019332a9bc3c4853d12f7657","signature":"a6785ff8e286cc4b64221c4d9c6a1084f328837d1e3225db9045130dcc8a0ca8"},{"version":"c4a595c3f6c279163c73fde607c056c813ef53f47537bc91b2623dd95f45f2fc","signature":"0dacf73b071d41f5506c1858dbc30a73b85482307cb5e15a63ba4fe654906ce4"},{"version":"ed3e852ca411e14e13830f6a4dfd400148a288bc3ae0ad491e8271e6f48dd762","signature":"8a213784ba67fbef718313cf101abea9344ecda14d2bb6bff94f865c03078234"},"297345b01475c22e441a079350ad6a0eaac4a20bf494e609c2887dc84987bdcc",{"version":"3b96187464c836feb9457f746b1ed44dc91ad76aa65ee8cdd64283b46fda9234","signature":"ec7adc98a93755b7c1ecb861e78423a549a0d8b30751571340bac9c0c974c66a"},{"version":"b4b0f493c3ebf51fc982a1ce59e95b863836dc00c5685d09289cf1953a40fba0","signature":"db39cdaf4c7f6939a6e0533c49464ad9d9a7699adb355247500f92cce14d2a67"},{"version":"b4508ea58d02c3b0931995c9d5a5f464864efa7242243677de743ff9d248ab0d","signature":"02b514f6c19f6e26cd1993dd2504a4d94566ea6af541e90dfcc8ed378c1f0eac"},{"version":"0b4b350c8213fa00346cd95d2ecd342abe0e715b631441517b9c9b8fed415ffd","signature":"aebcd9d3d659ae67d056b135e8106bebe305147634c712f006cf4f18f98e55ed"},{"version":"c006648fc51b28eb665766dad55b4c29216d8ef0c68c2c126d68f12210020085","signature":"4510a663c34911f93bd529151c52ebc9c4d419d1698a6d76d0e945bd4f0ef43d"},"c8ee5252beca2d319a6278420d47c729480b35fd410a970fb96f5bdf925cb527",{"version":"b9b5488f9e8043ff017f648745a75b1c2e030a8efa16c6310fa6e6f9e52bcef5","signature":"58969267c23c27443642dec465082e37d7e210fa71c2cf13f516d876ff97b6fa"},{"version":"73a0ee6395819b063df4b148211985f2e1442945c1a057204cf4cf6281760dc3","affectsGlobalScope":true,"impliedFormat":1},{"version":"d05d8c67116dceafc62e691c47ac89f8f10cf7313cd1b2fb4fe801c2bf1bb1a7","impliedFormat":1},{"version":"3c5bb5207df7095882400323d692957e90ec17323ccff5fd5f29a1ecf3b165d0","impliedFormat":1},{"version":"7959556e748fee75a1ed10822bed4e92edae99ed75e428b650376bacb20ce257","signature":"5a81de12417101e8964d1370944563204a470760be39079613c910b549a0b53a"},"4ca18ab8cd76fb5fb9f7013772f05cdf7b17cdd2f65c39a20399c01c04952a9e",{"version":"4c3ca267ee80eeaf80286cc85d7ce0002d816762421ea30bacfa4251cb3cb3ba","signature":"d0693ad605cf280272587f426caee0f16c0f008dd2cc0687ee6a69effc0fe05f"},{"version":"5c54a34e3d91727f7ae840bfe4d5d1c9a2f93c54cb7b6063d06ee4a6c3322656","impliedFormat":99},{"version":"db4da53b03596668cf6cc9484834e5de3833b9e7e64620cf08399fe069cd398d","impliedFormat":99},{"version":"ac7c28f153820c10850457994db1462d8c8e462f253b828ad942a979f726f2f9","impliedFormat":99},{"version":"f9b028d3c3891dd817e24d53102132b8f696269309605e6ed4f0db2c113bbd82","impliedFormat":99},{"version":"fb7c8d90e52e2884509166f96f3d591020c7b7977ab473b746954b0c8d100960","impliedFormat":99},{"version":"0bff51d6ed0c9093f6955b9d8258ce152ddb273359d50a897d8baabcb34de2c4","impliedFormat":99},{"version":"45cec9a1ba6549060552eead8959d47226048e0b71c7d0702ae58b7e16a28912","impliedFormat":99},{"version":"ef13c73d6157a32933c612d476c1524dd674cf5b9a88571d7d6a0d147544d529","impliedFormat":99},{"version":"13918e2b81c4288695f9b1f3dcc2468caf0f848d5c1f3dc00071c619d34ff63a","impliedFormat":99},{"version":"6907b09850f86610e7a528348c15484c1e1c09a18a9c1e98861399dfe4b18b46","impliedFormat":99},{"version":"12deea8eaa7a4fc1a2908e67da99831e5c5a6b46ad4f4f948fd4759314ea2b80","impliedFormat":99},{"version":"f0a8b376568a18f9a4976ecb0855187672b16b96c4df1c183a7e52dc1b5d98e8","impliedFormat":99},{"version":"8124828a11be7db984fcdab052fd4ff756b18edcfa8d71118b55388176210923","impliedFormat":99},{"version":"092944a8c05f9b96579161e88c6f211d5304a76bd2c47f8d4c30053269146bc8","impliedFormat":99},{"version":"70521b6ab0dcba37539e5303104f29b721bfb2940b2776da4cc818c07e1fefc1","affectsGlobalScope":true,"impliedFormat":1},{"version":"030e350db2525514580ed054f712ffb22d273e6bc7eddc1bb7eda1e0ba5d395e","affectsGlobalScope":true,"impliedFormat":1},{"version":"d153a11543fd884b596587ccd97aebbeed950b26933ee000f94009f1ab142848","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"a79e62f1e20467e11a904399b8b18b18c0c6eea6b50c1168bf215356d5bebfaf","affectsGlobalScope":true,"impliedFormat":1},{"version":"d802f0e6b5188646d307f070d83512e8eb94651858de8a82d1e47f60fb6da4e2","affectsGlobalScope":true,"impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"3b724a66c071d616203133f8d099a0cb881b0b43fd42e8621e611243c5f30cd6","affectsGlobalScope":true,"impliedFormat":1},{"version":"a38efe83ff77c34e0f418a806a01ca3910c02ee7d64212a59d59bca6c2c38fa1","impliedFormat":1},{"version":"7394959e5a741b185456e1ef5d64599c36c60a323207450991e7a42e08911419","impliedFormat":1},{"version":"3fe4022ba1e738034e38ad9afacbf0f1f16b458ed516326f5bf9e4a31e9be1dc","impliedFormat":1},{"version":"a957197054b074bcdf5555d26286e8461680c7c878040d0f4e2d5509a7524944","affectsGlobalScope":true,"impliedFormat":1},{"version":"4314c7a11517e221f7296b46547dbc4df047115b182f544d072bdccffa57fc72","impliedFormat":1},{"version":"e9b97d69510658d2f4199b7d384326b7c4053b9e6645f5c19e1c2a54ede427fc","impliedFormat":1},{"version":"c2510f124c0293ab80b1777c44d80f812b75612f297b9857406468c0f4dafe29","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"f478f6f5902dc144c0d6d7bdc919c5177cac4d17a8ca8653c2daf6d7dc94317f","affectsGlobalScope":true,"impliedFormat":1},{"version":"19d5f8d3930e9f99aa2c36258bf95abbe5adf7e889e6181872d1cdba7c9a7dd5","impliedFormat":1},{"version":"b200675fd112ffef97c166d0341fb33f6e29e9f27660adde7868e95c5bc98beb","impliedFormat":1},{"version":"a6bf63d17324010ca1fbf0389cab83f93389bb0b9a01dc8a346d092f65b3605f","impliedFormat":1},{"version":"e009777bef4b023a999b2e5b9a136ff2cde37dc3f77c744a02840f05b18be8ff","impliedFormat":1},{"version":"1e0d1f8b0adfa0b0330e028c7941b5a98c08b600efe7f14d2d2a00854fb2f393","impliedFormat":1},{"version":"ee1ee365d88c4c6c0c0a5a5701d66ebc27ccd0bcfcfaa482c6e2e7fe7b98edf7","affectsGlobalScope":true,"impliedFormat":1},{"version":"88bc59b32d0d5b4e5d9632ac38edea23454057e643684c3c0b94511296f2998c","affectsGlobalScope":true,"impliedFormat":1},{"version":"a0a1dda070290b92da5a50113b73ecc4dd6bcbffad66e3c86503d483eafbadcf","impliedFormat":1},{"version":"59dcad36c4549175a25998f6a8b33c1df8e18df9c12ebad1dfb25af13fd4b1ce","impliedFormat":1},{"version":"9ba5b6a30cb7961b68ad4fb18dca148db151c2c23b8d0a260fc18b83399d19d3","impliedFormat":1},{"version":"3f3edb8e44e3b9df3b7ca3219ab539710b6a7f4fe16bd884d441af207e03cd57","impliedFormat":1},{"version":"528b62e4272e3ddfb50e8eed9e359dedea0a4d171c3eb8f337f4892aac37b24b","impliedFormat":1},{"version":"d71535813e39c23baa113bc4a29a0e187b87d1105ccc8c5a6ebaca38d9a9bff2","impliedFormat":1},{"version":"8cf7e92bdb2862c2d28ba4535c43dc599cfbc0025db5ed9973d9b708dcbe3d98","affectsGlobalScope":true,"impliedFormat":1},{"version":"278e70975bd456bba5874eaee17692355432e8d379b809a97f6af0eee2b702d8","impliedFormat":1},{"version":"b1b6ee0d012aeebe11d776a155d8979730440082797695fc8e2a5c326285678f","impliedFormat":1},{"version":"45875bcae57270aeb3ebc73a5e3fb4c7b9d91d6b045f107c1d8513c28ece71c0","impliedFormat":1},{"version":"1dc73f8854e5c4506131c4d95b3a6c24d0c80336d3758e95110f4c7b5cb16397","affectsGlobalScope":true,"impliedFormat":1},{"version":"636302a00dfd1f9fe6e8e91e4e9350c6518dcc8d51a474e4fc3a9ba07135100b","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f16a7e4deafa527ed9995a772bb380eb7d3c2c0fd4ae178c5263ed18394db2c","impliedFormat":1},{"version":"933921f0bb0ec12ef45d1062a1fc0f27635318f4d294e4d99de9a5493e618ca2","impliedFormat":1},{"version":"71a0f3ad612c123b57239a7749770017ecfe6b66411488000aba83e4546fde25","impliedFormat":1},{"version":"8145e07aad6da5f23f2fcd8c8e4c5c13fb26ee986a79d03b0829b8fce152d8b2","impliedFormat":1},{"version":"e1120271ebbc9952fdc7b2dd3e145560e52e06956345e6fdf91d70ca4886464f","impliedFormat":1},{"version":"814118df420c4e38fe5ae1b9a3bafb6e9c2aa40838e528cde908381867be6466","impliedFormat":1},{"version":"e1ce1d622f1e561f6cdf246372ead3bbc07ce0342024d0e9c7caf3136f712698","impliedFormat":1},{"version":"c878f74b6d10b267f6075c51ac1d8becd15b4aa6a58f79c0cfe3b24908357f60","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"125d792ec6c0c0f657d758055c494301cc5fdb327d9d9d5960b3f129aff76093","impliedFormat":1},{"version":"27e4532aaaa1665d0dd19023321e4dc12a35a741d6b8e1ca3517fcc2544e0efe","affectsGlobalScope":true,"impliedFormat":1},{"version":"2754d8221d77c7b382096651925eb476f1066b3348da4b73fe71ced7801edada","impliedFormat":1},{"version":"8c2ad42d5d1a2e8e6112625767f8794d9537f1247907378543106f7ba6c7df90","affectsGlobalScope":true,"impliedFormat":1},{"version":"f0be1b8078cd549d91f37c30c222c2a187ac1cf981d994fb476a1adc61387b14","affectsGlobalScope":true,"impliedFormat":1},{"version":"0aaed1d72199b01234152f7a60046bc947f1f37d78d182e9ae09c4289e06a592","impliedFormat":1},{"version":"98ffdf93dfdd206516971d28e3e473f417a5cfd41172e46b4ce45008f640588e","impliedFormat":1},{"version":"66ba1b2c3e3a3644a1011cd530fb444a96b1b2dfe2f5e837a002d41a1a799e60","impliedFormat":1},{"version":"7e514f5b852fdbc166b539fdd1f4e9114f29911592a5eb10a94bb3a13ccac3c4","impliedFormat":1},{"version":"7d6ff413e198d25639f9f01f16673e7df4e4bd2875a42455afd4ecc02ef156da","affectsGlobalScope":true,"impliedFormat":1},{"version":"12e8ce658dd17662d82fb0509d2057afc5e6ee30369a2e9e0957eff725b1f11d","affectsGlobalScope":true,"impliedFormat":1},{"version":"74736930d108365d7bbe740c7154706ccfb1b2a3855a897963ab3e5c07ecbf19","impliedFormat":1},{"version":"858f999b3e4a45a4e74766d43030941466460bf8768361d254234d5870480a53","impliedFormat":1},{"version":"ac5ed35e649cdd8143131964336ab9076937fa91802ec760b3ea63b59175c10a","impliedFormat":1},{"version":"63b05afa6121657f25e99e1519596b0826cda026f09372c9100dfe21417f4bd6","affectsGlobalScope":true,"impliedFormat":1},{"version":"3797dd6f4ea3dc15f356f8cdd3128bfa18122213b38a80d6c1f05d8e13cbdad8","impliedFormat":1},{"version":"ad90122e1cb599b3bc06a11710eb5489101be678f2920f2322b0ac3e195af78d","impliedFormat":1},{"version":"e2b48abff5a8adc6bb1cd13a702b9ef05e6045a98e7cfa95a8779b53b6d0e69d","impliedFormat":1},{"version":"a02d26c056491b1ddfa53a671ad60ce852969b369f0e71993dbac8ddcf0d038b","affectsGlobalScope":true,"impliedFormat":1},{"version":"a660aa95476042d3fdcc1343cf6bb8fdf24772d31712b1db321c5a4dcc325434","impliedFormat":1},{"version":"035d0934d304483f07148427a5bd5b98ac265dae914a6b49749fe23fbd893ec7","impliedFormat":99},{"version":"e2ed5b81cbed3a511b21a18ab2539e79ac1f4bc1d1d28f8d35d8104caa3b429f","impliedFormat":99},{"version":"dd7ca4f0ef3661dac7043fb2cdf1b99e008d2b6bc5cd998dd1fa5a2968034984","affectsGlobalScope":true,"impliedFormat":1},{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"8885cf05f3e2abf117590bbb951dcf6359e3e5ac462af1c901cfd24c6a6472e2","impliedFormat":1},{"version":"33f3718dababfc26dfd9832c150149ea4e934f255130f8c118a59ae69e5ed441","impliedFormat":1},{"version":"e61df3640a38d535fd4bc9f4a53aef17c296b58dc4b6394fd576b808dd2fe5e6","impliedFormat":1},{"version":"459920181700cec8cbdf2a5faca127f3f17fd8dd9d9e577ed3f5f3af5d12a2e4","impliedFormat":1},{"version":"4719c209b9c00b579553859407a7e5dcfaa1c472994bd62aa5dd3cc0757eb077","impliedFormat":1},{"version":"7ec359bbc29b69d4063fe7dad0baaf35f1856f914db16b3f4f6e3e1bca4099fa","impliedFormat":1},{"version":"70790a7f0040993ca66ab8a07a059a0f8256e7bb57d968ae945f696cbff4ac7a","impliedFormat":1},{"version":"d1b9a81e99a0050ca7f2d98d7eedc6cda768f0eb9fa90b602e7107433e64c04c","impliedFormat":1},{"version":"a022503e75d6953d0e82c2c564508a5c7f8556fad5d7f971372d2d40479e4034","impliedFormat":1},{"version":"b215c4f0096f108020f666ffcc1f072c81e9f2f95464e894a5d5f34c5ea2a8b1","impliedFormat":1},{"version":"644491cde678bd462bb922c1d0cfab8f17d626b195ccb7f008612dc31f445d2d","impliedFormat":1},{"version":"dfe54dab1fa4961a6bcfba68c4ca955f8b5bbeb5f2ab3c915aa7adaa2eabc03a","impliedFormat":1},{"version":"1bb61aa2f08ab4506d41dbe16c5f3f5010f014bbf46fa3d715c0cbe3b00f4e1c","impliedFormat":1},{"version":"47865c5e695a382a916b1eedda1b6523145426e48a2eae4647e96b3b5e52024f","impliedFormat":1},{"version":"e42820cd611b15910c204cd133f692dcd602532b39317d4f2a19389b27e6f03d","impliedFormat":1},{"version":"331b8f71bfae1df25d564f5ea9ee65a0d847c4a94baa45925b6f38c55c7039bf","impliedFormat":1},{"version":"2a771d907aebf9391ac1f50e4ad37952943515eeea0dcc7e78aa08f508294668","impliedFormat":1},{"version":"0146fd6262c3fd3da51cb0254bb6b9a4e42931eb2f56329edd4c199cb9aaf804","impliedFormat":1},{"version":"183f480885db5caa5a8acb833c2be04f98056bdcc5fb29e969ff86e07efe57ab","impliedFormat":99},{"version":"f7eebe1b25040d805aefe8971310b805cd49b8602ec206d25b38dc48c542f165","impliedFormat":1},{"version":"a18642ddf216f162052a16cba0944892c4c4c977d3306a87cb673d46abbb0cbf","impliedFormat":1},{"version":"509f8efdfc5f9f6b52284170e8d7413552f02d79518d1db691ee15acc0088676","impliedFormat":1},{"version":"4ec16d7a4e366c06a4573d299e15fe6207fc080f41beac5da06f4af33ea9761e","impliedFormat":1},{"version":"7870becb94cbc11d2d01b77c4422589adcba4d8e59f726246d40cd0d129784d8","affectsGlobalScope":true,"impliedFormat":1},{"version":"f70b8328a15ca1d10b1436b691e134a49bc30dcf3183a69bfaa7ba77e1b78ecd","impliedFormat":1},{"version":"ff3660e2664e6096196280deb4e176633b1bb1e58a7dcc9b021ec0e913a6f96f","impliedFormat":99},{"version":"b34b5f6b506abb206b1ea73c6a332b9ee9c8c98be0f6d17cdbda9430ecc1efab","impliedFormat":99},{"version":"75d4c746c3d16af0df61e7b0afe9606475a23335d9f34fcc525d388c21e9058b","impliedFormat":99},{"version":"fa959bf357232201c32566f45d97e70538c75a093c940af594865d12f31d4912","impliedFormat":99},{"version":"d2c52abd76259fc39a30dfae70a2e5ce77fd23144457a7ff1b64b03de6e3aec7","impliedFormat":99},{"version":"e6233e1c976265e85aa8ad76c3881febe6264cb06ae3136f0257e1eab4a6cc5a","impliedFormat":99},{"version":"f73e2335e568014e279927321770da6fe26facd4ac96cdc22a56687f1ecbb58e","impliedFormat":99},{"version":"317878f156f976d487e21fd1d58ad0461ee0a09185d5b0a43eedf2a56eb7e4ea","impliedFormat":99},{"version":"324ac98294dab54fbd580c7d0e707d94506d7b2c3d5efe981a8495f02cf9ad96","impliedFormat":99},{"version":"9ec72eb493ff209b470467e24264116b6a8616484bca438091433a545dfba17e","impliedFormat":99},{"version":"d6ee22aba183d5fc0c7b8617f77ee82ecadc2c14359cc51271c135e23f6ed51f","impliedFormat":99},{"version":"49747416f08b3ba50500a215e7a55d75268b84e31e896a40313c8053e8dec908","impliedFormat":99},{"version":"81e634f1c5e1ca309e7e3dc69e2732eea932ef07b8b34517d452e5a3e9a36fa3","impliedFormat":99},{"version":"34f39f75f2b5aa9c84a9f8157abbf8322e6831430e402badeaf58dd284f9b9a6","impliedFormat":99},{"version":"427fe2004642504828c1476d0af4270e6ad4db6de78c0b5da3e4c5ca95052a99","impliedFormat":1},{"version":"c8905dbea83f3220676a669366cd8c1acef56af4d9d72a8b2241b1d044bb4302","affectsGlobalScope":true,"impliedFormat":99},{"version":"891694d3694abd66f0b8872997b85fd8e52bc51632ce0f8128c96962b443189f","impliedFormat":99},{"version":"69bf2422313487956e4dacf049f30cb91b34968912058d244cb19e4baa24da97","impliedFormat":99},{"version":"971a2c327ff166c770c5fb35699575ba2d13bba1f6d2757309c9be4b30036c8e","impliedFormat":99},{"version":"4f45e8effab83434a78d17123b01124259fbd1e335732135c213955d85222234","impliedFormat":99},{"version":"7bd51996fb7717941cbe094b05adc0d80b9503b350a77b789bbb0fc786f28053","impliedFormat":99},{"version":"b62006bbc815fe8190c7aee262aad6bff993e3f9ade70d7057dfceab6de79d2f","impliedFormat":99},{"version":"13497c0d73306e27f70634c424cd2f3b472187164f36140b504b3756b0ff476d","impliedFormat":99},{"version":"bf7a2d0f6d9e72d59044079d61000c38da50328ccdff28c47528a1a139c610ec","impliedFormat":99},{"version":"04471dc55f802c29791cc75edda8c4dd2a121f71c2401059da61eff83099e8ab","impliedFormat":99},{"version":"120a80aa556732f684db3ed61aeff1d6671e1655bd6cba0aa88b22b88ac9a6b1","affectsGlobalScope":true,"impliedFormat":99},{"version":"e58c0b5226aff07b63be6ac6e1bec9d55bc3d2bda3b11b9b68cccea8c24ae839","affectsGlobalScope":true,"impliedFormat":99},{"version":"a23a08b626aa4d4a1924957bd8c4d38a7ffc032e21407bbd2c97413e1d8c3dbd","impliedFormat":99},{"version":"5a88655bf852c8cc007d6bc874ab61d1d63fba97063020458177173c454e9b4a","impliedFormat":99},{"version":"7e4dfae2da12ec71ffd9f55f4641a6e05610ce0d6784838659490e259e4eb13c","impliedFormat":99},{"version":"c30a41267fc04c6518b17e55dcb2b810f267af4314b0b6d7df1c33a76ce1b330","impliedFormat":1},{"version":"72422d0bac4076912385d0c10911b82e4694fc106e2d70added091f88f0824ba","impliedFormat":1},{"version":"da251b82c25bee1d93f9fd80c5a61d945da4f708ca21285541d7aff83ecb8200","impliedFormat":1},{"version":"4c8ca51077f382498f47074cf304d654aba5d362416d4f809dfdd5d4f6b3aaca","impliedFormat":1},{"version":"98b94085c9f78eba36d3d2314affe973e8994f99864b8708122750788825c771","impliedFormat":1},{"version":"13573a613314e40482386fe9c7934f9d86f3e06f19b840466c75391fb833b99b","impliedFormat":99},{"version":"d64dfcaf017e7f655369c96fdceb0263988f744f5993dd3a6d5d8ceba3270467","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"776a79ff22af80f6311f47ec6dc535954f9812b2802d9022aca5b805cb1839e1","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"fd2acf9e3fbe9231047047071e8298e040e0186599416658900762e2483f5c8a","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"a0acca63c9e39580f32a10945df231815f0fe554c074da96ba6564010ffbd2d8","impliedFormat":1}],"root":[52,54,61,[76,97],[101,103],[284,286]],"options":{"allowImportingTsExtensions":true,"allowSyntheticDefaultImports":true,"composite":true,"declaration":true,"esModuleInterop":true,"jsx":4,"module":99,"noFallthroughCasesInSwitch":true,"noUnusedLocals":true,"noUnusedParameters":true,"skipLibCheck":true,"strict":true,"target":7,"useDefineForClassFields":true},"referencedMap":[[52,1],[54,2],[61,3],[263,4],[262,5],[216,5],[162,6],[163,6],[164,7],[123,8],[165,9],[166,10],[167,11],[118,5],[121,12],[119,5],[120,5],[168,13],[169,14],[170,15],[171,16],[172,17],[173,18],[174,18],[176,19],[175,20],[177,21],[178,22],[179,23],[161,24],[122,5],[180,25],[181,26],[182,27],[215,28],[183,29],[184,30],[185,31],[186,32],[187,33],[188,34],[189,35],[190,36],[191,37],[192,38],[193,38],[194,39],[195,5],[196,5],[197,40],[199,41],[198,42],[200,43],[201,44],[202,45],[203,46],[204,47],[205,48],[206,49],[207,50],[208,51],[209,52],[210,53],[211,54],[212,55],[213,56],[214,57],[53,58],[287,58],[48,5],[50,59],[51,58],[273,60],[251,61],[249,5],[250,5],[104,5],[115,62],[110,63],[113,64],[264,65],[256,5],[259,66],[258,67],[269,67],[257,68],[272,5],[112,69],[114,69],[106,70],[109,71],[252,70],[111,72],[105,5],[49,5],[100,73],[99,74],[98,5],[221,5],[280,75],[282,76],[281,77],[279,78],[278,5],[242,5],[244,79],[243,5],[238,80],[236,81],[237,82],[225,83],[226,81],[233,84],[224,85],[229,86],[239,5],[230,87],[235,88],[241,89],[240,90],[223,91],[231,92],[232,93],[227,94],[234,80],[228,95],[218,96],[217,97],[222,5],[265,5],[107,5],[108,98],[46,5],[47,5],[8,5],[9,5],[11,5],[10,5],[2,5],[12,5],[13,5],[14,5],[15,5],[16,5],[17,5],[18,5],[19,5],[3,5],[20,5],[21,5],[4,5],[22,5],[26,5],[23,5],[24,5],[25,5],[27,5],[28,5],[29,5],[5,5],[30,5],[31,5],[32,5],[33,5],[6,5],[37,5],[34,5],[35,5],[36,5],[38,5],[7,5],[39,5],[44,5],[45,5],[40,5],[41,5],[42,5],[43,5],[1,5],[139,99],[149,100],[138,99],[159,101],[130,102],[129,103],[158,104],[152,105],[157,106],[132,107],[146,108],[131,109],[155,110],[127,111],[126,104],[156,112],[128,113],[133,114],[134,5],[137,114],[124,5],[160,115],[150,116],[141,117],[142,118],[144,119],[140,120],[143,121],[153,104],[135,122],[136,123],[145,124],[125,125],[148,116],[147,114],[151,5],[154,126],[267,127],[254,128],[255,127],[253,5],[60,129],[248,130],[220,131],[219,132],[56,132],[55,5],[57,133],[58,5],[59,134],[246,5],[245,135],[247,136],[266,137],[260,138],[268,139],[117,140],[274,141],[276,142],[270,143],[277,144],[275,145],[261,146],[271,147],[283,148],[116,5],[75,149],[66,150],[73,151],[68,5],[69,5],[67,152],[70,149],[62,5],[63,5],[74,153],[65,154],[71,5],[72,155],[64,156],[97,157],[82,5],[84,158],[83,5],[76,5],[77,159],[101,160],[102,161],[103,5],[80,162],[91,163],[95,162],[92,162],[96,164],[93,5],[94,165],[79,166],[81,162],[284,167],[85,168],[88,169],[285,170],[78,171],[89,166],[90,172],[286,173],[86,174],[87,166]],"affectedFilesPendingEmit":[[52,17],[54,17],[97,17],[82,17],[84,17],[83,17],[76,17],[77,17],[101,17],[102,17],[103,17],[80,17],[91,17],[95,17],[92,17],[96,17],[93,17],[94,17],[79,17],[81,17],[284,17],[85,17],[88,17],[285,17],[78,17],[89,17],[90,17],[286,17],[86,17],[87,17]],"emitSignatures":[52,54,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,101,102,103,284,285,286],"version":"5.8.3"} \ No newline at end of file diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 538bd9f1..5c4005a7 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -1,7 +1,5 @@ import path from 'path'; -import tailwindcss from '@tailwindcss/vite'; -import { TanStackRouterVite } from '@tanstack/router-plugin/vite'; import react from '@vitejs/plugin-react'; import { defineConfig, loadEnv } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; @@ -22,12 +20,7 @@ export default defineConfig(({ mode }) => { outDir: 'dist', }, envDir: path.resolve(__dirname, '../..'), - plugins: [ - TanStackRouterVite({ autoCodeSplitting: true, target: 'react' }), - react(), - tailwindcss(), - tsconfigPaths(), - ], + plugins: [react(), tsconfigPaths()], server: { host, open: true, diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 56d9be4c..fe6f02dc 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -1,17 +1,22 @@ -# 📊 Database Schema - Ledgerly +# Database Schema - Ledgerly Database schema documentation for Ledgerly financial tracker. -## 🎯 Overview +## Overview -Ledgerly uses SQLite with Drizzle ORM for personal finance management. The architecture is built on these principles: -- **User data isolation** - all data is tied to userId -- **Double-entry bookkeeping** - operations belong directly to transactions -- **Multi-currency support** - different currencies with trading accounts -- **Cascade deletions** - data integrity -- **Soft deletes** - using `isTombstone` flag +Ledgerly uses SQLite with Drizzle ORM for personal finance management. The +architecture is built on these principles: -## 📋 Entity Relationship Diagram +- **User data isolation** - all user-owned data is tied to `userId`. +- **Double-entry bookkeeping** - operations belong directly to transactions. +- **Commodity-based accounting** - accounts and transactions reference + user-owned Commodities by stable id. +- **Cascade deletions** - user-owned data is removed with the user where the + schema defines cascading relations. +- **Soft deletes** - domain records use an `isTombstone` flag where deletion + must remain observable. + +## Entity Relationship Diagram ```mermaid erDiagram @@ -23,18 +28,24 @@ erDiagram timestamp createdAt timestamp updatedAt } - - CURRENCIES { - string code PK "USD, EUR, RUB" + + COMMODITIES { + uuid id PK + uuid userId FK + string code string name - string symbol + string symbol "nullable" + integer precision + boolean isTombstone + timestamp createdAt + timestamp updatedAt } - + ACCOUNTS { uuid id PK + uuid commodityId FK string name string type "Asset|Liability|Income|Expense" - string currency FK string description integer initialBalance integer currentClearedBalanceLocal @@ -44,11 +55,11 @@ erDiagram timestamp createdAt timestamp updatedAt } - + TRANSACTIONS { uuid id PK + uuid commodityId FK string description - string currency date transactionDate date postingDate integer version @@ -57,7 +68,7 @@ erDiagram timestamp createdAt timestamp updatedAt } - + OPERATIONS { uuid id PK uuid transactionId FK @@ -71,33 +82,33 @@ erDiagram timestamp createdAt timestamp updatedAt } - + SETTINGS { - uuid userId PK,FK - string baseCurrency FK + uuid userId FK timestamp createdAt timestamp updatedAt } - %% Main relations + USERS ||--o{ COMMODITIES : "owns" USERS ||--o{ ACCOUNTS : "owns" USERS ||--o{ TRANSACTIONS : "makes" USERS ||--o{ OPERATIONS : "performs" - USERS ||--|| SETTINGS : "has" - - CURRENCIES ||--o{ ACCOUNTS : "denominated_in" - CURRENCIES ||--o{ SETTINGS : "base_currency" - + USERS ||--o{ SETTINGS : "has" + + COMMODITIES ||--o{ ACCOUNTS : "denominates" + COMMODITIES ||--o{ TRANSACTIONS : "values" + TRANSACTIONS ||--o{ OPERATIONS : "contains" ACCOUNTS ||--o{ OPERATIONS : "affects" - - %% Unique constraints + + COMMODITIES }|--|| USERS : "unique(userId, code)" ACCOUNTS }|--|| USERS : "unique(userId, name)" ``` -## 🏗️ Entities +## Entities + +### Users -### 🏦 **Users** Base entity for authentication and authorization. | Field | Type | Description | Constraints | @@ -110,203 +121,248 @@ Base entity for authentication and authorization. | `updatedAt` | Timestamp | Last update date | NOT NULL | **Relations:** + +- `1:N` with `commodities` (cascade delete) - `1:N` with `accounts` (cascade delete) - `1:N` with `transactions` (cascade delete) - `1:N` with `operations` (cascade delete) -- `1:1` with `settings` +- `1:N` with `settings` --- -### 💰 **Accounts** -User's financial accounts for tracking funds. +### Commodities + +User-owned monetary units used by accounts and transactions. | Field | Type | Description | Constraints | |------|-----|----------|-------------| -| `id` | UUID | Primary key | PK, NOT NULL | -| `name` | String | Account name | NOT NULL | -| `type` | Enum | Account type | `Asset\|Liability\|Income\|Expense` | -| `currency` | String | Account currency | NOT NULL (CurrencyCode) | -| `description` | String | Account description | NULLABLE | -| `initialBalance` | Integer | Initial balance (in cents) | NOT NULL, default: 0 | -| `currentClearedBalanceLocal` | Integer | Current balance (in cents) | NOT NULL, default: 0 | -| `isSystem` | Boolean | System account flag (for trading accounts) | NOT NULL, default: false | -| `isTombstone` | Boolean | Soft delete flag | NOT NULL, default: false | -| `userId` | UUID | Account owner | FK → `users.id` | +| `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 | **Constraints:** -- `UNIQUE(userId, name)` - unique name per user + +- `UNIQUE(userId, code)` through `commodities_user_id_code_unique_idx`. **Relations:** + - `N:1` with `users` -- `1:N` with `operations` +- `1:N` with `accounts` +- `1:N` with `transactions` **Notes:** -- Currency constraint to `currencies` table is removed for test performance -- Application-level validation should ensure valid currency codes -- Money amounts stored as integers (cents) to avoid floating-point issues -- System accounts (trading accounts) have `isSystem = true` + +- Commodity identity is `id`, not `code`. +- Predefined currencies such as `USD`, `EUR`, and `RUB` are ordinary per-user + Commodity records when created. +- The MVP does not include global Commodity records, `ReferenceAsset`, market + rates, provider metadata, automatic conversion, or Commodity archiving. --- -### 💱 **Currencies** -Dictionary of supported currencies. +### Accounts -> Design note: this table is an MVP currency dictionary, not the final identity -> model for all monetary units. Ledgerly is expected to need an asset/commodity -> registry for fiat currencies, crypto assets, network-specific tokens, and -> custom user assets before final transaction currency existence validation is -> implemented. See -> [ADR 0006](./architecture/adr/0006-asset-registry-before-currency-validation.md). +User's financial accounts for tracking funds. | Field | Type | Description | Constraints | |------|-----|----------|-------------| -| `code` | String | Currency code (ISO 4217) | PK, NOT NULL | -| `name` | String | Full name | NOT NULL | -| `symbol` | String | Currency symbol | NOT NULL | +| `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 | -**Examples:** -- `USD` - United States Dollar - `$` -- `EUR` - Euro - `€` -- `RUB` - Russian Ruble - `₽` +**Constraints:** + +- `UNIQUE(userId, name)` through `user_id_name_unique_idx`. **Relations:** -- `1:N` with `accounts` -- `1:N` with `settings` + +- `N:1` with `users` +- `N:1` with `commodities` +- `1:N` with `operations` + +**Notes:** + +- Account Commodity is immutable after account creation. +- Money amounts are stored as integer minor units to avoid floating-point + issues. +- System accounts are reserved for future trading-account support. --- -### 📝 **Transactions** +### Transactions + 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 | -| `currency` | String | Base currency of the transaction | NOT NULL (CurrencyCode) | | `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` | +| `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:** + - `N:1` with `users` +- `N:1` with `commodities` - `1:N` with `operations` (cascade delete) **Indexes:** -- `idx_transactions_user_date` on `(userId, transactionDate)` + +- `idx_transactions_user_date` on `(userId, transactionDate)`. **Notes:** -- `currency` is the base/reporting currency for the entire transaction; used to compute `value` on each operation -- `version` supports optimistic concurrency control—increment on every update + +- `commodityId` determines the transaction Commodity and denominates operation + `value`. +- `version` supports optimistic concurrency control and increments on aggregate + updates. --- -### 🔄 **Operations** +### Operations + Individual financial postings affecting accounts. | Field | Type | Description | Constraints | |------|-----|----------|-------------| | `id` | UUID | Primary key | PK, NOT NULL | -| `transactionId` | UUID | Parent transaction | FK → `transactions.id` | -| `accountId` | UUID | Affected account | FK → `accounts.id` | -| `amount` | Integer | Amount in **account's** currency (cents) | NOT NULL | -| `value` | Integer | Amount in **transaction's** currency (cents) | NOT NULL | -| `description` | String | Operation description | NULLABLE | -| `isSystem` | Boolean | System operation flag (for trading ops) | NOT NULL, default: false | +| `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` | +| `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:** -- `N:1` with `transactions` (cascade delete) -- `N:1` with `accounts` (restrict delete) + +- `N:1` with `transactions` +- `N:1` with `accounts` - `N:1` with `users` **Business Rules:** -- `amount` is always in the account's native currency -- `value` is in the transaction's base currency (equal to `amount` for same-currency transactions) -- Positive = debit (increases Asset/Expense, decreases Liability/Income) -- Negative = credit (decreases Asset/Expense, increases Liability/Income) -- System operations (trading operations) have `isSystem = true` -- Balance check: `sum(value)` across all operations in a transaction must equal 0 + +- `amount` is denominated by the operation account's Commodity. +- `value` is denominated by the parent transaction's Commodity. +- Balance check: `sum(value)` across active operations in a transaction must + equal 0. +- System operations are reserved for future trading-account support. **Indexes:** -- `idx_operations_transaction` on `transactionId` -- `idx_operations_account` on `accountId` -- `idx_operations_user` on `userId` + +- `idx_operations_transaction` on `transactionId`. +- `idx_operations_account` on `accountId`. +- `idx_operations_user` on `userId`. --- -### ⚙️ **Settings** +### Settings + User application settings. | Field | Type | Description | Constraints | |------|-----|----------|-------------| -| `userId` | UUID | User | PK, FK → `users.id` | -| `baseCurrency` | String | Base currency for reporting | FK → `currencies.code`, default: 'RUB' | +| `userId` | UUID | User | FK -> `users.id`, NOT NULL | | `createdAt` | Timestamp | Creation date | NOT NULL | | `updatedAt` | Timestamp | Last update date | NOT NULL | **Relations:** -- `1:1` with `users` -- `N:1` with `currencies` + +- `N:1` with `users` + +**Notes:** + +- Settings do not currently store a base reporting Commodity. --- -## 🔗 Relationships +## Relationships ### Data Hierarchy -``` + +```text USERS (root entity) -├── ACCOUNTS (financial accounts) -├── TRANSACTIONS (financial events) -│ └── OPERATIONS (individual postings) -└── SETTINGS (user preferences) +|- COMMODITIES (user-owned monetary units) +|- ACCOUNTS (financial accounts) +|- TRANSACTIONS (financial events) +| `- OPERATIONS (individual postings) +`- SETTINGS (user preferences) ``` ### Entity Relations -- **Users ↔ Accounts**: `1:N` with cascade delete -- **Users ↔ Transactions**: `1:N` with cascade delete -- **Users ↔ Operations**: `1:N` with cascade delete -- **Users ↔ Settings**: `1:1` -- **Transactions ↔ Operations**: `1:N` with cascade delete -- **Accounts ↔ Operations**: `1:N` (restrict delete) -- **Currencies ↔ Accounts**: `1:N` -- **Currencies ↔ Settings**: `1:N` + +- **Users <-> Commodities**: `1:N` with cascade delete. +- **Users <-> Accounts**: `1:N` with cascade delete. +- **Users <-> Transactions**: `1:N` with cascade delete. +- **Users <-> Operations**: `1:N` with cascade delete. +- **Users <-> Settings**: `1:N`. +- **Commodities <-> Accounts**: `1:N`. +- **Commodities <-> Transactions**: `1:N`. +- **Transactions <-> Operations**: `1:N` with cascade delete. +- **Accounts <-> Operations**: `1:N` with restrict delete. --- -## 💡 Business Rules +## Business Rules ### Uniqueness -1. **User email** must be unique system-wide -2. **Account name** must be unique per user + +1. **User email** must be unique system-wide. +2. **Commodity code** must be unique per user. +3. **Account name** must be unique per user. ### Data Integrity -1. **Cascade deletions**: When a user is deleted, all their data is deleted -2. **Required relations**: Each operation must have a transaction and account -3. **Currency constraints**: All accounts reference existing currencies (application-level) -4. **Soft deletes**: Entities use `isTombstone` flag instead of hard deletes + +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. +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. +4. **Commodity precision**: `precision` is immutable after Commodity creation. ### Accounting Principles -1. **Double-entry bookkeeping**: `sum(value)` across all operations in a transaction must equal 0 -2. **Multi-currency support**: Handled through `amount` (account currency) + `value` (transaction currency) and trading accounts -3. **Immutable operations**: Operations cannot be edited, only recreated on transaction updates + +1. **Double-entry bookkeeping**: `sum(value)` across active operations in a + transaction must equal 0. +2. **Commodity denomination**: `amount` is in account Commodity units; `value` + is in transaction Commodity units. +3. **Immutable operations**: operations cannot be edited, only recreated on + transaction updates. --- -## 🚀 Schema Files +## Schema Files Schemas are defined in the following files: + - `apps/backend/src/db/schemas/users.ts` - Users +- `apps/backend/src/db/schemas/commodities.ts` - Commodities - `apps/backend/src/db/schemas/accounts.ts` - Accounts -- `apps/backend/src/db/schemas/currencies.ts` - Currencies - `apps/backend/src/db/schemas/transactions.ts` - Transactions - `apps/backend/src/db/schemas/operations.ts` - Operations - `apps/backend/src/db/schemas/settings.ts` - Settings @@ -315,4 +371,4 @@ All schemas are exported through `apps/backend/src/db/schema.ts`. --- -*Last updated: March 11, 2026* +*Last updated: July 23, 2026* diff --git a/docs/DOMAIN.md b/docs/DOMAIN.md index 45c7218b..f6241392 100644 --- a/docs/DOMAIN.md +++ b/docs/DOMAIN.md @@ -30,8 +30,8 @@ Represents a single financial posting affecting an account. - Links to a transaction (`transactionId`) and an account (`accountId`) - Belongs to a user (`userId`) -- `amount` — signed integer in the **account's native currency** (cents) -- `value` — signed integer in the **transaction's currency** (cents); used for balance validation +- `amount` — signed integer in the **account's Commodity** minor units +- `value` — signed integer in the **transaction Commodity** minor units; used for balance validation - `amount` and `value` must be valid integer minor-unit values. `NaN`, `Infinity`, missing values, and decimal/floating-point values are invalid. Zero is allowed and is not rejected by the domain model. @@ -55,7 +55,7 @@ Represents different financial accounts with unified structure for all account t - **Liability**: Debts, loans, credit - **Income**: Revenue sources (salary, interest) - **Expense**: Spending categories -- Has a designated currency +- Has a designated Commodity - **Balance tracking**: - For Asset/Liability: real balance stored in `currentClearedBalanceLocal`, must match reality - For Income/Expense: reporting metric (sum over period), calculated from operations @@ -64,24 +64,28 @@ Represents different financial accounts with unified structure for all account t - `isSystem = true` is reserved for future system trading accounts (currently unused) - Has soft delete support (`isTombstone`) -### Currency +### Commodity -Represents different monetary units used in the system. - -> Design note: this concept is expected to evolve into an asset/commodity -> registry before transaction currency existence validation is finalized. Fiat -> currencies, crypto assets, tokenized assets on different networks, and custom -> user-defined assets should not all rely on a currency code as primary -> identity. See -> [ADR 0006](./architecture/adr/0006-asset-registry-before-currency-validation.md). +Represents a user-owned monetary unit used in the system. Commodity identity is +a stable id, not `code`; display codes such as `USD`, `EUR`, or `RUB` are +metadata owned by each user. See +[ADR 0006](./architecture/adr/0006-commodity-registry-before-currency-validation.md). #### Key Properties -- Each account has a designated currency -- System has a base currency for reporting -- Operations always store amounts in the account's currency -- Currency conversion is handled through trading operations -- Historical rates can be stored for accurate reporting +- Each account has a designated Commodity +- Account Commodity is immutable after account creation +- Transactions have `commodityId`, the transaction Commodity that determines + `value` denomination +- 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 + authenticated user +- Archived Commodities remain readable but cannot be updated or archived again ## Entity API Conventions @@ -117,6 +121,10 @@ 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. 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 @@ -208,6 +216,11 @@ 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). + New endpoint operations should be implemented as application use cases. `apps/backend/src/application/services/*` is reserved for helper orchestration used by use cases. Root-level `apps/backend/src/services/*` is legacy and must @@ -236,9 +249,9 @@ tasks. can be a valid transaction when the transaction-level balance rule is satisfied; there is no separate "non-zero net effect per account" invariant. -### Currency Handling +### Commodity Handling -1. Each operation carries both `amount` (account currency) and `value` (transaction currency) +1. Each operation carries both `amount` (account Commodity) and `value` (transaction Commodity) 2. For same-currency operations `amount === value` 3. **Trading operations** (`isSystem = true`) and system trading accounts are **not currently implemented**; they are reserved for a future multi-currency reconciliation phase @@ -343,6 +356,7 @@ Balance: `sum(value) = 0` ✓ — currently this phase is not implemented. ``` Transaction - id: UUID +- сommodityId: UUID (FK) -- denominates operation value fields - description: string - transactionDate: date (ISO string) - postingDate: date (ISO string) @@ -356,8 +370,8 @@ Operation - id: UUID - transactionId: UUID (FK) -- directly linked to Transaction (Entry removed) - accountId: UUID (FK) -- amount: integer -- in account's native currency (cents) -- value: integer -- in transaction's currency (cents); used for balance validation +- amount: integer -- in account Commodity minor units +- value: integer -- in transaction Commodity minor units; used for balance validation - description: string (optional) - isSystem: boolean -- reserved for future trading operations (currently always false) - isTombstone: boolean @@ -369,7 +383,7 @@ Account - id: UUID - name: string - type: enum (Asset, Liability, Income, Expense) -- currency: string (FK) +- commodityId: UUID (FK) - description: string - initialBalance: integer (cents) - currentClearedBalanceLocal: integer (cents) @@ -379,22 +393,19 @@ Account - createdAt: timestamp - updatedAt: timestamp -Currency -- code: string (PK) +Commodity +- id: UUID +- userId: UUID (FK) +- code: string -- display/search value, unique per user - name: string -- symbol: string - -Future asset registry direction: -- id: UUID or stable asset identifier -- type: fiat | crypto | commodity | custom -- code/symbol: display and search values, not necessarily globally unique -- precision: minor-unit scale for integer amount storage -- network and contract address: optional metadata for tokenized assets -- createdByUserId: nullable owner for user-defined assets +- symbol: string (optional) +- precision: integer -- immutable after creation in the MVP +- isTombstone: boolean +- createdAt: timestamp +- updatedAt: timestamp Settings -- userId: UUID (PK, FK) -- baseCurrency: string (FK) +- userId: UUID (FK) - createdAt: timestamp - updatedAt: timestamp ``` @@ -427,7 +438,7 @@ Settings - Schema validation (Zod) - Domain validation (business rules) - Database constraints (Drizzle) -2. Branded types for CurrencyCode and other primitive domain values where useful +2. Branded types for Commodity codes, Commodity precision, and other primitive domain values where useful 3. Operation hash-based idempotent updates 4. Enhanced error handling with domain-specific errors @@ -459,7 +470,7 @@ Settings - Domain business accessors expose active operations by default 6. **Type safety**: - Branded types for dates (`IsoDatetimeString`) - - Planned branded types for `CurrencyCode` and other primitive domain values where useful + - Branded types for Commodity codes, Commodity precision, dates, and other primitive domain values where useful - Strict TypeScript configuration - Error handling - Response serialization diff --git a/docs/MULTICURRENCY_DESIGN.md b/docs/MULTICURRENCY_DESIGN.md index 0572acba..07f72333 100644 --- a/docs/MULTICURRENCY_DESIGN.md +++ b/docs/MULTICURRENCY_DESIGN.md @@ -128,17 +128,15 @@ Trading accounts will never be displayed to the user. ## 5. Data Structure -> Design note: the current schema uses `currency` strings, but this should not -> be treated as the final persistence model for monetary units. Before adding -> existence validation for transaction currency, define an asset/commodity -> registry that can represent fiat currencies, crypto assets, tokens on specific -> networks, and custom user assets. See -> [ADR 0006](./architecture/adr/0006-asset-registry-before-currency-validation.md). +Ledgerly stores monetary-unit identity through user-owned Commodities. +Commodity identity is a stable id, not a display code. See +[ADR 0006](./architecture/adr/0006-commodity-registry-before-currency-validation.md). ### Transaction Top-level entity representing a financial event: - `id`, `userId`, `description` -- `currency` (base currency for the transaction — determines `value` denomination) +- `commodityId`, which determines the transaction Commodity and `value` + denomination - `transactionDate`, `postingDate` - `version` (optimistic concurrency) - `createdAt`, `updatedAt`, `isTombstone` @@ -146,8 +144,8 @@ Top-level entity representing a financial event: ### Operation Individual account posting: - `id`, `transactionId`, `accountId`, `userId` -- `amount` (signed integer, in **account's currency**) -- `value` (signed integer, in **transaction's currency**) +- `amount` (signed integer, in the **account's Commodity**) +- `value` (signed integer, in the **transaction Commodity**) - `description` (optional) - `isSystem` (true for trading postings) - `createdAt`, `updatedAt`, `isTombstone` @@ -156,17 +154,17 @@ Individual account posting: This follows the standard GnuCash split model: -| Field | Currency | Purpose | -|-------|----------|---------| -| `amount` | Account's native currency | How much was posted to this account | -| `value` | Transaction's currency | The equivalent amount in the transaction's denomination | +| Field | Denomination | Purpose | +|-------|--------------|---------| +| `amount` | Account's Commodity | How much was posted to this account | +| `value` | Transaction Commodity | The equivalent amount in the transaction's denomination | For **same-currency** transactions both fields are equal. For **cross-currency** transactions they differ: ``` -Transaction currency: USD +Transaction Commodity: USD Operation on Assets:WalletRUB amount = -1000 (RUB — account's currency) value = -10 (USD — transaction's currency) @@ -175,7 +173,7 @@ Transaction currency: USD **Balance validation** is performed on `value`: the sum of `value` across all operations of a transaction must equal zero, regardless of how many currencies are involved. Notes: -- Currency is determined by the account. +- Account denomination is determined by the account's Commodity. - Operations are immutable. - Normal operations have `isSystem = false`. - Trading operations have `isSystem = true`. @@ -255,7 +253,7 @@ Currently, Ledgerly stays closer to GnuCash than to PTA. - From `MIN_TRANSACTION_OPERATIONS` to `MAX_TRANSACTION_OPERATIONS` active operations per transaction (currently 2 to 1000) - Balance validated by `sum(value) = 0` across all operations - Immutable ledger operations -- `amount` = account currency; `value` = transaction currency (GnuCash convention) +- `amount` = account Commodity; `value` = transaction Commodity (GnuCash convention) ### Planned diff --git a/docs/architecture/adr/0006-asset-registry-before-currency-validation.md b/docs/architecture/adr/0006-asset-registry-before-currency-validation.md deleted file mode 100644 index 589c4faa..00000000 --- a/docs/architecture/adr/0006-asset-registry-before-currency-validation.md +++ /dev/null @@ -1,78 +0,0 @@ -# ADR 0006: Asset Registry Before Currency Validation - -- Status: Proposed -- Date: 2026-06-20 -- Jira: https://gorushkin.atlassian.net/browse/LED-64 -- PR: TBD - -## Context - -LED-47 asks for transaction currency consistency: a transaction currency must be -present and must exist in the system. The current database has a `currencies` -table keyed by currency code, while `accounts.currency` and -`transactions.currency` are plain text fields without foreign keys. - -That model is too narrow for the planned Ledgerly domain. Ledgerly needs to -represent fiat currencies, crypto assets, tokenized assets on different -networks, and possibly user-defined assets. In that domain, a display code such -as `USDT` is not a stable primary identifier because the same symbol can exist -on multiple networks or collide with user-defined assets. - -## Decision - -Do not implement LED-47 as final validation against the existing -`currencies.code` model. - -Before enforcing transaction currency existence, introduce or design a broader -asset registry model. The registry should treat fiat currencies as one asset -type rather than the only supported monetary unit. - -The future registry should support at least: - -- stable asset identity independent of display code; -- asset type, such as `fiat`, `crypto`, `commodity`, or `custom`; -- precision/decimal scale for integer minor-unit storage; -- optional network and contract address for token assets; -- active/disabled state; -- ownership or availability rules for user-defined assets. - -Transaction, account, and settings currency references should eventually point -to asset identifiers rather than relying only on text currency codes. - -## Alternatives Considered - -1. Validate LED-47 against `currencies.code` now - -- Pros: small change, closes the immediate task. -- Cons: reinforces a model already known to be insufficient for crypto and - custom assets; likely requires rework when asset identity is introduced. - -2. Add foreign keys from accounts and transactions to `currencies.code` - -- Pros: improves relational consistency for the current schema. -- Cons: still assumes ISO-like currency codes are the primary identity and does - not solve token/network collisions. - -3. Keep validation only in request schemas - -- Pros: minimal work. -- Cons: does not protect application use cases called outside HTTP and does not - ensure referenced monetary units exist in the system. - -## Consequences - -- LED-47 should be deferred or re-scoped until the asset registry decision is - accepted. -- Short-term validation may continue to check syntactic currency-code shape, but - should not be treated as final existence validation. -- Future migrations will need to preserve existing `USD`, `EUR`, and similar - rows as fiat assets. -- Amount validation should eventually use asset precision rather than assuming - all money has two decimal places. - -## Related - -- [LED-47: Currency Consistency (MVP)](https://gorushkin.atlassian.net/browse/LED-47) -- [Multicurrency Design](../../MULTICURRENCY_DESIGN.md) -- [Domain Model](../../DOMAIN.md) -- [Database Schema](../../DATABASE_SCHEMA.md) diff --git a/docs/architecture/adr/0006-commodity-registry-before-currency-validation.md b/docs/architecture/adr/0006-commodity-registry-before-currency-validation.md new file mode 100644 index 00000000..cd2e855c --- /dev/null +++ b/docs/architecture/adr/0006-commodity-registry-before-currency-validation.md @@ -0,0 +1,139 @@ +# ADR 0006: Commodity Registry Before Currency Validation + +- Status: Accepted +- Date: 2026-07-15 +- Jira: https://gorushkin.atlassian.net/browse/LED-65 +- PR: TBD + +## Context + +LED-47 asks for transaction currency consistency: a transaction currency must be +present and must exist in the system. At the time of this decision, the database +had a `currencies` table keyed by currency code, while `accounts.currency` and +`transactions.currency` were plain text fields without foreign keys. + +That model is too narrow for the planned Ledgerly domain. Ledgerly is inspired +by GnuCash, where monetary units are modeled as commodities. Fiat currencies are +commodities, but the same abstraction can also represent crypto assets, +tokenized assets, points, or user-defined units. + +In this domain, a display code such as `USD` or `USDT` is not stable identity: +codes can be edited by the user, can collide across users, and can collide +across networks or custom assets. Ledgerly also needs a path to user-created and +user-edited monetary units, so `Currency` should not remain a closed value +object with predefined values. + +## Decision + +Do not implement LED-47 as final validation against the existing +`currencies.code` model. Introduce a user-owned Commodity Registry first. + +`Commodity` is the backend/domain term for a monetary unit. Product copy and UI +may still use words such as currency, asset, or unit where that is clearer for a +user, but domain code should converge on Commodity. + +The Commodity model is: + +- each Commodity has a stable `id` and `userId`; +- Commodity identity is `id`, not `code`; +- predefined `USD`, `EUR`, and `RUB` are created per user as ordinary + Commodities, not global records; +- `code`, `name`, and `symbol` are editable display/search metadata; +- `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. + +Domain references should move from currency strings to Commodity ids: + +- `Account` references `commodityId`; +- account Commodity is immutable after account creation; if the user selected + the wrong Commodity, they create a new account; +- `Transaction` references `commodityId`, the transaction Commodity that + denominates operation `value`; +- `Operation.amount` is denominated in the account Commodity; +- `Operation.value` is denominated in the transaction Commodity; +- transaction balance validation continues to use the sum of active + `Operation.value` values. + +The existing `/currencies` HTTP stub is not the target API and should be +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. + +The MVP explicitly excludes global reference assets, market data providers, +exchange-rate history, automatic conversion, and network/contract metadata. +Those can be introduced by later ADRs if needed. + +## Alternatives Considered + +1. Validate LED-47 against `currencies.code` now + +- Pros: small change, closes the immediate task. +- Cons: reinforces a model already known to be insufficient; makes display code + the identity even though Commodity identity must be stable and user-owned. + +2. Add foreign keys from accounts and transactions to `currencies.code` + +- Pros: improves relational consistency for the current schema. +- Cons: still assumes ISO-like currency codes are the primary identity and does + not support editable, user-owned Commodities. + +3. Keep validation only in request schemas + +- Pros: minimal work. +- Cons: does not protect application use cases called outside HTTP and does not + ensure referenced monetary units exist in the system. + +4. Introduce a global asset registry first + +- Pros: closer to a future market data and reference-asset model. +- Cons: too large for the current product step and conflicts with the immediate + need for user-created, user-edited Commodities. + +## Consequences + +- LED-47 is re-scoped: final consistency validation must use Commodity + existence, ownership, and tombstone state instead of `currencies.code`. +- The current `Currency` value object should stop being domain identity. During + migration it may survive only as a primitive code/format helper if useful. +- Persistence needs a `commodities` model and a migration path from existing + currency strings to per-user Commodity records. +- Account creation must require a Commodity; account Commodity changes are not + 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. +- 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. + +## Related + +- [LED-64: Commodity Registry Epic](https://gorushkin.atlassian.net/browse/LED-64) +- [LED-65: Define Commodity domain model and ADR](https://gorushkin.atlassian.net/browse/LED-65) +- [LED-47: Currency Consistency (MVP)](https://gorushkin.atlassian.net/browse/LED-47) +- [LED-109: Add Commodity domain primitives](https://gorushkin.atlassian.net/browse/LED-109) +- [LED-66: Design Commodity schema and migration path](https://gorushkin.atlassian.net/browse/LED-66) +- [LED-67: Implement Commodity persistence, repository, and defaults](https://gorushkin.atlassian.net/browse/LED-67) +- [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-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) +- [Multicurrency Design](../../MULTICURRENCY_DESIGN.md) +- [Domain Model](../../DOMAIN.md) +- [Database Schema](../../DATABASE_SCHEMA.md) diff --git a/docs/architecture/adr/0007-no-operation-amount-sign-invariant.md b/docs/architecture/adr/0007-no-operation-amount-sign-invariant.md index 1d3d1a97..278aa8ff 100644 --- a/docs/architecture/adr/0007-no-operation-amount-sign-invariant.md +++ b/docs/architecture/adr/0007-no-operation-amount-sign-invariant.md @@ -10,14 +10,14 @@ `LED-53` proposed requiring every transaction to contain at least one operation with a positive `amount` and one with a negative `amount`. -`amount` is denominated in an operation's account currency. Its sign represents +`amount` is denominated in an operation's account Commodity. Its sign represents the change to that particular account, not a universal debit or credit classification. The meaning of a debit or credit depends on account type, and -cross-currency transactions may use `amount` values that cannot be compared as +cross-Commodity transactions may use `amount` values that cannot be compared as a transaction-wide accounting invariant. The domain already validates the relevant transaction-level invariant: the sum -of operation `value` values in the transaction currency must equal zero. +of operation `value` values in the transaction Commodity must equal zero. ## Decision @@ -34,7 +34,7 @@ account types correctly. 1. Require both `amount` signs in every transaction - Pros: rejects a small class of suspicious operation sets. -- Cons: treats raw account-currency amounts as debit/credit semantics and can +- Cons: treats raw account-Commodity amounts as debit/credit semantics and can reject valid accounting shapes. 2. Derive debit and credit from account types @@ -54,7 +54,7 @@ account types correctly. - `LED-53` is not implemented as a domain validation rule. - No code or test changes are required for this decision. - Any later debit/credit validation must be designed around account types, - currencies, and a defined business use case. + Commodities, and a defined business use case. ## Related diff --git a/docs/architecture/adr/0014-keep-amount-value-object-name.md b/docs/architecture/adr/0014-keep-amount-value-object-name.md index caeb5b65..64e0f082 100644 --- a/docs/architecture/adr/0014-keep-amount-value-object-name.md +++ b/docs/architecture/adr/0014-keep-amount-value-object-name.md @@ -8,16 +8,16 @@ ## Context `Amount` represents a signed integer monetary magnitude in minor units without -currency. It is used for `Operation.amount`, `Operation.value`, account +Commodity identity. It is used for `Operation.amount`, `Operation.value`, account balances and test utilities. The name can be confused with the `Operation.amount` field, but the value object is intentionally broader than that field. `Operation.amount` is an -`Amount` denominated in the account currency, while `Operation.value` is an -`Amount` denominated in the transaction currency. +`Amount` denominated in the account Commodity, while `Operation.value` is an +`Amount` denominated in the transaction Commodity. Ledgerly previously had a separate `Money` value object for amount plus -currency semantics, but it was unused by the current domain model and removed +Commodity semantics, but it was unused by the current domain model and removed in LED-104. ## Decision @@ -25,8 +25,8 @@ in LED-104. Keep the value object named `Amount`. Document `Amount` as a signed integer minor-unit monetary amount without -currency. Use surrounding property names and domain context to explain the -currency denomination when needed. +Commodity identity. Use surrounding property names and domain context to +explain the Commodity denomination when needed. Do not rename `Amount` as part of domain invariant cleanup. @@ -36,7 +36,7 @@ Do not rename `Amount` as part of domain invariant cleanup. - Pros: highlights that the magnitude is monetary. - Cons: historically overlapped with `Money`, which meant amount plus - currency. + Commodity. 2. Rename to `MinorUnitAmount`. @@ -61,13 +61,13 @@ Positive: - Existing domain code keeps the short, established name. - `Amount` remains usable for both `Operation.amount` and `Operation.value`. -- The distinction between `Amount` and currency denomination stays explicit: +- The distinction between `Amount` and Commodity denomination stays explicit: `Amount` carries only the signed minor-unit magnitude, and surrounding domain - context supplies the currency. + context supplies the Commodity. Neutral/cost: -- Readers still need domain context to know which currency an `Amount` is +- Readers still need domain context to know which Commodity an `Amount` is denominated in. - `Operation.amount: Amount` keeps the field/type name overlap. diff --git a/docs/architecture/adr/0016-backend-request-flow.md b/docs/architecture/adr/0016-backend-request-flow.md index df21ef92..933a262c 100644 --- a/docs/architecture/adr/0016-backend-request-flow.md +++ b/docs/architecture/adr/0016-backend-request-flow.md @@ -72,6 +72,9 @@ 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. 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 new file mode 100644 index 00000000..311a6fc2 --- /dev/null +++ b/docs/architecture/adr/0019-repository-enforced-commodity-reference-validation.md @@ -0,0 +1,92 @@ +# ADR 0019: Repository-Enforced Commodity Reference Validation + +- Status: Accepted +- Date: 2026-07-23 +- Jira: https://gorushkin.atlassian.net/browse/LED-69 +- PR: TBD + +## Context + +ADR 0006 defines Commodity identity and requires accounts and transactions to +reference user-owned Commodities by stable id rather than currency code strings. + +There are two possible places to validate those references: + +- application use cases can load the referenced Commodity before creating the + aggregate; +- write repositories can validate the referenced Commodity immediately before + inserting or updating the row that stores the foreign key. + +The account and transaction write paths need the same boundary. Keeping account +validation in the use case while transaction validation lives in the repository +creates an avoidable asymmetry. + +## Decision + +Commodity reference validation for account and transaction writes is enforced by +write repositories. + +Before persisting a new account or transaction, the repository must verify that +the referenced Commodity: + +- exists; +- belongs to the same user as the write; +- is not tombstoned. + +Create repositories accept the authenticated `userId` as an explicit argument +and must reject snapshots whose `userId` differs from that argument. Snapshot +ownership remains domain state, but repository writes must not trust it as the +only authority for cross-user persistence boundaries. + +Use cases remain responsible for request orchestration, aggregate construction, +transaction boundaries and calling repository interfaces. They do not need to +load the Commodity solely to prove that the persistence reference is valid. + +Repository checks are part of the repository contract, not a substitute for +request shape validation. HTTP/request schemas still validate that the supplied +Commodity id has the expected UUID shape. + +## Alternatives Considered + +1. Application-level validation before aggregate creation + +- Pros: makes the policy visible in use cases and avoids entering repository + write paths for invalid references. +- Cons: duplicates repository-side data checks, introduces a time-of-check to + time-of-use gap and makes account and transaction flows carry Commodity data + that is not otherwise needed for aggregate behavior. + +2. Rely only on database foreign keys + +- Pros: smallest implementation. +- Cons: foreign keys do not express same-user ownership or active/tombstone + policy and produce less intentional error contracts. + +3. Repository-enforced validation + +- Pros: validates closest to the write, keeps account and transaction write + paths symmetric and gives repositories one clear contract for Commodity-backed + persistence references. +- Cons: invalid references reach the repository call, so tests must assert + stable repository errors rather than expecting use cases to short-circuit. + +## Consequences + +- `CreateAccountUseCase` and `CreateTransactionUseCase` should not duplicate + Commodity existence/ownership/tombstone checks solely for reference integrity. +- Account and transaction repositories must keep explicit pre-write checks for + Commodity ownership and tombstone state. +- Create repositories must keep authenticated `userId` separate from snapshots + and fail fast on ownership mismatches before inserting rows. +- Tests for missing, foreign and tombstoned Commodity references belong at the + repository/write-boundary level, with higher-level tests added only where they + verify public error mapping or user-visible behavior. +- LED-69 should be read as Commodity-backed write reference validation, not as a + requirement that failed validation must avoid all repository calls. + +## Related + +- [ADR 0006: Commodity Registry Before Currency Validation](./0006-commodity-registry-before-currency-validation.md) +- [ADR 0016: Backend Request Flow](./0016-backend-request-flow.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) diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index f07e91a1..0489a2c3 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -36,7 +36,7 @@ - [ADR 0003: No zero-net account effect invariant](./0003-no-zero-net-account-effect-invariant.md) - [ADR 0004: No minimum distinct accounts invariant](./0004-no-minimum-distinct-accounts-invariant.md) - [ADR 0005: No non-zero operation invariant](./0005-no-non-zero-operation-invariant.md) -- [ADR 0006: Asset registry before currency validation](./0006-asset-registry-before-currency-validation.md) +- [ADR 0006: Commodity registry before currency validation](./0006-commodity-registry-before-currency-validation.md) - [ADR 0007: No operation amount sign invariant](./0007-no-operation-amount-sign-invariant.md) - [ADR 0008: Structured API error contract](./0008-structured-api-error-contract.md) - [ADR 0009: Repository error-code migration](./0009-repository-error-code-migration.md) @@ -49,3 +49,4 @@ - [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) diff --git a/packages/shared/src/routes/index.ts b/packages/shared/src/routes/index.ts index 74bb0390..c24f5d90 100644 --- a/packages/shared/src/routes/index.ts +++ b/packages/shared/src/routes/index.ts @@ -1,8 +1,7 @@ export const ROUTES = { accounts: "/accounts", auth: "/auth", - currencies: "/currencies", - entries: "/entries", + commodities: "/commodities", transactions: "/transactions", user: "/user", }; diff --git a/packages/shared/src/types/account.ts b/packages/shared/src/types/account.ts index e94c2e18..28dbaea9 100644 --- a/packages/shared/src/types/account.ts +++ b/packages/shared/src/types/account.ts @@ -1,4 +1,4 @@ -import { CurrencyCode, IsoDatetimeString, AmountString, UUID } from "./types"; +import { IsoDatetimeString, AmountString, UUID, RequiredText } from "./types"; export type AccountTypeValue = | "asset" @@ -8,35 +8,29 @@ export type AccountTypeValue = | "currencyTrading" | "expense"; -export type AccountDomain = { +export type AccountCreateDTO = { + commodityId: UUID; + description: string; + initialBalance: AmountString; + name: string; + type: AccountTypeValue; +}; + +export type AccountUpdateDTO = Partial< + Pick +>; + +export type AccountResponseDTO = { + commodityId: UUID; createdAt: IsoDatetimeString; - currency: CurrencyCode; currentClearedBalanceLocal: AmountString; description: string; id: UUID; initialBalance: AmountString; isSystem: boolean; isTombstone: boolean; - name: string; + name: RequiredText; type: AccountTypeValue; updatedAt: IsoDatetimeString; userId: UUID; }; - -export type AccountCreateDTO = { - currency: CurrencyCode; - description: string; - initialBalance: AmountString; - isSystem?: boolean; - name: string; - type: AccountTypeValue; -}; - -export type AccountUpdateDTO = Partial< - Pick< - AccountCreateDTO, - "name" | "description" | "type" | "currency" | "isSystem" - > ->; - -export type AccountResponseDTO = AccountDomain; diff --git a/packages/shared/src/types/commodity.ts b/packages/shared/src/types/commodity.ts new file mode 100644 index 00000000..66b745ca --- /dev/null +++ b/packages/shared/src/types/commodity.ts @@ -0,0 +1,31 @@ +import { + CommodityCodeString, + CommodityPrecisionNumber, + CommoditySymbolString, + IsoDatetimeString, + RequiredText, + UUID, +} from "./types"; + +export type CommodityCreateDTO = { + code: CommodityCodeString; + name: RequiredText; + precision?: CommodityPrecisionNumber; + symbol?: CommoditySymbolString | null; +}; + +export type CommodityResponseDTO = { + code: CommodityCodeString; + createdAt: IsoDatetimeString; + id: UUID; + isTombstone: boolean; + name: RequiredText; + precision: CommodityPrecisionNumber; + symbol: CommoditySymbolString | null; + updatedAt: IsoDatetimeString; + userId: UUID; +}; + +export type CommodityUpdateDTO = Partial< + Pick +>; diff --git a/packages/shared/src/types/error-contract.ts b/packages/shared/src/types/error-contract.ts index 81189bae..f0db0b07 100644 --- a/packages/shared/src/types/error-contract.ts +++ b/packages/shared/src/types/error-contract.ts @@ -14,6 +14,9 @@ export const apiErrorCodes = { internalServerError: "INTERNAL_SERVER_ERROR", invalidAccountType: "INVALID_ACCOUNT_TYPE", invalidAmount: "INVALID_AMOUNT", + invalidCommodityCode: "INVALID_COMMODITY_CODE", + invalidCommodityPrecision: "INVALID_COMMODITY_PRECISION", + invalidCommoditySymbol: "INVALID_COMMODITY_SYMBOL", invalidDate: "INVALID_DATE", invalidEmail: "INVALID_EMAIL", invalidIdentifier: "INVALID_IDENTIFIER", @@ -91,6 +94,21 @@ export type ErrorContextByCode = { reason: "NOT_INTEGER_MINOR_UNITS"; received: string; }; + INVALID_COMMODITY_CODE: { + field?: string; + reason: "INVALID_FORMAT"; + received: string; + }; + INVALID_COMMODITY_PRECISION: { + field?: string; + reason: "INVALID_FORMAT"; + received: string; + }; + INVALID_COMMODITY_SYMBOL: { + field?: string; + reason: "INVALID_FORMAT"; + received: string; + }; INVALID_DATE: { reason: "INVALID_FORMAT"; }; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index a4fb3e21..7f364b89 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -5,3 +5,4 @@ export * from "./types"; export * from "./error-contract"; export * from "./pagination"; export * from "./transaction"; +export * from "./commodity"; diff --git a/packages/shared/src/types/transaction.ts b/packages/shared/src/types/transaction.ts index c0bf2ee4..38a4f6fa 100644 --- a/packages/shared/src/types/transaction.ts +++ b/packages/shared/src/types/transaction.ts @@ -4,7 +4,6 @@ import { getTransactionsQuerySchema } from "../validation/baseValidations"; import type { PaginatedResponse } from "./pagination"; import type { - CurrencyCode, IsoDateString, IsoDatetimeString, AmountString, @@ -43,7 +42,7 @@ export type OperationResponseDTO = { }; export type CreateTransactionRequestDTO = { - currencyCode: CurrencyCode; + commodityId: UUID; description: string; operations: CreateOperationRequestDTO[]; postingDate: IsoDateString; @@ -63,8 +62,8 @@ export type UpdateTransactionRequestDTO = { }; export type TransactionResponseDTO = { + commodityId: UUID; createdAt: IsoDatetimeString; - currency: CurrencyCode; description: string; id: UUID; operations: OperationResponseDTO[]; diff --git a/packages/shared/src/types/types.ts b/packages/shared/src/types/types.ts index ffd4afc4..ba2f7c0d 100644 --- a/packages/shared/src/types/types.ts +++ b/packages/shared/src/types/types.ts @@ -1,20 +1,27 @@ import { z } from "zod"; import { - currencyCode, isoDate, isoDatetime, sha256String, uuid, amountString, + commodityCode, + commodityPrecision, + commoditySymbol, + queryStatus, + queryStatusSchema, + requiredText, } from "../validation/baseValidations"; -export type CurrencyCode = z.infer; - export type IsoDatetimeString = z.infer; 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 new file mode 100644 index 00000000..5fced25c --- /dev/null +++ b/packages/shared/src/validation/accounts.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { accountCreateSchema, accountUpdateSchema } from "./accounts"; + +const validCreateAccount = { + commodityId: "00000000-0000-4000-8000-000000000000", + initialBalance: "0", + name: "Checking", + type: "asset", +}; + +describe("accountCreateSchema", () => { + it("defaults omitted description to an empty string", () => { + expect(accountCreateSchema.parse(validCreateAccount)).toEqual({ + ...validCreateAccount, + description: "", + }); + }); +}); + +describe("accountUpdateSchema", () => { + it("does not default omitted description", () => { + expect(accountUpdateSchema.parse({ name: "Checking" })).toEqual({ + name: "Checking", + }); + }); + + it("allows an explicit empty description to clear the field", () => { + expect(accountUpdateSchema.parse({ description: "" })).toEqual({ + description: "", + }); + }); +}); diff --git a/packages/shared/src/validation/accounts.ts b/packages/shared/src/validation/accounts.ts index e92acc73..38de0b7c 100644 --- a/packages/shared/src/validation/accounts.ts +++ b/packages/shared/src/validation/accounts.ts @@ -3,10 +3,9 @@ import { z } from "zod"; import { ACCOUNT_TYPE_VALUES } from "../constants"; import { - notNullText, + defaultText, + optionalText, requiredText, - currencyCode, - isoDatetime, uuid, amountString, } from "./baseValidations"; @@ -14,31 +13,15 @@ import { const accountType = z.enum(ACCOUNT_TYPE_VALUES); export const accountCreateSchema = z.object({ - currency: currencyCode, - description: requiredText, + commodityId: uuid, + description: defaultText, initialBalance: amountString, - name: notNullText, + name: requiredText, type: accountType, }); -export const accountUpdateSchema = accountCreateSchema - .pick({ - currency: true, - description: true, - name: true, - type: true, - }) - .partial(); - -export const accountResponseSchema = z.object({ - createdAt: isoDatetime, - currency: currencyCode, - currentClearedBalanceLocal: z.number(), - description: requiredText, - id: uuid, - initialBalance: z.number(), - name: requiredText, - type: accountType, - updatedAt: isoDatetime, - userId: uuid, +export const accountUpdateSchema = z.object({ + description: optionalText, + name: requiredText.optional(), + type: accountType.optional(), }); diff --git a/packages/shared/src/validation/baseValidations.test.ts b/packages/shared/src/validation/baseValidations.test.ts new file mode 100644 index 00000000..ed84e72d --- /dev/null +++ b/packages/shared/src/validation/baseValidations.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { optionalText, requiredText } from "./baseValidations"; + +describe("requiredText", () => { + it("rejects empty text", () => { + expect(requiredText.safeParse("").success).toBe(false); + }); + + it("rejects whitespace-only text", () => { + expect(requiredText.safeParse(" ").success).toBe(false); + }); + + it("trims valid text", () => { + expect(requiredText.parse(" Ledgerly ")).toBe("Ledgerly"); + }); +}); + +describe("optionalText", () => { + it("keeps omitted text omitted", () => { + expect(optionalText.parse(undefined)).toBeUndefined(); + }); + + it("allows an explicit empty string", () => { + expect(optionalText.parse("")).toBe(""); + }); +}); diff --git a/packages/shared/src/validation/baseValidations.ts b/packages/shared/src/validation/baseValidations.ts index 5b53f1ec..7bf19222 100644 --- a/packages/shared/src/validation/baseValidations.ts +++ b/packages/shared/src/validation/baseValidations.ts @@ -10,28 +10,22 @@ import { DEFAULT_TRANSACTION_SORT_ORDER, } from "../constants/transactions"; -export const requiredText = z.string().default(""); - -export const defaultText = z.string().default("").optional(); -export const defaultNumber = z.number().default(0).optional(); -export const notNullText = z.string().min(1, "This field cannot be empty"); +export const text = z.string(); +export const optionalText = text.optional(); +export const defaultText = text.default(""); +export const requiredText = z + .string() + .trim() + .min(1, "Required text must not be empty"); export const updatedAt = z.string(); export const createdAt = z.string(); export const uuid = z.string().uuid().brand<"UUID">(); -export const name = z.string().min(1); export const dateText = z.string().refine((d) => !isNaN(Date.parse(d)), { message: "Invalid date format", }); - export const uniqueIdSchema = z.object({ id: uuid, }); - -export const currencyCode = z - .string() - .length(3, "Currency code must be exactly 3 characters") - .brand<"CurrencyCode">(); - export const isoDatetime = z.string().datetime().brand<"IsoDatetimeString">(); export const sha256String = z.string().regex(/^[a-f0-9]{64}$/, { @@ -80,3 +74,33 @@ 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.ts b/packages/shared/src/validation/commodities.ts new file mode 100644 index 00000000..063b39a5 --- /dev/null +++ b/packages/shared/src/validation/commodities.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +import { + requiredText, + commodityPrecision, + commoditySymbol, + commodityCode, +} from "./baseValidations"; + +export const commodityCreateSchema = z.object({ + code: commodityCode, + name: requiredText, + precision: commodityPrecision.optional(), + symbol: commoditySymbol.optional(), +}); + +export const commodityUpdateSchema = commodityCreateSchema + .pick({ + code: true, + name: true, + symbol: true, + }) + .partial(); diff --git a/packages/shared/src/validation/index.ts b/packages/shared/src/validation/index.ts index d9a19085..1147d7eb 100644 --- a/packages/shared/src/validation/index.ts +++ b/packages/shared/src/validation/index.ts @@ -3,3 +3,4 @@ export * from "./transactions"; export * from "./users"; export * from "./auth"; export * from "./baseValidations"; +export * from "./commodities"; diff --git a/packages/shared/src/validation/transactions.test.ts b/packages/shared/src/validation/transactions.test.ts new file mode 100644 index 00000000..11e000a4 --- /dev/null +++ b/packages/shared/src/validation/transactions.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; + +import { + operationUpdateSchema, + transactionCreateSchema, + transactionUpdateSchema, +} from "./transactions"; + +const accountId = "00000000-0000-4000-8000-000000000001"; +const commodityId = "00000000-0000-4000-8000-000000000002"; +const operationId = "00000000-0000-4000-8000-000000000003"; + +const validOperationCreate = { + accountId, + amount: "100", + value: "100", +}; + +const validOperationUpdate = { + ...validOperationCreate, + description: "Updated operation", + id: operationId, +}; + +const validTransactionUpdate = { + description: "Updated transaction", + operations: { + create: [], + delete: [], + update: [validOperationUpdate], + }, + postingDate: "2026-07-27", + transactionDate: "2026-07-27", + version: 1, +}; + +describe("transactionCreateSchema", () => { + it("defaults omitted transaction and operation descriptions to empty strings", () => { + expect( + transactionCreateSchema.parse({ + commodityId, + operations: [ + validOperationCreate, + { + ...validOperationCreate, + amount: "-100", + value: "-100", + }, + ], + postingDate: "2026-07-27", + transactionDate: "2026-07-27", + }), + ).toMatchObject({ + description: "", + operations: [{ description: "" }, { description: "" }], + }); + }); +}); + +describe("transactionUpdateSchema", () => { + it("requires an explicit transaction description", () => { + const { description: _description, ...payload } = validTransactionUpdate; + + expect(transactionUpdateSchema.safeParse(payload).success).toBe(false); + }); + + it("requires an explicit updated operation description", () => { + const { description: _description, ...operation } = validOperationUpdate; + + expect( + operationUpdateSchema.safeParse({ + ...operation, + }).success, + ).toBe(false); + }); + + it("allows explicit empty descriptions to clear them", () => { + expect( + transactionUpdateSchema.parse({ + ...validTransactionUpdate, + description: "", + operations: { + ...validTransactionUpdate.operations, + update: [ + { + ...validOperationUpdate, + description: "", + }, + ], + }, + }), + ).toMatchObject({ + description: "", + operations: { + update: [{ description: "" }], + }, + }); + }); +}); diff --git a/packages/shared/src/validation/transactions.ts b/packages/shared/src/validation/transactions.ts index 18090a27..77b2662f 100644 --- a/packages/shared/src/validation/transactions.ts +++ b/packages/shared/src/validation/transactions.ts @@ -7,21 +7,20 @@ import { import { uuid, - requiredText, + text, + defaultText, isoDate, - isoDatetime, amountString, - currencyCode, } from "./baseValidations"; -// amount — posting in the account's native currency -// value — posting in the transaction's currency (GnuCash convention) +// amount — posting in the account Commodity +// value — posting in the transaction Commodity (GnuCash convention) // For same-currency transactions amount === value. // Transaction balance is validated by summing value across all operations (must equal 0). export const operationCreateSchema = z.object({ accountId: uuid, amount: amountString, - description: requiredText, + description: defaultText, value: amountString, }); @@ -29,14 +28,14 @@ export const operationCreateSchema = z.object({ export const operationUpdateSchema = z.object({ accountId: uuid, amount: amountString, - description: requiredText, + description: text, id: uuid, value: amountString, }); export const transactionCreateSchema = z.object({ - currencyCode: currencyCode, - description: requiredText, + commodityId: uuid, + description: defaultText, operations: z .array(operationCreateSchema) .min(MIN_TRANSACTION_OPERATIONS) @@ -46,7 +45,7 @@ export const transactionCreateSchema = z.object({ }); export const transactionUpdateSchema = z.object({ - description: requiredText, + description: text, operations: z.object({ create: z.array(operationCreateSchema).max(MAX_TRANSACTION_OPERATIONS), delete: z.array(uuid).max(MAX_TRANSACTION_OPERATIONS), @@ -57,21 +56,7 @@ export const transactionUpdateSchema = z.object({ version: z.number().int().nonnegative(), }); -export const transactionResponseSchema = z.object({ - createdAt: isoDatetime, - description: requiredText, - id: uuid, - isTombstone: z.boolean(), - postingDate: isoDate, - transactionDate: isoDate, - updatedAt: isoDatetime, - userId: uuid, - version: z.number().int().nonnegative(), -}); - export type OperationCreateInput = z.infer; export type OperationUpdateInput = z.infer; - export type TransactionCreateInput = z.infer; export type TransactionUpdateInput = z.infer; -export type TransactionResponse = z.infer; diff --git a/packages/shared/src/validation/users.ts b/packages/shared/src/validation/users.ts index 92b97b2d..0a2ac80d 100644 --- a/packages/shared/src/validation/users.ts +++ b/packages/shared/src/validation/users.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { notNullText } from "./baseValidations"; +import { requiredText } from "./baseValidations"; const password = z .string() @@ -12,7 +12,7 @@ const password = z export const usersCreateSchema = z.object({ email: z.string().email().toLowerCase().trim().min(1).max(255), - name: notNullText, + name: requiredText, password, }); @@ -25,7 +25,7 @@ export const usersResponseSchema = z export const usersUpdateSchema = z .object({ email: z.string().email().toLowerCase().trim().min(1).max(255).optional(), - name: notNullText.optional(), + name: requiredText.optional(), }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -33,6 +33,6 @@ export const usersUpdateSchema = z }); export const passwordChangeSchema = z.object({ - currentPassword: notNullText, + currentPassword: requiredText, newPassword: password, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 244c1885..4e2c6324 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,55 +130,13 @@ importers: apps/frontend: dependencies: - '@hookform/resolvers': - specifier: ^5.0.1 - version: 5.0.1(react-hook-form@7.56.4(react@19.1.0)) - '@ledgerly/shared': - specifier: workspace:* - version: link:../../packages/shared - '@tailwindcss/vite': - specifier: ^4.1.5 - version: 4.1.7(vite@6.3.5(@types/node@22.15.18)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.1)) - '@tanstack/react-router': - specifier: ^1.119.0 - version: 1.120.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@tanstack/react-router-devtools': - specifier: ^1.119.1 - version: 1.120.5(@tanstack/react-router@1.120.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@tanstack/router-core@1.120.5)(csstype@3.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tiny-invariant@1.3.3) - clsx: - specifier: ^2.1.1 - version: 2.1.1 - lucide-react: - specifier: ^0.507.0 - version: 0.507.0(react@19.1.0) - mobx: - specifier: ^6.13.7 - version: 6.13.7 - mobx-react-lite: - specifier: ^4.1.0 - version: 4.1.0(mobx@6.13.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: specifier: ^19.0.0 version: 19.1.0 react-dom: specifier: ^19.0.0 version: 19.1.0(react@19.1.0) - react-hook-form: - specifier: ^7.56.2 - version: 7.56.4(react@19.1.0) - tailwind-merge: - specifier: ^3.3.0 - version: 3.3.0 - tailwindcss: - specifier: ^4.1.5 - version: 4.1.7 devDependencies: - '@tanstack/eslint-plugin-router': - specifier: ^1.115.0 - version: 1.115.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) - '@tanstack/router-plugin': - specifier: ^1.119.0 - version: 1.120.5(@tanstack/react-router@1.120.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.3.5(@types/node@22.15.18)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.1)) '@types/react': specifier: ^19.0.10 version: 19.1.4 @@ -188,9 +146,6 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.4.1(vite@6.3.5(@types/node@22.15.18)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.1)) - daisyui: - specifier: ^5.0.35 - version: 5.0.35 eslint-plugin-react: specifier: ^7.37.5 version: 7.37.5(eslint@9.27.0(jiti@2.4.2)) @@ -280,18 +235,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/plugin-syntax-jsx@7.27.1': - resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-typescript@7.27.1': - resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} @@ -679,11 +622,6 @@ packages: '@fastify/proxy-addr@5.0.0': resolution: {integrity: sha512-37qVVA1qZ5sgH7KpHkkC4z9SK6StIsIcOmpjvMPXNb3vx2GQxhZocogVYbr2PbbeLCQxYIPDok307xEvRZOzGA==} - '@hookform/resolvers@5.0.1': - resolution: {integrity: sha512-u/+Jp83luQNx9AdyW2fIPGY6Y7NG68eN2ZW8FOJYL+M0i4s49+refdJdOp/A9n9HFQtQs3HIDHQvX3ZET2o7YA==} - peerDependencies: - react-hook-form: ^7.55.0 - '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -708,10 +646,6 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} - '@isaacs/fs-minipass@4.0.1': - resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} - engines: {node: '>=18.0.0'} - '@istanbuljs/schema@0.1.3': resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} @@ -978,186 +912,6 @@ packages: '@rushstack/ts-command-line@5.0.1': resolution: {integrity: sha512-bsbUucn41UXrQK7wgM8CNM/jagBytEyJqXw/umtI8d68vFm1Jwxh1OtLrlW7uGZgjCWiiPH6ooUNa1aVsuVr3Q==} - '@standard-schema/utils@0.3.0': - resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} - - '@tailwindcss/node@4.1.7': - resolution: {integrity: sha512-9rsOpdY9idRI2NH6CL4wORFY0+Q6fnx9XP9Ju+iq/0wJwGD5IByIgFmwVbyy4ymuyprj8Qh4ErxMKTUL4uNh3g==} - - '@tailwindcss/oxide-android-arm64@4.1.7': - resolution: {integrity: sha512-IWA410JZ8fF7kACus6BrUwY2Z1t1hm0+ZWNEzykKmMNM09wQooOcN/VXr0p/WJdtHZ90PvJf2AIBS/Ceqx1emg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [android] - - '@tailwindcss/oxide-darwin-arm64@4.1.7': - resolution: {integrity: sha512-81jUw9To7fimGGkuJ2W5h3/oGonTOZKZ8C2ghm/TTxbwvfSiFSDPd6/A/KE2N7Jp4mv3Ps9OFqg2fEKgZFfsvg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@tailwindcss/oxide-darwin-x64@4.1.7': - resolution: {integrity: sha512-q77rWjEyGHV4PdDBtrzO0tgBBPlQWKY7wZK0cUok/HaGgbNKecegNxCGikuPJn5wFAlIywC3v+WMBt0PEBtwGw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@tailwindcss/oxide-freebsd-x64@4.1.7': - resolution: {integrity: sha512-RfmdbbK6G6ptgF4qqbzoxmH+PKfP4KSVs7SRlTwcbRgBwezJkAO3Qta/7gDy10Q2DcUVkKxFLXUQO6J3CRvBGw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [freebsd] - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.7': - resolution: {integrity: sha512-OZqsGvpwOa13lVd1z6JVwQXadEobmesxQ4AxhrwRiPuE04quvZHWn/LnihMg7/XkN+dTioXp/VMu/p6A5eZP3g==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-gnu@4.1.7': - resolution: {integrity: sha512-voMvBTnJSfKecJxGkoeAyW/2XRToLZ227LxswLAwKY7YslG/Xkw9/tJNH+3IVh5bdYzYE7DfiaPbRkSHFxY1xA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-musl@4.1.7': - resolution: {integrity: sha512-PjGuNNmJeKHnP58M7XyjJyla8LPo+RmwHQpBI+W/OxqrwojyuCQ+GUtygu7jUqTEexejZHr/z3nBc/gTiXBj4A==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-gnu@4.1.7': - resolution: {integrity: sha512-HMs+Va+ZR3gC3mLZE00gXxtBo3JoSQxtu9lobbZd+DmfkIxR54NO7Z+UQNPsa0P/ITn1TevtFxXTpsRU7qEvWg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-musl@4.1.7': - resolution: {integrity: sha512-MHZ6jyNlutdHH8rd+YTdr3QbXrHXqwIhHw9e7yXEBcQdluGwhpQY2Eku8UZK6ReLaWtQ4gijIv5QoM5eE+qlsA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-wasm32-wasi@4.1.7': - resolution: {integrity: sha512-ANaSKt74ZRzE2TvJmUcbFQ8zS201cIPxUDm5qez5rLEwWkie2SkGtA4P+GPTj+u8N6JbPrC8MtY8RmJA35Oo+A==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - bundledDependencies: - - '@napi-rs/wasm-runtime' - - '@emnapi/core' - - '@emnapi/runtime' - - '@tybys/wasm-util' - - '@emnapi/wasi-threads' - - tslib - - '@tailwindcss/oxide-win32-arm64-msvc@4.1.7': - resolution: {integrity: sha512-HUiSiXQ9gLJBAPCMVRk2RT1ZrBjto7WvqsPBwUrNK2BcdSxMnk19h4pjZjI7zgPhDxlAbJSumTC4ljeA9y0tEw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@tailwindcss/oxide-win32-x64-msvc@4.1.7': - resolution: {integrity: sha512-rYHGmvoHiLJ8hWucSfSOEmdCBIGZIq7SpkPRSqLsH2Ab2YUNgKeAPT1Fi2cx3+hnYOrAb0jp9cRyode3bBW4mQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@tailwindcss/oxide@4.1.7': - resolution: {integrity: sha512-5SF95Ctm9DFiUyjUPnDGkoKItPX/k+xifcQhcqX5RA85m50jw1pT/KzjdvlqxRja45Y52nR4MR9fD1JYd7f8NQ==} - engines: {node: '>= 10'} - - '@tailwindcss/vite@4.1.7': - resolution: {integrity: sha512-tYa2fO3zDe41I7WqijyVbRd8oWT0aEID1Eokz5hMT6wShLIHj3yvwj9XbfuloHP9glZ6H+aG2AN/+ZrxJ1Y5RQ==} - peerDependencies: - vite: ^5.2.0 || ^6 - - '@tanstack/eslint-plugin-router@1.115.0': - resolution: {integrity: sha512-vwKP9NjzAQ2nU3qADDoTSTxD1vNduLcK0312AWkXkicCWUZQZJtmRuZXPk9cEfuzDD2BAJCIUmlaF1K0vq4oRw==} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - - '@tanstack/history@1.115.0': - resolution: {integrity: sha512-K7JJNrRVvyjAVnbXOH2XLRhFXDkeP54Kt2P4FR1Kl2KDGlIbkua5VqZQD2rot3qaDrpufyUa63nuLai1kOLTsQ==} - engines: {node: '>=12'} - - '@tanstack/react-router-devtools@1.120.5': - resolution: {integrity: sha512-unCiEL/QbB4O2Gt5U5U6xPoo2fdA38J+1cAzv0CNiK3cuj+XNd/5oegIuusFn4z0W+S2CIxCBXR1w0A49iMxrA==} - engines: {node: '>=12'} - peerDependencies: - '@tanstack/react-router': ^1.120.5 - react: '>=18.0.0 || >=19.0.0' - react-dom: '>=18.0.0 || >=19.0.0' - - '@tanstack/react-router@1.120.5': - resolution: {integrity: sha512-A+YRftGwAeFBxa8DF5ujNYqkSEbjCa1KjxDNYr+jWj16jjTxrz/XqgOJCv5ZfbAqqqOa3yLYoQbWa7OGz5jHuA==} - engines: {node: '>=12'} - peerDependencies: - react: '>=18.0.0 || >=19.0.0' - react-dom: '>=18.0.0 || >=19.0.0' - - '@tanstack/react-store@0.7.0': - resolution: {integrity: sha512-S/Rq17HaGOk+tQHV/yrePMnG1xbsKZIl/VsNWnNXt4XW+tTY8dTlvpJH2ZQ3GRALsusG5K6Q3unAGJ2pd9W/Ng==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - '@tanstack/router-core@1.120.5': - resolution: {integrity: sha512-IXLNv3j7rpTL/YNCWHijZgrnxFuvD4Nz/nUiGSak4x5BKzlnuZEso81xFcIuczVrEW72NxZv8IfzpR5M5Tuc0A==} - engines: {node: '>=12'} - - '@tanstack/router-devtools-core@1.120.5': - resolution: {integrity: sha512-PciNudzA26JoE+M1LC2iBXZNOx2X2xCnccSdRLieuv6ZJrf+jT+rTYf59n/Z13H2oIJac1ZDv3oIOwLvfPCqkQ==} - engines: {node: '>=12'} - peerDependencies: - '@tanstack/router-core': ^1.120.5 - csstype: ^3.0.10 - solid-js: '>=1.9.5' - tiny-invariant: ^1.3.3 - peerDependenciesMeta: - csstype: - optional: true - - '@tanstack/router-generator@1.120.5': - resolution: {integrity: sha512-09YWjEXqrJE+j89gKBGSbt0ca3CYwo0QUlsDlCQ1WSI4e3GuQLvSROjq97nMjaPtrnWiROxgnw5S18BlcVVvfA==} - engines: {node: '>=12'} - peerDependencies: - '@tanstack/react-router': ^1.120.5 - peerDependenciesMeta: - '@tanstack/react-router': - optional: true - - '@tanstack/router-plugin@1.120.5': - resolution: {integrity: sha512-5w6vW7g5LgGZ1IM1D2HPInRmJHqK5jSa2REr7QFwKVDvbVHfzOAcqfAIw1kiPRRqIh/r6YSsrGyMV5MVbrJSCA==} - engines: {node: '>=12'} - peerDependencies: - '@rsbuild/core': '>=1.0.2' - '@tanstack/react-router': ^1.120.5 - vite: '>=5.0.0 || >=6.0.0' - vite-plugin-solid: ^2.11.2 - webpack: '>=5.92.0' - peerDependenciesMeta: - '@rsbuild/core': - optional: true - '@tanstack/react-router': - optional: true - vite: - optional: true - vite-plugin-solid: - optional: true - webpack: - optional: true - - '@tanstack/router-utils@1.115.0': - resolution: {integrity: sha512-Dng4y+uLR9b5zPGg7dHReHOTHQa6x+G6nCoZshsDtWrYsrdCcJEtLyhwZ5wG8OyYS6dVr/Cn+E5Bd2b6BhJ89w==} - engines: {node: '>=12'} - - '@tanstack/store@0.7.0': - resolution: {integrity: sha512-CNIhdoUsmD2NolYuaIs8VfWM467RK6oIBAW4nPEKZhg1smZ+/CwtCdpURgp7nxSqOaV9oKkzdWD80+bC66F/Jg==} - - '@tanstack/virtual-file-routes@1.115.0': - resolution: {integrity: sha512-XLUh1Py3AftcERrxkxC5Y5m5mfllRH3YR6YVlyjFgI2Tc2Ssy2NKmQFQIafoxfW459UJ8Dn81nWKETEIJifE4g==} - engines: {node: '>=12'} - '@tsconfig/node10@1.0.11': resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} @@ -1417,10 +1171,6 @@ packages: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} - ansis@3.17.0: - resolution: {integrity: sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==} - engines: {node: '>=14'} - anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -1491,9 +1241,6 @@ packages: avvio@9.1.0: resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} - babel-dead-code-elimination@1.0.10: - resolution: {integrity: sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA==} - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1565,10 +1312,6 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} - chownr@3.0.0: - resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} - engines: {node: '>=18'} - cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -1577,10 +1320,6 @@ packages: resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==} engines: {node: '>=20'} - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1624,9 +1363,6 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} - daisyui@5.0.35: - resolution: {integrity: sha512-AWi11n/x5++mps55jcwrBf0Lmip1euWY0FYcH/05SFGmoqrU7S7/aIUWaiaeqlJ5EcmEZ/7zEY73aOxMv6hcIg==} - data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -1697,10 +1433,6 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} - diff@7.0.0: - resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} - engines: {node: '>=0.3.1'} - doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -1833,10 +1565,6 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - enhanced-resolve@5.18.1: - resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} - engines: {node: '>=10.13.0'} - entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -2237,11 +1965,6 @@ packages: globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} - goober@2.1.16: - resolution: {integrity: sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==} - peerDependencies: - csstype: ^3.0.10 - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -2531,6 +2254,7 @@ packages: libsql@0.5.10: resolution: {integrity: sha512-lQu5RLqDLFuo6H5SuR3CnEstGVph77Jd8lm3fdW64p6tUjOC0X8Z9PlfAdZYxWyirYLH9uwcEZUrABsNKYwOiQ==} + cpu: [x64, arm64, wasm32, arm] os: [darwin, linux, win32] light-my-request@6.6.0: @@ -2644,11 +2368,6 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} - lucide-react@0.507.0: - resolution: {integrity: sha512-XfgE6gvAHwAtnbUvWiTTHx4S3VGR+cUJHEc0vrh9Ogu672I1Tue2+Cp/8JJqpytgcBHAB1FVI297W4XGNwc2dQ==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -2698,42 +2417,17 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} - minizlib@3.0.2: - resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==} - engines: {node: '>= 18'} - mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} hasBin: true - mkdirp@3.0.1: - resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} - engines: {node: '>=10'} - hasBin: true - mlly@1.7.4: resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==} mnemonist@0.40.3: resolution: {integrity: sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==} - mobx-react-lite@4.1.0: - resolution: {integrity: sha512-QEP10dpHHBeQNv1pks3WnHRCem2Zp636lq54M2nKO2Sarr13pL4u6diQXf65yzXUn0mkk18SyIDCm9UOJYTi1w==} - peerDependencies: - mobx: ^6.9.0 - react: ^16.8.0 || ^17 || ^18 || ^19 - react-dom: '*' - react-native: '*' - peerDependenciesMeta: - react-dom: - optional: true - react-native: - optional: true - - mobx@6.13.7: - resolution: {integrity: sha512-aChaVU/DO5aRPmk1GX8L+whocagUUpBQqoPtJk+cm7UOXUk87J4PeWCh6nNmTTIfEhiR9DI/+FnA8dln/hTK7g==} - mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -2957,12 +2651,6 @@ packages: peerDependencies: react: ^19.1.0 - react-hook-form@7.56.4: - resolution: {integrity: sha512-Rob7Ftz2vyZ/ZGsQZPaRdIefkgOSrQSPXfqBdvOPwJfoGnjwRJUs7EM7Kc1mcoDv3NOtqBzPGbcMB8CGn9CKgw==} - engines: {node: '>=18.0.0'} - peerDependencies: - react: ^16.8.0 || ^17 || ^18 || ^19 - react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -3083,16 +2771,6 @@ packages: engines: {node: '>=10'} hasBin: true - seroval-plugins@1.3.1: - resolution: {integrity: sha512-dOlUoiI3fgZbQIcj6By+l865pzeWdP3XCSLdI3xlKnjCk5983yLWPsXytFOUI0BUZKG9qwqbj78n9yVcVwUqaQ==} - engines: {node: '>=10'} - peerDependencies: - seroval: ^1.0 - - seroval@1.3.1: - resolution: {integrity: sha512-F+T9EQPdLzgdewgxnBh4mSc+vde+EOkU6dC9BDuu/bfGb+UyUlqM6t8znFCTPQSuai/ZcfFg0gu79h+bVW2O0w==} - engines: {node: '>=10'} - set-cookie-parser@2.7.1: resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} @@ -3147,9 +2825,6 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} - solid-js@1.9.7: - resolution: {integrity: sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==} - sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} @@ -3258,20 +2933,6 @@ packages: resolution: {integrity: sha512-2pR2ubZSV64f/vqm9eLPz/KOvR9Dm+Co/5ChLgeHl0yEDRc6h5hXHoxEQH8Y5Ljycozd3p1k5TTSVdzYGkPvLw==} engines: {node: ^14.18.0 || >=16.0.0} - tailwind-merge@3.3.0: - resolution: {integrity: sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==} - - tailwindcss@4.1.7: - resolution: {integrity: sha512-kr1o/ErIdNhTz8uzAYL7TpaUuzKIE6QPQ4qmSdxnoX/lo+5wmUHQA6h3L5yIqEImSRnAAURDirLu/BgiXGPAhg==} - - tapable@2.2.1: - resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} - engines: {node: '>=6'} - - tar@7.4.3: - resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} - engines: {node: '>=18'} - test-exclude@7.0.1: resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} engines: {node: '>=18'} @@ -3279,12 +2940,6 @@ packages: thread-stream@3.1.0: resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} - tiny-invariant@1.3.3: - resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - - tiny-warning@1.0.3: - resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} - tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -3430,10 +3085,6 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - unplugin@2.3.4: - resolution: {integrity: sha512-m4PjxTurwpWfpMomp8AptjD5yj8qEZN5uQjjGM3TAs9MWWD2tXSSNNj6jGR2FoVGod4293ytyV6SwBbertfyJg==} - engines: {node: '>=18.12.0'} - update-browserslist-db@1.1.3: resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} hasBin: true @@ -3443,11 +3094,6 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - use-sync-external-store@1.5.0: - resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -3548,9 +3194,6 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} - webpack-virtual-modules@0.6.2: - resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} - which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -3618,10 +3261,6 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - yallist@5.0.0: - resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} - engines: {node: '>=18'} - yaml@2.8.1: resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} engines: {node: '>= 14.6'} @@ -3722,16 +3361,6 @@ snapshots: dependencies: '@babel/types': 7.27.1 - '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.27.1)': - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.27.1)': - dependencies: - '@babel/core': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 @@ -4004,11 +3633,6 @@ snapshots: '@fastify/forwarded': 3.0.0 ipaddr.js: 2.2.0 - '@hookform/resolvers@5.0.1(react-hook-form@7.56.4(react@19.1.0))': - dependencies: - '@standard-schema/utils': 0.3.0 - react-hook-form: 7.56.4(react@19.1.0) - '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -4031,10 +3655,6 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@isaacs/fs-minipass@4.0.1': - dependencies: - minipass: 7.1.2 - '@istanbuljs/schema@0.1.3': {} '@jridgewell/gen-mapping@0.3.8': @@ -4288,180 +3908,6 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@standard-schema/utils@0.3.0': {} - - '@tailwindcss/node@4.1.7': - dependencies: - '@ampproject/remapping': 2.3.0 - enhanced-resolve: 5.18.1 - jiti: 2.4.2 - lightningcss: 1.30.1 - magic-string: 0.30.17 - source-map-js: 1.2.1 - tailwindcss: 4.1.7 - - '@tailwindcss/oxide-android-arm64@4.1.7': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.1.7': - optional: true - - '@tailwindcss/oxide-darwin-x64@4.1.7': - optional: true - - '@tailwindcss/oxide-freebsd-x64@4.1.7': - optional: true - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.7': - optional: true - - '@tailwindcss/oxide-linux-arm64-gnu@4.1.7': - optional: true - - '@tailwindcss/oxide-linux-arm64-musl@4.1.7': - optional: true - - '@tailwindcss/oxide-linux-x64-gnu@4.1.7': - optional: true - - '@tailwindcss/oxide-linux-x64-musl@4.1.7': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.1.7': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.1.7': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.1.7': - optional: true - - '@tailwindcss/oxide@4.1.7': - dependencies: - detect-libc: 2.0.4 - tar: 7.4.3 - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.1.7 - '@tailwindcss/oxide-darwin-arm64': 4.1.7 - '@tailwindcss/oxide-darwin-x64': 4.1.7 - '@tailwindcss/oxide-freebsd-x64': 4.1.7 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.7 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.7 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.7 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.7 - '@tailwindcss/oxide-linux-x64-musl': 4.1.7 - '@tailwindcss/oxide-wasm32-wasi': 4.1.7 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.7 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.7 - - '@tailwindcss/vite@4.1.7(vite@6.3.5(@types/node@22.15.18)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.1))': - dependencies: - '@tailwindcss/node': 4.1.7 - '@tailwindcss/oxide': 4.1.7 - tailwindcss: 4.1.7 - vite: 6.3.5(@types/node@22.15.18)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.1) - - '@tanstack/eslint-plugin-router@1.115.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)': - dependencies: - '@typescript-eslint/utils': 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) - eslint: 9.27.0(jiti@2.4.2) - transitivePeerDependencies: - - supports-color - - typescript - - '@tanstack/history@1.115.0': {} - - '@tanstack/react-router-devtools@1.120.5(@tanstack/react-router@1.120.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@tanstack/router-core@1.120.5)(csstype@3.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tiny-invariant@1.3.3)': - dependencies: - '@tanstack/react-router': 1.120.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@tanstack/router-devtools-core': 1.120.5(@tanstack/router-core@1.120.5)(csstype@3.1.3)(solid-js@1.9.7)(tiny-invariant@1.3.3) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - solid-js: 1.9.7 - transitivePeerDependencies: - - '@tanstack/router-core' - - csstype - - tiny-invariant - - '@tanstack/react-router@1.120.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@tanstack/history': 1.115.0 - '@tanstack/react-store': 0.7.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@tanstack/router-core': 1.120.5 - jsesc: 3.1.0 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - tiny-invariant: 1.3.3 - tiny-warning: 1.0.3 - - '@tanstack/react-store@0.7.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@tanstack/store': 0.7.0 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - use-sync-external-store: 1.5.0(react@19.1.0) - - '@tanstack/router-core@1.120.5': - dependencies: - '@tanstack/history': 1.115.0 - '@tanstack/store': 0.7.0 - tiny-invariant: 1.3.3 - - '@tanstack/router-devtools-core@1.120.5(@tanstack/router-core@1.120.5)(csstype@3.1.3)(solid-js@1.9.7)(tiny-invariant@1.3.3)': - dependencies: - '@tanstack/router-core': 1.120.5 - clsx: 2.1.1 - goober: 2.1.16(csstype@3.1.3) - solid-js: 1.9.7 - tiny-invariant: 1.3.3 - optionalDependencies: - csstype: 3.1.3 - - '@tanstack/router-generator@1.120.5(@tanstack/react-router@1.120.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0))': - dependencies: - '@tanstack/virtual-file-routes': 1.115.0 - prettier: 3.5.3 - tsx: 4.19.4 - zod: 3.24.4 - optionalDependencies: - '@tanstack/react-router': 1.120.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - - '@tanstack/router-plugin@1.120.5(@tanstack/react-router@1.120.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.3.5(@types/node@22.15.18)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.1))': - dependencies: - '@babel/core': 7.27.1 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.27.1) - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.27.1) - '@babel/template': 7.27.2 - '@babel/traverse': 7.27.1 - '@babel/types': 7.27.1 - '@tanstack/router-core': 1.120.5 - '@tanstack/router-generator': 1.120.5(@tanstack/react-router@1.120.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) - '@tanstack/router-utils': 1.115.0 - '@tanstack/virtual-file-routes': 1.115.0 - '@types/babel__core': 7.20.5 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.20.7 - babel-dead-code-elimination: 1.0.10 - chokidar: 3.6.0 - unplugin: 2.3.4 - zod: 3.24.4 - optionalDependencies: - '@tanstack/react-router': 1.120.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - vite: 6.3.5(@types/node@22.15.18)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.1) - transitivePeerDependencies: - - supports-color - - '@tanstack/router-utils@1.115.0': - dependencies: - '@babel/generator': 7.27.1 - '@babel/parser': 7.27.2 - ansis: 3.17.0 - diff: 7.0.0 - - '@tanstack/store@0.7.0': {} - - '@tanstack/virtual-file-routes@1.115.0': {} - '@tsconfig/node10@1.0.11': {} '@tsconfig/node12@1.0.11': {} @@ -4802,8 +4248,6 @@ snapshots: ansi-styles@6.2.1: {} - ansis@3.17.0: {} - anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -4910,15 +4354,6 @@ snapshots: '@fastify/error': 4.1.0 fastq: 1.19.1 - babel-dead-code-elimination@1.0.10: - dependencies: - '@babel/core': 7.27.1 - '@babel/parser': 7.27.2 - '@babel/traverse': 7.27.1 - '@babel/types': 7.27.1 - transitivePeerDependencies: - - supports-color - balanced-match@1.0.2: {} bcryptjs@3.0.2: {} @@ -4999,8 +4434,6 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - chownr@3.0.0: {} - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -5010,8 +4443,6 @@ snapshots: slice-ansi: 7.1.2 string-width: 8.1.0 - clsx@2.1.1: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -5044,8 +4475,6 @@ snapshots: csstype@3.1.3: {} - daisyui@5.0.35: {} - data-uri-to-buffer@4.0.1: {} data-view-buffer@1.0.2: @@ -5098,12 +4527,11 @@ snapshots: detect-libc@2.0.2: {} - detect-libc@2.0.4: {} + detect-libc@2.0.4: + optional: true diff@4.0.2: {} - diff@7.0.0: {} - doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -5152,11 +4580,6 @@ snapshots: emoji-regex@9.2.2: {} - enhanced-resolve@5.18.1: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.2.1 - entities@4.5.0: {} environment@1.1.0: {} @@ -5741,10 +5164,6 @@ snapshots: globrex@0.1.2: {} - goober@2.1.16(csstype@3.1.3): - dependencies: - csstype: 3.1.3 - gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -5962,7 +5381,8 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 - jiti@2.4.2: {} + jiti@2.4.2: + optional: true jju@1.4.0: {} @@ -6085,6 +5505,7 @@ snapshots: lightningcss-linux-x64-musl: 1.30.1 lightningcss-win32-arm64-msvc: 1.30.1 lightningcss-win32-x64-msvc: 1.30.1 + optional: true lint-staged@16.2.6: dependencies: @@ -6143,10 +5564,6 @@ snapshots: dependencies: yallist: 4.0.0 - lucide-react@0.507.0(react@19.1.0): - dependencies: - react: 19.1.0 - magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 @@ -6192,14 +5609,8 @@ snapshots: minipass@7.1.2: {} - minizlib@3.0.2: - dependencies: - minipass: 7.1.2 - mkdirp@1.0.4: {} - mkdirp@3.0.1: {} - mlly@1.7.4: dependencies: acorn: 8.14.1 @@ -6211,16 +5622,6 @@ snapshots: dependencies: obliterator: 2.0.5 - mobx-react-lite@4.1.0(mobx@6.13.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): - dependencies: - mobx: 6.13.7 - react: 19.1.0 - use-sync-external-store: 1.5.0(react@19.1.0) - optionalDependencies: - react-dom: 19.1.0(react@19.1.0) - - mobx@6.13.7: {} - mrmime@2.0.1: {} ms@2.1.3: {} @@ -6430,10 +5831,6 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 - react-hook-form@7.56.4(react@19.1.0): - dependencies: - react: 19.1.0 - react-is@16.13.1: {} react-refresh@0.17.0: {} @@ -6570,12 +5967,6 @@ snapshots: semver@7.7.2: {} - seroval-plugins@1.3.1(seroval@1.3.1): - dependencies: - seroval: 1.3.1 - - seroval@1.3.1: {} - set-cookie-parser@2.7.1: {} set-function-length@1.2.2: @@ -6649,12 +6040,6 @@ snapshots: ansi-styles: 6.2.1 is-fullwidth-code-point: 5.1.0 - solid-js@1.9.7: - dependencies: - csstype: 3.1.3 - seroval: 1.3.1 - seroval-plugins: 1.3.1(seroval@1.3.1) - sonic-boom@4.2.0: dependencies: atomic-sleep: 1.0.0 @@ -6785,21 +6170,6 @@ snapshots: dependencies: '@pkgr/core': 0.2.4 - tailwind-merge@3.3.0: {} - - tailwindcss@4.1.7: {} - - tapable@2.2.1: {} - - tar@7.4.3: - dependencies: - '@isaacs/fs-minipass': 4.0.1 - chownr: 3.0.0 - minipass: 7.1.2 - minizlib: 3.0.2 - mkdirp: 3.0.1 - yallist: 5.0.0 - test-exclude@7.0.1: dependencies: '@istanbuljs/schema': 0.1.3 @@ -6810,10 +6180,6 @@ snapshots: dependencies: real-require: 0.2.0 - tiny-invariant@1.3.3: {} - - tiny-warning@1.0.3: {} - tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -6974,12 +6340,6 @@ snapshots: universalify@2.0.1: {} - unplugin@2.3.4: - dependencies: - acorn: 8.14.1 - picomatch: 4.0.2 - webpack-virtual-modules: 0.6.2 - update-browserslist-db@1.1.3(browserslist@4.24.5): dependencies: browserslist: 4.24.5 @@ -6990,10 +6350,6 @@ snapshots: dependencies: punycode: 2.3.1 - use-sync-external-store@1.5.0(react@19.1.0): - dependencies: - react: 19.1.0 - v8-compile-cache-lib@3.0.1: {} vite-node@3.2.4(@types/node@22.15.18)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.1): @@ -7109,8 +6465,6 @@ snapshots: web-streams-polyfill@3.3.3: {} - webpack-virtual-modules@0.6.2: {} - which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -7191,8 +6545,6 @@ snapshots: yallist@4.0.0: {} - yallist@5.0.0: {} - yaml@2.8.1: {} yn@3.1.1: {} diff --git a/restAPI/transactions/create.bru b/restAPI/transactions/create.bru index e2825014..dfa7d422 100644 --- a/restAPI/transactions/create.bru +++ b/restAPI/transactions/create.bru @@ -20,18 +20,21 @@ auth:bearer { body:json { { + "commodityId": "{{commodityId}}", "description": "some transaction", - "entries": [ - [ - { - "accountId": "{{accountId1}}", - "amount": "-100" - }, - { - "accountId": "{{accountId2}}", - "amount": "100" - } - ] + "operations": [ + { + "accountId": "{{accountId1}}", + "amount": "-100", + "description": "Transfer from checking", + "value": "-100" + }, + { + "accountId": "{{accountId2}}", + "amount": "100", + "description": "Transfer to savings", + "value": "100" + } ], "postingDate": "2025-11-07", "transactionDate": "2025-11-07" diff --git a/restAPI/transactions/createMultyCurrencyTransaction.bru b/restAPI/transactions/createMultyCurrencyTransaction.bru index 5fd510a9..9553ac48 100644 --- a/restAPI/transactions/createMultyCurrencyTransaction.bru +++ b/restAPI/transactions/createMultyCurrencyTransaction.bru @@ -20,18 +20,21 @@ auth:bearer { body:json { { + "commodityId": "{{commodityId}}", "description": "MultiCurrency Transaction", - "entries": [ - [ - { - "accountId": "3141d189-4b2e-4af4-8b9c-f2ff0d7e0d72", - "amount": "-100" - }, - { - "accountId": "{{accountId2}}", - "amount": "100" - } - ] + "operations": [ + { + "accountId": "{{rubAccountId}}", + "amount": "-1000", + "description": "Spend RUB", + "value": "-10" + }, + { + "accountId": "{{usdAccountId}}", + "amount": "10", + "description": "Receive USD", + "value": "10" + } ], "postingDate": "2025-11-07", "transactionDate": "2025-11-07" diff --git a/restAPI/transactions/createWithAccountNonExistError.bru b/restAPI/transactions/createWithAccountNonExistError.bru index 774c517b..dddcd9f9 100644 --- a/restAPI/transactions/createWithAccountNonExistError.bru +++ b/restAPI/transactions/createWithAccountNonExistError.bru @@ -20,22 +20,20 @@ auth:bearer { body:json { { + "commodityId": "{{commodityId}}", "description": "some transaction", - "entries": [ + "operations": [ { - "description": "non-existent account", - "operations": [ - { - "accountId": "{{accountId1}}", - "amount": "-100", - "description": "Transfer from checking" - }, - { - "accountId": "{{accountId2}}", - "amount": "100", - "description": "Transfer to savings" - } - ] + "accountId": "{{nonExistentAccountId}}", + "amount": "-100", + "description": "Transfer from missing account", + "value": "-100" + }, + { + "accountId": "{{accountId2}}", + "amount": "100", + "description": "Transfer to savings", + "value": "100" } ], "postingDate": "2025-11-07", diff --git a/restAPI/transactions/update.bru b/restAPI/transactions/update.bru index 28b2d59a..4d0a9341 100644 --- a/restAPI/transactions/update.bru +++ b/restAPI/transactions/update.bru @@ -4,8 +4,8 @@ meta { seq: 5 } -post { - url: {{baseUrl}}/transactions +put { + url: {{baseUrl}}/transactions/{{transactionId}} body: json auth: bearer } @@ -20,20 +20,29 @@ auth:bearer { body:json { { - "description": "some transaction", - "entries": [ - [ + "description": "updated transaction", + "operations": { + "create": [], + "delete": [], + "update": [ { + "id": "{{operationId1}}", "accountId": "{{accountId1}}", - "amount": "-100" + "amount": "-100", + "description": "Updated transfer from checking", + "value": "-100" }, { + "id": "{{operationId2}}", "accountId": "{{accountId2}}", - "amount": "100" + "amount": "100", + "description": "Updated transfer to savings", + "value": "100" } ] - ], + }, "postingDate": "2025-11-07", - "transactionDate": "2025-11-07" + "transactionDate": "2025-11-07", + "version": {{transactionVersion}} } }