From 7316d83027a8f8d73fec994d6867431344c9863e Mon Sep 17 00:00:00 2001 From: wucm667 Date: Sat, 27 Jun 2026 12:11:36 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(payment):=20=E5=8C=BA=E5=88=86=E9=80=80?= =?UTF-8?q?=E6=AC=BE=20pending=20=E5=B9=B6=E6=94=B6=E6=95=9B=E5=8C=BF?= =?UTF-8?q?=E5=90=8D=E6=9F=A5=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/payment_handler.go | 47 +++++- .../handler/payment_handler_resume_test.go | 51 +++--- backend/internal/payment/types.go | 1 + backend/internal/service/payment_refund.go | 78 ++++++++- .../internal/service/payment_refund_test.go | 153 +++++++++++++++++- backend/internal/service/payment_service.go | 3 +- .../admin/payment/AdminOrderTable.vue | 1 + .../admin/payment/AdminRefundDialog.vue | 2 +- .../components/payment/OrderStatusBadge.vue | 1 + frontend/src/components/payment/orderUtils.ts | 1 + frontend/src/i18n/locales/en.ts | 1 + frontend/src/i18n/locales/zh.ts | 1 + frontend/src/types/payment.ts | 1 + .../views/admin/orders/AdminOrdersView.vue | 1 + 14 files changed, 302 insertions(+), 40 deletions(-) diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index f3c16f5d7c..4047cbfd27 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -454,8 +454,9 @@ func (h *PaymentHandler) VerifyOrder(c *gin.Context) { response.Success(c, sanitizePaymentOrderForResponse(order)) } -// PublicOrderResult is the limited order info returned by the public verify endpoint. -// No user details are exposed — only payment status information. +// PublicOrderResult is returned after a signed resume-token lookup. The token +// proves possession of the checkout session, so the result keeps the legacy +// frontend contract needed by payment result pages. type PublicOrderResult struct { ID int64 `json:"id"` OutTradeNo string `json:"out_trade_no"` @@ -478,6 +479,18 @@ type PublicOrderResult struct { PlanID *int64 `json:"plan_id,omitempty"` } +// PublicOrderVerifyResult is returned by the legacy anonymous out_trade_no +// lookup. Keep this intentionally minimal because out_trade_no is not secret. +type PublicOrderVerifyResult struct { + OutTradeNo string `json:"out_trade_no"` + Status string `json:"status"` + Paid bool `json:"paid"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + PaidAt *time.Time `json:"paid_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} + func buildPublicOrderResult(order *dbent.PaymentOrder) PublicOrderResult { return PublicOrderResult{ ID: order.ID, @@ -502,6 +515,34 @@ func buildPublicOrderResult(order *dbent.PaymentOrder) PublicOrderResult { } } +func buildPublicOrderVerifyResult(order *dbent.PaymentOrder) PublicOrderVerifyResult { + return PublicOrderVerifyResult{ + OutTradeNo: order.OutTradeNo, + Status: order.Status, + Paid: publicOrderStatusPaid(order.Status), + CreatedAt: order.CreatedAt, + ExpiresAt: order.ExpiresAt, + PaidAt: order.PaidAt, + CompletedAt: order.CompletedAt, + } +} + +func publicOrderStatusPaid(status string) bool { + switch status { + case service.OrderStatusPaid, + service.OrderStatusCompleted, + service.OrderStatusRefundRequested, + service.OrderStatusRefunding, + service.OrderStatusRefundPending, + service.OrderStatusPartiallyRefunded, + service.OrderStatusRefunded, + service.OrderStatusRefundFailed: + return true + default: + return false + } +} + // VerifyOrderPublic keeps the legacy anonymous out_trade_no lookup available as // a compatibility path for older result pages and staggered deploys. // POST /api/v1/payment/public/orders/verify @@ -517,7 +558,7 @@ func (h *PaymentHandler) VerifyOrderPublic(c *gin.Context) { response.ErrorFrom(c, err) return } - response.Success(c, buildPublicOrderResult(order)) + response.Success(c, buildPublicOrderVerifyResult(order)) } // ResolveOrderPublicByResumeToken resolves a payment order from a signed resume token. diff --git a/backend/internal/handler/payment_handler_resume_test.go b/backend/internal/handler/payment_handler_resume_test.go index 752088320b..21fd8ad763 100644 --- a/backend/internal/handler/payment_handler_resume_test.go +++ b/backend/internal/handler/payment_handler_resume_test.go @@ -135,35 +135,34 @@ func TestVerifyOrderPublicReturnsLegacyOrderState(t *testing.T) { require.Equal(t, http.StatusOK, recorder.Code) var resp struct { - Code int `json:"code"` - Data struct { - ID int64 `json:"id"` - OutTradeNo string `json:"out_trade_no"` - Amount float64 `json:"amount"` - PayAmount float64 `json:"pay_amount"` - FeeRate float64 `json:"fee_rate"` - Currency string `json:"currency"` - PaymentType string `json:"payment_type"` - OrderType string `json:"order_type"` - Status string `json:"status"` - RefundAmount float64 `json:"refund_amount"` - CreatedAt string `json:"created_at"` - ExpiresAt string `json:"expires_at"` - } `json:"data"` + Code int `json:"code"` + Data map[string]any `json:"data"` } require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) require.Equal(t, 0, resp.Code) - require.Equal(t, order.ID, resp.Data.ID) - require.Equal(t, "legacy-order-no", resp.Data.OutTradeNo) - require.Equal(t, 90.64, resp.Data.PayAmount) - require.Equal(t, 0.03, resp.Data.FeeRate) - require.Equal(t, "HKD", resp.Data.Currency) - require.Equal(t, payment.TypeAlipay, resp.Data.PaymentType) - require.Equal(t, payment.OrderTypeBalance, resp.Data.OrderType) - require.Equal(t, service.OrderStatusPending, resp.Data.Status) - require.Equal(t, 0.0, resp.Data.RefundAmount) - require.NotEmpty(t, resp.Data.CreatedAt) - require.NotEmpty(t, resp.Data.ExpiresAt) + require.Equal(t, "legacy-order-no", resp.Data["out_trade_no"]) + require.Equal(t, service.OrderStatusPending, resp.Data["status"]) + require.Equal(t, false, resp.Data["paid"]) + require.NotEmpty(t, resp.Data["created_at"]) + require.NotEmpty(t, resp.Data["expires_at"]) + for _, field := range []string{ + "id", + "amount", + "pay_amount", + "fee_rate", + "currency", + "payment_type", + "order_type", + "refund_amount", + "refund_reason", + "refund_requested_at", + "refund_requested_by", + "refund_request_reason", + "plan_id", + } { + require.NotContains(t, resp.Data, field) + } + require.NotZero(t, order.ID) } func TestResolveOrderPublicByResumeTokenReturnsFrontendContractFields(t *testing.T) { diff --git a/backend/internal/payment/types.go b/backend/internal/payment/types.go index d27d9696b2..9f08d96149 100644 --- a/backend/internal/payment/types.go +++ b/backend/internal/payment/types.go @@ -31,6 +31,7 @@ const ( OrderStatusFailed = "FAILED" OrderStatusRefundRequested = "REFUND_REQUESTED" OrderStatusRefunding = "REFUNDING" + OrderStatusRefundPending = "REFUND_PENDING" OrderStatusPartiallyRefunded = "PARTIALLY_REFUNDED" OrderStatusRefunded = "REFUNDED" OrderStatusRefundFailed = "REFUND_FAILED" diff --git a/backend/internal/service/payment_refund.go b/backend/internal/service/payment_refund.go index 4df3e41f77..866b9e4542 100644 --- a/backend/internal/service/payment_refund.go +++ b/backend/internal/service/payment_refund.go @@ -316,29 +316,30 @@ func (s *PaymentService) ExecuteRefund(ctx context.Context, p *RefundPlan) (*Ref p.SubDaysToDeduct = 0 } } - if err := s.gwRefund(ctx, p); err != nil { + resp, err := s.gwRefund(ctx, p) + if err != nil { return s.handleGwFail(ctx, p, err) } - return s.markRefundOk(ctx, p) + return s.finishRefund(ctx, p, resp) } -func (s *PaymentService) gwRefund(ctx context.Context, p *RefundPlan) error { +func (s *PaymentService) gwRefund(ctx context.Context, p *RefundPlan) (*payment.RefundResponse, error) { if p.Order.PaymentTradeNo == "" { s.writeAuditLog(ctx, p.Order.ID, "REFUND_NO_TRADE_NO", "admin", map[string]any{"detail": "skipped"}) - return nil + return &payment.RefundResponse{Status: payment.ProviderStatusSuccess}, nil } // Use the exact provider instance that created this order, not a random one // from the registry. Each instance has its own merchant credentials. prov, err := s.getRefundProvider(ctx, p.Order) if err != nil { - return fmt.Errorf("get refund provider: %w", err) + return nil, fmt.Errorf("get refund provider: %w", err) } if err := validateProviderSnapshotMetadata(p.Order, prov.ProviderKey(), providerMerchantIdentityMetadata(prov)); err != nil { s.writeAuditLog(ctx, p.Order.ID, "REFUND_PROVIDER_METADATA_MISMATCH", "admin", map[string]any{ "detail": err.Error(), }) - return err + return nil, err } resp, err := prov.Refund(ctx, payment.RefundRequest{ TradeNo: p.Order.PaymentTradeNo, @@ -347,9 +348,15 @@ func (s *PaymentService) gwRefund(ctx context.Context, p *RefundPlan) error { Reason: p.Reason, }) if err != nil { - return err + if resp != nil && strings.TrimSpace(resp.Status) == payment.ProviderStatusPending { + return resp, nil + } + return nil, err } - return validateRefundProviderResponse(resp) + if err := validateRefundProviderResponse(resp); err != nil { + return nil, err + } + return resp, nil } func formatGatewayRefundAmount(amount float64, order *dbent.PaymentOrder) string { @@ -371,6 +378,20 @@ func validateRefundProviderResponse(resp *payment.RefundResponse) error { } } +func (s *PaymentService) finishRefund(ctx context.Context, p *RefundPlan, resp *payment.RefundResponse) (*RefundResult, error) { + if err := validateRefundProviderResponse(resp); err != nil { + return s.handleGwFail(ctx, p, err) + } + switch strings.TrimSpace(resp.Status) { + case payment.ProviderStatusSuccess, payment.ProviderStatusRefunded: + return s.markRefundOk(ctx, p) + case payment.ProviderStatusPending: + return s.markRefundPending(ctx, p) + default: + return s.handleGwFail(ctx, p, fmt.Errorf("payment refund returned unknown status: %s", strings.TrimSpace(resp.Status))) + } +} + // getRefundProvider creates a provider using the order's original instance config. // Delegates to getOrderProvider which handles instance lookup and fallback. func (s *PaymentService) getRefundProvider(ctx context.Context, o *dbent.PaymentOrder) (payment.Provider, error) { @@ -410,6 +431,47 @@ func (s *PaymentService) markRefundOk(ctx context.Context, p *RefundPlan) (*Refu return &RefundResult{Success: true, BalanceDeducted: p.BalanceToDeduct, SubDaysDeducted: p.SubDaysToDeduct}, nil } +func (s *PaymentService) markRefundPending(ctx context.Context, p *RefundPlan) (*RefundResult, error) { + balanceDeducted := p.BalanceToDeduct + subDaysDeducted := p.SubDaysToDeduct + rollbackOK := s.RollbackRefund(ctx, p, nil) + if rollbackOK { + p.BalanceToDeduct = 0 + p.SubDaysToDeduct = 0 + } + + _, err := s.entClient.PaymentOrder.UpdateOneID(p.OrderID). + SetStatus(OrderStatusRefundPending). + SetRefundAmount(p.RefundAmount). + SetRefundReason(p.Reason). + ClearRefundAt(). + SetForceRefund(p.Force). + ClearFailedAt(). + ClearFailedReason(). + Save(ctx) + if err != nil { + return nil, fmt.Errorf("mark refund pending: %w", err) + } + + detail := map[string]any{ + "refundAmount": p.RefundAmount, + "reason": p.Reason, + "force": p.Force, + "balanceDeducted": p.BalanceToDeduct, + "subDaysDeducted": p.SubDaysToDeduct, + "balanceRolledBack": balanceDeducted, + "subDaysRolledBack": subDaysDeducted, + "deductionRollbackOK": rollbackOK, + } + s.writeAuditLog(ctx, p.OrderID, "REFUND_PENDING", "admin", detail) + + warning := "gateway refund is pending confirmation" + if !rollbackOK { + warning += "; refund deduction rollback failed" + } + return &RefundResult{Success: false, Warning: warning}, nil +} + func (s *PaymentService) RollbackRefund(ctx context.Context, p *RefundPlan, gErr error) bool { if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 { if err := s.userRepo.UpdateBalance(ctx, p.Order.UserID, p.BalanceToDeduct); err != nil { diff --git a/backend/internal/service/payment_refund_test.go b/backend/internal/service/payment_refund_test.go index c275bb65ce..c0b7587360 100644 --- a/backend/internal/service/payment_refund_test.go +++ b/backend/internal/service/payment_refund_test.go @@ -9,6 +9,7 @@ import ( "time" dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/paymentauditlog" "github.com/Wei-Shaw/sub2api/internal/payment" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/stretchr/testify/require" @@ -176,7 +177,7 @@ func TestGwRefundRejectsAlipayMerchantIdentitySnapshotMismatch(t *testing.T) { loadBalancer: newWebhookProviderTestLoadBalancer(client), } - err = svc.gwRefund(ctx, &RefundPlan{ + _, err = svc.gwRefund(ctx, &RefundPlan{ OrderID: order.ID, Order: order, RefundAmount: order.Amount, @@ -208,3 +209,153 @@ func TestValidateRefundProviderResponseAcceptsPending(t *testing.T) { require.Error(t, validateRefundProviderResponse(&payment.RefundResponse{Status: payment.ProviderStatusFailed})) require.Error(t, validateRefundProviderResponse(nil)) } + +func TestFinishRefundPendingMarksOrderPendingAndRollsBackDeduction(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + + user, err := client.User.Create(). + SetEmail("refund-pending@example.com"). + SetPasswordHash("hash"). + SetUsername("refund-pending-user"). + Save(ctx) + require.NoError(t, err) + + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(100). + SetPayAmount(100). + SetFeeRate(0). + SetRechargeCode("REFUND-PENDING-ORDER"). + SetOutTradeNo("sub2_refund_pending_order"). + SetPaymentType(payment.TypeStripe). + SetPaymentTradeNo("pi_refund_pending"). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusRefunding). + SetExpiresAt(time.Now().Add(time.Hour)). + SetPaidAt(time.Now()). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + Save(ctx) + require.NoError(t, err) + + var rolledBack float64 + userRepo := &mockUserRepo{} + userRepo.updateBalanceFn = func(ctx context.Context, id int64, amount float64) error { + require.Equal(t, user.ID, id) + rolledBack += amount + return nil + } + svc := &PaymentService{ + entClient: client, + userRepo: userRepo, + } + plan := &RefundPlan{ + OrderID: order.ID, + Order: order, + RefundAmount: 40, + GatewayAmount: 40, + Reason: "gateway accepted but not final", + Force: true, + DeductionType: payment.DeductionTypeBalance, + BalanceToDeduct: 40, + } + + result, err := svc.finishRefund(ctx, plan, &payment.RefundResponse{Status: payment.ProviderStatusPending}) + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.Success) + require.Contains(t, result.Warning, "pending confirmation") + require.Equal(t, 40.0, rolledBack) + require.Zero(t, plan.BalanceToDeduct) + + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusRefundPending, reloaded.Status) + require.Equal(t, 40.0, reloaded.RefundAmount) + require.NotNil(t, reloaded.RefundReason) + require.Equal(t, "gateway accepted but not final", *reloaded.RefundReason) + require.Nil(t, reloaded.RefundAt) + + pendingAudits, err := client.PaymentAuditLog.Query(). + Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_PENDING")). + Count(ctx) + require.NoError(t, err) + require.Equal(t, 1, pendingAudits) + successAudits, err := client.PaymentAuditLog.Query(). + Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_SUCCESS")). + Count(ctx) + require.NoError(t, err) + require.Zero(t, successAudits) +} + +func TestFinishRefundSuccessStatusesFinalize(t *testing.T) { + for _, status := range []string{payment.ProviderStatusSuccess, payment.ProviderStatusRefunded} { + t.Run(status, func(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + + user, err := client.User.Create(). + SetEmail("refund-success-" + status + "@example.com"). + SetPasswordHash("hash"). + SetUsername("refund-success-" + status). + Save(ctx) + require.NoError(t, err) + + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(100). + SetPayAmount(100). + SetFeeRate(0). + SetRechargeCode("REFUND-SUCCESS-" + status). + SetOutTradeNo("sub2_refund_success_" + status). + SetPaymentType(payment.TypeStripe). + SetPaymentTradeNo("pi_refund_success_" + status). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusRefunding). + SetExpiresAt(time.Now().Add(time.Hour)). + SetPaidAt(time.Now()). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + Save(ctx) + require.NoError(t, err) + + svc := &PaymentService{entClient: client} + plan := &RefundPlan{ + OrderID: order.ID, + Order: order, + RefundAmount: 100, + GatewayAmount: 100, + Reason: "final success", + DeductionType: payment.DeductionTypeBalance, + BalanceToDeduct: 100, + } + + result, err := svc.finishRefund(ctx, plan, &payment.RefundResponse{Status: status}) + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.Success) + require.Equal(t, 100.0, result.BalanceDeducted) + + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusRefunded, reloaded.Status) + require.NotNil(t, reloaded.RefundAt) + + successAudits, err := client.PaymentAuditLog.Query(). + Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_SUCCESS")). + Count(ctx) + require.NoError(t, err) + require.Equal(t, 1, successAudits) + pendingAudits, err := client.PaymentAuditLog.Query(). + Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_PENDING")). + Count(ctx) + require.NoError(t, err) + require.Zero(t, pendingAudits) + }) + } +} diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index c1deb667ef..3698a59c0b 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -30,6 +30,7 @@ const ( OrderStatusFailed = payment.OrderStatusFailed OrderStatusRefundRequested = payment.OrderStatusRefundRequested OrderStatusRefunding = payment.OrderStatusRefunding + OrderStatusRefundPending = payment.OrderStatusRefundPending OrderStatusPartiallyRefunded = payment.OrderStatusPartiallyRefunded OrderStatusRefunded = payment.OrderStatusRefunded OrderStatusRefundFailed = payment.OrderStatusRefundFailed @@ -252,7 +253,7 @@ func (s *PaymentService) loadProviders(ctx context.Context) { func psIsRefundStatus(s string) bool { switch s { - case OrderStatusRefundRequested, OrderStatusRefunding, OrderStatusPartiallyRefunded, OrderStatusRefunded, OrderStatusRefundFailed: + case OrderStatusRefundRequested, OrderStatusRefunding, OrderStatusRefundPending, OrderStatusPartiallyRefunded, OrderStatusRefunded, OrderStatusRefundFailed: return true } return false diff --git a/frontend/src/components/admin/payment/AdminOrderTable.vue b/frontend/src/components/admin/payment/AdminOrderTable.vue index dda0ec27b0..5290866cae 100644 --- a/frontend/src/components/admin/payment/AdminOrderTable.vue +++ b/frontend/src/components/admin/payment/AdminOrderTable.vue @@ -210,6 +210,7 @@ const statusFilterOptions = computed(() => [ { value: 'FAILED', label: t('payment.status.failed') }, { value: 'REFUNDED', label: t('payment.status.refunded') }, { value: 'REFUND_REQUESTED', label: t('payment.status.refund_requested') }, + { value: 'REFUND_PENDING', label: t('payment.status.refund_pending') }, { value: 'REFUND_FAILED', label: t('payment.status.refund_failed') }, ]) diff --git a/frontend/src/components/admin/payment/AdminRefundDialog.vue b/frontend/src/components/admin/payment/AdminRefundDialog.vue index f5f5412d54..f58b810388 100644 --- a/frontend/src/components/admin/payment/AdminRefundDialog.vue +++ b/frontend/src/components/admin/payment/AdminRefundDialog.vue @@ -198,7 +198,7 @@ const form = reactive({ force: false, }) -// In REFUND_REQUESTED status, refund_amount is the REQUESTED amount, not actually refunded. +// In REFUND_REQUESTED / REFUND_PENDING status, refund_amount is requested/pending, not actually refunded. // Only PARTIALLY_REFUNDED / REFUNDED have real refund amounts. const actuallyRefunded = computed(() => { if (!props.order) return 0 diff --git a/frontend/src/components/payment/OrderStatusBadge.vue b/frontend/src/components/payment/OrderStatusBadge.vue index a0425492a2..87975ef5eb 100644 --- a/frontend/src/components/payment/OrderStatusBadge.vue +++ b/frontend/src/components/payment/OrderStatusBadge.vue @@ -28,6 +28,7 @@ const statusMap: Record = { FAILED: { key: 'payment.status.failed', class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' }, REFUND_REQUESTED: { key: 'payment.status.refund_requested', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400' }, REFUNDING: { key: 'payment.status.refunding', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400' }, + REFUND_PENDING: { key: 'payment.status.refund_pending', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400' }, REFUNDED: { key: 'payment.status.refunded', class: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400' }, PARTIALLY_REFUNDED: { key: 'payment.status.partially_refunded', class: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400' }, REFUND_FAILED: { key: 'payment.status.refund_failed', class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' }, diff --git a/frontend/src/components/payment/orderUtils.ts b/frontend/src/components/payment/orderUtils.ts index e89eaeac28..974a4c4199 100644 --- a/frontend/src/components/payment/orderUtils.ts +++ b/frontend/src/components/payment/orderUtils.ts @@ -13,6 +13,7 @@ const STATUS_BADGE_MAP: Record = { FAILED: 'badge-danger', REFUND_REQUESTED: 'badge-warning', REFUNDING: 'badge-warning', + REFUND_PENDING: 'badge-warning', PARTIALLY_REFUNDED: 'badge-warning', REFUNDED: 'badge-info', REFUND_FAILED: 'badge-danger', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 34f5e891c8..5bd19d6923 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -7012,6 +7012,7 @@ export default { failed: 'Failed', refund_requested: 'Refund Requested', refunding: 'Refunding', + refund_pending: 'Refund Pending', refunded: 'Refunded', partially_refunded: 'Partially Refunded', refund_failed: 'Refund Failed', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index cf51d588d9..23a2f6ffa7 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -7192,6 +7192,7 @@ export default { failed: '失败', refund_requested: '退款申请中', refunding: '退款中', + refund_pending: '退款处理中', refunded: '已退款', partially_refunded: '部分退款', refund_failed: '退款失败', diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index d4c4fddaef..ac20b82e4f 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -14,6 +14,7 @@ export type OrderStatus = | 'FAILED' | 'REFUND_REQUESTED' | 'REFUNDING' + | 'REFUND_PENDING' | 'PARTIALLY_REFUNDED' | 'REFUNDED' | 'REFUND_FAILED' diff --git a/frontend/src/views/admin/orders/AdminOrdersView.vue b/frontend/src/views/admin/orders/AdminOrdersView.vue index a005741c89..6d9bf5dfee 100644 --- a/frontend/src/views/admin/orders/AdminOrdersView.vue +++ b/frontend/src/views/admin/orders/AdminOrdersView.vue @@ -190,6 +190,7 @@ const statusFilterOptions = computed(() => [ { value: 'FAILED', label: t('payment.status.failed') }, { value: 'REFUNDED', label: t('payment.status.refunded') }, { value: 'REFUND_REQUESTED', label: t('payment.status.refund_requested') }, + { value: 'REFUND_PENDING', label: t('payment.status.refund_pending') }, { value: 'REFUND_FAILED', label: t('payment.status.refund_failed') }, ]) From 93a3bf307781c291d0a896ff53bdc44e70cfa4dd Mon Sep 17 00:00:00 2001 From: wucm667 Date: Tue, 30 Jun 2026 10:19:50 +0800 Subject: [PATCH 2/2] Fix refund pending finalization gaps --- .../internal/handler/admin/payment_handler.go | 16 ++ .../internal/payment/provider/airwallex.go | 19 +++ backend/internal/payment/provider/stripe.go | 44 +++++ backend/internal/payment/provider/wxpay.go | 55 ++++++- backend/internal/payment/types.go | 15 ++ backend/internal/server/routes/payment.go | 1 + .../service/payment_order_lifecycle.go | 3 +- backend/internal/service/payment_refund.go | 152 +++++++++++++++++- .../internal/service/payment_refund_test.go | 150 +++++++++++++++++ backend/internal/service/user_service_test.go | 8 +- frontend/src/api/admin/payment.ts | 15 +- frontend/src/api/payment.ts | 12 +- frontend/src/i18n/locales/en.ts | 2 + frontend/src/i18n/locales/zh.ts | 2 + .../views/admin/orders/AdminOrdersView.vue | 46 +++++- frontend/src/views/user/PaymentResultView.vue | 59 ++++--- .../user/__tests__/PaymentResultView.spec.ts | 31 ++++ 17 files changed, 591 insertions(+), 39 deletions(-) diff --git a/backend/internal/handler/admin/payment_handler.go b/backend/internal/handler/admin/payment_handler.go index d8e211e68b..1200b47f88 100644 --- a/backend/internal/handler/admin/payment_handler.go +++ b/backend/internal/handler/admin/payment_handler.go @@ -257,6 +257,22 @@ func (h *PaymentHandler) ProcessRefund(c *gin.Context) { response.Success(c, result) } +// QueryAndFinalizeRefund queries the provider refund status and finalizes a pending refund. +// POST /api/v1/admin/payment/orders/:id/refund/query +func (h *PaymentHandler) QueryAndFinalizeRefund(c *gin.Context) { + orderID, ok := parseIDParam(c, "id") + if !ok { + return + } + + result, err := h.paymentService.QueryAndFinalizeRefund(c.Request.Context(), orderID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + // --- Subscription Plans --- // ListPlans returns all subscription plans. diff --git a/backend/internal/payment/provider/airwallex.go b/backend/internal/payment/provider/airwallex.go index 31652fb08d..0c9ec7e71f 100644 --- a/backend/internal/payment/provider/airwallex.go +++ b/backend/internal/payment/provider/airwallex.go @@ -300,6 +300,25 @@ func (a *Airwallex) Refund(ctx context.Context, req payment.RefundRequest) (*pay return refundResp, nil } +func (a *Airwallex) QueryRefund(ctx context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) { + refundID := strings.TrimSpace(req.RefundID) + if refundID == "" { + return nil, fmt.Errorf("airwallex query refund: missing refund id") + } + token, err := a.accessToken(ctx) + if err != nil { + return nil, fmt.Errorf("airwallex auth: %w", err) + } + var resp airwallexRefund + if err := a.doJSON(ctx, http.MethodGet, "/pa/refunds/"+url.PathEscape(refundID), token, nil, &resp); err != nil { + return nil, fmt.Errorf("airwallex query refund: %w", err) + } + if strings.TrimSpace(resp.ID) == "" { + resp.ID = refundID + } + return &payment.RefundResponse{RefundID: resp.ID, Status: airwallexRefundProviderStatus(resp.Status)}, nil +} + func (a *Airwallex) CancelPayment(ctx context.Context, tradeNo string) error { intentID := strings.TrimSpace(tradeNo) if intentID == "" { diff --git a/backend/internal/payment/provider/stripe.go b/backend/internal/payment/provider/stripe.go index 168b90bec1..59e31f8122 100644 --- a/backend/internal/payment/provider/stripe.go +++ b/backend/internal/payment/provider/stripe.go @@ -248,6 +248,50 @@ func (s *Stripe) Refund(ctx context.Context, req payment.RefundRequest) (*paymen }, nil } +// QueryRefund retrieves a Stripe refund by refund ID when available, otherwise +// falls back to the latest refund for the PaymentIntent. +func (s *Stripe) QueryRefund(ctx context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) { + s.ensureInit() + + var r *stripe.Refund + var err error + if refundID := strings.TrimSpace(req.RefundID); refundID != "" { + r, err = s.sc.V1Refunds.Retrieve(ctx, refundID, nil) + if err != nil { + return nil, fmt.Errorf("stripe query refund: %w", err) + } + } else { + tradeNo := strings.TrimSpace(req.TradeNo) + if tradeNo == "" { + return nil, fmt.Errorf("stripe query refund: missing payment intent id") + } + params := &stripe.RefundListParams{PaymentIntent: stripe.String(tradeNo)} + params.Limit = stripe.Int64(1) + list := s.sc.V1Refunds.List(ctx, params) + if list.Err() != nil { + return nil, fmt.Errorf("stripe query refund: %w", list.Err()) + } + refunds := list.Data() + if len(refunds) == 0 { + return nil, fmt.Errorf("stripe query refund: no refund found") + } + r = refunds[0] + } + + return &payment.RefundResponse{RefundID: r.ID, Status: stripeRefundProviderStatus(r.Status)}, nil +} + +func stripeRefundProviderStatus(status stripe.RefundStatus) string { + switch status { + case stripe.RefundStatusSucceeded: + return payment.ProviderStatusSuccess + case stripe.RefundStatusFailed, stripe.RefundStatusCanceled: + return payment.ProviderStatusFailed + default: + return payment.ProviderStatusPending + } +} + func stripeIntentCurrency(raw stripe.Currency, fallback string) string { currency, err := payment.NormalizePaymentCurrency(string(raw)) if err != nil || currency == payment.DefaultPaymentCurrency && strings.TrimSpace(string(raw)) == "" { diff --git a/backend/internal/payment/provider/wxpay.go b/backend/internal/payment/provider/wxpay.go index e6291dd31d..5dd5628739 100644 --- a/backend/internal/payment/provider/wxpay.go +++ b/backend/internal/payment/provider/wxpay.go @@ -10,7 +10,6 @@ import ( "strconv" "strings" "sync" - "time" "github.com/Wei-Shaw/sub2api/internal/payment" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" @@ -471,24 +470,66 @@ func (w *Wxpay) Refund(ctx context.Context, req payment.RefundRequest) (*payment } rs := refunddomestic.RefundsApiService{Client: c} cur := wxpayCurrency + outRefundNo := wxpayRefundID(req.OrderID, req.Amount) res, _, err := rs.Create(ctx, refunddomestic.CreateRequest{ OutTradeNo: core.String(req.OrderID), - OutRefundNo: core.String(fmt.Sprintf("%s-refund-%d", req.OrderID, time.Now().UnixNano())), + OutRefundNo: core.String(outRefundNo), Reason: core.String(req.Reason), Amount: &refunddomestic.AmountReq{Refund: core.Int64(rf), Total: core.Int64(tf), Currency: &cur}, }) if err != nil { return nil, fmt.Errorf("wxpay refund: %w", err) } - rid := wxSV(res.RefundId) - if rid == "" { - rid = fmt.Sprintf("%s-refund", req.OrderID) - } st := payment.ProviderStatusPending if res.Status != nil && *res.Status == refunddomestic.STATUS_SUCCESS { st = payment.ProviderStatusSuccess } - return &payment.RefundResponse{RefundID: rid, Status: st}, nil + return &payment.RefundResponse{RefundID: outRefundNo, Status: st}, nil +} + +func (w *Wxpay) QueryRefund(ctx context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) { + c, err := w.ensureClient() + if err != nil { + return nil, err + } + outRefundNo := strings.TrimSpace(req.RefundID) + if outRefundNo == "" { + outRefundNo = wxpayRefundID(req.OrderID, req.Amount) + } + if outRefundNo == "" { + return nil, fmt.Errorf("wxpay query refund: missing refund id") + } + rs := refunddomestic.RefundsApiService{Client: c} + res, _, err := rs.QueryByOutRefundNo(ctx, refunddomestic.QueryByOutRefundNoRequest{ + OutRefundNo: core.String(outRefundNo), + }) + if err != nil { + return nil, fmt.Errorf("wxpay query refund: %w", err) + } + status := payment.ProviderStatusPending + if res != nil && res.Status != nil { + switch *res.Status { + case refunddomestic.STATUS_SUCCESS: + status = payment.ProviderStatusSuccess + case refunddomestic.STATUS_CLOSED, refunddomestic.STATUS_ABNORMAL: + status = payment.ProviderStatusFailed + default: + status = payment.ProviderStatusPending + } + } + return &payment.RefundResponse{RefundID: outRefundNo, Status: status}, nil +} + +func wxpayRefundID(orderID, amount string) string { + orderID = strings.TrimSpace(orderID) + if orderID == "" { + return "" + } + amount = strings.NewReplacer(".", "", "-", "").Replace(strings.TrimSpace(amount)) + if amount == "" { + return orderID + "-refund" + } + return orderID + "-refund-" + amount } func (w *Wxpay) queryOrderTotalFen(ctx context.Context, c *core.Client, orderID string) (int64, error) { diff --git a/backend/internal/payment/types.go b/backend/internal/payment/types.go index 9f08d96149..0fc161c11d 100644 --- a/backend/internal/payment/types.go +++ b/backend/internal/payment/types.go @@ -182,6 +182,15 @@ type RefundRequest struct { Reason string } +// RefundQueryRequest contains identifiers needed to query a previously +// requested refund. +type RefundQueryRequest struct { + TradeNo string + OrderID string + RefundID string + Amount string +} + // RefundResponse is returned after a refund request. type RefundResponse struct { RefundID string @@ -216,6 +225,12 @@ type Provider interface { Refund(ctx context.Context, req RefundRequest) (*RefundResponse, error) } +// RefundQueryProvider extends Provider with refund status querying. +type RefundQueryProvider interface { + Provider + QueryRefund(ctx context.Context, req RefundQueryRequest) (*RefundResponse, error) +} + // CancelableProvider extends Provider with the ability to cancel pending payments. type CancelableProvider interface { Provider diff --git a/backend/internal/server/routes/payment.go b/backend/internal/server/routes/payment.go index 2e26f2c0a0..7c54770028 100644 --- a/backend/internal/server/routes/payment.go +++ b/backend/internal/server/routes/payment.go @@ -85,6 +85,7 @@ func RegisterPaymentRoutes( adminOrders.POST("/:id/cancel", adminPaymentHandler.CancelOrder) adminOrders.POST("/:id/retry", adminPaymentHandler.RetryFulfillment) adminOrders.POST("/:id/refund", adminPaymentHandler.ProcessRefund) + adminOrders.POST("/:id/refund/query", adminPaymentHandler.QueryAndFinalizeRefund) } // Subscription Plans diff --git a/backend/internal/service/payment_order_lifecycle.go b/backend/internal/service/payment_order_lifecycle.go index ffe120d016..8ed18797dd 100644 --- a/backend/internal/service/payment_order_lifecycle.go +++ b/backend/internal/service/payment_order_lifecycle.go @@ -12,7 +12,6 @@ import ( "github.com/Wei-Shaw/sub2api/ent/paymentauditlog" "github.com/Wei-Shaw/sub2api/ent/paymentorder" "github.com/Wei-Shaw/sub2api/internal/payment" - "github.com/Wei-Shaw/sub2api/internal/payment/provider" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) @@ -454,7 +453,7 @@ func (s *PaymentService) createProviderFromInstance(ctx context.Context, inst *d } instID := strconv.FormatInt(int64(inst.ID), 10) - prov, err := provider.CreateProvider(inst.ProviderKey, instID, cfg) + prov, err := createPaymentProviderFromInstance(inst.ProviderKey, instID, cfg) if err != nil { return nil, fmt.Errorf("create provider from instance: %w", err) } diff --git a/backend/internal/service/payment_refund.go b/backend/internal/service/payment_refund.go index 866b9e4542..91822680ed 100644 --- a/backend/internal/service/payment_refund.go +++ b/backend/internal/service/payment_refund.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "fmt" "log/slog" @@ -10,15 +11,20 @@ import ( "strings" "time" + "entgo.io/ent/dialect/sql" dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/paymentauditlog" "github.com/Wei-Shaw/sub2api/ent/paymentorder" "github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance" "github.com/Wei-Shaw/sub2api/internal/payment" + "github.com/Wei-Shaw/sub2api/internal/payment/provider" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) // --- Refund Flow --- +var createPaymentProviderFromInstance = provider.CreateProvider + // getOrderProviderInstance looks up the provider instance that processed this order. // For legacy orders without provider_instance_id, it resolves only when the // historical instance is uniquely identifiable from the stored order fields. @@ -203,7 +209,7 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float if err != nil { return nil, nil, infraerrors.NotFound("NOT_FOUND", "order not found") } - ok := []string{OrderStatusCompleted, OrderStatusRefundRequested, OrderStatusRefundFailed} + ok := []string{OrderStatusCompleted, OrderStatusRefundRequested, OrderStatusRefundPending, OrderStatusRefundFailed} if !psSliceContains(ok, o.Status) { return nil, nil, infraerrors.BadRequest("INVALID_STATUS", "order status does not allow refund") } @@ -274,7 +280,7 @@ func (s *PaymentService) prepDeduct(ctx context.Context, o *dbent.PaymentOrder, } func (s *PaymentService) ExecuteRefund(ctx context.Context, p *RefundPlan) (*RefundResult, error) { - c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(p.OrderID), paymentorder.StatusIn(OrderStatusCompleted, OrderStatusRefundRequested, OrderStatusRefundFailed)).SetStatus(OrderStatusRefunding).Save(ctx) + c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(p.OrderID), paymentorder.StatusIn(OrderStatusCompleted, OrderStatusRefundRequested, OrderStatusRefundPending, OrderStatusRefundFailed)).SetStatus(OrderStatusRefunding).Save(ctx) if err != nil { return nil, fmt.Errorf("lock: %w", err) } @@ -386,12 +392,142 @@ func (s *PaymentService) finishRefund(ctx context.Context, p *RefundPlan, resp * case payment.ProviderStatusSuccess, payment.ProviderStatusRefunded: return s.markRefundOk(ctx, p) case payment.ProviderStatusPending: - return s.markRefundPending(ctx, p) + return s.markRefundPending(ctx, p, resp) default: return s.handleGwFail(ctx, p, fmt.Errorf("payment refund returned unknown status: %s", strings.TrimSpace(resp.Status))) } } +func (s *PaymentService) QueryAndFinalizeRefund(ctx context.Context, oid int64) (*RefundResult, error) { + o, err := s.entClient.PaymentOrder.Get(ctx, oid) + if err != nil { + return nil, infraerrors.NotFound("NOT_FOUND", "order not found") + } + if o.Status != OrderStatusRefundPending { + return nil, infraerrors.BadRequest("INVALID_STATUS", "only refund pending orders can be finalized") + } + + prov, err := s.getRefundProvider(ctx, o) + if err != nil { + return nil, fmt.Errorf("get refund provider: %w", err) + } + queryProvider, ok := prov.(payment.RefundQueryProvider) + if !ok { + return nil, infraerrors.BadRequest("REFUND_QUERY_UNSUPPORTED", "this payment provider does not support refund status query; please verify manually") + } + + pendingDetail := s.latestRefundPendingDetail(ctx, oid) + resp, err := queryProvider.QueryRefund(ctx, payment.RefundQueryRequest{ + TradeNo: o.PaymentTradeNo, + OrderID: o.OutTradeNo, + RefundID: pendingDetail.RefundID, + Amount: formatGatewayRefundAmount(o.RefundAmount, o), + }) + if err != nil { + return nil, fmt.Errorf("query refund: %w", err) + } + if err := validateRefundProviderResponse(resp); err != nil { + return s.finalizeRefundFailed(ctx, o, err) + } + + plan := s.refundFinalizePlan(o) + if !pendingDetail.DeductionRollbackOK { + plan.BalanceToDeduct = 0 + plan.SubDaysToDeduct = 0 + } else if o.OrderType == payment.OrderTypeSubscription { + if early := s.prepDeduct(ctx, o, plan, true); early != nil { + return early, nil + } + } + switch strings.TrimSpace(resp.Status) { + case payment.ProviderStatusSuccess, payment.ProviderStatusRefunded: + if err := s.applyRefundFinalDeduction(ctx, plan); err != nil { + return nil, err + } + return s.markRefundOk(ctx, plan) + case payment.ProviderStatusPending: + s.writeAuditLog(ctx, oid, "REFUND_QUERY_PENDING", "admin", map[string]any{"refundID": resp.RefundID}) + return &RefundResult{Success: false, Warning: "gateway refund is still pending confirmation"}, nil + default: + return s.finalizeRefundFailed(ctx, o, fmt.Errorf("payment refund returned unknown status: %s", strings.TrimSpace(resp.Status))) + } +} + +func (s *PaymentService) refundFinalizePlan(o *dbent.PaymentOrder) *RefundPlan { + refundAmount := o.RefundAmount + reason := strings.TrimSpace(psStringValue(o.RefundReason)) + if reason == "" { + reason = fmt.Sprintf("refund order:%d", o.ID) + } + return &RefundPlan{ + OrderID: o.ID, + Order: o, + RefundAmount: refundAmount, + GatewayAmount: calculateGatewayRefundAmount(o.Amount, o.PayAmount, refundAmount, PaymentOrderCurrency(o)), + Reason: reason, + Force: o.ForceRefund, + DeductBalance: true, + DeductionType: payment.DeductionTypeBalance, + BalanceToDeduct: func() float64 { + if o.OrderType == payment.OrderTypeBalance { + return refundAmount + } + return 0 + }(), + } +} + +func (s *PaymentService) applyRefundFinalDeduction(ctx context.Context, p *RefundPlan) error { + if s.hasAuditLog(ctx, p.OrderID, "REFUND_SUCCESS") { + p.BalanceToDeduct = 0 + p.SubDaysToDeduct = 0 + return nil + } + if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 { + if err := s.userRepo.DeductBalance(ctx, p.Order.UserID, p.BalanceToDeduct); err != nil { + return fmt.Errorf("deduction: %w", err) + } + } + if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 { + if _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, -p.SubDaysToDeduct); err != nil { + if errors.Is(err, ErrAdjustWouldExpire) { + if revokeErr := s.subscriptionSvc.RevokeSubscription(ctx, p.SubscriptionID); revokeErr != nil { + return fmt.Errorf("revoke subscription: %w", revokeErr) + } + } else { + return fmt.Errorf("deduct subscription days: %w", err) + } + } + } + return nil +} + +func (s *PaymentService) finalizeRefundFailed(ctx context.Context, o *dbent.PaymentOrder, gErr error) (*RefundResult, error) { + now := time.Now() + _, _ = s.entClient.PaymentOrder.UpdateOneID(o.ID).SetStatus(OrderStatusRefundFailed).SetFailedAt(now).SetFailedReason(psErrMsg(gErr)).Save(ctx) + s.writeAuditLog(ctx, o.ID, "REFUND_FAILED", "admin", map[string]any{"detail": psErrMsg(gErr)}) + return &RefundResult{Success: false, Warning: "gateway refund failed: " + psErrMsg(gErr)}, nil +} + +type refundPendingAuditDetail struct { + RefundID string `json:"refundID"` + DeductionRollbackOK bool `json:"deductionRollbackOK"` +} + +func (s *PaymentService) latestRefundPendingDetail(ctx context.Context, oid int64) refundPendingAuditDetail { + logEntry, err := s.entClient.PaymentAuditLog.Query(). + Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(oid, 10)), paymentauditlog.ActionEQ("REFUND_PENDING")). + Order(paymentauditlog.ByCreatedAt(sql.OrderDesc())). + First(ctx) + if err != nil || logEntry == nil { + return refundPendingAuditDetail{DeductionRollbackOK: true} + } + detail := refundPendingAuditDetail{DeductionRollbackOK: true} + _ = json.Unmarshal([]byte(logEntry.Detail), &detail) + detail.RefundID = strings.TrimSpace(detail.RefundID) + return detail +} + // getRefundProvider creates a provider using the order's original instance config. // Delegates to getOrderProvider which handles instance lookup and fallback. func (s *PaymentService) getRefundProvider(ctx context.Context, o *dbent.PaymentOrder) (payment.Provider, error) { @@ -431,7 +567,7 @@ func (s *PaymentService) markRefundOk(ctx context.Context, p *RefundPlan) (*Refu return &RefundResult{Success: true, BalanceDeducted: p.BalanceToDeduct, SubDaysDeducted: p.SubDaysToDeduct}, nil } -func (s *PaymentService) markRefundPending(ctx context.Context, p *RefundPlan) (*RefundResult, error) { +func (s *PaymentService) markRefundPending(ctx context.Context, p *RefundPlan, resp *payment.RefundResponse) (*RefundResult, error) { balanceDeducted := p.BalanceToDeduct subDaysDeducted := p.SubDaysToDeduct rollbackOK := s.RollbackRefund(ctx, p, nil) @@ -454,6 +590,7 @@ func (s *PaymentService) markRefundPending(ctx context.Context, p *RefundPlan) ( } detail := map[string]any{ + "refundID": refundResponseID(resp), "refundAmount": p.RefundAmount, "reason": p.Reason, "force": p.Force, @@ -472,6 +609,13 @@ func (s *PaymentService) markRefundPending(ctx context.Context, p *RefundPlan) ( return &RefundResult{Success: false, Warning: warning}, nil } +func refundResponseID(resp *payment.RefundResponse) string { + if resp == nil { + return "" + } + return strings.TrimSpace(resp.RefundID) +} + func (s *PaymentService) RollbackRefund(ctx context.Context, p *RefundPlan, gErr error) bool { if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 { if err := s.userRepo.UpdateBalance(ctx, p.Order.UserID, p.BalanceToDeduct); err != nil { diff --git a/backend/internal/service/payment_refund_test.go b/backend/internal/service/payment_refund_test.go index c0b7587360..dc9c3fa0de 100644 --- a/backend/internal/service/payment_refund_test.go +++ b/backend/internal/service/payment_refund_test.go @@ -359,3 +359,153 @@ func TestFinishRefundSuccessStatusesFinalize(t *testing.T) { }) } } + +func TestQueryAndFinalizeRefundFinalizesProviderStatuses(t *testing.T) { + for _, tc := range []struct { + name string + status string + wantStatus string + wantDeduct float64 + }{ + {name: "success", status: payment.ProviderStatusSuccess, wantStatus: OrderStatusRefunded, wantDeduct: 100}, + {name: "failed", status: payment.ProviderStatusFailed, wantStatus: OrderStatusRefundFailed}, + {name: "pending", status: payment.ProviderStatusPending, wantStatus: OrderStatusRefundPending}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + order := createPendingRefundOrderForTest(t, ctx, client, "query-finalize-"+tc.name) + + var deducted float64 + svc := &PaymentService{ + entClient: client, + loadBalancer: &captureLoadBalancer{}, + userRepo: &mockUserRepo{deductBalanceFn: func(ctx context.Context, id int64, amount float64) error { + deducted += amount + return nil + }}, + } + restore := replacePaymentProviderFactoryForTest(t, &refundQueryProviderTestDouble{ + refundResponse: &payment.RefundResponse{RefundID: "rf_test", Status: tc.status}, + }) + defer restore() + + result, err := svc.QueryAndFinalizeRefund(ctx, order.ID) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, tc.status == payment.ProviderStatusSuccess, result.Success) + require.Equal(t, tc.wantDeduct, deducted) + + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, tc.wantStatus, reloaded.Status) + }) + } +} + +func TestQueryAndFinalizeRefundUnsupportedProviderReturnsClearError(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + order := createPendingRefundOrderForTest(t, ctx, client, "query-finalize-unsupported") + svc := &PaymentService{entClient: client, loadBalancer: &captureLoadBalancer{}} + restore := replacePaymentProviderFactoryForTest(t, refundProviderTestDouble{}) + defer restore() + + result, err := svc.QueryAndFinalizeRefund(ctx, order.ID) + require.Nil(t, result) + require.Error(t, err) + require.Equal(t, "REFUND_QUERY_UNSUPPORTED", infraerrors.Reason(err)) +} + +func createPendingRefundOrderForTest(t *testing.T, ctx context.Context, client *dbent.Client, suffix string) *dbent.PaymentOrder { + t.Helper() + + user, err := client.User.Create(). + SetEmail(suffix + "@example.com"). + SetPasswordHash("hash"). + SetUsername(suffix). + Save(ctx) + require.NoError(t, err) + + inst, err := client.PaymentProviderInstance.Create(). + SetProviderKey(payment.TypeStripe). + SetName(suffix + "-provider"). + SetConfig("{}"). + SetSupportedTypes("stripe"). + SetEnabled(true). + SetRefundEnabled(true). + Save(ctx) + require.NoError(t, err) + + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(100). + SetPayAmount(100). + SetFeeRate(0). + SetRechargeCode("REFUND-" + suffix). + SetOutTradeNo("sub2_" + suffix). + SetPaymentType(payment.TypeStripe). + SetPaymentTradeNo("pi_" + suffix). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusRefundPending). + SetRefundAmount(100). + SetRefundReason("pending refund"). + SetExpiresAt(time.Now().Add(time.Hour)). + SetPaidAt(time.Now()). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + SetProviderInstanceID(strconv.FormatInt(inst.ID, 10)). + Save(ctx) + require.NoError(t, err) + + _, err = client.PaymentAuditLog.Create(). + SetOrderID(strconv.FormatInt(order.ID, 10)). + SetAction("REFUND_PENDING"). + SetOperator("admin"). + SetDetail(`{"refundID":"rf_test","deductionRollbackOK":true}`). + Save(ctx) + require.NoError(t, err) + return order +} + +func replacePaymentProviderFactoryForTest(t *testing.T, prov payment.Provider) func() { + t.Helper() + original := createPaymentProviderFromInstance + createPaymentProviderFromInstance = func(providerKey, instanceID string, config map[string]string) (payment.Provider, error) { + return prov, nil + } + return func() { createPaymentProviderFromInstance = original } +} + +type refundProviderTestDouble struct{} + +func (refundProviderTestDouble) Name() string { return "refund-test" } +func (refundProviderTestDouble) ProviderKey() string { + return payment.TypeStripe +} +func (refundProviderTestDouble) SupportedTypes() []payment.PaymentType { + return []payment.PaymentType{payment.TypeStripe} +} +func (refundProviderTestDouble) CreatePayment(context.Context, payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) { + return nil, nil +} +func (refundProviderTestDouble) QueryOrder(context.Context, string) (*payment.QueryOrderResponse, error) { + return nil, nil +} +func (refundProviderTestDouble) VerifyNotification(context.Context, string, map[string]string) (*payment.PaymentNotification, error) { + return nil, nil +} +func (refundProviderTestDouble) Refund(context.Context, payment.RefundRequest) (*payment.RefundResponse, error) { + return nil, nil +} + +type refundQueryProviderTestDouble struct { + refundProviderTestDouble + refundResponse *payment.RefundResponse +} + +func (p *refundQueryProviderTestDouble) QueryRefund(context.Context, payment.RefundQueryRequest) (*payment.RefundResponse, error) { + return p.refundResponse, nil +} diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go index 417140adcc..ebe21cc89a 100644 --- a/backend/internal/service/user_service_test.go +++ b/backend/internal/service/user_service_test.go @@ -25,6 +25,7 @@ import ( type mockUserRepo struct { updateBalanceErr error updateBalanceFn func(ctx context.Context, id int64, amount float64) error + deductBalanceFn func(ctx context.Context, id int64, amount float64) error getByIDUser *User getByIDErr error identities []UserAuthIdentityRecord @@ -193,7 +194,12 @@ func (m *mockUserRepo) UpdateUserLastActiveAt(_ context.Context, userID int64, a m.updateLastActiveAt = append(m.updateLastActiveAt, activeAt) return m.updateLastActiveErr } -func (m *mockUserRepo) DeductBalance(context.Context, int64, float64) error { return nil } +func (m *mockUserRepo) DeductBalance(ctx context.Context, id int64, amount float64) error { + if m.deductBalanceFn != nil { + return m.deductBalanceFn(ctx, id, amount) + } + return nil +} func (m *mockUserRepo) UpdateConcurrency(context.Context, int64, int) error { return nil } func (m *mockUserRepo) ExistsByEmail(context.Context, string) (bool, error) { return false, nil } func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { diff --git a/frontend/src/api/admin/payment.ts b/frontend/src/api/admin/payment.ts index 3daf56b2f4..49efcc355d 100644 --- a/frontend/src/api/admin/payment.ts +++ b/frontend/src/api/admin/payment.ts @@ -49,6 +49,14 @@ export interface UpdatePaymentConfigRequest { help_text?: string } +export interface RefundResult { + success: boolean + warning?: string + require_force?: boolean + balance_deducted?: number + subscription_days_deducted?: number +} + export const adminPaymentAPI = { // ==================== Config ==================== @@ -105,7 +113,12 @@ export const adminPaymentAPI = { /** Process a refund */ refundOrder(id: number, data: { amount: number; reason: string; deduct_balance?: boolean; force?: boolean }) { - return apiClient.post(`/admin/payment/orders/${id}/refund`, data) + return apiClient.post(`/admin/payment/orders/${id}/refund`, data) + }, + + /** Query and finalize a pending refund */ + queryRefund(id: number) { + return apiClient.post(`/admin/payment/orders/${id}/refund/query`) }, // ==================== Channels ==================== diff --git a/frontend/src/api/payment.ts b/frontend/src/api/payment.ts index 92b0ec90c6..ab83c55d94 100644 --- a/frontend/src/api/payment.ts +++ b/frontend/src/api/payment.ts @@ -16,6 +16,14 @@ import type { } from '@/types/payment' import type { BasePaginationResponse } from '@/types' +export interface PublicOrderVerifyResult { + out_trade_no: string + status: string + paid: boolean + created_at: string + expires_at: string +} + export const paymentAPI = { /** Get payment configuration (enabled types, limits, etc.) */ getConfig() { @@ -69,12 +77,12 @@ export const paymentAPI = { /** Legacy-compatible public order lookup by out_trade_no */ verifyOrderPublic(outTradeNo: string) { - return apiClient.post('/payment/public/orders/verify', { out_trade_no: outTradeNo }) + return apiClient.post('/payment/public/orders/verify', { out_trade_no: outTradeNo }) }, /** Resolve an order from a signed resume token without auth */ resolveOrderPublicByResumeToken(resumeToken: string) { - return apiClient.post('/payment/public/orders/resolve', { resume_token: resumeToken }) + return apiClient.post('/payment/public/orders/resolve', { resume_token: resumeToken }) }, /** Request a refund for a completed order */ diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 5bd19d6923..7770fe5b88 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -7201,6 +7201,8 @@ export default { refundReasonPlaceholder: 'Please enter refund reason', confirmRefund: 'Confirm Refund', refundSuccess: 'Refund successful', + refundPending: 'Refund pending gateway confirmation', + queryRefundStatus: 'Query refund status', refundInfo: 'Refund Info', refundEnabled: 'Refund Enabled', allowUserRefund: 'Allow User Refund', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 23a2f6ffa7..9b9f544a97 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -7381,6 +7381,8 @@ export default { refundReasonPlaceholder: '请输入退款原因', confirmRefund: '确认退款', refundSuccess: '退款成功', + refundPending: '退款处理中,待网关确认', + queryRefundStatus: '查询退款状态', refundInfo: '退款信息', refundEnabled: '允许退款', alreadyRefunded: '已退款', diff --git a/frontend/src/views/admin/orders/AdminOrdersView.vue b/frontend/src/views/admin/orders/AdminOrdersView.vue index 6d9bf5dfee..7fe129968c 100644 --- a/frontend/src/views/admin/orders/AdminOrdersView.vue +++ b/frontend/src/views/admin/orders/AdminOrdersView.vue @@ -45,6 +45,10 @@ {{ t('payment.admin.retryRefund') }} +