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

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