feat(nestjs): migrate to NestJS

This commit is contained in:
Ahmed Bouhuolia
2025-04-07 11:51:24 +02:00
parent f068218a16
commit 55fcc908ef
3779 changed files with 631 additions and 195332 deletions

View File

@@ -0,0 +1,83 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
} from '@nestjs/common';
import { ExpensesApplication } from './ExpensesApplication.service';
import { IExpensesFilter } from './Expenses.types';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateExpenseDto, EditExpenseDto } from './dtos/Expense.dto';
@Controller('expenses')
@ApiTags('expenses')
export class ExpensesController {
constructor(private readonly expensesApplication: ExpensesApplication) {}
/**
* Create a new expense transaction.
* @param {IExpenseCreateDTO} expenseDTO
*/
@Post()
@ApiOperation({ summary: 'Create a new expense transaction.' })
public createExpense(@Body() expenseDTO: CreateExpenseDto) {
return this.expensesApplication.createExpense(expenseDTO);
}
/**
* Edit the given expense transaction.
* @param {number} expenseId
* @param {IExpenseEditDTO} expenseDTO
*/
@Put(':id')
@ApiOperation({ summary: 'Edit the given expense transaction.' })
public editExpense(
@Param('id') expenseId: number,
@Body() expenseDTO: EditExpenseDto,
) {
return this.expensesApplication.editExpense(expenseId, expenseDTO);
}
/**
* Delete the given expense transaction.
* @param {number} expenseId
*/
@Delete(':id')
@ApiOperation({ summary: 'Delete the given expense transaction.' })
public deleteExpense(@Param('id') expenseId: number) {
return this.expensesApplication.deleteExpense(expenseId);
}
/**
* Publish the given expense transaction.
* @param {number} expenseId
*/
@Post(':id/publish')
@ApiOperation({ summary: 'Publish the given expense transaction.' })
public publishExpense(@Param('id') expenseId: number) {
return this.expensesApplication.publishExpense(expenseId);
}
/**
* Get the expense transaction details.
*/
@Get('')
@ApiOperation({ summary: 'Get the expense transaction details.' })
public getExpenses(@Query() filterDTO: IExpensesFilter) {
return this.expensesApplication.getExpenses(filterDTO);
}
/**
* Get the expense transaction details.
* @param {number} expenseId
*/
@Get(':id')
@ApiOperation({ summary: 'Get the expense transaction details.' })
public getExpense(@Param('id') expenseId: number) {
return this.expensesApplication.getExpense(expenseId);
}
}

View File

@@ -0,0 +1,42 @@
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';
import { ExpenseDTOTransformer } from './commands/CommandExpenseDTO.transformer';
import { CommandExpenseValidator } from './commands/CommandExpenseValidator.service';
import { TenancyContext } from '../Tenancy/TenancyContext.service';
import { TransformerInjectable } from '../Transformer/TransformerInjectable.service';
import { ExpensesWriteGLSubscriber } from './subscribers/ExpenseGLEntries.subscriber';
import { ExpenseGLEntriesStorageService } from './subscribers/ExpenseGLEntriesStorage.sevice';
import { ExpenseGLEntriesService } from './subscribers/ExpenseGLEntries.service';
import { LedgerModule } from '../Ledger/Ledger.module';
import { BranchesModule } from '../Branches/Branches.module';
import { GetExpensesService } from './queries/GetExpenses.service';
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
@Module({
imports: [LedgerModule, BranchesModule, DynamicListModule],
controllers: [ExpensesController],
exports: [CreateExpense],
providers: [
CreateExpense,
ExpenseDTOTransformer,
CommandExpenseValidator,
EditExpense,
DeleteExpense,
PublishExpense,
GetExpenseService,
ExpensesApplication,
TenancyContext,
TransformerInjectable,
ExpensesWriteGLSubscriber,
ExpenseGLEntriesStorageService,
ExpenseGLEntriesService,
GetExpensesService,
],
})
export class ExpensesModule {}

View File

@@ -0,0 +1,79 @@
import { Knex } from 'knex';
import { Expense } from './models/Expense.model';
import { SystemUser } from '../System/models/SystemUser';
import { IFilterRole } from '../DynamicListing/DynamicFilter/DynamicFilter.types';
import { CreateExpenseDto, EditExpenseDto } from './dtos/Expense.dto';
import { CreateExpense } from './commands/CreateExpense.service';
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 IExpenseCreatingPayload {
trx: Knex.Transaction;
expenseDTO: CreateExpenseDto;
}
export interface IExpenseEventEditingPayload {
oldExpense: Expense;
expenseDTO: EditExpenseDto;
trx: Knex.Transaction;
}
export interface IExpenseCreatedPayload {
expenseId: number;
expense: Expense;
expenseDTO: CreateExpenseDto;
trx?: Knex.Transaction;
}
export interface IExpenseEventEditPayload {
expenseId: number;
expense: Expense;
expenseDTO: EditExpenseDto;
authorizedUser: SystemUser;
oldExpense: Expense;
trx: Knex.Transaction;
}
export interface IExpenseEventDeletePayload {
expenseId: number;
authorizedUser: SystemUser;
oldExpense: Expense;
trx: Knex.Transaction;
}
export interface IExpenseDeletingPayload {
trx: Knex.Transaction;
oldExpense: Expense;
}
export interface IExpenseEventPublishedPayload {
expenseId: number;
oldExpense: Expense;
expense: Expense;
authorizedUser: SystemUser;
trx: Knex.Transaction;
}
export interface IExpensePublishingPayload {
trx: Knex.Transaction;
oldExpense: Expense;
}
export enum ExpenseAction {
Create = 'Create',
Edit = 'Edit',
Delete = 'Delete',
View = 'View',
}

View File

@@ -0,0 +1,75 @@
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 { IExpensesFilter } from './interfaces/Expenses.interface';
import { GetExpensesService } from './queries/GetExpenses.service';
import { CreateExpenseDto, EditExpenseDto } from './dtos/Expense.dto';
@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: GetExpensesService,
) {}
/**
* Create a new expense transaction.
* @param {CreateExpenseDto} expenseDTO
* @returns {Promise<Expense>}
*/
public createExpense(expenseDTO: CreateExpenseDto) {
return this.createExpenseService.newExpense(expenseDTO);
}
/**
* Edits the given expense transaction.
* @param {number} expenseId - Expense id.
* @param {EditExpenseDto} expenseDTO
* @returns {Promise<Expense>}
*/
public editExpense(expenseId: number, expenseDTO: EditExpenseDto) {
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 {IExpensesFilter} expensesFilter
*/
public getExpenses(filterDTO: IExpensesFilter) {
return this.getExpensesService.getExpensesList(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,115 @@
import { Injectable } from '@nestjs/common';
import { omit, sumBy } from 'lodash';
import * as moment from 'moment';
import * as R from 'ramda';
import * as composeAsync from 'async/compose';
import { BranchTransactionDTOTransformer } from '@/modules/Branches/integrations/BranchTransactionDTOTransform';
import { Expense } from '../models/Expense.model';
import { assocItemEntriesDefaultIndex } from '@/utils/associate-item-entries-index';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import { CreateExpenseDto, EditExpenseDto } from '../dtos/Expense.dto';
@Injectable()
export class ExpenseDTOTransformer {
/**
* @param {BranchTransactionDTOTransformer} branchDTOTransform - Branch transaction DTO transformer.
* @param {TenancyContext} tenancyContext - Tenancy context.
*/
constructor(
private readonly branchDTOTransform: BranchTransactionDTOTransformer,
private readonly tenancyContext: TenancyContext,
) {}
/**
* Retrieve the expense landed cost amount.
* @param {IExpenseDTO} expenseDTO
* @return {number}
*/
private getExpenseLandedCostAmount = (
expenseDTO: CreateExpenseDto | EditExpenseDto,
): 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 async expenseDTOToModel(
expenseDTO: CreateExpenseDto | EditExpenseDto,
): Promise<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(),
}
: {}),
};
const asyncDto = await composeAsync(
this.branchDTOTransform.transformDTO<Expense>,
)(initialDTO);
return asyncDto as Expense;
}
/**
* Transforms the expense create DTO.
* @param {IExpenseCreateDTO} expenseDTO
* @returns {Promise<Expense>}
*/
public expenseCreateDTO = async (
expenseDTO: CreateExpenseDto | EditExpenseDto,
): Promise<Partial<Expense>> => {
const initialDTO = await 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 {EditExpenseDto} expenseDTO
* @returns {Promise<Expense>}
*/
public expenseEditDTO = async (
expenseDTO: EditExpenseDto,
): Promise<Expense> => {
return this.expenseDTOToModel(expenseDTO);
};
}

View File

@@ -0,0 +1,103 @@
import { sumBy, difference } from 'lodash';
import { ERRORS, SUPPORTED_EXPENSE_PAYMENT_ACCOUNT_TYPES } from '../constants';
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';
import { CreateExpenseDto, EditExpenseDto } from '../dtos/Expense.dto';
@Injectable()
export class CommandExpenseValidator {
/**
* Validates expense categories not equals zero.
* @param {IExpenseCreateDTO | IExpenseEditDTO} expenseDTO
* @throws {ServiceError}
*/
public validateCategoriesNotEqualZero = (
expenseDTO: CreateExpenseDto | EditExpenseDto,
) => {
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} 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} 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,122 @@
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import {
IExpenseCreatedPayload,
IExpenseCreatingPayload,
} from '../interfaces/Expenses.interface';
import { CommandExpenseValidator } from './CommandExpenseValidator.service';
import { ExpenseDTOTransformer } from './CommandExpenseDTO.transformer';
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';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { CreateExpenseDto } from '../dtos/Expense.dto';
@Injectable()
export class CreateExpense {
/**
* @param {EventEmitter2} eventEmitter - Event emitter.
* @param {UnitOfWork} uow - Unit of work.
* @param {CommandExpenseValidator} validator - Command expense validator.
* @param {ExpenseDTOTransformer} transformDTO - Expense DTO transformer.
* @param {typeof Account} accountModel - Account model.
* @param {typeof Expense} expenseModel - Expense model.
*/
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly uow: UnitOfWork,
private readonly validator: CommandExpenseValidator,
private readonly transformDTO: ExpenseDTOTransformer,
@Inject(Account.name)
private readonly accountModel: TenantModelProxy<typeof Account>,
@Inject(Expense.name)
private readonly expenseModel: TenantModelProxy<typeof Expense>,
) {}
/**
* Authorize before create a new expense transaction.
* @param {IExpenseDTO} expenseDTO
*/
private authorize = async (expenseDTO: CreateExpenseDto) => {
// 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: CreateExpenseDto,
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,79 @@
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';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class DeleteExpense {
/**
* @param {EventEmitter2} eventEmitter - Event emitter.
* @param {UnitOfWork} uow - Unit of work.
* @param {CommandExpenseValidator} validator - Command expense validator.
* @param {TenantModelProxy<typeof Expense>} expenseModel - Expense model.
* @param {TenantModelProxy<typeof ExpenseCategory>} expenseCategoryModel - Expense category model.
*/
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly uow: UnitOfWork,
private readonly validator: CommandExpenseValidator,
@Inject(Expense.name)
private expenseModel: TenantModelProxy<typeof Expense>,
@Inject(ExpenseCategory.name)
private expenseCategoryModel: TenantModelProxy<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,147 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import {
IExpenseEventEditPayload,
IExpenseEventEditingPayload,
} from '../interfaces/Expenses.interface';
import { CommandExpenseValidator } from './CommandExpenseValidator.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { ExpenseDTOTransformer } from './CommandExpenseDTO.transformer';
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';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { EditExpenseDto } from '../dtos/Expense.dto';
@Injectable()
export class EditExpense {
/**
* @param {EventEmitter2} eventEmitter - Event emitter.
* @param {UnitOfWork} uow - Unit of work.
* @param {CommandExpenseValidator} validator - Command expense validator.
* @param {ExpenseDTOTransformer} transformDTO - Expense DTO transformer.
* @param {typeof Expense} expenseModel - Expense model.
* @param {typeof Account} accountModel - Account model.
*/
constructor(
private eventEmitter: EventEmitter2,
private uow: UnitOfWork,
private validator: CommandExpenseValidator,
private transformDTO: ExpenseDTOTransformer,
@Inject(Expense.name)
private expenseModel: TenantModelProxy<typeof Expense>,
@Inject(Account.name)
private accountModel: TenantModelProxy<typeof Account>,
) {}
/**
* Authorize the DTO before editing expense transaction.
* @param {EditExpenseDto} expenseDTO
*/
public authorize = async (
oldExpense: Expense,
expenseDTO: EditExpenseDto,
) => {
// 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: EditExpenseDto,
): 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,74 @@
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';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class PublishExpense {
/**
* @param {EventEmitter2} eventPublisher - Event emitter.
* @param {UnitOfWork} uow - Unit of work.
* @param {CommandExpenseValidator} validator - Command expense validator.
* @param {typeof Expense} expenseModel - Expense model.
*/
constructor(
private readonly eventPublisher: EventEmitter2,
private readonly uow: UnitOfWork,
private readonly validator: CommandExpenseValidator,
@Inject(Expense.name)
private readonly expenseModel: TenantModelProxy<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,166 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsDate,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
MaxLength,
ValidateNested,
} from 'class-validator';
class AttachmentDto {
@IsString()
key: string;
}
export class ExpenseCategoryDto {
@IsInt()
@IsNotEmpty()
index: number;
@IsInt()
@IsNotEmpty()
expenseAccountId: number;
@IsNumber()
@IsOptional()
amount?: number;
@IsString()
@MaxLength(255)
@IsOptional()
description?: string;
@IsBoolean()
@IsOptional()
landedCost?: boolean;
@IsInt()
@IsOptional()
projectId?: number;
}
export class CommandExpenseDto {
@IsString()
@MaxLength(255)
@IsOptional()
@ApiProperty({
description: 'The reference number of the expense',
example: 'INV-123456',
})
referenceNo?: string;
@IsDate()
@IsNotEmpty()
@ApiProperty({
description: 'The payment date of the expense',
example: '2021-01-01',
})
paymentDate: Date;
@IsInt()
@IsNotEmpty()
@ApiProperty({
description: 'The payment account id of the expense',
example: 1,
})
paymentAccountId: number;
@IsString()
@MaxLength(1000)
@IsOptional()
@ApiProperty({
description: 'The description of the expense',
example: 'This is a description',
})
description?: string;
@IsNumber()
@IsOptional()
@ApiProperty({
description: 'The exchange rate of the expense',
example: 1,
})
exchangeRate?: number;
@IsString()
@MaxLength(3)
@IsOptional()
@ApiProperty({
description: 'The currency code of the expense',
example: 'USD',
})
currencyCode?: string;
@IsNumber()
@IsOptional()
@ApiProperty({
description: 'The exchange rate of the expense',
example: 1,
})
exchange_rate?: number;
@IsBoolean()
@IsOptional()
@ApiProperty({
description: 'The publish status of the expense',
example: true,
})
publish?: boolean;
@IsInt()
@IsOptional()
@ApiProperty({
description: 'The payee id of the expense',
example: 1,
})
payeeId?: number;
@IsInt()
@IsOptional()
@ApiProperty({
description: 'The branch id of the expense',
example: 1,
})
branchId?: number;
@IsArray()
@ValidateNested({ each: true })
@Type(() => ExpenseCategoryDto)
@ApiProperty({
description: 'The categories of the expense',
example: [
{
index: 1,
expenseAccountId: 1,
amount: 100,
description: 'This is a description',
landedCost: true,
projectId: 1,
},
],
})
categories: ExpenseCategoryDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => AttachmentDto)
@IsOptional()
@ApiProperty({
description: 'The attachments of the expense',
example: [
{
key: '123456',
},
],
})
attachments?: AttachmentDto[];
}
export class CreateExpenseDto extends CommandExpenseDto {}
export class EditExpenseDto extends CommandExpenseDto {}

View File

@@ -0,0 +1,87 @@
import { IFilterRole } from '@/modules/DynamicListing/DynamicFilter/DynamicFilter.types';
import { Knex } from 'knex';
import { Expense } from '../models/Expense.model';
import { CreateExpenseDto, EditExpenseDto } from '../dtos/Expense.dto';
// 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 IExpenseCategoryDTO {
id?: number;
expenseAccountId: number;
index: number;
amount: number;
description?: string;
expenseId: number;
landedCost?: boolean;
projectId?: number;
}
export interface IExpenseCreatingPayload {
expenseDTO: CreateExpenseDto;
trx: Knex.Transaction;
}
export interface IExpenseEventEditingPayload {
oldExpense: Expense;
expenseDTO: EditExpenseDto;
trx: Knex.Transaction;
}
export interface IExpenseCreatedPayload {
expenseId: number;
expense: Expense;
expenseDTO: CreateExpenseDto;
trx: Knex.Transaction;
}
export interface IExpenseEventEditPayload {
expenseId: number;
expense: Expense;
expenseDTO: EditExpenseDto;
oldExpense: Expense;
trx: Knex.Transaction;
}
export interface IExpenseEventDeletePayload {
expenseId: number;
oldExpense: Expense;
trx: Knex.Transaction;
}
export interface IExpenseDeletingPayload {
trx: Knex.Transaction;
oldExpense: Expense;
}
export interface IExpenseEventPublishedPayload {
expenseId: number;
oldExpense: Expense;
expense: Expense;
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,303 @@
import { Model, raw } from 'objection';
import * as moment from 'moment';
import { ExpenseCategory } from './ExpenseCategory.model';
import { Account } from '@/modules/Accounts/models/Account.model';
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
export class Expense extends TenantBaseModel {
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;
categories!: ExpenseCategory[];
paymentAccount!: Account;
attachments!: Document[];
/**
* 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('../../Accounts/models/Account.model');
const { ExpenseCategory } = require('./ExpenseCategory.model');
const { Document } = require('../../ChromiumlyTenancy/models/Document');
const { Branch } = require('../../Branches/models/Branch.model');
// const { MatchedBankTransaction } = require('models/MatchedBankTransaction');
return {
/**
* Expense transaction may belongs to a payment account.
*/
paymentAccount: {
relation: Model.BelongsToOneRelation,
modelClass: Account,
join: {
from: 'expenses_transactions.paymentAccountId',
to: 'accounts.id',
},
},
/**
* Expense transaction may has many expense categories.
*/
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,
join: {
from: 'expenses_transactions.branchId',
to: 'branches.id',
},
},
/**
* Expense transaction may has many attached attachments.
*/
attachments: {
relation: Model.ManyToManyRelation,
modelClass: Document,
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,50 @@
import { Model } from 'objection';
import { BaseModel } from '@/models/Model';
export class ExpenseCategory extends BaseModel {
public amount!: number;
public allocatedCostAmount!: number;
public expenseAccountId!: number;
public projectId!: number;
public description!: string;
/**
* 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('../../Accounts/models/Account.model');
return {
expenseAccount: {
relation: Model.BelongsToOneRelation,
modelClass: Account,
join: {
from: 'expense_transaction_categories.expenseAccountId',
to: 'accounts.id',
},
},
};
}
}

View File

@@ -0,0 +1,104 @@
import { Transformer } from '@/modules/Transformer/Transformer';
import { ExpenseCategoryTransformer } from './ExpenseCategory.transformer';
// import { AttachmentTransformer } from '@/services/Attachments/AttachmentTransformer';
import { Expense } from '../models/Expense.model';
import { AttachmentTransformer } from '@/modules/Attachments/Attachment.transformer';
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 {Expense} expense - Expense.
* @returns {string}
*/
protected formattedAmount = (expense: Expense): string => {
return this.formatNumber(expense.totalAmount, {
currencyCode: expense.currencyCode,
});
};
/**
* Retrieve formatted expense landed cost amount.
* @param {Expense} expense - Expense.
* @returns {string}
*/
protected formattedLandedCostAmount = (expense: Expense): string => {
return this.formatNumber(expense.landedCostAmount, {
currencyCode: expense.currencyCode,
});
};
/**
* Retrieve formatted allocated cost amount.
* @param {Expense} expense - Expense.
* @returns {string}
*/
protected formattedAllocatedCostAmount = (expense: Expense): string => {
return this.formatNumber(expense.allocatedCostAmount, {
currencyCode: expense.currencyCode,
});
};
/**
* Retriecve fromatted date.
* @param {Expense} expense - Expense.
* @returns {string}
*/
protected formattedDate = (expense: Expense): string => {
return this.formatDate(expense.paymentDate);
};
/**
* Retrieve formatted created at date.
* @param {Expense} expense - Expense.
* @returns {string}
*/
protected formattedCreatedAt = (expense: Expense): string => {
return this.formatDate(expense.createdAt);
};
/**
* Retrieves the transformed expense categories.
* @param {Expense} expense - Expense.
* @returns
*/
protected categories = (expense: Expense) => {
return this.item(expense.categories, new ExpenseCategoryTransformer(), {
currencyCode: expense.currencyCode,
});
};
/**
* Retrieves the sale invoice attachments.
* @param {Expense} expense - Expense.
* @returns
*/
protected attachments = (expense: Expense) => {
return this.item(expense.attachments, new AttachmentTransformer());
};
/**
* Retrieve formatted published at date.
* @param {Expense} expense - 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,33 @@
import { Inject, Injectable } from '@nestjs/common';
import { ExpenseTransfromer } from './Expense.transformer';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { Expense } from '../models/Expense.model';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class GetExpenseService {
constructor(
private readonly transformerService: TransformerInjectable,
@Inject(Expense.name)
private readonly expenseModel: TenantModelProxy<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,70 @@
import * as R from 'ramda';
import { ExpenseTransfromer } from './Expense.transformer';
import { DynamicListService } from '@/modules/DynamicListing/DynamicList.service';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { Inject, Injectable } from '@nestjs/common';
import { IExpensesFilter, IPaginationMeta } from '../Expenses.types';
import { Expense } from '../models/Expense.model';
import { IFilterMeta } from '@/interfaces/Model';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class GetExpensesService {
constructor(
private readonly transformer: TransformerInjectable,
private readonly dynamicListService: DynamicListService,
@Inject(Expense.name)
private readonly expense: TenantModelProxy<typeof Expense>,
) {}
/**
* Retrieve expenses paginated list.
* @param {IExpensesFilter} expensesFilter
* @return {IExpense[]}
*/
public async getExpensesList(filterDTO: IExpensesFilter): Promise<{
expenses: Expense[];
pagination: IPaginationMeta;
filterMeta: IFilterMeta;
}> {
// Parses list filter DTO.
const filter = this.parseListFilterDTO(filterDTO);
// Dynamic list service.
const dynamicList = await this.dynamicListService.dynamicList(
this.expense(),
filter,
);
// Retrieves the paginated results.
const { results, pagination } = await this.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(
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,106 @@
import * as R from 'ramda';
import { ILedger } from '@/modules/Ledger/types/Ledger.types';
import { AccountNormal } from '@/modules/Accounts/Accounts.types';
import { ILedgerEntry } from '@/modules/Ledger/types/Ledger.types';
import { ExpenseCategory } from '../models/ExpenseCategory.model';
import { Ledger } from '@/modules/Ledger/Ledger';
import { Expense } from '../models/Expense.model';
export class ExpenseGL {
private expense: Expense;
/**
* Constructor method.
* @param {Expense} expense - Expense.
*/
constructor(expense: Expense) {
this.expense = expense;
}
/**
* Retrieves the expense GL common entry.
*/
private getExpenseGLCommonEntry = () => {
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.
* @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 {ExpenseCategory} category - Expense category.
* @param {number} index
* @returns {ILedgerEntry}
*/
private getExpenseGLCategoryEntry = R.curry(
(category: ExpenseCategory, 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.
* @returns {ILedgerEntry[]}
*/
public getExpenseGLEntries = (): ILedgerEntry[] => {
const getCategoryEntry = this.getExpenseGLCategoryEntry();
const paymentEntry = this.getExpenseGLPaymentEntry();
const categoryEntries = this.expense.categories.map((category, index) =>
getCategoryEntry(category, index),
);
return [paymentEntry, ...categoryEntries];
};
/**
* Retrieves the given expense ledger.
* @returns {ILedger}
*/
public getExpenseLedger = (): ILedger => {
const entries = this.getExpenseGLEntries();
return new Ledger(entries);
};
}

View File

@@ -0,0 +1,48 @@
import { Knex } from 'knex';
import { ExpenseGL } from './ExpenseGL';
import { Inject, Injectable } from '@nestjs/common';
import { Expense } from '../models/Expense.model';
import { ILedger } from '@/modules/Ledger/types/Ledger.types';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class ExpenseGLEntriesService {
/**
* @param {TenantModelProxy<typeof Expense>} expense - Expense model.
*/
constructor(
@Inject(Expense.name)
private readonly expense: TenantModelProxy<typeof Expense>,
) {}
/**
* Retrieves the expense G/L of the given id.
* @param {number} expenseId
* @param {Knex.Transaction} trx - Knex transaction.
* @returns {Promise<ILedger>}
*/
public getExpenseLedgerById = async (
expenseId: number,
trx?: Knex.Transaction,
): Promise<ILedger> => {
const expense = await this.expense()
.query(trx)
.findById(expenseId)
.withGraphFetched('categories')
.withGraphFetched('paymentAccount')
.throwIfNotFound();
return this.getExpenseLedger(expense);
};
/**
* Retrieves the given expense ledger.
* @param {Expense} expense - Expense model.
* @returns {ILedger}
*/
public getExpenseLedger = (expense: Expense): ILedger => {
const expenseGL = new ExpenseGL(expense);
return expenseGL.getExpenseLedger();
};
}

View File

@@ -0,0 +1,79 @@
import {
IExpenseCreatedPayload,
IExpenseEventDeletePayload,
IExpenseEventEditPayload,
IExpenseEventPublishedPayload,
} from '../Expenses.types';
import { ExpenseGLEntriesStorageService } from './ExpenseGLEntriesStorage.sevice';
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
@Injectable()
export class ExpensesWriteGLSubscriber {
/**
* @param {ExpenseGLEntriesStorageService} expenseGLEntries - Expense GL entries storage service.
*/
constructor(
private readonly expenseGLEntries: ExpenseGLEntriesStorageService,
) {}
/**
* Handles the writing journal entries once the expense created.
* @param {IExpenseCreatedPayload} payload -
*/
@OnEvent(events.expenses.onCreated)
public async handleWriteGLEntriesOnceCreated({
expense,
trx,
}: IExpenseCreatedPayload) {
// In case expense published, write journal entries.
if (!expense.publishedAt) return;
await this.expenseGLEntries.writeExpenseGLEntries(expense.id, trx);
}
/**
* Handle writing expense journal entries once the expense edited.
* @param {IExpenseEventEditPayload} payload -
*/
@OnEvent(events.expenses.onEdited)
public async handleRewriteGLEntriesOnceEdited({
expenseId,
expense,
authorizedUser,
trx,
}: IExpenseEventEditPayload) {
// Cannot continue if the expense is not published.
if (!expense.publishedAt) return;
await this.expenseGLEntries.rewriteExpenseGLEntries(expense.id, trx);
}
/**
* Reverts expense journal entries once the expense deleted.
* @param {IExpenseEventDeletePayload} payload -
*/
@OnEvent(events.expenses.onDeleted)
public async handleRevertGLEntriesOnceDeleted({
expenseId,
trx,
}: IExpenseEventDeletePayload) {
await this.expenseGLEntries.revertExpenseGLEntries(expenseId, trx);
}
/**
* Handles writing expense journal once the expense publish.
* @param {IExpenseEventPublishedPayload} payload -
*/
@OnEvent(events.expenses.onPublished)
public async handleWriteGLEntriesOncePublished({
expense,
trx,
}: IExpenseEventPublishedPayload) {
// In case expense published, write journal entries.
if (!expense.publishedAt) return;
await this.expenseGLEntries.rewriteExpenseGLEntries(expense.id, trx);
}
}

View File

@@ -0,0 +1,68 @@
import { Knex } from 'knex';
import { Injectable } from '@nestjs/common';
import { ExpenseGLEntriesService } from './ExpenseGLEntries.service';
import { LedgerStorageService } from '@/modules/Ledger/LedgerStorage.service';
@Injectable()
export class ExpenseGLEntriesStorageService {
/**
* @param {ExpenseGLEntriesService} expenseGLEntries - Expense GL entries service.
* @param {LedgerStorageService} ledgerStorage - Ledger storage service.
*/
constructor(
private readonly expenseGLEntries: ExpenseGLEntriesService,
private readonly ledgerStorage: LedgerStorageService,
) {}
/**
* Writes the expense GL entries.
* @param {number} tenantId
* @param {number} expenseId
* @param {Knex.Transaction} trx
*/
public writeExpenseGLEntries = async (
expenseId: number,
trx?: Knex.Transaction,
) => {
// Retrieves the given expense ledger.
const expenseLedger = await this.expenseGLEntries.getExpenseLedgerById(
expenseId,
trx,
);
// Commits the expense ledger entries.
await this.ledgerStorage.commit(expenseLedger, trx);
};
/**
* Reverts the given expense GL entries.
* @param {number} tenantId
* @param {number} expenseId
* @param {Knex.Transaction} trx
*/
public revertExpenseGLEntries = async (
expenseId: number,
trx?: Knex.Transaction,
) => {
await this.ledgerStorage.deleteByReference(
expenseId,
'Expense',
trx,
);
};
/**
* Rewrites the expense GL entries.
* @param {number} expenseId
* @param {Knex.Transaction} trx
*/
public rewriteExpenseGLEntries = async (
expenseId: number,
trx?: Knex.Transaction,
) => {
// Reverts the expense GL entries.
await this.revertExpenseGLEntries(expenseId, trx);
// Writes the expense GL entries.
await this.writeExpenseGLEntries(expenseId, trx);
};
}