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,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);
});
}
}