LinePayReconcileTest.java 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. package com.ruoyi.app.pay;
  2. import com.alibaba.fastjson2.JSONObject;
  3. import com.baomidou.mybatisplus.core.conditions.Wrapper;
  4. import com.ruoyi.app.utils.linepay.LinePayClient;
  5. import com.ruoyi.app.utils.linepay.LinePayResponse;
  6. import com.ruoyi.system.domain.PosOrder;
  7. import com.ruoyi.system.domain.PosOrderLinePayment;
  8. import com.ruoyi.system.domain.PosOrderLineRefund;
  9. import com.ruoyi.system.domain.PosStoreLinePay;
  10. import com.ruoyi.system.service.IPosOrderLinePaymentService;
  11. import com.ruoyi.system.service.IPosOrderLineRefundService;
  12. import com.ruoyi.system.service.IPosOrderService;
  13. import com.ruoyi.system.service.IPosStoreLinePayService;
  14. import org.junit.jupiter.api.BeforeEach;
  15. import org.junit.jupiter.api.Test;
  16. import static org.junit.jupiter.api.Assertions.assertEquals;
  17. import static org.mockito.ArgumentMatchers.any;
  18. import static org.mockito.ArgumentMatchers.eq;
  19. import static org.mockito.Mockito.mock;
  20. import static org.mockito.Mockito.never;
  21. import static org.mockito.Mockito.verify;
  22. import static org.mockito.Mockito.when;
  23. class LinePayReconcileTest {
  24. private IPosOrderService orderService;
  25. private IPosStoreLinePayService credentialService;
  26. private IPosOrderLinePaymentService paymentService;
  27. private IPosOrderLineRefundService refundService;
  28. private LinePayClient client;
  29. private LinePayFactService factService;
  30. private LinePayService service;
  31. @BeforeEach
  32. void setUp() {
  33. orderService = mock(IPosOrderService.class);
  34. credentialService = mock(IPosStoreLinePayService.class);
  35. paymentService = mock(IPosOrderLinePaymentService.class);
  36. refundService = mock(IPosOrderLineRefundService.class);
  37. client = mock(LinePayClient.class);
  38. factService = mock(LinePayFactService.class);
  39. service = new LinePayService(orderService, credentialService, paymentService,
  40. refundService, client, factService);
  41. when(paymentService.getById(70L)).thenReturn(payment("WAITING_AUTH"));
  42. when(credentialService.getById(5L)).thenReturn(credential());
  43. }
  44. @Test
  45. void retrieveUniqueCapturedPaymentAppliesPaidFactWithoutCheckOrConfirm() throws Exception {
  46. when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(response(
  47. "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\","
  48. + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\","
  49. + "\"currency\":\"TWD\",\"payInfo\":[{\"amount\":280}]}]}"));
  50. String status = service.reconcilePayment(70L, "TASK");
  51. assertEquals("PAID", status);
  52. verify(factService).applyPaidFact(eq(70L), eq("TX-100"), any(), any());
  53. verify(client, never()).check(any(), any());
  54. verify(client, never()).confirm(any(), any(), any(Integer.class), any());
  55. }
  56. @Test
  57. void rotatedSameChannelCredentialMayRecoverOnlyAfterStrictRetrieveProof() throws Exception {
  58. PosStoreLinePay current = currentCredential("channel");
  59. when(credentialService.getCurrent(9L)).thenReturn(current);
  60. when(client.retrieveByTransactionId(eq(new com.ruoyi.app.utils.linepay.LinePayCredential(
  61. 5L, "channel", "secret")), eq("TX-100"))).thenReturn(
  62. response("{\"returnCode\":\"1105\"}"));
  63. when(client.retrieveByTransactionId(eq(new com.ruoyi.app.utils.linepay.LinePayCredential(
  64. 6L, "channel", "new-secret")), eq("TX-100"))).thenReturn(response(
  65. "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\","
  66. + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\","
  67. + "\"currency\":\"TWD\",\"payInfo\":[{\"amount\":280}]}]}"));
  68. assertEquals("PAID", service.reconcilePayment(70L, "TASK"));
  69. verify(factService).applyPaidFact(eq(70L), eq("TX-100"), any(), any());
  70. }
  71. @Test
  72. void rotatedDifferentChannelCredentialIsNeverUsedForOldTransaction() throws Exception {
  73. when(credentialService.getCurrent(9L)).thenReturn(currentCredential("different-channel"));
  74. when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(
  75. response("{\"returnCode\":\"1105\"}"));
  76. assertEquals("WAITING_AUTH", service.reconcilePayment(70L, "TASK"));
  77. verify(client, org.mockito.Mockito.times(1)).retrieveByTransactionId(any(), eq("TX-100"));
  78. verify(factService, never()).applyPaidFact(any(), any(), any(), any());
  79. }
  80. @Test
  81. void confirmedLocalRefundIsNeverDowngradedByLaterAmbiguousEvidence() throws Exception {
  82. PosOrderLineRefund refunded = new PosOrderLineRefund();
  83. refunded.setPaymentId(70L);
  84. refunded.setStatus("REFUNDED");
  85. when(refundService.getByPaymentId(70L)).thenReturn(refunded);
  86. when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(response(
  87. "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\","
  88. + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\","
  89. + "\"currency\":\"TWD\",\"payInfo\":[{\"amount\":280}],"
  90. + "\"refundList\":[{\"refundTransactionId\":\"RF-PART\","
  91. + "\"refundAmount\":-100}]}]}"));
  92. assertEquals("REFUNDED", service.reconcilePayment(70L, "TASK"));
  93. verify(factService, never()).applyPaidWithRefundReviewFact(any(), any(), any(), any());
  94. }
  95. @Test
  96. void check0110ConfirmsOnlyAfterLocalClaimAndThenAppliesPaidFact() throws Exception {
  97. when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(
  98. response("{\"returnCode\":\"1150\"}"));
  99. when(client.check(any(), eq("TX-100"))).thenReturn(
  100. response("{\"returnCode\":\"0110\"}"));
  101. when(orderService.getOne(any(Wrapper.class))).thenReturn(order(0L));
  102. when(paymentService.claimConfirm(eq(70L), eq(2L), eq("WAITING_AUTH"),
  103. any(), any())).thenReturn(1);
  104. when(client.confirm(any(), eq("TX-100"), eq(280), eq("TWD"))).thenReturn(
  105. response("{\"returnCode\":\"0000\",\"info\":{\"transactionId\":\"TX-100\","
  106. + "\"orderId\":\"LP-100\",\"currency\":\"TWD\","
  107. + "\"payInfo\":[{\"amount\":280}]}}"));
  108. String status = service.reconcilePayment(70L, "TASK");
  109. assertEquals("PAID", status);
  110. verify(paymentService).claimConfirm(eq(70L), eq(2L), eq("WAITING_AUTH"), any(), any());
  111. verify(factService).applyPaidFact(eq(70L), eq("TX-100"), any(), any());
  112. }
  113. @Test
  114. void check0121TerminatesOnlyAfterRetrieveFoundNoPayment() throws Exception {
  115. when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(
  116. response("{\"returnCode\":\"1150\"}"));
  117. when(client.check(any(), eq("TX-100"))).thenReturn(
  118. response("{\"returnCode\":\"0121\"}"));
  119. when(paymentService.markTerminal(70L, 2L, "WAITING_AUTH", "CANCELLED_OR_EXPIRED"))
  120. .thenReturn(1);
  121. String status = service.reconcilePayment(70L, "TASK");
  122. assertEquals("CANCELLED_OR_EXPIRED", status);
  123. verify(paymentService).markTerminal(70L, 2L, "WAITING_AUTH", "CANCELLED_OR_EXPIRED");
  124. }
  125. @Test
  126. void cancelledOrderIsNeverConfirmedAfterAuthorization() throws Exception {
  127. when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(
  128. response("{\"returnCode\":\"1150\"}"));
  129. when(client.check(any(), eq("TX-100"))).thenReturn(
  130. response("{\"returnCode\":\"0110\"}"));
  131. when(orderService.getOne(any(Wrapper.class))).thenReturn(order(4L));
  132. when(paymentService.markAuthDoneOrderCancelled(eq(70L), eq(2L), eq("WAITING_AUTH"),
  133. any(), any()))
  134. .thenReturn(1);
  135. String status = service.reconcilePayment(70L, "TASK");
  136. assertEquals("AUTH_DONE_ORDER_CANCELLED", status);
  137. verify(client, never()).confirm(any(), any(), any(Integer.class), any());
  138. }
  139. @Test
  140. void requestUnknownCanRecoverCapturedTransactionByOrderId() throws Exception {
  141. PosOrderLinePayment unknown = payment("REQUEST_UNKNOWN");
  142. unknown.setTransactionId(null);
  143. when(paymentService.getById(70L)).thenReturn(unknown);
  144. when(client.retrieveByOrderId(any(), eq("LP-100"))).thenReturn(response(
  145. "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-RECOVERED\","
  146. + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\","
  147. + "\"currency\":\"TWD\",\"payInfo\":[{\"amount\":280}]}]}"));
  148. String status = service.reconcilePayment(70L, "TASK");
  149. assertEquals("PAID", status);
  150. verify(factService).applyPaidFact(eq(70L), eq("TX-RECOVERED"), any(), any());
  151. }
  152. @Test
  153. void check0123ImmediatelyRetrievesAgainBeforeDeciding() throws Exception {
  154. when(client.retrieveByTransactionId(any(), eq("TX-100")))
  155. .thenReturn(response("{\"returnCode\":\"1150\"}"))
  156. .thenReturn(response("{\"returnCode\":\"0000\",\"info\":[{"
  157. + "\"transactionId\":\"TX-100\",\"orderId\":\"LP-100\","
  158. + "\"transactionType\":\"PAYMENT\",\"currency\":\"TWD\","
  159. + "\"payInfo\":[{\"amount\":280}]}]}"));
  160. when(client.check(any(), eq("TX-100"))).thenReturn(
  161. response("{\"returnCode\":\"0123\"}"));
  162. String status = service.reconcilePayment(70L, "TASK");
  163. assertEquals("PAID", status);
  164. verify(factService).applyPaidFact(eq(70L), eq("TX-100"), any(), any());
  165. }
  166. @Test
  167. void expiredRecoveryPerformsFinalRetrieveThenStopsForManualReview() throws Exception {
  168. PosOrderLinePayment expired = payment("CONFIRM_UNKNOWN");
  169. expired.setReconcileDeadline(new java.util.Date(1L));
  170. when(paymentService.getById(70L)).thenReturn(expired);
  171. when(client.retrieveByTransactionId(any(), eq("TX-100")))
  172. .thenThrow(new IllegalStateException("timeout"));
  173. when(paymentService.markManualReview(70L, 2L, "CONFIRM_UNKNOWN")).thenReturn(1);
  174. assertEquals("MANUAL_REVIEW", service.reconcilePayment(70L, "TASK"));
  175. verify(paymentService).markManualReview(70L, 2L, "CONFIRM_UNKNOWN");
  176. }
  177. @Test
  178. void confirmUnknownNeverChecksOrConfirmsAgain() throws Exception {
  179. PosOrderLinePayment unknown = payment("CONFIRM_UNKNOWN");
  180. when(paymentService.getById(70L)).thenReturn(unknown);
  181. when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(
  182. response("{\"returnCode\":\"1150\"}"));
  183. assertEquals("CONFIRM_UNKNOWN", service.reconcilePayment(70L, "TASK"));
  184. verify(client, never()).check(any(), any());
  185. verify(client, never()).confirm(any(), any(), any(Integer.class), any());
  186. }
  187. @Test
  188. void retrieveAmountMismatchDoesNotApplyPaidFact() throws Exception {
  189. when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(response(
  190. "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\","
  191. + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\","
  192. + "\"currency\":\"TWD\",\"payInfo\":[{\"amount\":100}]}]}"));
  193. assertEquals("WAITING_AUTH", service.reconcilePayment(70L, "TASK"));
  194. verify(factService, never()).applyPaidFact(any(), any(), any(), any());
  195. verify(client, never()).check(any(), any());
  196. }
  197. @Test
  198. void retrieveAlreadyFullyRefundedPaymentAppliesBothFacts() throws Exception {
  199. when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(response(
  200. "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\","
  201. + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\","
  202. + "\"currency\":\"TWD\",\"payInfo\":[{\"amount\":280}],"
  203. + "\"refundList\":[{\"refundTransactionId\":\"RF-1\","
  204. + "\"transactionType\":\"PARTIAL_REFUND\",\"refundAmount\":-280}]}]}"));
  205. assertEquals("REFUNDED", service.reconcilePayment(70L, "TASK"));
  206. verify(factService).applyPaidAndRefundedFact(eq(70L), eq("TX-100"), any(), any(),
  207. eq("RF-1"), any());
  208. verify(factService, never()).applyPaidFact(any(), any(), any(), any());
  209. }
  210. @Test
  211. void retrievePartiallyRefundedPaymentStopsForManualReviewWithoutFulfillment() throws Exception {
  212. when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(response(
  213. "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\","
  214. + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\","
  215. + "\"currency\":\"TWD\",\"payInfo\":[{\"amount\":280}],"
  216. + "\"refundList\":[{\"refundTransactionId\":\"RF-PART\","
  217. + "\"refundAmount\":-100}]}]}"));
  218. assertEquals("MANUAL_REVIEW", service.reconcilePayment(70L, "TASK"));
  219. verify(factService).applyPaidWithRefundReviewFact(eq(70L), eq("TX-100"), any(), any());
  220. verify(factService, never()).applyPaidFact(any(), any(), any(), any());
  221. }
  222. private static LinePayResponse response(String raw) {
  223. JSONObject root = JSONObject.parseObject(raw);
  224. return new LinePayResponse(200, root.getString("returnCode"),
  225. root.getString("returnMessage"), null, root.getJSONObject("info"), raw);
  226. }
  227. private static PosOrderLinePayment payment(String status) {
  228. PosOrderLinePayment payment = new PosOrderLinePayment();
  229. payment.setId(70L);
  230. payment.setDdId("DD-100");
  231. payment.setLineOrderId("LP-100");
  232. payment.setTransactionId("TX-100");
  233. payment.setCredentialId(5L);
  234. payment.setStoreId(9L);
  235. payment.setAmount(280);
  236. payment.setCurrency("TWD");
  237. payment.setStatus(status);
  238. payment.setActiveDdId("DD-100");
  239. payment.setVersion(2L);
  240. return payment;
  241. }
  242. private static PosStoreLinePay credential() {
  243. PosStoreLinePay value = new PosStoreLinePay();
  244. value.setId(5L);
  245. value.setEnvironment("SANDBOX");
  246. value.setChannelId("channel");
  247. value.setChannelSecret("secret");
  248. return value;
  249. }
  250. private static PosStoreLinePay currentCredential(String channelId) {
  251. PosStoreLinePay value = new PosStoreLinePay();
  252. value.setId(6L);
  253. value.setStoreId(9L);
  254. value.setEnvironment("SANDBOX");
  255. value.setChannelId(channelId);
  256. value.setChannelSecret("new-secret");
  257. return value;
  258. }
  259. private static PosOrder order(Long state) {
  260. PosOrder order = new PosOrder();
  261. order.setDdId("DD-100");
  262. order.setMdId(9L);
  263. order.setAmount(280);
  264. order.setPayType("3");
  265. order.setPayStatus(0L);
  266. order.setState(state);
  267. return order;
  268. }
  269. }