feat(server/stripe): update flux package handling and metadata integration

This commit is contained in:
RainbowBird
2026-03-28 03:24:51 +08:00
parent a3ef6767d4
commit 04c8f499b9
6 changed files with 41 additions and 45 deletions
+21 -25
View File
@@ -32,7 +32,7 @@ export function createStripeRoutes(
) {
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null
const fluxConfigGuard = configGuard(configKV, ['FLUX_PER_CENT'], 'Top-up is not available yet')
const fluxConfigGuard = configGuard(configKV, ['FLUX_PACKAGES'], 'Top-up is not available yet')
return new Hono<HonoEnv>()
.get('/packages', async (c) => {
@@ -59,6 +59,13 @@ export function createStripeRoutes(
})
}
// Match amount to a configured package so we know the fluxAmount
const packages = await configKV.get('FLUX_PACKAGES')
const pkg = packages.find(p => p.amount === amount)
if (!pkg) {
throw createBadRequestError('No matching package for the given amount', 'INVALID_PACKAGE', { amount })
}
// Reuse existing stripe customer if available
const customer = await stripeService.getCustomerByUserId(user.id)
const stripeCustomerId = customer?.stripeCustomerId
@@ -83,12 +90,14 @@ export function createStripeRoutes(
},
],
mode: 'payment',
allow_promotion_codes: true,
success_url: `${redirectBase}/settings/flux?success=true`,
cancel_url: `${redirectBase}/settings/flux?canceled=true`,
customer: stripeCustomerId,
customer_email: stripeCustomerId ? undefined : user.email,
metadata: {
userId: user.id,
fluxAmount: String(pkg.fluxAmount),
},
})
@@ -175,7 +184,7 @@ export function createStripeRoutes(
switch (event.type) {
case 'checkout.session.completed': {
await handleCheckoutSessionCompleted(event.id, event.data.object, fluxService, stripeService, billingService, configKV)
await handleCheckoutSessionCompleted(event.id, event.data.object, fluxService, stripeService, billingService)
metrics?.stripeCheckoutCompleted.add(1)
break
}
@@ -195,7 +204,7 @@ export function createStripeRoutes(
case 'invoice.updated':
case 'invoice.paid':
case 'invoice.payment_failed': {
await handleInvoiceEvent(event.id, event.data.object, stripeService, billingService, configKV)
await handleInvoiceEvent(event.data.object, stripeService)
if (event.type === 'invoice.payment_failed') {
metrics?.stripePaymentFailed.add(1)
}
@@ -215,7 +224,6 @@ async function handleCheckoutSessionCompleted(
fluxService: FluxService,
stripeService: StripeService,
billingService: BillingService,
configKV: ConfigKVService,
) {
const userId = session.metadata?.userId
if (!userId) {
@@ -256,9 +264,13 @@ async function handleCheckoutSessionCompleted(
// Idempotent flux credit: use fluxCredited flag inside a transaction
// to prevent double-crediting on webhook replay
if (session.mode === 'payment' && session.amount_total) {
const fluxPerCent = await configKV.getOrThrow('FLUX_PER_CENT')
const fluxAmount = session.amount_total * fluxPerCent
const metadataFlux = session.metadata?.fluxAmount
if (session.mode === 'payment' && session.amount_total && metadataFlux) {
const fluxAmount = Number(metadataFlux)
if (!Number.isFinite(fluxAmount) || fluxAmount <= 0) {
logger.withFields({ userId, sessionId: session.id, metadataFlux }).warn('Invalid fluxAmount in session metadata, skipping credit')
return
}
const result = await billingService.creditFluxFromStripeCheckout({
stripeEventId,
@@ -272,7 +284,6 @@ async function handleCheckoutSessionCompleted(
logger.withFields({
userId,
fluxAmount,
fluxPerCent,
amountTotal: session.amount_total,
applied: result.applied,
balanceAfter: result.balanceAfter,
@@ -327,11 +338,8 @@ async function handleSubscriptionEvent(
}
async function handleInvoiceEvent(
stripeEventId: string,
invoice: Stripe.Invoice,
stripeService: StripeService,
billingService: BillingService,
configKV: ConfigKVService,
) {
const stripeCustomerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id
if (!stripeCustomerId)
@@ -364,20 +372,8 @@ async function handleInvoiceEvent(
metadata: invoice.metadata ? JSON.stringify(invoice.metadata) : null,
})
// Idempotent flux credit for subscription invoice payments
// TODO: implement subscription-based flux crediting when subscriptions are enabled
if (invoice.status === 'paid' && invoice.amount_paid && subscriptionId) {
const fluxPerCent = await configKV.getOrThrow('FLUX_PER_CENT')
const fluxAmount = invoice.amount_paid * fluxPerCent
const result = await billingService.creditFluxFromInvoice({
stripeEventId,
userId: customer.userId,
stripeInvoiceId: invoice.id,
amountPaid: invoice.amount_paid,
currency: invoice.currency ?? 'unknown',
fluxAmount,
})
logger.withFields({ userId: customer.userId, fluxAmount, invoiceId: invoice.id, applied: result.applied }).log('Processed flux credit for subscription invoice')
logger.withFields({ userId: customer.userId, invoiceId: invoice.id, amountPaid: invoice.amount_paid }).warn('Subscription invoice paid but flux crediting for subscriptions is not yet implemented')
}
}
+2 -3
View File
@@ -46,8 +46,7 @@ function createMockBillingService(): BillingService {
function createMockConfigKV(overrides: Record<string, any> = {}): ConfigKVService {
const defaults: Record<string, any> = {
FLUX_PER_CENT: 1,
FLUX_PACKAGES: [{ amount: 500, label: '$5' }],
FLUX_PACKAGES: [{ amount: 500, fluxAmount: 5000, label: '5000 Flux', price: '$5' }],
MAX_CHECKOUT_AMOUNT_CENTS: 1_000_000,
...overrides,
}
@@ -168,7 +167,7 @@ describe('stripeRoutes', () => {
expect(res.status).toBe(200)
const data = await res.json()
expect(data).toEqual([{ amount: 500, label: '$5' }])
expect(data).toEqual([{ amount: 500, fluxAmount: 5000, label: '5000 Flux', price: '$5' }])
})
it('returns empty array when no packages configured', async () => {
@@ -15,7 +15,7 @@ import { createBillingService } from '../billing-service'
import * as schema from '../../../schemas'
function createMockConfigKV(overrides: Record<string, number> = {}): ReturnType<typeof createConfigKVService> {
const defaults: Record<string, number> = { INITIAL_USER_FLUX: 100, FLUX_PER_CENT: 1, FLUX_PER_REQUEST: 1, ...overrides }
const defaults: Record<string, number> = { INITIAL_USER_FLUX: 100, FLUX_PER_REQUEST: 1, ...overrides }
return {
get: vi.fn(async (key: string) => defaults[key]),
getOrThrow: vi.fn(async (key: string) => defaults[key]),
+3 -2
View File
@@ -9,13 +9,15 @@ import { configRedisKey } from '../utils/redis-keys'
export interface FluxPackage {
/** Amount in cents sent to Stripe */
amount: number
/** How much Flux the buyer receives for this package */
fluxAmount: number
/** Display label, e.g. "500 Flux" */
label: string
/** Display price, e.g. "$5" */
price: string
}
const FluxPackageSchema = object({ amount: number(), label: string(), price: string() })
const FluxPackageSchema = object({ amount: number(), fluxAmount: number(), label: string(), price: string() })
/**
* Config entry schemas are the single source of truth for:
@@ -24,7 +26,6 @@ const FluxPackageSchema = object({ amount: number(), label: string(), price: str
* - Redis serialization/deserialization shape
*/
const ConfigEntrySchemas = {
FLUX_PER_CENT: optional(number(), 10),
FLUX_PER_REQUEST: optional(number(), 5),
FLUX_PER_REQUEST_TTS: number(),
FLUX_PER_REQUEST_ASR: number(),
@@ -30,9 +30,9 @@ describe('configKVService', () => {
})
it('get should return numeric value when key is set', async () => {
redis._store.set(configRedisKey('FLUX_PER_CENT'), '5')
redis._store.set(configRedisKey('FLUX_PER_REQUEST'), '5')
const value = await service.getOrThrow('FLUX_PER_CENT')
const value = await service.getOrThrow('FLUX_PER_REQUEST')
expect(value).toBe(5)
})
@@ -46,8 +46,8 @@ describe('configKVService', () => {
// --- getOptional ---
it('getOptional should return schema default when key has one', async () => {
const value = await service.getOptional('FLUX_PER_CENT')
expect(value).toBe(10)
const value = await service.getOptional('FLUX_PER_REQUEST')
expect(value).toBe(5)
})
it('getOptional should return null when required key is not set', async () => {
@@ -65,10 +65,10 @@ describe('configKVService', () => {
// --- set ---
it('set should write value to Redis with prefix', async () => {
await service.set('FLUX_PER_CENT', 10)
await service.set('FLUX_PER_REQUEST', 10)
expect(redis.set).toHaveBeenCalledWith(configRedisKey('FLUX_PER_CENT'), '10')
expect(redis._store.get(configRedisKey('FLUX_PER_CENT'))).toBe('10')
expect(redis.set).toHaveBeenCalledWith(configRedisKey('FLUX_PER_REQUEST'), '10')
expect(redis._store.get(configRedisKey('FLUX_PER_REQUEST'))).toBe('10')
})
it('set should reject invalid values for string config keys', async () => {
@@ -88,8 +88,8 @@ describe('configKVService', () => {
it('get FLUX_PACKAGES should parse JSON array', async () => {
const packages = [
{ amount: 500, label: '500 Flux', price: '$5' },
{ amount: 1000, label: '1000 Flux', price: '$10' },
{ amount: 500, fluxAmount: 5000, label: '5000 Flux', price: '$5' },
{ amount: 1000, fluxAmount: 12000, label: '12000 Flux', price: '$10' },
]
redis._store.set(configRedisKey('FLUX_PACKAGES'), JSON.stringify(packages))
@@ -98,7 +98,7 @@ describe('configKVService', () => {
})
it('set FLUX_PACKAGES should serialize as JSON', async () => {
const packages = [{ amount: 500, label: '500 Flux', price: '$5' }]
const packages = [{ amount: 500, fluxAmount: 5000, label: '5000 Flux', price: '$5' }]
await service.set('FLUX_PACKAGES', packages)
const stored = redis._store.get(configRedisKey('FLUX_PACKAGES'))
@@ -107,9 +107,9 @@ describe('configKVService', () => {
it('fLUX_PACKAGES round-trip should preserve structure', async () => {
const packages = [
{ amount: 500, label: '500 Flux', price: '$5' },
{ amount: 1000, label: '1000 Flux', price: '$10' },
{ amount: 5000, label: '5000 Flux', price: '$45' },
{ amount: 500, fluxAmount: 5000, label: '5000 Flux', price: '$5' },
{ amount: 1000, fluxAmount: 12000, label: '12000 Flux', price: '$10' },
{ amount: 5000, fluxAmount: 75000, label: '75000 Flux', price: '$50' },
]
await service.set('FLUX_PACKAGES', packages)
+1 -1
View File
@@ -13,7 +13,7 @@ import { createFluxService } from '../flux'
import * as schema from '../../schemas'
function createMockConfigKV(overrides: Record<string, number> = {}): ReturnType<typeof createConfigKVService> {
const defaults: Record<string, number> = { INITIAL_USER_FLUX: 100, FLUX_PER_CENT: 1, FLUX_PER_REQUEST: 1, ...overrides }
const defaults: Record<string, number> = { INITIAL_USER_FLUX: 100, FLUX_PER_REQUEST: 1, ...overrides }
return {
get: vi.fn(async (key: string) => defaults[key]),
getOrThrow: vi.fn(async (key: string) => defaults[key]),