first
This commit is contained in:
commit
8538ab969a
434 changed files with 44051 additions and 0 deletions
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue