ReturnService.java

package com.rbctcsworld.ecommerce.returns;

import com.rbctcsworld.ecommerce.audit.AuditEvent;
import com.rbctcsworld.ecommerce.common.exception.BusinessRuleException;
import com.rbctcsworld.ecommerce.common.exception.ConflictException;
import com.rbctcsworld.ecommerce.common.exception.NotFoundException;
import com.rbctcsworld.ecommerce.inventory.InventoryService;
import com.rbctcsworld.ecommerce.inventory.StockMovement;
import com.rbctcsworld.ecommerce.order.CustomerOrder;
import com.rbctcsworld.ecommerce.order.OrderItem;
import com.rbctcsworld.ecommerce.order.OrderRepository;
import com.rbctcsworld.ecommerce.order.OrderService;
import com.rbctcsworld.ecommerce.order.OrderStatus;
import com.rbctcsworld.ecommerce.payment.PaymentService;
import org.springframework.beans.factory.annotation.Value;
import com.rbctcsworld.ecommerce.notification.OrderNotificationEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.List;

/**
 * Customer: request a return for a DELIVERED order within the return window (one request per order).
 * Admin:    approve (refund per ReturnPolicy + optional restock, order -> RETURNED)
 *           or reject (order goes back to DELIVERED).
 */
@Service
@Transactional
public class ReturnService {

    private final ReturnRequestRepository returns;
    private final OrderRepository orders;
    private final OrderService orderService;
    private final InventoryService inventory;
    private final PaymentService payments;
    private final Clock clock;
    private final int windowDays;
    private final ApplicationEventPublisher events;

    public ReturnService(ReturnRequestRepository returns, OrderRepository orders, OrderService orderService,
                         InventoryService inventory, PaymentService payments, Clock clock,
                         @Value("${app.returns.window-days}") int windowDays, ApplicationEventPublisher events) {
        this.returns = returns;
        this.orders = orders;
        this.orderService = orderService;
        this.inventory = inventory;
        this.payments = payments;
        this.clock = clock;
        this.windowDays = windowDays;
        this.events = events;
    }

    /** 201 | 400 window closed | 404 not mine | 409 not delivered / already requested */
    public ReturnRequest request(String email, Long orderId, String reason, String comment) {
        CustomerOrder order = orderService.owned(email, orderId);
        if (returns.existsByOrderId(order.getId())) {
            throw new ConflictException("A return was already requested for order " + order.getOrderNumber());
        }
        order.assertCanMoveTo(OrderStatus.RETURN_REQUESTED);          // only DELIVERED orders
        LocalDateTime deadline = order.getDeliveredAt().plusDays(windowDays);
        if (LocalDateTime.now(clock).isAfter(deadline)) {
            throw new BusinessRuleException("Return window of " + windowDays + " days has closed for order "
                    + order.getOrderNumber());
        }
        String from = order.getStatus();
        order.transitionTo(OrderStatus.RETURN_REQUESTED, "Return requested: " + reason);
        events.publishEvent(AuditEvent.orderStatus(order.getId(), from, order.getStatus()));
        return returns.save(new ReturnRequest(order.getId(), reason, comment));
    }

    public ReturnRequest approve(Long returnId, String note) {
        ReturnRequest r = pending(returnId);
        CustomerOrder order = orders.findById(r.getOrderId()).orElseThrow();
        boolean restock = ReturnPolicy.restock(r.getReason());
        BigDecimal refund = ReturnPolicy.refundAmount(r.getReason(), order);

        if (restock) {
            order.getItems().stream()
                    .sorted(Comparator.comparing(OrderItem::getProductId))
                    .forEach(i -> inventory.release(i.getProductId(), i.getQuantity(), order.getId(),
                            StockMovement.RETURN_RESTOCK));
        }
        payments.refund(order.getId(), refund);
        String from = order.getStatus();
        order.transitionTo(OrderStatus.RETURNED, "Return approved, refunded " + refund);
        events.publishEvent(AuditEvent.orderStatus(order.getId(), from, order.getStatus()));
        r.approve(refund, restock, note);
        events.publishEvent(new OrderNotificationEvent(order.getId(), OrderNotificationEvent.Type.RETURN_APPROVED, refund));
        return r;
    }

    public ReturnRequest reject(Long returnId, String note) {
        ReturnRequest r = pending(returnId);
        CustomerOrder order = orders.findById(r.getOrderId()).orElseThrow();
        String from = order.getStatus();
        order.transitionTo(OrderStatus.DELIVERED, "Return rejected" + (note == null ? "" : ": " + note));
        events.publishEvent(AuditEvent.orderStatus(order.getId(), from, order.getStatus()));
        r.reject(note);
        events.publishEvent(OrderNotificationEvent.of(order.getId(), OrderNotificationEvent.Type.RETURN_REJECTED));
        return r;
    }

    @Transactional(readOnly = true)
    public List<ReturnRequest> list(String status) {
        return status == null || status.isBlank()
                ? returns.findAllByOrderByIdAsc()
                : returns.findByStatusOrderByIdAsc(status.trim().toUpperCase());
    }

    @Transactional(readOnly = true)
    public ReturnRequest forOrder(String email, Long orderId) {
        CustomerOrder order = orderService.owned(email, orderId);
        return returns.findByOrderId(order.getId())
                .orElseThrow(() -> new NotFoundException("No return for order " + order.getOrderNumber()));
    }

    private ReturnRequest pending(Long id) {
        ReturnRequest r = returns.findById(id).orElseThrow(() -> new NotFoundException("Return not found: " + id));
        if (!ReturnRequest.REQUESTED.equals(r.getStatus())) {
            throw new ConflictException("Return " + id + " is already " + r.getStatus());
        }
        return r;
    }
}