diff --git a/server/src/api/controllers/Expenses.ts b/server/src/api/controllers/Expenses.ts index 33c2cc91c..1cbcd2eb4 100644 --- a/server/src/api/controllers/Expenses.ts +++ b/server/src/api/controllers/Expenses.ts @@ -170,7 +170,10 @@ export default class ExpensesController extends BaseController { expenseDTO, user ); - return res.status(200).send({ id: expense.id }); + return res.status(200).send({ + id: expense.id, + message: 'The expense has been created successfully.', + }); } catch (error) { next(error); } @@ -196,7 +199,7 @@ export default class ExpensesController extends BaseController { ); return res.status(200).send({ id: expenseId, - message: 'The expense has been created successfully.' + message: 'The expense has been edited successfully.', }); } catch (error) { next(error); @@ -283,7 +286,9 @@ export default class ExpensesController extends BaseController { const { ids: expensesIds } = req.query; try { - await this.expensesService.publishBulkExpenses( + const { + meta: { alreadyPublished, published, total }, + } = await this.expensesService.publishBulkExpenses( tenantId, expensesIds, user @@ -291,6 +296,11 @@ export default class ExpensesController extends BaseController { return res.status(200).send({ ids: expensesIds, message: 'The expenses have been published successfully.', + meta: { + alreadyPublished, + published, + total, + }, }); } catch (error) { next(error); @@ -372,6 +382,11 @@ export default class ExpensesController extends BaseController { errors: [{ type: 'EXPENSE_NOT_FOUND', code: 100 }], }); } + if (error.errorType === 'EXPENSES_NOT_FOUND') { + return res.boom.badRequest('Expenses not found.', { + errors: [{ type: 'EXPENSES_NOT_FOUND', code: 110 }], + }); + } if (error.errorType === 'total_amount_equals_zero') { return res.boom.badRequest('Expense total should not equal zero.', { errors: [{ type: 'TOTAL.AMOUNT.EQUALS.ZERO', code: 200 }], diff --git a/server/src/interfaces/Expenses.ts b/server/src/interfaces/Expenses.ts index 8a7ebac76..f2c5edba0 100644 --- a/server/src/interfaces/Expenses.ts +++ b/server/src/interfaces/Expenses.ts @@ -1,70 +1,111 @@ -import { ISystemUser } from "./User"; +import { ISystemUser } from './User'; export interface IPaginationMeta { - total: number, - page: number, - pageSize: number, -}; + total: number; + page: number; + pageSize: number; +} -export interface IExpensesFilter{ - page: number, - pageSize: number, -}; +export interface IExpensesFilter { + page: number; + pageSize: number; +} export interface IExpense { - id: number, - totalAmount: number, - currencyCode: string, - description?: string, - paymentAccountId: number, - peyeeId?: number, - referenceNo?: string, - publishedAt: Date|null, - userId: number, - paymentDate: Date, - payeeId: number, - categories: IExpenseCategory[], + id: number; + totalAmount: number; + currencyCode: string; + description?: string; + paymentAccountId: number; + peyeeId?: number; + referenceNo?: string; + publishedAt: Date | null; + userId: number; + paymentDate: Date; + payeeId: number; + categories: IExpenseCategory[]; } export interface IExpenseCategory { - expenseAccountId: number, - index: number, - description: string, - expenseId: number, - amount: number, + expenseAccountId: number; + index: number; + description: string; + expenseId: number; + amount: number; } export interface IExpenseDTO { - currencyCode: string, - description?: string, - paymentAccountId: number, - peyeeId?: number, - referenceNo?: string, - publish: boolean, - userId: number, - paymentDate: Date, - payeeId: number, - categories: IExpenseCategoryDTO[], + currencyCode: string; + description?: string; + paymentAccountId: number; + peyeeId?: number; + referenceNo?: string; + publish: boolean; + userId: number; + paymentDate: Date; + payeeId: number; + categories: IExpenseCategoryDTO[]; } export interface IExpenseCategoryDTO { - expenseAccountId: number, - index: number, - description?: string, - expenseId: number, -}; + expenseAccountId: number; + index: number; + description?: string; + expenseId: number; +} export interface IExpensesService { - newExpense(tenantid: number, expenseDTO: IExpenseDTO, authorizedUser: ISystemUser): Promise; - editExpense(tenantid: number, expenseId: number, expenseDTO: IExpenseDTO, authorizedUser: ISystemUser): void; + newExpense( + tenantid: number, + expenseDTO: IExpenseDTO, + authorizedUser: ISystemUser + ): Promise; - publishExpense(tenantId: number, expenseId: number, authorizedUser: ISystemUser): Promise; + editExpense( + tenantid: number, + expenseId: number, + expenseDTO: IExpenseDTO, + authorizedUser: ISystemUser + ): void; - deleteExpense(tenantId: number, expenseId: number, authorizedUser: ISystemUser): Promise; - deleteBulkExpenses(tenantId: number, expensesIds: number[], authorizedUser: ISystemUser): Promise; + publishExpense( + tenantId: number, + expenseId: number, + authorizedUser: ISystemUser + ): Promise; - publishBulkExpenses(tenantId: number, expensesIds: number[], authorizedUser: ISystemUser): Promise; + deleteExpense( + tenantId: number, + expenseId: number, + authorizedUser: ISystemUser + ): Promise; + + deleteBulkExpenses( + tenantId: number, + expensesIds: number[], + authorizedUser: ISystemUser + ): Promise; + + publishBulkExpenses( + tenantId: number, + expensesIds: number[], + authorizedUser: ISystemUser + ): Promise<{ + meta: { + alreadyPublished: number; + published: number; + total: number, + }, + }>; + + getExpensesList( + tenantId: number, + expensesFilter: IExpensesFilter + ): Promise<{ + expenses: IExpense[]; + pagination: IPaginationMeta; + filterMeta: IFilterMeta; + }>; - getExpensesList(tenantId: number, expensesFilter: IExpensesFilter): Promise<{ expenses: IExpense[], pagination: IPaginationMeta, filterMeta: IFilterMeta }>; getExpense(tenantId: number, expenseId: number): Promise; -} \ No newline at end of file +} diff --git a/server/src/repositories/ExpenseEntryRepository.ts b/server/src/repositories/ExpenseEntryRepository.ts index 5a6b5a639..4f84e81a3 100644 --- a/server/src/repositories/ExpenseEntryRepository.ts +++ b/server/src/repositories/ExpenseEntryRepository.ts @@ -1,7 +1,7 @@ import TenantRepository from "./TenantRepository"; import { ExpenseCategory } from 'models'; -export default class ExpenseEntyRepository extends TenantRepository { +export default class ExpenseEntryRepository extends TenantRepository { /** * Gets the repository's model. */ diff --git a/server/src/services/Accounting/JournalCommands.ts b/server/src/services/Accounting/JournalCommands.ts index d269befa7..0ee0a92b2 100644 --- a/server/src/services/Accounting/JournalCommands.ts +++ b/server/src/services/Accounting/JournalCommands.ts @@ -225,12 +225,15 @@ export default class JournalCommands { * Writes journal entries of expense model object. * @param {IExpense} expense */ - expense(expense: IExpense) { + expense( + expense: IExpense, + userId: number, + ) { const mixinEntry = { referenceType: 'Expense', referenceId: expense.id, date: expense.paymentDate, - userId: expense.userId, + userId, draft: !expense.publishedAt, }; const paymentJournalEntry = new JournalEntry({ diff --git a/server/src/services/Accounts/AccountsService.ts b/server/src/services/Accounts/AccountsService.ts index 7d073c31b..9a0f8843d 100644 --- a/server/src/services/Accounts/AccountsService.ts +++ b/server/src/services/Accounts/AccountsService.ts @@ -16,6 +16,22 @@ import { import DynamicListingService from 'services/DynamicListing/DynamicListService'; import events from 'subscribers/events'; +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', +} + @Service() export default class AccountsService { @Inject() @@ -50,7 +66,7 @@ export default class AccountsService { if (!accountType) { this.logger.info('[accounts] account type not found.'); - throw new ServiceError('account_type_not_found'); + throw new ServiceError(ERRORS.ACCOUNT_TYPE_NOT_FOUND); } return accountType; } @@ -85,7 +101,7 @@ export default class AccountsService { tenantId, accountId, }); - throw new ServiceError('parent_account_not_found'); + throw new ServiceError(ERRORS.PARENT_ACCOUNT_NOT_FOUND); } return parentAccount; } @@ -124,7 +140,7 @@ export default class AccountsService { tenantId, accountCode, }); - throw new ServiceError('account_code_not_unique'); + throw new ServiceError(ERRORS.ACCOUNT_CODE_NOT_UNIQUE); } } @@ -138,7 +154,7 @@ export default class AccountsService { parentAccount: IAccount ) { if (accountDTO.accountTypeId !== parentAccount.accountTypeId) { - throw new ServiceError('parent_has_different_type'); + throw new ServiceError(ERRORS.PARENT_ACCOUNT_HAS_DIFFERENT_TYPE); } } @@ -161,7 +177,7 @@ export default class AccountsService { this.logger.info('[accounts] the given account not found.', { accountId, }); - throw new ServiceError('account_not_found'); + throw new ServiceError(ERRORS.ACCOUNT_NOT_FOUND); } return account; } @@ -178,7 +194,7 @@ export default class AccountsService { newAccount: IAccount | IAccountDTO ) { if (oldAccount.accountTypeId !== newAccount.accountTypeId) { - throw new ServiceError('account_type_not_allowed_to_changed'); + throw new ServiceError(ERRORS.ACCOUNT_TYPE_NOT_ALLOWED_TO_CHANGE); } } @@ -208,7 +224,7 @@ export default class AccountsService { } }); if (foundAccount) { - throw new ServiceError('account_name_not_unqiue'); + throw new ServiceError(ERRORS.ACCOUNT_NAME_NOT_UNIQUE); } } @@ -346,7 +362,7 @@ export default class AccountsService { */ private throwErrorIfAccountPredefined(account: IAccount) { if (account.predefined) { - throw new ServiceError('account_predefined'); + throw new ServiceError(ERRORS.ACCOUNT_PREDEFINED); } } @@ -384,7 +400,7 @@ export default class AccountsService { accountId ); if (accountTransactions.length > 0) { - throw new ServiceError('account_has_associated_transactions'); + throw new ServiceError(ERRORS.ACCOUNT_HAS_ASSOCIATED_TRANSACTIONS); } } @@ -441,7 +457,7 @@ export default class AccountsService { tenantId, notFoundAccounts, }); - throw new ServiceError('accounts_not_found'); + throw new ServiceError(ERRORS.ACCOUNTS_NOT_FOUND); } return storedAccounts; } @@ -458,7 +474,7 @@ export default class AccountsService { if (predefined.length > 0) { this.logger.error('[accounts] some accounts predefined.', { predefined }); - throw new ServiceError('predefined_accounts'); + throw new ServiceError(ERRORS.PREDEFINED_ACCOUNTS); } return predefined; } @@ -487,7 +503,7 @@ export default class AccountsService { } }); if (accountsHasTransactions.length > 0) { - throw new ServiceError('accounts_have_transactions'); + throw new ServiceError(ERRORS.ACCOUNTS_HAVE_TRANSACTIONS); } } @@ -677,7 +693,7 @@ export default class AccountsService { ); if (accountType.rootType !== toAccountType.rootType) { - throw new ServiceError('close_account_and_to_account_not_same_type'); + throw new ServiceError(ERRORS.CLOSE_ACCOUNT_AND_TO_ACCOUNT_NOT_SAME_TYPE); } const updateAccountBalanceOper = await accountRepository.balanceChange( accountId, diff --git a/server/src/services/Expenses/ExpensesService.ts b/server/src/services/Expenses/ExpensesService.ts index c348bf227..290f6f6ba 100644 --- a/server/src/services/Expenses/ExpensesService.ts +++ b/server/src/services/Expenses/ExpensesService.ts @@ -1,18 +1,26 @@ -import { Service, Inject } from "typedi"; -import { difference, sumBy, omit } from 'lodash'; -import moment from "moment"; +import { Service, Inject } from 'typedi'; +import { difference, sumBy, omit, map } from 'lodash'; +import moment from 'moment'; import { EventDispatcher, EventDispatcherInterface, } from 'decorators/eventDispatcher'; -import { ServiceError } from "exceptions"; +import { ServiceError } from 'exceptions'; import TenancyService from 'services/Tenancy/TenancyService'; import JournalPoster from 'services/Accounting/JournalPoster'; import JournalCommands from 'services/Accounting/JournalCommands'; -import { IExpense, IExpensesFilter, IAccount, IExpenseDTO, IExpensesService, ISystemUser, IPaginationMeta } from 'interfaces'; +import { + IExpense, + IExpensesFilter, + IAccount, + IExpenseDTO, + IExpensesService, + ISystemUser, + IPaginationMeta, +} from 'interfaces'; import DynamicListingService from 'services/DynamicListing/DynamicListService'; import events from 'subscribers/events'; -import ContactsService from "services/Contacts/ContactsService"; +import ContactsService from 'services/Contacts/ContactsService'; const ERRORS = { EXPENSE_NOT_FOUND: 'expense_not_found', @@ -43,20 +51,31 @@ export default class ExpensesService implements IExpensesService { contactsService: ContactsService; /** - * Retrieve the payment account details or returns not found server error in case the + * Retrieve the payment account details or returns not found server error in case the * given account not found on the storage. - * @param {number} tenantId - * @param {number} paymentAccountId + * @param {number} tenantId + * @param {number} paymentAccountId * @returns {Promise} */ - private async getPaymentAccountOrThrowError(tenantId: number, paymentAccountId: number) { - this.logger.info('[expenses] trying to get the given payment account.', { tenantId, paymentAccountId }); + private async getPaymentAccountOrThrowError( + tenantId: number, + paymentAccountId: number + ) { + this.logger.info('[expenses] trying to get the given payment account.', { + tenantId, + paymentAccountId, + }); const { accountRepository } = this.tenancy.repositories(tenantId); - const paymentAccount = await accountRepository.findOneById(paymentAccountId) + const paymentAccount = await accountRepository.findOneById( + paymentAccountId + ); if (!paymentAccount) { - this.logger.info('[expenses] the given payment account not found.', { tenantId, paymentAccountId }); + this.logger.info('[expenses] the given payment account not found.', { + tenantId, + paymentAccountId, + }); throw new ServiceError(ERRORS.PAYMENT_ACCOUNT_NOT_FOUND); } return paymentAccount; @@ -65,25 +84,37 @@ export default class ExpensesService implements IExpensesService { /** * Retrieve expense accounts or throw error in case one of the given accounts * not found not the storage. - * @param {number} tenantId - * @param {number} expenseAccountsIds + * @param {number} tenantId + * @param {number} expenseAccountsIds * @throws {ServiceError} * @returns {Promise} */ - private async getExpensesAccountsOrThrowError(tenantId: number, expenseAccountsIds: number[]) { - this.logger.info('[expenses] trying to get expenses accounts.', { tenantId, expenseAccountsIds }); + private async getExpensesAccountsOrThrowError( + tenantId: number, + expenseAccountsIds: number[] + ) { + this.logger.info('[expenses] trying to get expenses accounts.', { + tenantId, + expenseAccountsIds, + }); const { accountRepository } = this.tenancy.repositories(tenantId); const storedExpenseAccounts = await accountRepository.findWhereIn( - 'id', expenseAccountsIds, + 'id', + expenseAccountsIds + ); + const storedExpenseAccountsIds = storedExpenseAccounts.map( + (a: IAccount) => a.id ); - const storedExpenseAccountsIds = storedExpenseAccounts.map((a: IAccount) => a.id); const notStoredAccountsIds = difference( expenseAccountsIds, storedExpenseAccountsIds ); if (notStoredAccountsIds.length > 0) { - this.logger.info('[expenses] some of expense accounts not found.', { tenantId, expenseAccountsIds }); + this.logger.info('[expenses] some of expense accounts not found.', { + tenantId, + expenseAccountsIds, + }); throw new ServiceError(ERRORS.SOME_ACCOUNTS_NOT_FOUND); } return storedExpenseAccounts; @@ -91,33 +122,44 @@ export default class ExpensesService implements IExpensesService { /** * Validates expense categories not equals zero. - * @param {IExpenseDTO|ServiceError} expenseDTO + * @param {IExpenseDTO|ServiceError} expenseDTO * @throws {ServiceError} */ private validateCategoriesNotEqualZero(expenseDTO: IExpenseDTO) { - this.logger.info('[expenses] validate the expenses categoires not equal zero.', { expenseDTO }); + this.logger.info( + '[expenses] validate the expenses categoires not equal zero.', + { expenseDTO } + ); const totalAmount = sumBy(expenseDTO.categories, 'amount') || 0; if (totalAmount <= 0) { - this.logger.info('[expenses] the given expense categories equal zero.', { expenseDTO }); + this.logger.info('[expenses] the given expense categories equal zero.', { + expenseDTO, + }); throw new ServiceError(ERRORS.TOTAL_AMOUNT_EQUALS_ZERO); } } /** * Validate expenses accounts type. - * @param {number} tenantId - * @param {number[]} expensesAccountsIds + * @param {number} tenantId + * @param {number[]} expensesAccountsIds */ - private async validateExpensesAccountsType(tenantId: number, expensesAccounts: number[]) { - this.logger.info('[expenses] trying to validate expenses accounts type.', { tenantId, expensesAccounts }); + private async validateExpensesAccountsType( + tenantId: number, + expensesAccounts: number[] + ) { + this.logger.info('[expenses] trying to validate expenses accounts type.', { + tenantId, + expensesAccounts, + }); const { accountTypeRepository } = this.tenancy.repositories(tenantId); // Retrieve accounts types of the given root type. const expensesTypes = await accountTypeRepository.getByRootType('expense'); - const expensesTypesIds = expensesTypes.map(t => t.id); + const expensesTypesIds = expensesTypes.map((t) => t.id); const invalidExpenseAccounts: number[] = []; expensesAccounts.forEach((expenseAccount) => { @@ -132,68 +174,83 @@ export default class ExpensesService implements IExpensesService { /** * Validates payment account type in case has invalid type throws errors. - * @param {number} tenantId - * @param {number} paymentAccountId + * @param {number} tenantId + * @param {number} paymentAccountId * @throws {ServiceError} */ - private async validatePaymentAccountType(tenantId: number, paymentAccount: number[]) { - this.logger.info('[expenses] trying to validate payment account type.', { tenantId, paymentAccount }); + private async validatePaymentAccountType( + tenantId: number, + paymentAccount: number[] + ) { + this.logger.info('[expenses] trying to validate payment account type.', { + tenantId, + paymentAccount, + }); const { accountTypeRepository } = this.tenancy.repositories(tenantId); // Retrieve account tpy eof the given key. const validAccountsType = await accountTypeRepository.getByKeys([ - 'current_asset', 'fixed_asset', + 'current_asset', + 'fixed_asset', ]); - const validAccountsTypeIds = validAccountsType.map(t => t.id); + const validAccountsTypeIds = validAccountsType.map((t) => t.id); if (validAccountsTypeIds.indexOf(paymentAccount.accountTypeId) === -1) { - this.logger.info('[expenses] the given payment account has invalid type', { tenantId, paymentAccount }); + this.logger.info( + '[expenses] the given payment account has invalid type', + { tenantId, paymentAccount } + ); throw new ServiceError(ERRORS.PAYMENT_ACCOUNT_HAS_INVALID_TYPE); } } /** * Reverts expense journal entries. - * @param {number} tenantId - * @param {number} expenseId + * @param {number} tenantId + * @param {number} expenseId */ public async revertJournalEntries( tenantId: number, - expenseId: number|number[], + expenseId: number | number[] ): Promise { const journal = new JournalPoster(tenantId); const journalCommands = new JournalCommands(journal); - + await journalCommands.revertJournalEntries(expenseId, 'Expense'); - - await Promise.all([ - journal.saveBalance(), - journal.deleteEntries(), - ]); + + await Promise.all([journal.saveBalance(), journal.deleteEntries()]); } /** * Writes expense journal entries. - * @param {number} tenantId - * @param {IExpense} expense - * @param {IUser} authorizedUser + * @param {number} tenantId + * @param {IExpense} expense + * @param {IUser} authorizedUser */ public async writeJournalEntries( tenantId: number, - expense: IExpense, - revertOld: boolean, - ) { - this.logger.info('[expense[ trying to write expense journal entries.', { tenantId, expense }); + expense: IExpense | IExpense[], + authorizedUserId: number, + override: boolean = false + ): Promise { + this.logger.info('[expense] trying to write expense journal entries.', { + tenantId, + expense, + }); const journal = new JournalPoster(tenantId); const journalCommands = new JournalCommands(journal); - if (revertOld) { - await journalCommands.revertJournalEntries(expense.id, 'Expense'); + const expenses = Array.isArray(expense) ? expense : [expense]; + const expensesIds = expenses.map((expense) => expense.id); + + if (override) { + await journalCommands.revertJournalEntries(expensesIds, 'Expense'); } - journalCommands.expense(expense); - - return Promise.all([ + expenses.forEach((expense: IExpense) => { + journalCommands.expense(expense, authorizedUserId); + }); + await Promise.all([ journal.saveBalance(), journal.saveEntries(), journal.deleteEntries(), @@ -202,20 +259,26 @@ export default class ExpensesService implements IExpensesService { /** * Retrieve the given expenses or throw not found error. - * @param {number} tenantId - * @param {number} expenseId + * @param {number} tenantId + * @param {number} expenseId * @returns {IExpense|ServiceError} */ private async getExpenseOrThrowError(tenantId: number, expenseId: number) { const { expenseRepository } = this.tenancy.repositories(tenantId); - this.logger.info('[expense] trying to get the given expense.', { tenantId, expenseId }); + this.logger.info('[expense] trying to get the given expense.', { + tenantId, + expenseId, + }); // Retrieve the given expense by id. const expense = await expenseRepository.findOneById(expenseId); if (!expense) { - this.logger.info('[expense] the given expense not found.', { tenantId, expenseId }); + this.logger.info('[expense] the given expense not found.', { + tenantId, + expenseId, + }); throw new ServiceError(ERRORS.EXPENSE_NOT_FOUND); } return expense; @@ -229,24 +292,31 @@ export default class ExpensesService implements IExpensesService { async getExpensesOrThrowError( tenantId: number, expensesIds: number[] - ): Promise { + ): Promise { const { expenseRepository } = this.tenancy.repositories(tenantId); - const storedExpenses = expenseRepository.findWhereIn('id', expensesIds); - + const storedExpenses = await expenseRepository.findWhereIn( + 'id', + expensesIds, + 'categories' + ); const storedExpensesIds = storedExpenses.map((expense) => expense.id); const notFoundExpenses = difference(expensesIds, storedExpensesIds); + // In case there is not found expenses throw service error. if (notFoundExpenses.length > 0) { - this.logger.info('[expense] the give expenses ids not found.', { tenantId, expensesIds }); - throw new ServiceError(ERRORS.EXPENSES_NOT_FOUND) + this.logger.info('[expense] the give expenses ids not found.', { + tenantId, + expensesIds, + }); + throw new ServiceError(ERRORS.EXPENSES_NOT_FOUND); } return storedExpenses; } /** * Validates expenses is not already published before. - * @param {IExpense} expense + * @param {IExpense} expense */ private validateExpenseIsNotPublished(expense: IExpense) { if (expense.publishedAt) { @@ -256,7 +326,7 @@ export default class ExpensesService implements IExpensesService { /** * Mapping expense DTO to model. - * @param {IExpenseDTO} expenseDTO + * @param {IExpenseDTO} expenseDTO * @param {ISystemUser} authorizedUser * @return {IExpense} */ @@ -268,88 +338,28 @@ export default class ExpensesService implements IExpensesService { ...omit(expenseDTO, ['publish']), totalAmount, paymentDate: moment(expenseDTO.paymentDate).toMySqlDateTime(), - ...(user) ? { - userId: user.id, - } : {}, - ...(expenseDTO.publish) ? { - publishedAt: moment().toMySqlDateTime(), - } : {}, - } + ...(user + ? { + userId: user.id, + } + : {}), + ...(expenseDTO.publish + ? { + publishedAt: moment().toMySqlDateTime(), + } + : {}), + }; } /** * Mapping the expenses accounts ids from expense DTO. - * @param {IExpenseDTO} expenseDTO + * @param {IExpenseDTO} expenseDTO * @return {number[]} */ mapExpensesAccountsIdsFromDTO(expenseDTO: IExpenseDTO) { return expenseDTO.categories.map((category) => category.expenseAccountId); } - /** - * Precedures. - * --------- - * 1. Validate expense existance. - * 2. Validate payment account existance on the storage. - * 3. Validate expense accounts exist on the storage. - * 4. Validate payment account type. - * 5. Validate expenses accounts type. - * 6. Validate the given expense categories not equal zero. - * 7. Stores the expense to the storage. - * --------- - * @param {number} tenantId - * @param {number} expenseId - * @param {IExpenseDTO} expenseDTO - * @param {ISystemUser} authorizedUser - */ - public async editExpense( - tenantId: number, - expenseId: number, - expenseDTO: IExpenseDTO, - authorizedUser: ISystemUser - ): Promise { - const { expenseRepository } = this.tenancy.repositories(tenantId); - const expense = await this.getExpenseOrThrowError(tenantId, expenseId); - - // - Validate payment account existance on the storage. - const paymentAccount = await this.getPaymentAccountOrThrowError( - tenantId, - expenseDTO.paymentAccountId, - ); - // - Validate expense accounts exist on the storage. - const expensesAccounts = await this.getExpensesAccountsOrThrowError( - tenantId, - this.mapExpensesAccountsIdsFromDTO(expenseDTO), - ); - // - Validate payment account type. - await this.validatePaymentAccountType(tenantId, paymentAccount); - - // - Validate expenses accounts type. - await this.validateExpensesAccountsType(tenantId, expensesAccounts); - - // - Validate the expense payee contact id existance on storage. - if (expenseDTO.payeeId) { - await this.contactsService.getContactByIdOrThrowError( - tenantId, - expenseDTO.payeeId, - ) - } - // - Validate the given expense categories not equal zero. - this.validateCategoriesNotEqualZero(expenseDTO); - - // - Update the expense on the storage. - const expenseObj = this.expenseDTOToModel(expenseDTO); - - // - Upsert the expense object with expense entries. - const expenseModel = await expenseRepository.upsertGraph({ - id: expenseId, - ...expenseObj, - }); - - this.logger.info('[expense] the expense updated on the storage successfully.', { tenantId, expenseDTO }); - return expenseModel; - } - /** * Precedures. * --------- @@ -361,25 +371,94 @@ export default class ExpensesService implements IExpensesService { * 6. Validate the given expense categories not equal zero. * 7. Stores the expense to the storage. * --------- - * @param {number} tenantId - * @param {IExpenseDTO} expenseDTO + * @param {number} tenantId + * @param {IExpenseDTO} expenseDTO */ public async newExpense( tenantId: number, expenseDTO: IExpenseDTO, - authorizedUser: ISystemUser, + authorizedUser: ISystemUser ): Promise { const { expenseRepository } = this.tenancy.repositories(tenantId); + // Validate payment account existance on the storage. + const paymentAccount = await this.getPaymentAccountOrThrowError( + tenantId, + expenseDTO.paymentAccountId + ); + // Validate expense accounts exist on the storage. + const expensesAccounts = await this.getExpensesAccountsOrThrowError( + tenantId, + this.mapExpensesAccountsIdsFromDTO(expenseDTO) + ); + // Validate payment account type. + await this.validatePaymentAccountType(tenantId, paymentAccount); + + // Validate expenses accounts type. + await this.validateExpensesAccountsType(tenantId, expensesAccounts); + + // Validate the expense payee contact id existance on storage. + if (expenseDTO.payeeId) { + await this.contactsService.getContactByIdOrThrowError( + tenantId, + expenseDTO.payeeId + ); + } + // Validate the given expense categories not equal zero. + this.validateCategoriesNotEqualZero(expenseDTO); + + // Save the expense to the storage. + const expenseObj = this.expenseDTOToModel(expenseDTO, authorizedUser); + const expense = await expenseRepository.upsertGraph(expenseObj); + + this.logger.info( + '[expense] the expense stored to the storage successfully.', + { tenantId, expenseDTO } + ); + // Triggers `onExpenseCreated` event. + this.eventDispatcher.dispatch(events.expenses.onCreated, { + tenantId, + expenseId: expense.id, + authorizedUser, + expense, + }); + return expense; + } + + /** + * Precedures. + * --------- + * 1. Validate expense existance. + * 2. Validate payment account existance on the storage. + * 3. Validate expense accounts exist on the storage. + * 4. Validate payment account type. + * 5. Validate expenses accounts type. + * 6. Validate the given expense categories not equal zero. + * 7. Stores the expense to the storage. + * --------- + * @param {number} tenantId + * @param {number} expenseId + * @param {IExpenseDTO} expenseDTO + * @param {ISystemUser} authorizedUser + */ + public async editExpense( + tenantId: number, + expenseId: number, + expenseDTO: IExpenseDTO, + authorizedUser: ISystemUser + ): Promise { + const { expenseRepository } = this.tenancy.repositories(tenantId); + const oldExpense = await this.getExpenseOrThrowError(tenantId, expenseId); + // - Validate payment account existance on the storage. const paymentAccount = await this.getPaymentAccountOrThrowError( tenantId, - expenseDTO.paymentAccountId, + expenseDTO.paymentAccountId ); // - Validate expense accounts exist on the storage. const expensesAccounts = await this.getExpensesAccountsOrThrowError( tenantId, - this.mapExpensesAccountsIdsFromDTO(expenseDTO), + this.mapExpensesAccountsIdsFromDTO(expenseDTO) ); // - Validate payment account type. await this.validatePaymentAccountType(tenantId, paymentAccount); @@ -391,140 +470,299 @@ export default class ExpensesService implements IExpensesService { if (expenseDTO.payeeId) { await this.contactsService.getContactByIdOrThrowError( tenantId, - expenseDTO.payeeId, - ) + expenseDTO.payeeId + ); } // - Validate the given expense categories not equal zero. this.validateCategoriesNotEqualZero(expenseDTO); - // - Save the expense to the storage. - const expenseObj = this.expenseDTOToModel(expenseDTO, authorizedUser); - const expenseModel = await expenseRepository.upsertGraph(expenseObj); + // - Update the expense on the storage. + const expenseObj = this.expenseDTOToModel(expenseDTO); - this.logger.info('[expense] the expense stored to the storage successfully.', { tenantId, expenseDTO }); + // - Upsert the expense object with expense entries. + const expense = await expenseRepository.upsertGraph({ + id: expenseId, + ...expenseObj, + }); + this.logger.info( + '[expense] the expense updated on the storage successfully.', + { tenantId, expenseId } + ); // Triggers `onExpenseCreated` event. - this.eventDispatcher.dispatch(events.expenses.onCreated, { tenantId, expenseId: expenseModel.id }); - - return expenseModel; + this.eventDispatcher.dispatch(events.expenses.onEdited, { + tenantId, + expenseId, + expense, + expenseDTO, + authorizedUser, + oldExpense, + }); + return expense; } /** * Publish the given expense. - * @param {number} tenantId - * @param {number} expenseId + * @param {number} tenantId + * @param {number} expenseId * @param {ISystemUser} authorizedUser * @return {Promise} */ - public async publishExpense(tenantId: number, expenseId: number, authorizedUser: ISystemUser) { + public async publishExpense( + tenantId: number, + expenseId: number, + authorizedUser: ISystemUser + ) { const { expenseRepository } = this.tenancy.repositories(tenantId); - const expense = await this.getExpenseOrThrowError(tenantId, expenseId); + const oldExpense = await this.getExpenseOrThrowError(tenantId, expenseId); - if (expense instanceof ServiceError) { - throw expense; + if (oldExpense instanceof ServiceError) { + throw oldExpense; } - this.validateExpenseIsNotPublished(expense); + this.validateExpenseIsNotPublished(oldExpense); - this.logger.info('[expense] trying to publish the expense.', { tenantId, expenseId }); + this.logger.info('[expense] trying to publish the expense.', { + tenantId, + expenseId, + }); + // Publish the given expense on the storage. await expenseRepository.publish(expenseId); - this.logger.info('[expense] the expense published successfully.', { tenantId, expenseId }); + // Retrieve the new expense after modification. + const expense = await expenseRepository.findOneById( + expenseId, + 'categories' + ); + this.logger.info('[expense] the expense published successfully.', { + tenantId, + expenseId, + }); // Triggers `onExpensePublished` event. - this.eventDispatcher.dispatch(events.expenses.onPublished, { tenantId, expenseId }); + this.eventDispatcher.dispatch(events.expenses.onPublished, { + tenantId, + expenseId, + oldExpense, + expense, + authorizedUser, + }); } /** * Deletes the given expense. - * @param {number} tenantId - * @param {number} expenseId + * @param {number} tenantId + * @param {number} expenseId * @param {ISystemUser} authorizedUser */ - public async deleteExpense(tenantId: number, expenseId: number, authorizedUser: ISystemUser) { - const expense = await this.getExpenseOrThrowError(tenantId, expenseId); - const { expenseRepository } = this.tenancy.repositories(tenantId); + public async deleteExpense( + tenantId: number, + expenseId: number, + authorizedUser: ISystemUser + ): Promise { + const oldExpense = await this.getExpenseOrThrowError(tenantId, expenseId); + const { + expenseRepository, + expenseEntryRepository, + } = this.tenancy.repositories(tenantId); - this.logger.info('[expense] trying to delete the expense.', { tenantId, expenseId }); + this.logger.info('[expense] trying to delete the expense.', { + tenantId, + expenseId, + }); + await expenseEntryRepository.deleteBy({ expenseId }); await expenseRepository.deleteById(expenseId); - this.logger.info('[expense] the expense deleted successfully.', { tenantId, expenseId }); + this.logger.info('[expense] the expense deleted successfully.', { + tenantId, + expenseId, + }); // Triggers `onExpenseDeleted` event. - this.eventDispatcher.dispatch(events.expenses.onDeleted, { tenantId, expenseId }); + this.eventDispatcher.dispatch(events.expenses.onDeleted, { + tenantId, + expenseId, + authorizedUser, + oldExpense, + }); } /** * Deletes the given expenses in bulk. - * @param {number} tenantId - * @param {number[]} expensesIds + * @param {number} tenantId + * @param {number[]} expensesIds * @param {ISystemUser} authorizedUser */ - public async deleteBulkExpenses(tenantId: number, expensesIds: number[], authorizedUser: ISystemUser) { - const expenses = await this.getExpensesOrThrowError(tenantId, expensesIds); - const { expenseRepository } = this.tenancy.repositories(tenantId); + public async deleteBulkExpenses( + tenantId: number, + expensesIds: number[], + authorizedUser: ISystemUser + ) { + const { + expenseRepository, + expenseEntryRepository, + } = this.tenancy.repositories(tenantId); - this.logger.info('[expense] trying to delete the given expenses.', { tenantId, expensesIds }); + // Retrieve olds expenses. + const oldExpenses = await this.getExpensesOrThrowError( + tenantId, + expensesIds + ); + + this.logger.info('[expense] trying to delete the given expenses.', { + tenantId, + expensesIds, + }); + await expenseEntryRepository.deleteWhereIn('expenseId', expensesIds); await expenseRepository.deleteWhereIdIn(expensesIds); - this.logger.info('[expense] the given expenses deleted successfully.', { tenantId, expensesIds }); - + this.logger.info('[expense] the given expenses deleted successfully.', { + tenantId, + expensesIds, + }); // Triggers `onExpenseBulkDeleted` event. - this.eventDispatcher.dispatch(events.expenses.onBulkDeleted, { tenantId, expensesIds }); + this.eventDispatcher.dispatch(events.expenses.onBulkDeleted, { + tenantId, + expensesIds, + oldExpenses, + authorizedUser, + }); + } + + /** + * Filters the not published expenses. + * @param {IExpense[]} expenses - + */ + public getNonePublishedExpenses(expenses: IExpense[]): IExpense[] { + return expenses.filter((expense) => !expense.publishedAt); + } + + /** + * Filtesr the published expenses. + * @param {IExpense[]} expenses - + * @return {IExpense[]} + */ + public getPublishedExpenses(expenses: IExpense[]): IExpense[] { + return expenses.filter((expense) => expense.publishedAt); } /** * Deletes the given expenses in bulk. - * @param {number} tenantId - * @param {number[]} expensesIds + * @param {number} tenantId + * @param {number[]} expensesIds * @param {ISystemUser} authorizedUser */ - public async publishBulkExpenses(tenantId: number, expensesIds: number[], authorizedUser: ISystemUser) { - const expenses = await this.getExpensesOrThrowError(tenantId, expensesIds); + public async publishBulkExpenses( + tenantId: number, + expensesIds: number[], + authorizedUser: ISystemUser + ): Promise<{ + meta: { + alreadyPublished: number; + published: number; + total: number, + }, + }> { + const oldExpenses = await this.getExpensesOrThrowError( + tenantId, + expensesIds + ); const { expenseRepository } = this.tenancy.repositories(tenantId); - this.logger.info('[expense] trying to publish the given expenses.', { tenantId, expensesIds }); - await expenseRepository.whereIdInPublish(expensesIds); + // Filters the not published expenses. + const notPublishedExpenses = this.getNonePublishedExpenses(oldExpenses); - this.logger.info('[expense] the given expenses ids published successfully.', { tenantId, expensesIds }); + // Filters the published expenses. + const publishedExpenses = this.getPublishedExpenses(oldExpenses); + // Mappes the published expenses to get id. + const notPublishedExpensesIds = map(notPublishedExpenses, 'id'); + + if (notPublishedExpensesIds.length > 0) { + this.logger.info('[expense] trying to publish the given expenses.', { + tenantId, + expensesIds, + }); + await expenseRepository.whereIdInPublish(notPublishedExpensesIds); + + this.logger.info( + '[expense] the given expenses ids published successfully.', + { tenantId, expensesIds } + ); + } + // Retrieve the new expenses after modification. + const expenses = await expenseRepository.findWhereIn( + 'id', + expensesIds, + 'categories' + ); // Triggers `onExpenseBulkDeleted` event. - this.eventDispatcher.dispatch(events.expenses.onBulkPublished, { tenantId, expensesIds }); + this.eventDispatcher.dispatch(events.expenses.onBulkPublished, { + tenantId, + expensesIds, + oldExpenses, + expenses, + authorizedUser, + }); + + return { + meta: { + alreadyPublished: publishedExpenses.length, + published: notPublishedExpenses.length, + total: oldExpenses.length, + }, + }; } /** * Retrieve expenses datatable lsit. - * @param {number} tenantId - * @param {IExpensesFilter} expensesFilter + * @param {number} tenantId + * @param {IExpensesFilter} expensesFilter * @return {IExpense[]} */ public async getExpensesList( tenantId: number, expensesFilter: IExpensesFilter - ): Promise<{ expenses: IExpense[], pagination: IPaginationMeta, filterMeta: IFilterMeta }> { + ): Promise<{ + expenses: IExpense[]; + pagination: IPaginationMeta; + filterMeta: IFilterMeta; + }> { const { Expense } = this.tenancy.models(tenantId); - const dynamicFilter = await this.dynamicListService.dynamicList(tenantId, Expense, expensesFilter); + const dynamicFilter = await this.dynamicListService.dynamicList( + tenantId, + Expense, + expensesFilter + ); - this.logger.info('[expense] trying to get expenses datatable list.', { tenantId, expensesFilter }); - const { results, pagination } = await Expense.query().onBuild((builder) => { - builder.withGraphFetched('paymentAccount'); - builder.withGraphFetched('categories.expenseAccount'); - dynamicFilter.buildQuery()(builder); - }).pagination(expensesFilter.page - 1, expensesFilter.pageSize); + this.logger.info('[expense] trying to get expenses datatable list.', { + tenantId, + expensesFilter, + }); + const { results, pagination } = await Expense.query() + .onBuild((builder) => { + builder.withGraphFetched('paymentAccount'); + builder.withGraphFetched('categories.expenseAccount'); + dynamicFilter.buildQuery()(builder); + }) + .pagination(expensesFilter.page - 1, expensesFilter.pageSize); return { expenses: results, - pagination, filterMeta: - dynamicFilter.getResponseMeta(), + pagination, + filterMeta: dynamicFilter.getResponseMeta(), }; } /** * Retrieve expense details. - * @param {number} tenantId - * @param {number} expenseId + * @param {number} tenantId + * @param {number} expenseId * @return {Promise} */ - public async getExpense(tenantId: number, expenseId: number): Promise { + public async getExpense( + tenantId: number, + expenseId: number + ): Promise { const { expenseRepository } = this.tenancy.repositories(tenantId); const expense = await expenseRepository.findOneById(expenseId, [ @@ -537,4 +775,4 @@ export default class ExpensesService implements IExpensesService { } return expense; } -} \ No newline at end of file +} diff --git a/server/src/services/Items/ItemsService.ts b/server/src/services/Items/ItemsService.ts index c5441b735..57c60d72d 100644 --- a/server/src/services/Items/ItemsService.ts +++ b/server/src/services/Items/ItemsService.ts @@ -400,7 +400,11 @@ export default class ItemsService implements IItemsService { const { Item } = this.tenancy.models(tenantId); this.logger.info('[items] trying to delete item.', { tenantId, itemId }); + + // Retreive the given item or throw not found service error. await this.getItemOrThrowError(tenantId, itemId); + + // Validate the item has no associated invoices or bills. await this.validateHasNoInvoicesOrBills(tenantId, itemId); await Item.query().findById(itemId).delete(); diff --git a/server/src/subscribers/events.ts b/server/src/subscribers/events.ts index 402a83ee8..5b665a378 100644 --- a/server/src/subscribers/events.ts +++ b/server/src/subscribers/events.ts @@ -68,7 +68,6 @@ export default { onEdited: 'onExpenseEdited', onDeleted: 'onExpenseDelted', onPublished: 'onExpensePublished', - onBulkDeleted: 'onExpenseBulkDeleted', onBulkPublished: 'onBulkPublished', }, diff --git a/server/src/subscribers/expenses.ts b/server/src/subscribers/expenses.ts index 3425509bf..d097a36df 100644 --- a/server/src/subscribers/expenses.ts +++ b/server/src/subscribers/expenses.ts @@ -16,36 +16,49 @@ export default class ExpensesSubscriber { } /** - * On expense created. + * Handles the writing journal entries once the expense created. */ @On(events.expenses.onCreated) - public async onExpenseCreated({ expenseId, tenantId }) { - const { expenseRepository } = this.tenancy.repositories(tenantId); - const expense = await expenseRepository.getById(expenseId); - + public async onExpenseCreated({ + expenseId, + expense, + tenantId, + authorizedUser, + }) { // In case expense published, write journal entries. if (expense.publishedAt) { - await this.expensesService.writeJournalEntries(tenantId, expense, false); + await this.expensesService.writeJournalEntries( + tenantId, + expense, + authorizedUser.id, + false + ); } } - + /** - * On expense edited. + * Handle writing expense journal entries once the expense edited. */ @On(events.expenses.onEdited) - public async onExpenseEdited({ expenseId, tenantId }) { - const { expenseRepository } = this.tenancy.repositories(tenantId); - const expense = await expenseRepository.getById(expenseId); - + public async onExpenseEdited({ + expenseId, + tenantId, + expense, + authorizedUser, + }) { // In case expense published, write journal entries. if (expense.publishedAt) { - await this.expensesService.writeJournalEntries(tenantId, expense, true); + await this.expensesService.writeJournalEntries( + tenantId, + expense, + authorizedUser.id, + true + ); } } /** - * - * @param param0 + * Reverts expense journal entries once the expense deleted. */ @On(events.expenses.onDeleted) public async onExpenseDeleted({ expenseId, tenantId }) { @@ -53,35 +66,61 @@ export default class ExpensesSubscriber { } /** - * - * @param param0 + * Handles writing expense journal once the expense publish. */ @On(events.expenses.onPublished) - public async onExpensePublished({ expenseId, tenantId }) { - const { expenseRepository } = this.tenancy.repositories(tenantId); - const expense = await expenseRepository.getById(expenseId); - + public async onExpensePublished({ + expenseId, + tenantId, + expense, + authorizedUser, + }) { // In case expense published, write journal entries. if (expense.publishedAt) { - await this.expensesService.writeJournalEntries(tenantId, expense, false); + await this.expensesService.writeJournalEntries( + tenantId, + expense, + authorizedUser.id, + false + ); } } /** - * - * @param param0 + * Handles the revert journal entries once the expenses deleted in bulk. */ @On(events.expenses.onBulkDeleted) - public onExpenseBulkDeleted({ expensesIds, tenantId }) { - + public async handleRevertJournalEntriesOnceDeleted({ + expensesIds, + tenantId, + }) { + await this.expensesService.revertJournalEntries(tenantId, expensesIds); } /** - * - * @param param0 + * Handles writing journal entriers of the not-published expenses. */ @On(events.expenses.onBulkPublished) - public onExpenseBulkPublished({ expensesIds, tenantId }) { + public async onExpenseBulkPublished({ + expensesIds, + tenantId, + expenses, + oldExpenses, + authorizedUser, + }) { + // Filters the not published expenses. + const notPublishedExpenses = this.expensesService.getNonePublishedExpenses( + oldExpenses + ); + // Can't continue if there is no not-published expoenses. + if (notPublishedExpenses.length === 0) { return; } + // Writing the journal entries of not-published expenses. + await this.expensesService.writeJournalEntries( + tenantId, + notPublishedExpenses, + authorizedUser.id, + false + ); } -} \ No newline at end of file +}