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:
co-authored by
Cursor
RainbowBird
parent
e447b15b0d
commit
37c837e502
@@ -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" />
|
||||
|
||||
@@ -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
@@ -162,6 +162,13 @@
|
||||
"when": 1786957404391,
|
||||
"tag": "0022_worthless_shriek",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1789035949919,
|
||||
"tag": "0023_payment_order",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user