Files
sub2api/backend/internal/service/payment_fulfillment_test.go
T
李建琦 6d655c9903
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
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

1063 lines
35 KiB
Go

//go:build unit
package service
import (
"context"
"errors"
"math"
"strconv"
"testing"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentauditlog"
"github.com/Wei-Shaw/sub2api/internal/payment"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type paymentFulfillmentTestProvider struct {
key string
supportedTypes []payment.PaymentType
}
func (p paymentFulfillmentTestProvider) Name() string { return p.key }
func (p paymentFulfillmentTestProvider) ProviderKey() string { return p.key }
func (p paymentFulfillmentTestProvider) SupportedTypes() []payment.PaymentType {
return p.supportedTypes
}
func (p paymentFulfillmentTestProvider) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
panic("unexpected call")
}
func (p paymentFulfillmentTestProvider) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
panic("unexpected call")
}
func (p paymentFulfillmentTestProvider) VerifyNotification(ctx context.Context, rawBody string, headers map[string]string) (*payment.PaymentNotification, error) {
panic("unexpected call")
}
func (p paymentFulfillmentTestProvider) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
panic("unexpected call")
}
type paymentFulfillmentAffiliateAccrueCall struct {
inviterID int64
inviteeUserID int64
amount float64
freezeHours int
sourceOrderID *int64
}
type paymentFulfillmentAffiliateRepoStub struct {
inviteeSummary *AffiliateSummary
inviterSummary *AffiliateSummary
accrueCalls []paymentFulfillmentAffiliateAccrueCall
}
func (r *paymentFulfillmentAffiliateRepoStub) EnsureUserAffiliate(_ context.Context, userID int64) (*AffiliateSummary, error) {
switch {
case r.inviteeSummary != nil && r.inviteeSummary.UserID == userID:
cp := *r.inviteeSummary
return &cp, nil
case r.inviterSummary != nil && r.inviterSummary.UserID == userID:
cp := *r.inviterSummary
return &cp, nil
default:
return &AffiliateSummary{UserID: userID, AffCode: "AFFTEST", CreatedAt: time.Now().Add(-time.Hour)}, nil
}
}
func (r *paymentFulfillmentAffiliateRepoStub) GetAffiliateByCode(context.Context, string) (*AffiliateSummary, error) {
panic("unexpected GetAffiliateByCode call")
}
func (r *paymentFulfillmentAffiliateRepoStub) BindInviter(context.Context, int64, int64) (bool, error) {
panic("unexpected BindInviter call")
}
func (r *paymentFulfillmentAffiliateRepoStub) AccrueQuota(_ context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int, sourceOrderID *int64) (bool, error) {
var sourceCopy *int64
if sourceOrderID != nil {
v := *sourceOrderID
sourceCopy = &v
}
r.accrueCalls = append(r.accrueCalls, paymentFulfillmentAffiliateAccrueCall{
inviterID: inviterID,
inviteeUserID: inviteeUserID,
amount: amount,
freezeHours: freezeHours,
sourceOrderID: sourceCopy,
})
return true, nil
}
func (r *paymentFulfillmentAffiliateRepoStub) GetAccruedRebateFromInvitee(context.Context, int64, int64) (float64, error) {
return 0, nil
}
func (r *paymentFulfillmentAffiliateRepoStub) ThawFrozenQuota(context.Context, int64) (float64, error) {
panic("unexpected ThawFrozenQuota call")
}
func (r *paymentFulfillmentAffiliateRepoStub) TransferQuotaToBalance(context.Context, int64) (float64, float64, error) {
panic("unexpected TransferQuotaToBalance call")
}
func (r *paymentFulfillmentAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]AffiliateInvitee, error) {
panic("unexpected ListInvitees call")
}
func (r *paymentFulfillmentAffiliateRepoStub) UpdateUserAffCode(context.Context, int64, string) error {
panic("unexpected UpdateUserAffCode call")
}
func (r *paymentFulfillmentAffiliateRepoStub) ResetUserAffCode(context.Context, int64) (string, error) {
panic("unexpected ResetUserAffCode call")
}
func (r *paymentFulfillmentAffiliateRepoStub) SetUserRebateRate(context.Context, int64, *float64) error {
panic("unexpected SetUserRebateRate call")
}
func (r *paymentFulfillmentAffiliateRepoStub) BatchSetUserRebateRate(context.Context, []int64, *float64) error {
panic("unexpected BatchSetUserRebateRate call")
}
func (r *paymentFulfillmentAffiliateRepoStub) ListUsersWithCustomSettings(context.Context, AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error) {
panic("unexpected ListUsersWithCustomSettings call")
}
func (r *paymentFulfillmentAffiliateRepoStub) ListAffiliateInviteRecords(context.Context, AffiliateRecordFilter) ([]AffiliateInviteRecord, int64, error) {
panic("unexpected ListAffiliateInviteRecords call")
}
func (r *paymentFulfillmentAffiliateRepoStub) ListAffiliateRebateRecords(context.Context, AffiliateRecordFilter) ([]AffiliateRebateRecord, int64, error) {
panic("unexpected ListAffiliateRebateRecords call")
}
func (r *paymentFulfillmentAffiliateRepoStub) ListAffiliateTransferRecords(context.Context, AffiliateRecordFilter) ([]AffiliateTransferRecord, int64, error) {
panic("unexpected ListAffiliateTransferRecords call")
}
func (r *paymentFulfillmentAffiliateRepoStub) GetAffiliateUserOverview(context.Context, int64) (*AffiliateUserOverview, error) {
panic("unexpected GetAffiliateUserOverview call")
}
type paymentFulfillmentSettingRepoStub struct {
values map[string]string
}
func (s *paymentFulfillmentSettingRepoStub) Get(context.Context, string) (*Setting, error) {
return nil, ErrSettingNotFound
}
func (s *paymentFulfillmentSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
if s.values == nil {
return "", ErrSettingNotFound
}
value, ok := s.values[key]
if !ok {
return "", ErrSettingNotFound
}
return value, nil
}
func (s *paymentFulfillmentSettingRepoStub) Set(_ context.Context, key, value string) error {
if s.values == nil {
s.values = map[string]string{}
}
s.values[key] = value
return nil
}
func (s *paymentFulfillmentSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
out := make(map[string]string, len(keys))
for _, key := range keys {
out[key] = s.values[key]
}
return out, nil
}
func (s *paymentFulfillmentSettingRepoStub) SetMultiple(_ context.Context, values map[string]string) error {
if s.values == nil {
s.values = map[string]string{}
}
for key, value := range values {
s.values[key] = value
}
return nil
}
func (s *paymentFulfillmentSettingRepoStub) GetAll(context.Context) (map[string]string, error) {
return s.values, nil
}
func (s *paymentFulfillmentSettingRepoStub) Delete(_ context.Context, key string) error {
delete(s.values, key)
return nil
}
func ensurePaymentAuditOrderActionUniqueIndex(t *testing.T, ctx context.Context, client *dbent.Client) {
t.Helper()
_, err := client.ExecContext(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_audit_logs_order_action_uniq ON payment_audit_logs(order_id, action)")
require.NoError(t, err)
}
// ---------------------------------------------------------------------------
// resolveRedeemAction — pure idempotency decision logic
// ---------------------------------------------------------------------------
func TestResolveRedeemAction_CodeNotFound(t *testing.T) {
t.Parallel()
action := resolveRedeemAction(nil, nil)
assert.Equal(t, redeemActionCreate, action, "nil code with nil error should create")
}
func TestResolveRedeemAction_LookupError(t *testing.T) {
t.Parallel()
action := resolveRedeemAction(nil, errors.New("db connection lost"))
assert.Equal(t, redeemActionCreate, action, "lookup error should fall back to create")
}
func TestResolveRedeemAction_LookupErrorWithNonNilCode(t *testing.T) {
t.Parallel()
// Edge case: both code and error are non-nil (shouldn't happen in practice,
// but the function should still treat error as authoritative)
code := &RedeemCode{Status: StatusUnused}
action := resolveRedeemAction(code, errors.New("partial error"))
assert.Equal(t, redeemActionCreate, action, "non-nil error should always result in create regardless of code")
}
func TestResolveRedeemAction_CodeExistsAndUsed(t *testing.T) {
t.Parallel()
code := &RedeemCode{
Code: "test-code-123",
Status: StatusUsed,
Type: RedeemTypeBalance,
Value: 10.0,
}
action := resolveRedeemAction(code, nil)
assert.Equal(t, redeemActionSkipCompleted, action, "used code should skip to completed")
}
func TestResolveRedeemAction_CodeExistsAndUnused(t *testing.T) {
t.Parallel()
code := &RedeemCode{
Code: "test-code-456",
Status: StatusUnused,
Type: RedeemTypeBalance,
Value: 25.0,
}
action := resolveRedeemAction(code, nil)
assert.Equal(t, redeemActionRedeem, action, "unused code should skip creation and proceed to redeem")
}
func TestResolveRedeemAction_CodeExistsWithExpiredStatus(t *testing.T) {
t.Parallel()
// A code with a non-standard status (neither "unused" nor "used")
// should NOT be treated as used, so it falls through to redeemActionRedeem.
code := &RedeemCode{
Code: "expired-code",
Status: StatusExpired,
}
action := resolveRedeemAction(code, nil)
assert.Equal(t, redeemActionRedeem, action, "expired-status code is not IsUsed(), should redeem")
}
// ---------------------------------------------------------------------------
// Table-driven comprehensive test
// ---------------------------------------------------------------------------
func TestResolveRedeemAction_Table(t *testing.T) {
t.Parallel()
tests := []struct {
name string
code *RedeemCode
err error
expected redeemAction
}{
{
name: "nil code, nil error — first run",
code: nil,
err: nil,
expected: redeemActionCreate,
},
{
name: "nil code, lookup error — treat as not found",
code: nil,
err: ErrRedeemCodeNotFound,
expected: redeemActionCreate,
},
{
name: "nil code, generic DB error — treat as not found",
code: nil,
err: errors.New("connection refused"),
expected: redeemActionCreate,
},
{
name: "code exists, used — previous run completed redeem",
code: &RedeemCode{Status: StatusUsed},
err: nil,
expected: redeemActionSkipCompleted,
},
{
name: "code exists, unused — previous run created code but crashed before redeem",
code: &RedeemCode{Status: StatusUnused},
err: nil,
expected: redeemActionRedeem,
},
{
name: "code exists but error also set — error takes precedence",
code: &RedeemCode{Status: StatusUsed},
err: errors.New("unexpected"),
expected: redeemActionCreate,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := resolveRedeemAction(tt.code, tt.err)
assert.Equal(t, tt.expected, got)
})
}
}
// ---------------------------------------------------------------------------
// redeemAction enum value sanity
// ---------------------------------------------------------------------------
func TestRedeemAction_DistinctValues(t *testing.T) {
t.Parallel()
// Ensure the three actions have distinct values (iota correctness)
assert.NotEqual(t, redeemActionCreate, redeemActionRedeem)
assert.NotEqual(t, redeemActionCreate, redeemActionSkipCompleted)
assert.NotEqual(t, redeemActionRedeem, redeemActionSkipCompleted)
}
// ---------------------------------------------------------------------------
// RedeemCode.IsUsed / CanUse interaction with resolveRedeemAction
// ---------------------------------------------------------------------------
func TestResolveRedeemAction_IsUsedCanUseConsistency(t *testing.T) {
t.Parallel()
usedCode := &RedeemCode{Status: StatusUsed}
unusedCode := &RedeemCode{Status: StatusUnused}
// Verify our decision function is consistent with the domain model methods
assert.True(t, usedCode.IsUsed())
assert.False(t, usedCode.CanUse())
assert.Equal(t, redeemActionSkipCompleted, resolveRedeemAction(usedCode, nil))
assert.False(t, unusedCode.IsUsed())
assert.True(t, unusedCode.CanUse())
assert.Equal(t, redeemActionRedeem, resolveRedeemAction(unusedCode, nil))
}
func TestExpectedNotificationProviderKeyPrefersOrderInstanceProvider(t *testing.T) {
t.Parallel()
registry := payment.NewRegistry()
registry.Register(paymentFulfillmentTestProvider{
key: payment.TypeAlipay,
supportedTypes: []payment.PaymentType{payment.TypeAlipay},
})
assert.Equal(t,
payment.TypeEasyPay,
expectedNotificationProviderKey(registry, payment.TypeAlipay, "", payment.TypeEasyPay),
)
}
func TestExpectedNotificationProviderKeyUsesRegistryMappingForLegacyOrders(t *testing.T) {
t.Parallel()
registry := payment.NewRegistry()
registry.Register(paymentFulfillmentTestProvider{
key: payment.TypeEasyPay,
supportedTypes: []payment.PaymentType{payment.TypeAlipay},
})
assert.Equal(t,
payment.TypeEasyPay,
expectedNotificationProviderKey(registry, payment.TypeAlipay, "", ""),
)
}
func TestExpectedNotificationProviderKeyFallsBackToPaymentType(t *testing.T) {
t.Parallel()
assert.Equal(t,
payment.TypeWxpay,
expectedNotificationProviderKey(nil, payment.TypeWxpay, "", ""),
)
}
func TestExpectedNotificationProviderKeyPrefersOrderSnapshotProviderKey(t *testing.T) {
t.Parallel()
registry := payment.NewRegistry()
registry.Register(paymentFulfillmentTestProvider{
key: payment.TypeAlipay,
supportedTypes: []payment.PaymentType{payment.TypeAlipay},
})
assert.Equal(t,
payment.TypeEasyPay,
expectedNotificationProviderKey(registry, payment.TypeAlipay, payment.TypeEasyPay, ""),
)
}
func TestExpectedNotificationProviderKeyForOrderUsesSnapshotProviderKey(t *testing.T) {
t.Parallel()
registry := payment.NewRegistry()
registry.Register(paymentFulfillmentTestProvider{
key: payment.TypeAlipay,
supportedTypes: []payment.PaymentType{payment.TypeAlipay},
})
order := &dbent.PaymentOrder{
PaymentType: payment.TypeAlipay,
ProviderSnapshot: map[string]any{
"schema_version": 1,
"provider_key": payment.TypeEasyPay,
},
}
assert.Equal(t,
payment.TypeEasyPay,
expectedNotificationProviderKeyForOrder(registry, order, ""),
)
}
func TestValidateProviderNotificationMetadataRejectsWxpaySnapshotMismatch(t *testing.T) {
t.Parallel()
order := &dbent.PaymentOrder{
PaymentType: payment.TypeWxpay,
ProviderSnapshot: map[string]any{
"schema_version": 1,
"merchant_app_id": "wx-app-expected",
"merchant_id": "mch-expected",
"currency": "CNY",
},
}
err := validateProviderNotificationMetadata(order, payment.TypeWxpay, map[string]string{
"appid": "wx-app-other",
"mchid": "mch-expected",
"currency": "CNY",
"trade_state": "SUCCESS",
})
assert.ErrorContains(t, err, "wxpay appid mismatch")
}
func TestValidateProviderNotificationMetadataAllowsLegacyOrdersWithoutSnapshotFields(t *testing.T) {
t.Parallel()
order := &dbent.PaymentOrder{
PaymentType: payment.TypeWxpay,
ProviderSnapshot: map[string]any{
"schema_version": 1,
"provider_instance_id": "9",
"provider_key": payment.TypeWxpay,
},
}
err := validateProviderNotificationMetadata(order, payment.TypeWxpay, map[string]string{
"appid": "wx-app-runtime",
"mchid": "mch-runtime",
"currency": "CNY",
"trade_state": "SUCCESS",
})
assert.NoError(t, err)
}
func TestParseLegacyPaymentOrderID(t *testing.T) {
t.Parallel()
oid, ok := parseLegacyPaymentOrderID("sub2_42", &dbent.NotFoundError{})
assert.True(t, ok)
assert.EqualValues(t, 42, oid)
_, ok = parseLegacyPaymentOrderID("42", &dbent.NotFoundError{})
assert.False(t, ok)
_, ok = parseLegacyPaymentOrderID("sub2_42", errors.New("db down"))
assert.False(t, ok)
}
func TestIsValidProviderAmount(t *testing.T) {
t.Parallel()
assert.True(t, isValidProviderAmount(0.01))
assert.False(t, isValidProviderAmount(0))
assert.False(t, isValidProviderAmount(-1))
assert.False(t, isValidProviderAmount(math.NaN()))
assert.False(t, isValidProviderAmount(math.Inf(1)))
}
func TestValidateProviderNotificationMetadataRejectsAlipaySnapshotMismatch(t *testing.T) {
t.Parallel()
order := &dbent.PaymentOrder{
PaymentType: payment.TypeAlipay,
ProviderSnapshot: map[string]any{
"schema_version": 2,
"merchant_app_id": "alipay-app-expected",
},
}
err := validateProviderNotificationMetadata(order, payment.TypeAlipay, map[string]string{
"app_id": "alipay-app-other",
})
assert.ErrorContains(t, err, "alipay app_id mismatch")
}
func TestValidateProviderNotificationMetadataRejectsEasyPaySnapshotMismatch(t *testing.T) {
t.Parallel()
order := &dbent.PaymentOrder{
PaymentType: payment.TypeAlipay,
ProviderSnapshot: map[string]any{
"schema_version": 2,
"merchant_id": "pid-expected",
},
}
err := validateProviderNotificationMetadata(order, payment.TypeEasyPay, map[string]string{
"pid": "pid-other",
})
assert.ErrorContains(t, err, "easypay pid mismatch")
}
func TestValidateProviderNotificationMetadataRejectsAirwallexSnapshotMismatch(t *testing.T) {
t.Parallel()
order := &dbent.PaymentOrder{
PaymentType: payment.TypeAirwallex,
ProviderSnapshot: map[string]any{
"schema_version": 2,
"merchant_id": "acct_expected",
"currency": "CNY",
},
}
err := validateProviderNotificationMetadata(order, payment.TypeAirwallex, map[string]string{
"account_id": "acct_other",
"currency": "CNY",
"status": "SUCCEEDED",
})
assert.ErrorContains(t, err, "airwallex account_id mismatch")
err = validateProviderNotificationMetadata(order, payment.TypeAirwallex, map[string]string{
"account_id": "acct_expected",
"currency": "USD",
"status": "SUCCEEDED",
})
assert.ErrorContains(t, err, "airwallex currency mismatch")
}
func TestValidateProviderNotificationMetadataRejectsStripeCurrencyMismatch(t *testing.T) {
t.Parallel()
order := &dbent.PaymentOrder{
PaymentType: payment.TypeStripe,
ProviderSnapshot: map[string]any{
"schema_version": 2,
"currency": "HKD",
},
}
err := validateProviderNotificationMetadata(order, payment.TypeStripe, map[string]string{
"currency": "USD",
})
assert.ErrorContains(t, err, "stripe currency mismatch")
}
func TestPaymentAmountToleranceForThreeDecimalCurrency(t *testing.T) {
t.Parallel()
assert.Equal(t, amountToleranceCNY, paymentAmountToleranceForCurrency("CNY"))
assert.Equal(t, amountToleranceCNY, paymentAmountToleranceForCurrency("JPY"))
assert.InDelta(t, 0.0005, paymentAmountToleranceForCurrency("KWD"), 1e-12)
}
func TestRetryFulfillmentRejectsFreshRechargingLease(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusRecharging, time.Now())
svc := &PaymentService{entClient: client}
err := svc.RetryFulfillment(ctx, order.ID)
require.Error(t, err)
require.Equal(t, "CONFLICT", infraerrors.Reason(err))
reloaded, getErr := client.PaymentOrder.Get(ctx, order.ID)
require.NoError(t, getErr)
require.Equal(t, OrderStatusRecharging, reloaded.Status)
}
func TestAlreadyProcessedRecoversStaleRechargingLease(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
ensurePaymentAuditOrderActionUniqueIndex(t, ctx, client)
order := createPaymentFulfillmentSubscriptionOrder(
t,
ctx,
client,
OrderStatusRecharging,
time.Now().Add(-paymentFulfillmentLeaseDuration-time.Minute),
)
_, err := client.PaymentAuditLog.Create().
SetOrderID(strconv.FormatInt(order.ID, 10)).
SetAction("SUBSCRIPTION_ASSIGNED").
SetDetail(`{"groupID":7,"validityDays":30}`).
SetOperator("system").
Save(ctx)
require.NoError(t, err)
groupRepo := &subscriptionGroupRepoStub{
group: &Group{ID: 7, Status: payment.EntityStatusActive, SubscriptionType: SubscriptionTypeSubscription},
}
svc := &PaymentService{
entClient: client,
groupRepo: groupRepo,
subscriptionSvc: NewSubscriptionService(groupRepo, userSubRepoNoop{}, nil, nil, nil),
}
require.NoError(t, svc.alreadyProcessed(ctx, order))
reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
require.NoError(t, err)
require.Equal(t, OrderStatusCompleted, reloaded.Status)
}
func TestFulfillmentLeaseVersionRejectsStaleWorker(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
staleAt := time.Now().Add(-paymentFulfillmentLeaseDuration - time.Minute)
order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusRecharging, staleAt)
svc := &PaymentService{entClient: client}
firstLease, err := svc.acquirePaymentFulfillmentLease(ctx, order)
require.NoError(t, err)
require.NotNil(t, firstLease)
_, err = client.PaymentOrder.UpdateOneID(order.ID).SetUpdatedAt(staleAt).Save(ctx)
require.NoError(t, err)
time.Sleep(time.Millisecond)
staleOrder, err := client.PaymentOrder.Get(ctx, order.ID)
require.NoError(t, err)
secondLease, err := svc.acquirePaymentFulfillmentLease(ctx, staleOrder)
require.NoError(t, err)
require.NotNil(t, secondLease)
require.False(t, firstLease.version.Equal(secondLease.version))
err = svc.markCompleted(ctx, order, firstLease, "SUBSCRIPTION_SUCCESS")
require.Error(t, err)
require.Equal(t, "CONFLICT", infraerrors.Reason(err))
svc.markFailed(ctx, order.ID, firstLease, errors.New("stale worker failure"))
reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
require.NoError(t, err)
require.Equal(t, OrderStatusRecharging, reloaded.Status)
require.NoError(t, svc.markCompleted(ctx, order, secondLease, "SUBSCRIPTION_SUCCESS"))
}
func TestExecuteBalanceFulfillmentRecoversAfterRedeemWithoutCreditingAgain(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
ensurePaymentAuditOrderActionUniqueIndex(t, ctx, client)
staleAt := time.Now().Add(-paymentFulfillmentLeaseDuration - time.Minute)
order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusRecharging, staleAt)
order, err := client.PaymentOrder.UpdateOneID(order.ID).
SetOrderType(payment.OrderTypeBalance).
ClearPlanID().
ClearSubscriptionGroupID().
ClearSubscriptionDays().
SetUpdatedAt(staleAt).
Save(ctx)
require.NoError(t, err)
redeemRepo := &redeemCodeRepoStub{codesByCode: map[string]*RedeemCode{
order.RechargeCode: {
ID: 101,
Code: order.RechargeCode,
Type: RedeemTypeBalance,
Value: order.Amount,
Status: StatusUsed,
},
}}
svc := &PaymentService{
entClient: client,
redeemService: &RedeemService{redeemRepo: redeemRepo},
}
require.NoError(t, svc.ExecuteBalanceFulfillment(ctx, order.ID))
require.Empty(t, redeemRepo.useCalls, "an already-used order code must not be redeemed again")
reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
require.NoError(t, err)
require.Equal(t, OrderStatusCompleted, reloaded.Status)
}
func TestDuplicatePaymentNotificationDoesNotReprocessCompletedBalanceOrder(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusCompleted, time.Now())
order, err := client.PaymentOrder.UpdateOneID(order.ID).
SetOrderType(payment.OrderTypeBalance).
ClearPlanID().
ClearSubscriptionGroupID().
ClearSubscriptionDays().
Save(ctx)
require.NoError(t, err)
redeemRepo := &redeemCodeRepoStub{codesByCode: map[string]*RedeemCode{
order.RechargeCode: {
ID: 102,
Code: order.RechargeCode,
Type: RedeemTypeBalance,
Value: order.Amount,
Status: StatusUnused,
},
}}
svc := &PaymentService{
entClient: client,
redeemService: &RedeemService{redeemRepo: redeemRepo},
}
notification := &payment.PaymentNotification{
TradeNo: "alipay-trade-replayed",
OrderID: order.OutTradeNo,
Amount: order.PayAmount,
Status: payment.NotificationStatusSuccess,
}
require.NoError(t, svc.HandlePaymentNotification(ctx, notification, payment.TypeAlipay))
require.NoError(t, svc.HandlePaymentNotification(ctx, notification, payment.TypeAlipay))
reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
require.NoError(t, err)
require.Equal(t, OrderStatusCompleted, reloaded.Status)
require.Empty(t, redeemRepo.useCalls, "a duplicate notification must not redeem the balance code again")
}
func TestPaymentNotificationRejectsAmountMismatchBeforeFulfillment(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusPending, time.Now())
order, err := client.PaymentOrder.UpdateOneID(order.ID).
SetOrderType(payment.OrderTypeBalance).
ClearPlanID().
ClearSubscriptionGroupID().
ClearSubscriptionDays().
Save(ctx)
require.NoError(t, err)
svc := &PaymentService{entClient: client}
err = svc.HandlePaymentNotification(ctx, &payment.PaymentNotification{
TradeNo: "alipay-trade-wrong-amount",
OrderID: order.OutTradeNo,
Amount: order.PayAmount - 1,
Status: payment.NotificationStatusSuccess,
}, payment.TypeAlipay)
require.ErrorContains(t, err, "amount mismatch")
reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
require.NoError(t, err)
require.Equal(t, OrderStatusPending, reloaded.Status)
}
func TestExecuteSubscriptionFulfillmentRecoversCommittedAssignmentWithoutExtendingAgain(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
ensurePaymentAuditOrderActionUniqueIndex(t, ctx, client)
staleAt := time.Now().Add(-paymentFulfillmentLeaseDuration - time.Minute)
order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusRecharging, staleAt)
expiresAt := time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second)
subRepo := newSubscriptionUserSubRepoStub()
subRepo.seed(&UserSubscription{
ID: 99,
UserID: order.UserID,
GroupID: *order.SubscriptionGroupID,
StartsAt: time.Now().Add(-time.Hour),
ExpiresAt: expiresAt,
Status: SubscriptionStatusActive,
Notes: "manual note\n" + paymentSubscriptionOrderNote(order.ID) + "\nretained note",
})
groupRepo := &subscriptionGroupRepoStub{
group: &Group{ID: 7, Status: payment.EntityStatusActive, SubscriptionType: SubscriptionTypeSubscription},
}
svc := &PaymentService{
entClient: client,
groupRepo: groupRepo,
subscriptionSvc: NewSubscriptionService(groupRepo, subRepo, nil, nil, nil),
}
require.NoError(t, svc.ExecuteSubscriptionFulfillment(ctx, order.ID))
assertPaymentSubscriptionExpiry(t, subRepo, order, expiresAt)
assignmentAuditCount, err := client.PaymentAuditLog.Query().
Where(
paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)),
paymentauditlog.ActionEQ("SUBSCRIPTION_ASSIGNED"),
).
Count(ctx)
require.NoError(t, err)
require.Equal(t, 1, assignmentAuditCount)
// Simulate another stale recovery attempt after completion. The durable audit
// must make replay a no-op for the subscription entitlement.
_, err = client.PaymentOrder.UpdateOneID(order.ID).
SetStatus(OrderStatusRecharging).
SetUpdatedAt(staleAt).
ClearCompletedAt().
Save(ctx)
require.NoError(t, err)
require.NoError(t, svc.ExecuteSubscriptionFulfillment(ctx, order.ID))
assertPaymentSubscriptionExpiry(t, subRepo, order, expiresAt)
assignmentAuditCount, err = client.PaymentAuditLog.Query().
Where(
paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)),
paymentauditlog.ActionEQ("SUBSCRIPTION_ASSIGNED"),
).
Count(ctx)
require.NoError(t, err)
require.Equal(t, 1, assignmentAuditCount)
}
func TestHasPaymentSubscriptionOrderNoteRequiresIndependentExactLine(t *testing.T) {
t.Parallel()
require.True(t, hasPaymentSubscriptionOrderNote("before\r\npayment order 42\r\nafter", "payment order 42"))
require.False(t, hasPaymentSubscriptionOrderNote("payment order 420", "payment order 42"))
require.False(t, hasPaymentSubscriptionOrderNote("prefix payment order 42 suffix", "payment order 42"))
}
func createPaymentFulfillmentSubscriptionOrder(
t *testing.T,
ctx context.Context,
client *dbent.Client,
status string,
updatedAt time.Time,
) *dbent.PaymentOrder {
t.Helper()
user, err := client.User.Create().
SetEmail("fulfillment-" + strconv.FormatInt(time.Now().UnixNano(), 10) + "@example.com").
SetPasswordHash("hash").
SetUsername("payment-fulfillment-user").
Save(ctx)
require.NoError(t, err)
order, err := client.PaymentOrder.Create().
SetUserID(user.ID).
SetUserEmail(user.Email).
SetUserName(user.Username).
SetAmount(80).
SetPayAmount(80).
SetFeeRate(0).
SetRechargeCode("PAY-SUB-" + strconv.FormatInt(time.Now().UnixNano(), 10)).
SetOutTradeNo("sub2_fulfillment_" + strconv.FormatInt(time.Now().UnixNano(), 10)).
SetPaymentType(payment.TypeAlipay).
SetPaymentTradeNo("trade-fulfillment").
SetOrderType(payment.OrderTypeSubscription).
SetPlanID(100).
SetSubscriptionGroupID(7).
SetSubscriptionDays(30).
SetStatus(status).
SetPaidAt(time.Now().Add(-time.Hour)).
SetExpiresAt(time.Now().Add(time.Hour)).
SetClientIP("127.0.0.1").
SetSrcHost("api.example.com").
SetUpdatedAt(updatedAt).
Save(ctx)
require.NoError(t, err)
return order
}
func assertPaymentSubscriptionExpiry(t *testing.T, repo *subscriptionUserSubRepoStub, order *dbent.PaymentOrder, expected time.Time) {
t.Helper()
sub, err := repo.GetByUserIDAndGroupID(context.Background(), order.UserID, *order.SubscriptionGroupID)
require.NoError(t, err)
require.True(t, sub.ExpiresAt.Equal(expected), "subscription expiry changed from %s to %s", expected, sub.ExpiresAt)
}
func TestExecuteSubscriptionFulfillmentAppliesAffiliateRebate(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
ensurePaymentAuditOrderActionUniqueIndex(t, ctx, client)
user, err := client.User.Create().
SetEmail("subscription-affiliate@example.com").
SetPasswordHash("hash").
SetUsername("subscription-affiliate-user").
Save(ctx)
require.NoError(t, err)
order, err := client.PaymentOrder.Create().
SetUserID(user.ID).
SetUserEmail(user.Email).
SetUserName(user.Username).
SetAmount(9.99).
SetPayAmount(71.36).
SetFeeRate(0).
SetRechargeCode("PAY-SUB-AFFILIATE").
SetOutTradeNo("sub2_subscription_affiliate").
SetPaymentType(payment.TypeAlipay).
SetPaymentTradeNo("trade-sub-affiliate").
SetOrderType(payment.OrderTypeSubscription).
SetPlanID(99).
SetSubscriptionGroupID(7).
SetSubscriptionDays(30).
SetStatus(OrderStatusPaid).
SetExpiresAt(time.Now().Add(time.Hour)).
SetClientIP("127.0.0.1").
SetSrcHost("api.example.com").
Save(ctx)
require.NoError(t, err)
inviterID := int64(9001)
affiliateRepo := &paymentFulfillmentAffiliateRepoStub{
inviteeSummary: &AffiliateSummary{
UserID: user.ID,
AffCode: "INVITEE",
InviterID: &inviterID,
CreatedAt: time.Now().Add(-24 * time.Hour),
},
inviterSummary: &AffiliateSummary{
UserID: inviterID,
AffCode: "INVITER",
CreatedAt: time.Now().Add(-48 * time.Hour),
},
}
settingSvc := NewSettingService(&paymentFulfillmentSettingRepoStub{values: map[string]string{
SettingKeyAffiliateEnabled: "true",
SettingKeyAffiliateRebateRate: "15",
SettingKeyAffiliateRebateFreezeHours: "0",
}}, nil)
subRepo := newSubscriptionUserSubRepoStub()
subscriptionSvc := NewSubscriptionService(&subscriptionGroupRepoStub{
group: &Group{ID: 7, Status: payment.EntityStatusActive, SubscriptionType: SubscriptionTypeSubscription},
}, subRepo, nil, nil, nil)
svc := &PaymentService{
entClient: client,
groupRepo: &subscriptionGroupRepoStub{group: &Group{ID: 7, Status: payment.EntityStatusActive, SubscriptionType: SubscriptionTypeSubscription}},
subscriptionSvc: subscriptionSvc,
affiliateService: NewAffiliateService(affiliateRepo, settingSvc, nil, nil),
}
err = svc.ExecuteSubscriptionFulfillment(ctx, order.ID)
require.NoError(t, err)
reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
require.NoError(t, err)
require.Equal(t, OrderStatusCompleted, reloaded.Status)
require.Len(t, affiliateRepo.accrueCalls, 1)
require.Equal(t, inviterID, affiliateRepo.accrueCalls[0].inviterID)
require.Equal(t, user.ID, affiliateRepo.accrueCalls[0].inviteeUserID)
require.InDelta(t, 1.4985, affiliateRepo.accrueCalls[0].amount, 0.00000001)
require.NotNil(t, affiliateRepo.accrueCalls[0].sourceOrderID)
require.Equal(t, order.ID, *affiliateRepo.accrueCalls[0].sourceOrderID)
require.Equal(t, 1, subRepo.createCalls)
applied, err := client.PaymentAuditLog.Query().
Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("AFFILIATE_REBATE_APPLIED")).
Only(ctx)
require.NoError(t, err)
require.Contains(t, applied.Detail, `"baseAmount":9.99`)
require.Contains(t, applied.Detail, `"rebateAmount":1.4985`)
}
func TestExecuteSubscriptionFulfillmentDoesNotDuplicateWorkAfterLegacySuccessAudit(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
ensurePaymentAuditOrderActionUniqueIndex(t, ctx, client)
user, err := client.User.Create().
SetEmail("subscription-affiliate-idempotent@example.com").
SetPasswordHash("hash").
SetUsername("subscription-affiliate-idempotent-user").
Save(ctx)
require.NoError(t, err)
order, err := client.PaymentOrder.Create().
SetUserID(user.ID).
SetUserEmail(user.Email).
SetUserName(user.Username).
SetAmount(80).
SetPayAmount(80).
SetFeeRate(0).
SetRechargeCode("PAY-SUB-AFFILIATE-IDEMPOTENT").
SetOutTradeNo("sub2_subscription_affiliate_idempotent").
SetPaymentType(payment.TypeAlipay).
SetPaymentTradeNo("trade-sub-affiliate-idempotent").
SetOrderType(payment.OrderTypeSubscription).
SetPlanID(100).
SetSubscriptionGroupID(7).
SetSubscriptionDays(30).
SetStatus(OrderStatusPaid).
SetExpiresAt(time.Now().Add(time.Hour)).
SetClientIP("127.0.0.1").
SetSrcHost("api.example.com").
Save(ctx)
require.NoError(t, err)
_, err = client.PaymentAuditLog.Create().
SetOrderID(strconv.FormatInt(order.ID, 10)).
SetAction("SUBSCRIPTION_SUCCESS").
SetDetail(`{"groupID":7,"validityDays":30}`).
SetOperator("system").
Save(ctx)
require.NoError(t, err)
_, err = client.PaymentAuditLog.Create().
SetOrderID(strconv.FormatInt(order.ID, 10)).
SetAction("AFFILIATE_REBATE_APPLIED").
SetDetail(`{"baseAmount":80,"rebateAmount":16}`).
SetOperator("system").
Save(ctx)
require.NoError(t, err)
inviterID := int64(9001)
affiliateRepo := &paymentFulfillmentAffiliateRepoStub{
inviteeSummary: &AffiliateSummary{
UserID: user.ID,
AffCode: "INVITEE",
InviterID: &inviterID,
CreatedAt: time.Now().Add(-24 * time.Hour),
},
inviterSummary: &AffiliateSummary{
UserID: inviterID,
AffCode: "INVITER",
CreatedAt: time.Now().Add(-48 * time.Hour),
},
}
settingSvc := NewSettingService(&paymentFulfillmentSettingRepoStub{values: map[string]string{
SettingKeyAffiliateEnabled: "true",
SettingKeyAffiliateRebateRate: "20",
}}, nil)
subRepo := newSubscriptionUserSubRepoStub()
subscriptionSvc := NewSubscriptionService(&subscriptionGroupRepoStub{
group: &Group{ID: 7, Status: payment.EntityStatusActive, SubscriptionType: SubscriptionTypeSubscription},
}, subRepo, nil, nil, nil)
svc := &PaymentService{
entClient: client,
groupRepo: &subscriptionGroupRepoStub{group: &Group{ID: 7, Status: payment.EntityStatusActive, SubscriptionType: SubscriptionTypeSubscription}},
subscriptionSvc: subscriptionSvc,
affiliateService: NewAffiliateService(affiliateRepo, settingSvc, nil, nil),
}
err = svc.ExecuteSubscriptionFulfillment(ctx, order.ID)
require.NoError(t, err)
reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
require.NoError(t, err)
require.Equal(t, OrderStatusCompleted, reloaded.Status)
require.Empty(t, affiliateRepo.accrueCalls)
require.Zero(t, subRepo.createCalls)
}
var _ AffiliateRepository = (*paymentFulfillmentAffiliateRepoStub)(nil)
var _ SettingRepository = (*paymentFulfillmentSettingRepoStub)(nil)