fix(payment): 区分退款 pending 并收敛匿名查单

This commit is contained in:
wucm667
2026-06-30 10:00:48 +08:00
parent 55902d8548
commit 7316d83027
14 changed files with 302 additions and 40 deletions
+44 -3
View File
@@ -454,8 +454,9 @@ func (h *PaymentHandler) VerifyOrder(c *gin.Context) {
response.Success(c, sanitizePaymentOrderForResponse(order))
}
// PublicOrderResult is the limited order info returned by the public verify endpoint.
// No user details are exposed — only payment status information.
// PublicOrderResult is returned after a signed resume-token lookup. The token
// proves possession of the checkout session, so the result keeps the legacy
// frontend contract needed by payment result pages.
type PublicOrderResult struct {
ID int64 `json:"id"`
OutTradeNo string `json:"out_trade_no"`
@@ -478,6 +479,18 @@ type PublicOrderResult struct {
PlanID *int64 `json:"plan_id,omitempty"`
}
// PublicOrderVerifyResult is returned by the legacy anonymous out_trade_no
// lookup. Keep this intentionally minimal because out_trade_no is not secret.
type PublicOrderVerifyResult struct {
OutTradeNo string `json:"out_trade_no"`
Status string `json:"status"`
Paid bool `json:"paid"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
PaidAt *time.Time `json:"paid_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
func buildPublicOrderResult(order *dbent.PaymentOrder) PublicOrderResult {
return PublicOrderResult{
ID: order.ID,
@@ -502,6 +515,34 @@ func buildPublicOrderResult(order *dbent.PaymentOrder) PublicOrderResult {
}
}
func buildPublicOrderVerifyResult(order *dbent.PaymentOrder) PublicOrderVerifyResult {
return PublicOrderVerifyResult{
OutTradeNo: order.OutTradeNo,
Status: order.Status,
Paid: publicOrderStatusPaid(order.Status),
CreatedAt: order.CreatedAt,
ExpiresAt: order.ExpiresAt,
PaidAt: order.PaidAt,
CompletedAt: order.CompletedAt,
}
}
func publicOrderStatusPaid(status string) bool {
switch status {
case service.OrderStatusPaid,
service.OrderStatusCompleted,
service.OrderStatusRefundRequested,
service.OrderStatusRefunding,
service.OrderStatusRefundPending,
service.OrderStatusPartiallyRefunded,
service.OrderStatusRefunded,
service.OrderStatusRefundFailed:
return true
default:
return false
}
}
// VerifyOrderPublic keeps the legacy anonymous out_trade_no lookup available as
// a compatibility path for older result pages and staggered deploys.
// POST /api/v1/payment/public/orders/verify
@@ -517,7 +558,7 @@ func (h *PaymentHandler) VerifyOrderPublic(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
response.Success(c, buildPublicOrderResult(order))
response.Success(c, buildPublicOrderVerifyResult(order))
}
// ResolveOrderPublicByResumeToken resolves a payment order from a signed resume token.
@@ -135,35 +135,34 @@ func TestVerifyOrderPublicReturnsLegacyOrderState(t *testing.T) {
require.Equal(t, http.StatusOK, recorder.Code)
var resp struct {
Code int `json:"code"`
Data struct {
ID int64 `json:"id"`
OutTradeNo string `json:"out_trade_no"`
Amount float64 `json:"amount"`
PayAmount float64 `json:"pay_amount"`
FeeRate float64 `json:"fee_rate"`
Currency string `json:"currency"`
PaymentType string `json:"payment_type"`
OrderType string `json:"order_type"`
Status string `json:"status"`
RefundAmount float64 `json:"refund_amount"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
} `json:"data"`
Code int `json:"code"`
Data map[string]any `json:"data"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
require.Equal(t, 0, resp.Code)
require.Equal(t, order.ID, resp.Data.ID)
require.Equal(t, "legacy-order-no", resp.Data.OutTradeNo)
require.Equal(t, 90.64, resp.Data.PayAmount)
require.Equal(t, 0.03, resp.Data.FeeRate)
require.Equal(t, "HKD", resp.Data.Currency)
require.Equal(t, payment.TypeAlipay, resp.Data.PaymentType)
require.Equal(t, payment.OrderTypeBalance, resp.Data.OrderType)
require.Equal(t, service.OrderStatusPending, resp.Data.Status)
require.Equal(t, 0.0, resp.Data.RefundAmount)
require.NotEmpty(t, resp.Data.CreatedAt)
require.NotEmpty(t, resp.Data.ExpiresAt)
require.Equal(t, "legacy-order-no", resp.Data["out_trade_no"])
require.Equal(t, service.OrderStatusPending, resp.Data["status"])
require.Equal(t, false, resp.Data["paid"])
require.NotEmpty(t, resp.Data["created_at"])
require.NotEmpty(t, resp.Data["expires_at"])
for _, field := range []string{
"id",
"amount",
"pay_amount",
"fee_rate",
"currency",
"payment_type",
"order_type",
"refund_amount",
"refund_reason",
"refund_requested_at",
"refund_requested_by",
"refund_request_reason",
"plan_id",
} {
require.NotContains(t, resp.Data, field)
}
require.NotZero(t, order.ID)
}
func TestResolveOrderPublicByResumeTokenReturnsFrontendContractFields(t *testing.T) {
+1
View File
@@ -31,6 +31,7 @@ const (
OrderStatusFailed = "FAILED"
OrderStatusRefundRequested = "REFUND_REQUESTED"
OrderStatusRefunding = "REFUNDING"
OrderStatusRefundPending = "REFUND_PENDING"
OrderStatusPartiallyRefunded = "PARTIALLY_REFUNDED"
OrderStatusRefunded = "REFUNDED"
OrderStatusRefundFailed = "REFUND_FAILED"
+70 -8
View File
@@ -316,29 +316,30 @@ func (s *PaymentService) ExecuteRefund(ctx context.Context, p *RefundPlan) (*Ref
p.SubDaysToDeduct = 0
}
}
if err := s.gwRefund(ctx, p); err != nil {
resp, err := s.gwRefund(ctx, p)
if err != nil {
return s.handleGwFail(ctx, p, err)
}
return s.markRefundOk(ctx, p)
return s.finishRefund(ctx, p, resp)
}
func (s *PaymentService) gwRefund(ctx context.Context, p *RefundPlan) error {
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 nil
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 fmt.Errorf("get refund provider: %w", err)
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 err
return nil, err
}
resp, err := prov.Refund(ctx, payment.RefundRequest{
TradeNo: p.Order.PaymentTradeNo,
@@ -347,9 +348,15 @@ func (s *PaymentService) gwRefund(ctx context.Context, p *RefundPlan) error {
Reason: p.Reason,
})
if err != nil {
return err
if resp != nil && strings.TrimSpace(resp.Status) == payment.ProviderStatusPending {
return resp, nil
}
return nil, err
}
return validateRefundProviderResponse(resp)
if err := validateRefundProviderResponse(resp); err != nil {
return nil, err
}
return resp, nil
}
func formatGatewayRefundAmount(amount float64, order *dbent.PaymentOrder) string {
@@ -371,6 +378,20 @@ func validateRefundProviderResponse(resp *payment.RefundResponse) error {
}
}
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)
default:
return s.handleGwFail(ctx, p, fmt.Errorf("payment refund returned unknown status: %s", strings.TrimSpace(resp.Status)))
}
}
// 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) {
@@ -410,6 +431,47 @@ func (s *PaymentService) markRefundOk(ctx context.Context, p *RefundPlan) (*Refu
return &RefundResult{Success: true, BalanceDeducted: p.BalanceToDeduct, SubDaysDeducted: p.SubDaysToDeduct}, nil
}
func (s *PaymentService) markRefundPending(ctx context.Context, p *RefundPlan) (*RefundResult, error) {
balanceDeducted := p.BalanceToDeduct
subDaysDeducted := p.SubDaysToDeduct
rollbackOK := s.RollbackRefund(ctx, p, nil)
if rollbackOK {
p.BalanceToDeduct = 0
p.SubDaysToDeduct = 0
}
_, err := s.entClient.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)
}
detail := map[string]any{
"refundAmount": p.RefundAmount,
"reason": p.Reason,
"force": p.Force,
"balanceDeducted": p.BalanceToDeduct,
"subDaysDeducted": p.SubDaysToDeduct,
"balanceRolledBack": balanceDeducted,
"subDaysRolledBack": subDaysDeducted,
"deductionRollbackOK": rollbackOK,
}
s.writeAuditLog(ctx, p.OrderID, "REFUND_PENDING", "admin", detail)
warning := "gateway refund is pending confirmation"
if !rollbackOK {
warning += "; refund deduction rollback failed"
}
return &RefundResult{Success: false, Warning: warning}, nil
}
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 {
+152 -1
View File
@@ -9,6 +9,7 @@ import (
"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"
@@ -176,7 +177,7 @@ func TestGwRefundRejectsAlipayMerchantIdentitySnapshotMismatch(t *testing.T) {
loadBalancer: newWebhookProviderTestLoadBalancer(client),
}
err = svc.gwRefund(ctx, &RefundPlan{
_, err = svc.gwRefund(ctx, &RefundPlan{
OrderID: order.ID,
Order: order,
RefundAmount: order.Amount,
@@ -208,3 +209,153 @@ func TestValidateRefundProviderResponseAcceptsPending(t *testing.T) {
require.Error(t, validateRefundProviderResponse(&payment.RefundResponse{Status: payment.ProviderStatusFailed}))
require.Error(t, validateRefundProviderResponse(nil))
}
func TestFinishRefundPendingMarksOrderPendingAndRollsBackDeduction(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
user, err := client.User.Create().
SetEmail("refund-pending@example.com").
SetPasswordHash("hash").
SetUsername("refund-pending-user").
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-PENDING-ORDER").
SetOutTradeNo("sub2_refund_pending_order").
SetPaymentType(payment.TypeStripe).
SetPaymentTradeNo("pi_refund_pending").
SetOrderType(payment.OrderTypeBalance).
SetStatus(OrderStatusRefunding).
SetExpiresAt(time.Now().Add(time.Hour)).
SetPaidAt(time.Now()).
SetClientIP("127.0.0.1").
SetSrcHost("api.example.com").
Save(ctx)
require.NoError(t, err)
var rolledBack float64
userRepo := &mockUserRepo{}
userRepo.updateBalanceFn = func(ctx context.Context, id int64, amount float64) error {
require.Equal(t, user.ID, id)
rolledBack += amount
return nil
}
svc := &PaymentService{
entClient: client,
userRepo: userRepo,
}
plan := &RefundPlan{
OrderID: order.ID,
Order: order,
RefundAmount: 40,
GatewayAmount: 40,
Reason: "gateway accepted but not final",
Force: true,
DeductionType: payment.DeductionTypeBalance,
BalanceToDeduct: 40,
}
result, err := svc.finishRefund(ctx, plan, &payment.RefundResponse{Status: payment.ProviderStatusPending})
require.NoError(t, err)
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)
reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
require.NoError(t, err)
require.Equal(t, OrderStatusRefundPending, reloaded.Status)
require.Equal(t, 40.0, reloaded.RefundAmount)
require.NotNil(t, reloaded.RefundReason)
require.Equal(t, "gateway accepted but not final", *reloaded.RefundReason)
require.Nil(t, reloaded.RefundAt)
pendingAudits, err := client.PaymentAuditLog.Query().
Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_PENDING")).
Count(ctx)
require.NoError(t, err)
require.Equal(t, 1, pendingAudits)
successAudits, err := client.PaymentAuditLog.Query().
Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_SUCCESS")).
Count(ctx)
require.NoError(t, err)
require.Zero(t, successAudits)
}
func TestFinishRefundSuccessStatusesFinalize(t *testing.T) {
for _, status := range []string{payment.ProviderStatusSuccess, payment.ProviderStatusRefunded} {
t.Run(status, func(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
user, err := client.User.Create().
SetEmail("refund-success-" + status + "@example.com").
SetPasswordHash("hash").
SetUsername("refund-success-" + status).
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-SUCCESS-" + status).
SetOutTradeNo("sub2_refund_success_" + status).
SetPaymentType(payment.TypeStripe).
SetPaymentTradeNo("pi_refund_success_" + status).
SetOrderType(payment.OrderTypeBalance).
SetStatus(OrderStatusRefunding).
SetExpiresAt(time.Now().Add(time.Hour)).
SetPaidAt(time.Now()).
SetClientIP("127.0.0.1").
SetSrcHost("api.example.com").
Save(ctx)
require.NoError(t, err)
svc := &PaymentService{entClient: client}
plan := &RefundPlan{
OrderID: order.ID,
Order: order,
RefundAmount: 100,
GatewayAmount: 100,
Reason: "final success",
DeductionType: payment.DeductionTypeBalance,
BalanceToDeduct: 100,
}
result, err := svc.finishRefund(ctx, plan, &payment.RefundResponse{Status: status})
require.NoError(t, err)
require.NotNil(t, result)
require.True(t, result.Success)
require.Equal(t, 100.0, result.BalanceDeducted)
reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
require.NoError(t, err)
require.Equal(t, OrderStatusRefunded, reloaded.Status)
require.NotNil(t, reloaded.RefundAt)
successAudits, err := client.PaymentAuditLog.Query().
Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_SUCCESS")).
Count(ctx)
require.NoError(t, err)
require.Equal(t, 1, successAudits)
pendingAudits, err := client.PaymentAuditLog.Query().
Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_PENDING")).
Count(ctx)
require.NoError(t, err)
require.Zero(t, pendingAudits)
})
}
}
+2 -1
View File
@@ -30,6 +30,7 @@ const (
OrderStatusFailed = payment.OrderStatusFailed
OrderStatusRefundRequested = payment.OrderStatusRefundRequested
OrderStatusRefunding = payment.OrderStatusRefunding
OrderStatusRefundPending = payment.OrderStatusRefundPending
OrderStatusPartiallyRefunded = payment.OrderStatusPartiallyRefunded
OrderStatusRefunded = payment.OrderStatusRefunded
OrderStatusRefundFailed = payment.OrderStatusRefundFailed
@@ -252,7 +253,7 @@ func (s *PaymentService) loadProviders(ctx context.Context) {
func psIsRefundStatus(s string) bool {
switch s {
case OrderStatusRefundRequested, OrderStatusRefunding, OrderStatusPartiallyRefunded, OrderStatusRefunded, OrderStatusRefundFailed:
case OrderStatusRefundRequested, OrderStatusRefunding, OrderStatusRefundPending, OrderStatusPartiallyRefunded, OrderStatusRefunded, OrderStatusRefundFailed:
return true
}
return false
@@ -210,6 +210,7 @@ const statusFilterOptions = computed(() => [
{ value: 'FAILED', label: t('payment.status.failed') },
{ value: 'REFUNDED', label: t('payment.status.refunded') },
{ value: 'REFUND_REQUESTED', label: t('payment.status.refund_requested') },
{ value: 'REFUND_PENDING', label: t('payment.status.refund_pending') },
{ value: 'REFUND_FAILED', label: t('payment.status.refund_failed') },
])
@@ -198,7 +198,7 @@ const form = reactive({
force: false,
})
// In REFUND_REQUESTED status, refund_amount is the REQUESTED amount, not actually refunded.
// In REFUND_REQUESTED / REFUND_PENDING status, refund_amount is requested/pending, not actually refunded.
// Only PARTIALLY_REFUNDED / REFUNDED have real refund amounts.
const actuallyRefunded = computed(() => {
if (!props.order) return 0
@@ -28,6 +28,7 @@ const statusMap: Record<OrderStatus, { key: string; class: string }> = {
FAILED: { key: 'payment.status.failed', class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' },
REFUND_REQUESTED: { key: 'payment.status.refund_requested', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400' },
REFUNDING: { key: 'payment.status.refunding', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400' },
REFUND_PENDING: { key: 'payment.status.refund_pending', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400' },
REFUNDED: { key: 'payment.status.refunded', class: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400' },
PARTIALLY_REFUNDED: { key: 'payment.status.partially_refunded', class: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400' },
REFUND_FAILED: { key: 'payment.status.refund_failed', class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' },
@@ -13,6 +13,7 @@ const STATUS_BADGE_MAP: Record<string, string> = {
FAILED: 'badge-danger',
REFUND_REQUESTED: 'badge-warning',
REFUNDING: 'badge-warning',
REFUND_PENDING: 'badge-warning',
PARTIALLY_REFUNDED: 'badge-warning',
REFUNDED: 'badge-info',
REFUND_FAILED: 'badge-danger',
+1
View File
@@ -7012,6 +7012,7 @@ export default {
failed: 'Failed',
refund_requested: 'Refund Requested',
refunding: 'Refunding',
refund_pending: 'Refund Pending',
refunded: 'Refunded',
partially_refunded: 'Partially Refunded',
refund_failed: 'Refund Failed',
+1
View File
@@ -7192,6 +7192,7 @@ export default {
failed: '失败',
refund_requested: '退款申请中',
refunding: '退款中',
refund_pending: '退款处理中',
refunded: '已退款',
partially_refunded: '部分退款',
refund_failed: '退款失败',
+1
View File
@@ -14,6 +14,7 @@ export type OrderStatus =
| 'FAILED'
| 'REFUND_REQUESTED'
| 'REFUNDING'
| 'REFUND_PENDING'
| 'PARTIALLY_REFUNDED'
| 'REFUNDED'
| 'REFUND_FAILED'
@@ -190,6 +190,7 @@ const statusFilterOptions = computed(() => [
{ value: 'FAILED', label: t('payment.status.failed') },
{ value: 'REFUNDED', label: t('payment.status.refunded') },
{ value: 'REFUND_REQUESTED', label: t('payment.status.refund_requested') },
{ value: 'REFUND_PENDING', label: t('payment.status.refund_pending') },
{ value: 'REFUND_FAILED', label: t('payment.status.refund_failed') },
])