feat: add Airwallex payments and multi-currency support

This commit is contained in:
shaw
2026-05-11 10:45:07 +08:00
parent dbc8ae658c
commit b23055af5b
65 changed files with 3164 additions and 162 deletions
@@ -8164,6 +8164,7 @@ const allPaymentTypes = computed(() => [
{ value: "alipay", label: t("payment.methods.alipay") },
{ value: "wxpay", label: t("payment.methods.wxpay") },
{ value: "stripe", label: t("payment.methods.stripe") },
{ value: "airwallex", label: t("payment.methods.airwallex") },
]);
function isPaymentTypeEnabled(type: string): boolean {
@@ -8220,6 +8221,7 @@ const providerKeyOptions = computed(() => [
{ value: "alipay", label: t("admin.settings.payment.providerAlipay") },
{ value: "wxpay", label: t("admin.settings.payment.providerWxpay") },
{ value: "stripe", label: t("admin.settings.payment.providerStripe") },
{ value: "airwallex", label: t("admin.settings.payment.providerAirwallex") },
]);
const enabledProviderKeyOptions = computed(() => {
@@ -192,6 +192,7 @@ const paymentTypeFilterOptions = computed(() => [
{ value: 'alipay', label: t('payment.methods.alipay') },
{ value: 'wxpay', label: t('payment.methods.wxpay') },
{ value: 'stripe', label: t('payment.methods.stripe') },
{ value: 'airwallex', label: t('payment.methods.airwallex') },
])
const orderTypeFilterOptions = computed(() => [
@@ -0,0 +1,133 @@
<template>
<AppLayout>
<div class="mx-auto max-w-lg space-y-6 py-8">
<div v-if="loading" class="flex items-center justify-center py-20">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-emerald-500 border-t-transparent"></div>
</div>
<div v-else-if="errorMessage" class="card p-8 text-center">
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
<Icon name="exclamationCircle" size="xl" class="text-red-500" />
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('payment.airwallexLoadFailed') }}</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ errorMessage }}</p>
<button class="btn btn-primary mt-6" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
</div>
<div v-else class="card p-6">
<div class="flex flex-col items-center space-y-4 py-4">
<div class="h-10 w-10 animate-spin rounded-full border-4 border-emerald-500 border-t-transparent"></div>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.payInNewWindowHint') }}</p>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
import {
PAYMENT_RECOVERY_STORAGE_KEY,
readPaymentRecoverySnapshot,
type PaymentRecoverySnapshot,
} from '@/components/payment/paymentFlow'
const { t, locale } = useI18n()
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const errorMessage = ref('')
function queryString(key: string): string {
const value = route.query[key]
if (Array.isArray(value)) return value[0] || ''
return typeof value === 'string' ? value : ''
}
function buildSuccessUrl(snapshot: PaymentRecoverySnapshot): string {
const url = new URL('/payment/result', window.location.origin)
const orderId = queryString('order_id')
const outTradeNo = queryString('out_trade_no')
const resumeToken = queryString('resume_token')
if (orderId || snapshot.orderId > 0) url.searchParams.set('order_id', orderId || String(snapshot.orderId))
if (outTradeNo || snapshot.outTradeNo) url.searchParams.set('out_trade_no', outTradeNo || snapshot.outTradeNo)
if (resumeToken || snapshot.resumeToken) url.searchParams.set('resume_token', resumeToken || snapshot.resumeToken)
return url.toString()
}
function restoreAirwallexSnapshot(): PaymentRecoverySnapshot | null {
if (typeof window === 'undefined') {
return null
}
const orderId = Number(queryString('order_id')) || 0
const outTradeNo = queryString('out_trade_no')
const resumeToken = queryString('resume_token')
const snapshot = readPaymentRecoverySnapshot(
window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY),
resumeToken ? { resumeToken } : {},
)
if (!snapshot || snapshot.paymentType !== 'airwallex') {
return null
}
if (orderId > 0 && snapshot.orderId !== orderId) {
return null
}
if (outTradeNo && snapshot.outTradeNo !== outTradeNo) {
return null
}
if (!snapshot.intentId || !snapshot.clientSecret) {
return null
}
return snapshot
}
onMounted(async () => {
const snapshot = restoreAirwallexSnapshot()
const checkoutLocale = locale.value.toLowerCase().startsWith('zh') ? 'zh' : 'en'
if (!snapshot) {
loading.value = false
errorMessage.value = t('payment.airwallexMissingParams')
return
}
try {
const airwallex = await import('@airwallex/components-sdk')
const result = await airwallex.init({
env: snapshot.paymentEnv === 'prod' ? 'prod' : 'demo',
enabledElements: ['payments'],
locale: checkoutLocale,
})
loading.value = false
const checkoutOptions = {
intent_id: snapshot.intentId,
client_secret: snapshot.clientSecret,
currency: snapshot.currency || 'CNY',
country_code: snapshot.countryCode || 'CN',
successUrl: buildSuccessUrl(snapshot),
}
if (!result.payments) {
throw new Error(t('payment.airwallexLoadFailed'))
}
const redirectResult = result.payments.redirectToCheckout(checkoutOptions)
if (typeof redirectResult === 'string' && redirectResult) {
window.location.assign(redirectResult)
}
} catch (err: unknown) {
loading.value = false
errorMessage.value = err instanceof Error && err.message
? err.message
: t('payment.airwallexLoadFailed')
}
})
</script>
+36 -10
View File
@@ -45,19 +45,19 @@
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.baseAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">&#165;{{ baseAmount.toFixed(2) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ formatGatewayAmount(baseAmount) }}</span>
</div>
<div v-if="order.fee_rate > 0" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.fee') }} ({{ order.fee_rate }}%)</span>
<span class="font-medium text-gray-900 dark:text-white">&#165;{{ feeAmount.toFixed(2) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ formatGatewayAmount(feeAmount) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</span>
<span class="font-bold text-primary-600 dark:text-primary-400">&#165;{{ order.pay_amount.toFixed(2) }}</span>
<span class="font-bold text-primary-600 dark:text-primary-400">{{ formatGatewayAmount(order.pay_amount) }}</span>
</div>
<div v-if="order.amount !== order.pay_amount" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.creditedAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ order.order_type === 'balance' ? '$' : '¥' }}{{ order.amount.toFixed(2) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ order.order_type === 'balance' ? '$' + order.amount.toFixed(2) : formatGatewayAmount(order.amount) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</span>
@@ -78,7 +78,7 @@
</div>
<div v-if="returnInfo.money" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">&#165;{{ returnInfo.money }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ formatGatewayAmount(Number(returnInfo.money) || 0) }}</span>
</div>
<div v-if="returnInfo.type" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</span>
@@ -109,15 +109,18 @@ import {
import { usePaymentStore } from '@/stores/payment'
import { paymentAPI } from '@/api/payment'
import type { PaymentOrder } from '@/types/payment'
import { formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import { normalizePaymentMethodForDisplay, paymentMethodI18nKey } from './paymentUx'
const { t } = useI18n()
const i18n = useI18n()
const { t } = i18n
const route = useRoute()
const router = useRouter()
const paymentStore = usePaymentStore()
const order = ref<PaymentOrder | null>(null)
const loading = ref(true)
const currency = ref('CNY')
interface ReturnInfo {
outTradeNo: string
@@ -147,6 +150,15 @@ const feeAmount = computed(() => {
return Math.round((order.value.pay_amount - baseAmount.value) * 100) / 100
})
const localeCode = computed(() => {
const raw = i18n.locale as unknown
if (typeof raw === 'string') return raw
if (raw && typeof raw === 'object' && 'value' in raw) {
return String((raw as { value?: string }).value || '')
}
return undefined
})
const isSuccess = computed(() => {
return isSuccessStatus(order.value?.status)
})
@@ -169,6 +181,17 @@ function normalizedOrderPaymentType(paymentType: string): string {
return normalizePaymentMethodForDisplay(paymentType) || paymentType
}
function formatGatewayAmount(value: number): string {
return formatPaymentAmount(value, currency.value, localeCode.value)
}
function setResolvedOrder(nextOrder: PaymentOrder | null): void {
order.value = nextOrder
if (nextOrder?.currency) {
currency.value = normalizePaymentCurrency(nextOrder.currency)
}
}
function normalizeOrderStatus(status: string | null | undefined): string {
return String(status || '').trim().toUpperCase()
}
@@ -276,7 +299,7 @@ function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>
refreshAttempts.value += 1
const refreshedOrder = await refreshOrder()
if (refreshedOrder) {
order.value = refreshedOrder
setResolvedOrder(refreshedOrder)
clearRecoverySnapshotForTerminalStatus(refreshedOrder.status)
}
@@ -301,6 +324,9 @@ onMounted(async () => {
if (restored?.orderId) {
orderId = restored.orderId
}
if (restored?.currency) {
currency.value = normalizePaymentCurrency(restored.currency)
}
if (!outTradeNo && restored?.outTradeNo) {
outTradeNo = restored.outTradeNo
}
@@ -308,7 +334,7 @@ onMounted(async () => {
if (resumeToken) {
const resolvedOrder = await resolveOrderFromResumeToken(resumeToken)
if (resolvedOrder) {
order.value = resolvedOrder
setResolvedOrder(resolvedOrder)
if (!orderId) {
orderId = resolvedOrder.id
}
@@ -327,7 +353,7 @@ onMounted(async () => {
if (!order.value && orderId && (!resumeToken || routeOrderId > 0)) {
try {
order.value = await paymentStore.pollOrderStatus(orderId)
setResolvedOrder(await paymentStore.pollOrderStatus(orderId))
} catch (_err: unknown) {
// Order lookup failed, will try legacy fallback below when possible.
}
@@ -336,7 +362,7 @@ onMounted(async () => {
if (!order.value && shouldUsePublicOutTradeNo && (!resumeToken || resumeTokenLookupFailed)) {
const legacyOrder = await resolveOrderFromOutTradeNo(outTradeNo)
if (legacyOrder) {
order.value = legacyOrder
setResolvedOrder(legacyOrder)
if (!orderId) {
orderId = legacyOrder.id
}
+50 -14
View File
@@ -21,6 +21,7 @@
:payment-type="paymentState.paymentType"
:pay-url="paymentState.payUrl"
:order-type="paymentState.orderType"
:currency="paymentState.currency || selectedCurrency"
@done="onPaymentDone"
@success="onPaymentSuccess"
@settled="onPaymentSettled"
@@ -60,15 +61,15 @@
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.paymentAmount') }}</span>
<span class="text-gray-900 dark:text-white">¥{{ validAmount.toFixed(2) }}</span>
<span class="text-gray-900 dark:text-white">{{ formatSelectedPaymentAmount(validAmount) }}</span>
</div>
<div v-if="feeRate > 0" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
<span class="text-gray-900 dark:text-white">¥{{ feeAmount.toFixed(2) }}</span>
<span class="text-gray-900 dark:text-white">{{ formatSelectedPaymentAmount(feeAmount) }}</span>
</div>
<div v-if="feeRate > 0" class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span>
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">¥{{ totalAmount.toFixed(2) }}</span>
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">{{ formatSelectedPaymentAmount(totalAmount) }}</span>
</div>
<div v-if="balanceRechargeMultiplier !== 1" class="flex justify-between" :class="{ 'border-t border-gray-200 pt-2 dark:border-dark-600': feeRate <= 0 }">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.creditedBalance') }}</span>
@@ -84,7 +85,7 @@
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
{{ t('common.processing') }}
</span>
<span v-else>{{ t('payment.createOrder') }} ¥{{ totalAmount.toFixed(2) }}</span>
<span v-else>{{ t('payment.createOrder') }} {{ formatSelectedPaymentAmount(totalAmount) }}</span>
</button>
</template>
</template>
@@ -103,9 +104,9 @@
<!-- Price -->
<div class="flex items-baseline gap-2">
<span v-if="selectedPlan.original_price" class="text-sm text-gray-400 line-through dark:text-gray-500">
¥{{ selectedPlan.original_price }}
{{ formatSelectedPaymentAmount(selectedPlan.original_price) }}
</span>
<span :class="['text-3xl font-bold', planTextClass]">¥{{ selectedPlan.price }}</span>
<span :class="['text-3xl font-bold', planTextClass]">{{ formatSelectedPaymentAmount(selectedPlan.price) }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">/ {{ planValiditySuffix }}</span>
</div>
<!-- Description -->
@@ -149,15 +150,15 @@
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.amountLabel') }}</span>
<span class="text-gray-900 dark:text-white">¥{{ selectedPlan.price.toFixed(2) }}</span>
<span class="text-gray-900 dark:text-white">{{ formatSelectedPaymentAmount(selectedPlan.price) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
<span class="text-gray-900 dark:text-white">¥{{ subFeeAmount.toFixed(2) }}</span>
<span class="text-gray-900 dark:text-white">{{ formatSelectedPaymentAmount(subFeeAmount) }}</span>
</div>
<div class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span>
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">¥{{ subTotalAmount.toFixed(2) }}</span>
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">{{ formatSelectedPaymentAmount(subTotalAmount) }}</span>
</div>
</div>
</div>
@@ -166,7 +167,7 @@
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
{{ t('common.processing') }}
</span>
<span v-else>{{ t('payment.createOrder') }} ¥{{ (feeRate > 0 ? subTotalAmount : selectedPlan.price).toFixed(2) }}</span>
<span v-else>{{ t('payment.createOrder') }} {{ formatSelectedPaymentAmount(feeRate > 0 ? subTotalAmount : selectedPlan.price) }}</span>
</button>
<button class="btn btn-secondary w-full" @click="selectedPlan = null">{{ t('common.cancel') }}</button>
</template>
@@ -274,11 +275,13 @@ import { platformAccentBarClass, platformBadgeLightClass, platformBadgeClass, pl
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
import Icon from '@/components/icons/Icon.vue'
import { formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
import { buildPaymentErrorToastMessage, describePaymentScenarioError } from './paymentUx'
import { hasWechatResumeQuery, parseWechatResumeRoute, stripWechatResumeQuery } from './paymentWechatResume'
const { t } = useI18n()
const i18n = useI18n()
const { t } = i18n
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
@@ -332,6 +335,10 @@ function emptyPaymentState(): PaymentRecoverySnapshot {
payUrl: '',
outTradeNo: '',
clientSecret: '',
intentId: '',
currency: '',
countryCode: '',
paymentEnv: '',
payAmount: 0,
orderType: '',
paymentMode: '',
@@ -523,6 +530,19 @@ const globalMaxAmount = computed(() => {
// Selected method's limits (for validation and error messages)
const selectedLimit = computed(() => visibleMethods.value[selectedMethod.value])
const selectedCurrency = computed(() => normalizePaymentCurrency(selectedLimit.value?.currency))
const localeCode = computed(() => {
const raw = i18n.locale as unknown
if (typeof raw === 'string') return raw
if (raw && typeof raw === 'object' && 'value' in raw) {
return String((raw as { value?: string }).value || '')
}
return undefined
})
function formatSelectedPaymentAmount(value: number): string {
return formatPaymentAmount(value, selectedCurrency.value, localeCode.value)
}
const methodOptions = computed<PaymentMethodOption[]>(() =>
enabledMethods.value.map((type) => {
@@ -556,8 +576,8 @@ const amountError = computed(() => {
// Selected method can't handle this amount (but others can)
const ml = selectedLimit.value
if (ml) {
if (ml.single_min > 0 && validAmount.value < ml.single_min) return t('payment.amountTooLow', { min: ml.single_min })
if (ml.single_max > 0 && validAmount.value > ml.single_max) return t('payment.amountTooHigh', { max: ml.single_max })
if (ml.single_min > 0 && validAmount.value < ml.single_min) return t('payment.amountTooLow', { min: formatSelectedPaymentAmount(ml.single_min) })
if (ml.single_max > 0 && validAmount.value > ml.single_max) return t('payment.amountTooHigh', { max: formatSelectedPaymentAmount(ml.single_max) })
}
return ''
})
@@ -613,6 +633,7 @@ const paymentButtonClass = computed(() => {
if (m.includes('alipay')) return 'btn-alipay'
if (m.includes('wxpay')) return 'btn-wxpay'
if (m === 'stripe') return 'btn-stripe'
if (m === 'airwallex') return 'btn-airwallex'
return 'btn-primary'
})
@@ -698,7 +719,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
const stripeMethod = visibleMethod === 'stripe'
? ''
: visibleMethod === 'wxpay' ? 'wechat_pay' : 'alipay'
const stripeRouteUrl = result.client_secret
const stripeRouteUrl = result.client_secret && visibleMethod !== 'airwallex'
? router.resolve({
path: '/payment/stripe',
query: {
@@ -709,6 +730,16 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
},
}).href
: ''
const airwallexRouteUrl = result.client_secret && result.intent_id
? router.resolve({
path: '/payment/airwallex',
query: {
order_id: String(result.order_id),
out_trade_no: result.out_trade_no || undefined,
resume_token: result.resume_token || undefined,
},
}).href
: ''
const decision = decidePaymentLaunch(result, {
visibleMethod,
orderType,
@@ -716,6 +747,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent),
stripePopupUrl: stripeRouteUrl,
stripeRouteUrl,
airwallexRouteUrl,
})
if (decision.kind === 'wechat_oauth' && decision.oauth?.authorize_url) {
@@ -745,6 +777,10 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
window.location.href = decision.paymentState.payUrl
return
}
if (decision.kind === 'airwallex_route') {
window.location.href = decision.paymentState.payUrl
return
}
if (decision.kind === 'wechat_jsapi' && decision.jsapi) {
try {
const jsapiResult = await invokeWechatJsapiPayment(decision.jsapi as Record<string, unknown>)
+42 -16
View File
@@ -13,15 +13,15 @@
<button class="btn btn-primary mt-6" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
</div>
<template v-else>
<!-- Amount header -->
<!-- 金额头部 -->
<div v-if="order" class="card overflow-hidden">
<div class="bg-gradient-to-br from-[#635bff] to-[#4f46e5] px-6 py-6 text-center">
<p class="text-sm font-medium text-indigo-200">{{ t('payment.actualPay') }}</p>
<p class="mt-1 text-3xl font-bold text-white">&#165;{{ order.pay_amount.toFixed(2) }}</p>
<p class="mt-1 text-3xl font-bold text-white">{{ formatGatewayAmount(order.pay_amount) }}</p>
</div>
</div>
<!-- WeChat QR Code display -->
<!-- 微信二维码展示 -->
<template v-if="wechatQrUrl">
<div class="card p-6">
<div class="flex flex-col items-center space-y-4">
@@ -42,7 +42,7 @@
</div>
</template>
<!-- Alipay redirecting state -->
<!-- 支付宝跳转状态 -->
<template v-else-if="redirecting">
<div class="card p-6">
<div class="flex flex-col items-center space-y-4 py-4">
@@ -52,7 +52,7 @@
</div>
</template>
<!-- Success state -->
<!-- 成功状态 -->
<template v-else-if="stripeSuccess">
<div class="card p-6 text-center">
<div class="flex flex-col items-center gap-3 py-4">
@@ -65,7 +65,7 @@
</div>
</template>
<!-- Fallback: full Payment Element (no method param or unknown method) -->
<!-- 无指定方式或未知方式时展示完整 Payment Element -->
<template v-else-if="showPaymentElement">
<div class="card p-6">
<div id="stripe-payment-element" class="min-h-[200px]"></div>
@@ -83,7 +83,7 @@
</div>
</template>
<!-- Error -->
<!-- 错误状态 -->
<div v-if="stripeError && !showPaymentElement" class="card p-4">
<p class="text-sm text-red-600 dark:text-red-400">{{ stripeError }}</p>
<button class="btn btn-secondary mt-3 w-full" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
@@ -101,17 +101,20 @@ import { usePaymentStore } from '@/stores/payment'
import { paymentAPI } from '@/api/payment'
import { extractI18nErrorMessage } from '@/utils/apiError'
import { isMobileDevice } from '@/utils/device'
import { formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import { PAYMENT_RECOVERY_STORAGE_KEY, readPaymentRecoverySnapshot } from '@/components/payment/paymentFlow'
import type { PaymentOrder } from '@/types/payment'
import type { Stripe, StripeElements } from '@stripe/stripe-js'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const i18n = useI18n()
const { t } = i18n
const route = useRoute()
const router = useRouter()
const paymentStore = usePaymentStore()
// Popup mode: skip AppLayout when opened with a specific method (alipay/wechat_pay)
// 弹窗模式:指定支付宝或微信方式时跳过 AppLayout
const isPopup = computed(() => !!route.query.method)
const loading = ref(true)
@@ -121,6 +124,7 @@ const stripeSubmitting = ref(false)
const stripeSuccess = ref(false)
const stripeReady = ref(false)
const order = ref<PaymentOrder | null>(null)
const currency = ref('CNY')
const wechatQrUrl = ref('')
const redirecting = ref(false)
const showPaymentElement = ref(false)
@@ -133,6 +137,7 @@ onMounted(async () => {
const orderId = Number(route.query.order_id)
const clientSecret = String(route.query.client_secret || '')
const method = String(route.query.method || '')
const resumeToken = typeof route.query.resume_token === 'string' ? route.query.resume_token : undefined
if (!orderId || !clientSecret) {
loading.value = false
@@ -141,8 +146,20 @@ onMounted(async () => {
}
try {
if (typeof window !== 'undefined') {
const restored = readPaymentRecoverySnapshot(
window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY),
{ resumeToken },
)
if (restored?.orderId === orderId) {
currency.value = normalizePaymentCurrency(restored.currency)
}
}
const res = await paymentAPI.getOrder(orderId)
order.value = res.data
if (res.data.currency) {
currency.value = normalizePaymentCurrency(res.data.currency)
}
await paymentStore.fetchConfig()
const publishableKey = paymentStore.config?.stripe_publishable_key
@@ -155,13 +172,13 @@ onMounted(async () => {
stripeInstance = stripe
loading.value = false
// Direct confirm for specific methods (no Payment Element needed)
// 指定方式直接确认,无需渲染完整 Payment Element
if (method === 'alipay') {
await confirmAlipay(stripe, clientSecret, orderId)
} else if (method === 'wechat_pay') {
await confirmWechatPay(stripe, clientSecret)
} else {
// Fallback: render full Payment Element
// 未指定方式时渲染完整 Payment Element
showPaymentElement.value = true
await nextTick()
mountPaymentElement(stripe, clientSecret)
@@ -173,10 +190,19 @@ onMounted(async () => {
}
})
onUnmounted(() => {
if (redirectTimer) clearTimeout(redirectTimer)
const localeCode = computed(() => {
const raw = i18n.locale as unknown
if (typeof raw === 'string') return raw
if (raw && typeof raw === 'object' && 'value' in raw) {
return String((raw as { value?: string }).value || '')
}
return undefined
})
function formatGatewayAmount(value: number): string {
return formatPaymentAmount(value, currency.value, localeCode.value)
}
async function confirmAlipay(stripe: Stripe, clientSecret: string, orderId: number) {
redirecting.value = true
const returnUrl = window.location.origin + '/payment/result?order_id=' + orderId + '&status=success'
@@ -185,7 +211,7 @@ async function confirmAlipay(stripe: Stripe, clientSecret: string, orderId: numb
redirecting.value = false
stripeError.value = error.message || t('payment.result.failed')
}
// If no error, Stripe redirects automatically — nothing else to do
// 无错误时 Stripe 会自动跳转
}
async function confirmWechatPay(stripe: Stripe, clientSecret: string) {
@@ -200,11 +226,11 @@ async function confirmWechatPay(stripe: Stripe, clientSecret: string) {
return
}
// Extract QR code image from next_action
// next_action 中提取二维码
const qrData = paymentIntent?.next_action?.wechat_pay_display_qr_code?.image_data_url
if (qrData) {
wechatQrUrl.value = qrData
// Poll for completion
// 轮询支付完成状态
startPolling()
} else if (paymentIntent?.status === 'succeeded') {
stripeSuccess.value = true
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, shallowMount } from '@vue/test-utils'
import AirwallexPaymentView from '../AirwallexPaymentView.vue'
import {
PAYMENT_RECOVERY_STORAGE_KEY,
type PaymentRecoverySnapshot,
} from '@/components/payment/paymentFlow'
const routeState = vi.hoisted(() => ({
query: {} as Record<string, unknown>,
}))
const routerPush = vi.hoisted(() => vi.fn())
const airwallexInit = vi.hoisted(() => vi.fn())
const redirectToCheckout = vi.hoisted(() => vi.fn())
vi.mock('vue-router', async () => {
const actual = await vi.importActual<typeof import('vue-router')>('vue-router')
return {
...actual,
useRoute: () => routeState,
useRouter: () => ({ push: routerPush }),
}
})
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key,
locale: { value: 'zh-CN' },
}),
}
})
vi.mock('@airwallex/components-sdk', () => ({
init: airwallexInit,
}))
function airwallexSnapshot(overrides: Partial<PaymentRecoverySnapshot> = {}): PaymentRecoverySnapshot {
return {
orderId: 101,
amount: 88,
qrCode: '',
expiresAt: '2099-01-01T00:10:00.000Z',
paymentType: 'airwallex',
payUrl: '/payment/airwallex?order_id=101&out_trade_no=sub2_awx_101&resume_token=resume-awx',
outTradeNo: 'sub2_awx_101',
clientSecret: 'awx_client_secret',
intentId: 'int_awx_101',
currency: 'CNY',
countryCode: 'CN',
paymentEnv: 'demo',
payAmount: 88,
orderType: 'balance',
paymentMode: '',
resumeToken: 'resume-awx',
createdAt: Date.UTC(2099, 0, 1, 0, 0, 0),
...overrides,
}
}
function mountView() {
return shallowMount(AirwallexPaymentView, {
global: {
stubs: {
AppLayout: { template: '<div><slot /></div>' },
Icon: true,
},
},
})
}
describe('AirwallexPaymentView', () => {
beforeEach(() => {
routeState.query = {}
routerPush.mockReset()
airwallexInit.mockReset().mockResolvedValue({
payments: {
redirectToCheckout,
},
})
redirectToCheckout.mockReset()
window.localStorage.clear()
})
it('从本地恢复快照读取支付参数,避免在 URL 中暴露 client_secret', async () => {
routeState.query = {
order_id: '101',
out_trade_no: 'sub2_awx_101',
resume_token: 'resume-awx',
}
window.localStorage.setItem(
PAYMENT_RECOVERY_STORAGE_KEY,
JSON.stringify(airwallexSnapshot()),
)
mountView()
await flushPromises()
await flushPromises()
expect(airwallexInit).toHaveBeenCalledWith({
env: 'demo',
enabledElements: ['payments'],
locale: 'zh',
})
expect(redirectToCheckout).toHaveBeenCalledWith(expect.objectContaining({
intent_id: 'int_awx_101',
client_secret: 'awx_client_secret',
currency: 'CNY',
country_code: 'CN',
}))
const checkoutOptions = redirectToCheckout.mock.calls[0][0]
const successUrl = new URL(checkoutOptions.successUrl)
expect(successUrl.searchParams.get('order_id')).toBe('101')
expect(successUrl.searchParams.get('out_trade_no')).toBe('sub2_awx_101')
expect(successUrl.searchParams.get('resume_token')).toBe('resume-awx')
})
it('拒绝只从 URL query 读取 Airwallex 支付密钥', async () => {
routeState.query = {
order_id: '101',
intent_id: 'int_from_query',
client_secret: 'secret_from_query',
}
const wrapper = mountView()
await flushPromises()
expect(airwallexInit).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('payment.airwallexMissingParams')
})
})
@@ -44,6 +44,7 @@ vi.mock('@/api/payment', () => ({
import PaymentResultView from '../PaymentResultView.vue'
import { PAYMENT_RECOVERY_STORAGE_KEY } from '@/components/payment/paymentFlow'
import { formatPaymentAmount } from '@/components/payment/currency'
const orderFactory = (status: string) => ({
id: 42,
@@ -69,6 +70,10 @@ const recoverySnapshotFactory = (resumeToken: string) => ({
payUrl: 'https://pay.example.com/session/42',
outTradeNo: 'sub2_20260420abcd1234',
clientSecret: '',
intentId: '',
currency: '',
countryCode: '',
paymentEnv: '',
payAmount: 88,
orderType: 'balance',
paymentMode: 'popup',
@@ -105,6 +110,10 @@ describe('PaymentResultView', () => {
payUrl: 'https://pay.example.com/session/42',
outTradeNo: 'sub2_20260420abcd1234',
clientSecret: '',
intentId: '',
currency: '',
countryCode: '',
paymentEnv: '',
payAmount: 88,
orderType: 'balance',
paymentMode: 'redirect',
@@ -147,6 +156,10 @@ describe('PaymentResultView', () => {
payUrl: 'https://pay.example.com/session/42',
outTradeNo: 'sub2_20260420abcd1234',
clientSecret: '',
intentId: '',
currency: '',
countryCode: '',
paymentEnv: '',
payAmount: 88,
orderType: 'balance',
paymentMode: 'popup',
@@ -375,6 +388,33 @@ describe('PaymentResultView', () => {
expect(wrapper.text()).toContain('payment.result.success')
})
it('uses the currency returned by the order API when rendering amounts', async () => {
routeState.query = {
resume_token: 'resume-hkd',
}
resolveOrderPublicByResumeToken.mockResolvedValue({
data: {
...orderFactory('PAID'),
currency: 'HKD',
amount: 100,
pay_amount: 103,
fee_rate: 3,
},
})
const wrapper = mount(PaymentResultView, {
global: {
stubs: {
OrderStatusBadge: true,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain(formatPaymentAmount(103, 'HKD'))
})
it('normalizes aliased payment methods before rendering the label', async () => {
routeState.query = {
resume_token: 'resume-88',
@@ -293,6 +293,10 @@ describe('PaymentView WeChat JSAPI flow', () => {
payUrl: 'https://pay.example.com/stale',
outTradeNo: 'stale-out-trade-no',
clientSecret: '',
intentId: '',
currency: '',
countryCode: '',
paymentEnv: '',
payAmount: 66,
orderType: 'balance',
paymentMode: 'popup',
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, shallowMount } from '@vue/test-utils'
const routeState = vi.hoisted(() => ({
query: {} as Record<string, unknown>,
}))
const routerPush = vi.hoisted(() => vi.fn())
const getOrder = vi.hoisted(() => vi.fn())
const paymentStore = vi.hoisted(() => ({
config: { stripe_publishable_key: 'pk_test' } as { stripe_publishable_key?: string },
fetchConfig: vi.fn(),
pollOrderStatus: vi.fn(),
}))
const loadStripe = vi.hoisted(() => vi.fn())
const stripeElements = vi.hoisted(() => ({
create: vi.fn(),
}))
const stripePaymentElement = vi.hoisted(() => ({
mount: vi.fn(),
on: vi.fn(),
}))
const stripeInstance = vi.hoisted(() => ({
elements: vi.fn(),
confirmPayment: vi.fn(),
confirmAlipayPayment: vi.fn(),
confirmWechatPayPayment: vi.fn(),
}))
vi.mock('vue-router', async () => {
const actual = await vi.importActual<typeof import('vue-router')>('vue-router')
return {
...actual,
useRoute: () => routeState,
useRouter: () => ({ push: routerPush }),
}
})
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key,
locale: { value: 'zh-CN' },
}),
}
})
vi.mock('@/stores/payment', () => ({
usePaymentStore: () => paymentStore,
}))
vi.mock('@/api/payment', () => ({
paymentAPI: {
getOrder,
},
}))
vi.mock('@stripe/stripe-js', () => ({
loadStripe,
}))
import StripePaymentView from '../StripePaymentView.vue'
import { formatPaymentAmount } from '@/components/payment/currency'
import type { PaymentOrder } from '@/types/payment'
function orderFactory(overrides: Partial<PaymentOrder> = {}): PaymentOrder {
return {
id: 42,
user_id: 7,
amount: 100,
pay_amount: 103,
currency: 'CNY',
fee_rate: 0.03,
payment_type: 'stripe',
out_trade_no: 'sub2_stripe_42',
status: 'PENDING',
order_type: 'balance',
created_at: '2026-04-20T12:00:00Z',
expires_at: '2026-04-20T12:30:00Z',
refund_amount: 0,
...overrides,
}
}
function mountView() {
return shallowMount(StripePaymentView, {
global: {
stubs: {
AppLayout: { template: '<div><slot /></div>' },
Icon: true,
},
},
})
}
describe('StripePaymentView', () => {
beforeEach(() => {
routeState.query = {
order_id: '42',
client_secret: 'pi_secret_42',
}
routerPush.mockReset()
getOrder.mockReset()
paymentStore.config = { stripe_publishable_key: 'pk_test' }
paymentStore.fetchConfig.mockReset().mockResolvedValue(undefined)
paymentStore.pollOrderStatus.mockReset()
loadStripe.mockReset().mockResolvedValue(stripeInstance)
stripeElements.create.mockReset().mockReturnValue(stripePaymentElement)
stripePaymentElement.mount.mockReset()
stripePaymentElement.on.mockReset().mockImplementation((event: string, callback: () => void) => {
if (event === 'ready') callback()
})
stripeInstance.elements.mockReset().mockReturnValue(stripeElements)
stripeInstance.confirmPayment.mockReset()
stripeInstance.confirmAlipayPayment.mockReset()
stripeInstance.confirmWechatPayPayment.mockReset()
window.localStorage.clear()
})
it('本地恢复快照缺失时使用订单接口返回的 Stripe 币种展示金额', async () => {
getOrder.mockResolvedValue({
data: orderFactory({ currency: 'HKD', pay_amount: 103 }),
})
const wrapper = mountView()
await flushPromises()
await flushPromises()
expect(getOrder).toHaveBeenCalledWith(42)
expect(loadStripe).toHaveBeenCalledWith('pk_test')
expect(wrapper.text()).toContain(formatPaymentAmount(103, 'HKD', 'zh-CN'))
})
})