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
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
package com.talentcard.server.business.payment;
 
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;
import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway;
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentCommand;
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;
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>业务规则:先付钱后核销的订单,钱必须留在通道「待确认」,核销确认后才分配给商户;支付即核销的
 * 到店消费订单不延迟,也不该被这个流程影响。
 */
@SpringBootTest
@ActiveProfiles("test")
class PaymentFundConfirmationTests {
 
    private static final String HUIFU_ID = "6666000226048687";
 
    @Autowired
    private JdbcTemplate jdbc;
 
    @Autowired
    private AppOrderPortalService orderPortalService;
 
    @Autowired
    private HuifuPaymentService paymentService;
 
    @Autowired
    private PaymentFundConfirmationService confirmationService;
 
    @Autowired
    private PaymentSettlementService settlementService;
 
    @Autowired
    private PaymentFundConfirmationRetry confirmationRetry;
 
    @MockitoBean
    private HuifuMiniProgramPaymentGateway paymentGateway;
 
    @MockitoBean
    private HuifuDelayAccountGateway delayGateway;
 
    private Long merchantId;
    private Long storeId;
    private Long talentId;
    private Long productId;
    private String suffix;
 
    @BeforeEach
    void prepare() {
        suffix = String.valueOf(System.nanoTime());
        jdbc.update("insert into merchant(merchant_name, status, deleted) values (?, 1, 0)", "延迟入账商户 " + suffix);
        merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
        jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)",
                "delay-" + suffix, "延迟入账用户", "13900000009");
        talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
        jdbc.update("insert into talent_card(talent_id, card_no, status) values (?, ?, 1)", talentId, "DC" + suffix);
        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 merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name,
                                                            contact_name, contact_mobile, idempotency_key, status, provider_huifu_id,
                                                            auto_settle_status, cash_switch_status, merchant_stat, capability_verified_at)
                values (?, ?, ?, 'individual', 'Delay owner', 'Delay owner', '13900000009', ?, 'approved', ?, '0', '1', 'NORMAL', current_timestamp)
                """, merchantId, storeId, "MO-DELAY-" + suffix, "delay-onboarding-" + suffix, HUIFU_ID);
        Long onboardingId = jdbc.queryForObject("select max(id) from merchant_onboarding_application", Long.class);
        jdbc.update("""
                insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, city_code, card_tail, card_masked,
                                               card_hash, status, idempotency_key, provider_token_no, reviewed_at)
                values (?, ?, '中国建设银行', '攀枝花东区支行', '510400', '8888', '**** **** **** 8888', ?, 'approved', ?, ?, current_timestamp)
                """, merchantId, storeId, "hash-delay-" + suffix, "delay-card-" + suffix, "T" + suffix);
        Long cardId = jdbc.queryForObject("select max(id) from merchant_bank_card", Long.class);
        jdbc.update("update merchant_onboarding_application set initial_bank_card_id = ? where id = ?", cardId, onboardingId);
        // 收款就绪要求微信支付业务开通与实名/授权都有通道证据,这里的商户建模成已办完这两步。
        jdbc.update("insert into merchant_onboarding_progress(application_id, stages_json) values (?, '{\"wechat\":\"confirmed\",\"realname\":\"confirmed\"}')", onboardingId);
        jdbc.update("""
                insert into merchant_product(merchant_id, product_no, product_type, product_name, original_price,
                                             sale_price, stock, audit_status, status, deleted)
                values (?, ?, 'GROUP_BUY', '延迟入账商品', 100.00, 100.00, -1, 2, 1, 0)
                """, merchantId, "DELAY-PRODUCT-" + suffix);
        productId = jdbc.queryForObject("select max(id) from merchant_product", Long.class);
    }
 
    @Test
    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"));
 
        assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
                PaymentFundConfirmationService.CONFIRMED);
 
        Map<String, Object> payment = jdbc.queryForMap(
                "select funds_confirmation_status, funds_confirmed_amount, funds_confirmed_at, funds_confirm_no"
                        + " from payment_transaction where payment_no = ?", paymentNo);
        assertThat(payment).containsEntry("funds_confirmation_status", "confirmed");
        // 通道回执的已确认金额是「元」字符串,本地存的是分。
        assertThat(((Number) payment.get("funds_confirmed_amount")).longValue()).isEqualTo(100L);
        assertThat(payment.get("funds_confirmed_at")).isNotNull();
        assertThat(String.valueOf(payment.get("funds_confirm_no"))).isNotBlank();
    }
 
    @Test
    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));
        assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
                PaymentFundConfirmationService.CONFIRMED);
 
        verify(delayGateway).confirm(any());
    }
 
    @Test
    void aConfirmationAlreadyInFlightIsNotStartedAgain() {
        String orderNo = createOrder("GROUP_BUY", 100L);
        insertPaidPayment(orderNo, 100L, "confirming");
        markVerified(orderId(orderNo));
 
        assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
                PaymentFundConfirmationService.PROCESSING);
        verify(delayGateway, never()).confirm(any());
    }
 
    /**
     * 确认失败时状态有意保留 pending,而订单已经核销、不会再有人走核销入口。
     * 没有补偿的话这笔钱会永远冻在通道延迟户里。
     */
    @Test
    void aPendingConfirmationOfAVerifiedOrderIsRetriedWithoutAnotherVerification() {
        String orderNo = createOrder("GROUP_BUY", 100L);
        String paymentNo = insertPaidPayment(orderNo, 100L, "pending");
        long orderId = orderId(orderNo);
        jdbc.update("update benefit_order set order_status = 1, verify_status = 1 where id = ?", orderId);
        when(delayGateway.confirm(any())).thenReturn(succeeded("1.00"));
        // 补偿任务扫的是整库、按订单号从小到大取一批,而测试库共享且不清理。先把自己之外的相关行移出
        // 扫描条件(那些用例已经跑完),本用例这条才一定落在窗口内——否则它会随测试库累积而必然失败
        // (docs/agents/testing.md 记的「环境相关失败」)。
        jdbc.update("""
                update benefit_order set refund_status = 1
                where id <> ? and deleted = 0 and order_status = 1 and verify_status = 1 and refund_status = 0
                  and exists (select 1 from payment_transaction p
                              where p.order_id = benefit_order.id and p.status = 'SUCCEEDED'
                                and p.funds_confirmation_status in ('pending', 'confirming', 'reverting'))
                """, orderId);
 
        assertThat(confirmationRetry.retry(50)).isEqualTo(1);
 
        assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
                + " where payment_no = ?", String.class, paymentNo)).isEqualTo("confirmed");
    }
 
    /** 已经确认过的订单不该被补偿任务反复打扰通道。 */
    @Test
    void theRetrySkipsOrdersWhoseFundsAreAlreadyConfirmed() {
        String orderNo = createOrder("GROUP_BUY", 100L);
        String paymentNo = insertPaidPayment(orderNo, 100L, "confirmed");
        markVerified(orderId(orderNo));
        // 补偿任务扫的是整库,会顺带处理其它用例留下的「已核销 + 待确认」订单,所以给它们一个可用的
        // 回执,并把断言收窄到本单:要证明的是这笔已经确认过的钱不会被再确认一次。
        when(delayGateway.confirm(any())).thenReturn(processing());
 
        confirmationRetry.retry(50);
 
        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
    void anOrderThatWasNotDelayedNeedsNoConfirmation() {
        String orderNo = createOrder("STORE_CONSUME", 100L);
        insertPaidPayment(orderNo, 100L, null);
 
        assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
                PaymentFundConfirmationService.NOT_DELAYED);
        verify(delayGateway, never()).confirm(any());
    }
 
    @Test
    void anUnsettledPaymentIsLeftForLater() {
        String orderNo = createOrder("GROUP_BUY", 100L);
        String paymentNo = insertPayment(orderNo, 100L, "PREPAY_CREATED", "pending");
        // 确认补偿任务也在跑,会挑走库里其它用例留下的已核销订单;这里只断言本用例这笔不受影响。
        when(delayGateway.confirm(any())).thenReturn(processing());
 
        assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
                PaymentFundConfirmationService.NOT_SETTLED);
        assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction where payment_no = ?",
                String.class, paymentNo)).isEqualTo("pending");
    }
 
    @Test
    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(
                PaymentFundConfirmationService.FAILED);
 
        assertThat(jdbc.queryForMap("select funds_confirmation_status, funds_confirm_message"
                + " from payment_transaction where payment_no = ?", paymentNo))
                .containsEntry("funds_confirmation_status", "pending")
                .containsEntry("funds_confirm_message", "商户信息不存在");
    }
 
    @Test
    void aConfirmedOrderCanBeRefundedWhileAnUnconfirmedOneIsNotWithdrawn() {
        // 这个用例只断言资金状态这一维:确认前订单不可提现,确认后才可提现(提现门槛本身在
        // AutoWithdrawalAggregationTests 覆盖)。退款路径的改造尚未完成,这里不做断言。
        String orderNo = createOrder("GROUP_BUY", 100L);
        insertPaidPayment(orderNo, 100L, "pending");
        long orderId = orderId(orderNo);
        jdbc.update("update benefit_order set order_status = 1, verify_status = 1 where id = ?", orderId);
 
        when(delayGateway.confirm(any())).thenReturn(succeeded("100"));
        confirmationService.confirmForOrder(orderId);
 
        assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
                + " where order_id = ?", String.class, orderId)).isEqualTo("confirmed");
        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
    // ------------------------------------------------------------------------------------------
 
    private HuifuDelayAccountGateway.HuifuDelayConfirmation succeeded(String confirmedYuan) {
        return new HuifuDelayAccountGateway.HuifuDelayConfirmation(
                HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.SUCCEEDED,
                "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);
    }
 
    /**
     * 通道说「已无未确认金额」表示这笔延迟交易此前已经确认过(重新确认不会重复分账)。
     * 必须按成功处理:否则补偿任务会一直认为没确认,每隔几分钟无意义地打一次通道。
     */
    @Test
    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", "已无未确认金额", "", "", ""));
 
        assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
                PaymentFundConfirmationService.CONFIRMED);
        assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
                + " 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());
    }
 
    /**
     * 当天认领出来的「确认中」靠**确认查询**收敛,而不是整整等一天。
     *
     * <p>认领规则不允许当天重发确认(避免重复分账),于是同一天的确认中在旧实现里没人再看——而确认查询
     * 接口({@code delaytrans.confirmquery})从写下那天起就是死代码。这里把它接上。
     */
    @Test
    void aConfirmationClaimedTodayConvergesThroughTheConfirmationQuery() {
        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 = ?, funds_confirm_no = ?
                where payment_no = ?
                """, LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE), "DC-CLAIMED-" + paymentNo, paymentNo);
        when(delayGateway.queryConfirm(any(), any())).thenReturn(succeeded("1.00"));
 
        assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
                PaymentFundConfirmationService.CONFIRMED);
 
        assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction where payment_no = ?",
                String.class, paymentNo)).isEqualTo("confirmed");
        verify(delayGateway, never()).confirm(any());
    }
 
    /**
     * 撤销核销的回退停在 {@code reverting} 时也要有人再看:回退没有查询接口,补偿任务原先只扫
     * 「待确认/确认中」,这类记录会永久卡住撤销核销。幂等的重发就是它的查询对等物。
     */
    @Test
    void anInFlightRevertConvergesThroughTheRetryTask() {
        String orderNo = createOrder("GROUP_BUY", 100L);
        String paymentNo = insertPaidPayment(orderNo, 100L, "reverting");
        long orderId = orderId(orderNo);
        markVerified(orderId);
        jdbc.update("""
                update payment_transaction
                set funds_confirm_no = ?, funds_confirm_request_date = ?, funds_revert_no = ?,
                    funds_revert_request_date = ?, funds_confirmed_amount = 100
                where payment_no = ?
                """, "DC-ORIGINAL-" + paymentNo, LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE),
                "DR-INFLIGHT-" + paymentNo, LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE), paymentNo);
        // 补偿任务按订单号从小到大取一批,而测试库共享且不清理:先把别人的相关行移出扫描条件。
        jdbc.update("""
                update benefit_order set refund_status = 1
                where id <> ? and deleted = 0 and order_status = 1 and verify_status = 1 and refund_status = 0
                  and exists (select 1 from payment_transaction p
                              where p.order_id = benefit_order.id and p.status = 'SUCCEEDED'
                                and p.funds_confirmation_status in ('pending', 'confirming', 'reverting'))
                """, orderId);
        when(delayGateway.refund(any())).thenReturn(new HuifuDelayAccountGateway.HuifuDelayRevert(
                HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.SUCCEEDED, "00000000", "交易成功", "HF-REVERT-RETRY"));
 
        assertThat(confirmationRetry.retry(50)).isEqualTo(1);
 
        assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction where payment_no = ?",
                String.class, paymentNo))
                .as("回退成功了,钱重新回到延迟户(pending):撤销核销不再被永久卡住")
                .isEqualTo("pending");
    }
 
    /** 通道还在处理:状态保持 pending,供确认补偿任务下一轮再试。 */
    private HuifuDelayAccountGateway.HuifuDelayConfirmation processing() {
        return new HuifuDelayAccountGateway.HuifuDelayConfirmation(
                HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.PROCESSING,
                "00000100", "交易处理中", "1.00", "0.00", "");
    }
 
    private HuifuDelayAccountGateway.HuifuDelayConfirmation failed(String code, String message) {        return new HuifuDelayAccountGateway.HuifuDelayConfirmation(
                HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.FAILED,
                code, message, "", "", "");
    }
 
    private String createOrder(String orderType, long amountInCent) {
        Map<String, Object> created = orderPortalService.create(talentId,
                new AppOrderCreateRequest(merchantId, productId, null), "delay-fund-" + System.nanoTime());
        String orderNo = String.valueOf(created.get("orderNo"));
        jdbc.update("update benefit_order set order_type = ?, verify_status = 0 where order_no = ?", orderType, orderNo);
        return orderNo;
    }
 
    private String insertPaidPayment(String orderNo, long amountInCent, String fundsStatus) {
        return insertPayment(orderNo, amountInCent, "SUCCEEDED", fundsStatus);
    }
 
    private String insertPayment(String orderNo, long amountInCent, String status, String fundsStatus) {
        String paymentNo = "P-DELAY-" + System.nanoTime();
        Long orderId = orderId(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', ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
                """, paymentNo, orderId, orderNo, talentId, merchantId, HUIFU_ID, amountInCent, status,
                "delay-payment-" + paymentNo, LocalDateTime.now(), fundsStatus);
        return paymentNo;
    }
 
    private long orderId(String orderNo) {
        return jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo);
    }
}