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
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:
@@ -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" />
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
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(),
|
||||
)
|
||||
|
||||
await expect(catalog.findActivePrice(productId, 'price_new')).resolves.toMatchObject({
|
||||
id: 'price_new',
|
||||
metadata: { fluxAmount: '500', recommended: 'true' },
|
||||
})
|
||||
})
|
||||
|
||||
await expect(listStripePackages(createStripe(retrieve), createTestRedis(), [starterPack])).resolves.toEqual([])
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
// 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 })
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
}
|
||||
catch { /* corrupted cache, refetch */ }
|
||||
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 = 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> {
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const items = []
|
||||
let complete = true
|
||||
for (const pack of packs) {
|
||||
const priceId = pack.processors.stripe?.priceId
|
||||
if (!priceId)
|
||||
continue
|
||||
|
||||
let price: Stripe.Price
|
||||
try {
|
||||
price = await stripe.prices.retrieve(priceId, { expand: ['currency_options'] })
|
||||
}
|
||||
catch (error) {
|
||||
complete = false
|
||||
logger.withError(error).withFields({ priceId, packKey: pack.key }).warn('Stripe price lookup skipped')
|
||||
continue
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 () => ({
|
||||
id: 'price_test_500',
|
||||
currency: 'usd',
|
||||
unit_amount: 500,
|
||||
currency_options: {},
|
||||
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',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -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))),
|
||||
currency: optional(string()),
|
||||
}),
|
||||
check(
|
||||
(value) => {
|
||||
const selected = [value.packKey, value.stripePriceId].filter(Boolean)
|
||||
return selected.length === 1
|
||||
},
|
||||
'Provide exactly one of packKey or stripePriceId',
|
||||
),
|
||||
)
|
||||
export const CheckoutBodySchema = object({
|
||||
stripePriceId: pipe(string(), minLength(1)),
|
||||
currency: optional(string()),
|
||||
})
|
||||
|
||||
@@ -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:
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user