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, }); } }