import { difference, sumBy, omit, map } from 'lodash'; import { Service, Inject } from 'typedi'; import moment from 'moment'; import { ServiceError, ServiceErrors } from 'exceptions'; import { IManualJournalDTO, IManualJournalsService, IManualJournalsFilter, ISystemUser, IManualJournal, IPaginationMeta, IManualJournalEntry, } from 'interfaces'; import TenancyService from 'services/Tenancy/TenancyService'; import DynamicListingService from 'services/DynamicListing/DynamicListService'; import events from 'subscribers/events'; import { EventDispatcher, EventDispatcherInterface, } from 'decorators/eventDispatcher'; import JournalPoster from 'services/Accounting/JournalPoster'; import JournalCommands from 'services/Accounting/JournalCommands'; import JournalPosterService from 'services/Sales/JournalPosterService'; import AutoIncrementOrdersService from 'services/Sales/AutoIncrementOrdersService'; import { ERRORS } from './constants'; @Service() export default class ManualJournalsService implements IManualJournalsService { @Inject() tenancy: TenancyService; @Inject() dynamicListService: DynamicListingService; @Inject() journalService: JournalPosterService; @Inject('logger') logger: any; @EventDispatcher() eventDispatcher: EventDispatcherInterface; @Inject() autoIncrementOrdersService: AutoIncrementOrdersService; /** * Validates the manual journal existance. * @param {number} tenantId * @param {number} manualJournalId */ private async getManualJournalOrThrowError( tenantId: number, manualJournalId: number ) { const { ManualJournal } = this.tenancy.models(tenantId); this.logger.info('[manual_journal] trying to validate existance.', { tenantId, manualJournalId, }); const manualJournal = await ManualJournal.query() .findById(manualJournalId) .withGraphFetched('entries'); if (!manualJournal) { this.logger.warn('[manual_journal] not exists on the storage.', { tenantId, manualJournalId, }); throw new ServiceError(ERRORS.NOT_FOUND); } return manualJournal; } /** * Validate manual journals existance. * @param {number} tenantId - Tenant id. * @param {number[]} manualJournalsIds - Manual jorunal ids. * @throws {ServiceError} */ private async getManualJournalsOrThrowError( tenantId: number, manualJournalsIds: number[] ) { const { ManualJournal } = this.tenancy.models(tenantId); const manualJournals = await ManualJournal.query() .whereIn('id', manualJournalsIds) .withGraphFetched('entries'); const notFoundManualJournals = difference( manualJournalsIds, manualJournals.map((m) => m.id) ); if (notFoundManualJournals.length > 0) { throw new ServiceError(ERRORS.NOT_FOUND); } return manualJournals; } /** * Validate manual journal credit and debit should be equal. * @param {IManualJournalDTO} manualJournalDTO */ private valdiateCreditDebitTotalEquals(manualJournalDTO: IManualJournalDTO) { let totalCredit = 0; let totalDebit = 0; manualJournalDTO.entries.forEach((entry) => { if (entry.credit > 0) { totalCredit += entry.credit; } if (entry.debit > 0) { totalDebit += entry.debit; } }); if (totalCredit <= 0 || totalDebit <= 0) { this.logger.info( '[manual_journal] the total credit and debit equals zero.' ); throw new ServiceError(ERRORS.CREDIT_DEBIT_NOT_EQUAL_ZERO); } if (totalCredit !== totalDebit) { this.logger.info( '[manual_journal] the total credit not equals total debit.' ); throw new ServiceError(ERRORS.CREDIT_DEBIT_NOT_EQUAL); } } /** * Validate manual entries accounts existance on the storage. * @param {number} tenantId - * @param {IManualJournalDTO} manualJournalDTO - */ private async validateAccountsExistance( tenantId: number, manualJournalDTO: IManualJournalDTO ) { const { Account } = this.tenancy.models(tenantId); const manualAccountsIds = manualJournalDTO.entries.map((e) => e.accountId); const accounts = await Account.query().whereIn('id', manualAccountsIds); const storedAccountsIds = accounts.map((account) => account.id); if (difference(manualAccountsIds, storedAccountsIds).length > 0) { this.logger.info('[manual_journal] some entries accounts not exist.', { tenantId, manualAccountsIds, }); throw new ServiceError(ERRORS.ACCCOUNTS_IDS_NOT_FOUND); } } /** * Validate manual journal number unique. * @param {number} tenantId * @param {IManualJournalDTO} manualJournalDTO */ private async validateManualJournalNoUnique( tenantId: number, journalNumber: string, notId?: number ) { const { ManualJournal } = this.tenancy.models(tenantId); const journals = await ManualJournal.query() .where('journal_number', journalNumber) .onBuild((builder) => { if (notId) { builder.whereNot('id', notId); } }); if (journals.length > 0) { throw new ServiceError(ERRORS.JOURNAL_NUMBER_EXISTS); } } /** * Validate accounts with contact type. * @param {number} tenantId * @param {IManualJournalDTO} manualJournalDTO * @param {string} accountBySlug * @param {string} contactType */ private async validateAccountWithContactType( tenantId: number, entriesDTO: IManualJournalEntry[], accountBySlug: string, contactType: string ): Promise { const { Account } = this.tenancy.models(tenantId); const { contactRepository } = this.tenancy.repositories(tenantId); // Retrieve account meta by the given account slug. const account = await Account.query().findOne('slug', accountBySlug); // Retrieve all stored contacts on the storage from contacts entries. const storedContacts = await contactRepository.findWhereIn( 'id', entriesDTO .filter((entry) => entry.contactId) .map((entry) => entry.contactId) ); // Converts the stored contacts to map with id as key and entry as value. const storedContactsMap = new Map( storedContacts.map((contact) => [contact.id, contact]) ); // Filter all entries of the given account. const accountEntries = entriesDTO.filter( (entry) => entry.accountId === account.id ); // Can't continue if there is no entry that associate to the given account. if (accountEntries.length === 0) { return; } // Filter entries that have no contact type or not equal the valid type. const entriesNoContact = accountEntries.filter((entry) => { const contact = storedContactsMap.get(entry.contactId); return !contact || contact.contactService !== contactType; }); // Throw error in case one of entries that has invalid contact type. if (entriesNoContact.length > 0) { const indexes = entriesNoContact.map((e) => e.index); return new ServiceError(ERRORS.ENTRIES_SHOULD_ASSIGN_WITH_CONTACT, '', { accountSlug: accountBySlug, contactType, indexes, }); } } /** * Dynamic validates accounts with contacts. * @param {number} tenantId * @param {IManualJournalDTO} manualJournalDTO */ private async dynamicValidateAccountsWithContactType( tenantId: number, entriesDTO: IManualJournalEntry[] ): Promise { return Promise.all([ this.validateAccountWithContactType( tenantId, entriesDTO, 'accounts-receivable', 'customer' ), this.validateAccountWithContactType( tenantId, entriesDTO, 'accounts-payable', 'vendor' ), ]).then((results) => { const metadataErrors = results .filter((result) => result instanceof ServiceError) .map((result: ServiceError) => result.payload); if (metadataErrors.length > 0) { throw new ServiceError( ERRORS.ENTRIES_SHOULD_ASSIGN_WITH_CONTACT, '', metadataErrors ); } return results; }); } /** * Validate entries contacts existance. * @param {number} tenantId - * @param {IManualJournalDTO} manualJournalDTO */ private async validateContactsExistance( tenantId: number, manualJournalDTO: IManualJournalDTO ) { const { contactRepository } = this.tenancy.repositories(tenantId); // Filters the entries that have contact only. const entriesContactPairs = manualJournalDTO.entries.filter( (entry) => entry.contactId ); if (entriesContactPairs.length > 0) { const entriesContactsIds = entriesContactPairs.map( (entry) => entry.contactId ); // Retrieve all stored contacts on the storage from contacts entries. const storedContacts = await contactRepository.findWhereIn( 'id', entriesContactsIds ); // Converts the stored contacts to map with id as key and entry as value. const storedContactsMap = new Map( storedContacts.map((contact) => [contact.id, contact]) ); const notFoundContactsIds = []; entriesContactPairs.forEach((contactEntry) => { const storedContact = storedContactsMap.get(contactEntry.contactId); // in case the contact id not found. if (!storedContact) { notFoundContactsIds.push(storedContact); } }); if (notFoundContactsIds.length > 0) { throw new ServiceError(ERRORS.CONTACTS_NOT_FOUND, '', { contactsIds: notFoundContactsIds, }); } } } /** * Retrieve the next journal number. */ getNextJournalNumber(tenantId: number): string { return this.autoIncrementOrdersService.getNextTransactionNumber( tenantId, 'manual_journals' ); } /** * Increment the manual journal number. * @param {number} tenantId */ incrementNextJournalNumber(tenantId: number) { return this.autoIncrementOrdersService.incrementSettingsNextNumber( tenantId, 'manual_journals' ); } /** * Validates the manual journal number require. * @param {string} journalNumber */ private validateJournalNoRequire(journalNumber: string) { if (!journalNumber) { throw new ServiceError(ERRORS.MANUAL_JOURNAL_NO_REQUIRED); } } /** * Transform the new manual journal DTO to upsert graph operation. * @param {IManualJournalDTO} manualJournalDTO - Manual jorunal DTO. * @param {ISystemUser} authorizedUser */ private transformNewDTOToModel( tenantId, manualJournalDTO: IManualJournalDTO, authorizedUser: ISystemUser ) { const amount = sumBy(manualJournalDTO.entries, 'credit') || 0; const date = moment(manualJournalDTO.date).format('YYYY-MM-DD'); // Retrieve the next manual journal number. const autoNextNumber = this.getNextJournalNumber(tenantId); const journalNumber = manualJournalDTO.journalNumber || autoNextNumber; // Settings tenant service. const settings = this.tenancy.settings(tenantId); const currencyCode = settings.get({ group: 'organization', key: 'base_currency', }); // Validate manual journal number require. this.validateJournalNoRequire(journalNumber); return { ...omit(manualJournalDTO, ['publish']), ...(manualJournalDTO.publish ? { publishedAt: moment().toMySqlDateTime() } : {}), amount, currencyCode, date, journalNumber, userId: authorizedUser.id, }; } /** * Transform the edit manual journal DTO to upsert graph operation. * @param {IManualJournalDTO} manualJournalDTO - Manual jorunal DTO. * @param {IManualJournal} oldManualJournal */ private transformEditDTOToModel( manualJournalDTO: IManualJournalDTO, oldManualJournal: IManualJournal ) { const amount = sumBy(manualJournalDTO.entries, 'credit') || 0; const date = moment(manualJournalDTO.date).format('YYYY-MM-DD'); return { id: oldManualJournal.id, ...omit(manualJournalDTO, ['publish']), ...(manualJournalDTO.publish && !oldManualJournal.publishedAt ? { publishedAt: moment().toMySqlDateTime() } : {}), amount, date, }; } /** * Make journal entries. * @param {number} tenantId * @param {IManualJournalDTO} manualJournalDTO * @param {ISystemUser} authorizedUser */ public async makeJournalEntries( tenantId: number, manualJournalDTO: IManualJournalDTO, authorizedUser: ISystemUser ): Promise<{ manualJournal: IManualJournal }> { const { ManualJournal } = this.tenancy.models(tenantId); // Transformes the next DTO to model. const manualJournalObj = this.transformNewDTOToModel( tenantId, manualJournalDTO, authorizedUser ); // Validate the total credit should equals debit. this.valdiateCreditDebitTotalEquals(manualJournalDTO); // Validate the contacts existance. await this.validateContactsExistance(tenantId, manualJournalDTO); // Validate entries accounts existance. await this.validateAccountsExistance(tenantId, manualJournalDTO); // Validate manual journal uniquiness on the storage. await this.validateManualJournalNoUnique( tenantId, manualJournalObj.journalNumber ); // Validate accounts with contact type from the given config. await this.dynamicValidateAccountsWithContactType( tenantId, manualJournalDTO.entries ); this.logger.info( '[manual_journal] trying to save manual journal to the storage.', { tenantId, manualJournalDTO } ); // Upsert the manual journal object. const manualJournal = await ManualJournal.query().upsertGraph({ ...manualJournalObj, }); // Triggers `onManualJournalCreated` event. this.eventDispatcher.dispatch(events.manualJournals.onCreated, { tenantId, manualJournal, manualJournalId: manualJournal.id, }); this.logger.info( '[manual_journal] the manual journal inserted successfully.', { tenantId } ); return { manualJournal }; } /** * Edits jouranl entries. * @param {number} tenantId * @param {number} manualJournalId * @param {IMakeJournalDTO} manualJournalDTO * @param {ISystemUser} authorizedUser */ public async editJournalEntries( tenantId: number, manualJournalId: number, manualJournalDTO: IManualJournalDTO, authorizedUser: ISystemUser ): Promise<{ manualJournal: IManualJournal; oldManualJournal: IManualJournal; }> { const { ManualJournal } = this.tenancy.models(tenantId); // Validates the manual journal existance on the storage. const oldManualJournal = await this.getManualJournalOrThrowError( tenantId, manualJournalId ); // Transform manual journal DTO to model. const manualJournalObj = this.transformEditDTOToModel( manualJournalDTO, oldManualJournal ); // Validates the total credit and debit to be equals. this.valdiateCreditDebitTotalEquals(manualJournalDTO); // Validate the contacts existance. await this.validateContactsExistance(tenantId, manualJournalDTO); // Validates entries accounts existance. await this.validateAccountsExistance(tenantId, manualJournalDTO); // Validates the manual journal number uniquiness. if (manualJournalDTO.journalNumber) { await this.validateManualJournalNoUnique( tenantId, manualJournalDTO.journalNumber, manualJournalId ); } // Validate accounts with contact type from the given config. await this.dynamicValidateAccountsWithContactType( tenantId, manualJournalDTO.entries ); await ManualJournal.query().upsertGraph({ ...manualJournalObj, }); // Retrieve the given manual journal with associated entries after modifications. const manualJournal = await ManualJournal.query() .findById(manualJournalId) .withGraphFetched('entries'); // Triggers `onManualJournalEdited` event. this.eventDispatcher.dispatch(events.manualJournals.onEdited, { tenantId, manualJournal, oldManualJournal, }); return { manualJournal, oldManualJournal }; } /** * Deletes the given manual journal * @param {number} tenantId * @param {number} manualJournalId * @return {Promise} */ public async deleteManualJournal( tenantId: number, manualJournalId: number ): Promise<{ oldManualJournal: IManualJournal; }> { const { ManualJournal, ManualJournalEntry } = this.tenancy.models(tenantId); // Validate the manual journal exists on the storage. const oldManualJournal = await this.getManualJournalOrThrowError( tenantId, manualJournalId ); this.logger.info('[manual_journal] trying to delete the manual journal.', { tenantId, manualJournalId, }); // Deletes the manual journal entries. await ManualJournalEntry.query() .where('manual_journal_id', manualJournalId) .delete(); // Deletes the manual journal transaction. await ManualJournal.query().findById(manualJournalId).delete(); // Triggers `onManualJournalDeleted` event. this.eventDispatcher.dispatch(events.manualJournals.onDeleted, { tenantId, manualJournalId, oldManualJournal, }); this.logger.info( '[manual_journal] the given manual journal deleted successfully.', { tenantId, manualJournalId } ); return { oldManualJournal }; } /** * Deletes the given manual journals. * @param {number} tenantId * @param {number[]} manualJournalsIds * @return {Promise} */ public async deleteManualJournals( tenantId: number, manualJournalsIds: number[] ): Promise<{ oldManualJournals: IManualJournal[]; }> { const { ManualJournal, ManualJournalEntry } = this.tenancy.models(tenantId); // Validate the manual journals exist on the storage. const oldManualJournals = await this.getManualJournalsOrThrowError( tenantId, manualJournalsIds ); this.logger.info('[manual_journal] trying to delete the manual journals.', { tenantId, manualJournalsIds, }); // Deletes the manual journal entries. await ManualJournalEntry.query() .whereIn('manual_journal_id', manualJournalsIds) .delete(); // Deletes the manual journal transaction. await ManualJournal.query().whereIn('id', manualJournalsIds).delete(); // Triggers `onManualJournalDeletedBulk` event. this.eventDispatcher.dispatch(events.manualJournals.onDeletedBulk, { tenantId, manualJournalsIds, }); this.logger.info( '[manual_journal] the given manual journals deleted successfully.', { tenantId, manualJournalsIds } ); return { oldManualJournals }; } /** * Publish the given manual journals. * @param {number} tenantId * @param {number[]} manualJournalsIds */ public async publishManualJournals( tenantId: number, manualJournalsIds: number[] ): Promise<{ meta: { alreadyPublished: number; published: number; total: number; }; }> { const { ManualJournal } = this.tenancy.models(tenantId); // Retrieve manual journals or throw service error. const oldManualJournals = await this.getManualJournalsOrThrowError( tenantId, manualJournalsIds ); // Filters the not published journals. const notPublishedJournals = this.getNonePublishedManualJournals( oldManualJournals ); // Filters the published journals. const publishedJournals = this.getPublishedManualJournals( oldManualJournals ); // Mappes the not-published journals to get id. const notPublishedJournalsIds = map(notPublishedJournals, 'id'); this.logger.info('[manual_journal] trying to publish the manual journal.', { tenantId, manualJournalsIds, }); if (notPublishedJournals.length > 0) { // Mark the given manual journals as published. await ManualJournal.query().whereIn('id', notPublishedJournalsIds).patch({ publishedAt: moment().toMySqlDateTime(), }); } // Triggers `onManualJournalPublishedBulk` event. this.eventDispatcher.dispatch(events.manualJournals.onPublishedBulk, { tenantId, manualJournalsIds, oldManualJournals, }); this.logger.info( '[manual_journal] the given manula journal published successfully.', { tenantId, manualJournalsIds } ); return { meta: { alreadyPublished: publishedJournals.length, published: notPublishedJournals.length, total: oldManualJournals.length, }, }; } /** * Validates expenses is not already published before. * @param {IManualJournal} manualJournal */ private validateManualJournalIsNotPublished(manualJournal: IManualJournal) { if (manualJournal.publishedAt) { throw new ServiceError(ERRORS.MANUAL_JOURNAL_ALREADY_PUBLISHED); } } /** * Filters the not published manual jorunals. * @param {IManualJournal[]} manualJournal - Manual journal. * @return {IManualJournal[]} */ public getNonePublishedManualJournals( manualJournals: IManualJournal[] ): IManualJournal[] { return manualJournals.filter((manualJournal) => !manualJournal.publishedAt); } /** * Filters the published manual journals. * @param {IManualJournal[]} manualJournal - Manual journal. * @return {IManualJournal[]} */ public getPublishedManualJournals( manualJournals: IManualJournal[] ): IManualJournal[] { return manualJournals.filter((expense) => expense.publishedAt); } /** * Publish the given manual journal. * @param {number} tenantId - Tenant id. * @param {number} manualJournalId - Manual journal id. */ public async publishManualJournal( tenantId: number, manualJournalId: number ): Promise { const { ManualJournal } = this.tenancy.models(tenantId); const oldManualJournal = await this.getManualJournalOrThrowError( tenantId, manualJournalId ); this.logger.info('[manual_journal] trying to publish the manual journal.', { tenantId, manualJournalId, }); this.validateManualJournalIsNotPublished(oldManualJournal); // Mark the given manual journal as published. await ManualJournal.query().findById(manualJournalId).patch({ publishedAt: moment().toMySqlDateTime(), }); // Retrieve the manual journal with enrties after modification. const manualJournal = await ManualJournal.query() .findById(manualJournalId) .withGraphFetched('entries'); // Triggers `onManualJournalPublishedBulk` event. this.eventDispatcher.dispatch(events.manualJournals.onPublished, { tenantId, manualJournal, manualJournalId, oldManualJournal, }); this.logger.info( '[manual_journal] the given manula journal published successfully.', { tenantId, manualJournalId } ); } /** * Retrieve manual journals datatable list. * @param {number} tenantId * @param {IManualJournalsFilter} filter */ public async getManualJournals( tenantId: number, filter: IManualJournalsFilter ): Promise<{ manualJournals: IManualJournal; pagination: IPaginationMeta; filterMeta: IFilterMeta; }> { const { ManualJournal } = this.tenancy.models(tenantId); const dynamicList = await this.dynamicListService.dynamicList( tenantId, ManualJournal, filter ); this.logger.info('[manual_journals] trying to get manual journals list.', { tenantId, filter, }); const { results, pagination } = await ManualJournal.query() .onBuild((builder) => { dynamicList.buildQuery()(builder); builder.withGraphFetched('entries.account'); }) .pagination(filter.page - 1, filter.pageSize); return { manualJournals: results, pagination, filterMeta: dynamicList.getResponseMeta(), }; } /** * Retrieve manual journal details with assocaited journal transactions. * @param {number} tenantId * @param {number} manualJournalId */ public async getManualJournal(tenantId: number, manualJournalId: number) { const { ManualJournal } = this.tenancy.models(tenantId); await this.getManualJournalOrThrowError(tenantId, manualJournalId); this.logger.info( '[manual_journals] trying to get specific manual journal.', { tenantId, manualJournalId } ); const manualJournal = await ManualJournal.query() .findById(manualJournalId) .withGraphFetched('entries.account') .withGraphFetched('entries.contact') .withGraphFetched('transactions') .withGraphFetched('media'); return manualJournal; } /** * Reverts the manual journal journal entries. * @param {number} tenantId * @param {number|number[]} manualJournalId * @return {Promise} */ public async revertJournalEntries( tenantId: number, manualJournalId: number | number[] ): Promise { this.logger.info('[manual_journal] trying to revert journal entries.', { tenantId, manualJournalId, }); return this.journalService.revertJournalTransactions( tenantId, manualJournalId, 'Journal' ); } /** * Write manual journal entries. * @param {number} tenantId * @param {number} manualJournalId * @param {IManualJournal|null} manualJournalObj * @param {boolean} override */ public async writeJournalEntries( tenantId: number, manualJorunal: IManualJournal | IManualJournal[], override: Boolean = false ) { const journal = new JournalPoster(tenantId); const journalCommands = new JournalCommands(journal); const manualJournals = Array.isArray(manualJorunal) ? manualJorunal : [manualJorunal]; const manualJournalsIds = map(manualJournals, 'id'); if (override) { this.logger.info('[manual_journal] trying to revert journal entries.', { tenantId, manualJorunal, }); await journalCommands.revertJournalEntries(manualJournalsIds, 'Journal'); } manualJournals.forEach((manualJournal) => { journalCommands.manualJournal(manualJournal); }); this.logger.info('[manual_journal] trying to save journal entries.', { tenantId, manualJorunal, }); await Promise.all([ journal.saveBalance(), journal.deleteEntries(), journal.saveEntries(), journal.saveContactsBalance(), ]); this.logger.info( '[manual_journal] the journal entries saved successfully.', { tenantId, manualJournalId: manualJorunal.id } ); } }