From 67a86103288f5932c924e570dc6eaf73ee2befe5 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 24 Aug 2024 20:46:30 +0200 Subject: [PATCH] feat: cancel/resume LS subscriptions --- .../Subscription/SubscriptionController.ts | 2 +- .../Subscription/LemonCancelSubscription.ts | 18 ++++---- .../LemonChangeSubscriptionPlan.ts | 11 +++-- .../Subscription/LemonResumeSubscription.ts | 17 ++++---- .../Subscription/LemonSqueezyWebhooks.ts | 16 ++++--- .../src/services/Subscription/Subscription.ts | 42 +++++++++++++++++++ .../Subscription/SubscriptionApplication.ts | 20 +++++++-- .../events/SubscribeFreeOnSignupCommunity.tsx | 2 +- .../server/src/services/Subscription/types.ts | 4 +- packages/server/src/subscribers/events.ts | 15 ++++--- 10 files changed, 108 insertions(+), 39 deletions(-) diff --git a/packages/server/src/api/controllers/Subscription/SubscriptionController.ts b/packages/server/src/api/controllers/Subscription/SubscriptionController.ts index d84e4c2c2..4c51b776a 100644 --- a/packages/server/src/api/controllers/Subscription/SubscriptionController.ts +++ b/packages/server/src/api/controllers/Subscription/SubscriptionController.ts @@ -113,7 +113,7 @@ export class SubscriptionController extends BaseController { const { tenantId } = req; try { - await this.subscriptionApp.cancelSubscription(tenantId, '455610'); + await this.subscriptionApp.cancelSubscription(tenantId); return res.status(200).send({ status: 200, diff --git a/packages/server/src/services/Subscription/LemonCancelSubscription.ts b/packages/server/src/services/Subscription/LemonCancelSubscription.ts index ef8441198..66d4b0691 100644 --- a/packages/server/src/services/Subscription/LemonCancelSubscription.ts +++ b/packages/server/src/services/Subscription/LemonCancelSubscription.ts @@ -5,7 +5,7 @@ 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'; +import { ERRORS, IOrganizationSubscriptionCancelled } from './types'; @Service() export class LemonCancelSubscription { @@ -18,12 +18,15 @@ export class LemonCancelSubscription { * @param {number} subscriptionId * @returns {Promise} */ - public async cancelSubscription(tenantId: number) { + public async cancelSubscription( + tenantId: number, + subscriptionSlug: string = 'main' + ) { configureLemonSqueezy(); const subscription = await PlanSubscription.query().findOne({ tenantId, - slug: 'main', + slug: subscriptionSlug, }); if (!subscription) { throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT); @@ -35,13 +38,10 @@ export class LemonCancelSubscription { if (cancelledSub.error) { throw new Error(cancelledSub.error.message); } - await PlanSubscription.query().findById(subscriptionId).patch({ - canceledAt: new Date(), - }); - // Triggers `onSubscriptionCanceled` event. + // Triggers `onSubscriptionCancelled` event. await this.eventPublisher.emitAsync( - events.subscription.onSubscriptionCanceled, - { tenantId, subscriptionId } as IOrganizationSubscriptionCanceled + events.subscription.onSubscriptionCancel, + { tenantId, subscriptionId } as IOrganizationSubscriptionCancelled ); } } diff --git a/packages/server/src/services/Subscription/LemonChangeSubscriptionPlan.ts b/packages/server/src/services/Subscription/LemonChangeSubscriptionPlan.ts index 9be404601..4cb451bd4 100644 --- a/packages/server/src/services/Subscription/LemonChangeSubscriptionPlan.ts +++ b/packages/server/src/services/Subscription/LemonChangeSubscriptionPlan.ts @@ -18,25 +18,30 @@ export class LemonChangeSubscriptionPlan { * @param {number} newVariantId - New variant id. * @returns {Promise} */ - public async changeSubscriptionPlan(tenantId: number, newVariantId: number) { + public async changeSubscriptionPlan( + tenantId: number, + newVariantId: number, + subscriptionSlug: string = 'main' + ) { configureLemonSqueezy(); const subscription = await PlanSubscription.query().findOne({ tenantId, - slug: 'main', + slug: subscriptionSlug, }); const lemonSubscriptionId = subscription.lemonSubscriptionId; // Send request to Lemon Squeezy to change the subscription. const updatedSub = await updateSubscription(lemonSubscriptionId, { variantId: newVariantId, + invoiceImmediately: true, }); if (updatedSub.error) { throw new ServiceError('SOMETHING_WENT_WRONG'); } // Triggers `onSubscriptionPlanChanged` event. await this.eventPublisher.emitAsync( - events.subscription.onSubscriptionPlanChanged, + events.subscription.onSubscriptionPlanChange, { tenantId, lemonSubscriptionId, diff --git a/packages/server/src/services/Subscription/LemonResumeSubscription.ts b/packages/server/src/services/Subscription/LemonResumeSubscription.ts index cd0ee0d2e..8bc7112cc 100644 --- a/packages/server/src/services/Subscription/LemonResumeSubscription.ts +++ b/packages/server/src/services/Subscription/LemonResumeSubscription.ts @@ -14,15 +14,16 @@ export class LemonResumeSubscription { /** * Resumes the main subscription of the given tenant. - * @param {number} tenantId - + * @param {number} tenantId - Tenant id. + * @param {string} subscriptionSlug - Subscription slug by default main subscription. * @returns {Promise} */ - public async resumeSubscription(tenantId: number) { + public async resumeSubscription(tenantId: number, subscriptionSlug: string = 'main') { configureLemonSqueezy(); const subscription = await PlanSubscription.query().findOne({ tenantId, - slug: 'main', + slug: subscriptionSlug, }); if (!subscription) { throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT); @@ -33,15 +34,11 @@ export class LemonResumeSubscription { cancelled: false, }); if (returnedSub.error) { - throw new ServiceError(''); + throw new ServiceError(ٌٌُERRORS.SOMETHING_WENT_WRONG_WITH_LS); } - // Update the subscription of the organization. - await PlanSubscription.query().findById(subscriptionId).patch({ - canceledAt: null, - }); - // Triggers `onSubscriptionCanceled` event. + // Triggers `onSubscriptionResume` event. await this.eventPublisher.emitAsync( - events.subscription.onSubscriptionResumed, + events.subscription.onSubscriptionResume, { tenantId, subscriptionId } as IOrganizationSubscriptionResumed ); } diff --git a/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts b/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts index ca97d3bcd..3dcce2845 100644 --- a/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts +++ b/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts @@ -59,6 +59,7 @@ export class LemonSqueezyWebhooks { const userId = eventBody.meta.custom_data?.user_id; const tenantId = eventBody.meta.custom_data?.tenant_id; + const subscriptionSlug = 'main'; if (!webhookHasMeta(eventBody)) { throw new Error("Event body is missing the 'meta' property."); @@ -68,13 +69,13 @@ export class LemonSqueezyWebhooks { if (webhookEvent === 'subscription_payment_success') { await this.subscriptionService.markSubscriptionPaymentSucceed( tenantId, - 'main' + subscriptionSlug ); // Marks the main subscription payment as failed. } else if (webhookEvent === 'subscription_payment_failed') { await this.subscriptionService.markSubscriptionPaymentFailed( tenantId, - 'main' + subscriptionSlug ); } // Save subscription invoices; eventBody is a SubscriptionInvoice @@ -100,20 +101,25 @@ export class LemonSqueezyWebhooks { await this.subscriptionService.newSubscribtion( tenantId, plan.slug, - 'main', + subscriptionSlug, { lemonSqueezyId: subscriptionId } ); // Cancel the given subscription of the organization. } else if (webhookEvent === 'subscription_cancelled') { await this.subscriptionService.cancelSubscription( tenantId, - plan.slug + subscriptionSlug ); } else if (webhookEvent === 'subscription_plan_changed') { await this.subscriptionService.subscriptionPlanChanged( tenantId, plan.slug, - 'main' + subscriptionSlug + ); + } else if (webhookEvent === 'subscription_resumed') { + await this.subscriptionService.resumeSubscription( + tenantId, + subscriptionSlug ); } } else if (webhookEvent.startsWith('order_')) { diff --git a/packages/server/src/services/Subscription/Subscription.ts b/packages/server/src/services/Subscription/Subscription.ts index ab883675f..ead569822 100644 --- a/packages/server/src/services/Subscription/Subscription.ts +++ b/packages/server/src/services/Subscription/Subscription.ts @@ -82,6 +82,48 @@ export class Subscription { throw new ServiceError(ERRORS.SUBSCRIPTION_ALREADY_CANCELED); } await subscription.$query().patch({ canceledAt: new Date() }); + + // Triggers `onSubscriptionCancelled` event. + await this.eventPublisher.emitAsync( + events.subscription.onSubscriptionCancelled, + { + tenantId, + subscriptionSlug, + } + ); + } + + /** + * Resumes the given tenant subscription. + * @param {number} tenantId + * @param {string} subscriptionSlug - Subscription slug by deafult main subscription. + * @returns {Promise} + */ + async resumeSubscription( + tenantId: number, + subscriptionSlug: string = 'main' + ) { + const tenant = await Tenant.query().findById(tenantId).throwIfNotFound(); + + const subscription = await PlanSubscription.query().findOne({ + tenantId, + slug: subscriptionSlug, + }); + // Throw error early if the subscription is not exist. + if (!subscription) { + throw new ServiceError(ERRORS.SUBSCRIPTION_NOT_EXIST); + } + // Throw error early if the subscription is not cancelled. + if (!subscription.canceled()) { + throw new ServiceError(ERRORS.SUBSCRIPTION_ALREADY_ACTIVE); + } + await subscription.$query().patch({ canceledAt: null }); + + // Triggers `onSubscriptionResumed` event. + await this.eventPublisher.emitAsync( + events.subscription.onSubscriptionResumed, + { tenantId, subscriptionSlug } + ); } /** diff --git a/packages/server/src/services/Subscription/SubscriptionApplication.ts b/packages/server/src/services/Subscription/SubscriptionApplication.ts index c7f97569a..c830607b7 100644 --- a/packages/server/src/services/Subscription/SubscriptionApplication.ts +++ b/packages/server/src/services/Subscription/SubscriptionApplication.ts @@ -20,8 +20,14 @@ export class SubscriptionApplication { * @param {string} id * @returns {Promise} */ - public cancelSubscription(tenantId: number, id: string) { - return this.cancelSubscriptionService.cancelSubscription(tenantId, id); + public cancelSubscription( + tenantId: number, + subscriptionSlug: string = 'main' + ) { + return this.cancelSubscriptionService.cancelSubscription( + tenantId, + subscriptionSlug + ); } /** @@ -29,8 +35,14 @@ export class SubscriptionApplication { * @param {number} tenantId * @returns {Promise} */ - public resumeSubscription(tenantId: number) { - return this.resumeSubscriptionService.resumeSubscription(tenantId); + public resumeSubscription( + tenantId: number, + subscriptionSlug: string = 'main' + ) { + return this.resumeSubscriptionService.resumeSubscription( + tenantId, + subscriptionSlug + ); } /** diff --git a/packages/server/src/services/Subscription/events/SubscribeFreeOnSignupCommunity.tsx b/packages/server/src/services/Subscription/events/SubscribeFreeOnSignupCommunity.tsx index 1649b0a7e..f42a037b8 100644 --- a/packages/server/src/services/Subscription/events/SubscribeFreeOnSignupCommunity.tsx +++ b/packages/server/src/services/Subscription/events/SubscribeFreeOnSignupCommunity.tsx @@ -1,8 +1,8 @@ +import { Inject, Service } from 'typedi'; import { IAuthSignedUpEventPayload } from '@/interfaces'; import events from '@/subscribers/events'; import config from '@/config'; import { Subscription } from '../Subscription'; -import { Inject, Service } from 'typedi'; @Service() export class SubscribeFreeOnSignupCommunity { diff --git a/packages/server/src/services/Subscription/types.ts b/packages/server/src/services/Subscription/types.ts index 55460a04d..b6b5318c1 100644 --- a/packages/server/src/services/Subscription/types.ts +++ b/packages/server/src/services/Subscription/types.ts @@ -3,6 +3,8 @@ export const ERRORS = { 'SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT', SUBSCRIPTION_NOT_EXIST: 'SUBSCRIPTION_NOT_EXIST', SUBSCRIPTION_ALREADY_CANCELED: 'SUBSCRIPTION_ALREADY_CANCELED', + SUBSCRIPTION_ALREADY_ACTIVE: 'SUBSCRIPTION_ALREADY_ACTIVE', + SOMETHING_WENT_WRONG_WITH_LS: 'SOMETHING_WENT_WRONG_WITH_LS' }; export interface IOrganizationSubscriptionChanged { @@ -11,7 +13,7 @@ export interface IOrganizationSubscriptionChanged { newVariantId: number; } -export interface IOrganizationSubscriptionCanceled { +export interface IOrganizationSubscriptionCancelled { tenantId: number; subscriptionId: string; } diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index f41ebca17..d21ffac98 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -46,12 +46,17 @@ export default { * Organization subscription. */ subscription: { - onSubscriptionCanceled: 'onSubscriptionCanceled', - onSubscriptionResumed: 'onSubscriptionResumed', - onSubscriptionPlanChanged: 'onSubscriptionPlanChanged', - onSubscribed: 'onOrganizationSubscribed', - + onSubscriptionCancel: 'onSubscriptionCancel', onSubscriptionCancelled: 'onSubscriptionCancelled', + + onSubscriptionResume: 'onSubscriptionResume', + onSubscriptionResumed: 'onSubscriptionResumed', + + onSubscriptionPlanChange: 'onSubscriptionPlanChange', + onSubscriptionPlanChanged: 'onSubscriptionPlanChanged', + + onSubscriptionSubscribed: 'onSubscriptionSubscribed', + onSubscriptionPaymentSucceed: 'onSubscriptionPaymentSucceed', onSubscriptionPaymentFailed: 'onSubscriptionPaymentFailed' },