Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
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
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
This commit is contained in:
@@ -0,0 +1,678 @@
|
||||
//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
|
||||
}
|
||||
Reference in New Issue
Block a user