Compare commits

...

20 Commits

Author SHA1 Message Date
Ahmed Bouhuolia
1130975efd refactor(nestjs): landed cost 2025-06-10 17:08:32 +02:00
Ahmed Bouhuolia
fa180b3ac5 refactor: gl entries 2025-06-10 12:29:46 +02:00
Ahmed Bouhuolia
90d6bea9b9 fix: mail state 2025-06-09 15:37:20 +02:00
Ahmed Bouhuolia
4366bf478a refactor: mail templates 2025-06-08 16:49:03 +02:00
Ahmed Bouhuolia
0a57b6e20e fix: cashflow statement localization 2025-06-06 20:40:56 +02:00
Ahmed Bouhuolia
9a685ffe5d refactor: financial reports query dtos 2025-06-06 00:11:51 +02:00
Ahmed Bouhuolia
51988dba3b refactor(nestjs): bank transactions matching 2025-06-05 14:41:26 +02:00
Ahmed Bouhuolia
f87bd341e9 refactor(nestjs): banking modules 2025-06-03 21:42:09 +02:00
Ahmed Bouhuolia
5595478e19 refactor(nestjs): banking module 2025-06-02 21:32:53 +02:00
Ahmed Bouhuolia
7247b52fe5 refactor(nestjs): banking module 2025-06-02 15:41:41 +02:00
Ahmed Bouhuolia
deadd5ac80 refactor(nestjs): plaid banking syncing 2025-06-01 18:38:44 +02:00
Ahmed Bouhuolia
66a2261e50 refactor(nestjs): wip 2025-05-28 21:32:48 +02:00
Ahmed Bouhuolia
c51347d3ec refactor(nestjs): wip import module 2025-05-28 17:01:46 +02:00
Ahmed Bouhuolia
b7a3c42074 refactor(nestjs): wip 2025-05-27 15:42:27 +02:00
Ahmed Bouhuolia
83c9392b74 refactor(nestjs): wip dtos validation schema 2025-05-26 17:04:53 +02:00
Ahmed Bouhuolia
24bf3dd06d refactor(nestjs): validation schema dtos 2025-05-25 23:39:54 +02:00
Ahmed Bouhuolia
2b3f98d8fe refactor(nestjs): hook the new endpoints 2025-05-22 19:55:55 +02:00
Ahmed Bouhuolia
4e64a9eadb refactor(nestjs): pdf templates 2025-05-22 13:36:10 +02:00
Ahmed Bouhuolia
0823bfc4e9 refactor(nestjs): contacts module 2025-05-20 23:55:39 +02:00
Ahmed Bouhuolia
99fe5a6b0d refactor(nestjs): Implement users module 2025-05-20 17:55:58 +02:00
381 changed files with 9759 additions and 1656 deletions

View File

@@ -0,0 +1,7 @@
import { registerAs } from '@nestjs/config';
export default registerAs('bankfeed', () => ({
enabled:
process.env.BANK_FEED_ENABLED === 'true' ||
process.env.BANK_FEED_ENABLED === 'yes',
}));

View File

@@ -13,6 +13,7 @@ import signupRestrictions from './signup-restrictions';
import jwt from './jwt';
import mail from './mail';
import loops from './loops';
import bankfeed from './bankfeed';
export const config = [
systemDatabase,
@@ -29,5 +30,6 @@ export const config = [
signupRestrictions,
jwt,
mail,
loops
loops,
bankfeed,
];

View File

@@ -0,0 +1,57 @@
/**
* Map to store all models that have been marked to prevent base currency mutation.
* Key is the model name, value is the model class.
*/
export const preventMutateBaseCurrencyModels = new Map<string, any>();
/**
* Decorator that marks an ORM model to prevent base currency mutation.
* When applied to a model class, it adds a static property `preventMutateBaseCurrency` set to true
* and registers the model in the preventMutateBaseCurrencyModels map.
*
* @returns {ClassDecorator} A decorator function that can be applied to a class.
*/
export function PreventMutateBaseCurrency(): ClassDecorator {
return (target: any) => {
// Set the static property on the model class
target.preventMutateBaseCurrency = true;
// Register the model in the map
const modelName = target.name;
preventMutateBaseCurrencyModels.set(modelName, target);
// Return the modified class
return target;
};
}
/**
* Get all registered models that prevent base currency mutation.
*
* @returns {Map<string, any>} Map of model names to model classes
*/
export function getPreventMutateBaseCurrencyModels(): Map<string, any> {
return preventMutateBaseCurrencyModels;
}
/**
* Check if a model is registered to prevent base currency mutation.
*
* @param {string} modelName - The name of the model to check
* @returns {boolean} True if the model is registered, false otherwise
*/
export function isModelPreventMutateBaseCurrency(modelName: string): boolean {
return preventMutateBaseCurrencyModels.has(modelName);
}
/**
* Get a specific model by name that prevents base currency mutation.
*
* @param {string} modelName - The name of the model to retrieve
* @returns {any | undefined} The model class if found, undefined otherwise
*/
export function getPreventMutateBaseCurrencyModel(
modelName: string,
): any | undefined {
return preventMutateBaseCurrencyModels.get(modelName);
}

View File

@@ -0,0 +1,32 @@
import { Transform } from 'class-transformer';
import { ValidateIf, ValidationOptions } from 'class-validator';
/**
* Decorator that converts the property value to a number.
* @returns PropertyDecorator
*/
export function ToNumber() {
return Transform(({ value, key }) => {
const defaultValue = null;
if (typeof value === 'number') {
return value;
}
// If value is an empty string or undefined/null, return it as-is (wont pass validation)
if (value === '' || value === null || value === undefined) {
return defaultValue;
}
const parsed = Number(value);
return !isNaN(parsed) ? parsed : value;
});
}
/**
* Validates if the property is not empty.
* @returns PropertyDecorator
*/
export function IsOptional(validationOptions?: ValidationOptions) {
return ValidateIf((_obj, value) => {
return value !== null && value !== undefined && value !== '';
}, validationOptions);
}

View File

@@ -70,7 +70,10 @@ export class SerializeInterceptor implements NestInterceptor<any, any> {
next: CallHandler<any>,
): Observable<any> {
const request = context.switchToHttp().getRequest();
// Transform both body and query parameters
request.body = this.strategy.in(request.body);
request.query = this.strategy.in(request.query);
// handle returns stream..
return next.handle().pipe(map(this.strategy.out));

View File

@@ -14,12 +14,15 @@ export class ValidationPipe implements PipeTransform<any> {
return value;
}
const object = plainToInstance(metatype, value);
const errors = await validate(object);
const errors = await validate(object, {
// Strip validated object of any properties that do not have any decorators.
whitelist: true,
});
if (errors.length > 0) {
throw new BadRequestException(errors);
}
return value;
return object;
}
private toValidate(metatype: Function): boolean {

View File

@@ -1,9 +1,9 @@
{
"previoud_period_date": "{{date}} (PP)",
"previoud_period_date": "{date} (PP)",
"fianncial_sheet.previous_period_change": "Change (PP)",
"previous_period_percentage": "% Change (PP)",
"previous_year_date": "{{date}} (PY)",
"previous_year_date": "{date} (PY)",
"previous_year_change": "Change (PY)",
"previous_year_percentage": "% Change (PY)",
"total_row": "Total {{value}}"
"total_row": "Total {value}"
}

View File

@@ -0,0 +1,4 @@
{
"decrement": "Decrement",
"increment": "Increment"
}

View File

@@ -0,0 +1,4 @@
{
"primary_warehouse": "Primary Warehouse"
}

View File

@@ -1,4 +1,4 @@
import FormData from 'form-data';
import * as FormData from 'form-data';
import { GotenbergUtils } from './GotenbergUtils';
import { PageProperties } from './_types';

View File

@@ -1,5 +1,5 @@
import FormData from 'form-data';
import Axios from 'axios';
import * as FormData from 'form-data';
import { Axios } from 'axios';
export class GotenbergUtils {
public static assert(condition: boolean, message: string): asserts condition {
@@ -10,12 +10,12 @@ export class GotenbergUtils {
public static async fetch(endpoint: string, data: FormData): Promise<Buffer> {
try {
const response = await Axios.post(endpoint, data, {
const response = await new Axios({
headers: {
...data.getHeaders(),
},
responseType: 'arraybuffer', // This ensures you get a Buffer bac
});
}).post(endpoint, data);
return response.data;
} catch (error) {
console.error(error);

View File

@@ -1,5 +1,5 @@
import { constants, createReadStream, PathLike, promises } from 'fs';
import FormData from 'form-data';
import * as FormData from 'form-data';
import { GotenbergUtils } from './GotenbergUtils';
import { IConverter, PageProperties } from './_types';
import { PdfFormat, ChromiumRoute } from './_types';

View File

@@ -1,4 +1,4 @@
import FormData from 'form-data';
import * as FormData from 'form-data';
import { IConverter, PageProperties, PdfFormat, ChromiumRoute } from './_types';
import { ConverterUtils } from './ConvertUtils';
import { Converter } from './Converter';

View File

@@ -1,22 +1,19 @@
// import { Inject, Service } from 'typedi';
// import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Inject, Injectable } from '@nestjs/common';
import { Account } from './models/Account.model';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
// @Service()
// export class MutateBaseCurrencyAccounts {
// @Inject()
// tenancy: HasTenancyService;
@Injectable()
export class MutateBaseCurrencyAccounts {
constructor(
@Inject(Account.name)
private readonly accountModel: TenantModelProxy<typeof Account>,
) {}
// /**
// * Mutates the all accounts or the organziation.
// * @param {number} tenantId
// * @param {string} currencyCode
// */
// public mutateAllAccountsCurrency = async (
// tenantId: number,
// currencyCode: string
// ) => {
// const { Account } = this.tenancy.models(tenantId);
// await Account.query().update({ currencyCode });
// };
// }
/**
* Mutates the all accounts or the organziation.
* @param {string} currencyCode
*/
async mutateAllAccountsCurrency(currencyCode: string) {
await this.accountModel().query().update({ currencyCode });
}
}

View File

@@ -0,0 +1,24 @@
import { OnEvent } from '@nestjs/event-emitter';
import { Injectable } from '@nestjs/common';
import { events } from '@/common/events/events';
import { MutateBaseCurrencyAccounts } from '../MutateBaseCurrencyAccounts';
@Injectable()
export class MutateBaseCurrencyAccountsSubscriber {
constructor(
public readonly mutateBaseCurrencyAccounts: MutateBaseCurrencyAccounts,
) {}
/**
* Updates the all accounts currency once the base currency
* of the organization is mutated.
*/
@OnEvent(events.organization.baseCurrencyUpdated)
async updateAccountsCurrencyOnBaseCurrencyMutated({
organizationDTO,
}) {
await this.mutateBaseCurrencyAccounts.mutateAllAccountsCurrency(
organizationDTO.baseCurrency
);
};
}

View File

@@ -1,34 +0,0 @@
// import { Service, Inject } from 'typedi';
// import events from '@/subscribers/events';
// import { MutateBaseCurrencyAccounts } from '../MutateBaseCurrencyAccounts';
// @Service()
// export class MutateBaseCurrencyAccountsSubscriber {
// @Inject()
// public mutateBaseCurrencyAccounts: MutateBaseCurrencyAccounts;
// /**
// * Attaches the events with handles.
// * @param bus
// */
// attach(bus) {
// bus.subscribe(
// events.organization.baseCurrencyUpdated,
// this.updateAccountsCurrencyOnBaseCurrencyMutated
// );
// }
// /**
// * Updates the all accounts currency once the base currency
// * of the organization is mutated.
// */
// private updateAccountsCurrencyOnBaseCurrencyMutated = async ({
// tenantId,
// organizationDTO,
// }) => {
// await this.mutateBaseCurrencyAccounts.mutateAllAccountsCurrency(
// tenantId,
// organizationDTO.baseCurrency
// );
// };
// }

View File

@@ -86,6 +86,12 @@ import { CreditNotesApplyInvoiceModule } from '../CreditNotesApplyInvoice/Credit
import { ResourceModule } from '../Resource/Resource.module';
import { ViewsModule } from '../Views/Views.module';
import { CurrenciesModule } from '../Currencies/Currencies.module';
import { MiscellaneousModule } from '../Miscellaneous/Miscellaneous.module';
import { UsersModule } from '../UsersModule/Users.module';
import { ContactsModule } from '../Contacts/Contacts.module';
import { BankingPlaidModule } from '../BankingPlaid/BankingPlaid.module';
import { BankingCategorizeModule } from '../BankingCategorize/BankingCategorize.module';
import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module';
@Module({
imports: [
@@ -147,6 +153,7 @@ import { CurrenciesModule } from '../Currencies/Currencies.module';
ScheduleModule.forRoot(),
TenancyDatabaseModule,
TenancyModelsModule,
TenantModelsInitializeModule,
AuthModule,
TenancyModule,
ChromiumlyTenancyModule,
@@ -179,10 +186,12 @@ import { CurrenciesModule } from '../Currencies/Currencies.module';
LedgerModule,
BankAccountsModule,
BankRulesModule,
BankingTransactionsModule,
BankingTransactionsExcludeModule,
BankingTransactionsRegonizeModule,
BankingTransactionsModule,
BankingMatchingModule,
BankingPlaidModule,
BankingCategorizeModule,
TransactionsLockingModule,
SettingsModule,
FeaturesModule,
@@ -206,7 +215,10 @@ import { CurrenciesModule } from '../Currencies/Currencies.module';
ImportModule,
ResourceModule,
ViewsModule,
CurrenciesModule
CurrenciesModule,
MiscellaneousModule,
UsersModule,
ContactsModule
],
controllers: [AppController],
providers: [

View File

@@ -33,6 +33,7 @@ const models = [
@Module({
imports: [S3Module, ...models],
exports: [...models],
controllers: [AttachmentsController],
providers: [
DeleteAttachment,

View File

@@ -1,4 +1,4 @@
import path from 'path';
import * as path from 'path';
// import config from '@/config';
export const getUploadedObjectUri = (objectKey: string) => {

View File

@@ -23,13 +23,13 @@ export class EditBankRuleService {
) {}
/**
*
* @param createDTO
* Transforms the given edit bank rule dto to model object.
* @param editDTO
* @returns
*/
private transformDTO(createDTO: EditBankRuleDto): ModelObject<BankRule> {
private transformDTO(editDTO: EditBankRuleDto): ModelObject<BankRule> {
return {
...createDTO,
...editDTO,
} as ModelObject<BankRule>;
}

View File

@@ -12,6 +12,7 @@ import {
} from 'class-validator';
import { BankRuleComparator } from '../types';
import { ApiProperty } from '@nestjs/swagger';
import { ToNumber } from '@/common/decorators/Validators';
class BankRuleConditionDto {
@IsNotEmpty()
@@ -44,6 +45,8 @@ export class CommandBankRuleDto {
})
name: string;
@IsNotEmpty()
@ToNumber()
@IsInt()
@Min(0)
@ApiProperty({
@@ -53,6 +56,7 @@ export class CommandBankRuleDto {
order: number;
@IsOptional()
@ToNumber()
@IsInt()
@Min(0)
@ApiProperty({
@@ -61,6 +65,7 @@ export class CommandBankRuleDto {
})
applyIfAccountId?: number;
@IsNotEmpty()
@IsIn(['deposit', 'withdrawal'])
@ApiProperty({
description: 'The transaction type to apply the rule if',
@@ -82,11 +87,14 @@ export class CommandBankRuleDto {
@Type(() => BankRuleConditionDto)
@ApiProperty({
description: 'The conditions to apply the rule if',
example: [{ field: 'description', comparator: 'contains', value: 'Salary' }],
example: [
{ field: 'description', comparator: 'contains', value: 'Salary' },
],
})
conditions: BankRuleConditionDto[];
@IsString()
@IsNotEmpty()
@ApiProperty({
description: 'The category to assign the rule if',
example: 'Income:Salary',
@@ -95,6 +103,8 @@ export class CommandBankRuleDto {
@IsInt()
@Min(0)
@ToNumber()
@IsNotEmpty()
@ApiProperty({
description: 'The account ID to assign the rule if',
example: 1,

View File

@@ -15,6 +15,8 @@ import { BankingTransactionsModule } from '../BankingTransactions/BankingTransac
import { GetBankAccountsService } from './queries/GetBankAccounts';
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
import { GetBankAccountSummary } from './queries/GetBankAccountSummary';
import { MutateBaseCurrencyAccountsSubscriber } from '../Accounts/susbcribers/MutateBaseCurrencyAccounts.subscriber';
import { MutateBaseCurrencyAccounts } from '../Accounts/MutateBaseCurrencyAccounts';
@Module({
imports: [
@@ -23,7 +25,7 @@ import { GetBankAccountSummary } from './queries/GetBankAccountSummary';
BankRulesModule,
BankingTransactionsRegonizeModule,
BankingTransactionsModule,
DynamicListModule
DynamicListModule,
],
providers: [
DisconnectBankAccountService,
@@ -34,7 +36,9 @@ import { GetBankAccountSummary } from './queries/GetBankAccountSummary';
DisconnectPlaidItemOnAccountDeleted,
BankAccountsApplication,
GetBankAccountsService,
GetBankAccountSummary
GetBankAccountSummary,
MutateBaseCurrencyAccounts,
MutateBaseCurrencyAccountsSubscriber,
],
exports: [BankAccountsApplication],
controllers: [BankAccountsController],

View File

@@ -1,8 +1,13 @@
import { Inject, Injectable } from '@nestjs/common';
import { Account } from '@/modules/Accounts/models/Account.model';
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
import { BaseModel } from '@/models/Model';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '@/modules/Tenancy/TenancyDB/TenancyDB.constants';
import { initialize } from 'objection';
import { MatchedBankTransaction } from '@/modules/BankingMatching/models/MatchedBankTransaction';
import { TenantModel } from '@/modules/System/models/TenantModel';
import { RecognizedBankTransaction } from '@/modules/BankingTranasctionsRegonize/models/RecognizedBankTransaction';
@Injectable()
export class GetBankAccountSummary {
@@ -14,6 +19,19 @@ export class GetBankAccountSummary {
private readonly uncategorizedBankTransactionModel: TenantModelProxy<
typeof UncategorizedBankTransaction
>,
@Inject(MatchedBankTransaction.name)
private readonly matchedBankTransactionModel: TenantModelProxy<
typeof MatchedBankTransaction
>,
@Inject(RecognizedBankTransaction.name)
private readonly recognizedBankTransaction: TenantModelProxy<
typeof RecognizedBankTransaction
>,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantDb: () => Knex,
) {}
/**
@@ -27,6 +45,11 @@ export class GetBankAccountSummary {
.findById(bankAccountId)
.throwIfNotFound();
await initialize(this.tenantDb(), [
this.uncategorizedBankTransactionModel(),
this.matchedBankTransactionModel(),
this.recognizedBankTransaction(),
]);
const commonQuery = (q) => {
// Include just the given account.
q.where('accountId', bankAccountId);
@@ -37,11 +60,6 @@ export class GetBankAccountSummary {
// Only the not categorized.
q.modify('notCategorized');
};
interface UncategorizedTransactionsCount {
total: number;
}
// Retrieves the uncategorized transactions count of the given bank account.
const uncategorizedTranasctionsCount =
await this.uncategorizedBankTransactionModel()

View File

@@ -0,0 +1,92 @@
import { Knex } from 'knex';
import { CategorizeBankTransaction } from './commands/CategorizeBankTransaction';
import { UncategorizeBankTransactionService } from './commands/UncategorizeBankTransaction.service';
import { UncategorizeBankTransactionsBulk } from './commands/UncategorizeBankTransactionsBulk.service';
import { UncategorizedBankTransactionDto } from './dtos/CreateUncategorizedBankTransaction.dto';
import { CategorizeBankTransactionDto } from './dtos/CategorizeBankTransaction.dto';
import { CategorizeTransactionAsExpense } from './commands/CategorizeTransactionAsExpense';
import { CreateUncategorizedTransactionService } from './commands/CreateUncategorizedTransaction.service';
import { ICategorizeCashflowTransactioDTO } from './types/BankingCategorize.types';
import { Injectable } from '@nestjs/common';
@Injectable()
export class BankingCategorizeApplication {
constructor(
private readonly categorizeBankTransaction: CategorizeBankTransaction,
private readonly uncategorizeBankTransaction: UncategorizeBankTransactionService,
private readonly uncategorizeBankTransactionsBulk: UncategorizeBankTransactionsBulk,
private readonly categorizeTransactionAsExpense: CategorizeTransactionAsExpense,
private readonly createUncategorizedTransaction: CreateUncategorizedTransactionService,
) {}
/**
* Categorize a bank transaction with the given ID and categorization data.
* @param {number | Array<number>} uncategorizedTransactionId - The ID(s) of the uncategorized transaction(s) to categorize.
* @param {CategorizeBankTransactionDto} categorizeDTO - Data for categorization.
* @returns {Promise<any>} The result of the categorization operation.
*/
public categorizeTransaction(
uncategorizedTransactionId: number | Array<number>,
categorizeDTO: CategorizeBankTransactionDto,
) {
return this.categorizeBankTransaction.categorize(
uncategorizedTransactionId,
categorizeDTO,
);
}
/**
* Uncategorize a bank transaction with the given ID.
* @param {number} uncategorizedTransactionId - The ID of the transaction to uncategorize.
* @returns {Promise<Array<number>>} Array of affected transaction IDs.
*/
public uncategorizeTransaction(
uncategorizedTransactionId: number,
): Promise<Array<number>> {
return this.uncategorizeBankTransaction.uncategorize(
uncategorizedTransactionId,
);
}
/**
* Uncategorize multiple bank transactions in bulk.
* @param {number | Array<number>} uncategorizedTransactionIds - The ID(s) of the transaction(s) to uncategorize.
* @returns {Promise<void>}
*/
public uncategorizeTransactionsBulk(
uncategorizedTransactionIds: number | Array<number>,
) {
return this.uncategorizeBankTransactionsBulk.uncategorizeBulk(
uncategorizedTransactionIds,
);
}
/**
* Categorize a transaction as an expense.
* @param {number} cashflowTransactionId - The ID of the cashflow transaction to categorize.
* @param {ICategorizeCashflowTransactioDTO} transactionDTO - Data for categorization.
* @returns {Promise<any>} The result of the categorization operation.
*/
public categorizeTransactionAsExpenseType(
cashflowTransactionId: number,
transactionDTO: ICategorizeCashflowTransactioDTO,
) {
return this.categorizeTransactionAsExpense.categorize(
cashflowTransactionId,
transactionDTO,
);
}
/**
* Create a new uncategorized bank transaction.
* @param {UncategorizedBankTransactionDto} createDTO - Data for creating the uncategorized transaction.
* @param {Knex.Transaction} [trx] - Optional Knex transaction.
* @returns {Promise<any>} The created uncategorized transaction.
*/
public createUncategorizedBankTransaction(
createDTO: UncategorizedBankTransactionDto,
trx?: Knex.Transaction,
) {
return this.createUncategorizedTransaction.create(createDTO, trx);
}
}

View File

@@ -0,0 +1,56 @@
import { Body, Controller, Delete, Param, Post, Query } from '@nestjs/common';
import { castArray, omit } from 'lodash';
import { BankingCategorizeApplication } from './BankingCategorize.application';
import { CategorizeBankTransactionRouteDto } from './dtos/CategorizeBankTransaction.dto';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
@Controller('banking/categorize')
@ApiTags('banking-categorization')
export class BankingCategorizeController {
constructor(
private readonly bankingCategorizeApplication: BankingCategorizeApplication,
) {}
@Post()
@ApiOperation({ summary: 'Categorize bank transactions.' })
@ApiResponse({
status: 200,
description: 'The bank transactions have been categorized successfully.',
})
public categorizeTransaction(
@Body() body: CategorizeBankTransactionRouteDto,
) {
return this.bankingCategorizeApplication.categorizeTransaction(
castArray(body.uncategorizedTransactionIds),
omit(body, 'uncategorizedTransactionIds'),
);
}
@Delete('/bulk')
@ApiOperation({ summary: 'Uncategorize bank transactions.' })
@ApiResponse({
status: 200,
description: 'The bank transactions have been uncategorized successfully.',
})
public uncategorizeTransactionsBulk(
@Query() uncategorizedTransactionIds: number[] | number,
) {
return this.bankingCategorizeApplication.uncategorizeTransactionsBulk(
castArray(uncategorizedTransactionIds),
);
}
@Delete('/:id')
@ApiOperation({ summary: 'Uncategorize a bank transaction.' })
@ApiResponse({
status: 200,
description: 'The bank transaction has been uncategorized successfully.',
})
public uncategorizeTransaction(
@Param('id') uncategorizedTransactionId: number,
) {
return this.bankingCategorizeApplication.uncategorizeTransaction(
Number(uncategorizedTransactionId),
);
}
}

View File

@@ -1,20 +1,38 @@
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { CreateUncategorizedTransactionService } from './commands/CreateUncategorizedTransaction.service';
import { CategorizeTransactionAsExpense } from './commands/CategorizeTransactionAsExpense';
import { BankingTransactionsModule } from '../BankingTransactions/BankingTransactions.module';
import { ExpensesModule } from '../Expenses/Expenses.module';
import { UncategorizedTransactionsImportable } from './commands/UncategorizedTransactionsImportable';
import { BankingCategorizeController } from './BankingCategorize.controller';
import { BankingCategorizeApplication } from './BankingCategorize.application';
import { CategorizeBankTransaction } from './commands/CategorizeBankTransaction';
import { UncategorizeBankTransactionService } from './commands/UncategorizeBankTransaction.service';
import { UncategorizeBankTransactionsBulk } from './commands/UncategorizeBankTransactionsBulk.service';
@Module({
imports: [BankingTransactionsModule, ExpensesModule],
imports: [
BankingTransactionsModule,
ExpensesModule,
forwardRef(() => BankingTransactionsModule),
],
providers: [
CreateUncategorizedTransactionService,
CategorizeTransactionAsExpense,
UncategorizedTransactionsImportable
UncategorizedTransactionsImportable,
BankingCategorizeApplication,
CategorizeBankTransaction,
UncategorizeBankTransactionService,
UncategorizeBankTransactionsBulk,
],
exports: [
CreateUncategorizedTransactionService,
CategorizeTransactionAsExpense,
BankingCategorizeApplication,
CategorizeBankTransaction,
UncategorizeBankTransactionService,
UncategorizeBankTransactionsBulk,
],
controllers: [BankingCategorizeController],
})
export class BankingCategorizeModule {}

View File

@@ -5,7 +5,6 @@ import { Knex } from 'knex';
import {
ICashflowTransactionCategorizedPayload,
ICashflowTransactionUncategorizingPayload,
ICategorizeCashflowTransactioDTO,
} from '../types/BankingCategorize.types';
import {
transformCategorizeTransToCashflow,
@@ -17,9 +16,10 @@ import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
import { events } from '@/common/events/events';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { CategorizeBankTransactionDto } from '../dtos/CategorizeBankTransaction.dto';
@Injectable()
export class CategorizeCashflowTransaction {
export class CategorizeBankTransaction {
constructor(
private readonly eventPublisher: EventEmitter2,
private readonly uow: UnitOfWork,
@@ -38,7 +38,7 @@ export class CategorizeCashflowTransaction {
*/
public async categorize(
uncategorizedTransactionId: number | Array<number>,
categorizeDTO: ICategorizeCashflowTransactioDTO,
categorizeDTO: CategorizeBankTransactionDto,
) {
const uncategorizedTransactionIds = castArray(uncategorizedTransactionId);
@@ -68,7 +68,6 @@ export class CategorizeCashflowTransaction {
await this.eventPublisher.emitAsync(
events.cashflow.onTransactionCategorizing,
{
// tenantId,
oldUncategorizedTransactions,
trx,
} as ICashflowTransactionUncategorizingPayload,

View File

@@ -1,6 +1,5 @@
import { Knex } from 'knex';
import {
CreateUncategorizedTransactionDTO,
IUncategorizedTransactionCreatedEventPayload,
IUncategorizedTransactionCreatingEventPayload,
} from '../types/BankingCategorize.types';
@@ -10,6 +9,7 @@ import { UncategorizedBankTransaction } from '../../BankingTransactions/models/U
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { events } from '@/common/events/events';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { UncategorizedBankTransactionDto } from '../dtos/CreateUncategorizedBankTransaction.dto';
@Injectable()
export class CreateUncategorizedTransactionService {
@@ -30,7 +30,7 @@ export class CreateUncategorizedTransactionService {
* @returns {Promise<UncategorizedBankTransaction>}
*/
public create(
createUncategorizedTransactionDTO: CreateUncategorizedTransactionDTO,
createUncategorizedTransactionDTO: UncategorizedBankTransactionDto,
trx?: Knex.Transaction,
) {
return this.uow.withTransaction(async (trx: Knex.Transaction) => {

View File

@@ -12,7 +12,7 @@ import { UncategorizedBankTransaction } from '../../BankingTransactions/models/U
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class UncategorizeCashflowTransactionService {
export class UncategorizeBankTransactionService {
constructor(
private readonly eventPublisher: EventEmitter2,
private readonly uow: UnitOfWork,

View File

@@ -1,18 +1,17 @@
import { castArray } from 'lodash';
import { PromisePool } from '@supercharge/promise-pool';
import { Injectable } from '@nestjs/common';
import { UncategorizeCashflowTransactionService } from './UncategorizeCashflowTransaction.service';
import { UncategorizeBankTransactionService } from './UncategorizeBankTransaction.service';
@Injectable()
export class UncategorizeCashflowTransactionsBulk {
export class UncategorizeBankTransactionsBulk {
constructor(
private readonly uncategorizeTransactionService: UncategorizeCashflowTransactionService
private readonly uncategorizeTransactionService: UncategorizeBankTransactionService
) {}
/**
* Uncategorize the given bank transactions in bulk.
* @param {number} tenantId
* @param {number} uncategorizedTransactionId
* @param {number | Array<number>} uncategorizedTransactionId
*/
public async uncategorizeBulk(
uncategorizedTransactionId: number | Array<number>

View File

@@ -0,0 +1,113 @@
import { ToNumber } from '@/common/decorators/Validators';
import {
IsArray,
IsDateString,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/**
* DTO for categorizing bank transactions
*/
export class CategorizeBankTransactionDto {
@ApiProperty({
description: 'The date of the bank transaction',
type: Date,
example: '2023-01-01T00:00:00.000Z',
})
@IsDateString()
@IsNotEmpty()
date: Date;
@ApiProperty({
description: 'ID of the credit account associated with this transaction',
type: Number,
example: 1001,
})
@IsInt()
@ToNumber()
@IsNotEmpty()
creditAccountId: number;
@ApiPropertyOptional({
description: 'Optional external reference number',
type: String,
example: 'REF-001',
})
@IsString()
@IsOptional()
referenceNo: string;
@ApiPropertyOptional({
description: 'Optional transaction number or reference',
type: String,
example: 'TRX-001',
})
@IsString()
@IsOptional()
transactionNumber: string;
@ApiProperty({
description: 'Type of bank transaction (e.g., deposit, withdrawal)',
type: String,
example: 'deposit',
})
@IsString()
@IsNotEmpty()
transactionType: string;
@ApiPropertyOptional({
description: 'Exchange rate for currency conversion',
type: Number,
default: 1,
example: 1.15,
})
@IsNumber()
@ToNumber()
@IsOptional()
exchangeRate: number = 1;
@ApiPropertyOptional({
description: 'Currency code for the transaction',
type: String,
example: 'USD',
})
@IsString()
@IsOptional()
currencyCode: string;
@ApiPropertyOptional({
description: 'Description of the bank transaction',
type: String,
example: 'Monthly rent payment',
})
@IsString()
@IsOptional()
description: string;
@ApiPropertyOptional({
description: 'ID of the branch where the transaction occurred',
type: Number,
example: 101,
})
@IsNumber()
@IsOptional()
branchId: number;
}
/**
* Extended DTO for categorizing bank transactions with IDs of uncategorized transactions
*/
export class CategorizeBankTransactionRouteDto extends CategorizeBankTransactionDto {
@ApiProperty({
description: 'Array of uncategorized transaction IDs to be categorized',
type: [Number],
example: [1001, 1002, 1003],
})
@IsArray()
uncategorizedTransactionIds: Array<number>;
}

View File

@@ -0,0 +1,36 @@
import { IsBoolean, IsDateString, IsNumber, IsString } from 'class-validator';
export class UncategorizedBankTransactionDto {
@IsDateString()
date: Date | string;
@IsNumber()
accountId: number;
@IsNumber()
amount: number;
@IsString()
currencyCode: string;
@IsString()
payee?: string;
@IsString()
description?: string;
@IsString()
referenceNo?: string | null;
@IsString()
plaidTransactionId?: string | null;
@IsBoolean()
pending?: boolean;
@IsString()
pendingPlaidTransactionId?: string | null;
@IsString()
batch?: string;
}

View File

@@ -1,47 +1,44 @@
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { BankingMatchingApplication } from './BankingMatchingApplication';
import { GetMatchedTransactionsFilter, IMatchTransactionDTO } from './types';
import { GetMatchedTransactionsFilter } from './types';
import { MatchBankTransactionDto } from './dtos/MatchBankTransaction.dto';
@Controller('banking/matching')
@ApiTags('banking-transactions-matching')
export class BankingMatchingController {
constructor(
private readonly bankingMatchingApplication: BankingMatchingApplication
private readonly bankingMatchingApplication: BankingMatchingApplication,
) {}
@Get('matched/transactions')
@Get('matched')
@ApiOperation({ summary: 'Retrieves the matched transactions.' })
async getMatchedTransactions(
@Query('uncategorizedTransactionIds') uncategorizedTransactionIds: number[],
@Query() filter: GetMatchedTransactionsFilter
@Query() filter: GetMatchedTransactionsFilter,
) {
return this.bankingMatchingApplication.getMatchedTransactions(
uncategorizedTransactionIds,
filter
filter,
);
}
@Post('/match/:uncategorizedTransactionId')
@Post('/match')
@ApiOperation({ summary: 'Match the given uncategorized transaction.' })
async matchTransaction(
@Param('uncategorizedTransactionId') uncategorizedTransactionId: number | number[],
@Body() matchedTransactions: MatchBankTransactionDto
) {
async matchTransaction(@Body() matchedTransactions: MatchBankTransactionDto) {
return this.bankingMatchingApplication.matchTransaction(
uncategorizedTransactionId,
matchedTransactions
matchedTransactions.uncategorizedTransactions,
matchedTransactions.matchedTransactions,
);
}
@Post('/unmatch/:uncategorizedTransactionId')
@ApiOperation({ summary: 'Unmatch the given uncategorized transaction.' })
async unmatchMatchedTransaction(
@Param('uncategorizedTransactionId') uncategorizedTransactionId: number
@Param('uncategorizedTransactionId') uncategorizedTransactionId: number,
) {
return this.bankingMatchingApplication.unmatchMatchedTransaction(
uncategorizedTransactionId
uncategorizedTransactionId,
);
}
}

View File

@@ -2,8 +2,8 @@ import { Injectable } from '@nestjs/common';
import { GetMatchedTransactions } from './queries/GetMatchedTransactions.service';
import { MatchBankTransactions } from './commands/MatchTransactions';
import { UnmatchMatchedBankTransaction } from './commands/UnmatchMatchedTransaction.service';
import { GetMatchedTransactionsFilter, IMatchTransactionDTO } from './types';
import { MatchBankTransactionDto } from './dtos/MatchBankTransaction.dto';
import { GetMatchedTransactionsFilter } from './types';
import { MatchTransactionEntryDto } from './dtos/MatchBankTransaction.dto';
@Injectable()
export class BankingMatchingApplication {
@@ -31,17 +31,18 @@ export class BankingMatchingApplication {
/**
* Matches the given uncategorized transaction with the given system transaction.
* @param {number} uncategorizedTransactionId
* @param {IMatchTransactionDTO} matchTransactionsDTO
* @param {IMatchBankTransactionDto} matchedTransactionsDTO
* @returns {Promise<void>}
*/
public matchTransaction(
uncategorizedTransactionId: number | Array<number>,
matchedTransactions: MatchBankTransactionDto,
matchedTransactionsDto:
| MatchTransactionEntryDto
| Array<MatchTransactionEntryDto>,
): Promise<void> {
return this.matchTransactionService.matchTransaction(
uncategorizedTransactionId,
matchedTransactions,
matchedTransactionsDto,
);
}

View File

@@ -21,7 +21,7 @@ import { ServiceError } from '@/modules/Items/ServiceError';
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
import { events } from '@/common/events/events';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { MatchBankTransactionDto } from '../dtos/MatchBankTransaction.dto';
import { MatchTransactionEntryDto } from '../dtos/MatchBankTransaction.dto';
@Injectable()
export class MatchBankTransactions {
@@ -107,16 +107,15 @@ export class MatchBankTransactions {
/**
* Matches the given uncategorized transaction to the given references.
* @param {number} tenantId
* @param {number} uncategorizedTransactionId
* @returns {Promise<void>}
*/
public async matchTransaction(
uncategorizedTransactionId: number | Array<number>,
matchedTransactionsDto: MatchBankTransactionDto,
matchedTransactionsDto: MatchTransactionEntryDto | Array<MatchTransactionEntryDto>,
): Promise<void> {
const uncategorizedTransactionIds = castArray(uncategorizedTransactionId);
const matchedTransactions = matchedTransactionsDto.entries;
const matchedTransactions = castArray(matchedTransactionsDto);
// Validates the given matching transactions DTO.
await this.validate(uncategorizedTransactionIds, matchedTransactions);
@@ -131,7 +130,7 @@ export class MatchBankTransactions {
// Matches the given transactions under promise pool concurrency controlling.
await PromisePool.withConcurrency(10)
.for(matchedTransactions)
.process(async (matchedTransaction) => {
.process(async (matchedTransaction: MatchTransactionEntryDto) => {
const getMatchedTransactionsService =
this.matchedBankTransactions.registry.get(
matchedTransaction.referenceType,

View File

@@ -1,4 +1,5 @@
import {
ArrayMinSize,
IsArray,
IsNotEmpty,
IsNumber,
@@ -27,6 +28,10 @@ export class MatchTransactionEntryDto {
}
export class MatchBankTransactionDto {
@IsArray()
@ArrayMinSize(1)
uncategorizedTransactions: Array<number>
@IsArray()
@ValidateNested({ each: true })
@Type(() => MatchTransactionEntryDto)
@@ -37,5 +42,5 @@ export class MatchBankTransactionDto {
{ referenceType: 'SaleInvoice', referenceId: 2 },
],
})
entries: MatchTransactionEntryDto[];
matchedTransactions: MatchTransactionEntryDto[];
}

View File

@@ -1,14 +1,14 @@
import PromisePool from '@supercharge/promise-pool';
import { OnEvent } from '@nestjs/event-emitter';
import { Inject, Injectable } from '@nestjs/common';
import {
IBankTransactionMatchedEventPayload,
IBankTransactionUnmatchedEventPayload,
} from '../types';
import PromisePool from '@supercharge/promise-pool';
import { OnEvent } from '@nestjs/event-emitter';
import { Account } from '@/modules/Accounts/models/Account.model';
import { Inject, Injectable } from '@nestjs/common';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
import { events } from '@/common/events/events';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class DecrementUncategorizedTransactionOnMatchingSubscriber {

View File

@@ -1,3 +1,4 @@
import { Knex } from 'knex';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
import { GetMatchedTransactionCashflowTransformer } from './GetMatchedTransactionCashflowTransformer';
import { GetMatchedTransactionsFilter } from '../types';

View File

@@ -1,10 +1,13 @@
import { Inject, Injectable } from '@nestjs/common';
import { GetMatchedTransactionsFilter, MatchedTransactionPOJO } from '../types';
import { GetMatchedTransactionsFilter, MatchedTransactionPOJO, MatchedTransactionsPOJO } from '../types';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
import { GetMatchedTransactionExpensesTransformer } from './GetMatchedTransactionExpensesTransformer';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { Expense } from '@/modules/Expenses/models/Expense.model';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '@/modules/Tenancy/TenancyDB/TenancyDB.constants';
import { initialize } from 'objection';
@Injectable()
export class GetMatchedTransactionsByExpenses extends GetMatchedTransactionsByType {
@@ -13,17 +16,26 @@ export class GetMatchedTransactionsByExpenses extends GetMatchedTransactionsByTy
@Inject(Expense.name)
protected readonly expenseModel: TenantModelProxy<typeof Expense>,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantDb: () => Knex,
@Inject('TENANT_MODELS_INIT')
private readonly tenantModelsInit: () => Promise<boolean>,
) {
super();
}
/**
* Retrieves the matched transactions of expenses.
* @param {number} tenantId
* @param {GetMatchedTransactionsFilter} filter
* @returns
*/
async getMatchedTransactions(filter: GetMatchedTransactionsFilter) {
async getMatchedTransactions(
filter: GetMatchedTransactionsFilter,
): Promise<MatchedTransactionsPOJO> {
// await this.tenantModelsInit();
// Retrieve the expense matches.
const expenses = await this.expenseModel()
.query()
@@ -49,6 +61,7 @@ export class GetMatchedTransactionsByExpenses extends GetMatchedTransactionsByTy
}
query.orderBy('paymentDate', 'DESC');
});
return this.transformer.transform(
expenses,
new GetMatchedTransactionExpensesTransformer(),

View File

@@ -1,3 +1,4 @@
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { first } from 'lodash';
import { GetMatchedTransactionInvoicesTransformer } from './GetMatchedTransactionInvoicesTransformer';
@@ -9,7 +10,6 @@ import {
} from '../types';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
import { CreatePaymentReceivedService } from '@/modules/PaymentReceived/commands/CreatePaymentReceived.serivce';
import { Inject, Injectable } from '@nestjs/common';
import { SaleInvoice } from '@/modules/SaleInvoices/models/SaleInvoice';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
@@ -86,7 +86,6 @@ export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByTy
/**
* Creates the common matched transaction.
* @param {number} tenantId
* @param {Array<number>} uncategorizedTransactionIds
* @param {IMatchTransactionDTO} matchTransactionDTO
* @param {Knex.Transaction} trx

View File

@@ -1,10 +1,13 @@
import { Knex } from 'knex';
import { Inject, Injectable } from '@nestjs/common';
import { initialize } from 'objection';
import { GetMatchedTransactionManualJournalsTransformer } from './GetMatchedTransactionManualJournalsTransformer';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
import { GetMatchedTransactionsFilter } from '../types';
import { ManualJournal } from '@/modules/ManualJournals/models/ManualJournal';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { TENANCY_DB_CONNECTION } from '@/modules/Tenancy/TenancyDB/TenancyDB.constants';
@Injectable()
export class GetMatchedTransactionsByManualJournals extends GetMatchedTransactionsByType {

View File

@@ -12,7 +12,7 @@ import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
export abstract class GetMatchedTransactionsByType {
@Inject(MatchedBankTransaction.name)
private readonly matchedBankTransactionModel: TenantModelProxy<
matchedBankTransactionModel: TenantModelProxy<
typeof MatchedBankTransaction
>;

View File

@@ -0,0 +1,22 @@
import { Body, Controller, Post } from '@nestjs/common';
import { PlaidApplication } from './PlaidApplication';
import { PlaidItemDto } from './dtos/PlaidItem.dto';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
@Controller('banking/plaid')
@ApiTags('banking-plaid')
export class BankingPlaidController {
constructor(private readonly plaidApplication: PlaidApplication) {}
@Post('link-token')
@ApiOperation({ summary: 'Get Plaid link token' })
getLinkToken() {
return this.plaidApplication.getLinkToken();
}
@Post('exchange-token')
@ApiOperation({ summary: 'Exchange Plaid access token' })
exchangeToken(@Body() itemDTO: PlaidItemDto) {
return this.plaidApplication.exchangeToken(itemDTO);
}
}

View File

@@ -1,3 +1,4 @@
import { BullModule } from '@nestjs/bullmq';
import { Module } from '@nestjs/common';
import { PlaidUpdateTransactionsOnItemCreatedSubscriber } from './subscribers/PlaidUpdateTransactionsOnItemCreatedSubscriber';
import { PlaidUpdateTransactions } from './command/PlaidUpdateTransactions';
@@ -15,6 +16,11 @@ import { PlaidItemService } from './command/PlaidItem';
import { TenancyContext } from '../Tenancy/TenancyContext.service';
import { InjectSystemModel } from '../System/SystemModels/SystemModels.module';
import { SystemPlaidItem } from './models/SystemPlaidItem';
import { BankingPlaidController } from './BankingPlaid.controller';
import { BankingPlaidWebhooksController } from './BankingPlaidWebhooks.controller';
import { SetupPlaidItemTenantService } from './command/SetupPlaidItemTenant.service';
import { UpdateBankingPlaidTransitionsQueueJob } from './types/BankingPlaid.types';
import { PlaidFetchTransactionsProcessor } from './jobs/PlaidFetchTransactionsJob';
const models = [RegisterTenancyModel(PlaidItem)];
@@ -24,6 +30,7 @@ const models = [RegisterTenancyModel(PlaidItem)];
AccountsModule,
BankingCategorizeModule,
BankingTransactionsModule,
BullModule.registerQueue({ name: UpdateBankingPlaidTransitionsQueueJob }),
...models,
],
providers: [
@@ -34,9 +41,12 @@ const models = [RegisterTenancyModel(PlaidItem)];
PlaidWebooks,
PlaidLinkTokenService,
PlaidApplication,
PlaidUpdateTransactionsOnItemCreatedSubscriber,
SetupPlaidItemTenantService,
TenancyContext,
PlaidFetchTransactionsProcessor,
PlaidUpdateTransactionsOnItemCreatedSubscriber,
],
exports: [...models],
controllers: [BankingPlaidController, BankingPlaidWebhooksController],
})
export class BankingPlaidModule {}

View File

@@ -0,0 +1,31 @@
import { Body, Controller, Post } from '@nestjs/common';
import { PlaidWebhookDto } from './dtos/PlaidItem.dto';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { PlaidApplication } from './PlaidApplication';
import { PublicRoute } from '../Auth/guards/jwt.guard';
import { SetupPlaidItemTenantService } from './command/SetupPlaidItemTenant.service';
@Controller('banking/plaid')
@ApiTags('banking-plaid')
@PublicRoute()
export class BankingPlaidWebhooksController {
constructor(
private readonly plaidApplication: PlaidApplication,
private readonly setupPlaidItemTenantService: SetupPlaidItemTenantService,
) {}
@Post('webhooks')
@ApiOperation({ summary: 'Listen to Plaid webhooks' })
webhooks(@Body() { itemId, webhookType, webhookCode }: PlaidWebhookDto) {
return this.setupPlaidItemTenantService.setupPlaidTenant(
itemId,
() => {
return this.plaidApplication.webhooks(
itemId,
webhookType,
webhookCode,
);
},
);
}
}

View File

@@ -1,8 +1,12 @@
import { ClsService } from 'nestjs-cls';
import { Inject, Injectable } from '@nestjs/common';
import { PlaidLinkTokenService } from './queries/GetPlaidLinkToken.service';
import { PlaidItemService } from './command/PlaidItem';
import { PlaidWebooks } from './command/PlaidWebhooks';
import { Injectable } from '@nestjs/common';
import { PlaidItemDTO } from './types/BankingPlaid.types';
import { PlaidItemDto } from './dtos/PlaidItem.dto';
import { SystemPlaidItem } from './models/SystemPlaidItem';
import { TenantModel } from '../System/models/TenantModel';
import { SystemUser } from '../System/models/SystemUser';
@Injectable()
export class PlaidApplication {
@@ -10,6 +14,16 @@ export class PlaidApplication {
private readonly getLinkTokenService: PlaidLinkTokenService,
private readonly plaidItemService: PlaidItemService,
private readonly plaidWebhooks: PlaidWebooks,
private readonly clsService: ClsService,
@Inject(SystemPlaidItem.name)
private readonly systemPlaidItemModel: typeof SystemPlaidItem,
@Inject(TenantModel.name)
private readonly tenantModel: typeof TenantModel,
@Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser,
) {}
/**
@@ -25,7 +39,7 @@ export class PlaidApplication {
* @param {PlaidItemDTO} itemDTO
* @returns
*/
public exchangeToken(itemDTO: PlaidItemDTO): Promise<void> {
public exchangeToken(itemDTO: PlaidItemDto): Promise<void> {
return this.plaidItemService.item(itemDTO);
}
@@ -41,10 +55,33 @@ export class PlaidApplication {
webhookType: string,
webhookCode: string,
): Promise<void> {
return this.plaidWebhooks.webhooks(
plaidItemId,
webhookType,
webhookCode,
);
return this.plaidWebhooks.webhooks(plaidItemId, webhookType, webhookCode);
}
public async setupPlaidTenant(plaidItemId: string, callback: () => void) {
const plaidItem = await this.systemPlaidItemModel
.query()
.findOne({ plaidItemId });
if (!plaidItem) {
throw new Error('Plaid item not found');
}
const tenant = await this.tenantModel
.query()
.findOne({ id: plaidItem.tenantId })
.throwIfNotFound();
const user = await this.systemUserModel
.query()
.findOne({
tenantId: tenant.id,
})
.modify('active')
.throwIfNotFound();
this.clsService.set('organizationId', tenant.organizationId);
this.clsService.set('userId', user.id);
return callback();
}
}

View File

@@ -1,32 +0,0 @@
// import { Request, Response, NextFunction } from 'express';
// import { SystemPlaidItem, Tenant } from '@/system/models';
// import tenantDependencyInjection from '@/api/middleware/TenantDependencyInjection';
// export const PlaidWebhookTenantBootMiddleware = async (
// req: Request,
// res: Response,
// next: NextFunction
// ) => {
// const { item_id: plaidItemId } = req.body;
// const plaidItem = await SystemPlaidItem.query().findOne({ plaidItemId });
// const notFoundOrganization = () => {
// return res.boom.unauthorized('Organization identication not found.', {
// errors: [{ type: 'ORGANIZATION.ID.NOT.FOUND', code: 100 }],
// });
// };
// // In case the given organization not found.
// if (!plaidItem) {
// return notFoundOrganization();
// }
// const tenant = await Tenant.query()
// .findById(plaidItem.tenantId)
// .withGraphFetched('metadata');
// // When the given organization id not found on the system storage.
// if (!tenant) {
// return notFoundOrganization();
// }
// tenantDependencyInjection(req, tenant);
// next();
// };

View File

@@ -6,11 +6,9 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
import { SystemPlaidItem } from '../models/SystemPlaidItem';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import {
IPlaidItemCreatedEventPayload,
PlaidItemDTO,
} from '../types/BankingPlaid.types';
import { IPlaidItemCreatedEventPayload } from '../types/BankingPlaid.types';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { PlaidItemDto } from '../dtos/PlaidItem.dto';
@Injectable()
export class PlaidItemService {
@@ -19,9 +17,7 @@ export class PlaidItemService {
private readonly tenancyContext: TenancyContext,
@Inject(SystemPlaidItem.name)
private readonly systemPlaidItemModel: TenantModelProxy<
typeof SystemPlaidItem
>,
private readonly systemPlaidItemModel: typeof SystemPlaidItem,
@Inject(PlaidItem.name)
private readonly plaidItemModel: TenantModelProxy<typeof PlaidItem>,
@@ -33,10 +29,10 @@ export class PlaidItemService {
/**
* Exchanges the public token to get access token and item id and then creates
* a new Plaid item.
* @param {PlaidItemDTO} itemDTO - Plaid item data transfer object.
* @param {PlaidItemDto} itemDTO - Plaid item data transfer object.
* @returns {Promise<void>}
*/
public async item(itemDTO: PlaidItemDTO): Promise<void> {
public async item(itemDTO: PlaidItemDto): Promise<void> {
const { publicToken, institutionId } = itemDTO;
const tenant = await this.tenancyContext.getTenant();
@@ -57,7 +53,7 @@ export class PlaidItemService {
plaidInstitutionId: institutionId,
});
// Stores the Plaid item id on system scope.
await this.systemPlaidItemModel().query().insert({ tenantId, plaidItemId });
await this.systemPlaidItemModel.query().insert({ tenantId, plaidItemId });
// Triggers `onPlaidItemCreated` event.
await this.eventEmitter.emitAsync(events.plaid.onItemCreated, {

View File

@@ -1,5 +1,6 @@
import * as R from 'ramda';
import bluebird from 'bluebird';
import * as bluebird from 'bluebird';
import * as uniqid from 'uniqid';
import { entries, groupBy } from 'lodash';
import {
AccountBase as PlaidAccountBase,
@@ -12,7 +13,6 @@ import {
transformPlaidTrxsToCashflowCreate,
} from '../utils';
import { Knex } from 'knex';
import uniqid from 'uniqid';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { RemovePendingUncategorizedTransaction } from '../../BankingTransactions/commands/RemovePendingUncategorizedTransaction.service';
import { CreateAccountService } from '../../Accounts/CreateAccount.service';

View File

@@ -15,6 +15,13 @@ import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class PlaidUpdateTransactions {
/**
* Constructor method.
* @param {PlaidSyncDb} plaidSync - Plaid sync service.
* @param {UnitOfWork} uow - Unit of work.
* @param {TenantModelProxy<typeof PlaidItem>} plaidItemModel - Plaid item model.
* @param {PlaidApi} plaidClient - Plaid client.
*/
constructor(
private readonly plaidSync: PlaidSyncDb,
private readonly uow: UnitOfWork,
@@ -28,8 +35,7 @@ export class PlaidUpdateTransactions {
/**
* Handles sync the Plaid item to Bigcaptial under UOW.
* @param {number} tenantId - Tenant id.
* @param {number} plaidItemId - Plaid item id.
* @param {string} plaidItemId - Plaid item id.
* @returns {Promise<{ addedCount: number; modifiedCount: number; removedCount: number; }>}
*/
public async updateTransactions(plaidItemId: string) {
@@ -44,9 +50,9 @@ export class PlaidUpdateTransactions {
* - New bank accounts.
* - Last accounts feeds updated at.
* - Turn on the accounts feed flag.
* @param {number} tenantId - Tenant ID.
* @param {string} plaidItemId - The Plaid ID for the item.
* @returns {Promise<{ addedCount: number; modifiedCount: number; removedCount: number; }>}
* @param {Knex.Transaction} trx - Knex transaction.
* @returns {Promise<{ addedCount: number; modifiedCount: number; removedCount: number; }>}
*/
public async updateTransactionsWork(
plaidItemId: string,
@@ -97,7 +103,6 @@ export class PlaidUpdateTransactions {
/**
* Fetches transactions from the `Plaid API` for a given item.
* @param {number} tenantId - Tenant ID.
* @param {string} plaidItemId - The Plaid ID for the item.
* @returns {Promise<PlaidFetchedTransactionsUpdates>}
*/

View File

@@ -1,3 +1,4 @@
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { PlaidItem } from '../models/PlaidItem';
import { PlaidUpdateTransactions } from './PlaidUpdateTransactions';
import { Inject, Injectable } from '@nestjs/common';
@@ -8,7 +9,7 @@ export class PlaidWebooks {
private readonly updateTransactionsService: PlaidUpdateTransactions,
@Inject(PlaidItem.name)
private readonly plaidItemModel: typeof PlaidItem,
private readonly plaidItemModel: TenantModelProxy<typeof PlaidItem>,
) {}
/**
@@ -76,11 +77,10 @@ export class PlaidWebooks {
* @returns {Promise<void>}
*/
public async handleTransactionsWebooks(
tenantId: number,
plaidItemId: string,
webhookCode: string,
): Promise<void> {
const plaidItem = await this.plaidItemModel
const plaidItem = await this.plaidItemModel()
.query()
.findOne({ plaidItemId })
.throwIfNotFound();
@@ -122,9 +122,8 @@ export class PlaidWebooks {
/**
* Handles all Item webhook events.
* @param {number} tenantId - Tenant ID
* @param {string} webhookCode - The webhook code
* @param {string} plaidItemId - The Plaid ID for the item
* @param {string} webhookCode - The webhook code
* @returns {Promise<void>}
*/
public async itemsHandler(

View File

@@ -0,0 +1,54 @@
import { ClsService } from 'nestjs-cls';
import { Inject, Injectable } from '@nestjs/common';
import { SystemPlaidItem } from '../models/SystemPlaidItem';
import { TenantModel } from '@/modules/System/models/TenantModel';
import { SystemUser } from '@/modules/System/models/SystemUser';
@Injectable()
export class SetupPlaidItemTenantService {
constructor(
private readonly clsService: ClsService,
@Inject(SystemPlaidItem.name)
private readonly systemPlaidItemModel: typeof SystemPlaidItem,
@Inject(TenantModel.name)
private readonly tenantModel: typeof TenantModel,
@Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser,
) {}
/**
* Sets up the Plaid tenant.
* @param {string} plaidItemId - The Plaid item id.
* @param {() => void} callback - The callback function to execute after setting up the Plaid tenant.
* @returns {Promise<void>}
*/
public async setupPlaidTenant(plaidItemId: string, callback: () => void) {
const plaidItem = await this.systemPlaidItemModel
.query()
.findOne({ plaidItemId });
if (!plaidItem) {
throw new Error('Plaid item not found');
}
const tenant = await this.tenantModel
.query()
.findOne({ id: plaidItem.tenantId })
.throwIfNotFound();
const user = await this.systemUserModel
.query()
.findOne({
tenantId: tenant.id,
})
.modify('active')
.throwIfNotFound();
this.clsService.set('organizationId', tenant.organizationId);
this.clsService.set('userId', user.id);
return callback();
}
}

View File

@@ -0,0 +1,26 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class PlaidItemDto {
@IsString()
@IsNotEmpty()
publicToken: string;
@IsString()
@IsNotEmpty()
institutionId: string;
}
export class PlaidWebhookDto {
@IsString()
@IsNotEmpty()
itemId: string;
@IsString()
@IsNotEmpty()
webhookType: string;
@IsString()
@IsNotEmpty()
webhookCode: string;
}

View File

@@ -1,43 +1,46 @@
// import Container, { Service } from 'typedi';
// import { PlaidUpdateTransactions } from './PlaidUpdateTransactions';
// import { IPlaidItemCreatedEventPayload } from '@/interfaces';
import { Process } from '@nestjs/bull';
import { UseCls } from 'nestjs-cls';
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Scope } from '@nestjs/common';
import { Job } from 'bullmq';
import {
PlaidFetchTransitonsEventPayload,
UpdateBankingPlaidTransitionsJob,
UpdateBankingPlaidTransitionsQueueJob,
} from '../types/BankingPlaid.types';
import { PlaidUpdateTransactions } from '../command/PlaidUpdateTransactions';
import { SetupPlaidItemTenantService } from '../command/SetupPlaidItemTenant.service';
// @Service()
// export class PlaidFetchTransactionsJob {
// /**
// * Constructor method.
// */
// constructor(agenda) {
// agenda.define(
// 'plaid-update-account-transactions',
// { priority: 'high', concurrency: 2 },
// this.handler
// );
// }
@Processor({
name: UpdateBankingPlaidTransitionsQueueJob,
scope: Scope.REQUEST,
})
export class PlaidFetchTransactionsProcessor extends WorkerHost {
constructor(
private readonly plaidFetchTransactionsService: PlaidUpdateTransactions,
private readonly setupPlaidItemService: SetupPlaidItemTenantService,
) {
super();
}
// /**
// * Triggers the function.
// */
// private handler = async (job, done: Function) => {
// const { tenantId, plaidItemId } = job.attrs
// .data as IPlaidItemCreatedEventPayload;
/**
* Triggers the function.
*/
@Process(UpdateBankingPlaidTransitionsJob)
@UseCls()
async process(job: Job<PlaidFetchTransitonsEventPayload>) {
const { plaidItemId } = job.data;
// const plaidFetchTransactionsService = Container.get(
// PlaidUpdateTransactions
// );
// const io = Container.get('socket');
// try {
// await plaidFetchTransactionsService.updateTransactions(
// tenantId,
// plaidItemId
// );
// // Notify the frontend to reflect the new transactions changes.
// io.emit('NEW_TRANSACTIONS_DATA', { plaidItemId });
// done();
// } catch (error) {
// console.log(error);
// done(error);
// }
// };
// }
try {
await this.setupPlaidItemService.setupPlaidTenant(plaidItemId, () => {
return this.plaidFetchTransactionsService.updateTransactions(
plaidItemId,
);
});
// Notify the frontend to reflect the new transactions changes.
// io.emit('NEW_TRANSACTIONS_DATA', { plaidItemId });
} catch (error) {
console.log(error);
}
}
}

View File

@@ -30,7 +30,7 @@ export class SystemPlaidItem extends BaseModel {
* Relationship mapping.
*/
static get relationMappings() {
const Tenant = require('system/models/Tenant');
const { TenantModel } = require('../../System/models/TenantModel');
return {
/**
@@ -38,7 +38,7 @@ export class SystemPlaidItem extends BaseModel {
*/
tenant: {
relation: Model.BelongsToOneRelation,
modelClass: Tenant.default,
modelClass: TenantModel,
join: {
from: 'users.tenantId',
to: 'tenants.id',

View File

@@ -1,22 +1,34 @@
import { events } from '@/common/events/events';
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { IPlaidItemCreatedEventPayload } from '../types/BankingPlaid.types';
import { Queue } from 'bullmq';
import { InjectQueue } from '@nestjs/bullmq';
import {
IPlaidItemCreatedEventPayload,
UpdateBankingPlaidTransitionsJob,
UpdateBankingPlaidTransitionsQueueJob,
} from '../types/BankingPlaid.types';
@Injectable()
export class PlaidUpdateTransactionsOnItemCreatedSubscriber {
constructor(
@InjectQueue(UpdateBankingPlaidTransitionsQueueJob)
private readonly updateTransitionsQueue: Queue,
) {}
/**
* Updates the Plaid item transactions
* @param {IPlaidItemCreatedEventPayload} payload - Event payload.
*/
@OnEvent(events.plaid.onItemCreated)
public async handleUpdateTransactionsOnItemCreated({
tenantId,
plaidItemId,
plaidAccessToken,
plaidInstitutionId,
}: IPlaidItemCreatedEventPayload) {
const payload = { tenantId, plaidItemId };
// await this.agenda.now('plaid-update-account-transactions', payload);
};
const payload = { plaidItemId };
await this.updateTransitionsQueue.add(
UpdateBankingPlaidTransitionsJob,
payload,
);
}
}

View File

@@ -1,11 +1,11 @@
import { Knex } from "knex";
import { RemovedTransaction, Transaction } from "plaid";
import { Knex } from 'knex';
import { RemovedTransaction, Transaction } from 'plaid';
export interface IPlaidTransactionsSyncedEventPayload {
// tenantId: number;
plaidAccountId: number;
batch: string;
trx?: Knex.Transaction
trx?: Knex.Transaction;
}
export interface PlaidItemDTO {
@@ -13,7 +13,6 @@ export interface PlaidItemDTO {
institutionId: string;
}
export interface PlaidFetchedTransactionsUpdates {
added: Transaction[];
modified: Transaction[];
@@ -22,11 +21,20 @@ export interface PlaidFetchedTransactionsUpdates {
cursor: string;
}
export interface IPlaidItemCreatedEventPayload {
tenantId: number;
plaidAccessToken: string;
plaidItemId: string;
plaidInstitutionId: string;
}
export const UpdateBankingPlaidTransitionsJob =
'update-banking-plaid-transitions-job';
export const UpdateBankingPlaidTransitionsQueueJob =
'update-banking-plaid-transitions-query';
export interface PlaidFetchTransitonsEventPayload {
plaidItemId: string;
}

View File

@@ -0,0 +1,25 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { RecognizedTransactionsApplication } from './RecognizedTransactions.application';
@Controller('banking/recognized')
@ApiTags('banking-recognized')
export class BankingRecognizedTransactionsController {
constructor(
private readonly recognizedTransactionsApplication: RecognizedTransactionsApplication,
) {}
@Get(':recognizedTransactionId')
async getRecognizedTransaction(
@Param('recognizedTransactionId') recognizedTransactionId: number,
) {
return this.recognizedTransactionsApplication.getRecognizedTransaction(
Number(recognizedTransactionId),
);
}
@Get()
async getRecognizedTransactions(@Query() query: any) {
return this.recognizedTransactionsApplication.getRecognizedTransactions(query);
}
}

View File

@@ -1,32 +1,46 @@
import { forwardRef, Module } from '@nestjs/common';
import { RegisterTenancyModel } from '../Tenancy/TenancyModels/Tenancy.module';
import { RecognizedBankTransaction } from './models/RecognizedBankTransaction';
import { GetAutofillCategorizeTransactionService } from './queries/GetAutofillCategorizeTransaction.service';
import { RevertRecognizedTransactionsService } from './commands/RevertRecognizedTransactions.service';
import { RecognizeTranasctionsService } from './commands/RecognizeTranasctions.service';
import { TriggerRecognizedTransactionsSubscriber } from './events/TriggerRecognizedTransactions';
import { BankingTransactionsModule } from '../BankingTransactions/BankingTransactions.module';
import { BankRulesModule } from '../BankRules/BankRules.module';
import { BankingRecognizedTransactionsController } from './BankingRecognizedTransactions.controller';
import { RecognizedTransactionsApplication } from './RecognizedTransactions.application';
import { GetRecognizedTransactionsService } from './GetRecongizedTransactions';
import { GetRecognizedTransactionService } from './queries/GetRecognizedTransaction.service';
import { BullModule } from '@nestjs/bullmq';
import { RecognizeUncategorizedTransactionsQueue } from './_types';
import { RegonizeTransactionsPrcessor } from './jobs/RecognizeTransactionsJob';
import { TenancyModule } from '../Tenancy/Tenancy.module';
const models = [RegisterTenancyModel(RecognizedBankTransaction)];
@Module({
imports: [
BankingTransactionsModule,
TenancyModule,
forwardRef(() => BankRulesModule),
BullModule.registerQueue({
name: RecognizeUncategorizedTransactionsQueue,
}),
...models,
],
providers: [
GetAutofillCategorizeTransactionService,
RecognizedTransactionsApplication,
GetRecognizedTransactionsService,
RevertRecognizedTransactionsService,
RecognizeTranasctionsService,
TriggerRecognizedTransactionsSubscriber,
GetRecognizedTransactionService,
RegonizeTransactionsPrcessor,
],
exports: [
...models,
GetAutofillCategorizeTransactionService,
RevertRecognizedTransactionsService,
RecognizeTranasctionsService,
],
controllers: [BankingRecognizedTransactionsController],
})
export class BankingTransactionsRegonizeModule {}

View File

@@ -1,8 +1,9 @@
import { Inject, Injectable } from '@nestjs/common';
import { GetRecognizedTransactionTransformer } from './GetRecognizedTransactionTransformer';
import { UncategorizedBankTransaction } from '../models/UncategorizedBankTransaction';
import { GetRecognizedTransactionTransformer } from './queries/GetRecognizedTransactionTransformer';
import { UncategorizedBankTransaction } from '../BankingTransactions/models/UncategorizedBankTransaction';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { IGetRecognizedTransactionsQuery } from '../types/BankingTransactions.types';
import { IGetRecognizedTransactionsQuery } from '../BankingTransactions/types/BankingTransactions.types';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class GetRecognizedTransactionsService {
@@ -10,7 +11,7 @@ export class GetRecognizedTransactionsService {
private readonly transformer: TransformerInjectable,
@Inject(UncategorizedBankTransaction.name)
private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction,
private readonly uncategorizedBankTransactionModel: TenantModelProxy<typeof UncategorizedBankTransaction>,
) {}
/**
@@ -25,7 +26,7 @@ export class GetRecognizedTransactionsService {
...filter,
};
const { results, pagination } =
await this.uncategorizedBankTransactionModel.query()
await this.uncategorizedBankTransactionModel().query()
.onBuild((q) => {
q.withGraphFetched('recognizedTransaction.assignAccount');
q.withGraphFetched('recognizedTransaction.bankRule');

View File

@@ -0,0 +1,57 @@
import { Knex } from 'knex';
import { Injectable } from '@nestjs/common';
import { GetRecognizedTransactionsService } from './GetRecongizedTransactions';
import { GetRecognizedTransactionService } from './queries/GetRecognizedTransaction.service';
import { RevertRecognizedTransactionsService } from './commands/RevertRecognizedTransactions.service';
import { IGetRecognizedTransactionsQuery } from '../BankingTransactions/types/BankingTransactions.types';
import { RevertRecognizedTransactionsCriteria } from './_types';
@Injectable()
export class RecognizedTransactionsApplication {
constructor(
private readonly getRecognizedTransactionsService: GetRecognizedTransactionsService,
private readonly getRecognizedTransactionService: GetRecognizedTransactionService,
private readonly revertRecognizedTransactionsService: RevertRecognizedTransactionsService,
) {}
/**
* Retrieves the recognized transactions based on the provided filter.
* @param {IGetRecognizedTransactionsQuery} filter - Filter criteria.
* @returns {Promise<{ data: any[], pagination: any }>}
*/
public getRecognizedTransactions(filter: IGetRecognizedTransactionsQuery) {
return this.getRecognizedTransactionsService.getRecognizedTranactions(
filter,
);
}
/**
* Retrieves a specific recognized transaction by ID.
* @param {number} recognizedTransactionId - The ID of the recognized transaction.
* @returns {Promise<any>}
*/
public getRecognizedTransaction(recognizedTransactionId: number) {
return this.getRecognizedTransactionService.getRecognizedTransaction(
recognizedTransactionId,
);
}
/**
* Reverts a recognized transaction.
* @param {number} ruleId - The ID of the recognized transaction to revert.
* @param {RevertRecognizedTransactionsCriteria} transactionsCriteria - The criteria for the recognized transaction to revert.
* @param {Knex.Transaction} trx - The transaction to use for the revert operation.
* @returns {Promise<any>}
*/
public revertRecognizedTransactions(
ruleId?: number | Array<number>,
transactionsCriteria?: RevertRecognizedTransactionsCriteria,
trx?: Knex.Transaction,
) {
return this.revertRecognizedTransactionsService.revertRecognizedTransactions(
ruleId,
transactionsCriteria,
trx,
);
}
}

View File

@@ -1,3 +1,5 @@
import { TenantJobPayload } from "@/interfaces/Tenant";
export interface RevertRecognizedTransactionsCriteria {
batch?: string;
accountId?: number;
@@ -7,3 +9,14 @@ export interface RecognizeTransactionsCriteria {
batch?: string;
accountId?: number;
}
export const RecognizeUncategorizedTransactionsJob =
'recognize-uncategorized-transactions-job';
export const RecognizeUncategorizedTransactionsQueue =
'recognize-uncategorized-transactions-queue';
export interface RecognizeUncategorizedTransactionsJobPayload extends TenantJobPayload {
ruleId: number,
transactionsCriteria: any;
}

View File

@@ -2,21 +2,47 @@ import { isEqual, omit } from 'lodash';
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
import { IBankRuleEventCreatedPayload, IBankRuleEventDeletedPayload, IBankRuleEventEditedPayload } from '@/modules/BankRules/types';
import {
IBankRuleEventCreatedPayload,
IBankRuleEventDeletedPayload,
IBankRuleEventEditedPayload,
} from '@/modules/BankRules/types';
import { Queue } from 'bullmq';
import { InjectQueue } from '@nestjs/bullmq';
import {
RecognizeUncategorizedTransactionsJob,
RecognizeUncategorizedTransactionsJobPayload,
RecognizeUncategorizedTransactionsQueue,
} from '../_types';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
@Injectable()
export class TriggerRecognizedTransactionsSubscriber {
constructor(
private readonly tenancyContect: TenancyContext,
@InjectQueue(RecognizeUncategorizedTransactionsQueue)
private readonly recognizeTransactionsQueue: Queue,
) {}
/**
* Triggers the recognize uncategorized transactions job on rule created.
* @param {IBankRuleEventCreatedPayload} payload -
*/
@OnEvent(events.bankRules.onCreated)
private async recognizedTransactionsOnRuleCreated({
async recognizedTransactionsOnRuleCreated({
bankRule,
}: IBankRuleEventCreatedPayload) {
const payload = { ruleId: bankRule.id };
const tenantPayload = await this.tenancyContect.getTenantJobPayload();
const payload = {
ruleId: bankRule.id,
...tenantPayload,
} as RecognizeUncategorizedTransactionsJobPayload;
// await this.agenda.now('recognize-uncategorized-transactions-job', payload);
await this.recognizeTransactionsQueue.add(
RecognizeUncategorizedTransactionsJob,
payload,
);
}
/**
@@ -24,27 +50,33 @@ export class TriggerRecognizedTransactionsSubscriber {
* @param {IBankRuleEventEditedPayload} payload -
*/
@OnEvent(events.bankRules.onEdited)
private async recognizedTransactionsOnRuleEdited({
async recognizedTransactionsOnRuleEdited({
editRuleDTO,
oldBankRule,
bankRule,
}: IBankRuleEventEditedPayload) {
const payload = { ruleId: bankRule.id };
// Cannot continue if the new and old bank rule values are the same,
// after excluding `createdAt` and `updatedAt` dates.
if (
isEqual(
omit(bankRule, ['createdAt', 'updatedAt']),
omit(oldBankRule, ['createdAt', 'updatedAt'])
omit(oldBankRule, ['createdAt', 'updatedAt']),
)
) {
return;
}
// await this.agenda.now(
// 'rerecognize-uncategorized-transactions-job',
// payload
// );
const tenantPayload = await this.tenancyContect.getTenantJobPayload();
const payload = {
ruleId: bankRule.id,
...tenantPayload,
} as RecognizeUncategorizedTransactionsJobPayload;
// Re-recognize the transactions based on the new rules.
await this.recognizeTransactionsQueue.add(
RecognizeUncategorizedTransactionsJob,
payload,
);
}
/**
@@ -52,15 +84,20 @@ export class TriggerRecognizedTransactionsSubscriber {
* @param {IBankRuleEventDeletedPayload} payload -
*/
@OnEvent(events.bankRules.onDeleted)
private async recognizedTransactionsOnRuleDeleted({
async recognizedTransactionsOnRuleDeleted({
ruleId,
}: IBankRuleEventDeletedPayload) {
const payload = { ruleId };
const tenantPayload = await this.tenancyContect.getTenantJobPayload();
const payload = {
ruleId,
...tenantPayload,
} as RecognizeUncategorizedTransactionsJobPayload;
// await this.agenda.now(
// 'revert-recognized-uncategorized-transactions-job',
// payload
// );
// Re-recognize the transactions based on the new rules.
await this.recognizeTransactionsQueue.add(
RecognizeUncategorizedTransactionsJob,
payload,
);
}
/**
@@ -68,7 +105,7 @@ export class TriggerRecognizedTransactionsSubscriber {
* @param {IImportFileCommitedEventPayload} payload -
*/
@OnEvent(events.import.onImportCommitted)
private async triggerRecognizeTransactionsOnImportCommitted({
async triggerRecognizeTransactionsOnImportCommitted({
importId,
// @ts-ignore
@@ -76,10 +113,8 @@ export class TriggerRecognizedTransactionsSubscriber {
// const importFile = await Import.query().findOne({ importId });
// const batch = importFile.paramsParsed.batch;
// const payload = { transactionsCriteria: { batch } };
// // Cannot continue if the imported resource is not bank account transactions.
// if (importFile.resource !== 'UncategorizedCashflowTransaction') return;
// await this.agenda.now('recognize-uncategorized-transactions-job', payload);
}
}

View File

@@ -1,36 +1,48 @@
// import Container, { Service } from 'typedi';
// import { RecognizeTranasctionsService } from '../commands/RecognizeTranasctions.service';
import { Job } from 'bullmq';
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Scope } from '@nestjs/common';
import { ClsService, UseCls } from 'nestjs-cls';
import { RecognizeTranasctionsService } from '../commands/RecognizeTranasctions.service';
import {
RecognizeUncategorizedTransactionsJobPayload,
RecognizeUncategorizedTransactionsQueue,
} from '../_types';
import { Process } from '@nestjs/bull';
// @Service()
// export class RegonizeTransactionsJob {
// /**
// * Constructor method.
// */
// constructor(agenda) {
// agenda.define(
// 'recognize-uncategorized-transactions-job',
// { priority: 'high', concurrency: 2 },
// this.handler
// );
// }
@Processor({
name: RecognizeUncategorizedTransactionsQueue,
scope: Scope.REQUEST,
})
export class RegonizeTransactionsPrcessor extends WorkerHost {
/**
* @param {RecognizeTranasctionsService} recognizeTranasctionsService -
* @param {ClsService} clsService -
*/
constructor(
private readonly recognizeTranasctionsService: RecognizeTranasctionsService,
private readonly clsService: ClsService,
) {
super();
}
// /**
// * Triggers sending invoice mail.
// */
// private handler = async (job, done: Function) => {
// const { tenantId, ruleId, transactionsCriteria } = job.attrs.data;
// const regonizeTransactions = Container.get(RecognizeTranasctionsService);
/**
* Triggers sending invoice mail.
*/
@Process(RecognizeUncategorizedTransactionsQueue)
@UseCls()
async process(job: Job<RecognizeUncategorizedTransactionsJobPayload>) {
const { ruleId, transactionsCriteria } = job.data;
// try {
// await regonizeTransactions.recognizeTransactions(
// tenantId,
// ruleId,
// transactionsCriteria
// );
// done();
// } catch (error) {
// console.log(error);
// done(error);
// }
// };
// }
this.clsService.set('organizationId', job.data.organizationId);
this.clsService.set('userId', job.data.userId);
try {
await this.recognizeTranasctionsService.recognizeTransactions(
ruleId,
transactionsCriteria,
);
} catch (error) {
console.log(error);
}
}
}

View File

@@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { GetRecognizedTransactionTransformer } from './GetRecognizedTransactionTransformer';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { UncategorizedBankTransaction } from '../models/UncategorizedBankTransaction';
import { UncategorizedBankTransaction } from '../../BankingTransactions/models/UncategorizedBankTransaction';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()

View File

@@ -1,54 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Query,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { BankingTransactionsApplication } from './BankingTransactionsApplication.service';
import {
IBankAccountsFilter,
ICashflowAccountTransactionsQuery,
} from './types/BankingTransactions.types';
import { CreateBankTransactionDto } from './dtos/CreateBankTransaction.dto';
@Controller('banking/transactions')
@ApiTags('banking-transactions')
export class BankingTransactionsController {
constructor(
private readonly bankingTransactionsApplication: BankingTransactionsApplication,
) {}
@Get()
async getBankAccountTransactions(
@Query() query: ICashflowAccountTransactionsQuery,
) {
return this.bankingTransactionsApplication.getBankAccountTransactions(
query,
);
}
@Post()
async createTransaction(@Body() transactionDTO: CreateBankTransactionDto) {
return this.bankingTransactionsApplication.createTransaction(
transactionDTO,
);
}
@Delete(':id')
async deleteTransaction(@Param('id') transactionId: string) {
return this.bankingTransactionsApplication.deleteTransaction(
Number(transactionId),
);
}
@Get(':id')
async getTransaction(@Param('id') transactionId: string) {
return this.bankingTransactionsApplication.getTransaction(
Number(transactionId),
);
}
}

View File

@@ -19,13 +19,19 @@ import { CommandBankTransactionValidator } from './commands/CommandCasflowValida
import { BranchTransactionDTOTransformer } from '../Branches/integrations/BranchTransactionDTOTransform';
import { BranchesModule } from '../Branches/Branches.module';
import { RemovePendingUncategorizedTransaction } from './commands/RemovePendingUncategorizedTransaction.service';
import { BankingTransactionsController } from './BankingTransactions.controller';
import { BankingTransactionsController } from './controllers/BankingTransactions.controller';
import { GetBankAccountsService } from './queries/GetBankAccounts.service';
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
import { BankAccount } from './models/BankAccount';
import { LedgerModule } from '../Ledger/Ledger.module';
import { GetBankAccountTransactionsService } from './queries/GetBankAccountTransactions/GetBankAccountTransactions.service';
import { GetBankAccountTransactionsRepository } from './queries/GetBankAccountTransactions/GetBankAccountTransactionsRepo.service';
import { GetUncategorizedTransactions } from './queries/GetUncategorizedTransactions';
import { GetUncategorizedBankTransactionService } from './queries/GetUncategorizedBankTransaction.service';
import { BankingUncategorizedTransactionsController } from './controllers/BankingUncategorizedTransactions.controller';
import { BankingPendingTransactionsController } from './controllers/BankingPendingTransactions.controller';
import { GetPendingBankAccountTransactions } from './queries/GetPendingBankAccountTransaction.service';
import { GetAutofillCategorizeTransactionService } from './queries/GetAutofillCategorizeTransaction/GetAutofillCategorizeTransaction.service';
const models = [
RegisterTenancyModel(UncategorizedBankTransaction),
@@ -42,7 +48,11 @@ const models = [
DynamicListModule,
...models,
],
controllers: [BankingTransactionsController],
controllers: [
BankingTransactionsController,
BankingUncategorizedTransactionsController,
BankingPendingTransactionsController,
],
providers: [
BankTransactionAutoIncrement,
BankTransactionGLEntriesService,
@@ -61,7 +71,16 @@ const models = [
RemovePendingUncategorizedTransaction,
GetBankAccountTransactionsRepository,
GetBankAccountTransactionsService,
GetUncategorizedTransactions,
GetUncategorizedBankTransactionService,
GetPendingBankAccountTransactions,
GetAutofillCategorizeTransactionService,
],
exports: [
...models,
RemovePendingUncategorizedTransaction,
CommandBankTransactionValidator,
CreateBankTransactionService
],
exports: [...models, RemovePendingUncategorizedTransaction],
})
export class BankingTransactionsModule {}

View File

@@ -9,6 +9,13 @@ import {
import { GetBankAccountsService } from './queries/GetBankAccounts.service';
import { CreateBankTransactionDto } from './dtos/CreateBankTransaction.dto';
import { GetBankAccountTransactionsService } from './queries/GetBankAccountTransactions/GetBankAccountTransactions.service';
import { GetUncategorizedTransactions } from './queries/GetUncategorizedTransactions';
import { GetUncategorizedBankTransactionService } from './queries/GetUncategorizedBankTransaction.service';
import { GetUncategorizedTransactionsQueryDto } from './dtos/GetUncategorizedTransactionsQuery.dto';
import { GetPendingBankAccountTransactions } from './queries/GetPendingBankAccountTransaction.service';
import { GetPendingTransactionsQueryDto } from './dtos/GetPendingTransactionsQuery.dto';
import { GetAutofillCategorizeTransactionService } from './queries/GetAutofillCategorizeTransaction/GetAutofillCategorizeTransaction.service';
import { GetBankTransactionsQueryDto } from './dtos/GetBankTranasctionsQuery.dto';
@Injectable()
export class BankingTransactionsApplication {
@@ -18,6 +25,10 @@ export class BankingTransactionsApplication {
private readonly getCashflowTransactionService: GetBankTransactionService,
private readonly getBankAccountsService: GetBankAccountsService,
private readonly getBankAccountTransactionsService: GetBankAccountTransactionsService,
private readonly getBankAccountUncategorizedTransitionsService: GetUncategorizedTransactions,
private readonly getBankAccountUncategorizedTransactionService: GetUncategorizedBankTransactionService,
private readonly getPendingBankAccountTransactionsService: GetPendingBankAccountTransactions,
private readonly getAutofillCategorizeTransactionService: GetAutofillCategorizeTransactionService,
) {}
/**
@@ -44,7 +55,7 @@ export class BankingTransactionsApplication {
* Retrieves the bank transactions of the given bank id.
* @param {ICashflowAccountTransactionsQuery} query
*/
public getBankAccountTransactions(query: ICashflowAccountTransactionsQuery) {
public getBankAccountTransactions(query: GetBankTransactionsQueryDto) {
return this.getBankAccountTransactionsService.bankAccountTransactions(
query,
);
@@ -68,4 +79,53 @@ export class BankingTransactionsApplication {
public getBankAccounts(filterDTO: IBankAccountsFilter) {
return this.getBankAccountsService.getBankAccounts(filterDTO);
}
/**
* Retrieves the uncategorized cashflow transactions.
* @param {number} accountId - Account id.
* @param {IGetUncategorizedTransactionsQuery} query - Query.
*/
public getBankAccountUncategorizedTransactions(
accountId: number,
query: GetUncategorizedTransactionsQueryDto,
) {
return this.getBankAccountUncategorizedTransitionsService.getTransactions(
accountId,
query,
);
}
/**
* Retrieves specific uncategorized cashflow transaction.
* @param {number} uncategorizedTransactionId - Uncategorized transaction id.
*/
public getUncategorizedTransaction(uncategorizedTransactionId: number) {
return this.getBankAccountUncategorizedTransactionService.getTransaction(
uncategorizedTransactionId,
);
}
/**
* Retrieves the pending bank account transactions.
* @param {GetPendingTransactionsQueryDto} filter - Pending transactions query.
*/
public getPendingBankAccountTransactions(
filter?: GetPendingTransactionsQueryDto,
) {
return this.getPendingBankAccountTransactionsService.getPendingTransactions(
filter,
);
}
/**
* Retrieves the autofill values of categorize transactions form.
* @param {Array<number> | number} uncategorizeTransactionsId - Uncategorized transactions ids.
*/
public getAutofillCategorizeTransaction(
uncategorizeTransactionsId: Array<number> | number,
) {
return this.getAutofillCategorizeTransactionService.getAutofillCategorizeTransaction(
uncategorizeTransactionsId,
);
}
}

View File

@@ -1,3 +1,4 @@
import { Injectable } from '@nestjs/common';
import { includes, camelCase, upperFirst, sumBy } from 'lodash';
import { getCashflowTransactionType } from '../utils';
import {
@@ -6,7 +7,6 @@ import {
ERRORS,
} from '../constants';
import { Account } from '@/modules/Accounts/models/Account.model';
import { Injectable } from '@nestjs/common';
import { ServiceError } from '@/modules/Items/ServiceError';
import { BankTransaction } from '../models/BankTransaction';
import { UncategorizedBankTransaction } from '../models/UncategorizedBankTransaction';

View File

@@ -1,7 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { pick } from 'lodash';
import { Knex } from 'knex';
import * as R from 'ramda';
import * as composeAsync from 'async/compose';
import { CASHFLOW_TRANSACTION_TYPE } from '../constants';
import { transformCashflowTransactionType } from '../utils';
@@ -14,12 +13,12 @@ import { events } from '@/common/events/events';
import { Account } from '@/modules/Accounts/models/Account.model';
import { BankTransaction } from '../models/BankTransaction';
import {
ICashflowNewCommandDTO,
ICommandCashflowCreatedPayload,
ICommandCashflowCreatingPayload,
} from '../types/BankingTransactions.types';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { CreateBankTransactionDto } from '../dtos/CreateBankTransaction.dto';
import { formatDateFields } from '@/utils/format-date-fields';
@Injectable()
export class CreateBankTransactionService {
@@ -42,7 +41,7 @@ export class CreateBankTransactionService {
* @param {ICashflowNewCommandDTO} newCashflowTransactionDTO
*/
public authorize = async (
newCashflowTransactionDTO: ICashflowNewCommandDTO,
newCashflowTransactionDTO: CreateBankTransactionDto,
creditAccount: Account,
) => {
const transactionType = transformCashflowTransactionType(
@@ -60,7 +59,7 @@ export class CreateBankTransactionService {
/**
* Transformes owner contribution DTO to cashflow transaction.
* @param {ICashflowNewCommandDTO} newCashflowTransactionDTO - New transaction DTO.
* @param {CreateBankTransactionDto} newCashflowTransactionDTO - New transaction DTO.
* @returns {ICashflowTransactionInput} - Cashflow transaction object.
*/
private transformCashflowTransactionDTO = async (
@@ -91,7 +90,7 @@ export class CreateBankTransactionService {
const initialDTO = {
amount,
...fromDTO,
...formatDateFields(fromDTO, ['date']),
transactionNumber,
currencyCode: cashflowAccount.currencyCode,
exchangeRate: fromDTO?.exchangeRate || 1,
@@ -117,7 +116,7 @@ export class CreateBankTransactionService {
* @returns {Promise<ICashflowTransaction>}
*/
public newCashflowTransaction = async (
newTransactionDTO: ICashflowNewCommandDTO,
newTransactionDTO: CreateBankTransactionDto,
userId?: number,
): Promise<BankTransaction> => {
// Retrieves the cashflow account or throw not found error.

View File

@@ -0,0 +1,42 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { BankingTransactionsApplication } from '../BankingTransactionsApplication.service';
import { GetPendingTransactionsQueryDto } from '../dtos/GetPendingTransactionsQuery.dto';
@Controller('banking/pending')
@ApiTags('banking-pending')
export class BankingPendingTransactionsController {
constructor(
private readonly bankingTransactionsApplication: BankingTransactionsApplication,
) {}
@Get()
@ApiOperation({ summary: 'Get pending bank account transactions' })
@ApiResponse({
status: 200,
description: 'Returns a list of pending bank account transactions',
})
@ApiQuery({
name: 'page',
required: false,
type: Number,
description: 'Page number for pagination',
})
@ApiQuery({
name: 'pageSize',
required: false,
type: Number,
description: 'Number of items per page',
})
@ApiQuery({
name: 'accountId',
required: false,
type: Number,
description: 'Filter by bank account ID',
})
async getPendingTransactions(@Query() query: GetPendingTransactionsQueryDto) {
return this.bankingTransactionsApplication.getPendingBankAccountTransactions(
query,
);
}
}

View File

@@ -0,0 +1,108 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Query,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger';
import { BankingTransactionsApplication } from '../BankingTransactionsApplication.service';
import { CreateBankTransactionDto } from '../dtos/CreateBankTransaction.dto';
import { GetBankTransactionsQueryDto } from '../dtos/GetBankTranasctionsQuery.dto';
@Controller('banking/transactions')
@ApiTags('banking-transactions')
export class BankingTransactionsController {
constructor(
private readonly bankingTransactionsApplication: BankingTransactionsApplication,
) {}
@Get()
@ApiOperation({ summary: 'Get bank account transactions' })
@ApiResponse({
status: 200,
description: 'Returns a list of bank account transactions',
})
@ApiQuery({
name: 'page',
required: false,
type: Number,
description: 'Page number for pagination',
})
@ApiQuery({
name: 'pageSize',
required: false,
type: Number,
description: 'Number of items per page',
})
async getBankAccountTransactions(
@Query() query: GetBankTransactionsQueryDto,
) {
return this.bankingTransactionsApplication.getBankAccountTransactions(
query,
);
}
@Post()
@ApiOperation({ summary: 'Create a new bank transaction' })
@ApiResponse({
status: 201,
description: 'The bank transaction has been successfully created',
})
@ApiResponse({
status: 400,
description: 'Invalid input data',
})
@ApiBody({ type: CreateBankTransactionDto })
async createTransaction(@Body() transactionDTO: CreateBankTransactionDto) {
return this.bankingTransactionsApplication.createTransaction(
transactionDTO,
);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a bank transaction' })
@ApiResponse({
status: 200,
description: 'The bank transaction has been successfully deleted',
})
@ApiResponse({
status: 404,
description: 'Bank transaction not found',
})
@ApiParam({
name: 'id',
required: true,
type: String,
description: 'Bank transaction ID',
})
async deleteTransaction(@Param('id') transactionId: string) {
return this.bankingTransactionsApplication.deleteTransaction(
Number(transactionId),
);
}
@Get(':id')
@ApiOperation({ summary: 'Get a specific bank transaction by ID' })
@ApiResponse({
status: 200,
description: 'Returns the bank transaction details',
})
@ApiResponse({
status: 404,
description: 'Bank transaction not found',
})
@ApiParam({
name: 'id',
required: true,
type: String,
description: 'Bank transaction ID',
})
async getTransaction(@Param('id') transactionId: string) {
return this.bankingTransactionsApplication.getTransaction(
Number(transactionId),
);
}
}

View File

@@ -0,0 +1,106 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiQuery,
} from '@nestjs/swagger';
import { GetUncategorizedTransactionsQueryDto } from '../dtos/GetUncategorizedTransactionsQuery.dto';
import { BankingTransactionsApplication } from '../BankingTransactionsApplication.service';
@Controller('banking/uncategorized')
@ApiTags('banking-uncategorized')
export class BankingUncategorizedTransactionsController {
constructor(
private readonly bankingTransactionsApplication: BankingTransactionsApplication,
) {}
@Get('autofill')
@ApiOperation({ summary: 'Get autofill values for categorize transactions' })
@ApiResponse({
status: 200,
description: 'Returns autofill values for categorize transactions',
})
@ApiParam({
name: 'accountId',
required: true,
type: Number,
description: 'Bank account ID',
})
@ApiQuery({
name: 'uncategorizeTransactionsId',
required: true,
type: Number,
description: 'Uncategorize transactions ID',
})
async getAutofillCategorizeTransaction(
@Query('uncategorizedTransactionIds') uncategorizedTransactionIds: Array<number> | number,
) {
console.log(uncategorizedTransactionIds)
return this.bankingTransactionsApplication.getAutofillCategorizeTransaction(
uncategorizedTransactionIds,
);
}
@Get('accounts/:accountId')
@ApiOperation({
summary: 'Get uncategorized transactions for a specific bank account',
})
@ApiResponse({
status: 200,
description:
'Returns a list of uncategorized transactions for the specified bank account',
})
@ApiParam({
name: 'accountId',
required: true,
type: Number,
description: 'Bank account ID',
})
@ApiQuery({
name: 'page',
required: false,
type: Number,
description: 'Page number for pagination',
})
@ApiQuery({
name: 'pageSize',
required: false,
type: Number,
description: 'Number of items per page',
})
async getBankAccountUncategorizedTransactions(
@Param('accountId') accountId: number,
@Query() query: GetUncategorizedTransactionsQueryDto,
) {
return this.bankingTransactionsApplication.getBankAccountUncategorizedTransactions(
accountId,
query,
);
}
@Get(':uncategorizedTransactionId')
@ApiOperation({ summary: 'Get a specific uncategorized transaction by ID' })
@ApiResponse({
status: 200,
description: 'Returns the uncategorized transaction details',
})
@ApiResponse({
status: 404,
description: 'Uncategorized transaction not found',
})
@ApiParam({
name: 'uncategorizedTransactionId',
required: true,
type: Number,
description: 'Uncategorized transaction ID',
})
async getUncategorizedTransaction(
@Param('uncategorizedTransactionId') uncategorizedTransactionId: number,
) {
return this.bankingTransactionsApplication.getUncategorizedTransaction(
Number(uncategorizedTransactionId),
);
}
}

View File

@@ -1,58 +1,153 @@
import { ToNumber } from '@/common/decorators/Validators';
import {
IsBoolean,
IsDate,
IsDateString,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateBankTransactionDto {
@IsDate()
@ApiProperty({
description: 'The date of the bank transaction',
type: Date,
example: '2023-01-01T00:00:00.000Z',
})
@IsDateString()
@IsNotEmpty()
date: Date;
@ApiPropertyOptional({
description: 'Optional transaction number or reference',
type: String,
example: 'TRX-001',
})
@IsString()
transactionNumber: string;
@IsOptional()
transactionNumber?: string;
@ApiPropertyOptional({
description: 'Optional external reference number',
type: String,
example: 'REF-001',
})
@IsString()
referenceNo: string;
@IsOptional()
referenceNo?: string;
@ApiProperty({
description: 'Type of bank transaction (e.g., deposit, withdrawal)',
type: String,
example: 'deposit',
})
@IsNotEmpty()
@IsString()
transactionType: string;
@ApiProperty({
description: 'Description of the bank transaction',
type: String,
example: 'Monthly rent payment',
})
@IsString()
description: string;
@ApiProperty({
description: 'Transaction amount',
type: Number,
example: 1000.5,
})
@IsNotEmpty()
@ToNumber()
@IsNumber()
amount: number;
@ApiProperty({
description: 'Exchange rate for currency conversion',
type: Number,
default: 1,
example: 1.15,
})
@ToNumber()
@IsNumber()
exchangeRate: number;
exchangeRate: number = 1;
@ApiPropertyOptional({
description: 'Currency code for the transaction',
type: String,
example: 'USD',
})
@IsString()
@IsOptional()
currencyCode: string;
@IsNumber()
@ApiProperty({
description: 'ID of the credit account associated with this transaction',
type: Number,
example: 1001,
})
@IsNotEmpty()
@ToNumber()
@IsInt()
creditAccountId: number;
@IsNumber()
@ApiProperty({
description: 'ID of the cashflow account associated with this transaction',
type: Number,
example: 2001,
})
@IsNotEmpty()
@ToNumber()
@IsInt()
cashflowAccountId: number;
@ApiProperty({
description: 'Whether the transaction should be published',
type: Boolean,
default: true,
})
@IsBoolean()
publish: boolean;
@IsOptional()
@IsNumber()
publish: boolean = true;
@ApiPropertyOptional({
description: 'ID of the branch where the transaction occurred',
type: Number,
example: 101,
})
@IsOptional()
@ToNumber()
@IsInt()
branchId?: number;
@ApiPropertyOptional({
description: 'Plaid transaction ID if imported from Plaid',
type: String,
example: 'plaid_trx_12345',
})
@IsOptional()
@IsString()
plaidTransactionId?: string;
@ApiPropertyOptional({
description: 'Plaid account ID if imported from Plaid',
type: String,
example: 'plaid_acc_67890',
})
@IsOptional()
@IsString()
plaidAccountId?: string;
@ApiPropertyOptional({
description:
'ID of the uncategorized transaction if this is categorizing an existing transaction',
type: Number,
example: 5001,
})
@IsOptional()
@IsNumber()
@IsInt()
uncategorizedTransactionId?: number;
}

View File

@@ -0,0 +1,52 @@
import {
IsNotEmpty,
IsNumber,
IsNumberString,
IsOptional,
} from 'class-validator';
import { NumberFormatQueryDto } from './NumberFormatQuery.dto';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
export class GetBankTransactionsQueryDto {
@IsOptional()
@Type(() => Number)
@IsNumber()
@ApiProperty({
description: 'Page number for pagination',
required: false,
type: Number,
example: 1
})
page: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@ApiProperty({
description: 'Number of items per page',
required: false,
type: Number,
example: 10
})
pageSize: number;
@IsNotEmpty()
@Type(() => Number)
@IsNumber()
@ApiProperty({
description: 'Bank account ID',
required: true,
type: Number,
example: 1
})
accountId: number;
@IsOptional()
@ApiProperty({
description: 'Number format options',
required: false,
type: NumberFormatQueryDto
})
numberFormat: NumberFormatQueryDto;
}

View File

@@ -0,0 +1,31 @@
import { IsOptional } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
export class GetPendingTransactionsQueryDto {
@IsOptional()
@ApiProperty({
description: 'Page number for pagination',
required: false,
type: Number,
example: 1
})
page?: number;
@IsOptional()
@ApiProperty({
description: 'Number of items per page',
required: false,
type: Number,
example: 10
})
pageSize?: number;
@IsOptional()
@ApiProperty({
description: 'Filter by bank account ID',
required: false,
type: Number,
example: 1
})
accountId?: number;
}

View File

@@ -0,0 +1,58 @@
import { IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class GetUncategorizedTransactionsQueryDto {
@IsOptional()
@ApiProperty({
description: 'Page number for pagination',
required: false,
type: Number,
example: 1
})
page?: number;
@IsOptional()
@ApiProperty({
description: 'Number of items per page',
required: false,
type: Number,
example: 10
})
pageSize?: number;
@IsOptional()
@ApiProperty({
description: 'Minimum date for filtering transactions',
required: false,
type: Date,
example: '2023-01-01'
})
minDate?: Date;
@IsOptional()
@ApiProperty({
description: 'Maximum date for filtering transactions',
required: false,
type: Date,
example: '2023-12-31'
})
maxDate?: Date;
@IsOptional()
@ApiProperty({
description: 'Minimum amount for filtering transactions',
required: false,
type: Number,
example: 100
})
minAmount?: number;
@IsOptional()
@ApiProperty({
description: 'Maximum amount for filtering transactions',
required: false,
type: Number,
example: 1000
})
maxAmount?: number;
}

View File

@@ -0,0 +1,26 @@
import { Type } from "class-transformer";
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsPositive } from "class-validator";
export class NumberFormatQueryDto {
@Type(() => Number)
@IsNumber()
@IsPositive()
@IsOptional()
readonly precision: number;
@IsBoolean()
@IsOptional()
readonly divideOn1000: boolean;
@IsBoolean()
@IsOptional()
readonly showZero: boolean;
@IsEnum(['total', 'always', 'none'])
@IsOptional()
readonly formatMoney: 'total' | 'always' | 'none';
@IsEnum(['parentheses', 'mines'])
@IsOptional()
readonly negativeFormat: 'parentheses' | 'mines';
}

View File

@@ -1,9 +1,9 @@
/* eslint-disable global-require */
import * as moment from 'moment';
import { Model } from 'objection';
import { BaseModel } from '@/models/Model';
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
export class UncategorizedBankTransaction extends BaseModel {
export class UncategorizedBankTransaction extends TenantBaseModel {
readonly amount!: number;
readonly date!: Date | string;
readonly categorized!: boolean;

View File

@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { getBankAccountTransactionsDefaultQuery } from './_utils';
import { GetBankAccountTransactionsRepository } from './GetBankAccountTransactionsRepo.service';
import { GetBankAccountTransactions } from './GetBankAccountTransactions';
import { ICashflowAccountTransactionsQuery } from '../../types/BankingTransactions.types';
import { GetBankTransactionsQueryDto } from '../../dtos/GetBankTranasctionsQuery.dto';
@Injectable()
export class GetBankAccountTransactionsService {
@@ -16,7 +16,7 @@ export class GetBankAccountTransactionsService {
* @return {Promise<IInvetoryItemDetailDOO>}
*/
public async bankAccountTransactions(
query: ICashflowAccountTransactionsQuery,
query: GetBankTransactionsQueryDto,
) {
const parsedQuery = {
...getBankAccountTransactionsDefaultQuery(),

View File

@@ -1,6 +1,6 @@
// @ts-nocheck
import R from 'ramda';
import moment from 'moment';
import * as R from 'ramda';
import * as moment from 'moment';
import { first, isEmpty } from 'lodash';
import {
ICashflowAccountTransaction,

View File

@@ -1,13 +1,16 @@
import { Injectable, Scope } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { ICashflowAccountTransactionsQuery } from '../../types/BankingTransactions.types';
import {
groupMatchedBankTransactions,
groupUncategorizedTransactions,
} from './_utils';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { AccountTransaction } from '@/modules/Accounts/models/AccountTransaction.model';
import { UncategorizedBankTransaction } from '../../models/UncategorizedBankTransaction';
import { MatchedBankTransaction } from '@/modules/BankingMatching/models/MatchedBankTransaction';
@Injectable({ scope: Scope.REQUEST })
export class GetBankAccountTransactionsRepository {
private models: any;
public query: ICashflowAccountTransactionsQuery;
public transactions: any;
public uncategorizedTransactions: any;
@@ -17,6 +20,28 @@ export class GetBankAccountTransactionsRepository {
public pagination: any;
public openingBalance: any;
/**
* @param {TenantModelProxy<typeof AccountTransaction>} accountTransactionModel - Account transaction model.
* @param {TenantModelProxy<typeof UncategorizedBankTransaction>} uncategorizedBankTransactionModel - Uncategorized transaction model
* @param {TenantModelProxy<typeof MatchedBankTransaction>} matchedBankTransactionModel - Matched bank transaction model.
*/
constructor(
@Inject(AccountTransaction.name)
private readonly accountTransactionModel: TenantModelProxy<
typeof AccountTransaction
>,
@Inject(UncategorizedBankTransaction.name)
private readonly uncategorizedBankTransactionModel: TenantModelProxy<
typeof UncategorizedBankTransaction
>,
@Inject(MatchedBankTransaction.name)
private readonly matchedBankTransactionModel: TenantModelProxy<
typeof MatchedBankTransaction
>,
) {}
setQuery(query: ICashflowAccountTransactionsQuery) {
this.query = query;
}
@@ -37,9 +62,8 @@ export class GetBankAccountTransactionsRepository {
* @param {ICashflowAccountTransactionsQuery} query -
*/
async initCashflowAccountTransactions() {
const { AccountTransaction } = this.models;
const { results, pagination } = await AccountTransaction.query()
const { results, pagination } = await this.accountTransactionModel()
.query()
.where('account_id', this.query.accountId)
.orderBy([
{ column: 'date', order: 'desc' },
@@ -59,10 +83,9 @@ export class GetBankAccountTransactionsRepository {
* @return {Promise<number>}
*/
async initCashflowAccountOpeningBalance(): Promise<void> {
const { AccountTransaction } = this.models;
// Retrieve the opening balance of credit and debit balances.
const openingBalancesSubquery = AccountTransaction.query()
const openingBalancesSubquery = this.accountTransactionModel()
.query()
.where('account_id', this.query.accountId)
.orderBy([
{ column: 'date', order: 'desc' },
@@ -72,7 +95,8 @@ export class GetBankAccountTransactionsRepository {
.offset(this.pagination.pageSize * (this.pagination.page - 1));
// Sumation of credit and debit balance.
const openingBalances = await AccountTransaction.query()
const openingBalances = await this.accountTransactionModel()
.query()
.sum('credit as credit')
.sum('debit as debit')
.from(openingBalancesSubquery.as('T'))
@@ -87,14 +111,11 @@ export class GetBankAccountTransactionsRepository {
* Initialize the uncategorized transactions of the bank account.
*/
async initCategorizedTransactions() {
const { UncategorizedCashflowTransaction } = this.models;
const refs = this.transactions.map((t) => [t.referenceType, t.referenceId]);
const uncategorizedTransactions =
await UncategorizedCashflowTransaction.query().whereIn(
['categorizeRefType', 'categorizeRefId'],
refs,
);
await this.uncategorizedBankTransactionModel()
.query()
.whereIn(['categorizeRefType', 'categorizeRefId'], refs);
this.uncategorizedTransactions = uncategorizedTransactions;
this.uncategorizedTransactionsMapByRef = groupUncategorizedTransactions(
@@ -106,14 +127,11 @@ export class GetBankAccountTransactionsRepository {
* Initialize the matched bank transactions of the bank account.
*/
async initMatchedTransactions(): Promise<void> {
const { MatchedBankTransaction } = this.models;
const refs = this.transactions.map((t) => [t.referenceType, t.referenceId]);
const matchedBankTransactions =
await MatchedBankTransaction.query().whereIn(
['referenceType', 'referenceId'],
refs,
);
const matchedBankTransactions = await this.matchedBankTransactionModel()
.query()
.whereIn(['referenceType', 'referenceId'], refs);
this.matchedBankTransactions = matchedBankTransactions;
this.matchedBankTransactionsMapByRef = groupMatchedBankTransactions(
matchedBankTransactions,

View File

@@ -2,6 +2,8 @@ import { Inject, Injectable } from '@nestjs/common';
import { GetPendingBankAccountTransactionTransformer } from './GetPendingBankAccountTransactionTransformer';
import { UncategorizedBankTransaction } from '../models/UncategorizedBankTransaction';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { GetPendingTransactionsQueryDto } from '../dtos/GetPendingTransactionsQuery.dto';
@Injectable()
export class GetPendingBankAccountTransactions {
@@ -9,40 +11,37 @@ export class GetPendingBankAccountTransactions {
private readonly transformerService: TransformerInjectable,
@Inject(UncategorizedBankTransaction.name)
private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction
private readonly uncategorizedBankTransactionModel: TenantModelProxy<
typeof UncategorizedBankTransaction
>,
) {}
/**
* Retrieves the given bank accounts pending transaction.
* @param {GetPendingTransactionsQuery} filter - Pending transactions query.
* @param {GetPendingTransactionsQueryDto} filter - Pending transactions query.
*/
async getPendingTransactions(filter?: GetPendingTransactionsQuery) {
async getPendingTransactions(filter?: GetPendingTransactionsQueryDto) {
const _filter = {
page: 1,
page: 1,
pageSize: 20,
...filter,
};
const { results, pagination } =
await this.uncategorizedBankTransactionModel.query()
await this.uncategorizedBankTransactionModel()
.query()
.onBuild((q) => {
q.modify('pending');
if (_filter?.accountId) {
q.where('accountId', _filter.accountId);
}
})
.pagination(_filter.page - 1, _filter.pageSize);
if (_filter?.accountId) {
q.where('accountId', _filter.accountId);
}
})
.pagination(_filter.page - 1, _filter.pageSize);
const data = await this.transformerService.transform(
results,
new GetPendingBankAccountTransactionTransformer()
new GetPendingBankAccountTransactionTransformer(),
);
return { data, pagination };
}
}
interface GetPendingTransactionsQuery {
page?: number;
pageSize?: number;
accountId?: number;
}

View File

@@ -1,26 +1,33 @@
import { Inject, Injectable } from '@nestjs/common';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { UncategorizedBankTransaction } from '../models/UncategorizedBankTransaction';
import { Inject, Injectable } from '@nestjs/common';
import { UncategorizedTransactionTransformer } from '../../BankingCategorize/commands/UncategorizedTransaction.transformer';
import { IGetUncategorizedTransactionsQuery } from '../types/BankingTransactions.types';
import { GetUncategorizedTransactionsQueryDto } from '../dtos/GetUncategorizedTransactionsQuery.dto';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class GetUncategorizedTransactions {
/**
* @param {TransformerInjectable} transformer
* @param {UncategorizedBankTransaction.name} uncategorizedBankTransactionModel
*/
constructor(
private readonly transformer: TransformerInjectable,
@Inject(UncategorizedBankTransaction.name)
private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction,
private readonly uncategorizedBankTransactionModel: TenantModelProxy<
typeof UncategorizedBankTransaction
>,
) {}
/**
* Retrieves the uncategorized cashflow transactions.
* @param {number} tenantId - Tenant id.
* @param {number} accountId - Account Id.
* @param {IGetUncategorizedTransactionsQuery} query - Query.
*/
public async getTransactions(
accountId: number,
query: IGetUncategorizedTransactionsQuery
query: GetUncategorizedTransactionsQueryDto,
) {
// Parsed query with default values.
const _query = {
@@ -28,9 +35,9 @@ export class GetUncategorizedTransactions {
pageSize: 20,
...query,
};
const { results, pagination } =
await this.uncategorizedBankTransactionModel.query()
await this.uncategorizedBankTransactionModel()
.query()
.onBuild((q) => {
q.where('accountId', accountId);
q.where('categorized', false);
@@ -63,7 +70,7 @@ export class GetUncategorizedTransactions {
const data = await this.transformer.transform(
results,
new UncategorizedTransactionTransformer()
new UncategorizedTransactionTransformer(),
);
return {
data,

View File

@@ -3,7 +3,10 @@ import { OnEvent } from '@nestjs/event-emitter';
import { BankTransactionAutoIncrement } from '../commands/BankTransactionAutoIncrement.service';
import { BankTransactionGLEntriesService } from '../commands/BankTransactionGLEntries';
import { events } from '@/common/events/events';
import { ICommandCashflowCreatedPayload, ICommandCashflowDeletedPayload } from '../types/BankingTransactions.types';
import {
ICommandCashflowCreatedPayload,
ICommandCashflowDeletedPayload,
} from '../types/BankingTransactions.types';
@Injectable()
export class BankingTransactionGLEntriesSubscriber {
@@ -56,5 +59,5 @@ export class BankingTransactionGLEntriesSubscriber {
cashflowTransactionId,
trx,
);
};
}
}

View File

@@ -8,12 +8,13 @@ import { Inject, Injectable } from '@nestjs/common';
import { events } from '@/common/events/events';
import { Account } from '@/modules/Accounts/models/Account.model';
import { UncategorizedBankTransaction } from '../models/UncategorizedBankTransaction';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class DecrementUncategorizedTransactionOnCategorizeSubscriber {
constructor(
@Inject(Account.name)
private readonly accountModel: typeof Account,
private readonly accountModel: TenantModelProxy<typeof Account>,
) {}
/**
@@ -33,7 +34,7 @@ export class DecrementUncategorizedTransactionOnCategorizeSubscriber {
if (uncategorizedTransaction.isPending) {
return;
}
await this.accountModel
await this.accountModel()
.query(trx)
.findById(uncategorizedTransaction.accountId)
.decrement('uncategorizedTransactions', 1);
@@ -58,7 +59,7 @@ export class DecrementUncategorizedTransactionOnCategorizeSubscriber {
if (uncategorizedTransaction.isPending) {
return;
}
await this.accountModel
await this.accountModel()
.query(trx)
.findById(uncategorizedTransaction.accountId)
.increment('uncategorizedTransactions', 1);
@@ -80,7 +81,7 @@ export class DecrementUncategorizedTransactionOnCategorizeSubscriber {
// Cannot continue if the transaction is still pending.
if (uncategorizedTransaction.isPending) return;
await this.accountModel
await this.accountModel()
.query(trx)
.findById(uncategorizedTransaction.accountId)
.increment('uncategorizedTransactions', 1);

View File

@@ -5,19 +5,34 @@ import {
Get,
Param,
Post,
Put,
Query,
} from '@nestjs/common';
import { ExcludeBankTransactionsApplication } from './ExcludeBankTransactionsApplication';
import { ExcludedBankTransactionsQuery } from './types/BankTransactionsExclude.types';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
@Controller('banking/transactions')
@Controller('banking/exclude')
@ApiTags('banking-transactions')
export class BankingTransactionsExcludeController {
constructor(
private readonly excludeBankTransactionsApplication: ExcludeBankTransactionsApplication,
) {}
@Put('bulk')
@ApiOperation({ summary: 'Exclude the given bank transactions.' })
public excludeBankTransactions(@Body('ids') ids: number[]) {
return this.excludeBankTransactionsApplication.excludeBankTransactions(ids);
}
@Delete('bulk')
@ApiOperation({ summary: 'Unexclude the given bank transactions.' })
public unexcludeBankTransactions(@Body('ids') ids: number[]) {
return this.excludeBankTransactionsApplication.unexcludeBankTransactions(
ids,
);
}
@Get()
@ApiOperation({ summary: 'Retrieves the excluded bank transactions.' })
public getExcludedBankTransactions(
@@ -28,7 +43,7 @@ export class BankingTransactionsExcludeController {
);
}
@Post(':id/exclude')
@Put(':id')
@ApiOperation({ summary: 'Exclude the given bank transaction.' })
public excludeBankTransaction(@Param('id') id: string) {
return this.excludeBankTransactionsApplication.excludeBankTransaction(
@@ -36,25 +51,11 @@ export class BankingTransactionsExcludeController {
);
}
@Delete(':id/exclude')
@Delete(':id')
@ApiOperation({ summary: 'Unexclude the given bank transaction.' })
public unexcludeBankTransaction(@Param('id') id: string) {
return this.excludeBankTransactionsApplication.unexcludeBankTransaction(
Number(id),
);
}
@Post('bulk/exclude')
@ApiOperation({ summary: 'Exclude the given bank transactions.' })
public excludeBankTransactions(@Body('ids') ids: number[]) {
return this.excludeBankTransactionsApplication.excludeBankTransactions(ids);
}
@Delete('bulk/exclude')
@ApiOperation({ summary: 'Unexclude the given bank transactions.' })
public unexcludeBankTransactions(@Body('ids') ids: number[]) {
return this.excludeBankTransactionsApplication.unexcludeBankTransactions(
ids,
);
}
}

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { ExcludeBankTransactionsApplication } from './ExcludeBankTransactionsApplication';
import { ExcludeBankTransactionService } from './commands/ExcludeBankTransaction.service';
import { UnexcludeBankTransactionService } from './commands/UnexcludeBankTransaction.service';
@@ -10,7 +10,9 @@ import { BankingTransactionsExcludeController } from './BankingTransactionsExclu
import { BankingTransactionsModule } from '../BankingTransactions/BankingTransactions.module';
@Module({
imports: [BankingTransactionsModule],
imports: [
forwardRef(() => BankingTransactionsModule),
],
providers: [
ExcludeBankTransactionsApplication,
ExcludeBankTransactionService,

View File

@@ -0,0 +1,191 @@
import { Inject } from '@nestjs/common';
import { difference, sumBy } from 'lodash';
import {
ILandedCostItemDTO,
ILandedCostDTO,
IBillLandedCostTransaction,
ILandedCostTransaction,
ILandedCostTransactionEntry,
} from './types/BillLandedCosts.types';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { BillLandedCost } from './models/BillLandedCost';
import { ServiceError } from '../Items/ServiceError';
import { CONFIG, ERRORS } from './utils';
import { ItemEntry } from '../TransactionItemEntry/models/ItemEntry';
import { Bill } from '../Bills/models/Bill';
import { TransactionLandedCost } from './commands/TransctionLandedCost.service';
export class BaseLandedCostService {
@Inject()
public readonly transactionLandedCost: TransactionLandedCost;
@Inject(BillLandedCost.name)
private readonly billLandedCostModel: TenantModelProxy<typeof BillLandedCost>;
/**
* Validates allocate cost items association with the purchase invoice entries.
* @param {IItemEntry[]} purchaseInvoiceEntries
* @param {ILandedCostItemDTO[]} landedCostItems
*/
protected validateAllocateCostItems = (
purchaseInvoiceEntries: ItemEntry[],
landedCostItems: ILandedCostItemDTO[],
): void => {
// Purchase invoice entries items ids.
const purchaseInvoiceItems = purchaseInvoiceEntries.map((e) => e.id);
const landedCostItemsIds = landedCostItems.map((item) => item.entryId);
// Not found items ids.
const notFoundItemsIds = difference(
purchaseInvoiceItems,
landedCostItemsIds,
);
// Throw items ids not found service error.
if (notFoundItemsIds.length > 0) {
throw new ServiceError(ERRORS.LANDED_COST_ITEMS_IDS_NOT_FOUND);
}
};
/**
* Transformes DTO to bill landed cost model object.
* @param {ILandedCostDTO} landedCostDTO
* @param {IBill} bill
* @param {ILandedCostTransaction} costTransaction
* @param {ILandedCostTransactionEntry} costTransactionEntry
* @returns
*/
protected transformToBillLandedCost(
landedCostDTO: ILandedCostDTO,
bill: Bill,
costTransaction: ILandedCostTransaction,
costTransactionEntry: ILandedCostTransactionEntry,
) {
const amount = sumBy(landedCostDTO.items, 'cost');
return {
billId: bill.id,
fromTransactionType: landedCostDTO.transactionType,
fromTransactionId: landedCostDTO.transactionId,
fromTransactionEntryId: landedCostDTO.transactionEntryId,
amount,
currencyCode: costTransaction.currencyCode,
exchangeRate: costTransaction.exchangeRate || 1,
allocationMethod: landedCostDTO.allocationMethod,
allocateEntries: landedCostDTO.items,
description: landedCostDTO.description,
costAccountId: costTransactionEntry.costAccountId,
};
}
/**
* Retrieve the cost transaction or throw not found error.
* @param {number} tenantId
* @param {transactionType} transactionType -
* @param {transactionId} transactionId -
*/
public getLandedCostOrThrowError = async (
transactionType: string,
transactionId: number,
) => {
const Model = this.transactionLandedCost.getModel(
transactionType,
);
const model = await Model.query().findById(transactionId);
if (!model) {
throw new ServiceError(ERRORS.LANDED_COST_TRANSACTION_NOT_FOUND);
}
return this.transactionLandedCost.transformToLandedCost(
transactionType,
model,
);
};
/**
* Retrieve the landed cost entries.
* @param {number} tenantId
* @param {string} transactionType
* @param {number} transactionId
* @returns
*/
public getLandedCostEntry = async (
transactionType: string,
transactionId: number,
transactionEntryId: number,
): Promise<any> => {
const Model = this.transactionLandedCost.getModel(
tenantId,
transactionType,
);
const relation = CONFIG.COST_TYPES[transactionType].entries;
const entry = await Model.relatedQuery(relation)
.for(transactionId)
.findOne('id', transactionEntryId)
.where('landedCost', true)
.onBuild((q) => {
if (transactionType === 'Bill') {
q.withGraphFetched('item');
} else if (transactionType === 'Expense') {
q.withGraphFetched('expenseAccount');
}
});
if (!entry) {
throw new ServiceError(ERRORS.LANDED_COST_ENTRY_NOT_FOUND);
}
return this.transactionLandedCost.transformToLandedCostEntry(
transactionType,
entry,
);
};
/**
* Retrieve allocate items cost total.
* @param {ILandedCostDTO} landedCostDTO
* @returns {number}
*/
protected getAllocateItemsCostTotal = (
landedCostDTO: ILandedCostDTO,
): number => {
return sumBy(landedCostDTO.items, 'cost');
};
/**
* Validates the landed cost entry amount.
* @param {number} unallocatedCost -
* @param {number} amount -
*/
protected validateLandedCostEntryAmount = (
unallocatedCost: number,
amount: number,
): void => {
if (unallocatedCost < amount) {
throw new ServiceError(ERRORS.COST_AMOUNT_BIGGER_THAN_UNALLOCATED_AMOUNT);
}
};
/**
* Retrieve the give bill landed cost or throw not found service error.
* @param {number} tenantId - Tenant id.
* @param {number} landedCostId - Landed cost id.
* @returns {Promise<IBillLandedCost>}
*/
public getBillLandedCostOrThrowError = async (
landedCostId: number,
): Promise<BillLandedCost> => {
// Retrieve the bill landed cost model.
const billLandedCost = await this.billLandedCostModel()
.query()
.findById(landedCostId);
if (!billLandedCost) {
throw new ServiceError(ERRORS.BILL_LANDED_COST_NOT_FOUND);
}
return billLandedCost;
};
}

View File

@@ -1,8 +1,32 @@
import { Module } from '@nestjs/common';
import { TransactionLandedCostEntriesService } from './TransactionLandedCostEntries.service';
import { AllocateLandedCostService } from './commands/AllocateLandedCost.service';
import { LandedCostGLEntriesSubscriber } from './commands/LandedCostGLEntries.subscriber';
import { LandedCostGLEntries } from './commands/LandedCostGLEntries.service';
import { LandedCostSyncCostTransactions } from './commands/LandedCostSyncCostTransactions.service';
import { LandedCostSyncCostTransactionsSubscriber } from './commands/LandedCostSyncCostTransactions.subscriber';
import { BillAllocatedLandedCostTransactions } from './commands/BillAllocatedLandedCostTransactions.service';
import { BillAllocateLandedCostController } from './LandedCost.controller';
import { RevertAllocatedLandedCost } from './commands/RevertAllocatedLandedCost.service';
import LandedCostTranasctions from './commands/LandedCostTransactions.service';
import { LandedCostInventoryTransactions } from './commands/LandedCostInventoryTransactions.service';
import { InventoryCostModule } from '../InventoryCost/InventoryCost.module';
@Module({
providers: [TransactionLandedCostEntriesService],
imports: [InventoryCostModule],
providers: [
AllocateLandedCostService,
TransactionLandedCostEntriesService,
BillAllocatedLandedCostTransactions,
LandedCostGLEntriesSubscriber,
LandedCostGLEntries,
LandedCostSyncCostTransactions,
RevertAllocatedLandedCost,
LandedCostInventoryTransactions,
LandedCostTranasctions,
LandedCostSyncCostTransactionsSubscriber,
],
exports: [TransactionLandedCostEntriesService],
controllers: [BillAllocateLandedCostController],
})
export class BillLandedCostsModule {}

View File

@@ -0,0 +1,84 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Query,
} from '@nestjs/common';
import { AllocateBillLandedCostDto } from './dtos/AllocateBillLandedCost.dto';
import { AllocateLandedCostService } from './commands/AllocateLandedCost.service';
import { BillAllocatedLandedCostTransactions } from './commands/BillAllocatedLandedCostTransactions.service';
import { RevertAllocatedLandedCost } from './commands/RevertAllocatedLandedCost.service';
import { LandedCostTranasctions } from './commands/LandedCostTransactions.service';
@Controller('landed-cost')
export class BillAllocateLandedCostController {
constructor(
private allocateLandedCost: AllocateLandedCostService,
private billAllocatedCostTransactions: BillAllocatedLandedCostTransactions,
private revertAllocatedLandedCost: RevertAllocatedLandedCost,
private landedCostTranasctions: LandedCostTranasctions,
) {}
@Get('/transactions')
async getLandedCostTransactions(
@Query('transaction_type') transactionType: string,
) {
const transactions =
await this.landedCostTranasctions.getLandedCostTransactions(transactionType);
return transactions;
}
@Post('/bills/:billId/allocate')
public async calculateLandedCost(
@Param('billId') billId: number,
@Body() landedCostDTO: AllocateBillLandedCostDto,
) {
const billLandedCost = await this.allocateLandedCost.allocateLandedCost(
landedCostDTO,
billId,
);
return {
id: billLandedCost.id,
message: 'The items cost are located successfully.',
};
}
@Delete('/:allocatedLandedCostId')
public async deleteAllocatedLandedCost(
@Param('allocatedLandedCostId') allocatedLandedCostId: number,
) {
await this.revertAllocatedLandedCost.deleteAllocatedLandedCost(
allocatedLandedCostId,
);
return {
id: allocatedLandedCostId,
message: 'The allocated landed cost are delete successfully.',
};
}
public async listLandedCosts(
) {
const transactions =
await this.landedCostTranasctions.getLandedCostTransactions(query);
return transactions;
};
@Get('/bills/:billId/transactions')
async getBillLandedCostTransactions(@Param('billId') billId: number) {
const transactions =
await this.billAllocatedCostTransactions.getBillLandedCostTransactions(
billId,
);
return {
billId,
transactions,
};
}
}

View File

@@ -1,7 +1,10 @@
import { Injectable } from '@nestjs/common';
import { ServiceError } from '../Items/ServiceError';
import { transformToMap } from '@/utils/transform-to-key';
import { ICommonLandedCostEntry, ICommonLandedCostEntryDTO } from './types/BillLandedCosts.types';
import {
ICommonLandedCostEntry,
ICommonLandedCostEntryDTO,
} from './types/BillLandedCosts.types';
const ERRORS = {
ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED:
@@ -19,7 +22,7 @@ export class TransactionLandedCostEntriesService {
*/
public getLandedCostEntriesDeleted(
oldCommonEntries: ICommonLandedCostEntry[],
newCommonEntriesDTO: ICommonLandedCostEntryDTO[]
newCommonEntriesDTO: ICommonLandedCostEntryDTO[],
): ICommonLandedCostEntry[] {
const newBillEntriesById = transformToMap(newCommonEntriesDTO, 'id');
@@ -40,11 +43,11 @@ export class TransactionLandedCostEntriesService {
*/
public validateLandedCostEntriesNotDeleted(
oldCommonEntries: ICommonLandedCostEntry[],
newCommonEntriesDTO: ICommonLandedCostEntryDTO[]
newCommonEntriesDTO: ICommonLandedCostEntryDTO[],
): void {
const entriesDeleted = this.getLandedCostEntriesDeleted(
oldCommonEntries,
newCommonEntriesDTO
newCommonEntriesDTO,
);
if (entriesDeleted.length > 0) {
throw new ServiceError(ERRORS.ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED);
@@ -58,7 +61,7 @@ export class TransactionLandedCostEntriesService {
*/
public validateLocatedCostEntriesSmallerThanNewEntries(
oldCommonEntries: ICommonLandedCostEntry[],
newCommonEntriesDTO: ICommonLandedCostEntryDTO[]
newCommonEntriesDTO: ICommonLandedCostEntryDTO[],
): void {
const oldBillEntriesById = transformToMap(oldCommonEntries, 'id');
@@ -67,7 +70,7 @@ export class TransactionLandedCostEntriesService {
if (oldEntry && oldEntry.allocatedCostAmount > entry.amount) {
throw new ServiceError(
ERRORS.LOCATED_COST_ENTRIES_SHOULD_BIGGE_THAN_NEW_ENTRIES
ERRORS.LOCATED_COST_ENTRIES_SHOULD_BIGGE_THAN_NEW_ENTRIES,
);
}
});

View File

@@ -0,0 +1,107 @@
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import {
IAllocatedLandedCostCreatedPayload,
ILandedCostDTO,
} from '../types/BillLandedCosts.types';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { Bill } from '@/modules/Bills/models/Bill';
import { BillLandedCost } from '../models/BillLandedCost';
import { BaseLandedCostService } from '../BaseLandedCost.service';
import { events } from '@/common/events/events';
import { AllocateBillLandedCostDto } from '../dtos/AllocateBillLandedCost.dto';
@Injectable()
export class AllocateLandedCostService extends BaseLandedCostService {
constructor(
private readonly uow: UnitOfWork,
private readonly eventPublisher: EventEmitter2,
@Inject(Bill.name)
private readonly billModel: TenantModelProxy<typeof Bill>,
@Inject(BillLandedCost.name)
private readonly billLandedCostModel: TenantModelProxy<typeof BillLandedCost>
) {
super();
}
/**
* =================================
* - Allocate landed cost.
* =================================
* - Validates the allocate cost not the same purchase invoice id.
* - Get the given bill (purchase invoice) or throw not found error.
* - Get the given landed cost transaction or throw not found error.
* - Validate landed cost transaction has enough unallocated cost amount.
* - Validate landed cost transaction entry has enough unallocated cost amount.
* - Validate allocate entries existance and associated with cost bill transaction.
* - Writes inventory landed cost transaction.
* - Increment the allocated landed cost transaction.
* - Increment the allocated landed cost transaction entry.
* --------------------------------
* @param {ILandedCostDTO} landedCostDTO - Landed cost DTO.
* @param {number} tenantId - Tenant id.
* @param {number} billId - Purchase invoice id.
*/
public async allocateLandedCost(
allocateCostDTO: AllocateBillLandedCostDto,
billId: number,
): Promise<BillLandedCost> {
// Retrieve total cost of allocated items.
const amount = this.getAllocateItemsCostTotal(allocateCostDTO);
// Retrieve the purchase invoice or throw not found error.
const bill = await Bill.query()
.findById(billId)
.withGraphFetched('entries')
.throwIfNotFound();
// Retrieve landed cost transaction or throw not found service error.
const costTransaction = await this.getLandedCostOrThrowError(
allocateCostDTO.transactionType,
allocateCostDTO.transactionId,
);
// Retrieve landed cost transaction entries.
const costTransactionEntry = await this.getLandedCostEntry(
allocateCostDTO.transactionType,
allocateCostDTO.transactionId,
allocateCostDTO.transactionEntryId,
);
// Validates allocate cost items association with the purchase invoice entries.
this.validateAllocateCostItems(bill.entries, allocateCostDTO.items);
// Validate the amount of cost with unallocated landed cost.
this.validateLandedCostEntryAmount(
costTransactionEntry.unallocatedCostAmount,
amount,
);
// Transformes DTO to bill landed cost model object.
const billLandedCostObj = this.transformToBillLandedCost(
allocateCostDTO,
bill,
costTransaction,
costTransactionEntry,
);
// Saves landed cost transactions with associated tranasctions under
// unit-of-work eniverment.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Save the bill landed cost model.
const billLandedCost =
await BillLandedCost.query(trx).insertGraph(billLandedCostObj);
// Triggers `onBillLandedCostCreated` event.
await this.eventPublisher.emitAsync(events.billLandedCost.onCreated, {
bill,
billLandedCostId: billLandedCost.id,
billLandedCost,
costTransaction,
costTransactionEntry,
trx,
} as IAllocatedLandedCostCreatedPayload);
return billLandedCost;
});
};
}

View File

@@ -0,0 +1,177 @@
import { Inject, Injectable } from '@nestjs/common';
import { I18nService } from 'nestjs-i18n';
import { omit } from 'lodash';
import * as R from 'ramda';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { Bill } from '@/modules/Bills/models/Bill';
import { BillLandedCost } from '../models/BillLandedCost';
import { IBillLandedCostTransaction } from '../types/BillLandedCosts.types';
@Injectable()
export class BillAllocatedLandedCostTransactions {
constructor(
private readonly i18nService: I18nService,
@Inject(Bill.name)
private readonly billModel: TenantModelProxy<typeof Bill>,
@Inject(BillLandedCost.name)
private readonly billLandedCostModel: TenantModelProxy<
typeof BillLandedCost
>,
) {}
/**
* Retrieve the bill associated landed cost transactions.
* @param {number} tenantId - Tenant id.
* @param {number} billId - Bill id.
* @return {Promise<IBillLandedCostTransaction>}
*/
public getBillLandedCostTransactions = async (
billId: number,
): Promise<IBillLandedCostTransaction> => {
// Retrieve the given bill id or throw not found service error.
const bill = await this.billModel()
.query()
.findById(billId)
.throwIfNotFound();
// Retrieve the bill associated allocated landed cost with bill and expense entry.
const landedCostTransactions = await this.billLandedCostModel()
.query()
.where('bill_id', billId)
.withGraphFetched('allocateEntries')
.withGraphFetched('allocatedFromBillEntry.item')
.withGraphFetched('allocatedFromExpenseEntry.expenseAccount')
.withGraphFetched('bill');
const transactionsJson = this.i18nService.i18nApply(
[[qim.$each, 'allocationMethodFormatted']],
landedCostTransactions.map((a) => a.toJSON()),
tenantId,
);
return this.transformBillLandedCostTransactions(transactionsJson);
};
/**
*
* @param {IBillLandedCostTransaction[]} landedCostTransactions
* @returns
*/
private transformBillLandedCostTransactions = (
landedCostTransactions: IBillLandedCostTransaction[],
) => {
return landedCostTransactions.map(this.transformBillLandedCostTransaction);
};
/**
*
* @param {IBillLandedCostTransaction} transaction
* @returns
*/
private transformBillLandedCostTransaction = (
transaction: IBillLandedCostTransaction,
) => {
const getTransactionName = R.curry(this.condBillLandedTransactionName)(
transaction.fromTransactionType,
);
const getTransactionDesc = R.curry(
this.condBillLandedTransactionDescription,
)(transaction.fromTransactionType);
return {
formattedAmount: formatNumber(transaction.amount, {
currencyCode: transaction.currencyCode,
}),
...omit(transaction, [
'allocatedFromBillEntry',
'allocatedFromExpenseEntry',
]),
name: getTransactionName(transaction),
description: getTransactionDesc(transaction),
formattedLocalAmount: formatNumber(transaction.localAmount, {
currencyCode: 'USD',
}),
};
};
/**
* Retrieve bill landed cost tranaction name based on the given transaction type.
* @param transactionType
* @param transaction
* @returns
*/
private condBillLandedTransactionName = (
transactionType: string,
transaction,
) => {
return R.cond([
[
R.always(R.equals(transactionType, 'Bill')),
this.getLandedBillTransactionName,
],
[
R.always(R.equals(transactionType, 'Expense')),
this.getLandedExpenseTransactionName,
],
])(transaction);
};
/**
*
* @param transaction
* @returns
*/
private getLandedBillTransactionName = (transaction): string => {
return transaction.allocatedFromBillEntry.item.name;
};
/**
*
* @param transaction
* @returns
*/
private getLandedExpenseTransactionName = (transaction): string => {
return transaction.allocatedFromExpenseEntry.expenseAccount.name;
};
/**
* Retrieve landed cost.
* @param transaction
* @returns
*/
private getLandedBillTransactionDescription = (transaction): string => {
return transaction.allocatedFromBillEntry.description;
};
/**
*
* @param transaction
* @returns
*/
private getLandedExpenseTransactionDescription = (transaction): string => {
return transaction.allocatedFromExpenseEntry.description;
};
/**
* Retrieve the bill landed cost transaction description based on transaction type.
* @param {string} tranasctionType
* @param transaction
* @returns
*/
private condBillLandedTransactionDescription = (
tranasctionType: string,
transaction,
) => {
return R.cond([
[
R.always(R.equals(tranasctionType, 'Bill')),
this.getLandedBillTransactionDescription,
],
[
R.always(R.equals(tranasctionType, 'Expense')),
this.getLandedExpenseTransactionDescription,
],
])(transaction);
};
}

View File

@@ -0,0 +1,234 @@
import * as R from 'ramda';
import { Knex } from 'knex';
import { Inject, Injectable } from '@nestjs/common';
import { BaseLandedCostService } from '../BaseLandedCost.service';
import { BillLandedCost } from '../models/BillLandedCost';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { Bill } from '@/modules/Bills/models/Bill';
import { BillLandedCostEntry } from '../models/BillLandedCostEntry';
import { ILedger, ILedgerEntry } from '@/modules/Ledger/types/Ledger.types';
import { Ledger } from '@/modules/Ledger/Ledger';
@Injectable()
export class LandedCostGLEntries extends BaseLandedCostService {
constructor(
private readonly journalService: JournalPosterService,
private readonly ledgerRepository: LedgerRepository,
@Inject(BillLandedCost.name)
private readonly billLandedCostModel: TenantModelProxy<typeof BillLandedCost>,
) {
super();
}
/**
* Retrieves the landed cost GL common entry.
* @param {IBill} bill
* @param {IBillLandedCost} allocatedLandedCost
* @returns
*/
private getLandedCostGLCommonEntry = (
bill: Bill,
allocatedLandedCost: BillLandedCost
) => {
return {
date: bill.billDate,
currencyCode: allocatedLandedCost.currencyCode,
exchangeRate: allocatedLandedCost.exchangeRate,
transactionType: 'LandedCost',
transactionId: allocatedLandedCost.id,
transactionNumber: bill.billNumber,
referenceNumber: bill.referenceNo,
credit: 0,
debit: 0,
};
};
/**
* Retrieves the landed cost GL inventory entry.
* @param {IBill} bill
* @param {IBillLandedCost} allocatedLandedCost
* @param {IBillLandedCostEntry} allocatedEntry
* @returns {ILedgerEntry}
*/
private getLandedCostGLInventoryEntry = (
bill: Bill,
allocatedLandedCost: BillLandedCost,
allocatedEntry: BillLandedCostEntry
): ILedgerEntry => {
const commonEntry = this.getLandedCostGLCommonEntry(
bill,
allocatedLandedCost
);
return {
...commonEntry,
debit: allocatedLandedCost.localAmount,
accountId: allocatedEntry.itemEntry.item.inventoryAccountId,
index: 1,
accountNormal: AccountNormal.DEBIT,
};
};
/**
* Retrieves the landed cost GL cost entry.
* @param {IBill} bill
* @param {IBillLandedCost} allocatedLandedCost
* @param {ILandedCostTransactionEntry} fromTransactionEntry
* @returns {ILedgerEntry}
*/
private getLandedCostGLCostEntry = (
bill: Bill,
allocatedLandedCost: BillLandedCost,
fromTransactionEntry: ILandedCostTransactionEntry
): ILedgerEntry => {
const commonEntry = this.getLandedCostGLCommonEntry(
bill,
allocatedLandedCost
);
return {
...commonEntry,
credit: allocatedLandedCost.localAmount,
accountId: fromTransactionEntry.costAccountId,
index: 2,
accountNormal: AccountNormal.CREDIT,
};
};
/**
* Retrieve allocated landed cost entry GL entries.
* @param {IBill} bill
* @param {IBillLandedCost} allocatedLandedCost
* @param {ILandedCostTransactionEntry} fromTransactionEntry
* @param {IBillLandedCostEntry} allocatedEntry
* @returns {ILedgerEntry}
*/
private getLandedCostGLAllocateEntry = R.curry(
(
bill: Bill,
allocatedLandedCost: BillLandedCost,
fromTransactionEntry: LandedCostTransactionEntry,
allocatedEntry: BillLandedCostEntry
): ILedgerEntry[] => {
const inventoryEntry = this.getLandedCostGLInventoryEntry(
bill,
allocatedLandedCost,
allocatedEntry
);
const costEntry = this.getLandedCostGLCostEntry(
bill,
allocatedLandedCost,
fromTransactionEntry
);
return [inventoryEntry, costEntry];
}
);
/**
* Compose the landed cost GL entries.
* @param {BillLandedCost} allocatedLandedCost
* @param {Bill} bill
* @param {ILandedCostTransactionEntry} fromTransactionEntry
* @returns {ILedgerEntry[]}
*/
public getLandedCostGLEntries = (
allocatedLandedCost: BillLandedCost,
bill: Bill,
fromTransactionEntry: LandedCostTransactionEntry
): ILedgerEntry[] => {
const getEntry = this.getLandedCostGLAllocateEntry(
bill,
allocatedLandedCost,
fromTransactionEntry
);
return allocatedLandedCost.allocateEntries.map(getEntry).flat();
};
/**
* Retrieves the landed cost GL ledger.
* @param {IBillLandedCost} allocatedLandedCost
* @param {Bill} bill
* @param {ILandedCostTransactionEntry} fromTransactionEntry
* @returns {ILedger}
*/
public getLandedCostLedger = (
allocatedLandedCost: BillLandedCost,
bill: Bill,
fromTransactionEntry: LandedCostTransactionEntry
): ILedger => {
const entries = this.getLandedCostGLEntries(
allocatedLandedCost,
bill,
fromTransactionEntry
);
return new Ledger(entries);
};
/**
* Writes landed cost GL entries to the storage layer.
* @param {number} tenantId -
*/
public writeLandedCostGLEntries = async (
allocatedLandedCost: BillLandedCost,
bill: Bill,
fromTransactionEntry: ILandedCostTransactionEntry,
trx?: Knex.Transaction
) => {
const ledgerEntries = this.getLandedCostGLEntries(
allocatedLandedCost,
bill,
fromTransactionEntry
);
await this.ledgerRepository.saveLedgerEntries(ledgerEntries, trx);
};
/**
* Generates and writes GL entries of the given landed cost.
* @param {number} billLandedCostId
* @param {Knex.Transaction} trx
*/
public createLandedCostGLEntries = async (
billLandedCostId: number,
trx?: Knex.Transaction
) => {
// Retrieve the bill landed cost transacion with associated
// allocated entries and items.
const allocatedLandedCost = await this.billLandedCostModel().query(trx)
.findById(billLandedCostId)
.withGraphFetched('bill')
.withGraphFetched('allocateEntries.itemEntry.item');
// Retrieve the allocated from transactione entry.
const transactionEntry = await this.getLandedCostEntry(
allocatedLandedCost.fromTransactionType,
allocatedLandedCost.fromTransactionId,
allocatedLandedCost.fromTransactionEntryId
);
// Writes the given landed cost GL entries to the storage layer.
await this.writeLandedCostGLEntries(
allocatedLandedCost,
allocatedLandedCost.bill,
transactionEntry,
trx
);
};
/**
* Reverts GL entries of the given allocated landed cost transaction.
* @param {number} tenantId
* @param {number} landedCostId
* @param {Knex.Transaction} trx
*/
public revertLandedCostGLEntries = async (
landedCostId: number,
trx: Knex.Transaction
) => {
await this.journalService.revertJournalTransactions(
landedCostId,
'LandedCost',
trx
);
};
}

View File

@@ -0,0 +1,45 @@
import {
IAllocatedLandedCostCreatedPayload,
IAllocatedLandedCostDeletedPayload,
} from '../types/BillLandedCosts.types';
import { OnEvent } from '@nestjs/event-emitter';
import { LandedCostGLEntries } from './LandedCostGLEntries.service';
import { Injectable } from '@nestjs/common';
import { events } from '@/common/events/events';
@Injectable()
export class LandedCostGLEntriesSubscriber {
constructor(
private readonly billLandedCostGLEntries: LandedCostGLEntries,
) {}
/**
* Writes GL entries once landed cost transaction created.
* @param {IAllocatedLandedCostCreatedPayload} payload -
*/
@OnEvent(events.billLandedCost.onCreated)
async writeGLEntriesOnceLandedCostCreated({
billLandedCost,
trx,
}: IAllocatedLandedCostCreatedPayload) {
await this.billLandedCostGLEntries.createLandedCostGLEntries(
billLandedCost.id,
trx
);
};
/**
* Reverts GL entries associated to landed cost transaction once deleted.
* @param {IAllocatedLandedCostDeletedPayload} payload -
*/
@OnEvent(events.billLandedCost.onDeleted)
async revertGLEnteriesOnceLandedCostDeleted({
oldBillLandedCost,
trx,
}: IAllocatedLandedCostDeletedPayload) {
await this.billLandedCostGLEntries.revertLandedCostGLEntries(
oldBillLandedCost.id,
trx
);
};
}

View File

@@ -0,0 +1,66 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { IBillLandedCostTransaction } from '../types/BillLandedCosts.types';
import { Bill } from '@/modules/Bills/models/Bill';
import { mergeLocatedWithBillEntries } from '../utils';
import { InventoryTransactionsService } from '@/modules/InventoryCost/commands/InventoryTransactions.service';
@Injectable()
export class LandedCostInventoryTransactions {
constructor(
private readonly inventoryTransactionsService: InventoryTransactionsService,
) {}
/**
* Records inventory transactions.
* @param {number} tenantId
* @param {IBillLandedCostTransaction} billLandedCost
* @param {IBill} bill -
*/
public recordInventoryTransactions = async (
billLandedCost: IBillLandedCostTransaction,
bill: Bill,
trx?: Knex.Transaction,
) => {
// Retrieve the merged allocated entries with bill entries.
const allocateEntries = mergeLocatedWithBillEntries(
billLandedCost.allocateEntries,
bill.entries,
);
// Mappes the allocate cost entries to inventory transactions.
const inventoryTransactions = allocateEntries.map((allocateEntry) => ({
date: bill.billDate,
itemId: allocateEntry.entry.itemId,
direction: 'IN',
quantity: null,
rate: allocateEntry.cost,
transactionType: 'LandedCost',
transactionId: billLandedCost.id,
entryId: allocateEntry.entryId,
}));
// Writes inventory transactions.
return this.inventoryTransactionsService.recordInventoryTransactions(
inventoryTransactions,
false,
trx,
);
};
/**
* Deletes the inventory transaction.
* @param {number} tenantId - Tenant id.
* @param {number} landedCostId - Landed cost id.
* @param {Knex.Transaction} trx - Knex transactions.
* @returns
*/
public removeInventoryTransactions = (
landedCostId: number,
trx?: Knex.Transaction,
) => {
return this.inventoryTransactionsService.deleteInventoryTransactions(
landedCostId,
'LandedCost',
trx,
);
};
}

Some files were not shown because too many files have changed in this diff Show More