Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/stripe.server.ts
T

710 lines
21 KiB
TypeScript

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<Stripe.Invoice[]> {
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<Stripe.Billing.MeterEvent | null> {
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<Stripe.Billing.MeterEventSummary[]> {
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<Stripe.PaymentIntent> = 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<Map<string, Stripe.Invoice>> {
return cachified({
key: `stripe:invoicesByPaymentIntent`,
ttl: 1000 * 60 * 60 * 24, // 24 hours
getFreshValue: async () => {
const map = new Map<string, Stripe.Invoice>();
let hasMore = true;
let startingAfter: string | undefined = undefined;
let max = 10;
while (hasMore) {
const result: Stripe.ApiList<Stripe.Invoice> = 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<StripeRevenueByProductPriceCurrency[]> {
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));
}