refactor: migrate item categories module to nestjs

This commit is contained in:
Ahmed Bouhuolia
2024-12-19 19:06:03 +02:00
parent 93bf6d9d3d
commit 83dfaa00fd
55 changed files with 2780 additions and 69 deletions

View File

@@ -0,0 +1,68 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
} from '@nestjs/common';
import { ExpensesApplication } from './ExpensesApplication.service';
import {
IExpenseCreateDTO,
IExpenseEditDTO,
} from './interfaces/Expenses.interface';
@Controller('expenses')
export class ExpensesController {
constructor(private readonly expensesApplication: ExpensesApplication) {}
/**
* Create a new expense transaction.
* @param {IExpenseCreateDTO} expenseDTO
*/
@Post()
public createExpense(@Body() expenseDTO: IExpenseCreateDTO) {
return this.expensesApplication.createExpense(expenseDTO);
}
/**
* Edit the given expense transaction.
* @param {number} expenseId
* @param {IExpenseEditDTO} expenseDTO
*/
@Put(':id')
public editExpense(
@Param('id') expenseId: number,
@Body() expenseDTO: IExpenseEditDTO,
) {
return this.expensesApplication.editExpense(expenseId, expenseDTO);
}
/**
* Delete the given expense transaction.
* @param {number} expenseId
*/
@Delete(':id')
public deleteExpense(@Param('id') expenseId: number) {
return this.expensesApplication.deleteExpense(expenseId);
}
/**
* Publish the given expense transaction.
* @param {number} expenseId
*/
@Post(':id/publish')
public publishExpense(@Param('id') expenseId: number) {
return this.expensesApplication.publishExpense(expenseId);
}
/**
* Get the expense transaction details.
* @param {number} expenseId
*/
@Get(':id')
public getExpense(@Param('id') expenseId: number) {
return this.expensesApplication.getExpense(expenseId);
}
}

View File

@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { CreateExpense } from './commands/CreateExpense.service';
import { DeleteExpense } from './commands/DeleteExpense.service';
import { EditExpense } from './commands/EditExpense.service';
import { PublishExpense } from './commands/PublishExpense.service';
import { ExpensesController } from './Expenses.controller';
import { ExpensesApplication } from './ExpensesApplication.service';
import { GetExpenseService } from './queries/GetExpense.service';
@Module({
imports: [],
controllers: [ExpensesController],
providers: [
CreateExpense,
EditExpense,
DeleteExpense,
PublishExpense,
GetExpenseService,
ExpensesApplication,
],
})
export class ExpensesModule {}

View File

@@ -0,0 +1,77 @@
import { Injectable } from '@nestjs/common';
import { CreateExpense } from './commands/CreateExpense.service';
import { EditExpense } from './commands/EditExpense.service';
import { DeleteExpense } from './commands/DeleteExpense.service';
import { PublishExpense } from './commands/PublishExpense.service';
import { GetExpenseService } from './queries/GetExpense.service';
import {
IExpenseCreateDTO,
IExpenseEditDTO,
} from './interfaces/Expenses.interface';
@Injectable()
export class ExpensesApplication {
constructor(
private readonly createExpenseService: CreateExpense,
private readonly editExpenseService: EditExpense,
private readonly deleteExpenseService: DeleteExpense,
private readonly publishExpenseService: PublishExpense,
private readonly getExpenseService: GetExpenseService,
// private readonly getExpensesService: GetExpenseService,
) {}
/**
* Create a new expense transaction.
* @param {IExpenseDTO} expenseDTO
* @returns {Promise<Expense>}
*/
public createExpense = (expenseDTO: IExpenseCreateDTO) => {
return this.createExpenseService.newExpense(expenseDTO);
};
/**
* Edits the given expense transaction.
* @param {number} expenseId - Expense id.
* @param {IExpenseEditDTO} expenseDTO
* @returns {Promise<Expense>}
*/
public editExpense = (expenseId: number, expenseDTO: IExpenseEditDTO) => {
return this.editExpenseService.editExpense(expenseId, expenseDTO);
};
/**
* Deletes the given expense.
* @param {number} expenseId - Expense id.
* @returns {Promise<void>}
*/
public deleteExpense = (expenseId: number) => {
return this.deleteExpenseService.deleteExpense(expenseId);
};
/**
* Publishes the given expense.
* @param {number} expenseId - Expense id.
* @returns {Promise<void>}
*/
public publishExpense = (expenseId: number) => {
return this.publishExpenseService.publishExpense(expenseId);
};
/**
* Retrieve the given expense details.
* @param {number} expenseId -Expense id.
* @return {Promise<Expense>}
*/
public getExpense = (expenseId: number) => {
return this.getExpenseService.getExpense(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,34 @@
// import { Inject, Service } from 'typedi';
// import { Exportable } from '../Export/Exportable';
// import { IExpensesFilter } from '@/interfaces';
// import { ExpensesApplication } from './ExpensesApplication.service';
// import { EXPORT_SIZE_LIMIT } from '../Export/constants';
// @Service()
// export class ExpensesExportable extends Exportable {
// @Inject()
// private expensesApplication: ExpensesApplication;
// /**
// * Retrieves the accounts data to exportable sheet.
// * @param {number} tenantId
// * @returns
// */
// public exportable(tenantId: number, query: IExpensesFilter) {
// const filterQuery = (query) => {
// query.withGraphFetched('branch');
// };
// const parsedQuery = {
// sortOrder: 'desc',
// columnSortBy: 'created_at',
// ...query,
// page: 1,
// pageSize: EXPORT_SIZE_LIMIT,
// filterQuery,
// } as IExpensesFilter;
// return this.expensesApplication
// .getExpenses(tenantId, parsedQuery)
// .then((output) => output.expenses);
// }
// }

View File

@@ -0,0 +1,46 @@
// import { Inject, Service } from 'typedi';
// import { Knex } from 'knex';
// import { IExpenseCreateDTO } from '@/interfaces';
// import { Importable } from '../Import/Importable';
// import { CreateExpense } from './CRUD/CreateExpense.service';
// import { ExpensesSampleData } from './constants';
// @Service()
// export class ExpensesImportable extends Importable {
// @Inject()
// private createExpenseService: CreateExpense;
// /**
// * Importing to account service.
// * @param {number} tenantId
// * @param {IAccountCreateDTO} createAccountDTO
// * @returns
// */
// public importable(
// tenantId: number,
// createAccountDTO: IExpenseCreateDTO,
// trx?: Knex.Transaction
// ) {
// return this.createExpenseService.newExpense(
// tenantId,
// createAccountDTO,
// {},
// trx
// );
// }
// /**
// * Concurrrency controlling of the importing process.
// * @returns {number}
// */
// public get concurrency() {
// return 1;
// }
// /**
// * Retrieves the sample data that used to download accounts sample sheet.
// */
// public sampleData(): any[] {
// return ExpensesSampleData;
// }
// }

View File

@@ -0,0 +1,109 @@
import { sumBy, difference } from 'lodash';
import { ERRORS, SUPPORTED_EXPENSE_PAYMENT_ACCOUNT_TYPES } from '../constants';
import {
IExpenseCreateDTO,
IExpenseEditDTO,
} from '../interfaces/Expenses.interface';
import { ACCOUNT_ROOT_TYPE } from '@/constants/accounts';
import { Account } from '@/modules/Accounts/models/Account.model';
import { Injectable } from '@nestjs/common';
import { Expense } from '../models/Expense.model';
import { ServiceError } from '@/modules/Items/ServiceError';
@Injectable()
export class CommandExpenseValidator {
/**
* 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: Account[],
DTOAccountsIds: number[],
) {
const storedExpenseAccountsIds = expenseAccounts.map((a: Account) => a.id);
const notStoredAccountsIds = difference(
DTOAccountsIds,
storedExpenseAccountsIds,
);
if (notStoredAccountsIds.length > 0) {
throw new ServiceError(ERRORS.SOME_ACCOUNTS_NOT_FOUND);
}
}
/**
* Validate expenses accounts type.
* @param {Account[]} expensesAccounts
* @throws {ServiceError}
*/
public validateExpensesAccountsType = (expensesAccounts: Account[]) => {
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 {Account} paymentAccount
* @throws {ServiceError}
*/
public validatePaymentAccountType = (paymentAccount: Account) => {
if (
!paymentAccount.isAccountType(SUPPORTED_EXPENSE_PAYMENT_ACCOUNT_TYPES)
) {
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(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: Expense) {
if (expense.publishedAt) {
throw new ServiceError(ERRORS.EXPENSE_ALREADY_PUBLISHED);
}
}
}

View File

@@ -0,0 +1,113 @@
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import {
IExpenseCreateDTO,
IExpenseCreatedPayload,
IExpenseCreatingPayload,
} from '../interfaces/Expenses.interface';
import { CommandExpenseValidator } from './CommandExpenseValidator.service';
import { ExpenseDTOTransformer } from './ExpenseDTOTransformer';
import { Account } from '@/modules/Accounts/models/Account.model';
import { Expense } from '@/modules/Expenses/models/Expense.model';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
@Injectable()
export class CreateExpense {
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly uow: UnitOfWork,
private readonly validator: CommandExpenseValidator,
private readonly transformDTO: ExpenseDTOTransformer,
@Inject(Account.name)
private readonly accountModel: typeof Account,
@Inject(Expense.name)
private readonly expenseModel: typeof Expense,
) {}
/**
* Authorize before create a new expense transaction.
* @param {IExpenseDTO} expenseDTO
*/
private authorize = async (expenseDTO: IExpenseCreateDTO) => {
// Validate payment account existance on the storage.
const paymentAccount = await this.accountModel
.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 this.accountModel
.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 (
expenseDTO: IExpenseCreateDTO,
trx?: Knex.Transaction,
): Promise<Expense> => {
// Authorize before create a new expense.
await this.authorize(expenseDTO);
// Save the expense to the storage.
const expenseObj = await this.transformDTO.expenseCreateDTO(expenseDTO);
// Writes the expense transaction with associated transactions under
// unit-of-work envirement.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Triggers `onExpenseCreating` event.
await this.eventEmitter.emitAsync(events.expenses.onCreating, {
trx,
expenseDTO,
} as IExpenseCreatingPayload);
// Creates a new expense transaction graph.
const expense = await this.expenseModel
.query(trx)
.upsertGraph(expenseObj);
// Triggers `onExpenseCreated` event.
await this.eventEmitter.emitAsync(events.expenses.onCreated, {
expenseId: expense.id,
expenseDTO,
expense,
trx,
} as IExpenseCreatedPayload);
return expense;
}, trx);
};
}

View File

@@ -0,0 +1,70 @@
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { CommandExpenseValidator } from './CommandExpenseValidator.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { Expense } from '../models/Expense.model';
import ExpenseCategory from '../models/ExpenseCategory.model';
import { events } from '@/common/events/events';
import {
IExpenseEventDeletePayload,
IExpenseDeletingPayload,
} from '../interfaces/Expenses.interface';
@Injectable()
export class DeleteExpense {
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly uow: UnitOfWork,
private readonly validator: CommandExpenseValidator,
@Inject(Expense.name)
private expenseModel: typeof Expense,
@Inject(ExpenseCategory.name)
private expenseCategoryModel: typeof ExpenseCategory,
) {}
/**
* Deletes the given expense.
* @param {number} expenseId
* @param {ISystemUser} authorizedUser
*/
public async deleteExpense(expenseId: number): Promise<void> {
// Retrieves the expense transaction with associated entries or
// throw not found error.
const oldExpense = await this.expenseModel
.query()
.findById(expenseId)
.withGraphFetched('categories')
.throwIfNotFound();
// Validates the expense has no associated landed cost.
await this.validator.validateNoAssociatedLandedCost(expenseId);
// Deletes expense transactions with associated transactions under
// unit-of-work envirement.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Triggers `onExpenseDeleting` event.
await this.eventEmitter.emitAsync(events.expenses.onDeleting, {
trx,
oldExpense,
} as IExpenseDeletingPayload);
// Deletes expense associated entries.
await this.expenseCategoryModel
.query(trx)
.where('expenseId', expenseId)
.delete();
// Deletes expense transactions.
await this.expenseModel.query(trx).findById(expenseId).delete();
// Triggers `onExpenseDeleted` event.
await this.eventEmitter.emitAsync(events.expenses.onDeleted, {
expenseId,
oldExpense,
trx,
} as IExpenseEventDeletePayload);
});
}
}

View File

@@ -0,0 +1,139 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import {
IExpenseEventEditPayload,
IExpenseEventEditingPayload,
IExpenseEditDTO,
} from '../interfaces/Expenses.interface';
import { CommandExpenseValidator } from './CommandExpenseValidator.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { ExpenseDTOTransformer } from './ExpenseDTOTransformer';
// import { EntriesService } from '@/services/Entries';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { Account } from '@/modules/Accounts/models/Account.model';
import { Expense } from '../models/Expense.model';
import { events } from '@/common/events/events';
@Injectable()
export class EditExpense {
constructor(
private eventEmitter: EventEmitter2,
private uow: UnitOfWork,
private validator: CommandExpenseValidator,
private transformDTO: ExpenseDTOTransformer,
// private entriesService: EntriesService,
@Inject(Expense.name)
private expenseModel: typeof Expense,
@Inject(Account.name)
private accountModel: typeof Account,
) {}
/**
* Authorize the DTO before editing expense transaction.
* @param {IExpenseEditDTO} expenseDTO
*/
public authorize = async (
oldExpense: Expense,
expenseDTO: IExpenseEditDTO,
) => {
// Validate payment account existance on the storage.
const paymentAccount = await this.accountModel
.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 this.accountModel
.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} expenseId
* @param {IExpenseDTO} expenseDTO
* @param {ISystemUser} authorizedUser
*/
public async editExpense(
expenseId: number,
expenseDTO: IExpenseEditDTO,
): Promise<Expense> {
// Retrieves the expense model or throw not found error.
const oldExpense = await this.expenseModel
.query()
.findById(expenseId)
.withGraphFetched('categories')
.throwIfNotFound();
// Authorize expense DTO before editing.
await this.authorize(oldExpense, expenseDTO);
// Update the expense on the storage.
const expenseObj = await this.transformDTO.expenseEditDTO(expenseDTO);
// Edits expense transactions and associated transactions under UOW envirement.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Triggers `onExpenseEditing` event.
await this.eventEmitter.emitAsync(events.expenses.onEditing, {
oldExpense,
expenseDTO,
trx,
} as IExpenseEventEditingPayload);
// Upsert the expense object with expense entries.
const expense = await this.expenseModel
.query(trx)
.upsertGraphAndFetch({
id: expenseId,
...expenseObj,
});
// Triggers `onExpenseCreated` event.
await this.eventEmitter.emitAsync(events.expenses.onEdited, {
expenseId,
expense,
expenseDTO,
oldExpense,
trx,
} as IExpenseEventEditPayload);
return expense;
});
}
}

View File

@@ -0,0 +1,117 @@
import { omit, sumBy } from 'lodash';
import moment from 'moment';
import * as R from 'ramda';
import {
IExpenseCreateDTO,
IExpenseCommonDTO,
IExpenseEditDTO,
} from '../interfaces/Expenses.interface';
// import { BranchTransactionDTOTransform } from '@/services/Branches/Integrations/BranchTransactionDTOTransform';
// import { TenantMetadata } from '@/system/models';
import { Injectable } from '@nestjs/common';
import { Expense } from '../models/Expense.model';
import { assocItemEntriesDefaultIndex } from '@/utils/associate-item-entries-index';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
@Injectable()
export class ExpenseDTOTransformer {
constructor(
// private readonly branchDTOTransform: BranchTransactionDTOTransform;
private readonly tenancyContext: TenancyContext,
) {}
/**
* Retrieve the expense landed cost amount.
* @param {IExpenseDTO} expenseDTO
* @return {number}
*/
private getExpenseLandedCostAmount = (
expenseDTO: IExpenseCreateDTO | IExpenseEditDTO,
): 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(
expenseDTO: IExpenseCreateDTO | IExpenseEditDTO,
// user?: ISystemUser
): Expense {
const landedCostAmount = this.getExpenseLandedCostAmount(expenseDTO);
const totalAmount = this.getExpenseCategoriesTotal(expenseDTO.categories);
const categories = R.compose(
// Associate the default index to categories lines.
assocItemEntriesDefaultIndex,
)(expenseDTO.categories || []);
const initialDTO = {
...omit(expenseDTO, ['publish', 'attachments']),
categories,
totalAmount,
landedCostAmount,
paymentDate: moment(expenseDTO.paymentDate).toMySqlDateTime(),
...(expenseDTO.publish
? {
publishedAt: moment().toMySqlDateTime(),
}
: {}),
};
return initialDTO;
// return R.compose(this.branchDTOTransform.transformDTO<IExpense>(tenantId))(
// initialDTO
// );
}
/**
* Transformes the expense create DTO.
* @param {IExpenseCreateDTO} expenseDTO
* @returns {Promise<Expense>}
*/
public expenseCreateDTO = async (
expenseDTO: IExpenseCreateDTO,
): Promise<Expense> => {
const initialDTO = this.expenseDTOToModel(expenseDTO);
const tenant = await this.tenancyContext.getTenant(true);
return {
...initialDTO,
currencyCode: expenseDTO.currencyCode || tenant?.metadata?.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 (
expenseDTO: IExpenseEditDTO,
): Promise<Expense> => {
return this.expenseDTOToModel(expenseDTO);
};
}

View File

@@ -0,0 +1,67 @@
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import {
IExpensePublishingPayload,
IExpenseEventPublishedPayload,
} from '../interfaces/Expenses.interface';
import { CommandExpenseValidator } from './CommandExpenseValidator.service';
import { Expense } from '../models/Expense.model';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { events } from '@/common/events/events';
import { EventEmitter2 } from '@nestjs/event-emitter';
@Injectable()
export class PublishExpense {
constructor(
private readonly eventPublisher: EventEmitter2,
private readonly uow: UnitOfWork,
private readonly validator: CommandExpenseValidator,
@Inject(Expense.name)
private readonly expenseModel: typeof Expense,
) {}
/**
* Publish the given expense.
* @param {number} expenseId
* @param {ISystemUser} authorizedUser
* @return {Promise<void>}
*/
public async publishExpense(expenseId: number) {
// Retrieves the old expense or throw not found error.
const oldExpense = await this.expenseModel
.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(async (trx: Knex.Transaction) => {
// Trigggers `onExpensePublishing` event.
await this.eventPublisher.emitAsync(events.expenses.onPublishing, {
trx,
oldExpense,
} as IExpensePublishingPayload);
// Publish the given expense on the storage.
await this.expenseModel.query().findById(expenseId).modify('publish');
// Retrieve the new expense after modification.
const expense = await this.expenseModel
.query()
.findById(expenseId)
.withGraphFetched('categories');
// Triggers `onExpensePublished` event.
await this.eventPublisher.emitAsync(events.expenses.onPublished, {
expenseId,
oldExpense,
expense,
trx,
} as IExpenseEventPublishedPayload);
});
}
}

View File

@@ -0,0 +1,89 @@
import { ACCOUNT_TYPE } from "@/constants/accounts";
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',
};
export const ExpensesSampleData = [
{
'Payment Date': '2024-03-01',
'Reference No.': 'REF-1',
'Payment Account': 'Petty Cash',
Description: 'Vel et dolorem architecto veniam.',
'Currency Code': '',
'Exchange Rate': '',
'Expense Account': 'Utilities Expense',
Amount: 9000,
'Line Description': 'Voluptates voluptas corporis vel.',
Publish: 'T',
},
{
'Payment Date': '2024-03-02',
'Reference No.': 'REF-2',
'Payment Account': 'Petty Cash',
Description: 'Id est molestias.',
'Currency Code': '',
'Exchange Rate': '',
'Expense Account': 'Utilities Expense',
Amount: 9000,
'Line Description': 'Eos voluptatem cumque et voluptate reiciendis.',
Publish: 'T',
},
{
'Payment Date': '2024-03-03',
'Reference No.': 'REF-3',
'Payment Account': 'Petty Cash',
Description: 'Quam cupiditate at nihil dicta dignissimos non fugit illo.',
'Currency Code': '',
'Exchange Rate': '',
'Expense Account': 'Utilities Expense',
Amount: 9000,
'Line Description':
'Hic alias rerum sed commodi dolores sint animi perferendis.',
Publish: 'T',
},
];
export const SUPPORTED_EXPENSE_PAYMENT_ACCOUNT_TYPES = [
ACCOUNT_TYPE.CASH,
ACCOUNT_TYPE.BANK,
ACCOUNT_TYPE.CREDIT_CARD,
ACCOUNT_TYPE.OTHER_CURRENT_ASSET,
ACCOUNT_TYPE.NON_CURRENT_ASSET,
ACCOUNT_TYPE.FIXED_ASSET,
];

View File

@@ -0,0 +1,117 @@
import { IFilterRole } from '@/modules/DynamicListing/DynamicFilter/DynamicFilter.types';
import { Knex } from 'knex';
import { Expense } from '../models/Expense.model';
// import { AttachmentLinkDTO } from '../Attachments/Attachments';
export interface IPaginationMeta {
total: number;
page: number;
pageSize: number;
}
export interface IExpensesFilter {
page: number;
pageSize: number;
filterRoles?: IFilterRole[];
columnSortBy: string;
sortOrder: string;
viewSlug?: string;
filterQuery?: (query: any) => void;
}
export interface IExpenseCommonDTO {
currencyCode: string;
exchangeRate?: number;
description?: string;
paymentAccountId: number;
peyeeId?: number;
referenceNo?: string;
publish: boolean;
userId: number;
paymentDate: Date;
payeeId: number;
categories: IExpenseCategoryDTO[];
branchId?: number;
// attachments?: AttachmentLinkDTO[];
}
export interface IExpenseCreateDTO extends IExpenseCommonDTO {}
export interface IExpenseEditDTO extends IExpenseCommonDTO {}
export interface IExpenseCategoryDTO {
id?: number;
expenseAccountId: number;
index: number;
amount: number;
description?: string;
expenseId: number;
landedCost?: boolean;
projectId?: number;
}
export interface IExpenseCreatingPayload {
trx: Knex.Transaction;
tenantId: number;
expenseDTO: IExpenseCreateDTO;
}
export interface IExpenseEventEditingPayload {
tenantId: number;
oldExpense: Expense;
expenseDTO: IExpenseEditDTO;
trx: Knex.Transaction;
}
export interface IExpenseCreatedPayload {
tenantId: number;
expenseId: number;
// authorizedUser: ISystemUser;
expense: Expense;
expenseDTO: IExpenseCreateDTO;
trx: Knex.Transaction;
}
export interface IExpenseEventEditPayload {
tenantId: number;
expenseId: number;
expense: Expense;
expenseDTO: IExpenseEditDTO;
// authorizedUser: ISystemUser;
oldExpense: Expense;
trx: Knex.Transaction;
}
export interface IExpenseEventDeletePayload {
tenantId: number;
expenseId: number;
// authorizedUser: ISystemUser;
oldExpense: Expense;
trx: Knex.Transaction;
}
export interface IExpenseDeletingPayload {
trx: Knex.Transaction;
tenantId: number;
oldExpense: Expense;
}
export interface IExpenseEventPublishedPayload {
tenantId: number;
expenseId: number;
oldExpense: Expense;
expense: Expense;
// authorizedUser: ISystemUser;
trx: Knex.Transaction;
}
export interface IExpensePublishingPayload {
trx: Knex.Transaction;
oldExpense: Expense;
tenantId: number;
}
export enum ExpenseAction {
Create = 'Create',
Edit = 'Edit',
Delete = 'Delete',
View = 'View',
}

View File

@@ -0,0 +1,302 @@
import { Model, mixin, raw } from 'objection';
// import TenantModel from 'models/TenantModel';
// import { viewRolesBuilder } from '@/lib/ViewRolesBuilder';
// import ModelSetting from './ModelSetting';
// import ExpenseSettings from './Expense.Settings';
// import CustomViewBaseModel from './CustomViewBaseModel';
// import { DEFAULT_VIEWS } from '@/services/Expenses/constants';
// import ModelSearchable from './ModelSearchable';
import moment from 'moment';
import { BaseModel } from '@/models/Model';
export class Expense extends BaseModel {
// ModelSetting,
// CustomViewBaseModel,
// ModelSearchable,
// ]) {
totalAmount!: number;
currencyCode!: string;
exchangeRate!: number;
description?: string;
paymentAccountId!: number;
peyeeId!: number;
referenceNo!: string;
publishedAt!: Date | null;
userId!: number;
paymentDate!: Date;
payeeId!: number;
landedCostAmount!: number;
allocatedCostAmount!: number;
invoicedAmount: number;
branchId!: number;
createdAt!: Date;
/**
* Table name
*/
static get tableName() {
return 'expenses_transactions';
}
/**
* Account transaction reference type.
*/
static get referenceType() {
return 'Expense';
}
/**
* Model timestamps.
*/
get timestamps() {
return ['createdAt', 'updatedAt'];
}
/**
* Virtual attributes.
*/
static get virtualAttributes() {
return [
'isPublished',
'unallocatedCostAmount',
'localAmount',
'localLandedCostAmount',
'localUnallocatedCostAmount',
'localAllocatedCostAmount',
'billableAmount',
];
}
/**
* Retrieves the local amount of expense.
* @returns {number}
*/
get localAmount() {
return this.totalAmount * this.exchangeRate;
}
/**
* Rertieves the local landed cost amount of expense.
* @returns {number}
*/
get localLandedCostAmount() {
return this.landedCostAmount * this.exchangeRate;
}
/**
* Retrieves the local allocated cost amount.
* @returns {number}
*/
get localAllocatedCostAmount() {
return this.allocatedCostAmount * this.exchangeRate;
}
/**
* Retrieve the unallocated cost amount.
* @return {number}
*/
get unallocatedCostAmount() {
return Math.max(this.totalAmount - this.allocatedCostAmount, 0);
}
/**
* Retrieves the local unallocated cost amount.
* @returns {number}
*/
get localUnallocatedCostAmount() {
return this.unallocatedCostAmount * this.exchangeRate;
}
/**
* Detarmines whether the expense is published.
* @returns {boolean}
*/
get isPublished() {
return Boolean(this.publishedAt);
}
/**
* Retrieves the calculated amount which have not been invoiced.
*/
get billableAmount() {
return Math.max(this.totalAmount - this.invoicedAmount, 0);
}
/**
* Model modifiers.
*/
static get modifiers() {
return {
filterByDateRange(query, startDate, endDate) {
if (startDate) {
query.where('date', '>=', startDate);
}
if (endDate) {
query.where('date', '<=', endDate);
}
},
filterByAmountRange(query, from, to) {
if (from) {
query.where('amount', '>=', from);
}
if (to) {
query.where('amount', '<=', to);
}
},
filterByExpenseAccount(query, accountId) {
if (accountId) {
query.where('expense_account_id', accountId);
}
},
filterByPaymentAccount(query, accountId) {
if (accountId) {
query.where('payment_account_id', accountId);
}
},
// viewRolesBuilder(query, conditionals, expression) {
// viewRolesBuilder(conditionals, expression)(query);
// },
filterByDraft(query) {
query.where('published_at', null);
},
filterByPublished(query) {
query.whereNot('published_at', null);
},
filterByStatus(query, status) {
switch (status) {
case 'draft':
query.modify('filterByDraft');
break;
case 'published':
default:
query.modify('filterByPublished');
break;
}
},
publish(query) {
query.update({
publishedAt: moment().toMySqlDateTime(),
});
},
/**
* Filters the expenses have billable amount.
*/
billable(query) {
query.where(raw('AMOUNT > INVOICED_AMOUNT'));
},
};
}
/**
* Relationship mapping.
*/
static get relationMappings() {
// const Account = require('models/Account');
const { ExpenseCategory } = require('./ExpenseCategory.model');
// const Document = require('models/Document');
// const Branch = require('models/Branch');
// const { MatchedBankTransaction } = require('models/MatchedBankTransaction');
return {
// paymentAccount: {
// relation: Model.BelongsToOneRelation,
// modelClass: Account.default,
// join: {
// from: 'expenses_transactions.paymentAccountId',
// to: 'accounts.id',
// },
// },
categories: {
relation: Model.HasManyRelation,
modelClass: ExpenseCategory,
join: {
from: 'expenses_transactions.id',
to: 'expense_transaction_categories.expenseId',
},
filter: (query) => {
query.orderBy('index', 'ASC');
},
},
/**
* Expense transction may belongs to a branch.
*/
// branch: {
// relation: Model.BelongsToOneRelation,
// modelClass: Branch.default,
// join: {
// from: 'expenses_transactions.branchId',
// to: 'branches.id',
// },
// },
// /**
// * Expense transaction may has many attached attachments.
// */
// attachments: {
// relation: Model.ManyToManyRelation,
// modelClass: Document.default,
// join: {
// from: 'expenses_transactions.id',
// through: {
// from: 'document_links.modelId',
// to: 'document_links.documentId',
// },
// to: 'documents.id',
// },
// filter(query) {
// query.where('model_ref', 'Expense');
// },
// },
// /**
// * Expense may belongs to matched bank transaction.
// */
// matchedBankTransaction: {
// relation: Model.HasManyRelation,
// modelClass: MatchedBankTransaction,
// join: {
// from: 'expenses_transactions.id',
// to: 'matched_bank_transactions.referenceId',
// },
// filter(query) {
// query.where('reference_type', 'Expense');
// },
// },
};
}
// static get meta() {
// return ExpenseSettings;
// }
// /**
// * Retrieve the default custom views, roles and columns.
// */
// static get defaultViews() {
// return DEFAULT_VIEWS;
// }
/**
* Model search attributes.
*/
static get searchRoles() {
return [
{ fieldKey: 'reference_no', comparator: 'contains' },
{ condition: 'or', fieldKey: 'amount', comparator: 'equals' },
];
}
/**
* Prevents mutate base currency since the model is not empty.
*/
static get preventMutateBaseCurrency() {
return true;
}
}

View File

@@ -0,0 +1,47 @@
import { Model } from 'objection';
import { BaseModel } from '@/models/Model';
export default class ExpenseCategory extends BaseModel {
amount!: number;
allocatedCostAmount!: number;
/**
* Table name
*/
static get tableName() {
return 'expense_transaction_categories';
}
/**
* Virtual attributes.
*/
static get virtualAttributes() {
return ['unallocatedCostAmount'];
}
/**
* Remain unallocated landed cost.
* @return {number}
*/
get unallocatedCostAmount() {
return Math.max(this.amount - this.allocatedCostAmount, 0);
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const Account = require('models/Account');
return {
expenseAccount: {
relation: Model.BelongsToOneRelation,
modelClass: Account.default,
join: {
from: 'expense_transaction_categories.expenseAccountId',
to: 'accounts.id',
},
},
};
}
}

View File

@@ -0,0 +1,103 @@
import { Transformer } from '@/modules/Transformer/Transformer';
import { ExpenseCategoryTransformer } from './ExpenseCategory.transformer';
// import { AttachmentTransformer } from '@/services/Attachments/AttachmentTransformer';
import { Expense } from '../models/Expense.model';
export class ExpenseTransfromer extends Transformer {
/**
* Include these attributes to expense object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'formattedAmount',
'formattedLandedCostAmount',
'formattedAllocatedCostAmount',
'formattedDate',
'formattedCreatedAt',
'formattedPublishedAt',
'categories',
'attachments',
];
};
/**
* Retrieve formatted expense amount.
* @param {IExpense} expense
* @returns {string}
*/
protected formattedAmount = (expense: Expense): string => {
return this.formatNumber(expense.totalAmount, {
currencyCode: expense.currencyCode,
});
};
/**
* Retrieve formatted expense landed cost amount.
* @param {IExpense} expense
* @returns {string}
*/
protected formattedLandedCostAmount = (expense: Expense): string => {
return this.formatNumber(expense.landedCostAmount, {
currencyCode: expense.currencyCode,
});
};
/**
* Retrieve formatted allocated cost amount.
* @param {IExpense} expense
* @returns {string}
*/
protected formattedAllocatedCostAmount = (expense: Expense): string => {
return this.formatNumber(expense.allocatedCostAmount, {
currencyCode: expense.currencyCode,
});
};
/**
* Retriecve fromatted date.
* @param {IExpense} expense
* @returns {string}
*/
protected formattedDate = (expense: Expense): string => {
return this.formatDate(expense.paymentDate);
};
/**
* Retrieve formatted created at date.
* @param {IExpense} expense
* @returns {string}
*/
protected formattedCreatedAt = (expense: Expense): string => {
return this.formatDate(expense.createdAt);
}
/**
* Retrieves the transformed expense categories.
* @param {IExpense} expense
* @returns {}
*/
protected categories = (expense: Expense) => {
// return this.item(expense.categories, new ExpenseCategoryTransformer(), {
// currencyCode: expense.currencyCode,
// });
};
/**
* Retrieves the sale invoice attachments.
* @param {ISaleInvoice} invoice
* @returns
*/
protected attachments = (expense: Expense) => {
// return this.item(expense.attachments, new AttachmentTransformer());
};
/**
* Retrieve formatted published at date.
* @param {IExpense} expense
* @returns {string}
*/
protected formattedPublishedAt = (expense: Expense): string => {
return this.formatDate(expense.publishedAt);
}
}

View File

@@ -0,0 +1,25 @@
import { Transformer } from '@/modules/Transformer/Transformer';
import ExpenseCategory from '../models/ExpenseCategory.model';
export class ExpenseCategoryTransformer extends Transformer {
/**
* Include these attributes to expense object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return ['amountFormatted'];
};
/**
* Retrieves the formatted amount.
* @param {ExpenseCategory} category
* @returns {string}
*/
protected amountFormatted(category: ExpenseCategory) {
return this.formatNumber(category.amount, {
currencyCode: this.context.organization.baseCurrency,
money: false,
});
}
}

View File

@@ -0,0 +1,32 @@
import { Inject, Injectable } from '@nestjs/common';
import { ExpenseTransfromer } from './Expense.transformer';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { Expense } from '../models/Expense.model';
@Injectable()
export class GetExpenseService {
constructor(
private readonly transformerService: TransformerInjectable,
@Inject(Expense.name)
private readonly expenseModel: typeof Expense,
) {}
/**
* Retrieve expense details.
* @param {number} expenseId
* @return {Promise<IExpense>}
*/
public async getExpense(expenseId: number): Promise<Expense> {
const expense = await this.expenseModel
.query()
.findById(expenseId)
.withGraphFetched('categories.expenseAccount')
.withGraphFetched('paymentAccount')
.withGraphFetched('branch')
.withGraphFetched('attachments')
.throwIfNotFound();
return this.transformerService.transform(expense, new ExpenseTransfromer());
}
}

View File

@@ -0,0 +1,81 @@
// 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 './Expense.transformer';
// 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);
// filterDTO?.filterQuery && filterDTO?.filterQuery(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,113 @@
import * as R from 'ramda';
import {
AccountNormal,
IExpenseCategory,
ILedger,
ILedgerEntry,
} from '@/interfaces';
import Ledger from '../Accounting/Ledger';
export class ExpenseGL {
private expense: any;
/**
* Constructor method.
*/
constructor(expense: any) {
this.expense = expense;
}
/**
* Retrieves the expense GL common entry.
* @param {IExpense} expense
* @returns {Partial<ILedgerEntry>}
*/
private getExpenseGLCommonEntry = (): Partial<ILedgerEntry> => {
return {
currencyCode: this.expense.currencyCode,
exchangeRate: this.expense.exchangeRate,
transactionType: 'Expense',
transactionId: this.expense.id,
date: this.expense.paymentDate,
userId: this.expense.userId,
debit: 0,
credit: 0,
branchId: this.expense.branchId,
};
};
/**
* Retrieves the expense GL payment entry.
* @param {IExpense} expense
* @returns {ILedgerEntry}
*/
private getExpenseGLPaymentEntry = (): ILedgerEntry => {
const commonEntry = this.getExpenseGLCommonEntry();
return {
...commonEntry,
credit: this.expense.localAmount,
accountId: this.expense.paymentAccountId,
accountNormal:
this.expense?.paymentAccount?.accountNormal === 'debit'
? AccountNormal.DEBIT
: 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(
(category: IExpenseCategory, index: number): ILedgerEntry => {
const commonEntry = this.getExpenseGLCommonEntry();
const localAmount = category.amount * this.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 = (): ILedgerEntry[] => {
const getCategoryEntry = this.getExpenseGLCategoryEntry();
const paymentEntry = this.getExpenseGLPaymentEntry();
const categoryEntries = this.expense.categories.map(getCategoryEntry);
return [paymentEntry, ...categoryEntries];
};
/**
* Retrieves the given expense ledger.
* @param {IExpense} expense
* @returns {ILedger}
*/
public getExpenseLedger = (): ILedger => {
const entries = this.getExpenseGLEntries();
console.log(entries, 'entries');
return new Ledger(entries);
};
}

View File

@@ -0,0 +1,45 @@
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import { IExpense, ILedger } from '@/interfaces';
import { ExpenseGL } from './ExpenseGL';
import HasTenancyService from '../Tenancy/TenancyService';
@Service()
export class ExpenseGLEntries {
@Inject()
private tenancy: HasTenancyService;
/**
* Retrieves the expense G/L of the given id.
* @param {number} tenantId
* @param {number} expenseId
* @param {Knex.Transaction} trx
* @returns {Promise<ILedger>}
*/
public getExpenseLedgerById = async (
tenantId: number,
expenseId: number,
trx?: Knex.Transaction
): Promise<ILedger> => {
const { Expense } = await this.tenancy.models(tenantId);
const expense = await Expense.query(trx)
.findById(expenseId)
.withGraphFetched('categories')
.withGraphFetched('paymentAccount')
.throwIfNotFound();
return this.getExpenseLedger(expense);
};
/**
* Retrieves the given expense ledger.
* @param {IExpense} expense
* @returns {ILedger}
*/
public getExpenseLedger = (expense: IExpense): ILedger => {
const expenseGL = new ExpenseGL(expense);
return expenseGL.getExpenseLedger();
};
}

View File

@@ -0,0 +1,72 @@
import { Knex } from 'knex';
import { Service, Inject } from 'typedi';
import LedgerStorageService from '@/services/Accounting/LedgerStorageService';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { ExpenseGLEntries } from './ExpenseGLEntriesService';
@Service()
export class ExpenseGLEntriesStorage {
@Inject()
private expenseGLEntries: ExpenseGLEntries;
@Inject()
private ledgerStorage: LedgerStorageService;
/**
* 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
) => {
// Retrieves the given expense ledger.
const expenseLedger = await this.expenseGLEntries.getExpenseLedgerById(
tenantId,
expenseId,
trx
);
// 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
*/
public 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) => {
// Cannot continue if the expense is not published.
if (!expense.publishedAt) return;
await this.expenseGLEntries.rewriteExpenseGLEntries(
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
);
};
}