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" />
|
||||
|
||||
Reference in New Issue
Block a user