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; } }