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:
@@ -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
|
||||
|
||||
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,
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user