RefundDispatchJob.java

package com.ecommerce.payment.infrastructure.refund;

import com.ecommerce.payment.application.port.RefundChannelPort;
import com.ecommerce.payment.infrastructure.config.PaymentSchedulingConfig;
import com.ecommerce.payment.infrastructure.persistence.entity.RefundOrderEntity;
import com.ecommerce.payment.infrastructure.persistence.mapper.RefundOrderMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.Instant;

@Component
@ConditionalOnProperty(prefix = "ecommerce.payment.refund-dispatch", name = "enabled", havingValue = "true")
public class RefundDispatchJob {

    private static final Logger log = LoggerFactory.getLogger(RefundDispatchJob.class);

    private final RefundOrderMapper refundMapper;
    private final RefundChannelPort refundChannel;
    private final RefundDispatchProperties properties;

    public RefundDispatchJob(
            RefundOrderMapper refundMapper,
            RefundChannelPort refundChannel,
            RefundDispatchProperties properties) {
        this.refundMapper = refundMapper;
        this.refundChannel = refundChannel;
        this.properties = properties;
    }

    @Scheduled(
            fixedDelayString = "${ecommerce.payment.refund-dispatch.fixed-delay:2000}",
            initialDelayString = "${ecommerce.payment.refund-dispatch.initial-delay:0}",
            scheduler = PaymentSchedulingConfig.CONTROL_SCHEDULER)
    public void dispatchDueRefunds() {
        Instant now = refundMapper.currentTime();
        refundMapper.resetStaleRequestClaims(now, now);
        // Database timestamps are stored with millisecond precision. A freshly persisted due time
        // can therefore round just beyond the nanosecond-precision application clock.
        Instant dueCutoff = now.plusMillis(1);
        for (RefundOrderEntity refund : refundMapper.selectDueRequests(dueCutoff, properties.batchSize())) {
            if (refundMapper.claimRequest(
                    refund.getId(),
                    properties.dispatcherId(),
                    refund.getRequestAttempts(),
                    dueCutoff,
                    now,
                    now.plus(properties.claimTimeout())) != 1) {
                continue;
            }
            try {
                refundChannel.requestRefund(new RefundChannelPort.RefundRequest(
                        refund.getRefundNo(), refund.getPaymentNo(), refund.getChannel(), refund.getAmount()));
                Instant completedAt = refundMapper.currentTime();
                if (refundMapper.markRequestSent(
                        refund.getId(), properties.dispatcherId(), completedAt) != 1) {
                    log.warn("Refund request was accepted by the channel after its dispatch lease was lost: "
                            + "refundNo={}", refund.getRefundNo());
                }
            } catch (Exception exception) {
                Instant failedAt = refundMapper.currentTime();
                int updated = refundMapper.markRequestFailed(
                        refund.getId(),
                        properties.dispatcherId(),
                        properties.maxAttempts(),
                        failedAt.plus(properties.retryDelay()),
                        conciseError(exception),
                        failedAt);
                if (updated == 1) {
                    log.warn("Refund request dispatch failed and remains governed by persisted retry state: "
                                    + "refundNo={}",
                            refund.getRefundNo());
                } else {
                    log.warn("Refund request failed after its dispatch lease was lost: refundNo={}",
                            refund.getRefundNo());
                }
            }
        }
    }

    private String conciseError(Exception exception) {
        String message = exception.getClass().getSimpleName() + ": " + exception.getMessage();
        return message.length() <= 500 ? message : message.substring(0, 500);
    }
}