Merge pull request #3509 from wucm667/fix/refund-pending-not-success

fix(payment): 退款 pending 不再当成最终成功,避免站内账务与网关状态不一致
This commit is contained in:
Wesley Liddick
2026-06-30 13:38:04 +08:00
committed by GitHub
25 changed files with 891 additions and 77 deletions
@@ -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.
+44 -3
View File
@@ -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.
@@ -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) {
@@ -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 == "" {
@@ -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)) == "" {
+48 -7
View File
@@ -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) {
+16
View File
@@ -31,6 +31,7 @@ const (
OrderStatusFailed = "FAILED"
OrderStatusRefundRequested = "REFUND_REQUESTED"
OrderStatusRefunding = "REFUNDING"
OrderStatusRefundPending = "REFUND_PENDING"
OrderStatusPartiallyRefunded = "PARTIALLY_REFUNDED"
OrderStatusRefunded = "REFUNDED"
OrderStatusRefundFailed = "REFUND_FAILED"
@@ -181,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
@@ -215,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
@@ -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
@@ -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)
}
+216 -10
View File
@@ -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)
}
@@ -316,29 +322,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 +354,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 +384,150 @@ 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, 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) {
@@ -410,6 +567,55 @@ 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, resp *payment.RefundResponse) (*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{
"refundID": refundResponseID(resp),
"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 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 {
+302 -1
View File
@@ -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,303 @@ 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)
})
}
}
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
}
+2 -1
View File
@@ -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
@@ -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) {
+14 -1
View File
@@ -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<RefundResult>(`/admin/payment/orders/${id}/refund`, data)
},
/** Query and finalize a pending refund */
queryRefund(id: number) {
return apiClient.post<RefundResult>(`/admin/payment/orders/${id}/refund/query`)
},
// ==================== Channels ====================
+10 -2
View File
@@ -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<PaymentOrder>('/payment/public/orders/verify', { out_trade_no: outTradeNo })
return apiClient.post<PublicOrderVerifyResult>('/payment/public/orders/verify', { out_trade_no: outTradeNo })
},
/** Resolve an order from a signed resume token without auth */
resolveOrderPublicByResumeToken(resumeToken: string) {
return apiClient.post<PaymentOrder>('/payment/public/orders/resolve', { resume_token: resumeToken })
return apiClient.post<PublicOrderVerifyResult>('/payment/public/orders/resolve', { resume_token: resumeToken })
},
/** Request a refund for a completed order */
@@ -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') },
])
@@ -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
@@ -28,6 +28,7 @@ const statusMap: Record<OrderStatus, { key: string; class: string }> = {
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' },
@@ -13,6 +13,7 @@ const STATUS_BADGE_MAP: Record<string, string> = {
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',
+3
View File
@@ -7013,6 +7013,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',
@@ -7201,6 +7202,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',
+3
View File
@@ -7193,6 +7193,7 @@ export default {
failed: '失败',
refund_requested: '退款申请中',
refunding: '退款中',
refund_pending: '退款处理中',
refunded: '已退款',
partially_refunded: '部分退款',
refund_failed: '退款失败',
@@ -7381,6 +7382,8 @@ export default {
refundReasonPlaceholder: '请输入退款原因',
confirmRefund: '确认退款',
refundSuccess: '退款成功',
refundPending: '退款处理中,待网关确认',
queryRefundStatus: '查询退款状态',
refundInfo: '退款信息',
refundEnabled: '允许退款',
alreadyRefunded: '已退款',
+1
View File
@@ -14,6 +14,7 @@ export type OrderStatus =
| 'FAILED'
| 'REFUND_REQUESTED'
| 'REFUNDING'
| 'REFUND_PENDING'
| 'PARTIALLY_REFUNDED'
| 'REFUNDED'
| 'REFUND_FAILED'
@@ -45,6 +45,10 @@
<Icon name="refresh" size="sm" />
{{ t('payment.admin.retryRefund') }}
</button>
<button v-else-if="row.status === 'REFUND_PENDING'" :disabled="refundQueryingIds.has(row.id)" @click="handleQueryRefund(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-orange-600 hover:bg-orange-50 disabled:opacity-60 dark:text-orange-400 dark:hover:bg-orange-900/20">
<Icon name="refresh" size="sm" :class="refundQueryingIds.has(row.id) ? 'animate-spin' : ''" />
{{ t('payment.admin.queryRefundStatus') }}
</button>
<button v-else-if="row.status === 'COMPLETED' || row.status === 'PARTIALLY_REFUNDED'" @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20">
<Icon name="dollar" size="sm" />
{{ t('payment.admin.refund') }}
@@ -149,6 +153,7 @@ const selectedOrder = ref<PaymentOrder | null>(null)
const showDetailDialog = ref(false)
const showRefundDialog = ref(false)
const refundSubmitting = ref(false)
const refundQueryingIds = ref(new Set<number>())
const orderAuditLogs = ref<AuditLog[]>([])
const creditedAmountSymbol = currencySymbol('USD')
@@ -190,6 +195,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') },
])
@@ -231,16 +237,53 @@ async function handleRetryOrder(order: PaymentOrder) {
function openRefundDialog(order: PaymentOrder) { selectedOrder.value = order; showRefundDialog.value = true }
function isRefundPendingWarning(warning: string | undefined): boolean {
return /pending|处理中|待/.test(String(warning || '').toLowerCase())
}
async function handleRefund(data: { amount: number; reason: string; deduct_balance: boolean; force: boolean }) {
if (!selectedOrder.value) return
refundSubmitting.value = true
try {
await adminPaymentAPI.refundOrder(selectedOrder.value.id, { amount: data.amount, reason: data.reason, deduct_balance: data.deduct_balance, force: data.force })
appStore.showSuccess(t('payment.admin.refundSuccess')); showRefundDialog.value = false; loadOrders()
const res = await adminPaymentAPI.refundOrder(selectedOrder.value.id, { amount: data.amount, reason: data.reason, deduct_balance: data.deduct_balance, force: data.force })
if (res.data.success) {
appStore.showSuccess(t('payment.admin.refundSuccess'))
showRefundDialog.value = false
loadOrders()
return
}
if (isRefundPendingWarning(res.data.warning)) {
appStore.showSuccess(t('payment.admin.refundPending'))
showRefundDialog.value = false
loadOrders()
return
}
appStore.showError(res.data.warning || t('common.error'))
} catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
finally { refundSubmitting.value = false }
}
async function handleQueryRefund(order: PaymentOrder) {
refundQueryingIds.value = new Set(refundQueryingIds.value).add(order.id)
try {
const res = await adminPaymentAPI.queryRefund(order.id)
if (res.data.success) {
appStore.showSuccess(t('payment.admin.refundSuccess'))
} else if (isRefundPendingWarning(res.data.warning)) {
appStore.showSuccess(t('payment.admin.refundPending'))
} else {
appStore.showError(res.data.warning || t('common.error'))
}
loadOrders()
} catch (err: unknown) {
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
} finally {
const next = new Set(refundQueryingIds.value)
next.delete(order.id)
refundQueryingIds.value = next
}
}
function formatDateTime(dateStr: string): string { return formatOrderDateTime(dateStr) }
onMounted(() => loadOrders())
+39 -20
View File
@@ -35,7 +35,7 @@
<!-- Order Info -->
<div v-if="order" class="rounded-xl bg-white p-5 shadow-sm dark:bg-dark-800">
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<div v-if="hasOrderId(order)" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
<span class="font-medium text-gray-900 dark:text-white">#{{ order.id }}</span>
</div>
@@ -43,29 +43,29 @@
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderNo') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ order.out_trade_no }}</span>
</div>
<div class="flex justify-between">
<div v-if="hasAmountFields(order)" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.baseAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ formatGatewayAmount(baseAmount) }}</span>
</div>
<div v-if="order.fee_rate > 0" class="flex justify-between">
<div v-if="hasAmountFields(order) && order.fee_rate > 0" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.fee') }} ({{ order.fee_rate }}%)</span>
<span class="font-medium text-gray-900 dark:text-white">{{ formatGatewayAmount(feeAmount) }}</span>
</div>
<div class="flex justify-between">
<div v-if="hasAmountFields(order)" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</span>
<span class="font-bold text-primary-600 dark:text-primary-400">{{ formatGatewayAmount(order.pay_amount) }}</span>
</div>
<div v-if="order.amount !== order.pay_amount" class="flex justify-between">
<div v-if="hasAmountFields(order) && order.amount !== order.pay_amount" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.creditedAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ order.order_type === 'balance' ? '$' + order.amount.toFixed(2) : formatGatewayAmount(order.amount) }}</span>
</div>
<div class="flex justify-between">
<div v-if="hasPaymentType(order)" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ t(paymentMethodI18nKey(order.payment_type), normalizedOrderPaymentType(order.payment_type)) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.status') }}</span>
<OrderStatusBadge :status="order.status" />
<OrderStatusBadge :status="displayOrderStatus(order.status)" />
</div>
</div>
</div>
@@ -108,7 +108,8 @@ import {
} from '@/components/payment/paymentFlow'
import { usePaymentStore } from '@/stores/payment'
import { paymentAPI } from '@/api/payment'
import type { PaymentOrder } from '@/types/payment'
import type { PublicOrderVerifyResult } from '@/api/payment'
import type { OrderStatus, PaymentOrder } from '@/types/payment'
import { formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import { normalizePaymentMethodForDisplay, paymentMethodI18nKey } from './paymentUx'
@@ -118,7 +119,9 @@ const route = useRoute()
const router = useRouter()
const paymentStore = usePaymentStore()
const order = ref<PaymentOrder | null>(null)
type ResolvedOrder = PaymentOrder | PublicOrderVerifyResult
const order = ref<ResolvedOrder | null>(null)
const loading = ref(true)
const currency = ref('CNY')
@@ -140,7 +143,7 @@ const refreshAttempts = ref(0)
/** 充值金额 = pay_amount / (1 + fee_rate/100),fee_rate=0 时等于 pay_amount */
const baseAmount = computed(() => {
if (!order.value) return 0
if (!hasAmountFields(order.value)) return 0
const feeRate = Number(order.value.fee_rate) || 0
if (feeRate <= 0) return order.value.pay_amount ?? 0
return Math.round((order.value.pay_amount / (1 + feeRate / 100)) * 100) / 100
@@ -148,7 +151,7 @@ const baseAmount = computed(() => {
/** 手续费 = pay_amount - baseAmount */
const feeAmount = computed(() => {
if (!order.value) return 0
if (!hasAmountFields(order.value)) return 0
const feeRate = Number(order.value.fee_rate) || 0
if (feeRate <= 0) return 0
return Math.round((order.value.pay_amount - baseAmount.value) * 100) / 100
@@ -182,24 +185,40 @@ const statusTitle = computed(() => {
})
function normalizedOrderPaymentType(paymentType: string): string {
return normalizePaymentMethodForDisplay(paymentType) || paymentType
return normalizePaymentMethodForDisplay(paymentType || '') || paymentType || ''
}
function formatGatewayAmount(value: number): string {
return formatPaymentAmount(value, currency.value, localeCode.value)
}
function setResolvedOrder(nextOrder: PaymentOrder | null): void {
function setResolvedOrder(nextOrder: ResolvedOrder | null): void {
order.value = nextOrder
if (nextOrder?.currency) {
if (nextOrder && 'currency' in nextOrder && nextOrder.currency) {
currency.value = normalizePaymentCurrency(nextOrder.currency)
}
}
function hasOrderId(nextOrder: ResolvedOrder | null): nextOrder is PaymentOrder {
return !!nextOrder && 'id' in nextOrder && typeof nextOrder.id === 'number'
}
function hasAmountFields(nextOrder: ResolvedOrder | null): nextOrder is PaymentOrder {
return !!nextOrder && 'pay_amount' in nextOrder && typeof nextOrder.pay_amount === 'number' && 'amount' in nextOrder && typeof nextOrder.amount === 'number'
}
function hasPaymentType(nextOrder: ResolvedOrder | null): nextOrder is PaymentOrder {
return !!nextOrder && 'payment_type' in nextOrder && typeof nextOrder.payment_type === 'string' && nextOrder.payment_type.trim() !== ''
}
function normalizeOrderStatus(status: string | null | undefined): string {
return String(status || '').trim().toUpperCase()
}
function displayOrderStatus(status: string): OrderStatus {
return normalizeOrderStatus(status) as OrderStatus
}
function isSuccessStatus(status: string | null | undefined): boolean {
return SUCCESS_STATUSES.has(normalizeOrderStatus(status))
}
@@ -256,7 +275,7 @@ function restoreRecoverySnapshot(context: {
return restored
}
async function resolveOrderFromResumeToken(resumeToken: string): Promise<PaymentOrder | null> {
async function resolveOrderFromResumeToken(resumeToken: string): Promise<ResolvedOrder | null> {
try {
const result = await paymentAPI.resolveOrderPublicByResumeToken(resumeToken)
return result.data
@@ -265,7 +284,7 @@ async function resolveOrderFromResumeToken(resumeToken: string): Promise<Payment
}
}
async function resolveOrderFromOutTradeNo(outTradeNo: string): Promise<PaymentOrder | null> {
async function resolveOrderFromOutTradeNo(outTradeNo: string): Promise<ResolvedOrder | null> {
try {
const result = await paymentAPI.verifyOrder(outTradeNo)
return result.data
@@ -298,7 +317,7 @@ function clearRecoverySnapshotForTerminalStatus(status: string | null | undefine
}
}
function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>) | null): void {
function scheduleStatusRefresh(refreshOrder: (() => Promise<ResolvedOrder | null>) | null): void {
clearStatusRefreshTimer()
if (!refreshOrder || !isPending.value || refreshAttempts.value >= STATUS_REFRESH_MAX_ATTEMPTS) {
return
@@ -345,7 +364,7 @@ onMounted(async () => {
if (resolvedOrder) {
setResolvedOrder(resolvedOrder)
if (!orderId) {
orderId = resolvedOrder.id
orderId = hasOrderId(resolvedOrder) ? resolvedOrder.id : 0
}
} else if (routeOrderId > 0) {
resumeTokenLookupFailed = true
@@ -373,7 +392,7 @@ onMounted(async () => {
if (legacyOrder) {
setResolvedOrder(legacyOrder)
if (!orderId) {
orderId = legacyOrder.id
orderId = hasOrderId(legacyOrder) ? legacyOrder.id : 0
}
}
}
@@ -387,7 +406,7 @@ onMounted(async () => {
}
}
const refreshOrder = async (): Promise<PaymentOrder | null> => {
const refreshOrder = async (): Promise<ResolvedOrder | null> => {
if (resumeToken) {
const resolvedOrder = await resolveOrderFromResumeToken(resumeToken)
if (resolvedOrder) {
@@ -353,6 +353,37 @@ describe('PaymentResultView', () => {
expect(wrapper.text()).toContain('payment.result.success')
})
it('renders the minimal public out_trade_no verification result without payment_type', async () => {
routeState.query = {
out_trade_no: 'legacy-minimal',
trade_status: 'TRADE_SUCCESS',
}
verifyOrder.mockRejectedValue(new Error('auth required'))
verifyOrderPublic.mockResolvedValue({
data: {
out_trade_no: 'legacy-minimal',
status: 'PAID',
paid: true,
created_at: '2026-04-20T12:00:00Z',
expires_at: '2026-04-20T12:30:00Z',
},
})
const wrapper = mount(PaymentResultView, {
global: {
stubs: {
OrderStatusBadge: true,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('payment.result.success')
expect(wrapper.text()).toContain('legacy-minimal')
expect(wrapper.text()).not.toContain('payment.orders.paymentMethod')
})
it('prefers authenticated order verification before falling back to public lookup', async () => {
routeState.query = {
out_trade_no: 'auth-verify-123',