refactor(api): restore Stripe product catalog as Flux pack source (#2533)
CI / Lint (push) Failing after 12m52s
CI / Build Test (stage-tamagotchi-godot) (push) Canceled after 0s
CI / Build Test (stage-web) (push) Canceled after 0s
CI / Build Test (ui-loading-screens) (push) Canceled after 0s
CI / Build Test (ui-transitions) (push) Canceled after 0s
CI / Unit Test (push) Canceled after 0s
CI / Type Check (push) Canceled after 0s
CI / Check Provenance (push) Canceled after 0s
CI / Build Test (stage-tamagotchi) (push) Canceled after 7m23s
Cloudflare Pages (Auth UI) / Deploy - ui-server-auth (push) Canceled after 0s
Cloudflare Workers / Deploy - stage-web (push) Canceled after 0s

This commit is contained in:
Lulu
2026-09-12 07:33:17 -07:00
committed by GitHub
parent 553d8a0da4
commit 00c6867b7f
12 changed files with 336 additions and 340 deletions
@@ -36,14 +36,14 @@ if (isStageTamagotchi())
useEventListener(window, 'focus', () => authStore.updateCredits())
interface FluxPackage {
packKey: string
stripePriceId: string
label: string
defaultCurrency: string
currencies: Record<string, string>
recommended?: boolean
}
const loadingPackKey = ref<string | null>(null)
const loadingPriceId = ref<string | null>(null)
const message = ref<{ type: 'success' | 'error', text: string } | null>(null)
const checkoutReturnMessageActive = ref(false)
const packages = ref<FluxPackage[]>([])
@@ -304,8 +304,8 @@ onMounted(async () => {
}
})
async function handleBuy(packKey: string) {
loadingPackKey.value = packKey
async function handleBuy(stripePriceId: string) {
loadingPriceId.value = stripePriceId
checkoutReturnMessageActive.value = false
message.value = null
// OpenPanel funnel step 2: user picked a plan. price_minor_unit lives on
@@ -317,12 +317,12 @@ async function handleBuy(packKey: string) {
current_plan: 'flux',
trigger: 'manual_topup',
})
trackPlanSelected(packKey, {
trackPlanSelected(stripePriceId, {
currency: selectedCurrency.value,
entry_surface: 'settings_flux',
})
try {
const res = await client.api.v1.stripe.checkout.$post({ json: { packKey, currency: selectedCurrency.value } })
const res = await client.api.v1.stripe.checkout.$post({ json: { stripePriceId, currency: selectedCurrency.value } })
if (!res.ok) {
const data = await res.json() as { error?: string, message?: string }
message.value = { type: 'error', text: data.message || t('settings.pages.flux.checkout.error') }
@@ -332,7 +332,7 @@ async function handleBuy(packKey: string) {
if (data.url) {
// Start capture before redirecting to Stripe so fetch keepalive can
// finish delivery after the page unloads.
trackCheckoutStarted(packKey, {
trackCheckoutStarted(stripePriceId, {
currency: selectedCurrency.value,
entry_surface: 'settings_flux',
})
@@ -350,7 +350,7 @@ async function handleBuy(packKey: string) {
message.value = { type: 'error', text: t('settings.pages.flux.checkout.error') }
}
finally {
loadingPackKey.value = null
loadingPriceId.value = null
}
}
</script>
@@ -401,8 +401,8 @@ async function handleBuy(packKey: string) {
<div grid="~ cols-1 sm:cols-3 gap-4">
<button
v-for="(pkg, index) in packages" :key="pkg.packKey"
:disabled="loadingPackKey !== null"
v-for="(pkg, index) in packages" :key="pkg.stripePriceId"
:disabled="loadingPriceId !== null"
:class="[
'group relative flex flex-row sm:flex-col items-center justify-between sm:justify-center overflow-hidden text-left sm:text-center gap-4 sm:gap-2',
'rounded-2xl border-2 bg-white p-6 transition-all duration-300 ease-out',
@@ -410,9 +410,9 @@ async function handleBuy(packKey: string) {
'dark:bg-neutral-900',
'hover:-translate-y-1 hover:border-primary-400 hover:shadow-md dark:hover:border-primary-500',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
loadingPackKey !== null && loadingPackKey !== pkg.packKey ? 'opacity-50 grayscale-50 cursor-not-allowed' : 'cursor-pointer',
loadingPriceId !== null && loadingPriceId !== pkg.stripePriceId ? 'opacity-50 grayscale-50 cursor-not-allowed' : 'cursor-pointer',
]"
@click="handleBuy(pkg.packKey)"
@click="handleBuy(pkg.stripePriceId)"
>
<!-- Recommended Badge -->
<div
@@ -425,7 +425,7 @@ async function handleBuy(packKey: string) {
<!-- Loading Overlay -->
<div
v-if="loadingPackKey === pkg.packKey"
v-if="loadingPriceId === pkg.stripePriceId"
class="absolute inset-0 z-10 flex items-center justify-center bg-white/60 backdrop-blur-sm dark:bg-neutral-900/60"
>
<div class="i-svg-spinners:90-ring-with-bg size-8 text-primary-500" />
+6 -5
View File
@@ -15,11 +15,12 @@ auth/OIDC routes.
## Payment
`src/services/domain/payment` owns pack grant and `payment_order` rows.
CORE exposes `openPending`, `bindProcessorOrder`, `abandon`, `settle`,
and `deleteAllForUser`. Checkout and package list live in the Stripe
adapter on `/api/v1/stripe/*`. CORE never sees a raw processor event.
The adapter maps the processor result onto a `ClaimReceipt`, then calls
`settle`.
Checkout and package list live in the Stripe adapter on `/api/v1/stripe/*`.
ConfigKV stores `STRIPE_FLUX_PRODUCT_ID`. The adapter lists that product's
Prices from Stripe. `GET /packages` returns `stripePriceId`. Checkout accepts
`stripePriceId`. Label, flux amount, and display prices come from Price
metadata and Stripe amounts.
The adapter maps a verified session onto a `ClaimReceipt`, then calls `settle`.
## Run locally
@@ -1,5 +1,6 @@
import type { Database } from '../../libs/db'
import type { ConfigDefinitions, ConfigKVService } from '../../services/adapters/config-kv'
import type { ConfigKVService } from '../../services/adapters/config-kv'
import type { CachedPrice, StripePriceCatalog } from './price-catalog'
import { eq } from 'drizzle-orm'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -12,12 +13,15 @@ import { createCheckoutOperation } from './operations/checkout'
import * as schema from '../../schemas'
const starterPack: ConfigDefinitions['FLUX_PACKS'][number] = {
key: 'starter',
name: '500 Flux',
fluxAmount: 500,
recommended: false,
processors: { stripe: { priceId: 'price_starter' } },
const productId = 'prod_flux'
const starterPrice: CachedPrice = {
id: 'price_starter',
unitAmount: 500,
currency: 'usd',
product: productId,
active: true,
metadata: { fluxAmount: '500' },
currencyOptions: {},
}
const testEnv = {
@@ -26,15 +30,15 @@ const testEnv = {
API_SERVER_URL: 'http://localhost:8787',
WEB_APP_URL: 'https://airi.moeru.ai',
ADDITIONAL_TRUSTED_ORIGINS: [],
} as any
} as never
const testUser = { id: 'user-pay-1', name: 'Pay User', email: 'pay@example.com' }
function createPacksConfigKV(packs: ConfigDefinitions['FLUX_PACKS']): ConfigKVService {
function createProductConfigKV(): ConfigKVService {
return {
getOptional: vi.fn(async (key: string) => {
if (key === 'FLUX_PACKS')
return packs
if (key === 'STRIPE_FLUX_PRODUCT_ID')
return productId
return null
}),
getOrThrow: vi.fn(),
@@ -44,16 +48,28 @@ function createPacksConfigKV(packs: ConfigDefinitions['FLUX_PACKS']): ConfigKVSe
} as ConfigKVService
}
function createCatalog(price: CachedPrice | null = starterPrice): StripePriceCatalog {
return {
getActivePrices: vi.fn(async () => price ? [price] : []),
findActivePrice: vi.fn(async (_productId: string, stripePriceId: string) => {
if (price && price.id === stripePriceId)
return price
return null
}),
}
}
function createCheckout(
payment: ReturnType<typeof createPaymentService>,
stripe: { checkout: { sessions: { create: ReturnType<typeof vi.fn> } }, prices?: { retrieve: ReturnType<typeof vi.fn> } },
packs: ConfigDefinitions['FLUX_PACKS'] = [starterPack],
stripe: { checkout: { sessions: { create: ReturnType<typeof vi.fn> } } },
catalog: StripePriceCatalog = createCatalog(),
productEventService: { track: ReturnType<typeof vi.fn> } | null = null,
) {
return createCheckoutOperation(
payment,
{ prices: { retrieve: vi.fn(async () => ({ active: true, type: 'one_time' })) }, ...stripe } as never,
createPacksConfigKV(packs),
stripe as never,
catalog,
createProductConfigKV(),
testEnv,
null,
productEventService as never,
@@ -75,7 +91,7 @@ describe('stripe checkout', () => {
beforeEach(async () => {
const redis = createTestRedis()
const billing = createBillingService(db, redis, createPacksConfigKV([starterPack]))
const billing = createBillingService(db, redis, createProductConfigKV())
payment = createPaymentService(db, billing)
await db.delete(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, 'user-pay-1'))
@@ -89,9 +105,11 @@ describe('stripe checkout', () => {
const [order] = await db.select().from(schema.paymentOrder).where(eq(schema.paymentOrder.userId, 'user-pay-1'))
expect(order?.status).toBe('pending')
expect(order?.processorOrderId).toBeNull()
expect(order?.packKey).toBe('starter')
expect(order?.packKey).toBe('price_starter')
expect(order?.fluxAmount).toBe(500)
expect(params.metadata?.payment_order_id).toBe(order?.id)
expect(params.metadata?.stripePriceId).toBe('price_starter')
expect(params.metadata?.fluxAmount).toBe('500')
return {
id: 'cs_test_1',
@@ -105,7 +123,7 @@ describe('stripe checkout', () => {
const result = await checkout(
testUser,
{ packKey: 'starter', currency: 'usd' },
{ stripePriceId: 'price_starter', currency: 'usd' },
new Request('http://localhost/api/v1/stripe/checkout'),
)
@@ -118,26 +136,39 @@ describe('stripe checkout', () => {
expect(order?.currency).toBe('usd')
})
it('resolves previous-version stripePriceId onto a pack snapshot', async () => {
const create = vi.fn(async () => ({
id: 'cs_test_price',
url: 'https://checkout.stripe.test/cs_test_price',
amount_total: 500,
currency: 'usd',
}))
it('rejects a price that is not on the configured product', async () => {
const checkout = createCheckout(
payment,
{ checkout: { sessions: { create: vi.fn() } } },
createCatalog(null),
)
const checkout = createCheckout(payment, { checkout: { sessions: { create } } })
await expect(checkout(
testUser,
{ stripePriceId: 'price_other' },
new Request('http://localhost/api/v1/stripe/checkout'),
)).rejects.toMatchObject({
statusCode: 400,
errorCode: 'INVALID_PACKAGE',
})
})
await checkout(
it('rejects a price without fluxAmount metadata', async () => {
const checkout = createCheckout(
payment,
{ checkout: { sessions: { create: vi.fn() } } },
createCatalog({ ...starterPrice, metadata: {} }),
)
await expect(checkout(
testUser,
{ stripePriceId: 'price_starter' },
new Request('http://localhost/api/v1/stripe/checkout'),
)
const [order] = await db.select().from(schema.paymentOrder).where(eq(schema.paymentOrder.userId, 'user-pay-1'))
expect(order?.packKey).toBe('starter')
expect(order?.fluxAmount).toBe(500)
expect(create).toHaveBeenCalled()
)).rejects.toMatchObject({
statusCode: 400,
errorCode: 'INVALID_PACKAGE',
})
expect(await db.select().from(schema.paymentOrder)).toHaveLength(0)
})
it('credits Flux when settle runs before the session id is bound', async () => {
@@ -167,7 +198,7 @@ describe('stripe checkout', () => {
await checkout(
testUser,
{ packKey: 'starter' },
{ stripePriceId: 'price_starter' },
new Request('http://localhost/api/v1/stripe/checkout'),
)
@@ -188,11 +219,11 @@ describe('stripe checkout', () => {
}))
const productEventService = { track: vi.fn() }
const checkout = createCheckout(payment, { checkout: { sessions: { create } } }, [starterPack], productEventService)
const checkout = createCheckout(payment, { checkout: { sessions: { create } } }, createCatalog(), productEventService)
await checkout(
testUser,
{ packKey: 'starter' },
{ stripePriceId: 'price_starter' },
new Request('http://localhost/api/v1/stripe/checkout', {
headers: {
'x-openpanel-device-id': 'anon-browser-1',
@@ -211,6 +242,7 @@ describe('stripe checkout', () => {
action: 'checkout_started',
metadata: expect.objectContaining({
openpanel_device_id: 'anon-browser-1',
stripe_price_id: 'price_starter',
}),
}))
})
@@ -237,7 +269,7 @@ describe('stripe checkout', () => {
await checkout(
testUser,
{ packKey: 'starter' },
{ stripePriceId: 'price_starter' },
new Request('http://localhost/api/v1/stripe/checkout'),
)
@@ -252,7 +284,7 @@ describe('stripe checkout', () => {
await expect(checkout(
testUser,
{ packKey: 'starter' },
{ stripePriceId: 'price_starter' },
new Request('http://localhost/api/v1/stripe/checkout'),
)).rejects.toThrow('stripe down')
@@ -272,7 +304,7 @@ describe('stripe checkout', () => {
await expect(checkout(
testUser,
{ packKey: 'starter' },
{ stripePriceId: 'price_starter' },
new Request('http://localhost/api/v1/stripe/checkout'),
)).rejects.toMatchObject({
statusCode: 503,
@@ -282,11 +314,4 @@ describe('stripe checkout', () => {
const [order] = await db.select().from(schema.paymentOrder).where(eq(schema.paymentOrder.userId, 'user-pay-1'))
expect(order?.status).toBe('canceled')
})
it('rejects inactive prices before creating a payment order', async () => {
const create = vi.fn()
const checkout = createCheckout(payment, { checkout: { sessions: { create } }, prices: { retrieve: vi.fn(async () => ({ active: false, type: 'one_time' })) } })
await expect(checkout(testUser, { packKey: 'starter' }, new Request('http://localhost/api/v1/stripe/checkout'))).rejects.toMatchObject({ statusCode: 400 })
expect(await db.select().from(schema.paymentOrder)).toHaveLength(0)
expect(create).not.toHaveBeenCalled()
})
})
+7 -10
View File
@@ -15,14 +15,8 @@ import { authGuard } from '../../middlewares/auth'
import { rateLimiter } from '../../middlewares/rate-limit'
import { createCheckoutOperation } from './operations/checkout'
import { createWebhookOperation } from './operations/webhook'
import { listStripePackages } from './price-catalog'
import { createStripePriceCatalog, listStripePackages } from './price-catalog'
/**
* Creates Stripe HTTP routes for Flux purchase.
*
* Paths stay on `/api/v1/stripe`. Checkout lives in this adapter.
* Webhook dispatch maps a session onto Payment CORE `settle`.
*/
export function createStripeRoutes(
payment: PaymentService,
db: Database,
@@ -34,13 +28,16 @@ export function createStripeRoutes(
rateLimitMetrics: RateLimitMetrics | null,
productEventService: ProductEventService | null,
) {
const checkout = createCheckoutOperation(payment, stripe, configKV, env, metrics, productEventService)
const priceCatalog = stripe ? createStripePriceCatalog(stripe, redis) : null
const checkout = createCheckoutOperation(payment, stripe, priceCatalog, configKV, env, metrics, productEventService)
const webhook = createWebhookOperation(stripe, env.STRIPE_WEBHOOK_SECRET ?? null, payment, db, metrics, productEventService)
return new Hono<HonoEnv>()
.get('/packages', async (c) => {
const packs = await configKV.getOptional('FLUX_PACKS') ?? []
return c.json(await listStripePackages(stripe, redis, packs))
const fluxProductId = await configKV.getOptional('STRIPE_FLUX_PRODUCT_ID')
if (!priceCatalog || !fluxProductId)
return c.json([])
return c.json(await listStripePackages(priceCatalog, fluxProductId))
})
.post('/checkout', authGuard, rateLimiter({ max: 10, windowSec: 60, metrics: rateLimitMetrics, routeLabel: 'stripe.checkout' }), async (c) => {
const body = await c.req.json()
@@ -2,24 +2,21 @@ import type Stripe from 'stripe'
import type { Env } from '../../../libs/env'
import type { RevenueMetrics } from '../../../otel'
import type { ConfigDefinitions, ConfigKVService } from '../../../services/adapters/config-kv'
import type { ConfigKVService } from '../../../services/adapters/config-kv'
import type { PaymentService } from '../../../services/domain/payment'
import type { ProductEventService } from '../../../services/domain/product-events'
import type { StripePriceCatalog } from '../price-catalog'
import { boolean, object, parse, picklist, safeParse } from 'valibot'
import { safeParse } from 'valibot'
import { createBadRequestError, createServiceUnavailableError } from '../../../utils/error'
import { resolveCheckoutRedirectBase } from '../../../utils/origin'
import { CheckoutBodySchema } from '../schema'
/**
* Opens a pending order through Payment CORE, then creates a Stripe Checkout Session.
*
* `{ packKey }` and previous-version `{ stripePriceId }` resolve a Flux pack.
*/
export function createCheckoutOperation(
payment: PaymentService,
stripe: Stripe | null,
priceCatalog: StripePriceCatalog | null,
configKV: ConfigKVService,
env: Env,
metrics: RevenueMetrics | null,
@@ -30,25 +27,22 @@ export function createCheckoutOperation(
body: unknown,
request: Request,
): Promise<{ url: string }> => {
if (!stripe)
const fluxProductId = await configKV.getOptional('STRIPE_FLUX_PRODUCT_ID')
if (!stripe || !priceCatalog || !fluxProductId)
throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED')
const parsed = safeParse(CheckoutBodySchema, body)
if (!parsed.success)
throw createBadRequestError('Invalid checkout request', 'INVALID_REQUEST', parsed.issues)
const { packKey, stripePriceId, currency } = parsed.output
const packs = await configKV.getOptional('FLUX_PACKS') ?? []
const pack = resolveStripeCheckoutPack(packs, packKey, stripePriceId)
if (!pack)
throw createBadRequestError('Invalid pack', 'INVALID_PACKAGE', { packKey })
const priceId = pack.processors.stripe?.priceId
if (!priceId)
throw createServiceUnavailableError('Stripe pack mapping is missing', 'STRIPE_PACK_NOT_MAPPED', { packKey: pack.key })
const { stripePriceId, currency } = parsed.output
const price = await priceCatalog.findActivePrice(fluxProductId, stripePriceId)
if (!price)
throw createBadRequestError('Invalid price', 'INVALID_PACKAGE', { stripePriceId })
const price = parse(object({ active: boolean(), type: picklist(['one_time', 'recurring']) }), await stripe.prices.retrieve(priceId))
if (!price.active || price.type !== 'one_time')
throw createBadRequestError('Stripe price is not available for one-time purchases', 'INVALID_PACKAGE')
const fluxAmount = Number(price.metadata.fluxAmount)
if (!Number.isFinite(fluxAmount) || fluxAmount <= 0)
throw createBadRequestError('Price is missing fluxAmount metadata', 'INVALID_PACKAGE', { stripePriceId })
const redirectBase = resolveCheckoutRedirectBase(request, env.ADDITIONAL_TRUSTED_ORIGINS, env.WEB_APP_URL)
const openpanelIdentity = readOpenpanelIdentityHeaders(request)
@@ -56,13 +50,13 @@ export function createCheckoutOperation(
const order = await payment.openPending({
userId: user.id,
processor: 'stripe',
packKey: pack.key,
fluxAmount: pack.fluxAmount,
packKey: stripePriceId,
fluxAmount,
currency,
})
const sessionParams: Stripe.Checkout.SessionCreateParams = {
line_items: [{ price: priceId, quantity: 1 }],
line_items: [{ price: stripePriceId, quantity: 1 }],
mode: 'payment',
allow_promotion_codes: true,
success_url: `${redirectBase}/settings/flux?success=true`,
@@ -72,8 +66,8 @@ export function createCheckoutOperation(
metadata: {
payment_order_id: order.id,
userId: user.id,
packKey: pack.key,
fluxAmount: String(pack.fluxAmount),
stripePriceId,
fluxAmount: String(fluxAmount),
...(openpanelIdentity.distinctId && { openpanelDeviceId: openpanelIdentity.distinctId }),
...(openpanelIdentity.sessionId && { openpanelSessionId: openpanelIdentity.sessionId }),
},
@@ -120,8 +114,8 @@ export function createCheckoutOperation(
eventId: order.id,
source: 'stripe.checkout',
metadata: {
pack_key: pack.key,
flux_amount: pack.fluxAmount,
stripe_price_id: stripePriceId,
flux_amount: fluxAmount,
amount_total: session.amount_total,
currency: session.currency,
...(openpanelIdentity.distinctId && { openpanel_device_id: openpanelIdentity.distinctId }),
@@ -133,18 +127,6 @@ export function createCheckoutOperation(
}
}
function resolveStripeCheckoutPack(
packs: ConfigDefinitions['FLUX_PACKS'],
packKey: string | undefined,
stripePriceId: string | undefined,
) {
if (packKey)
return packs.find(item => item.key === packKey)
if (stripePriceId)
return packs.find(item => item.processors.stripe?.priceId === stripePriceId)
return undefined
}
function readOpenpanelIdentityHeaders(request: Request) {
const distinctId = readStripeMetadataHeader(request, 'x-openpanel-device-id')
const sessionId = readStripeMetadataHeader(request, 'x-openpanel-session-id')
@@ -56,7 +56,7 @@ async function resolvePaymentOrderId(
const metadata = legacy.metadata
? parse(object({
fluxAmount: optional(pipe(string(), regex(/^[1-9]\d*$/), transform(Number), safeInteger())),
packKey: optional(string()),
stripePriceId: optional(string()),
}), JSON.parse(legacy.metadata))
: undefined
await db.insert(paymentSchema.paymentOrder).values({
@@ -66,7 +66,7 @@ async function resolvePaymentOrderId(
processorOrderId: legacy.stripeSessionId,
status: legacy.fluxCredited ? 'paid' : legacy.status === 'expired' ? 'expired' : 'pending',
fluxAmount: metadata?.fluxAmount,
packKey: metadata?.packKey,
packKey: metadata?.stripePriceId,
amount: legacy.amountTotal,
currency: legacy.currency,
creditedAt: legacy.fluxCredited ? legacy.updatedAt : null,
@@ -147,7 +147,7 @@ export function createWebhookOperation(
amount_total: session.amount_total ?? null,
currency: session.currency ?? null,
flux_amount: result.fluxAmount,
pack_key: session.metadata?.packKey ?? null,
stripe_price_id: session.metadata?.stripePriceId ?? null,
stripe_checkout_session_id: session.id,
stripe_customer_id: typeof session.customer === 'string' ? session.customer : session.customer?.id ?? null,
...(openpanelDeviceId && { openpanel_device_id: openpanelDeviceId }),
@@ -1,35 +1,48 @@
import type { ConfigDefinitions } from '../../services/adapters/config-kv'
import type Stripe from 'stripe'
import { describe, expect, it, vi } from 'vitest'
import { createTestRedis } from '../../libs/tests/redis'
import { listStripePackages } from './price-catalog'
import { createStripePriceCatalog, listStripePackages } from './price-catalog'
const starterPack: ConfigDefinitions['FLUX_PACKS'][number] = {
key: 'starter',
name: '500 Flux',
fluxAmount: 500,
recommended: true,
processors: { stripe: { priceId: 'price_starter' } },
const productId = 'prod_flux'
function createStripe(overrides: {
list?: ReturnType<typeof vi.fn>
retrieve?: ReturnType<typeof vi.fn>
} = {}) {
return {
prices: {
list: overrides.list ?? vi.fn(async () => ({ data: [] })),
retrieve: overrides.retrieve ?? vi.fn(),
},
} as unknown as Stripe
}
function createStripe(retrieve: ReturnType<typeof vi.fn>) {
function listedPrice(overrides: Partial<Stripe.Price> = {}): Stripe.Price {
return {
prices: { retrieve },
} as never
id: 'price_starter',
object: 'price',
active: true,
currency: 'usd',
unit_amount: 500,
product: productId,
metadata: { fluxAmount: '500', recommended: 'true' },
currency_options: { jpy: { unit_amount: 500 } },
...overrides,
} as Stripe.Price
}
describe('listStripePackages', () => {
it('lists Stripe prices including extra currencies', async () => {
const retrieve = vi.fn(async (priceId: string) => ({
id: priceId,
currency: 'usd',
unit_amount: 500,
currency_options: { jpy: { unit_amount: 500 } },
}))
it('lists Stripe product prices including extra currencies', async () => {
const catalog = createStripePriceCatalog(
createStripe({
list: vi.fn(async () => ({ data: [listedPrice()] })),
}),
createTestRedis(),
)
await expect(listStripePackages(createStripe(retrieve), createTestRedis(), [starterPack])).resolves.toEqual([{
packKey: 'starter',
await expect(listStripePackages(catalog, productId)).resolves.toEqual([{
stripePriceId: 'price_starter',
label: '500 Flux',
defaultCurrency: 'usd',
@@ -38,41 +51,64 @@ describe('listStripePackages', () => {
}])
})
it('reuses the Stripe price cache for the same price id set', async () => {
const retrieve = vi.fn(async () => ({
id: 'price_starter',
currency: 'usd',
unit_amount: 500,
currency_options: {},
}))
const redis = createTestRedis()
it('reuses the Stripe price cache for the same product', async () => {
const list = vi.fn(async () => ({ data: [listedPrice()] }))
const catalog = createStripePriceCatalog(createStripe({ list }), createTestRedis())
await listStripePackages(createStripe(retrieve), redis, [starterPack])
await listStripePackages(createStripe(retrieve), redis, [starterPack])
expect(retrieve).toHaveBeenCalledTimes(1)
await listStripePackages(catalog, productId)
await listStripePackages(catalog, productId)
expect(list).toHaveBeenCalledTimes(1)
})
it('skips a pack when price lookup fails', async () => {
const retrieve = vi.fn(async () => {
throw new Error('no such price')
it('returns no packages when Stripe price list fails', async () => {
const catalog = createStripePriceCatalog(
createStripe({
list: vi.fn(async () => {
throw new Error('timeout')
}),
}),
createTestRedis(),
)
await expect(listStripePackages(catalog, productId)).resolves.toEqual([])
})
await expect(listStripePackages(createStripe(retrieve), createTestRedis(), [starterPack])).resolves.toEqual([])
it('does not cache a Stripe list failure', async () => {
const list = vi.fn()
.mockRejectedValueOnce(new Error('timeout'))
.mockResolvedValue({ data: [listedPrice()] })
const catalog = createStripePriceCatalog(createStripe({ list }), createTestRedis())
await expect(listStripePackages(catalog, productId)).resolves.toEqual([])
expect(await listStripePackages(catalog, productId)).toHaveLength(1)
})
})
// https://github.com/moeru-ai/airi/pull/2335
it('refreshes a renamed pack without waiting for the price cache', async () => {
const retrieve = vi.fn(async () => ({ id: 'price_starter', currency: 'usd', unit_amount: 500, currency_options: {} }))
const redis = createTestRedis()
await listStripePackages(createStripe(retrieve), redis, [starterPack])
const items = await listStripePackages(createStripe(retrieve), redis, [{ ...starterPack, key: 'renamed', name: 'New name', recommended: false }])
expect(items[0]).toMatchObject({ packKey: 'renamed', label: 'New name', recommended: false })
})
describe('findActivePrice', () => {
it('retrieves a newly created price that is missing from cache', async () => {
const catalog = createStripePriceCatalog(
createStripe({
list: vi.fn(async () => ({ data: [] })),
retrieve: vi.fn(async () => listedPrice({ id: 'price_new' })),
}),
createTestRedis(),
)
it('does not cache a transient lookup failure', async () => {
const retrieve = vi.fn().mockRejectedValueOnce(new Error('timeout')).mockResolvedValue({ id: 'price_starter', currency: 'usd', unit_amount: 500, currency_options: {} })
const redis = createTestRedis()
await listStripePackages(createStripe(retrieve), redis, [starterPack])
expect(await listStripePackages(createStripe(retrieve), redis, [starterPack])).toHaveLength(1)
await expect(catalog.findActivePrice(productId, 'price_new')).resolves.toMatchObject({
id: 'price_new',
metadata: { fluxAmount: '500', recommended: 'true' },
})
})
it('rejects a price that belongs to another product', async () => {
const catalog = createStripePriceCatalog(
createStripe({
list: vi.fn(async () => ({ data: [] })),
retrieve: vi.fn(async () => listedPrice({ product: 'prod_other' })),
}),
createTestRedis(),
)
await expect(catalog.findActivePrice(productId, 'price_starter')).resolves.toBeNull()
})
})
@@ -1,85 +1,126 @@
import type Redis from 'ioredis'
import type Stripe from 'stripe'
import type { ConfigDefinitions } from '../../services/adapters/config-kv'
import { useLogger } from '@guiiai/logg'
import { array, boolean, object, record, safeParse, string } from 'valibot'
import { formatPrice } from '../../utils/format-price'
import { redisKeyFrom } from '../../utils/redis-keys'
const logger = useLogger('stripe.catalog')
const logger = useLogger('stripe')
/** Display prices stay 5 minutes old. */
const PRICES_CACHE_KEY = redisKeyFrom('cache', 'stripe', 'prices')
const PRICES_CACHE_TTL_SEC = 5 * 60
const PRICES_CACHE_KEY = redisKeyFrom('cache', 'stripe', 'prices', 'v2')
const packageSchema = object({
packKey: string(),
stripePriceId: string(),
label: string(),
defaultCurrency: string(),
currencies: record(string(), string()),
recommended: boolean(),
})
const cacheSchema = object({ cacheKey: string(), items: array(packageSchema) })
interface CachedCurrencyOption {
unitAmount: number | null
}
export async function listStripePackages(
stripe: Stripe | null,
redis: Redis,
packs: ConfigDefinitions['FLUX_PACKS'],
) {
if (!stripe)
return []
export interface CachedPrice {
id: string
unitAmount: number | null
currency: string
product: string
active: boolean
metadata: Record<string, string>
currencyOptions: Record<string, CachedCurrencyOption>
}
const cacheKey = JSON.stringify(packs)
export interface StripePriceCatalog {
getActivePrices: (productId: string) => Promise<CachedPrice[]>
findActivePrice: (productId: string, stripePriceId: string) => Promise<CachedPrice | null>
}
export interface StripePackage {
stripePriceId: string
label: string
defaultCurrency: string
currencies: Record<string, string>
recommended: boolean
}
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 = safeParse(cacheSchema, JSON.parse(cached))
if (parsed.success && parsed.output.cacheKey === cacheKey)
return parsed.output.items
const parsed = JSON.parse(cached) as { productId: string, prices: CachedPrice[] }
if (parsed.productId === productId)
return parsed.prices
}
catch { /* corrupted cache, refetch */ }
}
const items = []
let complete = true
for (const pack of packs) {
const priceId = pack.processors.stripe?.priceId
if (!priceId)
continue
let price: Stripe.Price
let result: Stripe.ApiList<Stripe.Price>
try {
price = await stripe.prices.retrieve(priceId, { expand: ['currency_options'] })
result = await stripe.prices.list({ product: productId, active: true, expand: ['data.currency_options'] })
}
catch (error) {
complete = false
logger.withError(error).withFields({ priceId, packKey: pack.key }).warn('Stripe price lookup skipped')
continue
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> {
const cachedPrices = await this.getActivePrices(productId)
const cached = cachedPrices.find(p => p.id === stripePriceId)
if (cached)
return cached
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
await redis.del(PRICES_CACHE_KEY)
return toCachedPrice(fetched)
},
}
}
export async function listStripePackages(catalog: StripePriceCatalog, productId: string): Promise<StripePackage[]> {
const prices = await catalog.getActivePrices(productId)
return prices.map((price) => {
const currencies: Record<string, string> = {
[price.currency]: formatPrice(price.unit_amount, price.currency),
}
for (const [currency, option] of Object.entries(price.currency_options ?? {})) {
currencies[currency] = formatPrice(option.unit_amount, currency)
[price.currency]: formatPrice(price.unitAmount, price.currency),
}
for (const [currency, option] of Object.entries(price.currencyOptions))
currencies[currency] = formatPrice(option.unitAmount, currency)
items.push({
packKey: pack.key,
stripePriceId: priceId,
label: pack.name,
return {
stripePriceId: price.id,
label: `${price.metadata.fluxAmount ?? '?'} Flux`,
defaultCurrency: price.currency,
currencies,
recommended: pack.recommended,
recommended: price.metadata.recommended === 'true',
}
})
}
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(([currency, option]) => [currency, { unitAmount: option.unit_amount }]),
),
}
// A transient provider failure must not hide a pack for the full cache TTL.
if (complete)
await redis.set(PRICES_CACHE_KEY, JSON.stringify({ cacheKey, items }), 'EX', PRICES_CACHE_TTL_SEC)
return items
}
+13 -16
View File
@@ -45,15 +45,8 @@ function createMockPayment(overrides: Partial<PaymentService> = {}): PaymentServ
function createMockConfigKV(overrides: Partial<ConfigKVService> = {}): ConfigKVService {
return {
getOptional: vi.fn(async (key: string) => {
if (key === 'FLUX_PACKS') {
return [{
key: 'starter',
name: '500 Flux',
fluxAmount: 500,
recommended: false,
processors: { stripe: { priceId: 'price_test_500' } },
}]
}
if (key === 'STRIPE_FLUX_PRODUCT_ID')
return 'prod_flux'
return null
}),
getOrThrow: vi.fn(),
@@ -78,7 +71,7 @@ function createTestApp(
payment: PaymentService,
envOverrides: Record<string, any> = {},
stripe: any = {
prices: { retrieve: vi.fn() },
prices: { list: vi.fn(async () => ({ data: [] })), retrieve: vi.fn() },
checkout: { sessions: { create: vi.fn() } },
webhooks: { constructEvent: vi.fn() },
},
@@ -122,14 +115,19 @@ function createTestApp(
describe('stripeRoutes', () => {
describe('gET /api/v1/stripe/packages', () => {
it('returns ConfigKV packs with Stripe display prices', async () => {
it('returns Stripe product prices as Flux packages', async () => {
const stripe = {
prices: {
retrieve: vi.fn(async () => ({
list: vi.fn(async () => ({
data: [{
id: 'price_test_500',
currency: 'usd',
unit_amount: 500,
product: 'prod_flux',
active: true,
metadata: { fluxAmount: '500' },
currency_options: {},
}],
})),
},
webhooks: { constructEvent: vi.fn() },
@@ -139,7 +137,6 @@ describe('stripeRoutes', () => {
const res = await app.request('/api/v1/stripe/packages')
expect(res.status).toBe(200)
expect(await res.json()).toEqual([{
packKey: 'starter',
stripePriceId: 'price_test_500',
label: '500 Flux',
defaultCurrency: 'usd',
@@ -155,7 +152,7 @@ describe('stripeRoutes', () => {
const res = await app.request('/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ packKey: 'starter' }),
body: JSON.stringify({ stripePriceId: 'price_starter' }),
})
expect(res.status).toBe(401)
})
@@ -230,7 +227,7 @@ describe('stripeRoutes', () => {
currency: 'usd',
metadata: {
payment_order_id: 'po_1',
packKey: 'starter',
stripePriceId: 'price_starter',
openpanelDeviceId: 'anon-browser-1',
openpanelSessionId: 'ph-session-1',
},
@@ -265,7 +262,7 @@ describe('stripeRoutes', () => {
action: 'payment_completed',
metadata: expect.objectContaining({
openpanel_device_id: 'anon-browser-1',
pack_key: 'starter',
stripe_price_id: 'price_starter',
}),
}))
})
+4 -18
View File
@@ -1,20 +1,6 @@
import { check, minLength, object, optional, pipe, string } from 'valibot'
import { minLength, object, optional, pipe, string } from 'valibot'
// NOTICE:
// Previous-version clients send stripePriceId. Current clients send packKey.
// Checkout accepts exactly one of these fields so both clients can open a session.
// Remove stripePriceId after previous-version clients ship packKey.
export const CheckoutBodySchema = pipe(
object({
packKey: optional(pipe(string(), minLength(1))),
stripePriceId: optional(pipe(string(), minLength(1))),
export const CheckoutBodySchema = object({
stripePriceId: pipe(string(), minLength(1)),
currency: optional(string()),
}),
check(
(value) => {
const selected = [value.packKey, value.stripePriceId].filter(Boolean)
return selected.length === 1
},
'Provide exactly one of packKey or stripePriceId',
),
)
})
@@ -1,6 +1,6 @@
import type { InferOutput } from 'valibot'
import { any, array, boolean, check, minValue, nonEmpty, number, object, optional, picklist, pipe, record, regex, safeInteger, string } from 'valibot'
import { any, array, boolean, check, nonEmpty, number, object, optional, picklist, pipe, record, regex, string } from 'valibot'
/**
* LLM/TTS router config tree. Single composite entry under configKV holds the
@@ -233,35 +233,6 @@ export const llmRouterConfigSchema = object({
defaults: llmRouterDefaultsSchema,
})
// Processor mappings stay optional so a pack can exist without a Stripe Price.
// Checkout rejects that pack. `object()` (not `strictObject`) strips unknown
// processor keys, so another channel can write ConfigKV before its schema lands.
const fluxPackSchema = object({
key: pipe(string(), nonEmpty('FLUX_PACKS[].key must not be empty')),
name: pipe(string(), nonEmpty('FLUX_PACKS[].name must not be empty')),
fluxAmount: pipe(number(), minValue(1, 'FLUX_PACKS[].fluxAmount must be >= 1'), safeInteger()),
recommended: optional(boolean(), false),
processors: optional(object({
stripe: optional(object({
priceId: pipe(string(), nonEmpty('FLUX_PACKS[].processors.stripe.priceId must not be empty')),
})),
}), {}),
})
const fluxPacksSchema = optional(pipe(
array(fluxPackSchema),
check(
packs => new Set(packs.map(pack => pack.key)).size === packs.length,
'FLUX_PACKS[].key must be unique',
),
check((packs) => {
const priceIds = packs
.map(pack => pack.processors.stripe?.priceId)
.filter((priceId): priceId is string => priceId != null)
return new Set(priceIds).size === priceIds.length
}, 'FLUX_PACKS[].processors.stripe.priceId must be unique'),
), [])
/**
* Config entry schemas are the single source of truth for:
* - runtime validation
@@ -276,8 +247,8 @@ export const configEntrySchemas = {
// Debt-ledger TTL: residual TTS chars below 1 Flux are forgiven on expiry.
// 24h gives users a long-enough window for accumulated dust to settle naturally.
TTS_DEBT_TTL_SECONDS: optional(number(), 86400),
// Display prices come from Stripe Price hydration, not ConfigKV strings.
FLUX_PACKS: fluxPacksSchema,
// No default — absent means top-up is not available yet
STRIPE_FLUX_PRODUCT_ID: optional(string()),
// No default — absent lets Stripe auto-select payment methods via Dashboard config
STRIPE_PAYMENT_METHODS: optional(array(string())),
STRIPE_PAYMENT_METHOD_OPTIONS: optional(record(string(), any()), {}),
@@ -92,46 +92,6 @@ describe('configKVService', () => {
})
})
it('rejects FLUX_PACKS with duplicate keys', async () => {
store._store.set('FLUX_PACKS', JSON.stringify([
{ key: 'starter', name: 'Starter', fluxAmount: 100, processors: { stripe: { priceId: 'price_a' } } },
{ key: 'starter', name: 'Starter 2', fluxAmount: 200, processors: { stripe: { priceId: 'price_b' } } },
]))
await expect(service.getOptional('FLUX_PACKS'))
.rejects
.toMatchObject({
statusCode: 503,
errorCode: 'CONFIG_INVALID',
})
})
it('rejects FLUX_PACKS with duplicate Stripe price ids', async () => {
store._store.set('FLUX_PACKS', JSON.stringify([
{ key: 'starter', name: 'Starter', fluxAmount: 100, processors: { stripe: { priceId: 'price_shared' } } },
{ key: 'plus', name: 'Plus', fluxAmount: 500, processors: { stripe: { priceId: 'price_shared' } } },
]))
await expect(service.getOptional('FLUX_PACKS'))
.rejects
.toMatchObject({
statusCode: 503,
errorCode: 'CONFIG_INVALID',
})
})
it('accepts FLUX_PACKS that omit Stripe when keys are unique', async () => {
store._store.set('FLUX_PACKS', JSON.stringify([
{ key: 'starter', name: 'Starter', fluxAmount: 100 },
{ key: 'plus', name: 'Plus', fluxAmount: 500, processors: { stripe: { priceId: 'price_plus' } } },
]))
await expect(service.getOptional('FLUX_PACKS')).resolves.toEqual([
{ key: 'starter', name: 'Starter', fluxAmount: 100, recommended: false, processors: {} },
{ key: 'plus', name: 'Plus', fluxAmount: 500, recommended: false, processors: { stripe: { priceId: 'price_plus' } } },
])
})
// https://github.com/moeru-ai/airi/pull/2445#discussion_r3913931906
// ROOT CAUSE:
//