better-auth/packages/payments/provider/lemonsqueezy/index.ts

247 lines
5.3 KiB
TypeScript
Raw Permalink Normal View History

2025-10-29 13:43:43 +05:00
import { createHmac, timingSafeEqual } from "node:crypto";
import {
cancelSubscription as cancelSubscriptionResolver,
createCheckout,
getCustomer,
getSubscription,
lemonSqueezySetup,
updateSubscriptionItem,
} from "@lemonsqueezy/lemonsqueezy.js";
import {
createPurchase,
deletePurchaseBySubscriptionId,
getPurchaseBySubscriptionId,
updatePurchase,
} from "@repo/database";
import { setCustomerIdToEntity } from "../../src/lib/customer";
import type {
CancelSubscription,
CreateCheckoutLink,
CreateCustomerPortalLink,
SetSubscriptionSeats,
WebhookHandler,
} from "../../types";
function initLemonsqueezyApi() {
lemonSqueezySetup({
apiKey: process.env.LEMONSQUEEZY_API_KEY as string,
});
}
export const createCheckoutLink: CreateCheckoutLink = async (options) => {
initLemonsqueezyApi();
const { customerId, seats, productId, redirectUrl, email, name } = options;
const response = await createCheckout(
String(process.env.LEMONSQUEEZY_STORE_ID),
productId,
{
productOptions: {
redirectUrl,
enabledVariants: [Number.parseInt(productId)],
},
checkoutData: {
email,
name,
variantQuantities: [
{
variantId: Number.parseInt(productId),
quantity: seats ?? 1,
},
],
custom:
"organizationId" in options
? {
organization_id: options.organizationId,
}
: {
user_id: options.userId,
},
},
},
);
return response.data?.data.attributes.url ?? null;
};
export const createCustomerPortalLink: CreateCustomerPortalLink = async ({
customerId,
}) => {
initLemonsqueezyApi();
const response = await getCustomer(customerId);
return response.data?.data.attributes.urls.customer_portal ?? null;
};
export const setSubscriptionSeats: SetSubscriptionSeats = async ({
id,
seats,
}) => {
initLemonsqueezyApi();
const subscription = await getSubscription(id, {
include: ["subscription-items"],
});
if (!subscription) {
throw new Error("Subscription not found.");
}
const subscriptionItem =
subscription.data?.data.relationships["subscription-items"].data?.[0];
if (!subscriptionItem) {
throw new Error("Subscription item not found.");
}
await updateSubscriptionItem(subscriptionItem.id, {
quantity: seats,
});
};
export const cancelSubscription: CancelSubscription = async (id) => {
initLemonsqueezyApi();
await cancelSubscriptionResolver(id);
};
export const webhookHandler: WebhookHandler = async (req: Request) => {
try {
const text = await req.text();
const hmac = createHmac(
"sha256",
process.env.LEMONSQUEEZY_WEBHOOK_SECRET as string,
);
const digest = Buffer.from(hmac.update(text).digest("hex"), "utf8");
const signature = Buffer.from(
req.headers.get("x-signature") as string,
"utf8",
);
if (!timingSafeEqual(digest, signature)) {
return new Response("Invalid signature.", {
status: 400,
});
}
const payload = JSON.parse(text) as {
meta: {
event_name: string;
custom_data: {
organization_id?: string;
user_id?: string;
};
};
data: {
id: string;
attributes: {
customer_id: string;
product_id: string;
variant_id: string;
status: string;
trial_ends_at?: number;
renews_at?: number;
};
};
} | null;
if (!payload) {
return new Response("Invalid payload.", {
status: 400,
});
}
const {
meta: { event_name: eventName, custom_data: customData },
data,
} = payload;
const id = String(data.id);
switch (eventName) {
case "subscription_created": {
await createPurchase({
organizationId: customData.organization_id,
userId: customData.user_id,
subscriptionId: id,
customerId: String(data.attributes.customer_id),
productId: String(data.attributes.variant_id),
status: data.attributes.status,
type: "SUBSCRIPTION",
});
await setCustomerIdToEntity(
String(data.attributes.customer_id),
{
organizationId: customData.organization_id,
userId: customData.user_id,
},
);
break;
}
case "subscription_updated":
case "subscription_cancelled":
case "subscription_resumed": {
const subscriptionId = String(data.id);
const existingPurchase =
await getPurchaseBySubscriptionId(subscriptionId);
if (existingPurchase) {
await updatePurchase({
id: existingPurchase.id,
status: data.attributes.status,
});
}
break;
}
case "subscription_expired": {
const subscriptionId = String(data.id);
await deletePurchaseBySubscriptionId(subscriptionId);
break;
}
case "order_created": {
await createPurchase({
organizationId: customData.organization_id,
userId: customData.user_id,
customerId: String(data.attributes.customer_id),
productId: String(data.attributes.product_id),
type: "ONE_TIME",
});
await setCustomerIdToEntity(
String(data.attributes.customer_id),
{
organizationId: customData.organization_id,
userId: customData.user_id,
},
);
break;
}
default: {
return new Response("Unhandled event type.", {
status: 200,
});
}
}
return new Response(null, { status: 204 });
} catch (error) {
return new Response(
`Webhook error: ${error instanceof Error ? error.message : ""}`,
{
status: 400,
},
);
}
};