refactor(api): shrink payment CORE to settle and deleteAllForUser

Move Stripe checkout, package listing, and session mapping into the Stripe channel so CORE only claims pending orders.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lulu
2026-08-26 10:15:09 +08:00
co-authored by Cursor
parent 4bf1fe09ea
commit dd446c9418
14 changed files with 828 additions and 935 deletions
+14 -11
View File
@@ -14,19 +14,22 @@ auth/OIDC routes.
## Payment
`src/services/domain/payment` owns `payment_order`, `provider_account`,
provider adapters, and the ConfigKV pack mapping (`FLUX_PACKS`).
`src/services/domain/payment` owns pack grant and `payment_order` rows.
CORE exposes `settle` and `deleteAllForUser`.
Each provider keeps its own HTTP paths: Stripe stays on `/api/v1/stripe/*`;
Apple and Steam add `/api/v1/apple-iap/*` and `/api/v1/steam/*`. CORE never
sees a raw provider event.
Checkout, package list, and session mapping live in the Stripe channel
at `src/routes/stripe`. Each provider keeps its own HTTP paths. Stripe
stays on `/api/v1/stripe/*`. CORE never sees a raw provider event.
- Claim is the order transition `pending` `paid`; one transaction writes
`credited_at` and calls `creditFlux`. Replay returns `applied: false`.
- Pack snapshots (`pack_key`, `flux_amount`) live on the order row, not in
`provider_data`.
- `FLUX_PACKS` maps pack key -> Stripe price id + Flux amount; display prices
and currencies come from Stripe through the provider adapter.
- `settle` claims a pending order (`pending` to `paid`). One transaction
writes `credited_at` and calls `creditFlux`. Replay returns `applied: false`.
- Pack snapshots (`pack_key`, `flux_amount`) live on the order row.
- The Stripe channel reads `FLUX_PACKS` and Stripe Price objects for
display prices.
- `POST /api/v1/stripe/checkout` inserts the pending order, then creates
the Checkout Session.
- `POST /api/v1/stripe/webhook` verifies the signature, maps the session
to a `ClaimReceipt`, and calls `settle`.
## Run locally
-1
View File
@@ -21,7 +21,6 @@ function createTestDeps() {
fluxService: {} as never,
fluxTransactionService: {} as never,
paymentService: {} as never,
stripeAdapter: {} as never,
stripe: null,
billingService: {} as never,
ttsMeter: {} as never,
+8 -19
View File
@@ -10,7 +10,7 @@ import type { ChatService } from './services/domain/chats'
import type { FluxService } from './services/domain/flux'
import type { FluxTransactionService } from './services/domain/flux-transaction'
import type { LlmRouterService } from './services/domain/llm-router'
import type { PaymentProvider, PaymentService } from './services/domain/payment'
import type { PaymentService } from './services/domain/payment'
import type { ProductEventService } from './services/domain/product-events'
import type { ProviderCatalogService } from './services/domain/provider-catalog'
import type { ProviderService } from './services/domain/providers'
@@ -68,7 +68,7 @@ import { createChatService } from './services/domain/chats'
import { createFluxService } from './services/domain/flux'
import { createFluxTransactionService } from './services/domain/flux-transaction'
import { createConcurrencyLedger, createConfigSyncSubscriber, createLlmRouterService } from './services/domain/llm-router'
import { createPaymentService, createStripePaymentProvider } from './services/domain/payment'
import { createPaymentService } from './services/domain/payment'
import { createProductEventService } from './services/domain/product-events'
import { createProviderCatalogService } from './services/domain/provider-catalog'
import { createProviderService } from './services/domain/providers'
@@ -88,7 +88,6 @@ interface AppDeps {
fluxService: FluxService
fluxTransactionService: FluxTransactionService
paymentService: PaymentService
stripeAdapter: PaymentProvider
stripe: Stripe | null
billingService: BillingService
ttsMeter: FluxMeter
@@ -401,8 +400,9 @@ export async function buildApp(deps: AppDeps) {
*/
.route('/api/v1/stripe', createStripeRoutes({
payment: deps.paymentService,
stripeAdapter: deps.stripeAdapter,
db: deps.db,
stripe: deps.stripe,
configKV: deps.configKV,
env: deps.env,
metrics: deps.otel?.revenue,
rateLimitMetrics: deps.otel?.rateLimit,
@@ -581,17 +581,11 @@ export async function createApp() {
dependsOn: { env: parsedEnv },
build: ({ dependsOn }) => {
// Stripe SDK is optional — when STRIPE_SECRET_KEY is unset (dev/CI)
// billing routes degrade gracefully and the user-deletion pipeline
// skips the API cancel call.
// billing routes degrade gracefully.
return dependsOn.env.STRIPE_SECRET_KEY ? new Stripe(dependsOn.env.STRIPE_SECRET_KEY) : null
},
})
const stripeAdapter = injeca.provide('services:stripeAdapter', {
dependsOn: { stripe, configKV },
build: ({ dependsOn }) => createStripePaymentProvider(dependsOn.stripe, dependsOn.configKV),
})
const fluxTransactionService = injeca.provide('services:fluxTransaction', {
dependsOn: { db },
build: ({ dependsOn }) => createFluxTransactionService(dependsOn.db),
@@ -623,12 +617,10 @@ export async function createApp() {
})
const paymentService = injeca.provide('services:payment', {
dependsOn: { db, billingService, configKV, stripeAdapter },
dependsOn: { db, billingService },
build: ({ dependsOn }) => createPaymentService({
db: dependsOn.db,
billing: dependsOn.billingService,
configKV: dependsOn.configKV,
providers: { stripe: dependsOn.stripeAdapter },
}),
})
@@ -643,10 +635,9 @@ export async function createApp() {
dependsOn: { paymentService, fluxService, providerService, characterService, chatService },
build: ({ dependsOn }) => {
const service = createUserDeletionService()
// priority: 10 = external side-effects (Stripe API cancel — unrollable),
// 20 = financial / cache state (Flux balance + Redis),
// priority: 20 = financial / cache state (Flux balance + Redis),
// 30 = pure DB soft-delete (no external touch).
service.register({ name: 'payment', priority: 10, softDelete: ({ userId }) => dependsOn.paymentService.deleteAllForUser(userId) })
service.register({ name: 'payment', priority: 30, softDelete: ({ userId }) => dependsOn.paymentService.deleteAllForUser(userId) })
service.register({ name: 'flux', priority: 20, softDelete: ({ userId }) => dependsOn.fluxService.deleteAllForUser(userId) })
service.register({ name: 'providers', priority: 30, softDelete: ({ userId }) => dependsOn.providerService.deleteAllForUser(userId) })
service.register({ name: 'characters', priority: 30, softDelete: ({ userId }) => dependsOn.characterService.deleteAllForUser(userId) })
@@ -717,7 +708,6 @@ export async function createApp() {
voicePackService,
productEventService,
paymentService,
stripeAdapter,
stripe,
billingService,
ttsMeter,
@@ -744,7 +734,6 @@ export async function createApp() {
fluxService: resolved.fluxService,
fluxTransactionService: resolved.fluxTransactionService,
paymentService: resolved.paymentService,
stripeAdapter: resolved.stripeAdapter,
stripe: resolved.stripe,
voicePackService: resolved.voicePackService,
billingService: resolved.billingService,
@@ -0,0 +1,99 @@
import type Stripe from 'stripe'
import type { ConfigKVService } from '../../services/adapters/config-kv'
import type { FluxPack } from '../../services/domain/payment'
import { useLogger } from '@guiiai/logg'
const logger = useLogger('stripe.catalog')
export interface StripePackListItem {
packKey: string
stripePriceId?: string
label: string
defaultCurrency: string
currencies: Record<string, string>
recommended: boolean
}
export async function loadFluxPacks(configKV: ConfigKVService): Promise<FluxPack[]> {
const packs = await configKV.getOptional('FLUX_PACKS') ?? []
return packs.map(pack => ({
key: pack.key,
name: pack.name,
fluxAmount: pack.fluxAmount,
recommended: pack.recommended ?? false,
providers: pack.providers ?? {},
}))
}
export function findFluxPackByKey(packs: FluxPack[], packKey: string): FluxPack | undefined {
return packs.find(pack => pack.key === packKey)
}
export function findFluxPackByStripePriceId(packs: FluxPack[], priceId: string): FluxPack | undefined {
return packs.find(pack => pack.providers.stripe?.priceId === priceId)
}
export async function listStripePackages(
stripe: Stripe | null,
packs: FluxPack[],
): Promise<StripePackListItem[]> {
if (!stripe)
return []
const items: StripePackListItem[] = []
for (const pack of packs) {
const priceId = pack.providers.stripe?.priceId
if (!priceId)
continue
let price: Stripe.Price
try {
price = await stripe.prices.retrieve(priceId, { expand: ['currency_options'] })
}
catch (error) {
logger.withError(error).withFields({ priceId, packKey: pack.key }).warn('Stripe price lookup skipped')
continue
}
const currencies: Record<string, string> = {}
currencies[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)
}
items.push({
packKey: pack.key,
stripePriceId: price.id,
label: pack.name,
defaultCurrency: price.currency,
currencies,
recommended: pack.recommended,
})
}
return items
}
/**
* Formats a Stripe smallest-unit amount into a display price string.
*
* @example
* formatPrice(300, 'usd')
* // => '$3.00'
*/
function formatPrice(unitAmount: number | null, currency: string): string {
if (unitAmount == null)
return currency.toUpperCase()
try {
const formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency })
const fractionDigits = formatter.resolvedOptions().minimumFractionDigits ?? 2
const amount = unitAmount / (10 ** fractionDigits)
return formatter.format(amount)
}
catch {
return `${unitAmount / 100} ${currency.toUpperCase()}`
}
}
@@ -0,0 +1,223 @@
import type { Database } from '../../libs/db'
import type { ConfigKVService } from '../../services/adapters/config-kv'
import type { FluxPack } from '../../services/domain/payment'
import { eq } from 'drizzle-orm'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mockDB } from '../../libs/mock-db'
import { createTestRedis } from '../../libs/tests/redis'
import { createBillingService } from '../../services/domain/billing/billing-service'
import { createPaymentService } from '../../services/domain/payment'
import { createCheckoutOperation } from './operations/checkout'
import * as schema from '../../schemas'
const starterPack: FluxPack = {
key: 'starter',
name: '500 Flux',
fluxAmount: 500,
recommended: false,
providers: { stripe: { priceId: 'price_starter' } },
}
const testEnv = {
STRIPE_SECRET_KEY: 'sk_test_fake',
STRIPE_WEBHOOK_SECRET: 'whsec_test_fake',
API_SERVER_URL: 'http://localhost:8787',
WEB_APP_URL: 'https://airi.moeru.ai',
ADDITIONAL_TRUSTED_ORIGINS: [],
} as any
const testUser = { id: 'user-pay-1', name: 'Pay User', email: 'pay@example.com' }
function createPacksConfigKV(packs: FluxPack[]): ConfigKVService {
return {
getOptional: vi.fn(async (key: string) => {
if (key === 'FLUX_PACKS')
return packs
return null
}),
getOrThrow: vi.fn(),
get: vi.fn(),
refresh: vi.fn(),
invalidateCache: vi.fn(),
} as ConfigKVService
}
describe('stripe checkout', () => {
let db: Database
let payment: ReturnType<typeof createPaymentService>
beforeAll(async () => {
db = await mockDB(schema)
await db.insert(schema.user).values({
id: 'user-pay-1',
name: 'Pay User',
email: 'pay@example.com',
})
})
beforeEach(async () => {
const redis = createTestRedis()
const billing = createBillingService(db, redis, createPacksConfigKV([starterPack]))
payment = createPaymentService({ db, billing })
await db.delete(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, 'user-pay-1'))
await db.delete(schema.userFlux).where(eq(schema.userFlux.userId, 'user-pay-1'))
await db.delete(schema.paymentOrder).where(eq(schema.paymentOrder.userId, 'user-pay-1'))
await db.delete(schema.providerAccount).where(eq(schema.providerAccount.userId, 'user-pay-1'))
})
it('inserts a pending order then creates a Checkout Session', async () => {
const create = vi.fn(async (params: { metadata?: Record<string, string> }) => {
const [order] = await db.select().from(schema.paymentOrder).where(eq(schema.paymentOrder.userId, 'user-pay-1'))
expect(order?.status).toBe('pending')
expect(order?.providerOrderId).toBeNull()
expect(order?.packKey).toBe('starter')
expect(order?.fluxAmount).toBe(500)
expect(params.metadata?.payment_order_id).toBe(order?.id)
return {
id: 'cs_test_1',
url: 'https://checkout.stripe.test/cs_test_1',
amount_total: 500,
currency: 'usd',
}
})
const checkout = createCheckoutOperation({
db,
stripe: { checkout: { sessions: { create } } } as any,
configKV: createPacksConfigKV([starterPack]),
env: testEnv,
})
const result = await checkout({
user: testUser as any,
body: { packKey: 'starter', currency: 'usd' },
request: new Request('http://localhost/api/v1/stripe/checkout'),
})
expect(result).toEqual({ url: 'https://checkout.stripe.test/cs_test_1' })
const [order] = await db.select().from(schema.paymentOrder).where(eq(schema.paymentOrder.userId, 'user-pay-1'))
expect(order?.status).toBe('pending')
expect(order?.providerOrderId).toBe('cs_test_1')
expect(order?.amount).toBe(500)
expect(order?.currency).toBe('usd')
})
it('resolves legacy 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',
}))
const checkout = createCheckoutOperation({
db,
stripe: { checkout: { sessions: { create } } } as any,
configKV: createPacksConfigKV([starterPack]),
env: testEnv,
})
await checkout({
user: testUser as any,
body: { stripePriceId: 'price_starter' },
request: 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()
})
it('credits Flux when settle runs before the session id is bound', async () => {
const create = vi.fn(async (params: { metadata?: Record<string, string> }) => {
const paymentOrderId = params.metadata?.payment_order_id
expect(paymentOrderId).toBeTruthy()
const result = await payment.settle({
kind: 'claim',
provider: 'stripe',
paymentOrderId: paymentOrderId!,
providerOrderId: 'cs_test_race',
status: 'paid',
providerCustomerId: 'cus_test',
})
expect(result.applied).toBe(true)
return {
id: 'cs_test_race',
url: 'https://checkout.stripe.test/cs_test_race',
amount_total: 500,
currency: 'usd',
}
})
const checkout = createCheckoutOperation({
db,
stripe: { checkout: { sessions: { create } } } as any,
configKV: createPacksConfigKV([starterPack]),
env: testEnv,
})
await checkout({
user: testUser as any,
body: { packKey: 'starter' },
request: new Request('http://localhost/api/v1/stripe/checkout'),
})
const [flux] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-pay-1'))
expect(flux?.flux).toBe(500)
const [order] = await db.select().from(schema.paymentOrder).where(eq(schema.paymentOrder.userId, 'user-pay-1'))
expect(order?.status).toBe('paid')
expect(order?.providerOrderId).toBe('cs_test_race')
})
it('stores browser PostHog identity in Checkout Session metadata', async () => {
const create = vi.fn(async () => ({
id: 'cs_test_ph',
url: 'https://checkout.stripe.test/cs_test_ph',
amount_total: 500,
currency: 'usd',
}))
const productEventService = { track: vi.fn() }
const checkout = createCheckoutOperation({
db,
stripe: { checkout: { sessions: { create } } } as any,
configKV: createPacksConfigKV([starterPack]),
env: testEnv,
productEventService: productEventService as any,
})
await checkout({
user: testUser as any,
body: { packKey: 'starter' },
request: new Request('http://localhost/api/v1/stripe/checkout', {
headers: {
'x-posthog-distinct-id': 'anon-browser-1',
'x-posthog-session-id': 'ph-session-1',
},
}),
})
expect(create).toHaveBeenCalledWith(expect.objectContaining({
metadata: expect.objectContaining({
posthogDistinctId: 'anon-browser-1',
posthogSessionId: 'ph-session-1',
}),
}))
expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({
action: 'checkout_started',
metadata: expect.objectContaining({
posthog_distinct_id: 'anon-browser-1',
}),
}))
})
})
@@ -0,0 +1,40 @@
import type Stripe from 'stripe'
import type { ClaimReceipt } from '../../services/domain/payment'
import { createInternalError } from '../../utils/error'
/**
* Maps a verified Stripe Checkout Session onto a CORE claim receipt.
*/
export function claimReceiptFromCheckoutSession(session: Stripe.Checkout.Session): ClaimReceipt {
const providerCustomerId = typeof session.customer === 'string'
? session.customer
: session.customer?.id
const paymentOrderId = session.metadata?.payment_order_id
if (!paymentOrderId)
throw createInternalError('Payment confirmation is missing payment_order_id')
const status = session.status === 'expired' ? 'expired' : 'paid'
return {
kind: 'claim',
provider: 'stripe',
paymentOrderId,
providerOrderId: session.id,
status,
amount: session.amount_total ?? undefined,
currency: session.currency ?? undefined,
providerCustomerId,
extras: {
sessionId: session.id,
customerId: providerCustomerId,
paymentIntentId: typeof session.payment_intent === 'string'
? session.payment_intent
: session.payment_intent?.id,
mode: session.mode,
paymentStatus: session.payment_status,
},
}
}
+13 -26
View File
@@ -1,8 +1,10 @@
import type Stripe from 'stripe'
import type { Database } from '../../libs/db'
import type { Env } from '../../libs/env'
import type { RateLimitMetrics, RevenueMetrics } from '../../otel'
import type { PaymentProvider, PaymentService } from '../../services/domain/payment'
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 { HonoEnv } from '../../types/hono'
@@ -10,15 +12,15 @@ import { Hono } from 'hono'
import { authGuard } from '../../middlewares/auth'
import { rateLimiter } from '../../middlewares/rate-limit'
import { createBadRequestError, createServiceUnavailableError } from '../../utils/error'
import { resolveCheckoutRedirectBase } from '../../utils/origin'
import { listStripePackages, loadFluxPacks } from './catalog'
import { createCheckoutOperation } from './operations/checkout'
import { createWebhookOperation } from './operations/webhook'
export interface StripeRouteDeps {
payment: PaymentService
stripeAdapter: PaymentProvider
db: Database
stripe: Stripe | null
configKV: ConfigKVService
env: Env
metrics?: RevenueMetrics | null
rateLimitMetrics?: RateLimitMetrics | null
@@ -28,11 +30,14 @@ export interface StripeRouteDeps {
/**
* Creates Stripe HTTP routes for Flux purchase.
*
* Paths stay on `/api/v1/stripe`. Checkout and webhook dispatch into Payment CORE.
* Paths stay on `/api/v1/stripe`. Checkout lives in this channel.
* Webhook dispatch maps a session onto Payment CORE `settle`.
*/
export function createStripeRoutes(deps: StripeRouteDeps) {
const checkout = createCheckoutOperation({
payment: deps.payment,
db: deps.db,
stripe: deps.stripe,
configKV: deps.configKV,
env: deps.env,
metrics: deps.metrics,
productEventService: deps.productEventService,
@@ -40,7 +45,6 @@ export function createStripeRoutes(deps: StripeRouteDeps) {
const webhook = createWebhookOperation({
stripe: deps.stripe,
webhookSecret: deps.env.STRIPE_WEBHOOK_SECRET,
stripeAdapter: deps.stripeAdapter,
payment: deps.payment,
metrics: deps.metrics,
productEventService: deps.productEventService,
@@ -48,7 +52,8 @@ export function createStripeRoutes(deps: StripeRouteDeps) {
return new Hono<HonoEnv>()
.get('/packages', async (c) => {
return c.json(await deps.payment.listPacks('stripe'))
const packs = await loadFluxPacks(deps.configKV)
return c.json(await listStripePackages(deps.stripe, packs))
})
.post('/checkout', authGuard, rateLimiter({ max: 10, windowSec: 60, metrics: deps.rateLimitMetrics, routeLabel: 'stripe.checkout' }), async (c) => {
const body = await c.req.json()
@@ -58,24 +63,6 @@ export function createStripeRoutes(deps: StripeRouteDeps) {
request: c.req.raw,
}))
})
.post('/portal', authGuard, async (c) => {
if (!deps.stripe)
throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED')
const user = c.get('user')!
const account = await deps.payment.getProviderAccount({ userId: user.id, provider: 'stripe' })
if (!account)
throw createBadRequestError('No billing account found', 'NO_CUSTOMER')
const portalReturnBase = resolveCheckoutRedirectBase(c.req.raw, deps.env.ADDITIONAL_TRUSTED_ORIGINS, deps.env.WEB_APP_URL)
const portalSession = await deps.stripe.billingPortal.sessions.create({
customer: account.providerCustomerId,
return_url: `${portalReturnBase}/settings/flux`,
})
return c.json({ url: portalSession.url })
})
.post('/webhook', async (c) => {
const signature = c.req.header('stripe-signature') ?? null
const body = signature ? await c.req.text() : ''
@@ -1,19 +1,30 @@
import type Stripe from 'stripe'
import type { Database } from '../../../libs/db'
import type { Env } from '../../../libs/env'
import type { RevenueMetrics } from '../../../otel'
import type { PaymentService } from '../../../services/domain/payment'
import type { ConfigKVService } from '../../../services/adapters/config-kv'
import type { FluxPack } from '../../../services/domain/payment'
import type { ProductEventService } from '../../../services/domain/product-events'
import type { HonoEnv } from '../../../types/hono'
import { and, eq, isNull } from 'drizzle-orm'
import { safeParse } from 'valibot'
import { createBadRequestError } from '../../../utils/error'
import { createBadRequestError, createInternalError, createServiceUnavailableError } from '../../../utils/error'
import { resolveCheckoutRedirectBase } from '../../../utils/origin'
import { findFluxPackByKey, findFluxPackByStripePriceId, loadFluxPacks } from '../catalog'
import { CheckoutBodySchema } from '../schema'
import * as schema from '../../../schemas/payment'
type AuthenticatedUser = NonNullable<HonoEnv['Variables']['user']>
type CheckoutSessionCreateParams = NonNullable<Parameters<Stripe['checkout']['sessions']['create']>[0]>
export interface CheckoutOperationDeps {
payment: PaymentService
db: Database
stripe: Stripe | null
configKV: ConfigKVService
env: Env
metrics?: RevenueMetrics | null
productEventService?: ProductEventService
@@ -26,13 +37,16 @@ export interface CheckoutOperationInput {
}
/**
* Dispatches Stripe checkout onto Payment CORE.
* Inserts a pending `payment_order`, then creates a Stripe Checkout Session.
*
* `{ packKey }` and legacy `{ stripePriceId }` call `startPack`.
* `{ planKey }` is rejected until Phase 2.
* `{ packKey }` and legacy `{ stripePriceId }` resolve a Flux pack.
* `{ planKey }` is rejected until subscriptions ship.
*/
export function createCheckoutOperation(deps: CheckoutOperationDeps) {
return async (input: CheckoutOperationInput): Promise<{ url: string }> => {
if (!deps.stripe)
throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED')
const parsed = safeParse(CheckoutBodySchema, input.body)
if (!parsed.success)
throw createBadRequestError('Invalid checkout request', 'INVALID_REQUEST', parsed.issues)
@@ -41,49 +55,156 @@ export function createCheckoutOperation(deps: CheckoutOperationDeps) {
if (planKey)
throw createBadRequestError('Subscription checkout is not available', 'PLAN_CHECKOUT_UNAVAILABLE')
let resolvedPackKey = packKey
if (!resolvedPackKey) {
const pack = await deps.payment.resolvePack({ provider: 'stripe', providerProductId: stripePriceId! })
if (!pack)
throw createBadRequestError('Invalid price', 'INVALID_PACKAGE', { stripePriceId })
resolvedPackKey = pack.key
const packs = await loadFluxPacks(deps.configKV)
const pack = resolveCheckoutPack(packs, packKey, stripePriceId)
if (!pack) {
throw createBadRequestError(
packKey ? 'Invalid pack' : 'Invalid price',
'INVALID_PACKAGE',
packKey ? { packKey } : { stripePriceId },
)
}
const redirectBase = resolveCheckoutRedirectBase(input.request, deps.env.ADDITIONAL_TRUSTED_ORIGINS, deps.env.WEB_APP_URL)
const posthogIdentity = readPosthogIdentityHeaders(input.request)
const result = await deps.payment.startPack({
const [order] = await deps.db.insert(schema.paymentOrder).values({
userId: input.user.id,
provider: 'stripe',
packKey: resolvedPackKey,
startContext: {
currency,
successUrl: `${redirectBase}/settings/flux?success=true`,
cancelUrl: `${redirectBase}/settings/flux?canceled=true`,
customerEmail: input.user.email,
metadata: {
...(posthogIdentity.distinctId && { posthogDistinctId: posthogIdentity.distinctId }),
...(posthogIdentity.sessionId && { posthogSessionId: posthogIdentity.sessionId }),
},
status: 'pending',
packKey: pack.key,
fluxAmount: pack.fluxAmount,
currency,
}).returning()
if (!order)
throw createInternalError('Failed to create payment order')
const [account] = await deps.db
.select({ providerCustomerId: schema.providerAccount.providerCustomerId })
.from(schema.providerAccount)
.where(and(
eq(schema.providerAccount.userId, input.user.id),
eq(schema.providerAccount.provider, 'stripe'),
isNull(schema.providerAccount.deletedAt),
))
.limit(1)
const created = await createCheckoutSession(deps.stripe, deps.configKV, {
paymentOrderId: order.id,
userId: input.user.id,
pack,
currency,
successUrl: `${redirectBase}/settings/flux?success=true`,
cancelUrl: `${redirectBase}/settings/flux?canceled=true`,
customerEmail: input.user.email,
providerCustomerId: account?.providerCustomerId ?? null,
metadata: {
...(posthogIdentity.distinctId && { posthogDistinctId: posthogIdentity.distinctId }),
...(posthogIdentity.sessionId && { posthogSessionId: posthogIdentity.sessionId }),
},
})
await deps.db.update(schema.paymentOrder)
.set({
providerOrderId: created.providerOrderId,
amount: created.amount,
currency: created.currency ?? currency,
updatedAt: new Date(),
})
.where(and(
eq(schema.paymentOrder.id, order.id),
isNull(schema.paymentOrder.providerOrderId),
))
deps.metrics?.stripeCheckoutCreated.add(1)
void deps.productEventService?.track({
userId: input.user.id,
feature: 'billing',
action: 'checkout_started',
status: 'succeeded',
eventId: result.paymentOrderId,
eventId: order.id,
source: 'stripe.checkout',
metadata: {
pack_key: resolvedPackKey,
pack_key: pack.key,
...(posthogIdentity.distinctId && { posthog_distinct_id: posthogIdentity.distinctId }),
...(posthogIdentity.sessionId && { posthog_session_id: posthogIdentity.sessionId }),
},
})
return { url: result.url }
return { url: created.url }
}
}
function resolveCheckoutPack(
packs: FluxPack[],
packKey: string | undefined,
stripePriceId: string | undefined,
): FluxPack | undefined {
if (packKey)
return findFluxPackByKey(packs, packKey)
if (stripePriceId)
return findFluxPackByStripePriceId(packs, stripePriceId)
return undefined
}
async function createCheckoutSession(
stripe: Stripe,
configKV: ConfigKVService,
input: {
paymentOrderId: string
userId: string
pack: FluxPack
currency?: string
successUrl: string
cancelUrl: string
customerEmail?: string
providerCustomerId?: string | null
metadata?: Record<string, string>
},
): Promise<{ providerOrderId: string, url: string, amount?: number, currency?: string }> {
const priceId = input.pack.providers.stripe?.priceId
if (!priceId)
throw createServiceUnavailableError('Stripe pack mapping is missing', 'STRIPE_PACK_NOT_MAPPED', { packKey: input.pack.key })
const paymentMethods = await configKV.getOptional('STRIPE_PAYMENT_METHODS')
const paymentMethodOptions = await configKV.getOptional('STRIPE_PAYMENT_METHOD_OPTIONS') ?? {}
const sessionParams: CheckoutSessionCreateParams = {
line_items: [{ price: priceId, quantity: 1 }],
mode: 'payment',
allow_promotion_codes: true,
success_url: input.successUrl,
cancel_url: input.cancelUrl,
customer: input.providerCustomerId ?? undefined,
customer_email: input.providerCustomerId ? undefined : input.customerEmail,
metadata: {
payment_order_id: input.paymentOrderId,
userId: input.userId,
packKey: input.pack.key,
fluxAmount: String(input.pack.fluxAmount),
...input.metadata,
},
}
if (paymentMethods)
sessionParams.payment_method_types = paymentMethods as CheckoutSessionCreateParams['payment_method_types']
if (Object.keys(paymentMethodOptions).length > 0)
sessionParams.payment_method_options = paymentMethodOptions as CheckoutSessionCreateParams['payment_method_options']
if (input.currency)
sessionParams.currency = input.currency
const session = await stripe.checkout.sessions.create(sessionParams)
if (!session.url)
throw createServiceUnavailableError('Stripe checkout did not return a URL', 'STRIPE_CHECKOUT_URL_MISSING')
return {
providerOrderId: session.id,
url: session.url,
amount: session.amount_total ?? undefined,
currency: session.currency ?? undefined,
}
}
@@ -1,20 +1,20 @@
import type Stripe from 'stripe'
import type { RevenueMetrics } from '../../../otel'
import type { PaymentProvider, PaymentService } from '../../../services/domain/payment'
import type { PaymentService } from '../../../services/domain/payment'
import type { ProductEventService } from '../../../services/domain/product-events'
import { useLogger } from '@guiiai/logg'
import { errorMessageFrom } from '@moeru/std'
import { createBadRequestError, createServiceUnavailableError } from '../../../utils/error'
import { errorMessageFromUnknown } from '../../../utils/error-message'
import { claimReceiptFromCheckoutSession } from '../claim'
const logger = useLogger('stripe')
export interface WebhookOperationDeps {
stripe: Stripe | null
webhookSecret: string | undefined
stripeAdapter: PaymentProvider
payment: PaymentService
metrics?: RevenueMetrics | null
productEventService?: ProductEventService
@@ -26,8 +26,8 @@ export interface WebhookOperationInput {
}
/**
* Verifies a Stripe webhook, maps the native event through the Stripe adapter,
* then calls Payment CORE. Subscription and invoice events are logged only.
* Verifies a Stripe webhook, maps a Checkout Session to a claim receipt,
* then calls Payment CORE. Unknown events are ignored.
*/
export function createWebhookOperation(deps: WebhookOperationDeps) {
return async (input: WebhookOperationInput): Promise<{ received: true }> => {
@@ -42,7 +42,7 @@ export function createWebhookOperation(deps: WebhookOperationDeps) {
event = deps.stripe.webhooks.constructEvent(input.body, input.signature, deps.webhookSecret)
}
catch (err: unknown) {
throw createBadRequestError(`Webhook Error: ${errorMessageFromUnknown(err)}`, 'WEBHOOK_ERROR')
throw createBadRequestError(`Webhook Error: ${errorMessageFrom(err) ?? 'unknown error'}`, 'WEBHOOK_ERROR')
}
logger.withFields({ type: event.type, id: event.id }).log('Webhook event received')
@@ -56,8 +56,8 @@ export function createWebhookOperation(deps: WebhookOperationDeps) {
break
}
const facts = deps.stripeAdapter.confirmed(session)
const result = await deps.payment.applyConfirmation(facts)
const receipt = claimReceiptFromCheckoutSession(session)
const result = await deps.payment.settle(receipt)
deps.metrics?.stripeCheckoutCompleted.add(1)
if (session.amount_total != null && session.currency) {
deps.metrics?.stripeRevenue.add(session.amount_total, {
@@ -89,18 +89,8 @@ export function createWebhookOperation(deps: WebhookOperationDeps) {
break
}
case 'checkout.session.expired': {
const facts = deps.stripeAdapter.confirmed(event.data.object)
await deps.payment.applyConfirmation(facts)
break
}
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
case 'invoice.created':
case 'invoice.updated':
case 'invoice.paid':
case 'invoice.payment_failed': {
logger.withFields({ type: event.type, id: event.id }).log('Ignoring subscription or invoice event until Phase 2')
const receipt = claimReceiptFromCheckoutSession(event.data.object)
await deps.payment.settle(receipt)
break
}
default:
+51 -131
View File
@@ -1,4 +1,5 @@
import type { PaymentProvider, PaymentService } from '../../services/domain/payment'
import type { ConfigKVService } from '../../services/adapters/config-kv'
import type { PaymentService } from '../../services/domain/payment'
import type { HonoEnv } from '../../types/hono'
import { Hono } from 'hono'
@@ -6,44 +7,36 @@ import { describe, expect, it, vi } from 'vitest'
import { createStripeRoutes } from '.'
import { ApiError } from '../../utils/error'
import { createCheckoutOperation } from './operations/checkout'
import { createWebhookOperation } from './operations/webhook'
function createMockPayment(overrides: Partial<PaymentService> = {}): PaymentService {
return {
listPacks: vi.fn(async () => []),
resolvePack: vi.fn(async () => ({
key: 'starter',
name: '500 Flux',
fluxAmount: 500,
recommended: false,
providers: { stripe: { priceId: 'price_test_500' } },
})),
getProviderAccount: vi.fn(async () => null),
startPack: vi.fn(async () => ({ kind: 'redirect' as const, url: 'https://checkout.stripe.com/cs_1', paymentOrderId: 'po_1' })),
applyConfirmation: vi.fn(async () => ({ applied: true, userId: 'user-1', fluxAmount: 500, balanceAfter: 500 })),
cancel: vi.fn(),
settle: vi.fn(async () => ({ applied: true, userId: 'user-1', fluxAmount: 500, balanceAfter: 500 })),
deleteAllForUser: vi.fn(),
...overrides,
} as PaymentService
}
}
function createMockStripeAdapter(): PaymentProvider {
function createMockConfigKV(overrides: Partial<ConfigKVService> = {}): ConfigKVService {
return {
create: vi.fn(),
listPackages: vi.fn(async () => []),
confirmed: vi.fn((native: any) => ({
provider: 'stripe' as const,
paymentOrderId: native.metadata?.payment_order_id,
providerOrderId: native.id,
status: native.status === 'expired' ? 'expired' as const : 'paid' as const,
amount: native.amount_total,
currency: native.currency,
providerCustomerId: native.customer,
})),
cancel: vi.fn(),
getStatus: vi.fn(async () => null),
}
getOptional: vi.fn(async (key: string) => {
if (key === 'FLUX_PACKS') {
return [{
key: 'starter',
name: '500 Flux',
fluxAmount: 500,
recommended: false,
providers: { stripe: { priceId: 'price_test_500' } },
}]
}
return null
}),
getOrThrow: vi.fn(),
get: vi.fn(),
refresh: vi.fn(),
invalidateCache: vi.fn(),
...overrides,
} as ConfigKVService
}
const testEnv = {
@@ -59,12 +52,18 @@ const testUser = { id: 'user-1', name: 'Test User', email: 'test@example.com' }
function createTestApp(
payment: PaymentService,
envOverrides: Record<string, any> = {},
stripe: any = { billingPortal: { sessions: { create: vi.fn() } }, webhooks: { constructEvent: vi.fn() } },
stripe: any = {
prices: { retrieve: vi.fn() },
checkout: { sessions: { create: vi.fn() } },
webhooks: { constructEvent: vi.fn() },
},
configKV: ConfigKVService = createMockConfigKV(),
) {
const routes = createStripeRoutes({
payment,
stripeAdapter: createMockStripeAdapter(),
db: {} as never,
stripe: envOverrides.STRIPE_SECRET_KEY === '' ? null : stripe,
configKV,
env: { ...testEnv, ...envOverrides },
})
const app = new Hono<HonoEnv>()
@@ -93,18 +92,19 @@ function createTestApp(
describe('stripeRoutes', () => {
describe('gET /api/v1/stripe/packages', () => {
it('returns ConfigKV packs', async () => {
const payment = createMockPayment({
listPacks: vi.fn(async () => [{
packKey: 'starter',
stripePriceId: 'price_test_500',
label: '500 Flux',
defaultCurrency: 'usd',
currencies: { usd: '$5.00' },
recommended: false,
}]),
})
const app = createTestApp(payment)
it('returns ConfigKV packs with Stripe display prices', async () => {
const stripe = {
prices: {
retrieve: vi.fn(async () => ({
id: 'price_test_500',
currency: 'usd',
unit_amount: 500,
currency_options: {},
})),
},
webhooks: { constructEvent: vi.fn() },
}
const app = createTestApp(createMockPayment(), {}, stripe)
const res = await app.request('/api/v1/stripe/packages')
expect(res.status).toBe(200)
@@ -157,75 +157,6 @@ describe('stripeRoutes', () => {
const data = await res.json() as any
expect(data.error).toBe('PLAN_CHECKOUT_UNAVAILABLE')
})
it('starts a pack checkout from packKey', async () => {
const payment = createMockPayment()
const app = createTestApp(payment)
const res = await app.fetch(
new Request('http://localhost/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ packKey: 'starter', currency: 'usd' }),
}),
{ user: testUser } as any,
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ url: 'https://checkout.stripe.com/cs_1' })
expect(payment.startPack).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1',
provider: 'stripe',
packKey: 'starter',
}))
})
it('resolves legacy stripePriceId onto startPack', async () => {
const payment = createMockPayment()
const app = createTestApp(payment)
const res = await app.fetch(
new Request('http://localhost/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stripePriceId: 'price_test_500' }),
}),
{ user: testUser } as any,
)
expect(res.status).toBe(200)
expect(payment.resolvePack).toHaveBeenCalledWith({
provider: 'stripe',
providerProductId: 'price_test_500',
})
expect(payment.startPack).toHaveBeenCalledWith(expect.objectContaining({ packKey: 'starter' }))
})
it('stores browser PostHog identity in startContext metadata', async () => {
const payment = createMockPayment()
const productEventService = { track: vi.fn() }
const operation = createCheckoutOperation({
payment,
env: testEnv,
productEventService: productEventService as any,
})
await operation({
user: testUser as any,
body: { packKey: 'starter' },
request: new Request('http://localhost/api/v1/stripe/checkout', {
headers: {
'x-posthog-distinct-id': 'anon-browser-1',
'x-posthog-session-id': 'ph-session-1',
},
}),
})
expect(payment.startPack).toHaveBeenCalledWith(expect.objectContaining({
startContext: expect.objectContaining({
metadata: {
posthogDistinctId: 'anon-browser-1',
posthogSessionId: 'ph-session-1',
},
}),
}))
})
})
describe('gET /api/v1/stripe/orders', () => {
@@ -251,21 +182,13 @@ describe('stripeRoutes', () => {
})
describe('pOST /api/v1/stripe/portal', () => {
it('returns 401 when unauthenticated', async () => {
const app = createTestApp(createMockPayment())
const res = await app.request('/api/v1/stripe/portal', { method: 'POST' })
expect(res.status).toBe(401)
})
it('returns 400 when user has no billing account', async () => {
it('returns 404 after the billing portal was removed', async () => {
const app = createTestApp(createMockPayment())
const res = await app.fetch(
new Request('http://localhost/api/v1/stripe/portal', { method: 'POST' }),
{ user: testUser } as any,
)
expect(res.status).toBe(400)
const data = await res.json() as any
expect(data.error).toBe('NO_CUSTOMER')
expect(res.status).toBe(404)
})
})
@@ -310,7 +233,7 @@ describe('stripeRoutes', () => {
expect(res.status).toBe(503)
})
it('applies confirmation for a paid checkout session', async () => {
it('settles a paid checkout session', async () => {
const checkoutEvent = {
id: 'evt_checkout_completed',
type: 'checkout.session.completed',
@@ -333,7 +256,6 @@ describe('stripeRoutes', () => {
},
}
const payment = createMockPayment()
const stripeAdapter = createMockStripeAdapter()
const productEventService = { track: vi.fn() }
const webhook = createWebhookOperation({
stripe: {
@@ -342,15 +264,14 @@ describe('stripeRoutes', () => {
},
} as any,
webhookSecret: 'whsec_test',
stripeAdapter,
payment,
productEventService: productEventService as any,
})
await webhook({ signature: 'test_sig', body: '{}' })
expect(stripeAdapter.confirmed).toHaveBeenCalled()
expect(payment.applyConfirmation).toHaveBeenCalledWith(expect.objectContaining({
expect(payment.settle).toHaveBeenCalledWith(expect.objectContaining({
kind: 'claim',
provider: 'stripe',
paymentOrderId: 'po_1',
providerOrderId: 'cs_1',
@@ -365,7 +286,7 @@ describe('stripeRoutes', () => {
}))
})
it('logs subscription events and does not apply confirmation', async () => {
it('ignores unknown events and does not settle', async () => {
const payment = createMockPayment()
const webhook = createWebhookOperation({
stripe: {
@@ -378,12 +299,11 @@ describe('stripeRoutes', () => {
},
} as any,
webhookSecret: 'whsec_test',
stripeAdapter: createMockStripeAdapter(),
payment,
})
await webhook({ signature: 'test_sig', body: '{}' })
expect(payment.applyConfirmation).not.toHaveBeenCalled()
expect(payment.settle).not.toHaveBeenCalled()
})
})
})
@@ -1,178 +0,0 @@
import type Stripe from 'stripe'
import type { ConfigKVService } from '../../../adapters/config-kv'
import type { ConfirmationFacts, FluxPack, FluxPackListItem, PaymentProvider, ProviderCreateInput, ProviderCreateResult } from '../types'
import { useLogger } from '@guiiai/logg'
import { createServiceUnavailableError } from '../../../../utils/error'
const logger = useLogger('payment.stripe')
type CheckoutSessionCreateParams = NonNullable<Parameters<Stripe['checkout']['sessions']['create']>[0]>
/**
* Stripe adapter for the Payment Provider port.
*
* Checkout create and native-to-facts mapping live here. Signature verify and
* Customer Portal stay in the Stripe route.
*/
export function createStripePaymentProvider(
stripe: Stripe | null,
configKV: ConfigKVService,
): PaymentProvider {
return {
async listPackages(packs: FluxPack[]): Promise<FluxPackListItem[]> {
if (!stripe)
return []
const items: FluxPackListItem[] = []
for (const pack of packs) {
const priceId = pack.providers.stripe?.priceId
if (!priceId)
continue
let price: Stripe.Price
try {
price = await stripe.prices.retrieve(priceId, { expand: ['currency_options'] })
}
catch (error) {
logger.withError(error).withFields({ priceId, packKey: pack.key }).warn('Stripe price lookup skipped')
continue
}
const currencies: Record<string, string> = {}
currencies[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)
}
items.push({
packKey: pack.key,
stripePriceId: price.id,
label: pack.name,
defaultCurrency: price.currency,
currencies,
recommended: pack.recommended,
})
}
return items
},
async create(input: ProviderCreateInput): Promise<ProviderCreateResult> {
if (!stripe)
throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED')
const priceId = input.pack.providers.stripe?.priceId
if (!priceId)
throw createServiceUnavailableError('Stripe pack mapping is missing', 'STRIPE_PACK_NOT_MAPPED', { packKey: input.pack.key })
const paymentMethods = await configKV.getOptional('STRIPE_PAYMENT_METHODS')
const paymentMethodOptions = await configKV.getOptional('STRIPE_PAYMENT_METHOD_OPTIONS') ?? {}
const sessionParams: CheckoutSessionCreateParams = {
line_items: [{ price: priceId, quantity: 1 }],
mode: 'payment',
allow_promotion_codes: true,
success_url: input.successUrl,
cancel_url: input.cancelUrl,
customer: input.providerCustomerId ?? undefined,
customer_email: input.providerCustomerId ? undefined : input.customerEmail,
metadata: {
payment_order_id: input.paymentOrderId,
userId: input.userId,
packKey: input.pack.key,
fluxAmount: String(input.pack.fluxAmount),
...input.metadata,
},
}
if (paymentMethods)
sessionParams.payment_method_types = paymentMethods as CheckoutSessionCreateParams['payment_method_types']
if (Object.keys(paymentMethodOptions).length > 0)
sessionParams.payment_method_options = paymentMethodOptions as CheckoutSessionCreateParams['payment_method_options']
if (input.currency)
sessionParams.currency = input.currency
const session = await stripe.checkout.sessions.create(sessionParams)
if (!session.url)
throw createServiceUnavailableError('Stripe checkout did not return a URL', 'STRIPE_CHECKOUT_URL_MISSING')
return {
providerOrderId: session.id,
url: session.url,
amount: session.amount_total ?? undefined,
currency: session.currency ?? undefined,
}
},
confirmed(native: unknown): ConfirmationFacts {
const session = native as Stripe.Checkout.Session
const providerCustomerId = typeof session.customer === 'string'
? session.customer
: session.customer?.id
const status = session.status === 'expired' ? 'expired' : 'paid'
return {
provider: 'stripe',
paymentOrderId: session.metadata?.payment_order_id || undefined,
providerOrderId: session.id,
status,
amount: session.amount_total ?? undefined,
currency: session.currency ?? undefined,
providerCustomerId,
providerData: {
sessionId: session.id,
customerId: providerCustomerId,
paymentIntentId: typeof session.payment_intent === 'string'
? session.payment_intent
: session.payment_intent?.id,
mode: session.mode,
paymentStatus: session.payment_status,
},
}
},
async cancel(input) {
if (!stripe)
return
try {
await stripe.checkout.sessions.expire(input.providerOrderId)
}
catch (error) {
logger.withError(error).withFields({ providerOrderId: input.providerOrderId }).warn('Stripe checkout expire skipped')
}
},
async getStatus() {
return null
},
}
}
/**
* Formats a Stripe smallest-unit amount into a display price string.
*
* @example
* formatPrice(300, 'usd') // => '$3.00'
* formatPrice(500, 'jpy') // => '¥500'
*/
function formatPrice(unitAmount: number | null, currency: string): string {
if (unitAmount == null)
return currency.toUpperCase()
try {
const formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency })
const fractionDigits = formatter.resolvedOptions().minimumFractionDigits ?? 2
const amount = unitAmount / (10 ** fractionDigits)
return formatter.format(amount)
}
catch {
return `${unitAmount / 100} ${currency.toUpperCase()}`
}
}
@@ -1,86 +1,34 @@
import type { Database } from '../../../libs/db'
import type { ConfigKVService } from '../../adapters/config-kv'
import type { BillingService } from '../billing/billing-service'
import type {
ApplyConfirmationResult,
ConfirmationFacts,
FluxPack,
FluxPackListItem,
PaymentProvider,
PaymentProviderName,
ProviderProductRef,
StartPackInput,
StartPackResult,
} from './types'
import type { ClaimReceipt, SettleResult } from './types'
import { useLogger } from '@guiiai/logg'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { and, eq, isNull } from 'drizzle-orm'
import { createBadRequestError, createInternalError, createServiceUnavailableError } from '../../../utils/error'
import { createInternalError } from '../../../utils/error'
import * as schema from '../../../schemas/payment'
export { createStripePaymentProvider } from './adapters/stripe'
export type { ApplyConfirmationResult, ConfirmationFacts, FluxPack, FluxPackListItem, PackStartContext, PaymentProvider, StartPackInput, StartPackResult } from './types'
export type { ClaimReceipt, FluxPack, SettleResult } from './types'
const logger = useLogger('payment')
const OPEN_CHECKOUT_CANCEL_STATUSES = ['pending'] as const
export interface PaymentServiceDeps {
db: Database
billing: BillingService
configKV: ConfigKVService
providers: Partial<Record<PaymentProviderName, PaymentProvider>>
}
/**
* Payment CORE: one-time pack checkout, claim, and account deletion.
* Payment CORE: pack grant and `payment_order` ownership.
*
* Call stack:
*
* Stripe `POST /checkout`
* -> {@link createPaymentService} `startPack`
* -> Provider `create`
*
* Stripe `POST /webhook` (after signature verify)
* -> Provider `confirmed`
* -> {@link createPaymentService} `applyConfirmation`
* -> channel maps session to {@link ClaimReceipt}
* -> {@link createPaymentService} `settle`
* -> {@link BillingService.creditFlux}
*/
export function createPaymentService(deps: PaymentServiceDeps) {
function requireProvider(provider: PaymentProviderName): PaymentProvider {
const adapter = deps.providers[provider]
if (!adapter)
throw createServiceUnavailableError('Payment provider is not configured', 'PAYMENT_PROVIDER_UNAVAILABLE', { provider })
return adapter
}
async function loadFluxPacks(): Promise<FluxPack[]> {
const packs = await deps.configKV.getOptional('FLUX_PACKS') ?? []
return packs.map(pack => ({
key: pack.key,
name: pack.name,
fluxAmount: pack.fluxAmount,
recommended: pack.recommended ?? false,
providers: pack.providers ?? {},
}))
}
async function getFluxPackByKey(packKey: string): Promise<FluxPack> {
const pack = (await loadFluxPacks()).find(item => item.key === packKey)
if (!pack)
throw createBadRequestError('Invalid pack', 'INVALID_PACKAGE', { packKey })
return pack
}
async function resolvePack(ref: ProviderProductRef): Promise<FluxPack | null> {
const packs = await loadFluxPacks()
if (ref.provider === 'stripe')
return packs.find(item => item.providers.stripe?.priceId === ref.providerProductId) ?? null
return null
}
async function upsertProviderAccount(
tx: Pick<Database, 'insert' | 'update' | 'select'>,
input: { userId: string, provider: string, providerCustomerId: string },
@@ -110,240 +58,125 @@ export function createPaymentService(deps: PaymentServiceDeps) {
})
}
return {
async listPacks(provider: PaymentProviderName): Promise<FluxPackListItem[]> {
const adapter = requireProvider(provider)
return adapter.listPackages(await loadFluxPacks())
},
resolvePack,
async getProviderAccount(input: { userId: string, provider: PaymentProviderName }) {
const row = await deps.db.query.providerAccount.findFirst({
where: and(
eq(schema.providerAccount.userId, input.userId),
eq(schema.providerAccount.provider, input.provider),
isNull(schema.providerAccount.deletedAt),
),
})
if (!row)
return null
return { providerCustomerId: row.providerCustomerId }
},
async startPack(input: StartPackInput): Promise<StartPackResult> {
const adapter = requireProvider(input.provider)
const pack = await getFluxPackByKey(input.packKey)
const [order] = await deps.db.insert(schema.paymentOrder).values({
userId: input.userId,
provider: input.provider,
status: 'pending',
packKey: pack.key,
fluxAmount: pack.fluxAmount,
currency: input.startContext.currency,
}).returning()
if (!order)
throw createInternalError('Failed to create payment order')
const account = await deps.db.query.providerAccount.findFirst({
where: and(
eq(schema.providerAccount.userId, input.userId),
eq(schema.providerAccount.provider, input.provider),
isNull(schema.providerAccount.deletedAt),
),
})
const created = await adapter.create({
paymentOrderId: order.id,
userId: input.userId,
pack,
currency: input.startContext.currency,
successUrl: input.startContext.successUrl,
cancelUrl: input.startContext.cancelUrl,
customerEmail: input.startContext.customerEmail,
providerCustomerId: account?.providerCustomerId ?? null,
metadata: input.startContext.metadata,
})
await deps.db.update(schema.paymentOrder)
.set({
providerOrderId: created.providerOrderId,
amount: created.amount,
currency: created.currency ?? input.startContext.currency,
updatedAt: new Date(),
})
.where(and(
eq(schema.paymentOrder.id, order.id),
isNull(schema.paymentOrder.providerOrderId),
))
return { kind: 'redirect', url: created.url, paymentOrderId: order.id }
},
async applyConfirmation(facts: ConfirmationFacts): Promise<ApplyConfirmationResult> {
if (!facts.paymentOrderId)
throw createInternalError('Payment confirmation is missing payment_order_id')
const paymentOrderId = facts.paymentOrderId
const result = await deps.db.transaction(async (tx) => {
const [order] = await tx
.select()
.from(schema.paymentOrder)
.where(eq(schema.paymentOrder.id, paymentOrderId))
.for('update')
if (!order)
throw createInternalError('Payment order not found')
switch (facts.status) {
case 'paid': {
if (order.status === 'paid')
return { applied: false as const }
if (order.status !== 'pending')
return { applied: false as const }
const fluxAmount = order.fluxAmount
if (fluxAmount == null || fluxAmount <= 0)
throw createInternalError('Payment order is missing flux_amount')
const [claimed] = await tx.update(schema.paymentOrder)
.set({
status: 'paid',
creditedAt: new Date(),
providerOrderId: facts.providerOrderId,
amount: facts.amount ?? order.amount,
currency: facts.currency ?? order.currency,
providerData: facts.providerData ?? order.providerData,
updatedAt: new Date(),
})
.where(and(
eq(schema.paymentOrder.id, order.id),
eq(schema.paymentOrder.status, 'pending'),
))
.returning()
if (!claimed)
return { applied: false as const }
const credit = await deps.billing.creditFlux({
userId: order.userId,
amount: fluxAmount,
requestId: order.id,
description: `Flux pack ${claimed.packKey ?? 'unknown'}`,
source: 'payment.pack',
tx,
})
if (facts.providerCustomerId) {
await upsertProviderAccount(tx, {
userId: order.userId,
provider: order.provider,
providerCustomerId: facts.providerCustomerId,
})
}
return {
applied: true as const,
userId: order.userId,
fluxAmount,
balanceAfter: credit.balanceAfter,
}
}
case 'canceled':
case 'expired': {
if (order.status !== 'pending')
return { applied: false as const }
await tx.update(schema.paymentOrder)
.set({
status: facts.status,
providerOrderId: facts.providerOrderId,
providerData: facts.providerData ?? order.providerData,
updatedAt: new Date(),
})
.where(and(
eq(schema.paymentOrder.id, order.id),
eq(schema.paymentOrder.status, 'pending'),
))
return { applied: false as const }
}
default: {
const exhaustive: never = facts.status
throw createInternalError(`Unhandled payment confirmation status: ${String(exhaustive)}`)
}
}
})
if (result.applied) {
await deps.billing.syncFluxCache(result.userId, result.balanceAfter, {
amount: result.fluxAmount,
source: 'payment.pack',
})
}
return result
},
async cancel(input: { paymentOrderId: string }) {
const [order] = await deps.db
async function claimExistingOrder(receipt: ClaimReceipt): Promise<SettleResult> {
const result = await deps.db.transaction(async (tx) => {
const [order] = await tx
.select()
.from(schema.paymentOrder)
.where(and(
eq(schema.paymentOrder.id, input.paymentOrderId),
isNull(schema.paymentOrder.deletedAt),
))
.limit(1)
.where(eq(schema.paymentOrder.id, receipt.paymentOrderId))
.for('update')
if (!order)
throw createBadRequestError('Payment order not found', 'PAYMENT_ORDER_NOT_FOUND')
throw createInternalError('Payment order not found')
if (order.status !== 'pending')
return
switch (receipt.status) {
case 'paid': {
if (order.status === 'paid')
return { applied: false as const }
if (order.providerOrderId) {
const adapter = requireProvider(order.provider as PaymentProviderName)
await adapter.cancel({ providerOrderId: order.providerOrderId })
if (order.status !== 'pending')
return { applied: false as const }
const fluxAmount = order.fluxAmount
if (fluxAmount == null || fluxAmount <= 0)
throw createInternalError('Payment order is missing flux_amount')
const [claimed] = await tx.update(schema.paymentOrder)
.set({
status: 'paid',
creditedAt: new Date(),
providerOrderId: receipt.providerOrderId,
amount: receipt.amount ?? order.amount,
currency: receipt.currency ?? order.currency,
providerData: receipt.extras ?? order.providerData,
updatedAt: new Date(),
})
.where(and(
eq(schema.paymentOrder.id, order.id),
eq(schema.paymentOrder.status, 'pending'),
))
.returning()
if (!claimed)
return { applied: false as const }
const credit = await deps.billing.creditFlux({
userId: order.userId,
amount: fluxAmount,
requestId: order.id,
description: `Flux pack ${claimed.packKey ?? 'unknown'}`,
source: 'payment.pack',
tx,
})
if (receipt.providerCustomerId) {
await upsertProviderAccount(tx, {
userId: order.userId,
provider: order.provider,
providerCustomerId: receipt.providerCustomerId,
})
}
return {
applied: true as const,
userId: order.userId,
fluxAmount,
balanceAfter: credit.balanceAfter,
}
}
case 'canceled':
case 'expired': {
if (order.status !== 'pending')
return { applied: false as const }
await tx.update(schema.paymentOrder)
.set({
status: receipt.status,
providerOrderId: receipt.providerOrderId,
providerData: receipt.extras ?? order.providerData,
updatedAt: new Date(),
})
.where(and(
eq(schema.paymentOrder.id, order.id),
eq(schema.paymentOrder.status, 'pending'),
))
return { applied: false as const }
}
default: {
const exhaustive: never = receipt.status
throw createInternalError(`Unhandled payment claim status: ${String(exhaustive)}`)
}
}
})
await deps.db.update(schema.paymentOrder)
.set({ status: 'canceled', updatedAt: new Date() })
.where(and(
eq(schema.paymentOrder.id, order.id),
eq(schema.paymentOrder.status, 'pending'),
))
if (result.applied) {
await deps.billing.syncFluxCache(result.userId, result.balanceAfter, {
amount: result.fluxAmount,
source: 'payment.pack',
})
}
return result
}
return {
async settle(receipt: ClaimReceipt): Promise<SettleResult> {
switch (receipt.kind) {
case 'claim':
return claimExistingOrder(receipt)
default: {
const exhaustive: never = receipt.kind
throw createInternalError(`Unhandled payment receipt kind: ${String(exhaustive)}`)
}
}
},
/**
* Cancels open provider objects, then soft-deletes orders and accounts.
* `flux_transaction` is not touched.
* Soft-deletes `payment_order` and `provider_account` rows.
* `flux_transaction` is not touched. Checkout sessions time out on Stripe.
*/
async deleteAllForUser(userId: string) {
const pending = await deps.db
.select({
id: schema.paymentOrder.id,
provider: schema.paymentOrder.provider,
providerOrderId: schema.paymentOrder.providerOrderId,
})
.from(schema.paymentOrder)
.where(and(
eq(schema.paymentOrder.userId, userId),
inArray(schema.paymentOrder.status, [...OPEN_CHECKOUT_CANCEL_STATUSES]),
isNull(schema.paymentOrder.deletedAt),
))
for (const order of pending) {
if (!order.providerOrderId)
continue
const adapter = deps.providers[order.provider as PaymentProviderName]
if (!adapter)
continue
await adapter.cancel({ providerOrderId: order.providerOrderId })
}
const now = new Date()
await deps.db.update(schema.paymentOrder)
@@ -360,7 +193,7 @@ export function createPaymentService(deps: PaymentServiceDeps) {
isNull(schema.providerAccount.deletedAt),
))
logger.withFields({ userId, cancelledOrders: pending.length }).log('Payment rows soft-deleted for user')
logger.withFields({ userId }).log('Payment rows soft-deleted for user')
},
}
}
@@ -1,6 +1,6 @@
import type { Database } from '../../../../libs/db'
import type { ConfigKVService } from '../../../adapters/config-kv'
import type { FluxPack, PaymentProvider, ProviderCreateInput } from '../types'
import type { ClaimReceipt } from '../types'
import { eq } from 'drizzle-orm'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -13,69 +13,20 @@ import { createPaymentService } from '../index'
import * as schema from '../../../../schemas'
const starterPack: FluxPack = {
key: 'starter',
name: '500 Flux',
fluxAmount: 500,
recommended: false,
providers: { stripe: { priceId: 'price_starter' } },
}
function createTestPaymentProvider(options?: {
onCreate?: (input: ProviderCreateInput) => Promise<void> | void
}): PaymentProvider {
function createPacksConfigKV(): ConfigKVService {
return {
async create(input) {
await options?.onCreate?.(input)
return {
providerOrderId: `cs_test_${input.paymentOrderId}`,
url: `https://checkout.stripe.test/${input.paymentOrderId}`,
}
},
async listPackages(packs) {
return packs.map(pack => ({
packKey: pack.key,
stripePriceId: pack.providers.stripe?.priceId,
label: pack.name,
defaultCurrency: 'usd',
currencies: { usd: '$5.00' },
recommended: pack.recommended,
}))
},
confirmed() {
throw new Error('test adapter does not map native payloads')
},
async cancel() {},
async getStatus() {
return null
},
}
}
function createPacksConfigKV(initial: FluxPack[]): ConfigKVService & { setPacks: (packs: FluxPack[]) => void } {
let packs = initial
return {
getOptional: vi.fn(async (key: string) => {
if (key === 'FLUX_PACKS')
return packs
return null
}),
getOptional: vi.fn(async () => null),
getOrThrow: vi.fn(),
get: vi.fn(),
refresh: vi.fn(),
invalidateCache: vi.fn(),
setPacks(next: FluxPack[]) {
packs = next
},
} as ConfigKVService & { setPacks: (packs: FluxPack[]) => void }
} as ConfigKVService
}
describe('payment CORE', () => {
let db: Database
let redis: ReturnType<typeof createTestRedis>
let configKV: ReturnType<typeof createPacksConfigKV>
let payment: ReturnType<typeof createPaymentService>
let applyDuringCreate: boolean
beforeAll(async () => {
db = await mockDB(schema)
@@ -88,34 +39,8 @@ describe('payment CORE', () => {
beforeEach(async () => {
redis = createTestRedis()
configKV = createPacksConfigKV([starterPack])
applyDuringCreate = false
const billing = createBillingService(db, redis, configKV)
let service: ReturnType<typeof createPaymentService>
const stripe = createTestPaymentProvider({
onCreate: async (input) => {
if (!applyDuringCreate)
return
await service.applyConfirmation({
provider: 'stripe',
paymentOrderId: input.paymentOrderId,
providerOrderId: `cs_test_${input.paymentOrderId}`,
status: 'paid',
amount: 500,
currency: 'usd',
providerCustomerId: 'cus_test',
})
},
})
service = createPaymentService({
db,
billing,
configKV,
providers: { stripe },
})
payment = service
const billing = createBillingService(db, redis, createPacksConfigKV())
payment = createPaymentService({ db, billing })
await db.delete(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, 'user-pay-1'))
await db.delete(schema.userFlux).where(eq(schema.userFlux.userId, 'user-pay-1'))
@@ -123,34 +48,35 @@ describe('payment CORE', () => {
await db.delete(schema.providerAccount).where(eq(schema.providerAccount.userId, 'user-pay-1'))
})
async function startStarterPack() {
return payment.startPack({
async function insertPendingOrder() {
const [order] = await db.insert(schema.paymentOrder).values({
userId: 'user-pay-1',
provider: 'stripe',
status: 'pending',
packKey: 'starter',
startContext: {
currency: 'usd',
successUrl: 'https://example.test/success',
cancelUrl: 'https://example.test/cancel',
customerEmail: 'pay@example.com',
},
})
fluxAmount: 500,
currency: 'usd',
}).returning()
return order!
}
it('startPack snapshots the pack and applyConfirmation credits Flux', async () => {
const started = await startStarterPack()
expect(started.kind).toBe('redirect')
expect(started.url).toContain('checkout.stripe.test')
const result = await payment.applyConfirmation({
function paidReceipt(paymentOrderId: string, overrides: Partial<ClaimReceipt> = {}): ClaimReceipt {
return {
kind: 'claim',
provider: 'stripe',
paymentOrderId: started.paymentOrderId,
providerOrderId: `cs_test_${started.paymentOrderId}`,
paymentOrderId,
providerOrderId: `cs_test_${paymentOrderId}`,
status: 'paid',
amount: 500,
currency: 'usd',
providerCustomerId: 'cus_test',
})
...overrides,
}
}
it('settle credits Flux from the pending order snapshot', async () => {
const order = await insertPendingOrder()
const result = await payment.settle(paidReceipt(order.id))
expect(result).toMatchObject({ applied: true, fluxAmount: 500, balanceAfter: 500 })
@@ -159,49 +85,24 @@ describe('payment CORE', () => {
const [ledger] = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, 'user-pay-1'))
expect(ledger?.amount).toBe(500)
expect(ledger?.requestId).toBe(started.paymentOrderId)
expect(ledger?.requestId).toBe(order.id)
const [order] = await db.select().from(schema.paymentOrder).where(eq(schema.paymentOrder.id, started.paymentOrderId))
expect(order?.status).toBe('paid')
expect(order?.creditedAt).toBeInstanceOf(Date)
expect(order?.packKey).toBe('starter')
expect(order?.fluxAmount).toBe(500)
const [paid] = await db.select().from(schema.paymentOrder).where(eq(schema.paymentOrder.id, order.id))
expect(paid?.status).toBe('paid')
expect(paid?.creditedAt).toBeInstanceOf(Date)
expect(paid?.packKey).toBe('starter')
expect(paid?.fluxAmount).toBe(500)
expect(paid?.providerOrderId).toBe(`cs_test_${order.id}`)
expect(await redis.get(userFluxRedisKey('user-pay-1'))).toBe('500')
})
it('listPacks returns platform price items through the provider', async () => {
const items = await payment.listPacks('stripe')
expect(items).toEqual([{
packKey: 'starter',
stripePriceId: 'price_starter',
label: '500 Flux',
defaultCurrency: 'usd',
currencies: { usd: '$5.00' },
recommended: false,
}])
})
it('settle replay returns applied false and does not double credit', async () => {
const order = await insertPendingOrder()
const receipt = paidReceipt(order.id)
it('resolvePack finds a pack by Stripe price id', async () => {
await expect(payment.resolvePack({ provider: 'stripe', providerProductId: 'price_starter' }))
.resolves
.toMatchObject({ key: 'starter', fluxAmount: 500 })
await expect(payment.resolvePack({ provider: 'stripe', providerProductId: 'price_unknown' }))
.resolves
.toBeNull()
})
it('applyConfirmation replay returns applied false and does not double credit', async () => {
const started = await startStarterPack()
const facts = {
provider: 'stripe' as const,
paymentOrderId: started.paymentOrderId,
providerOrderId: `cs_test_${started.paymentOrderId}`,
status: 'paid' as const,
}
const first = await payment.applyConfirmation(facts)
const second = await payment.applyConfirmation(facts)
const first = await payment.settle(receipt)
const second = await payment.settle(receipt)
expect(first.applied).toBe(true)
expect(second.applied).toBe(false)
@@ -213,16 +114,10 @@ describe('payment CORE', () => {
expect(flux?.flux).toBe(500)
})
it('credits the snapshot when FLUX_PACKS changes after startPack', async () => {
const started = await startStarterPack()
configKV.setPacks([{ ...starterPack, fluxAmount: 9999 }])
it('credits the snapshot on the pending row when the catalog amount differs', async () => {
const order = await insertPendingOrder()
const result = await payment.applyConfirmation({
provider: 'stripe',
paymentOrderId: started.paymentOrderId,
providerOrderId: `cs_test_${started.paymentOrderId}`,
status: 'paid',
})
const result = await payment.settle(paidReceipt(order.id))
expect(result).toMatchObject({ applied: true, fluxAmount: 500 })
@@ -230,28 +125,63 @@ describe('payment CORE', () => {
expect(flux?.flux).toBe(500)
})
it('accepts webhook-before-checkout when the order exists and create has not returned', async () => {
applyDuringCreate = true
const started = await startStarterPack()
expect(started.kind).toBe('redirect')
const [flux] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-pay-1'))
expect(flux?.flux).toBe(500)
const [order] = await db.select().from(schema.paymentOrder).where(eq(schema.paymentOrder.id, started.paymentOrderId))
expect(order?.status).toBe('paid')
expect(order?.providerOrderId).toBe(`cs_test_${started.paymentOrderId}`)
})
it('throws when applyConfirmation runs before the order exists so the channel can retry', async () => {
await expect(payment.applyConfirmation({
provider: 'stripe',
paymentOrderId: 'missing-order',
providerOrderId: 'cs_test_missing',
status: 'paid',
})).rejects.toMatchObject({
it('throws when settle runs before the order exists so the channel can retry', async () => {
await expect(payment.settle(paidReceipt('missing-order'))).rejects.toMatchObject({
statusCode: 500,
})
})
it('marks a pending order canceled without crediting Flux', async () => {
const order = await insertPendingOrder()
const result = await payment.settle({
kind: 'claim',
provider: 'stripe',
paymentOrderId: order.id,
providerOrderId: `cs_test_${order.id}`,
status: 'canceled',
})
expect(result).toEqual({ applied: false })
const [updated] = await db.select().from(schema.paymentOrder).where(eq(schema.paymentOrder.id, order.id))
expect(updated?.status).toBe('canceled')
const ledger = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, 'user-pay-1'))
expect(ledger).toHaveLength(0)
})
it('marks a pending order expired without crediting Flux', async () => {
const order = await insertPendingOrder()
const result = await payment.settle({
kind: 'claim',
provider: 'stripe',
paymentOrderId: order.id,
providerOrderId: `cs_test_${order.id}`,
status: 'expired',
})
expect(result).toEqual({ applied: false })
const [updated] = await db.select().from(schema.paymentOrder).where(eq(schema.paymentOrder.id, order.id))
expect(updated?.status).toBe('expired')
})
it('deleteAllForUser soft-deletes orders and accounts', async () => {
const order = await insertPendingOrder()
await db.insert(schema.providerAccount).values({
userId: 'user-pay-1',
provider: 'stripe',
providerCustomerId: 'cus_test',
})
await payment.deleteAllForUser('user-pay-1')
const [deletedOrder] = await db.select().from(schema.paymentOrder).where(eq(schema.paymentOrder.id, order.id))
expect(deletedOrder?.deletedAt).toBeInstanceOf(Date)
const [deletedAccount] = await db.select().from(schema.providerAccount).where(eq(schema.providerAccount.userId, 'user-pay-1'))
expect(deletedAccount?.deletedAt).toBeInstanceOf(Date)
})
})
@@ -6,19 +6,12 @@ export const PAYMENT_ORDER_STATUSES = ['pending', 'paid', 'canceled', 'expired']
export type PaymentOrderStatus = typeof PAYMENT_ORDER_STATUSES[number]
export const CONFIRMATION_STATUSES = ['paid', 'canceled', 'expired'] as const
export type ConfirmationStatus = typeof CONFIRMATION_STATUSES[number]
export type ClaimStatus = 'paid' | 'canceled' | 'expired'
export interface CatalogProviderIds {
stripe?: { priceId: string }
}
export interface ProviderProductRef {
provider: PaymentProviderName
providerProductId: string | number
}
export interface FluxPack {
key: string
name: string
@@ -27,80 +20,24 @@ export interface FluxPack {
providers: CatalogProviderIds
}
export interface FluxPackListItem {
packKey: string
stripePriceId?: string
label: string
defaultCurrency: string
currencies: Record<string, string>
recommended: boolean
}
export interface PackStartContext {
currency?: string
successUrl: string
cancelUrl: string
customerEmail?: string
metadata?: Record<string, string>
}
export interface StartPackInput {
userId: string
provider: PaymentProviderName
packKey: string
startContext: PackStartContext
}
export interface StartPackResult {
kind: 'redirect'
url: string
/**
* Stripe webhook claim for a pending `payment_order`.
*
* The channel maps a verified Checkout Session onto this receipt.
* CORE claims by `paymentOrderId`.
*/
export interface ClaimReceipt {
kind: 'claim'
provider: 'stripe'
paymentOrderId: string
}
export interface ConfirmationFacts {
provider: PaymentProviderName
paymentOrderId?: string
providerOrderId: string
status: ConfirmationStatus
status: ClaimStatus
amount?: number
currency?: string
providerCustomerId?: string
providerData?: Record<string, unknown>
extras?: Record<string, unknown>
}
export type ApplyConfirmationResult
export type SettleResult
= | { applied: true, userId: string, fluxAmount: number, balanceAfter: number }
| { applied: false }
export interface ProviderCreateInput {
paymentOrderId: string
userId: string
pack: FluxPack
currency?: string
successUrl: string
cancelUrl: string
customerEmail?: string
providerCustomerId?: string | null
metadata?: Record<string, string>
}
export interface ProviderCreateResult {
providerOrderId: string
url: string
amount?: number
currency?: string
}
/**
* Internal Provider seam. Stripe satisfies this.
*
* Channel routes call {@link PaymentProvider.confirmed} after they verify
* the native payload. CORE calls {@link PaymentProvider.create}.
*/
export interface PaymentProvider {
create: (input: ProviderCreateInput) => Promise<ProviderCreateResult>
listPackages: (packs: FluxPack[]) => Promise<FluxPackListItem[]>
confirmed: (native: unknown) => ConfirmationFacts
cancel: (input: { providerOrderId: string }) => Promise<void>
getStatus: (input: { providerOrderId: string }) => Promise<PaymentOrderStatus | null>
}