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/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..e6628cc0c --- /dev/null +++ b/packages/server/src/services/Subscription/LemonResumeSubscription.ts @@ -0,0 +1,48 @@ +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; +import { Inject, Service } from 'typedi'; +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/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 711dbce35..78fca991e 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -40,6 +40,15 @@ export default { baseCurrencyUpdated: 'onOrganizationBaseCurrencyUpdated', }, + /** + * Organization subscription. + */ + subscription: { + onSubscriptionCanceled: 'onSubscriptionCanceled', + onSubscriptionResumed: 'onSubscriptionResumed', + onSubscriptionPlanChanged: 'onSubscriptionPlanChanged', + }, + /** * Tenants managment service. */ 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/models/Subscriptions/PlanSubscription.ts b/packages/server/src/system/models/Subscriptions/PlanSubscription.ts index d77ee6418..c3e63530c 100644 --- a/packages/server/src/system/models/Subscriptions/PlanSubscription.ts +++ b/packages/server/src/system/models/Subscriptions/PlanSubscription.ts @@ -4,6 +4,8 @@ import moment from 'moment'; import SubscriptionPeriod from '@/services/Subscription/SubscriptionPeriod'; export default class PlanSubscription extends mixin(SystemModel) { + lemonSubscriptionId: number; + /** * Table name. */ diff --git a/packages/webapp/src/containers/AlertsContainer/registered.tsx b/packages/webapp/src/containers/AlertsContainer/registered.tsx index d1257cc1b..40724c66c 100644 --- a/packages/webapp/src/containers/AlertsContainer/registered.tsx +++ b/packages/webapp/src/containers/AlertsContainer/registered.tsx @@ -27,6 +27,7 @@ import ProjectAlerts from '@/containers/Projects/containers/ProjectAlerts'; import TaxRatesAlerts from '@/containers/TaxRates/alerts'; import { CashflowAlerts } from '../CashFlow/CashflowAlerts'; import { BankRulesAlerts } from '../Banking/Rules/RulesList/BankRulesAlerts'; +import { SubscriptionAlerts } from '../Subscriptions/alerts/alerts'; export default [ ...AccountsAlerts, @@ -56,5 +57,6 @@ export default [ ...ProjectAlerts, ...TaxRatesAlerts, ...CashflowAlerts, - ...BankRulesAlerts + ...BankRulesAlerts, + ...SubscriptionAlerts ]; diff --git a/packages/webapp/src/containers/Subscriptions/BillingPage.tsx b/packages/webapp/src/containers/Subscriptions/BillingPage.tsx new file mode 100644 index 000000000..e1f0c07b5 --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/BillingPage.tsx @@ -0,0 +1,24 @@ +// @ts-nocheck +import * as R from 'ramda'; +import { Button } from '@blueprintjs/core'; +import withAlertActions from '../Alert/withAlertActions'; + +function BillingPageRoot({ openAlert }) { + const handleCancelSubBtnClick = () => { + openAlert('cancel-main-subscription'); + }; + const handleResumeSubBtnClick = () => { + openAlert('resume-main-subscription'); + }; + const handleUpdatePaymentMethod = () => {}; + + return ( +

+ + + +

+ ); +} + +export default R.compose(withAlertActions)(BillingPageRoot); diff --git a/packages/webapp/src/containers/Subscriptions/BillingPageBoot.tsx b/packages/webapp/src/containers/Subscriptions/BillingPageBoot.tsx new file mode 100644 index 000000000..93d8d7b1b --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/BillingPageBoot.tsx @@ -0,0 +1,3 @@ +export function BillingPageBoot() { + return null; +} diff --git a/packages/webapp/src/containers/Subscriptions/alerts/CancelMainSubscriptionAlert.tsx b/packages/webapp/src/containers/Subscriptions/alerts/CancelMainSubscriptionAlert.tsx new file mode 100644 index 000000000..9e2b5979c --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/alerts/CancelMainSubscriptionAlert.tsx @@ -0,0 +1,74 @@ +// @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 cancel.', + intent: Intent.SUCCESS, + }); + }) + .catch( + ({ + response: { + data: { errors }, + }, + }) => {}, + ) + .finally(() => { + closeAlert(name); + }); + }; + + return ( + } + confirmButtonText={'Cancel Subscription'} + intent={Intent.DANGER} + isOpen={isOpen} + onCancel={handleCancel} + onConfirm={handleConfirm} + loading={isLoading} + > +

asdfsadf asdf asdfdsaf

+
+ ); +} + +export default R.compose( + withAlertStoreConnect(), + withAlertActions, +)(CancelMainSubscriptionAlert); diff --git a/packages/webapp/src/containers/Subscriptions/alerts/ResumeMainSubscriptionAlert.tsx b/packages/webapp/src/containers/Subscriptions/alerts/ResumeMainSubscriptionAlert.tsx new file mode 100644 index 000000000..33149e752 --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/alerts/ResumeMainSubscriptionAlert.tsx @@ -0,0 +1,73 @@ +// @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 ( + } + confirmButtonText={'Resume Subscription'} + intent={Intent.DANGER} + isOpen={isOpen} + onCancel={handleCancel} + onConfirm={handleConfirm} + loading={isLoading} + > +

asdfsadf asdf asdfdsaf

+
+ ); +} + +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/hooks/query/subscription.tsx b/packages/webapp/src/hooks/query/subscription.tsx new file mode 100644 index 000000000..58dbe81ee --- /dev/null +++ b/packages/webapp/src/hooks/query/subscription.tsx @@ -0,0 +1,115 @@ +// @ts-nocheck +import { + useMutation, + UseMutationOptions, + UseMutationResult, + useQueryClient, +} from 'react-query'; +import useApiRequest from '../useRequest'; + +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), + { + ...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), + { + ...options, + }, + ); +} + +interface ChangeMainSubscriptionPlanValues { + variantId: 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< + ChangeMainSubscriptionPlanValues, + Error, + ChangeMainSubscriptionPlanResponse + >( + (values) => + apiRequest.post(`/subscription/change`, values).then((res) => 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: `/`,