mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-20 23:00:34 +00:00
Merge branch 'develop' into bulk-categorize-bank-transactions
This commit is contained in:
@@ -132,6 +132,15 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"bug"
|
"bug"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "oleynikd",
|
||||||
|
"name": "Denis",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/3976868?v=4",
|
||||||
|
"profile": "https://github.com/oleynikd",
|
||||||
|
"contributions": [
|
||||||
|
"bug"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"contributorsPerLine": 7,
|
"contributorsPerLine": 7,
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
|||||||
<td align="center" valign="top" width="14.28%"><a href="http://vederis.id"><img src="https://avatars.githubusercontent.com/u/13505006?v=4?s=100" width="100px;" alt="Vederis Leunardus"/><br /><sub><b>Vederis Leunardus</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/commits?author=cloudsbird" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://vederis.id"><img src="https://avatars.githubusercontent.com/u/13505006?v=4?s=100" width="100px;" alt="Vederis Leunardus"/><br /><sub><b>Vederis Leunardus</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/commits?author=cloudsbird" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://www.pivoten.com"><img src="https://avatars.githubusercontent.com/u/104120598?v=4?s=100" width="100px;" alt="Chris Cantrell"/><br /><sub><b>Chris Cantrell</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/issues?q=author%3Accantrell72" title="Bug reports">🐛</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://www.pivoten.com"><img src="https://avatars.githubusercontent.com/u/104120598?v=4?s=100" width="100px;" alt="Chris Cantrell"/><br /><sub><b>Chris Cantrell</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/issues?q=author%3Accantrell72" title="Bug reports">🐛</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/oleynikd"><img src="https://avatars.githubusercontent.com/u/3976868?v=4?s=100" width="100px;" alt="Denis"/><br /><sub><b>Denis</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/issues?q=author%3Aoleynikd" title="Bug reports">🐛</a></td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ import { NextFunction, Request, Response, Router } from 'express';
|
|||||||
import BaseController from '@/api/controllers/BaseController';
|
import BaseController from '@/api/controllers/BaseController';
|
||||||
import { CashflowApplication } from '@/services/Cashflow/CashflowApplication';
|
import { CashflowApplication } from '@/services/Cashflow/CashflowApplication';
|
||||||
import { GetBankAccountSummary } from '@/services/Banking/BankAccounts/GetBankAccountSummary';
|
import { GetBankAccountSummary } from '@/services/Banking/BankAccounts/GetBankAccountSummary';
|
||||||
|
import { BankAccountsApplication } from '@/services/Banking/BankAccounts/BankAccountsApplication';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class BankAccountsController extends BaseController {
|
export class BankAccountsController extends BaseController {
|
||||||
@Inject()
|
@Inject()
|
||||||
private getBankAccountSummaryService: GetBankAccountSummary;
|
private getBankAccountSummaryService: GetBankAccountSummary;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private bankAccountsApp: BankAccountsApplication;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Router constructor.
|
* Router constructor.
|
||||||
*/
|
*/
|
||||||
@@ -16,6 +20,11 @@ export class BankAccountsController extends BaseController {
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/:bankAccountId/meta', this.getBankAccountSummary.bind(this));
|
router.get('/:bankAccountId/meta', this.getBankAccountSummary.bind(this));
|
||||||
|
router.post(
|
||||||
|
'/:bankAccountId/disconnect',
|
||||||
|
this.disconnectBankAccount.bind(this)
|
||||||
|
);
|
||||||
|
router.post('/:bankAccountId/update', this.refreshBankAccount.bind(this));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
@@ -46,4 +55,58 @@ export class BankAccountsController extends BaseController {
|
|||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disonnect the given bank account.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
* @returns {Promise<Response|null>}
|
||||||
|
*/
|
||||||
|
async disconnectBankAccount(
|
||||||
|
req: Request<{ bankAccountId: number }>,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
const { bankAccountId } = req.params;
|
||||||
|
const { tenantId } = req;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.bankAccountsApp.disconnectBankAccount(tenantId, bankAccountId);
|
||||||
|
|
||||||
|
return res.status(200).send({
|
||||||
|
id: bankAccountId,
|
||||||
|
message: 'The bank account has been disconnected.',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the given bank account.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
* @returns {Promise<Response|null>}
|
||||||
|
*/
|
||||||
|
async refreshBankAccount(
|
||||||
|
req: Request<{ bankAccountId: number }>,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
const { bankAccountId } = req.params;
|
||||||
|
const { tenantId } = req;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.bankAccountsApp.refreshBankAccount(tenantId, bankAccountId);
|
||||||
|
|
||||||
|
return res.status(200).send({
|
||||||
|
id: bankAccountId,
|
||||||
|
message: 'The bank account has been disconnected.',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import SubscriptionService from '@/services/Subscription/SubscriptionService';
|
|||||||
import asyncMiddleware from '@/api/middleware/asyncMiddleware';
|
import asyncMiddleware from '@/api/middleware/asyncMiddleware';
|
||||||
import BaseController from '../BaseController';
|
import BaseController from '../BaseController';
|
||||||
import { LemonSqueezyService } from '@/services/Subscription/LemonSqueezyService';
|
import { LemonSqueezyService } from '@/services/Subscription/LemonSqueezyService';
|
||||||
|
import { SubscriptionApplication } from '@/services/Subscription/SubscriptionApplication';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class SubscriptionController extends BaseController {
|
export class SubscriptionController extends BaseController {
|
||||||
@@ -17,6 +18,9 @@ export class SubscriptionController extends BaseController {
|
|||||||
@Inject()
|
@Inject()
|
||||||
private lemonSqueezyService: LemonSqueezyService;
|
private lemonSqueezyService: LemonSqueezyService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private subscriptionApp: SubscriptionApplication;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Router constructor.
|
* Router constructor.
|
||||||
*/
|
*/
|
||||||
@@ -33,6 +37,14 @@ export class SubscriptionController extends BaseController {
|
|||||||
this.validationResult,
|
this.validationResult,
|
||||||
this.getCheckoutUrl.bind(this)
|
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)));
|
router.get('/', asyncMiddleware(this.getSubscriptions.bind(this)));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
@@ -85,4 +97,84 @@ export class SubscriptionController extends BaseController {
|
|||||||
next(error);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,6 +236,10 @@ module.exports = {
|
|||||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||||||
endpoint: process.env.S3_ENDPOINT,
|
endpoint: process.env.S3_ENDPOINT,
|
||||||
bucket: process.env.S3_BUCKET || 'bigcapital-documents',
|
bucket: process.env.S3_BUCKET || 'bigcapital-documents',
|
||||||
|
forcePathStyle: parseBoolean(
|
||||||
|
defaultTo(process.env.S3_FORCE_PATH_STYLE, false),
|
||||||
|
false
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
loops: {
|
loops: {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
exports.up = function (knex) {
|
||||||
|
return knex.schema.table('accounts', (table) => {
|
||||||
|
table.string('plaid_item_id').nullable();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (knex) {
|
||||||
|
return knex.schema.table('accounts', (table) => {
|
||||||
|
table.dropColumn('plaid_item_id');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
exports.up = function (knex) {
|
||||||
|
return knex.schema
|
||||||
|
.table('accounts', (table) => {
|
||||||
|
table
|
||||||
|
.boolean('is_syncing_owner')
|
||||||
|
.defaultTo(false)
|
||||||
|
.after('is_feeds_active');
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
return knex('accounts')
|
||||||
|
.whereNotNull('plaid_item_id')
|
||||||
|
.orWhereNotNull('plaid_account_id')
|
||||||
|
.update('is_syncing_owner', true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (knex) {
|
||||||
|
table.dropColumn('is_syncing_owner');
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ export interface IAccountDTO {
|
|||||||
export interface IAccountCreateDTO extends IAccountDTO {
|
export interface IAccountCreateDTO extends IAccountDTO {
|
||||||
currencyCode?: string;
|
currencyCode?: string;
|
||||||
plaidAccountId?: string;
|
plaidAccountId?: string;
|
||||||
|
plaidItemId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAccountEditDTO extends IAccountDTO {}
|
export interface IAccountEditDTO extends IAccountDTO {}
|
||||||
@@ -37,6 +38,8 @@ export interface IAccount {
|
|||||||
accountNormal: string;
|
accountNormal: string;
|
||||||
accountParentType: string;
|
accountParentType: string;
|
||||||
bankBalance: string;
|
bankBalance: string;
|
||||||
|
plaidItemId: number | null
|
||||||
|
lastFeedsUpdatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AccountNormal {
|
export enum AccountNormal {
|
||||||
|
|||||||
@@ -1,69 +1,12 @@
|
|||||||
import { forEach } from 'lodash';
|
|
||||||
import { Configuration, PlaidApi, PlaidEnvironments } from 'plaid';
|
import { Configuration, PlaidApi, PlaidEnvironments } from 'plaid';
|
||||||
import { createPlaidApiEvent } from './PlaidApiEventsDBSync';
|
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
|
|
||||||
const OPTIONS = { clientApp: 'Plaid-Pattern' };
|
|
||||||
|
|
||||||
// We want to log requests to / responses from the Plaid API (via the Plaid client), as this data
|
|
||||||
// can be useful for troubleshooting.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logging function for Plaid client methods that use an access_token as an argument. Associates
|
|
||||||
* the Plaid API event log entry with the item and user the request is for.
|
|
||||||
*
|
|
||||||
* @param {string} clientMethod the name of the Plaid client method called.
|
|
||||||
* @param {Array} clientMethodArgs the arguments passed to the Plaid client method.
|
|
||||||
* @param {Object} response the response from the Plaid client.
|
|
||||||
*/
|
|
||||||
const defaultLogger = async (clientMethod, clientMethodArgs, response) => {
|
|
||||||
const accessToken = clientMethodArgs[0].access_token;
|
|
||||||
// const { id: itemId, user_id: userId } = await retrieveItemByPlaidAccessToken(
|
|
||||||
// accessToken
|
|
||||||
// );
|
|
||||||
// await createPlaidApiEvent(1, 1, clientMethod, clientMethodArgs, response);
|
|
||||||
|
|
||||||
// console.log(response);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logging function for Plaid client methods that do not use access_token as an argument. These
|
|
||||||
* Plaid API event log entries will not be associated with an item or user.
|
|
||||||
*
|
|
||||||
* @param {string} clientMethod the name of the Plaid client method called.
|
|
||||||
* @param {Array} clientMethodArgs the arguments passed to the Plaid client method.
|
|
||||||
* @param {Object} response the response from the Plaid client.
|
|
||||||
*/
|
|
||||||
const noAccessTokenLogger = async (
|
|
||||||
clientMethod,
|
|
||||||
clientMethodArgs,
|
|
||||||
response
|
|
||||||
) => {
|
|
||||||
// console.log(response);
|
|
||||||
|
|
||||||
// await createPlaidApiEvent(
|
|
||||||
// undefined,
|
|
||||||
// undefined,
|
|
||||||
// clientMethod,
|
|
||||||
// clientMethodArgs,
|
|
||||||
// response
|
|
||||||
// );
|
|
||||||
};
|
|
||||||
|
|
||||||
// Plaid client methods used in this app, mapped to their appropriate logging functions.
|
|
||||||
const clientMethodLoggingFns = {
|
|
||||||
accountsGet: defaultLogger,
|
|
||||||
institutionsGet: noAccessTokenLogger,
|
|
||||||
institutionsGetById: noAccessTokenLogger,
|
|
||||||
itemPublicTokenExchange: noAccessTokenLogger,
|
|
||||||
itemRemove: defaultLogger,
|
|
||||||
linkTokenCreate: noAccessTokenLogger,
|
|
||||||
transactionsSync: defaultLogger,
|
|
||||||
sandboxItemResetLogin: defaultLogger,
|
|
||||||
};
|
|
||||||
// Wrapper for the Plaid client. This allows us to easily log data for all Plaid client requests.
|
// Wrapper for the Plaid client. This allows us to easily log data for all Plaid client requests.
|
||||||
export class PlaidClientWrapper {
|
export class PlaidClientWrapper {
|
||||||
constructor() {
|
private static instance: PlaidClientWrapper;
|
||||||
|
private client: PlaidApi;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
// Initialize the Plaid client.
|
// Initialize the Plaid client.
|
||||||
const configuration = new Configuration({
|
const configuration = new Configuration({
|
||||||
basePath: PlaidEnvironments[config.plaid.env],
|
basePath: PlaidEnvironments[config.plaid.env],
|
||||||
@@ -75,26 +18,13 @@ export class PlaidClientWrapper {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.client = new PlaidApi(configuration);
|
this.client = new PlaidApi(configuration);
|
||||||
|
|
||||||
// Wrap the Plaid client methods to add a logging function.
|
|
||||||
forEach(clientMethodLoggingFns, (logFn, method) => {
|
|
||||||
this[method] = this.createWrappedClientMethod(method, logFn);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allows us to log API request data for troubleshooting purposes.
|
public static getClient(): PlaidApi {
|
||||||
createWrappedClientMethod(clientMethod, log) {
|
if (!PlaidClientWrapper.instance) {
|
||||||
return async (...args) => {
|
PlaidClientWrapper.instance = new PlaidClientWrapper();
|
||||||
try {
|
}
|
||||||
const res = await this.client[clientMethod](...args);
|
return PlaidClientWrapper.instance.client;
|
||||||
await log(clientMethod, args, res);
|
|
||||||
return res;
|
|
||||||
} catch (err) {
|
|
||||||
await log(clientMethod, args, err?.response?.data);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export const s3 = new S3Client({
|
|||||||
secretAccessKey: config.s3.secretAccessKey,
|
secretAccessKey: config.s3.secretAccessKey,
|
||||||
},
|
},
|
||||||
endpoint: config.s3.endpoint,
|
endpoint: config.s3.endpoint,
|
||||||
|
forcePathStyle: config.s3.forcePathStyle,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ import { UnlinkBankRuleOnDeleteBankRule } from '@/services/Banking/Rules/events/
|
|||||||
import { DecrementUncategorizedTransactionOnMatching } from '@/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch';
|
import { DecrementUncategorizedTransactionOnMatching } from '@/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch';
|
||||||
import { DecrementUncategorizedTransactionOnExclude } from '@/services/Banking/Exclude/events/DecrementUncategorizedTransactionOnExclude';
|
import { DecrementUncategorizedTransactionOnExclude } from '@/services/Banking/Exclude/events/DecrementUncategorizedTransactionOnExclude';
|
||||||
import { DecrementUncategorizedTransactionOnCategorize } from '@/services/Cashflow/subscribers/DecrementUncategorizedTransactionOnCategorize';
|
import { DecrementUncategorizedTransactionOnCategorize } from '@/services/Cashflow/subscribers/DecrementUncategorizedTransactionOnCategorize';
|
||||||
|
import { DisconnectPlaidItemOnAccountDeleted } from '@/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted';
|
||||||
import { LoopsEventsSubscriber } from '@/services/Loops/LoopsEventsSubscriber';
|
import { LoopsEventsSubscriber } from '@/services/Loops/LoopsEventsSubscriber';
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
@@ -275,6 +276,7 @@ export const susbcribers = () => {
|
|||||||
|
|
||||||
// Plaid
|
// Plaid
|
||||||
RecognizeSyncedBankTranasctions,
|
RecognizeSyncedBankTranasctions,
|
||||||
|
DisconnectPlaidItemOnAccountDeleted,
|
||||||
|
|
||||||
// Loops
|
// Loops
|
||||||
LoopsEventsSubscriber
|
LoopsEventsSubscriber
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ export default class Account extends mixin(TenantModel, [
|
|||||||
const ExpenseEntry = require('models/ExpenseCategory');
|
const ExpenseEntry = require('models/ExpenseCategory');
|
||||||
const ItemEntry = require('models/ItemEntry');
|
const ItemEntry = require('models/ItemEntry');
|
||||||
const UncategorizedTransaction = require('models/UncategorizedCashflowTransaction');
|
const UncategorizedTransaction = require('models/UncategorizedCashflowTransaction');
|
||||||
|
const PlaidItem = require('models/PlaidItem');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/**
|
/**
|
||||||
@@ -321,6 +322,18 @@ export default class Account extends mixin(TenantModel, [
|
|||||||
query.where('categorized', false);
|
query.where('categorized', false);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account model may belongs to a Plaid item.
|
||||||
|
*/
|
||||||
|
plaidItem: {
|
||||||
|
relation: Model.BelongsToOneRelation,
|
||||||
|
modelClass: PlaidItem.default,
|
||||||
|
join: {
|
||||||
|
from: 'accounts.plaidItemId',
|
||||||
|
to: 'plaid_items.plaidItemId',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ export class AccountTransformer extends Transformer {
|
|||||||
* @returns {Array}
|
* @returns {Array}
|
||||||
*/
|
*/
|
||||||
public includeAttributes = (): string[] => {
|
public includeAttributes = (): string[] => {
|
||||||
return ['formattedAmount', 'flattenName', 'bankBalanceFormatted'];
|
return [
|
||||||
|
'formattedAmount',
|
||||||
|
'flattenName',
|
||||||
|
'bankBalanceFormatted',
|
||||||
|
'lastFeedsUpdatedAtFormatted',
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,6 +57,15 @@ export class AccountTransformer extends Transformer {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the formatted last feeds update at.
|
||||||
|
* @param {IAccount} account
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
protected lastFeedsUpdatedAtFormatted = (account: IAccount): string => {
|
||||||
|
return this.formatDate(account.lastFeedsUpdatedAt);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transformes the accounts collection to flat or nested array.
|
* Transformes the accounts collection to flat or nested array.
|
||||||
* @param {IAccount[]}
|
* @param {IAccount[]}
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ export class CreateAccount {
|
|||||||
...createAccountDTO,
|
...createAccountDTO,
|
||||||
slug: kebabCase(createAccountDTO.name),
|
slug: kebabCase(createAccountDTO.name),
|
||||||
currencyCode: createAccountDTO.currencyCode || baseCurrency,
|
currencyCode: createAccountDTO.currencyCode || baseCurrency,
|
||||||
|
|
||||||
|
// Mark the account is Plaid owner since Plaid item/account is defined on creating.
|
||||||
|
isSyncingOwner: Boolean(
|
||||||
|
createAccountDTO.plaidAccountId || createAccountDTO.plaidItemId
|
||||||
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,12 +122,7 @@ export class CreateAccount {
|
|||||||
const tenantMeta = await TenantMetadata.query().findOne({ tenantId });
|
const tenantMeta = await TenantMetadata.query().findOne({ tenantId });
|
||||||
|
|
||||||
// Authorize the account creation.
|
// Authorize the account creation.
|
||||||
await this.authorize(
|
await this.authorize(tenantId, accountDTO, tenantMeta.baseCurrency, params);
|
||||||
tenantId,
|
|
||||||
accountDTO,
|
|
||||||
tenantMeta.baseCurrency,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
// Transformes the DTO to model.
|
// Transformes the DTO to model.
|
||||||
const accountInputModel = this.transformDTOToModel(
|
const accountInputModel = this.transformDTOToModel(
|
||||||
accountDTO,
|
accountDTO,
|
||||||
@@ -157,4 +157,3 @@ export class CreateAccount {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Inject, Service } from 'typedi';
|
||||||
|
import { DisconnectBankAccount } from './DisconnectBankAccount';
|
||||||
|
import { RefreshBankAccountService } from './RefreshBankAccount';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class BankAccountsApplication {
|
||||||
|
@Inject()
|
||||||
|
private disconnectBankAccountService: DisconnectBankAccount;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private refreshBankAccountService: RefreshBankAccountService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnects the given bank account.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number} bankAccountId
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async disconnectBankAccount(tenantId: number, bankAccountId: number) {
|
||||||
|
return this.disconnectBankAccountService.disconnectBankAccount(
|
||||||
|
tenantId,
|
||||||
|
bankAccountId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the bank transactions of the given bank account.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number} bankAccountId
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async refreshBankAccount(tenantId: number, bankAccountId: number) {
|
||||||
|
return this.refreshBankAccountService.refreshBankAccount(
|
||||||
|
tenantId,
|
||||||
|
bankAccountId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { Inject, Service } from 'typedi';
|
||||||
|
import { ServiceError } from '@/exceptions';
|
||||||
|
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
|
||||||
|
import { PlaidClientWrapper } from '@/lib/Plaid';
|
||||||
|
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||||
|
import UnitOfWork from '@/services/UnitOfWork';
|
||||||
|
import events from '@/subscribers/events';
|
||||||
|
import {
|
||||||
|
ERRORS,
|
||||||
|
IBankAccountDisconnectedEventPayload,
|
||||||
|
IBankAccountDisconnectingEventPayload,
|
||||||
|
} from './types';
|
||||||
|
import { ACCOUNT_TYPE } from '@/data/AccountTypes';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class DisconnectBankAccount {
|
||||||
|
@Inject()
|
||||||
|
private tenancy: HasTenancyService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private eventPublisher: EventPublisher;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private uow: UnitOfWork;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnects the given bank account.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number} bankAccountId
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public async disconnectBankAccount(tenantId: number, bankAccountId: number) {
|
||||||
|
const { Account, PlaidItem } = this.tenancy.models(tenantId);
|
||||||
|
|
||||||
|
// Retrieve the bank account or throw not found error.
|
||||||
|
const account = await Account.query()
|
||||||
|
.findById(bankAccountId)
|
||||||
|
.whereIn('account_type', [ACCOUNT_TYPE.CASH, ACCOUNT_TYPE.BANK])
|
||||||
|
.withGraphFetched('plaidItem')
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
const oldPlaidItem = account.plaidItem;
|
||||||
|
|
||||||
|
if (!oldPlaidItem) {
|
||||||
|
throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED);
|
||||||
|
}
|
||||||
|
const plaidInstance = PlaidClientWrapper.getClient();
|
||||||
|
|
||||||
|
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
|
||||||
|
// Triggers `onBankAccountDisconnecting` event.
|
||||||
|
await this.eventPublisher.emitAsync(events.bankAccount.onDisconnecting, {
|
||||||
|
tenantId,
|
||||||
|
bankAccountId,
|
||||||
|
} as IBankAccountDisconnectingEventPayload);
|
||||||
|
|
||||||
|
// Remove the Plaid item from the system.
|
||||||
|
await PlaidItem.query(trx).findById(account.plaidItemId).delete();
|
||||||
|
|
||||||
|
// Remove the plaid item association to the bank account.
|
||||||
|
await Account.query(trx).findById(bankAccountId).patch({
|
||||||
|
plaidAccountId: null,
|
||||||
|
plaidItemId: null,
|
||||||
|
isFeedsActive: false,
|
||||||
|
});
|
||||||
|
// Remove the Plaid item.
|
||||||
|
await plaidInstance.itemRemove({
|
||||||
|
access_token: oldPlaidItem.plaidAccessToken,
|
||||||
|
});
|
||||||
|
// Triggers `onBankAccountDisconnected` event.
|
||||||
|
await this.eventPublisher.emitAsync(events.bankAccount.onDisconnected, {
|
||||||
|
tenantId,
|
||||||
|
bankAccountId,
|
||||||
|
trx,
|
||||||
|
} as IBankAccountDisconnectedEventPayload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Inject, Service } from 'typedi';
|
||||||
|
import { ServiceError } from '@/exceptions';
|
||||||
|
import { PlaidClientWrapper } from '@/lib/Plaid';
|
||||||
|
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||||
|
import { ERRORS } from './types';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class RefreshBankAccountService {
|
||||||
|
@Inject()
|
||||||
|
private tenancy: HasTenancyService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks Plaid to trigger syncing the given bank account.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number} bankAccountId
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
public async refreshBankAccount(tenantId: number, bankAccountId: number) {
|
||||||
|
const { Account } = this.tenancy.models(tenantId);
|
||||||
|
|
||||||
|
const bankAccount = await Account.query()
|
||||||
|
.findById(bankAccountId)
|
||||||
|
.withGraphFetched('plaidItem')
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
// Can't continue if the given account is not linked with Plaid item.
|
||||||
|
if (!bankAccount.plaidItem) {
|
||||||
|
throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED);
|
||||||
|
}
|
||||||
|
const plaidInstance = PlaidClientWrapper.getClient();
|
||||||
|
|
||||||
|
await plaidInstance.transactionsRefresh({
|
||||||
|
access_token: bankAccount.plaidItem.plaidAccessToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Inject, Service } from 'typedi';
|
||||||
|
import { IAccountEventDeletedPayload } from '@/interfaces';
|
||||||
|
import { PlaidClientWrapper } from '@/lib/Plaid';
|
||||||
|
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||||
|
import events from '@/subscribers/events';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class DisconnectPlaidItemOnAccountDeleted {
|
||||||
|
@Inject()
|
||||||
|
private tenancy: HasTenancyService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor method.
|
||||||
|
*/
|
||||||
|
public attach(bus) {
|
||||||
|
bus.subscribe(
|
||||||
|
events.accounts.onDeleted,
|
||||||
|
this.handleDisconnectPlaidItemOnAccountDelete.bind(this)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes Plaid item from the system and Plaid once the account deleted.
|
||||||
|
* @param {IAccountEventDeletedPayload} payload
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
private async handleDisconnectPlaidItemOnAccountDelete({
|
||||||
|
tenantId,
|
||||||
|
oldAccount,
|
||||||
|
trx,
|
||||||
|
}: IAccountEventDeletedPayload) {
|
||||||
|
const { PlaidItem, Account } = this.tenancy.models(tenantId);
|
||||||
|
|
||||||
|
// Can't continue if the deleted account is not linked to Plaid item.
|
||||||
|
if (!oldAccount.plaidItemId) return;
|
||||||
|
|
||||||
|
// Retrieves the Plaid item that associated to the deleted account.
|
||||||
|
const oldPlaidItem = await PlaidItem.query(trx).findOne(
|
||||||
|
'plaidItemId',
|
||||||
|
oldAccount.plaidItemId
|
||||||
|
);
|
||||||
|
// Unlink the Plaid item from all account before deleting it.
|
||||||
|
await Account.query(trx)
|
||||||
|
.where('plaidItemId', oldAccount.plaidItemId)
|
||||||
|
.patch({
|
||||||
|
plaidAccountId: null,
|
||||||
|
plaidItemId: null,
|
||||||
|
});
|
||||||
|
// Remove the Plaid item from the system.
|
||||||
|
await PlaidItem.query(trx)
|
||||||
|
.findOne('plaidItemId', oldAccount.plaidItemId)
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
if (oldPlaidItem) {
|
||||||
|
const plaidInstance = PlaidClientWrapper.getClient();
|
||||||
|
|
||||||
|
// Remove the Plaid item.
|
||||||
|
await plaidInstance.itemRemove({
|
||||||
|
access_token: oldPlaidItem.plaidAccessToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
packages/server/src/services/Banking/BankAccounts/types.ts
Normal file
17
packages/server/src/services/Banking/BankAccounts/types.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
|
||||||
|
export interface IBankAccountDisconnectingEventPayload {
|
||||||
|
tenantId: number;
|
||||||
|
bankAccountId: number;
|
||||||
|
trx: Knex.Transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBankAccountDisconnectedEventPayload {
|
||||||
|
tenantId: number;
|
||||||
|
bankAccountId: number;
|
||||||
|
trx: Knex.Transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ERRORS = {
|
||||||
|
BANK_ACCOUNT_NOT_CONNECTED: 'BANK_ACCOUNT_NOT_CONNECTED',
|
||||||
|
};
|
||||||
@@ -28,7 +28,7 @@ export class PlaidItemService {
|
|||||||
const { PlaidItem } = this.tenancy.models(tenantId);
|
const { PlaidItem } = this.tenancy.models(tenantId);
|
||||||
const { publicToken, institutionId } = itemDTO;
|
const { publicToken, institutionId } = itemDTO;
|
||||||
|
|
||||||
const plaidInstance = new PlaidClientWrapper();
|
const plaidInstance = PlaidClientWrapper.getClient();
|
||||||
|
|
||||||
// Exchange the public token for a private access token and store with the item.
|
// Exchange the public token for a private access token and store with the item.
|
||||||
const response = await plaidInstance.itemPublicTokenExchange({
|
const response = await plaidInstance.itemPublicTokenExchange({
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export class PlaidLinkTokenService {
|
|||||||
webhook: config.plaid.linkWebhook,
|
webhook: config.plaid.linkWebhook,
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
};
|
};
|
||||||
const plaidInstance = new PlaidClientWrapper();
|
const plaidInstance = PlaidClientWrapper.getClient();
|
||||||
const createResponse = await plaidInstance.linkTokenCreate(linkTokenParams);
|
const createResponse = await plaidInstance.linkTokenCreate(linkTokenParams);
|
||||||
|
|
||||||
return createResponse.data;
|
return createResponse.data;
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import * as R from 'ramda';
|
|||||||
import { Inject, Service } from 'typedi';
|
import { Inject, Service } from 'typedi';
|
||||||
import bluebird from 'bluebird';
|
import bluebird from 'bluebird';
|
||||||
import { entries, groupBy } from 'lodash';
|
import { entries, groupBy } from 'lodash';
|
||||||
|
import {
|
||||||
|
AccountBase as PlaidAccountBase,
|
||||||
|
Item as PlaidItem,
|
||||||
|
Institution as PlaidInstitution,
|
||||||
|
} from 'plaid';
|
||||||
import { CreateAccount } from '@/services/Accounts/CreateAccount';
|
import { CreateAccount } from '@/services/Accounts/CreateAccount';
|
||||||
import {
|
import {
|
||||||
IAccountCreateDTO,
|
IAccountCreateDTO,
|
||||||
@@ -53,6 +58,7 @@ export class PlaidSyncDb {
|
|||||||
trx?: Knex.Transaction
|
trx?: Knex.Transaction
|
||||||
) {
|
) {
|
||||||
const { Account } = this.tenancy.models(tenantId);
|
const { Account } = this.tenancy.models(tenantId);
|
||||||
|
|
||||||
const plaidAccount = await Account.query().findOne(
|
const plaidAccount = await Account.query().findOne(
|
||||||
'plaidAccountId',
|
'plaidAccountId',
|
||||||
createBankAccountDTO.plaidAccountId
|
createBankAccountDTO.plaidAccountId
|
||||||
@@ -77,13 +83,15 @@ export class PlaidSyncDb {
|
|||||||
*/
|
*/
|
||||||
public async syncBankAccounts(
|
public async syncBankAccounts(
|
||||||
tenantId: number,
|
tenantId: number,
|
||||||
plaidAccounts: PlaidAccount[],
|
plaidAccounts: PlaidAccountBase[],
|
||||||
institution: any,
|
institution: PlaidInstitution,
|
||||||
|
item: PlaidItem,
|
||||||
trx?: Knex.Transaction
|
trx?: Knex.Transaction
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const transformToPlaidAccounts =
|
const transformToPlaidAccounts = transformPlaidAccountToCreateAccount(
|
||||||
transformPlaidAccountToCreateAccount(institution);
|
item,
|
||||||
|
institution
|
||||||
|
);
|
||||||
const accountCreateDTOs = R.map(transformToPlaidAccounts)(plaidAccounts);
|
const accountCreateDTOs = R.map(transformToPlaidAccounts)(plaidAccounts);
|
||||||
|
|
||||||
await bluebird.map(
|
await bluebird.map(
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export class PlaidUpdateTransactions {
|
|||||||
await this.fetchTransactionUpdates(tenantId, plaidItemId);
|
await this.fetchTransactionUpdates(tenantId, plaidItemId);
|
||||||
|
|
||||||
const request = { access_token: accessToken };
|
const request = { access_token: accessToken };
|
||||||
const plaidInstance = new PlaidClientWrapper();
|
const plaidInstance = PlaidClientWrapper.getClient();
|
||||||
const {
|
const {
|
||||||
data: { accounts, item },
|
data: { accounts, item },
|
||||||
} = await plaidInstance.accountsGet(request);
|
} = await plaidInstance.accountsGet(request);
|
||||||
@@ -66,7 +66,13 @@ export class PlaidUpdateTransactions {
|
|||||||
country_codes: ['US', 'UK'],
|
country_codes: ['US', 'UK'],
|
||||||
});
|
});
|
||||||
// Sync bank accounts.
|
// Sync bank accounts.
|
||||||
await this.plaidSync.syncBankAccounts(tenantId, accounts, institution, trx);
|
await this.plaidSync.syncBankAccounts(
|
||||||
|
tenantId,
|
||||||
|
accounts,
|
||||||
|
institution,
|
||||||
|
item,
|
||||||
|
trx
|
||||||
|
);
|
||||||
// Sync bank account transactions.
|
// Sync bank account transactions.
|
||||||
await this.plaidSync.syncAccountsTransactions(
|
await this.plaidSync.syncAccountsTransactions(
|
||||||
tenantId,
|
tenantId,
|
||||||
@@ -141,7 +147,7 @@ export class PlaidUpdateTransactions {
|
|||||||
cursor: cursor,
|
cursor: cursor,
|
||||||
count: batchSize,
|
count: batchSize,
|
||||||
};
|
};
|
||||||
const plaidInstance = new PlaidClientWrapper();
|
const plaidInstance = PlaidClientWrapper.getClient();
|
||||||
const response = await plaidInstance.transactionsSync(request);
|
const response = await plaidInstance.transactionsSync(request);
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
// Add this page of results
|
// Add this page of results
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
import * as R from 'ramda';
|
import * as R from 'ramda';
|
||||||
|
import {
|
||||||
|
Item as PlaidItem,
|
||||||
|
Institution as PlaidInstitution,
|
||||||
|
AccountBase as PlaidAccount,
|
||||||
|
} from 'plaid';
|
||||||
import {
|
import {
|
||||||
CreateUncategorizedTransactionDTO,
|
CreateUncategorizedTransactionDTO,
|
||||||
IAccountCreateDTO,
|
IAccountCreateDTO,
|
||||||
PlaidAccount,
|
|
||||||
PlaidTransaction,
|
PlaidTransaction,
|
||||||
} from '@/interfaces';
|
} from '@/interfaces';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transformes the Plaid account to create cashflow account DTO.
|
* Transformes the Plaid account to create cashflow account DTO.
|
||||||
* @param {PlaidAccount} plaidAccount
|
* @param {PlaidItem} item -
|
||||||
|
* @param {PlaidInstitution} institution -
|
||||||
|
* @param {PlaidAccount} plaidAccount -
|
||||||
* @returns {IAccountCreateDTO}
|
* @returns {IAccountCreateDTO}
|
||||||
*/
|
*/
|
||||||
export const transformPlaidAccountToCreateAccount = R.curry(
|
export const transformPlaidAccountToCreateAccount = R.curry(
|
||||||
(institution: any, plaidAccount: PlaidAccount): IAccountCreateDTO => {
|
(
|
||||||
|
item: PlaidItem,
|
||||||
|
institution: PlaidInstitution,
|
||||||
|
plaidAccount: PlaidAccount
|
||||||
|
): IAccountCreateDTO => {
|
||||||
return {
|
return {
|
||||||
name: `${institution.name} - ${plaidAccount.name}`,
|
name: `${institution.name} - ${plaidAccount.name}`,
|
||||||
code: '',
|
code: '',
|
||||||
@@ -20,9 +30,10 @@ export const transformPlaidAccountToCreateAccount = R.curry(
|
|||||||
currencyCode: plaidAccount.balances.iso_currency_code,
|
currencyCode: plaidAccount.balances.iso_currency_code,
|
||||||
accountType: 'cash',
|
accountType: 'cash',
|
||||||
active: true,
|
active: true,
|
||||||
plaidAccountId: plaidAccount.account_id,
|
|
||||||
bankBalance: plaidAccount.balances.current,
|
bankBalance: plaidAccount.balances.current,
|
||||||
accountMask: plaidAccount.mask,
|
accountMask: plaidAccount.mask,
|
||||||
|
plaidAccountId: plaidAccount.account_id,
|
||||||
|
plaidItemId: item.item_id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { Inject, Service } from 'typedi';
|
||||||
import { IFeatureAllItem, ISystemUser } from '@/interfaces';
|
import { IFeatureAllItem, ISystemUser } from '@/interfaces';
|
||||||
import { FeaturesManager } from '@/services/Features/FeaturesManager';
|
import { FeaturesManager } from '@/services/Features/FeaturesManager';
|
||||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||||
import { Inject, Service } from 'typedi';
|
import config from '@/config';
|
||||||
|
|
||||||
interface IRoleAbility {
|
interface IRoleAbility {
|
||||||
subject: string;
|
subject: string;
|
||||||
@@ -11,15 +12,16 @@ interface IRoleAbility {
|
|||||||
interface IDashboardBootMeta {
|
interface IDashboardBootMeta {
|
||||||
abilities: IRoleAbility[];
|
abilities: IRoleAbility[];
|
||||||
features: IFeatureAllItem[];
|
features: IFeatureAllItem[];
|
||||||
|
isBigcapitalCloud: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class DashboardService {
|
export default class DashboardService {
|
||||||
@Inject()
|
@Inject()
|
||||||
tenancy: HasTenancyService;
|
private tenancy: HasTenancyService;
|
||||||
|
|
||||||
@Inject()
|
@Inject()
|
||||||
featuresManager: FeaturesManager;
|
private featuresManager: FeaturesManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve dashboard meta.
|
* Retrieve dashboard meta.
|
||||||
@@ -39,6 +41,7 @@ export default class DashboardService {
|
|||||||
return {
|
return {
|
||||||
abilities,
|
abilities,
|
||||||
features,
|
features,
|
||||||
|
isBigcapitalCloud: config.hostedOnBigcapitalCloud
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { PlanSubscription } from '@/system/models';
|
||||||
|
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
|
||||||
|
import { GetSubscriptionsTransformer } from './GetSubscriptionsTransformer';
|
||||||
|
import { configureLemonSqueezy } from './utils';
|
||||||
|
import { fromPairs } from 'lodash';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class SubscriptionService {
|
export default class SubscriptionService {
|
||||||
|
@Inject()
|
||||||
|
private transformer: TransformerInjectable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve all subscription of the given tenant.
|
* Retrieve all subscription of the given tenant.
|
||||||
* @param {number} tenantId
|
* @param {number} tenantId
|
||||||
*/
|
*/
|
||||||
public async getSubscriptions(tenantId: number) {
|
public async getSubscriptions(tenantId: number) {
|
||||||
const subscriptions = await PlanSubscription.query().where(
|
configureLemonSqueezy();
|
||||||
'tenant_id',
|
|
||||||
tenantId
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
packages/server/src/services/Subscription/types.ts
Normal file
20
packages/server/src/services/Subscription/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -41,9 +41,12 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User subscription events.
|
* Organization subscription.
|
||||||
*/
|
*/
|
||||||
subscription: {
|
subscription: {
|
||||||
|
onSubscriptionCanceled: 'onSubscriptionCanceled',
|
||||||
|
onSubscriptionResumed: 'onSubscriptionResumed',
|
||||||
|
onSubscriptionPlanChanged: 'onSubscriptionPlanChanged',
|
||||||
onSubscribed: 'onOrganizationSubscribed',
|
onSubscribed: 'onOrganizationSubscribed',
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -658,6 +661,11 @@ export default {
|
|||||||
onUnexcluded: 'onBankTransactionUnexcluded',
|
onUnexcluded: 'onBankTransactionUnexcluded',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
bankAccount: {
|
||||||
|
onDisconnecting: 'onBankAccountDisconnecting',
|
||||||
|
onDisconnected: 'onBankAccountDisconnected',
|
||||||
|
},
|
||||||
|
|
||||||
// Import files.
|
// Import files.
|
||||||
import: {
|
import: {
|
||||||
onImportCommitted: 'onImportFileCommitted',
|
onImportCommitted: 'onImportFileCommitted',
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -4,6 +4,15 @@ import moment from 'moment';
|
|||||||
import SubscriptionPeriod from '@/services/Subscription/SubscriptionPeriod';
|
import SubscriptionPeriod from '@/services/Subscription/SubscriptionPeriod';
|
||||||
|
|
||||||
export default class PlanSubscription extends mixin(SystemModel) {
|
export default class PlanSubscription extends mixin(SystemModel) {
|
||||||
|
public lemonSubscriptionId: number;
|
||||||
|
|
||||||
|
public endsAt: Date;
|
||||||
|
public startsAt: Date;
|
||||||
|
|
||||||
|
public canceledAt: Date;
|
||||||
|
|
||||||
|
public trialEndsAt: Date;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Table name.
|
* Table name.
|
||||||
*/
|
*/
|
||||||
@@ -22,7 +31,7 @@ export default class PlanSubscription extends mixin(SystemModel) {
|
|||||||
* Defined virtual attributes.
|
* Defined virtual attributes.
|
||||||
*/
|
*/
|
||||||
static get virtualAttributes() {
|
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);
|
builder.where('trial_ends_at', '>', now);
|
||||||
},
|
},
|
||||||
|
|
||||||
inactiveSubscriptions() {
|
inactiveSubscriptions(builder) {
|
||||||
builder.modify('endedTrial');
|
builder.modify('endedTrial');
|
||||||
builder.modify('endedPeriod');
|
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}
|
* @return {Boolean}
|
||||||
*/
|
*/
|
||||||
active() {
|
public active() {
|
||||||
return !this.ended() || this.onTrial();
|
return this.onTrial() || !this.ended();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if subscription is inactive.
|
* Check if the subscription is inactive.
|
||||||
* @return {Boolean}
|
* @return {Boolean}
|
||||||
*/
|
*/
|
||||||
inactive() {
|
public inactive() {
|
||||||
return !this.active();
|
return !this.active();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if subscription period has ended.
|
* Check if paid subscription period has ended.
|
||||||
* @return {Boolean}
|
* @return {Boolean}
|
||||||
*/
|
*/
|
||||||
ended() {
|
public ended() {
|
||||||
return this.endsAt ? moment().isAfter(this.endsAt) : false;
|
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.
|
* Check if subscription is currently on trial.
|
||||||
* @return {Boolean}
|
* @return {Boolean}
|
||||||
*/
|
*/
|
||||||
onTrial() {
|
public onTrial() {
|
||||||
return this.trailEndsAt ? moment().isAfter(this.trailEndsAt) : false;
|
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(
|
const period = new SubscriptionPeriod(
|
||||||
invoiceInterval,
|
invoiceInterval,
|
||||||
invoicePeriod,
|
invoicePeriod,
|
||||||
start,
|
start
|
||||||
);
|
);
|
||||||
|
|
||||||
const startsAt = period.getStartDate();
|
const startsAt = period.getStartDate();
|
||||||
@@ -157,7 +196,7 @@ export default class PlanSubscription extends mixin(SystemModel) {
|
|||||||
renew(invoiceInterval, invoicePeriod) {
|
renew(invoiceInterval, invoicePeriod) {
|
||||||
const { startsAt, endsAt } = PlanSubscription.setNewPeriod(
|
const { startsAt, endsAt } = PlanSubscription.setNewPeriod(
|
||||||
invoiceInterval,
|
invoiceInterval,
|
||||||
invoicePeriod,
|
invoicePeriod
|
||||||
);
|
);
|
||||||
return this.$query().update({ startsAt, endsAt });
|
return this.$query().update({ startsAt, endsAt });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,6 @@
|
|||||||
import { Position, Toaster, Intent } from '@blueprintjs/core';
|
import { Position, Toaster, Intent } from '@blueprintjs/core';
|
||||||
|
|
||||||
export const AppToaster = Toaster.create({
|
export const AppToaster = Toaster.create({
|
||||||
position: Position.RIGHT_BOTTOM,
|
position: Position.TOP,
|
||||||
intent: Intent.WARNING,
|
intent: Intent.WARNING,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useAuthActions } from '@/hooks/state';
|
|||||||
|
|
||||||
import withDialogActions from '@/containers/Dialog/withDialogActions';
|
import withDialogActions from '@/containers/Dialog/withDialogActions';
|
||||||
|
|
||||||
import { useAuthenticatedAccount } from '@/hooks/query';
|
import { useAuthenticatedAccount, useDashboardMeta } from '@/hooks/query';
|
||||||
import { firstLettersArgs, compose } from '@/utils';
|
import { firstLettersArgs, compose } from '@/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,6 +31,9 @@ function DashboardTopbarUser({
|
|||||||
// Retrieve authenticated user information.
|
// Retrieve authenticated user information.
|
||||||
const { data: user } = useAuthenticatedAccount();
|
const { data: user } = useAuthenticatedAccount();
|
||||||
|
|
||||||
|
const { data: dashboardMeta } = useDashboardMeta({
|
||||||
|
keepPreviousData: true,
|
||||||
|
});
|
||||||
const onClickLogout = () => {
|
const onClickLogout = () => {
|
||||||
setLogout();
|
setLogout();
|
||||||
};
|
};
|
||||||
@@ -58,6 +61,12 @@ function DashboardTopbarUser({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<MenuDivider />
|
<MenuDivider />
|
||||||
|
{dashboardMeta.is_bigcapital_cloud && (
|
||||||
|
<MenuItem
|
||||||
|
text={'Billing'}
|
||||||
|
onClick={() => history.push('/billing')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
text={<T id={'keyboard_shortcuts'} />}
|
text={<T id={'keyboard_shortcuts'} />}
|
||||||
onClick={onKeyboardShortcut}
|
onClick={onKeyboardShortcut}
|
||||||
@@ -79,6 +88,4 @@ function DashboardTopbarUser({
|
|||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export default compose(
|
export default compose(withDialogActions)(DashboardTopbarUser);
|
||||||
withDialogActions,
|
|
||||||
)(DashboardTopbarUser);
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import RefundVendorCreditDetailDrawer from '@/containers/Drawers/RefundVendorCre
|
|||||||
import WarehouseTransferDetailDrawer from '@/containers/Drawers/WarehouseTransferDetailDrawer';
|
import WarehouseTransferDetailDrawer from '@/containers/Drawers/WarehouseTransferDetailDrawer';
|
||||||
import TaxRateDetailsDrawer from '@/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsDrawer';
|
import TaxRateDetailsDrawer from '@/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsDrawer';
|
||||||
import CategorizeTransactionDrawer from '@/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionDrawer';
|
import CategorizeTransactionDrawer from '@/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionDrawer';
|
||||||
|
import ChangeSubscriptionPlanDrawer from '@/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanDrawer';
|
||||||
|
|
||||||
import { DRAWERS } from '@/constants/drawers';
|
import { DRAWERS } from '@/constants/drawers';
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ export default function DrawersContainer() {
|
|||||||
/>
|
/>
|
||||||
<TaxRateDetailsDrawer name={DRAWERS.TAX_RATE_DETAILS} />
|
<TaxRateDetailsDrawer name={DRAWERS.TAX_RATE_DETAILS} />
|
||||||
<CategorizeTransactionDrawer name={DRAWERS.CATEGORIZE_TRANSACTION} />
|
<CategorizeTransactionDrawer name={DRAWERS.CATEGORIZE_TRANSACTION} />
|
||||||
|
<ChangeSubscriptionPlanDrawer name={DRAWERS.CHANGE_SUBSCARIPTION_PLAN} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ export enum DRAWERS {
|
|||||||
WAREHOUSE_TRANSFER_DETAILS = 'warehouse-transfer-detail-drawer',
|
WAREHOUSE_TRANSFER_DETAILS = 'warehouse-transfer-detail-drawer',
|
||||||
TAX_RATE_DETAILS = 'tax-rate-detail-drawer',
|
TAX_RATE_DETAILS = 'tax-rate-detail-drawer',
|
||||||
CATEGORIZE_TRANSACTION = 'categorize-transaction',
|
CATEGORIZE_TRANSACTION = 'categorize-transaction',
|
||||||
|
CHANGE_SUBSCARIPTION_PLAN = 'change-subscription-plan'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import ProjectAlerts from '@/containers/Projects/containers/ProjectAlerts';
|
|||||||
import TaxRatesAlerts from '@/containers/TaxRates/alerts';
|
import TaxRatesAlerts from '@/containers/TaxRates/alerts';
|
||||||
import { CashflowAlerts } from '../CashFlow/CashflowAlerts';
|
import { CashflowAlerts } from '../CashFlow/CashflowAlerts';
|
||||||
import { BankRulesAlerts } from '../Banking/Rules/RulesList/BankRulesAlerts';
|
import { BankRulesAlerts } from '../Banking/Rules/RulesList/BankRulesAlerts';
|
||||||
|
import { SubscriptionAlerts } from '../Subscriptions/alerts/alerts';
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
...AccountsAlerts,
|
...AccountsAlerts,
|
||||||
@@ -56,5 +57,6 @@ export default [
|
|||||||
...ProjectAlerts,
|
...ProjectAlerts,
|
||||||
...TaxRatesAlerts,
|
...TaxRatesAlerts,
|
||||||
...CashflowAlerts,
|
...CashflowAlerts,
|
||||||
...BankRulesAlerts
|
...BankRulesAlerts,
|
||||||
|
...SubscriptionAlerts
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -14,14 +14,17 @@ import {
|
|||||||
Intent,
|
Intent,
|
||||||
Switch,
|
Switch,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
MenuDivider,
|
||||||
} from '@blueprintjs/core';
|
} from '@blueprintjs/core';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
|
import { isEmpty } from 'lodash';
|
||||||
import {
|
import {
|
||||||
Icon,
|
Icon,
|
||||||
DashboardActionsBar,
|
DashboardActionsBar,
|
||||||
DashboardRowsHeightButton,
|
DashboardRowsHeightButton,
|
||||||
FormattedMessage as T,
|
FormattedMessage as T,
|
||||||
AppToaster,
|
AppToaster,
|
||||||
|
If,
|
||||||
} from '@/components';
|
} from '@/components';
|
||||||
|
|
||||||
import { CashFlowMenuItems } from './utils';
|
import { CashFlowMenuItems } from './utils';
|
||||||
@@ -37,13 +40,14 @@ import withSettings from '@/containers/Settings/withSettings';
|
|||||||
import withSettingsActions from '@/containers/Settings/withSettingsActions';
|
import withSettingsActions from '@/containers/Settings/withSettingsActions';
|
||||||
|
|
||||||
import { compose } from '@/utils';
|
import { compose } from '@/utils';
|
||||||
import { withBanking } from '../withBanking';
|
|
||||||
import { isEmpty } from 'lodash';
|
|
||||||
import {
|
import {
|
||||||
|
useDisconnectBankAccount,
|
||||||
|
useUpdateBankAccount,
|
||||||
useExcludeUncategorizedTransactions,
|
useExcludeUncategorizedTransactions,
|
||||||
useUnexcludeUncategorizedTransactions,
|
useUnexcludeUncategorizedTransactions,
|
||||||
} from '@/hooks/query/bank-rules';
|
} from '@/hooks/query/bank-rules';
|
||||||
import { withBankingActions } from '../withBankingActions';
|
import { withBankingActions } from '../withBankingActions';
|
||||||
|
import { withBanking } from '../withBanking';
|
||||||
|
|
||||||
function AccountTransactionsActionsBar({
|
function AccountTransactionsActionsBar({
|
||||||
// #withDialogActions
|
// #withDialogActions
|
||||||
@@ -63,15 +67,21 @@ function AccountTransactionsActionsBar({
|
|||||||
enableMultipleCategorization,
|
enableMultipleCategorization,
|
||||||
}) {
|
}) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { accountId } = useAccountTransactionsContext();
|
const { accountId, currentAccount } = useAccountTransactionsContext();
|
||||||
|
|
||||||
// Refresh cashflow infinity transactions hook.
|
// Refresh cashflow infinity transactions hook.
|
||||||
const { refresh } = useRefreshCashflowTransactionsInfinity();
|
const { refresh } = useRefreshCashflowTransactionsInfinity();
|
||||||
|
|
||||||
|
const { mutateAsync: disconnectBankAccount } = useDisconnectBankAccount();
|
||||||
|
const { mutateAsync: updateBankAccount } = useUpdateBankAccount();
|
||||||
|
|
||||||
// Retrieves the money in/out buttons options.
|
// Retrieves the money in/out buttons options.
|
||||||
const addMoneyInOptions = useMemo(() => getAddMoneyInOptions(), []);
|
const addMoneyInOptions = useMemo(() => getAddMoneyInOptions(), []);
|
||||||
const addMoneyOutOptions = useMemo(() => getAddMoneyOutOptions(), []);
|
const addMoneyOutOptions = useMemo(() => getAddMoneyOutOptions(), []);
|
||||||
|
|
||||||
|
const isFeedsActive = !!currentAccount.is_feeds_active;
|
||||||
|
const isSyncingOwner = currentAccount.is_syncing_owner;
|
||||||
|
|
||||||
// Handle table row size change.
|
// Handle table row size change.
|
||||||
const handleTableRowSizeChange = (size) => {
|
const handleTableRowSizeChange = (size) => {
|
||||||
addSetting('cashflowTransactions', 'tableSize', size);
|
addSetting('cashflowTransactions', 'tableSize', size);
|
||||||
@@ -100,6 +110,39 @@ function AccountTransactionsActionsBar({
|
|||||||
const handleBankRulesClick = () => {
|
const handleBankRulesClick = () => {
|
||||||
history.push(`/bank-rules?accountId=${accountId}`);
|
history.push(`/bank-rules?accountId=${accountId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handles the bank account disconnect click.
|
||||||
|
const handleDisconnectClick = () => {
|
||||||
|
disconnectBankAccount({ bankAccountId: accountId })
|
||||||
|
.then(() => {
|
||||||
|
AppToaster.show({
|
||||||
|
message: 'The bank account has been disconnected.',
|
||||||
|
intent: Intent.SUCCESS,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
AppToaster.show({
|
||||||
|
message: 'Something went wrong.',
|
||||||
|
intent: Intent.DANGER,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// handles the bank update button click.
|
||||||
|
const handleBankUpdateClick = () => {
|
||||||
|
updateBankAccount({ bankAccountId: accountId })
|
||||||
|
.then(() => {
|
||||||
|
AppToaster.show({
|
||||||
|
message: 'The transactions of the bank account has been updated.',
|
||||||
|
intent: Intent.SUCCESS,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
AppToaster.show({
|
||||||
|
message: 'Something went wrong.',
|
||||||
|
intent: Intent.DANGER,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
// Handle the refresh button click.
|
// Handle the refresh button click.
|
||||||
const handleRefreshBtnClick = () => {
|
const handleRefreshBtnClick = () => {
|
||||||
refresh();
|
refresh();
|
||||||
@@ -200,6 +243,24 @@ function AccountTransactionsActionsBar({
|
|||||||
/>
|
/>
|
||||||
<NavbarDivider />
|
<NavbarDivider />
|
||||||
|
|
||||||
|
<If condition={isSyncingOwner}>
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
isFeedsActive
|
||||||
|
? 'The bank syncing is active'
|
||||||
|
: 'The bank syncing is disconnected'
|
||||||
|
}
|
||||||
|
minimal={true}
|
||||||
|
position={Position.BOTTOM}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
className={Classes.MINIMAL}
|
||||||
|
icon={<Icon icon="feed" iconSize={16} />}
|
||||||
|
intent={isFeedsActive ? Intent.SUCCESS : Intent.DANGER}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</If>
|
||||||
|
|
||||||
{!isEmpty(uncategorizedTransationsIdsSelected) && (
|
{!isEmpty(uncategorizedTransationsIdsSelected) && (
|
||||||
<Button
|
<Button
|
||||||
icon={<Icon icon="disable" iconSize={16} />}
|
icon={<Icon icon="disable" iconSize={16} />}
|
||||||
@@ -246,7 +307,15 @@ function AccountTransactionsActionsBar({
|
|||||||
}}
|
}}
|
||||||
content={
|
content={
|
||||||
<Menu>
|
<Menu>
|
||||||
|
<If condition={isSyncingOwner && isFeedsActive}>
|
||||||
|
<MenuItem onClick={handleBankUpdateClick} text={'Update'} />
|
||||||
|
<MenuDivider />
|
||||||
|
</If>
|
||||||
<MenuItem onClick={handleBankRulesClick} text={'Bank rules'} />
|
<MenuItem onClick={handleBankRulesClick} text={'Bank rules'} />
|
||||||
|
|
||||||
|
<If condition={isSyncingOwner && isFeedsActive}>
|
||||||
|
<MenuItem onClick={handleDisconnectClick} text={'Disconnect'} />
|
||||||
|
</If>
|
||||||
</Menu>
|
</Menu>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,15 +3,14 @@ import React from 'react';
|
|||||||
import intl from 'react-intl-universal';
|
import intl from 'react-intl-universal';
|
||||||
import { Formik } from 'formik';
|
import { Formik } from 'formik';
|
||||||
import { Intent } from '@blueprintjs/core';
|
import { Intent } from '@blueprintjs/core';
|
||||||
import { pick } from 'lodash';
|
import { omit } from 'lodash';
|
||||||
|
|
||||||
import { AppToaster } from '@/components';
|
import { AppToaster } from '@/components';
|
||||||
import { CreateQuickPaymentMadeFormSchema } from './QuickPaymentMade.schema';
|
import { CreateQuickPaymentMadeFormSchema } from './QuickPaymentMade.schema';
|
||||||
import { useQuickPaymentMadeContext } from './QuickPaymentMadeFormProvider';
|
import { useQuickPaymentMadeContext } from './QuickPaymentMadeFormProvider';
|
||||||
import QuickPaymentMadeFormContent from './QuickPaymentMadeFormContent';
|
import QuickPaymentMadeFormContent from './QuickPaymentMadeFormContent';
|
||||||
|
|
||||||
import withDialogActions from '@/containers/Dialog/withDialogActions';
|
import withDialogActions from '@/containers/Dialog/withDialogActions';
|
||||||
import { defaultPaymentMade, transformErrors } from './utils';
|
import { defaultPaymentMade, transformBillToForm, transformErrors } from './utils';
|
||||||
import { compose } from '@/utils';
|
import { compose } from '@/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,31 +20,24 @@ function QuickPaymentMadeForm({
|
|||||||
// #withDialogActions
|
// #withDialogActions
|
||||||
closeDialog,
|
closeDialog,
|
||||||
}) {
|
}) {
|
||||||
|
const { bill, dialogName, createPaymentMadeMutate } =
|
||||||
|
useQuickPaymentMadeContext();
|
||||||
|
|
||||||
const {
|
// Initial form values.
|
||||||
bill,
|
|
||||||
dialogName,
|
|
||||||
createPaymentMadeMutate,
|
|
||||||
} = useQuickPaymentMadeContext();
|
|
||||||
|
|
||||||
// Initial form values
|
|
||||||
const initialValues = {
|
const initialValues = {
|
||||||
...defaultPaymentMade,
|
...defaultPaymentMade,
|
||||||
...bill,
|
...transformBillToForm(bill),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handles the form submit.
|
// Handles the form submit.
|
||||||
const handleFormSubmit = (values, { setSubmitting, setFieldError }) => {
|
const handleFormSubmit = (values, { setSubmitting, setFieldError }) => {
|
||||||
const entries = [values]
|
const entries = [
|
||||||
.filter((entry) => entry.id && entry.payment_amount)
|
{
|
||||||
.map((entry) => ({
|
payment_amount: values.amount,
|
||||||
bill_id: entry.id,
|
bill_id: values.bill_id,
|
||||||
...pick(entry, ['payment_amount']),
|
},
|
||||||
}));
|
];
|
||||||
|
|
||||||
const form = {
|
const form = {
|
||||||
...values,
|
...omit(values, ['bill_id']),
|
||||||
vendor_id: values?.vendor?.id,
|
|
||||||
entries,
|
entries,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ function QuickPaymentMadeFormFields({
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
{/*------------ Amount Received -----------*/}
|
{/*------------ Amount Received -----------*/}
|
||||||
<FastField name={'payment_amount'}>
|
<FastField name={'amount'}>
|
||||||
{({
|
{({
|
||||||
form: { values, setFieldValue },
|
form: { values, setFieldValue },
|
||||||
field: { value },
|
field: { value },
|
||||||
@@ -135,7 +135,7 @@ function QuickPaymentMadeFormFields({
|
|||||||
labelInfo={<FieldRequiredHint />}
|
labelInfo={<FieldRequiredHint />}
|
||||||
className={classNames('form-group--payment_amount', CLASSES.FILL)}
|
className={classNames('form-group--payment_amount', CLASSES.FILL)}
|
||||||
intent={inputIntent({ error, touched })}
|
intent={inputIntent({ error, touched })}
|
||||||
helperText={<ErrorMessage name="payment_amount" />}
|
helperText={<ErrorMessage name="amount" />}
|
||||||
>
|
>
|
||||||
<ControlGroup>
|
<ControlGroup>
|
||||||
<InputPrependText text={values.currency_code} />
|
<InputPrependText text={values.currency_code} />
|
||||||
@@ -144,7 +144,7 @@ function QuickPaymentMadeFormFields({
|
|||||||
value={value}
|
value={value}
|
||||||
minimal={true}
|
minimal={true}
|
||||||
onChange={(amount) => {
|
onChange={(amount) => {
|
||||||
setFieldValue('payment_amount', amount);
|
setFieldValue('amount', amount);
|
||||||
}}
|
}}
|
||||||
intent={inputIntent({ error, touched })}
|
intent={inputIntent({ error, touched })}
|
||||||
inputRef={(ref) => (paymentMadeFieldRef.current = ref)}
|
inputRef={(ref) => (paymentMadeFieldRef.current = ref)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { DialogContent } from '@/components';
|
import { DialogContent } from '@/components';
|
||||||
import {
|
import {
|
||||||
useBill,
|
useBill,
|
||||||
@@ -11,7 +11,6 @@ import { Features } from '@/constants';
|
|||||||
import { useFeatureCan } from '@/hooks/state';
|
import { useFeatureCan } from '@/hooks/state';
|
||||||
import { pick } from 'lodash';
|
import { pick } from 'lodash';
|
||||||
|
|
||||||
|
|
||||||
const QuickPaymentMadeContext = React.createContext();
|
const QuickPaymentMadeContext = React.createContext();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,13 +39,14 @@ function QuickPaymentMadeFormProvider({ query, billId, dialogName, ...props }) {
|
|||||||
isSuccess: isBranchesSuccess,
|
isSuccess: isBranchesSuccess,
|
||||||
} = useBranches(query, { enabled: isBranchFeatureCan });
|
} = useBranches(query, { enabled: isBranchFeatureCan });
|
||||||
|
|
||||||
|
const paymentBill = useMemo(
|
||||||
|
() => pick(bill, ['id', 'due_amount', 'vendor_id', 'currency_code']),
|
||||||
|
[bill],
|
||||||
|
);
|
||||||
|
|
||||||
// State provider.
|
// State provider.
|
||||||
const provider = {
|
const provider = {
|
||||||
bill: {
|
bill: paymentBill,
|
||||||
...pick(bill, ['id', 'due_amount', 'vendor', 'currency_code']),
|
|
||||||
vendor_id: bill?.vendor?.display_name,
|
|
||||||
payment_amount: bill?.due_amount,
|
|
||||||
},
|
|
||||||
accounts,
|
accounts,
|
||||||
branches,
|
branches,
|
||||||
dialogName,
|
dialogName,
|
||||||
|
|||||||
@@ -2,24 +2,25 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import intl from 'react-intl-universal';
|
import intl from 'react-intl-universal';
|
||||||
import { first } from 'lodash';
|
import { first, pick } from 'lodash';
|
||||||
|
import { useFormikContext } from 'formik';
|
||||||
import { Intent } from '@blueprintjs/core';
|
import { Intent } from '@blueprintjs/core';
|
||||||
import { AppToaster } from '@/components';
|
import { AppToaster } from '@/components';
|
||||||
import { useFormikContext } from 'formik';
|
|
||||||
import { useQuickPaymentMadeContext } from './QuickPaymentMadeFormProvider';
|
import { useQuickPaymentMadeContext } from './QuickPaymentMadeFormProvider';
|
||||||
import { PAYMENT_MADE_ERRORS } from '@/containers/Purchases/PaymentMades/constants';
|
import { PAYMENT_MADE_ERRORS } from '@/containers/Purchases/PaymentMades/constants';
|
||||||
|
|
||||||
// Default initial values of payment made.
|
// Default initial values of payment made.
|
||||||
export const defaultPaymentMade = {
|
export const defaultPaymentMade = {
|
||||||
|
bill_id: '',
|
||||||
vendor_id: '',
|
vendor_id: '',
|
||||||
payment_account_id: '',
|
payment_account_id: '',
|
||||||
payment_date: moment(new Date()).format('YYYY-MM-DD'),
|
payment_date: moment(new Date()).format('YYYY-MM-DD'),
|
||||||
reference: '',
|
reference: '',
|
||||||
payment_number: '',
|
payment_number: '',
|
||||||
|
amount: '',
|
||||||
// statement: '',
|
// statement: '',
|
||||||
exchange_rate: 1,
|
exchange_rate: 1,
|
||||||
branch_id: '',
|
branch_id: '',
|
||||||
entries: [{ bill_id: '', payment_amount: '' }],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const transformErrors = (errors, { setFieldError }) => {
|
export const transformErrors = (errors, { setFieldError }) => {
|
||||||
@@ -58,3 +59,11 @@ export const useSetPrimaryBranchToForm = () => {
|
|||||||
}
|
}
|
||||||
}, [isBranchesSuccess, setFieldValue, branches]);
|
}, [isBranchesSuccess, setFieldValue, branches]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const transformBillToForm = (bill) => {
|
||||||
|
return {
|
||||||
|
...pick(bill, ['vendor_id', 'currency_code']),
|
||||||
|
amount: bill.due_amount,
|
||||||
|
bill_id: bill.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from 'react';
|
|||||||
import intl from 'react-intl-universal';
|
import intl from 'react-intl-universal';
|
||||||
import { Formik } from 'formik';
|
import { Formik } from 'formik';
|
||||||
import { Intent } from '@blueprintjs/core';
|
import { Intent } from '@blueprintjs/core';
|
||||||
import { pick, defaultTo, omit } from 'lodash';
|
import { defaultTo, omit } from 'lodash';
|
||||||
|
|
||||||
import { AppToaster } from '@/components';
|
import { AppToaster } from '@/components';
|
||||||
import { useQuickPaymentReceiveContext } from './QuickPaymentReceiveFormProvider';
|
import { useQuickPaymentReceiveContext } from './QuickPaymentReceiveFormProvider';
|
||||||
@@ -12,7 +12,11 @@ import QuickPaymentReceiveFormContent from './QuickPaymentReceiveFormContent';
|
|||||||
|
|
||||||
import withSettings from '@/containers/Settings/withSettings';
|
import withSettings from '@/containers/Settings/withSettings';
|
||||||
import withDialogActions from '@/containers/Dialog/withDialogActions';
|
import withDialogActions from '@/containers/Dialog/withDialogActions';
|
||||||
import { defaultInitialValues, transformErrors } from './utils';
|
import {
|
||||||
|
defaultInitialValues,
|
||||||
|
transformErrors,
|
||||||
|
transformInvoiceToForm,
|
||||||
|
} from './utils';
|
||||||
import { compose, transactionNumber } from '@/utils';
|
import { compose, transactionNumber } from '@/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,14 +30,10 @@ function QuickPaymentReceiveForm({
|
|||||||
paymentReceiveAutoIncrement,
|
paymentReceiveAutoIncrement,
|
||||||
paymentReceiveNumberPrefix,
|
paymentReceiveNumberPrefix,
|
||||||
paymentReceiveNextNumber,
|
paymentReceiveNextNumber,
|
||||||
preferredDepositAccount
|
preferredDepositAccount,
|
||||||
}) {
|
}) {
|
||||||
|
const { dialogName, invoice, createPaymentReceiveMutate } =
|
||||||
const {
|
useQuickPaymentReceiveContext();
|
||||||
dialogName,
|
|
||||||
invoice,
|
|
||||||
createPaymentReceiveMutate,
|
|
||||||
} = useQuickPaymentReceiveContext();
|
|
||||||
|
|
||||||
// Payment receive number.
|
// Payment receive number.
|
||||||
const nextPaymentNumber = transactionNumber(
|
const nextPaymentNumber = transactionNumber(
|
||||||
@@ -48,24 +48,22 @@ function QuickPaymentReceiveForm({
|
|||||||
payment_receive_no: nextPaymentNumber,
|
payment_receive_no: nextPaymentNumber,
|
||||||
}),
|
}),
|
||||||
deposit_account_id: defaultTo(preferredDepositAccount, ''),
|
deposit_account_id: defaultTo(preferredDepositAccount, ''),
|
||||||
...invoice,
|
...transformInvoiceToForm(invoice),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handles the form submit.
|
// Handles the form submit.
|
||||||
const handleFormSubmit = (values, { setSubmitting, setFieldError }) => {
|
const handleFormSubmit = (values, { setSubmitting, setFieldError }) => {
|
||||||
const entries = [values]
|
const entries = [
|
||||||
.filter((entry) => entry.id && entry.payment_amount)
|
{
|
||||||
.map((entry) => ({
|
invoice_id: values.invoice_id,
|
||||||
invoice_id: entry.id,
|
payment_amount: values.amount,
|
||||||
...pick(entry, ['payment_amount']),
|
},
|
||||||
}));
|
];
|
||||||
|
|
||||||
const form = {
|
const form = {
|
||||||
...omit(values, ['payment_receive_no']),
|
...omit(values, ['payment_receive_no', 'invoice_id']),
|
||||||
...(!paymentReceiveAutoIncrement && {
|
...(!paymentReceiveAutoIncrement && {
|
||||||
payment_receive_no: values.payment_receive_no,
|
payment_receive_no: values.payment_receive_no,
|
||||||
}),
|
}),
|
||||||
customer_id: values.customer.id,
|
|
||||||
entries,
|
entries,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ function QuickPaymentReceiveFormFields({
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
{/*------------ Amount Received -----------*/}
|
{/*------------ Amount Received -----------*/}
|
||||||
<FastField name={'payment_amount'}>
|
<FastField name={'amount'}>
|
||||||
{({
|
{({
|
||||||
form: { values, setFieldValue },
|
form: { values, setFieldValue },
|
||||||
field: { value },
|
field: { value },
|
||||||
@@ -139,7 +139,7 @@ function QuickPaymentReceiveFormFields({
|
|||||||
labelInfo={<FieldRequiredHint />}
|
labelInfo={<FieldRequiredHint />}
|
||||||
className={classNames('form-group--payment_amount', CLASSES.FILL)}
|
className={classNames('form-group--payment_amount', CLASSES.FILL)}
|
||||||
intent={inputIntent({ error, touched })}
|
intent={inputIntent({ error, touched })}
|
||||||
helperText={<ErrorMessage name="payment_amount" />}
|
helperText={<ErrorMessage name="amount" />}
|
||||||
>
|
>
|
||||||
<ControlGroup>
|
<ControlGroup>
|
||||||
<InputPrependText text={values.currency_code} />
|
<InputPrependText text={values.currency_code} />
|
||||||
@@ -148,7 +148,7 @@ function QuickPaymentReceiveFormFields({
|
|||||||
value={value}
|
value={value}
|
||||||
minimal={true}
|
minimal={true}
|
||||||
onChange={(amount) => {
|
onChange={(amount) => {
|
||||||
setFieldValue('payment_amount', amount);
|
setFieldValue('amount', amount);
|
||||||
}}
|
}}
|
||||||
intent={inputIntent({ error, touched })}
|
intent={inputIntent({ error, touched })}
|
||||||
inputRef={(ref) => (paymentReceiveFieldRef.current = ref)}
|
inputRef={(ref) => (paymentReceiveFieldRef.current = ref)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import React, { useContext, createContext } from 'react';
|
import React, { useContext, createContext, useMemo } from 'react';
|
||||||
import { pick } from 'lodash';
|
import { pick } from 'lodash';
|
||||||
import { DialogContent } from '@/components';
|
import { DialogContent } from '@/components';
|
||||||
import { Features } from '@/constants';
|
import { Features } from '@/constants';
|
||||||
@@ -47,15 +47,16 @@ function QuickPaymentReceiveFormProvider({
|
|||||||
isSuccess: isBranchesSuccess,
|
isSuccess: isBranchesSuccess,
|
||||||
} = useBranches(query, { enabled: isBranchFeatureCan });
|
} = useBranches(query, { enabled: isBranchFeatureCan });
|
||||||
|
|
||||||
|
const invoicePayment = useMemo(
|
||||||
|
() => pick(invoice, ['id', 'due_amount', 'customer_id', 'currency_code']),
|
||||||
|
[invoice],
|
||||||
|
);
|
||||||
|
|
||||||
// State provider.
|
// State provider.
|
||||||
const provider = {
|
const provider = {
|
||||||
accounts,
|
accounts,
|
||||||
branches,
|
branches,
|
||||||
invoice: {
|
invoice: invoicePayment,
|
||||||
...pick(invoice, ['id', 'due_amount', 'customer', 'currency_code']),
|
|
||||||
customer_id: invoice?.customer?.display_name,
|
|
||||||
payment_amount: invoice.due_amount,
|
|
||||||
},
|
|
||||||
isAccountsLoading,
|
isAccountsLoading,
|
||||||
isSettingsLoading,
|
isSettingsLoading,
|
||||||
isBranchesSuccess,
|
isBranchesSuccess,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import intl from 'react-intl-universal';
|
import intl from 'react-intl-universal';
|
||||||
import { first } from 'lodash';
|
import { first, pick } from 'lodash';
|
||||||
import { Intent } from '@blueprintjs/core';
|
import { Intent } from '@blueprintjs/core';
|
||||||
import { AppToaster } from '@/components';
|
import { AppToaster } from '@/components';
|
||||||
|
|
||||||
@@ -10,15 +10,16 @@ import { useFormikContext } from 'formik';
|
|||||||
import { useQuickPaymentReceiveContext } from './QuickPaymentReceiveFormProvider';
|
import { useQuickPaymentReceiveContext } from './QuickPaymentReceiveFormProvider';
|
||||||
|
|
||||||
export const defaultInitialValues = {
|
export const defaultInitialValues = {
|
||||||
|
invoice_id: '',
|
||||||
customer_id: '',
|
customer_id: '',
|
||||||
deposit_account_id: '',
|
deposit_account_id: '',
|
||||||
payment_receive_no: '',
|
payment_receive_no: '',
|
||||||
payment_date: moment(new Date()).format('YYYY-MM-DD'),
|
payment_date: moment(new Date()).format('YYYY-MM-DD'),
|
||||||
reference_no: '',
|
reference_no: '',
|
||||||
|
amount: '',
|
||||||
// statement: '',
|
// statement: '',
|
||||||
exchange_rate: 1,
|
exchange_rate: 1,
|
||||||
branch_id: '',
|
branch_id: '',
|
||||||
entries: [{ invoice_id: '', payment_amount: '' }],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const transformErrors = (errors, { setFieldError }) => {
|
export const transformErrors = (errors, { setFieldError }) => {
|
||||||
@@ -44,7 +45,9 @@ export const transformErrors = (errors, { setFieldError }) => {
|
|||||||
}
|
}
|
||||||
if (getError('PAYMENT_ACCOUNT_CURRENCY_INVALID')) {
|
if (getError('PAYMENT_ACCOUNT_CURRENCY_INVALID')) {
|
||||||
AppToaster.show({
|
AppToaster.show({
|
||||||
message: intl.get('payment_Receive.error.payment_account_currency_invalid'),
|
message: intl.get(
|
||||||
|
'payment_Receive.error.payment_account_currency_invalid',
|
||||||
|
),
|
||||||
intent: Intent.DANGER,
|
intent: Intent.DANGER,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -64,3 +67,11 @@ export const useSetPrimaryBranchToForm = () => {
|
|||||||
}
|
}
|
||||||
}, [isBranchesSuccess, setFieldValue, branches]);
|
}, [isBranchesSuccess, setFieldValue, branches]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const transformInvoiceToForm = (invoice) => {
|
||||||
|
return {
|
||||||
|
...pick(invoice, ['customer_id', 'currency_code']),
|
||||||
|
amount: invoice.due_amount,
|
||||||
|
invoice_id: invoice.id,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,28 +1,66 @@
|
|||||||
import { Group } from '@/components';
|
// @ts-nocheck
|
||||||
import { SubscriptionPlan } from './SubscriptionPlan';
|
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 { 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();
|
const subscriptionPlans = useSubscriptionPlans();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group spacing={14} noWrap align="stretch">
|
<Group spacing={14} noWrap align="stretch" {...wrapProps}>
|
||||||
{subscriptionPlans.map((plan, index) => (
|
{subscriptionPlans.map((plan, index) => (
|
||||||
<SubscriptionPlan
|
<SubscriptionPlanMapped key={index} plan={plan} />
|
||||||
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}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</Group>
|
</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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
|
||||||
28
packages/webapp/src/containers/Subscriptions/BillingPage.tsx
Normal file
28
packages/webapp/src/containers/Subscriptions/BillingPage.tsx
Normal 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);
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 32px 40px;
|
||||||
|
min-width: 800px;
|
||||||
|
max-width: 900px;
|
||||||
|
width: 75%;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import React from 'react';
|
|
||||||
import BillingPlansForm from './BillingPlansForm';
|
|
||||||
|
|
||||||
export default function BillingTab() {
|
|
||||||
return (<BillingPlansForm />);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
17
packages/webapp/src/containers/Subscriptions/_utils.ts
Normal file
17
packages/webapp/src/containers/Subscriptions/_utils.ts
Normal 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 '';
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { Intent } from '@blueprintjs/core';
|
|
||||||
import * as R from 'ramda';
|
import * as R from 'ramda';
|
||||||
import { AppToaster } from '@/components';
|
|
||||||
import { useGetLemonSqueezyCheckout } from '@/hooks/query';
|
|
||||||
import { PricingPlan } from '@/components/PricingPlan/PricingPlan';
|
import { PricingPlan } from '@/components/PricingPlan/PricingPlan';
|
||||||
import { SubscriptionPlansPeriod } from '@/store/plans/plans.reducer';
|
import { SubscriptionPlansPeriod } from '@/store/plans/plans.reducer';
|
||||||
import {
|
import {
|
||||||
WithPlansProps,
|
WithPlansProps,
|
||||||
withPlans,
|
withPlans,
|
||||||
} from '@/containers/Subscriptions/withPlans';
|
} from '@/containers/Subscriptions/withPlans';
|
||||||
|
import { ButtonProps } from '@blueprintjs/core';
|
||||||
|
|
||||||
interface SubscriptionPricingFeature {
|
interface SubscriptionPricingFeature {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -27,8 +25,8 @@ interface SubscriptionPricingProps {
|
|||||||
monthlyPriceLabel: string;
|
monthlyPriceLabel: string;
|
||||||
annuallyPrice: string;
|
annuallyPrice: string;
|
||||||
annuallyPriceLabel: string;
|
annuallyPriceLabel: string;
|
||||||
monthlyVariantId?: string;
|
onSubscribe?: (variantId: number) => void;
|
||||||
annuallyVariantId?: string;
|
subscribeButtonProps?: Optional<ButtonProps>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SubscriptionPricingCombinedProps
|
interface SubscriptionPricingCombinedProps
|
||||||
@@ -44,32 +42,14 @@ function SubscriptionPlanRoot({
|
|||||||
monthlyPriceLabel,
|
monthlyPriceLabel,
|
||||||
annuallyPrice,
|
annuallyPrice,
|
||||||
annuallyPriceLabel,
|
annuallyPriceLabel,
|
||||||
monthlyVariantId,
|
onSubscribe,
|
||||||
annuallyVariantId,
|
subscribeButtonProps,
|
||||||
|
|
||||||
// #withPlans
|
// #withPlans
|
||||||
plansPeriod,
|
plansPeriod,
|
||||||
}: SubscriptionPricingCombinedProps) {
|
}: SubscriptionPricingCombinedProps) {
|
||||||
const { mutateAsync: getLemonCheckout, isLoading } =
|
|
||||||
useGetLemonSqueezyCheckout();
|
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
const variantId =
|
onSubscribe && onSubscribe();
|
||||||
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 (
|
return (
|
||||||
@@ -85,7 +65,7 @@ function SubscriptionPlanRoot({
|
|||||||
subPrice={annuallyPriceLabel}
|
subPrice={annuallyPriceLabel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<PricingPlan.BuyButton loading={isLoading} onClick={handleClick}>
|
<PricingPlan.BuyButton onClick={handleClick} {...subscribeButtonProps}>
|
||||||
Subscribe
|
Subscribe
|
||||||
</PricingPlan.BuyButton>
|
</PricingPlan.BuyButton>
|
||||||
|
|
||||||
@@ -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)} />;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * as default from './ChangeSubscriptionPlanDrawer';
|
||||||
@@ -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(),
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { SubscriptionPlans } from '@/constants/subscriptionModels';
|
||||||
|
|
||||||
|
export const useSubscriptionPlans = () => {
|
||||||
|
return SubscriptionPlans;
|
||||||
|
};
|
||||||
@@ -61,6 +61,76 @@ export function useCreateBankRule(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DisconnectBankAccountRes {}
|
||||||
|
interface DisconnectBankAccountValues {
|
||||||
|
bankAccountId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnects the given bank account.
|
||||||
|
* @param {UseMutationOptions<DisconnectBankAccountRes, Error, DisconnectBankAccountValues>} options
|
||||||
|
* @returns {UseMutationResult<DisconnectBankAccountRes, Error, DisconnectBankAccountValues>}
|
||||||
|
*/
|
||||||
|
export function useDisconnectBankAccount(
|
||||||
|
options?: UseMutationOptions<
|
||||||
|
DisconnectBankAccountRes,
|
||||||
|
Error,
|
||||||
|
DisconnectBankAccountValues
|
||||||
|
>,
|
||||||
|
): UseMutationResult<
|
||||||
|
DisconnectBankAccountRes,
|
||||||
|
Error,
|
||||||
|
DisconnectBankAccountValues
|
||||||
|
> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const apiRequest = useApiRequest();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
DisconnectBankAccountRes,
|
||||||
|
Error,
|
||||||
|
DisconnectBankAccountValues
|
||||||
|
>(
|
||||||
|
({ bankAccountId }) =>
|
||||||
|
apiRequest.post(`/banking/bank_accounts/${bankAccountId}/disconnect`),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
onSuccess: (res, values) => {
|
||||||
|
queryClient.invalidateQueries([t.ACCOUNT, values.bankAccountId]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateBankAccountRes {}
|
||||||
|
interface UpdateBankAccountValues {
|
||||||
|
bankAccountId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the bank transactions of the bank account.
|
||||||
|
* @param {UseMutationOptions<UpdateBankAccountRes, Error, UpdateBankAccountValues>}
|
||||||
|
* @returns {UseMutationResult<UpdateBankAccountRes, Error, UpdateBankAccountValues>}
|
||||||
|
*/
|
||||||
|
export function useUpdateBankAccount(
|
||||||
|
options?: UseMutationOptions<
|
||||||
|
UpdateBankAccountRes,
|
||||||
|
Error,
|
||||||
|
UpdateBankAccountValues
|
||||||
|
>,
|
||||||
|
): UseMutationResult<UpdateBankAccountRes, Error, UpdateBankAccountValues> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const apiRequest = useApiRequest();
|
||||||
|
|
||||||
|
return useMutation<DisconnectBankAccountRes, Error, UpdateBankAccountValues>(
|
||||||
|
({ bankAccountId }) =>
|
||||||
|
apiRequest.post(`/banking/bank_accounts/${bankAccountId}/update`),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
onSuccess: () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface EditBankRuleValues {
|
interface EditBankRuleValues {
|
||||||
id: number;
|
id: number;
|
||||||
value: any;
|
value: any;
|
||||||
|
|||||||
196
packages/webapp/src/hooks/query/subscription.tsx
Normal file
196
packages/webapp/src/hooks/query/subscription.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1231,6 +1231,13 @@ export const getDashboardRoutes = () => [
|
|||||||
breadcrumb: 'Bank Rules',
|
breadcrumb: 'Bank Rules',
|
||||||
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
|
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/billing',
|
||||||
|
component: lazy(() => import('@/containers/Subscriptions/BillingPage')),
|
||||||
|
pageTitle: 'Billing',
|
||||||
|
breadcrumb: 'Billing',
|
||||||
|
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
|
||||||
|
},
|
||||||
// Homepage
|
// Homepage
|
||||||
{
|
{
|
||||||
path: `/`,
|
path: `/`,
|
||||||
|
|||||||
@@ -635,4 +635,11 @@ export default {
|
|||||||
],
|
],
|
||||||
viewBox: '0 0 16 16',
|
viewBox: '0 0 16 16',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
feed: {
|
||||||
|
path: [
|
||||||
|
'M1.99,11.99c-1.1,0-2,0.9-2,2s0.9,2,2,2s2-0.9,2-2S3.1,11.99,1.99,11.99zM2.99,7.99c-0.55,0-1,0.45-1,1s0.45,1,1,1c1.66,0,3,1.34,3,3c0,0.55,0.45,1,1,1s1-0.45,1-1C7.99,10.23,5.75,7.99,2.99,7.99zM2.99,3.99c-0.55,0-1,0.45-1,1s0.45,1,1,1c3.87,0,7,3.13,7,7c0,0.55,0.45,1,1,1s1-0.45,1-1C11.99,8.02,7.96,3.99,2.99,3.99zM2.99-0.01c-0.55,0-1,0.45-1,1s0.45,1,1,1c6.08,0,11,4.92,11,11c0,0.55,0.45,1,1,1s1-0.45,1-1C15.99,5.81,10.17-0.01,2.99-0.01z',
|
||||||
|
],
|
||||||
|
viewBox: '0 0 16 16',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user