feat(nestjs): migrate to NestJS

This commit is contained in:
Ahmed Bouhuolia
2025-04-07 11:51:24 +02:00
parent f068218a16
commit 55fcc908ef
3779 changed files with 631 additions and 195332 deletions

View File

@@ -0,0 +1,69 @@
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 {
IPlaidItemCreatedEventPayload,
PlaidItemDTO,
} from '../types/BankingPlaid.types';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class PlaidItemService {
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly tenancyContext: TenancyContext,
@Inject(SystemPlaidItem.name)
private readonly systemPlaidItemModel: TenantModelProxy<
typeof SystemPlaidItem
>,
@Inject(PlaidItem.name)
private readonly plaidItemModel: TenantModelProxy<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,240 @@
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';
import { TenantModelProxy } from '../../System/models/TenantBaseModel';
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: TenantModelProxy<typeof Account>,
@Inject(PlaidItemModel.name)
private readonly plaidItemModel: TenantModelProxy<typeof PlaidItemModel>,
@Inject(UncategorizedBankTransaction.name)
private readonly uncategorizedBankTransactionModel: TenantModelProxy<
typeof UncategorizedBankTransaction
>,
) {}
/**
* Syncs the Plaid bank account.
* @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 {PlaidAccount[]} plaidAccounts
* @returns {Promise<void>}
*/
public async syncBankAccounts(
plaidAccounts: PlaidAccountBase[],
institution: PlaidInstitution,
item: PlaidItem,
trx?: Knex.Transaction,
): Promise<void> {
const transformToPlaidAccounts = R.curry(
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} 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 = R.curry(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 {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 {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 {string[]} plaidAccountIds - Plaid accounts ids.
* @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[]} plaidAccountIds - Plaid accounts ids.
* @param {boolean} isFeedsActive - Feeds active status.
* @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,152 @@
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';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class PlaidUpdateTransactions {
constructor(
private readonly plaidSync: PlaidSyncDb,
private readonly uow: UnitOfWork,
@Inject(PlaidItem.name)
private readonly plaidItemModel: TenantModelProxy<typeof PlaidItem>,
@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', webhookCode, plaidItemId);
break;
case 'ERROR': {
break;
}
case 'PENDING_EXPIRATION': {
break;
}
default:
this.serverLogAndEmitSocket(
'unhandled webhook type received.',
webhookCode,
plaidItemId,
);
}
}
}