mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-18 05:40:31 +00:00
refactor: banking services to Nestjs
This commit is contained in:
@@ -12,6 +12,7 @@ import { TenantModel } from '@/modules/System/models/TenantModel';
|
||||
// import { ModelSettings } from '@/modules/Settings/ModelSettings';
|
||||
import { AccountTypesUtils } from '@/libs/accounts-utils/AccountTypesUtils';
|
||||
import { Model } from 'objection';
|
||||
import { PlaidItem } from '@/modules/BankingPlaid/models/PlaidItem';
|
||||
// import AccountSettings from './Account.Settings';
|
||||
// import { DEFAULT_VIEWS } from '@/modules/Accounts/constants';
|
||||
// import { buildFilterQuery, buildSortColumnQuery } from '@/lib/ViewRolesBuilder';
|
||||
@@ -25,17 +26,20 @@ import { Model } from 'objection';
|
||||
// ]) {
|
||||
|
||||
export class Account extends TenantModel {
|
||||
name: string;
|
||||
slug: string;
|
||||
code: string;
|
||||
index: number;
|
||||
accountType: string;
|
||||
predefined: boolean;
|
||||
currencyCode: string;
|
||||
active: boolean;
|
||||
bankBalance: number;
|
||||
lastFeedsUpdatedAt: string | null;
|
||||
amount: number;
|
||||
public name!: string;
|
||||
public slug!: string;
|
||||
public code!: string;
|
||||
public index!: number;
|
||||
public accountType!: string;
|
||||
public predefined!: boolean;
|
||||
public currencyCode!: string;
|
||||
public active!: boolean;
|
||||
public bankBalance!: number;
|
||||
public lastFeedsUpdatedAt!: string | null;
|
||||
public amount!: number;
|
||||
public plaidItemId!: number;
|
||||
|
||||
public plaidItem!: PlaidItem;
|
||||
|
||||
/**
|
||||
* Table name.
|
||||
|
||||
@@ -54,6 +54,8 @@ import { CreditNoteRefundsModule } from '../CreditNoteRefunds/CreditNoteRefunds.
|
||||
import { BillPaymentsModule } from '../BillPayments/BillPayments.module';
|
||||
import { PaymentsReceivedModule } from '../PaymentReceived/PaymentsReceived.module';
|
||||
import { LedgerModule } from '../Ledger/Ledger.module';
|
||||
import { BankRulesModule } from '../BankRules/BankRules.module';
|
||||
import { BankAccountsModule } from '../BankingAccounts/BankAccounts.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -132,6 +134,9 @@ import { LedgerModule } from '../Ledger/Ledger.module';
|
||||
BillPaymentsModule,
|
||||
PaymentsReceivedModule,
|
||||
LedgerModule,
|
||||
|
||||
BankAccountsModule,
|
||||
BankRulesModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
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 {
|
||||
pausedAt: Date;
|
||||
plaidAccessToken: string;
|
||||
lastCursor?: string;
|
||||
tenantId: number;
|
||||
plaidItemId: string;
|
||||
plaidInstitutionId: string;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Knex } from 'knex';
|
||||
import { ERRORS } from '../constants';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { BankTransaction } from '../models/BankTransaction';
|
||||
import { BankTransactionLine } from '../models/BankTransactionLine';
|
||||
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||
import { events } from '@/common/events/events';
|
||||
import {
|
||||
ICommandCashflowDeletedPayload,
|
||||
ICommandCashflowDeletingPayload,
|
||||
} from '../types/BankingTransactions.types';
|
||||
|
||||
@Injectable()
|
||||
export class DeleteCashflowTransaction {
|
||||
constructor(
|
||||
private readonly uow: UnitOfWork,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
|
||||
@Inject(BankTransaction.name)
|
||||
private readonly bankTransaction: typeof BankTransaction,
|
||||
|
||||
@Inject(BankTransactionLine.name)
|
||||
private readonly bankTransactionLine: typeof BankTransactionLine,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Deletes the cashflow transaction with associated journal entries.
|
||||
* @param {number} tenantId -
|
||||
* @param {number} userId - User id.
|
||||
*/
|
||||
public deleteCashflowTransaction = async (
|
||||
cashflowTransactionId: number,
|
||||
trx?: Knex.Transaction,
|
||||
): Promise<BankTransaction> => {
|
||||
// Retrieve the cashflow transaction.
|
||||
const oldCashflowTransaction = await this.bankTransaction
|
||||
.query()
|
||||
.findById(cashflowTransactionId);
|
||||
// Throw not found error if the given transaction id not found.
|
||||
this.throwErrorIfTransactionNotFound(oldCashflowTransaction);
|
||||
|
||||
// Starting database transaction.
|
||||
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||
// Triggers `onCashflowTransactionDelete` event.
|
||||
await this.eventEmitter.emitAsync(events.cashflow.onTransactionDeleting, {
|
||||
trx,
|
||||
oldCashflowTransaction,
|
||||
} as ICommandCashflowDeletingPayload);
|
||||
|
||||
// Delete cashflow transaction associated lines first.
|
||||
await this.bankTransactionLine
|
||||
.query(trx)
|
||||
.where('cashflow_transaction_id', cashflowTransactionId)
|
||||
.delete();
|
||||
|
||||
// Delete cashflow transaction.
|
||||
await this.bankTransaction
|
||||
.query(trx)
|
||||
.findById(cashflowTransactionId)
|
||||
.delete();
|
||||
|
||||
// Triggers `onCashflowTransactionDeleted` event.
|
||||
await this.eventEmitter.emitAsync(events.cashflow.onTransactionDeleted, {
|
||||
trx,
|
||||
cashflowTransactionId,
|
||||
oldCashflowTransaction,
|
||||
} as ICommandCashflowDeletedPayload);
|
||||
|
||||
return oldCashflowTransaction;
|
||||
}, trx);
|
||||
};
|
||||
|
||||
/**
|
||||
* Throw not found error if the given transaction id not found.
|
||||
* @param transaction
|
||||
*/
|
||||
private throwErrorIfTransactionNotFound(transaction) {
|
||||
if (!transaction) {
|
||||
throw new ServiceError(ERRORS.CASHFLOW_TRANSACTION_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Knex } from 'knex';
|
||||
import { ERRORS } from '../constants';
|
||||
import {
|
||||
IPendingTransactionRemovedEventPayload,
|
||||
IPendingTransactionRemovingEventPayload,
|
||||
} from '../types/BankingTransactions.types';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { UncategorizedBankTransaction } from '../models/UncategorizedBankTransaction';
|
||||
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||
import { events } from '@/common/events/events';
|
||||
|
||||
@Injectable()
|
||||
export class RemovePendingUncategorizedTransaction {
|
||||
constructor(
|
||||
private readonly eventPublisher: EventEmitter2,
|
||||
private readonly uow: UnitOfWork,
|
||||
|
||||
@Inject(UncategorizedBankTransaction.name)
|
||||
private readonly uncategorizedBankTransaction: typeof UncategorizedBankTransaction,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* REmoves the pending uncategorized transaction.
|
||||
* @param {number} uncategorizedTransactionId -
|
||||
* @param {Knex.Transaction} trx -
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public async removePendingTransaction(
|
||||
uncategorizedTransactionId: number,
|
||||
trx?: Knex.Transaction,
|
||||
): Promise<void> {
|
||||
const pendingTransaction = await this.uncategorizedBankTransaction
|
||||
.query(trx)
|
||||
.findById(uncategorizedTransactionId)
|
||||
.throwIfNotFound();
|
||||
|
||||
if (!pendingTransaction.isPending) {
|
||||
throw new ServiceError(ERRORS.TRANSACTION_NOT_PENDING);
|
||||
}
|
||||
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.bankTransactions.onPendingRemoving,
|
||||
{
|
||||
uncategorizedTransactionId,
|
||||
pendingTransaction,
|
||||
trx,
|
||||
} as IPendingTransactionRemovingEventPayload,
|
||||
);
|
||||
// Removes the pending uncategorized transaction.
|
||||
await this.uncategorizedBankTransaction
|
||||
.query(trx)
|
||||
.findById(uncategorizedTransactionId)
|
||||
.delete();
|
||||
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.bankTransactions.onPendingRemoved,
|
||||
{
|
||||
uncategorizedTransactionId,
|
||||
pendingTransaction,
|
||||
trx,
|
||||
} as IPendingTransactionRemovedEventPayload,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user