fix(payment): finalize failed pending refunds atomically

- Claim REFUND_PENDING -> REFUND_FAILED, return the held deduction and
  write the audit in one transaction. If the deduction cannot be
  returned, the order is still marked failed together with a
  REFUND_ROLLBACK_FAILED audit so a retried refund skips the deduction.
- Return an error when the REFUND_PENDING audit cannot be loaded or
  parsed instead of falling back to the legacy finalization path.
This commit is contained in:
shaw
2026-10-15 21:38:57 +08:00
parent f996b0bfaa
commit 44e35fbf84
3 changed files with 231 additions and 21 deletions
+115 -19
View File
@@ -446,7 +446,10 @@ func (s *PaymentService) QueryAndFinalizeRefund(ctx context.Context, oid int64)
return nil, infraerrors.BadRequest("REFUND_QUERY_UNSUPPORTED", "this payment provider does not support refund status query; please verify manually")
}
pendingDetail := s.latestRefundPendingDetail(ctx, oid)
pendingDetail, err := s.latestRefundPendingDetail(ctx, oid)
if err != nil {
return nil, err
}
finishProviderCall := servertiming.ObserveDependency(ctx, "payment")
resp, err := queryProvider.QueryRefund(ctx, payment.RefundQueryRequest{
TradeNo: o.PaymentTradeNo,
@@ -573,28 +576,114 @@ func (s *PaymentService) applyRefundFinalDeduction(ctx context.Context, p *Refun
return nil
}
// finalizeRefundFailed 将 REFUND_PENDING 收敛为 REFUND_FAILED,并归还进入 pending 时保留的扣减。
// 认领状态、归还扣减与审计在同一事务内完成:两者分开提交时,进程在中间退出会留下
// 已失败但扣减未归还、且没有 REFUND_ROLLBACK_FAILED 标记的订单,重试退款会再扣一次。
func (s *PaymentService) finalizeRefundFailed(ctx context.Context, p *RefundPlan, gErr error) (*RefundResult, error) {
// 先认领 REFUND_PENDING 再归还扣减,避免并发查询重复归还。
now := time.Now()
claimed, err := s.entClient.PaymentOrder.Update().
warning := "gateway refund failed: " + psErrMsg(gErr)
returnErr, err := s.finalizeRefundFailedTx(ctx, p, gErr, nil)
if err != nil {
return nil, err
}
if returnErr == nil {
if p.deductionHeld && p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 {
s.invalidateRefundBalanceCache(ctx, p.Order.UserID)
}
return &RefundResult{Success: false, Warning: warning}, nil
}
// 归还失败(整个事务已回滚,订单仍为 REFUND_PENDING):仍收敛为 REFUND_FAILED,
// 并同事务写入 REFUND_ROLLBACK_FAILED,使重试退款跳过扣减,避免二次扣减。
slog.Error("[CRITICAL] refund failed but held deduction could not be returned", "orderID", p.OrderID, "error", returnErr)
if _, err := s.finalizeRefundFailedTx(ctx, p, gErr, returnErr); err != nil {
return nil, err
}
return &RefundResult{Success: false, Warning: warning + "; refund deduction rollback failed"}, nil
}
// finalizeRefundFailedTx 在单个事务内认领 REFUND_PENDING → REFUND_FAILED。rollbackErr 为 nil 时同事务
// 归还保留的扣减,归还失败则回滚整个事务并通过 returnErr 返回;rollbackErr 非 nil 时不再归还,
// 改为同事务记录 REFUND_ROLLBACK_FAILED。
func (s *PaymentService) finalizeRefundFailedTx(ctx context.Context, p *RefundPlan, gErr, rollbackErr error) (returnErr error, err error) {
tx, err := s.entClient.Tx(ctx)
if err != nil {
return nil, fmt.Errorf("begin refund failure finalization: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
txCtx := dbent.NewTxContext(ctx, tx)
claimed, err := tx.PaymentOrder.Update().
Where(paymentorder.IDEQ(p.OrderID), paymentorder.StatusEQ(OrderStatusRefundPending)).
SetStatus(OrderStatusRefundFailed).
SetFailedAt(now).
SetFailedAt(time.Now()).
SetFailedReason(psErrMsg(gErr)).
Save(ctx)
Save(txCtx)
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"
if p.deductionHeld && rollbackErr == nil {
if err := s.returnHeldRefundDeduction(txCtx, p); err != nil {
return err, nil
}
}
s.writeAuditLog(ctx, p.OrderID, "REFUND_FAILED", "admin", map[string]any{"detail": psErrMsg(gErr)})
return &RefundResult{Success: false, Warning: warning}, nil
if rollbackErr != nil {
if err := createPaymentAuditLogTx(txCtx, tx.Client(), p.OrderID, "REFUND_ROLLBACK_FAILED", map[string]any{
"gatewayError": psErrMsg(gErr),
"rollbackError": psErrMsg(rollbackErr),
"balanceDeducted": p.BalanceToDeduct,
"subDaysDeducted": p.SubDaysToDeduct,
}); err != nil {
return nil, err
}
}
if err := createPaymentAuditLogTx(txCtx, tx.Client(), p.OrderID, "REFUND_FAILED", map[string]any{"detail": psErrMsg(gErr)}); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit refund failure finalization: %w", err)
}
committed = true
return nil, nil
}
// returnHeldRefundDeduction 归还进入 REFUND_PENDING 时保留的扣减;ctx 携带事务时随事务提交或回滚。
func (s *PaymentService) returnHeldRefundDeduction(ctx context.Context, p *RefundPlan) error {
if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 {
if err := s.userRepo.UpdateBalance(ctx, p.Order.UserID, p.BalanceToDeduct); err != nil {
return fmt.Errorf("return held balance 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 {
return fmt.Errorf("return held subscription deduction: %w", err)
}
}
return nil
}
func createPaymentAuditLogTx(ctx context.Context, client *dbent.Client, oid int64, action string, detail map[string]any) error {
raw, err := json.Marshal(detail)
if err != nil {
return fmt.Errorf("marshal %s audit: %w", action, err)
}
if _, err := client.PaymentAuditLog.Create().
SetOrderID(strconv.FormatInt(oid, 10)).
SetAction(action).
SetDetail(string(raw)).
SetOperator("admin").
Save(ctx); err != nil {
return fmt.Errorf("write %s audit: %w", action, err)
}
return nil
}
// refundPendingAuditDetail 记录在 REFUND_PENDING 审计日志中。DeductionHeld 为 true 表示
@@ -623,18 +712,25 @@ func (d refundPendingAuditDetail) applyTo(p *RefundPlan) {
p.SubscriptionID = d.SubscriptionID
}
func (s *PaymentService) latestRefundPendingDetail(ctx context.Context, oid int64) refundPendingAuditDetail {
// latestRefundPendingDetail 读取最近一次 REFUND_PENDING 审计。没有记录视为旧订单(扣减已在
// pending 时归还);读取或解析失败必须返回错误:误判为旧记录会让保留的扣减被重复扣减或不再归还。
func (s *PaymentService) latestRefundPendingDetail(ctx context.Context, oid int64) (refundPendingAuditDetail, error) {
detail := refundPendingAuditDetail{DeductionRollbackOK: true}
logEntry, err := s.entClient.PaymentAuditLog.Query().
Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(oid, 10)), paymentauditlog.ActionEQ("REFUND_PENDING")).
Order(paymentauditlog.ByCreatedAt(sql.OrderDesc())).
Order(paymentauditlog.ByCreatedAt(sql.OrderDesc()), paymentauditlog.ByID(sql.OrderDesc())).
First(ctx)
if err != nil || logEntry == nil {
return refundPendingAuditDetail{DeductionRollbackOK: true}
if err != nil {
if dbent.IsNotFound(err) {
return detail, nil
}
return refundPendingAuditDetail{}, fmt.Errorf("load refund pending audit: %w", err)
}
if err := json.Unmarshal([]byte(logEntry.Detail), &detail); err != nil {
return refundPendingAuditDetail{}, fmt.Errorf("parse refund pending audit: %w", err)
}
detail := refundPendingAuditDetail{DeductionRollbackOK: true}
_ = json.Unmarshal([]byte(logEntry.Detail), &detail)
detail.RefundID = strings.TrimSpace(detail.RefundID)
return detail
return detail, nil
}
// getRefundProvider creates a provider using the order's original instance config.
@@ -4,12 +4,14 @@ package service
import (
"context"
"errors"
"math"
"strconv"
"testing"
"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"
@@ -126,7 +128,9 @@ func TestFinalizeRefundFailedRestoresHeldDeductionOnce(t *testing.T) {
wallet := &refundHoldWallet{}
svc := &PaymentService{entClient: client, userRepo: wallet.repo(order.UserID)}
plan := svc.refundFinalizePlan(order)
svc.latestRefundPendingDetail(ctx, order.ID).applyTo(plan)
detail, err := svc.latestRefundPendingDetail(ctx, order.ID)
require.NoError(t, err)
detail.applyTo(plan)
first, err := svc.finalizeRefundFailed(ctx, plan, context.DeadlineExceeded)
require.NoError(t, err)
@@ -158,6 +162,115 @@ func TestQueryAndFinalizeRefundHeldDeductionWithoutBalanceDeduction(t *testing.T
require.Equal(t, 100.0, wallet.balance)
}
type successRefundProviderTestDouble struct {
refundProviderTestDouble
refundCalls int
}
func (p *successRefundProviderTestDouble) Refund(context.Context, payment.RefundRequest) (*payment.RefundResponse, error) {
p.refundCalls++
return &payment.RefundResponse{RefundID: "rf_retry", Status: payment.ProviderStatusSuccess}, nil
}
func countRefundAuditsForTest(t *testing.T, ctx context.Context, client *dbent.Client, orderID int64, action string) int {
t.Helper()
n, err := client.PaymentAuditLog.Query().
Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(orderID, 10)), paymentauditlog.ActionEQ(action)).
Count(ctx)
require.NoError(t, err)
return n
}
func TestFinalizeRefundFailedRecordsRollbackFailureWhenHeldDeductionCannotBeReturned(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
order := createPendingRefundOrderWithDetailForTest(t, ctx, client, "hold-return-fails",
`{"refundID":"rf_test","deductionHeld":true,"deductionType":"balance","balanceDeducted":60}`)
wallet := &refundHoldWallet{balance: 40}
repo := wallet.repo(order.UserID)
repo.updateBalanceFn = func(context.Context, int64, float64) error {
wallet.restoreCalls++
return errors.New("db unavailable")
}
restore := replacePaymentProviderFactoryForTest(t, &pendingRefundProviderTestDouble{queryStatus: payment.ProviderStatusFailed})
defer restore()
svc := &PaymentService{entClient: client, loadBalancer: &captureLoadBalancer{}, userRepo: repo}
result, err := svc.QueryAndFinalizeRefund(ctx, order.ID)
require.NoError(t, err)
require.False(t, result.Success)
require.Contains(t, result.Warning, "refund deduction rollback failed")
require.Equal(t, 1, wallet.restoreCalls)
require.Equal(t, 40.0, wallet.balance)
reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
require.NoError(t, err)
require.Equal(t, OrderStatusRefundFailed, reloaded.Status)
require.Equal(t, 1, countRefundAuditsForTest(t, ctx, client, order.ID, "REFUND_ROLLBACK_FAILED"))
require.Equal(t, 1, countRefundAuditsForTest(t, ctx, client, order.ID, "REFUND_FAILED"))
// 扣减仍被保留:重试退款必须跳过扣减,否则同一笔退款会扣两次。
retryProvider := &successRefundProviderTestDouble{}
restoreRetry := replacePaymentProviderFactoryForTest(t, retryProvider)
defer restoreRetry()
plan, early, err := svc.PrepareRefund(ctx, order.ID, 0, "retry", true, true)
require.NoError(t, err)
require.Nil(t, early)
retried, err := svc.ExecuteRefund(ctx, plan)
require.NoError(t, err)
require.True(t, retried.Success)
require.Zero(t, wallet.deductCalls)
require.Equal(t, 40.0, wallet.balance)
require.Equal(t, 1, retryProvider.refundCalls)
}
func TestFinalizeRefundFailedReturnsHeldDeductionAtomically(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
order := createPendingRefundOrderWithDetailForTest(t, ctx, client, "hold-return-atomic",
`{"refundID":"rf_test","deductionHeld":true,"deductionType":"balance","balanceDeducted":60}`)
wallet := &refundHoldWallet{}
restore := replacePaymentProviderFactoryForTest(t, &pendingRefundProviderTestDouble{queryStatus: payment.ProviderStatusFailed})
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.False(t, result.Success)
require.NotContains(t, result.Warning, "rollback failed")
require.Equal(t, 1, wallet.restoreCalls)
require.Equal(t, 60.0, wallet.balance)
require.Equal(t, 1, countRefundAuditsForTest(t, ctx, client, order.ID, "REFUND_FAILED"))
require.Zero(t, countRefundAuditsForTest(t, ctx, client, order.ID, "REFUND_ROLLBACK_FAILED"))
}
func TestQueryAndFinalizeRefundRejectsUnreadablePendingAudit(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
// 审计无法解析时不能退化为旧逻辑:成功会二次扣减,失败会吞掉保留的扣减。
order := createPendingRefundOrderWithDetailForTest(t, ctx, client, "hold-bad-audit", `not-json`)
for _, queryStatus := range []string{payment.ProviderStatusSuccess, payment.ProviderStatusFailed} {
wallet := &refundHoldWallet{balance: 100}
restore := replacePaymentProviderFactoryForTest(t, &pendingRefundProviderTestDouble{queryStatus: queryStatus})
svc := &PaymentService{entClient: client, loadBalancer: &captureLoadBalancer{}, userRepo: wallet.repo(order.UserID)}
result, err := svc.QueryAndFinalizeRefund(ctx, order.ID)
restore()
require.Error(t, err, queryStatus)
require.Nil(t, result)
require.Zero(t, wallet.deductCalls)
require.Zero(t, wallet.restoreCalls)
require.Equal(t, 100.0, wallet.balance)
}
reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
require.NoError(t, err)
require.Equal(t, OrderStatusRefundPending, reloaded.Status)
}
func createCompletedRefundableOrderForTest(t *testing.T, ctx context.Context, client *dbent.Client, suffix string) *dbent.PaymentOrder {
t.Helper()
user, err := client.User.Create().
@@ -373,7 +373,8 @@ func TestFinishRefundPendingMarksOrderPendingAndHoldsDeduction(t *testing.T) {
Count(ctx)
require.NoError(t, err)
require.Equal(t, 1, pendingAudits)
detail := svc.latestRefundPendingDetail(ctx, order.ID)
detail, err := svc.latestRefundPendingDetail(ctx, order.ID)
require.NoError(t, err)
require.True(t, detail.DeductionHeld)
require.Equal(t, payment.DeductionTypeBalance, detail.DeductionType)
require.Equal(t, 40.0, detail.BalanceDeducted)