173 lines
6.9 KiB
TypeScript
173 lines
6.9 KiB
TypeScript
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 { ProductEventService } from '../../../services/domain/product-events'
|
|
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
|
|
productEventService?: ProductEventService
|
|
}
|
|
|
|
export interface CheckoutOperationInput {
|
|
user: AuthenticatedUser
|
|
body: unknown
|
|
request: Request
|
|
}
|
|
|
|
interface PosthogIdentityHeaders {
|
|
distinctId?: string
|
|
sessionId?: string
|
|
}
|
|
|
|
/**
|
|
* 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 posthogIdentity = readPosthogIdentityHeaders(input.request)
|
|
|
|
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),
|
|
...(posthogIdentity.distinctId && { posthogDistinctId: posthogIdentity.distinctId }),
|
|
...(posthogIdentity.sessionId && { posthogSessionId: posthogIdentity.sessionId }),
|
|
},
|
|
}
|
|
|
|
// 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)
|
|
void deps.productEventService?.track({
|
|
userId: input.user.id,
|
|
feature: 'billing',
|
|
action: 'checkout_started',
|
|
status: 'succeeded',
|
|
eventId: session.id,
|
|
source: 'stripe.checkout',
|
|
metadata: {
|
|
flux_amount: fluxAmount,
|
|
amount_total: session.amount_total,
|
|
currency: session.currency,
|
|
...(posthogIdentity.distinctId && { posthog_distinct_id: posthogIdentity.distinctId }),
|
|
...(posthogIdentity.sessionId && { posthog_session_id: posthogIdentity.sessionId }),
|
|
},
|
|
})
|
|
|
|
return { url: session.url }
|
|
}
|
|
}
|
|
|
|
function readPosthogIdentityHeaders(request: Request): PosthogIdentityHeaders {
|
|
const distinctId = readStripeMetadataHeader(request, 'x-posthog-distinct-id')
|
|
const sessionId = readStripeMetadataHeader(request, 'x-posthog-session-id')
|
|
return {
|
|
...(distinctId && { distinctId }),
|
|
...(sessionId && { sessionId }),
|
|
}
|
|
}
|
|
|
|
function readStripeMetadataHeader(request: Request, name: string): string | undefined {
|
|
const value = request.headers.get(name)?.trim()
|
|
if (!value)
|
|
return undefined
|
|
|
|
// Stripe metadata values are capped and user-controlled headers can be
|
|
// oversized. Truncating keeps the checkout request valid without turning
|
|
// analytics identity into a payment blocker.
|
|
return value.slice(0, 200)
|
|
}
|