payment_fulfillment_test.go 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "math"
  7. "strconv"
  8. "testing"
  9. "time"
  10. dbent "github.com/Wei-Shaw/sub2api/ent"
  11. "github.com/Wei-Shaw/sub2api/ent/paymentauditlog"
  12. "github.com/Wei-Shaw/sub2api/internal/payment"
  13. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  14. "github.com/stretchr/testify/assert"
  15. "github.com/stretchr/testify/require"
  16. )
  17. type paymentFulfillmentTestProvider struct {
  18. key string
  19. supportedTypes []payment.PaymentType
  20. }
  21. type paymentFulfillmentRedeemCacheStub struct {
  22. count int
  23. getCalls int
  24. incrementCalls int
  25. acquireCalls int
  26. releaseCalls int
  27. }
  28. type paymentFulfillmentRedeemRepo struct {
  29. paymentOrderLifecycleRedeemRepo
  30. createCalls int
  31. }
  32. func (r *paymentFulfillmentRedeemRepo) Create(_ context.Context, code *RedeemCode) error {
  33. r.createCalls++
  34. if r.codesByCode == nil {
  35. r.codesByCode = make(map[string]*RedeemCode)
  36. }
  37. cloned := *code
  38. cloned.ID = int64(100 + r.createCalls)
  39. code.ID = cloned.ID
  40. r.codesByCode[cloned.Code] = &cloned
  41. return nil
  42. }
  43. func (c *paymentFulfillmentRedeemCacheStub) GetRedeemAttemptCount(context.Context, int64) (int, error) {
  44. c.getCalls++
  45. return c.count, nil
  46. }
  47. func (c *paymentFulfillmentRedeemCacheStub) IncrementRedeemAttemptCount(context.Context, int64) error {
  48. c.incrementCalls++
  49. c.count++
  50. return nil
  51. }
  52. func (c *paymentFulfillmentRedeemCacheStub) AcquireRedeemLock(context.Context, string, time.Duration) (bool, error) {
  53. c.acquireCalls++
  54. return true, nil
  55. }
  56. func (c *paymentFulfillmentRedeemCacheStub) ReleaseRedeemLock(context.Context, string) error {
  57. c.releaseCalls++
  58. return nil
  59. }
  60. func (p paymentFulfillmentTestProvider) Name() string { return p.key }
  61. func (p paymentFulfillmentTestProvider) ProviderKey() string { return p.key }
  62. func (p paymentFulfillmentTestProvider) SupportedTypes() []payment.PaymentType {
  63. return p.supportedTypes
  64. }
  65. func (p paymentFulfillmentTestProvider) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
  66. panic("unexpected call")
  67. }
  68. func (p paymentFulfillmentTestProvider) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
  69. panic("unexpected call")
  70. }
  71. func (p paymentFulfillmentTestProvider) VerifyNotification(ctx context.Context, rawBody string, headers map[string]string) (*payment.PaymentNotification, error) {
  72. panic("unexpected call")
  73. }
  74. func (p paymentFulfillmentTestProvider) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
  75. panic("unexpected call")
  76. }
  77. type paymentFulfillmentAffiliateAccrueCall struct {
  78. inviterID int64
  79. inviteeUserID int64
  80. amount float64
  81. freezeHours int
  82. sourceOrderID *int64
  83. }
  84. type paymentFulfillmentAffiliateRepoStub struct {
  85. inviteeSummary *AffiliateSummary
  86. inviterSummary *AffiliateSummary
  87. accrueCalls []paymentFulfillmentAffiliateAccrueCall
  88. }
  89. func (r *paymentFulfillmentAffiliateRepoStub) EnsureUserAffiliate(_ context.Context, userID int64) (*AffiliateSummary, error) {
  90. switch {
  91. case r.inviteeSummary != nil && r.inviteeSummary.UserID == userID:
  92. cp := *r.inviteeSummary
  93. return &cp, nil
  94. case r.inviterSummary != nil && r.inviterSummary.UserID == userID:
  95. cp := *r.inviterSummary
  96. return &cp, nil
  97. default:
  98. return &AffiliateSummary{UserID: userID, AffCode: "AFFTEST", CreatedAt: time.Now().Add(-time.Hour)}, nil
  99. }
  100. }
  101. func (r *paymentFulfillmentAffiliateRepoStub) GetAffiliateByCode(context.Context, string) (*AffiliateSummary, error) {
  102. panic("unexpected GetAffiliateByCode call")
  103. }
  104. func (r *paymentFulfillmentAffiliateRepoStub) BindInviter(context.Context, int64, int64) (bool, error) {
  105. panic("unexpected BindInviter call")
  106. }
  107. func (r *paymentFulfillmentAffiliateRepoStub) AccrueQuota(_ context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int, sourceOrderID *int64) (bool, error) {
  108. var sourceCopy *int64
  109. if sourceOrderID != nil {
  110. v := *sourceOrderID
  111. sourceCopy = &v
  112. }
  113. r.accrueCalls = append(r.accrueCalls, paymentFulfillmentAffiliateAccrueCall{
  114. inviterID: inviterID,
  115. inviteeUserID: inviteeUserID,
  116. amount: amount,
  117. freezeHours: freezeHours,
  118. sourceOrderID: sourceCopy,
  119. })
  120. return true, nil
  121. }
  122. func (r *paymentFulfillmentAffiliateRepoStub) GetAccruedRebateFromInvitee(context.Context, int64, int64) (float64, error) {
  123. return 0, nil
  124. }
  125. func (r *paymentFulfillmentAffiliateRepoStub) ThawFrozenQuota(context.Context, int64) (float64, error) {
  126. panic("unexpected ThawFrozenQuota call")
  127. }
  128. func (r *paymentFulfillmentAffiliateRepoStub) TransferQuotaToBalance(context.Context, int64) (float64, float64, error) {
  129. panic("unexpected TransferQuotaToBalance call")
  130. }
  131. func (r *paymentFulfillmentAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]AffiliateInvitee, error) {
  132. panic("unexpected ListInvitees call")
  133. }
  134. func (r *paymentFulfillmentAffiliateRepoStub) UpdateUserAffCode(context.Context, int64, string) error {
  135. panic("unexpected UpdateUserAffCode call")
  136. }
  137. func (r *paymentFulfillmentAffiliateRepoStub) ResetUserAffCode(context.Context, int64) (string, error) {
  138. panic("unexpected ResetUserAffCode call")
  139. }
  140. func (r *paymentFulfillmentAffiliateRepoStub) SetUserRebateRate(context.Context, int64, *float64) error {
  141. panic("unexpected SetUserRebateRate call")
  142. }
  143. func (r *paymentFulfillmentAffiliateRepoStub) BatchSetUserRebateRate(context.Context, []int64, *float64) error {
  144. panic("unexpected BatchSetUserRebateRate call")
  145. }
  146. func (r *paymentFulfillmentAffiliateRepoStub) ListUsersWithCustomSettings(context.Context, AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error) {
  147. panic("unexpected ListUsersWithCustomSettings call")
  148. }
  149. func (r *paymentFulfillmentAffiliateRepoStub) ListAffiliateInviteRecords(context.Context, AffiliateRecordFilter) ([]AffiliateInviteRecord, int64, error) {
  150. panic("unexpected ListAffiliateInviteRecords call")
  151. }
  152. func (r *paymentFulfillmentAffiliateRepoStub) ListAffiliateRebateRecords(context.Context, AffiliateRecordFilter) ([]AffiliateRebateRecord, int64, error) {
  153. panic("unexpected ListAffiliateRebateRecords call")
  154. }
  155. func (r *paymentFulfillmentAffiliateRepoStub) ListAffiliateTransferRecords(context.Context, AffiliateRecordFilter) ([]AffiliateTransferRecord, int64, error) {
  156. panic("unexpected ListAffiliateTransferRecords call")
  157. }
  158. func (r *paymentFulfillmentAffiliateRepoStub) GetAffiliateUserOverview(context.Context, int64) (*AffiliateUserOverview, error) {
  159. panic("unexpected GetAffiliateUserOverview call")
  160. }
  161. type paymentFulfillmentSettingRepoStub struct {
  162. values map[string]string
  163. }
  164. func (s *paymentFulfillmentSettingRepoStub) Get(context.Context, string) (*Setting, error) {
  165. return nil, ErrSettingNotFound
  166. }
  167. func (s *paymentFulfillmentSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
  168. if s.values == nil {
  169. return "", ErrSettingNotFound
  170. }
  171. value, ok := s.values[key]
  172. if !ok {
  173. return "", ErrSettingNotFound
  174. }
  175. return value, nil
  176. }
  177. func (s *paymentFulfillmentSettingRepoStub) Set(_ context.Context, key, value string) error {
  178. if s.values == nil {
  179. s.values = map[string]string{}
  180. }
  181. s.values[key] = value
  182. return nil
  183. }
  184. func (s *paymentFulfillmentSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
  185. out := make(map[string]string, len(keys))
  186. for _, key := range keys {
  187. out[key] = s.values[key]
  188. }
  189. return out, nil
  190. }
  191. func (s *paymentFulfillmentSettingRepoStub) SetMultiple(_ context.Context, values map[string]string) error {
  192. if s.values == nil {
  193. s.values = map[string]string{}
  194. }
  195. for key, value := range values {
  196. s.values[key] = value
  197. }
  198. return nil
  199. }
  200. func (s *paymentFulfillmentSettingRepoStub) GetAll(context.Context) (map[string]string, error) {
  201. return s.values, nil
  202. }
  203. func (s *paymentFulfillmentSettingRepoStub) Delete(_ context.Context, key string) error {
  204. delete(s.values, key)
  205. return nil
  206. }
  207. func ensurePaymentAuditOrderActionUniqueIndex(t *testing.T, ctx context.Context, client *dbent.Client) {
  208. t.Helper()
  209. _, err := client.ExecContext(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_audit_logs_order_action_uniq ON payment_audit_logs(order_id, action)")
  210. require.NoError(t, err)
  211. }
  212. // ---------------------------------------------------------------------------
  213. // resolveRedeemAction — pure idempotency decision logic
  214. // ---------------------------------------------------------------------------
  215. func TestResolveRedeemAction_CodeNotFound(t *testing.T) {
  216. t.Parallel()
  217. action, err := resolveRedeemAction(nil, ErrRedeemCodeNotFound)
  218. require.NoError(t, err)
  219. assert.Equal(t, redeemActionCreate, action, "a missing code should be created")
  220. }
  221. func TestResolveRedeemAction_LookupError(t *testing.T) {
  222. t.Parallel()
  223. _, err := resolveRedeemAction(nil, errors.New("db connection lost"))
  224. require.ErrorContains(t, err, "lookup payment redeem code")
  225. }
  226. func TestResolveRedeemAction_LookupErrorWithNonNilCode(t *testing.T) {
  227. t.Parallel()
  228. // Edge case: both code and error are non-nil (shouldn't happen in practice,
  229. // but the function should still treat error as authoritative)
  230. code := &RedeemCode{Status: StatusUnused}
  231. _, err := resolveRedeemAction(code, errors.New("partial error"))
  232. require.ErrorContains(t, err, "lookup payment redeem code")
  233. }
  234. func TestResolveRedeemAction_CodeExistsAndUsed(t *testing.T) {
  235. t.Parallel()
  236. code := &RedeemCode{
  237. Code: "test-code-123",
  238. Status: StatusUsed,
  239. Type: RedeemTypeBalance,
  240. Value: 10.0,
  241. }
  242. action, err := resolveRedeemAction(code, nil)
  243. require.NoError(t, err)
  244. assert.Equal(t, redeemActionSkipCompleted, action, "used code should skip to completed")
  245. }
  246. func TestResolveRedeemAction_CodeExistsAndUnused(t *testing.T) {
  247. t.Parallel()
  248. code := &RedeemCode{
  249. Code: "test-code-456",
  250. Status: StatusUnused,
  251. Type: RedeemTypeBalance,
  252. Value: 25.0,
  253. }
  254. action, err := resolveRedeemAction(code, nil)
  255. require.NoError(t, err)
  256. assert.Equal(t, redeemActionRedeem, action, "unused code should skip creation and proceed to redeem")
  257. }
  258. func TestResolveRedeemAction_CodeExistsWithExpiredStatus(t *testing.T) {
  259. t.Parallel()
  260. // A code with a non-standard status (neither "unused" nor "used")
  261. // should NOT be treated as used, so it falls through to redeemActionRedeem.
  262. code := &RedeemCode{
  263. Code: "expired-code",
  264. Status: StatusExpired,
  265. }
  266. action, err := resolveRedeemAction(code, nil)
  267. require.NoError(t, err)
  268. assert.Equal(t, redeemActionRedeem, action, "expired-status code is not IsUsed(), should redeem")
  269. }
  270. // ---------------------------------------------------------------------------
  271. // Table-driven comprehensive test
  272. // ---------------------------------------------------------------------------
  273. func TestResolveRedeemAction_Table(t *testing.T) {
  274. t.Parallel()
  275. tests := []struct {
  276. name string
  277. code *RedeemCode
  278. err error
  279. expected redeemAction
  280. wantErr bool
  281. }{
  282. {
  283. name: "nil code, nil error — first run",
  284. code: nil,
  285. err: nil,
  286. expected: redeemActionCreate,
  287. },
  288. {
  289. name: "nil code, lookup error — treat as not found",
  290. code: nil,
  291. err: ErrRedeemCodeNotFound,
  292. expected: redeemActionCreate,
  293. },
  294. {
  295. name: "nil code, generic DB error — fail closed",
  296. code: nil,
  297. err: errors.New("connection refused"),
  298. expected: redeemActionCreate,
  299. wantErr: true,
  300. },
  301. {
  302. name: "code exists, used — previous run completed redeem",
  303. code: &RedeemCode{Status: StatusUsed},
  304. err: nil,
  305. expected: redeemActionSkipCompleted,
  306. },
  307. {
  308. name: "code exists, unused — previous run created code but crashed before redeem",
  309. code: &RedeemCode{Status: StatusUnused},
  310. err: nil,
  311. expected: redeemActionRedeem,
  312. },
  313. {
  314. name: "code exists but error also set — fail closed",
  315. code: &RedeemCode{Status: StatusUsed},
  316. err: errors.New("unexpected"),
  317. expected: redeemActionCreate,
  318. wantErr: true,
  319. },
  320. }
  321. for _, tt := range tests {
  322. t.Run(tt.name, func(t *testing.T) {
  323. t.Parallel()
  324. got, err := resolveRedeemAction(tt.code, tt.err)
  325. if tt.wantErr {
  326. require.Error(t, err)
  327. return
  328. }
  329. require.NoError(t, err)
  330. assert.Equal(t, tt.expected, got)
  331. })
  332. }
  333. }
  334. // ---------------------------------------------------------------------------
  335. // redeemAction enum value sanity
  336. // ---------------------------------------------------------------------------
  337. func TestRedeemAction_DistinctValues(t *testing.T) {
  338. t.Parallel()
  339. // Ensure the three actions have distinct values (iota correctness)
  340. assert.NotEqual(t, redeemActionCreate, redeemActionRedeem)
  341. assert.NotEqual(t, redeemActionCreate, redeemActionSkipCompleted)
  342. assert.NotEqual(t, redeemActionRedeem, redeemActionSkipCompleted)
  343. }
  344. // ---------------------------------------------------------------------------
  345. // RedeemCode.IsUsed / CanUse interaction with resolveRedeemAction
  346. // ---------------------------------------------------------------------------
  347. func TestResolveRedeemAction_IsUsedCanUseConsistency(t *testing.T) {
  348. t.Parallel()
  349. usedCode := &RedeemCode{Status: StatusUsed}
  350. unusedCode := &RedeemCode{Status: StatusUnused}
  351. // Verify our decision function is consistent with the domain model methods
  352. assert.True(t, usedCode.IsUsed())
  353. assert.False(t, usedCode.CanUse())
  354. usedAction, err := resolveRedeemAction(usedCode, nil)
  355. require.NoError(t, err)
  356. assert.Equal(t, redeemActionSkipCompleted, usedAction)
  357. assert.False(t, unusedCode.IsUsed())
  358. assert.True(t, unusedCode.CanUse())
  359. unusedAction, err := resolveRedeemAction(unusedCode, nil)
  360. require.NoError(t, err)
  361. assert.Equal(t, redeemActionRedeem, unusedAction)
  362. }
  363. func TestValidatePaymentRedeemCode(t *testing.T) {
  364. t.Parallel()
  365. userID := int64(42)
  366. otherUserID := int64(43)
  367. order := &dbent.PaymentOrder{ID: 7, UserID: userID, RechargeCode: "PAY-7-12345", Amount: 80}
  368. tests := []struct {
  369. name string
  370. code *RedeemCode
  371. wantErr string
  372. }{
  373. {name: "unused code", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusUnused}},
  374. {name: "used by order user", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusUsed, UsedBy: &userID}},
  375. {name: "used without user", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusUsed}, wantErr: "user mismatch"},
  376. {name: "used by another user", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusUsed, UsedBy: &otherUserID}, wantErr: "user mismatch"},
  377. {name: "wrong code", code: &RedeemCode{Code: "OTHER", Type: RedeemTypeBalance, Value: 80, Status: StatusUnused}, wantErr: "code mismatch"},
  378. {name: "wrong type", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeConcurrency, Value: 80, Status: StatusUnused}, wantErr: "type mismatch"},
  379. {name: "wrong amount", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 79, Status: StatusUnused}, wantErr: "amount mismatch"},
  380. {name: "invalid status", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusDisabled}, wantErr: "invalid status"},
  381. }
  382. for _, tt := range tests {
  383. t.Run(tt.name, func(t *testing.T) {
  384. err := validatePaymentRedeemCode(order, tt.code)
  385. if tt.wantErr == "" {
  386. require.NoError(t, err)
  387. return
  388. }
  389. require.ErrorContains(t, err, tt.wantErr)
  390. })
  391. }
  392. }
  393. func TestExpectedNotificationProviderKeyPrefersOrderInstanceProvider(t *testing.T) {
  394. t.Parallel()
  395. registry := payment.NewRegistry()
  396. registry.Register(paymentFulfillmentTestProvider{
  397. key: payment.TypeAlipay,
  398. supportedTypes: []payment.PaymentType{payment.TypeAlipay},
  399. })
  400. assert.Equal(t,
  401. payment.TypeEasyPay,
  402. expectedNotificationProviderKey(registry, payment.TypeAlipay, "", payment.TypeEasyPay),
  403. )
  404. }
  405. func TestExpectedNotificationProviderKeyUsesRegistryMappingForLegacyOrders(t *testing.T) {
  406. t.Parallel()
  407. registry := payment.NewRegistry()
  408. registry.Register(paymentFulfillmentTestProvider{
  409. key: payment.TypeEasyPay,
  410. supportedTypes: []payment.PaymentType{payment.TypeAlipay},
  411. })
  412. assert.Equal(t,
  413. payment.TypeEasyPay,
  414. expectedNotificationProviderKey(registry, payment.TypeAlipay, "", ""),
  415. )
  416. }
  417. func TestExpectedNotificationProviderKeyFallsBackToPaymentType(t *testing.T) {
  418. t.Parallel()
  419. assert.Equal(t,
  420. payment.TypeWxpay,
  421. expectedNotificationProviderKey(nil, payment.TypeWxpay, "", ""),
  422. )
  423. }
  424. func TestExpectedNotificationProviderKeyPrefersOrderSnapshotProviderKey(t *testing.T) {
  425. t.Parallel()
  426. registry := payment.NewRegistry()
  427. registry.Register(paymentFulfillmentTestProvider{
  428. key: payment.TypeAlipay,
  429. supportedTypes: []payment.PaymentType{payment.TypeAlipay},
  430. })
  431. assert.Equal(t,
  432. payment.TypeEasyPay,
  433. expectedNotificationProviderKey(registry, payment.TypeAlipay, payment.TypeEasyPay, ""),
  434. )
  435. }
  436. func TestExpectedNotificationProviderKeyForOrderUsesSnapshotProviderKey(t *testing.T) {
  437. t.Parallel()
  438. registry := payment.NewRegistry()
  439. registry.Register(paymentFulfillmentTestProvider{
  440. key: payment.TypeAlipay,
  441. supportedTypes: []payment.PaymentType{payment.TypeAlipay},
  442. })
  443. order := &dbent.PaymentOrder{
  444. PaymentType: payment.TypeAlipay,
  445. ProviderSnapshot: map[string]any{
  446. "schema_version": 1,
  447. "provider_key": payment.TypeEasyPay,
  448. },
  449. }
  450. assert.Equal(t,
  451. payment.TypeEasyPay,
  452. expectedNotificationProviderKeyForOrder(registry, order, ""),
  453. )
  454. }
  455. func TestValidateProviderNotificationMetadataRejectsWxpaySnapshotMismatch(t *testing.T) {
  456. t.Parallel()
  457. order := &dbent.PaymentOrder{
  458. PaymentType: payment.TypeWxpay,
  459. ProviderSnapshot: map[string]any{
  460. "schema_version": 1,
  461. "merchant_app_id": "wx-app-expected",
  462. "merchant_id": "mch-expected",
  463. "currency": "CNY",
  464. },
  465. }
  466. err := validateProviderNotificationMetadata(order, payment.TypeWxpay, map[string]string{
  467. "appid": "wx-app-other",
  468. "mchid": "mch-expected",
  469. "currency": "CNY",
  470. "trade_state": "SUCCESS",
  471. })
  472. assert.ErrorContains(t, err, "wxpay appid mismatch")
  473. }
  474. func TestValidateProviderNotificationMetadataAllowsLegacyOrdersWithoutSnapshotFields(t *testing.T) {
  475. t.Parallel()
  476. order := &dbent.PaymentOrder{
  477. PaymentType: payment.TypeWxpay,
  478. ProviderSnapshot: map[string]any{
  479. "schema_version": 1,
  480. "provider_instance_id": "9",
  481. "provider_key": payment.TypeWxpay,
  482. },
  483. }
  484. err := validateProviderNotificationMetadata(order, payment.TypeWxpay, map[string]string{
  485. "appid": "wx-app-runtime",
  486. "mchid": "mch-runtime",
  487. "currency": "CNY",
  488. "trade_state": "SUCCESS",
  489. })
  490. assert.NoError(t, err)
  491. }
  492. func TestParseLegacyPaymentOrderID(t *testing.T) {
  493. t.Parallel()
  494. oid, ok := parseLegacyPaymentOrderID("sub2_42", &dbent.NotFoundError{})
  495. assert.True(t, ok)
  496. assert.EqualValues(t, 42, oid)
  497. _, ok = parseLegacyPaymentOrderID("42", &dbent.NotFoundError{})
  498. assert.False(t, ok)
  499. _, ok = parseLegacyPaymentOrderID("sub2_42", errors.New("db down"))
  500. assert.False(t, ok)
  501. }
  502. func TestIsValidProviderAmount(t *testing.T) {
  503. t.Parallel()
  504. assert.True(t, isValidProviderAmount(0.01))
  505. assert.False(t, isValidProviderAmount(0))
  506. assert.False(t, isValidProviderAmount(-1))
  507. assert.False(t, isValidProviderAmount(math.NaN()))
  508. assert.False(t, isValidProviderAmount(math.Inf(1)))
  509. }
  510. func TestValidateProviderNotificationMetadataRejectsAlipaySnapshotMismatch(t *testing.T) {
  511. t.Parallel()
  512. order := &dbent.PaymentOrder{
  513. PaymentType: payment.TypeAlipay,
  514. ProviderSnapshot: map[string]any{
  515. "schema_version": 2,
  516. "merchant_app_id": "alipay-app-expected",
  517. },
  518. }
  519. err := validateProviderNotificationMetadata(order, payment.TypeAlipay, map[string]string{
  520. "app_id": "alipay-app-other",
  521. })
  522. assert.ErrorContains(t, err, "alipay app_id mismatch")
  523. }
  524. func TestValidateProviderNotificationMetadataRejectsEasyPaySnapshotMismatch(t *testing.T) {
  525. t.Parallel()
  526. order := &dbent.PaymentOrder{
  527. PaymentType: payment.TypeAlipay,
  528. ProviderSnapshot: map[string]any{
  529. "schema_version": 2,
  530. "merchant_id": "pid-expected",
  531. },
  532. }
  533. err := validateProviderNotificationMetadata(order, payment.TypeEasyPay, map[string]string{
  534. "pid": "pid-other",
  535. })
  536. assert.ErrorContains(t, err, "easypay pid mismatch")
  537. }
  538. func TestValidateProviderNotificationMetadataRejectsAirwallexSnapshotMismatch(t *testing.T) {
  539. t.Parallel()
  540. order := &dbent.PaymentOrder{
  541. PaymentType: payment.TypeAirwallex,
  542. ProviderSnapshot: map[string]any{
  543. "schema_version": 2,
  544. "merchant_id": "acct_expected",
  545. "currency": "CNY",
  546. },
  547. }
  548. err := validateProviderNotificationMetadata(order, payment.TypeAirwallex, map[string]string{
  549. "account_id": "acct_other",
  550. "currency": "CNY",
  551. "status": "SUCCEEDED",
  552. })
  553. assert.ErrorContains(t, err, "airwallex account_id mismatch")
  554. err = validateProviderNotificationMetadata(order, payment.TypeAirwallex, map[string]string{
  555. "account_id": "acct_expected",
  556. "currency": "USD",
  557. "status": "SUCCEEDED",
  558. })
  559. assert.ErrorContains(t, err, "airwallex currency mismatch")
  560. }
  561. func TestValidateProviderNotificationMetadataRejectsStripeCurrencyMismatch(t *testing.T) {
  562. t.Parallel()
  563. order := &dbent.PaymentOrder{
  564. PaymentType: payment.TypeStripe,
  565. ProviderSnapshot: map[string]any{
  566. "schema_version": 2,
  567. "currency": "HKD",
  568. },
  569. }
  570. err := validateProviderNotificationMetadata(order, payment.TypeStripe, map[string]string{
  571. "currency": "USD",
  572. })
  573. assert.ErrorContains(t, err, "stripe currency mismatch")
  574. }
  575. func TestPaymentAmountToleranceForThreeDecimalCurrency(t *testing.T) {
  576. t.Parallel()
  577. assert.Equal(t, amountToleranceCNY, paymentAmountToleranceForCurrency("CNY"))
  578. assert.Equal(t, amountToleranceCNY, paymentAmountToleranceForCurrency("JPY"))
  579. assert.InDelta(t, 0.0005, paymentAmountToleranceForCurrency("KWD"), 1e-12)
  580. }
  581. func TestRetryFulfillmentRejectsFreshRechargingLease(t *testing.T) {
  582. ctx := context.Background()
  583. client := newPaymentConfigServiceTestClient(t)
  584. order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusRecharging, time.Now())
  585. svc := &PaymentService{entClient: client}
  586. err := svc.RetryFulfillment(ctx, order.ID)
  587. require.Error(t, err)
  588. require.Equal(t, "CONFLICT", infraerrors.Reason(err))
  589. reloaded, getErr := client.PaymentOrder.Get(ctx, order.ID)
  590. require.NoError(t, getErr)
  591. require.Equal(t, OrderStatusRecharging, reloaded.Status)
  592. }
  593. func TestAlreadyProcessedRecoversStaleRechargingLease(t *testing.T) {
  594. ctx := context.Background()
  595. client := newPaymentConfigServiceTestClient(t)
  596. ensurePaymentAuditOrderActionUniqueIndex(t, ctx, client)
  597. order := createPaymentFulfillmentSubscriptionOrder(
  598. t,
  599. ctx,
  600. client,
  601. OrderStatusRecharging,
  602. time.Now().Add(-paymentFulfillmentLeaseDuration-time.Minute),
  603. )
  604. _, err := client.PaymentAuditLog.Create().
  605. SetOrderID(strconv.FormatInt(order.ID, 10)).
  606. SetAction("SUBSCRIPTION_ASSIGNED").
  607. SetDetail(`{"groupID":7,"validityDays":30}`).
  608. SetOperator("system").
  609. Save(ctx)
  610. require.NoError(t, err)
  611. groupRepo := &subscriptionGroupRepoStub{
  612. group: &Group{ID: 7, Status: payment.EntityStatusActive, SubscriptionType: SubscriptionTypeSubscription},
  613. }
  614. svc := &PaymentService{
  615. entClient: client,
  616. groupRepo: groupRepo,
  617. subscriptionSvc: NewSubscriptionService(groupRepo, userSubRepoNoop{}, nil, nil, nil),
  618. }
  619. require.NoError(t, svc.alreadyProcessed(ctx, order))
  620. reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
  621. require.NoError(t, err)
  622. require.Equal(t, OrderStatusCompleted, reloaded.Status)
  623. }
  624. func TestFulfillmentLeaseVersionRejectsStaleWorker(t *testing.T) {
  625. ctx := context.Background()
  626. client := newPaymentConfigServiceTestClient(t)
  627. staleAt := time.Now().Add(-paymentFulfillmentLeaseDuration - time.Minute)
  628. order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusRecharging, staleAt)
  629. svc := &PaymentService{entClient: client}
  630. firstLease, err := svc.acquirePaymentFulfillmentLease(ctx, order)
  631. require.NoError(t, err)
  632. require.NotNil(t, firstLease)
  633. _, err = client.PaymentOrder.UpdateOneID(order.ID).SetUpdatedAt(staleAt).Save(ctx)
  634. require.NoError(t, err)
  635. time.Sleep(time.Millisecond)
  636. staleOrder, err := client.PaymentOrder.Get(ctx, order.ID)
  637. require.NoError(t, err)
  638. secondLease, err := svc.acquirePaymentFulfillmentLease(ctx, staleOrder)
  639. require.NoError(t, err)
  640. require.NotNil(t, secondLease)
  641. require.False(t, firstLease.version.Equal(secondLease.version))
  642. err = svc.markCompleted(ctx, order, firstLease, "SUBSCRIPTION_SUCCESS")
  643. require.Error(t, err)
  644. require.Equal(t, "CONFLICT", infraerrors.Reason(err))
  645. svc.markFailed(ctx, order.ID, firstLease, errors.New("stale worker failure"))
  646. reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
  647. require.NoError(t, err)
  648. require.Equal(t, OrderStatusRecharging, reloaded.Status)
  649. require.NoError(t, svc.markCompleted(ctx, order, secondLease, "SUBSCRIPTION_SUCCESS"))
  650. }
  651. func TestPublicRedeemStillEnforcesFailureLimit(t *testing.T) {
  652. cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxFailedAttempts}
  653. svc := &RedeemService{cache: cache}
  654. result, err := svc.Redeem(context.Background(), 42, "PUBLIC-CODE")
  655. require.Nil(t, result)
  656. require.ErrorIs(t, err, ErrRedeemRateLimited)
  657. require.Equal(t, 1, cache.getCalls)
  658. require.Zero(t, cache.incrementCalls)
  659. require.Zero(t, cache.acquireCalls)
  660. }
  661. func TestPublicRedeemStillIncrementsInvalidCodeFailures(t *testing.T) {
  662. cache := &paymentFulfillmentRedeemCacheStub{}
  663. redeemRepo := &redeemRejectRepo{code: RedeemCode{Code: "OTHER"}}
  664. svc := &RedeemService{redeemRepo: redeemRepo, cache: cache}
  665. result, err := svc.Redeem(context.Background(), 42, "MISSING")
  666. require.Nil(t, result)
  667. require.ErrorIs(t, err, ErrRedeemCodeNotFound)
  668. require.Equal(t, 1, cache.getCalls)
  669. require.Equal(t, 1, cache.incrementCalls)
  670. require.Equal(t, 1, cache.count)
  671. require.Equal(t, 1, cache.acquireCalls)
  672. require.Equal(t, 1, cache.releaseCalls)
  673. }
  674. func TestPaymentRedeemDoesNotIncrementFailureLimit(t *testing.T) {
  675. ctx := context.Background()
  676. cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxFailedAttempts}
  677. redeemRepo := &redeemRejectRepo{code: RedeemCode{
  678. ID: 1,
  679. Code: "PAY-EXPIRED",
  680. Type: RedeemTypeBalance,
  681. Value: 10,
  682. Status: StatusExpired,
  683. }}
  684. svc := &RedeemService{redeemRepo: redeemRepo, cache: cache}
  685. result, err := svc.redeemForPaymentFulfillment(ctx, 42, redeemRepo.code.Code)
  686. require.Nil(t, result)
  687. require.ErrorIs(t, err, ErrRedeemCodeExpired)
  688. require.Zero(t, cache.getCalls)
  689. require.Zero(t, cache.incrementCalls)
  690. require.Equal(t, 1, cache.acquireCalls)
  691. require.Equal(t, 1, cache.releaseCalls)
  692. }
  693. func TestExecuteBalanceFulfillmentBypassesUserRedeemRateLimit(t *testing.T) {
  694. ctx := context.Background()
  695. client := newPaymentConfigServiceTestClient(t)
  696. ensurePaymentAuditOrderActionUniqueIndex(t, ctx, client)
  697. order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusPaid, time.Now())
  698. order, err := client.PaymentOrder.UpdateOneID(order.ID).
  699. SetOrderType(payment.OrderTypeBalance).
  700. ClearPlanID().
  701. ClearSubscriptionGroupID().
  702. ClearSubscriptionDays().
  703. Save(ctx)
  704. require.NoError(t, err)
  705. redeemRepo := &paymentFulfillmentRedeemRepo{}
  706. credited := 0.0
  707. userRepo := &mockUserRepo{getByIDUser: &User{ID: order.UserID, Balance: 0}}
  708. userRepo.updateBalanceFn = func(_ context.Context, id int64, amount float64) error {
  709. require.Equal(t, order.UserID, id)
  710. credited += amount
  711. return nil
  712. }
  713. cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxFailedAttempts}
  714. redeemService := NewRedeemService(redeemRepo, userRepo, nil, cache, nil, client, nil, nil)
  715. svc := &PaymentService{entClient: client, redeemService: redeemService, userRepo: userRepo}
  716. require.NoError(t, svc.ExecuteBalanceFulfillment(ctx, order.ID))
  717. require.InDelta(t, order.Amount, credited, 1e-8)
  718. require.Zero(t, cache.getCalls, "trusted payment fulfillment must not read the public failure counter")
  719. require.Zero(t, cache.incrementCalls, "trusted payment fulfillment must not mutate the public failure counter")
  720. require.Equal(t, 1, cache.acquireCalls)
  721. require.Equal(t, 1, cache.releaseCalls)
  722. require.Equal(t, 1, redeemRepo.createCalls)
  723. require.Len(t, redeemRepo.useCalls, 1)
  724. usedCode := redeemRepo.codesByCode[order.RechargeCode]
  725. require.Equal(t, StatusUsed, usedCode.Status)
  726. require.NotNil(t, usedCode.UsedBy)
  727. require.Equal(t, order.UserID, *usedCode.UsedBy)
  728. reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
  729. require.NoError(t, err)
  730. require.Equal(t, OrderStatusCompleted, reloaded.Status)
  731. }
  732. func TestExecuteBalanceFulfillmentRecoversAfterRedeemWithoutCreditingAgain(t *testing.T) {
  733. ctx := context.Background()
  734. client := newPaymentConfigServiceTestClient(t)
  735. ensurePaymentAuditOrderActionUniqueIndex(t, ctx, client)
  736. staleAt := time.Now().Add(-paymentFulfillmentLeaseDuration - time.Minute)
  737. order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusRecharging, staleAt)
  738. order, err := client.PaymentOrder.UpdateOneID(order.ID).
  739. SetOrderType(payment.OrderTypeBalance).
  740. ClearPlanID().
  741. ClearSubscriptionGroupID().
  742. ClearSubscriptionDays().
  743. SetUpdatedAt(staleAt).
  744. Save(ctx)
  745. require.NoError(t, err)
  746. usedBy := order.UserID
  747. redeemRepo := &redeemCodeRepoStub{codesByCode: map[string]*RedeemCode{
  748. order.RechargeCode: {
  749. ID: 101,
  750. Code: order.RechargeCode,
  751. Type: RedeemTypeBalance,
  752. Value: order.Amount,
  753. Status: StatusUsed,
  754. UsedBy: &usedBy,
  755. },
  756. }}
  757. svc := &PaymentService{
  758. entClient: client,
  759. redeemService: &RedeemService{redeemRepo: redeemRepo},
  760. }
  761. require.NoError(t, svc.ExecuteBalanceFulfillment(ctx, order.ID))
  762. require.Empty(t, redeemRepo.useCalls, "an already-used order code must not be redeemed again")
  763. reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
  764. require.NoError(t, err)
  765. require.Equal(t, OrderStatusCompleted, reloaded.Status)
  766. }
  767. func TestDuplicatePaymentNotificationDoesNotReprocessCompletedBalanceOrder(t *testing.T) {
  768. ctx := context.Background()
  769. client := newPaymentConfigServiceTestClient(t)
  770. order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusCompleted, time.Now())
  771. order, err := client.PaymentOrder.UpdateOneID(order.ID).
  772. SetOrderType(payment.OrderTypeBalance).
  773. ClearPlanID().
  774. ClearSubscriptionGroupID().
  775. ClearSubscriptionDays().
  776. Save(ctx)
  777. require.NoError(t, err)
  778. redeemRepo := &redeemCodeRepoStub{codesByCode: map[string]*RedeemCode{
  779. order.RechargeCode: {
  780. ID: 102,
  781. Code: order.RechargeCode,
  782. Type: RedeemTypeBalance,
  783. Value: order.Amount,
  784. Status: StatusUnused,
  785. },
  786. }}
  787. svc := &PaymentService{
  788. entClient: client,
  789. redeemService: &RedeemService{redeemRepo: redeemRepo},
  790. }
  791. notification := &payment.PaymentNotification{
  792. TradeNo: "alipay-trade-replayed",
  793. OrderID: order.OutTradeNo,
  794. Amount: order.PayAmount,
  795. Status: payment.NotificationStatusSuccess,
  796. }
  797. require.NoError(t, svc.HandlePaymentNotification(ctx, notification, payment.TypeAlipay))
  798. require.NoError(t, svc.HandlePaymentNotification(ctx, notification, payment.TypeAlipay))
  799. reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
  800. require.NoError(t, err)
  801. require.Equal(t, OrderStatusCompleted, reloaded.Status)
  802. require.Empty(t, redeemRepo.useCalls, "a duplicate notification must not redeem the balance code again")
  803. }
  804. func TestPaymentNotificationRejectsAmountMismatchBeforeFulfillment(t *testing.T) {
  805. ctx := context.Background()
  806. client := newPaymentConfigServiceTestClient(t)
  807. order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusPending, time.Now())
  808. order, err := client.PaymentOrder.UpdateOneID(order.ID).
  809. SetOrderType(payment.OrderTypeBalance).
  810. ClearPlanID().
  811. ClearSubscriptionGroupID().
  812. ClearSubscriptionDays().
  813. Save(ctx)
  814. require.NoError(t, err)
  815. svc := &PaymentService{entClient: client}
  816. err = svc.HandlePaymentNotification(ctx, &payment.PaymentNotification{
  817. TradeNo: "alipay-trade-wrong-amount",
  818. OrderID: order.OutTradeNo,
  819. Amount: order.PayAmount - 1,
  820. Status: payment.NotificationStatusSuccess,
  821. }, payment.TypeAlipay)
  822. require.ErrorContains(t, err, "amount mismatch")
  823. reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
  824. require.NoError(t, err)
  825. require.Equal(t, OrderStatusPending, reloaded.Status)
  826. }
  827. func TestExecuteBalanceFulfillmentRejectsCodeUsedByAnotherUser(t *testing.T) {
  828. ctx := context.Background()
  829. client := newPaymentConfigServiceTestClient(t)
  830. ensurePaymentAuditOrderActionUniqueIndex(t, ctx, client)
  831. order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusPaid, time.Now())
  832. order, err := client.PaymentOrder.UpdateOneID(order.ID).
  833. SetOrderType(payment.OrderTypeBalance).
  834. ClearPlanID().
  835. ClearSubscriptionGroupID().
  836. ClearSubscriptionDays().
  837. Save(ctx)
  838. require.NoError(t, err)
  839. otherUserID := order.UserID + 1
  840. redeemRepo := &redeemCodeRepoStub{codesByCode: map[string]*RedeemCode{
  841. order.RechargeCode: {
  842. ID: 101,
  843. Code: order.RechargeCode,
  844. Type: RedeemTypeBalance,
  845. Value: order.Amount,
  846. Status: StatusUsed,
  847. UsedBy: &otherUserID,
  848. },
  849. }}
  850. svc := &PaymentService{
  851. entClient: client,
  852. redeemService: &RedeemService{redeemRepo: redeemRepo},
  853. }
  854. err = svc.ExecuteBalanceFulfillment(ctx, order.ID)
  855. require.ErrorContains(t, err, "payment redeem code user mismatch")
  856. require.Empty(t, redeemRepo.useCalls)
  857. reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
  858. require.NoError(t, err)
  859. require.Equal(t, OrderStatusFailed, reloaded.Status)
  860. require.Nil(t, reloaded.CompletedAt)
  861. }
  862. func TestExecuteSubscriptionFulfillmentRecoversCommittedAssignmentWithoutExtendingAgain(t *testing.T) {
  863. ctx := context.Background()
  864. client := newPaymentConfigServiceTestClient(t)
  865. ensurePaymentAuditOrderActionUniqueIndex(t, ctx, client)
  866. staleAt := time.Now().Add(-paymentFulfillmentLeaseDuration - time.Minute)
  867. order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusRecharging, staleAt)
  868. expiresAt := time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second)
  869. subRepo := newSubscriptionUserSubRepoStub()
  870. subRepo.seed(&UserSubscription{
  871. ID: 99,
  872. UserID: order.UserID,
  873. GroupID: *order.SubscriptionGroupID,
  874. StartsAt: time.Now().Add(-time.Hour),
  875. ExpiresAt: expiresAt,
  876. Status: SubscriptionStatusActive,
  877. Notes: "manual note\n" + paymentSubscriptionOrderNote(order.ID) + "\nretained note",
  878. })
  879. groupRepo := &subscriptionGroupRepoStub{
  880. group: &Group{ID: 7, Status: payment.EntityStatusActive, SubscriptionType: SubscriptionTypeSubscription},
  881. }
  882. svc := &PaymentService{
  883. entClient: client,
  884. groupRepo: groupRepo,
  885. subscriptionSvc: NewSubscriptionService(groupRepo, subRepo, nil, nil, nil),
  886. }
  887. require.NoError(t, svc.ExecuteSubscriptionFulfillment(ctx, order.ID))
  888. assertPaymentSubscriptionExpiry(t, subRepo, order, expiresAt)
  889. assignmentAuditCount, err := client.PaymentAuditLog.Query().
  890. Where(
  891. paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)),
  892. paymentauditlog.ActionEQ("SUBSCRIPTION_ASSIGNED"),
  893. ).
  894. Count(ctx)
  895. require.NoError(t, err)
  896. require.Equal(t, 1, assignmentAuditCount)
  897. // Simulate another stale recovery attempt after completion. The durable audit
  898. // must make replay a no-op for the subscription entitlement.
  899. _, err = client.PaymentOrder.UpdateOneID(order.ID).
  900. SetStatus(OrderStatusRecharging).
  901. SetUpdatedAt(staleAt).
  902. ClearCompletedAt().
  903. Save(ctx)
  904. require.NoError(t, err)
  905. require.NoError(t, svc.ExecuteSubscriptionFulfillment(ctx, order.ID))
  906. assertPaymentSubscriptionExpiry(t, subRepo, order, expiresAt)
  907. assignmentAuditCount, err = client.PaymentAuditLog.Query().
  908. Where(
  909. paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)),
  910. paymentauditlog.ActionEQ("SUBSCRIPTION_ASSIGNED"),
  911. ).
  912. Count(ctx)
  913. require.NoError(t, err)
  914. require.Equal(t, 1, assignmentAuditCount)
  915. }
  916. func TestHasPaymentSubscriptionOrderNoteRequiresIndependentExactLine(t *testing.T) {
  917. t.Parallel()
  918. require.True(t, hasPaymentSubscriptionOrderNote("before\r\npayment order 42\r\nafter", "payment order 42"))
  919. require.False(t, hasPaymentSubscriptionOrderNote("payment order 420", "payment order 42"))
  920. require.False(t, hasPaymentSubscriptionOrderNote("prefix payment order 42 suffix", "payment order 42"))
  921. }
  922. func createPaymentFulfillmentSubscriptionOrder(
  923. t *testing.T,
  924. ctx context.Context,
  925. client *dbent.Client,
  926. status string,
  927. updatedAt time.Time,
  928. ) *dbent.PaymentOrder {
  929. t.Helper()
  930. user, err := client.User.Create().
  931. SetEmail("fulfillment-" + strconv.FormatInt(time.Now().UnixNano(), 10) + "@example.com").
  932. SetPasswordHash("hash").
  933. SetUsername("payment-fulfillment-user").
  934. Save(ctx)
  935. require.NoError(t, err)
  936. order, err := client.PaymentOrder.Create().
  937. SetUserID(user.ID).
  938. SetUserEmail(user.Email).
  939. SetUserName(user.Username).
  940. SetAmount(80).
  941. SetPayAmount(80).
  942. SetFeeRate(0).
  943. SetRechargeCode("PAY-SUB-" + strconv.FormatInt(time.Now().UnixNano(), 10)).
  944. SetOutTradeNo("sub2_fulfillment_" + strconv.FormatInt(time.Now().UnixNano(), 10)).
  945. SetPaymentType(payment.TypeAlipay).
  946. SetPaymentTradeNo("trade-fulfillment").
  947. SetOrderType(payment.OrderTypeSubscription).
  948. SetPlanID(100).
  949. SetSubscriptionGroupID(7).
  950. SetSubscriptionDays(30).
  951. SetStatus(status).
  952. SetPaidAt(time.Now().Add(-time.Hour)).
  953. SetExpiresAt(time.Now().Add(time.Hour)).
  954. SetClientIP("127.0.0.1").
  955. SetSrcHost("api.example.com").
  956. SetUpdatedAt(updatedAt).
  957. Save(ctx)
  958. require.NoError(t, err)
  959. return order
  960. }
  961. func assertPaymentSubscriptionExpiry(t *testing.T, repo *subscriptionUserSubRepoStub, order *dbent.PaymentOrder, expected time.Time) {
  962. t.Helper()
  963. sub, err := repo.GetByUserIDAndGroupID(context.Background(), order.UserID, *order.SubscriptionGroupID)
  964. require.NoError(t, err)
  965. require.True(t, sub.ExpiresAt.Equal(expected), "subscription expiry changed from %s to %s", expected, sub.ExpiresAt)
  966. }
  967. func TestExecuteSubscriptionFulfillmentAppliesAffiliateRebate(t *testing.T) {
  968. ctx := context.Background()
  969. client := newPaymentConfigServiceTestClient(t)
  970. ensurePaymentAuditOrderActionUniqueIndex(t, ctx, client)
  971. user, err := client.User.Create().
  972. SetEmail("subscription-affiliate@example.com").
  973. SetPasswordHash("hash").
  974. SetUsername("subscription-affiliate-user").
  975. Save(ctx)
  976. require.NoError(t, err)
  977. order, err := client.PaymentOrder.Create().
  978. SetUserID(user.ID).
  979. SetUserEmail(user.Email).
  980. SetUserName(user.Username).
  981. SetAmount(9.99).
  982. SetPayAmount(71.36).
  983. SetFeeRate(0).
  984. SetRechargeCode("PAY-SUB-AFFILIATE").
  985. SetOutTradeNo("sub2_subscription_affiliate").
  986. SetPaymentType(payment.TypeAlipay).
  987. SetPaymentTradeNo("trade-sub-affiliate").
  988. SetOrderType(payment.OrderTypeSubscription).
  989. SetPlanID(99).
  990. SetSubscriptionGroupID(7).
  991. SetSubscriptionDays(30).
  992. SetStatus(OrderStatusPaid).
  993. SetExpiresAt(time.Now().Add(time.Hour)).
  994. SetClientIP("127.0.0.1").
  995. SetSrcHost("api.example.com").
  996. Save(ctx)
  997. require.NoError(t, err)
  998. inviterID := int64(9001)
  999. affiliateRepo := &paymentFulfillmentAffiliateRepoStub{
  1000. inviteeSummary: &AffiliateSummary{
  1001. UserID: user.ID,
  1002. AffCode: "INVITEE",
  1003. InviterID: &inviterID,
  1004. CreatedAt: time.Now().Add(-24 * time.Hour),
  1005. },
  1006. inviterSummary: &AffiliateSummary{
  1007. UserID: inviterID,
  1008. AffCode: "INVITER",
  1009. CreatedAt: time.Now().Add(-48 * time.Hour),
  1010. },
  1011. }
  1012. settingSvc := NewSettingService(&paymentFulfillmentSettingRepoStub{values: map[string]string{
  1013. SettingKeyAffiliateEnabled: "true",
  1014. SettingKeyAffiliateRebateRate: "15",
  1015. SettingKeyAffiliateRebateFreezeHours: "0",
  1016. }}, nil)
  1017. subRepo := newSubscriptionUserSubRepoStub()
  1018. subscriptionSvc := NewSubscriptionService(&subscriptionGroupRepoStub{
  1019. group: &Group{ID: 7, Status: payment.EntityStatusActive, SubscriptionType: SubscriptionTypeSubscription},
  1020. }, subRepo, nil, nil, nil)
  1021. svc := &PaymentService{
  1022. entClient: client,
  1023. groupRepo: &subscriptionGroupRepoStub{group: &Group{ID: 7, Status: payment.EntityStatusActive, SubscriptionType: SubscriptionTypeSubscription}},
  1024. subscriptionSvc: subscriptionSvc,
  1025. affiliateService: NewAffiliateService(affiliateRepo, settingSvc, nil, nil),
  1026. }
  1027. err = svc.ExecuteSubscriptionFulfillment(ctx, order.ID)
  1028. require.NoError(t, err)
  1029. reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
  1030. require.NoError(t, err)
  1031. require.Equal(t, OrderStatusCompleted, reloaded.Status)
  1032. require.Len(t, affiliateRepo.accrueCalls, 1)
  1033. require.Equal(t, inviterID, affiliateRepo.accrueCalls[0].inviterID)
  1034. require.Equal(t, user.ID, affiliateRepo.accrueCalls[0].inviteeUserID)
  1035. require.InDelta(t, 1.4985, affiliateRepo.accrueCalls[0].amount, 0.00000001)
  1036. require.NotNil(t, affiliateRepo.accrueCalls[0].sourceOrderID)
  1037. require.Equal(t, order.ID, *affiliateRepo.accrueCalls[0].sourceOrderID)
  1038. require.Equal(t, 1, subRepo.createCalls)
  1039. applied, err := client.PaymentAuditLog.Query().
  1040. Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("AFFILIATE_REBATE_APPLIED")).
  1041. Only(ctx)
  1042. require.NoError(t, err)
  1043. require.Contains(t, applied.Detail, `"baseAmount":9.99`)
  1044. require.Contains(t, applied.Detail, `"rebateAmount":1.4985`)
  1045. }
  1046. func TestExecuteSubscriptionFulfillmentDoesNotDuplicateWorkAfterLegacySuccessAudit(t *testing.T) {
  1047. ctx := context.Background()
  1048. client := newPaymentConfigServiceTestClient(t)
  1049. ensurePaymentAuditOrderActionUniqueIndex(t, ctx, client)
  1050. user, err := client.User.Create().
  1051. SetEmail("subscription-affiliate-idempotent@example.com").
  1052. SetPasswordHash("hash").
  1053. SetUsername("subscription-affiliate-idempotent-user").
  1054. Save(ctx)
  1055. require.NoError(t, err)
  1056. order, err := client.PaymentOrder.Create().
  1057. SetUserID(user.ID).
  1058. SetUserEmail(user.Email).
  1059. SetUserName(user.Username).
  1060. SetAmount(80).
  1061. SetPayAmount(80).
  1062. SetFeeRate(0).
  1063. SetRechargeCode("PAY-SUB-AFFILIATE-IDEMPOTENT").
  1064. SetOutTradeNo("sub2_subscription_affiliate_idempotent").
  1065. SetPaymentType(payment.TypeAlipay).
  1066. SetPaymentTradeNo("trade-sub-affiliate-idempotent").
  1067. SetOrderType(payment.OrderTypeSubscription).
  1068. SetPlanID(100).
  1069. SetSubscriptionGroupID(7).
  1070. SetSubscriptionDays(30).
  1071. SetStatus(OrderStatusPaid).
  1072. SetExpiresAt(time.Now().Add(time.Hour)).
  1073. SetClientIP("127.0.0.1").
  1074. SetSrcHost("api.example.com").
  1075. Save(ctx)
  1076. require.NoError(t, err)
  1077. _, err = client.PaymentAuditLog.Create().
  1078. SetOrderID(strconv.FormatInt(order.ID, 10)).
  1079. SetAction("SUBSCRIPTION_SUCCESS").
  1080. SetDetail(`{"groupID":7,"validityDays":30}`).
  1081. SetOperator("system").
  1082. Save(ctx)
  1083. require.NoError(t, err)
  1084. _, err = client.PaymentAuditLog.Create().
  1085. SetOrderID(strconv.FormatInt(order.ID, 10)).
  1086. SetAction("AFFILIATE_REBATE_APPLIED").
  1087. SetDetail(`{"baseAmount":80,"rebateAmount":16}`).
  1088. SetOperator("system").
  1089. Save(ctx)
  1090. require.NoError(t, err)
  1091. inviterID := int64(9001)
  1092. affiliateRepo := &paymentFulfillmentAffiliateRepoStub{
  1093. inviteeSummary: &AffiliateSummary{
  1094. UserID: user.ID,
  1095. AffCode: "INVITEE",
  1096. InviterID: &inviterID,
  1097. CreatedAt: time.Now().Add(-24 * time.Hour),
  1098. },
  1099. inviterSummary: &AffiliateSummary{
  1100. UserID: inviterID,
  1101. AffCode: "INVITER",
  1102. CreatedAt: time.Now().Add(-48 * time.Hour),
  1103. },
  1104. }
  1105. settingSvc := NewSettingService(&paymentFulfillmentSettingRepoStub{values: map[string]string{
  1106. SettingKeyAffiliateEnabled: "true",
  1107. SettingKeyAffiliateRebateRate: "20",
  1108. }}, nil)
  1109. subRepo := newSubscriptionUserSubRepoStub()
  1110. subscriptionSvc := NewSubscriptionService(&subscriptionGroupRepoStub{
  1111. group: &Group{ID: 7, Status: payment.EntityStatusActive, SubscriptionType: SubscriptionTypeSubscription},
  1112. }, subRepo, nil, nil, nil)
  1113. svc := &PaymentService{
  1114. entClient: client,
  1115. groupRepo: &subscriptionGroupRepoStub{group: &Group{ID: 7, Status: payment.EntityStatusActive, SubscriptionType: SubscriptionTypeSubscription}},
  1116. subscriptionSvc: subscriptionSvc,
  1117. affiliateService: NewAffiliateService(affiliateRepo, settingSvc, nil, nil),
  1118. }
  1119. err = svc.ExecuteSubscriptionFulfillment(ctx, order.ID)
  1120. require.NoError(t, err)
  1121. reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
  1122. require.NoError(t, err)
  1123. require.Equal(t, OrderStatusCompleted, reloaded.Status)
  1124. require.Empty(t, affiliateRepo.accrueCalls)
  1125. require.Zero(t, subRepo.createCalls)
  1126. }
  1127. var _ AffiliateRepository = (*paymentFulfillmentAffiliateRepoStub)(nil)
  1128. var _ SettingRepository = (*paymentFulfillmentSettingRepoStub)(nil)