Files
baiyue-firstleg/finance/src/test/java/com/baiyue/finance/service/ProcurementSettlementTransactionTest.java
T

201 lines
13 KiB
Java

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();
}
}