package com.ruoyi.app.pay; import com.alibaba.fastjson2.JSONObject; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.ruoyi.app.utils.linepay.LinePayClient; import com.ruoyi.app.utils.linepay.LinePayResponse; import com.ruoyi.system.domain.PosOrder; import com.ruoyi.system.domain.PosOrderLinePayment; import com.ruoyi.system.domain.PosOrderLineRefund; import com.ruoyi.system.domain.PosStoreLinePay; import com.ruoyi.system.service.IPosOrderLinePaymentService; import com.ruoyi.system.service.IPosOrderLineRefundService; import com.ruoyi.system.service.IPosOrderService; import com.ruoyi.system.service.IPosStoreLinePayService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class LinePayReconcileTest { private IPosOrderService orderService; private IPosStoreLinePayService credentialService; private IPosOrderLinePaymentService paymentService; private IPosOrderLineRefundService refundService; private LinePayClient client; private LinePayFactService factService; private LinePayService service; @BeforeEach void setUp() { orderService = mock(IPosOrderService.class); credentialService = mock(IPosStoreLinePayService.class); paymentService = mock(IPosOrderLinePaymentService.class); refundService = mock(IPosOrderLineRefundService.class); client = mock(LinePayClient.class); factService = mock(LinePayFactService.class); service = new LinePayService(orderService, credentialService, paymentService, refundService, client, factService); when(paymentService.getById(70L)).thenReturn(payment("WAITING_AUTH")); when(credentialService.getById(5L)).thenReturn(credential()); } @Test void retrieveUniqueCapturedPaymentAppliesPaidFactWithoutCheckOrConfirm() throws Exception { when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(response( "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\"," + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\"," + "\"currency\":\"TWD\",\"payInfo\":[{\"amount\":280}]}]}")); String status = service.reconcilePayment(70L, "TASK"); assertEquals("PAID", status); verify(factService).applyPaidFact(eq(70L), eq("TX-100"), any(), any()); verify(client, never()).check(any(), any()); verify(client, never()).confirm(any(), any(), any(Integer.class), any()); } @Test void rotatedSameChannelCredentialMayRecoverOnlyAfterStrictRetrieveProof() throws Exception { PosStoreLinePay current = currentCredential("channel"); when(credentialService.getCurrent(9L)).thenReturn(current); when(client.retrieveByTransactionId(eq(new com.ruoyi.app.utils.linepay.LinePayCredential( 5L, "channel", "secret")), eq("TX-100"))).thenReturn( response("{\"returnCode\":\"1105\"}")); when(client.retrieveByTransactionId(eq(new com.ruoyi.app.utils.linepay.LinePayCredential( 6L, "channel", "new-secret")), eq("TX-100"))).thenReturn(response( "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\"," + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\"," + "\"currency\":\"TWD\",\"payInfo\":[{\"amount\":280}]}]}")); assertEquals("PAID", service.reconcilePayment(70L, "TASK")); verify(factService).applyPaidFact(eq(70L), eq("TX-100"), any(), any()); } @Test void rotatedDifferentChannelCredentialIsNeverUsedForOldTransaction() throws Exception { when(credentialService.getCurrent(9L)).thenReturn(currentCredential("different-channel")); when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn( response("{\"returnCode\":\"1105\"}")); assertEquals("WAITING_AUTH", service.reconcilePayment(70L, "TASK")); verify(client, org.mockito.Mockito.times(1)).retrieveByTransactionId(any(), eq("TX-100")); verify(factService, never()).applyPaidFact(any(), any(), any(), any()); } @Test void confirmedLocalRefundIsNeverDowngradedByLaterAmbiguousEvidence() throws Exception { PosOrderLineRefund refunded = new PosOrderLineRefund(); refunded.setPaymentId(70L); refunded.setStatus("REFUNDED"); when(refundService.getByPaymentId(70L)).thenReturn(refunded); when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(response( "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\"," + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\"," + "\"currency\":\"TWD\",\"payInfo\":[{\"amount\":280}]," + "\"refundList\":[{\"refundTransactionId\":\"RF-PART\"," + "\"refundAmount\":-100}]}]}")); assertEquals("REFUNDED", service.reconcilePayment(70L, "TASK")); verify(factService, never()).applyPaidWithRefundReviewFact(any(), any(), any(), any()); } @Test void check0110ConfirmsOnlyAfterLocalClaimAndThenAppliesPaidFact() throws Exception { when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn( response("{\"returnCode\":\"1150\"}")); when(client.check(any(), eq("TX-100"))).thenReturn( response("{\"returnCode\":\"0110\"}")); when(orderService.getOne(any(Wrapper.class))).thenReturn(order(0L)); when(paymentService.claimConfirm(eq(70L), eq(2L), eq("WAITING_AUTH"), any(), any())).thenReturn(1); when(client.confirm(any(), eq("TX-100"), eq(280), eq("TWD"))).thenReturn( response("{\"returnCode\":\"0000\",\"info\":{\"transactionId\":\"TX-100\"," + "\"orderId\":\"LP-100\",\"currency\":\"TWD\"," + "\"payInfo\":[{\"amount\":280}]}}")); String status = service.reconcilePayment(70L, "TASK"); assertEquals("PAID", status); verify(paymentService).claimConfirm(eq(70L), eq(2L), eq("WAITING_AUTH"), any(), any()); verify(factService).applyPaidFact(eq(70L), eq("TX-100"), any(), any()); } @Test void check0121TerminatesOnlyAfterRetrieveFoundNoPayment() throws Exception { when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn( response("{\"returnCode\":\"1150\"}")); when(client.check(any(), eq("TX-100"))).thenReturn( response("{\"returnCode\":\"0121\"}")); when(paymentService.markTerminal(70L, 2L, "WAITING_AUTH", "CANCELLED_OR_EXPIRED")) .thenReturn(1); String status = service.reconcilePayment(70L, "TASK"); assertEquals("CANCELLED_OR_EXPIRED", status); verify(paymentService).markTerminal(70L, 2L, "WAITING_AUTH", "CANCELLED_OR_EXPIRED"); } @Test void cancelledOrderIsNeverConfirmedAfterAuthorization() throws Exception { when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn( response("{\"returnCode\":\"1150\"}")); when(client.check(any(), eq("TX-100"))).thenReturn( response("{\"returnCode\":\"0110\"}")); when(orderService.getOne(any(Wrapper.class))).thenReturn(order(4L)); when(paymentService.markAuthDoneOrderCancelled(eq(70L), eq(2L), eq("WAITING_AUTH"), any(), any())) .thenReturn(1); String status = service.reconcilePayment(70L, "TASK"); assertEquals("AUTH_DONE_ORDER_CANCELLED", status); verify(client, never()).confirm(any(), any(), any(Integer.class), any()); } @Test void requestUnknownCanRecoverCapturedTransactionByOrderId() throws Exception { PosOrderLinePayment unknown = payment("REQUEST_UNKNOWN"); unknown.setTransactionId(null); when(paymentService.getById(70L)).thenReturn(unknown); when(client.retrieveByOrderId(any(), eq("LP-100"))).thenReturn(response( "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-RECOVERED\"," + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\"," + "\"currency\":\"TWD\",\"payInfo\":[{\"amount\":280}]}]}")); String status = service.reconcilePayment(70L, "TASK"); assertEquals("PAID", status); verify(factService).applyPaidFact(eq(70L), eq("TX-RECOVERED"), any(), any()); } @Test void check0123ImmediatelyRetrievesAgainBeforeDeciding() throws Exception { when(client.retrieveByTransactionId(any(), eq("TX-100"))) .thenReturn(response("{\"returnCode\":\"1150\"}")) .thenReturn(response("{\"returnCode\":\"0000\",\"info\":[{" + "\"transactionId\":\"TX-100\",\"orderId\":\"LP-100\"," + "\"transactionType\":\"PAYMENT\",\"currency\":\"TWD\"," + "\"payInfo\":[{\"amount\":280}]}]}")); when(client.check(any(), eq("TX-100"))).thenReturn( response("{\"returnCode\":\"0123\"}")); String status = service.reconcilePayment(70L, "TASK"); assertEquals("PAID", status); verify(factService).applyPaidFact(eq(70L), eq("TX-100"), any(), any()); } @Test void expiredRecoveryPerformsFinalRetrieveThenStopsForManualReview() throws Exception { PosOrderLinePayment expired = payment("CONFIRM_UNKNOWN"); expired.setReconcileDeadline(new java.util.Date(1L)); when(paymentService.getById(70L)).thenReturn(expired); when(client.retrieveByTransactionId(any(), eq("TX-100"))) .thenThrow(new IllegalStateException("timeout")); when(paymentService.markManualReview(70L, 2L, "CONFIRM_UNKNOWN")).thenReturn(1); assertEquals("MANUAL_REVIEW", service.reconcilePayment(70L, "TASK")); verify(paymentService).markManualReview(70L, 2L, "CONFIRM_UNKNOWN"); } @Test void confirmUnknownNeverChecksOrConfirmsAgain() throws Exception { PosOrderLinePayment unknown = payment("CONFIRM_UNKNOWN"); when(paymentService.getById(70L)).thenReturn(unknown); when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn( response("{\"returnCode\":\"1150\"}")); assertEquals("CONFIRM_UNKNOWN", service.reconcilePayment(70L, "TASK")); verify(client, never()).check(any(), any()); verify(client, never()).confirm(any(), any(), any(Integer.class), any()); } @Test void retrieveAmountMismatchDoesNotApplyPaidFact() throws Exception { when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(response( "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\"," + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\"," + "\"currency\":\"TWD\",\"payInfo\":[{\"amount\":100}]}]}")); assertEquals("WAITING_AUTH", service.reconcilePayment(70L, "TASK")); verify(factService, never()).applyPaidFact(any(), any(), any(), any()); verify(client, never()).check(any(), any()); } @Test void retrieveAlreadyFullyRefundedPaymentAppliesBothFacts() throws Exception { when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(response( "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\"," + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\"," + "\"currency\":\"TWD\",\"payInfo\":[{\"amount\":280}]," + "\"refundList\":[{\"refundTransactionId\":\"RF-1\"," + "\"transactionType\":\"PARTIAL_REFUND\",\"refundAmount\":-280}]}]}")); assertEquals("REFUNDED", service.reconcilePayment(70L, "TASK")); verify(factService).applyPaidAndRefundedFact(eq(70L), eq("TX-100"), any(), any(), eq("RF-1"), any()); verify(factService, never()).applyPaidFact(any(), any(), any(), any()); } @Test void retrievePartiallyRefundedPaymentStopsForManualReviewWithoutFulfillment() throws Exception { when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(response( "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\"," + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\"," + "\"currency\":\"TWD\",\"payInfo\":[{\"amount\":280}]," + "\"refundList\":[{\"refundTransactionId\":\"RF-PART\"," + "\"refundAmount\":-100}]}]}")); assertEquals("MANUAL_REVIEW", service.reconcilePayment(70L, "TASK")); verify(factService).applyPaidWithRefundReviewFact(eq(70L), eq("TX-100"), any(), any()); verify(factService, never()).applyPaidFact(any(), any(), any(), any()); } private static LinePayResponse response(String raw) { JSONObject root = JSONObject.parseObject(raw); return new LinePayResponse(200, root.getString("returnCode"), root.getString("returnMessage"), null, root.getJSONObject("info"), raw); } private static PosOrderLinePayment payment(String status) { PosOrderLinePayment payment = new PosOrderLinePayment(); payment.setId(70L); payment.setDdId("DD-100"); payment.setLineOrderId("LP-100"); payment.setTransactionId("TX-100"); payment.setCredentialId(5L); payment.setStoreId(9L); payment.setAmount(280); payment.setCurrency("TWD"); payment.setStatus(status); payment.setActiveDdId("DD-100"); payment.setVersion(2L); return payment; } private static PosStoreLinePay credential() { PosStoreLinePay value = new PosStoreLinePay(); value.setId(5L); value.setEnvironment("SANDBOX"); value.setChannelId("channel"); value.setChannelSecret("secret"); return value; } private static PosStoreLinePay currentCredential(String channelId) { PosStoreLinePay value = new PosStoreLinePay(); value.setId(6L); value.setStoreId(9L); value.setEnvironment("SANDBOX"); value.setChannelId(channelId); value.setChannelSecret("new-secret"); return value; } private static PosOrder order(Long state) { PosOrder order = new PosOrder(); order.setDdId("DD-100"); order.setMdId(9L); order.setAmount(280); order.setPayType("3"); order.setPayStatus(0L); order.setState(state); return order; } }