mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-24 16:49:48 +00:00
refactor: banking services to Nestjs
This commit is contained in:
@@ -72,7 +72,11 @@
|
|||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"serialize-interceptor": "^1.1.7",
|
"serialize-interceptor": "^1.1.7",
|
||||||
"strategy": "^1.1.1",
|
"strategy": "^1.1.1",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8",
|
||||||
|
"plaid": "^10.3.0",
|
||||||
|
"@supercharge/promise-pool": "^3.2.0",
|
||||||
|
"yup": "^0.28.1",
|
||||||
|
"uniqid": "^5.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.0.0",
|
"@nestjs/cli": "^10.0.0",
|
||||||
@@ -82,6 +86,7 @@
|
|||||||
"@types/jest": "^29.5.2",
|
"@types/jest": "^29.5.2",
|
||||||
"@types/node": "^20.3.1",
|
"@types/node": "^20.3.1",
|
||||||
"@types/supertest": "^6.0.0",
|
"@types/supertest": "^6.0.0",
|
||||||
|
"@types/yup": "^0.29.13",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||||
"@typescript-eslint/parser": "^8.0.0",
|
"@typescript-eslint/parser": "^8.0.0",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
import { Model } from 'objection';
|
import { Constructor, Model, QueryBuilderType, TransactionOrKnex } from 'objection';
|
||||||
|
|
||||||
export class BaseModel extends Model {
|
export class BaseModel extends Model {
|
||||||
public readonly id: number;
|
public readonly id: number;
|
||||||
public readonly tableName: string;
|
public readonly tableName: string;
|
||||||
|
|
||||||
|
static get QueryBuilder() {
|
||||||
|
return PaginationQueryBuilder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PaginationQueryBuilder<M extends Model, R = M[]> extends Model.QueryBuilder<M, R> {
|
||||||
|
pagination(page: number, pageSize: number) {
|
||||||
|
return super.page(page, pageSize).runAfter(({ results, total }) => {
|
||||||
|
return {
|
||||||
|
results,
|
||||||
|
pagination: {
|
||||||
|
total,
|
||||||
|
page: page + 1,
|
||||||
|
pageSize,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import { TenantModel } from '@/modules/System/models/TenantModel';
|
|||||||
// import { ModelSettings } from '@/modules/Settings/ModelSettings';
|
// import { ModelSettings } from '@/modules/Settings/ModelSettings';
|
||||||
import { AccountTypesUtils } from '@/libs/accounts-utils/AccountTypesUtils';
|
import { AccountTypesUtils } from '@/libs/accounts-utils/AccountTypesUtils';
|
||||||
import { Model } from 'objection';
|
import { Model } from 'objection';
|
||||||
|
import { PlaidItem } from '@/modules/BankingPlaid/models/PlaidItem';
|
||||||
// import AccountSettings from './Account.Settings';
|
// import AccountSettings from './Account.Settings';
|
||||||
// import { DEFAULT_VIEWS } from '@/modules/Accounts/constants';
|
// import { DEFAULT_VIEWS } from '@/modules/Accounts/constants';
|
||||||
// import { buildFilterQuery, buildSortColumnQuery } from '@/lib/ViewRolesBuilder';
|
// import { buildFilterQuery, buildSortColumnQuery } from '@/lib/ViewRolesBuilder';
|
||||||
@@ -25,17 +26,20 @@ import { Model } from 'objection';
|
|||||||
// ]) {
|
// ]) {
|
||||||
|
|
||||||
export class Account extends TenantModel {
|
export class Account extends TenantModel {
|
||||||
name: string;
|
public name!: string;
|
||||||
slug: string;
|
public slug!: string;
|
||||||
code: string;
|
public code!: string;
|
||||||
index: number;
|
public index!: number;
|
||||||
accountType: string;
|
public accountType!: string;
|
||||||
predefined: boolean;
|
public predefined!: boolean;
|
||||||
currencyCode: string;
|
public currencyCode!: string;
|
||||||
active: boolean;
|
public active!: boolean;
|
||||||
bankBalance: number;
|
public bankBalance!: number;
|
||||||
lastFeedsUpdatedAt: string | null;
|
public lastFeedsUpdatedAt!: string | null;
|
||||||
amount: number;
|
public amount!: number;
|
||||||
|
public plaidItemId!: number;
|
||||||
|
|
||||||
|
public plaidItem!: PlaidItem;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Table name.
|
* Table name.
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ import { CreditNoteRefundsModule } from '../CreditNoteRefunds/CreditNoteRefunds.
|
|||||||
import { BillPaymentsModule } from '../BillPayments/BillPayments.module';
|
import { BillPaymentsModule } from '../BillPayments/BillPayments.module';
|
||||||
import { PaymentsReceivedModule } from '../PaymentReceived/PaymentsReceived.module';
|
import { PaymentsReceivedModule } from '../PaymentReceived/PaymentsReceived.module';
|
||||||
import { LedgerModule } from '../Ledger/Ledger.module';
|
import { LedgerModule } from '../Ledger/Ledger.module';
|
||||||
|
import { BankRulesModule } from '../BankRules/BankRules.module';
|
||||||
|
import { BankAccountsModule } from '../BankingAccounts/BankAccounts.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -132,6 +134,9 @@ import { LedgerModule } from '../Ledger/Ledger.module';
|
|||||||
BillPaymentsModule,
|
BillPaymentsModule,
|
||||||
PaymentsReceivedModule,
|
PaymentsReceivedModule,
|
||||||
LedgerModule,
|
LedgerModule,
|
||||||
|
|
||||||
|
BankAccountsModule,
|
||||||
|
BankRulesModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { BankRulesApplication } from './BankRulesApplication';
|
||||||
|
import { ICreateBankRuleDTO, IEditBankRuleDTO } from './types';
|
||||||
|
|
||||||
|
@Controller('banking/rules')
|
||||||
|
export class BankRulesController {
|
||||||
|
constructor(private readonly bankRulesApplication: BankRulesApplication) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createBankRule(
|
||||||
|
@Body() createRuleDTO: ICreateBankRuleDTO,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.bankRulesApplication.createBankRule(createRuleDTO);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
async editBankRule(
|
||||||
|
@Param('id') ruleId: number,
|
||||||
|
@Body() editRuleDTO: IEditBankRuleDTO,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.bankRulesApplication.editBankRule(ruleId, editRuleDTO);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
async deleteBankRule(@Param('id') ruleId: number): Promise<void> {
|
||||||
|
return this.bankRulesApplication.deleteBankRule(ruleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async getBankRule(@Param('id') ruleId: number): Promise<any> {
|
||||||
|
return this.bankRulesApplication.getBankRule(ruleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getBankRules(): Promise<any> {
|
||||||
|
return this.bankRulesApplication.getBankRules();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CreateBankRuleService } from './commands/CreateBankRule.service';
|
||||||
|
import { EditBankRuleService } from './commands/EditBankRule.service';
|
||||||
|
import { DeleteBankRuleService } from './commands/DeleteBankRule.service';
|
||||||
|
import { GetBankRulesService } from './queries/GetBankRules.service';
|
||||||
|
import { GetBankRuleService } from './queries/GetBankRule.service';
|
||||||
|
import { BankRulesApplication } from './BankRulesApplication';
|
||||||
|
import { RegisterTenancyModel } from '../Tenancy/TenancyModels/Tenancy.module';
|
||||||
|
import { BankRuleCondition } from './models/BankRuleCondition';
|
||||||
|
import { BankRule } from './models/BankRule';
|
||||||
|
import { BankRulesController } from './BankRules.controller';
|
||||||
|
import { UnlinkBankRuleOnDeleteBankRuleSubscriber } from './events/UnlinkBankRuleOnDeleteBankRule';
|
||||||
|
|
||||||
|
const models = [
|
||||||
|
RegisterTenancyModel(BankRule),
|
||||||
|
RegisterTenancyModel(BankRuleCondition),
|
||||||
|
];
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [BankRulesController],
|
||||||
|
imports: [],
|
||||||
|
providers: [
|
||||||
|
...models,
|
||||||
|
CreateBankRuleService,
|
||||||
|
EditBankRuleService,
|
||||||
|
DeleteBankRuleService,
|
||||||
|
GetBankRuleService,
|
||||||
|
GetBankRulesService,
|
||||||
|
BankRulesApplication,
|
||||||
|
UnlinkBankRuleOnDeleteBankRuleSubscriber
|
||||||
|
],
|
||||||
|
exports: [...models],
|
||||||
|
})
|
||||||
|
export class BankRulesModule {}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { CreateBankRuleService } from './commands/CreateBankRule.service';
|
||||||
|
import { DeleteBankRuleService } from './commands/DeleteBankRule.service';
|
||||||
|
import { EditBankRuleService } from './commands/EditBankRule.service';
|
||||||
|
import { GetBankRuleService } from './queries/GetBankRule.service';
|
||||||
|
import { GetBankRulesService } from './queries/GetBankRules.service';
|
||||||
|
import { ICreateBankRuleDTO, IEditBankRuleDTO } from './types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BankRulesApplication {
|
||||||
|
constructor(
|
||||||
|
private readonly createBankRuleService: CreateBankRuleService,
|
||||||
|
private readonly editBankRuleService: EditBankRuleService,
|
||||||
|
private readonly deleteBankRuleService: DeleteBankRuleService,
|
||||||
|
private readonly getBankRuleService: GetBankRuleService,
|
||||||
|
private readonly getBankRulesService: GetBankRulesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates new bank rule.
|
||||||
|
* @param {ICreateBankRuleDTO} createRuleDTO - Bank rule data.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public createBankRule(createRuleDTO: ICreateBankRuleDTO): Promise<void> {
|
||||||
|
return this.createBankRuleService.createBankRule(createRuleDTO);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edits the given bank rule.
|
||||||
|
* @param {number} ruleId - Bank rule identifier.
|
||||||
|
* @param {IEditBankRuleDTO} editRuleDTO - Bank rule data.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public editBankRule(
|
||||||
|
ruleId: number,
|
||||||
|
editRuleDTO: IEditBankRuleDTO,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.editBankRuleService.editBankRule(ruleId, editRuleDTO);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the given bank rule.
|
||||||
|
* @param {number} ruleId - Bank rule identifier.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public deleteBankRule(ruleId: number): Promise<void> {
|
||||||
|
return this.deleteBankRuleService.deleteBankRule(ruleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the given bank rule.
|
||||||
|
* @param {number} ruleId - Bank rule identifier.
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
public getBankRule(ruleId: number): Promise<any> {
|
||||||
|
return this.getBankRuleService.getBankRule(ruleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the bank rules of the given account.
|
||||||
|
* @param {number} accountId - Bank account identifier.
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
public getBankRules(): Promise<any> {
|
||||||
|
return this.getBankRulesService.getBankRules();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
IBankRuleEventCreatedPayload,
|
||||||
|
IBankRuleEventCreatingPayload,
|
||||||
|
ICreateBankRuleDTO,
|
||||||
|
} from '../types';
|
||||||
|
import { UnitOfWork } from '../../Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
import { BankRule } from '../models/BankRule';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CreateBankRuleService {
|
||||||
|
constructor(
|
||||||
|
private readonly uow: UnitOfWork,
|
||||||
|
private readonly eventPublisher: EventEmitter2,
|
||||||
|
|
||||||
|
@Inject(BankRule.name) private readonly bankRuleModel: typeof BankRule,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms the DTO to model.
|
||||||
|
* @param {ICreateBankRuleDTO} createDTO
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
private transformDTO(createDTO: ICreateBankRuleDTO): Partial<BankRule> {
|
||||||
|
return {
|
||||||
|
...createDTO,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new bank rule.
|
||||||
|
* @param {ICreateBankRuleDTO} createRuleDTO
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public async createBankRule(createRuleDTO: ICreateBankRuleDTO): Promise<void> {
|
||||||
|
const transformDTO = this.transformDTO(createRuleDTO);
|
||||||
|
|
||||||
|
await this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||||
|
// Triggers `onBankRuleCreating` event.
|
||||||
|
await this.eventPublisher.emitAsync(events.bankRules.onCreating, {
|
||||||
|
createRuleDTO,
|
||||||
|
trx,
|
||||||
|
} as IBankRuleEventCreatingPayload);
|
||||||
|
|
||||||
|
const bankRule = await this.bankRuleModel.query(trx).upsertGraph({
|
||||||
|
...transformDTO,
|
||||||
|
});
|
||||||
|
// Triggers `onBankRuleCreated` event.
|
||||||
|
await this.eventPublisher.emitAsync(events.bankRules.onCreated, {
|
||||||
|
createRuleDTO,
|
||||||
|
bankRule,
|
||||||
|
trx,
|
||||||
|
} as IBankRuleEventCreatedPayload);
|
||||||
|
|
||||||
|
return bankRule;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
IBankRuleEventDeletedPayload,
|
||||||
|
IBankRuleEventDeletingPayload,
|
||||||
|
} from '../types';
|
||||||
|
import { BankRule } from '../models/BankRule';
|
||||||
|
import { BankRuleCondition } from '../models/BankRuleCondition';
|
||||||
|
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DeleteBankRuleService {
|
||||||
|
constructor(
|
||||||
|
@Inject(BankRule.name) private bankRuleModel: typeof BankRule,
|
||||||
|
@Inject(BankRuleCondition.name)
|
||||||
|
private bankRuleConditionModel: typeof BankRuleCondition,
|
||||||
|
|
||||||
|
private readonly uow: UnitOfWork,
|
||||||
|
private readonly eventPublisher: EventEmitter2,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the given bank rule.
|
||||||
|
* @param {number} ruleId
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public async deleteBankRule(
|
||||||
|
ruleId: number,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> {
|
||||||
|
const oldBankRule = await this.bankRuleModel
|
||||||
|
.query()
|
||||||
|
.findById(ruleId)
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||||
|
// Triggers `onBankRuleDeleting` event.
|
||||||
|
await this.eventPublisher.emitAsync(events.bankRules.onDeleting, {
|
||||||
|
oldBankRule,
|
||||||
|
ruleId,
|
||||||
|
trx,
|
||||||
|
} as IBankRuleEventDeletingPayload);
|
||||||
|
|
||||||
|
await this.bankRuleConditionModel
|
||||||
|
.query(trx)
|
||||||
|
.where('ruleId', ruleId)
|
||||||
|
.delete();
|
||||||
|
await this.bankRuleModel.query(trx).findById(ruleId).delete();
|
||||||
|
|
||||||
|
// Triggers `onBankRuleDeleted` event.
|
||||||
|
await this.eventPublisher.emitAsync(events.bankRules.onDeleted, {
|
||||||
|
ruleId,
|
||||||
|
trx,
|
||||||
|
} as IBankRuleEventDeletedPayload);
|
||||||
|
}, trx);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { PromisePool } from '@supercharge/promise-pool';
|
||||||
|
import { castArray, uniq } from 'lodash';
|
||||||
|
import { DeleteBankRuleService } from './DeleteBankRule.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DeleteBankRulesService {
|
||||||
|
constructor(private readonly deleteBankRuleService: DeleteBankRuleService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete bank rules.
|
||||||
|
* @param {number | Array<number>} bankRuleId - The bank rule id or ids.
|
||||||
|
* @param {Knex.Transaction} trx - The transaction.
|
||||||
|
*/
|
||||||
|
async deleteBankRules(
|
||||||
|
bankRuleId: number | Array<number>,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
) {
|
||||||
|
const bankRulesIds = uniq(castArray(bankRuleId));
|
||||||
|
|
||||||
|
const results = await PromisePool.withConcurrency(1)
|
||||||
|
.for(bankRulesIds)
|
||||||
|
.process(async (bankRuleId: number) => {
|
||||||
|
await this.deleteBankRuleService.deleteBankRule(bankRuleId, trx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
IBankRuleEventEditedPayload,
|
||||||
|
IBankRuleEventEditingPayload,
|
||||||
|
IEditBankRuleDTO,
|
||||||
|
} from '../types';
|
||||||
|
import { BankRule } from '../models/BankRule';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EditBankRuleService {
|
||||||
|
constructor(
|
||||||
|
private readonly uow: UnitOfWork,
|
||||||
|
private readonly eventPublisher: EventEmitter2,
|
||||||
|
|
||||||
|
@Inject(BankRule.name) private bankRuleModel: typeof BankRule,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param createDTO
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
private transformDTO(createDTO: IEditBankRuleDTO): Partial<BankRule> {
|
||||||
|
return {
|
||||||
|
...createDTO,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edits the given bank rule.
|
||||||
|
* @param {number} ruleId -
|
||||||
|
* @param {IEditBankRuleDTO} editBankDTO
|
||||||
|
*/
|
||||||
|
public async editBankRule(
|
||||||
|
ruleId: number,
|
||||||
|
editRuleDTO: IEditBankRuleDTO
|
||||||
|
) {
|
||||||
|
const oldBankRule = await this.bankRuleModel.query()
|
||||||
|
.findById(ruleId)
|
||||||
|
.withGraphFetched('conditions')
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
const tranformDTO = this.transformDTO(editRuleDTO);
|
||||||
|
|
||||||
|
return this.uow.withTransaction(async (trx) => {
|
||||||
|
// Triggers `onBankRuleEditing` event.
|
||||||
|
await this.eventPublisher.emitAsync(events.bankRules.onEditing, {
|
||||||
|
oldBankRule,
|
||||||
|
ruleId,
|
||||||
|
editRuleDTO,
|
||||||
|
trx,
|
||||||
|
} as IBankRuleEventEditingPayload);
|
||||||
|
|
||||||
|
// Updates the given bank rule.
|
||||||
|
const bankRule = await this.bankRuleModel.query(trx).upsertGraphAndFetch({
|
||||||
|
...tranformDTO,
|
||||||
|
id: ruleId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Triggers `onBankRuleEdited` event.
|
||||||
|
await this.eventPublisher.emitAsync(events.bankRules.onEdited, {
|
||||||
|
oldBankRule,
|
||||||
|
bankRule,
|
||||||
|
editRuleDTO,
|
||||||
|
trx,
|
||||||
|
} as IBankRuleEventEditedPayload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { IBankRuleEventDeletingPayload } from '../types';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { RevertRecognizedTransactionsService } from '@/modules/BankingTranasctionsRegonize/commands/RevertRecognizedTransactions.service';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UnlinkBankRuleOnDeleteBankRuleSubscriber {
|
||||||
|
private revertRecognizedTransactionsService: RevertRecognizedTransactionsService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlinks the bank rule out of recognized transactions.
|
||||||
|
* @param {IBankRuleEventDeletingPayload} payload -
|
||||||
|
*/
|
||||||
|
@OnEvent(events.bankRules.onDeleting)
|
||||||
|
public async unlinkBankRuleOutRecognizedTransactionsOnRuleDeleting({
|
||||||
|
oldBankRule,
|
||||||
|
}: IBankRuleEventDeletingPayload) {
|
||||||
|
await this.revertRecognizedTransactionsService.revertRecognizedTransactions(
|
||||||
|
oldBankRule.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { BaseModel } from '@/models/Model';
|
||||||
|
import { Model } from 'objection';
|
||||||
|
import { BankRuleCondition } from './BankRuleCondition';
|
||||||
|
import { BankRuleAssignCategory, BankRuleConditionType } from '../types';
|
||||||
|
|
||||||
|
export class BankRule extends BaseModel {
|
||||||
|
public id!: number;
|
||||||
|
public name!: string;
|
||||||
|
public order!: number;
|
||||||
|
public applyIfAccountId!: number;
|
||||||
|
public applyIfTransactionType!: string;
|
||||||
|
public assignCategory!: BankRuleAssignCategory;
|
||||||
|
public assignAccountId!: number;
|
||||||
|
public assignPayee!: string;
|
||||||
|
public assignMemo!: string;
|
||||||
|
public conditionsType!: BankRuleConditionType;
|
||||||
|
|
||||||
|
conditions!: BankRuleCondition[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table name
|
||||||
|
*/
|
||||||
|
static get tableName() {
|
||||||
|
return 'bank_rules';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamps columns.
|
||||||
|
*/
|
||||||
|
get timestamps() {
|
||||||
|
return ['created_at', 'updated_at'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Virtual attributes.
|
||||||
|
*/
|
||||||
|
static get virtualAttributes() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relationship mapping.
|
||||||
|
*/
|
||||||
|
static get relationMappings() {
|
||||||
|
const { BankRuleCondition } = require('./BankRuleCondition');
|
||||||
|
const { Account } = require('../../Accounts/models/Account.model');
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Sale invoice associated entries.
|
||||||
|
*/
|
||||||
|
conditions: {
|
||||||
|
relation: Model.HasManyRelation,
|
||||||
|
modelClass: BankRuleCondition,
|
||||||
|
join: {
|
||||||
|
from: 'bank_rules.id',
|
||||||
|
to: 'bank_rule_conditions.ruleId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bank rule may associated to the assign account.
|
||||||
|
*/
|
||||||
|
assignAccount: {
|
||||||
|
relation: Model.BelongsToOneRelation,
|
||||||
|
modelClass: Account,
|
||||||
|
join: {
|
||||||
|
from: 'bank_rules.assignAccountId',
|
||||||
|
to: 'accounts.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { BaseModel } from '@/models/Model';
|
||||||
|
|
||||||
|
export class BankRuleCondition extends BaseModel {
|
||||||
|
public id!: number;
|
||||||
|
public bankRuleId!: number;
|
||||||
|
public field!: string;
|
||||||
|
public comparator!: string;
|
||||||
|
public value!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table name.
|
||||||
|
*/
|
||||||
|
static get tableName() {
|
||||||
|
return 'bank_rule_conditions';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamps columns.
|
||||||
|
*/
|
||||||
|
get timestamps() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Virtual attributes.
|
||||||
|
*/
|
||||||
|
static get virtualAttributes() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { GetBankRuleTransformer } from './GetBankRuleTransformer';
|
||||||
|
import { TransformerInjectable } from '../../Transformer/TransformerInjectable.service';
|
||||||
|
import { BankRule } from '../models/BankRule';
|
||||||
|
import { GetBankRulesTransformer } from './GetBankRulesTransformer';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetBankRuleService {
|
||||||
|
constructor(
|
||||||
|
@Inject(BankRule.name) private bankRuleModel: typeof BankRule,
|
||||||
|
private transformer: TransformerInjectable,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the bank rule.
|
||||||
|
* @param {number} ruleId
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
async getBankRule(ruleId: number): Promise<any> {
|
||||||
|
const bankRule = await this.bankRuleModel
|
||||||
|
.query()
|
||||||
|
.findById(ruleId)
|
||||||
|
.withGraphFetched('conditions');
|
||||||
|
|
||||||
|
return this.transformer.transform(
|
||||||
|
bankRule,
|
||||||
|
new GetBankRulesTransformer()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Transformer } from "@/modules/Transformer/Transformer";
|
||||||
|
|
||||||
|
export class GetBankRuleTransformer extends Transformer {
|
||||||
|
/**
|
||||||
|
* Include these attributes to sale invoice object.
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
public includeAttributes = (): string[] => {
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { GetBankRulesTransformer } from './GetBankRulesTransformer';
|
||||||
|
import { BankRule } from '../models/BankRule';
|
||||||
|
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetBankRulesService {
|
||||||
|
constructor(
|
||||||
|
private transformer: TransformerInjectable,
|
||||||
|
|
||||||
|
@Inject(BankRule.name)
|
||||||
|
private bankRuleModel: typeof BankRule,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the bank rules of the given account.
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
public async getBankRules(): Promise<any> {
|
||||||
|
const bankRule = await this.bankRuleModel
|
||||||
|
.query()
|
||||||
|
.withGraphFetched('conditions')
|
||||||
|
.withGraphFetched('assignAccount');
|
||||||
|
|
||||||
|
return this.transformer.transform(
|
||||||
|
bankRule,
|
||||||
|
new GetBankRulesTransformer()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Transformer } from '@/modules/Transformer/Transformer';
|
||||||
|
import { getCashflowTransactionFormattedType } from '../../BankingTransactions/utils';
|
||||||
|
|
||||||
|
export class GetBankRulesTransformer extends Transformer {
|
||||||
|
/**
|
||||||
|
* Include these attributes to sale invoice object.
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
public includeAttributes = (): string[] => {
|
||||||
|
return [
|
||||||
|
'assignAccountName',
|
||||||
|
'assignCategoryFormatted',
|
||||||
|
'conditionsFormatted',
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the assign account name.
|
||||||
|
* @param bankRule
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected assignAccountName(bankRule: any) {
|
||||||
|
return bankRule.assignAccount.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigned category formatted.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected assignCategoryFormatted(bankRule: any) {
|
||||||
|
return getCashflowTransactionFormattedType(bankRule.assignCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the bank rule formatted conditions.
|
||||||
|
* @param bankRule
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected conditionsFormatted(bankRule: any) {
|
||||||
|
return bankRule.conditions
|
||||||
|
.map((condition) => {
|
||||||
|
const field =
|
||||||
|
condition.field.charAt(0).toUpperCase() + condition.field.slice(1);
|
||||||
|
|
||||||
|
return `${field} ${condition.comparator} ${condition.value}`;
|
||||||
|
})
|
||||||
|
.join(bankRule.conditionsType === 'and' ? ' and ' : ' or ');
|
||||||
|
}
|
||||||
|
}
|
||||||
124
packages/server-nest/src/modules/BankRules/types.ts
Normal file
124
packages/server-nest/src/modules/BankRules/types.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { BankRule } from './models/BankRule';
|
||||||
|
|
||||||
|
export enum BankRuleConditionField {
|
||||||
|
Amount = 'amount',
|
||||||
|
Description = 'description',
|
||||||
|
Payee = 'payee',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum BankRuleConditionComparator {
|
||||||
|
Contains = 'contains',
|
||||||
|
Equals = 'equals',
|
||||||
|
Equal = 'equal',
|
||||||
|
NotContain = 'not_contain',
|
||||||
|
Bigger = 'bigger',
|
||||||
|
BiggerOrEqual = 'bigger_or_equal',
|
||||||
|
Smaller = 'smaller',
|
||||||
|
SmallerOrEqual = 'smaller_or_equal',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBankRuleCondition {
|
||||||
|
id?: number;
|
||||||
|
field: BankRuleConditionField;
|
||||||
|
comparator: BankRuleConditionComparator;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum BankRuleConditionType {
|
||||||
|
Or = 'or',
|
||||||
|
And = 'and',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum BankRuleApplyIfTransactionType {
|
||||||
|
Deposit = 'deposit',
|
||||||
|
Withdrawal = 'withdrawal',
|
||||||
|
}
|
||||||
|
|
||||||
|
// export interface BankRule {
|
||||||
|
// id?: number;
|
||||||
|
// name: string;
|
||||||
|
// order?: number;
|
||||||
|
// applyIfAccountId: number;
|
||||||
|
// applyIfTransactionType: BankRuleApplyIfTransactionType;
|
||||||
|
|
||||||
|
// conditionsType: BankRuleConditionType;
|
||||||
|
// conditions: IBankRuleCondition[];
|
||||||
|
|
||||||
|
// assignCategory: BankRuleAssignCategory;
|
||||||
|
// assignAccountId: number;
|
||||||
|
// assignPayee?: string;
|
||||||
|
// assignMemo?: string;
|
||||||
|
// }
|
||||||
|
|
||||||
|
export enum BankRuleAssignCategory {
|
||||||
|
InterestIncome = 'InterestIncome',
|
||||||
|
OtherIncome = 'OtherIncome',
|
||||||
|
Deposit = 'Deposit',
|
||||||
|
Expense = 'Expense',
|
||||||
|
OwnerDrawings = 'OwnerDrawings',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBankRuleConditionDTO {
|
||||||
|
id?: number;
|
||||||
|
field: string;
|
||||||
|
comparator:
|
||||||
|
| 'contains'
|
||||||
|
| 'equals'
|
||||||
|
| 'not_contains'
|
||||||
|
| 'equal'
|
||||||
|
| 'bigger'
|
||||||
|
| 'bigger_or_equal'
|
||||||
|
| 'smaller'
|
||||||
|
| 'smaller_or_equal';
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBankRuleCommonDTO {
|
||||||
|
name: string;
|
||||||
|
order?: number;
|
||||||
|
applyIfAccountId: number;
|
||||||
|
applyIfTransactionType: string;
|
||||||
|
|
||||||
|
conditions: IBankRuleConditionDTO[];
|
||||||
|
|
||||||
|
assignCategory: BankRuleAssignCategory;
|
||||||
|
assignAccountId: number;
|
||||||
|
assignPayee?: string;
|
||||||
|
assignMemo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICreateBankRuleDTO extends IBankRuleCommonDTO {}
|
||||||
|
export interface IEditBankRuleDTO extends IBankRuleCommonDTO {}
|
||||||
|
|
||||||
|
export interface IBankRuleEventCreatingPayload {
|
||||||
|
createRuleDTO: ICreateBankRuleDTO;
|
||||||
|
trx?: Knex.Transaction;
|
||||||
|
}
|
||||||
|
export interface IBankRuleEventCreatedPayload {
|
||||||
|
createRuleDTO: ICreateBankRuleDTO;
|
||||||
|
bankRule: BankRule;
|
||||||
|
trx?: Knex.Transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBankRuleEventEditingPayload {
|
||||||
|
ruleId: number;
|
||||||
|
oldBankRule: any;
|
||||||
|
editRuleDTO: IEditBankRuleDTO;
|
||||||
|
trx?: Knex.Transaction;
|
||||||
|
}
|
||||||
|
export interface IBankRuleEventEditedPayload {
|
||||||
|
oldBankRule: BankRule;
|
||||||
|
bankRule: BankRule;
|
||||||
|
editRuleDTO: IEditBankRuleDTO;
|
||||||
|
trx?: Knex.Transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBankRuleEventDeletingPayload {
|
||||||
|
oldBankRule: any;
|
||||||
|
trx?: Knex.Transaction;
|
||||||
|
}
|
||||||
|
export interface IBankRuleEventDeletedPayload {
|
||||||
|
ruleId: number;
|
||||||
|
trx?: Knex.Transaction;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Controller, Param, Post } from '@nestjs/common';
|
||||||
|
import { BankAccountsApplication } from './BankAccountsApplication.service';
|
||||||
|
|
||||||
|
@Controller('banking/accounts')
|
||||||
|
export class BankAccountsController {
|
||||||
|
constructor(private bankAccountsApplication: BankAccountsApplication) {}
|
||||||
|
|
||||||
|
@Post(':id/disconnect')
|
||||||
|
async disconnectBankAccount(@Param('id') bankAccountId: number) {
|
||||||
|
return this.bankAccountsApplication.disconnectBankAccount(bankAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/refresh')
|
||||||
|
async refreshBankAccount(@Param('id') bankAccountId: number) {
|
||||||
|
return this.bankAccountsApplication.refreshBankAccount(bankAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/pause')
|
||||||
|
async pauseBankAccount(@Param('id') bankAccountId: number) {
|
||||||
|
return this.bankAccountsApplication.pauseBankAccount(bankAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/resume')
|
||||||
|
async resumeBankAccount(@Param('id') bankAccountId: number) {
|
||||||
|
return this.bankAccountsApplication.resumeBankAccount(bankAccountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { BankAccountsApplication } from './BankAccountsApplication.service';
|
||||||
|
import { DisconnectBankAccountService } from './commands/DisconnectBankAccount.service';
|
||||||
|
import { RefreshBankAccountService } from './commands/RefreshBankAccount.service';
|
||||||
|
import { ResumeBankAccountFeedsService } from './commands/ResumeBankAccountFeeds.service';
|
||||||
|
import { PauseBankAccountFeeds } from './commands/PauseBankAccountFeeds.service';
|
||||||
|
import { DeleteUncategorizedTransactionsOnAccountDeleting } from './subscribers/DeleteUncategorizedTransactionsOnAccountDeleting';
|
||||||
|
import { DisconnectPlaidItemOnAccountDeleted } from './subscribers/DisconnectPlaidItemOnAccountDeleted';
|
||||||
|
import { BankAccountsController } from './BankAccounts.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
DisconnectBankAccountService,
|
||||||
|
RefreshBankAccountService,
|
||||||
|
ResumeBankAccountFeedsService,
|
||||||
|
PauseBankAccountFeeds,
|
||||||
|
DeleteUncategorizedTransactionsOnAccountDeleting,
|
||||||
|
DisconnectPlaidItemOnAccountDeleted,
|
||||||
|
],
|
||||||
|
providers: [BankAccountsApplication],
|
||||||
|
exports: [BankAccountsApplication],
|
||||||
|
controllers: [BankAccountsController],
|
||||||
|
})
|
||||||
|
export class BankAccountsModule {}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DisconnectBankAccountService } from './commands/DisconnectBankAccount.service';
|
||||||
|
import { RefreshBankAccountService } from './commands/RefreshBankAccount.service';
|
||||||
|
import { ResumeBankAccountFeedsService } from './commands/ResumeBankAccountFeeds.service';
|
||||||
|
import { PauseBankAccountFeeds } from './commands/PauseBankAccountFeeds.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BankAccountsApplication {
|
||||||
|
constructor(
|
||||||
|
private disconnectBankAccountService: DisconnectBankAccountService,
|
||||||
|
private readonly refreshBankAccountService: RefreshBankAccountService,
|
||||||
|
private readonly resumeBankAccountFeedsService: ResumeBankAccountFeedsService,
|
||||||
|
private readonly pauseBankAccountFeedsService: PauseBankAccountFeeds,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnects the given bank account.
|
||||||
|
* @param {number} bankAccountId - Bank account identifier.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async disconnectBankAccount(bankAccountId: number) {
|
||||||
|
return this.disconnectBankAccountService.disconnectBankAccount(
|
||||||
|
bankAccountId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the bank transactions of the given bank account.
|
||||||
|
* @param {number} bankAccountId - Bank account identifier.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async refreshBankAccount(bankAccountId: number) {
|
||||||
|
return this.refreshBankAccountService.refreshBankAccount(bankAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pauses the feeds sync of the given bank account.
|
||||||
|
* @param {number} bankAccountId - Bank account identifier.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async pauseBankAccount(bankAccountId: number) {
|
||||||
|
return this.pauseBankAccountFeedsService.pauseBankAccountFeeds(
|
||||||
|
bankAccountId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resumes the feeds sync of the given bank account.
|
||||||
|
* @param {number} bankAccountId - Bank account identifier.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async resumeBankAccount(bankAccountId: number) {
|
||||||
|
return this.resumeBankAccountFeedsService.resumeBankAccountFeeds(
|
||||||
|
bankAccountId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { PlaidApi } from 'plaid';
|
||||||
|
import {
|
||||||
|
ERRORS,
|
||||||
|
IBankAccountDisconnectedEventPayload,
|
||||||
|
IBankAccountDisconnectingEventPayload,
|
||||||
|
} from '../types/BankAccounts.types';
|
||||||
|
import { ACCOUNT_TYPE } from '@/constants/accounts';
|
||||||
|
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
|
import { Account } from '@/modules/Accounts/models/Account.model';
|
||||||
|
import { PlaidItem } from '@/modules/BankingPlaid/models/PlaidItem';
|
||||||
|
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
|
import { PLAID_CLIENT } from '@/modules/Plaid/Plaid.module';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DisconnectBankAccountService {
|
||||||
|
constructor(
|
||||||
|
private eventPublisher: EventEmitter2,
|
||||||
|
private uow: UnitOfWork,
|
||||||
|
|
||||||
|
@Inject(Account.name) private accountModel: typeof Account,
|
||||||
|
@Inject(PlaidItem.name) private plaidItemModel: typeof PlaidItem,
|
||||||
|
@Inject(PLAID_CLIENT) private plaidClient: PlaidApi,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnects the given bank account.
|
||||||
|
* @param {number} bankAccountId
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public async disconnectBankAccount(bankAccountId: number) {
|
||||||
|
// Retrieve the bank account or throw not found error.
|
||||||
|
const account = await this.accountModel
|
||||||
|
.query()
|
||||||
|
.findById(bankAccountId)
|
||||||
|
.whereIn('account_type', [ACCOUNT_TYPE.CASH, ACCOUNT_TYPE.BANK])
|
||||||
|
.withGraphFetched('plaidItem')
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
const oldPlaidItem = account.plaidItem;
|
||||||
|
|
||||||
|
if (!oldPlaidItem) {
|
||||||
|
throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED);
|
||||||
|
}
|
||||||
|
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||||
|
// Triggers `onBankAccountDisconnecting` event.
|
||||||
|
await this.eventPublisher.emitAsync(events.bankAccount.onDisconnecting, {
|
||||||
|
bankAccountId,
|
||||||
|
} as IBankAccountDisconnectingEventPayload);
|
||||||
|
|
||||||
|
// Remove the Plaid item from the system.
|
||||||
|
await this.plaidItemModel.query(trx).findById(account.plaidItemId).delete();
|
||||||
|
|
||||||
|
// Remove the plaid item association to the bank account.
|
||||||
|
await this.accountModel.query(trx).findById(bankAccountId).patch({
|
||||||
|
plaidAccountId: null,
|
||||||
|
plaidItemId: null,
|
||||||
|
isFeedsActive: false,
|
||||||
|
});
|
||||||
|
// Remove the Plaid item.
|
||||||
|
await this.plaidClient.itemRemove({
|
||||||
|
access_token: oldPlaidItem.plaidAccessToken,
|
||||||
|
});
|
||||||
|
// Triggers `onBankAccountDisconnected` event.
|
||||||
|
await this.eventPublisher.emitAsync(events.bankAccount.onDisconnected, {
|
||||||
|
bankAccountId,
|
||||||
|
trx,
|
||||||
|
} as IBankAccountDisconnectedEventPayload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { ERRORS } from '../types/BankAccounts.types';
|
||||||
|
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||||
|
import { Account } from '@/modules/Accounts/models/Account.model';
|
||||||
|
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
|
import { PlaidItem } from '@/modules/BankingPlaid/models/PlaidItem';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PauseBankAccountFeeds {
|
||||||
|
constructor(
|
||||||
|
@Inject(Account.name) private accountModel: typeof Account,
|
||||||
|
@Inject(PlaidItem.name) private plaidItemModel: typeof PlaidItem,
|
||||||
|
|
||||||
|
private uow: UnitOfWork,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pauses the bankfeed syncing of the given bank account.
|
||||||
|
* @param {number} bankAccountId
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public async pauseBankAccountFeeds(bankAccountId: number) {
|
||||||
|
const oldAccount = await this.accountModel
|
||||||
|
.query()
|
||||||
|
.findById(bankAccountId)
|
||||||
|
.withGraphFetched('plaidItem')
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
// Can't continue if the bank account is not connected.
|
||||||
|
if (!oldAccount.plaidItem) {
|
||||||
|
throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED);
|
||||||
|
}
|
||||||
|
// Cannot continue if the bank account feeds is already paused.
|
||||||
|
if (oldAccount.plaidItem.isPaused) {
|
||||||
|
throw new ServiceError(ERRORS.BANK_ACCOUNT_FEEDS_ALREADY_PAUSED);
|
||||||
|
}
|
||||||
|
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||||
|
await this.plaidItemModel
|
||||||
|
.query(trx)
|
||||||
|
.findById(oldAccount.plaidItem.id)
|
||||||
|
.patch({
|
||||||
|
pausedAt: new Date(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { PlaidApi } from 'plaid';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { Account } from '@/modules/Accounts/models/Account.model';
|
||||||
|
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||||
|
import { PLAID_CLIENT } from '@/modules/Plaid/Plaid.module';
|
||||||
|
import { ERRORS } from '../types/BankAccounts.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RefreshBankAccountService {
|
||||||
|
constructor(
|
||||||
|
@Inject(PLAID_CLIENT) private plaidClient: PlaidApi,
|
||||||
|
@Inject(Account.name) private readonly accountModel: typeof Account,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks Plaid to trigger syncing the given bank account.
|
||||||
|
* @param {number} bankAccountId - Bank account identifier.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public async refreshBankAccount(bankAccountId: number) {
|
||||||
|
const bankAccount = await this.accountModel
|
||||||
|
.query()
|
||||||
|
.findById(bankAccountId)
|
||||||
|
.withGraphFetched('plaidItem')
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
// Can't continue if the given account is not linked with Plaid item.
|
||||||
|
if (!bankAccount.plaidItem) {
|
||||||
|
throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED);
|
||||||
|
}
|
||||||
|
await this.plaidClient.transactionsRefresh({
|
||||||
|
access_token: bankAccount.plaidItem.plaidAccessToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { ERRORS } from '../types/BankAccounts.types';
|
||||||
|
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
|
import { PlaidItem } from '@/modules/BankingPlaid/models/PlaidItem';
|
||||||
|
import { Account } from '@/modules/Accounts/models/Account.model';
|
||||||
|
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ResumeBankAccountFeedsService {
|
||||||
|
constructor(
|
||||||
|
@Inject(Account.name) private accountModel: typeof Account,
|
||||||
|
@Inject(PlaidItem.name) private plaidItemModel: typeof PlaidItem,
|
||||||
|
|
||||||
|
private uow: UnitOfWork,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resumes the bank feeds syncing of the bank account.
|
||||||
|
* @param {number} bankAccountId
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public async resumeBankAccountFeeds(bankAccountId: number) {
|
||||||
|
const oldAccount = await this.accountModel
|
||||||
|
.query()
|
||||||
|
.findById(bankAccountId)
|
||||||
|
.withGraphFetched('plaidItem');
|
||||||
|
|
||||||
|
// Can't continue if the bank account is not connected.
|
||||||
|
if (!oldAccount.plaidItem) {
|
||||||
|
throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED);
|
||||||
|
}
|
||||||
|
// Cannot continue if the bank account feeds is already paused.
|
||||||
|
if (!oldAccount.plaidItem.isPaused) {
|
||||||
|
throw new ServiceError(ERRORS.BANK_ACCOUNT_FEEDS_ALREADY_RESUMED);
|
||||||
|
}
|
||||||
|
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||||
|
await this.plaidItemModel
|
||||||
|
.query(trx)
|
||||||
|
.findById(oldAccount.plaidItem.id)
|
||||||
|
.patch({
|
||||||
|
pausedAt: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { Account } from '@/modules/Accounts/models/Account.model';
|
||||||
|
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetBankAccountSummary {
|
||||||
|
constructor(
|
||||||
|
@Inject(Account.name)
|
||||||
|
private readonly accountModel: typeof Account,
|
||||||
|
|
||||||
|
@Inject(UncategorizedBankTransaction.name)
|
||||||
|
private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the bank account meta summary
|
||||||
|
* @param {number} bankAccountId - The bank account id.
|
||||||
|
* @returns {Promise<IBankAccountSummary>}
|
||||||
|
*/
|
||||||
|
public async getBankAccountSummary(bankAccountId: number) {
|
||||||
|
const bankAccount = await this.accountModel
|
||||||
|
.query()
|
||||||
|
.findById(bankAccountId)
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
const commonQuery = (q) => {
|
||||||
|
// Include just the given account.
|
||||||
|
q.where('accountId', bankAccountId);
|
||||||
|
|
||||||
|
// Only the not excluded.
|
||||||
|
q.modify('notExcluded');
|
||||||
|
|
||||||
|
// Only the not categorized.
|
||||||
|
q.modify('notCategorized');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Retrieves the uncategorized transactions count of the given bank account.
|
||||||
|
const uncategorizedTranasctionsCount =
|
||||||
|
await this.uncategorizedBankTransactionModel.query().onBuild((q) => {
|
||||||
|
commonQuery(q);
|
||||||
|
|
||||||
|
// Only the not matched bank transactions.
|
||||||
|
q.withGraphJoined('matchedBankTransactions');
|
||||||
|
q.whereNull('matchedBankTransactions.id');
|
||||||
|
|
||||||
|
// Exclude the pending transactions.
|
||||||
|
q.modify('notPending');
|
||||||
|
|
||||||
|
// Count the results.
|
||||||
|
q.count('uncategorized_cashflow_transactions.id as total');
|
||||||
|
q.first();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retrives the recognized transactions count.
|
||||||
|
const recognizedTransactionsCount =
|
||||||
|
await this.uncategorizedBankTransactionModel.query().onBuild((q) => {
|
||||||
|
commonQuery(q);
|
||||||
|
|
||||||
|
q.withGraphJoined('recognizedTransaction');
|
||||||
|
q.whereNotNull('recognizedTransaction.id');
|
||||||
|
|
||||||
|
// Exclude the pending transactions.
|
||||||
|
q.modify('notPending');
|
||||||
|
|
||||||
|
// Count the results.
|
||||||
|
q.count('uncategorized_cashflow_transactions.id as total');
|
||||||
|
q.first();
|
||||||
|
});
|
||||||
|
// Retrieves excluded transactions count.
|
||||||
|
const excludedTransactionsCount =
|
||||||
|
await this.uncategorizedBankTransactionModel.query().onBuild((q) => {
|
||||||
|
q.where('accountId', bankAccountId);
|
||||||
|
q.modify('excluded');
|
||||||
|
|
||||||
|
// Exclude the pending transactions.
|
||||||
|
q.modify('notPending');
|
||||||
|
|
||||||
|
// Count the results.
|
||||||
|
q.count('uncategorized_cashflow_transactions.id as total');
|
||||||
|
q.first();
|
||||||
|
});
|
||||||
|
// Retrieves the pending transactions count.
|
||||||
|
const pendingTransactionsCount =
|
||||||
|
await this.uncategorizedBankTransactionModel.query().onBuild((q) => {
|
||||||
|
q.where('accountId', bankAccountId);
|
||||||
|
q.modify('pending');
|
||||||
|
|
||||||
|
// Count the results.
|
||||||
|
q.count('uncategorized_cashflow_transactions.id as total');
|
||||||
|
q.first();
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalUncategorizedTransactions =
|
||||||
|
uncategorizedTranasctionsCount?.total || 0;
|
||||||
|
const totalRecognizedTransactions = recognizedTransactionsCount?.total || 0;
|
||||||
|
const totalExcludedTransactions = excludedTransactionsCount?.total || 0;
|
||||||
|
const totalPendingTransactions = pendingTransactionsCount?.total || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: bankAccount.name,
|
||||||
|
totalUncategorizedTransactions,
|
||||||
|
totalRecognizedTransactions,
|
||||||
|
totalExcludedTransactions,
|
||||||
|
totalPendingTransactions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
import { IAccountEventDeletePayload } from '@/interfaces/Account';
|
||||||
|
import { RevertRecognizedTransactionsService } from '@/modules/BankingTranasctionsRegonize/commands/RevertRecognizedTransactions.service';
|
||||||
|
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
|
||||||
|
import { DeleteBankRulesService } from '@/modules/BankRules/commands/DeleteBankRules.service';
|
||||||
|
import { BankRule } from '@/modules/BankRules/models/BankRule';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DeleteUncategorizedTransactionsOnAccountDeleting {
|
||||||
|
constructor(
|
||||||
|
private readonly deleteBankRules: DeleteBankRulesService,
|
||||||
|
private readonly revertRecognizedTransactins: RevertRecognizedTransactionsService,
|
||||||
|
|
||||||
|
@Inject(BankRule.name) private bankRuleModel: typeof BankRule,
|
||||||
|
@Inject(UncategorizedBankTransaction.name)
|
||||||
|
private uncategorizedCashflowTransactionModel: typeof UncategorizedBankTransaction,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles revert the recognized transactions and delete all the bank rules
|
||||||
|
* associated to the deleted bank account.
|
||||||
|
* @param {IAccountEventDeletePayload}
|
||||||
|
*/
|
||||||
|
@OnEvent(events.accounts.onDelete)
|
||||||
|
public async handleDeleteBankRulesOnAccountDeleting({
|
||||||
|
oldAccount,
|
||||||
|
trx,
|
||||||
|
}: IAccountEventDeletePayload) {
|
||||||
|
const foundAssociatedRules = await this.bankRuleModel.query(trx).where(
|
||||||
|
'applyIfAccountId',
|
||||||
|
oldAccount.id,
|
||||||
|
);
|
||||||
|
const foundAssociatedRulesIds = foundAssociatedRules.map((rule) => rule.id);
|
||||||
|
|
||||||
|
// Revert the recognized transactions of the given bank rules.
|
||||||
|
await this.revertRecognizedTransactins.revertRecognizedTransactions(
|
||||||
|
foundAssociatedRulesIds,
|
||||||
|
null,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
// Delete the associated uncategorized transactions.
|
||||||
|
await this.uncategorizedCashflowTransactionModel
|
||||||
|
.query(trx)
|
||||||
|
.where('accountId', oldAccount.id)
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
// Delete the given bank rules.
|
||||||
|
await this.deleteBankRules.deleteBankRules(
|
||||||
|
foundAssociatedRulesIds,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { IAccountEventDeletedPayload } from '@/interfaces/Account';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
import { PlaidItem } from '@/modules/BankingPlaid/models/PlaidItem';
|
||||||
|
import { Account } from '@/modules/Accounts/models/Account.model';
|
||||||
|
import { PlaidApi } from 'plaid';
|
||||||
|
import { PLAID_CLIENT } from '@/modules/Plaid/Plaid.module';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DisconnectPlaidItemOnAccountDeleted {
|
||||||
|
constructor(
|
||||||
|
@Inject(PLAID_CLIENT) private plaidClient: PlaidApi,
|
||||||
|
@Inject(PlaidItem.name) private plaidItemModel: typeof PlaidItem,
|
||||||
|
@Inject(Account.name) private accountModel: typeof Account,
|
||||||
|
) {}
|
||||||
|
/**
|
||||||
|
* Deletes Plaid item from the system and Plaid once the account deleted.
|
||||||
|
* @param {IAccountEventDeletedPayload} payload
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
@OnEvent(events.accounts.onDeleted)
|
||||||
|
public async handleDisconnectPlaidItemOnAccountDelete({
|
||||||
|
tenantId,
|
||||||
|
oldAccount,
|
||||||
|
trx,
|
||||||
|
}: IAccountEventDeletedPayload) {
|
||||||
|
// Can't continue if the deleted account is not linked to Plaid item.
|
||||||
|
if (!oldAccount.plaidItemId) return;
|
||||||
|
|
||||||
|
// Retrieves the Plaid item that associated to the deleted account.
|
||||||
|
const oldPlaidItem = await this.plaidItemModel
|
||||||
|
.query(trx)
|
||||||
|
.findOne('plaidItemId', oldAccount.plaidItemId);
|
||||||
|
// Unlink the Plaid item from all account before deleting it.
|
||||||
|
await this.accountModel
|
||||||
|
.query(trx)
|
||||||
|
.where('plaidItemId', oldAccount.plaidItemId)
|
||||||
|
.patch({
|
||||||
|
plaidAccountId: null,
|
||||||
|
plaidItemId: null,
|
||||||
|
});
|
||||||
|
// Remove the Plaid item from the system.
|
||||||
|
await this.plaidItemModel
|
||||||
|
.query(trx)
|
||||||
|
.findOne('plaidItemId', oldAccount.plaidItemId)
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
// Remove Plaid item once the transaction resolve.
|
||||||
|
if (oldPlaidItem) {
|
||||||
|
// Remove the Plaid item.
|
||||||
|
await this.plaidClient.itemRemove({
|
||||||
|
access_token: oldPlaidItem.plaidAccessToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
|
||||||
|
export interface IBankAccountDisconnectingEventPayload {
|
||||||
|
bankAccountId: number;
|
||||||
|
trx: Knex.Transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBankAccountDisconnectedEventPayload {
|
||||||
|
bankAccountId: number;
|
||||||
|
trx: Knex.Transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ERRORS = {
|
||||||
|
BANK_ACCOUNT_NOT_CONNECTED: 'BANK_ACCOUNT_NOT_CONNECTED',
|
||||||
|
BANK_ACCOUNT_FEEDS_ALREADY_PAUSED: 'BANK_ACCOUNT_FEEDS_ALREADY_PAUSED',
|
||||||
|
BANK_ACCOUNT_FEEDS_ALREADY_RESUMED: 'BANK_ACCOUNT_FEEDS_ALREADY_RESUMED',
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { castArray } from 'lodash';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import {
|
||||||
|
ICashflowTransactionCategorizedPayload,
|
||||||
|
ICashflowTransactionUncategorizingPayload,
|
||||||
|
ICategorizeCashflowTransactioDTO,
|
||||||
|
} from '../types/BankingCategorize.types';
|
||||||
|
import {
|
||||||
|
transformCategorizeTransToCashflow,
|
||||||
|
validateUncategorizedTransactionsNotExcluded,
|
||||||
|
} from '../../BankingTransactions/utils';
|
||||||
|
import { CommandBankTransactionValidator } from '../../BankingTransactions/commands/CommandCasflowValidator.service';
|
||||||
|
import { CreateBankTransactionService } from '../../BankingTransactions/commands/CreateBankTransaction.service';
|
||||||
|
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
|
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CategorizeCashflowTransaction {
|
||||||
|
constructor(
|
||||||
|
private readonly eventPublisher: EventEmitter2,
|
||||||
|
private readonly uow: UnitOfWork,
|
||||||
|
private readonly commandValidators: CommandBankTransactionValidator,
|
||||||
|
private readonly createBankTransaction: CreateBankTransactionService,
|
||||||
|
|
||||||
|
@Inject(UncategorizedBankTransaction.name)
|
||||||
|
private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorize the given cashflow transaction.
|
||||||
|
* @param {ICategorizeCashflowTransactioDTO} categorizeDTO - Categorize DTO.
|
||||||
|
*/
|
||||||
|
public async categorize(
|
||||||
|
uncategorizedTransactionId: number | Array<number>,
|
||||||
|
categorizeDTO: ICategorizeCashflowTransactioDTO,
|
||||||
|
) {
|
||||||
|
const uncategorizedTransactionIds = castArray(uncategorizedTransactionId);
|
||||||
|
|
||||||
|
// Retrieves the uncategorized transaction or throw an error.
|
||||||
|
const oldUncategorizedTransactions =
|
||||||
|
await this.uncategorizedBankTransactionModel.query()
|
||||||
|
.whereIn('id', uncategorizedTransactionIds)
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
// Validate cannot categorize excluded transaction.
|
||||||
|
validateUncategorizedTransactionsNotExcluded(oldUncategorizedTransactions);
|
||||||
|
|
||||||
|
// Validates the transaction shouldn't be categorized before.
|
||||||
|
this.commandValidators.validateTransactionsShouldNotCategorized(
|
||||||
|
oldUncategorizedTransactions,
|
||||||
|
);
|
||||||
|
// Validate the uncateogirzed transaction if it's deposit the transaction direction
|
||||||
|
// should `IN` and the same thing if it's withdrawal the direction should be OUT.
|
||||||
|
this.commandValidators.validateUncategorizeTransactionType(
|
||||||
|
oldUncategorizedTransactions,
|
||||||
|
categorizeDTO.transactionType,
|
||||||
|
);
|
||||||
|
// Edits the cashflow transaction under UOW env.
|
||||||
|
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||||
|
// Triggers `onTransactionCategorizing` event.
|
||||||
|
await this.eventPublisher.emitAsync(
|
||||||
|
events.cashflow.onTransactionCategorizing,
|
||||||
|
{
|
||||||
|
// tenantId,
|
||||||
|
oldUncategorizedTransactions,
|
||||||
|
trx,
|
||||||
|
} as ICashflowTransactionUncategorizingPayload,
|
||||||
|
);
|
||||||
|
// Transformes the categorize DTO to the cashflow transaction.
|
||||||
|
const cashflowTransactionDTO = transformCategorizeTransToCashflow(
|
||||||
|
oldUncategorizedTransactions,
|
||||||
|
categorizeDTO,
|
||||||
|
);
|
||||||
|
// Creates a new cashflow transaction.
|
||||||
|
const cashflowTransaction =
|
||||||
|
await this.createBankTransaction.newCashflowTransaction(
|
||||||
|
cashflowTransactionDTO,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Updates the uncategorized transaction as categorized.
|
||||||
|
await this.uncategorizedBankTransactionModel.query(trx)
|
||||||
|
.whereIn('id', uncategorizedTransactionIds)
|
||||||
|
.patch({
|
||||||
|
categorized: true,
|
||||||
|
categorizeRefType: 'CashflowTransaction',
|
||||||
|
categorizeRefId: cashflowTransaction.id,
|
||||||
|
});
|
||||||
|
// Fetch the new updated uncategorized transactions.
|
||||||
|
const uncategorizedTransactions =
|
||||||
|
await this.uncategorizedBankTransactionModel.query(trx).whereIn(
|
||||||
|
'id',
|
||||||
|
uncategorizedTransactionIds,
|
||||||
|
);
|
||||||
|
// Triggers `onCashflowTransactionCategorized` event.
|
||||||
|
await this.eventPublisher.emitAsync(
|
||||||
|
events.cashflow.onTransactionCategorized,
|
||||||
|
{
|
||||||
|
cashflowTransaction,
|
||||||
|
uncategorizedTransactions,
|
||||||
|
oldUncategorizedTransactions,
|
||||||
|
categorizeDTO,
|
||||||
|
trx,
|
||||||
|
} as ICashflowTransactionCategorizedPayload,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
CategorizeTransactionAsExpenseDTO,
|
||||||
|
ICashflowTransactionCategorizedPayload,
|
||||||
|
} from '@/interfaces';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
|
||||||
|
import { Expense } from '@/modules/Expenses/models/Expense.model';
|
||||||
|
import { BankTransaction } from '@/modules/BankingTransactions/models/BankTransaction';
|
||||||
|
import { CreateExpense } from '@/modules/Expenses/commands/CreateExpense.service';
|
||||||
|
import { Inject } from '@nestjs/common';
|
||||||
|
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
import { ICashflowTransactionUncategorizedPayload } from '../types/BankingCategorize.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CategorizeTransactionAsExpense {
|
||||||
|
constructor(
|
||||||
|
private readonly uow: UnitOfWork,
|
||||||
|
private readonly eventPublisher: EventEmitter2,
|
||||||
|
private readonly createExpenseService: CreateExpense,
|
||||||
|
|
||||||
|
@Inject(UncategorizedBankTransaction.name)
|
||||||
|
private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction,
|
||||||
|
|
||||||
|
@Inject(Expense.name)
|
||||||
|
private readonly expenseModel: typeof Expense,
|
||||||
|
|
||||||
|
@Inject(BankTransaction.name)
|
||||||
|
private readonly bankTransactionModel: typeof BankTransaction,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorize the transaction as expense transaction.
|
||||||
|
* @param {number} cashflowTransactionId
|
||||||
|
* @param {CategorizeTransactionAsExpenseDTO} transactionDTO
|
||||||
|
*/
|
||||||
|
public async categorize(
|
||||||
|
cashflowTransactionId: number,
|
||||||
|
transactionDTO: CategorizeTransactionAsExpenseDTO,
|
||||||
|
) {
|
||||||
|
const transaction = await this.bankTransactionModel
|
||||||
|
.query()
|
||||||
|
.findById(cashflowTransactionId)
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||||
|
// Triggers `onTransactionUncategorizing` event.
|
||||||
|
await this.eventPublisher.emitAsync(
|
||||||
|
events.cashflow.onTransactionCategorizingAsExpense,
|
||||||
|
{
|
||||||
|
trx,
|
||||||
|
} as ICashflowTransactionCategorizedPayload,
|
||||||
|
);
|
||||||
|
// Creates a new expense transaction.
|
||||||
|
const expenseTransaction = await this.createExpenseService.newExpense({});
|
||||||
|
|
||||||
|
// Updates the item on the storage and fetches the updated once.
|
||||||
|
const cashflowTransaction = await this.bankTransactionModel
|
||||||
|
.query(trx)
|
||||||
|
.patchAndFetchById(cashflowTransactionId, {
|
||||||
|
categorizeRefType: 'Expense',
|
||||||
|
categorizeRefId: expenseTransaction.id,
|
||||||
|
uncategorized: true,
|
||||||
|
});
|
||||||
|
// Triggers `onTransactionUncategorized` event.
|
||||||
|
await this.eventPublisher.emitAsync(
|
||||||
|
events.cashflow.onTransactionCategorizedAsExpense,
|
||||||
|
{
|
||||||
|
cashflowTransaction,
|
||||||
|
trx,
|
||||||
|
} as ICashflowTransactionUncategorizedPayload,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import {
|
||||||
|
CreateUncategorizedTransactionDTO,
|
||||||
|
IUncategorizedTransactionCreatedEventPayload,
|
||||||
|
IUncategorizedTransactionCreatingEventPayload,
|
||||||
|
} from '../types/BankingCategorize.types';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { UncategorizedBankTransaction } from '../../BankingTransactions/models/UncategorizedBankTransaction';
|
||||||
|
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CreateUncategorizedTransactionService {
|
||||||
|
constructor(
|
||||||
|
private readonly uow: UnitOfWork,
|
||||||
|
private readonly eventPublisher: EventEmitter2,
|
||||||
|
|
||||||
|
@Inject(UncategorizedBankTransaction.name)
|
||||||
|
private readonly uncategorizedBankTransaction: typeof UncategorizedBankTransaction,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an uncategorized cashflow transaction.
|
||||||
|
* @param {CreateUncategorizedTransactionDTO} createDTO - Create uncategorized transaction DTO.
|
||||||
|
* @param {Knex.Transaction} trx - Knex transaction.
|
||||||
|
* @returns {Promise<UncategorizedBankTransaction>}
|
||||||
|
*/
|
||||||
|
public create(
|
||||||
|
createUncategorizedTransactionDTO: CreateUncategorizedTransactionDTO,
|
||||||
|
trx?: Knex.Transaction
|
||||||
|
) {
|
||||||
|
return this.uow.withTransaction(
|
||||||
|
async (trx: Knex.Transaction) => {
|
||||||
|
await this.eventPublisher.emitAsync(
|
||||||
|
events.cashflow.onTransactionUncategorizedCreating,
|
||||||
|
{
|
||||||
|
createUncategorizedTransactionDTO,
|
||||||
|
trx,
|
||||||
|
} as IUncategorizedTransactionCreatingEventPayload
|
||||||
|
);
|
||||||
|
|
||||||
|
const uncategorizedTransaction =
|
||||||
|
await this.uncategorizedBankTransaction.query(trx).insertAndFetch({
|
||||||
|
...createUncategorizedTransactionDTO,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.eventPublisher.emitAsync(
|
||||||
|
events.cashflow.onTransactionUncategorizedCreated,
|
||||||
|
{
|
||||||
|
uncategorizedTransaction,
|
||||||
|
createUncategorizedTransactionDTO,
|
||||||
|
trx,
|
||||||
|
} as IUncategorizedTransactionCreatedEventPayload
|
||||||
|
);
|
||||||
|
return uncategorizedTransaction;
|
||||||
|
},
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import {
|
||||||
|
ICashflowTransactionUncategorizedPayload,
|
||||||
|
ICashflowTransactionUncategorizingPayload,
|
||||||
|
} from '../types/BankingCategorize.types';
|
||||||
|
import { validateTransactionShouldBeCategorized } from '../../BankingTransactions/utils';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
import { UncategorizedBankTransaction } from '../../BankingTransactions/models/UncategorizedBankTransaction';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UncategorizeCashflowTransactionService {
|
||||||
|
constructor(
|
||||||
|
private readonly eventPublisher: EventEmitter2,
|
||||||
|
private readonly uow: UnitOfWork,
|
||||||
|
|
||||||
|
@Inject(UncategorizedBankTransaction.name)
|
||||||
|
private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uncategorizes the given cashflow transaction.
|
||||||
|
* @param {number} cashflowTransactionId - The id of the cashflow transaction to be uncategorized.
|
||||||
|
* @returns {Promise<Array<number>>}
|
||||||
|
*/
|
||||||
|
public async uncategorize(
|
||||||
|
uncategorizedTransactionId: number,
|
||||||
|
): Promise<Array<number>> {
|
||||||
|
const oldMainUncategorizedTransaction =
|
||||||
|
await this.uncategorizedBankTransactionModel
|
||||||
|
.query()
|
||||||
|
.findById(uncategorizedTransactionId)
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
validateTransactionShouldBeCategorized(oldMainUncategorizedTransaction);
|
||||||
|
|
||||||
|
const associatedUncategorizedTransactions =
|
||||||
|
await this.uncategorizedBankTransactionModel
|
||||||
|
.query()
|
||||||
|
.where(
|
||||||
|
'categorizeRefId',
|
||||||
|
oldMainUncategorizedTransaction.categorizeRefId,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
'categorizeRefType',
|
||||||
|
oldMainUncategorizedTransaction.categorizeRefType,
|
||||||
|
)
|
||||||
|
// Exclude the main transaction.
|
||||||
|
.whereNot('id', uncategorizedTransactionId);
|
||||||
|
|
||||||
|
const oldUncategorizedTransactions = [
|
||||||
|
oldMainUncategorizedTransaction,
|
||||||
|
...associatedUncategorizedTransactions,
|
||||||
|
];
|
||||||
|
const oldUncategoirzedTransactionsIds = oldUncategorizedTransactions.map(
|
||||||
|
(t) => t.id,
|
||||||
|
);
|
||||||
|
// Updates the transaction under UOW.
|
||||||
|
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||||
|
// Triggers `onTransactionUncategorizing` event.
|
||||||
|
await this.eventPublisher.emitAsync(
|
||||||
|
events.cashflow.onTransactionUncategorizing,
|
||||||
|
{
|
||||||
|
uncategorizedTransactionId,
|
||||||
|
oldUncategorizedTransactions,
|
||||||
|
trx,
|
||||||
|
} as ICashflowTransactionUncategorizingPayload,
|
||||||
|
);
|
||||||
|
// Removes the ref relation with the related transaction.
|
||||||
|
await this.uncategorizedBankTransactionModel
|
||||||
|
.query(trx)
|
||||||
|
.whereIn('id', oldUncategoirzedTransactionsIds)
|
||||||
|
.patch({
|
||||||
|
categorized: false,
|
||||||
|
categorizeRefId: null,
|
||||||
|
categorizeRefType: null,
|
||||||
|
});
|
||||||
|
const uncategorizedTransactions =
|
||||||
|
await this.uncategorizedBankTransactionModel
|
||||||
|
.query(trx)
|
||||||
|
.whereIn('id', oldUncategoirzedTransactionsIds);
|
||||||
|
// Triggers `onTransactionUncategorized` event.
|
||||||
|
await this.eventPublisher.emitAsync(
|
||||||
|
events.cashflow.onTransactionUncategorized,
|
||||||
|
{
|
||||||
|
uncategorizedTransactionId,
|
||||||
|
oldMainUncategorizedTransaction,
|
||||||
|
uncategorizedTransactions,
|
||||||
|
oldUncategorizedTransactions,
|
||||||
|
trx,
|
||||||
|
} as ICashflowTransactionUncategorizedPayload,
|
||||||
|
);
|
||||||
|
return oldUncategoirzedTransactionsIds;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { castArray } from 'lodash';
|
||||||
|
import { PromisePool } from '@supercharge/promise-pool';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { UncategorizeCashflowTransactionService } from './UncategorizeCashflowTransaction.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UncategorizeCashflowTransactionsBulk {
|
||||||
|
constructor(
|
||||||
|
private readonly uncategorizeTransactionService: UncategorizeCashflowTransactionService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uncategorize the given bank transactions in bulk.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number} uncategorizedTransactionId
|
||||||
|
*/
|
||||||
|
public async uncategorizeBulk(
|
||||||
|
uncategorizedTransactionId: number | Array<number>
|
||||||
|
) {
|
||||||
|
const uncategorizedTransactionIds = castArray(uncategorizedTransactionId);
|
||||||
|
|
||||||
|
const result = await PromisePool.withConcurrency(MIGRATION_CONCURRENCY)
|
||||||
|
.for(uncategorizedTransactionIds)
|
||||||
|
.process(async (_uncategorizedTransactionId: number, index, pool) => {
|
||||||
|
await this.uncategorizeTransactionService.uncategorize(
|
||||||
|
_uncategorizedTransactionId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIGRATION_CONCURRENCY = 1;
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { Transformer } from "../../Transformer/Transformer";
|
||||||
|
|
||||||
|
export class UncategorizedTransactionTransformer extends Transformer {
|
||||||
|
/**
|
||||||
|
* Include these attributes to sale invoice object.
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
public includeAttributes = (): string[] => {
|
||||||
|
return [
|
||||||
|
'formattedAmount',
|
||||||
|
'formattedDate',
|
||||||
|
'formattedDepositAmount',
|
||||||
|
'formattedWithdrawalAmount',
|
||||||
|
|
||||||
|
'assignedAccountId',
|
||||||
|
'assignedAccountName',
|
||||||
|
'assignedAccountCode',
|
||||||
|
'assignedPayee',
|
||||||
|
'assignedMemo',
|
||||||
|
'assignedCategory',
|
||||||
|
'assignedCategoryFormatted',
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exclude all attributes.
|
||||||
|
* @returns {Array<string>}
|
||||||
|
*/
|
||||||
|
public excludeAttributes = (): string[] => {
|
||||||
|
return ['recognizedTransaction'];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formattes the transaction date.
|
||||||
|
* @param transaction
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
public formattedDate(transaction) {
|
||||||
|
return this.formatDate(transaction.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatted amount.
|
||||||
|
* @param transaction
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
public formattedAmount(transaction) {
|
||||||
|
return this.formatNumber(transaction.amount, {
|
||||||
|
currencyCode: transaction.currencyCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatted deposit amount.
|
||||||
|
* @param transaction
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected formattedDepositAmount(transaction) {
|
||||||
|
if (transaction.isDepositTransaction) {
|
||||||
|
return this.formatNumber(transaction.deposit, {
|
||||||
|
currencyCode: transaction.currencyCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatted withdrawal amount.
|
||||||
|
* @param transaction
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected formattedWithdrawalAmount(transaction) {
|
||||||
|
if (transaction.isWithdrawalTransaction) {
|
||||||
|
return this.formatNumber(transaction.withdrawal, {
|
||||||
|
currencyCode: transaction.currencyCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// # Recgonized transaction
|
||||||
|
// --------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Get the assigned account ID of the transaction.
|
||||||
|
* @param {object} transaction
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
public assignedAccountId(transaction: any): number {
|
||||||
|
return transaction.recognizedTransaction?.assignedAccountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the assigned account name of the transaction.
|
||||||
|
* @param {object} transaction
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
public assignedAccountName(transaction: any): string {
|
||||||
|
return transaction.recognizedTransaction?.assignAccount?.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the assigned account code of the transaction.
|
||||||
|
* @param {object} transaction
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
public assignedAccountCode(transaction: any): string {
|
||||||
|
return transaction.recognizedTransaction?.assignAccount?.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the assigned payee of the transaction.
|
||||||
|
* @param {object} transaction
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
public getAssignedPayee(transaction: any): string {
|
||||||
|
return transaction.recognizedTransaction?.assignedPayee;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the assigned memo of the transaction.
|
||||||
|
* @param {object} transaction
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
public assignedMemo(transaction: any): string {
|
||||||
|
return transaction.recognizedTransaction?.assignedMemo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the assigned category of the transaction.
|
||||||
|
* @param {object} transaction
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
public assignedCategory(transaction: any): string {
|
||||||
|
return transaction.recognizedTransaction?.assignedCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the assigned formatted category.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
public assignedCategoryFormatted() {
|
||||||
|
return 'Other Income';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import * as yup from 'yup';
|
||||||
|
import uniqid from 'uniqid';
|
||||||
|
import { Importable } from '../../Import/Importable';
|
||||||
|
import { CreateUncategorizedTransactionService } from './CreateUncategorizedTransaction.service';
|
||||||
|
import { ImportableContext } from '../../Import/interfaces';
|
||||||
|
import { BankTransactionsSampleData } from '../../BankingTransactions/constants';
|
||||||
|
import { Account } from '@/modules/Accounts/models/Account.model';
|
||||||
|
import { CreateUncategorizedTransactionDTO } from '../types/BankingCategorize.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UncategorizedTransactionsImportable extends Importable {
|
||||||
|
constructor(
|
||||||
|
private readonly createUncategorizedTransaction: CreateUncategorizedTransactionService,
|
||||||
|
|
||||||
|
@Inject(Account.name)
|
||||||
|
private readonly accountModel: typeof Account,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passing the sheet DTO to create uncategorized transaction.
|
||||||
|
* @param {CreateUncategorizedTransactionDTO,} createDTO
|
||||||
|
* @param {Knex.Transaction} trx
|
||||||
|
*/
|
||||||
|
public async importable(
|
||||||
|
createDTO: CreateUncategorizedTransactionDTO,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
) {
|
||||||
|
return this.createUncategorizedTransaction.create(createDTO, trx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transformes the DTO before validating and importing.
|
||||||
|
* @param {CreateUncategorizedTransactionDTO} createDTO
|
||||||
|
* @param {ImportableContext} context
|
||||||
|
* @returns {CreateUncategorizedTransactionDTO}
|
||||||
|
*/
|
||||||
|
public transform(
|
||||||
|
createDTO: CreateUncategorizedTransactionDTO,
|
||||||
|
context?: ImportableContext,
|
||||||
|
): CreateUncategorizedTransactionDTO {
|
||||||
|
return {
|
||||||
|
...createDTO,
|
||||||
|
accountId: context.import.paramsParsed.accountId,
|
||||||
|
batch: context.import.paramsParsed.batch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample data used to download sample sheet.
|
||||||
|
* @returns {Record<string, any>[]}
|
||||||
|
*/
|
||||||
|
public sampleData(): Record<string, any>[] {
|
||||||
|
return BankTransactionsSampleData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------
|
||||||
|
// # Params
|
||||||
|
// ------------------
|
||||||
|
/**
|
||||||
|
* Params validation schema.
|
||||||
|
* @returns {ValidationSchema[]}
|
||||||
|
*/
|
||||||
|
public paramsValidationSchema() {
|
||||||
|
return yup.object().shape({
|
||||||
|
accountId: yup.number().required(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the params existance asyncly.
|
||||||
|
* @param {number} tenantId -
|
||||||
|
* @param {Record<string, any>} params -
|
||||||
|
*/
|
||||||
|
public async validateParams(params: Record<string, any>): Promise<void> {
|
||||||
|
if (params.accountId) {
|
||||||
|
await this.accountModel
|
||||||
|
.query()
|
||||||
|
.findById(params.accountId)
|
||||||
|
.throwIfNotFound({});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms the import params before storing them.
|
||||||
|
* @param {Record<string, any>} parmas
|
||||||
|
*/
|
||||||
|
public transformParams(parmas: Record<string, any>) {
|
||||||
|
const batch = uniqid();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...parmas,
|
||||||
|
batch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { UncategorizedBankTransaction } from "@/modules/BankingTransactions/models/UncategorizedBankTransaction";
|
||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
export interface ICashflowTransactionCategorizedPayload {
|
||||||
|
uncategorizedTransactions: Array<UncategorizedBankTransaction>;
|
||||||
|
cashflowTransaction: UncategorizedBankTransaction;
|
||||||
|
oldUncategorizedTransactions: Array<UncategorizedBankTransaction>;
|
||||||
|
categorizeDTO: any;
|
||||||
|
trx: Knex.Transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICashflowTransactionUncategorizingPayload {
|
||||||
|
uncategorizedTransactionId: number;
|
||||||
|
oldUncategorizedTransactions: Array<UncategorizedBankTransaction>;
|
||||||
|
trx: Knex.Transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICashflowTransactionUncategorizedPayload {
|
||||||
|
uncategorizedTransactionId: number;
|
||||||
|
uncategorizedTransactions: Array<UncategorizedBankTransaction>;
|
||||||
|
oldMainUncategorizedTransaction: UncategorizedBankTransaction;
|
||||||
|
oldUncategorizedTransactions: Array<UncategorizedBankTransaction>;
|
||||||
|
trx: Knex.Transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICategorizeCashflowTransactioDTO {
|
||||||
|
date: Date;
|
||||||
|
creditAccountId: number;
|
||||||
|
referenceNo: string;
|
||||||
|
transactionNumber: string;
|
||||||
|
transactionType: string;
|
||||||
|
exchangeRate: number;
|
||||||
|
description: string;
|
||||||
|
branchId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface IUncategorizedTransactionCreatingEventPayload {
|
||||||
|
tenantId: number;
|
||||||
|
createUncategorizedTransactionDTO: CreateUncategorizedTransactionDTO;
|
||||||
|
trx: Knex.Transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IUncategorizedTransactionCreatedEventPayload {
|
||||||
|
tenantId: number;
|
||||||
|
uncategorizedTransaction: any;
|
||||||
|
createUncategorizedTransactionDTO: CreateUncategorizedTransactionDTO;
|
||||||
|
trx: Knex.Transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUncategorizedTransactionDTO {
|
||||||
|
date: Date | string;
|
||||||
|
accountId: number;
|
||||||
|
amount: number;
|
||||||
|
currencyCode: string;
|
||||||
|
payee?: string;
|
||||||
|
description?: string;
|
||||||
|
referenceNo?: string | null;
|
||||||
|
plaidTransactionId?: string | null;
|
||||||
|
pending?: boolean;
|
||||||
|
pendingPlaidTransactionId?: string | null;
|
||||||
|
batch?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MatchedBankTransaction } from './models/MatchedBankTransaction';
|
||||||
|
import { RegisterTenancyModel } from '../Tenancy/TenancyModels/Tenancy.module';
|
||||||
|
import { BankingMatchingApplication } from './BankingMatchingApplication';
|
||||||
|
import { GetMatchedTransactions } from './queries/GetMatchedTransactions.service';
|
||||||
|
import { UnmatchMatchedBankTransaction } from './commands/UnmatchMatchedTransaction.service';
|
||||||
|
import { GetMatchedTransactionsByBills } from './queries/GetMatchedTransactionsByBills.service';
|
||||||
|
import { GetMatchedTransactionsByCashflow } from './queries/GetMatchedTransactionsByCashflow';
|
||||||
|
import { GetMatchedTransactionsByExpenses } from './queries/GetMatchedTransactionsByExpenses';
|
||||||
|
import { GetMatchedTransactionsByInvoices } from './queries/GetMatchedTransactionsByInvoices.service';
|
||||||
|
import { ValidateMatchingOnExpenseDeleteSubscriber } from './events/ValidateMatchingOnExpenseDelete';
|
||||||
|
import { ValidateMatchingOnPaymentReceivedDeleteSubscriber } from './events/ValidateMatchingOnPaymentReceivedDelete';
|
||||||
|
import { DecrementUncategorizedTransactionOnMatchingSubscriber } from './events/DecrementUncategorizedTransactionsOnMatch';
|
||||||
|
import { ValidateMatchingOnPaymentMadeDeleteSubscriber } from './events/ValidateMatchingOnPaymentMadeDelete';
|
||||||
|
import { ValidateMatchingOnManualJournalDeleteSubscriber } from './events/ValidateMatchingOnManualJournalDelete';
|
||||||
|
import { ValidateMatchingOnCashflowDeleteSubscriber } from './events/ValidateMatchingOnCashflowDelete';
|
||||||
|
|
||||||
|
const models = [RegisterTenancyModel(MatchedBankTransaction)];
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
...models,
|
||||||
|
GetMatchedTransactionsByBills,
|
||||||
|
GetMatchedTransactionsByCashflow,
|
||||||
|
GetMatchedTransactionsByExpenses,
|
||||||
|
GetMatchedTransactionsByInvoices,
|
||||||
|
BankingMatchingApplication,
|
||||||
|
GetMatchedTransactions,
|
||||||
|
UnmatchMatchedBankTransaction,
|
||||||
|
ValidateMatchingOnExpenseDeleteSubscriber,
|
||||||
|
ValidateMatchingOnPaymentReceivedDeleteSubscriber,
|
||||||
|
DecrementUncategorizedTransactionOnMatchingSubscriber,
|
||||||
|
ValidateMatchingOnPaymentMadeDeleteSubscriber,
|
||||||
|
ValidateMatchingOnManualJournalDeleteSubscriber,
|
||||||
|
ValidateMatchingOnCashflowDeleteSubscriber
|
||||||
|
],
|
||||||
|
exports: [...models],
|
||||||
|
})
|
||||||
|
export class BankingMatchingModule {}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BankingMatchingApplication {
|
||||||
|
constructor(
|
||||||
|
private readonly getMatchedTransactionsService: GetMatchedTransactions,
|
||||||
|
private readonly matchTransactionService: MatchBankTransactions,
|
||||||
|
private readonly unmatchMatchedTransactionService: UnmatchMatchedBankTransaction
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the matched transactions.
|
||||||
|
* @param {Array<number>} uncategorizedTransactionsIds - Uncategorized transactions ids.
|
||||||
|
* @param {GetMatchedTransactionsFilter} filter -
|
||||||
|
* @returns {Promise<MatchedTransactionsPOJO>}
|
||||||
|
*/
|
||||||
|
public getMatchedTransactions(
|
||||||
|
uncategorizedTransactionsIds: Array<number>,
|
||||||
|
filter: GetMatchedTransactionsFilter
|
||||||
|
) {
|
||||||
|
return this.getMatchedTransactionsService.getMatchedTransactions(
|
||||||
|
uncategorizedTransactionsIds,
|
||||||
|
filter
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches the given uncategorized transaction with the given system transaction.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number} uncategorizedTransactionId
|
||||||
|
* @param {IMatchTransactionDTO} matchTransactionsDTO
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public matchTransaction(
|
||||||
|
tenantId: number,
|
||||||
|
uncategorizedTransactionId: number | Array<number>,
|
||||||
|
matchedTransactions: Array<IMatchTransactionDTO>
|
||||||
|
): Promise<void> {
|
||||||
|
return this.matchTransactionService.matchTransaction(
|
||||||
|
tenantId,
|
||||||
|
uncategorizedTransactionId,
|
||||||
|
matchedTransactions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmatch the given matched transaction.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number} uncategorizedTransactionId
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public unmatchMatchedTransaction(
|
||||||
|
tenantId: number,
|
||||||
|
uncategorizedTransactionId: number
|
||||||
|
) {
|
||||||
|
return this.unmatchMatchedTransactionService.unmatchMatchedTransaction(
|
||||||
|
tenantId,
|
||||||
|
uncategorizedTransactionId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
packages/server-nest/src/modules/BankingMatching/_utils.ts
Normal file
64
packages/server-nest/src/modules/BankingMatching/_utils.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import moment from 'moment';
|
||||||
|
import * as R from 'ramda';
|
||||||
|
import { isEmpty, sumBy } from 'lodash';
|
||||||
|
import { ERRORS, MatchedTransactionPOJO } from './types';
|
||||||
|
import { ServiceError } from '../Items/ServiceError';
|
||||||
|
|
||||||
|
export const sortClosestMatchTransactions = (
|
||||||
|
amount: number,
|
||||||
|
date: Date,
|
||||||
|
matches: MatchedTransactionPOJO[]
|
||||||
|
) => {
|
||||||
|
return R.sortWith([
|
||||||
|
// Sort by amount difference (closest to uncategorized transaction amount first)
|
||||||
|
R.ascend((match: MatchedTransactionPOJO) =>
|
||||||
|
Math.abs(match.amount - amount)
|
||||||
|
),
|
||||||
|
// Sort by date difference (closest to uncategorized transaction date first)
|
||||||
|
R.ascend((match: MatchedTransactionPOJO) =>
|
||||||
|
Math.abs(moment(match.date).diff(moment(date), 'days'))
|
||||||
|
),
|
||||||
|
])(matches);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sumMatchTranasctions = (transactions: Array<any>) => {
|
||||||
|
return transactions.reduce(
|
||||||
|
(total, item) =>
|
||||||
|
total +
|
||||||
|
(item.transactionNormal === 'debit' ? 1 : -1) * parseFloat(item.amount),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sumUncategorizedTransactions = (
|
||||||
|
uncategorizedTransactions: Array<any>
|
||||||
|
) => {
|
||||||
|
return sumBy(uncategorizedTransactions, 'amount');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateUncategorizedTransactionsNotMatched = (
|
||||||
|
uncategorizedTransactions: any
|
||||||
|
) => {
|
||||||
|
const matchedTransactions = uncategorizedTransactions.filter(
|
||||||
|
(trans) => !isEmpty(trans.matchedBankTransactions)
|
||||||
|
);
|
||||||
|
//
|
||||||
|
if (matchedTransactions.length > 0) {
|
||||||
|
throw new ServiceError(ERRORS.TRANSACTION_ALREADY_MATCHED, '', {
|
||||||
|
matchedTransactionsIds: matchedTransactions?.map((m) => m.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateUncategorizedTransactionsExcluded = (
|
||||||
|
uncategorizedTransactions: any
|
||||||
|
) => {
|
||||||
|
const excludedTransactions = uncategorizedTransactions.filter(
|
||||||
|
(trans) => trans.excluded
|
||||||
|
);
|
||||||
|
if (excludedTransactions.length > 0) {
|
||||||
|
throw new ServiceError(ERRORS.CANNOT_MATCH_EXCLUDED_TRANSACTION, '', {
|
||||||
|
excludedTransactionsIds: excludedTransactions.map((e) => e.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { castArray } from 'lodash';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { PromisePool } from '@supercharge/promise-pool';
|
||||||
|
import {
|
||||||
|
ERRORS,
|
||||||
|
IBankTransactionMatchedEventPayload,
|
||||||
|
IBankTransactionMatchingEventPayload,
|
||||||
|
IMatchTransactionDTO,
|
||||||
|
} from '../types';
|
||||||
|
import { MatchTransactionsTypes } from './MatchTransactionsTypes';
|
||||||
|
import {
|
||||||
|
sumMatchTranasctions,
|
||||||
|
sumUncategorizedTransactions,
|
||||||
|
validateUncategorizedTransactionsExcluded,
|
||||||
|
validateUncategorizedTransactionsNotMatched,
|
||||||
|
} from '../_utils';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
|
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||||
|
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MatchBankTransactions {
|
||||||
|
constructor(
|
||||||
|
private readonly uow: UnitOfWork,
|
||||||
|
private readonly eventPublisher: EventEmitter2,
|
||||||
|
private readonly matchedBankTransactions: MatchTransactionsTypes,
|
||||||
|
|
||||||
|
@Inject(UncategorizedBankTransaction.name)
|
||||||
|
private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the match bank transactions DTO.
|
||||||
|
* @param {number} uncategorizedTransactionId - Uncategorized transaction id.
|
||||||
|
* @param {IMatchTransactionsDTO} matchTransactionsDTO - Match transactions DTO.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async validate(
|
||||||
|
uncategorizedTransactionId: number | Array<number>,
|
||||||
|
matchedTransactions: Array<IMatchTransactionDTO>,
|
||||||
|
) {
|
||||||
|
const uncategorizedTransactionIds = castArray(uncategorizedTransactionId);
|
||||||
|
|
||||||
|
// Validates the uncategorized transaction existance.
|
||||||
|
const uncategorizedTransactions =
|
||||||
|
await this.uncategorizedBankTransactionModel
|
||||||
|
.query()
|
||||||
|
.whereIn('id', uncategorizedTransactionIds)
|
||||||
|
.withGraphFetched('matchedBankTransactions')
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
// Validates the uncategorized transaction is not already matched.
|
||||||
|
validateUncategorizedTransactionsNotMatched(uncategorizedTransactions);
|
||||||
|
|
||||||
|
// Validate the uncategorized transaction is not excluded.
|
||||||
|
validateUncategorizedTransactionsExcluded(uncategorizedTransactions);
|
||||||
|
|
||||||
|
// Validates the given matched transaction.
|
||||||
|
const validateMatchedTransaction = async (matchedTransaction) => {
|
||||||
|
const getMatchedTransactionsService =
|
||||||
|
this.matchedBankTransactions.registry.get(
|
||||||
|
matchedTransaction.referenceType,
|
||||||
|
);
|
||||||
|
if (!getMatchedTransactionsService) {
|
||||||
|
throw new ServiceError(
|
||||||
|
ERRORS.RESOURCE_TYPE_MATCHING_TRANSACTION_INVALID,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const foundMatchedTransaction =
|
||||||
|
await getMatchedTransactionsService.getMatchedTransaction(
|
||||||
|
matchedTransaction.referenceId,
|
||||||
|
);
|
||||||
|
if (!foundMatchedTransaction) {
|
||||||
|
throw new ServiceError(ERRORS.RESOURCE_ID_MATCHING_TRANSACTION_INVALID);
|
||||||
|
}
|
||||||
|
return foundMatchedTransaction;
|
||||||
|
};
|
||||||
|
// Matches the given transactions under promise pool concurrency controlling.
|
||||||
|
const validatationResult = await PromisePool.withConcurrency(10)
|
||||||
|
.for(matchedTransactions)
|
||||||
|
.process(validateMatchedTransaction);
|
||||||
|
|
||||||
|
if (validatationResult.errors?.length > 0) {
|
||||||
|
const error = validatationResult.errors.map((er) => er.raw)[0];
|
||||||
|
throw new ServiceError(error);
|
||||||
|
}
|
||||||
|
// Calculate the total given matching transactions.
|
||||||
|
const totalMatchedTranasctions = sumMatchTranasctions(
|
||||||
|
validatationResult.results,
|
||||||
|
);
|
||||||
|
const totalUncategorizedTransactions = sumUncategorizedTransactions(
|
||||||
|
uncategorizedTransactions,
|
||||||
|
);
|
||||||
|
// Validates the total given matching transcations whether is not equal
|
||||||
|
// uncategorized transaction amount.
|
||||||
|
if (totalUncategorizedTransactions !== totalMatchedTranasctions) {
|
||||||
|
throw new ServiceError(ERRORS.TOTAL_MATCHING_TRANSACTIONS_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches the given uncategorized transaction to the given references.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number} uncategorizedTransactionId
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public async matchTransaction(
|
||||||
|
tenantId: number,
|
||||||
|
uncategorizedTransactionId: number | Array<number>,
|
||||||
|
matchedTransactions: Array<IMatchTransactionDTO>,
|
||||||
|
): Promise<void> {
|
||||||
|
const uncategorizedTransactionIds = castArray(uncategorizedTransactionId);
|
||||||
|
|
||||||
|
// Validates the given matching transactions DTO.
|
||||||
|
await this.validate(uncategorizedTransactionIds, matchedTransactions);
|
||||||
|
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||||
|
// Triggers the event `onBankTransactionMatching`.
|
||||||
|
await this.eventPublisher.emitAsync(events.bankMatch.onMatching, {
|
||||||
|
uncategorizedTransactionIds,
|
||||||
|
matchedTransactions,
|
||||||
|
trx,
|
||||||
|
} as IBankTransactionMatchingEventPayload);
|
||||||
|
|
||||||
|
// Matches the given transactions under promise pool concurrency controlling.
|
||||||
|
await PromisePool.withConcurrency(10)
|
||||||
|
.for(matchedTransactions)
|
||||||
|
.process(async (matchedTransaction) => {
|
||||||
|
const getMatchedTransactionsService =
|
||||||
|
this.matchedBankTransactions.registry.get(
|
||||||
|
matchedTransaction.referenceType,
|
||||||
|
);
|
||||||
|
await getMatchedTransactionsService.createMatchedTransaction(
|
||||||
|
uncategorizedTransactionIds,
|
||||||
|
matchedTransaction,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// Triggers the event `onBankTransactionMatched`.
|
||||||
|
await this.eventPublisher.emitAsync(events.bankMatch.onMatched, {
|
||||||
|
uncategorizedTransactionIds,
|
||||||
|
matchedTransactions,
|
||||||
|
trx,
|
||||||
|
} as IBankTransactionMatchedEventPayload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { GetMatchedTransactionsByExpenses } from '../queries/GetMatchedTransactionsByExpenses';
|
||||||
|
import { GetMatchedTransactionsByBills } from '../queries/GetMatchedTransactionsByBills.service';
|
||||||
|
import { GetMatchedTransactionsByManualJournals } from '../queries/GetMatchedTransactionsByManualJournals.service';
|
||||||
|
import { MatchTransactionsTypesRegistry } from './MatchTransactionsTypesRegistry';
|
||||||
|
import { GetMatchedTransactionsByInvoices } from '../queries/GetMatchedTransactionsByInvoices.service';
|
||||||
|
import { GetMatchedTransactionCashflowTransformer } from '../queries/GetMatchedTransactionCashflowTransformer';
|
||||||
|
import { GetMatchedTransactionsByCashflow } from '../queries/GetMatchedTransactionsByCashflow';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MatchTransactionsTypes {
|
||||||
|
private static registry: MatchTransactionsTypesRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consttuctor method.
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
this.boot();
|
||||||
|
}
|
||||||
|
|
||||||
|
get registered() {
|
||||||
|
return [
|
||||||
|
{ type: 'SaleInvoice', service: GetMatchedTransactionsByInvoices },
|
||||||
|
{ type: 'Bill', service: GetMatchedTransactionsByBills },
|
||||||
|
{ type: 'Expense', service: GetMatchedTransactionsByExpenses },
|
||||||
|
{
|
||||||
|
type: 'ManualJournal',
|
||||||
|
service: GetMatchedTransactionsByManualJournals,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'CashflowTransaction',
|
||||||
|
service: GetMatchedTransactionsByCashflow,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importable instances.
|
||||||
|
*/
|
||||||
|
private types = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public get registry() {
|
||||||
|
return MatchTransactionsTypes.registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boots all the registered importables.
|
||||||
|
*/
|
||||||
|
public boot() {
|
||||||
|
if (!MatchTransactionsTypes.registry) {
|
||||||
|
const instance = MatchTransactionsTypesRegistry.getInstance();
|
||||||
|
|
||||||
|
this.registered.forEach((registered) => {
|
||||||
|
const serviceInstanace = Container.get(registered.service);
|
||||||
|
instance.register(registered.type, serviceInstanace);
|
||||||
|
});
|
||||||
|
MatchTransactionsTypes.registry = instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { camelCase, upperFirst } from 'lodash';
|
||||||
|
import { GetMatchedTransactionsByType } from '../queries/GetMatchedTransactionsByType';
|
||||||
|
|
||||||
|
export class MatchTransactionsTypesRegistry {
|
||||||
|
private static instance: MatchTransactionsTypesRegistry;
|
||||||
|
private importables: Record<string, GetMatchedTransactionsByType>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.importables = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets singleton instance of registry.
|
||||||
|
* @returns {MatchTransactionsTypesRegistry}
|
||||||
|
*/
|
||||||
|
public static getInstance(): MatchTransactionsTypesRegistry {
|
||||||
|
if (!MatchTransactionsTypesRegistry.instance) {
|
||||||
|
MatchTransactionsTypesRegistry.instance =
|
||||||
|
new MatchTransactionsTypesRegistry();
|
||||||
|
}
|
||||||
|
return MatchTransactionsTypesRegistry.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the given importable service.
|
||||||
|
* @param {string} resource
|
||||||
|
* @param {GetMatchedTransactionsByType} importable
|
||||||
|
*/
|
||||||
|
public register(
|
||||||
|
resource: string,
|
||||||
|
importable: GetMatchedTransactionsByType
|
||||||
|
): void {
|
||||||
|
const _resource = this.sanitizeResourceName(resource);
|
||||||
|
this.importables[_resource] = importable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the importable service instance of the given resource name.
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {GetMatchedTransactionsByType}
|
||||||
|
*/
|
||||||
|
public get(name: string): GetMatchedTransactionsByType {
|
||||||
|
const _name = this.sanitizeResourceName(name);
|
||||||
|
return this.importables[_name];
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeResourceName(resource: string) {
|
||||||
|
return upperFirst(camelCase(resource));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { IBankTransactionUnmatchingEventPayload } from '../types';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { UnitOfWork } from '../../Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
import { MatchedBankTransaction } from '../models/MatchedBankTransaction';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UnmatchMatchedBankTransaction {
|
||||||
|
constructor(
|
||||||
|
private readonly uow: UnitOfWork,
|
||||||
|
private readonly eventPublisher: EventEmitter2,
|
||||||
|
|
||||||
|
@Inject(MatchedBankTransaction.name)
|
||||||
|
private readonly matchedBankTransactionModel: typeof MatchedBankTransaction,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmatch the matched the given uncategorized bank transaction.
|
||||||
|
* @param {number} uncategorizedTransactionId - Uncategorized transaction id.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public unmatchMatchedTransaction(
|
||||||
|
tenantId: number,
|
||||||
|
uncategorizedTransactionId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.uow.withTransaction(async (trx) => {
|
||||||
|
await this.eventPublisher.emitAsync(events.bankMatch.onUnmatching, {
|
||||||
|
tenantId,
|
||||||
|
uncategorizedTransactionId,
|
||||||
|
trx,
|
||||||
|
} as IBankTransactionUnmatchingEventPayload);
|
||||||
|
|
||||||
|
await this.matchedBankTransactionModel
|
||||||
|
.query(trx)
|
||||||
|
.where('uncategorizedTransactionId', uncategorizedTransactionId)
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
await this.eventPublisher.emitAsync(events.bankMatch.onUnmatched, {
|
||||||
|
tenantId,
|
||||||
|
uncategorizedTransactionId,
|
||||||
|
trx,
|
||||||
|
} as IBankTransactionUnmatchingEventPayload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { ERRORS } from '../types';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||||
|
import { MatchedBankTransaction } from '../models/MatchedBankTransaction';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ValidateTransactionMatched {
|
||||||
|
constructor(
|
||||||
|
@Inject(MatchedBankTransaction.name)
|
||||||
|
private readonly matchedBankTransactionModel: typeof MatchedBankTransaction,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the given transaction whether is matched with bank transactions.
|
||||||
|
* @param {string} referenceType - Transaction reference type.
|
||||||
|
* @param {number} referenceId - Transaction reference id.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public async validateTransactionNoMatchLinking(
|
||||||
|
referenceType: string,
|
||||||
|
referenceId: number,
|
||||||
|
trx?: Knex.Transaction
|
||||||
|
) {
|
||||||
|
const foundMatchedTransaction =
|
||||||
|
await this.matchedBankTransactionModel.query(trx).findOne({
|
||||||
|
referenceType,
|
||||||
|
referenceId,
|
||||||
|
});
|
||||||
|
if (foundMatchedTransaction) {
|
||||||
|
throw new ServiceError(ERRORS.CANNOT_DELETE_TRANSACTION_MATCHED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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 { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DecrementUncategorizedTransactionOnMatchingSubscriber {
|
||||||
|
constructor(
|
||||||
|
@Inject(Account.name)
|
||||||
|
private readonly accountModel: typeof Account,
|
||||||
|
|
||||||
|
@Inject(UncategorizedBankTransaction.name)
|
||||||
|
private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the cashflow transaction whether matched with bank transaction on deleting.
|
||||||
|
* @param {IManualJournalDeletingPayload}
|
||||||
|
*/
|
||||||
|
@OnEvent(events.bankMatch.onMatched)
|
||||||
|
public async decrementUnCategorizedTransactionsOnMatching({
|
||||||
|
uncategorizedTransactionIds,
|
||||||
|
trx,
|
||||||
|
}: IBankTransactionMatchedEventPayload) {
|
||||||
|
const uncategorizedTransactions =
|
||||||
|
await this.uncategorizedBankTransactionModel.query().whereIn(
|
||||||
|
'id',
|
||||||
|
uncategorizedTransactionIds
|
||||||
|
);
|
||||||
|
await PromisePool.withConcurrency(1)
|
||||||
|
.for(uncategorizedTransactions)
|
||||||
|
.process(async (transaction) => {
|
||||||
|
await this.accountModel
|
||||||
|
.query(trx)
|
||||||
|
.findById(transaction.accountId)
|
||||||
|
.decrement('uncategorizedTransactions', 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the cashflow transaction whether matched with bank transaction on deleting.
|
||||||
|
* @param {IManualJournalDeletingPayload}
|
||||||
|
*/
|
||||||
|
@OnEvent(events.bankMatch.onUnmatched)
|
||||||
|
public async incrementUnCategorizedTransactionsOnUnmatching({
|
||||||
|
uncategorizedTransactionId,
|
||||||
|
trx,
|
||||||
|
}: IBankTransactionUnmatchedEventPayload) {
|
||||||
|
const transaction =
|
||||||
|
await this.uncategorizedBankTransactionModel.query().findById(
|
||||||
|
uncategorizedTransactionId
|
||||||
|
);
|
||||||
|
await this.accountModel
|
||||||
|
.query(trx)
|
||||||
|
.findById(transaction.accountId)
|
||||||
|
.increment('uncategorizedTransactions', 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
import { ValidateTransactionMatched } from '../commands/ValidateTransactionsMatched.service';
|
||||||
|
import { ICommandCashflowDeletingPayload } from '@/modules/BankingTransactions/types/BankingTransactions.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ValidateMatchingOnCashflowDeleteSubscriber {
|
||||||
|
constructor(
|
||||||
|
private readonly validateNoMatchingLinkedService: ValidateTransactionMatched,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the cashflow transaction whether matched with bank transaction on deleting.
|
||||||
|
* @param {IManualJournalDeletingPayload}
|
||||||
|
*/
|
||||||
|
@OnEvent(events.cashflow.onTransactionDeleting)
|
||||||
|
public async validateMatchingOnCashflowDeleting({
|
||||||
|
oldCashflowTransaction,
|
||||||
|
trx,
|
||||||
|
}: ICommandCashflowDeletingPayload) {
|
||||||
|
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
|
||||||
|
'CashflowTransaction',
|
||||||
|
oldCashflowTransaction.id,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { ValidateTransactionMatched } from '../commands/ValidateTransactionsMatched.service';
|
||||||
|
import { IExpenseEventDeletePayload } from '@/modules/Expenses/interfaces/Expenses.interface';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ValidateMatchingOnExpenseDeleteSubscriber {
|
||||||
|
constructor(
|
||||||
|
private readonly validateNoMatchingLinkedService: ValidateTransactionMatched,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the expense transaction whether matched with bank transaction on deleting.
|
||||||
|
* @param {IExpenseEventDeletePayload}
|
||||||
|
*/
|
||||||
|
@OnEvent(events.expenses.onDeleting)
|
||||||
|
public async validateMatchingOnExpenseDeleting({
|
||||||
|
oldExpense,
|
||||||
|
trx,
|
||||||
|
}: IExpenseEventDeletePayload) {
|
||||||
|
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
|
||||||
|
'Expense',
|
||||||
|
oldExpense.id,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ValidateTransactionMatched } from '../commands/ValidateTransactionsMatched.service';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { IManualJournalDeletingPayload } from '@/modules/ManualJournals/types/ManualJournals.types';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ValidateMatchingOnManualJournalDeleteSubscriber {
|
||||||
|
constructor(
|
||||||
|
private readonly validateNoMatchingLinkedService: ValidateTransactionMatched,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the manual journal transaction whether matched with bank transaction on deleting.
|
||||||
|
* @param {IManualJournalDeletingPayload}
|
||||||
|
*/
|
||||||
|
@OnEvent(events.manualJournals.onDeleting)
|
||||||
|
public async validateMatchingOnManualJournalDeleting({
|
||||||
|
oldManualJournal,
|
||||||
|
trx,
|
||||||
|
}: IManualJournalDeletingPayload) {
|
||||||
|
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
|
||||||
|
'ManualJournal',
|
||||||
|
oldManualJournal.id,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { ValidateTransactionMatched } from '../commands/ValidateTransactionsMatched.service';
|
||||||
|
import { IBillPaymentEventDeletedPayload } from '@/modules/BillPayments/types/BillPayments.types';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ValidateMatchingOnPaymentMadeDeleteSubscriber {
|
||||||
|
constructor(
|
||||||
|
private readonly validateNoMatchingLinkedService: ValidateTransactionMatched,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the payment made transaction whether matched with bank transaction on deleting.
|
||||||
|
* @param {IPaymentReceivedDeletedPayload}
|
||||||
|
*/
|
||||||
|
@OnEvent(events.billPayment.onDeleting)
|
||||||
|
public async validateMatchingOnPaymentMadeDeleting({
|
||||||
|
oldBillPayment,
|
||||||
|
trx,
|
||||||
|
}: IBillPaymentEventDeletedPayload) {
|
||||||
|
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
|
||||||
|
'PaymentMade',
|
||||||
|
oldBillPayment.id,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ValidateTransactionMatched } from '../commands/ValidateTransactionsMatched.service';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
import { IPaymentReceivedDeletedPayload } from '@/modules/PaymentReceived/types/PaymentReceived.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ValidateMatchingOnPaymentReceivedDeleteSubscriber {
|
||||||
|
constructor(
|
||||||
|
private readonly validateNoMatchingLinkedService: ValidateTransactionMatched,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the payment received transaction whether matched with bank transaction on deleting.
|
||||||
|
* @param {IPaymentReceivedDeletedPayload}
|
||||||
|
*/
|
||||||
|
@OnEvent(events.paymentReceive.onDeleting)
|
||||||
|
public async validateMatchingOnPaymentReceivedDeleting({
|
||||||
|
oldPaymentReceive,
|
||||||
|
trx,
|
||||||
|
}: IPaymentReceivedDeletedPayload) {
|
||||||
|
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
|
||||||
|
'PaymentReceive',
|
||||||
|
oldPaymentReceive.id,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { BaseModel } from '@/models/Model';
|
||||||
|
|
||||||
|
export class MatchedBankTransaction extends BaseModel {
|
||||||
|
public referenceId!: number;
|
||||||
|
public referenceType!: string;
|
||||||
|
public uncategorizedTransactionId!: number;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table name.
|
||||||
|
*/
|
||||||
|
static get tableName() {
|
||||||
|
return 'matched_bank_transactions';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamps columns.
|
||||||
|
*/
|
||||||
|
get timestamps() {
|
||||||
|
return ['createdAt', 'updatedAt'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Virtual attributes.
|
||||||
|
*/
|
||||||
|
static get virtualAttributes() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relationship mapping.
|
||||||
|
*/
|
||||||
|
static get relationMappings() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { Transformer } from "@/modules/Transformer/Transformer";
|
||||||
|
|
||||||
|
export class GetMatchedTransactionBillsTransformer extends Transformer {
|
||||||
|
/**
|
||||||
|
* Include these attributes to sale credit note object.
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
public includeAttributes = (): string[] => {
|
||||||
|
return [
|
||||||
|
'referenceNo',
|
||||||
|
'amount',
|
||||||
|
'amountFormatted',
|
||||||
|
'transactionNo',
|
||||||
|
'date',
|
||||||
|
'dateFormatted',
|
||||||
|
'transactionId',
|
||||||
|
'transactionNo',
|
||||||
|
'transactionType',
|
||||||
|
'transsactionTypeFormatted',
|
||||||
|
'transactionNormal',
|
||||||
|
'referenceId',
|
||||||
|
'referenceType',
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exclude all attributes.
|
||||||
|
* @returns {Array<string>}
|
||||||
|
*/
|
||||||
|
public excludeAttributes = (): string[] => {
|
||||||
|
return ['*'];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the reference number of the bill.
|
||||||
|
* @param {Object} bill - The bill object.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected referenceNo(bill) {
|
||||||
|
return bill.referenceNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the amount of the bill.
|
||||||
|
* @param {Object} bill - The bill object.
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
protected amount(bill) {
|
||||||
|
return bill.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the formatted amount of the bill.
|
||||||
|
* @param {Object} bill - The bill object.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected amountFormatted(bill) {
|
||||||
|
return this.formatNumber(bill.amount, {
|
||||||
|
currencyCode: bill.currencyCode,
|
||||||
|
money: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the date of the bill.
|
||||||
|
* @param {Object} bill - The bill object.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected date(bill) {
|
||||||
|
return bill.billDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the formatted date of the bill.
|
||||||
|
* @param {Object} bill - The bill object.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected dateFormatted(bill) {
|
||||||
|
return this.formatDate(bill.billDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the transcation id of the bill.
|
||||||
|
* @param {Object} bill - The bill object.
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
protected transactionId(bill) {
|
||||||
|
return bill.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the manual journal transaction type.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected transactionType() {
|
||||||
|
return 'Bill';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the manual journal formatted transaction type.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected transsactionTypeFormatted() {
|
||||||
|
return 'Bill';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the bill transaction normal (debit or credit).
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected transactionNormal() {
|
||||||
|
return 'credit';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the match transaction reference id.
|
||||||
|
* @param bill
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
protected referenceId(bill) {
|
||||||
|
return bill.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the match transaction referenece type.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected referenceType() {
|
||||||
|
return 'Bill';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { Transformer } from "@/modules/Transformer/Transformer";
|
||||||
|
|
||||||
|
export class GetMatchedTransactionCashflowTransformer extends Transformer {
|
||||||
|
/**
|
||||||
|
* Include these attributes to sale credit note object.
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
public includeAttributes = (): string[] => {
|
||||||
|
return [
|
||||||
|
'referenceNo',
|
||||||
|
'amount',
|
||||||
|
'amountFormatted',
|
||||||
|
'transactionNo',
|
||||||
|
'date',
|
||||||
|
'dateFormatted',
|
||||||
|
'transactionId',
|
||||||
|
'transactionNo',
|
||||||
|
'transactionType',
|
||||||
|
'transsactionTypeFormatted',
|
||||||
|
'transactionNormal',
|
||||||
|
'referenceId',
|
||||||
|
'referenceType',
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exclude all attributes.
|
||||||
|
* @returns {Array<string>}
|
||||||
|
*/
|
||||||
|
public excludeAttributes = (): string[] => {
|
||||||
|
return ['*'];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the invoice reference number.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected referenceNo(invoice) {
|
||||||
|
return invoice.referenceNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the transaction amount.
|
||||||
|
* @param transaction
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
protected amount(transaction) {
|
||||||
|
return transaction.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the transaction formatted amount.
|
||||||
|
* @param transaction
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected amountFormatted(transaction) {
|
||||||
|
return this.formatNumber(transaction.amount, {
|
||||||
|
currencyCode: transaction.currencyCode,
|
||||||
|
money: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the date of the invoice.
|
||||||
|
* @param invoice
|
||||||
|
* @returns {Date}
|
||||||
|
*/
|
||||||
|
protected date(transaction) {
|
||||||
|
return transaction.date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the date of the invoice.
|
||||||
|
* @param invoice
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected dateFormatted(transaction) {
|
||||||
|
return this.formatDate(transaction.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the transaction ID of the invoice.
|
||||||
|
* @param invoice
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
protected transactionId(transaction) {
|
||||||
|
return transaction.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the invoice transaction number.
|
||||||
|
* @param invoice
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected transactionNo(transaction) {
|
||||||
|
return transaction.transactionNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the invoice transaction type.
|
||||||
|
* @param invoice
|
||||||
|
* @returns {String}
|
||||||
|
*/
|
||||||
|
protected transactionType(transaction) {
|
||||||
|
return transaction.transactionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the invoice formatted transaction type.
|
||||||
|
* @param invoice
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected transsactionTypeFormatted(transaction) {
|
||||||
|
return transaction.transactionTypeFormatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the cashflow transaction normal (credit or debit).
|
||||||
|
* @param transaction
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected transactionNormal(transaction) {
|
||||||
|
return transaction.isCashCredit ? 'credit' : 'debit';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the cashflow transaction reference id.
|
||||||
|
* @param transaction
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
protected referenceId(transaction) {
|
||||||
|
return transaction.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the cashflow transaction reference type.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected referenceType() {
|
||||||
|
return 'CashflowTransaction';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { Transformer } from "@/modules/Transformer/Transformer";
|
||||||
|
|
||||||
|
export class GetMatchedTransactionExpensesTransformer extends Transformer {
|
||||||
|
/**
|
||||||
|
* Include these attributes to sale credit note object.
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
public includeAttributes = (): string[] => {
|
||||||
|
return [
|
||||||
|
'referenceNo',
|
||||||
|
'amount',
|
||||||
|
'amountFormatted',
|
||||||
|
'transactionNo',
|
||||||
|
'date',
|
||||||
|
'dateFormatted',
|
||||||
|
'transactionId',
|
||||||
|
'transactionNo',
|
||||||
|
'transactionType',
|
||||||
|
'transsactionTypeFormatted',
|
||||||
|
'transactionNormal',
|
||||||
|
'referenceType',
|
||||||
|
'referenceId',
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exclude all attributes.
|
||||||
|
* @returns {Array<string>}
|
||||||
|
*/
|
||||||
|
public excludeAttributes = (): string[] => {
|
||||||
|
return ['*'];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the expense reference number.
|
||||||
|
* @param expense
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected referenceNo(expense) {
|
||||||
|
return expense.referenceNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the expense amount.
|
||||||
|
* @param expense
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
protected amount(expense) {
|
||||||
|
return expense.totalAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats the amount of the expense.
|
||||||
|
* @param expense
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected amountFormatted(expense) {
|
||||||
|
return this.formatNumber(expense.totalAmount, {
|
||||||
|
currencyCode: expense.currencyCode,
|
||||||
|
money: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the date of the expense.
|
||||||
|
* @param expense
|
||||||
|
* @returns {Date}
|
||||||
|
*/
|
||||||
|
protected date(expense) {
|
||||||
|
return expense.paymentDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats the date of the expense.
|
||||||
|
* @param expense
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected dateFormatted(expense) {
|
||||||
|
return this.formatDate(expense.paymentDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the transaction ID of the expense.
|
||||||
|
* @param expense
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
protected transactionId(expense) {
|
||||||
|
return expense.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the expense transaction number.
|
||||||
|
* @param expense
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected transactionNo(expense) {
|
||||||
|
return expense.expenseNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the expense transaction type.
|
||||||
|
* @param expense
|
||||||
|
* @returns {String}
|
||||||
|
*/
|
||||||
|
protected transactionType() {
|
||||||
|
return 'Expense';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the formatted transaction type of the expense.
|
||||||
|
* @param expense
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected transsactionTypeFormatted() {
|
||||||
|
return 'Expense';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the expense transaction normal (credit or debit).
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected transactionNormal() {
|
||||||
|
return 'credit';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the transaction reference type.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected referenceType() {
|
||||||
|
return 'Expense';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the transaction reference id.
|
||||||
|
* @param transaction
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
protected referenceId(transaction) {
|
||||||
|
return transaction.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { Transformer } from "@/modules/Transformer/Transformer";
|
||||||
|
|
||||||
|
export class GetMatchedTransactionInvoicesTransformer extends Transformer {
|
||||||
|
/**
|
||||||
|
* Include these attributes to sale credit note object.
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
public includeAttributes = (): string[] => {
|
||||||
|
return [
|
||||||
|
'referenceNo',
|
||||||
|
'amount',
|
||||||
|
'amountFormatted',
|
||||||
|
'transactionNo',
|
||||||
|
'date',
|
||||||
|
'dateFormatted',
|
||||||
|
'transactionId',
|
||||||
|
'transactionNo',
|
||||||
|
'transactionType',
|
||||||
|
'transsactionTypeFormatted',
|
||||||
|
'transactionNormal',
|
||||||
|
'referenceType',
|
||||||
|
'referenceId'
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exclude all attributes.
|
||||||
|
* @returns {Array<string>}
|
||||||
|
*/
|
||||||
|
public excludeAttributes = (): string[] => {
|
||||||
|
return ['*'];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the invoice reference number.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected referenceNo(invoice) {
|
||||||
|
return invoice.referenceNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the invoice amount.
|
||||||
|
* @param invoice
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
protected amount(invoice) {
|
||||||
|
return invoice.dueAmount;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Format the amount of the invoice.
|
||||||
|
* @param invoice
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected amountFormatted(invoice) {
|
||||||
|
return this.formatNumber(invoice.dueAmount, {
|
||||||
|
currencyCode: invoice.currencyCode,
|
||||||
|
money: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the date of the invoice.
|
||||||
|
* @param invoice
|
||||||
|
* @returns {Date}
|
||||||
|
*/
|
||||||
|
protected date(invoice) {
|
||||||
|
return invoice.invoiceDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the date of the invoice.
|
||||||
|
* @param invoice
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected dateFormatted(invoice) {
|
||||||
|
return this.formatDate(invoice.invoiceDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the transaction ID of the invoice.
|
||||||
|
* @param invoice
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
protected transactionId(invoice) {
|
||||||
|
return invoice.id;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Retrieve the invoice transaction number.
|
||||||
|
* @param invoice
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected transactionNo(invoice) {
|
||||||
|
return invoice.invoiceNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the invoice transaction type.
|
||||||
|
* @param invoice
|
||||||
|
* @returns {String}
|
||||||
|
*/
|
||||||
|
protected transactionType(invoice) {
|
||||||
|
return 'SaleInvoice';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the invoice formatted transaction type.
|
||||||
|
* @param invoice
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected transsactionTypeFormatted(invoice) {
|
||||||
|
return 'Sale invoice';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the transaction normal of invoice (credit or debit).
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected transactionNormal() {
|
||||||
|
return 'debit';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the transaction reference type.
|
||||||
|
* @returns {string}
|
||||||
|
*/ protected referenceType() {
|
||||||
|
return 'SaleInvoice';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the transaction reference id.
|
||||||
|
* @param transaction
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
protected referenceId(transaction) {
|
||||||
|
return transaction.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { sumBy } from 'lodash';
|
||||||
|
import { AccountNormal } from '@/interfaces/Account';
|
||||||
|
import { Transformer } from '@/modules/Transformer/Transformer';
|
||||||
|
|
||||||
|
export class GetMatchedTransactionManualJournalsTransformer extends Transformer {
|
||||||
|
/**
|
||||||
|
* Include these attributes to sale credit note object.
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
public includeAttributes = (): string[] => {
|
||||||
|
return [
|
||||||
|
'referenceNo',
|
||||||
|
'amount',
|
||||||
|
'amountFormatted',
|
||||||
|
'transactionNo',
|
||||||
|
'date',
|
||||||
|
'dateFormatted',
|
||||||
|
'transactionId',
|
||||||
|
'transactionNo',
|
||||||
|
'transactionType',
|
||||||
|
'transsactionTypeFormatted',
|
||||||
|
'transactionNormal',
|
||||||
|
'referenceType',
|
||||||
|
'referenceId',
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exclude all attributes.
|
||||||
|
* @returns {Array<string>}
|
||||||
|
*/
|
||||||
|
public excludeAttributes = (): string[] => {
|
||||||
|
return ['*'];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the manual journal reference no.
|
||||||
|
* @param manualJournal
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected referenceNo(manualJournal) {
|
||||||
|
return manualJournal.referenceNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected total(manualJournal) {
|
||||||
|
const credit = sumBy(manualJournal?.entries, 'credit');
|
||||||
|
const debit = sumBy(manualJournal?.entries, 'debit');
|
||||||
|
|
||||||
|
return debit - credit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the manual journal amount.
|
||||||
|
* @param manualJournal
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
protected amount(manualJournal) {
|
||||||
|
return Math.abs(this.total(manualJournal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the manual journal formatted amount.
|
||||||
|
* @param manualJournal
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected amountFormatted(manualJournal) {
|
||||||
|
return this.formatNumber(manualJournal.amount, {
|
||||||
|
currencyCode: manualJournal.currencyCode,
|
||||||
|
money: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retreives the manual journal date.
|
||||||
|
* @param manualJournal
|
||||||
|
* @returns {Date}
|
||||||
|
*/
|
||||||
|
protected date(manualJournal) {
|
||||||
|
return manualJournal.date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the manual journal formatted date.
|
||||||
|
* @param manualJournal
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected dateFormatted(manualJournal) {
|
||||||
|
return this.formatDate(manualJournal.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the manual journal transaction id.
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
protected transactionId(manualJournal) {
|
||||||
|
return manualJournal.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the manual journal transaction number.
|
||||||
|
* @param manualJournal
|
||||||
|
*/
|
||||||
|
protected transactionNo(manualJournal) {
|
||||||
|
return manualJournal.journalNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the manual journal transaction type.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected transactionType() {
|
||||||
|
return 'ManualJournal';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the manual journal formatted transaction type.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected transsactionTypeFormatted() {
|
||||||
|
return 'Manual Journal';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the manual journal transaction normal (credit or debit).
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected transactionNormal(transaction) {
|
||||||
|
const amount = this.total(transaction);
|
||||||
|
|
||||||
|
return amount >= 0 ? AccountNormal.DEBIT : AccountNormal.CREDIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the manual journal reference type.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected referenceType() {
|
||||||
|
return 'ManualJournal';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the manual journal reference id.
|
||||||
|
* @param transaction
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
protected referenceId(transaction) {
|
||||||
|
return transaction.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import * as R from 'ramda';
|
||||||
|
import moment from 'moment';
|
||||||
|
import { first, sumBy } from 'lodash';
|
||||||
|
import { PromisePool } from '@supercharge/promise-pool';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { GetMatchedTransactionsFilter, MatchedTransactionsPOJO } from '../types';
|
||||||
|
import { GetMatchedTransactionsByExpenses } from './GetMatchedTransactionsByExpenses';
|
||||||
|
import { GetMatchedTransactionsByBills } from './GetMatchedTransactionsByBills.service';
|
||||||
|
import { GetMatchedTransactionsByManualJournals } from './GetMatchedTransactionsByManualJournals.service';
|
||||||
|
import { GetMatchedTransactionsByCashflow } from './GetMatchedTransactionsByCashflow';
|
||||||
|
import { GetMatchedTransactionsByInvoices } from './GetMatchedTransactionsByInvoices.service';
|
||||||
|
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
|
||||||
|
import { sortClosestMatchTransactions } from '../_utils';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetMatchedTransactions {
|
||||||
|
constructor(
|
||||||
|
private readonly getMatchedInvoicesService: GetMatchedTransactionsByInvoices,
|
||||||
|
private readonly getMatchedBillsService: GetMatchedTransactionsByBills,
|
||||||
|
private readonly getMatchedManualJournalService: GetMatchedTransactionsByManualJournals,
|
||||||
|
private readonly getMatchedExpensesService: GetMatchedTransactionsByExpenses,
|
||||||
|
private readonly getMatchedCashflowService: GetMatchedTransactionsByCashflow,
|
||||||
|
|
||||||
|
@Inject(UncategorizedBankTransaction.name)
|
||||||
|
private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registered matched transactions types.
|
||||||
|
*/
|
||||||
|
get registered() {
|
||||||
|
return [
|
||||||
|
{ type: 'SaleInvoice', service: this.getMatchedInvoicesService },
|
||||||
|
{ type: 'Bill', service: this.getMatchedBillsService },
|
||||||
|
{ type: 'Expense', service: this.getMatchedExpensesService },
|
||||||
|
{ type: 'ManualJournal', service: this.getMatchedManualJournalService },
|
||||||
|
{ type: 'Cashflow', service: this.getMatchedCashflowService },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the matched transactions.
|
||||||
|
* @param {Array<number>} uncategorizedTransactionIds - Uncategorized transactions ids.
|
||||||
|
* @param {GetMatchedTransactionsFilter} filter -
|
||||||
|
* @returns {Promise<MatchedTransactionsPOJO>}
|
||||||
|
*/
|
||||||
|
public async getMatchedTransactions(
|
||||||
|
uncategorizedTransactionIds: Array<number>,
|
||||||
|
filter: GetMatchedTransactionsFilter
|
||||||
|
): Promise<MatchedTransactionsPOJO> {
|
||||||
|
const uncategorizedTransactions =
|
||||||
|
await this.uncategorizedBankTransactionModel.query()
|
||||||
|
.whereIn('id', uncategorizedTransactionIds)
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
const totalPending = sumBy(uncategorizedTransactions, 'amount');
|
||||||
|
|
||||||
|
const filtered = filter.transactionType
|
||||||
|
? this.registered.filter((item) => item.type === filter.transactionType)
|
||||||
|
: this.registered;
|
||||||
|
|
||||||
|
const matchedTransactions = await PromisePool.withConcurrency(2)
|
||||||
|
.for(filtered)
|
||||||
|
.process(async ({ type, service }) => {
|
||||||
|
return service.getMatchedTransactions(filter);
|
||||||
|
});
|
||||||
|
const { perfectMatches, possibleMatches } = this.groupMatchedResults(
|
||||||
|
uncategorizedTransactions,
|
||||||
|
matchedTransactions
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
perfectMatches,
|
||||||
|
possibleMatches,
|
||||||
|
totalPending,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups the given results for getting perfect and possible matches
|
||||||
|
* based on the given uncategorized transaction.
|
||||||
|
* @param uncategorizedTransaction
|
||||||
|
* @param matchedTransactions
|
||||||
|
* @returns {MatchedTransactionsPOJO}
|
||||||
|
*/
|
||||||
|
private groupMatchedResults(
|
||||||
|
uncategorizedTransactions: Array<any>,
|
||||||
|
matchedTransactions
|
||||||
|
): MatchedTransactionsPOJO {
|
||||||
|
const results = R.compose(R.flatten)(matchedTransactions?.results);
|
||||||
|
|
||||||
|
const firstUncategorized = first(uncategorizedTransactions);
|
||||||
|
const amount = sumBy(uncategorizedTransactions, 'amount');
|
||||||
|
const date = firstUncategorized.date;
|
||||||
|
|
||||||
|
// Sort the results based on amount, date, and transaction type
|
||||||
|
const closestResullts = sortClosestMatchTransactions(amount, date, results);
|
||||||
|
const perfectMatches = R.filter(
|
||||||
|
(match) =>
|
||||||
|
match.amount === amount && moment(match.date).isSame(date, 'day'),
|
||||||
|
closestResullts
|
||||||
|
);
|
||||||
|
const possibleMatches = R.difference(closestResullts, perfectMatches);
|
||||||
|
const totalPending = sumBy(uncategorizedTransactions, 'amount');
|
||||||
|
|
||||||
|
return { perfectMatches, possibleMatches, totalPending };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { initialize } from 'objection';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { first } from 'lodash';
|
||||||
|
import { GetMatchedTransactionBillsTransformer } from './GetMatchedTransactionBillsTransformer';
|
||||||
|
import {
|
||||||
|
GetMatchedTransactionsFilter,
|
||||||
|
IMatchTransactionDTO,
|
||||||
|
MatchedTransactionPOJO,
|
||||||
|
} from '../types';
|
||||||
|
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
|
||||||
|
import { CreateBillPaymentService } from '@/modules/BillPayments/commands/CreateBillPayment.service';
|
||||||
|
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
|
||||||
|
import { IBillPaymentDTO } from '@/modules/BillPayments/types/BillPayments.types';
|
||||||
|
import { Bill } from '@/modules/Bills/models/Bill';
|
||||||
|
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetMatchedTransactionsByBills extends GetMatchedTransactionsByType {
|
||||||
|
constructor(
|
||||||
|
private readonly createPaymentMadeService: CreateBillPaymentService,
|
||||||
|
private readonly transformer: TransformerInjectable,
|
||||||
|
|
||||||
|
@Inject(Bill.name)
|
||||||
|
private readonly billModel: typeof Bill,
|
||||||
|
|
||||||
|
@Inject(UncategorizedBankTransaction.name)
|
||||||
|
private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the matched transactions.
|
||||||
|
* @param {number} tenantId -
|
||||||
|
* @param {GetMatchedTransactionsFilter} filter -
|
||||||
|
*/
|
||||||
|
public async getMatchedTransactions(
|
||||||
|
filter: GetMatchedTransactionsFilter,
|
||||||
|
) {
|
||||||
|
// Retrieves the bill matches.
|
||||||
|
const bills = await Bill.query().onBuild((q) => {
|
||||||
|
q.withGraphJoined('matchedBankTransaction');
|
||||||
|
q.whereNull('matchedBankTransaction.id');
|
||||||
|
q.modify('published');
|
||||||
|
|
||||||
|
if (filter.fromDate) {
|
||||||
|
q.where('billDate', '>=', filter.fromDate);
|
||||||
|
}
|
||||||
|
if (filter.toDate) {
|
||||||
|
q.where('billDate', '<=', filter.toDate);
|
||||||
|
}
|
||||||
|
q.orderBy('billDate', 'DESC');
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.transformer.transform(
|
||||||
|
bills,
|
||||||
|
new GetMatchedTransactionBillsTransformer(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the given bill matched transaction.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number} transactionId
|
||||||
|
* @returns {Promise<MatchedTransactionPOJO>}
|
||||||
|
*/
|
||||||
|
public async getMatchedTransaction(
|
||||||
|
transactionId: number,
|
||||||
|
): Promise<MatchedTransactionPOJO> {
|
||||||
|
const bill = await this.billModel
|
||||||
|
.query()
|
||||||
|
.findById(transactionId)
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
return this.transformer.transform(
|
||||||
|
bill,
|
||||||
|
new GetMatchedTransactionBillsTransformer(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the common matched transaction.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {Array<number>} uncategorizedTransactionIds
|
||||||
|
* @param {IMatchTransactionDTO} matchTransactionDTO
|
||||||
|
* @param {Knex.Transaction} trx
|
||||||
|
*/
|
||||||
|
public async createMatchedTransaction(
|
||||||
|
uncategorizedTransactionIds: Array<number>,
|
||||||
|
matchTransactionDTO: IMatchTransactionDTO,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> {
|
||||||
|
await super.createMatchedTransaction(
|
||||||
|
uncategorizedTransactionIds,
|
||||||
|
matchTransactionDTO,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
const uncategorizedTransactionId = first(uncategorizedTransactionIds);
|
||||||
|
const uncategorizedTransaction =
|
||||||
|
await this.uncategorizedBankTransactionModel
|
||||||
|
.query(trx)
|
||||||
|
.findById(uncategorizedTransactionId)
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
const bill = await this.billModel
|
||||||
|
.query(trx)
|
||||||
|
.findById(matchTransactionDTO.referenceId)
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
const createPaymentMadeDTO: IBillPaymentDTO = {
|
||||||
|
vendorId: bill.vendorId,
|
||||||
|
paymentAccountId: uncategorizedTransaction.accountId,
|
||||||
|
paymentDate: uncategorizedTransaction.date,
|
||||||
|
exchangeRate: 1,
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
paymentAmount: bill.dueAmount,
|
||||||
|
billId: bill.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
branchId: bill.branchId,
|
||||||
|
};
|
||||||
|
// Create a new bill payment associated to the matched bill.
|
||||||
|
const billPayment = await this.createPaymentMadeService.createBillPayment(
|
||||||
|
createPaymentMadeDTO,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
// Link the create bill payment with matched transaction.
|
||||||
|
await super.createMatchedTransaction(
|
||||||
|
uncategorizedTransactionIds,
|
||||||
|
{
|
||||||
|
referenceType: 'BillPayment',
|
||||||
|
referenceId: billPayment.id,
|
||||||
|
},
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
|
||||||
|
import { GetMatchedTransactionCashflowTransformer } from './GetMatchedTransactionCashflowTransformer';
|
||||||
|
import { GetMatchedTransactionsFilter } from '../types';
|
||||||
|
import { BankTransaction } from '@/modules/BankingTransactions/models/BankTransaction';
|
||||||
|
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetMatchedTransactionsByCashflow extends GetMatchedTransactionsByType {
|
||||||
|
constructor(
|
||||||
|
private readonly transformer: TransformerInjectable,
|
||||||
|
|
||||||
|
@Inject(BankTransaction.name)
|
||||||
|
private readonly bankTransactionModel: typeof BankTransaction,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the matched transactions of cash flow.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {GetMatchedTransactionsFilter} filter
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
async getMatchedTransactions(
|
||||||
|
filter: Omit<GetMatchedTransactionsFilter, 'transactionType'>,
|
||||||
|
) {
|
||||||
|
const transactions = await this.bankTransactionModel
|
||||||
|
.query()
|
||||||
|
.onBuild((q) => {
|
||||||
|
// Not matched to bank transaction.
|
||||||
|
q.withGraphJoined('matchedBankTransaction');
|
||||||
|
q.whereNull('matchedBankTransaction.id');
|
||||||
|
|
||||||
|
// Not categorized.
|
||||||
|
q.modify('notCategorized');
|
||||||
|
|
||||||
|
// Published.
|
||||||
|
q.modify('published');
|
||||||
|
|
||||||
|
if (filter.fromDate) {
|
||||||
|
q.where('date', '>=', filter.fromDate);
|
||||||
|
}
|
||||||
|
if (filter.toDate) {
|
||||||
|
q.where('date', '<=', filter.toDate);
|
||||||
|
}
|
||||||
|
q.orderBy('date', 'DESC');
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.transformer.transform(
|
||||||
|
transactions,
|
||||||
|
new GetMatchedTransactionCashflowTransformer(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the matched transaction of cash flow.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number} transactionId
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
async getMatchedTransaction(transactionId: number) {
|
||||||
|
const transactions = await this.bankTransactionModel
|
||||||
|
.query()
|
||||||
|
.findById(transactionId)
|
||||||
|
.withGraphJoined('matchedBankTransaction')
|
||||||
|
.whereNull('matchedBankTransaction.id')
|
||||||
|
.modify('notCategorized')
|
||||||
|
.modify('published')
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
return this.transformer.transform(
|
||||||
|
transactions,
|
||||||
|
new GetMatchedTransactionCashflowTransformer(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { GetMatchedTransactionsFilter, MatchedTransactionPOJO } 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';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetMatchedTransactionsByExpenses extends GetMatchedTransactionsByType {
|
||||||
|
constructor(
|
||||||
|
protected readonly transformer: TransformerInjectable,
|
||||||
|
|
||||||
|
@Inject(Expense.name)
|
||||||
|
protected readonly expenseModel: typeof Expense,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the matched transactions of expenses.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {GetMatchedTransactionsFilter} filter
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
async getMatchedTransactions(filter: GetMatchedTransactionsFilter) {
|
||||||
|
// Retrieve the expense matches.
|
||||||
|
const expenses = await this.expenseModel.query().onBuild((query) => {
|
||||||
|
// Filter out the not matched to bank transactions.
|
||||||
|
query.withGraphJoined('matchedBankTransaction');
|
||||||
|
query.whereNull('matchedBankTransaction.id');
|
||||||
|
|
||||||
|
// Filter the published onyl
|
||||||
|
query.modify('filterByPublished');
|
||||||
|
|
||||||
|
if (filter.fromDate) {
|
||||||
|
query.where('paymentDate', '>=', filter.fromDate);
|
||||||
|
}
|
||||||
|
if (filter.toDate) {
|
||||||
|
query.where('paymentDate', '<=', filter.toDate);
|
||||||
|
}
|
||||||
|
if (filter.minAmount) {
|
||||||
|
query.where('totalAmount', '>=', filter.minAmount);
|
||||||
|
}
|
||||||
|
if (filter.maxAmount) {
|
||||||
|
query.where('totalAmount', '<=', filter.maxAmount);
|
||||||
|
}
|
||||||
|
query.orderBy('paymentDate', 'DESC');
|
||||||
|
});
|
||||||
|
return this.transformer.transform(
|
||||||
|
expenses,
|
||||||
|
new GetMatchedTransactionExpensesTransformer(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the given matched expense transaction.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number} transactionId
|
||||||
|
* @returns {GetMatchedTransactionExpensesTransformer-}
|
||||||
|
*/
|
||||||
|
public async getMatchedTransaction(
|
||||||
|
transactionId: number,
|
||||||
|
): Promise<MatchedTransactionPOJO> {
|
||||||
|
const expense = await this.expenseModel
|
||||||
|
.query()
|
||||||
|
.findById(transactionId)
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
return this.transformer.transform(
|
||||||
|
expense,
|
||||||
|
new GetMatchedTransactionExpensesTransformer(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { first } from 'lodash';
|
||||||
|
import { GetMatchedTransactionInvoicesTransformer } from './GetMatchedTransactionInvoicesTransformer';
|
||||||
|
import {
|
||||||
|
GetMatchedTransactionsFilter,
|
||||||
|
IMatchTransactionDTO,
|
||||||
|
MatchedTransactionPOJO,
|
||||||
|
MatchedTransactionsPOJO,
|
||||||
|
} 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';
|
||||||
|
import { IPaymentReceivedCreateDTO } from '@/modules/PaymentReceived/types/PaymentReceived.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByType {
|
||||||
|
constructor(
|
||||||
|
private readonly transformer: TransformerInjectable,
|
||||||
|
private readonly createPaymentReceivedService: CreatePaymentReceivedService,
|
||||||
|
|
||||||
|
@Inject(SaleInvoice.name)
|
||||||
|
private readonly saleInvoiceModel: typeof SaleInvoice,
|
||||||
|
|
||||||
|
@Inject(UncategorizedBankTransaction.name)
|
||||||
|
private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the matched transactions.
|
||||||
|
* @param {GetMatchedTransactionsFilter} filter -
|
||||||
|
* @returns {Promise<MatchedTransactionsPOJO>}
|
||||||
|
*/
|
||||||
|
public async getMatchedTransactions(
|
||||||
|
filter: GetMatchedTransactionsFilter
|
||||||
|
): Promise<MatchedTransactionsPOJO> {
|
||||||
|
// Retrieve the invoices that not matched, unpaid.
|
||||||
|
const invoices = await this.saleInvoiceModel.query().onBuild((q) => {
|
||||||
|
q.withGraphJoined('matchedBankTransaction');
|
||||||
|
q.whereNull('matchedBankTransaction.id');
|
||||||
|
q.modify('unpaid');
|
||||||
|
q.modify('published');
|
||||||
|
|
||||||
|
if (filter.fromDate) {
|
||||||
|
q.where('invoiceDate', '>=', filter.fromDate);
|
||||||
|
}
|
||||||
|
if (filter.toDate) {
|
||||||
|
q.where('invoiceDate', '<=', filter.toDate);
|
||||||
|
}
|
||||||
|
q.orderBy('invoiceDate', 'DESC');
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.transformer.transform(
|
||||||
|
invoices,
|
||||||
|
new GetMatchedTransactionInvoicesTransformer()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the matched transaction.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number} transactionId
|
||||||
|
* @returns {Promise<MatchedTransactionPOJO>}
|
||||||
|
*/
|
||||||
|
public async getMatchedTransaction(
|
||||||
|
transactionId: number
|
||||||
|
): Promise<MatchedTransactionPOJO> {
|
||||||
|
const invoice = await this.saleInvoiceModel.query().findById(transactionId);
|
||||||
|
|
||||||
|
return this.transformer.transform(
|
||||||
|
invoice,
|
||||||
|
new GetMatchedTransactionInvoicesTransformer()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the common matched transaction.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {Array<number>} uncategorizedTransactionIds
|
||||||
|
* @param {IMatchTransactionDTO} matchTransactionDTO
|
||||||
|
* @param {Knex.Transaction} trx
|
||||||
|
*/
|
||||||
|
public async createMatchedTransaction(
|
||||||
|
uncategorizedTransactionIds: Array<number>,
|
||||||
|
matchTransactionDTO: IMatchTransactionDTO,
|
||||||
|
trx?: Knex.Transaction
|
||||||
|
) {
|
||||||
|
await super.createMatchedTransaction(
|
||||||
|
uncategorizedTransactionIds,
|
||||||
|
matchTransactionDTO,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
const uncategorizedTransactionId = first(uncategorizedTransactionIds);
|
||||||
|
const uncategorizedTransaction =
|
||||||
|
await this.uncategorizedBankTransactionModel.query(trx)
|
||||||
|
.findById(uncategorizedTransactionId)
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
const invoice = await SaleInvoice.query(trx)
|
||||||
|
.findById(matchTransactionDTO.referenceId)
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
const createPaymentReceivedDTO: IPaymentReceivedCreateDTO = {
|
||||||
|
customerId: invoice.customerId,
|
||||||
|
paymentDate: uncategorizedTransaction.date,
|
||||||
|
amount: invoice.dueAmount,
|
||||||
|
depositAccountId: uncategorizedTransaction.accountId,
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
paymentAmount: invoice.dueAmount,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
branchId: invoice.branchId,
|
||||||
|
};
|
||||||
|
// Create a payment received associated to the matched invoice.
|
||||||
|
const paymentReceived = await this.createPaymentReceivedService.createPaymentReceived(
|
||||||
|
createPaymentReceivedDTO,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
// Link the create payment received with matched invoice transaction.
|
||||||
|
await super.createMatchedTransaction(uncategorizedTransactionIds, {
|
||||||
|
referenceType: 'PaymentReceive',
|
||||||
|
referenceId: paymentReceived.id,
|
||||||
|
}, trx)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
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';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetMatchedTransactionsByManualJournals extends GetMatchedTransactionsByType {
|
||||||
|
constructor(
|
||||||
|
private readonly transformer: TransformerInjectable,
|
||||||
|
|
||||||
|
@Inject(ManualJournal.name)
|
||||||
|
private readonly manualJournalModel: typeof ManualJournal,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the matched transactions of manual journals.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {GetMatchedTransactionsFilter} filter
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
async getMatchedTransactions(
|
||||||
|
filter: Omit<GetMatchedTransactionsFilter, 'transactionType'>,
|
||||||
|
) {
|
||||||
|
// @todo: get the account id from the filter
|
||||||
|
const accountId = 1000;
|
||||||
|
|
||||||
|
const manualJournals = await this.manualJournalModel.query().onBuild((query) => {
|
||||||
|
query.withGraphJoined('matchedBankTransaction');
|
||||||
|
query.whereNull('matchedBankTransaction.id');
|
||||||
|
|
||||||
|
query.withGraphJoined('entries');
|
||||||
|
query.where('entries.accountId', accountId);
|
||||||
|
query.modify('filterByPublished');
|
||||||
|
|
||||||
|
if (filter.fromDate) {
|
||||||
|
query.where('date', '>=', filter.fromDate);
|
||||||
|
}
|
||||||
|
if (filter.toDate) {
|
||||||
|
query.where('date', '<=', filter.toDate);
|
||||||
|
}
|
||||||
|
if (filter.minAmount) {
|
||||||
|
query.where('amount', '>=', filter.minAmount);
|
||||||
|
}
|
||||||
|
if (filter.maxAmount) {
|
||||||
|
query.where('amount', '<=', filter.maxAmount);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return this.transformer.transform(
|
||||||
|
manualJournals,
|
||||||
|
new GetMatchedTransactionManualJournalsTransformer(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the matched transaction of manual journals.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number} transactionId
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
public async getMatchedTransaction(transactionId: number) {
|
||||||
|
const manualJournal = await this.manualJournalModel.query()
|
||||||
|
.findById(transactionId)
|
||||||
|
.whereNotExists(ManualJournal.relatedQuery('matchedBankTransaction'))
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
return this.transformer.transform(
|
||||||
|
manualJournal,
|
||||||
|
new GetMatchedTransactionManualJournalsTransformer(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import {
|
||||||
|
GetMatchedTransactionsFilter,
|
||||||
|
IMatchTransactionDTO,
|
||||||
|
MatchedTransactionPOJO,
|
||||||
|
MatchedTransactionsPOJO,
|
||||||
|
} from '../types';
|
||||||
|
import PromisePool from '@supercharge/promise-pool';
|
||||||
|
import { MatchedBankTransaction } from '../models/MatchedBankTransaction';
|
||||||
|
import { Inject } from '@nestjs/common';
|
||||||
|
|
||||||
|
export abstract class GetMatchedTransactionsByType {
|
||||||
|
@Inject(MatchedBankTransaction.name)
|
||||||
|
private readonly matchedBankTransactionModel: typeof MatchedBankTransaction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the matched transactions.
|
||||||
|
* @param {number} tenantId -
|
||||||
|
* @param {GetMatchedTransactionsFilter} filter -
|
||||||
|
* @returns {Promise<MatchedTransactionsPOJO>}
|
||||||
|
*/
|
||||||
|
public async getMatchedTransactions(
|
||||||
|
filter: GetMatchedTransactionsFilter
|
||||||
|
): Promise<MatchedTransactionsPOJO> {
|
||||||
|
throw new Error(
|
||||||
|
'The `getMatchedTransactions` method is not defined for the transaction type.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the matched transaction details.
|
||||||
|
* @param {number} tenantId -
|
||||||
|
* @param {number} transactionId -
|
||||||
|
* @returns {Promise<MatchedTransactionPOJO>}
|
||||||
|
*/
|
||||||
|
public async getMatchedTransaction(
|
||||||
|
transactionId: number
|
||||||
|
): Promise<MatchedTransactionPOJO> {
|
||||||
|
throw new Error(
|
||||||
|
'The `getMatchedTransaction` method is not defined for the transaction type.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the common matched transaction.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {Array<number>} uncategorizedTransactionIds
|
||||||
|
* @param {IMatchTransactionDTO} matchTransactionDTO
|
||||||
|
* @param {Knex.Transaction} trx
|
||||||
|
*/
|
||||||
|
public async createMatchedTransaction(
|
||||||
|
uncategorizedTransactionIds: Array<number>,
|
||||||
|
matchTransactionDTO: IMatchTransactionDTO,
|
||||||
|
trx?: Knex.Transaction
|
||||||
|
) {
|
||||||
|
await PromisePool.withConcurrency(2)
|
||||||
|
.for(uncategorizedTransactionIds)
|
||||||
|
.process(async (uncategorizedTransactionId) => {
|
||||||
|
await this.matchedBankTransactionModel.query(trx).insert({
|
||||||
|
uncategorizedTransactionId,
|
||||||
|
referenceType: matchTransactionDTO.referenceType,
|
||||||
|
referenceId: matchTransactionDTO.referenceId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
73
packages/server-nest/src/modules/BankingMatching/types.ts
Normal file
73
packages/server-nest/src/modules/BankingMatching/types.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
|
||||||
|
export interface IBankTransactionMatchingEventPayload {
|
||||||
|
tenantId: number;
|
||||||
|
uncategorizedTransactionIds: Array<number>;
|
||||||
|
matchedTransactions: Array<IMatchTransactionDTO>;
|
||||||
|
trx?: Knex.Transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBankTransactionMatchedEventPayload {
|
||||||
|
// tenantId: number;
|
||||||
|
uncategorizedTransactionIds: Array<number>;
|
||||||
|
matchedTransactions: Array<IMatchTransactionDTO>;
|
||||||
|
trx?: Knex.Transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBankTransactionUnmatchingEventPayload {
|
||||||
|
// tenantId: number;
|
||||||
|
uncategorizedTransactionId: number;
|
||||||
|
trx?: Knex.Transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBankTransactionUnmatchedEventPayload {
|
||||||
|
// tenantId: number;
|
||||||
|
uncategorizedTransactionId: number;
|
||||||
|
trx?: Knex.Transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMatchTransactionDTO {
|
||||||
|
referenceType: string;
|
||||||
|
referenceId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMatchTransactionsDTO {
|
||||||
|
uncategorizedTransactionIds: Array<number>;
|
||||||
|
matchedTransactions: Array<IMatchTransactionDTO>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetMatchedTransactionsFilter {
|
||||||
|
fromDate: string;
|
||||||
|
toDate: string;
|
||||||
|
minAmount: number;
|
||||||
|
maxAmount: number;
|
||||||
|
transactionType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchedTransactionPOJO {
|
||||||
|
amount: number;
|
||||||
|
amountFormatted: string;
|
||||||
|
date: string;
|
||||||
|
dateFormatted: string;
|
||||||
|
referenceNo: string;
|
||||||
|
transactionNo: string;
|
||||||
|
transactionId: number;
|
||||||
|
transactionType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MatchedTransactionsPOJO = {
|
||||||
|
perfectMatches: Array<MatchedTransactionPOJO>;
|
||||||
|
possibleMatches: Array<MatchedTransactionPOJO>;
|
||||||
|
totalPending: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ERRORS = {
|
||||||
|
RESOURCE_TYPE_MATCHING_TRANSACTION_INVALID:
|
||||||
|
'RESOURCE_TYPE_MATCHING_TRANSACTION_INVALID',
|
||||||
|
RESOURCE_ID_MATCHING_TRANSACTION_INVALID:
|
||||||
|
'RESOURCE_ID_MATCHING_TRANSACTION_INVALID',
|
||||||
|
TOTAL_MATCHING_TRANSACTIONS_INVALID: 'TOTAL_MATCHING_TRANSACTIONS_INVALID',
|
||||||
|
TRANSACTION_ALREADY_MATCHED: 'TRANSACTION_ALREADY_MATCHED',
|
||||||
|
CANNOT_MATCH_EXCLUDED_TRANSACTION: 'CANNOT_MATCH_EXCLUDED_TRANSACTION',
|
||||||
|
CANNOT_DELETE_TRANSACTION_MATCHED: 'CANNOT_DELETE_TRANSACTION_MATCHED',
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PlaidUpdateTransactionsOnItemCreatedSubscriber } from "./subscribers/PlaidUpdateTransactionsOnItemCreatedSubscriber";
|
||||||
|
import { PlaidUpdateTransactions } from "./command/PlaidUpdateTransactions";
|
||||||
|
import { PlaidSyncDb } from "./command/PlaidSyncDB";
|
||||||
|
import { PlaidWebooks } from "./command/PlaidWebhooks";
|
||||||
|
import { PlaidLinkTokenService } from "./queries/GetPlaidLinkToken.service";
|
||||||
|
import { PlaidApplication } from "./PlaidApplication";
|
||||||
|
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
PlaidUpdateTransactions,
|
||||||
|
PlaidSyncDb,
|
||||||
|
PlaidWebooks,
|
||||||
|
PlaidLinkTokenService,
|
||||||
|
PlaidApplication,
|
||||||
|
PlaidUpdateTransactionsOnItemCreatedSubscriber,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BankingPlaidModule {}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PlaidApplication {
|
||||||
|
constructor(
|
||||||
|
private readonly getLinkTokenService: PlaidLinkTokenService,
|
||||||
|
private readonly plaidItemService: PlaidItemService,
|
||||||
|
private readonly plaidWebhooks: PlaidWebooks,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the Plaid link token.
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
public getLinkToken() {
|
||||||
|
return this.getLinkTokenService.getLinkToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchanges the Plaid access token.
|
||||||
|
* @param {PlaidItemDTO} itemDTO
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
public exchangeToken(itemDTO: PlaidItemDTO): Promise<void> {
|
||||||
|
return this.plaidItemService.item(itemDTO);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens to Plaid webhooks
|
||||||
|
* @param {string} plaidItemId - Plaid item id.
|
||||||
|
* @param {string} webhookType - Webhook type.
|
||||||
|
* @param {string} webhookCode - Webhook code.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public webhooks(
|
||||||
|
plaidItemId: string,
|
||||||
|
webhookType: string,
|
||||||
|
webhookCode: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.plaidWebhooks.webhooks(
|
||||||
|
plaidItemId,
|
||||||
|
webhookType,
|
||||||
|
webhookCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// import Container, { Service } from 'typedi';
|
||||||
|
// import { PlaidUpdateTransactions } from './PlaidUpdateTransactions';
|
||||||
|
// import { IPlaidItemCreatedEventPayload } from '@/interfaces';
|
||||||
|
|
||||||
|
// @Service()
|
||||||
|
// export class PlaidFetchTransactionsJob {
|
||||||
|
// /**
|
||||||
|
// * Constructor method.
|
||||||
|
// */
|
||||||
|
// constructor(agenda) {
|
||||||
|
// agenda.define(
|
||||||
|
// 'plaid-update-account-transactions',
|
||||||
|
// { priority: 'high', concurrency: 2 },
|
||||||
|
// this.handler
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Triggers the function.
|
||||||
|
// */
|
||||||
|
// private handler = async (job, done: Function) => {
|
||||||
|
// const { tenantId, plaidItemId } = job.attrs
|
||||||
|
// .data as IPlaidItemCreatedEventPayload;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
// }
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// 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();
|
||||||
|
// };
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { PlaidItem } from '../models/PlaidItem';
|
||||||
|
import { PlaidApi } from 'plaid';
|
||||||
|
import { PLAID_CLIENT } from '../../Plaid/Plaid.module';
|
||||||
|
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 { PlaidItemDTO } from '../types/BankingPlaid.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PlaidItemService {
|
||||||
|
constructor(
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
|
private readonly tenancyContext: TenancyContext,
|
||||||
|
|
||||||
|
@Inject(SystemPlaidItem.name)
|
||||||
|
private readonly systemPlaidItemModel: typeof SystemPlaidItem,
|
||||||
|
|
||||||
|
@Inject(PlaidItem.name)
|
||||||
|
private readonly plaidItemModel: typeof PlaidItem,
|
||||||
|
|
||||||
|
@Inject(PLAID_CLIENT)
|
||||||
|
private readonly plaidClient: PlaidApi,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public async item(itemDTO: PlaidItemDTO): Promise<void> {
|
||||||
|
const { publicToken, institutionId } = itemDTO;
|
||||||
|
|
||||||
|
const tenant = await this.tenancyContext.getTenant();
|
||||||
|
const tenantId = tenant.id;
|
||||||
|
|
||||||
|
// Exchange the public token for a private access token and store with the item.
|
||||||
|
const response = await this.plaidClient.itemPublicTokenExchange({
|
||||||
|
public_token: publicToken,
|
||||||
|
});
|
||||||
|
const plaidAccessToken = response.data.access_token;
|
||||||
|
const plaidItemId = response.data.item_id;
|
||||||
|
|
||||||
|
// Store the Plaid item metadata on tenant scope.
|
||||||
|
const plaidItem = await this.plaidItemModel.query().insertAndFetch({
|
||||||
|
tenantId,
|
||||||
|
plaidAccessToken,
|
||||||
|
plaidItemId,
|
||||||
|
plaidInstitutionId: institutionId,
|
||||||
|
});
|
||||||
|
// Stores the Plaid item id on system scope.
|
||||||
|
await this.systemPlaidItemModel.query().insert({ tenantId, plaidItemId });
|
||||||
|
|
||||||
|
// Triggers `onPlaidItemCreated` event.
|
||||||
|
await this.eventEmitter.emitAsync(events.plaid.onItemCreated, {
|
||||||
|
plaidAccessToken,
|
||||||
|
plaidItemId,
|
||||||
|
plaidInstitutionId: institutionId,
|
||||||
|
} as IPlaidItemCreatedEventPayload);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import * as R from 'ramda';
|
||||||
|
import bluebird from 'bluebird';
|
||||||
|
import { entries, groupBy } from 'lodash';
|
||||||
|
import {
|
||||||
|
AccountBase as PlaidAccountBase,
|
||||||
|
Item as PlaidItem,
|
||||||
|
Institution as PlaidInstitution,
|
||||||
|
Transaction as PlaidTransaction,
|
||||||
|
} from 'plaid';
|
||||||
|
import {
|
||||||
|
transformPlaidAccountToCreateAccount,
|
||||||
|
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';
|
||||||
|
import { Account } from '../../Accounts/models/Account.model';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
import { PlaidItem as PlaidItemModel } from '../models/PlaidItem';
|
||||||
|
import { IAccountCreateDTO } from '@/interfaces/Account';
|
||||||
|
import { IPlaidTransactionsSyncedEventPayload } from '../types/BankingPlaid.types';
|
||||||
|
import { UncategorizedBankTransaction } from '../../BankingTransactions/models/UncategorizedBankTransaction';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { CreateUncategorizedTransactionService } from '@/modules/BankingCategorize/commands/CreateUncategorizedTransaction.service';
|
||||||
|
|
||||||
|
const CONCURRENCY_ASYNC = 10;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PlaidSyncDb {
|
||||||
|
constructor(
|
||||||
|
private readonly createAccountService: CreateAccountService,
|
||||||
|
private readonly createUncategorizedTransaction: CreateUncategorizedTransactionService,
|
||||||
|
private readonly removePendingTransaction: RemovePendingUncategorizedTransaction,
|
||||||
|
private readonly eventPublisher: EventEmitter2,
|
||||||
|
|
||||||
|
@Inject(Account.name)
|
||||||
|
private readonly accountModel: typeof Account,
|
||||||
|
|
||||||
|
@Inject(PlaidItemModel.name)
|
||||||
|
private readonly plaidItemModel: typeof PlaidItemModel,
|
||||||
|
|
||||||
|
@Inject(UncategorizedBankTransaction.name)
|
||||||
|
private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs the Plaid bank account.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {IAccountCreateDTO} createBankAccountDTO
|
||||||
|
* @param {Knex.Transaction} trx
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public async syncBankAccount(
|
||||||
|
createBankAccountDTO: IAccountCreateDTO,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
) {
|
||||||
|
const plaidAccount = await this.accountModel
|
||||||
|
.query(trx)
|
||||||
|
.findOne('plaidAccountId', createBankAccountDTO.plaidAccountId);
|
||||||
|
// Can't continue if the Plaid account is already created.
|
||||||
|
if (plaidAccount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.createAccountService.createAccount(createBankAccountDTO, trx, {
|
||||||
|
ignoreUniqueName: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs the plaid accounts to the system accounts.
|
||||||
|
* @param {number} tenantId Tenant ID.
|
||||||
|
* @param {PlaidAccount[]} plaidAccounts
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public async syncBankAccounts(
|
||||||
|
plaidAccounts: PlaidAccountBase[],
|
||||||
|
institution: PlaidInstitution,
|
||||||
|
item: PlaidItem,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> {
|
||||||
|
const transformToPlaidAccounts = transformPlaidAccountToCreateAccount(
|
||||||
|
item,
|
||||||
|
institution,
|
||||||
|
);
|
||||||
|
const accountCreateDTOs = R.map(transformToPlaidAccounts)(plaidAccounts);
|
||||||
|
|
||||||
|
await bluebird.map(
|
||||||
|
accountCreateDTOs,
|
||||||
|
(createAccountDTO: any) => this.syncBankAccount(createAccountDTO, trx),
|
||||||
|
{ concurrency: CONCURRENCY_ASYNC },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synsc the Plaid transactions to the system GL entries.
|
||||||
|
* @param {number} tenantId - Tenant ID.
|
||||||
|
* @param {number} plaidAccountId - Plaid account ID.
|
||||||
|
* @param {PlaidTransaction[]} plaidTranasctions - Plaid transactions
|
||||||
|
* @return {Promise<void>}
|
||||||
|
*/
|
||||||
|
public async syncAccountTranactions(
|
||||||
|
plaidAccountId: number,
|
||||||
|
plaidTranasctions: PlaidTransaction[],
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> {
|
||||||
|
const batch = uniqid();
|
||||||
|
const cashflowAccount = await this.accountModel
|
||||||
|
.query(trx)
|
||||||
|
.findOne({ plaidAccountId })
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
// Transformes the Plaid transactions to cashflow create DTOs.
|
||||||
|
const transformTransaction = transformPlaidTrxsToCashflowCreate(
|
||||||
|
cashflowAccount.id,
|
||||||
|
);
|
||||||
|
const uncategorizedTransDTOs =
|
||||||
|
R.map(transformTransaction)(plaidTranasctions);
|
||||||
|
|
||||||
|
// Creating account transaction queue.
|
||||||
|
await bluebird.map(
|
||||||
|
uncategorizedTransDTOs,
|
||||||
|
(uncategoriedDTO) =>
|
||||||
|
this.createUncategorizedTransaction.create(
|
||||||
|
{ ...uncategoriedDTO, batch },
|
||||||
|
trx,
|
||||||
|
),
|
||||||
|
{ concurrency: 1 },
|
||||||
|
);
|
||||||
|
// Triggers `onPlaidTransactionsSynced` event.
|
||||||
|
await this.eventPublisher.emitAsync(events.plaid.onTransactionsSynced, {
|
||||||
|
plaidAccountId,
|
||||||
|
batch,
|
||||||
|
} as IPlaidTransactionsSyncedEventPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs the accounts transactions in paraller under controlled concurrency.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {PlaidTransaction[]} plaidTransactions
|
||||||
|
* @return {Promise<void>}
|
||||||
|
*/
|
||||||
|
public async syncAccountsTransactions(
|
||||||
|
plaidAccountsTransactions: PlaidTransaction[],
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> {
|
||||||
|
const groupedTrnsxByAccountId = entries(
|
||||||
|
groupBy(plaidAccountsTransactions, 'account_id'),
|
||||||
|
);
|
||||||
|
await bluebird.map(
|
||||||
|
groupedTrnsxByAccountId,
|
||||||
|
([plaidAccountId, plaidTransactions]: [number, PlaidTransaction[]]) => {
|
||||||
|
return this.syncAccountTranactions(
|
||||||
|
plaidAccountId,
|
||||||
|
plaidTransactions,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ concurrency: CONCURRENCY_ASYNC },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs the removed Plaid transactions ids from the cashflow system transactions.
|
||||||
|
* @param {string[]} plaidTransactionsIds - Plaid Transactions IDs.
|
||||||
|
*/
|
||||||
|
public async syncRemoveTransactions(
|
||||||
|
plaidTransactionsIds: string[],
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
) {
|
||||||
|
const uncategorizedTransactions =
|
||||||
|
await this.uncategorizedBankTransactionModel
|
||||||
|
.query(trx)
|
||||||
|
.whereIn('plaidTransactionId', plaidTransactionsIds);
|
||||||
|
const uncategorizedTransactionsIds = uncategorizedTransactions.map(
|
||||||
|
(trans) => trans.id,
|
||||||
|
);
|
||||||
|
await bluebird.map(
|
||||||
|
uncategorizedTransactionsIds,
|
||||||
|
(uncategorizedTransactionId: number) =>
|
||||||
|
this.removePendingTransaction.removePendingTransaction(
|
||||||
|
uncategorizedTransactionId,
|
||||||
|
trx,
|
||||||
|
),
|
||||||
|
{ concurrency: CONCURRENCY_ASYNC },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs the Plaid item last transaction cursor.
|
||||||
|
* @param {number} tenantId - Tenant ID.
|
||||||
|
* @param {string} itemId - Plaid item ID.
|
||||||
|
* @param {string} lastCursor - Last transaction cursor.
|
||||||
|
* @return {Promise<void>}
|
||||||
|
*/
|
||||||
|
public async syncTransactionsCursor(
|
||||||
|
plaidItemId: string,
|
||||||
|
lastCursor: string,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.plaidItemModel
|
||||||
|
.query(trx)
|
||||||
|
.findOne({ plaidItemId })
|
||||||
|
.patch({ lastCursor });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the last feeds updated at of the given Plaid accounts ids.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {string[]} plaidAccountIds
|
||||||
|
* @return {Promise<void>}
|
||||||
|
*/
|
||||||
|
public async updateLastFeedsUpdatedAt(
|
||||||
|
plaidAccountIds: string[],
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.accountModel
|
||||||
|
.query(trx)
|
||||||
|
.whereIn('plaid_account_id', plaidAccountIds)
|
||||||
|
.patch({
|
||||||
|
lastFeedsUpdatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the accounts feed active status of the given Plaid accounts ids.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number[]} plaidAccountIds
|
||||||
|
* @param {boolean} isFeedsActive
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public async updateAccountsFeedsActive(
|
||||||
|
plaidAccountIds: string[],
|
||||||
|
isFeedsActive: boolean = true,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.accountModel
|
||||||
|
.query(trx)
|
||||||
|
.whereIn('plaid_account_id', plaidAccountIds)
|
||||||
|
.patch({
|
||||||
|
isFeedsActive,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { PlaidSyncDb } from './PlaidSyncDB';
|
||||||
|
import { PlaidFetchedTransactionsUpdates } from '../types/BankingPlaid.types';
|
||||||
|
import { PlaidItem } from '../models/PlaidItem';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
|
import {
|
||||||
|
CountryCode,
|
||||||
|
PlaidApi,
|
||||||
|
Transaction as PlaidTransaction,
|
||||||
|
RemovedTransaction,
|
||||||
|
} from 'plaid';
|
||||||
|
import { PLAID_CLIENT } from '@/modules/Plaid/Plaid.module';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PlaidUpdateTransactions {
|
||||||
|
constructor(
|
||||||
|
private readonly plaidItemModel: typeof PlaidItem,
|
||||||
|
private readonly plaidSync: PlaidSyncDb,
|
||||||
|
private readonly uow: UnitOfWork,
|
||||||
|
|
||||||
|
@Inject(PLAID_CLIENT)
|
||||||
|
private readonly plaidClient: PlaidApi,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles sync the Plaid item to Bigcaptial under UOW.
|
||||||
|
* @param {number} tenantId - Tenant id.
|
||||||
|
* @param {number} plaidItemId - Plaid item id.
|
||||||
|
* @returns {Promise<{ addedCount: number; modifiedCount: number; removedCount: number; }>}
|
||||||
|
*/
|
||||||
|
public async updateTransactions(plaidItemId: string) {
|
||||||
|
return this.uow.withTransaction((trx: Knex.Transaction) => {
|
||||||
|
return this.updateTransactionsWork(plaidItemId, trx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the fetching and storing the following:
|
||||||
|
* - New, modified, or removed transactions.
|
||||||
|
* - 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; }>}
|
||||||
|
*/
|
||||||
|
public async updateTransactionsWork(
|
||||||
|
plaidItemId: string,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<{
|
||||||
|
addedCount: number;
|
||||||
|
modifiedCount: number;
|
||||||
|
removedCount: number;
|
||||||
|
}> {
|
||||||
|
// Fetch new transactions from plaid api.
|
||||||
|
const { added, modified, removed, cursor, accessToken } =
|
||||||
|
await this.fetchTransactionUpdates(plaidItemId);
|
||||||
|
|
||||||
|
const request = { access_token: accessToken };
|
||||||
|
const {
|
||||||
|
data: { accounts, item },
|
||||||
|
} = await this.plaidClient.accountsGet(request);
|
||||||
|
|
||||||
|
const plaidAccountsIds = accounts.map((a) => a.account_id);
|
||||||
|
const {
|
||||||
|
data: { institution },
|
||||||
|
} = await this.plaidClient.institutionsGetById({
|
||||||
|
institution_id: item.institution_id,
|
||||||
|
country_codes: [CountryCode.Us, CountryCode.Gb],
|
||||||
|
});
|
||||||
|
// Sync bank accounts.
|
||||||
|
await this.plaidSync.syncBankAccounts(accounts, institution, item, trx);
|
||||||
|
// Sync removed transactions.
|
||||||
|
await this.plaidSync.syncRemoveTransactions(
|
||||||
|
removed?.map((r) => r.transaction_id),
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
// Sync bank account transactions.
|
||||||
|
await this.plaidSync.syncAccountsTransactions(added.concat(modified), trx);
|
||||||
|
// Sync transactions cursor.
|
||||||
|
await this.plaidSync.syncTransactionsCursor(plaidItemId, cursor, trx);
|
||||||
|
// Update the last feeds updated at of the updated accounts.
|
||||||
|
await this.plaidSync.updateLastFeedsUpdatedAt(plaidAccountsIds, trx);
|
||||||
|
// Turn on the accounts feeds flag.
|
||||||
|
await this.plaidSync.updateAccountsFeedsActive(plaidAccountsIds, true, trx);
|
||||||
|
|
||||||
|
return {
|
||||||
|
addedCount: added.length,
|
||||||
|
modifiedCount: modified.length,
|
||||||
|
removedCount: removed.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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>}
|
||||||
|
*/
|
||||||
|
private async fetchTransactionUpdates(
|
||||||
|
plaidItemId: string,
|
||||||
|
): Promise<PlaidFetchedTransactionsUpdates> {
|
||||||
|
// the transactions endpoint is paginated, so we may need to hit it multiple times to
|
||||||
|
// retrieve all available transactions.
|
||||||
|
const plaidItem = await this.plaidItemModel
|
||||||
|
.query()
|
||||||
|
.findOne('plaidItemId', plaidItemId);
|
||||||
|
if (!plaidItem) {
|
||||||
|
throw new Error('The given Plaid item id is not found.');
|
||||||
|
}
|
||||||
|
const { plaidAccessToken, lastCursor } = plaidItem;
|
||||||
|
let cursor = lastCursor;
|
||||||
|
|
||||||
|
// New transaction updates since "cursor"
|
||||||
|
let added: PlaidTransaction[] = [];
|
||||||
|
let modified: PlaidTransaction[] = [];
|
||||||
|
// Removed transaction ids
|
||||||
|
let removed: RemovedTransaction[] = [];
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
const batchSize = 100;
|
||||||
|
try {
|
||||||
|
// Iterate through each page of new transaction updates for item
|
||||||
|
/* eslint-disable no-await-in-loop */
|
||||||
|
while (hasMore) {
|
||||||
|
const request = {
|
||||||
|
access_token: plaidAccessToken,
|
||||||
|
cursor: cursor,
|
||||||
|
count: batchSize,
|
||||||
|
};
|
||||||
|
const response = await this.plaidClient.transactionsSync(request);
|
||||||
|
const data = response.data;
|
||||||
|
// Add this page of results
|
||||||
|
added = added.concat(data.added);
|
||||||
|
modified = modified.concat(data.modified);
|
||||||
|
removed = removed.concat(data.removed);
|
||||||
|
hasMore = data.has_more;
|
||||||
|
// Update cursor to the next cursor
|
||||||
|
cursor = data.next_cursor;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error fetching transactions: ${err.message}`);
|
||||||
|
cursor = lastCursor;
|
||||||
|
}
|
||||||
|
return { added, modified, removed, cursor, accessToken: plaidAccessToken };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { PlaidItem } from '../models/PlaidItem';
|
||||||
|
import { PlaidUpdateTransactions } from './PlaidUpdateTransactions';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PlaidWebooks {
|
||||||
|
constructor(
|
||||||
|
private readonly updateTransactionsService: PlaidUpdateTransactions,
|
||||||
|
|
||||||
|
@Inject(PlaidItem.name)
|
||||||
|
private readonly plaidItemModel: typeof PlaidItem,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens to Plaid webhooks
|
||||||
|
* @param {string} webhookType - Webhook type.
|
||||||
|
* @param {string} plaidItemId - Plaid item Id.
|
||||||
|
* @param {string} webhookCode - webhook code.
|
||||||
|
*/
|
||||||
|
public async webhooks(
|
||||||
|
plaidItemId: string,
|
||||||
|
webhookType: string,
|
||||||
|
webhookCode: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const _webhookType = webhookType.toLowerCase();
|
||||||
|
|
||||||
|
// There are five types of webhooks: AUTH, TRANSACTIONS, ITEM, INCOME, and ASSETS.
|
||||||
|
// @TODO implement handling for remaining webhook types.
|
||||||
|
const webhookHandlerMap = {
|
||||||
|
transactions: this.handleTransactionsWebooks.bind(this),
|
||||||
|
item: this.itemsHandler.bind(this),
|
||||||
|
};
|
||||||
|
const webhookHandler =
|
||||||
|
webhookHandlerMap[_webhookType] || this.unhandledWebhook;
|
||||||
|
|
||||||
|
await webhookHandler(plaidItemId, webhookCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles all unhandled/not yet implemented webhook events.
|
||||||
|
* @param {string} webhookType - Webhook type.
|
||||||
|
* @param {string} webhookCode - Webhook code.
|
||||||
|
* @param {string} plaidItemId - Plaid item id.
|
||||||
|
*/
|
||||||
|
private async unhandledWebhook(
|
||||||
|
webhookType: string,
|
||||||
|
webhookCode: string,
|
||||||
|
plaidItemId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
console.log(
|
||||||
|
`UNHANDLED ${webhookType} WEBHOOK: ${webhookCode}: Plaid item id ${plaidItemId}: unhandled webhook type received.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs to console and emits to socket
|
||||||
|
* @param {string} additionalInfo - Additional info.
|
||||||
|
* @param {string} webhookCode - Webhook code.
|
||||||
|
* @param {string} plaidItemId - Plaid item id.
|
||||||
|
*/
|
||||||
|
private serverLogAndEmitSocket(
|
||||||
|
additionalInfo: string,
|
||||||
|
webhookCode: string,
|
||||||
|
plaidItemId: string,
|
||||||
|
): void {
|
||||||
|
console.log(
|
||||||
|
`PLAID WEBHOOK: TRANSACTIONS: ${webhookCode}: Plaid_item_id ${plaidItemId}: ${additionalInfo}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles all transaction webhook events. The transaction webhook notifies
|
||||||
|
* you that a single item has new transactions available.
|
||||||
|
* @param {string} plaidItemId - Plaid item id.
|
||||||
|
* @param {string} webhookCode - Webhook code.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public async handleTransactionsWebooks(
|
||||||
|
tenantId: number,
|
||||||
|
plaidItemId: string,
|
||||||
|
webhookCode: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const plaidItem = await this.plaidItemModel
|
||||||
|
.query()
|
||||||
|
.findOne({ plaidItemId })
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
switch (webhookCode) {
|
||||||
|
case 'SYNC_UPDATES_AVAILABLE': {
|
||||||
|
if (plaidItem.isPaused) {
|
||||||
|
this.serverLogAndEmitSocket(
|
||||||
|
'Plaid item syncing is paused.',
|
||||||
|
webhookCode,
|
||||||
|
plaidItemId,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fired when new transactions data becomes available.
|
||||||
|
const { addedCount, modifiedCount, removedCount } =
|
||||||
|
await this.updateTransactionsService.updateTransactions(plaidItemId);
|
||||||
|
|
||||||
|
this.serverLogAndEmitSocket(
|
||||||
|
`Transactions: ${addedCount} added, ${modifiedCount} modified, ${removedCount} removed`,
|
||||||
|
webhookCode,
|
||||||
|
plaidItemId,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'DEFAULT_UPDATE':
|
||||||
|
case 'INITIAL_UPDATE':
|
||||||
|
case 'HISTORICAL_UPDATE':
|
||||||
|
/* ignore - not needed if using sync endpoint + webhook */
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.serverLogAndEmitSocket(
|
||||||
|
`unhandled webhook type received.`,
|
||||||
|
webhookCode,
|
||||||
|
plaidItemId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public async itemsHandler(
|
||||||
|
plaidItemId: string,
|
||||||
|
webhookCode: string,
|
||||||
|
): Promise<void> {
|
||||||
|
switch (webhookCode) {
|
||||||
|
case 'WEBHOOK_UPDATE_ACKNOWLEDGED':
|
||||||
|
this.serverLogAndEmitSocket('is updated', plaidItemId, error);
|
||||||
|
break;
|
||||||
|
case 'ERROR': {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'PENDING_EXPIRATION': {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
this.serverLogAndEmitSocket(
|
||||||
|
'unhandled webhook type received.',
|
||||||
|
webhookCode,
|
||||||
|
plaidItemId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,11 @@ import { BaseModel } from '@/models/Model';
|
|||||||
|
|
||||||
export class PlaidItem extends BaseModel {
|
export class PlaidItem extends BaseModel {
|
||||||
pausedAt: Date;
|
pausedAt: Date;
|
||||||
|
plaidAccessToken: string;
|
||||||
|
lastCursor?: string;
|
||||||
|
tenantId: number;
|
||||||
|
plaidItemId: string;
|
||||||
|
plaidInstitutionId: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Table name.
|
* Table name.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { BaseModel } from '@/models/Model';
|
||||||
|
import { Model } from 'objection';
|
||||||
|
|
||||||
|
export class SystemPlaidItem extends BaseModel {
|
||||||
|
tenantId: number;
|
||||||
|
plaidItemId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table name.
|
||||||
|
*/
|
||||||
|
static get tableName() {
|
||||||
|
return 'plaid_items';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamps columns.
|
||||||
|
*/
|
||||||
|
get timestamps() {
|
||||||
|
return ['createdAt', 'updatedAt'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Virtual attributes.
|
||||||
|
*/
|
||||||
|
static get virtualAttributes() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relationship mapping.
|
||||||
|
*/
|
||||||
|
static get relationMappings() {
|
||||||
|
const Tenant = require('system/models/Tenant');
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* System user may belongs to tenant model.
|
||||||
|
*/
|
||||||
|
tenant: {
|
||||||
|
relation: Model.BelongsToOneRelation,
|
||||||
|
modelClass: Tenant.default,
|
||||||
|
join: {
|
||||||
|
from: 'users.tenantId',
|
||||||
|
to: 'tenants.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PLAID_CLIENT } from '@/modules/Plaid/Plaid.module';
|
||||||
|
import { CountryCode, PlaidApi, Products } from 'plaid';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PlaidLinkTokenService {
|
||||||
|
constructor(
|
||||||
|
public readonly configService: ConfigService,
|
||||||
|
|
||||||
|
@Inject(PLAID_CLIENT)
|
||||||
|
private readonly plaidClient: PlaidApi,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the plaid link token.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
public async getLinkToken() {
|
||||||
|
const accessToken = null;
|
||||||
|
|
||||||
|
// Must include transactions in order to receive transactions webhooks
|
||||||
|
const linkTokenParams = {
|
||||||
|
user: {
|
||||||
|
// This should correspond to a unique id for the current user.
|
||||||
|
client_user_id: 'uniqueId' + 1,
|
||||||
|
},
|
||||||
|
client_name: 'Pattern',
|
||||||
|
products: [Products.Transactions],
|
||||||
|
country_codes: [CountryCode.Us],
|
||||||
|
language: 'en',
|
||||||
|
webhook: this.configService.get('plaid.linkWebhook'),
|
||||||
|
access_token: accessToken,
|
||||||
|
};
|
||||||
|
const createResponse =
|
||||||
|
await this.plaidClient.linkTokenCreate(linkTokenParams);
|
||||||
|
|
||||||
|
return createResponse.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { events } from '@/common/events/events';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { IPlaidItemCreatedEventPayload } from '../types/BankingPlaid.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PlaidUpdateTransactionsOnItemCreatedSubscriber {
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { events } from '@/common/events/events';
|
||||||
|
import { RecognizeTranasctionsService } from '@/modules/BankingTranasctionsRegonize/commands/RecognizeTranasctions.service';
|
||||||
|
import { runAfterTransaction } from '@/modules/Tenancy/TenancyDB/TransactionsHooks';
|
||||||
|
import { IPlaidTransactionsSyncedEventPayload } from '../types/BankingPlaid.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RecognizeSyncedBankTranasctionsSubscriber {
|
||||||
|
constructor(
|
||||||
|
private readonly recognizeTranasctionsService: RecognizeTranasctionsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the Plaid item transactions
|
||||||
|
* @param {IPlaidItemCreatedEventPayload} payload - Event payload.
|
||||||
|
*/
|
||||||
|
@OnEvent(events.plaid.onTransactionsSynced)
|
||||||
|
public async handleRecognizeSyncedBankTransactions({
|
||||||
|
batch,
|
||||||
|
trx,
|
||||||
|
}: IPlaidTransactionsSyncedEventPayload) {
|
||||||
|
runAfterTransaction(trx, async () => {
|
||||||
|
await this.recognizeTranasctionsService.recognizeTransactions(
|
||||||
|
null,
|
||||||
|
{ batch }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
import { RemovedTransaction, Transaction } from "plaid";
|
||||||
|
|
||||||
|
export interface IPlaidTransactionsSyncedEventPayload {
|
||||||
|
// tenantId: number;
|
||||||
|
plaidAccountId: number;
|
||||||
|
batch: string;
|
||||||
|
trx?: Knex.Transaction
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaidItemDTO {
|
||||||
|
publicToken: string;
|
||||||
|
institutionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface PlaidFetchedTransactionsUpdates {
|
||||||
|
added: Transaction[];
|
||||||
|
modified: Transaction[];
|
||||||
|
removed: RemovedTransaction[];
|
||||||
|
accessToken: string;
|
||||||
|
cursor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export interface IPlaidItemCreatedEventPayload {
|
||||||
|
tenantId: number;
|
||||||
|
plaidAccessToken: string;
|
||||||
|
plaidItemId: string;
|
||||||
|
plaidInstitutionId: string;
|
||||||
|
}
|
||||||
85
packages/server-nest/src/modules/BankingPlaid/utils.ts
Normal file
85
packages/server-nest/src/modules/BankingPlaid/utils.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import * as R from 'ramda';
|
||||||
|
import {
|
||||||
|
Item as PlaidItem,
|
||||||
|
Institution as PlaidInstitution,
|
||||||
|
AccountBase as PlaidAccount,
|
||||||
|
TransactionBase as PlaidTransactionBase,
|
||||||
|
AccountType as PlaidAccountType,
|
||||||
|
} from 'plaid';
|
||||||
|
import { ACCOUNT_TYPE } from '@/constants/accounts';
|
||||||
|
import { IAccountCreateDTO } from '@/interfaces/Account';
|
||||||
|
import { CreateUncategorizedTransactionDTO } from '../BankingCategorize/types/BankingCategorize.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the system account type from the given Plaid account type.
|
||||||
|
* @param {PlaidAccountType} plaidAccountType
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
const getAccountTypeFromPlaidAccountType = (
|
||||||
|
plaidAccountType: PlaidAccountType
|
||||||
|
) => {
|
||||||
|
if (plaidAccountType === PlaidAccountType.Credit) {
|
||||||
|
return ACCOUNT_TYPE.CREDIT_CARD;
|
||||||
|
}
|
||||||
|
return ACCOUNT_TYPE.BANK;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transformes the Plaid account to create cashflow account DTO.
|
||||||
|
* @param {PlaidItem} item - Plaid item.
|
||||||
|
* @param {PlaidInstitution} institution - Plaid institution.
|
||||||
|
* @param {PlaidAccount} plaidAccount - Plaid account.
|
||||||
|
* @returns {IAccountCreateDTO}
|
||||||
|
*/
|
||||||
|
export const transformPlaidAccountToCreateAccount = R.curry(
|
||||||
|
(
|
||||||
|
item: PlaidItem,
|
||||||
|
institution: PlaidInstitution,
|
||||||
|
plaidAccount: PlaidAccount
|
||||||
|
): IAccountCreateDTO => {
|
||||||
|
return {
|
||||||
|
name: `${institution.name} - ${plaidAccount.name}`,
|
||||||
|
code: '',
|
||||||
|
description: plaidAccount.official_name,
|
||||||
|
currencyCode: plaidAccount.balances.iso_currency_code,
|
||||||
|
accountType: getAccountTypeFromPlaidAccountType(plaidAccount.type),
|
||||||
|
active: true,
|
||||||
|
bankBalance: plaidAccount.balances.current,
|
||||||
|
accountMask: plaidAccount.mask,
|
||||||
|
plaidAccountId: plaidAccount.account_id,
|
||||||
|
plaidItemId: item.item_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transformes the plaid transaction to cashflow create DTO.
|
||||||
|
* @param {number} cashflowAccountId - Cashflow account ID.
|
||||||
|
* @param {number} creditAccountId - Credit account ID.
|
||||||
|
* @param {PlaidTransaction} plaidTranasction - Plaid transaction.
|
||||||
|
* @returns {CreateUncategorizedTransactionDTO}
|
||||||
|
*/
|
||||||
|
export const transformPlaidTrxsToCashflowCreate = R.curry(
|
||||||
|
(
|
||||||
|
cashflowAccountId: number,
|
||||||
|
plaidTranasction: PlaidTransactionBase
|
||||||
|
): CreateUncategorizedTransactionDTO => {
|
||||||
|
return {
|
||||||
|
date: plaidTranasction.date,
|
||||||
|
|
||||||
|
// Plaid: Positive values when money moves out of the account; negative values
|
||||||
|
// when money moves in. For example, debit card purchases are positive;
|
||||||
|
// credit card payments, direct deposits, and refunds are negative.
|
||||||
|
amount: -1 * plaidTranasction.amount,
|
||||||
|
|
||||||
|
description: plaidTranasction.name,
|
||||||
|
payee: plaidTranasction.payment_meta?.payee,
|
||||||
|
currencyCode: plaidTranasction.iso_currency_code,
|
||||||
|
accountId: cashflowAccountId,
|
||||||
|
referenceNo: plaidTranasction.payment_meta?.reference_number,
|
||||||
|
plaidTransactionId: plaidTranasction.transaction_id,
|
||||||
|
pending: plaidTranasction.pending,
|
||||||
|
pendingPlaidTransactionId: plaidTranasction.pending_transaction_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { 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';
|
||||||
|
|
||||||
|
const models = [RegisterTenancyModel(RecognizedBankTransaction)];
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
...models,
|
||||||
|
GetAutofillCategorizeTransactionService,
|
||||||
|
RevertRecognizedTransactionsService,
|
||||||
|
RecognizeTranasctionsService,
|
||||||
|
TriggerRecognizedTransactionsSubscriber,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
...models,
|
||||||
|
GetAutofillCategorizeTransactionService,
|
||||||
|
RevertRecognizedTransactionsService,
|
||||||
|
RecognizeTranasctionsService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BankingTransactionsRegonizeModule {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export interface RevertRecognizedTransactionsCriteria {
|
||||||
|
batch?: string;
|
||||||
|
accountId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface RecognizeTransactionsCriteria {
|
||||||
|
batch?: string;
|
||||||
|
accountId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { lowerCase } from 'lodash';
|
||||||
|
import { UncategorizedBankTransaction } from '../BankingTransactions/models/UncategorizedBankTransaction';
|
||||||
|
import { BankRuleApplyIfTransactionType, BankRuleConditionComparator, BankRuleConditionType, IBankRuleCondition } from '../BankRules/types';
|
||||||
|
import { BankRule } from '../BankRules/models/BankRule';
|
||||||
|
import { BankRuleCondition } from '../BankRules/models/BankRuleCondition';
|
||||||
|
|
||||||
|
const conditionsMatch = (
|
||||||
|
transaction: UncategorizedBankTransaction,
|
||||||
|
conditions: BankRuleCondition[],
|
||||||
|
conditionsType: BankRuleConditionType = BankRuleConditionType.And
|
||||||
|
) => {
|
||||||
|
const method =
|
||||||
|
conditionsType === BankRuleConditionType.And ? 'every' : 'some';
|
||||||
|
|
||||||
|
return conditions[method]((condition) => {
|
||||||
|
switch (determineFieldType(condition.field)) {
|
||||||
|
case 'number':
|
||||||
|
return matchNumberCondition(transaction, condition);
|
||||||
|
case 'text':
|
||||||
|
return matchTextCondition(transaction, condition);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchNumberCondition = (
|
||||||
|
transaction: UncategorizedBankTransaction,
|
||||||
|
condition: BankRuleCondition
|
||||||
|
) => {
|
||||||
|
const conditionValue = parseFloat(condition.value);
|
||||||
|
const transactionAmount =
|
||||||
|
condition.field === 'amount'
|
||||||
|
? Math.abs(transaction[condition.field])
|
||||||
|
: (transaction[condition.field] as unknown as number);
|
||||||
|
|
||||||
|
switch (condition.comparator) {
|
||||||
|
case BankRuleConditionComparator.Equals:
|
||||||
|
case BankRuleConditionComparator.Equal:
|
||||||
|
return transactionAmount === conditionValue;
|
||||||
|
|
||||||
|
case BankRuleConditionComparator.BiggerOrEqual:
|
||||||
|
return transactionAmount >= conditionValue;
|
||||||
|
|
||||||
|
case BankRuleConditionComparator.Bigger:
|
||||||
|
return transactionAmount > conditionValue;
|
||||||
|
|
||||||
|
case BankRuleConditionComparator.Smaller:
|
||||||
|
return transactionAmount < conditionValue;
|
||||||
|
|
||||||
|
case BankRuleConditionComparator.SmallerOrEqual:
|
||||||
|
return transactionAmount <= conditionValue;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchTextCondition = (
|
||||||
|
transaction: UncategorizedBankTransaction,
|
||||||
|
condition: BankRuleCondition
|
||||||
|
): boolean => {
|
||||||
|
const transactionValue = transaction[condition.field] as string;
|
||||||
|
|
||||||
|
switch (condition.comparator) {
|
||||||
|
case BankRuleConditionComparator.Equals:
|
||||||
|
case BankRuleConditionComparator.Equal:
|
||||||
|
return transactionValue === condition.value;
|
||||||
|
case BankRuleConditionComparator.Contains:
|
||||||
|
const fieldValue = lowerCase(transactionValue);
|
||||||
|
const conditionValue = lowerCase(condition.value);
|
||||||
|
|
||||||
|
return fieldValue.includes(conditionValue);
|
||||||
|
case BankRuleConditionComparator.NotContain:
|
||||||
|
return !transactionValue?.includes(condition.value.toString());
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchTransactionType = (
|
||||||
|
bankRule: BankRule,
|
||||||
|
transaction: UncategorizedBankTransaction
|
||||||
|
): boolean => {
|
||||||
|
return (
|
||||||
|
(transaction.isDepositTransaction &&
|
||||||
|
bankRule.applyIfTransactionType ===
|
||||||
|
BankRuleApplyIfTransactionType.Deposit) ||
|
||||||
|
(transaction.isWithdrawalTransaction &&
|
||||||
|
bankRule.applyIfTransactionType ===
|
||||||
|
BankRuleApplyIfTransactionType.Withdrawal)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bankRulesMatchTransaction = (
|
||||||
|
transaction: UncategorizedBankTransaction,
|
||||||
|
bankRules: BankRule[]
|
||||||
|
) => {
|
||||||
|
return bankRules.find((rule) => {
|
||||||
|
return (
|
||||||
|
matchTransactionType(rule, transaction) &&
|
||||||
|
conditionsMatch(transaction, rule.conditions, rule.conditionsType)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const determineFieldType = (field: string): string => {
|
||||||
|
switch (field) {
|
||||||
|
case 'amount':
|
||||||
|
return 'number';
|
||||||
|
case 'description':
|
||||||
|
case 'payee':
|
||||||
|
default:
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { castArray, isEmpty } from 'lodash';
|
||||||
|
import { PromisePool } from '@supercharge/promise-pool';
|
||||||
|
import { bankRulesMatchTransaction } from '../_utils';
|
||||||
|
import { RecognizeTransactionsCriteria } from '../_types';
|
||||||
|
import { BankRule } from '@/modules/BankRules/models/BankRule';
|
||||||
|
import { RecognizedBankTransaction } from '../models/RecognizedBankTransaction';
|
||||||
|
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
|
||||||
|
import { transformToMapBy } from '@/utils/transform-to-map-by';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RecognizeTranasctionsService {
|
||||||
|
constructor(
|
||||||
|
@Inject(UncategorizedBankTransaction.name)
|
||||||
|
private readonly uncategorizedCashflowTransactionModel: typeof UncategorizedBankTransaction,
|
||||||
|
|
||||||
|
@Inject(RecognizedBankTransaction.name)
|
||||||
|
private readonly recognizedBankTransactionModel: typeof RecognizedBankTransaction,
|
||||||
|
|
||||||
|
@Inject(BankRule.name)
|
||||||
|
private readonly bankRuleModel: typeof BankRule,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the uncategorized transaction as recognized from the given bank rule.
|
||||||
|
* @param {BankRule} bankRule -
|
||||||
|
* @param {UncategorizedCashflowTransaction} transaction -
|
||||||
|
* @param {Knex.Transaction} trx -
|
||||||
|
*/
|
||||||
|
private async markBankRuleAsRecognized(
|
||||||
|
bankRule: BankRule,
|
||||||
|
transaction: UncategorizedBankTransaction,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
) {
|
||||||
|
const recognizedTransaction = await this.recognizedBankTransactionModel
|
||||||
|
.query(trx)
|
||||||
|
.insert({
|
||||||
|
bankRuleId: bankRule.id,
|
||||||
|
uncategorizedTransactionId: transaction.id,
|
||||||
|
assignedCategory: bankRule.assignCategory,
|
||||||
|
assignedAccountId: bankRule.assignAccountId,
|
||||||
|
assignedPayee: bankRule.assignPayee,
|
||||||
|
assignedMemo: bankRule.assignMemo,
|
||||||
|
});
|
||||||
|
await this.uncategorizedCashflowTransactionModel
|
||||||
|
.query(trx)
|
||||||
|
.findById(transaction.id)
|
||||||
|
.patch({
|
||||||
|
recognizedTransactionId: recognizedTransaction.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regonized the uncategorized transactions.
|
||||||
|
* @param {number|Array<number>} ruleId - The target rule id/ids.
|
||||||
|
* @param {RecognizeTransactionsCriteria}
|
||||||
|
* @param {Knex.Transaction} trx -
|
||||||
|
*/
|
||||||
|
public async recognizeTransactions(
|
||||||
|
ruleId?: number | Array<number>,
|
||||||
|
transactionsCriteria?: RecognizeTransactionsCriteria,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
) {
|
||||||
|
const uncategorizedTranasctions =
|
||||||
|
await this.uncategorizedCashflowTransactionModel
|
||||||
|
.query(trx)
|
||||||
|
.onBuild((query) => {
|
||||||
|
query.modify('notRecognized');
|
||||||
|
query.modify('notCategorized');
|
||||||
|
|
||||||
|
// Filter the transactions based on the given criteria.
|
||||||
|
if (transactionsCriteria?.batch) {
|
||||||
|
query.where('batch', transactionsCriteria.batch);
|
||||||
|
}
|
||||||
|
if (transactionsCriteria?.accountId) {
|
||||||
|
query.where('accountId', transactionsCriteria.accountId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const bankRules = await this.bankRuleModel.query(trx).onBuild((q) => {
|
||||||
|
const rulesIds = !isEmpty(ruleId) ? castArray(ruleId) : [];
|
||||||
|
|
||||||
|
if (rulesIds?.length > 0) {
|
||||||
|
q.whereIn('id', rulesIds);
|
||||||
|
}
|
||||||
|
q.withGraphFetched('conditions');
|
||||||
|
});
|
||||||
|
const bankRulesByAccountId = transformToMapBy(
|
||||||
|
bankRules,
|
||||||
|
'applyIfAccountId',
|
||||||
|
);
|
||||||
|
// Try to recognize the transaction.
|
||||||
|
const regonizeTransaction = async (
|
||||||
|
transaction: UncategorizedBankTransaction,
|
||||||
|
) => {
|
||||||
|
const allAccountsBankRules = bankRulesByAccountId.get(`null`);
|
||||||
|
const accountBankRules = bankRulesByAccountId.get(
|
||||||
|
`${transaction.accountId}`,
|
||||||
|
);
|
||||||
|
const recognizedBankRule = bankRulesMatchTransaction(
|
||||||
|
transaction,
|
||||||
|
accountBankRules,
|
||||||
|
);
|
||||||
|
if (recognizedBankRule) {
|
||||||
|
await this.markBankRuleAsRecognized(
|
||||||
|
recognizedBankRule,
|
||||||
|
transaction,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const result = await PromisePool.withConcurrency(MIGRATION_CONCURRENCY)
|
||||||
|
.for(uncategorizedTranasctions)
|
||||||
|
.process((transaction: UncategorizedBankTransaction, index, pool) => {
|
||||||
|
return regonizeTransaction(transaction);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {number} uncategorizedTransaction
|
||||||
|
*/
|
||||||
|
public async regonizeTransaction(
|
||||||
|
uncategorizedTransaction: UncategorizedBankTransaction,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIGRATION_CONCURRENCY = 10;
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { castArray } from 'lodash';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { RevertRecognizedTransactionsCriteria } from '../_types';
|
||||||
|
import { RecognizedBankTransaction } from '../models/RecognizedBankTransaction';
|
||||||
|
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
|
||||||
|
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RevertRecognizedTransactionsService {
|
||||||
|
constructor(
|
||||||
|
private readonly uow: UnitOfWork,
|
||||||
|
|
||||||
|
@Inject(RecognizedBankTransaction.name)
|
||||||
|
private readonly recognizedBankTransactionModel: typeof RecognizedBankTransaction,
|
||||||
|
|
||||||
|
@Inject(UncategorizedBankTransaction.name)
|
||||||
|
private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revert and unlinks the recognized transactions based on the given bank rule
|
||||||
|
* and transactions criteria..
|
||||||
|
* @param {number|Array<number>} bankRuleId - Bank rule id.
|
||||||
|
* @param {RevertRecognizedTransactionsCriteria} transactionsCriteria -
|
||||||
|
* @param {Knex.Transaction} trx - Knex transaction.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public async revertRecognizedTransactions(
|
||||||
|
ruleId?: number | Array<number>,
|
||||||
|
transactionsCriteria?: RevertRecognizedTransactionsCriteria,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> {
|
||||||
|
const rulesIds = castArray(ruleId);
|
||||||
|
|
||||||
|
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||||
|
// Retrieves all the recognized transactions of the banbk rule.
|
||||||
|
const uncategorizedTransactions =
|
||||||
|
await this.uncategorizedBankTransactionModel.query(trx).onBuild((q) => {
|
||||||
|
q.withGraphJoined('recognizedTransaction');
|
||||||
|
q.whereNotNull('recognizedTransaction.id');
|
||||||
|
|
||||||
|
if (rulesIds.length > 0) {
|
||||||
|
q.whereIn('recognizedTransaction.bankRuleId', rulesIds);
|
||||||
|
}
|
||||||
|
if (transactionsCriteria?.accountId) {
|
||||||
|
q.where('accountId', transactionsCriteria.accountId);
|
||||||
|
}
|
||||||
|
if (transactionsCriteria?.batch) {
|
||||||
|
q.where('batch', transactionsCriteria.batch);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const uncategorizedTransactionIds = uncategorizedTransactions.map(
|
||||||
|
(r) => r.id,
|
||||||
|
);
|
||||||
|
// Unlink the recongized transactions out of uncategorized transactions.
|
||||||
|
await this.uncategorizedBankTransactionModel
|
||||||
|
.query(trx)
|
||||||
|
.whereIn('id', uncategorizedTransactionIds)
|
||||||
|
.patch({
|
||||||
|
recognizedTransactionId: null,
|
||||||
|
});
|
||||||
|
// Delete the recognized bank transactions that assocaited to bank rule.
|
||||||
|
await this.recognizedBankTransactionModel
|
||||||
|
.query(trx)
|
||||||
|
.whereIn('uncategorizedTransactionId', uncategorizedTransactionIds)
|
||||||
|
.delete();
|
||||||
|
}, trx);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TriggerRecognizedTransactionsSubscriber {
|
||||||
|
/**
|
||||||
|
* Triggers the recognize uncategorized transactions job on rule created.
|
||||||
|
* @param {IBankRuleEventCreatedPayload} payload -
|
||||||
|
*/
|
||||||
|
@OnEvent(events.bankRules.onCreated)
|
||||||
|
private async recognizedTransactionsOnRuleCreated({
|
||||||
|
bankRule,
|
||||||
|
}: IBankRuleEventCreatedPayload) {
|
||||||
|
const payload = { ruleId: bankRule.id };
|
||||||
|
|
||||||
|
// await this.agenda.now('recognize-uncategorized-transactions-job', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers the recognize uncategorized transactions job on rule edited.
|
||||||
|
* @param {IBankRuleEventEditedPayload} payload -
|
||||||
|
*/
|
||||||
|
@OnEvent(events.bankRules.onEdited)
|
||||||
|
private 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'])
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// await this.agenda.now(
|
||||||
|
// 'rerecognize-uncategorized-transactions-job',
|
||||||
|
// payload
|
||||||
|
// );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers the recognize uncategorized transactions job on rule deleted.
|
||||||
|
* @param {IBankRuleEventDeletedPayload} payload -
|
||||||
|
*/
|
||||||
|
@OnEvent(events.bankRules.onDeleted)
|
||||||
|
private async recognizedTransactionsOnRuleDeleted({
|
||||||
|
ruleId,
|
||||||
|
}: IBankRuleEventDeletedPayload) {
|
||||||
|
const payload = { ruleId };
|
||||||
|
|
||||||
|
// await this.agenda.now(
|
||||||
|
// 'revert-recognized-uncategorized-transactions-job',
|
||||||
|
// payload
|
||||||
|
// );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers the recognize bank transactions once the imported file commit.
|
||||||
|
* @param {IImportFileCommitedEventPayload} payload -
|
||||||
|
*/
|
||||||
|
@OnEvent(events.import.onImportCommitted)
|
||||||
|
private async triggerRecognizeTransactionsOnImportCommitted({
|
||||||
|
importId,
|
||||||
|
}: IImportFileCommitedEventPayload) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// import Container, { Service } from 'typedi';
|
||||||
|
// import { RecognizeTranasctionsService } from '../commands/RecognizeTranasctions.service';
|
||||||
|
|
||||||
|
// @Service()
|
||||||
|
// export class RegonizeTransactionsJob {
|
||||||
|
// /**
|
||||||
|
// * Constructor method.
|
||||||
|
// */
|
||||||
|
// constructor(agenda) {
|
||||||
|
// agenda.define(
|
||||||
|
// 'recognize-uncategorized-transactions-job',
|
||||||
|
// { priority: 'high', concurrency: 2 },
|
||||||
|
// this.handler
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Triggers sending invoice mail.
|
||||||
|
// */
|
||||||
|
// private handler = async (job, done: Function) => {
|
||||||
|
// const { tenantId, ruleId, transactionsCriteria } = job.attrs.data;
|
||||||
|
// const regonizeTransactions = Container.get(RecognizeTranasctionsService);
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// await regonizeTransactions.recognizeTransactions(
|
||||||
|
// tenantId,
|
||||||
|
// ruleId,
|
||||||
|
// transactionsCriteria
|
||||||
|
// );
|
||||||
|
// done();
|
||||||
|
// } catch (error) {
|
||||||
|
// console.log(error);
|
||||||
|
// done(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
// }
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// import Container, { Service } from 'typedi';
|
||||||
|
// import { RecognizeTranasctionsService } from '../commands/RecognizeTranasctions.service';
|
||||||
|
// import { RevertRecognizedTransactions } from '../commands/RevertRecognizedTransactions.service';
|
||||||
|
|
||||||
|
// @Service()
|
||||||
|
// export class ReregonizeTransactionsJob {
|
||||||
|
// /**
|
||||||
|
// * Constructor method.
|
||||||
|
// */
|
||||||
|
// constructor(agenda) {
|
||||||
|
// agenda.define(
|
||||||
|
// 'rerecognize-uncategorized-transactions-job',
|
||||||
|
// { priority: 'high', concurrency: 2 },
|
||||||
|
// this.handler
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Triggers sending invoice mail.
|
||||||
|
// */
|
||||||
|
// private handler = async (job, done: Function) => {
|
||||||
|
// const { tenantId, ruleId, transactionsCriteria } = job.attrs.data;
|
||||||
|
// const regonizeTransactions = Container.get(RecognizeTranasctionsService);
|
||||||
|
// const revertRegonizedTransactions = Container.get(
|
||||||
|
// RevertRecognizedTransactions
|
||||||
|
// );
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// await revertRegonizedTransactions.revertRecognizedTransactions(
|
||||||
|
// tenantId,
|
||||||
|
// ruleId,
|
||||||
|
// transactionsCriteria
|
||||||
|
// );
|
||||||
|
// await regonizeTransactions.recognizeTransactions(
|
||||||
|
// tenantId,
|
||||||
|
// ruleId,
|
||||||
|
// transactionsCriteria
|
||||||
|
// );
|
||||||
|
// done();
|
||||||
|
// } catch (error) {
|
||||||
|
// console.log(error);
|
||||||
|
// done(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
// }
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// import Container, { Service } from 'typedi';
|
||||||
|
// import { RevertRecognizedTransactions } from '../commands/RevertRecognizedTransactions.service';
|
||||||
|
|
||||||
|
// @Service()
|
||||||
|
// export class RevertRegonizeTransactionsJob {
|
||||||
|
// /**
|
||||||
|
// * Constructor method.
|
||||||
|
// */
|
||||||
|
// constructor(agenda) {
|
||||||
|
// agenda.define(
|
||||||
|
// 'revert-recognized-uncategorized-transactions-job',
|
||||||
|
// { priority: 'high', concurrency: 2 },
|
||||||
|
// this.handler
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Triggers sending invoice mail.
|
||||||
|
// */
|
||||||
|
// private handler = async (job, done: Function) => {
|
||||||
|
// const { tenantId, ruleId, transactionsCriteria } = job.attrs.data;
|
||||||
|
// const revertRegonizedTransactions = Container.get(
|
||||||
|
// RevertRecognizedTransactions
|
||||||
|
// );
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// await revertRegonizedTransactions.revertRecognizedTransactions(
|
||||||
|
// tenantId,
|
||||||
|
// ruleId,
|
||||||
|
// transactionsCriteria
|
||||||
|
// );
|
||||||
|
// done();
|
||||||
|
// } catch (error) {
|
||||||
|
// console.log(error);
|
||||||
|
// done(error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
// }
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { BaseModel } from '@/models/Model';
|
||||||
|
import { Model } from 'objection';
|
||||||
|
|
||||||
|
export class RecognizedBankTransaction extends BaseModel {
|
||||||
|
public bankRuleId!: number;
|
||||||
|
public uncategorizedTransactionId!: number;
|
||||||
|
public assignedCategory!: string;
|
||||||
|
public assignedAccountId!: number;
|
||||||
|
public assignedPayee!: string;
|
||||||
|
public assignedMemo!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table name.
|
||||||
|
*/
|
||||||
|
static get tableName() {
|
||||||
|
return 'recognized_bank_transactions';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamps columns.
|
||||||
|
*/
|
||||||
|
get timestamps() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Virtual attributes.
|
||||||
|
*/
|
||||||
|
static get virtualAttributes() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relationship mapping.
|
||||||
|
*/
|
||||||
|
static get relationMappings() {
|
||||||
|
const UncategorizedCashflowTransaction = require('./UncategorizedCashflowTransaction');
|
||||||
|
const Account = require('./Account');
|
||||||
|
const { BankRule } = require('./BankRule');
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Recognized bank transaction may belongs to uncategorized transactions.
|
||||||
|
*/
|
||||||
|
uncategorizedTransactions: {
|
||||||
|
relation: Model.HasManyRelation,
|
||||||
|
modelClass: UncategorizedCashflowTransaction.default,
|
||||||
|
join: {
|
||||||
|
from: 'recognized_bank_transactions.uncategorizedTransactionId',
|
||||||
|
to: 'uncategorized_cashflow_transactions.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recognized bank transaction may belongs to assign account.
|
||||||
|
*/
|
||||||
|
assignAccount: {
|
||||||
|
relation: Model.BelongsToOneRelation,
|
||||||
|
modelClass: Account.default,
|
||||||
|
join: {
|
||||||
|
from: 'recognized_bank_transactions.assignedAccountId',
|
||||||
|
to: 'accounts.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recognized bank transaction may belongs to bank rule.
|
||||||
|
*/
|
||||||
|
bankRule: {
|
||||||
|
relation: Model.BelongsToOneRelation,
|
||||||
|
modelClass: BankRule,
|
||||||
|
join: {
|
||||||
|
from: 'recognized_bank_transactions.bankRuleId',
|
||||||
|
to: 'bank_rules.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { castArray, first, uniq } from 'lodash';
|
||||||
|
import { GetAutofillCategorizeTransctionTransformer } from './GetAutofillCategorizeTransactionTransformer';
|
||||||
|
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
|
||||||
|
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetAutofillCategorizeTransactionService {
|
||||||
|
constructor(
|
||||||
|
private readonly transformer: TransformerInjectable,
|
||||||
|
|
||||||
|
@Inject(UncategorizedBankTransaction.name)
|
||||||
|
private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the autofill values of categorize transactions form.
|
||||||
|
* @param {Array<number> | number} uncategorizeTransactionsId - Uncategorized transactions ids.
|
||||||
|
*/
|
||||||
|
public async getAutofillCategorizeTransaction(
|
||||||
|
uncategorizeTransactionsId: Array<number> | number
|
||||||
|
) {
|
||||||
|
const uncategorizeTransactionsIds = uniq(
|
||||||
|
castArray(uncategorizeTransactionsId)
|
||||||
|
);
|
||||||
|
const uncategorizedTransactions =
|
||||||
|
await this.uncategorizedBankTransactionModel.query()
|
||||||
|
.whereIn('id', uncategorizeTransactionsIds)
|
||||||
|
.withGraphFetched('recognizedTransaction.assignAccount')
|
||||||
|
.withGraphFetched('recognizedTransaction.bankRule')
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
return this.transformer.transform(
|
||||||
|
{},
|
||||||
|
new GetAutofillCategorizeTransctionTransformer(),
|
||||||
|
{
|
||||||
|
uncategorizedTransactions,
|
||||||
|
firstUncategorizedTransaction: first(uncategorizedTransactions),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { sumBy } from 'lodash';
|
||||||
|
import { Transformer } from '@/modules/Transformer/Transformer';
|
||||||
|
|
||||||
|
export class GetAutofillCategorizeTransctionTransformer extends Transformer {
|
||||||
|
/**
|
||||||
|
* Included attributes to the object.
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
public includeAttributes = (): string[] => {
|
||||||
|
return [
|
||||||
|
'amount',
|
||||||
|
'formattedAmount',
|
||||||
|
'isRecognized',
|
||||||
|
'date',
|
||||||
|
'formattedDate',
|
||||||
|
'creditAccountId',
|
||||||
|
'debitAccountId',
|
||||||
|
'referenceNo',
|
||||||
|
'transactionType',
|
||||||
|
'recognizedByRuleId',
|
||||||
|
'recognizedByRuleName',
|
||||||
|
'isWithdrawalTransaction',
|
||||||
|
'isDepositTransaction',
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detarmines whether the transaction is recognized.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
public isRecognized() {
|
||||||
|
return !!this.options.firstUncategorizedTransaction?.recognizedTransaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the total amount of uncategorized transactions.
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
public amount() {
|
||||||
|
return sumBy(this.options.uncategorizedTransactions, 'amount');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the formatted total amount of uncategorized transactions.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
public formattedAmount() {
|
||||||
|
return this.formatNumber(this.amount(), {
|
||||||
|
currencyCode: 'USD',
|
||||||
|
money: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detarmines whether the transaction is deposit.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
public isDepositTransaction() {
|
||||||
|
const amount = this.amount();
|
||||||
|
|
||||||
|
return amount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detarmines whether the transaction is withdrawal.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
public isWithdrawalTransaction() {
|
||||||
|
const amount = this.amount();
|
||||||
|
|
||||||
|
return amount < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string}
|
||||||
|
*/
|
||||||
|
public date() {
|
||||||
|
return this.options.firstUncategorizedTransaction?.date || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the formatted date of uncategorized transaction.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
public formattedDate() {
|
||||||
|
return this.formatDate(this.date());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string}
|
||||||
|
*/
|
||||||
|
public referenceNo() {
|
||||||
|
return this.options.firstUncategorizedTransaction?.referenceNo || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
public creditAccountId() {
|
||||||
|
return (
|
||||||
|
this.options.firstUncategorizedTransaction?.recognizedTransaction
|
||||||
|
?.assignedAccountId || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
public debitAccountId() {
|
||||||
|
return this.options.firstUncategorizedTransaction?.accountId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the assigned category of recognized transaction, if is not recognized
|
||||||
|
* returns the default transaction type depends on the transaction normal.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
public transactionType() {
|
||||||
|
const assignedCategory =
|
||||||
|
this.options.firstUncategorizedTransaction?.recognizedTransaction
|
||||||
|
?.assignedCategory;
|
||||||
|
|
||||||
|
return (
|
||||||
|
assignedCategory ||
|
||||||
|
(this.isDepositTransaction() ? 'other_income' : 'other_expense')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
public payee() {
|
||||||
|
return (
|
||||||
|
this.options.firstUncategorizedTransaction?.recognizedTransaction
|
||||||
|
?.assignedPayee || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
public memo() {
|
||||||
|
return (
|
||||||
|
this.options.firstUncategorizedTransaction?.recognizedTransaction
|
||||||
|
?.assignedMemo || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the rule id the transaction recongized by.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
public recognizedByRuleId() {
|
||||||
|
return (
|
||||||
|
this.options.firstUncategorizedTransaction?.recognizedTransaction
|
||||||
|
?.bankRuleId || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the rule name the transaction recongized by.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
public recognizedByRuleName() {
|
||||||
|
return (
|
||||||
|
this.options.firstUncategorizedTransaction?.recognizedTransaction
|
||||||
|
?.bankRule?.name || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { RegisterTenancyModel } from '../Tenancy/TenancyModels/Tenancy.module';
|
||||||
|
import { UncategorizedBankTransaction } from './models/UncategorizedBankTransaction';
|
||||||
|
import { BankTransactionLine } from './models/BankTransactionLine';
|
||||||
|
import { BankTransaction } from './models/BankTransaction';
|
||||||
|
import { BankTransactionAutoIncrement } from './commands/BankTransactionAutoIncrement.service';
|
||||||
|
import BankingTransactionGLEntriesSubscriber from './subscribers/CashflowTransactionSubscriber';
|
||||||
|
import { DecrementUncategorizedTransactionOnCategorizeSubscriber } from './subscribers/DecrementUncategorizedTransactionOnCategorize';
|
||||||
|
import { DeleteCashflowTransactionOnUncategorizeSubscriber } from './subscribers/DeleteCashflowTransactionOnUncategorize';
|
||||||
|
import { PreventDeleteTransactionOnDeleteSubscriber } from './subscribers/PreventDeleteTransactionsOnDelete';
|
||||||
|
import { ValidateDeleteBankAccountTransactions } from './commands/ValidateDeleteBankAccountTransactions.service';
|
||||||
|
import { BankTransactionGLEntriesService } from './commands/BankTransactionGLEntries';
|
||||||
|
import { BankingTransactionsApplication } from './BankingTransactionsApplication.service';
|
||||||
|
|
||||||
|
const models = [
|
||||||
|
RegisterTenancyModel(UncategorizedBankTransaction),
|
||||||
|
RegisterTenancyModel(BankTransaction),
|
||||||
|
RegisterTenancyModel(BankTransactionLine),
|
||||||
|
];
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
exports: [
|
||||||
|
BankTransactionAutoIncrement,
|
||||||
|
BankTransactionGLEntriesService,
|
||||||
|
ValidateDeleteBankAccountTransactions,
|
||||||
|
BankingTransactionGLEntriesSubscriber,
|
||||||
|
DecrementUncategorizedTransactionOnCategorizeSubscriber,
|
||||||
|
DeleteCashflowTransactionOnUncategorizeSubscriber,
|
||||||
|
PreventDeleteTransactionOnDeleteSubscriber,
|
||||||
|
BankingTransactionsApplication,
|
||||||
|
...models,
|
||||||
|
],
|
||||||
|
providers: [...models],
|
||||||
|
})
|
||||||
|
export class BankingTransactionsModule {}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { DeleteCashflowTransaction } from './commands/DeleteCashflowTransaction.service';
|
||||||
|
import { CreateBankTransactionService } from './commands/CreateBankTransaction.service';
|
||||||
|
import { GetBankTransactionService } from './queries/GetBankTransaction.service';
|
||||||
|
import { ICashflowNewCommandDTO } from './types/BankingTransactions.types';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BankingTransactionsApplication {
|
||||||
|
constructor(
|
||||||
|
private readonly createTransactionService: CreateBankTransactionService,
|
||||||
|
private readonly deleteTransactionService: DeleteCashflowTransaction,
|
||||||
|
private readonly getCashflowTransactionService: GetBankTransactionService,
|
||||||
|
// private readonly getCashflowAccountsService: GetBankingAccountsServic,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new cashflow transaction.
|
||||||
|
* @param {ICashflowNewCommandDTO} transactionDTO
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
public createTransaction(transactionDTO: ICashflowNewCommandDTO) {
|
||||||
|
return this.createTransactionService.newCashflowTransaction(transactionDTO);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the given cashflow transaction.
|
||||||
|
* @param {number} cashflowTransactionId - Cashflow transaction id.
|
||||||
|
* @returns {Promise<{ oldCashflowTransaction: ICashflowTransaction }>}
|
||||||
|
*/
|
||||||
|
public deleteTransaction(cashflowTransactionId: number) {
|
||||||
|
return this.deleteTransactionService.deleteCashflowTransaction(
|
||||||
|
cashflowTransactionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves specific cashflow transaction.
|
||||||
|
* @param {number} cashflowTransactionId
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
public getTransaction(cashflowTransactionId: number) {
|
||||||
|
return this.getCashflowTransactionService.getBankTransaction(
|
||||||
|
cashflowTransactionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the cashflow accounts.
|
||||||
|
* @param {ICashflowAccountsFilter} filterDTO
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
public getCashflowAccounts(
|
||||||
|
// filterDTO: ICashflowAccountsFilter,
|
||||||
|
) {
|
||||||
|
// return this.getCashflowAccountsService.getCashflowAccounts(filterDTO);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AutoIncrementOrdersService } from '../../AutoIncrementOrders/AutoIncrementOrders.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BankTransactionAutoIncrement {
|
||||||
|
constructor(
|
||||||
|
private readonly autoIncrementOrdersService: AutoIncrementOrdersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the next unique invoice number.
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
public getNextTransactionNumber = (): string => {
|
||||||
|
return this.autoIncrementOrdersService.getNextTransactionNumber('cashflow');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment the invoice next number.
|
||||||
|
*/
|
||||||
|
public incrementNextTransactionNumber = () => {
|
||||||
|
return this.autoIncrementOrdersService.incrementSettingsNextNumber(
|
||||||
|
'cashflow',
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { ILedgerEntry } from '@/modules/Ledger/types/Ledger.types';
|
||||||
|
import { BankTransaction } from '../models/BankTransaction';
|
||||||
|
import { transformCashflowTransactionType } from '../utils';
|
||||||
|
import { Ledger } from '@/modules/Ledger/Ledger';
|
||||||
|
|
||||||
|
export class BankTransactionGL {
|
||||||
|
private bankTransactionModel: BankTransaction;
|
||||||
|
/**
|
||||||
|
* @param {BankTransaction} bankTransactionModel - The bank transaction model.
|
||||||
|
*/
|
||||||
|
constructor(bankTransactionModel: BankTransaction) {
|
||||||
|
this.bankTransactionModel = bankTransactionModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the common entry of cashflow transaction.
|
||||||
|
* @returns {Partial<ILedgerEntry>}
|
||||||
|
*/
|
||||||
|
private get commonEntry() {
|
||||||
|
const { entries, ...transaction } = this.bankTransactionModel;
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: this.bankTransactionModel.date,
|
||||||
|
currencyCode: this.bankTransactionModel.currencyCode,
|
||||||
|
exchangeRate: this.bankTransactionModel.exchangeRate,
|
||||||
|
|
||||||
|
transactionType: 'CashflowTransaction',
|
||||||
|
transactionId: this.bankTransactionModel.id,
|
||||||
|
transactionNumber: this.bankTransactionModel.transactionNumber,
|
||||||
|
transactionSubType: transformCashflowTransactionType(
|
||||||
|
this.bankTransactionModel.transactionType,
|
||||||
|
),
|
||||||
|
referenceNumber: this.bankTransactionModel.referenceNo,
|
||||||
|
|
||||||
|
note: this.bankTransactionModel.description,
|
||||||
|
|
||||||
|
branchId: this.bankTransactionModel.branchId,
|
||||||
|
userId: this.bankTransactionModel.userId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the cashflow debit GL entry.
|
||||||
|
* @returns {ILedgerEntry}
|
||||||
|
*/
|
||||||
|
private get cashflowDebitGLEntry(): ILedgerEntry {
|
||||||
|
const commonEntry = this.commonEntry;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...commonEntry,
|
||||||
|
accountId: this.bankTransactionModel.cashflowAccountId,
|
||||||
|
credit: this.bankTransactionModel.isCashCredit
|
||||||
|
? this.bankTransactionModel.localAmount
|
||||||
|
: 0,
|
||||||
|
debit: this.bankTransactionModel.isCashDebit
|
||||||
|
? this.bankTransactionModel.localAmount
|
||||||
|
: 0,
|
||||||
|
accountNormal: this.bankTransactionModel?.cashflowAccount?.accountNormal,
|
||||||
|
index: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the cashflow credit GL entry.
|
||||||
|
* @returns {ILedgerEntry}
|
||||||
|
*/
|
||||||
|
private get cashflowCreditGLEntry(): ILedgerEntry {
|
||||||
|
return {
|
||||||
|
...this.commonEntry,
|
||||||
|
credit: this.bankTransactionModel.isCashDebit
|
||||||
|
? this.bankTransactionModel.localAmount
|
||||||
|
: 0,
|
||||||
|
debit: this.bankTransactionModel.isCashCredit
|
||||||
|
? this.bankTransactionModel.localAmount
|
||||||
|
: 0,
|
||||||
|
accountId: this.bankTransactionModel.creditAccountId,
|
||||||
|
accountNormal: this.bankTransactionModel.creditAccount.accountNormal,
|
||||||
|
index: 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the cashflow transaction GL entry.
|
||||||
|
* @returns {ILedgerEntry[]}
|
||||||
|
*/
|
||||||
|
private getJournalEntries(): ILedgerEntry[] {
|
||||||
|
const debitEntry = this.cashflowDebitGLEntry;
|
||||||
|
const creditEntry = this.cashflowCreditGLEntry;
|
||||||
|
|
||||||
|
return [debitEntry, creditEntry];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the cashflow GL ledger.
|
||||||
|
* @returns {Ledger}
|
||||||
|
*/
|
||||||
|
public getCashflowLedger() {
|
||||||
|
const entries = this.getJournalEntries();
|
||||||
|
|
||||||
|
return new Ledger(entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { LedgerStorageService } from '@/modules/Ledger/LedgerStorage.service';
|
||||||
|
import { BankTransaction } from '../models/BankTransaction';
|
||||||
|
import { BankTransactionGL } from './BankTransactionGL';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BankTransactionGLEntriesService {
|
||||||
|
constructor(
|
||||||
|
private readonly ledgerStorage: LedgerStorageService,
|
||||||
|
|
||||||
|
@Inject(BankTransaction.name)
|
||||||
|
private readonly bankTransactionModel: typeof BankTransaction,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the journal entries of the given cashflow transaction.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {ICashflowTransaction} cashflowTransaction
|
||||||
|
* @return {Promise<void>}
|
||||||
|
*/
|
||||||
|
public writeJournalEntries = async (
|
||||||
|
cashflowTransactionId: number,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> => {
|
||||||
|
// Retrieves the cashflow transactions with associated entries.
|
||||||
|
const transaction = await this.bankTransactionModel
|
||||||
|
.query(trx)
|
||||||
|
.findById(cashflowTransactionId)
|
||||||
|
.withGraphFetched('cashflowAccount')
|
||||||
|
.withGraphFetched('creditAccount');
|
||||||
|
|
||||||
|
// Retrieves the cashflow transaction ledger.
|
||||||
|
const ledger = new BankTransactionGL(transaction).getCashflowLedger();
|
||||||
|
|
||||||
|
await this.ledgerStorage.commit(ledger, trx);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the journal entries.
|
||||||
|
* @param {number} cashflowTransactionId - Cashflow transaction id.
|
||||||
|
* @return {Promise<void>}
|
||||||
|
*/
|
||||||
|
public revertJournalEntries = async (
|
||||||
|
cashflowTransactionId: number,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<void> => {
|
||||||
|
await this.ledgerStorage.deleteByReference(
|
||||||
|
cashflowTransactionId,
|
||||||
|
'CashflowTransaction',
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { includes, camelCase, upperFirst, sumBy } from 'lodash';
|
||||||
|
import { getCashflowTransactionType } from '../utils';
|
||||||
|
import {
|
||||||
|
CASHFLOW_DIRECTION,
|
||||||
|
CASHFLOW_TRANSACTION_TYPE,
|
||||||
|
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';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CommandBankTransactionValidator {
|
||||||
|
/**
|
||||||
|
* Validates the lines accounts type should be cash or bank account.
|
||||||
|
* @param {Account} accounts -
|
||||||
|
*/
|
||||||
|
public validateCreditAccountWithCashflowType = (
|
||||||
|
creditAccount: Account,
|
||||||
|
cashflowTransactionType: CASHFLOW_TRANSACTION_TYPE
|
||||||
|
): void => {
|
||||||
|
const transactionTypeMeta = getCashflowTransactionType(
|
||||||
|
cashflowTransactionType
|
||||||
|
);
|
||||||
|
const noneCashflowAccount = !includes(
|
||||||
|
transactionTypeMeta.creditType,
|
||||||
|
creditAccount.accountType
|
||||||
|
);
|
||||||
|
if (noneCashflowAccount) {
|
||||||
|
throw new ServiceError(ERRORS.CREDIT_ACCOUNTS_HAS_INVALID_TYPE);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the cashflow transaction type.
|
||||||
|
* @param {string} transactionType
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
public validateCashflowTransactionType = (transactionType: string) => {
|
||||||
|
const transformedType = upperFirst(
|
||||||
|
camelCase(transactionType)
|
||||||
|
) as CASHFLOW_TRANSACTION_TYPE;
|
||||||
|
|
||||||
|
// Retrieve the given transaction type meta.
|
||||||
|
const transactionTypeMeta = getCashflowTransactionType(transformedType);
|
||||||
|
|
||||||
|
// Throw service error in case not the found the given transaction type.
|
||||||
|
if (!transactionTypeMeta) {
|
||||||
|
throw new ServiceError(ERRORS.CASHFLOW_TRANSACTION_TYPE_INVALID);
|
||||||
|
}
|
||||||
|
return transformedType;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the given transaction should be categorized.
|
||||||
|
* @param {CashflowTransaction} cashflowTransaction
|
||||||
|
*/
|
||||||
|
public validateTransactionShouldCategorized(
|
||||||
|
cashflowTransaction: BankTransaction
|
||||||
|
) {
|
||||||
|
if (!cashflowTransaction.uncategorize) {
|
||||||
|
throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the given transcation shouldn't be categorized.
|
||||||
|
* @param {CashflowTransaction} cashflowTransaction
|
||||||
|
*/
|
||||||
|
public validateTransactionsShouldNotCategorized(
|
||||||
|
cashflowTransactions: Array<UncategorizedBankTransaction>
|
||||||
|
) {
|
||||||
|
const categorized = cashflowTransactions.filter((t) => t.categorized);
|
||||||
|
|
||||||
|
if (categorized?.length > 0) {
|
||||||
|
throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED, '', {
|
||||||
|
ids: categorized.map((t) => t.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the uncategorize transaction type.
|
||||||
|
* @param {uncategorizeTransaction}
|
||||||
|
* @param {string} transactionType
|
||||||
|
* @throws {ServiceError(ERRORS.UNCATEGORIZED_TRANSACTION_TYPE_INVALID)}
|
||||||
|
*/
|
||||||
|
public validateUncategorizeTransactionType(
|
||||||
|
uncategorizeTransactions: Array<UncategorizedBankTransaction>,
|
||||||
|
transactionType: string
|
||||||
|
) {
|
||||||
|
const amount = sumBy(uncategorizeTransactions, 'amount');
|
||||||
|
const isDepositTransaction = amount > 0;
|
||||||
|
const isWithdrawalTransaction = amount <= 0;
|
||||||
|
|
||||||
|
const type = getCashflowTransactionType(
|
||||||
|
transactionType as CASHFLOW_TRANSACTION_TYPE
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
(type.direction === CASHFLOW_DIRECTION.IN && isDepositTransaction) ||
|
||||||
|
(type.direction === CASHFLOW_DIRECTION.OUT && isWithdrawalTransaction)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new ServiceError(ERRORS.UNCATEGORIZED_TRANSACTION_TYPE_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { pick } from 'lodash';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import * as R from 'ramda';
|
||||||
|
import { CASHFLOW_TRANSACTION_TYPE } from '../constants';
|
||||||
|
import { transformCashflowTransactionType } from '../utils';
|
||||||
|
import { CommandBankTransactionValidator } from './CommandCasflowValidator.service';
|
||||||
|
import { BankTransactionAutoIncrement } from './BankTransactionAutoIncrement.service';
|
||||||
|
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { BranchTransactionDTOTransformer } from '@/modules/Branches/integrations/BranchTransactionDTOTransform';
|
||||||
|
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';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CreateBankTransactionService {
|
||||||
|
constructor(
|
||||||
|
private validator: CommandBankTransactionValidator,
|
||||||
|
private uow: UnitOfWork,
|
||||||
|
private eventPublisher: EventEmitter2,
|
||||||
|
private autoIncrement: BankTransactionAutoIncrement,
|
||||||
|
private branchDTOTransform: BranchTransactionDTOTransformer,
|
||||||
|
|
||||||
|
@Inject(BankTransaction.name)
|
||||||
|
private bankTransactionModel: typeof BankTransaction,
|
||||||
|
|
||||||
|
@Inject(Account.name)
|
||||||
|
private accountModel: typeof Account,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorize the cashflow creating transaction.
|
||||||
|
* @param {ICashflowNewCommandDTO} newCashflowTransactionDTO
|
||||||
|
*/
|
||||||
|
public authorize = async (
|
||||||
|
newCashflowTransactionDTO: ICashflowNewCommandDTO,
|
||||||
|
creditAccount: Account,
|
||||||
|
) => {
|
||||||
|
const transactionType = transformCashflowTransactionType(
|
||||||
|
newCashflowTransactionDTO.transactionType,
|
||||||
|
);
|
||||||
|
// Validates the cashflow transaction type.
|
||||||
|
this.validator.validateCashflowTransactionType(transactionType);
|
||||||
|
|
||||||
|
// Retrieve accounts of the cashflow lines object.
|
||||||
|
this.validator.validateCreditAccountWithCashflowType(
|
||||||
|
creditAccount,
|
||||||
|
transactionType as CASHFLOW_TRANSACTION_TYPE,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transformes owner contribution DTO to cashflow transaction.
|
||||||
|
* @param {ICashflowNewCommandDTO} newCashflowTransactionDTO - New transaction DTO.
|
||||||
|
* @returns {ICashflowTransactionInput} - Cashflow transaction object.
|
||||||
|
*/
|
||||||
|
private transformCashflowTransactionDTO = (
|
||||||
|
newCashflowTransactionDTO: ICashflowNewCommandDTO,
|
||||||
|
cashflowAccount: Account,
|
||||||
|
userId: number,
|
||||||
|
): BankTransaction => {
|
||||||
|
const amount = newCashflowTransactionDTO.amount;
|
||||||
|
|
||||||
|
const fromDTO = pick(newCashflowTransactionDTO, [
|
||||||
|
'date',
|
||||||
|
'referenceNo',
|
||||||
|
'description',
|
||||||
|
'transactionType',
|
||||||
|
'exchangeRate',
|
||||||
|
'cashflowAccountId',
|
||||||
|
'creditAccountId',
|
||||||
|
'branchId',
|
||||||
|
'plaidTransactionId',
|
||||||
|
'uncategorizedTransactionId',
|
||||||
|
]);
|
||||||
|
// Retreive the next invoice number.
|
||||||
|
const autoNextNumber = this.autoIncrement.getNextTransactionNumber();
|
||||||
|
|
||||||
|
// Retrieve the transaction number.
|
||||||
|
const transactionNumber =
|
||||||
|
newCashflowTransactionDTO.transactionNumber || autoNextNumber;
|
||||||
|
|
||||||
|
const initialDTO = {
|
||||||
|
amount,
|
||||||
|
...fromDTO,
|
||||||
|
transactionNumber,
|
||||||
|
currencyCode: cashflowAccount.currencyCode,
|
||||||
|
exchangeRate: fromDTO?.exchangeRate || 1,
|
||||||
|
transactionType: transformCashflowTransactionType(
|
||||||
|
fromDTO.transactionType,
|
||||||
|
),
|
||||||
|
userId,
|
||||||
|
...(newCashflowTransactionDTO.publish
|
||||||
|
? {
|
||||||
|
publishedAt: new Date(),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
return R.compose(this.branchDTOTransform.transformDTO<BankTransaction>)(
|
||||||
|
initialDTO,
|
||||||
|
) as BankTransaction;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owner contribution money in.
|
||||||
|
* @param {ICashflowOwnerContributionDTO} ownerContributionDTO
|
||||||
|
* @param {number} userId - User id.
|
||||||
|
* @returns {Promise<ICashflowTransaction>}
|
||||||
|
*/
|
||||||
|
public newCashflowTransaction = async (
|
||||||
|
newTransactionDTO: ICashflowNewCommandDTO,
|
||||||
|
userId?: number,
|
||||||
|
): Promise<BankTransaction> => {
|
||||||
|
// Retrieves the cashflow account or throw not found error.
|
||||||
|
const cashflowAccount = await this.accountModel
|
||||||
|
.query()
|
||||||
|
.findById(newTransactionDTO.cashflowAccountId)
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
// Retrieves the credit account or throw not found error.
|
||||||
|
const creditAccount = await this.accountModel
|
||||||
|
.query()
|
||||||
|
.findById(newTransactionDTO.creditAccountId)
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
// Authorize before creating cashflow transaction.
|
||||||
|
await this.authorize(newTransactionDTO, creditAccount);
|
||||||
|
|
||||||
|
// Transformes owner contribution DTO to cashflow transaction.
|
||||||
|
const cashflowTransactionObj = this.transformCashflowTransactionDTO(
|
||||||
|
newTransactionDTO,
|
||||||
|
cashflowAccount,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
// Creates a new cashflow transaction under UOW envirement.
|
||||||
|
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||||
|
// Triggers `onCashflowTransactionCreate` event.
|
||||||
|
await this.eventPublisher.emitAsync(
|
||||||
|
events.cashflow.onTransactionCreating,
|
||||||
|
{
|
||||||
|
trx,
|
||||||
|
newTransactionDTO,
|
||||||
|
} as ICommandCashflowCreatingPayload,
|
||||||
|
);
|
||||||
|
// Inserts cashflow owner contribution transaction.
|
||||||
|
const cashflowTransaction = await this.bankTransactionModel
|
||||||
|
.query(trx)
|
||||||
|
.upsertGraph(cashflowTransactionObj);
|
||||||
|
|
||||||
|
// Triggers `onCashflowTransactionCreated` event.
|
||||||
|
await this.eventPublisher.emitAsync(
|
||||||
|
events.cashflow.onTransactionCreated,
|
||||||
|
{
|
||||||
|
newTransactionDTO,
|
||||||
|
cashflowTransaction,
|
||||||
|
trx,
|
||||||
|
} as ICommandCashflowCreatedPayload,
|
||||||
|
);
|
||||||
|
return cashflowTransaction;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user