Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
679 lines
22 KiB
Go
679 lines
22 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 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)
|
|
})
|
|
}
|
|
}
|
|
|
|
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()
|
|
|
|
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(`{"refundID":"rf_test","deductionRollbackOK":true}`).
|
|
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
|
|
}
|