payment_refund_test.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "fmt"
  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/require"
  15. )
  16. func TestValidateRefundRequestRejectsLegacyGuessedProviderInstance(t *testing.T) {
  17. ctx := context.Background()
  18. client := newPaymentConfigServiceTestClient(t)
  19. user, err := client.User.Create().
  20. SetEmail("refund-legacy@example.com").
  21. SetPasswordHash("hash").
  22. SetUsername("refund-legacy-user").
  23. Save(ctx)
  24. require.NoError(t, err)
  25. _, err = client.PaymentProviderInstance.Create().
  26. SetProviderKey(payment.TypeAlipay).
  27. SetName("alipay-refund-instance").
  28. SetConfig("{}").
  29. SetSupportedTypes("alipay").
  30. SetEnabled(true).
  31. SetAllowUserRefund(true).
  32. SetRefundEnabled(true).
  33. Save(ctx)
  34. require.NoError(t, err)
  35. order, err := client.PaymentOrder.Create().
  36. SetUserID(user.ID).
  37. SetUserEmail(user.Email).
  38. SetUserName(user.Username).
  39. SetAmount(88).
  40. SetPayAmount(88).
  41. SetFeeRate(0).
  42. SetRechargeCode("REFUND-LEGACY-ORDER").
  43. SetOutTradeNo("sub2_refund_legacy_order").
  44. SetPaymentType(payment.TypeAlipay).
  45. SetPaymentTradeNo("trade-legacy-refund").
  46. SetOrderType(payment.OrderTypeBalance).
  47. SetStatus(OrderStatusCompleted).
  48. SetExpiresAt(time.Now().Add(time.Hour)).
  49. SetPaidAt(time.Now()).
  50. SetClientIP("127.0.0.1").
  51. SetSrcHost("api.example.com").
  52. Save(ctx)
  53. require.NoError(t, err)
  54. svc := &PaymentService{
  55. entClient: client,
  56. }
  57. _, err = svc.validateRefundRequest(ctx, order.ID, user.ID)
  58. require.Error(t, err)
  59. require.Equal(t, "USER_REFUND_DISABLED", infraerrors.Reason(err))
  60. }
  61. func TestPrepareRefundRejectsLegacyGuessedProviderInstance(t *testing.T) {
  62. ctx := context.Background()
  63. client := newPaymentConfigServiceTestClient(t)
  64. user, err := client.User.Create().
  65. SetEmail("refund-legacy-admin@example.com").
  66. SetPasswordHash("hash").
  67. SetUsername("refund-legacy-admin-user").
  68. Save(ctx)
  69. require.NoError(t, err)
  70. _, err = client.PaymentProviderInstance.Create().
  71. SetProviderKey(payment.TypeAlipay).
  72. SetName("alipay-refund-admin-instance").
  73. SetConfig("{}").
  74. SetSupportedTypes("alipay").
  75. SetEnabled(true).
  76. SetAllowUserRefund(true).
  77. SetRefundEnabled(true).
  78. Save(ctx)
  79. require.NoError(t, err)
  80. order, err := client.PaymentOrder.Create().
  81. SetUserID(user.ID).
  82. SetUserEmail(user.Email).
  83. SetUserName(user.Username).
  84. SetAmount(188).
  85. SetPayAmount(188).
  86. SetFeeRate(0).
  87. SetRechargeCode("REFUND-LEGACY-ADMIN-ORDER").
  88. SetOutTradeNo("sub2_refund_legacy_admin_order").
  89. SetPaymentType(payment.TypeAlipay).
  90. SetPaymentTradeNo("trade-legacy-admin-refund").
  91. SetOrderType(payment.OrderTypeBalance).
  92. SetStatus(OrderStatusCompleted).
  93. SetExpiresAt(time.Now().Add(time.Hour)).
  94. SetPaidAt(time.Now()).
  95. SetClientIP("127.0.0.1").
  96. SetSrcHost("api.example.com").
  97. Save(ctx)
  98. require.NoError(t, err)
  99. svc := &PaymentService{
  100. entClient: client,
  101. }
  102. plan, result, err := svc.PrepareRefund(ctx, order.ID, 0, "", false, false)
  103. require.Nil(t, plan)
  104. require.Nil(t, result)
  105. require.Error(t, err)
  106. require.Equal(t, "REFUND_DISABLED", infraerrors.Reason(err))
  107. }
  108. func TestPrepDeductBalanceRequiresForceWhenBalanceIsInsufficient(t *testing.T) {
  109. for _, tc := range []struct {
  110. name string
  111. balance float64
  112. force bool
  113. wantDeduct float64
  114. wantWarning bool
  115. }{
  116. {name: "insufficient balance", balance: 40, wantWarning: true},
  117. {name: "forced insufficient balance", balance: 40, force: true, wantDeduct: 40},
  118. {name: "equal balance", balance: 100, wantDeduct: 100},
  119. } {
  120. t.Run(tc.name, func(t *testing.T) {
  121. plan := &RefundPlan{RefundAmount: 100}
  122. svc := &PaymentService{userRepo: &mockUserRepo{getByIDUser: &User{Balance: tc.balance}}}
  123. result := svc.prepDeduct(context.Background(), &dbent.PaymentOrder{
  124. UserID: 1,
  125. OrderType: payment.OrderTypeBalance,
  126. }, plan, tc.force)
  127. if tc.wantWarning {
  128. require.NotNil(t, result)
  129. require.False(t, result.Success)
  130. require.True(t, result.RequireForce)
  131. require.Equal(t, "user balance is insufficient for deduction, use force", result.Warning)
  132. require.Zero(t, plan.BalanceToDeduct)
  133. return
  134. }
  135. require.Nil(t, result)
  136. require.Equal(t, payment.DeductionTypeBalance, plan.DeductionType)
  137. require.Equal(t, tc.wantDeduct, plan.BalanceToDeduct)
  138. })
  139. }
  140. }
  141. func TestExecuteRefundUsesActualAvailableBalanceDeduction(t *testing.T) {
  142. ctx := context.Background()
  143. client := newPaymentConfigServiceTestClient(t)
  144. user, err := client.User.Create().
  145. SetEmail("refund-execute-clamp@example.com").
  146. SetPasswordHash("hash").
  147. SetUsername("refund-execute-clamp").
  148. Save(ctx)
  149. require.NoError(t, err)
  150. order, err := client.PaymentOrder.Create().
  151. SetUserID(user.ID).
  152. SetUserEmail(user.Email).
  153. SetUserName(user.Username).
  154. SetAmount(100).
  155. SetPayAmount(100).
  156. SetFeeRate(0).
  157. SetRechargeCode("REFUND-EXECUTE-CLAMP").
  158. SetOutTradeNo("refund_execute_clamp").
  159. SetPaymentType(payment.TypeStripe).
  160. SetPaymentTradeNo("").
  161. SetOrderType(payment.OrderTypeBalance).
  162. SetStatus(OrderStatusCompleted).
  163. SetExpiresAt(time.Now().Add(time.Hour)).
  164. SetPaidAt(time.Now()).
  165. SetClientIP("127.0.0.1").
  166. SetSrcHost("api.example.com").
  167. Save(ctx)
  168. require.NoError(t, err)
  169. repo := &mockUserRepo{deductAvailableBalanceFn: func(_ context.Context, id int64, amount float64) (float64, error) {
  170. require.Equal(t, user.ID, id)
  171. require.Equal(t, 100.0, amount)
  172. return 25, nil
  173. }}
  174. plan := &RefundPlan{
  175. OrderID: order.ID, Order: order, RefundAmount: 100, GatewayAmount: 100,
  176. Reason: "concurrent spend", Force: true, DeductionType: payment.DeductionTypeBalance, BalanceToDeduct: 100,
  177. }
  178. result, err := (&PaymentService{entClient: client, userRepo: repo}).ExecuteRefund(ctx, plan)
  179. require.NoError(t, err)
  180. require.True(t, result.Success)
  181. require.Equal(t, 25.0, plan.BalanceToDeduct)
  182. require.Equal(t, 25.0, result.BalanceDeducted)
  183. audit, err := client.PaymentAuditLog.Query().
  184. Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_SUCCESS")).
  185. Only(ctx)
  186. require.NoError(t, err)
  187. require.Contains(t, audit.Detail, `"balanceDeducted":25`)
  188. }
  189. func TestGwRefundRejectsAlipayMerchantIdentitySnapshotMismatch(t *testing.T) {
  190. ctx := context.Background()
  191. client := newPaymentConfigServiceTestClient(t)
  192. user, err := client.User.Create().
  193. SetEmail("refund-snapshot-mismatch@example.com").
  194. SetPasswordHash("hash").
  195. SetUsername("refund-snapshot-mismatch-user").
  196. Save(ctx)
  197. require.NoError(t, err)
  198. inst, err := client.PaymentProviderInstance.Create().
  199. SetProviderKey(payment.TypeAlipay).
  200. SetName("alipay-refund-mismatch-instance").
  201. SetConfig(encryptWebhookProviderConfig(t, map[string]string{
  202. "appId": "runtime-alipay-app",
  203. "privateKey": "runtime-private-key",
  204. })).
  205. SetSupportedTypes("alipay").
  206. SetEnabled(true).
  207. SetRefundEnabled(true).
  208. Save(ctx)
  209. require.NoError(t, err)
  210. instID := strconv.FormatInt(inst.ID, 10)
  211. order, err := client.PaymentOrder.Create().
  212. SetUserID(user.ID).
  213. SetUserEmail(user.Email).
  214. SetUserName(user.Username).
  215. SetAmount(88).
  216. SetPayAmount(88).
  217. SetFeeRate(0).
  218. SetRechargeCode("REFUND-SNAPSHOT-MISMATCH-ORDER").
  219. SetOutTradeNo("sub2_refund_snapshot_mismatch_order").
  220. SetPaymentType(payment.TypeAlipay).
  221. SetPaymentTradeNo("trade-refund-snapshot-mismatch").
  222. SetOrderType(payment.OrderTypeBalance).
  223. SetStatus(OrderStatusCompleted).
  224. SetExpiresAt(time.Now().Add(time.Hour)).
  225. SetPaidAt(time.Now()).
  226. SetClientIP("127.0.0.1").
  227. SetSrcHost("api.example.com").
  228. SetProviderInstanceID(instID).
  229. SetProviderKey(payment.TypeAlipay).
  230. SetProviderSnapshot(map[string]any{
  231. "schema_version": 2,
  232. "provider_instance_id": instID,
  233. "provider_key": payment.TypeAlipay,
  234. "merchant_app_id": "expected-alipay-app",
  235. }).
  236. Save(ctx)
  237. require.NoError(t, err)
  238. svc := &PaymentService{
  239. entClient: client,
  240. loadBalancer: newWebhookProviderTestLoadBalancer(client),
  241. }
  242. _, err = svc.gwRefund(ctx, &RefundPlan{
  243. OrderID: order.ID,
  244. Order: order,
  245. RefundAmount: order.Amount,
  246. GatewayAmount: order.Amount,
  247. Reason: "snapshot mismatch",
  248. })
  249. require.ErrorContains(t, err, "alipay app_id mismatch")
  250. }
  251. func TestCalculateGatewayRefundAmountUsesCurrencyPrecision(t *testing.T) {
  252. require.InDelta(t, 6.173, calculateGatewayRefundAmount(100, 12.345, 50, "KWD"), 1e-12)
  253. require.InDelta(t, 12.345, calculateGatewayRefundAmount(100, 12.345, 100, "KWD"), 1e-12)
  254. require.InDelta(t, 52, calculateGatewayRefundAmount(100, 103, 50, "JPY"), 1e-12)
  255. }
  256. func TestFormatGatewayRefundAmountUsesOrderCurrency(t *testing.T) {
  257. order := &dbent.PaymentOrder{
  258. ProviderSnapshot: map[string]any{
  259. "currency": "KWD",
  260. },
  261. }
  262. require.Equal(t, "12.345", formatGatewayRefundAmount(12.345, order))
  263. }
  264. func TestValidateRefundProviderResponseAcceptsPending(t *testing.T) {
  265. require.NoError(t, validateRefundProviderResponse(&payment.RefundResponse{Status: payment.ProviderStatusPending}))
  266. require.NoError(t, validateRefundProviderResponse(&payment.RefundResponse{Status: payment.ProviderStatusSuccess}))
  267. require.Error(t, validateRefundProviderResponse(&payment.RefundResponse{Status: payment.ProviderStatusFailed}))
  268. require.Error(t, validateRefundProviderResponse(nil))
  269. }
  270. func TestFinishRefundPendingMarksOrderPendingAndRollsBackDeduction(t *testing.T) {
  271. ctx := context.Background()
  272. client := newPaymentConfigServiceTestClient(t)
  273. user, err := client.User.Create().
  274. SetEmail("refund-pending@example.com").
  275. SetPasswordHash("hash").
  276. SetUsername("refund-pending-user").
  277. Save(ctx)
  278. require.NoError(t, err)
  279. order, err := client.PaymentOrder.Create().
  280. SetUserID(user.ID).
  281. SetUserEmail(user.Email).
  282. SetUserName(user.Username).
  283. SetAmount(100).
  284. SetPayAmount(100).
  285. SetFeeRate(0).
  286. SetRechargeCode("REFUND-PENDING-ORDER").
  287. SetOutTradeNo("sub2_refund_pending_order").
  288. SetPaymentType(payment.TypeStripe).
  289. SetPaymentTradeNo("pi_refund_pending").
  290. SetOrderType(payment.OrderTypeBalance).
  291. SetStatus(OrderStatusRefunding).
  292. SetExpiresAt(time.Now().Add(time.Hour)).
  293. SetPaidAt(time.Now()).
  294. SetClientIP("127.0.0.1").
  295. SetSrcHost("api.example.com").
  296. Save(ctx)
  297. require.NoError(t, err)
  298. var rolledBack float64
  299. userRepo := &mockUserRepo{}
  300. userRepo.updateBalanceFn = func(ctx context.Context, id int64, amount float64) error {
  301. require.Equal(t, user.ID, id)
  302. rolledBack += amount
  303. return nil
  304. }
  305. svc := &PaymentService{
  306. entClient: client,
  307. userRepo: userRepo,
  308. }
  309. plan := &RefundPlan{
  310. OrderID: order.ID,
  311. Order: order,
  312. RefundAmount: 40,
  313. GatewayAmount: 40,
  314. Reason: "gateway accepted but not final",
  315. Force: true,
  316. DeductionType: payment.DeductionTypeBalance,
  317. BalanceToDeduct: 40,
  318. }
  319. result, err := svc.finishRefund(ctx, plan, &payment.RefundResponse{Status: payment.ProviderStatusPending})
  320. require.NoError(t, err)
  321. require.NotNil(t, result)
  322. require.False(t, result.Success)
  323. require.Contains(t, result.Warning, "pending confirmation")
  324. require.Equal(t, 40.0, rolledBack)
  325. require.Zero(t, plan.BalanceToDeduct)
  326. reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
  327. require.NoError(t, err)
  328. require.Equal(t, OrderStatusRefundPending, reloaded.Status)
  329. require.Equal(t, 40.0, reloaded.RefundAmount)
  330. require.NotNil(t, reloaded.RefundReason)
  331. require.Equal(t, "gateway accepted but not final", *reloaded.RefundReason)
  332. require.Nil(t, reloaded.RefundAt)
  333. pendingAudits, err := client.PaymentAuditLog.Query().
  334. Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_PENDING")).
  335. Count(ctx)
  336. require.NoError(t, err)
  337. require.Equal(t, 1, pendingAudits)
  338. successAudits, err := client.PaymentAuditLog.Query().
  339. Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_SUCCESS")).
  340. Count(ctx)
  341. require.NoError(t, err)
  342. require.Zero(t, successAudits)
  343. }
  344. func TestFinishRefundSuccessStatusesFinalize(t *testing.T) {
  345. for _, status := range []string{payment.ProviderStatusSuccess, payment.ProviderStatusRefunded} {
  346. t.Run(status, func(t *testing.T) {
  347. ctx := context.Background()
  348. client := newPaymentConfigServiceTestClient(t)
  349. user, err := client.User.Create().
  350. SetEmail("refund-success-" + status + "@example.com").
  351. SetPasswordHash("hash").
  352. SetUsername("refund-success-" + status).
  353. Save(ctx)
  354. require.NoError(t, err)
  355. order, err := client.PaymentOrder.Create().
  356. SetUserID(user.ID).
  357. SetUserEmail(user.Email).
  358. SetUserName(user.Username).
  359. SetAmount(100).
  360. SetPayAmount(100).
  361. SetFeeRate(0).
  362. SetRechargeCode("REFUND-SUCCESS-" + status).
  363. SetOutTradeNo("sub2_refund_success_" + status).
  364. SetPaymentType(payment.TypeStripe).
  365. SetPaymentTradeNo("pi_refund_success_" + status).
  366. SetOrderType(payment.OrderTypeBalance).
  367. SetStatus(OrderStatusRefunding).
  368. SetExpiresAt(time.Now().Add(time.Hour)).
  369. SetPaidAt(time.Now()).
  370. SetClientIP("127.0.0.1").
  371. SetSrcHost("api.example.com").
  372. Save(ctx)
  373. require.NoError(t, err)
  374. svc := &PaymentService{entClient: client}
  375. plan := &RefundPlan{
  376. OrderID: order.ID,
  377. Order: order,
  378. RefundAmount: 100,
  379. GatewayAmount: 100,
  380. Reason: "final success",
  381. DeductionType: payment.DeductionTypeBalance,
  382. BalanceToDeduct: 100,
  383. }
  384. result, err := svc.finishRefund(ctx, plan, &payment.RefundResponse{Status: status})
  385. require.NoError(t, err)
  386. require.NotNil(t, result)
  387. require.True(t, result.Success)
  388. require.Equal(t, 100.0, result.BalanceDeducted)
  389. reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
  390. require.NoError(t, err)
  391. require.Equal(t, OrderStatusRefunded, reloaded.Status)
  392. require.NotNil(t, reloaded.RefundAt)
  393. successAudits, err := client.PaymentAuditLog.Query().
  394. Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_SUCCESS")).
  395. Count(ctx)
  396. require.NoError(t, err)
  397. require.Equal(t, 1, successAudits)
  398. pendingAudits, err := client.PaymentAuditLog.Query().
  399. Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_PENDING")).
  400. Count(ctx)
  401. require.NoError(t, err)
  402. require.Zero(t, pendingAudits)
  403. })
  404. }
  405. }
  406. func TestQueryAndFinalizeRefundFinalizesProviderStatuses(t *testing.T) {
  407. for _, tc := range []struct {
  408. name string
  409. status string
  410. wantStatus string
  411. wantDeduct float64
  412. available float64
  413. }{
  414. {name: "success", status: payment.ProviderStatusSuccess, wantStatus: OrderStatusRefunded, wantDeduct: 100, available: 100},
  415. {name: "success clamps current balance", status: payment.ProviderStatusSuccess, wantStatus: OrderStatusRefunded, wantDeduct: 35, available: 35},
  416. {name: "failed", status: payment.ProviderStatusFailed, wantStatus: OrderStatusRefundFailed},
  417. {name: "pending", status: payment.ProviderStatusPending, wantStatus: OrderStatusRefundPending},
  418. } {
  419. t.Run(tc.name, func(t *testing.T) {
  420. ctx := context.Background()
  421. client := newPaymentConfigServiceTestClient(t)
  422. order := createPendingRefundOrderForTest(t, ctx, client, "query-finalize-"+tc.name)
  423. var deducted float64
  424. svc := &PaymentService{
  425. entClient: client,
  426. loadBalancer: &captureLoadBalancer{},
  427. userRepo: &mockUserRepo{deductAvailableBalanceFn: func(ctx context.Context, id int64, amount float64) (float64, error) {
  428. deducted += tc.available
  429. return tc.available, nil
  430. }},
  431. }
  432. restore := replacePaymentProviderFactoryForTest(t, &refundQueryProviderTestDouble{
  433. refundResponse: &payment.RefundResponse{RefundID: "rf_test", Status: tc.status},
  434. })
  435. defer restore()
  436. result, err := svc.QueryAndFinalizeRefund(ctx, order.ID)
  437. require.NoError(t, err)
  438. require.NotNil(t, result)
  439. require.Equal(t, tc.status == payment.ProviderStatusSuccess, result.Success)
  440. require.Equal(t, tc.wantDeduct, deducted)
  441. if tc.status == payment.ProviderStatusSuccess {
  442. require.Equal(t, tc.wantDeduct, result.BalanceDeducted)
  443. audit, err := client.PaymentAuditLog.Query().
  444. Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_SUCCESS")).
  445. Only(ctx)
  446. require.NoError(t, err)
  447. require.Contains(t, audit.Detail, fmt.Sprintf(`"balanceDeducted":%v`, tc.wantDeduct))
  448. }
  449. reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
  450. require.NoError(t, err)
  451. require.Equal(t, tc.wantStatus, reloaded.Status)
  452. })
  453. }
  454. }
  455. func TestFinalizePendingRefundSuccessRejectsStaleCallerBeforeSecondDeduction(t *testing.T) {
  456. ctx := context.Background()
  457. client := newPaymentConfigServiceTestClient(t)
  458. order := createPendingRefundOrderForTest(t, ctx, client, "finalize-stale")
  459. deductions := 0
  460. svc := &PaymentService{
  461. entClient: client,
  462. userRepo: &mockUserRepo{deductAvailableBalanceFn: func(ctx context.Context, id int64, amount float64) (float64, error) {
  463. require.NotNil(t, dbent.TxFromContext(ctx))
  464. deductions++
  465. return amount, nil
  466. }},
  467. }
  468. first, err := svc.finalizePendingRefundSuccess(ctx, svc.refundFinalizePlan(order))
  469. require.NoError(t, err)
  470. require.True(t, first.Success)
  471. second, err := svc.finalizePendingRefundSuccess(ctx, svc.refundFinalizePlan(order))
  472. require.Nil(t, second)
  473. require.Error(t, err)
  474. require.Equal(t, "CONFLICT", infraerrors.Reason(err))
  475. require.Equal(t, 1, deductions)
  476. successAudits, err := client.PaymentAuditLog.Query().
  477. Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_SUCCESS")).
  478. Count(ctx)
  479. require.NoError(t, err)
  480. require.Equal(t, 1, successAudits)
  481. }
  482. func TestFinalizePendingRefundSuccessRollsBackPostDeductionFailure(t *testing.T) {
  483. ctx := context.Background()
  484. client := newPaymentConfigServiceTestClient(t)
  485. order := createPendingRefundOrderForTest(t, ctx, client, "finalize-rollback")
  486. _, err := client.User.UpdateOneID(order.UserID).SetBalance(100).Save(ctx)
  487. require.NoError(t, err)
  488. svc := &PaymentService{
  489. entClient: client,
  490. userRepo: &mockUserRepo{deductAvailableBalanceFn: func(ctx context.Context, id int64, amount float64) (float64, error) {
  491. tx := dbent.TxFromContext(ctx)
  492. require.NotNil(t, tx)
  493. if _, updateErr := tx.Client().User.UpdateOneID(id).AddBalance(-amount).Save(ctx); updateErr != nil {
  494. return 0, updateErr
  495. }
  496. return 0, errors.New("injected failure after deduction")
  497. }},
  498. }
  499. result, err := svc.finalizePendingRefundSuccess(ctx, svc.refundFinalizePlan(order))
  500. require.Nil(t, result)
  501. require.ErrorContains(t, err, "injected failure after deduction")
  502. user, err := client.User.Get(ctx, order.UserID)
  503. require.NoError(t, err)
  504. require.Equal(t, 100.0, user.Balance)
  505. reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
  506. require.NoError(t, err)
  507. require.Equal(t, OrderStatusRefundPending, reloaded.Status)
  508. successAudits, err := client.PaymentAuditLog.Query().
  509. Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_SUCCESS")).
  510. Count(ctx)
  511. require.NoError(t, err)
  512. require.Zero(t, successAudits)
  513. }
  514. func TestQueryAndFinalizeRefundUnsupportedProviderReturnsClearError(t *testing.T) {
  515. ctx := context.Background()
  516. client := newPaymentConfigServiceTestClient(t)
  517. order := createPendingRefundOrderForTest(t, ctx, client, "query-finalize-unsupported")
  518. svc := &PaymentService{entClient: client, loadBalancer: &captureLoadBalancer{}}
  519. restore := replacePaymentProviderFactoryForTest(t, refundProviderTestDouble{})
  520. defer restore()
  521. result, err := svc.QueryAndFinalizeRefund(ctx, order.ID)
  522. require.Nil(t, result)
  523. require.Error(t, err)
  524. require.Equal(t, "REFUND_QUERY_UNSUPPORTED", infraerrors.Reason(err))
  525. }
  526. func createPendingRefundOrderForTest(t *testing.T, ctx context.Context, client *dbent.Client, suffix string) *dbent.PaymentOrder {
  527. t.Helper()
  528. user, err := client.User.Create().
  529. SetEmail(suffix + "@example.com").
  530. SetPasswordHash("hash").
  531. SetUsername(suffix).
  532. Save(ctx)
  533. require.NoError(t, err)
  534. inst, err := client.PaymentProviderInstance.Create().
  535. SetProviderKey(payment.TypeStripe).
  536. SetName(suffix + "-provider").
  537. SetConfig("{}").
  538. SetSupportedTypes("stripe").
  539. SetEnabled(true).
  540. SetRefundEnabled(true).
  541. Save(ctx)
  542. require.NoError(t, err)
  543. order, err := client.PaymentOrder.Create().
  544. SetUserID(user.ID).
  545. SetUserEmail(user.Email).
  546. SetUserName(user.Username).
  547. SetAmount(100).
  548. SetPayAmount(100).
  549. SetFeeRate(0).
  550. SetRechargeCode("REFUND-" + suffix).
  551. SetOutTradeNo("sub2_" + suffix).
  552. SetPaymentType(payment.TypeStripe).
  553. SetPaymentTradeNo("pi_" + suffix).
  554. SetOrderType(payment.OrderTypeBalance).
  555. SetStatus(OrderStatusRefundPending).
  556. SetRefundAmount(100).
  557. SetRefundReason("pending refund").
  558. SetExpiresAt(time.Now().Add(time.Hour)).
  559. SetPaidAt(time.Now()).
  560. SetClientIP("127.0.0.1").
  561. SetSrcHost("api.example.com").
  562. SetProviderInstanceID(strconv.FormatInt(inst.ID, 10)).
  563. Save(ctx)
  564. require.NoError(t, err)
  565. _, err = client.PaymentAuditLog.Create().
  566. SetOrderID(strconv.FormatInt(order.ID, 10)).
  567. SetAction("REFUND_PENDING").
  568. SetOperator("admin").
  569. SetDetail(`{"refundID":"rf_test","deductionRollbackOK":true}`).
  570. Save(ctx)
  571. require.NoError(t, err)
  572. return order
  573. }
  574. func replacePaymentProviderFactoryForTest(t *testing.T, prov payment.Provider) func() {
  575. t.Helper()
  576. original := createPaymentProviderFromInstance
  577. createPaymentProviderFromInstance = func(providerKey, instanceID string, config map[string]string) (payment.Provider, error) {
  578. return prov, nil
  579. }
  580. return func() { createPaymentProviderFromInstance = original }
  581. }
  582. type refundProviderTestDouble struct{}
  583. func (refundProviderTestDouble) Name() string { return "refund-test" }
  584. func (refundProviderTestDouble) ProviderKey() string {
  585. return payment.TypeStripe
  586. }
  587. func (refundProviderTestDouble) SupportedTypes() []payment.PaymentType {
  588. return []payment.PaymentType{payment.TypeStripe}
  589. }
  590. func (refundProviderTestDouble) CreatePayment(context.Context, payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
  591. return nil, nil
  592. }
  593. func (refundProviderTestDouble) QueryOrder(context.Context, string) (*payment.QueryOrderResponse, error) {
  594. return nil, nil
  595. }
  596. func (refundProviderTestDouble) VerifyNotification(context.Context, string, map[string]string) (*payment.PaymentNotification, error) {
  597. return nil, nil
  598. }
  599. func (refundProviderTestDouble) Refund(context.Context, payment.RefundRequest) (*payment.RefundResponse, error) {
  600. return nil, nil
  601. }
  602. type refundQueryProviderTestDouble struct {
  603. refundProviderTestDouble
  604. refundResponse *payment.RefundResponse
  605. }
  606. func (p *refundQueryProviderTestDouble) QueryRefund(context.Context, payment.RefundQueryRequest) (*payment.RefundResponse, error) {
  607. return p.refundResponse, nil
  608. }