StockMovementService.java

package com.stocks.stockease.movement;

import java.util.List;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.stocks.stockease.invoice.InvoiceItem;
import com.stocks.stockease.invoice.InvoiceService;
import com.stocks.stockease.invoice.InvoiceStatus;
import com.stocks.stockease.invoice.InvoiceType;
import com.stocks.stockease.movement.internal.StockMovementRepository;
import com.stocks.stockease.product.Product;
import com.stocks.stockease.product.ProductService;
import com.stocks.stockease.security.User;

import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;

/**
 * Movement module's public API and the single write path for product stock quantities.
 * Movements are append-only: a recorded movement is never updated or deleted.
 */
@Service
@RequiredArgsConstructor
public class StockMovementService {

    private final StockMovementRepository stockMovementRepository;
    private final ProductService productService;
    private final InvoiceService invoiceService;

    /**
     * Returns every stock movement a user triggered, newest first.
     *
     * @param userId user identifier
     * @return that user's movements ordered by creation time descending
     */
    @Transactional(readOnly = true)
    public List<StockMovement> findByUser(long userId) {
        return stockMovementRepository.findByUserIdOrderByCreatedAtDesc(userId);
    }

    /**
     * Records a stock movement and applies its quantity change to the product atomically.
     *
     * @param command the movement to record
     * @param user the user triggering the movement
     * @return the persisted movement
     * @throws IllegalArgumentException if a required field is missing or a field is supplied that the reason forbids
     * @throws EntityNotFoundException if the referenced invoice item or product does not exist
     * @throws IllegalStateException if the movement contradicts its invoice item, duplicates an existing
     *         movement, or would drive the product's stock negative
     */
    @Transactional
    public StockMovement recordMovement(RecordMovementCommand command, User user) {
        if (user == null) {
            throw new IllegalArgumentException("User is required.");
        }
        if (command.productId() == null || command.reason() == null) {
            throw new IllegalArgumentException("Product and reason are required.");
        }
        if (command.quantity() <= 0) {
            throw new IllegalArgumentException("Quantity must be positive.");
        }

        MovementReason reason = command.reason();
        validateFields(command, reason);

        InvoiceItem item = requiresInvoiceItem(reason) ? loadAndValidateItem(command, reason) : null;
        if (reason == MovementReason.PURCHASE || reason == MovementReason.SOLD) {
            validateNotAlreadyRecorded(command, reason, item);
        }
        if (reason == MovementReason.RETURN_FROM_CUSTOMER || reason == MovementReason.RETURNED_TO_SUPPLIER) {
            // cap enforcement and the returnedQty increment join this transaction
            invoiceService.registerReturn(command.invoiceItemId(), command.quantity());
        }

        int delta = reason.getType() == MovementType.INCREASE ? command.quantity() : -command.quantity();
        Product product = productService.adjustQuantity(command.productId(), delta);

        return stockMovementRepository.save(buildMovement(command, reason, user, product, item));
    }

    /** Rejects fields the reason forbids and demands the ones it requires. */
    private void validateFields(RecordMovementCommand command, MovementReason reason) {
        switch (reason) {
            case NEW_PRODUCT -> {
                if (command.invoiceItemId() != null) {
                    throw new IllegalArgumentException("NEW_PRODUCT movements must not reference an invoice item.");
                }
                if (command.unitCost() == null || command.unitCost().signum() <= 0) {
                    throw new IllegalArgumentException("NEW_PRODUCT movements require a positive unit cost.");
                }
            }
            case LOST, DESTROYED -> {
                if (command.invoiceItemId() != null || command.unitCost() != null) {
                    throw new IllegalArgumentException(
                            "LOST and DESTROYED movements carry no invoice item or prices.");
                }
            }
            default -> {
                if (command.invoiceItemId() == null) {
                    throw new IllegalArgumentException(reason + " movements require an invoice item.");
                }
                if (command.unitCost() != null) {
                    throw new IllegalArgumentException(
                            "Unit cost is derived from the invoice item and must not be supplied.");
                }
            }
        }
    }

    /** Loads the linked invoice item and checks it matches the movement's invoice type and product. */
    private InvoiceItem loadAndValidateItem(RecordMovementCommand command, MovementReason reason) {
        InvoiceItem item = invoiceService.findItemById(command.invoiceItemId())
                .orElseThrow(() -> new EntityNotFoundException(
                        "Invoice item with ID " + command.invoiceItemId() + " not found."));
        InvoiceType requiredType = reason == MovementReason.PURCHASE || reason == MovementReason.RETURNED_TO_SUPPLIER
                ? InvoiceType.PURCHASE
                : InvoiceType.SALE;
        if (item.getInvoice().getType() != requiredType) {
            throw new IllegalStateException(
                    reason + " movements must reference a " + requiredType + " invoice item.");
        }
        if (item.getInvoice().getStatus() == InvoiceStatus.OPEN) {
            throw new IllegalStateException("Movements cannot be recorded against an open invoice.");
        }
        if (!item.getProduct().getId().equals(command.productId())) {
            throw new IllegalStateException(
                    "Invoice item " + command.invoiceItemId() + " belongs to a different product.");
        }
        return item;
    }

    /** Holds a purchase or sale to its invoice line's exact quantity, once per line. */
    private void validateNotAlreadyRecorded(RecordMovementCommand command, MovementReason reason, InvoiceItem item) {
        if (command.quantity() != item.getQuantity()) {
            throw new IllegalStateException(
                    "Movement quantity must equal the invoice item quantity (" + item.getQuantity() + ").");
        }
        if (stockMovementRepository.existsByInvoiceItemIdAndReason(command.invoiceItemId(), reason)) {
            throw new IllegalStateException(
                    "A " + reason + " movement already exists for invoice item " + command.invoiceItemId() + ".");
        }
    }

    /** Assembles the movement row, snapshotting prices from the invoice item rather than the caller. */
    private StockMovement buildMovement(RecordMovementCommand command, MovementReason reason, User user,
            Product product, InvoiceItem item) {
        StockMovement movement = new StockMovement();
        movement.setProduct(product);
        movement.setUser(user);
        movement.setType(reason.getType());
        movement.setReason(reason);
        movement.setQuantity(command.quantity());
        movement.setInvoiceItem(item);
        switch (reason) {
            case PURCHASE -> movement.setUnitCost(item.getUnitPrice());
            case SOLD, RETURN_FROM_CUSTOMER -> movement.setSoldPrice(item.getUnitPrice());
            case NEW_PRODUCT -> movement.setUnitCost(command.unitCost());
            default -> {
                // LOST, DESTROYED and RETURNED_TO_SUPPLIER carry no price snapshot
            }
        }
        return movement;
    }

    /** Reports whether the reason must be backed by an invoice line. */
    private static boolean requiresInvoiceItem(MovementReason reason) {
        return reason == MovementReason.PURCHASE || reason == MovementReason.SOLD
                || reason == MovementReason.RETURN_FROM_CUSTOMER || reason == MovementReason.RETURNED_TO_SUPPLIER;
    }
}