mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #3509 from wucm667/fix/refund-pending-not-success
fix(payment): 退款 pending 不再当成最终成功,避免站内账务与网关状态不一致
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)) == "" {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ====================
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '已退款',
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user