Merge branch 'develop' into billing-subscription-page

This commit is contained in:
Ahmed Bouhuolia
2024-07-30 10:03:31 +02:00
87 changed files with 1779 additions and 279 deletions

View File

@@ -3,12 +3,16 @@ import { NextFunction, Request, Response, Router } from 'express';
import BaseController from '@/api/controllers/BaseController'; import BaseController from '@/api/controllers/BaseController';
import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; import { CashflowApplication } from '@/services/Cashflow/CashflowApplication';
import { GetBankAccountSummary } from '@/services/Banking/BankAccounts/GetBankAccountSummary'; import { GetBankAccountSummary } from '@/services/Banking/BankAccounts/GetBankAccountSummary';
import { BankAccountsApplication } from '@/services/Banking/BankAccounts/BankAccountsApplication';
@Service() @Service()
export class BankAccountsController extends BaseController { export class BankAccountsController extends BaseController {
@Inject() @Inject()
private getBankAccountSummaryService: GetBankAccountSummary; private getBankAccountSummaryService: GetBankAccountSummary;
@Inject()
private bankAccountsApp: BankAccountsApplication;
/** /**
* Router constructor. * Router constructor.
*/ */
@@ -16,6 +20,11 @@ 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.post(
'/:bankAccountId/disconnect',
this.disconnectBankAccount.bind(this)
);
router.post('/:bankAccountId/update', this.refreshBankAccount.bind(this));
return router; return router;
} }
@@ -46,4 +55,58 @@ export class BankAccountsController extends BaseController {
next(error); 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);
}
}
} }

View File

@@ -1,8 +1,9 @@
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import { param } from 'express-validator'; import { body, param, query } from 'express-validator';
import { NextFunction, Request, Response, Router, query } from 'express'; import { NextFunction, Request, Response, Router } from 'express';
import BaseController from '../BaseController'; import BaseController from '../BaseController';
import { ExcludeBankTransactionsApplication } from '@/services/Banking/Exclude/ExcludeBankTransactionsApplication'; import { ExcludeBankTransactionsApplication } from '@/services/Banking/Exclude/ExcludeBankTransactionsApplication';
import { map, parseInt, trim } from 'lodash';
@Service() @Service()
export class ExcludeBankTransactionsController extends BaseController { export class ExcludeBankTransactionsController extends BaseController {
@@ -15,9 +16,21 @@ export class ExcludeBankTransactionsController extends BaseController {
public router() { public router() {
const router = 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( router.put(
'/transactions/:transactionId/exclude', '/transactions/:transactionId/exclude',
[param('transactionId').exists()], [param('transactionId').exists().toInt()],
this.validationResult, this.validationResult,
this.excludeBankTransaction.bind(this) 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. * Retrieves the excluded uncategorized bank transactions.
* @param {Request} req * @param {Request} req
@@ -109,7 +179,6 @@ export class ExcludeBankTransactionsController extends BaseController {
const { tenantId } = req; const { tenantId } = req;
const filter = this.matchedBodyData(req); const filter = this.matchedBodyData(req);
console.log('123');
try { try {
const data = const data =
await this.excludeBankTransactionApp.getExcludedBankTransactions( await this.excludeBankTransactionApp.getExcludedBankTransactions(

View File

@@ -111,6 +111,7 @@ export default class BillsPayments extends BaseController {
check('vendor_id').exists().isNumeric().toInt(), check('vendor_id').exists().isNumeric().toInt(),
check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(), check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(),
check('amount').exists().isNumeric().toFloat(),
check('payment_account_id').exists().isNumeric().toInt(), check('payment_account_id').exists().isNumeric().toInt(),
check('payment_number').optional({ nullable: true }).trim().escape(), check('payment_number').optional({ nullable: true }).trim().escape(),
check('payment_date').exists(), check('payment_date').exists(),
@@ -118,7 +119,7 @@ export default class BillsPayments extends BaseController {
check('reference').optional().trim().escape(), check('reference').optional().trim().escape(),
check('branch_id').optional({ nullable: true }).isNumeric().toInt(), check('branch_id').optional({ nullable: true }).isNumeric().toInt(),
check('entries').exists().isArray({ min: 1 }), check('entries').exists().isArray(),
check('entries.*.index').optional().isNumeric().toInt(), check('entries.*.index').optional().isNumeric().toInt(),
check('entries.*.bill_id').exists().isNumeric().toInt(), check('entries.*.bill_id').exists().isNumeric().toInt(),
check('entries.*.payment_amount').exists().isNumeric().toFloat(), check('entries.*.payment_amount').exists().isNumeric().toFloat(),

View File

@@ -150,6 +150,7 @@ export default class PaymentReceivesController extends BaseController {
check('customer_id').exists().isNumeric().toInt(), check('customer_id').exists().isNumeric().toInt(),
check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(), check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(),
check('amount').exists().isNumeric().toFloat(),
check('payment_date').exists(), check('payment_date').exists(),
check('reference_no').optional(), check('reference_no').optional(),
check('deposit_account_id').exists().isNumeric().toInt(), check('deposit_account_id').exists().isNumeric().toInt(),
@@ -158,8 +159,7 @@ export default class PaymentReceivesController extends BaseController {
check('branch_id').optional({ nullable: true }).isNumeric().toInt(), check('branch_id').optional({ nullable: true }).isNumeric().toInt(),
check('entries').isArray({ min: 1 }), check('entries').isArray({}),
check('entries.*.id').optional({ nullable: true }).isNumeric().toInt(), check('entries.*.id').optional({ nullable: true }).isNumeric().toInt(),
check('entries.*.index').optional().isNumeric().toInt(), check('entries.*.index').optional().isNumeric().toInt(),
check('entries.*.invoice_id').exists().isNumeric().toInt(), check('entries.*.invoice_id').exists().isNumeric().toInt(),

View File

@@ -236,5 +236,13 @@ module.exports = {
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
endpoint: process.env.S3_ENDPOINT, endpoint: process.env.S3_ENDPOINT,
bucket: process.env.S3_BUCKET || 'bigcapital-documents', bucket: process.env.S3_BUCKET || 'bigcapital-documents',
forcePathStyle: parseBoolean(
defaultTo(process.env.S3_FORCE_PATH_STYLE, false),
false
),
},
loops: {
apiKey: process.env.LOOPS_API_KEY,
}, },
}; };

View File

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

View File

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

View File

@@ -12,8 +12,7 @@ export default class SeedAccounts extends TenantSeeder {
description: this.i18n.__(account.description), description: this.i18n.__(account.description),
currencyCode: this.tenant.metadata.baseCurrency, currencyCode: this.tenant.metadata.baseCurrency,
seededAt: new Date(), seededAt: new Date(),
}) }));
);
return knex('accounts').then(async () => { return knex('accounts').then(async () => {
// Inserts seed entries. // Inserts seed entries.
return knex('accounts').insert(data); return knex('accounts').insert(data);

View File

@@ -9,6 +9,28 @@ export const TaxPayableAccount = {
predefined: 1, predefined: 1,
}; };
export const UnearnedRevenueAccount = {
name: 'Unearned Revenue',
slug: 'unearned-revenue',
account_type: 'other-current-liability',
parent_account_id: null,
code: '50005',
active: true,
index: 1,
predefined: true,
};
export const PrepardExpenses = {
name: 'Prepaid Expenses',
slug: 'prepaid-expenses',
account_type: 'other-current-asset',
parent_account_id: null,
code: '100010',
active: true,
index: 1,
predefined: true,
};
export default [ export default [
{ {
name: 'Bank Account', name: 'Bank Account',
@@ -323,4 +345,6 @@ export default [
index: 1, index: 1,
predefined: 0, predefined: 0,
}, },
UnearnedRevenueAccount,
PrepardExpenses,
]; ];

View File

@@ -15,6 +15,7 @@ export interface IAccountDTO {
export interface IAccountCreateDTO extends IAccountDTO { export interface IAccountCreateDTO extends IAccountDTO {
currencyCode?: string; currencyCode?: string;
plaidAccountId?: string; plaidAccountId?: string;
plaidItemId?: string;
} }
export interface IAccountEditDTO extends IAccountDTO {} export interface IAccountEditDTO extends IAccountDTO {}
@@ -37,6 +38,8 @@ export interface IAccount {
accountNormal: string; accountNormal: string;
accountParentType: string; accountParentType: string;
bankBalance: string; bankBalance: string;
plaidItemId: number | null
lastFeedsUpdatedAt: Date;
} }
export enum AccountNormal { export enum AccountNormal {

View File

@@ -40,7 +40,7 @@ export interface ILedgerEntry {
date: Date | string; date: Date | string;
transactionType: string; transactionType: string;
transactionSubType: string; transactionSubType?: string;
transactionId: number; transactionId: number;

View File

@@ -1,69 +1,12 @@
import { forEach } from 'lodash';
import { Configuration, PlaidApi, PlaidEnvironments } from 'plaid'; import { Configuration, PlaidApi, PlaidEnvironments } from 'plaid';
import { createPlaidApiEvent } from './PlaidApiEventsDBSync';
import config from '@/config'; 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. // Wrapper for the Plaid client. This allows us to easily log data for all Plaid client requests.
export class PlaidClientWrapper { export class PlaidClientWrapper {
constructor() { private static instance: PlaidClientWrapper;
private client: PlaidApi;
private constructor() {
// Initialize the Plaid client. // Initialize the Plaid client.
const configuration = new Configuration({ const configuration = new Configuration({
basePath: PlaidEnvironments[config.plaid.env], basePath: PlaidEnvironments[config.plaid.env],
@@ -75,26 +18,13 @@ export class PlaidClientWrapper {
}, },
}, },
}); });
this.client = new PlaidApi(configuration); 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. public static getClient(): PlaidApi {
createWrappedClientMethod(clientMethod, log) { if (!PlaidClientWrapper.instance) {
return async (...args) => { PlaidClientWrapper.instance = new PlaidClientWrapper();
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;
} }
}; return PlaidClientWrapper.instance.client;
} }
} }

View File

@@ -8,4 +8,5 @@ export const s3 = new S3Client({
secretAccessKey: config.s3.secretAccessKey, secretAccessKey: config.s3.secretAccessKey,
}, },
endpoint: config.s3.endpoint, endpoint: config.s3.endpoint,
forcePathStyle: config.s3.forcePathStyle,
}); });

View File

@@ -113,6 +113,8 @@ import { UnlinkBankRuleOnDeleteBankRule } from '@/services/Banking/Rules/events/
import { DecrementUncategorizedTransactionOnMatching } from '@/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch'; import { DecrementUncategorizedTransactionOnMatching } from '@/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch';
import { DecrementUncategorizedTransactionOnExclude } from '@/services/Banking/Exclude/events/DecrementUncategorizedTransactionOnExclude'; import { DecrementUncategorizedTransactionOnExclude } from '@/services/Banking/Exclude/events/DecrementUncategorizedTransactionOnExclude';
import { DecrementUncategorizedTransactionOnCategorize } from '@/services/Cashflow/subscribers/DecrementUncategorizedTransactionOnCategorize'; import { DecrementUncategorizedTransactionOnCategorize } from '@/services/Cashflow/subscribers/DecrementUncategorizedTransactionOnCategorize';
import { DisconnectPlaidItemOnAccountDeleted } from '@/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted';
import { LoopsEventsSubscriber } from '@/services/Loops/LoopsEventsSubscriber';
export default () => { export default () => {
return new EventPublisher(); return new EventPublisher();
@@ -274,5 +276,9 @@ export const susbcribers = () => {
// Plaid // Plaid
RecognizeSyncedBankTranasctions, RecognizeSyncedBankTranasctions,
DisconnectPlaidItemOnAccountDeleted,
// Loops
LoopsEventsSubscriber
]; ];
}; };

View File

@@ -197,6 +197,7 @@ export default class Account extends mixin(TenantModel, [
const ExpenseEntry = require('models/ExpenseCategory'); const ExpenseEntry = require('models/ExpenseCategory');
const ItemEntry = require('models/ItemEntry'); const ItemEntry = require('models/ItemEntry');
const UncategorizedTransaction = require('models/UncategorizedCashflowTransaction'); const UncategorizedTransaction = require('models/UncategorizedCashflowTransaction');
const PlaidItem = require('models/PlaidItem');
return { return {
/** /**
@@ -321,6 +322,18 @@ export default class Account extends mixin(TenantModel, [
query.where('categorized', false); 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',
},
},
}; };
} }

View File

@@ -525,9 +525,9 @@ export default class Bill extends mixin(TenantModel, [
return notFoundBillsIds; return notFoundBillsIds;
} }
static changePaymentAmount(billId, amount) { static changePaymentAmount(billId, amount, trx) {
const changeMethod = amount > 0 ? 'increment' : 'decrement'; const changeMethod = amount > 0 ? 'increment' : 'decrement';
return this.query() return this.query(trx)
.where('id', billId) .where('id', billId)
[changeMethod]('payment_amount', Math.abs(amount)); [changeMethod]('payment_amount', Math.abs(amount));
} }

View File

@@ -2,7 +2,12 @@ import { Account } from 'models';
import TenantRepository from '@/repositories/TenantRepository'; import TenantRepository from '@/repositories/TenantRepository';
import { IAccount } from '@/interfaces'; import { IAccount } from '@/interfaces';
import { Knex } from 'knex'; import { Knex } from 'knex';
import { TaxPayableAccount } from '@/database/seeds/data/accounts'; import {
PrepardExpenses,
TaxPayableAccount,
UnearnedRevenueAccount,
} from '@/database/seeds/data/accounts';
import { TenantMetadata } from '@/system/models';
export default class AccountRepository extends TenantRepository { export default class AccountRepository extends TenantRepository {
/** /**
@@ -179,4 +184,67 @@ export default class AccountRepository extends TenantRepository {
} }
return result; return result;
}; };
/**
* Finds or creates the unearned revenue.
* @param {Record<string, string>} extraAttrs
* @param {Knex.Transaction} trx
* @returns
*/
public async findOrCreateUnearnedRevenue(
extraAttrs: Record<string, string> = {},
trx?: Knex.Transaction
) {
// Retrieves the given tenant metadata.
const tenantMeta = await TenantMetadata.query().findOne({
tenantId: this.tenantId,
});
const _extraAttrs = {
currencyCode: tenantMeta.baseCurrency,
...extraAttrs,
};
let result = await this.model
.query(trx)
.findOne({ slug: UnearnedRevenueAccount.slug, ..._extraAttrs });
if (!result) {
result = await this.model.query(trx).insertAndFetch({
...UnearnedRevenueAccount,
..._extraAttrs,
});
}
return result;
}
/**
* Finds or creates the prepard expenses account.
* @param {Record<string, string>} extraAttrs
* @param {Knex.Transaction} trx
* @returns
*/
public async findOrCreatePrepardExpenses(
extraAttrs: Record<string, string> = {},
trx?: Knex.Transaction
) {
// Retrieves the given tenant metadata.
const tenantMeta = await TenantMetadata.query().findOne({
tenantId: this.tenantId,
});
const _extraAttrs = {
currencyCode: tenantMeta.baseCurrency,
...extraAttrs,
};
let result = await this.model
.query(trx)
.findOne({ slug: PrepardExpenses.slug, ..._extraAttrs });
if (!result) {
result = await this.model.query(trx).insertAndFetch({
...PrepardExpenses,
..._extraAttrs,
});
}
return result;
}
} }

View File

@@ -4,6 +4,7 @@ import CachableRepository from './CachableRepository';
export default class TenantRepository extends CachableRepository { export default class TenantRepository extends CachableRepository {
repositoryName: string; repositoryName: string;
tenantId: number;
/** /**
* Constructor method. * Constructor method.
@@ -12,4 +13,8 @@ export default class TenantRepository extends CachableRepository {
constructor(knex, cache, i18n) { constructor(knex, cache, i18n) {
super(knex, cache, i18n); super(knex, cache, i18n);
} }
setTenantId(tenantId: number) {
this.tenantId = tenantId;
}
} }

View File

@@ -13,7 +13,12 @@ export class AccountTransformer extends Transformer {
* @returns {Array} * @returns {Array}
*/ */
public includeAttributes = (): string[] => { 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. * Transformes the accounts collection to flat or nested array.
* @param {IAccount[]} * @param {IAccount[]}

View File

@@ -96,6 +96,11 @@ export class CreateAccount {
...createAccountDTO, ...createAccountDTO,
slug: kebabCase(createAccountDTO.name), slug: kebabCase(createAccountDTO.name),
currencyCode: createAccountDTO.currencyCode || baseCurrency, 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 }); const tenantMeta = await TenantMetadata.query().findOne({ tenantId });
// Authorize the account creation. // Authorize the account creation.
await this.authorize( await this.authorize(tenantId, accountDTO, tenantMeta.baseCurrency, params);
tenantId,
accountDTO,
tenantMeta.baseCurrency,
params
);
// Transformes the DTO to model. // Transformes the DTO to model.
const accountInputModel = this.transformDTOToModel( const accountInputModel = this.transformDTOToModel(
accountDTO, accountDTO,
@@ -157,4 +157,3 @@ export class CreateAccount {
); );
}; };
} }

View File

@@ -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
);
}
}

View File

@@ -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);
});
}
}

View File

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

View File

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

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

View File

@@ -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
);
});
}
}

View File

@@ -3,6 +3,8 @@ import { ExcludeBankTransaction } from './ExcludeBankTransaction';
import { UnexcludeBankTransaction } from './UnexcludeBankTransaction'; import { UnexcludeBankTransaction } from './UnexcludeBankTransaction';
import { GetExcludedBankTransactionsService } from './GetExcludedBankTransactions'; import { GetExcludedBankTransactionsService } from './GetExcludedBankTransactions';
import { ExcludedBankTransactionsQuery } from './_types'; import { ExcludedBankTransactionsQuery } from './_types';
import { UnexcludeBankTransactions } from './UnexcludeBankTransactions';
import { ExcludeBankTransactions } from './ExcludeBankTransactions';
@Service() @Service()
export class ExcludeBankTransactionsApplication { export class ExcludeBankTransactionsApplication {
@@ -15,6 +17,12 @@ export class ExcludeBankTransactionsApplication {
@Inject() @Inject()
private getExcludedBankTransactionsService: GetExcludedBankTransactionsService; private getExcludedBankTransactionsService: GetExcludedBankTransactionsService;
@Inject()
private excludeBankTransactionsService: ExcludeBankTransactions;
@Inject()
private unexcludeBankTransactionsService: UnexcludeBankTransactions;
/** /**
* Marks a bank transaction as excluded. * Marks a bank transaction as excluded.
* @param {number} tenantId - The ID of the tenant. * @param {number} tenantId - The ID of the tenant.
@@ -56,4 +64,36 @@ export class ExcludeBankTransactionsApplication {
filter 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
);
}
} }

View File

@@ -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
);
});
}
}

View File

@@ -28,7 +28,7 @@ export class PlaidItemService {
const { PlaidItem } = this.tenancy.models(tenantId); const { PlaidItem } = this.tenancy.models(tenantId);
const { publicToken, institutionId } = itemDTO; 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. // Exchange the public token for a private access token and store with the item.
const response = await plaidInstance.itemPublicTokenExchange({ const response = await plaidInstance.itemPublicTokenExchange({

View File

@@ -26,7 +26,7 @@ export class PlaidLinkTokenService {
webhook: config.plaid.linkWebhook, webhook: config.plaid.linkWebhook,
access_token: accessToken, access_token: accessToken,
}; };
const plaidInstance = new PlaidClientWrapper(); const plaidInstance = PlaidClientWrapper.getClient();
const createResponse = await plaidInstance.linkTokenCreate(linkTokenParams); const createResponse = await plaidInstance.linkTokenCreate(linkTokenParams);
return createResponse.data; return createResponse.data;

View File

@@ -2,6 +2,11 @@ import * as R from 'ramda';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import bluebird from 'bluebird'; import bluebird from 'bluebird';
import { entries, groupBy } from 'lodash'; import { entries, groupBy } from 'lodash';
import {
AccountBase as PlaidAccountBase,
Item as PlaidItem,
Institution as PlaidInstitution,
} from 'plaid';
import { CreateAccount } from '@/services/Accounts/CreateAccount'; import { CreateAccount } from '@/services/Accounts/CreateAccount';
import { import {
IAccountCreateDTO, IAccountCreateDTO,
@@ -53,6 +58,7 @@ export class PlaidSyncDb {
trx?: Knex.Transaction trx?: Knex.Transaction
) { ) {
const { Account } = this.tenancy.models(tenantId); const { Account } = this.tenancy.models(tenantId);
const plaidAccount = await Account.query().findOne( const plaidAccount = await Account.query().findOne(
'plaidAccountId', 'plaidAccountId',
createBankAccountDTO.plaidAccountId createBankAccountDTO.plaidAccountId
@@ -77,13 +83,15 @@ export class PlaidSyncDb {
*/ */
public async syncBankAccounts( public async syncBankAccounts(
tenantId: number, tenantId: number,
plaidAccounts: PlaidAccount[], plaidAccounts: PlaidAccountBase[],
institution: any, institution: PlaidInstitution,
item: PlaidItem,
trx?: Knex.Transaction trx?: Knex.Transaction
): Promise<void> { ): Promise<void> {
const transformToPlaidAccounts = const transformToPlaidAccounts = transformPlaidAccountToCreateAccount(
transformPlaidAccountToCreateAccount(institution); item,
institution
);
const accountCreateDTOs = R.map(transformToPlaidAccounts)(plaidAccounts); const accountCreateDTOs = R.map(transformToPlaidAccounts)(plaidAccounts);
await bluebird.map( await bluebird.map(

View File

@@ -53,7 +53,7 @@ export class PlaidUpdateTransactions {
await this.fetchTransactionUpdates(tenantId, plaidItemId); await this.fetchTransactionUpdates(tenantId, plaidItemId);
const request = { access_token: accessToken }; const request = { access_token: accessToken };
const plaidInstance = new PlaidClientWrapper(); const plaidInstance = PlaidClientWrapper.getClient();
const { const {
data: { accounts, item }, data: { accounts, item },
} = await plaidInstance.accountsGet(request); } = await plaidInstance.accountsGet(request);
@@ -66,7 +66,13 @@ export class PlaidUpdateTransactions {
country_codes: ['US', 'UK'], country_codes: ['US', 'UK'],
}); });
// Sync bank accounts. // Sync bank accounts.
await this.plaidSync.syncBankAccounts(tenantId, accounts, institution, trx); await this.plaidSync.syncBankAccounts(
tenantId,
accounts,
institution,
item,
trx
);
// Sync bank account transactions. // Sync bank account transactions.
await this.plaidSync.syncAccountsTransactions( await this.plaidSync.syncAccountsTransactions(
tenantId, tenantId,
@@ -141,7 +147,7 @@ export class PlaidUpdateTransactions {
cursor: cursor, cursor: cursor,
count: batchSize, count: batchSize,
}; };
const plaidInstance = new PlaidClientWrapper(); const plaidInstance = PlaidClientWrapper.getClient();
const response = await plaidInstance.transactionsSync(request); const response = await plaidInstance.transactionsSync(request);
const data = response.data; const data = response.data;
// Add this page of results // Add this page of results

View File

@@ -1,18 +1,28 @@
import * as R from 'ramda'; import * as R from 'ramda';
import {
Item as PlaidItem,
Institution as PlaidInstitution,
AccountBase as PlaidAccount,
} from 'plaid';
import { import {
CreateUncategorizedTransactionDTO, CreateUncategorizedTransactionDTO,
IAccountCreateDTO, IAccountCreateDTO,
PlaidAccount,
PlaidTransaction, PlaidTransaction,
} from '@/interfaces'; } from '@/interfaces';
/** /**
* Transformes the Plaid account to create cashflow account DTO. * Transformes the Plaid account to create cashflow account DTO.
* @param {PlaidAccount} plaidAccount * @param {PlaidItem} item -
* @param {PlaidInstitution} institution -
* @param {PlaidAccount} plaidAccount -
* @returns {IAccountCreateDTO} * @returns {IAccountCreateDTO}
*/ */
export const transformPlaidAccountToCreateAccount = R.curry( export const transformPlaidAccountToCreateAccount = R.curry(
(institution: any, plaidAccount: PlaidAccount): IAccountCreateDTO => { (
item: PlaidItem,
institution: PlaidInstitution,
plaidAccount: PlaidAccount
): IAccountCreateDTO => {
return { return {
name: `${institution.name} - ${plaidAccount.name}`, name: `${institution.name} - ${plaidAccount.name}`,
code: '', code: '',
@@ -20,9 +30,10 @@ export const transformPlaidAccountToCreateAccount = R.curry(
currencyCode: plaidAccount.balances.iso_currency_code, currencyCode: plaidAccount.balances.iso_currency_code,
accountType: 'cash', accountType: 'cash',
active: true, active: true,
plaidAccountId: plaidAccount.account_id,
bankBalance: plaidAccount.balances.current, bankBalance: plaidAccount.balances.current,
accountMask: plaidAccount.mask, accountMask: plaidAccount.mask,
plaidAccountId: plaidAccount.account_id,
plaidItemId: item.item_id,
}; };
} }
); );

View File

@@ -0,0 +1,51 @@
import axios from 'axios';
import config from '@/config';
import { IAuthSignUpVerifiedEventPayload } from '@/interfaces';
import events from '@/subscribers/events';
import { SystemUser } from '@/system/models';
export class LoopsEventsSubscriber {
/**
* Constructor method.
*/
public attach(bus) {
bus.subscribe(
events.auth.signUpConfirmed,
this.triggerEventOnSignupVerified.bind(this)
);
}
/**
* Once the user verified sends the event to the Loops.
* @param {IAuthSignUpVerifiedEventPayload} param0
*/
public async triggerEventOnSignupVerified({
email,
userId,
}: IAuthSignUpVerifiedEventPayload) {
// Can't continue since the Loops the api key is not configured.
if (!config.loops.apiKey) {
return;
}
const user = await SystemUser.query().findById(userId);
const options = {
method: 'POST',
url: 'https://app.loops.so/api/v1/events/send',
headers: {
Authorization: `Bearer ${config.loops.apiKey}`,
'Content-Type': 'application/json',
},
data: {
email,
userId,
firstName: user.firstName,
lastName: user.lastName,
eventName: 'USER_VERIFIED',
eventProperties: {},
mailingLists: {},
},
};
await axios(options);
}
}

View File

@@ -4,6 +4,7 @@ import { omit, sumBy } from 'lodash';
import { IBillPayment, IBillPaymentDTO, IVendor } from '@/interfaces'; import { IBillPayment, IBillPaymentDTO, IVendor } from '@/interfaces';
import { BranchTransactionDTOTransform } from '@/services/Branches/Integrations/BranchTransactionDTOTransform'; import { BranchTransactionDTOTransform } from '@/services/Branches/Integrations/BranchTransactionDTOTransform';
import { formatDateFields } from '@/utils'; import { formatDateFields } from '@/utils';
import HasTenancyService from '@/services/Tenancy/TenancyService';
@Service() @Service()
export class CommandBillPaymentDTOTransformer { export class CommandBillPaymentDTOTransformer {
@@ -23,11 +24,14 @@ export class CommandBillPaymentDTOTransformer {
vendor: IVendor, vendor: IVendor,
oldBillPayment?: IBillPayment oldBillPayment?: IBillPayment
): Promise<IBillPayment> { ): Promise<IBillPayment> {
const amount =
billPaymentDTO.amount ?? sumBy(billPaymentDTO.entries, 'paymentAmount');
const initialDTO = { const initialDTO = {
...formatDateFields(omit(billPaymentDTO, ['attachments']), [ ...formatDateFields(omit(billPaymentDTO, ['attachments']), [
'paymentDate', 'paymentDate',
]), ]),
amount: sumBy(billPaymentDTO.entries, 'paymentAmount'), amount,
currencyCode: vendor.currencyCode, currencyCode: vendor.currencyCode,
exchangeRate: billPaymentDTO.exchangeRate || 1, exchangeRate: billPaymentDTO.exchangeRate || 1,
entries: billPaymentDTO.entries, entries: billPaymentDTO.entries,

View File

@@ -36,7 +36,9 @@ export class PaymentReceiveDTOTransformer {
paymentReceiveDTO: IPaymentReceiveCreateDTO | IPaymentReceiveEditDTO, paymentReceiveDTO: IPaymentReceiveCreateDTO | IPaymentReceiveEditDTO,
oldPaymentReceive?: IPaymentReceive oldPaymentReceive?: IPaymentReceive
): Promise<IPaymentReceive> { ): Promise<IPaymentReceive> {
const paymentAmount = sumBy(paymentReceiveDTO.entries, 'paymentAmount'); const amount =
paymentReceiveDTO.amount ??
sumBy(paymentReceiveDTO.entries, 'paymentAmount');
// Retreive the next invoice number. // Retreive the next invoice number.
const autoNextNumber = const autoNextNumber =
@@ -54,7 +56,7 @@ export class PaymentReceiveDTOTransformer {
...formatDateFields(omit(paymentReceiveDTO, ['entries', 'attachments']), [ ...formatDateFields(omit(paymentReceiveDTO, ['entries', 'attachments']), [
'paymentDate', 'paymentDate',
]), ]),
amount: paymentAmount, amount,
currencyCode: customer.currencyCode, currencyCode: customer.currencyCode,
...(paymentReceiveNo ? { paymentReceiveNo } : {}), ...(paymentReceiveNo ? { paymentReceiveNo } : {}),
exchangeRate: paymentReceiveDTO.exchangeRate || 1, exchangeRate: paymentReceiveDTO.exchangeRate || 1,

View File

@@ -77,7 +77,12 @@ export default class HasTenancyService {
const knex = this.knex(tenantId); const knex = this.knex(tenantId);
const i18n = this.i18n(tenantId); const i18n = this.i18n(tenantId);
return tenantRepositoriesLoader(knex, cache, i18n); const repositories = tenantRepositoriesLoader(knex, cache, i18n);
Object.values(repositories).forEach((repository) => {
repository.setTenantId(tenantId);
});
return repositories;
}); });
} }

View File

@@ -47,6 +47,7 @@ export default {
onSubscriptionCanceled: 'onSubscriptionCanceled', onSubscriptionCanceled: 'onSubscriptionCanceled',
onSubscriptionResumed: 'onSubscriptionResumed', onSubscriptionResumed: 'onSubscriptionResumed',
onSubscriptionPlanChanged: 'onSubscriptionPlanChanged', onSubscriptionPlanChanged: 'onSubscriptionPlanChanged',
onSubscribed: 'onOrganizationSubscribed',
}, },
/** /**
@@ -660,6 +661,11 @@ export default {
onUnexcluded: 'onBankTransactionUnexcluded', onUnexcluded: 'onBankTransactionUnexcluded',
}, },
bankAccount: {
onDisconnecting: 'onBankAccountDisconnecting',
onDisconnected: 'onBankAccountDisconnected',
},
// Import files. // Import files.
import: { import: {
onImportCommitted: 'onImportFileCommitted', onImportCommitted: 'onImportFileCommitted',

View File

@@ -2,6 +2,6 @@
import { Position, Toaster, Intent } from '@blueprintjs/core'; import { Position, Toaster, Intent } from '@blueprintjs/core';
export const AppToaster = Toaster.create({ export const AppToaster = Toaster.create({
position: Position.RIGHT_BOTTOM, position: Position.TOP,
intent: Intent.WARNING, intent: Intent.WARNING,
}); });

View File

@@ -4,9 +4,9 @@ export const ACCOUNT_TYPE = {
BANK: 'bank', BANK: 'bank',
ACCOUNTS_RECEIVABLE: 'accounts-receivable', ACCOUNTS_RECEIVABLE: 'accounts-receivable',
INVENTORY: 'inventory', INVENTORY: 'inventory',
OTHER_CURRENT_ASSET: 'other-ACCOUNT_PARENT_TYPE.CURRENT_ASSET', OTHER_CURRENT_ASSET: 'other-current-asset',
FIXED_ASSET: 'fixed-asset', FIXED_ASSET: 'fixed-asset',
NON_CURRENT_ASSET: 'non-ACCOUNT_PARENT_TYPE.CURRENT_ASSET', NON_CURRENT_ASSET: 'non-current-asset',
ACCOUNTS_PAYABLE: 'accounts-payable', ACCOUNTS_PAYABLE: 'accounts-payable',
CREDIT_CARD: 'credit-card', CREDIT_CARD: 'credit-card',

View File

@@ -7,7 +7,6 @@ import { DashboardPageContent } from '@/components';
import { transformTableStateToQuery, compose } from '@/utils'; import { transformTableStateToQuery, compose } from '@/utils';
import { ManualJournalsListProvider } from './ManualJournalsListProvider'; import { ManualJournalsListProvider } from './ManualJournalsListProvider';
import ManualJournalsViewTabs from './ManualJournalsViewTabs';
import ManualJournalsDataTable from './ManualJournalsDataTable'; import ManualJournalsDataTable from './ManualJournalsDataTable';
import ManualJournalsActionsBar from './ManualJournalActionsBar'; import ManualJournalsActionsBar from './ManualJournalActionsBar';
import withManualJournals from './withManualJournals'; import withManualJournals from './withManualJournals';
@@ -29,7 +28,6 @@ function ManualJournalsTable({
<ManualJournalsActionsBar /> <ManualJournalsActionsBar />
<DashboardPageContent> <DashboardPageContent>
<ManualJournalsViewTabs />
<ManualJournalsDataTable /> <ManualJournalsDataTable />
</DashboardPageContent> </DashboardPageContent>
</ManualJournalsListProvider> </ManualJournalsListProvider>

View File

@@ -2,15 +2,15 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import '@/style/pages/Accounts/List.scss'; import '@/style/pages/Accounts/List.scss';
import { DashboardPageContent, DashboardContentTable } from '@/components';
import { DashboardPageContent, DashboardContentTable } from '@/components';
import { AccountsChartProvider } from './AccountsChartProvider'; import { AccountsChartProvider } from './AccountsChartProvider';
import AccountsViewsTabs from './AccountsViewsTabs';
import AccountsActionsBar from './AccountsActionsBar'; import AccountsActionsBar from './AccountsActionsBar';
import AccountsDataTable from './AccountsDataTable'; import AccountsDataTable from './AccountsDataTable';
import withAccounts from '@/containers/Accounts/withAccounts'; import withAccounts from '@/containers/Accounts/withAccounts';
import withAccountsTableActions from './withAccountsTableActions'; import withAccountsTableActions from './withAccountsTableActions';
import { transformAccountsStateToQuery } from './utils'; import { transformAccountsStateToQuery } from './utils';
import { compose } from '@/utils'; import { compose } from '@/utils';
@@ -41,8 +41,6 @@ function AccountsChart({
<AccountsActionsBar /> <AccountsActionsBar />
<DashboardPageContent> <DashboardPageContent>
<AccountsViewsTabs />
<DashboardContentTable> <DashboardContentTable>
<AccountsDataTable /> <AccountsDataTable />
</DashboardContentTable> </DashboardContentTable>

View File

@@ -11,13 +11,19 @@ import {
MenuItem, MenuItem,
PopoverInteractionKind, PopoverInteractionKind,
Position, Position,
Intent,
Tooltip,
MenuDivider,
} from '@blueprintjs/core'; } from '@blueprintjs/core';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { isEmpty } from 'lodash';
import { import {
Icon, Icon,
DashboardActionsBar, DashboardActionsBar,
DashboardRowsHeightButton, DashboardRowsHeightButton,
FormattedMessage as T, FormattedMessage as T,
AppToaster,
If,
} from '@/components'; } from '@/components';
import { CashFlowMenuItems } from './utils'; import { CashFlowMenuItems } from './utils';
@@ -33,6 +39,13 @@ import withSettings from '@/containers/Settings/withSettings';
import withSettingsActions from '@/containers/Settings/withSettingsActions'; import withSettingsActions from '@/containers/Settings/withSettingsActions';
import { compose } from '@/utils'; import { compose } from '@/utils';
import {
useDisconnectBankAccount,
useUpdateBankAccount,
useExcludeUncategorizedTransactions,
useUnexcludeUncategorizedTransactions,
} from '@/hooks/query/bank-rules';
import { withBanking } from '../withBanking';
function AccountTransactionsActionsBar({ function AccountTransactionsActionsBar({
// #withDialogActions // #withDialogActions
@@ -43,17 +56,27 @@ function AccountTransactionsActionsBar({
// #withSettingsActions // #withSettingsActions
addSetting, addSetting,
// #withBanking
uncategorizedTransationsIdsSelected,
excludedTransactionsIdsSelected,
}) { }) {
const history = useHistory(); const history = useHistory();
const { accountId } = useAccountTransactionsContext(); const { accountId, currentAccount } = useAccountTransactionsContext();
// Refresh cashflow infinity transactions hook. // Refresh cashflow infinity transactions hook.
const { refresh } = useRefreshCashflowTransactionsInfinity(); const { refresh } = useRefreshCashflowTransactionsInfinity();
const { mutateAsync: disconnectBankAccount } = useDisconnectBankAccount();
const { mutateAsync: updateBankAccount } = useUpdateBankAccount();
// Retrieves the money in/out buttons options. // Retrieves the money in/out buttons options.
const addMoneyInOptions = useMemo(() => getAddMoneyInOptions(), []); const addMoneyInOptions = useMemo(() => getAddMoneyInOptions(), []);
const addMoneyOutOptions = useMemo(() => getAddMoneyOutOptions(), []); const addMoneyOutOptions = useMemo(() => getAddMoneyOutOptions(), []);
const isFeedsActive = !!currentAccount.is_feeds_active;
const isSyncingOwner = currentAccount.is_syncing_owner;
// Handle table row size change. // Handle table row size change.
const handleTableRowSizeChange = (size) => { const handleTableRowSizeChange = (size) => {
addSetting('cashflowTransactions', 'tableSize', size); addSetting('cashflowTransactions', 'tableSize', size);
@@ -82,11 +105,92 @@ function AccountTransactionsActionsBar({
const handleBankRulesClick = () => { const handleBankRulesClick = () => {
history.push(`/bank-rules?accountId=${accountId}`); 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. // Handle the refresh button click.
const handleRefreshBtnClick = () => { const handleRefreshBtnClick = () => {
refresh(); 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 ( return (
<DashboardActionsBar> <DashboardActionsBar>
<NavbarGroup> <NavbarGroup>
@@ -129,6 +233,45 @@ function AccountTransactionsActionsBar({
onChange={handleTableRowSizeChange} onChange={handleTableRowSizeChange}
/> />
<NavbarDivider /> <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>
<NavbarGroup align={Alignment.RIGHT}> <NavbarGroup align={Alignment.RIGHT}>
@@ -141,7 +284,15 @@ function AccountTransactionsActionsBar({
}} }}
content={ content={
<Menu> <Menu>
<If condition={isSyncingOwner && isFeedsActive}>
<MenuItem onClick={handleBankUpdateClick} text={'Update'} />
<MenuDivider />
</If>
<MenuItem onClick={handleBankRulesClick} text={'Bank rules'} /> <MenuItem onClick={handleBankRulesClick} text={'Bank rules'} />
<If condition={isSyncingOwner && isFeedsActive}>
<MenuItem onClick={handleDisconnectClick} text={'Disconnect'} />
</If>
</Menu> </Menu>
} }
> >
@@ -164,4 +315,13 @@ export default compose(
withSettings(({ cashflowTransactionsSettings }) => ({ withSettings(({ cashflowTransactionsSettings }) => ({
cashflowTansactionsTableSize: cashflowTransactionsSettings?.tableSize, cashflowTansactionsTableSize: cashflowTransactionsSettings?.tableSize,
})), })),
withBanking(
({
uncategorizedTransationsIdsSelected,
excludedTransactionsIdsSelected,
}) => ({
uncategorizedTransationsIdsSelected,
excludedTransactionsIdsSelected,
}),
),
)(AccountTransactionsActionsBar); )(AccountTransactionsActionsBar);

View File

@@ -33,6 +33,7 @@ function AccountTransactionsDataTable({
// #withBankingActions // #withBankingActions
setUncategorizedTransactionIdForMatching, setUncategorizedTransactionIdForMatching,
setUncategorizedTransactionsSelected,
}) { }) {
// Retrieve table columns. // Retrieve table columns.
const columns = useAccountUncategorizedTransactionsColumns(); 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 ( return (
<CashflowTransactionsTable <CashflowTransactionsTable
noInitialFetch={true} noInitialFetch={true}
columns={columns} columns={columns}
data={uncategorizedTransactions || []} data={uncategorizedTransactions || []}
sticky={true} sticky={true}
selectionColumn={true}
loading={isUncategorizedTransactionsLoading} loading={isUncategorizedTransactionsLoading}
headerLoading={isUncategorizedTransactionsLoading} headerLoading={isUncategorizedTransactionsLoading}
expandColumnSpace={1} expandColumnSpace={1}
@@ -99,6 +107,7 @@ function AccountTransactionsDataTable({
'There is no uncategorized transactions in the current account.' 'There is no uncategorized transactions in the current account.'
} }
className="table-constrant" className="table-constrant"
onSelectedRowsChange={handleSelectedRowsChange}
payload={{ payload={{
onExclude: handleExcludeTransaction, onExclude: handleExcludeTransaction,
onCategorize: handleCategorizeBtnClick, onCategorize: handleCategorizeBtnClick,

View File

@@ -2,7 +2,7 @@
import React from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { Intent } from '@blueprintjs/core'; import { Intent } from '@blueprintjs/core';
import * as R from 'ramda';
import { import {
DataTable, DataTable,
TableFastCell, TableFastCell,
@@ -19,11 +19,20 @@ import { useExcludedTransactionsBoot } from './ExcludedTransactionsTableBoot';
import { ActionsMenu } from './_components'; import { ActionsMenu } from './_components';
import { useUnexcludeUncategorizedTransaction } from '@/hooks/query/bank-rules'; import { useUnexcludeUncategorizedTransaction } from '@/hooks/query/bank-rules';
import {
WithBankingActionsProps,
withBankingActions,
} from '../../withBankingActions';
interface ExcludeTransactionsTableProps extends WithBankingActionsProps {}
/** /**
* Renders the recognized account transactions datatable. * Renders the recognized account transactions datatable.
*/ */
export function ExcludedTransactionsTable() { function ExcludedTransactionsTableRoot({
// #withBankingActions
setExcludedTransactionsSelected,
}: ExcludeTransactionsTableProps) {
const { excludedBankTransactions } = useExcludedTransactionsBoot(); const { excludedBankTransactions } = useExcludedTransactionsBoot();
const { mutateAsync: unexcludeBankTransaction } = const { mutateAsync: unexcludeBankTransaction } =
useUnexcludeUncategorizedTransaction(); 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 ( return (
<CashflowTransactionsTable <CashflowTransactionsTable
noInitialFetch={true} noInitialFetch={true}
@@ -80,6 +95,8 @@ export function ExcludedTransactionsTable() {
onColumnResizing={handleColumnResizing} onColumnResizing={handleColumnResizing}
noResults={'There is no excluded bank transactions.'} noResults={'There is no excluded bank transactions.'}
className="table-constrant" className="table-constrant"
selectionColumn={true}
onSelectedRowsChange={handleSelectedRowsChange}
payload={{ payload={{
onRestore: handleRestoreClick, onRestore: handleRestoreClick,
}} }}
@@ -87,6 +104,10 @@ export function ExcludedTransactionsTable() {
); );
} }
export const ExcludedTransactionsTable = R.compose(withBankingActions)(
ExcludedTransactionsTableRoot,
);
const DashboardConstrantTable = styled(DataTable)` const DashboardConstrantTable = styled(DataTable)`
.table { .table {
.thead { .thead {

View File

@@ -1,8 +1,27 @@
import { ExcludedTransactionsTable } from "../ExcludedTransactions/ExcludedTransactionsTable"; // @ts-nocheck
import { ExcludedBankTransactionsTableBoot } from "../ExcludedTransactions/ExcludedTransactionsTableBoot"; import { useEffect } from 'react';
import { AccountTransactionsCard } from "./AccountTransactionsCard"; 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 ( return (
<ExcludedBankTransactionsTableBoot> <ExcludedBankTransactionsTableBoot>
<AccountTransactionsCard> <AccountTransactionsCard>
@@ -11,3 +30,7 @@ export function AccountExcludedTransactions() {
</ExcludedBankTransactionsTableBoot> </ExcludedBankTransactionsTableBoot>
); );
} }
export const AccountExcludedTransactions = R.compose(withBankingActions)(
AccountExcludedTransactionsRoot,
);

View File

@@ -1,8 +1,26 @@
import * as R from 'ramda';
import { useEffect } from 'react';
import AccountTransactionsUncategorizedTable from '../AccountTransactionsUncategorizedTable'; import AccountTransactionsUncategorizedTable from '../AccountTransactionsUncategorizedTable';
import { AccountUncategorizedTransactionsBoot } from '../AllTransactionsUncategorizedBoot'; import { AccountUncategorizedTransactionsBoot } from '../AllTransactionsUncategorizedBoot';
import { AccountTransactionsCard } from './AccountTransactionsCard'; import { AccountTransactionsCard } from './AccountTransactionsCard';
import {
WithBankingActionsProps,
withBankingActions,
} from '../../withBankingActions';
interface AccountUncategorizedTransactionsAllRootProps
extends WithBankingActionsProps {}
function AccountUncategorizedTransactionsAllRoot({
resetUncategorizedTransactionsSelected,
}: AccountUncategorizedTransactionsAllRootProps) {
useEffect(
() => () => {
resetUncategorizedTransactionsSelected();
},
[resetUncategorizedTransactionsSelected],
);
export function AccountUncategorizedTransactionsAll() {
return ( return (
<AccountUncategorizedTransactionsBoot> <AccountUncategorizedTransactionsBoot>
<AccountTransactionsCard> <AccountTransactionsCard>
@@ -11,3 +29,7 @@ export function AccountUncategorizedTransactionsAll() {
</AccountUncategorizedTransactionsBoot> </AccountUncategorizedTransactionsBoot>
); );
} }
export const AccountUncategorizedTransactionsAll = R.compose(
withBankingActions,
)(AccountUncategorizedTransactionsAllRoot);

View File

@@ -13,6 +13,11 @@ export const withBanking = (mapState) => {
reconcileMatchingTransactionPendingAmount: reconcileMatchingTransactionPendingAmount:
state.plaid.openReconcileMatchingTransaction.pending, state.plaid.openReconcileMatchingTransaction.pending,
uncategorizedTransationsIdsSelected:
state.plaid.uncategorizedTransactionsSelected,
excludedTransactionsIdsSelected: state.plaid.excludedTransactionsSelected,
}; };
return mapState ? mapState(mapped, state, props) : mapped; return mapState ? mapState(mapped, state, props) : mapped;
}; };

View File

@@ -4,6 +4,10 @@ import {
setUncategorizedTransactionIdForMatching, setUncategorizedTransactionIdForMatching,
openReconcileMatchingTransaction, openReconcileMatchingTransaction,
closeReconcileMatchingTransaction, closeReconcileMatchingTransaction,
setUncategorizedTransactionsSelected,
resetUncategorizedTransactionsSelected,
resetExcludedTransactionsSelected,
setExcludedTransactionsSelected,
} from '@/store/banking/banking.reducer'; } from '@/store/banking/banking.reducer';
export interface WithBankingActionsProps { export interface WithBankingActionsProps {
@@ -13,6 +17,12 @@ export interface WithBankingActionsProps {
) => void; ) => void;
openReconcileMatchingTransaction: (pendingAmount: number) => void; openReconcileMatchingTransaction: (pendingAmount: number) => void;
closeReconcileMatchingTransaction: () => void; closeReconcileMatchingTransaction: () => void;
setUncategorizedTransactionsSelected: (ids: Array<string | number>) => void;
resetUncategorizedTransactionsSelected: () => void;
setExcludedTransactionsSelected: (ids: Array<string | number>) => void;
resetExcludedTransactionsSelected: () => void;
} }
const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
@@ -28,6 +38,40 @@ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
dispatch(openReconcileMatchingTransaction({ pending: pendingAmount })), dispatch(openReconcileMatchingTransaction({ pending: pendingAmount })),
closeReconcileMatchingTransaction: () => closeReconcileMatchingTransaction: () =>
dispatch(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< export const withBankingActions = connect<

View File

@@ -6,7 +6,6 @@ import '@/style/pages/Customers/List.scss';
import { DashboardPageContent } from '@/components'; import { DashboardPageContent } from '@/components';
import CustomersActionsBar from './CustomersActionsBar'; import CustomersActionsBar from './CustomersActionsBar';
import CustomersViewsTabs from './CustomersViewsTabs';
import CustomersTable from './CustomersTable'; import CustomersTable from './CustomersTable';
import { CustomersListProvider } from './CustomersListProvider'; import { CustomersListProvider } from './CustomersListProvider';
@@ -42,7 +41,6 @@ function CustomersList({
<CustomersActionsBar /> <CustomersActionsBar />
<DashboardPageContent> <DashboardPageContent>
<CustomersViewsTabs />
<CustomersTable /> <CustomersTable />
</DashboardPageContent> </DashboardPageContent>
</CustomersListProvider> </CustomersListProvider>

View File

@@ -6,7 +6,6 @@ import '@/style/pages/Expense/List.scss';
import { DashboardPageContent } from '@/components'; import { DashboardPageContent } from '@/components';
import ExpenseActionsBar from './ExpenseActionsBar'; import ExpenseActionsBar from './ExpenseActionsBar';
import ExpenseViewTabs from './ExpenseViewTabs';
import ExpenseDataTable from './ExpenseDataTable'; import ExpenseDataTable from './ExpenseDataTable';
import withExpenses from './withExpenses'; import withExpenses from './withExpenses';
@@ -42,7 +41,6 @@ function ExpensesList({
<ExpenseActionsBar /> <ExpenseActionsBar />
<DashboardPageContent> <DashboardPageContent>
<ExpenseViewTabs />
<ExpenseDataTable /> <ExpenseDataTable />
</DashboardPageContent> </DashboardPageContent>
</ExpensesListProvider> </ExpensesListProvider>

View File

@@ -8,7 +8,6 @@ import { DashboardPageContent } from '@/components';
import { ItemsListProvider } from './ItemsListProvider'; import { ItemsListProvider } from './ItemsListProvider';
import ItemsActionsBar from './ItemsActionsBar'; import ItemsActionsBar from './ItemsActionsBar';
import ItemsViewsTabs from './ItemsViewsTabs';
import ItemsDataTable from './ItemsDataTable'; import ItemsDataTable from './ItemsDataTable';
import withItems from './withItems'; import withItems from './withItems';
@@ -41,7 +40,6 @@ function ItemsList({
<ItemsActionsBar /> <ItemsActionsBar />
<DashboardPageContent> <DashboardPageContent>
<ItemsViewsTabs />
<ItemsDataTable /> <ItemsDataTable />
</DashboardPageContent> </DashboardPageContent>
</ItemsListProvider> </ItemsListProvider>

View File

@@ -7,7 +7,6 @@ import '@/style/pages/Bills/List.scss';
import { BillsListProvider } from './BillsListProvider'; import { BillsListProvider } from './BillsListProvider';
import BillsActionsBar from './BillsActionsBar'; import BillsActionsBar from './BillsActionsBar';
import BillsViewsTabs from './BillsViewsTabs';
import BillsTable from './BillsTable'; import BillsTable from './BillsTable';
import withBills from './withBills'; import withBills from './withBills';
@@ -42,7 +41,6 @@ function BillsList({
<BillsActionsBar /> <BillsActionsBar />
<DashboardPageContent> <DashboardPageContent>
<BillsViewsTabs />
<BillsTable /> <BillsTable />
</DashboardPageContent> </DashboardPageContent>
</BillsListProvider> </BillsListProvider>

View File

@@ -5,7 +5,6 @@ import '@/style/pages/VendorsCreditNote/List.scss';
import { DashboardPageContent } from '@/components'; import { DashboardPageContent } from '@/components';
import VendorsCreditNoteActionsBar from './VendorsCreditNoteActionsBar'; import VendorsCreditNoteActionsBar from './VendorsCreditNoteActionsBar';
import VendorsCreditNoteViewTabs from './VendorsCreditNoteViewTabs';
import VendorsCreditNoteDataTable from './VendorsCreditNoteDataTable'; import VendorsCreditNoteDataTable from './VendorsCreditNoteDataTable';
import withVendorsCreditNotes from './withVendorsCreditNotes'; import withVendorsCreditNotes from './withVendorsCreditNotes';
@@ -37,7 +36,6 @@ function VendorsCreditNotesList({
> >
<VendorsCreditNoteActionsBar /> <VendorsCreditNoteActionsBar />
<DashboardPageContent> <DashboardPageContent>
<VendorsCreditNoteViewTabs />
<VendorsCreditNoteDataTable /> <VendorsCreditNoteDataTable />
</DashboardPageContent> </DashboardPageContent>
</VendorsCreditNoteListProvider> </VendorsCreditNoteListProvider>

View File

@@ -0,0 +1,9 @@
import { ExcessPaymentDialog } from './dialogs/PaymentMadeExcessDialog';
export function PaymentMadeDialogs() {
return (
<>
<ExcessPaymentDialog dialogName={'payment-made-excessed-payment'} />
</>
);
}

View File

@@ -2,7 +2,7 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import classNames from 'classnames'; import classNames from 'classnames';
import { Formik, Form } from 'formik'; import { Formik, Form, FormikHelpers } from 'formik';
import { Intent } from '@blueprintjs/core'; import { Intent } from '@blueprintjs/core';
import { sumBy, defaultTo } from 'lodash'; import { sumBy, defaultTo } from 'lodash';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
@@ -14,6 +14,7 @@ import PaymentMadeFloatingActions from './PaymentMadeFloatingActions';
import PaymentMadeFooter from './PaymentMadeFooter'; import PaymentMadeFooter from './PaymentMadeFooter';
import PaymentMadeFormBody from './PaymentMadeFormBody'; import PaymentMadeFormBody from './PaymentMadeFormBody';
import PaymentMadeFormTopBar from './PaymentMadeFormTopBar'; import PaymentMadeFormTopBar from './PaymentMadeFormTopBar';
import { PaymentMadeDialogs } from './PaymentMadeDialogs';
import { PaymentMadeInnerProvider } from './PaymentMadeInnerProvider'; import { PaymentMadeInnerProvider } from './PaymentMadeInnerProvider';
import { usePaymentMadeFormContext } from './PaymentMadeFormProvider'; import { usePaymentMadeFormContext } from './PaymentMadeFormProvider';
@@ -21,6 +22,7 @@ import { compose, orderingLinesIndexes } from '@/utils';
import withSettings from '@/containers/Settings/withSettings'; import withSettings from '@/containers/Settings/withSettings';
import withCurrentOrganization from '@/containers/Organization/withCurrentOrganization'; import withCurrentOrganization from '@/containers/Organization/withCurrentOrganization';
import withDialogActions from '@/containers/Dialog/withDialogActions';
import { import {
EditPaymentMadeFormSchema, EditPaymentMadeFormSchema,
@@ -31,6 +33,7 @@ import {
transformToEditForm, transformToEditForm,
transformErrors, transformErrors,
transformFormToRequest, transformFormToRequest,
getPaymentExcessAmountFromValues,
} from './utils'; } from './utils';
/** /**
@@ -42,6 +45,9 @@ function PaymentMadeForm({
// #withCurrentOrganization // #withCurrentOrganization
organization: { base_currency }, organization: { base_currency },
// #withDialogActions
openDialog,
}) { }) {
const history = useHistory(); const history = useHistory();
@@ -54,6 +60,7 @@ function PaymentMadeForm({
submitPayload, submitPayload,
createPaymentMadeMutate, createPaymentMadeMutate,
editPaymentMadeMutate, editPaymentMadeMutate,
isExcessConfirmed,
} = usePaymentMadeFormContext(); } = usePaymentMadeFormContext();
// Form initial values. // Form initial values.
@@ -76,13 +83,11 @@ function PaymentMadeForm({
// Handle the form submit. // Handle the form submit.
const handleSubmitForm = ( const handleSubmitForm = (
values, values,
{ setSubmitting, resetForm, setFieldError }, { setSubmitting, resetForm, setFieldError }: FormikHelpers<any>,
) => { ) => {
setSubmitting(true); setSubmitting(true);
// Total payment amount of entries.
const totalPaymentAmount = sumBy(values.entries, 'payment_amount');
if (totalPaymentAmount <= 0) { if (values.amount <= 0) {
AppToaster.show({ AppToaster.show({
message: intl.get('you_cannot_make_payment_with_zero_total_amount'), message: intl.get('you_cannot_make_payment_with_zero_total_amount'),
intent: Intent.DANGER, intent: Intent.DANGER,
@@ -90,6 +95,16 @@ function PaymentMadeForm({
setSubmitting(false); setSubmitting(false);
return; return;
} }
const excessAmount = getPaymentExcessAmountFromValues(values);
// Show the confirmation popup if the excess amount bigger than zero and
// has not been confirmed yet.
if (excessAmount > 0 && !isExcessConfirmed) {
openDialog('payment-made-excessed-payment');
setSubmitting(false);
return;
}
// Transformes the form values to request body. // Transformes the form values to request body.
const form = transformFormToRequest(values); const form = transformFormToRequest(values);
@@ -119,11 +134,12 @@ function PaymentMadeForm({
} }
setSubmitting(false); setSubmitting(false);
}; };
if (!isNewMode) { if (!isNewMode) {
editPaymentMadeMutate([paymentMadeId, form]).then(onSaved).catch(onError); return editPaymentMadeMutate([paymentMadeId, form])
.then(onSaved)
.catch(onError);
} else { } else {
createPaymentMadeMutate(form).then(onSaved).catch(onError); return createPaymentMadeMutate(form).then(onSaved).catch(onError);
} }
}; };
@@ -149,6 +165,7 @@ function PaymentMadeForm({
<PaymentMadeFormBody /> <PaymentMadeFormBody />
<PaymentMadeFooter /> <PaymentMadeFooter />
<PaymentMadeFloatingActions /> <PaymentMadeFloatingActions />
<PaymentMadeDialogs />
</PaymentMadeInnerProvider> </PaymentMadeInnerProvider>
</Form> </Form>
</Formik> </Formik>
@@ -163,4 +180,5 @@ export default compose(
preferredPaymentAccount: parseInt(billPaymentSettings?.withdrawalAccount), preferredPaymentAccount: parseInt(billPaymentSettings?.withdrawalAccount),
})), })),
withCurrentOrganization(), withCurrentOrganization(),
withDialogActions,
)(PaymentMadeForm); )(PaymentMadeForm);

View File

@@ -1,17 +1,23 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { useFormikContext } from 'formik';
import { import {
T, T,
TotalLines, TotalLines,
TotalLine, TotalLine,
TotalLineBorderStyle, TotalLineBorderStyle,
TotalLineTextStyle, TotalLineTextStyle,
FormatNumber,
} from '@/components'; } from '@/components';
import { usePaymentMadeTotals } from './utils'; import { usePaymentMadeExcessAmount, usePaymentMadeTotals } from './utils';
export function PaymentMadeFormFooterRight() { export function PaymentMadeFormFooterRight() {
const { formattedSubtotal, formattedTotal } = usePaymentMadeTotals(); const { formattedSubtotal, formattedTotal } = usePaymentMadeTotals();
const excessAmount = usePaymentMadeExcessAmount();
const {
values: { currency_code: currencyCode },
} = useFormikContext();
return ( return (
<PaymentMadeTotalLines labelColWidth={'180px'} amountColWidth={'180px'}> <PaymentMadeTotalLines labelColWidth={'180px'} amountColWidth={'180px'}>
@@ -25,6 +31,11 @@ export function PaymentMadeFormFooterRight() {
value={formattedTotal} value={formattedTotal}
textStyle={TotalLineTextStyle.Bold} textStyle={TotalLineTextStyle.Bold}
/> />
<TotalLine
title={'Excess Amount'}
value={<FormatNumber value={excessAmount} currency={currencyCode} />}
textStyle={TotalLineTextStyle.Regular}
/>
</PaymentMadeTotalLines> </PaymentMadeTotalLines>
); );
} }

View File

@@ -1,12 +1,12 @@
// @ts-nocheck // @ts-nocheck
import React, { useMemo } from 'react'; import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { useFormikContext } from 'formik'; import { useFormikContext } from 'formik';
import { sumBy } from 'lodash';
import { CLASSES } from '@/constants/classes'; import { CLASSES } from '@/constants/classes';
import { Money, FormattedMessage as T } from '@/components'; import { Money, FormattedMessage as T } from '@/components';
import PaymentMadeFormHeaderFields from './PaymentMadeFormHeaderFields'; import PaymentMadeFormHeaderFields from './PaymentMadeFormHeaderFields';
import { usePaymentmadeTotalAmount } from './utils';
/** /**
* Payment made header form. * Payment made header form.
@@ -14,11 +14,10 @@ import PaymentMadeFormHeaderFields from './PaymentMadeFormHeaderFields';
function PaymentMadeFormHeader() { function PaymentMadeFormHeader() {
// Formik form context. // Formik form context.
const { const {
values: { entries, currency_code }, values: { currency_code },
} = useFormikContext(); } = useFormikContext();
// Calculate the payment amount of the entries. const totalAmount = usePaymentmadeTotalAmount();
const amountPaid = useMemo(() => sumBy(entries, 'payment_amount'), [entries]);
return ( return (
<div className={classNames(CLASSES.PAGE_FORM_HEADER)}> <div className={classNames(CLASSES.PAGE_FORM_HEADER)}>
@@ -30,8 +29,9 @@ function PaymentMadeFormHeader() {
<span class="big-amount__label"> <span class="big-amount__label">
<T id={'amount_received'} /> <T id={'amount_received'} />
</span> </span>
<h1 class="big-amount__number"> <h1 class="big-amount__number">
<Money amount={amountPaid} currency={currency_code} /> <Money amount={totalAmount} currency={currency_code} />
</h1> </h1>
</div> </div>
</div> </div>

View File

@@ -2,6 +2,7 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import classNames from 'classnames'; import classNames from 'classnames';
import { isEmpty, toSafeInteger } from 'lodash';
import { import {
FormGroup, FormGroup,
InputGroup, InputGroup,
@@ -13,7 +14,6 @@ import {
import { DateInput } from '@blueprintjs/datetime'; import { DateInput } from '@blueprintjs/datetime';
import { FastField, Field, useFormikContext, ErrorMessage } from 'formik'; import { FastField, Field, useFormikContext, ErrorMessage } from 'formik';
import { FormattedMessage as T, VendorsSelect } from '@/components'; import { FormattedMessage as T, VendorsSelect } from '@/components';
import { toSafeInteger } from 'lodash';
import { CLASSES } from '@/constants/classes'; import { CLASSES } from '@/constants/classes';
import { import {
@@ -68,7 +68,7 @@ function PaymentMadeFormHeaderFields({ organization: { base_currency } }) {
const fullAmount = safeSumBy(newEntries, 'payment_amount'); const fullAmount = safeSumBy(newEntries, 'payment_amount');
setFieldValue('entries', newEntries); setFieldValue('entries', newEntries);
setFieldValue('full_amount', fullAmount); setFieldValue('amount', fullAmount);
}; };
// Handles the full-amount field blur. // Handles the full-amount field blur.
@@ -115,10 +115,10 @@ function PaymentMadeFormHeaderFields({ organization: { base_currency } }) {
</FastField> </FastField>
{/* ------------ Full amount ------------ */} {/* ------------ Full amount ------------ */}
<Field name={'full_amount'}> <Field name={'amount'}>
{({ {({
form: { form: {
values: { currency_code }, values: { currency_code, entries },
}, },
field: { value }, field: { value },
meta: { error, touched }, meta: { error, touched },
@@ -129,19 +129,20 @@ function PaymentMadeFormHeaderFields({ organization: { base_currency } }) {
className={('form-group--full-amount', Classes.FILL)} className={('form-group--full-amount', Classes.FILL)}
intent={inputIntent({ error, touched })} intent={inputIntent({ error, touched })}
labelInfo={<Hint />} labelInfo={<Hint />}
helperText={<ErrorMessage name="full_amount" />} helperText={<ErrorMessage name="amount" />}
> >
<ControlGroup> <ControlGroup>
<InputPrependText text={currency_code} /> <InputPrependText text={currency_code} />
<MoneyInputGroup <MoneyInputGroup
value={value} value={value}
onChange={(value) => { onChange={(value) => {
setFieldValue('full_amount', value); setFieldValue('amount', value);
}} }}
onBlurValue={onFullAmountBlur} onBlurValue={onFullAmountBlur}
/> />
</ControlGroup> </ControlGroup>
{!isEmpty(entries) && (
<Button <Button
onClick={handleReceiveFullAmountClick} onClick={handleReceiveFullAmountClick}
className={'receive-full-amount'} className={'receive-full-amount'}
@@ -151,6 +152,7 @@ function PaymentMadeFormHeaderFields({ organization: { base_currency } }) {
<T id={'receive_full_amount'} /> ( <T id={'receive_full_amount'} /> (
<Money amount={payableFullAmount} currency={currency_code} />) <Money amount={payableFullAmount} currency={currency_code} />)
</Button> </Button>
)}
</FormGroup> </FormGroup>
)} )}
</Field> </Field>

View File

@@ -1,5 +1,5 @@
// @ts-nocheck // @ts-nocheck
import React, { createContext, useContext } from 'react'; import React, { createContext, useContext, useState } from 'react';
import { Features } from '@/constants'; import { Features } from '@/constants';
import { useFeatureCan } from '@/hooks/state'; import { useFeatureCan } from '@/hooks/state';
import { import {
@@ -71,6 +71,8 @@ function PaymentMadeFormProvider({ query, paymentMadeId, ...props }) {
const isFeatureLoading = isBranchesLoading; const isFeatureLoading = isBranchesLoading;
const [isExcessConfirmed, setIsExcessConfirmed] = useState<boolean>(false);
// Provider payload. // Provider payload.
const provider = { const provider = {
paymentMadeId, paymentMadeId,
@@ -98,6 +100,9 @@ function PaymentMadeFormProvider({ query, paymentMadeId, ...props }) {
setSubmitPayload, setSubmitPayload,
setPaymentVendorId, setPaymentVendorId,
isExcessConfirmed,
setIsExcessConfirmed,
}; };
return ( return (

View File

@@ -0,0 +1,37 @@
// @ts-nocheck
import React from 'react';
import { Dialog, DialogSuspense } from '@/components';
import withDialogRedux from '@/components/DialogReduxConnect';
import { compose } from '@/utils';
const ExcessPaymentDialogContent = React.lazy(() =>
import('./PaymentMadeExcessDialogContent').then((module) => ({
default: module.ExcessPaymentDialogContent,
})),
);
/**
* Exess payment dialog of the payment made form.
*/
function ExcessPaymentDialogRoot({ dialogName, isOpen }) {
return (
<Dialog
name={dialogName}
title={'Excess Payment'}
isOpen={isOpen}
canEscapeJeyClose={true}
autoFocus={true}
style={{ width: 500 }}
>
<DialogSuspense>
<ExcessPaymentDialogContent dialogName={dialogName} />
</DialogSuspense>
</Dialog>
);
}
export const ExcessPaymentDialog = compose(withDialogRedux())(
ExcessPaymentDialogRoot,
);
ExcessPaymentDialog.displayName = 'ExcessPaymentDialog';

View File

@@ -0,0 +1,93 @@
// @ts-nocheck
import * as R from 'ramda';
import React from 'react';
import { Button, Classes, Intent } from '@blueprintjs/core';
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import { FormatNumber } from '@/components';
import { usePaymentMadeFormContext } from '../../PaymentMadeFormProvider';
import withDialogActions from '@/containers/Dialog/withDialogActions';
import { usePaymentMadeExcessAmount } from '../../utils';
interface ExcessPaymentValues {}
function ExcessPaymentDialogContentRoot({ dialogName, closeDialog }) {
const {
submitForm,
values: { currency_code: currencyCode },
} = useFormikContext();
const { setIsExcessConfirmed } = usePaymentMadeFormContext();
// Handles the form submitting.
const handleSubmit = (
values: ExcessPaymentValues,
{ setSubmitting }: FormikHelpers<ExcessPaymentValues>,
) => {
setSubmitting(true);
setIsExcessConfirmed(true);
return submitForm().then(() => {
setSubmitting(false);
closeDialog(dialogName);
});
};
// Handle close button click.
const handleCloseBtn = () => {
closeDialog(dialogName);
};
const excessAmount = usePaymentMadeExcessAmount();
return (
<Formik initialValues={{}} onSubmit={handleSubmit}>
<Form>
<ExcessPaymentDialogContentForm
excessAmount={
<FormatNumber value={excessAmount} currency={currencyCode} />
}
onClose={handleCloseBtn}
/>
</Form>
</Formik>
);
}
export const ExcessPaymentDialogContent = R.compose(withDialogActions)(
ExcessPaymentDialogContentRoot,
);
interface ExcessPaymentDialogContentFormProps {
excessAmount: string | number | React.ReactNode;
onClose?: () => void;
}
function ExcessPaymentDialogContentForm({
excessAmount,
onClose,
}: ExcessPaymentDialogContentFormProps) {
const { submitForm, isSubmitting } = useFormikContext();
const handleCloseBtn = () => {
onClose && onClose();
};
return (
<>
<div className={Classes.DIALOG_BODY}>
<p style={{ marginBottom: 20 }}>
Would you like to record the excess amount of{' '}
<strong>{excessAmount}</strong> as credit payment from the vendor.
</p>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button
intent={Intent.PRIMARY}
loading={isSubmitting}
onClick={() => submitForm()}
>
Save Payment as Credit
</Button>
<Button onClick={handleCloseBtn}>Cancel</Button>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1 @@
export * from './PaymentMadeExcessDialog';

View File

@@ -37,7 +37,7 @@ export const defaultPaymentMadeEntry = {
// Default initial values of payment made. // Default initial values of payment made.
export const defaultPaymentMade = { export const defaultPaymentMade = {
full_amount: '', amount: '',
vendor_id: '', vendor_id: '',
payment_account_id: '', payment_account_id: '',
payment_date: moment(new Date()).format('YYYY-MM-DD'), payment_date: moment(new Date()).format('YYYY-MM-DD'),
@@ -53,10 +53,10 @@ export const defaultPaymentMade = {
export const transformToEditForm = (paymentMade, paymentMadeEntries) => { export const transformToEditForm = (paymentMade, paymentMadeEntries) => {
const attachments = transformAttachmentsToForm(paymentMade); const attachments = transformAttachmentsToForm(paymentMade);
const appliedAmount = safeSumBy(paymentMadeEntries, 'payment_amount');
return { return {
...transformToForm(paymentMade, defaultPaymentMade), ...transformToForm(paymentMade, defaultPaymentMade),
full_amount: safeSumBy(paymentMadeEntries, 'payment_amount'),
entries: [ entries: [
...paymentMadeEntries.map((paymentMadeEntry) => ({ ...paymentMadeEntries.map((paymentMadeEntry) => ({
...transformToForm(paymentMadeEntry, defaultPaymentMadeEntry), ...transformToForm(paymentMadeEntry, defaultPaymentMadeEntry),
@@ -177,6 +177,30 @@ export const usePaymentMadeTotals = () => {
}; };
}; };
export const usePaymentmadeTotalAmount = () => {
const {
values: { amount },
} = useFormikContext();
return amount;
};
export const usePaymentMadeAppliedAmount = () => {
const {
values: { entries },
} = useFormikContext();
// Retrieves the invoice entries total.
return React.useMemo(() => sumBy(entries, 'payment_amount'), [entries]);
};
export const usePaymentMadeExcessAmount = () => {
const appliedAmount = usePaymentMadeAppliedAmount();
const totalAmount = usePaymentmadeTotalAmount();
return Math.abs(totalAmount - appliedAmount);
};
/** /**
* Detarmines whether the bill has foreign customer. * Detarmines whether the bill has foreign customer.
* @returns {boolean} * @returns {boolean}
@@ -191,3 +215,10 @@ export const usePaymentMadeIsForeignCustomer = () => {
); );
return isForeignCustomer; return isForeignCustomer;
}; };
export const getPaymentExcessAmountFromValues = (values) => {
const appliedAmount = sumBy(values.entries, 'payment_amount');
const totalAmount = values.amount;
return Math.abs(totalAmount - appliedAmount);
};

View File

@@ -7,7 +7,6 @@ import { DashboardPageContent } from '@/components';
import { PaymentMadesListProvider } from './PaymentMadesListProvider'; import { PaymentMadesListProvider } from './PaymentMadesListProvider';
import PaymentMadeActionsBar from './PaymentMadeActionsBar'; import PaymentMadeActionsBar from './PaymentMadeActionsBar';
import PaymentMadesTable from './PaymentMadesTable'; import PaymentMadesTable from './PaymentMadesTable';
import PaymentMadeViewTabs from './PaymentMadeViewTabs';
import withPaymentMades from './withPaymentMade'; import withPaymentMades from './withPaymentMade';
import withPaymentMadeActions from './withPaymentMadeActions'; import withPaymentMadeActions from './withPaymentMadeActions';
@@ -41,7 +40,6 @@ function PaymentMadeList({
<PaymentMadeActionsBar /> <PaymentMadeActionsBar />
<DashboardPageContent> <DashboardPageContent>
<PaymentMadeViewTabs />
<PaymentMadesTable /> <PaymentMadesTable />
</DashboardPageContent> </DashboardPageContent>
</PaymentMadesListProvider> </PaymentMadesListProvider>

View File

@@ -5,7 +5,6 @@ import '@/style/pages/CreditNote/List.scss';
import { DashboardPageContent } from '@/components'; import { DashboardPageContent } from '@/components';
import CreditNotesActionsBar from './CreditNotesActionsBar'; import CreditNotesActionsBar from './CreditNotesActionsBar';
import CreditNotesViewTabs from './CreditNotesViewTabs';
import CreditNotesDataTable from './CreditNotesDataTable'; import CreditNotesDataTable from './CreditNotesDataTable';
import withCreditNotes from './withCreditNotes'; import withCreditNotes from './withCreditNotes';
@@ -36,8 +35,8 @@ function CreditNotesList({
tableStateChanged={creditNoteTableStateChanged} tableStateChanged={creditNoteTableStateChanged}
> >
<CreditNotesActionsBar /> <CreditNotesActionsBar />
<DashboardPageContent> <DashboardPageContent>
<CreditNotesViewTabs />
<CreditNotesDataTable /> <CreditNotesDataTable />
</DashboardPageContent> </DashboardPageContent>
</CreditNotesListProvider> </CreditNotesListProvider>

View File

@@ -1,11 +1,10 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React from 'react';
import { DashboardContentTable, DashboardPageContent } from '@/components'; import { DashboardPageContent } from '@/components';
import '@/style/pages/SaleEstimate/List.scss'; import '@/style/pages/SaleEstimate/List.scss';
import EstimatesActionsBar from './EstimatesActionsBar'; import EstimatesActionsBar from './EstimatesActionsBar';
import EstimatesViewTabs from './EstimatesViewTabs';
import EstimatesDataTable from './EstimatesDataTable'; import EstimatesDataTable from './EstimatesDataTable';
import withEstimates from './withEstimates'; import withEstimates from './withEstimates';
@@ -41,7 +40,6 @@ function EstimatesList({
<EstimatesActionsBar /> <EstimatesActionsBar />
<DashboardPageContent> <DashboardPageContent>
<EstimatesViewTabs />
<EstimatesDataTable /> <EstimatesDataTable />
</DashboardPageContent> </DashboardPageContent>
</EstimatesListProvider> </EstimatesListProvider>

View File

@@ -6,7 +6,6 @@ import '@/style/pages/SaleInvoice/List.scss';
import { DashboardPageContent } from '@/components'; import { DashboardPageContent } from '@/components';
import { InvoicesListProvider } from './InvoicesListProvider'; import { InvoicesListProvider } from './InvoicesListProvider';
import InvoiceViewTabs from './InvoiceViewTabs';
import InvoicesDataTable from './InvoicesDataTable'; import InvoicesDataTable from './InvoicesDataTable';
import InvoicesActionsBar from './InvoicesActionsBar'; import InvoicesActionsBar from './InvoicesActionsBar';
@@ -43,7 +42,6 @@ function InvoicesList({
<InvoicesActionsBar /> <InvoicesActionsBar />
<DashboardPageContent> <DashboardPageContent>
<InvoiceViewTabs />
<InvoicesDataTable /> <InvoicesDataTable />
</DashboardPageContent> </DashboardPageContent>
</InvoicesListProvider> </InvoicesListProvider>

View File

@@ -1,5 +1,5 @@
// @ts-nocheck // @ts-nocheck
import React, { useMemo } from 'react'; import React, { useMemo, useRef } from 'react';
import { sumBy, isEmpty, defaultTo } from 'lodash'; import { sumBy, isEmpty, defaultTo } from 'lodash';
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import classNames from 'classnames'; import classNames from 'classnames';
@@ -21,6 +21,7 @@ import { PaymentReceiveInnerProvider } from './PaymentReceiveInnerProvider';
import withSettings from '@/containers/Settings/withSettings'; import withSettings from '@/containers/Settings/withSettings';
import withCurrentOrganization from '@/containers/Organization/withCurrentOrganization'; import withCurrentOrganization from '@/containers/Organization/withCurrentOrganization';
import withDialogActions from '@/containers/Dialog/withDialogActions';
import { import {
EditPaymentReceiveFormSchema, EditPaymentReceiveFormSchema,
@@ -36,6 +37,7 @@ import {
transformFormToRequest, transformFormToRequest,
transformErrors, transformErrors,
resetFormState, resetFormState,
getExceededAmountFromValues,
} from './utils'; } from './utils';
import { PaymentReceiveSyncIncrementSettingsToForm } from './components'; import { PaymentReceiveSyncIncrementSettingsToForm } from './components';
@@ -51,6 +53,9 @@ function PaymentReceiveForm({
// #withCurrentOrganization // #withCurrentOrganization
organization: { base_currency }, organization: { base_currency },
// #withDialogActions
openDialog,
}) { }) {
const history = useHistory(); const history = useHistory();
@@ -63,6 +68,7 @@ function PaymentReceiveForm({
submitPayload, submitPayload,
editPaymentReceiveMutate, editPaymentReceiveMutate,
createPaymentReceiveMutate, createPaymentReceiveMutate,
isExcessConfirmed,
} = usePaymentReceiveFormContext(); } = usePaymentReceiveFormContext();
// Payment receive number. // Payment receive number.
@@ -94,18 +100,16 @@ function PaymentReceiveForm({
preferredDepositAccount, preferredDepositAccount,
], ],
); );
// Handle form submit. // Handle form submit.
const handleSubmitForm = ( const handleSubmitForm = (
values, values,
{ setSubmitting, resetForm, setFieldError }, { setSubmitting, resetForm, setFieldError },
) => { ) => {
setSubmitting(true); setSubmitting(true);
const exceededAmount = getExceededAmountFromValues(values);
// Calculates the total payment amount of entries. // Validates the amount should be bigger than zero.
const totalPaymentAmount = sumBy(values.entries, 'payment_amount'); if (values.amount <= 0) {
if (totalPaymentAmount <= 0) {
AppToaster.show({ AppToaster.show({
message: intl.get('you_cannot_make_payment_with_zero_total_amount'), message: intl.get('you_cannot_make_payment_with_zero_total_amount'),
intent: Intent.DANGER, intent: Intent.DANGER,
@@ -113,6 +117,13 @@ function PaymentReceiveForm({
setSubmitting(false); setSubmitting(false);
return; return;
} }
// Show the confirm popup if the excessed amount bigger than zero and
// excess confirmation has not been confirmed yet.
if (exceededAmount > 0 && !isExcessConfirmed) {
setSubmitting(false);
openDialog('payment-received-excessed-payment');
return;
}
// Transformes the form values to request body. // Transformes the form values to request body.
const form = transformFormToRequest(values); const form = transformFormToRequest(values);
@@ -148,11 +159,11 @@ function PaymentReceiveForm({
}; };
if (paymentReceiveId) { if (paymentReceiveId) {
editPaymentReceiveMutate([paymentReceiveId, form]) return editPaymentReceiveMutate([paymentReceiveId, form])
.then(onSaved) .then(onSaved)
.catch(onError); .catch(onError);
} else { } else {
createPaymentReceiveMutate(form).then(onSaved).catch(onError); return createPaymentReceiveMutate(form).then(onSaved).catch(onError);
} }
}; };
return ( return (
@@ -202,4 +213,5 @@ export default compose(
preferredDepositAccount: paymentReceiveSettings?.preferredDepositAccount, preferredDepositAccount: paymentReceiveSettings?.preferredDepositAccount,
})), })),
withCurrentOrganization(), withCurrentOrganization(),
withDialogActions,
)(PaymentReceiveForm); )(PaymentReceiveForm);

View File

@@ -2,6 +2,7 @@
import React from 'react'; import React from 'react';
import { useFormikContext } from 'formik'; import { useFormikContext } from 'formik';
import PaymentReceiveNumberDialog from '@/containers/Dialogs/PaymentReceiveNumberDialog'; import PaymentReceiveNumberDialog from '@/containers/Dialogs/PaymentReceiveNumberDialog';
import { ExcessPaymentDialog } from './dialogs/ExcessPaymentDialog';
/** /**
* Payment receive form dialogs. * Payment receive form dialogs.
@@ -21,9 +22,12 @@ export default function PaymentReceiveFormDialogs() {
}; };
return ( return (
<>
<PaymentReceiveNumberDialog <PaymentReceiveNumberDialog
dialogName={'payment-receive-number-form'} dialogName={'payment-receive-number-form'}
onConfirm={handleUpdatePaymentNumber} onConfirm={handleUpdatePaymentNumber}
/> />
<ExcessPaymentDialog dialogName={'payment-received-excessed-payment'} />
</>
); );
} }

View File

@@ -7,11 +7,16 @@ import {
TotalLine, TotalLine,
TotalLineBorderStyle, TotalLineBorderStyle,
TotalLineTextStyle, TotalLineTextStyle,
FormatNumber,
} from '@/components'; } from '@/components';
import { usePaymentReceiveTotals } from './utils'; import {
usePaymentReceiveTotals,
usePaymentReceivedTotalExceededAmount,
} from './utils';
export function PaymentReceiveFormFootetRight() { export function PaymentReceiveFormFootetRight() {
const { formattedSubtotal, formattedTotal } = usePaymentReceiveTotals(); const { formattedSubtotal, formattedTotal } = usePaymentReceiveTotals();
const exceededAmount = usePaymentReceivedTotalExceededAmount();
return ( return (
<PaymentReceiveTotalLines labelColWidth={'180px'} amountColWidth={'180px'}> <PaymentReceiveTotalLines labelColWidth={'180px'} amountColWidth={'180px'}>
@@ -25,6 +30,11 @@ export function PaymentReceiveFormFootetRight() {
value={formattedTotal} value={formattedTotal}
textStyle={TotalLineTextStyle.Bold} textStyle={TotalLineTextStyle.Bold}
/> />
<TotalLine
title={'Exceeded Amount'}
value={<FormatNumber value={exceededAmount} />}
textStyle={TotalLineTextStyle.Regular}
/>
</PaymentReceiveTotalLines> </PaymentReceiveTotalLines>
); );
} }

View File

@@ -30,15 +30,9 @@ function PaymentReceiveFormHeader() {
function PaymentReceiveFormBigTotal() { function PaymentReceiveFormBigTotal() {
// Formik form context. // Formik form context.
const { const {
values: { currency_code, entries }, values: { currency_code, amount },
} = useFormikContext(); } = useFormikContext();
// Calculates the total payment amount from due amount.
const paymentFullAmount = useMemo(
() => sumBy(entries, 'payment_amount'),
[entries],
);
return ( return (
<div className={classNames(CLASSES.PAGE_FORM_HEADER_BIG_NUMBERS)}> <div className={classNames(CLASSES.PAGE_FORM_HEADER_BIG_NUMBERS)}>
<div class="big-amount"> <div class="big-amount">
@@ -46,7 +40,7 @@ function PaymentReceiveFormBigTotal() {
<T id={'amount_received'} /> <T id={'amount_received'} />
</span> </span>
<h1 class="big-amount__number"> <h1 class="big-amount__number">
<Money amount={paymentFullAmount} currency={currency_code} /> <Money amount={amount} currency={currency_code} />
</h1> </h1>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
// @ts-nocheck // @ts-nocheck
import React, { createContext, useContext } from 'react'; import React, { createContext, useContext, useState } from 'react';
import { Features } from '@/constants'; import { Features } from '@/constants';
import { useFeatureCan } from '@/hooks/state'; import { useFeatureCan } from '@/hooks/state';
import { DashboardInsider } from '@/components'; import { DashboardInsider } from '@/components';
@@ -74,6 +74,8 @@ function PaymentReceiveFormProvider({ query, paymentReceiveId, ...props }) {
const { mutateAsync: editPaymentReceiveMutate } = useEditPaymentReceive(); const { mutateAsync: editPaymentReceiveMutate } = useEditPaymentReceive();
const { mutateAsync: createPaymentReceiveMutate } = useCreatePaymentReceive(); const { mutateAsync: createPaymentReceiveMutate } = useCreatePaymentReceive();
const [isExcessConfirmed, setIsExcessConfirmed] = useState<boolean>(false);
// Provider payload. // Provider payload.
const provider = { const provider = {
paymentReceiveId, paymentReceiveId,
@@ -97,6 +99,9 @@ function PaymentReceiveFormProvider({ query, paymentReceiveId, ...props }) {
editPaymentReceiveMutate, editPaymentReceiveMutate,
createPaymentReceiveMutate, createPaymentReceiveMutate,
isExcessConfirmed,
setIsExcessConfirmed,
}; };
return ( return (

View File

@@ -11,7 +11,7 @@ import {
Button, Button,
} from '@blueprintjs/core'; } from '@blueprintjs/core';
import { DateInput } from '@blueprintjs/datetime'; import { DateInput } from '@blueprintjs/datetime';
import { toSafeInteger } from 'lodash'; import { isEmpty, toSafeInteger } from 'lodash';
import { FastField, Field, useFormikContext, ErrorMessage } from 'formik'; import { FastField, Field, useFormikContext, ErrorMessage } from 'formik';
import { import {
@@ -124,11 +124,11 @@ export default function PaymentReceiveHeaderFields() {
</FastField> </FastField>
{/* ------------ Full amount ------------ */} {/* ------------ Full amount ------------ */}
<Field name={'full_amount'}> <Field name={'amount'}>
{({ {({
form: { form: {
setFieldValue, setFieldValue,
values: { currency_code }, values: { currency_code, entries },
}, },
field: { value, onChange }, field: { value, onChange },
meta: { error, touched }, meta: { error, touched },
@@ -146,12 +146,13 @@ export default function PaymentReceiveHeaderFields() {
<MoneyInputGroup <MoneyInputGroup
value={value} value={value}
onChange={(value) => { onChange={(value) => {
setFieldValue('full_amount', value); setFieldValue('amount', value);
}} }}
onBlurValue={onFullAmountBlur} onBlurValue={onFullAmountBlur}
/> />
</ControlGroup> </ControlGroup>
{!isEmpty(entries) && (
<Button <Button
onClick={handleReceiveFullAmountClick} onClick={handleReceiveFullAmountClick}
className={'receive-full-amount'} className={'receive-full-amount'}
@@ -161,6 +162,7 @@ export default function PaymentReceiveHeaderFields() {
<T id={'receive_full_amount'} /> ( <T id={'receive_full_amount'} /> (
<Money amount={totalDueAmount} currency={currency_code} />) <Money amount={totalDueAmount} currency={currency_code} />)
</Button> </Button>
)}
</FormGroup> </FormGroup>
)} )}
</Field> </Field>

View File

@@ -0,0 +1,37 @@
// @ts-nocheck
import React from 'react';
import { Dialog, DialogSuspense } from '@/components';
import withDialogRedux from '@/components/DialogReduxConnect';
import { compose } from '@/utils';
const ExcessPaymentDialogContent = React.lazy(() =>
import('./ExcessPaymentDialogContent').then((module) => ({
default: module.ExcessPaymentDialogContent,
})),
);
/**
* Excess payment dialog of the payment received form.
*/
function ExcessPaymentDialogRoot({ dialogName, isOpen }) {
return (
<Dialog
name={dialogName}
title={'Excess Payment'}
isOpen={isOpen}
canEscapeJeyClose={true}
autoFocus={true}
style={{ width: 500 }}
>
<DialogSuspense>
<ExcessPaymentDialogContent dialogName={dialogName} />
</DialogSuspense>
</Dialog>
);
}
export const ExcessPaymentDialog = compose(withDialogRedux())(
ExcessPaymentDialogRoot,
);
ExcessPaymentDialog.displayName = 'ExcessPaymentDialog';

View File

@@ -0,0 +1,86 @@
// @ts-nocheck
import * as Yup from 'yup';
import * as R from 'ramda';
import { Button, Classes, Intent } from '@blueprintjs/core';
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import { FormatNumber } from '@/components';
import { usePaymentReceiveFormContext } from '../../PaymentReceiveFormProvider';
import withDialogActions from '@/containers/Dialog/withDialogActions';
import { usePaymentReceivedTotalExceededAmount } from '../../utils';
interface ExcessPaymentValues {}
export function ExcessPaymentDialogContentRoot({ dialogName, closeDialog }) {
const {
submitForm,
values: { currency_code: currencyCode },
} = useFormikContext();
const { setIsExcessConfirmed } = usePaymentReceiveFormContext();
const exceededAmount = usePaymentReceivedTotalExceededAmount();
const handleSubmit = (
values: ExcessPaymentValues,
{ setSubmitting }: FormikHelpers<ExcessPaymentValues>,
) => {
setSubmitting(true);
setIsExcessConfirmed(true);
submitForm().then(() => {
closeDialog(dialogName);
setSubmitting(false);
});
};
const handleClose = () => {
closeDialog(dialogName);
};
return (
<Formik initialValues={{}} onSubmit={handleSubmit}>
<Form>
<ExcessPaymentDialogContentForm
exceededAmount={
<FormatNumber value={exceededAmount} currency={currencyCode} />
}
onClose={handleClose}
/>
</Form>
</Formik>
);
}
export const ExcessPaymentDialogContent = R.compose(withDialogActions)(
ExcessPaymentDialogContentRoot,
);
function ExcessPaymentDialogContentForm({ onClose, exceededAmount }) {
const { submitForm, isSubmitting } = useFormikContext();
const handleCloseBtn = () => {
onClose && onClose();
};
return (
<>
<div className={Classes.DIALOG_BODY}>
<p style={{ marginBottom: 20 }}>
Would you like to record the excess amount of{' '}
<strong>{exceededAmount}</strong> as credit payment from the customer.
</p>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button
intent={Intent.PRIMARY}
loading={isSubmitting}
disabled={isSubmitting}
onClick={() => submitForm()}
>
Save Payment as Credit
</Button>
<Button onClick={handleCloseBtn}>Cancel</Button>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1 @@
export * from './ExcessPaymentDialog';

View File

@@ -42,12 +42,12 @@ export const defaultPaymentReceive = {
// Holds the payment number that entered manually only. // Holds the payment number that entered manually only.
payment_receive_no_manually: '', payment_receive_no_manually: '',
statement: '', statement: '',
full_amount: '', amount: '',
currency_code: '', currency_code: '',
branch_id: '', branch_id: '',
exchange_rate: 1, exchange_rate: 1,
entries: [], entries: [],
attachments: [] attachments: [],
}; };
export const defaultRequestPaymentEntry = { export const defaultRequestPaymentEntry = {
@@ -249,6 +249,30 @@ export const usePaymentReceiveTotals = () => {
}; };
}; };
export const usePaymentReceivedTotalAppliedAmount = () => {
const {
values: { entries },
} = useFormikContext();
// Retrieves the invoice entries total.
return React.useMemo(() => sumBy(entries, 'payment_amount'), [entries]);
};
export const usePaymentReceivedTotalAmount = () => {
const {
values: { amount },
} = useFormikContext();
return amount;
};
export const usePaymentReceivedTotalExceededAmount = () => {
const totalAmount = usePaymentReceivedTotalAmount();
const totalApplied = usePaymentReceivedTotalAppliedAmount();
return Math.abs(totalAmount - totalApplied);
};
/** /**
* Detarmines whether the payment has foreign customer. * Detarmines whether the payment has foreign customer.
* @returns {boolean} * @returns {boolean}
@@ -273,3 +297,10 @@ export const resetFormState = ({ initialValues, values, resetForm }) => {
}, },
}); });
}; };
export const getExceededAmountFromValues = (values) => {
const totalApplied = sumBy(values.entries, 'payment_amount');
const totalAmount = values.amount;
return totalAmount - totalApplied;
};

View File

@@ -5,7 +5,6 @@ import '@/style/pages/PaymentReceive/List.scss';
import { DashboardPageContent } from '@/components'; import { DashboardPageContent } from '@/components';
import { PaymentReceivesListProvider } from './PaymentReceiptsListProvider'; import { PaymentReceivesListProvider } from './PaymentReceiptsListProvider';
import PaymentReceiveViewTabs from './PaymentReceiveViewTabs';
import PaymentReceivesTable from './PaymentReceivesTable'; import PaymentReceivesTable from './PaymentReceivesTable';
import PaymentReceiveActionsBar from './PaymentReceiveActionsBar'; import PaymentReceiveActionsBar from './PaymentReceiveActionsBar';
@@ -41,7 +40,6 @@ function PaymentReceiveList({
<PaymentReceiveActionsBar /> <PaymentReceiveActionsBar />
<DashboardPageContent> <DashboardPageContent>
<PaymentReceiveViewTabs />
<PaymentReceivesTable /> <PaymentReceivesTable />
</DashboardPageContent> </DashboardPageContent>
</PaymentReceivesListProvider> </PaymentReceivesListProvider>

View File

@@ -7,7 +7,6 @@ import { DashboardPageContent } from '@/components';
import { VendorsListProvider } from './VendorsListProvider'; import { VendorsListProvider } from './VendorsListProvider';
import VendorActionsBar from './VendorActionsBar'; import VendorActionsBar from './VendorActionsBar';
import VendorViewsTabs from './VendorViewsTabs';
import VendorsTable from './VendorsTable'; import VendorsTable from './VendorsTable';
import withVendors from './withVendors'; import withVendors from './withVendors';
@@ -42,7 +41,6 @@ function VendorsList({
<VendorActionsBar /> <VendorActionsBar />
<DashboardPageContent> <DashboardPageContent>
<VendorViewsTabs />
<VendorsTable /> <VendorsTable />
</DashboardPageContent> </DashboardPageContent>
</VendorsListProvider> </VendorsListProvider>

View File

@@ -3,7 +3,6 @@ import React from 'react';
import { DashboardPageContent } from '@/components'; import { DashboardPageContent } from '@/components';
import WarehouseTransfersActionsBar from './WarehouseTransfersActionsBar'; import WarehouseTransfersActionsBar from './WarehouseTransfersActionsBar';
import WarehouseTransfersViewTabs from './WarehouseTransfersViewTabs';
import WarehouseTransfersDataTable from './WarehouseTransfersDataTable'; import WarehouseTransfersDataTable from './WarehouseTransfersDataTable';
import withWarehouseTransfers from './withWarehouseTransfers'; import withWarehouseTransfers from './withWarehouseTransfers';
import withWarehouseTransfersActions from './withWarehouseTransfersActions'; import withWarehouseTransfersActions from './withWarehouseTransfersActions';
@@ -33,8 +32,8 @@ function WarehouseTransfersList({
tableStateChanged={warehouseTransferTableStateChanged} tableStateChanged={warehouseTransferTableStateChanged}
> >
<WarehouseTransfersActionsBar /> <WarehouseTransfersActionsBar />
<DashboardPageContent> <DashboardPageContent>
<WarehouseTransfersViewTabs />
<WarehouseTransfersDataTable /> <WarehouseTransfersDataTable />
</DashboardPageContent> </DashboardPageContent>
</WarehouseTransfersListProvider> </WarehouseTransfersListProvider>

View File

@@ -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 { interface EditBankRuleValues {
id: number; id: number;
value: any; 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; type ExcludeUncategorizedTransactionValue = number;
interface ExcludeUncategorizedTransactionRes {} interface ExcludeUncategorizedTransactionRes {}
@@ -228,19 +312,7 @@ export function useExcludeUncategorizedTransaction(
), ),
{ {
onSuccess: (res, id) => { onSuccess: (res, id) => {
// Invalidate queries. onValidateExcludeUncategorizedTransaction(queryClient);
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);
}, },
...options, ...options,
}, },
@@ -281,19 +353,83 @@ export function useUnexcludeUncategorizedTransaction(
), ),
{ {
onSuccess: (res, id) => { onSuccess: (res, id) => {
// Invalidate queries. onValidateExcludeUncategorizedTransaction(queryClient);
queryClient.invalidateQueries( },
QUERY_KEY.EXCLUDED_BANK_TRANSACTIONS_INFINITY, ...options,
},
); );
queryClient.invalidateQueries( }
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
// Invalidate accounts.
queryClient.invalidateQueries(t.ACCOUNTS);
queryClient.invalidateQueries(t.ACCOUNT);
// Invalidate bank account summary. type ExcludeBankTransactionsValue = { ids: Array<number | string> };
queryClient.invalidateQueries(QUERY_KEY.BANK_ACCOUNT_SUMMARY_META); 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, ...options,
}, },

View File

@@ -635,4 +635,11 @@ export default {
], ],
viewBox: '0 0 16 16', 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',
},
}; };

View File

@@ -5,6 +5,9 @@ interface StorePlaidState {
openMatchingTransactionAside: boolean; openMatchingTransactionAside: boolean;
uncategorizedTransactionIdForMatching: number | null; uncategorizedTransactionIdForMatching: number | null;
openReconcileMatchingTransaction: { isOpen: boolean; pending: number }; openReconcileMatchingTransaction: { isOpen: boolean; pending: number };
uncategorizedTransactionsSelected: Array<number | string>;
excludedTransactionsSelected: Array<number | string>;
} }
export const PlaidSlice = createSlice({ export const PlaidSlice = createSlice({
@@ -17,6 +20,8 @@ export const PlaidSlice = createSlice({
isOpen: false, isOpen: false,
pending: 0, pending: 0,
}, },
uncategorizedTransactionsSelected: [],
excludedTransactionsSelected: [],
} as StorePlaidState, } as StorePlaidState,
reducers: { reducers: {
setPlaidId: (state: StorePlaidState, action: PayloadAction<string>) => { setPlaidId: (state: StorePlaidState, action: PayloadAction<string>) => {
@@ -52,6 +57,46 @@ export const PlaidSlice = createSlice({
state.openReconcileMatchingTransaction.isOpen = false; state.openReconcileMatchingTransaction.isOpen = false;
state.openReconcileMatchingTransaction.pending = 0; 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, closeMatchingTransactionAside,
openReconcileMatchingTransaction, openReconcileMatchingTransaction,
closeReconcileMatchingTransaction, closeReconcileMatchingTransaction,
setUncategorizedTransactionsSelected,
resetUncategorizedTransactionsSelected,
setExcludedTransactionsSelected,
resetExcludedTransactionsSelected,
} = PlaidSlice.actions; } = PlaidSlice.actions;
export const getPlaidToken = (state: any) => state.plaid.plaidToken; export const getPlaidToken = (state: any) => state.plaid.plaidToken;

View File

@@ -128,18 +128,14 @@
cursor: auto; cursor: auto;
&, &,
&:hover { &::before {
height: 15px; height: 15px;
width: 15px; width: 15px;
} }
} }
.bp4-control.bp4-checkbox { .bp4-control.bp4-checkbox input:not(:checked):not(:indeterminate) ~ .bp4-control-indicator{
box-shadow: inset 0 0 0 1px #C5CBD3;
input:checked~.bp4-control-indicator,
input:indeterminate~.bp4-control-indicator {
border-color: #0052ff;
}
} }
.skeleton { .skeleton {

View File

@@ -208,12 +208,16 @@ $dashboard-views-bar-height: 44px;
} }
&.#{$ns}-minimal.#{$ns}-intent-danger { &.#{$ns}-minimal.#{$ns}-intent-danger {
color: #c23030; color: rgb(194, 48, 48);
&:not(.bp4-disabled)
&:hover, &:hover,
&:focus { &:focus {
background: rgba(219, 55, 55, 0.1); background: rgba(219, 55, 55, 0.1);
} }
&.bp4-disabled{
color: rgb(194, 48, 48, 0.6);
}
} }
&.#{$ns}-minimal.#{$ns}-intent-success{ &.#{$ns}-minimal.#{$ns}-intent-success{
color: #1c6e42; color: #1c6e42;