feat(finance): add credit and wallet hold lifecycle

This commit is contained in:
2026-09-05 11:54:55 +08:00
parent 6f2b2babc6
commit ba4407eb0d
19 changed files with 587 additions and 70 deletions
@@ -9,6 +9,7 @@ import com.baiyue.finance.service.FinanceEventException;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -22,7 +23,7 @@ import static org.mockito.Mockito.when;
class FinanceCustomerAccountServiceTest {
@Test
void listsMasterCustomersWithZeroFinanceMetricsAndForwardsSearchFields() {
void listsMasterCustomersWithConfiguredInitialCreditAndForwardsSearchFields() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
IamCustomerClient iam = mock(IamCustomerClient.class);
when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of());
@@ -31,7 +32,7 @@ class FinanceCustomerAccountServiceTest {
new IamCustomerClient.Customer(900000000000000001L, "TEN-001", "Alice Trading", "ACTIVE", "Alice", "13800138000")),
1, 20, 1, 1)));
var result = new FinanceCustomerAccountService(jdbc, iam, "internal-token")
var result = new FinanceCustomerAccountService(jdbc, iam, "internal-token", new BigDecimal("8000.00"))
.list(1, 20, " Alice ", "active", 900000000000000001L);
AccountSummary row = result.data().get(0);
@@ -40,6 +41,9 @@ class FinanceCustomerAccountServiceTest {
assertThat(row.contactPhone()).isEqualTo("13800138000");
assertThat(row.outstandingReceivable()).isZero();
assertThat(row.pendingBilling()).isZero();
assertThat(row.creditLimit()).isEqualByComparingTo("8000.00");
assertThat(row.availableCredit()).isEqualByComparingTo("8000.00");
assertThat(row.availableBalance()).isEqualByComparingTo("8000.00");
verify(iam).page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L);
}
@@ -52,7 +56,7 @@ class FinanceCustomerAccountServiceTest {
new PageResult<>(List.of(new IamCustomerClient.Customer(9L, "C-9", "客户9", "ACTIVE", null, null)),
1, 20, 1, 1)));
new FinanceCustomerAccountService(jdbc, iam, "internal-token").list(1, 20, null, null, 9L);
new FinanceCustomerAccountService(jdbc, iam, "internal-token", BigDecimal.ZERO).list(1, 20, null, null, 9L);
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
verify(jdbc, atLeastOnce()).queryForList(sql.capture(), any(Object[].class));
@@ -68,7 +72,7 @@ class FinanceCustomerAccountServiceTest {
@Test
void rejectsUnsupportedFinanceAccountStatus() {
var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token");
var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token", BigDecimal.ZERO);
assertThatThrownBy(() -> service.list(1, 20, null, "PENDING", null))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code())
@@ -374,6 +374,7 @@ class FinancePhase1OperationBehaviorTest {
9L, "9", "客户9", null, null, new BigDecimal("100.00"),
new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, 0, 0,
"ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0)));
}
@@ -43,8 +43,8 @@ class FinanceSqlCommentContractTest {
missing.add(currentTable + "." + column.group(1));
}
}
assertThat(tableCount).isEqualTo(20);
assertThat(columnCount).isEqualTo(310);
assertThat(tableCount).isEqualTo(21);
assertThat(columnCount).isEqualTo(330);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@@ -1,10 +1,8 @@
package com.baiyue.finance;
package com.baiyue.finance.service;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.dto.FinanceWalletDtos.AccountAdjustmentRequest;
import com.baiyue.finance.service.FinanceDocumentNumberGenerator;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceWalletService;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletAccount;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -32,6 +30,8 @@ class FinanceWalletBehaviorTest {
assertThat(account.actualBalance()).isEqualByComparingTo("0.00");
assertThat(account.frozenBalance()).isEqualByComparingTo("0.00");
assertThat(account.availableBalance()).isEqualByComparingTo("0.00");
assertThat(account.creditLimit()).isEqualByComparingTo("0.00");
assertThat(account.availableCredit()).isEqualByComparingTo("0.00");
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
@@ -47,7 +47,8 @@ class FinanceWalletBehaviorTest {
@Test
void developmentProfileUsesTheNonPayableSampleQrCode() {
var channels = new FinanceWalletService(mock(JdbcTemplate.class), new ObjectMapper().findAndRegisterModules(),
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", "", "", "cn", "development").channels();
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", "", "",
BigDecimal.ZERO, "cn", "development").channels();
assertThat(channels).allSatisfy(channel -> {
assertThat(channel.enabled()).isTrue();
@@ -68,6 +69,9 @@ class FinanceWalletBehaviorTest {
when(rs.getBigDecimal("actual_balance")).thenReturn(new BigDecimal("10.00"));
when(rs.getBigDecimal("frozen_balance")).thenReturn(BigDecimal.ZERO);
when(rs.getBigDecimal("available_balance")).thenReturn(new BigDecimal("10.00"));
when(rs.getBigDecimal("credit_limit")).thenReturn(BigDecimal.ZERO);
when(rs.getBigDecimal("credit_used")).thenReturn(BigDecimal.ZERO);
when(rs.getBigDecimal("credit_frozen")).thenReturn(BigDecimal.ZERO);
when(rs.getInt("version")).thenReturn(2);
when(rs.getTimestamp("updated_at")).thenReturn(Timestamp.valueOf("2026-09-05 08:00:00"));
return mapper.mapRow(rs, 0);
@@ -81,8 +85,45 @@ class FinanceWalletBehaviorTest {
verify(jdbc, never()).update(startsWith("UPDATE fin_wallet_account SET actual_balance="), any(Object[].class));
}
@Test
void configuredCreditLimitIsUsedForAFirstAccountRead() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
var account = service(jdbc, "x", "y", new BigDecimal("8000.00")).account(9L);
assertThat(account.creditLimit()).isEqualByComparingTo("8000.00");
assertThat(account.availableCredit()).isEqualByComparingTo("8000.00");
assertThat(account.availableBalance()).isEqualByComparingTo("8000.00");
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
@Test
void deductionUsesCashBeforeCreditAndRechargeRepaysCreditFirst() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
WalletAccount initial = service.accountState(new BigDecimal("100.00"), BigDecimal.ZERO,
new BigDecimal("50.00"), BigDecimal.ZERO, BigDecimal.ZERO, 3);
WalletAccount deducted = service.deductAccount(initial, new BigDecimal("120.00"));
assertThat(deducted.actualBalance()).isEqualByComparingTo("0.00");
assertThat(deducted.creditUsed()).isEqualByComparingTo("20.00");
assertThat(deducted.availableCredit()).isEqualByComparingTo("30.00");
assertThat(deducted.availableBalance()).isEqualByComparingTo("30.00");
WalletAccount recharged = service.rechargeAccount(deducted, new BigDecimal("25.00"));
assertThat(recharged.creditUsed()).isEqualByComparingTo("0.00");
assertThat(recharged.actualBalance()).isEqualByComparingTo("5.00");
assertThat(recharged.availableBalance()).isEqualByComparingTo("55.00");
}
private static FinanceWalletService service(JdbcTemplate jdbc, String wechat, String alipay) {
return service(jdbc, wechat, alipay, BigDecimal.ZERO);
}
private static FinanceWalletService service(JdbcTemplate jdbc, String wechat, String alipay,
BigDecimal defaultCreditLimit) {
return new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(),
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", wechat, alipay, "test", "test");
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", wechat, alipay,
defaultCreditLimit, "test", "test");
}
}
@@ -14,19 +14,23 @@ class FinanceWalletContractTest {
String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fin_wallet_account")
.contains("actual_balance = frozen_balance + available_balance")
.contains("available_balance = actual_balance+credit_limit-credit_used-frozen_balance")
.contains("credit_limit >= credit_used+credit_frozen")
.contains("CREATE TABLE IF NOT EXISTS fin_recharge_request")
.contains("status IN ('PENDING','APPROVED','REJECTED')")
.contains("CREATE TABLE IF NOT EXISTS fin_wallet_ledger")
.contains("amount <> 0")
.contains("uk_fin_wallet_ledger_no")
.contains("idx_fin_recharge_customer_status");
.contains("idx_fin_recharge_customer_status")
.contains("CREATE TABLE IF NOT EXISTS fin_wallet_hold")
.contains("amount=actual_frozen_amount+credit_frozen_amount");
}
@Test
void controllersEnforceCustomerScopeAndSeparateWritePermissions() throws Exception {
String client = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/ClientFinanceWalletController.java"));
String platform = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceWalletController.java"));
String internal = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/InternalFinanceWalletController.java"));
assertThat(client)
.contains("user.tenantId()")
.contains("merchant:finance:account:view")
@@ -36,6 +40,13 @@ class FinanceWalletContractTest {
assertThat(platform)
.contains("platform:finance:recharge:review")
.contains("platform:finance:customer-account:adjust")
.contains("/accounts/{customerId}/credit-limit")
.contains("Idempotency-Key");
assertThat(internal)
.contains("/internal/finance/v1/wallet")
.contains("X-Internal-Service-Token")
.contains("/holds/{businessType}/{businessNo}/settle")
.contains("/holds/{businessType}/{businessNo}/release")
.contains("Idempotency-Key");
}
}