mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
- 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>
688 lines
23 KiB
Go
688 lines
23 KiB
Go
//go:build unit
|
|
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"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"
|
|
)
|
|
|
|
func TestValidateRefundRequestRejectsLegacyGuessedProviderInstance(t *testing.T) {
|
|
ctx := context.Background()
|
|
client := newPaymentConfigServiceTestClient(t)
|
|
|
|
user, err := client.User.Create().
|
|
SetEmail("refund-legacy@example.com").
|
|
SetPasswordHash("hash").
|
|
SetUsername("refund-legacy-user").
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.PaymentProviderInstance.Create().
|
|
SetProviderKey(payment.TypeAlipay).
|
|
SetName("alipay-refund-instance").
|
|
SetConfig("{}").
|
|
SetSupportedTypes("alipay").
|
|
SetEnabled(true).
|
|
SetAllowUserRefund(true).
|
|
SetRefundEnabled(true).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
order, err := client.PaymentOrder.Create().
|
|
SetUserID(user.ID).
|
|
SetUserEmail(user.Email).
|
|
SetUserName(user.Username).
|
|
SetAmount(88).
|
|
SetPayAmount(88).
|
|
SetFeeRate(0).
|
|
SetRechargeCode("REFUND-LEGACY-ORDER").
|
|
SetOutTradeNo("sub2_refund_legacy_order").
|
|
SetPaymentType(payment.TypeAlipay).
|
|
SetPaymentTradeNo("trade-legacy-refund").
|
|
SetOrderType(payment.OrderTypeBalance).
|
|
SetStatus(OrderStatusCompleted).
|
|
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,
|
|
}
|
|
|
|
_, err = svc.validateRefundRequest(ctx, order.ID, user.ID)
|
|
require.Error(t, err)
|
|
require.Equal(t, "USER_REFUND_DISABLED", infraerrors.Reason(err))
|
|
}
|
|
|
|
func TestPrepareRefundRejectsLegacyGuessedProviderInstance(t *testing.T) {
|
|
ctx := context.Background()
|
|
client := newPaymentConfigServiceTestClient(t)
|
|
|
|
user, err := client.User.Create().
|
|
SetEmail("refund-legacy-admin@example.com").
|
|
SetPasswordHash("hash").
|
|
SetUsername("refund-legacy-admin-user").
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.PaymentProviderInstance.Create().
|
|
SetProviderKey(payment.TypeAlipay).
|
|
SetName("alipay-refund-admin-instance").
|
|
SetConfig("{}").
|
|
SetSupportedTypes("alipay").
|
|
SetEnabled(true).
|
|
SetAllowUserRefund(true).
|
|
SetRefundEnabled(true).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
order, err := client.PaymentOrder.Create().
|
|
SetUserID(user.ID).
|
|
SetUserEmail(user.Email).
|
|
SetUserName(user.Username).
|
|
SetAmount(188).
|
|
SetPayAmount(188).
|
|
SetFeeRate(0).
|
|
SetRechargeCode("REFUND-LEGACY-ADMIN-ORDER").
|
|
SetOutTradeNo("sub2_refund_legacy_admin_order").
|
|
SetPaymentType(payment.TypeAlipay).
|
|
SetPaymentTradeNo("trade-legacy-admin-refund").
|
|
SetOrderType(payment.OrderTypeBalance).
|
|
SetStatus(OrderStatusCompleted).
|
|
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, result, err := svc.PrepareRefund(ctx, order.ID, 0, "", false, false)
|
|
require.Nil(t, plan)
|
|
require.Nil(t, result)
|
|
require.Error(t, err)
|
|
require.Equal(t, "REFUND_DISABLED", infraerrors.Reason(err))
|
|
}
|
|
|
|
func TestPrepDeductBalanceRequiresForceWhenBalanceIsInsufficient(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
balance float64
|
|
force bool
|
|
wantDeduct float64
|
|
wantWarning bool
|
|
}{
|
|
{name: "insufficient balance", balance: 40, wantWarning: true},
|
|
{name: "forced insufficient balance", balance: 40, force: true, wantDeduct: 40},
|
|
{name: "equal balance", balance: 100, wantDeduct: 100},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
plan := &RefundPlan{RefundAmount: 100}
|
|
svc := &PaymentService{userRepo: &mockUserRepo{getByIDUser: &User{Balance: tc.balance}}}
|
|
|
|
result := svc.prepDeduct(context.Background(), &dbent.PaymentOrder{
|
|
UserID: 1,
|
|
OrderType: payment.OrderTypeBalance,
|
|
}, plan, tc.force)
|
|
|
|
if tc.wantWarning {
|
|
require.NotNil(t, result)
|
|
require.False(t, result.Success)
|
|
require.True(t, result.RequireForce)
|
|
require.Equal(t, "user balance is insufficient for deduction, use force", result.Warning)
|
|
require.Zero(t, plan.BalanceToDeduct)
|
|
return
|
|
}
|
|
require.Nil(t, result)
|
|
require.Equal(t, payment.DeductionTypeBalance, plan.DeductionType)
|
|
require.Equal(t, tc.wantDeduct, plan.BalanceToDeduct)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExecuteRefundUsesActualAvailableBalanceDeduction(t *testing.T) {
|
|
ctx := context.Background()
|
|
client := newPaymentConfigServiceTestClient(t)
|
|
user, err := client.User.Create().
|
|
SetEmail("refund-execute-clamp@example.com").
|
|
SetPasswordHash("hash").
|
|
SetUsername("refund-execute-clamp").
|
|
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-EXECUTE-CLAMP").
|
|
SetOutTradeNo("refund_execute_clamp").
|
|
SetPaymentType(payment.TypeStripe).
|
|
SetPaymentTradeNo("").
|
|
SetOrderType(payment.OrderTypeBalance).
|
|
SetStatus(OrderStatusCompleted).
|
|
SetExpiresAt(time.Now().Add(time.Hour)).
|
|
SetPaidAt(time.Now()).
|
|
SetClientIP("127.0.0.1").
|
|
SetSrcHost("api.example.com").
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
repo := &mockUserRepo{deductAvailableBalanceFn: func(_ context.Context, id int64, amount float64) (float64, error) {
|
|
require.Equal(t, user.ID, id)
|
|
require.Equal(t, 100.0, amount)
|
|
return 25, nil
|
|
}}
|
|
plan := &RefundPlan{
|
|
OrderID: order.ID, Order: order, RefundAmount: 100, GatewayAmount: 100,
|
|
Reason: "concurrent spend", Force: true, DeductionType: payment.DeductionTypeBalance, BalanceToDeduct: 100,
|
|
}
|
|
|
|
result, err := (&PaymentService{entClient: client, userRepo: repo}).ExecuteRefund(ctx, plan)
|
|
require.NoError(t, err)
|
|
require.True(t, result.Success)
|
|
require.Equal(t, 25.0, plan.BalanceToDeduct)
|
|
require.Equal(t, 25.0, result.BalanceDeducted)
|
|
audit, err := client.PaymentAuditLog.Query().
|
|
Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_SUCCESS")).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Contains(t, audit.Detail, `"balanceDeducted":25`)
|
|
}
|
|
|
|
func TestGwRefundRejectsAlipayMerchantIdentitySnapshotMismatch(t *testing.T) {
|
|
ctx := context.Background()
|
|
client := newPaymentConfigServiceTestClient(t)
|
|
|
|
user, err := client.User.Create().
|
|
SetEmail("refund-snapshot-mismatch@example.com").
|
|
SetPasswordHash("hash").
|
|
SetUsername("refund-snapshot-mismatch-user").
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
inst, err := client.PaymentProviderInstance.Create().
|
|
SetProviderKey(payment.TypeAlipay).
|
|
SetName("alipay-refund-mismatch-instance").
|
|
SetConfig(encryptWebhookProviderConfig(t, map[string]string{
|
|
"appId": "runtime-alipay-app",
|
|
"privateKey": "runtime-private-key",
|
|
})).
|
|
SetSupportedTypes("alipay").
|
|
SetEnabled(true).
|
|
SetRefundEnabled(true).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
instID := strconv.FormatInt(inst.ID, 10)
|
|
order, err := client.PaymentOrder.Create().
|
|
SetUserID(user.ID).
|
|
SetUserEmail(user.Email).
|
|
SetUserName(user.Username).
|
|
SetAmount(88).
|
|
SetPayAmount(88).
|
|
SetFeeRate(0).
|
|
SetRechargeCode("REFUND-SNAPSHOT-MISMATCH-ORDER").
|
|
SetOutTradeNo("sub2_refund_snapshot_mismatch_order").
|
|
SetPaymentType(payment.TypeAlipay).
|
|
SetPaymentTradeNo("trade-refund-snapshot-mismatch").
|
|
SetOrderType(payment.OrderTypeBalance).
|
|
SetStatus(OrderStatusCompleted).
|
|
SetExpiresAt(time.Now().Add(time.Hour)).
|
|
SetPaidAt(time.Now()).
|
|
SetClientIP("127.0.0.1").
|
|
SetSrcHost("api.example.com").
|
|
SetProviderInstanceID(instID).
|
|
SetProviderKey(payment.TypeAlipay).
|
|
SetProviderSnapshot(map[string]any{
|
|
"schema_version": 2,
|
|
"provider_instance_id": instID,
|
|
"provider_key": payment.TypeAlipay,
|
|
"merchant_app_id": "expected-alipay-app",
|
|
}).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
svc := &PaymentService{
|
|
entClient: client,
|
|
loadBalancer: newWebhookProviderTestLoadBalancer(client),
|
|
}
|
|
|
|
_, err = svc.gwRefund(ctx, &RefundPlan{
|
|
OrderID: order.ID,
|
|
Order: order,
|
|
RefundAmount: order.Amount,
|
|
GatewayAmount: order.Amount,
|
|
Reason: "snapshot mismatch",
|
|
})
|
|
require.ErrorContains(t, err, "alipay app_id mismatch")
|
|
}
|
|
|
|
func TestCalculateGatewayRefundAmountUsesCurrencyPrecision(t *testing.T) {
|
|
require.InDelta(t, 6.173, calculateGatewayRefundAmount(100, 12.345, 50, "KWD"), 1e-12)
|
|
require.InDelta(t, 12.345, calculateGatewayRefundAmount(100, 12.345, 100, "KWD"), 1e-12)
|
|
require.InDelta(t, 52, calculateGatewayRefundAmount(100, 103, 50, "JPY"), 1e-12)
|
|
}
|
|
|
|
func TestFormatGatewayRefundAmountUsesOrderCurrency(t *testing.T) {
|
|
order := &dbent.PaymentOrder{
|
|
ProviderSnapshot: map[string]any{
|
|
"currency": "KWD",
|
|
},
|
|
}
|
|
|
|
require.Equal(t, "12.345", formatGatewayRefundAmount(12.345, order))
|
|
}
|
|
|
|
func TestValidateRefundProviderResponseAcceptsPending(t *testing.T) {
|
|
require.NoError(t, validateRefundProviderResponse(&payment.RefundResponse{Status: payment.ProviderStatusPending}))
|
|
require.NoError(t, validateRefundProviderResponse(&payment.RefundResponse{Status: payment.ProviderStatusSuccess}))
|
|
require.Error(t, validateRefundProviderResponse(&payment.RefundResponse{Status: payment.ProviderStatusFailed}))
|
|
require.Error(t, validateRefundProviderResponse(nil))
|
|
}
|
|
|
|
func TestFinishRefundPendingMarksOrderPendingAndHoldsDeduction(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.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)
|
|
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)
|
|
detail := svc.latestRefundPendingDetail(ctx, order.ID)
|
|
require.True(t, detail.DeductionHeld)
|
|
require.Equal(t, payment.DeductionTypeBalance, detail.DeductionType)
|
|
require.Equal(t, 40.0, detail.BalanceDeducted)
|
|
successAudits, err := client.PaymentAuditLog.Query().
|
|
Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_SUCCESS")).
|
|
Count(ctx)
|
|
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)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestQueryAndFinalizeRefundFinalizesProviderStatuses(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
status string
|
|
wantStatus string
|
|
wantDeduct float64
|
|
available float64
|
|
}{
|
|
{name: "success", status: payment.ProviderStatusSuccess, wantStatus: OrderStatusRefunded, wantDeduct: 100, available: 100},
|
|
{name: "success clamps current balance", status: payment.ProviderStatusSuccess, wantStatus: OrderStatusRefunded, wantDeduct: 35, available: 35},
|
|
{name: "failed", status: payment.ProviderStatusFailed, wantStatus: OrderStatusRefundFailed},
|
|
{name: "pending", status: payment.ProviderStatusPending, wantStatus: OrderStatusRefundPending},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
client := newPaymentConfigServiceTestClient(t)
|
|
order := createPendingRefundOrderForTest(t, ctx, client, "query-finalize-"+tc.name)
|
|
|
|
var deducted float64
|
|
svc := &PaymentService{
|
|
entClient: client,
|
|
loadBalancer: &captureLoadBalancer{},
|
|
userRepo: &mockUserRepo{deductAvailableBalanceFn: func(ctx context.Context, id int64, amount float64) (float64, error) {
|
|
deducted += tc.available
|
|
return tc.available, nil
|
|
}},
|
|
}
|
|
restore := replacePaymentProviderFactoryForTest(t, &refundQueryProviderTestDouble{
|
|
refundResponse: &payment.RefundResponse{RefundID: "rf_test", Status: tc.status},
|
|
})
|
|
defer restore()
|
|
|
|
result, err := svc.QueryAndFinalizeRefund(ctx, order.ID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, tc.status == payment.ProviderStatusSuccess, result.Success)
|
|
require.Equal(t, tc.wantDeduct, deducted)
|
|
if tc.status == payment.ProviderStatusSuccess {
|
|
require.Equal(t, tc.wantDeduct, result.BalanceDeducted)
|
|
audit, err := client.PaymentAuditLog.Query().
|
|
Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_SUCCESS")).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Contains(t, audit.Detail, fmt.Sprintf(`"balanceDeducted":%v`, tc.wantDeduct))
|
|
}
|
|
|
|
reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tc.wantStatus, reloaded.Status)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFinalizePendingRefundSuccessRejectsStaleCallerBeforeSecondDeduction(t *testing.T) {
|
|
ctx := context.Background()
|
|
client := newPaymentConfigServiceTestClient(t)
|
|
order := createPendingRefundOrderForTest(t, ctx, client, "finalize-stale")
|
|
|
|
deductions := 0
|
|
svc := &PaymentService{
|
|
entClient: client,
|
|
userRepo: &mockUserRepo{deductAvailableBalanceFn: func(ctx context.Context, id int64, amount float64) (float64, error) {
|
|
require.NotNil(t, dbent.TxFromContext(ctx))
|
|
deductions++
|
|
return amount, nil
|
|
}},
|
|
}
|
|
|
|
first, err := svc.finalizePendingRefundSuccess(ctx, svc.refundFinalizePlan(order))
|
|
require.NoError(t, err)
|
|
require.True(t, first.Success)
|
|
|
|
second, err := svc.finalizePendingRefundSuccess(ctx, svc.refundFinalizePlan(order))
|
|
require.Nil(t, second)
|
|
require.Error(t, err)
|
|
require.Equal(t, "CONFLICT", infraerrors.Reason(err))
|
|
require.Equal(t, 1, deductions)
|
|
|
|
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)
|
|
}
|
|
|
|
func TestFinalizePendingRefundSuccessRollsBackPostDeductionFailure(t *testing.T) {
|
|
ctx := context.Background()
|
|
client := newPaymentConfigServiceTestClient(t)
|
|
order := createPendingRefundOrderForTest(t, ctx, client, "finalize-rollback")
|
|
_, err := client.User.UpdateOneID(order.UserID).SetBalance(100).Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
svc := &PaymentService{
|
|
entClient: client,
|
|
userRepo: &mockUserRepo{deductAvailableBalanceFn: func(ctx context.Context, id int64, amount float64) (float64, error) {
|
|
tx := dbent.TxFromContext(ctx)
|
|
require.NotNil(t, tx)
|
|
if _, updateErr := tx.Client().User.UpdateOneID(id).AddBalance(-amount).Save(ctx); updateErr != nil {
|
|
return 0, updateErr
|
|
}
|
|
return 0, errors.New("injected failure after deduction")
|
|
}},
|
|
}
|
|
|
|
result, err := svc.finalizePendingRefundSuccess(ctx, svc.refundFinalizePlan(order))
|
|
require.Nil(t, result)
|
|
require.ErrorContains(t, err, "injected failure after deduction")
|
|
|
|
user, err := client.User.Get(ctx, order.UserID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 100.0, user.Balance)
|
|
reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, OrderStatusRefundPending, reloaded.Status)
|
|
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 TestQueryAndFinalizeRefundUnsupportedProviderReturnsClearError(t *testing.T) {
|
|
ctx := context.Background()
|
|
client := newPaymentConfigServiceTestClient(t)
|
|
order := createPendingRefundOrderForTest(t, ctx, client, "query-finalize-unsupported")
|
|
svc := &PaymentService{entClient: client, loadBalancer: &captureLoadBalancer{}}
|
|
restore := replacePaymentProviderFactoryForTest(t, refundProviderTestDouble{})
|
|
defer restore()
|
|
|
|
result, err := svc.QueryAndFinalizeRefund(ctx, order.ID)
|
|
require.Nil(t, result)
|
|
require.Error(t, err)
|
|
require.Equal(t, "REFUND_QUERY_UNSUPPORTED", infraerrors.Reason(err))
|
|
}
|
|
|
|
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").
|
|
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(OrderStatusRefundPending).
|
|
SetRefundAmount(100).
|
|
SetRefundReason("pending refund").
|
|
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)
|
|
|
|
_, err = client.PaymentAuditLog.Create().
|
|
SetOrderID(strconv.FormatInt(order.ID, 10)).
|
|
SetAction("REFUND_PENDING").
|
|
SetOperator("admin").
|
|
SetDetail(pendingDetail).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
return order
|
|
}
|
|
|
|
func replacePaymentProviderFactoryForTest(t *testing.T, prov payment.Provider) func() {
|
|
t.Helper()
|
|
original := createPaymentProviderFromInstance
|
|
createPaymentProviderFromInstance = func(providerKey, instanceID string, config map[string]string) (payment.Provider, error) {
|
|
return prov, nil
|
|
}
|
|
return func() { createPaymentProviderFromInstance = original }
|
|
}
|
|
|
|
type refundProviderTestDouble struct{}
|
|
|
|
func (refundProviderTestDouble) Name() string { return "refund-test" }
|
|
func (refundProviderTestDouble) ProviderKey() string {
|
|
return payment.TypeStripe
|
|
}
|
|
func (refundProviderTestDouble) SupportedTypes() []payment.PaymentType {
|
|
return []payment.PaymentType{payment.TypeStripe}
|
|
}
|
|
func (refundProviderTestDouble) CreatePayment(context.Context, payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
|
return nil, nil
|
|
}
|
|
func (refundProviderTestDouble) QueryOrder(context.Context, string) (*payment.QueryOrderResponse, error) {
|
|
return nil, nil
|
|
}
|
|
func (refundProviderTestDouble) VerifyNotification(context.Context, string, map[string]string) (*payment.PaymentNotification, error) {
|
|
return nil, nil
|
|
}
|
|
func (refundProviderTestDouble) Refund(context.Context, payment.RefundRequest) (*payment.RefundResponse, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
type refundQueryProviderTestDouble struct {
|
|
refundProviderTestDouble
|
|
refundResponse *payment.RefundResponse
|
|
}
|
|
|
|
func (p *refundQueryProviderTestDouble) QueryRefund(context.Context, payment.RefundQueryRequest) (*payment.RefundResponse, error) {
|
|
return p.refundResponse, nil
|
|
}
|