""" @router CartRouter (Backend/app/api/v1/routers/CartRouter.py) @purpose Database-backed shopping cart for authenticated customers (and optional guest visitor_id). """ from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from typing import List, Optional, Any from sqlalchemy.orm import Session import ulid import json from app.core.database.db_session import get_db from app.api.v1.routers.CustomerProfileRouter import get_current_customer from app.models.EcomCustomerModel import EcomCustomer from app.models.CartModel import Cart from app.models.ProductModel import ProductVariant from app.services.InventoryService import get_available_stock router = APIRouter(prefix="/api/v1/cart", tags=["Shopping Cart"]) class CartItemSchema(BaseModel): variant_id: str qty: int unit_price: Optional[float] = None product_name: Optional[str] = None sku: Optional[str] = None total_price: Optional[float] = None class AddToCartRequest(BaseModel): visitor_id: Optional[str] = None variant_id: str qty: int = 1 class SetCartItemsRequest(BaseModel): """Replace entire cart contents (used by checkout sync from Zustand).""" items: List[AddToCartRequest] class MergeGuestCartRequest(BaseModel): visitor_id: str def _ensure_stock(db: Session, variant_id: str, requested_qty: int, sku: Optional[str] = None) -> None: available = get_available_stock(variant_id, db) if requested_qty > available: label = sku or variant_id raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Insufficient stock for {label}. Available: {available}, requested: {requested_qty}.", ) def _parse_items(raw: Any) -> List[dict]: if raw is None: return [] if isinstance(raw, str): try: raw = json.loads(raw) except Exception: return [] if not isinstance(raw, list): return [] return [i for i in raw if isinstance(i, dict) and i.get("variant_id")] def _get_or_create_customer_cart(db: Session, customer_id: str) -> Cart: cart = db.query(Cart).filter(Cart.customer_id == customer_id).first() if not cart: cart = Cart( cart_id=str(ulid.ULID()), customer_id=customer_id, items_json=[], ) db.add(cart) db.flush() return cart def _enrich_and_totals(db: Session, items: List[dict]) -> dict: enriched = [] subtotal = 0.0 for item in items: variant_id = item.get("variant_id") qty = int(item.get("qty", 1)) if qty <= 0: continue variant = db.query(ProductVariant).filter(ProductVariant.variant_id == variant_id).first() if not variant: continue unit_price = float(variant.price) line_total = unit_price * qty subtotal += line_total product_name = variant.product.name if variant.product else "Product" enriched.append({ "variant_id": variant_id, "qty": qty, "unit_price": unit_price, "product_name": product_name, "sku": variant.sku, "total_price": round(line_total, 2), "available_stock": get_available_stock(variant_id, db), }) tax = round(subtotal * 0.18, 2) shipping = 0.0 if subtotal > 99 else 15.0 return { "items": enriched, "subtotal": round(subtotal, 2), "tax": tax, "shipping": shipping, "total_amount": round(subtotal + tax + shipping, 2), } def _upsert_item(items: List[dict], variant_id: str, qty: int, unit_price: float) -> List[dict]: found = False for item in items: if item.get("variant_id") == variant_id: item["qty"] = int(item.get("qty", 0)) + qty item["unit_price"] = unit_price found = True break if not found: items.append({"variant_id": variant_id, "qty": qty, "unit_price": unit_price}) return [i for i in items if int(i.get("qty", 0)) > 0] @router.get("") def get_cart( visitor_id: Optional[str] = None, customer: EcomCustomer = Depends(get_current_customer), db: Session = Depends(get_db), ): """Return the authenticated customer's cart with server-side prices.""" cart = db.query(Cart).filter(Cart.customer_id == customer.customer_id).first() if not cart: return { "cart_id": None, "items": [], "subtotal": 0.0, "tax": 0.0, "shipping": 0.0, "total_amount": 0.0, } totals = _enrich_and_totals(db, _parse_items(cart.items_json)) return { "cart_id": cart.cart_id, **totals, } @router.post("/items") def add_to_cart( payload: AddToCartRequest, customer: EcomCustomer = Depends(get_current_customer), db: Session = Depends(get_db), ): """Add or increment a variant in the customer cart.""" if payload.qty <= 0: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Quantity must be positive") variant = db.query(ProductVariant).filter(ProductVariant.variant_id == payload.variant_id).first() if not variant: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Variant not found") cart = _get_or_create_customer_cart(db, customer.customer_id) items = _parse_items(cart.items_json) current_qty = sum(int(i.get("qty", 0)) for i in items if i.get("variant_id") == payload.variant_id) _ensure_stock(db, payload.variant_id, current_qty + payload.qty, variant.sku) items = _upsert_item(items, payload.variant_id, payload.qty, float(variant.price)) cart.items_json = items db.commit() db.refresh(cart) totals = _enrich_and_totals(db, items) return {"message": "Item added to cart", "cart_id": cart.cart_id, **totals} @router.put("/items") def set_cart_items( payload: SetCartItemsRequest, customer: EcomCustomer = Depends(get_current_customer), db: Session = Depends(get_db), ): """ Replace the entire customer cart with the provided items. Used by storefront checkout to sync Zustand cart before order creation. """ cart = _get_or_create_customer_cart(db, customer.customer_id) merged: dict = {} for entry in payload.items: if entry.qty <= 0: continue variant = db.query(ProductVariant).filter(ProductVariant.variant_id == entry.variant_id).first() if not variant: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Variant {entry.variant_id} not found", ) if entry.variant_id in merged: merged[entry.variant_id]["qty"] += entry.qty else: merged[entry.variant_id] = { "variant_id": entry.variant_id, "qty": entry.qty, "unit_price": float(variant.price), "sku": variant.sku, } items = [] for row in merged.values(): _ensure_stock(db, row["variant_id"], row["qty"], row.get("sku")) items.append({ "variant_id": row["variant_id"], "qty": row["qty"], "unit_price": row["unit_price"], }) cart.items_json = items if items else None db.commit() db.refresh(cart) totals = _enrich_and_totals(db, items) return {"message": "Cart synchronized", "cart_id": cart.cart_id, **totals} @router.delete("/items/{variant_id}") def remove_cart_item( variant_id: str, customer: EcomCustomer = Depends(get_current_customer), db: Session = Depends(get_db), ): cart = db.query(Cart).filter(Cart.customer_id == customer.customer_id).first() if not cart: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cart not found") items = [i for i in _parse_items(cart.items_json) if i.get("variant_id") != variant_id] cart.items_json = items if items else None db.commit() return {"message": "Item removed", "cart_id": cart.cart_id} @router.delete("") def clear_cart( customer: EcomCustomer = Depends(get_current_customer), db: Session = Depends(get_db), ): cart = db.query(Cart).filter(Cart.customer_id == customer.customer_id).first() if cart: cart.items_json = None db.commit() return {"message": "Cart cleared"} @router.post("/merge") def merge_guest_cart( payload: MergeGuestCartRequest, customer: EcomCustomer = Depends(get_current_customer), db: Session = Depends(get_db), ): """Merge a guest visitor cart into the authenticated customer cart.""" from app.api.v1.routers.CustomerAuthRouter import perform_guest_cart_merge perform_guest_cart_merge(db, customer.customer_id, payload.visitor_id) cart = db.query(Cart).filter(Cart.customer_id == customer.customer_id).first() items = _parse_items(cart.items_json) if cart else [] totals = _enrich_and_totals(db, items) return { "message": "Guest cart merged successfully", "cart_id": cart.cart_id if cart else None, **totals, }