mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-17 21:30:31 +00:00
feat: listen LemonSqueezy webhooks
This commit is contained in:
@@ -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"
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,4 +38,4 @@ export default class SubscriptionPeriod {
|
||||
getIntervalCount() {
|
||||
return this.interval;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user