Files
sub2api/backend/internal/service/payment_refund.go
T
shaw 44e35fbf84 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.
2026-10-15 21:38:57 +08:00

888 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math"
"strconv"
"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"
"github.com/Wei-Shaw/sub2api/internal/pkg/servertiming"
)
// --- 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.
func (s *PaymentService) getOrderProviderInstance(ctx context.Context, o *dbent.PaymentOrder) (*dbent.PaymentProviderInstance, error) {
if s == nil || s.entClient == nil || o == nil {
return nil, nil
}
if snapshot := psOrderProviderSnapshot(o); snapshot != nil {
return s.resolveSnapshotOrderProviderInstance(ctx, o, snapshot)
}
instIDStr := strings.TrimSpace(psStringValue(o.ProviderInstanceID))
if instIDStr == "" {
return s.resolveUniqueLegacyOrderProviderInstance(ctx, o)
}
instID, err := strconv.ParseInt(instIDStr, 10, 64)
if err != nil {
return nil, nil
}
return s.entClient.PaymentProviderInstance.Get(ctx, instID)
}
// getRefundOrderProviderInstance resolves the provider instance for refund paths.
// Refunds must be pinned to an explicit historical binding, so legacy
// "best-effort" provider guessing is intentionally not allowed here.
func (s *PaymentService) getRefundOrderProviderInstance(ctx context.Context, o *dbent.PaymentOrder) (*dbent.PaymentProviderInstance, error) {
if s == nil || s.entClient == nil || o == nil {
return nil, nil
}
if snapshot := psOrderProviderSnapshot(o); snapshot != nil {
return s.resolveSnapshotOrderProviderInstance(ctx, o, snapshot)
}
instIDStr := strings.TrimSpace(psStringValue(o.ProviderInstanceID))
if instIDStr == "" {
return nil, nil
}
instID, err := strconv.ParseInt(instIDStr, 10, 64)
if err != nil {
return nil, fmt.Errorf("order %d refund provider instance id is invalid: %s", o.ID, instIDStr)
}
inst, err := s.entClient.PaymentProviderInstance.Get(ctx, instID)
if err != nil {
if dbent.IsNotFound(err) {
return nil, fmt.Errorf("order %d refund provider instance %s is missing", o.ID, instIDStr)
}
return nil, err
}
return inst, nil
}
func (s *PaymentService) resolveUniqueLegacyOrderProviderInstance(ctx context.Context, o *dbent.PaymentOrder) (*dbent.PaymentProviderInstance, error) {
paymentType := payment.GetBasePaymentType(strings.TrimSpace(o.PaymentType))
providerKey := strings.TrimSpace(psStringValue(o.ProviderKey))
if providerKey != "" {
instances, err := s.entClient.PaymentProviderInstance.Query().
Where(paymentproviderinstance.ProviderKeyEQ(providerKey)).
All(ctx)
if err != nil {
return nil, err
}
matched := psFilterLegacyOrderProviderInstances(paymentType, instances)
if len(matched) == 1 {
return matched[0], nil
}
return nil, nil
}
if paymentType == "" {
return nil, nil
}
instances, err := s.entClient.PaymentProviderInstance.Query().
All(ctx)
if err != nil {
return nil, err
}
matched := psFilterLegacyOrderProviderInstances(paymentType, instances)
if len(matched) == 1 {
return matched[0], nil
}
return nil, nil
}
func psFilterLegacyOrderProviderInstances(orderPaymentType string, instances []*dbent.PaymentProviderInstance) []*dbent.PaymentProviderInstance {
if len(instances) == 0 {
return nil
}
if strings.TrimSpace(orderPaymentType) == "" {
return instances
}
var matched []*dbent.PaymentProviderInstance
for _, inst := range instances {
if psLegacyOrderMatchesInstance(orderPaymentType, inst) {
matched = append(matched, inst)
}
}
return matched
}
func psLegacyOrderMatchesInstance(orderPaymentType string, inst *dbent.PaymentProviderInstance) bool {
if inst == nil {
return false
}
baseType := payment.GetBasePaymentType(strings.TrimSpace(orderPaymentType))
instanceProviderKey := strings.TrimSpace(inst.ProviderKey)
if baseType == "" {
return false
}
if baseType == payment.TypeStripe {
return instanceProviderKey == payment.TypeStripe
}
if instanceProviderKey == payment.TypeStripe {
return false
}
if instanceProviderKey == baseType {
return true
}
return payment.InstanceSupportsType(inst.SupportedTypes, baseType)
}
func (s *PaymentService) RequestRefund(ctx context.Context, oid, uid int64, reason string) error {
o, err := s.validateRefundRequest(ctx, oid, uid)
if err != nil {
return err
}
u, err := s.userRepo.GetByID(ctx, o.UserID)
if err != nil {
return fmt.Errorf("get user: %w", err)
}
if u.Balance < o.Amount {
return infraerrors.BadRequest("BALANCE_NOT_ENOUGH", "refund amount exceeds balance")
}
nr := strings.TrimSpace(reason)
now := time.Now()
by := fmt.Sprintf("%d", uid)
c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(oid), paymentorder.UserIDEQ(uid), paymentorder.StatusEQ(OrderStatusCompleted), paymentorder.OrderTypeEQ(payment.OrderTypeBalance)).SetStatus(OrderStatusRefundRequested).SetRefundRequestedAt(now).SetRefundRequestReason(nr).SetRefundRequestedBy(by).SetRefundAmount(o.Amount).Save(ctx)
if err != nil {
return fmt.Errorf("update: %w", err)
}
if c == 0 {
return infraerrors.Conflict("CONFLICT", "order status changed")
}
s.writeAuditLog(ctx, oid, "REFUND_REQUESTED", fmt.Sprintf("user:%d", uid), map[string]any{"amount": o.Amount, "reason": nr})
return nil
}
func (s *PaymentService) validateRefundRequest(ctx context.Context, oid, uid int64) (*dbent.PaymentOrder, error) {
o, err := s.entClient.PaymentOrder.Get(ctx, oid)
if err != nil {
return nil, infraerrors.NotFound("NOT_FOUND", "order not found")
}
if o.UserID != uid {
return nil, infraerrors.Forbidden("FORBIDDEN", "no permission")
}
if o.OrderType != payment.OrderTypeBalance {
return nil, infraerrors.BadRequest("INVALID_ORDER_TYPE", "only balance orders can request refund")
}
if o.Status != OrderStatusCompleted {
return nil, infraerrors.BadRequest("INVALID_STATUS", "only completed orders can request refund")
}
// Check provider instance allows user refund
inst, err := s.getRefundOrderProviderInstance(ctx, o)
if err != nil || inst == nil {
return nil, infraerrors.Forbidden("USER_REFUND_DISABLED", "refund is not available for this order")
}
if !inst.AllowUserRefund {
return nil, infraerrors.Forbidden("USER_REFUND_DISABLED", "user refund is not enabled for this provider")
}
return o, nil
}
func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float64, reason string, force, deduct bool) (*RefundPlan, *RefundResult, error) {
o, err := s.entClient.PaymentOrder.Get(ctx, oid)
if err != nil {
return nil, nil, infraerrors.NotFound("NOT_FOUND", "order not found")
}
// 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")
}
// Check provider instance allows admin refund
inst, instErr := s.getRefundOrderProviderInstance(ctx, o)
if instErr != nil {
slog.Warn("refund: provider instance lookup failed", "orderID", oid, "error", instErr)
return nil, nil, infraerrors.InternalServer("PROVIDER_LOOKUP_FAILED", "failed to look up payment provider for this order")
}
if inst == nil {
// Legacy order without provider_instance_id — block refund
return nil, nil, infraerrors.Forbidden("REFUND_DISABLED", "refund is not available for this order")
}
if !inst.RefundEnabled {
return nil, nil, infraerrors.Forbidden("REFUND_DISABLED", "refund is not enabled for this provider")
}
if math.IsNaN(amt) || math.IsInf(amt, 0) {
return nil, nil, infraerrors.BadRequest("INVALID_AMOUNT", "invalid refund amount")
}
if amt <= 0 {
amt = o.Amount
}
orderCurrency := PaymentOrderCurrency(o)
if amt-o.Amount > paymentAmountToleranceForCurrency(orderCurrency) {
return nil, nil, infraerrors.BadRequest("REFUND_AMOUNT_EXCEEDED", "refund amount exceeds recharge")
}
ga := calculateGatewayRefundAmount(o.Amount, o.PayAmount, amt, orderCurrency)
rr := strings.TrimSpace(reason)
if rr == "" && o.RefundRequestReason != nil {
rr = *o.RefundRequestReason
}
if rr == "" {
rr = fmt.Sprintf("refund order:%d", o.ID)
}
p := &RefundPlan{OrderID: oid, Order: o, RefundAmount: amt, GatewayAmount: ga, Reason: rr, Force: force, DeductBalance: deduct, DeductionType: payment.DeductionTypeNone}
if deduct {
if er := s.prepDeduct(ctx, o, p, force); er != nil {
return nil, er, nil
}
}
return p, nil, nil
}
func (s *PaymentService) prepDeduct(ctx context.Context, o *dbent.PaymentOrder, p *RefundPlan, force bool) *RefundResult {
if o.OrderType == payment.OrderTypeSubscription {
p.DeductionType = payment.DeductionTypeSubscription
if o.SubscriptionGroupID != nil && o.SubscriptionDays != nil {
p.SubDaysToDeduct = *o.SubscriptionDays
sub, err := s.subscriptionSvc.GetActiveSubscription(ctx, o.UserID, *o.SubscriptionGroupID)
if err == nil && sub != nil {
p.SubscriptionID = sub.ID
} else if !force {
return &RefundResult{Success: false, Warning: "cannot find active subscription for deduction, use force", RequireForce: true}
}
}
return nil
}
u, err := s.userRepo.GetByID(ctx, o.UserID)
if err != nil {
if !force {
return &RefundResult{Success: false, Warning: "cannot fetch user balance, use force", RequireForce: true}
}
return nil
}
p.DeductionType = payment.DeductionTypeBalance
if u.Balance < p.RefundAmount && !force {
return &RefundResult{Success: false, Warning: "user balance is insufficient for deduction, use force", RequireForce: true}
}
p.BalanceToDeduct = math.Max(0, math.Min(p.RefundAmount, u.Balance))
return nil
}
type availableBalanceDeductor interface {
DeductAvailableBalance(ctx context.Context, id int64, amount float64) (float64, error)
}
func (s *PaymentService) deductAvailableBalance(ctx context.Context, userID int64, amount float64) (float64, error) {
repo, ok := s.userRepo.(availableBalanceDeductor)
if !ok {
return 0, errors.New("user repository does not support available balance deduction")
}
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, OrderStatusRefundFailed)).SetStatus(OrderStatusRefunding).Save(ctx)
if err != nil {
return nil, fmt.Errorf("lock: %w", err)
}
if c == 0 {
return nil, infraerrors.Conflict("CONFLICT", "order status changed")
}
if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 {
// Skip balance deduction on retry if previous attempt already deducted
// but failed to roll back (REFUND_ROLLBACK_FAILED in audit log).
if !s.hasAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED") {
deducted, err := s.deductAvailableBalance(ctx, p.Order.UserID, p.BalanceToDeduct)
if err != nil {
s.restoreStatus(ctx, p)
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
}
}
if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 {
if !s.hasAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED") {
_, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, -p.SubDaysToDeduct)
if err != nil {
if errors.Is(err, ErrAdjustWouldExpire) {
// Deduction would expire the subscription — revoke it entirely
slog.Info("subscription deduction would expire, revoking", "orderID", p.OrderID, "subID", p.SubscriptionID, "days", p.SubDaysToDeduct)
if revokeErr := s.subscriptionSvc.RevokeSubscription(ctx, p.SubscriptionID); revokeErr != nil {
s.restoreStatus(ctx, p)
return nil, fmt.Errorf("revoke subscription: %w", revokeErr)
}
} else {
// Other errors (DB failure, not found) — abort refund
s.restoreStatus(ctx, p)
return nil, fmt.Errorf("deduct subscription days: %w", err)
}
}
} else {
slog.Warn("skipping subscription deduction on retry (previous rollback failed)", "orderID", p.OrderID)
p.SubDaysToDeduct = 0
}
}
resp, err := s.gwRefund(ctx, p)
if err != nil {
return s.handleGwFail(ctx, p, err)
}
return s.finishRefund(ctx, p, resp)
}
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 &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 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 nil, err
}
finishProviderCall := servertiming.ObserveDependency(ctx, "payment")
resp, err := prov.Refund(ctx, payment.RefundRequest{
TradeNo: p.Order.PaymentTradeNo,
OrderID: p.Order.OutTradeNo,
Amount: formatGatewayRefundAmount(p.GatewayAmount, p.Order),
Reason: p.Reason,
})
finishProviderCall()
if err != nil {
if resp != nil && strings.TrimSpace(resp.Status) == payment.ProviderStatusPending {
return resp, nil
}
return nil, err
}
if err := validateRefundProviderResponse(resp); err != nil {
return nil, err
}
return resp, nil
}
func formatGatewayRefundAmount(amount float64, order *dbent.PaymentOrder) string {
return payment.FormatAmountForCurrency(amount, PaymentOrderCurrency(order))
}
func validateRefundProviderResponse(resp *payment.RefundResponse) error {
if resp == nil {
return fmt.Errorf("payment refund response missing")
}
status := strings.TrimSpace(resp.Status)
switch status {
case payment.ProviderStatusSuccess, payment.ProviderStatusRefunded, payment.ProviderStatusPending:
return nil
case payment.ProviderStatusFailed:
return fmt.Errorf("payment refund failed: status %s", status)
default:
return fmt.Errorf("payment refund returned unknown status: %s", status)
}
}
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, 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,
OrderID: o.OutTradeNo,
RefundID: pendingDetail.RefundID,
Amount: formatGatewayRefundAmount(o.RefundAmount, o),
})
finishProviderCall()
if err != nil {
return nil, fmt.Errorf("query refund: %w", err)
}
plan := s.refundFinalizePlan(o)
switch {
case pendingDetail.DeductionHeld:
pendingDetail.applyTo(plan)
case !pendingDetail.DeductionRollbackOK:
plan.BalanceToDeduct = 0
plan.SubDaysToDeduct = 0
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)
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, plan, fmt.Errorf("payment refund returned unknown status: %s", strings.TrimSpace(resp.Status)))
}
}
func (s *PaymentService) finalizePendingRefundSuccess(ctx context.Context, p *RefundPlan) (_ *RefundResult, err error) {
tx, err := s.entClient.Tx(ctx)
if err != nil {
return nil, fmt.Errorf("begin refund finalization: %w", err)
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
txCtx := dbent.NewTxContext(ctx, tx)
claimed, err := tx.PaymentOrder.Update().
Where(paymentorder.IDEQ(p.OrderID), paymentorder.StatusEQ(OrderStatusRefundPending)).
SetStatus(OrderStatusRefunding).
Save(txCtx)
if err != nil {
return nil, fmt.Errorf("claim pending refund: %w", err)
}
if claimed == 0 {
return nil, infraerrors.Conflict("CONFLICT", "order status changed")
}
if err := s.applyRefundFinalDeduction(txCtx, p); err != nil {
return nil, err
}
result, err := s.markRefundOkTx(txCtx, tx.Client(), p)
if err != nil {
return nil, err
}
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
}
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 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 {
return fmt.Errorf("deduction: %w", err)
}
p.BalanceToDeduct = deducted
}
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
}
// finalizeRefundFailed 将 REFUND_PENDING 收敛为 REFUND_FAILED,并归还进入 pending 时保留的扣减。
// 认领状态、归还扣减与审计在同一事务内完成:两者分开提交时,进程在中间退出会留下
// 已失败但扣减未归还、且没有 REFUND_ROLLBACK_FAILED 标记的订单,重试退款会再扣一次。
func (s *PaymentService) finalizeRefundFailed(ctx context.Context, p *RefundPlan, gErr error) (*RefundResult, error) {
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(time.Now()).
SetFailedReason(psErrMsg(gErr)).
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")
}
if p.deductionHeld && rollbackErr == nil {
if err := s.returnHeldRefundDeduction(txCtx, p); err != nil {
return err, 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 表示
// 进入 pending 时扣减被保留;旧记录没有该字段,扣减已在 pending 时归还(DeductionRollbackOK)。
type refundPendingAuditDetail struct {
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
}
// 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()), paymentauditlog.ByID(sql.OrderDesc())).
First(ctx)
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.RefundID = strings.TrimSpace(detail.RefundID)
return detail, nil
}
// 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) {
inst, err := s.getRefundOrderProviderInstance(ctx, o)
if err != nil {
return nil, err
}
if inst == nil {
return nil, fmt.Errorf("refund provider instance is unavailable for order %d", o.ID)
}
return s.createProviderFromInstance(ctx, inst)
}
func (s *PaymentService) handleGwFail(ctx context.Context, p *RefundPlan, gErr error) (*RefundResult, error) {
if s.RollbackRefund(ctx, p, gErr) {
s.restoreStatus(ctx, p)
s.writeAuditLog(ctx, p.OrderID, "REFUND_GATEWAY_FAILED", "admin", map[string]any{"detail": psErrMsg(gErr)})
return &RefundResult{Success: false, Warning: "gateway failed: " + psErrMsg(gErr) + ", rolled back"}, nil
}
now := time.Now()
_, _ = s.entClient.PaymentOrder.UpdateOneID(p.OrderID).SetStatus(OrderStatusRefundFailed).SetFailedAt(now).SetFailedReason(psErrMsg(gErr)).Save(ctx)
s.writeAuditLog(ctx, p.OrderID, "REFUND_FAILED", "admin", map[string]any{"detail": psErrMsg(gErr)})
return nil, infraerrors.InternalServer("REFUND_FAILED", psErrMsg(gErr))
}
func (s *PaymentService) markRefundOk(ctx context.Context, p *RefundPlan) (*RefundResult, error) {
fs := OrderStatusRefunded
if p.RefundAmount < p.Order.Amount {
fs = OrderStatusPartiallyRefunded
}
now := time.Now()
_, err := s.entClient.PaymentOrder.UpdateOneID(p.OrderID).SetStatus(fs).SetRefundAmount(p.RefundAmount).SetRefundReason(p.Reason).SetRefundAt(now).SetForceRefund(p.Force).Save(ctx)
if err != nil {
return nil, fmt.Errorf("mark refund: %w", err)
}
s.writeAuditLog(ctx, p.OrderID, "REFUND_SUCCESS", "admin", map[string]any{"refundAmount": p.RefundAmount, "reason": p.Reason, "balanceDeducted": p.BalanceToDeduct, "force": p.Force})
return &RefundResult{Success: true, BalanceDeducted: p.BalanceToDeduct, SubDaysDeducted: p.SubDaysToDeduct}, nil
}
func (s *PaymentService) markRefundOkTx(ctx context.Context, client *dbent.Client, p *RefundPlan) (*RefundResult, error) {
fs := OrderStatusRefunded
if p.RefundAmount < p.Order.Amount {
fs = OrderStatusPartiallyRefunded
}
now := time.Now()
_, err := client.PaymentOrder.UpdateOneID(p.OrderID).SetStatus(fs).SetRefundAmount(p.RefundAmount).SetRefundReason(p.Reason).SetRefundAt(now).SetForceRefund(p.Force).Save(ctx)
if err != nil {
return nil, fmt.Errorf("mark refund: %w", err)
}
detail, err := json.Marshal(map[string]any{"refundAmount": p.RefundAmount, "reason": p.Reason, "balanceDeducted": p.BalanceToDeduct, "force": p.Force})
if err != nil {
return nil, fmt.Errorf("marshal refund audit: %w", err)
}
if _, err := client.PaymentAuditLog.Create().
SetOrderID(strconv.FormatInt(p.OrderID, 10)).
SetAction("REFUND_SUCCESS").
SetDetail(string(detail)).
SetOperator("admin").
Save(ctx); err != nil {
return nil, fmt.Errorf("write refund audit: %w", err)
}
return &RefundResult{Success: true, BalanceDeducted: p.BalanceToDeduct, SubDaysDeducted: p.SubDaysToDeduct}, nil
}
// 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)
}
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).
ClearRefundAt().
SetForceRefund(p.Force).
ClearFailedAt().
ClearFailedReason().
Save(ctx)
if err != nil {
return nil, fmt.Errorf("mark refund pending: %w", err)
}
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)
}
if err = tx.Commit(); err != nil {
return nil, fmt.Errorf("commit mark refund pending: %w", err)
}
return &RefundResult{Success: false, Warning: "gateway refund is pending confirmation"}, 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 {
slog.Error("[CRITICAL] rollback failed", "orderID", p.OrderID, "amount", p.BalanceToDeduct, "error", err)
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 {
slog.Error("[CRITICAL] subscription rollback failed", "orderID", p.OrderID, "subID", p.SubscriptionID, "days", p.SubDaysToDeduct, "error", err)
s.writeAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED", "admin", map[string]any{"gatewayError": psErrMsg(gErr), "rollbackError": psErrMsg(err), "subDaysDeducted": p.SubDaysToDeduct})
return false
}
}
return true
}
func (s *PaymentService) restoreStatus(ctx context.Context, p *RefundPlan) {
rs := OrderStatusCompleted
if p.Order.Status == OrderStatusRefundRequested {
rs = OrderStatusRefundRequested
}
_, _ = s.entClient.PaymentOrder.UpdateOneID(p.OrderID).SetStatus(rs).Save(ctx)
}