package com.ruoyi.app.pay; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.ruoyi.system.domain.PosOrder; import com.ruoyi.system.domain.PosOrderLinePayment; import com.ruoyi.system.domain.PosOrderLineRefund; import com.ruoyi.system.service.IPosOrderLinePaymentService; import com.ruoyi.system.service.IPosOrderLineRefundService; import com.ruoyi.system.service.IPosOrderService; import com.ruoyi.system.mapper.PosOrderMapper; import com.ruoyi.app.order.OrderLifecycleService; import org.junit.jupiter.api.Test; import java.util.Date; 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; import static org.mockito.Mockito.times; class LinePayCancellationRaceTest { @Test void offlineCancellationFirstThenLateCaptureCreatesOneDurableRefundIntent() { IPosOrderService orders = mock(IPosOrderService.class); IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class); IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class); PosOrderLinePayment payment = payment("CONFIRM_UNKNOWN"); payment.setPaymentMode("OFFLINE"); payment.setLineOrderId("LPOFF-1"); when(payments.getById(70L)).thenReturn(payment); when(payments.markPaid(eq(70L), eq(2L), eq("CONFIRM_UNKNOWN"), eq("TX-1"), any(), any())).thenReturn(1); PosOrder cancelled = order(4L); when(orders.getOne(any(Wrapper.class))).thenReturn(cancelled); LinePayFactService facts = new LinePayFactService(orders, payments, refunds); facts.applyPaidFact(70L, "TX-1", "TSP", new Date()); verify(refunds).createIfAbsent(payment, "TASK"); assertEquals("PAID", payment.getStatus()); } @Test void tableOrderStateOneAcceptsCaptureWithoutCompensationRefund() { IPosOrderService orders = mock(IPosOrderService.class); IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class); IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class); PosOrderLinePayment payment = payment("WAITING_AUTH"); when(payments.getById(70L)).thenReturn(payment); when(payments.markPaid(eq(70L), eq(2L), eq("WAITING_AUTH"), eq("TX-1"), any(), any())).thenReturn(1); when(orders.getOne(any(Wrapper.class))).thenReturn(order(1L)); LinePayFactService facts = new LinePayFactService(orders, payments, refunds); facts.applyPaidFact(70L, "TX-1", "TSP", new Date()); verify(refunds, never()).createIfAbsent(any(), any()); } @Test void paidFirstThenCancellationUsesUniqueRefundRow() { IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class); IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class); PosOrderLinePayment payment = payment("PAID"); when(payments.getById(70L)).thenReturn(payment); PosOrderLineRefund existing = new PosOrderLineRefund(); existing.setPaymentId(70L); existing.setStatus("CREATED"); when(refunds.createIfAbsent(payment, "USER_CANCEL")).thenReturn(existing); LinePayRefundService service = new LinePayRefundService(payments, refunds, mock(com.ruoyi.system.service.IPosStoreLinePayService.class), mock(com.ruoyi.app.utils.linepay.LinePayClient.class), mock(LinePayFactService.class)); assertEquals("CREATED", service.createRefundIntent(70L, "USER_CANCEL")); assertEquals("CREATED", service.createRefundIntent(70L, "USER_CANCEL")); verify(refunds, org.mockito.Mockito.times(2)).createIfAbsent(payment, "USER_CANCEL"); } @Test void lateDuplicateCaptureCreatesCompensationRefundWhenOrderWasAlreadyPaidElsewhere() { IPosOrderService orders = mock(IPosOrderService.class); IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class); IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class); PosOrderMapper orderMapper = mock(PosOrderMapper.class); PosOrderLinePayment payment = payment("FAILED"); when(payments.getById(70L)).thenReturn(payment); when(payments.markPaid(eq(70L), eq(2L), eq("FAILED"), eq("TX-1"), any(), any())).thenReturn(1); PosOrder order = order(1L); order.setPayType("2"); order.setPayStatus(1L); when(orders.getOne(any(Wrapper.class))).thenReturn(order); when(orderMapper.markLinePaid("DD-1", 70L)).thenReturn(0); LinePayFactService facts = new LinePayFactService(orders, payments, refunds, orderMapper); facts.applyPaidFact(70L, "TX-1", "TSP", new Date()); verify(refunds).createIfAbsent(payment, "DUPLICATE_PAYMENT"); } @Test void firstSuccessfulOrderCaptureTriggersNotificationOnlyOnce() { IPosOrderService orders = mock(IPosOrderService.class); IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class); IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class); PosOrderMapper orderMapper = mock(PosOrderMapper.class); LinePayOrderNotificationService notifications = mock(LinePayOrderNotificationService.class); PosOrderLinePayment payment = payment("WAITING_AUTH"); when(payments.getById(70L)).thenReturn(payment); when(payments.markPaid(eq(70L), eq(2L), eq("WAITING_AUTH"), eq("TX-1"), any(), any())).thenReturn(1); PosOrder order = order(1L); when(orders.getOne(any(Wrapper.class))).thenReturn(order); when(orderMapper.markLinePaid("DD-1", 70L)).thenReturn(1, 0); LinePayFactService facts = new LinePayFactService(orders, payments, refunds, orderMapper, notifications, null); facts.applyPaidFact(70L, "TX-1", "TSP", new Date()); payment.setStatus("PAID"); facts.applyPaidFact(70L, "TX-1", "TSP", new Date()); verify(notifications, times(1)).paymentCaptured(order); } @Test void alreadyRefundedRetrieveDoesNotNotifyOrderAsNewlyPaid() { IPosOrderService orders = mock(IPosOrderService.class); IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class); IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class); PosOrderMapper orderMapper = mock(PosOrderMapper.class); LinePayOrderNotificationService notifications = mock(LinePayOrderNotificationService.class); PosOrderLinePayment payment = payment("WAITING_AUTH"); when(payments.getById(70L)).thenReturn(payment); when(payments.markPaid(eq(70L), eq(2L), eq("WAITING_AUTH"), eq("TX-1"), any(), any())).thenReturn(1); PosOrder order = order(1L); when(orders.getOne(any(Wrapper.class))).thenReturn(order); when(orderMapper.markLinePaid("DD-1", 70L)).thenReturn(1); when(orderMapper.markLineRefunded("DD-1", 70L)).thenReturn(1); PosOrderLineRefund refund = new PosOrderLineRefund(); refund.setId(80L); refund.setPaymentId(70L); refund.setStatus("CREATED"); refund.setVersion(1L); when(refunds.createIfAbsent(payment, "RETRIEVE")).thenReturn(refund); when(refunds.markRefundedFromEvidence(eq(80L), eq(1L), eq("CREATED"), eq("RF-1"), any())).thenReturn(1); LinePayFactService facts = new LinePayFactService(orders, payments, refunds, orderMapper, notifications, null); facts.applyPaidAndRefundedFact(70L, "TX-1", "TSP", new Date(), "RF-1", new Date()); verify(notifications, never()).paymentCaptured(any()); } @Test void verifiedFullRefundEvidenceEscalatesPriorFailedRefund() { IPosOrderService orders = mock(IPosOrderService.class); IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class); IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class); PosOrderMapper orderMapper = mock(PosOrderMapper.class); PosOrderLinePayment payment = payment("PAID"); when(payments.getById(70L)).thenReturn(payment); when(payments.markPaid(eq(70L), eq(2L), eq("PAID"), eq("TX-1"), any(), any())).thenReturn(1); PosOrder order = order(1L); order.setPayStatus(1L); when(orders.getOne(any(Wrapper.class))).thenReturn(order); when(orderMapper.markLineRefunded("DD-1", 70L)).thenReturn(1); PosOrderLineRefund failed = new PosOrderLineRefund(); failed.setId(80L); failed.setPaymentId(70L); failed.setStatus("FAILED"); failed.setVersion(3L); when(refunds.createIfAbsent(payment, "RETRIEVE")).thenReturn(failed); when(refunds.markRefundedFromEvidence(eq(80L), eq(3L), eq("FAILED"), eq("RF-EXTERNAL"), any())).thenReturn(1); LinePayFactService facts = new LinePayFactService(orders, payments, refunds, orderMapper, mock(LinePayOrderNotificationService.class), null); facts.applyPaidAndRefundedFact(70L, "TX-1", "TSP", new Date(), "RF-EXTERNAL", new Date()); verify(refunds).markRefundedFromEvidence(eq(80L), eq(3L), eq("FAILED"), eq("RF-EXTERNAL"), any()); verify(refunds, never()).markRefunded(any(), any(), any(), any()); } @Test void partialRefundEvidenceRecordsPaymentFactWithoutFulfillingOrder() { IPosOrderService orders = mock(IPosOrderService.class); IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class); IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class); PosOrderMapper orderMapper = mock(PosOrderMapper.class); LinePayOrderNotificationService notifications = mock(LinePayOrderNotificationService.class); PosOrderLinePayment payment = payment("WAITING_AUTH"); when(payments.getById(70L)).thenReturn(payment); when(payments.markPaid(eq(70L), eq(2L), eq("WAITING_AUTH"), eq("TX-1"), any(), any())).thenReturn(1); PosOrderLineRefund refund = new PosOrderLineRefund(); refund.setId(80L); refund.setPaymentId(70L); refund.setStatus("CREATED"); refund.setVersion(1L); when(refunds.createIfAbsent(payment, "RETRIEVE")).thenReturn(refund); when(refunds.markManualReviewFromEvidence(80L, 1L, "CREATED")).thenReturn(1); LinePayFactService facts = new LinePayFactService(orders, payments, refunds, orderMapper, notifications, null); facts.applyPaidWithRefundReviewFact(70L, "TX-1", "TSP", new Date()); verify(orderMapper, never()).markLinePaid(any(), any()); verify(notifications, never()).paymentCaptured(any()); verify(refunds).markManualReviewFromEvidence(80L, 1L, "CREATED"); } @Test void ambiguousLaterEvidenceNeverDowngradesConfirmedRefund() { IPosOrderService orders = mock(IPosOrderService.class); IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class); IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class); PosOrderLinePayment payment = payment("PAID"); when(payments.getById(70L)).thenReturn(payment); when(payments.markPaid(eq(70L), eq(2L), eq("PAID"), eq("TX-1"), any(), any())).thenReturn(1); PosOrderLineRefund refunded = new PosOrderLineRefund(); refunded.setId(80L); refunded.setPaymentId(70L); refunded.setStatus("REFUNDED"); refunded.setVersion(3L); when(refunds.createIfAbsent(payment, "RETRIEVE")).thenReturn(refunded); LinePayFactService facts = new LinePayFactService(orders, payments, refunds); facts.applyPaidWithRefundReviewFact(70L, "TX-1", "TSP", new Date()); verify(refunds, never()).markManualReview(any(), any(), any()); } @Test void verifiedPartialRefundEvidenceEscalatesPriorFailedRefund() { IPosOrderService orders = mock(IPosOrderService.class); IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class); IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class); PosOrderLinePayment payment = payment("PAID"); when(payments.getById(70L)).thenReturn(payment); when(payments.markPaid(eq(70L), eq(2L), eq("PAID"), eq("TX-1"), any(), any())).thenReturn(1); PosOrderLineRefund failed = new PosOrderLineRefund(); failed.setId(80L); failed.setPaymentId(70L); failed.setStatus("FAILED"); failed.setVersion(3L); when(refunds.createIfAbsent(payment, "RETRIEVE")).thenReturn(failed); when(refunds.markManualReviewFromEvidence(80L, 3L, "FAILED")).thenReturn(1); LinePayFactService facts = new LinePayFactService(orders, payments, refunds); facts.applyPaidWithRefundReviewFact(70L, "TX-1", "TSP", new Date()); verify(refunds).markManualReviewFromEvidence(80L, 3L, "FAILED"); verify(refunds, never()).markManualReview(any(), any(), any()); } @Test void cancelledLateCaptureCanCommitFullRefundWithoutPaymentNotification() { IPosOrderService orders = mock(IPosOrderService.class); IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class); IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class); PosOrderMapper orderMapper = mock(PosOrderMapper.class); LinePayOrderNotificationService notifications = mock(LinePayOrderNotificationService.class); OrderLifecycleService lifecycle = mock(OrderLifecycleService.class); PosOrderLinePayment payment = payment("CONFIRM_UNKNOWN"); when(payments.getById(70L)).thenReturn(payment); when(payments.markPaid(eq(70L), eq(2L), eq("CONFIRM_UNKNOWN"), eq("TX-1"), any(), any())).thenReturn(1); PosOrder cancelled = order(4L); when(orders.getOne(any(Wrapper.class))).thenReturn(cancelled); when(orderMapper.markLinePaid("DD-1", 70L)).thenAnswer(invocation -> { cancelled.setPayStatus(1L); return 1; }); PosOrderLineRefund refund = new PosOrderLineRefund(); refund.setId(80L); refund.setPaymentId(70L); refund.setStatus("CREATED"); refund.setVersion(1L); when(refunds.createIfAbsent(payment, "TASK")).thenReturn(refund); when(refunds.markRefunded(eq(80L), eq(1L), eq("RF-1"), any())).thenReturn(1); when(orderMapper.markLineRefunded("DD-1", 70L)).thenAnswer(invocation -> { cancelled.setPayStatus(2L); return 1; }); LinePayFactService facts = new LinePayFactService(orders, payments, refunds, orderMapper, notifications, lifecycle); facts.applyPaidFact(70L, "TX-1", "TSP", new Date()); facts.applyRefundedFact(80L, 70L, 1L, "RF-1", new Date()); assertEquals(2L, cancelled.getPayStatus()); verify(notifications, never()).paymentCaptured(any()); verify(lifecycle).finalizeSystemLineRefund(9L); } @Test void amountMismatchFactCreatesRefundIntentOnlyAfterEvidenceCas() { IPosOrderService orders = mock(IPosOrderService.class); IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class); IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class); PosOrderLinePayment persisted = payment("AMOUNT_MISMATCH"); persisted.setCapturedAmount(100); when(payments.markOfflineEvidence(eq(70L), eq(2L), eq("REQUESTING"), eq("AMOUNT_MISMATCH"), eq("TX-1"), eq("TSP"), eq(100), any(Date.class))) .thenReturn(1); when(payments.getById(70L)).thenReturn(persisted); LinePayFactService facts = new LinePayFactService(orders, payments, refunds); assertEquals(true, facts.applyAmountMismatchFact(70L, 2L, "REQUESTING", "TX-1", "TSP", 100, new Date())); verify(refunds).createIfAbsent(persisted, "AMOUNT_MISMATCH"); } @Test void failedAmountMismatchEvidenceCasNeverCreatesRefundIntent() { IPosOrderService orders = mock(IPosOrderService.class); IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class); IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class); when(payments.markOfflineEvidence(eq(70L), eq(2L), eq("REQUESTING"), eq("AMOUNT_MISMATCH"), eq("TX-1"), eq("TSP"), eq(100), any(Date.class))) .thenReturn(0); LinePayFactService facts = new LinePayFactService(orders, payments, refunds); assertEquals(false, facts.applyAmountMismatchFact(70L, 2L, "REQUESTING", "TX-1", "TSP", 100, new Date())); verify(refunds, never()).createIfAbsent(any(), any()); } private static PosOrderLinePayment payment(String status) { PosOrderLinePayment payment = new PosOrderLinePayment(); payment.setId(70L); payment.setDdId("DD-1"); payment.setTransactionId("TX-1"); payment.setStatus(status); payment.setVersion(2L); return payment; } private static PosOrder order(Long state) { PosOrder order = new PosOrder(); order.setId(9L); order.setDdId("DD-1"); order.setState(state); order.setPayType("3"); order.setPayStatus(0L); order.setAfterSaleStatus(0L); return order; } }