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: 一张没有实付金额的订单(整单被权益免费额度覆盖)到底能不能退。
|
*
|
* <p>它没有钱在通道里——下单不经过支付,订单出生就是待核销状态——所以要退的是那一次被占用的免费额度,
|
* 而不是钱。这条用例走真实链路:商品配好免费额度 → 人才端下单(价格被算成 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<String, Object> 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<String, Object> usageRecord(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 usage records"));
|
}
|
|
private long count(String sql, Object... args) {
|
Long value = jdbc.queryForObject(sql, Long.class, args);
|
return value == null ? 0 : value;
|
}
|
}
|