From 37c837e5026bd6c1f68d1c134fc427c763a244a0 Mon Sep 17 00:00:00 2001 From: Lulu Date: Sat, 12 Sep 2026 02:43:10 -0700 Subject: [PATCH] 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 Co-authored-by: RainbowBird --- .../stage-pages/src/pages/settings/flux.vue | 26 +- server/apps/api/README.md | 9 + .../apps/api/drizzle/0023_payment_order.sql | 113 + .../apps/api/drizzle/meta/0023_snapshot.json | 3795 +++++++++++++++++ server/apps/api/drizzle/meta/_journal.json | 9 +- server/apps/api/src/app.test.ts | 3 +- server/apps/api/src/app.ts | 85 +- server/apps/api/src/otel/index.ts | 12 - server/apps/api/src/routes/flux/route.test.ts | 1 - .../api/src/routes/openai/v1/route.test.ts | 3 - .../api/src/routes/stripe/checkout.test.ts | 292 ++ server/apps/api/src/routes/stripe/claim.ts | 49 + server/apps/api/src/routes/stripe/index.ts | 117 +- .../src/routes/stripe/operations/checkout.ts | 193 +- .../src/routes/stripe/operations/webhook.ts | 436 +- .../src/routes/stripe/payment-release.test.ts | 120 + .../src/routes/stripe/price-catalog.test.ts | 78 + .../api/src/routes/stripe/price-catalog.ts | 191 +- .../apps/api/src/routes/stripe/route.test.ts | 768 +--- server/apps/api/src/routes/stripe/schema.ts | 24 +- server/apps/api/src/schemas/flux.ts | 2 + server/apps/api/src/schemas/index.ts | 1 + server/apps/api/src/schemas/payment.ts | 55 + server/apps/api/src/schemas/stripe.ts | 83 +- .../adapters/config-kv/definitions.ts | 35 +- .../services/adapters/config-kv/index.test.ts | 46 +- .../domain/billing/billing-service.ts | 187 +- .../billing/tests/billing-service.test.ts | 78 - .../apps/api/src/services/domain/flux.test.ts | 7 - server/apps/api/src/services/domain/flux.ts | 15 - .../api/src/services/domain/payment/index.ts | 290 ++ .../domain/payment/tests/payment.test.ts | 259 ++ .../api/src/services/domain/payment/types.ts | 48 + .../api/src/services/domain/stripe.test.ts | 468 -- server/apps/api/src/services/domain/stripe.ts | 212 - server/apps/api/src/utils/format-price.ts | 20 + server/apps/api/src/utils/observability.ts | 2 - 37 files changed, 5843 insertions(+), 2289 deletions(-) create mode 100644 server/apps/api/drizzle/0023_payment_order.sql create mode 100644 server/apps/api/drizzle/meta/0023_snapshot.json create mode 100644 server/apps/api/src/routes/stripe/checkout.test.ts create mode 100644 server/apps/api/src/routes/stripe/claim.ts create mode 100644 server/apps/api/src/routes/stripe/payment-release.test.ts create mode 100644 server/apps/api/src/routes/stripe/price-catalog.test.ts create mode 100644 server/apps/api/src/schemas/payment.ts create mode 100644 server/apps/api/src/services/domain/payment/index.ts create mode 100644 server/apps/api/src/services/domain/payment/tests/payment.test.ts create mode 100644 server/apps/api/src/services/domain/payment/types.ts delete mode 100644 server/apps/api/src/services/domain/stripe.test.ts delete mode 100644 server/apps/api/src/services/domain/stripe.ts create mode 100644 server/apps/api/src/utils/format-price.ts diff --git a/packages/stage-pages/src/pages/settings/flux.vue b/packages/stage-pages/src/pages/settings/flux.vue index ac3c5b7bc..8c9a83a3c 100644 --- a/packages/stage-pages/src/pages/settings/flux.vue +++ b/packages/stage-pages/src/pages/settings/flux.vue @@ -36,14 +36,14 @@ if (isStageTamagotchi()) useEventListener(window, 'focus', () => authStore.updateCredits()) interface FluxPackage { - stripePriceId: string + packKey: string label: string defaultCurrency: string currencies: Record recommended?: boolean } -const loadingPriceId = ref(null) +const loadingPackKey = ref(null) const message = ref<{ type: 'success' | 'error', text: string } | null>(null) const checkoutReturnMessageActive = ref(false) const packages = ref([]) @@ -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 } } @@ -401,8 +401,8 @@ async function handleBuy(stripePriceId: string) {