feat: complete resumable payment settlement

agent/payment-training-progress
chenyuan 2 weeks ago
parent 754cb2620b
commit 2ddaa9d0ee

@ -181,7 +181,7 @@ public class PaymentApplicationService {
@Transactional
public void cancel(UUID id, PaymentActor actor) {
PaymentOrder order = ownedForUpdate(id, actor);
if (order.getStatus().ordinal() >= PaymentStatus.CENTRAL_SETTLED.ordinal()) {
if (order.getStatus().ordinal() >= PaymentStatus.CENTRAL_OWNERSHIP_TRANSFERRED.ordinal()) {
throw validation("中央银行已完成权属转移,不能取消支付实验");
}
if (order.getStatus() == PaymentStatus.COMPLIANCE_LOCKED
@ -192,6 +192,98 @@ public class PaymentApplicationService {
}
}
public PaymentOrderView verifyPayerBankSignature(UUID id, PaymentActor actor) {
PaymentOrder order = owned(id, actor);
if (order.getStatus().ordinal() < PaymentStatus.PAYER_BANK_ACCEPTED.ordinal()) {
throw validation("请先完成付款银行合规处理");
}
resourceRepository.verifyPayerBankSignature(order, actor);
return view(order, actor);
}
@Transactional
public PaymentOrderView transferOwnership(UUID id, PaymentActor actor) {
PaymentOrder order = ownedForUpdate(id, actor);
if (order.getStatus() != PaymentStatus.PAYER_BANK_ACCEPTED) {
return atOrAfter(order, PaymentStatus.PAYER_BANK_ACCEPTED, actor);
}
resourceRepository.verifyPayerBankSignature(order, actor);
order.transferOwnership(resourceRepository.transferOwnership(order, actor));
orderRepository.save(order, actor.getUsername());
return view(order, actor);
}
@Transactional
public PaymentOrderView concatenateSettlement(UUID id, PaymentActor actor) {
PaymentOrder order = ownedForUpdate(id, actor);
if (order.getStatus() != PaymentStatus.CENTRAL_OWNERSHIP_TRANSFERRED) {
return atOrAfter(order, PaymentStatus.CENTRAL_OWNERSHIP_TRANSFERRED, actor);
}
String clearingNo = "ACS_" + order.getPaymentNo();
String original = "SETTLEMENT|" + order.getPaymentNo() + "|" + order.getTransactionId() + "|" +
order.getPayerWalletId() + "|" + order.getPayeeWalletId() + "|" + order.getCoinCount() + "|" +
order.getAmount().toPlainString() + "|" + order.getPayerBankCode() + "|" +
order.getPayeeBankCode() + "|" + clearingNo + "|" + clock.instant().toString();
order.prepareSettlement(clearingNo, original);
orderRepository.save(order, actor.getUsername());
return view(order, actor);
}
@Transactional
public PaymentOrderView generateSettlementDigest(UUID id, PaymentActor actor) {
PaymentOrder order = ownedForUpdate(id, actor);
if (order.getStatus() != PaymentStatus.SETTLEMENT_TEXT_PREPARED) {
return atOrAfter(order, PaymentStatus.SETTLEMENT_TEXT_PREPARED, actor);
}
order.digestSettlement(cryptography.sm3(order.getSettlementOriginalText()));
orderRepository.save(order, actor.getUsername());
return view(order, actor);
}
@Transactional
public PaymentOrderView signSettlement(UUID id, PaymentActor actor) {
PaymentOrder order = ownedForUpdate(id, actor);
if (order.getStatus() != PaymentStatus.SETTLEMENT_DIGESTED) {
return atOrAfter(order, PaymentStatus.SETTLEMENT_DIGESTED, actor);
}
order.signSettlement(resourceRepository.signSettlementDigest(order, actor, order.getSettlementDigest()));
orderRepository.save(order, actor.getUsername());
return view(order, actor);
}
@Transactional
public PaymentOrderView clearAtCentralBank(UUID id, PaymentActor actor) {
PaymentOrder order = ownedForUpdate(id, actor);
if (order.getStatus() != PaymentStatus.CENTRAL_SIGNED) {
return atOrAfter(order, PaymentStatus.CENTRAL_SIGNED, actor);
}
resourceRepository.recordCentralSettlement(order, actor);
order.recordCentralClearing();
orderRepository.save(order, actor.getUsername());
resourceRepository.appendStepLog(order, "04", "中央银行结算", order.getSettlementOriginalText(), actor);
return view(order, actor);
}
public PaymentOrderView verifyCentralBankSignature(UUID id, PaymentActor actor) {
PaymentOrder order = owned(id, actor);
if (order.getStatus().ordinal() < PaymentStatus.CENTRAL_SETTLED.ordinal()) {
throw validation("请先完成中央银行结算");
}
resourceRepository.verifyCentralBankSignature(order, actor);
return view(order, actor);
}
public PaymentOrderView prepareCredit(UUID id, PaymentActor actor) {
PaymentOrder order = owned(id, actor);
if (order.getStatus() != PaymentStatus.CENTRAL_SETTLED) {
if (order.getStatus().ordinal() > PaymentStatus.CENTRAL_SETTLED.ordinal()) return view(order, actor);
throw validation("请先完成中央银行结算");
}
resourceRepository.verifyCentralBankSignature(order, actor);
resourceRepository.validateCredit(order);
return view(order, actor);
}
@Transactional
public PaymentOrderView settleAtCentralBank(UUID id, PaymentActor actor) {
PaymentOrder order = ownedForUpdate(id, actor);
@ -225,6 +317,29 @@ public class PaymentApplicationService {
return view(order, actor);
}
@Transactional
public PaymentOrderView notifyPayer(UUID id, PaymentActor actor) {
PaymentOrder order = ownedForUpdate(id, actor);
if (order.getStatus() != PaymentStatus.PAYEE_CREDITED) {
return atOrAfter(order, PaymentStatus.PAYEE_CREDITED, actor);
}
resourceRepository.createPayerNotification(order);
return view(order, actor);
}
@Transactional
public PaymentOrderView notifyPayee(UUID id, PaymentActor actor) {
PaymentOrder order = ownedForUpdate(id, actor);
if (order.getStatus() != PaymentStatus.PAYEE_CREDITED) {
return atOrAfter(order, PaymentStatus.PAYEE_CREDITED, actor);
}
resourceRepository.createPayeeNotification(order);
order.complete(clock.instant());
orderRepository.save(order, actor.getUsername());
resourceRepository.appendStepLog(order, "06", "用户返回", "已生成支付回执和到账通知", actor);
return view(order, actor);
}
public PaymentOrderView get(UUID id, PaymentActor actor) {
PaymentOrder order = order(id);
if (!order.involves(actor.getUserId())) throw forbidden();

@ -41,7 +41,12 @@ public class PaymentTrainingActionService {
"02:wallet-sign", "02:package-payment", "02:send-payment",
"03:verify-payer-signature", "03:compliance-check", "03:generate-compliance-report",
"03:generate-compliance-digest", "03:bank-sign", "03:package-bank-request",
"03:send-to-central-bank");
"03:send-to-central-bank",
"04:verify-payer-bank-signature", "04:reserve-and-transfer-ownership",
"04:concatenate-settlement", "04:generate-settlement-digest", "04:central-bank-sign",
"04:acs-clear", "04:package-settlement", "04:return-settlement",
"05:verify-central-bank-signature", "05:prepare-credit", "05:execute-credit",
"06:advance-receipt-flow", "06:notify-payer", "06:notify-payee");
private final ExperimentAttemptService attempts;
private final PaymentApplicationService payments;
@ -100,6 +105,9 @@ public class PaymentTrainingActionService {
case "02:send-payment":
case "03:package-bank-request":
case "03:send-to-central-bank":
case "04:package-settlement":
case "04:return-settlement":
case "06:advance-receipt-flow":
return completed(payments.get(paymentId, actor));
case "03:verify-payer-signature":
return completed(payments.verifyPayerSignature(paymentId, actor));
@ -111,6 +119,28 @@ public class PaymentTrainingActionService {
return completed(payments.generateComplianceDigest(paymentId, actor));
case "03:bank-sign":
return completed(payments.signCompliance(paymentId, actor));
case "04:verify-payer-bank-signature":
return completed(payments.verifyPayerBankSignature(paymentId, actor));
case "04:reserve-and-transfer-ownership":
return completed(payments.transferOwnership(paymentId, actor));
case "04:concatenate-settlement":
return completed(payments.concatenateSettlement(paymentId, actor));
case "04:generate-settlement-digest":
return completed(payments.generateSettlementDigest(paymentId, actor));
case "04:central-bank-sign":
return completed(payments.signSettlement(paymentId, actor));
case "04:acs-clear":
return completed(payments.clearAtCentralBank(paymentId, actor));
case "05:verify-central-bank-signature":
return completed(payments.verifyCentralBankSignature(paymentId, actor));
case "05:prepare-credit":
return completed(payments.prepareCredit(paymentId, actor));
case "05:execute-credit":
return completed(payments.creditPayee(paymentId, actor));
case "06:notify-payer":
return completed(payments.notifyPayer(paymentId, actor));
case "06:notify-payee":
return ActionOutcome.completedAttempt(payments.notifyPayee(paymentId, actor));
default:
throw validation("不支持的支付实验动作:" + key);
}

@ -122,11 +122,38 @@ public final class PaymentOrder {
coinCount = 0;
status = PaymentStatus.PAYER_SIGNED;
}
public void settle(String transactionId, String clearingNo, String originalText, String digest, String signature) {
require(PaymentStatus.PAYER_BANK_ACCEPTED); this.transactionId = required(transactionId); this.clearingNo = required(clearingNo);
settlementOriginalText = required(originalText); settlementDigest = required(digest); centralBankSignature = required(signature);
public void transferOwnership(String value) {
require(PaymentStatus.PAYER_BANK_ACCEPTED);
transactionId = required(value);
status = PaymentStatus.CENTRAL_OWNERSHIP_TRANSFERRED;
}
public void prepareSettlement(String value, String originalText) {
require(PaymentStatus.CENTRAL_OWNERSHIP_TRANSFERRED);
clearingNo = required(value);
settlementOriginalText = required(originalText);
status = PaymentStatus.SETTLEMENT_TEXT_PREPARED;
}
public void digestSettlement(String digest) {
require(PaymentStatus.SETTLEMENT_TEXT_PREPARED);
settlementDigest = required(digest);
status = PaymentStatus.SETTLEMENT_DIGESTED;
}
public void signSettlement(String signature) {
require(PaymentStatus.SETTLEMENT_DIGESTED);
centralBankSignature = required(signature);
status = PaymentStatus.CENTRAL_SIGNED;
}
public void recordCentralClearing() {
require(PaymentStatus.CENTRAL_SIGNED);
status = PaymentStatus.CENTRAL_SETTLED;
}
public void settle(String transactionId, String clearingNo, String originalText, String digest, String signature) {
transferOwnership(transactionId);
prepareSettlement(clearingNo, originalText);
digestSettlement(digest);
signSettlement(signature);
recordCentralClearing();
}
public void creditPayee(BigDecimal payerAfter, BigDecimal payeeAfter) {
require(PaymentStatus.CENTRAL_SETTLED); payerBalanceAfter = moneyOrZero(payerAfter); payeeBalanceAfter = moneyOrZero(payeeAfter);
status = PaymentStatus.PAYEE_CREDITED;

@ -5,6 +5,10 @@ public enum PaymentStatus {
PAYER_SIGNED,
COMPLIANCE_LOCKED,
PAYER_BANK_ACCEPTED,
CENTRAL_OWNERSHIP_TRANSFERRED,
SETTLEMENT_TEXT_PREPARED,
SETTLEMENT_DIGESTED,
CENTRAL_SIGNED,
CENTRAL_SETTLED,
PAYEE_CREDITED,
SUCCESS

@ -20,10 +20,18 @@ public interface PaymentResourceRepository {
List<PaymentCoin> lockForCompliance(PaymentOrder order, PaymentActor actor);
void releasePaymentResources(PaymentOrder order, PaymentActor actor);
String signComplianceDigest(PaymentActor actor, String digest);
void verifyPayerBankSignature(PaymentOrder order, PaymentActor actor);
String transferOwnership(PaymentOrder order, PaymentActor actor);
String signSettlementDigest(PaymentOrder order, PaymentActor actor, String digest);
void recordCentralSettlement(PaymentOrder order, PaymentActor actor);
void verifyCentralBankSignature(PaymentOrder order, PaymentActor actor);
void validateCredit(PaymentOrder order);
PayerBankProcessingResult processPayerBank(PaymentOrder order, PaymentActor actor);
CentralSettlementResult settleAtCentralBank(PaymentOrder order, PaymentActor actor);
PaymentCreditResult creditPayee(PaymentOrder order, PaymentActor actor);
void createNotifications(PaymentOrder order, PaymentActor actor);
void createPayerNotification(PaymentOrder order);
void createPayeeNotification(PaymentOrder order);
List<PaymentCoin> findCoins(PaymentOrder order);
List<PaymentNotification> findNotifications(PaymentOrder order, String currentUserId);
void appendStepLog(PaymentOrder order, String stepCode, String stepName, String output, PaymentActor actor);

@ -138,6 +138,76 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository
return signature;
}
@Override
public void verifyPayerBankSignature(PaymentOrder order, PaymentActor actor) {
InstitutionKeySubject subject = subject(actor);
if (!keyService.verifyCommercialBank(subject, order.getComplianceDigest(), order.getPayerBankSignature())) {
throw validation("中央银行验证商业银行A签名失败");
}
}
@Override
public String transferOwnership(PaymentOrder order, PaymentActor actor) {
List<PaymentCoin> coins = findCoins(order);
if (coins.isEmpty() || coins.size() != order.getCoinCount()) {
throw validation("付款币串锁定记录不完整");
}
String transactionId = "PAY_TXN_" + order.getPaymentNo();
for (PaymentCoin coin : coins) {
if (!ownershipLocks.transferLocked(coin.getCurrencyId(), "WALLET", order.getPayerWalletId(),
"PAYMENT", order.getId().toString(), "WALLET", order.getPayeeWalletId(), transactionId)) {
throw validation("中央银行登记中心币串权属已变化:" + coin.getCurrencyId());
}
int updated = jdbc.update("UPDATE payment_coin_reservation SET status='TRANSFERRED'," +
"transferred_at=CURRENT_TIMESTAMP WHERE payment_id=? AND currency_id=? AND status='RESERVED'",
order.getId().toString(), coin.getCurrencyId());
if (updated != 1) throw validation("付款币串预留状态已变化:" + coin.getCurrencyId());
}
return transactionId;
}
@Override
public String signSettlementDigest(PaymentOrder order, PaymentActor actor, String digest) {
InstitutionKeySubject subject = subject(actor);
String signature = keyService.signCentralBank(subject, digest);
if (!keyService.verifyCentralBank(subject, digest, signature)) {
throw validation("中央银行结算确认签名验证失败");
}
return signature;
}
@Override
public void recordCentralSettlement(PaymentOrder order, PaymentActor actor) {
jdbc.update("INSERT INTO payment_ownership_transfer (transaction_id,payment_id,from_wallet_id,to_wallet_id," +
"amount,coin_count,settlement_digest,central_bank_signature,status,confirmed_at,confirmed_by) " +
"VALUES (?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP,?)",
order.getTransactionId(), order.getId().toString(), order.getPayerWalletId(), order.getPayeeWalletId(),
order.getAmount(), order.getCoinCount(), order.getSettlementDigest(),
order.getCentralBankSignature(), actor.getUsername());
jdbc.update("INSERT INTO payment_clearing_record (clearing_no,payment_id,payer_bank_code,payee_bank_code," +
"amount,status,settled_at) VALUES (?,?,?,?,?,'SETTLED',CURRENT_TIMESTAMP)",
order.getClearingNo(), order.getId().toString(), order.getPayerBankCode(),
order.getPayeeBankCode(), order.getAmount());
}
@Override
public void verifyCentralBankSignature(PaymentOrder order, PaymentActor actor) {
InstitutionKeySubject subject = subject(actor);
if (!keyService.verifyCentralBank(subject, order.getSettlementDigest(), order.getCentralBankSignature())) {
throw validation("商业银行B验证中央银行签名失败");
}
}
@Override
public void validateCredit(PaymentOrder order) {
WalletSnapshot payer = wallet(order.getPayerWalletId());
WalletSnapshot payee = wallet(order.getPayeeWalletId());
if (payer.frozenAmount.compareTo(order.getAmount()) < 0 || payer.balance.compareTo(order.getAmount()) < 0) {
throw validation("付款钱包冻结金额或余额异常");
}
if (!"ACTIVE".equals(payee.status)) throw validation("收款钱包状态异常");
}
@Override
public CentralSettlementResult settleAtCentralBank(PaymentOrder order, PaymentActor actor) {
InstitutionKeySubject subject = subject(actor);
@ -178,12 +248,16 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository
@Override
public PaymentCreditResult creditPayee(PaymentOrder order, PaymentActor actor) {
InstitutionKeySubject subject = subject(actor);
if (!keyService.verifyCentralBank(subject, order.getSettlementDigest(), order.getCentralBankSignature())) {
throw validation("商业银行B验证中央银行签名失败");
verifyCentralBankSignature(order, actor);
WalletSnapshot payer;
WalletSnapshot payee;
if (order.getPayerWalletId().compareTo(order.getPayeeWalletId()) <= 0) {
payer = lockWallet(order.getPayerWalletId());
payee = lockWallet(order.getPayeeWalletId());
} else {
payee = lockWallet(order.getPayeeWalletId());
payer = lockWallet(order.getPayerWalletId());
}
WalletSnapshot payer = lockWallet(order.getPayerWalletId());
WalletSnapshot payee = lockWallet(order.getPayeeWalletId());
if (payer.frozenAmount.compareTo(order.getAmount()) < 0 || payer.balance.compareTo(order.getAmount()) < 0) {
throw validation("付款钱包冻结金额或余额异常");
}
@ -206,11 +280,22 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository
@Override
public void createNotifications(PaymentOrder order, PaymentActor actor) {
createPayerNotification(order);
createPayeeNotification(order);
}
@Override
public void createPayerNotification(PaymentOrder order) {
Instant now = Instant.now();
jdbc.update("INSERT INTO payment_notification (id,payment_id,recipient_user_id,recipient_wallet_id,notification_type,content,status,created_at) " +
"VALUES (?,?,?,?,?,?, 'CREATED',?)",
UUID.randomUUID().toString(), order.getId().toString(), order.getPayerUserId(), order.getPayerWalletId(),
"PAYMENT_RECEIPT", "支付成功:" + order.getPaymentNo(), java.sql.Timestamp.from(now));
}
@Override
public void createPayeeNotification(PaymentOrder order) {
Instant now = Instant.now();
jdbc.update("INSERT INTO payment_notification (id,payment_id,recipient_user_id,recipient_wallet_id,notification_type,content,status,created_at) " +
"VALUES (?,?,?,?,?,?, 'CREATED',?)",
UUID.randomUUID().toString(), order.getId().toString(), order.getPayeeUserId(), order.getPayeeWalletId(),
@ -267,6 +352,15 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository
return values.get(0);
}
private WalletSnapshot wallet(String walletId) {
List<WalletSnapshot> values = jdbc.query("SELECT balance,COALESCE(frozen_amount,0) frozen_amount,status " +
"FROM digital_wallet WHERE wallet_id=?",
(rs, row) -> new WalletSnapshot(rs.getBigDecimal("balance"), rs.getBigDecimal("frozen_amount"),
rs.getString("status")), walletId);
if (values.isEmpty()) throw validation("钱包不存在");
return values.get(0);
}
private void assertPayerContract(PaymentOrder order, PaymentActor actor) {
List<ContractSnapshot> values = jdbc.query("SELECT single_payment_limit,daily_payment_limit,daily_used_amount,daily_counter_date," +
"annual_payment_limit,annual_used_amount,annual_counter_year,status,valid_until FROM wallet_contract " +

@ -94,15 +94,44 @@ public class PaymentController {
@PostMapping("/{id}/central-bank-settle")
@Operation(summary = "用户支付数字货币:步骤四,中央银行结算、权属变更和跨行清算")
public ApiResponse<PaymentOrderView> centralBankSettle(@PathVariable UUID id) { return ok(service.settleAtCentralBank(id, actor())); }
public ApiResponse<PaymentOrderView> centralBankSettle(@PathVariable UUID id) {
CurrentUser user = currentUserService.getCurrentUser();
ExperimentAttemptView attempt = matchingAttempt(id, user);
if (attempt == null) return ok(service.settleAtCentralBank(id, actor(user)));
String[] actionCodes = {"verify-payer-bank-signature", "reserve-and-transfer-ownership",
"concatenate-settlement", "generate-settlement-digest", "central-bank-sign", "acs-clear",
"package-settlement", "return-settlement"};
for (String actionCode : actionCodes) {
run(attempt, "04", actionCode, new PaymentActionRequest(), user);
}
return ok(service.get(id, actor(user)));
}
@PostMapping("/{id}/payee-bank-credit")
@Operation(summary = "用户支付数字货币步骤五商业银行B验签并向收款钱包入账")
public ApiResponse<PaymentOrderView> payeeBankCredit(@PathVariable UUID id) { return ok(service.creditPayee(id, actor())); }
public ApiResponse<PaymentOrderView> payeeBankCredit(@PathVariable UUID id) {
CurrentUser user = currentUserService.getCurrentUser();
ExperimentAttemptView attempt = matchingAttempt(id, user);
if (attempt == null) return ok(service.creditPayee(id, actor(user)));
String[] actionCodes = {"verify-central-bank-signature", "prepare-credit", "execute-credit"};
for (String actionCode : actionCodes) {
run(attempt, "05", actionCode, new PaymentActionRequest(), user);
}
return ok(service.get(id, actor(user)));
}
@PostMapping("/{id}/notifications")
@Operation(summary = "用户支付数字货币:步骤六,生成付款回执和收款到账通知")
public ApiResponse<PaymentOrderView> notifications(@PathVariable UUID id) { return ok(service.createNotifications(id, actor())); }
public ApiResponse<PaymentOrderView> notifications(@PathVariable UUID id) {
CurrentUser user = currentUserService.getCurrentUser();
ExperimentAttemptView attempt = matchingAttempt(id, user);
if (attempt == null) return ok(service.createNotifications(id, actor(user)));
String[] actionCodes = {"advance-receipt-flow", "notify-payer", "notify-payee"};
for (String actionCode : actionCodes) {
run(attempt, "06", actionCode, new PaymentActionRequest(), user);
}
return ok(service.get(id, actor(user)));
}
@GetMapping("/{id}")
@Operation(summary = "查询用户支付数字货币实验订单和步骤结果")

@ -0,0 +1,66 @@
package com.yau.digitalrmb.payment.application.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yau.digitalrmb.payment.domain.model.PaymentActor;
import com.yau.digitalrmb.payment.interfaces.dto.PaymentActionRequest;
import com.yau.digitalrmb.training.attempt.application.ExperimentAttemptService;
import com.yau.digitalrmb.training.attempt.domain.ActionStatus;
import com.yau.digitalrmb.training.attempt.domain.AttemptStatus;
import com.yau.digitalrmb.training.attempt.domain.ExperimentModule;
import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject;
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentActionView;
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentAttemptView;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class PaymentTrainingActionServiceTest {
@Test
void acceptsTheFirstCentralSettlementActionAfterCompliance() {
ExperimentAttemptService attempts = mock(ExperimentAttemptService.class);
PaymentApplicationService payments = mock(PaymentApplicationService.class);
PaymentTrainingActionService service = new PaymentTrainingActionService(attempts, payments, new ObjectMapper());
UUID attemptId = UUID.randomUUID();
ExperimentSubject subject = new ExperimentSubject("user-1", 1L, 2L, "");
PaymentActor actor = new PaymentActor("user-1", "tester", 1L, 2L);
ExperimentAttemptView attempt = completedThroughCompliance(attemptId);
when(attempts.detail(attemptId, subject)).thenReturn(attempt);
when(attempts.execute(any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(action(attemptId, "04", "verify-payer-bank-signature"));
assertThatCode(() -> service.execute(attemptId, "04", "verify-payer-bank-signature",
new PaymentActionRequest(), subject, actor)).doesNotThrowAnyException();
}
private ExperimentAttemptView completedThroughCompliance(UUID attemptId) {
String[][] keys = {
{"01", "refresh-payer"}, {"01", "refresh-payee"},
{"02", "generate-timestamp"}, {"02", "concatenate-payment"},
{"02", "generate-digest"}, {"02", "wallet-sign"},
{"02", "package-payment"}, {"02", "send-payment"},
{"03", "verify-payer-signature"}, {"03", "compliance-check"},
{"03", "generate-compliance-report"}, {"03", "generate-compliance-digest"},
{"03", "bank-sign"}, {"03", "package-bank-request"},
{"03", "send-to-central-bank"}
};
List<ExperimentActionView<?>> actions = new ArrayList<ExperimentActionView<?>>();
for (String[] key : keys) actions.add(action(attemptId, key[0], key[1]));
return new ExperimentAttemptView(attemptId, ExperimentModule.PAYMENT, 1,
AttemptStatus.IN_PROGRESS, "04", "verify-payer-bank-signature", UUID.randomUUID().toString(),
Instant.now(), Instant.now(), null, actions);
}
private ExperimentActionView<Object> action(UUID attemptId, String step, String action) {
return new ExperimentActionView<Object>(attemptId, ExperimentModule.PAYMENT, step, action,
ActionStatus.COMPLETED, AttemptStatus.IN_PROGRESS, step, action,
Instant.now(), null);
}
}

@ -128,6 +128,8 @@ class PaymentControllerTest {
.andExpect(jsonPath("$.data.paymentOriginalText").value(org.hamcrest.Matchers.startsWith("PAY|")))
.andReturn().getResponse().getContentAsString();
String id = JsonPath.read(created, "$.data.id");
String attemptId = jdbc.queryForObject("SELECT id FROM training_experiment_attempt " +
"WHERE module_code='PAYMENT' AND business_id=? ORDER BY attempt_no DESC LIMIT 1", String.class, id);
mockMvc.perform(post("/api/v1/payments/{id}/sign", id).with(payer()))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("PAYER_SIGNED"));
@ -145,13 +147,27 @@ class PaymentControllerTest {
mockMvc.perform(post("/api/v1/payments/{id}/central-bank-settle", id).with(payer()))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("CENTRAL_SETTLED"))
.andExpect(jsonPath("$.data.clearingNo").value(org.hamcrest.Matchers.startsWith("ACS_PAY_REQ_")));
mockMvc.perform(get("/api/v1/payment/attempts/current").with(payer()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.currentStepCode").value("05"))
.andExpect(jsonPath("$.data.currentActionCode").value("verify-central-bank-signature"));
mockMvc.perform(post("/api/v1/payment/attempts/{id}/cancel", attemptId).with(payer()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
mockMvc.perform(post("/api/v1/payments/{id}/payee-bank-credit", id).with(payer()))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("PAYEE_CREDITED"))
.andExpect(jsonPath("$.data.payerBalanceAfter").value(0.00))
.andExpect(jsonPath("$.data.payeeBalanceAfter").value(200.00));
mockMvc.perform(post("/api/v1/payments/{id}/payee-bank-credit", id).with(payer()))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("PAYEE_CREDITED"));
mockMvc.perform(post("/api/v1/payments/{id}/notifications", id).with(payer()))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("SUCCESS"))
.andExpect(jsonPath("$.data.notifications.length()").value(1))
.andExpect(jsonPath("$.data.notifications[0].notificationType").value("PAYMENT_RECEIPT"));
mockMvc.perform(get("/api/v1/payments/{id}/notifications", id).with(payee()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.notifications.length()").value(1))
.andExpect(jsonPath("$.data.notifications[0].notificationType").value("CREDIT_NOTICE"));
mockMvc.perform(get("/api/v1/training-tasks/CURRENCY_PAYMENT").with(payer()))
.andExpect(status().isOk())
@ -254,6 +270,44 @@ class PaymentControllerTest {
Integer.class, id)).isZero();
}
@Test
void resumesAfterOwnershipTransferAndCreditsWalletsExactlyOnce() throws Exception {
String id = createSignedPayment(PAYER_WALLET, PAYEE_WALLET, payer());
mockMvc.perform(post("/api/v1/payments/{id}/payer-bank-process", id).with(payer()))
.andExpect(status().isOk());
String attemptId = jdbc.queryForObject("SELECT id FROM training_experiment_attempt " +
"WHERE module_code='PAYMENT' AND business_id=? ORDER BY attempt_no DESC LIMIT 1", String.class, id);
executeAction(attemptId, "04", "verify-payer-bank-signature", payer());
executeAction(attemptId, "04", "reserve-and-transfer-ownership", payer());
mockMvc.perform(get("/api/v1/payments/{id}", id).with(payer()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("CENTRAL_OWNERSHIP_TRANSFERRED"));
assertThat(jdbc.queryForObject("SELECT owner_id FROM central_bank_currency_ownership " +
"WHERE currency_id='DC_PAYMENT_1'", String.class)).isEqualTo(PAYEE_WALLET);
assertThat(jdbc.queryForObject("SELECT balance FROM digital_wallet WHERE wallet_id=?",
BigDecimal.class, PAYER_WALLET)).isEqualByComparingTo("200.00");
assertThat(jdbc.queryForObject("SELECT frozen_amount FROM digital_wallet WHERE wallet_id=?",
BigDecimal.class, PAYER_WALLET)).isEqualByComparingTo("200.00");
mockMvc.perform(post("/api/v1/payment/attempts/{id}/cancel", attemptId).with(payer()))
.andExpect(status().isBadRequest());
String[] remainingSettlement = {"concatenate-settlement", "generate-settlement-digest",
"central-bank-sign", "acs-clear", "package-settlement", "return-settlement"};
for (String action : remainingSettlement) executeAction(attemptId, "04", action, payer());
executeAction(attemptId, "05", "verify-central-bank-signature", payer());
executeAction(attemptId, "05", "prepare-credit", payer());
executeAction(attemptId, "05", "execute-credit", payer());
executeAction(attemptId, "05", "execute-credit", payer());
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM payment_wallet_ledger " +
"WHERE payment_id=? AND direction='DEBIT'", Integer.class, id)).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM payment_wallet_ledger " +
"WHERE payment_id=? AND direction='CREDIT'", Integer.class, id)).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT balance FROM digital_wallet WHERE wallet_id=?",
BigDecimal.class, PAYEE_WALLET)).isEqualByComparingTo("200.00");
}
private String createSignedPayment(String payerWalletId, String payeeWalletId,
RequestPostProcessor actor) throws Exception {
String created = mockMvc.perform(post("/api/v1/payments").with(actor).contentType(MediaType.APPLICATION_JSON)
@ -267,6 +321,15 @@ class PaymentControllerTest {
return id;
}
private void executeAction(String attemptId, String step, String action,
RequestPostProcessor actor) throws Exception {
mockMvc.perform(post("/api/v1/payment/attempts/{id}/steps/{step}/actions/{action}",
attemptId, step, action).with(actor)
.contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.actionStatus").value("COMPLETED"));
}
private void insertUser(String id, String account, String schoolId, String classId) {
jdbc.update("INSERT INTO sys_user (user_id,student_id,password,user_name,class_id,class_name,school_id,school_name,role_id,is_deleted,zy_user_id) " +
"VALUES (?,?,?,?,?,'支付测试班',?,'延安大学',4,0,?)", id, account, "unused", account, classId, schoolId, account);

@ -61,7 +61,14 @@ class PaymentTrainingAttemptControllerTest {
{"03", "verify-payer-signature"}, {"03", "compliance-check"},
{"03", "generate-compliance-report"}, {"03", "generate-compliance-digest"},
{"03", "bank-sign"}, {"03", "package-bank-request"},
{"03", "send-to-central-bank"}
{"03", "send-to-central-bank"},
{"04", "verify-payer-bank-signature"}, {"04", "reserve-and-transfer-ownership"},
{"04", "concatenate-settlement"}, {"04", "generate-settlement-digest"},
{"04", "central-bank-sign"}, {"04", "acs-clear"},
{"04", "package-settlement"}, {"04", "return-settlement"},
{"05", "verify-central-bank-signature"}, {"05", "prepare-credit"},
{"05", "execute-credit"}, {"06", "advance-receipt-flow"},
{"06", "notify-payer"}, {"06", "notify-payee"}
};
for (String[] action : actionCodes) {
mockMvc.perform(post("/api/v1/payment/attempts/{id}/steps/{step}/actions/{action}",

Loading…
Cancel
Save