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

@@ -1,5 +1,5 @@
import { Transformer } from '../Transformer/Transformer';
import { AccountModel } from './models/Account.model';
import { Account } from './models/Account.model';
import { flatToNestedArray } from '@/utils/flat-to-nested-array';
import { assocDepthLevelToObjectTree } from '@/utils/assoc-depth-level-to-object-tree';
import { nestedArrayToFlatten } from '@/utils/nested-array-to-flatten';
@@ -35,7 +35,7 @@ export class AccountTransformer extends Transformer {
* @param {IAccount} account -
* @returns {string}
*/
public flattenName = (account: AccountModel): string => {
public flattenName = (account: Account): string => {
const parentDependantsIds = this.options.accountsGraph.dependantsOf(
account.id,
);
@@ -51,7 +51,7 @@ export class AccountTransformer extends Transformer {
* @param {IAccount} invoice
* @returns {string}
*/
protected formattedAmount = (account: AccountModel): string => {
protected formattedAmount = (account: Account): string => {
return this.formatNumber(account.amount, {
currencyCode: account.currencyCode,
});
@@ -59,10 +59,10 @@ export class AccountTransformer extends Transformer {
/**
* Retrieves the formatted bank balance.
* @param {AccountModel} account
* @param {Account} account
* @returns {string}
*/
protected bankBalanceFormatted = (account: AccountModel): string => {
protected bankBalanceFormatted = (account: Account): string => {
return this.formatNumber(account.bankBalance, {
currencyCode: account.currencyCode,
});
@@ -73,7 +73,7 @@ export class AccountTransformer extends Transformer {
* @param {IAccount} account
* @returns {string}
*/
protected lastFeedsUpdatedAtFormatted = (account: AccountModel): string => {
protected lastFeedsUpdatedAtFormatted = (account: Account): string => {
return account.lastFeedsUpdatedAt
? this.formatDate(account.lastFeedsUpdatedAt)
: '';
@@ -84,7 +84,7 @@ export class AccountTransformer extends Transformer {
* @param account
* @returns {boolean}
*/
protected isFeedsPaused = (account: AccountModel): boolean => {
protected isFeedsPaused = (account: Account): boolean => {
// return account.plaidItem?.isPaused || false;
return false;
@@ -94,7 +94,7 @@ export class AccountTransformer extends Transformer {
* Retrieves formatted account type label.
* @returns {string}
*/
protected accountTypeLabel = (account: AccountModel): string => {
protected accountTypeLabel = (account: Account): string => {
return this.context.i18n.t(account.accountTypeLabel);
};
@@ -102,7 +102,7 @@ export class AccountTransformer extends Transformer {
* Retrieves formatted account normal.
* @returns {string}
*/
protected accountNormalFormatted = (account: AccountModel): string => {
protected accountNormalFormatted = (account: Account): string => {
return this.context.i18n.t(account.accountNormalFormatted);
};
@@ -111,7 +111,7 @@ export class AccountTransformer extends Transformer {
* @param {IAccount[]}
* @returns {IAccount[]}
*/
protected postCollectionTransform = (accounts: AccountModel[]) => {
protected postCollectionTransform = (accounts: Account[]) => {
// Transfom the flatten to accounts tree.
const transformed = flatToNestedArray(accounts, {
id: 'id',

View File

@@ -1,5 +1,5 @@
import { Knex } from 'knex';
import { AccountModel } from './models/Account.model';
import { Account } from './models/Account.model';
// import { IDynamicListFilterDTO } from '@/interfaces/DynamicFilter';
export enum AccountNormal {
@@ -42,26 +42,26 @@ export interface IAccountEventCreatingPayload {
trx: Knex.Transaction;
}
export interface IAccountEventCreatedPayload {
account: AccountModel;
account: Account;
accountId: number;
trx: Knex.Transaction;
}
export interface IAccountEventEditedPayload {
account: AccountModel;
oldAccount: AccountModel;
account: Account;
oldAccount: Account;
trx: Knex.Transaction;
}
export interface IAccountEventDeletedPayload {
accountId: number;
oldAccount: AccountModel;
oldAccount: Account;
trx: Knex.Transaction;
}
export interface IAccountEventDeletePayload {
trx: Knex.Transaction;
oldAccount: AccountModel;
oldAccount: Account;
}
export interface IAccountEventActivatedPayload {

View File

@@ -4,7 +4,7 @@ import { CreateAccountService } from './CreateAccount.service';
import { DeleteAccount } from './DeleteAccount.service';
import { EditAccount } from './EditAccount.service';
import { CreateAccountDTO } from './CreateAccount.dto';
import { AccountModel } from './models/Account.model';
import { Account } from './models/Account.model';
import { EditAccountDTO } from './EditAccount.dto';
import { GetAccount } from './GetAccount.service';
import { ActivateAccount } from './ActivateAccount.service';
@@ -37,7 +37,7 @@ export class AccountsApplication {
public createAccount = (
accountDTO: CreateAccountDTO,
trx?: Knex.Transaction,
): Promise<AccountModel> => {
): Promise<Account> => {
return this.createAccountService.createAccount(accountDTO, trx);
};

View File

@@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { IAccountEventActivatedPayload } from './Accounts.types';
import { AccountModel } from './models/Account.model';
import { Account } from './models/Account.model';
import { AccountRepository } from './repositories/Account.repository';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
@@ -13,8 +13,8 @@ export class ActivateAccount {
private readonly eventEmitter: EventEmitter2,
private readonly uow: UnitOfWork,
@Inject(AccountModel.name)
private readonly accountModel: typeof AccountModel,
@Inject(Account.name)
private readonly accountModel: typeof Account,
private readonly accountRepository: AccountRepository,
) {}

View File

@@ -4,7 +4,7 @@ import { Inject, Injectable, Scope } from '@nestjs/common';
// import AccountTypesUtils from '@/lib/AccountTypes';
import { ServiceError } from '../Items/ServiceError';
import { ERRORS, MAX_ACCOUNTS_CHART_DEPTH } from './constants';
import { AccountModel } from './models/Account.model';
import { Account } from './models/Account.model';
import { AccountRepository } from './repositories/Account.repository';
import { AccountTypesUtils } from './utils/AccountType.utils';
import { CreateAccountDTO } from './CreateAccount.dto';
@@ -13,16 +13,16 @@ import { EditAccountDTO } from './EditAccount.dto';
@Injectable({ scope: Scope.REQUEST })
export class CommandAccountValidators {
constructor(
@Inject(AccountModel.name)
private readonly accountModel: typeof AccountModel,
@Inject(Account.name)
private readonly accountModel: typeof Account,
private readonly accountRepository: AccountRepository,
) {}
/**
* Throws error if the account was prefined.
* @param {AccountModel} account
* @param {Account} account
*/
public throwErrorIfAccountPredefined(account: AccountModel) {
public throwErrorIfAccountPredefined(account: Account) {
if (account.predefined) {
throw new ServiceError(ERRORS.ACCOUNT_PREDEFINED);
}
@@ -31,12 +31,12 @@ export class CommandAccountValidators {
/**
* Diff account type between new and old account, throw service error
* if they have different account type.
* @param {AccountModel|CreateAccountDTO|EditAccountDTO} oldAccount
* @param {AccountModel|CreateAccountDTO|EditAccountDTO} newAccount
* @param {Account|CreateAccountDTO|EditAccountDTO} oldAccount
* @param {Account|CreateAccountDTO|EditAccountDTO} newAccount
*/
public async isAccountTypeChangedOrThrowError(
oldAccount: AccountModel | CreateAccountDTO | EditAccountDTO,
newAccount: AccountModel | CreateAccountDTO | EditAccountDTO,
oldAccount: Account | CreateAccountDTO | EditAccountDTO,
newAccount: Account | CreateAccountDTO | EditAccountDTO,
) {
if (oldAccount.accountType !== newAccount.accountType) {
throw new ServiceError(ERRORS.ACCOUNT_TYPE_NOT_ALLOWED_TO_CHANGE);
@@ -155,13 +155,13 @@ export class CommandAccountValidators {
* Validates the account DTO currency code whether equals the currency code of
* parent account.
* @param {CreateAccountDTO | EditAccountDTO} accountDTO
* @param {AccountModel} parentAccount
* @param {Account} parentAccount
* @param {string} baseCurrency -
* @throws {ServiceError(ERRORS.ACCOUNT_CURRENCY_NOT_SAME_PARENT_ACCOUNT)}
*/
public validateCurrentSameParentAccount = (
accountDTO: CreateAccountDTO | EditAccountDTO,
parentAccount: AccountModel,
parentAccount: Account,
baseCurrency: string,
) => {
// If the account DTO currency not assigned and the parent account has no base currency.
@@ -187,7 +187,7 @@ export class CommandAccountValidators {
*/
public throwErrorIfParentHasDiffType(
accountDTO: CreateAccountDTO | EditAccountDTO,
parentAccount: AccountModel,
parentAccount: Account,
) {
if (accountDTO.accountType !== parentAccount.accountType) {
throw new ServiceError(ERRORS.PARENT_ACCOUNT_HAS_DIFFERENT_TYPE);

View File

@@ -11,7 +11,7 @@ import {
CreateAccountParams,
} from './Accounts.types';
import { CommandAccountValidators } from './CommandAccountValidators.service';
import { AccountModel } from './models/Account.model';
import { Account } from './models/Account.model';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { TenancyContext } from '../Tenancy/TenancyContext.service';
import { events } from '@/common/events/events';
@@ -20,8 +20,8 @@ import { CreateAccountDTO } from './CreateAccount.dto';
@Injectable()
export class CreateAccountService {
constructor(
@Inject(AccountModel.name)
private readonly accountModel: typeof AccountModel,
@Inject(Account.name)
private readonly accountModel: typeof Account,
private readonly eventEmitter: EventEmitter2,
private readonly uow: UnitOfWork,
private readonly validator: CommandAccountValidators,
@@ -102,7 +102,7 @@ export class CreateAccountService {
accountDTO: CreateAccountDTO,
trx?: Knex.Transaction,
params: CreateAccountParams = { ignoreUniqueName: false },
): Promise<AccountModel> => {
): Promise<Account> => {
// Retrieves the given tenant metadata.
const tenant = await this.tenancyContext.getTenant(true);

View File

@@ -2,7 +2,7 @@ import { Knex } from 'knex';
import { Inject, Injectable } from '@nestjs/common';
// import { IAccountEventDeletedPayload } from '@/interfaces';
import { CommandAccountValidators } from './CommandAccountValidators.service';
import { AccountModel } from './models/Account.model';
import { Account } from './models/Account.model';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { events } from '@/common/events/events';
@@ -11,7 +11,7 @@ import { IAccountEventDeletedPayload } from './Accounts.types';
@Injectable()
export class DeleteAccount {
constructor(
@Inject(AccountModel.name) private accountModel: typeof AccountModel,
@Inject(Account.name) private accountModel: typeof Account,
private eventEmitter: EventEmitter2,
private uow: UnitOfWork,
private validator: CommandAccountValidators,
@@ -21,7 +21,7 @@ export class DeleteAccount {
* Authorize account delete.
* @param {number} accountId - Account id.
*/
private authorize = async (accountId: number, oldAccount: AccountModel) => {
private authorize = async (accountId: number, oldAccount: Account) => {
// Throw error if the account was predefined.
this.validator.throwErrorIfAccountPredefined(oldAccount);
};

View File

@@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { CommandAccountValidators } from './CommandAccountValidators.service';
import { AccountModel } from './models/Account.model';
import { Account } from './models/Account.model';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { events } from '@/common/events/events';
@@ -14,8 +14,8 @@ export class EditAccount {
private readonly uow: UnitOfWork,
private readonly validator: CommandAccountValidators,
@Inject(AccountModel.name)
private readonly accountModel: typeof AccountModel,
@Inject(Account.name)
private readonly accountModel: typeof Account,
) {}
/**
@@ -27,7 +27,7 @@ export class EditAccount {
private authorize = async (
accountId: number,
accountDTO: EditAccountDTO,
oldAccount: AccountModel,
oldAccount: Account,
) => {
// Validate account name uniquiness.
await this.validator.validateAccountNameUniquiness(
@@ -64,7 +64,7 @@ export class EditAccount {
public async editAccount(
accountId: number,
accountDTO: EditAccountDTO,
): Promise<AccountModel> {
): Promise<Account> {
// Retrieve the old account or throw not found service error.
const oldAccount = await this.accountModel
.query()

View File

@@ -1,6 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { AccountTransformer } from './Account.transformer';
import { AccountModel } from './models/Account.model';
import { Account } from './models/Account.model';
import { AccountRepository } from './repositories/Account.repository';
import { TransformerInjectable } from '../Transformer/TransformerInjectable.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
@@ -9,8 +9,8 @@ import { events } from '@/common/events/events';
@Injectable()
export class GetAccount {
constructor(
@Inject(AccountModel.name)
private readonly accountModel: typeof AccountModel,
@Inject(Account.name)
private readonly accountModel: typeof Account,
private readonly accountRepository: AccountRepository,
private readonly transformer: TransformerInjectable,
private readonly eventEmitter: EventEmitter2,

View File

@@ -4,7 +4,7 @@ import {
} from './Accounts.types';
import { AccountTransactionTransformer } from './AccountTransaction.transformer';
import { AccountTransaction } from './models/AccountTransaction.model';
import { AccountModel } from './models/Account.model';
import { Account } from './models/Account.model';
import { Inject, Injectable } from '@nestjs/common';
import { TransformerInjectable } from '../Transformer/TransformerInjectable.service';
@@ -16,8 +16,8 @@ export class GetAccountTransactionsService {
@Inject(AccountTransaction.name)
private readonly accountTransaction: typeof AccountTransaction,
@Inject(AccountModel.name)
private readonly account: typeof AccountModel,
@Inject(Account.name)
private readonly account: typeof Account,
) {}
/**

View File

@@ -8,7 +8,7 @@
// import { DynamicListService } from '../DynamicListing/DynamicListService';
// import { AccountTransformer } from './Account.transformer';
// import { TransformerInjectable } from '../Transformer/TransformerInjectable.service';
// import { AccountModel } from './models/Account.model';
// import { Account } from './models/Account.model';
// import { AccountRepository } from './repositories/Account.repository';
// @Injectable()
@@ -16,7 +16,7 @@
// constructor(
// private readonly dynamicListService: DynamicListService,
// private readonly transformerService: TransformerInjectable,
// private readonly accountModel: typeof AccountModel,
// private readonly accountModel: typeof Account,
// private readonly accountRepository: AccountRepository,
// ) {}

View File

@@ -18,13 +18,13 @@ import { Model } from 'objection';
// import { flatToNestedArray } from 'utils';
// @ts-expect-error
// export class AccountModel extends mixin(TenantModel, [
// export class Account extends mixin(TenantModel, [
// ModelSettings,
// CustomViewBaseModel,
// SearchableModel,
// ]) {
export class AccountModel extends TenantModel {
export class Account extends TenantModel {
name: string;
slug: string;
code: string;
@@ -126,7 +126,7 @@ export class AccountModel extends TenantModel {
* Model modifiers.
*/
static get modifiers() {
const TABLE_NAME = AccountModel.tableName;
const TABLE_NAME = Account.tableName;
return {
/**

View File

@@ -2,7 +2,7 @@ import { Knex } from 'knex';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { TenantRepository } from '@/common/repository/TenantRepository';
import { TENANCY_DB_CONNECTION } from '@/modules/Tenancy/TenancyDB/TenancyDB.constants';
import { AccountModel } from '../models/Account.model';
import { Account } from '../models/Account.model';
// import { TenantMetadata } from '@/modules/System/models/TenantMetadataModel';
// import { IAccount } from '../Accounts.types';
// import {
@@ -20,8 +20,8 @@ export class AccountRepository extends TenantRepository {
/**
* Gets the repository's model.
*/
get model(): typeof AccountModel {
return AccountModel.bindKnex(this.tenantDBKnex);
get model(): typeof Account {
return Account.bindKnex(this.tenantDBKnex);
}
/**

View File

@@ -32,6 +32,7 @@ import { TenancyGlobalMiddleware } from '../Tenancy/TenancyGlobal.middleware';
import { TransformerInjectable } from '../Transformer/TransformerInjectable.service';
import { TransformerModule } from '../Transformer/Transformer.module';
import { AccountsModule } from '../Accounts/Accounts.module';
import { ExpensesModule } from '../Expenses/Expenses.module';
@Module({
imports: [
@@ -86,7 +87,8 @@ import { AccountsModule } from '../Accounts/Accounts.module';
TenancyDatabaseModule,
TenancyModelsModule,
ItemsModule,
AccountsModule
AccountsModule,
ExpensesModule,
],
controllers: [AppController],
providers: [

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

View File

@@ -0,0 +1,29 @@
// import { Inject, Service } from 'typedi';
// import { Exportable } from '../Export/Exportable';
// import { IAccountsFilter, IAccountsStructureType } from '@/interfaces';
// import ItemCategoriesService from './ItemCategoriesService';
// @Service()
// export class ItemCategoriesExportable extends Exportable {
// @Inject()
// private itemCategoriesApplication: ItemCategoriesService;
// /**
// * Retrieves the accounts data to exportable sheet.
// * @param {number} tenantId
// * @returns
// */
// public exportable(tenantId: number, query: IAccountsFilter) {
// const parsedQuery = {
// sortOrder: 'desc',
// columnSortBy: 'created_at',
// inactiveMode: false,
// ...query,
// structure: IAccountsStructureType.Flat,
// } as IAccountsFilter;
// return this.itemCategoriesApplication
// .getItemCategoriesList(tenantId, parsedQuery, {})
// .then((output) => output.itemCategories);
// }
// }

View File

@@ -0,0 +1,38 @@
// import { Inject, Service } from 'typedi';
// import ItemCategoriesService from './ItemCategoriesService';
// import { Importable } from '../Import/Importable';
// import { Knex } from 'knex';
// import { IItemCategoryOTD } from '@/interfaces';
// import { ItemCategoriesSampleData } from './constants';
// @Service()
// export class ItemCategoriesImportable extends Importable {
// @Inject()
// private itemCategoriesService: ItemCategoriesService;
// /**
// * Importing to create new item category service.
// * @param {number} tenantId
// * @param {any} createDTO
// * @param {Knex.Transaction} trx
// */
// public async importable(
// tenantId: number,
// createDTO: IItemCategoryOTD,
// trx?: Knex.Transaction
// ) {
// await this.itemCategoriesService.newItemCategory(
// tenantId,
// createDTO,
// {},
// trx
// );
// }
// /**
// * Item categories sample data used to download sample sheet file.
// */
// public sampleData(): any[] {
// return ItemCategoriesSampleData;
// }
// }

View File

@@ -0,0 +1,64 @@
import { IItemCategoryOTD } from './ItemCategory.interfaces';
import { CreateItemCategoryService } from './commands/CreateItemCategory.service';
import { DeleteItemCategoryService } from './commands/DeleteItemCategory.service';
import { EditItemCategoryService } from './commands/EditItemCategory.service';
import { GetItemCategoryService } from './queries/GetItemCategory.service';
export class ItemCategoryApplication {
constructor(
private readonly createItemCategoryService: CreateItemCategoryService,
private readonly editItemCategoryService: EditItemCategoryService,
private readonly getItemCategoryService: GetItemCategoryService,
private readonly deleteItemCategoryService: DeleteItemCategoryService,
) {}
/**
* Creates a new item category.
* @param {number} tenantId - The tenant id.
* @param {IItemCategoryOTD} itemCategoryDTO - The item category data.
* @returns {Promise<ItemCategory>} The created item category.
*/
public createItemCategory(
tenantId: number,
itemCategoryDTO: IItemCategoryOTD,
) {
return this.createItemCategoryService.newItemCategory(
tenantId,
itemCategoryDTO,
);
}
/**
* Updates an existing item category.
* @param {number} itemCategoryId - The item category id to update.
* @param {IItemCategoryOTD} itemCategoryDTO - The updated item category data.
* @returns {Promise<ItemCategory>} The updated item category.
*/
public editItemCategory(
itemCategoryId: number,
itemCategoryDTO: IItemCategoryOTD,
) {
return this.editItemCategoryService.editItemCategory(
itemCategoryId,
itemCategoryDTO,
);
}
/**
* Retrieves an item category by id.
* @param {number} itemCategoryId - The item category id to retrieve.
* @returns {Promise<ItemCategory>} The requested item category.
*/
public getItemCategory(itemCategoryId: number) {
return this.getItemCategoryService.getItemCategory(itemCategoryId);
}
/**
* Deletes an item category.
* @param {number} itemCategoryId - The item category id to delete.
* @returns {Promise<void>}
*/
public deleteItemCategory(itemCategoryId: number) {
return this.deleteItemCategoryService.deleteItemCategory(itemCategoryId);
}
}

View File

@@ -0,0 +1,47 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
} from '@nestjs/common';
import { ItemCategoryApplication } from './ItemCategory.application';
import { IItemCategoryOTD } from './ItemCategory.interfaces';
@Controller('item-categories')
export class ItemCategoryController {
constructor(
private readonly itemCategoryApplication: ItemCategoryApplication,
) {}
@Post()
async createItemCategory(
@Body('tenantId') tenantId: number,
@Body() itemCategoryDTO: IItemCategoryOTD,
) {
return this.itemCategoryApplication.createItemCategory(
tenantId,
itemCategoryDTO,
);
}
@Put(':id')
async editItemCategory(
@Param('id') id: number,
@Body() itemCategoryDTO: IItemCategoryOTD,
) {
return this.itemCategoryApplication.editItemCategory(id, itemCategoryDTO);
}
@Get(':id')
async getItemCategory(@Param('id') id: number) {
return this.itemCategoryApplication.getItemCategory(id);
}
@Delete(':id')
async deleteItemCategory(@Param('id') id: number) {
return this.itemCategoryApplication.deleteItemCategory(id);
}
}

View File

@@ -0,0 +1,37 @@
import { Knex } from 'knex';
// import { IDynamicListFilterDTO } from './DynamicFilter';
// import { ISystemUser } from './User';
import { ItemCategory } from './models/ItemCategory.model';
export interface IItemCategoryOTD {
name: string;
description?: string;
userId: number;
costAccountId?: number;
sellAccountId?: number;
inventoryAccountId?: number;
costMethod?: string;
}
// export interface IItemCategoriesFilter extends IDynamicListFilterDTO {
// stringifiedFilterRoles?: string;
// }
export interface IItemCategoryCreatedPayload {
itemCategory: ItemCategory;
trx: Knex.Transaction;
}
export interface IItemCategoryEditedPayload {
oldItemCategory: ItemCategory;
trx: Knex.Transaction;
}
export interface IItemCategoryDeletedPayload {
itemCategoryId: number;
oldItemCategory: ItemCategory;
}

View File

@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { TenancyDatabaseModule } from '../Tenancy/TenancyDB/TenancyDB.module';
import { CreateItemCategoryService } from './commands/CreateItemCategory.service';
import { DeleteItemCategoryService } from './commands/DeleteItemCategory.service';
import { EditItemCategoryService } from './commands/EditItemCategory.service';
import { GetItemCategoryService } from './queries/GetItemCategory.service';
import { ItemCategoryApplication } from './ItemCategory.application';
import { ItemCategoryController } from './ItemCategory.controller';
@Module({
imports: [TenancyDatabaseModule],
controllers: [ItemCategoryController],
providers: [
CreateItemCategoryService,
EditItemCategoryService,
GetItemCategoryService,
DeleteItemCategoryService,
ItemCategoryApplication,
],
})
export class ItemCategoryModule {}

View File

@@ -0,0 +1,93 @@
import { Account } from '@/modules/Accounts/models/Account.model';
import { ItemCategory } from '../models/ItemCategory.model';
import { Inject } from '@nestjs/common';
import { ServiceError } from '@/modules/Items/ServiceError';
import { ERRORS } from '../constants';
import { ACCOUNT_ROOT_TYPE, ACCOUNT_TYPE } from '@/constants/accounts';
export class CommandItemCategoryValidatorService {
constructor(
@Inject(ItemCategory.name)
private readonly itemCategoryModel: typeof ItemCategory,
@Inject(Account.name)
private readonly accountModel: typeof Account,
) {}
/**
* Validates the category name uniquiness.
* @param {string} categoryName - Category name.
* @param {number} notAccountId - Ignore the account id.
*/
public async validateCategoryNameUniquiness(
categoryName: string,
notCategoryId?: number,
) {
const foundItemCategory = await this.itemCategoryModel
.query()
.findOne('name', categoryName)
.onBuild((query) => {
if (notCategoryId) {
query.whereNot('id', notCategoryId);
}
});
if (foundItemCategory) {
throw new ServiceError(
ERRORS.CATEGORY_NAME_EXISTS,
'The item category name is already exist.',
);
}
}
/**
* Validates sell account existance and type.
* @param {number} sellAccountId - Sell account id.
* @return {Promise<void>}
*/
public async validateSellAccount(sellAccountId: number) {
const foundAccount = await this.accountModel
.query()
.findById(sellAccountId);
if (!foundAccount) {
throw new ServiceError(ERRORS.SELL_ACCOUNT_NOT_FOUND);
} else if (!foundAccount.isRootType(ACCOUNT_ROOT_TYPE.INCOME)) {
throw new ServiceError(ERRORS.SELL_ACCOUNT_NOT_INCOME);
}
}
/**
* Validates COGS account existance and type.
* @param {number} costAccountId -
* @return {Promise<void>}
*/
public async validateCostAccount(costAccountId: number) {
const foundAccount = await this.accountModel
.query()
.findById(costAccountId);
if (!foundAccount) {
throw new ServiceError(ERRORS.COST_ACCOUNT_NOT_FOUMD);
} else if (!foundAccount.isRootType(ACCOUNT_ROOT_TYPE.EXPENSE)) {
throw new ServiceError(ERRORS.COST_ACCOUNT_NOT_COGS);
}
}
/**
* Validates inventory account existance and type.
* @param {number} inventoryAccountId
* @return {Promise<void>}
*/
public async validateInventoryAccount(inventoryAccountId: number) {
const foundAccount = await this.accountModel
.query()
.findById(inventoryAccountId);
if (!foundAccount) {
throw new ServiceError(ERRORS.INVENTORY_ACCOUNT_NOT_FOUND);
} else if (!foundAccount.isAccountType(ACCOUNT_TYPE.INVENTORY)) {
throw new ServiceError(ERRORS.INVENTORY_ACCOUNT_NOT_INVENTORY);
}
}
}

View File

@@ -0,0 +1,81 @@
import { Injectable, Inject } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Knex } from 'knex';
import {
IItemCategoryOTD,
IItemCategoryCreatedPayload,
} from '../ItemCategory.interfaces';
import { events } from '@/common/events/events';
import { CommandItemCategoryValidatorService } from './CommandItemCategoryValidator.service';
import { ItemCategory } from '../models/ItemCategory.model';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { SystemUser } from '@/modules/System/models/SystemUser';
@Injectable()
export class CreateItemCategoryService {
constructor(
private readonly uow: UnitOfWork,
private readonly validator: CommandItemCategoryValidatorService,
private readonly eventEmitter: EventEmitter2,
@Inject(ItemCategory.name)
private readonly itemCategoryModel: typeof ItemCategory,
) {}
/**
* Transforms OTD to model object.
* @param {IItemCategoryOTD} itemCategoryOTD
* @param {ISystemUser} authorizedUser
*/
private transformOTDToObject(
itemCategoryOTD: IItemCategoryOTD,
authorizedUser: SystemUser,
): ItemCategory {
return { ...itemCategoryOTD, userId: authorizedUser.id };
}
/**
* Inserts a new item category.
* @param {number} tenantId
* @param {IItemCategoryOTD} itemCategoryOTD
* @return {Promise<void>}
*/
public async newItemCategory(
tenantId: number,
itemCategoryOTD: IItemCategoryOTD,
trx?: Knex.Transaction,
): Promise<ItemCategory> {
// Validate the category name uniquiness.
await this.validator.validateCategoryNameUniquiness(itemCategoryOTD.name);
if (itemCategoryOTD.sellAccountId) {
await this.validator.validateSellAccount(itemCategoryOTD.sellAccountId);
}
if (itemCategoryOTD.costAccountId) {
await this.validator.validateCostAccount(itemCategoryOTD.costAccountId);
}
if (itemCategoryOTD.inventoryAccountId) {
await this.validator.validateInventoryAccount(
itemCategoryOTD.inventoryAccountId,
);
}
const itemCategoryObj = this.transformOTDToObject(
itemCategoryOTD,
authorizedUser,
);
// Creates item category under unit-of-work evnirement.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Inserts the item category.
const itemCategory = await this.itemCategoryModel.query(trx).insert({
...itemCategoryObj,
});
// Triggers `onItemCategoryCreated` event.
await this.eventEmitter.emitAsync(events.itemCategory.onCreated, {
itemCategory,
tenantId,
trx,
} as IItemCategoryCreatedPayload);
return itemCategory;
}, trx);
}
}

View File

@@ -0,0 +1,69 @@
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { CommandItemCategoryValidatorService } from './CommandItemCategoryValidator.service';
import { ItemCategory } from '../models/ItemCategory.model';
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
import { IItemCategoryDeletedPayload } from '../ItemCategory.interfaces';
import { Item } from '@/modules/Items/models/Item';
@Injectable()
export class DeleteItemCategoryService {
constructor(
private readonly uow: UnitOfWork,
private readonly validator: CommandItemCategoryValidatorService,
private readonly eventEmitter: EventEmitter2,
@Inject(ItemCategory.name)
private readonly itemCategoryModel: typeof ItemCategory,
@Inject(Item.name)
private readonly itemModel: typeof Item,
) {}
/**
* Deletes the given item category.
* @param {number} tenantId - Tenant id.
* @param {number} itemCategoryId - Item category id.
* @return {Promise<void>}
*/
public async deleteItemCategory(itemCategoryId: number) {
// Retrieve item category or throw not found error.
const oldItemCategory = await this.itemCategoryModel
.query()
.findById(itemCategoryId)
.throwIfNotFound();
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Unassociate items with item category.
await this.unassociateItemsWithCategories(itemCategoryId, trx);
// Delete item category.
await ItemCategory.query(trx).findById(itemCategoryId).delete();
// Triggers `onItemCategoryDeleted` event.
await this.eventEmitter.emitAsync(events.itemCategory.onDeleted, {
itemCategoryId,
oldItemCategory,
} as IItemCategoryDeletedPayload);
});
}
/**
* Unlink items relations with item categories.
* @param {number|number[]} itemCategoryId -
* @return {Promise<void>}
*/
private async unassociateItemsWithCategories(
itemCategoryId: number | number[],
trx?: Knex.Transaction,
): Promise<void> {
const ids = Array.isArray(itemCategoryId)
? itemCategoryId
: [itemCategoryId];
await this.itemModel.query(trx)
.whereIn('category_id', ids)
.patch({ categoryId: null });
}
}

View File

@@ -0,0 +1,87 @@
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { CommandItemCategoryValidatorService } from './CommandItemCategoryValidator.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
import {
IItemCategoryEditedPayload,
IItemCategoryOTD,
} from '../ItemCategory.interfaces';
import { SystemUser } from '@/modules/System/models/SystemUser';
import { Knex } from 'knex';
import { ItemCategory } from '../models/ItemCategory.model';
import { Inject } from '@nestjs/common';
export class EditItemCategoryService {
constructor(
private readonly uow: UnitOfWork,
private readonly validator: CommandItemCategoryValidatorService,
private readonly eventEmitter: EventEmitter2,
@Inject(ItemCategory.name)
private readonly itemCategoryModel: typeof ItemCategory,
) {}
/**
* Edits item category.
* @param {number} tenantId
* @param {number} itemCategoryId
* @param {IItemCategoryOTD} itemCategoryOTD
* @return {Promise<void>}
*/
public async editItemCategory(
itemCategoryId: number,
itemCategoryOTD: IItemCategoryOTD,
): Promise<IItemCategory> {
// Retrieve the item category from the storage.
const oldItemCategory = await this.itemCategoryModel
.query()
.findById(itemCategoryId)
.throwIfNotFound();
// Validate the category name whether unique on the storage.
await this.validator.validateCategoryNameUniquiness(
itemCategoryOTD.name,
itemCategoryId,
);
if (itemCategoryOTD.sellAccountId) {
await this.validator.validateSellAccount(itemCategoryOTD.sellAccountId);
}
if (itemCategoryOTD.costAccountId) {
await this.validator.validateCostAccount(itemCategoryOTD.costAccountId);
}
if (itemCategoryOTD.inventoryAccountId) {
await this.validator.validateInventoryAccount(
itemCategoryOTD.inventoryAccountId,
);
}
const itemCategoryObj = this.transformOTDToObject(
itemCategoryOTD,
authorizedUser,
);
//
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
//
const itemCategory = await ItemCategory.query().patchAndFetchById(
itemCategoryId,
{ ...itemCategoryObj },
);
// Triggers `onItemCategoryEdited` event.
await this.eventEmitter.emitAsync(events.itemCategory.onEdited, {
oldItemCategory,
trx,
} as IItemCategoryEditedPayload);
return itemCategory;
});
}
/**
* Transforms OTD to model object.
* @param {IItemCategoryOTD} itemCategoryOTD
* @param {ISystemUser} authorizedUser
*/
private transformOTDToObject(
itemCategoryOTD: IItemCategoryOTD,
authorizedUser: SystemUser,
) {
return { ...itemCategoryOTD, userId: authorizedUser.id };
}
}

View File

@@ -0,0 +1,35 @@
// eslint-disable-next-line import/prefer-default-export
export const ERRORS = {
ITEM_CATEGORIES_NOT_FOUND: 'ITEM_CATEGORIES_NOT_FOUND',
CATEGORY_NAME_EXISTS: 'CATEGORY_NAME_EXISTS',
CATEGORY_NOT_FOUND: 'CATEGORY_NOT_FOUND',
COST_ACCOUNT_NOT_FOUMD: 'COST_ACCOUNT_NOT_FOUMD',
COST_ACCOUNT_NOT_COGS: 'COST_ACCOUNT_NOT_COGS',
SELL_ACCOUNT_NOT_INCOME: 'SELL_ACCOUNT_NOT_INCOME',
SELL_ACCOUNT_NOT_FOUND: 'SELL_ACCOUNT_NOT_FOUND',
INVENTORY_ACCOUNT_NOT_FOUND: 'INVENTORY_ACCOUNT_NOT_FOUND',
INVENTORY_ACCOUNT_NOT_INVENTORY: 'INVENTORY_ACCOUNT_NOT_INVENTORY',
CATEGORY_HAVE_ITEMS: 'CATEGORY_HAVE_ITEMS',
};
export const ItemCategoriesSampleData = [
{
Name: 'Kassulke Group',
Description: 'Optio itaque eaque qui adipisci illo sed.',
},
{
Name: 'Crist, Mraz and Lueilwitz',
Description:
'Dolores veniam deserunt sed commodi error quia veritatis non.',
},
{
Name: 'Gutmann and Sons',
Description:
'Ratione aperiam voluptas rem adipisci assumenda eos neque veritatis tempora.',
},
{
Name: 'Reichel - Raynor',
Description:
'Necessitatibus repellendus placeat possimus dolores excepturi ut.',
},
];

View File

@@ -0,0 +1,63 @@
import { BaseModel } from '@/models/Model';
import { Model, mixin } from 'objection';
// import TenantModel from 'models/TenantModel';
// import ModelSetting from './ModelSetting';
// import ItemCategorySettings from './ItemCategory.Settings';
export class ItemCategory extends BaseModel {
/**
* Table name.
*/
static get tableName() {
return 'items_categories';
}
/**
* Timestamps columns.
*/
get timestamps() {
return ['createdAt', 'updatedAt'];
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const { Item } = require('../../Items/models/Item');
return {
/**
* Item category may has many items.
*/
items: {
relation: Model.HasManyRelation,
modelClass: Item,
join: {
from: 'items_categories.id',
to: 'items.categoryId',
},
},
};
}
/**
* Model modifiers.
*/
static get modifiers() {
return {
/**
* Inactive/Active mode.
*/
sortByCount(query, order = 'asc') {
query.orderBy('count', order);
},
};
}
/**
* Model meta.
*/
// static get meta() {
// return ItemCategorySettings;
// }
}

View File

@@ -0,0 +1,24 @@
import { Inject, Injectable } from '@nestjs/common';
import { ItemCategory } from '../models/ItemCategory.model';
@Injectable()
export class GetItemCategoryService {
constructor(
@Inject(ItemCategory.name)
private readonly itemCategoryModel: typeof ItemCategory,
) {}
/**
* Retrieves item category by id.
* @param {number} itemCategoryId
* @returns {Promise<IItemCategory>}
*/
public async getItemCategory(itemCategoryId: number) {
const itemCategory = await this.itemCategoryModel
.query()
.findById(itemCategoryId)
.throwIfNotFound();
return itemCategory;
}
}

View File

@@ -8,13 +8,13 @@ import { ServiceError } from './ServiceError';
import { IItem, IItemDTO } from '@/interfaces/Item';
import { ERRORS } from './Items.constants';
import { Item } from './models/Item';
import { AccountModel } from '../Accounts/models/Account.model';
import { Account } from '../Accounts/models/Account.model';
@Injectable()
export class ItemsValidators {
constructor(
@Inject(Item.name) private itemModel: typeof Item,
@Inject(AccountModel.name) private accountModel: typeof AccountModel,
@Inject(Account.name) private accountModel: typeof Account,
@Inject(Item.name) private taxRateModel: typeof Item,
@Inject(Item.name) private itemEntryModel: typeof Item,
@Inject(Item.name) private itemCategoryModel: typeof Item,

View File

@@ -3,11 +3,11 @@ import { Global, Module, Scope } from '@nestjs/common';
import { TENANCY_DB_CONNECTION } from '../TenancyDB/TenancyDB.constants';
import { Item } from '../../../modules/Items/models/Item';
import { AccountModel } from '@/modules/Accounts/models/Account.model';
import { Account } from '@/modules/Accounts/models/Account.model';
import { ItemEntry } from '@/modules/Items/models/ItemEntry';
import { AccountTransaction } from '@/modules/Accounts/models/AccountTransaction.model';
const models = [Item, AccountModel, ItemEntry, AccountTransaction];
const models = [Item, Account, ItemEntry, AccountTransaction];
const modelProviders = models.map((model) => {
return {

View File

@@ -165,7 +165,7 @@ export class Transformer {
* @param {string} format
* @returns {string}
*/
protected formatDate(date: string, format?: string) {
protected formatDate(date: string | Date, format?: string) {
// Use the export date format if the async operation is in exporting,
// otherwise use the given or default format.
const _format = this.context.exportAls.isExport