From 5c33cf1f2622d4470b861cb041024ead1775c828 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期三, 16 九月 2026 18:05:27 +0800
Subject: [PATCH] feat(payment): 撤销核销把已确认的资金退回延迟户

---
 talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java                      |   12 
 talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPayload.java                        |   16 
 talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuDelayAccountGateway.java            |   82 ++++
 talent-card-server/src/main/resources/db/migration/V147__payment_fund_revert.sql                                       |   70 ++++
 talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingRefundConfirmationTests.java            |   22 +
 talent-card-server/src/test/java/com/talentcard/server/business/payment/RevokeVerificationFundTests.java               |  160 +++++++++
 CONTEXT.md                                                                                                             |    4 
 talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuDelayRevertClassificationTests.java |   71 ++++
 talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java                        |    6 
 talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentFundConfirmationService.java            |  289 ++++++++++++++++
 talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentFundConfirmationTests.java              |  241 ++++++++++++++
 11 files changed, 957 insertions(+), 16 deletions(-)

diff --git a/CONTEXT.md b/CONTEXT.md
index e35c2d1..f4979aa 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -17,6 +17,10 @@
 核销成功时触发交易确认。资金确认状态记在 `payment_transaction.funds_confirmation_status`:
 `NULL` 表示未走延迟入账,`pending` 表示钱还在延迟户,`confirmed` 表示已分账到商户基本户。
 
+撤销核销必须走反方向:调用通道的**交易确认退款**把已确认的金额退回延迟户,状态回到 `pending`,
+商户重新核销时会再确认一次。回退期间状态是 `reverting`(钱在哪一边未定),此时不允许撤销核销。
+钱被 D1 结算划到商户银行卡之后就退不回来,撤销核销只能被拒绝——所以撤销窗口实际上止于下一次自动结算。
+
 ## 结算
 
 出款由斗拱按结算周期自动完成,平台**不生成提现记录**,也不再发起主动取现。商户进件时通过
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java
index 324c506..0e52884 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java
@@ -38,16 +38,19 @@
 	private final OrderFundBoundaryService orderFundBoundary;
 	private final com.talentcard.server.business.order.OrderVerificationService orderVerificationService;
 	private final HuifuRefundService refundService;
+	private final PaymentFundConfirmationService fundConfirmation;
 
 	public MerchantWorkService(JdbcTemplate jdbcTemplate, ManualConsumptionService manualConsumptionService,
 			OrderFundBoundaryService orderFundBoundary,
 			com.talentcard.server.business.order.OrderVerificationService orderVerificationService,
-			HuifuRefundService refundService) {
+			HuifuRefundService refundService,
+			PaymentFundConfirmationService fundConfirmation) {
 		this.jdbcTemplate = jdbcTemplate;
 		this.manualConsumptionService = manualConsumptionService;
 		this.orderFundBoundary = orderFundBoundary;
 		this.orderVerificationService = orderVerificationService;
 		this.refundService = refundService;
+		this.fundConfirmation = fundConfirmation;
 	}
 
 	@Transactional(readOnly = true)
@@ -280,6 +283,13 @@
 		if (verifyTime == null || Duration.between(verifyTime, LocalDateTime.now()).toHours() >= REVOCATION_HOURS) {
 			throw new BusinessException(ErrorCode.CONFLICT, "已过可撤销时间");
 		}
+		// 核销成功时钱已经确认给了商户(基本户),撤销核销必须先把钱退回通道延迟户。回退没成功就不许
+		// 翻单:否则订单显示「待核销」,钱却已经结算给商户,商户还能拿着这张单再核销一次。
+		String revertOutcome = fundConfirmation.revertForOrder(orderId);
+		if (!PaymentFundConfirmationService.moneyBackInDelayAccount(revertOutcome)) {
+			throw new BusinessException(ErrorCode.CONFLICT,
+					PaymentFundConfirmationService.revertRefusalMessage(revertOutcome));
+		}
 		String reason = request.reason() == null || request.reason().isBlank() ? "商户撤销核销" : request.reason().trim();
 		int updated = jdbcTemplate.update("""
 				update verify_record set status = 2, revoked_at = now(), revoked_by = ?, revoke_reason = ?, revoke_idempotency_key = ?
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java
index 48787ca..3df6f34 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java
@@ -6,6 +6,7 @@
 import com.talentcard.server.business.order.OrderFundBoundaryService;
 import com.talentcard.server.business.order.OrderRefundRequest;
 import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
+import com.talentcard.server.business.payment.huifu.HuifuPayload;
 import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties;
 import com.talentcard.server.business.payment.huifu.HuifuRefundCommand;
 import com.talentcard.server.business.payment.huifu.HuifuRefundGateway;
@@ -287,7 +288,10 @@
             String providerNo = textValue(data.get("hf_seq_id"));
             boolean codeSuccess = "00000000".equals(code) || "10000".equals(code) || "0".equals(code) || "SUCCESS".equalsIgnoreCase(code);
             boolean success = codeSuccess && (status.isBlank() || "S".equalsIgnoreCase(status) || "SUCCESS".equalsIgnoreCase(status) || "PAID".equalsIgnoreCase(status));
-            boolean failed = !success && !("P".equalsIgnoreCase(status) || "PROCESSING".equalsIgnoreCase(status) || "PENDING".equalsIgnoreCase(status));
+            // 语义未定的码一律不判失败。判失败会把订单解锁成「可以再退一次」,万一通道那边第一次其实
+            // 已经出款,第二次就是重复出款;停在在途最多是让对账/人工多收敛一轮。
+            boolean failed = !success && !HuifuPayload.undeterminedCode(code)
+                    && !("P".equalsIgnoreCase(status) || "PROCESSING".equalsIgnoreCase(status) || "PENDING".equalsIgnoreCase(status));
             return new ProviderResult(success, failed, code, message, providerNo);
         }
         private static String textValue(Object value) { return value == null ? "" : String.valueOf(value).trim(); }
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentFundConfirmationService.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentFundConfirmationService.java
index f62aec9..43c8fa3 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentFundConfirmationService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentFundConfirmationService.java
@@ -9,12 +9,15 @@
 import com.talentcard.server.business.DatabaseRows;
 import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway;
 import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayConfirmCommand;
+import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayConfirmRefundCommand;
 import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayConfirmation;
+import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayRevert;
 import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.TransactionDefinition;
 import org.springframework.transaction.support.TransactionTemplate;
 
 /**
@@ -26,6 +29,9 @@
  * <p>状态记在 {@code payment_transaction.funds_confirmation_status}:{@code NULL} 表示这笔支付没有延迟
  * (支付即核销的到店消费),{@code pending} 表示钱还在通道冻着,{@code confirmed} 表示已经分配完成。
  * 自动提现只认 {@code NULL} 和 {@code confirmed},因此「忘了确认」不会变成「提前把钱提走」。
+ *
+ * <p>撤销核销走反方向({@link #revertForOrder}):调用交易确认退款把已确认的钱退回延迟户,成功后状态
+ * 退回 {@code pending},商户重新核销时会再确认一次。回退期间用 {@code reverting} 占位。
  */
 @Service
 public class PaymentFundConfirmationService {
@@ -48,6 +54,33 @@
     public static final String FAILED = "FAILED";
     /** 找不到对应的支付记录。 */
     public static final String UNKNOWN = "UNKNOWN";
+    /**
+     * 订单此刻不再是「已核销」,不能把钱放给商户。
+     *
+     * <p>撤销核销会把订单翻回待核销,此时钱必须留在延迟户:确认在放款前复查这个前提,
+     * 避免「订单已回到待核销、钱却已经打给商户」。
+     */
+    public static final String NOT_VERIFIED = "NOT_VERIFIED";
+
+    /** 钱已退回(或本来就在)通道延迟户,撤销核销可以继续。 */
+    public static final String REVERTED = "REVERTED";
+    /** 这笔资金不需要回退:没有支付记录、支付没有走延迟入账,或钱本来就在延迟户。 */
+    public static final String REVERT_NOT_NEEDED = "REVERT_NOT_NEEDED";
+    /** 通道明确拒绝回退(例如资金已结算到银行卡、基本户余额不足),钱没有动。 */
+    public static final String REVERT_REJECTED = "REVERT_REJECTED";
+    /** 回退结果未定或仍在处理中:钱的位置不确定,撤销核销必须停在「已核销」。 */
+    public static final String REVERT_IN_FLIGHT = "REVERT_IN_FLIGHT";
+    /**
+     * 返回码在这条路径上语义未定:通道给了话,但我们不知道它是什么意思,需要人工核对。
+     *
+     * <p>和 {@link #REVERT_IN_FLIGHT} 分开是因为给商户的话不同:在途可以说「稍后重试」,
+     * 语义未定只能请他联系平台——它也可能是「已结算」,让商户反复重试是没有意义的。
+     */
+    public static final String REVERT_NEEDS_REVIEW = "REVERT_NEEDS_REVIEW";
+    /** 回退请求根本没发出去(本地配置或 SDK 出错):钱没动,可以重试。 */
+    public static final String REVERT_NOT_SENT = "REVERT_NOT_SENT";
+    /** 找不到支付记录,或缺少定位原确认请求的键。 */
+    public static final String REVERT_UNKNOWN = "REVERT_UNKNOWN";
 
     private static final DateTimeFormatter REQUEST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
     private static final Logger log = LoggerFactory.getLogger(PaymentFundConfirmationService.class);
@@ -56,11 +89,24 @@
     private final TransactionTemplate transactions;
     private final HuifuDelayAccountGateway gateway;
 
+    /**
+     * 资金侧的写入用独立事务提交。
+     *
+     * <p>撤销核销会在自己的事务里调用本服务,而通道调用不是事务性的:认领和结果如果跟着外层事务一起
+     * 回滚,通道那边已经退回延迟户的钱在本地就再也看不到了——状态还写着 {@code confirmed},钱其实已经
+     * 不在商户基本户。用独立事务提交,判断依据就永远留在库里。
+     */
+    private final TransactionTemplate moneyTransactions;
+
     public PaymentFundConfirmationService(JdbcTemplate jdbc, TransactionTemplate transactions,
                                           HuifuDelayAccountGateway gateway) {
         this.jdbc = jdbc;
         this.transactions = transactions;
         this.gateway = gateway;
+        // 注入的 TransactionTemplate 是容器里的单例,支付、退款和自动提现共用它,不能改它的传播行为,
+        // 否则那些服务会跟着变成 REQUIRES_NEW、外层事务被静默挂起。这里自己新建一个。
+        this.moneyTransactions = new TransactionTemplate(transactions.getTransactionManager());
+        this.moneyTransactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
     }
 
     /**
@@ -95,7 +141,9 @@
         }
 
         long paymentId = number(payment.get("id"));
-        if (!claim(paymentId)) {
+        ClaimOutcome claimOutcome = transactions.execute(status -> claimConfirmation(orderId, paymentId));
+        if (claimOutcome == ClaimOutcome.ORDER_NOT_VERIFIED) return NOT_VERIFIED;
+        if (claimOutcome != ClaimOutcome.CLAIMED) {
             // 另一个请求已经在确认这笔资金,或者它刚被确认为终态。
             return isConfirmed(currentStatus(paymentId)) ? CONFIRMED : PROCESSING;
         }
@@ -125,19 +173,243 @@
         return applyResult(paymentId, confirmNo, result);
     }
 
-    /** 认领这笔资金确认:只有仍为 pending 的那一次能拿到,避免重复向通道发确认。 */
+    /**
+     * 撤销核销:把这笔已经确认给商户的资金退回通道延迟户。
+     *
+     * <p>调用方只有在拿到 {@link #REVERTED} 或 {@link #REVERT_NOT_NEEDED} 时才可以撤销核销。其余结果都
+     * 表示钱的位置不确定、或仍在商户手里:此时翻单会造成「订单显示待核销、钱已经结算给商户」。
+     *
+     * <p>方法自身不开事务,认领与落结果各走独立事务(见 {@link #moneyTransactions}):通道调用不是
+     * 事务性的,让这些写入跟着外层事务回滚,会把通道的真实结果丢掉。
+     */
+    public String revertForOrder(long orderId) {
+        List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
+                select p.id, p.payment_no, p.funds_confirmation_status, p.funds_confirm_no,
+                       p.funds_confirm_request_date, p.funds_confirmed_amount, p.provider_huifu_id,
+                       o.collect_huifu_id
+                from payment_transaction p
+                join benefit_order o on o.id = p.order_id
+                where p.order_id = ?
+                order by p.id desc
+                limit 1
+                """, orderId));
+        // 没有支付记录:这笔订单在通道里没有钱(历史数据或手工数据),没有已确认的资金需要回退。
+        if (rows.isEmpty()) return REVERT_NOT_NEEDED;
+
+        Map<String, Object> payment = rows.get(0);
+        String fundsStatus = text(payment.get("fundsConfirmationStatus"));
+        // 没有走延迟入账(支付即核销的到店消费),或钱本来就在延迟户:同样不需要回退。
+        if (fundsStatus.isBlank() || statusIs(fundsStatus, "pending")) return REVERT_NOT_NEEDED;
+        // 回退在途或确认在途:钱在哪一边未定,等它收敛后再撤销。
+        if (statusIs(fundsStatus, "reverting") || statusIs(fundsStatus, "confirming")) return REVERT_IN_FLIGHT;
+        if (!statusIs(fundsStatus, "confirmed")) return REVERT_UNKNOWN;
+
+        String confirmNo = text(payment.get("fundsConfirmNo"));
+        String confirmRequestDate = text(payment.get("fundsConfirmRequestDate"));
+        if (confirmNo.isBlank() || confirmRequestDate.isBlank()) {
+            // 没有原确认的定位键就撤销不了确认;钱确实在商户手里,不能翻单,只能人工处理。
+            log.warn("Payment {} is confirmed but has no confirmation reference, the revert needs a human",
+                    payment.get("paymentNo"));
+            return REVERT_UNKNOWN;
+        }
+        String huifuId = firstNonBlank(text(payment.get("providerHuifuId")), text(payment.get("collectHuifuId")));
+        if (huifuId.isBlank()) {
+            log.warn("Payment {} has no collecting sub-merchant, the revert needs a human", payment.get("paymentNo"));
+            return REVERT_UNKNOWN;
+        }
+
+        long paymentId = number(payment.get("id"));
+        String revertNo = BusinessIdGenerator.next("DR");
+        String requestDate = REQUEST_DATE.format(LocalDate.now());
+        if (!claimRevert(paymentId, revertNo, requestDate)) {
+            // 另一个请求已经在回退这笔钱。
+            return REVERT_IN_FLIGHT;
+        }
+
+        HuifuDelayRevert result;
+        try {
+            result = gateway.refund(new HuifuDelayConfirmRefundCommand(
+                    revertNo, huifuId, confirmNo, confirmRequestDate, "撤销核销退回延迟户"));
+        } catch (HuifuPaymentGatewayException exception) {
+            // 请求可能已经被通道受理:停在 reverting 交给人工核对,绝不按失败处理,也绝不放行撤销核销。
+            log.warn("Fund revert for payment {} has an unknown result: {}",
+                    payment.get("paymentNo"), exception.getMessage());
+            recordRevertAttempt(paymentId, revertNo, "", exception.getMessage());
+            return REVERT_IN_FLIGHT;
+        } catch (RuntimeException exception) {
+            // 通道根本没被调到(配置不完整、SDK 初始化失败之类):钱没动,可以重试。不能说成「已结算」,
+            // 商户照着这句话去找平台就白跑了。
+            log.warn("Fund revert for payment {} failed before reaching the provider: {}",
+                    payment.get("paymentNo"), exception.getMessage());
+            abandonRevertClaim(paymentId, exception.getMessage());
+            return REVERT_NOT_SENT;
+        }
+
+        return applyRevertResult(paymentId, revertNo, result, payment);
+    }
+
+    /**
+     * 认领这笔资金确认:只有仍为 pending 的那一次能拿到,避免重复向通道发确认。
+     *
+     * <p>同时收掉「认领之后进程就没了」留下的孤儿:状态停在 {@code confirming}、认领日期又不是今天的
+     * 那些请求,说明那次确认没走到落结果就中断了(部署、崩溃、超时被杀)。补偿任务只挑得到这种行而
+     * 不会重发,没有这一步这笔钱就永远冻在延迟户里。重新确认是幂等的——通道会按「已无未确认金额」
+     * 返回,{@link HuifuDelayConfirmation} 把那个码当成功,所以隔天补发一次是安全的。
+     *
+     * <p>认领日期为空的行不动:那说明它不是我们认领出来的(手工数据),宁可留着给人工看。
+     */
     private boolean claim(long paymentId) {
+        String today = REQUEST_DATE.format(LocalDate.now());
         int claimed = jdbc.update("""
                 update payment_transaction
                 set funds_confirmation_status = 'confirming', funds_confirm_request_date = ?
-                where id = ? and funds_confirmation_status = 'pending'
-                """, REQUEST_DATE.format(LocalDate.now()), paymentId);
+                where id = ?
+                  and (funds_confirmation_status = 'pending'
+                       or (funds_confirmation_status = 'confirming'
+                           and funds_confirm_request_date is not null
+                           and funds_confirm_request_date < ?))
+                """, today, paymentId, today);
         return claimed == 1;
     }
 
-    /** 数据库里的确认状态是小写,比较必须大小写无关。 */
+    /**
+     * 在订单行锁里复查「订单此刻仍然已核销」,再认领资金确认。
+     *
+     * <p>撤销核销锁的是同一行,因此这个判断在整个认领期间是稳定的:撤销核销提交在前,这里会看到
+     * {@code verify_status} 已经为 0 而拒绝认领;认领在前,撤销核销会看到 {@code confirming} 而拒绝翻单。
+     * 两个方向都不会出现「订单回到待核销、钱却打给了商户」。
+     */
+    private ClaimOutcome claimConfirmation(long orderId, long paymentId) {
+        List<Map<String, Object>> orders = DatabaseRows.normalize(jdbc.queryForList("""
+                select verify_status, order_status, refund_status
+                from benefit_order
+                where id = ? and deleted = 0
+                limit 1 for update
+                """, orderId));
+        if (orders.isEmpty()) return ClaimOutcome.ORDER_NOT_VERIFIED;
+        Map<String, Object> order = orders.get(0);
+        if (number(order.get("verifyStatus")) != 1 || number(order.get("orderStatus")) != 1
+                || number(order.get("refundStatus")) != 0) {
+            return ClaimOutcome.ORDER_NOT_VERIFIED;
+        }
+        return claim(paymentId) ? ClaimOutcome.CLAIMED : ClaimOutcome.NOT_CLAIMED;
+    }
+
+    private enum ClaimOutcome { CLAIMED, NOT_CLAIMED, ORDER_NOT_VERIFIED }
+
+    /** 认领这笔资金回退:只有仍为 confirmed 的那一次能拿到,避免重复向通道发回退请求。 */
+    private boolean claimRevert(long paymentId, String revertNo, String requestDate) {
+        int claimed = moneyTransactions.execute(status -> jdbc.update("""
+                update payment_transaction
+                set funds_confirmation_status = 'reverting', funds_revert_no = ?,
+                    funds_revert_request_date = ?, funds_revert_code = null, funds_revert_message = null
+                where id = ? and funds_confirmation_status = 'confirmed'
+                """, revertNo, requestDate, paymentId));
+        return claimed == 1;
+    }
+
+    /**
+     * 落回退结果。
+     *
+     * <p>只有通道明确说成功才把状态退回 {@code pending}(等于「钱在延迟户」)。明确拒绝退回
+     * {@code confirmed},其余一律停在 {@code reverting}:这条路径上没有真实回执证明码的含义之前不猜,
+     * 因为把失败当成功会让订单翻回待核销而钱还在商户基本户。
+     */
+    private String applyRevertResult(long paymentId, String revertNo, HuifuDelayRevert result,
+                                     Map<String, Object> payment) {
+        switch (result.outcome()) {
+            case SUCCEEDED -> {
+                // 上一轮的确认回执(funds_confirm_*)保留下来作为「曾经确认过」的证据;商户重新核销时
+                // 会走一次全新的交易确认。
+                Long revertedAmount = cents(payment.get("fundsConfirmedAmount"));
+                moneyTransactions.executeWithoutResult(status -> jdbc.update("""
+                        update payment_transaction
+                        set funds_confirmation_status = 'pending', funds_revert_no = ?,
+                            funds_revert_code = ?, funds_revert_message = ?,
+                            funds_reverted_amount = ?, funds_reverted_at = now()
+                        where id = ? and funds_confirmation_status = 'reverting'
+                        """, revertNo, result.code(), result.describe(), revertedAmount, paymentId));
+                log.info("Fund revert {} succeeded: the money is back in the delay account", revertNo);
+                return REVERTED;
+            }
+            case FAILED -> {
+                // 通道明确拒绝:钱没有动,状态退回可重试的 confirmed,并把回执留作证据。
+                moneyTransactions.executeWithoutResult(status -> jdbc.update("""
+                        update payment_transaction
+                        set funds_confirmation_status = 'confirmed',
+                            funds_revert_code = ?, funds_revert_message = ?
+                        where id = ? and funds_confirmation_status = 'reverting'
+                        """, result.code(), result.describe(), paymentId));
+                log.warn("Fund revert {} was rejected: {} ({})", revertNo, result.describe(), result.code());
+                return REVERT_REJECTED;
+            }
+            case NEEDS_REVIEW -> {
+                // 通道给了话但语义未定(例如 23000003):不能假定钱回到了延迟户,留在 reverting 等人工。
+                // 通道流水号要进日志:人工去控台核对时靠它定位这笔回退。
+                recordRevertAttempt(paymentId, revertNo, result.code(), result.describe());
+                log.warn("Fund revert {} has an undetermined result, the order stays verified:"
+                                + " provider={} code={} desc={}",
+                        revertNo, result.providerTransactionNo(), result.code(), result.describe());
+                return REVERT_NEEDS_REVIEW;
+            }
+            default -> {
+                // 通道还在处理:同样是钱的位置未定,但它是正常的在途,商户稍后重试就能拿到结果。
+                recordRevertAttempt(paymentId, revertNo, result.code(), result.describe());
+                return REVERT_IN_FLIGHT;
+            }
+        }
+    }
+
+    /** 只记录一次回退尝试的请求号与描述,不改状态。 */
+    private void recordRevertAttempt(long paymentId, String revertNo, String code, String message) {
+        moneyTransactions.executeWithoutResult(status -> jdbc.update("""
+                update payment_transaction
+                set funds_revert_no = ?, funds_revert_code = ?, funds_revert_message = ?
+                where id = ?
+                """, revertNo, code, message == null ? "" : message, paymentId));
+    }
+
+    /**
+     * 放弃这次回退认领:请求根本没发出去(本地配置或 SDK 出错),钱没有动,退回 confirmed 让商户重试。
+     *
+     * <p>请求号与请求日期要清掉:它们是给通道侧定位用的,这次通道根本没收到。失败原因留在
+     * {@code funds_revert_code/message} 里,免得排查时只看到一条干干净净的 confirmed。
+     */
+    private void abandonRevertClaim(long paymentId, String message) {
+        moneyTransactions.executeWithoutResult(status -> jdbc.update("""
+                update payment_transaction
+                set funds_confirmation_status = 'confirmed', funds_revert_no = null,
+                    funds_revert_request_date = null, funds_revert_code = 'LOCAL_ERROR',
+                    funds_revert_message = ?
+                where id = ? and funds_confirmation_status = 'reverting'
+                """, message == null ? "" : message, paymentId));
+    }
+
+    /** 这笔资金是否确实在通道延迟户——撤销核销只有在这个前提下才允许翻单。 */
+    public static boolean moneyBackInDelayAccount(String outcome) {
+        return REVERTED.equals(outcome) || REVERT_NOT_NEEDED.equals(outcome);
+    }
+
+    /**
+     * 回退没有成功时给商户的话。
+     *
+     * <p>不能只说「撤销失败」:商户需要分清是钱已经结算到银行卡(要联系平台),还是通道还在处理
+     * (稍后重试就行),否则只会反复点。文案跟结论常量放在一起,免得将来新增一种结论时漏配。
+     */
+    public static String revertRefusalMessage(String outcome) {
+        if (REVERT_REJECTED.equals(outcome)) return "这笔订单的资金已结算到商户银行卡,无法撤销核销";
+        if (REVERT_IN_FLIGHT.equals(outcome)) return "这笔订单的资金正在退回中,请稍后重试";
+        if (REVERT_NOT_SENT.equals(outcome)) return "撤销核销暂时无法发起,请稍后重试";
+        return "这笔订单的资金状态无法核对,请联系平台处理";
+    }
+
+    /** 数据库里的状态是小写,比较必须大小写无关。 */
+    private static boolean statusIs(String status, String expected) {
+        return status != null && expected.equalsIgnoreCase(status.trim());
+    }
+
     private static boolean isConfirmed(String status) {
-        return status != null && "confirmed".equalsIgnoreCase(status.trim());
+        return statusIs(status, "confirmed");
     }
 
     private String currentStatus(long paymentId) {
@@ -196,6 +468,11 @@
         return value instanceof Number number ? number.longValue() : 0L;
     }
 
+    /** 库里的金额字段(分);空值保持 null,好和「没记过金额」区分开。 */
+    private static Long cents(Object value) {
+        return value instanceof Number number ? number.longValue() : null;
+    }
+
     private LocalDate localDate(Object value) {
         if (value instanceof java.sql.Timestamp timestamp) return timestamp.toLocalDateTime().toLocalDate();
         if (value instanceof java.time.LocalDateTime dateTime) return dateTime.toLocalDate();
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuDelayAccountGateway.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuDelayAccountGateway.java
index 83730d6..496ec68 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuDelayAccountGateway.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuDelayAccountGateway.java
@@ -24,6 +24,9 @@
  *
  * <p>业务上是否延迟由订单类型决定:先付钱后核销的订单延迟,核销成功才确认;支付即核销的订单不延迟,
  * 因此不需要确认。
+ *
+ * <p>核销被撤销时走反方向:交易确认退款把已确认给商户的钱退回延迟户,重新变成不可结算、不可取现,
+ * 等商户重新核销时再确认一次。这条路径的结果分类与确认分开,见 {@link HuifuDelayRevert}。
  */
 @Service
 public class HuifuDelayAccountGateway {
@@ -81,8 +84,8 @@
         return execute(request, CONFIRM_QUERY_FUNCTION, "汇付交易确认查询失败");
     }
 
-    /** 把已确认的金额退回原商户账户。 */
-    public HuifuDelayConfirmation refund(HuifuDelayConfirmRefundCommand command) {
+    /** 把已确认的金额退回原商户账户(延迟户),撤销核销用它把钱要回来。 */
+    public HuifuDelayRevert refund(HuifuDelayConfirmRefundCommand command) {
         ensureConfigured();
         initializeSdk();
         Map<String, Object> request = new LinkedHashMap<>();
@@ -96,10 +99,10 @@
                 "req_seq_id", command.refundNo(),
                 "huifu_id", command.huifuId().trim(),
                 "org_req_seq_id", command.confirmNo())));
-        return execute(request, CONFIRM_REFUND_FUNCTION, "汇付交易确认退款失败");
+        return executeRevert(request, CONFIRM_REFUND_FUNCTION, "汇付交易确认退款失败");
     }
 
-    private HuifuDelayConfirmation execute(Map<String, Object> request, String function, String failureMessage) {
+    private Map<String, Object> call(Map<String, Object> request, String function, String failureMessage) {
         Map<String, Object> response;
         try {
             response = BasePayClient.request(request, function, false);
@@ -109,7 +112,15 @@
             throw new HuifuPaymentGatewayException(failureMessage, isOutcomeUnknown(exception), exception);
         }
         if (response == null) throw new HuifuPaymentGatewayException(failureMessage, true, null);
-        return HuifuDelayConfirmation.from(response);
+        return response;
+    }
+
+    private HuifuDelayConfirmation execute(Map<String, Object> request, String function, String failureMessage) {
+        return HuifuDelayConfirmation.from(call(request, function, failureMessage));
+    }
+
+    private HuifuDelayRevert executeRevert(Map<String, Object> request, String function, String failureMessage) {
+        return HuifuDelayRevert.from(call(request, function, failureMessage));
     }
 
     private void ensureConfigured() {
@@ -239,4 +250,65 @@
             return HuifuPayload.amountInCent(confirmedAmount);
         }
     }
+
+    /**
+     * 交易确认退款(撤销核销回退资金)的结果。
+     *
+     * <p>刻意不复用 {@link HuifuDelayConfirmation} 的分类:同一个业务码在确认和回退两条路径上语义
+     * 不同。{@code 23000003} 在确认路径上是「已无未确认金额」——那是既有真实回执复验过的幂等成功,
+     * 而在回退路径上它既可能是「已无已确认金额」(同样是幂等),也可能是被拒的原因(例如基本户
+     * 余额不足)。这里一律不猜:判成成功会让订单翻回「待核销」而钱还在商户基本户,是资金事故;
+     * 判成失败又没有回执依据。所以语义未定的码单独归到 {@link Outcome#NEEDS_REVIEW},停在
+     * 「已核销」等人工核对。
+     */
+    public record HuifuDelayRevert(
+            Outcome outcome,
+            String code,
+            String message,
+            String providerTransactionNo
+    ) {
+
+        public enum Outcome {
+            /** 钱已退回商户延迟户。 */
+            SUCCEEDED,
+            /** 通道还在处理,结果未定。 */
+            PROCESSING,
+            /** 通道明确拒绝(例如资金已结算到银行卡、余额不足),钱没有动。 */
+            FAILED,
+            /** 返回码在这条路径上语义未定,不能当作成功或失败,需要人工核对。 */
+            NEEDS_REVIEW
+        }
+
+        static HuifuDelayRevert from(Map<String, Object> response) {
+            Map<String, Object> data = HuifuPayload.businessData(response);
+            String code = HuifuPayload.code(data);
+            String message = HuifuPayload.firstNonBlank(
+                    HuifuPayload.value(data, "sub_resp_desc", "trans_desc"), HuifuPayload.message(data));
+            String status = HuifuPayload.value(data, "trans_stat", "trans_status", "transStat", "transStatus");
+            String providerNo = HuifuPayload.value(data, "hf_seq_id", "org_hf_seq_id", "hfSeqId", "orgHfSeqId");
+            return new HuifuDelayRevert(classify(status, code), code, message, providerNo);
+        }
+
+        /**
+         * 在回退路径上语义未定的业务码见 {@link HuifuPayload#undeterminedCode(String)}。
+         *
+         * <p>它们必须排在交易状态之前判断:通道给这些码时会同时给 {@code trans_stat=F},
+         * 先看状态就会把它们当成明确失败(交易确认那条路上正是这样踩过一次坑)。
+         */
+        private static Outcome classify(String status, String code) {
+            String normalizedCode = code == null ? "" : code.trim();
+            if (HuifuPayload.undeterminedCode(normalizedCode)) return Outcome.NEEDS_REVIEW;
+            if (HuifuPayload.successCode(normalizedCode)) return Outcome.SUCCEEDED;
+            if (HuifuPayload.status(status) == HuifuPayload.TransStatus.FAILED) return Outcome.FAILED;
+            if ("00000100".equals(normalizedCode)) return Outcome.PROCESSING;
+            return normalizedCode.isBlank() ? Outcome.PROCESSING : Outcome.FAILED;
+        }
+
+        /** 供日志与故障定位使用的一句话结论。 */
+        public String describe() {
+            if (!message.isBlank()) return message;
+            if (!code.isBlank()) return "Huifu delay confirmation refund returned code " + code;
+            return "The provider returned no description";
+        }
+    }
 }
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPayload.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPayload.java
index 38dcf33..467ee1d 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPayload.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPayload.java
@@ -60,6 +60,22 @@
         return "00000000".equals(value) || "10000".equals(value) || "0".equals(value) || "SUCCESS".equalsIgnoreCase(value);
     }
 
+    /**
+     * 在延时交易的资金路径上语义未定的业务码:既不能当作成功,也不能当作失败。
+     *
+     * <p>同一个码按接口和场景重载。{@code 23000003} 在交易确认页写作「确认金额大于可确认金额」或
+     * 「已无未确认金额」;{@code 23000004} 在同一页写作「需要传入分账串」。交易确认那条路已经用真实
+     * 回执验证过「{@code 23000003} 同时带 {@code trans_stat=F}」实际是幂等成功,说明只看交易状态
+     * 会判错;而它在退款、交易确认退款方向上的含义还没有回执验证过。
+     *
+     * <p>因此遇到这些码的下游一律不要下结论,两个方向的错都很贵:判成功等于放行可能没动的钱,
+     * 判失败等于让本地以为钱没动、于是再动一次(重复出款)。
+     */
+    public static boolean undeterminedCode(String code) {
+        String value = text(code);
+        return "23000003".equals(value) || "23000004".equals(value);
+    }
+
     /** Provider amount in yuan with two decimals, converted to integer cents. Null when unusable. */
     public static Long amountInCent(String yuan) {
         String value = text(yuan);
diff --git a/talent-card-server/src/main/resources/db/migration/V147__payment_fund_revert.sql b/talent-card-server/src/main/resources/db/migration/V147__payment_fund_revert.sql
new file mode 100644
index 0000000..3452280
--- /dev/null
+++ b/talent-card-server/src/main/resources/db/migration/V147__payment_fund_revert.sql
@@ -0,0 +1,70 @@
+-- 撤销核销的资金回退。
+--
+-- 核销会把延迟入账的钱从通道「待确认」确认给商户(钱进商户基本户)。撤销核销必须把钱要回来,
+-- 靠的是通道的【交易确认退款】接口:把已确认的金额退回商户的延迟户,重新变成不可结算、不可取现。
+--
+-- 回退期间 funds_confirmation_status 用 'reverting' 占位:
+--   'pending'    钱在通道延迟户(从未确认过,或已回退成功)
+--   'confirming' 已发出交易确认请求,结果未定
+--   'confirmed'  确认成功,钱在商户基本户
+--   'reverting'  已发出交易确认退款请求,结果未定——既不能当作钱在基本户,也不能当作钱已回到
+--                延迟户,因此撤销核销必须停在「已核销」等它收敛
+--
+-- 回退失败(例如钱已被 D1 结算划到商户银行卡、基本户余额不足)会把状态退回 'confirmed',
+-- 并把返回码与描述留在下面这几列里作为证据;回退成功后退回 'pending',商户重新核销时会走一次
+-- 全新的交易确认,因此上一轮的确认回执(funds_confirm_*)要保留下来,不要清空。
+--
+-- 用存储过程按 exists 判断逐列追加,使脚本可以重复执行(本地开发的库常常手工加过列)。
+DROP PROCEDURE IF EXISTS add_fund_revert_columns;
+DELIMITER //
+CREATE PROCEDURE add_fund_revert_columns()
+BEGIN
+    IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+                   WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
+                     AND column_name = 'funds_revert_no') THEN
+        ALTER TABLE payment_transaction
+            ADD COLUMN funds_revert_no VARCHAR(64) DEFAULT NULL COMMENT '交易确认退款(撤销核销回退)请求流水号';
+    END IF;
+    IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+                   WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
+                     AND column_name = 'funds_revert_request_date') THEN
+        ALTER TABLE payment_transaction
+            ADD COLUMN funds_revert_request_date CHAR(8) DEFAULT NULL COMMENT '交易确认退款请求日期(yyyyMMdd)';
+    END IF;
+    IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+                   WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
+                     AND column_name = 'funds_revert_code') THEN
+        ALTER TABLE payment_transaction
+            ADD COLUMN funds_revert_code VARCHAR(64) DEFAULT NULL COMMENT '交易确认退款返回码';
+    END IF;
+    IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+                   WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
+                     AND column_name = 'funds_revert_message') THEN
+        ALTER TABLE payment_transaction
+            ADD COLUMN funds_revert_message VARCHAR(512) DEFAULT NULL COMMENT '交易确认退款返回描述';
+    END IF;
+    IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+                   WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
+                     AND column_name = 'funds_reverted_amount') THEN
+        ALTER TABLE payment_transaction
+            ADD COLUMN funds_reverted_amount BIGINT DEFAULT NULL COMMENT '回退的金额(分),取自上一轮确认回执的已确认金额';
+    END IF;
+    IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+                   WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
+                     AND column_name = 'funds_reverted_at') THEN
+        ALTER TABLE payment_transaction
+            ADD COLUMN funds_reverted_at DATETIME DEFAULT NULL COMMENT '资金退回延迟户的时间';
+    END IF;
+    -- 状态列多了一个 'reverting':注释只在还是旧文案时刷新一次。
+    IF EXISTS (SELECT 1 FROM information_schema.columns
+               WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
+                 AND column_name = 'funds_confirmation_status'
+                 AND column_comment LIKE '%confirmed,NULL 表示%') THEN
+        ALTER TABLE payment_transaction
+            MODIFY COLUMN funds_confirmation_status VARCHAR(16) DEFAULT NULL
+                COMMENT '延迟交易的资金确认状态:pending/confirming/confirmed/reverting,NULL 表示未走延迟入账';
+    END IF;
+END //
+DELIMITER ;
+CALL add_fund_revert_columns();
+DROP PROCEDURE add_fund_revert_columns;
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentFundConfirmationTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentFundConfirmationTests.java
index 19833f2..ab9af53 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentFundConfirmationTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentFundConfirmationTests.java
@@ -2,12 +2,15 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import java.math.BigDecimal;
+import java.time.LocalDate;
 import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
 import java.util.Map;
 
 import com.talentcard.server.business.order.OrderRefundRequest;
@@ -16,6 +19,8 @@
 import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway;
 import com.talentcard.server.business.portal.AppOrderCreateRequest;
 import com.talentcard.server.business.portal.AppOrderPortalService;
+import com.talentcard.server.common.BusinessException;
+import com.talentcard.server.common.ErrorCode;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
@@ -108,6 +113,7 @@
     void aDelayedOrderHoldsTheMoneyUntilItIsConfirmed() {
         String orderNo = createOrder("GROUP_BUY", 100L);
         String paymentNo = insertPaidPayment(orderNo, 100L, "pending");
+        markVerified(orderId(orderNo));
 
         when(delayGateway.confirm(any())).thenReturn(succeeded("1.00"));
 
@@ -128,6 +134,7 @@
     void anAlreadyConfirmedOrderIsNeverConfirmedTwice() {
         String orderNo = createOrder("GROUP_BUY", 100L);
         insertPaidPayment(orderNo, 100L, "pending");
+        markVerified(orderId(orderNo));
         when(delayGateway.confirm(any())).thenReturn(succeeded("1.00"));
 
         confirmationService.confirmForOrder(orderId(orderNo));
@@ -141,6 +148,7 @@
     void aConfirmationAlreadyInFlightIsNotStartedAgain() {
         String orderNo = createOrder("GROUP_BUY", 100L);
         insertPaidPayment(orderNo, 100L, "confirming");
+        markVerified(orderId(orderNo));
 
         assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
                 PaymentFundConfirmationService.PROCESSING);
@@ -169,12 +177,17 @@
     @Test
     void theRetrySkipsOrdersWhoseFundsAreAlreadyConfirmed() {
         String orderNo = createOrder("GROUP_BUY", 100L);
-        insertPaidPayment(orderNo, 100L, "confirmed");
-        jdbc.update("update benefit_order set order_status = 1, verify_status = 1 where id = ?", orderId(orderNo));
+        String paymentNo = insertPaidPayment(orderNo, 100L, "confirmed");
+        markVerified(orderId(orderNo));
+        // 补偿任务扫的是整库,会顺带处理其它用例留下的「已核销 + 待确认」订单,所以给它们一个可用的
+        // 回执,并把断言收窄到本单:要证明的是这笔已经确认过的钱不会被再确认一次。
+        when(delayGateway.confirm(any())).thenReturn(processing());
 
         confirmationRetry.retry(50);
 
-        verify(delayGateway, never()).confirm(any());
+        assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
+                + " where payment_no = ?", String.class, paymentNo)).isEqualTo("confirmed");
+        verify(delayGateway, never()).confirm(argThat(command -> paymentNo.equals(command.paymentNo())));
     }
 
     @Test
@@ -204,6 +217,7 @@
     void aRejectedConfirmationStaysRetryableInsteadOfLosingTheMoney() {
         String orderNo = createOrder("GROUP_BUY", 100L);
         String paymentNo = insertPaidPayment(orderNo, 100L, "pending");
+        markVerified(orderId(orderNo));
         when(delayGateway.confirm(any())).thenReturn(failed("22000002", "商户信息不存在"));
 
         assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
@@ -232,6 +246,161 @@
         assertThat(new OrderRefundRequest(BigDecimal.ONE, "test", "tester")).isNotNull();
     }
 
+    /**
+     * 撤销核销要把已确认给商户的钱退回通道延迟户,这是「撤销之后钱又变成不可结算」的唯一保证。
+     */
+    @Test
+    void aRevertPutsTheMoneyBackInTheDelayAccount() {
+        String paymentNo = confirmedDelayOrder(100L);
+        when(delayGateway.refund(any())).thenReturn(revertSucceeded());
+
+        assertThat(confirmationService.revertForOrder(orderIdOfPayment(paymentNo))).isEqualTo(
+                PaymentFundConfirmationService.REVERTED);
+
+        Map<String, Object> payment = jdbc.queryForMap("""
+                select funds_confirmation_status, funds_revert_no, funds_revert_request_date,
+                       funds_reverted_amount, funds_reverted_at, funds_confirm_no
+                from payment_transaction where payment_no = ?
+                """, paymentNo);
+        // 钱回到延迟户:状态退回 pending,商户重新核销时会走一次全新的交易确认。
+        assertThat(payment).containsEntry("funds_confirmation_status", "pending");
+        assertThat(String.valueOf(payment.get("funds_revert_no"))).isNotBlank();
+        assertThat(String.valueOf(payment.get("funds_revert_request_date"))).isNotBlank();
+        assertThat(((Number) payment.get("funds_reverted_amount")).longValue()).isEqualTo(100L);
+        assertThat(payment.get("funds_reverted_at")).isNotNull();
+        // 上一轮确认的回执要留着:它是「这笔钱曾经确认过」的证据。
+        assertThat(String.valueOf(payment.get("funds_confirm_no"))).isNotBlank();
+    }
+
+    /**
+     * 通道明确拒绝(例如钱已被 D1 结算划到商户银行卡):钱没有动,状态回到可重试的 confirmed,
+     * 回执留作证据。调用方据此拒绝撤销核销。
+     */
+    @Test
+    void aRejectedRevertKeepsTheMoneyWithTheMerchant() {
+        String paymentNo = confirmedDelayOrder(100L);
+        when(delayGateway.refund(any())).thenReturn(new HuifuDelayAccountGateway.HuifuDelayRevert(
+                HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.FAILED, "22000003", "账户余额不足", ""));
+
+        assertThat(confirmationService.revertForOrder(orderIdOfPayment(paymentNo))).isEqualTo(
+                PaymentFundConfirmationService.REVERT_REJECTED);
+
+        assertThat(jdbc.queryForMap("select funds_confirmation_status, funds_revert_message"
+                + " from payment_transaction where payment_no = ?", paymentNo))
+                .containsEntry("funds_confirmation_status", "confirmed")
+                .containsEntry("funds_revert_message", "账户余额不足");
+    }
+
+    /**
+     * 返回码语义未定时绝不能算成功。
+     *
+     * <p>{@code 23000003} 在交易确认那条路上是「已无未确认金额」=幂等成功(有真实回执复验过),
+     * 但在回退这条路上没有回执证明它的含义。判成成功会让订单翻回「待核销」而钱还在商户基本户,
+     * 因此只能停在 reverting 等人工核对。
+     */
+    @Test
+    void anUndeterminedRevertCodeNeverCountsAsSuccess() {
+        String paymentNo = confirmedDelayOrder(100L);
+        when(delayGateway.refund(any())).thenReturn(new HuifuDelayAccountGateway.HuifuDelayRevert(
+                HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.NEEDS_REVIEW, "23000003", "已无已确认金额", ""));
+
+        assertThat(confirmationService.revertForOrder(orderIdOfPayment(paymentNo))).isEqualTo(
+                PaymentFundConfirmationService.REVERT_NEEDS_REVIEW);
+
+        assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
+                + " where payment_no = ?", String.class, paymentNo)).isEqualTo("reverting");
+    }
+
+    /**
+     * 请求根本没发出去(配置不完整、SDK 初始化失败)时,钱没有动,可以重试。
+     *
+     * <p>不能报成「已结算到银行卡」:那是另一回事,商户照着这句话去找平台就白跑了。
+     */
+    @Test
+    void aRevertThatNeverReachedTheProviderIsRetryable() {
+        String paymentNo = confirmedDelayOrder(100L);
+        when(delayGateway.refund(any())).thenThrow(new BusinessException(ErrorCode.CONFLICT, "汇付支付配置不完整"));
+
+        assertThat(confirmationService.revertForOrder(orderIdOfPayment(paymentNo))).isEqualTo(
+                PaymentFundConfirmationService.REVERT_NOT_SENT);
+
+        Map<String, Object> payment = jdbc.queryForMap("select funds_confirmation_status, funds_revert_no,"
+                + " funds_revert_code, funds_revert_message from payment_transaction where payment_no = ?", paymentNo);
+        assertThat(payment).containsEntry("funds_confirmation_status", "confirmed")
+                .containsEntry("funds_revert_code", "LOCAL_ERROR");
+        // 请求号是给通道侧定位用的,这次通道没收到,不能留在库里冒充一次回退。
+        assertThat(payment.get("funds_revert_no")).isNull();
+        assertThat(String.valueOf(payment.get("funds_revert_message"))).isNotBlank();
+    }
+
+    /** 钱还在延迟户:没有已确认的资金要回退,也不该白打一次通道。 */
+    @Test
+    void moneyStillInTheDelayAccountNeedsNoRevert() {
+        String orderNo = createOrder("GROUP_BUY", 100L);
+        insertPaidPayment(orderNo, 100L, "pending");
+        markVerified(orderId(orderNo));
+
+        assertThat(confirmationService.revertForOrder(orderId(orderNo))).isEqualTo(
+                PaymentFundConfirmationService.REVERT_NOT_NEEDED);
+        verify(delayGateway, never()).refund(any());
+    }
+
+    /** 支付即核销的到店消费不走延迟入账,钱从来不在延迟户,也就没有回退这回事。 */
+    @Test
+    void aNonDelayedPaymentNeedsNoRevert() {
+        String orderNo = createOrder("STORE_CONSUME", 100L);
+        insertPaidPayment(orderNo, 100L, null);
+        markVerified(orderId(orderNo));
+
+        assertThat(confirmationService.revertForOrder(orderId(orderNo))).isEqualTo(
+                PaymentFundConfirmationService.REVERT_NOT_NEEDED);
+        verify(delayGateway, never()).refund(any());
+    }
+
+    /** 已经在回退中的资金不重复发请求:钱的位置未定,撤销核销要等它收敛。 */
+    @Test
+    void aRevertInFlightIsNotStartedAgain() {
+        String orderNo = createOrder("GROUP_BUY", 100L);
+        insertPaidPayment(orderNo, 100L, "reverting");
+        markVerified(orderId(orderNo));
+
+        assertThat(confirmationService.revertForOrder(orderId(orderNo))).isEqualTo(
+                PaymentFundConfirmationService.REVERT_IN_FLIGHT);
+        verify(delayGateway, never()).refund(any());
+    }
+
+    /** 没有原确认的定位键就撤销不了确认:保持原状交人工,不能假装钱已经退回延迟户。 */
+    @Test
+    void aConfirmedPaymentWithoutTheConfirmationReferenceNeedsAHuman() {
+        String orderNo = createOrder("GROUP_BUY", 100L);
+        String paymentNo = insertPaidPayment(orderNo, 100L, "confirmed");
+        markVerified(orderId(orderNo));
+
+        assertThat(confirmationService.revertForOrder(orderId(orderNo))).isEqualTo(
+                PaymentFundConfirmationService.REVERT_UNKNOWN);
+        verify(delayGateway, never()).refund(any());
+        assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
+                + " where payment_no = ?", String.class, paymentNo)).isEqualTo("confirmed");
+    }
+
+    /**
+     * 放款前必须复查订单仍是「已核销」:撤销核销会把订单翻回待核销,钱必须留在延迟户。
+     *
+     * <p>没有这道复查,补偿任务(或在途的确认请求)会在撤销之后把钱打给商户,而订单已经回到待核销。
+     */
+    @Test
+    void aConfirmationIsRefusedOnceTheOrderIsNoLongerVerified() {
+        String orderNo = createOrder("GROUP_BUY", 100L);
+        String paymentNo = insertPaidPayment(orderNo, 100L, "pending");
+        jdbc.update("update benefit_order set order_status = 1, verify_status = 0 where id = ?", orderId(orderNo));
+
+        assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
+                PaymentFundConfirmationService.NOT_VERIFIED);
+        verify(delayGateway, never()).confirm(any());
+        assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
+                + " where payment_no = ?", String.class, paymentNo)).isEqualTo("pending");
+    }
+
     // ------------------------------------------------------------------------------------------
     // helpers
     // ------------------------------------------------------------------------------------------
@@ -242,6 +411,37 @@
                 "00000000", "交易成功", "0.00", confirmedYuan, "HF-" + System.nanoTime());
     }
 
+    private HuifuDelayAccountGateway.HuifuDelayRevert revertSucceeded() {
+        return new HuifuDelayAccountGateway.HuifuDelayRevert(
+                HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.SUCCEEDED,
+                "00000000", "交易成功", "HF-R-" + System.nanoTime());
+    }
+
+    /** 资金确认与资金回退都要求订单处于「已核销」。 */
+    private void markVerified(long orderId) {
+        jdbc.update("update benefit_order set order_status = 1, verify_status = 1 where id = ?", orderId);
+    }
+
+    /**
+     * 造一笔「延迟入账 + 资金已确认给商户」的订单,返回支付流水号。
+     *
+     * <p>确认走真实服务而不是直接改字段:撤销时用来定位原确认的请求号与请求日期,正是它写下的。
+     */
+    private String confirmedDelayOrder(long amountInCent) {
+        String orderNo = createOrder("GROUP_BUY", amountInCent);
+        String paymentNo = insertPaidPayment(orderNo, amountInCent, "pending");
+        long orderId = orderId(orderNo);
+        markVerified(orderId);
+        when(delayGateway.confirm(any())).thenReturn(succeeded(BigDecimal.valueOf(amountInCent, 2).toPlainString()));
+        confirmationService.confirmForOrder(orderId);
+        return paymentNo;
+    }
+
+    private long orderIdOfPayment(String paymentNo) {
+        return jdbc.queryForObject("select order_id from payment_transaction where payment_no = ?",
+                Long.class, paymentNo);
+    }
+
     /**
      * 通道说「已无未确认金额」表示这笔延迟交易此前已经确认过(重新确认不会重复分账)。
      * 必须按成功处理:否则补偿任务会一直认为没确认,每隔几分钟无意义地打一次通道。
@@ -250,6 +450,7 @@
     void nothingLeftToConfirmCountsAsAlreadyConfirmed() {
         String orderNo = createOrder("GROUP_BUY", 100L);
         String paymentNo = insertPaidPayment(orderNo, 100L, "pending");
+        markVerified(orderId(orderNo));
         when(delayGateway.confirm(any())).thenReturn(new HuifuDelayAccountGateway.HuifuDelayConfirmation(
                 HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.SUCCEEDED,
                 "23000003", "已无未确认金额", "", "", ""));
@@ -260,6 +461,40 @@
                 + " where payment_no = ?", String.class, paymentNo)).isEqualTo("confirmed");
     }
 
+    /**
+     * 认领之后进程就没了(部署、崩溃)会留下 {@code confirming} 的孤儿:补偿任务此前只挑得到它、
+     * 从不重发,这笔钱就永远冻在延迟户。隔天必须能重新确认一次。
+     */
+    @Test
+    void aConfirmationAbandonedByACrashIsRetriedTheNextDay() {
+        String orderNo = createOrder("GROUP_BUY", 100L);
+        String paymentNo = insertPaidPayment(orderNo, 100L, "confirming");
+        long orderId = orderId(orderNo);
+        markVerified(orderId);
+        jdbc.update("update payment_transaction set funds_confirm_request_date = ? where payment_no = ?",
+                LocalDate.now().minusDays(1).format(DateTimeFormatter.BASIC_ISO_DATE), paymentNo);
+        when(delayGateway.confirm(any())).thenReturn(succeeded("1.00"));
+
+        assertThat(confirmationService.confirmForOrder(orderId)).isEqualTo(
+                PaymentFundConfirmationService.CONFIRMED);
+        assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
+                + " where payment_no = ?", String.class, paymentNo)).isEqualTo("confirmed");
+    }
+
+    /** 今天认领的确认不重发:它可能还在通道里处理。 */
+    @Test
+    void aConfirmationClaimedTodayIsNotSentAgain() {
+        String orderNo = createOrder("GROUP_BUY", 100L);
+        String paymentNo = insertPaidPayment(orderNo, 100L, "confirming");
+        markVerified(orderId(orderNo));
+        jdbc.update("update payment_transaction set funds_confirm_request_date = ? where payment_no = ?",
+                LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE), paymentNo);
+
+        assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
+                PaymentFundConfirmationService.PROCESSING);
+        verify(delayGateway, never()).confirm(any());
+    }
+
     /** 通道还在处理:状态保持 pending,供确认补偿任务下一轮再试。 */
     private HuifuDelayAccountGateway.HuifuDelayConfirmation processing() {
         return new HuifuDelayAccountGateway.HuifuDelayConfirmation(
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingRefundConfirmationTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingRefundConfirmationTests.java
index 0ae6efe..7440025 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingRefundConfirmationTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingRefundConfirmationTests.java
@@ -112,6 +112,28 @@
                 .isEqualTo(1);
     }
 
+    /**
+     * 语义未定的业务码不能判成失败:判失败会把订单解锁,用户再退一次就成了重复出款。
+     *
+     * <p>{@code 23000003} 在交易确认那条路上已经用真实回执验证过会伴随 {@code trans_stat=F},
+     * 所以在退款这条路上也只能停在「退款中」,等通道查单/对账/人工给答案。
+     */
+    @Test
+    void anUndeterminedRefundCodeDoesNotUnlockTheOrderForAnotherRefund() {
+        RefundingOrder order = refundingOrder(1);
+        when(refundGateway.queryRefund(any(), any())).thenReturn(providerResponse(Map.of(
+                "trans_stat", "F", "sub_resp_code", "23000003", "sub_resp_desc", "已无未确认金额")));
+
+        recordOf(order.orderNo());
+
+        assertThat(jdbc.queryForObject("select refund_status from order_refund where refund_no = ?", Integer.class, order.refundNo()))
+                .as("语义未定的码既不能判成功也不能判失败")
+                .isEqualTo(1);
+        assertThat(jdbc.queryForObject("select refund_status from benefit_order where order_no = ?", Integer.class, order.orderNo()))
+                .as("订单不能被解锁去再退一次")
+                .isEqualTo(1);
+    }
+
     @Test
     void anAlreadyRefundedOrderIsNotWaitingToBeVerifiedAndAsksTheProviderNothing() {
         RefundingOrder order = refundingOrder(2);
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/payment/RevokeVerificationFundTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/payment/RevokeVerificationFundTests.java
new file mode 100644
index 0000000..c45f22f
--- /dev/null
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/payment/RevokeVerificationFundTests.java
@@ -0,0 +1,160 @@
+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.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+
+import com.talentcard.server.business.merchant.MerchantRevokeVerificationRequest;
+import com.talentcard.server.business.merchant.MerchantWorkService;
+import com.talentcard.server.business.merchant.VerifyRequest;
+import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
+import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway;
+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;
+
+/**
+ * 撤销核销的资金侧:钱必须真的回到通道延迟户,订单才允许翻回「待核销」。
+ *
+ * <p>核销时钱已经从延迟户划给商户(进了基本户),撤销核销要靠通道的交易确认退款把它退回来。
+ * 回退没成功就不许翻单——否则订单显示「待核销」,钱却已经结算给商户,商户还能拿着这张单再核销一次;
+ * 等 D1 自动结算把钱划到商户银行卡之后,通道那边也退不回来了。
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+class RevokeVerificationFundTests {
+
+    private static final String HUIFU_ID = "6666000226048687";
+
+    @Autowired
+    private JdbcTemplate jdbc;
+
+    @Autowired
+    private MerchantWorkService merchantWorkService;
+
+    @MockitoBean
+    private HuifuDelayAccountGateway delayGateway;
+
+    private Long merchantId;
+    private Long storeId;
+    private Long talentId;
+    private MerchantPrincipal principal;
+
+    @BeforeEach
+    void prepareMerchant() {
+        String suffix = String.valueOf(System.nanoTime());
+        jdbc.update("insert into merchant(merchant_name, status, can_login_merchant_app, deleted) values (?, 1, 1, 0)",
+                "撤销核销商户 " + suffix);
+        merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
+        jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)",
+                merchantId, "撤销核销门店 " + suffix);
+        storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class);
+        jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)",
+                "revoke-fund-" + suffix, "撤销核销用户", "13900000010");
+        talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
+        principal = new MerchantPrincipal(9101L, merchantId, storeId, "operator");
+    }
+
+    /** 撤销核销要把钱退回延迟户:退回成功才翻单,这笔钱才算重新变成不可结算。 */
+    @Test
+    void revokesOnlyAfterTheMoneyComesBackToTheDelayAccount() {
+        long orderId = verifiedOrderWithConfirmedMoney();
+        when(delayGateway.refund(any())).thenReturn(revert(HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.SUCCEEDED,
+                "00000000", "交易成功"));
+
+        Map<String, Object> revoked = merchantWorkService.revokeVerification(principal,
+                new MerchantRevokeVerificationRequest(String.valueOf(orderId), "test reversal"), "revoke-back-" + orderId);
+
+        assertThat(revoked).containsEntry("orderStatus", "pending_verification");
+        assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = ?", Integer.class, orderId))
+                .isZero();
+        // 钱回到延迟户,商户下次核销时会走一次全新的交易确认。
+        assertThat(fundsStatus(orderId)).isEqualTo("pending");
+    }
+
+    /**
+     * 通道没把钱退回来就不许翻单:否则订单显示「待核销」,钱却已经结算给商户,商户还能拿着这张单再核销。
+     */
+    @Test
+    void refusesToRevokeWhileTheMoneyHasNotComeBack() {
+        long orderId = verifiedOrderWithConfirmedMoney();
+        when(delayGateway.refund(any())).thenReturn(revert(
+                HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.NEEDS_REVIEW, "23000003", "已无已确认金额"));
+
+        assertThatThrownBy(() -> merchantWorkService.revokeVerification(principal,
+                new MerchantRevokeVerificationRequest(String.valueOf(orderId), "test reversal"), "revoke-held-" + orderId))
+                .isInstanceOf(BusinessException.class)
+                // 语义未定时不能报成「已结算」,也不能让商户反复重试:请他联系平台。
+                .hasMessageContaining("无法核对");
+
+        assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = ?", Integer.class, orderId))
+                .isEqualTo(1);
+        // 钱的位置没定:状态停在 reverting,等人工核对,不能当成已经退回延迟户。
+        assertThat(fundsStatus(orderId)).isEqualTo("reverting");
+    }
+
+    /** 钱已经结算到银行卡时,商户要知道原因,而不是只看到一句「撤销失败」。 */
+    @Test
+    void tellsTheMerchantWhenTheSettledMoneyCannotBeCalledBack() {
+        long orderId = verifiedOrderWithConfirmedMoney();
+        when(delayGateway.refund(any())).thenReturn(revert(
+                HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.FAILED, "22000003", "账户余额不足"));
+
+        assertThatThrownBy(() -> merchantWorkService.revokeVerification(principal,
+                new MerchantRevokeVerificationRequest(String.valueOf(orderId), "test reversal"), "revoke-settled-" + orderId))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("已结算到商户银行卡");
+
+        assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = ?", Integer.class, orderId))
+                .isEqualTo(1);
+        // 明确拒绝:钱没有动,状态回到可重试的 confirmed,商户补足后还能再撤。
+        assertThat(fundsStatus(orderId)).isEqualTo("confirmed");
+    }
+
+    private HuifuDelayAccountGateway.HuifuDelayRevert revert(
+            HuifuDelayAccountGateway.HuifuDelayRevert.Outcome outcome, String code, String message) {
+        return new HuifuDelayAccountGateway.HuifuDelayRevert(outcome, code, message, "HF-R-" + System.nanoTime());
+    }
+
+    /**
+     * 造一笔「已核销 + 资金已确认给商户」的延迟入账订单,返回订单主键。
+     *
+     * <p>核销走真实服务:它会写下核销记录并调用交易确认,撤销时才有核销记录可撤、有确认可退。
+     */
+    private long verifiedOrderWithConfirmedMoney() {
+        String orderNo = "ORDER-REVOKE-" + System.nanoTime();
+        jdbc.update("""
+                insert into benefit_order(order_no, talent_id, merchant_id, store_id, order_type, order_amount,
+                                          subsidy_amount, pay_amount, order_status, verify_status, created_at, deleted)
+                values (?, ?, ?, ?, 'GROUP_BUY', 10000, 1000, 9000, 1, 0, ?, 0)
+                """, orderNo, talentId, merchantId, storeId, java.sql.Timestamp.valueOf(LocalDateTime.now()));
+        Long orderId = jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo);
+        jdbc.update("""
+                insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id,
+                                                provider_huifu_id, amount, status, idempotency_key, attempt_no,
+                                                paid_at, funds_confirmation_status)
+                values (?, 'HUIFU', ?, ?, ?, ?, ?, 9000, 'SUCCEEDED', ?, 1, now(), 'pending')
+                """, "P-VERIFY-" + System.nanoTime(), orderId, orderNo, talentId, merchantId, HUIFU_ID,
+                "verify-payment-" + orderNo);
+        when(delayGateway.confirm(any())).thenReturn(new HuifuDelayAccountGateway.HuifuDelayConfirmation(
+                HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.SUCCEEDED,
+                "00000000", "交易成功", "0.00", "90.00", "HF-C-" + System.nanoTime()));
+        merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "test verification"),
+                "verify-" + orderNo);
+        return orderId;
+    }
+
+    private String fundsStatus(long orderId) {
+        return jdbc.queryForObject("select funds_confirmation_status from payment_transaction where order_id = ?",
+                String.class, orderId);
+    }
+}
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuDelayRevertClassificationTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuDelayRevertClassificationTests.java
new file mode 100644
index 0000000..a50caec
--- /dev/null
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuDelayRevertClassificationTests.java
@@ -0,0 +1,71 @@
+package com.talentcard.server.business.payment.huifu;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayRevert;
+import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayRevert.Outcome;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 资金回退(交易确认退款)的结果分类。
+ *
+ * <p>这一组用例盯的是「同一个业务码在两条路径上语义不同」:交易确认把 {@code 23000003} 当作
+ * 「已无未确认金额」=幂等成功(有真实回执复验过),但回退路径上没有回执证明它的含义,一律不能算成功
+ * ——判成成功会让订单翻回「待核销」而钱还在商户基本户,属于资金事故。
+ */
+class HuifuDelayRevertClassificationTests {
+
+    @Test
+    void aSuccessCodeFromTheProviderIsTheOnlySuccess() {
+        assertThat(outcomeOf("00000000", "S")).isEqualTo(Outcome.SUCCEEDED);
+        // 顶层网关码也代表成功调用。
+        assertThat(outcomeOf("10000", "S")).isEqualTo(Outcome.SUCCEEDED);
+    }
+
+    /**
+     * 语义未定的码既不算成功也不算失败:{@code 23000003} 在确认路径上是幂等成功,
+     * 在回退路径上可能是「已无已确认金额」,也可能是被拒的原因(例如余额不足)。
+     */
+    @Test
+    void codesWithUndeterminedMeaningAreNeverSuccess() {
+        assertThat(outcomeOf("23000003", "F")).isEqualTo(Outcome.NEEDS_REVIEW);
+        assertThat(outcomeOf("23000004", "F")).isEqualTo(Outcome.NEEDS_REVIEW);
+        assertThat(outcomeOf("23000003", "S")).isEqualTo(Outcome.NEEDS_REVIEW);
+    }
+
+    @Test
+    void aDeclaredFailureIsAFailure() {
+        assertThat(outcomeOf("22000002", "F")).isEqualTo(Outcome.FAILED);
+        assertThat(outcomeOf("", "F")).isEqualTo(Outcome.FAILED);
+    }
+
+    @Test
+    void aRequestStillInFlightIsNotASettledResult() {
+        assertThat(outcomeOf("00000100", "P")).isEqualTo(Outcome.PROCESSING);
+        assertThat(outcomeOf("", "")).isEqualTo(Outcome.PROCESSING);
+        // 状态说成功但没有任何业务码:两个证据不一致,按未定处理,不按成功放行。
+        assertThat(outcomeOf("", "S")).isEqualTo(Outcome.PROCESSING);
+    }
+
+    @Test
+    void theProviderDescriptionIsKeptForTroubleshooting() {
+        HuifuDelayRevert revert = HuifuDelayRevert.from(response("22000003", "F", "账户余额不足"));
+        assertThat(revert.describe()).isEqualTo("账户余额不足");
+        assertThat(revert.code()).isEqualTo("22000003");
+    }
+
+    private Outcome outcomeOf(String code, String transStat) {
+        return HuifuDelayRevert.from(response(code, transStat, "返回描述")).outcome();
+    }
+
+    private Map<String, Object> response(String code, String transStat, String description) {
+        Map<String, Object> response = new LinkedHashMap<>();
+        response.put("sub_resp_code", code);
+        response.put("sub_resp_desc", description);
+        response.put("trans_stat", transStat);
+        return response;
+    }
+}

--
Gitblit v1.7.1