fix payment qr fallback and admin guidance

This commit is contained in:
IanShaw027
2026-04-22 07:33:14 -07:00
parent 5551349349
commit f35e967516
20 changed files with 845 additions and 43 deletions

View File

@@ -311,6 +311,7 @@ interface CreateOrderOptions {
wechatResumeToken?: string
paymentType?: string
isResume?: boolean
mobileQrFallbackAttempted?: boolean
}
interface WeixinJSBridgeLike {
@@ -666,14 +667,15 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
submitting.value = true
errorMessage.value = ''
errorHintMessage.value = ''
const requestType = normalizeVisibleMethod(options.paymentType || selectedMethod.value) || options.paymentType || selectedMethod.value
try {
const requestType = normalizeVisibleMethod(options.paymentType || selectedMethod.value) || options.paymentType || selectedMethod.value
const payload = buildCreateOrderPayload({
amount: orderAmount,
paymentType: requestType,
orderType,
planId,
origin: typeof window !== 'undefined' ? window.location.origin : '',
isMobile: isMobileDevice(),
isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent),
})
if (options.openid) {
@@ -747,8 +749,20 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
appStore.showInfo(t('payment.qr.cancelled'))
resetPayment()
} else if (errMsg && !errMsg.includes('ok')) {
applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod)
resetPayment()
const fallbackApplied = await attemptMobileQrFallback(
{ reason: 'WECHAT_JSAPI_FAILED', message: errMsg },
{
orderAmount,
orderType,
planId,
paymentType: visibleMethod,
attempted: options.mobileQrFallbackAttempted === true,
},
)
if (!fallbackApplied) {
applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod)
}
} else {
const resultState = { ...decision.paymentState }
resetPayment()
@@ -756,7 +770,16 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
}
} catch (err: unknown) {
resetPayment()
throw err
const fallbackApplied = await attemptMobileQrFallback(err, {
orderAmount,
orderType,
planId,
paymentType: visibleMethod,
attempted: options.mobileQrFallbackAttempted === true,
})
if (!fallbackApplied) {
throw err
}
}
return
}
@@ -776,6 +799,14 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
} else if (apiErr.reason === 'CANCEL_RATE_LIMITED') {
errorMessage.value = t('payment.errors.cancelRateLimited')
errorHintMessage.value = ''
} else if (await attemptMobileQrFallback(err, {
orderAmount,
orderType,
planId,
paymentType: requestType,
attempted: options.mobileQrFallbackAttempted === true,
})) {
return
} else {
const handled = applyScenarioError(
err,
@@ -795,6 +826,101 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
}
}
interface MobileQrFallbackContext {
orderAmount: number
orderType: OrderType
planId?: number
paymentType: string
attempted: boolean
}
function shouldFallbackToDesktopQr(err: unknown, paymentMethod: string, attempted: boolean): boolean {
if (attempted || !isMobileDevice()) {
return false
}
const normalizedMethod = normalizeVisibleMethod(paymentMethod) || paymentMethod
const reason = typeof err === 'object' && err && 'reason' in err && typeof err.reason === 'string'
? err.reason
: ''
const message = err instanceof Error
? err.message
: (typeof err === 'object' && err && 'message' in err && typeof err.message === 'string'
? err.message
: '')
const normalizedMessage = message.toLowerCase()
if (normalizedMethod === 'wxpay') {
return reason === 'WECHAT_H5_NOT_AUTHORIZED'
|| reason === 'WECHAT_PAYMENT_MP_NOT_CONFIGURED'
|| reason === 'WECHAT_JSAPI_FAILED'
|| reason === 'PAYMENT_GATEWAY_ERROR'
|| reason === 'UNHANDLED_PAYMENT_SCENARIO'
|| normalizedMessage.includes('weixinjsbridge is unavailable')
|| normalizedMessage.includes('wechat_jsapi_unavailable')
}
if (normalizedMethod === 'alipay') {
return reason === 'PAYMENT_GATEWAY_ERROR' || reason === 'UNHANDLED_PAYMENT_SCENARIO'
}
return false
}
async function attemptMobileQrFallback(err: unknown, context: MobileQrFallbackContext): Promise<boolean> {
if (!shouldFallbackToDesktopQr(err, context.paymentType, context.attempted)) {
return false
}
try {
const visibleMethod = normalizeVisibleMethod(context.paymentType) || context.paymentType
const payload = buildCreateOrderPayload({
amount: context.orderAmount,
paymentType: visibleMethod,
orderType: context.orderType,
planId: context.planId,
origin: typeof window !== 'undefined' ? window.location.origin : '',
isMobile: false,
isWechatBrowser: false,
})
const result = await paymentStore.createOrder(payload) as CreateOrderResult & { resume_token?: string }
const stripeMethod = visibleMethod === 'wxpay' ? 'wechat_pay' : 'alipay'
const stripeRouteUrl = result.client_secret
? router.resolve({
path: '/payment/stripe',
query: {
order_id: String(result.order_id),
client_secret: result.client_secret,
method: stripeMethod,
resume_token: result.resume_token || undefined,
},
}).href
: ''
const decision = decidePaymentLaunch(result, {
visibleMethod,
orderType: context.orderType,
isMobile: false,
isWechatBrowser: false,
stripePopupUrl: stripeRouteUrl,
stripeRouteUrl,
})
if (decision.kind !== 'qr_waiting' || !decision.paymentState.qrCode) {
return false
}
errorMessage.value = ''
errorHintMessage.value = ''
paymentState.value = decision.paymentState
paymentPhase.value = 'paying'
persistRecoverySnapshot(decision.recovery)
appStore.showWarning(t('payment.errors.mobilePaymentFallbackToQr'))
return true
} catch {
return false
}
}
function applyScenarioError(err: unknown, paymentMethod: string): boolean {
const descriptor = describePaymentScenarioError(err, {
paymentMethod,

View File

@@ -16,6 +16,7 @@ const refreshUser = vi.hoisted(() => vi.fn())
const fetchActiveSubscriptions = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
const showError = vi.hoisted(() => vi.fn())
const showInfo = vi.hoisted(() => vi.fn())
const showWarning = vi.hoisted(() => vi.fn())
const getCheckoutInfo = vi.hoisted(() => vi.fn())
const bridgeInvoke = vi.hoisted(() => vi.fn())
@@ -69,6 +70,7 @@ vi.mock('@/stores', () => ({
useAppStore: () => ({
showError,
showInfo,
showWarning,
}),
}))
@@ -193,6 +195,7 @@ describe('PaymentView WeChat JSAPI flow', () => {
fetchActiveSubscriptions.mockReset().mockResolvedValue(undefined)
showError.mockReset()
showInfo.mockReset()
showWarning.mockReset()
getCheckoutInfo.mockReset().mockResolvedValue(checkoutInfoFixture())
bridgeInvoke.mockReset()
window.localStorage.clear()
@@ -364,13 +367,24 @@ describe('PaymentView WeChat JSAPI flow', () => {
})
})
it('shows explicit H5 authorization guidance instead of failing silently', async () => {
it('falls back to QR flow when mobile WeChat payment is unavailable', async () => {
routeState.query = {
wechat_resume: '1',
wechat_resume_token: 'resume-token-h5',
payment_type: 'wxpay_direct',
}
createOrder.mockRejectedValueOnce({ reason: 'WECHAT_H5_NOT_AUTHORIZED' })
createOrder
.mockRejectedValueOnce({ reason: 'WECHAT_H5_NOT_AUTHORIZED' })
.mockResolvedValueOnce({
order_id: 778,
amount: 88,
pay_amount: 88,
fee_rate: 0,
expires_at: '2099-01-01T00:10:00.000Z',
payment_type: 'wxpay',
qr_code: 'weixin://wxpay/bizpayurl?pr=fallback-native',
out_trade_no: 'sub2_qr_778',
})
shallowMount(PaymentView, {
global: {
@@ -383,8 +397,18 @@ describe('PaymentView WeChat JSAPI flow', () => {
await flushPromises()
await flushPromises()
expect(showError).toHaveBeenCalledWith(
'payment.errors.wechatH5NotAuthorized payment.errors.wechatOpenInWeChatHint',
)
expect(createOrder).toHaveBeenNthCalledWith(1, expect.objectContaining({
payment_type: 'wxpay',
is_mobile: true,
wechat_resume_token: 'resume-token-h5',
}))
expect(createOrder).toHaveBeenNthCalledWith(2, expect.objectContaining({
payment_type: 'wxpay',
is_mobile: false,
payment_source: 'hosted_redirect',
}))
expect(showWarning).toHaveBeenCalledWith('payment.errors.mobilePaymentFallbackToQr')
expect(showError).not.toHaveBeenCalled()
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toContain('weixin://wxpay/bizpayurl?pr=fallback-native')
})
})