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:
45
packages/billing/src/config/features.ts
Normal file
45
packages/billing/src/config/features.ts
Normal 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;
|
||||
18
packages/billing/src/config/index.ts
Normal file
18
packages/billing/src/config/index.ts
Normal 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";
|
||||
107
packages/billing/src/config/plans.ts
Normal file
107
packages/billing/src/config/plans.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
];
|
||||
69
packages/billing/src/config/schema.ts
Normal file
69
packages/billing/src/config/schema.ts
Normal 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([]),
|
||||
});
|
||||
1
packages/billing/src/env.ts
Normal file
1
packages/billing/src/env.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./providers/env";
|
||||
3
packages/billing/src/index.ts
Normal file
3
packages/billing/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./types";
|
||||
export * from "./utils";
|
||||
export * from "./config";
|
||||
36
packages/billing/src/lib/customer.ts
Normal file
36
packages/billing/src/lib/customer.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
18
packages/billing/src/lib/schema.ts
Normal file
18
packages/billing/src/lib/schema.ts
Normal 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>;
|
||||
1
packages/billing/src/providers/env.ts
Normal file
1
packages/billing/src/providers/env.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./stripe/env";
|
||||
1
packages/billing/src/providers/index.ts
Normal file
1
packages/billing/src/providers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./stripe";
|
||||
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}`,
|
||||
);
|
||||
};
|
||||
20
packages/billing/src/providers/lemon-squeezy/client.ts
Normal file
20
packages/billing/src/providers/lemon-squeezy/client.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
81
packages/billing/src/providers/lemon-squeezy/customer.ts
Normal file
81
packages/billing/src/providers/lemon-squeezy/customer.ts
Normal 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;
|
||||
};
|
||||
23
packages/billing/src/providers/lemon-squeezy/env.ts
Normal file
23
packages/billing/src/providers/lemon-squeezy/env.ts
Normal 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,
|
||||
});
|
||||
6
packages/billing/src/providers/lemon-squeezy/index.ts
Normal file
6
packages/billing/src/providers/lemon-squeezy/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { setup } from "./client";
|
||||
|
||||
setup();
|
||||
|
||||
export { checkout, getBillingPortal } from "./checkout";
|
||||
export { webhookHandler } from "./webhook";
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
48
packages/billing/src/providers/lemon-squeezy/subscription.ts
Normal file
48
packages/billing/src/providers/lemon-squeezy/subscription.ts
Normal 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}`,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const LEMON_SQUEEZY_SIGNATURE_HEADER = "X-Signature";
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
124
packages/billing/src/providers/polar/checkout.ts
Normal file
124
packages/billing/src/providers/polar/checkout.ts
Normal 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}`,
|
||||
);
|
||||
};
|
||||
16
packages/billing/src/providers/polar/client.ts
Normal file
16
packages/billing/src/providers/polar/client.ts
Normal 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;
|
||||
};
|
||||
63
packages/billing/src/providers/polar/customer.ts
Normal file
63
packages/billing/src/providers/polar/customer.ts
Normal 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;
|
||||
};
|
||||
26
packages/billing/src/providers/polar/env.ts
Normal file
26
packages/billing/src/providers/polar/env.ts
Normal 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),
|
||||
},
|
||||
});
|
||||
2
packages/billing/src/providers/polar/index.ts
Normal file
2
packages/billing/src/providers/polar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { checkout, getBillingPortal } from "./checkout";
|
||||
export { webhookHandler } from "./webhook";
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
37
packages/billing/src/providers/polar/subscription.ts
Normal file
37
packages/billing/src/providers/polar/subscription.ts
Normal 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}`,
|
||||
);
|
||||
};
|
||||
76
packages/billing/src/providers/polar/webhook/index.ts
Normal file
76
packages/billing/src/providers/polar/webhook/index.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
189
packages/billing/src/providers/stripe/checkout.ts
Normal file
189
packages/billing/src/providers/stripe/checkout.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
15
packages/billing/src/providers/stripe/client.ts
Normal file
15
packages/billing/src/providers/stripe/client.ts
Normal 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;
|
||||
};
|
||||
86
packages/billing/src/providers/stripe/customer.ts
Normal file
86
packages/billing/src/providers/stripe/customer.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
};
|
||||
22
packages/billing/src/providers/stripe/env.ts
Normal file
22
packages/billing/src/providers/stripe/env.ts
Normal 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,
|
||||
});
|
||||
2
packages/billing/src/providers/stripe/index.ts
Normal file
2
packages/billing/src/providers/stripe/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { checkout, getBillingPortal } from "./checkout";
|
||||
export { webhookHandler } from "./webhook";
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
62
packages/billing/src/providers/stripe/subscription.ts
Normal file
62
packages/billing/src/providers/stripe/subscription.ts
Normal 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}`,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const STRIPE_SIGNATURE_HEADER = "stripe-signature";
|
||||
9
packages/billing/src/providers/stripe/webhook/event.ts
Normal file
9
packages/billing/src/providers/stripe/webhook/event.ts
Normal 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);
|
||||
};
|
||||
77
packages/billing/src/providers/stripe/webhook/index.ts
Normal file
77
packages/billing/src/providers/stripe/webhook/index.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
};
|
||||
23
packages/billing/src/providers/types.ts
Normal file
23
packages/billing/src/providers/types.ts
Normal 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 }>;
|
||||
}
|
||||
4
packages/billing/src/server.ts
Normal file
4
packages/billing/src/server.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { webhookHandler, checkout, getBillingPortal } from "./providers";
|
||||
|
||||
export { getCustomerByUserId } from "./lib/customer";
|
||||
export * from "./lib/schema";
|
||||
67
packages/billing/src/types/index.ts
Normal file
67
packages/billing/src/types/index.ts
Normal 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";
|
||||
23
packages/billing/src/utils/env.ts
Normal file
23
packages/billing/src/utils/env.ts
Normal 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,
|
||||
});
|
||||
1
packages/billing/src/utils/index.ts
Normal file
1
packages/billing/src/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./price";
|
||||
208
packages/billing/src/utils/price.ts
Normal file
208
packages/billing/src/utils/price.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
266
packages/billing/src/utils/test/price.test.ts
Normal file
266
packages/billing/src/utils/test/price.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user