This commit is contained in:
 Илья Шалонин 2025-10-29 13:43:43 +05:00
commit 8538ab969a
434 changed files with 44051 additions and 0 deletions

View file

@ -0,0 +1,3 @@
{
"extends": ["../../biome.json"]
}

View file

@ -0,0 +1,2 @@
export * from "./provider";
export * from "./src/lib/customer";

View file

@ -0,0 +1,32 @@
{
"dependencies": {
"@lemonsqueezy/lemonsqueezy.js": "^4.0.0",
"@polar-sh/sdk": "^0.33.0",
"@repo/config": "workspace:*",
"@repo/database": "workspace:*",
"@repo/logs": "workspace:*",
"chargebee-typescript": "^2.48.0",
"stripe": "^18.2.1",
"ufo": "^1.6.1"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@repo/tsconfig": "workspace:*",
"@types/node": "^22.15.30"
},
"main": "./index.ts",
"exports": {
".": {
"types": "./index.ts",
"default": "./index.ts"
},
"./types": "./types.ts",
"./lib/helper": "./src/lib/helper.ts"
},
"name": "@repo/payments",
"scripts": {
"type-check": "tsc --noEmit"
},
"types": "./**/.ts",
"version": "0.0.0"
}

View file

@ -0,0 +1,220 @@
import { createHmac } from "node:crypto";
import {
createPurchase,
deletePurchaseBySubscriptionId,
getPurchaseBySubscriptionId,
updatePurchase,
} from "@repo/database";
import { logger } from "@repo/logs";
import { joinURL } from "ufo";
import type {
CancelSubscription,
CreateCheckoutLink,
CreateCustomerPortalLink,
SetSubscriptionSeats,
WebhookHandler,
} from "../../types";
export function creemFetch(path: string, init: Parameters<typeof fetch>[1]) {
const creemApiKey = process.env.CREEM_API_KEY as string;
if (!creemApiKey) {
throw new Error("Missing env variable CREEM_API_KEY");
}
const baseUrl =
process.env.NODE_ENV === "production"
? "https://api.creem.io/v1"
: "https://test-api.creem.io/v1";
const requestUrl = joinURL(baseUrl, path);
return fetch(requestUrl, {
...init,
headers: {
"x-api-key": creemApiKey,
"Content-Type": "application/json",
},
});
}
export const createCheckoutLink: CreateCheckoutLink = async (options) => {
const { productId, redirectUrl, organizationId, userId, seats, email } =
options;
const response = await creemFetch("/checkouts", {
method: "POST",
body: JSON.stringify({
product_id: productId,
units: seats ?? 1,
success_url: redirectUrl ?? undefined,
metadata: {
organization_id: organizationId || null,
user_id: userId || null,
},
customer: {
email,
},
}),
});
if (!response.ok) {
logger.error("Failed to create checkout link", await response.json());
throw new Error("Failed to create checkout link");
}
const { checkout_url } = await response.json();
return checkout_url;
};
export const createCustomerPortalLink: CreateCustomerPortalLink = async ({
customerId,
}) => {
const response = await creemFetch("/customers/billing", {
method: "POST",
body: JSON.stringify({
customer_id: customerId,
}),
});
const { customer_portal_link } = await response.json();
return customer_portal_link;
};
export const setSubscriptionSeats: SetSubscriptionSeats = async ({
id,
seats,
}) => {
const response = await creemFetch(`/subscriptions?subscription_id=${id}`, {
method: "GET",
});
const { items } = await response.json();
await creemFetch(`/subscriptions/${id}`, {
method: "POST",
body: JSON.stringify({
items: [
{
id: items[0].id,
quantity: seats,
},
],
}),
});
};
export const cancelSubscription: CancelSubscription = async (id) => {
await creemFetch(`/subscriptions/${id}/cancel`, {
method: "POST",
});
};
export const webhookHandler: WebhookHandler = async (req) => {
if (req.method !== "POST") {
return new Response("Method not allowed.", {
status: 405,
});
}
const signature = req.headers.get("creem-signature");
if (!signature) {
return new Response("Missing signature.", {
status: 400,
});
}
const secret = process.env.CREEM_WEBHOOK_SECRET as string;
if (!secret) {
return new Response("Missing webhook secret.", {
status: 400,
});
}
const bodyText = await req.text();
const computedSignature = createHmac("sha256", secret)
.update(bodyText)
.digest("hex");
if (computedSignature !== signature) {
return new Response("Invalid signature.", {
status: 400,
});
}
const payload = JSON.parse(bodyText);
try {
switch (payload.eventType) {
case "checkout.completed": {
const { product, metadata, object, customer } = payload.object;
if (!product?.id) {
return new Response("Missing product ID.", {
status: 400,
});
}
if (object === "subscription") {
break;
}
await createPurchase({
organizationId: metadata?.organization_id || null,
userId: metadata?.user_id || null,
customerId: customer as string,
type: "ONE_TIME",
productId: product.id,
});
break;
}
case "subscription.active": {
const { id, customer, product, metadata } = payload.object;
const existingPurchase = await getPurchaseBySubscriptionId(id);
if (existingPurchase) {
await updatePurchase({
id: existingPurchase.id,
status: product.status,
productId: product.id,
});
break;
}
await createPurchase({
subscriptionId: id,
customerId: customer.id,
type: "SUBSCRIPTION",
productId: product.id,
organizationId: metadata?.organization_id || null,
userId: metadata?.user_id || null,
});
break;
}
case "subscription.canceled":
case "subscription.expired": {
const { id } = payload.object;
await deletePurchaseBySubscriptionId(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,
},
);
}
};

View file

@ -0,0 +1 @@
export * from "./stripe";

View file

@ -0,0 +1,246 @@
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,
},
);
}
};

View file

@ -0,0 +1,181 @@
import { Polar } from "@polar-sh/sdk";
import {
WebhookVerificationError,
validateEvent,
} from "@polar-sh/sdk/webhooks.js";
import {
createPurchase,
deletePurchaseBySubscriptionId,
getPurchaseBySubscriptionId,
updatePurchase,
} from "@repo/database";
import { setCustomerIdToEntity } from "../../src/lib/customer";
import type {
CancelSubscription,
CreateCheckoutLink,
CreateCustomerPortalLink,
SetSubscriptionSeats,
WebhookHandler,
} from "../../types";
const polarAccessToken = process.env.POLAR_ACCESS_TOKEN as string;
const polarWebhookSecret = process.env.POLAR_WEBHOOK_SECRET as string;
if (!polarAccessToken) {
throw new Error("Missing env variable POLAR_ACCESS_TOKEN");
}
if (!polarWebhookSecret) {
throw new Error("Missing env variable POLAR_WEBHOOK_SECRET");
}
const polarClient = new Polar({
accessToken: polarAccessToken,
server: process.env.NODE_ENV === "production" ? "production" : "sandbox",
});
export const createCheckoutLink: CreateCheckoutLink = async (options) => {
const { productId, redirectUrl, customerId, organizationId, userId } =
options;
const metadata: Record<string, string> = {};
if (organizationId) {
metadata.organization_id = organizationId;
}
if (userId) {
metadata.user_id = userId;
}
const response = await polarClient.checkouts.create({
products: [productId],
successUrl: redirectUrl ?? "",
metadata,
customerId: customerId || undefined,
});
return response.url;
};
export const createCustomerPortalLink: CreateCustomerPortalLink = async ({
customerId,
}) => {
const response = await polarClient.customerSessions.create({
customerId: customerId,
});
return response.customerPortalUrl;
};
export const setSubscriptionSeats: SetSubscriptionSeats = async () => {
throw new Error("Not implemented");
};
export const cancelSubscription: CancelSubscription = async (id) => {
await polarClient.subscriptions.revoke({
id,
});
};
export const webhookHandler: WebhookHandler = async (req) => {
try {
if (!req.body) {
return new Response("No body", {
status: 400,
});
}
const event = validateEvent(
await req.text(),
Object.fromEntries(req.headers.entries()),
polarWebhookSecret,
);
switch (event.type) {
case "order.created": {
const { metadata, customerId, subscription, productId } =
event.data;
if (subscription) {
break;
}
await createPurchase({
organizationId:
(metadata?.organization_id as string) || null,
userId: (metadata?.user_id as string) || null,
customerId,
type: "ONE_TIME",
productId,
});
await setCustomerIdToEntity(customerId, {
organizationId: metadata?.organization_id as string,
userId: metadata?.user_id as string,
});
break;
}
case "subscription.created": {
const { metadata, customerId, productId, id, status } =
event.data;
await createPurchase({
subscriptionId: id,
organizationId: metadata?.organization_id as string,
userId: metadata?.user_id as string,
customerId,
type: "SUBSCRIPTION",
productId,
status,
});
await setCustomerIdToEntity(customerId, {
organizationId: metadata?.organization_id as string,
userId: metadata?.user_id as string,
});
break;
}
case "subscription.updated": {
const { id, status, productId } = event.data;
const existingPurchase = await getPurchaseBySubscriptionId(id);
if (existingPurchase) {
await updatePurchase({
id: existingPurchase.id,
status,
productId,
});
}
break;
}
case "subscription.canceled": {
const { id } = event.data;
await deletePurchaseBySubscriptionId(id);
break;
}
default:
return new Response("Unhandled event type.", {
status: 200,
});
}
return new Response(null, {
status: 202,
});
} catch (error) {
if (error instanceof WebhookVerificationError) {
return new Response("Invalid request.", {
status: 403,
});
}
throw error;
}
};

View file

@ -0,0 +1,254 @@
import {
createPurchase,
deletePurchaseBySubscriptionId,
getPurchaseBySubscriptionId,
updatePurchase,
} from "@repo/database";
import { logger } from "@repo/logs";
import Stripe from "stripe";
import { setCustomerIdToEntity } from "../../src/lib/customer";
import type {
CancelSubscription,
CreateCheckoutLink,
CreateCustomerPortalLink,
SetSubscriptionSeats,
WebhookHandler,
} from "../../types";
let stripeClient: Stripe | null = null;
export function getStripeClient() {
if (stripeClient) {
return stripeClient;
}
const stripeSecretKey = process.env.STRIPE_SECRET_KEY as string;
if (!stripeSecretKey) {
throw new Error("Missing env variable STRIPE_SECRET_KEY");
}
stripeClient = new Stripe(stripeSecretKey);
return stripeClient;
}
export const createCheckoutLink: CreateCheckoutLink = async (options) => {
const stripeClient = getStripeClient();
const {
type,
productId,
redirectUrl,
customerId,
organizationId,
userId,
trialPeriodDays,
seats,
email,
} = options;
const metadata = {
organization_id: organizationId || null,
user_id: userId || null,
};
const response = await stripeClient.checkout.sessions.create({
mode: type === "subscription" ? "subscription" : "payment",
success_url: redirectUrl ?? "",
line_items: [
{
quantity: seats ?? 1,
price: productId,
},
],
customer: customerId,
customer_email: email,
...(type === "one-time"
? {
payment_intent_data: {
metadata,
},
customer_creation: "always",
}
: {
subscription_data: {
metadata,
trial_period_days: trialPeriodDays,
},
}),
metadata,
});
return response.url;
};
export const createCustomerPortalLink: CreateCustomerPortalLink = async ({
customerId,
redirectUrl,
}) => {
const stripeClient = getStripeClient();
const response = await stripeClient.billingPortal.sessions.create({
customer: customerId,
return_url: redirectUrl ?? "",
});
return response.url;
};
export const setSubscriptionSeats: SetSubscriptionSeats = async ({
id,
seats,
}) => {
const stripeClient = getStripeClient();
const subscription = await stripeClient.subscriptions.retrieve(id);
if (!subscription) {
throw new Error("Subscription not found.");
}
await stripeClient.subscriptions.update(id, {
items: [
{
id: subscription.items.data[0].id,
quantity: seats,
},
],
});
};
export const cancelSubscription: CancelSubscription = async (id) => {
const stripeClient = getStripeClient();
await stripeClient.subscriptions.cancel(id);
};
export const webhookHandler: WebhookHandler = async (req) => {
const stripeClient = getStripeClient();
if (!req.body) {
return new Response("Invalid request.", {
status: 400,
});
}
let event: Stripe.Event | undefined;
try {
event = await stripeClient.webhooks.constructEventAsync(
await req.text(),
req.headers.get("stripe-signature") as string,
process.env.STRIPE_WEBHOOK_SECRET as string,
);
} catch (e) {
logger.error(e);
return new Response("Invalid request.", {
status: 400,
});
}
try {
switch (event.type) {
case "checkout.session.completed": {
const { mode, metadata, customer, id } = event.data.object;
if (mode === "subscription") {
break;
}
const checkoutSession =
await stripeClient.checkout.sessions.retrieve(id, {
expand: ["line_items"],
});
const productId = checkoutSession.line_items?.data[0].price?.id;
if (!productId) {
return new Response("Missing product ID.", {
status: 400,
});
}
await createPurchase({
organizationId: metadata?.organization_id || null,
userId: metadata?.user_id || null,
customerId: customer as string,
type: "ONE_TIME",
productId,
});
await setCustomerIdToEntity(customer as string, {
organizationId: metadata?.organization_id,
userId: metadata?.user_id,
});
break;
}
case "customer.subscription.created": {
const { metadata, customer, items, id } = event.data.object;
const productId = items?.data[0].price?.id;
if (!productId) {
return new Response("Missing product ID.", {
status: 400,
});
}
await createPurchase({
subscriptionId: id,
organizationId: metadata?.organization_id || null,
userId: metadata?.user_id || null,
customerId: customer as string,
type: "SUBSCRIPTION",
productId,
status: event.data.object.status,
});
await setCustomerIdToEntity(customer as string, {
organizationId: metadata?.organization_id,
userId: metadata?.user_id,
});
break;
}
case "customer.subscription.updated": {
const subscriptionId = event.data.object.id;
const existingPurchase =
await getPurchaseBySubscriptionId(subscriptionId);
if (existingPurchase) {
await updatePurchase({
id: existingPurchase.id,
status: event.data.object.status,
productId: event.data.object.items?.data[0].price?.id,
});
}
break;
}
case "customer.subscription.deleted": {
await deletePurchaseBySubscriptionId(event.data.object.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,
},
);
}
};

View file

@ -0,0 +1,36 @@
import {
getOrganizationById,
getUserById,
updateOrganization,
updateUser,
} from "@repo/database";
export async function setCustomerIdToEntity(
customerId: string,
{ organizationId, userId }: { organizationId?: string; userId?: string },
) {
if (organizationId) {
await updateOrganization({
id: organizationId,
paymentsCustomerId: customerId,
});
} else if (userId) {
await updateUser({
id: userId,
paymentsCustomerId: customerId,
});
}
}
export const getCustomerIdFromEntity = async (
props: { organizationId: string } | { userId: string },
) => {
if ("organizationId" in props) {
return (
(await getOrganizationById(props.organizationId))
?.paymentsCustomerId ?? null
);
}
return (await getUserById(props.userId))?.paymentsCustomerId ?? null;
};

View file

@ -0,0 +1,85 @@
import { type Config, config } from "@repo/config";
import type { Purchase } from "@repo/database";
const plans = config.payments.plans as Config["payments"]["plans"];
type PlanId = keyof typeof config.payments.plans;
type PurchaseWithoutTimestamps = Omit<Purchase, "createdAt" | "updatedAt">;
function getActivePlanFromPurchases(purchases?: PurchaseWithoutTimestamps[]) {
const subscriptionPurchase = purchases?.find(
(purchase) => purchase.type === "SUBSCRIPTION",
);
if (subscriptionPurchase) {
const plan = Object.entries(plans).find(([_, plan]) =>
plan.prices?.some(
(price) => price.productId === subscriptionPurchase.productId,
),
);
return {
id: plan?.[0] as PlanId,
price: plan?.[1].prices?.find(
(price) => price.productId === subscriptionPurchase.productId,
),
status: subscriptionPurchase.status,
purchaseId: subscriptionPurchase.id,
};
}
const oneTimePurchase = purchases?.find(
(purchase) => purchase.type === "ONE_TIME",
);
if (oneTimePurchase) {
const plan = Object.entries(plans).find(([_, plan]) =>
plan.prices?.some(
(price) => price.productId === oneTimePurchase.productId,
),
);
return {
id: plan?.[0] as PlanId,
price: plan?.[1].prices?.find(
(price) => price.productId === oneTimePurchase.productId,
),
status: "active",
purchaseId: oneTimePurchase.id,
};
}
const freePlan = Object.entries(plans).find(([_, plan]) => plan.isFree);
return freePlan
? {
id: freePlan[0] as PlanId,
status: "active",
}
: null;
}
export function createPurchasesHelper(purchases: PurchaseWithoutTimestamps[]) {
const activePlan = getActivePlanFromPurchases(purchases);
const hasSubscription = (planIds?: PlanId[] | PlanId) => {
return (
!!activePlan &&
(Array.isArray(planIds)
? planIds.includes(activePlan.id)
: planIds === activePlan.id)
);
};
const hasPurchase = (planId: PlanId) => {
return !!purchases?.some((purchase) =>
Object.entries(plans)
.find(([id]) => id === planId)?.[1]
.prices?.some(
(price) => price.productId === purchase.productId,
),
);
};
return { activePlan, hasSubscription, hasPurchase };
}

View file

@ -0,0 +1,5 @@
{
"extends": "@repo/tsconfig/base.json",
"include": ["**/*.ts"],
"exclude": ["dist", "build", "node_modules"]
}

View file

@ -0,0 +1,33 @@
export type CreateCheckoutLink = (params: {
type: "subscription" | "one-time";
productId: string;
email?: string;
name?: string;
redirectUrl?: string;
customerId?: string;
organizationId?: string;
userId?: string;
trialPeriodDays?: number;
seats?: number;
}) => Promise<string | null>;
export type CreateCustomerPortalLink = (params: {
subscriptionId?: string;
customerId: string;
redirectUrl?: string;
}) => Promise<string | null>;
export type SetSubscriptionSeats = (params: {
id: string;
seats: number;
}) => Promise<void>;
export type CancelSubscription = (id: string) => Promise<void>;
export type WebhookHandler = (req: Request) => Promise<Response>;
export type PaymentProvider = {
createCheckoutLink: CreateCheckoutLink;
createCustomerPortalLink: CreateCustomerPortalLink;
webhookHandler: WebhookHandler;
};