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