From 83dfaa00fda0055416ba53411acb8c1fc8c6e918 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 19 Dec 2024 19:06:03 +0200 Subject: [PATCH] refactor: migrate item categories module to nestjs --- .../modules/Accounts/Account.transformer.ts | 20 +- .../src/modules/Accounts/Accounts.types.ts | 12 +- .../Accounts/AccountsApplication.service.ts | 4 +- .../Accounts/ActivateAccount.service.ts | 6 +- .../CommandAccountValidators.service.ts | 24 +- .../modules/Accounts/CreateAccount.service.ts | 8 +- .../modules/Accounts/DeleteAccount.service.ts | 6 +- .../modules/Accounts/EditAccount.service.ts | 10 +- .../modules/Accounts/GetAccount.service.ts | 6 +- .../GetAccountTransactions.service.ts | 6 +- .../modules/Accounts/GetAccounts.service.ts | 4 +- .../modules/Accounts/models/Account.model.ts | 6 +- .../repositories/Account.repository.ts | 6 +- .../server-nest/src/modules/App/App.module.ts | 4 +- .../modules/Expenses/Expenses.controller.ts | 68 ++++ .../src/modules/Expenses/Expenses.module.ts | 22 ++ .../Expenses/ExpensesApplication.service.ts | 77 +++++ .../modules/Expenses/ExpensesExportable.ts | 34 ++ .../modules/Expenses/ExpensesImportable.ts | 46 +++ .../CommandExpenseValidator.service.ts | 109 +++++++ .../commands/CreateExpense.service.ts | 113 +++++++ .../commands/DeleteExpense.service.ts | 70 ++++ .../Expenses/commands/EditExpense.service.ts | 139 ++++++++ .../commands/ExpenseDTOTransformer.ts | 117 +++++++ .../commands/PublishExpense.service.ts | 67 ++++ .../src/modules/Expenses/constants.ts | 89 ++++++ .../Expenses/interfaces/Expenses.interface.ts | 117 +++++++ .../modules/Expenses/models/Expense.model.ts | 302 ++++++++++++++++++ .../Expenses/models/ExpenseCategory.model.ts | 47 +++ .../Expenses/queries/Expense.transformer.ts | 103 ++++++ .../queries/ExpenseCategory.transformer.ts | 25 ++ .../Expenses/queries/GetExpense.service.ts | 32 ++ .../Expenses/queries/GetExpenses.service.ts | 81 +++++ .../modules/Expenses/subscribers/ExpenseGL.ts | 113 +++++++ .../subscribers/ExpenseGLEntriesService.ts | 45 +++ .../subscribers/ExpenseGLEntriesStorage.ts | 72 +++++ .../subscribers/ExpenseGLEntriesSubscriber.ts | 117 +++++++ .../ItemCategoriesExportable.ts | 29 ++ .../ItemCategoriesImportable.ts | 38 +++ .../ItemCategory.application.ts | 64 ++++ .../ItemCategories/ItemCategory.controller.ts | 47 +++ .../ItemCategories/ItemCategory.interfaces.ts | 37 +++ .../ItemCategories/ItemCategory.module.ts | 21 ++ .../CommandItemCategoryValidator.service.ts | 93 ++++++ .../commands/CreateItemCategory.service.ts | 81 +++++ .../commands/DeleteItemCategory.service.ts | 69 ++++ .../commands/EditItemCategory.service.ts | 87 +++++ .../src/modules/ItemCategories/constants.ts | 35 ++ .../models/ItemCategory.model.ts | 63 ++++ .../queries/GetItemCategory.service.ts | 24 ++ .../modules/Items/ItemValidator.service.ts | 4 +- .../Tenancy/TenancyModels/Tenancy.module.ts | 4 +- .../src/modules/Transformer/Transformer.ts | 2 +- .../src/utils/associate-item-entries-index.ts | 15 + packages/server-nest/tsconfig.build.json | 9 +- 55 files changed, 2780 insertions(+), 69 deletions(-) create mode 100644 packages/server-nest/src/modules/Expenses/Expenses.controller.ts create mode 100644 packages/server-nest/src/modules/Expenses/Expenses.module.ts create mode 100644 packages/server-nest/src/modules/Expenses/ExpensesApplication.service.ts create mode 100644 packages/server-nest/src/modules/Expenses/ExpensesExportable.ts create mode 100644 packages/server-nest/src/modules/Expenses/ExpensesImportable.ts create mode 100644 packages/server-nest/src/modules/Expenses/commands/CommandExpenseValidator.service.ts create mode 100644 packages/server-nest/src/modules/Expenses/commands/CreateExpense.service.ts create mode 100644 packages/server-nest/src/modules/Expenses/commands/DeleteExpense.service.ts create mode 100644 packages/server-nest/src/modules/Expenses/commands/EditExpense.service.ts create mode 100644 packages/server-nest/src/modules/Expenses/commands/ExpenseDTOTransformer.ts create mode 100644 packages/server-nest/src/modules/Expenses/commands/PublishExpense.service.ts create mode 100644 packages/server-nest/src/modules/Expenses/constants.ts create mode 100644 packages/server-nest/src/modules/Expenses/interfaces/Expenses.interface.ts create mode 100644 packages/server-nest/src/modules/Expenses/models/Expense.model.ts create mode 100644 packages/server-nest/src/modules/Expenses/models/ExpenseCategory.model.ts create mode 100644 packages/server-nest/src/modules/Expenses/queries/Expense.transformer.ts create mode 100644 packages/server-nest/src/modules/Expenses/queries/ExpenseCategory.transformer.ts create mode 100644 packages/server-nest/src/modules/Expenses/queries/GetExpense.service.ts create mode 100644 packages/server-nest/src/modules/Expenses/queries/GetExpenses.service.ts create mode 100644 packages/server-nest/src/modules/Expenses/subscribers/ExpenseGL.ts create mode 100644 packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesService.ts create mode 100644 packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesStorage.ts create mode 100644 packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesSubscriber.ts create mode 100644 packages/server-nest/src/modules/ItemCategories/ItemCategoriesExportable.ts create mode 100644 packages/server-nest/src/modules/ItemCategories/ItemCategoriesImportable.ts create mode 100644 packages/server-nest/src/modules/ItemCategories/ItemCategory.application.ts create mode 100644 packages/server-nest/src/modules/ItemCategories/ItemCategory.controller.ts create mode 100644 packages/server-nest/src/modules/ItemCategories/ItemCategory.interfaces.ts create mode 100644 packages/server-nest/src/modules/ItemCategories/ItemCategory.module.ts create mode 100644 packages/server-nest/src/modules/ItemCategories/commands/CommandItemCategoryValidator.service.ts create mode 100644 packages/server-nest/src/modules/ItemCategories/commands/CreateItemCategory.service.ts create mode 100644 packages/server-nest/src/modules/ItemCategories/commands/DeleteItemCategory.service.ts create mode 100644 packages/server-nest/src/modules/ItemCategories/commands/EditItemCategory.service.ts create mode 100644 packages/server-nest/src/modules/ItemCategories/constants.ts create mode 100644 packages/server-nest/src/modules/ItemCategories/models/ItemCategory.model.ts create mode 100644 packages/server-nest/src/modules/ItemCategories/queries/GetItemCategory.service.ts create mode 100644 packages/server-nest/src/utils/associate-item-entries-index.ts diff --git a/packages/server-nest/src/modules/Accounts/Account.transformer.ts b/packages/server-nest/src/modules/Accounts/Account.transformer.ts index 560d19f82..b65dd82a5 100644 --- a/packages/server-nest/src/modules/Accounts/Account.transformer.ts +++ b/packages/server-nest/src/modules/Accounts/Account.transformer.ts @@ -1,5 +1,5 @@ import { Transformer } from '../Transformer/Transformer'; -import { AccountModel } from './models/Account.model'; +import { Account } from './models/Account.model'; import { flatToNestedArray } from '@/utils/flat-to-nested-array'; import { assocDepthLevelToObjectTree } from '@/utils/assoc-depth-level-to-object-tree'; import { nestedArrayToFlatten } from '@/utils/nested-array-to-flatten'; @@ -35,7 +35,7 @@ export class AccountTransformer extends Transformer { * @param {IAccount} account - * @returns {string} */ - public flattenName = (account: AccountModel): string => { + public flattenName = (account: Account): string => { const parentDependantsIds = this.options.accountsGraph.dependantsOf( account.id, ); @@ -51,7 +51,7 @@ export class AccountTransformer extends Transformer { * @param {IAccount} invoice * @returns {string} */ - protected formattedAmount = (account: AccountModel): string => { + protected formattedAmount = (account: Account): string => { return this.formatNumber(account.amount, { currencyCode: account.currencyCode, }); @@ -59,10 +59,10 @@ export class AccountTransformer extends Transformer { /** * Retrieves the formatted bank balance. - * @param {AccountModel} account + * @param {Account} account * @returns {string} */ - protected bankBalanceFormatted = (account: AccountModel): string => { + protected bankBalanceFormatted = (account: Account): string => { return this.formatNumber(account.bankBalance, { currencyCode: account.currencyCode, }); @@ -73,7 +73,7 @@ export class AccountTransformer extends Transformer { * @param {IAccount} account * @returns {string} */ - protected lastFeedsUpdatedAtFormatted = (account: AccountModel): string => { + protected lastFeedsUpdatedAtFormatted = (account: Account): string => { return account.lastFeedsUpdatedAt ? this.formatDate(account.lastFeedsUpdatedAt) : ''; @@ -84,7 +84,7 @@ export class AccountTransformer extends Transformer { * @param account * @returns {boolean} */ - protected isFeedsPaused = (account: AccountModel): boolean => { + protected isFeedsPaused = (account: Account): boolean => { // return account.plaidItem?.isPaused || false; return false; @@ -94,7 +94,7 @@ export class AccountTransformer extends Transformer { * Retrieves formatted account type label. * @returns {string} */ - protected accountTypeLabel = (account: AccountModel): string => { + protected accountTypeLabel = (account: Account): string => { return this.context.i18n.t(account.accountTypeLabel); }; @@ -102,7 +102,7 @@ export class AccountTransformer extends Transformer { * Retrieves formatted account normal. * @returns {string} */ - protected accountNormalFormatted = (account: AccountModel): string => { + protected accountNormalFormatted = (account: Account): string => { return this.context.i18n.t(account.accountNormalFormatted); }; @@ -111,7 +111,7 @@ export class AccountTransformer extends Transformer { * @param {IAccount[]} * @returns {IAccount[]} */ - protected postCollectionTransform = (accounts: AccountModel[]) => { + protected postCollectionTransform = (accounts: Account[]) => { // Transfom the flatten to accounts tree. const transformed = flatToNestedArray(accounts, { id: 'id', diff --git a/packages/server-nest/src/modules/Accounts/Accounts.types.ts b/packages/server-nest/src/modules/Accounts/Accounts.types.ts index 1dfd0bc95..66a4a1ebe 100644 --- a/packages/server-nest/src/modules/Accounts/Accounts.types.ts +++ b/packages/server-nest/src/modules/Accounts/Accounts.types.ts @@ -1,5 +1,5 @@ import { Knex } from 'knex'; -import { AccountModel } from './models/Account.model'; +import { Account } from './models/Account.model'; // import { IDynamicListFilterDTO } from '@/interfaces/DynamicFilter'; export enum AccountNormal { @@ -42,26 +42,26 @@ export interface IAccountEventCreatingPayload { trx: Knex.Transaction; } export interface IAccountEventCreatedPayload { - account: AccountModel; + account: Account; accountId: number; trx: Knex.Transaction; } export interface IAccountEventEditedPayload { - account: AccountModel; - oldAccount: AccountModel; + account: Account; + oldAccount: Account; trx: Knex.Transaction; } export interface IAccountEventDeletedPayload { accountId: number; - oldAccount: AccountModel; + oldAccount: Account; trx: Knex.Transaction; } export interface IAccountEventDeletePayload { trx: Knex.Transaction; - oldAccount: AccountModel; + oldAccount: Account; } export interface IAccountEventActivatedPayload { diff --git a/packages/server-nest/src/modules/Accounts/AccountsApplication.service.ts b/packages/server-nest/src/modules/Accounts/AccountsApplication.service.ts index 9510196dc..5fa4b31f3 100644 --- a/packages/server-nest/src/modules/Accounts/AccountsApplication.service.ts +++ b/packages/server-nest/src/modules/Accounts/AccountsApplication.service.ts @@ -4,7 +4,7 @@ import { CreateAccountService } from './CreateAccount.service'; import { DeleteAccount } from './DeleteAccount.service'; import { EditAccount } from './EditAccount.service'; import { CreateAccountDTO } from './CreateAccount.dto'; -import { AccountModel } from './models/Account.model'; +import { Account } from './models/Account.model'; import { EditAccountDTO } from './EditAccount.dto'; import { GetAccount } from './GetAccount.service'; import { ActivateAccount } from './ActivateAccount.service'; @@ -37,7 +37,7 @@ export class AccountsApplication { public createAccount = ( accountDTO: CreateAccountDTO, trx?: Knex.Transaction, - ): Promise => { + ): Promise => { return this.createAccountService.createAccount(accountDTO, trx); }; diff --git a/packages/server-nest/src/modules/Accounts/ActivateAccount.service.ts b/packages/server-nest/src/modules/Accounts/ActivateAccount.service.ts index 5fb088c9f..a18b98255 100644 --- a/packages/server-nest/src/modules/Accounts/ActivateAccount.service.ts +++ b/packages/server-nest/src/modules/Accounts/ActivateAccount.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Knex } from 'knex'; import { IAccountEventActivatedPayload } from './Accounts.types'; -import { AccountModel } from './models/Account.model'; +import { Account } from './models/Account.model'; import { AccountRepository } from './repositories/Account.repository'; import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; @@ -13,8 +13,8 @@ export class ActivateAccount { private readonly eventEmitter: EventEmitter2, private readonly uow: UnitOfWork, - @Inject(AccountModel.name) - private readonly accountModel: typeof AccountModel, + @Inject(Account.name) + private readonly accountModel: typeof Account, private readonly accountRepository: AccountRepository, ) {} diff --git a/packages/server-nest/src/modules/Accounts/CommandAccountValidators.service.ts b/packages/server-nest/src/modules/Accounts/CommandAccountValidators.service.ts index 22ed4933f..4d08ce8d4 100644 --- a/packages/server-nest/src/modules/Accounts/CommandAccountValidators.service.ts +++ b/packages/server-nest/src/modules/Accounts/CommandAccountValidators.service.ts @@ -4,7 +4,7 @@ import { Inject, Injectable, Scope } from '@nestjs/common'; // import AccountTypesUtils from '@/lib/AccountTypes'; import { ServiceError } from '../Items/ServiceError'; import { ERRORS, MAX_ACCOUNTS_CHART_DEPTH } from './constants'; -import { AccountModel } from './models/Account.model'; +import { Account } from './models/Account.model'; import { AccountRepository } from './repositories/Account.repository'; import { AccountTypesUtils } from './utils/AccountType.utils'; import { CreateAccountDTO } from './CreateAccount.dto'; @@ -13,16 +13,16 @@ import { EditAccountDTO } from './EditAccount.dto'; @Injectable({ scope: Scope.REQUEST }) export class CommandAccountValidators { constructor( - @Inject(AccountModel.name) - private readonly accountModel: typeof AccountModel, + @Inject(Account.name) + private readonly accountModel: typeof Account, private readonly accountRepository: AccountRepository, ) {} /** * Throws error if the account was prefined. - * @param {AccountModel} account + * @param {Account} account */ - public throwErrorIfAccountPredefined(account: AccountModel) { + public throwErrorIfAccountPredefined(account: Account) { if (account.predefined) { throw new ServiceError(ERRORS.ACCOUNT_PREDEFINED); } @@ -31,12 +31,12 @@ export class CommandAccountValidators { /** * Diff account type between new and old account, throw service error * if they have different account type. - * @param {AccountModel|CreateAccountDTO|EditAccountDTO} oldAccount - * @param {AccountModel|CreateAccountDTO|EditAccountDTO} newAccount + * @param {Account|CreateAccountDTO|EditAccountDTO} oldAccount + * @param {Account|CreateAccountDTO|EditAccountDTO} newAccount */ public async isAccountTypeChangedOrThrowError( - oldAccount: AccountModel | CreateAccountDTO | EditAccountDTO, - newAccount: AccountModel | CreateAccountDTO | EditAccountDTO, + oldAccount: Account | CreateAccountDTO | EditAccountDTO, + newAccount: Account | CreateAccountDTO | EditAccountDTO, ) { if (oldAccount.accountType !== newAccount.accountType) { throw new ServiceError(ERRORS.ACCOUNT_TYPE_NOT_ALLOWED_TO_CHANGE); @@ -155,13 +155,13 @@ export class CommandAccountValidators { * Validates the account DTO currency code whether equals the currency code of * parent account. * @param {CreateAccountDTO | EditAccountDTO} accountDTO - * @param {AccountModel} parentAccount + * @param {Account} parentAccount * @param {string} baseCurrency - * @throws {ServiceError(ERRORS.ACCOUNT_CURRENCY_NOT_SAME_PARENT_ACCOUNT)} */ public validateCurrentSameParentAccount = ( accountDTO: CreateAccountDTO | EditAccountDTO, - parentAccount: AccountModel, + parentAccount: Account, baseCurrency: string, ) => { // If the account DTO currency not assigned and the parent account has no base currency. @@ -187,7 +187,7 @@ export class CommandAccountValidators { */ public throwErrorIfParentHasDiffType( accountDTO: CreateAccountDTO | EditAccountDTO, - parentAccount: AccountModel, + parentAccount: Account, ) { if (accountDTO.accountType !== parentAccount.accountType) { throw new ServiceError(ERRORS.PARENT_ACCOUNT_HAS_DIFFERENT_TYPE); diff --git a/packages/server-nest/src/modules/Accounts/CreateAccount.service.ts b/packages/server-nest/src/modules/Accounts/CreateAccount.service.ts index 836570819..5b6d8f33f 100644 --- a/packages/server-nest/src/modules/Accounts/CreateAccount.service.ts +++ b/packages/server-nest/src/modules/Accounts/CreateAccount.service.ts @@ -11,7 +11,7 @@ import { CreateAccountParams, } from './Accounts.types'; import { CommandAccountValidators } from './CommandAccountValidators.service'; -import { AccountModel } from './models/Account.model'; +import { Account } from './models/Account.model'; import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service'; import { TenancyContext } from '../Tenancy/TenancyContext.service'; import { events } from '@/common/events/events'; @@ -20,8 +20,8 @@ import { CreateAccountDTO } from './CreateAccount.dto'; @Injectable() export class CreateAccountService { constructor( - @Inject(AccountModel.name) - private readonly accountModel: typeof AccountModel, + @Inject(Account.name) + private readonly accountModel: typeof Account, private readonly eventEmitter: EventEmitter2, private readonly uow: UnitOfWork, private readonly validator: CommandAccountValidators, @@ -102,7 +102,7 @@ export class CreateAccountService { accountDTO: CreateAccountDTO, trx?: Knex.Transaction, params: CreateAccountParams = { ignoreUniqueName: false }, - ): Promise => { + ): Promise => { // Retrieves the given tenant metadata. const tenant = await this.tenancyContext.getTenant(true); diff --git a/packages/server-nest/src/modules/Accounts/DeleteAccount.service.ts b/packages/server-nest/src/modules/Accounts/DeleteAccount.service.ts index c89731b1c..2403bb3df 100644 --- a/packages/server-nest/src/modules/Accounts/DeleteAccount.service.ts +++ b/packages/server-nest/src/modules/Accounts/DeleteAccount.service.ts @@ -2,7 +2,7 @@ import { Knex } from 'knex'; import { Inject, Injectable } from '@nestjs/common'; // import { IAccountEventDeletedPayload } from '@/interfaces'; import { CommandAccountValidators } from './CommandAccountValidators.service'; -import { AccountModel } from './models/Account.model'; +import { Account } from './models/Account.model'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service'; import { events } from '@/common/events/events'; @@ -11,7 +11,7 @@ import { IAccountEventDeletedPayload } from './Accounts.types'; @Injectable() export class DeleteAccount { constructor( - @Inject(AccountModel.name) private accountModel: typeof AccountModel, + @Inject(Account.name) private accountModel: typeof Account, private eventEmitter: EventEmitter2, private uow: UnitOfWork, private validator: CommandAccountValidators, @@ -21,7 +21,7 @@ export class DeleteAccount { * Authorize account delete. * @param {number} accountId - Account id. */ - private authorize = async (accountId: number, oldAccount: AccountModel) => { + private authorize = async (accountId: number, oldAccount: Account) => { // Throw error if the account was predefined. this.validator.throwErrorIfAccountPredefined(oldAccount); }; diff --git a/packages/server-nest/src/modules/Accounts/EditAccount.service.ts b/packages/server-nest/src/modules/Accounts/EditAccount.service.ts index 7586bfd6c..3b5386381 100644 --- a/packages/server-nest/src/modules/Accounts/EditAccount.service.ts +++ b/packages/server-nest/src/modules/Accounts/EditAccount.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Knex } from 'knex'; import { CommandAccountValidators } from './CommandAccountValidators.service'; -import { AccountModel } from './models/Account.model'; +import { Account } from './models/Account.model'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service'; import { events } from '@/common/events/events'; @@ -14,8 +14,8 @@ export class EditAccount { private readonly uow: UnitOfWork, private readonly validator: CommandAccountValidators, - @Inject(AccountModel.name) - private readonly accountModel: typeof AccountModel, + @Inject(Account.name) + private readonly accountModel: typeof Account, ) {} /** @@ -27,7 +27,7 @@ export class EditAccount { private authorize = async ( accountId: number, accountDTO: EditAccountDTO, - oldAccount: AccountModel, + oldAccount: Account, ) => { // Validate account name uniquiness. await this.validator.validateAccountNameUniquiness( @@ -64,7 +64,7 @@ export class EditAccount { public async editAccount( accountId: number, accountDTO: EditAccountDTO, - ): Promise { + ): Promise { // Retrieve the old account or throw not found service error. const oldAccount = await this.accountModel .query() diff --git a/packages/server-nest/src/modules/Accounts/GetAccount.service.ts b/packages/server-nest/src/modules/Accounts/GetAccount.service.ts index 7387ab0d5..23cbceb18 100644 --- a/packages/server-nest/src/modules/Accounts/GetAccount.service.ts +++ b/packages/server-nest/src/modules/Accounts/GetAccount.service.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { AccountTransformer } from './Account.transformer'; -import { AccountModel } from './models/Account.model'; +import { Account } from './models/Account.model'; import { AccountRepository } from './repositories/Account.repository'; import { TransformerInjectable } from '../Transformer/TransformerInjectable.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; @@ -9,8 +9,8 @@ import { events } from '@/common/events/events'; @Injectable() export class GetAccount { constructor( - @Inject(AccountModel.name) - private readonly accountModel: typeof AccountModel, + @Inject(Account.name) + private readonly accountModel: typeof Account, private readonly accountRepository: AccountRepository, private readonly transformer: TransformerInjectable, private readonly eventEmitter: EventEmitter2, diff --git a/packages/server-nest/src/modules/Accounts/GetAccountTransactions.service.ts b/packages/server-nest/src/modules/Accounts/GetAccountTransactions.service.ts index 68803d438..bb92df38c 100644 --- a/packages/server-nest/src/modules/Accounts/GetAccountTransactions.service.ts +++ b/packages/server-nest/src/modules/Accounts/GetAccountTransactions.service.ts @@ -4,7 +4,7 @@ import { } from './Accounts.types'; import { AccountTransactionTransformer } from './AccountTransaction.transformer'; import { AccountTransaction } from './models/AccountTransaction.model'; -import { AccountModel } from './models/Account.model'; +import { Account } from './models/Account.model'; import { Inject, Injectable } from '@nestjs/common'; import { TransformerInjectable } from '../Transformer/TransformerInjectable.service'; @@ -16,8 +16,8 @@ export class GetAccountTransactionsService { @Inject(AccountTransaction.name) private readonly accountTransaction: typeof AccountTransaction, - @Inject(AccountModel.name) - private readonly account: typeof AccountModel, + @Inject(Account.name) + private readonly account: typeof Account, ) {} /** diff --git a/packages/server-nest/src/modules/Accounts/GetAccounts.service.ts b/packages/server-nest/src/modules/Accounts/GetAccounts.service.ts index 968e7b3db..9372d4a9b 100644 --- a/packages/server-nest/src/modules/Accounts/GetAccounts.service.ts +++ b/packages/server-nest/src/modules/Accounts/GetAccounts.service.ts @@ -8,7 +8,7 @@ // import { DynamicListService } from '../DynamicListing/DynamicListService'; // import { AccountTransformer } from './Account.transformer'; // import { TransformerInjectable } from '../Transformer/TransformerInjectable.service'; -// import { AccountModel } from './models/Account.model'; +// import { Account } from './models/Account.model'; // import { AccountRepository } from './repositories/Account.repository'; // @Injectable() @@ -16,7 +16,7 @@ // constructor( // private readonly dynamicListService: DynamicListService, // private readonly transformerService: TransformerInjectable, -// private readonly accountModel: typeof AccountModel, +// private readonly accountModel: typeof Account, // private readonly accountRepository: AccountRepository, // ) {} diff --git a/packages/server-nest/src/modules/Accounts/models/Account.model.ts b/packages/server-nest/src/modules/Accounts/models/Account.model.ts index ef0c97bb4..75834b3c4 100644 --- a/packages/server-nest/src/modules/Accounts/models/Account.model.ts +++ b/packages/server-nest/src/modules/Accounts/models/Account.model.ts @@ -18,13 +18,13 @@ import { Model } from 'objection'; // import { flatToNestedArray } from 'utils'; // @ts-expect-error -// export class AccountModel extends mixin(TenantModel, [ +// export class Account extends mixin(TenantModel, [ // ModelSettings, // CustomViewBaseModel, // SearchableModel, // ]) { -export class AccountModel extends TenantModel { +export class Account extends TenantModel { name: string; slug: string; code: string; @@ -126,7 +126,7 @@ export class AccountModel extends TenantModel { * Model modifiers. */ static get modifiers() { - const TABLE_NAME = AccountModel.tableName; + const TABLE_NAME = Account.tableName; return { /** diff --git a/packages/server-nest/src/modules/Accounts/repositories/Account.repository.ts b/packages/server-nest/src/modules/Accounts/repositories/Account.repository.ts index 586b79f4e..3d7dbe33b 100644 --- a/packages/server-nest/src/modules/Accounts/repositories/Account.repository.ts +++ b/packages/server-nest/src/modules/Accounts/repositories/Account.repository.ts @@ -2,7 +2,7 @@ import { Knex } from 'knex'; import { Inject, Injectable, Scope } from '@nestjs/common'; import { TenantRepository } from '@/common/repository/TenantRepository'; import { TENANCY_DB_CONNECTION } from '@/modules/Tenancy/TenancyDB/TenancyDB.constants'; -import { AccountModel } from '../models/Account.model'; +import { Account } from '../models/Account.model'; // import { TenantMetadata } from '@/modules/System/models/TenantMetadataModel'; // import { IAccount } from '../Accounts.types'; // import { @@ -20,8 +20,8 @@ export class AccountRepository extends TenantRepository { /** * Gets the repository's model. */ - get model(): typeof AccountModel { - return AccountModel.bindKnex(this.tenantDBKnex); + get model(): typeof Account { + return Account.bindKnex(this.tenantDBKnex); } /** diff --git a/packages/server-nest/src/modules/App/App.module.ts b/packages/server-nest/src/modules/App/App.module.ts index 20e5246f7..0a027cba7 100644 --- a/packages/server-nest/src/modules/App/App.module.ts +++ b/packages/server-nest/src/modules/App/App.module.ts @@ -32,6 +32,7 @@ import { TenancyGlobalMiddleware } from '../Tenancy/TenancyGlobal.middleware'; import { TransformerInjectable } from '../Transformer/TransformerInjectable.service'; import { TransformerModule } from '../Transformer/Transformer.module'; import { AccountsModule } from '../Accounts/Accounts.module'; +import { ExpensesModule } from '../Expenses/Expenses.module'; @Module({ imports: [ @@ -86,7 +87,8 @@ import { AccountsModule } from '../Accounts/Accounts.module'; TenancyDatabaseModule, TenancyModelsModule, ItemsModule, - AccountsModule + AccountsModule, + ExpensesModule, ], controllers: [AppController], providers: [ diff --git a/packages/server-nest/src/modules/Expenses/Expenses.controller.ts b/packages/server-nest/src/modules/Expenses/Expenses.controller.ts new file mode 100644 index 000000000..d9b1eac8e --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/Expenses.controller.ts @@ -0,0 +1,68 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, +} from '@nestjs/common'; +import { ExpensesApplication } from './ExpensesApplication.service'; +import { + IExpenseCreateDTO, + IExpenseEditDTO, +} from './interfaces/Expenses.interface'; + +@Controller('expenses') +export class ExpensesController { + constructor(private readonly expensesApplication: ExpensesApplication) {} + + /** + * Create a new expense transaction. + * @param {IExpenseCreateDTO} expenseDTO + */ + @Post() + public createExpense(@Body() expenseDTO: IExpenseCreateDTO) { + return this.expensesApplication.createExpense(expenseDTO); + } + + /** + * Edit the given expense transaction. + * @param {number} expenseId + * @param {IExpenseEditDTO} expenseDTO + */ + @Put(':id') + public editExpense( + @Param('id') expenseId: number, + @Body() expenseDTO: IExpenseEditDTO, + ) { + return this.expensesApplication.editExpense(expenseId, expenseDTO); + } + + /** + * Delete the given expense transaction. + * @param {number} expenseId + */ + @Delete(':id') + public deleteExpense(@Param('id') expenseId: number) { + return this.expensesApplication.deleteExpense(expenseId); + } + + /** + * Publish the given expense transaction. + * @param {number} expenseId + */ + @Post(':id/publish') + public publishExpense(@Param('id') expenseId: number) { + return this.expensesApplication.publishExpense(expenseId); + } + + /** + * Get the expense transaction details. + * @param {number} expenseId + */ + @Get(':id') + public getExpense(@Param('id') expenseId: number) { + return this.expensesApplication.getExpense(expenseId); + } +} diff --git a/packages/server-nest/src/modules/Expenses/Expenses.module.ts b/packages/server-nest/src/modules/Expenses/Expenses.module.ts new file mode 100644 index 000000000..8881d152b --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/Expenses.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { CreateExpense } from './commands/CreateExpense.service'; +import { DeleteExpense } from './commands/DeleteExpense.service'; +import { EditExpense } from './commands/EditExpense.service'; +import { PublishExpense } from './commands/PublishExpense.service'; +import { ExpensesController } from './Expenses.controller'; +import { ExpensesApplication } from './ExpensesApplication.service'; +import { GetExpenseService } from './queries/GetExpense.service'; + +@Module({ + imports: [], + controllers: [ExpensesController], + providers: [ + CreateExpense, + EditExpense, + DeleteExpense, + PublishExpense, + GetExpenseService, + ExpensesApplication, + ], +}) +export class ExpensesModule {} diff --git a/packages/server-nest/src/modules/Expenses/ExpensesApplication.service.ts b/packages/server-nest/src/modules/Expenses/ExpensesApplication.service.ts new file mode 100644 index 000000000..aea04f129 --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/ExpensesApplication.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common'; +import { CreateExpense } from './commands/CreateExpense.service'; +import { EditExpense } from './commands/EditExpense.service'; +import { DeleteExpense } from './commands/DeleteExpense.service'; +import { PublishExpense } from './commands/PublishExpense.service'; +import { GetExpenseService } from './queries/GetExpense.service'; +import { + IExpenseCreateDTO, + IExpenseEditDTO, +} from './interfaces/Expenses.interface'; + +@Injectable() +export class ExpensesApplication { + constructor( + private readonly createExpenseService: CreateExpense, + private readonly editExpenseService: EditExpense, + private readonly deleteExpenseService: DeleteExpense, + private readonly publishExpenseService: PublishExpense, + private readonly getExpenseService: GetExpenseService, + // private readonly getExpensesService: GetExpenseService, + ) {} + + /** + * Create a new expense transaction. + * @param {IExpenseDTO} expenseDTO + * @returns {Promise} + */ + public createExpense = (expenseDTO: IExpenseCreateDTO) => { + return this.createExpenseService.newExpense(expenseDTO); + }; + + /** + * Edits the given expense transaction. + * @param {number} expenseId - Expense id. + * @param {IExpenseEditDTO} expenseDTO + * @returns {Promise} + */ + public editExpense = (expenseId: number, expenseDTO: IExpenseEditDTO) => { + return this.editExpenseService.editExpense(expenseId, expenseDTO); + }; + + /** + * Deletes the given expense. + * @param {number} expenseId - Expense id. + * @returns {Promise} + */ + public deleteExpense = (expenseId: number) => { + return this.deleteExpenseService.deleteExpense(expenseId); + }; + + /** + * Publishes the given expense. + * @param {number} expenseId - Expense id. + * @returns {Promise} + */ + public publishExpense = (expenseId: number) => { + return this.publishExpenseService.publishExpense(expenseId); + }; + + /** + * Retrieve the given expense details. + * @param {number} expenseId -Expense id. + * @return {Promise} + */ + public getExpense = (expenseId: number) => { + return this.getExpenseService.getExpense(expenseId); + }; + + // /** + // * Retrieve expenses paginated list. + // * @param {number} tenantId + // * @param {IExpensesFilter} expensesFilter + // */ + // public getExpenses = (tenantId: number, filterDTO: IExpensesFilter) => { + // return this.getExpensesService.getExpensesList(tenantId, filterDTO); + // }; +} diff --git a/packages/server-nest/src/modules/Expenses/ExpensesExportable.ts b/packages/server-nest/src/modules/Expenses/ExpensesExportable.ts new file mode 100644 index 000000000..7a1681506 --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/ExpensesExportable.ts @@ -0,0 +1,34 @@ +// import { Inject, Service } from 'typedi'; +// import { Exportable } from '../Export/Exportable'; +// import { IExpensesFilter } from '@/interfaces'; +// import { ExpensesApplication } from './ExpensesApplication.service'; +// import { EXPORT_SIZE_LIMIT } from '../Export/constants'; + +// @Service() +// export class ExpensesExportable extends Exportable { +// @Inject() +// private expensesApplication: ExpensesApplication; + +// /** +// * Retrieves the accounts data to exportable sheet. +// * @param {number} tenantId +// * @returns +// */ +// public exportable(tenantId: number, query: IExpensesFilter) { +// const filterQuery = (query) => { +// query.withGraphFetched('branch'); +// }; +// const parsedQuery = { +// sortOrder: 'desc', +// columnSortBy: 'created_at', +// ...query, +// page: 1, +// pageSize: EXPORT_SIZE_LIMIT, +// filterQuery, +// } as IExpensesFilter; + +// return this.expensesApplication +// .getExpenses(tenantId, parsedQuery) +// .then((output) => output.expenses); +// } +// } diff --git a/packages/server-nest/src/modules/Expenses/ExpensesImportable.ts b/packages/server-nest/src/modules/Expenses/ExpensesImportable.ts new file mode 100644 index 000000000..fabacf1f4 --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/ExpensesImportable.ts @@ -0,0 +1,46 @@ +// import { Inject, Service } from 'typedi'; +// import { Knex } from 'knex'; +// import { IExpenseCreateDTO } from '@/interfaces'; +// import { Importable } from '../Import/Importable'; +// import { CreateExpense } from './CRUD/CreateExpense.service'; +// import { ExpensesSampleData } from './constants'; + +// @Service() +// export class ExpensesImportable extends Importable { +// @Inject() +// private createExpenseService: CreateExpense; + +// /** +// * Importing to account service. +// * @param {number} tenantId +// * @param {IAccountCreateDTO} createAccountDTO +// * @returns +// */ +// public importable( +// tenantId: number, +// createAccountDTO: IExpenseCreateDTO, +// trx?: Knex.Transaction +// ) { +// return this.createExpenseService.newExpense( +// tenantId, +// createAccountDTO, +// {}, +// trx +// ); +// } + +// /** +// * Concurrrency controlling of the importing process. +// * @returns {number} +// */ +// public get concurrency() { +// return 1; +// } + +// /** +// * Retrieves the sample data that used to download accounts sample sheet. +// */ +// public sampleData(): any[] { +// return ExpensesSampleData; +// } +// } diff --git a/packages/server-nest/src/modules/Expenses/commands/CommandExpenseValidator.service.ts b/packages/server-nest/src/modules/Expenses/commands/CommandExpenseValidator.service.ts new file mode 100644 index 000000000..20367d6b4 --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/commands/CommandExpenseValidator.service.ts @@ -0,0 +1,109 @@ +import { sumBy, difference } from 'lodash'; +import { ERRORS, SUPPORTED_EXPENSE_PAYMENT_ACCOUNT_TYPES } from '../constants'; +import { + IExpenseCreateDTO, + IExpenseEditDTO, +} from '../interfaces/Expenses.interface'; +import { ACCOUNT_ROOT_TYPE } from '@/constants/accounts'; +import { Account } from '@/modules/Accounts/models/Account.model'; +import { Injectable } from '@nestjs/common'; +import { Expense } from '../models/Expense.model'; +import { ServiceError } from '@/modules/Items/ServiceError'; + +@Injectable() +export class CommandExpenseValidator { + /** + * Validates expense categories not equals zero. + * @param {IExpenseCreateDTO | IExpenseEditDTO} expenseDTO + * @throws {ServiceError} + */ + public validateCategoriesNotEqualZero = ( + expenseDTO: IExpenseCreateDTO | IExpenseEditDTO, + ) => { + const totalAmount = sumBy(expenseDTO.categories, 'amount') || 0; + + if (totalAmount <= 0) { + throw new ServiceError(ERRORS.TOTAL_AMOUNT_EQUALS_ZERO); + } + }; + + /** + * Retrieve expense accounts or throw error in case one of the given accounts + * not found not the storage. + * @param {number} tenantId + * @param {number} expenseAccountsIds + * @throws {ServiceError} + * @returns {Promise} + */ + public validateExpensesAccountsExistance( + expenseAccounts: Account[], + DTOAccountsIds: number[], + ) { + const storedExpenseAccountsIds = expenseAccounts.map((a: Account) => a.id); + + const notStoredAccountsIds = difference( + DTOAccountsIds, + storedExpenseAccountsIds, + ); + if (notStoredAccountsIds.length > 0) { + throw new ServiceError(ERRORS.SOME_ACCOUNTS_NOT_FOUND); + } + } + + /** + * Validate expenses accounts type. + * @param {Account[]} expensesAccounts + * @throws {ServiceError} + */ + public validateExpensesAccountsType = (expensesAccounts: Account[]) => { + const invalidExpenseAccounts: number[] = []; + + expensesAccounts.forEach((expenseAccount) => { + if (!expenseAccount.isRootType(ACCOUNT_ROOT_TYPE.EXPENSE)) { + invalidExpenseAccounts.push(expenseAccount.id); + } + }); + if (invalidExpenseAccounts.length > 0) { + throw new ServiceError(ERRORS.EXPENSES_ACCOUNT_HAS_INVALID_TYPE); + } + }; + + /** + * Validates payment account type in case has invalid type throws errors. + * @param {Account} paymentAccount + * @throws {ServiceError} + */ + public validatePaymentAccountType = (paymentAccount: Account) => { + if ( + !paymentAccount.isAccountType(SUPPORTED_EXPENSE_PAYMENT_ACCOUNT_TYPES) + ) { + throw new ServiceError(ERRORS.PAYMENT_ACCOUNT_HAS_INVALID_TYPE); + } + }; + + /** + * Validates the expense has not associated landed cost + * references to the given expense. + * @param {number} tenantId + * @param {number} expenseId + */ + public async validateNoAssociatedLandedCost(expenseId: number) { + // const { BillLandedCost } = this.tenancy.models(tenantId); + // const associatedLandedCosts = await BillLandedCost.query() + // .where('fromTransactionType', 'Expense') + // .where('fromTransactionId', expenseId); + // if (associatedLandedCosts.length > 0) { + // throw new ServiceError(ERRORS.EXPENSE_HAS_ASSOCIATED_LANDED_COST); + // } + } + + /** + * Validates expenses is not already published before. + * @param {IExpense} expense + */ + public validateExpenseIsNotPublished(expense: Expense) { + if (expense.publishedAt) { + throw new ServiceError(ERRORS.EXPENSE_ALREADY_PUBLISHED); + } + } +} diff --git a/packages/server-nest/src/modules/Expenses/commands/CreateExpense.service.ts b/packages/server-nest/src/modules/Expenses/commands/CreateExpense.service.ts new file mode 100644 index 000000000..1df82ae1d --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/commands/CreateExpense.service.ts @@ -0,0 +1,113 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { + IExpenseCreateDTO, + IExpenseCreatedPayload, + IExpenseCreatingPayload, +} from '../interfaces/Expenses.interface'; +import { CommandExpenseValidator } from './CommandExpenseValidator.service'; +import { ExpenseDTOTransformer } from './ExpenseDTOTransformer'; +import { Account } from '@/modules/Accounts/models/Account.model'; +import { Expense } from '@/modules/Expenses/models/Expense.model'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; + +@Injectable() +export class CreateExpense { + constructor( + private readonly eventEmitter: EventEmitter2, + private readonly uow: UnitOfWork, + private readonly validator: CommandExpenseValidator, + private readonly transformDTO: ExpenseDTOTransformer, + + @Inject(Account.name) + private readonly accountModel: typeof Account, + + @Inject(Expense.name) + private readonly expenseModel: typeof Expense, + ) {} + + /** + * Authorize before create a new expense transaction. + * @param {IExpenseDTO} expenseDTO + */ + private authorize = async (expenseDTO: IExpenseCreateDTO) => { + // Validate payment account existance on the storage. + const paymentAccount = await this.accountModel + .query() + .findById(expenseDTO.paymentAccountId) + .throwIfNotFound(); + + // Retrieves the DTO expense accounts ids. + const DTOExpenseAccountsIds = expenseDTO.categories.map( + (category) => category.expenseAccountId, + ); + // Retrieves the expenses accounts. + const expenseAccounts = await this.accountModel + .query() + .whereIn('id', DTOExpenseAccountsIds); + // Validate expense accounts exist on the storage. + this.validator.validateExpensesAccountsExistance( + expenseAccounts, + DTOExpenseAccountsIds, + ); + // Validate payment account type. + this.validator.validatePaymentAccountType(paymentAccount); + + // Validate expenses accounts type. + this.validator.validateExpensesAccountsType(expenseAccounts); + + // Validate the given expense categories not equal zero. + this.validator.validateCategoriesNotEqualZero(expenseDTO); + }; + + /** + * Precedures. + * --------- + * 1. Validate payment account existance on the storage. + * 2. Validate expense accounts exist on the storage. + * 3. Validate payment account type. + * 4. Validate expenses accounts type. + * 5. Validate the expense payee contact id existance on storage. + * 6. Validate the given expense categories not equal zero. + * 7. Stores the expense to the storage. + * --------- + * @param {number} tenantId + * @param {IExpenseDTO} expenseDTO + */ + public newExpense = async ( + expenseDTO: IExpenseCreateDTO, + trx?: Knex.Transaction, + ): Promise => { + // Authorize before create a new expense. + await this.authorize(expenseDTO); + + // Save the expense to the storage. + const expenseObj = await this.transformDTO.expenseCreateDTO(expenseDTO); + + // Writes the expense transaction with associated transactions under + // unit-of-work envirement. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Triggers `onExpenseCreating` event. + await this.eventEmitter.emitAsync(events.expenses.onCreating, { + trx, + expenseDTO, + } as IExpenseCreatingPayload); + + // Creates a new expense transaction graph. + const expense = await this.expenseModel + .query(trx) + .upsertGraph(expenseObj); + // Triggers `onExpenseCreated` event. + await this.eventEmitter.emitAsync(events.expenses.onCreated, { + expenseId: expense.id, + expenseDTO, + expense, + trx, + } as IExpenseCreatedPayload); + + return expense; + }, trx); + }; +} diff --git a/packages/server-nest/src/modules/Expenses/commands/DeleteExpense.service.ts b/packages/server-nest/src/modules/Expenses/commands/DeleteExpense.service.ts new file mode 100644 index 000000000..bf113db8e --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/commands/DeleteExpense.service.ts @@ -0,0 +1,70 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { CommandExpenseValidator } from './CommandExpenseValidator.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { Expense } from '../models/Expense.model'; +import ExpenseCategory from '../models/ExpenseCategory.model'; +import { events } from '@/common/events/events'; +import { + IExpenseEventDeletePayload, + IExpenseDeletingPayload, +} from '../interfaces/Expenses.interface'; + +@Injectable() +export class DeleteExpense { + constructor( + private readonly eventEmitter: EventEmitter2, + private readonly uow: UnitOfWork, + private readonly validator: CommandExpenseValidator, + @Inject(Expense.name) + private expenseModel: typeof Expense, + + @Inject(ExpenseCategory.name) + private expenseCategoryModel: typeof ExpenseCategory, + ) {} + + /** + * Deletes the given expense. + * @param {number} expenseId + * @param {ISystemUser} authorizedUser + */ + public async deleteExpense(expenseId: number): Promise { + // Retrieves the expense transaction with associated entries or + // throw not found error. + const oldExpense = await this.expenseModel + .query() + .findById(expenseId) + .withGraphFetched('categories') + .throwIfNotFound(); + + // Validates the expense has no associated landed cost. + await this.validator.validateNoAssociatedLandedCost(expenseId); + + // Deletes expense transactions with associated transactions under + // unit-of-work envirement. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Triggers `onExpenseDeleting` event. + await this.eventEmitter.emitAsync(events.expenses.onDeleting, { + trx, + oldExpense, + } as IExpenseDeletingPayload); + + // Deletes expense associated entries. + await this.expenseCategoryModel + .query(trx) + .where('expenseId', expenseId) + .delete(); + + // Deletes expense transactions. + await this.expenseModel.query(trx).findById(expenseId).delete(); + + // Triggers `onExpenseDeleted` event. + await this.eventEmitter.emitAsync(events.expenses.onDeleted, { + expenseId, + oldExpense, + trx, + } as IExpenseEventDeletePayload); + }); + } +} diff --git a/packages/server-nest/src/modules/Expenses/commands/EditExpense.service.ts b/packages/server-nest/src/modules/Expenses/commands/EditExpense.service.ts new file mode 100644 index 000000000..2c308fc25 --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/commands/EditExpense.service.ts @@ -0,0 +1,139 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { Knex } from 'knex'; +import { + IExpenseEventEditPayload, + IExpenseEventEditingPayload, + IExpenseEditDTO, +} from '../interfaces/Expenses.interface'; +import { CommandExpenseValidator } from './CommandExpenseValidator.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { ExpenseDTOTransformer } from './ExpenseDTOTransformer'; +// import { EntriesService } from '@/services/Entries'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { Account } from '@/modules/Accounts/models/Account.model'; +import { Expense } from '../models/Expense.model'; +import { events } from '@/common/events/events'; + +@Injectable() +export class EditExpense { + constructor( + private eventEmitter: EventEmitter2, + private uow: UnitOfWork, + private validator: CommandExpenseValidator, + private transformDTO: ExpenseDTOTransformer, + // private entriesService: EntriesService, + @Inject(Expense.name) + private expenseModel: typeof Expense, + @Inject(Account.name) + private accountModel: typeof Account, + ) {} + + /** + * Authorize the DTO before editing expense transaction. + * @param {IExpenseEditDTO} expenseDTO + */ + public authorize = async ( + oldExpense: Expense, + expenseDTO: IExpenseEditDTO, + ) => { + // Validate payment account existance on the storage. + const paymentAccount = await this.accountModel + .query() + .findById(expenseDTO.paymentAccountId) + .throwIfNotFound(); + + // Retrieves the DTO expense accounts ids. + const DTOExpenseAccountsIds = expenseDTO.categories.map( + (category) => category.expenseAccountId, + ); + // Retrieves the expenses accounts. + const expenseAccounts = await this.accountModel + .query() + .whereIn('id', DTOExpenseAccountsIds); + // Validate expense accounts exist on the storage. + this.validator.validateExpensesAccountsExistance( + expenseAccounts, + DTOExpenseAccountsIds, + ); + // Validate payment account type. + await this.validator.validatePaymentAccountType(paymentAccount); + + // Validate expenses accounts type. + await this.validator.validateExpensesAccountsType(expenseAccounts); + // Validate the given expense categories not equal zero. + this.validator.validateCategoriesNotEqualZero(expenseDTO); + + // Validate expense entries that have allocated landed cost cannot be deleted. + // this.entriesService.validateLandedCostEntriesNotDeleted( + // oldExpense.categories, + // expenseDTO.categories, + // ); + // // Validate expense entries that have allocated cost amount should be bigger than amount. + // this.entriesService.validateLocatedCostEntriesSmallerThanNewEntries( + // oldExpense.categories, + // expenseDTO.categories, + // ); + }; + + /** + * Precedures. + * --------- + * 1. Validate expense existance. + * 2. Validate payment account existance on the storage. + * 3. Validate expense accounts exist on the storage. + * 4. Validate payment account type. + * 5. Validate expenses accounts type. + * 6. Validate the given expense categories not equal zero. + * 7. Stores the expense to the storage. + * --------- + * @param {number} expenseId + * @param {IExpenseDTO} expenseDTO + * @param {ISystemUser} authorizedUser + */ + public async editExpense( + expenseId: number, + expenseDTO: IExpenseEditDTO, + ): Promise { + // Retrieves the expense model or throw not found error. + const oldExpense = await this.expenseModel + .query() + .findById(expenseId) + .withGraphFetched('categories') + .throwIfNotFound(); + + // Authorize expense DTO before editing. + await this.authorize(oldExpense, expenseDTO); + + // Update the expense on the storage. + const expenseObj = await this.transformDTO.expenseEditDTO(expenseDTO); + + // Edits expense transactions and associated transactions under UOW envirement. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Triggers `onExpenseEditing` event. + await this.eventEmitter.emitAsync(events.expenses.onEditing, { + oldExpense, + expenseDTO, + trx, + } as IExpenseEventEditingPayload); + + // Upsert the expense object with expense entries. + const expense = await this.expenseModel + .query(trx) + .upsertGraphAndFetch({ + id: expenseId, + ...expenseObj, + }); + + // Triggers `onExpenseCreated` event. + await this.eventEmitter.emitAsync(events.expenses.onEdited, { + expenseId, + expense, + expenseDTO, + oldExpense, + trx, + } as IExpenseEventEditPayload); + + return expense; + }); + } +} diff --git a/packages/server-nest/src/modules/Expenses/commands/ExpenseDTOTransformer.ts b/packages/server-nest/src/modules/Expenses/commands/ExpenseDTOTransformer.ts new file mode 100644 index 000000000..9ebd61635 --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/commands/ExpenseDTOTransformer.ts @@ -0,0 +1,117 @@ +import { omit, sumBy } from 'lodash'; +import moment from 'moment'; +import * as R from 'ramda'; +import { + IExpenseCreateDTO, + IExpenseCommonDTO, + IExpenseEditDTO, +} from '../interfaces/Expenses.interface'; +// import { BranchTransactionDTOTransform } from '@/services/Branches/Integrations/BranchTransactionDTOTransform'; +// import { TenantMetadata } from '@/system/models'; +import { Injectable } from '@nestjs/common'; +import { Expense } from '../models/Expense.model'; +import { assocItemEntriesDefaultIndex } from '@/utils/associate-item-entries-index'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; + +@Injectable() +export class ExpenseDTOTransformer { + constructor( + // private readonly branchDTOTransform: BranchTransactionDTOTransform; + private readonly tenancyContext: TenancyContext, + ) {} + + /** + * Retrieve the expense landed cost amount. + * @param {IExpenseDTO} expenseDTO + * @return {number} + */ + private getExpenseLandedCostAmount = ( + expenseDTO: IExpenseCreateDTO | IExpenseEditDTO, + ): number => { + const landedCostEntries = expenseDTO.categories.filter((entry) => { + return entry.landedCost === true; + }); + return this.getExpenseCategoriesTotal(landedCostEntries); + }; + + /** + * Retrieve the given expense categories total. + * @param {IExpenseCategory} categories + * @returns {number} + */ + private getExpenseCategoriesTotal = (categories): number => { + return sumBy(categories, 'amount'); + }; + + /** + * Mapping expense DTO to model. + * @param {IExpenseDTO} expenseDTO + * @param {ISystemUser} authorizedUser + * @return {IExpense} + */ + private expenseDTOToModel( + expenseDTO: IExpenseCreateDTO | IExpenseEditDTO, + // user?: ISystemUser + ): Expense { + const landedCostAmount = this.getExpenseLandedCostAmount(expenseDTO); + const totalAmount = this.getExpenseCategoriesTotal(expenseDTO.categories); + + const categories = R.compose( + // Associate the default index to categories lines. + assocItemEntriesDefaultIndex, + )(expenseDTO.categories || []); + + const initialDTO = { + ...omit(expenseDTO, ['publish', 'attachments']), + categories, + totalAmount, + landedCostAmount, + paymentDate: moment(expenseDTO.paymentDate).toMySqlDateTime(), + ...(expenseDTO.publish + ? { + publishedAt: moment().toMySqlDateTime(), + } + : {}), + }; + return initialDTO; + // return R.compose(this.branchDTOTransform.transformDTO(tenantId))( + // initialDTO + // ); + } + + /** + * Transformes the expense create DTO. + * @param {IExpenseCreateDTO} expenseDTO + * @returns {Promise} + */ + public expenseCreateDTO = async ( + expenseDTO: IExpenseCreateDTO, + ): Promise => { + const initialDTO = this.expenseDTOToModel(expenseDTO); + const tenant = await this.tenancyContext.getTenant(true); + + return { + ...initialDTO, + currencyCode: expenseDTO.currencyCode || tenant?.metadata?.baseCurrency, + exchangeRate: expenseDTO.exchangeRate || 1, + // ...(user + // ? { + // userId: user.id, + // } + // : {}), + }; + }; + + /** + * Transformes the expense edit DTO. + * @param {number} tenantId + * @param {IExpenseEditDTO} expenseDTO + * @param {ISystemUser} user + * @returns {IExpense} + */ + public expenseEditDTO = async ( + expenseDTO: IExpenseEditDTO, + ): Promise => { + return this.expenseDTOToModel(expenseDTO); + }; +} diff --git a/packages/server-nest/src/modules/Expenses/commands/PublishExpense.service.ts b/packages/server-nest/src/modules/Expenses/commands/PublishExpense.service.ts new file mode 100644 index 000000000..f93f38e64 --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/commands/PublishExpense.service.ts @@ -0,0 +1,67 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { + IExpensePublishingPayload, + IExpenseEventPublishedPayload, +} from '../interfaces/Expenses.interface'; +import { CommandExpenseValidator } from './CommandExpenseValidator.service'; +import { Expense } from '../models/Expense.model'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { events } from '@/common/events/events'; +import { EventEmitter2 } from '@nestjs/event-emitter'; + +@Injectable() +export class PublishExpense { + constructor( + private readonly eventPublisher: EventEmitter2, + private readonly uow: UnitOfWork, + private readonly validator: CommandExpenseValidator, + + @Inject(Expense.name) + private readonly expenseModel: typeof Expense, + ) {} + + /** + * Publish the given expense. + * @param {number} expenseId + * @param {ISystemUser} authorizedUser + * @return {Promise} + */ + public async publishExpense(expenseId: number) { + // Retrieves the old expense or throw not found error. + const oldExpense = await this.expenseModel + .query() + .findById(expenseId) + .throwIfNotFound(); + + // Validate the expense whether is published before. + this.validator.validateExpenseIsNotPublished(oldExpense); + + // Publishes expense transactions with associated transactions + // under unit-of-work envirement. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Trigggers `onExpensePublishing` event. + await this.eventPublisher.emitAsync(events.expenses.onPublishing, { + trx, + oldExpense, + } as IExpensePublishingPayload); + + // Publish the given expense on the storage. + await this.expenseModel.query().findById(expenseId).modify('publish'); + + // Retrieve the new expense after modification. + const expense = await this.expenseModel + .query() + .findById(expenseId) + .withGraphFetched('categories'); + + // Triggers `onExpensePublished` event. + await this.eventPublisher.emitAsync(events.expenses.onPublished, { + expenseId, + oldExpense, + expense, + trx, + } as IExpenseEventPublishedPayload); + }); + } +} diff --git a/packages/server-nest/src/modules/Expenses/constants.ts b/packages/server-nest/src/modules/Expenses/constants.ts new file mode 100644 index 000000000..713610ca2 --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/constants.ts @@ -0,0 +1,89 @@ +import { ACCOUNT_TYPE } from "@/constants/accounts"; + +export const DEFAULT_VIEW_COLUMNS = []; +export const DEFAULT_VIEWS = [ + { + name: 'Draft', + slug: 'draft', + rolesLogicExpression: '1', + roles: [ + { index: 1, fieldKey: 'status', comparator: 'equals', value: 'draft' }, + ], + columns: DEFAULT_VIEW_COLUMNS, + }, + { + name: 'Published', + slug: 'published', + rolesLogicExpression: '1', + roles: [ + { + index: 1, + fieldKey: 'status', + comparator: 'equals', + value: 'published', + }, + ], + columns: DEFAULT_VIEW_COLUMNS, + }, +]; + +export const ERRORS = { + EXPENSE_NOT_FOUND: 'expense_not_found', + EXPENSES_NOT_FOUND: 'EXPENSES_NOT_FOUND', + PAYMENT_ACCOUNT_NOT_FOUND: 'payment_account_not_found', + SOME_ACCOUNTS_NOT_FOUND: 'some_expenses_not_found', + TOTAL_AMOUNT_EQUALS_ZERO: 'total_amount_equals_zero', + PAYMENT_ACCOUNT_HAS_INVALID_TYPE: 'payment_account_has_invalid_type', + EXPENSES_ACCOUNT_HAS_INVALID_TYPE: 'expenses_account_has_invalid_type', + EXPENSE_ALREADY_PUBLISHED: 'expense_already_published', + EXPENSE_HAS_ASSOCIATED_LANDED_COST: 'EXPENSE_HAS_ASSOCIATED_LANDED_COST', +}; + +export const ExpensesSampleData = [ + { + 'Payment Date': '2024-03-01', + 'Reference No.': 'REF-1', + 'Payment Account': 'Petty Cash', + Description: 'Vel et dolorem architecto veniam.', + 'Currency Code': '', + 'Exchange Rate': '', + 'Expense Account': 'Utilities Expense', + Amount: 9000, + 'Line Description': 'Voluptates voluptas corporis vel.', + Publish: 'T', + }, + { + 'Payment Date': '2024-03-02', + 'Reference No.': 'REF-2', + 'Payment Account': 'Petty Cash', + Description: 'Id est molestias.', + 'Currency Code': '', + 'Exchange Rate': '', + 'Expense Account': 'Utilities Expense', + Amount: 9000, + 'Line Description': 'Eos voluptatem cumque et voluptate reiciendis.', + Publish: 'T', + }, + { + 'Payment Date': '2024-03-03', + 'Reference No.': 'REF-3', + 'Payment Account': 'Petty Cash', + Description: 'Quam cupiditate at nihil dicta dignissimos non fugit illo.', + 'Currency Code': '', + 'Exchange Rate': '', + 'Expense Account': 'Utilities Expense', + Amount: 9000, + 'Line Description': + 'Hic alias rerum sed commodi dolores sint animi perferendis.', + Publish: 'T', + }, +]; + +export const SUPPORTED_EXPENSE_PAYMENT_ACCOUNT_TYPES = [ + ACCOUNT_TYPE.CASH, + ACCOUNT_TYPE.BANK, + ACCOUNT_TYPE.CREDIT_CARD, + ACCOUNT_TYPE.OTHER_CURRENT_ASSET, + ACCOUNT_TYPE.NON_CURRENT_ASSET, + ACCOUNT_TYPE.FIXED_ASSET, +]; diff --git a/packages/server-nest/src/modules/Expenses/interfaces/Expenses.interface.ts b/packages/server-nest/src/modules/Expenses/interfaces/Expenses.interface.ts new file mode 100644 index 000000000..55c138867 --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/interfaces/Expenses.interface.ts @@ -0,0 +1,117 @@ +import { IFilterRole } from '@/modules/DynamicListing/DynamicFilter/DynamicFilter.types'; +import { Knex } from 'knex'; +import { Expense } from '../models/Expense.model'; + +// import { AttachmentLinkDTO } from '../Attachments/Attachments'; + +export interface IPaginationMeta { + total: number; + page: number; + pageSize: number; +} + +export interface IExpensesFilter { + page: number; + pageSize: number; + filterRoles?: IFilterRole[]; + columnSortBy: string; + sortOrder: string; + viewSlug?: string; + filterQuery?: (query: any) => void; +} + +export interface IExpenseCommonDTO { + currencyCode: string; + exchangeRate?: number; + description?: string; + paymentAccountId: number; + peyeeId?: number; + referenceNo?: string; + publish: boolean; + userId: number; + paymentDate: Date; + payeeId: number; + categories: IExpenseCategoryDTO[]; + + branchId?: number; + // attachments?: AttachmentLinkDTO[]; +} + +export interface IExpenseCreateDTO extends IExpenseCommonDTO {} +export interface IExpenseEditDTO extends IExpenseCommonDTO {} + +export interface IExpenseCategoryDTO { + id?: number; + expenseAccountId: number; + index: number; + amount: number; + description?: string; + expenseId: number; + landedCost?: boolean; + projectId?: number; +} + +export interface IExpenseCreatingPayload { + trx: Knex.Transaction; + tenantId: number; + expenseDTO: IExpenseCreateDTO; +} + +export interface IExpenseEventEditingPayload { + tenantId: number; + oldExpense: Expense; + expenseDTO: IExpenseEditDTO; + trx: Knex.Transaction; +} +export interface IExpenseCreatedPayload { + tenantId: number; + expenseId: number; + // authorizedUser: ISystemUser; + expense: Expense; + expenseDTO: IExpenseCreateDTO; + trx: Knex.Transaction; +} + +export interface IExpenseEventEditPayload { + tenantId: number; + expenseId: number; + expense: Expense; + expenseDTO: IExpenseEditDTO; + // authorizedUser: ISystemUser; + oldExpense: Expense; + trx: Knex.Transaction; +} + +export interface IExpenseEventDeletePayload { + tenantId: number; + expenseId: number; + // authorizedUser: ISystemUser; + oldExpense: Expense; + trx: Knex.Transaction; +} + +export interface IExpenseDeletingPayload { + trx: Knex.Transaction; + tenantId: number; + oldExpense: Expense; +} +export interface IExpenseEventPublishedPayload { + tenantId: number; + expenseId: number; + oldExpense: Expense; + expense: Expense; + // authorizedUser: ISystemUser; + trx: Knex.Transaction; +} + +export interface IExpensePublishingPayload { + trx: Knex.Transaction; + oldExpense: Expense; + tenantId: number; +} +export enum ExpenseAction { + Create = 'Create', + Edit = 'Edit', + Delete = 'Delete', + View = 'View', +} diff --git a/packages/server-nest/src/modules/Expenses/models/Expense.model.ts b/packages/server-nest/src/modules/Expenses/models/Expense.model.ts new file mode 100644 index 000000000..3f9a3a572 --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/models/Expense.model.ts @@ -0,0 +1,302 @@ +import { Model, mixin, raw } from 'objection'; +// import TenantModel from 'models/TenantModel'; +// import { viewRolesBuilder } from '@/lib/ViewRolesBuilder'; +// import ModelSetting from './ModelSetting'; +// import ExpenseSettings from './Expense.Settings'; +// import CustomViewBaseModel from './CustomViewBaseModel'; +// import { DEFAULT_VIEWS } from '@/services/Expenses/constants'; +// import ModelSearchable from './ModelSearchable'; +import moment from 'moment'; +import { BaseModel } from '@/models/Model'; + +export class Expense extends BaseModel { + // ModelSetting, + // CustomViewBaseModel, + // ModelSearchable, + // ]) { + + totalAmount!: number; + currencyCode!: string; + exchangeRate!: number; + description?: string; + paymentAccountId!: number; + peyeeId!: number; + referenceNo!: string; + publishedAt!: Date | null; + userId!: number; + paymentDate!: Date; + payeeId!: number; + landedCostAmount!: number; + allocatedCostAmount!: number; + invoicedAmount: number; + branchId!: number; + createdAt!: Date; + + /** + * Table name + */ + static get tableName() { + return 'expenses_transactions'; + } + + /** + * Account transaction reference type. + */ + static get referenceType() { + return 'Expense'; + } + + /** + * Model timestamps. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return [ + 'isPublished', + 'unallocatedCostAmount', + 'localAmount', + 'localLandedCostAmount', + 'localUnallocatedCostAmount', + 'localAllocatedCostAmount', + 'billableAmount', + ]; + } + + /** + * Retrieves the local amount of expense. + * @returns {number} + */ + get localAmount() { + return this.totalAmount * this.exchangeRate; + } + + /** + * Rertieves the local landed cost amount of expense. + * @returns {number} + */ + get localLandedCostAmount() { + return this.landedCostAmount * this.exchangeRate; + } + + /** + * Retrieves the local allocated cost amount. + * @returns {number} + */ + get localAllocatedCostAmount() { + return this.allocatedCostAmount * this.exchangeRate; + } + + /** + * Retrieve the unallocated cost amount. + * @return {number} + */ + get unallocatedCostAmount() { + return Math.max(this.totalAmount - this.allocatedCostAmount, 0); + } + + /** + * Retrieves the local unallocated cost amount. + * @returns {number} + */ + get localUnallocatedCostAmount() { + return this.unallocatedCostAmount * this.exchangeRate; + } + + /** + * Detarmines whether the expense is published. + * @returns {boolean} + */ + get isPublished() { + return Boolean(this.publishedAt); + } + + /** + * Retrieves the calculated amount which have not been invoiced. + */ + get billableAmount() { + return Math.max(this.totalAmount - this.invoicedAmount, 0); + } + + /** + * Model modifiers. + */ + static get modifiers() { + return { + filterByDateRange(query, startDate, endDate) { + if (startDate) { + query.where('date', '>=', startDate); + } + if (endDate) { + query.where('date', '<=', endDate); + } + }, + filterByAmountRange(query, from, to) { + if (from) { + query.where('amount', '>=', from); + } + if (to) { + query.where('amount', '<=', to); + } + }, + filterByExpenseAccount(query, accountId) { + if (accountId) { + query.where('expense_account_id', accountId); + } + }, + filterByPaymentAccount(query, accountId) { + if (accountId) { + query.where('payment_account_id', accountId); + } + }, + // viewRolesBuilder(query, conditionals, expression) { + // viewRolesBuilder(conditionals, expression)(query); + // }, + + filterByDraft(query) { + query.where('published_at', null); + }, + + filterByPublished(query) { + query.whereNot('published_at', null); + }, + + filterByStatus(query, status) { + switch (status) { + case 'draft': + query.modify('filterByDraft'); + break; + case 'published': + default: + query.modify('filterByPublished'); + break; + } + }, + + publish(query) { + query.update({ + publishedAt: moment().toMySqlDateTime(), + }); + }, + + /** + * Filters the expenses have billable amount. + */ + billable(query) { + query.where(raw('AMOUNT > INVOICED_AMOUNT')); + }, + }; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + // const Account = require('models/Account'); + const { ExpenseCategory } = require('./ExpenseCategory.model'); + // const Document = require('models/Document'); + // const Branch = require('models/Branch'); + // const { MatchedBankTransaction } = require('models/MatchedBankTransaction'); + + return { + // paymentAccount: { + // relation: Model.BelongsToOneRelation, + // modelClass: Account.default, + // join: { + // from: 'expenses_transactions.paymentAccountId', + // to: 'accounts.id', + // }, + // }, + categories: { + relation: Model.HasManyRelation, + modelClass: ExpenseCategory, + join: { + from: 'expenses_transactions.id', + to: 'expense_transaction_categories.expenseId', + }, + filter: (query) => { + query.orderBy('index', 'ASC'); + }, + }, + + /** + * Expense transction may belongs to a branch. + */ + // branch: { + // relation: Model.BelongsToOneRelation, + // modelClass: Branch.default, + // join: { + // from: 'expenses_transactions.branchId', + // to: 'branches.id', + // }, + // }, + + // /** + // * Expense transaction may has many attached attachments. + // */ + // attachments: { + // relation: Model.ManyToManyRelation, + // modelClass: Document.default, + // join: { + // from: 'expenses_transactions.id', + // through: { + // from: 'document_links.modelId', + // to: 'document_links.documentId', + // }, + // to: 'documents.id', + // }, + // filter(query) { + // query.where('model_ref', 'Expense'); + // }, + // }, + + // /** + // * Expense may belongs to matched bank transaction. + // */ + // matchedBankTransaction: { + // relation: Model.HasManyRelation, + // modelClass: MatchedBankTransaction, + // join: { + // from: 'expenses_transactions.id', + // to: 'matched_bank_transactions.referenceId', + // }, + // filter(query) { + // query.where('reference_type', 'Expense'); + // }, + // }, + }; + } + + // static get meta() { + // return ExpenseSettings; + // } + + // /** + // * Retrieve the default custom views, roles and columns. + // */ + // static get defaultViews() { + // return DEFAULT_VIEWS; + // } + + /** + * Model search attributes. + */ + static get searchRoles() { + return [ + { fieldKey: 'reference_no', comparator: 'contains' }, + { condition: 'or', fieldKey: 'amount', comparator: 'equals' }, + ]; + } + + /** + * Prevents mutate base currency since the model is not empty. + */ + static get preventMutateBaseCurrency() { + return true; + } +} diff --git a/packages/server-nest/src/modules/Expenses/models/ExpenseCategory.model.ts b/packages/server-nest/src/modules/Expenses/models/ExpenseCategory.model.ts new file mode 100644 index 000000000..d78b32b9c --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/models/ExpenseCategory.model.ts @@ -0,0 +1,47 @@ +import { Model } from 'objection'; +import { BaseModel } from '@/models/Model'; + +export default class ExpenseCategory extends BaseModel { + amount!: number; + allocatedCostAmount!: number; + + /** + * Table name + */ + static get tableName() { + return 'expense_transaction_categories'; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return ['unallocatedCostAmount']; + } + + /** + * Remain unallocated landed cost. + * @return {number} + */ + get unallocatedCostAmount() { + return Math.max(this.amount - this.allocatedCostAmount, 0); + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const Account = require('models/Account'); + + return { + expenseAccount: { + relation: Model.BelongsToOneRelation, + modelClass: Account.default, + join: { + from: 'expense_transaction_categories.expenseAccountId', + to: 'accounts.id', + }, + }, + }; + } +} diff --git a/packages/server-nest/src/modules/Expenses/queries/Expense.transformer.ts b/packages/server-nest/src/modules/Expenses/queries/Expense.transformer.ts new file mode 100644 index 000000000..69164b68a --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/queries/Expense.transformer.ts @@ -0,0 +1,103 @@ +import { Transformer } from '@/modules/Transformer/Transformer'; +import { ExpenseCategoryTransformer } from './ExpenseCategory.transformer'; +// import { AttachmentTransformer } from '@/services/Attachments/AttachmentTransformer'; +import { Expense } from '../models/Expense.model'; + +export class ExpenseTransfromer extends Transformer { + /** + * Include these attributes to expense object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'formattedAmount', + 'formattedLandedCostAmount', + 'formattedAllocatedCostAmount', + 'formattedDate', + 'formattedCreatedAt', + 'formattedPublishedAt', + 'categories', + 'attachments', + ]; + }; + + /** + * Retrieve formatted expense amount. + * @param {IExpense} expense + * @returns {string} + */ + protected formattedAmount = (expense: Expense): string => { + return this.formatNumber(expense.totalAmount, { + currencyCode: expense.currencyCode, + }); + }; + + /** + * Retrieve formatted expense landed cost amount. + * @param {IExpense} expense + * @returns {string} + */ + protected formattedLandedCostAmount = (expense: Expense): string => { + return this.formatNumber(expense.landedCostAmount, { + currencyCode: expense.currencyCode, + }); + }; + + /** + * Retrieve formatted allocated cost amount. + * @param {IExpense} expense + * @returns {string} + */ + protected formattedAllocatedCostAmount = (expense: Expense): string => { + return this.formatNumber(expense.allocatedCostAmount, { + currencyCode: expense.currencyCode, + }); + }; + + /** + * Retriecve fromatted date. + * @param {IExpense} expense + * @returns {string} + */ + protected formattedDate = (expense: Expense): string => { + return this.formatDate(expense.paymentDate); + }; + + /** + * Retrieve formatted created at date. + * @param {IExpense} expense + * @returns {string} + */ + protected formattedCreatedAt = (expense: Expense): string => { + return this.formatDate(expense.createdAt); + } + + /** + * Retrieves the transformed expense categories. + * @param {IExpense} expense + * @returns {} + */ + protected categories = (expense: Expense) => { + // return this.item(expense.categories, new ExpenseCategoryTransformer(), { + // currencyCode: expense.currencyCode, + // }); + }; + + /** + * Retrieves the sale invoice attachments. + * @param {ISaleInvoice} invoice + * @returns + */ + protected attachments = (expense: Expense) => { + // return this.item(expense.attachments, new AttachmentTransformer()); + }; + + /** + * Retrieve formatted published at date. + * @param {IExpense} expense + * @returns {string} + */ + protected formattedPublishedAt = (expense: Expense): string => { + return this.formatDate(expense.publishedAt); + } +} diff --git a/packages/server-nest/src/modules/Expenses/queries/ExpenseCategory.transformer.ts b/packages/server-nest/src/modules/Expenses/queries/ExpenseCategory.transformer.ts new file mode 100644 index 000000000..e2f2befb8 --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/queries/ExpenseCategory.transformer.ts @@ -0,0 +1,25 @@ + +import { Transformer } from '@/modules/Transformer/Transformer'; +import ExpenseCategory from '../models/ExpenseCategory.model'; + +export class ExpenseCategoryTransformer extends Transformer { + /** + * Include these attributes to expense object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return ['amountFormatted']; + }; + + /** + * Retrieves the formatted amount. + * @param {ExpenseCategory} category + * @returns {string} + */ + protected amountFormatted(category: ExpenseCategory) { + return this.formatNumber(category.amount, { + currencyCode: this.context.organization.baseCurrency, + money: false, + }); + } +} diff --git a/packages/server-nest/src/modules/Expenses/queries/GetExpense.service.ts b/packages/server-nest/src/modules/Expenses/queries/GetExpense.service.ts new file mode 100644 index 000000000..5da180083 --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/queries/GetExpense.service.ts @@ -0,0 +1,32 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ExpenseTransfromer } from './Expense.transformer'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { Expense } from '../models/Expense.model'; + +@Injectable() +export class GetExpenseService { + constructor( + private readonly transformerService: TransformerInjectable, + + @Inject(Expense.name) + private readonly expenseModel: typeof Expense, + ) {} + + /** + * Retrieve expense details. + * @param {number} expenseId + * @return {Promise} + */ + public async getExpense(expenseId: number): Promise { + const expense = await this.expenseModel + .query() + .findById(expenseId) + .withGraphFetched('categories.expenseAccount') + .withGraphFetched('paymentAccount') + .withGraphFetched('branch') + .withGraphFetched('attachments') + .throwIfNotFound(); + + return this.transformerService.transform(expense, new ExpenseTransfromer()); + } +} diff --git a/packages/server-nest/src/modules/Expenses/queries/GetExpenses.service.ts b/packages/server-nest/src/modules/Expenses/queries/GetExpenses.service.ts new file mode 100644 index 000000000..b6e9872bc --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/queries/GetExpenses.service.ts @@ -0,0 +1,81 @@ +// import { Service, Inject } from 'typedi'; +// import * as R from 'ramda'; +// import { +// IExpensesFilter, +// IExpense, +// IPaginationMeta, +// IFilterMeta, +// } from '@/interfaces'; +// import DynamicListingService from '@/services/DynamicListing/DynamicListService'; +// import HasTenancyService from '@/services/Tenancy/TenancyService'; +// import { ExpenseTransfromer } from './Expense.transformer'; +// import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; + +// @Service() +// export class GetExpenses { +// @Inject() +// private dynamicListService: DynamicListingService; + +// @Inject() +// private tenancy: HasTenancyService; + +// @Inject() +// private transformer: TransformerInjectable; + +// /** +// * Retrieve expenses paginated list. +// * @param {number} tenantId +// * @param {IExpensesFilter} expensesFilter +// * @return {IExpense[]} +// */ +// public getExpensesList = async ( +// tenantId: number, +// filterDTO: IExpensesFilter +// ): Promise<{ +// expenses: IExpense[]; +// pagination: IPaginationMeta; +// filterMeta: IFilterMeta; +// }> => { +// const { Expense } = this.tenancy.models(tenantId); + +// // Parses list filter DTO. +// const filter = this.parseListFilterDTO(filterDTO); + +// // Dynamic list service. +// const dynamicList = await this.dynamicListService.dynamicList( +// tenantId, +// Expense, +// filter +// ); +// // Retrieves the paginated results. +// const { results, pagination } = await Expense.query() +// .onBuild((builder) => { +// builder.withGraphFetched('paymentAccount'); +// builder.withGraphFetched('categories.expenseAccount'); + +// dynamicList.buildQuery()(builder); +// filterDTO?.filterQuery && filterDTO?.filterQuery(builder); +// }) +// .pagination(filter.page - 1, filter.pageSize); + +// // Transformes the expenses models to POJO. +// const expenses = await this.transformer.transform( +// tenantId, +// results, +// new ExpenseTransfromer() +// ); +// return { +// expenses, +// pagination, +// filterMeta: dynamicList.getResponseMeta(), +// }; +// }; + +// /** +// * Parses filter DTO of expenses list. +// * @param filterDTO - +// */ +// private parseListFilterDTO(filterDTO) { +// return R.compose(this.dynamicListService.parseStringifiedFilter)(filterDTO); +// } +// } diff --git a/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGL.ts b/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGL.ts new file mode 100644 index 000000000..ba437af98 --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGL.ts @@ -0,0 +1,113 @@ +import * as R from 'ramda'; +import { + AccountNormal, + IExpenseCategory, + ILedger, + ILedgerEntry, +} from '@/interfaces'; +import Ledger from '../Accounting/Ledger'; + +export class ExpenseGL { + private expense: any; + + /** + * Constructor method. + */ + constructor(expense: any) { + this.expense = expense; + } + + /** + * Retrieves the expense GL common entry. + * @param {IExpense} expense + * @returns {Partial} + */ + private getExpenseGLCommonEntry = (): Partial => { + return { + currencyCode: this.expense.currencyCode, + exchangeRate: this.expense.exchangeRate, + + transactionType: 'Expense', + transactionId: this.expense.id, + + date: this.expense.paymentDate, + userId: this.expense.userId, + + debit: 0, + credit: 0, + + branchId: this.expense.branchId, + }; + }; + + /** + * Retrieves the expense GL payment entry. + * @param {IExpense} expense + * @returns {ILedgerEntry} + */ + private getExpenseGLPaymentEntry = (): ILedgerEntry => { + const commonEntry = this.getExpenseGLCommonEntry(); + + return { + ...commonEntry, + credit: this.expense.localAmount, + accountId: this.expense.paymentAccountId, + accountNormal: + this.expense?.paymentAccount?.accountNormal === 'debit' + ? AccountNormal.DEBIT + : AccountNormal.CREDIT, + index: 1, + }; + }; + + /** + * Retrieves the expense GL category entry. + * @param {IExpense} expense - + * @param {IExpenseCategory} expenseCategory - + * @param {number} index + * @returns {ILedgerEntry} + */ + private getExpenseGLCategoryEntry = R.curry( + (category: IExpenseCategory, index: number): ILedgerEntry => { + const commonEntry = this.getExpenseGLCommonEntry(); + const localAmount = category.amount * this.expense.exchangeRate; + + return { + ...commonEntry, + accountId: category.expenseAccountId, + accountNormal: AccountNormal.DEBIT, + debit: localAmount, + note: category.description, + index: index + 2, + projectId: category.projectId, + }; + } + ); + + /** + * Retrieves the expense GL entries. + * @param {IExpense} expense + * @returns {ILedgerEntry[]} + */ + public getExpenseGLEntries = (): ILedgerEntry[] => { + const getCategoryEntry = this.getExpenseGLCategoryEntry(); + + const paymentEntry = this.getExpenseGLPaymentEntry(); + const categoryEntries = this.expense.categories.map(getCategoryEntry); + + return [paymentEntry, ...categoryEntries]; + }; + + /** + * Retrieves the given expense ledger. + * @param {IExpense} expense + * @returns {ILedger} + */ + public getExpenseLedger = (): ILedger => { + const entries = this.getExpenseGLEntries(); + + console.log(entries, 'entries'); + + return new Ledger(entries); + }; +} diff --git a/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesService.ts b/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesService.ts new file mode 100644 index 000000000..8502fb574 --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesService.ts @@ -0,0 +1,45 @@ +import { Knex } from 'knex'; +import { Inject, Service } from 'typedi'; +import { IExpense, ILedger } from '@/interfaces'; +import { ExpenseGL } from './ExpenseGL'; +import HasTenancyService from '../Tenancy/TenancyService'; + +@Service() +export class ExpenseGLEntries { + @Inject() + private tenancy: HasTenancyService; + + /** + * Retrieves the expense G/L of the given id. + * @param {number} tenantId + * @param {number} expenseId + * @param {Knex.Transaction} trx + * @returns {Promise} + */ + public getExpenseLedgerById = async ( + tenantId: number, + expenseId: number, + trx?: Knex.Transaction + ): Promise => { + const { Expense } = await this.tenancy.models(tenantId); + + const expense = await Expense.query(trx) + .findById(expenseId) + .withGraphFetched('categories') + .withGraphFetched('paymentAccount') + .throwIfNotFound(); + + return this.getExpenseLedger(expense); + }; + + /** + * Retrieves the given expense ledger. + * @param {IExpense} expense + * @returns {ILedger} + */ + public getExpenseLedger = (expense: IExpense): ILedger => { + const expenseGL = new ExpenseGL(expense); + + return expenseGL.getExpenseLedger(); + }; +} diff --git a/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesStorage.ts b/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesStorage.ts new file mode 100644 index 000000000..ac451a2e2 --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesStorage.ts @@ -0,0 +1,72 @@ +import { Knex } from 'knex'; +import { Service, Inject } from 'typedi'; +import LedgerStorageService from '@/services/Accounting/LedgerStorageService'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { ExpenseGLEntries } from './ExpenseGLEntriesService'; + +@Service() +export class ExpenseGLEntriesStorage { + @Inject() + private expenseGLEntries: ExpenseGLEntries; + + @Inject() + private ledgerStorage: LedgerStorageService; + + /** + * Writes the expense GL entries. + * @param {number} tenantId + * @param {number} expenseId + * @param {Knex.Transaction} trx + */ + public writeExpenseGLEntries = async ( + tenantId: number, + expenseId: number, + trx?: Knex.Transaction + ) => { + // Retrieves the given expense ledger. + const expenseLedger = await this.expenseGLEntries.getExpenseLedgerById( + tenantId, + expenseId, + trx + ); + // Commits the expense ledger entries. + await this.ledgerStorage.commit(tenantId, expenseLedger, trx); + }; + + /** + * Reverts the given expense GL entries. + * @param {number} tenantId + * @param {number} expenseId + * @param {Knex.Transaction} trx + */ + public revertExpenseGLEntries = async ( + tenantId: number, + expenseId: number, + trx?: Knex.Transaction + ) => { + await this.ledgerStorage.deleteByReference( + tenantId, + expenseId, + 'Expense', + trx + ); + }; + + /** + * Rewrites the expense GL entries. + * @param {number} tenantId + * @param {number} expenseId + * @param {Knex.Transaction} trx + */ + public rewriteExpenseGLEntries = async ( + tenantId: number, + expenseId: number, + trx?: Knex.Transaction + ) => { + // Reverts the expense GL entries. + await this.revertExpenseGLEntries(tenantId, expenseId, trx); + + // Writes the expense GL entries. + await this.writeExpenseGLEntries(tenantId, expenseId, trx); + }; +} diff --git a/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesSubscriber.ts b/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesSubscriber.ts new file mode 100644 index 000000000..4c69b7f04 --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesSubscriber.ts @@ -0,0 +1,117 @@ +import { Inject, Service } from 'typedi'; +import events from '@/subscribers/events'; +import TenancyService from '@/services/Tenancy/TenancyService'; +import { + IExpenseCreatedPayload, + IExpenseEventDeletePayload, + IExpenseEventEditPayload, + IExpenseEventPublishedPayload, +} from '@/interfaces'; +import { ExpenseGLEntriesStorage } from './ExpenseGLEntriesStorage'; + +@Service() +export class ExpensesWriteGLSubscriber { + @Inject() + private tenancy: TenancyService; + + @Inject() + private expenseGLEntries: ExpenseGLEntriesStorage; + + /** + * Attaches events with handlers. + * @param bus + */ + public attach(bus) { + bus.subscribe( + events.expenses.onCreated, + this.handleWriteGLEntriesOnceCreated + ); + bus.subscribe( + events.expenses.onEdited, + this.handleRewriteGLEntriesOnceEdited + ); + bus.subscribe( + events.expenses.onDeleted, + this.handleRevertGLEntriesOnceDeleted + ); + bus.subscribe( + events.expenses.onPublished, + this.handleWriteGLEntriesOncePublished + ); + } + + /** + * Handles the writing journal entries once the expense created. + * @param {IExpenseCreatedPayload} payload - + */ + public handleWriteGLEntriesOnceCreated = async ({ + expense, + tenantId, + trx, + }: IExpenseCreatedPayload) => { + // In case expense published, write journal entries. + if (!expense.publishedAt) return; + + await this.expenseGLEntries.writeExpenseGLEntries( + tenantId, + expense.id, + trx + ); + }; + + /** + * Handle writing expense journal entries once the expense edited. + * @param {IExpenseEventEditPayload} payload - + */ + public handleRewriteGLEntriesOnceEdited = async ({ + expenseId, + tenantId, + expense, + authorizedUser, + trx, + }: IExpenseEventEditPayload) => { + // Cannot continue if the expense is not published. + if (!expense.publishedAt) return; + + await this.expenseGLEntries.rewriteExpenseGLEntries( + tenantId, + expense.id, + trx + ); + }; + + /** + * Reverts expense journal entries once the expense deleted. + * @param {IExpenseEventDeletePayload} payload - + */ + public handleRevertGLEntriesOnceDeleted = async ({ + expenseId, + tenantId, + trx, + }: IExpenseEventDeletePayload) => { + await this.expenseGLEntries.revertExpenseGLEntries( + tenantId, + expenseId, + trx + ); + }; + + /** + * Handles writing expense journal once the expense publish. + * @param {IExpenseEventPublishedPayload} payload - + */ + public handleWriteGLEntriesOncePublished = async ({ + tenantId, + expense, + trx, + }: IExpenseEventPublishedPayload) => { + // In case expense published, write journal entries. + if (!expense.publishedAt) return; + + await this.expenseGLEntries.rewriteExpenseGLEntries( + tenantId, + expense.id, + trx + ); + }; +} diff --git a/packages/server-nest/src/modules/ItemCategories/ItemCategoriesExportable.ts b/packages/server-nest/src/modules/ItemCategories/ItemCategoriesExportable.ts new file mode 100644 index 000000000..3b0d16fd4 --- /dev/null +++ b/packages/server-nest/src/modules/ItemCategories/ItemCategoriesExportable.ts @@ -0,0 +1,29 @@ +// import { Inject, Service } from 'typedi'; +// import { Exportable } from '../Export/Exportable'; +// import { IAccountsFilter, IAccountsStructureType } from '@/interfaces'; +// import ItemCategoriesService from './ItemCategoriesService'; + +// @Service() +// export class ItemCategoriesExportable extends Exportable { +// @Inject() +// private itemCategoriesApplication: ItemCategoriesService; + +// /** +// * Retrieves the accounts data to exportable sheet. +// * @param {number} tenantId +// * @returns +// */ +// public exportable(tenantId: number, query: IAccountsFilter) { +// const parsedQuery = { +// sortOrder: 'desc', +// columnSortBy: 'created_at', +// inactiveMode: false, +// ...query, +// structure: IAccountsStructureType.Flat, +// } as IAccountsFilter; + +// return this.itemCategoriesApplication +// .getItemCategoriesList(tenantId, parsedQuery, {}) +// .then((output) => output.itemCategories); +// } +// } diff --git a/packages/server-nest/src/modules/ItemCategories/ItemCategoriesImportable.ts b/packages/server-nest/src/modules/ItemCategories/ItemCategoriesImportable.ts new file mode 100644 index 000000000..b1540ba72 --- /dev/null +++ b/packages/server-nest/src/modules/ItemCategories/ItemCategoriesImportable.ts @@ -0,0 +1,38 @@ +// import { Inject, Service } from 'typedi'; +// import ItemCategoriesService from './ItemCategoriesService'; +// import { Importable } from '../Import/Importable'; +// import { Knex } from 'knex'; +// import { IItemCategoryOTD } from '@/interfaces'; +// import { ItemCategoriesSampleData } from './constants'; + +// @Service() +// export class ItemCategoriesImportable extends Importable { +// @Inject() +// private itemCategoriesService: ItemCategoriesService; + +// /** +// * Importing to create new item category service. +// * @param {number} tenantId +// * @param {any} createDTO +// * @param {Knex.Transaction} trx +// */ +// public async importable( +// tenantId: number, +// createDTO: IItemCategoryOTD, +// trx?: Knex.Transaction +// ) { +// await this.itemCategoriesService.newItemCategory( +// tenantId, +// createDTO, +// {}, +// trx +// ); +// } + +// /** +// * Item categories sample data used to download sample sheet file. +// */ +// public sampleData(): any[] { +// return ItemCategoriesSampleData; +// } +// } diff --git a/packages/server-nest/src/modules/ItemCategories/ItemCategory.application.ts b/packages/server-nest/src/modules/ItemCategories/ItemCategory.application.ts new file mode 100644 index 000000000..83430aea3 --- /dev/null +++ b/packages/server-nest/src/modules/ItemCategories/ItemCategory.application.ts @@ -0,0 +1,64 @@ +import { IItemCategoryOTD } from './ItemCategory.interfaces'; +import { CreateItemCategoryService } from './commands/CreateItemCategory.service'; +import { DeleteItemCategoryService } from './commands/DeleteItemCategory.service'; +import { EditItemCategoryService } from './commands/EditItemCategory.service'; +import { GetItemCategoryService } from './queries/GetItemCategory.service'; + +export class ItemCategoryApplication { + constructor( + private readonly createItemCategoryService: CreateItemCategoryService, + private readonly editItemCategoryService: EditItemCategoryService, + private readonly getItemCategoryService: GetItemCategoryService, + private readonly deleteItemCategoryService: DeleteItemCategoryService, + ) {} + + /** + * Creates a new item category. + * @param {number} tenantId - The tenant id. + * @param {IItemCategoryOTD} itemCategoryDTO - The item category data. + * @returns {Promise} The created item category. + */ + public createItemCategory( + tenantId: number, + itemCategoryDTO: IItemCategoryOTD, + ) { + return this.createItemCategoryService.newItemCategory( + tenantId, + itemCategoryDTO, + ); + } + + /** + * Updates an existing item category. + * @param {number} itemCategoryId - The item category id to update. + * @param {IItemCategoryOTD} itemCategoryDTO - The updated item category data. + * @returns {Promise} The updated item category. + */ + public editItemCategory( + itemCategoryId: number, + itemCategoryDTO: IItemCategoryOTD, + ) { + return this.editItemCategoryService.editItemCategory( + itemCategoryId, + itemCategoryDTO, + ); + } + + /** + * Retrieves an item category by id. + * @param {number} itemCategoryId - The item category id to retrieve. + * @returns {Promise} The requested item category. + */ + public getItemCategory(itemCategoryId: number) { + return this.getItemCategoryService.getItemCategory(itemCategoryId); + } + + /** + * Deletes an item category. + * @param {number} itemCategoryId - The item category id to delete. + * @returns {Promise} + */ + public deleteItemCategory(itemCategoryId: number) { + return this.deleteItemCategoryService.deleteItemCategory(itemCategoryId); + } +} diff --git a/packages/server-nest/src/modules/ItemCategories/ItemCategory.controller.ts b/packages/server-nest/src/modules/ItemCategories/ItemCategory.controller.ts new file mode 100644 index 000000000..be4d929e9 --- /dev/null +++ b/packages/server-nest/src/modules/ItemCategories/ItemCategory.controller.ts @@ -0,0 +1,47 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, +} from '@nestjs/common'; +import { ItemCategoryApplication } from './ItemCategory.application'; +import { IItemCategoryOTD } from './ItemCategory.interfaces'; + +@Controller('item-categories') +export class ItemCategoryController { + constructor( + private readonly itemCategoryApplication: ItemCategoryApplication, + ) {} + + @Post() + async createItemCategory( + @Body('tenantId') tenantId: number, + @Body() itemCategoryDTO: IItemCategoryOTD, + ) { + return this.itemCategoryApplication.createItemCategory( + tenantId, + itemCategoryDTO, + ); + } + + @Put(':id') + async editItemCategory( + @Param('id') id: number, + @Body() itemCategoryDTO: IItemCategoryOTD, + ) { + return this.itemCategoryApplication.editItemCategory(id, itemCategoryDTO); + } + + @Get(':id') + async getItemCategory(@Param('id') id: number) { + return this.itemCategoryApplication.getItemCategory(id); + } + + @Delete(':id') + async deleteItemCategory(@Param('id') id: number) { + return this.itemCategoryApplication.deleteItemCategory(id); + } +} diff --git a/packages/server-nest/src/modules/ItemCategories/ItemCategory.interfaces.ts b/packages/server-nest/src/modules/ItemCategories/ItemCategory.interfaces.ts new file mode 100644 index 000000000..07fd57705 --- /dev/null +++ b/packages/server-nest/src/modules/ItemCategories/ItemCategory.interfaces.ts @@ -0,0 +1,37 @@ +import { Knex } from 'knex'; +// import { IDynamicListFilterDTO } from './DynamicFilter'; +// import { ISystemUser } from './User'; +import { ItemCategory } from './models/ItemCategory.model'; + + +export interface IItemCategoryOTD { + name: string; + + description?: string; + userId: number; + + costAccountId?: number; + sellAccountId?: number; + inventoryAccountId?: number; + + costMethod?: string; +} + +// export interface IItemCategoriesFilter extends IDynamicListFilterDTO { +// stringifiedFilterRoles?: string; +// } + +export interface IItemCategoryCreatedPayload { + itemCategory: ItemCategory; + trx: Knex.Transaction; +} + +export interface IItemCategoryEditedPayload { + oldItemCategory: ItemCategory; + trx: Knex.Transaction; +} + +export interface IItemCategoryDeletedPayload { + itemCategoryId: number; + oldItemCategory: ItemCategory; +} diff --git a/packages/server-nest/src/modules/ItemCategories/ItemCategory.module.ts b/packages/server-nest/src/modules/ItemCategories/ItemCategory.module.ts new file mode 100644 index 000000000..0626185a6 --- /dev/null +++ b/packages/server-nest/src/modules/ItemCategories/ItemCategory.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { TenancyDatabaseModule } from '../Tenancy/TenancyDB/TenancyDB.module'; +import { CreateItemCategoryService } from './commands/CreateItemCategory.service'; +import { DeleteItemCategoryService } from './commands/DeleteItemCategory.service'; +import { EditItemCategoryService } from './commands/EditItemCategory.service'; +import { GetItemCategoryService } from './queries/GetItemCategory.service'; +import { ItemCategoryApplication } from './ItemCategory.application'; +import { ItemCategoryController } from './ItemCategory.controller'; + +@Module({ + imports: [TenancyDatabaseModule], + controllers: [ItemCategoryController], + providers: [ + CreateItemCategoryService, + EditItemCategoryService, + GetItemCategoryService, + DeleteItemCategoryService, + ItemCategoryApplication, + ], +}) +export class ItemCategoryModule {} diff --git a/packages/server-nest/src/modules/ItemCategories/commands/CommandItemCategoryValidator.service.ts b/packages/server-nest/src/modules/ItemCategories/commands/CommandItemCategoryValidator.service.ts new file mode 100644 index 000000000..b11a46f35 --- /dev/null +++ b/packages/server-nest/src/modules/ItemCategories/commands/CommandItemCategoryValidator.service.ts @@ -0,0 +1,93 @@ +import { Account } from '@/modules/Accounts/models/Account.model'; +import { ItemCategory } from '../models/ItemCategory.model'; +import { Inject } from '@nestjs/common'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { ERRORS } from '../constants'; +import { ACCOUNT_ROOT_TYPE, ACCOUNT_TYPE } from '@/constants/accounts'; + +export class CommandItemCategoryValidatorService { + constructor( + @Inject(ItemCategory.name) + private readonly itemCategoryModel: typeof ItemCategory, + + @Inject(Account.name) + private readonly accountModel: typeof Account, + ) {} + + /** + * Validates the category name uniquiness. + * @param {string} categoryName - Category name. + * @param {number} notAccountId - Ignore the account id. + */ + public async validateCategoryNameUniquiness( + categoryName: string, + notCategoryId?: number, + ) { + const foundItemCategory = await this.itemCategoryModel + .query() + .findOne('name', categoryName) + .onBuild((query) => { + if (notCategoryId) { + query.whereNot('id', notCategoryId); + } + }); + + if (foundItemCategory) { + throw new ServiceError( + ERRORS.CATEGORY_NAME_EXISTS, + 'The item category name is already exist.', + ); + } + } + + /** + * Validates sell account existance and type. + * @param {number} sellAccountId - Sell account id. + * @return {Promise} + */ + public async validateSellAccount(sellAccountId: number) { + const foundAccount = await this.accountModel + .query() + .findById(sellAccountId); + + if (!foundAccount) { + throw new ServiceError(ERRORS.SELL_ACCOUNT_NOT_FOUND); + } else if (!foundAccount.isRootType(ACCOUNT_ROOT_TYPE.INCOME)) { + throw new ServiceError(ERRORS.SELL_ACCOUNT_NOT_INCOME); + } + } + + /** + * Validates COGS account existance and type. + * @param {number} costAccountId - + * @return {Promise} + */ + public async validateCostAccount(costAccountId: number) { + const foundAccount = await this.accountModel + .query() + .findById(costAccountId); + + if (!foundAccount) { + throw new ServiceError(ERRORS.COST_ACCOUNT_NOT_FOUMD); + } else if (!foundAccount.isRootType(ACCOUNT_ROOT_TYPE.EXPENSE)) { + throw new ServiceError(ERRORS.COST_ACCOUNT_NOT_COGS); + } + } + + /** + * Validates inventory account existance and type. + * @param {number} inventoryAccountId + * @return {Promise} + */ + public async validateInventoryAccount(inventoryAccountId: number) { + const foundAccount = await this.accountModel + .query() + .findById(inventoryAccountId); + + if (!foundAccount) { + throw new ServiceError(ERRORS.INVENTORY_ACCOUNT_NOT_FOUND); + } else if (!foundAccount.isAccountType(ACCOUNT_TYPE.INVENTORY)) { + throw new ServiceError(ERRORS.INVENTORY_ACCOUNT_NOT_INVENTORY); + } + } +} diff --git a/packages/server-nest/src/modules/ItemCategories/commands/CreateItemCategory.service.ts b/packages/server-nest/src/modules/ItemCategories/commands/CreateItemCategory.service.ts new file mode 100644 index 000000000..1988aa026 --- /dev/null +++ b/packages/server-nest/src/modules/ItemCategories/commands/CreateItemCategory.service.ts @@ -0,0 +1,81 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Knex } from 'knex'; +import { + IItemCategoryOTD, + IItemCategoryCreatedPayload, +} from '../ItemCategory.interfaces'; +import { events } from '@/common/events/events'; +import { CommandItemCategoryValidatorService } from './CommandItemCategoryValidator.service'; +import { ItemCategory } from '../models/ItemCategory.model'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { SystemUser } from '@/modules/System/models/SystemUser'; + +@Injectable() +export class CreateItemCategoryService { + constructor( + private readonly uow: UnitOfWork, + private readonly validator: CommandItemCategoryValidatorService, + private readonly eventEmitter: EventEmitter2, + + @Inject(ItemCategory.name) + private readonly itemCategoryModel: typeof ItemCategory, + ) {} + + /** + * Transforms OTD to model object. + * @param {IItemCategoryOTD} itemCategoryOTD + * @param {ISystemUser} authorizedUser + */ + private transformOTDToObject( + itemCategoryOTD: IItemCategoryOTD, + authorizedUser: SystemUser, + ): ItemCategory { + return { ...itemCategoryOTD, userId: authorizedUser.id }; + } + /** + * Inserts a new item category. + * @param {number} tenantId + * @param {IItemCategoryOTD} itemCategoryOTD + * @return {Promise} + */ + public async newItemCategory( + tenantId: number, + itemCategoryOTD: IItemCategoryOTD, + trx?: Knex.Transaction, + ): Promise { + // Validate the category name uniquiness. + await this.validator.validateCategoryNameUniquiness(itemCategoryOTD.name); + + if (itemCategoryOTD.sellAccountId) { + await this.validator.validateSellAccount(itemCategoryOTD.sellAccountId); + } + if (itemCategoryOTD.costAccountId) { + await this.validator.validateCostAccount(itemCategoryOTD.costAccountId); + } + if (itemCategoryOTD.inventoryAccountId) { + await this.validator.validateInventoryAccount( + itemCategoryOTD.inventoryAccountId, + ); + } + const itemCategoryObj = this.transformOTDToObject( + itemCategoryOTD, + authorizedUser, + ); + // Creates item category under unit-of-work evnirement. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Inserts the item category. + const itemCategory = await this.itemCategoryModel.query(trx).insert({ + ...itemCategoryObj, + }); + // Triggers `onItemCategoryCreated` event. + await this.eventEmitter.emitAsync(events.itemCategory.onCreated, { + itemCategory, + tenantId, + trx, + } as IItemCategoryCreatedPayload); + + return itemCategory; + }, trx); + } +} diff --git a/packages/server-nest/src/modules/ItemCategories/commands/DeleteItemCategory.service.ts b/packages/server-nest/src/modules/ItemCategories/commands/DeleteItemCategory.service.ts new file mode 100644 index 000000000..64f3b848b --- /dev/null +++ b/packages/server-nest/src/modules/ItemCategories/commands/DeleteItemCategory.service.ts @@ -0,0 +1,69 @@ +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { CommandItemCategoryValidatorService } from './CommandItemCategoryValidator.service'; +import { ItemCategory } from '../models/ItemCategory.model'; +import { Inject, Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; +import { IItemCategoryDeletedPayload } from '../ItemCategory.interfaces'; +import { Item } from '@/modules/Items/models/Item'; + +@Injectable() +export class DeleteItemCategoryService { + constructor( + private readonly uow: UnitOfWork, + private readonly validator: CommandItemCategoryValidatorService, + private readonly eventEmitter: EventEmitter2, + @Inject(ItemCategory.name) + private readonly itemCategoryModel: typeof ItemCategory, + + @Inject(Item.name) + private readonly itemModel: typeof Item, + ) {} + + /** + * Deletes the given item category. + * @param {number} tenantId - Tenant id. + * @param {number} itemCategoryId - Item category id. + * @return {Promise} + */ + public async deleteItemCategory(itemCategoryId: number) { + // Retrieve item category or throw not found error. + const oldItemCategory = await this.itemCategoryModel + .query() + .findById(itemCategoryId) + .throwIfNotFound(); + + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Unassociate items with item category. + await this.unassociateItemsWithCategories(itemCategoryId, trx); + + // Delete item category. + await ItemCategory.query(trx).findById(itemCategoryId).delete(); + + // Triggers `onItemCategoryDeleted` event. + await this.eventEmitter.emitAsync(events.itemCategory.onDeleted, { + itemCategoryId, + oldItemCategory, + } as IItemCategoryDeletedPayload); + }); + } + + /** + * Unlink items relations with item categories. + * @param {number|number[]} itemCategoryId - + * @return {Promise} + */ + private async unassociateItemsWithCategories( + itemCategoryId: number | number[], + trx?: Knex.Transaction, + ): Promise { + const ids = Array.isArray(itemCategoryId) + ? itemCategoryId + : [itemCategoryId]; + + await this.itemModel.query(trx) + .whereIn('category_id', ids) + .patch({ categoryId: null }); + } +} diff --git a/packages/server-nest/src/modules/ItemCategories/commands/EditItemCategory.service.ts b/packages/server-nest/src/modules/ItemCategories/commands/EditItemCategory.service.ts new file mode 100644 index 000000000..7152a64eb --- /dev/null +++ b/packages/server-nest/src/modules/ItemCategories/commands/EditItemCategory.service.ts @@ -0,0 +1,87 @@ +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { CommandItemCategoryValidatorService } from './CommandItemCategoryValidator.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; +import { + IItemCategoryEditedPayload, + IItemCategoryOTD, +} from '../ItemCategory.interfaces'; +import { SystemUser } from '@/modules/System/models/SystemUser'; +import { Knex } from 'knex'; +import { ItemCategory } from '../models/ItemCategory.model'; +import { Inject } from '@nestjs/common'; + +export class EditItemCategoryService { + constructor( + private readonly uow: UnitOfWork, + private readonly validator: CommandItemCategoryValidatorService, + private readonly eventEmitter: EventEmitter2, + @Inject(ItemCategory.name) + private readonly itemCategoryModel: typeof ItemCategory, + ) {} + /** + * Edits item category. + * @param {number} tenantId + * @param {number} itemCategoryId + * @param {IItemCategoryOTD} itemCategoryOTD + * @return {Promise} + */ + public async editItemCategory( + itemCategoryId: number, + itemCategoryOTD: IItemCategoryOTD, + ): Promise { + // Retrieve the item category from the storage. + const oldItemCategory = await this.itemCategoryModel + .query() + .findById(itemCategoryId) + .throwIfNotFound(); + + // Validate the category name whether unique on the storage. + await this.validator.validateCategoryNameUniquiness( + itemCategoryOTD.name, + itemCategoryId, + ); + if (itemCategoryOTD.sellAccountId) { + await this.validator.validateSellAccount(itemCategoryOTD.sellAccountId); + } + if (itemCategoryOTD.costAccountId) { + await this.validator.validateCostAccount(itemCategoryOTD.costAccountId); + } + if (itemCategoryOTD.inventoryAccountId) { + await this.validator.validateInventoryAccount( + itemCategoryOTD.inventoryAccountId, + ); + } + const itemCategoryObj = this.transformOTDToObject( + itemCategoryOTD, + authorizedUser, + ); + // + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // + const itemCategory = await ItemCategory.query().patchAndFetchById( + itemCategoryId, + { ...itemCategoryObj }, + ); + // Triggers `onItemCategoryEdited` event. + await this.eventEmitter.emitAsync(events.itemCategory.onEdited, { + oldItemCategory, + trx, + } as IItemCategoryEditedPayload); + + return itemCategory; + }); + } + + /** + * Transforms OTD to model object. + * @param {IItemCategoryOTD} itemCategoryOTD + * @param {ISystemUser} authorizedUser + */ + private transformOTDToObject( + itemCategoryOTD: IItemCategoryOTD, + authorizedUser: SystemUser, + ) { + return { ...itemCategoryOTD, userId: authorizedUser.id }; + } +} diff --git a/packages/server-nest/src/modules/ItemCategories/constants.ts b/packages/server-nest/src/modules/ItemCategories/constants.ts new file mode 100644 index 000000000..c92830805 --- /dev/null +++ b/packages/server-nest/src/modules/ItemCategories/constants.ts @@ -0,0 +1,35 @@ +// eslint-disable-next-line import/prefer-default-export +export const ERRORS = { + ITEM_CATEGORIES_NOT_FOUND: 'ITEM_CATEGORIES_NOT_FOUND', + CATEGORY_NAME_EXISTS: 'CATEGORY_NAME_EXISTS', + CATEGORY_NOT_FOUND: 'CATEGORY_NOT_FOUND', + COST_ACCOUNT_NOT_FOUMD: 'COST_ACCOUNT_NOT_FOUMD', + COST_ACCOUNT_NOT_COGS: 'COST_ACCOUNT_NOT_COGS', + SELL_ACCOUNT_NOT_INCOME: 'SELL_ACCOUNT_NOT_INCOME', + SELL_ACCOUNT_NOT_FOUND: 'SELL_ACCOUNT_NOT_FOUND', + INVENTORY_ACCOUNT_NOT_FOUND: 'INVENTORY_ACCOUNT_NOT_FOUND', + INVENTORY_ACCOUNT_NOT_INVENTORY: 'INVENTORY_ACCOUNT_NOT_INVENTORY', + CATEGORY_HAVE_ITEMS: 'CATEGORY_HAVE_ITEMS', +}; + +export const ItemCategoriesSampleData = [ + { + Name: 'Kassulke Group', + Description: 'Optio itaque eaque qui adipisci illo sed.', + }, + { + Name: 'Crist, Mraz and Lueilwitz', + Description: + 'Dolores veniam deserunt sed commodi error quia veritatis non.', + }, + { + Name: 'Gutmann and Sons', + Description: + 'Ratione aperiam voluptas rem adipisci assumenda eos neque veritatis tempora.', + }, + { + Name: 'Reichel - Raynor', + Description: + 'Necessitatibus repellendus placeat possimus dolores excepturi ut.', + }, +]; diff --git a/packages/server-nest/src/modules/ItemCategories/models/ItemCategory.model.ts b/packages/server-nest/src/modules/ItemCategories/models/ItemCategory.model.ts new file mode 100644 index 000000000..8f61a4c91 --- /dev/null +++ b/packages/server-nest/src/modules/ItemCategories/models/ItemCategory.model.ts @@ -0,0 +1,63 @@ +import { BaseModel } from '@/models/Model'; +import { Model, mixin } from 'objection'; +// import TenantModel from 'models/TenantModel'; +// import ModelSetting from './ModelSetting'; +// import ItemCategorySettings from './ItemCategory.Settings'; + +export class ItemCategory extends BaseModel { + /** + * Table name. + */ + static get tableName() { + return 'items_categories'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const { Item } = require('../../Items/models/Item'); + + return { + /** + * Item category may has many items. + */ + items: { + relation: Model.HasManyRelation, + modelClass: Item, + join: { + from: 'items_categories.id', + to: 'items.categoryId', + }, + }, + }; + } + + /** + * Model modifiers. + */ + static get modifiers() { + return { + /** + * Inactive/Active mode. + */ + sortByCount(query, order = 'asc') { + query.orderBy('count', order); + }, + }; + } + + /** + * Model meta. + */ + // static get meta() { + // return ItemCategorySettings; + // } +} diff --git a/packages/server-nest/src/modules/ItemCategories/queries/GetItemCategory.service.ts b/packages/server-nest/src/modules/ItemCategories/queries/GetItemCategory.service.ts new file mode 100644 index 000000000..1882883b2 --- /dev/null +++ b/packages/server-nest/src/modules/ItemCategories/queries/GetItemCategory.service.ts @@ -0,0 +1,24 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ItemCategory } from '../models/ItemCategory.model'; + +@Injectable() +export class GetItemCategoryService { + constructor( + @Inject(ItemCategory.name) + private readonly itemCategoryModel: typeof ItemCategory, + ) {} + + /** + * Retrieves item category by id. + * @param {number} itemCategoryId + * @returns {Promise} + */ + public async getItemCategory(itemCategoryId: number) { + const itemCategory = await this.itemCategoryModel + .query() + .findById(itemCategoryId) + .throwIfNotFound(); + + return itemCategory; + } +} diff --git a/packages/server-nest/src/modules/Items/ItemValidator.service.ts b/packages/server-nest/src/modules/Items/ItemValidator.service.ts index 25d251359..bfc585c15 100644 --- a/packages/server-nest/src/modules/Items/ItemValidator.service.ts +++ b/packages/server-nest/src/modules/Items/ItemValidator.service.ts @@ -8,13 +8,13 @@ import { ServiceError } from './ServiceError'; import { IItem, IItemDTO } from '@/interfaces/Item'; import { ERRORS } from './Items.constants'; import { Item } from './models/Item'; -import { AccountModel } from '../Accounts/models/Account.model'; +import { Account } from '../Accounts/models/Account.model'; @Injectable() export class ItemsValidators { constructor( @Inject(Item.name) private itemModel: typeof Item, - @Inject(AccountModel.name) private accountModel: typeof AccountModel, + @Inject(Account.name) private accountModel: typeof Account, @Inject(Item.name) private taxRateModel: typeof Item, @Inject(Item.name) private itemEntryModel: typeof Item, @Inject(Item.name) private itemCategoryModel: typeof Item, diff --git a/packages/server-nest/src/modules/Tenancy/TenancyModels/Tenancy.module.ts b/packages/server-nest/src/modules/Tenancy/TenancyModels/Tenancy.module.ts index fef4c1913..d6522be4f 100644 --- a/packages/server-nest/src/modules/Tenancy/TenancyModels/Tenancy.module.ts +++ b/packages/server-nest/src/modules/Tenancy/TenancyModels/Tenancy.module.ts @@ -3,11 +3,11 @@ import { Global, Module, Scope } from '@nestjs/common'; import { TENANCY_DB_CONNECTION } from '../TenancyDB/TenancyDB.constants'; import { Item } from '../../../modules/Items/models/Item'; -import { AccountModel } from '@/modules/Accounts/models/Account.model'; +import { Account } from '@/modules/Accounts/models/Account.model'; import { ItemEntry } from '@/modules/Items/models/ItemEntry'; import { AccountTransaction } from '@/modules/Accounts/models/AccountTransaction.model'; -const models = [Item, AccountModel, ItemEntry, AccountTransaction]; +const models = [Item, Account, ItemEntry, AccountTransaction]; const modelProviders = models.map((model) => { return { diff --git a/packages/server-nest/src/modules/Transformer/Transformer.ts b/packages/server-nest/src/modules/Transformer/Transformer.ts index 510de5280..488f2bc2b 100644 --- a/packages/server-nest/src/modules/Transformer/Transformer.ts +++ b/packages/server-nest/src/modules/Transformer/Transformer.ts @@ -165,7 +165,7 @@ export class Transformer { * @param {string} format * @returns {string} */ - protected formatDate(date: string, format?: string) { + protected formatDate(date: string | Date, format?: string) { // Use the export date format if the async operation is in exporting, // otherwise use the given or default format. const _format = this.context.exportAls.isExport diff --git a/packages/server-nest/src/utils/associate-item-entries-index.ts b/packages/server-nest/src/utils/associate-item-entries-index.ts new file mode 100644 index 000000000..839df2955 --- /dev/null +++ b/packages/server-nest/src/utils/associate-item-entries-index.ts @@ -0,0 +1,15 @@ +import { isNull, isUndefined } from 'lodash'; + +export function assocItemEntriesDefaultIndex( + entries: Array, +): Array { + return entries.map((entry, index) => { + return { + index: + isUndefined(entry.index) || isNull(entry.index) + ? index + 1 + : entry.index, + ...entry, + }; + }); +} diff --git a/packages/server-nest/tsconfig.build.json b/packages/server-nest/tsconfig.build.json index 760558e7a..78721cbbb 100644 --- a/packages/server-nest/tsconfig.build.json +++ b/packages/server-nest/tsconfig.build.json @@ -6,10 +6,11 @@ "dist", "**/*spec.ts", // "./src/modules/DynamicListing/**/*.ts", - "./src/modules/Export", - "./src/modules/Import", + "./src/modules/Import/**/*.ts", + "./src/modules/Export/**/*.ts", "./src/modules/DynamicListing", - // "./src/modules/DynamicListing", - "./src/modules/Views" + "./src/modules/DynamicListing/**/*.ts", + "./src/modules/Views", + "./src/modules/Expenses/subscribers" ] }