From dd446c94189a661ddb624881a50f13a18c7ae659 Mon Sep 17 00:00:00 2001 From: Lulu Date: Thu, 20 Aug 2026 00:30:11 +0800 Subject: [PATCH] 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 --- server/apps/api/README.md | 25 +- server/apps/api/src/app.test.ts | 1 - server/apps/api/src/app.ts | 27 +- server/apps/api/src/routes/stripe/catalog.ts | 99 +++++ .../api/src/routes/stripe/checkout.test.ts | 223 ++++++++++ server/apps/api/src/routes/stripe/claim.ts | 40 ++ server/apps/api/src/routes/stripe/index.ts | 39 +- .../src/routes/stripe/operations/checkout.ts | 173 ++++++-- .../src/routes/stripe/operations/webhook.ts | 30 +- .../apps/api/src/routes/stripe/route.test.ts | 182 +++----- .../domain/payment/adapters/stripe.ts | 178 -------- .../api/src/services/domain/payment/index.ts | 395 +++++------------- .../domain/payment/tests/payment.test.ts | 262 +++++------- .../api/src/services/domain/payment/types.ts | 89 +--- 14 files changed, 828 insertions(+), 935 deletions(-) create mode 100644 server/apps/api/src/routes/stripe/catalog.ts create mode 100644 server/apps/api/src/routes/stripe/checkout.test.ts create mode 100644 server/apps/api/src/routes/stripe/claim.ts delete mode 100644 server/apps/api/src/services/domain/payment/adapters/stripe.ts diff --git a/server/apps/api/README.md b/server/apps/api/README.md index b5cb89261..3d41cd107 100644 --- a/server/apps/api/README.md +++ b/server/apps/api/README.md @@ -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 diff --git a/server/apps/api/src/app.test.ts b/server/apps/api/src/app.test.ts index 5d9c89d30..fe3a24c9e 100644 --- a/server/apps/api/src/app.test.ts +++ b/server/apps/api/src/app.test.ts @@ -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, diff --git a/server/apps/api/src/app.ts b/server/apps/api/src/app.ts index 5bc80a40e..9462ca1ff 100644 --- a/server/apps/api/src/app.ts +++ b/server/apps/api/src/app.ts @@ -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, diff --git a/server/apps/api/src/routes/stripe/catalog.ts b/server/apps/api/src/routes/stripe/catalog.ts new file mode 100644 index 000000000..57a68a5ef --- /dev/null +++ b/server/apps/api/src/routes/stripe/catalog.ts @@ -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 + recommended: boolean +} + +export async function loadFluxPacks(configKV: ConfigKVService): Promise { + 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 { + 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 = {} + 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()}` + } +} diff --git a/server/apps/api/src/routes/stripe/checkout.test.ts b/server/apps/api/src/routes/stripe/checkout.test.ts new file mode 100644 index 000000000..f462fdd4f --- /dev/null +++ b/server/apps/api/src/routes/stripe/checkout.test.ts @@ -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 + + 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 }) => { + 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 }) => { + 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', + }), + })) + }) +}) diff --git a/server/apps/api/src/routes/stripe/claim.ts b/server/apps/api/src/routes/stripe/claim.ts new file mode 100644 index 000000000..f597c5718 --- /dev/null +++ b/server/apps/api/src/routes/stripe/claim.ts @@ -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, + }, + } +} diff --git a/server/apps/api/src/routes/stripe/index.ts b/server/apps/api/src/routes/stripe/index.ts index 8e8a408f2..e1b441a34 100644 --- a/server/apps/api/src/routes/stripe/index.ts +++ b/server/apps/api/src/routes/stripe/index.ts @@ -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() .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() : '' diff --git a/server/apps/api/src/routes/stripe/operations/checkout.ts b/server/apps/api/src/routes/stripe/operations/checkout.ts index 1a7fef9f9..36e8fd1df 100644 --- a/server/apps/api/src/routes/stripe/operations/checkout.ts +++ b/server/apps/api/src/routes/stripe/operations/checkout.ts @@ -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 +type CheckoutSessionCreateParams = NonNullable[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 + }, +): 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, } } diff --git a/server/apps/api/src/routes/stripe/operations/webhook.ts b/server/apps/api/src/routes/stripe/operations/webhook.ts index 58dd7018f..2601520f4 100644 --- a/server/apps/api/src/routes/stripe/operations/webhook.ts +++ b/server/apps/api/src/routes/stripe/operations/webhook.ts @@ -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: diff --git a/server/apps/api/src/routes/stripe/route.test.ts b/server/apps/api/src/routes/stripe/route.test.ts index c77d0d9c2..84329d223 100644 --- a/server/apps/api/src/routes/stripe/route.test.ts +++ b/server/apps/api/src/routes/stripe/route.test.ts @@ -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 { 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 { 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 = {}, - 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() @@ -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() }) }) }) diff --git a/server/apps/api/src/services/domain/payment/adapters/stripe.ts b/server/apps/api/src/services/domain/payment/adapters/stripe.ts deleted file mode 100644 index d7423d689..000000000 --- a/server/apps/api/src/services/domain/payment/adapters/stripe.ts +++ /dev/null @@ -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[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 { - 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 = {} - 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 { - 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()}` - } -} diff --git a/server/apps/api/src/services/domain/payment/index.ts b/server/apps/api/src/services/domain/payment/index.ts index 5fbc970ba..531248617 100644 --- a/server/apps/api/src/services/domain/payment/index.ts +++ b/server/apps/api/src/services/domain/payment/index.ts @@ -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> } /** - * 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 { - 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 { - 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 { - 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, input: { userId: string, provider: string, providerCustomerId: string }, @@ -110,240 +58,125 @@ export function createPaymentService(deps: PaymentServiceDeps) { }) } - return { - async listPacks(provider: PaymentProviderName): Promise { - 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 { - 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 { - 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 { + 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 { + 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') }, } } diff --git a/server/apps/api/src/services/domain/payment/tests/payment.test.ts b/server/apps/api/src/services/domain/payment/tests/payment.test.ts index 6fc9ce305..cc42e3d6b 100644 --- a/server/apps/api/src/services/domain/payment/tests/payment.test.ts +++ b/server/apps/api/src/services/domain/payment/tests/payment.test.ts @@ -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 -}): 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 - let configKV: ReturnType let payment: ReturnType - 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 - 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 { + 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) + }) }) diff --git a/server/apps/api/src/services/domain/payment/types.ts b/server/apps/api/src/services/domain/payment/types.ts index 1365cf3a8..3836612d7 100644 --- a/server/apps/api/src/services/domain/payment/types.ts +++ b/server/apps/api/src/services/domain/payment/types.ts @@ -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 - recommended: boolean -} - -export interface PackStartContext { - currency?: string - successUrl: string - cancelUrl: string - customerEmail?: string - metadata?: Record -} - -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 + extras?: Record } -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 -} - -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 - listPackages: (packs: FluxPack[]) => Promise - confirmed: (native: unknown) => ConfirmationFacts - cancel: (input: { providerOrderId: string }) => Promise - getStatus: (input: { providerOrderId: string }) => Promise -}