diff --git a/backend/internal/payment/provider/alipay.go b/backend/internal/payment/provider/alipay.go
index af8a90c6..4f87e5a7 100644
--- a/backend/internal/payment/provider/alipay.go
+++ b/backend/internal/payment/provider/alipay.go
@@ -26,6 +26,18 @@ const (
alipayRefundSuffix = "-refund"
)
+var (
+ alipayTradeWapPay = func(client *alipay.Client, param alipay.TradeWapPay) (*url.URL, error) {
+ return client.TradeWapPay(param)
+ }
+ alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
+ return client.TradePagePay(param)
+ }
+ alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
+ return client.TradePreCreate(ctx, param)
+ }
+)
+
// Alipay implements payment.Provider and payment.CancelableProvider using the smartwalle/alipay SDK.
type Alipay struct {
instanceID string
@@ -80,7 +92,7 @@ func (a *Alipay) SupportedTypes() []payment.PaymentType {
}
// CreatePayment creates an Alipay payment page URL.
-func (a *Alipay) CreatePayment(_ context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
+func (a *Alipay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
client, err := a.getClient()
if err != nil {
return nil, err
@@ -96,12 +108,12 @@ func (a *Alipay) CreatePayment(_ context.Context, req payment.CreatePaymentReque
}
if req.IsMobile {
- return a.createTrade(client, req, notifyURL, returnURL, true)
+ return a.createTrade(ctx, client, req, notifyURL, returnURL, true)
}
- return a.createTrade(client, req, notifyURL, returnURL, false)
+ return a.createTrade(ctx, client, req, notifyURL, returnURL, false)
}
-func (a *Alipay) createTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string, isMobile bool) (*payment.CreatePaymentResponse, error) {
+func (a *Alipay) createTrade(ctx context.Context, client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string, isMobile bool) (*payment.CreatePaymentResponse, error) {
if isMobile {
param := alipay.TradeWapPay{}
param.OutTradeNo = req.OrderID
@@ -111,7 +123,7 @@ func (a *Alipay) createTrade(client *alipay.Client, req payment.CreatePaymentReq
param.NotifyURL = notifyURL
param.ReturnURL = returnURL
- payURL, err := client.TradeWapPay(param)
+ payURL, err := alipayTradeWapPay(client, param)
if err != nil {
return nil, fmt.Errorf("alipay TradeWapPay: %w", err)
}
@@ -121,22 +133,19 @@ func (a *Alipay) createTrade(client *alipay.Client, req payment.CreatePaymentReq
}, nil
}
- param := alipay.TradePagePay{}
+ param := alipay.TradePreCreate{}
param.OutTradeNo = req.OrderID
param.TotalAmount = req.Amount
param.Subject = req.Subject
- param.ProductCode = alipayProductCodePagePay
param.NotifyURL = notifyURL
- param.ReturnURL = returnURL
- payURL, err := client.TradePagePay(param)
+ resp, err := alipayTradePreCreate(ctx, client, param)
if err != nil {
- return nil, fmt.Errorf("alipay TradePagePay: %w", err)
+ return nil, fmt.Errorf("alipay TradePreCreate: %w", err)
}
return &payment.CreatePaymentResponse{
TradeNo: req.OrderID,
- PayURL: payURL.String(),
- QRCode: payURL.String(),
+ QRCode: strings.TrimSpace(resp.QRCode),
}, nil
}
diff --git a/backend/internal/payment/provider/alipay_test.go b/backend/internal/payment/provider/alipay_test.go
index 7b0ce0d8..6cc4246c 100644
--- a/backend/internal/payment/provider/alipay_test.go
+++ b/backend/internal/payment/provider/alipay_test.go
@@ -3,9 +3,14 @@
package provider
import (
+ "context"
"errors"
+ "net/url"
"strings"
"testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ "github.com/smartwalle/alipay/v3"
)
func TestIsTradeNotExist(t *testing.T) {
@@ -130,3 +135,111 @@ func TestNewAlipay(t *testing.T) {
})
}
}
+
+func TestCreateTradeUsesPreCreateForDesktop(t *testing.T) {
+ origPreCreate := alipayTradePreCreate
+ origPagePay := alipayTradePagePay
+ origWapPay := alipayTradeWapPay
+ t.Cleanup(func() {
+ alipayTradePreCreate = origPreCreate
+ alipayTradePagePay = origPagePay
+ alipayTradeWapPay = origWapPay
+ })
+
+ preCreateCalls := 0
+ pagePayCalls := 0
+ wapPayCalls := 0
+ alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
+ preCreateCalls++
+ if param.OutTradeNo != "sub2_100" {
+ t.Fatalf("out_trade_no = %q, want %q", param.OutTradeNo, "sub2_100")
+ }
+ if param.NotifyURL != "https://merchant.example.com/api/v1/payment/webhook/alipay" {
+ t.Fatalf("notify_url = %q", param.NotifyURL)
+ }
+ return &alipay.TradePreCreateRsp{
+ OutTradeNo: "sub2_100",
+ QRCode: "https://qr.alipay.example.com/precreate-token",
+ }, nil
+ }
+ alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
+ pagePayCalls++
+ return url.Parse("https://openapi.alipay.com/gateway.do?page-pay")
+ }
+ alipayTradeWapPay = func(client *alipay.Client, param alipay.TradeWapPay) (*url.URL, error) {
+ wapPayCalls++
+ return url.Parse("https://openapi.alipay.com/gateway.do?wap-pay")
+ }
+
+ provider := &Alipay{}
+ resp, err := provider.createTrade(context.Background(), &alipay.Client{}, payment.CreatePaymentRequest{
+ OrderID: "sub2_100",
+ Amount: "88.00",
+ Subject: "Balance recharge",
+ }, "https://merchant.example.com/api/v1/payment/webhook/alipay", "https://merchant.example.com/payment/result", false)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if preCreateCalls != 1 {
+ t.Fatalf("precreate calls = %d, want 1", preCreateCalls)
+ }
+ if pagePayCalls != 0 {
+ t.Fatalf("page pay calls = %d, want 0", pagePayCalls)
+ }
+ if wapPayCalls != 0 {
+ t.Fatalf("wap pay calls = %d, want 0", wapPayCalls)
+ }
+ if resp.QRCode != "https://qr.alipay.example.com/precreate-token" {
+ t.Fatalf("qr_code = %q", resp.QRCode)
+ }
+ if resp.PayURL != "" {
+ t.Fatalf("pay_url = %q, want empty", resp.PayURL)
+ }
+}
+
+func TestCreateTradeUsesWapPayForMobile(t *testing.T) {
+ origPreCreate := alipayTradePreCreate
+ origWapPay := alipayTradeWapPay
+ t.Cleanup(func() {
+ alipayTradePreCreate = origPreCreate
+ alipayTradeWapPay = origWapPay
+ })
+
+ preCreateCalls := 0
+ alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
+ preCreateCalls++
+ return &alipay.TradePreCreateRsp{}, nil
+ }
+
+ wapPayCalls := 0
+ alipayTradeWapPay = func(client *alipay.Client, param alipay.TradeWapPay) (*url.URL, error) {
+ wapPayCalls++
+ if param.ReturnURL != "https://merchant.example.com/payment/result" {
+ t.Fatalf("return_url = %q", param.ReturnURL)
+ }
+ return url.Parse("https://openapi.alipay.com/gateway.do?wap-pay")
+ }
+
+ provider := &Alipay{}
+ resp, err := provider.createTrade(context.Background(), &alipay.Client{}, payment.CreatePaymentRequest{
+ OrderID: "sub2_101",
+ Amount: "18.00",
+ Subject: "Balance recharge",
+ IsMobile: true,
+ }, "https://merchant.example.com/api/v1/payment/webhook/alipay", "https://merchant.example.com/payment/result", true)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if preCreateCalls != 0 {
+ t.Fatalf("precreate calls = %d, want 0", preCreateCalls)
+ }
+ if wapPayCalls != 1 {
+ t.Fatalf("wap pay calls = %d, want 1", wapPayCalls)
+ }
+ if resp.PayURL == "" {
+ t.Fatal("expected pay_url for mobile wap pay")
+ }
+ if resp.QRCode != "" {
+ t.Fatalf("qr_code = %q, want empty", resp.QRCode)
+ }
+}
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 7d058a74..e17ed616 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -5453,6 +5453,18 @@ export default {
errors: {
tooManyPending: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.',
cancelRateLimited: 'Too many cancellations. Please try again later.',
+ wechatH5NotAuthorized: 'This merchant has not enabled WeChat H5 payment. Open this page in WeChat to continue.',
+ wechatPaymentMpNotConfigured: 'This site has not completed WeChat MP/JSAPI payment setup, so in-app WeChat payment is unavailable right now.',
+ wechatJsapiUnavailable: 'WeChat payment could not be invoked in the current environment. Reopen this page inside WeChat and try again.',
+ wechatJsapiFailed: 'WeChat payment did not complete. Try invoking it again or switch to QR payment.',
+ wechatUnavailable: 'WeChat payment is temporarily unavailable. Please try again later.',
+ 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.',
+ 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.',
+ alipayMobileOpenHint: 'Allow the current page to open the Alipay app, or retry from the system browser.',
PENDING_ORDERS: 'This provider has pending orders. Please wait for them to complete before making changes.',
},
stripePay: 'Pay Now',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 6dd74334..d54c0aba 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -5641,6 +5641,18 @@ export default {
errors: {
tooManyPending: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单',
cancelRateLimited: '取消订单过于频繁,请稍后再试',
+ wechatH5NotAuthorized: '当前商户未开通微信 H5 支付,请在微信中打开当前页面继续支付。',
+ wechatPaymentMpNotConfigured: '当前站点未完成公众号/JSAPI 支付配置,暂时无法在微信内直接拉起支付。',
+ wechatJsapiUnavailable: '当前环境未能拉起微信支付,请确认正在微信内打开本页后重试。',
+ wechatJsapiFailed: '微信支付未完成,请重新拉起支付或改用扫码支付。',
+ wechatUnavailable: '当前微信支付暂不可用,请稍后重试。',
+ wechatOpenInWeChatHint: '请复制当前页面链接到微信内打开,或直接改用电脑端微信扫码支付。',
+ wechatScanOnDesktopHint: '电脑端请直接使用微信扫一扫完成支付;移动端请在微信内打开当前页面。',
+ wechatSwitchBrowserHint: '请改用电脑端微信扫码,或在外部浏览器重新打开本页后再试。',
+ alipayDesktopUnavailable: '当前支付宝桌面支付未成功生成二维码。',
+ alipayDesktopQrHint: '电脑端支付宝应展示扫码单,请刷新后重试,或确认浏览器未拦截当前支付页。',
+ alipayMobileUnavailable: '当前页面未成功跳转到支付宝。',
+ alipayMobileOpenHint: '请允许当前页面打开支付宝 App,或改用系统浏览器重新发起支付。',
PENDING_ORDERS: '该服务商有未完成的订单,请等待订单完成后再操作',
},
stripePay: '立即支付',
diff --git a/frontend/src/views/user/PaymentResultView.vue b/frontend/src/views/user/PaymentResultView.vue
index 9687d1c7..53bbb550 100644
--- a/frontend/src/views/user/PaymentResultView.vue
+++ b/frontend/src/views/user/PaymentResultView.vue
@@ -54,7 +54,7 @@
{{ t('payment.orders.paymentMethod') }}
- {{ t('payment.methods.' + order.payment_type, order.payment_type) }}
+ {{ t(paymentMethodI18nKey(order.payment_type), normalizedOrderPaymentType(order.payment_type)) }}
{{ t('payment.orders.status') }}
@@ -75,7 +75,7 @@
{{ t('payment.orders.paymentMethod') }}
- {{ t('payment.methods.' + returnInfo.type, returnInfo.type) }}
+ {{ t(paymentMethodI18nKey(returnInfo.type), normalizedOrderPaymentType(returnInfo.type)) }}
@@ -98,6 +98,7 @@ import { PAYMENT_RECOVERY_STORAGE_KEY, readPaymentRecoverySnapshot } from '@/com
import { usePaymentStore } from '@/stores/payment'
import { paymentAPI } from '@/api/payment'
import type { PaymentOrder } from '@/types/payment'
+import { normalizePaymentMethodForDisplay, paymentMethodI18nKey } from './paymentUx'
const { t } = useI18n()
const route = useRoute()
@@ -133,6 +134,10 @@ const isSuccess = computed(() => {
return !!order.value && SUCCESS_STATUSES.has(order.value.status)
})
+function normalizedOrderPaymentType(paymentType: string): string {
+ return normalizePaymentMethodForDisplay(paymentType) || paymentType
+}
+
onMounted(async () => {
const resumeToken = typeof route.query.resume_token === 'string'
? route.query.resume_token
diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue
index bfb9dae2..019de16a 100644
--- a/frontend/src/views/user/PaymentView.vue
+++ b/frontend/src/views/user/PaymentView.vue
@@ -88,6 +88,7 @@
{{ errorMessage }}
+
{{ errorHintMessage }}
@@ -174,6 +175,7 @@
{{ errorMessage }}
+
{{ errorHintMessage }}
@@ -281,6 +283,7 @@ import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
import Icon from '@/components/icons/Icon.vue'
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
+import { describePaymentScenarioError } from './paymentUx'
const { t } = useI18n()
const route = useRoute()
@@ -301,6 +304,7 @@ function getDaysRemaining(expiresAt: string): number {
const loading = ref(true)
const submitting = ref(false)
const errorMessage = ref('')
+const errorHintMessage = ref('')
const activeTab = ref<'recharge' | 'subscription'>('recharge')
const amount = ref(null)
const selectedMethod = ref('')
@@ -619,6 +623,7 @@ async function confirmSubscribe() {
async function createOrder(orderAmount: number, orderType: OrderType, planId?: number, options: CreateOrderOptions = {}) {
submitting.value = true
errorMessage.value = ''
+ errorHintMessage.value = ''
try {
const requestType = normalizeVisibleMethod(options.paymentType || selectedMethod.value) || options.paymentType || selectedMethod.value
const payload = buildCreateOrderPayload({
@@ -668,8 +673,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
}
if (decision.kind === 'unhandled') {
- errorMessage.value = t('payment.result.failed')
- appStore.showError(errorMessage.value)
+ applyScenarioError({ reason: 'UNHANDLED_PAYMENT_SCENARIO' }, visibleMethod)
return
}
@@ -691,7 +695,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
if (errMsg.includes('cancel')) {
appStore.showInfo(t('payment.qr.cancelled'))
} else if (errMsg && !errMsg.includes('ok')) {
- appStore.showError(t('payment.result.failed'))
+ applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod)
}
return
}
@@ -707,10 +711,16 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
if (apiErr.reason === 'TOO_MANY_PENDING') {
const metadata = apiErr.metadata as Record | undefined
errorMessage.value = t('payment.errors.tooManyPending', { max: metadata?.max || '' })
+ errorHintMessage.value = ''
} else if (apiErr.reason === 'CANCEL_RATE_LIMITED') {
errorMessage.value = t('payment.errors.cancelRateLimited')
+ errorHintMessage.value = ''
} else {
- errorMessage.value = extractApiErrorMessage(err, t('payment.result.failed'))
+ applyScenarioError(err, normalizeVisibleMethod(options.paymentType || selectedMethod.value) || selectedMethod.value)
+ if (!errorMessage.value) {
+ errorMessage.value = extractApiErrorMessage(err, t('payment.result.failed'))
+ errorHintMessage.value = ''
+ }
}
appStore.showError(errorMessage.value)
} finally {
@@ -718,6 +728,21 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
}
}
+function applyScenarioError(err: unknown, paymentMethod: string) {
+ const descriptor = describePaymentScenarioError(err, {
+ paymentMethod,
+ isMobile: isMobileDevice(),
+ isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent),
+ })
+ if (!descriptor) {
+ errorMessage.value = ''
+ errorHintMessage.value = ''
+ return
+ }
+ errorMessage.value = t(descriptor.messageKey)
+ errorHintMessage.value = descriptor.hintKey ? t(descriptor.hintKey) : ''
+}
+
async function resumeWechatPaymentFromQuery() {
const openid = readRouteQueryValue(route.query.openid)
if (readRouteQueryValue(route.query.wechat_resume) !== '1' || !openid) {
diff --git a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts
index d23a60d9..b1caa526 100644
--- a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts
+++ b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts
@@ -155,4 +155,29 @@ describe('PaymentResultView', () => {
expect(wrapper.text()).toContain('payment.result.success')
expect(verifyOrderPublic).not.toHaveBeenCalled()
})
+
+ it('normalizes aliased payment methods before rendering the label', async () => {
+ routeState.query = {
+ resume_token: 'resume-88',
+ }
+ resolveOrderPublicByResumeToken.mockResolvedValueOnce({
+ data: {
+ ...orderFactory('PAID'),
+ payment_type: 'alipay_direct',
+ },
+ })
+
+ const wrapper = mount(PaymentResultView, {
+ global: {
+ stubs: {
+ OrderStatusBadge: true,
+ },
+ },
+ })
+
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('payment.methods.alipay')
+ expect(wrapper.text()).not.toContain('payment.methods.alipay_direct')
+ })
})
diff --git a/frontend/src/views/user/__tests__/paymentUx.spec.ts b/frontend/src/views/user/__tests__/paymentUx.spec.ts
new file mode 100644
index 00000000..6cf105f2
--- /dev/null
+++ b/frontend/src/views/user/__tests__/paymentUx.spec.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest'
+import {
+ describePaymentScenarioError,
+ normalizePaymentMethodForDisplay,
+} from '../paymentUx'
+
+describe('normalizePaymentMethodForDisplay', () => {
+ it('collapses visible payment aliases to canonical method ids', () => {
+ expect(normalizePaymentMethodForDisplay(' alipay_direct ')).toBe('alipay')
+ expect(normalizePaymentMethodForDisplay('wxpay_direct')).toBe('wxpay')
+ expect(normalizePaymentMethodForDisplay('wechat_pay')).toBe('wxpay')
+ })
+
+ it('leaves non-aliased methods untouched', () => {
+ expect(normalizePaymentMethodForDisplay('stripe')).toBe('stripe')
+ })
+})
+
+describe('describePaymentScenarioError', () => {
+ it('maps WeChat H5 authorization errors to explicit in-app guidance', () => {
+ expect(describePaymentScenarioError(
+ { reason: 'WECHAT_H5_NOT_AUTHORIZED' },
+ { paymentMethod: 'wxpay', isMobile: true, isWechatBrowser: false },
+ )).toEqual({
+ messageKey: 'payment.errors.wechatH5NotAuthorized',
+ hintKey: 'payment.errors.wechatOpenInWeChatHint',
+ })
+ })
+
+ it('maps missing WeixinJSBridge to a JSAPI-specific prompt', () => {
+ expect(describePaymentScenarioError(
+ new Error('WeixinJSBridge is unavailable'),
+ { paymentMethod: 'wxpay', isMobile: true, isWechatBrowser: true },
+ )).toEqual({
+ messageKey: 'payment.errors.wechatJsapiUnavailable',
+ hintKey: 'payment.errors.wechatOpenInWeChatHint',
+ })
+ })
+
+ it('maps generic desktop Alipay failures to QR guidance', () => {
+ expect(describePaymentScenarioError(
+ { reason: 'PAYMENT_GATEWAY_ERROR' },
+ { paymentMethod: 'alipay', isMobile: false, isWechatBrowser: false },
+ )).toEqual({
+ messageKey: 'payment.errors.alipayDesktopUnavailable',
+ hintKey: 'payment.errors.alipayDesktopQrHint',
+ })
+ })
+})
diff --git a/frontend/src/views/user/paymentUx.ts b/frontend/src/views/user/paymentUx.ts
new file mode 100644
index 00000000..443529a7
--- /dev/null
+++ b/frontend/src/views/user/paymentUx.ts
@@ -0,0 +1,105 @@
+import { normalizeVisibleMethod } from '@/components/payment/paymentFlow'
+import { extractApiErrorCode } from '@/utils/apiError'
+
+const DISPLAY_METHOD_ALIASES: Record = {
+ wechat: 'wxpay',
+ wechat_pay: 'wxpay',
+}
+
+export interface PaymentScenarioContext {
+ paymentMethod: string
+ isMobile: boolean
+ isWechatBrowser: boolean
+}
+
+export interface PaymentScenarioErrorDescriptor {
+ messageKey: string
+ hintKey?: string
+}
+
+export function normalizePaymentMethodForDisplay(paymentType: string): string {
+ const trimmed = paymentType.trim().toLowerCase()
+ const visibleMethod = normalizeVisibleMethod(trimmed)
+ if (visibleMethod) return visibleMethod
+ return DISPLAY_METHOD_ALIASES[trimmed] ?? trimmed
+}
+
+export function paymentMethodI18nKey(paymentType: string): string {
+ return `payment.methods.${normalizePaymentMethodForDisplay(paymentType)}`
+}
+
+function defaultWechatHint(context: PaymentScenarioContext): string {
+ if (!context.isMobile) return 'payment.errors.wechatScanOnDesktopHint'
+ return 'payment.errors.wechatOpenInWeChatHint'
+}
+
+function defaultAlipayHint(context: PaymentScenarioContext): string {
+ if (context.isMobile) return 'payment.errors.alipayMobileOpenHint'
+ return 'payment.errors.alipayDesktopQrHint'
+}
+
+export function describePaymentScenarioError(
+ error: unknown,
+ context: PaymentScenarioContext,
+): PaymentScenarioErrorDescriptor | null {
+ const method = normalizePaymentMethodForDisplay(context.paymentMethod)
+ const code = extractApiErrorCode(error)
+ const message = error instanceof Error
+ ? error.message
+ : (typeof error === 'object' && error && 'message' in error && typeof error.message === 'string'
+ ? error.message
+ : String(error || ''))
+ const normalizedMessage = message.toLowerCase()
+
+ if (method === 'wxpay') {
+ if (code === 'WECHAT_H5_NOT_AUTHORIZED') {
+ return {
+ messageKey: 'payment.errors.wechatH5NotAuthorized',
+ hintKey: defaultWechatHint(context),
+ }
+ }
+ if (code === 'WECHAT_PAYMENT_MP_NOT_CONFIGURED') {
+ return {
+ messageKey: 'payment.errors.wechatPaymentMpNotConfigured',
+ hintKey: context.isWechatBrowser
+ ? 'payment.errors.wechatSwitchBrowserHint'
+ : defaultWechatHint(context),
+ }
+ }
+ if (code === 'NO_AVAILABLE_INSTANCE') {
+ return {
+ messageKey: 'payment.errors.wechatUnavailable',
+ hintKey: defaultWechatHint(context),
+ }
+ }
+ if (code === 'WECHAT_JSAPI_FAILED' || normalizedMessage.includes('get_brand_wcpay_request:fail')) {
+ return {
+ messageKey: 'payment.errors.wechatJsapiFailed',
+ hintKey: defaultWechatHint(context),
+ }
+ }
+ if (normalizedMessage.includes('weixinjsbridge is unavailable')) {
+ return {
+ messageKey: 'payment.errors.wechatJsapiUnavailable',
+ hintKey: 'payment.errors.wechatOpenInWeChatHint',
+ }
+ }
+ if (code === 'PAYMENT_GATEWAY_ERROR' || code === 'UNHANDLED_PAYMENT_SCENARIO') {
+ return {
+ messageKey: 'payment.errors.wechatUnavailable',
+ hintKey: defaultWechatHint(context),
+ }
+ }
+ }
+
+ if (method === 'alipay' && (code === 'PAYMENT_GATEWAY_ERROR' || code === 'UNHANDLED_PAYMENT_SCENARIO')) {
+ return {
+ messageKey: context.isMobile
+ ? 'payment.errors.alipayMobileUnavailable'
+ : 'payment.errors.alipayDesktopUnavailable',
+ hintKey: defaultAlipayHint(context),
+ }
+ }
+
+ return null
+}