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

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