diff --git a/packages/server/src/api/controllers/Subscription/SubscriptionController.ts b/packages/server/src/api/controllers/Subscription/SubscriptionController.ts
index 7f394a666..d84e4c2c2 100644
--- a/packages/server/src/api/controllers/Subscription/SubscriptionController.ts
+++ b/packages/server/src/api/controllers/Subscription/SubscriptionController.ts
@@ -8,6 +8,7 @@ import SubscriptionService from '@/services/Subscription/SubscriptionService';
import asyncMiddleware from '@/api/middleware/asyncMiddleware';
import BaseController from '../BaseController';
import { LemonSqueezyService } from '@/services/Subscription/LemonSqueezyService';
+import { SubscriptionApplication } from '@/services/Subscription/SubscriptionApplication';
@Service()
export class SubscriptionController extends BaseController {
@@ -17,6 +18,9 @@ export class SubscriptionController extends BaseController {
@Inject()
private lemonSqueezyService: LemonSqueezyService;
+ @Inject()
+ private subscriptionApp: SubscriptionApplication;
+
/**
* Router constructor.
*/
@@ -33,6 +37,14 @@ export class SubscriptionController extends BaseController {
this.validationResult,
this.getCheckoutUrl.bind(this)
);
+ router.post('/cancel', asyncMiddleware(this.cancelSubscription.bind(this)));
+ router.post('/resume', asyncMiddleware(this.resumeSubscription.bind(this)));
+ router.post(
+ '/change',
+ [body('variant_id').exists().trim()],
+ this.validationResult,
+ asyncMiddleware(this.changeSubscriptionPlan.bind(this))
+ );
router.get('/', asyncMiddleware(this.getSubscriptions.bind(this)));
return router;
@@ -85,4 +97,84 @@ export class SubscriptionController extends BaseController {
next(error);
}
}
+
+ /**
+ * Cancels the subscription of the current organization.
+ * @param {Request} req
+ * @param {Response} res
+ * @param {NextFunction} next
+ * @returns {Promise}
+ */
+ 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}
+ */
+ 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}
+ */
+ 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);
+ }
+ }
}
diff --git a/packages/server/src/services/Dashboard/DashboardService.ts b/packages/server/src/services/Dashboard/DashboardService.ts
index 81c24a026..e8411b1bf 100644
--- a/packages/server/src/services/Dashboard/DashboardService.ts
+++ b/packages/server/src/services/Dashboard/DashboardService.ts
@@ -1,7 +1,8 @@
+import { Inject, Service } from 'typedi';
import { IFeatureAllItem, ISystemUser } from '@/interfaces';
import { FeaturesManager } from '@/services/Features/FeaturesManager';
import HasTenancyService from '@/services/Tenancy/TenancyService';
-import { Inject, Service } from 'typedi';
+import config from '@/config';
interface IRoleAbility {
subject: string;
@@ -11,15 +12,16 @@ interface IRoleAbility {
interface IDashboardBootMeta {
abilities: IRoleAbility[];
features: IFeatureAllItem[];
+ isBigcapitalCloud: boolean;
}
@Service()
export default class DashboardService {
@Inject()
- tenancy: HasTenancyService;
+ private tenancy: HasTenancyService;
@Inject()
- featuresManager: FeaturesManager;
+ private featuresManager: FeaturesManager;
/**
* Retrieve dashboard meta.
@@ -39,6 +41,7 @@ export default class DashboardService {
return {
abilities,
features,
+ isBigcapitalCloud: config.hostedOnBigcapitalCloud
};
};
diff --git a/packages/server/src/services/Subscription/GetSubscriptionsTransformer.ts b/packages/server/src/services/Subscription/GetSubscriptionsTransformer.ts
new file mode 100644
index 000000000..3257c0034
--- /dev/null
+++ b/packages/server/src/services/Subscription/GetSubscriptionsTransformer.ts
@@ -0,0 +1,170 @@
+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);
+ console.log(lemonSusbcription);
+
+ return lemonSusbcription?.data?.attributes?.urls;
+ };
+}
diff --git a/packages/server/src/services/Subscription/LemonCancelSubscription.ts b/packages/server/src/services/Subscription/LemonCancelSubscription.ts
new file mode 100644
index 000000000..ef8441198
--- /dev/null
+++ b/packages/server/src/services/Subscription/LemonCancelSubscription.ts
@@ -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}
+ */
+ 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
+ );
+ }
+}
diff --git a/packages/server/src/services/Subscription/LemonChangeSubscriptionPlan.ts b/packages/server/src/services/Subscription/LemonChangeSubscriptionPlan.ts
new file mode 100644
index 000000000..9be404601
--- /dev/null
+++ b/packages/server/src/services/Subscription/LemonChangeSubscriptionPlan.ts
@@ -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}
+ */
+ 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
+ );
+ }
+}
diff --git a/packages/server/src/services/Subscription/LemonResumeSubscription.ts b/packages/server/src/services/Subscription/LemonResumeSubscription.ts
new file mode 100644
index 000000000..cd0ee0d2e
--- /dev/null
+++ b/packages/server/src/services/Subscription/LemonResumeSubscription.ts
@@ -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}
+ */
+ 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
+ );
+ }
+}
diff --git a/packages/server/src/services/Subscription/SubscriptionApplication.ts b/packages/server/src/services/Subscription/SubscriptionApplication.ts
new file mode 100644
index 000000000..c7f97569a
--- /dev/null
+++ b/packages/server/src/services/Subscription/SubscriptionApplication.ts
@@ -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}
+ */
+ public cancelSubscription(tenantId: number, id: string) {
+ return this.cancelSubscriptionService.cancelSubscription(tenantId, id);
+ }
+
+ /**
+ * Resumes the subscription of the given tenant.
+ * @param {number} tenantId
+ * @returns {Promise}
+ */
+ public resumeSubscription(tenantId: number) {
+ return this.resumeSubscriptionService.resumeSubscription(tenantId);
+ }
+
+ /**
+ * Changes the given organization subscription plan.
+ * @param {number} tenantId
+ * @param {number} newVariantId
+ * @returns {Promise}
+ */
+ public changeSubscriptionPlan(tenantId: number, newVariantId: number) {
+ return this.changeSubscriptionPlanService.changeSubscriptionPlan(
+ tenantId,
+ newVariantId
+ );
+ }
+}
diff --git a/packages/server/src/services/Subscription/SubscriptionService.ts b/packages/server/src/services/Subscription/SubscriptionService.ts
index 8e70c55d8..a61714af3 100644
--- a/packages/server/src/services/Subscription/SubscriptionService.ts
+++ b/packages/server/src/services/Subscription/SubscriptionService.ts
@@ -1,17 +1,50 @@
-import { Service } from 'typedi';
+import { Inject, Service } from 'typedi';
+import { getSubscription } from '@lemonsqueezy/lemonsqueezy.js';
+import { PromisePool } from '@supercharge/promise-pool';
import { PlanSubscription } from '@/system/models';
+import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
+import { GetSubscriptionsTransformer } from './GetSubscriptionsTransformer';
+import { configureLemonSqueezy } from './utils';
+import { fromPairs } from 'lodash';
@Service()
export default class SubscriptionService {
+ @Inject()
+ private transformer: TransformerInjectable;
+
/**
* Retrieve all subscription of the given tenant.
* @param {number} tenantId
*/
public async getSubscriptions(tenantId: number) {
- const subscriptions = await PlanSubscription.query().where(
- 'tenant_id',
- tenantId
+ configureLemonSqueezy();
+
+ const subscriptions = await PlanSubscription.query()
+ .where('tenant_id', tenantId)
+ .withGraphFetched('plan');
+
+ const lemonSubscriptionsResult = await PromisePool.withConcurrency(1)
+ .for(subscriptions)
+ .process(async (subscription, index, pool) => {
+ if (subscription.lemonSubscriptionId) {
+ const res = await getSubscription(subscription.lemonSubscriptionId);
+
+ if (res.error) {
+ return;
+ }
+ return [subscription.lemonSubscriptionId, res.data];
+ }
+ });
+ const lemonSubscriptions = fromPairs(
+ lemonSubscriptionsResult?.results.filter((result) => !!result[1])
+ );
+ return this.transformer.transform(
+ tenantId,
+ subscriptions,
+ new GetSubscriptionsTransformer(),
+ {
+ lemonSubscriptions,
+ }
);
- return subscriptions;
}
}
diff --git a/packages/server/src/services/Subscription/types.ts b/packages/server/src/services/Subscription/types.ts
new file mode 100644
index 000000000..c506b634f
--- /dev/null
+++ b/packages/server/src/services/Subscription/types.ts
@@ -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;
+}
diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts
index ae638341f..c72a2e7d3 100644
--- a/packages/server/src/subscribers/events.ts
+++ b/packages/server/src/subscribers/events.ts
@@ -41,9 +41,12 @@ export default {
},
/**
- * User subscription events.
+ * Organization subscription.
*/
subscription: {
+ onSubscriptionCanceled: 'onSubscriptionCanceled',
+ onSubscriptionResumed: 'onSubscriptionResumed',
+ onSubscriptionPlanChanged: 'onSubscriptionPlanChanged',
onSubscribed: 'onOrganizationSubscribed',
},
diff --git a/packages/server/src/system/migrations/20240727094214_add_lemon_subscription_id_to_subscriptions_table.js b/packages/server/src/system/migrations/20240727094214_add_lemon_subscription_id_to_subscriptions_table.js
new file mode 100644
index 000000000..29907345a
--- /dev/null
+++ b/packages/server/src/system/migrations/20240727094214_add_lemon_subscription_id_to_subscriptions_table.js
@@ -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');
+ });
+};
diff --git a/packages/server/src/system/migrations/20240728123419_add_trial_columns_to_subscription_table.js b/packages/server/src/system/migrations/20240728123419_add_trial_columns_to_subscription_table.js
new file mode 100644
index 000000000..b8addd516
--- /dev/null
+++ b/packages/server/src/system/migrations/20240728123419_add_trial_columns_to_subscription_table.js
@@ -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();
+ });
+};
diff --git a/packages/server/src/system/models/Subscriptions/PlanSubscription.ts b/packages/server/src/system/models/Subscriptions/PlanSubscription.ts
index d77ee6418..d7c988d8f 100644
--- a/packages/server/src/system/models/Subscriptions/PlanSubscription.ts
+++ b/packages/server/src/system/models/Subscriptions/PlanSubscription.ts
@@ -4,6 +4,15 @@ import moment from 'moment';
import SubscriptionPeriod from '@/services/Subscription/SubscriptionPeriod';
export default class PlanSubscription extends mixin(SystemModel) {
+ public lemonSubscriptionId: number;
+
+ public endsAt: Date;
+ public startsAt: Date;
+
+ public canceledAt: Date;
+
+ public trialEndsAt: Date;
+
/**
* Table name.
*/
@@ -22,7 +31,7 @@ export default class PlanSubscription extends mixin(SystemModel) {
* Defined virtual attributes.
*/
static get virtualAttributes() {
- return ['active', 'inactive', 'ended', 'onTrial'];
+ return ['active', 'inactive', 'ended', 'canceled', 'onTrial', 'status'];
}
/**
@@ -38,7 +47,7 @@ export default class PlanSubscription extends mixin(SystemModel) {
builder.where('trial_ends_at', '>', now);
},
- inactiveSubscriptions() {
+ inactiveSubscriptions(builder) {
builder.modify('endedTrial');
builder.modify('endedPeriod');
},
@@ -98,35 +107,65 @@ export default class PlanSubscription extends mixin(SystemModel) {
}
/**
- * Check if subscription is active.
+ * Check if the subscription is active.
* @return {Boolean}
*/
- active() {
- return !this.ended() || this.onTrial();
+ public active() {
+ return this.onTrial() || !this.ended();
}
/**
- * Check if subscription is inactive.
+ * Check if the subscription is inactive.
* @return {Boolean}
*/
- inactive() {
+ public inactive() {
return !this.active();
}
/**
- * Check if subscription period has ended.
+ * Check if paid subscription period has ended.
* @return {Boolean}
*/
- ended() {
+ public ended() {
return this.endsAt ? moment().isAfter(this.endsAt) : false;
}
+ /**
+ * Check if the paid subscription has started.
+ * @returns {Boolean}
+ */
+ public started() {
+ return this.startsAt ? moment().isAfter(this.startsAt) : false;
+ }
+
/**
* Check if subscription is currently on trial.
* @return {Boolean}
*/
- onTrial() {
- return this.trailEndsAt ? moment().isAfter(this.trailEndsAt) : false;
+ public onTrial() {
+ return this.trialEndsAt ? moment().isBefore(this.trialEndsAt) : false;
+ }
+
+ /**
+ * Check if the subscription is canceled.
+ * @returns {boolean}
+ */
+ public canceled() {
+ return !!this.canceledAt;
+ }
+
+ /**
+ * Retrieves the subscription status.
+ * @returns {string}
+ */
+ public status() {
+ return this.canceled()
+ ? 'canceled'
+ : this.onTrial()
+ ? 'on_trial'
+ : this.active()
+ ? 'active'
+ : 'inactive';
}
/**
@@ -141,7 +180,7 @@ export default class PlanSubscription extends mixin(SystemModel) {
const period = new SubscriptionPeriod(
invoiceInterval,
invoicePeriod,
- start,
+ start
);
const startsAt = period.getStartDate();
@@ -157,7 +196,7 @@ export default class PlanSubscription extends mixin(SystemModel) {
renew(invoiceInterval, invoicePeriod) {
const { startsAt, endsAt } = PlanSubscription.setNewPeriod(
invoiceInterval,
- invoicePeriod,
+ invoicePeriod
);
return this.$query().update({ startsAt, endsAt });
}
diff --git a/packages/webapp/src/components/Dashboard/TopbarUser.tsx b/packages/webapp/src/components/Dashboard/TopbarUser.tsx
index 889bd3426..3cf8bb03c 100644
--- a/packages/webapp/src/components/Dashboard/TopbarUser.tsx
+++ b/packages/webapp/src/components/Dashboard/TopbarUser.tsx
@@ -15,7 +15,7 @@ import { useAuthActions } from '@/hooks/state';
import withDialogActions from '@/containers/Dialog/withDialogActions';
-import { useAuthenticatedAccount } from '@/hooks/query';
+import { useAuthenticatedAccount, useDashboardMeta } from '@/hooks/query';
import { firstLettersArgs, compose } from '@/utils';
/**
@@ -31,6 +31,9 @@ function DashboardTopbarUser({
// Retrieve authenticated user information.
const { data: user } = useAuthenticatedAccount();
+ const { data: dashboardMeta } = useDashboardMeta({
+ keepPreviousData: true,
+ });
const onClickLogout = () => {
setLogout();
};
@@ -58,6 +61,12 @@ function DashboardTopbarUser({
}
/>
+ {dashboardMeta.is_bigcapital_cloud && (
+
+
+ );
+}
+
+export default R.compose(
+ withAlertStoreConnect(),
+ withAlertActions,
+)(ResumeMainSubscriptionAlert);
diff --git a/packages/webapp/src/containers/Subscriptions/alerts/alerts.ts b/packages/webapp/src/containers/Subscriptions/alerts/alerts.ts
new file mode 100644
index 000000000..94939a56a
--- /dev/null
+++ b/packages/webapp/src/containers/Subscriptions/alerts/alerts.ts
@@ -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,
+ },
+];
diff --git a/packages/webapp/src/containers/Setup/SetupSubscription/SubscriptionPlan.tsx b/packages/webapp/src/containers/Subscriptions/component/SubscriptionPlan.tsx
similarity index 69%
rename from packages/webapp/src/containers/Setup/SetupSubscription/SubscriptionPlan.tsx
rename to packages/webapp/src/containers/Subscriptions/component/SubscriptionPlan.tsx
index 4ebb88d5f..e23780e14 100644
--- a/packages/webapp/src/containers/Setup/SetupSubscription/SubscriptionPlan.tsx
+++ b/packages/webapp/src/containers/Subscriptions/component/SubscriptionPlan.tsx
@@ -1,14 +1,12 @@
// @ts-nocheck
-import { Intent } from '@blueprintjs/core';
import * as R from 'ramda';
-import { AppToaster } from '@/components';
-import { useGetLemonSqueezyCheckout } from '@/hooks/query';
import { PricingPlan } from '@/components/PricingPlan/PricingPlan';
import { SubscriptionPlansPeriod } from '@/store/plans/plans.reducer';
import {
WithPlansProps,
withPlans,
} from '@/containers/Subscriptions/withPlans';
+import { ButtonProps } from '@blueprintjs/core';
interface SubscriptionPricingFeature {
text: string;
@@ -27,8 +25,8 @@ interface SubscriptionPricingProps {
monthlyPriceLabel: string;
annuallyPrice: string;
annuallyPriceLabel: string;
- monthlyVariantId?: string;
- annuallyVariantId?: string;
+ onSubscribe?: (variantId: number) => void;
+ subscribeButtonProps?: Optional;
}
interface SubscriptionPricingCombinedProps
@@ -44,32 +42,14 @@ function SubscriptionPlanRoot({
monthlyPriceLabel,
annuallyPrice,
annuallyPriceLabel,
- monthlyVariantId,
- annuallyVariantId,
+ onSubscribe,
+ subscribeButtonProps,
// #withPlans
plansPeriod,
}: SubscriptionPricingCombinedProps) {
- const { mutateAsync: getLemonCheckout, isLoading } =
- useGetLemonSqueezyCheckout();
-
const handleClick = () => {
- const variantId =
- SubscriptionPlansPeriod.Monthly === plansPeriod
- ? monthlyVariantId
- : annuallyVariantId;
-
- getLemonCheckout({ variantId })
- .then((res) => {
- const checkoutUrl = res.data.data.attributes.url;
- window.LemonSqueezy.Url.Open(checkoutUrl);
- })
- .catch(() => {
- AppToaster.show({
- message: 'Something went wrong!',
- intent: Intent.DANGER,
- });
- });
+ onSubscribe && onSubscribe();
};
return (
@@ -85,7 +65,7 @@ function SubscriptionPlanRoot({
subPrice={annuallyPriceLabel}
/>
)}
-
+
Subscribe
diff --git a/packages/webapp/src/containers/Subscriptions/component/withSubscriptionPlanMapper.tsx b/packages/webapp/src/containers/Subscriptions/component/withSubscriptionPlanMapper.tsx
new file mode 100644
index 000000000..b0cd58938
--- /dev/null
+++ b/packages/webapp/src/containers/Subscriptions/component/withSubscriptionPlanMapper.tsx
@@ -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,
+) => {
+ return function WithSubscriptionPlanMapper(
+ props: WithSubscriptionPlanProps &
+ Omit
,
+ ) {
+ 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 ;
+ };
+};
diff --git a/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanContent.tsx b/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanContent.tsx
new file mode 100644
index 000000000..85d0361e8
--- /dev/null
+++ b/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanContent.tsx
@@ -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 (
+
+
+
+ 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.
+
+
+
+
+
+
+ );
+}
diff --git a/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanDrawer.tsx b/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanDrawer.tsx
new file mode 100644
index 000000000..a8aaf60ad
--- /dev/null
+++ b/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanDrawer.tsx
@@ -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 (
+
+
+
+
+
+
+ );
+}
+
+export default R.compose(withDrawers())(ChangeSubscriptionPlanDrawer);
diff --git a/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlans.tsx b/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlans.tsx
new file mode 100644
index 000000000..1edc6d7cf
--- /dev/null
+++ b/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlans.tsx
@@ -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 (
+
+ {subscriptionPlans.map((plan, index) => (
+
+ ))}
+
+ );
+}
+
+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 (
+
+ );
+ },
+);
diff --git a/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/index.ts b/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/index.ts
new file mode 100644
index 000000000..4af1d02b2
--- /dev/null
+++ b/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/index.ts
@@ -0,0 +1 @@
+export * as default from './ChangeSubscriptionPlanDrawer';
\ No newline at end of file
diff --git a/packages/webapp/src/containers/Subscriptions/utils.tsx b/packages/webapp/src/containers/Subscriptions/utils.tsx
deleted file mode 100644
index 041234fc9..000000000
--- a/packages/webapp/src/containers/Subscriptions/utils.tsx
+++ /dev/null
@@ -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(),
- });
diff --git a/packages/webapp/src/hooks/constants/useSubscriptionPlans.tsx b/packages/webapp/src/hooks/constants/useSubscriptionPlans.tsx
new file mode 100644
index 000000000..3beb2a34b
--- /dev/null
+++ b/packages/webapp/src/hooks/constants/useSubscriptionPlans.tsx
@@ -0,0 +1,5 @@
+import { SubscriptionPlans } from '@/constants/subscriptionModels';
+
+export const useSubscriptionPlans = () => {
+ return SubscriptionPlans;
+};
diff --git a/packages/webapp/src/hooks/query/subscription.tsx b/packages/webapp/src/hooks/query/subscription.tsx
new file mode 100644
index 000000000..d3d0ebc4e
--- /dev/null
+++ b/packages/webapp/src/hooks/query/subscription.tsx
@@ -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} options -
+ * @returns {UseMutationResult}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} options -
+ * @returns {UseMutationResult}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} options -
+ * @returns {UseMutationResult}
+ */
+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;
+}
+
+/**
+ * Changese the main subscription of the current organization.
+ * @param {UseMutationOptions} options -
+ * @returns {UseMutationResult}
+ */
+export function useGetSubscriptions(
+ options?: UseQueryOptions<
+ GetSubscriptionsQuery,
+ Error,
+ GetSubscriptionsResponse
+ >,
+): UseQueryResult {
+ const apiRequest = useApiRequest();
+
+ return useQuery(
+ [QueryKeys.Subscriptions],
+ (values) =>
+ apiRequest
+ .get(`/subscription`)
+ .then((res) => transformToCamelCase(res.data)),
+ {
+ ...options,
+ },
+ );
+}
diff --git a/packages/webapp/src/routes/dashboard.tsx b/packages/webapp/src/routes/dashboard.tsx
index b1b4cb1d4..aaedb5853 100644
--- a/packages/webapp/src/routes/dashboard.tsx
+++ b/packages/webapp/src/routes/dashboard.tsx
@@ -1231,6 +1231,13 @@ export const getDashboardRoutes = () => [
breadcrumb: 'Bank Rules',
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
},
+ {
+ path: '/billing',
+ component: lazy(() => import('@/containers/Subscriptions/BillingPage')),
+ pageTitle: 'Billing',
+ breadcrumb: 'Billing',
+ subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
+ },
// Homepage
{
path: `/`,