| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291 |
- //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
- }
- type paymentFulfillmentRedeemCacheStub struct {
- count int
- getCalls int
- incrementCalls int
- acquireCalls int
- releaseCalls int
- }
- type paymentFulfillmentRedeemRepo struct {
- paymentOrderLifecycleRedeemRepo
- createCalls int
- }
- func (r *paymentFulfillmentRedeemRepo) Create(_ context.Context, code *RedeemCode) error {
- r.createCalls++
- if r.codesByCode == nil {
- r.codesByCode = make(map[string]*RedeemCode)
- }
- cloned := *code
- cloned.ID = int64(100 + r.createCalls)
- code.ID = cloned.ID
- r.codesByCode[cloned.Code] = &cloned
- return nil
- }
- func (c *paymentFulfillmentRedeemCacheStub) GetRedeemAttemptCount(context.Context, int64) (int, error) {
- c.getCalls++
- return c.count, nil
- }
- func (c *paymentFulfillmentRedeemCacheStub) IncrementRedeemAttemptCount(context.Context, int64) error {
- c.incrementCalls++
- c.count++
- return nil
- }
- func (c *paymentFulfillmentRedeemCacheStub) AcquireRedeemLock(context.Context, string, time.Duration) (bool, error) {
- c.acquireCalls++
- return true, nil
- }
- func (c *paymentFulfillmentRedeemCacheStub) ReleaseRedeemLock(context.Context, string) error {
- c.releaseCalls++
- return nil
- }
- 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, err := resolveRedeemAction(nil, ErrRedeemCodeNotFound)
- require.NoError(t, err)
- assert.Equal(t, redeemActionCreate, action, "a missing code should be created")
- }
- func TestResolveRedeemAction_LookupError(t *testing.T) {
- t.Parallel()
- _, err := resolveRedeemAction(nil, errors.New("db connection lost"))
- require.ErrorContains(t, err, "lookup payment redeem code")
- }
- 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}
- _, err := resolveRedeemAction(code, errors.New("partial error"))
- require.ErrorContains(t, err, "lookup payment redeem code")
- }
- func TestResolveRedeemAction_CodeExistsAndUsed(t *testing.T) {
- t.Parallel()
- code := &RedeemCode{
- Code: "test-code-123",
- Status: StatusUsed,
- Type: RedeemTypeBalance,
- Value: 10.0,
- }
- action, err := resolveRedeemAction(code, nil)
- require.NoError(t, err)
- 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, err := resolveRedeemAction(code, nil)
- require.NoError(t, err)
- 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, err := resolveRedeemAction(code, nil)
- require.NoError(t, err)
- 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
- wantErr bool
- }{
- {
- 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 — fail closed",
- code: nil,
- err: errors.New("connection refused"),
- expected: redeemActionCreate,
- wantErr: true,
- },
- {
- 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 — fail closed",
- code: &RedeemCode{Status: StatusUsed},
- err: errors.New("unexpected"),
- expected: redeemActionCreate,
- wantErr: true,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- got, err := resolveRedeemAction(tt.code, tt.err)
- if tt.wantErr {
- require.Error(t, err)
- return
- }
- require.NoError(t, 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())
- usedAction, err := resolveRedeemAction(usedCode, nil)
- require.NoError(t, err)
- assert.Equal(t, redeemActionSkipCompleted, usedAction)
- assert.False(t, unusedCode.IsUsed())
- assert.True(t, unusedCode.CanUse())
- unusedAction, err := resolveRedeemAction(unusedCode, nil)
- require.NoError(t, err)
- assert.Equal(t, redeemActionRedeem, unusedAction)
- }
- func TestValidatePaymentRedeemCode(t *testing.T) {
- t.Parallel()
- userID := int64(42)
- otherUserID := int64(43)
- order := &dbent.PaymentOrder{ID: 7, UserID: userID, RechargeCode: "PAY-7-12345", Amount: 80}
- tests := []struct {
- name string
- code *RedeemCode
- wantErr string
- }{
- {name: "unused code", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusUnused}},
- {name: "used by order user", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusUsed, UsedBy: &userID}},
- {name: "used without user", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusUsed}, wantErr: "user mismatch"},
- {name: "used by another user", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusUsed, UsedBy: &otherUserID}, wantErr: "user mismatch"},
- {name: "wrong code", code: &RedeemCode{Code: "OTHER", Type: RedeemTypeBalance, Value: 80, Status: StatusUnused}, wantErr: "code mismatch"},
- {name: "wrong type", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeConcurrency, Value: 80, Status: StatusUnused}, wantErr: "type mismatch"},
- {name: "wrong amount", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 79, Status: StatusUnused}, wantErr: "amount mismatch"},
- {name: "invalid status", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusDisabled}, wantErr: "invalid status"},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- err := validatePaymentRedeemCode(order, tt.code)
- if tt.wantErr == "" {
- require.NoError(t, err)
- return
- }
- require.ErrorContains(t, err, tt.wantErr)
- })
- }
- }
- 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 TestPublicRedeemStillEnforcesFailureLimit(t *testing.T) {
- cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxFailedAttempts}
- svc := &RedeemService{cache: cache}
- result, err := svc.Redeem(context.Background(), 42, "PUBLIC-CODE")
- require.Nil(t, result)
- require.ErrorIs(t, err, ErrRedeemRateLimited)
- require.Equal(t, 1, cache.getCalls)
- require.Zero(t, cache.incrementCalls)
- require.Zero(t, cache.acquireCalls)
- }
- func TestPublicRedeemStillIncrementsInvalidCodeFailures(t *testing.T) {
- cache := &paymentFulfillmentRedeemCacheStub{}
- redeemRepo := &redeemRejectRepo{code: RedeemCode{Code: "OTHER"}}
- svc := &RedeemService{redeemRepo: redeemRepo, cache: cache}
- result, err := svc.Redeem(context.Background(), 42, "MISSING")
- require.Nil(t, result)
- require.ErrorIs(t, err, ErrRedeemCodeNotFound)
- require.Equal(t, 1, cache.getCalls)
- require.Equal(t, 1, cache.incrementCalls)
- require.Equal(t, 1, cache.count)
- require.Equal(t, 1, cache.acquireCalls)
- require.Equal(t, 1, cache.releaseCalls)
- }
- func TestPaymentRedeemDoesNotIncrementFailureLimit(t *testing.T) {
- ctx := context.Background()
- cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxFailedAttempts}
- redeemRepo := &redeemRejectRepo{code: RedeemCode{
- ID: 1,
- Code: "PAY-EXPIRED",
- Type: RedeemTypeBalance,
- Value: 10,
- Status: StatusExpired,
- }}
- svc := &RedeemService{redeemRepo: redeemRepo, cache: cache}
- result, err := svc.redeemForPaymentFulfillment(ctx, 42, redeemRepo.code.Code)
- require.Nil(t, result)
- require.ErrorIs(t, err, ErrRedeemCodeExpired)
- require.Zero(t, cache.getCalls)
- require.Zero(t, cache.incrementCalls)
- require.Equal(t, 1, cache.acquireCalls)
- require.Equal(t, 1, cache.releaseCalls)
- }
- func TestExecuteBalanceFulfillmentBypassesUserRedeemRateLimit(t *testing.T) {
- ctx := context.Background()
- client := newPaymentConfigServiceTestClient(t)
- ensurePaymentAuditOrderActionUniqueIndex(t, ctx, client)
- order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusPaid, time.Now())
- order, err := client.PaymentOrder.UpdateOneID(order.ID).
- SetOrderType(payment.OrderTypeBalance).
- ClearPlanID().
- ClearSubscriptionGroupID().
- ClearSubscriptionDays().
- Save(ctx)
- require.NoError(t, err)
- redeemRepo := &paymentFulfillmentRedeemRepo{}
- credited := 0.0
- userRepo := &mockUserRepo{getByIDUser: &User{ID: order.UserID, Balance: 0}}
- userRepo.updateBalanceFn = func(_ context.Context, id int64, amount float64) error {
- require.Equal(t, order.UserID, id)
- credited += amount
- return nil
- }
- cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxFailedAttempts}
- redeemService := NewRedeemService(redeemRepo, userRepo, nil, cache, nil, client, nil, nil)
- svc := &PaymentService{entClient: client, redeemService: redeemService, userRepo: userRepo}
- require.NoError(t, svc.ExecuteBalanceFulfillment(ctx, order.ID))
- require.InDelta(t, order.Amount, credited, 1e-8)
- require.Zero(t, cache.getCalls, "trusted payment fulfillment must not read the public failure counter")
- require.Zero(t, cache.incrementCalls, "trusted payment fulfillment must not mutate the public failure counter")
- require.Equal(t, 1, cache.acquireCalls)
- require.Equal(t, 1, cache.releaseCalls)
- require.Equal(t, 1, redeemRepo.createCalls)
- require.Len(t, redeemRepo.useCalls, 1)
- usedCode := redeemRepo.codesByCode[order.RechargeCode]
- require.Equal(t, StatusUsed, usedCode.Status)
- require.NotNil(t, usedCode.UsedBy)
- require.Equal(t, order.UserID, *usedCode.UsedBy)
- reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
- require.NoError(t, err)
- require.Equal(t, OrderStatusCompleted, reloaded.Status)
- }
- 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)
- usedBy := order.UserID
- redeemRepo := &redeemCodeRepoStub{codesByCode: map[string]*RedeemCode{
- order.RechargeCode: {
- ID: 101,
- Code: order.RechargeCode,
- Type: RedeemTypeBalance,
- Value: order.Amount,
- Status: StatusUsed,
- UsedBy: &usedBy,
- },
- }}
- 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 TestExecuteBalanceFulfillmentRejectsCodeUsedByAnotherUser(t *testing.T) {
- ctx := context.Background()
- client := newPaymentConfigServiceTestClient(t)
- ensurePaymentAuditOrderActionUniqueIndex(t, ctx, client)
- order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusPaid, time.Now())
- order, err := client.PaymentOrder.UpdateOneID(order.ID).
- SetOrderType(payment.OrderTypeBalance).
- ClearPlanID().
- ClearSubscriptionGroupID().
- ClearSubscriptionDays().
- Save(ctx)
- require.NoError(t, err)
- otherUserID := order.UserID + 1
- redeemRepo := &redeemCodeRepoStub{codesByCode: map[string]*RedeemCode{
- order.RechargeCode: {
- ID: 101,
- Code: order.RechargeCode,
- Type: RedeemTypeBalance,
- Value: order.Amount,
- Status: StatusUsed,
- UsedBy: &otherUserID,
- },
- }}
- svc := &PaymentService{
- entClient: client,
- redeemService: &RedeemService{redeemRepo: redeemRepo},
- }
- err = svc.ExecuteBalanceFulfillment(ctx, order.ID)
- require.ErrorContains(t, err, "payment redeem code user mismatch")
- require.Empty(t, redeemRepo.useCalls)
- reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
- require.NoError(t, err)
- require.Equal(t, OrderStatusFailed, reloaded.Status)
- require.Nil(t, reloaded.CompletedAt)
- }
- 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)
|