mirror of
https://github.com/moeru-ai/airi.git
synced 2026-09-21 05:20:03 +08:00
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:
+14
-11
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user