yeg
2 天以前 65085442d5e6dfe2eaa0c7794a2fbc09d801a0dd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
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;
    }
}