mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-16 12:50:38 +00:00
feat: pause, resume main subscription
This commit is contained in:
@@ -8,6 +8,7 @@ import SubscriptionService from '@/services/Subscription/SubscriptionService';
|
||||
import asyncMiddleware from '@/api/middleware/asyncMiddleware';
|
||||
import BaseController from '../BaseController';
|
||||
import { LemonSqueezyService } from '@/services/Subscription/LemonSqueezyService';
|
||||
import { SubscriptionApplication } from '@/services/Subscription/SubscriptionApplication';
|
||||
|
||||
@Service()
|
||||
export class SubscriptionController extends BaseController {
|
||||
@@ -17,6 +18,9 @@ export class SubscriptionController extends BaseController {
|
||||
@Inject()
|
||||
private lemonSqueezyService: LemonSqueezyService;
|
||||
|
||||
@Inject()
|
||||
private subscriptionApp: SubscriptionApplication;
|
||||
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
@@ -33,6 +37,14 @@ export class SubscriptionController extends BaseController {
|
||||
this.validationResult,
|
||||
this.getCheckoutUrl.bind(this)
|
||||
);
|
||||
router.post('/cancel', asyncMiddleware(this.cancelSubscription.bind(this)));
|
||||
router.post('/resume', asyncMiddleware(this.resumeSubscription.bind(this)));
|
||||
router.post(
|
||||
'/change',
|
||||
[body('variant_id').exists().trim()],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.changeSubscriptionPlan.bind(this))
|
||||
);
|
||||
router.get('/', asyncMiddleware(this.getSubscriptions.bind(this)));
|
||||
|
||||
return router;
|
||||
@@ -85,4 +97,84 @@ export class SubscriptionController extends BaseController {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the subscription of the current organization.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
* @returns {Promise<Response|null>}
|
||||
*/
|
||||
private async cancelSubscription(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const { tenantId } = req;
|
||||
|
||||
try {
|
||||
await this.subscriptionApp.cancelSubscription(tenantId, '455610');
|
||||
|
||||
return res.status(200).send({
|
||||
status: 200,
|
||||
message: 'The organization subscription has been canceled.',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes the subscription of the current organization.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
* @returns {Promise<Response | null>}
|
||||
*/
|
||||
private async resumeSubscription(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const { tenantId } = req;
|
||||
|
||||
try {
|
||||
await this.subscriptionApp.resumeSubscription(tenantId);
|
||||
|
||||
return res.status(200).send({
|
||||
status: 200,
|
||||
message: 'The organization subscription has been resumed.',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the main subscription plan of the current organization.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
* @returns {Promise<Response | null>}
|
||||
*/
|
||||
public async changeSubscriptionPlan(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const { tenantId } = req;
|
||||
const body = this.matchedBodyData(req);
|
||||
|
||||
try {
|
||||
await this.subscriptionApp.changeSubscriptionPlan(
|
||||
tenantId,
|
||||
body.variantId
|
||||
);
|
||||
return res.status(200).send({
|
||||
message: 'The subscription plan has been changed.',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { 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<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
|
||||
);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -40,6 +40,15 @@ export default {
|
||||
baseCurrencyUpdated: 'onOrganizationBaseCurrencyUpdated',
|
||||
},
|
||||
|
||||
/**
|
||||
* Organization subscription.
|
||||
*/
|
||||
subscription: {
|
||||
onSubscriptionCanceled: 'onSubscriptionCanceled',
|
||||
onSubscriptionResumed: 'onSubscriptionResumed',
|
||||
onSubscriptionPlanChanged: 'onSubscriptionPlanChanged',
|
||||
},
|
||||
|
||||
/**
|
||||
* Tenants managment service.
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
};
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user