package com.talentcard.server.business.merchant;
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.mockito.ArgumentMatchers.*;
|
import static org.mockito.Mockito.*;
|
import java.util.Map;
|
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
|
import com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway;
|
import org.junit.jupiter.api.Test;
|
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|
@SpringBootTest
|
@ActiveProfiles("test")
|
class MerchantExistingOnboardingTests {
|
@Autowired JdbcTemplate jdbc;
|
@Autowired MerchantSettlementService service;
|
@MockitoBean HuifuOnboardingProgressGateway gateway;
|
// 刷新现在会先推进开通步骤(微信配置、业务开通),所以这两个网关也要挡住,
|
// 否则测试会真的打到汇付。
|
@MockitoBean com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway wechatConfigGateway;
|
@MockitoBean com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway businessOpenGateway;
|
// 提交进件会真的上传图片、并向汇付递交申请,这两个也一并挡住——测试不该打网络。
|
@MockitoBean com.talentcard.server.business.payment.huifu.HuifuPictureUploadGateway pictureUploadGateway;
|
@MockitoBean com.talentcard.server.business.payment.huifu.HuifuIndividualOnboardingGateway individualOnboardingGateway;
|
@MockitoBean com.talentcard.server.business.payment.huifu.HuifuEnterpriseOnboardingGateway enterpriseOnboardingGateway;
|
@Autowired org.springframework.web.context.WebApplicationContext context;
|
@Autowired com.talentcard.server.business.admin.auth.AdminAuthService adminAuth;
|
|
@Test
|
void successfulDelayCallbackIgnoresLateFailureForTheSameAttempt() {
|
MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","N","forced_delay_flag","N"));
|
when(gateway.configureDelay(anyString(),anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_no","delay-apply"));
|
service.refreshOnboarding(principal,false);
|
var number=org.mockito.ArgumentCaptor.forClass(String.class);
|
var date=org.mockito.ArgumentCaptor.forClass(String.class);
|
verify(gateway).configureDelay(anyString(),number.capture(),date.capture());
|
assertThat(service.handleBusinessOpenCallback(Map.of("huifu_id","6666000241335656","req_seq_id",number.getValue(),"req_date",date.getValue(),"audit_status","Y","sub_resp_code","00000000"))).isTrue();
|
assertThat(service.handleBusinessOpenCallback(Map.of("huifu_id","6666000241335656","req_seq_id",number.getValue(),"req_date",date.getValue(),"audit_status","N","sub_resp_code","99999999"))).isTrue();
|
assertThat(service.refreshOnboarding(principal,true)).containsEntry("retryable",false);
|
verify(gateway,times(1)).configureDelay(anyString(),anyString(),anyString());
|
}
|
|
@Test
|
void completesOnlyAfterEveryCapabilityAndTheOriginalCardAreConfirmed() {
|
MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
|
jdbc.update("update merchant_bank_card set card_hash=? where merchant_id=?",sha256("622200001234"),principal.merchantId());
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S","bind_card_status","S"));
|
var scenes=java.util.List.of("4","5","12").stream().map(scene -> Map.of("pay_scene",scene,"fee_rate","0.70","fee_min_amt","0.00","fee_charge_type","1","out_fee_flag","2",
|
"wx_mer_infos",java.util.List.of(Map.of("bank_mer_code","wx-merchant","pay_channel_id","00016904","is_default","1")))).toList();
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N","qry_wx_conf_list",scenes,
|
"qry_settle_config_list",java.util.List.of(Map.of("settle_status","1","settle_cycle","D1","settle_pattern","P0","min_amt","0","remained_amt","0","token_no","original-card-token")),
|
"qry_cash_card_info_list",java.util.List.of(Map.of("card_no","622200001234","token_no","original-card-token"))));
|
when(gateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code","00000000","atu_config_bo_list",java.util.List.of(Map.of("fee_type","01","wx_config_list",java.util.List.of(Map.of("config_type","1","config_value","wxcfee477acb54d179"))))));
|
when(gateway.queryRealname(anyString())).thenReturn(Map.of("resp_code","00000000","applyment_stat","APPLYMENT_STATE_PASSED","authorize_stat","1"));
|
assertThat(service.refreshOnboarding(principal,false)).containsEntry("status","approved");
|
assertThat(service.onboardingStatus(principal)).containsEntry("status","approved");
|
assertThat(service.bankCards(principal)).hasSize(1);
|
verify(gateway,never()).configureDelay(anyString(),anyString(),anyString());
|
}
|
|
private static String sha256(String value) {
|
try { return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8))); }
|
catch (Exception exception) { throw new IllegalStateException(exception); }
|
}
|
|
@Test
|
void concurrentRefreshCannotCreateASecondDelayAttempt() throws Exception {
|
MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","N","forced_delay_flag","N"));
|
var entered=new java.util.concurrent.CountDownLatch(1);
|
var release=new java.util.concurrent.CountDownLatch(1);
|
when(gateway.configureDelay(anyString(),anyString(),anyString())).thenAnswer(call -> {
|
entered.countDown();
|
if (!release.await(10,java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("Test release timed out");
|
return Map.of("resp_code","00000000","apply_no","concurrent-apply");
|
});
|
var executor=java.util.concurrent.Executors.newSingleThreadExecutor();
|
try {
|
var first=executor.submit(() -> service.refreshOnboarding(principal,false));
|
assertThat(entered.await(10,java.util.concurrent.TimeUnit.SECONDS)).isTrue();
|
service.refreshOnboarding(principal,true);
|
release.countDown();
|
first.get(10,java.util.concurrent.TimeUnit.SECONDS);
|
verify(gateway,times(1)).configureDelay(anyString(),anyString(),anyString());
|
} finally { release.countDown(); executor.shutdownNow(); }
|
}
|
|
@Test
|
void retriesOnlyAnExplicitlyFailedDelayAttemptWithANewRequestNumber() {
|
MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","N","forced_delay_flag","N"));
|
when(gateway.configureDelay(anyString(),anyString(),anyString())).thenReturn(Map.of("resp_code","20002"),Map.of("resp_code","00000000","apply_no","retry-apply"));
|
assertThat(service.refreshOnboarding(principal,false)).containsEntry("retryable",true);
|
service.refreshOnboarding(principal,false);
|
service.refreshOnboarding(principal,true);
|
var requests=org.mockito.ArgumentCaptor.forClass(String.class);
|
verify(gateway,times(2)).configureDelay(eq("6666000241335656"),requests.capture(),anyString());
|
assertThat(requests.getAllValues()).doesNotHaveDuplicates();
|
}
|
|
@Test
|
void oldBasicFailureCallbackCannotRejectAnExistingVerifiedMerchantOrCard() {
|
Map<String,Object> fixture=fixture();
|
MerchantPrincipal principal=(MerchantPrincipal)fixture.get("principal");
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N"));
|
service.refreshOnboarding(principal,false);
|
service.handleOnboardingCallback(Map.of("req_seq_id",fixture.get("applicationNo"),"huifu_id","6666000241335656","audit_status","N","sub_resp_code","99999999"));
|
assertThat(service.bankCards(principal)).allSatisfy(card -> assertThat(card.get("status")).isEqualTo("approved"));
|
assertThat(service.onboardingStatus(principal)).containsEntry("status","pending");
|
}
|
|
@Test
|
void unknownDelayUpdateIsQueriedBeforeRetryAndNeverSubmittedTwice() {
|
MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","N","forced_delay_flag","N"));
|
when(gateway.configureDelay(anyString(),anyString(),anyString())).thenThrow(new com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException("timeout",true,null));
|
service.refreshOnboarding(principal,false);
|
Map<String,Object> result=service.refreshOnboarding(principal,true);
|
verify(gateway,times(1)).configureDelay(eq("6666000241335656"),matches("[a-f0-9]{32}"),matches("[0-9]{8}"));
|
assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","delay").containsEntry("status","pending"));
|
assertThat(result).containsEntry("retryable",false).containsEntry("status","pending");
|
}
|
|
@Test
|
void failedRealnameQueryPreservesConfirmedStateAndReportsQueryFailure() {
|
MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N"));
|
when(gateway.queryRealname(anyString())).thenReturn(Map.of("resp_code","00000000","applyment_stat","APPLYMENT_STATE_PASSED","authorize_stat","1"),Map.of("resp_code","20002"));
|
service.refreshOnboarding(principal,false);
|
Map<String,Object> result=service.refreshOnboarding(principal,false);
|
assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","realname").containsEntry("status","confirmed"));
|
assertThat(result.get("syncError")).isEqualTo("汇付查询权限不足(20002),请联系平台运营核实接口权限");
|
}
|
|
@Test
|
void realnamePassedStillNeedsAuthorizationAndControlledAppIdIsNotReady() {
|
MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N"));
|
when(gateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code","00000000","atu_config_bo_list",java.util.List.of(Map.of("fee_type","01","wx_applet_app_id","wxcfee477acb54d179","wx_config_list",java.util.List.of(Map.of("config_type","1","config_value","wxcfee477acb54d179","control_flag","Y"))))));
|
when(gateway.queryRealname(anyString())).thenReturn(Map.of("resp_code","00000000","applyment_stat","APPLYMENT_STATE_PASSED","authorize_stat","0"));
|
Map<String,Object> result=service.refreshOnboarding(principal,false);
|
assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","appId").containsEntry("status","action_required"));
|
assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","realname").containsEntry("status","action_required"));
|
assertThat(result).containsEntry("status","pending");
|
}
|
|
@Test
|
void ordinaryReadsExposeSameProgressWithoutCallingHuifu() {
|
MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","N","forced_delay_flag","N"));
|
Map<String,Object> refreshed=service.refreshOnboarding(principal,false);
|
clearInvocations(gateway);
|
assertThat(service.onboardingStatus(principal).get("stages")).isEqualTo(refreshed.get("stages"));
|
verifyNoInteractions(gateway);
|
}
|
|
@Test
|
void refreshRequiresSettlementEditPermissionEvenForAnAuthenticatedAdmin() throws Exception {
|
String account="t02admin"+System.nanoTime();
|
jdbc.update("insert into admin_operator(account,password_hash,display_name,role_code,status,deleted) values (?,?,?,'t02-no-rights',1,0)",account,
|
com.talentcard.server.business.merchant.auth.MerchantPasswordHasher.encode("TestPass123"),account);
|
String token=String.valueOf(adminAuth.login(new com.talentcard.server.business.admin.auth.AdminLoginRequest(account,"TestPass123"),account).get("accessToken"));
|
org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup(context).build().perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/admin/management/merchant-settlement/onboardings/449/progress/refresh")
|
.header("Authorization","Bearer "+token)).andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isForbidden());
|
verifyNoInteractions(gateway);
|
}
|
|
@Test
|
void configuredWechatScenesStillRequireTalentAppAndAuthorization() {
|
MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
|
var scenes=java.util.List.of("4","5","12").stream().map(scene -> Map.of("pay_scene",scene,"fee_rate","0.70","fee_min_amt","0.00","fee_charge_type","1","out_fee_flag","2",
|
"wx_mer_infos",java.util.List.of(Map.of("bank_mer_code","wx-merchant","pay_channel_id","00016904","is_default","1")))).toList();
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N","qry_wx_conf_list",scenes));
|
Map<String,Object> result=service.refreshOnboarding(principal,false);
|
assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","wechat").containsEntry("status","confirmed"));
|
assertThat(result).containsEntry("status","pending");
|
}
|
|
/**
|
* 通道**不返回** {@code fee_min_amt} 时,微信支付阶段同样要算已生效。
|
*
|
* <p>该字段是可选的,文档写明「大于 0」,不设最低收费时我们本来就不下发它,通道也就不返回它。
|
* 判定原先要求它必须出现且为 0,于是真实商户的微信业务明明已经开好(三场景、费率 0.70、内扣、
|
* 子商户号与默认标识都在),阶段却永远停在未确认——2026-09-16 拿通道回执逐项核对才发现。
|
*/
|
@Test
|
void anAbsentMinimumFeeStillCountsAsWechatConfirmed() {
|
MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
|
// 与真实回执同形:三个场景都不带 fee_min_amt。
|
var scenes=java.util.List.of("4","5","12").stream().map(scene -> Map.of("pay_scene",scene,"fee_rate","0.70","fee_charge_type","1","out_fee_flag","2",
|
"wx_mer_infos",java.util.List.of(Map.of("bank_mer_code","922609119","pay_channel_id","00016904","is_default","1")))).toList();
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N","qry_wx_conf_list",scenes));
|
|
Map<String,Object> result=service.refreshOnboarding(principal,false);
|
|
assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","wechat").containsEntry("status","confirmed"));
|
}
|
|
/** 但真设了最低收费(非 0)时仍不算生效——放宽的只是"字段缺失",不是"值不对"。 */
|
@Test
|
void aNonZeroMinimumFeeKeepsTheWechatStageUnconfirmed() {
|
MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
|
var scenes=java.util.List.of("4","5","12").stream().map(scene -> Map.of("pay_scene",scene,"fee_rate","0.70","fee_min_amt","1.00","fee_charge_type","1","out_fee_flag","2",
|
"wx_mer_infos",java.util.List.of(Map.of("bank_mer_code","922609119","is_default","1")))).toList();
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N","qry_wx_conf_list",scenes));
|
|
Map<String,Object> result=service.refreshOnboarding(principal,false);
|
|
assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","wechat").containsEntry("status","missing"));
|
}
|
|
@Test
|
void confirmsD1OnlyFromEffectiveZeroThresholdBatchSettlement() {
|
MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N",
|
"qry_settle_config_list","[{\"settle_status\":\"1\",\"settle_cycle\":\"D1\",\"settle_pattern\":\"P0\",\"min_amt\":0,\"remained_amt\":0}]"));
|
Map<String,Object> result=service.refreshOnboarding(principal,false);
|
assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","settlement").containsEntry("status","confirmed"));
|
assertThat(result).containsEntry("status","pending");
|
}
|
|
@Test
|
void submittingAgainCannotReplaceDesignatedExistingMerchantAfterConfigFailure() {
|
Map<String,Object> application=fixture();
|
jdbc.update("update merchant_onboarding_application set wechat_config_status='failed' where application_no=?",application.get("applicationNo"));
|
Map<String,Object> result=service.submitOnboarding((MerchantPrincipal)application.get("principal"),null,"T02-retry-"+System.nanoTime());
|
assertThat(result.get("applicationNo")).isEqualTo(application.get("applicationNo"));
|
}
|
|
@Test
|
void missingDelayFieldsKeepLastConfirmedEvidenceAndReportIncompleteQuery() {
|
MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N"),Map.of("resp_code","00000000"));
|
service.refreshOnboarding(principal,false);
|
Map<String,Object> result=service.refreshOnboarding(principal,false);
|
assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","delay").containsEntry("status","confirmed"));
|
assertThat(result.get("syncError")).isEqualTo("汇付返回缺少延迟配置字段,已保留最后有效状态");
|
}
|
|
@Test
|
void capabilityQueryErrorKeepsConfirmedDelayAndReportsSyncError() {
|
Map<String,Object> application=fixture();
|
MerchantPrincipal principal=(MerchantPrincipal)application.get("principal");
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N"),Map.of("resp_code","20002","resp_desc","数据权限认证失败"));
|
service.refreshOnboarding(principal,false);
|
Map<String,Object> result=service.refreshOnboarding(principal,false);
|
assertThat(result.get("syncError")).isEqualTo("汇付查询权限不足(20002),请联系平台运营核实接口权限");
|
assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","delay").containsEntry("status","confirmed"));
|
}
|
|
@Test
|
void approvedBasicApplicationIsNotCompleteWithoutWechatAndFundsEvidence() {
|
Map<String,Object> application=fixture();
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S","bind_card_status","S"));
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL"));
|
Map<String,Object> result=service.refreshOnboarding((MerchantPrincipal)application.get("principal"),false);
|
assertThat(result).containsEntry("status","pending");
|
assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","basic").containsEntry("status","confirmed"));
|
assertThat(result.get("lastSyncedAt")).isNotEqualTo("");
|
}
|
|
private Map<String,Object> fixture() {
|
String key="T02-"+System.nanoTime();
|
jdbc.update("insert into merchant(merchant_name,status,deleted) values (?,1,0)",key);
|
Long merchant=jdbc.queryForObject("select id from merchant where merchant_name=?",Long.class,key);
|
jdbc.update("insert into merchant_store(merchant_id,store_name,status,deleted) values (?,?,1,0)",merchant,key);
|
Long store=jdbc.queryForObject("select id from merchant_store where merchant_id=?",Long.class,merchant);
|
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) values (?,?,'Bank','Branch','510100','1234','****1234',?,'approved',?)",merchant,store,key,key);
|
Long card=jdbc.queryForObject("select id from merchant_bank_card where idempotency_key=?",Long.class,key);
|
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,provider_apply_no,initial_bank_card_id,wechat_config_status,business_open_status) values (?, ?, ?, 'enterprise','Owner','Owner','13700000000',?,'pending','6666000241335656','original-apply',?,'approved','approved')",merchant,store,key,key,card);
|
return Map.of("principal",new MerchantPrincipal(1L,merchant,store,"owner"),"applicationNo",key);
|
}
|
|
/**
|
* 通道侧已经生效的步骤不再重复提交。微信商户配置是**添加**接口(成功后不支持改删、每种参数
|
* 最多 5 组),业务开通是申请型接口,重复提交会在通道留下重复配置或第二份申请。
|
*/
|
@Test
|
void refreshingSkipsStepsAlreadyEffectiveOnTheChannel() {
|
Map<String,Object> fixture=provisioningFixture();
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N",
|
"qry_wx_conf_list",java.util.List.of(Map.of("pay_scene","4","fee_rate","0.70"))));
|
when(gateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code","00000000","atu_config_bo_list",
|
java.util.List.of(Map.of("fee_type","01","wx_config_list",java.util.List.of(Map.of("config_type","1","config_value","wxcfee477acb54d179"))))));
|
|
service.refreshOnboarding((MerchantPrincipal)fixture.get("principal"),false);
|
|
assertThat(statusOf("wechat_config_status",String.valueOf(fixture.get("applicationNo")))).isEqualTo("approved");
|
assertThat(statusOf("business_open_status",String.valueOf(fixture.get("applicationNo")))).isEqualTo("approved");
|
verify(wechatConfigGateway,never()).configure(anyString(),anyString());
|
verify(businessOpenGateway,never()).open(anyString(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),anyString(),anyString());
|
}
|
|
/**
|
* 顺序与闸门:先做业务开通;微信小程序配置要等**通道侧真的有微信子商户号**才发。
|
*
|
* <p>微信子商户号由业务开通创建,但入驻是**异步**的——业务开通回 {@code 90000000}(审核中)
|
* 并不代表子商户号已经建好,此时发配置会被「根据渠道号或微信子商户号未查到入驻记录」驳回。
|
* 所以闸门取通道证据({@code qry_wx_conf_list} 里出现线上场景),不取本地受理状态。
|
* 2026-09-16 用本地状态当闸门的真实代价就是这个报错。
|
*/
|
@Test
|
void theWechatConfigWaitsUntilTheChannelConfirmsTheWechatBusiness() {
|
Map<String,Object> fixture=provisioningFixture();
|
String applicationNo=String.valueOf(fixture.get("applicationNo"));
|
MerchantPrincipal principal=(MerchantPrincipal)fixture.get("principal");
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N"));
|
when(businessOpenGateway.open(anyString(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway.Result(true,"BO-APPLY","90000000","审核中"));
|
|
// 第一轮:通道侧还没有微信业务 → 只提交业务开通,配置这一步根本不碰。
|
service.refreshOnboarding(principal,false);
|
assertThat(statusOf("business_open_status",applicationNo)).isEqualTo("processing");
|
verify(wechatConfigGateway,never()).configure(anyString(),anyString());
|
// 保持 not_started,不写成 failed:这一步根本没试过。
|
assertThat(statusOf("wechat_config_status",applicationNo)).isEqualTo("not_started");
|
|
// 第二轮:通道侧已经开通线上场景 → 业务开通按已开通收口(不重复提交),配置才发出去。
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N",
|
"qry_wx_conf_list",java.util.List.of(Map.of("pay_scene","4"))));
|
when(wechatConfigGateway.configure(anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway.Result(true,"00000000","成功"));
|
|
service.refreshOnboarding(principal,false);
|
|
// 业务开通只提交过一次——通道已开通时不再重复提交,避免第二份申请。
|
verify(businessOpenGateway,times(1)).open(anyString(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),anyString(),anyString());
|
verify(wechatConfigGateway,times(1)).configure(anyString(),anyString());
|
assertThat(statusOf("business_open_status",applicationNo)).isEqualTo("approved");
|
assertThat(statusOf("wechat_config_status",applicationNo)).isEqualTo("approved");
|
}
|
|
/** 微信配置失败不影响业务开通的推进与收口:两步各自记状态、各自可重试。 */
|
@Test
|
void aFailedWechatConfigDoesNotHoldBackTheBusinessOpen() {
|
Map<String,Object> fixture=provisioningFixture();
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N",
|
"qry_wx_conf_list",java.util.List.of(Map.of("pay_scene","4"))));
|
when(wechatConfigGateway.configure(anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway.Result(false,"00000001","参数非法"));
|
|
service.refreshOnboarding((MerchantPrincipal)fixture.get("principal"),false);
|
|
String applicationNo=String.valueOf(fixture.get("applicationNo"));
|
// 业务开通照样收口为已开通(通道证据),不被另一步的失败牵连。
|
assertThat(statusOf("business_open_status",applicationNo)).isEqualTo("approved");
|
assertThat(statusOf("wechat_config_status",applicationNo)).isEqualTo("failed");
|
assertThat(statusOf("reject_reason",applicationNo)).contains("参数非法");
|
}
|
|
/**
|
* 延迟入账失败时把通道给的描述一起落库。
|
*
|
* <p>这一条失败回的是 {@code 99999999}(通道的「失败:<原因>」通用码),原先只存 code,
|
* 描述一丢就完全看不出为什么失败——2026-09-16 排查时正是卡在这里。
|
*/
|
@Test
|
void aFailedDelayAttemptKeepsTheChannelMessage() {
|
Map<String,Object> fixture=fixture();
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","N","forced_delay_flag","N"));
|
when(gateway.configureDelay(anyString(),anyString(),anyString())).thenReturn(Map.of("resp_code","99999999","sub_resp_desc","复用交易信息,上级商户号为空"));
|
|
service.refreshOnboarding((MerchantPrincipal)fixture.get("principal"),false);
|
|
String message=jdbc.queryForObject("""
|
select t.response_message from merchant_onboarding_attempt t
|
join merchant_onboarding_application a on a.id=t.application_id
|
where a.application_no=? and t.step='delay'
|
""",String.class,String.valueOf(fixture.get("applicationNo")));
|
assertThat(message).isEqualTo("复用交易信息,上级商户号为空");
|
}
|
|
/**
|
* 通道按流水号去重:上一步被通道**明确驳回**后重试必须换新号,沿用旧号会被当成重复请求。
|
* 进件与实名走的是同一条规则,业务开通与微信配置原本是复用——2026-09-16 被驳回后要重发时才发现。
|
*/
|
@Test
|
void aFailedBusinessOpenIsRetriedWithANewRequestNumber() {
|
Map<String,Object> fixture=provisioningFixture();
|
String applicationNo=String.valueOf(fixture.get("applicationNo"));
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N"));
|
when(businessOpenGateway.open(anyString(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway.Result(false,"","00000001","wx_conf_list格式错误"));
|
|
MerchantPrincipal principal=(MerchantPrincipal)fixture.get("principal");
|
service.refreshOnboarding(principal,false);
|
assertThat(statusOf("business_open_status",applicationNo)).isEqualTo("failed");
|
service.refreshOnboarding(principal,false);
|
|
var requests=org.mockito.ArgumentCaptor.forClass(String.class);
|
verify(businessOpenGateway,times(2)).open(requests.capture(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),anyString(),anyString());
|
assertThat(requests.getAllValues()).doesNotHaveDuplicates();
|
assertThat(requests.getAllValues().get(0)).isEqualTo(applicationNo+"BO");
|
assertThat(statusOf("business_open_request_no",applicationNo)).isEqualTo(requests.getAllValues().get(1));
|
}
|
|
@Test
|
void aFailedWechatConfigIsRetriedWithANewRequestNumber() {
|
Map<String,Object> fixture=provisioningFixture();
|
String applicationNo=String.valueOf(fixture.get("applicationNo"));
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
|
// 通道侧已开通线上场景,配置这一步才有东西可挂(见 advanceWechatConfig 的闸门)。
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N",
|
"qry_wx_conf_list",java.util.List.of(Map.of("pay_scene","4"))));
|
when(wechatConfigGateway.configure(anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway.Result(false,"00000001","参数非法"));
|
|
MerchantPrincipal principal=(MerchantPrincipal)fixture.get("principal");
|
service.refreshOnboarding(principal,false);
|
assertThat(statusOf("wechat_config_status",applicationNo)).isEqualTo("failed");
|
service.refreshOnboarding(principal,false);
|
|
var requests=org.mockito.ArgumentCaptor.forClass(String.class);
|
verify(wechatConfigGateway,times(2)).configure(requests.capture(),anyString());
|
assertThat(requests.getAllValues()).doesNotHaveDuplicates();
|
assertThat(requests.getAllValues().get(0)).isEqualTo(applicationNo+"WC");
|
assertThat(statusOf("wechat_config_request_no",applicationNo)).isEqualTo(requests.getAllValues().get(1));
|
}
|
|
private String statusOf(String column,String applicationNo) {
|
return jdbc.queryForObject("select "+column+" from merchant_onboarding_application where application_no=?",String.class,applicationNo);
|
}
|
|
/**
|
* 进件通过后**新增**的结算卡也要能从人工刷新那条路收敛为已批准。
|
*
|
* <p>它的状态原先只有绑定回调能推进(回调会丢,开发与内网环境根本收不到),而初次进件那张卡的
|
* 查询对等物只认 {@code initial_bank_card_id}。补法是把同一条查询路径推广到该门店所有待处理的卡,
|
* 不新增定时任务。
|
*/
|
@Test
|
void anAddedBankCardConvergesThroughTheSameManualRefresh() {
|
Map<String,Object> fixture = fixture();
|
MerchantPrincipal principal = (MerchantPrincipal) fixture.get("principal");
|
String applicationNo = String.valueOf(fixture.get("applicationNo"));
|
String addedCardKey = "T02ADD-" + applicationNo;
|
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)
|
values (?, ?, 'Bank', 'Branch', '510100', '5678', '****5678', ?, 'pending', ?)
|
""", principal.merchantId(), principal.storeId(), sha256("622200009999"), addedCardKey);
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S","bind_card_status","S"));
|
when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N",
|
"qry_cash_card_info_list",java.util.List.of(Map.of("card_no","622200009999","token_no","added-card-token")),
|
"qry_settle_config_list",java.util.List.of(Map.of("settle_status","1","settle_cycle","D1","settle_pattern","P0","min_amt","0","remained_amt","0","token_no","added-card-token"))));
|
|
service.refreshOnboarding(principal,false);
|
|
assertThat(jdbc.queryForObject("select status from merchant_bank_card where idempotency_key=?",String.class,addedCardKey))
|
.as("新增的结算卡也要能靠查询收敛,而不是永远停在处理中")
|
.isEqualTo("approved");
|
}
|
|
/**
|
* 分阶段模型下重复提交不新建进件,也就不会在通道上多出一家商户。唯一的例外是补齐个人主体的
|
* 门店照片:那是开通微信支付与发起微信实名的硬材料,而商户端没有别的补图入口——真实开发库里
|
* 已经有拿到汇付商户号的个人进件停在这个形态上(有商户号、门店照片为空、业务开通没做过)。
|
*/
|
@Test
|
void resubmittingSuppliesTheMissingStorePhotosWithoutCreatingANewApplication() {
|
Map<String,Object> fixture = individualProvisioningFixture();
|
MerchantPrincipal principal = (MerchantPrincipal) fixture.get("principal");
|
String applicationNo = String.valueOf(fixture.get("applicationNo"));
|
|
service.submitOnboarding(principal, request("https://cdn.example.com/store-header.jpg","https://cdn.example.com/store-indoor.jpg"), "T02-store-"+System.nanoTime());
|
|
assertThat(statusOf("store_header_url",applicationNo)).isEqualTo("https://cdn.example.com/store-header.jpg");
|
assertThat(statusOf("store_indoor_url",applicationNo)).isEqualTo("https://cdn.example.com/store-indoor.jpg");
|
assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_application where application_no=?",Integer.class,applicationNo)).isEqualTo(1);
|
// 没有重新进件,原汇付商户号与银行卡关系都保持。
|
assertThat(statusOf("provider_huifu_id",applicationNo)).isEqualTo("6666000241335656");
|
assertThat(jdbc.queryForObject("select initial_bank_card_id from merchant_onboarding_application where application_no=?",Long.class,applicationNo)).isNotNull();
|
}
|
|
/** 已经有值的门店照片不会被重新提交覆盖。 */
|
@Test
|
void resubmittingDoesNotOverwriteAnExistingStorePhoto() {
|
Map<String,Object> fixture = individualProvisioningFixture();
|
MerchantPrincipal principal = (MerchantPrincipal) fixture.get("principal");
|
String applicationNo = String.valueOf(fixture.get("applicationNo"));
|
jdbc.update("update merchant_onboarding_application set store_header_url='https://cdn.example.com/original.jpg' where application_no=?", applicationNo);
|
|
service.submitOnboarding(principal, request("https://cdn.example.com/other.jpg","https://cdn.example.com/store-indoor.jpg"), "T02-store-"+System.nanoTime());
|
|
assertThat(statusOf("store_header_url",applicationNo)).isEqualTo("https://cdn.example.com/original.jpg");
|
assertThat(statusOf("store_indoor_url",applicationNo)).isEqualTo("https://cdn.example.com/store-indoor.jpg");
|
}
|
|
/**
|
* 存量进件缺「结算银行卡卡号面」时,重新提交要能补上。
|
*
|
* <p>这一列过去从来没落过库(2026-09-18 工单),库里也没有别的地方能还原它;而分阶段模型下
|
* 重复提交不会新建进件,重提就是已提交进件唯一的补救入口,所以这条必须补得住。
|
*/
|
@Test
|
void resubmittingSuppliesTheMissingSettlementCardFront() {
|
Map<String,Object> fixture = individualProvisioningFixture();
|
MerchantPrincipal principal = (MerchantPrincipal) fixture.get("principal");
|
String applicationNo = String.valueOf(fixture.get("applicationNo"));
|
assertThat(statusOf("settlement_card_front_url",applicationNo)).isNull();
|
|
service.submitOnboarding(principal, request(
|
"https://cdn.example.com/store-header.jpg",
|
"https://cdn.example.com/store-indoor.jpg",
|
"https://cdn.example.com/card-front.jpg"), "T02-card-"+System.nanoTime());
|
|
assertThat(statusOf("settlement_card_front_url",applicationNo)).isEqualTo("https://cdn.example.com/card-front.jpg");
|
// 没有重新进件,原汇付商户号与银行卡关系都保持。
|
assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_application where application_no=?",Integer.class,applicationNo)).isEqualTo(1);
|
}
|
|
/** 已经有值的卡号面不会被重新提交覆盖——存档的那张才是当时提交的。 */
|
@Test
|
void resubmittingDoesNotOverwriteAnExistingSettlementCardFront() {
|
Map<String,Object> fixture = individualProvisioningFixture();
|
MerchantPrincipal principal = (MerchantPrincipal) fixture.get("principal");
|
String applicationNo = String.valueOf(fixture.get("applicationNo"));
|
jdbc.update("update merchant_onboarding_application set settlement_card_front_url='https://cdn.example.com/original-card.jpg' where application_no=?", applicationNo);
|
|
service.submitOnboarding(principal, request(
|
"https://cdn.example.com/store-header.jpg",
|
"https://cdn.example.com/store-indoor.jpg",
|
"https://cdn.example.com/other-card.jpg"), "T02-card-"+System.nanoTime());
|
|
assertThat(statusOf("settlement_card_front_url",applicationNo)).isEqualTo("https://cdn.example.com/original-card.jpg");
|
}
|
|
private Map<String,Object> individualProvisioningFixture() {
|
String key="T02I-"+System.nanoTime();
|
jdbc.update("insert into merchant(merchant_name,status,deleted) values (?,1,0)",key);
|
Long merchant=jdbc.queryForObject("select id from merchant where merchant_name=?",Long.class,key);
|
jdbc.update("insert into merchant_store(merchant_id,store_name,status,deleted) values (?,?,1,0)",merchant,key);
|
Long store=jdbc.queryForObject("select id from merchant_store where merchant_id=?",Long.class,merchant);
|
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) values (?,?,'Bank','Branch','510100','1234','****1234',?,'approved',?)",merchant,store,key,key);
|
Long card=jdbc.queryForObject("select id from merchant_bank_card where idempotency_key=?",Long.class,key);
|
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,provider_apply_no,initial_bank_card_id,business_open_status) values (?, ?, ?, 'individual','Owner','Owner','13700000000',?,'pending','6666000241335656','original-apply',?,'failed')",merchant,store,key,key,card);
|
return Map.of("principal",new MerchantPrincipal(1L,merchant,store,"owner"),"applicationNo",key);
|
}
|
|
/** 补齐门店照片这一步只读这两项,其余字段留空即可。 */
|
private static MerchantOnboardingRequest request(String storeHeaderUrl,String storeIndoorUrl) {
|
return request(storeHeaderUrl, storeIndoorUrl, null);
|
}
|
|
private static MerchantOnboardingRequest request(String storeHeaderUrl,String storeIndoorUrl,String settlementCardFrontUrl) {
|
return new MerchantOnboardingRequest("individual","Owner",null,null,null,null,null,null,null,null,
|
null,null,"510100",null,null,null,null,null,null,null,null,null,null,null,null,null,
|
"Owner","13700000000",null,null,"510100",null,settlementCardFrontUrl,null,storeHeaderUrl,storeIndoorUrl);
|
}
|
|
/**
|
* 新提交的个人进件要把「结算银行卡卡号面」真的写进库。
|
*
|
* <p>在这之前那条 INSERT 的列清单里没有 {@code settlement_card_front_url}:商户端传了值、后端也
|
* 拿它上传了汇付图片(F13),但写库时被静默丢掉,于是后台详情里这一格永远是 {@code -}。
|
* 断言直接盯数据库那一列,不看接口返回值——返回值在修复前也是「成功」的。
|
*/
|
@Test
|
void aNewIndividualSubmissionPersistsTheSettlementCardFront() {
|
String key = "T02C-" + System.nanoTime();
|
Map<String,Long> ids = merchantAndStore(key);
|
String clearingCode = twelveDigitCode();
|
insertBankBranch(clearingCode);
|
stubPictureUpload();
|
// 通道递交刻意回「驳回」:写库发生在递交**之前**,而驳回会让流程就此收口,
|
// 不再触发后面的开通链路——这条测试只关心那一列有没有落库,其余都是噪音。
|
when(individualOnboardingGateway.create(any())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuIndividualOnboardingGateway.Result(false,"","","","00000001","参数非法"));
|
|
service.submitOnboarding(new MerchantPrincipal(1L,ids.get("merchantId"),ids.get("storeId"),"owner"), individualRequest(clearingCode), "T02-card-"+System.nanoTime());
|
|
assertThat(settlementProofOf(ids.get("merchantId")))
|
.as("上传了结算卡卡号面就必须落库,否则后台详情只能显示 -")
|
.isEqualTo("https://cdn.example.com/card-front.jpg");
|
}
|
|
/**
|
* 对公(企业且非个体工商户)主体的「结算凭证图」是**开户许可证**,同样必须落库。
|
*
|
* <p>这一列一列两义:商户端对私把图放 settlementCardFrontUrl、对公放 registrationAccountUrl。
|
* 只按对私那一路取值的话,对公提交出来这一格永远是空的——后台详情里就是那个刺眼的 `-`。
|
*/
|
@Test
|
void aNewEnterpriseSubmissionPersistsTheRegistrationAccountProof() {
|
String key = "T02E-" + System.nanoTime();
|
Map<String,Long> ids = merchantAndStore(key);
|
String clearingCode = twelveDigitCode();
|
insertBankBranch(clearingCode);
|
stubPictureUpload();
|
when(enterpriseOnboardingGateway.create(any())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuEnterpriseOnboardingGateway.Result(false,"","","","00000001","参数非法"));
|
|
service.submitOnboarding(new MerchantPrincipal(1L,ids.get("merchantId"),ids.get("storeId"),"owner"), enterpriseRequest(clearingCode), "T02-card-"+System.nanoTime());
|
|
assertThat(settlementProofOf(ids.get("merchantId")))
|
.as("对公主体的结算凭证图是开户许可证,不能取对私那个字段")
|
.isEqualTo("https://cdn.example.com/registration-account.jpg");
|
}
|
|
/** 对公主体重提时补的也是开户许可证那一张。 */
|
@Test
|
void resubmittingAnEnterpriseApplicationSuppliesTheRegistrationAccountProof() {
|
Map<String,Object> fixture = provisioningFixture();
|
MerchantPrincipal principal = (MerchantPrincipal) fixture.get("principal");
|
String applicationNo = String.valueOf(fixture.get("applicationNo"));
|
assertThat(statusOf("settlement_card_front_url",applicationNo)).isNull();
|
|
service.submitOnboarding(principal, registrationAccountRequest("https://cdn.example.com/registration-account.jpg"), "T02-card-"+System.nanoTime());
|
|
assertThat(statusOf("settlement_card_front_url",applicationNo)).isEqualTo("https://cdn.example.com/registration-account.jpg");
|
assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_application where application_no=?",Integer.class,applicationNo)).isEqualTo(1);
|
}
|
|
private String settlementProofOf(Long merchantId) {
|
return jdbc.queryForObject("select settlement_card_front_url from merchant_onboarding_application where merchant_id=?",String.class,merchantId);
|
}
|
|
private Map<String,Long> merchantAndStore(String key) {
|
jdbc.update("insert into merchant(merchant_name,merchant_type,status,deleted) values (?, '餐饮服务', 1, 0)", key);
|
Long merchant = jdbc.queryForObject("select id from merchant where merchant_name=?", Long.class, key);
|
jdbc.update("insert into merchant_store(merchant_id,store_name,status,deleted) values (?,?,1,0)", merchant, key);
|
Long store = jdbc.queryForObject("select id from merchant_store where merchant_id=?", Long.class, merchant);
|
return Map.of("merchantId", merchant, "storeId", store);
|
}
|
|
/** 开户行目录里必须有这一条,且带汇付银行编码,否则提交会在选行那一步被拦。 */
|
private void insertBankBranch(String clearingCode) {
|
jdbc.update("insert into bank_branch_directory(clearing_code,branch_name,bank_name,head_bank_code) values (?,?,?,?)",
|
clearingCode, "测试支行", "测试银行", "HB" + clearingCode);
|
}
|
|
private static String twelveDigitCode() {
|
return String.format("%012d", Math.abs(System.nanoTime() % 1_000_000_000_000L));
|
}
|
|
private void stubPictureUpload() {
|
when(pictureUploadGateway.uploadUrl(anyString(),anyString())).thenReturn("pic-id");
|
}
|
|
/** 对公(企业且非个体工商户)的完整提交:结算凭证图走 registrationAccountUrl。 */
|
private static MerchantOnboardingRequest enterpriseRequest(String clearingCode) {
|
return new MerchantOnboardingRequest(
|
"enterprise","测试法人","91110108MA01ABCDEF","511623200211245724","fixed","2022-02-14","2032-02-14",
|
"fixed","2020-01-01","2040-01-01","四川省 成都市 武侯区","510000","510100","510107","四川省成都市武侯区某路1号",
|
"测试企业有限公司",null,"私营企业","https://cdn.example.com/license.jpg","2020-01-01","100",
|
"https://cdn.example.com/legal-front.jpg","https://cdn.example.com/legal-back.jpg","四川省成都市武侯区某路1号",null,"owner@example.com",
|
"测试联系人","13700000001","6222000012345678","510000","510100",clearingCode,
|
null,"https://cdn.example.com/registration-account.jpg",
|
null,null);
|
}
|
|
/** 对公主体重提时要补的是开户许可证那一张(请求里走 registrationAccountUrl)。 */
|
private static MerchantOnboardingRequest registrationAccountRequest(String registrationAccountUrl) {
|
return new MerchantOnboardingRequest("enterprise","Owner",null,null,null,null,null,null,null,null,
|
null,null,"510100",null,null,null,null,null,null,null,null,null,null,null,null,null,
|
"Owner","13700000000",null,null,"510100",null,null,registrationAccountUrl,null,null);
|
}
|
|
/** 个人进件的一次完整提交(除门店照片与结算卡卡号面外的必填项也都给上)。 */
|
private static MerchantOnboardingRequest individualRequest(String clearingCode) {
|
return new MerchantOnboardingRequest(
|
"individual","测试负责人",null,"511623200211245724","fixed","2022-02-14","2032-02-14",
|
null,null,null,"四川省 成都市 武侯区","510000","510100","510107","四川省邻水县丰禾镇9组108号",
|
null,null,"其它",null,null,null,
|
"https://cdn.example.com/id-front.jpg","https://cdn.example.com/id-back.jpg","四川省邻水县丰禾镇9组108号","13700000000","owner@example.com",
|
"测试负责人","13700000000","6222000012345678","510000","510100",clearingCode,
|
"https://cdn.example.com/card-front.jpg",null,
|
"https://cdn.example.com/store-header.jpg","https://cdn.example.com/store-indoor.jpg");
|
}
|
|
/** 建一张还停在 not_started 的进件,用来观察刷新会不会去推进开通步骤。 */
|
private Map<String,Object> provisioningFixture() {
|
String key="T02P-"+System.nanoTime();
|
jdbc.update("insert into merchant(merchant_name,status,deleted) values (?,1,0)",key);
|
Long merchant=jdbc.queryForObject("select id from merchant where merchant_name=?",Long.class,key);
|
jdbc.update("insert into merchant_store(merchant_id,store_name,status,deleted) values (?,?,1,0)",merchant,key);
|
Long store=jdbc.queryForObject("select id from merchant_store where merchant_id=?",Long.class,merchant);
|
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) values (?,?,'Bank','Branch','510100','1234','****1234',?,'approved',?)",merchant,store,key,key);
|
Long card=jdbc.queryForObject("select id from merchant_bank_card where idempotency_key=?",Long.class,key);
|
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,provider_apply_no,initial_bank_card_id) values (?, ?, ?, 'enterprise','Owner','Owner','13700000000',?,'pending','6666000241335656','original-apply',?)",merchant,store,key,key,card);
|
return Map.of("principal",new MerchantPrincipal(1L,merchant,store,"owner"),"applicationNo",key);
|
}
|
|
@Test
|
void queryPermissionErrorPreservesOriginalApplicationAndCard() {
|
String key = "T02-" + System.nanoTime();
|
jdbc.update("insert into merchant(merchant_name,status,deleted) values (?,1,0)", key);
|
Long merchant = jdbc.queryForObject("select id from merchant where merchant_name=?", Long.class,key);
|
jdbc.update("insert into merchant_store(merchant_id,store_name,status,deleted) values (?,?,1,0)",merchant,key);
|
Long store = jdbc.queryForObject("select id from merchant_store where merchant_id=?",Long.class,merchant);
|
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) values (?,?,'Bank','Branch','510100','1234','****1234',?,'approved',?)",merchant,store,key,key);
|
Long card = jdbc.queryForObject("select id from merchant_bank_card where idempotency_key=?",Long.class,key);
|
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,provider_apply_no,initial_bank_card_id,wechat_config_status,business_open_status) values (?, ?, ?, 'enterprise','Owner','Owner','13700000000',?,'pending','6666000241335656','original-apply',?,'approved','approved')",merchant,store,key,key,card);
|
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","20002","resp_desc","数据权限认证失败"));
|
Map<String,Object> result=service.refreshOnboarding(new MerchantPrincipal(1L,merchant,store,"owner"),false);
|
assertThat(result).containsEntry("applicationNo",key).containsEntry("status","pending").containsEntry("syncError","汇付查询权限不足(20002),请联系平台运营核实接口权限");
|
assertThat(service.bankCards(new MerchantPrincipal(1L,merchant,store,"owner"))).isNotNull();
|
assertThat(jdbc.queryForObject("select initial_bank_card_id from merchant_onboarding_application where application_no=?",Long.class,key)).isEqualTo(card);
|
}
|
}
|