feat(finance): add credit and wallet hold lifecycle
This commit is contained in:
+1
-1
@@ -15,7 +15,7 @@
|
||||
| 客户只读账单、收款与应收 API | `controller/ClientFinanceController.java`,统一前缀 `/api/client/finance/v1` |
|
||||
| 订单与客户账户、费用来源审核与结算纳入 | `controller/FinancePhase1OperationController.java`、`service/FinancePhase1OperationService.java` |
|
||||
| 客户财务账户汇总 | `service/FinanceCustomerAccountService.java`、`client/IamCustomerClient.java`;客户主档事实仍归 IAM |
|
||||
| 客户资金账户与充值 | `controller/ClientFinanceWalletController.java`、`controller/PlatformFinanceWalletController.java`、`service/FinanceWalletService.java`、`dto/FinanceWalletDtos.java`;当前只做静态收款码、截图申请、平台审核和手工充扣,消费与支付回调尚未接入 |
|
||||
| 客户资金账户、授信与充值 | `controller/ClientFinanceWalletController.java`、`controller/PlatformFinanceWalletController.java`、`controller/InternalFinanceWalletController.java`、`service/FinanceWalletService.java`、`dto/FinanceWalletDtos.java`;支持静态收款码、截图申请、平台审核、手工充扣、授信配置及供业务模块使用的冻结/结算/释放,第三方支付回调尚未接入 |
|
||||
| 客户对账单 | `controller/ReconciliationStatementController.java`、`service/ReconciliationStatementService.java` |
|
||||
| 应收款月度统计 | `controller/ReceivableStatisticsController.java`、`service/ReceivableStatisticsService.java`、`dto/ReceivableStatisticsDtos.java`;按结算单生成月份和客户汇总 CNY 正式应收结算单 |
|
||||
| 应收/应付结算单 | `controller/FinanceSettlementController.java`、`service/FinanceSettlementService.java` |
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.baiyue.finance.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletFreezeRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest;
|
||||
import com.baiyue.finance.service.FinanceWalletService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/internal/finance/v1/wallet")
|
||||
public class InternalFinanceWalletController {
|
||||
private final FinanceWalletService service;
|
||||
private final String expectedToken;
|
||||
|
||||
public InternalFinanceWalletController(FinanceWalletService service,
|
||||
@Value("${baiyue.security.internal-service-token}") String expectedToken) {
|
||||
this.service = service;
|
||||
this.expectedToken = expectedToken;
|
||||
}
|
||||
|
||||
@GetMapping("/accounts/{customerId}")
|
||||
public ApiResponse<?> account(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@PathVariable long customerId) {
|
||||
check(token);
|
||||
return FinanceResponses.success(service.account(customerId));
|
||||
}
|
||||
|
||||
@PostMapping("/accounts/{customerId}/holds")
|
||||
public ApiResponse<?> freeze(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@PathVariable long customerId,
|
||||
@RequestBody WalletFreezeRequest request) {
|
||||
check(token);
|
||||
return FinanceResponses.success(service.freeze(customerId, idempotencyKey, request));
|
||||
}
|
||||
|
||||
@PostMapping("/accounts/{customerId}/holds/{businessType}/{businessNo}/settle")
|
||||
public ApiResponse<?> settle(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@PathVariable long customerId, @PathVariable String businessType,
|
||||
@PathVariable String businessNo,
|
||||
@RequestBody(required = false) WalletHoldActionRequest request) {
|
||||
check(token);
|
||||
return FinanceResponses.success(service.settle(customerId, businessType, businessNo,
|
||||
idempotencyKey, request));
|
||||
}
|
||||
|
||||
@PostMapping("/accounts/{customerId}/holds/{businessType}/{businessNo}/release")
|
||||
public ApiResponse<?> release(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@PathVariable long customerId, @PathVariable String businessType,
|
||||
@PathVariable String businessNo,
|
||||
@RequestBody(required = false) WalletHoldActionRequest request) {
|
||||
check(token);
|
||||
return FinanceResponses.success(service.release(customerId, businessType, businessNo,
|
||||
idempotencyKey, request));
|
||||
}
|
||||
|
||||
private void check(String token) {
|
||||
if (!StringUtils.hasText(expectedToken) || token == null || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),
|
||||
expectedToken.getBytes(StandardCharsets.UTF_8))) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -3,6 +3,7 @@ package com.baiyue.finance.controller;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.AccountAdjustmentRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.CreditLimitRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeReviewRequest;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeRequest;
|
||||
@@ -48,6 +49,16 @@ public class PlatformFinanceWalletController {
|
||||
idempotencyKey, request));
|
||||
}
|
||||
|
||||
@PutMapping("/accounts/{customerId}/credit-limit")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-account:adjust')")
|
||||
public ApiResponse<?> creditLimit(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long customerId,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@RequestBody CreditLimitRequest request) {
|
||||
return FinanceResponses.success(service.setCreditLimit(customerId, user.userId(), user.displayName(),
|
||||
idempotencyKey, request));
|
||||
}
|
||||
|
||||
@GetMapping("/recharge-requests/{requestId}/voucher")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
|
||||
public ResponseEntity<byte[]> voucher(@PathVariable long requestId) { return service.voucher(requestId); }
|
||||
|
||||
@@ -31,7 +31,9 @@ public final class FinancePhase1Dtos {
|
||||
BigDecimal receivedInPeriod, BigDecimal totalCost,
|
||||
BigDecimal paidCost, BigDecimal outstandingPayable,
|
||||
BigDecimal actualBalance, BigDecimal frozenBalance,
|
||||
BigDecimal availableBalance, int accountVersion,
|
||||
BigDecimal availableBalance, BigDecimal creditLimit,
|
||||
BigDecimal creditUsed, BigDecimal availableCredit,
|
||||
int accountVersion,
|
||||
long pendingRechargeCount,
|
||||
String status, LocalDateTime lastChangedAt) {}
|
||||
|
||||
@@ -42,6 +44,8 @@ public final class FinancePhase1Dtos {
|
||||
String remark, String walletBusinessNo, String changeType,
|
||||
BigDecimal walletAmount, BigDecimal actualBalanceAfter,
|
||||
BigDecimal frozenBalanceAfter, BigDecimal availableBalanceAfter,
|
||||
BigDecimal creditLimitAfter, BigDecimal creditUsedAfter,
|
||||
BigDecimal availableCreditAfter,
|
||||
String sourceType, String sourceNo, LocalDate occurredDate,
|
||||
String paymentMethod, String referenceNo) {}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ public final class FinanceWalletDtos {
|
||||
|
||||
public record WalletAccount(String currencyCode, BigDecimal actualBalance,
|
||||
BigDecimal frozenBalance, BigDecimal availableBalance,
|
||||
BigDecimal creditLimit, BigDecimal creditUsed,
|
||||
BigDecimal creditFrozen, BigDecimal availableCredit,
|
||||
int version, LocalDateTime updatedAt) { }
|
||||
|
||||
public record WalletPage<T>(List<T> records, long total, int page, int size, int pages) {
|
||||
@@ -22,6 +24,8 @@ public final class FinanceWalletDtos {
|
||||
public record WalletLedgerEntry(long ledgerId, String businessNo, String changeType,
|
||||
BigDecimal amount, BigDecimal actualBalanceAfter,
|
||||
BigDecimal frozenBalanceAfter, BigDecimal availableBalanceAfter,
|
||||
BigDecimal creditLimitAfter, BigDecimal creditUsedAfter,
|
||||
BigDecimal availableCreditAfter,
|
||||
String sourceType, String sourceNo, LocalDate occurredDate,
|
||||
String paymentMethod, String referenceNo, String operatorName,
|
||||
String remark, LocalDateTime createdAt) { }
|
||||
@@ -40,4 +44,19 @@ public final class FinanceWalletDtos {
|
||||
public record AccountAdjustmentRequest(String type, BigDecimal amount, LocalDate occurredDate,
|
||||
String paymentMethod, String referenceNo, String remark,
|
||||
int expectedAccountVersion) { }
|
||||
|
||||
public record CreditLimitRequest(BigDecimal creditLimit, String remark,
|
||||
int expectedAccountVersion) { }
|
||||
|
||||
public record WalletFreezeRequest(BigDecimal amount, String businessType,
|
||||
String businessNo, String remark) { }
|
||||
|
||||
public record WalletHoldActionRequest(String remark) { }
|
||||
|
||||
public record WalletHold(long holdId, long customerId, String businessType,
|
||||
String businessNo, BigDecimal amount,
|
||||
BigDecimal actualFrozenAmount, BigDecimal creditFrozenAmount,
|
||||
String status, Long freezeLedgerId, Long actionLedgerId,
|
||||
WalletAccount account, LocalDateTime createdAt,
|
||||
LocalDateTime completedAt) { }
|
||||
}
|
||||
|
||||
+26
-9
@@ -21,12 +21,18 @@ 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.security.internal-service-token:}") String internalToken,
|
||||
@Value("${baiyue.finance.wallet.default-credit-limit:0.00}") BigDecimal defaultCreditLimit) {
|
||||
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) {
|
||||
@@ -47,12 +53,13 @@ public class FinanceCustomerAccountService {
|
||||
PageResult<IamCustomerClient.Customer> customers = response.data();
|
||||
Map<Long, Metrics> metrics = loadMetrics(customers.records().stream().map(IamCustomerClient.Customer::id).toList());
|
||||
List<AccountSummary> rows = customers.records().stream().map(customer -> {
|
||||
Metrics value = metrics.getOrDefault(customer.id(), Metrics.ZERO);
|
||||
Metrics value = metrics.get(customer.id());
|
||||
return new AccountSummary(customer.id(), customer.tenantCode(), customer.tenantName(),
|
||||
customer.contactName(), customer.contactPhone(), value.outstandingReceivable(),
|
||||
value.pendingBilling(), value.receivedInPeriod(), value.totalCost(), value.paidCost(),
|
||||
value.outstandingPayable(),
|
||||
value.actualBalance(), value.frozenBalance(), value.availableBalance(),
|
||||
value.creditLimit(), value.creditUsed(), value.availableCredit(),
|
||||
value.accountVersion(), value.pendingRechargeCount(),
|
||||
customer.status(), value.lastChangedAt());
|
||||
}).toList();
|
||||
@@ -63,7 +70,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()));
|
||||
customerIds.forEach(id -> result.put(id, new MutableMetrics(defaultCreditLimit)));
|
||||
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())
|
||||
@@ -76,7 +83,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,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,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));
|
||||
@@ -94,6 +101,9 @@ public class FinanceCustomerAccountService {
|
||||
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"));
|
||||
target.creditUsed = decimal(row.get("credit_used"));
|
||||
target.availableCredit = target.creditLimit.subtract(target.creditUsed).subtract(decimal(row.get("credit_frozen")));
|
||||
target.accountVersion = row.get("version") instanceof Number value ? value.intValue() : 0;
|
||||
}
|
||||
|
||||
@@ -145,9 +155,17 @@ public class FinanceCustomerAccountService {
|
||||
private BigDecimal actualBalance = BigDecimal.ZERO;
|
||||
private BigDecimal frozenBalance = BigDecimal.ZERO;
|
||||
private BigDecimal availableBalance = BigDecimal.ZERO;
|
||||
private BigDecimal creditLimit;
|
||||
private BigDecimal creditUsed = BigDecimal.ZERO;
|
||||
private BigDecimal availableCredit;
|
||||
private int accountVersion;
|
||||
private long pendingRechargeCount;
|
||||
private LocalDateTime lastChangedAt;
|
||||
private MutableMetrics(BigDecimal defaultCreditLimit) {
|
||||
this.creditLimit = defaultCreditLimit;
|
||||
this.availableCredit = defaultCreditLimit;
|
||||
this.availableBalance = defaultCreditLimit;
|
||||
}
|
||||
void set(String field, BigDecimal value) {
|
||||
switch (field) {
|
||||
case "outstandingReceivable" -> outstandingReceivable = value;
|
||||
@@ -160,16 +178,15 @@ public class FinanceCustomerAccountService {
|
||||
}
|
||||
}
|
||||
Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, totalCost, paidCost, outstandingPayable,
|
||||
actualBalance, frozenBalance, availableBalance, accountVersion, pendingRechargeCount, lastChangedAt); }
|
||||
actualBalance, frozenBalance, availableBalance, creditLimit, creditUsed, availableCredit,
|
||||
accountVersion, pendingRechargeCount, lastChangedAt); }
|
||||
}
|
||||
|
||||
private record Metrics(BigDecimal outstandingReceivable, BigDecimal pendingBilling,
|
||||
BigDecimal receivedInPeriod, BigDecimal totalCost, BigDecimal paidCost,
|
||||
BigDecimal outstandingPayable, BigDecimal actualBalance, BigDecimal frozenBalance,
|
||||
BigDecimal availableBalance, int accountVersion, long pendingRechargeCount,
|
||||
BigDecimal availableBalance, BigDecimal creditLimit, BigDecimal creditUsed,
|
||||
BigDecimal availableCredit, int accountVersion, long pendingRechargeCount,
|
||||
LocalDateTime lastChangedAt) {
|
||||
private static final Metrics ZERO = new Metrics(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, 0, 0, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,9 @@ public class FinancePhase1OperationService {
|
||||
"COALESCE((SELECT actual_balance FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) actual_balance," +
|
||||
"COALESCE((SELECT frozen_balance FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) frozen_balance," +
|
||||
"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_limit-credit_used-credit_frozen FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) available_credit," +
|
||||
"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 ?";
|
||||
@@ -85,6 +88,7 @@ public class FinancePhase1OperationService {
|
||||
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);
|
||||
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
|
||||
@@ -107,7 +111,7 @@ public class FinancePhase1OperationService {
|
||||
outstanding = outstanding.add(receivableChange);
|
||||
BigDecimal payable = List.of("DIRECT_PAID_COST", "AP_PAYMENT").contains(row.documentType()) ? row.amount() : BigDecimal.ZERO;
|
||||
LedgerEntry entry = new LedgerEntry(row.id(), row.occurredAt(), row.businessType(), row.documentType(), row.id(), row.documentNo(), receivableChange, payable, outstanding, row.operatorName(), row.remark(),
|
||||
null, null, null, null, null, null, null, null, null, null, null);
|
||||
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
|
||||
if ((businessType == null || businessType.isBlank() || businessType.equals(entry.businessType())) && inRange(entry.occurredAt(), startDate, endDate)) projected.add(entry);
|
||||
}
|
||||
BigDecimal derivedOutstanding = outstanding;
|
||||
@@ -120,6 +124,8 @@ public class FinancePhase1OperationService {
|
||||
rs.getString("operator_name"), rs.getString("remark"), rs.getString("business_no"), type,
|
||||
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.getString("source_type"), rs.getString("source_no"), occurredDate,
|
||||
rs.getString("payment_method"), rs.getString("reference_no"));
|
||||
}, customerId).stream().filter(entry ->
|
||||
|
||||
@@ -3,10 +3,14 @@ package com.baiyue.finance.service;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.finance.client.FinanceFileClient;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.AccountAdjustmentRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.CreditLimitRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeChannel;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeReviewRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletAccount;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletFreezeRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -49,6 +53,7 @@ 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,
|
||||
@@ -56,6 +61,7 @@ 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;
|
||||
@@ -63,6 +69,7 @@ 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);
|
||||
@@ -77,12 +84,9 @@ public class FinanceWalletService {
|
||||
public WalletAccount account(long customerId) {
|
||||
requirePositive(customerId, "客户");
|
||||
List<WalletAccount> rows = jdbc.query(
|
||||
"SELECT currency_code,actual_balance,frozen_balance,available_balance,version,updated_at FROM fin_wallet_account WHERE customer_id=?",
|
||||
(rs, n) -> new WalletAccount(rs.getString("currency_code"), rs.getBigDecimal("actual_balance"),
|
||||
rs.getBigDecimal("frozen_balance"), rs.getBigDecimal("available_balance"), rs.getInt("version"),
|
||||
timestamp(rs.getTimestamp("updated_at"))), customerId);
|
||||
return rows.isEmpty() ? new WalletAccount("CNY", BigDecimal.ZERO.setScale(2), BigDecimal.ZERO.setScale(2),
|
||||
BigDecimal.ZERO.setScale(2), 0, null) : rows.get(0);
|
||||
"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=?",
|
||||
(rs, n) -> accountRow(rs), customerId);
|
||||
return rows.isEmpty() ? zeroAccount() : rows.get(0);
|
||||
}
|
||||
|
||||
public WalletPage<WalletLedgerEntry> ledger(long customerId, int page, int size) {
|
||||
@@ -169,10 +173,9 @@ public class FinanceWalletService {
|
||||
if (updated != 1) throw error("FIN-WALLET-409", "充值申请已处理或版本已变化");
|
||||
if ("APPROVE".equals(decision)) {
|
||||
WalletAccount account = accountForUpdate(beforeCustomerId(requestId));
|
||||
BigDecimal actual = account.actualBalance().add(before.amount());
|
||||
BigDecimal available = account.availableBalance().add(before.amount());
|
||||
updateAccount(beforeCustomerId(requestId), actual, account.frozenBalance(), available, account.version(), operatorId);
|
||||
insertLedger(beforeCustomerId(requestId), "RECHARGE", before.amount(), actual, account.frozenBalance(), available,
|
||||
WalletAccount afterAccount = rechargeAccount(account, before.amount());
|
||||
updateAccount(beforeCustomerId(requestId), afterAccount, account.version(), operatorId);
|
||||
insertLedger(beforeCustomerId(requestId), "RECHARGE", before.amount(), afterAccount,
|
||||
"CLIENT_RECHARGE", before.requestNo(), LocalDate.now(), before.paymentMethod(), null,
|
||||
operatorId, operatorName, request.remark());
|
||||
}
|
||||
@@ -199,15 +202,15 @@ public class FinanceWalletService {
|
||||
if (replay != null) return requiredLedger(replay, customerId);
|
||||
WalletAccount before = accountForUpdate(customerId);
|
||||
if (before.version() != request.expectedAccountVersion()) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
|
||||
BigDecimal signed = "RECHARGE".equals(type) ? amount : amount.negate();
|
||||
if ("DEDUCTION".equals(type) && before.availableBalance().compareTo(amount) < 0) {
|
||||
throw error("FIN-WALLET-INSUFFICIENT", "扣款金额不能超过当前可用余额");
|
||||
}
|
||||
BigDecimal actual = before.actualBalance().add(signed);
|
||||
BigDecimal available = before.availableBalance().add(signed);
|
||||
updateAccount(customerId, actual, before.frozenBalance(), available, before.version(), operatorId);
|
||||
BigDecimal signed = "RECHARGE".equals(type) ? amount : amount.negate();
|
||||
WalletAccount afterAccount = "RECHARGE".equals(type)
|
||||
? rechargeAccount(before, amount) : deductAccount(before, amount);
|
||||
updateAccount(customerId, afterAccount, before.version(), operatorId);
|
||||
String sourceType = "RECHARGE".equals(type) ? "MANUAL_RECHARGE" : "MANUAL_DEDUCTION";
|
||||
WalletLedgerEntry entry = insertLedger(customerId, type, signed, actual, before.frozenBalance(), available,
|
||||
WalletLedgerEntry entry = insertLedger(customerId, type, signed, afterAccount,
|
||||
sourceType, clean(request.referenceNo(), 128), request.occurredDate() == null ? LocalDate.now() : request.occurredDate(),
|
||||
adjustmentPaymentMethod, clean(request.referenceNo(), 128), operatorId, operatorName, request.remark());
|
||||
audit(customerId, "WALLET_ACCOUNT", customerId, "MANUAL_" + type, request.remark(), before,
|
||||
@@ -216,6 +219,121 @@ public class FinanceWalletService {
|
||||
return entry;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public WalletLedgerEntry setCreditLimit(long customerId, long operatorId, String operatorName,
|
||||
String idempotencyKey, CreditLimitRequest request) {
|
||||
requirePositive(customerId, "客户"); requirePositive(operatorId, "操作人");
|
||||
if (request == null || request.expectedAccountVersion() < 0) throw bad("授信额度请求无效");
|
||||
BigDecimal limit = nonNegativeMoney(request.creditLimit(), "授信额度");
|
||||
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) {
|
||||
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());
|
||||
updateAccount(customerId, after, before.version(), operatorId);
|
||||
WalletLedgerEntry entry = insertLedger(customerId, "CREDIT_LIMIT", delta, after,
|
||||
"CREDIT_LIMIT_ADJUSTMENT", null, LocalDate.now(), null, null,
|
||||
operatorId, operatorName, request.remark());
|
||||
audit(customerId, "WALLET_ACCOUNT", customerId, "CREDIT_LIMIT_UPDATE", request.remark(),
|
||||
before, account(customerId), operatorId, operatorName);
|
||||
complete(customerId, operatorId, "WALLET_CREDIT_LIMIT", idempotencyKey, entry.ledgerId());
|
||||
return entry;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public WalletHold freeze(long customerId, String idempotencyKey, WalletFreezeRequest request) {
|
||||
requirePositive(customerId, "客户");
|
||||
if (request == null) throw bad("冻结请求无效");
|
||||
BigDecimal amount = money(request.amount());
|
||||
String businessType = businessType(request.businessType());
|
||||
String businessNo = businessNo(request.businessNo());
|
||||
String requestHash = hash(Map.of("customerId", customerId, "amount", amount,
|
||||
"businessType", businessType, "businessNo", businessNo,
|
||||
"remark", String.valueOf(request.remark())));
|
||||
Long replay = reserve(customerId, 0, "WALLET_HOLD_FREEZE", idempotencyKey, requestHash);
|
||||
if (replay != null) return requiredHold(replay, customerId);
|
||||
WalletHold existing = findHold(customerId, businessType, businessNo, false);
|
||||
if (existing != null) {
|
||||
if (existing.amount().compareTo(amount) != 0) {
|
||||
throw error("FIN-IDEMPOTENCY-CONFLICT", "同一业务单号的冻结金额不一致");
|
||||
}
|
||||
complete(customerId, 0, "WALLET_HOLD_FREEZE", idempotencyKey, existing.holdId());
|
||||
return withAccount(existing);
|
||||
}
|
||||
WalletAccount before = accountForUpdate(customerId);
|
||||
if (before.availableBalance().compareTo(amount) < 0) {
|
||||
throw error("FIN-WALLET-INSUFFICIENT", "可用余额和授信额度不足");
|
||||
}
|
||||
BigDecimal actualAvailable = before.actualBalance()
|
||||
.subtract(before.frozenBalance().subtract(before.creditFrozen()));
|
||||
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());
|
||||
updateAccount(customerId, after, before.version(), 0);
|
||||
long holdId = insertHold(customerId, businessType, businessNo, amount, actualFrozen, creditFrozen,
|
||||
clean(request.remark(), 500));
|
||||
WalletLedgerEntry ledger = insertLedger(customerId, "FREEZE", amount.negate(), after,
|
||||
businessType, businessNo, LocalDate.now(), null, businessNo, 0, "业务系统", request.remark());
|
||||
jdbc.update("UPDATE fin_wallet_hold SET freeze_ledger_id=? WHERE id=?", ledger.ledgerId(), holdId);
|
||||
audit(customerId, "WALLET_HOLD", holdId, "FREEZE", request.remark(), before, after, 0, "业务系统");
|
||||
complete(customerId, 0, "WALLET_HOLD_FREEZE", idempotencyKey, holdId);
|
||||
return requiredHold(holdId, customerId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public WalletHold settle(long customerId, String businessType, String businessNo,
|
||||
String idempotencyKey, WalletHoldActionRequest request) {
|
||||
return completeHold(customerId, businessType, businessNo, idempotencyKey, request, true);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public WalletHold release(long customerId, String businessType, String businessNo,
|
||||
String idempotencyKey, WalletHoldActionRequest request) {
|
||||
return completeHold(customerId, businessType, businessNo, idempotencyKey, request, false);
|
||||
}
|
||||
|
||||
private WalletHold completeHold(long customerId, String rawBusinessType, String rawBusinessNo,
|
||||
String idempotencyKey, WalletHoldActionRequest request, boolean settle) {
|
||||
requirePositive(customerId, "客户");
|
||||
String businessType = businessType(rawBusinessType);
|
||||
String businessNo = businessNo(rawBusinessNo);
|
||||
String operation = settle ? "WALLET_HOLD_SETTLE" : "WALLET_HOLD_RELEASE";
|
||||
String requestHash = hash(Map.of("customerId", customerId, "businessType", businessType,
|
||||
"businessNo", businessNo, "remark", String.valueOf(request == null ? null : request.remark())));
|
||||
Long replay = reserve(customerId, 0, operation, idempotencyKey, requestHash);
|
||||
if (replay != null) return requiredHold(replay, customerId);
|
||||
WalletAccount before = accountForUpdate(customerId);
|
||||
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 frozen = before.frozenBalance().subtract(hold.amount());
|
||||
BigDecimal creditFrozen = before.creditFrozen().subtract(hold.creditFrozenAmount());
|
||||
WalletAccount after = accountState(actual, frozen, before.creditLimit(), creditUsed,
|
||||
creditFrozen, before.version());
|
||||
updateAccount(customerId, after, before.version(), 0);
|
||||
String action = settle ? "SETTLED" : "RELEASED";
|
||||
WalletLedgerEntry ledger = insertLedger(customerId, settle ? "CONSUMPTION" : "UNFREEZE",
|
||||
settle ? hold.amount().negate() : hold.amount(), after, businessType, businessNo,
|
||||
LocalDate.now(), null, businessNo, 0, "业务系统", request == null ? null : request.remark());
|
||||
jdbc.update("UPDATE fin_wallet_hold SET status=?,action_ledger_id=?,completed_at=CURRENT_TIMESTAMP(3) WHERE id=? AND status='FROZEN'",
|
||||
action, ledger.ledgerId(), hold.holdId());
|
||||
audit(customerId, "WALLET_HOLD", hold.holdId(), action,
|
||||
request == null ? null : request.remark(), before, after, 0, "业务系统");
|
||||
complete(customerId, 0, operation, idempotencyKey, hold.holdId());
|
||||
return requiredHold(hold.holdId(), customerId);
|
||||
}
|
||||
|
||||
public ResponseEntity<byte[]> voucher(long requestId) {
|
||||
RechargeRequest request = requiredRecharge(requestId, null);
|
||||
long customerId = beforeCustomerId(requestId);
|
||||
@@ -228,23 +346,22 @@ 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,version,created_by,updated_by) VALUES(?,'CNY',0,0,0,0,0,0)", 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);
|
||||
return jdbc.queryForObject(
|
||||
"SELECT currency_code,actual_balance,frozen_balance,available_balance,version,updated_at FROM fin_wallet_account WHERE customer_id=? FOR UPDATE",
|
||||
(rs, n) -> new WalletAccount(rs.getString("currency_code"), rs.getBigDecimal("actual_balance"),
|
||||
rs.getBigDecimal("frozen_balance"), rs.getBigDecimal("available_balance"), rs.getInt("version"),
|
||||
timestamp(rs.getTimestamp("updated_at"))), customerId);
|
||||
"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",
|
||||
(rs, n) -> accountRow(rs), customerId);
|
||||
}
|
||||
|
||||
private void updateAccount(long customerId, BigDecimal actual, BigDecimal frozen, BigDecimal available,
|
||||
int expectedVersion, long operatorId) {
|
||||
int updated = jdbc.update("UPDATE fin_wallet_account SET actual_balance=?,frozen_balance=?,available_balance=?,version=version+1,updated_by=? WHERE customer_id=? AND version=?",
|
||||
actual, frozen, available, operatorId, customerId, expectedVersion);
|
||||
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=?",
|
||||
account.actualBalance(), account.frozenBalance(), account.availableBalance(), account.creditLimit(),
|
||||
account.creditUsed(), account.creditFrozen(), operatorId, customerId, expectedVersion);
|
||||
if (updated != 1) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
|
||||
}
|
||||
|
||||
private WalletLedgerEntry insertLedger(long customerId, String changeType, BigDecimal amount,
|
||||
BigDecimal actual, BigDecimal frozen, BigDecimal available,
|
||||
WalletAccount account,
|
||||
String sourceType, String sourceNo, LocalDate occurredDate,
|
||||
String paymentMethod, String referenceNo, long operatorId,
|
||||
String operatorName, String remark) {
|
||||
@@ -252,14 +369,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,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,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, actual); statement.setBigDecimal(6, frozen);
|
||||
statement.setBigDecimal(7, available); statement.setString(8, sourceType); statement.setString(9, sourceNo);
|
||||
statement.setObject(10, occurredDate); statement.setString(11, paymentMethod); statement.setString(12, referenceNo);
|
||||
statement.setLong(13, operatorId); statement.setString(14, clean(operatorName, 128));
|
||||
statement.setString(15, clean(remark, 500)); return statement;
|
||||
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;
|
||||
}, holder);
|
||||
return requiredLedger(generatedId(holder), customerId);
|
||||
}
|
||||
@@ -271,6 +391,102 @@ public class FinanceWalletService {
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
WalletAccount deductAccount(WalletAccount before, BigDecimal amount) {
|
||||
BigDecimal actualAvailable = before.actualBalance()
|
||||
.subtract(before.frozenBalance().subtract(before.creditFrozen()));
|
||||
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());
|
||||
}
|
||||
|
||||
WalletAccount accountState(BigDecimal actual, BigDecimal frozen, BigDecimal creditLimit,
|
||||
BigDecimal creditUsed, BigDecimal creditFrozen, int version) {
|
||||
BigDecimal availableCredit = creditLimit.subtract(creditUsed).subtract(creditFrozen);
|
||||
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) {
|
||||
throw error("FIN-WALLET-409", "账户余额状态无效");
|
||||
}
|
||||
return new WalletAccount("CNY", actual, frozen, available, creditLimit, creditUsed,
|
||||
creditFrozen, availableCredit, 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"),
|
||||
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);
|
||||
}
|
||||
|
||||
private long insertHold(long customerId, String businessType, String businessNo, BigDecimal amount,
|
||||
BigDecimal actualFrozen, BigDecimal creditFrozen, String remark) {
|
||||
KeyHolder holder = new GeneratedKeyHolder();
|
||||
try {
|
||||
jdbc.update(connection -> {
|
||||
PreparedStatement statement = connection.prepareStatement(
|
||||
"INSERT INTO fin_wallet_hold(customer_id,business_type,business_no,amount,actual_frozen_amount,credit_frozen_amount,status,remark) VALUES(?,?,?,?,?,?,'FROZEN',?)",
|
||||
Statement.RETURN_GENERATED_KEYS);
|
||||
statement.setLong(1, customerId); statement.setString(2, businessType);
|
||||
statement.setString(3, businessNo); statement.setBigDecimal(4, amount);
|
||||
statement.setBigDecimal(5, actualFrozen); statement.setBigDecimal(6, creditFrozen);
|
||||
statement.setString(7, remark); return statement;
|
||||
}, holder);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
throw error("FIN-IDEMPOTENCY-CONFLICT", "同一业务单号已存在资金冻结记录");
|
||||
}
|
||||
return generatedId(holder);
|
||||
}
|
||||
|
||||
private WalletHold requiredHold(long holdId, long customerId) {
|
||||
List<WalletHold> rows = jdbc.query("SELECT * FROM fin_wallet_hold WHERE id=? AND customer_id=?",
|
||||
(rs, n) -> holdRow(rs), holdId, customerId);
|
||||
if (rows.isEmpty()) throw error("FIN-WALLET-HOLD-404", "资金冻结记录不存在");
|
||||
return withAccount(rows.get(0));
|
||||
}
|
||||
|
||||
private WalletHold findHold(long customerId, String businessType, String businessNo, boolean forUpdate) {
|
||||
String sql = "SELECT * FROM fin_wallet_hold WHERE customer_id=? AND business_type=? AND business_no=?"
|
||||
+ (forUpdate ? " FOR UPDATE" : "");
|
||||
List<WalletHold> rows = jdbc.query(sql, (rs, n) -> holdRow(rs), customerId, businessType, businessNo);
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
private WalletHold withAccount(WalletHold hold) {
|
||||
return new WalletHold(hold.holdId(), hold.customerId(), hold.businessType(), hold.businessNo(),
|
||||
hold.amount(), hold.actualFrozenAmount(), hold.creditFrozenAmount(), hold.status(),
|
||||
hold.freezeLedgerId(), hold.actionLedgerId(), account(hold.customerId()), hold.createdAt(),
|
||||
hold.completedAt());
|
||||
}
|
||||
|
||||
private WalletHold holdRow(java.sql.ResultSet rs) throws java.sql.SQLException {
|
||||
long customerId = rs.getLong("customer_id");
|
||||
Number freezeLedger = (Number) rs.getObject("freeze_ledger_id");
|
||||
Number actionLedger = (Number) rs.getObject("action_ledger_id");
|
||||
return new WalletHold(rs.getLong("id"), customerId, rs.getString("business_type"),
|
||||
rs.getString("business_no"), rs.getBigDecimal("amount"), rs.getBigDecimal("actual_frozen_amount"),
|
||||
rs.getBigDecimal("credit_frozen_amount"), rs.getString("status"),
|
||||
freezeLedger == null ? null : freezeLedger.longValue(), actionLedger == null ? null : actionLedger.longValue(),
|
||||
null, timestamp(rs.getTimestamp("created_at")), timestamp(rs.getTimestamp("completed_at")));
|
||||
}
|
||||
|
||||
private RechargeRequest requiredRecharge(long requestId, Long customerId) {
|
||||
String sql = "SELECT * FROM fin_recharge_request WHERE id=?" + (customerId == null ? "" : " AND customer_id=?");
|
||||
Object[] args = customerId == null ? new Object[]{requestId} : new Object[]{requestId, customerId};
|
||||
@@ -328,7 +544,9 @@ public class FinanceWalletService {
|
||||
private WalletLedgerEntry ledgerRow(java.sql.ResultSet rs) throws java.sql.SQLException {
|
||||
return new WalletLedgerEntry(rs.getLong("id"), rs.getString("business_no"), rs.getString("change_type"),
|
||||
rs.getBigDecimal("amount"), rs.getBigDecimal("actual_balance_after"), rs.getBigDecimal("frozen_balance_after"),
|
||||
rs.getBigDecimal("available_balance_after"), rs.getString("source_type"), rs.getString("source_no"),
|
||||
rs.getBigDecimal("available_balance_after"), rs.getBigDecimal("credit_limit_after"),
|
||||
rs.getBigDecimal("credit_used_after"), rs.getBigDecimal("available_credit_after"),
|
||||
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")));
|
||||
}
|
||||
@@ -364,6 +582,23 @@ public class FinanceWalletService {
|
||||
}
|
||||
return value.setScale(2, RoundingMode.UNNECESSARY);
|
||||
}
|
||||
private static BigDecimal nonNegativeMoney(BigDecimal value, String label) {
|
||||
if (value == null || value.signum() < 0 || value.scale() > 2
|
||||
|| value.compareTo(new BigDecimal("999999999999.99")) > 0) {
|
||||
throw bad(label + "不能为负数且最多保留两位小数");
|
||||
}
|
||||
return value.setScale(2, RoundingMode.UNNECESSARY);
|
||||
}
|
||||
private static String businessType(String value) {
|
||||
String normalized = upper(value);
|
||||
if (!normalized.matches("[A-Z][A-Z0-9_]{1,31}")) throw bad("业务类型格式无效");
|
||||
return normalized;
|
||||
}
|
||||
private static String businessNo(String value) {
|
||||
String normalized = clean(value, 128);
|
||||
if (normalized == null) throw bad("业务单号不能为空");
|
||||
return normalized;
|
||||
}
|
||||
private static String paymentMethod(String value) {
|
||||
String normalized = upper(value);
|
||||
if (!PAYMENT_METHODS.contains(normalized)) throw bad("付款方式无效");
|
||||
|
||||
@@ -436,14 +436,17 @@ CREATE TABLE IF NOT EXISTS fin_settlement_payment (
|
||||
) ENGINE=InnoDB COMMENT='正式结算单人工收付款与凭证记录';
|
||||
|
||||
|
||||
-- 一件代发客户资金账户:充值先行,订单消费与冻结/解冻后续接入。
|
||||
-- 一件代发客户资金账户:实际余额优先,授信额度补足;冻结后结算或解冻。
|
||||
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 '账户实际余额,包含冻结资金',
|
||||
frozen_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结金额',
|
||||
available_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 '总可用余额=实际余额+授信额度-已用授信-冻结总额',
|
||||
credit_limit 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 '冻结总额中占用的授信',
|
||||
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',
|
||||
@@ -453,7 +456,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 actual_balance = frozen_balance + available_balance)
|
||||
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)
|
||||
) ENGINE=InnoDB COMMENT='客户CNY资金账户主账';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fin_recharge_request (
|
||||
@@ -487,11 +490,14 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
|
||||
business_no VARCHAR(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####',
|
||||
change_type VARCHAR(16) NOT NULL COMMENT '变动类型:RECHARGE充值、DEDUCTION扣款、FREEZE冻结、UNFREEZE解冻、CONSUMPTION消费',
|
||||
change_type VARCHAR(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整',
|
||||
amount DECIMAL(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负',
|
||||
actual_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后实际余额',
|
||||
frozen_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后冻结金额',
|
||||
available_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后可用余额',
|
||||
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 '变动后可用授信',
|
||||
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 '业务发生日期',
|
||||
@@ -507,5 +513,28 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
|
||||
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 actual_balance_after = frozen_balance_after + available_balance_after)
|
||||
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)
|
||||
) ENGINE=InnoDB COMMENT='客户资金账户不可变余额流水';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fin_wallet_hold (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
|
||||
business_type VARCHAR(32) NOT NULL COMMENT '调用方业务类型',
|
||||
business_no VARCHAR(128) NOT NULL COMMENT '调用方业务单号',
|
||||
amount DECIMAL(20,2) NOT NULL COMMENT '冻结总额',
|
||||
actual_frozen_amount DECIMAL(20,2) NOT NULL COMMENT '冻结时占用实际资金',
|
||||
credit_frozen_amount DECIMAL(20,2) NOT NULL COMMENT '冻结时占用授信',
|
||||
status VARCHAR(16) NOT NULL COMMENT '冻结状态:FROZEN冻结中、SETTLED已结算、RELEASED已释放',
|
||||
freeze_ledger_id BIGINT UNSIGNED NULL COMMENT '冻结流水ID',
|
||||
action_ledger_id BIGINT UNSIGNED NULL COMMENT '结算或解冻流水ID',
|
||||
remark VARCHAR(500) NULL COMMENT '业务说明',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '冻结记录创建时间',
|
||||
completed_at DATETIME(3) NULL COMMENT '结算或释放完成时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_fin_wallet_hold_business (customer_id,business_type,business_no),
|
||||
KEY idx_fin_wallet_hold_status (customer_id,status,created_at,id),
|
||||
CONSTRAINT chk_fin_wallet_hold_customer CHECK (customer_id > 0),
|
||||
CONSTRAINT chk_fin_wallet_hold_amount CHECK (amount > 0 AND actual_frozen_amount >= 0 AND credit_frozen_amount >= 0 AND amount=actual_frozen_amount+credit_frozen_amount),
|
||||
CONSTRAINT chk_fin_wallet_hold_status CHECK (status IN ('FROZEN','SETTLED','RELEASED'))
|
||||
) ENGINE=InnoDB COMMENT='客户资金冻结及结算生命周期';
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.baiyue.finance.service.FinanceEventException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -22,7 +23,7 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
class FinanceCustomerAccountServiceTest {
|
||||
@Test
|
||||
void listsMasterCustomersWithZeroFinanceMetricsAndForwardsSearchFields() {
|
||||
void listsMasterCustomersWithConfiguredInitialCreditAndForwardsSearchFields() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
IamCustomerClient iam = mock(IamCustomerClient.class);
|
||||
when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of());
|
||||
@@ -31,7 +32,7 @@ class FinanceCustomerAccountServiceTest {
|
||||
new IamCustomerClient.Customer(900000000000000001L, "TEN-001", "Alice Trading", "ACTIVE", "Alice", "13800138000")),
|
||||
1, 20, 1, 1)));
|
||||
|
||||
var result = new FinanceCustomerAccountService(jdbc, iam, "internal-token")
|
||||
var result = new FinanceCustomerAccountService(jdbc, iam, "internal-token", new BigDecimal("8000.00"))
|
||||
.list(1, 20, " Alice ", "active", 900000000000000001L);
|
||||
|
||||
AccountSummary row = result.data().get(0);
|
||||
@@ -40,6 +41,9 @@ class FinanceCustomerAccountServiceTest {
|
||||
assertThat(row.contactPhone()).isEqualTo("13800138000");
|
||||
assertThat(row.outstandingReceivable()).isZero();
|
||||
assertThat(row.pendingBilling()).isZero();
|
||||
assertThat(row.creditLimit()).isEqualByComparingTo("8000.00");
|
||||
assertThat(row.availableCredit()).isEqualByComparingTo("8000.00");
|
||||
assertThat(row.availableBalance()).isEqualByComparingTo("8000.00");
|
||||
verify(iam).page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L);
|
||||
}
|
||||
|
||||
@@ -52,7 +56,7 @@ class FinanceCustomerAccountServiceTest {
|
||||
new PageResult<>(List.of(new IamCustomerClient.Customer(9L, "C-9", "客户9", "ACTIVE", null, null)),
|
||||
1, 20, 1, 1)));
|
||||
|
||||
new FinanceCustomerAccountService(jdbc, iam, "internal-token").list(1, 20, null, null, 9L);
|
||||
new FinanceCustomerAccountService(jdbc, iam, "internal-token", BigDecimal.ZERO).list(1, 20, null, null, 9L);
|
||||
|
||||
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc, atLeastOnce()).queryForList(sql.capture(), any(Object[].class));
|
||||
@@ -68,7 +72,7 @@ class FinanceCustomerAccountServiceTest {
|
||||
|
||||
@Test
|
||||
void rejectsUnsupportedFinanceAccountStatus() {
|
||||
var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token");
|
||||
var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token", BigDecimal.ZERO);
|
||||
assertThatThrownBy(() -> service.list(1, 20, null, "PENDING", null))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code())
|
||||
|
||||
@@ -374,6 +374,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
9L, "9", "客户9", null, null, new BigDecimal("100.00"),
|
||||
new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, 0, 0,
|
||||
"ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0)));
|
||||
}
|
||||
|
||||
@@ -43,8 +43,8 @@ class FinanceSqlCommentContractTest {
|
||||
missing.add(currentTable + "." + column.group(1));
|
||||
}
|
||||
}
|
||||
assertThat(tableCount).isEqualTo(20);
|
||||
assertThat(columnCount).isEqualTo(310);
|
||||
assertThat(tableCount).isEqualTo(21);
|
||||
assertThat(columnCount).isEqualTo(330);
|
||||
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package com.baiyue.finance;
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.finance.client.FinanceFileClient;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.AccountAdjustmentRequest;
|
||||
import com.baiyue.finance.service.FinanceDocumentNumberGenerator;
|
||||
import com.baiyue.finance.service.FinanceEventException;
|
||||
import com.baiyue.finance.service.FinanceWalletService;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletAccount;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -32,6 +30,8 @@ class FinanceWalletBehaviorTest {
|
||||
assertThat(account.actualBalance()).isEqualByComparingTo("0.00");
|
||||
assertThat(account.frozenBalance()).isEqualByComparingTo("0.00");
|
||||
assertThat(account.availableBalance()).isEqualByComparingTo("0.00");
|
||||
assertThat(account.creditLimit()).isEqualByComparingTo("0.00");
|
||||
assertThat(account.availableCredit()).isEqualByComparingTo("0.00");
|
||||
verify(jdbc, never()).update(anyString(), any(Object[].class));
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@ class FinanceWalletBehaviorTest {
|
||||
@Test
|
||||
void developmentProfileUsesTheNonPayableSampleQrCode() {
|
||||
var channels = new FinanceWalletService(mock(JdbcTemplate.class), new ObjectMapper().findAndRegisterModules(),
|
||||
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", "", "", "cn", "development").channels();
|
||||
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", "", "",
|
||||
BigDecimal.ZERO, "cn", "development").channels();
|
||||
|
||||
assertThat(channels).allSatisfy(channel -> {
|
||||
assertThat(channel.enabled()).isTrue();
|
||||
@@ -68,6 +69,9 @@ class FinanceWalletBehaviorTest {
|
||||
when(rs.getBigDecimal("actual_balance")).thenReturn(new BigDecimal("10.00"));
|
||||
when(rs.getBigDecimal("frozen_balance")).thenReturn(BigDecimal.ZERO);
|
||||
when(rs.getBigDecimal("available_balance")).thenReturn(new BigDecimal("10.00"));
|
||||
when(rs.getBigDecimal("credit_limit")).thenReturn(BigDecimal.ZERO);
|
||||
when(rs.getBigDecimal("credit_used")).thenReturn(BigDecimal.ZERO);
|
||||
when(rs.getBigDecimal("credit_frozen")).thenReturn(BigDecimal.ZERO);
|
||||
when(rs.getInt("version")).thenReturn(2);
|
||||
when(rs.getTimestamp("updated_at")).thenReturn(Timestamp.valueOf("2026-09-05 08:00:00"));
|
||||
return mapper.mapRow(rs, 0);
|
||||
@@ -81,8 +85,45 @@ class FinanceWalletBehaviorTest {
|
||||
verify(jdbc, never()).update(startsWith("UPDATE fin_wallet_account SET actual_balance="), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void configuredCreditLimitIsUsedForAFirstAccountRead() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.query(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
|
||||
|
||||
var account = service(jdbc, "x", "y", new BigDecimal("8000.00")).account(9L);
|
||||
|
||||
assertThat(account.creditLimit()).isEqualByComparingTo("8000.00");
|
||||
assertThat(account.availableCredit()).isEqualByComparingTo("8000.00");
|
||||
assertThat(account.availableBalance()).isEqualByComparingTo("8000.00");
|
||||
verify(jdbc, never()).update(anyString(), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deductionUsesCashBeforeCreditAndRechargeRepaysCreditFirst() {
|
||||
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
|
||||
WalletAccount initial = service.accountState(new BigDecimal("100.00"), BigDecimal.ZERO,
|
||||
new BigDecimal("50.00"), BigDecimal.ZERO, BigDecimal.ZERO, 3);
|
||||
|
||||
WalletAccount deducted = service.deductAccount(initial, new BigDecimal("120.00"));
|
||||
assertThat(deducted.actualBalance()).isEqualByComparingTo("0.00");
|
||||
assertThat(deducted.creditUsed()).isEqualByComparingTo("20.00");
|
||||
assertThat(deducted.availableCredit()).isEqualByComparingTo("30.00");
|
||||
assertThat(deducted.availableBalance()).isEqualByComparingTo("30.00");
|
||||
|
||||
WalletAccount recharged = service.rechargeAccount(deducted, new BigDecimal("25.00"));
|
||||
assertThat(recharged.creditUsed()).isEqualByComparingTo("0.00");
|
||||
assertThat(recharged.actualBalance()).isEqualByComparingTo("5.00");
|
||||
assertThat(recharged.availableBalance()).isEqualByComparingTo("55.00");
|
||||
}
|
||||
|
||||
private static FinanceWalletService service(JdbcTemplate jdbc, String wechat, String alipay) {
|
||||
return service(jdbc, wechat, alipay, BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
private static FinanceWalletService service(JdbcTemplate jdbc, String wechat, String alipay,
|
||||
BigDecimal defaultCreditLimit) {
|
||||
return new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
||||
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", wechat, alipay, "test", "test");
|
||||
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", wechat, alipay,
|
||||
defaultCreditLimit, "test", "test");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,19 +14,23 @@ class FinanceWalletContractTest {
|
||||
String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
|
||||
assertThat(sql)
|
||||
.contains("CREATE TABLE IF NOT EXISTS fin_wallet_account")
|
||||
.contains("actual_balance = frozen_balance + available_balance")
|
||||
.contains("available_balance = actual_balance+credit_limit-credit_used-frozen_balance")
|
||||
.contains("credit_limit >= credit_used+credit_frozen")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fin_recharge_request")
|
||||
.contains("status IN ('PENDING','APPROVED','REJECTED')")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fin_wallet_ledger")
|
||||
.contains("amount <> 0")
|
||||
.contains("uk_fin_wallet_ledger_no")
|
||||
.contains("idx_fin_recharge_customer_status");
|
||||
.contains("idx_fin_recharge_customer_status")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fin_wallet_hold")
|
||||
.contains("amount=actual_frozen_amount+credit_frozen_amount");
|
||||
}
|
||||
|
||||
@Test
|
||||
void controllersEnforceCustomerScopeAndSeparateWritePermissions() throws Exception {
|
||||
String client = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/ClientFinanceWalletController.java"));
|
||||
String platform = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceWalletController.java"));
|
||||
String internal = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/InternalFinanceWalletController.java"));
|
||||
assertThat(client)
|
||||
.contains("user.tenantId()")
|
||||
.contains("merchant:finance:account:view")
|
||||
@@ -36,6 +40,13 @@ class FinanceWalletContractTest {
|
||||
assertThat(platform)
|
||||
.contains("platform:finance:recharge:review")
|
||||
.contains("platform:finance:customer-account:adjust")
|
||||
.contains("/accounts/{customerId}/credit-limit")
|
||||
.contains("Idempotency-Key");
|
||||
assertThat(internal)
|
||||
.contains("/internal/finance/v1/wallet")
|
||||
.contains("X-Internal-Service-Token")
|
||||
.contains("/holds/{businessType}/{businessNo}/settle")
|
||||
.contains("/holds/{businessType}/{businessNo}/release")
|
||||
.contains("Idempotency-Key");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user