import Stripe from "stripe"; import { loadWebConfig } from "@toolctrl/config"; import { SubscriptionUsageBasedPriceDto } from "~/application/dtos/subscriptions/SubscriptionUsageBasedPriceDto"; import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod"; import { cachified } from "./cache.server"; import { getAllSubscriptionProducts } from "./db/subscriptionProducts.db.server"; import { convertToCurrency } from "./services/.server/subscriptionService"; import { getAbsoluteCurrentUrl } from "./url.server"; import { createScopedLogger } from "./logger.server"; const stripeLogger = createScopedLogger("stripe"); const STRIPE_API_VERSION = "2025-10-29.clover"; let stripeClient: Stripe | null = null; let stripeClientSecretKey: string | undefined; export function getStripeSecretKey() { return loadWebConfig().config.stripe.secretKey; } export function getStripeWebhookSigningSecret() { return loadWebConfig().config.stripe.webhookSigningSecret; } export function isStripeConfigured() { return Boolean(getStripeSecretKey()); } export function isStripeTestMode() { return getStripeSecretKey()?.startsWith("sk_test_") ?? true; } export function getStripeClient() { const secretKey = getStripeSecretKey(); if (!secretKey) { throw new Error("STRIPE_SK is required before using Stripe services."); } if (!stripeClient || stripeClientSecretKey !== secretKey) { stripeClient = new Stripe(secretKey, { apiVersion: STRIPE_API_VERSION, }); stripeClientSecretKey = secretKey; } return stripeClient; } const stripe = new Proxy({} as Stripe, { get(_target, property, receiver) { return Reflect.get(getStripeClient(), property, receiver); }, }); export async function createStripeCheckoutSession(data: { subscriptionProduct: { billingAddressCollection?: string | null; }; customer?: string; line_items: { price: string; quantity?: number }[]; mode: "payment" | "setup" | "subscription"; success_url: string; cancel_url: string; freeTrialDays?: number; coupon?: string; promo_code?: string; referral?: string | null; }) { let discounts: { coupon?: string; promotion_code?: string }[] | undefined = undefined; if (data.coupon) { discounts = [{ coupon: data.coupon }]; } else if (data.promo_code) { discounts = [{ promotion_code: data.promo_code }]; } let billing_address_collection: "auto" | "required" | undefined = undefined; if (data.subscriptionProduct.billingAddressCollection === "required") { billing_address_collection = "required"; } else if (data.subscriptionProduct.billingAddressCollection === "auto") { billing_address_collection = "auto"; } let client_reference_id: string | undefined = undefined; if (data.referral) { client_reference_id = data.referral; } return await stripe.checkout.sessions .create({ discounts, customer: data.customer, line_items: data.line_items, mode: data.mode, success_url: data.success_url, cancel_url: data.cancel_url, customer_creation: data.mode === "payment" && !data.customer ? "always" : undefined, payment_method_collection: data.mode === "subscription" ? "if_required" : undefined, subscription_data: { trial_period_days: data.freeTrialDays, }, billing_address_collection, client_reference_id, allow_promotion_codes: !discounts ? true : undefined, }) .catch((e) => { stripeLogger.error({ err: e }, "Stripe checkout session creation failed"); throw e; }); } export async function createStripeSetupSession(request: Request, customer: string) { const currentUrl = getAbsoluteCurrentUrl(request); return await stripe.checkout.sessions.create({ customer, success_url: `${currentUrl}?session_id={CHECKOUT_SESSION_ID}`, cancel_url: currentUrl, mode: "setup", payment_method_types: ["card"], }); } export async function deleteStripePaymentMethod(id: string, customerId?: string | null) { if (customerId) { const paymentMethod = await stripe.paymentMethods.retrieve(id); const paymentMethodCustomerId = typeof paymentMethod.customer === "string" ? paymentMethod.customer : paymentMethod.customer?.id; if (paymentMethodCustomerId !== customerId) { throw new Error("Payment method not found"); } } return await stripe.paymentMethods.detach(id); } export async function getStripeSession(id: string) { return await stripe.checkout.sessions .retrieve(id, { expand: ["line_items"], }) .catch(() => { return null; }); } export async function cancelStripeSubscription(subscriptionId: string) { return await stripe.subscriptions.cancel(subscriptionId).catch((e: any) => { stripeLogger.warn({ err: e }, "Could not cancel stripe subscription"); }); } export async function reactivateStripeSubscription(subscriptionId: string) { return await stripe.subscriptions .update(subscriptionId, { cancel_at_period_end: false, }) .catch((e: any) => { stripeLogger.warn({ err: e }, "Could not reactivate stripe subscription"); }); } export async function getStripeSubscription(id: string) { try { return await stripe.subscriptions.retrieve(id); } catch (e) { return null; } } export async function createStripeSubscription(customer: string, price: string, trial_end?: number) { return await stripe.subscriptions.create({ customer, items: [ { price, }, ], trial_end, }); } export const DEFAULT_STRIPE_INVOICE_LIST_LIMIT = 10; export async function getStripeInvoices(id: string | null, options: { limit?: number } = {}) { if (!id) { return []; } const limit = Math.min(Math.max(Math.floor(options.limit ?? DEFAULT_STRIPE_INVOICE_LIST_LIMIT), 1), 100); try { return ( await stripe.invoices.list({ customer: id, limit, }) ).data; } catch (e) { return []; } } export async function getStripePaymentIntents(id: string | null, status?: Stripe.PaymentIntent.Status) { if (!id) { return []; } try { let items = ( await stripe.paymentIntents.list({ customer: id, }) ).data; if (status) { items = items.filter((item) => item.status === status); } return items; } catch (e) { return []; } } export async function getStripePaymentIntent(id: string) { try { return await stripe.paymentIntents.retrieve(id); } catch (e) { return null; } } export async function getStripeUpcomingInvoice(id: string | null) { if (!id) { return null; } try { const items = await stripe.invoices.createPreview({ customer: id, }); stripeLogger.debug({ items }, "Stripe upcoming invoice retrieved"); return items; } catch (e) { return null; } } export async function getStripeUpcomingInvoices(ids: string[]): Promise { try { const items = await Promise.all( ids.map(async (id) => { const items = await stripe.invoices .createPreview({ subscription: id, }) .catch((e) => { stripeLogger.error({ err: e }, "Could not get upcoming invoice"); return null; }); return items; }) ); return items.filter((x) => x !== null) as Stripe.Invoice[]; } catch (e) { stripeLogger.error({ err: e }, "Failed to fetch upcoming invoices batch"); return []; } } export async function getStripePaymentMethods(customer: string | null) { if (!customer) { return []; } try { return ( await stripe.paymentMethods.list({ customer, type: "card", }) ).data; } catch (e) { return []; } } export async function createStripeCustomer(email: string, name: string) { return await stripe.customers .create({ email, name, }) .catch((e: any) => { stripeLogger.error({ err: e }, "createStripeCustomer failed"); return null; }); } export async function deleteStripeCustomer(id: string) { return await stripe.customers.del(id).catch((e: any) => { stripeLogger.warn({ err: e }, "Could not delete stripe customer"); }); } export async function updateStripeCustomerPaymentMethod(id: string, default_payment_method: string) { return await stripe.customers.update(id, { invoice_settings: { default_payment_method }, }); } export async function createStripeProduct(data: { title: string }) { return await stripe.products.create({ name: data.title, }); } export async function updateStripeProduct(id: string, data: { title: string }) { return await stripe.products .update(id, { name: data.title, }) .catch((error) => { // console.error(error); // ignore }); } export async function createStripePrice( productId: string, data: { billingPeriod: SubscriptionBillingPeriod; price: number; currency: string; trialDays?: number } ) { if (!productId) { return undefined; } let recurring: | { interval: "day" | "week" | "month" | "year"; trial_period_days: number | undefined; interval_count?: number; } | undefined = undefined; switch (data.billingPeriod) { case SubscriptionBillingPeriod.MONTHLY: recurring = { interval: "month", trial_period_days: data.trialDays }; break; case SubscriptionBillingPeriod.QUARTERLY: recurring = { interval: "month", interval_count: 3, trial_period_days: data.trialDays }; break; case SubscriptionBillingPeriod.SEMI_ANNUAL: recurring = { interval: "month", interval_count: 6, trial_period_days: data.trialDays }; break; case SubscriptionBillingPeriod.WEEKLY: recurring = { interval: "week", trial_period_days: data.trialDays }; break; case SubscriptionBillingPeriod.YEARLY: recurring = { interval: "year", trial_period_days: data.trialDays }; break; case SubscriptionBillingPeriod.DAILY: recurring = { interval: "day", trial_period_days: data.trialDays }; break; } return await stripe.prices.create({ unit_amount: Math.round(data.price * 100), currency: data.currency, recurring, product: productId, active: true, }); } export async function createStripeUsageBasedPrice(productId: string, data: SubscriptionUsageBasedPriceDto) { if (!productId) { return undefined; } let interval: "day" | "week" | "month" | "year" = "month"; let interval_count: number | undefined = undefined; switch (data.billingPeriod) { case SubscriptionBillingPeriod.MONTHLY: interval = "month"; break; case SubscriptionBillingPeriod.QUARTERLY: interval = "month"; interval_count = 3; break; case SubscriptionBillingPeriod.SEMI_ANNUAL: interval = "month"; interval_count = 6; break; case SubscriptionBillingPeriod.WEEKLY: interval = "week"; break; case SubscriptionBillingPeriod.YEARLY: interval = "year"; break; case SubscriptionBillingPeriod.DAILY: interval = "day"; break; } const usage_type: "licensed" | "metered" = data.usageType === "licensed" ? "licensed" : "metered"; const tiers_mode: "graduated" | "volume" = data.tiersMode === "graduated" ? "graduated" : "volume"; const billing_scheme: "per_unit" | "tiered" = data.billingScheme === "per_unit" ? "per_unit" : "tiered"; const tiers: { up_to: "inf" | number; unit_amount_decimal?: string; flat_amount_decimal?: string; }[] = data.tiers .sort((x, y) => { if (x.to && y.to) { return x.to > y.to ? 1 : -1; } return 1; }) .map((tier) => { let up_to: "inf" | number = Number(tier.to); if (tier.to === undefined || tier.to === null) { up_to = "inf"; } return { up_to, unit_amount_decimal: tier.perUnitPrice !== undefined ? (Number(tier.perUnitPrice) * 100)?.toString() : undefined, flat_amount_decimal: tier.flatFeePrice !== undefined ? (Number(tier.flatFeePrice) * 100)?.toString() : undefined, }; }); return await stripe.prices.create({ currency: data.currency, tiers_mode, billing_scheme, nickname: data.unitTitle, recurring: { interval, interval_count, usage_type }, product: productId, tiers, active: true, expand: ["tiers"], }); } export async function deleteStripeProduct(productId: string) { return await stripe.products.del(productId); } export async function archiveStripeProduct(productId: string) { return await stripe.products .update(productId, { active: false, }) .catch(() => { // ignore }); } export async function archiveStripePrice(productId: string) { return await stripe.prices .update(productId, { active: false, }) .catch(() => { // ignore }); } /** * Meters-API counterpart of {@link createUsageRecord}. Records a usage * event against an operator-provisioned billing meter. The `eventName` * matches the Stripe Meter's `event_name`; the customer is identified * via the meter's `customer_mapping.event_payload_key` (default * `stripe_customer_id`). The `value` payload field corresponds to the * meter's `value_settings.event_payload_key` (default `value`). * * Idempotency: pass `identifier` for client-side de-dup within Stripe's * 24h rolling window. * * Returns `null` on Stripe error so callers can no-op gracefully; check * structured logs for failure detail. */ export async function createMeterEvent(input: { eventName: string; stripeCustomerId: string; value: number; identifier?: string; timestamp?: number; }): Promise { return await stripe.billing.meterEvents .create({ event_name: input.eventName, payload: { stripe_customer_id: input.stripeCustomerId, value: String(input.value), }, identifier: input.identifier, timestamp: input.timestamp, }) .catch((e: unknown) => { stripeLogger.error({ err: e, eventName: input.eventName }, "Stripe meterEvents.create failed"); return null; }); } /** * Meters-API counterpart of {@link getUsageRecordSummaries}. Reads the * aggregated event summaries for a given meter id and customer. Returns * an empty array on any error so admin UIs degrade gracefully. */ export async function listMeterEventSummaries(meterId: string, params: { customer: string; startTime: number; endTime: number; }): Promise { try { const result = await stripe.billing.meters.listEventSummaries(meterId, { customer: params.customer, start_time: params.startTime, end_time: params.endTime, }); return result.data; } catch (e) { stripeLogger.warn({ err: e, meterId }, "listMeterEventSummaries failed; returning empty"); return []; } } export async function getStripeCoupon(id: string) { return await stripe.coupons.retrieve(id, { expand: ["applies_to"], }); } export async function getStripePromotionCode(id: string) { return await stripe.promotionCodes.retrieve(id); } export async function getStripeCustomer(id: string | null) { if (!id) { return null; } return await stripe.customers.retrieve(id, { expand: ["invoice_settings"], }); } export async function getOpenInvoices(id: string) { return await cachified({ key: `stripe:openInvoices:${id}`, ttl: 1000 * 60 * 60 * 24, // 24 hours getFreshValue: () => stripe.invoices.list({ customer: id, status: "open", }), }); } export async function createCustomerPortalSession(request: Request, id: string) { return await stripe.billingPortal.sessions.create({ customer: id, return_url: getAbsoluteCurrentUrl(request), }); } export async function getAllStripePayments() { return cachified({ key: `stripe:payments`, ttl: 1000 * 60 * 60 * 24, // 24 hours getFreshValue: async () => { let items: Stripe.PaymentIntent[] = []; let hasMore = true; let startingAfter: string | undefined = undefined; let max = 10; while (hasMore) { const result: Stripe.ApiList = await stripe.paymentIntents.list({ limit: 100, starting_after: startingAfter, expand: ["data.charges"], }); items = items.concat(result.data); hasMore = result.has_more; startingAfter = result.data[result.data.length - 1].id; max--; if (max <= 0) { break; } } return items; }, }); } /** * Build a `paymentIntentId → Invoice` map by paginating invoices and * pivoting on `Invoice.payment_intent`. Stripe 19 removed * `PaymentIntent.invoice`, so consumers that previously read * `payment.invoice` directly must traverse the opposite direction * (invoice → payment_intent) and look up the invoice via this map. * * Cached on the same 24h horizon as `getAllStripePayments` so the * revenue rollup remains a single-pass aggregation. */ export async function getAllStripeInvoicesByPaymentIntent(): Promise> { return cachified({ key: `stripe:invoicesByPaymentIntent`, ttl: 1000 * 60 * 60 * 24, // 24 hours getFreshValue: async () => { const map = new Map(); let hasMore = true; let startingAfter: string | undefined = undefined; let max = 10; while (hasMore) { const result: Stripe.ApiList = await stripe.invoices.list({ limit: 100, starting_after: startingAfter, }); for (const invoice of result.data) { // Stripe 19 removed Invoice.payment_intent. The id now lives on // each Invoice.payments[].payment.payment_intent (one invoice // can have multiple payment attempts; we index by all). for (const invoicePayment of invoice.payments?.data ?? []) { const ref = invoicePayment.payment.payment_intent; const piId = typeof ref === "string" ? ref : ref?.id; if (piId) { map.set(piId, invoice); } } } hasMore = result.has_more; startingAfter = result.data[result.data.length - 1]?.id; max--; if (max <= 0) { break; } } return map; }, }); } export type StripeRevenueByProductPriceCurrency = { product: string; billingPeriod: SubscriptionBillingPeriod; currency: string; revenueInCurrency: number; count: number; countPercentage: number; revenueUsd: number; revenuePercentageUsd: number; }; export async function getStripeRevenueByProductPriceCurrency(): Promise { const items: StripeRevenueByProductPriceCurrency[] = []; const allProducts = await getAllSubscriptionProducts(); const [payments, invoicesByPaymentIntent] = await Promise.all([ getAllStripePayments(), getAllStripeInvoicesByPaymentIntent(), ]); payments.forEach((payment) => { if (payment.status !== "succeeded") { return; } // Stripe 19 removed PaymentIntent.invoice. Look up the invoice via // the inverted map built from Invoice.payment_intent. const invoice = invoicesByPaymentIntent.get(payment.id); const lines = invoice?.lines?.data[0]; // Stripe 19 dropped InvoiceLineItem.plan; the (price, product) // identifiers now live on InvoiceLineItem.pricing.price_details. const pricing = lines?.pricing; const amount = payment.amount / 100; const currency = payment.currency; const priceId = pricing?.price_details?.price; const productId = pricing?.price_details?.product; const product = allProducts.find((x) => x.stripeId === productId); let productName = "?"; let billingPeriod: SubscriptionBillingPeriod = SubscriptionBillingPeriod.DAILY; if (product) { productName = product.title; } const price = product?.prices.find((x) => x.stripeId === priceId); if (price) { billingPeriod = price.billingPeriod; } const existing = items.find((x) => x.product === productName && x.currency === currency && x.billingPeriod === billingPeriod); let revenueUsd = amount; if (currency !== "usd") { revenueUsd = convertToCurrency({ from: currency, to: "usd", price: amount, }); } if (existing) { existing.revenueInCurrency += amount; existing.revenueUsd += revenueUsd; existing.count++; } else { items.push({ product: productName, billingPeriod, currency, revenueInCurrency: amount, revenueUsd, count: 1, countPercentage: 0, revenuePercentageUsd: 0, }); } }); // process percentages const totalRevenue = items.reduce((x, y) => x + y.revenueUsd, 0); const totalCount = items.reduce((x, y) => x + y.count, 0); items.forEach((item) => { item.countPercentage = item.count / totalCount; item.revenuePercentageUsd = item.revenueUsd / totalRevenue; }); // sort by revenue desc return items.sort((x, y) => (x.revenueUsd > y.revenueUsd ? -1 : 1)); }