254 lines
5.3 KiB
TypeScript
254 lines
5.3 KiB
TypeScript
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,
|
|
},
|
|
);
|
|
}
|
|
};
|