Files
bigcapital/server/src/services/ManualJournals/ManualJournalsService.ts
a.bouhuolia c75109a975 fix: specific account transactions.
fix: specific expense associated expense account graph.
fix: specific manual journal associated account and contact graph.
2021-04-25 23:42:36 +02:00

900 lines
26 KiB
TypeScript

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<void | ServiceError> {
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<any> {
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<void>}
*/
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<void>}
*/
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<void> {
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<void>}
*/
public async revertJournalEntries(
tenantId: number,
manualJournalId: number | number[]
): Promise<void> {
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 }
);
}
}