feat(loyalty): transaction categories (what was sold)
Some checks failed
Some checks failed
Merchants can configure per-store product categories (e.g., Men,
Women, Accessories, Kids) that sellers select when entering loyalty
transactions. Enables per-category sales analytics.
Backend:
- New model: StoreTransactionCategory (store-scoped, max 10 per store)
- Migration loyalty_007: creates table + adds category_id FK on
loyalty_transactions
- New category_service.py with CRUD + validation
- New schemas/category.py (Create, Update, Response, ListResponse)
- Admin CRUD: GET/POST/PATCH/DELETE /admin/loyalty/stores/{id}/categories
- Store CRUD: GET/POST/PATCH/DELETE /store/loyalty/categories
- Stamp/Points request schemas accept optional category_id
- Stamp/Points services pass category_id to transaction creation
- TransactionResponse includes category_id + category_name
342 tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||||
@@ -49,6 +49,10 @@ from app.modules.loyalty.models.staff_pin import (
|
|||||||
# Model
|
# Model
|
||||||
StaffPin,
|
StaffPin,
|
||||||
)
|
)
|
||||||
|
from app.modules.loyalty.models.transaction_category import (
|
||||||
|
# Model
|
||||||
|
StoreTransactionCategory,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Enums
|
# Enums
|
||||||
@@ -62,4 +66,5 @@ __all__ = [
|
|||||||
"StaffPin",
|
"StaffPin",
|
||||||
"AppleDeviceRegistration",
|
"AppleDeviceRegistration",
|
||||||
"MerchantLoyaltySettings",
|
"MerchantLoyaltySettings",
|
||||||
|
"StoreTransactionCategory",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ class LoyaltyTransaction(Base, TimestampMixin):
|
|||||||
index=True,
|
index=True,
|
||||||
comment="Staff PIN used for this operation",
|
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 (for voids/returns)
|
||||||
related_transaction_id = Column(
|
related_transaction_id = Column(
|
||||||
|
|||||||
44
app/modules/loyalty/models/transaction_category.py
Normal file
44
app/modules/loyalty/models/transaction_category.py
Normal file
@@ -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"<StoreTransactionCategory(id={self.id}, store={self.store_id}, name='{self.name}')>"
|
||||||
@@ -497,6 +497,79 @@ def get_platform_stats(
|
|||||||
return program_service.get_platform_stats(db)
|
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
|
# Advanced Analytics
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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
|
# Staff PINs
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -684,6 +762,7 @@ def add_stamp(
|
|||||||
qr_code=data.qr_code,
|
qr_code=data.qr_code,
|
||||||
card_number=data.card_number,
|
card_number=data.card_number,
|
||||||
staff_pin=data.staff_pin,
|
staff_pin=data.staff_pin,
|
||||||
|
category_id=data.category_id,
|
||||||
ip_address=ip,
|
ip_address=ip,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
notes=data.notes,
|
notes=data.notes,
|
||||||
@@ -774,6 +853,7 @@ def earn_points(
|
|||||||
purchase_amount_cents=data.purchase_amount_cents,
|
purchase_amount_cents=data.purchase_amount_cents,
|
||||||
order_reference=data.order_reference,
|
order_reference=data.order_reference,
|
||||||
staff_pin=data.staff_pin,
|
staff_pin=data.staff_pin,
|
||||||
|
category_id=data.category_id,
|
||||||
ip_address=ip,
|
ip_address=ip,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
notes=data.notes,
|
notes=data.notes,
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ class TransactionResponse(BaseModel):
|
|||||||
order_reference: str | None = None
|
order_reference: str | None = None
|
||||||
reward_id: str | None = None
|
reward_id: str | None = None
|
||||||
reward_description: str | None = None
|
reward_description: str | None = None
|
||||||
|
category_id: int | None = None
|
||||||
|
category_name: str | None = None
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
|
|
||||||
# Customer
|
# Customer
|
||||||
|
|||||||
42
app/modules/loyalty/schemas/category.py
Normal file
42
app/modules/loyalty/schemas/category.py
Normal file
@@ -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
|
||||||
@@ -47,6 +47,12 @@ class PointsEarnRequest(BaseModel):
|
|||||||
description="Staff PIN for verification",
|
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
|
# Optional metadata
|
||||||
notes: str | None = Field(
|
notes: str | None = Field(
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ class StampRequest(BaseModel):
|
|||||||
description="Staff PIN for verification",
|
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
|
# Optional metadata
|
||||||
notes: str | None = Field(
|
notes: str | None = Field(
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -836,6 +836,8 @@ class CardService:
|
|||||||
"transaction_at": tx.transaction_at.isoformat() if tx.transaction_at else None,
|
"transaction_at": tx.transaction_at.isoformat() if tx.transaction_at else None,
|
||||||
"notes": tx.notes,
|
"notes": tx.notes,
|
||||||
"store_name": None,
|
"store_name": None,
|
||||||
|
"category_id": tx.category_id,
|
||||||
|
"category_name": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
if tx.store_id:
|
if tx.store_id:
|
||||||
@@ -843,6 +845,16 @@ class CardService:
|
|||||||
if store_obj:
|
if store_obj:
|
||||||
tx_data["store_name"] = store_obj.name
|
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)
|
tx_responses.append(tx_data)
|
||||||
|
|
||||||
return tx_responses, total
|
return tx_responses, total
|
||||||
|
|||||||
160
app/modules/loyalty/services/category_service.py
Normal file
160
app/modules/loyalty/services/category_service.py
Normal file
@@ -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()
|
||||||
@@ -48,6 +48,7 @@ class PointsService:
|
|||||||
purchase_amount_cents: int,
|
purchase_amount_cents: int,
|
||||||
order_reference: str | None = None,
|
order_reference: str | None = None,
|
||||||
staff_pin: str | None = None,
|
staff_pin: str | None = None,
|
||||||
|
category_id: int | None = None,
|
||||||
ip_address: str | None = None,
|
ip_address: str | None = None,
|
||||||
user_agent: str | None = None,
|
user_agent: str | None = None,
|
||||||
notes: str | None = None,
|
notes: str | None = None,
|
||||||
@@ -181,6 +182,7 @@ class PointsService:
|
|||||||
card_id=card.id,
|
card_id=card.id,
|
||||||
store_id=store_id,
|
store_id=store_id,
|
||||||
staff_pin_id=verified_pin.id if verified_pin else None,
|
staff_pin_id=verified_pin.id if verified_pin else None,
|
||||||
|
category_id=category_id,
|
||||||
transaction_type=TransactionType.POINTS_EARNED.value,
|
transaction_type=TransactionType.POINTS_EARNED.value,
|
||||||
points_delta=points_earned,
|
points_delta=points_earned,
|
||||||
stamps_balance_after=card.stamp_count,
|
stamps_balance_after=card.stamp_count,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class StampService:
|
|||||||
qr_code: str | None = None,
|
qr_code: str | None = None,
|
||||||
card_number: str | None = None,
|
card_number: str | None = None,
|
||||||
staff_pin: str | None = None,
|
staff_pin: str | None = None,
|
||||||
|
category_id: int | None = None,
|
||||||
ip_address: str | None = None,
|
ip_address: str | None = None,
|
||||||
user_agent: str | None = None,
|
user_agent: str | None = None,
|
||||||
notes: str | None = None,
|
notes: str | None = None,
|
||||||
@@ -143,6 +144,7 @@ class StampService:
|
|||||||
card_id=card.id,
|
card_id=card.id,
|
||||||
store_id=store_id,
|
store_id=store_id,
|
||||||
staff_pin_id=verified_pin.id if verified_pin else None,
|
staff_pin_id=verified_pin.id if verified_pin else None,
|
||||||
|
category_id=category_id,
|
||||||
transaction_type=TransactionType.STAMP_EARNED.value,
|
transaction_type=TransactionType.STAMP_EARNED.value,
|
||||||
stamps_delta=1,
|
stamps_delta=1,
|
||||||
stamps_balance_after=card.stamp_count,
|
stamps_balance_after=card.stamp_count,
|
||||||
|
|||||||
Reference in New Issue
Block a user