package com.talentcard.server.business.payment; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.Map; import com.talentcard.server.business.PageResult; import com.talentcard.server.business.merchant.MerchantOrderQuery; import com.talentcard.server.business.merchant.MerchantWorkService; import com.talentcard.server.business.payment.huifu.HuifuRefundGateway; import com.talentcard.server.business.portal.PendingUsePortalService; import com.talentcard.server.business.portal.UsageRecordPortalService; import com.talentcard.server.common.BusinessException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; /** * Seam: a submitted refund is not finished until the provider says so, and the provider's * notification cannot be relied on. Reading the order must therefore ask the provider about a refund * that is still in flight, so the order stops looking verifiable the moment money goes back. * *

A refund in flight is never shown as "waiting to be verified": either the provider confirms it * (the order leaves the verifiable set for good) or it is still in flight (it stays unverifiable * until the provider decides). Only the provider's answer may change that. */ @SpringBootTest @ActiveProfiles("test") class PendingRefundConfirmationTests { @Autowired private JdbcTemplate jdbc; @Autowired private PendingUsePortalService pendingUsePortalService; @Autowired private UsageRecordPortalService usageRecordPortalService; @Autowired private MerchantWorkService merchantWorkService; @MockitoBean private HuifuRefundGateway refundGateway; private Long talentId; private Long merchantId; private String huifuId; private String suffix; private long sequence; @BeforeEach void prepareData() { suffix = String.valueOf(System.nanoTime()); jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)", "pending-refund-openid-" + suffix, "Pending refund user", "13900000004"); talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class); jdbc.update("insert into merchant(merchant_name, contact_mobile, address, status, deleted) values (?, ?, ?, 1, 0)", "Pending refund merchant " + suffix, "08120000003", "Test address"); merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class); huifuId = "H" + suffix.substring(Math.max(0, suffix.length() - 8)); sequence = 0; } @Test void openingAnOrderConvergesTheRefundThatIsStillInFlight() { RefundingOrder order = refundingOrder(1); providerSettlesTheRefund(); Map detail = pendingUsePortalService.detail(order.orderNo(), talentId); verify(refundGateway).queryRefund(any(), any()); assertThat(((Number) detail.get("refundStatus")).intValue()).isEqualTo(2); assertThat(jdbc.queryForMap("select order_status, refund_status from benefit_order where order_no = ?", order.orderNo())) .containsEntry("order_status", 2) .containsEntry("refund_status", 2); assertThat(jdbc.queryForObject("select refund_status from order_refund where refund_no = ?", Integer.class, order.refundNo())) .isEqualTo(2); } @Test void theRecordListConvergesTheRefundInsteadOfLeavingTheOrderWaitingToBeVerified() { RefundingOrder order = refundingOrder(1); providerSettlesTheRefund(); Map record = recordOf(order.orderNo()); verify(refundGateway).queryRefund(any(), any()); assertThat(record).containsEntry("status", "cancelled"); } @Test void anOrderWhoseRefundIsStillInFlightIsNotWaitingToBeVerifiedEither() { RefundingOrder order = refundingOrder(1); when(refundGateway.queryRefund(any(), any())).thenReturn(providerResponse(Map.of( "trans_stat", "P", "resp_code", "00000100", "sub_resp_desc", "交易处理中", "hf_seq_id", "HF-PENDING-" + suffix))); assertThat(recordOf(order.orderNo())).containsEntry("status", "cancelled"); assertThat(jdbc.queryForObject("select refund_status from order_refund where refund_no = ?", Integer.class, order.refundNo())) .as("通道还在处理就不能自行判定退款成功") .isEqualTo(1); assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, order.orderNo())) .isEqualTo(1); } @Test void anAlreadyRefundedOrderIsNotWaitingToBeVerifiedAndAsksTheProviderNothing() { RefundingOrder order = refundingOrder(2); assertThat(recordOf(order.orderNo())).containsEntry("status", "cancelled"); verify(refundGateway, never()).queryRefund(any(), any()); } @Test void anOrderWithARefundInFlightOpensOnTheCancelledDetail() { RefundingOrder order = refundingOrder(1); when(refundGateway.queryRefund(any(), any())).thenReturn(providerResponse(Map.of( "trans_stat", "P", "resp_code", "00000100", "sub_resp_desc", "交易处理中", "hf_seq_id", "HF-PENDING-" + suffix))); Map detail = pendingUsePortalService.cancelledDetail(order.orderNo(), talentId); assertThat(detail).containsEntry("refundStatusText", "退款中"); assertThat(detail).containsEntry("cancelSource", "退款处理"); } @Test void anOrderWithARefundStillInFlightDoesNotIssueAVerificationCode() { RefundingOrder order = refundingOrder(1); when(refundGateway.queryRefund(any(), any())).thenReturn(providerResponse(Map.of( "trans_stat", "P", "resp_code", "00000100", "sub_resp_desc", "交易处理中", "hf_seq_id", "HF-PENDING-" + suffix))); assertThatThrownBy(() -> pendingUsePortalService.issueCode(order.orderNo(), talentId)) .isInstanceOf(BusinessException.class) .hasMessageContaining("不可核销"); } @Test void aRefundConfirmedByAQueryKeepsTheProviderReferenceItWasGiven() { RefundingOrder order = refundingOrder(1); // 退款查询回执里没有通道退款流水号(只有发起退款时那次回执有)。 when(refundGateway.queryRefund(any(), any())).thenReturn(providerResponse(Map.of( "trans_stat", "S", "resp_code", "00000000", "sub_resp_desc", "查询成功"))); pendingUsePortalService.detail(order.orderNo(), talentId); assertThat(jdbc.queryForObject("select provider_refund_no from order_refund where refund_no = ?", String.class, order.refundNo())) .as("查单确认不能把发起退款时拿到的通道退款流水号覆盖成空") .isEqualTo(order.providerRefundNo()); } @Test void theMerchantOrderListConvergesTheRefundTooInsteadOfShowingItAsRefunding() { RefundingOrder order = refundingOrder(1); providerSettlesTheRefund(); Map record = merchantRecordOf(order.orderNo()); verify(refundGateway).queryRefund(any(), any()); assertThat(record) .as("商户看到的退款状态不该比人才端晚几分钟") .containsEntry("status", "refunded") .containsEntry("statusText", "已退款"); } @Test void theTalentOrderListCarriesTheRefundStateSoARefundIsNotJustAnotherCancellation() { RefundingOrder refunded = refundingOrder(2); assertThat(((Number) recordOf(refunded.orderNo()).get("refundStatus")).intValue()) .as("列表要能区分「未支付被取消」和「钱已经退回来」") .isEqualTo(2); RefundingOrder refunding = refundingOrder(1); when(refundGateway.queryRefund(any(), any())).thenReturn(providerResponse(Map.of( "trans_stat", "P", "resp_code", "00000100", "sub_resp_desc", "交易处理中", "hf_seq_id", "HF-PENDING-" + suffix))); Map refundingRecord = recordOf(refunding.orderNo()); assertThat(((Number) refundingRecord.get("refundStatus")).intValue()).isEqualTo(1); assertThat(refundingRecord).containsEntry("status", "cancelled"); } /** A paid order whose refund was submitted and has no final provider answer yet. */ private RefundingOrder refundingOrder(int refundStatus) { String orderNo = "REFUND-" + suffix + "-" + (++sequence); String refundNo = "R-REFUND-" + suffix + "-" + sequence; String paymentNo = "P-REFUND-" + suffix + "-" + sequence; String providerRefundNo = "HF-REFUND-SUBMIT-" + sequence; jdbc.update(""" insert into benefit_order(order_no, talent_id, merchant_id, order_type, order_amount, base_pay_amount, pay_amount, order_status, verify_status, refund_status, price_version, collect_huifu_id, product_name_snapshot) values (?, ?, ?, 'GROUP_BUY', 2000, 2000, 2000, 1, 0, ?, 1, ?, '退款测试订单') """, orderNo, talentId, merchantId, refundStatus, huifuId); Long orderId = jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo); jdbc.update(""" insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id, provider_huifu_id, amount, status, idempotency_key, paid_at) values (?, 'HUIFU', ?, ?, ?, ?, ?, 2000, 'SUCCEEDED', ?, now()) """, paymentNo, orderId, orderNo, talentId, merchantId, huifuId, "refund-payment-" + paymentNo); jdbc.update(""" insert into order_refund(refund_no, order_id, order_no, talent_id, merchant_id, order_type, paid_amount, refund_amount, refundable_amount, refund_status, provider, provider_huifu_id, provider_refund_no, provider_original_req_seq_id, provider_original_req_date, reason, operator_name, created_at) values (?, ?, ?, ?, ?, 'GROUP_BUY', 2000, 2000, 0, ?, 'HUIFU', ?, ?, ?, ?, '用户申请退款', '人才端用户', now() - interval 2 minute) """, refundNo, orderId, orderNo, talentId, merchantId, refundStatus, huifuId, providerRefundNo, paymentNo, java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd").format(java.time.LocalDate.now())); return new RefundingOrder(orderNo, refundNo, providerRefundNo); } private void providerSettlesTheRefund() { when(refundGateway.queryRefund(any(), any())).thenReturn(providerResponse(Map.of( "trans_stat", "S", "resp_code", "00000000", "sub_resp_desc", "交易成功", "hf_seq_id", "HF-REFUND-" + suffix))); } /** The record the talent's order list serves for this order. */ private Map recordOf(String orderNo) { PageResult> page = usageRecordPortalService.list(talentId, null, 1, 50); return page.records().stream() .filter(record -> orderNo.equals(String.valueOf(record.get("businessId")))) .findFirst() .orElseThrow(() -> new AssertionError("Order " + orderNo + " is missing from the talent order list")); } /** The record the merchant's order list serves for this order. */ private Map merchantRecordOf(String orderNo) { PageResult> page = merchantWorkService.orders(merchantId, null, 1, 50, new MerchantOrderQuery("group", "all", null, null)); return page.records().stream() .filter(record -> orderNo.equals(String.valueOf(record.get("orderNo")))) .findFirst() .orElseThrow(() -> new AssertionError("Order " + orderNo + " is missing from the merchant order list")); } private Map providerResponse(Map data) { return Map.of("data", data); } private record RefundingOrder(String orderNo, String refundNo, String providerRefundNo) { } }