PaymentService.java
package com.ecommerce.payment.application.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.ecommerce.payment.application.exception.PaymentError;
import com.ecommerce.payment.application.exception.PaymentException;
import com.ecommerce.payment.application.model.PaymentModels.CallbackCommand;
import com.ecommerce.payment.application.model.PaymentModels.CreatePaymentCommand;
import com.ecommerce.payment.application.model.PaymentModels.PaymentView;
import com.ecommerce.payment.application.port.TradePort;
import com.ecommerce.payment.application.port.TradePort.PaymentContext;
import com.ecommerce.payment.domain.OutboxStatus;
import com.ecommerce.payment.domain.PaymentStatus;
import com.ecommerce.payment.infrastructure.config.MockChannelProperties;
import com.ecommerce.payment.infrastructure.persistence.entity.CallbackSecurityAuditEntity;
import com.ecommerce.payment.infrastructure.persistence.entity.OutboxEventEntity;
import com.ecommerce.payment.infrastructure.persistence.entity.PaymentCallbackLogEntity;
import com.ecommerce.payment.infrastructure.persistence.entity.PaymentOrderEntity;
import com.ecommerce.payment.infrastructure.persistence.entity.PaymentTransactionEntity;
import com.ecommerce.payment.infrastructure.persistence.mapper.CallbackSecurityAuditMapper;
import com.ecommerce.payment.infrastructure.persistence.mapper.OutboxEventMapper;
import com.ecommerce.payment.infrastructure.persistence.mapper.PaymentCallbackLogMapper;
import com.ecommerce.payment.infrastructure.persistence.mapper.PaymentOrderMapper;
import com.ecommerce.payment.infrastructure.persistence.mapper.PaymentTransactionMapper;
import com.ecommerce.platform.common.observability.MessagingTracing;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
@Service
public class PaymentService {
private static final String MOCK_CHANNEL = "MOCK";
private final PaymentOrderMapper paymentMapper;
private final PaymentTransactionMapper transactionMapper;
private final PaymentCallbackLogMapper callbackMapper;
private final CallbackSecurityAuditMapper securityAuditMapper;
private final OutboxEventMapper outboxMapper;
private final TradePort tradePort;
private final MockChannelProperties channelProperties;
private final ObjectMapper objectMapper;
private final TransactionTemplate transactionTemplate;
private final MessagingTracing messagingTracing;
public PaymentService(
PaymentOrderMapper paymentMapper,
PaymentTransactionMapper transactionMapper,
PaymentCallbackLogMapper callbackMapper,
CallbackSecurityAuditMapper securityAuditMapper,
OutboxEventMapper outboxMapper,
TradePort tradePort,
MockChannelProperties channelProperties,
ObjectMapper objectMapper,
TransactionTemplate transactionTemplate,
MessagingTracing messagingTracing) {
this.paymentMapper = paymentMapper;
this.transactionMapper = transactionMapper;
this.callbackMapper = callbackMapper;
this.securityAuditMapper = securityAuditMapper;
this.outboxMapper = outboxMapper;
this.tradePort = tradePort;
this.channelProperties = channelProperties;
this.objectMapper = objectMapper;
this.transactionTemplate = transactionTemplate;
this.messagingTracing = messagingTracing;
}
public PaymentView createPayment(CreatePaymentCommand command) {
String channel = command.channel().toUpperCase(Locale.ROOT);
if (!MOCK_CHANNEL.equals(channel)) {
throw new PaymentException(PaymentError.UNSUPPORTED_CHANNEL);
}
String requestHash = sha256(command.orderNo() + "|" + channel);
PaymentView existing = findStablePayment(
command.userId(), command.idempotencyKey(), command.orderNo(), channel, requestHash);
if (existing != null) {
return existing;
}
PaymentContext context = tradePort.getPaymentContext(command.orderNo());
if (!context.userId().equals(command.userId())) {
throw new PaymentException(PaymentError.RESOURCE_NOT_FOUND);
}
Instant now = paymentMapper.currentTime();
if (!"PENDING_PAYMENT".equals(context.status())
|| !context.paymentDeadline().isAfter(now)) {
throw new PaymentException(PaymentError.INVALID_STATE);
}
long id = IdWorker.getId();
return Objects.requireNonNull(transactionTemplate.execute(ignored -> {
PaymentOrderEntity candidate = new PaymentOrderEntity();
candidate.setId(id);
candidate.setPaymentNo("PAY" + id);
candidate.setOrderNo(context.orderNo());
candidate.setUserId(command.userId());
candidate.setReservationNo(context.reservationNo());
candidate.setIdempotencyKey(command.idempotencyKey());
candidate.setRequestHash(requestHash);
candidate.setChannel(channel);
candidate.setStatus(PaymentStatus.PROCESSING.name());
candidate.setAmount(context.totalAmount());
candidate.setVersion(0);
candidate.setCreatedAt(now);
candidate.setUpdatedAt(now);
paymentMapper.insertOrLockExisting(candidate);
PaymentOrderEntity payment = paymentMapper.selectByIdempotencyForUpdate(
command.userId(), command.idempotencyKey());
if (payment != null) {
if (!constantEquals(payment.getRequestHash(), requestHash)) {
throw new PaymentException(PaymentError.IDEMPOTENCY_CONFLICT);
}
return view(payment);
}
payment = paymentMapper.selectByOrderForUpdate(context.orderNo());
if (payment == null) {
throw new PaymentException(PaymentError.CONCURRENT_MODIFICATION);
}
if (!payment.getUserId().equals(command.userId()) || !payment.getChannel().equals(channel)) {
throw new PaymentException(PaymentError.IDEMPOTENCY_CONFLICT);
}
return view(payment);
}));
}
public PaymentView getPayment(Long userId, String paymentNo) {
PaymentOrderEntity payment = requirePayment(paymentNo);
if (!payment.getUserId().equals(userId)) {
throw new PaymentException(PaymentError.RESOURCE_NOT_FOUND);
}
return view(payment);
}
public PaymentView getPaymentByIdempotencyKey(Long userId, String idempotencyKey) {
PaymentOrderEntity payment = paymentMapper.selectByIdempotency(userId, idempotencyKey);
if (payment == null) {
throw new PaymentException(PaymentError.RESOURCE_NOT_FOUND);
}
return view(payment);
}
public PaymentView getPaymentByOrder(Long userId, String orderNo) {
PaymentOrderEntity payment = paymentMapper.selectByOrder(orderNo);
if (payment == null || !payment.getUserId().equals(userId)) {
throw new PaymentException(PaymentError.RESOURCE_NOT_FOUND);
}
return view(payment);
}
public PaymentView processMockCallback(CallbackCommand command) {
Instant receivedAt = paymentMapper.currentTime();
if (!MockCallbackSignature.verify(command, channelProperties.callbackSecret())) {
recordUntrustedCallback(command, receivedAt);
throw new PaymentException(PaymentError.INVALID_SIGNATURE);
}
if (callbackExpired(command.timestamp(), receivedAt)) {
recordRejectedCallback(command, PaymentError.CALLBACK_EXPIRED, true, receivedAt);
throw new PaymentException(PaymentError.CALLBACK_EXPIRED);
}
try {
return Objects.requireNonNull(transactionTemplate.execute(ignored -> processValidCallback(command, receivedAt)));
} catch (PaymentException exception) {
recordRejectedCallback(command, exception.error(), true, receivedAt);
throw exception;
}
}
private PaymentView processValidCallback(CallbackCommand command, Instant receivedAt) {
String requestHash = callbackHash(command);
PaymentCallbackLogEntity candidate = callbackLog(command, requestHash, true, "RECEIVED", null, receivedAt);
callbackMapper.insertOrLockExisting(candidate);
PaymentCallbackLogEntity callback = callbackMapper.selectForUpdate(MOCK_CHANNEL, command.externalEventId());
if (callback == null) {
throw new PaymentException(PaymentError.CONCURRENT_MODIFICATION);
}
if (!constantEquals(callback.getRequestHash(), requestHash)) {
throw new PaymentException(PaymentError.IDEMPOTENCY_CONFLICT);
}
if ("PROCESSED".equals(callback.getProcessingStatus())) {
return view(requirePayment(command.paymentNo()));
}
if (!Boolean.TRUE.equals(callback.getSignatureValid())) {
throw new PaymentException(PaymentError.IDEMPOTENCY_CONFLICT);
}
PaymentOrderEntity payment = paymentMapper.selectByPaymentNoForUpdate(command.paymentNo());
if (payment == null) {
throw new PaymentException(PaymentError.RESOURCE_NOT_FOUND);
}
if (payment.getAmount().compareTo(command.amount()) != 0) {
throw new PaymentException(PaymentError.AMOUNT_MISMATCH);
}
if (!MOCK_CHANNEL.equals(payment.getChannel())) {
throw new PaymentException(PaymentError.INVALID_STATE);
}
if ("SUCCESS".equals(command.status())) {
applySuccess(payment, command, receivedAt);
} else if ("FAILED".equals(command.status())) {
applyFailure(payment, command, receivedAt);
} else {
throw new PaymentException(PaymentError.INVALID_STATE);
}
callback.setProcessingStatus("PROCESSED");
callback.setProcessedAt(receivedAt);
callback.setErrorMessage(null);
requireUpdated(callbackMapper.updateById(callback));
return view(payment);
}
private void applySuccess(PaymentOrderEntity payment, CallbackCommand command, Instant now) {
if (PaymentStatus.SUCCESS.name().equals(payment.getStatus())) {
requireSameChannelTransactionNo(payment, command);
return;
}
if (!PaymentStatus.PROCESSING.name().equals(payment.getStatus())) {
throw new PaymentException(PaymentError.INVALID_STATE);
}
payment.setStatus(PaymentStatus.SUCCESS.name());
payment.setChannelTransactionNo(command.externalTransactionNo());
payment.setPaidAt(now);
payment.setUpdatedAt(now);
requireUpdated(paymentMapper.updateById(payment));
insertTransaction(payment, command, "SUCCESS", now);
appendPaymentSucceeded(payment, now);
}
private void applyFailure(PaymentOrderEntity payment, CallbackCommand command, Instant now) {
if (PaymentStatus.FAILED.name().equals(payment.getStatus())) {
requireSameChannelTransactionNo(payment, command);
return;
}
if (!PaymentStatus.PROCESSING.name().equals(payment.getStatus())) {
throw new PaymentException(PaymentError.INVALID_STATE);
}
payment.setStatus(PaymentStatus.FAILED.name());
payment.setChannelTransactionNo(command.externalTransactionNo());
payment.setUpdatedAt(now);
requireUpdated(paymentMapper.updateById(payment));
insertTransaction(payment, command, "FAILED", now);
}
private void insertTransaction(
PaymentOrderEntity payment,
CallbackCommand command,
String status,
Instant now) {
PaymentTransactionEntity existing = transactionMapper.selectByChannelTransactionNoForUpdate(
MOCK_CHANNEL, command.externalTransactionNo());
if (existing != null) {
if (!existing.getPaymentId().equals(payment.getId())
|| existing.getAmount().compareTo(command.amount()) != 0
|| !existing.getStatus().equals(status)) {
throw new PaymentException(PaymentError.IDEMPOTENCY_CONFLICT);
}
return;
}
PaymentTransactionEntity transaction = new PaymentTransactionEntity();
transaction.setId(IdWorker.getId());
transaction.setPaymentId(payment.getId());
transaction.setTransactionType("PAYMENT");
transaction.setChannel(MOCK_CHANNEL);
transaction.setChannelTransactionNo(command.externalTransactionNo());
transaction.setAmount(command.amount());
transaction.setStatus(status);
transaction.setCreatedAt(now);
transactionMapper.insert(transaction);
}
private void requireSameChannelTransactionNo(
PaymentOrderEntity payment,
CallbackCommand command) {
if (!Objects.equals(
payment.getChannelTransactionNo(), command.externalTransactionNo())) {
throw new PaymentException(PaymentError.IDEMPOTENCY_CONFLICT);
}
}
private void appendPaymentSucceeded(PaymentOrderEntity payment, Instant now) {
String eventId = UUID.randomUUID().toString();
Map<String, Object> envelope = new LinkedHashMap<>();
envelope.put("eventId", eventId);
envelope.put("eventType", "PaymentSucceeded");
envelope.put("aggregateType", "PaymentOrder");
envelope.put("aggregateId", payment.getPaymentNo());
envelope.put("aggregateVersion", payment.getVersion());
envelope.put("occurredAt", now);
envelope.put("producer", "payment-service");
envelope.put("traceId", messagingTracing.currentTraceId());
envelope.put("traceContext", messagingTracing.capture());
envelope.put("payloadVersion", 1);
envelope.put("payload", Map.of(
"paymentNo", payment.getPaymentNo(),
"orderNo", payment.getOrderNo(),
"userId", payment.getUserId(),
"reservationNo", payment.getReservationNo(),
"amount", payment.getAmount(),
"channel", payment.getChannel(),
"channelTransactionNo", payment.getChannelTransactionNo()
));
OutboxEventEntity event = new OutboxEventEntity();
event.setId(eventId);
event.setEventType("PaymentSucceeded");
event.setAggregateType("PaymentOrder");
event.setAggregateId(payment.getPaymentNo());
event.setAggregateVersion(payment.getVersion());
event.setPayload(writeJson(envelope));
event.setStatus(OutboxStatus.PENDING.name());
event.setAttempts(0);
event.setNextAttemptAt(now);
event.setCreatedAt(now);
event.setUpdatedAt(now);
outboxMapper.insert(event);
}
private boolean callbackExpired(long timestamp, Instant now) {
try {
return Math.abs(Math.subtractExact(now.getEpochSecond(), timestamp))
> channelProperties.callbackMaxSkew().toSeconds();
} catch (ArithmeticException exception) {
return true;
}
}
private void recordUntrustedCallback(CallbackCommand command, Instant receivedAt) {
CallbackSecurityAuditEntity audit = new CallbackSecurityAuditEntity();
audit.setId(IdWorker.getId());
audit.setCallbackType("PAYMENT");
audit.setChannel(MOCK_CHANNEL);
audit.setClaimedExternalEventId(command.externalEventId());
audit.setReferenceNo(command.paymentNo());
audit.setRequestHash(callbackHash(command));
audit.setSignatureValid(false);
audit.setErrorCode(PaymentError.INVALID_SIGNATURE.code());
audit.setRawPayload(command.rawPayload());
audit.setReceivedAt(receivedAt);
transactionTemplate.executeWithoutResult(ignored -> securityAuditMapper.insert(audit));
}
private void recordRejectedCallback(
CallbackCommand command,
PaymentError error,
boolean signatureValid,
Instant receivedAt) {
transactionTemplate.executeWithoutResult(ignored -> callbackMapper.insertOrLockExisting(callbackLog(
command, callbackHash(command), signatureValid, "REJECTED", error.code(), receivedAt)));
}
private PaymentCallbackLogEntity callbackLog(
CallbackCommand command,
String requestHash,
boolean signatureValid,
String status,
String error,
Instant receivedAt) {
PaymentCallbackLogEntity callback = new PaymentCallbackLogEntity();
callback.setId(IdWorker.getId());
callback.setChannel(MOCK_CHANNEL);
callback.setExternalEventId(command.externalEventId());
callback.setPaymentNo(command.paymentNo());
callback.setRequestHash(requestHash);
callback.setSignatureValid(signatureValid);
callback.setProcessingStatus(status);
callback.setRawPayload(command.rawPayload());
callback.setErrorMessage(error);
callback.setReceivedAt(receivedAt);
return callback;
}
private String callbackHash(CallbackCommand command) {
return sha256(MockCallbackSignature.canonical(command));
}
private PaymentOrderEntity requirePayment(String paymentNo) {
PaymentOrderEntity payment = paymentMapper.selectOne(new LambdaQueryWrapper<PaymentOrderEntity>()
.eq(PaymentOrderEntity::getPaymentNo, paymentNo));
if (payment == null) {
throw new PaymentException(PaymentError.RESOURCE_NOT_FOUND);
}
return payment;
}
private PaymentView findStablePayment(
Long userId,
String idempotencyKey,
String orderNo,
String channel,
String requestHash) {
PaymentOrderEntity byIdempotency = paymentMapper.selectByIdempotency(userId, idempotencyKey);
if (byIdempotency != null) {
if (!constantEquals(byIdempotency.getRequestHash(), requestHash)) {
throw new PaymentException(PaymentError.IDEMPOTENCY_CONFLICT);
}
return view(byIdempotency);
}
PaymentOrderEntity byOrder = paymentMapper.selectByOrder(orderNo);
if (byOrder == null) {
return null;
}
if (!byOrder.getUserId().equals(userId)) {
throw new PaymentException(PaymentError.RESOURCE_NOT_FOUND);
}
if (!byOrder.getChannel().equals(channel)) {
throw new PaymentException(PaymentError.IDEMPOTENCY_CONFLICT);
}
return view(byOrder);
}
private PaymentView view(PaymentOrderEntity payment) {
return new PaymentView(
payment.getPaymentNo(), payment.getOrderNo(), payment.getChannel(), payment.getStatus(),
payment.getAmount(), payment.getChannelTransactionNo(), payment.getPaidAt(),
payment.getCreatedAt(), payment.getUpdatedAt());
}
private void requireUpdated(int rows) {
if (rows != 1) {
throw new PaymentException(PaymentError.CONCURRENT_MODIFICATION);
}
}
private boolean constantEquals(String left, String right) {
return MessageDigest.isEqual(
left.getBytes(StandardCharsets.UTF_8),
right.getBytes(StandardCharsets.UTF_8));
}
private String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
private String writeJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Payment event serialization failed", exception);
}
}
}