feat: Pending bank transactions

This commit is contained in:
Ahmed Bouhuolia
2024-08-11 16:14:13 +02:00
parent c7c021c969
commit 9ae5644af9
20 changed files with 385 additions and 52 deletions

View File

@@ -1,9 +1,10 @@
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import { NextFunction, Request, Response, Router } from 'express'; import { NextFunction, Request, Response, Router } from 'express';
import { param, query } from 'express-validator';
import BaseController from '@/api/controllers/BaseController'; import BaseController from '@/api/controllers/BaseController';
import { GetBankAccountSummary } from '@/services/Banking/BankAccounts/GetBankAccountSummary'; import { GetBankAccountSummary } from '@/services/Banking/BankAccounts/GetBankAccountSummary';
import { BankAccountsApplication } from '@/services/Banking/BankAccounts/BankAccountsApplication'; import { BankAccountsApplication } from '@/services/Banking/BankAccounts/BankAccountsApplication';
import { param } from 'express-validator'; import { GetPendingBankAccountTransactions } from '@/services/Cashflow/GetPendingBankAccountTransaction';
@Service() @Service()
export class BankAccountsController extends BaseController { export class BankAccountsController extends BaseController {
@@ -13,6 +14,9 @@ export class BankAccountsController extends BaseController {
@Inject() @Inject()
private bankAccountsApp: BankAccountsApplication; private bankAccountsApp: BankAccountsApplication;
@Inject()
private getPendingTransactionsService: GetPendingBankAccountTransactions;
/** /**
* Router constructor. * Router constructor.
*/ */
@@ -20,6 +24,16 @@ export class BankAccountsController extends BaseController {
const router = Router(); const router = Router();
router.get('/:bankAccountId/meta', this.getBankAccountSummary.bind(this)); router.get('/:bankAccountId/meta', this.getBankAccountSummary.bind(this));
router.get(
'/pending_transactions',
[
query('account_id').optional().isNumeric().toInt(),
query('page').optional().isNumeric().toInt(),
query('page_size').optional().isNumeric().toInt(),
],
this.validationResult,
this.getBankAccountsPendingTransactions.bind(this)
);
router.post( router.post(
'/:bankAccountId/disconnect', '/:bankAccountId/disconnect',
this.disconnectBankAccount.bind(this) this.disconnectBankAccount.bind(this)
@@ -27,17 +41,13 @@ export class BankAccountsController extends BaseController {
router.post('/:bankAccountId/update', this.refreshBankAccount.bind(this)); router.post('/:bankAccountId/update', this.refreshBankAccount.bind(this));
router.post( router.post(
'/:bankAccountId/pause_feeds', '/:bankAccountId/pause_feeds',
[ [param('bankAccountId').exists().isNumeric().toInt()],
param('bankAccountId').exists().isNumeric().toInt(),
],
this.validationResult, this.validationResult,
this.pauseBankAccountFeeds.bind(this) this.pauseBankAccountFeeds.bind(this)
); );
router.post( router.post(
'/:bankAccountId/resume_feeds', '/:bankAccountId/resume_feeds',
[ [param('bankAccountId').exists().isNumeric().toInt()],
param('bankAccountId').exists().isNumeric().toInt(),
],
this.validationResult, this.validationResult,
this.resumeBankAccountFeeds.bind(this) this.resumeBankAccountFeeds.bind(this)
); );
@@ -72,6 +82,30 @@ export class BankAccountsController extends BaseController {
} }
} }
/**
* Retrieves the bank account pending transactions.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
async getBankAccountsPendingTransactions(
req: Request,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
try {
const data =
await this.getPendingTransactionsService.getPendingTransactions(
tenantId
);
return res.status(200).send(data);
} catch (error) {
next(error);
}
}
/** /**
* Disonnect the given bank account. * Disonnect the given bank account.
* @param {Request} req * @param {Request} req

View File

@@ -0,0 +1,13 @@
exports.up = function (knex) {
return knex.schema.table('uncategorized_cashflow_transactions', (table) => {
table.boolean('pending').defaultTo(false);
table.string('pending_plaid_transaction_id').nullable();
});
};
exports.down = function (knex) {
return knex.schema.table('uncategorized_cashflow_transactions', (table) => {
table.dropColumn('pending');
table.dropColumn('pending_plaid_transaction_id');
});
};

View File

@@ -268,6 +268,8 @@ export interface CreateUncategorizedTransactionDTO {
description?: string; description?: string;
referenceNo?: string | null; referenceNo?: string | null;
plaidTransactionId?: string | null; plaidTransactionId?: string | null;
pending?: boolean;
pendingPlaidTransactionId?: string | null;
batch?: string; batch?: string;
} }

View File

@@ -21,6 +21,7 @@ export default class UncategorizedCashflowTransaction extends mixin(
plaidTransactionId!: string; plaidTransactionId!: string;
recognizedTransactionId!: number; recognizedTransactionId!: number;
excludedAt: Date; excludedAt: Date;
pending: boolean;
/** /**
* Table name. * Table name.
@@ -46,7 +47,8 @@ export default class UncategorizedCashflowTransaction extends mixin(
'isDepositTransaction', 'isDepositTransaction',
'isWithdrawalTransaction', 'isWithdrawalTransaction',
'isRecognized', 'isRecognized',
'isExcluded' 'isExcluded',
'isPending',
]; ];
} }
@@ -99,6 +101,14 @@ export default class UncategorizedCashflowTransaction extends mixin(
return !!this.excludedAt; return !!this.excludedAt;
} }
/**
* Detarmines whether the transaction is pending.
* @returns {boolean}
*/
public get isPending(): boolean {
return !!this.pending;
}
/** /**
* Model modifiers. * Model modifiers.
*/ */
@@ -143,6 +153,20 @@ export default class UncategorizedCashflowTransaction extends mixin(
query.whereNull('categorizeRefType'); query.whereNull('categorizeRefType');
query.whereNull('categorizeRefId'); query.whereNull('categorizeRefId');
}, },
/**
* Filters the not pending transactions.
*/
notPending(query) {
query.where('pending', false);
},
/**
* Filters the pending transactions.
*/
pending(query) {
query.where('pending', true);
},
}; };
} }

View File

@@ -69,12 +69,22 @@ export class GetBankAccountSummary {
q.count('uncategorized_cashflow_transactions.id as total'); q.count('uncategorized_cashflow_transactions.id as total');
q.first(); q.first();
}); });
// Retrieves excluded transactions count.
const excludedTransactionsCount = const excludedTransactionsCount =
await UncategorizedCashflowTransaction.query().onBuild((q) => { await UncategorizedCashflowTransaction.query().onBuild((q) => {
q.where('accountId', bankAccountId); q.where('accountId', bankAccountId);
q.modify('excluded'); q.modify('excluded');
// Count the results.
q.count('uncategorized_cashflow_transactions.id as total');
q.first();
});
// Retrieves the pending transactions count.
const pendingTransactionsCount =
await UncategorizedCashflowTransaction.query().onBuild((q) => {
q.where('accountId', bankAccountId);
q.modify('pending');
// Count the results. // Count the results.
q.count('uncategorized_cashflow_transactions.id as total'); q.count('uncategorized_cashflow_transactions.id as total');
q.first(); q.first();
@@ -83,14 +93,15 @@ export class GetBankAccountSummary {
const totalUncategorizedTransactions = const totalUncategorizedTransactions =
uncategorizedTranasctionsCount?.total || 0; uncategorizedTranasctionsCount?.total || 0;
const totalRecognizedTransactions = recognizedTransactionsCount?.total || 0; const totalRecognizedTransactions = recognizedTransactionsCount?.total || 0;
const totalExcludedTransactions = excludedTransactionsCount?.total || 0; const totalExcludedTransactions = excludedTransactionsCount?.total || 0;
const totalPendingTransactions = pendingTransactionsCount?.total || 0;
return { return {
name: bankAccount.name, name: bankAccount.name,
totalUncategorizedTransactions, totalUncategorizedTransactions,
totalRecognizedTransactions, totalRecognizedTransactions,
totalExcludedTransactions, totalExcludedTransactions,
totalPendingTransactions,
}; };
} }
} }

View File

@@ -25,6 +25,7 @@ import { Knex } from 'knex';
import uniqid from 'uniqid'; import uniqid from 'uniqid';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events'; import events from '@/subscribers/events';
import { RemovePendingUncategorizedTransaction } from '@/services/Cashflow/RemovePendingUncategorizedTransaction';
const CONCURRENCY_ASYNC = 10; const CONCURRENCY_ASYNC = 10;
@@ -40,7 +41,7 @@ export class PlaidSyncDb {
private cashflowApp: CashflowApplication; private cashflowApp: CashflowApplication;
@Inject() @Inject()
private deleteCashflowTransactionService: DeleteCashflowTransaction; private removePendingTransaction: RemovePendingUncategorizedTransaction;
@Inject() @Inject()
private eventPublisher: EventPublisher; private eventPublisher: EventPublisher;
@@ -185,21 +186,22 @@ export class PlaidSyncDb {
plaidTransactionsIds: string[], plaidTransactionsIds: string[],
trx?: Knex.Transaction trx?: Knex.Transaction
) { ) {
const { CashflowTransaction } = this.tenancy.models(tenantId); const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const cashflowTransactions = await CashflowTransaction.query(trx).whereIn( const uncategorizedTransactions =
'plaidTransactionId', await UncategorizedCashflowTransaction.query(trx).whereIn(
plaidTransactionsIds 'plaidTransactionId',
); plaidTransactionsIds
const cashflowTransactionsIds = cashflowTransactions.map( );
const uncategorizedTransactionsIds = uncategorizedTransactions.map(
(trans) => trans.id (trans) => trans.id
); );
await bluebird.map( await bluebird.map(
cashflowTransactionsIds, uncategorizedTransactionsIds,
(transactionId: number) => (uncategorizedTransactionId: number) =>
this.deleteCashflowTransactionService.deleteCashflowTransaction( this.removePendingTransaction.removePendingTransaction(
tenantId, tenantId,
transactionId, uncategorizedTransactionId,
trx trx
), ),
{ concurrency: CONCURRENCY_ASYNC } { concurrency: CONCURRENCY_ASYNC }

View File

@@ -73,6 +73,12 @@ export class PlaidUpdateTransactions {
item, item,
trx trx
); );
// Sync removed transactions.
await this.plaidSync.syncRemoveTransactions(
tenantId,
removed?.map((r) => r.transaction_id),
trx
);
// Sync bank account transactions. // Sync bank account transactions.
await this.plaidSync.syncAccountsTransactions( await this.plaidSync.syncAccountsTransactions(
tenantId, tenantId,

View File

@@ -3,11 +3,11 @@ import {
Item as PlaidItem, Item as PlaidItem,
Institution as PlaidInstitution, Institution as PlaidInstitution,
AccountBase as PlaidAccount, AccountBase as PlaidAccount,
TransactionBase as PlaidTransactionBase,
} from 'plaid'; } from 'plaid';
import { import {
CreateUncategorizedTransactionDTO, CreateUncategorizedTransactionDTO,
IAccountCreateDTO, IAccountCreateDTO,
PlaidTransaction,
} from '@/interfaces'; } from '@/interfaces';
/** /**
@@ -48,7 +48,7 @@ export const transformPlaidAccountToCreateAccount = R.curry(
export const transformPlaidTrxsToCashflowCreate = R.curry( export const transformPlaidTrxsToCashflowCreate = R.curry(
( (
cashflowAccountId: number, cashflowAccountId: number,
plaidTranasction: PlaidTransaction plaidTranasction: PlaidTransactionBase
): CreateUncategorizedTransactionDTO => { ): CreateUncategorizedTransactionDTO => {
return { return {
date: plaidTranasction.date, date: plaidTranasction.date,
@@ -64,6 +64,8 @@ export const transformPlaidTrxsToCashflowCreate = R.curry(
accountId: cashflowAccountId, accountId: cashflowAccountId,
referenceNo: plaidTranasction.payment_meta?.reference_number, referenceNo: plaidTranasction.payment_meta?.reference_number,
plaidTransactionId: plaidTranasction.transaction_id, plaidTransactionId: plaidTranasction.transaction_id,
pending: plaidTranasction.pending,
pendingPlaidTransactionId: plaidTranasction.pending_transaction_id,
}; };
} }
); );

View File

@@ -0,0 +1,53 @@
import { Inject } from 'typedi';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import HasTenancyService from '../Tenancy/TenancyService';
import { GetPendingBankAccountTransactionTransformer } from './GetPendingBankAccountTransactionTransformer';
export class GetPendingBankAccountTransactions {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private transformer: TransformerInjectable;
/**
* Retrieves the given bank accounts pending transaction.
* @param {number} tenantId
* @param {number} bankAccountId
*/
async getPendingTransactions(
tenantId: number,
filter?: GetPendingTransactionsQuery
) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const _filter = {
page: 1,
pageSize: 20,
...filter,
};
const { results, pagination } =
await UncategorizedCashflowTransaction.query()
.onBuild((q) => {
q.modify('pending');
if (_filter?.accountId) {
q.where('accountId', _filter.accountId);
}
})
.pagination(_filter.page - 1, _filter.pageSize);
const data = this.transformer.transform(
tenantId,
results,
new GetPendingBankAccountTransactionTransformer()
);
return { data, pagination };
}
}
interface GetPendingTransactionsQuery {
page?: number;
pageSize?: number;
accountId?: number;
}

View File

@@ -0,0 +1,73 @@
import { Transformer } from '@/lib/Transformer/Transformer';
import { formatNumber } from '@/utils';
export class GetPendingBankAccountTransactionTransformer extends Transformer {
/**
* Include these attributes to sale invoice object.
* @returns {string[]}
*/
public includeAttributes = (): string[] => {
return [
'formattedAmount',
'formattedDate',
'formattedDepositAmount',
'formattedWithdrawalAmount',
];
};
/**
* Exclude all attributes.
* @returns {Array<string>}
*/
public excludeAttributes = (): string[] => {
return [];
};
/**
* 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 formatNumber(transaction.amount, {
currencyCode: transaction.currencyCode,
});
}
/**
* Formatted deposit amount.
* @param transaction
* @returns {string}
*/
protected formattedDepositAmount(transaction) {
if (transaction.isDepositTransaction) {
return formatNumber(transaction.deposit, {
currencyCode: transaction.currencyCode,
});
}
return '';
}
/**
* Formatted withdrawal amount.
* @param transaction
* @returns {string}
*/
protected formattedWithdrawalAmount(transaction) {
if (transaction.isWithdrawalTransaction) {
return formatNumber(transaction.withdrawal, {
currencyCode: transaction.currencyCode,
});
}
return '';
}
}

View File

@@ -0,0 +1,58 @@
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import UnitOfWork from '../UnitOfWork';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
import { ServiceError } from '@/exceptions';
import { ERRORS } from './constants';
@Service()
export class RemovePendingUncategorizedTransaction {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private uow: UnitOfWork;
@Inject()
private eventPublisher: EventPublisher;
/**
* REmoves the pending uncategorized transaction.
* @param {number} tenantId -
* @param {number} uncategorizedTransactionId -
* @param {Knex.Transaction} trx -
* @returns {Promise<void>}
*/
public async removePendingTransaction(
tenantId: number,
uncategorizedTransactionId: number,
trx?: Knex.Transaction
): Promise<void> {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const pendingTransaction = await UncategorizedCashflowTransaction.query(trx)
.findById(uncategorizedTransactionId)
.throwIfNotFound();
if (!pendingTransaction.isPending) {
throw new ServiceError(ERRORS.TRANSACTION_NOT_PENDING);
}
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
await this.eventPublisher.emitAsync(
events.bankTransactions.onPendingRemoving,
{ tenantId, uncategorizedTransactionId, trx }
);
// Removes the pending uncategorized transaction.
await UncategorizedCashflowTransaction.query(trx)
.findById(uncategorizedTransactionId)
.delete();
await this.eventPublisher.emitAsync(
events.bankTransactions.onPendingRemoved,
{ tenantId, uncategorizedTransactionId, trx }
);
});
}
}

View File

@@ -15,10 +15,10 @@ export const ERRORS = {
'UNCATEGORIZED_TRANSACTION_TYPE_INVALID', 'UNCATEGORIZED_TRANSACTION_TYPE_INVALID',
CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED: CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED:
'CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED', 'CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED',
CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION:
CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION: 'CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION', 'CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION',
TRANSACTION_NOT_CATEGORIZED: 'TRANSACTION_NOT_CATEGORIZED' TRANSACTION_NOT_CATEGORIZED: 'TRANSACTION_NOT_CATEGORIZED',
TRANSACTION_NOT_PENDING: 'TRANSACTION_NOT_PENDING',
}; };
export enum CASHFLOW_DIRECTION { export enum CASHFLOW_DIRECTION {

View File

@@ -659,6 +659,9 @@ export default {
onUnexcluding: 'onBankTransactionUnexcluding', onUnexcluding: 'onBankTransactionUnexcluding',
onUnexcluded: 'onBankTransactionUnexcluded', onUnexcluded: 'onBankTransactionUnexcluded',
onPendingRemoving: 'onBankTransactionPendingRemoving',
onPendingRemoved: 'onBankTransactionPendingRemoved',
}, },
bankAccount: { bankAccount: {

View File

@@ -7,4 +7,5 @@ export const BANK_QUERY_KEY = {
'RECOGNIZED_BANK_TRANSACTIONS_INFINITY', 'RECOGNIZED_BANK_TRANSACTIONS_INFINITY',
BANK_ACCOUNT_SUMMARY_META: 'BANK_ACCOUNT_SUMMARY_META', BANK_ACCOUNT_SUMMARY_META: 'BANK_ACCOUNT_SUMMARY_META',
AUTOFILL_CATEGORIZE_BANK_TRANSACTION: 'AUTOFILL_CATEGORIZE_BANK_TRANSACTION', AUTOFILL_CATEGORIZE_BANK_TRANSACTION: 'AUTOFILL_CATEGORIZE_BANK_TRANSACTION',
PENDING_BANK_ACCOUNT_TRANSACTIONS: 'PENDING_BANK_ACCOUNT_TRANSACTIONS'
}; };

View File

@@ -20,7 +20,8 @@ export function AccountTransactionsFilterTabs() {
const hasUncategorizedTransx = useMemo( const hasUncategorizedTransx = useMemo(
() => () =>
bankAccountMetaSummary?.totalUncategorizedTransactions > 0 || bankAccountMetaSummary?.totalUncategorizedTransactions > 0 ||
bankAccountMetaSummary?.totalExcludedTransactions > 0, bankAccountMetaSummary?.totalExcludedTransactions > 0 ||
bankAccountMetaSummary?.totalPendingTransactions > 0,
[bankAccountMetaSummary], [bankAccountMetaSummary],
); );

View File

@@ -1,4 +1,6 @@
// @ts-nocheck // @ts-nocheck
import * as R from 'ramda';
import { useMemo } from 'react';
import { useAppQueryString } from '@/hooks'; import { useAppQueryString } from '@/hooks';
import { Group } from '@/components'; import { Group } from '@/components';
import { useAccountTransactionsContext } from './AccountTransactionsProvider'; import { useAccountTransactionsContext } from './AccountTransactionsProvider';
@@ -12,31 +14,49 @@ export function AccountTransactionsUncategorizeFilter() {
bankAccountMetaSummary?.totalUncategorizedTransactions; bankAccountMetaSummary?.totalUncategorizedTransactions;
const totalRecognized = bankAccountMetaSummary?.totalRecognizedTransactions; const totalRecognized = bankAccountMetaSummary?.totalRecognizedTransactions;
const totalPending = bankAccountMetaSummary?.totalPendingTransactions;
const handleTabsChange = (value) => { const handleTabsChange = (value) => {
setLocationQuery({ uncategorizedFilter: value }); setLocationQuery({ uncategorizedFilter: value });
}; };
const options = useMemo(
() =>
R.when(
() => totalPending > 0,
R.append({
value: 'pending',
label: (
<>
Pending <strong>({totalPending})</strong>
</>
),
}),
)([
{
value: 'all',
label: (
<>
All <strong>({totalUncategorized})</strong>
</>
),
},
{
value: 'recognized',
label: (
<>
Recognized <strong>({totalRecognized})</strong>
</>
),
},
]),
[totalPending, totalRecognized, totalUncategorized],
);
return ( return (
<Group position={'apart'}> <Group position={'apart'}>
<TagsControl <TagsControl
options={[ options={options}
{
value: 'all',
label: (
<>
All <strong>({totalUncategorized})</strong>
</>
),
},
{
value: 'recognized',
label: (
<>
Recognized <strong>({totalRecognized})</strong>
</>
),
},
]}
value={locationQuery?.uncategorizedFilter || 'all'} value={locationQuery?.uncategorizedFilter || 'all'}
onValueChange={handleTabsChange} onValueChange={handleTabsChange}
/> />

View File

@@ -70,6 +70,8 @@ function AccountTransactionsSwitcher() {
case 'all': case 'all':
default: default:
return <AccountUncategorizedTransactions />; return <AccountUncategorizedTransactions />;
case 'pending':
return null;
} }
} }

View File

@@ -0,0 +1,28 @@
// @ts-nocheck
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import useApiRequest from '../useRequest';
import { BANK_QUERY_KEY } from '@/constants/query-keys/banking';
interface GetBankRuleRes {}
/**
* Retrieve the given bank rule.
* @param {number} bankRuleId -
* @param {UseQueryOptions<GetBankRuleRes, Error>} options -
* @returns {UseQueryResult<GetBankRuleRes, Error>}
*/
export function usePendingBankAccountTransactions(
bankRuleId: number,
options?: UseQueryOptions<GetBankRuleRes, Error>,
): UseQueryResult<GetBankRuleRes, Error> {
const apiRequest = useApiRequest();
return useQuery<GetBankRuleRes, Error>(
[BANK_QUERY_KEY.PENDING_BANK_ACCOUNT_TRANSACTIONS],
() =>
apiRequest
.get(`/banking/bank_account/pending_transactions`)
.then((res) => res.data),
{ ...options },
);
}