refactor(server): split stripe route operations

This commit is contained in:
RainbowBird
2026-06-03 13:48:18 +08:00
parent 03d2dc05db
commit ea1c658c8d
4 changed files with 668 additions and 510 deletions
+40 -510
View File
@@ -11,38 +11,32 @@ import type { HonoEnv } from '../../types/hono'
import Stripe from 'stripe'
import { useLogger } from '@guiiai/logg'
import { Hono } from 'hono'
import { safeParse } from 'valibot'
import { authGuard } from '../../middlewares/auth'
import { rateLimiter } from '../../middlewares/rate-limit'
import { captureSafe } from '../../services/adapters/posthog'
import { createBadRequestError, createServiceUnavailableError } from '../../utils/error'
import { errorMessageFromUnknown } from '../../utils/error-message'
import { resolveCheckoutRedirectBase } from '../../utils/origin'
import { redisKeyFrom } from '../../utils/redis-keys'
import { CheckoutBodySchema } from './schema'
import { createCheckoutOperation } from './operations/checkout'
import { createWebhookOperation } from './operations/webhook'
import { createStripePriceCatalog, formatPrice } from './price-catalog'
const logger = useLogger('stripe')
const PRICES_CACHE_KEY = redisKeyFrom('cache', 'stripe', 'prices')
const PRICES_CACHE_TTL_SEC = 5 * 60
interface CachedCurrencyOption {
unitAmount: number | null
}
interface CachedPrice {
id: string
unitAmount: number | null
currency: string
product: string
active: boolean
metadata: Record<string, string>
currencyOptions: Record<string, CachedCurrencyOption>
}
export { formatPrice } from './price-catalog'
/**
* Creates Stripe HTTP routes for Flux purchase and billing records.
*
* Use when:
* - Mounting `/api/v1/stripe` in the server app.
* - Wiring Stripe checkout, customer portal, package catalog, and webhooks.
*
* Expects:
* - Auth middleware to populate `c.get('user')` for protected endpoints.
* - Stripe configuration to be present for checkout, portal, and webhook routes.
*
* Returns:
* - A Hono router scoped to Stripe endpoints.
*/
export function createStripeRoutes(
fluxService: FluxService,
stripeService: StripeService,
@@ -55,54 +49,27 @@ export function createStripeRoutes(
posthog?: PostHog | null,
) {
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null
async function getActivePrices(productId: string): Promise<CachedPrice[]> {
// Try Redis cache first
const cached = await redis.get(PRICES_CACHE_KEY)
if (cached) {
try {
const parsed = JSON.parse(cached) as { productId: string, prices: CachedPrice[] }
if (parsed.productId === productId)
return parsed.prices
}
catch { /* corrupted cache, refetch */ }
}
let result: Stripe.ApiList<Stripe.Price>
try {
result = await stripe!.prices.list({ product: productId, active: true, expand: ['data.currency_options'] })
}
catch (err) {
logger.withError(err).warn('Failed to fetch prices from Stripe')
return []
}
const prices: CachedPrice[] = result.data
.sort((a, b) => (a.unit_amount ?? 0) - (b.unit_amount ?? 0))
.map(p => ({
id: p.id,
unitAmount: p.unit_amount,
currency: p.currency,
product: typeof p.product === 'string' ? p.product : p.product.id,
active: p.active,
metadata: p.metadata,
currencyOptions: Object.fromEntries(
Object.entries(p.currency_options ?? {}).map(([cur, opt]) => [cur, { unitAmount: opt.unit_amount }]),
),
}))
await redis.set(PRICES_CACHE_KEY, JSON.stringify({ productId, prices }), 'EX', PRICES_CACHE_TTL_SEC)
return prices
}
const priceCatalog = stripe ? createStripePriceCatalog(stripe, redis) : null
const checkout = createCheckoutOperation({ stripe, priceCatalog, stripeService, configKV, env, metrics })
const webhook = createWebhookOperation({
stripe,
webhookSecret: env.STRIPE_WEBHOOK_SECRET,
fluxService,
stripeService,
billingService,
metrics,
posthog,
})
return new Hono<HonoEnv>()
.get('/packages', async (c) => {
const fluxProductId = await configKV.getOptional('STRIPE_FLUX_PRODUCT_ID')
if (!stripe || !fluxProductId)
if (!priceCatalog || !fluxProductId)
return c.json([])
const prices = await getActivePrices(fluxProductId)
const prices = await priceCatalog.getActivePrices(fluxProductId)
// Build per-currency price map for each package
// Build per-currency price map for each package.
return c.json(prices.map((p) => {
const currencies: Record<string, string> = {
[p.currency]: formatPrice(p.unitAmount, p.currency),
@@ -121,125 +88,23 @@ export function createStripeRoutes(
}))
})
.post('/checkout', authGuard, rateLimiter({ max: 10, windowSec: 60, metrics: rateLimitMetrics, routeLabel: 'stripe.checkout' }), async (c) => {
const fluxProductId = await configKV.getOptional('STRIPE_FLUX_PRODUCT_ID')
if (!stripe || !fluxProductId)
throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED')
const user = c.get('user')!
const body = await c.req.json()
const result = safeParse(CheckoutBodySchema, body)
if (!result.success)
throw createBadRequestError('Invalid checkout request', 'INVALID_REQUEST', result.issues)
const { stripePriceId, currency } = result.output
// Validate against cached prices first, fall back to direct Stripe API
const cachedPrices = await getActivePrices(fluxProductId)
let price = cachedPrices.find(p => p.id === stripePriceId)
if (!price) {
// Cache miss — price may have just been created
let fetched: Stripe.Price
try {
fetched = await stripe.prices.retrieve(stripePriceId)
}
catch {
throw createBadRequestError('Invalid price', 'INVALID_PACKAGE', { stripePriceId })
}
if (!fetched.active || (typeof fetched.product === 'string' ? fetched.product : fetched.product.id) !== fluxProductId) {
throw createBadRequestError('Invalid price', 'INVALID_PACKAGE', { stripePriceId })
}
price = {
id: fetched.id,
unitAmount: fetched.unit_amount,
currency: fetched.currency,
product: typeof fetched.product === 'string' ? fetched.product : fetched.product.id,
active: fetched.active,
metadata: fetched.metadata,
currencyOptions: Object.fromEntries(
Object.entries(fetched.currency_options ?? {}).map(([cur, opt]) => [cur, { unitAmount: opt.unit_amount }]),
),
}
// Invalidate cache so all instances pick up the new price
await redis.del(PRICES_CACHE_KEY)
}
const fluxAmount = Number(price.metadata.fluxAmount)
if (!Number.isFinite(fluxAmount) || fluxAmount <= 0) {
throw createBadRequestError('Price is missing fluxAmount metadata', 'INVALID_PACKAGE', { stripePriceId })
}
// Reuse existing stripe customer if available
const customer = await stripeService.getCustomerByUserId(user.id)
const stripeCustomerId = customer?.stripeCustomerId
const redirectBase = resolveCheckoutRedirectBase(c.req.raw, env.ADDITIONAL_TRUSTED_ORIGINS, env.WEB_APP_URL)
const paymentMethods = await configKV.getOptional('STRIPE_PAYMENT_METHODS')
const paymentMethodOptions = await configKV.getOptional('STRIPE_PAYMENT_METHOD_OPTIONS') ?? {}
const session = await stripe.checkout.sessions.create({
// When STRIPE_PAYMENT_METHODS is not set, omit payment_method_types to let Stripe
// automatically determine available methods based on currency and Dashboard settings
...(paymentMethods && { payment_method_types: paymentMethods as any }),
...(Object.keys(paymentMethodOptions).length > 0 && { payment_method_options: paymentMethodOptions as any }),
// When currency is specified, Stripe uses the matching currency_options on the Price
...(currency && { currency }),
line_items: [{ price: stripePriceId, quantity: 1 }],
mode: 'payment',
allow_promotion_codes: true,
success_url: `${redirectBase}/settings/flux?success=true`,
cancel_url: `${redirectBase}/settings/flux?canceled=true`,
customer: stripeCustomerId,
customer_email: stripeCustomerId ? undefined : user.email,
metadata: {
userId: user.id,
fluxAmount: String(fluxAmount),
},
})
// Persist the checkout session
await stripeService.upsertCheckoutSession({
userId: user.id,
stripeSessionId: session.id,
stripeCustomerId: typeof session.customer === 'string' ? session.customer : session.customer?.id,
mode: session.mode ?? 'payment',
status: session.status,
paymentStatus: session.payment_status,
amountTotal: session.amount_total,
currency: session.currency,
successUrl: session.success_url,
cancelUrl: session.cancel_url,
stripePaymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id,
stripeSubscriptionId: typeof session.subscription === 'string' ? session.subscription : session.subscription?.id,
metadata: session.metadata ? JSON.stringify(session.metadata) : null,
expiresAt: session.expires_at ? new Date(session.expires_at * 1000) : null,
})
metrics?.stripeCheckoutCreated.add(1)
return c.json({ url: session.url })
return c.json(await checkout({
user: c.get('user')!,
body,
request: c.req.raw,
}))
})
// ---- Orders / checkout sessions history ----
.get('/orders', authGuard, async (c) => {
const user = c.get('user')!
const sessions = await stripeService.getCheckoutSessionsByUserId(user.id)
return c.json(sessions)
})
// ---- Invoices history ----
.get('/invoices', authGuard, async (c) => {
const user = c.get('user')!
const invoices = await stripeService.getInvoicesByUserId(user.id)
return c.json(invoices)
})
// ---- Customer portal ----
.post('/portal', authGuard, async (c) => {
if (!stripe)
throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED')
@@ -258,344 +123,9 @@ export function createStripeRoutes(
return c.json({ url: portalSession.url })
})
// ---- Webhook ----
.post('/webhook', async (c) => {
if (!stripe || !env.STRIPE_WEBHOOK_SECRET)
throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED')
const sig = c.req.header('stripe-signature')
if (!sig)
throw createBadRequestError('No signature', 'MISSING_SIGNATURE')
let event: Stripe.Event
try {
const body = await c.req.text()
event = stripe.webhooks.constructEvent(body, sig, env.STRIPE_WEBHOOK_SECRET)
}
catch (err: unknown) {
throw createBadRequestError(`Webhook Error: ${errorMessageFromUnknown(err)}`, 'WEBHOOK_ERROR')
}
logger.withFields({ type: event.type, id: event.id }).log('Webhook event received')
metrics?.stripeEvents.add(1, { event_type: event.type })
switch (event.type) {
case 'checkout.session.completed': {
const result = await handleCheckoutSessionCompleted(event.id, event.data.object, fluxService, stripeService, billingService)
metrics?.stripeCheckoutCompleted.add(1)
// Revenue capture in smallest currency unit (e.g. cents).
// Cross-currency aggregation is meaningless, so always group by `currency` in queries.
if (event.data.object.amount_total != null && event.data.object.currency) {
metrics?.stripeRevenue.add(event.data.object.amount_total, {
currency: event.data.object.currency,
source: 'checkout',
})
}
// PostHog: funnel terminator. Only fire when the handler actually
// processed the checkout — malformed sessions (missing userId,
// invalid fluxAmount) take the early-return path above and would
// otherwise poison the funnel with phantom conversions. distinctId
// is the Better Auth user id so it merges with the browser's
// `posthog.identify(userId)` and the prior `checkout_started`
// event lines up. See docs/ai-context/metrics-ownership.md.
if (result.processed)
await capturePaymentCompleted(posthog, event.data.object)
break
}
case 'customer.created':
case 'customer.updated': {
await handleCustomerEvent(event.data.object, stripeService)
break
}
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
await handleSubscriptionEvent(event.data.object, stripeService)
metrics?.stripeSubscriptionEvent.add(1, { event_type: event.type.replace('customer.subscription.', '') })
if (event.type === 'customer.subscription.deleted')
await captureSubscriptionCancelled(posthog, stripeService, event.data.object)
break
}
case 'invoice.created':
case 'invoice.updated':
case 'invoice.paid':
case 'invoice.payment_failed': {
await handleInvoiceEvent(event.data.object, stripeService)
if (event.type === 'invoice.payment_failed') {
metrics?.stripePaymentFailed.add(1)
}
if (event.type === 'invoice.paid' && event.data.object.amount_paid && event.data.object.currency) {
metrics?.stripeRevenue.add(event.data.object.amount_paid, {
currency: event.data.object.currency,
source: 'invoice',
})
}
break
}
}
return c.json({ received: true })
const signature = c.req.header('stripe-signature') ?? null
const body = signature ? await c.req.text() : ''
return c.json(await webhook({ signature, body }))
})
}
// ---- Webhook handlers ----
async function handleCheckoutSessionCompleted(
stripeEventId: string,
session: Stripe.Checkout.Session,
fluxService: FluxService,
stripeService: StripeService,
billingService: BillingService,
): Promise<{ processed: boolean }> {
const userId = session.metadata?.userId
if (!userId) {
logger.withFields({ sessionId: session.id }).warn('Checkout session missing userId in metadata')
return { processed: false }
}
logger.withFields({ userId, sessionId: session.id, mode: session.mode, amount: session.amount_total, currency: session.currency }).log('Processing checkout session')
// Upsert customer record if we got a customer back
if (session.customer) {
const stripeCustomerId = typeof session.customer === 'string' ? session.customer : session.customer.id
await stripeService.upsertCustomer({
userId,
stripeCustomerId,
email: session.customer_email ?? undefined,
})
await fluxService.updateStripeCustomerId(userId, stripeCustomerId)
}
// Update the checkout session record
await stripeService.upsertCheckoutSession({
userId,
stripeSessionId: session.id,
stripeCustomerId: typeof session.customer === 'string' ? session.customer : session.customer?.id,
mode: session.mode ?? 'payment',
status: session.status,
paymentStatus: session.payment_status,
amountTotal: session.amount_total,
currency: session.currency,
successUrl: session.success_url,
cancelUrl: session.cancel_url,
stripePaymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id,
stripeSubscriptionId: typeof session.subscription === 'string' ? session.subscription : session.subscription?.id,
metadata: session.metadata ? JSON.stringify(session.metadata) : null,
expiresAt: session.expires_at ? new Date(session.expires_at * 1000) : null,
})
// Idempotent flux credit: use fluxCredited flag inside a transaction
// to prevent double-crediting on webhook replay.
//
// For `payment` mode (one-time Flux purchase) `metadata.fluxAmount` is
// required — without it we can't credit anything, and the funnel must
// not see a `payment_completed` event for a checkout that didn't
// actually deliver Flux. Non-`payment` modes (e.g. `setup` for saving
// a card) deliberately skip crediting and still count as processed.
if (session.mode === 'payment') {
if (session.amount_total == null) {
logger.withFields({ userId, sessionId: session.id }).warn('Payment-mode checkout missing amount_total; skipping credit and capture')
return { processed: false }
}
const metadataFlux = session.metadata?.fluxAmount
if (!metadataFlux) {
logger.withFields({ userId, sessionId: session.id }).warn('Payment-mode checkout missing metadata.fluxAmount; skipping credit and capture')
return { processed: false }
}
const fluxAmount = Number(metadataFlux)
if (!Number.isFinite(fluxAmount) || fluxAmount <= 0) {
logger.withFields({ userId, sessionId: session.id, metadataFlux }).warn('Invalid fluxAmount in session metadata, skipping credit')
return { processed: false }
}
const result = await billingService.creditFluxFromStripeCheckout({
stripeEventId,
userId,
stripeSessionId: session.id,
amountTotal: session.amount_total,
currency: session.currency,
fluxAmount,
})
logger.withFields({
userId,
fluxAmount,
amountTotal: session.amount_total,
applied: result.applied,
balanceAfter: result.balanceAfter,
}).log('Processed flux credit for one-time payment')
}
return { processed: true }
}
async function capturePaymentCompleted(
posthog: PostHog | null | undefined,
session: Stripe.Checkout.Session,
): Promise<void> {
if (!posthog)
return
const userId = session.metadata?.userId
const email = session.customer_email
|| (typeof session.customer_details?.email === 'string' ? session.customer_details.email : null)
|| null
// distinctId fallback chain: userId (Better Auth, matches browser identify())
// > email (PostHog will merge on identify later) > stripe session id (last
// resort — orphan event but at least we count it).
const distinctId = userId || email || session.id
const fluxAmount = Number(session.metadata?.fluxAmount)
const stripeCustomerId = typeof session.customer === 'string' ? session.customer : session.customer?.id
await captureSafe(posthog, {
distinctId,
event: 'payment_completed',
properties: {
amount_total: session.amount_total,
currency: session.currency,
flux_amount: Number.isFinite(fluxAmount) ? fluxAmount : null,
mode: session.mode,
stripe_session_id: session.id,
stripe_customer_id: stripeCustomerId,
stripe_subscription_id: typeof session.subscription === 'string' ? session.subscription : session.subscription?.id,
...(userId ? { user_id: userId } : {}),
// $set populates the PostHog person profile so funnel joins work even
// when a user pays via direct checkout link before ever loading the SPA.
...(email ? { $set: { email } } : {}),
},
})
}
async function captureSubscriptionCancelled(
posthog: PostHog | null | undefined,
stripeService: StripeService,
subscription: Stripe.Subscription,
): Promise<void> {
if (!posthog)
return
const stripeCustomerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id
const customer = await stripeService.getCustomerByStripeId(stripeCustomerId)
const distinctId = customer?.userId || stripeCustomerId
await captureSafe(posthog, {
distinctId,
event: 'subscription_cancelled',
properties: {
stripe_subscription_id: subscription.id,
stripe_customer_id: stripeCustomerId,
cancel_at_period_end: subscription.cancel_at_period_end,
cancellation_reason: subscription.cancellation_details?.reason ?? null,
cancellation_comment: subscription.cancellation_details?.comment ?? null,
canceled_at: subscription.canceled_at,
ended_at: subscription.ended_at,
...(customer?.userId ? { user_id: customer.userId } : {}),
},
})
}
async function handleCustomerEvent(
customer: Stripe.Customer | Stripe.DeletedCustomer,
stripeService: StripeService,
) {
if (customer.deleted)
return
// Try to find existing customer to get userId
const existing = await stripeService.getCustomerByStripeId(customer.id)
if (!existing)
return // We don't know the userId yet; will be linked on checkout
await stripeService.upsertCustomer({
userId: existing.userId,
stripeCustomerId: customer.id,
email: customer.email ?? undefined,
name: customer.name ?? undefined,
})
}
async function handleSubscriptionEvent(
subscription: Stripe.Subscription,
stripeService: StripeService,
) {
const stripeCustomerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id
const customer = await stripeService.getCustomerByStripeId(stripeCustomerId)
if (!customer)
return
// In newer Stripe API, period info is on subscription items
const firstItem = subscription.items.data[0]
await stripeService.upsertSubscription({
userId: customer.userId,
stripeSubscriptionId: subscription.id,
stripeCustomerId,
stripePriceId: firstItem?.price?.id,
status: subscription.status,
currentPeriodStart: firstItem?.current_period_start ? new Date(firstItem.current_period_start * 1000) : null,
currentPeriodEnd: firstItem?.current_period_end ? new Date(firstItem.current_period_end * 1000) : null,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null,
endedAt: subscription.ended_at ? new Date(subscription.ended_at * 1000) : null,
metadata: subscription.metadata ? JSON.stringify(subscription.metadata) : null,
})
}
async function handleInvoiceEvent(
invoice: Stripe.Invoice,
stripeService: StripeService,
) {
const stripeCustomerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id
if (!stripeCustomerId)
return
const customer = await stripeService.getCustomerByStripeId(stripeCustomerId)
if (!customer)
return
// In newer Stripe API, subscription is under parent.subscription_details
const subDetails = invoice.parent?.subscription_details
const subscriptionId = subDetails
? (typeof subDetails.subscription === 'string' ? subDetails.subscription : subDetails.subscription?.id)
: undefined
await stripeService.upsertInvoice({
userId: customer.userId,
stripeInvoiceId: invoice.id,
stripeCustomerId,
stripeSubscriptionId: subscriptionId,
status: invoice.status,
amountDue: invoice.amount_due,
amountPaid: invoice.amount_paid,
currency: invoice.currency,
invoiceUrl: invoice.hosted_invoice_url,
invoicePdf: invoice.invoice_pdf,
periodStart: new Date(invoice.period_start * 1000),
periodEnd: new Date(invoice.period_end * 1000),
paidAt: invoice.status_transitions?.paid_at ? new Date(invoice.status_transitions.paid_at * 1000) : null,
metadata: invoice.metadata ? JSON.stringify(invoice.metadata) : null,
})
// TODO: implement subscription-based flux crediting when subscriptions are enabled
if (invoice.status === 'paid' && invoice.amount_paid && subscriptionId) {
logger.withFields({ userId: customer.userId, invoiceId: invoice.id, amountPaid: invoice.amount_paid }).warn('Subscription invoice paid but flux crediting for subscriptions is not yet implemented')
}
}
/** Format Stripe smallest-unit amount into a human-readable price string */
export function formatPrice(unitAmount: number | null, currency: string): string {
if (unitAmount == null)
return currency.toUpperCase()
try {
const formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency })
const fractionDigits = formatter.resolvedOptions().minimumFractionDigits ?? 2
const amount = unitAmount / (10 ** fractionDigits)
return formatter.format(amount)
}
catch {
return `${unitAmount / 100} ${currency.toUpperCase()}`
}
}
@@ -0,0 +1,127 @@
import type Stripe from 'stripe'
import type { Env } from '../../../libs/env'
import type { RevenueMetrics } from '../../../otel'
import type { ConfigKVService } from '../../../services/adapters/config-kv'
import type { StripeService } from '../../../services/domain/stripe'
import type { HonoEnv } from '../../../types/hono'
import type { StripePriceCatalog } from '../price-catalog'
import { safeParse } from 'valibot'
import { createBadRequestError, createServiceUnavailableError } from '../../../utils/error'
import { resolveCheckoutRedirectBase } from '../../../utils/origin'
import { CheckoutBodySchema } from '../schema'
type AuthenticatedUser = NonNullable<HonoEnv['Variables']['user']>
type CheckoutSessionCreateParams = NonNullable<Parameters<Stripe['checkout']['sessions']['create']>[0]>
export interface CheckoutOperationDeps {
stripe: Stripe | null
priceCatalog: StripePriceCatalog | null
stripeService: StripeService
configKV: ConfigKVService
env: Env
metrics?: RevenueMetrics | null
}
export interface CheckoutOperationInput {
user: AuthenticatedUser
body: unknown
request: Request
}
/**
* Creates Stripe checkout sessions for Flux packages.
*
* Use when:
* - A signed-in user starts a one-time Flux purchase.
* - The route already enforced auth and rate limiting.
*
* Expects:
* - ConfigKV has `STRIPE_FLUX_PRODUCT_ID`.
* - `body` matches {@link CheckoutBodySchema}.
*
* Returns:
* - A Stripe-hosted checkout URL.
*/
export function createCheckoutOperation(deps: CheckoutOperationDeps) {
return async (input: CheckoutOperationInput): Promise<{ url: string | null }> => {
const fluxProductId = await deps.configKV.getOptional('STRIPE_FLUX_PRODUCT_ID')
if (!deps.stripe || !deps.priceCatalog || !fluxProductId)
throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED')
const result = safeParse(CheckoutBodySchema, input.body)
if (!result.success)
throw createBadRequestError('Invalid checkout request', 'INVALID_REQUEST', result.issues)
const { stripePriceId, currency } = result.output
const price = await deps.priceCatalog.findActivePrice(fluxProductId, stripePriceId)
if (!price)
throw createBadRequestError('Invalid price', 'INVALID_PACKAGE', { stripePriceId })
const fluxAmount = Number(price.metadata.fluxAmount)
if (!Number.isFinite(fluxAmount) || fluxAmount <= 0)
throw createBadRequestError('Price is missing fluxAmount metadata', 'INVALID_PACKAGE', { stripePriceId })
// Reuse existing stripe customer if available.
const customer = await deps.stripeService.getCustomerByUserId(input.user.id)
const stripeCustomerId = customer?.stripeCustomerId
const redirectBase = resolveCheckoutRedirectBase(input.request, deps.env.ADDITIONAL_TRUSTED_ORIGINS, deps.env.WEB_APP_URL)
const paymentMethods = await deps.configKV.getOptional('STRIPE_PAYMENT_METHODS')
const paymentMethodOptions = await deps.configKV.getOptional('STRIPE_PAYMENT_METHOD_OPTIONS') ?? {}
const sessionParams: CheckoutSessionCreateParams = {
line_items: [{ price: stripePriceId, quantity: 1 }],
mode: 'payment',
allow_promotion_codes: true,
success_url: `${redirectBase}/settings/flux?success=true`,
cancel_url: `${redirectBase}/settings/flux?canceled=true`,
customer: stripeCustomerId,
customer_email: stripeCustomerId ? undefined : input.user.email,
metadata: {
userId: input.user.id,
fluxAmount: String(fluxAmount),
},
}
// When STRIPE_PAYMENT_METHODS is not set, omit payment_method_types to let Stripe
// automatically determine available methods based on currency and Dashboard settings.
if (paymentMethods)
sessionParams.payment_method_types = paymentMethods as CheckoutSessionCreateParams['payment_method_types']
if (Object.keys(paymentMethodOptions).length > 0)
sessionParams.payment_method_options = paymentMethodOptions as CheckoutSessionCreateParams['payment_method_options']
// When currency is specified, Stripe uses the matching currency_options on the Price.
if (currency)
sessionParams.currency = currency
const session = await deps.stripe.checkout.sessions.create(sessionParams)
// Persist the checkout session.
await deps.stripeService.upsertCheckoutSession({
userId: input.user.id,
stripeSessionId: session.id,
stripeCustomerId: typeof session.customer === 'string' ? session.customer : session.customer?.id,
mode: session.mode ?? 'payment',
status: session.status,
paymentStatus: session.payment_status,
amountTotal: session.amount_total,
currency: session.currency,
successUrl: session.success_url,
cancelUrl: session.cancel_url,
stripePaymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id,
stripeSubscriptionId: typeof session.subscription === 'string' ? session.subscription : session.subscription?.id,
metadata: session.metadata ? JSON.stringify(session.metadata) : null,
expiresAt: session.expires_at ? new Date(session.expires_at * 1000) : null,
})
deps.metrics?.stripeCheckoutCreated.add(1)
return { url: session.url }
}
}
@@ -0,0 +1,361 @@
import type { PostHog } from 'posthog-node'
import type Stripe from 'stripe'
import type { RevenueMetrics } from '../../../otel'
import type { BillingService } from '../../../services/domain/billing/billing-service'
import type { FluxService } from '../../../services/domain/flux'
import type { StripeService } from '../../../services/domain/stripe'
import { useLogger } from '@guiiai/logg'
import { captureSafe } from '../../../services/adapters/posthog'
import { createBadRequestError, createServiceUnavailableError } from '../../../utils/error'
import { errorMessageFromUnknown } from '../../../utils/error-message'
const logger = useLogger('stripe')
export interface WebhookOperationDeps {
stripe: Stripe | null
webhookSecret: string | undefined
fluxService: FluxService
stripeService: StripeService
billingService: BillingService
metrics?: RevenueMetrics | null
posthog?: PostHog | null
}
export interface WebhookOperationInput {
signature: string | null
body: string
}
/**
* Processes Stripe webhook events.
*
* Use when:
* - The route has captured the raw request body.
* - Stripe signature verification must happen before event dispatch.
*
* Expects:
* - A configured Stripe client and webhook secret.
*
* Returns:
* - `{ received: true }` after known and unknown events are accepted.
*/
export function createWebhookOperation(deps: WebhookOperationDeps) {
return async (input: WebhookOperationInput): Promise<{ received: true }> => {
if (!deps.stripe || !deps.webhookSecret)
throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED')
if (!input.signature)
throw createBadRequestError('No signature', 'MISSING_SIGNATURE')
let event: Stripe.Event
try {
event = deps.stripe.webhooks.constructEvent(input.body, input.signature, deps.webhookSecret)
}
catch (err: unknown) {
throw createBadRequestError(`Webhook Error: ${errorMessageFromUnknown(err)}`, 'WEBHOOK_ERROR')
}
logger.withFields({ type: event.type, id: event.id }).log('Webhook event received')
deps.metrics?.stripeEvents.add(1, { event_type: event.type })
switch (event.type) {
case 'checkout.session.completed': {
const result = await handleCheckoutSessionCompleted(event.id, event.data.object, deps.fluxService, deps.stripeService, deps.billingService)
deps.metrics?.stripeCheckoutCompleted.add(1)
// Revenue capture in smallest currency unit (e.g. cents).
// Cross-currency aggregation is meaningless, so always group by `currency` in queries.
if (event.data.object.amount_total != null && event.data.object.currency) {
deps.metrics?.stripeRevenue.add(event.data.object.amount_total, {
currency: event.data.object.currency,
source: 'checkout',
})
}
// PostHog: funnel terminator. Only fire when the handler actually
// processed the checkout — malformed sessions (missing userId,
// invalid fluxAmount) take the early-return path above and would
// otherwise poison the funnel with phantom conversions. distinctId
// is the Better Auth user id so it merges with the browser's
// `posthog.identify(userId)` and the prior `checkout_started`
// event lines up. See docs/ai-context/metrics-ownership.md.
if (result.processed)
await capturePaymentCompleted(deps.posthog, event.data.object)
break
}
case 'customer.created':
case 'customer.updated': {
await handleCustomerEvent(event.data.object, deps.stripeService)
break
}
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
await handleSubscriptionEvent(event.data.object, deps.stripeService)
deps.metrics?.stripeSubscriptionEvent.add(1, { event_type: event.type.replace('customer.subscription.', '') })
if (event.type === 'customer.subscription.deleted')
await captureSubscriptionCancelled(deps.posthog, deps.stripeService, event.data.object)
break
}
case 'invoice.created':
case 'invoice.updated':
case 'invoice.paid':
case 'invoice.payment_failed': {
await handleInvoiceEvent(event.data.object, deps.stripeService)
if (event.type === 'invoice.payment_failed')
deps.metrics?.stripePaymentFailed.add(1)
if (event.type === 'invoice.paid' && event.data.object.amount_paid && event.data.object.currency) {
deps.metrics?.stripeRevenue.add(event.data.object.amount_paid, {
currency: event.data.object.currency,
source: 'invoice',
})
}
break
}
}
return { received: true }
}
}
async function handleCheckoutSessionCompleted(
stripeEventId: string,
session: Stripe.Checkout.Session,
fluxService: FluxService,
stripeService: StripeService,
billingService: BillingService,
): Promise<{ processed: boolean }> {
const userId = session.metadata?.userId
if (!userId) {
logger.withFields({ sessionId: session.id }).warn('Checkout session missing userId in metadata')
return { processed: false }
}
logger.withFields({ userId, sessionId: session.id, mode: session.mode, amount: session.amount_total, currency: session.currency }).log('Processing checkout session')
// Upsert customer record if we got a customer back.
if (session.customer) {
const stripeCustomerId = typeof session.customer === 'string' ? session.customer : session.customer.id
await stripeService.upsertCustomer({
userId,
stripeCustomerId,
email: session.customer_email ?? undefined,
})
await fluxService.updateStripeCustomerId(userId, stripeCustomerId)
}
await stripeService.upsertCheckoutSession({
userId,
stripeSessionId: session.id,
stripeCustomerId: typeof session.customer === 'string' ? session.customer : session.customer?.id,
mode: session.mode ?? 'payment',
status: session.status,
paymentStatus: session.payment_status,
amountTotal: session.amount_total,
currency: session.currency,
successUrl: session.success_url,
cancelUrl: session.cancel_url,
stripePaymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id,
stripeSubscriptionId: typeof session.subscription === 'string' ? session.subscription : session.subscription?.id,
metadata: session.metadata ? JSON.stringify(session.metadata) : null,
expiresAt: session.expires_at ? new Date(session.expires_at * 1000) : null,
})
// Idempotent flux credit: use fluxCredited flag inside a transaction
// to prevent double-crediting on webhook replay.
//
// For `payment` mode (one-time Flux purchase) `metadata.fluxAmount` is
// required — without it we can't credit anything, and the funnel must
// not see a `payment_completed` event for a checkout that didn't
// actually deliver Flux. Non-`payment` modes (e.g. `setup` for saving
// a card) deliberately skip crediting and still count as processed.
if (session.mode === 'payment') {
if (session.amount_total == null) {
logger.withFields({ userId, sessionId: session.id }).warn('Payment-mode checkout missing amount_total; skipping credit and capture')
return { processed: false }
}
const metadataFlux = session.metadata?.fluxAmount
if (!metadataFlux) {
logger.withFields({ userId, sessionId: session.id }).warn('Payment-mode checkout missing metadata.fluxAmount; skipping credit and capture')
return { processed: false }
}
const fluxAmount = Number(metadataFlux)
if (!Number.isFinite(fluxAmount) || fluxAmount <= 0) {
logger.withFields({ userId, sessionId: session.id, metadataFlux }).warn('Invalid fluxAmount in session metadata, skipping credit')
return { processed: false }
}
const result = await billingService.creditFluxFromStripeCheckout({
stripeEventId,
userId,
stripeSessionId: session.id,
amountTotal: session.amount_total,
currency: session.currency,
fluxAmount,
})
logger.withFields({
userId,
fluxAmount,
amountTotal: session.amount_total,
applied: result.applied,
balanceAfter: result.balanceAfter,
}).log('Processed flux credit for one-time payment')
}
return { processed: true }
}
async function capturePaymentCompleted(
posthog: PostHog | null | undefined,
session: Stripe.Checkout.Session,
): Promise<void> {
if (!posthog)
return
const userId = session.metadata?.userId
const email = session.customer_email
|| (typeof session.customer_details?.email === 'string' ? session.customer_details.email : null)
|| null
// distinctId fallback chain: userId (Better Auth, matches browser identify())
// > email (PostHog will merge on identify later) > stripe session id (last
// resort — orphan event but at least we count it).
const distinctId = userId || email || session.id
const fluxAmount = Number(session.metadata?.fluxAmount)
const stripeCustomerId = typeof session.customer === 'string' ? session.customer : session.customer?.id
await captureSafe(posthog, {
distinctId,
event: 'payment_completed',
properties: {
amount_total: session.amount_total,
currency: session.currency,
flux_amount: Number.isFinite(fluxAmount) ? fluxAmount : null,
mode: session.mode,
stripe_session_id: session.id,
stripe_customer_id: stripeCustomerId,
stripe_subscription_id: typeof session.subscription === 'string' ? session.subscription : session.subscription?.id,
...(userId ? { user_id: userId } : {}),
// $set populates the PostHog person profile so funnel joins work even
// when a user pays via direct checkout link before ever loading the SPA.
...(email ? { $set: { email } } : {}),
},
})
}
async function captureSubscriptionCancelled(
posthog: PostHog | null | undefined,
stripeService: StripeService,
subscription: Stripe.Subscription,
): Promise<void> {
if (!posthog)
return
const stripeCustomerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id
const customer = await stripeService.getCustomerByStripeId(stripeCustomerId)
const distinctId = customer?.userId || stripeCustomerId
await captureSafe(posthog, {
distinctId,
event: 'subscription_cancelled',
properties: {
stripe_subscription_id: subscription.id,
stripe_customer_id: stripeCustomerId,
cancel_at_period_end: subscription.cancel_at_period_end,
cancellation_reason: subscription.cancellation_details?.reason ?? null,
cancellation_comment: subscription.cancellation_details?.comment ?? null,
canceled_at: subscription.canceled_at,
ended_at: subscription.ended_at,
...(customer?.userId ? { user_id: customer.userId } : {}),
},
})
}
async function handleCustomerEvent(
customer: Stripe.Customer | Stripe.DeletedCustomer,
stripeService: StripeService,
) {
if (customer.deleted)
return
// Try to find existing customer to get userId.
const existing = await stripeService.getCustomerByStripeId(customer.id)
if (!existing)
return
await stripeService.upsertCustomer({
userId: existing.userId,
stripeCustomerId: customer.id,
email: customer.email ?? undefined,
name: customer.name ?? undefined,
})
}
async function handleSubscriptionEvent(
subscription: Stripe.Subscription,
stripeService: StripeService,
) {
const stripeCustomerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id
const customer = await stripeService.getCustomerByStripeId(stripeCustomerId)
if (!customer)
return
// In newer Stripe API, period info is on subscription items.
const firstItem = subscription.items.data[0]
await stripeService.upsertSubscription({
userId: customer.userId,
stripeSubscriptionId: subscription.id,
stripeCustomerId,
stripePriceId: firstItem?.price?.id,
status: subscription.status,
currentPeriodStart: firstItem?.current_period_start ? new Date(firstItem.current_period_start * 1000) : null,
currentPeriodEnd: firstItem?.current_period_end ? new Date(firstItem.current_period_end * 1000) : null,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null,
endedAt: subscription.ended_at ? new Date(subscription.ended_at * 1000) : null,
metadata: subscription.metadata ? JSON.stringify(subscription.metadata) : null,
})
}
async function handleInvoiceEvent(
invoice: Stripe.Invoice,
stripeService: StripeService,
) {
const stripeCustomerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id
if (!stripeCustomerId)
return
const customer = await stripeService.getCustomerByStripeId(stripeCustomerId)
if (!customer)
return
// In newer Stripe API, subscription is under parent.subscription_details.
const subDetails = invoice.parent?.subscription_details
const subscriptionId = subDetails
? (typeof subDetails.subscription === 'string' ? subDetails.subscription : subDetails.subscription?.id)
: undefined
await stripeService.upsertInvoice({
userId: customer.userId,
stripeInvoiceId: invoice.id,
stripeCustomerId,
stripeSubscriptionId: subscriptionId,
status: invoice.status,
amountDue: invoice.amount_due,
amountPaid: invoice.amount_paid,
currency: invoice.currency,
invoiceUrl: invoice.hosted_invoice_url,
invoicePdf: invoice.invoice_pdf,
periodStart: new Date(invoice.period_start * 1000),
periodEnd: new Date(invoice.period_end * 1000),
paidAt: invoice.status_transitions?.paid_at ? new Date(invoice.status_transitions.paid_at * 1000) : null,
metadata: invoice.metadata ? JSON.stringify(invoice.metadata) : null,
})
// TODO: implement subscription-based flux crediting when subscriptions are enabled
if (invoice.status === 'paid' && invoice.amount_paid && subscriptionId)
logger.withFields({ userId: customer.userId, invoiceId: invoice.id, amountPaid: invoice.amount_paid }).warn('Subscription invoice paid but flux crediting for subscriptions is not yet implemented')
}
@@ -0,0 +1,140 @@
import type Redis from 'ioredis'
import type Stripe from 'stripe'
import { useLogger } from '@guiiai/logg'
import { redisKeyFrom } from '../../utils/redis-keys'
const logger = useLogger('stripe')
const PRICES_CACHE_KEY = redisKeyFrom('cache', 'stripe', 'prices')
const PRICES_CACHE_TTL_SEC = 5 * 60
interface CachedCurrencyOption {
unitAmount: number | null
}
export interface CachedPrice {
id: string
unitAmount: number | null
currency: string
product: string
active: boolean
metadata: Record<string, string>
currencyOptions: Record<string, CachedCurrencyOption>
}
export interface StripePriceCatalog {
getActivePrices: (productId: string) => Promise<CachedPrice[]>
findActivePrice: (productId: string, stripePriceId: string) => Promise<CachedPrice | null>
}
/**
* Creates a Stripe price catalog backed by Redis.
*
* Use when:
* - Listing public Flux packages.
* - Validating checkout price ids before creating Stripe sessions.
*
* Expects:
* - A configured Stripe client and Redis connection.
*
* Returns:
* - Cached active prices for a single configured product.
*/
export function createStripePriceCatalog(stripe: Stripe, redis: Redis): StripePriceCatalog {
return {
async getActivePrices(productId: string): Promise<CachedPrice[]> {
const cached = await redis.get(PRICES_CACHE_KEY)
if (cached) {
try {
const parsed = JSON.parse(cached) as { productId: string, prices: CachedPrice[] }
if (parsed.productId === productId)
return parsed.prices
}
catch { /* corrupted cache, refetch */ }
}
let result: Stripe.ApiList<Stripe.Price>
try {
result = await stripe.prices.list({ product: productId, active: true, expand: ['data.currency_options'] })
}
catch (err) {
logger.withError(err).warn('Failed to fetch prices from Stripe')
return []
}
const prices = result.data
.sort((a, b) => (a.unit_amount ?? 0) - (b.unit_amount ?? 0))
.map(toCachedPrice)
await redis.set(PRICES_CACHE_KEY, JSON.stringify({ productId, prices }), 'EX', PRICES_CACHE_TTL_SEC)
return prices
},
async findActivePrice(productId: string, stripePriceId: string): Promise<CachedPrice | null> {
// Validate against cached prices first, fall back to direct Stripe API.
const cachedPrices = await this.getActivePrices(productId)
const cached = cachedPrices.find(p => p.id === stripePriceId)
if (cached)
return cached
// Cache miss — price may have just been created.
let fetched: Stripe.Price
try {
fetched = await stripe.prices.retrieve(stripePriceId)
}
catch {
return null
}
const fetchedProductId = typeof fetched.product === 'string' ? fetched.product : fetched.product.id
if (!fetched.active || fetchedProductId !== productId)
return null
// Invalidate cache so all instances pick up the new price.
await redis.del(PRICES_CACHE_KEY)
return toCachedPrice(fetched)
},
}
}
function toCachedPrice(price: Stripe.Price): CachedPrice {
return {
id: price.id,
unitAmount: price.unit_amount,
currency: price.currency,
product: typeof price.product === 'string' ? price.product : price.product.id,
active: price.active,
metadata: price.metadata,
currencyOptions: Object.fromEntries(
Object.entries(price.currency_options ?? {}).map(([cur, opt]) => [cur, { unitAmount: opt.unit_amount }]),
),
}
}
/**
* Format Stripe smallest-unit amount into a human-readable price string.
*
* Before:
* - `300, "usd"`
* - `500, "jpy"`
*
* After:
* - `"$3.00"`
* - `"¥500"`
*/
export function formatPrice(unitAmount: number | null, currency: string): string {
if (unitAmount == null)
return currency.toUpperCase()
try {
const formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency })
const fractionDigits = formatter.resolvedOptions().minimumFractionDigits ?? 2
const amount = unitAmount / (10 ** fractionDigits)
return formatter.format(amount)
}
catch {
return `${unitAmount / 100} ${currency.toUpperCase()}`
}
}