Compare commits

...
Author SHA1 Message Date
shaw f37b69b779 fix(payment): treat Alipay refund query errors as pending and delay not-found failure
- The SDK returns signed business-failure responses (for example
  ACQ.SYSTEM_ERROR) with a nil error and empty fields. QueryRefund now
  checks the response code and returns an error, so the order stays
  REFUND_PENDING instead of being read as "no refund record" and failed.
- A missing refund record is treated as failed only after a 10 minute
  grace period from when the refund became pending. Alipay notes that a
  query sent too soon can miss a refund that later succeeds. The pending
  time comes from the REFUND_PENDING audit entry.
2026-10-15 22:26:53 +08:00
shaw 83337a0c65 fix(payment): query Alipay refund status and close unsupported order types
- Alipay: record the generated out_request_no as the refund ID and
  implement QueryRefund (alipay.trade.fastpay.refund.query), so refunds
  that return fund_change=N can be finalized from the admin refund query.
  Refunds recorded before this change return REFUND_QUERY_UNSUPPORTED.
- Fulfillment: mark paid orders with an unsupported order type as FAILED
  with an audit entry instead of erroring, so payment callbacks are
  acknowledged; RetryFulfillment rejects them with INVALID_ORDER_TYPE.
2026-10-15 22:03:15 +08:00
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
harukaandClaude Opus 5.5 f996b0bfaa fix(payment): load Stripe client secret from the local order snapshot
The /payment/stripe route no longer carries client_secret in its query.
The page reads it from the recovery snapshot written when the order was
created and requires the snapshot order to match order_id, consistent
with AirwallexPaymentView.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:38:52 +08:00
harukaandClaude Opus 5.5 7c6f70680d 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>
2026-10-15 21:38:48 +08:00
13 changed files with 973 additions and 88 deletions
+64 -9
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/payment"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/smartwalle/alipay/v3"
)
@@ -24,7 +25,10 @@ const (
const (
alipayFundChangeYes = "Y"
alipayErrTradeNotExist = "ACQ.TRADE_NOT_EXIST"
alipayRefundSuffix = "-refund"
alipayRefundStatusSucc = "REFUND_SUCCESS"
alipayRefundRequestSep = "-refund-"
// 支付宝提示退款发起后过早查询可能查不到、之后又处理成功;超过该时长仍无记录才判定失败。
alipayRefundQueryNotFoundGrace = 10 * time.Minute
)
var (
@@ -37,6 +41,12 @@ var (
alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
return client.TradePagePay(param)
}
alipayTradeRefund = func(ctx context.Context, client *alipay.Client, param alipay.TradeRefund) (*alipay.TradeRefundRsp, error) {
return client.TradeRefund(ctx, param)
}
alipayTradeFastPayRefundQuery = func(ctx context.Context, client *alipay.Client, param alipay.TradeFastPayRefundQuery) (*alipay.TradeFastPayRefundQueryRsp, error) {
return client.TradeFastPayRefundQuery(ctx, param)
}
)
// Alipay implements payment.Provider and payment.CancelableProvider using the smartwalle/alipay SDK.
@@ -338,32 +348,76 @@ func (a *Alipay) Refund(ctx context.Context, req payment.RefundRequest) (*paymen
return nil, err
}
result, err := client.TradeRefund(ctx, alipay.TradeRefund{
outRequestNo := fmt.Sprintf("%s%s%d", req.OrderID, alipayRefundRequestSep, time.Now().UnixNano())
result, err := alipayTradeRefund(ctx, client, alipay.TradeRefund{
OutTradeNo: req.OrderID,
RefundAmount: req.Amount,
RefundReason: req.Reason,
OutRequestNo: fmt.Sprintf("%s-refund-%d", req.OrderID, time.Now().UnixNano()),
OutRequestNo: outRequestNo,
})
if err != nil {
return nil, fmt.Errorf("alipay TradeRefund: %w", err)
}
if result == nil {
return nil, fmt.Errorf("alipay TradeRefund: empty response")
}
refundStatus := payment.ProviderStatusPending
if result.FundChange == alipayFundChangeYes {
refundStatus = payment.ProviderStatusSuccess
}
refundID := result.TradeNo
if refundID == "" {
refundID = req.OrderID + alipayRefundSuffix
}
// fund_change != Y 时需凭退款请求号查询终态,因此 RefundID 记录 out_request_no。
return &payment.RefundResponse{
RefundID: refundID,
RefundID: outRequestNo,
Status: refundStatus,
}, nil
}
// QueryRefund 通过 alipay.trade.fastpay.refund.query 查询退款终态。refund_status 为 REFUND_SUCCESS
// 表示成功;退款发起超过宽限期仍查不到记录表示支付宝未受理或退款失败;其余情况视为仍在处理中。
func (a *Alipay) QueryRefund(ctx context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) {
orderID := strings.TrimSpace(req.OrderID)
outRequestNo := strings.TrimSpace(req.RefundID)
// 旧版本把支付宝交易号记为 RefundID,无法据此查询退款请求,只能人工核实。
if orderID == "" || !strings.HasPrefix(outRequestNo, orderID+alipayRefundRequestSep) {
return nil, infraerrors.BadRequest("REFUND_QUERY_UNSUPPORTED", "this refund was issued without a queryable refund request number; please verify manually")
}
client, err := a.getClient()
if err != nil {
return nil, err
}
result, err := alipayTradeFastPayRefundQuery(ctx, client, alipay.TradeFastPayRefundQuery{
OutTradeNo: orderID,
OutRequestNo: outRequestNo,
})
if err != nil {
return nil, fmt.Errorf("alipay query refund: %w", err)
}
if result == nil {
return nil, fmt.Errorf("alipay query refund: empty response")
}
// SDK 对带签名的业务失败响应(如 ACQ.SYSTEM_ERROR)不返回 error,字段为空;
// 不检查错误码会被误判为"无退款记录"而失败收尾。
if result.IsFailure() {
return nil, fmt.Errorf("alipay query refund: %w", result.Error)
}
status := payment.ProviderStatusPending
switch {
case strings.TrimSpace(result.RefundStatus) == alipayRefundStatusSucc:
status = payment.ProviderStatusSuccess
case strings.TrimSpace(result.OutRequestNo) == "" && strings.TrimSpace(result.RefundAmount) == "" &&
alipayRefundAbsenceIsFinal(req.RequestedAt, time.Now()):
status = payment.ProviderStatusFailed
}
return &payment.RefundResponse{RefundID: outRequestNo, Status: status}, nil
}
func alipayRefundAbsenceIsFinal(requestedAt, now time.Time) bool {
return !requestedAt.IsZero() && now.Sub(requestedAt) >= alipayRefundQueryNotFoundGrace
}
// CancelPayment closes a pending trade on Alipay.
func (a *Alipay) CancelPayment(ctx context.Context, tradeNo string) error {
client, err := a.getClient()
@@ -406,5 +460,6 @@ func parseAlipayAmount(values ...string) (float64, error) {
var (
_ payment.Provider = (*Alipay)(nil)
_ payment.CancelableProvider = (*Alipay)(nil)
_ payment.RefundQueryProvider = (*Alipay)(nil)
_ payment.MerchantIdentityProvider = (*Alipay)(nil)
)
@@ -8,6 +8,7 @@ import (
"net/url"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/smartwalle/alipay/v3"
@@ -444,3 +445,129 @@ func TestParseAlipayAmount(t *testing.T) {
t.Fatal("expected error when no valid amount field exists")
}
}
func TestAlipayRefundRecordsOutRequestNoAsRefundID(t *testing.T) {
origRefund := alipayTradeRefund
t.Cleanup(func() { alipayTradeRefund = origRefund })
for _, tc := range []struct {
fundChange string
wantStatus string
}{
{fundChange: alipayFundChangeYes, wantStatus: payment.ProviderStatusSuccess},
{fundChange: "N", wantStatus: payment.ProviderStatusPending},
} {
var gotOutRequestNo string
alipayTradeRefund = func(_ context.Context, _ *alipay.Client, param alipay.TradeRefund) (*alipay.TradeRefundRsp, error) {
if param.OutTradeNo != "sub2_7" || param.RefundAmount != "12.50" {
t.Fatalf("unexpected refund param: %+v", param)
}
gotOutRequestNo = param.OutRequestNo
return &alipay.TradeRefundRsp{TradeNo: "2026101522001", FundChange: tc.fundChange}, nil
}
provider := &Alipay{client: &alipay.Client{}}
resp, err := provider.Refund(context.Background(), payment.RefundRequest{OrderID: "sub2_7", Amount: "12.50", Reason: "r"})
if err != nil {
t.Fatalf("Refund() error = %v", err)
}
if resp.Status != tc.wantStatus {
t.Fatalf("fund_change=%s status = %q, want %q", tc.fundChange, resp.Status, tc.wantStatus)
}
if !strings.HasPrefix(gotOutRequestNo, "sub2_7-refund-") || resp.RefundID != gotOutRequestNo {
t.Fatalf("RefundID = %q, out_request_no = %q", resp.RefundID, gotOutRequestNo)
}
}
}
func TestAlipayQueryRefundMapsStatus(t *testing.T) {
origQuery := alipayTradeFastPayRefundQuery
t.Cleanup(func() { alipayTradeFastPayRefundQuery = origQuery })
const outRequestNo = "sub2_7-refund-1760500000000000000"
ok := alipay.Error{Code: alipay.CodeSuccess, Msg: "Success"}
settled := time.Now().Add(-alipayRefundQueryNotFoundGrace - time.Minute)
for _, tc := range []struct {
name string
rsp *alipay.TradeFastPayRefundQueryRsp
requestedAt time.Time
wantStatus string
}{
{name: "success", rsp: &alipay.TradeFastPayRefundQueryRsp{Error: ok, OutRequestNo: outRequestNo, RefundAmount: "12.50", RefundStatus: alipayRefundStatusSucc}, requestedAt: settled, wantStatus: payment.ProviderStatusSuccess},
{name: "record without final status", rsp: &alipay.TradeFastPayRefundQueryRsp{Error: ok, OutRequestNo: outRequestNo, RefundAmount: "12.50"}, requestedAt: settled, wantStatus: payment.ProviderStatusPending},
{name: "no refund record after grace", rsp: &alipay.TradeFastPayRefundQueryRsp{Error: ok}, requestedAt: settled, wantStatus: payment.ProviderStatusFailed},
{name: "no refund record within grace", rsp: &alipay.TradeFastPayRefundQueryRsp{Error: ok}, requestedAt: time.Now().Add(-time.Minute), wantStatus: payment.ProviderStatusPending},
{name: "no refund record with unknown request time", rsp: &alipay.TradeFastPayRefundQueryRsp{Error: ok}, wantStatus: payment.ProviderStatusPending},
} {
t.Run(tc.name, func(t *testing.T) {
alipayTradeFastPayRefundQuery = func(_ context.Context, _ *alipay.Client, param alipay.TradeFastPayRefundQuery) (*alipay.TradeFastPayRefundQueryRsp, error) {
if param.OutTradeNo != "sub2_7" || param.OutRequestNo != outRequestNo {
t.Fatalf("unexpected query param: %+v", param)
}
return tc.rsp, nil
}
provider := &Alipay{client: &alipay.Client{}}
resp, err := provider.QueryRefund(context.Background(), payment.RefundQueryRequest{OrderID: "sub2_7", RefundID: outRequestNo, RequestedAt: tc.requestedAt})
if err != nil {
t.Fatalf("QueryRefund() error = %v", err)
}
if resp.Status != tc.wantStatus || resp.RefundID != outRequestNo {
t.Fatalf("QueryRefund() = %+v, want status %q", resp, tc.wantStatus)
}
})
}
}
// SDK 对带签名的业务失败响应返回 err == nil,错误码只在响应体里。
func TestAlipayQueryRefundBusinessFailureKeepsRefundPending(t *testing.T) {
origQuery := alipayTradeFastPayRefundQuery
t.Cleanup(func() { alipayTradeFastPayRefundQuery = origQuery })
for _, rspErr := range []alipay.Error{
{Code: "40004", Msg: "Business Failed", SubCode: "ACQ.SYSTEM_ERROR", SubMsg: "系统错误"},
{Code: "20000", Msg: "Service Currently Unavailable", SubCode: "isp.unknow-error"},
{},
} {
alipayTradeFastPayRefundQuery = func(context.Context, *alipay.Client, alipay.TradeFastPayRefundQuery) (*alipay.TradeFastPayRefundQueryRsp, error) {
return &alipay.TradeFastPayRefundQueryRsp{Error: rspErr}, nil
}
provider := &Alipay{client: &alipay.Client{}}
resp, err := provider.QueryRefund(context.Background(), payment.RefundQueryRequest{
OrderID: "sub2_7",
RefundID: "sub2_7-refund-1",
RequestedAt: time.Now().Add(-24 * time.Hour),
})
if err == nil {
t.Fatalf("code %q: QueryRefund() = %+v, want error", rspErr.Code, resp)
}
if rspErr.SubCode != "" && !strings.Contains(err.Error(), string(rspErr.Code)) {
t.Fatalf("code %q: error %q should carry the gateway code", rspErr.Code, err)
}
}
}
func TestAlipayQueryRefundErrorsKeepRefundPending(t *testing.T) {
origQuery := alipayTradeFastPayRefundQuery
t.Cleanup(func() { alipayTradeFastPayRefundQuery = origQuery })
calls := 0
alipayTradeFastPayRefundQuery = func(context.Context, *alipay.Client, alipay.TradeFastPayRefundQuery) (*alipay.TradeFastPayRefundQueryRsp, error) {
calls++
return nil, errors.New("ACQ.SYSTEM_ERROR")
}
provider := &Alipay{client: &alipay.Client{}}
// 旧版本记录的是支付宝交易号,无法查询退款请求,需人工核实,且不能调用网关。
for _, legacyID := range []string{"", "2026101522001", "sub2_7-refund", "sub2_70-refund-1"} {
if _, err := provider.QueryRefund(context.Background(), payment.RefundQueryRequest{OrderID: "sub2_7", RefundID: legacyID}); err == nil || !strings.Contains(err.Error(), "verify manually") {
t.Fatalf("legacy refund id %q: err = %v", legacyID, err)
}
}
if calls != 0 {
t.Fatalf("legacy refund ids must not reach the gateway, calls = %d", calls)
}
if _, err := provider.QueryRefund(context.Background(), payment.RefundQueryRequest{OrderID: "sub2_7", RefundID: "sub2_7-refund-1"}); err == nil {
t.Fatal("gateway error must be returned so the refund stays pending")
}
}
+6 -1
View File
@@ -2,7 +2,10 @@
// registry, load balancing, and shared utilities for the payment subsystem.
package payment
import "context"
import (
"context"
"time"
)
// PaymentType represents a supported payment method.
type PaymentType = string
@@ -192,6 +195,8 @@ type RefundQueryRequest struct {
OrderID string
RefundID string
Amount string
// RequestedAt 为退款进入 pending 的时间;零值表示未知。
RequestedAt time.Time
}
// RefundResponse is returned after a refund request.
@@ -218,10 +218,38 @@ 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:
// 不支持的订单类型不履约:标记 FAILED 后正常返回,避免支付回调持续 500 重试。
s.rejectUnsupportedOrderType(ctx, o)
return nil
}
}
func isSupportedPaymentOrderType(orderType string) bool {
return orderType == payment.OrderTypeBalance || orderType == payment.OrderTypeSubscription
}
func (s *PaymentService) rejectUnsupportedOrderType(ctx context.Context, o *dbent.PaymentOrder) {
reason := "unsupported order type: " + o.OrderType
c, err := s.entClient.PaymentOrder.Update().
Where(
paymentorder.IDEQ(o.ID),
paymentorder.StatusIn(OrderStatusPaid, OrderStatusRecharging),
).
SetStatus(OrderStatusFailed).SetFailedAt(time.Now()).SetFailedReason(reason).Save(ctx)
if err != nil {
slog.Error("mark unsupported order type FAILED", "orderID", o.ID, "error", err)
return
}
if c > 0 {
slog.Warn("payment order with unsupported order type not fulfilled", "orderID", o.ID, "orderType", o.OrderType)
s.writeAuditLog(ctx, o.ID, "FULFILLMENT_FAILED", "system", map[string]any{"reason": reason})
}
return s.ExecuteBalanceFulfillment(ctx, oid)
}
func (s *PaymentService) ExecuteBalanceFulfillment(ctx context.Context, oid int64) error {
@@ -229,6 +257,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
}
@@ -885,6 +916,9 @@ func (s *PaymentService) RetryFulfillment(ctx context.Context, oid int64) error
if o.Status != OrderStatusFailed && o.Status != OrderStatusPaid && o.Status != OrderStatusRecharging {
return infraerrors.BadRequest("INVALID_STATUS", "only paid, failed, and recoverable recharging orders can retry")
}
if !isSupportedPaymentOrderType(o.OrderType) {
return infraerrors.BadRequest("INVALID_ORDER_TYPE", "unsupported order type")
}
s.writeAuditLog(ctx, oid, "RECHARGE_RETRY", "admin", map[string]any{"detail": "admin manual retry"})
return s.executeFulfillment(ctx, oid)
}
+9 -4
View File
@@ -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,80 @@
//go:build unit
package service
import (
"context"
"strconv"
"testing"
"time"
"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"
)
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.ExecuteBalanceFulfillment(ctx, order.ID)
require.Equal(t, "INVALID_ORDER_TYPE", infraerrors.Reason(err))
// 不履约,但标记 FAILED 并正常返回,使支付回调得到确认而不是无限重试。
require.NoError(t, svc.executeFulfillment(ctx, order.ID))
reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
require.NoError(t, err)
require.Equal(t, OrderStatusFailed, reloaded.Status)
require.NotNil(t, reloaded.FailedReason)
require.Contains(t, *reloaded.FailedReason, "unsupported order type")
// 回调重试经 alreadyProcessed 再次进入履约:幂等,不重复写审计。
require.NoError(t, svc.executeFulfillment(ctx, order.ID))
audits, err := client.PaymentAuditLog.Query().
Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("FULFILLMENT_FAILED")).
Count(ctx)
require.NoError(t, err)
require.Equal(t, 1, audits)
err = svc.RetryFulfillment(ctx, order.ID)
require.Equal(t, "INVALID_ORDER_TYPE", infraerrors.Reason(err))
}
+224 -53
View File
@@ -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
@@ -436,31 +446,37 @@ 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,
OrderID: o.OutTradeNo,
RefundID: pendingDetail.RefundID,
Amount: formatGatewayRefundAmount(o.RefundAmount, o),
TradeNo: o.PaymentTradeNo,
OrderID: o.OutTradeNo,
RefundID: pendingDetail.RefundID,
Amount: formatGatewayRefundAmount(o.RefundAmount, o),
RequestedAt: pendingDetail.requestedAt,
})
finishProviderCall()
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 +484,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 +521,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 +552,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,30 +577,164 @@ 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) {
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
// 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"`
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"`
// requestedAt 取自审计记录的创建时间,不参与序列化。
requestedAt time.Time
}
func (s *PaymentService) latestRefundPendingDetail(ctx context.Context, oid int64) refundPendingAuditDetail {
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())).
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
detail.requestedAt = logEntry.CreatedAt
return detail, nil
}
// getRefundProvider creates a provider using the order's original instance config.
@@ -644,16 +801,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 +842,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 +870,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,312 @@
//go:build unit
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"
)
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)
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)
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)
}
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().
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,11 @@ func TestFinishRefundPendingMarksOrderPendingAndRollsBackDeduction(t *testing.T)
Count(ctx)
require.NoError(t, err)
require.Equal(t, 1, pendingAudits)
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)
successAudits, err := client.PaymentAuditLog.Query().
Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_SUCCESS")).
Count(ctx)
@@ -502,6 +507,26 @@ func TestQueryAndFinalizeRefundFinalizesProviderStatuses(t *testing.T) {
}
}
func TestQueryAndFinalizeRefundPassesPendingAuditTimeToProvider(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
order := createPendingRefundOrderForTest(t, ctx, client, "query-requested-at")
pendingAudit, err := client.PaymentAuditLog.Query().
Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_PENDING")).
Only(ctx)
require.NoError(t, err)
svc := &PaymentService{entClient: client, loadBalancer: &captureLoadBalancer{}, userRepo: &mockUserRepo{}}
prov := &refundQueryProviderTestDouble{refundResponse: &payment.RefundResponse{RefundID: "rf_test", Status: payment.ProviderStatusPending}}
restore := replacePaymentProviderFactoryForTest(t, prov)
defer restore()
_, err = svc.QueryAndFinalizeRefund(ctx, order.ID)
require.NoError(t, err)
require.Equal(t, "rf_test", prov.lastQuery.RefundID)
require.True(t, pendingAudit.CreatedAt.Equal(prov.lastQuery.RequestedAt), "RequestedAt = %v, want %v", prov.lastQuery.RequestedAt, pendingAudit.CreatedAt)
}
func TestFinalizePendingRefundSuccessRejectsStaleCallerBeforeSecondDeduction(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
@@ -586,6 +611,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 +661,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
@@ -671,8 +701,10 @@ func (refundProviderTestDouble) Refund(context.Context, payment.RefundRequest) (
type refundQueryProviderTestDouble struct {
refundProviderTestDouble
refundResponse *payment.RefundResponse
lastQuery payment.RefundQueryRequest
}
func (p *refundQueryProviderTestDouble) QueryRefund(context.Context, payment.RefundQueryRequest) (*payment.RefundResponse, error) {
func (p *refundQueryProviderTestDouble) QueryRefund(_ context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) {
p.lastQuery = req
return p.refundResponse, nil
}
@@ -134,6 +134,8 @@ type RefundPlan struct {
BalanceToDeduct float64
SubDaysToDeduct int
SubscriptionID int64
// deductionHeld 表示扣减已在进入 REFUND_PENDING 前执行并保留,终态收敛时不再重复扣减。
deductionHeld bool
}
type RefundResult struct {
-2
View File
@@ -874,7 +874,6 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
path: '/payment/stripe',
query: {
order_id: String(result.order_id),
client_secret: result.client_secret,
method: stripeMethod || undefined,
resume_token: result.resume_token || undefined,
},
@@ -1082,7 +1081,6 @@ async function attemptMobileQrFallback(err: unknown, context: MobileQrFallbackCo
path: '/payment/stripe',
query: {
order_id: String(result.order_id),
client_secret: result.client_secret,
method: stripeMethod,
resume_token: result.resume_token || undefined,
},
+7 -10
View File
@@ -136,9 +136,14 @@ let redirectTimer: ReturnType<typeof setTimeout> | null = null
onMounted(async () => {
const orderId = Number(route.query.order_id)
const clientSecret = String(route.query.client_secret || '')
const method = String(route.query.method || '')
const resumeToken = typeof route.query.resume_token === 'string' ? route.query.resume_token : undefined
// client_secret 只从本浏览器下单时写入的恢复快照读取,且快照订单必须与 URL 订单一致:
// 不信任 URL 中的 client_secret,避免页面展示的订单与实际收款的 PaymentIntent 被拼接错绑。
const restored = typeof window !== 'undefined'
? readPaymentRecoverySnapshot(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY), { resumeToken })
: null
const clientSecret = orderId && restored?.orderId === orderId ? restored.clientSecret : ''
if (!orderId || !clientSecret) {
loading.value = false
@@ -147,15 +152,7 @@ onMounted(async () => {
}
try {
if (typeof window !== 'undefined') {
const restored = readPaymentRecoverySnapshot(
window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY),
{ resumeToken },
)
if (restored?.orderId === orderId) {
currency.value = normalizePaymentCurrency(restored.currency)
}
}
currency.value = normalizePaymentCurrency(restored?.currency)
const res = await paymentAPI.getOrder(orderId)
order.value = res.data
if (res.data.currency) {
@@ -65,6 +65,10 @@ vi.mock('@stripe/stripe-js/pure', () => ({
import StripePaymentView from '../StripePaymentView.vue'
import { formatPaymentAmount } from '@/components/payment/currency'
import {
PAYMENT_RECOVERY_STORAGE_KEY,
type PaymentRecoverySnapshot,
} from '@/components/payment/paymentFlow'
import type { PaymentOrder } from '@/types/payment'
function orderFactory(overrides: Partial<PaymentOrder> = {}): PaymentOrder {
@@ -86,6 +90,33 @@ function orderFactory(overrides: Partial<PaymentOrder> = {}): PaymentOrder {
}
}
function stripeSnapshot(overrides: Partial<PaymentRecoverySnapshot> = {}): PaymentRecoverySnapshot {
return {
orderId: 42,
amount: 100,
qrCode: '',
expiresAt: '2099-01-01T00:30:00.000Z',
paymentType: 'stripe',
payUrl: '/payment/stripe?order_id=42',
outTradeNo: 'sub2_stripe_42',
clientSecret: 'pi_secret_42',
intentId: '',
currency: 'CNY',
countryCode: '',
paymentEnv: '',
payAmount: 103,
orderType: 'balance',
paymentMode: '',
resumeToken: '',
createdAt: Date.UTC(2099, 0, 1, 0, 0, 0),
...overrides,
}
}
function writeSnapshot(overrides: Partial<PaymentRecoverySnapshot> = {}) {
window.localStorage.setItem(PAYMENT_RECOVERY_STORAGE_KEY, JSON.stringify(stripeSnapshot(overrides)))
}
function mountView() {
return shallowMount(StripePaymentView, {
global: {
@@ -101,7 +132,6 @@ describe('StripePaymentView', () => {
beforeEach(() => {
routeState.query = {
order_id: '42',
client_secret: 'pi_secret_42',
}
routerPush.mockReset()
getOrder.mockReset().mockResolvedValue({ data: orderFactory() })
@@ -119,6 +149,42 @@ describe('StripePaymentView', () => {
stripeInstance.confirmAlipayPayment.mockReset()
stripeInstance.confirmWechatPayPayment.mockReset()
window.localStorage.clear()
writeSnapshot()
})
it('uses the client secret from the local snapshot of the same order', async () => {
routeState.query.method = 'alipay'
mountView()
await flushPromises()
await flushPromises()
expect(getOrder).toHaveBeenCalledWith(42)
expect(stripeInstance.confirmAlipayPayment).toHaveBeenCalledWith('pi_secret_42', expect.any(Object))
})
it('ignores a client secret supplied in the URL query', async () => {
window.localStorage.clear()
routeState.query = { order_id: '42', client_secret: 'pi_other_secret', method: 'alipay' }
const wrapper = mountView()
await flushPromises()
expect(getOrder).not.toHaveBeenCalled()
expect(loadStripe).not.toHaveBeenCalled()
expect(stripeInstance.confirmAlipayPayment).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('payment.stripeMissingParams')
})
it('rejects a snapshot that belongs to a different order', async () => {
writeSnapshot({ orderId: 7, clientSecret: 'pi_secret_7' })
routeState.query = { order_id: '42', client_secret: 'pi_secret_42', method: 'alipay' }
const wrapper = mountView()
await flushPromises()
expect(getOrder).not.toHaveBeenCalled()
expect(stripeInstance.confirmAlipayPayment).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('payment.stripeMissingParams')
})
it.each(['alipay', 'wechat_pay', ''])('does not start %s payment after leaving during SDK loading', async (method) => {
@@ -178,7 +244,8 @@ describe('StripePaymentView', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('本地恢复快照缺失时使用订单接口返回的 Stripe 币种展示金额', async () => {
it('本地恢复快照未记录币种时使用订单接口返回的 Stripe 币种展示金额', async () => {
writeSnapshot({ currency: '' })
getOrder.mockResolvedValue({
data: orderFactory({ currency: 'HKD', pay_amount: 103 }),
})