273 lines
9 KiB
Python
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,
|
|
}
|