From 04c8f499b9f5b0ee5a75328246abc78259eafc39 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Sat, 28 Mar 2026 03:24:51 +0800 Subject: [PATCH] feat(server/stripe): update flux package handling and metadata integration --- apps/server/src/routes/stripe/index.ts | 46 +++++++++---------- apps/server/src/routes/stripe/route.test.ts | 5 +- .../billing/tests/billing-service.test.ts | 2 +- apps/server/src/services/config-kv.ts | 5 +- .../src/services/tests/config-kv.test.ts | 26 +++++------ apps/server/src/services/tests/flux.test.ts | 2 +- 6 files changed, 41 insertions(+), 45 deletions(-) diff --git a/apps/server/src/routes/stripe/index.ts b/apps/server/src/routes/stripe/index.ts index 88fd1d9d3..8f2af6e90 100644 --- a/apps/server/src/routes/stripe/index.ts +++ b/apps/server/src/routes/stripe/index.ts @@ -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() .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') } } diff --git a/apps/server/src/routes/stripe/route.test.ts b/apps/server/src/routes/stripe/route.test.ts index 178a05702..fee5f3e9a 100644 --- a/apps/server/src/routes/stripe/route.test.ts +++ b/apps/server/src/routes/stripe/route.test.ts @@ -46,8 +46,7 @@ function createMockBillingService(): BillingService { function createMockConfigKV(overrides: Record = {}): ConfigKVService { const defaults: Record = { - 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 () => { diff --git a/apps/server/src/services/billing/tests/billing-service.test.ts b/apps/server/src/services/billing/tests/billing-service.test.ts index 3b6272ae2..29c503ef9 100644 --- a/apps/server/src/services/billing/tests/billing-service.test.ts +++ b/apps/server/src/services/billing/tests/billing-service.test.ts @@ -15,7 +15,7 @@ import { createBillingService } from '../billing-service' import * as schema from '../../../schemas' function createMockConfigKV(overrides: Record = {}): ReturnType { - const defaults: Record = { INITIAL_USER_FLUX: 100, FLUX_PER_CENT: 1, FLUX_PER_REQUEST: 1, ...overrides } + const defaults: Record = { 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]), diff --git a/apps/server/src/services/config-kv.ts b/apps/server/src/services/config-kv.ts index e0058cbcc..a85b99026 100644 --- a/apps/server/src/services/config-kv.ts +++ b/apps/server/src/services/config-kv.ts @@ -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(), diff --git a/apps/server/src/services/tests/config-kv.test.ts b/apps/server/src/services/tests/config-kv.test.ts index b94b8ca43..dd6d04075 100644 --- a/apps/server/src/services/tests/config-kv.test.ts +++ b/apps/server/src/services/tests/config-kv.test.ts @@ -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) diff --git a/apps/server/src/services/tests/flux.test.ts b/apps/server/src/services/tests/flux.test.ts index a57435ce7..173dfcf2e 100644 --- a/apps/server/src/services/tests/flux.test.ts +++ b/apps/server/src/services/tests/flux.test.ts @@ -13,7 +13,7 @@ import { createFluxService } from '../flux' import * as schema from '../../schemas' function createMockConfigKV(overrides: Record = {}): ReturnType { - const defaults: Record = { INITIAL_USER_FLUX: 100, FLUX_PER_CENT: 1, FLUX_PER_REQUEST: 1, ...overrides } + const defaults: Record = { 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]),