feat(db): mesh data model — meshes, members, invites, audit log

- pgSchema "mesh" with 4 tables isolating the peer mesh domain
- Enums: visibility, transport, tier, role
- audit_log is metadata-only (E2E encryption enforced at broker/client)
- Cascade on mesh delete, soft-delete via archivedAt/revokedAt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-04 21:19:32 +01:00
commit d3163a5bff
1384 changed files with 314925 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
import baseConfig from "@turbostarter/eslint-config/base";
export default baseConfig;

View File

@@ -0,0 +1,39 @@
{
"name": "@turbostarter/billing",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./env": "./src/env.ts",
"./server": "./src/server.ts"
},
"scripts": {
"clean": "git clean -xdf .cache .turbo dist node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"prettier": "@turbostarter/prettier-config",
"dependencies": {
"@lemonsqueezy/lemonsqueezy.js": "4.0.0",
"@polar-sh/sdk": "0.40.3",
"@turbostarter/auth": "workspace:*",
"@turbostarter/db": "workspace:*",
"@turbostarter/shared": "workspace:*",
"envin": "catalog:",
"stripe": "19.2.0",
"zod": "catalog:"
},
"devDependencies": {
"@turbostarter/eslint-config": "workspace:*",
"@turbostarter/prettier-config": "workspace:*",
"@turbostarter/tsconfig": "workspace:*",
"@turbostarter/vitest-config": "workspace:*",
"eslint": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}

View File

@@ -0,0 +1,45 @@
import { PricingPlanType } from "../types";
const FREE_FEATURES = {
SYNC: "SYNC",
BASIC_SUPPORT: "BASIC_SUPPORT",
LIMITED_STORAGE: "LIMITED_STORAGE",
EMAIL_NOTIFICATIONS: "EMAIL_NOTIFICATIONS",
BASIC_REPORTS: "BASIC_REPORTS",
} as const;
const PREMIUM_FEATURES = {
...FREE_FEATURES,
ADVANCED_SYNC: "ADVANCED_SYNC",
PRIORITY_SUPPORT: "PRIORITY_SUPPORT",
MORE_STORAGE: "MORE_STORAGE",
TEAM_COLLABORATION: "TEAM_COLLABORATION",
SMS_NOTIFICATIONS: "SMS_NOTIFICATIONS",
ADVANCED_REPORTS: "ADVANCED_REPORTS",
} as const;
const ENTERPRISE_FEATURES = {
...PREMIUM_FEATURES,
UNLIMITED_STORAGE: "UNLIMITED_STORAGE",
CUSTOM_BRANDING: "CUSTOM_BRANDING",
DEDICATED_SUPPORT: "DEDICATED_SUPPORT",
API_ACCESS: "API_ACCESS",
USER_ROLES: "USER_ROLES",
AUDIT_LOGS: "AUDIT_LOGS",
SINGLE_SIGN_ON: "SINGLE_SIGN_ON",
ADVANCED_ANALYTICS: "ADVANCED_ANALYTICS",
} as const;
export const FEATURES = {
[PricingPlanType.FREE]: FREE_FEATURES,
[PricingPlanType.PREMIUM]: PREMIUM_FEATURES,
[PricingPlanType.ENTERPRISE]: ENTERPRISE_FEATURES,
} as const;
export type FreeFeature = (typeof FREE_FEATURES)[keyof typeof FREE_FEATURES];
export type PremiumFeature =
(typeof PREMIUM_FEATURES)[keyof typeof PREMIUM_FEATURES];
export type EnterpriseFeature =
(typeof ENTERPRISE_FEATURES)[keyof typeof ENTERPRISE_FEATURES];
export type Feature = FreeFeature | PremiumFeature | EnterpriseFeature;

View File

@@ -0,0 +1,18 @@
import { BillingStatus } from "../types";
import { discounts, plans } from "./plans";
import { billingConfigSchema } from "./schema";
import type { BillingConfig } from "../types";
export const config = billingConfigSchema.parse({
plans,
discounts,
}) satisfies BillingConfig;
export const ACTIVE_BILLING_STATUSES: BillingStatus[] = [
BillingStatus.ACTIVE,
BillingStatus.TRIALING,
];
export * from "./features";

View File

@@ -0,0 +1,107 @@
import {
BillingModel,
PricingPlanType,
RecurringInterval,
BillingDiscountType,
} from "../types";
import type { Discount } from "../types";
export const plans = [
{
id: PricingPlanType.FREE,
name: "plan.starter.name",
description: "plan.starter.description",
badge: null,
prices: [
{
id: "starter-lifetime",
amount: 0,
type: BillingModel.ONE_TIME,
},
{
id: "starter-monthly",
amount: 0,
interval: RecurringInterval.MONTH,
type: BillingModel.RECURRING,
},
{
id: "starter-yearly",
amount: 0,
interval: RecurringInterval.YEAR,
type: BillingModel.RECURRING,
},
],
},
{
id: PricingPlanType.PREMIUM,
name: "plan.premium.name",
description: "plan.premium.description",
badge: "plan.premium.badge",
prices: [
{
id: "price_1PpUagFQH4McJDTlHCzOmyT6",
amount: 29900,
type: BillingModel.ONE_TIME,
},
{
id: "price_1PpZAAFQH4McJDTlig6FBPyy",
amount: 1900,
interval: RecurringInterval.MONTH,
trialDays: 7,
type: BillingModel.RECURRING,
},
{
id: "price_1PpZALFQH4McJDTl8SWorWTO",
amount: 8900,
interval: RecurringInterval.YEAR,
trialDays: 7,
type: BillingModel.RECURRING,
},
],
},
{
id: PricingPlanType.ENTERPRISE,
name: "plan.enterprise.name",
description: "plan.enterprise.description",
badge: null,
prices: [
{
id: "enterprise-lifetime",
label: "common:contactUs",
href: "/contact",
type: BillingModel.ONE_TIME,
custom: true,
},
{
id: "enterprise-monthly",
label: "common:contactUs",
href: "/contact",
type: BillingModel.RECURRING,
interval: RecurringInterval.MONTH,
custom: true,
},
{
id: "enterprise-yearly",
label: "common:contactUs",
href: "/contact",
type: BillingModel.RECURRING,
interval: RecurringInterval.YEAR,
custom: true,
},
],
},
];
export const discounts: Discount[] = [
{
code: "50OFF",
type: BillingDiscountType.PERCENT,
off: 50,
appliesTo: [
"price_1PpUagFQH4McJDTlHCzOmyT6",
"price_1PpZAAFQH4McJDTlig6FBPyy",
"price_1PpZALFQH4McJDTl8SWorWTO",
],
},
];

View File

@@ -0,0 +1,69 @@
import * as z from "zod";
import {
BillingDiscountType,
BillingModel,
PricingPlanType,
RecurringInterval,
} from "../types";
export const discountSchema = z.object({
code: z.string(),
type: z.enum(BillingDiscountType),
off: z.number(),
appliesTo: z.array(z.string()),
});
const customPriceSchema = z.union([
z.object({
custom: z.literal(true),
label: z.string(),
href: z.string(),
}),
z.object({
custom: z.literal(false).optional().default(false),
amount: z.number(),
}),
]);
const sharedPriceSchema = z.intersection(
customPriceSchema,
z.object({
id: z.string(),
currency: z.string().optional(),
}),
);
const priceTypeSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal(BillingModel.ONE_TIME),
}),
z.object({
type: z.literal(BillingModel.RECURRING),
interval: z.enum(RecurringInterval),
trialDays: z.number().optional(),
}),
]);
export const priceSchema = z.intersection(sharedPriceSchema, priceTypeSchema);
export const planSchema = z.object({
id: z.enum(PricingPlanType),
name: z.string(),
description: z.string(),
badge: z.string().nullable().default(null),
prices: z.array(priceSchema),
});
export const billingConfigSchema = z.object({
plans: z.array(planSchema).refine(
(plans) => {
const types = new Set(plans.map((plan) => plan.id));
return types.size === plans.length;
},
{
message: "You can't have two plans with the same id!",
},
),
discounts: z.array(discountSchema).optional().default([]),
});

View File

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

View File

@@ -0,0 +1,3 @@
export * from "./types";
export * from "./utils";
export * from "./config";

View File

@@ -0,0 +1,36 @@
import { eq } from "@turbostarter/db";
import { customer } from "@turbostarter/db/schema";
import { db } from "@turbostarter/db/server";
import type { InsertCustomer } from "@turbostarter/db/schema";
export const getCustomerByUserId = async (userId: string) => {
const [data] = await db
.select()
.from(customer)
.where(eq(customer.userId, userId));
return data ?? null;
};
export const getCustomerByCustomerId = async (customerId: string) => {
const [data] = await db
.select()
.from(customer)
.where(eq(customer.customerId, customerId));
return data ?? null;
};
export const updateCustomer = (
userId: string,
data: Partial<InsertCustomer>,
) => {
return db.update(customer).set(data).where(eq(customer.userId, userId));
};
export const upsertCustomer = (data: InsertCustomer) => {
return db.insert(customer).values(data).onConflictDoUpdate({
target: customer.userId,
set: data,
});
};

View File

@@ -0,0 +1,18 @@
import * as z from "zod";
export const checkoutSchema = z.object({
price: z.object({
id: z.string(),
}),
redirect: z.object({
success: z.url(),
cancel: z.url(),
}),
});
export const getBillingPortalSchema = z.object({
redirectUrl: z.url(),
});
export type CheckoutPayload = z.infer<typeof checkoutSchema>;
export type GetBillingPortalPayload = z.infer<typeof getBillingPortalSchema>;

View File

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

View File

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

View 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}`,
);
};

View File

@@ -0,0 +1,20 @@
import { lemonSqueezySetup } from "@lemonsqueezy/lemonsqueezy.js";
import { HttpStatusCode } from "@turbostarter/shared/constants";
import { logger } from "@turbostarter/shared/logger";
import { HttpException } from "@turbostarter/shared/utils";
import { env } from "./env";
export const setup = () => {
return lemonSqueezySetup({
apiKey: env.LEMON_SQUEEZY_API_KEY,
onError: (error) => {
logger.error(error);
throw new HttpException(HttpStatusCode.INTERNAL_SERVER_ERROR, {
code: "billing:error.lemonSqueezy",
message: error.message,
});
},
});
};

View File

@@ -0,0 +1,81 @@
import {
createCustomer,
getCustomer,
listCustomers,
} from "@lemonsqueezy/lemonsqueezy.js";
import { HttpStatusCode } from "@turbostarter/shared/constants";
import { logger } from "@turbostarter/shared/logger";
import { HttpException } from "@turbostarter/shared/utils";
import { updateCustomer, upsertCustomer } from "../../lib/customer";
import { getCustomerByUserId } from "../../server";
import { env } from "./env";
const getLemonSqueezyCustomerById = async (customerId: string) => {
return getCustomer(customerId);
};
const getLemonSqueezyCustomerByEmail = async (email: string) => {
const { data } = await listCustomers({
filter: {
email: email,
},
});
return data?.data[0];
};
const createLemonSqueezyCustomer = async (email: string) => {
const newCustomer = await createCustomer(env.LEMON_SQUEEZY_STORE_ID, {
name: email.split("@")[0] ?? "",
email: email,
});
return newCustomer.data?.data;
};
export const createOrRetrieveCustomer = async ({
email,
id,
}: {
email: string;
id: string;
}) => {
const existingCustomer = await getCustomerByUserId(id);
const lemonSqueezyCustomer = existingCustomer?.customerId
? (await getLemonSqueezyCustomerById(existingCustomer.customerId)).data
?.data
: await getLemonSqueezyCustomerByEmail(email);
const lemonSqueezyCustomerToProcess =
lemonSqueezyCustomer ?? (await createLemonSqueezyCustomer(email));
if (!lemonSqueezyCustomerToProcess) {
throw new HttpException(HttpStatusCode.INTERNAL_SERVER_ERROR, {
code: "billing:error.customerCreation",
});
}
if (existingCustomer && lemonSqueezyCustomer) {
if (existingCustomer.customerId !== lemonSqueezyCustomer.id) {
await updateCustomer(id, {
customerId: lemonSqueezyCustomerToProcess.id,
});
logger.warn(
`Customer ${id} had a different customerId. Updated to ${lemonSqueezyCustomerToProcess.id}.`,
);
}
return lemonSqueezyCustomerToProcess;
}
await upsertCustomer({
userId: id,
customerId: lemonSqueezyCustomerToProcess.id,
});
return lemonSqueezyCustomerToProcess;
};

View File

@@ -0,0 +1,23 @@
import { defineEnv } from "envin";
import * as z from "zod";
import { envConfig } from "@turbostarter/shared/constants";
import { sharedPreset } from "../../utils/env";
import type { Preset } from "envin/types";
export const preset = {
id: "lemon-squeezy",
server: {
LEMON_SQUEEZY_API_KEY: z.string(),
LEMON_SQUEEZY_SIGNING_SECRET: z.string(),
LEMON_SQUEEZY_STORE_ID: z.string(),
},
extends: [sharedPreset],
} as const satisfies Preset;
export const env = defineEnv({
...envConfig,
...preset,
});

View File

@@ -0,0 +1,6 @@
import { setup } from "./client";
setup();
export { checkout, getBillingPortal } from "./checkout";
export { webhookHandler } from "./webhook";

View File

@@ -0,0 +1,38 @@
import { BillingStatus } from "../../../types";
export const toBillingStatus = (status: string): BillingStatus => {
switch (status) {
case "active":
return BillingStatus.ACTIVE;
case "on_trial":
return BillingStatus.TRIALING;
case "past_due":
return BillingStatus.PAST_DUE;
case "cancelled":
return BillingStatus.CANCELED;
case "expired":
return BillingStatus.INCOMPLETE_EXPIRED;
case "unpaid":
return BillingStatus.UNPAID;
case "paused":
return BillingStatus.PAUSED;
default:
throw new Error(`Invalid billing status: ${status}`);
}
};
export const toCheckoutBillingStatus = (status: string): BillingStatus => {
switch (status) {
case "paid":
return BillingStatus.ACTIVE;
case "refunded":
return BillingStatus.CANCELED;
case "failed":
return BillingStatus.UNPAID;
case "pending":
return BillingStatus.INCOMPLETE;
default:
throw new Error(`Invalid checkout billing status: ${status}`);
}
};

View File

@@ -0,0 +1,48 @@
import { getSubscription } 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 { toBillingStatus } from "./mappers/to-billing-status";
export const subscriptionStatusChangeHandler = async ({
id,
}: {
id: string;
}) => {
const { data } = await getSubscription(id);
const subscription = data?.data;
if (!subscription) {
throw new HttpException(HttpStatusCode.NOT_FOUND, {
code: "billing:error.subscriptionNotFound",
});
}
const customer = await getCustomerByCustomerId(
subscription.attributes.customer_id.toString(),
);
if (!customer) {
throw new HttpException(HttpStatusCode.NOT_FOUND, {
code: "billing:error.customerNotFound",
});
}
const priceId = subscription.attributes.variant_id.toString();
const plan = config.plans.find((p) => p.prices.find((x) => x.id === priceId));
await updateCustomer(customer.userId, {
status: toBillingStatus(subscription.attributes.status),
...(plan && { plan: plan.id }),
});
logger.info(
`✅ Subscription status changed for user ${customer.userId} to ${subscription.attributes.status}`,
);
};

View File

@@ -0,0 +1 @@
export const LEMON_SQUEEZY_SIGNATURE_HEADER = "X-Signature";

View File

@@ -0,0 +1,83 @@
import { HttpStatusCode } from "@turbostarter/shared/constants";
import { logger } from "@turbostarter/shared/logger";
import { HttpException } from "@turbostarter/shared/utils";
import { checkoutStatusChangeHandler } from "../checkout";
import { env } from "../env";
import { subscriptionStatusChangeHandler } from "../subscription";
import { LEMON_SQUEEZY_SIGNATURE_HEADER } from "./constants";
import { validateSignature } from "./signing";
import { webhookHasData, webhookHasMeta } from "./type-guards";
import type { BillingProviderStrategy } from "../../types";
export const webhookHandler: BillingProviderStrategy["webhookHandler"] = async (
req,
callbacks,
) => {
const body = await req.text();
const sig = req.headers.get(LEMON_SQUEEZY_SIGNATURE_HEADER);
if (!sig) {
throw new HttpException(HttpStatusCode.BAD_REQUEST, {
code: "billing:error.webhook.signatureNotFound",
});
}
await validateSignature(sig, env.LEMON_SQUEEZY_SIGNING_SECRET, body);
const data = JSON.parse(body);
if (!webhookHasMeta(data)) {
throw new HttpException(HttpStatusCode.BAD_REQUEST, {
code: "billing:error.webhook.metaInvalid",
});
}
const type = data.meta.event_name;
logger.info(`🔔 Webhook received: ${type}`);
if (!webhookHasData(data)) {
throw new HttpException(HttpStatusCode.BAD_REQUEST, {
code: "billing:error.webhook.dataInvalid",
});
}
await callbacks?.onEvent?.(data);
switch (type) {
case "subscription_created":
await callbacks?.onSubscriptionCreated?.(data.data.id);
await subscriptionStatusChangeHandler({
id: data.data.id,
});
break;
case "subscription_updated":
await callbacks?.onSubscriptionUpdated?.(data.data.id);
await subscriptionStatusChangeHandler({
id: data.data.id,
});
break;
case "subscription_expired":
await callbacks?.onSubscriptionDeleted?.(data.data.id);
await subscriptionStatusChangeHandler({
id: data.data.id,
});
break;
case "order_created":
await callbacks?.onCheckoutSessionCompleted?.(data.data.id);
await checkoutStatusChangeHandler({
id: data.data.id,
});
break;
}
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: {
"Content-Type": "application/json",
},
});
};

View File

@@ -0,0 +1,41 @@
import { HttpStatusCode } from "@turbostarter/shared/constants";
import { HttpException } from "@turbostarter/shared/utils";
export const validateSignature = async (
sig: string,
secret: string,
body: string,
) => {
const keyData = new TextEncoder().encode(secret);
const bodyData = new TextEncoder().encode(body);
const key = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signatureBuffer = await crypto.subtle.sign("HMAC", key, bodyData);
const expectedSignature = [...new Uint8Array(signatureBuffer)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
if (!timingSafeEqual(expectedSignature, sig)) {
throw new HttpException(HttpStatusCode.BAD_REQUEST, {
code: "billing:error.webhook.signatureInvalid",
});
}
};
const timingSafeEqual = (a: string, b: string) => {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
};

View File

@@ -0,0 +1,38 @@
import * as z from "zod";
const metaSchema = z.object({
meta: z.object({
event_name: z.string(),
custom_data: z.object({
user_id: z.string(),
}),
}),
});
/**
* Typeguard to check if the object has a 'meta' property
* and that the 'meta' property has the correct shape.
*/
export function webhookHasMeta(
obj: unknown,
): obj is z.infer<typeof metaSchema> {
return metaSchema.safeParse(obj).success;
}
const dataSchema = z.object({
data: z.object({
id: z.string(),
}),
});
/**
* Typeguard to check if the object has a 'data' property and the correct shape.
*
* @param obj - The object to check.
* @returns True if the object has a 'data' property.
*/
export function webhookHasData(
obj: unknown,
): obj is z.infer<typeof dataSchema> {
return dataSchema.safeParse(obj).success;
}

View File

@@ -0,0 +1,124 @@
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 { polar } from "./client";
import { createOrRetrieveCustomer } from "./customer";
import { env } from "./env";
import { toCheckoutBillingStatus } from "./mappers/to-billing-status";
import { subscriptionStatusChangeHandler } from "./subscription";
import type { BillingProviderStrategy } from "../types";
const getPolarDiscountByCode = async (code: string) => {
const discounts = await polar().discounts.list({
query: code,
});
return discounts.result.items[0];
};
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 discountId = discount
? await getPolarDiscountByCode(discount.code)
: undefined;
const checkout = await polar().checkouts.create({
products: [price.id],
successUrl: redirect.success,
customerId: customer.id,
discountId: discountId?.id,
});
return { url: checkout.url };
} 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://polar.sh/${env.POLAR_ORGANIZATION_SLUG}/portal`;
try {
const customer = await getCustomerByUserId(user.id);
if (!customer) {
return {
url: defaultUrl,
};
}
const customerSession = await polar().customerSessions.create({
customerId: customer.customerId,
});
return { url: customerSession.customerPortalUrl || 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 order = await polar().orders.get({ id });
const customer = await getCustomerByCustomerId(order.customerId);
if (!customer) {
throw new HttpException(HttpStatusCode.NOT_FOUND, {
code: "billing:error.customerNotFound",
});
}
if (order.subscription) {
await subscriptionStatusChangeHandler({
id: order.subscription.id,
});
return;
}
const priceId = order.productId;
const plan = config.plans.find((p) => p.prices.find((x) => x.id === priceId));
await updateCustomer(customer.userId, {
status: toCheckoutBillingStatus(order.status),
...(plan && { plan: plan.id }),
});
logger.info(
`✅ Checkout status changed for user ${customer.userId} to ${order.status}`,
);
};

View File

@@ -0,0 +1,16 @@
import { Polar } from "@polar-sh/sdk";
import { NodeEnv } from "@turbostarter/shared/constants";
import { env } from "./env";
let polarInstance: Polar | null = null;
export const polar = () => {
polarInstance ??= new Polar({
server: env.NODE_ENV === NodeEnv.PRODUCTION ? "production" : "sandbox",
accessToken: env.POLAR_ACCESS_TOKEN,
});
return polarInstance;
};

View File

@@ -0,0 +1,63 @@
import { logger } from "@turbostarter/shared/logger";
import { updateCustomer, upsertCustomer } from "../../lib/customer";
import { getCustomerByUserId } from "../../server";
import { polar } from "./client";
const getPolarCustomerById = async (customerId: string) => {
return polar().customers.get({ id: customerId });
};
const getPolarCustomerByEmail = async (email: string) => {
const customers = await polar().customers.list({
email,
});
return customers.result.items[0];
};
const createPolarCustomer = async (email: string) => {
const newCustomer = await polar().customers.create({
email,
});
return newCustomer;
};
export const createOrRetrieveCustomer = async ({
email,
id,
}: {
email: string;
id: string;
}) => {
const existingCustomer = await getCustomerByUserId(id);
const polarCustomer = existingCustomer?.customerId
? await getPolarCustomerById(existingCustomer.customerId)
: await getPolarCustomerByEmail(email);
const polarCustomerToProcess =
polarCustomer ?? (await createPolarCustomer(email));
if (existingCustomer && polarCustomer) {
if (existingCustomer.customerId !== polarCustomer.id) {
await updateCustomer(id, {
customerId: polarCustomerToProcess.id,
});
logger.warn(
`Customer ${id} had a different customerId. Updated to ${polarCustomerToProcess.id}.`,
);
}
return polarCustomerToProcess;
}
await upsertCustomer({
userId: id,
customerId: polarCustomerToProcess.id,
});
return polarCustomerToProcess;
};

View File

@@ -0,0 +1,26 @@
import { defineEnv } from "envin";
import * as z from "zod";
import { envConfig, NodeEnv } from "@turbostarter/shared/constants";
import { sharedPreset } from "../../utils/env";
import type { Preset } from "envin/types";
export const preset = {
id: "polar",
server: {
POLAR_ACCESS_TOKEN: z.string(),
POLAR_WEBHOOK_SECRET: z.string(),
POLAR_ORGANIZATION_SLUG: z.string().optional(),
},
extends: [sharedPreset],
} as const satisfies Preset;
export const env = defineEnv({
...envConfig,
...preset,
shared: {
NODE_ENV: z.enum(NodeEnv).default(NodeEnv.DEVELOPMENT),
},
});

View File

@@ -0,0 +1,2 @@
export { checkout, getBillingPortal } from "./checkout";
export { webhookHandler } from "./webhook";

View File

@@ -0,0 +1,35 @@
import { BillingStatus } from "../../../types";
import type { OrderStatus } from "@polar-sh/sdk/models/components/orderstatus";
import type { SubscriptionStatus } from "@polar-sh/sdk/models/components/subscriptionstatus";
export const toBillingStatus = (status: SubscriptionStatus): BillingStatus => {
switch (status) {
case "active":
return BillingStatus.ACTIVE;
case "trialing":
return BillingStatus.TRIALING;
case "past_due":
return BillingStatus.PAST_DUE;
case "canceled":
return BillingStatus.CANCELED;
case "incomplete_expired":
return BillingStatus.INCOMPLETE_EXPIRED;
case "unpaid":
return BillingStatus.UNPAID;
case "incomplete":
return BillingStatus.INCOMPLETE;
}
};
export const toCheckoutBillingStatus = (status: OrderStatus): BillingStatus => {
switch (status) {
case "paid":
return BillingStatus.ACTIVE;
case "refunded":
case "partially_refunded":
return BillingStatus.CANCELED;
case "pending":
return BillingStatus.INCOMPLETE;
}
};

View File

@@ -0,0 +1,37 @@
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 { polar } from "./client";
import { toBillingStatus } from "./mappers/to-billing-status";
export const subscriptionStatusChangeHandler = async ({
id,
}: {
id: string;
}) => {
const subscription = await polar().subscriptions.get({ id });
const customer = await getCustomerByCustomerId(subscription.customerId);
if (!customer) {
throw new HttpException(HttpStatusCode.NOT_FOUND, {
code: "billing:error.customerNotFound",
});
}
const priceId = subscription.productId;
const plan = config.plans.find((p) => p.prices.find((x) => x.id === priceId));
await updateCustomer(customer.userId, {
status: toBillingStatus(subscription.status),
...(plan && { plan: plan.id }),
});
logger.info(
`✅ Subscription status changed for user ${customer.userId} to ${subscription.status}`,
);
};

View File

@@ -0,0 +1,76 @@
import {
validateEvent,
WebhookVerificationError,
} from "@polar-sh/sdk/webhooks";
import { HttpStatusCode } from "@turbostarter/shared/constants";
import { logger } from "@turbostarter/shared/logger";
import { HttpException } from "@turbostarter/shared/utils";
import { checkoutStatusChangeHandler } from "../checkout";
import { env } from "../env";
import { subscriptionStatusChangeHandler } from "../subscription";
import type { BillingProviderStrategy } from "../../types";
export const webhookHandler: BillingProviderStrategy["webhookHandler"] = async (
req,
callbacks,
) => {
try {
const raw = await req.text();
const event = validateEvent(
raw,
Object.fromEntries(req.headers.entries()),
env.POLAR_WEBHOOK_SECRET,
);
const type = event.type;
logger.info(`🔔 Webhook received: ${type}`);
await callbacks?.onEvent?.(event);
switch (type) {
case "subscription.created":
await callbacks?.onSubscriptionCreated?.(event.data.id);
await subscriptionStatusChangeHandler({
id: event.data.id,
});
break;
case "subscription.updated":
await callbacks?.onSubscriptionUpdated?.(event.data.id);
await subscriptionStatusChangeHandler({
id: event.data.id,
});
break;
case "subscription.canceled":
case "subscription.revoked":
await callbacks?.onSubscriptionDeleted?.(event.data.id);
await subscriptionStatusChangeHandler({
id: event.data.id,
});
break;
case "order.created":
await callbacks?.onCheckoutSessionCompleted?.(event.data.id);
await checkoutStatusChangeHandler({
id: event.data.id,
});
break;
}
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: {
"Content-Type": "application/json",
},
});
} catch (error) {
if (error instanceof WebhookVerificationError) {
throw new HttpException(HttpStatusCode.BAD_REQUEST, {
code: "billing:error.webhook.invalidSignature",
});
}
throw new HttpException(HttpStatusCode.INTERNAL_SERVER_ERROR);
}
};

View File

@@ -0,0 +1,189 @@
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 { BillingModel } from "../../types";
import { getHighestDiscountForPrice } from "../../utils";
import { stripe } from "./client";
import {
createBillingPortalSession,
createOrRetrieveCustomer,
} from "./customer";
import { env } from "./env";
import {
toCheckoutBillingStatus,
toPaymentBillingStatus,
} from "./mappers/to-billing-status";
import {
getPromotionCode,
subscriptionStatusChangeHandler,
} from "./subscription";
import type { BillingProviderStrategy } from "../types";
import type Stripe from "stripe";
const createCheckoutSession = async (
params: Stripe.Checkout.SessionCreateParams,
) => {
try {
return await stripe().checkout.sessions.create(params);
} catch (e) {
logger.error(e);
throw new HttpException(HttpStatusCode.INTERNAL_SERVER_ERROR, {
code: "billing:error.checkout",
});
}
};
const getCheckoutSession = async (sessionId: string) => {
try {
return await stripe().checkout.sessions.retrieve(sessionId, {
expand: ["line_items", "line_items.data.price"],
});
} catch (e) {
logger.error(e);
throw new HttpException(HttpStatusCode.INTERNAL_SERVER_ERROR, {
code: "billing:error.checkoutRetrieve",
});
}
};
export const checkoutStatusChangeHandler = async (
session: Stripe.Checkout.Session,
) => {
const customerId = session.customer as string | null;
if (!customerId) {
throw new HttpException(HttpStatusCode.NOT_FOUND, {
code: "billing:error.customerNotFound",
});
}
if (session.mode === "subscription") {
await subscriptionStatusChangeHandler({
id: session.subscription as string,
customerId,
});
return;
}
const customer = await getCustomerByCustomerId(customerId);
if (!customer) {
throw new HttpException(HttpStatusCode.NOT_FOUND, {
code: "billing:error.customerNotFound",
});
}
const checkoutSession = await getCheckoutSession(session.id);
const priceId = checkoutSession.line_items?.data[0]?.price?.id;
if (!priceId) {
throw new HttpException(HttpStatusCode.NOT_FOUND, {
code: "billing:error.priceNotFound",
});
}
const plan = config.plans.find((p) =>
p.prices.some((price) => price.id === priceId),
);
await updateCustomer(customer.userId, {
status: checkoutSession.status
? toCheckoutBillingStatus(checkoutSession.status)
: toPaymentBillingStatus(checkoutSession.payment_status),
...(plan && { plan: plan.id }),
});
logger.info(
`✅ Checkout status changed for user ${customer.userId} to ${checkoutSession.status}`,
);
};
export const checkout: BillingProviderStrategy["checkout"] = async ({
user,
price: { id },
redirect,
}) => {
try {
const price = config.plans
.find((plan) => plan.prices.some((p) => p.id === id))
?.prices.find((p) => p.id === id);
if (!price) {
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 code = await getPromotionCode(discount?.code ?? "");
const session = await createCheckoutSession({
mode:
env.BILLING_MODEL === BillingModel.RECURRING
? "subscription"
: "payment",
billing_address_collection: "required",
customer,
customer_update: {
address: "auto",
},
line_items: [
{
price: price.id,
quantity: 1,
},
],
success_url: redirect.success,
cancel_url: redirect.cancel,
...("trialDays" in price && price.trialDays
? {
subscription_data: {
trial_period_days: price.trialDays,
},
}
: {}),
...(code && {
discounts: [
{
promotion_code: code.id,
},
],
}),
});
return { url: session.url };
} catch (e) {
logger.error(e);
throw new HttpException(HttpStatusCode.INTERNAL_SERVER_ERROR);
}
};
export const getBillingPortal: BillingProviderStrategy["getBillingPortal"] =
async ({ redirectUrl, user }) => {
try {
const customer = await createOrRetrieveCustomer({
email: user.email,
id: user.id,
});
const { url } = await createBillingPortalSession({
customer,
return_url: redirectUrl,
});
return { url };
} catch (e) {
logger.error(e);
throw new HttpException(HttpStatusCode.INTERNAL_SERVER_ERROR);
}
};

View File

@@ -0,0 +1,15 @@
import Stripe from "stripe";
import { env } from "./env";
let stripeInstance: Stripe | null = null;
export const stripe = () => {
const key = env.STRIPE_SECRET_KEY;
if (!key) {
throw new Error("STRIPE_SECRET_KEY is required when using Stripe billing");
}
stripeInstance ??= new Stripe(key);
return stripeInstance;
};

View File

@@ -0,0 +1,86 @@
import { HttpStatusCode } from "@turbostarter/shared/constants";
import { logger } from "@turbostarter/shared/logger";
import { HttpException } from "@turbostarter/shared/utils";
import {
getCustomerByUserId,
updateCustomer,
upsertCustomer,
} from "../../lib/customer";
import { stripe } from "./client";
import type Stripe from "stripe";
const getStripeCustomerById = async (stripeId: string) => {
return stripe().customers.retrieve(stripeId);
};
const getStripeCustomerByEmail = async (email: string) => {
const customers = await stripe().customers.list({ email: email });
return customers.data.length > 0 ? customers.data[0] : null;
};
const createStripeCustomer = async (id: string, email: string) => {
const customerData = { metadata: { userId: id }, email: email };
const newCustomer = await stripe().customers.create(customerData);
return newCustomer.id;
};
export const createOrRetrieveCustomer = async ({
email,
id,
}: {
email: string;
id: string;
}) => {
const existingCustomer = await getCustomerByUserId(id);
const stripeCustomerId = existingCustomer?.customerId
? (await getStripeCustomerById(existingCustomer.customerId)).id
: (await getStripeCustomerByEmail(email))?.id;
const stripeIdToInsert =
stripeCustomerId ?? (await createStripeCustomer(id, email));
if (!stripeIdToInsert) {
throw new HttpException(HttpStatusCode.INTERNAL_SERVER_ERROR, {
code: "billing:error.customerCreation",
});
}
if (existingCustomer && stripeCustomerId) {
if (existingCustomer.customerId !== stripeCustomerId) {
await updateCustomer(id, {
customerId: stripeCustomerId,
});
logger.warn(
`Customer ${id} had a different customerId. Updated to ${stripeCustomerId}.`,
);
}
return stripeCustomerId;
}
await upsertCustomer({
userId: id,
customerId: stripeIdToInsert,
});
return stripeIdToInsert;
};
export const createBillingPortalSession = async (
params: Stripe.BillingPortal.SessionCreateParams,
) => {
try {
return await stripe().billingPortal.sessions.create(params);
} catch (e) {
logger.error(e);
throw new HttpException(HttpStatusCode.INTERNAL_SERVER_ERROR, {
code: "billing:error.portal",
});
}
};

View File

@@ -0,0 +1,22 @@
import { defineEnv } from "envin";
import * as z from "zod";
import { envConfig } from "@turbostarter/shared/constants";
import { sharedPreset } from "../../utils/env";
import type { Preset } from "envin/types";
export const preset = {
id: "stripe",
server: {
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
},
extends: [sharedPreset],
} as const satisfies Preset;
export const env = defineEnv({
...envConfig,
...preset,
});

View File

@@ -0,0 +1,2 @@
export { checkout, getBillingPortal } from "./checkout";
export { webhookHandler } from "./webhook";

View File

@@ -0,0 +1,51 @@
import { BillingStatus } from "../../../types";
export const toBillingStatus = (status: string): BillingStatus => {
switch (status) {
case "active":
return BillingStatus.ACTIVE;
case "trialing":
return BillingStatus.TRIALING;
case "past_due":
return BillingStatus.PAST_DUE;
case "incomplete":
return BillingStatus.INCOMPLETE;
case "incomplete_expired":
return BillingStatus.INCOMPLETE_EXPIRED;
case "canceled":
return BillingStatus.CANCELED;
case "unpaid":
return BillingStatus.UNPAID;
case "paused":
return BillingStatus.PAUSED;
default:
throw new Error(`Invalid billing status: ${status}`);
}
};
export const toCheckoutBillingStatus = (status: string): BillingStatus => {
switch (status) {
case "open":
return BillingStatus.PAUSED;
case "complete":
return BillingStatus.ACTIVE;
case "expired":
return BillingStatus.CANCELED;
default:
throw new Error(`Invalid checkout billing status: ${status}`);
}
};
export const toPaymentBillingStatus = (status: string): BillingStatus => {
switch (status) {
case "paid":
return BillingStatus.ACTIVE;
case "unpaid":
return BillingStatus.UNPAID;
case "no_payment_required":
return BillingStatus.ACTIVE;
default:
throw new Error(`Invalid payment billing status: ${status}`);
}
};

View File

@@ -0,0 +1,62 @@
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 { stripe } from "./client";
import { toBillingStatus } from "./mappers/to-billing-status";
import type Stripe from "stripe";
const getSubscription = async (subscriptionId: string) => {
return stripe().subscriptions.retrieve(subscriptionId) as Promise<
Stripe.Response<Stripe.Subscription & { plan: Stripe.Plan }>
>;
};
export const getPromotionCode = async (code: string) => {
try {
const { data } = await stripe().promotionCodes.list({
code,
});
return data[0];
} catch (e) {
logger.error(e);
throw new HttpException(HttpStatusCode.INTERNAL_SERVER_ERROR, {
code: "billing:error.promotionCodeRetrieve",
});
}
};
export const subscriptionStatusChangeHandler = async ({
id,
customerId,
}: {
id: string;
customerId: string;
}) => {
const customer = await getCustomerByCustomerId(customerId);
if (!customer) {
throw new HttpException(HttpStatusCode.NOT_FOUND, {
code: "billing:error.customerNotFound",
});
}
const subscription = await getSubscription(id);
const priceId = subscription.plan.id;
const plan = config.plans.find((p) => p.prices.find((x) => x.id === priceId));
await updateCustomer(customer.userId, {
status: toBillingStatus(subscription.status),
...(plan && { plan: plan.id }),
});
logger.info(
`✅ Subscription status changed for user ${customer.userId} to ${subscription.status}`,
);
};

View File

@@ -0,0 +1 @@
export const STRIPE_SIGNATURE_HEADER = "stripe-signature";

View File

@@ -0,0 +1,9 @@
import { stripe } from "../client";
export const constructEvent = (data: {
payload: string;
sig: string;
secret: string;
}) => {
return stripe().webhooks.constructEvent(data.payload, data.sig, data.secret);
};

View File

@@ -0,0 +1,77 @@
import { HttpStatusCode } from "@turbostarter/shared/constants";
import { logger } from "@turbostarter/shared/logger";
import { HttpException } from "@turbostarter/shared/utils";
import { checkoutStatusChangeHandler } from "../checkout";
import { env } from "../env";
import { subscriptionStatusChangeHandler } from "../subscription";
import { STRIPE_SIGNATURE_HEADER } from "./constants";
import { constructEvent } from "./event";
import type { BillingProviderStrategy } from "../../types";
export const webhookHandler: BillingProviderStrategy["webhookHandler"] = async (
req,
callbacks,
) => {
const body = await req.text();
const sig = req.headers.get(STRIPE_SIGNATURE_HEADER);
if (!sig) {
throw new HttpException(HttpStatusCode.BAD_REQUEST, {
code: "billing:error.webhook.signatureNotFound",
});
}
const secret = env.STRIPE_WEBHOOK_SECRET;
if (!secret) {
throw new HttpException(HttpStatusCode.INTERNAL_SERVER_ERROR, {
code: "billing:error.webhook.secretNotConfigured",
});
}
const event = constructEvent({
payload: body,
sig,
secret,
});
logger.info(`🔔 Webhook received: ${event.type}`);
await callbacks?.onEvent?.(event);
switch (event.type) {
case "customer.subscription.created":
await callbacks?.onSubscriptionCreated?.(event.data.object.id);
await subscriptionStatusChangeHandler({
id: event.data.object.id,
customerId: event.data.object.customer as string,
});
break;
case "customer.subscription.updated":
await callbacks?.onSubscriptionUpdated?.(event.data.object.id);
await subscriptionStatusChangeHandler({
id: event.data.object.id,
customerId: event.data.object.customer as string,
});
break;
case "customer.subscription.deleted":
await callbacks?.onSubscriptionDeleted?.(event.data.object.id);
await subscriptionStatusChangeHandler({
id: event.data.object.id,
customerId: event.data.object.customer as string,
});
break;
case "checkout.session.completed":
await callbacks?.onCheckoutSessionCompleted?.(event.data.object.id);
await checkoutStatusChangeHandler(event.data.object);
break;
}
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: {
"Content-Type": "application/json",
},
});
};

View File

@@ -0,0 +1,23 @@
import type { CheckoutPayload, GetBillingPortalPayload } from "../lib/schema";
import type { User } from "@turbostarter/auth";
export interface WebhookCallbacks {
onCheckoutSessionCompleted?: (sessionId: string) => Promise<void> | void;
onSubscriptionCreated?: (subscriptionId: string) => Promise<void> | void;
onSubscriptionUpdated?: (subscriptionId: string) => Promise<void> | void;
onSubscriptionDeleted?: (subscriptionId: string) => Promise<void> | void;
onEvent?: (event: unknown) => Promise<void> | void;
}
export interface BillingProviderStrategy {
webhookHandler: (
req: Request,
callbacks?: WebhookCallbacks,
) => Promise<Response>;
checkout: (
input: CheckoutPayload & { user: User },
) => Promise<{ url: string | null }>;
getBillingPortal: (
input: GetBillingPortalPayload & { user: User },
) => Promise<{ url: string }>;
}

View File

@@ -0,0 +1,4 @@
export { webhookHandler, checkout, getBillingPortal } from "./providers";
export { getCustomerByUserId } from "./lib/customer";
export * from "./lib/schema";

View File

@@ -0,0 +1,67 @@
import {
billingStatusEnum,
pricingPlanTypeEnum,
} from "@turbostarter/db/schema";
import type {
billingConfigSchema,
discountSchema,
planSchema,
priceSchema,
} from "../config/schema";
import type { EnumToConstant } from "@turbostarter/shared/types";
import type * as z from "zod";
export const BillingStatus = Object.fromEntries(
Object.values(billingStatusEnum.enumValues).map((status) => [
status.toUpperCase(),
status,
]),
) as EnumToConstant<typeof billingStatusEnum.enumValues>;
export type BillingStatus = (typeof BillingStatus)[keyof typeof BillingStatus];
export const PricingPlanType = Object.fromEntries(
Object.values(pricingPlanTypeEnum.enumValues).map((plan) => [
plan.toUpperCase(),
plan,
]),
) as EnumToConstant<typeof pricingPlanTypeEnum.enumValues>;
export type PricingPlanType =
(typeof PricingPlanType)[keyof typeof PricingPlanType];
export const BillingModel = {
ONE_TIME: "one-time",
RECURRING: "recurring",
} as const;
export const RecurringInterval = {
DAY: "day",
MONTH: "month",
WEEK: "week",
YEAR: "year",
} as const;
export const BillingDiscountType = {
PERCENT: "percent",
AMOUNT: "amount",
} as const;
export const RecurringIntervalDuration: Record<RecurringInterval, number> = {
[RecurringInterval.DAY]: 1,
[RecurringInterval.WEEK]: 7,
[RecurringInterval.MONTH]: 30,
[RecurringInterval.YEAR]: 365,
};
export type BillingModel = (typeof BillingModel)[keyof typeof BillingModel];
export type RecurringInterval =
(typeof RecurringInterval)[keyof typeof RecurringInterval];
export type BillingDiscountType =
(typeof BillingDiscountType)[keyof typeof BillingDiscountType];
export type BillingConfig = z.infer<typeof billingConfigSchema>;
export type PricingPlan = z.infer<typeof planSchema>;
export type PricingPlanPrice = z.infer<typeof priceSchema>;
export type Discount = z.infer<typeof discountSchema>;
export type { SelectCustomer as Customer } from "@turbostarter/db/schema";

View File

@@ -0,0 +1,23 @@
import { defineEnv } from "envin";
import * as z from "zod";
import { envConfig } from "@turbostarter/shared/constants";
import { BillingModel } from "../types";
import type { Preset } from "envin/types";
export const sharedPreset = {
id: "shared",
server: {
BILLING_MODEL: z
.enum(BillingModel)
.optional()
.default(BillingModel.RECURRING),
},
} as const satisfies Preset;
export const sharedEnv = defineEnv({
...envConfig,
...sharedPreset,
});

View File

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

View File

@@ -0,0 +1,208 @@
import { BillingDiscountType, BillingModel } from "../types";
import type {
PricingPlanPrice,
PricingPlan,
RecurringInterval,
Discount,
} from "../types";
const INTERVAL_MULTIPLIER: Record<RecurringInterval, number> = {
day: 7,
week: 4,
month: 12,
year: 1,
};
export const getPlanPrice = (
plan: PricingPlan,
options: {
model: BillingModel;
interval?: RecurringInterval;
currency?: string;
},
) => {
const filteredPrices =
options.model === BillingModel.RECURRING && options.interval
? plan.prices.filter(
(price) =>
price.type === BillingModel.RECURRING &&
price.interval === options.interval,
)
: plan.prices.filter((price) => price.type === options.model);
return filteredPrices.find(
(price) =>
!price.currency ||
!options.currency ||
price.currency.toLowerCase() === options.currency.toLowerCase(),
);
};
export const formatPrice = (
price: { amount: number; currency: string },
lang?: string,
) => {
return new Intl.NumberFormat(lang, {
style: "currency",
currency: price.currency,
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(price.amount / 100);
};
export const calculateRecurringDiscount = (
product: PricingPlan,
interval: RecurringInterval,
) => {
const recurringPrices = product.prices.filter(
(price) => price.type === BillingModel.RECURRING,
);
const minPrice = recurringPrices.reduce((acc, price) => {
if (
"amount" in price &&
price.amount < (acc && "amount" in acc ? acc.amount : 0)
) {
return price;
}
return acc;
}, recurringPrices[0]);
const chosenPrice = recurringPrices.find(
(price) => "interval" in price && price.interval === interval,
);
if (!chosenPrice || !minPrice) {
return null;
}
const minMultiplierIndex = Object.entries(INTERVAL_MULTIPLIER).findIndex(
([intervalKey]) =>
"interval" in minPrice && intervalKey === minPrice.interval,
);
const maxMultiplierIndex = Object.entries(INTERVAL_MULTIPLIER).findIndex(
([intervalKey]) => intervalKey === interval,
);
const multiplersToApply = Object.values(INTERVAL_MULTIPLIER).slice(
minMultiplierIndex,
maxMultiplierIndex,
);
const minPriceInSameInterval =
("amount" in minPrice ? minPrice.amount : 0) *
multiplersToApply.reduce((acc, multiplier) => acc * multiplier, 1);
const discount = Math.round(
(1 -
("amount" in chosenPrice ? chosenPrice.amount : 0) /
minPriceInSameInterval) *
100,
);
return {
original: {
...minPrice,
amount: minPriceInSameInterval,
},
discounted: chosenPrice,
percentage: isNaN(discount) ? 0 : discount,
};
};
export const calculatePriceDiscount = (
price: PricingPlanPrice,
discount: Discount,
) => {
if (price.custom) {
return null;
}
const amount = price.amount;
if (discount.type === BillingDiscountType.AMOUNT) {
return {
original: price,
discounted: {
...price,
amount: amount - discount.off,
},
percentage: Math.floor((discount.off / amount) * 100),
type: BillingDiscountType.AMOUNT,
} as const;
}
return {
original: price,
discounted: {
...price,
amount: amount - (amount * discount.off) / 100,
},
percentage: discount.off,
type: BillingDiscountType.PERCENT,
} as const;
};
export const getHighestDiscountForPrice = (
price: PricingPlanPrice,
discounts: Discount[],
) => {
const discountsForPrice = discounts.filter((d) =>
d.appliesTo.includes(price.id),
);
const [highestDiscount] = discountsForPrice.sort((a, b) => {
const discountA = calculatePriceDiscount(price, a);
const discountB = calculatePriceDiscount(price, b);
const amountA =
(discountA?.original.amount ?? 0) - (discountA?.discounted.amount ?? 0);
const amountB =
(discountB?.original.amount ?? 0) - (discountB?.discounted.amount ?? 0);
return amountB - amountA;
});
return highestDiscount;
};
export const getPriceWithHighestDiscount = (
plans: PricingPlan[],
discounts: Discount[],
) => {
const pricesWithDiscounts = plans
.flatMap((plan) => plan.prices)
.map((price) => ({
...price,
discounts: discounts.filter((d) => d.appliesTo.includes(price.id)),
}));
const [priceWithHighestDiscount] = pricesWithDiscounts.sort((a, b) => {
const highestDiscountA = getHighestDiscountForPrice(a, discounts);
const highestDiscountB = getHighestDiscountForPrice(b, discounts);
const discountA = highestDiscountA
? calculatePriceDiscount(a, highestDiscountA)
: null;
const discountB = highestDiscountB
? calculatePriceDiscount(b, highestDiscountB)
: null;
const amountA =
(discountA?.original.amount ?? 0) - (discountA?.discounted.amount ?? 0);
const amountB =
(discountB?.original.amount ?? 0) - (discountB?.discounted.amount ?? 0);
return amountB - amountA;
});
if (!priceWithHighestDiscount) {
return null;
}
return {
...priceWithHighestDiscount,
discount: getHighestDiscountForPrice(priceWithHighestDiscount, discounts),
};
};

View File

@@ -0,0 +1,266 @@
import { describe, expect, it } from "vitest";
import {
BillingDiscountType,
BillingModel,
PricingPlanType,
RecurringInterval,
} from "../../types";
import {
calculatePriceDiscount,
calculateRecurringDiscount,
formatPrice,
getHighestDiscountForPrice,
getPlanPrice,
getPriceWithHighestDiscount,
} from "../price";
import type { Discount, PricingPlan, PricingPlanPrice } from "../../types";
const MONTHLY_PRICE: PricingPlanPrice = {
id: "price_monthly",
type: BillingModel.RECURRING,
interval: RecurringInterval.MONTH,
amount: 1000, // $10.00
currency: "USD",
custom: false,
};
const YEARLY_PRICE: PricingPlanPrice = {
id: "price_yearly",
type: BillingModel.RECURRING,
interval: RecurringInterval.YEAR,
amount: 10000, // $100.00
currency: "USD",
custom: false,
};
const LIFETIME_PRICE: PricingPlanPrice = {
id: "price_lifetime",
type: BillingModel.ONE_TIME,
amount: 30000, // $300.00
currency: "USD",
custom: false,
};
const EUR_PRICE: PricingPlanPrice = {
id: "price_eur",
type: BillingModel.RECURRING,
interval: RecurringInterval.MONTH,
amount: 900, // €9.00
currency: "EUR",
custom: false,
};
const PRICES: PricingPlanPrice[] = [
MONTHLY_PRICE,
YEARLY_PRICE,
LIFETIME_PRICE,
EUR_PRICE,
];
const PLAN: PricingPlan = {
id: PricingPlanType.PREMIUM,
name: "Premium Plan",
description: "Best for professionals",
badge: null,
prices: PRICES,
};
const PERCENT_DISCOUNT: Discount = {
code: "SAVE10",
type: BillingDiscountType.PERCENT,
off: 10,
appliesTo: ["price_monthly", "price_yearly"],
};
const AMOUNT_DISCOUNT: Discount = {
code: "MINUS5",
type: BillingDiscountType.AMOUNT,
off: 500, // $5.00
appliesTo: ["price_monthly"],
};
const DISCOUNTS: Discount[] = [PERCENT_DISCOUNT, AMOUNT_DISCOUNT];
describe("getPlanPrice", () => {
it("should return the correct recurring price for a given interval", () => {
const price = getPlanPrice(PLAN, {
model: BillingModel.RECURRING,
interval: RecurringInterval.MONTH,
});
expect(price).toEqual(MONTHLY_PRICE);
});
it("should return the correct one-time price", () => {
const price = getPlanPrice(PLAN, {
model: BillingModel.ONE_TIME,
});
expect(price).toEqual(LIFETIME_PRICE);
});
it("should match currency if provided", () => {
const price = getPlanPrice(PLAN, {
model: BillingModel.RECURRING,
interval: RecurringInterval.MONTH,
currency: "USD",
});
expect(price).toEqual(MONTHLY_PRICE);
});
it("should match currency case-insensitively", () => {
const price = getPlanPrice(PLAN, {
model: BillingModel.RECURRING,
interval: RecurringInterval.MONTH,
currency: "usd",
});
expect(price).toEqual(MONTHLY_PRICE);
});
it("should return specific currency price when multiple exist", () => {
const price = getPlanPrice(PLAN, {
model: BillingModel.RECURRING,
interval: RecurringInterval.MONTH,
currency: "EUR",
});
expect(price).toEqual(EUR_PRICE);
});
it("should return undefined if no matching price found", () => {
const price = getPlanPrice(PLAN, {
model: BillingModel.RECURRING,
interval: RecurringInterval.WEEK, // No weekly price in mock
});
expect(price).toBeUndefined();
});
});
describe("formatPrice", () => {
it("should format currency correctly using Intl.NumberFormat", () => {
const formatted = formatPrice({ amount: 1234, currency: "USD" }, "en-US");
expect(formatted).toBe("$12.3");
});
it("should handle round numbers without decimals if specified in implementation", () => {
const formatted = formatPrice({ amount: 1000, currency: "USD" }, "en-US");
expect(formatted).toBe("$10");
});
it("should handle default language", () => {
const formatted = formatPrice({ amount: 1000, currency: "USD" });
// Should not throw and return a string
expect(typeof formatted).toBe("string");
});
});
describe("calculateRecurringDiscount", () => {
it("should calculate discount for yearly billing compared to monthly", () => {
const result = calculateRecurringDiscount(PLAN, RecurringInterval.YEAR);
expect(result).toBeDefined();
expect(result?.percentage).toBe(7);
expect(result?.discounted.id).toBe("price_yearly");
expect(result?.original.amount).toBe(10800);
});
it("should return null if price not found", () => {
const result = calculateRecurringDiscount(PLAN, RecurringInterval.WEEK);
expect(result).toBeNull();
});
it("should return null if minPrice is missing/invalid", () => {
// Plan with no recurring prices
const emptyPlan: PricingPlan = { ...PLAN, prices: [] };
const result = calculateRecurringDiscount(
emptyPlan,
RecurringInterval.YEAR,
);
expect(result).toBeNull();
});
});
describe("calculatePriceDiscount", () => {
it("should calculate percentage discount", () => {
const price = MONTHLY_PRICE; // 1000
const discount = PERCENT_DISCOUNT; // 10%
const result = calculatePriceDiscount(price, discount);
expect(result).toBeDefined();
expect(result?.type).toBe(BillingDiscountType.PERCENT);
expect(result?.percentage).toBe(10);
expect(result?.discounted.amount).toBe(900); // 1000 - 10%
});
it("should calculate amount discount", () => {
const price = MONTHLY_PRICE; // 1000
const discount = AMOUNT_DISCOUNT; // 500 off
const result = calculatePriceDiscount(price, discount);
expect(result).toBeDefined();
expect(result?.type).toBe(BillingDiscountType.AMOUNT);
expect(result?.percentage).toBe(50); // 500/1000 * 100
expect(result?.discounted.amount).toBe(500);
});
it("should return null for custom price", () => {
const customPrice: PricingPlanPrice = {
id: "custom",
custom: true,
label: "Contact Us",
href: "/contact",
type: BillingModel.ONE_TIME,
currency: "USD",
};
const result = calculatePriceDiscount(customPrice, PERCENT_DISCOUNT);
expect(result).toBeNull();
});
});
describe("getHighestDiscountForPrice", () => {
it("should select the discount that gives the biggest reduction", () => {
// Price: 1000
// Percent discount: 10% -> 100 off -> 900
// Amount discount: 500 off -> 500 off -> 500
const best = getHighestDiscountForPrice(MONTHLY_PRICE, DISCOUNTS);
expect(best).toBe(AMOUNT_DISCOUNT);
});
it("should return undefined if no discount applies", () => {
const noDiscountPrice: PricingPlanPrice = {
...MONTHLY_PRICE,
id: "price_other",
};
const result = getHighestDiscountForPrice(noDiscountPrice, DISCOUNTS);
expect(result).toBeUndefined();
});
it("should return correct discount when comparison involves amount 0", () => {
// Edge case where discount makes it free or similar
const fullDiscount: Discount = {
code: "FREE",
type: BillingDiscountType.PERCENT,
off: 100,
appliesTo: ["price_monthly"],
};
const discounts = [...DISCOUNTS, fullDiscount];
const best = getHighestDiscountForPrice(MONTHLY_PRICE, discounts);
expect(best).toBe(fullDiscount);
});
});
describe("getPriceWithHighestDiscount", () => {
it("should return the price configuration that yields the biggest saving", () => {
const result = getPriceWithHighestDiscount([PLAN], DISCOUNTS);
expect(result).toBeDefined();
expect(result?.id).toBe(YEARLY_PRICE.id);
expect(result?.discount?.code).toBe("SAVE10");
});
it("should return null if no prices have discounts", () => {
const result = getPriceWithHighestDiscount([PLAN], []);
expect(result).not.toBeNull();
expect(result?.discount).toBeUndefined();
});
});

View File

@@ -0,0 +1,8 @@
{
"extends": "@turbostarter/tsconfig/internal.json",
"compilerOptions": {
"jsx": "preserve"
},
"include": ["src/**/*", "src/env.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,3 @@
import baseConfig from "@turbostarter/vitest-config/base";
export default baseConfig;