fix(loyalty): fix runtime bugs in storefront routes, point expiration, and enforce settings

- Add total_points_voided column to LoyaltyCard with migration (loyalty_002)
- Fix storefront self_enroll to use correct service method signature and schema fields
- Fix get_my_card/get_my_transactions to use get_card_by_customer_and_merchant
- Fix transaction history field reference (balance_after -> points_balance_after)
- Fix point_expiration task: wrong field names and manual balance update -> card.expire_points()
- Register storefront_router in definition.py and export all routers from __init__.py
- Enforce MerchantLoyaltySettings in storefront enrollment, points, and stamp void operations
- Fix test fixture using non-existent balance_after column
- Suppress intentional architecture validator warnings in templates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 14:20:18 +01:00
parent c3d26e9aa4
commit 82585b1363
14 changed files with 120 additions and 34 deletions

View File

@@ -5,7 +5,13 @@ Loyalty module API routes.
Provides REST API endpoints for:
- Admin: Platform-wide loyalty program management
- Store: Store loyalty operations (stamps, points, cards)
- Public: Customer enrollment and wallet passes
- Platform: Platform administration
- Storefront: Customer enrollment and wallet passes
"""
__all__: list[str] = []
from app.modules.loyalty.routes.api.admin import admin_router
from app.modules.loyalty.routes.api.store import store_router
from app.modules.loyalty.routes.api.platform import platform_router
from app.modules.loyalty.routes.api.storefront import storefront_router
__all__ = ["admin_router", "store_router", "platform_router", "storefront_router"]

View File

@@ -13,7 +13,7 @@ Uses store from middleware context (StoreContextMiddleware).
import logging
from fastapi import APIRouter, Depends, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_api
@@ -29,7 +29,7 @@ from app.modules.loyalty.schemas import (
)
from app.modules.tenancy.exceptions import StoreNotFoundException
router = APIRouter()
storefront_router = APIRouter()
logger = logging.getLogger(__name__)
@@ -38,7 +38,7 @@ logger = logging.getLogger(__name__)
# =============================================================================
@router.get("/loyalty/program")
@storefront_router.get("/loyalty/program")
def get_program_info(
request: Request,
db: Session = Depends(get_db),
@@ -63,7 +63,7 @@ def get_program_info(
return response
@router.post("/loyalty/enroll")
@storefront_router.post("/loyalty/enroll")
def self_enroll(
request: Request,
data: CardEnrollRequest,
@@ -77,15 +77,31 @@ def self_enroll(
if not store:
raise StoreNotFoundException("context", identifier_type="subdomain")
logger.info(f"Self-enrollment for {data.customer_email} at store {store.subdomain}")
# Check if self-enrollment is allowed
settings = program_service.get_merchant_settings(db, store.merchant_id)
if settings and not settings.allow_self_enrollment:
raise HTTPException(403, "Self-enrollment is not available")
card = card_service.enroll_customer(
db,
store_id=store.id,
customer_email=data.customer_email,
customer_phone=data.customer_phone,
customer_name=data.customer_name,
)
# Resolve customer_id
customer_id = data.customer_id
if not customer_id and data.email:
from app.modules.customers.models.customer import Customer
customer = (
db.query(Customer)
.filter(Customer.email == data.email, Customer.store_id == store.id)
.first()
)
if not customer:
raise HTTPException(400, "Customer not found with provided email")
customer_id = customer.id
if not customer_id:
raise HTTPException(400, "Either customer_id or email is required")
logger.info(f"Self-enrollment for customer {customer_id} at store {store.subdomain}")
card = card_service.enroll_customer_for_store(db, customer_id, store.id)
return CardResponse.model_validate(card)
@@ -95,7 +111,7 @@ def self_enroll(
# =============================================================================
@router.get("/loyalty/card")
@storefront_router.get("/loyalty/card")
def get_my_card(
request: Request,
customer: CustomerContext = Depends(get_current_customer_api),
@@ -116,11 +132,11 @@ def get_my_card(
if not program:
return {"card": None, "program": None, "locations": []}
# Look up card by customer email
card = card_service.get_card_by_customer_email(
# Look up card by customer ID and merchant
card = card_service.get_card_by_customer_and_merchant(
db,
customer_id=customer.id,
merchant_id=program.merchant_id,
customer_email=customer.email,
)
if not card:
@@ -146,7 +162,7 @@ def get_my_card(
}
@router.get("/loyalty/transactions")
@storefront_router.get("/loyalty/transactions")
def get_my_transactions(
request: Request,
skip: int = Query(0, ge=0),
@@ -169,17 +185,16 @@ def get_my_transactions(
return {"transactions": [], "total": 0}
# Get card
card = card_service.get_card_by_customer_email(
card = card_service.get_card_by_customer_and_merchant(
db,
customer_id=customer.id,
merchant_id=program.merchant_id,
customer_email=customer.email,
)
if not card:
return {"transactions": [], "total": 0}
# Get transactions
from sqlalchemy import func
from app.modules.loyalty.models import LoyaltyTransaction
from app.modules.tenancy.models import Store as StoreModel
@@ -200,7 +215,8 @@ def get_my_transactions(
"transaction_type": tx.transaction_type.value if hasattr(tx.transaction_type, 'value') else str(tx.transaction_type),
"points_delta": tx.points_delta,
"stamps_delta": tx.stamps_delta,
"balance_after": tx.balance_after,
"points_balance_after": tx.points_balance_after,
"stamps_balance_after": tx.stamps_balance_after,
"transaction_at": tx.transaction_at.isoformat() if tx.transaction_at else None,
"notes": tx.notes,
"store_name": None,