diff --git a/pyproject.toml b/pyproject.toml index c59e0a0..8c4181e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "asyncpg>=0.31.0", "authlib>=1.6.5", "cryptography>=46.0.3", + "email-validator>=2.2.0", "fastapi>=0.124.0", "pydantic-settings>=2.12.0", "pydantic>=2.12.5", diff --git a/src/app/chore/lifespan.py b/src/app/chore/lifespan.py index 13104af..7eb0398 100644 --- a/src/app/chore/lifespan.py +++ b/src/app/chore/lifespan.py @@ -9,6 +9,7 @@ from fastapi import FastAPI +import app.db_models # noqa: F401 — registers all ORM models with Base.metadata from app.shared.database.base import Base from app.shared.database.engine import close_database, get_engine, init_database diff --git a/src/app/db_models.py b/src/app/db_models.py new file mode 100644 index 0000000..83311e1 --- /dev/null +++ b/src/app/db_models.py @@ -0,0 +1,37 @@ +""" +Database Model Registry + +Imports ALL SQLAlchemy ORM models so that Base.metadata knows about every +table. This module must be imported before calling Base.metadata.create_all(). + +Why this file exists: + SQLAlchemy's Base.metadata only knows about a table after its ORM class + has been imported. If a model is never imported, its table is never created. + Centralising all imports here means you only need to import this one module + to guarantee that create_all() sees the full schema. + +Usage: + import app.db_models # noqa: F401 — side-effect import + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) +""" + +# Item store (partner service) +from app.services.crud_item_store.models.item_db_models import ItemDB # noqa: F401 + +# Customers +from app.services.customers.models.customers_db_models import AddressDB, CustomerDB # noqa: F401 + +# Inventory + +# Orders +from app.services.orders.models.orders_db_models import OrderDB, OrderItemDB # noqa: F401 + +# Payments +from app.services.payments.models.payments_db_models import PaymentDB # noqa: F401 + +# Webhooks +from app.services.webhooks.models.webhooks_db_models import WebhookEventDB # noqa: F401 + +# Shipments +from app.services.shipments.models.shipments_db_models import ShipmentDB # noqa: F401 diff --git a/src/app/services/crud_item_store/crud_item_store.py b/src/app/services/crud_item_store/crud_item_store.py index b2ea11f..0f75726 100644 --- a/src/app/services/crud_item_store/crud_item_store.py +++ b/src/app/services/crud_item_store/crud_item_store.py @@ -19,7 +19,7 @@ from fastapi import APIRouter # Import routers -from .routers import items +from .routers import items_router # Create the main router for this service router = APIRouter( @@ -28,6 +28,6 @@ ) # Include sub-routers -router.include_router(items.router) +router.include_router(items_router.router) __all__ = ["router"] diff --git a/src/app/services/crud_item_store/functions/__init__.py b/src/app/services/crud_item_store/functions/__init__.py index a69ea4a..a82b8c7 100644 --- a/src/app/services/crud_item_store/functions/__init__.py +++ b/src/app/services/crud_item_store/functions/__init__.py @@ -4,8 +4,8 @@ Business logic and transformation functions for items. """ -from .transformations import db_to_response, prepare_item_update_data -from .validation import check_duplicate_field, validate_update_conflicts +from .item_transformations import db_to_response, prepare_item_update_data +from .item_validation import check_duplicate_field, validate_update_conflicts __all__ = [ "db_to_response", diff --git a/src/app/services/crud_item_store/functions/transformations.py b/src/app/services/crud_item_store/functions/item_transformations.py similarity index 100% rename from src/app/services/crud_item_store/functions/transformations.py rename to src/app/services/crud_item_store/functions/item_transformations.py diff --git a/src/app/services/crud_item_store/functions/validation.py b/src/app/services/crud_item_store/functions/item_validation.py similarity index 91% rename from src/app/services/crud_item_store/functions/validation.py rename to src/app/services/crud_item_store/functions/item_validation.py index 21ae37c..4a3d5d0 100644 --- a/src/app/services/crud_item_store/functions/validation.py +++ b/src/app/services/crud_item_store/functions/item_validation.py @@ -8,9 +8,12 @@ from uuid import UUID from app.shared.exceptions import duplicate_entry +from app.shared.logger import get_logger from ..models import ItemDB from ..services import ItemRepository +logger = get_logger(__name__) + async def check_duplicate_field( repo: ItemRepository, @@ -44,6 +47,13 @@ async def check_duplicate_field( >>> # Can check any field on the model >>> await check_duplicate_field(repo, "name", "Test Product") """ + logger.debug( + "Checking for duplicate field value", + extra={ + "field_name": field_name, + "exclude_uuid": str(exclude_uuid) if exclude_uuid else None, + }, + ) # Use the repository's generic field_exists method # This will raise ValueError if field doesn't exist on the model exists = await repo.field_exists(field_name, field_value, exclude_uuid=exclude_uuid) diff --git a/src/app/services/crud_item_store/models/__init__.py b/src/app/services/crud_item_store/models/__init__.py index 5195e71..a5ee342 100644 --- a/src/app/services/crud_item_store/models/__init__.py +++ b/src/app/services/crud_item_store/models/__init__.py @@ -5,8 +5,8 @@ Response models are in the responses/ directory. """ -from .database import ItemDB -from .item import ( +from .item_db_models import ItemDB +from .item_models import ( DimensionUnit, DimensionsModel, IdentifiersModel, diff --git a/src/app/services/crud_item_store/models/database.py b/src/app/services/crud_item_store/models/item_db_models.py similarity index 100% rename from src/app/services/crud_item_store/models/database.py rename to src/app/services/crud_item_store/models/item_db_models.py diff --git a/src/app/services/crud_item_store/models/item.py b/src/app/services/crud_item_store/models/item_models.py similarity index 100% rename from src/app/services/crud_item_store/models/item.py rename to src/app/services/crud_item_store/models/item_models.py diff --git a/src/app/services/crud_item_store/responses/__init__.py b/src/app/services/crud_item_store/responses/__init__.py index 4b6c660..70199fc 100644 --- a/src/app/services/crud_item_store/responses/__init__.py +++ b/src/app/services/crud_item_store/responses/__init__.py @@ -5,7 +5,7 @@ Combines shared response structures with feature-specific models. """ -from .items import ItemResponse +from .items_response_models import ItemResponse __all__ = [ "ItemResponse", diff --git a/src/app/services/crud_item_store/responses/docs.py b/src/app/services/crud_item_store/responses/docs.py deleted file mode 100644 index b1fffdc..0000000 --- a/src/app/services/crud_item_store/responses/docs.py +++ /dev/null @@ -1,470 +0,0 @@ -""" -OpenAPI Documentation for Item Store Endpoints - -Contains response schemas, examples, and documentation for all item endpoints. -Separates API documentation from business logic to keep routers clean. -""" - -from app.shared.responses import ( - ErrorResponse, - PaginatedResponse, - ValidationErrorResponse, -) -from ..responses import ItemResponse - - -# ============================================================================ -# Common Error Examples -# ============================================================================ - -INVALID_UUID_EXAMPLE = { - "success": False, - "message": "Validation failed", - "status_code": 422, - "error_code": "invalid_input", - "error_category": "validation", - "details": { - "errors": [ - { - "loc": ["path", "item_uuid"], - "msg": "Input should be a valid UUID", - "type": "uuid_parsing", - } - ] - }, -} - -ITEM_NOT_FOUND_EXAMPLE = { - "success": False, - "message": "Item with ID '123e4567-e89b-12d3-a456-426614174000' not found", - "status_code": 404, - "error_code": "entity_not_found", - "error_category": "not_found", - "details": { - "entity_type": "Item", - "entity_id": "123e4567-e89b-12d3-a456-426614174000", - }, -} - -DUPLICATE_SKU_EXAMPLE = { - "success": False, - "message": "Item with sku='CHAIR-001' already exists", - "status_code": 422, - "error_code": "duplicate_entry", - "error_category": "validation", - "details": {"entity_type": "Item", "field": "sku", "value": "CHAIR-001"}, -} - -DUPLICATE_SLUG_EXAMPLE = { - "success": False, - "message": "Item with slug='red-chair' already exists", - "status_code": 422, - "error_code": "duplicate_entry", - "error_category": "validation", - "details": {"entity_type": "Item", "field": "slug", "value": "red-chair"}, -} - -DATABASE_ERROR_EXAMPLE = { - "success": False, - "message": "Database operation failed", - "status_code": 500, - "error_code": "database_query_error", - "error_category": "database", - "details": {"error_type": "DatabaseError"}, -} - -INTERNAL_ERROR_EXAMPLE = { - "success": False, - "message": "An unexpected error occurred", - "status_code": 500, - "error_code": "internal_error", - "error_category": "internal", - "details": {"error_type": "ValueError"}, -} - - -# ============================================================================ -# Create Item Documentation -# ============================================================================ - -CREATE_ITEM_RESPONSES = { - 201: { - "description": "Item created successfully", - "model": ItemResponse, - }, - 422: { - "description": "Validation error - duplicate SKU/slug or invalid input data", - "model": ValidationErrorResponse, - "content": { - "application/json": { - "examples": { - "duplicate_sku": { - "summary": "Duplicate SKU", - "value": DUPLICATE_SKU_EXAMPLE, - }, - "duplicate_slug": { - "summary": "Duplicate slug", - "value": DUPLICATE_SLUG_EXAMPLE, - }, - "invalid_input": { - "summary": "Invalid input data", - "value": { - "success": False, - "message": "Validation failed", - "status_code": 422, - "error_code": "invalid_input", - "error_category": "validation", - "details": { - "errors": [ - { - "loc": ["body", "price", "amount"], - "msg": "Input should be greater than or equal to 0", - "type": "greater_than_equal", - } - ] - }, - }, - }, - } - } - }, - }, - 500: { - "description": "Internal server error - database or unexpected error", - "model": ErrorResponse, - "content": { - "application/json": { - "examples": { - "database_error": { - "summary": "Database error", - "value": DATABASE_ERROR_EXAMPLE, - }, - "internal_error": { - "summary": "Unexpected error", - "value": INTERNAL_ERROR_EXAMPLE, - }, - } - } - }, - }, -} - - -# ============================================================================ -# Get Item by UUID Documentation -# ============================================================================ - -GET_ITEM_RESPONSES = { - 200: { - "description": "Item retrieved successfully", - "model": ItemResponse, - }, - 404: { - "description": "Item not found", - "model": ErrorResponse, - "content": {"application/json": {"example": ITEM_NOT_FOUND_EXAMPLE}}, - }, - 422: { - "description": "Invalid UUID format", - "model": ValidationErrorResponse, - "content": {"application/json": {"example": INVALID_UUID_EXAMPLE}}, - }, - 500: { - "description": "Internal server error - database or unexpected error", - "model": ErrorResponse, - "content": { - "application/json": { - "examples": { - "database_error": { - "summary": "Database error", - "value": DATABASE_ERROR_EXAMPLE, - }, - "internal_error": { - "summary": "Unexpected error", - "value": INTERNAL_ERROR_EXAMPLE, - }, - } - } - }, - }, -} - - -# ============================================================================ -# List Items Documentation -# ============================================================================ - -LIST_ITEMS_RESPONSES = { - 200: { - "description": "Items retrieved successfully", - "model": PaginatedResponse[ItemResponse], - }, - 422: { - "description": "Invalid query parameters", - "model": ValidationErrorResponse, - "content": { - "application/json": { - "examples": { - "invalid_skip": { - "summary": "Negative skip value", - "value": { - "success": False, - "message": "Validation failed", - "status_code": 422, - "error_code": "invalid_input", - "error_category": "validation", - "details": { - "errors": [ - { - "loc": ["query", "skip"], - "msg": "Input should be greater than or equal to 0", - "type": "greater_than_equal", - } - ] - }, - }, - }, - "invalid_limit": { - "summary": "Limit out of range", - "value": { - "success": False, - "message": "Validation failed", - "status_code": 422, - "error_code": "invalid_input", - "error_category": "validation", - "details": { - "errors": [ - { - "loc": ["query", "limit"], - "msg": "Input should be less than or equal to 100", - "type": "less_than_equal", - } - ] - }, - }, - }, - "invalid_status": { - "summary": "Invalid status value", - "value": { - "success": False, - "message": "Validation failed", - "status_code": 422, - "error_code": "invalid_input", - "error_category": "validation", - "details": { - "errors": [ - { - "loc": ["query", "status"], - "msg": "Input should be 'draft', 'active' or 'archived'", - "type": "enum", - } - ] - }, - }, - }, - } - } - }, - }, - 500: { - "description": "Internal server error - database or unexpected error", - "model": ErrorResponse, - "content": { - "application/json": { - "examples": { - "database_error": { - "summary": "Database error", - "value": DATABASE_ERROR_EXAMPLE, - }, - "internal_error": { - "summary": "Unexpected error", - "value": INTERNAL_ERROR_EXAMPLE, - }, - } - } - }, - }, -} - - -# ============================================================================ -# Get Item by SKU Documentation -# ============================================================================ - -GET_ITEM_BY_SKU_RESPONSES = { - 200: { - "description": "Item retrieved successfully", - "model": ItemResponse, - }, - 404: { - "description": "Item not found", - "model": ErrorResponse, - "content": { - "application/json": { - "example": { - "success": False, - "message": "Item with ID 'CHAIR-001' not found", - "status_code": 404, - "error_code": "entity_not_found", - "error_category": "not_found", - "details": {"entity_type": "Item", "entity_id": "CHAIR-001"}, - } - } - }, - }, - 500: { - "description": "Internal server error - database or unexpected error", - "model": ErrorResponse, - "content": { - "application/json": { - "examples": { - "database_error": { - "summary": "Database error", - "value": DATABASE_ERROR_EXAMPLE, - }, - "internal_error": { - "summary": "Unexpected error", - "value": INTERNAL_ERROR_EXAMPLE, - }, - } - } - }, - }, -} - - -# ============================================================================ -# Update Item Documentation -# ============================================================================ - -UPDATE_ITEM_RESPONSES = { - 200: { - "description": "Item updated successfully", - "model": ItemResponse, - }, - 404: { - "description": "Item not found", - "model": ErrorResponse, - "content": {"application/json": {"example": ITEM_NOT_FOUND_EXAMPLE}}, - }, - 422: { - "description": "Validation error - duplicate SKU/slug, invalid UUID, or invalid input data", - "model": ValidationErrorResponse, - "content": { - "application/json": { - "examples": { - "duplicate_sku": { - "summary": "SKU conflicts with another item", - "value": { - "success": False, - "message": "Item with sku='CHAIR-002' already exists", - "status_code": 422, - "error_code": "duplicate_entry", - "error_category": "validation", - "details": { - "entity_type": "Item", - "field": "sku", - "value": "CHAIR-002", - }, - }, - }, - "duplicate_slug": { - "summary": "Slug conflicts with another item", - "value": { - "success": False, - "message": "Item with slug='blue-chair' already exists", - "status_code": 422, - "error_code": "duplicate_entry", - "error_category": "validation", - "details": { - "entity_type": "Item", - "field": "slug", - "value": "blue-chair", - }, - }, - }, - "invalid_uuid": { - "summary": "Invalid UUID format", - "value": INVALID_UUID_EXAMPLE, - }, - "invalid_input": { - "summary": "Invalid field values", - "value": { - "success": False, - "message": "Validation failed", - "status_code": 422, - "error_code": "invalid_input", - "error_category": "validation", - "details": { - "errors": [ - { - "loc": ["body", "status"], - "msg": "Input should be 'draft', 'active' or 'archived'", - "type": "enum", - } - ] - }, - }, - }, - } - } - }, - }, - 500: { - "description": "Internal server error - database or unexpected error", - "model": ErrorResponse, - "content": { - "application/json": { - "examples": { - "database_error": { - "summary": "Database error", - "value": DATABASE_ERROR_EXAMPLE, - }, - "internal_error": { - "summary": "Unexpected error", - "value": INTERNAL_ERROR_EXAMPLE, - }, - } - } - }, - }, -} - - -# ============================================================================ -# Delete Item Documentation -# ============================================================================ - -DELETE_ITEM_RESPONSES = { - 204: { - "description": "Item deleted successfully", - }, - 404: { - "description": "Item not found", - "model": ErrorResponse, - "content": {"application/json": {"example": ITEM_NOT_FOUND_EXAMPLE}}, - }, - 422: { - "description": "Invalid UUID format", - "model": ValidationErrorResponse, - "content": {"application/json": {"example": INVALID_UUID_EXAMPLE}}, - }, - 500: { - "description": "Internal server error - database or unexpected error", - "model": ErrorResponse, - "content": { - "application/json": { - "examples": { - "database_error": { - "summary": "Database error", - "value": DATABASE_ERROR_EXAMPLE, - }, - "internal_error": { - "summary": "Unexpected error", - "value": INTERNAL_ERROR_EXAMPLE, - }, - } - } - }, - }, -} diff --git a/src/app/services/crud_item_store/responses/item_docs.py b/src/app/services/crud_item_store/responses/item_docs.py new file mode 100644 index 0000000..96b4e48 --- /dev/null +++ b/src/app/services/crud_item_store/responses/item_docs.py @@ -0,0 +1,323 @@ +""" +OpenAPI Documentation for Item Store Endpoints + +All error examples are built programmatically from the actual response model +instances so they can never drift from the real API output. +""" + +from app.shared.responses import ( + ErrorResponse, + PaginatedResponse, + ValidationErrorResponse, +) +from . import ItemResponse +from .items_response_models import _ITEM_EXAMPLE + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _err( + status: int, code: str, category: str, message: str, details: dict | None = None +) -> dict: + """Build an error example dict from an actual ErrorResponse instance.""" + return ErrorResponse( + status_code=status, + error_code=code, + error_category=category, + message=message, + details=details, + ).model_dump(mode="json", exclude_none=True) + + +def _validation_err(message: str, errors: list[dict]) -> dict: + """Build a validation error example dict from an actual ValidationErrorResponse instance.""" + return ValidationErrorResponse( + message=message, + validation_errors=errors, + ).model_dump(mode="json", exclude_none=True) + + +# --------------------------------------------------------------------------- +# Shared error examples +# --------------------------------------------------------------------------- + +INVALID_UUID_EXAMPLE = _validation_err( + message="Validation failed", + errors=[ + { + "loc": ["path", "item_uuid"], + "msg": "Input should be a valid UUID", + "type": "uuid_parsing", + } + ], +) + +ITEM_NOT_FOUND_EXAMPLE = _err( + status=404, + code="entity_not_found", + category="not_found", + message="Item with ID '123e4567-e89b-12d3-a456-426614174000' not found", + details={ + "entity_type": "Item", + "entity_id": "123e4567-e89b-12d3-a456-426614174000", + }, +) + +DUPLICATE_SKU_EXAMPLE = _err( + status=422, + code="duplicate_entry", + category="validation", + message="Item with sku='CHAIR-001' already exists", + details={"entity_type": "Item", "field": "sku", "value": "CHAIR-001"}, +) + +DUPLICATE_SLUG_EXAMPLE = _err( + status=422, + code="duplicate_entry", + category="validation", + message="Item with slug='red-chair' already exists", + details={"entity_type": "Item", "field": "slug", "value": "red-chair"}, +) + +DATABASE_ERROR_EXAMPLE = _err( + status=500, + code="database_query_error", + category="database", + message="Database operation failed", + details={"error_type": "DatabaseError"}, +) + +INTERNAL_ERROR_EXAMPLE = _err( + status=500, + code="internal_error", + category="internal", + message="An unexpected error occurred", + details={"error_type": "ValueError"}, +) + +_INVALID_BODY_EXAMPLE = _validation_err( + message="Validation failed", + errors=[ + { + "loc": ["body", "price", "amount"], + "msg": "Input should be greater than or equal to 0", + "type": "greater_than_equal", + } + ], +) + +_INVALID_PAGINATION_SKIP = _validation_err( + message="Validation failed", + errors=[ + { + "loc": ["query", "skip"], + "msg": "Input should be greater than or equal to 0", + "type": "greater_than_equal", + } + ], +) + +_INVALID_PAGINATION_LIMIT = _validation_err( + message="Validation failed", + errors=[ + { + "loc": ["query", "limit"], + "msg": "Input should be less than or equal to 100", + "type": "less_than_equal", + } + ], +) + +_INVALID_STATUS_PARAM = _validation_err( + message="Validation failed", + errors=[ + { + "loc": ["query", "status"], + "msg": "Input should be 'draft', 'active' or 'archived'", + "type": "enum", + } + ], +) + + +# --------------------------------------------------------------------------- +# Shared response blocks (reused across endpoints) +# --------------------------------------------------------------------------- + +_500_BLOCK = { + "description": "Database or internal server error", + "model": ErrorResponse, + "content": { + "application/json": { + "examples": { + "database_error": { + "summary": "Database error", + "value": DATABASE_ERROR_EXAMPLE, + }, + "internal_error": { + "summary": "Unexpected error", + "value": INTERNAL_ERROR_EXAMPLE, + }, + } + } + }, +} + +_404_BLOCK = { + "description": "Item not found", + "model": ErrorResponse, + "content": {"application/json": {"example": ITEM_NOT_FOUND_EXAMPLE}}, +} + +_422_UUID_BLOCK = { + "description": "Invalid UUID format", + "model": ValidationErrorResponse, + "content": {"application/json": {"example": INVALID_UUID_EXAMPLE}}, +} + + +# --------------------------------------------------------------------------- +# Per-endpoint response dicts +# --------------------------------------------------------------------------- + +CREATE_ITEM_RESPONSES = { + 201: { + "description": "Item created successfully", + "model": ItemResponse, + "content": {"application/json": {"example": _ITEM_EXAMPLE}}, + }, + 422: { + "description": "Duplicate SKU/slug or invalid input data", + "model": ValidationErrorResponse, + "content": { + "application/json": { + "examples": { + "duplicate_sku": { + "summary": "Duplicate SKU", + "value": DUPLICATE_SKU_EXAMPLE, + }, + "duplicate_slug": { + "summary": "Duplicate slug", + "value": DUPLICATE_SLUG_EXAMPLE, + }, + "invalid_input": { + "summary": "Invalid input data", + "value": _INVALID_BODY_EXAMPLE, + }, + } + } + }, + }, + 500: _500_BLOCK, +} + +GET_ITEM_RESPONSES = { + 200: { + "description": "Item retrieved successfully", + "model": ItemResponse, + "content": {"application/json": {"example": _ITEM_EXAMPLE}}, + }, + 404: _404_BLOCK, + 422: _422_UUID_BLOCK, + 500: _500_BLOCK, +} + +LIST_ITEMS_RESPONSES = { + 200: { + "description": "Items retrieved successfully", + "model": PaginatedResponse[ItemResponse], + }, + 422: { + "description": "Invalid query parameters", + "model": ValidationErrorResponse, + "content": { + "application/json": { + "examples": { + "invalid_skip": { + "summary": "Negative skip value", + "value": _INVALID_PAGINATION_SKIP, + }, + "invalid_limit": { + "summary": "Limit out of range", + "value": _INVALID_PAGINATION_LIMIT, + }, + "invalid_status": { + "summary": "Invalid status value", + "value": _INVALID_STATUS_PARAM, + }, + } + } + }, + }, + 500: _500_BLOCK, +} + +GET_ITEM_BY_SKU_RESPONSES = { + 200: { + "description": "Item retrieved successfully", + "model": ItemResponse, + "content": {"application/json": {"example": _ITEM_EXAMPLE}}, + }, + 404: { + "description": "Item not found", + "model": ErrorResponse, + "content": { + "application/json": { + "example": _err( + status=404, + code="entity_not_found", + category="not_found", + message="Item with ID 'CHAIR-RED-001' not found", + details={"entity_type": "Item", "entity_id": "CHAIR-RED-001"}, + ) + } + }, + }, + 500: _500_BLOCK, +} + +UPDATE_ITEM_RESPONSES = { + 200: { + "description": "Item updated successfully", + "model": ItemResponse, + "content": {"application/json": {"example": _ITEM_EXAMPLE}}, + }, + 404: _404_BLOCK, + 422: { + "description": "Duplicate SKU/slug, invalid UUID, or invalid input data", + "model": ValidationErrorResponse, + "content": { + "application/json": { + "examples": { + "duplicate_sku": { + "summary": "SKU conflicts with another item", + "value": DUPLICATE_SKU_EXAMPLE, + }, + "duplicate_slug": { + "summary": "Slug conflicts with another item", + "value": DUPLICATE_SLUG_EXAMPLE, + }, + "invalid_uuid": { + "summary": "Invalid UUID format", + "value": INVALID_UUID_EXAMPLE, + }, + "invalid_input": { + "summary": "Invalid field values", + "value": _INVALID_BODY_EXAMPLE, + }, + } + } + }, + }, + 500: _500_BLOCK, +} + +DELETE_ITEM_RESPONSES = { + 204: {"description": "Item deleted successfully"}, + 404: _404_BLOCK, + 422: _422_UUID_BLOCK, + 500: _500_BLOCK, +} diff --git a/src/app/services/crud_item_store/responses/items.py b/src/app/services/crud_item_store/responses/items.py deleted file mode 100644 index a0c18dd..0000000 --- a/src/app/services/crud_item_store/responses/items.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -Item Response Models - -API response schemas for item endpoints. -""" - -from datetime import datetime -from uuid import UUID - -from pydantic import ConfigDict, Field - -from ..models import ItemBase - - -class ItemResponse(ItemBase): - """ - Schema for item API responses. - - Extends ItemBase with database-generated fields like UUID and timestamps. - Used for returning item data from GET, POST, PATCH endpoints. - """ - - uuid: UUID = Field(..., description="Unique item identifier") - created_at: datetime = Field(..., description="Item creation timestamp") - updated_at: datetime = Field(..., description="Last update timestamp") - - model_config = ConfigDict(from_attributes=True) diff --git a/src/app/services/crud_item_store/responses/items_response_models.py b/src/app/services/crud_item_store/responses/items_response_models.py new file mode 100644 index 0000000..41ae99b --- /dev/null +++ b/src/app/services/crud_item_store/responses/items_response_models.py @@ -0,0 +1,78 @@ +""" +Item Response Models + +API response schemas for item endpoints. +""" + +from datetime import datetime +from uuid import UUID + +from pydantic import ConfigDict, Field + +from ..models import ItemBase + + +_ITEM_EXAMPLE = { + "uuid": "2f61e8db-1234-4abc-8def-426614174000", + "sku": "CHAIR-RED-001", + "status": "active", + "name": "Red Wooden Chair", + "slug": "red-wooden-chair", + "short_description": "A comfortable red wooden chair.", + "description": "
Handcrafted red wooden chair with ergonomic design.
", + "categories": ["a1b2c3d4-0000-0000-0000-000000000001"], + "brand": "WoodCraft", + "price": { + "amount": 4999, + "currency": "EUR", + "includes_tax": True, + "original_amount": 5999, + "tax_class": "standard", + }, + "media": { + "main_image": "https://cdn.example.com/items/chair-red-001.jpg", + "gallery": [ + "https://cdn.example.com/items/chair-red-001-side.jpg", + "https://cdn.example.com/items/chair-red-001-back.jpg", + ], + }, + "inventory": { + "stock_quantity": 42, + "stock_status": "in_stock", + "allow_backorder": False, + }, + "shipping": { + "is_physical": True, + "weight": {"value": 4.5, "unit": "kg"}, + "dimensions": {"width": 45.0, "height": 90.0, "length": 45.0, "unit": "cm"}, + "shipping_class": "standard", + }, + "attributes": {"color": "red", "material": "oak"}, + "identifiers": { + "barcode": "4006381333931", + "manufacturer_part_number": "WC-CHAIR-RED-01", + "country_of_origin": "DE", + }, + "custom": {}, + "system": {"log_table": None}, + "created_at": "2026-01-15T10:30:00Z", + "updated_at": "2026-02-20T14:00:00Z", +} + + +class ItemResponse(ItemBase): + """ + Schema for item API responses. + + Extends ItemBase with database-generated fields like UUID and timestamps. + Used for returning item data from GET, POST, PATCH endpoints. + """ + + uuid: UUID = Field(..., description="Unique item identifier") + created_at: datetime = Field(..., description="Item creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={"example": _ITEM_EXAMPLE}, + ) diff --git a/src/app/services/crud_item_store/routers/__init__.py b/src/app/services/crud_item_store/routers/__init__.py index c665580..c0447e6 100644 --- a/src/app/services/crud_item_store/routers/__init__.py +++ b/src/app/services/crud_item_store/routers/__init__.py @@ -4,6 +4,6 @@ Exports all routers for the item-store service. """ -from . import items +from . import items_router -__all__ = ["items"] +__all__ = ["items_router"] diff --git a/src/app/services/crud_item_store/routers/items.py b/src/app/services/crud_item_store/routers/items_router.py similarity index 92% rename from src/app/services/crud_item_store/routers/items.py rename to src/app/services/crud_item_store/routers/items_router.py index f3d8b61..fb7a698 100644 --- a/src/app/services/crud_item_store/routers/items.py +++ b/src/app/services/crud_item_store/routers/items_router.py @@ -15,7 +15,7 @@ from app.shared.responses import PaginatedResponse, PageInfo from ..models import ItemCreate, ItemStatus, ItemUpdate from ..responses import ItemResponse -from ..responses.docs import ( +from ..responses.item_docs import ( CREATE_ITEM_RESPONSES, GET_ITEM_RESPONSES, LIST_ITEMS_RESPONSES, @@ -30,7 +30,9 @@ validate_update_conflicts, prepare_item_update_data, ) +from app.shared.logger import get_logger +logger = get_logger(__name__) router = APIRouter() @@ -87,6 +89,7 @@ async def create_item( custom=item.custom, system=item.system.model_dump(), ) + logger.info("Item created", extra={"uuid": str(created.uuid), "sku": created.sku}) return db_to_response(created) @@ -117,6 +120,7 @@ async def get_item( DatabaseError (500): If database operation fails """ repo = get_item_repository(session) + logger.debug("Fetching item", extra={"uuid": str(item_uuid)}) item = await repo.get(item_uuid) if not item: @@ -159,6 +163,14 @@ async def list_items( DatabaseError (500): If database operation fails """ repo = get_item_repository(session) + logger.debug( + "Listing items", + extra={ + "skip": skip, + "limit": limit, + "status": status_filter.value if status_filter else None, + }, + ) # Apply filters filters = {} @@ -211,6 +223,7 @@ async def get_item_by_sku( DatabaseError (500): If database operation fails """ repo = get_item_repository(session) + logger.debug("Fetching item by SKU", extra={"sku": sku}) item = await repo.get_by(sku=sku) if not item: @@ -265,6 +278,7 @@ async def update_item( # Update item updated = await repo.update(item_uuid, **update_data) + logger.info("Item updated", extra={"uuid": str(item_uuid)}) return db_to_response(updated) @@ -298,3 +312,4 @@ async def delete_item( raise entity_not_found("Item", item_uuid) await repo.delete(item_uuid) + logger.info("Item deleted", extra={"uuid": str(item_uuid)}) diff --git a/src/app/services/crud_item_store/services/__init__.py b/src/app/services/crud_item_store/services/__init__.py index 0e2cd68..a7fd35a 100644 --- a/src/app/services/crud_item_store/services/__init__.py +++ b/src/app/services/crud_item_store/services/__init__.py @@ -2,6 +2,6 @@ Init file for services """ -from .database import ItemRepository, get_item_repository +from .item_db_service import ItemRepository, get_item_repository __all__ = ["ItemRepository", "get_item_repository"] diff --git a/src/app/services/crud_item_store/services/database.py b/src/app/services/crud_item_store/services/item_db_service.py similarity index 90% rename from src/app/services/crud_item_store/services/database.py rename to src/app/services/crud_item_store/services/item_db_service.py index 7b981a1..6e12d2e 100644 --- a/src/app/services/crud_item_store/services/database.py +++ b/src/app/services/crud_item_store/services/item_db_service.py @@ -12,8 +12,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.shared.database.repository import BaseRepository +from app.shared.logger import get_logger from ..models import ItemDB +logger = get_logger(__name__) + class ItemRepository(BaseRepository[ItemDB]): """ @@ -72,6 +75,16 @@ async def search( >>> # Search by brand and status >>> items = await repo.search(brand="TestBrand", status="active") """ + logger.debug( + "Searching items", + extra={ + "name": name, + "status": status, + "brand": brand, + "skip": skip, + "limit": limit, + }, + ) stmt = select(self.model) conditions = [] @@ -124,6 +137,13 @@ async def field_exists( ... "slug", "red-chair", exclude_uuid=item_uuid ... ) """ + logger.debug( + "Checking field existence", + extra={ + "field_name": field_name, + "exclude_uuid": str(exclude_uuid) if exclude_uuid else None, + }, + ) # Validate field exists on model if not hasattr(self.model, field_name): raise ValueError(f"Field '{field_name}' does not exist on ItemDB model") diff --git a/src/app/services/customers/__init__.py b/src/app/services/customers/__init__.py new file mode 100644 index 0000000..5e8368c --- /dev/null +++ b/src/app/services/customers/__init__.py @@ -0,0 +1,5 @@ +""" +Customers Service Package + +Handles customer profiles and addresses. +""" diff --git a/src/app/services/customers/models/__init__.py b/src/app/services/customers/models/__init__.py new file mode 100644 index 0000000..0ad0db7 --- /dev/null +++ b/src/app/services/customers/models/__init__.py @@ -0,0 +1,32 @@ +""" +Customers Models Package + +Exports ORM models and Pydantic schemas for the customers service. +""" + +from .customers_db_models import AddressDB, CustomerDB +from .customers_models import ( + AddressBase, + AddressCreate, + AddressResponse, + AddressUpdate, + CustomerBase, + CustomerCreate, + CustomerResponse, + CustomerUpdate, +) + +__all__ = [ + # ORM models + "CustomerDB", + "AddressDB", + # Pydantic schemas + "CustomerBase", + "CustomerCreate", + "CustomerUpdate", + "CustomerResponse", + "AddressBase", + "AddressCreate", + "AddressUpdate", + "AddressResponse", +] diff --git a/src/app/services/customers/models/customers_db_models.py b/src/app/services/customers/models/customers_db_models.py new file mode 100644 index 0000000..de8a3e8 --- /dev/null +++ b/src/app/services/customers/models/customers_db_models.py @@ -0,0 +1,148 @@ +""" +Customers Database Models + +SQLAlchemy ORM models for the customers service. +These define the DB schema only — no business logic lives here. +""" + +from uuid import UUID, uuid4 + +from sqlalchemy import Boolean, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.database.base import Base, TimestampMixin + + +class CustomerDB(Base, TimestampMixin): + """ + Customer database model. + + One customer per Keycloak user. Created automatically on first + authenticated request via GET /customers/me. + + Columns: + id: Internal UUID primary key. + keycloak_user_id: The 'sub' claim from the Keycloak JWT. Unique. + email: Customer email address. Unique. + first_name: Given name. + last_name: Family name. + created_at: Inherited from TimestampMixin. + updated_at: Inherited from TimestampMixin. + """ + + __tablename__ = "customers" + + id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + primary_key=True, + default=uuid4, + doc="Internal unique identifier", + ) + + keycloak_user_id: Mapped[str] = mapped_column( + String(255), + unique=True, + nullable=False, + index=True, + doc="Keycloak subject claim (sub) — links DB record to identity provider", + ) + + email: Mapped[str] = mapped_column( + String(255), + unique=True, + nullable=False, + index=True, + doc="Customer email address", + ) + + first_name: Mapped[str] = mapped_column( + String(100), + nullable=False, + doc="Given name", + ) + + last_name: Mapped[str] = mapped_column( + String(100), + nullable=False, + doc="Family name", + ) + + def __repr__(self) -> str: + return ( + f"CustomerDB(id={self.id}, email={self.email!r}, " + f"keycloak_user_id={self.keycloak_user_id!r})" + ) + + +class AddressDB(Base, TimestampMixin): + """ + Address database model. + + A customer may have multiple addresses. At most one may be the default. + + Columns: + id: Internal UUID primary key. + customer_id: FK → customers.id. + street: Street name and house number. + city: City name. + zip_code: Postal / ZIP code. + country: ISO 3166-1 alpha-2 country code (e.g. 'DE'). + is_default: Whether this is the customer's default shipping address. + created_at: Inherited from TimestampMixin. + updated_at: Inherited from TimestampMixin. + """ + + __tablename__ = "addresses" + + id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + primary_key=True, + default=uuid4, + doc="Internal unique identifier", + ) + + customer_id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("customers.id", ondelete="CASCADE"), + nullable=False, + index=True, + doc="FK to the owning customer", + ) + + street: Mapped[str] = mapped_column( + String(255), + nullable=False, + doc="Street name and house number", + ) + + city: Mapped[str] = mapped_column( + String(100), + nullable=False, + doc="City name", + ) + + zip_code: Mapped[str] = mapped_column( + String(20), + nullable=False, + doc="Postal / ZIP code", + ) + + country: Mapped[str] = mapped_column( + String(2), + nullable=False, + doc="ISO 3166-1 alpha-2 country code (e.g. 'DE')", + ) + + is_default: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + doc="Whether this is the customer's default shipping address", + ) + + def __repr__(self) -> str: + return ( + f"AddressDB(id={self.id}, customer_id={self.customer_id}, " + f"city={self.city!r}, country={self.country!r})" + ) diff --git a/src/app/services/customers/models/customers_models.py b/src/app/services/customers/models/customers_models.py new file mode 100644 index 0000000..db2f295 --- /dev/null +++ b/src/app/services/customers/models/customers_models.py @@ -0,0 +1,105 @@ +""" +Customers Pydantic Schemas + +API-level input/output validation for the customers service. +Completely independent of SQLAlchemy — only used in routers and functions. +""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + + +# ============================================================================ +# Customer Schemas +# ============================================================================ + + +class CustomerBase(BaseModel): + """Shared customer fields used in create and response schemas.""" + + keycloak_user_id: str = Field( + ..., + description="Keycloak subject claim (sub) from JWT", + ) + email: EmailStr = Field(..., description="Customer email address") + first_name: str = Field(..., min_length=1, max_length=100, description="Given name") + last_name: str = Field(..., min_length=1, max_length=100, description="Family name") + + +class CustomerCreate(CustomerBase): + """Schema for creating a new customer record. Used internally on first login.""" + + pass + + +class CustomerUpdate(BaseModel): + """Schema for partial customer updates. All fields optional.""" + + email: EmailStr | None = None + first_name: str | None = Field(default=None, min_length=1, max_length=100) + last_name: str | None = Field(default=None, min_length=1, max_length=100) + + +class CustomerResponse(CustomerBase): + """Customer response schema returned by the API.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID = Field(..., description="Internal customer UUID") + created_at: datetime = Field(..., description="Record creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + + +# ============================================================================ +# Address Schemas +# ============================================================================ + + +class AddressBase(BaseModel): + """Shared address fields.""" + + street: str = Field( + ..., min_length=1, max_length=255, description="Street and house number" + ) + city: str = Field(..., min_length=1, max_length=100, description="City name") + zip_code: str = Field( + ..., min_length=1, max_length=20, description="Postal / ZIP code" + ) + country: str = Field( + ..., + min_length=2, + max_length=2, + description="ISO 3166-1 alpha-2 country code (e.g. 'DE')", + ) + is_default: bool = Field( + default=False, description="Whether this is the default shipping address" + ) + + +class AddressCreate(AddressBase): + """Schema for creating a new address. customer_id is injected from the auth token.""" + + pass + + +class AddressUpdate(BaseModel): + """Schema for partial address updates. All fields optional.""" + + street: str | None = Field(default=None, min_length=1, max_length=255) + city: str | None = Field(default=None, min_length=1, max_length=100) + zip_code: str | None = Field(default=None, min_length=1, max_length=20) + country: str | None = Field(default=None, min_length=2, max_length=2) + is_default: bool | None = None + + +class AddressResponse(AddressBase): + """Address response schema returned by the API.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID = Field(..., description="Internal address UUID") + customer_id: UUID = Field(..., description="UUID of the owning customer") + created_at: datetime = Field(..., description="Record creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") diff --git a/src/app/services/customers/services/__init__.py b/src/app/services/customers/services/__init__.py new file mode 100644 index 0000000..275d08e --- /dev/null +++ b/src/app/services/customers/services/__init__.py @@ -0,0 +1,17 @@ +""" +Customers Services Package +""" + +from .customers_db_service import ( + AddressRepository, + CustomerRepository, + get_address_repository, + get_customer_repository, +) + +__all__ = [ + "CustomerRepository", + "AddressRepository", + "get_customer_repository", + "get_address_repository", +] diff --git a/src/app/services/customers/services/customers_db_service.py b/src/app/services/customers/services/customers_db_service.py new file mode 100644 index 0000000..dd88e34 --- /dev/null +++ b/src/app/services/customers/services/customers_db_service.py @@ -0,0 +1,48 @@ +""" +Customers Database Services + +Repositories for customer and address data access. +Business logic (e.g. enforcing one default address per customer) +belongs in functions/, not here. +""" + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.shared.database.repository import BaseRepository +from ..models.customers_db_models import AddressDB, CustomerDB + + +class CustomerRepository(BaseRepository[CustomerDB]): + """ + Repository for customer database operations. + + Inherits standard CRUD from BaseRepository. + Add customer-specific queries here as the service grows. + """ + + def __init__(self, session: AsyncSession) -> None: + super().__init__(CustomerDB, session) + + +class AddressRepository(BaseRepository[AddressDB]): + """ + Repository for address database operations. + """ + + def __init__(self, session: AsyncSession) -> None: + super().__init__(AddressDB, session) + + +# --------------------------------------------------------------------------- +# Dependency injection factories +# --------------------------------------------------------------------------- + + +def get_customer_repository(session: AsyncSession) -> CustomerRepository: + """Factory for CustomerRepository — use with FastAPI Depends.""" + return CustomerRepository(session) + + +def get_address_repository(session: AsyncSession) -> AddressRepository: + """Factory for AddressRepository — use with FastAPI Depends.""" + return AddressRepository(session) diff --git a/src/app/services/inventory/__init__.py b/src/app/services/inventory/__init__.py new file mode 100644 index 0000000..aa7f77b --- /dev/null +++ b/src/app/services/inventory/__init__.py @@ -0,0 +1,5 @@ +""" +Inventory Service Package + +Handles stock levels and reservations. +""" diff --git a/src/app/services/inventory/models/__init__.py b/src/app/services/inventory/models/__init__.py new file mode 100644 index 0000000..c3cda26 --- /dev/null +++ b/src/app/services/inventory/models/__init__.py @@ -0,0 +1,32 @@ +""" +Inventory Models Package + +Exports ORM models and Pydantic schemas for the inventory service. +""" + +from .inventory_db_models import InventoryItemDB, StockReservationDB +from .inventory_models import ( + InventoryItemBase, + InventoryItemCreate, + InventoryItemResponse, + InventoryItemUpdate, + ReservationStatus, + StockReservationBase, + StockReservationCreate, + StockReservationResponse, +) + +__all__ = [ + # ORM models + "InventoryItemDB", + "StockReservationDB", + # Pydantic schemas + "InventoryItemBase", + "InventoryItemCreate", + "InventoryItemUpdate", + "InventoryItemResponse", + "ReservationStatus", + "StockReservationBase", + "StockReservationCreate", + "StockReservationResponse", +] diff --git a/src/app/services/inventory/models/inventory_db_models.py b/src/app/services/inventory/models/inventory_db_models.py new file mode 100644 index 0000000..c64bb59 --- /dev/null +++ b/src/app/services/inventory/models/inventory_db_models.py @@ -0,0 +1,172 @@ +""" +Inventory Database Models + +SQLAlchemy ORM models for stock levels and reservations. + +Design decisions: +- inventory_items.sku is a soft reference to items.sku (no FK constraint). + This keeps the inventory service decoupled from the item service. + Referential integrity is enforced at the application layer. +- stock_reservations.order_id is a soft reference to orders.id for the + same reason: avoids circular FK dependencies at the DB level. +- DB constraints enforce on_hand >= 0, reserved >= 0, on_hand >= reserved. + These are the last line of defence against overselling. +""" + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import CheckConstraint, ForeignKey, Integer, String, DateTime, text +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.database.base import Base, TimestampMixin + + +class InventoryItemDB(Base, TimestampMixin): + """ + Inventory item database model. + + Tracks available (on_hand) and reserved stock for a single SKU. + 'reserved' counts units currently held by active StockReservations. + 'on_hand' is the physical quantity in the warehouse. + + Invariant (enforced by DB constraint): on_hand >= reserved >= 0 + + Columns: + id: UUID primary key. + sku: SKU string — soft reference to items.sku. + on_hand: Physical units in the warehouse. + reserved: Units locked by active reservations (subset of on_hand). + created_at: Inherited from TimestampMixin. + updated_at: Inherited from TimestampMixin. + """ + + __tablename__ = "inventory_items" + + id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + primary_key=True, + default=uuid4, + doc="Internal unique identifier", + ) + + sku: Mapped[str] = mapped_column( + String(100), + unique=True, + nullable=False, + index=True, + doc="Stock Keeping Unit — matches items.sku", + ) + + on_hand: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + server_default=text("0"), + doc="Physical stock count in the warehouse", + ) + + reserved: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + server_default=text("0"), + doc="Units locked by active reservations", + ) + + __table_args__ = ( + CheckConstraint("on_hand >= 0", name="ck_inventory_items_on_hand_non_negative"), + CheckConstraint( + "reserved >= 0", name="ck_inventory_items_reserved_non_negative" + ), + CheckConstraint( + "on_hand >= reserved", name="ck_inventory_items_on_hand_gte_reserved" + ), + ) + + def __repr__(self) -> str: + return ( + f"InventoryItemDB(id={self.id}, sku={self.sku!r}, " + f"on_hand={self.on_hand}, reserved={self.reserved})" + ) + + +class StockReservationDB(Base, TimestampMixin): + """ + Stock reservation database model. + + Created when a customer starts checkout. Holds stock until payment + is confirmed (COMMITTED) or the reservation expires (EXPIRED/RELEASED). + + Lifecycle: + ACTIVE → COMMITTED (payment succeeded, inventory committed) + ACTIVE → RELEASED (payment failed or cart abandoned) + ACTIVE → EXPIRED (TTL exceeded, cleaned up by background job) + + Columns: + id: UUID primary key. + inventory_item_id: FK → inventory_items.id. + order_id: Soft reference to orders.id (no DB FK). + quantity: Number of units reserved. + expires_at: When this reservation automatically expires. + status: Current lifecycle state (see ReservationStatus enum). + created_at: Inherited from TimestampMixin. + updated_at: Inherited from TimestampMixin. + """ + + __tablename__ = "stock_reservations" + + id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + primary_key=True, + default=uuid4, + doc="Internal unique identifier", + ) + + inventory_item_id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("inventory_items.id", ondelete="RESTRICT"), + nullable=False, + index=True, + doc="FK to the inventory item being reserved", + ) + + order_id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + nullable=False, + index=True, + doc="Soft reference to orders.id (no DB FK to avoid circular dependency)", + ) + + quantity: Mapped[int] = mapped_column( + Integer, + nullable=False, + doc="Number of units reserved", + ) + + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + index=True, + doc="Timestamp after which this reservation is considered expired", + ) + + status: Mapped[str] = mapped_column( + String(20), + nullable=False, + default="active", + server_default=text("'active'"), + index=True, + doc="Reservation status: active | committed | expired | released", + ) + + __table_args__ = ( + CheckConstraint("quantity > 0", name="ck_stock_reservations_quantity_positive"), + ) + + def __repr__(self) -> str: + return ( + f"StockReservationDB(id={self.id}, order_id={self.order_id}, " + f"quantity={self.quantity}, status={self.status!r})" + ) diff --git a/src/app/services/inventory/models/inventory_models.py b/src/app/services/inventory/models/inventory_models.py new file mode 100644 index 0000000..6a4c9f4 --- /dev/null +++ b/src/app/services/inventory/models/inventory_models.py @@ -0,0 +1,102 @@ +""" +Inventory Pydantic Schemas + +API-level input/output validation for the inventory service. +""" + +from datetime import datetime +from enum import Enum +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +# ============================================================================ +# Enums +# ============================================================================ + + +class ReservationStatus(str, Enum): + """Lifecycle states of a stock reservation.""" + + ACTIVE = "active" + COMMITTED = "committed" + EXPIRED = "expired" + RELEASED = "released" + + +# ============================================================================ +# InventoryItem Schemas +# ============================================================================ + + +class InventoryItemBase(BaseModel): + """Shared inventory item fields.""" + + sku: str = Field( + ..., min_length=1, max_length=100, description="SKU — matches items.sku" + ) + on_hand: int = Field(..., ge=0, description="Physical stock count in the warehouse") + reserved: int = Field( + default=0, ge=0, description="Units locked by active reservations" + ) + + +class InventoryItemCreate(InventoryItemBase): + """Schema for creating a new inventory item record.""" + + pass + + +class InventoryItemUpdate(BaseModel): + """Schema for partial inventory updates. All fields optional.""" + + on_hand: int | None = Field(default=None, ge=0) + reserved: int | None = Field(default=None, ge=0) + + +class InventoryItemResponse(InventoryItemBase): + """Inventory item response schema returned by the API.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID = Field(..., description="Internal inventory item UUID") + created_at: datetime = Field(..., description="Record creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + + +# ============================================================================ +# StockReservation Schemas +# ============================================================================ + + +class StockReservationBase(BaseModel): + """Shared stock reservation fields.""" + + inventory_item_id: UUID = Field( + ..., description="UUID of the reserved inventory item" + ) + order_id: UUID = Field(..., description="UUID of the associated order") + quantity: int = Field(..., gt=0, description="Number of units reserved") + expires_at: datetime = Field( + ..., description="Expiry timestamp for this reservation" + ) + + +class StockReservationCreate(StockReservationBase): + """Schema for creating a new stock reservation. Status is always ACTIVE on create.""" + + pass + + +class StockReservationResponse(StockReservationBase): + """Stock reservation response schema returned by the API.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID = Field(..., description="Internal reservation UUID") + status: ReservationStatus = Field( + ..., description="Current reservation lifecycle state" + ) + created_at: datetime = Field(..., description="Record creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") diff --git a/src/app/services/inventory/services/__init__.py b/src/app/services/inventory/services/__init__.py new file mode 100644 index 0000000..3a3da09 --- /dev/null +++ b/src/app/services/inventory/services/__init__.py @@ -0,0 +1,17 @@ +""" +Inventory Services Package +""" + +from .inventory_db_service import ( + InventoryRepository, + StockReservationRepository, + get_inventory_repository, + get_stock_reservation_repository, +) + +__all__ = [ + "InventoryRepository", + "StockReservationRepository", + "get_inventory_repository", + "get_stock_reservation_repository", +] diff --git a/src/app/services/inventory/services/inventory_db_service.py b/src/app/services/inventory/services/inventory_db_service.py new file mode 100644 index 0000000..0e8ffa7 --- /dev/null +++ b/src/app/services/inventory/services/inventory_db_service.py @@ -0,0 +1,122 @@ +""" +Inventory Database Services + +Repositories for inventory items and stock reservations. + +Important: The actual reservation/commit/release logic lives in +functions/ (Phase 1), NOT here. Repositories only handle raw data access. +""" + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import select, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.shared.database.repository import BaseRepository +from app.shared.logger import get_logger +from ..models.inventory_db_models import InventoryItemDB, StockReservationDB + +logger = get_logger(__name__) + + +class InventoryRepository(BaseRepository[InventoryItemDB]): + """ + Repository for inventory item database operations. + """ + + def __init__(self, session: AsyncSession) -> None: + super().__init__(InventoryItemDB, session) + + async def get_available_quantity(self, sku: str) -> int: + """ + Return the number of units available for reservation (on_hand - reserved). + + Uses a single column SELECT instead of fetching the full ORM object — + important when called repeatedly during checkout under load. + + Args: + sku: The SKU string. + + Returns: + Available quantity. Returns 0 if the SKU is not tracked. + """ + logger.debug("Getting available quantity", extra={"sku": sku}) + stmt = select( + (self.model.on_hand - self.model.reserved).label("available") + ).where(self.model.sku == sku) + result = await self.session.execute(stmt) + available = result.scalar_one_or_none() + if available is None: + return 0 + return max(0, available) + + +class StockReservationRepository(BaseRepository[StockReservationDB]): + """ + Repository for stock reservation database operations. + """ + + def __init__(self, session: AsyncSession) -> None: + super().__init__(StockReservationDB, session) + + async def get_active_for_order(self, order_id: UUID) -> list[StockReservationDB]: + """ + Return all ACTIVE reservations for a given order. + + Args: + order_id: UUID of the order. + + Returns: + List of active StockReservationDB instances. + """ + logger.debug( + "Getting active reservations for order", extra={"order_id": str(order_id)} + ) + stmt = select(self.model).where( + and_( + self.model.order_id == order_id, + self.model.status == "active", + ) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def get_expired_active(self, now: datetime) -> list[StockReservationDB]: + """ + Return all ACTIVE reservations whose expiry timestamp has passed. + + Used by the background cleanup job (Phase 1 — expire_reservations). + + Args: + now: Current UTC datetime. + + Returns: + List of expired-but-still-active StockReservationDB instances. + """ + logger.debug("Getting expired active reservations") + stmt = select(self.model).where( + and_( + self.model.status == "active", + self.model.expires_at <= now, + ) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + +# --------------------------------------------------------------------------- +# Dependency injection factories +# --------------------------------------------------------------------------- + + +def get_inventory_repository(session: AsyncSession) -> InventoryRepository: + """Factory for InventoryRepository — use with FastAPI Depends.""" + return InventoryRepository(session) + + +def get_stock_reservation_repository( + session: AsyncSession, +) -> StockReservationRepository: + """Factory for StockReservationRepository — use with FastAPI Depends.""" + return StockReservationRepository(session) diff --git a/src/app/services/orders/__init__.py b/src/app/services/orders/__init__.py new file mode 100644 index 0000000..5fffcb2 --- /dev/null +++ b/src/app/services/orders/__init__.py @@ -0,0 +1,5 @@ +""" +Orders Service Package + +Handles order lifecycle from DRAFT to SHIPPED. +""" diff --git a/src/app/services/orders/models/__init__.py b/src/app/services/orders/models/__init__.py new file mode 100644 index 0000000..2afbb10 --- /dev/null +++ b/src/app/services/orders/models/__init__.py @@ -0,0 +1,34 @@ +""" +Orders Models Package + +Exports ORM models and Pydantic schemas for the orders service. +""" + +from .orders_db_models import OrderDB, OrderItemDB +from .orders_models import ( + OrderBase, + OrderCreate, + OrderDetailResponse, + OrderItemBase, + OrderItemCreate, + OrderItemResponse, + OrderResponse, + OrderStatus, + OrderUpdate, +) + +__all__ = [ + # ORM models + "OrderDB", + "OrderItemDB", + # Pydantic schemas + "OrderStatus", + "OrderBase", + "OrderCreate", + "OrderUpdate", + "OrderResponse", + "OrderDetailResponse", + "OrderItemBase", + "OrderItemCreate", + "OrderItemResponse", +] diff --git a/src/app/services/orders/models/orders_db_models.py b/src/app/services/orders/models/orders_db_models.py new file mode 100644 index 0000000..38094ef --- /dev/null +++ b/src/app/services/orders/models/orders_db_models.py @@ -0,0 +1,158 @@ +""" +Orders Database Models + +SQLAlchemy ORM models for orders and order line items. + +Design decisions: +- OrderItemDB.unit_price is a price SNAPSHOT taken at checkout time. + It deliberately does NOT reference items.price — prices can change + after an order is placed and the order must reflect what the customer paid. +- OrderDB.customer_id is a hard FK to customers.id (CASCADE on delete). +- OrderItemDB.order_id is a hard FK to orders.id (CASCADE on delete). +- Orders support soft deletion via SoftDeleteMixin (deleted_at field). +""" + +from uuid import UUID, uuid4 + +from sqlalchemy import BigInteger, CheckConstraint, ForeignKey, Integer, String, text +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.database.base import Base, SoftDeleteMixin, TimestampMixin + + +class OrderDB(Base, TimestampMixin, SoftDeleteMixin): + """ + Order database model. + + Represents the lifecycle of a single customer purchase. + + Status transitions (enforced by application layer, not DB): + DRAFT → PENDING_PAYMENT → PAID → READY_TO_SHIP → SHIPPED + PENDING_PAYMENT / DRAFT → CANCELLED + + Columns: + id: UUID primary key. + customer_id: FK → customers.id. + status: Current order status (see OrderStatus enum). + total_amount: Order total in smallest currency unit (e.g. cents). + currency: ISO 4217 currency code (e.g. 'EUR'). + deleted_at: Soft delete timestamp — inherited from SoftDeleteMixin. + created_at: Inherited from TimestampMixin. + updated_at: Inherited from TimestampMixin. + """ + + __tablename__ = "orders" + + id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + primary_key=True, + default=uuid4, + doc="Internal unique identifier", + ) + + customer_id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("customers.id", ondelete="CASCADE"), + nullable=False, + index=True, + doc="FK to the customer who placed this order", + ) + + status: Mapped[str] = mapped_column( + String(25), + nullable=False, + default="draft", + server_default=text("'draft'"), + index=True, + doc="Order status: draft | pending_payment | paid | ready_to_ship | shipped | cancelled", + ) + + total_amount: Mapped[int] = mapped_column( + BigInteger, + nullable=False, + doc="Order total in smallest currency unit (e.g. cents)", + ) + + currency: Mapped[str] = mapped_column( + String(3), + nullable=False, + doc="ISO 4217 currency code (e.g. 'EUR')", + ) + + __table_args__ = ( + CheckConstraint( + "total_amount >= 0", name="ck_orders_total_amount_non_negative" + ), + ) + + def __repr__(self) -> str: + return ( + f"OrderDB(id={self.id}, customer_id={self.customer_id}, " + f"status={self.status!r}, total_amount={self.total_amount})" + ) + + +class OrderItemDB(Base, TimestampMixin): + """ + Order item database model. + + Represents a single line item (SKU + quantity + snapshot price) within an order. + + Columns: + id: UUID primary key. + order_id: FK → orders.id. + sku: SKU string — price snapshot, NOT a FK to items. + quantity: Number of units ordered. + unit_price: Price per unit at time of order (in smallest currency unit). + created_at: Inherited from TimestampMixin. + updated_at: Inherited from TimestampMixin. + """ + + __tablename__ = "order_items" + + id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + primary_key=True, + default=uuid4, + doc="Internal unique identifier", + ) + + order_id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("orders.id", ondelete="CASCADE"), + nullable=False, + index=True, + doc="FK to the parent order", + ) + + sku: Mapped[str] = mapped_column( + String(100), + nullable=False, + doc="SKU at time of order — price snapshot, not a live FK", + ) + + quantity: Mapped[int] = mapped_column( + Integer, + nullable=False, + doc="Number of units ordered", + ) + + unit_price: Mapped[int] = mapped_column( + BigInteger, + nullable=False, + doc="Price per unit at checkout time (in smallest currency unit)", + ) + + __table_args__ = ( + CheckConstraint("quantity > 0", name="ck_order_items_quantity_positive"), + CheckConstraint( + "unit_price >= 0", name="ck_order_items_unit_price_non_negative" + ), + ) + + def __repr__(self) -> str: + return ( + f"OrderItemDB(id={self.id}, order_id={self.order_id}, " + f"sku={self.sku!r}, quantity={self.quantity}, unit_price={self.unit_price})" + ) diff --git a/src/app/services/orders/models/orders_models.py b/src/app/services/orders/models/orders_models.py new file mode 100644 index 0000000..b96ce03 --- /dev/null +++ b/src/app/services/orders/models/orders_models.py @@ -0,0 +1,147 @@ +""" +Orders Pydantic Schemas + +API-level input/output validation for the orders service. +""" + +from datetime import datetime +from enum import Enum +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +# ============================================================================ +# Enums +# ============================================================================ + + +class OrderStatus(str, Enum): + """ + Order lifecycle states. + + Valid transitions (enforced by application layer): + DRAFT → PENDING_PAYMENT (checkout starts) + PENDING_PAYMENT → PAID (webhook: payment_succeeded) + PENDING_PAYMENT → CANCELLED (webhook: payment_failed / timeout) + DRAFT → CANCELLED (customer cancels) + PAID → READY_TO_SHIP (shipment created) + READY_TO_SHIP → SHIPPED (handed to carrier) + """ + + DRAFT = "draft" + PENDING_PAYMENT = "pending_payment" + PAID = "paid" + READY_TO_SHIP = "ready_to_ship" + SHIPPED = "shipped" + CANCELLED = "cancelled" + + +# ============================================================================ +# OrderItem Schemas +# ============================================================================ + + +class OrderItemBase(BaseModel): + """Shared order item fields.""" + + sku: str = Field( + ..., min_length=1, max_length=100, description="SKU of the ordered item" + ) + quantity: int = Field(..., gt=0, description="Number of units ordered") + unit_price: int = Field( + ..., ge=0, description="Price per unit at checkout time (in cents)" + ) + + +class OrderItemCreate(BaseModel): + """ + Schema for adding a line item during order creation. + + unit_price is NOT provided by the client — it is looked up from the + item service at checkout time and stored as an immutable snapshot. + """ + + sku: str = Field( + ..., min_length=1, max_length=100, description="SKU of the item to order" + ) + quantity: int = Field(..., gt=0, description="Number of units to order") + + +class OrderItemResponse(OrderItemBase): + """Order item response schema.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID = Field(..., description="Internal order item UUID") + order_id: UUID = Field(..., description="UUID of the parent order") + created_at: datetime = Field(..., description="Record creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + + +# ============================================================================ +# Order Schemas +# ============================================================================ + + +class OrderBase(BaseModel): + """Shared order fields.""" + + customer_id: UUID = Field(..., description="UUID of the customer") + currency: str = Field( + ..., + min_length=3, + max_length=3, + description="ISO 4217 currency code (e.g. 'EUR')", + ) + + +class OrderCreate(BaseModel): + """ + Schema for creating a new draft order. + + customer_id is injected from the Keycloak token — never from the + request body — to prevent customers from creating orders for others. + """ + + items: list[OrderItemCreate] = Field( + ..., + min_length=1, + description="Line items to include in the order", + ) + currency: str = Field( + default="EUR", + min_length=3, + max_length=3, + description="ISO 4217 currency code", + ) + + +class OrderUpdate(BaseModel): + """Schema for status updates. Used by admin endpoints.""" + + status: OrderStatus | None = None + + +class OrderResponse(OrderBase): + """Order summary response (list view).""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID = Field(..., description="Internal order UUID") + status: OrderStatus = Field(..., description="Current order status") + total_amount: int = Field(..., description="Order total in cents") + created_at: datetime = Field(..., description="Record creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + deleted_at: datetime | None = Field( + default=None, description="Soft delete timestamp" + ) + + +class OrderDetailResponse(OrderResponse): + """Order detail response (single order view) — includes line items.""" + + items: list[OrderItemResponse] = Field( + default_factory=list, + description="Line items of this order", + ) diff --git a/src/app/services/orders/services/__init__.py b/src/app/services/orders/services/__init__.py new file mode 100644 index 0000000..27f203d --- /dev/null +++ b/src/app/services/orders/services/__init__.py @@ -0,0 +1,17 @@ +""" +Orders Services Package +""" + +from .orders_db_service import ( + OrderItemRepository, + OrderRepository, + get_order_item_repository, + get_order_repository, +) + +__all__ = [ + "OrderRepository", + "OrderItemRepository", + "get_order_repository", + "get_order_item_repository", +] diff --git a/src/app/services/orders/services/orders_db_service.py b/src/app/services/orders/services/orders_db_service.py new file mode 100644 index 0000000..28aa755 --- /dev/null +++ b/src/app/services/orders/services/orders_db_service.py @@ -0,0 +1,129 @@ +""" +Orders Database Services + +Repositories for orders and order line items. + +Note: Status transition validation belongs in functions/ (Phase 1), not here. +The repositories are intentionally "dumb" — they perform data access only. +""" + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.shared.database.repository import BaseRepository +from app.shared.logger import get_logger +from ..models.orders_db_models import OrderDB, OrderItemDB +from ..models.orders_models import OrderStatus + +logger = get_logger(__name__) + + +class OrderRepository(BaseRepository[OrderDB]): + """ + Repository for order database operations. + + All list queries automatically exclude soft-deleted orders + (deleted_at IS NOT NULL). Use get() directly if you need + to access a deleted record for audit purposes. + """ + + def __init__(self, session: AsyncSession) -> None: + super().__init__(OrderDB, session) + + async def get_by_customer( + self, + customer_id: UUID, + skip: int = 0, + limit: int = 50, + ) -> list[OrderDB]: + """ + Return all non-deleted orders for a specific customer, newest first. + + Used for the customer-facing order history endpoint. + + Args: + customer_id: UUID of the customer. + skip: Pagination offset. + limit: Maximum number of results. + + Returns: + List of OrderDB instances (soft-deleted orders excluded). + """ + logger.debug( + "Getting orders for customer", + extra={"customer_id": str(customer_id), "skip": skip, "limit": limit}, + ) + stmt = ( + select(self.model) + .where( + self.model.customer_id == customer_id, + self.model.deleted_at.is_(None), + ) + .order_by(self.model.created_at.desc()) + .offset(skip) + .limit(limit) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def get_by_status( + self, + status: OrderStatus, + skip: int = 0, + limit: int = 50, + ) -> list[OrderDB]: + """ + Return all non-deleted orders with a given status, newest first. + + Used for admin order management (filter by status). + + Args: + status: OrderStatus enum value to filter by. + skip: Pagination offset. + limit: Maximum number of results. + + Returns: + List of OrderDB instances (soft-deleted orders excluded). + """ + logger.debug( + "Getting orders by status", + extra={"status": status.value, "skip": skip, "limit": limit}, + ) + stmt = ( + select(self.model) + .where( + self.model.status == status.value, + self.model.deleted_at.is_(None), + ) + .order_by(self.model.created_at.desc()) + .offset(skip) + .limit(limit) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + +class OrderItemRepository(BaseRepository[OrderItemDB]): + """ + Repository for order item database operations. + """ + + def __init__(self, session: AsyncSession) -> None: + super().__init__(OrderItemDB, session) + + +# --------------------------------------------------------------------------- +# Dependency injection factories +# --------------------------------------------------------------------------- + + +def get_order_repository(session: AsyncSession) -> OrderRepository: + """Factory for OrderRepository — use with FastAPI Depends.""" + return OrderRepository(session) + + +def get_order_item_repository(session: AsyncSession) -> OrderItemRepository: + """Factory for OrderItemRepository — use with FastAPI Depends.""" + return OrderItemRepository(session) diff --git a/src/app/services/payments/__init__.py b/src/app/services/payments/__init__.py new file mode 100644 index 0000000..628e920 --- /dev/null +++ b/src/app/services/payments/__init__.py @@ -0,0 +1,5 @@ +""" +Payments Service Package + +Handles payment records and status tracking. +""" diff --git a/src/app/services/payments/models/__init__.py b/src/app/services/payments/models/__init__.py new file mode 100644 index 0000000..d19d7a7 --- /dev/null +++ b/src/app/services/payments/models/__init__.py @@ -0,0 +1,23 @@ +""" +Payments Models Package +""" + +from .payments_db_models import PaymentDB +from .payments_models import ( + PaymentBase, + PaymentCreate, + PaymentProvider, + PaymentResponse, + PaymentStatus, + PaymentUpdate, +) + +__all__ = [ + "PaymentDB", + "PaymentStatus", + "PaymentProvider", + "PaymentBase", + "PaymentCreate", + "PaymentUpdate", + "PaymentResponse", +] diff --git a/src/app/services/payments/models/payments_db_models.py b/src/app/services/payments/models/payments_db_models.py new file mode 100644 index 0000000..f1d62a9 --- /dev/null +++ b/src/app/services/payments/models/payments_db_models.py @@ -0,0 +1,98 @@ +""" +Payments Database Models + +SQLAlchemy ORM model for payment records. + +Design decisions: +- One payment per order (UNIQUE on order_id). A new payment record is created + for a retry, but the old one is retained for audit purposes. +- provider_reference is UNIQUE to ensure a single PSP transaction maps to + exactly one payment record — critical for idempotent webhook handling. +- order_id is a hard FK to orders.id. +""" + +from uuid import UUID, uuid4 + +from sqlalchemy import BigInteger, ForeignKey, String, UniqueConstraint, text +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.database.base import Base, TimestampMixin + + +class PaymentDB(Base, TimestampMixin): + """ + Payment database model. + + Tracks the payment intent/session created with the PSP and its outcome. + + Columns: + id: UUID primary key. + order_id: FK → orders.id. UNIQUE (one payment per order). + provider: PSP provider name (e.g. 'stripe'). + provider_reference: PSP-side ID (e.g. Stripe PaymentIntent ID). UNIQUE. + amount: Charged amount in smallest currency unit (e.g. cents). + currency: ISO 4217 currency code (e.g. 'EUR'). + status: Payment status (see PaymentStatus enum). + created_at: Inherited from TimestampMixin. + updated_at: Inherited from TimestampMixin. + """ + + __tablename__ = "payments" + + id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + primary_key=True, + default=uuid4, + doc="Internal unique identifier", + ) + + order_id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("orders.id", ondelete="RESTRICT"), + nullable=False, + doc="FK to the associated order — one payment per order (indexed via UniqueConstraint)", + ) + + provider: Mapped[str] = mapped_column( + String(50), + nullable=False, + doc="PSP provider (e.g. 'stripe')", + ) + + provider_reference: Mapped[str] = mapped_column( + String(255), + unique=True, + nullable=False, + index=True, + doc="PSP-side transaction ID (e.g. Stripe pi_xxx) — unique for idempotency", + ) + + amount: Mapped[int] = mapped_column( + BigInteger, + nullable=False, + doc="Charged amount in smallest currency unit (e.g. cents)", + ) + + currency: Mapped[str] = mapped_column( + String(3), + nullable=False, + doc="ISO 4217 currency code (e.g. 'EUR')", + ) + + status: Mapped[str] = mapped_column( + String(20), + nullable=False, + default="pending", + server_default=text("'pending'"), + index=True, + doc="Payment status: pending | succeeded | failed | refunded", + ) + + __table_args__ = (UniqueConstraint("order_id", name="uq_payments_order_id"),) + + def __repr__(self) -> str: + return ( + f"PaymentDB(id={self.id}, order_id={self.order_id}, " + f"provider={self.provider!r}, status={self.status!r})" + ) diff --git a/src/app/services/payments/models/payments_models.py b/src/app/services/payments/models/payments_models.py new file mode 100644 index 0000000..2a2da0c --- /dev/null +++ b/src/app/services/payments/models/payments_models.py @@ -0,0 +1,69 @@ +""" +Payments Pydantic Schemas +""" + +from datetime import datetime +from enum import Enum +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +# ============================================================================ +# Enums +# ============================================================================ + + +class PaymentStatus(str, Enum): + """Payment lifecycle states.""" + + PENDING = "pending" + SUCCEEDED = "succeeded" + FAILED = "failed" + REFUNDED = "refunded" + + +class PaymentProvider(str, Enum): + """Supported payment service providers.""" + + STRIPE = "stripe" + + +# ============================================================================ +# Payment Schemas +# ============================================================================ + + +class PaymentBase(BaseModel): + """Shared payment fields.""" + + order_id: UUID = Field(..., description="UUID of the associated order") + provider: PaymentProvider = Field(..., description="PSP provider") + provider_reference: str = Field(..., description="PSP-side transaction ID") + amount: int = Field(..., ge=0, description="Charged amount in cents") + currency: str = Field( + ..., min_length=3, max_length=3, description="ISO 4217 currency code" + ) + + +class PaymentCreate(PaymentBase): + """Schema for creating a new payment record (called internally when PSP session is created).""" + + pass + + +class PaymentUpdate(BaseModel): + """Schema for updating payment status — used by the webhook handler.""" + + status: PaymentStatus | None = None + + +class PaymentResponse(PaymentBase): + """Payment response schema.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID = Field(..., description="Internal payment UUID") + status: PaymentStatus = Field(..., description="Current payment status") + created_at: datetime = Field(..., description="Record creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") diff --git a/src/app/services/payments/services/__init__.py b/src/app/services/payments/services/__init__.py new file mode 100644 index 0000000..99c6efa --- /dev/null +++ b/src/app/services/payments/services/__init__.py @@ -0,0 +1,7 @@ +""" +Payments Services Package +""" + +from .payments_db_service import PaymentRepository, get_payment_repository + +__all__ = ["PaymentRepository", "get_payment_repository"] diff --git a/src/app/services/payments/services/payments_db_service.py b/src/app/services/payments/services/payments_db_service.py new file mode 100644 index 0000000..2cc3577 --- /dev/null +++ b/src/app/services/payments/services/payments_db_service.py @@ -0,0 +1,29 @@ +""" +Payments Database Services + +Repository for payment data access. +""" + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.shared.database.repository import BaseRepository +from ..models.payments_db_models import PaymentDB + + +class PaymentRepository(BaseRepository[PaymentDB]): + """ + Repository for payment database operations. + """ + + def __init__(self, session: AsyncSession) -> None: + super().__init__(PaymentDB, session) + + +# --------------------------------------------------------------------------- +# Dependency injection factory +# --------------------------------------------------------------------------- + + +def get_payment_repository(session: AsyncSession) -> PaymentRepository: + """Factory for PaymentRepository — use with FastAPI Depends.""" + return PaymentRepository(session) diff --git a/src/app/services/shipments/__init__.py b/src/app/services/shipments/__init__.py new file mode 100644 index 0000000..afc704f --- /dev/null +++ b/src/app/services/shipments/__init__.py @@ -0,0 +1,5 @@ +""" +Shipments Service Package + +Handles shipment records and carrier label tracking. +""" diff --git a/src/app/services/shipments/models/__init__.py b/src/app/services/shipments/models/__init__.py new file mode 100644 index 0000000..94f1f92 --- /dev/null +++ b/src/app/services/shipments/models/__init__.py @@ -0,0 +1,25 @@ +""" +Shipments Models Package +""" + +from .shipments_db_models import ShipmentDB +from .shipments_models import ( + Carrier, + LabelFormat, + ShipmentBase, + ShipmentCreate, + ShipmentResponse, + ShipmentStatus, + ShipmentUpdate, +) + +__all__ = [ + "ShipmentDB", + "Carrier", + "LabelFormat", + "ShipmentStatus", + "ShipmentBase", + "ShipmentCreate", + "ShipmentUpdate", + "ShipmentResponse", +] diff --git a/src/app/services/shipments/models/shipments_db_models.py b/src/app/services/shipments/models/shipments_db_models.py new file mode 100644 index 0000000..abf562c --- /dev/null +++ b/src/app/services/shipments/models/shipments_db_models.py @@ -0,0 +1,97 @@ +""" +Shipments Database Models + +SQLAlchemy ORM model for shipment records. + +Design decisions: +- One shipment per order (UNIQUE on order_id). +- tracking_number and label_url are nullable — they are populated once the + carrier label is created (either manually in Phase 2 or via DHL in Phase 3). +- The CarrierAdapter interface (Phase 3) will write to this table via the + ShipmentRepository — the DB model does not need to change between phases. +""" + +from uuid import UUID, uuid4 + +from sqlalchemy import ForeignKey, String, Text, UniqueConstraint, text +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.database.base import Base, TimestampMixin + + +class ShipmentDB(Base, TimestampMixin): + """ + Shipment database model. + + Created when an admin marks an order as ready to ship. + Stores carrier, tracking info and label reference. + + Columns: + id: UUID primary key. + order_id: FK → orders.id. UNIQUE (one shipment per order). + carrier: Carrier name (see Carrier enum, e.g. 'dhl' | 'manual'). + tracking_number: Carrier tracking number — NULL until label is created. + label_url: URL/path to label file in storage — NULL until created. + label_format: Label format: 'pdf' | 'zpl' | NULL. + status: Shipment status (see ShipmentStatus enum). + created_at: Inherited from TimestampMixin. + updated_at: Inherited from TimestampMixin. + """ + + __tablename__ = "shipments" + + id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + primary_key=True, + default=uuid4, + doc="Internal unique identifier", + ) + + order_id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("orders.id", ondelete="RESTRICT"), + nullable=False, + doc="FK to the associated order — one shipment per order (indexed via UniqueConstraint)", + ) + + carrier: Mapped[str] = mapped_column( + String(50), + nullable=False, + doc="Carrier identifier: 'manual' | 'dhl'", + ) + + tracking_number: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + doc="Carrier tracking number — populated after label creation", + ) + + label_url: Mapped[str | None] = mapped_column( + Text, + nullable=True, + doc="URL or storage path to the label file", + ) + + label_format: Mapped[str | None] = mapped_column( + String(10), + nullable=True, + doc="Label file format: 'pdf' | 'zpl' | NULL", + ) + + status: Mapped[str] = mapped_column( + String(25), + nullable=False, + default="pending", + server_default=text("'pending'"), + index=True, + doc="Shipment status: pending | label_created | handed_over", + ) + + __table_args__ = (UniqueConstraint("order_id", name="uq_shipments_order_id"),) + + def __repr__(self) -> str: + return ( + f"ShipmentDB(id={self.id}, order_id={self.order_id}, " + f"carrier={self.carrier!r}, status={self.status!r})" + ) diff --git a/src/app/services/shipments/models/shipments_models.py b/src/app/services/shipments/models/shipments_models.py new file mode 100644 index 0000000..9b9d178 --- /dev/null +++ b/src/app/services/shipments/models/shipments_models.py @@ -0,0 +1,100 @@ +""" +Shipments Pydantic Schemas +""" + +from datetime import datetime +from enum import Enum +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +# ============================================================================ +# Enums +# ============================================================================ + + +class Carrier(str, Enum): + """Supported shipping carriers.""" + + MANUAL = "manual" + DHL = "dhl" + + +class LabelFormat(str, Enum): + """Supported shipping label formats.""" + + PDF = "pdf" + ZPL = "zpl" + + +class ShipmentStatus(str, Enum): + """ + Shipment lifecycle states. + + PENDING → shipment record created, no label yet + LABEL_CREATED → carrier label generated, ready for pick & pack + HANDED_OVER → physically handed to carrier / pickup scan confirmed + """ + + PENDING = "pending" + LABEL_CREATED = "label_created" + HANDED_OVER = "handed_over" + + +# ============================================================================ +# Shipment Schemas +# ============================================================================ + + +class ShipmentBase(BaseModel): + """Shared shipment fields.""" + + order_id: UUID = Field(..., description="UUID of the associated order") + carrier: Carrier = Field(..., description="Shipping carrier") + + +class ShipmentCreate(ShipmentBase): + """ + Schema for creating a new shipment record. + + tracking_number may be provided immediately for manual carriers. + For automated carriers (DHL), it is populated later by the label job. + """ + + tracking_number: str | None = Field( + default=None, description="Carrier tracking number" + ) + label_url: str | None = Field( + default=None, description="Label file URL/path in storage" + ) + label_format: LabelFormat | None = Field( + default=None, description="Label file format" + ) + + +class ShipmentUpdate(BaseModel): + """Schema for updating a shipment record (e.g. after label creation).""" + + tracking_number: str | None = None + label_url: str | None = None + label_format: LabelFormat | None = None + status: ShipmentStatus | None = None + + +class ShipmentResponse(ShipmentBase): + """Shipment response schema.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID = Field(..., description="Internal shipment UUID") + tracking_number: str | None = Field( + default=None, description="Carrier tracking number" + ) + label_url: str | None = Field(default=None, description="Label file URL/path") + label_format: LabelFormat | None = Field( + default=None, description="Label file format" + ) + status: ShipmentStatus = Field(..., description="Current shipment status") + created_at: datetime = Field(..., description="Record creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") diff --git a/src/app/services/shipments/services/__init__.py b/src/app/services/shipments/services/__init__.py new file mode 100644 index 0000000..b3c7d05 --- /dev/null +++ b/src/app/services/shipments/services/__init__.py @@ -0,0 +1,7 @@ +""" +Shipments Services Package +""" + +from .shipments_db_service import ShipmentRepository, get_shipment_repository + +__all__ = ["ShipmentRepository", "get_shipment_repository"] diff --git a/src/app/services/shipments/services/shipments_db_service.py b/src/app/services/shipments/services/shipments_db_service.py new file mode 100644 index 0000000..6148ab8 --- /dev/null +++ b/src/app/services/shipments/services/shipments_db_service.py @@ -0,0 +1,29 @@ +""" +Shipments Database Services + +Repository for shipment data access. +""" + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.shared.database.repository import BaseRepository +from ..models.shipments_db_models import ShipmentDB + + +class ShipmentRepository(BaseRepository[ShipmentDB]): + """ + Repository for shipment database operations. + """ + + def __init__(self, session: AsyncSession) -> None: + super().__init__(ShipmentDB, session) + + +# --------------------------------------------------------------------------- +# Dependency injection factory +# --------------------------------------------------------------------------- + + +def get_shipment_repository(session: AsyncSession) -> ShipmentRepository: + """Factory for ShipmentRepository — use with FastAPI Depends.""" + return ShipmentRepository(session) diff --git a/src/app/services/webhooks/__init__.py b/src/app/services/webhooks/__init__.py new file mode 100644 index 0000000..d89b7d2 --- /dev/null +++ b/src/app/services/webhooks/__init__.py @@ -0,0 +1,5 @@ +""" +Webhooks Service Package + +Handles incoming webhook events and idempotency. +""" diff --git a/src/app/services/webhooks/models/__init__.py b/src/app/services/webhooks/models/__init__.py new file mode 100644 index 0000000..4e0c809 --- /dev/null +++ b/src/app/services/webhooks/models/__init__.py @@ -0,0 +1,12 @@ +""" +Webhooks Models Package +""" + +from .webhooks_db_models import WebhookEventDB +from .webhooks_models import WebhookEventCreate, WebhookEventResponse + +__all__ = [ + "WebhookEventDB", + "WebhookEventCreate", + "WebhookEventResponse", +] diff --git a/src/app/services/webhooks/models/webhooks_db_models.py b/src/app/services/webhooks/models/webhooks_db_models.py new file mode 100644 index 0000000..c053a4e --- /dev/null +++ b/src/app/services/webhooks/models/webhooks_db_models.py @@ -0,0 +1,95 @@ +""" +Webhooks Database Models + +SQLAlchemy ORM model for the webhook event inbox. + +Purpose: Idempotency. Before processing any incoming webhook event, the +handler checks this table. If the (provider, event_id) pair already exists, +the event is a duplicate and the handler returns 200 immediately without +side effects. Otherwise it inserts a row and processes the event inside the +same DB transaction — guaranteeing exactly-once processing. +""" + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import DateTime, String, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.database.base import Base + + +class WebhookEventDB(Base): + """ + Webhook event inbox model. + + Stores every received and accepted webhook event for idempotency and audit. + + Columns: + id: UUID primary key. + provider: Source of the webhook (e.g. 'stripe'). + event_id: Provider-side event ID (e.g. Stripe evt_xxx). + payload: Raw event payload stored as JSONB for auditability. + processed_at: Timestamp when the event was successfully processed. + NULL means the event was received but processing failed. + created_at: When the event was first received. + + Constraints: + UNIQUE(provider, event_id) — prevents duplicate processing. + """ + + __tablename__ = "webhook_events" + + id: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + primary_key=True, + default=uuid4, + doc="Internal unique identifier", + ) + + provider: Mapped[str] = mapped_column( + String(50), + nullable=False, + index=True, + doc="Webhook source provider (e.g. 'stripe')", + ) + + event_id: Mapped[str] = mapped_column( + String(255), + nullable=False, + index=True, + doc="Provider-side event ID — unique per provider", + ) + + payload: Mapped[dict] = mapped_column( + JSONB, + nullable=False, + doc="Raw event payload for auditability", + ) + + processed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + default=None, + doc="When the event was successfully processed (NULL = not yet processed)", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + doc="When the event record was created", + ) + + __table_args__ = ( + UniqueConstraint( + "provider", "event_id", name="uq_webhook_events_provider_event_id" + ), + ) + + def __repr__(self) -> str: + return ( + f"WebhookEventDB(id={self.id}, provider={self.provider!r}, " + f"event_id={self.event_id!r}, processed_at={self.processed_at})" + ) diff --git a/src/app/services/webhooks/models/webhooks_models.py b/src/app/services/webhooks/models/webhooks_models.py new file mode 100644 index 0000000..9aa2d97 --- /dev/null +++ b/src/app/services/webhooks/models/webhooks_models.py @@ -0,0 +1,35 @@ +""" +Webhooks Pydantic Schemas +""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class WebhookEventCreate(BaseModel): + """ + Schema for inserting a new webhook event record. + + Used internally by the webhook handler — never exposed to the public API. + """ + + provider: str = Field(..., description="Webhook source provider (e.g. 'stripe')") + event_id: str = Field(..., description="Provider-side event ID") + payload: dict = Field(..., description="Raw event payload") + + +class WebhookEventResponse(BaseModel): + """Webhook event response schema — for audit/admin views.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID = Field(..., description="Internal event UUID") + provider: str = Field(..., description="Webhook source provider") + event_id: str = Field(..., description="Provider-side event ID") + payload: dict = Field(..., description="Raw event payload") + processed_at: datetime | None = Field( + default=None, description="Processing timestamp" + ) + created_at: datetime = Field(..., description="Record creation timestamp") diff --git a/src/app/services/webhooks/services/__init__.py b/src/app/services/webhooks/services/__init__.py new file mode 100644 index 0000000..3a44a8a --- /dev/null +++ b/src/app/services/webhooks/services/__init__.py @@ -0,0 +1,7 @@ +""" +Webhooks Services Package +""" + +from .webhooks_db_service import WebhookEventRepository, get_webhook_event_repository + +__all__ = ["WebhookEventRepository", "get_webhook_event_repository"] diff --git a/src/app/services/webhooks/services/webhooks_db_service.py b/src/app/services/webhooks/services/webhooks_db_service.py new file mode 100644 index 0000000..7072a54 --- /dev/null +++ b/src/app/services/webhooks/services/webhooks_db_service.py @@ -0,0 +1,29 @@ +""" +Webhooks Database Services + +Repository for webhook event inbox. +""" + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.shared.database.repository import BaseRepository +from ..models.webhooks_db_models import WebhookEventDB + + +class WebhookEventRepository(BaseRepository[WebhookEventDB]): + """ + Repository for webhook event inbox operations. + """ + + def __init__(self, session: AsyncSession) -> None: + super().__init__(WebhookEventDB, session) + + +# --------------------------------------------------------------------------- +# Dependency injection factory +# --------------------------------------------------------------------------- + + +def get_webhook_event_repository(session: AsyncSession) -> WebhookEventRepository: + """Factory for WebhookEventRepository — use with FastAPI Depends.""" + return WebhookEventRepository(session) diff --git a/tests/test_responses_module.py b/tests/test_responses_module.py index 00e45be..6ac76f6 100644 --- a/tests/test_responses_module.py +++ b/tests/test_responses_module.py @@ -247,8 +247,8 @@ def test_validation_error_response_defaults(): assert response.success is False assert response.status_code == 422 - assert response.error_code == "VALIDATION_ERROR" - assert response.error_category == "VALIDATION" + assert response.error_code == "invalid_input" + assert response.error_category == "validation" def test_validation_error_response_with_errors(): diff --git a/uv.lock b/uv.lock index ecb79cb..468ad7d 100644 --- a/uv.lock +++ b/uv.lock @@ -329,6 +329,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "fastapi" version = "0.124.0" @@ -353,6 +375,7 @@ dependencies = [ { name = "asyncpg" }, { name = "authlib" }, { name = "cryptography" }, + { name = "email-validator" }, { name = "fastapi" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -371,6 +394,7 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.31.0" }, { name = "authlib", specifier = ">=1.6.5" }, { name = "cryptography", specifier = ">=46.0.3" }, + { name = "email-validator", specifier = ">=2.2.0" }, { name = "fastapi", specifier = ">=0.124.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic-settings", specifier = ">=2.12.0" },