InvoiceService.java
package com.stocks.stockease.invoice;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.stocks.stockease.customer.Customer;
import com.stocks.stockease.customer.CustomerService;
import com.stocks.stockease.invoice.internal.InvoiceItemRepository;
import com.stocks.stockease.invoice.internal.InvoiceRepository;
import com.stocks.stockease.product.Product;
import com.stocks.stockease.product.ProductService;
import com.stocks.stockease.security.User;
import com.stocks.stockease.supplier.Supplier;
import com.stocks.stockease.supplier.SupplierService;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
/**
* Invoice module's public API for the invoice lifecycle: creation, closing, returns and deletion.
* Other modules depend on this service rather than reaching into the module's repositories.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class InvoiceService {
private final InvoiceItemRepository invoiceItemRepository;
private final InvoiceRepository invoiceRepository;
private final ProductService productService;
private final SupplierService supplierService;
private final CustomerService customerService;
private final ApplicationEventPublisher eventPublisher;
/**
* Finds an invoice item by its ID.
*
* @param id invoice item identifier
* @return the invoice item, or empty if none exists with that ID
*/
public Optional<InvoiceItem> findItemById(long id) {
return invoiceItemRepository.findById(id);
}
/**
* Returns every invoice a user closed, most recently closed first.
*
* @param userId user identifier
* @return the invoices that user closed, ordered by closing time descending
*/
public List<Invoice> findClosedBy(long userId) {
// closed invoices are never soft-deleted (deletion is vetoed for non-open invoices), so no
// restriction bypass is needed here
return invoiceRepository.findByClosedByIdOrderByClosedAtDesc(userId);
}
/**
* Creates an invoice together with all of its lines in one transaction.
*
* @param command the invoice and lines to create
* @return the persisted invoice including its generated ID and items
* @throws IllegalArgumentException if a required field is missing, the counterparty does not match the
* invoice type, or a line has a non-positive quantity or unit price
* @throws EntityNotFoundException if the supplier, customer or any product does not exist
*/
@Transactional
public Invoice createInvoice(CreateInvoiceCommand command) {
if (command.type() == null) {
throw new IllegalArgumentException("Invoice type is required.");
}
if (command.dueDate() == null) {
throw new IllegalArgumentException("Due date is required.");
}
if (command.items() == null || command.items().isEmpty()) {
throw new IllegalArgumentException("An invoice requires at least one item.");
}
Invoice invoice = new Invoice();
invoice.setType(command.type());
applyCounterparty(command, invoice);
invoice.setStatus(InvoiceStatus.OPEN);
invoice.setDueDate(command.dueDate());
invoice.setInterestRate(command.interestRate() == null ? BigDecimal.ZERO : command.interestRate());
invoice.setFineValue(command.fineValue() == null ? BigDecimal.ZERO : command.fineValue());
for (CreateInvoiceCommand.ItemLine line : command.items()) {
invoice.getItems().add(buildItem(invoice, line));
}
return invoiceRepository.save(invoice);
}
/** Resolves and attaches the counterparty the invoice type calls for. */
private void applyCounterparty(CreateInvoiceCommand command, Invoice invoice) {
if (command.type() == InvoiceType.PURCHASE) {
if (command.supplierId() == null || command.customerId() != null) {
throw new IllegalArgumentException("Purchase invoices require a supplier and no customer.");
}
Supplier supplier = supplierService.findById(command.supplierId())
.orElseThrow(() -> new EntityNotFoundException(
"Supplier with ID " + command.supplierId() + " not found."));
invoice.setSupplier(supplier);
return;
}
if (command.supplierId() != null) {
throw new IllegalArgumentException("Sale invoices must not reference a supplier.");
}
if (command.customerId() != null) {
Customer customer = customerService.findById(command.customerId())
.orElseThrow(() -> new EntityNotFoundException(
"Customer with ID " + command.customerId() + " not found."));
invoice.setCustomer(customer);
}
}
/** Validates a line and builds its persistent item. */
private InvoiceItem buildItem(Invoice invoice, CreateInvoiceCommand.ItemLine line) {
if (line.quantity() <= 0) {
throw new IllegalArgumentException("Item quantity must be positive.");
}
if (line.unitPrice() == null || line.unitPrice().signum() <= 0) {
throw new IllegalArgumentException("Item unit price must be positive.");
}
Product product = productService.findById(line.productId())
.orElseThrow(() -> new EntityNotFoundException(
"Product with ID " + line.productId() + " not found."));
InvoiceItem item = new InvoiceItem();
item.setInvoice(invoice);
item.setProduct(product);
item.setQuantity(line.quantity());
item.setUnitPrice(line.unitPrice());
item.setReturnedQty(0);
return item;
}
/**
* Closes an open invoice and books its lines into stock.
*
* @param invoiceId invoice identifier
* @param user user closing the invoice
* @return the closed invoice
* @throws EntityNotFoundException if no invoice exists with the given ID
* @throws IllegalStateException if the invoice is not open, or if booking its stock movements fails
*/
@Transactional
public Invoice close(long invoiceId, User user) {
Invoice invoice = invoiceRepository.findById(invoiceId)
.orElseThrow(() -> new EntityNotFoundException("Invoice with ID " + invoiceId + " not found."));
if (invoice.getStatus() != InvoiceStatus.OPEN) {
throw new IllegalStateException("Only open invoices can be closed.");
}
invoice.setStatus(InvoiceStatus.CLOSED);
invoice.setClosedBy(user);
invoice.setClosedAt(LocalDateTime.now());
invoiceRepository.save(invoice);
List<InvoiceClosedEvent.Line> lines = invoice.getItems().stream()
.map(item -> new InvoiceClosedEvent.Line(item.getId(), item.getProduct().getId(), item.getQuantity()))
.toList();
// synchronous listeners run inside this transaction - a failed stock booking rolls back the close
eventPublisher.publishEvent(new InvoiceClosedEvent(invoice.getId(), invoice.getType(), user, lines));
return invoice;
}
/**
* Registers a return of {@code quantity} units against an invoice item.
*
* @param itemId invoice item identifier
* @param quantity number of units being returned; must be positive
* @return the updated invoice item
* @throws EntityNotFoundException if no invoice item exists with the given ID
* @throws IllegalArgumentException if {@code quantity} is not positive
* @throws IllegalStateException if the invoice is still open, or if the return would exceed the item's
* remaining returnable quantity
*/
@Transactional
public InvoiceItem registerReturn(long itemId, int quantity) {
InvoiceItem item = invoiceItemRepository.findById(itemId)
.orElseThrow(() -> new EntityNotFoundException("Invoice item with ID " + itemId + " not found."));
if (item.getInvoice().getStatus() == InvoiceStatus.OPEN) {
throw new IllegalStateException("Returns require a closed invoice.");
}
if (quantity <= 0) {
throw new IllegalArgumentException("Return quantity must be positive.");
}
// database CHECK on returned_qty is the backstop for this invariant
if (item.getReturnedQty() + quantity > item.getQuantity()) {
throw new IllegalStateException("Return of " + quantity + " exceeds remaining returnable quantity "
+ (item.getQuantity() - item.getReturnedQty()) + " for invoice item " + itemId + ".");
}
item.setReturnedQty(item.getReturnedQty() + quantity);
InvoiceItem saved = invoiceItemRepository.save(item);
Invoice invoice = item.getInvoice();
// allMatch over an empty collection is vacuously true - guard against any future creation
// path that could yield an itemless invoice
if (!invoice.getItems().isEmpty()
&& invoice.getItems().stream().allMatch(line -> line.getReturnedQty().equals(line.getQuantity()))) {
invoice.setStatus(InvoiceStatus.FULLY_RETURNED);
invoiceRepository.save(invoice);
}
return saved;
}
/**
* Records that an invoice has been paid, stamping the moment of payment.
* Payment is independent of the invoice lifecycle status: an invoice may be paid before or after
* it is closed, so no status precondition applies.
*
* @param invoiceId invoice identifier
* @return the paid invoice
* @throws EntityNotFoundException if no invoice exists with the given ID
* @throws IllegalStateException if the invoice is already marked as paid
*/
@Transactional
public Invoice markAsPaid(long invoiceId) {
Invoice invoice = invoiceRepository.findById(invoiceId)
.orElseThrow(() -> new EntityNotFoundException("Invoice with ID " + invoiceId + " not found."));
if (invoice.getPaidAt() != null) {
throw new IllegalStateException("Invoice is already marked as paid.");
}
invoice.setPaidAt(LocalDateTime.now());
return invoiceRepository.save(invoice);
}
/**
* Deletes an open invoice; the entity's soft-delete mapping applies.
*
* @param invoiceId invoice identifier
* @throws EntityNotFoundException if no invoice exists with the given ID
* @throws IllegalStateException if the invoice is not open
*/
@Transactional
public void deleteById(long invoiceId) {
Invoice invoice = invoiceRepository.findById(invoiceId)
.orElseThrow(() -> new EntityNotFoundException("Invoice with ID " + invoiceId + " not found."));
if (invoice.getStatus() != InvoiceStatus.OPEN) {
throw new IllegalStateException("Only open invoices can be deleted.");
}
invoiceRepository.delete(invoice);
}
}