From bfff56c47074d74d25832b782eaae10c770fbc3b Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 16 Dec 2024 16:45:56 +0200 Subject: [PATCH] refactor: accounts module to Nestjs --- packages/server-nest/package.json | 1 + .../common/exceptions/ModelEntityNotFound.ts | 6 + .../common/repository/CachableRepository.ts | 260 ++++++++ .../src/common/repository/EntityRepository.ts | 232 +++++++ .../src/common/repository/TenantRepository.ts | 6 + .../modules/Accounts/Account.transformer.ts | 132 ++++ .../AccountTransaction.transformer.ts | 124 ++++ .../modules/Accounts/Accounts.constants.ts | 592 ++++++++++++++++++ .../modules/Accounts/Accounts.controller.ts | 72 +++ .../src/modules/Accounts/Accounts.module.ts | 38 ++ .../src/modules/Accounts/Accounts.types.ts | 94 +++ .../Accounts/AccountsApplication.service.ts | 133 ++++ .../Accounts/AccountsExportable.service.ts | 32 + .../Accounts/AccountsImportable.SampleData.ts | 50 ++ .../Accounts/AccountsImportable.service.ts | 45 ++ .../Accounts/ActivateAccount.service.ts | 53 ++ .../CommandAccountValidators.service.ts | 223 +++++++ .../src/modules/Accounts/CreateAccount.dto.ts | 51 ++ .../modules/Accounts/CreateAccount.service.ts | 139 ++++ .../modules/Accounts/DeleteAccount.service.ts | 80 +++ .../src/modules/Accounts/EditAccount.dto.ts | 34 + .../modules/Accounts/EditAccount.service.ts | 100 +++ .../modules/Accounts/GetAccount.service.ts | 46 ++ .../GetAccountTransactions.service.ts | 52 ++ .../Accounts/GetAccountTypes.service.ts | 17 + .../modules/Accounts/GetAccounts.service.ts | 68 ++ .../Accounts/MutateBaseCurrencyAccounts.ts | 22 + .../src/modules/Accounts/constants.ts | 103 +++ .../models/{Account.ts => Account.model.ts} | 32 +- .../models/AccountTransaction.model.ts | 218 +++++++ .../repositories/Account.repository.ts | 291 +++++++++ .../susbcribers/MutateBaseCurrencyAccounts.ts | 34 + .../Accounts/utils/AccountType.utils.ts | 101 +++ .../server-nest/src/modules/App/App.module.ts | 2 + .../src/modules/Items/Item.transformer.ts | 7 +- .../modules/Items/ItemValidator.service.ts | 4 +- .../Tenancy/TenancyModels/Tenancy.module.ts | 5 +- 37 files changed, 3482 insertions(+), 17 deletions(-) create mode 100644 packages/server-nest/src/common/exceptions/ModelEntityNotFound.ts create mode 100644 packages/server-nest/src/common/repository/CachableRepository.ts create mode 100644 packages/server-nest/src/common/repository/EntityRepository.ts create mode 100644 packages/server-nest/src/common/repository/TenantRepository.ts create mode 100644 packages/server-nest/src/modules/Accounts/Account.transformer.ts create mode 100644 packages/server-nest/src/modules/Accounts/AccountTransaction.transformer.ts create mode 100644 packages/server-nest/src/modules/Accounts/Accounts.constants.ts create mode 100644 packages/server-nest/src/modules/Accounts/Accounts.controller.ts create mode 100644 packages/server-nest/src/modules/Accounts/Accounts.module.ts create mode 100644 packages/server-nest/src/modules/Accounts/Accounts.types.ts create mode 100644 packages/server-nest/src/modules/Accounts/AccountsApplication.service.ts create mode 100644 packages/server-nest/src/modules/Accounts/AccountsExportable.service.ts create mode 100644 packages/server-nest/src/modules/Accounts/AccountsImportable.SampleData.ts create mode 100644 packages/server-nest/src/modules/Accounts/AccountsImportable.service.ts create mode 100644 packages/server-nest/src/modules/Accounts/ActivateAccount.service.ts create mode 100644 packages/server-nest/src/modules/Accounts/CommandAccountValidators.service.ts create mode 100644 packages/server-nest/src/modules/Accounts/CreateAccount.dto.ts create mode 100644 packages/server-nest/src/modules/Accounts/CreateAccount.service.ts create mode 100644 packages/server-nest/src/modules/Accounts/DeleteAccount.service.ts create mode 100644 packages/server-nest/src/modules/Accounts/EditAccount.dto.ts create mode 100644 packages/server-nest/src/modules/Accounts/EditAccount.service.ts create mode 100644 packages/server-nest/src/modules/Accounts/GetAccount.service.ts create mode 100644 packages/server-nest/src/modules/Accounts/GetAccountTransactions.service.ts create mode 100644 packages/server-nest/src/modules/Accounts/GetAccountTypes.service.ts create mode 100644 packages/server-nest/src/modules/Accounts/GetAccounts.service.ts create mode 100644 packages/server-nest/src/modules/Accounts/MutateBaseCurrencyAccounts.ts create mode 100644 packages/server-nest/src/modules/Accounts/constants.ts rename packages/server-nest/src/modules/Accounts/models/{Account.ts => Account.model.ts} (94%) create mode 100644 packages/server-nest/src/modules/Accounts/models/AccountTransaction.model.ts create mode 100644 packages/server-nest/src/modules/Accounts/repositories/Account.repository.ts create mode 100644 packages/server-nest/src/modules/Accounts/susbcribers/MutateBaseCurrencyAccounts.ts create mode 100644 packages/server-nest/src/modules/Accounts/utils/AccountType.utils.ts diff --git a/packages/server-nest/package.json b/packages/server-nest/package.json index cd08509d3..3405c2e3c 100644 --- a/packages/server-nest/package.json +++ b/packages/server-nest/package.json @@ -36,6 +36,7 @@ "@types/ramda": "^0.30.2", "js-money": "^0.6.3", "accounting": "^0.4.1", + "object-hash": "^2.0.3", "bull": "^4.16.3", "bullmq": "^5.21.1", "cache-manager": "^6.1.1", diff --git a/packages/server-nest/src/common/exceptions/ModelEntityNotFound.ts b/packages/server-nest/src/common/exceptions/ModelEntityNotFound.ts new file mode 100644 index 000000000..4b378a56c --- /dev/null +++ b/packages/server-nest/src/common/exceptions/ModelEntityNotFound.ts @@ -0,0 +1,6 @@ +export class ModelEntityNotFound extends Error { + constructor(entityId, message?) { + message = message || `Entity with id ${entityId} does not exist`; + super(message); + } +} diff --git a/packages/server-nest/src/common/repository/CachableRepository.ts b/packages/server-nest/src/common/repository/CachableRepository.ts new file mode 100644 index 000000000..f292e78ff --- /dev/null +++ b/packages/server-nest/src/common/repository/CachableRepository.ts @@ -0,0 +1,260 @@ +// import hashObject from 'object-hash'; +// import { EntityRepository } from './EntityRepository'; + +// export class CachableRepository extends EntityRepository { +// repositoryName: string; +// cache: any; +// i18n: any; + +// /** +// * Constructor method. +// * @param {Knex} knex +// * @param {Cache} cache +// */ +// constructor(knex, cache, i18n) { +// super(knex); + +// this.cache = cache; +// this.i18n = i18n; +// this.repositoryName = this.constructor.name; +// } + +// getByCache(key, callback) { +// return callback(); +// } + +// /** +// * Retrieve the cache key of the method name and arguments. +// * @param {string} method +// * @param {...any} args +// * @return {string} +// */ +// getCacheKey(method, ...args) { +// const hashArgs = hashObject({ ...args }); +// const repositoryName = this.repositoryName; + +// return `${repositoryName}-${method}-${hashArgs}`; +// } + +// /** +// * Retrieve all entries with specified relations. +// * @param withRelations +// */ +// all(withRelations?, trx?) { +// const cacheKey = this.getCacheKey('all', withRelations); + +// return this.getByCache(cacheKey, () => { +// return super.all(withRelations, trx); +// }); +// } + +// /** +// * Finds list of entities with specified attributes +// * @param {Object} attributeValues - values to filter retrieved entities by +// * @param {string || string[]} [withRelations] - name of relation(s) to eagerly retrieve. +// * @returns {Promise} - query builder. You can chain additional methods to it or call "await" or then() on it to execute +// */ +// find(attributeValues = {}, withRelations?) { +// const cacheKey = this.getCacheKey('find', attributeValues, withRelations); + +// return this.getByCache(cacheKey, () => { +// return super.find(attributeValues, withRelations); +// }); +// } + +// /** +// * Finds list of entities with attribute values that are different from specified ones +// * @param {Object} attributeValues - values to filter retrieved entities by +// * @param {string || string[]} [withRelations] - name of relation(s) to eagerly retrieve, as defined in model relationMappings() +// * @returns {Promise} - query builder. You can chain additional methods to it or call "await" or then() on it to execute +// */ +// findWhereNot(attributeValues = {}, withRelations?) { +// const cacheKey = this.getCacheKey( +// 'findWhereNot', +// attributeValues, +// withRelations +// ); + +// return this.getByCache(cacheKey, () => { +// return super.findWhereNot(attributeValues, withRelations); +// }); +// } + +// /** +// * Finds list of entities with specified attributes (any of multiple specified values) +// * Supports both ('attrName', ['value1', 'value2]) and ({attrName: ['value1', 'value2']} formats) +// * +// * @param {string|Object} searchParam - attribute name or search criteria object +// * @param {*[]} [attributeValues] - attribute values to filter retrieved entities by +// * @param {string || string[]} [withRelations] - name of relation(s) to eagerly retrieve, as defined in model relationMappings() +// * @returns {PromiseLike} - query builder. You can chain additional methods to it or call "await" or then() on it to execute +// */ +// findWhereIn(searchParam, attributeValues, withRelations?) { +// const cacheKey = this.getCacheKey( +// 'findWhereIn', +// attributeValues, +// withRelations +// ); + +// return this.getByCache(cacheKey, () => { +// return super.findWhereIn(searchParam, attributeValues, withRelations); +// }); +// } + +// /** +// * Finds first entity by given parameters +// * +// * @param {Object} attributeValues - values to filter retrieved entities by +// * @param {string || string[]} [withRelations] - name of relation(s) to eagerly retrieve, as defined in model relationMappings() +// * @returns {Promise} +// */ +// findOne(attributeValues = {}, withRelations?) { +// const cacheKey = this.getCacheKey( +// 'findOne', +// attributeValues, +// withRelations +// ); +// return this.getByCache(cacheKey, () => { +// return super.findOne(attributeValues, withRelations); +// }); +// } + +// /** +// * Finds first entity by given parameters +// * +// * @param {string || number} id - value of id column of the entity +// * @param {string || string[]} [withRelations] - name of relation(s) to eagerly retrieve, as defined in model relationMappings() +// * @returns {Promise} +// */ +// findOneById(id, withRelations?) { +// const cacheKey = this.getCacheKey('findOneById', id, withRelations); + +// return this.getByCache(cacheKey, () => { +// return super.findOneById(id, withRelations); +// }); +// } + +// /** +// * Persists new entity or an array of entities. +// * This method does not recursively persist related entities, use createRecursively (to be implemented) for that. +// * Batch insert only works on PostgreSQL +// * @param {Object} entity - model instance or parameters for a new entity +// * @returns {Promise} - query builder. You can chain additional methods to it or call "await" or then() on it to execute +// */ +// async create(entity, trx?) { +// const result = await super.create(entity, trx); + +// // Flushes the repository cache after insert operation. +// this.flushCache(); + +// return result; +// } + +// /** +// * Persists updated entity. If previously set fields are not present, performs an incremental update (does not remove fields unless explicitly set to null) +// * +// * @param {Object} entity - single entity instance +// * @param {Object} [trx] - knex transaction instance. If not specified, new implicit transaction will be used. +// * @returns {Promise} number of affected rows +// */ +// async update(entity, whereAttributes?, trx?) { +// const result = await super.update(entity, whereAttributes, trx); + +// // Flushes the repository cache after update operation. +// this.flushCache(); + +// return result; +// } + +// /** +// * @param {Object} attributeValues - values to filter deleted entities by +// * @param {Object} [trx] +// * @returns {Promise} Query builder. After promise is resolved, returns count of deleted rows +// */ +// async deleteBy(attributeValues, trx?) { +// const result = await super.deleteBy(attributeValues, trx); +// this.flushCache(); + +// return result; +// } + +// /** +// * @param {string || number} id - value of id column of the entity +// * @returns {Promise} Query builder. After promise is resolved, returns count of deleted rows +// */ +// deleteById(id: number | string, trx?) { +// const result = super.deleteById(id, trx); + +// // Flushes the repository cache after insert operation. +// this.flushCache(); + +// return result; +// } + +// /** +// * +// * @param {string|number[]} values - +// */ +// async deleteWhereIn(field: string, values: string | number[]) { +// const result = await super.deleteWhereIn(field, values); + +// // Flushes the repository cache after delete operation. +// this.flushCache(); + +// return result; +// } + +// /** +// * +// * @param {string|number[]} values +// */ +// async deleteWhereIdIn(values: string | number[], trx?) { +// const result = await super.deleteWhereIdIn(values, trx); + +// // Flushes the repository cache after delete operation. +// this.flushCache(); + +// return result; +// } + +// /** +// * +// * @param graph +// * @param options +// */ +// async upsertGraph(graph, options) { +// const result = await super.upsertGraph(graph, options); + +// // Flushes the repository cache after insert operation. +// this.flushCache(); + +// return result; +// } + +// /** +// * +// * @param {} whereAttributes +// * @param {string} field +// * @param {number} amount +// */ +// async changeNumber(whereAttributes, field: string, amount: number, trx?) { +// const result = await super.changeNumber( +// whereAttributes, +// field, +// amount, +// trx +// ); + +// // Flushes the repository cache after update operation. +// this.flushCache(); + +// return result; +// } + +// /** +// * Flush repository cache. +// */ +// flushCache(): void { +// this.cache.delStartWith(this.repositoryName); +// } +// } diff --git a/packages/server-nest/src/common/repository/EntityRepository.ts b/packages/server-nest/src/common/repository/EntityRepository.ts new file mode 100644 index 000000000..2694ba309 --- /dev/null +++ b/packages/server-nest/src/common/repository/EntityRepository.ts @@ -0,0 +1,232 @@ +import { cloneDeep, forOwn, isString } from 'lodash'; +import { ModelEntityNotFound } from '../exceptions/ModelEntityNotFound'; +import { Model } from 'objection'; + +function applyGraphFetched(withRelations, builder) { + const relations = Array.isArray(withRelations) + ? withRelations + : typeof withRelations === 'string' + ? withRelations.split(',').map((relation) => relation.trim()) + : []; + + relations.forEach((relation) => { + builder.withGraphFetched(relation); + }); +} + +export class EntityRepository { + idColumn: string = 'id'; + knex: any; + + /** + * Retrieve the repository model binded it to knex instance. + */ + get model(): typeof Model { + throw new Error("The repository's model is not defined."); + } + + /** + * Retrieve all entries with specified relations. + * @param withRelations + */ + all(withRelations?, trx?) { + const builder = this.model.query(trx); + applyGraphFetched(withRelations, builder); + + return builder; + } + + /** + * Finds list of entities with specified attributes + * + * @param {Object} attributeValues - values to filter retrieved entities by + * @param {string || string[]} [withRelations] - name of relation(s) to eagerly retrieve. + * @returns {Promise} - query builder. You can chain additional methods to it or call "await" or then() on it to execute + */ + find(attributeValues = {}, withRelations?) { + const builder = this.model.query().where(attributeValues); + + applyGraphFetched(withRelations, builder); + return builder; + } + + /** + * Finds list of entities with attribute values that are different from specified ones + * + * @param {Object} attributeValues - values to filter retrieved entities by + * @param {string || string[]} [withRelations] - name of relation(s) to eagerly retrieve, as defined in model relationMappings() + * @returns {PromiseLike} - query builder. You can chain additional methods to it or call "await" or then() on it to execute + */ + findWhereNot(attributeValues = {}, withRelations?) { + const builder = this.model.query().whereNot(attributeValues); + + applyGraphFetched(withRelations, builder); + return builder; + } + + /** + * Finds list of entities with specified attributes (any of multiple specified values) + * Supports both ('attrName', ['value1', 'value2]) and ({attrName: ['value1', 'value2']} formats) + * + * @param {string|Object} searchParam - attribute name or search criteria object + * @param {*[]} [attributeValues] - attribute values to filter retrieved entities by + * @param {string || string[]} [withRelations] - name of relation(s) to eagerly retrieve, as defined in model relationMappings() + * @returns {PromiseLike} - query builder. You can chain additional methods to it or call "await" or then() on it to execute + */ + findWhereIn(searchParam, attributeValues, withRelations?) { + const commonBuilder = (builder) => { + applyGraphFetched(withRelations, builder); + }; + if (isString(searchParam)) { + return this.model + .query() + .whereIn(searchParam, attributeValues) + .onBuild(commonBuilder); + } else { + const builder = this.model.query(this.knex).onBuild(commonBuilder); + + forOwn(searchParam, (value, key) => { + if (Array.isArray(value)) { + builder.whereIn(key, value); + } else { + builder.where(key, value); + } + }); + return builder; + } + } + + /** + * Finds first entity by given parameters + * + * @param {Object} attributeValues - values to filter retrieved entities by + * @param {string || string[]} [withRelations] - name of relation(s) to eagerly retrieve, as defined in model relationMappings() + * @returns {Promise} + */ + async findOne(attributeValues = {}, withRelations?) { + const results = await this.find(attributeValues, withRelations); + return results[0] || null; + } + + /** + * Finds first entity by given parameters + * + * @param {string || number} id - value of id column of the entity + * @param {string || string[]} [withRelations] - name of relation(s) to eagerly retrieve, as defined in model relationMappings() + * @returns {Promise} + */ + findOneById(id, withRelations?) { + return this.findOne({ [this.idColumn]: id }, withRelations); + } + + /** + * Persists new entity or an array of entities. + * This method does not recursively persist related entities, use createRecursively (to be implemented) for that. + * Batch insert only works on PostgreSQL + * + * @param {Object} entity - model instance or parameters for a new entity + * @returns {Promise} - query builder. You can chain additional methods to it or call "await" or then() on it to execute + */ + create(entity, trx?) { + // Keep the input parameter immutable + const instanceDTO = cloneDeep(entity); + + return this.model.query(trx).insert(instanceDTO); + } + + /** + * Persists updated entity. If previously set fields are not present, performs an incremental update (does not remove fields unless explicitly set to null) + * + * @param {Object} entity - single entity instance + * @returns {Promise} number of affected rows + */ + async update(entity, whereAttributes?, trx?) { + const entityDto = cloneDeep(entity); + const identityClause = {}; + + if (Array.isArray(this.idColumn)) { + this.idColumn.forEach( + (idColumn) => (identityClause[idColumn] = entityDto[idColumn]), + ); + } else { + identityClause[this.idColumn] = entityDto[this.idColumn]; + } + const whereConditions = whereAttributes || identityClause; + const modifiedEntitiesCount = await this.model + .query(trx) + .where(whereConditions) + .update(entityDto); + + if (modifiedEntitiesCount === 0) { + throw new ModelEntityNotFound(entityDto[this.idColumn]); + } + return modifiedEntitiesCount; + } + + /** + * + * @param {Object} attributeValues - values to filter deleted entities by + * @param {Object} [trx] + * @returns {Promise} Query builder. After promise is resolved, returns count of deleted rows + */ + deleteBy(attributeValues, trx?) { + return this.model.query(trx).delete().where(attributeValues); + } + + /** + * @param {string || number} id - value of id column of the entity + * @returns {Promise} Query builder. After promise is resolved, returns count of deleted rows + */ + deleteById(id: number | string, trx?) { + return this.deleteBy( + { + [this.idColumn]: id, + }, + trx, + ); + } + + /** + * Deletes the given entries in the array on the specific field. + * @param {string} field - + * @param {number|string} values - + */ + deleteWhereIn(field: string, values: (string | number)[], trx) { + return this.model.query(trx).whereIn(field, values).delete(); + } + + /** + * + * @param {string|number[]} values + */ + deleteWhereIdIn(values: (string | number)[], trx?) { + return this.deleteWhereIn(this.idColumn, values, trx); + } + + /** + * Arbitrary relation graphs can be upserted (insert + update + delete) + * using the upsertGraph method. + * @param graph + * @param options + */ + upsertGraph(graph, options) { + // Keep the input grpah immutable + const graphCloned = cloneDeep(graph); + return this.model.query().upsertGraph(graphCloned, options); + } + + /** + * + * @param {object} whereAttributes + * @param {string} field + * @param amount + */ + changeNumber(whereAttributes, field: string, amount: number, trx) { + const changeMethod = amount > 0 ? 'increment' : 'decrement'; + + return this.model + .query(trx) + .where(whereAttributes) + [changeMethod](field, Math.abs(amount)); + } +} diff --git a/packages/server-nest/src/common/repository/TenantRepository.ts b/packages/server-nest/src/common/repository/TenantRepository.ts new file mode 100644 index 000000000..f226e286a --- /dev/null +++ b/packages/server-nest/src/common/repository/TenantRepository.ts @@ -0,0 +1,6 @@ +// import { CachableRepository } from './CachableRepository'; +import { EntityRepository } from './EntityRepository'; + +export class TenantRepository extends EntityRepository { + +} diff --git a/packages/server-nest/src/modules/Accounts/Account.transformer.ts b/packages/server-nest/src/modules/Accounts/Account.transformer.ts new file mode 100644 index 000000000..94ac3cee1 --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/Account.transformer.ts @@ -0,0 +1,132 @@ +// import { IAccountsStructureType } from './Accounts.types'; +// import { +// assocDepthLevelToObjectTree, +// flatToNestedArray, +// nestedArrayToFlatten, +// } from 'utils'; +import { Transformer } from '../Transformer/Transformer'; +import { AccountModel } from './models/Account.model'; + +export class AccountTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'accountTypeLabel', + 'accountNormalFormatted', + 'formattedAmount', + 'flattenName', + 'bankBalanceFormatted', + 'lastFeedsUpdatedAtFormatted', + 'isFeedsPaused', + ]; + }; + + /** + * Exclude attributes. + * @returns {string[]} + */ + public excludeAttributes = (): string[] => { + return ['plaidItem']; + }; + + /** + * Retrieves the flatten name with all dependants accounts names. + * @param {IAccount} account - + * @returns {string} + */ + public flattenName = (account: AccountModel): string => { + const parentDependantsIds = this.options.accountsGraph.dependantsOf( + account.id, + ); + const prefixAccounts = parentDependantsIds.map((dependId) => { + const node = this.options.accountsGraph.getNodeData(dependId); + return `${node.name}: `; + }); + return `${prefixAccounts}${account.name}`; + }; + + /** + * Retrieve formatted account amount. + * @param {IAccount} invoice + * @returns {string} + */ + protected formattedAmount = (account: AccountModel): string => { + return this.formatNumber(account.amount, { + currencyCode: account.currencyCode, + }); + }; + + /** + * Retrieves the formatted bank balance. + * @param {AccountModel} account + * @returns {string} + */ + protected bankBalanceFormatted = (account: AccountModel): string => { + return this.formatNumber(account.bankBalance, { + currencyCode: account.currencyCode, + }); + }; + + /** + * Retrieves the formatted last feeds update at. + * @param {IAccount} account + * @returns {string} + */ + protected lastFeedsUpdatedAtFormatted = (account: AccountModel): string => { + return account.lastFeedsUpdatedAt + ? this.formatDate(account.lastFeedsUpdatedAt) + : ''; + }; + + /** + * Detarmines whether the bank account connection is paused. + * @param account + * @returns {boolean} + */ + protected isFeedsPaused = (account: AccountModel): boolean => { + // return account.plaidItem?.isPaused || false; + + return false; + }; + + /** + * Retrieves formatted account type label. + * @returns {string} + */ + protected accountTypeLabel = (account: AccountModel): string => { + return this.context.i18n.t(account.accountTypeLabel); + }; + + /** + * Retrieves formatted account normal. + * @returns {string} + */ + protected accountNormalFormatted = (account: AccountModel): string => { + return this.context.i18n.t(account.accountNormalFormatted); + }; + + /** + * Transformes the accounts collection to flat or nested array. + * @param {IAccount[]} + * @returns {IAccount[]} + */ + // protected postCollectionTransform = (accounts: AccountModel[]) => { + // // Transfom the flatten to accounts tree. + // const transformed = flatToNestedArray(accounts, { + // id: 'id', + // parentId: 'parentAccountId', + // }); + // // Associate `accountLevel` attr to indicate object depth. + // const transformed2 = assocDepthLevelToObjectTree( + // transformed, + // 1, + // 'accountLevel', + // ); + // return this.options.structure === IAccountsStructureType.Flat + // ? nestedArrayToFlatten(transformed2) + // : transformed2; + // }; +} diff --git a/packages/server-nest/src/modules/Accounts/AccountTransaction.transformer.ts b/packages/server-nest/src/modules/Accounts/AccountTransaction.transformer.ts new file mode 100644 index 000000000..46d98a548 --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/AccountTransaction.transformer.ts @@ -0,0 +1,124 @@ +import { Transformer } from '../Transformer/Transformer'; +import { AccountTransaction } from './models/AccountTransaction.model'; + +export class AccountTransactionTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'date', + 'formattedDate', + 'transactionType', + 'transactionId', + 'transactionTypeFormatted', + 'credit', + 'debit', + 'formattedCredit', + 'formattedDebit', + 'fcCredit', + 'fcDebit', + 'formattedFcCredit', + 'formattedFcDebit', + ]; + }; + + /** + * Exclude all attributes of the model. + * @returns {Array} + */ + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Retrieves the formatted date. + * @returns {string} + */ + public formattedDate(transaction: AccountTransaction) { + return this.formatDate(transaction.date); + } + + /** + * Retrieves the formatted transaction type. + * @returns {string} + */ + public transactionTypeFormatted(transaction: AccountTransaction) { + return this.context.i18n.t(transaction.referenceTypeFormatted); + } + + /** + * Retrieves the tranasction type. + * @returns {string} + */ + public transactionType(transaction: AccountTransaction) { + return transaction.referenceType; + } + + /** + * Retrieves the transaction id. + * @returns {number} + */ + public transactionId(transaction: AccountTransaction) { + return transaction.referenceId; + } + + /** + * Retrieves the credit amount. + * @returns {string} + */ + protected formattedCredit(transaction: AccountTransaction) { + return this.formatMoney(transaction.credit, { + excerptZero: true, + }); + } + + /** + * Retrieves the credit amount. + * @returns {string} + */ + protected formattedDebit(transaction: AccountTransaction) { + return this.formatMoney(transaction.debit, { + excerptZero: true, + }); + } + + /** + * Retrieves the foreign credit amount. + * @returns {number} + */ + protected fcCredit(transaction: AccountTransaction) { + return transaction.credit * transaction.exchangeRate; + } + + /** + * Retrieves the foreign debit amount. + * @returns {number} + */ + protected fcDebit(transaction: AccountTransaction) { + return transaction.debit * transaction.exchangeRate; + } + + /** + * Retrieves the formatted foreign credit amount. + * @returns {string} + */ + protected formattedFcCredit(transaction: AccountTransaction) { + return this.formatMoney(this.fcCredit(transaction), { + currencyCode: transaction.currencyCode, + excerptZero: true, + }); + } + + /** + * Retrieves the formatted foreign debit amount. + * @returns {string} + */ + protected formattedFcDebit(transaction: AccountTransaction) { + return this.formatMoney(this.fcDebit(transaction), { + currencyCode: transaction.currencyCode, + excerptZero: true, + }); + } +} diff --git a/packages/server-nest/src/modules/Accounts/Accounts.constants.ts b/packages/server-nest/src/modules/Accounts/Accounts.constants.ts new file mode 100644 index 000000000..b37dbd218 --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/Accounts.constants.ts @@ -0,0 +1,592 @@ +export const TaxPayableAccount = { + name: 'Tax Payable', + slug: 'tax-payable', + account_type: 'tax-payable', + code: '20006', + description: '', + active: 1, + index: 1, + predefined: 1, +}; + +export const UnearnedRevenueAccount = { + name: 'Unearned Revenue', + slug: 'unearned-revenue', + account_type: 'other-current-liability', + parent_account_id: null, + code: '50005', + active: true, + index: 1, + predefined: true, +}; + +export const PrepardExpenses = { + name: 'Prepaid Expenses', + slug: 'prepaid-expenses', + account_type: 'other-current-asset', + parent_account_id: null, + code: '100010', + active: true, + index: 1, + predefined: true, +}; + +export const StripeClearingAccount = { + name: 'Stripe Clearing', + slug: 'stripe-clearing', + account_type: 'other-current-asset', + parent_account_id: null, + code: '100020', + active: true, + index: 1, + predefined: true, +}; + +export const SeedAccounts = [ + { + name: 'Bank Account', + slug: 'bank-account', + account_type: 'bank', + code: '10001', + description: '', + active: 1, + index: 1, + predefined: 1, + }, + { + name: 'Saving Bank Account', + slug: 'saving-bank-account', + account_type: 'bank', + code: '10002', + description: '', + active: 1, + index: 1, + predefined: 0, + }, + { + name: 'Undeposited Funds', + slug: 'undeposited-funds', + account_type: 'cash', + code: '10003', + description: '', + active: 1, + index: 1, + predefined: 1, + }, + { + name: 'Petty Cash', + slug: 'petty-cash', + account_type: 'cash', + code: '10004', + description: '', + active: 1, + index: 1, + predefined: 1, + }, + { + name: 'Computer Equipment', + slug: 'computer-equipment', + code: '10005', + account_type: 'fixed-asset', + predefined: 0, + parent_account_id: null, + index: 1, + active: 1, + description: '', + }, + { + name: 'Office Equipment', + slug: 'office-equipment', + code: '10006', + account_type: 'fixed-asset', + predefined: 0, + parent_account_id: null, + index: 1, + active: 1, + description: '', + }, + { + name: 'Accounts Receivable (A/R)', + slug: 'accounts-receivable', + account_type: 'accounts-receivable', + code: '10007', + description: '', + active: 1, + index: 1, + predefined: 1, + }, + { + name: 'Inventory Asset', + slug: 'inventory-asset', + code: '10008', + account_type: 'inventory', + predefined: 1, + parent_account_id: null, + index: 1, + active: 1, + description: + 'An account that holds valuation of products or goods that available for sale.', + }, + + // Libilities + { + name: 'Accounts Payable (A/P)', + slug: 'accounts-payable', + account_type: 'accounts-payable', + parent_account_id: null, + code: '20001', + description: '', + active: 1, + index: 1, + predefined: 1, + }, + { + name: 'Owner A Drawings', + slug: 'owner-drawings', + account_type: 'other-current-liability', + parent_account_id: null, + code: '20002', + description: 'Withdrawals by the owners.', + active: 1, + index: 1, + predefined: 0, + }, + { + name: 'Loan', + slug: 'owner-drawings', + account_type: 'other-current-liability', + code: '20003', + description: 'Money that has been borrowed from a creditor.', + active: 1, + index: 1, + predefined: 0, + }, + { + name: 'Opening Balance Liabilities', + slug: 'opening-balance-liabilities', + account_type: 'other-current-liability', + code: '20004', + description: + 'This account will hold the difference in the debits and credits entered during the opening balance..', + active: 1, + index: 1, + predefined: 0, + }, + { + name: 'Revenue Received in Advance', + slug: 'revenue-received-in-advance', + account_type: 'other-current-liability', + parent_account_id: null, + code: '20005', + description: 'When customers pay in advance for products/services.', + active: 1, + index: 1, + predefined: 0, + }, + TaxPayableAccount, + + // Equity + { + name: 'Retained Earnings', + slug: 'retained-earnings', + account_type: 'equity', + code: '30001', + description: + 'Retained earnings tracks net income from previous fiscal years.', + active: 1, + index: 1, + predefined: 1, + }, + { + name: 'Opening Balance Equity', + slug: 'opening-balance-equity', + account_type: 'equity', + code: '30002', + description: + 'When you enter opening balances to the accounts, the amounts enter in Opening balance equity. This ensures that you have a correct trial balance sheet for your company, without even specific the second credit or debit entry.', + active: 1, + index: 1, + predefined: 1, + }, + { + name: "Owner's Equity", + slug: 'owner-equity', + account_type: 'equity', + code: '30003', + description: '', + active: 1, + index: 1, + predefined: 1, + }, + { + name: `Drawings`, + slug: 'drawings', + account_type: 'equity', + code: '30003', + description: + 'Goods purchased with the intention of selling these to customers', + active: 1, + index: 1, + predefined: 1, + }, + + // Expenses + { + name: 'Other Expenses', + slug: 'other-expenses', + account_type: 'other-expense', + parent_account_id: null, + code: '40001', + description: '', + active: 1, + index: 1, + predefined: 1, + }, + { + name: 'Cost of Goods Sold', + slug: 'cost-of-goods-sold', + account_type: 'cost-of-goods-sold', + parent_account_id: null, + code: '40002', + description: 'Tracks the direct cost of the goods sold.', + active: 1, + index: 1, + predefined: 1, + }, + { + name: 'Office expenses', + slug: 'office-expenses', + account_type: 'expense', + parent_account_id: null, + code: '40003', + description: '', + active: 1, + index: 1, + predefined: 0, + }, + { + name: 'Rent', + slug: 'rent', + account_type: 'expense', + parent_account_id: null, + code: '40004', + description: '', + active: 1, + index: 1, + predefined: 0, + }, + { + name: 'Exchange Gain or Loss', + slug: 'exchange-grain-loss', + account_type: 'other-expense', + parent_account_id: null, + code: '40005', + description: 'Tracks the gain and losses of the exchange differences.', + active: 1, + index: 1, + predefined: 1, + }, + { + name: 'Bank Fees and Charges', + slug: 'bank-fees-and-charges', + account_type: 'expense', + parent_account_id: null, + code: '40006', + description: + 'Any bank fees levied is recorded into the bank fees and charges account. A bank account maintenance fee, transaction charges, a late payment fee are some examples.', + active: 1, + index: 1, + predefined: 0, + }, + { + name: 'Depreciation Expense', + slug: 'depreciation-expense', + account_type: 'expense', + parent_account_id: null, + code: '40007', + description: '', + active: 1, + index: 1, + predefined: 0, + }, + + // Income + { + name: 'Sales of Product Income', + slug: 'sales-of-product-income', + account_type: 'income', + predefined: 1, + parent_account_id: null, + code: '50001', + index: 1, + active: 1, + description: '', + }, + { + name: 'Sales of Service Income', + slug: 'sales-of-service-income', + account_type: 'income', + predefined: 0, + parent_account_id: null, + code: '50002', + index: 1, + active: 1, + description: '', + }, + { + name: 'Uncategorized Income', + slug: 'uncategorized-income', + account_type: 'income', + parent_account_id: null, + code: '50003', + description: '', + active: 1, + index: 1, + predefined: 1, + }, + { + name: 'Other Income', + slug: 'other-income', + account_type: 'other-income', + parent_account_id: null, + code: '50004', + description: + 'The income activities are not associated to the core business.', + active: 1, + index: 1, + predefined: 0, + }, + UnearnedRevenueAccount, + PrepardExpenses, +]; + +export const ACCOUNT_TYPE = { + CASH: 'cash', + BANK: 'bank', + ACCOUNTS_RECEIVABLE: 'accounts-receivable', + INVENTORY: 'inventory', + OTHER_CURRENT_ASSET: 'other-current-asset', + FIXED_ASSET: 'fixed-asset', + NON_CURRENT_ASSET: 'none-current-asset', + + ACCOUNTS_PAYABLE: 'accounts-payable', + CREDIT_CARD: 'credit-card', + TAX_PAYABLE: 'tax-payable', + OTHER_CURRENT_LIABILITY: 'other-current-liability', + LOGN_TERM_LIABILITY: 'long-term-liability', + NON_CURRENT_LIABILITY: 'non-current-liability', + + EQUITY: 'equity', + INCOME: 'income', + OTHER_INCOME: 'other-income', + COST_OF_GOODS_SOLD: 'cost-of-goods-sold', + EXPENSE: 'expense', + OTHER_EXPENSE: 'other-expense', +}; + +export const ACCOUNT_PARENT_TYPE = { + CURRENT_ASSET: 'current-asset', + FIXED_ASSET: 'fixed-asset', + NON_CURRENT_ASSET: 'non-current-asset', + + CURRENT_LIABILITY: 'current-liability', + LOGN_TERM_LIABILITY: 'long-term-liability', + NON_CURRENT_LIABILITY: 'non-current-liability', + + EQUITY: 'equity', + EXPENSE: 'expense', + INCOME: 'income', +}; + +export const ACCOUNT_ROOT_TYPE = { + ASSET: 'asset', + LIABILITY: 'liability', + EQUITY: 'equity', + EXPENSE: 'expense', + INCOME: 'income', +}; + +export const ACCOUNT_NORMAL = { + CREDIT: 'credit', + DEBIT: 'debit', +}; +export const ACCOUNT_TYPES = [ + { + label: 'Cash', + key: ACCOUNT_TYPE.CASH, + normal: ACCOUNT_NORMAL.DEBIT, + parentType: ACCOUNT_PARENT_TYPE.CURRENT_ASSET, + rootType: ACCOUNT_ROOT_TYPE.ASSET, + multiCurrency: true, + balanceSheet: true, + incomeSheet: false, + }, + { + label: 'Bank', + key: ACCOUNT_TYPE.BANK, + normal: ACCOUNT_NORMAL.DEBIT, + parentType: ACCOUNT_PARENT_TYPE.CURRENT_ASSET, + rootType: ACCOUNT_ROOT_TYPE.ASSET, + multiCurrency: true, + balanceSheet: true, + incomeSheet: false, + }, + { + label: 'Accounts Receivable', + key: ACCOUNT_TYPE.ACCOUNTS_RECEIVABLE, + normal: ACCOUNT_NORMAL.DEBIT, + rootType: ACCOUNT_ROOT_TYPE.ASSET, + parentType: ACCOUNT_PARENT_TYPE.CURRENT_ASSET, + balanceSheet: true, + incomeSheet: false, + }, + { + label: 'Inventory', + key: ACCOUNT_TYPE.INVENTORY, + normal: ACCOUNT_NORMAL.DEBIT, + rootType: ACCOUNT_ROOT_TYPE.ASSET, + parentType: ACCOUNT_PARENT_TYPE.CURRENT_ASSET, + balanceSheet: true, + incomeSheet: false, + }, + { + label: 'Other Current Asset', + key: ACCOUNT_TYPE.OTHER_CURRENT_ASSET, + normal: ACCOUNT_NORMAL.DEBIT, + rootType: ACCOUNT_ROOT_TYPE.ASSET, + parentType: ACCOUNT_PARENT_TYPE.CURRENT_ASSET, + balanceSheet: true, + incomeSheet: false, + }, + { + label: 'Fixed Asset', + key: ACCOUNT_TYPE.FIXED_ASSET, + normal: ACCOUNT_NORMAL.DEBIT, + rootType: ACCOUNT_ROOT_TYPE.ASSET, + parentType: ACCOUNT_PARENT_TYPE.FIXED_ASSET, + balanceSheet: true, + incomeSheet: false, + }, + { + label: 'Non-Current Asset', + key: ACCOUNT_TYPE.NON_CURRENT_ASSET, + normal: ACCOUNT_NORMAL.DEBIT, + rootType: ACCOUNT_ROOT_TYPE.ASSET, + parentType: ACCOUNT_PARENT_TYPE.FIXED_ASSET, + balanceSheet: true, + incomeSheet: false, + }, + { + label: 'Accounts Payable', + key: ACCOUNT_TYPE.ACCOUNTS_PAYABLE, + normal: ACCOUNT_NORMAL.CREDIT, + rootType: ACCOUNT_ROOT_TYPE.LIABILITY, + parentType: ACCOUNT_PARENT_TYPE.CURRENT_LIABILITY, + balanceSheet: true, + incomeSheet: false, + }, + { + label: 'Credit Card', + key: ACCOUNT_TYPE.CREDIT_CARD, + normal: ACCOUNT_NORMAL.CREDIT, + rootType: ACCOUNT_ROOT_TYPE.LIABILITY, + parentType: ACCOUNT_PARENT_TYPE.CURRENT_LIABILITY, + multiCurrency: true, + balanceSheet: true, + incomeSheet: false, + }, + { + label: 'Tax Payable', + key: ACCOUNT_TYPE.TAX_PAYABLE, + normal: ACCOUNT_NORMAL.CREDIT, + rootType: ACCOUNT_ROOT_TYPE.LIABILITY, + parentType: ACCOUNT_PARENT_TYPE.CURRENT_LIABILITY, + balanceSheet: true, + incomeSheet: false, + }, + { + label: 'Other Current Liability', + key: ACCOUNT_TYPE.OTHER_CURRENT_LIABILITY, + normal: ACCOUNT_NORMAL.CREDIT, + rootType: ACCOUNT_ROOT_TYPE.LIABILITY, + parentType: ACCOUNT_PARENT_TYPE.CURRENT_LIABILITY, + balanceSheet: false, + incomeSheet: true, + }, + { + label: 'Long Term Liability', + key: ACCOUNT_TYPE.LOGN_TERM_LIABILITY, + normal: ACCOUNT_NORMAL.CREDIT, + rootType: ACCOUNT_ROOT_TYPE.LIABILITY, + parentType: ACCOUNT_PARENT_TYPE.LOGN_TERM_LIABILITY, + balanceSheet: false, + incomeSheet: true, + }, + { + label: 'Non-Current Liability', + key: ACCOUNT_TYPE.NON_CURRENT_LIABILITY, + normal: ACCOUNT_NORMAL.CREDIT, + rootType: ACCOUNT_ROOT_TYPE.LIABILITY, + parentType: ACCOUNT_PARENT_TYPE.NON_CURRENT_LIABILITY, + balanceSheet: false, + incomeSheet: true, + }, + { + label: 'Equity', + key: ACCOUNT_TYPE.EQUITY, + normal: ACCOUNT_NORMAL.CREDIT, + rootType: ACCOUNT_ROOT_TYPE.EQUITY, + parentType: ACCOUNT_PARENT_TYPE.EQUITY, + balanceSheet: true, + incomeSheet: false, + }, + { + label: 'Income', + key: ACCOUNT_TYPE.INCOME, + normal: ACCOUNT_NORMAL.CREDIT, + rootType: ACCOUNT_ROOT_TYPE.INCOME, + parentType: ACCOUNT_PARENT_TYPE.INCOME, + balanceSheet: false, + incomeSheet: true, + }, + { + label: 'Other Income', + key: ACCOUNT_TYPE.OTHER_INCOME, + normal: ACCOUNT_NORMAL.CREDIT, + rootType: ACCOUNT_ROOT_TYPE.INCOME, + parentType: ACCOUNT_PARENT_TYPE.INCOME, + balanceSheet: false, + incomeSheet: true, + }, + { + label: 'Cost of Goods Sold', + key: ACCOUNT_TYPE.COST_OF_GOODS_SOLD, + normal: ACCOUNT_NORMAL.DEBIT, + rootType: ACCOUNT_ROOT_TYPE.EXPENSE, + parentType: ACCOUNT_PARENT_TYPE.EXPENSE, + balanceSheet: false, + incomeSheet: true, + }, + { + label: 'Expense', + key: ACCOUNT_TYPE.EXPENSE, + normal: ACCOUNT_NORMAL.DEBIT, + rootType: ACCOUNT_ROOT_TYPE.EXPENSE, + parentType: ACCOUNT_PARENT_TYPE.EXPENSE, + balanceSheet: false, + incomeSheet: true, + }, + { + label: 'Other Expense', + key: ACCOUNT_TYPE.OTHER_EXPENSE, + normal: ACCOUNT_NORMAL.DEBIT, + rootType: ACCOUNT_ROOT_TYPE.EXPENSE, + parentType: ACCOUNT_PARENT_TYPE.EXPENSE, + balanceSheet: false, + incomeSheet: true, + }, +]; + +export const getAccountsSupportsMultiCurrency = () => { + return ACCOUNT_TYPES.filter((account) => account.multiCurrency); +}; diff --git a/packages/server-nest/src/modules/Accounts/Accounts.controller.ts b/packages/server-nest/src/modules/Accounts/Accounts.controller.ts new file mode 100644 index 000000000..ecc4f7e50 --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/Accounts.controller.ts @@ -0,0 +1,72 @@ +import { + Controller, + Post, + Body, + Param, + Delete, + Get, + Query, + ParseIntPipe, +} from '@nestjs/common'; +import { AccountsApplication } from './AccountsApplication.service'; +import { CreateAccountDTO } from './CreateAccount.dto'; +import { EditAccountDTO } from './EditAccount.dto'; +import { PublicRoute } from '../Auth/Jwt.guard'; +import { IAccountsTransactionsFilter } from './Accounts.types'; +// import { IAccountsFilter, IAccountsTransactionsFilter } from './Accounts.types'; +// import { ZodValidationPipe } from '@/common/pipes/ZodValidation.pipe'; + +@Controller('accounts') +@PublicRoute() +export class AccountsController { + constructor(private readonly accountsApplication: AccountsApplication) {} + + @Post() + async createAccount(@Body() accountDTO: CreateAccountDTO) { + return this.accountsApplication.createAccount(accountDTO); + } + + @Post(':id') + async editAccount( + @Param('id', ParseIntPipe) id: number, + @Body() accountDTO: EditAccountDTO, + ) { + return this.accountsApplication.editAccount(id, accountDTO); + } + + @Delete(':id') + async deleteAccount(@Param('id', ParseIntPipe) id: number) { + return this.accountsApplication.deleteAccount(id); + } + + @Post(':id/activate') + async activateAccount(@Param('id', ParseIntPipe) id: number) { + return this.accountsApplication.activateAccount(id); + } + + @Post(':id/inactivate') + async inactivateAccount(@Param('id', ParseIntPipe) id: number) { + return this.accountsApplication.inactivateAccount(id); + } + + @Get('types') + async getAccountTypes() { + return this.accountsApplication.getAccountTypes(); + } + + @Get('transactions') + async getAccountTransactions(@Query() filter: IAccountsTransactionsFilter) { + return this.accountsApplication.getAccountsTransactions(filter); + } + + @Get(':id') + async getAccount(@Param('id', ParseIntPipe) id: number) { + return this.accountsApplication.getAccount(id); + } + + // @Get() + // async getAccounts(@Query() filter: IAccountsFilter) { + // return this.accountsApplication.getAccounts(filter); + // } + +} diff --git a/packages/server-nest/src/modules/Accounts/Accounts.module.ts b/packages/server-nest/src/modules/Accounts/Accounts.module.ts new file mode 100644 index 000000000..e3d22a583 --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/Accounts.module.ts @@ -0,0 +1,38 @@ +import { Module } from '@nestjs/common'; +import { TenancyDatabaseModule } from '../Tenancy/TenancyDB/TenancyDB.module'; +import { AccountsController } from './Accounts.controller'; +import { AccountsApplication } from './AccountsApplication.service'; +import { CreateAccountService } from './CreateAccount.service'; +import { TenancyContext } from '../Tenancy/TenancyContext.service'; +import { CommandAccountValidators } from './CommandAccountValidators.service'; +import { AccountRepository } from './repositories/Account.repository'; +import { EditAccount } from './EditAccount.service'; +import { DeleteAccount } from './DeleteAccount.service'; +import { GetAccount } from './GetAccount.service'; +import { TransformerInjectable } from '../Transformer/TransformerInjectable.service'; +import { ActivateAccount } from './ActivateAccount.service'; +import { GetAccountTypesService } from './GetAccountTypes.service'; +import { GetAccountTransactionsService } from './GetAccountTransactions.service'; +// import { EditAccount } from './EditAccount.service'; +// import { GetAccountsService } from './GetAccounts.service'; + +@Module({ + imports: [TenancyDatabaseModule], + controllers: [AccountsController], + providers: [ + AccountsApplication, + CreateAccountService, + TenancyContext, + CommandAccountValidators, + AccountRepository, + EditAccount, + DeleteAccount, + GetAccount, + TransformerInjectable, + ActivateAccount, + GetAccountTypesService, + GetAccountTransactionsService, + // GetAccountsService, + ], +}) +export class AccountsModule {} diff --git a/packages/server-nest/src/modules/Accounts/Accounts.types.ts b/packages/server-nest/src/modules/Accounts/Accounts.types.ts new file mode 100644 index 000000000..1dfd0bc95 --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/Accounts.types.ts @@ -0,0 +1,94 @@ +import { Knex } from 'knex'; +import { AccountModel } from './models/Account.model'; +// import { IDynamicListFilterDTO } from '@/interfaces/DynamicFilter'; + +export enum AccountNormal { + DEBIT = 'debit', + CREDIT = 'credit', +} + +export interface IAccountsTransactionsFilter { + accountId?: number; + limit?: number; +} + +export enum IAccountsStructureType { + Tree = 'tree', + Flat = 'flat', +} + +// export interface IAccountsFilter extends IDynamicListFilterDTO { +// stringifiedFilterRoles?: string; +// onlyInactive: boolean; +// structure?: IAccountsStructureType; +// } +export interface IAccountsFilter {} +export interface IAccountType { + label: string; + key: string; + normal: string; + rootType: string; + childType: string; + balanceSheet: boolean; + incomeSheet: boolean; +} + +export interface IAccountsTypesService { + getAccountsTypes(): Promise; +} + +export interface IAccountEventCreatingPayload { + accountDTO: any; + trx: Knex.Transaction; +} +export interface IAccountEventCreatedPayload { + account: AccountModel; + accountId: number; + trx: Knex.Transaction; +} + +export interface IAccountEventEditedPayload { + account: AccountModel; + oldAccount: AccountModel; + trx: Knex.Transaction; +} + +export interface IAccountEventDeletedPayload { + accountId: number; + oldAccount: AccountModel; + trx: Knex.Transaction; +} + +export interface IAccountEventDeletePayload { + trx: Knex.Transaction; + oldAccount: AccountModel; +} + +export interface IAccountEventActivatedPayload { + accountId: number; + trx: Knex.Transaction; +} + +export enum AccountAction { + CREATE = 'Create', + EDIT = 'Edit', + DELETE = 'Delete', + VIEW = 'View', + TransactionsLocking = 'TransactionsLocking', +} + +export enum TaxRateAction { + CREATE = 'Create', + EDIT = 'Edit', + DELETE = 'Delete', + VIEW = 'View', +} + +export interface CreateAccountParams { + ignoreUniqueName: boolean; +} + + +export interface IGetAccountTransactionPOJO { + +} diff --git a/packages/server-nest/src/modules/Accounts/AccountsApplication.service.ts b/packages/server-nest/src/modules/Accounts/AccountsApplication.service.ts new file mode 100644 index 000000000..e91a9461d --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/AccountsApplication.service.ts @@ -0,0 +1,133 @@ +import { Knex } from 'knex'; +import { Injectable } from '@nestjs/common'; +// import { +// IAccount, +// IAccountCreateDTO, +// IAccountEditDTO, +// IAccountResponse, +// IAccountsFilter, +// IAccountsTransactionsFilter, +// IFilterMeta, +// IGetAccountTransactionPOJO, +// } from '@/interfaces'; +import { CreateAccountService } from './CreateAccount.service'; +import { DeleteAccount } from './DeleteAccount.service'; +import { EditAccount } from './EditAccount.service'; +// import { GetAccounts } from './GetAccounts.service'; +// import { GetAccountTransactions } from './GetAccountTransactions.service'; +import { CreateAccountDTO } from './CreateAccount.dto'; +import { AccountModel } from './models/Account.model'; +import { EditAccountDTO } from './EditAccount.dto'; +import { GetAccount } from './GetAccount.service'; +import { ActivateAccount } from './ActivateAccount.service'; +import { GetAccountTypesService } from './GetAccountTypes.service'; +import { GetAccountTransactionsService } from './GetAccountTransactions.service'; +import { + IAccountsTransactionsFilter, + IGetAccountTransactionPOJO, +} from './Accounts.types'; + +@Injectable() +export class AccountsApplication { + constructor( + private readonly createAccountService: CreateAccountService, + private readonly deleteAccountService: DeleteAccount, + private readonly editAccountService: EditAccount, + private readonly activateAccountService: ActivateAccount, + private readonly getAccountTypesService: GetAccountTypesService, + private readonly getAccountService: GetAccount, + // private readonly getAccountsService: GetAccounts, + private readonly getAccountTransactionsService: GetAccountTransactionsService, + ) {} + + /** + * Creates a new account. + * @param {number} tenantId + * @param {IAccountCreateDTO} accountDTO + * @returns {Promise} + */ + public createAccount = ( + accountDTO: CreateAccountDTO, + trx?: Knex.Transaction, + ): Promise => { + return this.createAccountService.createAccount(accountDTO, trx); + }; + + /** + * Deletes the given account. + * @param {number} tenantId + * @param {number} accountId + * @returns {Promise} + */ + public deleteAccount = (accountId: number) => { + return this.deleteAccountService.deleteAccount(accountId); + }; + + /** + * Edits the given account. + * @param {number} tenantId + * @param {number} accountId + * @param {IAccountEditDTO} accountDTO + * @returns + */ + public editAccount = (accountId: number, accountDTO: EditAccountDTO) => { + return this.editAccountService.editAccount(accountId, accountDTO); + }; + + /** + * Activate the given account. + * @param {number} accountId - Account id. + */ + public activateAccount = (accountId: number) => { + return this.activateAccountService.activateAccount(accountId, true); + }; + + /** + * Inactivate the given account. + * @param {number} accountId - Account id. + */ + public inactivateAccount = (accountId: number) => { + return this.activateAccountService.activateAccount(accountId, false); + }; + + /** + * Retrieves the account details. + * @param {number} tenantId - Tenant id. + * @param {number} accountId - Account id. + * @returns {Promise} + */ + public getAccount = (accountId: number) => { + return this.getAccountService.getAccount(accountId); + }; + + /** + * Retrieves all account types. + * @returns {Promise} + */ + public getAccountTypes = () => { + return this.getAccountTypesService.getAccountsTypes(); + }; + + // /** + // * Retrieves the accounts list. + // * @param {number} tenantId + // * @param {IAccountsFilter} filterDTO + // * @returns {Promise<{ accounts: IAccountResponse[]; filterMeta: IFilterMeta }>} + // */ + // public getAccounts = ( + // filterDTO: IAccountsFilter, + // ): Promise<{ accounts: IAccountResponse[]; filterMeta: IFilterMeta }> => { + // return this.getAccountsService.getAccountsList(filterDTO); + // }; + + /** + * Retrieves the given account transactions. + * @param {IAccountsTransactionsFilter} filter + * @returns {Promise} + */ + public getAccountsTransactions = ( + filter: IAccountsTransactionsFilter, + ): Promise => { + return this.getAccountTransactionsService.getAccountsTransactions(filter); + }; +} diff --git a/packages/server-nest/src/modules/Accounts/AccountsExportable.service.ts b/packages/server-nest/src/modules/Accounts/AccountsExportable.service.ts new file mode 100644 index 000000000..64c30b061 --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/AccountsExportable.service.ts @@ -0,0 +1,32 @@ +// import { Inject, Service } from 'typedi'; +// import { AccountsApplication } from './AccountsApplication.service'; +// import { Exportable } from '../Export/Exportable'; +// import { IAccountsFilter, IAccountsStructureType } from '@/interfaces'; +// import { EXPORT_SIZE_LIMIT } from '../Export/constants'; + +// @Service() +// export class AccountsExportable extends Exportable { +// @Inject() +// private accountsApplication: AccountsApplication; + +// /** +// * 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, +// pageSize: EXPORT_SIZE_LIMIT, +// page: 1, +// } as IAccountsFilter; + +// return this.accountsApplication +// .getAccounts(tenantId, parsedQuery) +// .then((output) => output.accounts); +// } +// } diff --git a/packages/server-nest/src/modules/Accounts/AccountsImportable.SampleData.ts b/packages/server-nest/src/modules/Accounts/AccountsImportable.SampleData.ts new file mode 100644 index 000000000..1757bd498 --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/AccountsImportable.SampleData.ts @@ -0,0 +1,50 @@ +export const AccountsSampleData = [ + { + 'Account Name': 'Utilities Expense', + 'Account Code': 9000, + Type: 'Expense', + Description: 'Omnis voluptatum consequatur.', + Active: 'T', + 'Currency Code': '', + }, + { + 'Account Name': 'Unearned Revenue', + 'Account Code': 9010, + Type: 'Long Term Liability', + Description: 'Autem odit voluptas nihil unde.', + Active: 'T', + 'Currency Code': '', + }, + { + 'Account Name': 'Long-Term Debt', + 'Account Code': 9020, + Type: 'Long Term Liability', + Description: 'In voluptas cumque exercitationem.', + Active: 'T', + 'Currency Code': '', + }, + { + 'Account Name': 'Salaries and Wages Expense', + 'Account Code': 9030, + Type: 'Expense', + Description: 'Assumenda aspernatur soluta aliquid perspiciatis quasi.', + Active: 'T', + 'Currency Code': '', + }, + { + 'Account Name': 'Rental Income', + 'Account Code': 9040, + Type: 'Income', + Description: 'Omnis possimus amet occaecati inventore.', + Active: 'T', + 'Currency Code': '', + }, + { + 'Account Name': 'Paypal', + 'Account Code': 9050, + Type: 'Bank', + Description: 'In voluptas cumque exercitationem.', + Active: 'T', + 'Currency Code': '', + }, +]; diff --git a/packages/server-nest/src/modules/Accounts/AccountsImportable.service.ts b/packages/server-nest/src/modules/Accounts/AccountsImportable.service.ts new file mode 100644 index 000000000..3e4a61859 --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/AccountsImportable.service.ts @@ -0,0 +1,45 @@ +// import { Inject, Service } from 'typedi'; +// import { Knex } from 'knex'; +// import { IAccountCreateDTO } from '@/interfaces'; +// import { CreateAccount } from './CreateAccount.service'; +// import { Importable } from '../Import/Importable'; +// import { AccountsSampleData } from './AccountsImportable.SampleData'; + +// @Service() +// export class AccountsImportable extends Importable { +// @Inject() +// private createAccountService: CreateAccount; + +// /** +// * Importing to account service. +// * @param {number} tenantId +// * @param {IAccountCreateDTO} createAccountDTO +// * @returns +// */ +// public importable( +// tenantId: number, +// createAccountDTO: IAccountCreateDTO, +// trx?: Knex.Transaction +// ) { +// return this.createAccountService.createAccount( +// 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 AccountsSampleData; +// } +// } diff --git a/packages/server-nest/src/modules/Accounts/ActivateAccount.service.ts b/packages/server-nest/src/modules/Accounts/ActivateAccount.service.ts new file mode 100644 index 000000000..5fb088c9f --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/ActivateAccount.service.ts @@ -0,0 +1,53 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { IAccountEventActivatedPayload } from './Accounts.types'; +import { AccountModel } from './models/Account.model'; +import { AccountRepository } from './repositories/Account.repository'; +import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; + +@Injectable() +export class ActivateAccount { + constructor( + private readonly eventEmitter: EventEmitter2, + private readonly uow: UnitOfWork, + + @Inject(AccountModel.name) + private readonly accountModel: typeof AccountModel, + private readonly accountRepository: AccountRepository, + ) {} + + /** + * Activates/Inactivates the given account. + * @param {number} accountId + * @param {boolean} activate + */ + public activateAccount = async (accountId: number, activate?: boolean) => { + // Retrieve the given account or throw not found error. + const oldAccount = await this.accountModel + .query() + .findById(accountId) + .throwIfNotFound(); + + // Get all children accounts. + const accountsGraph = await this.accountRepository.getDependencyGraph(); + const dependenciesAccounts = accountsGraph.dependenciesOf(accountId); + + const patchAccountsIds = [...dependenciesAccounts, accountId]; + + // Activate account and associated transactions under unit-of-work environment. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Activate and inactivate the given accounts ids. + activate + ? await this.accountRepository.activateByIds(patchAccountsIds, trx) + : await this.accountRepository.inactivateByIds(patchAccountsIds, trx); + + // Triggers `onAccountActivated` event. + this.eventEmitter.emitAsync(events.accounts.onActivated, { + accountId, + trx, + } as IAccountEventActivatedPayload); + }); + }; +} diff --git a/packages/server-nest/src/modules/Accounts/CommandAccountValidators.service.ts b/packages/server-nest/src/modules/Accounts/CommandAccountValidators.service.ts new file mode 100644 index 000000000..22ed4933f --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/CommandAccountValidators.service.ts @@ -0,0 +1,223 @@ +// @ts-nocheck +import { Inject, Injectable, Scope } from '@nestjs/common'; +// import { IAccountDTO, IAccount, IAccountCreateDTO } from './Accounts.types'; +// 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 { AccountRepository } from './repositories/Account.repository'; +import { AccountTypesUtils } from './utils/AccountType.utils'; +import { CreateAccountDTO } from './CreateAccount.dto'; +import { EditAccountDTO } from './EditAccount.dto'; + +@Injectable({ scope: Scope.REQUEST }) +export class CommandAccountValidators { + constructor( + @Inject(AccountModel.name) + private readonly accountModel: typeof AccountModel, + private readonly accountRepository: AccountRepository, + ) {} + + /** + * Throws error if the account was prefined. + * @param {AccountModel} account + */ + public throwErrorIfAccountPredefined(account: AccountModel) { + if (account.predefined) { + throw new ServiceError(ERRORS.ACCOUNT_PREDEFINED); + } + } + + /** + * 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 + */ + public async isAccountTypeChangedOrThrowError( + oldAccount: AccountModel | CreateAccountDTO | EditAccountDTO, + newAccount: AccountModel | CreateAccountDTO | EditAccountDTO, + ) { + if (oldAccount.accountType !== newAccount.accountType) { + throw new ServiceError(ERRORS.ACCOUNT_TYPE_NOT_ALLOWED_TO_CHANGE); + } + } + + /** + * Retrieve account type or throws service error. + * @param {string} accountTypeKey - + * @return {IAccountType} + */ + public getAccountTypeOrThrowError(accountTypeKey: string) { + const accountType = AccountTypesUtils.getType(accountTypeKey); + + if (!accountType) { + throw new ServiceError(ERRORS.ACCOUNT_TYPE_NOT_FOUND); + } + return accountType; + } + + /** + * Retrieve parent account or throw service error. + * @param {number} accountId - Account id. + * @param {number} notAccountId - Ignore the account id. + */ + public async getParentAccountOrThrowError( + accountId: number, + notAccountId?: number, + ) { + const parentAccount = await this.accountModel + .query() + .findById(accountId) + .onBuild((query) => { + if (notAccountId) { + query.whereNot('id', notAccountId); + } + }); + if (!parentAccount) { + throw new ServiceError(ERRORS.PARENT_ACCOUNT_NOT_FOUND); + } + return parentAccount; + } + + /** + * Throws error if the account type was not unique on the storage. + * @param {string} accountCode - Account code. + * @param {number} notAccountId - Ignore the account id. + */ + public async isAccountCodeUniqueOrThrowError( + accountCode: string, + notAccountId?: number, + ) { + const account = await this.accountModel + .query() + .where('code', accountCode) + .onBuild((query) => { + if (notAccountId) { + query.whereNot('id', notAccountId); + } + }); + if (account.length > 0) { + throw new ServiceError( + ERRORS.ACCOUNT_CODE_NOT_UNIQUE, + 'Account code is not unique.', + ); + } + } + + /** + * Validates the account name uniquiness. + * @param {string} accountName - Account name. + * @param {number} notAccountId - Ignore the account id. + */ + public async validateAccountNameUniquiness( + accountName: string, + notAccountId?: number, + ) { + const foundAccount = await this.accountModel + .query() + .findOne('name', accountName) + .onBuild((query) => { + if (notAccountId) { + query.whereNot('id', notAccountId); + } + }); + if (foundAccount) { + throw new ServiceError( + ERRORS.ACCOUNT_NAME_NOT_UNIQUE, + 'Account name is not unique.', + ); + } + } + + /** + * Validates the given account type supports multi-currency. + * @param {CreateAccountDTO | EditAccountDTO} accountDTO - + */ + public validateAccountTypeSupportCurrency = ( + accountDTO: CreateAccountDTO | EditAccountDTO, + baseCurrency: string, + ) => { + // Can't continue to validate the type has multi-currency feature + // if the given currency equals the base currency or not assigned. + if (accountDTO.currencyCode === baseCurrency || !accountDTO.currencyCode) { + return; + } + const meta = AccountTypesUtils.getType(accountDTO.accountType); + + // Throw error if the account type does not support multi-currency. + if (!meta?.multiCurrency) { + throw new ServiceError(ERRORS.ACCOUNT_TYPE_NOT_SUPPORTS_MULTI_CURRENCY); + } + }; + + /** + * Validates the account DTO currency code whether equals the currency code of + * parent account. + * @param {CreateAccountDTO | EditAccountDTO} accountDTO + * @param {AccountModel} parentAccount + * @param {string} baseCurrency - + * @throws {ServiceError(ERRORS.ACCOUNT_CURRENCY_NOT_SAME_PARENT_ACCOUNT)} + */ + public validateCurrentSameParentAccount = ( + accountDTO: CreateAccountDTO | EditAccountDTO, + parentAccount: AccountModel, + baseCurrency: string, + ) => { + // If the account DTO currency not assigned and the parent account has no base currency. + if ( + !accountDTO.currencyCode && + parentAccount.currencyCode !== baseCurrency + ) { + throw new ServiceError(ERRORS.ACCOUNT_CURRENCY_NOT_SAME_PARENT_ACCOUNT); + } + // If the account DTO is assigned and not equals the currency code of parent account. + if ( + accountDTO.currencyCode && + parentAccount.currencyCode !== accountDTO.currencyCode + ) { + throw new ServiceError(ERRORS.ACCOUNT_CURRENCY_NOT_SAME_PARENT_ACCOUNT); + } + }; + + /** + * Throws service error if parent account has different type. + * @param {IAccountDTO} accountDTO + * @param {IAccount} parentAccount + */ + public throwErrorIfParentHasDiffType( + accountDTO: CreateAccountDTO | EditAccountDTO, + parentAccount: AccountModel, + ) { + if (accountDTO.accountType !== parentAccount.accountType) { + throw new ServiceError(ERRORS.PARENT_ACCOUNT_HAS_DIFFERENT_TYPE); + } + } + + /** + * Retrieve account of throw service error in case account not found. + * @param {number} accountId + * @return {IAccount} + */ + public async getAccountOrThrowError(accountId: number) { + const account = await this.accountRepository.findOneById(accountId); + + if (!account) { + throw new ServiceError(ERRORS.ACCOUNT_NOT_FOUND); + } + return account; + } + + /** + * Validates the max depth level of accounts chart. + * @param {number} parentAccountId - Parent account id. + */ + public async validateMaxParentAccountDepthLevels(parentAccountId: number) { + const accountsGraph = await this.accountRepository.getDependencyGraph(); + const parentDependantsIds = accountsGraph.dependantsOf(parentAccountId); + + if (parentDependantsIds.length >= MAX_ACCOUNTS_CHART_DEPTH) { + throw new ServiceError(ERRORS.PARENT_ACCOUNT_EXCEEDED_THE_DEPTH_LEVEL); + } + } +} diff --git a/packages/server-nest/src/modules/Accounts/CreateAccount.dto.ts b/packages/server-nest/src/modules/Accounts/CreateAccount.dto.ts new file mode 100644 index 000000000..0709e06a6 --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/CreateAccount.dto.ts @@ -0,0 +1,51 @@ +import { + IsString, + IsOptional, + IsInt, + MinLength, + MaxLength, + IsBoolean, +} from 'class-validator'; + +export class CreateAccountDTO { + @IsString() + @MinLength(3) + @MaxLength(255) // Assuming DATATYPES_LENGTH.STRING is 255 + name: string; + + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(6) + code?: string; + + @IsOptional() + @IsString() + currencyCode?: string; + + @IsString() + @MinLength(3) + @MaxLength(255) // Assuming DATATYPES_LENGTH.STRING is 255 + accountType: string; + + @IsOptional() + @IsString() + @MaxLength(65535) // Assuming DATATYPES_LENGTH.TEXT is 65535 + description?: string; + + @IsOptional() + @IsInt() + parentAccountId?: number; + + @IsOptional() + @IsBoolean() + active?: boolean; + + @IsOptional() + @IsString() + plaidAccountId?: string; + + @IsOptional() + @IsString() + plaidItemId?: string; +} diff --git a/packages/server-nest/src/modules/Accounts/CreateAccount.service.ts b/packages/server-nest/src/modules/Accounts/CreateAccount.service.ts new file mode 100644 index 000000000..836570819 --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/CreateAccount.service.ts @@ -0,0 +1,139 @@ + +import { Inject, Injectable } from '@nestjs/common'; +import { kebabCase } from 'lodash'; +import { Knex } from 'knex'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { + // IAccount, + // IAccountEventCreatedPayload, + // IAccountCreateDTO, + IAccountEventCreatingPayload, + CreateAccountParams, +} from './Accounts.types'; +import { CommandAccountValidators } from './CommandAccountValidators.service'; +import { AccountModel } from './models/Account.model'; +import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service'; +import { TenancyContext } from '../Tenancy/TenancyContext.service'; +import { events } from '@/common/events/events'; +import { CreateAccountDTO } from './CreateAccount.dto'; + +@Injectable() +export class CreateAccountService { + constructor( + @Inject(AccountModel.name) + private readonly accountModel: typeof AccountModel, + private readonly eventEmitter: EventEmitter2, + private readonly uow: UnitOfWork, + private readonly validator: CommandAccountValidators, + private readonly tenancyContext: TenancyContext, + ) {} + + /** + * Authorize the account creation. + * @param {CreateAccountDTO} accountDTO + */ + private authorize = async ( + accountDTO: CreateAccountDTO, + baseCurrency: string, + params?: CreateAccountParams, + ) => { + // Validate account name uniquiness. + if (!params.ignoreUniqueName) { + await this.validator.validateAccountNameUniquiness(accountDTO.name); + } + // Validate the account code uniquiness. + if (accountDTO.code) { + await this.validator.isAccountCodeUniqueOrThrowError(accountDTO.code); + } + // Retrieve the account type meta or throw service error if not found. + this.validator.getAccountTypeOrThrowError(accountDTO.accountType); + + // Ingore the parent account validation if not presented. + if (accountDTO.parentAccountId) { + const parentAccount = await this.validator.getParentAccountOrThrowError( + accountDTO.parentAccountId, + ); + this.validator.throwErrorIfParentHasDiffType(accountDTO, parentAccount); + + // Inherit active status from parent account. + accountDTO.active = parentAccount.active; + + // Validate should currency code be the same currency of parent account. + this.validator.validateCurrentSameParentAccount( + accountDTO, + parentAccount, + baseCurrency, + ); + // Validates the max depth level of accounts chart. + await this.validator.validateMaxParentAccountDepthLevels( + accountDTO.parentAccountId, + ); + } + // Validates the given account type supports the multi-currency. + this.validator.validateAccountTypeSupportCurrency(accountDTO, baseCurrency); + }; + + /** + * Transformes the create account DTO to input model. + * @param {IAccountCreateDTO} createAccountDTO + */ + private transformDTOToModel = ( + createAccountDTO: CreateAccountDTO, + baseCurrency: string, + ) => { + return { + ...createAccountDTO, + slug: kebabCase(createAccountDTO.name), + currencyCode: createAccountDTO.currencyCode || baseCurrency, + + // Mark the account is Plaid owner since Plaid item/account is defined on creating. + isSyncingOwner: Boolean( + createAccountDTO.plaidAccountId || createAccountDTO.plaidItemId, + ), + }; + }; + + /** + * Creates a new account on the storage. + * @param {IAccountCreateDTO} accountDTO + * @returns {Promise} + */ + public createAccount = async ( + accountDTO: CreateAccountDTO, + trx?: Knex.Transaction, + params: CreateAccountParams = { ignoreUniqueName: false }, + ): Promise => { + // Retrieves the given tenant metadata. + const tenant = await this.tenancyContext.getTenant(true); + + // Authorize the account creation. + await this.authorize(accountDTO, tenant.metadata.baseCurrency, params); + + // Transformes the DTO to model. + const accountInputModel = this.transformDTOToModel( + accountDTO, + tenant.metadata.baseCurrency, + ); + // Creates a new account with associated transactions under unit-of-work envirement. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Triggers `onAccountCreating` event. + await this.eventEmitter.emitAsync(events.accounts.onCreating, { + accountDTO, + trx, + } as IAccountEventCreatingPayload); + + // Inserts account to the storage. + const account = await this.accountModel.query().insert({ + ...accountInputModel, + }); + // Triggers `onAccountCreated` event. + // await this.eventEmitter.emitAsync(events.accounts.onCreated, { + // account, + // accountId: account.id, + // trx, + // } as IAccountEventCreatedPayload); + + return account; + }, trx); + }; +} diff --git a/packages/server-nest/src/modules/Accounts/DeleteAccount.service.ts b/packages/server-nest/src/modules/Accounts/DeleteAccount.service.ts new file mode 100644 index 000000000..c89731b1c --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/DeleteAccount.service.ts @@ -0,0 +1,80 @@ +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 { EventEmitter2 } from '@nestjs/event-emitter'; +import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service'; +import { events } from '@/common/events/events'; +import { IAccountEventDeletedPayload } from './Accounts.types'; + +@Injectable() +export class DeleteAccount { + constructor( + @Inject(AccountModel.name) private accountModel: typeof AccountModel, + private eventEmitter: EventEmitter2, + private uow: UnitOfWork, + private validator: CommandAccountValidators, + ) {} + + /** + * Authorize account delete. + * @param {number} accountId - Account id. + */ + private authorize = async (accountId: number, oldAccount: AccountModel) => { + // Throw error if the account was predefined. + this.validator.throwErrorIfAccountPredefined(oldAccount); + }; + + /** + * Unlink the given parent account with children accounts. + * @param {number|number[]} parentAccountId - + */ + private async unassociateChildrenAccountsFromParent( + parentAccountId: number | number[], + trx?: Knex.Transaction, + ) { + const accountsIds = Array.isArray(parentAccountId) + ? parentAccountId + : [parentAccountId]; + + await this.accountModel + .query(trx) + .whereIn('parent_account_id', accountsIds) + .patch({ parent_account_id: null }); + } + + /** + * Deletes the account from the storage. + * @param {number} accountId + */ + public deleteAccount = async (accountId: number): Promise => { + // Retrieve account or not found service error. + const oldAccount = await this.accountModel.query().findById(accountId); + + // Authorize before delete account. + await this.authorize(accountId, oldAccount); + + // Deletes the account and associated transactions under UOW environment. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Triggers `onAccountDelete` event. + await this.eventEmitter.emitAsync(events.accounts.onDelete, { + trx, + oldAccount, + } as IAccountEventDeletedPayload); + + // Unlink the parent account from children accounts. + await this.unassociateChildrenAccountsFromParent(accountId, trx); + + // Deletes account by the given id. + await this.accountModel.query(trx).deleteById(accountId); + + // Triggers `onAccountDeleted` event. + await this.eventEmitter.emitAsync(events.accounts.onDeleted, { + accountId, + oldAccount, + trx, + } as IAccountEventDeletedPayload); + }); + }; +} diff --git a/packages/server-nest/src/modules/Accounts/EditAccount.dto.ts b/packages/server-nest/src/modules/Accounts/EditAccount.dto.ts new file mode 100644 index 000000000..8503e6951 --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/EditAccount.dto.ts @@ -0,0 +1,34 @@ +import { + IsString, + IsOptional, + IsInt, + MinLength, + MaxLength, +} from 'class-validator'; + +export class EditAccountDTO { + @IsString() + @MinLength(3) + @MaxLength(255) // Assuming DATATYPES_LENGTH.STRING is 255 + name: string; + + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(6) + code?: string; + + @IsString() + @MinLength(3) + @MaxLength(255) // Assuming DATATYPES_LENGTH.STRING is 255 + accountType: string; + + @IsOptional() + @IsString() + @MaxLength(65535) // Assuming DATATYPES_LENGTH.TEXT is 65535 + description?: string; + + @IsOptional() + @IsInt() + parentAccountId?: number; +} diff --git a/packages/server-nest/src/modules/Accounts/EditAccount.service.ts b/packages/server-nest/src/modules/Accounts/EditAccount.service.ts new file mode 100644 index 000000000..7586bfd6c --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/EditAccount.service.ts @@ -0,0 +1,100 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { CommandAccountValidators } from './CommandAccountValidators.service'; +import { AccountModel } from './models/Account.model'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service'; +import { events } from '@/common/events/events'; +import { EditAccountDTO } from './EditAccount.dto'; + +@Injectable() +export class EditAccount { + constructor( + private readonly eventEmitter: EventEmitter2, + private readonly uow: UnitOfWork, + private readonly validator: CommandAccountValidators, + + @Inject(AccountModel.name) + private readonly accountModel: typeof AccountModel, + ) {} + + /** + * Authorize the account editing. + * @param {number} accountId + * @param {IAccountEditDTO} accountDTO + * @param {IAccount} oldAccount - + */ + private authorize = async ( + accountId: number, + accountDTO: EditAccountDTO, + oldAccount: AccountModel, + ) => { + // Validate account name uniquiness. + await this.validator.validateAccountNameUniquiness( + accountDTO.name, + accountId, + ); + // Validate the account type should be not mutated. + await this.validator.isAccountTypeChangedOrThrowError( + oldAccount, + accountDTO, + ); + // Validate the account code not exists on the storage. + if (accountDTO.code && accountDTO.code !== oldAccount.code) { + await this.validator.isAccountCodeUniqueOrThrowError( + accountDTO.code, + oldAccount.id, + ); + } + // Retrieve the parent account of throw not found service error. + if (accountDTO.parentAccountId) { + const parentAccount = await this.validator.getParentAccountOrThrowError( + accountDTO.parentAccountId, + oldAccount.id, + ); + this.validator.throwErrorIfParentHasDiffType(accountDTO, parentAccount); + } + }; + + /** + * Edits details of the given account. + * @param {number} accountId + * @param {IAccountDTO} accountDTO + */ + public async editAccount( + accountId: number, + accountDTO: EditAccountDTO, + ): Promise { + // Retrieve the old account or throw not found service error. + const oldAccount = await this.accountModel + .query() + .findById(accountId) + .throwIfNotFound(); + + // Authorize the account editing. + await this.authorize(accountId, accountDTO, oldAccount); + + // Edits account and associated transactions under unit-of-work environment. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Triggers `onAccountEditing` event. + await this.eventEmitter.emitAsync(events.accounts.onEditing, { + oldAccount, + accountDTO, + }); + // Update the account on the storage. + const account = await this.accountModel + .query(trx) + .findById(accountId) + .updateAndFetch({ ...accountDTO }); + + // Triggers `onAccountEdited` event. + // await this.eventEmitter.emitAsync(events.accounts.onEdited, { + // account, + // oldAccount, + // trx, + // } as IAccountEventEditedPayload); + + return account; + }); + } +} diff --git a/packages/server-nest/src/modules/Accounts/GetAccount.service.ts b/packages/server-nest/src/modules/Accounts/GetAccount.service.ts new file mode 100644 index 000000000..7387ab0d5 --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/GetAccount.service.ts @@ -0,0 +1,46 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { AccountTransformer } from './Account.transformer'; +import { AccountModel } from './models/Account.model'; +import { AccountRepository } from './repositories/Account.repository'; +import { TransformerInjectable } from '../Transformer/TransformerInjectable.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; + +@Injectable() +export class GetAccount { + constructor( + @Inject(AccountModel.name) + private readonly accountModel: typeof AccountModel, + private readonly accountRepository: AccountRepository, + private readonly transformer: TransformerInjectable, + private readonly eventEmitter: EventEmitter2, + ) {} + + /** + * Retrieve the given account details. + * @param {number} accountId + */ + public getAccount = async (accountId: number) => { + // Find the given account or throw not found error. + const account = await this.accountModel + .query() + .findById(accountId) + .withGraphFetched('plaidItem') + .throwIfNotFound(); + + const accountsGraph = await this.accountRepository.getDependencyGraph(); + + // Transforms the account model to POJO. + const transformed = await this.transformer.transform( + account, + new AccountTransformer(), + { accountsGraph }, + ); + const eventPayload = { accountId }; + + // Triggers `onAccountViewed` event. + await this.eventEmitter.emitAsync(events.accounts.onViewed, eventPayload); + + return transformed; + }; +} diff --git a/packages/server-nest/src/modules/Accounts/GetAccountTransactions.service.ts b/packages/server-nest/src/modules/Accounts/GetAccountTransactions.service.ts new file mode 100644 index 000000000..68803d438 --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/GetAccountTransactions.service.ts @@ -0,0 +1,52 @@ +import { + IAccountsTransactionsFilter, + IGetAccountTransactionPOJO, +} from './Accounts.types'; +import { AccountTransactionTransformer } from './AccountTransaction.transformer'; +import { AccountTransaction } from './models/AccountTransaction.model'; +import { AccountModel } from './models/Account.model'; +import { Inject, Injectable } from '@nestjs/common'; +import { TransformerInjectable } from '../Transformer/TransformerInjectable.service'; + +@Injectable() +export class GetAccountTransactionsService { + constructor( + private readonly transformer: TransformerInjectable, + + @Inject(AccountTransaction.name) + private readonly accountTransaction: typeof AccountTransaction, + + @Inject(AccountModel.name) + private readonly account: typeof AccountModel, + ) {} + + /** + * Retrieve the accounts transactions. + * @param {IAccountsTransactionsFilter} filter - + */ + public getAccountsTransactions = async ( + filter: IAccountsTransactionsFilter, + ): Promise => { + // Retrieve the given account or throw not found error. + if (filter.accountId) { + await this.account.query().findById(filter.accountId).throwIfNotFound(); + } + const transactions = await this.accountTransaction + .query() + .onBuild((query) => { + query.orderBy('date', 'DESC'); + + if (filter.accountId) { + query.where('account_id', filter.accountId); + } + query.withGraphFetched('account'); + query.withGraphFetched('contact'); + query.limit(filter.limit || 50); + }); + // Transform the account transaction. + return this.transformer.transform( + transactions, + new AccountTransactionTransformer(), + ); + }; +} diff --git a/packages/server-nest/src/modules/Accounts/GetAccountTypes.service.ts b/packages/server-nest/src/modules/Accounts/GetAccountTypes.service.ts new file mode 100644 index 000000000..441cb4114 --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/GetAccountTypes.service.ts @@ -0,0 +1,17 @@ +// import { IAccountType } from './Accounts.types'; +import { Injectable } from '@nestjs/common'; +import { AccountTypesUtils } from './utils/AccountType.utils'; + +@Injectable() +export class GetAccountTypesService { + /** + * Retrieve all accounts types. + * @param {number} tenantId - + * @return {IAccountType} + */ + public getAccountsTypes() { + const accountTypes = AccountTypesUtils.getList(); + + return accountTypes; + } +} diff --git a/packages/server-nest/src/modules/Accounts/GetAccounts.service.ts b/packages/server-nest/src/modules/Accounts/GetAccounts.service.ts new file mode 100644 index 000000000..1d3c44840 --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/GetAccounts.service.ts @@ -0,0 +1,68 @@ +// import { Injectable } from '@nestjs/common'; +// import * as R from 'ramda'; +// import { +// IAccountsFilter, +// IAccountResponse, +// IFilterMeta, +// } from '@/interfaces'; +// import { DynamicListingService } from '@/services/DynamicListing/DynamicListService'; +// import { AccountTransformer } from './Account.transformer'; +// import { TransformerService } from '@/lib/Transformer/TransformerService'; +// import { flatToNestedArray } from '@/utils'; +// import { Account } from './Account.model'; +// import { AccountRepository } from './repositories/Account.repository'; + +// @Injectable() +// export class GetAccountsService { +// constructor( +// private readonly dynamicListService: DynamicListingService, +// private readonly transformerService: TransformerService, +// private readonly accountModel: typeof Account, +// private readonly accountRepository: AccountRepository, +// ) {} + +// /** +// * Retrieve accounts datatable list. +// * @param {IAccountsFilter} accountsFilter +// * @returns {Promise<{ accounts: IAccountResponse[]; filterMeta: IFilterMeta }>} +// */ +// public async getAccountsList( +// filterDTO: IAccountsFilter, +// ): Promise<{ accounts: IAccountResponse[]; filterMeta: IFilterMeta }> { +// // Parses the stringified filter roles. +// const filter = this.parseListFilterDTO(filterDTO); + +// // Dynamic list service. +// const dynamicList = await this.dynamicListService.dynamicList( +// this.accountModel, +// filter, +// ); +// // Retrieve accounts model based on the given query. +// const accounts = await this.accountModel.query().onBuild((builder) => { +// dynamicList.buildQuery()(builder); +// builder.modify('inactiveMode', filter.inactiveMode); +// }); +// const accountsGraph = await this.accountRepository.getDependencyGraph(); + +// // Retrieves the transformed accounts collection. +// const transformedAccounts = await this.transformerService.transform( +// accounts, +// new AccountTransformer(), +// { accountsGraph, structure: filterDTO.structure }, +// ); + +// return { +// accounts: transformedAccounts, +// filterMeta: dynamicList.getResponseMeta(), +// }; +// } + +// /** +// * Parsees accounts list filter DTO. +// * @param filterDTO +// * @returns +// */ +// private parseListFilterDTO(filterDTO) { +// return R.compose(this.dynamicListService.parseStringifiedFilter)(filterDTO); +// } +// } diff --git a/packages/server-nest/src/modules/Accounts/MutateBaseCurrencyAccounts.ts b/packages/server-nest/src/modules/Accounts/MutateBaseCurrencyAccounts.ts new file mode 100644 index 000000000..2a3d13d2d --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/MutateBaseCurrencyAccounts.ts @@ -0,0 +1,22 @@ +// import { Inject, Service } from 'typedi'; +// import HasTenancyService from '@/services/Tenancy/TenancyService'; + +// @Service() +// export class MutateBaseCurrencyAccounts { +// @Inject() +// tenancy: HasTenancyService; + +// /** +// * Mutates the all accounts or the organziation. +// * @param {number} tenantId +// * @param {string} currencyCode +// */ +// public mutateAllAccountsCurrency = async ( +// tenantId: number, +// currencyCode: string +// ) => { +// const { Account } = this.tenancy.models(tenantId); + +// await Account.query().update({ currencyCode }); +// }; +// } diff --git a/packages/server-nest/src/modules/Accounts/constants.ts b/packages/server-nest/src/modules/Accounts/constants.ts new file mode 100644 index 000000000..deab658bc --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/constants.ts @@ -0,0 +1,103 @@ +export const ERRORS = { + ACCOUNT_NOT_FOUND: 'account_not_found', + ACCOUNT_TYPE_NOT_FOUND: 'account_type_not_found', + PARENT_ACCOUNT_NOT_FOUND: 'parent_account_not_found', + ACCOUNT_CODE_NOT_UNIQUE: 'account_code_not_unique', + ACCOUNT_NAME_NOT_UNIQUE: 'account_name_not_unqiue', + PARENT_ACCOUNT_HAS_DIFFERENT_TYPE: 'parent_has_different_type', + ACCOUNT_TYPE_NOT_ALLOWED_TO_CHANGE: 'account_type_not_allowed_to_changed', + ACCOUNT_PREDEFINED: 'account_predefined', + ACCOUNT_HAS_ASSOCIATED_TRANSACTIONS: 'account_has_associated_transactions', + PREDEFINED_ACCOUNTS: 'predefined_accounts', + ACCOUNTS_HAVE_TRANSACTIONS: 'accounts_have_transactions', + CLOSE_ACCOUNT_AND_TO_ACCOUNT_NOT_SAME_TYPE: + 'close_account_and_to_account_not_same_type', + ACCOUNTS_NOT_FOUND: 'accounts_not_found', + ACCOUNT_TYPE_NOT_SUPPORTS_MULTI_CURRENCY: + 'ACCOUNT_TYPE_NOT_SUPPORTS_MULTI_CURRENCY', + ACCOUNT_CURRENCY_NOT_SAME_PARENT_ACCOUNT: + 'ACCOUNT_CURRENCY_NOT_SAME_PARENT_ACCOUNT', + PARENT_ACCOUNT_EXCEEDED_THE_DEPTH_LEVEL: + 'PARENT_ACCOUNT_EXCEEDED_THE_DEPTH_LEVEL', +}; + +// Default views columns. +export const DEFAULT_VIEW_COLUMNS = [ + { key: 'name', label: 'Account name' }, + { key: 'code', label: 'Account code' }, + { key: 'account_type_label', label: 'Account type' }, + { key: 'account_normal', label: 'Account normal' }, + { key: 'amount', label: 'Balance' }, + { key: 'currencyCode', label: 'Currency' }, +]; + +export const MAX_ACCOUNTS_CHART_DEPTH = 5; + +// Accounts default views. +export const DEFAULT_VIEWS = [ + { + name: 'Assets', + slug: 'assets', + rolesLogicExpression: '1', + roles: [ + { index: 1, fieldKey: 'root_type', comparator: 'equals', value: 'asset' }, + ], + columns: DEFAULT_VIEW_COLUMNS, + }, + { + name: 'Liabilities', + slug: 'liabilities', + rolesLogicExpression: '1', + roles: [ + { + fieldKey: 'root_type', + index: 1, + comparator: 'equals', + value: 'liability', + }, + ], + columns: DEFAULT_VIEW_COLUMNS, + }, + { + name: 'Equity', + slug: 'equity', + rolesLogicExpression: '1', + roles: [ + { + fieldKey: 'root_type', + index: 1, + comparator: 'equals', + value: 'equity', + }, + ], + columns: DEFAULT_VIEW_COLUMNS, + }, + { + name: 'Income', + slug: 'income', + rolesLogicExpression: '1', + roles: [ + { + fieldKey: 'root_type', + index: 1, + comparator: 'equals', + value: 'income', + }, + ], + columns: DEFAULT_VIEW_COLUMNS, + }, + { + name: 'Expenses', + slug: 'expenses', + rolesLogicExpression: '1', + roles: [ + { + fieldKey: 'root_type', + index: 1, + comparator: 'equals', + value: 'expense', + }, + ], + columns: DEFAULT_VIEW_COLUMNS, + }, +]; diff --git a/packages/server-nest/src/modules/Accounts/models/Account.ts b/packages/server-nest/src/modules/Accounts/models/Account.model.ts similarity index 94% rename from packages/server-nest/src/modules/Accounts/models/Account.ts rename to packages/server-nest/src/modules/Accounts/models/Account.model.ts index 587ee9f75..192cb082d 100644 --- a/packages/server-nest/src/modules/Accounts/models/Account.ts +++ b/packages/server-nest/src/modules/Accounts/models/Account.model.ts @@ -1,5 +1,5 @@ /* eslint-disable global-require */ -import { mixin, Model } from 'objection'; +// import { mixin, Model } from 'objection'; import { castArray } from 'lodash'; import DependencyGraph from '@/libs/dependency-graph'; import { @@ -7,9 +7,9 @@ import { getAccountsSupportsMultiCurrency, } from '@/constants/accounts'; import { TenantModel } from '@/modules/System/models/TenantModel'; -import { SearchableModel } from '@/modules/Search/SearchableMdel'; -import { CustomViewBaseModel } from '@/modules/CustomViews/CustomViewBaseModel'; -import { ModelSettings } from '@/modules/Settings/ModelSettings'; +// import { SearchableModel } from '@/modules/Search/SearchableMdel'; +// import { CustomViewBaseModel } from '@/modules/CustomViews/CustomViewBaseModel'; +// import { ModelSettings } from '@/modules/Settings/ModelSettings'; import { AccountTypesUtils } from '@/libs/accounts-utils/AccountTypesUtils'; // import AccountSettings from './Account.Settings'; // import { DEFAULT_VIEWS } from '@/modules/Accounts/constants'; @@ -17,12 +17,24 @@ import { AccountTypesUtils } from '@/libs/accounts-utils/AccountTypesUtils'; // import { flatToNestedArray } from 'utils'; // @ts-expect-error -export class Account extends mixin(TenantModel, [ - ModelSettings, - CustomViewBaseModel, - SearchableModel, -]) { +// export class AccountModel extends mixin(TenantModel, [ +// ModelSettings, +// CustomViewBaseModel, +// SearchableModel, +// ]) { + +export class AccountModel extends TenantModel { + name: string; + slug: string; + code: string; + index: number; accountType: string; + predefined: boolean; + currencyCode: string; + active: boolean; + bankBalance: number; + lastFeedsUpdatedAt: string | null; + amount: number; /** * Table name. @@ -113,7 +125,7 @@ export class Account extends mixin(TenantModel, [ * Model modifiers. */ static get modifiers() { - const TABLE_NAME = Account.tableName; + const TABLE_NAME = AccountModel.tableName; return { /** diff --git a/packages/server-nest/src/modules/Accounts/models/AccountTransaction.model.ts b/packages/server-nest/src/modules/Accounts/models/AccountTransaction.model.ts new file mode 100644 index 000000000..9ae4f9c6b --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/models/AccountTransaction.model.ts @@ -0,0 +1,218 @@ +import { Model, raw } from 'objection'; +import moment from 'moment'; +import { isEmpty, castArray } from 'lodash'; +import { BaseModel } from '@/models/Model'; +// import { getTransactionTypeLabel } from '@/utils/transactions-types'; + +export class AccountTransaction extends BaseModel { + referenceType: string; + referenceId: number; + credit: number; + debit: number; + exchangeRate: number; + taxRate: number; + date: string; + transactionType: string; + currencyCode: string; + referenceTypeFormatted: string; + + /** + * Table name + */ + static get tableName() { + return 'accounts_transactions'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt']; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return ['referenceTypeFormatted', 'creditLocal', 'debitLocal']; + } + + /** + * Retrieves the credit amount in base currency. + * @return {number} + */ + get creditLocal() { + return this.credit * this.exchangeRate; + } + + /** + * Retrieves the debit amount in base currency. + * @return {number} + */ + get debitLocal() { + return this.debit * this.exchangeRate; + } + + // /** + // * Retrieve formatted reference type. + // * @return {string} + // */ + // get referenceTypeFormatted() { + // return getTransactionTypeLabel(this.referenceType, this.transactionType); + // } + + // /** + // * Model modifiers. + // */ + // static get modifiers() { + // return { + // /** + // * Filters accounts by the given ids. + // * @param {Query} query + // * @param {number[]} accountsIds + // */ + // filterAccounts(query, accountsIds) { + // if (Array.isArray(accountsIds) && accountsIds.length > 0) { + // query.whereIn('account_id', accountsIds); + // } + // }, + // filterTransactionTypes(query, types) { + // if (Array.isArray(types) && types.length > 0) { + // query.whereIn('reference_type', types); + // } else if (typeof types === 'string') { + // query.where('reference_type', types); + // } + // }, + // filterDateRange(query, startDate, endDate, type = 'day') { + // const dateFormat = 'YYYY-MM-DD'; + // const fromDate = moment(startDate).startOf(type).format(dateFormat); + // const toDate = moment(endDate).endOf(type).format(dateFormat); + + // if (startDate) { + // query.where('date', '>=', fromDate); + // } + // if (endDate) { + // query.where('date', '<=', toDate); + // } + // }, + // filterAmountRange(query, fromAmount, toAmount) { + // if (fromAmount) { + // query.andWhere((q) => { + // q.where('credit', '>=', fromAmount); + // q.orWhere('debit', '>=', fromAmount); + // }); + // } + // if (toAmount) { + // query.andWhere((q) => { + // q.where('credit', '<=', toAmount); + // q.orWhere('debit', '<=', toAmount); + // }); + // } + // }, + // sumationCreditDebit(query) { + // query.select(['accountId']); + + // query.sum('credit as credit'); + // query.sum('debit as debit'); + // query.groupBy('account_id'); + // }, + // filterContactType(query, contactType) { + // query.where('contact_type', contactType); + // }, + // filterContactIds(query, contactIds) { + // query.whereIn('contact_id', contactIds); + // }, + // openingBalance(query, fromDate) { + // query.modify('filterDateRange', null, fromDate); + // query.modify('sumationCreditDebit'); + // }, + // closingBalance(query, toDate) { + // query.modify('filterDateRange', null, toDate); + // query.modify('sumationCreditDebit'); + // }, + // contactsOpeningBalance( + // query, + // openingDate, + // receivableAccounts, + // customersIds + // ) { + // // Filter by date. + // query.modify('filterDateRange', null, openingDate); + + // // Filter by customers. + // query.whereNot('contactId', null); + // query.whereIn('accountId', castArray(receivableAccounts)); + + // if (!isEmpty(customersIds)) { + // query.whereIn('contactId', castArray(customersIds)); + // } + // // Group by the contact transactions. + // query.groupBy('contactId'); + // query.sum('credit as credit'); + // query.sum('debit as debit'); + // query.select('contactId'); + // }, + // creditDebitSummation(query) { + // query.sum('credit as credit'); + // query.sum('debit as debit'); + // }, + // groupByDateFormat(query, groupType = 'month') { + // const groupBy = { + // day: '%Y-%m-%d', + // month: '%Y-%m', + // year: '%Y', + // }; + // const dateFormat = groupBy[groupType]; + + // query.select(raw(`DATE_FORMAT(DATE, '${dateFormat}')`).as('date')); + // query.groupByRaw(`DATE_FORMAT(DATE, '${dateFormat}')`); + // }, + + // filterByBranches(query, branchesIds) { + // const formattedBranchesIds = castArray(branchesIds); + + // query.whereIn('branchId', formattedBranchesIds); + // }, + + // filterByProjects(query, projectsIds) { + // const formattedProjectsIds = castArray(projectsIds); + + // query.whereIn('projectId', formattedProjectsIds); + // }, + // }; + // } + + // /** + // * Relationship mapping. + // */ + // static get relationMappings() { + // const Account = require('models/Account'); + // const Contact = require('models/Contact'); + + // return { + // account: { + // relation: Model.BelongsToOneRelation, + // modelClass: Account.default, + // join: { + // from: 'accounts_transactions.accountId', + // to: 'accounts.id', + // }, + // }, + // contact: { + // relation: Model.BelongsToOneRelation, + // modelClass: Contact.default, + // join: { + // from: 'accounts_transactions.contactId', + // to: 'contacts.id', + // }, + // }, + // }; + // } + + /** + * Prevents mutate base currency since the model is not empty. + */ + static get preventMutateBaseCurrency() { + return true; + } +} diff --git a/packages/server-nest/src/modules/Accounts/repositories/Account.repository.ts b/packages/server-nest/src/modules/Accounts/repositories/Account.repository.ts new file mode 100644 index 000000000..586b79f4e --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/repositories/Account.repository.ts @@ -0,0 +1,291 @@ +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 { TenantMetadata } from '@/modules/System/models/TenantMetadataModel'; +// import { IAccount } from '../Accounts.types'; +// import { +// PrepardExpenses, +// StripeClearingAccount, +// TaxPayableAccount, +// UnearnedRevenueAccount, +// } from '../Accounts.constants'; + +@Injectable({ scope: Scope.REQUEST }) +export class AccountRepository extends TenantRepository { + @Inject(TENANCY_DB_CONNECTION) + private readonly tenantDBKnex: Knex; + + /** + * Gets the repository's model. + */ + get model(): typeof AccountModel { + return AccountModel.bindKnex(this.tenantDBKnex); + } + + /** + * Retrieve accounts dependency graph. + * @param {string} withRelation + * @param {Knex.Transaction} trx + * @returns {} + */ + public async getDependencyGraph( + withRelation?: string, + trx?: Knex.Transaction, + ) { + const accounts = await this.all(withRelation, trx); + + return this.model.toDependencyGraph(accounts); + } + + /** + * Retrieve account by slug. + * @param {string} slug + * @return {Promise} + */ + public findBySlug(slug: string) { + return this.findOne({ slug }); + } + + // /** + // * Changes account balance. + // * @param {number} accountId + // * @param {number} amount + // * @return {Promise} + // */ + // async balanceChange(accountId: number, amount: number): Promise { + // const method: string = amount < 0 ? 'decrement' : 'increment'; + + // await this.model.query().where('id', accountId)[method]('amount', amount); + // this.flushCache(); + // } + + /** + * Activate user by the given id. + * @param {number} userId - User id. + * @return {Promise} + */ + activateById(userId: number): Promise { + return super.update({ active: 1 }, { id: userId }); + } + + /** + * Inactivate user by the given id. + * @param {number} userId - User id. + * @return {Promise} + */ + inactivateById(userId: number): Promise { + return super.update({ active: 0 }, { id: userId }); + } + + /** + * Activate user by the given id. + * @param {number} userId - User id. + * @return {Promise} + */ + async activateByIds(userIds: number[], trx): Promise { + const results = await this.model + .query(trx) + .whereIn('id', userIds) + .patch({ active: true }); + + return results; + } + + /** + * Inactivate user by the given id. + * @param {number} userId - User id. + * @return {Promise} + */ + async inactivateByIds(userIds: number[], trx): Promise { + const results = await this.model + .query(trx) + .whereIn('id', userIds) + .patch({ active: false }); + + return results; + } + + // /** + // * + // * @param {string} currencyCode + // * @param extraAttrs + // * @param trx + // * @returns + // */ + // findOrCreateAccountReceivable = async ( + // currencyCode: string = '', + // extraAttrs = {}, + // trx?: Knex.Transaction, + // ) => { + // let result = await this.model + // .query(trx) + // .onBuild((query) => { + // if (currencyCode) { + // query.where('currencyCode', currencyCode); + // } + // query.where('accountType', 'accounts-receivable'); + // }) + // .first(); + + // if (!result) { + // result = await this.model.query(trx).insertAndFetch({ + // name: this.i18n.__('account.accounts_receivable.currency', { + // currency: currencyCode, + // }), + // accountType: 'accounts-receivable', + // currencyCode, + // active: 1, + // ...extraAttrs, + // }); + // } + // return result; + // }; + + // /** + // * Find or create tax payable account. + // * @param {Record}extraAttrs + // * @param {Knex.Transaction} trx + // * @returns + // */ + // async findOrCreateTaxPayable( + // extraAttrs: Record = {}, + // trx?: Knex.Transaction, + // ) { + // let result = await this.model + // .query(trx) + // .findOne({ slug: TaxPayableAccount.slug, ...extraAttrs }); + + // if (!result) { + // result = await this.model.query(trx).insertAndFetch({ + // ...TaxPayableAccount, + // ...extraAttrs, + // }); + // } + // return result; + // } + + // findOrCreateAccountsPayable = async ( + // currencyCode: string = '', + // extraAttrs = {}, + // trx?: Knex.Transaction, + // ) => { + // let result = await this.model + // .query(trx) + // .onBuild((query) => { + // if (currencyCode) { + // query.where('currencyCode', currencyCode); + // } + // query.where('accountType', 'accounts-payable'); + // }) + // .first(); + + // if (!result) { + // result = await this.model.query(trx).insertAndFetch({ + // name: this.i18n.__('account.accounts_payable.currency', { + // currency: currencyCode, + // }), + // accountType: 'accounts-payable', + // currencyCode, + // active: 1, + // ...extraAttrs, + // }); + // } + // return result; + // }; + + // /** + // * Finds or creates the unearned revenue. + // * @param {Record} extraAttrs + // * @param {Knex.Transaction} trx + // * @returns + // */ + // public async findOrCreateUnearnedRevenue( + // extraAttrs: Record = {}, + // trx?: Knex.Transaction, + // ) { + // // Retrieves the given tenant metadata. + // const tenantMeta = await TenantMetadata.query().findOne({ + // tenantId: this.tenantId, + // }); + // const _extraAttrs = { + // currencyCode: tenantMeta.baseCurrency, + // ...extraAttrs, + // }; + // let result = await this.model + // .query(trx) + // .findOne({ slug: UnearnedRevenueAccount.slug, ..._extraAttrs }); + + // if (!result) { + // result = await this.model.query(trx).insertAndFetch({ + // ...UnearnedRevenueAccount, + // ..._extraAttrs, + // }); + // } + // return result; + // } + + // /** + // * Finds or creates the prepard expenses account. + // * @param {Record} extraAttrs + // * @param {Knex.Transaction} trx + // * @returns + // */ + // public async findOrCreatePrepardExpenses( + // extraAttrs: Record = {}, + // trx?: Knex.Transaction, + // ) { + // // Retrieves the given tenant metadata. + // const tenantMeta = await TenantMetadata.query().findOne({ + // tenantId: this.tenantId, + // }); + // const _extraAttrs = { + // currencyCode: tenantMeta.baseCurrency, + // ...extraAttrs, + // }; + + // let result = await this.model + // .query(trx) + // .findOne({ slug: PrepardExpenses.slug, ..._extraAttrs }); + + // if (!result) { + // result = await this.model.query(trx).insertAndFetch({ + // ...PrepardExpenses, + // ..._extraAttrs, + // }); + // } + // return result; + // } + + // /** + // * Finds or creates the stripe clearing account. + // * @param {Record} extraAttrs + // * @param {Knex.Transaction} trx + // * @returns + // */ + // public async findOrCreateStripeClearing( + // extraAttrs: Record = {}, + // trx?: Knex.Transaction, + // ) { + // // Retrieves the given tenant metadata. + // const tenantMeta = await TenantMetadata.query().findOne({ + // tenantId: this.tenantId, + // }); + // const _extraAttrs = { + // currencyCode: tenantMeta.baseCurrency, + // ...extraAttrs, + // }; + // let result = await this.model + // .query(trx) + // .findOne({ slug: StripeClearingAccount.slug, ..._extraAttrs }); + + // if (!result) { + // result = await this.model.query(trx).insertAndFetch({ + // ...StripeClearingAccount, + // ..._extraAttrs, + // }); + // } + // return result; + // } +} diff --git a/packages/server-nest/src/modules/Accounts/susbcribers/MutateBaseCurrencyAccounts.ts b/packages/server-nest/src/modules/Accounts/susbcribers/MutateBaseCurrencyAccounts.ts new file mode 100644 index 000000000..48903a36b --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/susbcribers/MutateBaseCurrencyAccounts.ts @@ -0,0 +1,34 @@ +// import { Service, Inject } from 'typedi'; +// import events from '@/subscribers/events'; +// import { MutateBaseCurrencyAccounts } from '../MutateBaseCurrencyAccounts'; + +// @Service() +// export class MutateBaseCurrencyAccountsSubscriber { +// @Inject() +// public mutateBaseCurrencyAccounts: MutateBaseCurrencyAccounts; + +// /** +// * Attaches the events with handles. +// * @param bus +// */ +// attach(bus) { +// bus.subscribe( +// events.organization.baseCurrencyUpdated, +// this.updateAccountsCurrencyOnBaseCurrencyMutated +// ); +// } + +// /** +// * Updates the all accounts currency once the base currency +// * of the organization is mutated. +// */ +// private updateAccountsCurrencyOnBaseCurrencyMutated = async ({ +// tenantId, +// organizationDTO, +// }) => { +// await this.mutateBaseCurrencyAccounts.mutateAllAccountsCurrency( +// tenantId, +// organizationDTO.baseCurrency +// ); +// }; +// } diff --git a/packages/server-nest/src/modules/Accounts/utils/AccountType.utils.ts b/packages/server-nest/src/modules/Accounts/utils/AccountType.utils.ts new file mode 100644 index 000000000..f2da89076 --- /dev/null +++ b/packages/server-nest/src/modules/Accounts/utils/AccountType.utils.ts @@ -0,0 +1,101 @@ +import { get } from 'lodash'; +import { ACCOUNT_TYPES } from '../Accounts.constants'; + +export class AccountTypesUtils { + /** + * Retrieve account types list. + */ + static getList() { + return ACCOUNT_TYPES; + } + + /** + * Retrieve accounts types by the given root type. + * @param {string} rootType - + * @return {string} + */ + static getTypesByRootType(rootType: string) { + return ACCOUNT_TYPES.filter((type) => type.rootType === rootType); + } + + /** + * Retrieve account type by the given account type key. + * @param {string} key + * @param {string} accessor + */ + static getType(key: string, accessor?: string) { + const type = ACCOUNT_TYPES.find((type) => type.key === key); + + if (accessor) { + return get(type, accessor); + } + return type; + } + + /** + * Retrieve accounts types by the parent account type. + * @param {string} parentType + */ + static getTypesByParentType(parentType: string) { + return ACCOUNT_TYPES.filter((type) => type.parentType === parentType); + } + + /** + * Retrieve accounts types by the given account normal. + * @param {string} normal + */ + static getTypesByNormal(normal: string) { + return ACCOUNT_TYPES.filter((type) => type.normal === normal); + } + + /** + * Detarmines whether the root type equals the account type. + * @param {string} key + * @param {string} rootType + */ + static isRootTypeEqualsKey(key: string, rootType: string): boolean { + return ACCOUNT_TYPES.some((type) => { + const isType = type.key === key; + const isRootType = type.rootType === rootType; + + return isType && isRootType; + }); + } + + /** + * Detarmines whether the parent account type equals the account type key. + * @param {string} key - Account type key. + * @param {string} parentType - Account parent type. + */ + static isParentTypeEqualsKey(key: string, parentType: string): boolean { + return ACCOUNT_TYPES.some((type) => { + const isType = type.key === key; + const isParentType = type.parentType === parentType; + + return isType && isParentType; + }); + } + + /** + * Detarmines whether account type has balance sheet. + * @param {string} key - Account type key. + * + */ + static isTypeBalanceSheet(key: string): boolean { + return ACCOUNT_TYPES.some((type) => { + const isType = type.key === key; + return isType && type.balanceSheet; + }); + } + + /** + * Detarmines whether account type has profit/loss sheet. + * @param {string} key - Account type key. + */ + static isTypePLSheet(key: string): boolean { + return ACCOUNT_TYPES.some((type) => { + const isType = type.key === key; + return isType && type.incomeSheet; + }); + } +} diff --git a/packages/server-nest/src/modules/App/App.module.ts b/packages/server-nest/src/modules/App/App.module.ts index 058f5edaa..20e5246f7 100644 --- a/packages/server-nest/src/modules/App/App.module.ts +++ b/packages/server-nest/src/modules/App/App.module.ts @@ -31,6 +31,7 @@ import { UserIpInterceptor } from '@/interceptors/user-ip.interceptor'; import { TenancyGlobalMiddleware } from '../Tenancy/TenancyGlobal.middleware'; import { TransformerInjectable } from '../Transformer/TransformerInjectable.service'; import { TransformerModule } from '../Transformer/Transformer.module'; +import { AccountsModule } from '../Accounts/Accounts.module'; @Module({ imports: [ @@ -85,6 +86,7 @@ import { TransformerModule } from '../Transformer/Transformer.module'; TenancyDatabaseModule, TenancyModelsModule, ItemsModule, + AccountsModule ], controllers: [AppController], providers: [ diff --git a/packages/server-nest/src/modules/Items/Item.transformer.ts b/packages/server-nest/src/modules/Items/Item.transformer.ts index 312041772..6ab6fb909 100644 --- a/packages/server-nest/src/modules/Items/Item.transformer.ts +++ b/packages/server-nest/src/modules/Items/Item.transformer.ts @@ -1,4 +1,5 @@ import { Transformer } from '../Transformer/Transformer'; +import { Item } from './models/Item'; // import { GetItemWarehouseTransformer } from '@/services/Warehouses/Items/GettItemWarehouseTransformer'; export class ItemTransformer extends Transformer { @@ -20,7 +21,7 @@ export class ItemTransformer extends Transformer { * @param {IItem} item * @returns {string} */ - public typeFormatted(item): string { + public typeFormatted(item: Item): string { return this.context.i18n.t(`item.field.type.${item.type}`); } @@ -29,7 +30,7 @@ export class ItemTransformer extends Transformer { * @param item * @returns {string} */ - public sellPriceFormatted(item): string { + public sellPriceFormatted(item: Item): string { return this.formatNumber(item.sellPrice, { currencyCode: this.context.organization.baseCurrency, }); @@ -40,7 +41,7 @@ export class ItemTransformer extends Transformer { * @param item * @returns {string} */ - public costPriceFormatted(item): string { + public costPriceFormatted(item: Item): string { return this.formatNumber(item.costPrice, { currencyCode: this.context.organization.baseCurrency, }); diff --git a/packages/server-nest/src/modules/Items/ItemValidator.service.ts b/packages/server-nest/src/modules/Items/ItemValidator.service.ts index 32df0ed90..25d251359 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 { Account } from '../Accounts/models/Account'; +import { AccountModel } from '../Accounts/models/Account.model'; @Injectable() export class ItemsValidators { constructor( @Inject(Item.name) private itemModel: typeof Item, - @Inject(Account.name) private accountModel: typeof Account, + @Inject(AccountModel.name) private accountModel: typeof AccountModel, @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 2647d154e..fef4c1913 100644 --- a/packages/server-nest/src/modules/Tenancy/TenancyModels/Tenancy.module.ts +++ b/packages/server-nest/src/modules/Tenancy/TenancyModels/Tenancy.module.ts @@ -3,10 +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 { Account } from '@/modules/Accounts/models/Account'; +import { AccountModel } from '@/modules/Accounts/models/Account.model'; import { ItemEntry } from '@/modules/Items/models/ItemEntry'; +import { AccountTransaction } from '@/modules/Accounts/models/AccountTransaction.model'; -const models = [Item, Account, ItemEntry]; +const models = [Item, AccountModel, ItemEntry, AccountTransaction]; const modelProviders = models.map((model) => { return {