feat: add Airwallex payments and multi-currency support
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@airwallex/components-sdk": "^1.30.2",
|
||||
"@lobehub/icons": "^4.0.2",
|
||||
"@stripe/stripe-js": "^9.0.1",
|
||||
"@tanstack/vue-virtual": "^3.13.23",
|
||||
|
||||
Generated
+15
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@airwallex/components-sdk':
|
||||
specifier: ^1.30.2
|
||||
version: 1.30.2
|
||||
'@lobehub/icons':
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2(@lobehub/ui@4.9.2)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -129,6 +132,12 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@airwallex/airtracker@3.2.0':
|
||||
resolution: {integrity: sha512-PKE5N38ajTVg6ph9JzLpWsICNjqLtf/wWudNVU3UPX9SVy2I5s5ITc281sMSD8+LIE6RJoGjGTO+VYP/io5kig==}
|
||||
|
||||
'@airwallex/components-sdk@1.30.2':
|
||||
resolution: {integrity: sha512-BGwAPCACwOJm8XNxDxJGMq1o/73D9+ZWifvp5YHvfgIwxg1RGVCIME0tP1g8cash3fVLHgl7xObyS1QbIOSDXw==}
|
||||
|
||||
'@alloc/quick-lru@5.2.0':
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -4516,6 +4525,12 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@airwallex/airtracker@3.2.0': {}
|
||||
|
||||
'@airwallex/components-sdk@1.30.2':
|
||||
dependencies:
|
||||
'@airwallex/airtracker': 3.2.0
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48.1 32.3" role="img" aria-label="Airwallex">
|
||||
<defs>
|
||||
<linearGradient id="airwallex-mark" x1="0" y1="2" x2="48" y2="30" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#FF4F42"/>
|
||||
<stop offset="1" stop-color="#FF8E3C"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill="url(#airwallex-mark)"
|
||||
d="M312.76 380.53a6 6 0 0 1 1.42 6.42l-3.18 8.58a6.89 6.89 0 0 1-5 4.47 6.8 6.8 0 0 1-1.3.13 6.58 6.58 0 0 1-5.08-2.4l-19-22.69a.42.42 0 0 0-.71.12l-6.17 16.67a.42.42 0 0 0 .55.54l7.57-3.09a3.34 3.34 0 0 1 4.44 2.08 3.47 3.47 0 0 1-2 4.24l-9.89 4a5.93 5.93 0 0 1-7.88-7.56l7.29-19.68a6.84 6.84 0 0 1 11.68-2l10.88 13 10-4.08a5.84 5.84 0 0 1 6.38 1.24ZM307 387.07a.42.42 0 0 0-.55-.54l-5.53 2.26 3.32 4a.42.42 0 0 0 .71-.13Z"
|
||||
transform="translate(-266.13 -367.85)"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 858 B |
@@ -212,6 +212,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(() => [
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
@click="method.available && emit('select', method.type)"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<img :src="methodIcon(method.type)" :alt="t(`payment.methods.${method.type}`)" class="h-7 w-7" />
|
||||
<img :src="methodIcon(method.type)" :alt="t(`payment.methods.${method.type}`)" class="h-7 w-7 object-contain" />
|
||||
<span class="flex flex-col items-start leading-none">
|
||||
<span class="text-base font-semibold">{{ t(`payment.methods.${method.type}`) }}</span>
|
||||
<span
|
||||
@@ -43,6 +43,7 @@ import { METHOD_ORDER } from './providerConfig'
|
||||
import alipayIcon from '@/assets/icons/alipay.svg'
|
||||
import wxpayIcon from '@/assets/icons/wxpay.svg'
|
||||
import stripeIcon from '@/assets/icons/stripe.svg'
|
||||
import airwallexIcon from '@/assets/icons/airwallex.svg'
|
||||
|
||||
export interface PaymentMethodOption {
|
||||
type: string
|
||||
@@ -65,6 +66,7 @@ const METHOD_ICONS: Record<string, string> = {
|
||||
alipay: alipayIcon,
|
||||
wxpay: wxpayIcon,
|
||||
stripe: stripeIcon,
|
||||
airwallex: airwallexIcon,
|
||||
}
|
||||
|
||||
const sortedMethods = computed(() => {
|
||||
@@ -79,6 +81,7 @@ const sortedMethods = computed(() => {
|
||||
function methodIcon(type: string): string {
|
||||
if (type.includes('alipay')) return METHOD_ICONS.alipay
|
||||
if (type.includes('wxpay')) return METHOD_ICONS.wxpay
|
||||
if (type === 'airwallex') return METHOD_ICONS.airwallex
|
||||
return METHOD_ICONS[type] || alipayIcon
|
||||
}
|
||||
|
||||
@@ -86,6 +89,7 @@ function methodSelectedClass(type: string): string {
|
||||
if (type.includes('alipay')) return 'border-[#02A9F1] bg-blue-50 text-gray-900 shadow-sm dark:bg-blue-950 dark:text-gray-100'
|
||||
if (type.includes('wxpay')) return 'border-[#09BB07] bg-green-50 text-gray-900 shadow-sm dark:bg-green-950 dark:text-gray-100'
|
||||
if (type === 'stripe') return 'border-[#676BE5] bg-indigo-50 text-gray-900 shadow-sm dark:bg-indigo-950 dark:text-gray-100'
|
||||
if (type === 'airwallex') return 'border-[#FF6B3D] bg-orange-50 text-gray-900 shadow-sm dark:border-[#FF8E3C] dark:bg-orange-950 dark:text-gray-100'
|
||||
return 'border-primary-500 bg-primary-50 text-gray-900 shadow-sm dark:bg-primary-950 dark:text-gray-100'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -149,6 +149,12 @@
|
||||
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<Select
|
||||
v-else-if="field.options?.length"
|
||||
v-model="config[field.key]"
|
||||
:options="field.options"
|
||||
:searchable="field.options.length > 5"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
type="text"
|
||||
@@ -156,6 +162,9 @@
|
||||
class="input"
|
||||
:placeholder="field.defaultValue || ''"
|
||||
/>
|
||||
<p v-if="field.hintKey" class="mt-1 text-xs leading-relaxed text-gray-500 dark:text-gray-400">
|
||||
{{ t(field.hintKey) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -177,14 +186,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stripe webhook hint -->
|
||||
<div v-if="stripeWebhookUrl" class="mt-3 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800/50 dark:bg-blue-900/20">
|
||||
<!-- 服务商 Webhook 提示 -->
|
||||
<div v-if="providerWebhookUrl" class="mt-3 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800/50 dark:bg-blue-900/20">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-300">
|
||||
{{ t('admin.settings.payment.stripeWebhookHint') }}
|
||||
{{ t(providerWebhookHint) }}
|
||||
</p>
|
||||
<code class="mt-1 block break-all rounded bg-blue-100 px-2 py-1 text-xs text-blue-800 dark:bg-blue-900/40 dark:text-blue-200">
|
||||
{{ stripeWebhookUrl }}
|
||||
{{ providerWebhookUrl }}
|
||||
</code>
|
||||
<p v-if="form.provider_key === 'stripe'" class="mt-2 text-xs leading-relaxed text-blue-700 dark:text-blue-300">
|
||||
{{ t('admin.settings.payment.stripeWebhookApiVersionHint', { version: STRIPE_SDK_API_VERSION }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -266,6 +278,7 @@ import {
|
||||
WEBHOOK_PATHS,
|
||||
PAYMENT_MODE_QRCODE,
|
||||
PAYMENT_MODE_POPUP,
|
||||
STRIPE_SDK_API_VERSION,
|
||||
getAvailableTypes,
|
||||
extractBaseUrl,
|
||||
} from './providerConfig'
|
||||
@@ -330,8 +343,18 @@ const visibleFields = reactive<Record<string, boolean>>({})
|
||||
// --- Computed ---
|
||||
const defaultBaseUrl = typeof window !== 'undefined' ? window.location.origin : ''
|
||||
|
||||
const stripeWebhookUrl = computed(() =>
|
||||
form.provider_key === 'stripe' ? defaultBaseUrl + WEBHOOK_PATHS.stripe : '',
|
||||
const providerWebhookHintMap: Record<string, string> = {
|
||||
stripe: 'admin.settings.payment.stripeWebhookHint',
|
||||
airwallex: 'admin.settings.payment.airwallexWebhookHint',
|
||||
}
|
||||
|
||||
const providerWebhookUrl = computed(() => {
|
||||
const path = WEBHOOK_PATHS[form.provider_key]
|
||||
return providerWebhookHintMap[form.provider_key] && path ? defaultBaseUrl + path : ''
|
||||
})
|
||||
|
||||
const providerWebhookHint = computed(() =>
|
||||
providerWebhookHintMap[form.provider_key] || 'admin.settings.payment.stripeWebhookHint',
|
||||
)
|
||||
|
||||
const callbackPaths = computed(() => PROVIDER_CALLBACK_PATHS[form.provider_key] || null)
|
||||
@@ -415,6 +438,14 @@ const paymentGuide = computed<PaymentGuide | null>(() => {
|
||||
}
|
||||
}
|
||||
|
||||
if (form.provider_key === 'airwallex') {
|
||||
return {
|
||||
summary: t('admin.settings.payment.airwallexGuideSummary'),
|
||||
note: t('admin.settings.payment.airwallexGuideNote'),
|
||||
items: [],
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
@@ -527,9 +558,19 @@ function handleSave() {
|
||||
}
|
||||
}
|
||||
|
||||
const clearableConfigKeys = new Set(
|
||||
(PROVIDER_CONFIG_FIELDS[form.provider_key] || [])
|
||||
.filter(field => field.clearable)
|
||||
.map(field => field.key),
|
||||
)
|
||||
const filteredConfig: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(config)) {
|
||||
if (!v || !v.trim()) continue
|
||||
if (!v || !v.trim()) {
|
||||
if (clearableConfigKeys.has(k)) {
|
||||
filteredConfig[k] = ''
|
||||
}
|
||||
continue
|
||||
}
|
||||
filteredConfig[k] = v
|
||||
}
|
||||
|
||||
|
||||
@@ -22,11 +22,11 @@
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ paidOrder.order_type === 'balance' ? '$' : '¥' }}{{ paidOrder.amount.toFixed(2) }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ paidOrder.order_type === 'balance' ? '$' + paidOrder.amount.toFixed(2) : formatGatewayAmount(paidOrder.amount) }}</span>
|
||||
</div>
|
||||
<div 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">¥{{ paidOrder.pay_amount.toFixed(2) }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ formatGatewayAmount(paidOrder.pay_amount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,6 +129,7 @@ import { useAppStore } from '@/stores'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractI18nErrorMessage } from '@/utils/apiError'
|
||||
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
|
||||
import { formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import QRCode from 'qrcode'
|
||||
@@ -142,13 +143,15 @@ const props = defineProps<{
|
||||
paymentType: string
|
||||
payUrl?: string
|
||||
orderType?: string
|
||||
currency?: string
|
||||
}>()
|
||||
|
||||
type PaymentOutcome = 'success' | 'cancelled' | 'expired'
|
||||
|
||||
const emit = defineEmits<{ done: []; success: []; settled: [outcome: PaymentOutcome] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const i18n = useI18n()
|
||||
const { t } = i18n
|
||||
const paymentStore = usePaymentStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
@@ -157,6 +160,15 @@ const qrUrl = ref('')
|
||||
const remainingSeconds = ref(0)
|
||||
const cancelling = ref(false)
|
||||
const paidOrder = ref<PaymentOrder | null>(null)
|
||||
const paymentCurrency = computed(() => normalizePaymentCurrency(props.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
|
||||
})
|
||||
|
||||
// Terminal outcome: null = still active, 'success' | 'cancelled' | 'expired'
|
||||
const outcome = ref<PaymentOutcome | null>(null)
|
||||
@@ -197,6 +209,10 @@ const countdownDisplay = computed(() => {
|
||||
return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0')
|
||||
})
|
||||
|
||||
function formatGatewayAmount(value: number): string {
|
||||
return formatPaymentAmount(value, paymentCurrency.value, localeCode.value)
|
||||
}
|
||||
|
||||
function isSuccessStatus(status: string | null | undefined): boolean {
|
||||
return status === 'COMPLETED' || status === 'PAID' || status === 'RECHARGING'
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ const PROVIDER_KEY_LABELS: Record<string, string> = {
|
||||
alipay: 'admin.settings.payment.providerAlipay',
|
||||
wxpay: 'admin.settings.payment.providerWxpay',
|
||||
stripe: 'admin.settings.payment.providerStripe',
|
||||
airwallex: 'admin.settings.payment.providerAirwallex',
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -2,34 +2,66 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import PaymentProviderDialog from '@/components/payment/PaymentProviderDialog.vue'
|
||||
import { STRIPE_SDK_API_VERSION } from '@/components/payment/providerConfig'
|
||||
import type { ProviderInstance } from '@/types/payment'
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
'admin.settings.payment.providerConfig': 'Credentials',
|
||||
'admin.settings.payment.paymentGuideTrigger': 'View payment guide',
|
||||
'admin.settings.payment.alipayGuideSummary': 'Desktop prefers QR precreate and falls back to cashier; mobile prefers WAP checkout.',
|
||||
'admin.settings.payment.wxpayGuideSummary': 'Desktop prefers Native QR; mobile routes to JSAPI or H5 based on browser context.',
|
||||
'admin.settings.payment.airwallexGuideSummary': 'Use Payment Acceptance read/write only.',
|
||||
'admin.settings.payment.stripeWebhookHint': 'Configure Stripe webhook.',
|
||||
'admin.settings.payment.stripeWebhookApiVersionHint': 'Use Stripe API version {version}.',
|
||||
'admin.settings.payment.airwallexWebhookHint': 'Select payment_intent.succeeded and use the latest stable API version.',
|
||||
}
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => messages[key] ?? key,
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
const message = messages[key] ?? key
|
||||
if (!params) return message
|
||||
return Object.entries(params).reduce(
|
||||
(value, [name, replacement]) => value.replaceAll(`{${name}}`, replacement),
|
||||
message,
|
||||
)
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
function mountDialog() {
|
||||
function providerFactory(overrides: Partial<ProviderInstance> = {}): ProviderInstance {
|
||||
return {
|
||||
id: 1,
|
||||
provider_key: 'airwallex',
|
||||
name: 'Airwallex',
|
||||
config: {},
|
||||
supported_types: ['airwallex'],
|
||||
enabled: true,
|
||||
payment_mode: '',
|
||||
refund_enabled: false,
|
||||
allow_user_refund: false,
|
||||
limits: '',
|
||||
sort_order: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mountDialog(options: { editing?: ProviderInstance | null } = {}) {
|
||||
return mount(PaymentProviderDialog, {
|
||||
props: {
|
||||
show: true,
|
||||
saving: false,
|
||||
editing: null,
|
||||
editing: options.editing ?? null,
|
||||
allKeyOptions: [
|
||||
{ value: 'alipay', label: 'Alipay' },
|
||||
{ value: 'wxpay', label: 'WeChat Pay' },
|
||||
{ value: 'stripe', label: 'Stripe' },
|
||||
{ value: 'airwallex', label: 'Airwallex' },
|
||||
],
|
||||
enabledKeyOptions: [
|
||||
{ value: 'alipay', label: 'Alipay' },
|
||||
{ value: 'wxpay', label: 'WeChat Pay' },
|
||||
{ value: 'airwallex', label: 'Airwallex' },
|
||||
],
|
||||
allPaymentTypes: [
|
||||
{ value: 'alipay', label: 'Alipay' },
|
||||
@@ -66,6 +98,7 @@ describe('PaymentProviderDialog payment guide', () => {
|
||||
it.each([
|
||||
['alipay', 'admin.settings.payment.alipayGuideSummary'],
|
||||
['wxpay', 'admin.settings.payment.wxpayGuideSummary'],
|
||||
['airwallex', 'admin.settings.payment.airwallexGuideSummary'],
|
||||
])('shows the payment guide summary for %s', async (providerKey, summaryKey) => {
|
||||
const wrapper = mountDialog()
|
||||
|
||||
@@ -75,4 +108,52 @@ describe('PaymentProviderDialog payment guide', () => {
|
||||
expect(wrapper.text()).toContain(messages[summaryKey])
|
||||
expect(wrapper.find('button[title="View payment guide"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows Airwallex webhook event and API version guidance with the webhook URL', async () => {
|
||||
const wrapper = mountDialog()
|
||||
|
||||
;(wrapper.vm as unknown as { reset: (key: string) => void }).reset('airwallex')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain(messages['admin.settings.payment.airwallexWebhookHint'])
|
||||
expect(wrapper.text()).toContain('/api/v1/payment/webhook/airwallex')
|
||||
})
|
||||
|
||||
it('shows Stripe webhook API version guidance with the integrated SDK version', async () => {
|
||||
const wrapper = mountDialog()
|
||||
|
||||
;(wrapper.vm as unknown as { reset: (key: string) => void }).reset('stripe')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain(messages['admin.settings.payment.stripeWebhookHint'])
|
||||
expect(wrapper.text()).toContain(`Use Stripe API version ${STRIPE_SDK_API_VERSION}.`)
|
||||
expect(wrapper.text()).toContain('/api/v1/payment/webhook/stripe')
|
||||
})
|
||||
|
||||
it('emits an empty Airwallex accountId when the admin clears it', async () => {
|
||||
const provider = providerFactory({
|
||||
config: {
|
||||
clientId: 'cid_123',
|
||||
apiBase: 'https://api.airwallex.com/api/v1',
|
||||
countryCode: 'CN',
|
||||
currency: 'CNY',
|
||||
accountId: 'acct_123',
|
||||
},
|
||||
})
|
||||
const wrapper = mountDialog({ editing: provider })
|
||||
|
||||
;(wrapper.vm as unknown as { loadProvider: (provider: ProviderInstance) => void }).loadProvider(provider)
|
||||
await nextTick()
|
||||
|
||||
const accountIdInput = wrapper
|
||||
.findAll('input[type="text"]')
|
||||
.find(input => (input.element as HTMLInputElement).value === 'acct_123')
|
||||
if (!accountIdInput) throw new Error('accountId input not found')
|
||||
|
||||
await accountIdInput.setValue('')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
|
||||
const payload = wrapper.emitted('save')?.[0]?.[0] as { config: Record<string, string> }
|
||||
expect(payload.config.accountId).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatPaymentAmount } from '../currency'
|
||||
|
||||
describe('formatPaymentAmount', () => {
|
||||
it('uses the currency default fraction digits', () => {
|
||||
expect(formatPaymentAmount(100, 'JPY', 'en-US')).not.toContain('.00')
|
||||
expect(formatPaymentAmount(100, 'KRW', 'en-US')).not.toContain('.00')
|
||||
expect(formatPaymentAmount(100, 'HKD', 'en-US')).toContain('.00')
|
||||
})
|
||||
})
|
||||
@@ -38,12 +38,14 @@ describe('getVisibleMethods', () => {
|
||||
alipay_direct: methodLimit({ single_min: 5 }),
|
||||
wxpay: methodLimit({ single_max: 100 }),
|
||||
stripe: methodLimit({ fee_rate: 3 }),
|
||||
airwallex: methodLimit({ single_min: 10 }),
|
||||
})
|
||||
|
||||
expect(visible).toEqual({
|
||||
alipay: methodLimit({ single_min: 5 }),
|
||||
wxpay: methodLimit({ single_max: 100 }),
|
||||
stripe: methodLimit({ fee_rate: 3 }),
|
||||
airwallex: methodLimit({ single_min: 10 }),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -104,6 +106,29 @@ describe('decidePaymentLaunch', () => {
|
||||
expect(decision.paymentState.orderType).toBe('subscription')
|
||||
})
|
||||
|
||||
it('routes Airwallex client secrets through the hosted Airwallex page', () => {
|
||||
const decision = decidePaymentLaunch(createOrderResult({
|
||||
client_secret: 'awx_cs',
|
||||
intent_id: 'int_awx',
|
||||
currency: 'CNY',
|
||||
country_code: 'CN',
|
||||
payment_env: 'demo',
|
||||
out_trade_no: 'sub2_awx',
|
||||
}), {
|
||||
visibleMethod: 'airwallex',
|
||||
orderType: 'balance',
|
||||
isMobile: false,
|
||||
airwallexRouteUrl: '/payment/airwallex?order_id=101',
|
||||
})
|
||||
|
||||
expect(decision.kind).toBe('airwallex_route')
|
||||
expect(decision.paymentState.payUrl).toBe('/payment/airwallex?order_id=101')
|
||||
expect(decision.paymentState.intentId).toBe('int_awx')
|
||||
expect(decision.paymentState.currency).toBe('CNY')
|
||||
expect(decision.paymentState.countryCode).toBe('CN')
|
||||
expect(decision.paymentState.paymentEnv).toBe('demo')
|
||||
})
|
||||
|
||||
it('keeps hosted redirect metadata for recovery flows', () => {
|
||||
const decision = decidePaymentLaunch(createOrderResult({
|
||||
pay_url: 'https://pay.example.com/session/abc',
|
||||
@@ -248,6 +273,10 @@ describe('readPaymentRecoverySnapshot', () => {
|
||||
payUrl: 'https://pay.example.com/session/33',
|
||||
outTradeNo: 'sub2_33',
|
||||
clientSecret: '',
|
||||
intentId: '',
|
||||
currency: '',
|
||||
countryCode: '',
|
||||
paymentEnv: '',
|
||||
payAmount: 18,
|
||||
orderType: 'balance',
|
||||
paymentMode: 'popup',
|
||||
@@ -273,6 +302,10 @@ describe('readPaymentRecoverySnapshot', () => {
|
||||
payUrl: 'https://pay.example.com/session/55',
|
||||
outTradeNo: 'sub2_55',
|
||||
clientSecret: '',
|
||||
intentId: '',
|
||||
currency: '',
|
||||
countryCode: '',
|
||||
paymentEnv: '',
|
||||
payAmount: 18,
|
||||
orderType: 'balance',
|
||||
paymentMode: 'popup',
|
||||
@@ -317,4 +350,31 @@ describe('readPaymentRecoverySnapshot', () => {
|
||||
expect(restored?.orderId).toBe(44)
|
||||
expect(restored?.outTradeNo).toBe('')
|
||||
})
|
||||
|
||||
it('keeps backward compatibility with snapshots written before Airwallex fields existed', () => {
|
||||
const restored = readPaymentRecoverySnapshot(JSON.stringify({
|
||||
orderId: 45,
|
||||
amount: 28,
|
||||
qrCode: '',
|
||||
expiresAt: '2099-01-01T00:10:00.000Z',
|
||||
paymentType: 'airwallex',
|
||||
payUrl: '/payment/airwallex?order_id=45',
|
||||
outTradeNo: 'sub2_45',
|
||||
clientSecret: 'awx_cs',
|
||||
payAmount: 28,
|
||||
orderType: 'balance',
|
||||
paymentMode: '',
|
||||
resumeToken: 'resume-45',
|
||||
createdAt: Date.UTC(2099, 0, 1, 0, 0, 0),
|
||||
}), {
|
||||
now: Date.UTC(2099, 0, 1, 0, 1, 0),
|
||||
resumeToken: 'resume-45',
|
||||
})
|
||||
|
||||
expect(restored?.orderId).toBe(45)
|
||||
expect(restored?.intentId).toBe('')
|
||||
expect(restored?.currency).toBe('')
|
||||
expect(restored?.countryCode).toBe('')
|
||||
expect(restored?.paymentEnv).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PROVIDER_CONFIG_FIELDS } from '@/components/payment/providerConfig'
|
||||
import { PAYMENT_CURRENCY_OPTIONS, PROVIDER_CONFIG_FIELDS } from '@/components/payment/providerConfig'
|
||||
|
||||
function findField(key: string) {
|
||||
const fields = PROVIDER_CONFIG_FIELDS.wxpay || []
|
||||
function findField(providerKey: string, key: string) {
|
||||
const fields = PROVIDER_CONFIG_FIELDS[providerKey] || []
|
||||
return fields.find(field => field.key === key)
|
||||
}
|
||||
|
||||
describe('PROVIDER_CONFIG_FIELDS.wxpay', () => {
|
||||
it('keeps admin form validation aligned with backend-required credentials', () => {
|
||||
expect(findField('publicKeyId')?.optional).toBeFalsy()
|
||||
expect(findField('certSerial')?.optional).toBeFalsy()
|
||||
expect(findField('wxpay', 'publicKeyId')?.optional).toBeFalsy()
|
||||
expect(findField('wxpay', 'certSerial')?.optional).toBeFalsy()
|
||||
})
|
||||
|
||||
it('only keeps the simplified visible credential set in the admin form', () => {
|
||||
expect(findField('mpAppId')).toBeUndefined()
|
||||
expect(findField('h5AppName')).toBeUndefined()
|
||||
expect(findField('h5AppUrl')).toBeUndefined()
|
||||
expect(findField('wxpay', 'mpAppId')).toBeUndefined()
|
||||
expect(findField('wxpay', 'h5AppName')).toBeUndefined()
|
||||
expect(findField('wxpay', 'h5AppUrl')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PROVIDER_CONFIG_FIELDS.airwallex', () => {
|
||||
it('adds currency config with CNY as the default', () => {
|
||||
const currency = findField('airwallex', 'currency')
|
||||
|
||||
expect(currency?.defaultValue).toBe('CNY')
|
||||
expect(currency?.hintKey).toBe('admin.settings.payment.field_paymentCurrencyHint')
|
||||
expect(currency?.options).toBe(PAYMENT_CURRENCY_OPTIONS)
|
||||
})
|
||||
|
||||
it('marks accountId as optional and explains when it can be left blank', () => {
|
||||
const accountId = findField('airwallex', 'accountId')
|
||||
|
||||
expect(accountId?.optional).toBe(true)
|
||||
expect(accountId?.clearable).toBe(true)
|
||||
expect(accountId?.hintKey).toBe('admin.settings.payment.field_accountIdHint')
|
||||
})
|
||||
|
||||
it('explains that apiBase must match the Airwallex key environment', () => {
|
||||
expect(findField('airwallex', 'apiBase')?.hintKey).toBe('admin.settings.payment.field_airwallexApiBaseHint')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PROVIDER_CONFIG_FIELDS.stripe', () => {
|
||||
it('adds currency config with CNY as the default', () => {
|
||||
const currency = findField('stripe', 'currency')
|
||||
|
||||
expect(currency?.defaultValue).toBe('CNY')
|
||||
expect(currency?.hintKey).toBe('admin.settings.payment.field_paymentCurrencyHint')
|
||||
expect(currency?.options).toBe(PAYMENT_CURRENCY_OPTIONS)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export const DEFAULT_PAYMENT_CURRENCY = 'CNY'
|
||||
|
||||
export function normalizePaymentCurrency(currency?: string | null): string {
|
||||
const normalized = String(currency || '').trim().toUpperCase()
|
||||
return /^[A-Z]{3}$/.test(normalized) ? normalized : DEFAULT_PAYMENT_CURRENCY
|
||||
}
|
||||
|
||||
function paymentCurrencyFractionDigits(currency: string): number {
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
}).resolvedOptions().maximumFractionDigits ?? 2
|
||||
} catch {
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPaymentAmount(amount: number, currency?: string | null, locale?: string): string {
|
||||
const normalized = normalizePaymentCurrency(currency)
|
||||
const fractionDigits = paymentCurrencyFractionDigits(normalized)
|
||||
try {
|
||||
return new Intl.NumberFormat(locale || undefined, {
|
||||
style: 'currency',
|
||||
currency: normalized,
|
||||
currencyDisplay: 'narrowSymbol',
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
}).format(Number.isFinite(amount) ? amount : 0)
|
||||
} catch {
|
||||
return `${normalized} ${(Number.isFinite(amount) ? amount : 0).toFixed(fractionDigits)}`
|
||||
}
|
||||
}
|
||||
@@ -15,15 +15,17 @@ const VISIBLE_METHOD_ALIASES = {
|
||||
wxpay: 'wxpay',
|
||||
wxpay_direct: 'wxpay',
|
||||
stripe: 'stripe',
|
||||
airwallex: 'airwallex',
|
||||
} as const
|
||||
|
||||
export type VisiblePaymentMethod = 'alipay' | 'wxpay' | 'stripe'
|
||||
export type VisiblePaymentMethod = 'alipay' | 'wxpay' | 'stripe' | 'airwallex'
|
||||
export type StripeVisibleMethod = 'alipay' | 'wechat_pay'
|
||||
export type PaymentLaunchKind =
|
||||
| 'qr_waiting'
|
||||
| 'redirect_waiting'
|
||||
| 'stripe_popup'
|
||||
| 'stripe_route'
|
||||
| 'airwallex_route'
|
||||
| 'wechat_oauth'
|
||||
| 'wechat_jsapi'
|
||||
| 'unhandled'
|
||||
@@ -37,6 +39,10 @@ export interface PaymentRecoverySnapshot {
|
||||
payUrl: string
|
||||
outTradeNo: string
|
||||
clientSecret: string
|
||||
intentId: string
|
||||
currency: string
|
||||
countryCode: string
|
||||
paymentEnv: string
|
||||
payAmount: number
|
||||
orderType: OrderType | ''
|
||||
paymentMode: string
|
||||
@@ -52,6 +58,7 @@ export interface PaymentLaunchContext {
|
||||
now?: number
|
||||
stripePopupUrl?: string
|
||||
stripeRouteUrl?: string
|
||||
airwallexRouteUrl?: string
|
||||
}
|
||||
|
||||
export interface PaymentLaunchDecision {
|
||||
@@ -138,12 +145,24 @@ export function decidePaymentLaunch(
|
||||
payUrl: result.pay_url || '',
|
||||
outTradeNo: result.out_trade_no || '',
|
||||
clientSecret: result.client_secret || '',
|
||||
intentId: result.intent_id || '',
|
||||
currency: result.currency || '',
|
||||
countryCode: result.country_code || '',
|
||||
paymentEnv: result.payment_env || '',
|
||||
payAmount: result.pay_amount,
|
||||
orderType: context.orderType,
|
||||
paymentMode: (result.payment_mode || '').trim(),
|
||||
resumeToken: result.resume_token || '',
|
||||
}, context.now)
|
||||
|
||||
if (visibleMethod === 'airwallex' && baseState.clientSecret && baseState.intentId) {
|
||||
if (!context.airwallexRouteUrl) {
|
||||
return { kind: 'unhandled', paymentState: baseState, recovery: baseState }
|
||||
}
|
||||
const paymentState = { ...baseState, payUrl: context.airwallexRouteUrl || '' }
|
||||
return { kind: 'airwallex_route', paymentState, recovery: paymentState }
|
||||
}
|
||||
|
||||
if (baseState.clientSecret) {
|
||||
// visibleMethod === 'stripe' means the user clicked the dedicated Stripe button
|
||||
// and should land on the full Payment Element to choose a sub-method themselves.
|
||||
@@ -239,6 +258,10 @@ export function readPaymentRecoverySnapshot(
|
||||
|| typeof parsed.payUrl !== 'string'
|
||||
|| (parsed.outTradeNo != null && typeof parsed.outTradeNo !== 'string')
|
||||
|| typeof parsed.clientSecret !== 'string'
|
||||
|| (parsed.intentId != null && typeof parsed.intentId !== 'string')
|
||||
|| (parsed.currency != null && typeof parsed.currency !== 'string')
|
||||
|| (parsed.countryCode != null && typeof parsed.countryCode !== 'string')
|
||||
|| (parsed.paymentEnv != null && typeof parsed.paymentEnv !== 'string')
|
||||
|| typeof parsed.payAmount !== 'number'
|
||||
|| typeof parsed.paymentMode !== 'string'
|
||||
|| typeof parsed.resumeToken !== 'string'
|
||||
@@ -265,6 +288,10 @@ export function readPaymentRecoverySnapshot(
|
||||
payUrl: parsed.payUrl,
|
||||
outTradeNo: parsed.outTradeNo || '',
|
||||
clientSecret: parsed.clientSecret,
|
||||
intentId: parsed.intentId || '',
|
||||
currency: parsed.currency || '',
|
||||
countryCode: parsed.countryCode || '',
|
||||
paymentEnv: parsed.paymentEnv || '',
|
||||
payAmount: parsed.payAmount,
|
||||
orderType: parsed.orderType === 'subscription' ? 'subscription' : 'balance',
|
||||
paymentMode: parsed.paymentMode,
|
||||
|
||||
@@ -9,12 +9,16 @@ export interface ConfigFieldDef {
|
||||
label: string
|
||||
sensitive: boolean
|
||||
optional?: boolean
|
||||
clearable?: boolean
|
||||
defaultValue?: string
|
||||
hintKey?: string
|
||||
options?: TypeOption[]
|
||||
}
|
||||
|
||||
export interface TypeOption {
|
||||
value: string
|
||||
label: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Callback URL paths for a provider. */
|
||||
@@ -31,18 +35,36 @@ export const PROVIDER_SUPPORTED_TYPES: Record<string, string[]> = {
|
||||
alipay: ['alipay'],
|
||||
wxpay: ['wxpay'],
|
||||
stripe: ['card', 'alipay', 'wxpay', 'link'],
|
||||
airwallex: ['airwallex'],
|
||||
}
|
||||
|
||||
/** Available payment modes for EasyPay providers. */
|
||||
export const EASYPAY_PAYMENT_MODES = ['qrcode', 'popup'] as const
|
||||
|
||||
/** Fixed display order for user-facing payment methods */
|
||||
export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct', 'stripe'] as const
|
||||
export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct', 'stripe', 'airwallex'] as const
|
||||
|
||||
/** Payment mode constants */
|
||||
export const PAYMENT_MODE_QRCODE = 'qrcode'
|
||||
export const PAYMENT_MODE_POPUP = 'popup'
|
||||
|
||||
export const PAYMENT_CURRENCY_OPTIONS: TypeOption[] = [
|
||||
{ value: 'CNY', label: 'CNY' },
|
||||
{ value: 'HKD', label: 'HKD' },
|
||||
{ value: 'USD', label: 'USD' },
|
||||
{ value: 'EUR', label: 'EUR' },
|
||||
{ value: 'GBP', label: 'GBP' },
|
||||
{ value: 'AUD', label: 'AUD' },
|
||||
{ value: 'CAD', label: 'CAD' },
|
||||
{ value: 'SGD', label: 'SGD' },
|
||||
{ value: 'JPY', label: 'JPY' },
|
||||
{ value: 'KRW', label: 'KRW' },
|
||||
{ value: 'NZD', label: 'NZD' },
|
||||
]
|
||||
|
||||
// 与后端当前集成的 stripe-go v85.0.0 的 stripe.APIVersion 保持一致。
|
||||
export const STRIPE_SDK_API_VERSION = '2026-03-25.dahlia'
|
||||
|
||||
/** Preferred popup size for payment gateways. Alipay's standard checkout
|
||||
* (QR + account login panel) needs ~1200×900 to render without any scrolling. */
|
||||
const PAYMENT_POPUP_PREFERRED_WIDTH = 1250
|
||||
@@ -68,6 +90,7 @@ export const WEBHOOK_PATHS: Record<string, string> = {
|
||||
alipay: '/api/v1/payment/webhook/alipay',
|
||||
wxpay: '/api/v1/payment/webhook/wxpay',
|
||||
stripe: '/api/v1/payment/webhook/stripe',
|
||||
airwallex: '/api/v1/payment/webhook/airwallex',
|
||||
}
|
||||
|
||||
export const RETURN_PATH = '/payment/result'
|
||||
@@ -77,7 +100,8 @@ export const PROVIDER_CALLBACK_PATHS: Record<string, CallbackPaths> = {
|
||||
easypay: { notifyUrl: WEBHOOK_PATHS.easypay, returnUrl: RETURN_PATH },
|
||||
alipay: { notifyUrl: WEBHOOK_PATHS.alipay, returnUrl: RETURN_PATH },
|
||||
wxpay: { notifyUrl: WEBHOOK_PATHS.wxpay },
|
||||
// stripe: no callback URL config needed (webhook is separate)
|
||||
// stripe: 不需要回调 URL 配置,Webhook 单独配置。
|
||||
// airwallex: 不需要回调 URL 配置,Webhook 在空中云汇后台配置。
|
||||
}
|
||||
|
||||
/** Per-provider config fields (excludes notifyUrl/returnUrl which are handled separately). */
|
||||
@@ -107,6 +131,16 @@ export const PROVIDER_CONFIG_FIELDS: Record<string, ConfigFieldDef[]> = {
|
||||
{ key: 'secretKey', label: '', sensitive: true },
|
||||
{ key: 'publishableKey', label: '', sensitive: false },
|
||||
{ key: 'webhookSecret', label: '', sensitive: true },
|
||||
{ key: 'currency', label: '', sensitive: false, defaultValue: 'CNY', hintKey: 'admin.settings.payment.field_paymentCurrencyHint', options: PAYMENT_CURRENCY_OPTIONS },
|
||||
],
|
||||
airwallex: [
|
||||
{ key: 'clientId', label: '', sensitive: false },
|
||||
{ key: 'apiKey', label: '', sensitive: true },
|
||||
{ key: 'webhookSecret', label: '', sensitive: true },
|
||||
{ key: 'apiBase', label: '', sensitive: false, defaultValue: 'https://api.airwallex.com/api/v1', hintKey: 'admin.settings.payment.field_airwallexApiBaseHint' },
|
||||
{ key: 'countryCode', label: '', sensitive: false, defaultValue: 'CN' },
|
||||
{ key: 'currency', label: '', sensitive: false, defaultValue: 'CNY', hintKey: 'admin.settings.payment.field_paymentCurrencyHint', options: PAYMENT_CURRENCY_OPTIONS },
|
||||
{ key: 'accountId', label: '', sensitive: false, optional: true, clearable: true, hintKey: 'admin.settings.payment.field_accountIdHint' },
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -5514,6 +5514,7 @@ export default {
|
||||
providerAlipay: 'Alipay (Direct)',
|
||||
providerWxpay: 'WeChat Pay (Direct)',
|
||||
providerStripe: 'Stripe',
|
||||
providerAirwallex: 'Airwallex',
|
||||
typeDisabled: 'type disabled',
|
||||
enableTypesFirst: 'Enable at least one payment type above first',
|
||||
easypayRedirect: 'Redirect',
|
||||
@@ -5540,12 +5541,24 @@ export default {
|
||||
wxpayConfigHint: 'WeChat Pay usually only needs App ID. Fill MP App ID, H5 App Name, and H5 App URL only when your Official Account or H5 flow specifically requires them.',
|
||||
wxpayAdvancedOptions: 'WeChat Pay Advanced Options',
|
||||
field_secretKey: 'Secret Key',
|
||||
field_clientId: 'Client ID',
|
||||
field_apiKey: 'API Key',
|
||||
field_publishableKey: 'Publishable Key',
|
||||
field_webhookSecret: 'Webhook Secret',
|
||||
field_countryCode: 'Country/region code',
|
||||
field_currency: 'Payment currency',
|
||||
field_accountId: 'Airwallex Account ID',
|
||||
field_airwallexApiBaseHint: 'Must match the API key environment: use https://api-demo.airwallex.com/api/v1 for sandbox/demo keys, and https://api.airwallex.com/api/v1 for production keys. Mixed environments return credentials_invalid / Access Denied.',
|
||||
field_paymentCurrencyHint: 'Default is CNY. Stripe and Airwallex can choose HKD, USD, or another listed currency supported by the account; WeChat Pay, Alipay, and EasyPay remain CNY.',
|
||||
field_accountIdHint: 'Leave this empty unless you use multiple accounts, an organization-level key, or connected-account payments. A single-account scoped API key uses the selected account by default.',
|
||||
field_cid: 'Channel ID',
|
||||
field_cidAlipay: 'Alipay Channel ID',
|
||||
field_cidWxpay: 'WeChat Channel ID',
|
||||
stripeWebhookHint: 'Configure the following URL as a Webhook endpoint in Stripe Dashboard:',
|
||||
stripeWebhookApiVersionHint: 'Set this Webhook endpoint API version to match the integrated Stripe SDK. Recommended: {version}. A mismatch can cause webhook parsing errors.',
|
||||
airwallexWebhookHint: 'Configure the following URL as a Webhook endpoint in Airwallex. Select at least Payment Intent -> Succeeded (payment_intent.succeeded), preferably also Payment Intent -> Cancelled (payment_intent.cancelled). Use the account default or latest stable API version.',
|
||||
airwallexGuideSummary: 'When creating an Airwallex scoped API key, select Read and Write for Payment Acceptance under account-level permissions.',
|
||||
airwallexGuideNote: 'Do not grant unrelated permissions such as Spend, Payouts, Transfers, Funds Splits, or POS Terminals unless you explicitly need them. For webhooks, select at least payment_intent.succeeded, preferably also payment_intent.cancelled, and use the account default or latest stable API version.',
|
||||
limitsTitle: 'Limits',
|
||||
limitSingleMin: 'Min per order',
|
||||
limitSingleMax: 'Max per order',
|
||||
@@ -6402,6 +6415,7 @@ export default {
|
||||
alipay: 'Alipay',
|
||||
wxpay: 'WeChat Pay',
|
||||
stripe: 'Stripe',
|
||||
airwallex: 'Airwallex',
|
||||
card: 'Card',
|
||||
link: 'Link',
|
||||
alipay_direct: 'Alipay (Direct)',
|
||||
@@ -6487,6 +6501,8 @@ export default {
|
||||
stripeLoadFailed: 'Failed to load payment component. Please refresh and try again.',
|
||||
stripeMissingParams: 'Missing order ID or client secret',
|
||||
stripeNotConfigured: 'Stripe is not configured',
|
||||
airwallexLoadFailed: 'Failed to load Airwallex payment component. Please refresh and try again.',
|
||||
airwallexMissingParams: 'Missing Airwallex payment parameters',
|
||||
errors: {
|
||||
tooManyPending: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.',
|
||||
cancelRateLimited: 'Too many cancellations. Please try again later.',
|
||||
@@ -6532,6 +6548,7 @@ export default {
|
||||
REFUND_AMOUNT_EXCEEDED: 'Refund amount exceeds the recharge amount.',
|
||||
REFUND_FAILED: 'Refund failed.',
|
||||
},
|
||||
airwallexPay: 'Airwallex Payment',
|
||||
stripePay: 'Pay Now',
|
||||
stripeSuccessProcessing: 'Payment successful, processing your order...',
|
||||
stripePopup: {
|
||||
|
||||
@@ -5675,6 +5675,7 @@ export default {
|
||||
providerAlipay: '支付宝官方',
|
||||
providerWxpay: '微信官方',
|
||||
providerStripe: 'Stripe',
|
||||
providerAirwallex: 'Airwallex',
|
||||
typeDisabled: '类型已禁用',
|
||||
enableTypesFirst: '请先在上方启用至少一种服务商',
|
||||
easypayRedirect: '跳转',
|
||||
@@ -5701,12 +5702,24 @@ export default {
|
||||
wxpayConfigHint: '微信支付通常只需要填写 App ID。公众号 App ID、H5 应用名称、H5 应用地址仅在公众号支付或 H5 场景有特殊要求时再填写。',
|
||||
wxpayAdvancedOptions: '微信支付高级可选项',
|
||||
field_secretKey: '密钥',
|
||||
field_clientId: 'Client ID',
|
||||
field_apiKey: 'API Key',
|
||||
field_publishableKey: '公开密钥',
|
||||
field_webhookSecret: 'Webhook 密钥',
|
||||
field_countryCode: '国家/地区代码',
|
||||
field_currency: '支付币种',
|
||||
field_accountId: 'Airwallex 账户 ID',
|
||||
field_airwallexApiBaseHint: '必须和 API Key 所属环境一致:沙箱/测试密钥使用 https://api-demo.airwallex.com/api/v1,生产密钥使用 https://api.airwallex.com/api/v1。环境混用会返回 credentials_invalid / Access Denied。',
|
||||
field_paymentCurrencyHint: '默认 CNY。Stripe 和 Airwallex 可按账户支持从下拉项选择 HKD、USD 等币种;微信、支付宝、易支付仍按 CNY。',
|
||||
field_accountIdHint: '不涉及多账户、组织级密钥或连接账户收款时可以不填;单账户 Scoped API Key 会默认使用所选账户。',
|
||||
field_cid: '支付渠道 ID',
|
||||
field_cidAlipay: '支付宝渠道 ID',
|
||||
field_cidWxpay: '微信渠道 ID',
|
||||
stripeWebhookHint: '请在 Stripe Dashboard 中将以下地址配置为 Webhook 端点:',
|
||||
stripeWebhookApiVersionHint: 'Webhook 端点的 API 版本请与当前集成的 Stripe SDK 对齐,建议选择 {version};版本不一致可能导致回调事件解析失败。',
|
||||
airwallexWebhookHint: '请在 Airwallex 后台将以下地址配置为 Webhook 端点;事件至少选择 Payment Intent -> Succeeded(payment_intent.succeeded),建议同时选择 Payment Intent -> Cancelled(payment_intent.cancelled);API version 选择账户默认或最新稳定版本。',
|
||||
airwallexGuideSummary: '创建 Airwallex Scoped API 密钥时,建议只在账户级权限中为 Payment Acceptance 勾选读取和写入。',
|
||||
airwallexGuideNote: '不需要勾选 Spend、Payouts、Transfers、Funds Splits、POS 终端等与在线收款无关的权限。Webhook 事件至少选择 payment_intent.succeeded,建议同时选择 payment_intent.cancelled;API version 选择账户默认或最新稳定版本。',
|
||||
limitsTitle: '限额配置',
|
||||
limitSingleMin: '单笔最低',
|
||||
limitSingleMax: '单笔最高',
|
||||
@@ -6587,6 +6600,7 @@ export default {
|
||||
alipay: '支付宝',
|
||||
wxpay: '微信支付',
|
||||
stripe: 'Stripe',
|
||||
airwallex: 'Airwallex',
|
||||
card: '银行卡',
|
||||
link: 'Link',
|
||||
alipay_direct: '支付宝(直连)',
|
||||
@@ -6672,6 +6686,8 @@ export default {
|
||||
stripeLoadFailed: '支付组件加载失败,请刷新页面重试',
|
||||
stripeMissingParams: '缺少订单ID或支付密钥',
|
||||
stripeNotConfigured: 'Stripe 未配置',
|
||||
airwallexLoadFailed: 'Airwallex 支付组件加载失败,请刷新页面重试',
|
||||
airwallexMissingParams: '缺少 Airwallex 支付参数',
|
||||
errors: {
|
||||
tooManyPending: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单',
|
||||
cancelRateLimited: '取消订单过于频繁,请稍后再试',
|
||||
@@ -6717,6 +6733,7 @@ export default {
|
||||
REFUND_AMOUNT_EXCEEDED: '退款金额超过充值金额',
|
||||
REFUND_FAILED: '退款失败',
|
||||
},
|
||||
airwallexPay: 'Airwallex 支付',
|
||||
stripePay: '立即支付',
|
||||
stripeSuccessProcessing: '支付成功,正在处理订单...',
|
||||
stripePopup: {
|
||||
|
||||
@@ -316,6 +316,18 @@ const routes: RouteRecordRaw[] = [
|
||||
requiresPayment: false
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/payment/airwallex',
|
||||
name: 'AirwallexPayment',
|
||||
component: () => import('@/views/user/AirwallexPaymentView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
requiresAdmin: false,
|
||||
title: 'Airwallex Payment',
|
||||
titleKey: 'payment.airwallexPay',
|
||||
requiresPayment: false
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/payment/stripe-popup',
|
||||
name: 'StripePopup',
|
||||
@@ -656,7 +668,7 @@ let authInitialized = false
|
||||
const navigationLoading = useNavigationLoadingState()
|
||||
// 延迟初始化预加载,传入 router 实例
|
||||
let routePrefetch: ReturnType<typeof useRoutePrefetch> | null = null
|
||||
const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/result', '/legal']
|
||||
const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/result', '/payment/airwallex', '/legal']
|
||||
const BACKEND_MODE_CALLBACK_PATHS = [
|
||||
'/auth/callback',
|
||||
'/auth/linuxdo/callback',
|
||||
|
||||
@@ -121,6 +121,13 @@
|
||||
@apply dark:hover:bg-[#635bff];
|
||||
}
|
||||
|
||||
.btn-airwallex {
|
||||
@apply bg-[#14171A] text-white shadow-md shadow-emerald-500/20;
|
||||
@apply hover:bg-[#20252a] hover:shadow-lg hover:shadow-emerald-500/25;
|
||||
@apply dark:bg-[#7AF0C4] dark:text-gray-950 dark:shadow-emerald-500/20;
|
||||
@apply dark:hover:bg-[#62d9ad];
|
||||
}
|
||||
|
||||
.btn-alipay {
|
||||
@apply bg-[#00AEEF] text-white shadow-md shadow-[#00AEEF]/25;
|
||||
@apply hover:bg-[#009dd6] hover:shadow-lg hover:shadow-[#00AEEF]/30;
|
||||
|
||||
@@ -18,7 +18,7 @@ export type OrderStatus =
|
||||
| 'REFUNDED'
|
||||
| 'REFUND_FAILED'
|
||||
|
||||
export type PaymentType = 'alipay' | 'wxpay' | 'alipay_direct' | 'wxpay_direct' | 'stripe' | 'easypay'
|
||||
export type PaymentType = 'alipay' | 'wxpay' | 'alipay_direct' | 'wxpay_direct' | 'stripe' | 'easypay' | 'airwallex'
|
||||
|
||||
export type OrderType = 'balance' | 'subscription'
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface PaymentConfig {
|
||||
}
|
||||
|
||||
export interface MethodLimit {
|
||||
currency?: string
|
||||
daily_limit: number
|
||||
daily_used: number
|
||||
daily_remaining: number
|
||||
@@ -77,6 +78,7 @@ export interface PaymentOrder {
|
||||
user_id: number
|
||||
amount: number
|
||||
pay_amount: number
|
||||
currency?: string
|
||||
fee_rate: number
|
||||
payment_type: string
|
||||
out_trade_no: string
|
||||
@@ -187,6 +189,10 @@ export interface CreateOrderResult {
|
||||
pay_url?: string
|
||||
qr_code?: string
|
||||
client_secret?: string
|
||||
intent_id?: string
|
||||
currency?: string
|
||||
country_code?: string
|
||||
payment_env?: string
|
||||
pay_amount: number
|
||||
fee_rate: number
|
||||
expires_at: string
|
||||
|
||||
@@ -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>
|
||||
@@ -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">¥{{ 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">¥{{ 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">¥{{ 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">¥{{ 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
|
||||
}
|
||||
|
||||
@@ -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>)
|
||||
|
||||
@@ -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">¥{{ 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'))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user