feat(server/stripe): update flux package handling and metadata integration
This commit is contained in:
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]),
|
||||
|
||||
Reference in New Issue
Block a user