diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index 4c363d3e03..f5b2ad05f1 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -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 } diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index becefc6a1a..3ddd9fec47 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -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") diff --git a/backend/internal/service/payment_order_type_test.go b/backend/internal/service/payment_order_type_test.go new file mode 100644 index 0000000000..114e67a91f --- /dev/null +++ b/backend/internal/service/payment_order_type_test.go @@ -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) +} diff --git a/backend/internal/service/payment_refund.go b/backend/internal/service/payment_refund.go index e49c54ec59..c26ae0e5b6 100644 --- a/backend/internal/service/payment_refund.go +++ b/backend/internal/service/payment_refund.go @@ -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 { diff --git a/backend/internal/service/payment_refund_pending_hold_test.go b/backend/internal/service/payment_refund_pending_hold_test.go new file mode 100644 index 0000000000..7b010da739 --- /dev/null +++ b/backend/internal/service/payment_refund_pending_hold_test.go @@ -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 +} diff --git a/backend/internal/service/payment_refund_test.go b/backend/internal/service/payment_refund_test.go index 8a534e473e..55ed34e837 100644 --- a/backend/internal/service/payment_refund_test.go +++ b/backend/internal/service/payment_refund_test.go @@ -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 diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index dae1a7d938..41276d6251 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -134,6 +134,8 @@ type RefundPlan struct { BalanceToDeduct float64 SubDaysToDeduct int SubscriptionID int64 + // deductionHeld 表示扣减已在进入 REFUND_PENDING 前执行并保留,终态收敛时不再重复扣减。 + deductionHeld bool } type RefundResult struct {