费用提交

This commit is contained in:
2026-10-26 13:41:43 +08:00
parent 0fe1d10566
commit 761fe3686e
171 changed files with 8750 additions and 386 deletions
@@ -7,11 +7,16 @@ 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;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletRefundRequest;
import com.baiyue.finance.service.FinanceWalletService;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/finance/v1")
@@ -62,4 +67,25 @@ public class PlatformFinanceWalletController {
@GetMapping("/recharge-requests/{requestId}/voucher")
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
public ResponseEntity<byte[]> voucher(@PathVariable long requestId) { return service.voucher(requestId); }
/** 退款必须由平台财务针对原消费发起,复用资金服务的账户锁及累计可退上限。 */
@PostMapping("/accounts/{customerId}/ledger/{ledgerId}/refunds")
@PreAuthorize("hasAuthority('platform:finance:customer-account:adjust')")
public ApiResponse<?> refund(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long customerId, @PathVariable long ledgerId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody WalletRefundRequest request) {
if (user == null || user.userId() == null || !"PLATFORM".equals(user.appCode()) || !Long.valueOf(0L).equals(user.tenantId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台财务可操作退款");
}
if (request == null || idempotencyKey == null || idempotencyKey.isBlank()
|| idempotencyKey.trim().length() > 128) throw new IllegalArgumentException("退款请求及幂等键必填且幂等键不超过128字符");
if (request.reason() == null || request.reason().isBlank() || request.reason().length() > 500)
throw new IllegalArgumentException("退款原因必填且不超过500字符");
// 技术退款引用由客户和幂等键确定;资金流水号仍由正式 AC 编号生成器分配。
String refundNo = "MANUAL_REFUND:" + UUID.nameUUIDFromBytes(
(customerId + ":" + idempotencyKey.trim()).getBytes(StandardCharsets.UTF_8));
return FinanceResponses.success(service.refundConsumption(customerId, ledgerId, refundNo,
idempotencyKey.trim(), request.amount(), user.userId(), user.displayName(), request.reason().trim()));
}
}
@@ -54,6 +54,9 @@ public final class FinanceWalletDtos {
public record WalletHoldActionRequest(String remark) { }
/** 关联原消费退款,原因必填,原流水保持不可变。 */
public record WalletRefundRequest(BigDecimal amount, String reason) { }
public record WalletHold(long holdId, long customerId, String businessType,
String businessNo, BigDecimal amount,
BigDecimal actualFrozenAmount, BigDecimal creditFrozenAmount,
@@ -135,6 +135,7 @@ public class BusinessFeeSourceEventService {
positive(value.walletSettlement().holdId(), "walletSettlement.holdId");
positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId");
if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用");
walletIdentity(value);
} else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())) {
throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池");
}
@@ -154,7 +155,7 @@ public class BusinessFeeSourceEventService {
throw error("FIN-EVT-012", "应付来源供应商快照不一致");
validateSettlement(value);
} else if (!blank(value.supplierId()) || !blank(value.supplierName()) || !blank(value.supplierCategory())
|| !blank(value.settlementMode()) || value.paidAt() != null
|| value.settlementMode() != null && !Set.of("BILL", "WALLET").contains(value.settlementMode()) || value.paidAt() != null
|| !blank(value.paymentMethod()) || !blank(value.paymentReferenceNo())
|| (value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty())) {
throw error("FIN-EVT-012", "应收来源不得携带供应商或付款事实");
@@ -202,9 +203,10 @@ public class BusinessFeeSourceEventService {
long customerId = positive(source.customerId(), "customerId");
long holdId = positive(reference.holdId(), "holdId");
long ledgerId = positive(reference.ledgerId(), "ledgerId");
String[] identity = walletIdentity(source);
List<Map<String, Object>> rows = jdbc.queryForList(
"SELECT l.amount,c.result_json FROM fin_wallet_hold h JOIN fin_wallet_ledger l ON l.id=h.action_ledger_id AND l.customer_id=h.customer_id JOIN fin_wallet_command_inbox c ON c.event_id=? AND c.status='SUCCEEDED' WHERE h.id=? AND l.id=? AND h.customer_id=? AND h.business_type=? AND h.business_no=? AND h.status='SETTLED' AND l.change_type='CONSUMPTION' FOR UPDATE",
reference.commandEventId(), holdId, ledgerId, customerId, source.businessType(), source.businessNo());
reference.commandEventId(), holdId, ledgerId, customerId, identity[0], identity[1]);
if (rows.size() != 1 || !(rows.get(0).get("amount") instanceof java.math.BigDecimal amount)
|| amount.negate().compareTo(source.totalAmount()) != 0) {
throw error("FIN-EVT-017", "费用来源与成功扣款的客户、业务或金额不匹配");
@@ -218,8 +220,8 @@ public class BusinessFeeSourceEventService {
|| !reference.holdId().equals(result.path("walletHoldId").asText())
|| !reference.ledgerId().equals(result.path("walletLedgerId").asText())
|| result.path("customerId").asLong() != customerId
|| !source.businessType().equals(result.path("businessType").asText())
|| !source.businessNo().equals(result.path("businessNo").asText())
|| !identity[0].equals(result.path("businessType").asText())
|| !identity[1].equals(result.path("businessNo").asText())
|| result.path("amount").decimalValue().compareTo(source.totalAmount()) != 0) {
throw error("FIN-EVT-017", "费用来源引用的资金结果不匹配");
}
@@ -234,6 +236,25 @@ public class BusinessFeeSourceEventService {
feeSourceId);
}
/** 独立锁款仅接收已约定的人工费用或仓租身份,不能借此挂接任意业务扣款。 */
private String[] walletIdentity(BusinessFeeSourceChangedV1 source) {
var reference = source.walletSettlement();
String type = reference.walletBusinessType(), no = reference.walletBusinessNo();
if (type == null && no == null) return new String[]{source.businessType(), source.businessNo()};
if (blank(type) || blank(no) || !"LASTLEG".equals(source.sourceSystem()))
throw error("FIN-EVT-017", "资金业务引用不完整");
if ("OVERSEAS_MANUAL_FEE".equals(type)) {
if ("FEE_TYPE_077".equals(source.feeItemCode())
|| !no.matches(java.util.regex.Pattern.quote(source.sourceFeeId()) + "-[1-9][0-9]*"))
throw error("FIN-EVT-017", "人工费用资金引用与来源不匹配");
} else if ("STORAGE_RENT".equals(type)) {
if (!"FEE_TYPE_077".equals(source.feeItemCode()))
throw error("FIN-EVT-017", "仓租资金引用与费用项目不匹配");
positive(no, "walletBusinessNo");
} else throw error("FIN-EVT-017", "不支持的独立资金业务引用");
return new String[]{type, no};
}
private void materializeDirectPaidCost(String system, long feeId, BusinessFeeSourceChangedV1 value) {
jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,warehouse_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','BUSINESS_DIRECT_PAID',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)",
positive(value.customerId(), "customerId"), "DIRECT-" + system + '-' + value.sourceType() + '-' + feeId + "-V" + value.sourceVersion(),
@@ -0,0 +1,61 @@
openapi: 3.0.3
info:
title: 平台资金账户关联退款
version: 1.0.0
description: 原消费保持不可变;退款记录关联原消费流水号,累计退款不得超过原消费金额,包含业务退款事件已退金额。
paths:
/api/platform/finance/v1/accounts/{customerId}/ledger/{ledgerId}/refunds:
post:
operationId: refundWalletConsumption
summary: 原消费关联退款
description: 仅平台 tenantId=0 且具有 platform:finance:customer-account:adjust 权限可操作。账户行锁串行校验累计可退金额,无需前端提交账户版本。优先归还已用授信,再增加实际余额。相同键同请求返回原流水;相同键不同原消费或金额失败。非成功消费、跨客户原流水、超额退款均拒绝。
security:
- bearerAuth: []
parameters:
- {name: customerId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: ledgerId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [amount, reason]
properties:
amount: {type: number, minimum: 0.01, maximum: 999999999999.99, multipleOf: 0.01}
reason: {type: string, minLength: 1, maxLength: 500, description: 必填退款原因,不得仅空白}
responses:
'200':
description: 统一响应 code=200 成功;业务失败使用 FIN-REQ-400、FIN-WALLET-404、FIN-IDEMPOTENCY-CONFLICT 或 FIN-WALLET-REFUND-EXCEEDED 等业务码。
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data:
$ref: '#/components/schemas/RefundLedgerEntry'
'403': {description: 非平台主体或缺少调账权限}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
schemas:
RefundLedgerEntry:
type: object
description: 完整返回 FinanceWalletDtos.WalletLedgerEntry,资金余额字段沿用账户流水协议。
properties:
ledgerId: {type: string}
businessNo: {type: string, description: 新退款流水正式 AC 单号}
changeType: {type: string, enum: [REFUND]}
amount: {type: number, description: 实际退款正金额}
sourceType: {type: string, enum: [BUSINESS_REFUND]}
sourceNo: {type: string, description: 客户及幂等键派生的稳定技术退款引用}
referenceNo: {type: string, description: 原消费流水 businessNo,用于关联及累计可退校验}
operatorName: {type: string, nullable: true}
remark: {type: string, description: 退款原因}
actualBalanceAfter: {type: number}
frozenBalanceAfter: {type: number}
availableBalanceAfter: {type: number}
creditUsedAfter: {type: number}
availableCreditAfter: {type: number}
@@ -286,4 +286,82 @@ class BusinessFeeSourceEventServiceTest {
base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null,
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
}
@Test
void manualConsumptionUsesDedicatedFundingIdentityWhilePreservingServiceOrder() {
verifyDedicatedConsumption("OVERSEAS_MANUAL_FEE", "701-2", "FEE_TYPE_071");
}
@Test
void rentConsumptionUsesPreparationIdentityWhilePreservingServiceOrder() {
verifyDedicatedConsumption("STORAGE_RENT", "445", "FEE_TYPE_077");
}
@Test
void dedicatedFundingCannotReferenceAnotherManualFeeOrWrongFeeItem() {
assertThatThrownBy(() -> service.consume(dedicatedEvent("OVERSEAS_MANUAL_FEE", "702-1", "FEE_TYPE_071")))
.hasMessageContaining("来源不匹配");
assertThatThrownBy(() -> service.consume(dedicatedEvent("STORAGE_RENT", "445", "FEE_TYPE_071")))
.hasMessageContaining("项目不匹配");
noFinancialWrites();
}
@Test
void dedicatedConsumptionCannotReuseHoldAlreadyLinkedToAnotherSource() {
stubDedicatedConsumption("STORAGE_RENT", "445");
when(jdbc.queryForList(startsWith("SELECT id FROM fin_fee_source WHERE wallet_hold_id="),
eq(Long.class), eq(9L), eq(901L))).thenReturn(List.of(902L));
assertThatThrownBy(() -> service.consume(dedicatedEvent("STORAGE_RENT", "445", "FEE_TYPE_077")))
.hasMessageContaining("已关联其他费用");
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), any(Object[].class));
}
@Test
void dedicatedConsumptionRejectsResultFromDifferentFundingBusiness() {
stubDedicatedConsumption("STORAGE_RENT", "445");
when(jdbc.queryForList(startsWith("SELECT l.amount,c.result_json"), eq("command-1"), eq(9L), eq(72L),
eq(10001L), eq("STORAGE_RENT"), eq("445"))).thenReturn(List.of(Map.of(
"amount", new BigDecimal("-120.00"), "result_json", """
{"result":"SUCCEEDED","operation":"SETTLE","commandEventId":"command-1",
"walletHoldId":"9","walletLedgerId":"72","customerId":10001,
"businessType":"STORAGE_RENT","businessNo":"446","amount":120.00}
""")));
assertThatThrownBy(() -> service.consume(dedicatedEvent("STORAGE_RENT", "445", "FEE_TYPE_077")))
.hasMessageContaining("资金结果不匹配");
}
private void verifyDedicatedConsumption(String type, String no, String item) {
stubDedicatedConsumption(type, no);
EventEnvelope event = dedicatedEvent(type, no, item);
service.consume(event);
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), eq(9L), eq(901L), eq(10001L));
assertThat(event.payload().path("businessType").asText()).isEqualTo("WAREHOUSE_SERVICE");
assertThat(event.payload().path("businessNo").asText()).isEqualTo("PO-US-001");
}
private void stubDedicatedConsumption(String type, String no) {
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
eq("LASTLEG"), eq("OVERSEAS_WAREHOUSE_FEE"), eq(701L), eq(1))).thenReturn(901L);
ObjectNode result = json.createObjectNode();
result.put("result", "SUCCEEDED").put("operation", "SETTLE").put("commandEventId", "command-1")
.put("walletHoldId", "9").put("walletLedgerId", "72").put("customerId", 10001)
.put("businessType", type).put("businessNo", no).put("amount", new BigDecimal("120.00"));
when(jdbc.queryForList(startsWith("SELECT l.amount,c.result_json"), eq("command-1"), eq(9L), eq(72L),
eq(10001L), eq(type), eq(no))).thenReturn(List.of(Map.of(
"amount", new BigDecimal("-120.00"), "result_json", result.toString())));
}
private EventEnvelope dedicatedEvent(String type, String no, String item) {
EventEnvelope base = eventWithoutInbound();
ObjectNode payload = base.payload().deepCopy();
payload.put("feeItemCode", item);
payload.putObject("walletSettlement").put("commandEventId", "command-1").put("holdId", "9")
.put("ledgerId", "72").put("walletBusinessType", type).put("walletBusinessNo", no);
payload.putNull("action"); payload.putNull("sourceHash");
payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
payload.put("action", "PUBLISHED");
return new EventEnvelope(base.eventId(), base.eventType(), base.eventVersion(), base.occurredAt(), base.tenantId(),
base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null,
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
}
}
@@ -0,0 +1,183 @@
package com.baiyue.finance.service;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.dto.FinanceWalletDtos.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/** 真实资金服务、隔离数据库及事务验证海外费用释放、实扣与重复操作。 */
class OverseasFeeWalletClosureTest {
private JdbcTemplate jdbc;
private FinanceWalletService wallet;
private TransactionTemplate transaction;
/** 复用正式资金表定义,所有造数只进入本测试独立内存库。 */
@BeforeEach void setup() throws Exception {
var dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1");
jdbc = new JdbcTemplate(dataSource);
transaction = new TransactionTemplate(new DataSourceTransactionManager(dataSource));
var sql = Files.readString(Path.of("src/main/resources/db/finance.sql"));
for (var table : List.of("fin_wallet_account", "fin_wallet_hold", "fin_wallet_ledger", "fin_idempotency_record", "fin_audit_log")) {
var matcher = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS " + table + " \\(.*?\\) ENGINE=[^;]+;").matcher(sql);
assertThat(matcher.find()).as(table).isTrue();
// H2 使用同一列及约束,但去除 MySQL 引擎与更新时间自动生成,避免多列生成键差异。
var ddl = matcher.group().replaceAll("(?s) ENGINE=.*", "")
.replaceAll("(?i) UNSIGNED", "")
.replaceAll("(?i) ON UPDATE CURRENT_TIMESTAMP\\(3\\)", "")
.replaceAll("(?i) DEFAULT CURRENT_TIMESTAMP\\(3\\)", "")
.replaceAll("(?i)((?:created_at|updated_at) DATETIME\\(3\\)) NOT NULL", "$1 NULL");
jdbc.execute(ddl);
}
var numbers = mock(FinanceDocumentNumberGenerator.class);
var sequence = new AtomicInteger();
when(numbers.next(anyString())).thenAnswer(call -> "AC-TEST-" + sequence.incrementAndGet());
wallet = new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(),
mock(FinanceFileClient.class), numbers, "test", "", "test", "test");
jdbc.update("INSERT 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(9,'CNY',60,0,160,100,0,0,?,0,0,0)", LocalDate.now().plusDays(7));
}
/** 驳回后释放原冻结,实际余额和授信均恢复可用且不发生消费。 */
@Test void rejectionReleasesCashAndCreditExactlyOnce() {
freeze();
assertThat(wallet.account(9).frozenBalance()).isEqualByComparingTo("80.00");
assertThat(wallet.account(9).creditFrozen()).isEqualByComparingTo("20.00");
var request = new WalletHoldActionRequest("金额录错,驳回费用");
transaction.execute(status -> wallet.release(9, "OVERSEAS_MANUAL_FEE", "701-1", "release", request));
transaction.execute(status -> wallet.release(9, "OVERSEAS_MANUAL_FEE", "701-1", "release", request));
var account = wallet.account(9);
assertThat(account.actualBalance()).isEqualByComparingTo("60.00");
assertThat(account.frozenBalance()).isZero(); assertThat(account.creditFrozen()).isZero();
assertThat(account.availableBalance()).isEqualByComparingTo("160.00");
assertThat(count("CONSUMPTION")).isZero(); assertThat(count("UNFREEZE")).isEqualTo(1);
}
/** 扣款事务失败后可重试原请求,多次点击或不同请求键均不重复消费。 */
@Test void failedSettlementRollsBackAndRetryConsumesOnlyOnce() {
freeze();
var request = new WalletHoldActionRequest("审核通过扣款");
assertThatThrownBy(() -> transaction.execute(status -> {
wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", request);
throw new IllegalStateException("模拟扣款事务提交前异常");
})).hasMessageContaining("模拟扣款");
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("60.00");
assertThat(count("CONSUMPTION")).isZero();
for (int repeat = 0; repeat < 3; repeat++)
transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", request));
assertThatThrownBy(() -> transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "another-key", request)))
.hasMessageContaining("已处理");
assertThat(wallet.account(9).actualBalance()).isZero();
assertThat(wallet.account(9).creditUsed()).isEqualByComparingTo("20.00");
assertThat(wallet.account(9).frozenBalance()).isZero(); assertThat(count("CONSUMPTION")).isEqualTo(1);
}
/** 第一版错扣退回复用手工充值;原因、关联单号和操作人进入资金流水。 */
@Test void manualCreditRetainsCorrectionReasonAndReplaysWithoutDoubleCredit() {
var request = new AccountAdjustmentRequest("RECHARGE", new BigDecimal("12.00"), LocalDate.now(),
"OTHER", "TR-001 / 费用701", "退回多收操作费", 0);
var first = transaction.execute(status -> wallet.adjust(9, 7, "财务员", "correction-credit", request));
var second = transaction.execute(status -> wallet.adjust(9, 7, "财务员", "correction-credit", request));
assertThat(second.ledgerId()).isEqualTo(first.ledgerId());
assertThat(first.remark()).isEqualTo("退回多收操作费");
assertThat(first.referenceNo()).isEqualTo("TR-001 / 费用701");
assertThat(first.operatorName()).isEqualTo("财务员");
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("72.00");
assertThat(count("RECHARGE")).isEqualTo(1);
}
/** 部分退款优先还授信,累计退款不能超过原消费,原消费事实保持不变。 */
@Test void linkedRefundRestoresCreditThenCashAndCannotExceedOriginalConsumption() {
freeze();
var hold = transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", new WalletHoldActionRequest("审核扣款")));
long original = hold.actionLedgerId();
var first = transaction.execute(status -> wallet.refundConsumption(9, original, "refund-1", "refund-key-1", new BigDecimal("25"), 7, "财务", "多收退款"));
var replay = transaction.execute(status -> wallet.refundConsumption(9, original, "refund-1", "refund-key-1", new BigDecimal("25"), 7, "财务", "多收退款"));
assertThat(replay.ledgerId()).isEqualTo(first.ledgerId());
assertThat(first.changeType()).isEqualTo("REFUND");
assertThat(first.sourceType()).isEqualTo("BUSINESS_REFUND");
assertThat(first.referenceNo()).isEqualTo(jdbc.queryForObject("SELECT business_no FROM fin_wallet_ledger WHERE id=?", String.class, original));
assertThat(wallet.account(9).creditUsed()).isZero();
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("5.00");
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, original, "refund-1", "refund-key-1", BigDecimal.ONE, 7, "财务", "变更金额"))).hasMessageContaining("幂等");
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, original, "refund-too-much", "refund-too-much", new BigDecimal("56"), 7, "财务", "退款"))).hasMessageContaining("超过");
transaction.execute(status -> wallet.refundConsumption(9, original, "refund-2", "refund-key-2", new BigDecimal("55"), 7, "财务", "余款退款"));
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("60.00");
assertThat(count("REFUND")).isEqualTo(2);
assertThat(count("CONSUMPTION")).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT amount FROM fin_wallet_ledger WHERE id=?", BigDecimal.class, original)).isEqualByComparingTo("-80.00");
}
/** 非消费流水、跨客户原流水及非正金额均不能改变余额。 */
@Test void linkedRefundRejectsWrongCustomerNonConsumptionAndInvalidAmount() {
freeze();
long frozenLedger = jdbc.queryForObject("SELECT id FROM fin_wallet_ledger WHERE change_type='FREEZE'", Long.class);
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, frozenLedger, "bad", "bad", BigDecimal.ONE, 7, "财务", "退款"))).hasMessageContaining("成功业务扣款");
var hold = transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", new WalletHoldActionRequest("扣款")));
jdbc.update("INSERT 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(10,'CNY',0,0,0,0,0,0,0,0,0)");
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(10, hold.actionLedgerId(), "wrong-customer", "wrong-customer", BigDecimal.ONE, 7, "财务", "退款"))).hasMessageContaining("不存在");
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, hold.actionLedgerId(), "zero", "zero", BigDecimal.ZERO, 7, "财务", "退款"))).isInstanceOf(RuntimeException.class);
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, hold.actionLedgerId(), "blank", "blank", BigDecimal.ONE, 7, "财务", " "))).hasMessageContaining("原因");
assertThat(count("REFUND")).isZero();
assertThat(wallet.account(9).creditUsed()).isEqualByComparingTo("20.00");
assertThat(wallet.account(10).actualBalance()).isZero();
}
/** 不同请求键的并发退款同样受原消费可退余额约束。 */
@Test void concurrentRefundsSerializeOnAccountAndNeverOverRefund() throws Exception {
freeze();
var hold = transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", new WalletHoldActionRequest("扣款")));
var start = new java.util.concurrent.CountDownLatch(1);
var executor = java.util.concurrent.Executors.newFixedThreadPool(2);
try {
var results = new java.util.ArrayList<java.util.concurrent.Future<Boolean>>();
for (int i = 0; i < 2; i++) {
final String reference = "concurrent-" + i;
results.add(executor.submit(() -> {
start.await();
try {
transaction.execute(status -> wallet.refundConsumption(9, hold.actionLedgerId(), reference, reference, new BigDecimal("50"), 7, "财务", "并发退款"));
return true;
} catch (FinanceEventException ex) {
assertThat(ex.code()).isEqualTo("FIN-WALLET-REFUND-EXCEEDED");
return false;
}
}));
}
start.countDown();
int succeeded = 0;
for (var result : results) if (result.get(10, java.util.concurrent.TimeUnit.SECONDS)) succeeded++;
assertThat(succeeded).isEqualTo(1);
assertThat(count("REFUND")).isEqualTo(1);
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("30.00");
} finally { executor.shutdownNow(); }
}
/** 冻结始终沿用同一海外费用业务身份。 */
private void freeze() {
transaction.execute(status -> wallet.freeze(9, "freeze", new WalletFreezeRequest(
new BigDecimal("80.00"), "OVERSEAS_MANUAL_FEE", "701-1", "海外费用")));
}
/** 按真实流水类型统计资金影响,避免只检查服务返回状态。 */
private int count(String type) {
return jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger WHERE customer_id=9 AND change_type=?", Integer.class, type);
}
}