diff --git a/backend/internal/service/payment_order_expiry_service.go b/backend/internal/service/payment_order_expiry_service.go index b0cda3e5..32e51d7f 100644 --- a/backend/internal/service/payment_order_expiry_service.go +++ b/backend/internal/service/payment_order_expiry_service.go @@ -59,10 +59,18 @@ func (s *PaymentOrderExpiryService) Stop() { } func (s *PaymentOrderExpiryService) runOnce() { - ctx, cancel := context.WithTimeout(context.Background(), expiryCheckTimeout) - defer cancel() + reconcileCtx, cancel := context.WithTimeout(context.Background(), expiryCheckTimeout) + recovered, err := s.paymentSvc.ReconcilePendingWxpayOrders(reconcileCtx) + cancel() + if err != nil { + slog.Warn("[PaymentOrderExpiry] failed to reconcile pending wxpay orders", "error", err) + } else if recovered > 0 { + slog.Info("[PaymentOrderExpiry] reconciled paid wxpay orders", "count", recovered) + } - expired, err := s.paymentSvc.ExpireTimedOutOrders(ctx) + expireCtx, cancel := context.WithTimeout(context.Background(), expiryCheckTimeout) + defer cancel() + expired, err := s.paymentSvc.ExpireTimedOutOrders(expireCtx) if err != nil { slog.Error("[PaymentOrderExpiry] failed to expire orders", "error", err) return diff --git a/backend/internal/service/payment_order_lifecycle.go b/backend/internal/service/payment_order_lifecycle.go index b627ced4..ffe120d0 100644 --- a/backend/internal/service/payment_order_lifecycle.go +++ b/backend/internal/service/payment_order_lifecycle.go @@ -26,8 +26,14 @@ const ( rateLimitModeFixed = "fixed" checkPaidResultAlreadyPaid = "already_paid" checkPaidResultCancelled = "cancelled" + + pendingWxpayReconcileLimit = 20 ) +type checkPaidOptions struct { + cancelIfUnpaid bool +} + func (s *PaymentService) checkCancelRateLimit(ctx context.Context, userID int64, cfg *PaymentConfig) error { if !cfg.CancelRateLimitEnabled || cfg.CancelRateLimitMax <= 0 { return nil @@ -136,6 +142,14 @@ func (s *PaymentService) cancelCore(ctx context.Context, o *dbent.PaymentOrder, } func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) string { + return s.checkPaidWithOptions(ctx, o, checkPaidOptions{cancelIfUnpaid: true}) +} + +func (s *PaymentService) reconcilePaid(ctx context.Context, o *dbent.PaymentOrder) string { + return s.checkPaidWithOptions(ctx, o, checkPaidOptions{}) +} + +func (s *PaymentService) checkPaidWithOptions(ctx context.Context, o *dbent.PaymentOrder, opts checkPaidOptions) string { prov, err := s.getOrderProvider(ctx, o) if err != nil { return "" @@ -182,6 +196,9 @@ func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) s } return checkPaidResultAlreadyPaid } + if !opts.cancelIfUnpaid { + return "" + } if cp, ok := prov.(payment.CancelableProvider); ok { _ = cp.CancelPayment(ctx, queryRef) } @@ -268,7 +285,7 @@ func (s *PaymentService) VerifyOrderByOutTradeNo(ctx context.Context, outTradeNo } // Only verify orders that are still pending or recently expired if o.Status == OrderStatusPending || o.Status == OrderStatusExpired { - result := s.checkPaid(ctx, o) + result := s.reconcilePaid(ctx, o) if result == checkPaidResultAlreadyPaid { // Reload order to get updated status o, err = s.entClient.PaymentOrder.Get(ctx, o.ID) @@ -280,6 +297,37 @@ func (s *PaymentService) VerifyOrderByOutTradeNo(ctx context.Context, outTradeNo return o, nil } +// ReconcilePendingWxpayOrders actively checks recent pending WeChat orders so +// missed provider notifications do not wait until order expiry to fulfill. +func (s *PaymentService) ReconcilePendingWxpayOrders(ctx context.Context) (int, error) { + now := time.Now() + orders, err := s.entClient.PaymentOrder.Query(). + Where( + paymentorder.StatusEQ(OrderStatusPending), + paymentorder.ExpiresAtGT(now), + paymentorder.Or( + paymentorder.PaymentTypeEQ(payment.TypeWxpay), + paymentorder.PaymentTypeHasPrefix(payment.TypeWxpay+"_"), + paymentorder.ProviderKeyEQ(payment.TypeWxpay), + paymentorder.ProviderKeyHasPrefix(payment.TypeWxpay+"_"), + ), + ). + Order(dbent.Asc(paymentorder.FieldCreatedAt)). + Limit(pendingWxpayReconcileLimit). + All(ctx) + if err != nil { + return 0, fmt.Errorf("query pending wxpay orders: %w", err) + } + + recovered := 0 + for _, order := range orders { + if s.reconcilePaid(ctx, order) == checkPaidResultAlreadyPaid { + recovered++ + } + } + return recovered, nil +} + // VerifyOrderPublic returns the currently persisted public order state without // triggering any upstream reconciliation. Signed resume-token recovery is the // only public recovery path allowed to query upstream state. diff --git a/backend/internal/service/payment_order_lifecycle_test.go b/backend/internal/service/payment_order_lifecycle_test.go index d8595715..1964cdf6 100644 --- a/backend/internal/service/payment_order_lifecycle_test.go +++ b/backend/internal/service/payment_order_lifecycle_test.go @@ -20,10 +20,13 @@ import ( ) type paymentOrderLifecycleQueryProvider struct { - lastQueryTradeNo string - queryCalls int - responses []*payment.QueryOrderResponse - resp *payment.QueryOrderResponse + key string + lastQueryTradeNo string + lastCancelTradeNo string + queryCalls int + cancelCalls int + responses []*payment.QueryOrderResponse + resp *payment.QueryOrderResponse } type paymentOrderLifecycleRedeemRepo struct { @@ -38,10 +41,15 @@ func (p *paymentOrderLifecycleQueryProvider) Name() string { return "payment-order-lifecycle-query-provider" } -func (p *paymentOrderLifecycleQueryProvider) ProviderKey() string { return payment.TypeAlipay } +func (p *paymentOrderLifecycleQueryProvider) ProviderKey() string { + if p.key != "" { + return p.key + } + return payment.TypeAlipay +} func (p *paymentOrderLifecycleQueryProvider) SupportedTypes() []payment.PaymentType { - return []payment.PaymentType{payment.TypeAlipay} + return []payment.PaymentType{p.ProviderKey()} } func (p *paymentOrderLifecycleQueryProvider) CreatePayment(context.Context, payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) { @@ -69,6 +77,12 @@ func (p *paymentOrderLifecycleQueryProvider) Refund(context.Context, payment.Ref panic("unexpected call") } +func (p *paymentOrderLifecycleQueryProvider) CancelPayment(_ context.Context, tradeNo string) error { + p.lastCancelTradeNo = tradeNo + p.cancelCalls++ + return nil +} + func (r *paymentOrderLifecycleRedeemRepo) Create(context.Context, *RedeemCode) error { panic("unexpected call") } @@ -435,6 +449,222 @@ func TestVerifyOrderByOutTradeNoRejectsPaidQueryWithZeroAmount(t *testing.T) { require.Empty(t, redeemRepo.useCalls) } +func TestVerifyOrderByOutTradeNoDoesNotCancelUnpaidUpstreamOrder(t *testing.T) { + ctx := context.Background() + client := newPaymentOrderLifecycleTestClient(t) + + user, err := client.User.Create(). + SetEmail("checkpaid-pending@example.com"). + SetPasswordHash("hash"). + SetUsername("checkpaid-pending-user"). + Save(ctx) + require.NoError(t, err) + + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(88). + SetPayAmount(88). + SetFeeRate(0). + SetRechargeCode("CHECKPAID-PENDING"). + SetOutTradeNo("sub2_checkpaid_pending"). + SetPaymentType(payment.TypeAlipay). + SetPaymentTradeNo(""). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusPending). + SetExpiresAt(time.Now().Add(time.Hour)). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + Save(ctx) + require.NoError(t, err) + + registry := payment.NewRegistry() + provider := &paymentOrderLifecycleQueryProvider{ + resp: &payment.QueryOrderResponse{ + TradeNo: order.OutTradeNo, + Status: payment.ProviderStatusPending, + Amount: 0, + }, + } + registry.Register(provider) + + svc := &PaymentService{ + entClient: client, + registry: registry, + providersLoaded: true, + } + + got, err := svc.VerifyOrderByOutTradeNo(ctx, order.OutTradeNo, user.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusPending, got.Status) + require.Equal(t, order.OutTradeNo, provider.lastQueryTradeNo) + require.Zero(t, provider.cancelCalls) + + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusPending, reloaded.Status) +} + +func TestCancelOrderStillClosesUnpaidUpstreamOrder(t *testing.T) { + ctx := context.Background() + client := newPaymentOrderLifecycleTestClient(t) + + user, err := client.User.Create(). + SetEmail("cancel-pending@example.com"). + SetPasswordHash("hash"). + SetUsername("cancel-pending-user"). + Save(ctx) + require.NoError(t, err) + + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(88). + SetPayAmount(88). + SetFeeRate(0). + SetRechargeCode("CANCEL-PENDING"). + SetOutTradeNo("sub2_cancel_pending"). + SetPaymentType(payment.TypeAlipay). + SetPaymentTradeNo(""). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusPending). + SetExpiresAt(time.Now().Add(time.Hour)). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + Save(ctx) + require.NoError(t, err) + + registry := payment.NewRegistry() + provider := &paymentOrderLifecycleQueryProvider{ + resp: &payment.QueryOrderResponse{ + TradeNo: order.OutTradeNo, + Status: payment.ProviderStatusPending, + Amount: 0, + }, + } + registry.Register(provider) + + svc := &PaymentService{ + entClient: client, + registry: registry, + providersLoaded: true, + } + + outcome, err := svc.CancelOrder(ctx, order.ID, user.ID) + require.NoError(t, err) + require.Equal(t, checkPaidResultCancelled, outcome) + require.Equal(t, order.OutTradeNo, provider.lastCancelTradeNo) + require.Equal(t, 1, provider.cancelCalls) + + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusCancelled, reloaded.Status) +} + +func TestReconcilePendingWxpayOrdersBackfillsPaidOrder(t *testing.T) { + ctx := context.Background() + client := newPaymentOrderLifecycleTestClient(t) + + user, err := client.User.Create(). + SetEmail("wxpay-reconcile@example.com"). + SetPasswordHash("hash"). + SetUsername("wxpay-reconcile-user"). + Save(ctx) + require.NoError(t, err) + + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(50). + SetPayAmount(50). + SetFeeRate(0). + SetRechargeCode("WXPAY-RECONCILE"). + SetOutTradeNo("sub2_wxpay_reconcile"). + SetPaymentType(payment.TypeWxpay). + SetPaymentTradeNo(""). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusPending). + SetExpiresAt(time.Now().Add(time.Hour)). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + Save(ctx) + require.NoError(t, err) + + userRepo := &mockUserRepo{ + getByIDUser: &User{ + ID: user.ID, + Email: user.Email, + Username: user.Username, + Balance: 0, + }, + } + userRepo.updateBalanceFn = func(ctx context.Context, id int64, amount float64) error { + require.Equal(t, user.ID, id) + if userRepo.getByIDUser != nil { + userRepo.getByIDUser.Balance += amount + } + return nil + } + redeemRepo := &paymentOrderLifecycleRedeemRepo{ + codesByCode: map[string]*RedeemCode{ + order.RechargeCode: { + ID: 1, + Code: order.RechargeCode, + Type: RedeemTypeBalance, + Value: order.Amount, + Status: StatusUnused, + }, + }, + } + redeemService := NewRedeemService( + redeemRepo, + userRepo, + nil, + nil, + nil, + client, + nil, + nil, + ) + registry := payment.NewRegistry() + provider := &paymentOrderLifecycleQueryProvider{ + key: payment.TypeWxpay, + resp: &payment.QueryOrderResponse{ + TradeNo: "wxpay-upstream-trade-123", + Status: payment.ProviderStatusPaid, + Amount: 50, + Metadata: map[string]string{ + "trade_state": "SUCCESS", + }, + }, + } + registry.Register(provider) + + svc := &PaymentService{ + entClient: client, + registry: registry, + redeemService: redeemService, + userRepo: userRepo, + providersLoaded: true, + } + + recovered, err := svc.ReconcilePendingWxpayOrders(ctx) + require.NoError(t, err) + require.Equal(t, 1, recovered) + require.Equal(t, order.OutTradeNo, provider.lastQueryTradeNo) + require.Zero(t, provider.cancelCalls) + + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusCompleted, reloaded.Status) + require.Equal(t, "wxpay-upstream-trade-123", reloaded.PaymentTradeNo) + require.Equal(t, 50.0, userRepo.getByIDUser.Balance) + require.Len(t, redeemRepo.useCalls, 1) +} + func TestVerifyOrderByOutTradeNoUsesOutTradeNoWhenPaymentTradeNoAlreadyExistsForAlipay(t *testing.T) { ctx := context.Background() client := newPaymentOrderLifecycleTestClient(t) diff --git a/backend/internal/service/payment_resume_lookup.go b/backend/internal/service/payment_resume_lookup.go index 1ff061e8..fb41ced4 100644 --- a/backend/internal/service/payment_resume_lookup.go +++ b/backend/internal/service/payment_resume_lookup.go @@ -46,7 +46,7 @@ func (s *PaymentService) GetPublicOrderByResumeToken(ctx context.Context, token return nil, invalidResumeTokenMatchError() } if order.Status == OrderStatusPending || order.Status == OrderStatusExpired { - result := s.checkPaid(ctx, order) + result := s.reconcilePaid(ctx, order) if result == checkPaidResultAlreadyPaid { order, err = s.entClient.PaymentOrder.Get(ctx, order.ID) if err != nil { diff --git a/frontend/src/components/payment/PaymentQRDialog.vue b/frontend/src/components/payment/PaymentQRDialog.vue index 09d273cc..34cc5203 100644 --- a/frontend/src/components/payment/PaymentQRDialog.vue +++ b/frontend/src/components/payment/PaymentQRDialog.vue @@ -114,6 +114,11 @@ const paidOrder = ref(null) let pollTimer: ReturnType | null = null let countdownTimer: ReturnType | null = null +let verifyAttempts = 0 +let lastVerifyAt = 0 + +const VERIFY_RETRY_INTERVAL_MS = 15000 +const VERIFY_RETRY_MAX_ATTEMPTS = 6 const isAlipay = computed(() => props.paymentType.includes('alipay')) const isWxpay = computed(() => props.paymentType.includes('wxpay')) @@ -186,8 +191,9 @@ async function renderQR() { async function pollStatus() { if (!props.orderId) return - const order = await paymentStore.pollOrderStatus(props.orderId) + let order = await paymentStore.pollOrderStatus(props.orderId) if (!order) return + order = await tryRecoverPendingOrder(order) if (order.status === 'COMPLETED' || order.status === 'PAID') { cleanup() paidOrder.value = order @@ -199,6 +205,27 @@ async function pollStatus() { } } +async function tryRecoverPendingOrder(order: PaymentOrder): Promise { + if (!isWxpay.value) return order + const outTradeNo = String(order.out_trade_no || '').trim() + if (!outTradeNo) return order + const normalizedStatus = String(order.status || '').trim().toUpperCase() + if (normalizedStatus !== 'PENDING') return order + const now = Date.now() + if (verifyAttempts >= VERIFY_RETRY_MAX_ATTEMPTS || now - lastVerifyAt < VERIFY_RETRY_INTERVAL_MS) { + return order + } + + lastVerifyAt = now + verifyAttempts += 1 + try { + const result = await paymentAPI.verifyOrder(outTradeNo) + return result.data ?? order + } catch { + return order + } +} + function startCountdown(seconds: number) { remainingSeconds.value = Math.max(0, seconds) if (remainingSeconds.value <= 0) { @@ -250,6 +277,8 @@ function init() { expired.value = false cancelling.value = false qrUrl.value = props.qrCode + verifyAttempts = 0 + lastVerifyAt = 0 let seconds = 30 * 60 if (props.expiresAt) { diff --git a/frontend/src/components/payment/PaymentStatusPanel.vue b/frontend/src/components/payment/PaymentStatusPanel.vue index 2c8b0a93..2a1349af 100644 --- a/frontend/src/components/payment/PaymentStatusPanel.vue +++ b/frontend/src/components/payment/PaymentStatusPanel.vue @@ -175,6 +175,11 @@ const outcome = ref(null) let pollTimer: ReturnType | null = null let countdownTimer: ReturnType | null = null +let verifyAttempts = 0 +let lastVerifyAt = 0 + +const VERIFY_RETRY_INTERVAL_MS = 15000 +const VERIFY_RETRY_MAX_ATTEMPTS = 6 const isAlipay = computed(() => props.paymentType.includes('alipay')) const isWxpay = computed(() => props.paymentType.includes('wxpay')) @@ -241,10 +246,32 @@ async function renderQR() { }) } +async function tryRecoverPendingOrder(order: PaymentOrder): Promise { + if (!isWxpay.value) return order + const outTradeNo = String(order.out_trade_no || '').trim() + if (!outTradeNo) return order + const normalizedStatus = String(order.status || '').trim().toUpperCase() + if (normalizedStatus !== 'PENDING') return order + const now = Date.now() + if (verifyAttempts >= VERIFY_RETRY_MAX_ATTEMPTS || now - lastVerifyAt < VERIFY_RETRY_INTERVAL_MS) { + return order + } + + lastVerifyAt = now + verifyAttempts += 1 + try { + const result = await paymentAPI.verifyOrder(outTradeNo) + return result.data ?? order + } catch { + return order + } +} + async function pollStatus() { if (!props.orderId || outcome.value) return - const order = await paymentStore.pollOrderStatus(props.orderId) + let order = await paymentStore.pollOrderStatus(props.orderId) if (!order) return + order = await tryRecoverPendingOrder(order) if (isSuccessStatus(order.status)) { cleanup() paidOrder.value = order @@ -291,6 +318,8 @@ function cleanup() { // Initialize on mount qrUrl.value = props.qrCode +verifyAttempts = 0 +lastVerifyAt = 0 let seconds = 30 * 60 if (props.expiresAt) { seconds = Math.floor((new Date(props.expiresAt).getTime() - Date.now()) / 1000) diff --git a/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts b/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts index ea2b6377..7e392478 100644 --- a/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts +++ b/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts @@ -3,6 +3,7 @@ import { flushPromises, mount } from '@vue/test-utils' const pollOrderStatus = vi.hoisted(() => vi.fn()) const cancelOrder = vi.hoisted(() => vi.fn()) +const verifyOrder = vi.hoisted(() => vi.fn()) const showError = vi.hoisted(() => vi.fn()) const toCanvas = vi.hoisted(() => vi.fn()) @@ -31,6 +32,7 @@ vi.mock('@/stores', () => ({ vi.mock('@/api/payment', () => ({ paymentAPI: { cancelOrder, + verifyOrder, }, })) @@ -62,6 +64,7 @@ describe('PaymentStatusPanel', () => { vi.useFakeTimers() pollOrderStatus.mockReset() cancelOrder.mockReset() + verifyOrder.mockReset() showError.mockReset() toCanvas.mockReset().mockResolvedValue(undefined) }) @@ -128,4 +131,35 @@ describe('PaymentStatusPanel', () => { openSpy.mockRestore() }) + + it('actively verifies a stuck pending order and settles it when upstream confirms payment', async () => { + pollOrderStatus.mockResolvedValue(orderFactory('PENDING')) + verifyOrder.mockResolvedValue({ + data: orderFactory('COMPLETED'), + }) + + const wrapper = mount(PaymentStatusPanel, { + props: { + orderId: 42, + qrCode: 'https://pay.example.com/qr/42', + expiresAt: '2099-01-01T12:30:00Z', + paymentType: 'wxpay', + orderType: 'balance', + }, + global: { + stubs: { + Icon: true, + }, + }, + }) + + await flushPromises() + await vi.advanceTimersByTimeAsync(3000) + await flushPromises() + + expect(pollOrderStatus).toHaveBeenCalledWith(42) + expect(verifyOrder).toHaveBeenCalledWith('sub2_20260420abcd1234') + expect(wrapper.text()).toContain('payment.result.success') + expect(wrapper.emitted('success')).toHaveLength(1) + }) }) diff --git a/frontend/src/views/user/PaymentResultView.vue b/frontend/src/views/user/PaymentResultView.vue index 8d4b2d3e..51122d25 100644 --- a/frontend/src/views/user/PaymentResultView.vue +++ b/frontend/src/views/user/PaymentResultView.vue @@ -267,10 +267,15 @@ async function resolveOrderFromResumeToken(resumeToken: string): Promise { try { - const result = await paymentAPI.verifyOrderPublic(outTradeNo) + const result = await paymentAPI.verifyOrder(outTradeNo) return result.data } catch (_err: unknown) { - return null + try { + const result = await paymentAPI.verifyOrderPublic(outTradeNo) + return result.data + } catch (_innerErr: unknown) { + return null + } } } diff --git a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts index 49015ef4..09f2f0b6 100644 --- a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts +++ b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts @@ -7,6 +7,7 @@ const routeState = vi.hoisted(() => ({ const routerPush = vi.hoisted(() => vi.fn()) const pollOrderStatus = vi.hoisted(() => vi.fn()) +const verifyOrder = vi.hoisted(() => vi.fn()) const verifyOrderPublic = vi.hoisted(() => vi.fn()) const resolveOrderPublicByResumeToken = vi.hoisted(() => vi.fn()) @@ -37,6 +38,7 @@ vi.mock('@/stores/payment', () => ({ vi.mock('@/api/payment', () => ({ paymentAPI: { + verifyOrder, verifyOrderPublic, resolveOrderPublicByResumeToken, }, @@ -86,6 +88,7 @@ describe('PaymentResultView', () => { routeState.query = {} routerPush.mockReset() pollOrderStatus.mockReset() + verifyOrder.mockReset() verifyOrderPublic.mockReset() resolveOrderPublicByResumeToken.mockReset() window.localStorage.clear() @@ -329,6 +332,7 @@ describe('PaymentResultView', () => { out_trade_no: 'legacy-123', trade_status: 'TRADE_SUCCESS', } + verifyOrder.mockRejectedValue(new Error('auth required')) verifyOrderPublic.mockResolvedValue({ data: orderFactory('PAID'), }) @@ -343,11 +347,36 @@ describe('PaymentResultView', () => { await flushPromises() + expect(verifyOrder).toHaveBeenCalledWith('legacy-123') expect(verifyOrderPublic).toHaveBeenCalledWith('legacy-123') expect(pollOrderStatus).not.toHaveBeenCalled() expect(wrapper.text()).toContain('payment.result.success') }) + it('prefers authenticated order verification before falling back to public lookup', async () => { + routeState.query = { + out_trade_no: 'auth-verify-123', + trade_status: 'TRADE_SUCCESS', + } + verifyOrder.mockResolvedValue({ + data: orderFactory('COMPLETED'), + }) + + const wrapper = mount(PaymentResultView, { + global: { + stubs: { + OrderStatusBadge: true, + }, + }, + }) + + await flushPromises() + + expect(verifyOrder).toHaveBeenCalledWith('auth-verify-123') + expect(verifyOrderPublic).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('payment.result.success') + }) + it('does not use public out_trade_no verification for bare order numbers without legacy return markers', async () => { routeState.query = { out_trade_no: 'legacy-bare',