mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-17 05:10:31 +00:00
feat: integrate LemonSqueezy to subscription payment
This commit is contained in:
@@ -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!',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
99
packages/server/src/services/Subscription/LemonWebhooks.ts
Normal file
99
packages/server/src/services/Subscription/LemonWebhooks.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
102
packages/server/src/services/Subscription/utils.ts
Normal file
102
packages/server/src/services/Subscription/utils.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user