feat: turbostarter boilerplate
Production-ready Next.js boilerplate with: - Runtime env validation (fail-fast on missing vars) - Feature-gated config (S3, Stripe, email, OAuth) - Docker + Coolify deployment pipeline - PostgreSQL + pgvector, MinIO S3, Better Auth - TypeScript strict mode (no ignoreBuildErrors) - i18n (en/es), AI modules, billing, monitoring Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
129
packages/billing/src/providers/lemon-squeezy/checkout.ts
Normal file
129
packages/billing/src/providers/lemon-squeezy/checkout.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
createCheckout,
|
||||
getCustomer,
|
||||
getOrder,
|
||||
} from "@lemonsqueezy/lemonsqueezy.js";
|
||||
|
||||
import { HttpStatusCode } from "@turbostarter/shared/constants";
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
import { HttpException } from "@turbostarter/shared/utils";
|
||||
|
||||
import { config } from "../../config";
|
||||
import { getCustomerByCustomerId, updateCustomer } from "../../lib/customer";
|
||||
import { getCustomerByUserId } from "../../server";
|
||||
import { getHighestDiscountForPrice } from "../../utils";
|
||||
|
||||
import { createOrRetrieveCustomer } from "./customer";
|
||||
import { env } from "./env";
|
||||
import { toCheckoutBillingStatus } from "./mappers/to-billing-status";
|
||||
|
||||
import type { BillingProviderStrategy } from "../types";
|
||||
|
||||
export const checkout: BillingProviderStrategy["checkout"] = async ({
|
||||
user,
|
||||
price: { id },
|
||||
redirect,
|
||||
}) => {
|
||||
try {
|
||||
const plan = config.plans.find((plan) =>
|
||||
plan.prices.some((p) => p.id === id),
|
||||
);
|
||||
|
||||
const price = plan?.prices.find((p) => p.id === id);
|
||||
|
||||
if (!price || !plan) {
|
||||
throw new HttpException(HttpStatusCode.NOT_FOUND, {
|
||||
code: "billing:error.priceNotFound",
|
||||
});
|
||||
}
|
||||
|
||||
const customer = await createOrRetrieveCustomer({
|
||||
email: user.email,
|
||||
id: user.id,
|
||||
});
|
||||
|
||||
const discount = getHighestDiscountForPrice(price, config.discounts);
|
||||
|
||||
const session = await createCheckout(env.LEMON_SQUEEZY_STORE_ID, id, {
|
||||
checkoutData: {
|
||||
email: customer.attributes.email,
|
||||
name: customer.attributes.name,
|
||||
custom: {
|
||||
user_id: user.id,
|
||||
},
|
||||
...(discount && { discountCode: discount.code }),
|
||||
},
|
||||
productOptions: {
|
||||
enabledVariants: [Number(id)],
|
||||
redirectUrl: redirect.success,
|
||||
},
|
||||
});
|
||||
|
||||
return { url: session.data?.data.attributes.url ?? null };
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
throw new HttpException(HttpStatusCode.INTERNAL_SERVER_ERROR, {
|
||||
code: "billing:error.checkout",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getBillingPortal: BillingProviderStrategy["getBillingPortal"] =
|
||||
async ({ user }) => {
|
||||
const defaultUrl = `https://${env.LEMON_SQUEEZY_STORE_ID}.lemonsqueezy.com/billing`;
|
||||
|
||||
try {
|
||||
const customer = await getCustomerByUserId(user.id);
|
||||
|
||||
if (!customer) {
|
||||
return {
|
||||
url: defaultUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const lemonCustomer = await getCustomer(customer.customerId);
|
||||
|
||||
const url = lemonCustomer.data?.data.attributes.urls.customer_portal;
|
||||
|
||||
return { url: url ?? defaultUrl };
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
throw new HttpException(HttpStatusCode.INTERNAL_SERVER_ERROR, {
|
||||
code: "billing:error.portal",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const checkoutStatusChangeHandler = async ({ id }: { id: string }) => {
|
||||
const { data } = await getOrder(id);
|
||||
|
||||
const order = data?.data;
|
||||
|
||||
if (!order) {
|
||||
throw new HttpException(HttpStatusCode.NOT_FOUND, {
|
||||
code: "billing:error.orderNotFound",
|
||||
});
|
||||
}
|
||||
|
||||
const customer = await getCustomerByCustomerId(
|
||||
order.attributes.customer_id.toString(),
|
||||
);
|
||||
|
||||
if (!customer) {
|
||||
throw new HttpException(HttpStatusCode.NOT_FOUND, {
|
||||
code: "billing:error.customerNotFound",
|
||||
});
|
||||
}
|
||||
|
||||
const priceId = order.attributes.first_order_item.variant_id.toString();
|
||||
const plan = config.plans.find((p) => p.prices.find((x) => x.id === priceId));
|
||||
|
||||
await updateCustomer(customer.userId, {
|
||||
status: toCheckoutBillingStatus(order.attributes.status),
|
||||
...(plan && { plan: plan.id }),
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`✅ Checkout status changed for user ${customer.userId} to ${order.attributes.status}`,
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user