add server to monorepo.

This commit is contained in:
a.bouhuolia
2023-02-03 11:57:50 +02:00
parent 28e309981b
commit 80b97b5fdc
1303 changed files with 137049 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
import { Service, Inject } from 'typedi';
import { sumBy, difference } from 'lodash';
import { ServiceError } from '@/exceptions';
import { ERRORS } from '../constants';
import {
IAccount,
IExpense,
IExpenseCreateDTO,
IExpenseEditDTO,
} from '@/interfaces';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { ACCOUNT_PARENT_TYPE, ACCOUNT_ROOT_TYPE } from '@/data/AccountTypes';
@Service()
export class CommandExpenseValidator {
@Inject()
private tenancy: HasTenancyService;
/**
* Validates expense categories not equals zero.
* @param {IExpenseCreateDTO | IExpenseEditDTO} expenseDTO
* @throws {ServiceError}
*/
public validateCategoriesNotEqualZero = (
expenseDTO: IExpenseCreateDTO | IExpenseEditDTO
) => {
const totalAmount = sumBy(expenseDTO.categories, 'amount') || 0;
if (totalAmount <= 0) {
throw new ServiceError(ERRORS.TOTAL_AMOUNT_EQUALS_ZERO);
}
};
/**
* Retrieve expense accounts or throw error in case one of the given accounts
* not found not the storage.
* @param {number} tenantId
* @param {number} expenseAccountsIds
* @throws {ServiceError}
* @returns {Promise<IAccount[]>}
*/
public validateExpensesAccountsExistance(
expenseAccounts: IAccount[],
DTOAccountsIds: number[]
) {
const storedExpenseAccountsIds = expenseAccounts.map((a: IAccount) => a.id);
const notStoredAccountsIds = difference(
DTOAccountsIds,
storedExpenseAccountsIds
);
if (notStoredAccountsIds.length > 0) {
throw new ServiceError(ERRORS.SOME_ACCOUNTS_NOT_FOUND);
}
}
/**
* Validate expenses accounts type.
* @param {number} tenantId
* @param {number[]} expensesAccountsIds
*/
public validateExpensesAccountsType = (expensesAccounts: IAccount[]) => {
const invalidExpenseAccounts: number[] = [];
expensesAccounts.forEach((expenseAccount) => {
if (!expenseAccount.isRootType(ACCOUNT_ROOT_TYPE.EXPENSE)) {
invalidExpenseAccounts.push(expenseAccount.id);
}
});
if (invalidExpenseAccounts.length > 0) {
throw new ServiceError(ERRORS.EXPENSES_ACCOUNT_HAS_INVALID_TYPE);
}
};
/**
* Validates payment account type in case has invalid type throws errors.
* @param {number} tenantId
* @param {number} paymentAccountId
* @throws {ServiceError}
*/
public validatePaymentAccountType = (paymentAccount: number[]) => {
if (!paymentAccount.isParentType(ACCOUNT_PARENT_TYPE.CURRENT_ASSET)) {
throw new ServiceError(ERRORS.PAYMENT_ACCOUNT_HAS_INVALID_TYPE);
}
};
/**
* Validates the expense has not associated landed cost
* references to the given expense.
* @param {number} tenantId
* @param {number} expenseId
*/
public async validateNoAssociatedLandedCost(
tenantId: number,
expenseId: number
) {
const { BillLandedCost } = this.tenancy.models(tenantId);
const associatedLandedCosts = await BillLandedCost.query()
.where('fromTransactionType', 'Expense')
.where('fromTransactionId', expenseId);
if (associatedLandedCosts.length > 0) {
throw new ServiceError(ERRORS.EXPENSE_HAS_ASSOCIATED_LANDED_COST);
}
}
/**
* Validates expenses is not already published before.
* @param {IExpense} expense
*/
public validateExpenseIsNotPublished(expense: IExpense) {
if (expense.publishedAt) {
throw new ServiceError(ERRORS.EXPENSE_ALREADY_PUBLISHED);
}
}
}

View File

@@ -0,0 +1,130 @@
import { Service, Inject } from 'typedi';
import { Knex } from 'knex';
import events from '@/subscribers/events';
import {
IExpense,
IExpenseCreateDTO,
ISystemUser,
IExpenseCreatedPayload,
IExpenseCreatingPayload,
} from '@/interfaces';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import UnitOfWork from '@/services/UnitOfWork';
import { CommandExpenseValidator } from './CommandExpenseValidator';
import { ExpenseDTOTransformer } from './ExpenseDTOTransformer';
@Service()
export class CreateExpense {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private eventPublisher: EventPublisher;
@Inject()
private uow: UnitOfWork;
@Inject()
private validator: CommandExpenseValidator;
@Inject()
private transformDTO: ExpenseDTOTransformer;
/**
* Authorize before create a new expense transaction.
* @param {number} tenantId
* @param {IExpenseDTO} expenseDTO
*/
private authorize = async (
tenantId: number,
expenseDTO: IExpenseCreateDTO
) => {
const { Account } = await this.tenancy.models(tenantId);
// Validate payment account existance on the storage.
const paymentAccount = await Account.query()
.findById(expenseDTO.paymentAccountId)
.throwIfNotFound();
// Retrieves the DTO expense accounts ids.
const DTOExpenseAccountsIds = expenseDTO.categories.map(
(category) => category.expenseAccountId
);
// Retrieves the expenses accounts.
const expenseAccounts = await Account.query().whereIn(
'id',
DTOExpenseAccountsIds
);
// Validate expense accounts exist on the storage.
this.validator.validateExpensesAccountsExistance(
expenseAccounts,
DTOExpenseAccountsIds
);
// Validate payment account type.
this.validator.validatePaymentAccountType(paymentAccount);
// Validate expenses accounts type.
this.validator.validateExpensesAccountsType(expenseAccounts);
// Validate the given expense categories not equal zero.
this.validator.validateCategoriesNotEqualZero(expenseDTO);
};
/**
* Precedures.
* ---------
* 1. Validate payment account existance on the storage.
* 2. Validate expense accounts exist on the storage.
* 3. Validate payment account type.
* 4. Validate expenses accounts type.
* 5. Validate the expense payee contact id existance on storage.
* 6. Validate the given expense categories not equal zero.
* 7. Stores the expense to the storage.
* ---------
* @param {number} tenantId
* @param {IExpenseDTO} expenseDTO
*/
public newExpense = async (
tenantId: number,
expenseDTO: IExpenseCreateDTO,
authorizedUser: ISystemUser
): Promise<IExpense> => {
const { Expense } = await this.tenancy.models(tenantId);
// Authorize before create a new expense.
await this.authorize(tenantId, expenseDTO);
// Save the expense to the storage.
const expenseObj = await this.transformDTO.expenseCreateDTO(
tenantId,
expenseDTO,
authorizedUser
);
// Writes the expense transaction with associated transactions under
// unit-of-work envirement.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onExpenseCreating` event.
await this.eventPublisher.emitAsync(events.expenses.onCreating, {
trx,
tenantId,
expenseDTO,
} as IExpenseCreatingPayload);
// Creates a new expense transaction graph.
const expense: IExpense = await Expense.query(trx).upsertGraph(
expenseObj
);
// Triggers `onExpenseCreated` event.
await this.eventPublisher.emitAsync(events.expenses.onCreated, {
tenantId,
expenseId: expense.id,
authorizedUser,
expense,
trx,
} as IExpenseCreatedPayload);
return expense;
});
};
}

View File

@@ -0,0 +1,78 @@
import { Service, Inject } from 'typedi';
import { Knex } from 'knex';
import {
ISystemUser,
IExpenseEventDeletePayload,
IExpenseDeletingPayload,
} from '@/interfaces';
import events from '@/subscribers/events';
import UnitOfWork from '@/services/UnitOfWork';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import { CommandExpenseValidator } from './CommandExpenseValidator';
import { ExpenseCategory } from 'models';
import HasTenancyService from '@/services/Tenancy/TenancyService';
@Service()
export class DeleteExpense {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private eventPublisher: EventPublisher;
@Inject()
private uow: UnitOfWork;
@Inject()
private validator: CommandExpenseValidator;
/**
* Deletes the given expense.
* @param {number} tenantId
* @param {number} expenseId
* @param {ISystemUser} authorizedUser
*/
public deleteExpense = async (
tenantId: number,
expenseId: number,
authorizedUser: ISystemUser
): Promise<void> => {
const { Expense } = this.tenancy.models(tenantId);
// Retrieves the expense transaction with associated entries or
// throw not found error.
const oldExpense = await Expense.query()
.findById(expenseId)
.withGraphFetched('categories')
.throwIfNotFound();
// Validates the expense has no associated landed cost.
await this.validator.validateNoAssociatedLandedCost(tenantId, expenseId);
// Deletes expense transactions with associated transactions under
// unit-of-work envirement.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onExpenseDeleting` event.
await this.eventPublisher.emitAsync(events.expenses.onDeleting, {
trx,
tenantId,
oldExpense,
} as IExpenseDeletingPayload);
// Deletes expense associated entries.
await ExpenseCategory.query(trx).findById(expenseId).delete();
// Deletes expense transactions.
await Expense.query(trx).findById(expenseId).delete();
// Triggers `onExpenseDeleted` event.
await this.eventPublisher.emitAsync(events.expenses.onDeleted, {
tenantId,
expenseId,
authorizedUser,
oldExpense,
trx,
} as IExpenseEventDeletePayload);
});
};
}

View File

@@ -0,0 +1,157 @@
import { Service, Inject } from 'typedi';
import { Knex } from 'knex';
import {
IExpense,
ISystemUser,
IExpenseEventEditPayload,
IExpenseEventEditingPayload,
IExpenseEditDTO,
} from '@/interfaces';
import events from '@/subscribers/events';
import { CommandExpenseValidator } from './CommandExpenseValidator';
import UnitOfWork from '@/services/UnitOfWork';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { ExpenseDTOTransformer } from './ExpenseDTOTransformer';
import EntriesService from '@/services/Entries';
@Service()
export class EditExpense {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private eventPublisher: EventPublisher;
@Inject()
private uow: UnitOfWork;
@Inject()
private validator: CommandExpenseValidator;
@Inject()
private transformDTO: ExpenseDTOTransformer;
@Inject()
private entriesService: EntriesService;
/**
* Authorize the DTO before editing expense transaction.
* @param {number} tenantId
* @param {number} expenseId
* @param {IExpenseEditDTO} expenseDTO
*/
public authorize = async (
tenantId: number,
oldExpense: IExpense,
expenseDTO: IExpenseEditDTO
) => {
const { Account } = this.tenancy.models(tenantId);
// Validate payment account existance on the storage.
const paymentAccount = await Account.query()
.findById(expenseDTO.paymentAccountId)
.throwIfNotFound();
// Retrieves the DTO expense accounts ids.
const DTOExpenseAccountsIds = expenseDTO.categories.map(
(category) => category.expenseAccountId
);
// Retrieves the expenses accounts.
const expenseAccounts = await Account.query().whereIn(
'id',
DTOExpenseAccountsIds
);
// Validate expense accounts exist on the storage.
this.validator.validateExpensesAccountsExistance(
expenseAccounts,
DTOExpenseAccountsIds
);
// Validate payment account type.
await this.validator.validatePaymentAccountType(paymentAccount);
// Validate expenses accounts type.
await this.validator.validateExpensesAccountsType(expenseAccounts);
// Validate the given expense categories not equal zero.
this.validator.validateCategoriesNotEqualZero(expenseDTO);
// Validate expense entries that have allocated landed cost cannot be deleted.
this.entriesService.validateLandedCostEntriesNotDeleted(
oldExpense.categories,
expenseDTO.categories
);
// Validate expense entries that have allocated cost amount should be bigger than amount.
this.entriesService.validateLocatedCostEntriesSmallerThanNewEntries(
oldExpense.categories,
expenseDTO.categories
);
};
/**
* 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: IExpenseEditDTO,
authorizedUser: ISystemUser
): Promise<IExpense> {
const { Expense } = this.tenancy.models(tenantId);
// Retrieves the expense model or throw not found error.
const oldExpense = await Expense.query()
.findById(expenseId)
.withGraphFetched('categories')
.throwIfNotFound();
// Authorize expense DTO before editing.
await this.authorize(tenantId, oldExpense, expenseDTO);
// Update the expense on the storage.
const expenseObj = await this.transformDTO.expenseEditDTO(
tenantId,
expenseDTO
);
// Edits expense transactions and associated transactions under UOW envirement.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onExpenseEditing` event.
await this.eventPublisher.emitAsync(events.expenses.onEditing, {
tenantId,
oldExpense,
expenseDTO,
trx,
} as IExpenseEventEditingPayload);
// Upsert the expense object with expense entries.
const expense: IExpense = await Expense.query(trx).upsertGraph({
id: expenseId,
...expenseObj,
});
// Triggers `onExpenseCreated` event.
await this.eventPublisher.emitAsync(events.expenses.onEdited, {
tenantId,
expenseId,
expense,
expenseDTO,
authorizedUser,
oldExpense,
trx,
} as IExpenseEventEditPayload);
return expense;
});
}
}

View File

@@ -0,0 +1,115 @@
import { Service, Inject } from 'typedi';
import { omit, sumBy } from 'lodash';
import moment from 'moment';
import * as R from 'ramda';
import {
IExpense,
IExpenseCreateDTO,
IExpenseDTO,
IExpenseEditDTO,
ISystemUser,
} from '@/interfaces';
import { BranchTransactionDTOTransform } from '@/services/Branches/Integrations/BranchTransactionDTOTransform';
import { TenantMetadata } from '@/system/models';
@Service()
export class ExpenseDTOTransformer {
@Inject()
private branchDTOTransform: BranchTransactionDTOTransform;
/**
* Retrieve the expense landed cost amount.
* @param {IExpenseDTO} expenseDTO
* @return {number}
*/
private getExpenseLandedCostAmount = (expenseDTO: IExpenseDTO): number => {
const landedCostEntries = expenseDTO.categories.filter((entry) => {
return entry.landedCost === true;
});
return this.getExpenseCategoriesTotal(landedCostEntries);
};
/**
* Retrieve the given expense categories total.
* @param {IExpenseCategory} categories
* @returns {number}
*/
private getExpenseCategoriesTotal = (categories): number => {
return sumBy(categories, 'amount');
};
/**
* Mapping expense DTO to model.
* @param {IExpenseDTO} expenseDTO
* @param {ISystemUser} authorizedUser
* @return {IExpense}
*/
private expenseDTOToModel(
tenantId: number,
expenseDTO: IExpenseCreateDTO | IExpenseEditDTO,
user?: ISystemUser
): IExpense {
const landedCostAmount = this.getExpenseLandedCostAmount(expenseDTO);
const totalAmount = this.getExpenseCategoriesTotal(expenseDTO.categories);
const initialDTO = {
categories: [],
...omit(expenseDTO, ['publish']),
totalAmount,
landedCostAmount,
paymentDate: moment(expenseDTO.paymentDate).toMySqlDateTime(),
...(expenseDTO.publish
? {
publishedAt: moment().toMySqlDateTime(),
}
: {}),
};
return R.compose(this.branchDTOTransform.transformDTO<IExpense>(tenantId))(
initialDTO
);
}
/**
* Transformes the expense create DTO.
* @param {number} tenantId
* @param {IExpenseCreateDTO} expenseDTO
* @param {ISystemUser} user
* @returns {IExpense}
*/
public expenseCreateDTO = async (
tenantId: number,
expenseDTO: IExpenseCreateDTO,
user?: ISystemUser
): Promise<IExpense> => {
const initialDTO = this.expenseDTOToModel(tenantId, expenseDTO, user);
// Retrieves the tenant metadata.
const tenantMetadata = await TenantMetadata.query().findOne({ tenantId });
return {
...initialDTO,
currencyCode: expenseDTO.currencyCode || tenantMetadata?.baseCurrency,
exchangeRate: expenseDTO.exchangeRate || 1,
...(user
? {
userId: user.id,
}
: {}),
};
};
/**
* Transformes the expense edit DTO.
* @param {number} tenantId
* @param {IExpenseEditDTO} expenseDTO
* @param {ISystemUser} user
* @returns {IExpense}
*/
public expenseEditDTO = async (
tenantId: number,
expenseDTO: IExpenseEditDTO,
user?: ISystemUser
): Promise<IExpense> => {
return this.expenseDTOToModel(tenantId, expenseDTO, user);
};
}

View File

@@ -0,0 +1,60 @@
import { Transformer } from '@/lib/Transformer/Transformer';
import { formatNumber } from 'utils';
import { IExpense } from '@/interfaces';
export class ExpenseTransfromer extends Transformer {
/**
* Include these attributes to expense object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'formattedAmount',
'formattedLandedCostAmount',
'formattedAllocatedCostAmount',
'formattedDate'
];
};
/**
* Retrieve formatted expense amount.
* @param {IExpense} expense
* @returns {string}
*/
protected formattedAmount = (expense: IExpense): string => {
return formatNumber(expense.totalAmount, {
currencyCode: expense.currencyCode,
});
};
/**
* Retrieve formatted expense landed cost amount.
* @param {IExpense} expense
* @returns {string}
*/
protected formattedLandedCostAmount = (expense: IExpense): string => {
return formatNumber(expense.landedCostAmount, {
currencyCode: expense.currencyCode,
});
};
/**
* Retrieve formatted allocated cost amount.
* @param {IExpense} expense
* @returns {string}
*/
protected formattedAllocatedCostAmount = (expense: IExpense): string => {
return formatNumber(expense.allocatedCostAmount, {
currencyCode: expense.currencyCode,
});
};
/**
* Retriecve fromatted date.
* @param {IExpense} expense
* @returns {string}
*/
protected formattedDate = (expense: IExpense): string => {
return this.formatDate(expense.paymentDate);
}
}

View File

@@ -0,0 +1,41 @@
import { IExpense } from '@/interfaces';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Service, Inject } from 'typedi';
import { ExpenseTransfromer } from './ExpenseTransformer';
@Service()
export class GetExpense {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private transformer: TransformerInjectable;
/**
* Retrieve expense details.
* @param {number} tenantId
* @param {number} expenseId
* @return {Promise<IExpense>}
*/
public async getExpense(
tenantId: number,
expenseId: number
): Promise<IExpense> {
const { Expense } = this.tenancy.models(tenantId);
const expense = await Expense.query()
.findById(expenseId)
.withGraphFetched('categories.expenseAccount')
.withGraphFetched('paymentAccount')
.withGraphFetched('branch')
.throwIfNotFound();
// Transformes expense model to POJO.
return this.transformer.transform(
tenantId,
expense,
new ExpenseTransfromer()
);
}
}

View File

@@ -0,0 +1,80 @@
import { Service, Inject } from 'typedi';
import * as R from 'ramda';
import {
IExpensesFilter,
IExpense,
IPaginationMeta,
IFilterMeta,
} from '@/interfaces';
import DynamicListingService from '@/services/DynamicListing/DynamicListService';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { ExpenseTransfromer } from './ExpenseTransformer';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
@Service()
export class GetExpenses {
@Inject()
private dynamicListService: DynamicListingService;
@Inject()
private tenancy: HasTenancyService;
@Inject()
private transformer: TransformerInjectable;
/**
* Retrieve expenses paginated list.
* @param {number} tenantId
* @param {IExpensesFilter} expensesFilter
* @return {IExpense[]}
*/
public getExpensesList = async (
tenantId: number,
filterDTO: IExpensesFilter
): Promise<{
expenses: IExpense[];
pagination: IPaginationMeta;
filterMeta: IFilterMeta;
}> => {
const { Expense } = this.tenancy.models(tenantId);
// Parses list filter DTO.
const filter = this.parseListFilterDTO(filterDTO);
// Dynamic list service.
const dynamicList = await this.dynamicListService.dynamicList(
tenantId,
Expense,
filter
);
// Retrieves the paginated results.
const { results, pagination } = await Expense.query()
.onBuild((builder) => {
builder.withGraphFetched('paymentAccount');
builder.withGraphFetched('categories.expenseAccount');
dynamicList.buildQuery()(builder);
})
.pagination(filter.page - 1, filter.pageSize);
// Transformes the expenses models to POJO.
const expenses = await this.transformer.transform(
tenantId,
results,
new ExpenseTransfromer()
);
return {
expenses,
pagination,
filterMeta: dynamicList.getResponseMeta(),
};
};
/**
* Parses filter DTO of expenses list.
* @param filterDTO -
*/
private parseListFilterDTO(filterDTO) {
return R.compose(this.dynamicListService.parseStringifiedFilter)(filterDTO);
}
}

View File

@@ -0,0 +1,79 @@
import { Service, Inject } from 'typedi';
import { Knex } from 'knex';
import {
ISystemUser,
IExpensePublishingPayload,
IExpenseEventPublishedPayload,
} from '@/interfaces';
import events from '@/subscribers/events';
import { CommandExpenseValidator } from './CommandExpenseValidator';
import UnitOfWork from '@/services/UnitOfWork';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import HasTenancyService from '@/services/Tenancy/TenancyService';
@Inject()
export class PublishExpense {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private eventPublisher: EventPublisher;
@Inject()
private uow: UnitOfWork;
@Inject()
private validator: CommandExpenseValidator;
/**
* Publish the given expense.
* @param {number} tenantId
* @param {number} expenseId
* @param {ISystemUser} authorizedUser
* @return {Promise<void>}
*/
public async publishExpense(
tenantId: number,
expenseId: number,
authorizedUser: ISystemUser
) {
const { Expense } = this.tenancy.models(tenantId);
// Retrieves the old expense or throw not found error.
const oldExpense = await Expense.query()
.findById(expenseId)
.throwIfNotFound();
// Validate the expense whether is published before.
this.validator.validateExpenseIsNotPublished(oldExpense);
// Publishes expense transactions with associated transactions
// under unit-of-work envirement.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Trigggers `onExpensePublishing` event.
await this.eventPublisher.emitAsync(events.expenses.onPublishing, {
trx,
oldExpense,
tenantId,
} as IExpensePublishingPayload);
// Publish the given expense on the storage.
await Expense.query().findById(expenseId).modify('publish');
// Retrieve the new expense after modification.
const expense = await Expense.query()
.findById(expenseId)
.withGraphFetched('categories');
// Triggers `onExpensePublished` event.
await this.eventPublisher.emitAsync(events.expenses.onPublished, {
tenantId,
expenseId,
oldExpense,
expense,
authorizedUser,
trx,
} as IExpenseEventPublishedPayload);
});
}
}

View File

@@ -0,0 +1,106 @@
import * as R from 'ramda';
import { Service } from 'typedi';
import {
AccountNormal,
IExpense,
IExpenseCategory,
ILedger,
ILedgerEntry,
} from '@/interfaces';
import Ledger from '@/services/Accounting/Ledger';
@Service()
export class ExpenseGLEntries {
/**
* Retrieves the expense GL common entry.
* @param {IExpense} expense
* @returns
*/
private getExpenseGLCommonEntry = (expense: IExpense) => {
return {
currencyCode: expense.currencyCode,
exchangeRate: expense.exchangeRate,
transactionType: 'Expense',
transactionId: expense.id,
date: expense.paymentDate,
userId: expense.userId,
debit: 0,
credit: 0,
branchId: expense.branchId,
};
};
/**
* Retrieves the expense GL payment entry.
* @param {IExpense} expense
* @returns {ILedgerEntry}
*/
private getExpenseGLPaymentEntry = (expense: IExpense): ILedgerEntry => {
const commonEntry = this.getExpenseGLCommonEntry(expense);
return {
...commonEntry,
credit: expense.localAmount,
accountId: expense.paymentAccountId,
accountNormal: AccountNormal.CREDIT,
index: 1,
};
};
/**
* Retrieves the expense GL category entry.
* @param {IExpense} expense -
* @param {IExpenseCategory} expenseCategory -
* @param {number} index
* @returns {ILedgerEntry}
*/
private getExpenseGLCategoryEntry = R.curry(
(
expense: IExpense,
category: IExpenseCategory,
index: number
): ILedgerEntry => {
const commonEntry = this.getExpenseGLCommonEntry(expense);
const localAmount = category.amount * expense.exchangeRate;
return {
...commonEntry,
accountId: category.expenseAccountId,
accountNormal: AccountNormal.DEBIT,
debit: localAmount,
note: category.description,
index: index + 2,
projectId: category.projectId,
};
}
);
/**
* Retrieves the expense GL entries.
* @param {IExpense} expense
* @returns {ILedgerEntry[]}
*/
public getExpenseGLEntries = (expense: IExpense): ILedgerEntry[] => {
const getCategoryEntry = this.getExpenseGLCategoryEntry(expense);
const paymentEntry = this.getExpenseGLPaymentEntry(expense);
const categoryEntries = expense.categories.map(getCategoryEntry);
return [paymentEntry, ...categoryEntries];
};
/**
* Retrieves the given expense ledger.
* @param {IExpense} expense
* @returns {ILedger}
*/
public getExpenseLedger = (expense: IExpense): ILedger => {
const entries = this.getExpenseGLEntries(expense);
return new Ledger(entries);
};
}

View File

@@ -0,0 +1,78 @@
import { Knex } from 'knex';
import LedgerStorageService from '@/services/Accounting/LedgerStorageService';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Service, Inject } from 'typedi';
import { ExpenseGLEntries } from './ExpenseGLEntries';
@Service()
export class ExpenseGLEntriesStorage {
@Inject()
private expenseGLEntries: ExpenseGLEntries;
@Inject()
private ledgerStorage: LedgerStorageService;
@Inject()
private tenancy: HasTenancyService;
/**
* Writes the expense GL entries.
* @param {number} tenantId
* @param {number} expenseId
* @param {Knex.Transaction} trx
*/
public writeExpenseGLEntries = async (
tenantId: number,
expenseId: number,
trx?: Knex.Transaction
) => {
const { Expense } = await this.tenancy.models(tenantId);
const expense = await Expense.query(trx)
.findById(expenseId)
.withGraphFetched('categories');
// Retrieves the given expense ledger.
const expenseLedger = this.expenseGLEntries.getExpenseLedger(expense);
// Commits the expense ledger entries.
await this.ledgerStorage.commit(tenantId, expenseLedger, trx);
};
/**
* Reverts the given expense GL entries.
* @param {number} tenantId
* @param {number} expenseId
* @param {Knex.Transaction} trx
*/
public revertExpenseGLEntries = async (
tenantId: number,
expenseId: number,
trx?: Knex.Transaction
) => {
await this.ledgerStorage.deleteByReference(
tenantId,
expenseId,
'Expense',
trx
);
};
/**
* Rewrites the expense GL entries.
* @param {number} tenantId
* @param {number} expenseId
* @param {Knex.Transaction} trx
*/
public rewriteExpenseGLEntries = async (
tenantId: number,
expenseId: number,
trx?: Knex.Transaction
) => {
// Reverts the expense GL entries.
await this.revertExpenseGLEntries(tenantId, expenseId, trx);
// Writes the expense GL entries.
await this.writeExpenseGLEntries(tenantId, expenseId, trx);
};
}

View File

@@ -0,0 +1,117 @@
import { Inject, Service } from 'typedi';
import events from '@/subscribers/events';
import TenancyService from '@/services/Tenancy/TenancyService';
import {
IExpenseCreatedPayload,
IExpenseEventDeletePayload,
IExpenseEventEditPayload,
IExpenseEventPublishedPayload,
} from '@/interfaces';
import { ExpenseGLEntriesStorage } from './ExpenseGLEntriesStorage';
@Service()
export class ExpensesWriteGLSubscriber {
@Inject()
private tenancy: TenancyService;
@Inject()
private expenseGLEntries: ExpenseGLEntriesStorage;
/**
* Attaches events with handlers.
* @param bus
*/
attach(bus) {
bus.subscribe(
events.expenses.onCreated,
this.handleWriteGLEntriesOnceCreated
);
bus.subscribe(
events.expenses.onEdited,
this.handleRewriteGLEntriesOnceEdited
);
bus.subscribe(
events.expenses.onDeleted,
this.handleRevertGLEntriesOnceDeleted
);
bus.subscribe(
events.expenses.onPublished,
this.handleWriteGLEntriesOncePublished
);
}
/**
* Handles the writing journal entries once the expense created.
* @param {IExpenseCreatedPayload} payload -
*/
public handleWriteGLEntriesOnceCreated = async ({
expense,
tenantId,
trx,
}: IExpenseCreatedPayload) => {
// In case expense published, write journal entries.
if (!expense.publishedAt) return;
await this.expenseGLEntries.writeExpenseGLEntries(
tenantId,
expense.id,
trx
);
};
/**
* Handle writing expense journal entries once the expense edited.
* @param {IExpenseEventEditPayload} payload -
*/
public handleRewriteGLEntriesOnceEdited = async ({
expenseId,
tenantId,
expense,
authorizedUser,
trx,
}: IExpenseEventEditPayload) => {
// In case expense published, write journal entries.
if (expense.publishedAt) return;
await this.expenseGLEntries.writeExpenseGLEntries(
tenantId,
expense.id,
trx
);
};
/**
* Reverts expense journal entries once the expense deleted.
* @param {IExpenseEventDeletePayload} payload -
*/
public handleRevertGLEntriesOnceDeleted = async ({
expenseId,
tenantId,
trx,
}: IExpenseEventDeletePayload) => {
await this.expenseGLEntries.revertExpenseGLEntries(
tenantId,
expenseId,
trx
);
};
/**
* Handles writing expense journal once the expense publish.
* @param {IExpenseEventPublishedPayload} payload -
*/
public handleWriteGLEntriesOncePublished = async ({
tenantId,
expense,
trx,
}: IExpenseEventPublishedPayload) => {
// In case expense published, write journal entries.
if (!expense.publishedAt) return;
await this.expenseGLEntries.rewriteExpenseGLEntries(
tenantId,
expense.id,
trx
);
};
}

View File

@@ -0,0 +1,132 @@
import {
IExpense,
IExpenseCreateDTO,
IExpenseEditDTO,
IExpensesFilter,
ISystemUser,
} from '@/interfaces';
import { Service, Inject } from 'typedi';
import { CreateExpense } from './CRUD/CreateExpense';
import { DeleteExpense } from './CRUD/DeleteExpense';
import { EditExpense } from './CRUD/EditExpense';
import { GetExpense } from './CRUD/GetExpense';
import { GetExpenses } from './CRUD/GetExpenses';
import { PublishExpense } from './CRUD/PublishExpense';
@Service()
export class ExpensesApplication {
@Inject()
private createExpenseService: CreateExpense;
@Inject()
private editExpenseService: EditExpense;
@Inject()
private deleteExpenseService: DeleteExpense;
@Inject()
private publishExpenseService: PublishExpense;
@Inject()
private getExpenseService: GetExpense;
@Inject()
private getExpensesService: GetExpenses;
/**
* Create a new expense transaction.
* @param {number} tenantId
* @param {IExpenseDTO} expenseDTO
* @param {ISystemUser} authorizedUser
* @returns {Promise<IExpense>}
*/
public createExpense = (
tenantId: number,
expenseDTO: IExpenseCreateDTO,
authorizedUser: ISystemUser
): Promise<IExpense> => {
return this.createExpenseService.newExpense(
tenantId,
expenseDTO,
authorizedUser
);
};
/**
* Edits the given expense transaction.
* @param {number} tenantId
* @param {number} expenseId
* @param {IExpenseDTO} expenseDTO
* @param {ISystemUser} authorizedUser
*/
public editExpense = (
tenantId: number,
expenseId: number,
expenseDTO: IExpenseEditDTO,
authorizedUser: ISystemUser
) => {
return this.editExpenseService.editExpense(
tenantId,
expenseId,
expenseDTO,
authorizedUser
);
};
/**
* Deletes the given expense.
* @param {number} tenantId
* @param {number} expenseId
* @param {ISystemUser} authorizedUser
* @returns {Promise<void>}
*/
public deleteExpense = (
tenantId: number,
expenseId: number,
authorizedUser: ISystemUser
) => {
return this.deleteExpenseService.deleteExpense(
tenantId,
expenseId,
authorizedUser
);
};
/**
* Publishes the given expense.
* @param {number} tenantId
* @param {number} expenseId
* @param {ISystemUser} authorizedUser
* @return {Promise<void>}
*/
public publishExpense = (
tenantId: number,
expenseId: number,
authorizedUser: ISystemUser
) => {
return this.publishExpenseService.publishExpense(
tenantId,
expenseId,
authorizedUser
);
};
/**
* Retrieve the given expense details.
* @param {number} tenantId
* @param {number} expenseId
* @return {Promise<IExpense>}
*/
public getExpense = (tenantId: number, expenseId: number) => {
return this.getExpenseService.getExpense(tenantId, expenseId);
};
/**
* Retrieve expenses paginated list.
* @param {number} tenantId
* @param {IExpensesFilter} expensesFilter
*/
public getExpenses = (tenantId: number, filterDTO: IExpensesFilter) => {
return this.getExpensesService.getExpensesList(tenantId, filterDTO);
};
}

View File

@@ -0,0 +1,38 @@
export const DEFAULT_VIEW_COLUMNS = [];
export const DEFAULT_VIEWS = [
{
name: 'Draft',
slug: 'draft',
rolesLogicExpression: '1',
roles: [
{ index: 1, fieldKey: 'status', comparator: 'equals', value: 'draft' },
],
columns: DEFAULT_VIEW_COLUMNS,
},
{
name: 'Published',
slug: 'published',
rolesLogicExpression: '1',
roles: [
{
index: 1,
fieldKey: 'status',
comparator: 'equals',
value: 'published',
},
],
columns: DEFAULT_VIEW_COLUMNS,
},
];
export const ERRORS = {
EXPENSE_NOT_FOUND: 'expense_not_found',
EXPENSES_NOT_FOUND: 'EXPENSES_NOT_FOUND',
PAYMENT_ACCOUNT_NOT_FOUND: 'payment_account_not_found',
SOME_ACCOUNTS_NOT_FOUND: 'some_expenses_not_found',
TOTAL_AMOUNT_EQUALS_ZERO: 'total_amount_equals_zero',
PAYMENT_ACCOUNT_HAS_INVALID_TYPE: 'payment_account_has_invalid_type',
EXPENSES_ACCOUNT_HAS_INVALID_TYPE: 'expenses_account_has_invalid_type',
EXPENSE_ALREADY_PUBLISHED: 'expense_already_published',
EXPENSE_HAS_ASSOCIATED_LANDED_COST: 'EXPENSE_HAS_ASSOCIATED_LANDED_COST',
};