first
This commit is contained in:
commit
8538ab969a
434 changed files with 44051 additions and 0 deletions
3
packages/payments/biome.json
Normal file
3
packages/payments/biome.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": ["../../biome.json"]
|
||||
}
|
||||
2
packages/payments/index.ts
Normal file
2
packages/payments/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./provider";
|
||||
export * from "./src/lib/customer";
|
||||
32
packages/payments/package.json
Normal file
32
packages/payments/package.json
Normal 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"
|
||||
}
|
||||
220
packages/payments/provider/creem/index.ts
Normal file
220
packages/payments/provider/creem/index.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
1
packages/payments/provider/index.ts
Normal file
1
packages/payments/provider/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./stripe";
|
||||
246
packages/payments/provider/lemonsqueezy/index.ts
Normal file
246
packages/payments/provider/lemonsqueezy/index.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
181
packages/payments/provider/polar/index.ts
Normal file
181
packages/payments/provider/polar/index.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
254
packages/payments/provider/stripe/index.ts
Normal file
254
packages/payments/provider/stripe/index.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
36
packages/payments/src/lib/customer.ts
Normal file
36
packages/payments/src/lib/customer.ts
Normal 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;
|
||||
};
|
||||
85
packages/payments/src/lib/helper.ts
Normal file
85
packages/payments/src/lib/helper.ts
Normal 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 };
|
||||
}
|
||||
5
packages/payments/tsconfig.json
Normal file
5
packages/payments/tsconfig.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "@repo/tsconfig/base.json",
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
33
packages/payments/types.ts
Normal file
33
packages/payments/types.ts
Normal 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;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue