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
package com.talentcard.server.business.merchant;
 
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.business.payment.huifu.HuifuMerchantCapabilityGateway;
import com.talentcard.server.business.payment.huifu.HuifuMerchantCapabilityQueryCommand;
import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
 
/**
 * Single decision point for "may this merchant collect money?".
 *
 * <p>A merchant is payable only when the sub-merchant onboarding is approved, its settlement bank
 * card carries a provider token, the provider account is in a normal state, automatic settlement is
 * confirmed off and the encashment (取现) capability is confirmed available. When the provider
 * integration is disabled the network configuration is not required, but the onboarding approval
 * and the bound card token still are.
 */
@Service
public class MerchantPaymentReadinessService {
 
    private static final Logger log = LoggerFactory.getLogger(MerchantPaymentReadinessService.class);
 
    /**
     * 商户端「立即核实」的冷却窗口。与进件刷新那套 5 分钟租约同量级:手动核实是用户动作,
     * 不能变成对通道的无限制查询入口。
     */
    private static final int MERCHANT_VERIFY_COOLDOWN_MINUTES = 5;
 
    private final JdbcTemplate jdbc;
    private final HuifuMerchantCapabilityGateway capabilityGateway;
    private final HuifuPaymentProperties huifuPaymentProperties;
    private final TransactionTemplate refreshTransaction;
    private final TransactionTemplate providerTransaction;
 
    public MerchantPaymentReadinessService(
            JdbcTemplate jdbc,
            HuifuMerchantCapabilityGateway capabilityGateway,
            HuifuPaymentProperties huifuPaymentProperties,
            TransactionTemplate refreshTransaction
    ) {
        this.jdbc = jdbc;
        this.capabilityGateway = capabilityGateway;
        this.huifuPaymentProperties = huifuPaymentProperties;
        // 注入的 TransactionTemplate 是容器里的单例,支付、退款和自动提现都共用它。这里绝不能改它的
        // 传播行为,否则那些服务会跟着被改成 REQUIRES_NEW,外层事务被静默挂起。两个模板都自己新建。
        // The snapshot must survive a failed provider query in the caller's transaction, so it runs
        // in its own transaction instead of joining the caller's.
        this.refreshTransaction = new TransactionTemplate(refreshTransaction.getTransactionManager());
        this.refreshTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
        // The provider is an HTTP call: never make payment or onboarding hold a database transaction
        // (and its row locks) while waiting for it.
        this.providerTransaction = new TransactionTemplate(refreshTransaction.getTransactionManager());
        this.providerTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_NOT_SUPPORTED);
    }
 
    /** Readiness of the merchant's newest approved onboarding, refreshing the provider view when stale. */
    @Transactional
    public Readiness readiness(Long merchantId, Long storeId) {
        Application application = approvedApplication(merchantId, storeId);
        if (application == null) {
            return Readiness.blocked("商户进件尚未审核通过,暂时无法收款");
        }
        if (!approvedBankCardExists(merchantId, application.id())) {
            return Readiness.blocked("结算银行卡尚未绑定成功,暂时无法收款");
        }
        return readiness(application);
    }
 
    /**
     * 通道侧收款能力是否已核实且正常:快照核实过、子商户状态正常、取现可用。
     *
     * <p>它只回答「通道那边这家商户开通正常吗」,不含 {@link #readiness(Long, Long)} 额外要求的
     * 微信/实名与绑卡前置(那一条才回答「现在能不能收款」)。只读的监控页面用它决定「该不该去问
     * 通道」:通道侧没开通或被关停的商户问过去也会被拒(实测被关闭的子商户回「客户与产品关系已关闭」),
     * 而本地缺进度记录、通道侧却正常且在结算的存量商户不该因此被摘出监控。
     */
    @Transactional(readOnly = true)
    public boolean channelCapabilityNormal(Long applicationId) {
        List<Map<String, Object>> rows = rows("select * from merchant_onboarding_application where id = ? limit 1", applicationId);
        if (rows.isEmpty()) return false;
        Application application = application(rows.get(0));
        if (!"approved".equals(application.status()) || application.huifuId().isBlank()) return false;
        // 快照没核实过就不算正常:那意味着「通道侧到底是什么状态」还没有答案。
        if (clean(application.capabilityVerifiedAt()).isBlank()) return false;
        return isMerchantNormal(application.merchantStatus()) && isCashAvailable(application.cashSwitchStatus());
    }
 
    /**
     * Returns the collecting sub-merchant an order must be bound to, or throws with the first unmet
     * precondition. Callers persist the returned application, store and Huifu id on the order so the
     * payment request cannot drift to another sub-merchant later.
     */
    @Transactional
    public CollectingMerchant requireCollectingMerchant(Long merchantId, Long storeId) {
        Readiness readiness = readiness(merchantId, storeId);
        if (!readiness.ready()) throw new BusinessException(ErrorCode.CONFLICT, readiness.reason());
        return readiness.merchant();
    }
 
    /** Re-reads the provider configuration and stores the resulting snapshot. */
    public Map<String, Object> refresh(Long applicationId) {
        // The snapshot is only updated inside providerTransaction, which needs no row lock.
        queryProviderOutsideTransaction(requireApprovedApplication(applicationId));
        return capabilityView(requireApprovedApplication(applicationId));
    }
 
    /**
     * 商户端自查:与下单闸门同源、同文案的判定结果,外加最近核实时间与冷却状态。
     *
     * <p>只读,不查通道:快照的新鲜度由既有的定时核实与 {@link #merchantVerify} 负责。只返回派生
     * 结果——通道原始值({@code merchant_stat} 等)不下发给商户端。
     */
    public Map<String, Object> merchantView(Long merchantId, Long storeId) {
        Application application = approvedApplication(merchantId, storeId);
        Readiness readiness = readiness(merchantId, storeId);
        // 冷却剩余时间由服务端算好:客户端不必自己解析数据库时间戳字符串,也能在冷却结束时恢复按钮。
        long retryAfterSeconds = cooldownSeconds(application == null ? null : application.capabilitySyncUntil());
        Map<String, Object> view = new LinkedHashMap<>();
        view.put("ready", readiness.ready());
        view.put("reason", readiness.reason());
        view.put("verifiedAt", clean(application == null ? null : application.capabilityVerifiedAt()));
        view.put("nextVerifyAt", clean(application == null ? null : application.capabilitySyncUntil()));
        view.put("verifyAvailable", application != null && retryAfterSeconds == 0L);
        view.put("retryAfterSeconds", retryAfterSeconds);
        return view;
    }
 
    /**
     * 商户端手动核实:一次通道查询,受冷却保护,冷却期内直接回当前快照。
     *
     * <p>冷却用一个 UPDATE 兼做判定与占位:只有抢到窗口的那次调用会去查通道,并发的第二次点击
     * 直接拿当前快照。查询成功与失败都留在冷却里——失败**不**推进 {@code capability_verified_at},
     * 拿它当节流依据会让按钮被连点,而通道查不通时恰恰最需要节流。
     *
     * <p>刻意不加 {@code @Transactional}:冷却窗口必须在通道查询之前落库,否则查询进行中的并发点击
     * 会一起穿透;而通道查询也绝不能夹在一个持有该行锁的事务里。
     *
     * <p>{@code refreshed} 在每条返回路径上都有:它回答的是「这次到底查没查通道」,客户端据此说话,
     * 通道校验关掉时不能凭空宣称刚核实过。
     */
    public Map<String, Object> merchantVerify(Long merchantId, Long storeId) {
        Application application = approvedApplication(merchantId, storeId);
        if (application == null) throw new BusinessException(ErrorCode.CONFLICT, "商户进件尚未审核通过,暂时无法收款");
        if (application.huifuId().isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "商户进件缺少斗拱商户号,暂时无法收款");
        boolean refreshed = providerVerificationRequired() && huifuPaymentProperties.isEnabled() && acquireVerifyWindow(application.id());
        if (refreshed) queryProviderOutsideTransaction(application);
        Map<String, Object> view = merchantView(merchantId, storeId);
        view.put("refreshed", refreshed);
        return view;
    }
 
    /** 抢冷却窗口:抢到才去查通道。写侧的判据只有这一处,读侧见 {@link #capabilityCooldownSeconds}。 */
    private boolean acquireVerifyWindow(long applicationId) {
        return jdbc.update("""
                update merchant_onboarding_application
                set capability_sync_until = date_add(now(), interval ? minute)
                where id = ? and (capability_sync_until is null or capability_sync_until < now())
                """, MERCHANT_VERIFY_COOLDOWN_MINUTES, applicationId) > 0;
    }
 
    /**
     * 冷却剩余秒数,0 表示现在就能核实。用申请行上已经读到的那个时间戳算,不单发一条 select:
     * 开发库(flyway 关闭)的列由启动时 best-effort 补,见 {@code MerchantOnboardingSchemaInitializer};
     * 点名 `capability_sync_until` 的查询在缺列时会整条报错、把商户页打成 500,而读已读到的行只会少一个字段。
     *
     * <p>时区显式取 Asia/Shanghai:库里的 datetime 由 `serverTimezone=Asia/Shanghai` 解释成上海墙钟值,
     * 拿它跟"本机默认时区"的 now() 相减,在非上海时区的机器上会整体偏掉(本仓库其它地方同样显式钉这个时区)。
     *
     * <p>它只服务于按钮的倒计时:真正的闸门是 {@link #acquireVerifyWindow} 那条按数据库时钟判定的 UPDATE,
     * 所以即便这里算早了,也只会让按钮早恢复、点了回来一个 {@code refreshed=false},不会多查通道。
     */
    private static long cooldownSeconds(java.time.LocalDateTime syncUntil) {
        if (syncUntil == null) return 0L;
        java.time.LocalDateTime now = java.time.LocalDateTime.now(java.time.ZoneId.of("Asia/Shanghai"));
        return Math.max(0L, java.time.Duration.between(now, syncUntil).getSeconds());
    }
 
    /**
     * Capability snapshot for the admin settlement page. It queries the provider when the snapshot
     * has never been confirmed, because the explicit admin request is the moment to find out.
     */
    public Map<String, Object> capability(Long applicationId) {
        Application application = requireApprovedApplication(applicationId);
        if (clean(application.capabilityVerifiedAt()).isBlank()) {
            queryProviderOutsideTransaction(application);
            application = requireApprovedApplication(applicationId);
        }
        return capabilityView(application);
    }
 
    /**
     * Attempts to refresh approved applications whose capability snapshot is missing or older than
     * the configured interval. Each application is refreshed in its own transaction, so one failing
     * merchant never affects the others or the snapshots already stored.
     */
    public int refreshStaleCapabilities(int staleHours, int limit) {
        if (!providerVerificationRequired() || !huifuPaymentProperties.isEnabled()) return 0;
        List<Map<String, Object>> rows = rows("""
                select id from merchant_onboarding_application
                where status = 'approved' and provider_huifu_id is not null
                  and (capability_verified_at is null or capability_verified_at <= date_sub(now(), interval ? hour))
                order by capability_verified_at asc, id asc
                limit ?
                """, Math.max(1, staleHours), Math.max(1, Math.min(limit, 100)));
        int refreshed = 0;
        for (Map<String, Object> row : rows) {
            if (refreshQuietly(((Number) row.get("id")).longValue())) refreshed++;
        }
        return refreshed;
    }
 
    /**
     * Refreshes the snapshot for one application in its own transaction, so a provider failure never
     * rolls back the caller's work. Returns whether the snapshot was refreshed.
     */
    public boolean refreshQuietly(Long applicationId) {
        if (applicationId == null || !providerVerificationRequired() || !huifuPaymentProperties.isEnabled()) return false;
        try {
            return Boolean.TRUE.equals(refreshTransaction.execute(status -> {
                refresh(applicationId);
                return Boolean.TRUE;
            }));
        } catch (RuntimeException exception) {
            log.warn("Merchant capability refresh failed: applicationId={}, type={}", applicationId, exception.getClass().getSimpleName());
            return false;
        }
    }
 
    private Readiness readiness(Application application) {
        boolean unknown = application.autoSettleStatus().isBlank()
                || application.cashSwitchStatus().isBlank()
                || application.merchantStatus().isBlank()
                || clean(application.capabilityVerifiedAt()).isBlank();
        if (application.huifuId().isBlank()) return Readiness.blocked("商户进件缺少斗拱商户号,暂时无法收款");
        if (unknown && !providerVerificationRequired()) return localOnly(application);
        // The payment path never calls the provider itself: an unfilled snapshot is filled by the
        // onboarding completion, the scheduler, or an explicit admin refresh.
        if (unknown) return Readiness.blocked("商户收款能力待确认,暂时无法收款");
        // 不再要求「自动结算必须关闭」:未核销的钱由延迟户兜住(不可结算、不可取现),
        // 只有核销确认后才进基本户,因此自动结算开着也不会把钱提前结走。
        if (!isCashAvailable(application.cashSwitchStatus())) return Readiness.blocked("商户取现能力不可用,暂时无法收款");
        if (!isMerchantNormal(application.merchantStatus())) {
            return Readiness.blocked("斗拱子商户状态异常(" + application.merchantStatus() + "),暂时无法收款");
        }
        // 微信支付权限的前置:业务开通生效 + 实名与授权完成。只开通不实名,微信侧并没有把收款
        // 权限给到商户——实名认证的适用对象就是「需要开通微信支付权限的商户」。证据取自分阶段
        // 进度(由通道查询落库),不靠请求参数推断:「请求里带了参数」不等于通道侧生效。
        List<String> missingWechat = missingWechatCapabilities(application.id());
        if (!missingWechat.isEmpty()) {
            return Readiness.blocked("微信收款能力未就绪:" + String.join("、", missingWechat) + ",暂时无法收款");
        }
        return Readiness.allowed(new CollectingMerchant(application.id(), application.storeId(), application.huifuId()));
    }
 
    /** The payment integration is switched off, so provider-side network checks cannot be required. */
    private Readiness localOnly(Application application) {
        if (!application.cashSwitchStatus().isBlank() && !isCashAvailable(application.cashSwitchStatus())) {
            return Readiness.blocked("商户取现能力不可用,暂时无法收款");
        }
        return Readiness.allowed(new CollectingMerchant(application.id(), application.storeId(), application.huifuId()));
    }
 
    /**
     * Runs the provider query outside any surrounding transaction so a slow Huifu call never holds
     * the caller's row locks. Returns whether the snapshot was refreshed.
     */
    private boolean queryProviderOutsideTransaction(Application application) {
        return Boolean.TRUE.equals(providerTransaction.execute(status -> queryProvider(application)));
    }
 
    /** Returns false when the provider explicitly rejected the query; the snapshot then stays unknown. */
    private boolean queryProvider(Application application) {
        try {
            HuifuMerchantCapabilityGateway.CapabilityResult result = capabilityGateway.query(
                    new HuifuMerchantCapabilityQueryCommand(BusinessIdGenerator.next("MC"), application.huifuId()));
            if (!result.succeeded()) {
                jdbc.update("""
                        update merchant_onboarding_application
                        set capability_response_code = ?, capability_response_message = ?
                        where id = ?
                        """, clean(result.code()), clean(result.message()), application.id());
                return false;
            }
            jdbc.update("""
                    update merchant_onboarding_application
                    set merchant_stat = nullif(?,''), auto_settle_status = nullif(?,''), cash_switch_status = nullif(?,''),
                        cash_type = nullif(?,''), capability_response_code = ?, capability_response_message = ?,
                        capability_verified_at = now()
                    where id = ?
                    """, clean(result.merchantStatus()), clean(result.settleStatus()), clean(result.cashSwitchStatus()),
                    clean(result.cashType()), clean(result.code()), clean(result.message()), application.id());
            return true;
        } catch (HuifuPaymentGatewayException exception) {
            jdbc.update("""
                    update merchant_onboarding_application
                    set capability_response_code = 'QUERY_UNKNOWN', capability_response_message = ?
                    where id = ?
                    """, exception.isOutcomeUnknown() ? "商户收款能力查询结果待确认" : "商户收款能力查询失败", application.id());
            return false;
        }
    }
 
    private Application approvedApplication(Long merchantId, Long storeId) {
        String storeFilter = storeId == null ? "" : " and store_id = ?";
        Object[] args = storeId == null ? new Object[] { merchantId } : new Object[] { merchantId, storeId };
        List<Map<String, Object>> rows = rows("""
                select * from merchant_onboarding_application
                where merchant_id = ? and initial_bank_card_id is not null and status = 'approved'
                """ + storeFilter + " order by id desc limit 1", args);
        return rows.isEmpty() ? null : application(rows.get(0));
    }
 
    /**
     * 读取一条已通过的进件。这里刻意不加 {@code for update}:能力快照的 provider 查询和写入都在
     * {@code providerTransaction}({@code PROPAGATION_NOT_SUPPORTED},另一条连接)里完成,锁住这一行
     * 挡不住并发刷新(快照本来就是后写覆盖先写),却会和调用方事务已经持有的行锁撞在一起。进件审核的
     * 微信配置回调和业务开通回调都是在锁着这条进件记录的事务里走到这里的,行锁加在这里会让快照写入一直
     * 等到 {@code innodb_lock_wait_timeout} 后以 {@code CannotAcquireLockException} 失败,商户进件通过
     * 却永远无法收款。
     */
    private Application requireApprovedApplication(Long applicationId) {
        List<Map<String, Object>> rows = rows("select * from merchant_onboarding_application where id = ? limit 1", applicationId);
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商户进件不存在");
        Application application = application(rows.get(0));
        if (!"approved".equals(application.status())) throw new BusinessException(ErrorCode.CONFLICT, "商户进件尚未审核通过,无法确认收款能力");
        if (application.huifuId().isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "缺少斗拱商户号,无法确认收款能力");
        return application;
    }
 
    private Map<String, Object> capabilityView(Application application) {
        Map<String, Object> view = new LinkedHashMap<>();
        view.put("applicationId", String.valueOf(application.id()));
        view.put("huifuId", application.huifuId());
        view.put("merchantStatus", application.merchantStatus());
        view.put("autoSettleDisabled", !application.autoSettleStatus().isBlank() && isAutoSettleOff(application.autoSettleStatus()));
        view.put("autoSettleStatus", application.autoSettleStatus());
        view.put("cashAvailable", isCashAvailable(application.cashSwitchStatus()));
        view.put("cashSwitchStatus", application.cashSwitchStatus());
        view.put("verifiedAt", clean(application.capabilityVerifiedAt()));
        return view;
    }
 
    /** Prefers the onboarding's own card; falls back to any approved card for the store. */
    private boolean approvedBankCardExists(Long merchantId, Long applicationId) {
        List<Map<String, Object>> rows = rows("""
                select id from merchant_bank_card
                where status = 'approved' and provider_token_no is not null
                  and merchant_id = ?
                  and (id = (select initial_bank_card_id from merchant_onboarding_application where id = ?) or store_id = (select store_id from merchant_onboarding_application where id = ?))
                order by id desc limit 1
                """, merchantId, applicationId, applicationId);
        return !rows.isEmpty();
    }
 
    private boolean providerVerificationRequired() {
        return huifuPaymentProperties.isMerchantCapabilityRequired();
    }
 
    /**
     * 分阶段进度里代表微信收款权限的两个阶段,缺哪个报哪个。
     *
     * <p>没有进度记录时两个都算缺——查不到证据就不能认为能收款。后续配置(延迟入账、自动结算、
     * 银行卡)另有各自的阶段与快照,这里只管微信侧这两项。
     */
    private List<String> missingWechatCapabilities(long applicationId) {
        List<Map<String, Object>> saved = rows("select stages_json from merchant_onboarding_progress where application_id=?", applicationId);
        if (saved.isEmpty()) return List.of("微信支付业务开通", "微信实名与授权");
        Map<String, Object> stages = com.alibaba.fastjson.JSON.parseObject(clean(saved.get(0).get("stagesJson")), Map.class);
        List<String> missing = new java.util.ArrayList<>();
        if (!"confirmed".equals(clean(stages.get("wechat")))) missing.add("微信支付业务开通");
        if (!"confirmed".equals(clean(stages.get("realname")))) missing.add("微信实名与授权");
        return missing;
    }
 
    private boolean isAutoSettleOff(String value) { return "0".equals(value) || "N".equalsIgnoreCase(value); }
 
    private boolean isCashAvailable(String value) { return "1".equals(value) || "Y".equalsIgnoreCase(value); }
 
    private boolean isMerchantNormal(String value) { return value.isBlank() || "NORMAL".equalsIgnoreCase(value); }
 
    private Application application(Map<String, Object> row) {
        return new Application(
                ((Number) row.get("id")).longValue(),
                ((Number) row.get("merchantId")).longValue(),
                number(row.get("storeId")),
                clean(row.get("status")),
                clean(row.get("providerHuifuId")),
                clean(row.get("merchantStat")),
                clean(row.get("autoSettleStatus")),
                clean(row.get("cashSwitchStatus")),
                // DatabaseRows 已把 datetime 归一成 LocalDateTime;缺列时这里是 null,不是异常。
                (java.time.LocalDateTime) row.get("capabilityVerifiedAt"),
                (java.time.LocalDateTime) row.get("capabilitySyncUntil")
        );
    }
 
    /** 进件的 store_id 允许为空(历史数据):这里不能直接拆箱,否则 NullPointerException 会冒到调用方。 */
    private static long number(Object value) {
        return value instanceof Number number ? number.longValue() : 0L;
    }
 
    private List<Map<String, Object>> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); }
 
    private String clean(Object value) { return value == null ? "" : String.valueOf(value).trim(); }
 
    private record Application(
            long id,
            long merchantId,
            long storeId,
            String status,
            String huifuId,
            String merchantStatus,
            String autoSettleStatus,
            String cashSwitchStatus,
            java.time.LocalDateTime capabilityVerifiedAt,
            java.time.LocalDateTime capabilitySyncUntil
    ) {}
 
    /**
     * Whether a merchant can take money right now, with the first unmet precondition as the reason.
     * A ready result carries the collecting sub-merchant that orders must be bound to.
     */
    public record Readiness(boolean ready, String reason, CollectingMerchant merchant) {
        public static Readiness allowed(CollectingMerchant merchant) { return new Readiness(true, "", merchant); }
 
        public static Readiness blocked(String reason) { return new Readiness(false, reason, null); }
    }
 
    /**
     * The sub-merchant that collects an order's money: the approved onboarding, its store and the
     * Huifu merchant number. The null-safe accessors exist for the order inserts, where a free order
     * legitimately carries no collecting merchant.
     */
    public record CollectingMerchant(long applicationId, long storeId, String huifuId) {
        public static Long applicationIdOf(CollectingMerchant merchant) { return merchant == null ? null : merchant.applicationId(); }
 
        public static Long storeIdOf(CollectingMerchant merchant) { return merchant == null ? null : merchant.storeId(); }
 
        public static String huifuIdOf(CollectingMerchant merchant) { return merchant == null ? null : merchant.huifuId(); }
    }
}