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

## Why

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

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

## Changes

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

## Test plan

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

## Visual changes

No user-visible changes.

## Open: settle after account deletion

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: RainbowBird <git@luoling.moe>
This commit is contained in:
Lulu
2026-09-12 17:43:10 +08:00
committed by GitHub
co-authored by Cursor RainbowBird
parent e447b15b0d
commit 37c837e502
37 changed files with 5843 additions and 2289 deletions
@@ -36,14 +36,14 @@ if (isStageTamagotchi())
useEventListener(window, 'focus', () => authStore.updateCredits())
interface FluxPackage {
stripePriceId: string
packKey: string
label: string
defaultCurrency: string
currencies: Record<string, string>
recommended?: boolean
}
const loadingPriceId = ref<string | null>(null)
const loadingPackKey = ref<string | null>(null)
const message = ref<{ type: 'success' | 'error', text: string } | null>(null)
const checkoutReturnMessageActive = ref(false)
const packages = ref<FluxPackage[]>([])
@@ -304,8 +304,8 @@ onMounted(async () => {
}
})
async function handleBuy(stripePriceId: string) {
loadingPriceId.value = stripePriceId
async function handleBuy(packKey: string) {
loadingPackKey.value = packKey
checkoutReturnMessageActive.value = false
message.value = null
// OpenPanel funnel step 2: user picked a plan. price_minor_unit lives on
@@ -317,12 +317,12 @@ async function handleBuy(stripePriceId: string) {
current_plan: 'flux',
trigger: 'manual_topup',
})
trackPlanSelected(stripePriceId, {
trackPlanSelected(packKey, {
currency: selectedCurrency.value,
entry_surface: 'settings_flux',
})
try {
const res = await client.api.v1.stripe.checkout.$post({ json: { stripePriceId, currency: selectedCurrency.value } })
const res = await client.api.v1.stripe.checkout.$post({ json: { packKey, currency: selectedCurrency.value } })
if (!res.ok) {
const data = await res.json() as { error?: string, message?: string }
message.value = { type: 'error', text: data.message || t('settings.pages.flux.checkout.error') }
@@ -332,7 +332,7 @@ async function handleBuy(stripePriceId: string) {
if (data.url) {
// Start capture before redirecting to Stripe so fetch keepalive can
// finish delivery after the page unloads.
trackCheckoutStarted(stripePriceId, {
trackCheckoutStarted(packKey, {
currency: selectedCurrency.value,
entry_surface: 'settings_flux',
})
@@ -350,7 +350,7 @@ async function handleBuy(stripePriceId: string) {
message.value = { type: 'error', text: t('settings.pages.flux.checkout.error') }
}
finally {
loadingPriceId.value = null
loadingPackKey.value = null
}
}
</script>
@@ -401,8 +401,8 @@ async function handleBuy(stripePriceId: string) {
<div grid="~ cols-1 sm:cols-3 gap-4">
<button
v-for="(pkg, index) in packages" :key="pkg.stripePriceId"
:disabled="loadingPriceId !== null"
v-for="(pkg, index) in packages" :key="pkg.packKey"
:disabled="loadingPackKey !== null"
:class="[
'group relative flex flex-row sm:flex-col items-center justify-between sm:justify-center overflow-hidden text-left sm:text-center gap-4 sm:gap-2',
'rounded-2xl border-2 bg-white p-6 transition-all duration-300 ease-out',
@@ -410,9 +410,9 @@ async function handleBuy(stripePriceId: string) {
'dark:bg-neutral-900',
'hover:-translate-y-1 hover:border-primary-400 hover:shadow-md dark:hover:border-primary-500',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
loadingPriceId !== null && loadingPriceId !== pkg.stripePriceId ? 'opacity-50 grayscale-50 cursor-not-allowed' : 'cursor-pointer',
loadingPackKey !== null && loadingPackKey !== pkg.packKey ? 'opacity-50 grayscale-50 cursor-not-allowed' : 'cursor-pointer',
]"
@click="handleBuy(pkg.stripePriceId)"
@click="handleBuy(pkg.packKey)"
>
<!-- Recommended Badge -->
<div
@@ -425,7 +425,7 @@ async function handleBuy(stripePriceId: string) {
<!-- Loading Overlay -->
<div
v-if="loadingPriceId === pkg.stripePriceId"
v-if="loadingPackKey === pkg.packKey"
class="absolute inset-0 z-10 flex items-center justify-center bg-white/60 backdrop-blur-sm dark:bg-neutral-900/60"
>
<div class="i-svg-spinners:90-ring-with-bg size-8 text-primary-500" />