From 693ae61141e2ad566dd09604d818f2de29027687 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 14 Apr 2024 10:33:29 +0200 Subject: [PATCH] feat: integrate LemonSqueezy to subscription payment --- .env.example | 6 ++ .../src/api/controllers/Subscription/index.ts | 51 +++++++-- packages/server/src/loaders/express.ts | 8 +- .../Subscription/LemonSqueezyService.ts | 37 +++++++ .../services/Subscription/LemonWebhooks.ts | 99 +++++++++++++++++ .../server/src/services/Subscription/utils.ts | 102 ++++++++++++++++++ packages/webapp/public/index.html | 1 + .../containers/Setup/SetupSubscription.tsx | 22 +++- .../SetupSubscriptionForm.tsx | 29 +++-- .../webapp/src/hooks/query/subscriptions.tsx | 33 ++++-- 10 files changed, 363 insertions(+), 25 deletions(-) create mode 100644 packages/server/src/services/Subscription/LemonSqueezyService.ts create mode 100644 packages/server/src/services/Subscription/LemonWebhooks.ts create mode 100644 packages/server/src/services/Subscription/utils.ts diff --git a/.env.example b/.env.example index 72882dd40..87ed7d226 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/packages/server/src/api/controllers/Subscription/index.ts b/packages/server/src/api/controllers/Subscription/index.ts index 6145e7551..02e00a6ba 100644 --- a/packages/server/src/api/controllers/Subscription/index.ts +++ b/packages/server/src/api/controllers/Subscription/index.ts @@ -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); + } + } } diff --git a/packages/server/src/loaders/express.ts b/packages/server/src/loaders/express.ts index 70b4fd309..92589f70f 100644 --- a/packages/server/src/loaders/express.ts +++ b/packages/server/src/loaders/express.ts @@ -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()); diff --git a/packages/server/src/services/Subscription/LemonSqueezyService.ts b/packages/server/src/services/Subscription/LemonSqueezyService.ts new file mode 100644 index 000000000..53b1cdcf3 --- /dev/null +++ b/packages/server/src/services/Subscription/LemonSqueezyService.ts @@ -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!', + }, + }); + } +} diff --git a/packages/server/src/services/Subscription/LemonWebhooks.ts b/packages/server/src/services/Subscription/LemonWebhooks.ts new file mode 100644 index 000000000..68565ad92 --- /dev/null +++ b/packages/server/src/services/Subscription/LemonWebhooks.ts @@ -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, + 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 */ + } + } + } +} diff --git a/packages/server/src/services/Subscription/utils.ts b/packages/server/src/services/Subscription/utils.ts new file mode 100644 index 000000000..b41e8ce5e --- /dev/null +++ b/packages/server/src/services/Subscription/utils.ts @@ -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 { + 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 & { + 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); +} diff --git a/packages/webapp/public/index.html b/packages/webapp/public/index.html index b201cba67..1af330a07 100644 --- a/packages/webapp/public/index.html +++ b/packages/webapp/public/index.html @@ -51,5 +51,6 @@ href="https://cdnjs.cloudflare.com/ajax/libs/flexboxgrid/6.3.1/flexboxgrid.min.css" type="text/css" /> + diff --git a/packages/webapp/src/containers/Setup/SetupSubscription.tsx b/packages/webapp/src/containers/Setup/SetupSubscription.tsx index 7b0d419b5..866ed0bcc 100644 --- a/packages/webapp/src/containers/Setup/SetupSubscription.tsx +++ b/packages/webapp/src/containers/Setup/SetupSubscription.tsx @@ -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( diff --git a/packages/webapp/src/containers/Setup/SetupSubscription/SetupSubscriptionForm.tsx b/packages/webapp/src/containers/Setup/SetupSubscription/SetupSubscriptionForm.tsx index 1029bf0d4..52eed3f5f 100644 --- a/packages/webapp/src/containers/Setup/SetupSubscription/SetupSubscriptionForm.tsx +++ b/packages/webapp/src/containers/Setup/SetupSubscription/SetupSubscriptionForm.tsx @@ -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 ( -
- - - +
+
); } + +export default function SetupSubscriptionForm() { + return ( +
+
+ + + +
+
+ ); +} diff --git a/packages/webapp/src/hooks/query/subscriptions.tsx b/packages/webapp/src/hooks/query/subscriptions.tsx index 38d601ea5..c81e78cc6 100644 --- a/packages/webapp/src/hooks/query/subscriptions.tsx +++ b/packages/webapp/src/hooks/query/subscriptions.tsx @@ -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]) -}; \ No newline at end of file + }, [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, + }, + ); +};