import { omit, get } from 'lodash'; import moment from 'moment'; import { Container } from 'typedi'; import JournalEntry from 'services/Accounting/JournalEntry'; import TenancyService from 'services/Tenancy/TenancyService'; import { IJournalEntry, IJournalPoster, IAccountChange, IAccountsChange, TEntryType, } from 'interfaces'; export default class JournalPoster implements IJournalPoster { tenantId: number; tenancy: TenancyService; logger: any; models: any; repositories: any; deletedEntriesIds: number[] = []; entries: IJournalEntry[] = []; balancesChange: IAccountsChange = {}; accountsDepGraph: IAccountsChange = {}; accountsBalanceTable: { [key: number]: number; } = {}; /** * Journal poster constructor. * @param {number} tenantId - */ constructor( tenantId: number, accountsGraph?: any, ) { this.initTenancy(); this.tenantId = tenantId; this.models = this.tenancy.models(tenantId); this.repositories = this.tenancy.repositories(tenantId); if (accountsGraph) { this.accountsDepGraph = accountsGraph; } } /** * Initial tenancy. * @private */ private initTenancy() { try { this.tenancy = Container.get(TenancyService); this.logger = Container.get('logger'); } catch (exception) { throw new Error('Should execute this class inside tenancy area.'); } } /** * Async initialize acccounts dependency graph. * @private * @returns {Promise} */ public async initAccountsDepGraph(): Promise { const { accountRepository } = this.repositories; if (!this.accountsDepGraph) { const accountsDepGraph = await accountRepository.getDependencyGraph(); this.accountsDepGraph = accountsDepGraph; } } /** * Writes the credit entry for the given account. * @param {IJournalEntry} entry - */ public credit(entryModel: IJournalEntry): void { if (entryModel instanceof JournalEntry === false) { throw new Error('The entry is not instance of JournalEntry.'); } this.entries.push(entryModel.entry); this.setAccountBalanceChange(entryModel.entry); } /** * Writes the debit entry for the given account. * @param {JournalEntry} entry - */ public debit(entryModel: IJournalEntry): void { if (entryModel instanceof JournalEntry === false) { throw new Error('The entry is not instance of JournalEntry.'); } this.entries.push(entryModel.entry); this.setAccountBalanceChange(entryModel.entry); } /** * Sets account balance change. * @param {JournalEntry} entry * @param {String} type */ private setAccountBalanceChange(entry: IJournalEntry): void { const accountChange: IAccountChange = { debit: entry.debit, credit: entry.credit, }; this._setAccountBalanceChange(entry.account, accountChange); } /** * Sets account balance change. * @private * @param {number} accountId - * @param {IAccountChange} accountChange */ private _setAccountBalanceChange( accountId: number, accountChange: IAccountChange ) { this.balancesChange = this.accountBalanceChangeReducer( this.balancesChange, accountId, accountChange, ); } /** * Accounts balance change reducer. * @param {IAccountsChange} balancesChange * @param {number} accountId * @param {IAccountChange} accountChange * @return {IAccountChange} */ private accountBalanceChangeReducer( balancesChange: IAccountsChange, accountId: number, accountChange: IAccountChange, ) { const change = { ...balancesChange }; if (!change[accountId]) { change[accountId] = { credit: 0, debit: 0 }; } if (accountChange.credit) { change[accountId].credit += accountChange.credit; } if (accountChange.debit) { change[accountId].debit += accountChange.debit; } return change; } /** * Converts balance changes to array. * @private * @param {IAccountsChange} accountsChange - * @return {Promise<{ account: number, change: number }>} */ private async convertBalanceChangesToArr( accountsChange: IAccountsChange ) : Promise<{ account: number, change: number }[]>{ const { accountTypeRepository } = this.repositories; const mappedList: { account: number, change: number }[] = []; const accountsIds: number[] = Object.keys(accountsChange).map(id => parseInt(id, 10)); await Promise.all( accountsIds.map(async (account: number) => { const accountChange = accountsChange[account]; const accountNode = this.accountsDepGraph.getNodeData(account); const accountTypeMeta = await accountTypeRepository.findOneById(accountNode.accountTypeId); const { normal }: { normal: TEntryType } = accountTypeMeta; let change = 0; if (accountChange.credit) { change = (normal === 'credit') ? accountChange.credit : -1 * accountChange.credit; } if (accountChange.debit) { change = (normal === 'debit') ? accountChange.debit : -1 * accountChange.debit; } mappedList.push({ account, change }); }), ); return mappedList; } /** * Saves the balance change of journal entries. * @returns {Promise} */ public async saveBalance() { await this.initAccountsDepGraph(); const { Account } = this.models; const accountsChange = this.balanceChangeWithDepends(this.balancesChange); const balancesList = await this.convertBalanceChangesToArr(accountsChange); const balancesAccounts = balancesList.map(b => b.account); // Ensure the accounts has atleast zero in amount. await Account.query().where('amount', null).whereIn('id', balancesAccounts) .patch({ amount: 0 }); const balanceUpdateOpers: Promise[] = []; balancesList.forEach((balance: { account: number, change: number }) => { const method: string = (balance.change < 0) ? 'decrement' : 'increment'; this.logger.info('[journal_poster] increment/decrement account balance.', { balance, tenantId: this.tenantId, }) const query = Account.query() [method]('amount', Math.abs(balance.change)) .where('id', balance.account); balanceUpdateOpers.push(query); }); await Promise.all(balanceUpdateOpers); this.resetAccountsBalanceChange(); } /** * Changes all accounts that dependencies of changed accounts. * @param {IAccountsChange} accountsChange * @returns {IAccountsChange} */ private balanceChangeWithDepends(accountsChange: IAccountsChange): IAccountsChange { const accountsIds = Object.keys(accountsChange); let changes: IAccountsChange = {}; accountsIds.forEach((accountId) => { const accountChange = accountsChange[accountId]; const depAccountsIds = this.accountsDepGraph.dependenciesOf(accountId); [accountId, ...depAccountsIds].forEach((account) => { changes = this.accountBalanceChangeReducer(changes, account, accountChange); }); }); return changes; } /** * Resets accounts balance change. * @private */ private resetAccountsBalanceChange() { this.balancesChange = {}; } /** * Saves the stacked journal entries to the storage. * @returns {Promise} */ public async saveEntries() { const { AccountTransaction } = this.models; const saveOperations: Promise[] = []; this.entries.forEach((entry) => { const oper = AccountTransaction.query() .insert({ accountId: entry.account, ...omit(entry, ['account']), }); saveOperations.push(oper); }); await Promise.all(saveOperations); } /** * Reverses the stacked journal entries. */ public reverseEntries() { const reverseEntries: IJournalEntry[] = []; this.entries.forEach((entry) => { const reverseEntry = { ...entry }; if (entry.credit) { reverseEntry.debit = entry.credit; } if (entry.debit) { reverseEntry.credit = entry.debit; } reverseEntries.push(reverseEntry); }); this.entries = reverseEntries; } /** * Removes all stored entries or by the given in ids. * @param {Array} ids - */ removeEntries(ids: number[] = []) { const targetIds = ids.length <= 0 ? this.entries.map((e) => e.id) : ids; const removeEntries = this.entries.filter( (e) => targetIds.indexOf(e.id) !== -1 ); this.entries = this.entries.filter((e) => targetIds.indexOf(e.id) === -1); removeEntries.forEach((entry) => { entry.credit = -1 * entry.credit; entry.debit = -1 * entry.debit; this.setAccountBalanceChange(entry, entry.accountNormal); }); this.deletedEntriesIds.push(...removeEntries.map((entry) => entry.id)); } /** * Delete all the stacked entries. * @return {Promise} */ public async deleteEntries() { const { AccountTransaction } = this.models; if (this.deletedEntriesIds.length > 0) { await AccountTransaction.query() .whereIn('id', this.deletedEntriesIds) .delete(); } } /** * Load fetched accounts journal entries. * @param {IJournalEntry[]} entries - */ fromTransactions(transactions) { transactions.forEach((transaction) => { this.entries.push({ ...transaction, account: transaction.accountId, accountNormal: get(transaction, 'account.type.normal'), }); }); } /** * Calculates the entries balance change. * @public */ public calculateEntriesBalanceChange() { this.entries.forEach((entry) => { if (entry.credit) { this.setAccountBalanceChange(entry, 'credit'); } if (entry.debit) { this.setAccountBalanceChange(entry, 'debit'); } }); } static fromTransactions(entries, ...args: [number, ...any]) { const journal = new this(...args); journal.fromTransactions(entries); return journal; } /** * Retrieve the closing balance for the given account and closing date. * @param {Number} accountId - * @param {Date} closingDate - * @param {string} dataType? - * @return {number} */ getClosingBalance( accountId: number, closingDate: Date|string, dateType: string = 'day' ): number { let closingBalance = 0; const momentClosingDate = moment(closingDate); this.entries.forEach((entry) => { // Can not continue if not before or event same closing date. if ( (!momentClosingDate.isAfter(entry.date, dateType) && !momentClosingDate.isSame(entry.date, dateType)) || (entry.account !== accountId && accountId) ) { return; } if (entry.accountNormal === 'credit') { closingBalance += entry.credit ? entry.credit : -1 * entry.debit; } else if (entry.accountNormal === 'debit') { closingBalance += entry.debit ? entry.debit : -1 * entry.credit; } }); return closingBalance; } /** * Retrieve the given account balance with dependencies accounts. * @param {Number} accountId - * @param {Date} closingDate - * @param {String} dateType - * @return {Number} */ getAccountBalance(accountId: number, closingDate: Date|string, dateType: string) { const accountNode = this.accountsDepGraph.getNodeData(accountId); const depAccountsIds = this.accountsDepGraph.dependenciesOf(accountId); const depAccounts = depAccountsIds .map((id) => this.accountsDepGraph.getNodeData(id)); let balance: number = 0; [...depAccounts, accountNode].forEach((account) => { const closingBalance = this.getClosingBalance( account.id, closingDate, dateType ); this.accountsBalanceTable[account.id] = closingBalance; balance += this.accountsBalanceTable[account.id]; }); return balance; } /** * Retrieve the credit/debit sumation for the given account and date. * @param {Number} account - * @param {Date|String} closingDate - */ getTrialBalance(accountId, closingDate, dateType) { const momentClosingDate = moment(closingDate); const result = { credit: 0, debit: 0, balance: 0, }; this.entries.forEach((entry) => { if ( (!momentClosingDate.isAfter(entry.date, dateType) && !momentClosingDate.isSame(entry.date, dateType)) || (entry.account !== accountId && accountId) ) { return; } result.credit += entry.credit; result.debit += entry.debit; if (entry.accountNormal === 'credit') { result.balance += entry.credit - entry.debit; } else if (entry.accountNormal === 'debit') { result.balance += entry.debit - entry.credit; } }); return result; } /** * Retrieve trial balance of the given account with depends. * @param {Number} accountId * @param {Date} closingDate * @param {String} dateType * @return {Number} */ getTrialBalanceWithDepands(accountId: number, closingDate: Date, dateType: string) { const accountNode = this.accountsDepGraph.getNodeData(accountId); const depAccountsIds = this.accountsDepGraph.dependenciesOf(accountId); const depAccounts = depAccountsIds.map((id) => this.accountsDepGraph.getNodeData(id) ); const trialBalance = { credit: 0, debit: 0, balance: 0 }; [...depAccounts, accountNode].forEach((account) => { const _trialBalance = this.getTrialBalance( account.id, closingDate, dateType ); trialBalance.credit += _trialBalance.credit; trialBalance.debit += _trialBalance.debit; trialBalance.balance += _trialBalance.balance; }); return trialBalance; } getContactTrialBalance( accountId: number, contactId: number, contactType: string, closingDate: Date|string, openingDate: Date|string, ) { const momentClosingDate = moment(closingDate); const momentOpeningDate = moment(openingDate); const trial = { credit: 0, debit: 0, balance: 0, }; this.entries.forEach((entry) => { if ( (closingDate && !momentClosingDate.isAfter(entry.date, 'day') && !momentClosingDate.isSame(entry.date, 'day')) || (openingDate && !momentOpeningDate.isBefore(entry.date, 'day') && !momentOpeningDate.isSame(entry.date)) || (accountId && entry.account !== accountId) || (contactId && entry.contactId !== contactId) || entry.contactType !== contactType ) { return; } if (entry.credit) { trial.balance -= entry.credit; trial.credit += entry.credit; } if (entry.debit) { trial.balance += entry.debit; trial.debit += entry.debit; } }); return trial; } /** * Retrieve total balnace of the given customer/vendor contact. * @param {Number} accountId * @param {Number} contactId * @param {String} contactType * @param {Date} closingDate */ getContactBalance( accountId: number, contactId: number, contactType: string, closingDate: Date, openingDate: Date, ) { const momentClosingDate = moment(closingDate); let balance = 0; this.entries.forEach((entry) => { if ( (closingDate && !momentClosingDate.isAfter(entry.date, 'day') && !momentClosingDate.isSame(entry.date, 'day')) || (entry.account !== accountId && accountId) || (contactId && entry.contactId !== contactId) || entry.contactType !== contactType ) { return; } if (entry.credit) { balance -= entry.credit; } if (entry.debit) { balance += entry.debit; } }); return balance; } getAccountEntries(accountId: number) { return this.entries.filter((entry) => entry.account === accountId); } }