diff --git a/app/modules/loyalty/migrations/versions/loyalty_007_add_transaction_categories.py b/app/modules/loyalty/migrations/versions/loyalty_007_add_transaction_categories.py new file mode 100644 index 00000000..f150eabf --- /dev/null +++ b/app/modules/loyalty/migrations/versions/loyalty_007_add_transaction_categories.py @@ -0,0 +1,68 @@ +"""loyalty 007 - add transaction categories + +Adds store-scoped product categories (e.g., Men, Women, Accessories) +that sellers select when entering loyalty transactions. Also adds +category_id FK on loyalty_transactions. + +Revision ID: loyalty_007 +Revises: loyalty_006 +Create Date: 2026-04-19 +""" +import sqlalchemy as sa + +from alembic import op + +revision = "loyalty_007" +down_revision = "loyalty_006" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "store_transaction_categories", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column( + "store_id", + sa.Integer(), + sa.ForeignKey("stores.id"), + nullable=False, + ), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("display_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.UniqueConstraint("store_id", "name", name="uq_store_category_name"), + ) + op.create_index( + "idx_store_category_store", + "store_transaction_categories", + ["store_id", "is_active"], + ) + + op.add_column( + "loyalty_transactions", + sa.Column( + "category_id", + sa.Integer(), + sa.ForeignKey("store_transaction_categories.id", ondelete="SET NULL"), + nullable=True, + ), + ) + + +def downgrade() -> None: + op.drop_column("loyalty_transactions", "category_id") + op.drop_index("idx_store_category_store", table_name="store_transaction_categories") + op.drop_table("store_transaction_categories") diff --git a/app/modules/loyalty/models/__init__.py b/app/modules/loyalty/models/__init__.py index cb8b2250..e1d5ef21 100644 --- a/app/modules/loyalty/models/__init__.py +++ b/app/modules/loyalty/models/__init__.py @@ -49,6 +49,10 @@ from app.modules.loyalty.models.staff_pin import ( # Model StaffPin, ) +from app.modules.loyalty.models.transaction_category import ( + # Model + StoreTransactionCategory, +) __all__ = [ # Enums @@ -62,4 +66,5 @@ __all__ = [ "StaffPin", "AppleDeviceRegistration", "MerchantLoyaltySettings", + "StoreTransactionCategory", ] diff --git a/app/modules/loyalty/models/loyalty_transaction.py b/app/modules/loyalty/models/loyalty_transaction.py index 7c0c2602..8f0b61a3 100644 --- a/app/modules/loyalty/models/loyalty_transaction.py +++ b/app/modules/loyalty/models/loyalty_transaction.py @@ -104,6 +104,12 @@ class LoyaltyTransaction(Base, TimestampMixin): index=True, comment="Staff PIN used for this operation", ) + category_id = Column( + Integer, + ForeignKey("store_transaction_categories.id", ondelete="SET NULL"), + nullable=True, + comment="Product category (e.g., Men, Women, Accessories)", + ) # Related transaction (for voids/returns) related_transaction_id = Column( diff --git a/app/modules/loyalty/models/transaction_category.py b/app/modules/loyalty/models/transaction_category.py new file mode 100644 index 00000000..9d046a62 --- /dev/null +++ b/app/modules/loyalty/models/transaction_category.py @@ -0,0 +1,44 @@ +# app/modules/loyalty/models/transaction_category.py +""" +Store-scoped transaction categories. + +Merchants configure 4-10 categories per store (e.g., Men, Women, +Accessories, Kids) that sellers select when entering transactions. +""" + +from sqlalchemy import ( + Boolean, + Column, + ForeignKey, + Index, + Integer, + String, + UniqueConstraint, +) +from sqlalchemy.orm import relationship + +from app.core.database import Base +from models.database.base import TimestampMixin + + +class StoreTransactionCategory(Base, TimestampMixin): + """Product category for loyalty transactions.""" + + __tablename__ = "store_transaction_categories" + + id = Column(Integer, primary_key=True, index=True) + store_id = Column(Integer, ForeignKey("stores.id"), nullable=False) + name = Column(String(100), nullable=False) + display_order = Column(Integer, nullable=False, default=0) + is_active = Column(Boolean, nullable=False, default=True) + + # Relationships + store = relationship("Store") + + __table_args__ = ( + UniqueConstraint("store_id", "name", name="uq_store_category_name"), + Index("idx_store_category_store", "store_id", "is_active"), + ) + + def __repr__(self): + return f"" diff --git a/app/modules/loyalty/routes/api/admin.py b/app/modules/loyalty/routes/api/admin.py index 59bc3c32..0248e593 100644 --- a/app/modules/loyalty/routes/api/admin.py +++ b/app/modules/loyalty/routes/api/admin.py @@ -497,6 +497,79 @@ def get_platform_stats( return program_service.get_platform_stats(db) +# ============================================================================= +# Transaction Categories (admin manages on behalf of stores) +# ============================================================================= + + +@router.get("/stores/{store_id}/categories") +def list_store_categories( + store_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """List transaction categories for a store.""" + from app.modules.loyalty.schemas.category import ( + CategoryListResponse, + CategoryResponse, + ) + from app.modules.loyalty.services.category_service import category_service + + categories = category_service.list_categories(db, store_id) + return CategoryListResponse( + categories=[CategoryResponse.model_validate(c) for c in categories], + total=len(categories), + ) + + +@router.post("/stores/{store_id}/categories", status_code=201) +def create_store_category( + data: dict, + store_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Create a transaction category for a store.""" + from app.modules.loyalty.schemas.category import CategoryCreate, CategoryResponse + from app.modules.loyalty.services.category_service import category_service + + category = category_service.create_category( + db, store_id, CategoryCreate(**data) + ) + return CategoryResponse.model_validate(category) + + +@router.patch("/stores/{store_id}/categories/{category_id}") +def update_store_category( + data: dict, + store_id: int = Path(..., gt=0), + category_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Update a transaction category for a store.""" + from app.modules.loyalty.schemas.category import CategoryResponse, CategoryUpdate + from app.modules.loyalty.services.category_service import category_service + + category = category_service.update_category( + db, category_id, store_id, CategoryUpdate(**data) + ) + return CategoryResponse.model_validate(category) + + +@router.delete("/stores/{store_id}/categories/{category_id}", status_code=204) +def delete_store_category( + store_id: int = Path(..., gt=0), + category_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Delete a transaction category for a store.""" + from app.modules.loyalty.services.category_service import category_service + + category_service.delete_category(db, category_id, store_id) + + # ============================================================================= # Advanced Analytics # ============================================================================= diff --git a/app/modules/loyalty/routes/api/store.py b/app/modules/loyalty/routes/api/store.py index d4d8d136..2b7bc248 100644 --- a/app/modules/loyalty/routes/api/store.py +++ b/app/modules/loyalty/routes/api/store.py @@ -244,6 +244,84 @@ def get_revenue_attribution( ) +# ============================================================================= +# Transaction Categories +# ============================================================================= + + +@router.get("/categories") +def list_categories( + current_user: User = Depends(get_current_store_api), + db: Session = Depends(get_db), +): + """List transaction categories for this store.""" + from app.modules.loyalty.schemas.category import ( + CategoryListResponse, + CategoryResponse, + ) + from app.modules.loyalty.services.category_service import category_service + + categories = category_service.list_categories(db, current_user.token_store_id) + return CategoryListResponse( + categories=[CategoryResponse.model_validate(c) for c in categories], + total=len(categories), + ) + + +@router.post("/categories", status_code=201) +def create_category( + data: dict, + current_user: User = Depends(get_current_store_api), + db: Session = Depends(get_db), +): + """Create a transaction category for this store (merchant_owner only).""" + if current_user.role != "merchant_owner": + raise AuthorizationException("Only merchant owners can manage categories") + + from app.modules.loyalty.schemas.category import CategoryCreate, CategoryResponse + from app.modules.loyalty.services.category_service import category_service + + category = category_service.create_category( + db, current_user.token_store_id, CategoryCreate(**data) + ) + return CategoryResponse.model_validate(category) + + +@router.patch("/categories/{category_id}") +def update_category( + category_id: int, + data: dict, + current_user: User = Depends(get_current_store_api), + db: Session = Depends(get_db), +): + """Update a transaction category (merchant_owner only).""" + if current_user.role != "merchant_owner": + raise AuthorizationException("Only merchant owners can manage categories") + + from app.modules.loyalty.schemas.category import CategoryResponse, CategoryUpdate + from app.modules.loyalty.services.category_service import category_service + + category = category_service.update_category( + db, category_id, current_user.token_store_id, CategoryUpdate(**data) + ) + return CategoryResponse.model_validate(category) + + +@router.delete("/categories/{category_id}", status_code=204) +def delete_category( + category_id: int, + current_user: User = Depends(get_current_store_api), + db: Session = Depends(get_db), +): + """Delete a transaction category (merchant_owner only).""" + if current_user.role != "merchant_owner": + raise AuthorizationException("Only merchant owners can manage categories") + + from app.modules.loyalty.services.category_service import category_service + + category_service.delete_category(db, category_id, current_user.token_store_id) + + # ============================================================================= # Staff PINs # ============================================================================= @@ -684,6 +762,7 @@ def add_stamp( qr_code=data.qr_code, card_number=data.card_number, staff_pin=data.staff_pin, + category_id=data.category_id, ip_address=ip, user_agent=user_agent, notes=data.notes, @@ -774,6 +853,7 @@ def earn_points( purchase_amount_cents=data.purchase_amount_cents, order_reference=data.order_reference, staff_pin=data.staff_pin, + category_id=data.category_id, ip_address=ip, user_agent=user_agent, notes=data.notes, diff --git a/app/modules/loyalty/schemas/card.py b/app/modules/loyalty/schemas/card.py index ce5a0c56..be1249b4 100644 --- a/app/modules/loyalty/schemas/card.py +++ b/app/modules/loyalty/schemas/card.py @@ -188,6 +188,8 @@ class TransactionResponse(BaseModel): order_reference: str | None = None reward_id: str | None = None reward_description: str | None = None + category_id: int | None = None + category_name: str | None = None notes: str | None = None # Customer diff --git a/app/modules/loyalty/schemas/category.py b/app/modules/loyalty/schemas/category.py new file mode 100644 index 00000000..795e4cb8 --- /dev/null +++ b/app/modules/loyalty/schemas/category.py @@ -0,0 +1,42 @@ +# app/modules/loyalty/schemas/category.py +"""Pydantic schemas for transaction categories.""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class CategoryCreate(BaseModel): + """Schema for creating a transaction category.""" + + name: str = Field(..., min_length=1, max_length=100) + display_order: int = Field(default=0, ge=0) + + +class CategoryUpdate(BaseModel): + """Schema for updating a transaction category.""" + + name: str | None = Field(None, min_length=1, max_length=100) + display_order: int | None = Field(None, ge=0) + is_active: bool | None = None + + +class CategoryResponse(BaseModel): + """Schema for transaction category response.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + store_id: int + name: str + display_order: int + is_active: bool + created_at: datetime + updated_at: datetime + + +class CategoryListResponse(BaseModel): + """Schema for listing categories.""" + + categories: list[CategoryResponse] + total: int diff --git a/app/modules/loyalty/schemas/points.py b/app/modules/loyalty/schemas/points.py index ab4ff675..ded3ebd8 100644 --- a/app/modules/loyalty/schemas/points.py +++ b/app/modules/loyalty/schemas/points.py @@ -47,6 +47,12 @@ class PointsEarnRequest(BaseModel): description="Staff PIN for verification", ) + # Category (what was sold) + category_id: int | None = Field( + None, + description="Transaction category ID (e.g., Men, Women, Accessories)", + ) + # Optional metadata notes: str | None = Field( None, diff --git a/app/modules/loyalty/schemas/stamp.py b/app/modules/loyalty/schemas/stamp.py index 740e13a3..755fe198 100644 --- a/app/modules/loyalty/schemas/stamp.py +++ b/app/modules/loyalty/schemas/stamp.py @@ -37,6 +37,12 @@ class StampRequest(BaseModel): description="Staff PIN for verification", ) + # Category (what was sold) + category_id: int | None = Field( + None, + description="Transaction category ID (e.g., Men, Women, Accessories)", + ) + # Optional metadata notes: str | None = Field( None, diff --git a/app/modules/loyalty/services/card_service.py b/app/modules/loyalty/services/card_service.py index a5d25525..7c0c78b4 100644 --- a/app/modules/loyalty/services/card_service.py +++ b/app/modules/loyalty/services/card_service.py @@ -836,6 +836,8 @@ class CardService: "transaction_at": tx.transaction_at.isoformat() if tx.transaction_at else None, "notes": tx.notes, "store_name": None, + "category_id": tx.category_id, + "category_name": None, } if tx.store_id: @@ -843,6 +845,16 @@ class CardService: if store_obj: tx_data["store_name"] = store_obj.name + if tx.category_id: + from app.modules.loyalty.services.category_service import ( + category_service, + ) + + cat_name = category_service.validate_category_for_store( + db, tx.category_id, tx.store_id or 0 + ) + tx_data["category_name"] = cat_name + tx_responses.append(tx_data) return tx_responses, total diff --git a/app/modules/loyalty/services/category_service.py b/app/modules/loyalty/services/category_service.py new file mode 100644 index 00000000..d5776d81 --- /dev/null +++ b/app/modules/loyalty/services/category_service.py @@ -0,0 +1,160 @@ +# app/modules/loyalty/services/category_service.py +""" +Transaction category CRUD service. + +Store-scoped categories (e.g., Men, Women, Accessories) that sellers +select when entering loyalty transactions. +""" + +import logging + +from sqlalchemy.orm import Session + +from app.modules.loyalty.models.transaction_category import StoreTransactionCategory +from app.modules.loyalty.schemas.category import CategoryCreate, CategoryUpdate + +logger = logging.getLogger(__name__) + +MAX_CATEGORIES_PER_STORE = 10 + + +class CategoryService: + """CRUD operations for store transaction categories.""" + + def list_categories( + self, db: Session, store_id: int, active_only: bool = False + ) -> list[StoreTransactionCategory]: + """List categories for a store, ordered by display_order.""" + query = db.query(StoreTransactionCategory).filter( + StoreTransactionCategory.store_id == store_id + ) + if active_only: + query = query.filter(StoreTransactionCategory.is_active == True) # noqa: E712 + return query.order_by(StoreTransactionCategory.display_order).all() + + def create_category( + self, db: Session, store_id: int, data: CategoryCreate + ) -> StoreTransactionCategory: + """Create a new category for a store.""" + # Check max limit + count = ( + db.query(StoreTransactionCategory) + .filter(StoreTransactionCategory.store_id == store_id) + .count() + ) + if count >= MAX_CATEGORIES_PER_STORE: + from app.modules.loyalty.exceptions import LoyaltyException + + raise LoyaltyException( + message=f"Maximum {MAX_CATEGORIES_PER_STORE} categories per store", + error_code="MAX_CATEGORIES_REACHED", + ) + + # Check duplicate name + existing = ( + db.query(StoreTransactionCategory) + .filter( + StoreTransactionCategory.store_id == store_id, + StoreTransactionCategory.name == data.name, + ) + .first() + ) + if existing: + from app.modules.loyalty.exceptions import LoyaltyException + + raise LoyaltyException( + message=f"Category '{data.name}' already exists", + error_code="DUPLICATE_CATEGORY", + ) + + category = StoreTransactionCategory( + store_id=store_id, + name=data.name, + display_order=data.display_order, + ) + db.add(category) + db.commit() + db.refresh(category) + + logger.info(f"Created category '{data.name}' for store {store_id}") + return category + + def update_category( + self, + db: Session, + category_id: int, + store_id: int, + data: CategoryUpdate, + ) -> StoreTransactionCategory: + """Update a category (ownership check via store_id).""" + category = ( + db.query(StoreTransactionCategory) + .filter( + StoreTransactionCategory.id == category_id, + StoreTransactionCategory.store_id == store_id, + ) + .first() + ) + if not category: + from app.modules.loyalty.exceptions import LoyaltyException + + raise LoyaltyException( + message="Category not found", + error_code="CATEGORY_NOT_FOUND", + status_code=404, + ) + + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(category, field, value) + + db.commit() + db.refresh(category) + return category + + def delete_category( + self, db: Session, category_id: int, store_id: int + ) -> None: + """Delete a category (ownership check via store_id).""" + category = ( + db.query(StoreTransactionCategory) + .filter( + StoreTransactionCategory.id == category_id, + StoreTransactionCategory.store_id == store_id, + ) + .first() + ) + if not category: + from app.modules.loyalty.exceptions import LoyaltyException + + raise LoyaltyException( + message="Category not found", + error_code="CATEGORY_NOT_FOUND", + status_code=404, + ) + + db.delete(category) + db.commit() + logger.info(f"Deleted category {category_id} from store {store_id}") + + def validate_category_for_store( + self, db: Session, category_id: int, store_id: int + ) -> str | None: + """Validate that a category belongs to the store. + + Returns the category name if valid, None if not found. + """ + category = ( + db.query(StoreTransactionCategory) + .filter( + StoreTransactionCategory.id == category_id, + StoreTransactionCategory.store_id == store_id, + StoreTransactionCategory.is_active == True, # noqa: E712 + ) + .first() + ) + return category.name if category else None + + +# Singleton +category_service = CategoryService() diff --git a/app/modules/loyalty/services/points_service.py b/app/modules/loyalty/services/points_service.py index f65a57d3..4d0f5cb3 100644 --- a/app/modules/loyalty/services/points_service.py +++ b/app/modules/loyalty/services/points_service.py @@ -48,6 +48,7 @@ class PointsService: purchase_amount_cents: int, order_reference: str | None = None, staff_pin: str | None = None, + category_id: int | None = None, ip_address: str | None = None, user_agent: str | None = None, notes: str | None = None, @@ -181,6 +182,7 @@ class PointsService: card_id=card.id, store_id=store_id, staff_pin_id=verified_pin.id if verified_pin else None, + category_id=category_id, transaction_type=TransactionType.POINTS_EARNED.value, points_delta=points_earned, stamps_balance_after=card.stamp_count, diff --git a/app/modules/loyalty/services/stamp_service.py b/app/modules/loyalty/services/stamp_service.py index 3fd757e6..8bd53319 100644 --- a/app/modules/loyalty/services/stamp_service.py +++ b/app/modules/loyalty/services/stamp_service.py @@ -46,6 +46,7 @@ class StampService: qr_code: str | None = None, card_number: str | None = None, staff_pin: str | None = None, + category_id: int | None = None, ip_address: str | None = None, user_agent: str | None = None, notes: str | None = None, @@ -143,6 +144,7 @@ class StampService: card_id=card.id, store_id=store_id, staff_pin_id=verified_pin.id if verified_pin else None, + category_id=category_id, transaction_type=TransactionType.STAMP_EARNED.value, stamps_delta=1, stamps_balance_after=card.stamp_count,