mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-17 21:30:31 +00:00
feat: disconnect and update bank account
This commit is contained in:
@@ -22,8 +22,9 @@ export class BankAccountsController extends BaseController {
|
||||
router.get('/:bankAccountId/meta', this.getBankAccountSummary.bind(this));
|
||||
router.post(
|
||||
'/:bankAccountId/disconnect',
|
||||
this.discountBankAccount.bind(this)
|
||||
this.disconnectBankAccount.bind(this)
|
||||
);
|
||||
router.post('/:bankAccountId/update', this.refreshBankAccount.bind(this));
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -81,4 +82,31 @@ export class BankAccountsController extends BaseController {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the given bank account.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
* @returns {Promise<Response|null>}
|
||||
*/
|
||||
async refreshBankAccount(
|
||||
req: Request<{ bankAccountId: number }>,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const { bankAccountId } = req.params;
|
||||
const { tenantId } = req;
|
||||
|
||||
try {
|
||||
await this.bankAccountsApp.refreshBankAccount(tenantId, bankAccountId);
|
||||
|
||||
return res.status(200).send({
|
||||
id: bankAccountId,
|
||||
message: 'The bank account has been disconnected.',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.table('accounts', (table) => {
|
||||
table.string('plaid_item_id').nullable();
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.table('accounts', (table) => {
|
||||
table.dropColumn('plaid_item_id');
|
||||
});
|
||||
};
|
||||
@@ -52,6 +52,7 @@ const noAccessTokenLogger = async (
|
||||
|
||||
// Plaid client methods used in this app, mapped to their appropriate logging functions.
|
||||
const clientMethodLoggingFns = {
|
||||
transactionsRefresh: defaultLogger,
|
||||
accountsGet: defaultLogger,
|
||||
institutionsGet: noAccessTokenLogger,
|
||||
institutionsGetById: noAccessTokenLogger,
|
||||
|
||||
@@ -197,6 +197,7 @@ export default class Account extends mixin(TenantModel, [
|
||||
const ExpenseEntry = require('models/ExpenseCategory');
|
||||
const ItemEntry = require('models/ItemEntry');
|
||||
const UncategorizedTransaction = require('models/UncategorizedCashflowTransaction');
|
||||
const PlaidItem = require('models/PlaidItem');
|
||||
|
||||
return {
|
||||
/**
|
||||
@@ -321,6 +322,18 @@ export default class Account extends mixin(TenantModel, [
|
||||
query.where('categorized', false);
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Account model may belongs to a Plaid item.
|
||||
*/
|
||||
plaidItem: {
|
||||
relation: Model.BelongsToOneRelation,
|
||||
modelClass: PlaidItem.default,
|
||||
join: {
|
||||
from: 'accounts.plaidItemId',
|
||||
to: 'plaid_items.id',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { DisconnectBankAccount } from './DisconnectBankAccount';
|
||||
import { RefreshBankAccountService } from './RefreshBankAccount';
|
||||
|
||||
@Service()
|
||||
export class BankAccountsApplication {
|
||||
@Inject()
|
||||
private disconnectBankAccountService: DisconnectBankAccount;
|
||||
|
||||
@Inject()
|
||||
private refreshBankAccountService: RefreshBankAccountService;
|
||||
|
||||
/**
|
||||
* Disconnects the given bank account.
|
||||
* @param {number} tenantId
|
||||
@@ -18,4 +22,17 @@ export class BankAccountsApplication {
|
||||
bankAccountId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the bank transactions of the given bank account.
|
||||
* @param {number} tenantId
|
||||
* @param {number} bankAccountId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async refreshBankAccount(tenantId: number, bankAccountId: number) {
|
||||
return this.refreshBankAccountService.refreshBankAccount(
|
||||
tenantId,
|
||||
bankAccountId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PlaidClientWrapper } from '@/lib/Plaid';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import UnitOfWork from '@/services/UnitOfWork';
|
||||
import events from '@/subscribers/events';
|
||||
import { ERRORS } from './types';
|
||||
|
||||
@Service()
|
||||
export class DisconnectBankAccount {
|
||||
@@ -20,45 +21,46 @@ export class DisconnectBankAccount {
|
||||
|
||||
/**
|
||||
* Disconnects the given bank account.
|
||||
* @param {number} tenantId
|
||||
* @param {number} bankAccountId
|
||||
* @param {number} tenantId
|
||||
* @param {number} bankAccountId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async disconnectBankAccount(tenantId: number, bankAccountId: number) {
|
||||
const { Account, PlaidItem } = this.tenancy.models(tenantId);
|
||||
|
||||
// Retrieve the bank account or throw not found error.
|
||||
const account = await Account.query()
|
||||
.findById(bankAccountId)
|
||||
.where('type', ['bank'])
|
||||
.whereIn('account_type', ['bank', 'cash'])
|
||||
.throwIfNotFound();
|
||||
|
||||
const plaidItem = await PlaidItem.query().findById(account.plaidAccountId);
|
||||
const oldPlaidItem = await PlaidItem.query().findById(account.plaidItemId);
|
||||
|
||||
if (!plaidItem) {
|
||||
if (!oldPlaidItem) {
|
||||
throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED);
|
||||
}
|
||||
const request = {
|
||||
accessToken: plaidItem.plaidAccessToken,
|
||||
};
|
||||
const plaidInstance = new PlaidClientWrapper();
|
||||
|
||||
//
|
||||
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
|
||||
// Triggers `onBankAccountDisconnecting` event.
|
||||
await this.eventPublisher.emitAsync(events.bankAccount.onDisconnecting, {
|
||||
tenantId,
|
||||
bankAccountId,
|
||||
});
|
||||
// Remove the Plaid item.
|
||||
const data = await plaidInstance.itemRemove(request);
|
||||
|
||||
// Remove the Plaid item from the system.
|
||||
await PlaidItem.query().findById(account.plaidAccountId).delete();
|
||||
await PlaidItem.query(trx).findById(account.plaidItemId).delete();
|
||||
|
||||
// Remove the plaid item association to the bank account.
|
||||
await Account.query().findById(bankAccountId).patch({
|
||||
await Account.query(trx).findById(bankAccountId).patch({
|
||||
plaidAccountId: null,
|
||||
plaidItemId: null,
|
||||
isFeedsActive: false,
|
||||
});
|
||||
// Remove the Plaid item.
|
||||
const data = await plaidInstance.itemRemove({
|
||||
access_token: oldPlaidItem.plaidAccessToken,
|
||||
});
|
||||
// Triggers `onBankAccountDisconnected` event.
|
||||
await this.eventPublisher.emitAsync(events.bankAccount.onDisconnected, {
|
||||
tenantId,
|
||||
bankAccountId,
|
||||
@@ -66,7 +68,3 @@ export class DisconnectBankAccount {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const ERRORS = {
|
||||
BANK_ACCOUNT_NOT_CONNECTED: 'BANK_ACCOUNT_NOT_CONNECTED',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ServiceError } from '@/exceptions';
|
||||
import { PlaidClientWrapper } from '@/lib/Plaid';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import UnitOfWork from '@/services/UnitOfWork';
|
||||
import { Inject } from 'typedi';
|
||||
export class RefreshBankAccountService {
|
||||
@Inject()
|
||||
private tenancy: HasTenancyService;
|
||||
|
||||
@Inject()
|
||||
private uow: UnitOfWork;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} tenantId
|
||||
* @param {number} bankAccountId
|
||||
*/
|
||||
public async refreshBankAccount(tenantId: number, bankAccountId: number) {
|
||||
const { Account } = this.tenancy.models(tenantId);
|
||||
|
||||
const bankAccount = await Account.query()
|
||||
.findById(bankAccountId)
|
||||
.withGraphFetched('plaidItem')
|
||||
.throwIfNotFound();
|
||||
|
||||
if (!bankAccount.plaidItem) {
|
||||
throw new ServiceError('');
|
||||
}
|
||||
const plaidInstance = new PlaidClientWrapper();
|
||||
|
||||
const data = await plaidInstance.transactionsRefresh({
|
||||
access_token: bankAccount.plaidItem.plaidAccessToken,
|
||||
});
|
||||
await Account.query().findById(bankAccountId).patch({
|
||||
isFeedsActive: true,
|
||||
lastFeedsUpdatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
17
packages/server/src/services/Banking/BankAccounts/types.ts
Normal file
17
packages/server/src/services/Banking/BankAccounts/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Knex } from 'knex';
|
||||
|
||||
export interface IBankAccountDisconnectingEventPayload {
|
||||
tenantId: number;
|
||||
bankAccountId: number;
|
||||
trx: Knex.Transaction;
|
||||
}
|
||||
|
||||
export interface IBankAccountDisconnectedEventPayload {
|
||||
tenantId: number;
|
||||
bankAccountId: number;
|
||||
trx: Knex.Transaction;
|
||||
}
|
||||
|
||||
export const ERRORS = {
|
||||
BANK_ACCOUNT_NOT_CONNECTED: 'BANK_ACCOUNT_NOT_CONNECTED',
|
||||
};
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
PopoverInteractionKind,
|
||||
Position,
|
||||
Intent,
|
||||
Tooltip,
|
||||
MenuDivider,
|
||||
} from '@blueprintjs/core';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import {
|
||||
@@ -35,7 +37,10 @@ import withSettings from '@/containers/Settings/withSettings';
|
||||
import withSettingsActions from '@/containers/Settings/withSettingsActions';
|
||||
|
||||
import { compose } from '@/utils';
|
||||
import { useDisconnectBankAccount } from '@/hooks/query/bank-rules';
|
||||
import {
|
||||
useDisconnectBankAccount,
|
||||
useUpdateBankAccount,
|
||||
} from '@/hooks/query/bank-rules';
|
||||
|
||||
function AccountTransactionsActionsBar({
|
||||
// #withDialogActions
|
||||
@@ -54,6 +59,7 @@ function AccountTransactionsActionsBar({
|
||||
const { refresh } = useRefreshCashflowTransactionsInfinity();
|
||||
|
||||
const { mutateAsync: disconnectBankAccount } = useDisconnectBankAccount();
|
||||
const { mutateAsync: updateBankAccount } = useUpdateBankAccount();
|
||||
|
||||
// Retrieves the money in/out buttons options.
|
||||
const addMoneyInOptions = useMemo(() => getAddMoneyInOptions(), []);
|
||||
@@ -92,7 +98,7 @@ function AccountTransactionsActionsBar({
|
||||
|
||||
// Handles the bank account disconnect click.
|
||||
const handleDisconnectClick = () => {
|
||||
disconnectBankAccount(accountId)
|
||||
disconnectBankAccount({ bankAccountId: accountId })
|
||||
.then(() => {
|
||||
AppToaster.show({
|
||||
message: 'The bank account has been disconnected.',
|
||||
@@ -106,7 +112,22 @@ function AccountTransactionsActionsBar({
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// handles the bank update button click.
|
||||
const handleBankUpdateClick = () => {
|
||||
updateBankAccount({ bankAccountId: accountId })
|
||||
.then(() => {
|
||||
AppToaster.show({
|
||||
message: 'The transactions of the bank account has been updated.',
|
||||
intent: Intent.SUCCESS,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
AppToaster.show({
|
||||
message: 'Something went wrong.',
|
||||
intent: Intent.DANGER,
|
||||
});
|
||||
});
|
||||
};
|
||||
// Handle the refresh button click.
|
||||
const handleRefreshBtnClick = () => {
|
||||
refresh();
|
||||
@@ -154,6 +175,18 @@ function AccountTransactionsActionsBar({
|
||||
onChange={handleTableRowSizeChange}
|
||||
/>
|
||||
<NavbarDivider />
|
||||
|
||||
<Tooltip
|
||||
content={'The bank syncing is active'}
|
||||
minimal={true}
|
||||
position={Position.BOTTOM}
|
||||
>
|
||||
<Button
|
||||
className={Classes.MINIMAL}
|
||||
icon={<Icon icon="feed" iconSize={16} color="#238C2C" />}
|
||||
intent={Intent.SUCCESS}
|
||||
/>
|
||||
</Tooltip>
|
||||
</NavbarGroup>
|
||||
|
||||
<NavbarGroup align={Alignment.RIGHT}>
|
||||
@@ -166,6 +199,10 @@ function AccountTransactionsActionsBar({
|
||||
}}
|
||||
content={
|
||||
<Menu>
|
||||
{isConnected && (
|
||||
<MenuItem onClick={handleBankUpdateClick} text={'Update'} />
|
||||
)}
|
||||
<MenuDivider />
|
||||
<MenuItem onClick={handleBankRulesClick} text={'Bank rules'} />
|
||||
|
||||
{isConnected && (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import {
|
||||
QueryClient,
|
||||
UseMutationOptions,
|
||||
@@ -61,18 +62,66 @@ export function useCreateBankRule(
|
||||
}
|
||||
|
||||
interface DisconnectBankAccountRes {}
|
||||
interface DisconnectBankAccountValues {
|
||||
bankAccountId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects the given bank account.
|
||||
* @param {UseMutationOptions<DisconnectBankAccountRes, Error, DisconnectBankAccountValues>} options
|
||||
* @returns {UseMutationResult<DisconnectBankAccountRes, Error, DisconnectBankAccountValues>}
|
||||
*/
|
||||
export function useDisconnectBankAccount(
|
||||
options?: UseMutationOptions<number, Error, DisconnectBankAccountRes>,
|
||||
) {
|
||||
options?: UseMutationOptions<
|
||||
DisconnectBankAccountRes,
|
||||
Error,
|
||||
DisconnectBankAccountValues
|
||||
>,
|
||||
): UseMutationResult<
|
||||
DisconnectBankAccountRes,
|
||||
Error,
|
||||
DisconnectBankAccountValues
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
const apiRequest = useApiRequest();
|
||||
|
||||
return useMutation<number, Error, DisconnectBankAccountRes>(
|
||||
(bankAccountId: number) =>
|
||||
apiRequest
|
||||
.post(`/banking/bank_accounts/${bankAccountId}`)
|
||||
.then((res) => res.data),
|
||||
return useMutation<
|
||||
DisconnectBankAccountRes,
|
||||
Error,
|
||||
DisconnectBankAccountValues
|
||||
>(
|
||||
({ bankAccountId }) =>
|
||||
apiRequest.post(`/banking/bank_accounts/${bankAccountId}/disconnect`),
|
||||
{
|
||||
...options,
|
||||
onSuccess: () => {},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
interface UpdateBankAccountRes {}
|
||||
interface UpdateBankAccountValues {
|
||||
bankAccountId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the bank transactions of the bank account.
|
||||
* @param {UseMutationOptions<UpdateBankAccountRes, Error, UpdateBankAccountValues>}
|
||||
* @returns {UseMutationResult<UpdateBankAccountRes, Error, UpdateBankAccountValues>}
|
||||
*/
|
||||
export function useUpdateBankAccount(
|
||||
options?: UseMutationOptions<
|
||||
UpdateBankAccountRes,
|
||||
Error,
|
||||
UpdateBankAccountValues
|
||||
>,
|
||||
): UseMutationResult<UpdateBankAccountRes, Error, UpdateBankAccountValues> {
|
||||
const queryClient = useQueryClient();
|
||||
const apiRequest = useApiRequest();
|
||||
|
||||
return useMutation<DisconnectBankAccountRes, Error, UpdateBankAccountValues>(
|
||||
({ bankAccountId }) =>
|
||||
apiRequest.post(`/banking/bank_accounts/${bankAccountId}/update`),
|
||||
{
|
||||
...options,
|
||||
onSuccess: () => {},
|
||||
|
||||
@@ -635,4 +635,11 @@ export default {
|
||||
],
|
||||
viewBox: '0 0 16 16',
|
||||
},
|
||||
|
||||
feed: {
|
||||
path: [
|
||||
'M1.99,11.99c-1.1,0-2,0.9-2,2s0.9,2,2,2s2-0.9,2-2S3.1,11.99,1.99,11.99zM2.99,7.99c-0.55,0-1,0.45-1,1s0.45,1,1,1c1.66,0,3,1.34,3,3c0,0.55,0.45,1,1,1s1-0.45,1-1C7.99,10.23,5.75,7.99,2.99,7.99zM2.99,3.99c-0.55,0-1,0.45-1,1s0.45,1,1,1c3.87,0,7,3.13,7,7c0,0.55,0.45,1,1,1s1-0.45,1-1C11.99,8.02,7.96,3.99,2.99,3.99zM2.99-0.01c-0.55,0-1,0.45-1,1s0.45,1,1,1c6.08,0,11,4.92,11,11c0,0.55,0.45,1,1,1s1-0.45,1-1C15.99,5.81,10.17-0.01,2.99-0.01z',
|
||||
],
|
||||
viewBox: '0 0 16 16',
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user