201 lines
13 KiB
Java
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();
|
|
}
|
|
}
|