feat: integrate LemonSqueezy to subscription payment

This commit is contained in:
Ahmed Bouhuolia
2024-04-14 10:33:29 +02:00
parent 9807ac04b0
commit 693ae61141
10 changed files with 363 additions and 25 deletions

View File

@@ -95,3 +95,9 @@ PLAID_LINK_WEBHOOK=
PLAID_SANDBOX_REDIRECT_URI=
PLAID_DEVELOPMENT_REDIRECT_URI=
# https://docs.lemonsqueezy.com/guides/developer-guide/getting-started#create-an-api-key
LEMONSQUEEZY_API_KEY=
LEMONSQUEEZY_STORE_ID=
LEMONSQUEEZY_WEBHOOK_SECRET=

View File

@@ -1,16 +1,21 @@
import { Router, Request, Response, NextFunction } from 'express';
import { Container, Service, Inject } from 'typedi';
import { Service, Inject } from 'typedi';
import { body } from 'express-validator';
import JWTAuth from '@/api/middleware/jwtAuth';
import TenancyMiddleware from '@/api/middleware/TenancyMiddleware';
import AttachCurrentTenantUser from '@/api/middleware/AttachCurrentTenantUser';
import PaymentViaLicenseController from '@/api/controllers/Subscription/PaymentViaLicense';
import SubscriptionService from '@/services/Subscription/SubscriptionService';
import asyncMiddleware from '@/api/middleware/asyncMiddleware';
import BaseController from '../BaseController';
import { LemonSqueezyService } from '@/services/Subscription/LemonSqueezyService';
@Service()
export default class SubscriptionController {
export default class SubscriptionController extends BaseController {
@Inject()
subscriptionService: SubscriptionService;
private subscriptionService: SubscriptionService;
@Inject()
private lemonSqueezyService: LemonSqueezyService;
/**
* Router constructor.
@@ -22,7 +27,12 @@ export default class SubscriptionController {
router.use(AttachCurrentTenantUser);
router.use(TenancyMiddleware);
router.use('/license', Container.get(PaymentViaLicenseController).router());
router.post(
'/lemon/checkout_url',
[body('variantId').exists().trim()],
this.validationResult,
this.getCheckoutUrl.bind(this)
);
router.get('/', asyncMiddleware(this.getSubscriptions.bind(this)));
return router;
@@ -34,7 +44,11 @@ export default class SubscriptionController {
* @param {Response} res
* @param {NextFunction} next
*/
async getSubscriptions(req: Request, res: Response, next: NextFunction) {
private async getSubscriptions(
req: Request,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
try {
@@ -46,4 +60,29 @@ export default class SubscriptionController {
next(error);
}
}
/**
* Retrieves the LemonSqueezy checkout url.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
private async getCheckoutUrl(
req: Request,
res: Response,
next: NextFunction
) {
const { variantId } = this.matchedBodyData(req);
const { user } = req;
try {
const checkout = await this.lemonSqueezyService.getCheckout(
variantId,
user
);
return res.status(200).send(checkout);
} catch (error) {
next(error);
}
}
}

View File

@@ -36,7 +36,13 @@ export default ({ app }) => {
// Boom response objects.
app.use(boom());
app.use(bodyParser.json());
app.use(
bodyParser.json({
verify: (req, res, buf) => {
req.rawBody = buf;
},
})
);
// Parses both json and urlencoded.
app.use(json());

View File

@@ -0,0 +1,37 @@
import { Service } from 'typedi';
import { createCheckout } from '@lemonsqueezy/lemonsqueezy.js';
import { SystemUser } from '@/system/models';
import { configureLemonSqueezy } from './utils';
@Service()
export class LemonSqueezyService {
/**
* Retrieves the LemonSqueezy checkout url.
* @param {number} variantId
* @param {SystemUser} user
*/
async getCheckout(variantId: number, user: SystemUser) {
configureLemonSqueezy();
return createCheckout(process.env.LEMONSQUEEZY_STORE_ID!, variantId, {
checkoutOptions: {
embed: true,
media: true,
logo: true,
},
checkoutData: {
email: user.email,
custom: {
user_id: user.id + '',
tenant_id: user.tenantId + '',
},
},
productOptions: {
enabledVariants: [variantId],
redirectUrl: `http://localhost:4000/dashboard/billing/`,
receiptButtonText: 'Go to Dashboard',
receiptThankYouNote: 'Thank you for signing up to Lemon Stand!',
},
});
}
}

View File

@@ -0,0 +1,99 @@
import { getPrice } from '@lemonsqueezy/lemonsqueezy.js';
import { ServiceError } from '@/exceptions';
import { Service } from 'typedi';
import {
compareSignatures,
configureLemonSqueezy,
createHmacSignature,
webhookHasData,
webhookHasMeta,
} from './utils';
import { Plan } from '@/system/models';
@Service()
export class LemonWebhooks {
/**
*
* @param {string} rawBody
* @param {string} signature
* @returns
*/
public async handlePostWebhook(
rawData: any,
data: Record<string, any>,
signature: string
) {
configureLemonSqueezy();
if (!process.env.LEMONSQUEEZY_WEBHOOK_SECRET) {
return 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 });
}
// 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 });
}
/**
* This action will process a webhook event in the database.
*/
async processWebhookEvent(eventBody) {
let processingError = '';
const webhookEvent = eventBody.meta.event_name;
if (!webhookHasMeta(eventBody)) {
processingError = "Event body is missing the 'meta' property.";
} else if (webhookHasData(eventBody)) {
if (webhookEvent.startsWith('subscription_payment_')) {
// Save subscription invoices; eventBody is a SubscriptionInvoice
// Not implemented.
} else if (webhookEvent.startsWith('subscription_')) {
// Save subscription events; obj is a Subscription
const attributes = eventBody.data.attributes;
const variantId = attributes.variant_id as string;
// We assume that the Plan table is up to date.
const plan = await Plan.query().findOne('slug', 'essentials-yearly');
if (!plan) {
processingError = `Plan with variantId ${variantId} not found.`;
} else {
// Update the subscription in the database.
const priceId = attributes.first_subscription_item.price_id;
// Get the price data from Lemon Squeezy.
const priceData = await getPrice(priceId);
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 = {};
}
} else if (webhookEvent.startsWith('order_')) {
// Save orders; eventBody is a "Order"
/* Not implemented */
} else if (webhookEvent.startsWith('license_')) {
// Save license keys; eventBody is a "License key"
/* Not implemented */
}
}
}
}

View File

@@ -0,0 +1,102 @@
import { lemonSqueezySetup } from '@lemonsqueezy/lemonsqueezy.js';
/**
* Ensures that required environment variables are set and sets up the Lemon
* Squeezy JS SDK. Throws an error if any environment variables are missing or
* if there's an error setting up the SDK.
*/
export function configureLemonSqueezy() {
const requiredVars = [
'LEMONSQUEEZY_API_KEY',
'LEMONSQUEEZY_STORE_ID',
'LEMONSQUEEZY_WEBHOOK_SECRET',
];
const missingVars = requiredVars.filter((varName) => !process.env[varName]);
if (missingVars.length > 0) {
throw new Error(
`Missing required LEMONSQUEEZY env variables: ${missingVars.join(
', '
)}. Please, set them in your .env file.`
);
}
lemonSqueezySetup({
apiKey: process.env.LEMONSQUEEZY_API_KEY,
onError: (error) => {
console.log(error);
// console.log('LL', error.message);
// eslint-disable-next-line no-console -- allow logging
// console.error(error);
// throw new Error(`Lemon Squeezy API error: ${error.message}`);
},
});
}
/**
* Check if the value is an object.
*/
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
/**
* Typeguard to check if the object has a 'meta' property
* and that the 'meta' property has the correct shape.
*/
export function webhookHasMeta(obj: unknown): obj is {
meta: {
event_name: string;
custom_data: {
user_id: string;
};
};
} {
if (
isObject(obj) &&
isObject(obj.meta) &&
typeof obj.meta.event_name === 'string' &&
isObject(obj.meta.custom_data) &&
typeof obj.meta.custom_data.user_id === 'string'
) {
return true;
}
return false;
}
/**
* Typeguard to check if the object has a 'data' property and the correct shape.
*
* @param obj - The object to check.
* @returns True if the object has a 'data' property.
*/
export function webhookHasData(obj: unknown): obj is {
data: {
attributes: Record<string, unknown> & {
first_subscription_item: {
id: number;
price_id: number;
is_usage_based: boolean;
};
};
id: string;
};
} {
return (
isObject(obj) &&
'data' in obj &&
isObject(obj.data) &&
'attributes' in obj.data
);
}
export function createHmacSignature(secretKey, body) {
return require('crypto')
.createHmac('sha256', secretKey)
.update(body)
.digest('hex');
}
export function compareSignatures(signature, comparison_signature) {
const source = Buffer.from(signature, 'utf8');
const comparison = Buffer.from(comparison_signature, 'utf8');
return require('crypto').timingSafeEqual(source, comparison);
}

View File

@@ -51,5 +51,6 @@
href="https://cdnjs.cloudflare.com/ajax/libs/flexboxgrid/6.3.1/flexboxgrid.min.css"
type="text/css"
/>
<script src="https://app.lemonsqueezy.com/js/lemon.js"></script>
</body>
</html>

View File

@@ -8,6 +8,7 @@ import '@/style/pages/Setup/Subscription.scss';
import SetupSubscriptionForm from './SetupSubscription/SetupSubscriptionForm';
import { getSubscriptionFormSchema } from './SubscriptionForm.schema';
import withSubscriptionPlansActions from '../Subscriptions/withSubscriptionPlansActions';
import { useGetLemonSqueezyCheckout } from '@/hooks/query/subscriptions';
/**
* Subscription step of wizard setup.
@@ -20,14 +21,33 @@ function SetupSubscription({
initSubscriptionPlans();
}, [initSubscriptionPlans]);
React.useEffect(() => {
window.LemonSqueezy.Setup({
eventHandler: (event) => {
// Do whatever you want with this event data
if (event.event === 'Checkout.Success') {
}
},
});
}, []);
// Initial values.
const initialValues = {
plan_slug: 'essentials',
period: 'month',
license_code: '',
};
const { mutateAsync: getLemonCheckout } = useGetLemonSqueezyCheckout();
// Handle form submit.
const handleSubmit = (values) => {};
const handleSubmit = (values) => {
getLemonCheckout({ variantId: '337977' })
.then((res) => {
const checkoutUrl = res.data.data.attributes.url;
window.LemonSqueezy.Url.Open(checkoutUrl);
})
.catch(() => {});
};
// Retrieve momerized subscription form schema.
const SubscriptionFormSchema = React.useMemo(

View File

@@ -1,17 +1,28 @@
// @ts-nocheck
import React from 'react';
import { Form } from 'formik';
import SubscriptionPlansSection from './SubscriptionPlansSection';
import SubscriptionPeriodsSection from './SubscriptionPeriodsSection';
import SubscriptionPaymentMethodsSection from './SubscriptionPaymentsMethodsSection';
import { Button, Intent } from '@blueprintjs/core';
import { T } from '@/components';
export default function SetupSubscriptionForm() {
function StepSubscriptionActions() {
return (
<div class="billing-plans">
<SubscriptionPlansSection />
<SubscriptionPeriodsSection />
<SubscriptionPaymentMethodsSection />
<div>
<Button type="submit" intent={Intent.PRIMARY} large={true}>
<T id={'submit_voucher'} />
</Button>
</div>
);
}
export default function SetupSubscriptionForm() {
return (
<Form>
<div class="billing-plans">
<SubscriptionPlansSection />
<SubscriptionPeriodsSection />
<StepSubscriptionActions />
</div>
</Form>
);
}

View File

@@ -1,8 +1,8 @@
// @ts-nocheck
import { useEffect } from "react"
import { useMutation, useQueryClient } from "react-query";
import { useRequestQuery } from "../useQueryRequest";
import useApiRequest from "../useRequest";
import { useEffect } from 'react';
import { useMutation, useQueryClient } from 'react-query';
import { useRequestQuery } from '../useQueryRequest';
import useApiRequest from '../useRequest';
import { useSetSubscriptions } from '../state/subscriptions';
import T from './types';
@@ -22,9 +22,9 @@ export const usePaymentByVoucher = (props) => {
queryClient.invalidateQueries(T.ORGANIZATIONS);
},
...props,
}
},
);
}
};
/**
* Fetches the organization subscriptions.
@@ -41,5 +41,22 @@ export const useOrganizationSubscriptions = (props) => {
if (state.isSuccess) {
setSubscriptions(state.data);
}
}, [state.isSuccess, state.data, setSubscriptions])
};
}, [state.isSuccess, state.data, setSubscriptions]);
};
/**
* Fetches the checkout url of the lemon squeezy.
*/
export const useGetLemonSqueezyCheckout = (props = {}) => {
const apiRequest = useApiRequest();
return useMutation(
(values) =>
apiRequest
.post('subscription/lemon/checkout_url', values)
.then((res) => res.data),
{
...props,
},
);
};