refactor(api): export loadFluxPacks from payment core and deduplicate in stripe

Centralize Flux pack catalog reading in payment CORE and update Stripe
routes and checkout operations to import it, removing the duplicate copy
in price-catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lulu
2026-08-22 12:25:20 +08:00
parent 67a8d74bdd
commit 93045690df
5 changed files with 36 additions and 48 deletions
+16 -25
View File
@@ -24,8 +24,8 @@ stays on `/api/v1/stripe/*`. CORE never sees a raw provider event.
- `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.
- The Stripe channel reads `FLUX_PACKS` through payment CORE 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
@@ -49,30 +49,21 @@ pnpm dev:backend
For source-level debugging, start `@proj-airi/api-server` and
`@proj-airi/auth-server` separately instead.
`server/docker-compose.yaml` exposes the local Caddy gateway at `http://localhost:6112` and keeps
the API and Auth container ports private.
- `@proj-airi/api-server` (this package): listens on `PORT=3000` (local `https://localhost:3000` or via Caddy edge at `https://dev.airi.moeru.ai/api/v1`).
- `@proj-airi/auth-server`: listens on `PORT=3001` (local `https://localhost:3001` or via Caddy edge at `https://dev.airi.moeru.ai/api/auth`).
- `server/dev/caddy`: terminates HTTPS on `dev.airi.moeru.ai` with local mkcert certificates, routing `/api/auth/*` to auth and everything else to api.
- `server/docker-compose.yaml`: starts Postgres and Redis.
- `pnpm dev:backend` at the repo root starts Caddy and the containers, then runs both servers under `dotenvx` with `.env.local`.
## Service boundaries
## Configuration
- `AUTH_SERVER_URL` is Auth's canonical public issuer origin used for JWKS,
issuer, and audience validation. It must exactly equal Auth's `PUBLIC_URL`.
- `/internal/auth/*` is reachable only on the deployment's trusted private
network. The public edge must reject `/internal/*` and the API service must
not have its own public ingress.
- `AUTH_SERVER_INTERNAL_URL` optionally sends JWKS fetches directly to Auth on
the private network while issuer and audience remain `AUTH_SERVER_URL`.
- Auth tables and principal types come from `@proj-airi/auth-shared`; no module
under `server/apps/auth` is imported.
Environment variables are validated with Valibot in `src/libs/env.ts`.
## Railway
Key variables:
Deploy this as the Resource API Railway service with Config File Path
`/server/apps/api/railway.toml`; keep the service Root Directory at the
repository root because the Dockerfile copies shared workspace packages. The
config owns its Dockerfile, start command, `/readyz` healthcheck, and the
watch patterns for every copied build input.
Set `AUTH_SERVER_INTERNAL_URL` from Auth's Railway private domain. It is only
the private JWKS route; `AUTH_SERVER_URL` remains the public Auth issuer URL.
See [`server/README.md`](../../README.md#railway-deployment) for the complete
cross-service variable and migration contract.
- `DATABASE_URL`: PostgreSQL connection string.
- `REDIS_URL`: Redis connection string.
- `AUTH_JWKS_URL`: URL to fetch the Auth service's public JWKS for OIDC JWT verification (defaults to `http://127.0.0.1:3001/api/auth/jwks`).
- `AUTH_ISSUER`: Expected `iss` claim on incoming JWTs (defaults to `http://127.0.0.1:3001/api/auth`).
- `PORT`: HTTP port (defaults to 3000).
- `HOST`: Bind host (defaults to `0.0.0.0`).
+2 -1
View File
@@ -13,9 +13,10 @@ import { Hono } from 'hono'
import { authGuard } from '../../middlewares/auth'
import { rateLimiter } from '../../middlewares/rate-limit'
import { loadFluxPacks } from '../../services/domain/payment'
import { createCheckoutOperation } from './operations/checkout'
import { createWebhookOperation } from './operations/webhook'
import { listStripePackages, loadFluxPacks } from './price-catalog'
import { listStripePackages } from './price-catalog'
/**
* Creates Stripe HTTP routes for Flux purchase.
@@ -10,9 +10,9 @@ import type { ProductEventService } from '../../../services/domain/product-event
import { and, eq, isNull } from 'drizzle-orm'
import { safeParse } from 'valibot'
import { loadFluxPacks } from '../../../services/domain/payment'
import { createBadRequestError, createInternalError, createServiceUnavailableError } from '../../../utils/error'
import { resolveCheckoutRedirectBase } from '../../../utils/origin'
import { loadFluxPacks } from '../price-catalog'
import { CheckoutBodySchema } from '../schema'
import * as schema from '../../../schemas/payment'
@@ -1,7 +1,6 @@
import type Redis from 'ioredis'
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'
@@ -23,17 +22,6 @@ export interface StripePackListItem {
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 async function listStripePackages(
stripe: Stripe | null,
redis: Redis,
@@ -1,6 +1,7 @@
import type { Database } from '../../../libs/db'
import type { ConfigKVService } from '../../adapters/config-kv'
import type { BillingService } from '../billing/billing-service'
import type { ClaimReceipt, SettleResult } from './types'
import type { ClaimReceipt, FluxPack, SettleResult } from './types'
import { useLogger } from '@guiiai/logg'
import { and, eq, isNull } from 'drizzle-orm'
@@ -13,6 +14,20 @@ export type { ClaimReceipt, FluxPack, SettleResult } from './types'
const logger = useLogger('payment')
/**
* Loads the validated Flux pack catalog from ConfigKV.
*/
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 ?? {},
}))
}
/**
* Payment CORE: pack grant and `payment_order` ownership.
*
@@ -155,14 +170,7 @@ export function createPaymentService(db: Database, billing: BillingService) {
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)}`)
}
}
return claimExistingOrder(receipt)
},
/**