RefundService.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.CreatePaymentExceptionRefundCommand;
import com.ecommerce.payment.application.model.PaymentModels.PaymentExceptionRefundAuditView;
import com.ecommerce.payment.application.model.PaymentModels.RefundCallbackCommand;
import com.ecommerce.payment.application.model.PaymentModels.RefundDispatchRetryAuditView;
import com.ecommerce.payment.application.model.PaymentModels.RefundRequestedCommand;
import com.ecommerce.payment.application.model.PaymentModels.RefundView;
import com.ecommerce.payment.application.model.PaymentModels.RetryRefundDispatchCommand;
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.domain.RefundStatus;
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.PaymentOrderEntity;
import com.ecommerce.payment.infrastructure.persistence.entity.PaymentExceptionRefundAuditEntity;
import com.ecommerce.payment.infrastructure.persistence.entity.RefundCallbackLogEntity;
import com.ecommerce.payment.infrastructure.persistence.entity.RefundDispatchRetryAuditEntity;
import com.ecommerce.payment.infrastructure.persistence.entity.RefundOrderEntity;
import com.ecommerce.payment.infrastructure.persistence.entity.RefundTransactionEntity;
import com.ecommerce.payment.infrastructure.persistence.mapper.CallbackSecurityAuditMapper;
import com.ecommerce.payment.infrastructure.persistence.mapper.ConsumedEventMapper;
import com.ecommerce.payment.infrastructure.persistence.mapper.OutboxEventMapper;
import com.ecommerce.payment.infrastructure.persistence.mapper.PaymentOrderMapper;
import com.ecommerce.payment.infrastructure.persistence.mapper.PaymentExceptionRefundAuditMapper;
import com.ecommerce.payment.infrastructure.persistence.mapper.RefundCallbackLogMapper;
import com.ecommerce.payment.infrastructure.persistence.mapper.RefundDispatchRetryAuditMapper;
import com.ecommerce.payment.infrastructure.persistence.mapper.RefundOrderMapper;
import com.ecommerce.payment.infrastructure.persistence.mapper.RefundTransactionMapper;
import com.ecommerce.platform.common.idempotency.PayloadFingerprint;
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.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;

@Service
public class RefundService {

    public static final String REFUND_REQUESTED_CONSUMER_GROUP = "payment-refund-requested-v1";
    private static final String MOCK_CHANNEL = "MOCK";

    private final RefundOrderMapper refundMapper;
    private final RefundTransactionMapper transactionMapper;
    private final RefundCallbackLogMapper callbackMapper;
    private final CallbackSecurityAuditMapper securityAuditMapper;
    private final RefundDispatchRetryAuditMapper retryAuditMapper;
    private final PaymentExceptionRefundAuditMapper exceptionRefundAuditMapper;
    private final PaymentOrderMapper paymentMapper;
    private final ConsumedEventMapper consumedEventMapper;
    private final OutboxEventMapper outboxMapper;
    private final MockChannelProperties channelProperties;
    private final ObjectMapper objectMapper;
    private final TransactionTemplate transactionTemplate;
    private final MessagingTracing messagingTracing;
    private final TradePort tradePort;

    public RefundService(
            RefundOrderMapper refundMapper,
            RefundTransactionMapper transactionMapper,
            RefundCallbackLogMapper callbackMapper,
            CallbackSecurityAuditMapper securityAuditMapper,
            RefundDispatchRetryAuditMapper retryAuditMapper,
            PaymentExceptionRefundAuditMapper exceptionRefundAuditMapper,
            PaymentOrderMapper paymentMapper,
            ConsumedEventMapper consumedEventMapper,
            OutboxEventMapper outboxMapper,
            MockChannelProperties channelProperties,
            ObjectMapper objectMapper,
            TransactionTemplate transactionTemplate,
            MessagingTracing messagingTracing,
            TradePort tradePort) {
        this.refundMapper = refundMapper;
        this.transactionMapper = transactionMapper;
        this.callbackMapper = callbackMapper;
        this.securityAuditMapper = securityAuditMapper;
        this.retryAuditMapper = retryAuditMapper;
        this.exceptionRefundAuditMapper = exceptionRefundAuditMapper;
        this.paymentMapper = paymentMapper;
        this.consumedEventMapper = consumedEventMapper;
        this.outboxMapper = outboxMapper;
        this.channelProperties = channelProperties;
        this.objectMapper = objectMapper;
        this.transactionTemplate = transactionTemplate;
        this.messagingTracing = messagingTracing;
        this.tradePort = tradePort;
    }

    public RefundView createFromRefundRequested(RefundRequestedCommand command) {
        return Objects.requireNonNull(transactionTemplate.execute(ignored -> {
            String payloadFingerprint = PayloadFingerprint.of(command);
            if (consumedEventMapper.insertIfAbsent(
                    command.eventId(),
                    REFUND_REQUESTED_CONSUMER_GROUP,
                    payloadFingerprint,
                    refundMapper.currentTime()) != 1) {
                String storedFingerprint = consumedEventMapper.selectPayloadFingerprint(
                        command.eventId(), REFUND_REQUESTED_CONSUMER_GROUP);
                if (!PayloadFingerprint.matches(storedFingerprint, payloadFingerprint)) {
                    throw new PaymentException(PaymentError.IDEMPOTENCY_CONFLICT);
                }
                RefundOrderEntity repeated = requireByAfterSaleNo(command.afterSaleNo());
                if (!constantEquals(repeated.getRequestHash(), refundRequestHash(command))) {
                    throw new PaymentException(PaymentError.IDEMPOTENCY_CONFLICT);
                }
                return view(repeated);
            }
            PaymentOrderEntity payment = paymentMapper.selectByOrderForUpdate(command.orderNo());
            if (payment == null) {
                throw new PaymentException(PaymentError.RESOURCE_NOT_FOUND);
            }
            if (!PaymentStatus.SUCCESS.name().equals(payment.getStatus())
                    || !payment.getUserId().equals(command.userId())) {
                throw new PaymentException(PaymentError.INVALID_STATE);
            }
            if (payment.getAmount().compareTo(command.amount()) != 0) {
                throw new PaymentException(PaymentError.AMOUNT_MISMATCH);
            }
            String requestHash = refundRequestHash(command);
            Instant now = refundMapper.currentTime();
            long id = IdWorker.getId();
            RefundOrderEntity candidate = new RefundOrderEntity();
            candidate.setId(id);
            candidate.setRefundNo("RF" + id);
            candidate.setAfterSaleNo(command.afterSaleNo());
            candidate.setOrderNo(command.orderNo());
            candidate.setPaymentId(payment.getId());
            candidate.setPaymentNo(payment.getPaymentNo());
            candidate.setUserId(command.userId());
            candidate.setRequestHash(requestHash);
            candidate.setChannel(payment.getChannel());
            candidate.setStatus(RefundStatus.PROCESSING.name());
            candidate.setAmount(command.amount());
            candidate.setRequestStatus("PENDING");
            candidate.setRequestAttempts(0);
            candidate.setNextRequestAt(now);
            candidate.setVersion(0);
            candidate.setCreatedAt(now);
            candidate.setUpdatedAt(now);
            refundMapper.insertOrLockExisting(candidate);

            RefundOrderEntity refund = refundMapper.selectByAfterSaleNoForUpdate(command.afterSaleNo());
            if (refund == null) {
                if (refundMapper.selectByPaymentIdForUpdate(payment.getId()) != null) {
                    throw new PaymentException(PaymentError.INVALID_STATE);
                }
                throw new PaymentException(PaymentError.CONCURRENT_MODIFICATION);
            }
            if (!constantEquals(refund.getRequestHash(), requestHash)) {
                throw new PaymentException(PaymentError.IDEMPOTENCY_CONFLICT);
            }
            return view(refund);
        }));
    }

    public RefundView createPaymentExceptionRefund(
            CreatePaymentExceptionRefundCommand command) {
        String reason = command.reason().strip();
        String commandHash = paymentExceptionCommandHash(command, reason);
        PaymentExceptionRefundResult replayed = transactionTemplate.execute(ignored -> {
            PaymentExceptionRefundAuditEntity existing =
                    exceptionRefundAuditMapper.selectByCommandIdForUpdate(command.commandId());
            if (existing == null) {
                return null;
            }
            if (!constantEquals(existing.getRequestHash(), commandHash)) {
                throw new PaymentException(PaymentError.IDEMPOTENCY_CONFLICT);
            }
            return replayPaymentExceptionRefund(existing);
        });
        if (replayed != null) {
            if (replayed.error() != null) {
                throw new PaymentException(replayed.error());
            }
            return replayed.refund();
        }

        PaymentOrderEntity snapshot = paymentMapper.selectByPaymentNo(command.paymentNo());
        PaymentContext context = snapshot == null
                ? null
                : tradePort.getPaymentContext(snapshot.getOrderNo());

        PaymentExceptionRefundResult result = Objects.requireNonNull(
                transactionTemplate.execute(ignored -> {
                    Instant now = refundMapper.currentTime();
                    PaymentExceptionRefundAuditEntity candidate =
                            paymentExceptionAudit(command, reason, commandHash, now);
                    exceptionRefundAuditMapper.insertOrLockExisting(candidate);
                    PaymentExceptionRefundAuditEntity audit =
                            exceptionRefundAuditMapper.selectByCommandIdForUpdate(
                                    command.commandId());
                    if (audit == null) {
                        throw new PaymentException(PaymentError.CONCURRENT_MODIFICATION);
                    }
                    if (!constantEquals(audit.getRequestHash(), commandHash)) {
                        throw new PaymentException(PaymentError.IDEMPOTENCY_CONFLICT);
                    }
                    if (!candidate.getId().equals(audit.getId())) {
                        return replayPaymentExceptionRefund(audit);
                    }

                    PaymentOrderEntity payment =
                            paymentMapper.selectByPaymentNoForUpdate(command.paymentNo());
                    PaymentError eligibilityError =
                            paymentExceptionEligibilityError(payment, context);
                    if (eligibilityError != null) {
                        audit.setOrderNo(payment == null ? null : payment.getOrderNo());
                        audit.setOutcome("REJECTED");
                        audit.setErrorCode(eligibilityError.code());
                        requireUpdated(exceptionRefundAuditMapper.updateById(audit));
                        return new PaymentExceptionRefundResult(null, eligibilityError);
                    }

                    String exceptionReference = paymentExceptionReference(payment.getOrderNo());
                    RefundOrderEntity existing =
                            refundMapper.selectByPaymentIdForUpdate(payment.getId());
                    if (existing != null) {
                        if (!exceptionReference.equals(existing.getAfterSaleNo())
                                || !payment.getPaymentNo().equals(existing.getPaymentNo())
                                || payment.getAmount().compareTo(existing.getAmount()) != 0) {
                            audit.setOrderNo(payment.getOrderNo());
                            audit.setOutcome("REJECTED");
                            audit.setErrorCode(
                                    PaymentError.PAYMENT_EXCEPTION_REFUND_NOT_ALLOWED.code());
                            requireUpdated(exceptionRefundAuditMapper.updateById(audit));
                            return new PaymentExceptionRefundResult(
                                    null,
                                    PaymentError.PAYMENT_EXCEPTION_REFUND_NOT_ALLOWED);
                        }
                        acceptPaymentExceptionAudit(audit, payment, existing);
                        return new PaymentExceptionRefundResult(view(existing), null);
                    }

                    long id = IdWorker.getId();
                    RefundOrderEntity refund = new RefundOrderEntity();
                    refund.setId(id);
                    refund.setRefundNo("RF" + id);
                    refund.setAfterSaleNo(exceptionReference);
                    refund.setOrderNo(payment.getOrderNo());
                    refund.setPaymentId(payment.getId());
                    refund.setPaymentNo(payment.getPaymentNo());
                    refund.setUserId(payment.getUserId());
                    refund.setRequestHash(paymentExceptionRefundHash(payment));
                    refund.setChannel(payment.getChannel());
                    refund.setStatus(RefundStatus.PROCESSING.name());
                    refund.setAmount(payment.getAmount());
                    refund.setRequestStatus("PENDING");
                    refund.setRequestAttempts(0);
                    refund.setNextRequestAt(now);
                    refund.setVersion(0);
                    refund.setCreatedAt(now);
                    refund.setUpdatedAt(now);
                    refundMapper.insertOrLockExisting(refund);

                    RefundOrderEntity persisted =
                            refundMapper.selectByPaymentIdForUpdate(payment.getId());
                    if (persisted == null
                            || !exceptionReference.equals(persisted.getAfterSaleNo())
                            || !constantEquals(
                            persisted.getRequestHash(),
                            paymentExceptionRefundHash(payment))) {
                        throw new PaymentException(PaymentError.IDEMPOTENCY_CONFLICT);
                    }
                    acceptPaymentExceptionAudit(audit, payment, persisted);
                    return new PaymentExceptionRefundResult(view(persisted), null);
                }));
        if (result.error() != null) {
            throw new PaymentException(result.error());
        }
        return result.refund();
    }

    public List<PaymentExceptionRefundAuditView> listPaymentExceptionRefundAudits(
            String paymentNo,
            int limit) {
        return exceptionRefundAuditMapper.selectByPaymentNo(paymentNo, limit).stream()
                .map(this::paymentExceptionAuditView)
                .toList();
    }

    private String refundRequestHash(RefundRequestedCommand command) {
        return sha256(String.join("|", command.afterSaleNo(), command.orderNo(),
                command.userId().toString(), command.amount().stripTrailingZeros().toPlainString()));
    }

    public RefundView getForUser(Long userId, String refundNo) {
        RefundOrderEntity refund = requireRefund(refundNo);
        if (!refund.getUserId().equals(userId)) {
            throw new PaymentException(PaymentError.RESOURCE_NOT_FOUND);
        }
        return view(refund);
    }

    public RefundView getByAfterSaleNoForUser(Long userId, String afterSaleNo) {
        RefundOrderEntity refund = requireByAfterSaleNo(afterSaleNo);
        if (!refund.getUserId().equals(userId)) {
            throw new PaymentException(PaymentError.RESOURCE_NOT_FOUND);
        }
        return view(refund);
    }

    public RefundView retryDispatch(RetryRefundDispatchCommand command) {
        String reason = command.reason().strip();
        String requestHash = retryCommandHash(command, reason);
        RetryDispatchResult result = Objects.requireNonNull(transactionTemplate.execute(ignored -> {
            RefundDispatchRetryAuditEntity existing =
                    retryAuditMapper.selectByCommandIdForUpdate(command.commandId());
            if (existing != null) {
                return replayRetry(existing, requestHash);
            }

            RefundOrderEntity refund = refundMapper.selectByRefundNoForUpdate(command.refundNo());
            existing = retryAuditMapper.selectByCommandIdForUpdate(command.commandId());
            if (existing != null) {
                return replayRetry(existing, requestHash);
            }

            Instant now = refundMapper.currentTime();
            if (refund == null) {
                return rejectRetry(command, reason, requestHash, null,
                        PaymentError.RESOURCE_NOT_FOUND, now);
            }
            if (!isManualRetryAllowed(refund)) {
                return rejectRetry(command, reason, requestHash, refund,
                        PaymentError.REFUND_RETRY_NOT_ALLOWED, now);
            }

            RefundDispatchRetryAuditEntity audit = retryAudit(
                    command, reason, requestHash, refund, "ACCEPTED", null, now);
            requireUpdated(refundMapper.resetRequestForManualRetry(refund.getId(), now));
            RefundOrderEntity reset = refundMapper.selectByRefundNoForUpdate(command.refundNo());
            audit.setAfterRefundStatus(reset.getStatus());
            audit.setAfterRequestStatus(reset.getRequestStatus());
            audit.setAfterRequestAttempts(reset.getRequestAttempts());
            persistRetryAudit(audit, requestHash);
            return new RetryDispatchResult(view(reset), null);
        }));
        if (result.error() != null) {
            throw new PaymentException(result.error());
        }
        return result.refund();
    }

    public List<RefundDispatchRetryAuditView> listRetryAudits(String refundNo, int limit) {
        return retryAuditMapper.selectByRefundNo(refundNo, limit).stream()
                .map(this::retryAuditView)
                .toList();
    }

    public RefundView processMockCallback(RefundCallbackCommand command) {
        Instant receivedAt = refundMapper.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 RefundView processValidCallback(RefundCallbackCommand command, Instant receivedAt) {
        String requestHash = callbackHash(command);
        RefundCallbackLogEntity candidate = callbackLog(
                command, requestHash, true, "RECEIVED", null, receivedAt);
        callbackMapper.insertOrLockExisting(candidate);
        RefundCallbackLogEntity 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(requireRefund(command.refundNo()));
        }
        if (!Boolean.TRUE.equals(callback.getSignatureValid())) {
            throw new PaymentException(PaymentError.IDEMPOTENCY_CONFLICT);
        }

        RefundOrderEntity refund = refundMapper.selectByRefundNoForUpdate(command.refundNo());
        if (refund == null) {
            throw new PaymentException(PaymentError.RESOURCE_NOT_FOUND);
        }
        if (refund.getAmount().compareTo(command.amount()) != 0) {
            throw new PaymentException(PaymentError.AMOUNT_MISMATCH);
        }
        if (!MOCK_CHANNEL.equals(refund.getChannel())) {
            throw new PaymentException(PaymentError.INVALID_STATE);
        }
        if ("SUCCESS".equals(command.status())) {
            applySuccess(refund, command, receivedAt);
        } else if ("FAILED".equals(command.status())) {
            applyFailure(refund, command, receivedAt);
        } else {
            throw new PaymentException(PaymentError.INVALID_STATE);
        }
        callback.setProcessingStatus("PROCESSED");
        callback.setProcessedAt(receivedAt);
        callback.setErrorMessage(null);
        requireUpdated(callbackMapper.updateById(callback));
        return view(refund);
    }

    private void applySuccess(RefundOrderEntity refund, RefundCallbackCommand command, Instant now) {
        if (RefundStatus.SUCCESS.name().equals(refund.getStatus())) {
            requireSameChannelRefundNo(refund, command);
            return;
        }
        if (!List.of(RefundStatus.PROCESSING.name(), RefundStatus.FAILED.name())
                .contains(refund.getStatus())) {
            throw new PaymentException(PaymentError.INVALID_STATE);
        }
        refund.setStatus(RefundStatus.SUCCESS.name());
        refund.setChannelRefundNo(command.externalRefundNo());
        refund.setRefundedAt(now);
        refund.setUpdatedAt(now);
        requireUpdated(refundMapper.updateById(refund));
        acknowledgeRequest(refund, now);
        insertTransaction(refund, command, "SUCCESS", now);
        appendRefundEvent(refund, "RefundSucceeded", now);
    }

    private void applyFailure(RefundOrderEntity refund, RefundCallbackCommand command, Instant now) {
        if (RefundStatus.FAILED.name().equals(refund.getStatus())) {
            requireSameChannelRefundNo(refund, command);
            return;
        }
        if (!RefundStatus.PROCESSING.name().equals(refund.getStatus())) {
            throw new PaymentException(PaymentError.INVALID_STATE);
        }
        refund.setStatus(RefundStatus.FAILED.name());
        refund.setChannelRefundNo(command.externalRefundNo());
        refund.setUpdatedAt(now);
        requireUpdated(refundMapper.updateById(refund));
        acknowledgeRequest(refund, now);
        insertTransaction(refund, command, "FAILED", now);
        appendRefundEvent(refund, "RefundFailed", now);
    }

    private void insertTransaction(
            RefundOrderEntity refund,
            RefundCallbackCommand command,
            String status,
            Instant now) {
        RefundTransactionEntity existing = transactionMapper.selectByChannelRefundNoForUpdate(
                MOCK_CHANNEL, command.externalRefundNo());
        if (existing != null) {
            if (!existing.getRefundId().equals(refund.getId())
                    || existing.getAmount().compareTo(command.amount()) != 0) {
                throw new PaymentException(PaymentError.IDEMPOTENCY_CONFLICT);
            }
            if (existing.getStatus().equals(status)) {
                return;
            }
            if (!"FAILED".equals(existing.getStatus()) || !"SUCCESS".equals(status)) {
                throw new PaymentException(PaymentError.INVALID_STATE);
            }
            existing.setStatus(status);
            requireUpdated(transactionMapper.updateById(existing));
            return;
        }
        RefundTransactionEntity transaction = new RefundTransactionEntity();
        transaction.setId(IdWorker.getId());
        transaction.setRefundId(refund.getId());
        transaction.setChannel(MOCK_CHANNEL);
        transaction.setChannelRefundNo(command.externalRefundNo());
        transaction.setAmount(command.amount());
        transaction.setStatus(status);
        transaction.setCreatedAt(now);
        transactionMapper.insert(transaction);
    }

    private void appendRefundEvent(RefundOrderEntity refund, String eventType, Instant now) {
        String eventId = UUID.randomUUID().toString();
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("refundNo", refund.getRefundNo());
        payload.put("afterSaleNo", refund.getAfterSaleNo());
        payload.put("orderNo", refund.getOrderNo());
        payload.put("paymentNo", refund.getPaymentNo());
        payload.put("userId", refund.getUserId());
        payload.put("amount", refund.getAmount());
        payload.put("channel", refund.getChannel());
        payload.put("channelRefundNo", refund.getChannelRefundNo());
        payload.put("status", refund.getStatus());

        Map<String, Object> envelope = new LinkedHashMap<>();
        envelope.put("eventId", eventId);
        envelope.put("eventType", eventType);
        envelope.put("aggregateType", "RefundOrder");
        envelope.put("aggregateId", refund.getRefundNo());
        envelope.put("aggregateVersion", refund.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", payload);

        OutboxEventEntity event = new OutboxEventEntity();
        event.setId(eventId);
        event.setEventType(eventType);
        event.setAggregateType("RefundOrder");
        event.setAggregateId(refund.getRefundNo());
        event.setAggregateVersion(refund.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(
            RefundCallbackCommand command,
            Instant receivedAt) {
        CallbackSecurityAuditEntity audit = new CallbackSecurityAuditEntity();
        audit.setId(IdWorker.getId());
        audit.setCallbackType("REFUND");
        audit.setChannel(MOCK_CHANNEL);
        audit.setClaimedExternalEventId(command.externalEventId());
        audit.setReferenceNo(command.refundNo());
        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(
            RefundCallbackCommand command,
            PaymentError error,
            boolean signatureValid,
            Instant receivedAt) {
        transactionTemplate.executeWithoutResult(ignored -> callbackMapper.insertOrLockExisting(callbackLog(
                command, callbackHash(command), signatureValid, "REJECTED", error.code(), receivedAt)));
    }

    private RefundCallbackLogEntity callbackLog(
            RefundCallbackCommand command,
            String requestHash,
            boolean signatureValid,
            String status,
            String error,
            Instant receivedAt) {
        RefundCallbackLogEntity callback = new RefundCallbackLogEntity();
        callback.setId(IdWorker.getId());
        callback.setChannel(MOCK_CHANNEL);
        callback.setExternalEventId(command.externalEventId());
        callback.setRefundNo(command.refundNo());
        callback.setRequestHash(requestHash);
        callback.setSignatureValid(signatureValid);
        callback.setProcessingStatus(status);
        callback.setRawPayload(command.rawPayload());
        callback.setErrorMessage(error);
        callback.setReceivedAt(receivedAt);
        return callback;
    }

    private RefundOrderEntity requireByAfterSaleNo(String afterSaleNo) {
        RefundOrderEntity refund = refundMapper.selectOne(new LambdaQueryWrapper<RefundOrderEntity>()
                .eq(RefundOrderEntity::getAfterSaleNo, afterSaleNo));
        if (refund == null) {
            throw new PaymentException(PaymentError.RESOURCE_NOT_FOUND);
        }
        return refund;
    }

    private RefundOrderEntity requireRefund(String refundNo) {
        RefundOrderEntity refund = refundMapper.selectOne(new LambdaQueryWrapper<RefundOrderEntity>()
                .eq(RefundOrderEntity::getRefundNo, refundNo));
        if (refund == null) {
            throw new PaymentException(PaymentError.RESOURCE_NOT_FOUND);
        }
        return refund;
    }

    private void acknowledgeRequest(RefundOrderEntity refund, Instant now) {
        requireUpdated(refundMapper.markRequestAcknowledged(refund.getId(), now));
        refund.setRequestStatus("SENT");
        refund.setNextRequestAt(null);
        refund.setRequestClaimedAt(null);
        if (refund.getRequestSentAt() == null) {
            refund.setRequestSentAt(now);
        }
        refund.setLastRequestError(null);
    }

    private void requireSameChannelRefundNo(
            RefundOrderEntity refund,
            RefundCallbackCommand command) {
        if (!Objects.equals(refund.getChannelRefundNo(), command.externalRefundNo())) {
            throw new PaymentException(PaymentError.IDEMPOTENCY_CONFLICT);
        }
    }

    private RefundView view(RefundOrderEntity refund) {
        return new RefundView(
                refund.getRefundNo(), refund.getAfterSaleNo(), refund.getOrderNo(), refund.getPaymentNo(),
                refund.getUserId(), refund.getChannel(), refund.getStatus(), refund.getAmount(),
                refund.getChannelRefundNo(), refund.getRequestStatus(), refund.getRequestAttempts(),
                refund.getNextRequestAt(), refund.getRequestSentAt(), refund.getCreatedAt(),
                refund.getUpdatedAt(), refund.getRefundedAt());
    }

    private boolean isManualRetryAllowed(RefundOrderEntity refund) {
        return (RefundStatus.PROCESSING.name().equals(refund.getStatus())
                && "NEEDS_ATTENTION".equals(refund.getRequestStatus()))
                || (RefundStatus.FAILED.name().equals(refund.getStatus())
                && "SENT".equals(refund.getRequestStatus()));
    }

    private RetryDispatchResult rejectRetry(
            RetryRefundDispatchCommand command,
            String reason,
            String requestHash,
            RefundOrderEntity refund,
            PaymentError error,
            Instant now) {
        RefundDispatchRetryAuditEntity audit = retryAudit(
                command, reason, requestHash, refund, "REJECTED", error.name(), now);
        RefundDispatchRetryAuditEntity persisted = persistRetryAudit(audit, requestHash);
        if (!"REJECTED".equals(persisted.getOutcome())) {
            return replayRetry(persisted, requestHash);
        }
        return new RetryDispatchResult(null, PaymentError.valueOf(persisted.getErrorCode()));
    }

    private RefundDispatchRetryAuditEntity retryAudit(
            RetryRefundDispatchCommand command,
            String reason,
            String requestHash,
            RefundOrderEntity refund,
            String outcome,
            String errorCode,
            Instant now) {
        RefundDispatchRetryAuditEntity audit = new RefundDispatchRetryAuditEntity();
        audit.setId(IdWorker.getId());
        audit.setCommandId(command.commandId());
        audit.setRequestHash(requestHash);
        audit.setRefundNo(command.refundNo());
        audit.setOperatorId(command.operatorId());
        audit.setReason(reason);
        audit.setOutcome(outcome);
        audit.setErrorCode(errorCode);
        if (refund != null) {
            audit.setBeforeRefundStatus(refund.getStatus());
            audit.setBeforeRequestStatus(refund.getRequestStatus());
            audit.setBeforeRequestAttempts(refund.getRequestAttempts());
            audit.setBeforeLastError(refund.getLastRequestError());
            audit.setAfterRefundStatus(refund.getStatus());
            audit.setAfterRequestStatus(refund.getRequestStatus());
            audit.setAfterRequestAttempts(refund.getRequestAttempts());
        }
        audit.setCreatedAt(now);
        return audit;
    }

    private RefundDispatchRetryAuditEntity persistRetryAudit(
            RefundDispatchRetryAuditEntity audit,
            String requestHash) {
        retryAuditMapper.insertOrLockExisting(audit);
        RefundDispatchRetryAuditEntity persisted =
                retryAuditMapper.selectByCommandIdForUpdate(audit.getCommandId());
        if (persisted == null) {
            throw new PaymentException(PaymentError.CONCURRENT_MODIFICATION);
        }
        if (!constantEquals(persisted.getRequestHash(), requestHash)) {
            throw new PaymentException(PaymentError.IDEMPOTENCY_CONFLICT);
        }
        return persisted;
    }

    private RetryDispatchResult replayRetry(
            RefundDispatchRetryAuditEntity audit,
            String requestHash) {
        if (!constantEquals(audit.getRequestHash(), requestHash)) {
            throw new PaymentException(PaymentError.IDEMPOTENCY_CONFLICT);
        }
        if ("REJECTED".equals(audit.getOutcome())) {
            return new RetryDispatchResult(null, PaymentError.valueOf(audit.getErrorCode()));
        }
        return new RetryDispatchResult(view(requireRefund(audit.getRefundNo())), null);
    }

    private RefundDispatchRetryAuditView retryAuditView(RefundDispatchRetryAuditEntity audit) {
        return new RefundDispatchRetryAuditView(
                audit.getCommandId(), audit.getRefundNo(), audit.getOperatorId(), audit.getReason(),
                audit.getOutcome(), audit.getErrorCode(), audit.getBeforeRefundStatus(),
                audit.getBeforeRequestStatus(), audit.getBeforeRequestAttempts(),
                audit.getBeforeLastError(),
                audit.getAfterRefundStatus(), audit.getAfterRequestStatus(),
                audit.getAfterRequestAttempts(), audit.getCreatedAt());
    }

    private PaymentExceptionRefundAuditEntity paymentExceptionAudit(
            CreatePaymentExceptionRefundCommand command,
            String reason,
            String requestHash,
            Instant now) {
        PaymentExceptionRefundAuditEntity audit =
                new PaymentExceptionRefundAuditEntity();
        audit.setId(IdWorker.getId());
        audit.setCommandId(command.commandId());
        audit.setRequestHash(requestHash);
        audit.setPaymentNo(command.paymentNo());
        audit.setOperatorId(command.operatorId());
        audit.setReason(reason);
        audit.setOutcome("PROCESSING");
        audit.setCreatedAt(now);
        return audit;
    }

    private PaymentError paymentExceptionEligibilityError(
            PaymentOrderEntity payment,
            PaymentContext context) {
        if (payment == null) {
            return PaymentError.RESOURCE_NOT_FOUND;
        }
        if (!PaymentStatus.SUCCESS.name().equals(payment.getStatus())
                || context == null
                || !"PAYMENT_EXCEPTION".equals(context.status())
                || !Objects.equals(payment.getOrderNo(), context.orderNo())
                || !Objects.equals(payment.getUserId(), context.userId())
                || !Objects.equals(payment.getReservationNo(), context.reservationNo())
                || !Objects.equals(payment.getPaymentNo(), context.paymentNo())
                || payment.getAmount().compareTo(context.totalAmount()) != 0) {
            return PaymentError.PAYMENT_EXCEPTION_REFUND_NOT_ALLOWED;
        }
        return null;
    }

    private void acceptPaymentExceptionAudit(
            PaymentExceptionRefundAuditEntity audit,
            PaymentOrderEntity payment,
            RefundOrderEntity refund) {
        audit.setOrderNo(payment.getOrderNo());
        audit.setRefundNo(refund.getRefundNo());
        audit.setOutcome("ACCEPTED");
        audit.setErrorCode(null);
        requireUpdated(exceptionRefundAuditMapper.updateById(audit));
    }

    private PaymentExceptionRefundResult replayPaymentExceptionRefund(
            PaymentExceptionRefundAuditEntity audit) {
        return switch (audit.getOutcome()) {
            case "ACCEPTED" -> new PaymentExceptionRefundResult(
                    view(requireRefund(audit.getRefundNo())),
                    null);
            case "REJECTED" -> new PaymentExceptionRefundResult(
                    null,
                    PaymentError.valueOf(audit.getErrorCode()));
            default -> throw new PaymentException(PaymentError.CONCURRENT_MODIFICATION);
        };
    }

    private PaymentExceptionRefundAuditView paymentExceptionAuditView(
            PaymentExceptionRefundAuditEntity audit) {
        return new PaymentExceptionRefundAuditView(
                audit.getCommandId(),
                audit.getPaymentNo(),
                audit.getOrderNo(),
                audit.getRefundNo(),
                audit.getOperatorId(),
                audit.getReason(),
                audit.getOutcome(),
                audit.getErrorCode(),
                audit.getCreatedAt());
    }

    private String paymentExceptionReference(String orderNo) {
        return "PEX-" + orderNo;
    }

    private String paymentExceptionCommandHash(
            CreatePaymentExceptionRefundCommand command,
            String reason) {
        return sha256(
                hashPart(command.paymentNo())
                        + hashPart(command.operatorId())
                        + hashPart(reason));
    }

    private String paymentExceptionRefundHash(PaymentOrderEntity payment) {
        return sha256(String.join(
                "|",
                "PAYMENT_EXCEPTION",
                payment.getPaymentNo(),
                payment.getOrderNo(),
                payment.getUserId().toString(),
                payment.getAmount().stripTrailingZeros().toPlainString()));
    }

    private String retryCommandHash(RetryRefundDispatchCommand command, String reason) {
        return sha256(hashPart(command.refundNo()) + hashPart(command.operatorId()) + hashPart(reason));
    }

    private String hashPart(String value) {
        return value.length() + ":" + value;
    }

    private record RetryDispatchResult(RefundView refund, PaymentError error) {
    }

    private record PaymentExceptionRefundResult(
            RefundView refund,
            PaymentError error) {
    }

    private String callbackHash(RefundCallbackCommand command) {
        return sha256(MockCallbackSignature.canonical(command));
    }

    private boolean constantEquals(String left, String right) {
        return MessageDigest.isEqual(left.getBytes(StandardCharsets.UTF_8), right.getBytes(StandardCharsets.UTF_8));
    }

    private void requireUpdated(int rows) {
        if (rows != 1) {
            throw new PaymentException(PaymentError.CONCURRENT_MODIFICATION);
        }
    }

    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("Refund event serialization failed", exception);
        }
    }
}