feat: complete finance and overseas receiving workflow
This commit is contained in:
@@ -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,
|
||||
|
||||
+28
-16
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+31
-7
@@ -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")));
|
||||
|
||||
@@ -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")
|
||||
|
||||
+44
-64
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user