Compare commits

...

30 Commits

Author SHA1 Message Date
Ahmed Bouhuolia
8f30c86f5f fizx: remove debug console.log 2024-07-30 21:54:55 +02:00
Ahmed Bouhuolia
6affbedef4 feat: description to billing page 2024-07-30 21:43:33 +02:00
Ahmed Bouhuolia
ba7f32c1bf feat: abstract the pricing plans for setup and billing page 2024-07-30 17:47:03 +02:00
Ahmed Bouhuolia
07c57ed539 Merge branch 'develop' into billing-subscription-page 2024-07-30 10:03:31 +02:00
Ahmed Bouhuolia
788150f80d Merge pull request #549 from oleynikd/s3-path-style
Added support of path-style S3 endpoints
2024-07-30 00:06:16 +02:00
Denis
c09384e49b Added support of path-style S3 endpoints
This can be very useful when using S3-compatible object storages like MinIO
2024-07-29 23:48:29 +03:00
Ahmed Bouhuolia
e11f1a95f6 Merge pull request #529 from bigcapitalhq/disconnect-bank-account
feat: Disconnect bank account
2024-07-29 20:18:36 +02:00
Ahmed Bouhuolia
b91273eee4 Merge branch 'develop' into disconnect-bank-account 2024-07-29 20:17:09 +02:00
Ahmed Bouhuolia
b5d570417b fix: add events interfaces of disconnect bank account 2024-07-29 20:10:15 +02:00
Ahmed Bouhuolia
acd3265e35 feat: add migration to is_syncing_owner column in accounts table 2024-07-29 20:01:04 +02:00
Ahmed Bouhuolia
894c899847 feat: improvement in Plaid accounts disconnecting 2024-07-29 19:49:20 +02:00
Ahmed Bouhuolia
f6d4ec504f feat: tweaks in disconnecting bank account 2024-07-29 16:55:50 +02:00
Ahmed Bouhuolia
1a01461f5d feat: delete Plaid item once bank account deleted 2024-07-29 16:20:59 +02:00
Ahmed Bouhuolia
89552d7ee2 Merge pull request #532 from bigcapitalhq/bulk-exclude-bank-transactions
feat: Bulk exclude bank transactions
2024-07-29 13:01:56 +02:00
Ahmed Bouhuolia
4345623ea9 feat: document functions 2024-07-29 13:00:50 +02:00
Ahmed Bouhuolia
f457759e39 Merge branch 'develop' into bulk-exclude-bank-transactions 2024-07-29 12:00:49 +02:00
Ahmed Bouhuolia
14d5e82b4a fix: style of database checkbox 2024-07-29 12:00:34 +02:00
Ahmed Bouhuolia
333b6f5a4b feat: change subscription plan 2024-07-28 20:52:53 +02:00
Ahmed Bouhuolia
1660df20af feat: wip billing page 2024-07-28 17:53:55 +02:00
Ahmed Bouhuolia
14a9c4ba28 fix: style tweaks in billing page 2024-07-27 21:56:55 +02:00
Ahmed Bouhuolia
383be111fa feat: style the billing page 2024-07-27 21:47:17 +02:00
Ahmed Bouhuolia
7720b1cc34 feat: getting subscription endpoint 2024-07-27 17:39:50 +02:00
Ahmed Bouhuolia
db634cbb79 feat: pause, resume main subscription 2024-07-27 16:55:56 +02:00
Ahmed Bouhuolia
53f37f4f48 Merge pull request #546 from bigcapitalhq/remove-views-tabs
feat: Remove the views tabs bar from all tables
2024-07-25 19:21:50 +02:00
Ahmed Bouhuolia
0a7b522b87 chore: remove unused import 2024-07-25 19:21:16 +02:00
Ahmed Bouhuolia
9e6500ac79 feat: remove the views tabs bar from all tables 2024-07-25 19:17:54 +02:00
Ahmed Bouhuolia
998e6de211 feat: billing subscription page 2024-07-25 15:21:01 +02:00
Ahmed Bouhuolia
51471ed000 feat: exclude bank transactions in bulk 2024-07-17 23:19:59 +02:00
Ahmed Bouhuolia
c2815afbe3 feat: disconnect and update bank account 2024-07-16 17:09:00 +02:00
Ahmed Bouhuolia
fa7e6b1fca feat: disconnect bank account 2024-07-15 23:18:39 +02:00
100 changed files with 2707 additions and 708 deletions

View File

@@ -3,12 +3,16 @@ import { NextFunction, Request, Response, Router } from 'express';
import BaseController from '@/api/controllers/BaseController';
import { CashflowApplication } from '@/services/Cashflow/CashflowApplication';
import { GetBankAccountSummary } from '@/services/Banking/BankAccounts/GetBankAccountSummary';
import { BankAccountsApplication } from '@/services/Banking/BankAccounts/BankAccountsApplication';
@Service()
export class BankAccountsController extends BaseController {
@Inject()
private getBankAccountSummaryService: GetBankAccountSummary;
@Inject()
private bankAccountsApp: BankAccountsApplication;
/**
* Router constructor.
*/
@@ -16,6 +20,11 @@ export class BankAccountsController extends BaseController {
const router = Router();
router.get('/:bankAccountId/meta', this.getBankAccountSummary.bind(this));
router.post(
'/:bankAccountId/disconnect',
this.disconnectBankAccount.bind(this)
);
router.post('/:bankAccountId/update', this.refreshBankAccount.bind(this));
return router;
}
@@ -46,4 +55,58 @@ export class BankAccountsController extends BaseController {
next(error);
}
}
/**
* Disonnect the given bank account.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response|null>}
*/
async disconnectBankAccount(
req: Request<{ bankAccountId: number }>,
res: Response,
next: NextFunction
) {
const { bankAccountId } = req.params;
const { tenantId } = req;
try {
await this.bankAccountsApp.disconnectBankAccount(tenantId, bankAccountId);
return res.status(200).send({
id: bankAccountId,
message: 'The bank account has been disconnected.',
});
} catch (error) {
next(error);
}
}
/**
* Refresh the given bank account.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response|null>}
*/
async refreshBankAccount(
req: Request<{ bankAccountId: number }>,
res: Response,
next: NextFunction
) {
const { bankAccountId } = req.params;
const { tenantId } = req;
try {
await this.bankAccountsApp.refreshBankAccount(tenantId, bankAccountId);
return res.status(200).send({
id: bankAccountId,
message: 'The bank account has been disconnected.',
});
} catch (error) {
next(error);
}
}
}

View File

@@ -1,8 +1,9 @@
import { Inject, Service } from 'typedi';
import { param } from 'express-validator';
import { NextFunction, Request, Response, Router, query } from 'express';
import { body, param, query } from 'express-validator';
import { NextFunction, Request, Response, Router } from 'express';
import BaseController from '../BaseController';
import { ExcludeBankTransactionsApplication } from '@/services/Banking/Exclude/ExcludeBankTransactionsApplication';
import { map, parseInt, trim } from 'lodash';
@Service()
export class ExcludeBankTransactionsController extends BaseController {
@@ -15,9 +16,21 @@ export class ExcludeBankTransactionsController extends BaseController {
public router() {
const router = Router();
router.put(
'/transactions/exclude',
[body('ids').exists()],
this.validationResult,
this.excludeBulkBankTransactions.bind(this)
);
router.put(
'/transactions/unexclude',
[body('ids').exists()],
this.validationResult,
this.unexcludeBulkBankTransactins.bind(this)
);
router.put(
'/transactions/:transactionId/exclude',
[param('transactionId').exists()],
[param('transactionId').exists().toInt()],
this.validationResult,
this.excludeBankTransaction.bind(this)
);
@@ -94,6 +107,63 @@ export class ExcludeBankTransactionsController extends BaseController {
}
}
/**
* Exclude bank transactions in bulk.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
private async excludeBulkBankTransactions(
req: Request,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
const { ids } = this.matchedBodyData(req);
try {
await this.excludeBankTransactionApp.excludeBankTransactions(
tenantId,
ids
);
return res.status(200).send({
message: 'The given bank transactions have been excluded',
ids,
});
} catch (error) {
next(error);
}
}
/**
* Unexclude the given bank transactions in bulk.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response | null>}
*/
private async unexcludeBulkBankTransactins(
req: Request,
res: Response,
next: NextFunction
): Promise<Response | null> {
const { tenantId } = req;
const { ids } = this.matchedBodyData(req);
try {
await this.excludeBankTransactionApp.unexcludeBankTransactions(
tenantId,
ids
);
return res.status(200).send({
message: 'The given bank transactions have been excluded',
ids,
});
} catch (error) {
next(error);
}
}
/**
* Retrieves the excluded uncategorized bank transactions.
* @param {Request} req
@@ -109,7 +179,6 @@ export class ExcludeBankTransactionsController extends BaseController {
const { tenantId } = req;
const filter = this.matchedBodyData(req);
console.log('123');
try {
const data =
await this.excludeBankTransactionApp.getExcludedBankTransactions(

View File

@@ -8,6 +8,7 @@ import SubscriptionService from '@/services/Subscription/SubscriptionService';
import asyncMiddleware from '@/api/middleware/asyncMiddleware';
import BaseController from '../BaseController';
import { LemonSqueezyService } from '@/services/Subscription/LemonSqueezyService';
import { SubscriptionApplication } from '@/services/Subscription/SubscriptionApplication';
@Service()
export class SubscriptionController extends BaseController {
@@ -17,6 +18,9 @@ export class SubscriptionController extends BaseController {
@Inject()
private lemonSqueezyService: LemonSqueezyService;
@Inject()
private subscriptionApp: SubscriptionApplication;
/**
* Router constructor.
*/
@@ -33,6 +37,14 @@ export class SubscriptionController extends BaseController {
this.validationResult,
this.getCheckoutUrl.bind(this)
);
router.post('/cancel', asyncMiddleware(this.cancelSubscription.bind(this)));
router.post('/resume', asyncMiddleware(this.resumeSubscription.bind(this)));
router.post(
'/change',
[body('variant_id').exists().trim()],
this.validationResult,
asyncMiddleware(this.changeSubscriptionPlan.bind(this))
);
router.get('/', asyncMiddleware(this.getSubscriptions.bind(this)));
return router;
@@ -85,4 +97,84 @@ export class SubscriptionController extends BaseController {
next(error);
}
}
/**
* Cancels the subscription of the current organization.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response|null>}
*/
private async cancelSubscription(
req: Request,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
try {
await this.subscriptionApp.cancelSubscription(tenantId, '455610');
return res.status(200).send({
status: 200,
message: 'The organization subscription has been canceled.',
});
} catch (error) {
next(error);
}
}
/**
* Resumes the subscription of the current organization.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response | null>}
*/
private async resumeSubscription(
req: Request,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
try {
await this.subscriptionApp.resumeSubscription(tenantId);
return res.status(200).send({
status: 200,
message: 'The organization subscription has been resumed.',
});
} catch (error) {
next(error);
}
}
/**
* Changes the main subscription plan of the current organization.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response | null>}
*/
public async changeSubscriptionPlan(
req: Request,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
const body = this.matchedBodyData(req);
try {
await this.subscriptionApp.changeSubscriptionPlan(
tenantId,
body.variantId
);
return res.status(200).send({
message: 'The subscription plan has been changed.',
});
} catch (error) {
next(error);
}
}
}

View File

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

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

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

View File

@@ -1,69 +1,12 @@
import { forEach } from 'lodash';
import { Configuration, PlaidApi, PlaidEnvironments } from 'plaid';
import { createPlaidApiEvent } from './PlaidApiEventsDBSync';
import config from '@/config';
const OPTIONS = { clientApp: 'Plaid-Pattern' };
// We want to log requests to / responses from the Plaid API (via the Plaid client), as this data
// can be useful for troubleshooting.
/**
* Logging function for Plaid client methods that use an access_token as an argument. Associates
* the Plaid API event log entry with the item and user the request is for.
*
* @param {string} clientMethod the name of the Plaid client method called.
* @param {Array} clientMethodArgs the arguments passed to the Plaid client method.
* @param {Object} response the response from the Plaid client.
*/
const defaultLogger = async (clientMethod, clientMethodArgs, response) => {
const accessToken = clientMethodArgs[0].access_token;
// const { id: itemId, user_id: userId } = await retrieveItemByPlaidAccessToken(
// accessToken
// );
// await createPlaidApiEvent(1, 1, clientMethod, clientMethodArgs, response);
// console.log(response);
};
/**
* Logging function for Plaid client methods that do not use access_token as an argument. These
* Plaid API event log entries will not be associated with an item or user.
*
* @param {string} clientMethod the name of the Plaid client method called.
* @param {Array} clientMethodArgs the arguments passed to the Plaid client method.
* @param {Object} response the response from the Plaid client.
*/
const noAccessTokenLogger = async (
clientMethod,
clientMethodArgs,
response
) => {
// console.log(response);
// await createPlaidApiEvent(
// undefined,
// undefined,
// clientMethod,
// clientMethodArgs,
// response
// );
};
// Plaid client methods used in this app, mapped to their appropriate logging functions.
const clientMethodLoggingFns = {
accountsGet: defaultLogger,
institutionsGet: noAccessTokenLogger,
institutionsGetById: noAccessTokenLogger,
itemPublicTokenExchange: noAccessTokenLogger,
itemRemove: defaultLogger,
linkTokenCreate: noAccessTokenLogger,
transactionsSync: defaultLogger,
sandboxItemResetLogin: defaultLogger,
};
// Wrapper for the Plaid client. This allows us to easily log data for all Plaid client requests.
export class PlaidClientWrapper {
constructor() {
private static instance: PlaidClientWrapper;
private client: PlaidApi;
private constructor() {
// Initialize the Plaid client.
const configuration = new Configuration({
basePath: PlaidEnvironments[config.plaid.env],
@@ -75,26 +18,13 @@ export class PlaidClientWrapper {
},
},
});
this.client = new PlaidApi(configuration);
// Wrap the Plaid client methods to add a logging function.
forEach(clientMethodLoggingFns, (logFn, method) => {
this[method] = this.createWrappedClientMethod(method, logFn);
});
}
// Allows us to log API request data for troubleshooting purposes.
createWrappedClientMethod(clientMethod, log) {
return async (...args) => {
try {
const res = await this.client[clientMethod](...args);
await log(clientMethod, args, res);
return res;
} catch (err) {
await log(clientMethod, args, err?.response?.data);
throw err;
}
};
public static getClient(): PlaidApi {
if (!PlaidClientWrapper.instance) {
PlaidClientWrapper.instance = new PlaidClientWrapper();
}
return PlaidClientWrapper.instance.client;
}
}

View File

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

View File

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

View File

@@ -197,6 +197,7 @@ export default class Account extends mixin(TenantModel, [
const ExpenseEntry = require('models/ExpenseCategory');
const ItemEntry = require('models/ItemEntry');
const UncategorizedTransaction = require('models/UncategorizedCashflowTransaction');
const PlaidItem = require('models/PlaidItem');
return {
/**
@@ -321,6 +322,18 @@ export default class Account extends mixin(TenantModel, [
query.where('categorized', false);
},
},
/**
* Account model may belongs to a Plaid item.
*/
plaidItem: {
relation: Model.BelongsToOneRelation,
modelClass: PlaidItem.default,
join: {
from: 'accounts.plaidItemId',
to: 'plaid_items.plaidItemId',
},
},
};
}

View File

@@ -13,7 +13,12 @@ export class AccountTransformer extends Transformer {
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return ['formattedAmount', 'flattenName', 'bankBalanceFormatted'];
return [
'formattedAmount',
'flattenName',
'bankBalanceFormatted',
'lastFeedsUpdatedAtFormatted',
];
};
/**
@@ -52,6 +57,15 @@ export class AccountTransformer extends Transformer {
});
};
/**
* Retrieves the formatted last feeds update at.
* @param {IAccount} account
* @returns {string}
*/
protected lastFeedsUpdatedAtFormatted = (account: IAccount): string => {
return this.formatDate(account.lastFeedsUpdatedAt);
};
/**
* Transformes the accounts collection to flat or nested array.
* @param {IAccount[]}

View File

@@ -96,6 +96,11 @@ export class CreateAccount {
...createAccountDTO,
slug: kebabCase(createAccountDTO.name),
currencyCode: createAccountDTO.currencyCode || baseCurrency,
// Mark the account is Plaid owner since Plaid item/account is defined on creating.
isSyncingOwner: Boolean(
createAccountDTO.plaidAccountId || createAccountDTO.plaidItemId
),
};
};
@@ -117,12 +122,7 @@ export class CreateAccount {
const tenantMeta = await TenantMetadata.query().findOne({ tenantId });
// Authorize the account creation.
await this.authorize(
tenantId,
accountDTO,
tenantMeta.baseCurrency,
params
);
await this.authorize(tenantId, accountDTO, tenantMeta.baseCurrency, params);
// Transformes the DTO to model.
const accountInputModel = this.transformDTOToModel(
accountDTO,
@@ -157,4 +157,3 @@ export class CreateAccount {
);
};
}

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 { GetExcludedBankTransactionsService } from './GetExcludedBankTransactions';
import { ExcludedBankTransactionsQuery } from './_types';
import { UnexcludeBankTransactions } from './UnexcludeBankTransactions';
import { ExcludeBankTransactions } from './ExcludeBankTransactions';
@Service()
export class ExcludeBankTransactionsApplication {
@@ -15,6 +17,12 @@ export class ExcludeBankTransactionsApplication {
@Inject()
private getExcludedBankTransactionsService: GetExcludedBankTransactionsService;
@Inject()
private excludeBankTransactionsService: ExcludeBankTransactions;
@Inject()
private unexcludeBankTransactionsService: UnexcludeBankTransactions;
/**
* Marks a bank transaction as excluded.
* @param {number} tenantId - The ID of the tenant.
@@ -56,4 +64,36 @@ export class ExcludeBankTransactionsApplication {
filter
);
}
/**
* Exclude the given bank transactions in bulk.
* @param {number} tenantId
* @param {Array<number> | number} bankTransactionIds
* @returns {Promise<void>}
*/
public excludeBankTransactions(
tenantId: number,
bankTransactionIds: Array<number> | number
): Promise<void> {
return this.excludeBankTransactionsService.excludeBankTransactions(
tenantId,
bankTransactionIds
);
}
/**
* Exclude the given bank transactions in bulk.
* @param {number} tenantId
* @param {Array<number> | number} bankTransactionIds
* @returns {Promise<void>}
*/
public unexcludeBankTransactions(
tenantId: number,
bankTransactionIds: Array<number> | number
): Promise<void> {
return this.unexcludeBankTransactionsService.unexcludeBankTransactions(
tenantId,
bankTransactionIds
);
}
}

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 { publicToken, institutionId } = itemDTO;
const plaidInstance = new PlaidClientWrapper();
const plaidInstance = PlaidClientWrapper.getClient();
// Exchange the public token for a private access token and store with the item.
const response = await plaidInstance.itemPublicTokenExchange({

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import { Inject, Service } from 'typedi';
import { IFeatureAllItem, ISystemUser } from '@/interfaces';
import { FeaturesManager } from '@/services/Features/FeaturesManager';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Inject, Service } from 'typedi';
import config from '@/config';
interface IRoleAbility {
subject: string;
@@ -11,15 +12,16 @@ interface IRoleAbility {
interface IDashboardBootMeta {
abilities: IRoleAbility[];
features: IFeatureAllItem[];
isBigcapitalCloud: boolean;
}
@Service()
export default class DashboardService {
@Inject()
tenancy: HasTenancyService;
private tenancy: HasTenancyService;
@Inject()
featuresManager: FeaturesManager;
private featuresManager: FeaturesManager;
/**
* Retrieve dashboard meta.
@@ -39,6 +41,7 @@ export default class DashboardService {
return {
abilities,
features,
isBigcapitalCloud: config.hostedOnBigcapitalCloud
};
};

View File

@@ -0,0 +1,168 @@
import { Transformer } from '@/lib/Transformer/Transformer';
export class GetSubscriptionsTransformer extends Transformer {
/**
* Include these attributes to sale invoice object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'canceledAtFormatted',
'endsAtFormatted',
'trialStartsAtFormatted',
'trialEndsAtFormatted',
'statusFormatted',
'planName',
'planSlug',
'planPrice',
'planPriceCurrency',
'planPriceFormatted',
'planPeriod',
'lemonUrls',
];
};
/**
* Exclude attributes.
* @returns {string[]}
*/
public excludeAttributes = (): string[] => {
return ['id', 'plan'];
};
/**
* Retrieves the canceled at formatted.
* @param subscription
* @returns {string}
*/
public canceledAtFormatted = (subscription) => {
return subscription.canceledAt
? this.formatDate(subscription.canceledAt)
: null;
};
/**
* Retrieves the ends at date formatted.
* @param subscription
* @returns {string}
*/
public endsAtFormatted = (subscription) => {
return subscription.cancelsAt
? this.formatDate(subscription.endsAt)
: null;
};
/**
* Retrieves the trial starts at formatted date.
* @returns {string}
*/
public trialStartsAtFormatted = (subscription) => {
return subscription.trialStartsAt
? this.formatDate(subscription.trialStartsAt)
: null;
};
/**
* Retrieves the trial ends at formatted date.
* @returns {string}
*/
public trialEndsAtFormatted = (subscription) => {
return subscription.trialEndsAt
? this.formatDate(subscription.trialEndsAt)
: null;
};
/**
* Retrieves the Lemon subscription metadata.
* @param subscription
* @returns
*/
public lemonSubscription = (subscription) => {
return (
this.options.lemonSubscriptions[subscription.lemonSubscriptionId] || null
);
};
/**
* Retrieves the formatted subscription status.
* @param subscription
* @returns {string}
*/
public statusFormatted = (subscription) => {
const pairs = {
canceled: 'Canceled',
active: 'Active',
inactive: 'Inactive',
expired: 'Expired',
on_trial: 'On Trial',
};
return pairs[subscription.status] || '';
};
/**
* Retrieves the subscription plan name.
* @param subscription
* @returns {string}
*/
public planName(subscription) {
return subscription.plan?.name;
}
/**
* Retrieves the subscription plan slug.
* @param subscription
* @returns {string}
*/
public planSlug(subscription) {
return subscription.plan?.slug;
}
/**
* Retrieves the subscription plan price.
* @param subscription
* @returns {number}
*/
public planPrice(subscription) {
return subscription.plan?.price;
}
/**
* Retrieves the subscription plan price currency.
* @param subscription
* @returns {string}
*/
public planPriceCurrency(subscription) {
return subscription.plan?.currency;
}
/**
* Retrieves the subscription plan formatted price.
* @param subscription
* @returns {string}
*/
public planPriceFormatted(subscription) {
return this.formatMoney(subscription.plan?.price, {
currencyCode: subscription.plan?.currency,
precision: 0
});
}
/**
* Retrieves the subscription plan period.
* @param subscription
* @returns {string}
*/
public planPeriod(subscription) {
return subscription?.plan?.period;
}
/**
* Retrieve the subscription Lemon Urls.
* @param subscription
* @returns
*/
public lemonUrls = (subscription) => {
const lemonSusbcription = this.lemonSubscription(subscription);
return lemonSusbcription?.data?.attributes?.urls;
};
}

View File

@@ -0,0 +1,47 @@
import { Inject, Service } from 'typedi';
import { cancelSubscription } from '@lemonsqueezy/lemonsqueezy.js';
import { configureLemonSqueezy } from './utils';
import { PlanSubscription } from '@/system/models';
import { ServiceError } from '@/exceptions';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
import { ERRORS, IOrganizationSubscriptionCanceled } from './types';
@Service()
export class LemonCancelSubscription {
@Inject()
private eventPublisher: EventPublisher;
/**
* Cancels the subscription of the given tenant.
* @param {number} tenantId
* @param {number} subscriptionId
* @returns {Promise<void>}
*/
public async cancelSubscription(tenantId: number) {
configureLemonSqueezy();
const subscription = await PlanSubscription.query().findOne({
tenantId,
slug: 'main',
});
if (!subscription) {
throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT);
}
const lemonSusbcriptionId = subscription.lemonSubscriptionId;
const subscriptionId = subscription.id;
const cancelledSub = await cancelSubscription(lemonSusbcriptionId);
if (cancelledSub.error) {
throw new Error(cancelledSub.error.message);
}
await PlanSubscription.query().findById(subscriptionId).patch({
canceledAt: new Date(),
});
// Triggers `onSubscriptionCanceled` event.
await this.eventPublisher.emitAsync(
events.subscription.onSubscriptionCanceled,
{ tenantId, subscriptionId } as IOrganizationSubscriptionCanceled
);
}
}

View File

@@ -0,0 +1,47 @@
import { Inject, Service } from 'typedi';
import { updateSubscription } from '@lemonsqueezy/lemonsqueezy.js';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import { ServiceError } from '@/exceptions';
import { PlanSubscription } from '@/system/models';
import { configureLemonSqueezy } from './utils';
import events from '@/subscribers/events';
import { IOrganizationSubscriptionChanged } from './types';
@Service()
export class LemonChangeSubscriptionPlan {
@Inject()
private eventPublisher: EventPublisher;
/**
* Changes the given organization subscription plan.
* @param {number} tenantId - Tenant id.
* @param {number} newVariantId - New variant id.
* @returns {Promise<void>}
*/
public async changeSubscriptionPlan(tenantId: number, newVariantId: number) {
configureLemonSqueezy();
const subscription = await PlanSubscription.query().findOne({
tenantId,
slug: 'main',
});
const lemonSubscriptionId = subscription.lemonSubscriptionId;
// Send request to Lemon Squeezy to change the subscription.
const updatedSub = await updateSubscription(lemonSubscriptionId, {
variantId: newVariantId,
});
if (updatedSub.error) {
throw new ServiceError('SOMETHING_WENT_WRONG');
}
// Triggers `onSubscriptionPlanChanged` event.
await this.eventPublisher.emitAsync(
events.subscription.onSubscriptionPlanChanged,
{
tenantId,
lemonSubscriptionId,
newVariantId,
} as IOrganizationSubscriptionChanged
);
}
}

View File

@@ -0,0 +1,48 @@
import { Inject, Service } from 'typedi';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
import { configureLemonSqueezy } from './utils';
import { PlanSubscription } from '@/system/models';
import { ServiceError } from '@/exceptions';
import { ERRORS, IOrganizationSubscriptionResumed } from './types';
import { updateSubscription } from '@lemonsqueezy/lemonsqueezy.js';
@Service()
export class LemonResumeSubscription {
@Inject()
private eventPublisher: EventPublisher;
/**
* Resumes the main subscription of the given tenant.
* @param {number} tenantId -
* @returns {Promise<void>}
*/
public async resumeSubscription(tenantId: number) {
configureLemonSqueezy();
const subscription = await PlanSubscription.query().findOne({
tenantId,
slug: 'main',
});
if (!subscription) {
throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT);
}
const subscriptionId = subscription.id;
const lemonSubscriptionId = subscription.lemonSubscriptionId;
const returnedSub = await updateSubscription(lemonSubscriptionId, {
cancelled: false,
});
if (returnedSub.error) {
throw new ServiceError('');
}
// Update the subscription of the organization.
await PlanSubscription.query().findById(subscriptionId).patch({
canceledAt: null,
});
// Triggers `onSubscriptionCanceled` event.
await this.eventPublisher.emitAsync(
events.subscription.onSubscriptionResumed,
{ tenantId, subscriptionId } as IOrganizationSubscriptionResumed
);
}
}

View File

@@ -0,0 +1,48 @@
import { Inject, Service } from 'typedi';
import { LemonCancelSubscription } from './LemonCancelSubscription';
import { LemonChangeSubscriptionPlan } from './LemonChangeSubscriptionPlan';
import { LemonResumeSubscription } from './LemonResumeSubscription';
@Service()
export class SubscriptionApplication {
@Inject()
private cancelSubscriptionService: LemonCancelSubscription;
@Inject()
private resumeSubscriptionService: LemonResumeSubscription;
@Inject()
private changeSubscriptionPlanService: LemonChangeSubscriptionPlan;
/**
* Cancels the subscription of the given tenant.
* @param {number} tenantId
* @param {string} id
* @returns {Promise<void>}
*/
public cancelSubscription(tenantId: number, id: string) {
return this.cancelSubscriptionService.cancelSubscription(tenantId, id);
}
/**
* Resumes the subscription of the given tenant.
* @param {number} tenantId
* @returns {Promise<void>}
*/
public resumeSubscription(tenantId: number) {
return this.resumeSubscriptionService.resumeSubscription(tenantId);
}
/**
* Changes the given organization subscription plan.
* @param {number} tenantId
* @param {number} newVariantId
* @returns {Promise<void>}
*/
public changeSubscriptionPlan(tenantId: number, newVariantId: number) {
return this.changeSubscriptionPlanService.changeSubscriptionPlan(
tenantId,
newVariantId
);
}
}

View File

@@ -1,17 +1,50 @@
import { Service } from 'typedi';
import { Inject, Service } from 'typedi';
import { getSubscription } from '@lemonsqueezy/lemonsqueezy.js';
import { PromisePool } from '@supercharge/promise-pool';
import { PlanSubscription } from '@/system/models';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { GetSubscriptionsTransformer } from './GetSubscriptionsTransformer';
import { configureLemonSqueezy } from './utils';
import { fromPairs } from 'lodash';
@Service()
export default class SubscriptionService {
@Inject()
private transformer: TransformerInjectable;
/**
* Retrieve all subscription of the given tenant.
* @param {number} tenantId
*/
public async getSubscriptions(tenantId: number) {
const subscriptions = await PlanSubscription.query().where(
'tenant_id',
tenantId
configureLemonSqueezy();
const subscriptions = await PlanSubscription.query()
.where('tenant_id', tenantId)
.withGraphFetched('plan');
const lemonSubscriptionsResult = await PromisePool.withConcurrency(1)
.for(subscriptions)
.process(async (subscription, index, pool) => {
if (subscription.lemonSubscriptionId) {
const res = await getSubscription(subscription.lemonSubscriptionId);
if (res.error) {
return;
}
return [subscription.lemonSubscriptionId, res.data];
}
});
const lemonSubscriptions = fromPairs(
lemonSubscriptionsResult?.results.filter((result) => !!result[1])
);
return this.transformer.transform(
tenantId,
subscriptions,
new GetSubscriptionsTransformer(),
{
lemonSubscriptions,
}
);
return subscriptions;
}
}

View File

@@ -0,0 +1,20 @@
export const ERRORS = {
SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT:
'SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT',
};
export interface IOrganizationSubscriptionChanged {
tenantId: number;
lemonSubscriptionId: string;
newVariantId: number;
}
export interface IOrganizationSubscriptionCanceled {
tenantId: number;
subscriptionId: string;
}
export interface IOrganizationSubscriptionResumed {
tenantId: number;
subscriptionId: number;
}

View File

@@ -41,9 +41,12 @@ export default {
},
/**
* User subscription events.
* Organization subscription.
*/
subscription: {
onSubscriptionCanceled: 'onSubscriptionCanceled',
onSubscriptionResumed: 'onSubscriptionResumed',
onSubscriptionPlanChanged: 'onSubscriptionPlanChanged',
onSubscribed: 'onOrganizationSubscribed',
},
@@ -658,6 +661,11 @@ export default {
onUnexcluded: 'onBankTransactionUnexcluded',
},
bankAccount: {
onDisconnecting: 'onBankAccountDisconnecting',
onDisconnected: 'onBankAccountDisconnected',
},
// Import files.
import: {
onImportCommitted: 'onImportFileCommitted',

View File

@@ -0,0 +1,11 @@
exports.up = function (knex) {
return knex.schema.table('subscription_plan_subscriptions', (table) => {
table.string('lemon_subscription_id').nullable();
});
};
exports.down = function (knex) {
return knex.schema.table('subscription_plan_subscriptions', (table) => {
table.dropColumn('lemon_subscription_id');
});
};

View File

@@ -0,0 +1,13 @@
exports.up = function (knex) {
return knex.schema.table('subscription_plan_subscriptions', (table) => {
table.dateTime('trial_ends_at').nullable();
table.dropColumn('cancels_at');
});
};
exports.down = function (knex) {
return knex.schema.table('subscription_plan_subscriptions', (table) => {
table.dropColumn('trial_ends_at').nullable();
table.dateTime('cancels_at').nullable();
});
};

View File

@@ -4,6 +4,15 @@ import moment from 'moment';
import SubscriptionPeriod from '@/services/Subscription/SubscriptionPeriod';
export default class PlanSubscription extends mixin(SystemModel) {
public lemonSubscriptionId: number;
public endsAt: Date;
public startsAt: Date;
public canceledAt: Date;
public trialEndsAt: Date;
/**
* Table name.
*/
@@ -22,7 +31,7 @@ export default class PlanSubscription extends mixin(SystemModel) {
* Defined virtual attributes.
*/
static get virtualAttributes() {
return ['active', 'inactive', 'ended', 'onTrial'];
return ['active', 'inactive', 'ended', 'canceled', 'onTrial', 'status'];
}
/**
@@ -38,7 +47,7 @@ export default class PlanSubscription extends mixin(SystemModel) {
builder.where('trial_ends_at', '>', now);
},
inactiveSubscriptions() {
inactiveSubscriptions(builder) {
builder.modify('endedTrial');
builder.modify('endedPeriod');
},
@@ -98,35 +107,65 @@ export default class PlanSubscription extends mixin(SystemModel) {
}
/**
* Check if subscription is active.
* Check if the subscription is active.
* @return {Boolean}
*/
active() {
return !this.ended() || this.onTrial();
public active() {
return this.onTrial() || !this.ended();
}
/**
* Check if subscription is inactive.
* Check if the subscription is inactive.
* @return {Boolean}
*/
inactive() {
public inactive() {
return !this.active();
}
/**
* Check if subscription period has ended.
* Check if paid subscription period has ended.
* @return {Boolean}
*/
ended() {
public ended() {
return this.endsAt ? moment().isAfter(this.endsAt) : false;
}
/**
* Check if the paid subscription has started.
* @returns {Boolean}
*/
public started() {
return this.startsAt ? moment().isAfter(this.startsAt) : false;
}
/**
* Check if subscription is currently on trial.
* @return {Boolean}
*/
onTrial() {
return this.trailEndsAt ? moment().isAfter(this.trailEndsAt) : false;
public onTrial() {
return this.trialEndsAt ? moment().isBefore(this.trialEndsAt) : false;
}
/**
* Check if the subscription is canceled.
* @returns {boolean}
*/
public canceled() {
return !!this.canceledAt;
}
/**
* Retrieves the subscription status.
* @returns {string}
*/
public status() {
return this.canceled()
? 'canceled'
: this.onTrial()
? 'on_trial'
: this.active()
? 'active'
: 'inactive';
}
/**
@@ -141,7 +180,7 @@ export default class PlanSubscription extends mixin(SystemModel) {
const period = new SubscriptionPeriod(
invoiceInterval,
invoicePeriod,
start,
start
);
const startsAt = period.getStartDate();
@@ -157,7 +196,7 @@ export default class PlanSubscription extends mixin(SystemModel) {
renew(invoiceInterval, invoicePeriod) {
const { startsAt, endsAt } = PlanSubscription.setNewPeriod(
invoiceInterval,
invoicePeriod,
invoicePeriod
);
return this.$query().update({ startsAt, endsAt });
}

View File

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

View File

@@ -15,7 +15,7 @@ import { useAuthActions } from '@/hooks/state';
import withDialogActions from '@/containers/Dialog/withDialogActions';
import { useAuthenticatedAccount } from '@/hooks/query';
import { useAuthenticatedAccount, useDashboardMeta } from '@/hooks/query';
import { firstLettersArgs, compose } from '@/utils';
/**
@@ -31,6 +31,9 @@ function DashboardTopbarUser({
// Retrieve authenticated user information.
const { data: user } = useAuthenticatedAccount();
const { data: dashboardMeta } = useDashboardMeta({
keepPreviousData: true,
});
const onClickLogout = () => {
setLogout();
};
@@ -58,6 +61,12 @@ function DashboardTopbarUser({
}
/>
<MenuDivider />
{dashboardMeta.is_bigcapital_cloud && (
<MenuItem
text={'Billing'}
onClick={() => history.push('/billing')}
/>
)}
<MenuItem
text={<T id={'keyboard_shortcuts'} />}
onClick={onKeyboardShortcut}
@@ -79,6 +88,4 @@ function DashboardTopbarUser({
</Popover>
);
}
export default compose(
withDialogActions,
)(DashboardTopbarUser);
export default compose(withDialogActions)(DashboardTopbarUser);

View File

@@ -22,6 +22,7 @@ import RefundVendorCreditDetailDrawer from '@/containers/Drawers/RefundVendorCre
import WarehouseTransferDetailDrawer from '@/containers/Drawers/WarehouseTransferDetailDrawer';
import TaxRateDetailsDrawer from '@/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsDrawer';
import CategorizeTransactionDrawer from '@/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionDrawer';
import ChangeSubscriptionPlanDrawer from '@/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanDrawer';
import { DRAWERS } from '@/constants/drawers';
@@ -63,6 +64,7 @@ export default function DrawersContainer() {
/>
<TaxRateDetailsDrawer name={DRAWERS.TAX_RATE_DETAILS} />
<CategorizeTransactionDrawer name={DRAWERS.CATEGORIZE_TRANSACTION} />
<ChangeSubscriptionPlanDrawer name={DRAWERS.CHANGE_SUBSCARIPTION_PLAN} />
</div>
);
}

View File

@@ -24,4 +24,5 @@ export enum DRAWERS {
WAREHOUSE_TRANSFER_DETAILS = 'warehouse-transfer-detail-drawer',
TAX_RATE_DETAILS = 'tax-rate-detail-drawer',
CATEGORIZE_TRANSACTION = 'categorize-transaction',
CHANGE_SUBSCARIPTION_PLAN = 'change-subscription-plan'
}

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ import ProjectAlerts from '@/containers/Projects/containers/ProjectAlerts';
import TaxRatesAlerts from '@/containers/TaxRates/alerts';
import { CashflowAlerts } from '../CashFlow/CashflowAlerts';
import { BankRulesAlerts } from '../Banking/Rules/RulesList/BankRulesAlerts';
import { SubscriptionAlerts } from '../Subscriptions/alerts/alerts';
export default [
...AccountsAlerts,
@@ -56,5 +57,6 @@ export default [
...ProjectAlerts,
...TaxRatesAlerts,
...CashflowAlerts,
...BankRulesAlerts
...BankRulesAlerts,
...SubscriptionAlerts
];

View File

@@ -11,13 +11,19 @@ import {
MenuItem,
PopoverInteractionKind,
Position,
Intent,
Tooltip,
MenuDivider,
} from '@blueprintjs/core';
import { useHistory } from 'react-router-dom';
import { isEmpty } from 'lodash';
import {
Icon,
DashboardActionsBar,
DashboardRowsHeightButton,
FormattedMessage as T,
AppToaster,
If,
} from '@/components';
import { CashFlowMenuItems } from './utils';
@@ -33,6 +39,13 @@ import withSettings from '@/containers/Settings/withSettings';
import withSettingsActions from '@/containers/Settings/withSettingsActions';
import { compose } from '@/utils';
import {
useDisconnectBankAccount,
useUpdateBankAccount,
useExcludeUncategorizedTransactions,
useUnexcludeUncategorizedTransactions,
} from '@/hooks/query/bank-rules';
import { withBanking } from '../withBanking';
function AccountTransactionsActionsBar({
// #withDialogActions
@@ -43,17 +56,27 @@ function AccountTransactionsActionsBar({
// #withSettingsActions
addSetting,
// #withBanking
uncategorizedTransationsIdsSelected,
excludedTransactionsIdsSelected,
}) {
const history = useHistory();
const { accountId } = useAccountTransactionsContext();
const { accountId, currentAccount } = useAccountTransactionsContext();
// Refresh cashflow infinity transactions hook.
const { refresh } = useRefreshCashflowTransactionsInfinity();
const { mutateAsync: disconnectBankAccount } = useDisconnectBankAccount();
const { mutateAsync: updateBankAccount } = useUpdateBankAccount();
// Retrieves the money in/out buttons options.
const addMoneyInOptions = useMemo(() => getAddMoneyInOptions(), []);
const addMoneyOutOptions = useMemo(() => getAddMoneyOutOptions(), []);
const isFeedsActive = !!currentAccount.is_feeds_active;
const isSyncingOwner = currentAccount.is_syncing_owner;
// Handle table row size change.
const handleTableRowSizeChange = (size) => {
addSetting('cashflowTransactions', 'tableSize', size);
@@ -82,11 +105,92 @@ function AccountTransactionsActionsBar({
const handleBankRulesClick = () => {
history.push(`/bank-rules?accountId=${accountId}`);
};
// Handles the bank account disconnect click.
const handleDisconnectClick = () => {
disconnectBankAccount({ bankAccountId: accountId })
.then(() => {
AppToaster.show({
message: 'The bank account has been disconnected.',
intent: Intent.SUCCESS,
});
})
.catch((error) => {
AppToaster.show({
message: 'Something went wrong.',
intent: Intent.DANGER,
});
});
};
// handles the bank update button click.
const handleBankUpdateClick = () => {
updateBankAccount({ bankAccountId: accountId })
.then(() => {
AppToaster.show({
message: 'The transactions of the bank account has been updated.',
intent: Intent.SUCCESS,
});
})
.catch(() => {
AppToaster.show({
message: 'Something went wrong.',
intent: Intent.DANGER,
});
});
};
// Handle the refresh button click.
const handleRefreshBtnClick = () => {
refresh();
};
const {
mutateAsync: excludeUncategorizedTransactions,
isLoading: isExcludingLoading,
} = useExcludeUncategorizedTransactions();
const {
mutateAsync: unexcludeUncategorizedTransactions,
isLoading: isUnexcludingLoading,
} = useUnexcludeUncategorizedTransactions();
// Handles the exclude uncategorized transactions in bulk.
const handleExcludeUncategorizedBtnClick = () => {
excludeUncategorizedTransactions({
ids: uncategorizedTransationsIdsSelected,
})
.then(() => {
AppToaster.show({
message: 'The selected transactions have been excluded.',
intent: Intent.SUCCESS,
});
})
.catch(() => {
AppToaster.show({
message: 'Something went wrong',
intent: Intent.DANGER,
});
});
};
// Handles the unexclude categorized button click.
const handleUnexcludeUncategorizedBtnClick = () => {
unexcludeUncategorizedTransactions({
ids: excludedTransactionsIdsSelected,
})
.then(() => {
AppToaster.show({
message: 'The selected excluded transactions have been unexcluded.',
intent: Intent.SUCCESS,
});
})
.catch((error) => {
AppToaster.show({
message: 'Something went wrong',
intent: Intent.DANGER,
});
});
};
return (
<DashboardActionsBar>
<NavbarGroup>
@@ -129,6 +233,45 @@ function AccountTransactionsActionsBar({
onChange={handleTableRowSizeChange}
/>
<NavbarDivider />
<If condition={isSyncingOwner}>
<Tooltip
content={
isFeedsActive
? 'The bank syncing is active'
: 'The bank syncing is disconnected'
}
minimal={true}
position={Position.BOTTOM}
>
<Button
className={Classes.MINIMAL}
icon={<Icon icon="feed" iconSize={16} />}
intent={isFeedsActive ? Intent.SUCCESS : Intent.DANGER}
/>
</Tooltip>
</If>
{!isEmpty(uncategorizedTransationsIdsSelected) && (
<Button
icon={<Icon icon="disable" iconSize={16} />}
text={'Exclude'}
onClick={handleExcludeUncategorizedBtnClick}
className={Classes.MINIMAL}
intent={Intent.DANGER}
disabled={isExcludingLoading}
/>
)}
{!isEmpty(excludedTransactionsIdsSelected) && (
<Button
icon={<Icon icon="disable" iconSize={16} />}
text={'Unexclude'}
onClick={handleUnexcludeUncategorizedBtnClick}
className={Classes.MINIMAL}
intent={Intent.DANGER}
disabled={isUnexcludingLoading}
/>
)}
</NavbarGroup>
<NavbarGroup align={Alignment.RIGHT}>
@@ -141,7 +284,15 @@ function AccountTransactionsActionsBar({
}}
content={
<Menu>
<If condition={isSyncingOwner && isFeedsActive}>
<MenuItem onClick={handleBankUpdateClick} text={'Update'} />
<MenuDivider />
</If>
<MenuItem onClick={handleBankRulesClick} text={'Bank rules'} />
<If condition={isSyncingOwner && isFeedsActive}>
<MenuItem onClick={handleDisconnectClick} text={'Disconnect'} />
</If>
</Menu>
}
>
@@ -164,4 +315,13 @@ export default compose(
withSettings(({ cashflowTransactionsSettings }) => ({
cashflowTansactionsTableSize: cashflowTransactionsSettings?.tableSize,
})),
withBanking(
({
uncategorizedTransationsIdsSelected,
excludedTransactionsIdsSelected,
}) => ({
uncategorizedTransationsIdsSelected,
excludedTransactionsIdsSelected,
}),
),
)(AccountTransactionsActionsBar);

View File

@@ -33,6 +33,7 @@ function AccountTransactionsDataTable({
// #withBankingActions
setUncategorizedTransactionIdForMatching,
setUncategorizedTransactionsSelected,
}) {
// Retrieve table columns.
const columns = useAccountUncategorizedTransactionsColumns();
@@ -73,12 +74,19 @@ function AccountTransactionsDataTable({
});
};
// Handle selected rows change.
const handleSelectedRowsChange = (selected) => {
const _selectedIds = selected?.map((row) => row.original.id);
setUncategorizedTransactionsSelected(_selectedIds);
};
return (
<CashflowTransactionsTable
noInitialFetch={true}
columns={columns}
data={uncategorizedTransactions || []}
sticky={true}
selectionColumn={true}
loading={isUncategorizedTransactionsLoading}
headerLoading={isUncategorizedTransactionsLoading}
expandColumnSpace={1}
@@ -99,6 +107,7 @@ function AccountTransactionsDataTable({
'There is no uncategorized transactions in the current account.'
}
className="table-constrant"
onSelectedRowsChange={handleSelectedRowsChange}
payload={{
onExclude: handleExcludeTransaction,
onCategorize: handleCategorizeBtnClick,

View File

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

View File

@@ -1,8 +1,27 @@
import { ExcludedTransactionsTable } from "../ExcludedTransactions/ExcludedTransactionsTable";
import { ExcludedBankTransactionsTableBoot } from "../ExcludedTransactions/ExcludedTransactionsTableBoot";
import { AccountTransactionsCard } from "./AccountTransactionsCard";
// @ts-nocheck
import { useEffect } from 'react';
import * as R from 'ramda';
import {
WithBankingActionsProps,
withBankingActions,
} from '../../withBankingActions';
import { ExcludedTransactionsTable } from '../ExcludedTransactions/ExcludedTransactionsTable';
import { ExcludedBankTransactionsTableBoot } from '../ExcludedTransactions/ExcludedTransactionsTableBoot';
import { AccountTransactionsCard } from './AccountTransactionsCard';
interface AccountExcludedTransactionsProps extends WithBankingActionsProps {}
function AccountExcludedTransactionsRoot({
// #withBankingActions
resetExcludedTransactionsSelected,
}: AccountExcludedTransactionsProps) {
useEffect(
() => () => {
resetExcludedTransactionsSelected();
},
[resetExcludedTransactionsSelected],
);
export function AccountExcludedTransactions() {
return (
<ExcludedBankTransactionsTableBoot>
<AccountTransactionsCard>
@@ -11,3 +30,7 @@ export function AccountExcludedTransactions() {
</ExcludedBankTransactionsTableBoot>
);
}
export const AccountExcludedTransactions = R.compose(withBankingActions)(
AccountExcludedTransactionsRoot,
);

View File

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

View File

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

View File

@@ -4,6 +4,10 @@ import {
setUncategorizedTransactionIdForMatching,
openReconcileMatchingTransaction,
closeReconcileMatchingTransaction,
setUncategorizedTransactionsSelected,
resetUncategorizedTransactionsSelected,
resetExcludedTransactionsSelected,
setExcludedTransactionsSelected,
} from '@/store/banking/banking.reducer';
export interface WithBankingActionsProps {
@@ -13,6 +17,12 @@ export interface WithBankingActionsProps {
) => void;
openReconcileMatchingTransaction: (pendingAmount: number) => void;
closeReconcileMatchingTransaction: () => void;
setUncategorizedTransactionsSelected: (ids: Array<string | number>) => void;
resetUncategorizedTransactionsSelected: () => void;
setExcludedTransactionsSelected: (ids: Array<string | number>) => void;
resetExcludedTransactionsSelected: () => void;
}
const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
@@ -28,6 +38,40 @@ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
dispatch(openReconcileMatchingTransaction({ pending: pendingAmount })),
closeReconcileMatchingTransaction: () =>
dispatch(closeReconcileMatchingTransaction()),
/**
* Sets the selected uncategorized transactions.
* @param {Array<string | number>} ids
*/
setUncategorizedTransactionsSelected: (ids: Array<string | number>) =>
dispatch(
setUncategorizedTransactionsSelected({
transactionIds: ids,
}),
),
/**
* Resets the selected uncategorized transactions.
*/
resetUncategorizedTransactionsSelected: () =>
dispatch(resetUncategorizedTransactionsSelected()),
/**
* Sets excluded selected transactions.
* @param {Array<string | number>} ids
*/
setExcludedTransactionsSelected: (ids: Array<string | number>) =>
dispatch(
setExcludedTransactionsSelected({
ids,
}),
),
/**
* Resets the excluded selected transactions
*/
resetExcludedTransactionsSelected: () =>
dispatch(resetExcludedTransactionsSelected()),
});
export const withBankingActions = connect<

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,66 @@
import { Group } from '@/components';
import { SubscriptionPlan } from './SubscriptionPlan';
// @ts-nocheck
import * as R from 'ramda';
import { Intent } from '@blueprintjs/core';
import { AppToaster, Group, GroupProps } from '@/components';
import { SubscriptionPlansPeriod } from '@/store/plans/plans.reducer';
import { SubscriptionPlan } from '@/containers/Subscriptions/component/SubscriptionPlan';
import { useGetLemonSqueezyCheckout } from '@/hooks/query';
import { useSubscriptionPlans } from './hooks';
import { withPlans } from '@/containers/Subscriptions/withPlans';
import { withSubscriptionPlanMapper } from '@/containers/Subscriptions/component/withSubscriptionPlanMapper';
export function SubscriptionPlans() {
interface SubscriptionPlansProps {
wrapProps?: GroupProps;
onSubscribe?: (variantId: number) => void;
}
export function SubscriptionPlans({
wrapProps,
onSubscribe,
}: SubscriptionPlansProps) {
const subscriptionPlans = useSubscriptionPlans();
return (
<Group spacing={14} noWrap align="stretch">
<Group spacing={14} noWrap align="stretch" {...wrapProps}>
{subscriptionPlans.map((plan, index) => (
<SubscriptionPlan
key={index}
slug={plan.slug}
label={plan.name}
description={plan.description}
features={plan.features}
featured={plan.featured}
monthlyPrice={plan.monthlyPrice}
monthlyPriceLabel={plan.monthlyPriceLabel}
annuallyPrice={plan.annuallyPrice}
annuallyPriceLabel={plan.annuallyPriceLabel}
monthlyVariantId={plan.monthlyVariantId}
annuallyVariantId={plan.annuallyVariantId}
/>
<SubscriptionPlanMapped key={index} plan={plan} />
))}
</Group>
);
}
const SubscriptionPlanMapped = R.compose(
withSubscriptionPlanMapper,
withPlans(({ plansPeriod }) => ({ plansPeriod })),
)(({ plansPeriod, monthlyVariantId, annuallyVariantId, ...props }) => {
const { mutateAsync: getLemonCheckout, isLoading } =
useGetLemonSqueezyCheckout();
const handleSubscribeBtnClick = () => {
const variantId =
SubscriptionPlansPeriod.Monthly === plansPeriod
? monthlyVariantId
: annuallyVariantId;
getLemonCheckout({ variantId })
.then((res) => {
const checkoutUrl = res.data.data.attributes.url;
window.LemonSqueezy.Url.Open(checkoutUrl);
})
.catch(() => {
AppToaster.show({
message: 'Something went wrong!',
intent: Intent.DANGER,
});
});
};
return (
<SubscriptionPlan
{...props}
onSubscribe={handleSubscribeBtnClick}
subscribeButtonProps={{
loading: isLoading,
}}
/>
);
});

View File

@@ -1,95 +0,0 @@
// @ts-nocheck
import React, { useEffect } from 'react';
import intl from 'react-intl-universal';
import { Formik, Form } from 'formik';
import { DashboardInsider, If, Alert, T } from '@/components';
import '@/style/pages/Billing/BillingPage.scss';
import { compose } from '@/utils';
import { MasterBillingTabs } from './SubscriptionTabs';
import { getBillingFormValidationSchema } from './utils';
import withBillingActions from './withBillingActions';
import withDashboardActions from '@/containers/Dashboard/withDashboardActions';
import withSubscriptionPlansActions from './withSubscriptionPlansActions';
import withSubscriptions from './withSubscriptions';
/**
* Billing form.
*/
function BillingForm({
// #withDashboardActions
changePageTitle,
// #withBillingActions
requestSubmitBilling,
initSubscriptionPlans,
// #withSubscriptions
isSubscriptionInactive,
}) {
useEffect(() => {
changePageTitle(intl.get('billing'));
}, [changePageTitle]);
React.useEffect(() => {
initSubscriptionPlans();
}, [initSubscriptionPlans]);
// Initial values.
const initialValues = {
plan_slug: 'essentials',
period: 'month',
license_code: '',
};
// Handle form submitting.
const handleSubmit = (values, { setSubmitting }) => {
requestSubmitBilling({
...values,
plan_slug: 'essentials-monthly',
})
.then((response) => {
setSubmitting(false);
})
.catch((errors) => {
setSubmitting(false);
});
};
return (
<DashboardInsider name={'billing-page'}>
<div className={'billing-page'}>
<If condition={isSubscriptionInactive}>
<Alert
intent={'danger'}
title={<T id={'billing.suspend_message.title'} />}
description={<T id={'billing.suspend_message.description'} />}
/>
</If>
<Formik
validationSchema={getBillingFormValidationSchema()}
onSubmit={handleSubmit}
initialValues={initialValues}
>
<Form>
<MasterBillingTabs />
</Form>
</Formik>
</div>
</DashboardInsider>
);
}
export default compose(
withDashboardActions,
withBillingActions,
withSubscriptionPlansActions,
withSubscriptions(
({ isSubscriptionInactive }) => ({ isSubscriptionInactive }),
'main',
),
)(BillingForm);

View File

@@ -0,0 +1,28 @@
// @ts-nocheck
import * as R from 'ramda';
import { Redirect } from 'react-router-dom';
import { BillingPageBoot } from './BillingPageBoot';
import { BillingPageContent } from './BillingPageContent';
import { DashboardInsider } from '@/components';
import { useDashboardMeta } from '@/hooks/query';
import withAlertActions from '../Alert/withAlertActions';
function BillingPageRoot({ openAlert }) {
const { data: dashboardMeta } = useDashboardMeta({
keepPreviousData: true,
});
// In case the edition is not Bigcapital Cloud, redirect to the homepage.
if (!dashboardMeta.is_bigcapital_cloud) {
return <Redirect to={{ pathname: '/' }} />;
}
return (
<DashboardInsider>
<BillingPageBoot>
<BillingPageContent />
</BillingPageBoot>
</DashboardInsider>
);
}
export default R.compose(withAlertActions)(BillingPageRoot);

View File

@@ -0,0 +1,33 @@
import React, { createContext } from 'react';
import { useGetSubscriptions } from '@/hooks/query/subscription';
interface BillingBootContextValues {
isSubscriptionsLoading: boolean;
subscriptions: any;
}
const BillingBoot = createContext<BillingBootContextValues>(
{} as BillingBootContextValues,
);
interface BillingPageBootProps {
children: React.ReactNode;
}
export function BillingPageBoot({ children }: BillingPageBootProps) {
const { isLoading: isSubscriptionsLoading, data: subscriptionsRes } =
useGetSubscriptions();
const mainSubscription = subscriptionsRes?.subscriptions?.find(
(s) => s.slug === 'main',
);
const value = {
isSubscriptionsLoading,
subscriptions: subscriptionsRes?.subscriptions,
mainSubscription,
};
return <BillingBoot.Provider value={value}>{children}</BillingBoot.Provider>;
}
export const useBillingPageBoot = () => React.useContext(BillingBoot);

View File

@@ -0,0 +1,8 @@
.root {
display: flex;
flex-direction: column;
padding: 32px 40px;
min-width: 800px;
max-width: 900px;
width: 75%;
}

View File

@@ -0,0 +1,26 @@
// @ts-nocheck
import { Box, Group } from '@/components';
import { Spinner, Text } from '@blueprintjs/core';
import { Subscription } from './BillingSubscription';
import { useBillingPageBoot } from './BillingPageBoot';
import styles from './BillingPageContent.module.scss';
export function BillingPageContent() {
const { isSubscriptionsLoading, subscriptions } = useBillingPageBoot();
if (isSubscriptionsLoading || !subscriptions) {
return <Spinner size={30} />;
}
return (
<Box className={styles.root}>
<Text>
Only pay for what you really need. All plans come with 24/7 customer support.
</Text>
<Group style={{ marginTop: '2rem' }}>
<Subscription />
</Group>
</Box>
);
}

View File

@@ -1,16 +0,0 @@
// @ts-nocheck
import React from 'react';
import { T } from '@/components';
import { PaymentMethodTabs } from './SubscriptionTabs';
export default ({ formik, title, description }) => {
return (
<section class="billing-plans__section">
<h1 className="title"><T id={'setup.plans.payment_methods.title'} /></h1>
<p className="paragraph"><T id={'setup.plans.payment_methods.description' } /></p>
<PaymentMethodTabs formik={formik} />
</section>
);
};

View File

@@ -1,59 +0,0 @@
// @ts-nocheck
import React from 'react';
import classNames from 'classnames';
import { get } from 'lodash';
import '@/style/pages/Subscription/PlanPeriodRadio.scss';
import withPlan from '@/containers/Subscriptions/withPlan';
import { saveInvoke, compose } from '@/utils';
/**
* Billing period.
*/
function BillingPeriod({
// #ownProps
label,
value,
selectedOption,
onSelected,
period,
// #withPlan
price,
currencyCode,
}) {
const handlePeriodClick = () => {
saveInvoke(onSelected, value);
};
return (
<div
id={`plan-period-${period}`}
className={classNames(
{
'is-selected': value === selectedOption,
},
'period-radio',
)}
onClick={handlePeriodClick}
>
<span className={'period-radio__label'}>{label}</span>
<div className={'period-radio__price'}>
<span className={'period-radio__amount'}>
{price} {currencyCode}
</span>
<span className={'period-radio__period'}>{label}</span>
</div>
</div>
);
}
export default compose(
withPlan(({ plan }, state, { period }) => ({
price: get(plan, `price.${period}`),
currencyCode: get(plan, 'currencyCode'),
})),
)(BillingPeriod);

View File

@@ -1,49 +0,0 @@
// @ts-nocheck
import React from 'react';
import { Field } from 'formik';
import * as R from 'ramda';
import { T, SubscriptionPeriods } from '@/components';
import withPlan from './withPlan';
/**
* Sunscription periods enhanced.
*/
const SubscriptionPeriodsEnhanced = R.compose(
withPlan(({ plan }) => ({ plan })),
)(({ plan, ...restProps }) => {
if (!plan) return null;
return <SubscriptionPeriods periods={plan.periods} {...restProps} />;
});
/**
* Billing periods.
*/
export default function BillingPeriods() {
return (
<section class="billing-plans__section">
<h1 class="title">
<T id={'setup.plans.select_period.title'} />
</h1>
<div class="description">
<p className="paragraph">
<T id={'setup.plans.select_period.description'} />
</p>
</div>
<Field name={'period'}>
{({ field: { value }, form: { values, setFieldValue } }) => (
<SubscriptionPeriodsEnhanced
selectedPeriod={value}
planSlug={values.plan_slug}
onPeriodSelect={(period) => {
setFieldValue('period', period);
}}
/>
)}
</Field>
</section>
);
}

View File

@@ -1,54 +0,0 @@
// @ts-nocheck
import React from 'react';
import classNames from 'classnames';
import { FormattedMessage as T } from '@/components';
import { saveInvoke } from '@/utils';
/**
* Billing plan.
*/
export default function BillingPlan({
name,
description,
price,
currencyCode,
value,
selectedOption,
onSelected,
}) {
const handlePlanClick = () => {
saveInvoke(onSelected, value);
};
return (
<div
id={'basic-plan'}
className={classNames('plan-radio', {
'is-selected': selectedOption === value,
})}
onClick={handlePlanClick}
>
<div className={'plan-radio__header'}>
<div className={'plan-radio__name'}>{name}</div>
</div>
<div className={'plan-radio__description'}>
<ul>
{description.map((line) => (
<li>{line}</li>
))}
</ul>
</div>
<div className={'plan-radio__price'}>
<span className={'plan-radio__amount'}>
{price} {currencyCode}
</span>
<span className={'plan-radio__period'}>
<T id={'monthly'} />
</span>
</div>
</div>
);
}

View File

@@ -1,41 +0,0 @@
// @ts-nocheck
import React from 'react';
import * as R from 'ramda';
import '@/style/pages/Subscription/BillingPlans.scss';
import BillingPlansInput from './BillingPlansInput';
import BillingPeriodsInput from './BillingPeriodsInput';
import BillingPaymentMethod from './BillingPaymentMethod';
import withSubscriptions from './withSubscriptions';
/**
* Billing plans form.
*/
export default function BillingPlansForm() {
return (
<div class="billing-plans">
<BillingPlansInput />
<BillingPeriodsInput />
<BillingPaymentMethodWhenSubscriptionInactive />
</div>
);
}
/**
* Billing payment methods when subscription is inactive.
* @returns {JSX.Element}
*/
function BillingPaymentMethodWhenSubscriptionInactiveJSX({
// # withSubscriptions
isSubscriptionActive,
...props
}) {
return !isSubscriptionActive ? <BillingPaymentMethod {...props} /> : null;
}
const BillingPaymentMethodWhenSubscriptionInactive = R.compose(
withSubscriptions(({ isSubscriptionActive }) => ({ isSubscriptionActive })),
)(BillingPaymentMethodWhenSubscriptionInactiveJSX);

View File

@@ -1,38 +0,0 @@
// @ts-nocheck
import React from 'react';
import { Field } from 'formik';
import { T, SubscriptionPlans } from '@/components';
import withPlans from './withPlans';
import { compose } from '@/utils';
/**
* Billing plans.
*/
function BillingPlans({ plans, title, description, selectedOption }) {
return (
<section class="billing-plans__section">
<h1 class="title">
<T id={'setup.plans.select_plan.title'} />
</h1>
<div class="description">
<p className="paragraph">
<T id={'setup.plans.select_plan.description'} />
</p>
</div>
<Field name={'plan_slug'}>
{({ form: { setFieldValue }, field: { value } }) => (
<SubscriptionPlans
plans={plans}
value={value}
onSelect={(value) => {
setFieldValue('plan_slug', value);
}}
/>
)}
</Field>
</section>
);
}
export default compose(withPlans(({ plans }) => ({ plans })))(BillingPlans);

View File

@@ -0,0 +1,59 @@
.root {
width: 450px;
background: #fff;
border-radius: 5px;
box-shadow: 0 -8px 0 0px #BFCCD6, rgb(0 8 36 / 9%) 0px 4px 20px -5px;
border: 1px solid #C4D2D7;
min-height: 420px;
display: flex;
flex-direction: column;
}
.title{
margin: 0;
font-size: 18px;
font-weight: 600;
color: #3D4C58;
}
.description {
line-height: 1.5;
color: #394B59;
margin-top: 14px;
}
.period {
div + div {
&::before{
content: "";
text-align: center;
margin-right: 3px;
color: #999;
margin-left: 6px;
}
}
&:global(.bp4-intent-success){
color: #3e703e;
}
&:global(.bp4-intent-danger){
color: #A82A2A;
}
}
.periodStatus{
text-transform: uppercase;
font-weight: 500;
}
.priceAmount {
font-size: 24px;
font-weight: 500;
}
.subscribeButton{
border-radius: 32px;
padding-left: 16px;
padding-right: 16px;
}
.actions{
margin-top: 16px;
}

View File

@@ -0,0 +1,156 @@
// @ts-nocheck
import * as R from 'ramda';
import clsx from 'classnames';
import { includes } from 'lodash';
import { Box, Group, Stack } from '@/components';
import { Button, Card, Classes, Intent, Text } from '@blueprintjs/core';
import withAlertActions from '../Alert/withAlertActions';
import styles from './BillingSubscription.module.scss';
import withDrawerActions from '../Drawer/withDrawerActions';
import { DRAWERS } from '@/constants/drawers';
import { useBillingPageBoot } from './BillingPageBoot';
import { getSubscriptionStatusText } from './_utils';
function SubscriptionRoot({ openAlert, openDrawer }) {
const { mainSubscription } = useBillingPageBoot();
// Can't continue if the main subscription is not loaded.
if (!mainSubscription) {
return null;
}
const handleCancelSubBtnClick = () => {
openAlert('cancel-main-subscription');
};
const handleResumeSubBtnClick = () => {
openAlert('resume-main-subscription');
};
const handleUpdatePaymentMethod = () => {
window.LemonSqueezy.Url.Open(
mainSubscription.lemonUrls?.updatePaymentMethod,
);
};
// Handle upgrade button click.
const handleUpgradeBtnClick = () => {
openDrawer(DRAWERS.CHANGE_SUBSCARIPTION_PLAN);
};
return (
<Card className={styles.root}>
<Stack spacing={6}>
<h1 className={styles.title}>{mainSubscription.planName}</h1>
<Group
spacing={0}
className={clsx(styles.period, {
[Classes.INTENT_DANGER]: includes(
['on_trial', 'inactive'],
mainSubscription.status,
),
[Classes.INTENT_SUCCESS]: includes(
['active', 'canceled'],
mainSubscription.status,
),
})}
>
<Text className={styles.periodStatus}>
{mainSubscription.statusFormatted}
</Text>
<SubscriptionStatusText subscription={mainSubscription} />
</Group>
</Stack>
<Text className={styles.description}>
Control your business bookkeeping with automated accounting, to run
intelligent reports for faster decision-making.
</Text>
<Stack align="flex-start" spacing={8} className={styles.actions}>
<Button
minimal
small
inline
intent={Intent.PRIMARY}
onClick={handleUpgradeBtnClick}
>
Upgrade the Plan
</Button>
{mainSubscription.canceled && (
<Button
minimal
small
inline
intent={Intent.PRIMARY}
onClick={handleResumeSubBtnClick}
>
Resume Subscription
</Button>
)}
{!mainSubscription.canceled && (
<Button
minimal
small
inline
intent={Intent.PRIMARY}
onClick={handleCancelSubBtnClick}
>
Cancel Subscription
</Button>
)}
<Button
minimal
small
inline
intent={Intent.PRIMARY}
onClick={handleUpdatePaymentMethod}
>
Change Payment Method
</Button>
</Stack>
<Group position={'apart'} style={{ marginTop: 'auto' }}>
<Group spacing={4}>
<Text className={styles.priceAmount}>
{mainSubscription.planPriceFormatted}
</Text>
{mainSubscription.planPeriod && (
<Text className={styles.pricePeriod}>
{mainSubscription.planPeriod === 'month'
? 'mo'
: mainSubscription.planPeriod === 'year'
? 'yearly'
: ''}
</Text>
)}
</Group>
<Box>
{mainSubscription.canceled && (
<Button
intent={Intent.PRIMARY}
onClick={handleResumeSubBtnClick}
className={styles.subscribeButton}
>
Resume Subscription
</Button>
)}
</Box>
</Group>
</Card>
);
}
export const Subscription = R.compose(
withAlertActions,
withDrawerActions,
)(SubscriptionRoot);
function SubscriptionStatusText({ subscription }) {
const text = getSubscriptionStatusText(subscription);
if (!text) return null;
return <Text className={styles.periodText}>{text}</Text>;
}

View File

@@ -1,7 +0,0 @@
// @ts-nocheck
import React from 'react';
import BillingPlansForm from './BillingPlansForm';
export default function BillingTab() {
return (<BillingPlansForm />);
}

View File

@@ -1,42 +0,0 @@
// @ts-nocheck
import React from 'react';
import { Intent, Button } from '@blueprintjs/core';
import { useFormikContext } from 'formik';
import { FormattedMessage as T } from '@/components';
import { compose } from '@/utils';
import withDialogActions from '@/containers/Dialog/withDialogActions';
/**
* Payment via license code tab.
*/
function LicenseTab({ openDialog }) {
const { submitForm, values } = useFormikContext();
const handleSubmitBtnClick = () => {
submitForm().then(() => {
openDialog('payment-via-voucher', { ...values });
});
};
return (
<div className={'license-container'}>
<h3>
<T id={'voucher'} />
</h3>
<p className="paragraph">
<T id={'cards_will_be_charged'} />
</p>
<Button
onClick={handleSubmitBtnClick}
intent={Intent.PRIMARY}
large={true}
>
<T id={'submit_voucher'} />
</Button>
</div>
);
}
export default compose(withDialogActions)(LicenseTab);

View File

@@ -1,47 +0,0 @@
// @ts-nocheck
import React from 'react';
import intl from 'react-intl-universal';
import { Tabs, Tab } from '@blueprintjs/core';
import BillingTab from './BillingTab';
import LicenseTab from './LicenseTab';
/**
* Master billing tabs.
*/
export const MasterBillingTabs = ({ formik }) => {
return (
<div>
<Tabs animate={true} large={true}>
<Tab
title={intl.get('billing')}
id={'billing'}
panel={<BillingTab formik={formik} />}
/>
<Tab title={intl.get('usage')} id={'usage'} disabled={true} />
</Tabs>
</div>
);
};
/**
* Payment methods tabs.
*/
export const PaymentMethodTabs = ({ formik }) => {
return (
<div>
<Tabs animate={true} large={true}>
<Tab
title={intl.get('voucher')}
id={'voucher'}
panel={<LicenseTab formik={formik} />}
/>
<Tab
title={intl.get('credit_card')}
id={'credit_card'}
disabled={true}
/>
<Tab title={intl.get('paypal')} id={'paypal'} disabled={true} />
</Tabs>
</div>
);
};

View File

@@ -0,0 +1,17 @@
// @ts-nocheck
export const getSubscriptionStatusText = (subscription) => {
if (subscription.status === 'on_trial') {
return subscription.onTrial
? `Trials ends in ${subscription.trialEndsAtFormatted}`
: `Trial ended ${subscription.trialEndsAtFormatted}`;
} else if (subscription.status === 'active') {
return subscription.endsAtFormatted
? `Renews in ${subscription.endsAtFormatted}`
: 'Lifetime subscription';
} else if (subscription.status === 'canceled') {
return subscription.ended
? `Expires ${subscription.endsAtFormatted}`
: `Expired ${subscription.endsAtFormatted}`;
}
return '';
};

View File

@@ -0,0 +1,81 @@
// @ts-nocheck
import React from 'react';
import * as R from 'ramda';
import { Intent, Alert } from '@blueprintjs/core';
import { AppToaster, FormattedMessage as T } from '@/components';
import withAlertStoreConnect from '@/containers/Alert/withAlertStoreConnect';
import withAlertActions from '@/containers/Alert/withAlertActions';
import { useCancelMainSubscription } from '@/hooks/query/subscription';
/**
* Cancel Unlocking partial transactions alerts.
*/
function CancelMainSubscriptionAlert({
name,
// #withAlertStoreConnect
isOpen,
payload: { module },
// #withAlertActions
closeAlert,
}) {
const { mutateAsync: cancelSubscription, isLoading } =
useCancelMainSubscription();
// Handle cancel.
const handleCancel = () => {
closeAlert(name);
};
// Handle confirm.
const handleConfirm = () => {
const values = {
module: module,
};
cancelSubscription()
.then(() => {
AppToaster.show({
message: 'The subscription has been canceled.',
intent: Intent.SUCCESS,
});
})
.catch(
({
response: {
data: { errors },
},
}) => {},
)
.finally(() => {
closeAlert(name);
});
};
return (
<Alert
cancelButtonText={<T id={'cancel'} />}
confirmButtonText={'Cancel Subscription'}
intent={Intent.DANGER}
isOpen={isOpen}
onCancel={handleCancel}
onConfirm={handleConfirm}
loading={isLoading}
>
<p>
<strong>The subscription for this organization will end.</strong>
</p>
<p>
It will no longer be accessible to you or any other users. Make sure any
data has already been exported.
</p>
</Alert>
);
}
export default R.compose(
withAlertStoreConnect(),
withAlertActions,
)(CancelMainSubscriptionAlert);

View File

@@ -0,0 +1,79 @@
// @ts-nocheck
import React from 'react';
import * as R from 'ramda';
import { Intent, Alert } from '@blueprintjs/core';
import { AppToaster, FormattedMessage as T } from '@/components';
import withAlertStoreConnect from '@/containers/Alert/withAlertStoreConnect';
import withAlertActions from '@/containers/Alert/withAlertActions';
import { useResumeMainSubscription } from '@/hooks/query/subscription';
/**
* Resume Unlocking partial transactions alerts.
*/
function ResumeMainSubscriptionAlert({
name,
// #withAlertStoreConnect
isOpen,
payload: { module },
// #withAlertActions
closeAlert,
}) {
const { mutateAsync: resumeSubscription, isLoading } =
useResumeMainSubscription();
// Handle cancel.
const handleCancel = () => {
closeAlert(name);
};
// Handle confirm.
const handleConfirm = () => {
const values = {
module: module,
};
resumeSubscription()
.then(() => {
AppToaster.show({
message: 'The subscription has been resumed.',
intent: Intent.SUCCESS,
});
})
.catch(
({
response: {
data: { errors },
},
}) => {},
)
.finally(() => {
closeAlert(name);
});
};
return (
<Alert
cancelButtonText={<T id={'cancel'} />}
confirmButtonText={'Resume Subscription'}
intent={Intent.DANGER}
isOpen={isOpen}
onCancel={handleCancel}
onConfirm={handleConfirm}
loading={isLoading}
>
<p>
<strong>The subscription for this organization will resume.</strong>
<p>
Are you sure want to resume the subscription of this organization?
</p>
</p>
</Alert>
);
}
export default R.compose(
withAlertStoreConnect(),
withAlertActions,
)(ResumeMainSubscriptionAlert);

View File

@@ -0,0 +1,23 @@
// @ts-nocheck
import React from 'react';
const CancelMainSubscriptionAlert = React.lazy(
() => import('./CancelMainSubscriptionAlert'),
);
const ResumeMainSubscriptionAlert = React.lazy(
() => import('./ResumeMainSubscriptionAlert'),
);
/**
* Subscription alert.
*/
export const SubscriptionAlerts = [
{
name: 'cancel-main-subscription',
component: CancelMainSubscriptionAlert,
},
{
name: 'resume-main-subscription',
component: ResumeMainSubscriptionAlert,
},
];

View File

@@ -1,14 +1,12 @@
// @ts-nocheck
import { Intent } from '@blueprintjs/core';
import * as R from 'ramda';
import { AppToaster } from '@/components';
import { useGetLemonSqueezyCheckout } from '@/hooks/query';
import { PricingPlan } from '@/components/PricingPlan/PricingPlan';
import { SubscriptionPlansPeriod } from '@/store/plans/plans.reducer';
import {
WithPlansProps,
withPlans,
} from '@/containers/Subscriptions/withPlans';
import { ButtonProps } from '@blueprintjs/core';
interface SubscriptionPricingFeature {
text: string;
@@ -27,8 +25,8 @@ interface SubscriptionPricingProps {
monthlyPriceLabel: string;
annuallyPrice: string;
annuallyPriceLabel: string;
monthlyVariantId?: string;
annuallyVariantId?: string;
onSubscribe?: (variantId: number) => void;
subscribeButtonProps?: Optional<ButtonProps>;
}
interface SubscriptionPricingCombinedProps
@@ -44,32 +42,14 @@ function SubscriptionPlanRoot({
monthlyPriceLabel,
annuallyPrice,
annuallyPriceLabel,
monthlyVariantId,
annuallyVariantId,
onSubscribe,
subscribeButtonProps,
// #withPlans
plansPeriod,
}: SubscriptionPricingCombinedProps) {
const { mutateAsync: getLemonCheckout, isLoading } =
useGetLemonSqueezyCheckout();
const handleClick = () => {
const variantId =
SubscriptionPlansPeriod.Monthly === plansPeriod
? monthlyVariantId
: annuallyVariantId;
getLemonCheckout({ variantId })
.then((res) => {
const checkoutUrl = res.data.data.attributes.url;
window.LemonSqueezy.Url.Open(checkoutUrl);
})
.catch(() => {
AppToaster.show({
message: 'Something went wrong!',
intent: Intent.DANGER,
});
});
onSubscribe && onSubscribe();
};
return (
@@ -85,7 +65,7 @@ function SubscriptionPlanRoot({
subPrice={annuallyPriceLabel}
/>
)}
<PricingPlan.BuyButton loading={isLoading} onClick={handleClick}>
<PricingPlan.BuyButton onClick={handleClick} {...subscribeButtonProps}>
Subscribe
</PricingPlan.BuyButton>

View File

@@ -0,0 +1,52 @@
// @ts-nocheck
import React from 'react';
interface WithSubscriptionPlanProps {
plan: any;
onSubscribe?: (variantId: number) => void;
}
interface MappedSubscriptionPlanProps {
slug: string;
label: string;
description: string;
features: any[];
featured: boolean;
monthlyPrice: string;
monthlyPriceLabel: string;
annuallyPrice: string;
annuallyPriceLabel: string;
monthlyVariantId: number;
annuallyVariantId: number;
onSubscribe?: (variantId: number) => void;
}
export const withSubscriptionPlanMapper = <
P extends MappedSubscriptionPlanProps,
>(
WrappedComponent: React.ComponentType<P>,
) => {
return function WithSubscriptionPlanMapper(
props: WithSubscriptionPlanProps &
Omit<P, keyof MappedSubscriptionPlanProps>,
) {
const { plan, onSubscribe, ...restProps } = props;
const mappedProps: MappedSubscriptionPlanProps = {
slug: plan.slug,
label: plan.name,
description: plan.description,
features: plan.features,
featured: plan.featured,
monthlyPrice: plan.monthlyPrice,
monthlyPriceLabel: plan.monthlyPriceLabel,
annuallyPrice: plan.annuallyPrice,
annuallyPriceLabel: plan.annuallyPriceLabel,
monthlyVariantId: plan.monthlyVariantId,
annuallyVariantId: plan.annuallyVariantId,
onSubscribe,
};
return <WrappedComponent {...mappedProps} {...(restProps as P)} />;
};
};

View File

@@ -0,0 +1,29 @@
// @ts-nocheck
import * as R from 'ramda';
import { Callout, Classes } from '@blueprintjs/core';
import { Box } from '@/components';
import { SubscriptionPlansPeriodSwitcher } from '@/containers/Setup/SetupSubscription/SubscriptionPlansPeriodSwitcher';
import { ChangeSubscriptionPlans } from './ChangeSubscriptionPlans';
export default function ChangeSubscriptionPlanContent() {
return (
<Box className={Classes.DRAWER_BODY}>
<Box
style={{
maxWidth: 1024,
margin: '0 auto',
padding: '50px 20px 80px',
}}
>
<Callout style={{ marginBottom: '2rem' }} icon={null}>
Simple plans. Simple prices. Only pay for what you really need. All
plans come with award-winning 24/7 customer support. Prices do not
include applicable taxes.
</Callout>
<SubscriptionPlansPeriodSwitcher />
<ChangeSubscriptionPlans />
</Box>
</Box>
);
}

View File

@@ -0,0 +1,39 @@
// @ts-nocheck
import React, { lazy } from 'react';
import * as R from 'ramda';
import { Drawer, DrawerHeaderContent, DrawerSuspense } from '@/components';
import withDrawers from '@/containers/Drawer/withDrawers';
import { Position } from '@blueprintjs/core';
import { DRAWERS } from '@/constants/drawers';
const ChangeSubscriptionPlanContent = lazy(
() => import('./ChangeSubscriptionPlanContent'),
);
/**
* Account drawer.
*/
function ChangeSubscriptionPlanDrawer({
name,
// #withDrawer
isOpen,
}) {
return (
<Drawer
isOpen={isOpen}
name={name}
size={'calc(100% - 5px)'}
position={Position.BOTTOM}
>
<DrawerSuspense>
<DrawerHeaderContent
name={DRAWERS.CHANGE_SUBSCARIPTION_PLAN}
title={'Change Subscription Plan'}
/>
<ChangeSubscriptionPlanContent />
</DrawerSuspense>
</Drawer>
);
}
export default R.compose(withDrawers())(ChangeSubscriptionPlanDrawer);

View File

@@ -0,0 +1,72 @@
// @ts-nocheck
import * as R from 'ramda';
import { Intent } from '@blueprintjs/core';
import { AppToaster, Group } from '@/components';
import { SubscriptionPlan } from '../../component/SubscriptionPlan';
import { SubscriptionPlansPeriod } from '@/store/plans/plans.reducer';
import { useSubscriptionPlans } from '@/hooks/constants/useSubscriptionPlans';
import { useChangeSubscriptionPlan } from '@/hooks/query/subscription';
import { withSubscriptionPlanMapper } from '../../component/withSubscriptionPlanMapper';
import { withPlans } from '../../withPlans';
import withDrawerActions from '@/containers/Drawer/withDrawerActions';
import { DRAWERS } from '@/constants/drawers';
export function ChangeSubscriptionPlans() {
const subscriptionPlans = useSubscriptionPlans();
return (
<Group spacing={14} noWrap align="stretch">
{subscriptionPlans.map((plan, index) => (
<SubscriptionPlanMapped plan={plan} />
))}
</Group>
);
}
export const SubscriptionPlanMapped = R.compose(
withSubscriptionPlanMapper,
withDrawerActions,
withPlans(({ plansPeriod }) => ({ plansPeriod })),
)(
({
openDrawer,
closeDrawer,
monthlyVariantId,
annuallyVariantId,
plansPeriod,
...props
}) => {
const { mutateAsync: changeSubscriptionPlan, isLoading } =
useChangeSubscriptionPlan();
// Handles the subscribe button click.
const handleSubscribe = () => {
const variantId =
plansPeriod === SubscriptionPlansPeriod.Monthly
? monthlyVariantId
: annuallyVariantId;
changeSubscriptionPlan({ variant_id: variantId })
.then(() => {
closeDrawer(DRAWERS.CHANGE_SUBSCARIPTION_PLAN);
AppToaster.show({
message: 'The subscription plan has been changed.',
intent: Intent.SUCCESS,
});
})
.catch((error) => {
AppToaster.show({
message: 'Something went wrong.',
intent: Intent.DANGER,
});
});
};
return (
<SubscriptionPlan
{...props}
onSubscribe={handleSubscribe}
subscribeButtonProps={{ loading: isLoading }}
/>
);
},
);

View File

@@ -0,0 +1 @@
export * as default from './ChangeSubscriptionPlanDrawer';

View File

@@ -1,9 +0,0 @@
// @ts-nocheck
import * as Yup from 'yup';
export const getBillingFormValidationSchema = () =>
Yup.object().shape({
plan_slug: Yup.string().required(),
period: Yup.string().required(),
license_code: Yup.string().trim(),
});

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import { SubscriptionPlans } from '@/constants/subscriptionModels';
export const useSubscriptionPlans = () => {
return SubscriptionPlans;
};

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 {
id: number;
value: any;
@@ -195,6 +265,20 @@ export function useGetBankTransactionsMatches(
);
}
const onValidateExcludeUncategorizedTransaction = (queryClient) => {
// Invalidate queries.
queryClient.invalidateQueries(QUERY_KEY.EXCLUDED_BANK_TRANSACTIONS_INFINITY);
queryClient.invalidateQueries(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
// Invalidate accounts.
queryClient.invalidateQueries(t.ACCOUNTS);
queryClient.invalidateQueries(t.ACCOUNT);
// invalidate bank account summary.
queryClient.invalidateQueries(QUERY_KEY.BANK_ACCOUNT_SUMMARY_META);
};
type ExcludeUncategorizedTransactionValue = number;
interface ExcludeUncategorizedTransactionRes {}
@@ -228,19 +312,7 @@ export function useExcludeUncategorizedTransaction(
),
{
onSuccess: (res, id) => {
// Invalidate queries.
queryClient.invalidateQueries(
QUERY_KEY.EXCLUDED_BANK_TRANSACTIONS_INFINITY,
);
queryClient.invalidateQueries(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
// Invalidate accounts.
queryClient.invalidateQueries(t.ACCOUNTS);
queryClient.invalidateQueries(t.ACCOUNT);
// invalidate bank account summary.
queryClient.invalidateQueries(QUERY_KEY.BANK_ACCOUNT_SUMMARY_META);
onValidateExcludeUncategorizedTransaction(queryClient);
},
...options,
},
@@ -281,19 +353,83 @@ export function useUnexcludeUncategorizedTransaction(
),
{
onSuccess: (res, id) => {
// Invalidate queries.
queryClient.invalidateQueries(
QUERY_KEY.EXCLUDED_BANK_TRANSACTIONS_INFINITY,
);
queryClient.invalidateQueries(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
// Invalidate accounts.
queryClient.invalidateQueries(t.ACCOUNTS);
queryClient.invalidateQueries(t.ACCOUNT);
onValidateExcludeUncategorizedTransaction(queryClient);
},
...options,
},
);
}
// Invalidate bank account summary.
queryClient.invalidateQueries(QUERY_KEY.BANK_ACCOUNT_SUMMARY_META);
type ExcludeBankTransactionsValue = { ids: Array<number | string> };
interface ExcludeBankTransactionsResponse {}
/**
* Excludes the uncategorized bank transactions in bulk.
* @param {UseMutationResult<ExcludeBankTransactionsResponse, Error, ExcludeBankTransactionValue>} options
* @returns {UseMutationResult<ExcludeBankTransactionsResponse, Error, ExcludeBankTransactionValue>}
*/
export function useExcludeUncategorizedTransactions(
options?: UseMutationOptions<
ExcludeBankTransactionsResponse,
Error,
ExcludeBankTransactionsValue
>,
): UseMutationResult<
ExcludeBankTransactionsResponse,
Error,
ExcludeBankTransactionsValue
> {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation<
ExcludeBankTransactionsResponse,
Error,
ExcludeBankTransactionsValue
>(
(value: { ids: Array<number | string> }) =>
apiRequest.put(`/cashflow/transactions/exclude`, { ids: value.ids }),
{
onSuccess: (res, id) => {
onValidateExcludeUncategorizedTransaction(queryClient);
},
...options,
},
);
}
type UnexcludeBankTransactionsValue = { ids: Array<number | string> };
interface UnexcludeBankTransactionsResponse {}
/**
* Excludes the uncategorized bank transactions in bulk.
* @param {UseMutationResult<UnexcludeBankTransactionsResponse, Error, ExcludeBankTransactionValue>} options
* @returns {UseMutationResult<UnexcludeBankTransactionsResponse, Error, ExcludeBankTransactionValue>}
*/
export function useUnexcludeUncategorizedTransactions(
options?: UseMutationOptions<
UnexcludeBankTransactionsResponse,
Error,
UnexcludeBankTransactionsValue
>,
): UseMutationResult<
UnexcludeBankTransactionsResponse,
Error,
UnexcludeBankTransactionsValue
> {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation<
UnexcludeBankTransactionsResponse,
Error,
UnexcludeBankTransactionsValue
>(
(value: { ids: Array<number | string> }) =>
apiRequest.put(`/cashflow/transactions/unexclude`, { ids: value.ids }),
{
onSuccess: (res, id) => {
onValidateExcludeUncategorizedTransaction(queryClient);
},
...options,
},

View File

@@ -0,0 +1,196 @@
// @ts-nocheck
import {
useMutation,
UseMutationOptions,
UseMutationResult,
useQuery,
useQueryClient,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import useApiRequest from '../useRequest';
import { transformToCamelCase } from '@/utils';
const QueryKeys = {
Subscriptions: 'Subscriptions',
};
interface CancelMainSubscriptionValues {}
interface CancelMainSubscriptionResponse {}
/**
* Cancels the main subscription of the current organization.
* @param {UseMutationOptions<CreateBankRuleValues, Error, CreateBankRuleValues>} options -
* @returns {UseMutationResult<CreateBankRuleValues, Error, CreateBankRuleValues>}TCHES
*/
export function useCancelMainSubscription(
options?: UseMutationOptions<
CancelMainSubscriptionValues,
Error,
CancelMainSubscriptionResponse
>,
): UseMutationResult<
CancelMainSubscriptionValues,
Error,
CancelMainSubscriptionResponse
> {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation<
CancelMainSubscriptionValues,
Error,
CancelMainSubscriptionResponse
>(
(values) =>
apiRequest.post(`/subscription/cancel`, values).then((res) => res.data),
{
onSuccess: () => {
queryClient.invalidateQueries(QueryKeys.Subscriptions);
},
...options,
},
);
}
interface ResumeMainSubscriptionValues {}
interface ResumeMainSubscriptionResponse {}
/**
* Resumes the main subscription of the current organization.
* @param {UseMutationOptions<CreateBankRuleValues, Error, CreateBankRuleValues>} options -
* @returns {UseMutationResult<CreateBankRuleValues, Error, CreateBankRuleValues>}TCHES
*/
export function useResumeMainSubscription(
options?: UseMutationOptions<
ResumeMainSubscriptionValues,
Error,
ResumeMainSubscriptionResponse
>,
): UseMutationResult<
ResumeMainSubscriptionValues,
Error,
ResumeMainSubscriptionResponse
> {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation<
ResumeMainSubscriptionValues,
Error,
ResumeMainSubscriptionResponse
>(
(values) =>
apiRequest.post(`/subscription/resume`, values).then((res) => res.data),
{
onSuccess: () => {
queryClient.invalidateQueries(QueryKeys.Subscriptions);
},
...options,
},
);
}
interface ChangeMainSubscriptionPlanValues {
variant_id: string;
}
interface ChangeMainSubscriptionPlanResponse {}
/**
* Changese the main subscription of the current organization.
* @param {UseMutationOptions<ChangeMainSubscriptionPlanValues, Error, ChangeMainSubscriptionPlanResponse>} options -
* @returns {UseMutationResult<ChangeMainSubscriptionPlanValues, Error, ChangeMainSubscriptionPlanResponse>}
*/
export function useChangeSubscriptionPlan(
options?: UseMutationOptions<
ChangeMainSubscriptionPlanValues,
Error,
ChangeMainSubscriptionPlanResponse
>,
): UseMutationResult<
ChangeMainSubscriptionPlanValues,
Error,
ChangeMainSubscriptionPlanResponse
> {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation<
ChangeMainSubscriptionPlanResponse,
Error,
ChangeMainSubscriptionPlanValues
>(
(values) =>
apiRequest.post(`/subscription/change`, values).then((res) => res.data),
{
onSuccess: () => {
queryClient.invalidateQueries(QueryKeys.Subscriptions);
},
...options,
},
);
}
interface LemonSubscription {
active: boolean;
canceled: string | null;
canceledAt: string | null;
canceledAtFormatted: string | null;
cancelsAt: string | null;
cancelsAtFormatted: string | null;
createdAt: string;
ended: boolean;
endsAt: string | null;
inactive: boolean;
lemonSubscriptionId: string;
lemon_urls: {
updatePaymentMethod: string;
customerPortal: string;
customerPortalUpdateSubscription: string;
};
onTrial: boolean;
planId: number;
planName: string;
planSlug: string;
slug: string;
startsAt: string | null;
status: string;
statusFormatted: string;
tenantId: number;
trialEndsAt: string | null;
trialEndsAtFormatted: string | null;
trialStartsAt: string | null;
trialStartsAtFormatted: string | null;
updatedAt: string;
}
interface GetSubscriptionsQuery {}
interface GetSubscriptionsResponse {
subscriptions: Array<LemonSubscription>;
}
/**
* Changese the main subscription of the current organization.
* @param {UseMutationOptions<ChangeMainSubscriptionPlanValues, Error, ChangeMainSubscriptionPlanResponse>} options -
* @returns {UseMutationResult<ChangeMainSubscriptionPlanValues, Error, ChangeMainSubscriptionPlanResponse>}
*/
export function useGetSubscriptions(
options?: UseQueryOptions<
GetSubscriptionsQuery,
Error,
GetSubscriptionsResponse
>,
): UseQueryResult<GetSubscriptionsResponse, Error> {
const apiRequest = useApiRequest();
return useQuery<GetSubscriptionsQuery, Error, GetSubscriptionsResponse>(
[QueryKeys.Subscriptions],
(values) =>
apiRequest
.get(`/subscription`)
.then((res) => transformToCamelCase(res.data)),
{
...options,
},
);
}

View File

@@ -1231,6 +1231,13 @@ export const getDashboardRoutes = () => [
breadcrumb: 'Bank Rules',
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
},
{
path: '/billing',
component: lazy(() => import('@/containers/Subscriptions/BillingPage')),
pageTitle: 'Billing',
breadcrumb: 'Billing',
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
},
// Homepage
{
path: `/`,

View File

@@ -635,4 +635,11 @@ export default {
],
viewBox: '0 0 16 16',
},
feed: {
path: [
'M1.99,11.99c-1.1,0-2,0.9-2,2s0.9,2,2,2s2-0.9,2-2S3.1,11.99,1.99,11.99zM2.99,7.99c-0.55,0-1,0.45-1,1s0.45,1,1,1c1.66,0,3,1.34,3,3c0,0.55,0.45,1,1,1s1-0.45,1-1C7.99,10.23,5.75,7.99,2.99,7.99zM2.99,3.99c-0.55,0-1,0.45-1,1s0.45,1,1,1c3.87,0,7,3.13,7,7c0,0.55,0.45,1,1,1s1-0.45,1-1C11.99,8.02,7.96,3.99,2.99,3.99zM2.99-0.01c-0.55,0-1,0.45-1,1s0.45,1,1,1c6.08,0,11,4.92,11,11c0,0.55,0.45,1,1,1s1-0.45,1-1C15.99,5.81,10.17-0.01,2.99-0.01z',
],
viewBox: '0 0 16 16',
},
};

View File

@@ -5,6 +5,9 @@ interface StorePlaidState {
openMatchingTransactionAside: boolean;
uncategorizedTransactionIdForMatching: number | null;
openReconcileMatchingTransaction: { isOpen: boolean; pending: number };
uncategorizedTransactionsSelected: Array<number | string>;
excludedTransactionsSelected: Array<number | string>;
}
export const PlaidSlice = createSlice({
@@ -17,6 +20,8 @@ export const PlaidSlice = createSlice({
isOpen: false,
pending: 0,
},
uncategorizedTransactionsSelected: [],
excludedTransactionsSelected: [],
} as StorePlaidState,
reducers: {
setPlaidId: (state: StorePlaidState, action: PayloadAction<string>) => {
@@ -52,6 +57,46 @@ export const PlaidSlice = createSlice({
state.openReconcileMatchingTransaction.isOpen = false;
state.openReconcileMatchingTransaction.pending = 0;
},
/**
* Sets the selected uncategorized transactions.
* @param {StorePlaidState} state
* @param {PayloadAction<{ transactionIds: Array<string | number> }>} action
*/
setUncategorizedTransactionsSelected: (
state: StorePlaidState,
action: PayloadAction<{ transactionIds: Array<string | number> }>,
) => {
state.uncategorizedTransactionsSelected = action.payload.transactionIds;
},
/**
* Resets the selected uncategorized transactions.
* @param {StorePlaidState} state
*/
resetUncategorizedTransactionsSelected: (state: StorePlaidState) => {
state.uncategorizedTransactionsSelected = [];
},
/**
* Sets excluded selected transactions.
* @param {StorePlaidState} state
* @param {PayloadAction<{ ids: Array<string | number> }>} action
*/
setExcludedTransactionsSelected: (
state: StorePlaidState,
action: PayloadAction<{ ids: Array<string | number> }>,
) => {
state.excludedTransactionsSelected = action.payload.ids;
},
/**
* Resets the excluded selected transactions
* @param {StorePlaidState} state
*/
resetExcludedTransactionsSelected: (state: StorePlaidState) => {
state.excludedTransactionsSelected = [];
},
},
});
@@ -62,6 +107,10 @@ export const {
closeMatchingTransactionAside,
openReconcileMatchingTransaction,
closeReconcileMatchingTransaction,
setUncategorizedTransactionsSelected,
resetUncategorizedTransactionsSelected,
setExcludedTransactionsSelected,
resetExcludedTransactionsSelected,
} = PlaidSlice.actions;
export const getPlaidToken = (state: any) => state.plaid.plaidToken;

View File

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

View File

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