feat: add Airwallex payments and multi-currency support

This commit is contained in:
shaw
2026-05-11 11:17:26 +08:00
parent dbc8ae658c
commit b23055af5b
65 changed files with 3164 additions and 162 deletions
@@ -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)}`
}
}
+28 -1
View File
@@ -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' },
],
}