package com.talentcard.server.business.payment; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Map; import com.talentcard.server.business.order.OrderRefundRequest; import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway; import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentCommand; import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway; import com.talentcard.server.business.portal.AppOrderCreateRequest; import com.talentcard.server.business.portal.AppOrderPortalService; import com.talentcard.server.common.BusinessException; import com.talentcard.server.common.ErrorCode; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; 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; /** * 延迟入账与资金确认。 * *
业务规则:先付钱后核销的订单,钱必须留在通道「待确认」,核销确认后才分配给商户;支付即核销的
* 到店消费订单不延迟,也不该被这个流程影响。
*/
@SpringBootTest
@ActiveProfiles("test")
class PaymentFundConfirmationTests {
private static final String HUIFU_ID = "6666000226048687";
@Autowired
private JdbcTemplate jdbc;
@Autowired
private AppOrderPortalService orderPortalService;
@Autowired
private HuifuPaymentService paymentService;
@Autowired
private PaymentFundConfirmationService confirmationService;
@Autowired
private PaymentSettlementService settlementService;
@Autowired
private PaymentFundConfirmationRetry confirmationRetry;
@MockitoBean
private HuifuMiniProgramPaymentGateway paymentGateway;
@MockitoBean
private HuifuDelayAccountGateway delayGateway;
private Long merchantId;
private Long storeId;
private Long talentId;
private Long productId;
private String suffix;
@BeforeEach
void prepare() {
suffix = String.valueOf(System.nanoTime());
jdbc.update("insert into merchant(merchant_name, status, deleted) values (?, 1, 0)", "延迟入账商户 " + suffix);
merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)",
"delay-" + suffix, "延迟入账用户", "13900000009");
talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
jdbc.update("insert into talent_card(talent_id, card_no, status) values (?, ?, 1)", talentId, "DC" + suffix);
jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)",
merchantId, "延迟入账门店 " + suffix);
storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class);
// 下单要求商户的收款能力已确认:进件通过 + 斗拱商户号 + 能力快照(自动结算已关、取现可用)。
jdbc.update("""
insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name,
contact_name, contact_mobile, idempotency_key, status, provider_huifu_id,
auto_settle_status, cash_switch_status, merchant_stat, capability_verified_at)
values (?, ?, ?, 'individual', 'Delay owner', 'Delay owner', '13900000009', ?, 'approved', ?, '0', '1', 'NORMAL', current_timestamp)
""", merchantId, storeId, "MO-DELAY-" + suffix, "delay-onboarding-" + suffix, HUIFU_ID);
Long onboardingId = jdbc.queryForObject("select max(id) from merchant_onboarding_application", Long.class);
jdbc.update("""
insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, city_code, card_tail, card_masked,
card_hash, status, idempotency_key, provider_token_no, reviewed_at)
values (?, ?, '中国建设银行', '攀枝花东区支行', '510400', '8888', '**** **** **** 8888', ?, 'approved', ?, ?, current_timestamp)
""", merchantId, storeId, "hash-delay-" + suffix, "delay-card-" + suffix, "T" + suffix);
Long cardId = jdbc.queryForObject("select max(id) from merchant_bank_card", Long.class);
jdbc.update("update merchant_onboarding_application set initial_bank_card_id = ? where id = ?", cardId, onboardingId);
// 收款就绪要求微信支付业务开通与实名/授权都有通道证据,这里的商户建模成已办完这两步。
jdbc.update("insert into merchant_onboarding_progress(application_id, stages_json) values (?, '{\"wechat\":\"confirmed\",\"realname\":\"confirmed\"}')", onboardingId);
jdbc.update("""
insert into merchant_product(merchant_id, product_no, product_type, product_name, original_price,
sale_price, stock, audit_status, status, deleted)
values (?, ?, 'GROUP_BUY', '延迟入账商品', 100.00, 100.00, -1, 2, 1, 0)
""", merchantId, "DELAY-PRODUCT-" + suffix);
productId = jdbc.queryForObject("select max(id) from merchant_product", Long.class);
}
@Test
void aDelayedOrderHoldsTheMoneyUntilItIsConfirmed() {
String orderNo = createOrder("GROUP_BUY", 100L);
String paymentNo = insertPaidPayment(orderNo, 100L, "pending");
markVerified(orderId(orderNo));
when(delayGateway.confirm(any())).thenReturn(succeeded("1.00"));
assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
PaymentFundConfirmationService.CONFIRMED);
Map {@code 23000003} 在交易确认那条路上是「已无未确认金额」=幂等成功(有真实回执复验过),
* 但在回退这条路上没有回执证明它的含义。判成成功会让订单翻回「待核销」而钱还在商户基本户,
* 因此只能停在 reverting 等人工核对。
*/
@Test
void anUndeterminedRevertCodeNeverCountsAsSuccess() {
String paymentNo = confirmedDelayOrder(100L);
when(delayGateway.refund(any())).thenReturn(new HuifuDelayAccountGateway.HuifuDelayRevert(
HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.NEEDS_REVIEW, "23000003", "已无已确认金额", ""));
assertThat(confirmationService.revertForOrder(orderIdOfPayment(paymentNo))).isEqualTo(
PaymentFundConfirmationService.REVERT_NEEDS_REVIEW);
assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
+ " where payment_no = ?", String.class, paymentNo)).isEqualTo("reverting");
}
/**
* 请求根本没发出去(配置不完整、SDK 初始化失败)时,钱没有动,可以重试。
*
* 不能报成「已结算到银行卡」:那是另一回事,商户照着这句话去找平台就白跑了。
*/
@Test
void aRevertThatNeverReachedTheProviderIsRetryable() {
String paymentNo = confirmedDelayOrder(100L);
when(delayGateway.refund(any())).thenThrow(new BusinessException(ErrorCode.CONFLICT, "汇付支付配置不完整"));
assertThat(confirmationService.revertForOrder(orderIdOfPayment(paymentNo))).isEqualTo(
PaymentFundConfirmationService.REVERT_NOT_SENT);
Map 没有这道复查,补偿任务(或在途的确认请求)会在撤销之后把钱打给商户,而订单已经回到待核销。
*/
@Test
void aConfirmationIsRefusedOnceTheOrderIsNoLongerVerified() {
String orderNo = createOrder("GROUP_BUY", 100L);
String paymentNo = insertPaidPayment(orderNo, 100L, "pending");
jdbc.update("update benefit_order set order_status = 1, verify_status = 0 where id = ?", orderId(orderNo));
assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
PaymentFundConfirmationService.NOT_VERIFIED);
verify(delayGateway, never()).confirm(any());
assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
+ " where payment_no = ?", String.class, paymentNo)).isEqualTo("pending");
}
// ------------------------------------------------------------------------------------------
// helpers
// ------------------------------------------------------------------------------------------
private HuifuDelayAccountGateway.HuifuDelayConfirmation succeeded(String confirmedYuan) {
return new HuifuDelayAccountGateway.HuifuDelayConfirmation(
HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.SUCCEEDED,
"00000000", "交易成功", "0.00", confirmedYuan, "HF-" + System.nanoTime());
}
private HuifuDelayAccountGateway.HuifuDelayRevert revertSucceeded() {
return new HuifuDelayAccountGateway.HuifuDelayRevert(
HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.SUCCEEDED,
"00000000", "交易成功", "HF-R-" + System.nanoTime());
}
/** 资金确认与资金回退都要求订单处于「已核销」。 */
private void markVerified(long orderId) {
jdbc.update("update benefit_order set order_status = 1, verify_status = 1 where id = ?", orderId);
}
/**
* 造一笔「延迟入账 + 资金已确认给商户」的订单,返回支付流水号。
*
* 确认走真实服务而不是直接改字段:撤销时用来定位原确认的请求号与请求日期,正是它写下的。
*/
private String confirmedDelayOrder(long amountInCent) {
String orderNo = createOrder("GROUP_BUY", amountInCent);
String paymentNo = insertPaidPayment(orderNo, amountInCent, "pending");
long orderId = orderId(orderNo);
markVerified(orderId);
when(delayGateway.confirm(any())).thenReturn(succeeded(BigDecimal.valueOf(amountInCent, 2).toPlainString()));
confirmationService.confirmForOrder(orderId);
return paymentNo;
}
private long orderIdOfPayment(String paymentNo) {
return jdbc.queryForObject("select order_id from payment_transaction where payment_no = ?",
Long.class, paymentNo);
}
/**
* 通道说「已无未确认金额」表示这笔延迟交易此前已经确认过(重新确认不会重复分账)。
* 必须按成功处理:否则补偿任务会一直认为没确认,每隔几分钟无意义地打一次通道。
*/
@Test
void nothingLeftToConfirmCountsAsAlreadyConfirmed() {
String orderNo = createOrder("GROUP_BUY", 100L);
String paymentNo = insertPaidPayment(orderNo, 100L, "pending");
markVerified(orderId(orderNo));
when(delayGateway.confirm(any())).thenReturn(new HuifuDelayAccountGateway.HuifuDelayConfirmation(
HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.SUCCEEDED,
"23000003", "已无未确认金额", "", "", ""));
assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
PaymentFundConfirmationService.CONFIRMED);
assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
+ " where payment_no = ?", String.class, paymentNo)).isEqualTo("confirmed");
}
/**
* 认领之后进程就没了(部署、崩溃)会留下 {@code confirming} 的孤儿:补偿任务此前只挑得到它、
* 从不重发,这笔钱就永远冻在延迟户。隔天必须能重新确认一次。
*/
@Test
void aConfirmationAbandonedByACrashIsRetriedTheNextDay() {
String orderNo = createOrder("GROUP_BUY", 100L);
String paymentNo = insertPaidPayment(orderNo, 100L, "confirming");
long orderId = orderId(orderNo);
markVerified(orderId);
jdbc.update("update payment_transaction set funds_confirm_request_date = ? where payment_no = ?",
LocalDate.now().minusDays(1).format(DateTimeFormatter.BASIC_ISO_DATE), paymentNo);
when(delayGateway.confirm(any())).thenReturn(succeeded("1.00"));
assertThat(confirmationService.confirmForOrder(orderId)).isEqualTo(
PaymentFundConfirmationService.CONFIRMED);
assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
+ " where payment_no = ?", String.class, paymentNo)).isEqualTo("confirmed");
}
/** 今天认领的确认不重发:它可能还在通道里处理。 */
@Test
void aConfirmationClaimedTodayIsNotSentAgain() {
String orderNo = createOrder("GROUP_BUY", 100L);
String paymentNo = insertPaidPayment(orderNo, 100L, "confirming");
markVerified(orderId(orderNo));
jdbc.update("update payment_transaction set funds_confirm_request_date = ? where payment_no = ?",
LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE), paymentNo);
assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
PaymentFundConfirmationService.PROCESSING);
verify(delayGateway, never()).confirm(any());
}
/**
* 当天认领出来的「确认中」靠**确认查询**收敛,而不是整整等一天。
*
* 认领规则不允许当天重发确认(避免重复分账),于是同一天的确认中在旧实现里没人再看——而确认查询
* 接口({@code delaytrans.confirmquery})从写下那天起就是死代码。这里把它接上。
*/
@Test
void aConfirmationClaimedTodayConvergesThroughTheConfirmationQuery() {
String orderNo = createOrder("GROUP_BUY", 100L);
String paymentNo = insertPaidPayment(orderNo, 100L, "confirming");
markVerified(orderId(orderNo));
jdbc.update("""
update payment_transaction
set funds_confirm_request_date = ?, funds_confirm_no = ?
where payment_no = ?
""", LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE), "DC-CLAIMED-" + paymentNo, paymentNo);
when(delayGateway.queryConfirm(any(), any())).thenReturn(succeeded("1.00"));
assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
PaymentFundConfirmationService.CONFIRMED);
assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction where payment_no = ?",
String.class, paymentNo)).isEqualTo("confirmed");
verify(delayGateway, never()).confirm(any());
}
/**
* 撤销核销的回退停在 {@code reverting} 时也要有人再看:回退没有查询接口,补偿任务原先只扫
* 「待确认/确认中」,这类记录会永久卡住撤销核销。幂等的重发就是它的查询对等物。
*/
@Test
void anInFlightRevertConvergesThroughTheRetryTask() {
String orderNo = createOrder("GROUP_BUY", 100L);
String paymentNo = insertPaidPayment(orderNo, 100L, "reverting");
long orderId = orderId(orderNo);
markVerified(orderId);
jdbc.update("""
update payment_transaction
set funds_confirm_no = ?, funds_confirm_request_date = ?, funds_revert_no = ?,
funds_revert_request_date = ?, funds_confirmed_amount = 100
where payment_no = ?
""", "DC-ORIGINAL-" + paymentNo, LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE),
"DR-INFLIGHT-" + paymentNo, LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE), paymentNo);
// 补偿任务按订单号从小到大取一批,而测试库共享且不清理:先把别人的相关行移出扫描条件。
jdbc.update("""
update benefit_order set refund_status = 1
where id <> ? and deleted = 0 and order_status = 1 and verify_status = 1 and refund_status = 0
and exists (select 1 from payment_transaction p
where p.order_id = benefit_order.id and p.status = 'SUCCEEDED'
and p.funds_confirmation_status in ('pending', 'confirming', 'reverting'))
""", orderId);
when(delayGateway.refund(any())).thenReturn(new HuifuDelayAccountGateway.HuifuDelayRevert(
HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.SUCCEEDED, "00000000", "交易成功", "HF-REVERT-RETRY"));
assertThat(confirmationRetry.retry(50)).isEqualTo(1);
assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction where payment_no = ?",
String.class, paymentNo))
.as("回退成功了,钱重新回到延迟户(pending):撤销核销不再被永久卡住")
.isEqualTo("pending");
}
/** 通道还在处理:状态保持 pending,供确认补偿任务下一轮再试。 */
private HuifuDelayAccountGateway.HuifuDelayConfirmation processing() {
return new HuifuDelayAccountGateway.HuifuDelayConfirmation(
HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.PROCESSING,
"00000100", "交易处理中", "1.00", "0.00", "");
}
private HuifuDelayAccountGateway.HuifuDelayConfirmation failed(String code, String message) { return new HuifuDelayAccountGateway.HuifuDelayConfirmation(
HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.FAILED,
code, message, "", "", "");
}
private String createOrder(String orderType, long amountInCent) {
Map