| | |
| | | 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) { |