package com.talentcard.server.business.payment;
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.when;
|
|
import java.math.BigDecimal;
|
import java.util.LinkedHashMap;
|
import java.util.Map;
|
|
import com.alibaba.fastjson.JSON;
|
import com.talentcard.server.business.order.OrderRefundRequest;
|
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentCommand;
|
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway;
|
import com.talentcard.server.business.payment.huifu.HuifuPaymentQueryCommand;
|
import com.talentcard.server.business.payment.huifu.HuifuRefundCommand;
|
import com.talentcard.server.business.payment.huifu.HuifuRefundGateway;
|
import com.talentcard.server.business.payment.huifu.HuifuSignatureService;
|
import com.talentcard.server.business.portal.AppOrderCreateRequest;
|
import com.talentcard.server.business.portal.AppOrderPortalService;
|
import com.talentcard.server.common.BusinessException;
|
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.Test;
|
import org.mockito.ArgumentCaptor;
|
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|
/**
|
* Payment closure seam: an order is bound to one collecting sub-merchant at creation, the payment
|
* request and the reconciliation query use that sub-merchant, and only a verified callback or query
|
* whose merchant, order and amount match local state may mark the order paid.
|
*/
|
@SpringBootTest
|
@ActiveProfiles("test")
|
class PaymentSettlementTests {
|
|
@Autowired
|
private JdbcTemplate jdbc;
|
|
@Autowired
|
private AppOrderPortalService appOrderPortalService;
|
|
@Autowired
|
private HuifuPaymentService paymentService;
|
|
@Autowired
|
private HuifuPaymentCallbackService callbackService;
|
|
@Autowired
|
private PaymentReconciliationService reconciliationService;
|
|
@MockitoBean
|
private HuifuSignatureService signatureService;
|
|
@MockitoBean
|
private HuifuMiniProgramPaymentGateway paymentGateway;
|
|
@MockitoBean
|
private HuifuRefundGateway refundGateway;
|
|
@Autowired
|
private HuifuRefundService refundService;
|
|
private Long talentId;
|
private Long merchantId;
|
private Long productId;
|
private Long onboardingId;
|
private String huifuId;
|
private String suffix;
|
|
@BeforeEach
|
void prepareData() {
|
suffix = String.valueOf(System.nanoTime());
|
when(signatureService.verifyCallbackPayload(any(), any())).thenReturn(true);
|
// Only the transaction a test creates itself may look old enough for reconciliation.
|
jdbc.update("update payment_transaction set created_at = now(), last_reconciled_at = null where status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CLOSING', 'CANCEL_UNCONFIRMED')");
|
jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)", "payment-openid-" + suffix, "Payment user", "13900000001");
|
talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
|
jdbc.update("insert into talent_card(talent_id, card_no, card_type, card_level, status, deleted) values (?, ?, 'PERSONAL', ?, 1, 0)", talentId, "TC-PAY-" + suffix, "卓越人才卡");
|
jdbc.update("insert into merchant(merchant_name, contact_mobile, address, status, deleted) values (?, ?, ?, 1, 0)", "Payment merchant " + suffix, "08120000001", "Test address");
|
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, "Payment store " + suffix);
|
Long storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class);
|
huifuId = shortReference("H", suffix);
|
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', 'Payment owner', 'Payment owner', '13900000001', ?, 'approved', ?, '0', '1', 'NORMAL', current_timestamp)
|
""", merchantId, storeId, "MO-PAY-" + suffix, "payment-onboarding-" + suffix, huifuId);
|
onboardingId = jdbc.queryForObject("select max(id) from merchant_onboarding_application", Long.class);
|
jdbc.update("""
|
insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, city_code, card_tail, card_masked,
|
card_hash, status, idempotency_key, provider_token_no, reviewed_at)
|
values (?, ?, '中国建设银行', '攀枝花东区支行', '510400', '8888', '**** **** **** 8888', ?, 'approved', ?, ?, current_timestamp)
|
""", merchantId, storeId, "hash-payment-" + suffix, "payment-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, onboardingId);
|
// 收款就绪要求微信支付业务开通与实名/授权都有通道证据。这里的商户建模成已经办完这两步,
|
// 否则下单会被「微信收款能力未就绪」挡住——那些是开通流程的测试,不是支付结算的测试。
|
jdbc.update("insert into merchant_onboarding_progress(application_id, stages_json) values (?, '{\"wechat\":\"confirmed\",\"realname\":\"confirmed\"}')", onboardingId);
|
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 (?, ?, 'food', 'Payment package', 100.00, 60.00, 0.00, 10, 2, 1, 0)
|
""", "PAYMENT-PRODUCT-" + suffix, merchantId);
|
productId = jdbc.queryForObject("select max(id) from merchant_product", Long.class);
|
}
|
|
@Test
|
void fixesTheCollectingSubMerchantOnTheOrderAndUsesItForPayment() {
|
String orderNo = createOrder("GROUP_BUY");
|
when(paymentGateway.createMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
|
"trans_stat", "S", "resp_code", "00000000", "pay_info", "{\"appId\":\"wx-test\"}")));
|
|
Map<String, Object> result = paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "payment-key-" + suffix);
|
|
assertThat(result).containsEntry("paymentStatus", "PAYMENT_PENDING");
|
ArgumentCaptor<HuifuMiniProgramPaymentCommand> command = ArgumentCaptor.forClass(HuifuMiniProgramPaymentCommand.class);
|
verify(paymentGateway).createMiniProgramPayment(command.capture());
|
assertThat(command.getValue().huifuId()).isEqualTo(huifuId);
|
assertThat(command.getValue().amountInCent()).isEqualTo(6000L);
|
assertThat(jdbc.queryForMap("select collect_onboarding_id, collect_huifu_id from benefit_order where order_no = ?", orderNo))
|
.containsEntry("collect_onboarding_id", onboardingId)
|
.containsEntry("collect_huifu_id", huifuId);
|
assertThat(jdbc.queryForMap("select provider_huifu_id, amount, status from payment_transaction where payment_no = ?", result.get("paymentNo")))
|
.containsEntry("provider_huifu_id", huifuId)
|
.containsEntry("amount", 6000L)
|
.containsEntry("status", "PREPAY_CREATED");
|
}
|
|
@Test
|
void refusesPaymentWhenTheOrderCarriesNoCollectingSubMerchant() {
|
String orderNo = createOrder("GROUP_BUY");
|
jdbc.update("update benefit_order set collect_huifu_id = null where order_no = ?", orderNo);
|
|
assertThatThrownBy(() -> paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "payment-key-" + suffix))
|
.isInstanceOf(BusinessException.class)
|
.hasMessageContaining("收款子商户");
|
verify(paymentGateway, never()).createMiniProgramPayment(any());
|
}
|
|
@Test
|
void aVerifiedCallbackSettlesTheOrderOnce() {
|
String orderNo = createOrder("GROUP_BUY");
|
String paymentNo = insertPendingPayment(orderNo, 6000L, huifuId);
|
String payload = callbackPayload(paymentNo, huifuId, "60.00", "S", "00000000");
|
|
assertThat(callbackService.handle(payload, "signature")).isEqualTo("RECV_ORD_ID_" + paymentNo);
|
|
assertThat(jdbc.queryForMap("select status, provider_response_code, provider_huifu_id from payment_transaction where payment_no = ?", paymentNo))
|
.containsEntry("status", "SUCCEEDED")
|
.containsEntry("provider_huifu_id", huifuId);
|
assertThat(jdbc.queryForMap("select order_status, verify_status from benefit_order where order_no = ?", orderNo))
|
.containsEntry("order_status", 1);
|
assertThat(callbackEvent(paymentNo)).containsEntry("process_status", "PROCESSED");
|
}
|
|
@Test
|
void aRepeatedIdenticalCallbackChangesNoBusinessState() {
|
String orderNo = createOrder("GROUP_BUY");
|
String paymentNo = insertPendingPayment(orderNo, 6000L, huifuId);
|
String payload = callbackPayload(paymentNo, huifuId, "60.00", "S", "00000000");
|
callbackService.handle(payload, "signature");
|
markSettlementSentinel(paymentNo, orderNo);
|
|
assertThat(callbackService.handle(payload, "signature")).isEqualTo("RECV_ORD_ID_" + paymentNo);
|
|
assertSettlementSentinel(paymentNo, orderNo);
|
assertThat(jdbc.queryForObject("select processed_at = '2000-01-01 00:00:00' from payment_callback_event where provider_request_no = ?", Boolean.class, paymentNo))
|
.as("an identical duplicate notification must not be processed again")
|
.isTrue();
|
}
|
|
@Test
|
void aChangedDuplicateSuccessCallbackChangesNoBusinessState() {
|
String orderNo = createOrder("GROUP_BUY");
|
String paymentNo = insertPendingPayment(orderNo, 6000L, huifuId);
|
callbackService.handle(callbackPayload(paymentNo, huifuId, "60.00", "S", "00000000"), "signature");
|
markSettlementSentinel(paymentNo, orderNo);
|
|
// A retry carrying an extra provider field has a different payload hash but the same fact.
|
Map<String, Object> repeated = callbackData(paymentNo, huifuId, "60.00", "S", "00000000");
|
repeated.put("hf_seq_id", "HF-RETRY-1");
|
assertThat(callbackService.handle(JSON.toJSONString(repeated), "signature")).isEqualTo("RECV_ORD_ID_" + paymentNo);
|
|
assertSettlementSentinel(paymentNo, orderNo);
|
}
|
|
@Test
|
void anAmountMismatchNeverMarksTheOrderPaid() {
|
String orderNo = createOrder("GROUP_BUY");
|
String paymentNo = insertPendingPayment(orderNo, 6000L, huifuId);
|
|
callbackService.handle(callbackPayload(paymentNo, huifuId, "0.01", "S", "00000000"), "signature");
|
|
assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo)).isEqualTo("PREPAY_CREATED");
|
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
|
assertThat(callbackEvent(paymentNo)).containsEntry("process_status", "AMOUNT_MISMATCH");
|
assertThat(openIssueCount("PAYMENT_AMOUNT_MISMATCH", paymentNo)).isEqualTo(1L);
|
}
|
|
@Test
|
void aMerchantMismatchNeverMarksTheOrderPaid() {
|
String orderNo = createOrder("GROUP_BUY");
|
String paymentNo = insertPendingPayment(orderNo, 6000L, huifuId);
|
|
callbackService.handle(callbackPayload(paymentNo, shortReference("X", suffix), "60.00", "S", "00000000"), "signature");
|
|
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
|
assertThat(callbackEvent(paymentNo)).containsEntry("process_status", "MERCHANT_MISMATCH");
|
assertThat(openIssueCount("PAYMENT_MERCHANT_MISMATCH", paymentNo)).isEqualTo(1L);
|
}
|
|
@Test
|
void aSuccessCallbackWithoutAMerchantNumberNeverMarksTheOrderPaid() {
|
String orderNo = createOrder("GROUP_BUY");
|
String paymentNo = insertPendingPayment(orderNo, 6000L, huifuId);
|
|
callbackService.handle(callbackPayload(paymentNo, "", "60.00", "S", "00000000"), "signature");
|
|
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
|
assertThat(callbackEvent(paymentNo)).containsEntry("process_status", "MERCHANT_UNVERIFIED");
|
assertThat(openIssueCount("PAYMENT_MERCHANT_UNVERIFIED", paymentNo)).isEqualTo(1L);
|
}
|
|
@Test
|
void anUnknownRequestNumberIsAcknowledgedWithoutStateChange() {
|
String unknownNo = "P-UNKNOWN-" + suffix;
|
|
assertThat(callbackService.handle(callbackPayload(unknownNo, huifuId, "60.00", "S", "00000000"), "signature"))
|
.isEqualTo("RECV_ORD_ID_" + unknownNo);
|
|
assertThat(callbackEvent(unknownNo)).containsEntry("process_status", "UNMATCHED");
|
}
|
|
@Test
|
void anUnknownTransactionStatusNeverSettlesThePayment() {
|
String orderNo = createOrder("GROUP_BUY");
|
String paymentNo = insertPendingPayment(orderNo, 6000L, huifuId);
|
|
callbackService.handle(callbackPayload(paymentNo, huifuId, "60.00", "X", "00000000"), "signature");
|
|
assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo)).isEqualTo("PREPAY_CREATED");
|
assertThat(callbackEvent(paymentNo)).containsEntry("process_status", "UNKNOWN_STATUS");
|
}
|
|
@Test
|
void aProcessingNotificationKeepsThePaymentPending() {
|
String orderNo = createOrder("GROUP_BUY");
|
String paymentNo = insertPendingPayment(orderNo, 6000L, huifuId);
|
|
callbackService.handle(callbackPayload(paymentNo, huifuId, "60.00", "P", "00000000"), "signature");
|
|
assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo)).isEqualTo("PREPAY_CREATED");
|
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
|
assertThat(callbackEvent(paymentNo)).containsEntry("process_status", "PENDING_QUERY");
|
}
|
|
@Test
|
void aLateSuccessForACancelledOrderIsRecordedInsteadOfFulfilled() {
|
String orderNo = createOrder("GROUP_BUY");
|
String paymentNo = insertPendingPayment(orderNo, 6000L, huifuId);
|
jdbc.update("update payment_transaction set status='CLOSED', closed_at=now() where payment_no = ?", paymentNo);
|
jdbc.update("update benefit_order set order_status = 2 where order_no = ?", orderNo);
|
|
callbackService.handle(callbackPayload(paymentNo, huifuId, "60.00", "S", "00000000"), "signature");
|
|
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isEqualTo(2);
|
assertThat(callbackEvent(paymentNo)).containsEntry("process_status", "PAYMENT_CLOSED");
|
assertThat(openIssueCount("PAYMENT_ORDER_CANCELLED", paymentNo)).isEqualTo(1L);
|
}
|
|
@Test
|
void aNestedCallbackPayloadSettlesThePayment() {
|
String orderNo = createOrder("GROUP_BUY");
|
String paymentNo = insertPendingPayment(orderNo, 6000L, huifuId);
|
String envelope = JSON.toJSONString(Map.of(
|
"resp_code", "00000000",
|
"resp_desc", "成功",
|
"data", JSON.toJSONString(callbackData(paymentNo, huifuId, "60.00", "S", "00000000"))));
|
|
callbackService.handle(envelope, "signature");
|
|
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isEqualTo(1);
|
}
|
|
@Test
|
void aProviderQuerySettlesThePaymentAndVerifiesStoreConsumption() {
|
String orderNo = createOrder("STORE_CONSUME");
|
String paymentNo = insertPendingPayment(orderNo, 6000L, huifuId);
|
// 支付查询回执里通道全局流水号的字段是 org_hf_seq_id(hf_seq_id 只出现在异步通知里)。
|
// 用真实字段名,才能保证这条断言真的守住落库行为。
|
when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
|
"trans_stat", "S", "resp_code", "00000000", "trans_amt", "60.00",
|
"huifu_id", huifuId, "org_hf_seq_id", "HF-QUERY-1")));
|
|
Map<String, Object> result = reconciliationService.reconcile(5);
|
|
assertThat(result).containsEntry("paymentChecked", 1);
|
ArgumentCaptor<HuifuPaymentQueryCommand> command = ArgumentCaptor.forClass(HuifuPaymentQueryCommand.class);
|
verify(paymentGateway).queryMiniProgramPayment(command.capture());
|
assertThat(command.getValue().huifuId()).isEqualTo(huifuId);
|
assertThat(jdbc.queryForMap("select order_status, verify_status from benefit_order where order_no = ?", orderNo))
|
.containsEntry("order_status", 1)
|
.containsEntry("verify_status", 1);
|
assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo)).isEqualTo("SUCCEEDED");
|
assertThat(jdbc.queryForObject("select provider_transaction_no from payment_transaction where payment_no = ?", String.class, paymentNo))
|
.as("查询落账必须把通道全局流水号写进 provider_transaction_no,否则退款和排查都失去定位依据")
|
.isEqualTo("HF-QUERY-1");
|
}
|
|
@Test
|
void aProviderQueryWithADifferentAmountLeavesThePaymentPending() {
|
String orderNo = createOrder("GROUP_BUY");
|
String paymentNo = insertPendingPayment(orderNo, 6000L, huifuId);
|
when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
|
"trans_stat", "S", "resp_code", "00000000", "trans_amt", "0.01", "huifu_id", huifuId)));
|
|
reconciliationService.reconcile(5);
|
|
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
|
assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo)).isEqualTo("PREPAY_CREATED");
|
assertThat(openIssueCount("PAYMENT_AMOUNT_MISMATCH", paymentNo)).isEqualTo(1L);
|
}
|
|
@Test
|
void aPendingProviderQueryRecordsATimeoutIssue() {
|
String orderNo = createOrder("GROUP_BUY");
|
String paymentNo = insertPendingPayment(orderNo, 6000L, huifuId);
|
when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
|
"trans_stat", "P", "resp_code", "00000000", "trans_amt", "60.00", "huifu_id", huifuId)));
|
|
reconciliationService.reconcile(5);
|
|
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
|
assertThat(openIssueCount("PAYMENT_PENDING_TIMEOUT", paymentNo)).isEqualTo(1L);
|
}
|
|
@Test
|
void aQueryWithoutACollectingSubMerchantIsRecordedInsteadOfQueried() {
|
String orderNo = createOrder("GROUP_BUY");
|
String paymentNo = insertPendingPayment(orderNo, 6000L, huifuId);
|
jdbc.update("update payment_transaction set provider_huifu_id = null where payment_no = ?", paymentNo);
|
jdbc.update("update benefit_order set collect_huifu_id = null where order_no = ?", orderNo);
|
|
reconciliationService.reconcile(5);
|
|
verify(paymentGateway, never()).queryMiniProgramPayment(any());
|
assertThat(openIssueCount("PAYMENT_COLLECTOR_UNKNOWN", paymentNo)).isEqualTo(1L);
|
}
|
|
@Test
|
void refundsThroughTheCollectingSubMerchantOfThePayment() {
|
String orderNo = createOrder("GROUP_BUY");
|
String paymentNo = insertPendingPayment(orderNo, 6000L, huifuId);
|
callbackService.handle(callbackPayload(paymentNo, huifuId, "60.00", "S", "00000000"), "signature");
|
when(refundGateway.createRefund(any())).thenReturn(providerResponse(Map.of(
|
"trans_stat", "S", "resp_code", "00000000", "hf_seq_id", "HF-REFUND-1")));
|
|
Map<String, Object> refund = refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("60.00"), "test", "tester"));
|
|
ArgumentCaptor<HuifuRefundCommand> command = ArgumentCaptor.forClass(HuifuRefundCommand.class);
|
verify(refundGateway).createRefund(command.capture());
|
assertThat(command.getValue().huifuId()).isEqualTo(huifuId);
|
assertThat(jdbc.queryForMap("select provider_huifu_id, refund_status from order_refund where refund_no = ?", refund.get("refundNo")))
|
.containsEntry("provider_huifu_id", huifuId)
|
.containsEntry("refund_status", 2);
|
}
|
|
/** Creates a pending order through the real creation path so the collecting sub-merchant is bound. */
|
private String createOrder(String orderType) {
|
Map<String, Object> created = appOrderPortalService.create(talentId, new AppOrderCreateRequest(merchantId, productId, null), "payment-order-" + System.nanoTime());
|
String orderNo = String.valueOf(created.get("orderNo"));
|
jdbc.update("update benefit_order set order_type = ?, verify_status = 0 where order_no = ?", orderType, orderNo);
|
return orderNo;
|
}
|
|
private String insertPendingPayment(String orderNo, long amount, String paymentHuifuId) {
|
String paymentNo = "P-TX-" + System.nanoTime();
|
Map<String, Object> order = jdbc.queryForMap("select id, merchant_id from benefit_order where order_no = ?", orderNo);
|
jdbc.update("""
|
insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id,
|
provider_huifu_id, amount, status, idempotency_key, created_at)
|
values (?, 'HUIFU', ?, ?, ?, ?, ?, ?, 'PREPAY_CREATED', ?, now() - interval 10 minute)
|
""", paymentNo, order.get("id"), orderNo, talentId, order.get("merchant_id"), paymentHuifuId, amount, "payment-tx-" + paymentNo);
|
return paymentNo;
|
}
|
|
private Map<String, Object> callbackData(String paymentNo, String callbackHuifuId, String amountYuan, String status, String code) {
|
Map<String, Object> data = new LinkedHashMap<>();
|
data.put("resp_code", code);
|
data.put("resp_desc", "成功");
|
data.put("trans_stat", status);
|
data.put("req_seq_id", paymentNo);
|
data.put("huifu_id", callbackHuifuId);
|
data.put("trans_amt", amountYuan);
|
data.put("hf_seq_id", "HF-" + paymentNo);
|
return data;
|
}
|
|
private String callbackPayload(String paymentNo, String callbackHuifuId, String amountYuan, String status, String code) {
|
return JSON.toJSONString(callbackData(paymentNo, callbackHuifuId, amountYuan, status, code));
|
}
|
|
private Map<String, Object> providerResponse(Map<String, Object> data) {
|
return Map.of("data", data);
|
}
|
|
private Map<String, Object> callbackEvent(String requestNo) {
|
return jdbc.queryForMap("select process_status from payment_callback_event where provider_request_no = ?", requestNo);
|
}
|
|
private Long openIssueCount(String type, String businessNo) {
|
return jdbc.queryForObject("""
|
select count(*) from payment_reconciliation_issue
|
where issue_type = ? and business_no = ? and status = 'open'
|
""", Long.class, type, businessNo);
|
}
|
|
/** Moves settled timestamps into the past so a duplicate that re-settles becomes detectable. */
|
private void markSettlementSentinel(String paymentNo, String orderNo) {
|
jdbc.update("update payment_transaction set paid_at = '2000-01-01 00:00:00' where payment_no = ?", paymentNo);
|
jdbc.update("update benefit_order set updated_at = '2000-01-01 00:00:00' where order_no = ?", orderNo);
|
jdbc.update("update payment_callback_event set processed_at = '2000-01-01 00:00:00' where provider_request_no = ?", paymentNo);
|
}
|
|
private void assertSettlementSentinel(String paymentNo, String orderNo) {
|
assertThat(count("select count(*) from payment_transaction where payment_no = ? and paid_at = '2000-01-01 00:00:00'", paymentNo))
|
.as("duplicate notification must not settle the payment again")
|
.isEqualTo(1);
|
assertThat(count("select count(*) from benefit_order where order_no = ? and updated_at = '2000-01-01 00:00:00'", orderNo))
|
.as("duplicate notification must not update the order")
|
.isEqualTo(1);
|
assertThat(count("select count(*) from benefit_order where order_no = ? and order_status = 1", orderNo)).isEqualTo(1);
|
}
|
|
private long count(String sql, Object... args) {
|
Long value = jdbc.queryForObject(sql, Long.class, args);
|
return value == null ? 0 : value;
|
}
|
|
/** 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));
|
}
|
}
|