package com.talentcard.server.business.payment; import static org.assertj.core.api.Assertions.assertThat; 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.math.BigDecimal; import java.time.LocalDateTime; 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 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; @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_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"); when(delayGateway.confirm(any())).thenReturn(succeeded("1.00")); assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo( PaymentFundConfirmationService.CONFIRMED); Map payment = jdbc.queryForMap( "select funds_confirmation_status, funds_confirmed_amount, funds_confirmed_at, funds_confirm_no" + " from payment_transaction where payment_no = ?", paymentNo); assertThat(payment).containsEntry("funds_confirmation_status", "confirmed"); // 通道回执的已确认金额是「元」字符串,本地存的是分。 assertThat(((Number) payment.get("funds_confirmed_amount")).longValue()).isEqualTo(100L); assertThat(payment.get("funds_confirmed_at")).isNotNull(); assertThat(String.valueOf(payment.get("funds_confirm_no"))).isNotBlank(); } @Test void anAlreadyConfirmedOrderIsNeverConfirmedTwice() { String orderNo = createOrder("GROUP_BUY", 100L); insertPaidPayment(orderNo, 100L, "pending"); when(delayGateway.confirm(any())).thenReturn(succeeded("1.00")); confirmationService.confirmForOrder(orderId(orderNo)); assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo( PaymentFundConfirmationService.CONFIRMED); verify(delayGateway).confirm(any()); } @Test void aConfirmationAlreadyInFlightIsNotStartedAgain() { String orderNo = createOrder("GROUP_BUY", 100L); insertPaidPayment(orderNo, 100L, "confirming"); assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo( PaymentFundConfirmationService.PROCESSING); verify(delayGateway, never()).confirm(any()); } @Test void anOrderThatWasNotDelayedNeedsNoConfirmation() { String orderNo = createOrder("STORE_CONSUME", 100L); insertPaidPayment(orderNo, 100L, null); assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo( PaymentFundConfirmationService.NOT_DELAYED); verify(delayGateway, never()).confirm(any()); } @Test void anUnsettledPaymentIsLeftForLater() { String orderNo = createOrder("GROUP_BUY", 100L); String paymentNo = insertPayment(orderNo, 100L, "PREPAY_CREATED", "pending"); assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo( PaymentFundConfirmationService.NOT_SETTLED); verify(delayGateway, never()).confirm(any()); assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction where payment_no = ?", String.class, paymentNo)).isEqualTo("pending"); } @Test void aRejectedConfirmationStaysRetryableInsteadOfLosingTheMoney() { String orderNo = createOrder("GROUP_BUY", 100L); String paymentNo = insertPaidPayment(orderNo, 100L, "pending"); when(delayGateway.confirm(any())).thenReturn(failed("22000002", "商户信息不存在")); assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo( PaymentFundConfirmationService.FAILED); assertThat(jdbc.queryForMap("select funds_confirmation_status, funds_confirm_message" + " from payment_transaction where payment_no = ?", paymentNo)) .containsEntry("funds_confirmation_status", "pending") .containsEntry("funds_confirm_message", "商户信息不存在"); } @Test void aConfirmedOrderCanBeRefundedWhileAnUnconfirmedOneIsNotWithdrawn() { // 这个用例只断言资金状态这一维:确认前订单不可提现,确认后才可提现(提现门槛本身在 // AutoWithdrawalAggregationTests 覆盖)。退款路径的改造尚未完成,这里不做断言。 String orderNo = createOrder("GROUP_BUY", 100L); insertPaidPayment(orderNo, 100L, "pending"); long orderId = orderId(orderNo); jdbc.update("update benefit_order set order_status = 1, verify_status = 1 where id = ?", orderId); when(delayGateway.confirm(any())).thenReturn(succeeded("100")); confirmationService.confirmForOrder(orderId); assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction" + " where order_id = ?", String.class, orderId)).isEqualTo("confirmed"); assertThat(new OrderRefundRequest(BigDecimal.ONE, "test", "tester")).isNotNull(); } // ------------------------------------------------------------------------------------------ // helpers // ------------------------------------------------------------------------------------------ private HuifuDelayAccountGateway.HuifuDelayConfirmation succeeded(String confirmedYuan) { return new HuifuDelayAccountGateway.HuifuDelayConfirmation( HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.SUCCEEDED, "00000000", "交易成功", "0.00", confirmedYuan, "HF-" + System.nanoTime()); } 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 created = orderPortalService.create(talentId, new AppOrderCreateRequest(merchantId, productId, null), "delay-fund-" + System.nanoTime()); String orderNo = String.valueOf(created.get("orderNo")); jdbc.update("update benefit_order set order_type = ?, verify_status = 0 where order_no = ?", orderType, orderNo); return orderNo; } private String insertPaidPayment(String orderNo, long amountInCent, String fundsStatus) { return insertPayment(orderNo, amountInCent, "SUCCEEDED", fundsStatus); } private String insertPayment(String orderNo, long amountInCent, String status, String fundsStatus) { String paymentNo = "P-DELAY-" + System.nanoTime(); Long orderId = orderId(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, attempt_no, paid_at, funds_confirmation_status) values (?, 'HUIFU', ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?) """, paymentNo, orderId, orderNo, talentId, merchantId, HUIFU_ID, amountInCent, status, "delay-payment-" + paymentNo, LocalDateTime.now(), fundsStatus); return paymentNo; } private long orderId(String orderNo) { return jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo); } }