mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-18 22:00:31 +00:00
feat: listen LemonSqueezy webhooks
This commit is contained in:
@@ -1,185 +0,0 @@
|
||||
import { Service, Container, Inject } from 'typedi';
|
||||
import cryptoRandomString from 'crypto-random-string';
|
||||
import { times } from 'lodash';
|
||||
import { License, Plan } from '@/system/models';
|
||||
import { ILicense, ISendLicenseDTO } from '@/interfaces';
|
||||
import LicenseMailMessages from '@/services/Payment/LicenseMailMessages';
|
||||
import LicenseSMSMessages from '@/services/Payment/LicenseSMSMessages';
|
||||
import { ServiceError } from '@/exceptions';
|
||||
|
||||
const ERRORS = {
|
||||
PLAN_NOT_FOUND: 'PLAN_NOT_FOUND',
|
||||
LICENSE_NOT_FOUND: 'LICENSE_NOT_FOUND',
|
||||
LICENSE_ALREADY_DISABLED: 'LICENSE_ALREADY_DISABLED',
|
||||
NO_AVALIABLE_LICENSE_CODE: 'NO_AVALIABLE_LICENSE_CODE',
|
||||
};
|
||||
|
||||
@Service()
|
||||
export default class LicenseService {
|
||||
@Inject()
|
||||
smsMessages: LicenseSMSMessages;
|
||||
|
||||
@Inject()
|
||||
mailMessages: LicenseMailMessages;
|
||||
|
||||
/**
|
||||
* Validate the plan existance on the storage.
|
||||
* @param {number} tenantId -
|
||||
* @param {string} planSlug - Plan slug.
|
||||
*/
|
||||
private async getPlanOrThrowError(planSlug: string) {
|
||||
const foundPlan = await Plan.query().where('slug', planSlug).first();
|
||||
|
||||
if (!foundPlan) {
|
||||
throw new ServiceError(ERRORS.PLAN_NOT_FOUND);
|
||||
}
|
||||
return foundPlan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valdiate the license existance on the storage.
|
||||
* @param {number} licenseId - License id.
|
||||
*/
|
||||
private async getLicenseOrThrowError(licenseId: number) {
|
||||
const foundLicense = await License.query().findById(licenseId);
|
||||
|
||||
if (!foundLicense) {
|
||||
throw new ServiceError(ERRORS.LICENSE_NOT_FOUND);
|
||||
}
|
||||
return foundLicense;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates whether the license id is disabled.
|
||||
* @param {ILicense} license
|
||||
*/
|
||||
private validateNotDisabledLicense(license: ILicense) {
|
||||
if (license.disabledAt) {
|
||||
throw new ServiceError(ERRORS.LICENSE_ALREADY_DISABLED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the license code in the given period.
|
||||
* @param {number} licensePeriod
|
||||
* @return {Promise<ILicense>}
|
||||
*/
|
||||
public async generateLicense(
|
||||
licensePeriod: number,
|
||||
periodInterval: string = 'days',
|
||||
planSlug: string
|
||||
): ILicense {
|
||||
let licenseCode: string;
|
||||
let repeat: boolean = true;
|
||||
|
||||
// Retrieve plan or throw not found error.
|
||||
const plan = await this.getPlanOrThrowError(planSlug);
|
||||
|
||||
while (repeat) {
|
||||
licenseCode = cryptoRandomString({ length: 10, type: 'numeric' });
|
||||
const foundLicenses = await License.query().where(
|
||||
'license_code',
|
||||
licenseCode
|
||||
);
|
||||
|
||||
if (foundLicenses.length === 0) {
|
||||
repeat = false;
|
||||
}
|
||||
}
|
||||
return License.query().insert({
|
||||
licenseCode,
|
||||
licensePeriod,
|
||||
periodInterval,
|
||||
planId: plan.id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates licenses.
|
||||
* @param {number} loop
|
||||
* @param {number} licensePeriod
|
||||
* @param {string} periodInterval
|
||||
* @param {number} planId
|
||||
*/
|
||||
public async generateLicenses(
|
||||
loop = 1,
|
||||
licensePeriod: number,
|
||||
periodInterval: string = 'days',
|
||||
planSlug: string
|
||||
) {
|
||||
const asyncOpers: Promise<any>[] = [];
|
||||
|
||||
times(loop, () => {
|
||||
const generateOper = this.generateLicense(
|
||||
licensePeriod,
|
||||
periodInterval,
|
||||
planSlug
|
||||
);
|
||||
asyncOpers.push(generateOper);
|
||||
});
|
||||
return Promise.all(asyncOpers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables the given license id on the storage.
|
||||
* @param {string} licenseSlug - License slug.
|
||||
* @return {Promise}
|
||||
*/
|
||||
public async disableLicense(licenseId: number) {
|
||||
const license = await this.getLicenseOrThrowError(licenseId);
|
||||
|
||||
this.validateNotDisabledLicense(license);
|
||||
|
||||
return License.markLicenseAsDisabled(license.id, 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the given license id from the storage.
|
||||
* @param licenseSlug {string} - License slug.
|
||||
*/
|
||||
public async deleteLicense(licenseSlug: string) {
|
||||
const license = await this.getPlanOrThrowError(licenseSlug);
|
||||
|
||||
return License.query().where('id', license.id).delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends license code to the given customer via SMS or mail message.
|
||||
* @param {string} licenseCode - License code.
|
||||
* @param {string} phoneNumber - Phone number.
|
||||
* @param {string} email - Email address.
|
||||
*/
|
||||
public async sendLicenseToCustomer(sendLicense: ISendLicenseDTO) {
|
||||
const agenda = Container.get('agenda');
|
||||
const { phoneNumber, email, period, periodInterval } = sendLicense;
|
||||
|
||||
// Retreive plan details byt the given plan slug.
|
||||
const plan = await this.getPlanOrThrowError(sendLicense.planSlug);
|
||||
|
||||
const license = await License.query()
|
||||
.modify('filterActiveLicense')
|
||||
.where('license_period', period)
|
||||
.where('period_interval', periodInterval)
|
||||
.where('plan_id', plan.id)
|
||||
.first();
|
||||
|
||||
if (!license) {
|
||||
throw new ServiceError(ERRORS.NO_AVALIABLE_LICENSE_CODE)
|
||||
}
|
||||
// Mark the license as used.
|
||||
await License.markLicenseAsSent(license.licenseCode);
|
||||
|
||||
if (sendLicense.email) {
|
||||
await agenda.schedule('1 second', 'send-license-via-email', {
|
||||
licenseCode: license.licenseCode,
|
||||
email,
|
||||
});
|
||||
}
|
||||
if (phoneNumber) {
|
||||
await agenda.schedule('1 second', 'send-license-via-phone', {
|
||||
licenseCode: license.licenseCode,
|
||||
phoneNumber,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Container } from 'typedi';
|
||||
import Mail from '@/lib/Mail';
|
||||
import config from '@/config';
|
||||
export default class SubscriptionMailMessages {
|
||||
/**
|
||||
* Send license code to the given mail address.
|
||||
* @param {string} licenseCode
|
||||
* @param {email} email
|
||||
*/
|
||||
public async sendMailLicense(licenseCode: string, email: string) {
|
||||
const Logger = Container.get('logger');
|
||||
|
||||
const mail = new Mail()
|
||||
.setView('mail/LicenseReceive.html')
|
||||
.setSubject('Bigcapital - License code')
|
||||
.setTo(email)
|
||||
.setData({
|
||||
licenseCode,
|
||||
successEmail: config.customerSuccess.email,
|
||||
successPhoneNumber: config.customerSuccess.phoneNumber,
|
||||
});
|
||||
|
||||
await mail.send();
|
||||
Logger.info('[license_mail] sent successfully.');
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { License } from '@/system/models';
|
||||
import PaymentMethod from '@/services/Payment/PaymentMethod';
|
||||
import { Plan } from '@/system/models';
|
||||
import { IPaymentMethod, ILicensePaymentModel } from '@/interfaces';
|
||||
import {
|
||||
PaymentInputInvalid,
|
||||
PaymentAmountInvalidWithPlan,
|
||||
VoucherCodeRequired,
|
||||
} from '@/exceptions';
|
||||
|
||||
export default class LicensePaymentMethod
|
||||
extends PaymentMethod
|
||||
implements IPaymentMethod
|
||||
{
|
||||
/**
|
||||
* Payment subscription of organization via license code.
|
||||
* @param {ILicensePaymentModel} licensePaymentModel -
|
||||
*/
|
||||
public async payment(licensePaymentModel: ILicensePaymentModel, plan: Plan) {
|
||||
this.validateLicensePaymentModel(licensePaymentModel);
|
||||
|
||||
const license = await this.getLicenseOrThrowInvalid(licensePaymentModel);
|
||||
this.validatePaymentAmountWithPlan(license, plan);
|
||||
|
||||
// Mark the license code as used.
|
||||
return License.markLicenseAsUsed(licensePaymentModel.licenseCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the license code activation on the storage.
|
||||
* @param {ILicensePaymentModel} licensePaymentModel -
|
||||
*/
|
||||
private async getLicenseOrThrowInvalid(
|
||||
licensePaymentModel: ILicensePaymentModel
|
||||
) {
|
||||
const foundLicense = await License.query()
|
||||
.modify('filterActiveLicense')
|
||||
.where('license_code', licensePaymentModel.licenseCode)
|
||||
.first();
|
||||
|
||||
if (!foundLicense) {
|
||||
throw new PaymentInputInvalid();
|
||||
}
|
||||
return foundLicense;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the payment amount with given plan price.
|
||||
* @param {License} license
|
||||
* @param {Plan} plan
|
||||
*/
|
||||
private validatePaymentAmountWithPlan(license: License, plan: Plan) {
|
||||
if (license.planId !== plan.id) {
|
||||
throw new PaymentAmountInvalidWithPlan();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate voucher payload.
|
||||
* @param {ILicensePaymentModel} licenseModel -
|
||||
*/
|
||||
private validateLicensePaymentModel(licenseModel: ILicensePaymentModel) {
|
||||
if (!licenseModel || !licenseModel.licenseCode) {
|
||||
throw new VoucherCodeRequired();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Container, Inject } from 'typedi';
|
||||
import SMSClient from '@/services/SMSClient';
|
||||
|
||||
export default class SubscriptionSMSMessages {
|
||||
@Inject('SMSClient')
|
||||
smsClient: SMSClient;
|
||||
|
||||
/**
|
||||
* Sends license code to the given phone number via SMS message.
|
||||
* @param {string} phoneNumber
|
||||
* @param {string} licenseCode
|
||||
*/
|
||||
public async sendLicenseSMSMessage(phoneNumber: string, licenseCode: string) {
|
||||
const message: string = `Your license card number: ${licenseCode}. If you need any help please contact us. Bigcapital.`;
|
||||
return this.smsClient.sendMessage(phoneNumber, message);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import moment from 'moment';
|
||||
import { IPaymentModel } from '@/interfaces';
|
||||
|
||||
export default class PaymentMethod implements IPaymentModel {
|
||||
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { IPaymentMethod, IPaymentContext } from "interfaces";
|
||||
import { Plan } from '@/system/models';
|
||||
|
||||
export default class PaymentContext<PaymentModel> implements IPaymentContext{
|
||||
paymentMethod: IPaymentMethod;
|
||||
|
||||
/**
|
||||
* Constructor method.
|
||||
* @param {IPaymentMethod} paymentMethod
|
||||
*/
|
||||
constructor(paymentMethod: IPaymentMethod) {
|
||||
this.paymentMethod = paymentMethod;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {<PaymentModel>} paymentModel
|
||||
*/
|
||||
makePayment(paymentModel: PaymentModel, plan: Plan) {
|
||||
return this.paymentMethod.payment(paymentModel, plan);
|
||||
}
|
||||
}
|
||||
@@ -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