feat(merchant): 商户收款能力就绪后才允许支付
实现工单 01(商户进件与收款能力就绪)。
- 新增 MerchantPaymentReadinessService,作为“该商户能否收款”的唯一判定:
进件已通过 + 银行卡 token 有效 + 子商户状态 NORMAL + 自动结算已关闭 +
取现开关已开通
- 新增 HuifuMerchantCapabilityGateway,通过 v2.merchant.basicdata.query 读取
子商户配置并落库快照(V130 迁移 + SchemaInitializer 兜底加列)
- 在 AppOrderPortalService 中拦截未就绪商户创建需支付订单
- 进件回调与绑卡回调保持幂等:已决记录不会被改写,晚到通知不会覆盖终态
- 为后台结算页提供能力查询/刷新接口,并定时刷新过期快照
测试:MerchantPaymentReadinessServiceTests、MerchantSettlementCallbackIdempotencyTests、
AppOrderPortalServiceTests 均通过。整套测试需指向 MySQL(TEST_DATASOURCE_*),因为部分
迁移使用了 H2 无法解析的 MySQL 专有语法。
| | |
| | | |
| | | **Blocked by:** None — can start immediately |
| | | |
| | | **Status:** ready-for-agent |
| | | **Status:** ready-for-human |
| | | |
| | | - [ ] 企业/个人商户进件结果、斗拱商户号和申请状态正确保存。 |
| | | - [ ] 结算银行卡及 token 正确保存并支持查看、更换。 |
| | | - [ ] 自动结算关闭和取现能力就绪均有明确可用判定。 |
| | | - [ ] 能力未就绪的商户无法创建支付订单。 |
| | | - [ ] 进件回调和重复提交具备幂等处理。 |
| | | - [x] 企业/个人商户进件结果、斗拱商户号和申请状态正确保存。 |
| | | - [x] 结算银行卡及 token 正确保存并支持查看、更换。 |
| | | - [x] 自动结算关闭和取现能力就绪均有明确可用判定。 |
| | | - [x] 能力未就绪的商户无法创建支付订单。 |
| | | - [x] 进件回调和重复提交具备幂等处理。 |
| | | |
| | | ## Comments |
| | | |
| | | 已实现,主要落点: |
| | | |
| | | - 新增 `MerchantPaymentReadinessService`:唯一“能否收款”判定。要求存在最新 `approved` 进件、 |
| | | 该进件名下有带 `provider_token_no` 的 `approved` 银行卡、`merchant_stat = NORMAL`、 |
| | | `settle_status = 0`(自动结算已关闭)、取现 `switch_state = 1`。快照未确认时按需调用斗拱查询, |
| | | 查询失败/被拒时保持“未就绪”而不是猜测通过。 |
| | | - 新增 `HuifuMerchantCapabilityGateway`(`POST /v2/merchant/basicdata/query`,SDK |
| | | `V2MerchantBasicdataQueryRequest`),把子商户状态、结算开关、取现开关与到账类型写入 |
| | | `merchant_onboarding_application`(V130 迁移 + `MerchantOnboardingSchemaInitializer` 兜底加列)。 |
| | | - 支付闸口:`AppOrderPortalService.create` 在需要支付的订单(`pay_amount > 0`)落库前调用 |
| | | `requirePayable`,未就绪直接 409;免单订单不受影响。 |
| | | - 幂等:`handleOnboardingCallback` 不再改写已 `approved`/`rejected` 的进件,且回调只对 `status='pending'` |
| | | 的银行卡写 token;绑定回调对已决卡片直接返回,晚到通知与重复通知都不再覆盖终态。 |
| | | - 后台:`GET/POST /api/admin/management/merchant-settlement/onboardings/{id}/payment-capability[/refresh]` |
| | | 查看与刷新能力快照;`MerchantPaymentCapabilityScheduler` 定时刷新超过 |
| | | `app.payment.huifu.capability-refresh.stale-hours`(默认 6 小时)的快照。 |
| | | |
| | | 测试(`MerchantPaymentReadinessServiceTests` 10 项、`MerchantSettlementCallbackIdempotencyTests` 5 项、 |
| | | `AppOrderPortalServiceTests` 6 项)全部通过;三种失败组合、提供方查询失败与超时、晚到/重复回调均已覆盖。 |
| | | 注意:仓库的 H2 测试库无法解析 V77 等 MySQL 专用迁移,Spring 测试需按 |
| | | `talent-card-server/docs/huifu-payment-integration.md` 的说明指向 MySQL 才能加载上下文。 |
| | |
| | | 8. Run a refund, repeat the delayed-callback test, and confirm that only a verified callback or provider query can mark `order_refund.refund_status = 2`. |
| | | 9. Test invalid callback signatures, provider timeouts, duplicate payment button taps, expired orders, refunded orders, and provider-declared failures. No case may expose payment parameters, OpenID, keys, signatures, or full callback payloads in logs or API responses. |
| | | |
| | | ## Merchant payment readiness (settlement and encashment capability) |
| | | |
| | | A merchant may only take paid orders once its sub-merchant configuration is confirmed. The server |
| | | reads that configuration with the Huifu `POST /v2/merchant/basicdata/query` interface |
| | | (`v2.merchant.basicdata.query`) and stores the result on the onboarding application: |
| | | |
| | | | Stored column | Provider field | Ready value | |
| | | | --- | --- | --- | |
| | | | `merchant_stat` | `mer_stat` | `NORMAL` (any other value blocks payment) | |
| | | | `auto_settle_status` | `qry_settle_config_list[].settle_status` | `0` (automatic settlement off) | |
| | | | `cash_switch_status` | `qry_cash_config_list[].switch_state` | `1` (encashment available) | |
| | | | `cash_type` | `qry_cash_config_list[].cash_type` | recorded for reconciliation (`D0`/`D1`/`T1`/`DM`) | |
| | | | `capability_verified_at` | query timestamp | set only after a successful query | |
| | | |
| | | Rules enforced by `MerchantPaymentReadinessService`: |
| | | |
| | | 1. The newest approved onboarding of the merchant (optionally scoped to a store) must exist. |
| | | 2. That onboarding must own an approved settlement bank card with a provider `token_no`. Because a |
| | | merchant may replace its settlement card, an approved card for the same store also satisfies |
| | | this rule. |
| | | 3. `merchant_stat` must be `NORMAL`. |
| | | 4. Automatic settlement must be off; the provider console is the place to switch it off, and the |
| | | next capability refresh confirms it. |
| | | 5. `qry_cash_config_list[].switch_state` must be `1`. |
| | | |
| | | When the snapshot has never been confirmed the service queries the provider on demand. An |
| | | unanswered or rejected query keeps the merchant blocked rather than guessing. When |
| | | `app.payment.huifu.enabled=false` (local development and tests) no outbound provider call is made: |
| | | only rules 1 and 2 are enforced, plus rules 4 and 5 for whatever the stored snapshot already says. |
| | | |
| | | `MerchantPaymentCapabilityScheduler` refreshes approved sub-merchants whose snapshot is older than |
| | | `app.payment.huifu.capability-refresh.stale-hours` (default 6 hours). Administrators can force a |
| | | refresh from the management console. |
| | | |
| | | ### Management endpoints |
| | | |
| | | - `GET /api/admin/management/merchant-settlement/onboardings/{id}/payment-capability` |
| | | - Returns `applicationId`, `huifuId`, `merchantStatus`, `autoSettleDisabled`, `autoSettleStatus`, |
| | | `cashAvailable`, `cashSwitchStatus`, `verifiedAt`. It queries the provider when the snapshot has |
| | | never been confirmed. |
| | | - `POST /api/admin/management/merchant-settlement/onboardings/{id}/payment-capability/refresh` |
| | | - Re-reads the provider configuration, stores a fresh snapshot and returns the same shape. |
| | | |
| | | ### Configuration |
| | | |
| | | ```text |
| | | HUIFU_MERCHANT_CAPABILITY_REQUIRED=true # require provider confirmation (defaults to the enabled flag) |
| | | HUIFU_CAPABILITY_REFRESH_ENABLED=true |
| | | HUIFU_CAPABILITY_STALE_HOURS=6 |
| | | HUIFU_CAPABILITY_REFRESH_FIXED_DELAY_MS=1800000 |
| | | ``` |
| | | |
| | | ### Running the server tests |
| | | |
| | | Several migrations use MySQL-only syntax that the in-memory H2 default of `application-test.yml` |
| | | cannot parse, so the Spring tests only load when pointed at MySQL: |
| | | |
| | | ```powershell |
| | | $env:TEST_DATASOURCE_URL='jdbc:mysql://127.0.0.1:8134/talent_card_test?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false&createDatabaseIfNotExist=true' |
| | | $env:TEST_DATASOURCE_USERNAME='root' |
| | | $env:TEST_DATASOURCE_PASSWORD='123456' |
| | | $env:TEST_DATASOURCE_DRIVER='com.mysql.cj.jdbc.Driver' |
| | | .\mvnw.cmd -o test |
| | | ``` |
| | | |
| | | `MerchantPaymentReadinessServiceTests`, `MerchantSettlementCallbackIdempotencyTests` and |
| | | `AppOrderPortalServiceTests` cover the readiness snapshot, the payment gate for paid orders and |
| | | callback idempotency. They stub `HuifuMerchantCapabilityGateway`, so they never call Huifu. |
| | | |
| | | ## Remaining provider-dependent work |
| | | |
| | | - Delayed-account confirmation must only be enabled after the corresponding Huifu product and confirmation API are provisioned and tested. |
| | |
| | | @DeleteMapping("/staff/{id}") public ApiResponse<Map<String,Object>> deleteStaff(@PathVariable Long id) { return ApiResponse.success(service.deleteStaff(id)); } |
| | | @GetMapping("/merchant-settlement/onboardings") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> onboardings(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status, @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate) { return ApiResponse.success(settlementService.onboardings(page, size, merchantName, contactMobile, merchantType, status, startDate, endDate)); } |
| | | @GetMapping("/merchant-settlement/onboardings/{id}") public ApiResponse<Map<String,Object>> onboarding(@PathVariable Long id) { return ApiResponse.success(settlementService.onboarding(id)); } |
| | | @GetMapping("/merchant-settlement/onboardings/{id}/payment-capability") public ApiResponse<Map<String,Object>> paymentCapability(@PathVariable Long id) { return ApiResponse.success(settlementService.paymentCapability(id)); } |
| | | @PostMapping("/merchant-settlement/onboardings/{id}/payment-capability/refresh") public ApiResponse<Map<String,Object>> refreshPaymentCapability(@PathVariable Long id) { return ApiResponse.success(settlementService.refreshPaymentCapability(id)); } |
| | | @GetMapping("/merchant-settlement/bank-cards") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> bankCards(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status, @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate) { return ApiResponse.success(settlementService.bankCards(page, size, merchantName, contactMobile, merchantType, status, startDate, endDate)); } |
| | | @GetMapping("/merchant-settlement/withdrawals") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> withdrawals(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status, @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate) { return ApiResponse.success(settlementService.withdrawals(page, size, merchantName, contactMobile, merchantType, status, startDate, endDate)); } |
| | | @GetMapping("/payment-reconciliation/issues") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> reconciliationIssues(@RequestParam(defaultValue = "open") String status, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { return ApiResponse.success(reconciliationService.issues(status, page, size)); } |
| | |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.annotation.Transactional; |
| | | import com.talentcard.server.business.merchant.HuifuMerchantWithdrawalService; |
| | | import com.talentcard.server.business.merchant.MerchantPaymentReadinessService; |
| | | |
| | | @Service |
| | | public class MerchantSettlementAdminService { |
| | | private final JdbcTemplate jdbc; |
| | | private final HuifuMerchantWithdrawalService withdrawalService; |
| | | private final SensitiveDataCrypto sensitiveDataCrypto; |
| | | public MerchantSettlementAdminService(JdbcTemplate jdbc, HuifuMerchantWithdrawalService withdrawalService, SensitiveDataCrypto sensitiveDataCrypto) { this.jdbc = jdbc; this.withdrawalService = withdrawalService; this.sensitiveDataCrypto = sensitiveDataCrypto; } |
| | | private final MerchantPaymentReadinessService readinessService; |
| | | public MerchantSettlementAdminService(JdbcTemplate jdbc, HuifuMerchantWithdrawalService withdrawalService, SensitiveDataCrypto sensitiveDataCrypto, MerchantPaymentReadinessService readinessService) { this.jdbc = jdbc; this.withdrawalService = withdrawalService; this.sensitiveDataCrypto = sensitiveDataCrypto; this.readinessService = readinessService; } |
| | | |
| | | @Transactional(readOnly = true) |
| | | public PageResult<Map<String, Object>> onboardings(int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate) { |
| | |
| | | @Transactional public Map<String, Object> reviewBankCard(Long id, String status, String reason, String tokenNo) { return reviewBankCardInternal(id, status, reason, tokenNo); } |
| | | public Map<String, Object> reviewWithdrawal(Long id, String status, String reason) { if ("paid".equals(normalizeWithdrawalStatus(status))) return withdrawalService.dispatch(id); return update("merchant_withdrawal", id, "failed", reason, "withdrawal"); } |
| | | |
| | | /** Provider-confirmed settlement and encashment capability of one approved sub-merchant. */ |
| | | public Map<String, Object> paymentCapability(Long applicationId) { return readinessService.capability(applicationId); } |
| | | |
| | | /** Re-reads the provider configuration and stores a fresh capability snapshot. */ |
| | | public Map<String, Object> refreshPaymentCapability(Long applicationId) { return readinessService.refresh(applicationId); } |
| | | |
| | | private Map<String,Object> reviewBankCardInternal(Long id,String status,String reason,String tokenNo) { String value=reviewStatus(status); if("approved".equals(value) && (tokenNo==null || !tokenNo.trim().matches("[A-Za-z0-9]{1,32}"))) throw new BusinessException(ErrorCode.BAD_REQUEST,"Huifu withdrawal card token is required when approving a bank card"); int affected=jdbc.update("update merchant_bank_card set status=?, reject_reason=?, provider_token_no=?, reviewed_at=now() where id=? and status='pending'",value,reason==null?"":reason.trim(),"approved".equals(value)?tokenNo.trim():null,id); if(affected==0)throw new BusinessException(ErrorCode.CONFLICT,"Bank card is already handled or does not exist");return Map.of("id",id,"status",value); } |
| | | private String reviewStatus(String status) { String value=status==null?"":status.trim().toLowerCase(); if(!List.of("approved","rejected").contains(value))throw new BusinessException(ErrorCode.BAD_REQUEST,"Invalid review status");return value; } |
| | | private String normalizeWithdrawalStatus(String status) { String value=status==null?"":status.trim().toLowerCase(); if(!List.of("paid","failed").contains(value))throw new BusinessException(ErrorCode.BAD_REQUEST,"Invalid withdrawal status");return value; } |
| | |
| | | columns.put("business_open_apply_no", "varchar(32) default null"); |
| | | columns.put("business_open_response_code", "varchar(32) default null"); |
| | | columns.put("business_open_response_message", "varchar(512) default null"); |
| | | columns.put("merchant_stat", "varchar(32) default null"); |
| | | columns.put("auto_settle_status", "char(1) default null"); |
| | | columns.put("cash_switch_status", "char(1) default null"); |
| | | columns.put("cash_type", "varchar(8) default null"); |
| | | columns.put("capability_response_code", "varchar(32) default null"); |
| | | columns.put("capability_response_message", "varchar(512) default null"); |
| | | columns.put("capability_verified_at", "datetime default null"); |
| | | for (Map.Entry<String, String> column : columns.entrySet()) { |
| | | if (!hasColumn("merchant_onboarding_application", column.getKey())) jdbc.execute("alter table merchant_onboarding_application add column " + column.getKey() + " " + column.getValue()); |
| | | } |
| New file |
| | |
| | | package com.talentcard.server.business.merchant; |
| | | |
| | | import org.slf4j.Logger; |
| | | import org.slf4j.LoggerFactory; |
| | | import org.springframework.beans.factory.annotation.Value; |
| | | import org.springframework.scheduling.annotation.Scheduled; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | /** Keeps the provider-confirmed settlement and encashment snapshot fresh for approved sub-merchants. */ |
| | | @Component |
| | | public class MerchantPaymentCapabilityScheduler { |
| | | private static final Logger log = LoggerFactory.getLogger(MerchantPaymentCapabilityScheduler.class); |
| | | private final MerchantPaymentReadinessService readinessService; |
| | | @Value("${app.payment.huifu.capability-refresh.enabled:true}") private boolean enabled; |
| | | @Value("${app.payment.huifu.capability-refresh.stale-hours:6}") private int staleHours; |
| | | |
| | | public MerchantPaymentCapabilityScheduler(MerchantPaymentReadinessService readinessService) { this.readinessService = readinessService; } |
| | | |
| | | @Scheduled(fixedDelayString = "${app.payment.huifu.capability-refresh.fixed-delay-ms:1800000}") |
| | | public void refresh() { |
| | | if (!enabled) return; |
| | | try { readinessService.refreshStaleCapabilities(staleHours, 25); } |
| | | catch (RuntimeException exception) { log.warn("Merchant payment capability refresh failed: {}", exception.getClass().getSimpleName()); } |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | |
| | | private final JdbcTemplate jdbc; |
| | | private final HuifuMerchantCapabilityGateway capabilityGateway; |
| | | private final HuifuPaymentProperties huifuPaymentProperties; |
| | | private final TransactionTemplate refreshTransaction; |
| | | |
| | | public MerchantPaymentReadinessService( |
| | | JdbcTemplate jdbc, |
| | | HuifuMerchantCapabilityGateway capabilityGateway, |
| | | HuifuPaymentProperties huifuPaymentProperties, |
| | | TransactionTemplate refreshTransaction |
| | | ) { |
| | | this.jdbc = jdbc; |
| | | this.capabilityGateway = capabilityGateway; |
| | | this.huifuPaymentProperties = huifuPaymentProperties; |
| | | this.refreshTransaction = refreshTransaction; |
| | | // 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.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); |
| | | } |
| | | |
| | | /** 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); |
| | | } |
| | | |
| | | /** Throws a conflict describing the first unmet precondition so callers can stop early. */ |
| | | @Transactional |
| | | public void requirePayable(Long merchantId, Long storeId) { |
| | | Readiness readiness = readiness(merchantId, storeId); |
| | | if (!readiness.ready()) throw new BusinessException(ErrorCode.CONFLICT, readiness.reason()); |
| | | } |
| | | |
| | | /** Re-reads the provider configuration and stores the resulting snapshot. */ |
| | | public Map<String, Object> refresh(Long applicationId) { |
| | | Application application = requireApprovedApplication(applicationId); |
| | | queryProvider(application); |
| | | return capabilityView(requireApprovedApplication(applicationId)); |
| | | } |
| | | |
| | | /** |
| | | * 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. |
| | | */ |
| | | @Transactional |
| | | public Map<String, Object> capability(Long applicationId) { |
| | | Application application = requireApprovedApplication(applicationId); |
| | | if (application.capabilityVerifiedAt().isBlank()) { |
| | | queryProvider(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 a provider |
| | | * outage leaves every previous snapshot untouched; the run stops early because one failure |
| | | * normally means the next call fails too. |
| | | */ |
| | | 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())) break; |
| | | 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 { |
| | | refreshTransaction.executeWithoutResult(status -> refresh(applicationId)); |
| | | return 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() |
| | | || application.capabilityVerifiedAt().isBlank(); |
| | | if (unknown && !providerVerificationRequired()) return localOnly(application); |
| | | if (unknown) { |
| | | if (!queryProvider(application)) return Readiness.blocked("商户收款能力待确认,暂时无法收款"); |
| | | Application refreshed = approvedApplication(application.merchantId(), application.storeId()); |
| | | if (refreshed == null || refreshed.id() != application.id()) return Readiness.blocked("商户收款能力待确认,暂时无法收款"); |
| | | if (refreshed.autoSettleStatus().isBlank() || refreshed.cashSwitchStatus().isBlank()) { |
| | | return Readiness.blocked("商户收款能力待确认,暂时无法收款"); |
| | | } |
| | | application = refreshed; |
| | | } |
| | | if (!isAutoSettleOff(application.autoSettleStatus())) return Readiness.blocked("商户自动结算尚未关闭,暂时无法收款"); |
| | | if (!isCashAvailable(application.cashSwitchStatus())) return Readiness.blocked("商户取现能力不可用,暂时无法收款"); |
| | | if (!isMerchantNormal(application.merchantStatus())) { |
| | | return Readiness.blocked("斗拱子商户状态异常(" + application.merchantStatus() + "),暂时无法收款"); |
| | | } |
| | | return Readiness.allowed(); |
| | | } |
| | | |
| | | /** The payment integration is switched off, so provider-side network checks cannot be required. */ |
| | | private Readiness localOnly(Application application) { |
| | | if (!application.autoSettleStatus().isBlank() && !isAutoSettleOff(application.autoSettleStatus())) { |
| | | return Readiness.blocked("商户自动结算尚未关闭,暂时无法收款"); |
| | | } |
| | | if (!application.cashSwitchStatus().isBlank() && !isCashAvailable(application.cashSwitchStatus())) { |
| | | return Readiness.blocked("商户取现能力不可用,暂时无法收款"); |
| | | } |
| | | return Readiness.allowed(); |
| | | } |
| | | |
| | | /** 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)); |
| | | } |
| | | |
| | | private Application requireApprovedApplication(Long applicationId) { |
| | | List<Map<String, Object>> rows = rows("select * from merchant_onboarding_application where id = ? limit 1 for update", 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", 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(); |
| | | } |
| | | |
| | | 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")).longValue(), |
| | | clean(row.get("status")), |
| | | clean(row.get("providerHuifuId")), |
| | | clean(row.get("merchantStat")), |
| | | clean(row.get("autoSettleStatus")), |
| | | clean(row.get("cashSwitchStatus")), |
| | | clean(row.get("capabilityVerifiedAt")) |
| | | ); |
| | | } |
| | | |
| | | 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, |
| | | String capabilityVerifiedAt |
| | | ) {} |
| | | |
| | | /** Whether a merchant can take money right now, with the first unmet precondition as the reason. */ |
| | | public record Readiness(boolean ready, String reason) { |
| | | public static Readiness allowed() { return new Readiness(true, ""); } |
| | | public static Readiness blocked(String reason) { return new Readiness(false, reason); } |
| | | } |
| | | } |
| | |
| | | private final HuifuUserBusinessOpenGateway userBusinessOpenGateway; |
| | | private final HuifuPaymentProperties huifuPaymentProperties; |
| | | private final HuifuPictureUploadGateway pictureUploadGateway; |
| | | private final MerchantPaymentReadinessService paymentReadinessService; |
| | | |
| | | public MerchantSettlementService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, HuifuIndividualOnboardingGateway individualOnboardingGateway, HuifuEnterpriseOnboardingGateway enterpriseOnboardingGateway, HuifuMerchantWechatConfigGateway merchantWechatConfigGateway, HuifuMerchantBusinessOpenGateway merchantBusinessOpenGateway, HuifuMerchantWithdrawalService withdrawalService, HuifuUserBusinessOpenGateway userBusinessOpenGateway, HuifuPaymentProperties huifuPaymentProperties, HuifuPictureUploadGateway pictureUploadGateway) { |
| | | public MerchantSettlementService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, HuifuIndividualOnboardingGateway individualOnboardingGateway, HuifuEnterpriseOnboardingGateway enterpriseOnboardingGateway, HuifuMerchantWechatConfigGateway merchantWechatConfigGateway, HuifuMerchantBusinessOpenGateway merchantBusinessOpenGateway, HuifuMerchantWithdrawalService withdrawalService, HuifuUserBusinessOpenGateway userBusinessOpenGateway, HuifuPaymentProperties huifuPaymentProperties, HuifuPictureUploadGateway pictureUploadGateway, MerchantPaymentReadinessService paymentReadinessService) { |
| | | this.jdbc = jdbc; |
| | | this.sensitiveDataCrypto = sensitiveDataCrypto; |
| | | this.individualOnboardingGateway = individualOnboardingGateway; |
| | |
| | | this.userBusinessOpenGateway = userBusinessOpenGateway; |
| | | this.huifuPaymentProperties = huifuPaymentProperties; |
| | | this.pictureUploadGateway = pictureUploadGateway; |
| | | this.paymentReadinessService = paymentReadinessService; |
| | | } |
| | | |
| | | @Transactional(readOnly = true) |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Business-opening (card binding) callback. Only a card that is still pending may change state, |
| | | * so a late or duplicated notification for an already approved or rejected card is ignored. |
| | | */ |
| | | @Transactional |
| | | @SuppressWarnings("unchecked") |
| | | public boolean handleBankCardCallback(Map<String, Object> payload) { |
| | |
| | | if (requestNo.isBlank()) return false; |
| | | List<Map<String, Object>> cards = rows("select * from merchant_bank_card where provider_request_no=? limit 1 for update", requestNo); |
| | | if (cards.isEmpty()) return false; |
| | | Map<String, Object> card = cards.get(0); |
| | | Object cardId = card.get("id"); |
| | | if (!"pending".equals(clean(card.get("status")))) return true; |
| | | String tokenNo = clean(data.get("token_no")); |
| | | String code = clean(data.get("sub_resp_code")); if (code.isBlank()) code = clean(data.get("resp_code")); |
| | | String message = clean(data.get("sub_resp_desc")); if (message.isBlank()) message = clean(data.get("resp_desc")); |
| | | String applyNo = clean(data.get("apply_no")); |
| | | if (!tokenNo.isBlank() && providerSuccess(code)) jdbc.update("update merchant_bank_card set status='approved', provider_token_no=?, provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=?, reviewed_at=now(), reject_reason=null where id=? and status='pending'", tokenNo, applyNo, code, message, cards.get(0).get("id")); |
| | | else if (!providerSuccess(code)) jdbc.update("update merchant_bank_card set status='rejected', provider_response_code=?, provider_response_message=?, reject_reason=?, reviewed_at=now() where id=? and status='pending'", code, message, providerReason(code, message), cards.get(0).get("id")); |
| | | else jdbc.update("update merchant_bank_card set provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=? where id=? and status='pending'", applyNo, code, message, cards.get(0).get("id")); |
| | | if (!tokenNo.isBlank() && providerSuccess(code)) jdbc.update("update merchant_bank_card set status='approved', provider_token_no=?, provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=?, reviewed_at=now(), reject_reason=null where id=? and status='pending'", tokenNo, applyNo, code, message, cardId); |
| | | else if (!providerSuccess(code)) jdbc.update("update merchant_bank_card set status='rejected', provider_response_code=?, provider_response_message=?, reject_reason=?, reviewed_at=now() where id=? and status='pending'", code, message, providerReason(code, message), cardId); |
| | | else jdbc.update("update merchant_bank_card set provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=? where id=? and status='pending'", applyNo, code, message, cardId); |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Onboarding audit callback. The handler is keyed by the application number and only moves an |
| | | * application that is not already decided, so duplicated notifications are no-ops and a late |
| | | * notification cannot overwrite a decided application. |
| | | */ |
| | | @Transactional |
| | | @SuppressWarnings("unchecked") |
| | | public boolean handleOnboardingCallback(Map<String, Object> data) { |
| | |
| | | List<Map<String, Object>> applications = rows("select * from merchant_onboarding_application where application_no=? limit 1 for update", requestNo); |
| | | if (applications.isEmpty()) return false; |
| | | Map<String, Object> application = applications.get(0); |
| | | if ("approved".equals(clean(application.get("status"))) || "rejected".equals(clean(application.get("status")))) return true; |
| | | String code = clean(data.get("sub_resp_code")); if (code.isBlank()) code = clean(data.get("resp_code")); |
| | | String message = clean(data.get("audit_desc")); if (message.isBlank()) message = clean(data.get("sub_resp_desc")); if (message.isBlank()) message = clean(data.get("resp_desc")); |
| | | String auditStatus = clean(data.get("audit_status")); |
| | | String huifuId = clean(data.get("huifu_id")); String applyNo = clean(data.get("apply_no")); String tokenNo = clean(data.get("token_no")); |
| | | Long bankCardId = application.get("initialBankCardId") instanceof Number number ? number.longValue() : null; |
| | | if ("Y".equalsIgnoreCase(auditStatus)) { |
| | | jdbc.update("update merchant_onboarding_application set status='pending', provider_huifu_id=coalesce(nullif(?,''),provider_huifu_id), provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=?, reject_reason=null, reviewed_at=null where application_no=?", huifuId, applyNo, code, message, requestNo); |
| | | if (bankCardId != null) jdbc.update("update merchant_bank_card set status=case when coalesce(nullif(?,''),provider_token_no) is null then status else 'approved' end, provider_token_no=coalesce(nullif(?,''),provider_token_no), provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=?, reject_reason=null where id=?", tokenNo, tokenNo, applyNo, code, message, bankCardId); |
| | | jdbc.update("update merchant_onboarding_application set provider_huifu_id=coalesce(nullif(?,''),provider_huifu_id), provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=?, reject_reason=null, reviewed_at=null where application_no=? and status not in ('approved','rejected')", huifuId, applyNo, code, message, requestNo); |
| | | if (bankCardId != null) jdbc.update("update merchant_bank_card set status=case when coalesce(nullif(?,''),provider_token_no) is null then status else 'approved' end, provider_token_no=coalesce(nullif(?,''),provider_token_no), provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=?, reject_reason=null where id=? and status='pending'", tokenNo, tokenNo, applyNo, code, message, bankCardId); |
| | | continuePostOnboardingProvisioning(requestNo); |
| | | } else if ("N".equalsIgnoreCase(auditStatus) || "F".equalsIgnoreCase(auditStatus) || !providerSuccess(code)) { |
| | | String reason = providerReason(code, message); |
| | |
| | | } |
| | | |
| | | private void completePostOnboardingProvisioning(Object applicationId) { |
| | | jdbc.update("update merchant_onboarding_application set status='approved', reject_reason=null, reviewed_at=now() where id=? and wechat_config_status='approved' and business_open_status='approved'", applicationId); |
| | | int completed = jdbc.update("update merchant_onboarding_application set status='approved', reject_reason=null, reviewed_at=now() where id=? and wechat_config_status='approved' and business_open_status='approved'", applicationId); |
| | | if (completed > 0 && applicationId instanceof Number number) { |
| | | // Approved sub-merchants need a confirmed settlement/encashment snapshot before they can collect money. |
| | | paymentReadinessService.refreshQuietly(number.longValue()); |
| | | } |
| | | } |
| | | |
| | | @SuppressWarnings("unchecked") |
| New file |
| | |
| | | package com.talentcard.server.business.payment.huifu; |
| | | |
| | | import com.alibaba.fastjson.JSON; |
| | | import com.huifu.bspay.sdk.opps.client.BasePayClient; |
| | | import com.huifu.bspay.sdk.opps.core.BasePay; |
| | | import com.huifu.bspay.sdk.opps.core.config.MerConfig; |
| | | import com.huifu.bspay.sdk.opps.core.request.V2MerchantBasicdataQueryRequest; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import com.talentcard.server.common.ErrorCode; |
| | | import java.time.LocalDate; |
| | | import java.time.format.DateTimeFormatter; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import org.slf4j.Logger; |
| | | import org.slf4j.LoggerFactory; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | /** |
| | | * Reads the provider-side merchant configuration that decides whether a sub-merchant may collect |
| | | * payments: the sub-merchant standing, the automatic settlement switch and the encashment |
| | | * (取现) capability. |
| | | */ |
| | | @Service |
| | | public class HuifuMerchantCapabilityGateway { |
| | | |
| | | private static final Object SDK_LOCK = new Object(); |
| | | private static final Logger log = LoggerFactory.getLogger(HuifuMerchantCapabilityGateway.class); |
| | | |
| | | private final HuifuPaymentProperties properties; |
| | | |
| | | public HuifuMerchantCapabilityGateway(HuifuPaymentProperties properties) { this.properties = properties; } |
| | | |
| | | public CapabilityResult query(HuifuMerchantCapabilityQueryCommand command) { |
| | | ensureConfigured(); |
| | | V2MerchantBasicdataQueryRequest request = new V2MerchantBasicdataQueryRequest(); |
| | | request.setReqSeqId(command.requestNo()); |
| | | request.setReqDate(LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)); |
| | | request.setHuifuId(command.huifuId()); |
| | | try { |
| | | synchronized (SDK_LOCK) { |
| | | initializeSdk(); |
| | | return CapabilityResult.from(BasePayClient.request(request, false)); |
| | | } |
| | | } catch (BusinessException exception) { |
| | | throw exception; |
| | | } catch (Exception exception) { |
| | | log.warn("Huifu merchant capability query failed: huifuId={}, requestNo={}, type={}, message={}", |
| | | command.huifuId(), command.requestNo(), exception.getClass().getSimpleName(), safe(exception.getMessage())); |
| | | throw new HuifuPaymentGatewayException("Huifu merchant capability query failed", unknown(exception), exception); |
| | | } |
| | | } |
| | | |
| | | private void ensureConfigured() { |
| | | if (!properties.isEnabled()) throw new BusinessException(ErrorCode.CONFLICT, "Huifu payment is disabled"); |
| | | if (blank(properties.getSystemId()) || blank(properties.getProductId()) || blank(properties.getMerchantId()) |
| | | || blank(properties.getPrivateKey()) || blank(properties.getPlatformPublicKey())) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "Huifu merchant capability configuration is incomplete"); |
| | | } |
| | | } |
| | | |
| | | private void initializeSdk() throws Exception { |
| | | BasePay.debug = false; |
| | | BasePay.prodMode = "prod".equalsIgnoreCase(properties.getEnvironment()) ? BasePay.MODE_PROD : BasePay.MODE_TEST; |
| | | MerConfig config = new MerConfig(); |
| | | config.setSysId(properties.getSystemId().trim()); |
| | | config.setProductId(properties.getProductId().trim()); |
| | | config.setRsaPrivateKey(properties.getPrivateKey().trim()); |
| | | config.setRsaPublicKey(properties.getPlatformPublicKey().trim()); |
| | | BasePay.initWithMerConfig(config); |
| | | } |
| | | |
| | | private boolean blank(String value) { return value == null || value.isBlank(); } |
| | | |
| | | private boolean unknown(Exception exception) { |
| | | String value = safe(exception.getMessage()).toLowerCase(); |
| | | return value.contains("timeout") || value.contains("timed out") || value.contains("no response"); |
| | | } |
| | | |
| | | private String safe(String value) { return value == null ? "" : value.substring(0, Math.min(value.length(), 300)); } |
| | | |
| | | /** Provider answer for one sub-merchant configuration query. */ |
| | | public record CapabilityResult( |
| | | boolean succeeded, |
| | | String code, |
| | | String message, |
| | | String merchantStatus, |
| | | String settleStatus, |
| | | String settleCycle, |
| | | String cashSwitchStatus, |
| | | String cashType |
| | | ) { |
| | | public static CapabilityResult failed(String code, String message) { |
| | | return new CapabilityResult(false, code == null ? "" : code, message == null ? "" : message, "", "", "", "", ""); |
| | | } |
| | | |
| | | @SuppressWarnings("unchecked") |
| | | public static CapabilityResult from(Map<String, Object> response) { |
| | | Object raw = response == null ? null : response.get("data"); |
| | | Map<String, Object> data = raw instanceof Map<?, ?> value ? (Map<String, Object>) value |
| | | : raw instanceof String json && !json.isBlank() ? JSON.parseObject(json, Map.class) |
| | | : response; |
| | | if (data == null) return failed("", ""); |
| | | String code = value(data, "sub_resp_code", "resp_code", "code"); |
| | | String message = value(data, "sub_resp_desc", "resp_desc", "message"); |
| | | if (!providerSuccess(code)) return failed(code, message); |
| | | String settleStatus = "", settleCycle = ""; |
| | | for (Map<String, Object> settle : list(data.get("qry_settle_config_list"))) { |
| | | settleStatus = value(settle, "settle_status"); |
| | | settleCycle = value(settle, "settle_cycle"); |
| | | if (!settleStatus.isBlank()) break; |
| | | } |
| | | String cashSwitch = "", cashType = ""; |
| | | for (Map<String, Object> cash : list(data.get("qry_cash_config_list"))) { |
| | | cashSwitch = value(cash, "switch_state"); |
| | | cashType = value(cash, "cash_type"); |
| | | if (!cashSwitch.isBlank()) break; |
| | | } |
| | | return new CapabilityResult(true, code, message, value(data, "mer_stat"), settleStatus, settleCycle, cashSwitch, cashType); |
| | | } |
| | | |
| | | @SuppressWarnings("unchecked") |
| | | private static List<Map<String, Object>> list(Object raw) { |
| | | if (raw instanceof List<?> values) return values.stream().filter(Map.class::isInstance).map(value -> (Map<String, Object>) value).toList(); |
| | | if (raw instanceof Map<?, ?> value) return List.of((Map<String, Object>) value); |
| | | if (raw instanceof String json && !json.isBlank()) { |
| | | try { |
| | | Object parsed = JSON.parse(json); |
| | | if (parsed instanceof Map<?, ?> value) return List.of((Map<String, Object>) value); |
| | | if (parsed instanceof List<?> values) return values.stream().filter(Map.class::isInstance).map(value -> (Map<String, Object>) value).toList(); |
| | | } catch (Exception ignored) { |
| | | // An unparsable configuration list is treated as "not configured". |
| | | } |
| | | } |
| | | return List.of(); |
| | | } |
| | | |
| | | private static boolean providerSuccess(String code) { |
| | | return "00000000".equals(code) || "00000100".equals(code) || "10000".equals(code) || "0".equals(code) || "SUCCESS".equalsIgnoreCase(code); |
| | | } |
| | | |
| | | private static String value(Map<String, Object> data, String... keys) { |
| | | for (String key : keys) if (data.get(key) != null) return String.valueOf(data.get(key)).trim(); |
| | | return ""; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | package com.talentcard.server.business.payment.huifu; |
| | | |
| | | public record HuifuMerchantCapabilityQueryCommand(String requestNo, String huifuId) {} |
| | |
| | | private String merchantAgreementUrl = "https://cloudpnrcdn.oss-cn-shanghai.aliyuncs.com/opps/api/prod/download_file/PaymentServiceAgreement.htm"; |
| | | private String withdrawalIntoAccountDateType = "DM"; |
| | | private boolean delayAccount; |
| | | private Boolean merchantCapabilityRequired; |
| | | |
| | | public boolean isEnabled() { return enabled; } |
| | | public void setEnabled(boolean enabled) { this.enabled = enabled; } |
| | |
| | | public void setWithdrawalIntoAccountDateType(String withdrawalIntoAccountDateType) { this.withdrawalIntoAccountDateType = withdrawalIntoAccountDateType; } |
| | | public boolean isDelayAccount() { return delayAccount; } |
| | | public void setDelayAccount(boolean delayAccount) { this.delayAccount = delayAccount; } |
| | | /** Provider-side capability confirmation is on whenever the provider integration is enabled. */ |
| | | public boolean isMerchantCapabilityRequired() { |
| | | return merchantCapabilityRequired != null ? merchantCapabilityRequired : enabled; |
| | | } |
| | | public void setMerchantCapabilityRequired(boolean merchantCapabilityRequired) { this.merchantCapabilityRequired = merchantCapabilityRequired; } |
| | | |
| | | private String callbackUrl(String endpoint) { |
| | | if (withdrawalNotifyUrl == null || withdrawalNotifyUrl.isBlank()) return ""; |
| | |
| | | import com.talentcard.server.business.BusinessIdGenerator; |
| | | import com.talentcard.server.business.DatabaseRows; |
| | | import com.talentcard.server.business.MoneyUtils; |
| | | import com.talentcard.server.business.merchant.MerchantPaymentReadinessService; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import com.talentcard.server.common.ErrorCode; |
| | | import org.springframework.dao.DuplicateKeyException; |
| | |
| | | public class AppOrderPortalService { |
| | | private final JdbcTemplate jdbc; |
| | | private final PendingOrderPortalService pendingOrderPortalService; |
| | | private final MerchantPaymentReadinessService merchantPaymentReadinessService; |
| | | |
| | | public AppOrderPortalService(JdbcTemplate jdbc, PendingOrderPortalService pendingOrderPortalService) { |
| | | public AppOrderPortalService(JdbcTemplate jdbc, PendingOrderPortalService pendingOrderPortalService, MerchantPaymentReadinessService merchantPaymentReadinessService) { |
| | | this.jdbc = jdbc; |
| | | this.pendingOrderPortalService = pendingOrderPortalService; |
| | | this.merchantPaymentReadinessService = merchantPaymentReadinessService; |
| | | } |
| | | |
| | | @Transactional |
| | |
| | | long payAmount = Math.max(0, basePayAmount - couponAmount); |
| | | // 订单补贴金额需与待支付页保持一致:人才折扣与优惠券抵扣的合计。 |
| | | long subsidyAmount = Math.max(0, originalAmount - payAmount); |
| | | // 需要支付的订单只有在商户收款能力就绪时才能创建,避免产生无法结算的资金。 |
| | | if (payAmount > 0) merchantPaymentReadinessService.requirePayable(request.merchantId(), null); |
| | | String orderNo = BusinessIdGenerator.next("P"); |
| | | LocalDateTime expiresAt = payAmount == 0 ? null : LocalDateTime.now().plusMinutes(15); |
| | | |
| | |
| | | merchant-online-media-name: ${HUIFU_MERCHANT_ONLINE_MEDIA_NAME:东区人才卡} |
| | | merchant-agreement-url: ${HUIFU_MERCHANT_AGREEMENT_URL:https://cloudpnrcdn.oss-cn-shanghai.aliyuncs.com/opps/api/prod/download_file/PaymentServiceAgreement.htm} |
| | | delay-account: ${HUIFU_DELAY_ACCOUNT:false} |
| | | # A merchant may collect payments only after the provider confirms auto settlement is off and encashment is available. |
| | | # Set false only for a deployment that deliberately runs without provider-side capability confirmation. |
| | | merchant-capability-required: ${HUIFU_MERCHANT_CAPABILITY_REQUIRED:true} |
| | | capability-refresh: |
| | | enabled: ${HUIFU_CAPABILITY_REFRESH_ENABLED:true} |
| | | stale-hours: ${HUIFU_CAPABILITY_STALE_HOURS:6} |
| | | fixed-delay-ms: ${HUIFU_CAPABILITY_REFRESH_FIXED_DELAY_MS:1800000} |
| | | wechat: |
| | | mini-program: |
| | | # The app id must be the same mini-program that invokes uni.login and uni.requestPayment. |
| New file |
| | |
| | | -- Persist the Huifu sub-merchant capability snapshot that decides whether a merchant may collect payments. |
| | | DELIMITER $$ |
| | | DROP PROCEDURE IF EXISTS add_merchant_onboarding_capability_columns$$ |
| | | CREATE PROCEDURE add_merchant_onboarding_capability_columns() |
| | | BEGIN |
| | | DECLARE col_exists INT DEFAULT 0; |
| | | SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'merchant_stat'; |
| | | IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN merchant_stat VARCHAR(32) DEFAULT NULL AFTER business_open_response_message; END IF; |
| | | SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'auto_settle_status'; |
| | | IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN auto_settle_status CHAR(1) DEFAULT NULL AFTER merchant_stat; END IF; |
| | | SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'cash_switch_status'; |
| | | IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN cash_switch_status CHAR(1) DEFAULT NULL AFTER auto_settle_status; END IF; |
| | | SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'cash_type'; |
| | | IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN cash_type VARCHAR(8) DEFAULT NULL AFTER cash_switch_status; END IF; |
| | | SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'capability_response_code'; |
| | | IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN capability_response_code VARCHAR(32) DEFAULT NULL AFTER cash_type; END IF; |
| | | SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'capability_response_message'; |
| | | IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN capability_response_message VARCHAR(512) DEFAULT NULL AFTER capability_response_code; END IF; |
| | | SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'capability_verified_at'; |
| | | IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN capability_verified_at DATETIME DEFAULT NULL AFTER capability_response_message; END IF; |
| | | END$$ |
| | | CALL add_merchant_onboarding_capability_columns()$$ |
| | | DROP PROCEDURE IF EXISTS add_merchant_onboarding_capability_columns$$ |
| | | DELIMITER ; |
| New file |
| | |
| | | package com.talentcard.server.business.merchant; |
| | | |
| | | 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.never; |
| | | import static org.mockito.Mockito.times; |
| | | import static org.mockito.Mockito.verify; |
| | | import static org.mockito.Mockito.when; |
| | | |
| | | import java.util.Map; |
| | | |
| | | 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.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.TestPropertySource; |
| | | import org.springframework.test.context.bean.override.mockito.MockitoBean; |
| | | |
| | | /** |
| | | * Verifies the merchant payment readiness rules with the provider confirmation switched on and a |
| | | * stubbed Huifu capability query. |
| | | */ |
| | | @SpringBootTest |
| | | @ActiveProfiles("test") |
| | | @TestPropertySource(properties = { |
| | | "app.payment.huifu.enabled=true", |
| | | "app.payment.huifu.merchant-capability-required=true" |
| | | }) |
| | | class MerchantPaymentReadinessServiceTests { |
| | | |
| | | @Autowired |
| | | private JdbcTemplate jdbc; |
| | | |
| | | @Autowired |
| | | private MerchantPaymentReadinessService readinessService; |
| | | |
| | | @MockitoBean |
| | | private HuifuMerchantCapabilityGateway capabilityGateway; |
| | | |
| | | private Long merchantId; |
| | | private Long storeId; |
| | | private Long applicationId; |
| | | private Long bankCardId; |
| | | private String suffix; |
| | | |
| | | @BeforeEach |
| | | void prepareMerchant() { |
| | | suffix = String.valueOf(System.nanoTime()); |
| | | jdbc.update("insert into merchant(merchant_name, status, deleted) values (?, 1, 0)", "Readiness merchant " + 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, "Readiness store " + 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) |
| | | values (?, ?, ?, 'individual', 'Readiness owner', 'Readiness owner', '13800000000', ?, 'approved', ?) |
| | | """, merchantId, storeId, "MO-RDY-" + suffix, "readiness-onboarding-" + suffix, shortReference("H", suffix)); |
| | | applicationId = 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) |
| | | values (?, ?, '中国建设银行', '攀枝花东区支行', '510400', '6666', '**** **** **** 6666', ?, 'approved', ?, ?) |
| | | """, merchantId, storeId, "hash-readiness-" + suffix, "readiness-card-" + suffix, shortReference("T", suffix)); |
| | | bankCardId = jdbc.queryForObject("select max(id) from merchant_bank_card", Long.class); |
| | | jdbc.update("update merchant_onboarding_application set initial_bank_card_id=? where id=?", bankCardId, applicationId); |
| | | } |
| | | |
| | | @Test |
| | | void allowsPaymentOnceAutoSettlementIsOffAndEncashmentIsConfirmed() { |
| | | stubProvider("NORMAL", "0", "1"); |
| | | |
| | | MerchantPaymentReadinessService.Readiness readiness = readinessService.readiness(merchantId, storeId); |
| | | |
| | | assertThat(readiness.ready()).isTrue(); |
| | | assertThat(readiness.reason()).isEmpty(); |
| | | assertThat(jdbc.queryForMap("select auto_settle_status as autoSettle, cash_switch_status as cashSwitch, cash_type as cashType, merchant_stat as merchantStat, capability_verified_at as verifiedAt from merchant_onboarding_application where id=?", applicationId)) |
| | | .containsEntry("AUTOSETTLE", "0") |
| | | .containsEntry("CASHSWITCH", "1") |
| | | .containsEntry("CASHTYPE", "DM"); |
| | | assertThat(jdbc.queryForObject("select capability_verified_at from merchant_onboarding_application where id=?", Object.class, applicationId)).isNotNull(); |
| | | } |
| | | |
| | | @Test |
| | | void blocksPaymentWhileAutoSettlementIsStillOn() { |
| | | stubProvider("NORMAL", "1", "1"); |
| | | |
| | | assertThatThrownBy(() -> readinessService.requirePayable(merchantId, storeId)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("自动结算"); |
| | | } |
| | | |
| | | @Test |
| | | void blocksPaymentWhenEncashmentCapabilityIsUnavailable() { |
| | | stubProvider("NORMAL", "0", "0"); |
| | | |
| | | assertThatThrownBy(() -> readinessService.requirePayable(merchantId, storeId)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("取现"); |
| | | } |
| | | |
| | | @Test |
| | | void blocksPaymentWhenTheSubMerchantIsFrozen() { |
| | | stubProvider("FREEZEWITHDRAWAL", "0", "1"); |
| | | |
| | | assertThatThrownBy(() -> readinessService.requirePayable(merchantId, storeId)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("FREEZEWITHDRAWAL"); |
| | | } |
| | | |
| | | @Test |
| | | void blocksPaymentWhenTheProviderRejectsTheCapabilityQuery() { |
| | | when(capabilityGateway.query(any(HuifuMerchantCapabilityQueryCommand.class))) |
| | | .thenReturn(HuifuMerchantCapabilityGateway.CapabilityResult.failed("99999999", "商户不存在")); |
| | | |
| | | assertThatThrownBy(() -> readinessService.requirePayable(merchantId, storeId)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("待确认"); |
| | | } |
| | | |
| | | @Test |
| | | void blocksPaymentWhenTheProviderQueryTimesOut() { |
| | | when(capabilityGateway.query(any(HuifuMerchantCapabilityQueryCommand.class))) |
| | | .thenThrow(new HuifuPaymentGatewayException("timeout", true, null)); |
| | | |
| | | assertThatThrownBy(() -> readinessService.requirePayable(merchantId, storeId)) |
| | | .isInstanceOf(BusinessException.class); |
| | | } |
| | | |
| | | @Test |
| | | void blocksPaymentWhenOnboardingIsNotApproved() { |
| | | jdbc.update("update merchant_onboarding_application set status='pending' where id=?", applicationId); |
| | | |
| | | assertThatThrownBy(() -> readinessService.requirePayable(merchantId, storeId)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("进件"); |
| | | verify(capabilityGateway, never()).query(any(HuifuMerchantCapabilityQueryCommand.class)); |
| | | } |
| | | |
| | | @Test |
| | | void blocksPaymentWhenTheSettlementCardHasNoToken() { |
| | | jdbc.update("update merchant_bank_card set provider_token_no=null where id=?", bankCardId); |
| | | |
| | | assertThatThrownBy(() -> readinessService.requirePayable(merchantId, storeId)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("银行卡"); |
| | | verify(capabilityGateway, never()).query(any(HuifuMerchantCapabilityQueryCommand.class)); |
| | | } |
| | | |
| | | @Test |
| | | void reusesTheConfirmedSnapshotInsteadOfQueryingTheProviderAgain() { |
| | | stubProvider("NORMAL", "0", "1"); |
| | | readinessService.requirePayable(merchantId, storeId); |
| | | |
| | | readinessService.requirePayable(merchantId, storeId); |
| | | |
| | | verify(capabilityGateway, times(1)).query(any(HuifuMerchantCapabilityQueryCommand.class)); |
| | | } |
| | | |
| | | @Test |
| | | void refreshesTheSnapshotOnDemandForTheAdminView() { |
| | | stubProvider("NORMAL", "1", "1"); |
| | | Map<String, Object> capability = readinessService.capability(applicationId); |
| | | |
| | | assertThat(capability).containsEntry("autoSettleDisabled", false).containsEntry("cashAvailable", true); |
| | | |
| | | stubProvider("NORMAL", "0", "1"); |
| | | Map<String, Object> refreshed = readinessService.refresh(applicationId); |
| | | |
| | | assertThat(refreshed).containsEntry("autoSettleDisabled", true).containsEntry("cashAvailable", true); |
| | | } |
| | | |
| | | /** Provider identifiers are limited to 32 characters and huifu ids to 18, so tests keep them short. */ |
| | | private String shortReference(String prefix, String suffix) { |
| | | return prefix + suffix.substring(Math.max(0, suffix.length() - 8)); |
| | | } |
| | | |
| | | private void stubProvider(String merchantStatus, String settleStatus, String cashSwitch) { |
| | | when(capabilityGateway.query(any(HuifuMerchantCapabilityQueryCommand.class))) |
| | | .thenReturn(new HuifuMerchantCapabilityGateway.CapabilityResult(true, "00000000", "成功", merchantStatus, settleStatus, "D1", cashSwitch, "DM")); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.talentcard.server.business.merchant; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | |
| | | import java.util.HashMap; |
| | | import java.util.Map; |
| | | |
| | | 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; |
| | | |
| | | /** Duplicated and out-of-order Huifu callbacks must not corrupt an already decided onboarding. */ |
| | | @SpringBootTest |
| | | @ActiveProfiles("test") |
| | | class MerchantSettlementCallbackIdempotencyTests { |
| | | |
| | | @Autowired |
| | | private JdbcTemplate jdbc; |
| | | |
| | | @Autowired |
| | | private MerchantSettlementService settlementService; |
| | | |
| | | private Long merchantId; |
| | | private Long storeId; |
| | | private Long applicationId; |
| | | private Long bankCardId; |
| | | private String applicationNo; |
| | | private String suffix; |
| | | |
| | | @BeforeEach |
| | | void prepareOnboarding() { |
| | | suffix = String.valueOf(System.nanoTime()); |
| | | applicationNo = "MO-CB-" + suffix; |
| | | jdbc.update("insert into merchant(merchant_name, status, deleted) values (?, 1, 0)", "Callback merchant " + 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, "Callback store " + 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) |
| | | values (?, ?, ?, 'individual', 'Callback owner', 'Callback owner', '13700000000', ?, 'pending') |
| | | """, merchantId, storeId, applicationNo, "callback-onboarding-" + suffix); |
| | | applicationId = 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_request_no) |
| | | values (?, ?, '中国建设银行', '攀枝花东区支行', '510400', '1234', '**** **** **** 1234', ?, 'pending', ?, ?) |
| | | """, merchantId, storeId, "hash-callback-" + suffix, "callback-card-" + suffix, "BC-" + suffix); |
| | | bankCardId = jdbc.queryForObject("select max(id) from merchant_bank_card", Long.class); |
| | | jdbc.update("update merchant_onboarding_application set initial_bank_card_id=? where id=?", bankCardId, applicationId); |
| | | } |
| | | |
| | | @Test |
| | | void repeatedAuditCallbacksKeepTheSameOnboardingState() { |
| | | Map<String, Object> callback = new HashMap<>(); |
| | | callback.put("req_seq_id", applicationNo); |
| | | callback.put("audit_status", "N"); |
| | | callback.put("sub_resp_code", "10000001"); |
| | | callback.put("audit_desc", "证照不清晰"); |
| | | |
| | | assertThat(settlementService.handleOnboardingCallback(new HashMap<>(callback))).isTrue(); |
| | | Map<String, Object> first = onboardingState(); |
| | | assertThat(first).containsEntry("status", "rejected"); |
| | | assertThat(first.get("rejectReason")).isNotNull(); |
| | | |
| | | // The provider retries the identical notification before the late approval arrives. |
| | | assertThat(settlementService.handleOnboardingCallback(new HashMap<>(callback))).isTrue(); |
| | | assertThat(onboardingState()).isEqualTo(first); |
| | | } |
| | | |
| | | @Test |
| | | void aLateNotificationCannotOverwriteADecidedOnboarding() { |
| | | jdbc.update("update merchant_onboarding_application set status='approved', provider_huifu_id='6666000000000001', reject_reason='' where id=?", applicationId); |
| | | |
| | | Map<String, Object> lateRejection = new HashMap<>(); |
| | | lateRejection.put("req_seq_id", applicationNo); |
| | | lateRejection.put("audit_status", "N"); |
| | | lateRejection.put("sub_resp_code", "10000001"); |
| | | lateRejection.put("audit_desc", "证照不清晰"); |
| | | assertThat(settlementService.handleOnboardingCallback(lateRejection)).isTrue(); |
| | | |
| | | assertThat(onboardingState()) |
| | | .containsEntry("status", "approved") |
| | | .containsEntry("providerHuifuId", "6666000000000001") |
| | | .containsEntry("rejectReason", ""); |
| | | } |
| | | |
| | | @Test |
| | | void anApprovalCallbackBindsTheTokenOnlyOnce() { |
| | | Map<String, Object> approval = new HashMap<>(); |
| | | approval.put("req_seq_id", applicationNo); |
| | | approval.put("audit_status", "Y"); |
| | | approval.put("sub_resp_code", "00000000"); |
| | | approval.put("resp_desc", "成功"); |
| | | approval.put("huifu_id", "6666000000000002"); |
| | | approval.put("apply_no", "APPLY-0001"); |
| | | approval.put("token_no", "TOKEN-0001"); |
| | | |
| | | assertThat(settlementService.handleOnboardingCallback(approval)).isTrue(); |
| | | assertThat(onboardingState()) |
| | | .containsEntry("providerHuifuId", "6666000000000002") |
| | | .containsEntry("status", "pending"); |
| | | assertThat(cardState()).containsEntry("status", "approved").containsEntry("providerTokenNo", "TOKEN-0001"); |
| | | |
| | | // A duplicated approval carrying a different token must not replace the stored token. |
| | | Map<String, Object> repeated = new HashMap<>(approval); |
| | | repeated.put("token_no", "TOKEN-9999"); |
| | | assertThat(settlementService.handleOnboardingCallback(repeated)).isTrue(); |
| | | |
| | | assertThat(cardState()).containsEntry("status", "approved").containsEntry("providerTokenNo", "TOKEN-0001"); |
| | | } |
| | | |
| | | @Test |
| | | void cardBindingCallbackOnlyMovesAPendingCardOnce() { |
| | | String requestNo = jdbc.queryForObject("select provider_request_no from merchant_bank_card where id=?", String.class, bankCardId); |
| | | Map<String, Object> callback = new HashMap<>(); |
| | | callback.put("req_seq_id", requestNo); |
| | | callback.put("sub_resp_code", "00000000"); |
| | | callback.put("sub_resp_desc", "成功"); |
| | | callback.put("token_no", "TOKEN-BIND-1"); |
| | | |
| | | assertThat(settlementService.handleBankCardCallback(callback)).isTrue(); |
| | | assertThat(cardState()).containsEntry("status", "approved").containsEntry("providerTokenNo", "TOKEN-BIND-1"); |
| | | |
| | | Map<String, Object> rejectedAfterApproval = new HashMap<>(); |
| | | rejectedAfterApproval.put("req_seq_id", requestNo); |
| | | rejectedAfterApproval.put("sub_resp_code", "10000002"); |
| | | rejectedAfterApproval.put("sub_resp_desc", "绑卡失败"); |
| | | assertThat(settlementService.handleBankCardCallback(rejectedAfterApproval)).isTrue(); |
| | | |
| | | assertThat(cardState()).containsEntry("status", "approved").containsEntry("providerTokenNo", "TOKEN-BIND-1"); |
| | | } |
| | | |
| | | @Test |
| | | void unknownCallbackReferencesAreIgnored() { |
| | | Map<String, Object> unknown = new HashMap<>(); |
| | | unknown.put("req_seq_id", "MO-UNKNOWN-" + suffix); |
| | | unknown.put("audit_status", "Y"); |
| | | |
| | | assertThat(settlementService.handleOnboardingCallback(unknown)).isFalse(); |
| | | assertThat(settlementService.handleBankCardCallback(Map.of("req_seq_id", "BC-UNKNOWN-" + suffix))).isFalse(); |
| | | } |
| | | |
| | | private Map<String, Object> onboardingState() { |
| | | return jdbc.queryForMap(""" |
| | | select status as status, provider_huifu_id as providerHuifuId, provider_apply_no as providerApplyNo, |
| | | reject_reason as rejectReason |
| | | from merchant_onboarding_application where id = ? |
| | | """, applicationId); |
| | | } |
| | | |
| | | private Map<String, Object> cardState() { |
| | | return jdbc.queryForMap("select status as status, provider_token_no as providerTokenNo from merchant_bank_card where id = ?", bankCardId); |
| | | } |
| | | } |
| | |
| | | package com.talentcard.server.business.portal; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | import static org.assertj.core.api.Assertions.assertThatThrownBy; |
| | | |
| | | import java.util.Map; |
| | | |
| | | 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; |
| | |
| | | private Long merchantId; |
| | | private Long productId; |
| | | private Long couponId; |
| | | private Long onboardingId; |
| | | |
| | | @BeforeEach |
| | | void prepareData() { |
| | |
| | | jdbc.update("insert into talent_card(talent_id, card_no, card_type, card_level, status, deleted) values (?, ?, 'PERSONAL', ?, 1, 0)", talentId, "TC-APP-" + suffix, "卓越人才卡"); |
| | | jdbc.update("insert into merchant(merchant_name, contact_mobile, address, status, deleted) values (?, ?, ?, 1, 0)", "App order merchant " + suffix, "08120000000", "Test address"); |
| | | merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class); |
| | | // A merchant may only take paid orders once onboarding is approved and settlement capabilities are confirmed. |
| | | onboardingId = readyMerchant(merchantId, "app-order-" + suffix); |
| | | jdbc.update(""" |
| | | insert into merchant_product(product_no, merchant_id, product_type, product_name, original_price, sale_price, |
| | | subsidy_amount, stock, audit_status, status, deleted) |
| | |
| | | values (?, 'Test coupon', 'NONE', 10.00, 'ALL', 1, 0) |
| | | """, merchantId); |
| | | couponId = jdbc.queryForObject("select max(id) from merchant_coupon", Long.class); |
| | | } |
| | | |
| | | /** Creates a store, an approved onboarding and an approved settlement card for the merchant. */ |
| | | private Long readyMerchant(Long merchant, String suffix) { |
| | | jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)", merchant, "App order store " + suffix); |
| | | Long 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', 'App order owner', 'App order owner', '13900000000', ?, 'approved', ?, '0', '1', 'NORMAL', current_timestamp) |
| | | """, merchant, storeId, "MO-APP-" + suffix, "app-order-onboarding-" + suffix, shortReference("H", suffix)); |
| | | Long applicationId = 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) |
| | | """, merchant, storeId, "hash-app-order-" + suffix, "app-order-card-" + suffix, shortReference("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, applicationId); |
| | | return applicationId; |
| | | } |
| | | |
| | | /** Provider identifiers are limited to 32 characters, so tests keep them short. */ |
| | | private String shortReference(String prefix, String suffix) { |
| | | return prefix + suffix.substring(Math.max(0, suffix.length() - 8)); |
| | | } |
| | | |
| | | @Test |
| | |
| | | talentId, |
| | | new AppOrderCreateRequest(merchantId, productId, couponId), |
| | | "app-order-expired-first-" + System.nanoTime()); |
| | | jdbc.update("update benefit_order set expires_at = dateadd('MINUTE', -1, current_timestamp) where order_no = ?", expired.get("orderNo")); |
| | | jdbc.update("update benefit_order set expires_at = current_timestamp - interval '1' minute where order_no = ?", expired.get("orderNo")); |
| | | |
| | | Map<String, Object> replacement = appOrderPortalService.create( |
| | | talentId, |
| | |
| | | } |
| | | |
| | | @Test |
| | | void refusesPaidOrderWhileMerchantSettlementCapabilityIsMissing() { |
| | | jdbc.update("update merchant_onboarding_application set auto_settle_status='1' where id=?", onboardingId); |
| | | |
| | | assertThatThrownBy(() -> appOrderPortalService.create(talentId, new AppOrderCreateRequest(merchantId, productId, null), "app-order-blocked-" + System.nanoTime())) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("自动结算"); |
| | | assertThat(jdbc.queryForObject("select count(*) from benefit_order where merchant_id=?", Long.class, merchantId)).isZero(); |
| | | } |
| | | |
| | | @Test |
| | | void refusesPaidOrderWhenMerchantOnboardingIsNotApproved() { |
| | | jdbc.update("update merchant_onboarding_application set status='pending', capability_verified_at=null where id=?", onboardingId); |
| | | jdbc.update("update merchant_bank_card set status='pending', provider_token_no=null where merchant_id=?", merchantId); |
| | | |
| | | assertThatThrownBy(() -> appOrderPortalService.create(talentId, new AppOrderCreateRequest(merchantId, productId, null), "app-order-pending-" + System.nanoTime())) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("进件"); |
| | | } |
| | | |
| | | @Test |
| | | void usesAvailableFreeCountBeforeChargingTheProductPrice() { |
| | | jdbc.update(""" |
| | | update merchant_product |
| | |
| | | spring: |
| | | datasource: |
| | | url: jdbc:h2:mem:talent_card;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE |
| | | username: sa |
| | | password: |
| | | driver-class-name: org.h2.Driver |
| | | # Defaults to in-memory H2. CI or a developer with MySQL can point TEST_DATASOURCE_* at a real |
| | | # MySQL schema: several migrations use MySQL-only syntax (for example V77) that H2 cannot parse. |
| | | url: ${TEST_DATASOURCE_URL:jdbc:h2:mem:talent_card;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE} |
| | | username: ${TEST_DATASOURCE_USERNAME:sa} |
| | | password: ${TEST_DATASOURCE_PASSWORD:} |
| | | driver-class-name: ${TEST_DATASOURCE_DRIVER:org.h2.Driver} |
| | | flyway: |
| | | enabled: true |
| | | jpa: |
| | | hibernate: |
| | | ddl-auto: validate |
| | | |
| | | app: |
| | | payment: |
| | | huifu: |
| | | # The provider integration stays off in tests: no outbound Huifu calls, and readiness only |
| | | # enforces the local onboarding and bank-card preconditions. |
| | | enabled: false |
| | | merchant-capability-required: false |