payment_order_lifecycle_test.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "database/sql"
  6. "testing"
  7. "time"
  8. dbent "github.com/Wei-Shaw/sub2api/ent"
  9. "github.com/Wei-Shaw/sub2api/ent/enttest"
  10. "github.com/Wei-Shaw/sub2api/internal/payment"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  12. "github.com/stretchr/testify/require"
  13. "entgo.io/ent/dialect"
  14. entsql "entgo.io/ent/dialect/sql"
  15. _ "modernc.org/sqlite"
  16. )
  17. type paymentOrderLifecycleQueryProvider struct {
  18. key string
  19. lastQueryTradeNo string
  20. lastCancelTradeNo string
  21. queryCalls int
  22. cancelCalls int
  23. responses []*payment.QueryOrderResponse
  24. resp *payment.QueryOrderResponse
  25. }
  26. type paymentOrderLifecycleRedeemRepo struct {
  27. codesByCode map[string]*RedeemCode
  28. useCalls []struct {
  29. id int64
  30. userID int64
  31. }
  32. }
  33. func (p *paymentOrderLifecycleQueryProvider) Name() string {
  34. return "payment-order-lifecycle-query-provider"
  35. }
  36. func (p *paymentOrderLifecycleQueryProvider) ProviderKey() string {
  37. if p.key != "" {
  38. return p.key
  39. }
  40. return payment.TypeAlipay
  41. }
  42. func (p *paymentOrderLifecycleQueryProvider) SupportedTypes() []payment.PaymentType {
  43. return []payment.PaymentType{p.ProviderKey()}
  44. }
  45. func (p *paymentOrderLifecycleQueryProvider) CreatePayment(context.Context, payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
  46. panic("unexpected call")
  47. }
  48. func (p *paymentOrderLifecycleQueryProvider) QueryOrder(_ context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
  49. p.lastQueryTradeNo = tradeNo
  50. p.queryCalls++
  51. if len(p.responses) > 0 {
  52. resp := p.responses[0]
  53. if len(p.responses) > 1 {
  54. p.responses = p.responses[1:]
  55. }
  56. return resp, nil
  57. }
  58. return p.resp, nil
  59. }
  60. func (p *paymentOrderLifecycleQueryProvider) VerifyNotification(context.Context, string, map[string]string) (*payment.PaymentNotification, error) {
  61. panic("unexpected call")
  62. }
  63. func (p *paymentOrderLifecycleQueryProvider) Refund(context.Context, payment.RefundRequest) (*payment.RefundResponse, error) {
  64. panic("unexpected call")
  65. }
  66. func (p *paymentOrderLifecycleQueryProvider) CancelPayment(_ context.Context, tradeNo string) error {
  67. p.lastCancelTradeNo = tradeNo
  68. p.cancelCalls++
  69. return nil
  70. }
  71. func (r *paymentOrderLifecycleRedeemRepo) Create(context.Context, *RedeemCode) error {
  72. panic("unexpected call")
  73. }
  74. func (r *paymentOrderLifecycleRedeemRepo) CreateBatch(context.Context, []RedeemCode) error {
  75. panic("unexpected call")
  76. }
  77. func (r *paymentOrderLifecycleRedeemRepo) GetByID(_ context.Context, id int64) (*RedeemCode, error) {
  78. for _, code := range r.codesByCode {
  79. if code.ID != id {
  80. continue
  81. }
  82. cloned := *code
  83. return &cloned, nil
  84. }
  85. return nil, ErrRedeemCodeNotFound
  86. }
  87. func (r *paymentOrderLifecycleRedeemRepo) GetByCode(_ context.Context, code string) (*RedeemCode, error) {
  88. redeemCode, ok := r.codesByCode[code]
  89. if !ok {
  90. return nil, ErrRedeemCodeNotFound
  91. }
  92. cloned := *redeemCode
  93. return &cloned, nil
  94. }
  95. func (r *paymentOrderLifecycleRedeemRepo) Update(context.Context, *RedeemCode) error {
  96. panic("unexpected call")
  97. }
  98. func (r *paymentOrderLifecycleRedeemRepo) BatchUpdate(context.Context, []int64, RedeemCodeBatchUpdateFields) (int64, error) {
  99. panic("unexpected call")
  100. }
  101. func (r *paymentOrderLifecycleRedeemRepo) Delete(context.Context, int64) error {
  102. panic("unexpected call")
  103. }
  104. func (r *paymentOrderLifecycleRedeemRepo) Use(_ context.Context, id, userID int64) error {
  105. for code, redeemCode := range r.codesByCode {
  106. if redeemCode.ID != id {
  107. continue
  108. }
  109. now := time.Now().UTC()
  110. redeemCode.Status = StatusUsed
  111. redeemCode.UsedBy = &userID
  112. redeemCode.UsedAt = &now
  113. r.codesByCode[code] = redeemCode
  114. r.useCalls = append(r.useCalls, struct {
  115. id int64
  116. userID int64
  117. }{id: id, userID: userID})
  118. return nil
  119. }
  120. return ErrRedeemCodeNotFound
  121. }
  122. func (r *paymentOrderLifecycleRedeemRepo) List(context.Context, pagination.PaginationParams) ([]RedeemCode, *pagination.PaginationResult, error) {
  123. panic("unexpected call")
  124. }
  125. func (r *paymentOrderLifecycleRedeemRepo) ListWithFilters(context.Context, pagination.PaginationParams, string, string, string) ([]RedeemCode, *pagination.PaginationResult, error) {
  126. panic("unexpected call")
  127. }
  128. func (r *paymentOrderLifecycleRedeemRepo) ListByUser(context.Context, int64, int) ([]RedeemCode, error) {
  129. panic("unexpected call")
  130. }
  131. func (r *paymentOrderLifecycleRedeemRepo) ListByUserPaginated(context.Context, int64, pagination.PaginationParams, string) ([]RedeemCode, *pagination.PaginationResult, error) {
  132. panic("unexpected call")
  133. }
  134. func (r *paymentOrderLifecycleRedeemRepo) SumPositiveBalanceByUser(context.Context, int64) (float64, error) {
  135. panic("unexpected call")
  136. }
  137. func TestVerifyOrderByOutTradeNoBackfillsTradeNoFromPaidQuery(t *testing.T) {
  138. ctx := context.Background()
  139. client := newPaymentOrderLifecycleTestClient(t)
  140. user, err := client.User.Create().
  141. SetEmail("checkpaid@example.com").
  142. SetPasswordHash("hash").
  143. SetUsername("checkpaid-user").
  144. Save(ctx)
  145. require.NoError(t, err)
  146. order, err := client.PaymentOrder.Create().
  147. SetUserID(user.ID).
  148. SetUserEmail(user.Email).
  149. SetUserName(user.Username).
  150. SetAmount(88).
  151. SetPayAmount(88).
  152. SetFeeRate(0).
  153. SetRechargeCode("CHECKPAID-UPSTREAM-TRADE-NO").
  154. SetOutTradeNo("sub2_checkpaid_trade_no_missing").
  155. SetPaymentType(payment.TypeAlipay).
  156. SetPaymentTradeNo("").
  157. SetOrderType(payment.OrderTypeBalance).
  158. SetStatus(OrderStatusPending).
  159. SetExpiresAt(time.Now().Add(time.Hour)).
  160. SetClientIP("127.0.0.1").
  161. SetSrcHost("api.example.com").
  162. Save(ctx)
  163. require.NoError(t, err)
  164. userRepo := &mockUserRepo{
  165. getByIDUser: &User{
  166. ID: user.ID,
  167. Email: user.Email,
  168. Username: user.Username,
  169. Balance: 0,
  170. },
  171. }
  172. userRepo.updateBalanceFn = func(ctx context.Context, id int64, amount float64) error {
  173. require.Equal(t, user.ID, id)
  174. if userRepo.getByIDUser != nil {
  175. userRepo.getByIDUser.Balance += amount
  176. }
  177. return nil
  178. }
  179. redeemRepo := &paymentOrderLifecycleRedeemRepo{
  180. codesByCode: map[string]*RedeemCode{
  181. order.RechargeCode: {
  182. ID: 1,
  183. Code: order.RechargeCode,
  184. Type: RedeemTypeBalance,
  185. Value: order.Amount,
  186. Status: StatusUnused,
  187. },
  188. },
  189. }
  190. redeemService := NewRedeemService(
  191. redeemRepo,
  192. userRepo,
  193. nil,
  194. nil,
  195. nil,
  196. client,
  197. nil,
  198. nil,
  199. )
  200. registry := payment.NewRegistry()
  201. provider := &paymentOrderLifecycleQueryProvider{
  202. resp: &payment.QueryOrderResponse{
  203. TradeNo: "upstream-trade-123",
  204. Status: payment.ProviderStatusPaid,
  205. Amount: 88,
  206. },
  207. }
  208. registry.Register(provider)
  209. svc := &PaymentService{
  210. entClient: client,
  211. registry: registry,
  212. redeemService: redeemService,
  213. userRepo: userRepo,
  214. providersLoaded: true,
  215. }
  216. got, err := svc.VerifyOrderByOutTradeNo(ctx, order.OutTradeNo, user.ID)
  217. require.NoError(t, err)
  218. require.Equal(t, order.OutTradeNo, provider.lastQueryTradeNo)
  219. require.Equal(t, OrderStatusCompleted, got.Status)
  220. require.Equal(t, "upstream-trade-123", got.PaymentTradeNo)
  221. reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
  222. require.NoError(t, err)
  223. require.Equal(t, OrderStatusCompleted, reloaded.Status)
  224. require.Equal(t, "upstream-trade-123", reloaded.PaymentTradeNo)
  225. require.Equal(t, 88.0, userRepo.getByIDUser.Balance)
  226. require.Len(t, redeemRepo.useCalls, 1)
  227. require.Equal(t, int64(1), redeemRepo.useCalls[0].id)
  228. require.Equal(t, user.ID, redeemRepo.useCalls[0].userID)
  229. }
  230. func TestVerifyOrderByOutTradeNoRetriesZeroAmountPaidQueryOnce(t *testing.T) {
  231. ctx := context.Background()
  232. client := newPaymentOrderLifecycleTestClient(t)
  233. user, err := client.User.Create().
  234. SetEmail("checkpaid-retry@example.com").
  235. SetPasswordHash("hash").
  236. SetUsername("checkpaid-retry-user").
  237. Save(ctx)
  238. require.NoError(t, err)
  239. order, err := client.PaymentOrder.Create().
  240. SetUserID(user.ID).
  241. SetUserEmail(user.Email).
  242. SetUserName(user.Username).
  243. SetAmount(88).
  244. SetPayAmount(88).
  245. SetFeeRate(0).
  246. SetRechargeCode("CHECKPAID-UPSTREAM-RETRY").
  247. SetOutTradeNo("sub2_checkpaid_retry_zero_amount").
  248. SetPaymentType(payment.TypeAlipay).
  249. SetPaymentTradeNo("").
  250. SetOrderType(payment.OrderTypeBalance).
  251. SetStatus(OrderStatusPending).
  252. SetExpiresAt(time.Now().Add(time.Hour)).
  253. SetClientIP("127.0.0.1").
  254. SetSrcHost("api.example.com").
  255. Save(ctx)
  256. require.NoError(t, err)
  257. userRepo := &mockUserRepo{
  258. getByIDUser: &User{
  259. ID: user.ID,
  260. Email: user.Email,
  261. Username: user.Username,
  262. Balance: 0,
  263. },
  264. }
  265. userRepo.updateBalanceFn = func(ctx context.Context, id int64, amount float64) error {
  266. require.Equal(t, user.ID, id)
  267. if userRepo.getByIDUser != nil {
  268. userRepo.getByIDUser.Balance += amount
  269. }
  270. return nil
  271. }
  272. redeemRepo := &paymentOrderLifecycleRedeemRepo{
  273. codesByCode: map[string]*RedeemCode{
  274. order.RechargeCode: {
  275. ID: 1,
  276. Code: order.RechargeCode,
  277. Type: RedeemTypeBalance,
  278. Value: order.Amount,
  279. Status: StatusUnused,
  280. },
  281. },
  282. }
  283. redeemService := NewRedeemService(
  284. redeemRepo,
  285. userRepo,
  286. nil,
  287. nil,
  288. nil,
  289. client,
  290. nil,
  291. nil,
  292. )
  293. registry := payment.NewRegistry()
  294. provider := &paymentOrderLifecycleQueryProvider{
  295. responses: []*payment.QueryOrderResponse{
  296. {
  297. TradeNo: "upstream-trade-zero",
  298. Status: payment.ProviderStatusPaid,
  299. Amount: 0,
  300. },
  301. {
  302. TradeNo: "upstream-trade-retry",
  303. Status: payment.ProviderStatusPaid,
  304. Amount: 88,
  305. },
  306. },
  307. }
  308. registry.Register(provider)
  309. svc := &PaymentService{
  310. entClient: client,
  311. registry: registry,
  312. redeemService: redeemService,
  313. userRepo: userRepo,
  314. providersLoaded: true,
  315. }
  316. got, err := svc.VerifyOrderByOutTradeNo(ctx, order.OutTradeNo, user.ID)
  317. require.NoError(t, err)
  318. require.Equal(t, 2, provider.queryCalls)
  319. require.Equal(t, OrderStatusCompleted, got.Status)
  320. require.Equal(t, "upstream-trade-retry", got.PaymentTradeNo)
  321. }
  322. func TestVerifyOrderByOutTradeNoRejectsPaidQueryWithZeroAmount(t *testing.T) {
  323. ctx := context.Background()
  324. client := newPaymentOrderLifecycleTestClient(t)
  325. user, err := client.User.Create().
  326. SetEmail("checkpaid-zero-amount@example.com").
  327. SetPasswordHash("hash").
  328. SetUsername("checkpaid-zero-amount-user").
  329. Save(ctx)
  330. require.NoError(t, err)
  331. order, err := client.PaymentOrder.Create().
  332. SetUserID(user.ID).
  333. SetUserEmail(user.Email).
  334. SetUserName(user.Username).
  335. SetAmount(88).
  336. SetPayAmount(88).
  337. SetFeeRate(0).
  338. SetRechargeCode("CHECKPAID-ZERO-AMOUNT").
  339. SetOutTradeNo("sub2_checkpaid_zero_amount").
  340. SetPaymentType(payment.TypeAlipay).
  341. SetPaymentTradeNo("").
  342. SetOrderType(payment.OrderTypeBalance).
  343. SetStatus(OrderStatusPending).
  344. SetExpiresAt(time.Now().Add(time.Hour)).
  345. SetClientIP("127.0.0.1").
  346. SetSrcHost("api.example.com").
  347. Save(ctx)
  348. require.NoError(t, err)
  349. userRepo := &mockUserRepo{
  350. getByIDUser: &User{
  351. ID: user.ID,
  352. Email: user.Email,
  353. Username: user.Username,
  354. Balance: 0,
  355. },
  356. }
  357. redeemRepo := &paymentOrderLifecycleRedeemRepo{
  358. codesByCode: map[string]*RedeemCode{
  359. order.RechargeCode: {
  360. ID: 1,
  361. Code: order.RechargeCode,
  362. Type: RedeemTypeBalance,
  363. Value: order.Amount,
  364. Status: StatusUnused,
  365. },
  366. },
  367. }
  368. redeemService := NewRedeemService(
  369. redeemRepo,
  370. userRepo,
  371. nil,
  372. nil,
  373. nil,
  374. client,
  375. nil,
  376. nil,
  377. )
  378. registry := payment.NewRegistry()
  379. provider := &paymentOrderLifecycleQueryProvider{
  380. resp: &payment.QueryOrderResponse{
  381. TradeNo: "upstream-trade-zero",
  382. Status: payment.ProviderStatusPaid,
  383. Amount: 0,
  384. },
  385. }
  386. registry.Register(provider)
  387. svc := &PaymentService{
  388. entClient: client,
  389. registry: registry,
  390. redeemService: redeemService,
  391. userRepo: userRepo,
  392. providersLoaded: true,
  393. }
  394. got, err := svc.VerifyOrderByOutTradeNo(ctx, order.OutTradeNo, user.ID)
  395. require.NoError(t, err)
  396. require.Equal(t, order.OutTradeNo, provider.lastQueryTradeNo)
  397. require.Equal(t, OrderStatusPending, got.Status)
  398. require.Empty(t, got.PaymentTradeNo)
  399. reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
  400. require.NoError(t, err)
  401. require.Equal(t, OrderStatusPending, reloaded.Status)
  402. require.Empty(t, reloaded.PaymentTradeNo)
  403. require.Equal(t, 0.0, userRepo.getByIDUser.Balance)
  404. require.Empty(t, redeemRepo.useCalls)
  405. }
  406. func TestVerifyOrderByOutTradeNoDoesNotCancelUnpaidUpstreamOrder(t *testing.T) {
  407. ctx := context.Background()
  408. client := newPaymentOrderLifecycleTestClient(t)
  409. user, err := client.User.Create().
  410. SetEmail("checkpaid-pending@example.com").
  411. SetPasswordHash("hash").
  412. SetUsername("checkpaid-pending-user").
  413. Save(ctx)
  414. require.NoError(t, err)
  415. order, err := client.PaymentOrder.Create().
  416. SetUserID(user.ID).
  417. SetUserEmail(user.Email).
  418. SetUserName(user.Username).
  419. SetAmount(88).
  420. SetPayAmount(88).
  421. SetFeeRate(0).
  422. SetRechargeCode("CHECKPAID-PENDING").
  423. SetOutTradeNo("sub2_checkpaid_pending").
  424. SetPaymentType(payment.TypeAlipay).
  425. SetPaymentTradeNo("").
  426. SetOrderType(payment.OrderTypeBalance).
  427. SetStatus(OrderStatusPending).
  428. SetExpiresAt(time.Now().Add(time.Hour)).
  429. SetClientIP("127.0.0.1").
  430. SetSrcHost("api.example.com").
  431. Save(ctx)
  432. require.NoError(t, err)
  433. registry := payment.NewRegistry()
  434. provider := &paymentOrderLifecycleQueryProvider{
  435. resp: &payment.QueryOrderResponse{
  436. TradeNo: order.OutTradeNo,
  437. Status: payment.ProviderStatusPending,
  438. Amount: 0,
  439. },
  440. }
  441. registry.Register(provider)
  442. svc := &PaymentService{
  443. entClient: client,
  444. registry: registry,
  445. providersLoaded: true,
  446. }
  447. got, err := svc.VerifyOrderByOutTradeNo(ctx, order.OutTradeNo, user.ID)
  448. require.NoError(t, err)
  449. require.Equal(t, OrderStatusPending, got.Status)
  450. require.Equal(t, order.OutTradeNo, provider.lastQueryTradeNo)
  451. require.Zero(t, provider.cancelCalls)
  452. reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
  453. require.NoError(t, err)
  454. require.Equal(t, OrderStatusPending, reloaded.Status)
  455. }
  456. func TestCancelOrderStillClosesUnpaidUpstreamOrder(t *testing.T) {
  457. ctx := context.Background()
  458. client := newPaymentOrderLifecycleTestClient(t)
  459. user, err := client.User.Create().
  460. SetEmail("cancel-pending@example.com").
  461. SetPasswordHash("hash").
  462. SetUsername("cancel-pending-user").
  463. Save(ctx)
  464. require.NoError(t, err)
  465. order, err := client.PaymentOrder.Create().
  466. SetUserID(user.ID).
  467. SetUserEmail(user.Email).
  468. SetUserName(user.Username).
  469. SetAmount(88).
  470. SetPayAmount(88).
  471. SetFeeRate(0).
  472. SetRechargeCode("CANCEL-PENDING").
  473. SetOutTradeNo("sub2_cancel_pending").
  474. SetPaymentType(payment.TypeAlipay).
  475. SetPaymentTradeNo("").
  476. SetOrderType(payment.OrderTypeBalance).
  477. SetStatus(OrderStatusPending).
  478. SetExpiresAt(time.Now().Add(time.Hour)).
  479. SetClientIP("127.0.0.1").
  480. SetSrcHost("api.example.com").
  481. Save(ctx)
  482. require.NoError(t, err)
  483. registry := payment.NewRegistry()
  484. provider := &paymentOrderLifecycleQueryProvider{
  485. resp: &payment.QueryOrderResponse{
  486. TradeNo: order.OutTradeNo,
  487. Status: payment.ProviderStatusPending,
  488. Amount: 0,
  489. },
  490. }
  491. registry.Register(provider)
  492. svc := &PaymentService{
  493. entClient: client,
  494. registry: registry,
  495. providersLoaded: true,
  496. }
  497. outcome, err := svc.CancelOrder(ctx, order.ID, user.ID)
  498. require.NoError(t, err)
  499. require.Equal(t, checkPaidResultCancelled, outcome)
  500. require.Equal(t, order.OutTradeNo, provider.lastCancelTradeNo)
  501. require.Equal(t, 1, provider.cancelCalls)
  502. reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
  503. require.NoError(t, err)
  504. require.Equal(t, OrderStatusCancelled, reloaded.Status)
  505. }
  506. func TestReconcilePendingPaymentOrdersBackfillsPaidOrder(t *testing.T) {
  507. ctx := context.Background()
  508. client := newPaymentOrderLifecycleTestClient(t)
  509. user, err := client.User.Create().
  510. SetEmail("wxpay-reconcile@example.com").
  511. SetPasswordHash("hash").
  512. SetUsername("wxpay-reconcile-user").
  513. Save(ctx)
  514. require.NoError(t, err)
  515. order, err := client.PaymentOrder.Create().
  516. SetUserID(user.ID).
  517. SetUserEmail(user.Email).
  518. SetUserName(user.Username).
  519. SetAmount(50).
  520. SetPayAmount(50).
  521. SetFeeRate(0).
  522. SetRechargeCode("WXPAY-RECONCILE").
  523. SetOutTradeNo("sub2_wxpay_reconcile").
  524. SetPaymentType(payment.TypeWxpay).
  525. SetPaymentTradeNo("").
  526. SetOrderType(payment.OrderTypeBalance).
  527. SetStatus(OrderStatusPending).
  528. SetExpiresAt(time.Now().Add(time.Hour)).
  529. SetClientIP("127.0.0.1").
  530. SetSrcHost("api.example.com").
  531. Save(ctx)
  532. require.NoError(t, err)
  533. userRepo := &mockUserRepo{
  534. getByIDUser: &User{
  535. ID: user.ID,
  536. Email: user.Email,
  537. Username: user.Username,
  538. Balance: 0,
  539. },
  540. }
  541. userRepo.updateBalanceFn = func(ctx context.Context, id int64, amount float64) error {
  542. require.Equal(t, user.ID, id)
  543. if userRepo.getByIDUser != nil {
  544. userRepo.getByIDUser.Balance += amount
  545. }
  546. return nil
  547. }
  548. redeemRepo := &paymentOrderLifecycleRedeemRepo{
  549. codesByCode: map[string]*RedeemCode{
  550. order.RechargeCode: {
  551. ID: 1,
  552. Code: order.RechargeCode,
  553. Type: RedeemTypeBalance,
  554. Value: order.Amount,
  555. Status: StatusUnused,
  556. },
  557. },
  558. }
  559. redeemService := NewRedeemService(
  560. redeemRepo,
  561. userRepo,
  562. nil,
  563. nil,
  564. nil,
  565. client,
  566. nil,
  567. nil,
  568. )
  569. registry := payment.NewRegistry()
  570. provider := &paymentOrderLifecycleQueryProvider{
  571. key: payment.TypeWxpay,
  572. resp: &payment.QueryOrderResponse{
  573. TradeNo: "wxpay-upstream-trade-123",
  574. Status: payment.ProviderStatusPaid,
  575. Amount: 50,
  576. Metadata: map[string]string{
  577. "trade_state": "SUCCESS",
  578. },
  579. },
  580. }
  581. registry.Register(provider)
  582. svc := &PaymentService{
  583. entClient: client,
  584. registry: registry,
  585. redeemService: redeemService,
  586. userRepo: userRepo,
  587. providersLoaded: true,
  588. }
  589. recovered, err := svc.ReconcilePendingPaymentOrders(ctx)
  590. require.NoError(t, err)
  591. require.Equal(t, 1, recovered)
  592. require.Equal(t, order.OutTradeNo, provider.lastQueryTradeNo)
  593. require.Zero(t, provider.cancelCalls)
  594. reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
  595. require.NoError(t, err)
  596. require.Equal(t, OrderStatusCompleted, reloaded.Status)
  597. require.Equal(t, "wxpay-upstream-trade-123", reloaded.PaymentTradeNo)
  598. require.Equal(t, 50.0, userRepo.getByIDUser.Balance)
  599. require.Len(t, redeemRepo.useCalls, 1)
  600. }
  601. func TestReconcilePendingPaymentOrdersQueriesAlipayOrder(t *testing.T) {
  602. ctx := context.Background()
  603. client := newPaymentOrderLifecycleTestClient(t)
  604. user, err := client.User.Create().
  605. SetEmail("alipay-reconcile@example.com").
  606. SetPasswordHash("hash").
  607. SetUsername("alipay-reconcile-user").
  608. Save(ctx)
  609. require.NoError(t, err)
  610. order, err := client.PaymentOrder.Create().
  611. SetUserID(user.ID).
  612. SetUserEmail(user.Email).
  613. SetUserName(user.Username).
  614. SetAmount(50).
  615. SetPayAmount(50).
  616. SetFeeRate(0).
  617. SetRechargeCode("ALIPAY-RECONCILE").
  618. SetOutTradeNo("sub2_alipay_reconcile").
  619. SetPaymentType(payment.TypeAlipay).
  620. SetPaymentTradeNo("").
  621. SetOrderType(payment.OrderTypeBalance).
  622. SetStatus(OrderStatusPending).
  623. SetExpiresAt(time.Now().Add(time.Hour)).
  624. SetClientIP("127.0.0.1").
  625. SetSrcHost("api.example.com").
  626. Save(ctx)
  627. require.NoError(t, err)
  628. registry := payment.NewRegistry()
  629. provider := &paymentOrderLifecycleQueryProvider{
  630. key: payment.TypeAlipay,
  631. resp: &payment.QueryOrderResponse{
  632. TradeNo: order.OutTradeNo,
  633. Status: payment.ProviderStatusPending,
  634. },
  635. }
  636. registry.Register(provider)
  637. svc := &PaymentService{
  638. entClient: client,
  639. registry: registry,
  640. providersLoaded: true,
  641. }
  642. recovered, err := svc.ReconcilePendingPaymentOrders(ctx)
  643. require.NoError(t, err)
  644. require.Zero(t, recovered)
  645. require.Equal(t, 1, provider.queryCalls)
  646. require.Equal(t, order.OutTradeNo, provider.lastQueryTradeNo)
  647. }
  648. func TestVerifyOrderByOutTradeNoUsesOutTradeNoWhenPaymentTradeNoAlreadyExistsForAlipay(t *testing.T) {
  649. ctx := context.Background()
  650. client := newPaymentOrderLifecycleTestClient(t)
  651. user, err := client.User.Create().
  652. SetEmail("checkpaid-existing-trade@example.com").
  653. SetPasswordHash("hash").
  654. SetUsername("checkpaid-existing-trade-user").
  655. Save(ctx)
  656. require.NoError(t, err)
  657. order, err := client.PaymentOrder.Create().
  658. SetUserID(user.ID).
  659. SetUserEmail(user.Email).
  660. SetUserName(user.Username).
  661. SetAmount(88).
  662. SetPayAmount(88).
  663. SetFeeRate(0).
  664. SetRechargeCode("CHECKPAID-EXISTING-TRADE-NO").
  665. SetOutTradeNo("sub2_checkpaid_use_out_trade_no").
  666. SetPaymentType(payment.TypeAlipay).
  667. SetPaymentTradeNo("upstream-trade-existing").
  668. SetOrderType(payment.OrderTypeBalance).
  669. SetStatus(OrderStatusPending).
  670. SetExpiresAt(time.Now().Add(time.Hour)).
  671. SetClientIP("127.0.0.1").
  672. SetSrcHost("api.example.com").
  673. Save(ctx)
  674. require.NoError(t, err)
  675. userRepo := &mockUserRepo{
  676. getByIDUser: &User{
  677. ID: user.ID,
  678. Email: user.Email,
  679. Username: user.Username,
  680. Balance: 0,
  681. },
  682. }
  683. userRepo.updateBalanceFn = func(ctx context.Context, id int64, amount float64) error {
  684. require.Equal(t, user.ID, id)
  685. if userRepo.getByIDUser != nil {
  686. userRepo.getByIDUser.Balance += amount
  687. }
  688. return nil
  689. }
  690. redeemRepo := &paymentOrderLifecycleRedeemRepo{
  691. codesByCode: map[string]*RedeemCode{
  692. order.RechargeCode: {
  693. ID: 1,
  694. Code: order.RechargeCode,
  695. Type: RedeemTypeBalance,
  696. Value: order.Amount,
  697. Status: StatusUnused,
  698. },
  699. },
  700. }
  701. redeemService := NewRedeemService(
  702. redeemRepo,
  703. userRepo,
  704. nil,
  705. nil,
  706. nil,
  707. client,
  708. nil,
  709. nil,
  710. )
  711. registry := payment.NewRegistry()
  712. provider := &paymentOrderLifecycleQueryProvider{
  713. resp: &payment.QueryOrderResponse{
  714. TradeNo: "upstream-trade-existing",
  715. Status: payment.ProviderStatusPaid,
  716. Amount: 88,
  717. },
  718. }
  719. registry.Register(provider)
  720. svc := &PaymentService{
  721. entClient: client,
  722. registry: registry,
  723. redeemService: redeemService,
  724. userRepo: userRepo,
  725. providersLoaded: true,
  726. }
  727. got, err := svc.VerifyOrderByOutTradeNo(ctx, order.OutTradeNo, user.ID)
  728. require.NoError(t, err)
  729. require.Equal(t, order.OutTradeNo, provider.lastQueryTradeNo)
  730. require.Equal(t, "upstream-trade-existing", got.PaymentTradeNo)
  731. }
  732. func TestPaymentOrderAllowsRegistryFallbackOnlyForLegacyOrdersWithoutPinnedProviderState(t *testing.T) {
  733. t.Parallel()
  734. require.True(t, paymentOrderAllowsRegistryFallback(&dbent.PaymentOrder{
  735. PaymentType: payment.TypeAlipay,
  736. }))
  737. instanceID := "12"
  738. require.False(t, paymentOrderAllowsRegistryFallback(&dbent.PaymentOrder{
  739. PaymentType: payment.TypeAlipay,
  740. ProviderInstanceID: &instanceID,
  741. }))
  742. require.False(t, paymentOrderAllowsRegistryFallback(&dbent.PaymentOrder{
  743. PaymentType: payment.TypeAlipay,
  744. ProviderSnapshot: map[string]any{
  745. "schema_version": 2,
  746. "provider_instance_id": "12",
  747. },
  748. }))
  749. }
  750. func TestPaymentOrderQueryReferenceUsesOutTradeNoForOfficialProviders(t *testing.T) {
  751. t.Parallel()
  752. order := &dbent.PaymentOrder{
  753. PaymentType: payment.TypeWxpay,
  754. OutTradeNo: "sub2_out_trade_no",
  755. PaymentTradeNo: "wx-transaction-id",
  756. }
  757. require.Equal(t, "sub2_out_trade_no", paymentOrderQueryReference(order, &paymentOrderLifecycleQueryProvider{}))
  758. require.Equal(t, "sub2_out_trade_no", paymentOrderQueryReference(order, paymentFulfillmentTestProvider{
  759. key: payment.TypeWxpay,
  760. }))
  761. }
  762. func newPaymentOrderLifecycleTestClient(t *testing.T) *dbent.Client {
  763. t.Helper()
  764. db, err := sql.Open("sqlite", "file:payment_order_lifecycle?mode=memory&cache=shared&_fk=1")
  765. require.NoError(t, err)
  766. t.Cleanup(func() { _ = db.Close() })
  767. _, err = db.Exec("PRAGMA foreign_keys = ON")
  768. require.NoError(t, err)
  769. drv := entsql.OpenDB(dialect.SQLite, db)
  770. client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
  771. t.Cleanup(func() { _ = client.Close() })
  772. return client
  773. }