mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-18 13:50:31 +00:00
Compare commits
22 Commits
v0.18.11
...
all-contri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d5da42695 | ||
|
|
a298d2dd97 | ||
|
|
9090d0a7b2 | ||
|
|
ffc55fa81b | ||
|
|
788150f80d | ||
|
|
c4e77e4e3b | ||
|
|
c09384e49b | ||
|
|
4490c2d4b4 | ||
|
|
e11f1a95f6 | ||
|
|
b91273eee4 | ||
|
|
b5d570417b | ||
|
|
acd3265e35 | ||
|
|
894c899847 | ||
|
|
f6d4ec504f | ||
|
|
1a01461f5d | ||
|
|
89552d7ee2 | ||
|
|
4345623ea9 | ||
|
|
f457759e39 | ||
|
|
14d5e82b4a | ||
|
|
51471ed000 | ||
|
|
c2815afbe3 | ||
|
|
fa7e6b1fca |
@@ -132,6 +132,15 @@
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "oleynikd",
|
||||
"name": "Denis",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/3976868?v=4",
|
||||
"profile": "https://github.com/oleynikd",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
|
||||
@@ -126,6 +126,9 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://vederis.id"><img src="https://avatars.githubusercontent.com/u/13505006?v=4?s=100" width="100px;" alt="Vederis Leunardus"/><br /><sub><b>Vederis Leunardus</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/commits?author=cloudsbird" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://www.pivoten.com"><img src="https://avatars.githubusercontent.com/u/104120598?v=4?s=100" width="100px;" alt="Chris Cantrell"/><br /><sub><b>Chris Cantrell</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/issues?q=author%3Accantrell72" title="Bug reports">🐛</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/oleynikd"><img src="https://avatars.githubusercontent.com/u/3976868?v=4?s=100" width="100px;" alt="Denis"/><br /><sub><b>Denis</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/issues?q=author%3Aoleynikd" title="Bug reports">🐛</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -3,12 +3,16 @@ import { NextFunction, Request, Response, Router } from 'express';
|
||||
import BaseController from '@/api/controllers/BaseController';
|
||||
import { CashflowApplication } from '@/services/Cashflow/CashflowApplication';
|
||||
import { GetBankAccountSummary } from '@/services/Banking/BankAccounts/GetBankAccountSummary';
|
||||
import { BankAccountsApplication } from '@/services/Banking/BankAccounts/BankAccountsApplication';
|
||||
|
||||
@Service()
|
||||
export class BankAccountsController extends BaseController {
|
||||
@Inject()
|
||||
private getBankAccountSummaryService: GetBankAccountSummary;
|
||||
|
||||
@Inject()
|
||||
private bankAccountsApp: BankAccountsApplication;
|
||||
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
@@ -16,6 +20,11 @@ export class BankAccountsController extends BaseController {
|
||||
const router = Router();
|
||||
|
||||
router.get('/:bankAccountId/meta', this.getBankAccountSummary.bind(this));
|
||||
router.post(
|
||||
'/:bankAccountId/disconnect',
|
||||
this.disconnectBankAccount.bind(this)
|
||||
);
|
||||
router.post('/:bankAccountId/update', this.refreshBankAccount.bind(this));
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -46,4 +55,58 @@ export class BankAccountsController extends BaseController {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disonnect the given bank account.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
* @returns {Promise<Response|null>}
|
||||
*/
|
||||
async disconnectBankAccount(
|
||||
req: Request<{ bankAccountId: number }>,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const { bankAccountId } = req.params;
|
||||
const { tenantId } = req;
|
||||
|
||||
try {
|
||||
await this.bankAccountsApp.disconnectBankAccount(tenantId, bankAccountId);
|
||||
|
||||
return res.status(200).send({
|
||||
id: bankAccountId,
|
||||
message: 'The bank account has been disconnected.',
|
||||
});
|
||||
} catch (error) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { param } from 'express-validator';
|
||||
import { NextFunction, Request, Response, Router, query } from 'express';
|
||||
import { body, param, query } from 'express-validator';
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import BaseController from '../BaseController';
|
||||
import { ExcludeBankTransactionsApplication } from '@/services/Banking/Exclude/ExcludeBankTransactionsApplication';
|
||||
import { map, parseInt, trim } from 'lodash';
|
||||
|
||||
@Service()
|
||||
export class ExcludeBankTransactionsController extends BaseController {
|
||||
@@ -15,9 +16,21 @@ export class ExcludeBankTransactionsController extends BaseController {
|
||||
public router() {
|
||||
const router = Router();
|
||||
|
||||
router.put(
|
||||
'/transactions/exclude',
|
||||
[body('ids').exists()],
|
||||
this.validationResult,
|
||||
this.excludeBulkBankTransactions.bind(this)
|
||||
);
|
||||
router.put(
|
||||
'/transactions/unexclude',
|
||||
[body('ids').exists()],
|
||||
this.validationResult,
|
||||
this.unexcludeBulkBankTransactins.bind(this)
|
||||
);
|
||||
router.put(
|
||||
'/transactions/:transactionId/exclude',
|
||||
[param('transactionId').exists()],
|
||||
[param('transactionId').exists().toInt()],
|
||||
this.validationResult,
|
||||
this.excludeBankTransaction.bind(this)
|
||||
);
|
||||
@@ -94,6 +107,63 @@ export class ExcludeBankTransactionsController extends BaseController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclude bank transactions in bulk.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
private async excludeBulkBankTransactions(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const { tenantId } = req;
|
||||
const { ids } = this.matchedBodyData(req);
|
||||
|
||||
try {
|
||||
await this.excludeBankTransactionApp.excludeBankTransactions(
|
||||
tenantId,
|
||||
ids
|
||||
);
|
||||
return res.status(200).send({
|
||||
message: 'The given bank transactions have been excluded',
|
||||
ids,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unexclude the given bank transactions in bulk.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
* @returns {Promise<Response | null>}
|
||||
*/
|
||||
private async unexcludeBulkBankTransactins(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<Response | null> {
|
||||
const { tenantId } = req;
|
||||
const { ids } = this.matchedBodyData(req);
|
||||
|
||||
try {
|
||||
await this.excludeBankTransactionApp.unexcludeBankTransactions(
|
||||
tenantId,
|
||||
ids
|
||||
);
|
||||
return res.status(200).send({
|
||||
message: 'The given bank transactions have been excluded',
|
||||
ids,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the excluded uncategorized bank transactions.
|
||||
* @param {Request} req
|
||||
@@ -109,7 +179,6 @@ export class ExcludeBankTransactionsController extends BaseController {
|
||||
const { tenantId } = req;
|
||||
const filter = this.matchedBodyData(req);
|
||||
|
||||
console.log('123');
|
||||
try {
|
||||
const data =
|
||||
await this.excludeBankTransactionApp.getExcludedBankTransactions(
|
||||
|
||||
@@ -236,6 +236,10 @@ module.exports = {
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
bucket: process.env.S3_BUCKET || 'bigcapital-documents',
|
||||
forcePathStyle: parseBoolean(
|
||||
defaultTo(process.env.S3_FORCE_PATH_STYLE, false),
|
||||
false
|
||||
),
|
||||
},
|
||||
|
||||
loops: {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
exports.up = function (knex) {
|
||||
return knex.schema
|
||||
.table('accounts', (table) => {
|
||||
table
|
||||
.boolean('is_syncing_owner')
|
||||
.defaultTo(false)
|
||||
.after('is_feeds_active');
|
||||
})
|
||||
.then(() => {
|
||||
return knex('accounts')
|
||||
.whereNotNull('plaid_item_id')
|
||||
.orWhereNotNull('plaid_account_id')
|
||||
.update('is_syncing_owner', true);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function (knex) {
|
||||
table.dropColumn('is_syncing_owner');
|
||||
};
|
||||
@@ -15,6 +15,7 @@ export interface IAccountDTO {
|
||||
export interface IAccountCreateDTO extends IAccountDTO {
|
||||
currencyCode?: string;
|
||||
plaidAccountId?: string;
|
||||
plaidItemId?: string;
|
||||
}
|
||||
|
||||
export interface IAccountEditDTO extends IAccountDTO {}
|
||||
@@ -37,6 +38,8 @@ export interface IAccount {
|
||||
accountNormal: string;
|
||||
accountParentType: string;
|
||||
bankBalance: string;
|
||||
plaidItemId: number | null
|
||||
lastFeedsUpdatedAt: Date;
|
||||
}
|
||||
|
||||
export enum AccountNormal {
|
||||
|
||||
@@ -1,69 +1,12 @@
|
||||
import { forEach } from 'lodash';
|
||||
import { Configuration, PlaidApi, PlaidEnvironments } from 'plaid';
|
||||
import { createPlaidApiEvent } from './PlaidApiEventsDBSync';
|
||||
import config from '@/config';
|
||||
|
||||
const OPTIONS = { clientApp: 'Plaid-Pattern' };
|
||||
|
||||
// We want to log requests to / responses from the Plaid API (via the Plaid client), as this data
|
||||
// can be useful for troubleshooting.
|
||||
|
||||
/**
|
||||
* Logging function for Plaid client methods that use an access_token as an argument. Associates
|
||||
* the Plaid API event log entry with the item and user the request is for.
|
||||
*
|
||||
* @param {string} clientMethod the name of the Plaid client method called.
|
||||
* @param {Array} clientMethodArgs the arguments passed to the Plaid client method.
|
||||
* @param {Object} response the response from the Plaid client.
|
||||
*/
|
||||
const defaultLogger = async (clientMethod, clientMethodArgs, response) => {
|
||||
const accessToken = clientMethodArgs[0].access_token;
|
||||
// const { id: itemId, user_id: userId } = await retrieveItemByPlaidAccessToken(
|
||||
// accessToken
|
||||
// );
|
||||
// await createPlaidApiEvent(1, 1, clientMethod, clientMethodArgs, response);
|
||||
|
||||
// console.log(response);
|
||||
};
|
||||
|
||||
/**
|
||||
* Logging function for Plaid client methods that do not use access_token as an argument. These
|
||||
* Plaid API event log entries will not be associated with an item or user.
|
||||
*
|
||||
* @param {string} clientMethod the name of the Plaid client method called.
|
||||
* @param {Array} clientMethodArgs the arguments passed to the Plaid client method.
|
||||
* @param {Object} response the response from the Plaid client.
|
||||
*/
|
||||
const noAccessTokenLogger = async (
|
||||
clientMethod,
|
||||
clientMethodArgs,
|
||||
response
|
||||
) => {
|
||||
// console.log(response);
|
||||
|
||||
// await createPlaidApiEvent(
|
||||
// undefined,
|
||||
// undefined,
|
||||
// clientMethod,
|
||||
// clientMethodArgs,
|
||||
// response
|
||||
// );
|
||||
};
|
||||
|
||||
// Plaid client methods used in this app, mapped to their appropriate logging functions.
|
||||
const clientMethodLoggingFns = {
|
||||
accountsGet: defaultLogger,
|
||||
institutionsGet: noAccessTokenLogger,
|
||||
institutionsGetById: noAccessTokenLogger,
|
||||
itemPublicTokenExchange: noAccessTokenLogger,
|
||||
itemRemove: defaultLogger,
|
||||
linkTokenCreate: noAccessTokenLogger,
|
||||
transactionsSync: defaultLogger,
|
||||
sandboxItemResetLogin: defaultLogger,
|
||||
};
|
||||
// Wrapper for the Plaid client. This allows us to easily log data for all Plaid client requests.
|
||||
export class PlaidClientWrapper {
|
||||
constructor() {
|
||||
private static instance: PlaidClientWrapper;
|
||||
private client: PlaidApi;
|
||||
|
||||
private constructor() {
|
||||
// Initialize the Plaid client.
|
||||
const configuration = new Configuration({
|
||||
basePath: PlaidEnvironments[config.plaid.env],
|
||||
@@ -75,26 +18,13 @@ export class PlaidClientWrapper {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.client = new PlaidApi(configuration);
|
||||
|
||||
// Wrap the Plaid client methods to add a logging function.
|
||||
forEach(clientMethodLoggingFns, (logFn, method) => {
|
||||
this[method] = this.createWrappedClientMethod(method, logFn);
|
||||
});
|
||||
}
|
||||
|
||||
// Allows us to log API request data for troubleshooting purposes.
|
||||
createWrappedClientMethod(clientMethod, log) {
|
||||
return async (...args) => {
|
||||
try {
|
||||
const res = await this.client[clientMethod](...args);
|
||||
await log(clientMethod, args, res);
|
||||
return res;
|
||||
} catch (err) {
|
||||
await log(clientMethod, args, err?.response?.data);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
public static getClient(): PlaidApi {
|
||||
if (!PlaidClientWrapper.instance) {
|
||||
PlaidClientWrapper.instance = new PlaidClientWrapper();
|
||||
}
|
||||
return PlaidClientWrapper.instance.client;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ export const s3 = new S3Client({
|
||||
secretAccessKey: config.s3.secretAccessKey,
|
||||
},
|
||||
endpoint: config.s3.endpoint,
|
||||
forcePathStyle: config.s3.forcePathStyle,
|
||||
});
|
||||
|
||||
@@ -113,6 +113,7 @@ import { UnlinkBankRuleOnDeleteBankRule } from '@/services/Banking/Rules/events/
|
||||
import { DecrementUncategorizedTransactionOnMatching } from '@/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch';
|
||||
import { DecrementUncategorizedTransactionOnExclude } from '@/services/Banking/Exclude/events/DecrementUncategorizedTransactionOnExclude';
|
||||
import { DecrementUncategorizedTransactionOnCategorize } from '@/services/Cashflow/subscribers/DecrementUncategorizedTransactionOnCategorize';
|
||||
import { DisconnectPlaidItemOnAccountDeleted } from '@/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted';
|
||||
import { LoopsEventsSubscriber } from '@/services/Loops/LoopsEventsSubscriber';
|
||||
|
||||
export default () => {
|
||||
@@ -275,6 +276,7 @@ export const susbcribers = () => {
|
||||
|
||||
// Plaid
|
||||
RecognizeSyncedBankTranasctions,
|
||||
DisconnectPlaidItemOnAccountDeleted,
|
||||
|
||||
// Loops
|
||||
LoopsEventsSubscriber
|
||||
|
||||
@@ -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.plaidItemId',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,12 @@ export class AccountTransformer extends Transformer {
|
||||
* @returns {Array}
|
||||
*/
|
||||
public includeAttributes = (): string[] => {
|
||||
return ['formattedAmount', 'flattenName', 'bankBalanceFormatted'];
|
||||
return [
|
||||
'formattedAmount',
|
||||
'flattenName',
|
||||
'bankBalanceFormatted',
|
||||
'lastFeedsUpdatedAtFormatted',
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -52,6 +57,15 @@ export class AccountTransformer extends Transformer {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the formatted last feeds update at.
|
||||
* @param {IAccount} account
|
||||
* @returns {string}
|
||||
*/
|
||||
protected lastFeedsUpdatedAtFormatted = (account: IAccount): string => {
|
||||
return this.formatDate(account.lastFeedsUpdatedAt);
|
||||
};
|
||||
|
||||
/**
|
||||
* Transformes the accounts collection to flat or nested array.
|
||||
* @param {IAccount[]}
|
||||
|
||||
@@ -96,6 +96,11 @@ export class CreateAccount {
|
||||
...createAccountDTO,
|
||||
slug: kebabCase(createAccountDTO.name),
|
||||
currencyCode: createAccountDTO.currencyCode || baseCurrency,
|
||||
|
||||
// Mark the account is Plaid owner since Plaid item/account is defined on creating.
|
||||
isSyncingOwner: Boolean(
|
||||
createAccountDTO.plaidAccountId || createAccountDTO.plaidItemId
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -117,12 +122,7 @@ export class CreateAccount {
|
||||
const tenantMeta = await TenantMetadata.query().findOne({ tenantId });
|
||||
|
||||
// Authorize the account creation.
|
||||
await this.authorize(
|
||||
tenantId,
|
||||
accountDTO,
|
||||
tenantMeta.baseCurrency,
|
||||
params
|
||||
);
|
||||
await this.authorize(tenantId, accountDTO, tenantMeta.baseCurrency, params);
|
||||
// Transformes the DTO to model.
|
||||
const accountInputModel = this.transformDTOToModel(
|
||||
accountDTO,
|
||||
@@ -157,4 +157,3 @@ export class CreateAccount {
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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
|
||||
* @param {number} bankAccountId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async disconnectBankAccount(tenantId: number, bankAccountId: number) {
|
||||
return this.disconnectBankAccountService.disconnectBankAccount(
|
||||
tenantId,
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Knex } from 'knex';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { ServiceError } from '@/exceptions';
|
||||
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
|
||||
import { PlaidClientWrapper } from '@/lib/Plaid';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import UnitOfWork from '@/services/UnitOfWork';
|
||||
import events from '@/subscribers/events';
|
||||
import {
|
||||
ERRORS,
|
||||
IBankAccountDisconnectedEventPayload,
|
||||
IBankAccountDisconnectingEventPayload,
|
||||
} from './types';
|
||||
import { ACCOUNT_TYPE } from '@/data/AccountTypes';
|
||||
|
||||
@Service()
|
||||
export class DisconnectBankAccount {
|
||||
@Inject()
|
||||
private tenancy: HasTenancyService;
|
||||
|
||||
@Inject()
|
||||
private eventPublisher: EventPublisher;
|
||||
|
||||
@Inject()
|
||||
private uow: UnitOfWork;
|
||||
|
||||
/**
|
||||
* Disconnects the given bank account.
|
||||
* @param {number} tenantId
|
||||
* @param {number} bankAccountId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public 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)
|
||||
.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);
|
||||
}
|
||||
const plaidInstance = PlaidClientWrapper.getClient();
|
||||
|
||||
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
|
||||
// Triggers `onBankAccountDisconnecting` event.
|
||||
await this.eventPublisher.emitAsync(events.bankAccount.onDisconnecting, {
|
||||
tenantId,
|
||||
bankAccountId,
|
||||
} as IBankAccountDisconnectingEventPayload);
|
||||
|
||||
// Remove the Plaid item from the system.
|
||||
await PlaidItem.query(trx).findById(account.plaidItemId).delete();
|
||||
|
||||
// Remove the plaid item association to the bank account.
|
||||
await Account.query(trx).findById(bankAccountId).patch({
|
||||
plaidAccountId: null,
|
||||
plaidItemId: null,
|
||||
isFeedsActive: false,
|
||||
});
|
||||
// Remove the Plaid item.
|
||||
await plaidInstance.itemRemove({
|
||||
access_token: oldPlaidItem.plaidAccessToken,
|
||||
});
|
||||
// Triggers `onBankAccountDisconnected` event.
|
||||
await this.eventPublisher.emitAsync(events.bankAccount.onDisconnected, {
|
||||
tenantId,
|
||||
bankAccountId,
|
||||
trx,
|
||||
} as IBankAccountDisconnectedEventPayload);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { ServiceError } from '@/exceptions';
|
||||
import { PlaidClientWrapper } from '@/lib/Plaid';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import { ERRORS } from './types';
|
||||
|
||||
@Service()
|
||||
export class RefreshBankAccountService {
|
||||
@Inject()
|
||||
private tenancy: HasTenancyService;
|
||||
|
||||
/**
|
||||
* Asks Plaid to trigger syncing the given bank account.
|
||||
* @param {number} tenantId
|
||||
* @param {number} bankAccountId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public async refreshBankAccount(tenantId: number, bankAccountId: number) {
|
||||
const { Account } = this.tenancy.models(tenantId);
|
||||
|
||||
const bankAccount = await Account.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);
|
||||
}
|
||||
const plaidInstance = PlaidClientWrapper.getClient();
|
||||
|
||||
await plaidInstance.transactionsRefresh({
|
||||
access_token: bankAccount.plaidItem.plaidAccessToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { IAccountEventDeletedPayload } from '@/interfaces';
|
||||
import { PlaidClientWrapper } from '@/lib/Plaid';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import events from '@/subscribers/events';
|
||||
|
||||
@Service()
|
||||
export class DisconnectPlaidItemOnAccountDeleted {
|
||||
@Inject()
|
||||
private tenancy: HasTenancyService;
|
||||
|
||||
/**
|
||||
* Constructor method.
|
||||
*/
|
||||
public attach(bus) {
|
||||
bus.subscribe(
|
||||
events.accounts.onDeleted,
|
||||
this.handleDisconnectPlaidItemOnAccountDelete.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes Plaid item from the system and Plaid once the account deleted.
|
||||
* @param {IAccountEventDeletedPayload} payload
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
private async handleDisconnectPlaidItemOnAccountDelete({
|
||||
tenantId,
|
||||
oldAccount,
|
||||
trx,
|
||||
}: IAccountEventDeletedPayload) {
|
||||
const { PlaidItem, Account } = this.tenancy.models(tenantId);
|
||||
|
||||
// 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 PlaidItem.query(trx).findOne(
|
||||
'plaidItemId',
|
||||
oldAccount.plaidItemId
|
||||
);
|
||||
// Unlink the Plaid item from all account before deleting it.
|
||||
await Account.query(trx)
|
||||
.where('plaidItemId', oldAccount.plaidItemId)
|
||||
.patch({
|
||||
plaidAccountId: null,
|
||||
plaidItemId: null,
|
||||
});
|
||||
// Remove the Plaid item from the system.
|
||||
await PlaidItem.query(trx)
|
||||
.findOne('plaidItemId', oldAccount.plaidItemId)
|
||||
.delete();
|
||||
|
||||
if (oldPlaidItem) {
|
||||
const plaidInstance = PlaidClientWrapper.getClient();
|
||||
|
||||
// Remove the Plaid item.
|
||||
await plaidInstance.itemRemove({
|
||||
access_token: oldPlaidItem.plaidAccessToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
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',
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import PromisePool from '@supercharge/promise-pool';
|
||||
import { castArray } from 'lodash';
|
||||
import { ExcludeBankTransaction } from './ExcludeBankTransaction';
|
||||
|
||||
@Service()
|
||||
export class ExcludeBankTransactions {
|
||||
@Inject()
|
||||
private excludeBankTransaction: ExcludeBankTransaction;
|
||||
|
||||
/**
|
||||
* Exclude bank transactions in bulk.
|
||||
* @param {number} tenantId
|
||||
* @param {number} bankTransactionIds
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public async excludeBankTransactions(
|
||||
tenantId: number,
|
||||
bankTransactionIds: Array<number> | number
|
||||
) {
|
||||
const _bankTransactionIds = castArray(bankTransactionIds);
|
||||
|
||||
await PromisePool.withConcurrency(1)
|
||||
.for(_bankTransactionIds)
|
||||
.process((bankTransactionId: number) => {
|
||||
return this.excludeBankTransaction.excludeBankTransaction(
|
||||
tenantId,
|
||||
bankTransactionId
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { ExcludeBankTransaction } from './ExcludeBankTransaction';
|
||||
import { UnexcludeBankTransaction } from './UnexcludeBankTransaction';
|
||||
import { GetExcludedBankTransactionsService } from './GetExcludedBankTransactions';
|
||||
import { ExcludedBankTransactionsQuery } from './_types';
|
||||
import { UnexcludeBankTransactions } from './UnexcludeBankTransactions';
|
||||
import { ExcludeBankTransactions } from './ExcludeBankTransactions';
|
||||
|
||||
@Service()
|
||||
export class ExcludeBankTransactionsApplication {
|
||||
@@ -15,6 +17,12 @@ export class ExcludeBankTransactionsApplication {
|
||||
@Inject()
|
||||
private getExcludedBankTransactionsService: GetExcludedBankTransactionsService;
|
||||
|
||||
@Inject()
|
||||
private excludeBankTransactionsService: ExcludeBankTransactions;
|
||||
|
||||
@Inject()
|
||||
private unexcludeBankTransactionsService: UnexcludeBankTransactions;
|
||||
|
||||
/**
|
||||
* Marks a bank transaction as excluded.
|
||||
* @param {number} tenantId - The ID of the tenant.
|
||||
@@ -56,4 +64,36 @@ export class ExcludeBankTransactionsApplication {
|
||||
filter
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclude the given bank transactions in bulk.
|
||||
* @param {number} tenantId
|
||||
* @param {Array<number> | number} bankTransactionIds
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public excludeBankTransactions(
|
||||
tenantId: number,
|
||||
bankTransactionIds: Array<number> | number
|
||||
): Promise<void> {
|
||||
return this.excludeBankTransactionsService.excludeBankTransactions(
|
||||
tenantId,
|
||||
bankTransactionIds
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclude the given bank transactions in bulk.
|
||||
* @param {number} tenantId
|
||||
* @param {Array<number> | number} bankTransactionIds
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public unexcludeBankTransactions(
|
||||
tenantId: number,
|
||||
bankTransactionIds: Array<number> | number
|
||||
): Promise<void> {
|
||||
return this.unexcludeBankTransactionsService.unexcludeBankTransactions(
|
||||
tenantId,
|
||||
bankTransactionIds
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import PromisePool from '@supercharge/promise-pool';
|
||||
import { UnexcludeBankTransaction } from './UnexcludeBankTransaction';
|
||||
import { castArray } from 'lodash';
|
||||
|
||||
@Service()
|
||||
export class UnexcludeBankTransactions {
|
||||
@Inject()
|
||||
private unexcludeBankTransaction: UnexcludeBankTransaction;
|
||||
|
||||
/**
|
||||
* Unexclude bank transactions in bulk.
|
||||
* @param {number} tenantId
|
||||
* @param {number} bankTransactionIds
|
||||
*/
|
||||
public async unexcludeBankTransactions(
|
||||
tenantId: number,
|
||||
bankTransactionIds: Array<number> | number
|
||||
) {
|
||||
const _bankTransactionIds = castArray(bankTransactionIds);
|
||||
|
||||
await PromisePool.withConcurrency(1)
|
||||
.for(_bankTransactionIds)
|
||||
.process((bankTransactionId: number) => {
|
||||
return this.unexcludeBankTransaction.unexcludeBankTransaction(
|
||||
tenantId,
|
||||
bankTransactionId
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export class PlaidItemService {
|
||||
const { PlaidItem } = this.tenancy.models(tenantId);
|
||||
const { publicToken, institutionId } = itemDTO;
|
||||
|
||||
const plaidInstance = new PlaidClientWrapper();
|
||||
const plaidInstance = PlaidClientWrapper.getClient();
|
||||
|
||||
// Exchange the public token for a private access token and store with the item.
|
||||
const response = await plaidInstance.itemPublicTokenExchange({
|
||||
|
||||
@@ -26,7 +26,7 @@ export class PlaidLinkTokenService {
|
||||
webhook: config.plaid.linkWebhook,
|
||||
access_token: accessToken,
|
||||
};
|
||||
const plaidInstance = new PlaidClientWrapper();
|
||||
const plaidInstance = PlaidClientWrapper.getClient();
|
||||
const createResponse = await plaidInstance.linkTokenCreate(linkTokenParams);
|
||||
|
||||
return createResponse.data;
|
||||
|
||||
@@ -2,6 +2,11 @@ import * as R from 'ramda';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import bluebird from 'bluebird';
|
||||
import { entries, groupBy } from 'lodash';
|
||||
import {
|
||||
AccountBase as PlaidAccountBase,
|
||||
Item as PlaidItem,
|
||||
Institution as PlaidInstitution,
|
||||
} from 'plaid';
|
||||
import { CreateAccount } from '@/services/Accounts/CreateAccount';
|
||||
import {
|
||||
IAccountCreateDTO,
|
||||
@@ -53,6 +58,7 @@ export class PlaidSyncDb {
|
||||
trx?: Knex.Transaction
|
||||
) {
|
||||
const { Account } = this.tenancy.models(tenantId);
|
||||
|
||||
const plaidAccount = await Account.query().findOne(
|
||||
'plaidAccountId',
|
||||
createBankAccountDTO.plaidAccountId
|
||||
@@ -77,13 +83,15 @@ export class PlaidSyncDb {
|
||||
*/
|
||||
public async syncBankAccounts(
|
||||
tenantId: number,
|
||||
plaidAccounts: PlaidAccount[],
|
||||
institution: any,
|
||||
plaidAccounts: PlaidAccountBase[],
|
||||
institution: PlaidInstitution,
|
||||
item: PlaidItem,
|
||||
trx?: Knex.Transaction
|
||||
): Promise<void> {
|
||||
const transformToPlaidAccounts =
|
||||
transformPlaidAccountToCreateAccount(institution);
|
||||
|
||||
const transformToPlaidAccounts = transformPlaidAccountToCreateAccount(
|
||||
item,
|
||||
institution
|
||||
);
|
||||
const accountCreateDTOs = R.map(transformToPlaidAccounts)(plaidAccounts);
|
||||
|
||||
await bluebird.map(
|
||||
|
||||
@@ -53,7 +53,7 @@ export class PlaidUpdateTransactions {
|
||||
await this.fetchTransactionUpdates(tenantId, plaidItemId);
|
||||
|
||||
const request = { access_token: accessToken };
|
||||
const plaidInstance = new PlaidClientWrapper();
|
||||
const plaidInstance = PlaidClientWrapper.getClient();
|
||||
const {
|
||||
data: { accounts, item },
|
||||
} = await plaidInstance.accountsGet(request);
|
||||
@@ -66,7 +66,13 @@ export class PlaidUpdateTransactions {
|
||||
country_codes: ['US', 'UK'],
|
||||
});
|
||||
// Sync bank accounts.
|
||||
await this.plaidSync.syncBankAccounts(tenantId, accounts, institution, trx);
|
||||
await this.plaidSync.syncBankAccounts(
|
||||
tenantId,
|
||||
accounts,
|
||||
institution,
|
||||
item,
|
||||
trx
|
||||
);
|
||||
// Sync bank account transactions.
|
||||
await this.plaidSync.syncAccountsTransactions(
|
||||
tenantId,
|
||||
@@ -141,7 +147,7 @@ export class PlaidUpdateTransactions {
|
||||
cursor: cursor,
|
||||
count: batchSize,
|
||||
};
|
||||
const plaidInstance = new PlaidClientWrapper();
|
||||
const plaidInstance = PlaidClientWrapper.getClient();
|
||||
const response = await plaidInstance.transactionsSync(request);
|
||||
const data = response.data;
|
||||
// Add this page of results
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import * as R from 'ramda';
|
||||
import {
|
||||
Item as PlaidItem,
|
||||
Institution as PlaidInstitution,
|
||||
AccountBase as PlaidAccount,
|
||||
} from 'plaid';
|
||||
import {
|
||||
CreateUncategorizedTransactionDTO,
|
||||
IAccountCreateDTO,
|
||||
PlaidAccount,
|
||||
PlaidTransaction,
|
||||
} from '@/interfaces';
|
||||
|
||||
/**
|
||||
* Transformes the Plaid account to create cashflow account DTO.
|
||||
* @param {PlaidAccount} plaidAccount
|
||||
* @param {PlaidItem} item -
|
||||
* @param {PlaidInstitution} institution -
|
||||
* @param {PlaidAccount} plaidAccount -
|
||||
* @returns {IAccountCreateDTO}
|
||||
*/
|
||||
export const transformPlaidAccountToCreateAccount = R.curry(
|
||||
(institution: any, plaidAccount: PlaidAccount): IAccountCreateDTO => {
|
||||
(
|
||||
item: PlaidItem,
|
||||
institution: PlaidInstitution,
|
||||
plaidAccount: PlaidAccount
|
||||
): IAccountCreateDTO => {
|
||||
return {
|
||||
name: `${institution.name} - ${plaidAccount.name}`,
|
||||
code: '',
|
||||
@@ -20,9 +30,10 @@ export const transformPlaidAccountToCreateAccount = R.curry(
|
||||
currencyCode: plaidAccount.balances.iso_currency_code,
|
||||
accountType: 'cash',
|
||||
active: true,
|
||||
plaidAccountId: plaidAccount.account_id,
|
||||
bankBalance: plaidAccount.balances.current,
|
||||
accountMask: plaidAccount.mask,
|
||||
plaidAccountId: plaidAccount.account_id,
|
||||
plaidItemId: item.item_id,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -658,6 +658,11 @@ export default {
|
||||
onUnexcluded: 'onBankTransactionUnexcluded',
|
||||
},
|
||||
|
||||
bankAccount: {
|
||||
onDisconnecting: 'onBankAccountDisconnecting',
|
||||
onDisconnected: 'onBankAccountDisconnected',
|
||||
},
|
||||
|
||||
// Import files.
|
||||
import: {
|
||||
onImportCommitted: 'onImportFileCommitted',
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
import { Position, Toaster, Intent } from '@blueprintjs/core';
|
||||
|
||||
export const AppToaster = Toaster.create({
|
||||
position: Position.RIGHT_BOTTOM,
|
||||
position: Position.TOP,
|
||||
intent: Intent.WARNING,
|
||||
});
|
||||
|
||||
@@ -11,13 +11,19 @@ import {
|
||||
MenuItem,
|
||||
PopoverInteractionKind,
|
||||
Position,
|
||||
Intent,
|
||||
Tooltip,
|
||||
MenuDivider,
|
||||
} from '@blueprintjs/core';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { isEmpty } from 'lodash';
|
||||
import {
|
||||
Icon,
|
||||
DashboardActionsBar,
|
||||
DashboardRowsHeightButton,
|
||||
FormattedMessage as T,
|
||||
AppToaster,
|
||||
If,
|
||||
} from '@/components';
|
||||
|
||||
import { CashFlowMenuItems } from './utils';
|
||||
@@ -33,6 +39,13 @@ import withSettings from '@/containers/Settings/withSettings';
|
||||
import withSettingsActions from '@/containers/Settings/withSettingsActions';
|
||||
|
||||
import { compose } from '@/utils';
|
||||
import {
|
||||
useDisconnectBankAccount,
|
||||
useUpdateBankAccount,
|
||||
useExcludeUncategorizedTransactions,
|
||||
useUnexcludeUncategorizedTransactions,
|
||||
} from '@/hooks/query/bank-rules';
|
||||
import { withBanking } from '../withBanking';
|
||||
|
||||
function AccountTransactionsActionsBar({
|
||||
// #withDialogActions
|
||||
@@ -43,17 +56,27 @@ function AccountTransactionsActionsBar({
|
||||
|
||||
// #withSettingsActions
|
||||
addSetting,
|
||||
|
||||
// #withBanking
|
||||
uncategorizedTransationsIdsSelected,
|
||||
excludedTransactionsIdsSelected,
|
||||
}) {
|
||||
const history = useHistory();
|
||||
const { accountId } = useAccountTransactionsContext();
|
||||
const { accountId, currentAccount } = useAccountTransactionsContext();
|
||||
|
||||
// Refresh cashflow infinity transactions hook.
|
||||
const { refresh } = useRefreshCashflowTransactionsInfinity();
|
||||
|
||||
const { mutateAsync: disconnectBankAccount } = useDisconnectBankAccount();
|
||||
const { mutateAsync: updateBankAccount } = useUpdateBankAccount();
|
||||
|
||||
// Retrieves the money in/out buttons options.
|
||||
const addMoneyInOptions = useMemo(() => getAddMoneyInOptions(), []);
|
||||
const addMoneyOutOptions = useMemo(() => getAddMoneyOutOptions(), []);
|
||||
|
||||
const isFeedsActive = !!currentAccount.is_feeds_active;
|
||||
const isSyncingOwner = currentAccount.is_syncing_owner;
|
||||
|
||||
// Handle table row size change.
|
||||
const handleTableRowSizeChange = (size) => {
|
||||
addSetting('cashflowTransactions', 'tableSize', size);
|
||||
@@ -82,11 +105,92 @@ function AccountTransactionsActionsBar({
|
||||
const handleBankRulesClick = () => {
|
||||
history.push(`/bank-rules?accountId=${accountId}`);
|
||||
};
|
||||
|
||||
// Handles the bank account disconnect click.
|
||||
const handleDisconnectClick = () => {
|
||||
disconnectBankAccount({ bankAccountId: accountId })
|
||||
.then(() => {
|
||||
AppToaster.show({
|
||||
message: 'The bank account has been disconnected.',
|
||||
intent: Intent.SUCCESS,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
AppToaster.show({
|
||||
message: 'Something went wrong.',
|
||||
intent: Intent.DANGER,
|
||||
});
|
||||
});
|
||||
};
|
||||
// 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();
|
||||
};
|
||||
|
||||
const {
|
||||
mutateAsync: excludeUncategorizedTransactions,
|
||||
isLoading: isExcludingLoading,
|
||||
} = useExcludeUncategorizedTransactions();
|
||||
|
||||
const {
|
||||
mutateAsync: unexcludeUncategorizedTransactions,
|
||||
isLoading: isUnexcludingLoading,
|
||||
} = useUnexcludeUncategorizedTransactions();
|
||||
|
||||
// Handles the exclude uncategorized transactions in bulk.
|
||||
const handleExcludeUncategorizedBtnClick = () => {
|
||||
excludeUncategorizedTransactions({
|
||||
ids: uncategorizedTransationsIdsSelected,
|
||||
})
|
||||
.then(() => {
|
||||
AppToaster.show({
|
||||
message: 'The selected transactions have been excluded.',
|
||||
intent: Intent.SUCCESS,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
AppToaster.show({
|
||||
message: 'Something went wrong',
|
||||
intent: Intent.DANGER,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Handles the unexclude categorized button click.
|
||||
const handleUnexcludeUncategorizedBtnClick = () => {
|
||||
unexcludeUncategorizedTransactions({
|
||||
ids: excludedTransactionsIdsSelected,
|
||||
})
|
||||
.then(() => {
|
||||
AppToaster.show({
|
||||
message: 'The selected excluded transactions have been unexcluded.',
|
||||
intent: Intent.SUCCESS,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
AppToaster.show({
|
||||
message: 'Something went wrong',
|
||||
intent: Intent.DANGER,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardActionsBar>
|
||||
<NavbarGroup>
|
||||
@@ -129,6 +233,45 @@ function AccountTransactionsActionsBar({
|
||||
onChange={handleTableRowSizeChange}
|
||||
/>
|
||||
<NavbarDivider />
|
||||
|
||||
<If condition={isSyncingOwner}>
|
||||
<Tooltip
|
||||
content={
|
||||
isFeedsActive
|
||||
? 'The bank syncing is active'
|
||||
: 'The bank syncing is disconnected'
|
||||
}
|
||||
minimal={true}
|
||||
position={Position.BOTTOM}
|
||||
>
|
||||
<Button
|
||||
className={Classes.MINIMAL}
|
||||
icon={<Icon icon="feed" iconSize={16} />}
|
||||
intent={isFeedsActive ? Intent.SUCCESS : Intent.DANGER}
|
||||
/>
|
||||
</Tooltip>
|
||||
</If>
|
||||
|
||||
{!isEmpty(uncategorizedTransationsIdsSelected) && (
|
||||
<Button
|
||||
icon={<Icon icon="disable" iconSize={16} />}
|
||||
text={'Exclude'}
|
||||
onClick={handleExcludeUncategorizedBtnClick}
|
||||
className={Classes.MINIMAL}
|
||||
intent={Intent.DANGER}
|
||||
disabled={isExcludingLoading}
|
||||
/>
|
||||
)}
|
||||
{!isEmpty(excludedTransactionsIdsSelected) && (
|
||||
<Button
|
||||
icon={<Icon icon="disable" iconSize={16} />}
|
||||
text={'Unexclude'}
|
||||
onClick={handleUnexcludeUncategorizedBtnClick}
|
||||
className={Classes.MINIMAL}
|
||||
intent={Intent.DANGER}
|
||||
disabled={isUnexcludingLoading}
|
||||
/>
|
||||
)}
|
||||
</NavbarGroup>
|
||||
|
||||
<NavbarGroup align={Alignment.RIGHT}>
|
||||
@@ -141,7 +284,15 @@ function AccountTransactionsActionsBar({
|
||||
}}
|
||||
content={
|
||||
<Menu>
|
||||
<If condition={isSyncingOwner && isFeedsActive}>
|
||||
<MenuItem onClick={handleBankUpdateClick} text={'Update'} />
|
||||
<MenuDivider />
|
||||
</If>
|
||||
<MenuItem onClick={handleBankRulesClick} text={'Bank rules'} />
|
||||
|
||||
<If condition={isSyncingOwner && isFeedsActive}>
|
||||
<MenuItem onClick={handleDisconnectClick} text={'Disconnect'} />
|
||||
</If>
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
@@ -164,4 +315,13 @@ export default compose(
|
||||
withSettings(({ cashflowTransactionsSettings }) => ({
|
||||
cashflowTansactionsTableSize: cashflowTransactionsSettings?.tableSize,
|
||||
})),
|
||||
withBanking(
|
||||
({
|
||||
uncategorizedTransationsIdsSelected,
|
||||
excludedTransactionsIdsSelected,
|
||||
}) => ({
|
||||
uncategorizedTransationsIdsSelected,
|
||||
excludedTransactionsIdsSelected,
|
||||
}),
|
||||
),
|
||||
)(AccountTransactionsActionsBar);
|
||||
|
||||
@@ -33,6 +33,7 @@ function AccountTransactionsDataTable({
|
||||
|
||||
// #withBankingActions
|
||||
setUncategorizedTransactionIdForMatching,
|
||||
setUncategorizedTransactionsSelected,
|
||||
}) {
|
||||
// Retrieve table columns.
|
||||
const columns = useAccountUncategorizedTransactionsColumns();
|
||||
@@ -73,12 +74,19 @@ function AccountTransactionsDataTable({
|
||||
});
|
||||
};
|
||||
|
||||
// Handle selected rows change.
|
||||
const handleSelectedRowsChange = (selected) => {
|
||||
const _selectedIds = selected?.map((row) => row.original.id);
|
||||
setUncategorizedTransactionsSelected(_selectedIds);
|
||||
};
|
||||
|
||||
return (
|
||||
<CashflowTransactionsTable
|
||||
noInitialFetch={true}
|
||||
columns={columns}
|
||||
data={uncategorizedTransactions || []}
|
||||
sticky={true}
|
||||
selectionColumn={true}
|
||||
loading={isUncategorizedTransactionsLoading}
|
||||
headerLoading={isUncategorizedTransactionsLoading}
|
||||
expandColumnSpace={1}
|
||||
@@ -99,6 +107,7 @@ function AccountTransactionsDataTable({
|
||||
'There is no uncategorized transactions in the current account.'
|
||||
}
|
||||
className="table-constrant"
|
||||
onSelectedRowsChange={handleSelectedRowsChange}
|
||||
payload={{
|
||||
onExclude: handleExcludeTransaction,
|
||||
onCategorize: handleCategorizeBtnClick,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { Intent } from '@blueprintjs/core';
|
||||
|
||||
import * as R from 'ramda';
|
||||
import {
|
||||
DataTable,
|
||||
TableFastCell,
|
||||
@@ -19,11 +19,20 @@ import { useExcludedTransactionsBoot } from './ExcludedTransactionsTableBoot';
|
||||
|
||||
import { ActionsMenu } from './_components';
|
||||
import { useUnexcludeUncategorizedTransaction } from '@/hooks/query/bank-rules';
|
||||
import {
|
||||
WithBankingActionsProps,
|
||||
withBankingActions,
|
||||
} from '../../withBankingActions';
|
||||
|
||||
interface ExcludeTransactionsTableProps extends WithBankingActionsProps {}
|
||||
|
||||
/**
|
||||
* Renders the recognized account transactions datatable.
|
||||
*/
|
||||
export function ExcludedTransactionsTable() {
|
||||
function ExcludedTransactionsTableRoot({
|
||||
// #withBankingActions
|
||||
setExcludedTransactionsSelected,
|
||||
}: ExcludeTransactionsTableProps) {
|
||||
const { excludedBankTransactions } = useExcludedTransactionsBoot();
|
||||
const { mutateAsync: unexcludeBankTransaction } =
|
||||
useUnexcludeUncategorizedTransaction();
|
||||
@@ -55,6 +64,12 @@ export function ExcludedTransactionsTable() {
|
||||
});
|
||||
};
|
||||
|
||||
// Handle selected rows change.
|
||||
const handleSelectedRowsChange = (selected) => {
|
||||
const _selectedIds = selected?.map((row) => row.original.id);
|
||||
setExcludedTransactionsSelected(_selectedIds);
|
||||
};
|
||||
|
||||
return (
|
||||
<CashflowTransactionsTable
|
||||
noInitialFetch={true}
|
||||
@@ -80,6 +95,8 @@ export function ExcludedTransactionsTable() {
|
||||
onColumnResizing={handleColumnResizing}
|
||||
noResults={'There is no excluded bank transactions.'}
|
||||
className="table-constrant"
|
||||
selectionColumn={true}
|
||||
onSelectedRowsChange={handleSelectedRowsChange}
|
||||
payload={{
|
||||
onRestore: handleRestoreClick,
|
||||
}}
|
||||
@@ -87,6 +104,10 @@ export function ExcludedTransactionsTable() {
|
||||
);
|
||||
}
|
||||
|
||||
export const ExcludedTransactionsTable = R.compose(withBankingActions)(
|
||||
ExcludedTransactionsTableRoot,
|
||||
);
|
||||
|
||||
const DashboardConstrantTable = styled(DataTable)`
|
||||
.table {
|
||||
.thead {
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import { ExcludedTransactionsTable } from "../ExcludedTransactions/ExcludedTransactionsTable";
|
||||
import { ExcludedBankTransactionsTableBoot } from "../ExcludedTransactions/ExcludedTransactionsTableBoot";
|
||||
import { AccountTransactionsCard } from "./AccountTransactionsCard";
|
||||
// @ts-nocheck
|
||||
import { useEffect } from 'react';
|
||||
import * as R from 'ramda';
|
||||
import {
|
||||
WithBankingActionsProps,
|
||||
withBankingActions,
|
||||
} from '../../withBankingActions';
|
||||
import { ExcludedTransactionsTable } from '../ExcludedTransactions/ExcludedTransactionsTable';
|
||||
import { ExcludedBankTransactionsTableBoot } from '../ExcludedTransactions/ExcludedTransactionsTableBoot';
|
||||
import { AccountTransactionsCard } from './AccountTransactionsCard';
|
||||
|
||||
interface AccountExcludedTransactionsProps extends WithBankingActionsProps {}
|
||||
|
||||
function AccountExcludedTransactionsRoot({
|
||||
// #withBankingActions
|
||||
resetExcludedTransactionsSelected,
|
||||
}: AccountExcludedTransactionsProps) {
|
||||
useEffect(
|
||||
() => () => {
|
||||
resetExcludedTransactionsSelected();
|
||||
},
|
||||
[resetExcludedTransactionsSelected],
|
||||
);
|
||||
|
||||
export function AccountExcludedTransactions() {
|
||||
return (
|
||||
<ExcludedBankTransactionsTableBoot>
|
||||
<AccountTransactionsCard>
|
||||
@@ -11,3 +30,7 @@ export function AccountExcludedTransactions() {
|
||||
</ExcludedBankTransactionsTableBoot>
|
||||
);
|
||||
}
|
||||
|
||||
export const AccountExcludedTransactions = R.compose(withBankingActions)(
|
||||
AccountExcludedTransactionsRoot,
|
||||
);
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
import * as R from 'ramda';
|
||||
import { useEffect } from 'react';
|
||||
import AccountTransactionsUncategorizedTable from '../AccountTransactionsUncategorizedTable';
|
||||
import { AccountUncategorizedTransactionsBoot } from '../AllTransactionsUncategorizedBoot';
|
||||
import { AccountTransactionsCard } from './AccountTransactionsCard';
|
||||
import {
|
||||
WithBankingActionsProps,
|
||||
withBankingActions,
|
||||
} from '../../withBankingActions';
|
||||
|
||||
interface AccountUncategorizedTransactionsAllRootProps
|
||||
extends WithBankingActionsProps {}
|
||||
|
||||
function AccountUncategorizedTransactionsAllRoot({
|
||||
resetUncategorizedTransactionsSelected,
|
||||
}: AccountUncategorizedTransactionsAllRootProps) {
|
||||
useEffect(
|
||||
() => () => {
|
||||
resetUncategorizedTransactionsSelected();
|
||||
},
|
||||
[resetUncategorizedTransactionsSelected],
|
||||
);
|
||||
|
||||
export function AccountUncategorizedTransactionsAll() {
|
||||
return (
|
||||
<AccountUncategorizedTransactionsBoot>
|
||||
<AccountTransactionsCard>
|
||||
@@ -11,3 +29,7 @@ export function AccountUncategorizedTransactionsAll() {
|
||||
</AccountUncategorizedTransactionsBoot>
|
||||
);
|
||||
}
|
||||
|
||||
export const AccountUncategorizedTransactionsAll = R.compose(
|
||||
withBankingActions,
|
||||
)(AccountUncategorizedTransactionsAllRoot);
|
||||
|
||||
@@ -13,6 +13,11 @@ export const withBanking = (mapState) => {
|
||||
|
||||
reconcileMatchingTransactionPendingAmount:
|
||||
state.plaid.openReconcileMatchingTransaction.pending,
|
||||
|
||||
uncategorizedTransationsIdsSelected:
|
||||
state.plaid.uncategorizedTransactionsSelected,
|
||||
|
||||
excludedTransactionsIdsSelected: state.plaid.excludedTransactionsSelected,
|
||||
};
|
||||
return mapState ? mapState(mapped, state, props) : mapped;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
setUncategorizedTransactionIdForMatching,
|
||||
openReconcileMatchingTransaction,
|
||||
closeReconcileMatchingTransaction,
|
||||
setUncategorizedTransactionsSelected,
|
||||
resetUncategorizedTransactionsSelected,
|
||||
resetExcludedTransactionsSelected,
|
||||
setExcludedTransactionsSelected,
|
||||
} from '@/store/banking/banking.reducer';
|
||||
|
||||
export interface WithBankingActionsProps {
|
||||
@@ -13,6 +17,12 @@ export interface WithBankingActionsProps {
|
||||
) => void;
|
||||
openReconcileMatchingTransaction: (pendingAmount: number) => void;
|
||||
closeReconcileMatchingTransaction: () => void;
|
||||
|
||||
setUncategorizedTransactionsSelected: (ids: Array<string | number>) => void;
|
||||
resetUncategorizedTransactionsSelected: () => void;
|
||||
|
||||
setExcludedTransactionsSelected: (ids: Array<string | number>) => void;
|
||||
resetExcludedTransactionsSelected: () => void;
|
||||
}
|
||||
|
||||
const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
|
||||
@@ -28,6 +38,40 @@ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
|
||||
dispatch(openReconcileMatchingTransaction({ pending: pendingAmount })),
|
||||
closeReconcileMatchingTransaction: () =>
|
||||
dispatch(closeReconcileMatchingTransaction()),
|
||||
|
||||
/**
|
||||
* Sets the selected uncategorized transactions.
|
||||
* @param {Array<string | number>} ids
|
||||
*/
|
||||
setUncategorizedTransactionsSelected: (ids: Array<string | number>) =>
|
||||
dispatch(
|
||||
setUncategorizedTransactionsSelected({
|
||||
transactionIds: ids,
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Resets the selected uncategorized transactions.
|
||||
*/
|
||||
resetUncategorizedTransactionsSelected: () =>
|
||||
dispatch(resetUncategorizedTransactionsSelected()),
|
||||
|
||||
/**
|
||||
* Sets excluded selected transactions.
|
||||
* @param {Array<string | number>} ids
|
||||
*/
|
||||
setExcludedTransactionsSelected: (ids: Array<string | number>) =>
|
||||
dispatch(
|
||||
setExcludedTransactionsSelected({
|
||||
ids,
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Resets the excluded selected transactions
|
||||
*/
|
||||
resetExcludedTransactionsSelected: () =>
|
||||
dispatch(resetExcludedTransactionsSelected()),
|
||||
});
|
||||
|
||||
export const withBankingActions = connect<
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
.root{
|
||||
margin: 0 auto;
|
||||
padding: 0 40px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.periodSwitch {
|
||||
|
||||
@@ -6,7 +6,7 @@ export function SubscriptionPlans() {
|
||||
const subscriptionPlans = useSubscriptionPlans();
|
||||
|
||||
return (
|
||||
<Group spacing={10} noWrap align="stretch">
|
||||
<Group spacing={14} noWrap align="stretch">
|
||||
{subscriptionPlans.map((plan, index) => (
|
||||
<SubscriptionPlan
|
||||
key={index}
|
||||
|
||||
@@ -61,6 +61,76 @@ 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<
|
||||
DisconnectBankAccountRes,
|
||||
Error,
|
||||
DisconnectBankAccountValues
|
||||
>,
|
||||
): UseMutationResult<
|
||||
DisconnectBankAccountRes,
|
||||
Error,
|
||||
DisconnectBankAccountValues
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
const apiRequest = useApiRequest();
|
||||
|
||||
return useMutation<
|
||||
DisconnectBankAccountRes,
|
||||
Error,
|
||||
DisconnectBankAccountValues
|
||||
>(
|
||||
({ bankAccountId }) =>
|
||||
apiRequest.post(`/banking/bank_accounts/${bankAccountId}/disconnect`),
|
||||
{
|
||||
...options,
|
||||
onSuccess: (res, values) => {
|
||||
queryClient.invalidateQueries([t.ACCOUNT, values.bankAccountId]);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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: () => {},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
interface EditBankRuleValues {
|
||||
id: number;
|
||||
value: any;
|
||||
@@ -195,6 +265,20 @@ export function useGetBankTransactionsMatches(
|
||||
);
|
||||
}
|
||||
|
||||
const onValidateExcludeUncategorizedTransaction = (queryClient) => {
|
||||
// Invalidate queries.
|
||||
queryClient.invalidateQueries(QUERY_KEY.EXCLUDED_BANK_TRANSACTIONS_INFINITY);
|
||||
queryClient.invalidateQueries(
|
||||
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
|
||||
);
|
||||
// Invalidate accounts.
|
||||
queryClient.invalidateQueries(t.ACCOUNTS);
|
||||
queryClient.invalidateQueries(t.ACCOUNT);
|
||||
|
||||
// invalidate bank account summary.
|
||||
queryClient.invalidateQueries(QUERY_KEY.BANK_ACCOUNT_SUMMARY_META);
|
||||
};
|
||||
|
||||
type ExcludeUncategorizedTransactionValue = number;
|
||||
|
||||
interface ExcludeUncategorizedTransactionRes {}
|
||||
@@ -228,19 +312,7 @@ export function useExcludeUncategorizedTransaction(
|
||||
),
|
||||
{
|
||||
onSuccess: (res, id) => {
|
||||
// Invalidate queries.
|
||||
queryClient.invalidateQueries(
|
||||
QUERY_KEY.EXCLUDED_BANK_TRANSACTIONS_INFINITY,
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
|
||||
);
|
||||
// Invalidate accounts.
|
||||
queryClient.invalidateQueries(t.ACCOUNTS);
|
||||
queryClient.invalidateQueries(t.ACCOUNT);
|
||||
|
||||
// invalidate bank account summary.
|
||||
queryClient.invalidateQueries(QUERY_KEY.BANK_ACCOUNT_SUMMARY_META);
|
||||
onValidateExcludeUncategorizedTransaction(queryClient);
|
||||
},
|
||||
...options,
|
||||
},
|
||||
@@ -281,19 +353,83 @@ export function useUnexcludeUncategorizedTransaction(
|
||||
),
|
||||
{
|
||||
onSuccess: (res, id) => {
|
||||
// Invalidate queries.
|
||||
queryClient.invalidateQueries(
|
||||
QUERY_KEY.EXCLUDED_BANK_TRANSACTIONS_INFINITY,
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
|
||||
);
|
||||
// Invalidate accounts.
|
||||
queryClient.invalidateQueries(t.ACCOUNTS);
|
||||
queryClient.invalidateQueries(t.ACCOUNT);
|
||||
onValidateExcludeUncategorizedTransaction(queryClient);
|
||||
},
|
||||
...options,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate bank account summary.
|
||||
queryClient.invalidateQueries(QUERY_KEY.BANK_ACCOUNT_SUMMARY_META);
|
||||
type ExcludeBankTransactionsValue = { ids: Array<number | string> };
|
||||
interface ExcludeBankTransactionsResponse {}
|
||||
|
||||
/**
|
||||
* Excludes the uncategorized bank transactions in bulk.
|
||||
* @param {UseMutationResult<ExcludeBankTransactionsResponse, Error, ExcludeBankTransactionValue>} options
|
||||
* @returns {UseMutationResult<ExcludeBankTransactionsResponse, Error, ExcludeBankTransactionValue>}
|
||||
*/
|
||||
export function useExcludeUncategorizedTransactions(
|
||||
options?: UseMutationOptions<
|
||||
ExcludeBankTransactionsResponse,
|
||||
Error,
|
||||
ExcludeBankTransactionsValue
|
||||
>,
|
||||
): UseMutationResult<
|
||||
ExcludeBankTransactionsResponse,
|
||||
Error,
|
||||
ExcludeBankTransactionsValue
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
const apiRequest = useApiRequest();
|
||||
|
||||
return useMutation<
|
||||
ExcludeBankTransactionsResponse,
|
||||
Error,
|
||||
ExcludeBankTransactionsValue
|
||||
>(
|
||||
(value: { ids: Array<number | string> }) =>
|
||||
apiRequest.put(`/cashflow/transactions/exclude`, { ids: value.ids }),
|
||||
{
|
||||
onSuccess: (res, id) => {
|
||||
onValidateExcludeUncategorizedTransaction(queryClient);
|
||||
},
|
||||
...options,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
type UnexcludeBankTransactionsValue = { ids: Array<number | string> };
|
||||
interface UnexcludeBankTransactionsResponse {}
|
||||
|
||||
/**
|
||||
* Excludes the uncategorized bank transactions in bulk.
|
||||
* @param {UseMutationResult<UnexcludeBankTransactionsResponse, Error, ExcludeBankTransactionValue>} options
|
||||
* @returns {UseMutationResult<UnexcludeBankTransactionsResponse, Error, ExcludeBankTransactionValue>}
|
||||
*/
|
||||
export function useUnexcludeUncategorizedTransactions(
|
||||
options?: UseMutationOptions<
|
||||
UnexcludeBankTransactionsResponse,
|
||||
Error,
|
||||
UnexcludeBankTransactionsValue
|
||||
>,
|
||||
): UseMutationResult<
|
||||
UnexcludeBankTransactionsResponse,
|
||||
Error,
|
||||
UnexcludeBankTransactionsValue
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
const apiRequest = useApiRequest();
|
||||
|
||||
return useMutation<
|
||||
UnexcludeBankTransactionsResponse,
|
||||
Error,
|
||||
UnexcludeBankTransactionsValue
|
||||
>(
|
||||
(value: { ids: Array<number | string> }) =>
|
||||
apiRequest.put(`/cashflow/transactions/unexclude`, { ids: value.ids }),
|
||||
{
|
||||
onSuccess: (res, id) => {
|
||||
onValidateExcludeUncategorizedTransaction(queryClient);
|
||||
},
|
||||
...options,
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,6 +5,9 @@ interface StorePlaidState {
|
||||
openMatchingTransactionAside: boolean;
|
||||
uncategorizedTransactionIdForMatching: number | null;
|
||||
openReconcileMatchingTransaction: { isOpen: boolean; pending: number };
|
||||
|
||||
uncategorizedTransactionsSelected: Array<number | string>;
|
||||
excludedTransactionsSelected: Array<number | string>;
|
||||
}
|
||||
|
||||
export const PlaidSlice = createSlice({
|
||||
@@ -17,6 +20,8 @@ export const PlaidSlice = createSlice({
|
||||
isOpen: false,
|
||||
pending: 0,
|
||||
},
|
||||
uncategorizedTransactionsSelected: [],
|
||||
excludedTransactionsSelected: [],
|
||||
} as StorePlaidState,
|
||||
reducers: {
|
||||
setPlaidId: (state: StorePlaidState, action: PayloadAction<string>) => {
|
||||
@@ -52,6 +57,46 @@ export const PlaidSlice = createSlice({
|
||||
state.openReconcileMatchingTransaction.isOpen = false;
|
||||
state.openReconcileMatchingTransaction.pending = 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets the selected uncategorized transactions.
|
||||
* @param {StorePlaidState} state
|
||||
* @param {PayloadAction<{ transactionIds: Array<string | number> }>} action
|
||||
*/
|
||||
setUncategorizedTransactionsSelected: (
|
||||
state: StorePlaidState,
|
||||
action: PayloadAction<{ transactionIds: Array<string | number> }>,
|
||||
) => {
|
||||
state.uncategorizedTransactionsSelected = action.payload.transactionIds;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resets the selected uncategorized transactions.
|
||||
* @param {StorePlaidState} state
|
||||
*/
|
||||
resetUncategorizedTransactionsSelected: (state: StorePlaidState) => {
|
||||
state.uncategorizedTransactionsSelected = [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets excluded selected transactions.
|
||||
* @param {StorePlaidState} state
|
||||
* @param {PayloadAction<{ ids: Array<string | number> }>} action
|
||||
*/
|
||||
setExcludedTransactionsSelected: (
|
||||
state: StorePlaidState,
|
||||
action: PayloadAction<{ ids: Array<string | number> }>,
|
||||
) => {
|
||||
state.excludedTransactionsSelected = action.payload.ids;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resets the excluded selected transactions
|
||||
* @param {StorePlaidState} state
|
||||
*/
|
||||
resetExcludedTransactionsSelected: (state: StorePlaidState) => {
|
||||
state.excludedTransactionsSelected = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -62,6 +107,10 @@ export const {
|
||||
closeMatchingTransactionAside,
|
||||
openReconcileMatchingTransaction,
|
||||
closeReconcileMatchingTransaction,
|
||||
setUncategorizedTransactionsSelected,
|
||||
resetUncategorizedTransactionsSelected,
|
||||
setExcludedTransactionsSelected,
|
||||
resetExcludedTransactionsSelected,
|
||||
} = PlaidSlice.actions;
|
||||
|
||||
export const getPlaidToken = (state: any) => state.plaid.plaidToken;
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
@import "section";
|
||||
|
||||
.App {
|
||||
min-width: 1100px;
|
||||
min-width: 960px;
|
||||
}
|
||||
|
||||
// =======
|
||||
|
||||
@@ -124,22 +124,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
.bp4-control.bp4-checkbox .bp4-control-indicator {
|
||||
.bp4-control.bp4-checkbox .bp4-control-indicator {
|
||||
cursor: auto;
|
||||
|
||||
&,
|
||||
&:hover {
|
||||
&::before {
|
||||
height: 15px;
|
||||
width: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.bp4-control.bp4-checkbox {
|
||||
|
||||
input:checked~.bp4-control-indicator,
|
||||
input:indeterminate~.bp4-control-indicator {
|
||||
border-color: #0052ff;
|
||||
}
|
||||
.bp4-control.bp4-checkbox input:not(:checked):not(:indeterminate) ~ .bp4-control-indicator{
|
||||
box-shadow: inset 0 0 0 1px #C5CBD3;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
|
||||
@@ -208,12 +208,16 @@ $dashboard-views-bar-height: 44px;
|
||||
}
|
||||
|
||||
&.#{$ns}-minimal.#{$ns}-intent-danger {
|
||||
color: #c23030;
|
||||
color: rgb(194, 48, 48);
|
||||
|
||||
&:not(.bp4-disabled)
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: rgba(219, 55, 55, 0.1);
|
||||
}
|
||||
&.bp4-disabled{
|
||||
color: rgb(194, 48, 48, 0.6);
|
||||
}
|
||||
}
|
||||
&.#{$ns}-minimal.#{$ns}-intent-success{
|
||||
color: #1c6e42;
|
||||
|
||||
@@ -1,25 +1,36 @@
|
||||
@import "../../_base.scss";
|
||||
|
||||
.setup-page {
|
||||
display: grid;
|
||||
grid-template-columns: 40% 60%;
|
||||
max-width: 1600px;
|
||||
min-height: 100vh;
|
||||
|
||||
@media only screen and (max-width: 1500px) {
|
||||
grid-template-columns: 35% 65%;
|
||||
}
|
||||
@media only screen and (max-width: 1200px) {
|
||||
grid-template-columns: 26% 74%;
|
||||
}
|
||||
|
||||
&__right-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
max-width: 600px;
|
||||
min-width: 600px;
|
||||
|
||||
@media only screen and (max-width: 1500px) {
|
||||
min-width: 500px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 1024px) {
|
||||
min-width: 400px;
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h3 {
|
||||
font-weight: 500;
|
||||
@@ -33,9 +44,22 @@
|
||||
}
|
||||
|
||||
&__left-section {
|
||||
position: fixed;
|
||||
background-color: #2f3d6f;
|
||||
overflow: auto;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 600px;
|
||||
|
||||
@media only screen and (max-width: 1500px) {
|
||||
width: 500px;
|
||||
}
|
||||
@media only screen and (max-width: 1024px) {
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
@@ -52,11 +76,6 @@
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
&__header{
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 50px;
|
||||
font-weight: 100;
|
||||
@@ -65,8 +84,8 @@
|
||||
margin-top: 14px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
|
||||
@media only screen and (max-width: 1200px) {
|
||||
font-size: 36px;
|
||||
@media only screen and (max-width: 1024px) {
|
||||
font-size: 45px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,20 +111,16 @@
|
||||
|
||||
&__footer {
|
||||
margin-top: auto;
|
||||
position: sticky;
|
||||
bottom: 20px;
|
||||
}
|
||||
|
||||
&__links {
|
||||
text-align: left;
|
||||
opacity: 0.65;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
>div {
|
||||
font-size: 13px;
|
||||
margin-right: 15px;
|
||||
display: inline;
|
||||
|
||||
a {
|
||||
color: #fff;
|
||||
|
||||
Reference in New Issue
Block a user