From 08455554139a3b8cf08d23d92891f2d7a17057b1 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Thu, 29 Jan 2026 22:53:35 +0100 Subject: [PATCH] feat(modules): create cart, catalog, and checkout e-commerce modules Phase 3 of storefront restructure plan - create dedicated modules for e-commerce functionality: - cart: Shopping cart management with storefront API routes - CartItem model with cents-based pricing - CartService for cart operations - Storefront routes for cart CRUD operations - catalog: Product catalog browsing for customers - CatalogService for public product queries - Storefront routes for product listing/search/details - checkout: Order creation from cart (placeholder) - CheckoutService stub for future cart-to-order conversion - Schemas for checkout flow These modules separate e-commerce concerns from core platform concerns (customer auth), enabling non-commerce platforms. Co-Authored-By: Claude Opus 4.5 --- app/modules/cart/__init__.py | 11 + app/modules/cart/definition.py | 25 + app/modules/cart/models/__init__.py | 6 + app/modules/cart/models/cart.py | 78 +++ app/modules/cart/routes/__init__.py | 2 + app/modules/cart/routes/api/__init__.py | 9 + app/modules/cart/routes/api/storefront.py | 238 +++++++++ app/modules/cart/schemas/__init__.py | 20 + app/modules/cart/schemas/cart.py | 91 ++++ app/modules/cart/services/__init__.py | 6 + app/modules/cart/services/cart_service.py | 453 ++++++++++++++++++ app/modules/catalog/__init__.py | 9 + app/modules/catalog/definition.py | 17 + app/modules/catalog/models/__init__.py | 7 + app/modules/catalog/routes/__init__.py | 2 + app/modules/catalog/routes/api/__init__.py | 9 + app/modules/catalog/routes/api/storefront.py | 170 +++++++ app/modules/catalog/schemas/__init__.py | 14 + app/modules/catalog/schemas/catalog.py | 56 +++ app/modules/catalog/services/__init__.py | 6 + .../catalog/services/catalog_service.py | 183 +++++++ app/modules/checkout/__init__.py | 13 + app/modules/checkout/definition.py | 17 + app/modules/checkout/models/__init__.py | 11 + app/modules/checkout/routes/__init__.py | 2 + app/modules/checkout/routes/api/__init__.py | 9 + app/modules/checkout/routes/api/storefront.py | 99 ++++ app/modules/checkout/schemas/__init__.py | 14 + app/modules/checkout/schemas/checkout.py | 54 +++ app/modules/checkout/services/__init__.py | 6 + .../checkout/services/checkout_service.py | 108 +++++ .../PLAN_storefront-module-restructure.md | 6 +- 32 files changed, 1748 insertions(+), 3 deletions(-) create mode 100644 app/modules/cart/__init__.py create mode 100644 app/modules/cart/definition.py create mode 100644 app/modules/cart/models/__init__.py create mode 100644 app/modules/cart/models/cart.py create mode 100644 app/modules/cart/routes/__init__.py create mode 100644 app/modules/cart/routes/api/__init__.py create mode 100644 app/modules/cart/routes/api/storefront.py create mode 100644 app/modules/cart/schemas/__init__.py create mode 100644 app/modules/cart/schemas/cart.py create mode 100644 app/modules/cart/services/__init__.py create mode 100644 app/modules/cart/services/cart_service.py create mode 100644 app/modules/catalog/__init__.py create mode 100644 app/modules/catalog/definition.py create mode 100644 app/modules/catalog/models/__init__.py create mode 100644 app/modules/catalog/routes/__init__.py create mode 100644 app/modules/catalog/routes/api/__init__.py create mode 100644 app/modules/catalog/routes/api/storefront.py create mode 100644 app/modules/catalog/schemas/__init__.py create mode 100644 app/modules/catalog/schemas/catalog.py create mode 100644 app/modules/catalog/services/__init__.py create mode 100644 app/modules/catalog/services/catalog_service.py create mode 100644 app/modules/checkout/__init__.py create mode 100644 app/modules/checkout/definition.py create mode 100644 app/modules/checkout/models/__init__.py create mode 100644 app/modules/checkout/routes/__init__.py create mode 100644 app/modules/checkout/routes/api/__init__.py create mode 100644 app/modules/checkout/routes/api/storefront.py create mode 100644 app/modules/checkout/schemas/__init__.py create mode 100644 app/modules/checkout/schemas/checkout.py create mode 100644 app/modules/checkout/services/__init__.py create mode 100644 app/modules/checkout/services/checkout_service.py diff --git a/app/modules/cart/__init__.py b/app/modules/cart/__init__.py new file mode 100644 index 00000000..c23471a4 --- /dev/null +++ b/app/modules/cart/__init__.py @@ -0,0 +1,11 @@ +# app/modules/cart/__init__.py +""" +Cart Module + +Provides shopping cart functionality for storefronts: +- Session-based cart management +- Cart item operations (add, update, remove) +- Cart total calculations + +All monetary calculations use integer cents internally for precision. +""" diff --git a/app/modules/cart/definition.py b/app/modules/cart/definition.py new file mode 100644 index 00000000..1bec395e --- /dev/null +++ b/app/modules/cart/definition.py @@ -0,0 +1,25 @@ +# app/modules/cart/definition.py +""" +Cart module definition. + +This module provides shopping cart functionality for customer storefronts. +It is session-based and does not require customer authentication. +""" + +from app.modules.core.module_registry import ModuleDefinition + +module = ModuleDefinition( + code="cart", + name="Shopping Cart", + description="Session-based shopping cart for storefronts", + version="1.0.0", + is_self_contained=True, + dependencies=["inventory"], # Checks inventory availability + provides_models=True, + provides_schemas=True, + provides_services=True, + provides_api_routes=True, + provides_page_routes=False, + provides_admin_ui=False, + provides_vendor_ui=False, +) diff --git a/app/modules/cart/models/__init__.py b/app/modules/cart/models/__init__.py new file mode 100644 index 00000000..6f63567b --- /dev/null +++ b/app/modules/cart/models/__init__.py @@ -0,0 +1,6 @@ +# app/modules/cart/models/__init__.py +"""Cart module database models.""" + +from app.modules.cart.models.cart import CartItem + +__all__ = ["CartItem"] diff --git a/app/modules/cart/models/cart.py b/app/modules/cart/models/cart.py new file mode 100644 index 00000000..c2e7b2e6 --- /dev/null +++ b/app/modules/cart/models/cart.py @@ -0,0 +1,78 @@ +# app/modules/cart/models/cart.py +"""Cart item database model. + +Money values are stored as integer cents (e.g., €105.91 = 10591). +See docs/architecture/money-handling.md for details. +""" + +from sqlalchemy import ( + Column, + ForeignKey, + Index, + Integer, + String, + UniqueConstraint, +) +from sqlalchemy.orm import relationship + +from app.core.database import Base +from app.utils.money import cents_to_euros, euros_to_cents +from models.database.base import TimestampMixin + + +class CartItem(Base, TimestampMixin): + """ + Shopping cart items. + + Stores cart items per session, vendor, and product. + Sessions are identified by a session_id string (from browser cookies). + + Price is stored as integer cents for precision. + """ + + __tablename__ = "cart_items" + + id = Column(Integer, primary_key=True, index=True) + vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False) + session_id = Column(String(255), nullable=False, index=True) + + # Cart details + quantity = Column(Integer, nullable=False, default=1) + price_at_add_cents = Column(Integer, nullable=False) # Price in cents when added + + # Relationships + vendor = relationship("Vendor") + product = relationship("Product") + + # Constraints + __table_args__ = ( + UniqueConstraint("vendor_id", "session_id", "product_id", name="uq_cart_item"), + Index("idx_cart_session", "vendor_id", "session_id"), + Index("idx_cart_created", "created_at"), # For cleanup of old carts + ) + + def __repr__(self): + return f"" + + # === PRICE PROPERTIES (Euro convenience accessors) === + + @property + def price_at_add(self) -> float: + """Get price at add in euros.""" + return cents_to_euros(self.price_at_add_cents) + + @price_at_add.setter + def price_at_add(self, value: float): + """Set price at add from euros.""" + self.price_at_add_cents = euros_to_cents(value) + + @property + def line_total_cents(self) -> int: + """Calculate line total in cents.""" + return self.price_at_add_cents * self.quantity + + @property + def line_total(self) -> float: + """Calculate line total in euros.""" + return cents_to_euros(self.line_total_cents) diff --git a/app/modules/cart/routes/__init__.py b/app/modules/cart/routes/__init__.py new file mode 100644 index 00000000..910123dd --- /dev/null +++ b/app/modules/cart/routes/__init__.py @@ -0,0 +1,2 @@ +# app/modules/cart/routes/__init__.py +"""Cart module routes.""" diff --git a/app/modules/cart/routes/api/__init__.py b/app/modules/cart/routes/api/__init__.py new file mode 100644 index 00000000..ce8954ea --- /dev/null +++ b/app/modules/cart/routes/api/__init__.py @@ -0,0 +1,9 @@ +# app/modules/cart/routes/api/__init__.py +"""Cart module API routes.""" + +from app.modules.cart.routes.api.storefront import router as storefront_router + +# Tag for OpenAPI documentation +STOREFRONT_TAG = "Cart (Storefront)" + +__all__ = ["storefront_router", "STOREFRONT_TAG"] diff --git a/app/modules/cart/routes/api/storefront.py b/app/modules/cart/routes/api/storefront.py new file mode 100644 index 00000000..a2f04ead --- /dev/null +++ b/app/modules/cart/routes/api/storefront.py @@ -0,0 +1,238 @@ +# app/modules/cart/routes/api/storefront.py +""" +Cart Module - Storefront API Routes + +Public endpoints for managing shopping cart in storefront. +Uses vendor from middleware context (VendorContextMiddleware). +No authentication required - uses session ID for cart tracking. + +Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain +""" + +import logging + +from fastapi import APIRouter, Body, Depends, Path +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.modules.cart.services import cart_service +from app.modules.cart.schemas import ( + AddToCartRequest, + CartOperationResponse, + CartResponse, + ClearCartResponse, + UpdateCartItemRequest, +) +from middleware.vendor_context import require_vendor_context +from models.database.vendor import Vendor + +router = APIRouter() +logger = logging.getLogger(__name__) + + +# ============================================================================ +# CART ENDPOINTS +# ============================================================================ + + +@router.get("/cart/{session_id}", response_model=CartResponse) # public +def get_cart( + session_id: str = Path(..., description="Shopping session ID"), + vendor: Vendor = Depends(require_vendor_context()), + db: Session = Depends(get_db), +) -> CartResponse: + """ + Get shopping cart contents for current vendor. + + Vendor is automatically determined from request context (URL/subdomain/domain). + No authentication required - uses session ID for cart tracking. + + Path Parameters: + - session_id: Unique session identifier for the cart + """ + logger.info( + f"[CART_STOREFRONT] get_cart for session {session_id}, vendor {vendor.id}", + extra={ + "vendor_id": vendor.id, + "vendor_code": vendor.subdomain, + "session_id": session_id, + }, + ) + + cart = cart_service.get_cart(db=db, vendor_id=vendor.id, session_id=session_id) + + logger.info( + f"[CART_STOREFRONT] get_cart result: {len(cart.get('items', []))} items in cart", + extra={ + "session_id": session_id, + "vendor_id": vendor.id, + "item_count": len(cart.get("items", [])), + "total": cart.get("total", 0), + }, + ) + + return CartResponse.from_service_dict(cart) + + +@router.post("/cart/{session_id}/items", response_model=CartOperationResponse) # public +def add_to_cart( + session_id: str = Path(..., description="Shopping session ID"), + cart_data: AddToCartRequest = Body(...), + vendor: Vendor = Depends(require_vendor_context()), + db: Session = Depends(get_db), +) -> CartOperationResponse: + """ + Add product to cart for current vendor. + + Vendor is automatically determined from request context (URL/subdomain/domain). + No authentication required - uses session ID. + + Path Parameters: + - session_id: Unique session identifier for the cart + + Request Body: + - product_id: ID of product to add + - quantity: Quantity to add (default: 1) + """ + logger.info( + f"[CART_STOREFRONT] add_to_cart: product {cart_data.product_id}, qty {cart_data.quantity}, session {session_id}", + extra={ + "vendor_id": vendor.id, + "vendor_code": vendor.subdomain, + "session_id": session_id, + "product_id": cart_data.product_id, + "quantity": cart_data.quantity, + }, + ) + + result = cart_service.add_to_cart( + db=db, + vendor_id=vendor.id, + session_id=session_id, + product_id=cart_data.product_id, + quantity=cart_data.quantity, + ) + db.commit() + + logger.info( + f"[CART_STOREFRONT] add_to_cart result: {result}", + extra={ + "session_id": session_id, + "result": result, + }, + ) + + return CartOperationResponse(**result) + + +@router.put( + "/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse +) # public +def update_cart_item( + session_id: str = Path(..., description="Shopping session ID"), + product_id: int = Path(..., description="Product ID", gt=0), + cart_data: UpdateCartItemRequest = Body(...), + vendor: Vendor = Depends(require_vendor_context()), + db: Session = Depends(get_db), +) -> CartOperationResponse: + """ + Update cart item quantity for current vendor. + + Vendor is automatically determined from request context (URL/subdomain/domain). + No authentication required - uses session ID. + + Path Parameters: + - session_id: Unique session identifier for the cart + - product_id: ID of product to update + + Request Body: + - quantity: New quantity (must be >= 1) + """ + logger.debug( + f"[CART_STOREFRONT] update_cart_item: product {product_id}, qty {cart_data.quantity}", + extra={ + "vendor_id": vendor.id, + "vendor_code": vendor.subdomain, + "session_id": session_id, + "product_id": product_id, + "quantity": cart_data.quantity, + }, + ) + + result = cart_service.update_cart_item( + db=db, + vendor_id=vendor.id, + session_id=session_id, + product_id=product_id, + quantity=cart_data.quantity, + ) + db.commit() + + return CartOperationResponse(**result) + + +@router.delete( + "/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse +) # public +def remove_from_cart( + session_id: str = Path(..., description="Shopping session ID"), + product_id: int = Path(..., description="Product ID", gt=0), + vendor: Vendor = Depends(require_vendor_context()), + db: Session = Depends(get_db), +) -> CartOperationResponse: + """ + Remove item from cart for current vendor. + + Vendor is automatically determined from request context (URL/subdomain/domain). + No authentication required - uses session ID. + + Path Parameters: + - session_id: Unique session identifier for the cart + - product_id: ID of product to remove + """ + logger.debug( + f"[CART_STOREFRONT] remove_from_cart: product {product_id}", + extra={ + "vendor_id": vendor.id, + "vendor_code": vendor.subdomain, + "session_id": session_id, + "product_id": product_id, + }, + ) + + result = cart_service.remove_from_cart( + db=db, vendor_id=vendor.id, session_id=session_id, product_id=product_id + ) + db.commit() + + return CartOperationResponse(**result) + + +@router.delete("/cart/{session_id}", response_model=ClearCartResponse) # public +def clear_cart( + session_id: str = Path(..., description="Shopping session ID"), + vendor: Vendor = Depends(require_vendor_context()), + db: Session = Depends(get_db), +) -> ClearCartResponse: + """ + Clear all items from cart for current vendor. + + Vendor is automatically determined from request context (URL/subdomain/domain). + No authentication required - uses session ID. + + Path Parameters: + - session_id: Unique session identifier for the cart + """ + logger.debug( + f"[CART_STOREFRONT] clear_cart for session {session_id}", + extra={ + "vendor_id": vendor.id, + "vendor_code": vendor.subdomain, + "session_id": session_id, + }, + ) + + result = cart_service.clear_cart(db=db, vendor_id=vendor.id, session_id=session_id) + db.commit() + + return ClearCartResponse(**result) diff --git a/app/modules/cart/schemas/__init__.py b/app/modules/cart/schemas/__init__.py new file mode 100644 index 00000000..2601c1a6 --- /dev/null +++ b/app/modules/cart/schemas/__init__.py @@ -0,0 +1,20 @@ +# app/modules/cart/schemas/__init__.py +"""Cart module Pydantic schemas.""" + +from app.modules.cart.schemas.cart import ( + AddToCartRequest, + UpdateCartItemRequest, + CartItemResponse, + CartResponse, + CartOperationResponse, + ClearCartResponse, +) + +__all__ = [ + "AddToCartRequest", + "UpdateCartItemRequest", + "CartItemResponse", + "CartResponse", + "CartOperationResponse", + "ClearCartResponse", +] diff --git a/app/modules/cart/schemas/cart.py b/app/modules/cart/schemas/cart.py new file mode 100644 index 00000000..e122214a --- /dev/null +++ b/app/modules/cart/schemas/cart.py @@ -0,0 +1,91 @@ +# app/modules/cart/schemas/cart.py +""" +Pydantic schemas for shopping cart operations. +""" + +from pydantic import BaseModel, ConfigDict, Field + +# ============================================================================ +# Request Schemas +# ============================================================================ + + +class AddToCartRequest(BaseModel): + """Request model for adding items to cart.""" + + product_id: int = Field(..., description="Product ID to add", gt=0) + quantity: int = Field(1, ge=1, description="Quantity to add") + + +class UpdateCartItemRequest(BaseModel): + """Request model for updating cart item quantity.""" + + quantity: int = Field(..., ge=1, description="New quantity (must be >= 1)") + + +# ============================================================================ +# Response Schemas +# ============================================================================ + + +class CartItemResponse(BaseModel): + """Response model for a single cart item.""" + + model_config = ConfigDict(from_attributes=True) + + product_id: int = Field(..., description="Product ID") + product_name: str = Field(..., description="Product name") + quantity: int = Field(..., description="Quantity in cart") + price: float = Field(..., description="Price per unit when added to cart") + line_total: float = Field( + ..., description="Total price for this line (price * quantity)" + ) + image_url: str | None = Field(None, description="Product image URL") + + +class CartResponse(BaseModel): + """Response model for shopping cart.""" + + vendor_id: int = Field(..., description="Vendor ID") + session_id: str = Field(..., description="Shopping session ID") + items: list[CartItemResponse] = Field( + default_factory=list, description="Cart items" + ) + subtotal: float = Field(..., description="Subtotal of all items") + total: float = Field(..., description="Total amount (currently same as subtotal)") + item_count: int = Field(..., description="Total number of items in cart") + + @classmethod + def from_service_dict(cls, cart_dict: dict) -> "CartResponse": + """ + Create CartResponse from service layer dictionary. + + This is a convenience method to convert the dictionary format + returned by cart_service into a proper Pydantic model. + """ + items = [CartItemResponse(**item) for item in cart_dict.get("items", [])] + return cls( + vendor_id=cart_dict["vendor_id"], + session_id=cart_dict["session_id"], + items=items, + subtotal=cart_dict["subtotal"], + total=cart_dict["total"], + item_count=len(items), + ) + + +class CartOperationResponse(BaseModel): + """Response model for cart operations (add, update, remove).""" + + message: str = Field(..., description="Operation result message") + product_id: int = Field(..., description="Product ID affected") + quantity: int | None = Field( + None, description="New quantity (for add/update operations)" + ) + + +class ClearCartResponse(BaseModel): + """Response model for clearing cart.""" + + message: str = Field(..., description="Operation result message") + items_removed: int = Field(..., description="Number of items removed from cart") diff --git a/app/modules/cart/services/__init__.py b/app/modules/cart/services/__init__.py new file mode 100644 index 00000000..c9f3be33 --- /dev/null +++ b/app/modules/cart/services/__init__.py @@ -0,0 +1,6 @@ +# app/modules/cart/services/__init__.py +"""Cart module services.""" + +from app.modules.cart.services.cart_service import cart_service, CartService + +__all__ = ["cart_service", "CartService"] diff --git a/app/modules/cart/services/cart_service.py b/app/modules/cart/services/cart_service.py new file mode 100644 index 00000000..2a2ce284 --- /dev/null +++ b/app/modules/cart/services/cart_service.py @@ -0,0 +1,453 @@ +# app/modules/cart/services/cart_service.py +""" +Shopping cart service. + +This module provides: +- Session-based cart management +- Cart item operations (add, update, remove) +- Cart total calculations + +All monetary calculations use integer cents internally for precision. +See docs/architecture/money-handling.md for details. +""" + +import logging + +from sqlalchemy import and_ +from sqlalchemy.orm import Session + +from app.exceptions import ( + CartItemNotFoundException, + InsufficientInventoryForCartException, + InvalidCartQuantityException, + ProductNotFoundException, +) +from app.utils.money import cents_to_euros +from app.modules.cart.models.cart import CartItem +from models.database.product import Product + +logger = logging.getLogger(__name__) + + +class CartService: + """Service for managing shopping carts.""" + + def get_cart(self, db: Session, vendor_id: int, session_id: str) -> dict: + """ + Get cart contents for a session. + + Args: + db: Database session + vendor_id: Vendor ID + session_id: Session ID + + Returns: + Cart data with items and totals + """ + logger.info( + "[CART_SERVICE] get_cart called", + extra={ + "vendor_id": vendor_id, + "session_id": session_id, + }, + ) + + # Fetch cart items from database + cart_items = ( + db.query(CartItem) + .filter( + and_(CartItem.vendor_id == vendor_id, CartItem.session_id == session_id) + ) + .all() + ) + + logger.info( + f"[CART_SERVICE] Found {len(cart_items)} items in database", + extra={"item_count": len(cart_items)}, + ) + + # Build response - calculate totals in cents, return euros + items = [] + subtotal_cents = 0 + + for cart_item in cart_items: + product = cart_item.product + line_total_cents = cart_item.line_total_cents + + items.append( + { + "product_id": product.id, + "product_name": product.marketplace_product.get_title("en") + if product.marketplace_product + else str(product.id), + "quantity": cart_item.quantity, + "price": cart_item.price_at_add, # Returns euros via property + "line_total": cents_to_euros(line_total_cents), + "image_url": ( + product.marketplace_product.image_link + if product.marketplace_product + else None + ), + } + ) + + subtotal_cents += line_total_cents + + # Convert to euros for API response + subtotal = cents_to_euros(subtotal_cents) + cart_data = { + "vendor_id": vendor_id, + "session_id": session_id, + "items": items, + "subtotal": subtotal, + "total": subtotal, # Could add tax/shipping later + } + + logger.info( + f"[CART_SERVICE] get_cart returning: {len(cart_data['items'])} items, total: {cart_data['total']}", + extra={"cart": cart_data}, + ) + + return cart_data + + def add_to_cart( + self, + db: Session, + vendor_id: int, + session_id: str, + product_id: int, + quantity: int = 1, + ) -> dict: + """ + Add product to cart. + + Args: + db: Database session + vendor_id: Vendor ID + session_id: Session ID + product_id: Product ID + quantity: Quantity to add + + Returns: + Updated cart + + Raises: + ProductNotFoundException: If product not found + InsufficientInventoryException: If not enough inventory + """ + logger.info( + "[CART_SERVICE] add_to_cart called", + extra={ + "vendor_id": vendor_id, + "session_id": session_id, + "product_id": product_id, + "quantity": quantity, + }, + ) + + # Verify product exists and belongs to vendor + product = ( + db.query(Product) + .filter( + and_( + Product.id == product_id, + Product.vendor_id == vendor_id, + Product.is_active == True, + ) + ) + .first() + ) + + if not product: + logger.error( + "[CART_SERVICE] Product not found", + extra={"product_id": product_id, "vendor_id": vendor_id}, + ) + raise ProductNotFoundException(product_id=product_id, vendor_id=vendor_id) + + logger.info( + f"[CART_SERVICE] Product found: {product.marketplace_product.title}", + extra={ + "product_id": product_id, + "product_name": product.marketplace_product.title, + "available_inventory": product.available_inventory, + }, + ) + + # Get current price in cents (use sale_price if available, otherwise regular price) + current_price_cents = ( + product.sale_price_cents + or product.price_cents + or 0 + ) + + # Check if item already exists in cart + existing_item = ( + db.query(CartItem) + .filter( + and_( + CartItem.vendor_id == vendor_id, + CartItem.session_id == session_id, + CartItem.product_id == product_id, + ) + ) + .first() + ) + + if existing_item: + # Update quantity + new_quantity = existing_item.quantity + quantity + + # Check inventory for new total quantity + if product.available_inventory < new_quantity: + logger.warning( + "[CART_SERVICE] Insufficient inventory for update", + extra={ + "product_id": product_id, + "current_in_cart": existing_item.quantity, + "adding": quantity, + "requested_total": new_quantity, + "available": product.available_inventory, + }, + ) + raise InsufficientInventoryForCartException( + product_id=product_id, + product_name=product.marketplace_product.title, + requested=new_quantity, + available=product.available_inventory, + ) + + existing_item.quantity = new_quantity + db.flush() + db.refresh(existing_item) + + logger.info( + "[CART_SERVICE] Updated existing cart item", + extra={"cart_item_id": existing_item.id, "new_quantity": new_quantity}, + ) + + return { + "message": "Product quantity updated in cart", + "product_id": product_id, + "quantity": new_quantity, + } + # Check inventory for new item + if product.available_inventory < quantity: + logger.warning( + "[CART_SERVICE] Insufficient inventory", + extra={ + "product_id": product_id, + "requested": quantity, + "available": product.available_inventory, + }, + ) + raise InsufficientInventoryForCartException( + product_id=product_id, + product_name=product.marketplace_product.title, + requested=quantity, + available=product.available_inventory, + ) + + # Create new cart item (price stored in cents) + cart_item = CartItem( + vendor_id=vendor_id, + session_id=session_id, + product_id=product_id, + quantity=quantity, + price_at_add_cents=current_price_cents, + ) + db.add(cart_item) + db.flush() + db.refresh(cart_item) + + logger.info( + "[CART_SERVICE] Created new cart item", + extra={ + "cart_item_id": cart_item.id, + "quantity": quantity, + "price_cents": current_price_cents, + }, + ) + + return { + "message": "Product added to cart", + "product_id": product_id, + "quantity": quantity, + } + + def update_cart_item( + self, + db: Session, + vendor_id: int, + session_id: str, + product_id: int, + quantity: int, + ) -> dict: + """ + Update quantity of item in cart. + + Args: + db: Database session + vendor_id: Vendor ID + session_id: Session ID + product_id: Product ID + quantity: New quantity (must be >= 1) + + Returns: + Success message + + Raises: + ValidationException: If quantity < 1 + ProductNotFoundException: If product not found + InsufficientInventoryException: If not enough inventory + """ + if quantity < 1: + raise InvalidCartQuantityException(quantity=quantity, min_quantity=1) + + # Find cart item + cart_item = ( + db.query(CartItem) + .filter( + and_( + CartItem.vendor_id == vendor_id, + CartItem.session_id == session_id, + CartItem.product_id == product_id, + ) + ) + .first() + ) + + if not cart_item: + raise CartItemNotFoundException( + product_id=product_id, session_id=session_id + ) + + # Verify product still exists and is active + product = ( + db.query(Product) + .filter( + and_( + Product.id == product_id, + Product.vendor_id == vendor_id, + Product.is_active == True, + ) + ) + .first() + ) + + if not product: + raise ProductNotFoundException(str(product_id)) + + # Check inventory + if product.available_inventory < quantity: + raise InsufficientInventoryForCartException( + product_id=product_id, + product_name=product.marketplace_product.title, + requested=quantity, + available=product.available_inventory, + ) + + # Update quantity + cart_item.quantity = quantity + db.flush() + db.refresh(cart_item) + + logger.info( + "[CART_SERVICE] Updated cart item quantity", + extra={ + "cart_item_id": cart_item.id, + "product_id": product_id, + "new_quantity": quantity, + }, + ) + + return { + "message": "Cart updated", + "product_id": product_id, + "quantity": quantity, + } + + def remove_from_cart( + self, db: Session, vendor_id: int, session_id: str, product_id: int + ) -> dict: + """ + Remove item from cart. + + Args: + db: Database session + vendor_id: Vendor ID + session_id: Session ID + product_id: Product ID + + Returns: + Success message + + Raises: + ProductNotFoundException: If product not in cart + """ + # Find and delete cart item + cart_item = ( + db.query(CartItem) + .filter( + and_( + CartItem.vendor_id == vendor_id, + CartItem.session_id == session_id, + CartItem.product_id == product_id, + ) + ) + .first() + ) + + if not cart_item: + raise CartItemNotFoundException( + product_id=product_id, session_id=session_id + ) + + db.delete(cart_item) + + logger.info( + "[CART_SERVICE] Removed item from cart", + extra={ + "cart_item_id": cart_item.id, + "product_id": product_id, + "session_id": session_id, + }, + ) + + return {"message": "Item removed from cart", "product_id": product_id} + + def clear_cart(self, db: Session, vendor_id: int, session_id: str) -> dict: + """ + Clear all items from cart. + + Args: + db: Database session + vendor_id: Vendor ID + session_id: Session ID + + Returns: + Success message with count of items removed + """ + # Delete all cart items for this session + deleted_count = ( + db.query(CartItem) + .filter( + and_(CartItem.vendor_id == vendor_id, CartItem.session_id == session_id) + ) + .delete() + ) + + logger.info( + "[CART_SERVICE] Cleared cart", + extra={ + "session_id": session_id, + "vendor_id": vendor_id, + "items_removed": deleted_count, + }, + ) + + return {"message": "Cart cleared", "items_removed": deleted_count} + + +# Create service instance +cart_service = CartService() diff --git a/app/modules/catalog/__init__.py b/app/modules/catalog/__init__.py new file mode 100644 index 00000000..38ab244e --- /dev/null +++ b/app/modules/catalog/__init__.py @@ -0,0 +1,9 @@ +# app/modules/catalog/__init__.py +""" +Catalog Module + +Provides product catalog functionality for storefronts, including: +- Product browsing and listing +- Product search +- Product details +""" diff --git a/app/modules/catalog/definition.py b/app/modules/catalog/definition.py new file mode 100644 index 00000000..f54ff18d --- /dev/null +++ b/app/modules/catalog/definition.py @@ -0,0 +1,17 @@ +# app/modules/catalog/definition.py +"""Catalog module definition.""" + +from app.modules.base import ModuleDefinition + +module = ModuleDefinition( + code="catalog", + name="Product Catalog", + description="Product catalog browsing and search for storefronts", + version="1.0.0", + is_self_contained=True, + dependencies=["inventory"], + provides_models=False, # Uses Product model from products module + provides_schemas=True, + provides_services=True, + provides_api_routes=True, +) diff --git a/app/modules/catalog/models/__init__.py b/app/modules/catalog/models/__init__.py new file mode 100644 index 00000000..39f52d6c --- /dev/null +++ b/app/modules/catalog/models/__init__.py @@ -0,0 +1,7 @@ +# app/modules/catalog/models/__init__.py +""" +Catalog module models. + +Note: The catalog module uses the Product model from the products module. +This file exists for consistency with the module structure. +""" diff --git a/app/modules/catalog/routes/__init__.py b/app/modules/catalog/routes/__init__.py new file mode 100644 index 00000000..7b5058cc --- /dev/null +++ b/app/modules/catalog/routes/__init__.py @@ -0,0 +1,2 @@ +# app/modules/catalog/routes/__init__.py +"""Catalog module routes.""" diff --git a/app/modules/catalog/routes/api/__init__.py b/app/modules/catalog/routes/api/__init__.py new file mode 100644 index 00000000..115cd87f --- /dev/null +++ b/app/modules/catalog/routes/api/__init__.py @@ -0,0 +1,9 @@ +# app/modules/catalog/routes/api/__init__.py +"""Catalog module API routes.""" + +from app.modules.catalog.routes.api.storefront import router as storefront_router + +# Tag for OpenAPI documentation +STOREFRONT_TAG = "Catalog (Storefront)" + +__all__ = ["storefront_router", "STOREFRONT_TAG"] diff --git a/app/modules/catalog/routes/api/storefront.py b/app/modules/catalog/routes/api/storefront.py new file mode 100644 index 00000000..cb72b00f --- /dev/null +++ b/app/modules/catalog/routes/api/storefront.py @@ -0,0 +1,170 @@ +# app/modules/catalog/routes/api/storefront.py +""" +Catalog Module - Storefront API Routes + +Public endpoints for browsing product catalog in storefront. +Uses vendor from middleware context (VendorContextMiddleware). +No authentication required. + +Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain +""" + +import logging + +from fastapi import APIRouter, Depends, Path, Query, Request +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.modules.catalog.services import catalog_service +from app.modules.catalog.schemas import ( + ProductDetailResponse, + ProductListResponse, + ProductResponse, +) +from middleware.vendor_context import require_vendor_context +from models.database.vendor import Vendor + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.get("/products", response_model=ProductListResponse) # public +def get_product_catalog( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=1000), + search: str | None = Query(None, description="Search products by name"), + is_featured: bool | None = Query(None, description="Filter by featured products"), + vendor: Vendor = Depends(require_vendor_context()), + db: Session = Depends(get_db), +): + """ + Get product catalog for current vendor. + + Vendor is automatically determined from request context (URL/subdomain/domain). + Only returns active products visible to customers. + No authentication required. + + Query Parameters: + - skip: Number of products to skip (pagination) + - limit: Maximum number of products to return + - search: Search query for product name/description + - is_featured: Filter by featured products only + """ + logger.debug( + f"[CATALOG_STOREFRONT] get_product_catalog for vendor: {vendor.subdomain}", + extra={ + "vendor_id": vendor.id, + "vendor_code": vendor.subdomain, + "skip": skip, + "limit": limit, + "search": search, + "is_featured": is_featured, + }, + ) + + # Get only active products for public view + products, total = catalog_service.get_catalog_products( + db=db, + vendor_id=vendor.id, + skip=skip, + limit=limit, + is_featured=is_featured, + ) + + return ProductListResponse( + products=[ProductResponse.model_validate(p) for p in products], + total=total, + skip=skip, + limit=limit, + ) + + +@router.get("/products/{product_id}", response_model=ProductDetailResponse) # public +def get_product_details( + product_id: int = Path(..., description="Product ID", gt=0), + vendor: Vendor = Depends(require_vendor_context()), + db: Session = Depends(get_db), +): + """ + Get detailed product information for customers. + + Vendor is automatically determined from request context (URL/subdomain/domain). + No authentication required. + + Path Parameters: + - product_id: ID of the product to retrieve + """ + logger.debug( + f"[CATALOG_STOREFRONT] get_product_details for product {product_id}", + extra={ + "vendor_id": vendor.id, + "vendor_code": vendor.subdomain, + "product_id": product_id, + }, + ) + + product = catalog_service.get_product( + db=db, vendor_id=vendor.id, product_id=product_id + ) + + # Check if product is active + if not product.is_active: + from app.exceptions import ProductNotActiveException + + raise ProductNotActiveException(str(product_id)) + + return ProductDetailResponse.model_validate(product) + + +@router.get("/products/search", response_model=ProductListResponse) # public +def search_products( + request: Request, + q: str = Query(..., min_length=1, description="Search query"), + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + vendor: Vendor = Depends(require_vendor_context()), + db: Session = Depends(get_db), +): + """ + Search products in current vendor's catalog. + + Searches in product names, descriptions, SKUs, brands, and GTINs. + Vendor is automatically determined from request context (URL/subdomain/domain). + No authentication required. + + Query Parameters: + - q: Search query string (minimum 1 character) + - skip: Number of results to skip (pagination) + - limit: Maximum number of results to return + """ + # Get preferred language from request (via middleware or default) + language = getattr(request.state, "language", "en") + + logger.debug( + f"[CATALOG_STOREFRONT] search_products: '{q}'", + extra={ + "vendor_id": vendor.id, + "vendor_code": vendor.subdomain, + "query": q, + "skip": skip, + "limit": limit, + "language": language, + }, + ) + + # Search products using the service + products, total = catalog_service.search_products( + db=db, + vendor_id=vendor.id, + query=q, + skip=skip, + limit=limit, + language=language, + ) + + return ProductListResponse( + products=[ProductResponse.model_validate(p) for p in products], + total=total, + skip=skip, + limit=limit, + ) diff --git a/app/modules/catalog/schemas/__init__.py b/app/modules/catalog/schemas/__init__.py new file mode 100644 index 00000000..e2090fcd --- /dev/null +++ b/app/modules/catalog/schemas/__init__.py @@ -0,0 +1,14 @@ +# app/modules/catalog/schemas/__init__.py +"""Catalog module schemas.""" + +from app.modules.catalog.schemas.catalog import ( + ProductDetailResponse, + ProductListResponse, + ProductResponse, +) + +__all__ = [ + "ProductResponse", + "ProductDetailResponse", + "ProductListResponse", +] diff --git a/app/modules/catalog/schemas/catalog.py b/app/modules/catalog/schemas/catalog.py new file mode 100644 index 00000000..f5febe9f --- /dev/null +++ b/app/modules/catalog/schemas/catalog.py @@ -0,0 +1,56 @@ +# app/modules/catalog/schemas/catalog.py +""" +Pydantic schemas for catalog browsing operations. + +These schemas are for the public storefront catalog API. +For vendor product management, see the products module. +""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +from models.schema.inventory import InventoryLocationResponse +from models.schema.marketplace_product import MarketplaceProductResponse + + +class ProductResponse(BaseModel): + """Response model for a product in the catalog.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + vendor_id: int + marketplace_product: MarketplaceProductResponse + vendor_sku: str | None + price: float | None + sale_price: float | None + currency: str | None + availability: str | None + condition: str | None + is_featured: bool + is_active: bool + display_order: int + min_quantity: int + max_quantity: int | None + created_at: datetime + updated_at: datetime + + # Include inventory summary + total_inventory: int | None = None + available_inventory: int | None = None + + +class ProductDetailResponse(ProductResponse): + """Product with full inventory details.""" + + inventory_locations: list[InventoryLocationResponse] = [] + + +class ProductListResponse(BaseModel): + """Paginated list of products.""" + + products: list[ProductResponse] + total: int + skip: int + limit: int diff --git a/app/modules/catalog/services/__init__.py b/app/modules/catalog/services/__init__.py new file mode 100644 index 00000000..5a220728 --- /dev/null +++ b/app/modules/catalog/services/__init__.py @@ -0,0 +1,6 @@ +# app/modules/catalog/services/__init__.py +"""Catalog module services.""" + +from app.modules.catalog.services.catalog_service import catalog_service + +__all__ = ["catalog_service"] diff --git a/app/modules/catalog/services/catalog_service.py b/app/modules/catalog/services/catalog_service.py new file mode 100644 index 00000000..4241eca9 --- /dev/null +++ b/app/modules/catalog/services/catalog_service.py @@ -0,0 +1,183 @@ +# app/modules/catalog/services/catalog_service.py +""" +Catalog service for storefront product browsing. + +This module provides: +- Public product catalog retrieval +- Product search functionality +- Product detail retrieval + +Note: This is distinct from the product_service which handles +vendor product management. The catalog service is for public +storefront operations only. +""" + +import logging + +from sqlalchemy import or_ +from sqlalchemy.orm import Session, joinedload + +from app.exceptions import ProductNotFoundException, ValidationException +from models.database.product import Product +from models.database.product_translation import ProductTranslation + +logger = logging.getLogger(__name__) + + +class CatalogService: + """Service for public catalog browsing operations.""" + + def get_product(self, db: Session, vendor_id: int, product_id: int) -> Product: + """ + Get a product from vendor catalog. + + Args: + db: Database session + vendor_id: Vendor ID + product_id: Product ID + + Returns: + Product object + + Raises: + ProductNotFoundException: If product not found + """ + product = ( + db.query(Product) + .filter(Product.id == product_id, Product.vendor_id == vendor_id) + .first() + ) + + if not product: + raise ProductNotFoundException(f"Product {product_id} not found") + + return product + + def get_catalog_products( + self, + db: Session, + vendor_id: int, + skip: int = 0, + limit: int = 100, + is_featured: bool | None = None, + ) -> tuple[list[Product], int]: + """ + Get products in vendor catalog for public display. + + Only returns active products visible to customers. + + Args: + db: Database session + vendor_id: Vendor ID + skip: Pagination offset + limit: Pagination limit + is_featured: Filter by featured status + + Returns: + Tuple of (products, total_count) + """ + try: + # Always filter for active products only + query = db.query(Product).filter( + Product.vendor_id == vendor_id, + Product.is_active == True, + ) + + if is_featured is not None: + query = query.filter(Product.is_featured == is_featured) + + total = query.count() + products = query.offset(skip).limit(limit).all() + + return products, total + + except Exception as e: + logger.error(f"Error getting catalog products: {str(e)}") + raise ValidationException("Failed to retrieve products") + + def search_products( + self, + db: Session, + vendor_id: int, + query: str, + skip: int = 0, + limit: int = 50, + language: str = "en", + ) -> tuple[list[Product], int]: + """ + Search products in vendor catalog. + + Searches across: + - Product title and description (from translations) + - Product SKU, brand, and GTIN + + Args: + db: Database session + vendor_id: Vendor ID + query: Search query string + skip: Pagination offset + limit: Pagination limit + language: Language for translation search (default: 'en') + + Returns: + Tuple of (products, total_count) + """ + try: + # Prepare search pattern for LIKE queries + search_pattern = f"%{query}%" + + # Use subquery to get distinct IDs (PostgreSQL can't compare JSON for DISTINCT) + id_subquery = ( + db.query(Product.id) + .outerjoin( + ProductTranslation, + (Product.id == ProductTranslation.product_id) + & (ProductTranslation.language == language), + ) + .filter( + Product.vendor_id == vendor_id, + Product.is_active == True, + ) + .filter( + or_( + # Search in translations + ProductTranslation.title.ilike(search_pattern), + ProductTranslation.description.ilike(search_pattern), + ProductTranslation.short_description.ilike(search_pattern), + # Search in product fields + Product.vendor_sku.ilike(search_pattern), + Product.brand.ilike(search_pattern), + Product.gtin.ilike(search_pattern), + ) + ) + .distinct() + .subquery() + ) + + base_query = db.query(Product).filter( + Product.id.in_(db.query(id_subquery.c.id)) + ) + + # Get total count + total = base_query.count() + + # Get paginated results with eager loading for performance + products = ( + base_query.options(joinedload(Product.translations)) + .offset(skip) + .limit(limit) + .all() + ) + + logger.debug( + f"Search '{query}' for vendor {vendor_id}: {total} results" + ) + return products, total + + except Exception as e: + logger.error(f"Error searching products: {str(e)}") + raise ValidationException("Failed to search products") + + +# Create service instance +catalog_service = CatalogService() diff --git a/app/modules/checkout/__init__.py b/app/modules/checkout/__init__.py new file mode 100644 index 00000000..d3e259c1 --- /dev/null +++ b/app/modules/checkout/__init__.py @@ -0,0 +1,13 @@ +# app/modules/checkout/__init__.py +""" +Checkout Module + +Provides checkout and order creation functionality for storefronts, including: +- Cart to order conversion +- Shipping address handling +- Payment method selection +- Order confirmation + +Note: This module is a placeholder for future checkout functionality. +Currently, order creation is handled directly through the orders module. +""" diff --git a/app/modules/checkout/definition.py b/app/modules/checkout/definition.py new file mode 100644 index 00000000..c44d78cb --- /dev/null +++ b/app/modules/checkout/definition.py @@ -0,0 +1,17 @@ +# app/modules/checkout/definition.py +"""Checkout module definition.""" + +from app.modules.base import ModuleDefinition + +module = ModuleDefinition( + code="checkout", + name="Checkout", + description="Checkout and order creation for storefronts", + version="1.0.0", + is_self_contained=True, + dependencies=["cart", "orders", "payments", "customers"], + provides_models=False, # Uses Order model from orders module + provides_schemas=True, + provides_services=True, + provides_api_routes=True, +) diff --git a/app/modules/checkout/models/__init__.py b/app/modules/checkout/models/__init__.py new file mode 100644 index 00000000..7abd2d39 --- /dev/null +++ b/app/modules/checkout/models/__init__.py @@ -0,0 +1,11 @@ +# app/modules/checkout/models/__init__.py +""" +Checkout module models. + +Note: The checkout module uses models from other modules: +- Cart model from cart module +- Order model from orders module +- Customer model from customers module + +This file exists for consistency with the module structure. +""" diff --git a/app/modules/checkout/routes/__init__.py b/app/modules/checkout/routes/__init__.py new file mode 100644 index 00000000..7cabf8ae --- /dev/null +++ b/app/modules/checkout/routes/__init__.py @@ -0,0 +1,2 @@ +# app/modules/checkout/routes/__init__.py +"""Checkout module routes.""" diff --git a/app/modules/checkout/routes/api/__init__.py b/app/modules/checkout/routes/api/__init__.py new file mode 100644 index 00000000..33bae2ce --- /dev/null +++ b/app/modules/checkout/routes/api/__init__.py @@ -0,0 +1,9 @@ +# app/modules/checkout/routes/api/__init__.py +"""Checkout module API routes.""" + +from app.modules.checkout.routes.api.storefront import router as storefront_router + +# Tag for OpenAPI documentation +STOREFRONT_TAG = "Checkout (Storefront)" + +__all__ = ["storefront_router", "STOREFRONT_TAG"] diff --git a/app/modules/checkout/routes/api/storefront.py b/app/modules/checkout/routes/api/storefront.py new file mode 100644 index 00000000..8c352d80 --- /dev/null +++ b/app/modules/checkout/routes/api/storefront.py @@ -0,0 +1,99 @@ +# app/modules/checkout/routes/api/storefront.py +""" +Checkout Module - Storefront API Routes + +Public endpoints for checkout in storefront. +Uses vendor from middleware context (VendorContextMiddleware). + +Note: These endpoints are placeholders for future checkout functionality. + +Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain +""" + +import logging + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.modules.checkout.schemas import ( + CheckoutRequest, + CheckoutResponse, + CheckoutSessionResponse, +) +from app.modules.checkout.services import checkout_service +from middleware.vendor_context import require_vendor_context +from models.database.vendor import Vendor + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.post("/checkout/session", response_model=CheckoutSessionResponse) +def create_checkout_session( + checkout_data: CheckoutRequest, + vendor: Vendor = Depends(require_vendor_context()), + db: Session = Depends(get_db), +) -> CheckoutSessionResponse: + """ + Create a checkout session from cart. + + Validates the cart and prepares for checkout. + Vendor is automatically determined from request context. + + Request Body: + - session_id: Cart session ID + - shipping_address: Shipping address details + - billing_same_as_shipping: Use shipping for billing (default: true) + - billing_address: Billing address if different + - customer_email: Email for order confirmation + - customer_note: Optional note + """ + logger.info( + f"[CHECKOUT_STOREFRONT] create_checkout_session for vendor {vendor.id}", + extra={ + "vendor_id": vendor.id, + "session_id": checkout_data.session_id, + }, + ) + + result = checkout_service.create_checkout_session( + db=db, + vendor_id=vendor.id, + session_id=checkout_data.session_id, + ) + + return CheckoutSessionResponse(**result) + + +@router.post("/checkout/complete", response_model=CheckoutResponse) +def complete_checkout( + checkout_session_id: str, + vendor: Vendor = Depends(require_vendor_context()), + db: Session = Depends(get_db), +) -> CheckoutResponse: + """ + Complete checkout and create order. + + Converts the cart to an order and processes payment. + Vendor is automatically determined from request context. + + Query Parameters: + - checkout_session_id: The checkout session ID from create_checkout_session + """ + logger.info( + f"[CHECKOUT_STOREFRONT] complete_checkout for vendor {vendor.id}", + extra={ + "vendor_id": vendor.id, + "checkout_session_id": checkout_session_id, + }, + ) + + result = checkout_service.complete_checkout( + db=db, + vendor_id=vendor.id, + checkout_session_id=checkout_session_id, + ) + db.commit() + + return CheckoutResponse(**result) diff --git a/app/modules/checkout/schemas/__init__.py b/app/modules/checkout/schemas/__init__.py new file mode 100644 index 00000000..e09e9f5d --- /dev/null +++ b/app/modules/checkout/schemas/__init__.py @@ -0,0 +1,14 @@ +# app/modules/checkout/schemas/__init__.py +"""Checkout module schemas.""" + +from app.modules.checkout.schemas.checkout import ( + CheckoutRequest, + CheckoutResponse, + CheckoutSessionResponse, +) + +__all__ = [ + "CheckoutRequest", + "CheckoutResponse", + "CheckoutSessionResponse", +] diff --git a/app/modules/checkout/schemas/checkout.py b/app/modules/checkout/schemas/checkout.py new file mode 100644 index 00000000..a2926789 --- /dev/null +++ b/app/modules/checkout/schemas/checkout.py @@ -0,0 +1,54 @@ +# app/modules/checkout/schemas/checkout.py +""" +Pydantic schemas for checkout operations. + +These schemas handle the conversion of a shopping cart into an order. +""" + +from pydantic import BaseModel, Field + + +class ShippingAddress(BaseModel): + """Shipping address for checkout.""" + + first_name: str = Field(..., min_length=1, max_length=100) + last_name: str = Field(..., min_length=1, max_length=100) + address_line_1: str = Field(..., min_length=1, max_length=255) + address_line_2: str | None = Field(None, max_length=255) + city: str = Field(..., min_length=1, max_length=100) + postal_code: str = Field(..., min_length=1, max_length=20) + country: str = Field(..., min_length=2, max_length=2) # ISO 3166-1 alpha-2 + phone: str | None = Field(None, max_length=20) + + +class CheckoutRequest(BaseModel): + """Request to initiate checkout.""" + + session_id: str = Field(..., description="Cart session ID") + shipping_address: ShippingAddress = Field(..., description="Shipping address") + billing_same_as_shipping: bool = Field( + True, description="Use shipping address for billing" + ) + billing_address: ShippingAddress | None = Field( + None, description="Billing address (if different from shipping)" + ) + customer_email: str = Field(..., description="Customer email for order confirmation") + customer_note: str | None = Field(None, description="Optional customer note") + + +class CheckoutSessionResponse(BaseModel): + """Response for checkout session creation.""" + + checkout_session_id: str = Field(..., description="Checkout session ID") + cart_total: float = Field(..., description="Cart total in euros") + item_count: int = Field(..., description="Number of items in cart") + requires_payment: bool = Field(..., description="Whether payment is required") + + +class CheckoutResponse(BaseModel): + """Response for completed checkout.""" + + order_id: int = Field(..., description="Created order ID") + order_number: str = Field(..., description="Human-readable order number") + total: float = Field(..., description="Order total in euros") + message: str = Field(..., description="Confirmation message") diff --git a/app/modules/checkout/services/__init__.py b/app/modules/checkout/services/__init__.py new file mode 100644 index 00000000..d292e8cd --- /dev/null +++ b/app/modules/checkout/services/__init__.py @@ -0,0 +1,6 @@ +# app/modules/checkout/services/__init__.py +"""Checkout module services.""" + +from app.modules.checkout.services.checkout_service import checkout_service + +__all__ = ["checkout_service"] diff --git a/app/modules/checkout/services/checkout_service.py b/app/modules/checkout/services/checkout_service.py new file mode 100644 index 00000000..67f0cc53 --- /dev/null +++ b/app/modules/checkout/services/checkout_service.py @@ -0,0 +1,108 @@ +# app/modules/checkout/services/checkout_service.py +""" +Checkout service for order creation from cart. + +This module provides: +- Cart validation for checkout +- Order creation from cart +- Checkout session management + +Note: This is a placeholder service. Full implementation will +integrate with cart, orders, and payments modules. +""" + +import logging + +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + + +class CheckoutService: + """Service for checkout operations.""" + + def validate_cart_for_checkout( + self, db: Session, vendor_id: int, session_id: str + ) -> dict: + """ + Validate cart is ready for checkout. + + Args: + db: Database session + vendor_id: Vendor ID + session_id: Cart session ID + + Returns: + Validation result with cart summary + + Raises: + ValidationException: If cart is not valid for checkout + """ + # TODO: Implement cart validation + # - Check cart is not empty + # - Check all products are still active + # - Check inventory is available + # - Calculate totals + logger.info( + "[CHECKOUT_SERVICE] validate_cart_for_checkout", + extra={"vendor_id": vendor_id, "session_id": session_id}, + ) + raise NotImplementedError("Checkout service not yet implemented") + + def create_checkout_session( + self, db: Session, vendor_id: int, session_id: str + ) -> dict: + """ + Create a checkout session from cart. + + Args: + db: Database session + vendor_id: Vendor ID + session_id: Cart session ID + + Returns: + Checkout session data + """ + # TODO: Implement checkout session creation + logger.info( + "[CHECKOUT_SERVICE] create_checkout_session", + extra={"vendor_id": vendor_id, "session_id": session_id}, + ) + raise NotImplementedError("Checkout service not yet implemented") + + def complete_checkout( + self, + db: Session, + vendor_id: int, + checkout_session_id: str, + customer_id: int | None = None, + ) -> dict: + """ + Complete checkout and create order. + + Args: + db: Database session + vendor_id: Vendor ID + checkout_session_id: Checkout session ID + customer_id: Optional customer ID (for registered users) + + Returns: + Created order data + """ + # TODO: Implement checkout completion + # - Convert cart to order + # - Clear cart + # - Send confirmation email + logger.info( + "[CHECKOUT_SERVICE] complete_checkout", + extra={ + "vendor_id": vendor_id, + "checkout_session_id": checkout_session_id, + "customer_id": customer_id, + }, + ) + raise NotImplementedError("Checkout service not yet implemented") + + +# Create service instance +checkout_service = CheckoutService() diff --git a/docs/proposals/PLAN_storefront-module-restructure.md b/docs/proposals/PLAN_storefront-module-restructure.md index 0a74a032..2ef9a1d9 100644 --- a/docs/proposals/PLAN_storefront-module-restructure.md +++ b/docs/proposals/PLAN_storefront-module-restructure.md @@ -310,9 +310,9 @@ After migrated to `app/modules/cart/services/cart_service.py`. ## Execution Order -1. **Phase 1** - Add architecture rule (enables detection) -2. **Phase 2** - Rename shop → storefront (terminology) -3. **Phase 3** - Create new modules (cart, checkout, catalog) +1. **Phase 1** - Add architecture rule (enables detection) ✅ COMPLETE +2. **Phase 2** - Rename shop → storefront (terminology) ✅ COMPLETE +3. **Phase 3** - Create new modules (cart, checkout, catalog) ✅ COMPLETE 4. **Phase 4** - Move routes to modules 5. **Phase 5** - Fix direct model imports 6. **Phase 6** - Delete legacy files