refactor(api): extract payment CORE to support other payment providers [1/2] (#2335)

## Why

Stripe checkout used Stripe-only tables. This extracts a shared payment
CORE. New checkout writes `payment_order`. Old Stripe tables stay so
in-progress Sessions can still settle.

This PR is [1/2]. [#2368](https://github.com/moeru-ai/airi/pull/2368) is
[2/2]. That PR archives leftover Stripe tables after in-progress
Sessions finish or expire.

## Changes

- Add `payment_order` and `provider_account`.
- Copy `stripe_checkout_session` into `payment_order`.
- Copy `stripe_customer` into `provider_account`.
- Keep `stripe_*` tables and `user_flux.stripe_customer_id`.
- New checkout writes `payment_order` and stores
`metadata.payment_order_id`.
- Webhook resolves new Sessions by `metadata.payment_order_id`.
- Webhook resolves older Sessions by `provider_order_id`, then by a
leftover `stripe_checkout_session` row.
- That leftover-row lookup covers Sessions opened before this deploy,
and rows written while 0023 runs.
[#2368](https://github.com/moeru-ai/airi/pull/2368) deletes it after
those Sessions finish or expire.

## Test plan

- [x] `pnpm exec vitest run
server/apps/api/src/services/domain/payment/tests/payment.test.ts
server/apps/api/src/routes/stripe`
- [x] `pnpm -F @proj-airi/api-server typecheck`
- [x] `git diff --check`

## Visual changes

No user-visible changes.

## Open: settle after account deletion

Account deletion stamps `payment_order.deletedAt`. A Checkout Session
that is still open can still pay after that stamp. This PR keeps `main`
behavior. `settle` loads the row by id, including a soft-deleted row,
then marks it `paid` and credits Flux.

Follow-up policy: if `deletedAt` is set, skip. Do not update the archive
row. Do not credit Flux. Do not insert a live `provider_account`. Stripe
remains the payment record. Skip matches the soft-delete rule: a deleted
row is gone, not write it again.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: RainbowBird <git@luoling.moe>
This commit is contained in:
Lulu
2026-09-12 17:43:10 +08:00
committed by GitHub
co-authored by Cursor RainbowBird
parent e447b15b0d
commit 37c837e502
37 changed files with 5843 additions and 2289 deletions
@@ -36,14 +36,14 @@ if (isStageTamagotchi())
useEventListener(window, 'focus', () => authStore.updateCredits())
interface FluxPackage {
stripePriceId: string
packKey: string
label: string
defaultCurrency: string
currencies: Record<string, string>
recommended?: boolean
}
const loadingPriceId = ref<string | null>(null)
const loadingPackKey = ref<string | null>(null)
const message = ref<{ type: 'success' | 'error', text: string } | null>(null)
const checkoutReturnMessageActive = ref(false)
const packages = ref<FluxPackage[]>([])
@@ -304,8 +304,8 @@ onMounted(async () => {
}
})
async function handleBuy(stripePriceId: string) {
loadingPriceId.value = stripePriceId
async function handleBuy(packKey: string) {
loadingPackKey.value = packKey
checkoutReturnMessageActive.value = false
message.value = null
// OpenPanel funnel step 2: user picked a plan. price_minor_unit lives on
@@ -317,12 +317,12 @@ async function handleBuy(stripePriceId: string) {
current_plan: 'flux',
trigger: 'manual_topup',
})
trackPlanSelected(stripePriceId, {
trackPlanSelected(packKey, {
currency: selectedCurrency.value,
entry_surface: 'settings_flux',
})
try {
const res = await client.api.v1.stripe.checkout.$post({ json: { stripePriceId, currency: selectedCurrency.value } })
const res = await client.api.v1.stripe.checkout.$post({ json: { packKey, currency: selectedCurrency.value } })
if (!res.ok) {
const data = await res.json() as { error?: string, message?: string }
message.value = { type: 'error', text: data.message || t('settings.pages.flux.checkout.error') }
@@ -332,7 +332,7 @@ async function handleBuy(stripePriceId: string) {
if (data.url) {
// Start capture before redirecting to Stripe so fetch keepalive can
// finish delivery after the page unloads.
trackCheckoutStarted(stripePriceId, {
trackCheckoutStarted(packKey, {
currency: selectedCurrency.value,
entry_surface: 'settings_flux',
})
@@ -350,7 +350,7 @@ async function handleBuy(stripePriceId: string) {
message.value = { type: 'error', text: t('settings.pages.flux.checkout.error') }
}
finally {
loadingPriceId.value = null
loadingPackKey.value = null
}
}
</script>
@@ -401,8 +401,8 @@ async function handleBuy(stripePriceId: string) {
<div grid="~ cols-1 sm:cols-3 gap-4">
<button
v-for="(pkg, index) in packages" :key="pkg.stripePriceId"
:disabled="loadingPriceId !== null"
v-for="(pkg, index) in packages" :key="pkg.packKey"
:disabled="loadingPackKey !== null"
:class="[
'group relative flex flex-row sm:flex-col items-center justify-between sm:justify-center overflow-hidden text-left sm:text-center gap-4 sm:gap-2',
'rounded-2xl border-2 bg-white p-6 transition-all duration-300 ease-out',
@@ -410,9 +410,9 @@ async function handleBuy(stripePriceId: string) {
'dark:bg-neutral-900',
'hover:-translate-y-1 hover:border-primary-400 hover:shadow-md dark:hover:border-primary-500',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
loadingPriceId !== null && loadingPriceId !== pkg.stripePriceId ? 'opacity-50 grayscale-50 cursor-not-allowed' : 'cursor-pointer',
loadingPackKey !== null && loadingPackKey !== pkg.packKey ? 'opacity-50 grayscale-50 cursor-not-allowed' : 'cursor-pointer',
]"
@click="handleBuy(pkg.stripePriceId)"
@click="handleBuy(pkg.packKey)"
>
<!-- Recommended Badge -->
<div
@@ -425,7 +425,7 @@ async function handleBuy(stripePriceId: string) {
<!-- Loading Overlay -->
<div
v-if="loadingPriceId === pkg.stripePriceId"
v-if="loadingPackKey === pkg.packKey"
class="absolute inset-0 z-10 flex items-center justify-center bg-white/60 backdrop-blur-sm dark:bg-neutral-900/60"
>
<div class="i-svg-spinners:90-ring-with-bg size-8 text-primary-500" />
+9
View File
@@ -12,6 +12,15 @@ auth/OIDC routes.
- Redis cache, configuration KV, and cross-instance Pub/Sub.
- Local verification of Auth-issued OIDC JWTs through public JWKS.
## Payment
`src/services/domain/payment` owns pack grant and `payment_order` rows.
CORE exposes `openPending`, `bindProcessorOrder`, `abandon`, `settle`,
and `deleteAllForUser`. Checkout and package list live in the Stripe
adapter on `/api/v1/stripe/*`. CORE never sees a raw processor event.
The adapter maps the processor result onto a `ClaimReceipt`, then calls
`settle`.
## Run locally
```sh
@@ -0,0 +1,113 @@
CREATE TABLE "payment_order" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"processor" text NOT NULL,
"processor_order_id" text,
"status" text NOT NULL,
"amount" integer,
"currency" text,
"pack_key" text,
"flux_amount" bigint,
"credited_at" timestamp,
"processor_data" jsonb,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"deleted_at" timestamp
);
--> statement-breakpoint
CREATE TABLE "payment_customer" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"processor" text NOT NULL,
"customer_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"deleted_at" timestamp
);
--> statement-breakpoint
CREATE UNIQUE INDEX "payment_order_processor_order_uidx" ON "payment_order" USING btree ("processor","processor_order_id") WHERE processor_order_id IS NOT NULL;--> statement-breakpoint
CREATE INDEX "payment_order_user_id_idx" ON "payment_order" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "payment_customer_processor_customer_uidx" ON "payment_customer" USING btree ("processor","customer_id") WHERE deleted_at IS NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "payment_customer_processor_user_uidx" ON "payment_customer" USING btree ("processor","user_id") WHERE deleted_at IS NULL;--> statement-breakpoint
CREATE INDEX "payment_customer_user_id_idx" ON "payment_customer" USING btree ("user_id");--> statement-breakpoint
-- Keep stripe_* tables and user_flux.stripe_customer_id.
-- The previous process still uses them while this migration runs.
-- stripe_customer allowed several live rows per user. Copy the oldest live row and all deleted rows.
INSERT INTO "payment_customer" ("id", "user_id", "processor", "customer_id", "created_at", "updated_at", "deleted_at")
SELECT "id", "user_id", 'stripe', "stripe_customer_id", "created_at", "updated_at", "deleted_at"
FROM "stripe_customer"
WHERE "deleted_at" IS NOT NULL
UNION ALL
SELECT "id", "user_id", 'stripe', "stripe_customer_id", "created_at", "updated_at", "deleted_at"
FROM (
SELECT DISTINCT ON ("user_id") *
FROM "stripe_customer"
WHERE "deleted_at" IS NULL
ORDER BY "user_id", "created_at" ASC, "id" ASC
) live;--> statement-breakpoint
-- Copy checkout sessions so webhook retries can find them by Stripe session id.
-- flux_credited rows become paid so settle does not credit Flux again.
-- Old ledger request_id is the Stripe event id, not payment_order.id.
INSERT INTO "payment_order" (
"id",
"user_id",
"processor",
"processor_order_id",
"status",
"amount",
"currency",
"pack_key",
"flux_amount",
"credited_at",
"processor_data",
"created_at",
"updated_at",
"deleted_at"
)
SELECT
"id",
"user_id",
'stripe',
"stripe_session_id",
CASE
WHEN "flux_credited" THEN 'paid'
WHEN "status" = 'expired' THEN 'expired'
ELSE 'pending'
END,
"amount_total",
"currency",
CASE
WHEN "metadata" IS NOT NULL AND btrim("metadata") LIKE '{%' THEN "metadata"::jsonb->>'packKey'
ELSE NULL
END,
CASE
WHEN "metadata" IS NOT NULL AND btrim("metadata") LIKE '{%' AND ("metadata"::jsonb->>'fluxAmount') ~ '^-?[0-9]+$'
THEN ("metadata"::jsonb->>'fluxAmount')::bigint
ELSE NULL
END,
CASE
WHEN "flux_credited" THEN "updated_at"
ELSE NULL
END,
jsonb_strip_nulls(jsonb_build_object(
'stripeSessionId', "stripe_session_id",
'stripeCustomerId', "stripe_customer_id",
'mode', "mode",
'status', "status",
'paymentStatus', "payment_status",
'successUrl', "success_url",
'cancelUrl', "cancel_url",
'stripePaymentIntentId', "stripe_payment_intent_id",
'stripeSubscriptionId', "stripe_subscription_id",
'expiresAt', "expires_at",
'fluxCredited', "flux_credited",
'metadata', CASE
WHEN "metadata" IS NOT NULL AND btrim("metadata") LIKE '{%' THEN "metadata"::jsonb
WHEN "metadata" IS NOT NULL THEN to_jsonb("metadata")
ELSE NULL
END
)),
"created_at",
"updated_at",
"deleted_at"
FROM "stripe_checkout_session";
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -162,6 +162,13 @@
"when": 1786957404391,
"tag": "0022_worthless_shriek",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1789035949919,
"tag": "0023_payment_order",
"breakpoints": true
}
]
}
}
+2 -1
View File
@@ -20,7 +20,8 @@ function createTestDeps() {
providerService: {} as never,
fluxService: {} as never,
fluxTransactionService: {} as never,
stripeService: {} as never,
paymentService: {} as never,
stripe: null,
billingService: {} as never,
ttsMeter: {} as never,
requestLogService: {} as never,
+50 -35
View File
@@ -10,11 +10,11 @@ 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 { 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'
import type { RequestLogService } from './services/domain/request-log'
import type { StripeService } from './services/domain/stripe'
import type { UserDeletionService } from './services/domain/user-deletion'
import type { VoicePackService } from './services/domain/voice-packs'
import type { HonoEnv } from './types/hono'
@@ -68,11 +68,11 @@ 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 } from './services/domain/payment'
import { createProductEventService } from './services/domain/product-events'
import { createProviderCatalogService } from './services/domain/provider-catalog'
import { createProviderService } from './services/domain/providers'
import { createRequestLogService } from './services/domain/request-log'
import { createStripeService } from './services/domain/stripe'
import { createUserDeletionService } from './services/domain/user-deletion'
import { createVoicePackService } from './services/domain/voice-packs'
import { createEnvelopeCrypto } from './utils/envelope-crypto'
@@ -87,7 +87,8 @@ interface AppDeps {
providerService: ProviderService
fluxService: FluxService
fluxTransactionService: FluxTransactionService
stripeService: StripeService
paymentService: PaymentService
stripe: Stripe | null
billingService: BillingService
ttsMeter: FluxMeter
requestLogService: RequestLogService
@@ -397,7 +398,17 @@ export async function buildApp(deps: AppDeps) {
/**
* Stripe routes.
*/
.route('/api/v1/stripe', createStripeRoutes(deps.fluxService, deps.stripeService, deps.billingService, deps.configKV, deps.env, deps.redis, deps.otel?.revenue, deps.otel?.rateLimit, deps.productEventService))
.route('/api/v1/stripe', createStripeRoutes(
deps.paymentService,
deps.db,
deps.stripe,
deps.redis,
deps.configKV,
deps.env,
deps.otel?.revenue ?? null,
deps.otel?.rateLimit ?? null,
deps.productEventService,
))
/**
* Catch-all 404 in JSON. Replaces hono's default `text/html` "404 Not
@@ -561,14 +572,12 @@ export async function createApp() {
build: ({ dependsOn }) => createChatService(dependsOn.db, dependsOn.otel?.engagement),
})
const stripeService = injeca.provide('services:stripe', {
dependsOn: { db, env: parsedEnv },
const stripe = injeca.provide('libs:stripe', {
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.
const stripe = dependsOn.env.STRIPE_SECRET_KEY ? new Stripe(dependsOn.env.STRIPE_SECRET_KEY) : null
return createStripeService(dependsOn.db, stripe)
// billing routes degrade gracefully.
return dependsOn.env.STRIPE_SECRET_KEY ? new Stripe(dependsOn.env.STRIPE_SECRET_KEY) : null
},
})
@@ -582,29 +591,6 @@ export async function createApp() {
build: ({ dependsOn }) => createFluxService(dependsOn.db, dependsOn.redis, dependsOn.configKV),
})
// NOTICE:
// The deletion service is a thin scheduler that delegates to each business
// service's own `deleteAllForUser` method. Adding a new business module:
// 1. give it a `deleteAllForUser(userId)` method
// 2. add one `service.register(...)` line below
// Domain knowledge stays inside each service instead of being copied into
// a parallel handler file. See `server/apps/api/docs/ai-context/account-deletion.md`.
const userDeletionService = injeca.provide('services:userDeletion', {
dependsOn: { stripeService, 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),
// 30 = pure DB soft-delete (no external touch).
service.register({ name: 'stripe', priority: 10, softDelete: ({ userId }) => dependsOn.stripeService.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) })
service.register({ name: 'chats', priority: 30, softDelete: ({ userId }) => dependsOn.chatService.deleteAllForUser(userId) })
return service
},
})
const requestLogService = injeca.provide('services:requestLog', {
dependsOn: { db },
build: ({ dependsOn }) => createRequestLogService(dependsOn.db),
@@ -625,6 +611,33 @@ export async function createApp() {
build: ({ dependsOn }) => createBillingService(dependsOn.db, dependsOn.redis, dependsOn.configKV, dependsOn.otel?.revenue),
})
const paymentService = injeca.provide('services:payment', {
dependsOn: { db, billingService },
build: ({ dependsOn }) => createPaymentService(dependsOn.db, dependsOn.billingService),
})
// NOTICE:
// The deletion service is a thin scheduler that delegates to each business
// service's own `deleteAllForUser` method. Adding a new business module:
// 1. give it a `deleteAllForUser(userId)` method
// 2. add one `service.register(...)` line below
// Domain knowledge stays inside each service instead of being copied into
// a parallel handler file. See `server/apps/api/docs/ai-context/account-deletion.md`.
const userDeletionService = injeca.provide('services:userDeletion', {
dependsOn: { paymentService, fluxService, providerService, characterService, chatService },
build: ({ dependsOn }) => {
const service = createUserDeletionService()
// priority: 20 = financial / cache state (Flux balance + Redis),
// 30 = pure DB soft-delete (no external touch).
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) })
service.register({ name: 'chats', priority: 30, softDelete: ({ userId }) => dependsOn.chatService.deleteAllForUser(userId) })
return service
},
})
const ttsMeter = injeca.provide('services:ttsMeter', {
dependsOn: { redis, billingService, configKV, otel },
build: ({ dependsOn }) => createFluxMeter(dependsOn.redis, dependsOn.billingService, {
@@ -686,7 +699,8 @@ export async function createApp() {
requestLogService,
voicePackService,
productEventService,
stripeService,
paymentService,
stripe,
billingService,
ttsMeter,
configKV,
@@ -711,7 +725,8 @@ export async function createApp() {
providerService: resolved.providerService,
fluxService: resolved.fluxService,
fluxTransactionService: resolved.fluxTransactionService,
stripeService: resolved.stripeService,
paymentService: resolved.paymentService,
stripe: resolved.stripe,
voicePackService: resolved.voicePackService,
billingService: resolved.billingService,
ttsMeter: resolved.ttsMeter,
-12
View File
@@ -53,8 +53,6 @@ import {
METRIC_STRIPE_CHECKOUT_COMPLETED,
METRIC_STRIPE_CHECKOUT_CREATED,
METRIC_STRIPE_EVENTS,
METRIC_STRIPE_PAYMENT_FAILED,
METRIC_STRIPE_SUBSCRIPTION_EVENT,
METRIC_USER_LOGIN,
METRIC_USER_REGISTERED,
METRIC_WS_CONNECTIONS_ACTIVE,
@@ -112,8 +110,6 @@ export interface EngagementMetrics {
export interface RevenueMetrics {
stripeCheckoutCreated: Counter
stripeCheckoutCompleted: Counter
stripePaymentFailed: Counter
stripeSubscriptionEvent: Counter
stripeEvents: Counter
stripeRevenue: Counter
fluxInsufficientBalance: Counter
@@ -354,12 +350,6 @@ export function initOtel(env: Env): OtelInstance | null {
stripeCheckoutCompleted: meter.createCounter(METRIC_STRIPE_CHECKOUT_COMPLETED, {
description: 'Number of Stripe checkout sessions completed',
}),
stripePaymentFailed: meter.createCounter(METRIC_STRIPE_PAYMENT_FAILED, {
description: 'Number of failed Stripe payments',
}),
stripeSubscriptionEvent: meter.createCounter(METRIC_STRIPE_SUBSCRIPTION_EVENT, {
description: 'Number of Stripe subscription lifecycle events',
}),
stripeEvents: meter.createCounter(METRIC_STRIPE_EVENTS, {
description: 'Number of Stripe webhook events processed',
}),
@@ -503,8 +493,6 @@ export function initOtel(env: Env): OtelInstance | null {
engagement.wsMessagesReceived,
revenue.stripeCheckoutCreated,
revenue.stripeCheckoutCompleted,
revenue.stripePaymentFailed,
revenue.stripeSubscriptionEvent,
revenue.stripeEvents,
revenue.stripeRevenue,
revenue.fluxInsufficientBalance,
@@ -11,7 +11,6 @@ import { ApiError } from '../../utils/error'
function createMockFluxService(): FluxService {
return {
getFlux: vi.fn(async (userId: string) => ({ userId, flux: 42 })),
updateStripeCustomerId: vi.fn(),
} as any
}
@@ -23,7 +23,6 @@ import {
function createMockFluxService(flux = 100): FluxService {
return {
getFlux: vi.fn(async () => ({ userId: 'user-1', flux })),
updateStripeCustomerId: vi.fn(),
} as any
}
@@ -40,8 +39,6 @@ function createMockBillingService(flux = 100): BillingService {
return { userId: input.userId, flux: balance, charged, requested: input.amount }
}),
creditFlux: vi.fn(),
creditFluxFromStripeCheckout: vi.fn(),
creditFluxFromInvoice: vi.fn(),
} as any
}
@@ -0,0 +1,292 @@
import type { Database } from '../../libs/db'
import type { ConfigDefinitions, ConfigKVService } from '../../services/adapters/config-kv'
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: ConfigDefinitions['FLUX_PACKS'][number] = {
key: 'starter',
name: '500 Flux',
fluxAmount: 500,
recommended: false,
processors: { 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: ConfigDefinitions['FLUX_PACKS']): 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
}
function createCheckout(
payment: ReturnType<typeof createPaymentService>,
stripe: { checkout: { sessions: { create: ReturnType<typeof vi.fn> } }, prices?: { retrieve: ReturnType<typeof vi.fn> } },
packs: ConfigDefinitions['FLUX_PACKS'] = [starterPack],
productEventService: { track: ReturnType<typeof vi.fn> } | null = null,
) {
return createCheckoutOperation(
payment,
{ prices: { retrieve: vi.fn(async () => ({ active: true, type: 'one_time' })) }, ...stripe } as never,
createPacksConfigKV(packs),
testEnv,
null,
productEventService as never,
)
}
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.paymentCustomer).where(eq(schema.paymentCustomer.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?.processorOrderId).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 = createCheckout(payment, { checkout: { sessions: { create } } })
const result = await checkout(
testUser,
{ packKey: 'starter', currency: 'usd' },
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?.processorOrderId).toBe('cs_test_1')
expect(order?.amount).toBe(500)
expect(order?.currency).toBe('usd')
})
it('resolves previous-version 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 = createCheckout(payment, { checkout: { sessions: { create } } })
await checkout(
testUser,
{ stripePriceId: 'price_starter' },
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',
processor: 'stripe',
paymentOrderId: paymentOrderId!,
processorOrderId: 'cs_test_race',
status: 'paid',
customerId: '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 = createCheckout(payment, { checkout: { sessions: { create } } })
await checkout(
testUser,
{ packKey: 'starter' },
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?.processorOrderId).toBe('cs_test_race')
})
it('stores browser OpenPanel 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 = createCheckout(payment, { checkout: { sessions: { create } } }, [starterPack], productEventService)
await checkout(
testUser,
{ packKey: 'starter' },
new Request('http://localhost/api/v1/stripe/checkout', {
headers: {
'x-openpanel-device-id': 'anon-browser-1',
'x-openpanel-session-id': 'ph-session-1',
},
}),
)
expect(create).toHaveBeenCalledWith(expect.objectContaining({
metadata: expect.objectContaining({
openpanelDeviceId: 'anon-browser-1',
openpanelSessionId: 'ph-session-1',
}),
}))
expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({
action: 'checkout_started',
metadata: expect.objectContaining({
openpanel_device_id: 'anon-browser-1',
}),
}))
})
it('reuses the live Stripe customer on the Checkout Session', async () => {
await db.insert(schema.paymentCustomer).values({
userId: 'user-pay-1',
processor: 'stripe',
customerId: 'cus_existing',
})
const create = vi.fn(async (params: { customer?: string, customer_email?: string }) => {
expect(params.customer).toBe('cus_existing')
expect(params.customer_email).toBeUndefined()
return {
id: 'cs_test_customer',
url: 'https://checkout.stripe.test/cs_test_customer',
amount_total: 500,
currency: 'usd',
}
})
const checkout = createCheckout(payment, { checkout: { sessions: { create } } })
await checkout(
testUser,
{ packKey: 'starter' },
new Request('http://localhost/api/v1/stripe/checkout'),
)
expect(create).toHaveBeenCalled()
})
it('abandons the pending order when Checkout Session create fails', async () => {
const create = vi.fn(async () => {
throw new Error('stripe down')
})
const checkout = createCheckout(payment, { checkout: { sessions: { create } } })
await expect(checkout(
testUser,
{ packKey: 'starter' },
new Request('http://localhost/api/v1/stripe/checkout'),
)).rejects.toThrow('stripe down')
const [order] = await db.select().from(schema.paymentOrder).where(eq(schema.paymentOrder.userId, 'user-pay-1'))
expect(order?.status).toBe('canceled')
expect(order?.processorOrderId).toBeNull()
})
it('abandons the pending order when Checkout Session has no URL', async () => {
const create = vi.fn(async () => ({
id: 'cs_test_nourl',
url: null,
amount_total: 500,
currency: 'usd',
}))
const checkout = createCheckout(payment, { checkout: { sessions: { create } } })
await expect(checkout(
testUser,
{ packKey: 'starter' },
new Request('http://localhost/api/v1/stripe/checkout'),
)).rejects.toMatchObject({
statusCode: 503,
errorCode: 'STRIPE_CHECKOUT_URL_MISSING',
})
const [order] = await db.select().from(schema.paymentOrder).where(eq(schema.paymentOrder.userId, 'user-pay-1'))
expect(order?.status).toBe('canceled')
})
it('rejects inactive prices before creating a payment order', async () => {
const create = vi.fn()
const checkout = createCheckout(payment, { checkout: { sessions: { create } }, prices: { retrieve: vi.fn(async () => ({ active: false, type: 'one_time' })) } })
await expect(checkout(testUser, { packKey: 'starter' }, new Request('http://localhost/api/v1/stripe/checkout'))).rejects.toMatchObject({ statusCode: 400 })
expect(await db.select().from(schema.paymentOrder)).toHaveLength(0)
expect(create).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,49 @@
import type { InferOutput } from 'valibot'
import type { ClaimReceipt } from '../../services/domain/payment'
import { nullable, number, object, optional, parse, picklist, record, string, union } from 'valibot'
const processorReference = nullable(union([string(), object({ id: string() })]))
/** Fields consumed after Stripe verifies the event signature. */
export const checkoutSessionSchema = object({
id: string(),
mode: picklist(['payment', 'subscription', 'setup']),
status: nullable(picklist(['open', 'complete', 'expired'])),
payment_status: picklist(['paid', 'unpaid', 'no_payment_required']),
amount_total: optional(nullable(number())),
currency: optional(nullable(string())),
customer: optional(processorReference),
payment_intent: optional(processorReference),
metadata: optional(nullable(record(string(), string()))),
})
export type CheckoutSession = InferOutput<typeof checkoutSessionSchema>
/** Returns no claim while payment is pending. Expiration never grants Flux. */
export function claimReceiptFromCheckoutSession(
input: unknown,
paymentOrderId: string,
): ClaimReceipt | null {
const session = parse(checkoutSessionSchema, input)
if (session.status !== 'expired' && (session.status !== 'complete' || session.payment_status === 'unpaid'))
return null
return {
kind: 'claim',
processor: 'stripe',
paymentOrderId,
processorOrderId: session.id,
status: session.status === 'expired' ? 'expired' : 'paid',
amount: session.amount_total ?? undefined,
currency: session.currency ?? undefined,
customerId: typeof session.customer === 'string' ? session.customer : session.customer?.id,
extras: {
sessionId: session.id,
paymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id,
mode: session.mode,
paymentStatus: session.payment_status,
},
}
}
+20 -97
View File
@@ -1,131 +1,54 @@
import type Redis from 'ioredis'
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 { ConfigKVService } from '../../services/adapters/config-kv'
import type { BillingService } from '../../services/domain/billing/billing-service'
import type { FluxService } from '../../services/domain/flux'
import type { PaymentService } from '../../services/domain/payment'
import type { ProductEventService } from '../../services/domain/product-events'
import type { StripeService } from '../../services/domain/stripe'
import type { HonoEnv } from '../../types/hono'
import Stripe from 'stripe'
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 { createCheckoutOperation } from './operations/checkout'
import { createWebhookOperation } from './operations/webhook'
import { createStripePriceCatalog, formatPrice } from './price-catalog'
export { formatPrice } from './price-catalog'
import { listStripePackages } from './price-catalog'
/**
* Creates Stripe HTTP routes for Flux purchase and billing records.
* Creates Stripe HTTP routes for Flux purchase.
*
* Use when:
* - Mounting `/api/v1/stripe` in the server app.
* - Wiring Stripe checkout, customer portal, package catalog, and webhooks.
*
* Expects:
* - Auth middleware to populate `c.get('user')` for protected endpoints.
* - Stripe configuration to be present for checkout, portal, and webhook routes.
*
* Returns:
* - A Hono router scoped to Stripe endpoints.
* Paths stay on `/api/v1/stripe`. Checkout lives in this adapter.
* Webhook dispatch maps a session onto Payment CORE `settle`.
*/
export function createStripeRoutes(
fluxService: FluxService,
stripeService: StripeService,
billingService: BillingService,
payment: PaymentService,
db: Database,
stripe: Stripe | null,
redis: Redis,
configKV: ConfigKVService,
env: Env,
redis: Redis,
metrics?: RevenueMetrics | null,
rateLimitMetrics?: RateLimitMetrics | null,
productEventService?: ProductEventService,
metrics: RevenueMetrics | null,
rateLimitMetrics: RateLimitMetrics | null,
productEventService: ProductEventService | null,
) {
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null
const priceCatalog = stripe ? createStripePriceCatalog(stripe, redis) : null
const checkout = createCheckoutOperation({ stripe, priceCatalog, stripeService, configKV, env, metrics, productEventService })
const webhook = createWebhookOperation({
stripe,
webhookSecret: env.STRIPE_WEBHOOK_SECRET,
fluxService,
stripeService,
billingService,
metrics,
productEventService,
})
const checkout = createCheckoutOperation(payment, stripe, configKV, env, metrics, productEventService)
const webhook = createWebhookOperation(stripe, env.STRIPE_WEBHOOK_SECRET ?? null, payment, db, metrics, productEventService)
return new Hono<HonoEnv>()
.get('/packages', async (c) => {
const fluxProductId = await configKV.getOptional('STRIPE_FLUX_PRODUCT_ID')
if (!priceCatalog || !fluxProductId)
return c.json([])
const prices = await priceCatalog.getActivePrices(fluxProductId)
// Build per-currency price map for each package.
return c.json(prices.map((p) => {
const currencies: Record<string, string> = {
[p.currency]: formatPrice(p.unitAmount, p.currency),
}
for (const [cur, opt] of Object.entries(p.currencyOptions)) {
currencies[cur] = formatPrice(opt.unitAmount, cur)
}
return {
stripePriceId: p.id,
label: `${p.metadata.fluxAmount ?? '?'} Flux`,
defaultCurrency: p.currency,
currencies,
recommended: p.metadata.recommended === 'true',
}
}))
const packs = await configKV.getOptional('FLUX_PACKS') ?? []
return c.json(await listStripePackages(stripe, redis, packs))
})
.post('/checkout', authGuard, rateLimiter({ max: 10, windowSec: 60, metrics: rateLimitMetrics, routeLabel: 'stripe.checkout' }), async (c) => {
const body = await c.req.json()
return c.json(await checkout({
user: c.get('user')!,
body,
request: c.req.raw,
}))
})
.get('/orders', authGuard, async (c) => {
const user = c.get('user')!
const sessions = await stripeService.getCheckoutSessionsByUserId(user.id)
return c.json(sessions)
})
.get('/invoices', authGuard, async (c) => {
const user = c.get('user')!
const invoices = await stripeService.getInvoicesByUserId(user.id)
return c.json(invoices)
})
.post('/portal', authGuard, async (c) => {
if (!stripe)
throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED')
const user = c.get('user')!
const customer = await stripeService.getCustomerByUserId(user.id)
if (!customer)
throw createBadRequestError('No billing account found', 'NO_CUSTOMER')
const portalReturnBase = resolveCheckoutRedirectBase(c.req.raw, env.ADDITIONAL_TRUSTED_ORIGINS, env.WEB_APP_URL)
const portalSession = await stripe.billingPortal.sessions.create({
customer: customer.stripeCustomerId,
return_url: `${portalReturnBase}/settings/flux`,
})
return c.json({ url: portalSession.url })
return c.json(await checkout(c.get('user')!, body, c.req.raw))
})
.post('/webhook', async (c) => {
const signature = c.req.header('stripe-signature') ?? null
const body = signature ? await c.req.text() : ''
return c.json(await webhook({ signature, body }))
return c.json(await webhook(signature, body))
})
}
@@ -2,144 +2,126 @@ import type Stripe from 'stripe'
import type { Env } from '../../../libs/env'
import type { RevenueMetrics } from '../../../otel'
import type { ConfigKVService } from '../../../services/adapters/config-kv'
import type { ConfigDefinitions, ConfigKVService } from '../../../services/adapters/config-kv'
import type { PaymentService } from '../../../services/domain/payment'
import type { ProductEventService } from '../../../services/domain/product-events'
import type { StripeService } from '../../../services/domain/stripe'
import type { HonoEnv } from '../../../types/hono'
import type { StripePriceCatalog } from '../price-catalog'
import { safeParse } from 'valibot'
import { boolean, object, parse, picklist, safeParse } from 'valibot'
import { createBadRequestError, createServiceUnavailableError } from '../../../utils/error'
import { resolveCheckoutRedirectBase } from '../../../utils/origin'
import { CheckoutBodySchema } from '../schema'
type AuthenticatedUser = NonNullable<HonoEnv['Variables']['user']>
type CheckoutSessionCreateParams = NonNullable<Parameters<Stripe['checkout']['sessions']['create']>[0]>
export interface CheckoutOperationDeps {
stripe: Stripe | null
priceCatalog: StripePriceCatalog | null
stripeService: StripeService
configKV: ConfigKVService
env: Env
metrics?: RevenueMetrics | null
productEventService?: ProductEventService
}
export interface CheckoutOperationInput {
user: AuthenticatedUser
body: unknown
request: Request
}
interface OpenpanelIdentityHeaders {
distinctId?: string
sessionId?: string
}
/**
* Creates Stripe checkout sessions for Flux packages.
* Opens a pending order through Payment CORE, then creates a Stripe Checkout Session.
*
* Use when:
* - A signed-in user starts a one-time Flux purchase.
* - The route already enforced auth and rate limiting.
*
* Expects:
* - ConfigKV has `STRIPE_FLUX_PRODUCT_ID`.
* - `body` matches {@link CheckoutBodySchema}.
*
* Returns:
* - A Stripe-hosted checkout URL.
* `{ packKey }` and previous-version `{ stripePriceId }` resolve a Flux pack.
*/
export function createCheckoutOperation(deps: CheckoutOperationDeps) {
return async (input: CheckoutOperationInput): Promise<{ url: string | null }> => {
const fluxProductId = await deps.configKV.getOptional('STRIPE_FLUX_PRODUCT_ID')
if (!deps.stripe || !deps.priceCatalog || !fluxProductId)
export function createCheckoutOperation(
payment: PaymentService,
stripe: Stripe | null,
configKV: ConfigKVService,
env: Env,
metrics: RevenueMetrics | null,
productEventService: ProductEventService | null,
) {
return async (
user: { id: string, email: string },
body: unknown,
request: Request,
): Promise<{ url: string }> => {
if (!stripe)
throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED')
const result = safeParse(CheckoutBodySchema, input.body)
if (!result.success)
throw createBadRequestError('Invalid checkout request', 'INVALID_REQUEST', result.issues)
const parsed = safeParse(CheckoutBodySchema, body)
if (!parsed.success)
throw createBadRequestError('Invalid checkout request', 'INVALID_REQUEST', parsed.issues)
const { stripePriceId, currency } = result.output
const { packKey, stripePriceId, currency } = parsed.output
const packs = await configKV.getOptional('FLUX_PACKS') ?? []
const pack = resolveStripeCheckoutPack(packs, packKey, stripePriceId)
if (!pack)
throw createBadRequestError('Invalid pack', 'INVALID_PACKAGE', { packKey })
const priceId = pack.processors.stripe?.priceId
if (!priceId)
throw createServiceUnavailableError('Stripe pack mapping is missing', 'STRIPE_PACK_NOT_MAPPED', { packKey: pack.key })
const price = await deps.priceCatalog.findActivePrice(fluxProductId, stripePriceId)
if (!price)
throw createBadRequestError('Invalid price', 'INVALID_PACKAGE', { stripePriceId })
const price = parse(object({ active: boolean(), type: picklist(['one_time', 'recurring']) }), await stripe.prices.retrieve(priceId))
if (!price.active || price.type !== 'one_time')
throw createBadRequestError('Stripe price is not available for one-time purchases', 'INVALID_PACKAGE')
const fluxAmount = Number(price.metadata.fluxAmount)
if (!Number.isFinite(fluxAmount) || fluxAmount <= 0)
throw createBadRequestError('Price is missing fluxAmount metadata', 'INVALID_PACKAGE', { stripePriceId })
const redirectBase = resolveCheckoutRedirectBase(request, env.ADDITIONAL_TRUSTED_ORIGINS, env.WEB_APP_URL)
const openpanelIdentity = readOpenpanelIdentityHeaders(request)
// Reuse existing stripe customer if available.
const customer = await deps.stripeService.getCustomerByUserId(input.user.id)
const stripeCustomerId = customer?.stripeCustomerId
const order = await payment.openPending({
userId: user.id,
processor: 'stripe',
packKey: pack.key,
fluxAmount: pack.fluxAmount,
currency,
})
const redirectBase = resolveCheckoutRedirectBase(input.request, deps.env.ADDITIONAL_TRUSTED_ORIGINS, deps.env.WEB_APP_URL)
const paymentMethods = await deps.configKV.getOptional('STRIPE_PAYMENT_METHODS')
const paymentMethodOptions = await deps.configKV.getOptional('STRIPE_PAYMENT_METHOD_OPTIONS') ?? {}
const openpanelIdentity = readOpenpanelIdentityHeaders(input.request)
const sessionParams: CheckoutSessionCreateParams = {
line_items: [{ price: stripePriceId, quantity: 1 }],
const sessionParams: Stripe.Checkout.SessionCreateParams = {
line_items: [{ price: priceId, quantity: 1 }],
mode: 'payment',
allow_promotion_codes: true,
success_url: `${redirectBase}/settings/flux?success=true`,
cancel_url: `${redirectBase}/settings/flux?canceled=true`,
customer: stripeCustomerId,
customer_email: stripeCustomerId ? undefined : input.user.email,
customer: order.customerId,
customer_email: order.customerId ? undefined : user.email,
metadata: {
userId: input.user.id,
fluxAmount: String(fluxAmount),
payment_order_id: order.id,
userId: user.id,
packKey: pack.key,
fluxAmount: String(pack.fluxAmount),
...(openpanelIdentity.distinctId && { openpanelDeviceId: openpanelIdentity.distinctId }),
...(openpanelIdentity.sessionId && { openpanelSessionId: openpanelIdentity.sessionId }),
},
}
// When STRIPE_PAYMENT_METHODS is not set, omit payment_method_types to let Stripe
// automatically determine available methods based on currency and Dashboard settings.
const paymentMethods = await configKV.getOptional('STRIPE_PAYMENT_METHODS')
const paymentMethodOptions = await configKV.getOptional('STRIPE_PAYMENT_METHOD_OPTIONS') ?? {}
if (paymentMethods)
sessionParams.payment_method_types = paymentMethods as CheckoutSessionCreateParams['payment_method_types']
sessionParams.payment_method_types = paymentMethods as Stripe.Checkout.SessionCreateParams['payment_method_types']
if (Object.keys(paymentMethodOptions).length > 0)
sessionParams.payment_method_options = paymentMethodOptions as CheckoutSessionCreateParams['payment_method_options']
sessionParams.payment_method_options = paymentMethodOptions as Stripe.Checkout.SessionCreateParams['payment_method_options']
// When currency is specified, Stripe uses the matching currency_options on the Price.
if (currency)
sessionParams.currency = currency
const session = await deps.stripe.checkout.sessions.create(sessionParams)
let session: Stripe.Checkout.Session
try {
session = await stripe.checkout.sessions.create(sessionParams)
}
catch (error) {
await payment.abandon(order.id)
throw error
}
// Persist the checkout session.
await deps.stripeService.upsertCheckoutSession({
userId: input.user.id,
stripeSessionId: session.id,
stripeCustomerId: typeof session.customer === 'string' ? session.customer : session.customer?.id,
mode: session.mode ?? 'payment',
status: session.status,
paymentStatus: session.payment_status,
amountTotal: session.amount_total,
currency: session.currency,
successUrl: session.success_url,
cancelUrl: session.cancel_url,
stripePaymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id,
stripeSubscriptionId: typeof session.subscription === 'string' ? session.subscription : session.subscription?.id,
metadata: session.metadata ? JSON.stringify(session.metadata) : null,
expiresAt: session.expires_at ? new Date(session.expires_at * 1000) : null,
if (!session.url) {
await payment.abandon(order.id)
throw createServiceUnavailableError('Stripe checkout did not return a URL', 'STRIPE_CHECKOUT_URL_MISSING')
}
await payment.bindProcessorOrder(order.id, {
processorOrderId: session.id,
amount: session.amount_total ?? undefined,
currency: session.currency ?? currency,
})
deps.metrics?.stripeCheckoutCreated.add(1)
void deps.productEventService?.track({
userId: input.user.id,
metrics?.stripeCheckoutCreated.add(1)
void productEventService?.track({
userId: user.id,
feature: 'billing',
action: 'checkout_started',
status: 'succeeded',
eventId: session.id,
eventId: order.id,
source: 'stripe.checkout',
metadata: {
flux_amount: fluxAmount,
pack_key: pack.key,
flux_amount: pack.fluxAmount,
amount_total: session.amount_total,
currency: session.currency,
...(openpanelIdentity.distinctId && { openpanel_device_id: openpanelIdentity.distinctId }),
@@ -151,7 +133,19 @@ export function createCheckoutOperation(deps: CheckoutOperationDeps) {
}
}
function readOpenpanelIdentityHeaders(request: Request): OpenpanelIdentityHeaders {
function resolveStripeCheckoutPack(
packs: ConfigDefinitions['FLUX_PACKS'],
packKey: string | undefined,
stripePriceId: string | undefined,
) {
if (packKey)
return packs.find(item => item.key === packKey)
if (stripePriceId)
return packs.find(item => item.processors.stripe?.priceId === stripePriceId)
return undefined
}
function readOpenpanelIdentityHeaders(request: Request) {
const distinctId = readStripeMetadataHeader(request, 'x-openpanel-device-id')
const sessionId = readStripeMetadataHeader(request, 'x-openpanel-session-id')
return {
@@ -160,13 +154,10 @@ function readOpenpanelIdentityHeaders(request: Request): OpenpanelIdentityHeader
}
}
function readStripeMetadataHeader(request: Request, name: string): string | undefined {
function readStripeMetadataHeader(request: Request, name: string) {
const value = request.headers.get(name)?.trim()
if (!value)
return undefined
// Stripe metadata values are capped and user-controlled headers can be
// oversized. Truncating keeps the checkout request valid without turning
// analytics identity into a payment blocker.
return value.slice(0, 200)
}
@@ -1,340 +1,184 @@
import type Stripe from 'stripe'
import type { Database } from '../../../libs/db'
import type { RevenueMetrics } from '../../../otel'
import type { BillingService } from '../../../services/domain/billing/billing-service'
import type { FluxService } from '../../../services/domain/flux'
import type { PaymentService } from '../../../services/domain/payment'
import type { ProductEventService } from '../../../services/domain/product-events'
import type { StripeService } from '../../../services/domain/stripe'
import type { CheckoutSession } from '../claim'
import { useLogger } from '@guiiai/logg'
import { and, eq } from 'drizzle-orm'
import { object, optional, parse, pipe, regex, safeInteger, string, transform } from 'valibot'
import { stripeCheckoutSession } from '../../../schemas/stripe'
import { createBadRequestError, createServiceUnavailableError } from '../../../utils/error'
import { errorMessageFromUnknown } from '../../../utils/error-message'
import { checkoutSessionSchema, claimReceiptFromCheckoutSession } from '../claim'
import * as paymentSchema from '../../../schemas/payment'
const logger = useLogger('stripe')
interface StripeSubscriptionEventContext {
userId: string
stripeCustomerId: string
stripeSubscriptionId: string
stripePriceId?: string
subscriptionStatus?: string
amountPaid?: number
currency?: string
}
/**
* Finds the `payment_order` id for a verified Checkout Session.
*
* New Sessions store `metadata.payment_order_id`. Sessions copied by
* `0023_payment_order.sql` are found by Stripe session id.
*/
async function resolvePaymentOrderId(
db: Database,
session: CheckoutSession,
): Promise<string | undefined> {
const fromMetadata = session.metadata?.payment_order_id
if (fromMetadata)
return fromMetadata
export interface WebhookOperationDeps {
stripe: Stripe | null
webhookSecret: string | undefined
fluxService: FluxService
stripeService: StripeService
billingService: BillingService
metrics?: RevenueMetrics | null
productEventService?: ProductEventService
}
const [existing] = await db
.select({ id: paymentSchema.paymentOrder.id })
.from(paymentSchema.paymentOrder)
.where(and(
eq(paymentSchema.paymentOrder.processor, 'stripe'),
eq(paymentSchema.paymentOrder.processorOrderId, session.id),
))
.limit(1)
export interface WebhookOperationInput {
signature: string | null
body: string
if (existing)
return existing.id
// NOTICE:
// Old replicas can insert checkout rows after migration 0023 copies them.
// The retained Stripe table is the ownership proof for those sessions.
// See 0023_payment_order.sql. Remove this path with the legacy-table cutover.
const [legacy] = await db.select().from(stripeCheckoutSession).where(eq(stripeCheckoutSession.stripeSessionId, session.id)).limit(1)
if (!legacy)
return undefined
const metadata = legacy.metadata
? parse(object({
fluxAmount: optional(pipe(string(), regex(/^[1-9]\d*$/), transform(Number), safeInteger())),
packKey: optional(string()),
}), JSON.parse(legacy.metadata))
: undefined
await db.insert(paymentSchema.paymentOrder).values({
id: legacy.id,
userId: legacy.userId,
processor: 'stripe',
processorOrderId: legacy.stripeSessionId,
status: legacy.fluxCredited ? 'paid' : legacy.status === 'expired' ? 'expired' : 'pending',
fluxAmount: metadata?.fluxAmount,
packKey: metadata?.packKey,
amount: legacy.amountTotal,
currency: legacy.currency,
creditedAt: legacy.fluxCredited ? legacy.updatedAt : null,
createdAt: legacy.createdAt,
updatedAt: legacy.updatedAt,
deletedAt: legacy.deletedAt,
}).onConflictDoNothing()
return legacy.id
}
/**
* Processes Stripe webhook events.
*
* Use when:
* - The route has captured the raw request body.
* - Stripe signature verification must happen before event dispatch.
*
* Expects:
* - A configured Stripe client and webhook secret.
*
* Returns:
* - `{ received: true }` after known and unknown events are accepted.
* 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 }> => {
if (!deps.stripe || !deps.webhookSecret)
export function createWebhookOperation(
stripe: Stripe | null,
webhookSecret: string | null,
payment: PaymentService,
db: Database,
metrics: RevenueMetrics | null,
productEventService: ProductEventService | null,
) {
return async (signature: string | null, body: string): Promise<{ received: true }> => {
if (!stripe || !webhookSecret)
throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED')
if (!input.signature)
if (!signature)
throw createBadRequestError('No signature', 'MISSING_SIGNATURE')
let event: Stripe.Event
try {
event = deps.stripe.webhooks.constructEvent(input.body, input.signature, deps.webhookSecret)
event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
}
catch (err: unknown) {
throw createBadRequestError(`Webhook Error: ${errorMessageFromUnknown(err)}`, 'WEBHOOK_ERROR')
}
logger.withFields({ type: event.type, id: event.id }).log('Webhook event received')
deps.metrics?.stripeEvents.add(1, { event_type: event.type })
metrics?.stripeEvents.add(1, { event_type: event.type })
switch (event.type) {
case 'checkout.session.completed': {
const result = await handleCheckoutSessionCompleted(event.id, event.data.object, deps.fluxService, deps.stripeService, deps.billingService)
deps.metrics?.stripeCheckoutCompleted.add(1)
// Revenue capture in smallest currency unit (e.g. cents).
// Cross-currency aggregation is meaningless, so always group by `currency` in queries.
if (event.data.object.amount_total != null && event.data.object.currency) {
deps.metrics?.stripeRevenue.add(event.data.object.amount_total, {
currency: event.data.object.currency,
case 'checkout.session.completed':
case 'checkout.session.async_payment_succeeded': {
const session = parse(checkoutSessionSchema, event.data.object)
if (session.mode !== 'payment') {
logger.withFields({ sessionId: session.id, mode: session.mode }).log('Ignoring non-payment checkout session')
break
}
const paymentOrderId = await resolvePaymentOrderId(db, session)
if (!paymentOrderId) {
logger.withFields({ sessionId: session.id }).warn('Ignoring checkout session without payment_order_id')
break
}
const receipt = claimReceiptFromCheckoutSession(session, paymentOrderId)
if (!receipt)
break
const result = await payment.settle(receipt)
if (result.applied)
metrics?.stripeCheckoutCompleted.add(1)
if (result.applied && session.amount_total != null && session.currency) {
metrics?.stripeRevenue.add(session.amount_total, {
currency: session.currency ?? null,
source: 'checkout',
})
}
// Record the product conversion only after the handler actually
// processed the checkout. Malformed sessions (missing userId,
// invalid fluxAmount) take the early-return path above.
if (result.processed) {
const userId = event.data.object.metadata?.userId
if (userId) {
const fluxAmount = Number(event.data.object.metadata?.fluxAmount)
const openpanelDeviceId = event.data.object.metadata?.openpanelDeviceId
const openpanelSessionId = event.data.object.metadata?.openpanelSessionId
void deps.productEventService?.track({
userId,
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
eventId: event.data.object.id,
source: 'stripe.webhook',
metadata: {
amount_total: event.data.object.amount_total,
currency: event.data.object.currency,
flux_amount: Number.isFinite(fluxAmount) ? fluxAmount : null,
stripe_checkout_session_id: event.data.object.id,
stripe_customer_id: typeof event.data.object.customer === 'string' ? event.data.object.customer : event.data.object.customer?.id ?? null,
...(openpanelDeviceId && { openpanel_device_id: openpanelDeviceId }),
...(openpanelSessionId && { openpanel_session_id: openpanelSessionId }),
},
})
}
}
break
}
case 'customer.created':
case 'customer.updated': {
await handleCustomerEvent(event.data.object, deps.stripeService)
break
}
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
await handleSubscriptionEvent(event.data.object, deps.stripeService)
deps.metrics?.stripeSubscriptionEvent.add(1, { event_type: event.type.replace('customer.subscription.', '') })
break
}
case 'invoice.created':
case 'invoice.updated':
case 'invoice.paid':
case 'invoice.payment_failed': {
await handleInvoiceEvent(event.data.object, deps.stripeService)
if (event.type === 'invoice.payment_failed')
deps.metrics?.stripePaymentFailed.add(1)
if (event.type === 'invoice.paid' && event.data.object.amount_paid && event.data.object.currency) {
deps.metrics?.stripeRevenue.add(event.data.object.amount_paid, {
currency: event.data.object.currency,
source: 'invoice',
if (result.applied) {
const openpanelDeviceId = session.metadata?.openpanelDeviceId
const openpanelSessionId = session.metadata?.openpanelSessionId
void productEventService?.track({
userId: result.userId,
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
source: 'stripe.webhook',
metadata: {
amount_total: session.amount_total ?? null,
currency: session.currency ?? null,
flux_amount: result.fluxAmount,
pack_key: session.metadata?.packKey ?? null,
stripe_checkout_session_id: session.id,
stripe_customer_id: typeof session.customer === 'string' ? session.customer : session.customer?.id ?? null,
...(openpanelDeviceId && { openpanel_device_id: openpanelDeviceId }),
...(openpanelSessionId && { openpanel_session_id: openpanelSessionId }),
},
})
}
break
}
case 'checkout.session.expired':
case 'checkout.session.async_payment_failed': {
const session = parse(checkoutSessionSchema, event.data.object)
const paymentOrderId = await resolvePaymentOrderId(db, session)
if (!paymentOrderId) {
logger.withFields({ sessionId: session.id }).warn('Ignoring checkout session without payment_order_id')
break
}
if (event.type === 'checkout.session.async_payment_failed') {
await payment.settle({ kind: 'claim', processor: 'stripe', paymentOrderId, processorOrderId: session.id, status: 'canceled' })
break
}
const receipt = claimReceiptFromCheckoutSession(session, paymentOrderId)
if (receipt)
await payment.settle(receipt)
break
}
default:
break
}
return { received: true }
}
}
async function handleCheckoutSessionCompleted(
stripeEventId: string,
session: Stripe.Checkout.Session,
fluxService: FluxService,
stripeService: StripeService,
billingService: BillingService,
): Promise<{ processed: boolean }> {
const userId = session.metadata?.userId
if (!userId) {
logger.withFields({ sessionId: session.id }).warn('Checkout session missing userId in metadata')
return { processed: false }
}
logger.withFields({ userId, sessionId: session.id, mode: session.mode, amount: session.amount_total, currency: session.currency }).log('Processing checkout session')
// Upsert customer record if we got a customer back.
if (session.customer) {
const stripeCustomerId = typeof session.customer === 'string' ? session.customer : session.customer.id
await stripeService.upsertCustomer({
userId,
stripeCustomerId,
email: session.customer_email ?? undefined,
})
await fluxService.updateStripeCustomerId(userId, stripeCustomerId)
}
await stripeService.upsertCheckoutSession({
userId,
stripeSessionId: session.id,
stripeCustomerId: typeof session.customer === 'string' ? session.customer : session.customer?.id,
mode: session.mode ?? 'payment',
status: session.status,
paymentStatus: session.payment_status,
amountTotal: session.amount_total,
currency: session.currency,
successUrl: session.success_url,
cancelUrl: session.cancel_url,
stripePaymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id,
stripeSubscriptionId: typeof session.subscription === 'string' ? session.subscription : session.subscription?.id,
metadata: session.metadata ? JSON.stringify(session.metadata) : null,
expiresAt: session.expires_at ? new Date(session.expires_at * 1000) : null,
})
// Idempotent flux credit: use fluxCredited flag inside a transaction
// to prevent double-crediting on webhook replay.
//
// For `payment` mode (one-time Flux purchase) `metadata.fluxAmount` is
// required — without it we can't credit anything, and the funnel must
// not see a `payment_completed` event for a checkout that didn't
// actually deliver Flux. Non-`payment` modes (e.g. `setup` for saving
// a card) deliberately skip crediting and still count as processed.
if (session.mode === 'payment') {
if (session.amount_total == null) {
logger.withFields({ userId, sessionId: session.id }).warn('Payment-mode checkout missing amount_total; skipping credit and capture')
return { processed: false }
}
const metadataFlux = session.metadata?.fluxAmount
if (!metadataFlux) {
logger.withFields({ userId, sessionId: session.id }).warn('Payment-mode checkout missing metadata.fluxAmount; skipping credit and capture')
return { processed: false }
}
const fluxAmount = Number(metadataFlux)
if (!Number.isFinite(fluxAmount) || fluxAmount <= 0) {
logger.withFields({ userId, sessionId: session.id, metadataFlux }).warn('Invalid fluxAmount in session metadata, skipping credit')
return { processed: false }
}
const result = await billingService.creditFluxFromStripeCheckout({
stripeEventId,
userId,
stripeSessionId: session.id,
amountTotal: session.amount_total,
currency: session.currency,
fluxAmount,
})
logger.withFields({
userId,
fluxAmount,
amountTotal: session.amount_total,
applied: result.applied,
balanceAfter: result.balanceAfter,
}).log('Processed flux credit for one-time payment')
// Only the transaction that credits this checkout emits its conversion.
return { processed: result.applied }
}
return { processed: true }
}
async function handleCustomerEvent(
customer: Stripe.Customer | Stripe.DeletedCustomer,
stripeService: StripeService,
) {
if (customer.deleted)
return
// Try to find existing customer to get userId.
const existing = await stripeService.getCustomerByStripeId(customer.id)
if (!existing)
return
await stripeService.upsertCustomer({
userId: existing.userId,
stripeCustomerId: customer.id,
email: customer.email ?? undefined,
name: customer.name ?? undefined,
})
}
async function handleSubscriptionEvent(
subscription: Stripe.Subscription,
stripeService: StripeService,
): Promise<StripeSubscriptionEventContext | null> {
const stripeCustomerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id
const customer = await stripeService.getCustomerByStripeId(stripeCustomerId)
if (!customer)
return null
// In newer Stripe API, period info is on subscription items.
const firstItem = subscription.items.data[0]
await stripeService.upsertSubscription({
userId: customer.userId,
stripeSubscriptionId: subscription.id,
stripeCustomerId,
stripePriceId: firstItem?.price?.id,
status: subscription.status,
currentPeriodStart: firstItem?.current_period_start ? new Date(firstItem.current_period_start * 1000) : null,
currentPeriodEnd: firstItem?.current_period_end ? new Date(firstItem.current_period_end * 1000) : null,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null,
endedAt: subscription.ended_at ? new Date(subscription.ended_at * 1000) : null,
metadata: subscription.metadata ? JSON.stringify(subscription.metadata) : null,
})
return {
userId: customer.userId,
stripeCustomerId,
stripeSubscriptionId: subscription.id,
stripePriceId: firstItem?.price?.id,
subscriptionStatus: subscription.status,
}
}
async function handleInvoiceEvent(
invoice: Stripe.Invoice,
stripeService: StripeService,
): Promise<StripeSubscriptionEventContext | null> {
const stripeCustomerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id
if (!stripeCustomerId)
return null
const customer = await stripeService.getCustomerByStripeId(stripeCustomerId)
if (!customer)
return null
// In newer Stripe API, subscription is under parent.subscription_details.
const subDetails = invoice.parent?.subscription_details
const subscriptionId = subDetails
? (typeof subDetails.subscription === 'string' ? subDetails.subscription : subDetails.subscription?.id)
: undefined
await stripeService.upsertInvoice({
userId: customer.userId,
stripeInvoiceId: invoice.id,
stripeCustomerId,
stripeSubscriptionId: subscriptionId,
status: invoice.status,
amountDue: invoice.amount_due,
amountPaid: invoice.amount_paid,
currency: invoice.currency,
invoiceUrl: invoice.hosted_invoice_url,
invoicePdf: invoice.invoice_pdf,
periodStart: new Date(invoice.period_start * 1000),
periodEnd: new Date(invoice.period_end * 1000),
paidAt: invoice.status_transitions?.paid_at ? new Date(invoice.status_transitions.paid_at * 1000) : null,
metadata: invoice.metadata ? JSON.stringify(invoice.metadata) : null,
})
// TODO: implement subscription-based flux crediting when subscriptions are enabled
if (invoice.status === 'paid' && invoice.amount_paid && subscriptionId)
logger.withFields({ userId: customer.userId, invoiceId: invoice.id, amountPaid: invoice.amount_paid }).warn('Subscription invoice paid but flux crediting for subscriptions is not yet implemented')
return {
userId: customer.userId,
stripeCustomerId,
stripeSubscriptionId: subscriptionId ?? '',
subscriptionStatus: invoice.status ?? undefined,
amountPaid: invoice.amount_paid,
currency: invoice.currency,
}
}
@@ -0,0 +1,120 @@
import type { Database } from '../../libs/db'
import Stripe from 'stripe'
import { eq } from 'drizzle-orm'
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { mockDB } from '../../libs/mock-db'
import { createTestRedis } from '../../libs/tests/redis'
import { createConfigKVService } from '../../services/adapters/config-kv'
import { createConfigKVStore } from '../../services/adapters/config-kv/store'
import { createBillingService } from '../../services/domain/billing/billing-service'
import { createPaymentService } from '../../services/domain/payment'
import { createWebhookOperation } from './operations/webhook'
import * as schema from '../../schemas'
// https://github.com/moeru-ai/airi/pull/2335
// ROOT CAUSE:
// Checkout completion can precede payment. Migration snapshots also miss
// sessions created by old replicas. Exercise signed events against the ledger.
describe('pR #2335 payment release', () => {
let db: Database
let payment: ReturnType<typeof createPaymentService>
let webhook: ReturnType<typeof createWebhookOperation>
const stripe = new Stripe('sk_test_release')
const secret = 'whsec_release_test'
beforeAll(async () => {
db = await mockDB(schema)
await db.insert(schema.user).values({ id: 'release-user', name: 'Release', email: 'release@example.com' })
})
beforeEach(async () => {
await db.delete(schema.fluxTransaction)
await db.delete(schema.userFlux)
await db.delete(schema.paymentOrder)
await db.delete(schema.paymentCustomer)
await db.delete(schema.stripeCheckoutSession)
const redis = createTestRedis()
payment = createPaymentService(db, createBillingService(db, redis, createConfigKVService(createConfigKVStore(db, redis))))
webhook = createWebhookOperation(stripe, secret, payment, db, null, null)
})
async function deliver(type: string, session: { id: string, payment_status: string, metadata?: { payment_order_id: string } }) {
const payload = JSON.stringify({ id: `evt_${type}`, object: 'event', type, data: { object: {
object: 'checkout.session',
mode: 'payment',
status: 'complete',
amount_total: 300,
currency: 'usd',
customer: 'cus_release',
...session,
} } })
const signature = stripe.webhooks.generateTestHeaderString({ payload, secret })
return webhook(signature, payload)
}
async function pending() {
return payment.openPending({ userId: 'release-user', processor: 'stripe', packKey: 'flux-500', fluxAmount: 500 })
}
it('does not grant an unpaid completed session', async () => {
const order = await pending()
await deliver('checkout.session.completed', { id: 'cs_unpaid', payment_status: 'unpaid', metadata: { payment_order_id: order.id } })
expect(await db.select().from(schema.fluxTransaction)).toHaveLength(0)
const [stored] = await db.select().from(schema.paymentOrder)
expect(stored.status).toBe('pending')
})
it('grants async success exactly once', async () => {
const order = await pending()
const session = { id: 'cs_async', payment_status: 'paid', metadata: { payment_order_id: order.id } }
await deliver('checkout.session.async_payment_succeeded', session)
await deliver('checkout.session.async_payment_succeeded', session)
const ledger = await db.select().from(schema.fluxTransaction)
expect(ledger).toHaveLength(1)
expect(ledger[0].amount).toBe(500)
})
it('adopts a session created by an old replica after migration', async () => {
await db.insert(schema.stripeCheckoutSession).values({
id: 'old-order',
userId: 'release-user',
stripeSessionId: 'cs_old',
mode: 'payment',
metadata: JSON.stringify({ userId: 'release-user', fluxAmount: '500' }),
})
await deliver('checkout.session.completed', { id: 'cs_old', payment_status: 'paid' })
await deliver('checkout.session.completed', { id: 'cs_old', payment_status: 'paid' })
const ledger = await db.select().from(schema.fluxTransaction)
expect(ledger).toHaveLength(1)
expect(ledger[0].amount).toBe(500)
})
it('does not mutate archived orders or grant after deletion', async () => {
const order = await pending()
await payment.deleteAllForUser('release-user')
await deliver('checkout.session.completed', { id: 'cs_deleted', payment_status: 'paid', metadata: { payment_order_id: order.id } })
expect(await db.select().from(schema.fluxTransaction)).toHaveLength(0)
const [stored] = await db.select().from(schema.paymentOrder).where(eq(schema.paymentOrder.id, order.id))
expect(stored.status).toBe('pending')
expect(await db.select().from(schema.paymentCustomer)).toHaveLength(0)
})
it('does not credit again when an old replica settled after the migration snapshot', async () => {
const order = await pending()
await payment.bindProcessorOrder(order.id, { processorOrderId: 'cs_old_paid' })
await db.insert(schema.stripeCheckoutSession).values({ id: 'old-paid', userId: 'release-user', stripeSessionId: 'cs_old_paid', mode: 'payment', fluxCredited: true })
await deliver('checkout.session.completed', { id: 'cs_old_paid', payment_status: 'paid' })
expect(await db.select().from(schema.fluxTransaction)).toHaveLength(0)
const [stored] = await db.select().from(schema.paymentOrder)
expect(stored.status).toBe('paid')
})
it('rejects a receipt for a different processor', async () => {
const order = await pending()
await expect(payment.settle({ kind: 'claim', processor: 'steam', paymentOrderId: order.id, processorOrderId: 'other', status: 'paid' })).rejects.toThrow('Payment receipt does not match order')
expect(await db.select().from(schema.fluxTransaction)).toHaveLength(0)
})
})
@@ -0,0 +1,78 @@
import type { ConfigDefinitions } from '../../services/adapters/config-kv'
import { describe, expect, it, vi } from 'vitest'
import { createTestRedis } from '../../libs/tests/redis'
import { listStripePackages } from './price-catalog'
const starterPack: ConfigDefinitions['FLUX_PACKS'][number] = {
key: 'starter',
name: '500 Flux',
fluxAmount: 500,
recommended: true,
processors: { stripe: { priceId: 'price_starter' } },
}
function createStripe(retrieve: ReturnType<typeof vi.fn>) {
return {
prices: { retrieve },
} as never
}
describe('listStripePackages', () => {
it('lists Stripe prices including extra currencies', async () => {
const retrieve = vi.fn(async (priceId: string) => ({
id: priceId,
currency: 'usd',
unit_amount: 500,
currency_options: { jpy: { unit_amount: 500 } },
}))
await expect(listStripePackages(createStripe(retrieve), createTestRedis(), [starterPack])).resolves.toEqual([{
packKey: 'starter',
stripePriceId: 'price_starter',
label: '500 Flux',
defaultCurrency: 'usd',
currencies: { usd: '$5.00', jpy: '¥500' },
recommended: true,
}])
})
it('reuses the Stripe price cache for the same price id set', async () => {
const retrieve = vi.fn(async () => ({
id: 'price_starter',
currency: 'usd',
unit_amount: 500,
currency_options: {},
}))
const redis = createTestRedis()
await listStripePackages(createStripe(retrieve), redis, [starterPack])
await listStripePackages(createStripe(retrieve), redis, [starterPack])
expect(retrieve).toHaveBeenCalledTimes(1)
})
it('skips a pack when price lookup fails', async () => {
const retrieve = vi.fn(async () => {
throw new Error('no such price')
})
await expect(listStripePackages(createStripe(retrieve), createTestRedis(), [starterPack])).resolves.toEqual([])
})
})
// https://github.com/moeru-ai/airi/pull/2335
it('refreshes a renamed pack without waiting for the price cache', async () => {
const retrieve = vi.fn(async () => ({ id: 'price_starter', currency: 'usd', unit_amount: 500, currency_options: {} }))
const redis = createTestRedis()
await listStripePackages(createStripe(retrieve), redis, [starterPack])
const items = await listStripePackages(createStripe(retrieve), redis, [{ ...starterPack, key: 'renamed', name: 'New name', recommended: false }])
expect(items[0]).toMatchObject({ packKey: 'renamed', label: 'New name', recommended: false })
})
it('does not cache a transient lookup failure', async () => {
const retrieve = vi.fn().mockRejectedValueOnce(new Error('timeout')).mockResolvedValue({ id: 'price_starter', currency: 'usd', unit_amount: 500, currency_options: {} })
const redis = createTestRedis()
await listStripePackages(createStripe(retrieve), redis, [starterPack])
expect(await listStripePackages(createStripe(retrieve), redis, [starterPack])).toHaveLength(1)
})
@@ -1,140 +1,85 @@
import type Redis from 'ioredis'
import type Stripe from 'stripe'
import { useLogger } from '@guiiai/logg'
import type { ConfigDefinitions } from '../../services/adapters/config-kv'
import { useLogger } from '@guiiai/logg'
import { array, boolean, object, record, safeParse, string } from 'valibot'
import { formatPrice } from '../../utils/format-price'
import { redisKeyFrom } from '../../utils/redis-keys'
const logger = useLogger('stripe')
const logger = useLogger('stripe.catalog')
const PRICES_CACHE_KEY = redisKeyFrom('cache', 'stripe', 'prices')
/** Display prices stay 5 minutes old. */
const PRICES_CACHE_TTL_SEC = 5 * 60
const PRICES_CACHE_KEY = redisKeyFrom('cache', 'stripe', 'prices', 'v2')
interface CachedCurrencyOption {
unitAmount: number | null
}
const packageSchema = object({
packKey: string(),
stripePriceId: string(),
label: string(),
defaultCurrency: string(),
currencies: record(string(), string()),
recommended: boolean(),
})
const cacheSchema = object({ cacheKey: string(), items: array(packageSchema) })
export interface CachedPrice {
id: string
unitAmount: number | null
currency: string
product: string
active: boolean
metadata: Record<string, string>
currencyOptions: Record<string, CachedCurrencyOption>
}
export async function listStripePackages(
stripe: Stripe | null,
redis: Redis,
packs: ConfigDefinitions['FLUX_PACKS'],
) {
if (!stripe)
return []
export interface StripePriceCatalog {
getActivePrices: (productId: string) => Promise<CachedPrice[]>
findActivePrice: (productId: string, stripePriceId: string) => Promise<CachedPrice | null>
}
/**
* Creates a Stripe price catalog backed by Redis.
*
* Use when:
* - Listing public Flux packages.
* - Validating checkout price ids before creating Stripe sessions.
*
* Expects:
* - A configured Stripe client and Redis connection.
*
* Returns:
* - Cached active prices for a single configured product.
*/
export function createStripePriceCatalog(stripe: Stripe, redis: Redis): StripePriceCatalog {
return {
async getActivePrices(productId: string): Promise<CachedPrice[]> {
const cached = await redis.get(PRICES_CACHE_KEY)
if (cached) {
try {
const parsed = JSON.parse(cached) as { productId: string, prices: CachedPrice[] }
if (parsed.productId === productId)
return parsed.prices
}
catch { /* corrupted cache, refetch */ }
}
let result: Stripe.ApiList<Stripe.Price>
try {
result = await stripe.prices.list({ product: productId, active: true, expand: ['data.currency_options'] })
}
catch (err) {
logger.withError(err).warn('Failed to fetch prices from Stripe')
return []
}
const prices = result.data
.sort((a, b) => (a.unit_amount ?? 0) - (b.unit_amount ?? 0))
.map(toCachedPrice)
await redis.set(PRICES_CACHE_KEY, JSON.stringify({ productId, prices }), 'EX', PRICES_CACHE_TTL_SEC)
return prices
},
async findActivePrice(productId: string, stripePriceId: string): Promise<CachedPrice | null> {
// Validate against cached prices first, fall back to direct Stripe API.
const cachedPrices = await this.getActivePrices(productId)
const cached = cachedPrices.find(p => p.id === stripePriceId)
if (cached)
return cached
// Cache miss — price may have just been created.
let fetched: Stripe.Price
try {
fetched = await stripe.prices.retrieve(stripePriceId)
}
catch {
return null
}
const fetchedProductId = typeof fetched.product === 'string' ? fetched.product : fetched.product.id
if (!fetched.active || fetchedProductId !== productId)
return null
// Invalidate cache so all instances pick up the new price.
await redis.del(PRICES_CACHE_KEY)
return toCachedPrice(fetched)
},
const cacheKey = JSON.stringify(packs)
const cached = await redis.get(PRICES_CACHE_KEY)
if (cached) {
try {
const parsed = safeParse(cacheSchema, JSON.parse(cached))
if (parsed.success && parsed.output.cacheKey === cacheKey)
return parsed.output.items
}
catch { /* corrupted cache, refetch */ }
}
}
function toCachedPrice(price: Stripe.Price): CachedPrice {
return {
id: price.id,
unitAmount: price.unit_amount,
currency: price.currency,
product: typeof price.product === 'string' ? price.product : price.product.id,
active: price.active,
metadata: price.metadata,
currencyOptions: Object.fromEntries(
Object.entries(price.currency_options ?? {}).map(([cur, opt]) => [cur, { unitAmount: opt.unit_amount }]),
),
}
}
const items = []
let complete = true
for (const pack of packs) {
const priceId = pack.processors.stripe?.priceId
if (!priceId)
continue
/**
* Format Stripe smallest-unit amount into a human-readable price string.
*
* Before:
* - `300, "usd"`
* - `500, "jpy"`
*
* After:
* - `"$3.00"`
* - `"¥500"`
*/
export function formatPrice(unitAmount: number | null, currency: string): string {
if (unitAmount == null)
return currency.toUpperCase()
let price: Stripe.Price
try {
price = await stripe.prices.retrieve(priceId, { expand: ['currency_options'] })
}
catch (error) {
complete = false
logger.withError(error).withFields({ priceId, packKey: pack.key }).warn('Stripe price lookup skipped')
continue
}
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()}`
const currencies: Record<string, string> = {
[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: priceId,
label: pack.name,
defaultCurrency: price.currency,
currencies,
recommended: pack.recommended,
})
}
// A transient provider failure must not hide a pack for the full cache TTL.
if (complete)
await redis.set(PRICES_CACHE_KEY, JSON.stringify({ cacheKey, items }), 'EX', PRICES_CACHE_TTL_SEC)
return items
}
+185 -583
View File
@@ -1,153 +1,101 @@
import type { StripeCheckoutSession, StripeInvoice } from '../../schemas/stripe'
import type { Database } from '../../libs/db'
import type { ConfigKVService } from '../../services/adapters/config-kv'
import type { BillingService } from '../../services/domain/billing/billing-service'
import type { FluxService } from '../../services/domain/flux'
import type { StripeService } from '../../services/domain/stripe'
import type { PaymentService } from '../../services/domain/payment'
import type { HonoEnv } from '../../types/hono'
import { Hono } from 'hono'
import { describe, expect, it, vi } from 'vitest'
import { createStripeRoutes, formatPrice } from '.'
import { createStripeRoutes } from '.'
import { createTestRedis } from '../../libs/tests/redis'
import { ApiError } from '../../utils/error'
import { createCheckoutOperation } from './operations/checkout'
import { createWebhookOperation } from './operations/webhook'
// --- Mock helpers ---
function createMockFluxService(): FluxService {
function unusedWebhookDb(): Database {
return {
getFlux: vi.fn(async () => ({ userId: 'user-1', flux: 100 })),
updateStripeCustomerId: vi.fn(),
} as any
select: () => {
throw new Error('db should not be queried for new Sessions')
},
} as unknown as Database
}
function createMockStripeService(overrides: Partial<StripeService> = {}): StripeService {
function webhookDbWithoutOrder(): Database {
return {
upsertCustomer: vi.fn(async data => ({ id: 'id-1', createdAt: new Date(), updatedAt: new Date(), ...data })),
getCustomerByUserId: vi.fn(async () => undefined),
getCustomerByStripeId: vi.fn(async () => undefined),
upsertCheckoutSession: vi.fn(async data => ({ id: 'id-1', fluxCredited: false, createdAt: new Date(), updatedAt: new Date(), ...data })),
getCheckoutSessionsByUserId: vi.fn(async () => []),
upsertSubscription: vi.fn(async data => ({ id: 'id-1', createdAt: new Date(), updatedAt: new Date(), ...data })),
getActiveSubscription: vi.fn(async () => undefined),
upsertInvoice: vi.fn(async data => ({ id: 'id-1', fluxCredited: false, createdAt: new Date(), updatedAt: new Date(), ...data })),
getInvoicesByUserId: vi.fn(async () => []),
...overrides,
} as any
}
function createMockStripeCustomer(
overrides: Partial<NonNullable<Awaited<ReturnType<StripeService['getCustomerByStripeId']>>>> = {},
): NonNullable<Awaited<ReturnType<StripeService['getCustomerByStripeId']>>> {
const now = new Date()
return {
id: 'stripe-customer-1',
name: null,
email: null,
createdAt: now,
updatedAt: now,
userId: 'user-1',
deletedAt: null,
stripeCustomerId: 'cus_1',
...overrides,
}
}
function createMockBillingService(): BillingService {
return {
debitFlux: vi.fn(),
creditFlux: vi.fn(),
creditFluxFromStripeCheckout: vi.fn(async () => ({ applied: true, balanceAfter: 500 })),
creditFluxFromInvoice: vi.fn(async () => ({ applied: true, balanceAfter: 500 })),
} as any
}
function createMockConfigKV(overrides: Record<string, any> = {}): ConfigKVService {
const defaults: Record<string, any> = {
STRIPE_FLUX_PRODUCT_ID: 'prod_test_flux',
STRIPE_PAYMENT_METHODS: ['card'],
...overrides,
}
return {
getOrThrow: vi.fn(async (key: string) => {
if (defaults[key] === undefined)
throw new Error(`Config key "${key}" is not set`)
return defaults[key]
select: () => ({
from: () => ({
where: () => ({
limit: async () => [],
}),
}),
}),
getOptional: vi.fn(async (key: string) => defaults[key] ?? null),
get: vi.fn(async (key: string) => defaults[key]),
set: vi.fn(),
} as any
} as unknown as Database
}
function createMockPayment(overrides: Partial<PaymentService> = {}): PaymentService {
return {
openPending: vi.fn(async () => ({ id: 'po_mock' })),
bindProcessorOrder: vi.fn(async () => {}),
abandon: vi.fn(async () => {}),
settle: vi.fn(async () => ({ applied: true, userId: 'user-1', fluxAmount: 500, balanceAfter: 500 })),
deleteAllForUser: vi.fn(),
...overrides,
}
}
function createMockConfigKV(overrides: Partial<ConfigKVService> = {}): ConfigKVService {
return {
getOptional: vi.fn(async (key: string) => {
if (key === 'FLUX_PACKS') {
return [{
key: 'starter',
name: '500 Flux',
fluxAmount: 500,
recommended: false,
processors: { stripe: { priceId: 'price_test_500' } },
}]
}
return null
}),
getOrThrow: vi.fn(),
get: vi.fn(),
refresh: vi.fn(),
invalidateCache: vi.fn(),
...overrides,
} as ConfigKVService
}
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-1', name: 'Test User', email: 'test@example.com' }
function createCheckoutSession(overrides: Partial<StripeCheckoutSession> = {}): StripeCheckoutSession {
return {
id: 'checkout-1',
userId: 'user-1',
stripeSessionId: 'cs_1',
stripeCustomerId: null,
mode: 'payment',
status: 'open',
paymentStatus: null,
amountTotal: 500,
currency: 'usd',
successUrl: 'http://localhost/success',
cancelUrl: 'http://localhost/cancel',
stripePaymentIntentId: null,
stripeSubscriptionId: null,
fluxCredited: false,
metadata: null,
expiresAt: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
...overrides,
}
}
function createInvoice(overrides: Partial<StripeInvoice> = {}): StripeInvoice {
return {
id: 'invoice-1',
userId: 'user-1',
stripeInvoiceId: 'inv_1',
stripeCustomerId: null,
stripeSubscriptionId: null,
status: 'paid',
amountDue: 500,
amountPaid: 500,
currency: 'usd',
invoiceUrl: null,
invoicePdf: null,
periodStart: null,
periodEnd: null,
paidAt: null,
fluxCredited: false,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
...overrides,
}
}
function createTestApp(
fluxService: FluxService,
stripeService: StripeService,
billingService: BillingService,
configKV: ConfigKVService,
payment: PaymentService,
envOverrides: Record<string, any> = {},
stripe: any = {
prices: { retrieve: vi.fn() },
checkout: { sessions: { create: vi.fn() } },
webhooks: { constructEvent: vi.fn() },
},
configKV: ConfigKVService = createMockConfigKV(),
) {
const routes = createStripeRoutes(fluxService, stripeService, billingService, configKV, { ...testEnv, ...envOverrides }, createTestRedis())
const stripeClient = envOverrides.STRIPE_SECRET_KEY === '' ? null : stripe
const routes = createStripeRoutes(
payment,
{} as never,
stripeClient,
createTestRedis(),
configKV,
{ ...testEnv, ...envOverrides },
null,
null,
null,
)
const app = new Hono<HonoEnv>()
app.onError((err, c) => {
@@ -161,12 +109,10 @@ function createTestApp(
return c.json({ error: 'Internal Server Error', message: err.message }, 500)
})
// Inject user from env (simulates sessionMiddleware)
app.use('*', async (c, next) => {
const user = (c.env as any)?.user
if (user) {
if (user)
c.set('user', user)
}
await next()
})
@@ -174,97 +120,48 @@ function createTestApp(
return app
}
// --- Tests ---
describe('formatPrice', () => {
it('formats USD cents correctly', () => {
expect(formatPrice(300, 'usd')).toBe('$3.00')
expect(formatPrice(1200, 'usd')).toBe('$12.00')
expect(formatPrice(2500, 'usd')).toBe('$25.00')
})
it('formats CNY cents correctly', () => {
expect(formatPrice(2100, 'cny')).toBe('CN¥21.00')
})
it('formats JPY (zero-decimal currency) correctly', () => {
expect(formatPrice(500, 'jpy')).toBe('¥500')
})
it('formats GBP correctly', () => {
expect(formatPrice(1599, 'gbp')).toBe('£15.99')
})
it('returns currency code for null amount', () => {
expect(formatPrice(null, 'usd')).toBe('USD')
})
it('handles zero amount', () => {
expect(formatPrice(0, 'usd')).toBe('$0.00')
})
})
describe('stripeRoutes', () => {
describe('gET /api/v1/stripe/packages', () => {
it('returns empty array when Stripe is not configured', async () => {
const app = createTestApp(
createMockFluxService(),
createMockStripeService(),
createMockBillingService(),
createMockConfigKV({ STRIPE_FLUX_PRODUCT_ID: undefined }),
{ STRIPE_SECRET_KEY: '' },
)
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)
expect(await res.json()).toEqual([])
expect(await res.json()).toEqual([{
packKey: 'starter',
stripePriceId: 'price_test_500',
label: '500 Flux',
defaultCurrency: 'usd',
currencies: { usd: '$5.00' },
recommended: false,
}])
})
})
describe('pOST /api/v1/stripe/checkout', () => {
it('returns 401 when unauthenticated', async () => {
const app = createTestApp(
createMockFluxService(),
createMockStripeService(),
createMockBillingService(),
createMockConfigKV(),
)
const app = createTestApp(createMockPayment())
const res = await app.request('/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stripePriceId: 'price_test_500' }),
body: JSON.stringify({ packKey: 'starter' }),
})
expect(res.status).toBe(401)
})
it('returns 400 for empty stripePriceId', async () => {
const app = createTestApp(
createMockFluxService(),
createMockStripeService(),
createMockBillingService(),
createMockConfigKV(),
)
const res = await app.fetch(
new Request('http://localhost/api/v1/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stripePriceId: '' }),
}),
{ user: testUser } as any,
)
expect(res.status).toBe(400)
})
it('returns 400 for missing stripePriceId', async () => {
const app = createTestApp(
createMockFluxService(),
createMockStripeService(),
createMockBillingService(),
createMockConfigKV(),
)
it('returns 400 for an empty body', async () => {
const app = createTestApp(createMockPayment())
const res = await app.fetch(
new Request('http://localhost/api/v1/stripe/checkout', {
method: 'POST',
@@ -275,258 +172,41 @@ describe('stripeRoutes', () => {
)
expect(res.status).toBe(400)
})
it('returns 503 when Stripe is not configured', async () => {
const app = createTestApp(
createMockFluxService(),
createMockStripeService(),
createMockBillingService(),
createMockConfigKV({ STRIPE_FLUX_PRODUCT_ID: undefined }),
{ STRIPE_SECRET_KEY: '' },
)
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(503)
})
it('stores browser OpenPanel identity in Stripe checkout metadata', async () => {
const createSession = vi.fn(async input => ({
id: 'cs_1',
url: 'https://checkout.stripe.com/cs_1',
customer: null,
mode: 'payment',
status: 'open',
payment_status: 'unpaid',
amount_total: 500,
currency: 'usd',
success_url: 'http://localhost/settings/flux?success=true',
cancel_url: 'http://localhost/settings/flux?canceled=true',
payment_intent: null,
subscription: null,
metadata: input.metadata,
expires_at: null,
}))
const productEventService = { track: vi.fn() }
const operation = createCheckoutOperation({
stripe: {
checkout: {
sessions: {
create: createSession,
},
},
} as any,
priceCatalog: {
findActivePrice: vi.fn(async () => ({
id: 'price_test_500',
currency: 'usd',
unitAmount: 500,
currencyOptions: {},
metadata: { fluxAmount: '500' },
})),
getActivePrices: vi.fn(),
} as any,
stripeService: createMockStripeService(),
configKV: createMockConfigKV({ STRIPE_PAYMENT_METHODS: undefined }),
env: testEnv,
productEventService: productEventService as any,
})
await operation({
user: testUser as any,
body: { stripePriceId: 'price_test_500' },
request: new Request('http://localhost/api/v1/stripe/checkout', {
headers: {
'x-openpanel-device-id': 'anon-browser-1',
'x-openpanel-session-id': 'ph-session-1',
},
}),
})
expect(createSession).toHaveBeenCalledWith(expect.objectContaining({
metadata: {
userId: 'user-1',
fluxAmount: '500',
openpanelDeviceId: 'anon-browser-1',
openpanelSessionId: 'ph-session-1',
},
}))
expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1',
action: 'checkout_started',
metadata: expect.objectContaining({
openpanel_device_id: 'anon-browser-1',
openpanel_session_id: 'ph-session-1',
}),
}))
})
})
describe('gET /api/v1/stripe/orders', () => {
it('returns 401 when unauthenticated', async () => {
const app = createTestApp(
createMockFluxService(),
createMockStripeService(),
createMockBillingService(),
createMockConfigKV(),
)
const res = await app.request('/api/v1/stripe/orders')
expect(res.status).toBe(401)
})
it('returns checkout sessions for the authenticated user', async () => {
const mockSessions = [
createCheckoutSession({ id: '1', stripeSessionId: 'cs_1', status: 'complete' }),
createCheckoutSession({ id: '2', stripeSessionId: 'cs_2', status: 'open' }),
]
const stripeService = createMockStripeService({
getCheckoutSessionsByUserId: vi.fn(async () => mockSessions),
})
const app = createTestApp(
createMockFluxService(),
stripeService,
createMockBillingService(),
createMockConfigKV(),
)
const res = await app.fetch(
new Request('http://localhost/api/v1/stripe/orders'),
{ user: testUser } as any,
)
expect(res.status).toBe(200)
const data = await res.json()
expect(data).toHaveLength(2)
expect(stripeService.getCheckoutSessionsByUserId).toHaveBeenCalledWith('user-1')
})
})
describe('gET /api/v1/stripe/invoices', () => {
it('returns 401 when unauthenticated', async () => {
const app = createTestApp(
createMockFluxService(),
createMockStripeService(),
createMockBillingService(),
createMockConfigKV(),
)
const res = await app.request('/api/v1/stripe/invoices')
expect(res.status).toBe(401)
})
it('returns invoices for the authenticated user', async () => {
const mockInvoices = [createInvoice({ id: '1', stripeInvoiceId: 'inv_1', status: 'paid' })]
const stripeService = createMockStripeService({
getInvoicesByUserId: vi.fn(async () => mockInvoices),
})
const app = createTestApp(
createMockFluxService(),
stripeService,
createMockBillingService(),
createMockConfigKV(),
)
const res = await app.fetch(
new Request('http://localhost/api/v1/stripe/invoices'),
{ user: testUser } as any,
)
expect(res.status).toBe(200)
const data = await res.json()
expect(data).toHaveLength(1)
expect(stripeService.getInvoicesByUserId).toHaveBeenCalledWith('user-1')
})
})
describe('pOST /api/v1/stripe/portal', () => {
it('returns 401 when unauthenticated', async () => {
const app = createTestApp(
createMockFluxService(),
createMockStripeService(),
createMockBillingService(),
createMockConfigKV(),
)
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 () => {
const stripeService = createMockStripeService({
getCustomerByUserId: vi.fn(async () => undefined),
})
const app = createTestApp(
createMockFluxService(),
stripeService,
createMockBillingService(),
createMockConfigKV(),
)
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')
})
})
describe('pOST /api/v1/stripe/webhook', () => {
it('returns 400 when signature is missing', async () => {
const app = createTestApp(
createMockFluxService(),
createMockStripeService(),
createMockBillingService(),
createMockConfigKV(),
)
const app = createTestApp(createMockPayment())
const res = await app.request('/api/v1/stripe/webhook', {
method: 'POST',
body: '{}',
})
expect(res.status).toBe(400)
const data = await res.json() as any
expect(data.error).toBe('MISSING_SIGNATURE')
})
it('returns 400 when signature is invalid', async () => {
const app = createTestApp(
createMockFluxService(),
createMockStripeService(),
createMockBillingService(),
createMockConfigKV(),
)
const stripe = {
webhooks: {
constructEvent: vi.fn(() => {
throw new Error('bad sig')
}),
},
}
const app = createTestApp(createMockPayment(), {}, stripe)
const res = await app.request('/api/v1/stripe/webhook', {
method: 'POST',
headers: { 'stripe-signature': 'invalid_sig' },
body: '{}',
})
expect(res.status).toBe(400)
const data = await res.json() as any
expect(data.error).toBe('WEBHOOK_ERROR')
})
it('returns 503 when Stripe is not configured', async () => {
const app = createTestApp(
createMockFluxService(),
createMockStripeService(),
createMockBillingService(),
createMockConfigKV(),
{ STRIPE_SECRET_KEY: '', STRIPE_WEBHOOK_SECRET: '' },
)
const app = createTestApp(createMockPayment(), { STRIPE_SECRET_KEY: '', STRIPE_WEBHOOK_SECRET: '' })
const res = await app.request('/api/v1/stripe/webhook', {
method: 'POST',
headers: { 'stripe-signature': 'test_sig' },
@@ -535,7 +215,7 @@ describe('stripeRoutes', () => {
expect(res.status).toBe(503)
})
it('records payment completion with Stripe and OpenPanel identity from checkout metadata', async () => {
it('settles a paid checkout session', async () => {
const checkoutEvent = {
id: 'evt_checkout_completed',
type: 'checkout.session.completed',
@@ -543,183 +223,105 @@ describe('stripeRoutes', () => {
object: {
id: 'cs_1',
customer: 'cus_1',
customer_email: 'test@example.com',
mode: 'payment',
status: 'complete',
payment_status: 'paid',
amount_total: 500,
currency: 'usd',
success_url: 'http://localhost/settings/flux?success=true',
cancel_url: 'http://localhost/settings/flux?canceled=true',
payment_intent: 'pi_1',
subscription: null,
metadata: {
userId: 'user-1',
fluxAmount: '500',
payment_order_id: 'po_1',
packKey: 'starter',
openpanelDeviceId: 'anon-browser-1',
openpanelSessionId: 'ph-session-1',
},
expires_at: null,
},
},
}
const payment = createMockPayment()
const productEventService = { track: vi.fn() }
const billingService = createMockBillingService()
const webhook = createWebhookOperation({
stripe: {
const webhook = createWebhookOperation(
{
webhooks: {
constructEvent: vi.fn(() => checkoutEvent),
},
} as any,
webhookSecret: 'whsec_test',
fluxService: createMockFluxService(),
stripeService: createMockStripeService(),
billingService,
productEventService: productEventService as any,
})
'whsec_test',
payment,
unusedWebhookDb(),
null,
productEventService as any,
)
await webhook({ signature: 'test_sig', body: '{}' })
await webhook('test_sig', '{}')
expect(billingService.creditFluxFromStripeCheckout).toHaveBeenCalledWith(expect.objectContaining({
stripeEventId: 'evt_checkout_completed',
userId: 'user-1',
stripeSessionId: 'cs_1',
fluxAmount: 500,
}))
expect(productEventService.track).toHaveBeenCalledWith({
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
eventId: 'cs_1',
source: 'stripe.webhook',
metadata: {
amount_total: 500,
currency: 'usd',
flux_amount: 500,
stripe_checkout_session_id: 'cs_1',
stripe_customer_id: 'cus_1',
openpanel_device_id: 'anon-browser-1',
openpanel_session_id: 'ph-session-1',
},
})
vi.mocked(billingService.creditFluxFromStripeCheckout).mockResolvedValueOnce({ applied: false })
await webhook({ signature: 'test_sig', body: '{}' })
expect(productEventService.track).toHaveBeenCalledTimes(1)
})
it('processes subscription lifecycle webhooks without product events', async () => {
const subscriptionEvent = {
id: 'evt_sub_created',
type: 'customer.subscription.created',
data: {
object: {
id: 'sub_1',
customer: 'cus_1',
status: 'active',
items: {
data: [{
price: { id: 'price_1' },
current_period_start: 1_000,
current_period_end: 2_000,
}],
},
cancel_at_period_end: false,
canceled_at: null,
ended_at: null,
metadata: {},
},
},
}
const stripeService = createMockStripeService({
getCustomerByStripeId: vi.fn(async () => createMockStripeCustomer()),
})
const productEventService = { track: vi.fn(async () => undefined) }
const webhook = createWebhookOperation({
stripe: {
webhooks: {
constructEvent: vi.fn(() => subscriptionEvent),
},
} as any,
webhookSecret: 'whsec_test',
fluxService: createMockFluxService(),
stripeService,
billingService: createMockBillingService(),
productEventService: productEventService as any,
})
await webhook({ signature: 'test_sig', body: '{}' })
expect(stripeService.upsertSubscription).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1',
stripeSubscriptionId: 'sub_1',
stripeCustomerId: 'cus_1',
stripePriceId: 'price_1',
status: 'active',
cancelAtPeriodEnd: false,
}))
expect(productEventService.track).not.toHaveBeenCalled()
})
it('records subscription renewals only for subscription-cycle paid invoices', async () => {
const invoiceEvent = {
id: 'evt_invoice_paid',
type: 'invoice.paid',
data: {
object: {
id: 'inv_1',
customer: 'cus_1',
parent: {
subscription_details: {
subscription: 'sub_1',
},
},
billing_reason: 'subscription_cycle',
status: 'paid',
amount_due: 1_200,
amount_paid: 1_200,
currency: 'usd',
hosted_invoice_url: null,
invoice_pdf: null,
period_start: 1_000,
period_end: 2_000,
status_transitions: {
paid_at: 1_500,
},
metadata: {},
},
},
}
const stripeService = createMockStripeService({
getCustomerByStripeId: vi.fn(async () => createMockStripeCustomer()),
})
const productEventService = { track: vi.fn(async () => undefined) }
const webhook = createWebhookOperation({
stripe: {
webhooks: {
constructEvent: vi.fn(() => invoiceEvent),
},
} as any,
webhookSecret: 'whsec_test',
fluxService: createMockFluxService(),
stripeService,
billingService: createMockBillingService(),
productEventService: productEventService as any,
})
await webhook({ signature: 'test_sig', body: '{}' })
expect(stripeService.upsertInvoice).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1',
stripeInvoiceId: 'inv_1',
stripeCustomerId: 'cus_1',
stripeSubscriptionId: 'sub_1',
expect(payment.settle).toHaveBeenCalledWith(expect.objectContaining({
kind: 'claim',
processor: 'stripe',
paymentOrderId: 'po_1',
processorOrderId: 'cs_1',
status: 'paid',
amountDue: 1_200,
amountPaid: 1_200,
}))
expect(productEventService.track).not.toHaveBeenCalled()
expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({
action: 'payment_completed',
metadata: expect.objectContaining({
openpanel_device_id: 'anon-browser-1',
pack_key: 'starter',
}),
}))
})
it('ignores unknown events and does not settle', async () => {
const payment = createMockPayment()
const webhook = createWebhookOperation(
{
webhooks: {
constructEvent: vi.fn(() => ({
id: 'evt_charge',
type: 'charge.succeeded',
data: { object: { id: 'ch_1' } },
})),
},
} as any,
'whsec_test',
payment,
unusedWebhookDb(),
null,
null,
)
await webhook('test_sig', '{}')
expect(payment.settle).not.toHaveBeenCalled()
})
it('acknowledges a checkout session that is not an AIRI order', async () => {
const payment = createMockPayment()
const webhook = createWebhookOperation(
{
webhooks: {
constructEvent: vi.fn(() => ({
id: 'evt_foreign',
type: 'checkout.session.completed',
data: {
object: {
id: 'cs_foreign',
payment_status: 'paid',
mode: 'payment',
status: 'complete',
metadata: {},
},
},
})),
},
} as any,
'whsec_test',
payment,
webhookDbWithoutOrder(),
null,
null,
)
await expect(webhook('test_sig', '{}')).resolves.toEqual({ received: true })
expect(payment.settle).not.toHaveBeenCalled()
})
})
})
+19 -5
View File
@@ -1,6 +1,20 @@
import { minLength, object, optional, pipe, string } from 'valibot'
import { check, minLength, object, optional, pipe, string } from 'valibot'
export const CheckoutBodySchema = object({
stripePriceId: pipe(string(), minLength(1)),
currency: optional(string()),
})
// NOTICE:
// Previous-version clients send stripePriceId. Current clients send packKey.
// Checkout accepts exactly one of these fields so both clients can open a session.
// Remove stripePriceId after previous-version clients ship packKey.
export const CheckoutBodySchema = pipe(
object({
packKey: optional(pipe(string(), minLength(1))),
stripePriceId: optional(pipe(string(), minLength(1))),
currency: optional(string()),
}),
check(
(value) => {
const selected = [value.packKey, value.stripePriceId].filter(Boolean)
return selected.length === 1
},
'Provide exactly one of packKey or stripePriceId',
),
)
+2
View File
@@ -6,6 +6,8 @@ import { bigint, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
export const userFlux = pgTable('user_flux', {
userId: text('user_id').primaryKey(),
flux: bigint('flux', { mode: 'number' }).notNull().default(0),
// NOTICE:
// Unused at runtime. Keep the column so drizzle-kit does not emit DROP.
stripeCustomerId: text('stripe_customer_id'),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
+1
View File
@@ -4,6 +4,7 @@ export * from './config-kv'
export * from './flux'
export * from './flux-transaction'
export * from './llm-request-log'
export * from './payment'
export * from './provider-catalog'
export * from './providers'
export * from './stripe'
+55
View File
@@ -0,0 +1,55 @@
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
import { sql } from 'drizzle-orm'
import { bigint, index, integer, jsonb, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'
import { nanoid } from '../utils/id'
// NOTICE: bare userId is intentional — no FK to user.id. better-auth hard-deletes
// the user row; a cascade would wipe these soft-delete archive rows kept for
// billing audit. See `server/apps/api/docs/ai-context/account-deletion.md`.
export const paymentOrder = pgTable('payment_order', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
userId: text('user_id').notNull(),
processor: text('processor').notNull(),
processorOrderId: text('processor_order_id'),
status: text('status').notNull(),
amount: integer('amount'),
currency: text('currency'),
packKey: text('pack_key'),
fluxAmount: bigint('flux_amount', { mode: 'number' }),
creditedAt: timestamp('credited_at'),
processorData: jsonb('processor_data').$type<Record<string, unknown>>(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
}, table => [
uniqueIndex('payment_order_processor_order_uidx')
.on(table.processor, table.processorOrderId)
.where(sql`processor_order_id IS NOT NULL`),
index('payment_order_user_id_idx').on(table.userId),
])
export const paymentCustomer = pgTable('payment_customer', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
userId: text('user_id').notNull(),
processor: text('processor').notNull(),
customerId: text('customer_id').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
}, table => [
uniqueIndex('payment_customer_processor_customer_uidx')
.on(table.processor, table.customerId)
.where(sql`deleted_at IS NULL`),
uniqueIndex('payment_customer_processor_user_uidx')
.on(table.processor, table.userId)
.where(sql`deleted_at IS NULL`),
index('payment_customer_user_id_idx').on(table.userId),
])
export type PaymentOrder = InferSelectModel<typeof paymentOrder>
export type NewPaymentOrder = InferInsertModel<typeof paymentOrder>
export type PaymentCustomer = InferSelectModel<typeof paymentCustomer>
export type NewPaymentCustomer = InferInsertModel<typeof paymentCustomer>
+13 -70
View File
@@ -1,19 +1,9 @@
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
import { user } from '@proj-airi/auth-shared'
import { relations } from 'drizzle-orm'
import { boolean, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
import { nanoid } from '../utils/id'
// NOTICE: bare userId is intentional — no FK to user.id. better-auth hard-deletes
// the user row; a cascade would wipe these soft-delete archive rows kept for
// audit / billing review.
// See `server/apps/api/docs/ai-context/account-deletion.md`.
/**
* Stripe customers linked to our users.
*/
// NOTICE:
// Unused at runtime. Keep the defs so drizzle-kit does not emit DROP.
export const stripeCustomer = pgTable('stripe_customer', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
userId: text('user_id').notNull(),
@@ -25,64 +15,55 @@ export const stripeCustomer = pgTable('stripe_customer', {
deletedAt: timestamp('deleted_at'),
})
/**
* Stripe checkout sessions every checkout attempt is recorded.
*/
export const stripeCheckoutSession = pgTable('stripe_checkout_session', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
userId: text('user_id').notNull(),
stripeSessionId: text('stripe_session_id').notNull().unique(),
stripeCustomerId: text('stripe_customer_id'),
mode: text('mode').notNull(), // 'payment' | 'subscription' | 'setup'
status: text('status'), // 'open' | 'complete' | 'expired'
paymentStatus: text('payment_status'), // 'paid' | 'unpaid' | 'no_payment_required'
amountTotal: integer('amount_total'), // in cents
mode: text('mode').notNull(),
status: text('status'),
paymentStatus: text('payment_status'),
amountTotal: integer('amount_total'),
currency: text('currency'),
successUrl: text('success_url'),
cancelUrl: text('cancel_url'),
stripePaymentIntentId: text('stripe_payment_intent_id'),
stripeSubscriptionId: text('stripe_subscription_id'),
fluxCredited: boolean('flux_credited').notNull().default(false),
metadata: text('metadata'), // JSON stringified
metadata: text('metadata'),
expiresAt: timestamp('expires_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
})
/**
* Stripe subscriptions.
*/
export const stripeSubscription = pgTable('stripe_subscription', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
userId: text('user_id').notNull(),
stripeSubscriptionId: text('stripe_subscription_id').notNull().unique(),
stripeCustomerId: text('stripe_customer_id').notNull(),
stripePriceId: text('stripe_price_id'),
status: text('status').notNull(), // 'active' | 'past_due' | 'canceled' | 'incomplete' | etc
status: text('status').notNull(),
currentPeriodStart: timestamp('current_period_start'),
currentPeriodEnd: timestamp('current_period_end'),
cancelAtPeriodEnd: boolean('cancel_at_period_end'),
canceledAt: timestamp('canceled_at'),
endedAt: timestamp('ended_at'),
metadata: text('metadata'), // JSON stringified
metadata: text('metadata'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
})
/**
* Stripe invoices both one-time and subscription invoices.
*/
export const stripeInvoice = pgTable('stripe_invoice', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
userId: text('user_id').notNull(),
stripeInvoiceId: text('stripe_invoice_id').notNull().unique(),
stripeCustomerId: text('stripe_customer_id'),
stripeSubscriptionId: text('stripe_subscription_id'),
status: text('status'), // 'draft' | 'open' | 'paid' | 'uncollectible' | 'void'
amountDue: integer('amount_due'), // in cents
amountPaid: integer('amount_paid'), // in cents
status: text('status'),
amountDue: integer('amount_due'),
amountPaid: integer('amount_paid'),
currency: text('currency'),
invoiceUrl: text('invoice_url'),
invoicePdf: text('invoice_pdf'),
@@ -90,46 +71,8 @@ export const stripeInvoice = pgTable('stripe_invoice', {
periodEnd: timestamp('period_end'),
paidAt: timestamp('paid_at'),
fluxCredited: boolean('flux_credited').notNull().default(false),
metadata: text('metadata'), // JSON stringified
metadata: text('metadata'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
})
// ---------- Relations ----------
export const stripeCustomerRelations = relations(stripeCustomer, ({ one, many }) => ({
user: one(user, { fields: [stripeCustomer.userId], references: [user.id] }),
checkoutSessions: many(stripeCheckoutSession),
subscriptions: many(stripeSubscription),
invoices: many(stripeInvoice),
}))
export const stripeCheckoutSessionRelations = relations(stripeCheckoutSession, ({ one }) => ({
user: one(user, { fields: [stripeCheckoutSession.userId], references: [user.id] }),
customer: one(stripeCustomer, { fields: [stripeCheckoutSession.stripeCustomerId], references: [stripeCustomer.stripeCustomerId] }),
}))
export const stripeSubscriptionRelations = relations(stripeSubscription, ({ one }) => ({
user: one(user, { fields: [stripeSubscription.userId], references: [user.id] }),
customer: one(stripeCustomer, { fields: [stripeSubscription.stripeCustomerId], references: [stripeCustomer.stripeCustomerId] }),
}))
export const stripeInvoiceRelations = relations(stripeInvoice, ({ one }) => ({
user: one(user, { fields: [stripeInvoice.userId], references: [user.id] }),
customer: one(stripeCustomer, { fields: [stripeInvoice.stripeCustomerId], references: [stripeCustomer.stripeCustomerId] }),
}))
// ---------- Types ----------
export type StripeCustomer = InferSelectModel<typeof stripeCustomer>
export type NewStripeCustomer = InferInsertModel<typeof stripeCustomer>
export type StripeCheckoutSession = InferSelectModel<typeof stripeCheckoutSession>
export type NewStripeCheckoutSession = InferInsertModel<typeof stripeCheckoutSession>
export type StripeSubscription = InferSelectModel<typeof stripeSubscription>
export type NewStripeSubscription = InferInsertModel<typeof stripeSubscription>
export type StripeInvoice = InferSelectModel<typeof stripeInvoice>
export type NewStripeInvoice = InferInsertModel<typeof stripeInvoice>
@@ -1,6 +1,6 @@
import type { InferOutput } from 'valibot'
import { any, array, boolean, check, nonEmpty, number, object, optional, picklist, pipe, record, regex, string } from 'valibot'
import { any, array, boolean, check, minValue, nonEmpty, number, object, optional, picklist, pipe, record, regex, safeInteger, string } from 'valibot'
/**
* LLM/TTS router config tree. Single composite entry under configKV holds the
@@ -233,6 +233,35 @@ export const llmRouterConfigSchema = object({
defaults: llmRouterDefaultsSchema,
})
// Processor mappings stay optional so a pack can exist without a Stripe Price.
// Checkout rejects that pack. `object()` (not `strictObject`) strips unknown
// processor keys, so another channel can write ConfigKV before its schema lands.
const fluxPackSchema = object({
key: pipe(string(), nonEmpty('FLUX_PACKS[].key must not be empty')),
name: pipe(string(), nonEmpty('FLUX_PACKS[].name must not be empty')),
fluxAmount: pipe(number(), minValue(1, 'FLUX_PACKS[].fluxAmount must be >= 1'), safeInteger()),
recommended: optional(boolean(), false),
processors: optional(object({
stripe: optional(object({
priceId: pipe(string(), nonEmpty('FLUX_PACKS[].processors.stripe.priceId must not be empty')),
})),
}), {}),
})
const fluxPacksSchema = optional(pipe(
array(fluxPackSchema),
check(
packs => new Set(packs.map(pack => pack.key)).size === packs.length,
'FLUX_PACKS[].key must be unique',
),
check((packs) => {
const priceIds = packs
.map(pack => pack.processors.stripe?.priceId)
.filter((priceId): priceId is string => priceId != null)
return new Set(priceIds).size === priceIds.length
}, 'FLUX_PACKS[].processors.stripe.priceId must be unique'),
), [])
/**
* Config entry schemas are the single source of truth for:
* - runtime validation
@@ -247,8 +276,8 @@ export const configEntrySchemas = {
// Debt-ledger TTL: residual TTS chars below 1 Flux are forgiven on expiry.
// 24h gives users a long-enough window for accumulated dust to settle naturally.
TTS_DEBT_TTL_SECONDS: optional(number(), 86400),
// No default — absent means top-up is not available yet
STRIPE_FLUX_PRODUCT_ID: optional(string()),
// Display prices come from Stripe Price hydration, not ConfigKV strings.
FLUX_PACKS: fluxPacksSchema,
// No default — absent lets Stripe auto-select payment methods via Dashboard config
STRIPE_PAYMENT_METHODS: optional(array(string())),
STRIPE_PAYMENT_METHOD_OPTIONS: optional(record(string(), any()), {}),
@@ -92,6 +92,46 @@ describe('configKVService', () => {
})
})
it('rejects FLUX_PACKS with duplicate keys', async () => {
store._store.set('FLUX_PACKS', JSON.stringify([
{ key: 'starter', name: 'Starter', fluxAmount: 100, processors: { stripe: { priceId: 'price_a' } } },
{ key: 'starter', name: 'Starter 2', fluxAmount: 200, processors: { stripe: { priceId: 'price_b' } } },
]))
await expect(service.getOptional('FLUX_PACKS'))
.rejects
.toMatchObject({
statusCode: 503,
errorCode: 'CONFIG_INVALID',
})
})
it('rejects FLUX_PACKS with duplicate Stripe price ids', async () => {
store._store.set('FLUX_PACKS', JSON.stringify([
{ key: 'starter', name: 'Starter', fluxAmount: 100, processors: { stripe: { priceId: 'price_shared' } } },
{ key: 'plus', name: 'Plus', fluxAmount: 500, processors: { stripe: { priceId: 'price_shared' } } },
]))
await expect(service.getOptional('FLUX_PACKS'))
.rejects
.toMatchObject({
statusCode: 503,
errorCode: 'CONFIG_INVALID',
})
})
it('accepts FLUX_PACKS that omit Stripe when keys are unique', async () => {
store._store.set('FLUX_PACKS', JSON.stringify([
{ key: 'starter', name: 'Starter', fluxAmount: 100 },
{ key: 'plus', name: 'Plus', fluxAmount: 500, processors: { stripe: { priceId: 'price_plus' } } },
]))
await expect(service.getOptional('FLUX_PACKS')).resolves.toEqual([
{ key: 'starter', name: 'Starter', fluxAmount: 100, recommended: false, processors: {} },
{ key: 'plus', name: 'Plus', fluxAmount: 500, recommended: false, processors: { stripe: { priceId: 'price_plus' } } },
])
})
// https://github.com/moeru-ai/airi/pull/2445#discussion_r3913931906
// ROOT CAUSE:
//
@@ -342,10 +382,10 @@ describe('configKVService', () => {
})
it('refresh should bypass the ordinary store read', async () => {
store._store.set('STRIPE_FLUX_PRODUCT_ID', JSON.stringify('prod_abc123'))
store._store.set('FLUX_PER_REQUEST', '9')
await expect(service.refresh('STRIPE_FLUX_PRODUCT_ID')).resolves.toBe('prod_abc123')
expect(store.getFreshRaw).toHaveBeenCalledWith('STRIPE_FLUX_PRODUCT_ID')
await expect(service.refresh('FLUX_PER_REQUEST')).resolves.toBe(9)
expect(store.getFreshRaw).toHaveBeenCalledWith('FLUX_PER_REQUEST')
expect(store.getRaw).not.toHaveBeenCalled()
})
})
@@ -12,10 +12,12 @@ import { userFluxRedisKey } from '../../../utils/redis-keys'
import * as fluxSchema from '../../../schemas/flux'
import * as fluxTxSchema from '../../../schemas/flux-transaction'
import * as stripeSchema from '../../../schemas/stripe'
const logger = useLogger('billing-service')
/** Database handle used when Payment CORE already owns the outer transaction. */
export type BillingTransaction = Pick<Database, 'insert' | 'update' | 'select'>
export function createBillingService(
db: Database,
redis: Redis,
@@ -229,16 +231,21 @@ export function createBillingService(
description: string
source: string
/**
* Ledger row `type`. Defaults to `'credit'` for backward compatibility
* with existing callers (Stripe top-up). Admin promo grants pass
* `'promo'` so reports / dashboards can distinguish them.
* Ledger row `type`. Defaults to `'credit'` for pack credits.
* Admin promo grants pass `'promo'` so reports can distinguish them.
*/
type?: 'credit' | 'promo'
auditMetadata?: Record<string, unknown>
/**
* When Payment CORE already opened a transaction, write through that
* handle and skip the Redis cache update. Caller must call
* `syncFluxCache` after the outer transaction commits.
*/
tx?: BillingTransaction
}): Promise<{ balanceBefore: number, balanceAfter: number, fluxTransactionId: string, idempotent: boolean }> {
const ledgerType = input.type ?? 'credit'
const txResult = await db.transaction(async (tx) => {
const writeCredit = async (tx: BillingTransaction) => {
if (input.requestId != null) {
const [existing] = await tx
.select({
@@ -297,7 +304,11 @@ export function createBillingService(
fluxTransactionId: insertedTx!.id,
idempotent: false,
}
})
}
const txResult = input.tx
? await writeCredit(input.tx)
: await db.transaction(async tx => writeCredit(tx))
if (txResult.idempotent) {
logger.withFields({
@@ -308,13 +319,21 @@ export function createBillingService(
return txResult
}
await updateRedisCache(input.userId, txResult.balanceAfter)
metrics?.fluxCredited.add(input.amount, { source: input.source, type: ledgerType })
if (!input.tx) {
await updateRedisCache(input.userId, txResult.balanceAfter)
metrics?.fluxCredited.add(input.amount, { source: input.source, type: ledgerType })
}
logger.withFields({ userId: input.userId, amount: input.amount, balance: txResult.balanceAfter }).log('Credited flux')
return txResult
},
async syncFluxCache(userId: string, balance: number, credited?: { amount: number, source: string }): Promise<void> {
await updateRedisCache(userId, balance)
if (credited)
metrics?.fluxCredited.add(credited.amount, { source: credited.source, type: 'credit' })
},
/**
* Set a user's flux balance to an absolute value within a DB transaction.
*
@@ -401,158 +420,6 @@ export function createBillingService(
return txResult
},
/**
* Credit flux from a Stripe checkout session (one-time payment).
* Idempotent: claims the checkout session row by flipping `fluxCredited`
* from false to true; replays of the same Stripe event observe the row
* already claimed and apply nothing.
*/
async creditFluxFromStripeCheckout(input: {
stripeEventId: string
userId: string
stripeSessionId: string
amountTotal: number
currency: string | null
fluxAmount: number
}): Promise<{ applied: boolean, balanceAfter?: number }> {
const txResult = await db.transaction(async (tx) => {
// NOTICE: Webhook idempotency is enforced at the business-object level, not by a
// dedicated processed-events table keyed on Stripe `event.id`. We claim the
// checkout session row exactly once via `fluxCredited = false -> true`, which
// covers both Stripe retries of the same event and distinct Event objects that
// still refer to the same checkout session.
const [claimed] = await tx.update(stripeSchema.stripeCheckoutSession)
.set({ fluxCredited: true, updatedAt: new Date() })
.where(and(
eq(stripeSchema.stripeCheckoutSession.stripeSessionId, input.stripeSessionId),
eq(stripeSchema.stripeCheckoutSession.fluxCredited, false),
))
.returning()
if (!claimed) {
return { applied: false }
}
await tx.insert(fluxSchema.userFlux)
.values({ userId: input.userId, flux: 0 })
.onConflictDoNothing({ target: fluxSchema.userFlux.userId })
const [currentFlux] = await tx
.select({ flux: fluxSchema.userFlux.flux })
.from(fluxSchema.userFlux)
.where(eq(fluxSchema.userFlux.userId, input.userId))
.for('update')
const balanceBefore = currentFlux!.flux
const balanceAfter = balanceBefore + input.fluxAmount
await tx.update(fluxSchema.userFlux)
.set({ flux: balanceAfter, updatedAt: new Date() })
.where(eq(fluxSchema.userFlux.userId, input.userId))
const description = `Stripe payment ${input.currency?.toUpperCase() ?? 'UNKNOWN'} ${(input.amountTotal / 100).toFixed(2)}`
await tx.insert(fluxTxSchema.fluxTransaction).values({
userId: input.userId,
type: 'credit',
amount: input.fluxAmount,
balanceBefore,
balanceAfter,
requestId: input.stripeEventId,
description,
metadata: {
stripeEventId: input.stripeEventId,
stripeSessionId: input.stripeSessionId,
source: 'stripe.checkout.completed',
},
})
return { applied: true, balanceAfter }
})
if (txResult.applied && txResult.balanceAfter != null) {
await updateRedisCache(input.userId, txResult.balanceAfter)
metrics?.fluxCredited.add(input.fluxAmount, { source: 'stripe.checkout', type: 'credit' })
}
return txResult
},
/**
* Credit flux from a Stripe invoice payment (subscription).
* Idempotent: claims the invoice row by flipping `fluxCredited`
* from false to true; replays observe it already claimed and apply nothing.
*/
async creditFluxFromInvoice(input: {
stripeEventId: string
userId: string
stripeInvoiceId: string
amountPaid: number
currency: string
fluxAmount: number
}): Promise<{ applied: boolean, balanceAfter?: number }> {
const txResult = await db.transaction(async (tx) => {
// NOTICE: Invoice webhook idempotency follows the same object-level claim model
// as checkout sessions. We intentionally dedupe on the invoice record instead of
// only on Stripe `event.id`, because Stripe may emit multiple events that map to
// the same paid invoice while the balance must only be credited once.
const [claimed] = await tx.update(stripeSchema.stripeInvoice)
.set({ fluxCredited: true, updatedAt: new Date() })
.where(and(
eq(stripeSchema.stripeInvoice.stripeInvoiceId, input.stripeInvoiceId),
eq(stripeSchema.stripeInvoice.fluxCredited, false),
))
.returning()
if (!claimed) {
return { applied: false }
}
await tx.insert(fluxSchema.userFlux)
.values({ userId: input.userId, flux: 0 })
.onConflictDoNothing({ target: fluxSchema.userFlux.userId })
const [currentFlux] = await tx
.select({ flux: fluxSchema.userFlux.flux })
.from(fluxSchema.userFlux)
.where(eq(fluxSchema.userFlux.userId, input.userId))
.for('update')
const balanceBefore = currentFlux!.flux
const balanceAfter = balanceBefore + input.fluxAmount
await tx.update(fluxSchema.userFlux)
.set({ flux: balanceAfter, updatedAt: new Date() })
.where(eq(fluxSchema.userFlux.userId, input.userId))
const description = `Subscription invoice ${input.currency.toUpperCase()} ${(input.amountPaid / 100).toFixed(2)}`
await tx.insert(fluxTxSchema.fluxTransaction).values({
userId: input.userId,
type: 'credit',
amount: input.fluxAmount,
balanceBefore,
balanceAfter,
requestId: input.stripeEventId,
description,
metadata: {
stripeEventId: input.stripeEventId,
stripeInvoiceId: input.stripeInvoiceId,
source: 'invoice.paid',
},
})
return { applied: true, balanceAfter }
})
if (txResult.applied && txResult.balanceAfter != null) {
await updateRedisCache(input.userId, txResult.balanceAfter)
metrics?.fluxCredited.add(input.fluxAmount, { source: 'stripe.invoice', type: 'credit' })
}
return txResult
},
}
}
@@ -44,84 +44,6 @@ describe('billingService', () => {
await db.delete(schema.fluxTransaction)
await db.delete(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
await db.delete(schema.stripeCheckoutSession).where(eq(schema.stripeCheckoutSession.stripeSessionId, 'sess-billing-1'))
await db.insert(schema.stripeCheckoutSession).values({
userId: 'user-billing-1',
stripeSessionId: 'sess-billing-1',
mode: 'payment',
status: 'complete',
paymentStatus: 'paid',
amountTotal: 500,
currency: 'usd',
fluxCredited: false,
})
})
describe('creditFluxFromStripeCheckout', () => {
it('credits flux, records transaction, and enqueues outbox events in one transaction', async () => {
const result = await billingService.creditFluxFromStripeCheckout({
stripeEventId: 'stripe-evt-1',
userId: 'user-billing-1',
stripeSessionId: 'sess-billing-1',
amountTotal: 500,
currency: 'usd',
fluxAmount: 50,
})
expect(result).toEqual({ applied: true, balanceAfter: 50 })
const [fluxRecord] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
expect(fluxRecord?.flux).toBe(50)
// Verify transaction entry
const txRecords = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, 'user-billing-1'))
expect(txRecords).toHaveLength(1)
expect(txRecords[0]?.type).toBe('credit')
expect(txRecords[0]?.amount).toBe(50)
expect(txRecords[0]?.balanceBefore).toBe(0)
expect(txRecords[0]?.balanceAfter).toBe(50)
// Verify metadata on transaction entry
expect(txRecords[0]?.metadata).toMatchObject({
stripeEventId: 'stripe-evt-1',
stripeSessionId: 'sess-billing-1',
source: 'stripe.checkout.completed',
})
// Verify stripe session marked as credited
const [sessionRecord] = await db.select().from(schema.stripeCheckoutSession).where(eq(schema.stripeCheckoutSession.stripeSessionId, 'sess-billing-1'))
expect(sessionRecord?.fluxCredited).toBe(true)
// Verify Redis cache updated
expect(set).toHaveBeenCalledWith(userFluxRedisKey('user-billing-1'), '50')
})
it('is idempotent when the checkout session was already credited', async () => {
await billingService.creditFluxFromStripeCheckout({
stripeEventId: 'stripe-evt-1',
userId: 'user-billing-1',
stripeSessionId: 'sess-billing-1',
amountTotal: 500,
currency: 'usd',
fluxAmount: 50,
})
const second = await billingService.creditFluxFromStripeCheckout({
stripeEventId: 'stripe-evt-1',
userId: 'user-billing-1',
stripeSessionId: 'sess-billing-1',
amountTotal: 500,
currency: 'usd',
fluxAmount: 50,
})
expect(second).toEqual({ applied: false })
// Idempotent replay must not double-write the ledger
const txRecords = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, 'user-billing-1'))
expect(txRecords).toHaveLength(1)
})
})
describe('consumeFluxForLLM', () => {
@@ -85,11 +85,4 @@ describe('fluxService (DB-backed)', () => {
expect(record.flux).toBe(42)
expect(set).toHaveBeenCalledWith(userFluxRedisKey(testUser.id), '42')
})
it('updateStripeCustomerId should update DB only', async () => {
await db.insert(schema.userFlux).values({ userId: testUser.id, flux: 100 })
const result = await service.updateStripeCustomerId(testUser.id, 'cus_abc123')
expect(result!.stripeCustomerId).toBe('cus_abc123')
})
})
@@ -79,21 +79,6 @@ export function createFluxService(db: Database, redis: Redis, configKV: ConfigKV
return record
},
async updateStripeCustomerId(userId: string, stripeCustomerId: string) {
const [updated] = await db.update(schema.userFlux)
.set({
stripeCustomerId,
updatedAt: new Date(),
})
.where(and(
eq(schema.userFlux.userId, userId),
isNull(schema.userFlux.deletedAt),
))
.returning()
return updated
},
/**
* Soft-delete the user's flux balance and drop the cached value from
* Redis. Does NOT touch `flux_transaction` that ledger is preserved
@@ -0,0 +1,290 @@
import type { Database } from '../../../libs/db'
import type { BillingService } from '../billing/billing-service'
import type {
BindProcessorOrderInput,
ClaimReceipt,
OpenPendingInput,
PendingPaymentOrder,
SettleResult,
} from './types'
import { useLogger } from '@guiiai/logg'
import { and, eq, isNull } from 'drizzle-orm'
import { stripeCheckoutSession } from '../../../schemas/stripe'
import { createInternalError } from '../../../utils/error'
import * as schema from '../../../schemas/payment'
export type {
BindProcessorOrderInput,
ClaimReceipt,
OpenPendingInput,
PendingPaymentOrder,
SettleResult,
} from './types'
const logger = useLogger('payment')
/**
* Payment CORE: pack grant and `payment_order` ownership.
*
* Call stack:
*
* Stripe `POST /checkout`
* -> {@link createPaymentService} `openPending`
* -> Stripe adapter creates the Checkout Session
* -> {@link createPaymentService} `bindProcessorOrder`
*
* Stripe `POST /webhook` (after signature verify)
* -> Stripe adapter maps session to {@link ClaimReceipt}
* -> {@link createPaymentService} `settle`
* -> {@link BillingService.creditFlux}
*/
export function createPaymentService(db: Database, billing: BillingService) {
async function insertPaymentCustomerIfAbsent(
tx: Pick<Database, 'insert' | 'select'>,
userId: string,
processor: string,
customerId: string,
) {
const [existing] = await tx
.select({ id: schema.paymentCustomer.id })
.from(schema.paymentCustomer)
.where(and(
eq(schema.paymentCustomer.processor, processor),
eq(schema.paymentCustomer.customerId, customerId),
isNull(schema.paymentCustomer.deletedAt),
))
.limit(1)
if (existing)
return
// Unique races must not abort the settle transaction.
await tx.insert(schema.paymentCustomer).values({
userId,
processor,
customerId,
}).onConflictDoNothing()
}
async function findLivePaymentCustomer(userId: string, processor: string) {
const [customer] = await db
.select({ customerId: schema.paymentCustomer.customerId })
.from(schema.paymentCustomer)
.where(and(
eq(schema.paymentCustomer.userId, userId),
eq(schema.paymentCustomer.processor, processor),
isNull(schema.paymentCustomer.deletedAt),
))
.limit(1)
return customer?.customerId
}
async function claimExistingOrder(receipt: ClaimReceipt): Promise<SettleResult> {
const result = await db.transaction(async (tx) => {
const [order] = await tx
.select()
.from(schema.paymentOrder)
.where(eq(schema.paymentOrder.id, receipt.paymentOrderId))
.for('update')
if (!order)
throw createInternalError('Payment order not found')
if (order.processor !== receipt.processor || (order.processorOrderId && order.processorOrderId !== receipt.processorOrderId))
throw createInternalError('Payment receipt does not match order')
if (order.deletedAt)
return { applied: false as const }
switch (receipt.status) {
case 'paid': {
if (order.status === 'paid')
return { applied: false as const }
if (order.status !== 'pending')
return { applied: false as const }
// NOTICE:
// Old and new replicas must claim the same retained checkout row.
// Migration 0023 is a snapshot; an old webhook can credit after it.
// See BillingService.creditCheckoutSession on the pre-CORE version.
// Remove this claim only when all old payment writers are retired.
if (order.processor === 'stripe') {
const [legacy] = await tx.select().from(stripeCheckoutSession).where(eq(stripeCheckoutSession.stripeSessionId, receipt.processorOrderId)).for('update')
if (legacy?.fluxCredited) {
await tx.update(schema.paymentOrder).set({ status: 'paid', creditedAt: legacy.updatedAt, updatedAt: new Date() }).where(eq(schema.paymentOrder.id, order.id))
return { applied: false as const }
}
if (legacy) {
await tx.update(stripeCheckoutSession).set({ fluxCredited: true, updatedAt: new Date() }).where(eq(stripeCheckoutSession.id, legacy.id))
}
}
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(),
processorOrderId: receipt.processorOrderId,
amount: receipt.amount ?? order.amount,
currency: receipt.currency ?? order.currency,
processorData: receipt.extras ?? order.processorData,
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 billing.creditFlux({
userId: order.userId,
amount: fluxAmount,
requestId: order.id,
description: `Flux pack ${claimed.packKey ?? 'unknown'}`,
source: 'payment.pack',
tx,
})
if (receipt.customerId) {
await insertPaymentCustomerIfAbsent(tx, order.userId, order.processor, receipt.customerId)
}
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,
processorOrderId: receipt.processorOrderId,
processorData: receipt.extras ?? order.processorData,
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)}`)
}
}
})
if (result.applied) {
await billing.syncFluxCache(result.userId, result.balanceAfter, {
amount: result.fluxAmount,
source: 'payment.pack',
})
}
return result
}
return {
async openPending(input: OpenPendingInput): Promise<PendingPaymentOrder> {
const [row] = await db.insert(schema.paymentOrder).values({
userId: input.userId,
processor: input.processor,
status: 'pending',
packKey: input.packKey,
fluxAmount: input.fluxAmount,
currency: input.currency,
}).returning()
if (!row)
throw createInternalError('Failed to create payment order')
const customerId = await findLivePaymentCustomer(input.userId, input.processor)
return { id: row.id, customerId }
},
/**
* Stores the processor checkout id when the row still has none.
* A concurrent settle that already wrote the id wins.
*/
async bindProcessorOrder(orderId: string, input: BindProcessorOrderInput): Promise<void> {
await db.update(schema.paymentOrder)
.set({
processorOrderId: input.processorOrderId,
amount: input.amount,
currency: input.currency,
updatedAt: new Date(),
})
.where(and(
eq(schema.paymentOrder.id, orderId),
isNull(schema.paymentOrder.processorOrderId),
isNull(schema.paymentOrder.deletedAt),
))
},
/**
* Marks a pending order canceled. Does not credit Flux.
* No-op when the order is no longer pending.
*/
async abandon(orderId: string): Promise<void> {
await db.update(schema.paymentOrder)
.set({
status: 'canceled',
updatedAt: new Date(),
})
.where(and(
eq(schema.paymentOrder.id, orderId),
eq(schema.paymentOrder.status, 'pending'),
isNull(schema.paymentOrder.deletedAt),
))
},
async settle(receipt: ClaimReceipt): Promise<SettleResult> {
return claimExistingOrder(receipt)
},
/**
* Soft-deletes `payment_order` and `payment_customer` rows.
* `flux_transaction` is not touched. Checkout sessions time out at the processor.
*/
async deleteAllForUser(userId: string) {
const now = new Date()
await db.update(schema.paymentOrder)
.set({ deletedAt: now, updatedAt: now })
.where(and(
eq(schema.paymentOrder.userId, userId),
isNull(schema.paymentOrder.deletedAt),
))
await db.update(schema.paymentCustomer)
.set({ deletedAt: now, updatedAt: now })
.where(and(
eq(schema.paymentCustomer.userId, userId),
isNull(schema.paymentCustomer.deletedAt),
))
logger.withFields({ userId }).log('Payment rows soft-deleted for user')
},
}
}
export type PaymentService = ReturnType<typeof createPaymentService>
@@ -0,0 +1,259 @@
import type { Database } from '../../../../libs/db'
import type { ConfigKVService } from '../../../adapters/config-kv'
import type { ClaimReceipt } from '../types'
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 { userFluxRedisKey } from '../../../../utils/redis-keys'
import { createBillingService } from '../../billing/billing-service'
import { createPaymentService } from '../index'
import * as schema from '../../../../schemas'
function createPacksConfigKV(): ConfigKVService {
return {
getOptional: vi.fn(async () => null),
getOrThrow: vi.fn(),
get: vi.fn(),
refresh: vi.fn(),
invalidateCache: vi.fn(),
} as ConfigKVService
}
describe('payment CORE', () => {
let db: Database
let redis: ReturnType<typeof createTestRedis>
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 () => {
redis = createTestRedis()
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'))
await db.delete(schema.paymentOrder).where(eq(schema.paymentOrder.userId, 'user-pay-1'))
await db.delete(schema.paymentCustomer).where(eq(schema.paymentCustomer.userId, 'user-pay-1'))
})
async function insertPendingOrder() {
return payment.openPending({
userId: 'user-pay-1',
processor: 'stripe',
packKey: 'starter',
fluxAmount: 500,
currency: 'usd',
})
}
function paidReceipt(paymentOrderId: string, overrides: Partial<ClaimReceipt> = {}): ClaimReceipt {
return {
kind: 'claim',
processor: 'stripe',
paymentOrderId,
processorOrderId: `cs_test_${paymentOrderId}`,
status: 'paid',
amount: 500,
currency: 'usd',
customerId: '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 })
const [flux] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-pay-1'))
expect(flux?.flux).toBe(500)
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(order.id)
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?.processorOrderId).toBe(`cs_test_${order.id}`)
expect(await redis.get(userFluxRedisKey('user-pay-1'))).toBe('500')
})
it('settle replay returns applied false and does not double credit', async () => {
const order = await insertPendingOrder()
const receipt = paidReceipt(order.id)
const first = await payment.settle(receipt)
const second = await payment.settle(receipt)
expect(first.applied).toBe(true)
expect(second.applied).toBe(false)
const ledger = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, 'user-pay-1'))
expect(ledger).toHaveLength(1)
const [flux] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-pay-1'))
expect(flux?.flux).toBe(500)
})
it('throws when settle runs before the order exists so the adapter 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',
processor: 'stripe',
paymentOrderId: order.id,
processorOrderId: `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',
processor: 'stripe',
paymentOrderId: order.id,
processorOrderId: `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 customers', async () => {
const order = await insertPendingOrder()
await db.insert(schema.paymentCustomer).values({
userId: 'user-pay-1',
processor: 'stripe',
customerId: '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 [deletedCustomer] = await db.select().from(schema.paymentCustomer).where(eq(schema.paymentCustomer.userId, 'user-pay-1'))
expect(deletedCustomer?.deletedAt).toBeInstanceOf(Date)
})
it('openPending snapshots the pack and returns a live payment customer', async () => {
await db.insert(schema.paymentCustomer).values({
userId: 'user-pay-1',
processor: 'stripe',
customerId: 'cus_live',
})
const opened = await payment.openPending({
userId: 'user-pay-1',
processor: 'stripe',
packKey: 'starter',
fluxAmount: 500,
currency: 'usd',
})
expect(opened.customerId).toBe('cus_live')
const [row] = await db.select().from(schema.paymentOrder).where(eq(schema.paymentOrder.id, opened.id))
expect(row?.status).toBe('pending')
expect(row?.packKey).toBe('starter')
expect(row?.fluxAmount).toBe(500)
expect(row?.processorOrderId).toBeNull()
})
it('openPending ignores a soft-deleted payment customer', async () => {
await db.insert(schema.paymentCustomer).values({
userId: 'user-pay-1',
processor: 'stripe',
customerId: 'cus_deleted',
deletedAt: new Date(),
})
const opened = await payment.openPending({
userId: 'user-pay-1',
processor: 'stripe',
packKey: 'starter',
fluxAmount: 500,
})
expect(opened.customerId).toBeUndefined()
})
it('bindProcessorOrder does not overwrite an id that settle already stored', async () => {
const opened = await insertPendingOrder()
await payment.settle(paidReceipt(opened.id, { processorOrderId: 'cs_settle' }))
await payment.bindProcessorOrder(opened.id, {
processorOrderId: 'cs_bind',
amount: 999,
})
const [row] = await db.select().from(schema.paymentOrder).where(eq(schema.paymentOrder.id, opened.id))
expect(row?.status).toBe('paid')
expect(row?.processorOrderId).toBe('cs_settle')
expect(row?.amount).toBe(500)
})
it('abandon marks a pending order canceled without crediting Flux', async () => {
const opened = await insertPendingOrder()
await payment.abandon(opened.id)
const [row] = await db.select().from(schema.paymentOrder).where(eq(schema.paymentOrder.id, opened.id))
expect(row?.status).toBe('canceled')
const ledger = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, 'user-pay-1'))
expect(ledger).toHaveLength(0)
})
it('abandon does not reverse a paid order', async () => {
const opened = await insertPendingOrder()
await payment.settle(paidReceipt(opened.id))
await payment.abandon(opened.id)
const result = await payment.settle(paidReceipt(opened.id))
expect(result.applied).toBe(false)
const [row] = await db.select().from(schema.paymentOrder).where(eq(schema.paymentOrder.id, opened.id))
expect(row?.status).toBe('paid')
const [flux] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-pay-1'))
expect(flux?.flux).toBe(500)
})
})
@@ -0,0 +1,48 @@
export type ClaimStatus = 'paid' | 'canceled' | 'expired'
/**
* Adapter claim for a pending `payment_order`.
*
* The Stripe adapter maps a verified processor event onto this receipt.
* CORE claims by `paymentOrderId`.
*/
export interface ClaimReceipt {
kind: 'claim'
processor: string
paymentOrderId: string
processorOrderId: string
status: ClaimStatus
amount?: number
currency?: string
customerId?: string
extras?: Record<string, unknown>
}
export type SettleResult
= | { applied: true, userId: string, fluxAmount: number, balanceAfter: number }
| { applied: false }
/**
* Adapter request to insert a pending `payment_order`.
*
* The adapter resolves the pack. CORE snapshots flux on the row.
*/
export interface OpenPendingInput {
userId: string
processor: string
packKey: string
fluxAmount: number
currency?: string
}
export interface PendingPaymentOrder {
id: string
/** Live `payment_customer` for this user and processor, when one exists. */
customerId?: string
}
export interface BindProcessorOrderInput {
processorOrderId: string
amount?: number
currency?: string
}
@@ -1,468 +0,0 @@
import type { Database } from '../../libs/db'
import { eq } from 'drizzle-orm'
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { mockDB } from '../../libs/mock-db'
import { createStripeService } from './stripe'
import * as schema from '../../schemas'
describe('stripeService', () => {
let db: Database
let stripeService: ReturnType<typeof createStripeService>
beforeAll(async () => {
db = await mockDB(schema)
await db.insert(schema.user).values([
{ id: 'user-stripe-1', name: 'Stripe User 1', email: 'stripe1@example.com' },
{ id: 'user-stripe-2', name: 'Stripe User 2', email: 'stripe2@example.com' },
])
})
beforeEach(async () => {
stripeService = createStripeService(db, null)
// Clean all stripe tables between tests
await db.delete(schema.stripeInvoice)
await db.delete(schema.stripeSubscription)
await db.delete(schema.stripeCheckoutSession)
await db.delete(schema.stripeCustomer)
})
// ---- Customer ----
describe('upsertCustomer', () => {
it('inserts a new customer', async () => {
const result = await stripeService.upsertCustomer({
userId: 'user-stripe-1',
stripeCustomerId: 'cus_new_1',
email: 'stripe1@example.com',
})
expect(result.userId).toBe('user-stripe-1')
expect(result.stripeCustomerId).toBe('cus_new_1')
expect(result.email).toBe('stripe1@example.com')
})
it('updates an existing customer on conflict (atomic upsert)', async () => {
await stripeService.upsertCustomer({
userId: 'user-stripe-1',
stripeCustomerId: 'cus_dup_1',
email: 'old@example.com',
})
const updated = await stripeService.upsertCustomer({
userId: 'user-stripe-1',
stripeCustomerId: 'cus_dup_1',
email: 'new@example.com',
name: 'Updated Name',
})
expect(updated.email).toBe('new@example.com')
expect(updated.name).toBe('Updated Name')
// Verify only one record exists
const all = await db.select().from(schema.stripeCustomer).where(eq(schema.stripeCustomer.stripeCustomerId, 'cus_dup_1'))
expect(all).toHaveLength(1)
})
it('handles concurrent upserts for the same customer without error', async () => {
// Simulate two webhook events arriving at the same time for the same customer
const results = await Promise.all([
stripeService.upsertCustomer({
userId: 'user-stripe-1',
stripeCustomerId: 'cus_race_1',
email: 'a@example.com',
}),
stripeService.upsertCustomer({
userId: 'user-stripe-1',
stripeCustomerId: 'cus_race_1',
email: 'b@example.com',
}),
])
// Both should succeed (no unique constraint violation)
expect(results).toHaveLength(2)
results.forEach(r => expect(r.stripeCustomerId).toBe('cus_race_1'))
// Only one record should exist
const all = await db.select().from(schema.stripeCustomer).where(eq(schema.stripeCustomer.stripeCustomerId, 'cus_race_1'))
expect(all).toHaveLength(1)
})
})
describe('getCustomerByUserId', () => {
it('returns the customer for a given userId', async () => {
await stripeService.upsertCustomer({
userId: 'user-stripe-1',
stripeCustomerId: 'cus_lookup_1',
})
const found = await stripeService.getCustomerByUserId('user-stripe-1')
expect(found?.stripeCustomerId).toBe('cus_lookup_1')
})
it('returns undefined when no customer exists', async () => {
const found = await stripeService.getCustomerByUserId('user-nonexistent')
expect(found).toBeUndefined()
})
})
describe('getCustomerByStripeId', () => {
it('returns the customer for a given stripeCustomerId', async () => {
await stripeService.upsertCustomer({
userId: 'user-stripe-1',
stripeCustomerId: 'cus_sid_1',
})
const found = await stripeService.getCustomerByStripeId('cus_sid_1')
expect(found?.userId).toBe('user-stripe-1')
})
it('returns undefined when no customer exists', async () => {
const found = await stripeService.getCustomerByStripeId('cus_nonexistent')
expect(found).toBeUndefined()
})
})
// ---- Checkout Session ----
describe('upsertCheckoutSession', () => {
it('inserts a new checkout session', async () => {
const result = await stripeService.upsertCheckoutSession({
userId: 'user-stripe-1',
stripeSessionId: 'cs_new_1',
mode: 'payment',
status: 'open',
paymentStatus: 'unpaid',
amountTotal: 1000,
currency: 'usd',
})
expect(result.stripeSessionId).toBe('cs_new_1')
expect(result.amountTotal).toBe(1000)
expect(result.fluxCredited).toBe(false)
})
it('updates an existing checkout session on conflict', async () => {
await stripeService.upsertCheckoutSession({
userId: 'user-stripe-1',
stripeSessionId: 'cs_upd_1',
mode: 'payment',
status: 'open',
paymentStatus: 'unpaid',
amountTotal: 1000,
currency: 'usd',
})
const updated = await stripeService.upsertCheckoutSession({
userId: 'user-stripe-1',
stripeSessionId: 'cs_upd_1',
mode: 'payment',
status: 'complete',
paymentStatus: 'paid',
amountTotal: 1000,
currency: 'usd',
})
expect(updated.status).toBe('complete')
expect(updated.paymentStatus).toBe('paid')
const all = await db.select().from(schema.stripeCheckoutSession).where(eq(schema.stripeCheckoutSession.stripeSessionId, 'cs_upd_1'))
expect(all).toHaveLength(1)
})
it('handles concurrent upserts without error', async () => {
const results = await Promise.all([
stripeService.upsertCheckoutSession({
userId: 'user-stripe-1',
stripeSessionId: 'cs_race_1',
mode: 'payment',
status: 'open',
paymentStatus: 'unpaid',
amountTotal: 500,
currency: 'usd',
}),
stripeService.upsertCheckoutSession({
userId: 'user-stripe-1',
stripeSessionId: 'cs_race_1',
mode: 'payment',
status: 'complete',
paymentStatus: 'paid',
amountTotal: 500,
currency: 'usd',
}),
])
expect(results).toHaveLength(2)
const all = await db.select().from(schema.stripeCheckoutSession).where(eq(schema.stripeCheckoutSession.stripeSessionId, 'cs_race_1'))
expect(all).toHaveLength(1)
})
})
describe('getCheckoutSessionsByUserId', () => {
it('returns all sessions for the user', async () => {
await stripeService.upsertCheckoutSession({
userId: 'user-stripe-1',
stripeSessionId: 'cs_list_1',
mode: 'payment',
amountTotal: 100,
currency: 'usd',
})
await stripeService.upsertCheckoutSession({
userId: 'user-stripe-1',
stripeSessionId: 'cs_list_2',
mode: 'payment',
amountTotal: 200,
currency: 'usd',
})
const sessions = await stripeService.getCheckoutSessionsByUserId('user-stripe-1')
expect(sessions).toHaveLength(2)
const ids = sessions.map(s => s.stripeSessionId)
expect(ids).toContain('cs_list_1')
expect(ids).toContain('cs_list_2')
})
it('does not return sessions from other users', async () => {
await stripeService.upsertCheckoutSession({
userId: 'user-stripe-1',
stripeSessionId: 'cs_iso_1',
mode: 'payment',
})
await stripeService.upsertCheckoutSession({
userId: 'user-stripe-2',
stripeSessionId: 'cs_iso_2',
mode: 'payment',
})
const sessions = await stripeService.getCheckoutSessionsByUserId('user-stripe-1')
expect(sessions).toHaveLength(1)
expect(sessions[0]?.stripeSessionId).toBe('cs_iso_1')
})
})
// ---- Subscription ----
describe('upsertSubscription', () => {
it('inserts a new subscription', async () => {
await stripeService.upsertCustomer({
userId: 'user-stripe-1',
stripeCustomerId: 'cus_sub_1',
})
const result = await stripeService.upsertSubscription({
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_new_1',
stripeCustomerId: 'cus_sub_1',
status: 'active',
})
expect(result.stripeSubscriptionId).toBe('sub_new_1')
expect(result.status).toBe('active')
})
it('updates an existing subscription on conflict', async () => {
await stripeService.upsertSubscription({
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_upd_1',
stripeCustomerId: 'cus_sub_1',
status: 'active',
})
const updated = await stripeService.upsertSubscription({
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_upd_1',
stripeCustomerId: 'cus_sub_1',
status: 'canceled',
})
expect(updated.status).toBe('canceled')
const all = await db.select().from(schema.stripeSubscription).where(eq(schema.stripeSubscription.stripeSubscriptionId, 'sub_upd_1'))
expect(all).toHaveLength(1)
})
it('handles concurrent upserts without error', async () => {
const results = await Promise.all([
stripeService.upsertSubscription({
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_race_1',
stripeCustomerId: 'cus_sub_1',
status: 'active',
}),
stripeService.upsertSubscription({
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_race_1',
stripeCustomerId: 'cus_sub_1',
status: 'past_due',
}),
])
expect(results).toHaveLength(2)
const all = await db.select().from(schema.stripeSubscription).where(eq(schema.stripeSubscription.stripeSubscriptionId, 'sub_race_1'))
expect(all).toHaveLength(1)
})
})
describe('getActiveSubscription', () => {
it('returns only the active subscription', async () => {
await stripeService.upsertSubscription({
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_active_1',
stripeCustomerId: 'cus_sub_1',
status: 'canceled',
})
await stripeService.upsertSubscription({
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_active_2',
stripeCustomerId: 'cus_sub_1',
status: 'active',
})
const active = await stripeService.getActiveSubscription('user-stripe-1')
expect(active?.stripeSubscriptionId).toBe('sub_active_2')
expect(active?.status).toBe('active')
})
it('returns undefined when no active subscription exists', async () => {
await stripeService.upsertSubscription({
userId: 'user-stripe-1',
stripeSubscriptionId: 'sub_none_1',
stripeCustomerId: 'cus_sub_1',
status: 'canceled',
})
const active = await stripeService.getActiveSubscription('user-stripe-1')
expect(active).toBeUndefined()
})
it('does not return subscriptions from other users', async () => {
await stripeService.upsertSubscription({
userId: 'user-stripe-2',
stripeSubscriptionId: 'sub_other_1',
stripeCustomerId: 'cus_other_1',
status: 'active',
})
const active = await stripeService.getActiveSubscription('user-stripe-1')
expect(active).toBeUndefined()
})
})
// ---- Invoice ----
describe('upsertInvoice', () => {
it('inserts a new invoice', async () => {
const result = await stripeService.upsertInvoice({
userId: 'user-stripe-1',
stripeInvoiceId: 'inv_new_1',
stripeCustomerId: 'cus_inv_1',
status: 'open',
amountDue: 2000,
amountPaid: 0,
currency: 'usd',
})
expect(result.stripeInvoiceId).toBe('inv_new_1')
expect(result.status).toBe('open')
expect(result.fluxCredited).toBe(false)
})
it('updates an existing invoice on conflict', async () => {
await stripeService.upsertInvoice({
userId: 'user-stripe-1',
stripeInvoiceId: 'inv_upd_1',
status: 'open',
amountDue: 2000,
amountPaid: 0,
currency: 'usd',
})
const updated = await stripeService.upsertInvoice({
userId: 'user-stripe-1',
stripeInvoiceId: 'inv_upd_1',
status: 'paid',
amountDue: 2000,
amountPaid: 2000,
currency: 'usd',
})
expect(updated.status).toBe('paid')
expect(updated.amountPaid).toBe(2000)
const all = await db.select().from(schema.stripeInvoice).where(eq(schema.stripeInvoice.stripeInvoiceId, 'inv_upd_1'))
expect(all).toHaveLength(1)
})
it('handles concurrent upserts without error', async () => {
const results = await Promise.all([
stripeService.upsertInvoice({
userId: 'user-stripe-1',
stripeInvoiceId: 'inv_race_1',
status: 'open',
amountDue: 1000,
currency: 'usd',
}),
stripeService.upsertInvoice({
userId: 'user-stripe-1',
stripeInvoiceId: 'inv_race_1',
status: 'paid',
amountPaid: 1000,
currency: 'usd',
}),
])
expect(results).toHaveLength(2)
const all = await db.select().from(schema.stripeInvoice).where(eq(schema.stripeInvoice.stripeInvoiceId, 'inv_race_1'))
expect(all).toHaveLength(1)
})
})
describe('getInvoicesByUserId', () => {
it('returns all invoices for the user', async () => {
await stripeService.upsertInvoice({
userId: 'user-stripe-1',
stripeInvoiceId: 'inv_list_1',
status: 'paid',
currency: 'usd',
})
await stripeService.upsertInvoice({
userId: 'user-stripe-1',
stripeInvoiceId: 'inv_list_2',
status: 'open',
currency: 'usd',
})
const invoices = await stripeService.getInvoicesByUserId('user-stripe-1')
expect(invoices).toHaveLength(2)
const ids = invoices.map(i => i.stripeInvoiceId)
expect(ids).toContain('inv_list_1')
expect(ids).toContain('inv_list_2')
})
it('does not return invoices from other users', async () => {
await stripeService.upsertInvoice({
userId: 'user-stripe-1',
stripeInvoiceId: 'inv_iso_1',
status: 'paid',
currency: 'usd',
})
await stripeService.upsertInvoice({
userId: 'user-stripe-2',
stripeInvoiceId: 'inv_iso_2',
status: 'paid',
currency: 'usd',
})
const invoices = await stripeService.getInvoicesByUserId('user-stripe-1')
expect(invoices).toHaveLength(1)
expect(invoices[0]?.stripeInvoiceId).toBe('inv_iso_1')
})
})
})
@@ -1,212 +0,0 @@
import type Stripe from 'stripe'
import type { Database } from '../../libs/db'
import type { NewStripeCheckoutSession, NewStripeCustomer, NewStripeInvoice, NewStripeSubscription } from '../../schemas/stripe'
import { useLogger } from '@guiiai/logg'
import { and, eq, isNull, notInArray } from 'drizzle-orm'
import * as schema from '../../schemas/stripe'
const logger = useLogger('stripe-service')
// NOTICE:
// Read paths filter `deletedAt IS NULL` so soft-deleted users (whose
// stripe_* rows persist for billing audit) are invisible to user-facing
// API. Webhooks that arrive after deletion still match by stripeCustomerId
// and re-upsert into the soft-deleted row — that's by design (the row
// remains deletedAt-set, but we capture the late event for accurate audit).
// See `server/apps/api/docs/ai-context/account-deletion.md`.
export function createStripeService(db: Database, stripe: Stripe | null) {
return {
// ---- Customer ----
async upsertCustomer(data: NewStripeCustomer) {
const [row] = await db.insert(schema.stripeCustomer)
.values(data)
.onConflictDoUpdate({
target: schema.stripeCustomer.stripeCustomerId,
set: { ...data, updatedAt: new Date() },
})
.returning()
logger.withFields({ userId: data.userId, stripeCustomerId: data.stripeCustomerId }).log('Upserted Stripe customer')
return row
},
async getCustomerByUserId(userId: string) {
return db.query.stripeCustomer.findFirst({
where: and(
eq(schema.stripeCustomer.userId, userId),
isNull(schema.stripeCustomer.deletedAt),
),
})
},
async getCustomerByStripeId(stripeCustomerId: string) {
// NOTICE: NOT filtering by deletedAt — this lookup is by external
// Stripe id and is used by webhook handlers that need to reach
// soft-deleted archive rows for late events (cancellation receipts,
// final invoices arriving after account deletion). User-facing reads
// use getCustomerByUserId which DOES filter.
return db.query.stripeCustomer.findFirst({
where: eq(schema.stripeCustomer.stripeCustomerId, stripeCustomerId),
})
},
// ---- Checkout Session ----
async upsertCheckoutSession(data: NewStripeCheckoutSession) {
const [row] = await db.insert(schema.stripeCheckoutSession)
.values(data)
.onConflictDoUpdate({
target: schema.stripeCheckoutSession.stripeSessionId,
set: { ...data, updatedAt: new Date() },
})
.returning()
logger.withFields({ userId: data.userId, sessionId: data.stripeSessionId, status: data.status }).log('Upserted checkout session')
return row
},
async getCheckoutSessionsByUserId(userId: string) {
return db.query.stripeCheckoutSession.findMany({
where: and(
eq(schema.stripeCheckoutSession.userId, userId),
isNull(schema.stripeCheckoutSession.deletedAt),
),
orderBy: (t, { desc }) => [desc(t.createdAt)],
})
},
// ---- Subscription ----
async upsertSubscription(data: NewStripeSubscription) {
const [row] = await db.insert(schema.stripeSubscription)
.values(data)
.onConflictDoUpdate({
target: schema.stripeSubscription.stripeSubscriptionId,
set: { ...data, updatedAt: new Date() },
})
.returning()
logger.withFields({ userId: data.userId, subscriptionId: data.stripeSubscriptionId, status: data.status }).log('Upserted subscription')
return row
},
async getActiveSubscription(userId: string) {
return db.query.stripeSubscription.findFirst({
where: and(
eq(schema.stripeSubscription.userId, userId),
eq(schema.stripeSubscription.status, 'active'),
isNull(schema.stripeSubscription.deletedAt),
),
orderBy: (t, { desc }) => [desc(t.createdAt)],
})
},
// ---- Invoice ----
async upsertInvoice(data: NewStripeInvoice) {
const [row] = await db.insert(schema.stripeInvoice)
.values(data)
.onConflictDoUpdate({
target: schema.stripeInvoice.stripeInvoiceId,
set: { ...data, updatedAt: new Date() },
})
.returning()
logger.withFields({ userId: data.userId, invoiceId: data.stripeInvoiceId, status: data.status }).log('Upserted invoice')
return row
},
async getInvoicesByUserId(userId: string) {
return db.query.stripeInvoice.findMany({
where: and(
eq(schema.stripeInvoice.userId, userId),
isNull(schema.stripeInvoice.deletedAt),
),
orderBy: (t, { desc }) => [desc(t.createdAt)],
})
},
/**
* Cancel the user's active Stripe subscription via the API and stamp every
* `stripe_*` row with `deletedAt`. Called from the user-deletion pipeline
* (priority 10 runs first because Stripe API cancellation has no
* rollback path).
*
* Idempotent on retry: subsequent calls find no `active` subs to cancel
* and the `WHERE deletedAt IS NULL` guard skips already-stamped rows.
* Stripe `subscriptions.cancel` itself is also idempotent per spec
* cancelling an already-canceled sub returns 200.
*
* Cancellation is immediate, no proration, no refund see
* `server/apps/api/docs/ai-context/account-deletion.md`.
*/
async deleteAllForUser(userId: string) {
// Cancel every subscription that is NOT already in a terminal state.
// Stripe's terminal statuses are `canceled` and `incomplete_expired`;
// anything else (`active`, `trialing`, `past_due`, `unpaid`,
// `incomplete`, `paused`) can still bill or transition into billing,
// so leaving them uncancelled would charge a deleted account.
// Stripe `subscriptions.cancel` is idempotent per spec — safe to
// call on any non-terminal status.
const cancellableSubs = await db.query.stripeSubscription.findMany({
where: and(
eq(schema.stripeSubscription.userId, userId),
notInArray(schema.stripeSubscription.status, ['canceled', 'incomplete_expired']),
isNull(schema.stripeSubscription.deletedAt),
),
})
if (stripe && cancellableSubs.length > 0) {
for (const sub of cancellableSubs) {
try {
await stripe.subscriptions.cancel(sub.stripeSubscriptionId, {
prorate: false,
})
logger.withFields({ userId, subscriptionId: sub.stripeSubscriptionId, prevStatus: sub.status }).log('Cancelled Stripe subscription')
}
catch (err) {
logger.withError(err).withFields({ userId, subscriptionId: sub.stripeSubscriptionId, prevStatus: sub.status }).error('Failed to cancel Stripe subscription')
throw err
}
}
}
else if (!stripe && cancellableSubs.length > 0) {
logger.withFields({ userId, cancellableSubCount: cancellableSubs.length }).warn('Stripe SDK not configured; skipping API cancel — local rows will still be soft-deleted')
}
const now = new Date()
await db.update(schema.stripeSubscription)
.set({ deletedAt: now, updatedAt: now })
.where(and(
eq(schema.stripeSubscription.userId, userId),
isNull(schema.stripeSubscription.deletedAt),
))
await db.update(schema.stripeCheckoutSession)
.set({ deletedAt: now, updatedAt: now })
.where(and(
eq(schema.stripeCheckoutSession.userId, userId),
isNull(schema.stripeCheckoutSession.deletedAt),
))
await db.update(schema.stripeInvoice)
.set({ deletedAt: now, updatedAt: now })
.where(and(
eq(schema.stripeInvoice.userId, userId),
isNull(schema.stripeInvoice.deletedAt),
))
await db.update(schema.stripeCustomer)
.set({ deletedAt: now, updatedAt: now })
.where(and(
eq(schema.stripeCustomer.userId, userId),
isNull(schema.stripeCustomer.deletedAt),
))
logger.withFields({ userId, cancelledSubs: cancellableSubs.length }).log('Stripe rows soft-deleted for user')
},
}
}
export type StripeService = ReturnType<typeof createStripeService>
+20
View File
@@ -0,0 +1,20 @@
/**
* Formats a smallest-unit amount into a display price string.
*
* @example
* formatPrice(300, 'usd')
* // => '$3.00'
*/
export 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
return formatter.format(unitAmount / (10 ** fractionDigits))
}
catch {
return `${unitAmount / 100} ${currency.toUpperCase()}`
}
}
@@ -61,8 +61,6 @@ export const METRIC_WS_MESSAGES_RECEIVED = 'ws.messages.received'
// Revenue (AIRI custom)
export const METRIC_STRIPE_CHECKOUT_CREATED = 'stripe.checkout.created'
export const METRIC_STRIPE_CHECKOUT_COMPLETED = 'stripe.checkout.completed'
export const METRIC_STRIPE_PAYMENT_FAILED = 'stripe.payment.failed'
export const METRIC_STRIPE_SUBSCRIPTION_EVENT = 'stripe.subscription.event'
export const METRIC_STRIPE_EVENTS = 'stripe.events'
export const METRIC_FLUX_INSUFFICIENT_BALANCE = 'flux.insufficient_balance'