feat: complete finance and overseas receiving workflow

This commit is contained in:
2026-09-10 22:43:11 +08:00
parent e531ae9407
commit 56635ad4e4
66 changed files with 1601 additions and 417 deletions
+1
View File
@@ -42,6 +42,7 @@
- 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。
- 对账单出账后将客户确认状态置为 `PENDING_CONFIRMATION`;已出账账单调整后必须清除原确认快照并重新待确认,作废或重开时为 `NOT_REQUIRED`。客户只能确认本企业且状态为 `ISSUED`、`PARTIALLY_PAID` 或 `PAID` 的待确认账单,必须校验登录成员、乐观锁版本和幂等键,并记录确认人姓名快照与确认时间。
- 对账单、应收结算单和应付结算单编号统一按北京时间生成 `REC/AR/AP-yyMMdd-####`,各前缀每日独立递增且单日最多 9999;不得回退为数据库 ID 拼接、随机数或跨时区日期。
- 客户授信必须带晚于当天的到期日且不限制最远日期;到期日当天仍有效。有效期内消费先用实际资金,不足部分累计为已用授信;次日失效时已用授信自动并入实际余额并允许为负,充值先归还已用授信或负余额。Order 在客户提交新订单时必须拒绝负数实际余额,已存在的资金冻结仍按原业务单结算或释放。
- 应收款统计只按正式应收结算单的生成月份汇总 CNY 应收、已收和未收,支持可选客户筛选和分页;导出必须通过 Finance Provider + File `FINANCE_RECEIVABLE_STATISTICS` 异步任务,不在 Controller 或 Admin 直接生成文件。
- 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。
- 海外业务费用事件只接受非 `FIRSTLEG` 来源、`APPROVED` 审核快照和 `PUBLISHED` 活跃版本或明确 `VOIDED` 动作;按 `eventId + payloadHash` 防重放冲突,并以 `sourceSystem + sourceType + sourceFeeId + sourceVersion` 单调落库。事件携带完整客户、业务、方向、供应商、付款与来源哈希事实,Finance 不跨区同步回查 Lastleg。
@@ -33,6 +33,7 @@ public final class FinancePhase1Dtos {
BigDecimal actualBalance, BigDecimal frozenBalance,
BigDecimal availableBalance, BigDecimal creditLimit,
BigDecimal creditUsed, BigDecimal availableCredit,
LocalDate creditExpiresOn, boolean creditActive,
int accountVersion,
long pendingRechargeCount,
String status, LocalDateTime lastChangedAt) {}
@@ -12,6 +12,7 @@ public final class FinanceWalletDtos {
BigDecimal frozenBalance, BigDecimal availableBalance,
BigDecimal creditLimit, BigDecimal creditUsed,
BigDecimal creditFrozen, BigDecimal availableCredit,
LocalDate creditExpiresOn, boolean creditActive,
int version, LocalDateTime updatedAt) { }
public record WalletPage<T>(List<T> records, long total, int page, int size, int pages) {
@@ -26,6 +27,7 @@ public final class FinanceWalletDtos {
BigDecimal frozenBalanceAfter, BigDecimal availableBalanceAfter,
BigDecimal creditLimitAfter, BigDecimal creditUsedAfter,
BigDecimal availableCreditAfter,
LocalDate creditExpiresOnAfter,
String sourceType, String sourceNo, LocalDate occurredDate,
String paymentMethod, String referenceNo, String operatorName,
String remark, LocalDateTime createdAt) { }
@@ -45,7 +47,7 @@ public final class FinanceWalletDtos {
String paymentMethod, String referenceNo, String remark,
int expectedAccountVersion) { }
public record CreditLimitRequest(BigDecimal creditLimit, String remark,
public record CreditLimitRequest(BigDecimal creditLimit, LocalDate creditExpiresOn, String remark,
int expectedAccountVersion) { }
public record WalletFreezeRequest(BigDecimal amount, String businessType,
@@ -12,6 +12,7 @@ import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -21,18 +22,12 @@ public class FinanceCustomerAccountService {
private final JdbcTemplate jdbc;
private final IamCustomerClient iamCustomerClient;
private final String internalToken;
private final BigDecimal defaultCreditLimit;
public FinanceCustomerAccountService(JdbcTemplate jdbc, IamCustomerClient iamCustomerClient,
@Value("${baiyue.security.internal-service-token:}") String internalToken,
@Value("${baiyue.finance.wallet.default-credit-limit:0.00}") BigDecimal defaultCreditLimit) {
@Value("${baiyue.security.internal-service-token:}") String internalToken) {
this.jdbc = jdbc;
this.iamCustomerClient = iamCustomerClient;
this.internalToken = internalToken;
if (defaultCreditLimit == null || defaultCreditLimit.signum() < 0 || defaultCreditLimit.scale() > 2) {
throw new IllegalArgumentException("默认授信额度不能为负数且最多保留两位小数");
}
this.defaultCreditLimit = defaultCreditLimit.setScale(2);
}
public PageEnvelope<AccountSummary> list(int page, int pageSize, String keyword, String status, Long customerId) {
@@ -60,6 +55,7 @@ public class FinanceCustomerAccountService {
value.outstandingPayable(),
value.actualBalance(), value.frozenBalance(), value.availableBalance(),
value.creditLimit(), value.creditUsed(), value.availableCredit(),
value.creditExpiresOn(), value.creditActive(),
value.accountVersion(), value.pendingRechargeCount(),
customer.status(), value.lastChangedAt());
}).toList();
@@ -70,7 +66,7 @@ public class FinanceCustomerAccountService {
if (customerIds.isEmpty()) return Map.of();
String placeholders = String.join(",", customerIds.stream().map(id -> "?").toList());
Map<Long, MutableMetrics> result = new HashMap<>();
customerIds.forEach(id -> result.put(id, new MutableMetrics(defaultCreditLimit)));
customerIds.forEach(id -> result.put(id, new MutableMetrics()));
jdbc.queryForList("SELECT tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray())
.forEach(row -> metric(result, row, "outstandingReceivable", "amount"));
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM fin_fee_source f WHERE customer_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND review_status='APPROVED' AND currency_code='CNY' AND amount>0 AND status='AVAILABLE' AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') GROUP BY customer_id", customerIds.toArray())
@@ -83,7 +79,7 @@ public class FinanceCustomerAccountService {
.forEach(row -> metric(result, row, "paidCost", "amount"));
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,outstanding_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED' UNION ALL SELECT f.customer_id,f.amount FROM fin_fee_source f WHERE f.customer_id IN (" + placeholders + ") AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED')) payable_obligations GROUP BY customer_id", concat(customerIds, 2))
.forEach(row -> metric(result, row, "outstandingPayable", "amount"));
jdbc.queryForList("SELECT customer_id,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version FROM fin_wallet_account WHERE customer_id IN (" + placeholders + ")", customerIds.toArray())
jdbc.queryForList("SELECT customer_id,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version FROM fin_wallet_account WHERE customer_id IN (" + placeholders + ")", customerIds.toArray())
.forEach(row -> walletMetric(result, row));
jdbc.queryForList("SELECT customer_id,COUNT(*) pending_count FROM fin_recharge_request WHERE customer_id IN (" + placeholders + ") AND status='PENDING' GROUP BY customer_id", customerIds.toArray())
.forEach(row -> pendingMetric(result, row));
@@ -100,10 +96,22 @@ public class FinanceCustomerAccountService {
MutableMetrics target = result.get(number.longValue());
target.actualBalance = decimal(row.get("actual_balance"));
target.frozenBalance = decimal(row.get("frozen_balance"));
target.availableBalance = decimal(row.get("available_balance"));
target.creditLimit = decimal(row.get("credit_limit"));
BigDecimal creditFrozen = decimal(row.get("credit_frozen"));
target.creditExpiresOn = row.get("credit_expires_on") instanceof java.sql.Date value ? value.toLocalDate()
: row.get("credit_expires_on") instanceof LocalDate value ? value : null;
target.creditActive = target.creditLimit.signum() > 0 && target.creditExpiresOn != null
&& !target.creditExpiresOn.isBefore(LocalDate.now());
target.creditUsed = decimal(row.get("credit_used"));
target.availableCredit = target.creditLimit.subtract(target.creditUsed).subtract(decimal(row.get("credit_frozen")));
if (!target.creditActive && target.creditUsed.signum() > 0) {
target.actualBalance = target.actualBalance.subtract(target.creditUsed);
target.creditUsed = BigDecimal.ZERO;
}
target.availableCredit = target.creditActive
? target.creditLimit.subtract(target.creditUsed).subtract(creditFrozen) : BigDecimal.ZERO;
target.availableBalance = target.creditActive
? target.actualBalance.subtract(target.frozenBalance.subtract(creditFrozen)).add(target.availableCredit)
: target.actualBalance.subtract(target.frozenBalance);
target.accountVersion = row.get("version") instanceof Number value ? value.intValue() : 0;
}
@@ -158,13 +166,15 @@ public class FinanceCustomerAccountService {
private BigDecimal creditLimit;
private BigDecimal creditUsed = BigDecimal.ZERO;
private BigDecimal availableCredit;
private LocalDate creditExpiresOn;
private boolean creditActive;
private int accountVersion;
private long pendingRechargeCount;
private LocalDateTime lastChangedAt;
private MutableMetrics(BigDecimal defaultCreditLimit) {
this.creditLimit = defaultCreditLimit;
this.availableCredit = defaultCreditLimit;
this.availableBalance = defaultCreditLimit;
private MutableMetrics() {
this.creditLimit = BigDecimal.ZERO.setScale(2);
this.availableCredit = BigDecimal.ZERO.setScale(2);
this.availableBalance = BigDecimal.ZERO.setScale(2);
}
void set(String field, BigDecimal value) {
switch (field) {
@@ -179,6 +189,7 @@ public class FinanceCustomerAccountService {
}
Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, totalCost, paidCost, outstandingPayable,
actualBalance, frozenBalance, availableBalance, creditLimit, creditUsed, availableCredit,
creditExpiresOn, creditActive,
accountVersion, pendingRechargeCount, lastChangedAt); }
}
@@ -186,7 +197,8 @@ public class FinanceCustomerAccountService {
BigDecimal receivedInPeriod, BigDecimal totalCost, BigDecimal paidCost,
BigDecimal outstandingPayable, BigDecimal actualBalance, BigDecimal frozenBalance,
BigDecimal availableBalance, BigDecimal creditLimit, BigDecimal creditUsed,
BigDecimal availableCredit, int accountVersion, long pendingRechargeCount,
BigDecimal availableCredit, LocalDate creditExpiresOn, boolean creditActive,
int accountVersion, long pendingRechargeCount,
LocalDateTime lastChangedAt) {
}
}
@@ -78,22 +78,46 @@ public class FinancePhase1OperationService {
"COALESCE((SELECT available_balance FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) available_balance," +
"COALESCE((SELECT credit_limit FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_limit," +
"COALESCE((SELECT credit_used FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_used," +
"COALESCE((SELECT credit_frozen FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_frozen," +
"COALESCE((SELECT credit_limit-credit_used-credit_frozen FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) available_credit," +
"(SELECT credit_expires_on FROM fin_wallet_account w WHERE w.customer_id=c.customer_id) credit_expires_on," +
"COALESCE((SELECT version FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) account_version," +
"(SELECT COUNT(*) FROM fin_recharge_request rr WHERE rr.customer_id=c.customer_id AND rr.status='PENDING') pending_recharge_count," +
"GREATEST(COALESCE((SELECT MAX(updated_at) FROM fin_bill b WHERE b.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_receipt r WHERE r.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_cost x WHERE x.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_fee_source f WHERE f.customer_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_settlement x WHERE x.tenant_id=c.customer_id),'1970-01-01')) last_changed_at" + base + " ORDER BY last_changed_at DESC,c.customer_id DESC LIMIT ? OFFSET ?";
Object[] args = java.util.Arrays.copyOf(filterArgs, filterArgs.length + 2);
args[args.length - 2] = pageSize; args[args.length - 1] = (page - 1) * pageSize;
List<AccountSummary> rows = jdbc.query(select, (rs, row) -> new AccountSummary(rs.getLong("customer_id"), String.valueOf(rs.getLong("customer_id")), rs.getString("customer_name"), null, null,
money(rs, "outstanding_receivable"), money(rs, "pending_billing"), money(rs, "received_period"),
money(rs, "total_cost"), money(rs, "paid_cost"), money(rs, "outstanding_payable"),
money(rs, "actual_balance"), money(rs, "frozen_balance"), money(rs, "available_balance"),
money(rs, "credit_limit"), money(rs, "credit_used"), money(rs, "available_credit"),
rs.getInt("account_version"), rs.getLong("pending_recharge_count"),
rs.getString("account_status"), time(rs, "last_changed_at")), args);
List<AccountSummary> rows = jdbc.query(select, (rs, row) -> accountSummaryRow(rs), args);
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
}
private static AccountSummary accountSummaryRow(ResultSet rs) throws SQLException {
BigDecimal actualBalance = money(rs, "actual_balance");
BigDecimal frozenBalance = money(rs, "frozen_balance");
BigDecimal creditLimit = money(rs, "credit_limit");
BigDecimal creditFrozen = money(rs, "credit_frozen");
LocalDate creditExpiresOn = rs.getDate("credit_expires_on") == null
? null : rs.getDate("credit_expires_on").toLocalDate();
boolean creditActive = creditLimit.signum() > 0 && creditExpiresOn != null
&& !creditExpiresOn.isBefore(LocalDate.now());
BigDecimal creditUsed = money(rs, "credit_used");
if (!creditActive && creditUsed.signum() > 0) {
actualBalance = actualBalance.subtract(creditUsed);
creditUsed = BigDecimal.ZERO;
}
BigDecimal availableCredit = creditActive
? creditLimit.subtract(creditUsed).subtract(creditFrozen) : BigDecimal.ZERO;
BigDecimal availableBalance = creditActive
? actualBalance.subtract(frozenBalance.subtract(creditFrozen)).add(availableCredit)
: actualBalance.subtract(frozenBalance);
return new AccountSummary(rs.getLong("customer_id"), String.valueOf(rs.getLong("customer_id")),
rs.getString("customer_name"), null, null, money(rs, "outstanding_receivable"),
money(rs, "pending_billing"), money(rs, "received_period"), money(rs, "total_cost"),
money(rs, "paid_cost"), money(rs, "outstanding_payable"), actualBalance, frozenBalance,
availableBalance, creditLimit, creditUsed, availableCredit, creditExpiresOn, creditActive,
rs.getInt("account_version"), rs.getLong("pending_recharge_count"), rs.getString("account_status"),
time(rs, "last_changed_at"));
}
public PageEnvelope<LedgerEntry> listDerivedCustomerAccountLedger(long customerId, int page, int pageSize,
String businessType, LocalDate startDate, LocalDate endDate) {
customer(customerId); page = page(page); pageSize = pageSize(pageSize);
@@ -53,7 +53,6 @@ public class FinanceWalletService {
private final String internalToken;
private final String wechatQrCodeUrl;
private final String alipayQrCodeUrl;
private final BigDecimal defaultCreditLimit;
public FinanceWalletService(JdbcTemplate jdbc, ObjectMapper objectMapper,
FinanceFileClient fileClient,
@@ -61,7 +60,6 @@ public class FinanceWalletService {
@Value("${baiyue.security.internal-service-token:}") String internalToken,
@Value("${baiyue.finance.recharge.wechat-qr-code-url:}") String wechatQrCodeUrl,
@Value("${baiyue.finance.recharge.alipay-qr-code-url:}") String alipayQrCodeUrl,
@Value("${baiyue.finance.wallet.default-credit-limit:0.00}") BigDecimal defaultCreditLimit,
@Value("${spring.profiles.active:}") String activeProfiles,
@Value("${DEPLOY_ENV:}") String deployEnvironment) {
this.jdbc = jdbc;
@@ -69,7 +67,6 @@ public class FinanceWalletService {
this.fileClient = fileClient;
this.numberGenerator = numberGenerator;
this.internalToken = internalToken;
this.defaultCreditLimit = nonNegativeMoney(defaultCreditLimit, "默认授信额度");
boolean development = "development".equalsIgnoreCase(deployEnvironment.trim())
|| List.of(activeProfiles.split(",")).stream().map(String::trim).anyMatch("dev"::equalsIgnoreCase);
this.wechatQrCodeUrl = rechargeQrCode(wechatQrCodeUrl, development);
@@ -84,7 +81,7 @@ public class FinanceWalletService {
public WalletAccount account(long customerId) {
requirePositive(customerId, "客户");
List<WalletAccount> rows = jdbc.query(
"SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,updated_at FROM fin_wallet_account WHERE customer_id=?",
"SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,updated_at FROM fin_wallet_account WHERE customer_id=?",
(rs, n) -> accountRow(rs), customerId);
return rows.isEmpty() ? zeroAccount() : rows.get(0);
}
@@ -202,7 +199,7 @@ public class FinanceWalletService {
if (replay != null) return requiredLedger(replay, customerId);
WalletAccount before = accountForUpdate(customerId);
if (before.version() != request.expectedAccountVersion()) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
if ("DEDUCTION".equals(type) && before.availableBalance().compareTo(amount) < 0) {
if ("DEDUCTION".equals(type) && before.creditActive() && before.availableBalance().compareTo(amount) < 0) {
throw error("FIN-WALLET-INSUFFICIENT", "扣款金额不能超过当前可用余额");
}
BigDecimal signed = "RECHARGE".equals(type) ? amount : amount.negate();
@@ -225,19 +222,26 @@ public class FinanceWalletService {
requirePositive(customerId, "客户"); requirePositive(operatorId, "操作人");
if (request == null || request.expectedAccountVersion() < 0) throw bad("授信额度请求无效");
BigDecimal limit = nonNegativeMoney(request.creditLimit(), "授信额度");
LocalDate expiresOn = request.creditExpiresOn();
if (expiresOn == null || !expiresOn.isAfter(LocalDate.now())) {
throw bad("授信到期日必须晚于今天");
}
Long replay = reserve(customerId, operatorId, "WALLET_CREDIT_LIMIT", idempotencyKey, hash(request));
if (replay != null) return requiredLedger(replay, customerId);
WalletAccount before = accountForUpdate(customerId);
if (before.version() != request.expectedAccountVersion()) {
throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
}
if (limit.compareTo(before.creditUsed().add(before.creditFrozen())) < 0) {
BigDecimal existingDebt = before.actualBalance().signum() < 0
? before.actualBalance().negate() : before.creditUsed();
if (limit.compareTo(existingDebt.add(before.creditFrozen())) < 0) {
throw error("FIN-WALLET-CREDIT-IN-USE", "授信额度不能低于已用与已冻结授信合计");
}
BigDecimal delta = limit.subtract(before.creditLimit());
if (delta.signum() == 0) throw bad("授信额度未发生变化");
WalletAccount after = accountState(before.actualBalance(), before.frozenBalance(), limit,
before.creditUsed(), before.creditFrozen(), before.version());
if (delta.signum() == 0 && expiresOn.equals(before.creditExpiresOn())) throw bad("授信额度和有效期均未发生变化");
BigDecimal carriedDebt = existingDebt;
WalletAccount after = accountState(before.actualBalance().max(BigDecimal.ZERO), before.frozenBalance(), limit,
carriedDebt, before.creditFrozen(), expiresOn, before.version());
updateAccount(customerId, after, before.version(), operatorId);
WalletLedgerEntry entry = insertLedger(customerId, "CREDIT_LIMIT", delta, after,
"CREDIT_LIMIT_ADJUSTMENT", null, LocalDate.now(), null, null,
@@ -273,11 +277,12 @@ public class FinanceWalletService {
throw error("FIN-WALLET-INSUFFICIENT", "可用余额和授信额度不足");
}
BigDecimal actualAvailable = before.actualBalance()
.subtract(before.frozenBalance().subtract(before.creditFrozen()));
.subtract(before.frozenBalance().subtract(before.creditFrozen())).max(BigDecimal.ZERO);
BigDecimal actualFrozen = amount.min(actualAvailable);
BigDecimal creditFrozen = amount.subtract(actualFrozen);
WalletAccount after = accountState(before.actualBalance(), before.frozenBalance().add(amount),
before.creditLimit(), before.creditUsed(), before.creditFrozen().add(creditFrozen), before.version());
before.creditLimit(), before.creditUsed(), before.creditFrozen().add(creditFrozen),
before.creditExpiresOn(), before.version());
updateAccount(customerId, after, before.version(), 0);
long holdId = insertHold(customerId, businessType, businessNo, amount, actualFrozen, creditFrozen,
clean(request.remark(), 500));
@@ -315,12 +320,18 @@ public class FinanceWalletService {
WalletHold hold = findHold(customerId, businessType, businessNo, true);
if (hold == null) throw error("FIN-WALLET-HOLD-404", "资金冻结记录不存在");
if (!"FROZEN".equals(hold.status())) throw error("FIN-WALLET-HOLD-409", "资金冻结记录已处理");
BigDecimal actual = settle ? before.actualBalance().subtract(hold.actualFrozenAmount()) : before.actualBalance();
BigDecimal creditUsed = settle ? before.creditUsed().add(hold.creditFrozenAmount()) : before.creditUsed();
BigDecimal actual = before.actualBalance();
BigDecimal creditUsed = before.creditUsed();
if (settle && before.creditActive()) {
actual = actual.subtract(hold.actualFrozenAmount());
creditUsed = creditUsed.add(hold.creditFrozenAmount());
} else if (settle) {
actual = actual.subtract(hold.amount());
}
BigDecimal frozen = before.frozenBalance().subtract(hold.amount());
BigDecimal creditFrozen = before.creditFrozen().subtract(hold.creditFrozenAmount());
WalletAccount after = accountState(actual, frozen, before.creditLimit(), creditUsed,
creditFrozen, before.version());
creditFrozen, before.creditExpiresOn(), before.version());
updateAccount(customerId, after, before.version(), 0);
String action = settle ? "SETTLED" : "RELEASED";
WalletLedgerEntry ledger = insertLedger(customerId, settle ? "CONSUMPTION" : "UNFREEZE",
@@ -346,17 +357,17 @@ public class FinanceWalletService {
}
private WalletAccount accountForUpdate(long customerId) {
jdbc.update("INSERT IGNORE INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,created_by,updated_by) VALUES(?,'CNY',0,0,?,?,0,0,0,0,0)",
customerId, defaultCreditLimit, defaultCreditLimit);
jdbc.update("INSERT IGNORE INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,created_by,updated_by) VALUES(?,'CNY',0,0,0,0,0,0,NULL,0,0,0)",
customerId);
return jdbc.queryForObject(
"SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,updated_at FROM fin_wallet_account WHERE customer_id=? FOR UPDATE",
"SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,updated_at FROM fin_wallet_account WHERE customer_id=? FOR UPDATE",
(rs, n) -> accountRow(rs), customerId);
}
private void updateAccount(long customerId, WalletAccount account, int expectedVersion, long operatorId) {
int updated = jdbc.update("UPDATE fin_wallet_account SET actual_balance=?,frozen_balance=?,available_balance=?,credit_limit=?,credit_used=?,credit_frozen=?,version=version+1,updated_by=? WHERE customer_id=? AND version=?",
int updated = jdbc.update("UPDATE fin_wallet_account SET actual_balance=?,frozen_balance=?,available_balance=?,credit_limit=?,credit_used=?,credit_frozen=?,credit_expires_on=?,version=version+1,updated_by=? WHERE customer_id=? AND version=?",
account.actualBalance(), account.frozenBalance(), account.availableBalance(), account.creditLimit(),
account.creditUsed(), account.creditFrozen(), operatorId, customerId, expectedVersion);
account.creditUsed(), account.creditFrozen(), account.creditExpiresOn(), operatorId, customerId, expectedVersion);
if (updated != 1) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
}
@@ -369,17 +380,17 @@ public class FinanceWalletService {
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement statement = connection.prepareStatement(
"INSERT INTO fin_wallet_ledger(customer_id,business_no,change_type,amount,actual_balance_after,frozen_balance_after,available_balance_after,credit_limit_after,credit_used_after,available_credit_after,source_type,source_no,occurred_date,payment_method,reference_no,operator_id,operator_name,remark) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
"INSERT INTO fin_wallet_ledger(customer_id,business_no,change_type,amount,actual_balance_after,frozen_balance_after,available_balance_after,credit_limit_after,credit_used_after,available_credit_after,credit_expires_on_after,source_type,source_no,occurred_date,payment_method,reference_no,operator_id,operator_name,remark) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
Statement.RETURN_GENERATED_KEYS);
statement.setLong(1, customerId); statement.setString(2, businessNo); statement.setString(3, changeType);
statement.setBigDecimal(4, amount); statement.setBigDecimal(5, account.actualBalance());
statement.setBigDecimal(6, account.frozenBalance()); statement.setBigDecimal(7, account.availableBalance());
statement.setBigDecimal(8, account.creditLimit()); statement.setBigDecimal(9, account.creditUsed());
statement.setBigDecimal(10, account.availableCredit()); statement.setString(11, sourceType);
statement.setString(12, sourceNo); statement.setObject(13, occurredDate);
statement.setString(14, paymentMethod); statement.setString(15, referenceNo);
statement.setLong(16, operatorId); statement.setString(17, clean(operatorName, 128));
statement.setString(18, clean(remark, 500)); return statement;
statement.setBigDecimal(10, account.availableCredit()); statement.setObject(11, account.creditExpiresOn());
statement.setString(12, sourceType); statement.setString(13, sourceNo); statement.setObject(14, occurredDate);
statement.setString(15, paymentMethod); statement.setString(16, referenceNo);
statement.setLong(17, operatorId); statement.setString(18, clean(operatorName, 128));
statement.setString(19, clean(remark, 500)); return statement;
}, holder);
return requiredLedger(generatedId(holder), customerId);
}
@@ -392,48 +403,70 @@ public class FinanceWalletService {
}
WalletAccount rechargeAccount(WalletAccount before, BigDecimal amount) {
BigDecimal creditRepayment = amount.min(before.creditUsed());
return accountState(before.actualBalance().add(amount.subtract(creditRepayment)), before.frozenBalance(),
before.creditLimit(), before.creditUsed().subtract(creditRepayment), before.creditFrozen(), before.version());
BigDecimal repaidCredit = before.creditActive() ? amount.min(before.creditUsed()) : BigDecimal.ZERO;
return accountState(before.actualBalance().add(amount.subtract(repaidCredit)), before.frozenBalance(),
before.creditLimit(), before.creditUsed().subtract(repaidCredit), before.creditFrozen(),
before.creditExpiresOn(), before.version());
}
WalletAccount deductAccount(WalletAccount before, BigDecimal amount) {
BigDecimal actualAvailable = before.actualBalance()
.subtract(before.frozenBalance().subtract(before.creditFrozen()));
if (!before.creditActive()) {
return accountState(before.actualBalance().subtract(amount), before.frozenBalance(),
before.creditLimit(), BigDecimal.ZERO, before.creditFrozen(), before.creditExpiresOn(), before.version());
}
BigDecimal actualFrozen = before.frozenBalance().subtract(before.creditFrozen());
BigDecimal actualAvailable = before.actualBalance().subtract(actualFrozen).max(BigDecimal.ZERO);
BigDecimal actualDeduction = amount.min(actualAvailable);
BigDecimal creditDeduction = amount.subtract(actualDeduction);
return accountState(before.actualBalance().subtract(actualDeduction), before.frozenBalance(),
before.creditLimit(), before.creditUsed().add(creditDeduction), before.creditFrozen(), before.version());
before.creditLimit(), before.creditUsed().add(amount.subtract(actualDeduction)),
before.creditFrozen(), before.creditExpiresOn(), before.version());
}
WalletAccount accountState(BigDecimal actual, BigDecimal frozen, BigDecimal creditLimit,
BigDecimal creditUsed, BigDecimal creditFrozen, int version) {
BigDecimal availableCredit = creditLimit.subtract(creditUsed).subtract(creditFrozen);
BigDecimal creditFrozen, LocalDate creditExpiresOn, int version) {
return accountState(actual, frozen, creditLimit, BigDecimal.ZERO, creditFrozen, creditExpiresOn, version);
}
WalletAccount accountState(BigDecimal actual, BigDecimal frozen, BigDecimal creditLimit,
BigDecimal creditUsed, BigDecimal creditFrozen,
LocalDate creditExpiresOn, int version) {
boolean creditActive = creditLimit.signum() > 0 && creditExpiresOn != null
&& !creditExpiresOn.isBefore(LocalDate.now());
if (!creditActive && creditUsed.signum() > 0) {
actual = actual.subtract(creditUsed);
creditUsed = BigDecimal.ZERO.setScale(2);
}
BigDecimal availableCredit = creditActive
? creditLimit.subtract(creditUsed).subtract(creditFrozen) : BigDecimal.ZERO.setScale(2);
BigDecimal actualFrozen = frozen.subtract(creditFrozen);
BigDecimal available = actual.subtract(actualFrozen).add(availableCredit);
if (actual.signum() < 0 || frozen.signum() < 0 || creditLimit.signum() < 0 || creditUsed.signum() < 0
|| creditFrozen.signum() < 0 || actualFrozen.signum() < 0 || actual.compareTo(actualFrozen) < 0
|| availableCredit.signum() < 0 || available.signum() < 0) {
BigDecimal available = creditActive
? actual.subtract(actualFrozen).add(availableCredit)
: actual.subtract(frozen);
if (frozen.signum() < 0 || creditLimit.signum() < 0 || creditFrozen.signum() < 0
|| creditUsed.signum() < 0 || actualFrozen.signum() < 0
|| (creditActive && (actual.signum() < 0 || availableCredit.signum() < 0))) {
throw error("FIN-WALLET-409", "账户余额状态无效");
}
return new WalletAccount("CNY", actual, frozen, available, creditLimit, creditUsed,
creditFrozen, availableCredit, version, null);
creditFrozen, availableCredit, creditExpiresOn, creditActive, version, null);
}
private WalletAccount accountRow(java.sql.ResultSet rs) throws java.sql.SQLException {
BigDecimal creditLimit = rs.getBigDecimal("credit_limit");
BigDecimal creditUsed = rs.getBigDecimal("credit_used");
BigDecimal creditFrozen = rs.getBigDecimal("credit_frozen");
return new WalletAccount(rs.getString("currency_code"), rs.getBigDecimal("actual_balance"),
rs.getBigDecimal("frozen_balance"), rs.getBigDecimal("available_balance"), creditLimit, creditUsed,
creditFrozen, creditLimit.subtract(creditUsed).subtract(creditFrozen), rs.getInt("version"),
LocalDate expiresOn = rs.getDate("credit_expires_on") == null ? null : rs.getDate("credit_expires_on").toLocalDate();
WalletAccount calculated = accountState(rs.getBigDecimal("actual_balance"), rs.getBigDecimal("frozen_balance"),
creditLimit, rs.getBigDecimal("credit_used"), creditFrozen, expiresOn, rs.getInt("version"));
return new WalletAccount(calculated.currencyCode(), calculated.actualBalance(), calculated.frozenBalance(),
calculated.availableBalance(), calculated.creditLimit(), calculated.creditUsed(), calculated.creditFrozen(),
calculated.availableCredit(), calculated.creditExpiresOn(), calculated.creditActive(), calculated.version(),
timestamp(rs.getTimestamp("updated_at")));
}
private WalletAccount zeroAccount() {
BigDecimal zero = BigDecimal.ZERO.setScale(2);
return new WalletAccount("CNY", zero, zero, defaultCreditLimit, defaultCreditLimit, zero,
zero, defaultCreditLimit, 0, null);
return new WalletAccount("CNY", zero, zero, zero, zero, zero,
zero, zero, null, false, 0, null);
}
private long insertHold(long customerId, String businessType, String businessNo, BigDecimal amount,
@@ -546,6 +579,7 @@ public class FinanceWalletService {
rs.getBigDecimal("amount"), rs.getBigDecimal("actual_balance_after"), rs.getBigDecimal("frozen_balance_after"),
rs.getBigDecimal("available_balance_after"), rs.getBigDecimal("credit_limit_after"),
rs.getBigDecimal("credit_used_after"), rs.getBigDecimal("available_credit_after"),
rs.getObject("credit_expires_on_after", LocalDate.class),
rs.getString("source_type"), rs.getString("source_no"),
rs.getObject("occurred_date", LocalDate.class), rs.getString("payment_method"), rs.getString("reference_no"),
rs.getString("operator_name"), rs.getString("remark"), timestamp(rs.getTimestamp("created_at")));
+8 -6
View File
@@ -443,12 +443,13 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '当前仅CNY',
actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户实际余额,包含冻结资金',
actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户实际余额,授信消费可形成负余额',
frozen_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额,包含实际资金与授信冻结',
available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '总可用余额=实际余额+授信额度-已用授信-冻结总额',
available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '当前总可用余额;授信失效后不再计入',
credit_limit DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信额度',
credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '已结算使用的授信',
credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信有效期内累计已使用额度;到期后并入实际余额',
credit_frozen DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额中占用的授信',
credit_expires_on DATE NULL COMMENT '授信到期日,当天仍有效;次日失效',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '余额乐观锁版本',
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建操作人ID',
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后操作人ID',
@@ -458,7 +459,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account (
UNIQUE KEY uk_fin_wallet_account_customer (customer_id, currency_code),
CONSTRAINT chk_fin_wallet_account_customer CHECK (customer_id > 0),
CONSTRAINT chk_fin_wallet_account_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_wallet_account_balance CHECK (actual_balance >= 0 AND frozen_balance >= 0 AND available_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen AND actual_balance >= frozen_balance-credit_frozen AND credit_limit >= credit_used+credit_frozen AND available_balance = actual_balance+credit_limit-credit_used-frozen_balance)
CONSTRAINT chk_fin_wallet_account_balance CHECK (frozen_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen)
) ENGINE=InnoDB COMMENT='客户CNY资金账户主账';
CREATE TABLE IF NOT EXISTS fin_recharge_request (
@@ -500,6 +501,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
credit_limit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后授信额度',
credit_used_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后已用授信',
available_credit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后可用授信',
credit_expires_on_after DATE NULL COMMENT '变动后授信到期日',
source_type VARCHAR(32) NOT NULL COMMENT 'CLIENT_RECHARGE/MANUAL_RECHARGE/MANUAL_DEDUCTION等',
source_no VARCHAR(128) NULL COMMENT '充值申请号或外部凭证号',
occurred_date DATE NOT NULL COMMENT '业务发生日期',
@@ -514,8 +516,8 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
KEY idx_fin_wallet_ledger_customer_time (customer_id, created_at, id),
KEY idx_fin_wallet_ledger_source (customer_id, source_type, source_no),
CONSTRAINT chk_fin_wallet_ledger_customer CHECK (customer_id > 0),
CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0),
CONSTRAINT chk_fin_wallet_ledger_balance CHECK (actual_balance_after >= 0 AND frozen_balance_after >= 0 AND available_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0 AND credit_limit_after >= credit_used_after+available_credit_after AND available_balance_after = actual_balance_after-frozen_balance_after+credit_limit_after-credit_used_after)
CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0 OR change_type='CREDIT_LIMIT'),
CONSTRAINT chk_fin_wallet_ledger_balance CHECK (frozen_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0)
) ENGINE=InnoDB COMMENT='客户资金账户不可变余额流水';
CREATE TABLE IF NOT EXISTS fin_wallet_hold (
@@ -23,7 +23,7 @@ import static org.mockito.Mockito.when;
class FinanceCustomerAccountServiceTest {
@Test
void listsMasterCustomersWithConfiguredInitialCreditAndForwardsSearchFields() {
void listsMasterCustomersWithoutImplicitPermanentCreditAndForwardsSearchFields() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
IamCustomerClient iam = mock(IamCustomerClient.class);
when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of());
@@ -32,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", new BigDecimal("8000.00"))
var result = new FinanceCustomerAccountService(jdbc, iam, "internal-token")
.list(1, 20, " Alice ", "active", 900000000000000001L);
AccountSummary row = result.data().get(0);
@@ -41,9 +41,11 @@ 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");
assertThat(row.creditLimit()).isZero();
assertThat(row.availableCredit()).isZero();
assertThat(row.availableBalance()).isZero();
assertThat(row.creditExpiresOn()).isNull();
assertThat(row.creditActive()).isFalse();
verify(iam).page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L);
}
@@ -56,7 +58,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", BigDecimal.ZERO).list(1, 20, null, null, 9L);
new FinanceCustomerAccountService(jdbc, iam, "internal-token").list(1, 20, null, null, 9L);
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
verify(jdbc, atLeastOnce()).queryForList(sql.capture(), any(Object[].class));
@@ -72,7 +74,7 @@ class FinanceCustomerAccountServiceTest {
@Test
void rejectsUnsupportedFinanceAccountStatus() {
var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token", BigDecimal.ZERO);
var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token");
assertThatThrownBy(() -> service.list(1, 20, null, "PENDING", null))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code())
@@ -375,7 +375,7 @@ class FinancePhase1OperationBehaviorTest {
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,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, null, false, 0, 0,
"ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0)));
}
if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no")
@@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest {
}
}
assertThat(tableCount).isEqualTo(23);
assertThat(columnCount).isEqualTo(351);
assertThat(columnCount).isEqualTo(353);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@@ -14,12 +14,14 @@ 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("available_balance = actual_balance+credit_limit-credit_used-frozen_balance")
.contains("credit_limit >= credit_used+credit_frozen")
.contains("credit_expires_on DATE NULL")
.contains("actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0")
.contains("frozen_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0")
.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("amount <> 0 OR change_type='CREDIT_LIMIT'")
.contains("credit_expires_on_after DATE NULL")
.contains("uk_fin_wallet_ledger_no")
.contains("idx_fin_recharge_customer_status")
.contains("CREATE TABLE IF NOT EXISTS fin_wallet_hold")
@@ -1,7 +1,6 @@
package com.baiyue.finance.service;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.dto.FinanceWalletDtos.AccountAdjustmentRequest;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletAccount;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
@@ -9,8 +8,6 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.util.List;
@@ -48,7 +45,7 @@ class FinanceWalletBehaviorTest {
void developmentProfileUsesTheNonPayableSampleQrCode() {
var channels = new FinanceWalletService(mock(JdbcTemplate.class), new ObjectMapper().findAndRegisterModules(),
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", "", "",
BigDecimal.ZERO, "cn", "development").channels();
"cn", "development").channels();
assertThat(channels).allSatisfy(channel -> {
assertThat(channel.enabled()).isTrue();
@@ -57,44 +54,29 @@ class FinanceWalletBehaviorTest {
}
@Test
void manualDeductionCannotExceedAvailableBalance() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT request_hash"), any(Object[].class))).thenReturn(List.of());
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), any(Object[].class))).thenReturn(1);
when(jdbc.update(startsWith("INSERT IGNORE INTO fin_wallet_account"), any(Object[].class))).thenReturn(1);
when(jdbc.queryForObject(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getString("currency_code")).thenReturn("CNY");
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);
});
void deductionMayMakeActualBalanceNegative() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
WalletAccount initial = service.accountState(new BigDecimal("10.00"), BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO, null, 2);
assertThatThrownBy(() -> service(jdbc, "x", "y").adjust(9L, 7L, "财务员", "deduct-1",
new AccountAdjustmentRequest("DEDUCTION", new BigDecimal("10.01"), LocalDate.now(),
"BANK_TRANSFER", "REF-1", "人工扣款", 2)))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-INSUFFICIENT");
verify(jdbc, never()).update(startsWith("UPDATE fin_wallet_account SET actual_balance="), any(Object[].class));
WalletAccount deducted = service.deductAccount(initial, new BigDecimal("10.01"));
assertThat(deducted.actualBalance()).isEqualByComparingTo("-0.01");
assertThat(deducted.availableBalance()).isEqualByComparingTo("-0.01");
assertThat(deducted.creditActive()).isFalse();
}
@Test
void configuredCreditLimitIsUsedForAFirstAccountRead() {
void aMissingAccountNeverReceivesPermanentDefaultCredit() {
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);
var account = service(jdbc, "x", "y").account(9L);
assertThat(account.creditLimit()).isEqualByComparingTo("8000.00");
assertThat(account.availableCredit()).isEqualByComparingTo("8000.00");
assertThat(account.availableBalance()).isEqualByComparingTo("8000.00");
assertThat(account.creditLimit()).isZero();
assertThat(account.availableCredit()).isZero();
assertThat(account.availableBalance()).isZero();
assertThat(account.creditExpiresOn()).isNull();
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
@@ -102,7 +84,7 @@ class FinanceWalletBehaviorTest {
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);
new BigDecimal("50.00"), BigDecimal.ZERO, LocalDate.now().plusDays(7), 3);
WalletAccount deducted = service.deductAccount(initial, new BigDecimal("120.00"));
assertThat(deducted.actualBalance()).isEqualByComparingTo("0.00");
@@ -120,7 +102,7 @@ class FinanceWalletBehaviorTest {
void deductionNeverConsumesCashReservedByAnActiveHold() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
WalletAccount initial = service.accountState(new BigDecimal("100.00"), new BigDecimal("50.00"),
new BigDecimal("80.00"), BigDecimal.ZERO, new BigDecimal("20.00"), 4);
new BigDecimal("80.00"), new BigDecimal("20.00"), LocalDate.now().plusDays(7), 4);
WalletAccount deducted = service.deductAccount(initial, new BigDecimal("80.00"));
@@ -135,37 +117,40 @@ class FinanceWalletBehaviorTest {
@Test
void rechargeRepaysUsedCreditWithoutReleasingFrozenCredit() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
WalletAccount initial = service.accountState(new BigDecimal("30.00"), new BigDecimal("25.00"),
new BigDecimal("100.00"), new BigDecimal("40.00"), new BigDecimal("20.00"), 5);
WalletAccount initial = service.accountState(new BigDecimal("5.00"), new BigDecimal("25.00"),
new BigDecimal("100.00"), new BigDecimal("40.00"), new BigDecimal("20.00"),
LocalDate.now().plusDays(7), 5);
WalletAccount recharged = service.rechargeAccount(initial, new BigDecimal("50.00"));
assertThat(recharged.actualBalance()).isEqualByComparingTo("40.00");
assertThat(recharged.actualBalance()).isEqualByComparingTo("15.00");
assertThat(recharged.creditUsed()).isEqualByComparingTo("0.00");
assertThat(recharged.creditFrozen()).isEqualByComparingTo("20.00");
assertThat(recharged.frozenBalance()).isEqualByComparingTo("25.00");
assertThat(recharged.availableCredit()).isEqualByComparingTo("80.00");
assertThat(recharged.availableBalance()).isEqualByComparingTo("115.00");
assertThat(recharged.availableBalance()).isEqualByComparingTo("90.00");
}
@Test
void accountStateRejectsCreditUsedAndFrozenAboveTheLimit() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
assertThatThrownBy(() -> service.accountState(new BigDecimal("10.00"), new BigDecimal("25.00"),
new BigDecimal("50.00"), new BigDecimal("31.00"), new BigDecimal("20.00"), 1))
assertThatThrownBy(() -> service.accountState(BigDecimal.ZERO, new BigDecimal("20.00"),
new BigDecimal("50.00"), new BigDecimal("31.00"), new BigDecimal("20.00"),
LocalDate.now().plusDays(7), 1))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-409");
}
@Test
void accountStateRejectsFrozenCashAboveActualCash() {
void accountStateAllowsBalanceToBecomeNegativeWhileFundsRemainFrozen() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
assertThatThrownBy(() -> service.accountState(new BigDecimal("9.99"), new BigDecimal("10.00"),
new BigDecimal("20.00"), BigDecimal.ZERO, BigDecimal.ZERO, 1))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-409");
WalletAccount account = service.accountState(new BigDecimal("9.99"), new BigDecimal("10.00"),
new BigDecimal("20.00"), BigDecimal.ZERO, LocalDate.now().plusDays(7), 1);
assertThat(account.availableBalance()).isEqualByComparingTo("19.99");
assertThat(account.creditActive()).isTrue();
}
@Test
@@ -173,32 +158,27 @@ class FinanceWalletBehaviorTest {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
assertThatThrownBy(() -> service.accountState(new BigDecimal("100.00"), new BigDecimal("10.00"),
new BigDecimal("50.00"), BigDecimal.ZERO, new BigDecimal("10.01"), 1))
new BigDecimal("50.00"), new BigDecimal("10.01"), LocalDate.now().plusDays(7), 1))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-409");
}
@Test
void invalidDefaultCreditConfigurationFailsClosed() {
assertThatThrownBy(() -> service(mock(JdbcTemplate.class), "x", "y", new BigDecimal("-0.01")))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
assertThatThrownBy(() -> service(mock(JdbcTemplate.class), "x", "y", new BigDecimal("1.001")))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
assertThatThrownBy(() -> service(mock(JdbcTemplate.class), "x", "y", new BigDecimal("1000000000000.00")))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
void expiredCreditNoLongerContributesToAvailableBalance() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
WalletAccount account = service.accountState(BigDecimal.ZERO, BigDecimal.ZERO,
new BigDecimal("50.00"), new BigDecimal("20.00"), BigDecimal.ZERO,
LocalDate.now().minusDays(1), 1);
assertThat(account.creditActive()).isFalse();
assertThat(account.availableCredit()).isZero();
assertThat(account.availableBalance()).isEqualByComparingTo("-20.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,
defaultCreditLimit, "test", "test");
"test", "test");
}
}