feat(procurement): implement sample fulfillment and settled fee workflow

This commit is contained in:
2026-10-10 02:19:38 +08:00
parent 040f3bbd93
commit a973be2103
104 changed files with 9823 additions and 714 deletions
@@ -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); }
}
@@ -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();
}
}