OrderService.java

package com.rbctcsworld.ecommerce.order;

import com.rbctcsworld.ecommerce.audit.AuditEvent;
import com.rbctcsworld.ecommerce.auth.AppUser;
import com.rbctcsworld.ecommerce.auth.UserRepository;
import com.rbctcsworld.ecommerce.cart.CartItem;
import com.rbctcsworld.ecommerce.cart.CartItemRepository;
import com.rbctcsworld.ecommerce.common.exception.BusinessRuleException;
import com.rbctcsworld.ecommerce.common.exception.NotFoundException;
import com.rbctcsworld.ecommerce.common.exception.PaymentDeclinedException;
import com.rbctcsworld.ecommerce.coupon.Coupon;
import com.rbctcsworld.ecommerce.coupon.CouponService;
import com.rbctcsworld.ecommerce.inventory.InventoryService;
import com.rbctcsworld.ecommerce.order.OrderDtos.OrderLine;
import com.rbctcsworld.ecommerce.order.OrderDtos.OrderResponse;
import com.rbctcsworld.ecommerce.order.OrderDtos.QuoteResponse;
import com.rbctcsworld.ecommerce.order.OrderDtos.TrackingResponse;
import com.rbctcsworld.ecommerce.payment.PaymentDtos.PayRequest;
import com.rbctcsworld.ecommerce.payment.PaymentDtos.PaySavedRequest;
import com.rbctcsworld.ecommerce.payment.PaymentMethodService;
import com.rbctcsworld.ecommerce.payment.PaymentService;
import com.rbctcsworld.ecommerce.payment.PaymentTransaction;
import com.rbctcsworld.ecommerce.pricing.PricingService;
import com.rbctcsworld.ecommerce.pricing.PricingService.PriceBreakdown;
import com.rbctcsworld.ecommerce.product.Product;
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.security.SecureRandom;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.List;

/**
 * Place order = ONE database transaction:
 *   1. read the customer's cart (400 if empty) and check that there is a delivery address (400 if not)
 *   2. validate the optional coupon and calculate discount, shipping, tax, total
 *   3. for every line (sorted by product id to avoid deadlocks) reserve stock atomically (409 if not possible)
 *   4. save order + items + stock movements + coupon redemption
 *   5. empty the cart
 * If any step fails, everything is rolled back: no stock changes, no order, no coupon use, cart untouched.
 *
 * Pay  = PLACED -> PAID with a successful charge (a declined card is recorded but the order stays PLACED).
 * Cancel = PLACED/PAID -> CANCELLED, stock released, and a refund if it was PAID.
 */
@Service
@Transactional
public class OrderService {

    private static final String ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I confusion
    private static final SecureRandom RANDOM = new SecureRandom();

    private final OrderRepository orders;
    private final CartItemRepository cartItems;
    private final UserRepository users;
    private final InventoryService inventory;
    private final PricingService pricing;
    private final CouponService coupons;
    private final PaymentService payments;
    private final PaymentMethodService paymentMethods;
    private final ApplicationEventPublisher events;

    public OrderService(OrderRepository orders, CartItemRepository cartItems, UserRepository users,
                        InventoryService inventory, PricingService pricing, CouponService coupons,
                        PaymentService payments, PaymentMethodService paymentMethods, ApplicationEventPublisher events) {
        this.orders = orders;
        this.cartItems = cartItems;
        this.users = users;
        this.inventory = inventory;
        this.pricing = pricing;
        this.coupons = coupons;
        this.payments = payments;
        this.paymentMethods = paymentMethods;
        this.events = events;
    }

    // ---------------------------------------------------------------- checkout

    /** Price preview for the current cart (+ optional coupon). Read-only: nothing is reserved or redeemed. */
    @Transactional(readOnly = true)
    public QuoteResponse quote(String email, String couponCode) {
        AppUser user = user(email);
        List<CartItem> lines = cartLines(user);
        List<OrderLine> orderLines = lines.stream().map(OrderService::toLine).toList();
        BigDecimal subtotal = orderLines.stream().map(OrderLine::lineTotal).reduce(BigDecimal.ZERO, BigDecimal::add);
        Coupon coupon = hasText(couponCode) ? coupons.validate(couponCode, user.getId(), subtotal) : null;
        PriceBreakdown p = pricing.price(subtotal, coupon == null ? BigDecimal.ZERO : coupon.discountFor(subtotal));
        int qty = orderLines.stream().mapToInt(OrderLine::quantity).sum();
        return new QuoteResponse(orderLines, qty, p.subtotal(), coupon == null ? null : coupon.getCode(),
                p.discount(), p.shippingFee(), p.tax(), p.total());
    }

    public OrderResponse placeOrder(String email, String couponCode, ShippingAddress address) {
        AppUser user = user(email);
        List<CartItem> lines = cartLines(user);
        if (address == null) {
            throw new BusinessRuleException("Shipping address is required");
        }

        CustomerOrder order = new CustomerOrder(newOrderNumber(), user.getId());
        order.shipTo(address.normalized());
        for (CartItem line : lines) {
            Product p = line.getProduct();
            order.addItem(new OrderItem(p.getId(), p.getSku(), p.displayName(), p.currentPrice(), line.getQuantity()));
        }

        Coupon coupon = hasText(couponCode) ? coupons.validate(couponCode, user.getId(), order.getSubtotal()) : null;
        PriceBreakdown price = pricing.price(order.getSubtotal(),
                coupon == null ? BigDecimal.ZERO : coupon.discountFor(order.getSubtotal()));
        order.applyPricing(price.discount(), price.shippingFee(), price.tax(), price.total(),
                coupon == null ? null : coupon.getCode());
        orders.save(order); // gets the id used by stock movements / redemption

        for (CartItem line : lines) {
            inventory.reserve(line.getProduct(), line.getQuantity(), order.getId(), address.state());
        }
        if (coupon != null) {
            coupons.redeem(coupon, user.getId(), order.getId());
        }

        cartItems.deleteByUserId(user.getId());
        events.publishEvent(OrderNotificationEvent.of(order.getId(), OrderNotificationEvent.Type.ORDER_PLACED));
        return OrderResponse.from(order);
    }

    /**
     * noRollbackFor: a declined card must still leave its FAILED payment_transactions row in the database.
     */
    @Transactional(noRollbackFor = PaymentDeclinedException.class)
    public OrderResponse pay(String email, Long orderId, PayRequest card) {
        CustomerOrder order = owned(email, orderId);
        order.assertCanMoveTo(OrderStatus.PAID);                      // 409 before any money moves
        payments.charge(order.getId(), order.getTotal(), card);   // throws 400 / 402 on problems
        OrderResponse paid = markPaid(order);
        if (Boolean.TRUE.equals(card.saveCard())) {
            // "save this card": only after the money moved, and it can never fail the payment
            paymentMethods.saveAfterPayment(email, card.cardNumber(), card.expiryMonth(), card.expiryYear());
        }
        return paid;
    }

    /** Pays with a card from the wallet; the CVV is asked again and never kept. 404 for somebody else's card. */
    @Transactional(noRollbackFor = PaymentDeclinedException.class)
    public OrderResponse paySaved(String email, Long orderId, PaySavedRequest request) {
        CustomerOrder order = owned(email, orderId);
        order.assertCanMoveTo(OrderStatus.PAID);
        payments.chargeSaved(order.getId(), order.getTotal(), paymentMethods.ownedBy(email, request.paymentMethodId()));
        return markPaid(order);
    }

    private OrderResponse markPaid(CustomerOrder order) {
        String from = order.getStatus();
        order.markPaid();
        events.publishEvent(AuditEvent.orderStatus(order.getId(), from, order.getStatus()));
        events.publishEvent(OrderNotificationEvent.of(order.getId(), OrderNotificationEvent.Type.PAYMENT_RECEIVED));
        return OrderResponse.from(order);
    }

    // ---------------------------------------------------------------- after checkout

    @Transactional(readOnly = true)
    public List<OrderResponse> myOrders(String email) {
        return orders.findByUserIdOrderByIdDesc(user(email).getId()).stream().map(OrderResponse::from).toList();
    }

    @Transactional(readOnly = true)
    public OrderResponse myOrder(String email, Long orderId) {
        return OrderResponse.from(owned(email, orderId));
    }

    @Transactional(readOnly = true)
    public TrackingResponse myTracking(String email, Long orderId) {
        return TrackingResponse.from(owned(email, orderId));
    }

    @Transactional(readOnly = true)
    public List<PaymentTransaction> myPayments(String email, Long orderId) {
        return payments.history(owned(email, orderId).getId());
    }

    public OrderResponse cancel(String email, Long orderId) {
        CustomerOrder order = owned(email, orderId);
        order.assertCanMoveTo(OrderStatus.CANCELLED);                 // SHIPPED and later: 409
        order.getItems().stream()
                .sorted(Comparator.comparing(OrderItem::getProductId))
                .forEach(i -> inventory.release(i.getProductId(), i.getQuantity(), order.getId()));
        BigDecimal refunded = null;
        if (order.isPaid()) {
            payments.refund(order.getId(), order.getTotal());
            refunded = order.getTotal();
        }
        String from = order.getStatus();
        order.cancel();
        events.publishEvent(AuditEvent.orderStatus(order.getId(), from, order.getStatus()));
        events.publishEvent(new OrderNotificationEvent(order.getId(), OrderNotificationEvent.Type.ORDER_CANCELLED, refunded));
        return OrderResponse.from(order);
    }

    @Transactional(readOnly = true)
    public List<OrderResponse> allOrders() {
        return orders.findAllByOrderByIdDesc().stream().map(OrderResponse::from).toList();
    }

    // ---------------------------------------------------------------- helpers

    private List<CartItem> cartLines(AppUser user) {
        List<CartItem> lines = cartItems.findByUserIdOrderByIdAsc(user.getId()).stream()
                .sorted(Comparator.comparing(ci -> ci.getProduct().getId()))
                .toList();
        if (lines.isEmpty()) {
            throw new BusinessRuleException("Cart is empty");
        }
        return lines;
    }

    private static OrderLine toLine(CartItem ci) {
        Product p = ci.getProduct();
        return new OrderLine(p.getId(), p.getSku(), p.displayName(), p.currentPrice(), ci.getQuantity(),
                p.currentPrice().multiply(BigDecimal.valueOf(ci.getQuantity())));
    }

    /** Also used by ReturnService: the order if it belongs to this customer, otherwise 404 (IDOR). */
    public CustomerOrder owned(String email, Long orderId) {
        return orders.findByIdAndUserId(orderId, user(email).getId())
                .orElseThrow(() -> new NotFoundException("Order not found: " + orderId));
    }

    private AppUser user(String email) {
        return users.findByEmail(email).orElseThrow(() -> new NotFoundException("User not found"));
    }

    private static boolean hasText(String s) {
        return s != null && !s.isBlank();
    }

    /** e.g. ORD-20261002-7KQ2MZ */
    static String newOrderNumber() {
        StringBuilder sb = new StringBuilder("ORD-")
                .append(LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)).append('-');
        for (int i = 0; i < 6; i++) {
            sb.append(ALPHABET.charAt(RANDOM.nextInt(ALPHABET.length())));
        }
        return sb.toString();
    }
}