feat: listen LemonSqueezy webhooks

This commit is contained in:
Ahmed Bouhuolia
2024-04-14 11:55:36 +02:00
parent 693ae61141
commit a9748b23c0
22 changed files with 106 additions and 1015 deletions

View File

@@ -1,6 +1,6 @@
import { getPrice } from '@lemonsqueezy/lemonsqueezy.js';
import { ServiceError } from '@/exceptions';
import { Service } from 'typedi';
import { Inject, Service } from 'typedi';
import {
compareSignatures,
configureLemonSqueezy,
@@ -9,40 +9,41 @@ import {
webhookHasMeta,
} from './utils';
import { Plan } from '@/system/models';
import { Subscription } from './Subscription';
@Service()
export class LemonWebhooks {
export class LemonSqueezyWebhooks {
@Inject()
private subscriptionService: Subscription;
/**
*
* handle the LemonSqueezy webhooks.
* @param {string} rawBody
* @param {string} signature
* @returns
* @returns {Promise<void>}
*/
public async handlePostWebhook(
rawData: any,
data: Record<string, any>,
signature: string
) {
): Promise<void> {
configureLemonSqueezy();
if (!process.env.LEMONSQUEEZY_WEBHOOK_SECRET) {
return new ServiceError('Lemon Squeezy Webhook Secret not set in .env');
throw new ServiceError('Lemon Squeezy Webhook Secret not set in .env');
}
const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET;
const hmacSignature = createHmacSignature(secret, rawData);
if (!compareSignatures(hmacSignature, signature)) {
console.log('invalid');
return new Error('Invalid signature', { status: 400 });
throw new Error('Invalid signature');
}
// Type guard to check if the object has a 'meta' property.
if (webhookHasMeta(data)) {
// Non-blocking call to process the webhook event.
void this.processWebhookEvent(data);
return true;
}
return new Error('Data invalid', { status: 400 });
throw new Error('Data invalid');
}
/**
@@ -52,6 +53,9 @@ export class LemonWebhooks {
let processingError = '';
const webhookEvent = eventBody.meta.event_name;
const userId = eventBody.meta.custom_data?.user_id;
const tenantId = eventBody.meta.custom_data?.tenant_id;
if (!webhookHasMeta(eventBody)) {
processingError = "Event body is missing the 'meta' property.";
} else if (webhookHasData(eventBody)) {
@@ -78,14 +82,20 @@ export class LemonWebhooks {
if (priceData.error) {
processingError = `Failed to get the price data for the subscription ${eventBody.data.id}.`;
}
const isUsageBased =
attributes.first_subscription_item.is_usage_based;
const price = isUsageBased
? priceData.data?.data.attributes.unit_price_decimal
: priceData.data?.data.attributes.unit_price;
const newSubscription = {};
if (webhookEvent === 'subscription_created') {
await this.subscriptionService.newSubscribtion(
tenantId,
'pro-yearly',
'year',
1
);
}
}
} else if (webhookEvent.startsWith('order_')) {
// Save orders; eventBody is a "Order"

View File

@@ -1,37 +1,27 @@
import { Inject } from 'typedi';
import { Tenant, Plan } from '@/system/models';
import { IPaymentContext } from '@/interfaces';
import { Service } from 'typedi';
import { NotAllowedChangeSubscriptionPlan } from '@/exceptions';
import { Plan, Tenant } from '@/system/models';
export default class Subscription<PaymentModel> {
paymentContext: IPaymentContext | null;
@Inject('logger')
logger: any;
/**
* Constructor method.
* @param {IPaymentContext}
*/
constructor(payment?: IPaymentContext) {
this.paymentContext = payment;
}
@Service()
export class Subscription {
/**
* Give the tenant a new subscription.
* @param {Tenant} tenant
* @param {Plan} plan
* @param {number} tenantId - Tenant id.
* @param {string} planSlug - Plan slug.
* @param {string} invoiceInterval
* @param {number} invoicePeriod
* @param {string} subscriptionSlug
*/
protected async newSubscribtion(
tenant,
plan,
public async newSubscribtion(
tenantId: number,
planSlug: string,
invoiceInterval: string,
invoicePeriod: number,
subscriptionSlug: string = 'main'
) {
const tenant = await Tenant.query().findById(tenantId).throwIfNotFound();
const plan = await Plan.query().findOne('slug', planSlug).throwIfNotFound();
const subscription = await tenant
.$relatedQuery('subscriptions')
.modify('subscriptionBySlug', subscriptionSlug)
@@ -55,26 +45,4 @@ export default class Subscription<PaymentModel> {
);
}
}
/**
* Subscripe to the given plan.
* @param {Plan} plan
* @throws {NotAllowedChangeSubscriptionPlan}
*/
public async subscribe(
tenant: Tenant,
plan: Plan,
paymentModel?: PaymentModel,
subscriptionSlug: string = 'main'
) {
await this.paymentContext.makePayment(paymentModel, plan);
return this.newSubscribtion(
tenant,
plan,
plan.invoiceInterval,
plan.invoicePeriod,
subscriptionSlug
);
}
}

View File

@@ -38,4 +38,4 @@ export default class SubscriptionPeriod {
getIntervalCount() {
return this.interval;
}
}
}

View File

@@ -1,60 +1,8 @@
import { Service, Inject } from 'typedi';
import { Plan, PlanSubscription, Tenant } from '@/system/models';
import Subscription from '@/services/Subscription/Subscription';
import LicensePaymentMethod from '@/services/Payment/LicensePaymentMethod';
import PaymentContext from '@/services/Payment';
import SubscriptionSMSMessages from '@/services/Subscription/SMSMessages';
import SubscriptionMailMessages from '@/services/Subscription/MailMessages';
import { ILicensePaymentModel } from '@/interfaces';
import SubscriptionViaLicense from './SubscriptionViaLicense';
import { Service } from 'typedi';
import { PlanSubscription } from '@/system/models';
@Service()
export default class SubscriptionService {
@Inject()
smsMessages: SubscriptionSMSMessages;
@Inject()
mailMessages: SubscriptionMailMessages;
@Inject('logger')
logger: any;
@Inject('repositories')
sysRepositories: any;
/**
* Handles the payment process via license code and than subscribe to
* the given tenant.
* @param {number} tenantId
* @param {String} planSlug
* @param {string} licenseCode
* @return {Promise}
*/
public async subscriptionViaLicense(
tenantId: number,
planSlug: string,
paymentModel: ILicensePaymentModel,
subscriptionSlug: string = 'main'
) {
// Retrieve plan details.
const plan = await Plan.query().findOne('slug', planSlug);
// Retrieve tenant details.
const tenant = await Tenant.query().findById(tenantId);
// License payment method.
const paymentViaLicense = new LicensePaymentMethod();
// Payment context.
const paymentContext = new PaymentContext(paymentViaLicense);
// Subscription.
const subscription = new SubscriptionViaLicense(paymentContext);
// Subscribe.
await subscription.subscribe(tenant, plan, paymentModel, subscriptionSlug);
}
/**
* Retrieve all subscription of the given tenant.
* @param {number} tenantId

View File

@@ -1,54 +0,0 @@
import { License, Tenant, Plan } from '@/system/models';
import Subscription from './Subscription';
import { PaymentModel } from '@/interfaces';
export default class SubscriptionViaLicense extends Subscription<PaymentModel> {
/**
* Subscripe to the given plan.
* @param {Plan} plan
* @throws {NotAllowedChangeSubscriptionPlan}
*/
public async subscribe(
tenant: Tenant,
plan: Plan,
paymentModel?: PaymentModel,
subscriptionSlug: string = 'main'
): Promise<void> {
await this.paymentContext.makePayment(paymentModel, plan);
return this.newSubscriptionFromLicense(
tenant,
plan,
paymentModel.licenseCode,
subscriptionSlug
);
}
/**
* New subscription from the given license.
* @param {Tanant} tenant
* @param {Plab} plan
* @param {string} licenseCode
* @param {string} subscriptionSlug
* @returns {Promise<void>}
*/
private async newSubscriptionFromLicense(
tenant,
plan,
licenseCode: string,
subscriptionSlug: string = 'main'
): Promise<void> {
// License information.
const licenseInfo = await License.query().findOne(
'licenseCode',
licenseCode
);
return this.newSubscribtion(
tenant,
plan,
licenseInfo.periodInterval,
licenseInfo.licensePeriod,
subscriptionSlug
);
}
}