fix payment qr fallback and admin guidance
This commit is contained in:
@@ -1,23 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef, nextTick } from 'vue'
|
||||
import { onBeforeUnmount, onMounted, ref, useTemplateRef, nextTick } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
content?: string
|
||||
}>()
|
||||
trigger?: 'hover' | 'click'
|
||||
widthClass?: string
|
||||
}>(), {
|
||||
trigger: 'hover',
|
||||
widthClass: 'w-64',
|
||||
})
|
||||
|
||||
const show = ref(false)
|
||||
const triggerRef = useTemplateRef<HTMLElement>('trigger')
|
||||
const tooltipRef = useTemplateRef<HTMLElement>('tooltip')
|
||||
const tooltipStyle = ref({ top: '0px', left: '0px' })
|
||||
|
||||
function onEnter() {
|
||||
function openTooltip() {
|
||||
show.value = true
|
||||
nextTick(updatePosition)
|
||||
}
|
||||
|
||||
function onLeave() {
|
||||
function closeTooltip() {
|
||||
show.value = false
|
||||
}
|
||||
|
||||
function onEnter() {
|
||||
if (props.trigger !== 'hover') return
|
||||
openTooltip()
|
||||
}
|
||||
|
||||
function onLeave() {
|
||||
if (props.trigger !== 'hover') return
|
||||
closeTooltip()
|
||||
}
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
if (props.trigger !== 'click') return
|
||||
event.stopPropagation()
|
||||
if (show.value) {
|
||||
closeTooltip()
|
||||
return
|
||||
}
|
||||
openTooltip()
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (props.trigger !== 'click' || !show.value) return
|
||||
const target = event.target as Node | null
|
||||
if (!target) return
|
||||
if (triggerRef.value?.contains(target) || tooltipRef.value?.contains(target)) return
|
||||
closeTooltip()
|
||||
}
|
||||
|
||||
function onDocumentKeydown(event: KeyboardEvent) {
|
||||
if (props.trigger !== 'click') return
|
||||
if (event.key === 'Escape') {
|
||||
closeTooltip()
|
||||
}
|
||||
}
|
||||
|
||||
function onViewportChange() {
|
||||
if (!show.value) return
|
||||
updatePosition()
|
||||
}
|
||||
|
||||
function updatePosition() {
|
||||
const el = triggerRef.value
|
||||
if (!el) return
|
||||
@@ -27,6 +73,20 @@ function updatePosition() {
|
||||
left: `${rect.left + rect.width / 2 + window.scrollX}px`,
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocumentClick, true)
|
||||
document.addEventListener('keydown', onDocumentKeydown)
|
||||
window.addEventListener('resize', onViewportChange)
|
||||
window.addEventListener('scroll', onViewportChange, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick, true)
|
||||
document.removeEventListener('keydown', onDocumentKeydown)
|
||||
window.removeEventListener('resize', onViewportChange)
|
||||
window.removeEventListener('scroll', onViewportChange, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -35,6 +95,7 @@ function updatePosition() {
|
||||
class="group relative ml-1 inline-flex items-center align-middle"
|
||||
@mouseenter="onEnter"
|
||||
@mouseleave="onLeave"
|
||||
@click="onClick"
|
||||
>
|
||||
<!-- Trigger Icon -->
|
||||
<slot name="trigger">
|
||||
@@ -56,10 +117,26 @@ function updatePosition() {
|
||||
<!-- Teleport to body to escape modal overflow clipping -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
ref="tooltip"
|
||||
v-show="show"
|
||||
class="fixed z-[99999] w-64 -translate-x-1/2 -translate-y-full rounded-lg bg-gray-900 p-3 text-xs leading-relaxed text-white shadow-xl ring-1 ring-white/10 dark:bg-gray-800"
|
||||
role="tooltip"
|
||||
:class="[
|
||||
'fixed z-[99999] -translate-x-1/2 -translate-y-full rounded-lg bg-gray-900 p-3 text-xs leading-relaxed text-white shadow-xl ring-1 ring-white/10 dark:bg-gray-800',
|
||||
props.widthClass,
|
||||
]"
|
||||
:style="{ top: `calc(${tooltipStyle.top} - 8px)`, left: tooltipStyle.left }"
|
||||
>
|
||||
<button
|
||||
v-if="props.trigger === 'click'"
|
||||
type="button"
|
||||
class="absolute right-1.5 top-1.5 rounded p-1 text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
|
||||
aria-label="Close"
|
||||
@click.stop="closeTooltip"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<slot>{{ content }}</slot>
|
||||
<div class="absolute -bottom-1 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
|
||||
</div>
|
||||
|
||||
80
frontend/src/components/common/__tests__/HelpTooltip.spec.ts
Normal file
80
frontend/src/components/common/__tests__/HelpTooltip.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
||||
|
||||
function getTooltipElement(): HTMLDivElement {
|
||||
const tooltip = document.body.querySelector('[role="tooltip"]')
|
||||
if (!(tooltip instanceof HTMLDivElement)) {
|
||||
throw new Error('tooltip element not found')
|
||||
}
|
||||
return tooltip
|
||||
}
|
||||
|
||||
describe('HelpTooltip', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('keeps the existing hover interaction by default', async () => {
|
||||
const wrapper = mount(HelpTooltip, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
content: 'hover details',
|
||||
},
|
||||
})
|
||||
|
||||
const trigger = wrapper.get('.group')
|
||||
const tooltip = getTooltipElement()
|
||||
|
||||
expect(tooltip.style.display).toBe('none')
|
||||
|
||||
await trigger.trigger('mouseenter')
|
||||
await nextTick()
|
||||
expect(tooltip.style.display).not.toBe('none')
|
||||
|
||||
await trigger.trigger('mouseleave')
|
||||
await nextTick()
|
||||
expect(tooltip.style.display).toBe('none')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('supports click-to-toggle details and closes on outside click', async () => {
|
||||
const wrapper = mount(HelpTooltip, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
content: 'click details',
|
||||
trigger: 'click',
|
||||
},
|
||||
})
|
||||
|
||||
const trigger = wrapper.get('.group')
|
||||
const tooltip = getTooltipElement()
|
||||
|
||||
expect(tooltip.style.display).toBe('none')
|
||||
|
||||
await trigger.trigger('click')
|
||||
await nextTick()
|
||||
expect(tooltip.style.display).not.toBe('none')
|
||||
expect(tooltip.textContent).toContain('click details')
|
||||
|
||||
const closeButton = tooltip.querySelector('button[aria-label="Close"]')
|
||||
if (!(closeButton instanceof HTMLButtonElement)) {
|
||||
throw new Error('close button not found')
|
||||
}
|
||||
closeButton.click()
|
||||
await nextTick()
|
||||
expect(tooltip.style.display).toBe('none')
|
||||
|
||||
await trigger.trigger('click')
|
||||
await nextTick()
|
||||
expect(tooltip.style.display).not.toBe('none')
|
||||
|
||||
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(tooltip.style.display).toBe('none')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -73,9 +73,42 @@
|
||||
|
||||
<!-- Config fields -->
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-700">
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.payment.providerConfig') }}
|
||||
</h4>
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.payment.providerConfig') }}
|
||||
</h4>
|
||||
<HelpTooltip v-if="paymentGuide" trigger="click" width-class="w-80">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded-full border border-gray-300 text-[11px] font-semibold text-gray-400 transition-colors hover:border-primary-500 hover:text-primary-600 dark:border-dark-500 dark:text-gray-500 dark:hover:border-primary-400 dark:hover:text-primary-400"
|
||||
:aria-label="t('admin.settings.payment.paymentGuideTrigger')"
|
||||
:title="t('admin.settings.payment.paymentGuideTrigger')"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
</template>
|
||||
<div class="space-y-3">
|
||||
<p class="font-medium text-white">{{ paymentGuide.summary }}</p>
|
||||
<div
|
||||
v-for="item in paymentGuide.items"
|
||||
:key="item.title"
|
||||
class="space-y-1.5 border-t border-white/10 pt-2 first:border-t-0 first:pt-0"
|
||||
>
|
||||
<p class="font-medium text-white">{{ item.title }}</p>
|
||||
<p><span class="text-gray-300">{{ t('admin.settings.payment.guideOpenLabel') }}</span>{{ item.open }}</p>
|
||||
<p><span class="text-gray-300">{{ t('admin.settings.payment.guideCallLabel') }}</span>{{ item.call }}</p>
|
||||
<p><span class="text-gray-300">{{ t('admin.settings.payment.guideFallbackLabel') }}</span>{{ item.fallback }}</p>
|
||||
</div>
|
||||
<p v-if="paymentGuide.note" class="border-t border-white/10 pt-2 text-[11px] text-gray-300">
|
||||
{{ paymentGuide.note }}
|
||||
</p>
|
||||
</div>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
<p v-if="paymentGuide" class="mb-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ paymentGuide.summary }}
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div v-for="field in resolvedFields" :key="field.key">
|
||||
<label class="input-label">
|
||||
@@ -220,6 +253,7 @@
|
||||
import { reactive, computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import type { SelectOption } from '@/components/common/Select.vue'
|
||||
import ToggleSwitch from './ToggleSwitch.vue'
|
||||
@@ -263,6 +297,19 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface PaymentGuideItem {
|
||||
title: string
|
||||
open: string
|
||||
call: string
|
||||
fallback: string
|
||||
}
|
||||
|
||||
interface PaymentGuide {
|
||||
summary: string
|
||||
items: PaymentGuideItem[]
|
||||
note?: string
|
||||
}
|
||||
|
||||
// --- Form state ---
|
||||
const form = reactive({
|
||||
name: '',
|
||||
@@ -314,6 +361,63 @@ const resolvedFields = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
const paymentGuide = computed<PaymentGuide | null>(() => {
|
||||
if (form.provider_key === 'alipay') {
|
||||
return {
|
||||
summary: t('admin.settings.payment.alipayGuideSummary'),
|
||||
items: [
|
||||
{
|
||||
title: t('admin.settings.payment.alipayGuideFaceToFaceTitle'),
|
||||
open: t('admin.settings.payment.alipayGuideFaceToFaceOpen'),
|
||||
call: t('admin.settings.payment.alipayGuideFaceToFaceCall'),
|
||||
fallback: t('admin.settings.payment.alipayGuideFaceToFaceFallback'),
|
||||
},
|
||||
{
|
||||
title: t('admin.settings.payment.alipayGuidePagePayTitle'),
|
||||
open: t('admin.settings.payment.alipayGuidePagePayOpen'),
|
||||
call: t('admin.settings.payment.alipayGuidePagePayCall'),
|
||||
fallback: t('admin.settings.payment.alipayGuidePagePayFallback'),
|
||||
},
|
||||
{
|
||||
title: t('admin.settings.payment.alipayGuideWapTitle'),
|
||||
open: t('admin.settings.payment.alipayGuideWapOpen'),
|
||||
call: t('admin.settings.payment.alipayGuideWapCall'),
|
||||
fallback: t('admin.settings.payment.alipayGuideWapFallback'),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (form.provider_key === 'wxpay') {
|
||||
return {
|
||||
summary: t('admin.settings.payment.wxpayGuideSummary'),
|
||||
note: t('admin.settings.payment.wxpayGuideNote'),
|
||||
items: [
|
||||
{
|
||||
title: t('admin.settings.payment.wxpayGuideNativeTitle'),
|
||||
open: t('admin.settings.payment.wxpayGuideNativeOpen'),
|
||||
call: t('admin.settings.payment.wxpayGuideNativeCall'),
|
||||
fallback: t('admin.settings.payment.wxpayGuideNativeFallback'),
|
||||
},
|
||||
{
|
||||
title: t('admin.settings.payment.wxpayGuideJsapiTitle'),
|
||||
open: t('admin.settings.payment.wxpayGuideJsapiOpen'),
|
||||
call: t('admin.settings.payment.wxpayGuideJsapiCall'),
|
||||
fallback: t('admin.settings.payment.wxpayGuideJsapiFallback'),
|
||||
},
|
||||
{
|
||||
title: t('admin.settings.payment.wxpayGuideH5Title'),
|
||||
open: t('admin.settings.payment.wxpayGuideH5Open'),
|
||||
call: t('admin.settings.payment.wxpayGuideH5Call'),
|
||||
fallback: t('admin.settings.payment.wxpayGuideH5Fallback'),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const limitableTypes = computed(() => {
|
||||
// Stripe: single "stripe" entry (one set of shared limits)
|
||||
if (form.provider_key === 'stripe') {
|
||||
|
||||
@@ -84,6 +84,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="scanHint" class="text-center text-sm text-gray-500 dark:text-gray-400">{{ scanHint }}</p>
|
||||
<button v-if="payUrl" class="btn btn-secondary text-sm" @click="reopenPopup">
|
||||
{{ t('payment.qr.openPayWindow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import PaymentProviderDialog from '@/components/payment/PaymentProviderDialog.vue'
|
||||
|
||||
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.',
|
||||
}
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => messages[key] ?? key,
|
||||
}),
|
||||
}))
|
||||
|
||||
function mountDialog() {
|
||||
return mount(PaymentProviderDialog, {
|
||||
props: {
|
||||
show: true,
|
||||
saving: false,
|
||||
editing: null,
|
||||
allKeyOptions: [
|
||||
{ value: 'alipay', label: 'Alipay' },
|
||||
{ value: 'wxpay', label: 'WeChat Pay' },
|
||||
{ value: 'stripe', label: 'Stripe' },
|
||||
],
|
||||
enabledKeyOptions: [
|
||||
{ value: 'alipay', label: 'Alipay' },
|
||||
{ value: 'wxpay', label: 'WeChat Pay' },
|
||||
],
|
||||
allPaymentTypes: [
|
||||
{ value: 'alipay', label: 'Alipay' },
|
||||
{ value: 'wxpay', label: 'WeChat Pay' },
|
||||
],
|
||||
redirectLabel: 'Redirect',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: {
|
||||
template: '<div><slot /><slot name="footer" /></div>',
|
||||
},
|
||||
Select: {
|
||||
props: ['modelValue', 'options', 'disabled'],
|
||||
template: '<div />',
|
||||
},
|
||||
ToggleSwitch: {
|
||||
template: '<div />',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('PaymentProviderDialog payment guide', () => {
|
||||
it('shows no payment guide for providers without a flow guide', () => {
|
||||
const wrapper = mountDialog()
|
||||
|
||||
expect(wrapper.text()).not.toContain(messages['admin.settings.payment.alipayGuideSummary'])
|
||||
expect(wrapper.text()).not.toContain(messages['admin.settings.payment.wxpayGuideSummary'])
|
||||
expect(wrapper.find('button[title="View payment guide"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['alipay', 'admin.settings.payment.alipayGuideSummary'],
|
||||
['wxpay', 'admin.settings.payment.wxpayGuideSummary'],
|
||||
])('shows the payment guide summary for %s', async (providerKey, summaryKey) => {
|
||||
const wrapper = mountDialog()
|
||||
|
||||
;(wrapper.vm as unknown as { reset: (key: string) => void }).reset(providerKey)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain(messages[summaryKey])
|
||||
expect(wrapper.find('button[title="View payment guide"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -96,4 +96,36 @@ describe('PaymentStatusPanel', () => {
|
||||
expect(wrapper.text()).toContain('payment.result.success')
|
||||
expect(wrapper.emitted('success')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows reopen button in QR mode when payUrl is also available', async () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue({ closed: false } as Window)
|
||||
|
||||
const wrapper = mount(PaymentStatusPanel, {
|
||||
props: {
|
||||
orderId: 42,
|
||||
qrCode: 'https://pay.example.com/qr/42',
|
||||
payUrl: 'https://pay.example.com/session/42',
|
||||
expiresAt: '2099-01-01T12:30:00Z',
|
||||
paymentType: 'alipay',
|
||||
orderType: 'balance',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Icon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('payment.qr.openPayWindow')
|
||||
|
||||
await wrapper.get('button.btn.btn-secondary.text-sm').trigger('click')
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://pay.example.com/session/42',
|
||||
'paymentPopup',
|
||||
expect.any(String),
|
||||
)
|
||||
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -190,12 +190,14 @@ describe('buildCreateOrderPayload', () => {
|
||||
paymentType: 'alipay_direct',
|
||||
orderType: 'balance',
|
||||
origin: 'https://app.example.com/',
|
||||
isMobile: true,
|
||||
isWechatBrowser: false,
|
||||
})).toEqual({
|
||||
amount: 88,
|
||||
payment_type: 'alipay',
|
||||
order_type: 'balance',
|
||||
return_url: 'https://app.example.com/payment/result',
|
||||
is_mobile: true,
|
||||
payment_source: 'hosted_redirect',
|
||||
})
|
||||
})
|
||||
@@ -207,6 +209,7 @@ describe('buildCreateOrderPayload', () => {
|
||||
orderType: 'subscription',
|
||||
planId: 7,
|
||||
origin: 'https://app.example.com',
|
||||
isMobile: false,
|
||||
isWechatBrowser: true,
|
||||
})).toEqual({
|
||||
amount: 128,
|
||||
@@ -214,6 +217,7 @@ describe('buildCreateOrderPayload', () => {
|
||||
order_type: 'subscription',
|
||||
plan_id: 7,
|
||||
return_url: 'https://app.example.com/payment/result',
|
||||
is_mobile: false,
|
||||
payment_source: 'wechat_in_app_resume',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,9 +12,9 @@ describe('PROVIDER_CONFIG_FIELDS.wxpay', () => {
|
||||
expect(findField('certSerial')?.optional).toBeFalsy()
|
||||
})
|
||||
|
||||
it('exposes optional mp and H5 metadata fields for WeChat-specific flows', () => {
|
||||
expect(findField('mpAppId')?.optional).toBe(true)
|
||||
expect(findField('h5AppName')?.optional).toBe(true)
|
||||
expect(findField('h5AppUrl')?.optional).toBe(true)
|
||||
it('only keeps the simplified visible credential set in the admin form', () => {
|
||||
expect(findField('mpAppId')).toBeUndefined()
|
||||
expect(findField('h5AppName')).toBeUndefined()
|
||||
expect(findField('h5AppUrl')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface BuildCreateOrderPayloadInput {
|
||||
orderType: OrderType
|
||||
planId?: number
|
||||
origin?: string
|
||||
isMobile: boolean
|
||||
isWechatBrowser: boolean
|
||||
}
|
||||
|
||||
@@ -106,6 +107,7 @@ export function buildCreateOrderPayload(input: BuildCreateOrderPayloadInput): Cr
|
||||
amount: input.amount,
|
||||
payment_type: visibleMethod,
|
||||
order_type: input.orderType,
|
||||
is_mobile: input.isMobile,
|
||||
payment_source: visibleMethod === 'wxpay' && input.isWechatBrowser
|
||||
? 'wechat_in_app_resume'
|
||||
: 'hosted_redirect',
|
||||
|
||||
@@ -96,15 +96,12 @@ export const PROVIDER_CONFIG_FIELDS: Record<string, ConfigFieldDef[]> = {
|
||||
],
|
||||
wxpay: [
|
||||
{ key: 'appId', label: 'App ID', sensitive: false },
|
||||
{ key: 'mpAppId', label: '', sensitive: false, optional: true },
|
||||
{ key: 'mchId', label: '', sensitive: false },
|
||||
{ key: 'privateKey', label: '', sensitive: true },
|
||||
{ key: 'apiV3Key', label: '', sensitive: true },
|
||||
{ key: 'certSerial', label: '', sensitive: false },
|
||||
{ key: 'publicKey', label: '', sensitive: true },
|
||||
{ key: 'publicKeyId', label: '', sensitive: false },
|
||||
{ key: 'h5AppName', label: '', sensitive: false, optional: true },
|
||||
{ key: 'h5AppUrl', label: '', sensitive: false, optional: true },
|
||||
],
|
||||
stripe: [
|
||||
{ key: 'secretKey', label: '', sensitive: true },
|
||||
|
||||
@@ -4741,6 +4741,8 @@ export default {
|
||||
field_certSerial: 'Certificate Serial',
|
||||
field_h5AppName: 'H5 App Name',
|
||||
field_h5AppUrl: 'H5 App URL',
|
||||
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_publishableKey: 'Publishable Key',
|
||||
field_webhookSecret: 'Webhook Secret',
|
||||
@@ -4771,6 +4773,37 @@ export default {
|
||||
providerKey: 'Provider Type',
|
||||
selectProviderKey: 'Select Provider Type',
|
||||
providerConfig: 'Credentials',
|
||||
paymentGuideTrigger: 'View payment guide',
|
||||
guideOpenLabel: 'Enable: ',
|
||||
guideCallLabel: 'Call: ',
|
||||
guideFallbackLabel: 'Fallback: ',
|
||||
alipayGuideSummary: 'Desktop prefers QR precreate and falls back to cashier; mobile prefers WAP checkout.',
|
||||
alipayGuideFaceToFaceTitle: 'Face-to-face / QR Payment',
|
||||
alipayGuideFaceToFaceOpen: 'Enable face-to-face or QR payment capability.',
|
||||
alipayGuideFaceToFaceCall: 'Desktop orders call alipay.trade.precreate first and render the QR code directly.',
|
||||
alipayGuideFaceToFaceFallback: 'If unavailable or failed, the flow falls back to website checkout automatically.',
|
||||
alipayGuidePagePayTitle: 'Website Payment',
|
||||
alipayGuidePagePayOpen: 'Enable website payment.',
|
||||
alipayGuidePagePayCall: 'When face-to-face is unavailable on desktop, the flow calls alipay.trade.page.pay and still renders the returned link as a QR code.',
|
||||
alipayGuidePagePayFallback: 'The cashier link stays available so users can reopen the checkout page manually.',
|
||||
alipayGuideWapTitle: 'WAP Payment',
|
||||
alipayGuideWapOpen: 'Enable mobile website payment.',
|
||||
alipayGuideWapCall: 'Mobile orders call alipay.trade.wap.pay first and jump to Alipay checkout.',
|
||||
alipayGuideWapFallback: 'If mobile payment is unavailable or fails, the frontend switches to QR payment and shows a notice.',
|
||||
wxpayGuideSummary: 'Desktop prefers Native QR; mobile routes to JSAPI or H5 based on browser context.',
|
||||
wxpayGuideNote: 'The current form defaults to one shared App ID, which fits the common single-subject web, mobile, and Official Account setup.',
|
||||
wxpayGuideNativeTitle: 'Native / QR Payment',
|
||||
wxpayGuideNativeOpen: 'Enable Native or QR payment capability.',
|
||||
wxpayGuideNativeCall: 'Desktop orders use Native by default and the frontend renders the QR payload.',
|
||||
wxpayGuideNativeFallback: 'Mobile flows also fall back here when JSAPI or H5 cannot be used.',
|
||||
wxpayGuideJsapiTitle: 'JSAPI / Official Account',
|
||||
wxpayGuideJsapiOpen: 'Enable Official Account payment and ensure the browser is inside WeChat with an available OpenID.',
|
||||
wxpayGuideJsapiCall: 'Inside WeChat, the app calls JSAPI after authorization and launches WeChat Pay directly.',
|
||||
wxpayGuideJsapiFallback: 'If configuration is missing, the bridge is unavailable, or launch fails, the flow falls back to QR payment.',
|
||||
wxpayGuideH5Title: 'H5 Payment',
|
||||
wxpayGuideH5Open: 'Enable H5 payment.',
|
||||
wxpayGuideH5Call: 'On mobile browsers outside WeChat, the app calls H5 payment when a client IP is available.',
|
||||
wxpayGuideH5Fallback: 'If H5 is unavailable or order creation fails, the flow falls back to QR payment.',
|
||||
noProviders: 'No provider instances configured',
|
||||
supportedTypes: 'Supported Payment Types',
|
||||
supportedTypesHint: 'Comma-separated, e.g. alipay,wxpay',
|
||||
@@ -5627,6 +5660,7 @@ export default {
|
||||
wechatOpenInWeChatHint: 'Open the current page inside WeChat, or switch to desktop WeChat QR payment.',
|
||||
wechatScanOnDesktopHint: 'On desktop, use WeChat Scan to pay; on mobile, reopen the current page inside WeChat.',
|
||||
wechatSwitchBrowserHint: 'Switch to desktop WeChat QR payment, or reopen this page in an external browser and retry.',
|
||||
mobilePaymentFallbackToQr: 'This merchant has not enabled mobile payment. The flow has been switched to QR payment automatically.',
|
||||
alipayDesktopUnavailable: 'The desktop Alipay flow could not generate a QR code.',
|
||||
alipayDesktopQrHint: 'Desktop Alipay should render a QR code. Refresh and retry, or make sure the payment page was not blocked.',
|
||||
alipayMobileUnavailable: 'This page could not hand off to Alipay.',
|
||||
|
||||
@@ -4905,6 +4905,8 @@ export default {
|
||||
field_certSerial: '证书序列号',
|
||||
field_h5AppName: 'H5 应用名称',
|
||||
field_h5AppUrl: 'H5 应用地址',
|
||||
wxpayConfigHint: '微信支付通常只需要填写 App ID。公众号 App ID、H5 应用名称、H5 应用地址仅在公众号支付或 H5 场景有特殊要求时再填写。',
|
||||
wxpayAdvancedOptions: '微信支付高级可选项',
|
||||
field_secretKey: '密钥',
|
||||
field_publishableKey: '公开密钥',
|
||||
field_webhookSecret: 'Webhook 密钥',
|
||||
@@ -4935,6 +4937,37 @@ export default {
|
||||
providerKey: '服务商类型',
|
||||
selectProviderKey: '选择服务商类型',
|
||||
providerConfig: '凭证配置',
|
||||
paymentGuideTrigger: '查看支付方式说明',
|
||||
guideOpenLabel: '开通:',
|
||||
guideCallLabel: '调用:',
|
||||
guideFallbackLabel: '降级:',
|
||||
alipayGuideSummary: '桌面优先扫码单,失败再走收银台;移动优先手机网站支付。',
|
||||
alipayGuideFaceToFaceTitle: '当面付 / 扫码支付',
|
||||
alipayGuideFaceToFaceOpen: '需开通当面付或扫码支付能力。',
|
||||
alipayGuideFaceToFaceCall: '桌面端下单时优先调用 alipay.trade.precreate,前台直接渲染二维码。',
|
||||
alipayGuideFaceToFaceFallback: '接口不可用或返回失败时,自动降级到电脑网站支付。',
|
||||
alipayGuidePagePayTitle: '电脑网站支付',
|
||||
alipayGuidePagePayOpen: '需开通电脑网站支付。',
|
||||
alipayGuidePagePayCall: '桌面端当面付不可用时调用 alipay.trade.page.pay,并继续把返回链接渲染成二维码。',
|
||||
alipayGuidePagePayFallback: '同时保留打开收银台入口,用户可手动重新拉起支付页。',
|
||||
alipayGuideWapTitle: '手机网站支付',
|
||||
alipayGuideWapOpen: '需开通手机网站支付。',
|
||||
alipayGuideWapCall: '移动端优先调用 alipay.trade.wap.pay,跳转支付宝收银台。',
|
||||
alipayGuideWapFallback: '未开通或返回异常时,前端自动改走扫码支付并提示未开通移动支付。',
|
||||
wxpayGuideSummary: '桌面优先 Native 扫码,移动端按浏览器环境走 JSAPI 或 H5。',
|
||||
wxpayGuideNote: '当前表单默认共用一个 App ID,适合同主体下统一配置网页、移动和公众号场景。',
|
||||
wxpayGuideNativeTitle: 'Native / 扫码支付',
|
||||
wxpayGuideNativeOpen: '需开通 Native 或扫码支付能力。',
|
||||
wxpayGuideNativeCall: '桌面端默认调用 Native,下发二维码内容给前台渲染。',
|
||||
wxpayGuideNativeFallback: '移动端无法走 JSAPI 或 H5 时,也会自动回退到这里。',
|
||||
wxpayGuideJsapiTitle: 'JSAPI / 公众号支付',
|
||||
wxpayGuideJsapiOpen: '需开通公众号支付,并保证当前浏览器在微信内且能拿到 OpenID。',
|
||||
wxpayGuideJsapiCall: '微信内浏览器完成授权后调用 JSAPI,直接拉起微信支付。',
|
||||
wxpayGuideJsapiFallback: '未配置、Bridge 不可用或拉起失败时,自动改走扫码支付。',
|
||||
wxpayGuideH5Title: 'H5 支付',
|
||||
wxpayGuideH5Open: '需开通 H5 支付。',
|
||||
wxpayGuideH5Call: '移动端非微信浏览器且有客户端 IP 时调用 H5 支付,跳转微信收银台。',
|
||||
wxpayGuideH5Fallback: '未开通 H5 或下单失败时,自动改走扫码支付。',
|
||||
noProviders: '暂无服务商实例',
|
||||
supportedTypes: '支持的支付方式',
|
||||
supportedTypesHint: '逗号分隔,如 alipay,wxpay',
|
||||
@@ -5815,6 +5848,7 @@ export default {
|
||||
wechatOpenInWeChatHint: '请复制当前页面链接到微信内打开,或直接改用电脑端微信扫码支付。',
|
||||
wechatScanOnDesktopHint: '电脑端请直接使用微信扫一扫完成支付;移动端请在微信内打开当前页面。',
|
||||
wechatSwitchBrowserHint: '请改用电脑端微信扫码,或在外部浏览器重新打开本页后再试。',
|
||||
mobilePaymentFallbackToQr: '当前商户未开通移动支付,已自动切换为扫码支付。',
|
||||
alipayDesktopUnavailable: '当前支付宝桌面支付未成功生成二维码。',
|
||||
alipayDesktopQrHint: '电脑端支付宝应展示扫码单,请刷新后重试,或确认浏览器未拦截当前支付页。',
|
||||
alipayMobileUnavailable: '当前页面未成功跳转到支付宝。',
|
||||
|
||||
@@ -4166,6 +4166,8 @@
|
||||
}}</label>
|
||||
<ImageUpload
|
||||
v-model="form.payment_help_image_url"
|
||||
:upload-label="t('admin.settings.site.uploadImage')"
|
||||
:remove-label="t('admin.settings.site.remove')"
|
||||
:placeholder="
|
||||
t('admin.settings.payment.helpImagePlaceholder')
|
||||
"
|
||||
|
||||
@@ -155,6 +155,8 @@ vi.mock("vue-i18n", async () => {
|
||||
"admin.settings.payment.findProvider": "查看支持的支付方式",
|
||||
"admin.settings.openaiExperimentalScheduler.title": "OpenAI 实验调度策略",
|
||||
"admin.settings.openaiExperimentalScheduler.description": "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。",
|
||||
"admin.settings.site.uploadImage": "上传图片",
|
||||
"admin.settings.site.remove": "移除",
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
@@ -240,6 +242,37 @@ const SelectStub = defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
const ImageUploadStub = defineComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
uploadLabel: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
removeLabel: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
return () =>
|
||||
h("div", {
|
||||
class: "image-upload-stub",
|
||||
"data-model-value": props.modelValue,
|
||||
"data-upload-label": props.uploadLabel,
|
||||
"data-remove-label": props.removeLabel,
|
||||
"data-placeholder": props.placeholder,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const baseSettingsResponse = {
|
||||
registration_enabled: true,
|
||||
email_verify_enabled: false,
|
||||
@@ -375,7 +408,7 @@ function mountView() {
|
||||
GroupBadge: true,
|
||||
GroupOptionItem: true,
|
||||
ProxySelector: true,
|
||||
ImageUpload: true,
|
||||
ImageUpload: ImageUploadStub,
|
||||
BackupSettings: true,
|
||||
},
|
||||
},
|
||||
@@ -582,7 +615,7 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
GroupBadge: true,
|
||||
GroupOptionItem: true,
|
||||
ProxySelector: true,
|
||||
ImageUpload: true,
|
||||
ImageUpload: ImageUploadStub,
|
||||
BackupSettings: true,
|
||||
},
|
||||
},
|
||||
@@ -608,6 +641,24 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
);
|
||||
expect(wrapper.text()).not.toContain("OpenAI 高级调度器");
|
||||
});
|
||||
|
||||
it("passes translated upload and remove labels to the payment help image uploader", async () => {
|
||||
const wrapper = mountView();
|
||||
|
||||
await flushPromises();
|
||||
await openPaymentTab(wrapper);
|
||||
|
||||
const imageUploads = wrapper.findAll(".image-upload-stub");
|
||||
expect(imageUploads.length).toBeGreaterThan(0);
|
||||
|
||||
const paymentHelpImageUpload = imageUploads.find(
|
||||
(node) => node.attributes("data-placeholder") === "admin.settings.payment.helpImagePlaceholder",
|
||||
);
|
||||
|
||||
expect(paymentHelpImageUpload).toBeDefined();
|
||||
expect(paymentHelpImageUpload?.attributes("data-upload-label")).toBe("上传图片");
|
||||
expect(paymentHelpImageUpload?.attributes("data-remove-label")).toBe("移除");
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin SettingsView wechat connect controls", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user