fix(payment): 无实付金额的订单改走本地退款,不再向通道发起
0 元订单(整单被权益免费额度覆盖、下单即待核销)里没有钱,通道侧也没有原交易可指,
退款却按通道退款处理,卡在金额闸门上抛 Invalid refund amount:用户从「待使用」里出不去,
这一次免费额度也永远占着。开发库里有 12 单停在「有退款按钮、点了必定报错」的状态。
根因是退款把「能退的东西」等同于「通道收过的钱」。改为 paid_amount = 0 时本地结清:
不发通道请求,通道相关列留空并写一条自解释回执(LOCAL_NO_PAYABLE_AMOUNT),在同一事务里
让订单退出待核销,被占用的免费额度随之释放;不留「退款中」,因为那个状态的含义是等通道的
结论,而这单没有通道那一侧。悬账退款不受影响,仍然只走通道。
回归测试走真实链路(配了免费额度的商品 → 人才端下单 → 使用记录点退款),断言额度确实回来,
并用 verifyNoInteractions 锁住「从头到尾没碰过通道」;另有一条边界用例保证 0 元单上显式要求
退非零金额仍然被拒。修复前该用例报的正是用户那句 Invalid refund amount。
| | |
| | | public Map<String, Object> request(String orderNo, OrderRefundRequest request) { |
| | | PreparedRefund prepared = transactions.execute(status -> prepare(orderNo, request, false)); |
| | | if (prepared == null) throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Refund initialization failed"); |
| | | // 本地结清的退款在 prepare 的事务里就已经落定,没有通道答复要等(也没有通道可以问)。 |
| | | if (prepared.settledLocally()) return detail(prepared.refundNo()); |
| | | return submit(prepared); |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * 无实付金额订单的回执:它不是通道说的,是本地结清时自己写的。 |
| | | * |
| | | * <p>写进 {@code provider_response_*} 是为了让这条记录自解释——事后看到「退款成功、金额 0、通道退款号为空」 |
| | | * 时,能直接从这两列读出「它从来没有出过本地」,不必再去通道上找一笔不存在的退款。 |
| | | */ |
| | | private static final ProviderResult LOCAL_REFUND_RESULT = new ProviderResult( |
| | | true, false, "LOCAL_NO_PAYABLE_AMOUNT", "订单无实付金额(由权益免费额度覆盖),本地退款,无需通道退款", ""); |
| | | |
| | | /** |
| | | * 准备一笔退款:锁定订单行、确认这笔退款合法、算出可退金额、落一条退款记录。 |
| | | * |
| | | * <p>「支付键可见性」不是只看已成功流水:本地已关闭、但通道确认收过钱的那一类悬账 |
| | |
| | | long refundableAmount = Math.max(paidAmount - successfulRefund - pendingRefund, 0); |
| | | long requestedAmount = MoneyUtils.yuanToCent(request.refundAmount()); |
| | | long refundAmount = requestedAmount > 0 ? requestedAmount : refundableAmount; |
| | | if (refundAmount <= 0 || refundAmount > refundableAmount) throw new BusinessException(ErrorCode.BAD_REQUEST, "Invalid refund amount"); |
| | | // 没有实付金额的订单(整单被权益免费额度覆盖、下单即 order_status=1 且从来没有支付尝试)里没有钱, |
| | | // 通道那边也没有原交易可指。它仍然必须能退——要还回去的是那一次被占用的免费额度——所以它的出路是 |
| | | // 本地结清,而不是向通道发起退款:发起也只会得到「金额非法」,用户从此卡在待使用里出不去。 |
| | | // 悬账退款不在此列:悬账退的是通道真收过的钱,必须走通道。 |
| | | boolean settledLocally = !stranded && paidAmount <= 0 && refundAmount == 0; |
| | | if (!settledLocally && (refundAmount <= 0 || refundAmount > refundableAmount)) throw new BusinessException(ErrorCode.BAD_REQUEST, "Invalid refund amount"); |
| | | |
| | | String paymentNo = text(order.get("paymentNo")); |
| | | String providerNo = text(order.get("providerTransactionNo")); |
| | | if (paymentNo.isBlank() && providerNo.isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "Payment transaction is not ready for refund"); |
| | | String huifuId = refundHuifuId(order.get("huifuId")); |
| | | if (!settledLocally && paymentNo.isBlank() && providerNo.isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "Payment transaction is not ready for refund"); |
| | | String huifuId = settledLocally ? null : refundHuifuId(order.get("huifuId")); |
| | | String refundNo = BusinessIdGenerator.next("R"); |
| | | String requestDate = requestDate(order.get("paymentCreatedAt")); |
| | | String requestDate = settledLocally ? null : requestDate(order.get("paymentCreatedAt")); |
| | | jdbc.update(""" |
| | | insert into order_refund(refund_no, order_id, order_no, talent_id, card_id, benefit_id, merchant_id, |
| | | order_type, paid_amount, refund_amount, refundable_amount, refund_status, |
| | |
| | | """, |
| | | refundNo, order.get("id"), order.get("orderNo"), order.get("talentId"), order.get("cardId"), |
| | | order.get("benefitId"), order.get("merchantId"), order.get("orderType"), paidAmount, refundAmount, |
| | | refundableAmount - refundAmount, PROVIDER, huifuId, providerNo, paymentNo, requestDate, request.reason(), request.operatorName()); |
| | | refundableAmount - refundAmount, settledLocally ? null : PROVIDER, huifuId, providerNo, paymentNo, |
| | | requestDate, request.reason(), request.operatorName()); |
| | | // 退款受理成功即刻生效:在结果确定前订单不再可核销,也不参与提现汇总。 |
| | | orderFundBoundary.markRefundInProgress(number(order.get("id"))); |
| | | return new PreparedRefund(refundNo, number(order.get("id")), huifuId, paymentNo, providerNo, requestDate, refundAmount); |
| | | PreparedRefund result = new PreparedRefund(refundNo, number(order.get("id")), huifuId, paymentNo, providerNo, requestDate, refundAmount, settledLocally); |
| | | if (settledLocally) { |
| | | // 与 prepare 同一事务里当场结清,不把订单留在「退款中」:那个状态的含义是等通道的结论,而这 |
| | | // 单没有通道那一侧,留在这里就永远等不到(退款对账只扫 provider = HUIFU 的在途行)。 |
| | | markSucceeded(result, LOCAL_REFUND_RESULT); |
| | | } |
| | | return result; |
| | | } |
| | | |
| | | private void markSucceeded(PreparedRefund prepared, ProviderResult result) { |
| | |
| | | private long number(Object value) { return value instanceof Number number ? number.longValue() : 0; } |
| | | private String text(Object value) { return value == null ? "" : String.valueOf(value).trim(); } |
| | | |
| | | private record PreparedRefund(String refundNo, long orderId, String huifuId, String paymentNo, String originalProviderNo, String originalRequestDate, long refundAmount) { |
| | | private PreparedRefund(String refundNo, long orderId, long refundAmount) { this(refundNo, orderId, "", "", "", "", refundAmount); } |
| | | private record PreparedRefund(String refundNo, long orderId, String huifuId, String paymentNo, String originalProviderNo, String originalRequestDate, long refundAmount, boolean settledLocally) { |
| | | private PreparedRefund(String refundNo, long orderId, long refundAmount) { this(refundNo, orderId, "", "", "", "", refundAmount, false); } |
| | | } |
| | | |
| | | private record ProviderResult(boolean success, boolean failed, String code, String message, String providerNo) { |
| 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.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; |
| | | } |
| | | } |