refactor: banking services to Nestjs

This commit is contained in:
Ahmed Bouhuolia
2025-01-05 16:26:23 +02:00
parent b72f85b394
commit 1869ba216f
150 changed files with 9698 additions and 163 deletions

View File

@@ -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.

View File

@@ -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: [

View File

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

View File

@@ -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 {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',
},
},
};
}
}

View File

@@ -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 [];
}
}

View File

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

View File

@@ -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 [];
};
}

View File

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

View File

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

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

View File

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

View File

@@ -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 {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;

View File

@@ -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';
}
}

View File

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

View File

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

View File

@@ -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 {}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {};
}
}

View File

@@ -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';
}
}

View File

@@ -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';
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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',
};

View File

@@ -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 {}

View File

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

View File

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

View File

@@ -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();
// };

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.

View File

@@ -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',
},
},
};
}
}

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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 {}

View File

@@ -0,0 +1,11 @@
export interface RevertRecognizedTransactionsCriteria {
batch?: string;
accountId?: number;
}
export interface RecognizeTransactionsCriteria {
batch?: string;
accountId?: number;
}

View File

@@ -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';
}
};

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',
},
},
};
}
}

View File

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

View File

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

View File

@@ -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 {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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