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.Mockito.verifyNoInteractions; 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.huifu.HuifuRefundGateway; import com.talentcard.server.business.portal.AppOrderCreateRequest; import com.talentcard.server.business.portal.AppOrderPortalService; import com.talentcard.server.business.portal.PendingUsePortalService; import com.talentcard.server.business.portal.PendingUseRefundRequest; import com.talentcard.server.business.portal.PortalService; 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: 一张没有实付金额的订单(整单被权益免费额度覆盖)到底能不能退。 * *

它没有钱在通道里——下单不经过支付,订单出生就是待核销状态——所以要退的是那一次被占用的免费额度, * 而不是钱。这条用例走真实链路:商品配好免费额度 → 人才端下单(价格被算成 0)→ 使用记录里点退款, * 断言额度确实回来了,并且从头到尾没有向通道发起过退款(那张单在通道侧根本不存在)。 */ @SpringBootTest @ActiveProfiles("test") class ZeroAmountRefundTests { @Autowired private JdbcTemplate jdbc; @Autowired private AppOrderPortalService orderPortalService; @Autowired private PendingUsePortalService pendingUsePortalService; @Autowired private PortalService portalService; @Autowired private UsageRecordPortalService usageRecordPortalService; @Autowired private HuifuRefundService refundService; @MockitoBean private HuifuRefundGateway refundGateway; private Long talentId; private Long merchantId; private Long productId; private String suffix; @BeforeEach void prepareData() { suffix = String.valueOf(System.nanoTime()); jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)", "zero-refund-openid-" + suffix, "0元订单用户", "13900000007"); talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class); jdbc.update(""" insert into talent_card(talent_id, card_no, card_level, card_type, status, deleted) values (?, ?, '卓越人才卡', 'PERSONAL', 1, 0) """, talentId, "ZC" + suffix); jdbc.update("insert into merchant(merchant_name, contact_mobile, address, status, deleted) values (?, ?, ?, 1, 0)", "0元订单商户 " + suffix, "08120000006", "Test address"); merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class); // 免费权益:这个卡等级每单免费、周期内 1 次。resetCycle = NONE 表示不重置,额度窗口取全部历史, // 于是「用掉没还回来」在测试里是长期可见的,不受运行日期影响。 jdbc.update(""" insert into merchant_product(merchant_id, product_no, product_type, product_name, original_price, sale_price, stock, audit_status, status, rights_config, deleted) values (?, ?, 'GROUP_BUY', '0元订单测试商品', 20.00, 20.00, -1, 2, 1, ?, 0) """, merchantId, "ZERO-PRODUCT-" + suffix, "[{\"level\":\"卓越人才卡\",\"enabled\":true,\"free\":true,\"limitCount\":1,\"resetCycle\":\"NONE\"}]"); productId = jdbc.queryForObject("select max(id) from merchant_product", Long.class); } @Test void refundingAZeroAmountOrderGivesTheFreeQuotaBackWithoutTouchingTheProvider() { assertThat(talentPrice()).as("构造前提:额度在手,这个商品对这位人才是免费的").isEqualByComparingTo("0"); String orderNo = zeroAmountOrder(); assertThat(talentPrice()).as("下单即占用额度(0 元单没有支付这一步,出生就是待核销)").isEqualByComparingTo("20.00"); Map refund = pendingUsePortalService.refund(orderNo, new PendingUseRefundRequest(talentId, "用户申请退款")); // 这笔单在通道侧从来不存在,退款不该、也没法向通道发起:发起只会得到「金额非法」。 verifyNoInteractions(refundGateway); assertThat(((Number) refund.get("refundStatus")).intValue()) .as("接口回给人才端的退款状态:已经退成了,不是「退款中」") .isEqualTo(2); assertThat(jdbc.queryForMap(""" select paid_amount, refund_amount, provider, provider_refund_no, provider_response_code from order_refund where order_no = ? """, orderNo)) .as("这条退款记录要自解释:没有实付金额、没有通道退款号,说明它从来没有出过本地") .containsEntry("paid_amount", 0L) .containsEntry("refund_amount", 0L) .containsEntry("provider", null) .containsEntry("provider_refund_no", null) .containsEntry("provider_response_code", "LOCAL_NO_PAYABLE_AMOUNT"); assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)) .as("订单退出待核销,人才端不再为它显示核销码") .isEqualTo(2); assertThat(jdbc.queryForObject("select refund_status from benefit_order where order_no = ?", Integer.class, orderNo)) .isEqualTo(2); // 免费额度是按「pay_amount = 0 且 order_status = 1」计数的(AppOrderPortalService 与 PortalService 的 // usedFreeCount),所以订单退出待核销就是额度还回去了。这里直接问价格,看的是用户实际会看到的结果。 assertThat(talentPrice()) .as("这一次免费额度必须还回去,否则用户这个周期内再也买不到这个商品") .isEqualByComparingTo("0"); assertThat(usageRecord(orderNo)) .as("「使用记录」是人才端唯一看得见退款结果的地方") .containsEntry("status", "cancelled"); assertThat(((Number) usageRecord(orderNo).get("refundStatus")).intValue()) .as("页面按它渲染「已退款」") .isEqualTo(2); assertThatThrownBy(() -> pendingUsePortalService.refund(orderNo, new PendingUseRefundRequest(talentId, "再退一次"))) .as("重复点击不能再结清一次") .isInstanceOf(BusinessException.class); assertThat(count("select count(*) from order_refund where order_no = ?", orderNo)) .as("本地结清只发生一次") .isEqualTo(1L); } @Test void anExplicitAmountOnAZeroAmountOrderIsStillRefused() { String orderNo = zeroAmountOrder(); assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("5.00"), "超额", "admin"))) .as("没有实付金额不等于可以凭空退钱:本地结清只回答「无实付金额」这一种情形") .isInstanceOf(BusinessException.class) .hasMessageContaining("Invalid refund amount"); assertThat(count("select count(*) from order_refund where order_no = ?", orderNo)).isZero(); assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)) .as("被拒的退款不能改动订单状态") .isEqualTo(1); } /** 人才端下单。商品配了免费额度,所以这一步算出来的就是 0 元订单:没有支付,直接待核销。 */ private String zeroAmountOrder() { orderPortalService.create(talentId, new AppOrderCreateRequest(merchantId, productId, null), "zero-amount-refund-" + suffix); String orderNo = jdbc.queryForObject(""" select order_no from benefit_order where deleted = 0 and talent_id = ? and product_id = ? order by id desc limit 1 """, String.class, talentId, productId); assertThat(jdbc.queryForMap("select pay_amount, order_status, expires_at from benefit_order where order_no = ?", orderNo)) .as("构造前提:这是一张 0 元订单,出生即待核销,也没有支付时限") .containsEntry("pay_amount", 0L) .containsEntry("order_status", 1) .containsEntry("expires_at", null); assertThat(count("select count(*) from payment_transaction where order_no = ?", orderNo)) .as("构造前提:通道侧从来没有这笔钱") .isZero(); return orderNo; } /** 这位人才现在在这个商品上看到的价格:0 表示免费额度还有,20.00 表示这个周期的额度已经被占用了。 */ private BigDecimal talentPrice() { return portalService.products(merchantId, null, talentId, 1, 20).records().stream() .filter(product -> productId.equals(((Number) product.get("id")).longValue())) .map(product -> (BigDecimal) product.get("salePrice")) .findFirst() .orElseThrow(() -> new AssertionError("Product " + productId + " is missing from the product list")); } private Map usageRecord(String orderNo) { PageResult> 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 usage records")); } private long count(String sql, Object... args) { Long value = jdbc.queryForObject(sql, Long.class, args); return value == null ? 0 : value; } }