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.when;
|
|
import java.math.BigDecimal;
|
import java.util.Map;
|
|
import com.talentcard.server.business.PageResult;
|
import com.talentcard.server.business.order.OrderRefundRequest;
|
import com.talentcard.server.business.payment.HuifuRefundService.StrandedRefundOutcome;
|
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway;
|
import com.talentcard.server.business.payment.huifu.HuifuRefundGateway;
|
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: money the provider collected for an order that is already cancelled has exactly one right
|
* destination — back to the payer — and the refund has to be visible in local state.
|
*
|
* <p>The two provider seams (payment query and refund submission) are mocked; the reconciliation,
|
* settlement, fund boundary and refund services all run for real against the shared MySQL schema.
|
*/
|
@SpringBootTest
|
@ActiveProfiles("test")
|
class StrandedPaymentRefundTests {
|
|
@Autowired
|
private JdbcTemplate jdbc;
|
|
@Autowired
|
private PaymentReconciliationService reconciliationService;
|
|
@Autowired
|
private HuifuRefundService refundService;
|
|
@Autowired
|
private PaymentIssueRecorder issues;
|
|
@Autowired
|
private StrandedPaymentRefundRetry retry;
|
|
@Autowired
|
private UsageRecordPortalService usageRecordPortalService;
|
|
@MockitoBean
|
private HuifuMiniProgramPaymentGateway paymentGateway;
|
|
@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)",
|
"stranded-refund-openid-" + suffix, "Stranded refund user", "13900000005");
|
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)",
|
"Stranded refund merchant " + suffix, "08120000004", "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 aCancelledOrderWhosePaymentWasCollectedIsRefundedToThePayer() {
|
String orderNo = cancelledOrderWithCollectedPayment("CANCEL_UNCONFIRMED", 2000L);
|
providerAcceptsRefunds();
|
|
int refunded = retry.retry(1);
|
|
assertThat(refunded).as("这一轮应该发起一笔悬账退款").isEqualTo(1);
|
assertThat(jdbc.queryForMap("select paid_amount, refund_amount, refund_status from order_refund where order_no = ?", orderNo))
|
.as("悬账要真的退回付款人,而且是全额")
|
.containsEntry("paid_amount", 2000L)
|
.containsEntry("refund_amount", 2000L)
|
.containsEntry("refund_status", 2);
|
assertThat(openIssueCount("PAYMENT_ORDER_CANCELLED", paymentNo(orderNo)))
|
.as("钱有了唯一的出口,问题记录就该结掉")
|
.isZero();
|
assertThat(openIssueCount("STRANDED_REFUND_REJECTED", paymentNo(orderNo))).isZero();
|
assertThat(((Number) talentOrderRecord(orderNo).get("refundStatus")).intValue())
|
.as("人才端的「使用记录」要知道钱已经退回来了(页面渲染成「已退款」)")
|
.isEqualTo(2);
|
}
|
|
@Test
|
void aRefundTheProviderRejectsEscalatesToAHumanInsteadOfBeingSwallowed() {
|
String orderNo = cancelledOrderWithCollectedPayment("CANCEL_UNCONFIRMED", 2000L);
|
when(refundGateway.createRefund(any())).thenReturn(providerResponse(Map.of(
|
"trans_stat", "F", "sub_resp_code", "20000001", "sub_resp_desc", "基本户余额不足")));
|
|
retry.retry(1);
|
|
assertThat(openIssueCount("PAYMENT_ORDER_CANCELLED", paymentNo(orderNo)))
|
.as("退不回去的悬账必须留在账上,绝不能静默吞掉")
|
.isEqualTo(1L);
|
assertThat(openIssueCount("STRANDED_REFUND_REJECTED", paymentNo(orderNo)))
|
.as("通道拒绝要升级成一条能被人看见的告警")
|
.isEqualTo(1L);
|
assertThat(count("select count(*) from order_refund where order_no = ? and refund_status = 2", orderNo))
|
.as("通道拒绝就是没有退成,不能记成已退款")
|
.isZero();
|
}
|
|
/**
|
* 本地已关闭、但通道确认收过钱的悬账:旧的支付键可见性只认已成功流水,这类钱连支付键都看不到,
|
* 于是退款无从发起(D19 的回归)。
|
*/
|
@Test
|
void aPaymentClosedLocallyButConfirmedByTheProviderCanStillBeRefunded() {
|
String orderNo = cancelledOrder("CLOSED", 2000L);
|
String paymentNo = paymentNo(orderNo);
|
issues.issue("PAYMENT_ORDER_CANCELLED", paymentNo, orderNo, "cancelled", "provider confirmed QUERY", 2000L,
|
"Payment succeeded after the local order was cancelled");
|
providerAcceptsRefunds();
|
|
assertThat(refundService.refundStranded(orderNo)).isEqualTo(StrandedRefundOutcome.SUBMITTED);
|
|
assertThat(jdbc.queryForMap("""
|
select provider_original_req_seq_id, refund_amount from order_refund where order_no = ?
|
""", orderNo))
|
.as("退款必须定位到那笔已关闭的支付流水,否则通道侧无法原路退回")
|
.containsEntry("provider_original_req_seq_id", paymentNo)
|
.containsEntry("refund_amount", 2000L);
|
}
|
|
@Test
|
void theNarrowEntryRefusesAnythingThatIsNotAStrandedPayment() {
|
// 订单没取消:不能按悬账退款,否则会绕过普通退款路径的前置条件。
|
String liveOrderNo = paidOrder(2000L, "SUCCEEDED");
|
assertThat(refundService.refundStranded(liveOrderNo)).isEqualTo(StrandedRefundOutcome.NOT_APPLICABLE);
|
|
// 订单已取消,但通道从没确认收过钱(流水还是未定局):没有可退的悬账。
|
String undecidedNo = cancelledOrder("PREPAY_CREATED", 2000L);
|
assertThat(refundService.refundStranded(undecidedNo)).isEqualTo(StrandedRefundOutcome.NOT_APPLICABLE);
|
|
// 已经退过:不能重复退款。
|
String refundingNo = cancelledOrder("CANCEL_UNCONFIRMED", 2000L);
|
Long orderId = jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, refundingNo);
|
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, reason, operator_name)
|
values (?, ?, ?, ?, ?, 'GROUP_BUY', 2000, 2000, 0, 1, 'HUIFU', '已受理', 'system')
|
""", "R-EXISTING-" + suffix, orderId, refundingNo, talentId, merchantId);
|
assertThat(refundService.refundStranded(refundingNo))
|
.as("资金边界先锁订单行,再断言没有生效中的退款:在途退款不会被退第二次")
|
.isEqualTo(StrandedRefundOutcome.NOT_APPLICABLE);
|
}
|
|
@Test
|
void aPartialRefundLeavesTheRemainderRefundableWhileOverRefundingIsStillRefused() {
|
String orderNo = paidOrder(10000L, "SUCCEEDED");
|
providerAcceptsRefunds();
|
|
refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("40.00"), "部分退款", "admin"));
|
|
assertThat(jdbc.queryForMap("select refund_amount, refundable_amount, refund_status from order_refund where order_no = ?", orderNo))
|
.containsEntry("refund_amount", 4000L)
|
.containsEntry("refundable_amount", 6000L)
|
.containsEntry("refund_status", 2);
|
|
assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("70.00"), "超额", "admin")))
|
.as("超额退款仍被拒")
|
.isInstanceOf(BusinessException.class)
|
.hasMessageContaining("Invalid refund amount");
|
|
refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("60.00"), "退剩余", "admin"));
|
|
assertThat(count("select coalesce(sum(refund_amount), 0) from order_refund where order_no = ? and refund_status = 2", orderNo))
|
.as("首笔部分退款之后剩余金额必须还退得掉(旧规则下它永远退不了)")
|
.isEqualTo(10000L);
|
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
|
.as("全额退完订单才退出")
|
.isEqualTo(2);
|
}
|
|
@Test
|
void moneyStillInFlightCountsAgainstTheRefundableAmount() {
|
String orderNo = paidOrder(10000L, "SUCCEEDED");
|
when(refundGateway.createRefund(any())).thenReturn(providerResponse(Map.of(
|
"trans_stat", "P", "resp_code", "00000100", "sub_resp_desc", "交易处理中")));
|
|
refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("40.00"), "部分退款", "admin"));
|
|
assertThat(jdbc.queryForObject("select refund_status from order_refund where order_no = ?", Integer.class, orderNo))
|
.as("通道还在处理就停在退款中")
|
.isEqualTo(1);
|
assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("70.00"), "超额", "admin")))
|
.as("在途金额也要算进可退额度,否则同一笔钱会被退两次")
|
.isInstanceOf(BusinessException.class)
|
.hasMessageContaining("Invalid refund amount");
|
|
refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("60.00"), "退剩余", "admin"));
|
|
assertThat(count("select count(*) from order_refund where order_no = ?", orderNo)).isEqualTo(2);
|
assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("1.00"), "再多退一分", "admin")))
|
.as("实付金额退满之后一分钱都不能再退")
|
.isInstanceOf(BusinessException.class)
|
.hasMessageContaining("Invalid refund amount");
|
}
|
|
/** 一笔「订单已取消 + 通道确认收款」的悬账,按真实链路构造:先取消,再由对账把收款事实落下来。 */
|
private String cancelledOrderWithCollectedPayment(String paymentStatus, long amount) {
|
String orderNo = cancelledOrder(paymentStatus, amount);
|
when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
|
"trans_stat", "S", "resp_code", "00000000", "trans_amt", "20.00",
|
"huifu_id", huifuId, "org_hf_seq_id", "HF-STRANDED-" + suffix)));
|
|
reconciliationService.confirmPendingPayments(orderNo);
|
|
String paymentNo = paymentNo(orderNo);
|
assertThat(openIssueCount("PAYMENT_ORDER_CANCELLED", paymentNo))
|
.as("构造前提:对账发现了「取消后才收到钱」")
|
.isEqualTo(1L);
|
isolateIssueWindow(paymentNo);
|
return orderNo;
|
}
|
|
/**
|
* 消费者每轮按 {@code detected_at} 取最老的一批,而测试库是共享且不清理的:把本用例这条推到最老、
|
* 把别的悬账推到窗口之外,这一轮就只会处理它自己(docs/agents/testing.md 的做法)。
|
*/
|
private void isolateIssueWindow(String paymentNo) {
|
jdbc.update("""
|
update payment_reconciliation_issue set detected_at = now() + interval 30 day
|
where issue_type = 'PAYMENT_ORDER_CANCELLED' and status = 'open' and business_no <> ?
|
""", paymentNo);
|
jdbc.update("""
|
update payment_reconciliation_issue set detected_at = now() - interval 30 day
|
where issue_type = 'PAYMENT_ORDER_CANCELLED' and business_no = ?
|
""", paymentNo);
|
}
|
|
private String cancelledOrder(String paymentStatus, long amount) {
|
String orderNo = "STRANDED-" + suffix + "-" + (++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, price_version, collect_huifu_id,
|
product_name_snapshot)
|
values (?, ?, ?, 'GROUP_BUY', ?, ?, ?, 2, 0, 1, ?, '悬账测试订单')
|
""", orderNo, talentId, merchantId, amount, amount, amount, 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, created_at)
|
values (?, 'HUIFU', ?, ?, ?, ?, ?, ?, ?, ?, now() - interval 10 minute)
|
""", paymentNo(orderNo), orderId, orderNo, talentId, merchantId, huifuId, amount, paymentStatus,
|
"stranded-payment-" + suffix + "-" + sequence);
|
return orderNo;
|
}
|
|
private String paidOrder(long amount, String paymentStatus) {
|
String orderNo = "STRANDED-PAID-" + suffix + "-" + (++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, price_version, collect_huifu_id,
|
product_name_snapshot)
|
values (?, ?, ?, 'GROUP_BUY', ?, ?, ?, 1, 0, 1, ?, '部分退款测试订单')
|
""", orderNo, talentId, merchantId, amount, amount, amount, 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', ?, ?, ?, ?, ?, ?, ?, ?, now())
|
""", paymentNo(orderNo), orderId, orderNo, talentId, merchantId, huifuId, amount, paymentStatus,
|
"paid-payment-" + suffix + "-" + sequence);
|
return orderNo;
|
}
|
|
private String paymentNo(String orderNo) {
|
return "P-" + orderNo;
|
}
|
|
private void providerAcceptsRefunds() {
|
when(refundGateway.createRefund(any())).thenReturn(providerResponse(Map.of(
|
"trans_stat", "S", "resp_code", "00000000", "sub_resp_desc", "交易成功", "hf_seq_id", "HF-STRANDED-R-" + suffix)));
|
}
|
|
private Map<String, Object> providerResponse(Map<String, Object> data) {
|
return Map.of("data", data);
|
}
|
|
/** 人才端「使用记录」为该订单给出的那一条:页面按它的 refundStatus 渲染「已退款」。 */
|
private Map<String, Object> talentOrderRecord(String orderNo) {
|
PageResult<Map<String, Object>> 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"));
|
}
|
|
private Long openIssueCount(String type, String businessNo) { return jdbc.queryForObject("""
|
select count(*) from payment_reconciliation_issue
|
where issue_type = ? and business_no = ? and status = 'open'
|
""", Long.class, type, businessNo);
|
}
|
|
private long count(String sql, Object... args) {
|
Long value = jdbc.queryForObject(sql, Long.class, args);
|
return value == null ? 0 : value;
|
}
|
}
|