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:
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",
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user