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.clearInvocations;
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.payment.huifu.HuifuMiniProgramPaymentGateway;
import com.talentcard.server.business.payment.huifu.HuifuPaymentCloseCommand;
import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
import com.talentcard.server.business.portal.PendingOrderPortalService;
import com.talentcard.server.common.BusinessException;
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;
/**
* Seam: the local state of a pending order can be behind the provider, because a payment
* notification that never arrived leaves the order looking unpaid while the money was collected.
*
*
Cancelling such an order must therefore ask the provider first. A payment the provider
* confirms settles the order and blocks the cancellation; a provider that says the money may still
* be moving blocks it too, because a question about money is never answered by guessing.
*/
@SpringBootTest
@ActiveProfiles("test")
class PendingPaymentConfirmationTests {
@Autowired
private JdbcTemplate jdbc;
@Autowired
private PendingOrderPortalService pendingOrderService;
@Autowired
private PaymentReconciliationService reconciliationService;
@Autowired
private HuifuPaymentService paymentService;
@MockitoBean
private HuifuMiniProgramPaymentGateway paymentGateway;
private Long talentId;
private Long merchantId;
private String huifuId;
private String suffix;
private long orderSequence;
@BeforeEach
void prepareData() {
suffix = String.valueOf(System.nanoTime());
// 共享测试库里可能留着历史遗留的挂起流水,只有本用例自己创建的才能落进对账窗口。
jdbc.update("update payment_transaction set created_at = now() where status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CANCEL_UNCONFIRMED')");
jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)",
"pending-payment-openid-" + suffix, "Pending payment user", "13900000003");
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 payment merchant " + suffix, "08120000002", "Test address");
merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
huifuId = "H" + suffix.substring(Math.max(0, suffix.length() - 8));
orderSequence = 0;
}
@Test
void cancellingALocallyPendingOrderTheProviderCollectedIsRefusedAndTheOrderIsMarkedPaid() {
String orderNo = pendingOrder();
String paymentNo = pendingPayment(orderNo, 2000L);
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-CANCEL-" + suffix)));
assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("已支付");
assertThat(jdbc.queryForMap("select order_status, verify_status from benefit_order where order_no = ?", orderNo))
.containsEntry("order_status", 1)
.containsEntry("verify_status", 1);
assertThat(jdbc.queryForMap("select status, paid_at from payment_transaction where payment_no = ?", paymentNo))
.containsEntry("status", "SUCCEEDED");
assertThat(count("select count(*) from payment_transaction where payment_no = ? and paid_at is not null", paymentNo)).isEqualTo(1);
}
@Test
void cancellingAnOrderTheProviderHasNotCollectedAsksTheProviderAndClosesIt() {
String orderNo = pendingOrder();
String paymentNo = pendingPayment(orderNo, 2000L);
when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
"trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId)));
when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
"trans_stat", "S", "resp_code", "00000000", "resp_desc", "交易成功", "org_trans_stat", "P")));
Map result = pendingOrderService.cancel(orderNo, talentId);
assertThat(((Number) result.get("orderStatus")).intValue()).isEqualTo(2);
verify(paymentGateway).queryMiniProgramPayment(any());
ArgumentCaptor close = ArgumentCaptor.forClass(HuifuPaymentCloseCommand.class);
verify(paymentGateway).closeMiniProgramPayment(close.capture());
assertThat(close.getValue().paymentNo()).isEqualTo(paymentNo);
assertThat(count("select count(*) from payment_transaction where payment_no = ? and status = 'CLOSED'", paymentNo)).isEqualTo(1);
}
@Test
void cancellingIsRefusedAndRecordedWhenClosingRevealsTheTradeWasAlreadyPaid() {
String orderNo = pendingOrder();
String paymentNo = pendingPayment(orderNo, 2000L);
// 查单说「交易不存在」,关单却回「原订单已为终态」且原交易已成功:钱可能真的在,不能当成没收过款。
when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
"resp_code", "23000001", "resp_desc", "交易不存在", "huifu_id", huifuId)));
when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
"trans_stat", "F", "resp_code", "23000000", "resp_desc", "原订单已为终态,请发起查询交易获取",
"org_trans_stat", "S", "huifu_id", huifuId)));
// 查单拿不到金额,无法把订单落成已支付,所以不能谎称已支付;但订单也绝不能被取消。
assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("确认中");
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
assertThat(count("select count(*) from payment_transaction where payment_no = ? and status = 'PREPAY_CREATED'", paymentNo)).isEqualTo(1);
assertThat(openIssueCount("PAYMENT_CLOSE_CONFLICT", paymentNo)).isEqualTo(1L);
}
@Test
void cancellingProceedsButKeepsThePrepayWatchableWhenTheProviderRefusesToCloseIt() {
String orderNo = pendingOrder();
String paymentNo = pendingPayment(orderNo, 2000L);
when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
"trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId)));
when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
"trans_stat", "F", "resp_code", "20000001", "resp_desc", "不允许关闭一分钟以内的订单", "huifu_id", huifuId)));
pendingOrderService.cancel(orderNo, talentId);
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isEqualTo(2);
assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
.as("关不掉的预支付单还可能被支付,本地不能记成已关闭,要留给对账任务继续盯着")
.isEqualTo("CANCEL_UNCONFIRMED");
assertThat(openIssueCount("PAYMENT_CLOSE_UNCONFIRMED", paymentNo)).isEqualTo(1L);
}
@Test
void cancellingIsRefusedWhileTheProviderIsStillClosingThePrepay() {
String orderNo = pendingOrder();
pendingPayment(orderNo, 2000L);
when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
"trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId)));
when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
"trans_stat", "P", "resp_code", "00000100", "resp_desc", "交易处理中", "huifu_id", huifuId)));
assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("确认中");
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
}
@Test
void cancellingIsRefusedWhenTheProviderCannotSayWhetherTheMoneyWasCollected() {
String orderNo = pendingOrder();
String paymentNo = pendingPayment(orderNo, 2000L);
when(paymentGateway.queryMiniProgramPayment(any()))
.thenThrow(new HuifuPaymentGatewayException("Huifu payment query failed", true, null));
assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("确认中");
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
.as("an unanswerable payment question must not destroy the only record of the money")
.isEqualTo("PREPAY_CREATED");
assertThat(openIssueCount("PAYMENT_QUERY_FAILED", paymentNo)).isEqualTo(1L);
}
@Test
void retryingAPaymentTheProviderCollectedReportsItAsPaidInsteadOfAskingTheUserToPayAgain() {
String orderNo = pendingOrder();
pendingPayment(orderNo, 2000L);
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-RETRY-" + suffix)));
when(paymentGateway.createMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
"trans_stat", "P", "resp_code", "00000000", "pay_info", "{\"appId\":\"wx-test\"}")));
Map result = paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "retry-key-" + suffix);
assertThat(result).containsEntry("paymentStatus", "PAID");
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isEqualTo(1);
verify(paymentGateway, never()).createMiniProgramPayment(any());
}
@Test
void retryingAPaymentIsRefusedWhenTheProviderCannotSayWhetherTheMoneyWasCollected() {
String orderNo = pendingOrder();
pendingPayment(orderNo, 2000L);
when(paymentGateway.queryMiniProgramPayment(any()))
.thenThrow(new HuifuPaymentGatewayException("Huifu payment query failed", true, null));
assertThatThrownBy(() -> paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "retry-key-" + suffix))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("确认中");
verify(paymentGateway, never()).createMiniProgramPayment(any());
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
}
@Test
void askingForThePaymentStatusConfirmsWithTheProviderAndReportsTheCollectedPayment() {
String orderNo = pendingOrder();
String paymentNo = pendingPayment(orderNo, 2000L);
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-STATUS-" + suffix)));
Map result = paymentService.paymentStatus(orderNo, talentId, paymentNo);
assertThat(result).containsEntry("paymentStatus", "PAID");
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isEqualTo(1);
}
/** A pending order bound to a collecting sub-merchant, created directly so the payment seam is the only subject. */
@Test
void anExpiredOrderWithAnUndecidedPaymentIsNotCancelledByTheDeadlineSweep() {
String orderNo = expiredOrder();
String paymentNo = pendingPayment(orderNo, 2000L);
pendingOrderService.expireDueOrders(talentId);
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
.as("到期不等于没收到钱:未定局的支付尝试只能交给通道确认后再处理")
.isZero();
assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
.isEqualTo("PREPAY_CREATED");
}
@Test
void anExpiredOrderWhosePaymentNeverSucceededIsCancelledByTheDeadlineSweep() {
String orderNo = expiredOrder();
pendingPayment(orderNo, 2000L, "CREATE_FAILED");
pendingOrderService.expireDueOrders(talentId);
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isEqualTo(2);
}
@Test
void anExpiredOrderWithoutAnyPaymentIsCancelledByTheDeadlineSweep() {
String orderNo = expiredOrder();
pendingOrderService.expireDueOrders(talentId);
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isEqualTo(2);
}
@Test
void theReconciliationClosesAPrepayThatCouldNotBeClosedWhenTheOrderWasCancelled() {
String paymentNo = cancelledOrderWithUnconfirmedClose();
when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
"trans_stat", "S", "resp_code", "00000000", "resp_desc", "交易成功", "huifu_id", huifuId)));
reconciliationService.reconcile(5);
assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
.as("过了关单限制之后必须把预支付单关掉,否则用户手上那张单一直能付")
.isEqualTo("CLOSED");
assertThat(openIssueCount("PAYMENT_CLOSE_UNCONFIRMED", paymentNo)).isZero();
}
@Test
void theReconciliationNeverClosesAPrepayTheProviderAlreadyCollected() {
String paymentNo = cancelledOrderWithUnconfirmedClose();
clearInvocations(paymentGateway);
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-LATE-" + suffix)));
reconciliationService.reconcile(5);
verify(paymentGateway, never()).closeMiniProgramPayment(any());
assertThat(openIssueCount("PAYMENT_ORDER_CANCELLED", paymentNo))
.as("取消之后才收到的钱要留在账目上让人处理,而不是当成没收过")
.isEqualTo(1L);
assertThat(openIssueCount("PAYMENT_CLOSE_UNCONFIRMED", paymentNo)).isZero();
}
/** 订单刚创建就取消:通道不允许关 1 分钟以内的订单,取消成立,但预支付单留着等下一轮关。 */
private String cancelledOrderWithUnconfirmedClose() {
String orderNo = pendingOrder();
String paymentNo = pendingPayment(orderNo, 2000L);
when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
"trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId)));
when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
"trans_stat", "F", "resp_code", "20000001", "resp_desc", "不允许关闭一分钟以内的订单", "huifu_id", huifuId)));
pendingOrderService.cancel(orderNo, talentId);
assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
.isEqualTo("CANCEL_UNCONFIRMED");
// 对账只处理挂起超过 N 分钟的流水,把它推到窗口内。
jdbc.update("update payment_transaction set created_at = now() - interval 10 minute where payment_no = ?", paymentNo);
return paymentNo;
}
private String pendingOrder() {
return pendingOrder("date_add(now(), interval 30 minute)");
}
/** The same order, already past its payment deadline. */
private String expiredOrder() {
return pendingOrder("date_sub(now(), interval 1 minute)");
}
private String pendingOrder(String expiresAtSql) {
String orderNo = "PENDING-" + suffix + "-" + (++orderSequence);
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, expires_at, price_version, collect_huifu_id,
product_name_snapshot)
values (?, ?, ?, 'STORE_CONSUME', ?, ?, ?, 0, 0, %s, 1, ?, ?)
""".formatted(expiresAtSql), orderNo, talentId, merchantId, 2000L, 2000L, 2000L, huifuId, "待支付测试订单");
return orderNo;
}
private String pendingPayment(String orderNo, long amount) {
return pendingPayment(orderNo, amount, "PREPAY_CREATED");
}
private String pendingPayment(String orderNo, long amount, String status) {
String paymentNo = "P-PENDING-" + suffix + "-" + orderSequence;
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())
""", paymentNo, orderId, orderNo, talentId, merchantId, huifuId, amount, status, "pending-payment-" + paymentNo);
return paymentNo;
}
private Map providerResponse(Map data) {
return Map.of("data", data);
}
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;
}
}