mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
fix(payment): validate order types and keep refund deductions while pending
- Validate order_type (balance/subscription) at order creation and in fulfillment; other values are rejected with INVALID_ORDER_TYPE. - Keep the balance/subscription deduction while a gateway refund is pending. The deduction is recorded in the REFUND_PENDING audit (written in the same transaction as the status change), not applied again on successful finalization, and returned once if the gateway reports failure. Legacy pending audits keep the previous behaviour. - REFUND_PENDING orders are finalized via refund query only; PrepareRefund/ExecuteRefund no longer accept them. - Invalidate the billing balance cache after refund deductions/returns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
shaw
co-authored by
Claude Opus 5.5
parent
1b2243a4f5
commit
7c6f70680d
@@ -218,10 +218,14 @@ func (s *PaymentService) executeFulfillment(ctx context.Context, oid int64) erro
|
||||
if err != nil {
|
||||
return fmt.Errorf("get order: %w", err)
|
||||
}
|
||||
if o.OrderType == payment.OrderTypeSubscription {
|
||||
switch o.OrderType {
|
||||
case payment.OrderTypeSubscription:
|
||||
return s.ExecuteSubscriptionFulfillment(ctx, oid)
|
||||
case payment.OrderTypeBalance:
|
||||
return s.ExecuteBalanceFulfillment(ctx, oid)
|
||||
default:
|
||||
return infraerrors.BadRequest("INVALID_ORDER_TYPE", "unsupported order type "+o.OrderType)
|
||||
}
|
||||
return s.ExecuteBalanceFulfillment(ctx, oid)
|
||||
}
|
||||
|
||||
func (s *PaymentService) ExecuteBalanceFulfillment(ctx context.Context, oid int64) error {
|
||||
@@ -229,6 +233,9 @@ func (s *PaymentService) ExecuteBalanceFulfillment(ctx context.Context, oid int6
|
||||
if err != nil {
|
||||
return infraerrors.NotFound("NOT_FOUND", "order not found")
|
||||
}
|
||||
if o.OrderType != payment.OrderTypeBalance {
|
||||
return infraerrors.BadRequest("INVALID_ORDER_TYPE", "order is not a balance order")
|
||||
}
|
||||
if o.Status == OrderStatusCompleted {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -120,11 +120,16 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
|
||||
}
|
||||
|
||||
func (s *PaymentService) validateOrderInput(ctx context.Context, req CreateOrderRequest, cfg *PaymentConfig) (*dbent.SubscriptionPlan, error) {
|
||||
if req.OrderType == payment.OrderTypeBalance && cfg.BalanceDisabled {
|
||||
return nil, infraerrors.Forbidden("BALANCE_PAYMENT_DISABLED", "balance recharge has been disabled")
|
||||
}
|
||||
if req.OrderType == payment.OrderTypeSubscription {
|
||||
// 订单类型必须走白名单:未知类型会跳过充值开关与倍率计算,履约时却仍按余额到账。
|
||||
switch req.OrderType {
|
||||
case payment.OrderTypeBalance:
|
||||
if cfg.BalanceDisabled {
|
||||
return nil, infraerrors.Forbidden("BALANCE_PAYMENT_DISABLED", "balance recharge has been disabled")
|
||||
}
|
||||
case payment.OrderTypeSubscription:
|
||||
return s.validateSubOrder(ctx, req)
|
||||
default:
|
||||
return nil, infraerrors.BadRequest("INVALID_ORDER_TYPE", "unsupported order type")
|
||||
}
|
||||
if math.IsNaN(req.Amount) || math.IsInf(req.Amount, 0) || req.Amount <= 0 {
|
||||
return nil, infraerrors.BadRequest("INVALID_AMOUNT", "amount must be a positive number")
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateOrderInputRejectsUnknownOrderType(t *testing.T) {
|
||||
svc := &PaymentService{}
|
||||
for _, orderType := range []string{"x", "Balance", " balance", "subscription "} {
|
||||
for _, disabled := range []bool{false, true} {
|
||||
cfg := &PaymentConfig{BalanceDisabled: disabled}
|
||||
plan, err := svc.validateOrderInput(context.Background(), CreateOrderRequest{OrderType: orderType, Amount: 10}, cfg)
|
||||
require.Nil(t, plan)
|
||||
require.Error(t, err, "order type %q must be rejected", orderType)
|
||||
require.Equal(t, "INVALID_ORDER_TYPE", infraerrors.Reason(err), "order type %q", orderType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOrderInputKeepsBalanceDisabledSwitch(t *testing.T) {
|
||||
svc := &PaymentService{}
|
||||
_, err := svc.validateOrderInput(context.Background(), CreateOrderRequest{OrderType: payment.OrderTypeBalance, Amount: 10}, &PaymentConfig{BalanceDisabled: true})
|
||||
require.Equal(t, "BALANCE_PAYMENT_DISABLED", infraerrors.Reason(err))
|
||||
|
||||
plan, err := svc.validateOrderInput(context.Background(), CreateOrderRequest{OrderType: payment.OrderTypeBalance, Amount: 10}, &PaymentConfig{})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, plan)
|
||||
}
|
||||
|
||||
func TestFulfillmentRejectsUnknownOrderType(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := newPaymentConfigServiceTestClient(t)
|
||||
order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusPaid, time.Now())
|
||||
order, err := client.PaymentOrder.UpdateOneID(order.ID).
|
||||
SetOrderType("x").
|
||||
ClearPlanID().
|
||||
ClearSubscriptionGroupID().
|
||||
ClearSubscriptionDays().
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
userRepo := &mockUserRepo{getByIDUser: &User{ID: order.UserID}}
|
||||
userRepo.updateBalanceFn = func(context.Context, int64, float64) error {
|
||||
t.Fatal("unknown order type must not credit balance")
|
||||
return nil
|
||||
}
|
||||
svc := &PaymentService{entClient: client, userRepo: userRepo}
|
||||
|
||||
err = svc.executeFulfillment(ctx, order.ID)
|
||||
require.Equal(t, "INVALID_ORDER_TYPE", infraerrors.Reason(err))
|
||||
err = svc.ExecuteBalanceFulfillment(ctx, order.ID)
|
||||
require.Equal(t, "INVALID_ORDER_TYPE", infraerrors.Reason(err))
|
||||
|
||||
reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OrderStatusPaid, reloaded.Status)
|
||||
}
|
||||
@@ -210,7 +210,8 @@ 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, OrderStatusRefundPending, OrderStatusRefundFailed}
|
||||
// REFUND_PENDING 只能通过 QueryAndFinalizeRefund 收敛:重新发起会重复扣减并向网关重复退款。
|
||||
ok := []string{OrderStatusCompleted, OrderStatusRefundRequested, OrderStatusRefundFailed}
|
||||
if !psSliceContains(ok, o.Status) {
|
||||
return nil, nil, infraerrors.BadRequest("INVALID_STATUS", "order status does not allow refund")
|
||||
}
|
||||
@@ -295,8 +296,16 @@ func (s *PaymentService) deductAvailableBalance(ctx context.Context, userID int6
|
||||
return repo.DeductAvailableBalance(ctx, userID, amount)
|
||||
}
|
||||
|
||||
// invalidateRefundBalanceCache 退款扣减或归还余额后失效计费缓存,否则网关在缓存 TTL 内仍按旧余额放行。
|
||||
func (s *PaymentService) invalidateRefundBalanceCache(ctx context.Context, userID int64) {
|
||||
if s.redeemService == nil {
|
||||
return
|
||||
}
|
||||
s.redeemService.invalidateRedeemCaches(ctx, userID, &RedeemCode{Type: RedeemTypeBalance})
|
||||
}
|
||||
|
||||
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, OrderStatusRefundPending, OrderStatusRefundFailed)).SetStatus(OrderStatusRefunding).Save(ctx)
|
||||
c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(p.OrderID), paymentorder.StatusIn(OrderStatusCompleted, OrderStatusRefundRequested, OrderStatusRefundFailed)).SetStatus(OrderStatusRefunding).Save(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lock: %w", err)
|
||||
}
|
||||
@@ -313,6 +322,7 @@ func (s *PaymentService) ExecuteRefund(ctx context.Context, p *RefundPlan) (*Ref
|
||||
return nil, fmt.Errorf("deduction: %w", err)
|
||||
}
|
||||
p.BalanceToDeduct = deducted
|
||||
s.invalidateRefundBalanceCache(ctx, p.Order.UserID)
|
||||
} else {
|
||||
slog.Warn("skipping balance deduction on retry (previous rollback failed)", "orderID", p.OrderID)
|
||||
p.BalanceToDeduct = 0
|
||||
@@ -448,19 +458,21 @@ func (s *PaymentService) QueryAndFinalizeRefund(ctx context.Context, oid int64)
|
||||
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 {
|
||||
switch {
|
||||
case pendingDetail.DeductionHeld:
|
||||
pendingDetail.applyTo(plan)
|
||||
case !pendingDetail.DeductionRollbackOK:
|
||||
plan.BalanceToDeduct = 0
|
||||
plan.SubDaysToDeduct = 0
|
||||
} else if o.OrderType == payment.OrderTypeSubscription {
|
||||
case o.OrderType == payment.OrderTypeSubscription:
|
||||
if early := s.prepDeduct(ctx, o, plan, true); early != nil {
|
||||
return early, nil
|
||||
}
|
||||
}
|
||||
if err := validateRefundProviderResponse(resp); err != nil {
|
||||
return s.finalizeRefundFailed(ctx, plan, err)
|
||||
}
|
||||
switch strings.TrimSpace(resp.Status) {
|
||||
case payment.ProviderStatusSuccess, payment.ProviderStatusRefunded:
|
||||
return s.finalizePendingRefundSuccess(ctx, plan)
|
||||
@@ -468,7 +480,7 @@ func (s *PaymentService) QueryAndFinalizeRefund(ctx context.Context, oid int64)
|
||||
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)))
|
||||
return s.finalizeRefundFailed(ctx, plan, fmt.Errorf("payment refund returned unknown status: %s", strings.TrimSpace(resp.Status)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,6 +517,9 @@ func (s *PaymentService) finalizePendingRefundSuccess(ctx context.Context, p *Re
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit refund finalization: %w", err)
|
||||
}
|
||||
if !p.deductionHeld && p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 {
|
||||
s.invalidateRefundBalanceCache(ctx, p.Order.UserID)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -533,6 +548,10 @@ func (s *PaymentService) refundFinalizePlan(o *dbent.PaymentOrder) *RefundPlan {
|
||||
}
|
||||
|
||||
func (s *PaymentService) applyRefundFinalDeduction(ctx context.Context, p *RefundPlan) error {
|
||||
if p.deductionHeld {
|
||||
// 进入 pending 时扣减已保留,终态成功无需再扣。
|
||||
return nil
|
||||
}
|
||||
if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 {
|
||||
deducted, err := s.deductAvailableBalance(ctx, p.Order.UserID, p.BalanceToDeduct)
|
||||
if err != nil {
|
||||
@@ -554,16 +573,54 @@ func (s *PaymentService) applyRefundFinalDeduction(ctx context.Context, p *Refun
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) finalizeRefundFailed(ctx context.Context, o *dbent.PaymentOrder, gErr error) (*RefundResult, error) {
|
||||
func (s *PaymentService) finalizeRefundFailed(ctx context.Context, p *RefundPlan, gErr error) (*RefundResult, error) {
|
||||
// 先认领 REFUND_PENDING 再归还扣减,避免并发查询重复归还。
|
||||
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
|
||||
claimed, err := s.entClient.PaymentOrder.Update().
|
||||
Where(paymentorder.IDEQ(p.OrderID), paymentorder.StatusEQ(OrderStatusRefundPending)).
|
||||
SetStatus(OrderStatusRefundFailed).
|
||||
SetFailedAt(now).
|
||||
SetFailedReason(psErrMsg(gErr)).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mark refund failed: %w", err)
|
||||
}
|
||||
if claimed == 0 {
|
||||
return nil, infraerrors.Conflict("CONFLICT", "order status changed")
|
||||
}
|
||||
warning := "gateway refund failed: " + psErrMsg(gErr)
|
||||
// 未归还成功时 RollbackRefund 会写 REFUND_ROLLBACK_FAILED,重试退款将跳过重复扣减。
|
||||
if p.deductionHeld && !s.RollbackRefund(ctx, p, gErr) {
|
||||
warning += "; refund deduction rollback failed"
|
||||
}
|
||||
s.writeAuditLog(ctx, p.OrderID, "REFUND_FAILED", "admin", map[string]any{"detail": psErrMsg(gErr)})
|
||||
return &RefundResult{Success: false, Warning: warning}, nil
|
||||
}
|
||||
|
||||
// refundPendingAuditDetail 记录在 REFUND_PENDING 审计日志中。DeductionHeld 为 true 表示
|
||||
// 进入 pending 时扣减被保留;旧记录没有该字段,扣减已在 pending 时归还(DeductionRollbackOK)。
|
||||
type refundPendingAuditDetail struct {
|
||||
RefundID string `json:"refundID"`
|
||||
DeductionRollbackOK bool `json:"deductionRollbackOK"`
|
||||
RefundID string `json:"refundID"`
|
||||
RefundAmount float64 `json:"refundAmount"`
|
||||
Reason string `json:"reason"`
|
||||
Force bool `json:"force"`
|
||||
DeductionRollbackOK bool `json:"deductionRollbackOK,omitempty"`
|
||||
DeductionHeld bool `json:"deductionHeld,omitempty"`
|
||||
DeductionType string `json:"deductionType,omitempty"`
|
||||
BalanceDeducted float64 `json:"balanceDeducted"`
|
||||
SubDaysDeducted int `json:"subDaysDeducted"`
|
||||
SubscriptionID int64 `json:"subscriptionID,omitempty"`
|
||||
}
|
||||
|
||||
func (d refundPendingAuditDetail) applyTo(p *RefundPlan) {
|
||||
p.deductionHeld = true
|
||||
p.DeductionType = d.DeductionType
|
||||
if p.DeductionType == "" {
|
||||
p.DeductionType = payment.DeductionTypeNone
|
||||
}
|
||||
p.BalanceToDeduct = d.BalanceDeducted
|
||||
p.SubDaysToDeduct = d.SubDaysDeducted
|
||||
p.SubscriptionID = d.SubscriptionID
|
||||
}
|
||||
|
||||
func (s *PaymentService) latestRefundPendingDetail(ctx context.Context, oid int64) refundPendingAuditDetail {
|
||||
@@ -644,16 +701,36 @@ func (s *PaymentService) markRefundOkTx(ctx context.Context, client *dbent.Clien
|
||||
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
|
||||
// markRefundPending 在网关未给出终态时保留已执行的扣减:若此时归还,用户可在 pending 期间
|
||||
// 消费这部分余额,终态成功时只能扣到剩余余额。网关确认失败后由 finalizeRefundFailed 归还。
|
||||
// 订单状态与审计记录同事务写入,确保终态收敛时能读到保留的扣减。
|
||||
func (s *PaymentService) markRefundPending(ctx context.Context, p *RefundPlan, resp *payment.RefundResponse) (_ *RefundResult, err error) {
|
||||
detail, err := json.Marshal(refundPendingAuditDetail{
|
||||
RefundID: refundResponseID(resp),
|
||||
RefundAmount: p.RefundAmount,
|
||||
Reason: p.Reason,
|
||||
Force: p.Force,
|
||||
DeductionHeld: true,
|
||||
DeductionType: p.DeductionType,
|
||||
BalanceDeducted: p.BalanceToDeduct,
|
||||
SubDaysDeducted: p.SubDaysToDeduct,
|
||||
SubscriptionID: p.SubscriptionID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal refund pending audit: %w", err)
|
||||
}
|
||||
|
||||
_, err := s.entClient.PaymentOrder.UpdateOneID(p.OrderID).
|
||||
tx, err := s.entClient.Tx(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin mark refund pending: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
slog.Error("[CRITICAL] gateway refund pending but order not marked", "orderID", p.OrderID, "error", err)
|
||||
}
|
||||
}()
|
||||
_, err = tx.PaymentOrder.UpdateOneID(p.OrderID).
|
||||
SetStatus(OrderStatusRefundPending).
|
||||
SetRefundAmount(p.RefundAmount).
|
||||
SetRefundReason(p.Reason).
|
||||
@@ -665,25 +742,18 @@ func (s *PaymentService) markRefundPending(ctx context.Context, p *RefundPlan, r
|
||||
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,
|
||||
if _, err = tx.PaymentAuditLog.Create().
|
||||
SetOrderID(strconv.FormatInt(p.OrderID, 10)).
|
||||
SetAction("REFUND_PENDING").
|
||||
SetDetail(string(detail)).
|
||||
SetOperator("admin").
|
||||
Save(ctx); err != nil {
|
||||
return nil, fmt.Errorf("write refund pending audit: %w", err)
|
||||
}
|
||||
s.writeAuditLog(ctx, p.OrderID, "REFUND_PENDING", "admin", detail)
|
||||
|
||||
warning := "gateway refund is pending confirmation"
|
||||
if !rollbackOK {
|
||||
warning += "; refund deduction rollback failed"
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit mark refund pending: %w", err)
|
||||
}
|
||||
return &RefundResult{Success: false, Warning: warning}, nil
|
||||
return &RefundResult{Success: false, Warning: "gateway refund is pending confirmation"}, nil
|
||||
}
|
||||
|
||||
func refundResponseID(resp *payment.RefundResponse) string {
|
||||
@@ -700,6 +770,7 @@ func (s *PaymentService) RollbackRefund(ctx context.Context, p *RefundPlan, gErr
|
||||
s.writeAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED", "admin", map[string]any{"gatewayError": psErrMsg(gErr), "rollbackError": psErrMsg(err), "balanceDeducted": p.BalanceToDeduct})
|
||||
return false
|
||||
}
|
||||
s.invalidateRefundBalanceCache(ctx, p.Order.UserID)
|
||||
}
|
||||
if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 {
|
||||
if _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, p.SubDaysToDeduct); err != nil {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type pendingRefundProviderTestDouble struct {
|
||||
refundProviderTestDouble
|
||||
queryStatus string
|
||||
refundCalls int
|
||||
}
|
||||
|
||||
func (p *pendingRefundProviderTestDouble) Refund(context.Context, payment.RefundRequest) (*payment.RefundResponse, error) {
|
||||
p.refundCalls++
|
||||
return &payment.RefundResponse{RefundID: "rf_hold", Status: payment.ProviderStatusPending}, nil
|
||||
}
|
||||
|
||||
func (p *pendingRefundProviderTestDouble) QueryRefund(context.Context, payment.RefundQueryRequest) (*payment.RefundResponse, error) {
|
||||
return &payment.RefundResponse{RefundID: "rf_hold", Status: p.queryStatus}, nil
|
||||
}
|
||||
|
||||
// refundHoldWallet 模拟用户余额:DeductAvailableBalance 只能扣到当前余额。
|
||||
type refundHoldWallet struct {
|
||||
balance float64
|
||||
deductCalls int
|
||||
restoreCalls int
|
||||
}
|
||||
|
||||
func (w *refundHoldWallet) repo(userID int64) *mockUserRepo {
|
||||
return &mockUserRepo{
|
||||
getByIDUser: &User{ID: userID, Balance: w.balance},
|
||||
deductAvailableBalanceFn: func(_ context.Context, _ int64, amount float64) (float64, error) {
|
||||
w.deductCalls++
|
||||
deducted := math.Min(amount, math.Max(w.balance, 0))
|
||||
w.balance -= deducted
|
||||
return deducted, nil
|
||||
},
|
||||
updateBalanceFn: func(_ context.Context, _ int64, amount float64) error {
|
||||
w.restoreCalls++
|
||||
w.balance += amount
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefundPendingKeepsBalanceDeductedUntilGatewaySettles(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
queryStatus string
|
||||
wantStatus string
|
||||
wantBalance float64
|
||||
wantRestore int
|
||||
}{
|
||||
{name: "gateway succeeds", queryStatus: payment.ProviderStatusSuccess, wantStatus: OrderStatusRefunded, wantBalance: 0},
|
||||
{name: "gateway fails", queryStatus: payment.ProviderStatusFailed, wantStatus: OrderStatusRefundFailed, wantBalance: 100, wantRestore: 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := newPaymentConfigServiceTestClient(t)
|
||||
order := createCompletedRefundableOrderForTest(t, ctx, client, "hold-flow")
|
||||
|
||||
wallet := &refundHoldWallet{balance: 100}
|
||||
prov := &pendingRefundProviderTestDouble{queryStatus: tc.queryStatus}
|
||||
restore := replacePaymentProviderFactoryForTest(t, prov)
|
||||
defer restore()
|
||||
svc := &PaymentService{entClient: client, loadBalancer: &captureLoadBalancer{}, userRepo: wallet.repo(order.UserID)}
|
||||
|
||||
plan, early, err := svc.PrepareRefund(ctx, order.ID, 0, "user asked", false, true)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, early)
|
||||
result, err := svc.ExecuteRefund(ctx, plan)
|
||||
require.NoError(t, err)
|
||||
require.False(t, result.Success)
|
||||
require.Contains(t, result.Warning, "pending confirmation")
|
||||
|
||||
// pending 期间余额必须保持扣减,否则用户可先消费再等网关退款成功。
|
||||
require.Zero(t, wallet.balance)
|
||||
require.Zero(t, wallet.restoreCalls)
|
||||
reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OrderStatusRefundPending, reloaded.Status)
|
||||
|
||||
// pending 订单不能再次发起退款(会重复扣减并重复调用网关)。
|
||||
_, _, err = svc.PrepareRefund(ctx, order.ID, 0, "again", true, true)
|
||||
require.Equal(t, "INVALID_STATUS", infraerrors.Reason(err))
|
||||
|
||||
result, err = svc.QueryAndFinalizeRefund(ctx, order.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.queryStatus == payment.ProviderStatusSuccess, result.Success)
|
||||
if result.Success {
|
||||
require.Equal(t, 100.0, result.BalanceDeducted)
|
||||
}
|
||||
require.Equal(t, 1, wallet.deductCalls, "held deduction must not be applied twice")
|
||||
require.Equal(t, tc.wantRestore, wallet.restoreCalls)
|
||||
require.Equal(t, tc.wantBalance, wallet.balance)
|
||||
require.Equal(t, 1, prov.refundCalls)
|
||||
|
||||
reloaded, err = client.PaymentOrder.Get(ctx, order.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.wantStatus, reloaded.Status)
|
||||
|
||||
_, err = svc.QueryAndFinalizeRefund(ctx, order.ID)
|
||||
require.Equal(t, "INVALID_STATUS", infraerrors.Reason(err))
|
||||
require.Equal(t, tc.wantRestore, wallet.restoreCalls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizeRefundFailedRestoresHeldDeductionOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := newPaymentConfigServiceTestClient(t)
|
||||
order := createPendingRefundOrderWithDetailForTest(t, ctx, client, "hold-failed-once",
|
||||
`{"refundID":"rf_test","deductionHeld":true,"deductionType":"balance","balanceDeducted":60}`)
|
||||
|
||||
wallet := &refundHoldWallet{}
|
||||
svc := &PaymentService{entClient: client, userRepo: wallet.repo(order.UserID)}
|
||||
plan := svc.refundFinalizePlan(order)
|
||||
svc.latestRefundPendingDetail(ctx, order.ID).applyTo(plan)
|
||||
|
||||
first, err := svc.finalizeRefundFailed(ctx, plan, context.DeadlineExceeded)
|
||||
require.NoError(t, err)
|
||||
require.False(t, first.Success)
|
||||
second, err := svc.finalizeRefundFailed(ctx, plan, context.DeadlineExceeded)
|
||||
require.Nil(t, second)
|
||||
require.Equal(t, "CONFLICT", infraerrors.Reason(err))
|
||||
|
||||
require.Equal(t, 1, wallet.restoreCalls)
|
||||
require.Equal(t, 60.0, wallet.balance)
|
||||
}
|
||||
|
||||
func TestQueryAndFinalizeRefundHeldDeductionWithoutBalanceDeduction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := newPaymentConfigServiceTestClient(t)
|
||||
// 管理员选择不扣余额时,终态收敛也不能按旧逻辑补扣。
|
||||
order := createPendingRefundOrderWithDetailForTest(t, ctx, client, "hold-no-deduct",
|
||||
`{"refundID":"rf_test","deductionHeld":true,"deductionType":"none","balanceDeducted":0}`)
|
||||
|
||||
wallet := &refundHoldWallet{balance: 100}
|
||||
restore := replacePaymentProviderFactoryForTest(t, &pendingRefundProviderTestDouble{queryStatus: payment.ProviderStatusSuccess})
|
||||
defer restore()
|
||||
svc := &PaymentService{entClient: client, loadBalancer: &captureLoadBalancer{}, userRepo: wallet.repo(order.UserID)}
|
||||
|
||||
result, err := svc.QueryAndFinalizeRefund(ctx, order.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, result.Success)
|
||||
require.Zero(t, wallet.deductCalls)
|
||||
require.Equal(t, 100.0, wallet.balance)
|
||||
}
|
||||
|
||||
func createCompletedRefundableOrderForTest(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(OrderStatusCompleted).
|
||||
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)
|
||||
return order
|
||||
}
|
||||
@@ -299,7 +299,7 @@ func TestValidateRefundProviderResponseAcceptsPending(t *testing.T) {
|
||||
require.Error(t, validateRefundProviderResponse(nil))
|
||||
}
|
||||
|
||||
func TestFinishRefundPendingMarksOrderPendingAndRollsBackDeduction(t *testing.T) {
|
||||
func TestFinishRefundPendingMarksOrderPendingAndHoldsDeduction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := newPaymentConfigServiceTestClient(t)
|
||||
|
||||
@@ -357,8 +357,8 @@ func TestFinishRefundPendingMarksOrderPendingAndRollsBackDeduction(t *testing.T)
|
||||
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)
|
||||
require.Zero(t, rolledBack, "deduction must stay held while the gateway refund is pending")
|
||||
require.Equal(t, 40.0, plan.BalanceToDeduct)
|
||||
|
||||
reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
|
||||
require.NoError(t, err)
|
||||
@@ -373,6 +373,10 @@ func TestFinishRefundPendingMarksOrderPendingAndRollsBackDeduction(t *testing.T)
|
||||
Count(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, pendingAudits)
|
||||
detail := svc.latestRefundPendingDetail(ctx, order.ID)
|
||||
require.True(t, detail.DeductionHeld)
|
||||
require.Equal(t, payment.DeductionTypeBalance, detail.DeductionType)
|
||||
require.Equal(t, 40.0, detail.BalanceDeducted)
|
||||
successAudits, err := client.PaymentAuditLog.Query().
|
||||
Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_SUCCESS")).
|
||||
Count(ctx)
|
||||
@@ -586,6 +590,11 @@ func TestQueryAndFinalizeRefundUnsupportedProviderReturnsClearError(t *testing.T
|
||||
|
||||
func createPendingRefundOrderForTest(t *testing.T, ctx context.Context, client *dbent.Client, suffix string) *dbent.PaymentOrder {
|
||||
t.Helper()
|
||||
return createPendingRefundOrderWithDetailForTest(t, ctx, client, suffix, `{"refundID":"rf_test","deductionRollbackOK":true}`)
|
||||
}
|
||||
|
||||
func createPendingRefundOrderWithDetailForTest(t *testing.T, ctx context.Context, client *dbent.Client, suffix, pendingDetail string) *dbent.PaymentOrder {
|
||||
t.Helper()
|
||||
|
||||
user, err := client.User.Create().
|
||||
SetEmail(suffix + "@example.com").
|
||||
@@ -631,7 +640,7 @@ func createPendingRefundOrderForTest(t *testing.T, ctx context.Context, client *
|
||||
SetOrderID(strconv.FormatInt(order.ID, 10)).
|
||||
SetAction("REFUND_PENDING").
|
||||
SetOperator("admin").
|
||||
SetDetail(`{"refundID":"rf_test","deductionRollbackOK":true}`).
|
||||
SetDetail(pendingDetail).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
return order
|
||||
|
||||
@@ -134,6 +134,8 @@ type RefundPlan struct {
|
||||
BalanceToDeduct float64
|
||||
SubDaysToDeduct int
|
||||
SubscriptionID int64
|
||||
// deductionHeld 表示扣减已在进入 REFUND_PENDING 前执行并保留,终态收敛时不再重复扣减。
|
||||
deductionHeld bool
|
||||
}
|
||||
|
||||
type RefundResult struct {
|
||||
|
||||
Reference in New Issue
Block a user