[Java Backend Zero to Hello] BÀI 8.4: CART & ORDER MODULE
📚 Bài viết thuộc series Java Backend Zero to Hello 📌 Phần: Phase 8: Project Thực Tế Cuối Khóa | Bài 82/86
BÀI 8.4: CART & ORDER MODULE
Mục tiêu
- Implement Cart management
- Implement Order creation
- Quản lý trạng thái đơn hàng
- Tích hợp payment
1. CART MODULE
1.1 Entities
@Entity
@Table(name = "carts")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class Cart {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false, unique = true)
private User user;
@OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true)
private List<CartItem> items = new ArrayList<>();
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
public void addItem(CartItem item) {
items.add(item);
item.setCart(this);
}
public void removeItem(CartItem item) {
items.remove(item);
item.setCart(null);
}
public BigDecimal getTotalAmount() {
return items.stream()
.map(item -> item.getProduct().getPrice()
.multiply(BigDecimal.valueOf(item.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
@Entity
@Table(name = "cart_items")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class CartItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "cart_id", nullable = false)
private Cart cart;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(nullable = false)
private Integer quantity;
public BigDecimal getSubtotal() {
return product.getPrice().multiply(BigDecimal.valueOf(quantity));
}
}
1.2 Service
@Service
@RequiredArgsConstructor
@Slf4j
public class CartService {
private final CartRepository cartRepository;
private final ProductRepository productRepository;
private final UserRepository userRepository;
@Transactional
public CartResponse getCart(Long userId) {
Cart cart = getOrCreateCart(userId);
return CartResponse.from(cart);
}
@Transactional
public CartResponse addItem(Long userId, AddCartItemRequest request) {
Cart cart = getOrCreateCart(userId);
Product product = productRepository.findById(request.productId())
.orElseThrow(() -> new NotFoundException("Product not found"));
if (product.getStock() < request.quantity()) {
throw new BusinessException("INSUFFICIENT_STOCK",
"Not enough stock");
}
Optional<CartItem> existingItem = cart.getItems().stream()
.filter(item -> item.getProduct().getId().equals(product.getId()))
.findFirst();
if (existingItem.isPresent()) {
CartItem item = existingItem.get();
item.setQuantity(item.getQuantity() + request.quantity());
} else {
CartItem item = CartItem.builder()
.product(product)
.quantity(request.quantity())
.build();
cart.addItem(item);
}
Cart saved = cartRepository.save(cart);
return CartResponse.from(saved);
}
@Transactional
public CartResponse updateItemQuantity(Long userId, Long productId, Integer quantity) {
Cart cart = getOrCreateCart(userId);
CartItem item = cart.getItems().stream()
.filter(i -> i.getProduct().getId().equals(productId))
.findFirst()
.orElseThrow(() -> new NotFoundException("Item not in cart"));
if (quantity <= 0) {
cart.removeItem(item);
} else {
if (item.getProduct().getStock() < quantity) {
throw new BusinessException("INSUFFICIENT_STOCK", "Not enough stock");
}
item.setQuantity(quantity);
}
return CartResponse.from(cartRepository.save(cart));
}
@Transactional
public CartResponse removeItem(Long userId, Long productId) {
Cart cart = getOrCreateCart(userId);
CartItem item = cart.getItems().stream()
.filter(i -> i.getProduct().getId().equals(productId))
.findFirst()
.orElseThrow(() -> new NotFoundException("Item not in cart"));
cart.removeItem(item);
return CartResponse.from(cartRepository.save(cart));
}
@Transactional
public void clearCart(Long userId) {
Cart cart = getOrCreateCart(userId);
cart.getItems().clear();
cartRepository.save(cart);
}
private Cart getOrCreateCart(Long userId) {
return cartRepository.findByUserId(userId)
.orElseGet(() -> {
User user = userRepository.findById(userId)
.orElseThrow(() -> new NotFoundException("User not found"));
Cart newCart = Cart.builder().user(user).build();
return cartRepository.save(newCart);
});
}
}
2. ORDER MODULE
2.1 Entities
@Entity
@Table(name = "orders")
@EntityListeners(AuditingEntityListener.class)
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "order_number", unique = true, nullable = false, length = 50)
private String orderNumber;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private OrderStatus status = OrderStatus.PENDING;
@Column(name = "total_amount", nullable = false, precision = 12, scale = 2)
private BigDecimal totalAmount;
@Column(name = "shipping_fee", precision = 12, scale = 2)
private BigDecimal shippingFee = BigDecimal.ZERO;
@Column(precision = 12, scale = 2)
private BigDecimal discount = BigDecimal.ZERO;
@Column(name = "final_amount", nullable = false, precision = 12, scale = 2)
private BigDecimal finalAmount;
@Column(name = "shipping_address", columnDefinition = "TEXT")
private String shippingAddress;
@Column(name = "shipping_phone", length = 20)
private String shippingPhone;
@Column(name = "shipping_name", length = 100)
private String shippingName;
@Column(columnDefinition = "TEXT")
private String note;
@Enumerated(EnumType.STRING)
@Column(name = "payment_method", length = 20)
private PaymentMethod paymentMethod;
@Enumerated(EnumType.STRING)
@Column(name = "payment_status", length = 20)
private PaymentStatus paymentStatus = PaymentStatus.PENDING;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
public enum OrderStatus {
PENDING, CONFIRMED, PROCESSING, SHIPPED, DELIVERED, CANCELLED
}
public enum PaymentMethod {
COD, BANK_TRANSFER, MOMO, VNPAY
}
public enum PaymentStatus {
PENDING, PAID, FAILED, REFUNDED
}
2.2 OrderItem Entity
@Entity
@Table(name = "order_items")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(name = "product_name", nullable = false)
private String productName;
@Column(name = "product_price", nullable = false, precision = 12, scale = 2)
private BigDecimal productPrice;
@Column(nullable = false)
private Integer quantity;
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal subtotal;
}
2.3 Service
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderService {
private final OrderRepository orderRepository;
private final CartRepository cartRepository;
private final ProductRepository productRepository;
private final UserRepository userRepository;
@Transactional
public OrderResponse createOrder(Long userId, CreateOrderRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new NotFoundException("User not found"));
Cart cart = cartRepository.findByUserId(userId)
.orElseThrow(() -> new BusinessException("CART_EMPTY", "Cart is empty"));
if (cart.getItems().isEmpty()) {
throw new BusinessException("CART_EMPTY", "Cart is empty");
}
// Validate stock
for (CartItem item : cart.getItems()) {
Product product = item.getProduct();
if (product.getStock() < item.getQuantity()) {
throw new BusinessException("INSUFFICIENT_STOCK",
"Not enough stock for: " + product.getName());
}
}
// Create order
Order order = Order.builder()
.orderNumber(generateOrderNumber())
.user(user)
.status(OrderStatus.PENDING)
.shippingAddress(request.shippingAddress())
.shippingPhone(request.shippingPhone())
.shippingName(request.shippingName())
.note(request.note())
.paymentMethod(request.paymentMethod())
.paymentStatus(PaymentStatus.PENDING)
.shippingFee(BigDecimal.valueOf(30000))
.build();
BigDecimal totalAmount = BigDecimal.ZERO;
for (CartItem cartItem : cart.getItems()) {
Product product = cartItem.getProduct();
BigDecimal subtotal = product.getPrice()
.multiply(BigDecimal.valueOf(cartItem.getQuantity()));
OrderItem orderItem = OrderItem.builder()
.product(product)
.productName(product.getName())
.productPrice(product.getPrice())
.quantity(cartItem.getQuantity())
.subtotal(subtotal)
.build();
order.addItem(orderItem);
totalAmount = totalAmount.add(subtotal);
// Update stock
product.setStock(product.getStock() - cartItem.getQuantity());
productRepository.save(product);
}
order.setTotalAmount(totalAmount);
order.setFinalAmount(totalAmount.add(order.getShippingFee()).subtract(order.getDiscount()));
Order saved = orderRepository.save(order);
// Clear cart
cart.getItems().clear();
cartRepository.save(cart);
log.info("Order created: {}", saved.getOrderNumber());
return OrderResponse.from(saved);
}
public Page<OrderResponse> getUserOrders(Long userId, Pageable pageable) {
return orderRepository.findByUserId(userId, pageable)
.map(OrderResponse::from);
}
public OrderResponse getOrderById(Long userId, Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new NotFoundException("Order not found"));
if (!order.getUser().getId().equals(userId)) {
throw new BusinessException("FORBIDDEN", "Not your order");
}
return OrderResponse.from(order);
}
@Transactional
public OrderResponse cancelOrder(Long userId, Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new NotFoundException("Order not found"));
if (!order.getUser().getId().equals(userId)) {
throw new BusinessException("FORBIDDEN", "Not your order");
}
if (order.getStatus() != OrderStatus.PENDING) {
throw new BusinessException("CANNOT_CANCEL",
"Cannot cancel order in status: " + order.getStatus());
}
order.setStatus(OrderStatus.CANCELLED);
// Restore stock
for (OrderItem item : order.getItems()) {
Product product = item.getProduct();
product.setStock(product.getStock() + item.getQuantity());
productRepository.save(product);
}
return OrderResponse.from(orderRepository.save(order));
}
@Transactional
public OrderResponse updateStatus(Long orderId, OrderStatus status) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new NotFoundException("Order not found"));
order.setStatus(status);
return OrderResponse.from(orderRepository.save(order));
}
private String generateOrderNumber() {
return "ORD-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
+ "-" + UUID.randomUUID().toString().substring(0, 4).toUpperCase();
}
}
2.4 Controller
@RestController
@RequestMapping("/orders")
@RequiredArgsConstructor
@Tag(name = "Order", description = "Order APIs")
public class OrderController {
private final OrderService orderService;
@PostMapping
@Operation(summary = "Create order")
public ResponseEntity<ApiResponse<OrderResponse>> createOrder(
@AuthenticationPrincipal User user,
@RequestBody @Valid CreateOrderRequest request) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success("Order created",
orderService.createOrder(user.getId(), request)));
}
@GetMapping("/me")
@Operation(summary = "Get my orders")
public ApiResponse<PageResponse<OrderResponse>> getMyOrders(
@AuthenticationPrincipal User user,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Direction.DESC, "createdAt"));
return ApiResponse.success(PageResponse.from(
orderService.getUserOrders(user.getId(), pageable)));
}
@GetMapping("/me/{id}")
@Operation(summary = "Get my order by ID")
public ApiResponse<OrderResponse> getMyOrder(
@AuthenticationPrincipal User user,
@PathVariable Long id) {
return ApiResponse.success(orderService.getOrderById(user.getId(), id));
}
@PatchMapping("/me/{id}/cancel")
@Operation(summary = "Cancel my order")
public ApiResponse<OrderResponse> cancelMyOrder(
@AuthenticationPrincipal User user,
@PathVariable Long id) {
return ApiResponse.success(orderService.cancelOrder(user.getId(), id));
}
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Get all orders (Admin)")
public ApiResponse<PageResponse<OrderResponse>> getAllOrders(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
Pageable pageable = PageRequest.of(page, size);
return ApiResponse.success(PageResponse.from(
orderService.getAllOrders(pageable)));
}
@PatchMapping("/{id}/status")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Update order status (Admin)")
public ApiResponse<OrderResponse> updateStatus(
@PathVariable Long id,
@RequestParam OrderStatus status) {
return ApiResponse.success(orderService.updateStatus(id, status));
}
}
3. PAYMENT MODULE
3.1 Service
@Service
@RequiredArgsConstructor
@Slf4j
public class PaymentService {
private final PaymentRepository paymentRepository;
private final OrderRepository orderRepository;
@Transactional
public PaymentResponse processPayment(Long orderId, PaymentMethod method) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new NotFoundException("Order not found"));
if (order.getPaymentStatus() == PaymentStatus.PAID) {
throw new BusinessException("ALREADY_PAID", "Order already paid");
}
// Mock payment processing
Payment payment = Payment.builder()
.order(order)
.amount(order.getFinalAmount())
.method(method)
.status(PaymentStatus.PAID)
.transactionId(UUID.randomUUID().toString())
.paidAt(LocalDateTime.now())
.build();
Payment saved = paymentRepository.save(payment);
order.setPaymentStatus(PaymentStatus.PAID);
order.setStatus(OrderStatus.CONFIRMED);
orderRepository.save(order);
log.info("Payment processed for order: {}", order.getOrderNumber());
return PaymentResponse.from(saved);
}
}
4. TESTING
@SpringBootTest
@Testcontainers
@AutoConfigureMockMvc
class OrderIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private UserRepository userRepository;
@Autowired private ProductRepository productRepository;
@Autowired private CartRepository cartRepository;
@Test
void shouldCreateOrder() throws Exception {
// Setup
User user = createUser();
Product product = createProduct();
addToCart(user, product, 2);
// Create order
CreateOrderRequest request = new CreateOrderRequest(
"123 Street", "0123456789", "An", null, PaymentMethod.COD
);
mockMvc.perform(post("/orders")
.header("Authorization", "Bearer " + getToken(user))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.orderNumber").exists())
.andExpect(jsonPath("$.data.status").value("PENDING"));
}
}
5. BÀI TẬP
Bài 1: Cart
Implement Cart với add, update, remove items.
Bài 2: Order
Implement Order creation từ Cart.
Bài 3: Payment
Implement payment processing (mock).
Bài 4: Test
Viết integration test cho order flow.
Bài tiếp theo: 8.5 Statistics & Deployment
🧭 Điều Hướng Series
⬅️ Bài trước: BÀI 8.3: PRODUCT & CATEGORY MODULE
📋 Lộ trình tổng quan: Xem Toàn Bộ Series
➡️ Bài tiếp theo: BÀI 8.5: STATISTICS & DEPLOYMENT
All rights reserved