From 278d61ce61f1abcd541ee2149bd0c1044a9f6091 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 24 Aug 2024 18:50:12 +0200 Subject: [PATCH 1/3] fix: Listen to payment webhooks --- .../src/api/controllers/Webhooks/Webhooks.ts | 2 +- .../server/src/interfaces/Subscription.ts | 8 + packages/server/src/interfaces/index.ts | 1 + .../Subscription/LemonSqueezyWebhooks.ts | 47 +++++- .../src/services/Subscription/Subscription.ts | 146 ++++++++++++++++-- .../server/src/services/Subscription/types.ts | 2 + packages/server/src/subscribers/events.ts | 4 + ...d_payment_status_to_subscriptions_table.js | 19 +++ .../src/system/models/Subscriptions/Plan.ts | 4 + packages/server/src/system/models/Tenant.ts | 10 +- 10 files changed, 221 insertions(+), 22 deletions(-) create mode 100644 packages/server/src/interfaces/Subscription.ts create mode 100644 packages/server/src/system/migrations/20240824151006_add_payment_status_to_subscriptions_table.js diff --git a/packages/server/src/api/controllers/Webhooks/Webhooks.ts b/packages/server/src/api/controllers/Webhooks/Webhooks.ts index 9abc03dfa..3ccbc6d8c 100644 --- a/packages/server/src/api/controllers/Webhooks/Webhooks.ts +++ b/packages/server/src/api/controllers/Webhooks/Webhooks.ts @@ -35,7 +35,7 @@ export class Webhooks extends BaseController { */ public async lemonWebhooks(req: Request, res: Response, next: NextFunction) { const data = req.body; - const signature = req.headers['x-signature'] ?? ''; + const signature = req.headers['x-signature'] as string ?? ''; const rawBody = req.rawBody; try { diff --git a/packages/server/src/interfaces/Subscription.ts b/packages/server/src/interfaces/Subscription.ts new file mode 100644 index 000000000..90afdaf77 --- /dev/null +++ b/packages/server/src/interfaces/Subscription.ts @@ -0,0 +1,8 @@ +export interface SubscriptionPayload { + lemonSqueezyId?: string; +} + +export enum SubscriptionPaymentStatus { + Succeed = 'succeed', + Failed = 'failed', +} diff --git a/packages/server/src/interfaces/index.ts b/packages/server/src/interfaces/index.ts index 858acba51..cf7fc1669 100644 --- a/packages/server/src/interfaces/index.ts +++ b/packages/server/src/interfaces/index.ts @@ -75,6 +75,7 @@ export * from './Times'; export * from './ProjectProfitabilitySummary'; export * from './TaxRate'; export * from './Plaid'; +export * from './Subscription'; export interface I18nService { __: (input: string) => string; diff --git a/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts b/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts index 0be4dac35..ca97d3bcd 100644 --- a/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts +++ b/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts @@ -64,6 +64,19 @@ export class LemonSqueezyWebhooks { throw new Error("Event body is missing the 'meta' property."); } else if (webhookHasData(eventBody)) { if (webhookEvent.startsWith('subscription_payment_')) { + // Marks the main subscription payment as succeed. + if (webhookEvent === 'subscription_payment_success') { + await this.subscriptionService.markSubscriptionPaymentSucceed( + tenantId, + 'main' + ); + // Marks the main subscription payment as failed. + } else if (webhookEvent === 'subscription_payment_failed') { + await this.subscriptionService.markSubscriptionPaymentFailed( + tenantId, + 'main' + ); + } // Save subscription invoices; eventBody is a SubscriptionInvoice // Not implemented. } else if (webhookEvent.startsWith('subscription_')) { @@ -74,16 +87,34 @@ export class LemonSqueezyWebhooks { // We assume that the Plan table is up to date. const plan = await Plan.query().findOne('lemonVariantId', variantId); + // Update the subscription in the database. + const priceId = attributes.first_subscription_item.price_id; + const subscriptionId = eventBody.data.id; + + // Throw error early if the given lemon variant id is not associated to any plan. if (!plan) { throw new Error(`Plan with variantId ${variantId} not found.`); - } else { - // Update the subscription in the database. - const priceId = attributes.first_subscription_item.price_id; - - // Create a new subscription of the tenant. - if (webhookEvent === 'subscription_created') { - await this.subscriptionService.newSubscribtion(tenantId, plan.slug); - } + } + // Create a new subscription of the tenant. + if (webhookEvent === 'subscription_created') { + await this.subscriptionService.newSubscribtion( + tenantId, + plan.slug, + 'main', + { lemonSqueezyId: subscriptionId } + ); + // Cancel the given subscription of the organization. + } else if (webhookEvent === 'subscription_cancelled') { + await this.subscriptionService.cancelSubscription( + tenantId, + plan.slug + ); + } else if (webhookEvent === 'subscription_plan_changed') { + await this.subscriptionService.subscriptionPlanChanged( + tenantId, + plan.slug, + 'main' + ); } } else if (webhookEvent.startsWith('order_')) { // Save orders; eventBody is a "Order" diff --git a/packages/server/src/services/Subscription/Subscription.ts b/packages/server/src/services/Subscription/Subscription.ts index 39f1d5c57..ab883675f 100644 --- a/packages/server/src/services/Subscription/Subscription.ts +++ b/packages/server/src/services/Subscription/Subscription.ts @@ -1,22 +1,29 @@ -import { Service } from 'typedi'; -import { NotAllowedChangeSubscriptionPlan } from '@/exceptions'; -import { Plan, Tenant } from '@/system/models'; +import { Inject, Service } from 'typedi'; +import { NotAllowedChangeSubscriptionPlan, ServiceError } from '@/exceptions'; +import { Plan, PlanSubscription, Tenant } from '@/system/models'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; +import { SubscriptionPayload, SubscriptionPaymentStatus } from '@/interfaces'; +import { ERRORS } from './types'; @Service() export class Subscription { + @Inject() + private eventPublisher: EventPublisher; + /** * Give the tenant a new subscription. * @param {number} tenantId - Tenant id. - * @param {string} planSlug - Plan slug. - * @param {string} invoiceInterval - * @param {number} invoicePeriod - * @param {string} subscriptionSlug + * @param {string} planSlug - Plan slug of the new subscription. + * @param {string} subscriptionSlug - Subscription slug by default takes main subscription + * @param {SubscriptionPayload} payload - Subscription payload. */ public async newSubscribtion( tenantId: number, planSlug: string, - subscriptionSlug: string = 'main' - ) { + subscriptionSlug: string = 'main', + payload?: SubscriptionPayload + ): Promise { const tenant = await Tenant.query().findById(tenantId).throwIfNotFound(); const plan = await Plan.query().findOne('slug', planSlug).throwIfNotFound(); @@ -45,8 +52,127 @@ export class Subscription { plan.id, invoiceInterval, invoicePeriod, - subscriptionSlug + subscriptionSlug, + payload ); } } + + /** + * Cancels the given tenant subscription. + * @param {number} tenantId - Tenant id. + * @param {string} subscriptionSlug - Subscription slug. + */ + async cancelSubscription( + tenantId: number, + subscriptionSlug: string = 'main' + ): Promise { + 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 already canceled. + if (subscription.canceled()) { + throw new ServiceError(ERRORS.SUBSCRIPTION_ALREADY_CANCELED); + } + await subscription.$query().patch({ canceledAt: new Date() }); + } + + /** + * Mark the given subscription payment of the tenant as succeed. + * @param {number} tenantId + * @param {string} newPlanSlug + * @param {string} subscriptionSlug + */ + async subscriptionPlanChanged( + tenantId: number, + newPlanSlug: string, + subscriptionSlug: string = 'main' + ): Promise { + const tenant = await Tenant.query().findById(tenantId).throwIfNotFound(); + const newPlan = await Plan.query() + .findOne('slug', newPlanSlug) + .throwIfNotFound(); + + const subscription = await PlanSubscription.query().findOne({ + tenantId, + slug: subscriptionSlug, + }); + if (subscription.planId === newPlan.id) { + throw new ServiceError(''); + } + await subscription.$query().patch({ planId: newPlan.id }); + + // Triggers `onSubscriptionPlanChanged` event. + await this.eventPublisher.emitAsync( + events.subscription.onSubscriptionPlanChanged, + { + tenantId, + newPlanSlug, + subscriptionSlug, + } + ); + } + + /** + * Marks the subscription payment as succeed. + * @param {number} tenantId - Tenant id. + * @param {string} subscriptionSlug - Given subscription slug by default main subscription. + * @returns {Promise} + */ + async markSubscriptionPaymentSucceed( + tenantId: number, + subscriptionSlug: string = 'main' + ): Promise { + const subscription = await PlanSubscription.query() + .findOne({ tenantId, slug: subscriptionSlug }) + .throwIfNotFound(); + + await subscription + .$query() + .patch({ paymentStatus: SubscriptionPaymentStatus.Succeed }); + + // Triggers `onSubscriptionSucceed` event. + await this.eventPublisher.emitAsync( + events.subscription.onSubscriptionPaymentSucceed, + { + tenantId, + subscriptionSlug, + } + ); + } + + /** + * Marks the given subscription payment of the tenant as failed. + * @param {number} tenantId - Tenant id. + * @param {string} subscriptionSlug - Given subscription slug. + * @returns {Prmise} + */ + async markSubscriptionPaymentFailed( + tenantId: number, + subscriptionSlug: string = 'main' + ): Promise { + const subscription = await PlanSubscription.query() + .findOne({ tenantId, slug: subscriptionSlug }) + .throwIfNotFound(); + + await subscription + .$query() + .patch({ paymentStatus: SubscriptionPaymentStatus.Failed }); + + // Triggers `onSubscriptionPaymentFailed` event. + await this.eventPublisher.emitAsync( + events.subscription.onSubscriptionPaymentFailed, + { + tenantId, + subscriptionSlug, + } + ); + } } diff --git a/packages/server/src/services/Subscription/types.ts b/packages/server/src/services/Subscription/types.ts index c506b634f..55460a04d 100644 --- a/packages/server/src/services/Subscription/types.ts +++ b/packages/server/src/services/Subscription/types.ts @@ -1,6 +1,8 @@ export const ERRORS = { SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT: 'SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT', + SUBSCRIPTION_NOT_EXIST: 'SUBSCRIPTION_NOT_EXIST', + SUBSCRIPTION_ALREADY_CANCELED: 'SUBSCRIPTION_ALREADY_CANCELED', }; export interface IOrganizationSubscriptionChanged { diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index 478d336eb..f41ebca17 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -50,6 +50,10 @@ export default { onSubscriptionResumed: 'onSubscriptionResumed', onSubscriptionPlanChanged: 'onSubscriptionPlanChanged', onSubscribed: 'onOrganizationSubscribed', + + onSubscriptionCancelled: 'onSubscriptionCancelled', + onSubscriptionPaymentSucceed: 'onSubscriptionPaymentSucceed', + onSubscriptionPaymentFailed: 'onSubscriptionPaymentFailed' }, /** diff --git a/packages/server/src/system/migrations/20240824151006_add_payment_status_to_subscriptions_table.js b/packages/server/src/system/migrations/20240824151006_add_payment_status_to_subscriptions_table.js new file mode 100644 index 000000000..b1e70083f --- /dev/null +++ b/packages/server/src/system/migrations/20240824151006_add_payment_status_to_subscriptions_table.js @@ -0,0 +1,19 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.table('subscription_plan_subscriptions', (table) => { + table.string('payment_status'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.table('subscription_plan_subscriptions', (table) => { + table.dropColumn('payment_status'); + }); +}; diff --git a/packages/server/src/system/models/Subscriptions/Plan.ts b/packages/server/src/system/models/Subscriptions/Plan.ts index 2b3f6b057..dfbf45eff 100644 --- a/packages/server/src/system/models/Subscriptions/Plan.ts +++ b/packages/server/src/system/models/Subscriptions/Plan.ts @@ -3,6 +3,10 @@ import SystemModel from '@/system/models/SystemModel'; import { PlanSubscription } from '..'; export default class Plan extends mixin(SystemModel) { + price: number; + invoiceInternal: number; + invoicePeriod: string; + /** * Table name. */ diff --git a/packages/server/src/system/models/Tenant.ts b/packages/server/src/system/models/Tenant.ts index 1abf28d42..bea33bb2d 100644 --- a/packages/server/src/system/models/Tenant.ts +++ b/packages/server/src/system/models/Tenant.ts @@ -198,14 +198,16 @@ export default class Tenant extends BaseModel { planId, invoiceInterval, invoicePeriod, - subscriptionSlug + subscriptionSlug, + payload?, ) { return Tenant.newSubscription( this.id, planId, invoiceInterval, invoicePeriod, - subscriptionSlug + subscriptionSlug, + payload ); } @@ -217,7 +219,8 @@ export default class Tenant extends BaseModel { planId: number, invoiceInterval: 'month' | 'year', invoicePeriod: number, - subscriptionSlug: string + subscriptionSlug: string, + payload?: { lemonSqueezyId: string } ) { const period = new SubscriptionPeriod(invoiceInterval, invoicePeriod); @@ -227,6 +230,7 @@ export default class Tenant extends BaseModel { planId, startsAt: period.getStartDate(), endsAt: period.getEndDate(), + lemonSubscriptionId: payload?.lemonSqueezyId || null, }); } } From 67a86103288f5932c924e570dc6eaf73ee2befe5 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 24 Aug 2024 20:46:30 +0200 Subject: [PATCH 2/3] 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' }, From 8e94c7a755b04b46100abc1fbc690b5748a8427e Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 24 Aug 2024 21:40:28 +0200 Subject: [PATCH 3/3] feat(subscription): invalidate subscription cache --- packages/server/src/loaders/eventEmitter.ts | 3 ++ ...gerInvalidateCacheOnSubscriptionChange.tsx | 29 +++++++++++++++++++ .../components/Dashboard/DashboardSockets.tsx | 3 ++ .../webapp/src/hooks/query/subscription.tsx | 2 +- 4 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/services/Subscription/events/TriggerInvalidateCacheOnSubscriptionChange.tsx diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index 53042ce40..6fed9bc5d 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -117,6 +117,7 @@ import { DisconnectPlaidItemOnAccountDeleted } from '@/services/Banking/BankAcco import { LoopsEventsSubscriber } from '@/services/Loops/LoopsEventsSubscriber'; import { DeleteUncategorizedTransactionsOnAccountDeleting } from '@/services/Banking/BankAccounts/events/DeleteUncategorizedTransactionsOnAccountDeleting'; import { SeedInitialDemoAccountDataOnOrgBuild } from '@/services/OneClickDemo/events/SeedInitialDemoAccountData'; +import { TriggerInvalidateCacheOnSubscriptionChange } from '@/services/Subscription/events/TriggerInvalidateCacheOnSubscriptionChange'; export default () => { return new EventPublisher(); @@ -247,8 +248,10 @@ export const susbcribers = () => { DeleteCashflowTransactionOnUncategorize, PreventDeleteTransactionOnDelete, + // Subscription SubscribeFreeOnSignupCommunity, SendVerfiyMailOnSignUp, + TriggerInvalidateCacheOnSubscriptionChange, // Attachments AttachmentsOnSaleInvoiceCreated, diff --git a/packages/server/src/services/Subscription/events/TriggerInvalidateCacheOnSubscriptionChange.tsx b/packages/server/src/services/Subscription/events/TriggerInvalidateCacheOnSubscriptionChange.tsx new file mode 100644 index 000000000..8046efb5f --- /dev/null +++ b/packages/server/src/services/Subscription/events/TriggerInvalidateCacheOnSubscriptionChange.tsx @@ -0,0 +1,29 @@ +import events from '@/subscribers/events'; +import Container from 'typedi'; + +export class TriggerInvalidateCacheOnSubscriptionChange { + /** + * Attaches events with handlers. + */ + public attach = (bus) => { + bus.subscribe( + events.subscription.onSubscriptionCancelled, + this.triggerInvalidateCache.bind(this) + ); + bus.subscribe( + events.subscription.onSubscriptionResumed, + this.triggerInvalidateCache.bind(this) + ); + bus.subscribe( + events.subscription.onSubscriptionPlanChanged, + this.triggerInvalidateCache.bind(this) + ); + }; + + private triggerInvalidateCache() { + const io = Container.get('socket'); + + // Notify the frontend to reflect the new transactions changes. + io.emit('SUBSCRIPTION_CHANGED', { subscriptionSlug: 'main' }); + } +} diff --git a/packages/webapp/src/components/Dashboard/DashboardSockets.tsx b/packages/webapp/src/components/Dashboard/DashboardSockets.tsx index e0e095876..d02040591 100644 --- a/packages/webapp/src/components/Dashboard/DashboardSockets.tsx +++ b/packages/webapp/src/components/Dashboard/DashboardSockets.tsx @@ -23,6 +23,9 @@ export function DashboardSockets() { intent: Intent.SUCCESS, }); }); + socket.current.on('SUBSCRIPTION_CHANGED', () => { + client.invalidateQueries('GetSubscriptions'); + }); return () => { socket.current.removeAllListeners(); socket.current.close(); diff --git a/packages/webapp/src/hooks/query/subscription.tsx b/packages/webapp/src/hooks/query/subscription.tsx index d3d0ebc4e..6934cb93e 100644 --- a/packages/webapp/src/hooks/query/subscription.tsx +++ b/packages/webapp/src/hooks/query/subscription.tsx @@ -12,7 +12,7 @@ import useApiRequest from '../useRequest'; import { transformToCamelCase } from '@/utils'; const QueryKeys = { - Subscriptions: 'Subscriptions', + Subscriptions: 'GetSubscriptions', }; interface CancelMainSubscriptionValues {}