费用提交
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user