feat(procurement): implement sample fulfillment and settled fee workflow
This commit is contained in:
@@ -45,11 +45,11 @@ class BusinessFeeSourceEventServiceTest {
|
||||
when(jdbc.queryForList(startsWith("SELECT payload_hash,status,result_resource_id"),
|
||||
eq("LASTLEG"), eq(event.eventId()))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status FROM fin_fee_source"),
|
||||
eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(List.of());
|
||||
eq("LASTLEG"), eq("OVERSEAS_WAREHOUSE_FEE"), eq(701L), eq(1))).thenReturn(List.of());
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system=?"),
|
||||
eq(Integer.class), eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L))).thenReturn(0);
|
||||
eq(Integer.class), eq("LASTLEG"), eq("OVERSEAS_WAREHOUSE_FEE"), eq(701L))).thenReturn(0);
|
||||
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
|
||||
eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L);
|
||||
eq("LASTLEG"), eq("OVERSEAS_WAREHOUSE_FEE"), eq(701L), eq(1))).thenReturn(901L);
|
||||
|
||||
assertThat(service.consume(event).eventId()).isEqualTo(event.eventId());
|
||||
|
||||
@@ -65,21 +65,21 @@ class BusinessFeeSourceEventServiceTest {
|
||||
|
||||
private EventEnvelope eventWithoutInbound() {
|
||||
BusinessFeeSourceChangedV1 unsigned = new BusinessFeeSourceChangedV1(null, "LASTLEG",
|
||||
"OVERSEAS_PROCUREMENT_FEE", "701", 1, "10001", "客户A", "PO-US-001",
|
||||
"OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null,
|
||||
"OVERSEAS_WAREHOUSE_FEE", "701", 1, "10001", "客户A", "PO-US-001",
|
||||
"WAREHOUSE_SERVICE", "801", "PO-US-001", null, null, null, null, null,
|
||||
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
|
||||
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
|
||||
new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE,
|
||||
"2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", null);
|
||||
"2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", null, null);
|
||||
String sourceHash = EventPayloadHash.sha256(
|
||||
EventPayloadCanonicalizer.canonicalize(json.valueToTree(unsigned)));
|
||||
BusinessFeeSourceChangedV1 payload = new BusinessFeeSourceChangedV1("PUBLISHED", "LASTLEG",
|
||||
"OVERSEAS_PROCUREMENT_FEE", "701", 1, "10001", "客户A", "PO-US-001",
|
||||
"OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null,
|
||||
"OVERSEAS_WAREHOUSE_FEE", "701", 1, "10001", "客户A", "PO-US-001",
|
||||
"WAREHOUSE_SERVICE", "801", "PO-US-001", null, null, null, null, null,
|
||||
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
|
||||
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
|
||||
new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE,
|
||||
"2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", sourceHash);
|
||||
"2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", sourceHash, null);
|
||||
JsonNode node = json.valueToTree(payload);
|
||||
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||
return new EventEnvelope("123e4567-e89b-12d3-a456-426614174701",
|
||||
@@ -237,4 +237,53 @@ class BusinessFeeSourceEventServiceTest {
|
||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class));
|
||||
verify(jdbc, never()).update(startsWith("UPDATE fin_cost"), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void procurementWithoutConsumptionReferenceNeverEntersReceivablePool() {
|
||||
EventEnvelope event = procurementEvent(false);
|
||||
assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("必须引用成功实扣");
|
||||
noFinancialWrites();
|
||||
}
|
||||
|
||||
@Test
|
||||
void settledProcurementUsesVerifiedActualConsumptionAndNeverOriginalEstimate() {
|
||||
EventEnvelope event = procurementEvent(true);
|
||||
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
|
||||
eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L);
|
||||
when(jdbc.queryForList(startsWith("SELECT l.amount,c.result_json"), eq("command-1"), eq(9L), eq(72L),
|
||||
eq(10001L), eq("PROCUREMENT_FEE"), eq("PO-US-001"))).thenReturn(List.of(Map.of(
|
||||
"amount", new BigDecimal("-120.00"), "result_json", """
|
||||
{"result":"SUCCEEDED","operation":"SETTLE_ACTUAL","commandEventId":"command-1",
|
||||
"walletHoldId":"9","walletLedgerId":"72","customerId":10001,
|
||||
"businessType":"PROCUREMENT_FEE","businessNo":"PO-US-001","amount":120.00}
|
||||
""")));
|
||||
service.consume(event);
|
||||
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), eq(9L), eq(901L), eq(10001L));
|
||||
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source f JOIN fin_wallet_hold"), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void incorrectOrCrossCustomerConsumptionIsRejectedInsteadOfBecomingAvailable() {
|
||||
EventEnvelope event = procurementEvent(true);
|
||||
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
|
||||
eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L);
|
||||
assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("不匹配");
|
||||
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), any(Object[].class));
|
||||
}
|
||||
|
||||
private EventEnvelope procurementEvent(boolean reference) {
|
||||
EventEnvelope base = eventWithoutInbound();
|
||||
ObjectNode payload = (ObjectNode) base.payload().deepCopy();
|
||||
payload.put("sourceType", "OVERSEAS_PROCUREMENT_FEE");
|
||||
payload.put("businessType", "PROCUREMENT_FEE");
|
||||
if (reference) {
|
||||
payload.putObject("walletSettlement").put("commandEventId", "command-1").put("holdId", "9").put("ledgerId", "72");
|
||||
}
|
||||
payload.putNull("action"); payload.putNull("sourceHash");
|
||||
payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
payload.put("action", "PUBLISHED");
|
||||
return new EventEnvelope(base.eventId(), base.eventType(), base.eventVersion(), base.occurredAt(), base.tenantId(),
|
||||
base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null,
|
||||
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,38 @@ class WalletCommandEventServiceTest {
|
||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_wallet_event_outbox"), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void actualSettlementReportsActualChargeInsteadOfOriginalEstimate() throws Exception {
|
||||
EventEnvelope event = envelope("SETTLE_ACTUAL", 31L, new BigDecimal("60.00"));
|
||||
when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(event.eventId())))
|
||||
.thenReturn(List.of(event.payloadHash()));
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0);
|
||||
when(wallet.settleActual(eq(31L), eq("OVERSEAS_PROCUREMENT_FEE"), eq("PO-1"),
|
||||
eq("freeze-procurement-1"), argThat((BigDecimal amount) -> amount.compareTo(new BigDecimal("60.00")) == 0), any())).thenReturn(
|
||||
new WalletHold(9L, 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("100.00"),
|
||||
new BigDecimal("100.00"), BigDecimal.ZERO, "SETTLED", 71L, 72L, null, null, null));
|
||||
service.consume(event);
|
||||
var resultJson = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="), eq("SUCCEEDED"),
|
||||
resultJson.capture(), eq(event.eventId()));
|
||||
JsonNode result = mapper.readTree(resultJson.getValue());
|
||||
assertThat(result.path("amount").decimalValue()).isEqualByComparingTo("60.00");
|
||||
assertThat(result.path("holdStatus").asText()).isEqualTo("SETTLED");
|
||||
assertThat(result.path("walletHoldId").asText()).isEqualTo("9");
|
||||
assertThat(result.path("walletLedgerId").asText()).isEqualTo("72");
|
||||
verify(jdbc).update(contains("f.amount=-l.amount"), eq(9L));
|
||||
verify(wallet, never()).release(anyLong(), anyString(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void actualSettlementRejectsZeroAndMissingAmountsBeforeOpeningTransaction() {
|
||||
for (BigDecimal amount : new BigDecimal[]{null, BigDecimal.ZERO, new BigDecimal("0.001")}) {
|
||||
assertThatThrownBy(() -> service.consume(envelope("SETTLE_ACTUAL", 31L, amount)))
|
||||
.isInstanceOf(FinanceEventException.class);
|
||||
}
|
||||
verifyNoInteractions(jdbc, wallet, transactions);
|
||||
}
|
||||
|
||||
@Test
|
||||
void losingInboxInsertMustRecheckThePersistedHash() {
|
||||
EventEnvelope event = envelope("FREEZE", 31L, new BigDecimal("10.00"));
|
||||
@@ -133,9 +165,44 @@ class WalletCommandEventServiceTest {
|
||||
}
|
||||
|
||||
private EventEnvelope envelope(String operation, long customerId, BigDecimal amount) {
|
||||
return envelope(operation, customerId, amount, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refundRequiresOriginalConsumptionBeforeAnyWalletCall() {
|
||||
assertThatThrownBy(() -> service.consume(envelope("REFUND", 31L, BigDecimal.TEN)))
|
||||
.isInstanceOf(FinanceEventException.class);
|
||||
verifyNoInteractions(jdbc, wallet);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refundHasItsOwnLedgerReferenceAndNeverPretendsToSettleAHold() throws Exception {
|
||||
EventEnvelope event = envelope("REFUND", 31L, BigDecimal.TEN, "72");
|
||||
when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(event.eventId())))
|
||||
.thenReturn(List.of(event.payloadHash()));
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0);
|
||||
var refunded = new com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry(73L, "AC-TEST", "REFUND",
|
||||
BigDecimal.TEN, BigDecimal.TEN, BigDecimal.ZERO, BigDecimal.TEN, BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, null, "BUSINESS_REFUND", "PO-1", java.time.LocalDate.now(), null,
|
||||
"AC-ORIGINAL", "业务系统", "采购快递费", null);
|
||||
when(wallet.refundConsumption(eq(31L), eq(72L), eq("PO-1"), eq("freeze-procurement-1"),
|
||||
any(), eq(0L), anyString(), anyString())).thenReturn(refunded);
|
||||
service.consume(event);
|
||||
var capture = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="), eq("SUCCEEDED"),
|
||||
capture.capture(), eq(event.eventId()));
|
||||
JsonNode result = mapper.readTree(capture.getValue());
|
||||
assertThat(result.path("walletLedgerId").asText()).isEqualTo("73");
|
||||
assertThat(result.path("walletHoldId").isNull()).isTrue();
|
||||
assertThat(result.path("holdStatus").asText()).isEqualTo("REFUNDED");
|
||||
verify(wallet, never()).settle(anyLong(), anyString(), anyString(), anyString(), any());
|
||||
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class));
|
||||
}
|
||||
|
||||
private EventEnvelope envelope(String operation, long customerId, BigDecimal amount, String originalLedgerId) {
|
||||
WalletCommandRequestedV1 command = new WalletCommandRequestedV1(operation, customerId,
|
||||
"OVERSEAS_PROCUREMENT_FEE", "PO-1", amount, "采购快递费",
|
||||
"freeze-procurement-1", Instant.parse("2026-09-05T01:00:00Z"));
|
||||
"freeze-procurement-1", Instant.parse("2026-09-05T01:00:00Z"), originalLedgerId);
|
||||
JsonNode payload = mapper.valueToTree(command);
|
||||
return new EventEnvelope(UUID.randomUUID().toString(), EventTypes.WALLET_COMMAND_REQUESTED,
|
||||
WalletCommandRequestedV1.VERSION, Instant.parse("2026-09-05T01:00:00Z"), String.valueOf(customerId), "WALLET_HOLD", "PO-1", 1,
|
||||
|
||||
@@ -190,7 +190,7 @@ class WalletCommandTransactionTest {
|
||||
|
||||
private EventEnvelope event(String id, String amount) {
|
||||
var command = new WalletCommandRequestedV1("FREEZE", 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1",
|
||||
new BigDecimal(amount), "fee", "freeze-1", Instant.parse("2026-09-05T01:00:00Z"));
|
||||
new BigDecimal(amount), "fee", "freeze-1", Instant.parse("2026-09-05T01:00:00Z"), null);
|
||||
var payload = mapper.valueToTree(command);
|
||||
return new EventEnvelope(id, EventTypes.WALLET_COMMAND_REQUESTED, WalletCommandRequestedV1.VERSION,
|
||||
Instant.now(), "31", "WALLET_HOLD", "PO-1", 1,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.finance.client.FinanceFileClient;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletAccount;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class ProcurementActualSettlementTest {
|
||||
private final FinanceWalletService service = new FinanceWalletService(mock(JdbcTemplate.class),
|
||||
new ObjectMapper().findAndRegisterModules(), mock(FinanceFileClient.class),
|
||||
mock(FinanceDocumentNumberGenerator.class), "token", "", "test", "test");
|
||||
|
||||
@Test
|
||||
void smallerActualChargeReleasesRemainderAndKeepsOtherReservations() {
|
||||
WalletAccount before = account("200", "150", "0", "0", "0", null);
|
||||
WalletAccount after = service.settleActualAccount(before, hold("100", "100", "0"), n("60"));
|
||||
assertThat(after.actualBalance()).isEqualByComparingTo("140");
|
||||
assertThat(after.frozenBalance()).isEqualByComparingTo("50");
|
||||
assertThat(after.availableBalance()).isEqualByComparingTo("90");
|
||||
}
|
||||
|
||||
@Test
|
||||
void excessUsesAvailableCashThenActiveCreditWithoutTouchingOtherHolds() {
|
||||
WalletAccount before = account("120", "130", "100", "0", "30", LocalDate.now().plusDays(1));
|
||||
WalletAccount after = service.settleActualAccount(before, hold("100", "70", "30"), n("150"));
|
||||
assertThat(after.actualBalance()).isEqualByComparingTo("30");
|
||||
assertThat(after.creditUsed()).isEqualByComparingTo("60");
|
||||
assertThat(after.frozenBalance()).isEqualByComparingTo("30");
|
||||
assertThat(after.creditFrozen()).isZero();
|
||||
assertThat(after.availableBalance()).isEqualByComparingTo("40");
|
||||
}
|
||||
|
||||
@Test
|
||||
void insufficientExcessRejectsWithoutChangingOriginalAccount() {
|
||||
WalletAccount before = account("120", "100", "0", "0", "0", null);
|
||||
assertThatThrownBy(() -> service.settleActualAccount(before, hold("100", "100", "0"), n("120.01")))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(e -> ((FinanceEventException) e).code()).isEqualTo("FIN-WALLET-INSUFFICIENT");
|
||||
assertThat(before.actualBalance()).isEqualByComparingTo("120");
|
||||
assertThat(before.frozenBalance()).isEqualByComparingTo("100");
|
||||
}
|
||||
|
||||
@Test
|
||||
void partialChargeUsesReservedCashBeforeCredit() {
|
||||
WalletAccount before = account("70", "100", "100", "0", "30", LocalDate.now().plusDays(1));
|
||||
WalletAccount after = service.settleActualAccount(before, hold("100", "70", "30"), n("60"));
|
||||
assertThat(after.actualBalance()).isEqualByComparingTo("10");
|
||||
assertThat(after.creditUsed()).isZero();
|
||||
assertThat(after.creditFrozen()).isZero();
|
||||
assertThat(after.frozenBalance()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredCreditAllowsOriginalReservationButNeverNewCredit() {
|
||||
WalletAccount before = account("20", "100", "100", "0", "80", LocalDate.now().minusDays(1));
|
||||
WalletAccount after = service.settleActualAccount(before, hold("100", "20", "80"), n("100"));
|
||||
assertThat(after.actualBalance()).isEqualByComparingTo("-80");
|
||||
assertThat(after.creditUsed()).isZero();
|
||||
assertThat(after.frozenBalance()).isZero();
|
||||
assertThatThrownBy(() -> service.settleActualAccount(before, hold("100", "20", "80"), n("100.01")))
|
||||
.isInstanceOf(FinanceEventException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidActualAmountMustUseReleaseOrCorrectPrecision() {
|
||||
WalletAccount before = account("200", "100", "0", "0", "0", null);
|
||||
for (String value : new String[]{"0", "-1", "0.001"}) {
|
||||
assertThatThrownBy(() -> service.settleActualAccount(before, hold("100", "100", "0"), n(value)))
|
||||
.isInstanceOf(FinanceEventException.class);
|
||||
}
|
||||
}
|
||||
|
||||
private WalletAccount account(String actual, String frozen, String limit, String used, String creditFrozen, LocalDate expiry) {
|
||||
return service.accountState(n(actual), n(frozen), n(limit), n(used), n(creditFrozen), expiry, 1);
|
||||
}
|
||||
|
||||
private WalletHold hold(String amount, String cash, String credit) {
|
||||
return new WalletHold(1L, 9L, "PROCUREMENT_FEE", "PF-TEST", n(amount), n(cash), n(credit),
|
||||
"FROZEN", 1L, null, null, null, null);
|
||||
}
|
||||
|
||||
private static BigDecimal n(String value) { return new BigDecimal(value); }
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.finance.client.FinanceFileClient;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.dao.TransientDataAccessResourceException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** Executes the real wallet service against transactional JDBC; no mocked money mutations. */
|
||||
class ProcurementSettlementTransactionTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private TransactionTemplate tx;
|
||||
private FinanceWalletService wallet;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
JdbcDataSource source = new JdbcDataSource();
|
||||
source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000");
|
||||
jdbc = spy(new JdbcTemplate(source));
|
||||
tx = new TransactionTemplate(new DataSourceTransactionManager(source));
|
||||
jdbc.execute("CREATE TABLE fin_wallet_account(customer_id BIGINT PRIMARY KEY,currency_code VARCHAR(3),actual_balance DECIMAL(18,2),frozen_balance DECIMAL(18,2),available_balance DECIMAL(18,2),credit_limit DECIMAL(18,2),credit_used DECIMAL(18,2),credit_frozen DECIMAL(18,2),credit_expires_on DATE,version INT,created_by BIGINT,updated_by BIGINT,updated_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE fin_wallet_hold(id BIGINT AUTO_INCREMENT PRIMARY KEY,customer_id BIGINT,business_type VARCHAR(32),business_no VARCHAR(128),amount DECIMAL(18,2),actual_frozen_amount DECIMAL(18,2),credit_frozen_amount DECIMAL(18,2),status VARCHAR(32),freeze_ledger_id BIGINT,action_ledger_id BIGINT,created_at TIMESTAMP,completed_at TIMESTAMP,remark VARCHAR(500),UNIQUE(customer_id,business_type,business_no))");
|
||||
jdbc.execute("CREATE TABLE fin_idempotency_record(tenant_id BIGINT,operator_id BIGINT,operation VARCHAR(64),idempotency_key VARCHAR(128),request_hash VARCHAR(128),status VARCHAR(32),result_resource_id BIGINT,error_code VARCHAR(128),UNIQUE(tenant_id,operator_id,operation,idempotency_key))");
|
||||
jdbc.execute("CREATE TABLE fin_wallet_ledger(id BIGINT AUTO_INCREMENT PRIMARY KEY,customer_id BIGINT,business_no VARCHAR(128),change_type VARCHAR(32),amount DECIMAL(18,2),actual_balance_after DECIMAL(18,2),frozen_balance_after DECIMAL(18,2),available_balance_after DECIMAL(18,2),credit_limit_after DECIMAL(18,2),credit_used_after DECIMAL(18,2),available_credit_after DECIMAL(18,2),credit_expires_on_after DATE,source_type VARCHAR(32),source_no VARCHAR(128),occurred_date DATE,payment_method VARCHAR(32),reference_no VARCHAR(128),operator_id BIGINT,operator_name VARCHAR(128),remark VARCHAR(500),created_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE fin_audit_log(tenant_id BIGINT,resource_type VARCHAR(64),resource_id BIGINT,action VARCHAR(64),reason VARCHAR(500),before_data TEXT,after_data TEXT,operator_id BIGINT,operator_name_snapshot VARCHAR(128),request_id VARCHAR(128),trace_id VARCHAR(128))");
|
||||
jdbc.update("INSERT INTO fin_wallet_account VALUES(9,'CNY',200,100,100,0,0,0,NULL,0,0,0,CURRENT_TIMESTAMP)");
|
||||
jdbc.update("INSERT INTO fin_wallet_hold(id,customer_id,business_type,business_no,amount,actual_frozen_amount,credit_frozen_amount,status) VALUES(1,9,'PROCUREMENT_FEE','PF-1',100,100,0,'FROZEN')");
|
||||
FinanceDocumentNumberGenerator numbers = mock(FinanceDocumentNumberGenerator.class);
|
||||
AtomicInteger sequence = new AtomicInteger();
|
||||
when(numbers.next("AC")).thenAnswer(i -> "AC-TEST-" + sequence.incrementAndGet());
|
||||
wallet = new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
||||
mock(FinanceFileClient.class), numbers, "token", "", "test", "test");
|
||||
}
|
||||
|
||||
@Test
|
||||
void partialSettlementKeepsEstimateAndRecordsActualExactlyOnce() {
|
||||
WalletHold first = settle("request-1", "60");
|
||||
WalletHold replay = settle("request-1", "60");
|
||||
assertThat(replay.actionLedgerId()).isEqualTo(first.actionLedgerId());
|
||||
assertThat(first.amount()).isEqualByComparingTo("100");
|
||||
assertThat(first.account().actualBalance()).isEqualByComparingTo("140");
|
||||
assertThat(first.account().frozenBalance()).isZero();
|
||||
assertThat(jdbc.queryForObject("SELECT amount FROM fin_wallet_ledger", BigDecimal.class)).isEqualByComparingTo("-60");
|
||||
assertThatThrownBy(() -> settle("different-request", "60")).isInstanceOf(FinanceEventException.class);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger", Integer.class)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameKeyWithDifferentAmountCannotChangeCharge() {
|
||||
settle("request-1", "60");
|
||||
assertThatThrownBy(() -> settle("request-1", "70")).isInstanceOf(FinanceEventException.class)
|
||||
.extracting(e -> ((FinanceEventException) e).code()).isEqualTo("FIN-IDEMPOTENCY-CONFLICT");
|
||||
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("140");
|
||||
}
|
||||
|
||||
@Test
|
||||
void insufficientDifferenceLeavesOriginalHoldAndAccountUntouched() {
|
||||
assertThatThrownBy(() -> settle("request-1", "201")).isInstanceOf(FinanceEventException.class);
|
||||
assertUntouched();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_idempotency_record", Integer.class)).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void auditFailureRollsBackDebitLedgerHoldAndIdempotencyReservation() {
|
||||
doThrow(new TransientDataAccessResourceException("audit storage unavailable"))
|
||||
.when(jdbc).update(startsWith("INSERT INTO fin_audit_log"), any(Object[].class));
|
||||
assertThatThrownBy(() -> settle("request-1", "150"))
|
||||
.isInstanceOf(TransientDataAccessResourceException.class);
|
||||
assertUntouched();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_idempotency_record", Integer.class)).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentDifferentRequestsCannotSettleTheSameGoodsTwice() throws Exception {
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
try {
|
||||
Future<Boolean> one = executor.submit(() -> attempt(start, "one"));
|
||||
Future<Boolean> two = executor.submit(() -> attempt(start, "two"));
|
||||
start.countDown();
|
||||
assertThat((one.get(10, TimeUnit.SECONDS) ? 1 : 0) + (two.get(10, TimeUnit.SECONDS) ? 1 : 0)).isEqualTo(1);
|
||||
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("50");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger", Integer.class)).isEqualTo(1);
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean attempt(CountDownLatch start, String key) throws InterruptedException {
|
||||
if (!start.await(5, TimeUnit.SECONDS)) throw new AssertionError("start timeout");
|
||||
try { settle(key, "150"); return true; }
|
||||
catch (FinanceEventException expected) {
|
||||
assertThat(expected.code()).isEqualTo("FIN-WALLET-HOLD-409");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void refundsAreBoundedByOriginalConsumptionAndCannotReplayWithNewKey() {
|
||||
WalletHold settled = settle("settle", "60");
|
||||
var refund = tx.execute(s -> wallet.refundConsumption(9, settled.actionLedgerId(), "RF-1", "refund-1",
|
||||
new BigDecimal("40"), 7, "finance", "confirmed correction"));
|
||||
var replay = tx.execute(s -> wallet.refundConsumption(9, settled.actionLedgerId(), "RF-1", "new-key",
|
||||
new BigDecimal("40"), 7, "finance", "confirmed correction"));
|
||||
assertThat(replay.ledgerId()).isEqualTo(refund.ledgerId());
|
||||
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("180");
|
||||
assertThatThrownBy(() -> tx.execute(s -> wallet.refundConsumption(9, settled.actionLedgerId(), "RF-2", "refund-2",
|
||||
new BigDecimal("20.01"), 7, "finance", "too much")))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(e -> ((FinanceEventException) e).code()).isEqualTo("FIN-WALLET-REFUND-EXCEEDED");
|
||||
tx.execute(s -> wallet.refundConsumption(9, settled.actionLedgerId(), "RF-2", "refund-2",
|
||||
new BigDecimal("20"), 7, "finance", "remaining refund"));
|
||||
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("200");
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM fin_wallet_hold", String.class)).isEqualTo("SETTLED");
|
||||
assertThat(jdbc.queryForObject("SELECT SUM(amount) FROM fin_wallet_ledger WHERE source_type='BUSINESS_REFUND'", BigDecimal.class))
|
||||
.isEqualByComparingTo("60");
|
||||
}
|
||||
|
||||
@Test
|
||||
void refundCannotReferenceAnotherCustomerOrAnUnsettledReservation() {
|
||||
WalletHold settled = settle("settle", "60");
|
||||
assertThatThrownBy(() -> tx.execute(s -> wallet.refundConsumption(10, settled.actionLedgerId(), "RF-1", "refund-1",
|
||||
BigDecimal.ONE, 7, "finance", "wrong customer"))).isInstanceOf(FinanceEventException.class);
|
||||
assertThatThrownBy(() -> tx.execute(s -> wallet.refundConsumption(9, settled.actionLedgerId(), "RF-1", "refund-1",
|
||||
BigDecimal.ZERO, 7, "finance", "zero refund"))).isInstanceOf(FinanceEventException.class);
|
||||
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("140");
|
||||
}
|
||||
|
||||
@Test
|
||||
void refundFailureRollsBackBalanceAndCanRetryOriginalRequest() {
|
||||
WalletHold settled = settle("settle", "60");
|
||||
doThrow(new TransientDataAccessResourceException("audit unavailable"))
|
||||
.when(jdbc).update(startsWith("INSERT INTO fin_audit_log"), any(Object[].class));
|
||||
assertThatThrownBy(() -> tx.execute(s -> wallet.refundConsumption(9, settled.actionLedgerId(), "RF-1", "refund-1",
|
||||
new BigDecimal("20"), 7, "finance", "correction"))).isInstanceOf(TransientDataAccessResourceException.class);
|
||||
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("140");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger WHERE change_type='REFUND'", Integer.class)).isZero();
|
||||
doCallRealMethod().when(jdbc).update(startsWith("INSERT INTO fin_audit_log"), any(Object[].class));
|
||||
tx.execute(s -> wallet.refundConsumption(9, settled.actionLedgerId(), "RF-1", "refund-1",
|
||||
new BigDecimal("20"), 7, "finance", "correction"));
|
||||
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("160");
|
||||
}
|
||||
|
||||
private WalletHold settle(String key, String amount) {
|
||||
return tx.execute(s -> wallet.settleActual(9, "PROCUREMENT_FEE", "PF-1", key, new BigDecimal(amount), "actual freight"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentRefundsCannotExceedTheOriginalConsumption() throws Exception {
|
||||
WalletHold original = settle("settle", "60");
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
try {
|
||||
Future<Boolean> one = executor.submit(() -> refundAttempt(start, original.actionLedgerId(), "RF-A"));
|
||||
Future<Boolean> two = executor.submit(() -> refundAttempt(start, original.actionLedgerId(), "RF-B"));
|
||||
start.countDown();
|
||||
assertThat((one.get(10, TimeUnit.SECONDS) ? 1 : 0) + (two.get(10, TimeUnit.SECONDS) ? 1 : 0)).isEqualTo(1);
|
||||
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("180");
|
||||
assertThat(jdbc.queryForObject("SELECT SUM(amount) FROM fin_wallet_ledger WHERE change_type='REFUND'", BigDecimal.class))
|
||||
.isEqualByComparingTo("40");
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean refundAttempt(CountDownLatch start, long originalId, String number) throws InterruptedException {
|
||||
if (!start.await(5, TimeUnit.SECONDS)) throw new AssertionError("start timeout");
|
||||
try {
|
||||
tx.execute(s -> wallet.refundConsumption(9, originalId, number, number, new BigDecimal("40"),
|
||||
7, "finance", "correction"));
|
||||
return true;
|
||||
} catch (FinanceEventException expected) {
|
||||
assertThat(expected.code()).isEqualTo("FIN-WALLET-REFUND-EXCEEDED");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void assertUntouched() {
|
||||
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("200");
|
||||
assertThat(wallet.account(9).frozenBalance()).isEqualByComparingTo("100");
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM fin_wallet_hold", String.class)).isEqualTo("FROZEN");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger", Integer.class)).isZero();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user