queue-med/server/services/stripe.ts
Hermes bd580b849e feat: admin settings page - Stripe/Twilio/WhatsApp config UI
- 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
2026-04-25 23:55:43 +00:00

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