- Add AdminSettings page with 4 tabs: Integrations, WhatsApp, Notifications, General - Add tRPC admin endpoints: listConfig, setConfig, deleteConfig, testStripeConnection, testSmsConnection - Add clinicSettings.toggleSms endpoint for per-clinic SMS toggle - Add app_config table schema + DB helpers (listAllConfig, setConfigValue, deleteConfigValue) - Stripe and SMS services now read config from DB first, then env vars fallback - Add Settings nav item in sidebar (admin only) - Add /admin/settings route in App.tsx
288 lines
9.5 KiB
TypeScript
288 lines
9.5 KiB
TypeScript
import Stripe from "stripe";
|
|
import { eq, desc } from "drizzle-orm";
|
|
import { getDb, getSubscription, getUserById, updateSubscription, getConfigValue } from "../db.js";
|
|
import { subscriptions } from "../schema.js";
|
|
|
|
let stripeInstance: Stripe | null = null;
|
|
let stripeInstanceKey: string | null = null;
|
|
|
|
async function resolveStripeKey(): Promise<string | null> {
|
|
const dbKey = await getConfigValue("STRIPE_SECRET_KEY");
|
|
if (dbKey) return dbKey;
|
|
return process.env.STRIPE_SECRET_KEY ?? null;
|
|
}
|
|
|
|
export async function getStripe(): Promise<Stripe> {
|
|
const key = await resolveStripeKey();
|
|
if (!key) {
|
|
throw new Error("STRIPE_SECRET_KEY is not set");
|
|
}
|
|
if (stripeInstance && stripeInstanceKey === key) return stripeInstance;
|
|
stripeInstance = new Stripe(key, {
|
|
apiVersion: "2026-04-22.dahlia",
|
|
typescript: true,
|
|
});
|
|
stripeInstanceKey = key;
|
|
return stripeInstance;
|
|
}
|
|
|
|
export async function isStripeConfigured(): Promise<boolean> {
|
|
const key = await resolveStripeKey();
|
|
return Boolean(key);
|
|
}
|
|
|
|
async function resolvePriceId(envName: string, configKey: string): Promise<string | null> {
|
|
const dbVal = await getConfigValue(configKey);
|
|
if (dbVal) return dbVal;
|
|
return process.env[envName] ?? null;
|
|
}
|
|
|
|
function publicBaseUrl(): string {
|
|
const url = process.env.PUBLIC_BASE_URL ?? "";
|
|
if (!url) {
|
|
throw new Error("PUBLIC_BASE_URL is not set");
|
|
}
|
|
return url.replace(/\/$/, "");
|
|
}
|
|
|
|
async function ensureStripeCustomer(userId: number): Promise<string> {
|
|
const sub = await getSubscription(userId);
|
|
if (sub?.stripeCustomerId) return sub.stripeCustomerId;
|
|
const user = await getUserById(userId);
|
|
if (!user) throw new Error("User not found");
|
|
const stripe = await getStripe();
|
|
const customer = await stripe.customers.create({
|
|
email: user.email,
|
|
name: user.name ?? undefined,
|
|
metadata: { userId: String(userId) },
|
|
});
|
|
await updateSubscription(userId, { stripeCustomerId: customer.id });
|
|
return customer.id;
|
|
}
|
|
|
|
export async function createCheckoutSession(
|
|
userId: number,
|
|
priceId: string
|
|
): Promise<{ url: string; sessionId: string }> {
|
|
const stripe = await getStripe();
|
|
const customerId = await ensureStripeCustomer(userId);
|
|
const base = publicBaseUrl();
|
|
const session = await stripe.checkout.sessions.create({
|
|
mode: "subscription",
|
|
customer: customerId,
|
|
line_items: [{ price: priceId, quantity: 1 }],
|
|
success_url: `${base}/subscription/success?session_id={CHECKOUT_SESSION_ID}`,
|
|
cancel_url: `${base}/subscription/cancel`,
|
|
allow_promotion_codes: true,
|
|
subscription_data: { metadata: { userId: String(userId) } },
|
|
metadata: { userId: String(userId) },
|
|
});
|
|
if (!session.url) throw new Error("Stripe Checkout did not return a URL");
|
|
return { url: session.url, sessionId: session.id };
|
|
}
|
|
|
|
export async function createPortalSession(userId: number): Promise<{ url: string }> {
|
|
const stripe = await getStripe();
|
|
const sub = await getSubscription(userId);
|
|
if (!sub?.stripeCustomerId) {
|
|
throw new Error("No Stripe customer for this user yet");
|
|
}
|
|
const base = publicBaseUrl();
|
|
const portal = await stripe.billingPortal.sessions.create({
|
|
customer: sub.stripeCustomerId,
|
|
return_url: `${base}/dashboard/subscription`,
|
|
});
|
|
return { url: portal.url };
|
|
}
|
|
|
|
export async function getSubscriptionStatus(userId: number): Promise<{
|
|
plan: "trial" | "basic" | "pro" | null;
|
|
status: string | null;
|
|
currentPeriodEnd: Date | null;
|
|
trialEndsAt: Date | null;
|
|
stripeCustomerId: string | null;
|
|
stripeSubscriptionId: string | null;
|
|
} | null> {
|
|
const sub = await getSubscription(userId);
|
|
if (!sub) return null;
|
|
return {
|
|
plan: sub.plan,
|
|
status: sub.status,
|
|
currentPeriodEnd: sub.currentPeriodEnd,
|
|
trialEndsAt: sub.trialEndsAt,
|
|
stripeCustomerId: sub.stripeCustomerId,
|
|
stripeSubscriptionId: sub.stripeSubscriptionId,
|
|
};
|
|
}
|
|
|
|
async function planFromPriceId(priceId: string | null | undefined): Promise<"basic" | "pro"> {
|
|
const proPriceId = await resolvePriceId("STRIPE_PRO_PRICE_ID", "STRIPE_PRO_PRICE_ID");
|
|
if (priceId && proPriceId && priceId === proPriceId) return "pro";
|
|
return "basic";
|
|
}
|
|
|
|
async function findUserBySubscriptionId(
|
|
stripeSubscriptionId: string
|
|
): Promise<number | null> {
|
|
const db = await getDb();
|
|
const rows = await db
|
|
.select()
|
|
.from(subscriptions)
|
|
.where(eq(subscriptions.stripeSubscriptionId, stripeSubscriptionId))
|
|
.orderBy(desc(subscriptions.createdAt))
|
|
.limit(1);
|
|
return rows[0]?.userId ?? null;
|
|
}
|
|
|
|
async function findUserByCustomerId(stripeCustomerId: string): Promise<number | null> {
|
|
const db = await getDb();
|
|
const rows = await db
|
|
.select()
|
|
.from(subscriptions)
|
|
.where(eq(subscriptions.stripeCustomerId, stripeCustomerId))
|
|
.orderBy(desc(subscriptions.createdAt))
|
|
.limit(1);
|
|
return rows[0]?.userId ?? null;
|
|
}
|
|
|
|
function toDate(epochSeconds: number | null | undefined): Date | null {
|
|
if (!epochSeconds) return null;
|
|
return new Date(epochSeconds * 1000);
|
|
}
|
|
|
|
function mapStripeStatus(
|
|
s: Stripe.Subscription.Status
|
|
): "trialing" | "active" | "past_due" | "canceled" | "expired" {
|
|
switch (s) {
|
|
case "trialing":
|
|
return "trialing";
|
|
case "active":
|
|
return "active";
|
|
case "past_due":
|
|
case "unpaid":
|
|
return "past_due";
|
|
case "canceled":
|
|
return "canceled";
|
|
case "incomplete":
|
|
case "incomplete_expired":
|
|
case "paused":
|
|
return "expired";
|
|
default:
|
|
return "expired";
|
|
}
|
|
}
|
|
|
|
async function syncSubscriptionFromStripe(
|
|
userId: number,
|
|
subscription: Stripe.Subscription
|
|
): Promise<void> {
|
|
const item = subscription.items.data[0];
|
|
const priceId = item?.price?.id ?? null;
|
|
const plan = await planFromPriceId(priceId);
|
|
const status = mapStripeStatus(subscription.status);
|
|
await updateSubscription(userId, {
|
|
stripeSubscriptionId: subscription.id,
|
|
stripeCustomerId:
|
|
typeof subscription.customer === "string"
|
|
? subscription.customer
|
|
: subscription.customer.id,
|
|
stripePriceId: priceId,
|
|
plan,
|
|
status,
|
|
currentPeriodStart: toDate(item?.current_period_start ?? null),
|
|
currentPeriodEnd: toDate(item?.current_period_end ?? null),
|
|
canceledAt: toDate(subscription.canceled_at),
|
|
});
|
|
}
|
|
|
|
export async function handleWebhook(event: Stripe.Event): Promise<void> {
|
|
const stripe = await getStripe();
|
|
switch (event.type) {
|
|
case "checkout.session.completed": {
|
|
const session = event.data.object as Stripe.Checkout.Session;
|
|
const userIdRaw = session.metadata?.userId;
|
|
const userId = userIdRaw ? Number(userIdRaw) : null;
|
|
if (!userId || Number.isNaN(userId)) return;
|
|
if (typeof session.subscription === "string") {
|
|
const sub = await stripe.subscriptions.retrieve(session.subscription);
|
|
await syncSubscriptionFromStripe(userId, sub);
|
|
}
|
|
break;
|
|
}
|
|
case "customer.subscription.created":
|
|
case "customer.subscription.updated": {
|
|
const sub = event.data.object as Stripe.Subscription;
|
|
const metaUserId = sub.metadata?.userId ? Number(sub.metadata.userId) : null;
|
|
let userId = metaUserId && !Number.isNaN(metaUserId) ? metaUserId : null;
|
|
if (!userId) {
|
|
userId = await findUserBySubscriptionId(sub.id);
|
|
}
|
|
if (!userId) {
|
|
const customerId =
|
|
typeof sub.customer === "string" ? sub.customer : sub.customer.id;
|
|
userId = await findUserByCustomerId(customerId);
|
|
}
|
|
if (!userId) return;
|
|
await syncSubscriptionFromStripe(userId, sub);
|
|
break;
|
|
}
|
|
case "customer.subscription.deleted": {
|
|
const sub = event.data.object as Stripe.Subscription;
|
|
let userId = await findUserBySubscriptionId(sub.id);
|
|
if (!userId) {
|
|
const customerId =
|
|
typeof sub.customer === "string" ? sub.customer : sub.customer.id;
|
|
userId = await findUserByCustomerId(customerId);
|
|
}
|
|
if (!userId) return;
|
|
await updateSubscription(userId, {
|
|
status: "canceled",
|
|
canceledAt: toDate(sub.canceled_at) ?? new Date(),
|
|
});
|
|
break;
|
|
}
|
|
case "invoice.paid": {
|
|
const invoice = event.data.object as Stripe.Invoice;
|
|
const subRef = invoice.parent?.subscription_details?.subscription ?? null;
|
|
const subscriptionId =
|
|
typeof subRef === "string" ? subRef : subRef?.id ?? null;
|
|
const customerId =
|
|
typeof invoice.customer === "string"
|
|
? invoice.customer
|
|
: invoice.customer?.id ?? null;
|
|
let userId: number | null = null;
|
|
if (subscriptionId) userId = await findUserBySubscriptionId(subscriptionId);
|
|
if (!userId && customerId) userId = await findUserByCustomerId(customerId);
|
|
if (!userId || !subscriptionId) return;
|
|
const sub = await stripe.subscriptions.retrieve(subscriptionId);
|
|
await syncSubscriptionFromStripe(userId, sub);
|
|
break;
|
|
}
|
|
case "invoice.payment_failed": {
|
|
const invoice = event.data.object as Stripe.Invoice;
|
|
const customerId =
|
|
typeof invoice.customer === "string"
|
|
? invoice.customer
|
|
: invoice.customer?.id ?? null;
|
|
if (!customerId) return;
|
|
const userId = await findUserByCustomerId(customerId);
|
|
if (!userId) return;
|
|
await updateSubscription(userId, { status: "past_due" });
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
export async function verifyAndConstructEvent(
|
|
rawBody: Buffer,
|
|
signature: string
|
|
): Promise<Stripe.Event> {
|
|
const stripe = await getStripe();
|
|
const secret = (await getConfigValue("STRIPE_WEBHOOK_SECRET")) ?? process.env.STRIPE_WEBHOOK_SECRET;
|
|
if (!secret) {
|
|
throw new Error("STRIPE_WEBHOOK_SECRET is not set");
|
|
}
|
|
return stripe.webhooks.constructEvent(rawBody, signature, secret);
|
|
}
|