ifixkart-backend/Backend/app/api/v1/routers/CartRouter.py

273 lines
9 KiB
Python

"""
@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,
}