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 @@
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 }>;
}