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