feat(finance): add credit and wallet hold lifecycle
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user