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,250 +0,0 @@
import { Service, Inject } from 'typedi';
import { Router, Request, Response, NextFunction } from 'express';
import { check, oneOf, ValidationChain } from 'express-validator';
import basicAuth from 'express-basic-auth';
import config from '@/config';
import { License } from '@/system/models';
import { ServiceError } from '@/exceptions';
import BaseController from '@/api/controllers/BaseController';
import LicenseService from '@/services/Payment/License';
import asyncMiddleware from '@/api/middleware/asyncMiddleware';
import { ILicensesFilter, ISendLicenseDTO } from '@/interfaces';
@Service()
export default class LicensesController extends BaseController {
@Inject()
licenseService: LicenseService;
/**
* Router constructor.
*/
router() {
const router = Router();
router.use(
basicAuth({
users: {
[config.licensesAuth.user]: config.licensesAuth.password,
},
challenge: true,
})
);
router.post(
'/generate',
this.generateLicenseSchema,
this.validationResult,
asyncMiddleware(this.generateLicense.bind(this)),
this.catchServiceErrors,
);
router.post(
'/disable/:licenseId',
this.validationResult,
asyncMiddleware(this.disableLicense.bind(this)),
this.catchServiceErrors,
);
router.post(
'/send',
this.sendLicenseSchemaValidation,
this.validationResult,
asyncMiddleware(this.sendLicense.bind(this)),
this.catchServiceErrors,
);
router.delete(
'/:licenseId',
asyncMiddleware(this.deleteLicense.bind(this)),
this.catchServiceErrors,
);
router.get('/', asyncMiddleware(this.listLicenses.bind(this)));
return router;
}
/**
* Generate license validation schema.
*/
get generateLicenseSchema(): ValidationChain[] {
return [
check('loop').exists().isNumeric().toInt(),
check('period').exists().isNumeric().toInt(),
check('period_interval')
.exists()
.isIn(['month', 'months', 'year', 'years', 'day', 'days']),
check('plan_slug').exists().trim().escape(),
];
}
/**
* Specific license validation schema.
*/
get specificLicenseSchema(): ValidationChain[] {
return [
oneOf(
[check('license_id').exists().isNumeric().toInt()],
[check('license_code').exists().isNumeric().toInt()]
),
];
}
/**
* Send license validation schema.
*/
get sendLicenseSchemaValidation(): ValidationChain[] {
return [
check('period').exists().isNumeric(),
check('period_interval').exists().trim().escape(),
check('plan_slug').exists().trim().escape(),
oneOf([
check('phone_number').exists().trim().escape(),
check('email').exists().trim().escape(),
]),
];
}
/**
* Generate licenses codes with given period in bulk.
* @param {Request} req
* @param {Response} res
* @return {Response}
*/
async generateLicense(req: Request, res: Response, next: Function) {
const { loop = 10, period, periodInterval, planSlug } = this.matchedBodyData(
req
);
try {
await this.licenseService.generateLicenses(
loop,
period,
periodInterval,
planSlug
);
return res.status(200).send({
code: 100,
type: 'LICENSEES.GENERATED.SUCCESSFULLY',
message: 'The licenses have been generated successfully.',
});
} catch (error) {
next(error);
}
}
/**
* Disable the given license on the storage.
* @param {Request} req
* @param {Response} res
* @return {Response}
*/
async disableLicense(req: Request, res: Response, next: Function) {
const { licenseId } = req.params;
try {
await this.licenseService.disableLicense(licenseId);
return res.status(200).send({ license_id: licenseId });
} catch (error) {
next(error);
}
}
/**
* Deletes the given license code on the storage.
* @param {Request} req
* @param {Response} res
* @return {Response}
*/
async deleteLicense(req: Request, res: Response, next: Function) {
const { licenseId } = req.params;
try {
await this.licenseService.deleteLicense(licenseId);
return res.status(200).send({ license_id: licenseId });
} catch (error) {
next(error)
}
}
/**
* Send license code in the given period to the customer via email or phone number
* @param {Request} req
* @param {Response} res
* @return {Response}
*/
async sendLicense(req: Request, res: Response, next: Function) {
const sendLicenseDTO: ISendLicenseDTO = this.matchedBodyData(req);
try {
await this.licenseService.sendLicenseToCustomer(sendLicenseDTO);
return res.status(200).send({
status: 100,
code: 'LICENSE.CODE.SENT',
message: 'The license has been sent to the given customer.',
});
} catch (error) {
next(error);
}
}
/**
* Listing licenses.
* @param {Request} req
* @param {Response} res
*/
async listLicenses(req: Request, res: Response) {
const filter: ILicensesFilter = {
disabled: false,
used: false,
sent: false,
active: false,
...req.query,
};
const licenses = await License.query().onBuild((builder) => {
builder.modify('filter', filter);
builder.orderBy('createdAt', 'ASC');
});
return res.status(200).send({ licenses });
}
/**
* Catches all service errors.
*/
catchServiceErrors(error, req: Request, res: Response, next: NextFunction) {
if (error instanceof ServiceError) {
if (error.errorType === 'PLAN_NOT_FOUND') {
return res.status(400).send({
errors: [{
type: 'PLAN.NOT.FOUND',
code: 100,
message: 'The given plan not found.',
}],
});
}
if (error.errorType === 'LICENSE_NOT_FOUND') {
return res.status(400).send({
errors: [{
type: 'LICENSE_NOT_FOUND',
code: 200,
message: 'The given license id not found.'
}],
});
}
if (error.errorType === 'LICENSE_ALREADY_DISABLED') {
return res.status(400).send({
errors: [{
type: 'LICENSE.ALREADY.DISABLED',
code: 200,
message: 'License is already disabled.'
}],
});
}
if (error.errorType === 'NO_AVALIABLE_LICENSE_CODE') {
return res.status(400).send({
status: 110,
message: 'There is no licenses availiable right now with the given period and plan.',
code: 'NO.AVALIABLE.LICENSE.CODE',
});
}
}
next(error);
}
}

View File

@@ -1,125 +0,0 @@
import { Inject, Service } from 'typedi';
import { NextFunction, Router, Request, Response } from 'express';
import { check } from 'express-validator';
import asyncMiddleware from '@/api/middleware/asyncMiddleware';
import PaymentMethodController from '@/api/controllers/Subscription/PaymentMethod';
import {
NotAllowedChangeSubscriptionPlan,
NoPaymentModelWithPricedPlan,
PaymentAmountInvalidWithPlan,
PaymentInputInvalid,
VoucherCodeRequired,
} from '@/exceptions';
import { ILicensePaymentModel } from '@/interfaces';
import instance from 'tsyringe/dist/typings/dependency-container';
@Service()
export default class PaymentViaLicenseController extends PaymentMethodController {
@Inject('logger')
logger: any;
/**
* Router constructor.
*/
router() {
const router = Router();
router.post(
'/payment',
this.paymentViaLicenseSchema,
this.validationResult,
asyncMiddleware(this.validatePlanSlugExistance.bind(this)),
asyncMiddleware(this.paymentViaLicense.bind(this)),
this.handleErrors,
);
return router;
}
/**
* Payment via license validation schema.
*/
get paymentViaLicenseSchema() {
return [
check('plan_slug').exists().trim().escape(),
check('license_code').exists().trim().escape(),
];
}
/**
* Handle the subscription payment via license code.
* @param {Request} req
* @param {Response} res
* @return {Response}
*/
async paymentViaLicense(req: Request, res: Response, next: Function) {
const { planSlug, licenseCode } = this.matchedBodyData(req);
const { tenant } = req;
try {
const licenseModel: ILicensePaymentModel = { licenseCode };
await this.subscriptionService.subscriptionViaLicense(
tenant.id,
planSlug,
licenseModel
);
return res.status(200).send({
type: 'success',
code: 'PAYMENT.SUCCESSFULLY.MADE',
message: 'Payment via license has been made successfully.',
});
} catch (exception) {
next(exception);
}
}
/**
* Handle service errors.
* @param {Error} error
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
private handleErrors(
exception: Error,
req: Request,
res: Response,
next: NextFunction
) {
const errorReasons = [];
if (exception instanceof VoucherCodeRequired) {
errorReasons.push({
type: 'VOUCHER_CODE_REQUIRED',
code: 100,
});
}
if (exception instanceof NoPaymentModelWithPricedPlan) {
errorReasons.push({
type: 'NO_PAYMENT_WITH_PRICED_PLAN',
code: 140,
});
}
if (exception instanceof NotAllowedChangeSubscriptionPlan) {
errorReasons.push({
type: 'NOT.ALLOWED.RENEW.SUBSCRIPTION.WHILE.ACTIVE',
code: 120,
});
}
if (errorReasons.length > 0) {
return res.status(400).send({ errors: errorReasons });
}
if (exception instanceof PaymentInputInvalid) {
return res.status(400).send({
errors: [{ type: 'LICENSE.CODE.IS.INVALID', code: 120 }],
});
}
if (exception instanceof PaymentAmountInvalidWithPlan) {
return res.status(400).send({
errors: [{ type: 'LICENSE.NOT.FOR.GIVEN.PLAN' }],
});
}
next(exception);
}
}

View File

@@ -3,6 +3,7 @@ import { PlaidApplication } from '@/services/Banking/Plaid/PlaidApplication';
import { Request, Response } from 'express';
import { Inject, Service } from 'typedi';
import BaseController from '../BaseController';
import { LemonSqueezyWebhooks } from '@/services/Subscription/LemonSqueezyWebhooks';
import { PlaidWebhookTenantBootMiddleware } from '@/services/Banking/Plaid/PlaidWebhookTenantBootMiddleware';
@Service()
@@ -10,18 +11,39 @@ export class Webhooks extends BaseController {
@Inject()
private plaidApp: PlaidApplication;
@Inject()
private lemonWebhooksService: LemonSqueezyWebhooks;
/**
* Router constructor.
*/
router() {
const router = Router();
router.use(PlaidWebhookTenantBootMiddleware);
router.use('/plaid', PlaidWebhookTenantBootMiddleware);
router.post('/plaid', this.plaidWebhooks.bind(this));
router.post('/lemon', this.lemonWebhooks.bind(this));
return router;
}
/**
* Listens to LemonSqueezy webhooks events.
* @param {Request} req
* @param {Response} res
* @returns {Response}
*/
public async lemonWebhooks(req: Request, res: Response) {
const data = req.body;
const signature = req.headers['x-signature'] ?? '';
const rawBody = req.rawBody;
await this.lemonWebhooksService.handlePostWebhook(rawBody, data, signature);
return res.status(200).send();
}
/**
* Listens to Plaid webhooks.
* @param {Request} req

View File

@@ -1,33 +0,0 @@
import { Container } from 'typedi';
import LicenseService from '@/services/Payment/License';
export default class SendLicenseViaEmailJob {
/**
* Constructor method.
* @param agenda
*/
constructor(agenda) {
agenda.define(
'send-license-via-email',
{ priority: 'high', concurrency: 1, },
this.handler,
);
}
public async handler(job, done: Function): Promise<void> {
const Logger = Container.get('logger');
const licenseService = Container.get(LicenseService);
const { email, licenseCode } = job.attrs.data;
Logger.info(`[send_license_via_mail] started: ${job.attrs.data}`);
try {
await licenseService.mailMessages.sendMailLicense(licenseCode, email);
Logger.info(`[send_license_via_mail] completed: ${job.attrs.data}`);
done();
} catch(e) {
Logger.error(`[send_license_via_mail] ${job.attrs.data}, error: ${e}`);
done(e);
}
}
}

View File

@@ -1,33 +0,0 @@
import { Container } from 'typedi';
import LicenseService from '@/services/Payment/License';
export default class SendLicenseViaPhoneJob {
/**
* Constructor method.
*/
constructor(agenda) {
agenda.define(
'send-license-via-phone',
{ priority: 'high', concurrency: 1, },
this.handler,
);
}
public async handler(job, done: Function): Promise<void> {
const { phoneNumber, licenseCode } = job.attrs.data;
const Logger = Container.get('logger');
const licenseService = Container.get(LicenseService);
Logger.debug(`Send license via phone number - started: ${job.attrs.data}`);
try {
await licenseService.smsMessages.sendLicenseSMSMessage(phoneNumber, licenseCode);
Logger.debug(`Send license via phone number - completed: ${job.attrs.data}`);
done();
} catch(e) {
Logger.error(`Send license via phone number: ${job.attrs.data}, error: ${e}`);
done(e);
}
}
}

View File

@@ -2,8 +2,6 @@ import Agenda from 'agenda';
import ResetPasswordMailJob from 'jobs/ResetPasswordMail';
import ComputeItemCost from 'jobs/ComputeItemCost';
import RewriteInvoicesJournalEntries from 'jobs/WriteInvoicesJEntries';
import SendLicenseViaPhoneJob from 'jobs/SendLicensePhone';
import SendLicenseViaEmailJob from 'jobs/SendLicenseEmail';
import SendSMSNotificationSubscribeEnd from 'jobs/SMSNotificationSubscribeEnd';
import SendSMSNotificationTrialEnd from 'jobs/SMSNotificationTrialEnd';
import SendMailNotificationSubscribeEnd from 'jobs/MailNotificationSubscribeEnd';
@@ -22,8 +20,6 @@ import { ImportDeleteExpiredFilesJobs } from '@/services/Import/jobs/ImportDelet
export default ({ agenda }: { agenda: Agenda }) => {
new ResetPasswordMailJob(agenda);
new UserInviteMailJob(agenda);
new SendLicenseViaEmailJob(agenda);
new SendLicenseViaPhoneJob(agenda);
new ComputeItemCost(agenda);
new RewriteInvoicesJournalEntries(agenda);
new OrganizationSetupJob(agenda);

View File

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

View File

@@ -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.');
}
}

View File

@@ -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();
}
}
}

View File

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

View File

@@ -1,6 +0,0 @@
import moment from 'moment';
import { IPaymentModel } from '@/interfaces';
export default class PaymentMethod implements IPaymentModel {
}

View File

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

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

View File

@@ -1,15 +0,0 @@
exports.up = function(knex) {
return knex.schema.createTable('subscription_plan_features', table => {
table.increments();
table.integer('plan_id').unsigned().index().references('id').inTable('subscription_plans');
table.string('slug');
table.string('name');
table.string('description');
table.timestamps();
});
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('subscription_plan_features');
};

View File

@@ -1,22 +0,0 @@
exports.up = function(knex) {
return knex.schema.createTable('subscription_licenses', (table) => {
table.increments();
table.string('license_code').unique().index();
table.integer('plan_id').unsigned().index().references('id').inTable('subscription_plans');
table.integer('license_period').unsigned();
table.string('period_interval');
table.dateTime('sent_at').index();
table.dateTime('disabled_at').index();
table.dateTime('used_at').index();
table.timestamps();
})
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('subscription_licenses');
};

View File

@@ -1,36 +0,0 @@
import { Model, mixin } from 'objection';
import SystemModel from '@/system/models/SystemModel';
export default class PlanFeature extends mixin(SystemModel) {
/**
* Table name.
*/
static get tableName() {
return 'subscriptions.plan_features';
}
/**
* Timestamps columns.
*/
static get timestamps() {
return ['createdAt', 'updatedAt'];
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const Plan = require('system/models/Subscriptions/Plan');
return {
plan: {
relation: Model.BelongsToOneRelation,
modelClass: Plan.default,
join: {
from: 'subscriptions.plan_features.planId',
to: 'subscriptions.plans.id',
},
},
};
}
}

View File

@@ -147,9 +147,9 @@ export default class Tenant extends BaseModel {
/**
* Marks the given tenant as upgrading.
* @param {number} tenantId
* @param {string} upgradeJobId
* @returns
* @param {number} tenantId
* @param {string} upgradeJobId
* @returns
*/
static markAsUpgrading(tenantId, upgradeJobId) {
return this.query().update({ upgradeJobId }).where({ id: tenantId });
@@ -157,8 +157,8 @@ export default class Tenant extends BaseModel {
/**
* Markes the given tenant as upgraded.
* @param {number} tenantId
* @returns
* @param {number} tenantId
* @returns
*/
static markAsUpgraded(tenantId) {
return this.query().update({ upgradeJobId: null }).where({ id: tenantId });
@@ -185,4 +185,44 @@ export default class Tenant extends BaseModel {
saveMetadata(metadata) {
return Tenant.saveMetadata(this.id, metadata);
}
/**
*
* @param {*} planId
* @param {*} invoiceInterval
* @param {*} invoicePeriod
* @param {*} subscriptionSlug
* @returns
*/
public newSubscription(planId, invoiceInterval, invoicePeriod, subscriptionSlug) {
return Tenant.newSubscription(
this.id,
planId,
invoiceInterval,
invoicePeriod,
subscriptionSlug
);
);
}
/**
* Records a new subscription for the associated tenant.
*/
static newSubscription(
tenantId: number,
planId: number,
invoiceInterval: string,
invoicePeriod: number,
subscriptionSlug: string
) {
const period = new SubscriptionPeriod(invoiceInterval, invoicePeriod);
return PlanSubscription.query().insert({
tenantId,
slug: subscriptionSlug,
planId,
startsAt: period.getStartDate(),
endsAt: period.getEndDate(),
});
}
}

View File

@@ -1,5 +1,4 @@
import Plan from './Subscriptions/Plan';
import PlanFeature from './Subscriptions/PlanFeature';
import PlanSubscription from './Subscriptions/PlanSubscription';
import License from './Subscriptions/License';
import Tenant from './Tenant';
@@ -12,7 +11,6 @@ import { Import } from './Import';
export {
Plan,
PlanFeature,
PlanSubscription,
License,
Tenant,