feat(loyalty): implement Phase 2 - company-wide points system
Complete implementation of loyalty module Phase 2 features: Database & Models: - Add company_id to LoyaltyProgram for chain-wide loyalty - Add company_id to LoyaltyCard for multi-location support - Add CompanyLoyaltySettings model for admin-controlled settings - Add points expiration, welcome bonus, and minimum redemption fields - Add POINTS_EXPIRED, WELCOME_BONUS transaction types Services: - Update program_service for company-based queries - Update card_service with enrollment and welcome bonus - Update points_service with void_points for returns - Update stamp_service for company context - Update pin_service for company-wide operations API Endpoints: - Admin: Program listing with stats, company detail views - Vendor: Terminal operations, card management, settings - Storefront: Customer card/transactions, self-enrollment UI Templates: - Admin: Programs dashboard, company detail, settings - Vendor: Terminal, cards list, card detail, settings, stats, enrollment - Storefront: Dashboard, history, enrollment, success pages Background Tasks: - Point expiration task (daily, based on inactivity) - Wallet sync task (hourly) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
216
app/modules/loyalty/routes/api/storefront.py
Normal file
216
app/modules/loyalty/routes/api/storefront.py
Normal file
@@ -0,0 +1,216 @@
|
||||
# app/modules/loyalty/routes/api/storefront.py
|
||||
"""
|
||||
Loyalty Module - Storefront API Routes
|
||||
|
||||
Customer-facing endpoints for:
|
||||
- View loyalty card and balance
|
||||
- View transaction history
|
||||
- Self-service enrollment
|
||||
- Get program information
|
||||
|
||||
Uses vendor from middleware context (VendorContextMiddleware).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_customer_api
|
||||
from app.core.database import get_db
|
||||
from app.modules.customers.schemas import CustomerContext
|
||||
from app.modules.loyalty.services import card_service, program_service
|
||||
from app.modules.loyalty.schemas import (
|
||||
CardResponse,
|
||||
CardEnrollRequest,
|
||||
TransactionListResponse,
|
||||
TransactionResponse,
|
||||
ProgramResponse,
|
||||
)
|
||||
from app.modules.tenancy.exceptions import VendorNotFoundException
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Public Endpoints (No Authentication Required)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/loyalty/program")
|
||||
def get_program_info(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get loyalty program information for current vendor.
|
||||
Public endpoint - no authentication required.
|
||||
"""
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
if not vendor:
|
||||
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||
|
||||
program = program_service.get_program_by_vendor(db, vendor.id)
|
||||
if not program:
|
||||
return None
|
||||
|
||||
response = ProgramResponse.model_validate(program)
|
||||
response.is_stamps_enabled = program.is_stamps_enabled
|
||||
response.is_points_enabled = program.is_points_enabled
|
||||
response.display_name = program.display_name
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/loyalty/enroll")
|
||||
def self_enroll(
|
||||
request: Request,
|
||||
data: CardEnrollRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Self-service enrollment.
|
||||
Public endpoint - customers can enroll via QR code without authentication.
|
||||
"""
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
if not vendor:
|
||||
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||
|
||||
logger.info(f"Self-enrollment for {data.customer_email} at vendor {vendor.subdomain}")
|
||||
|
||||
card = card_service.enroll_customer(
|
||||
db,
|
||||
vendor_id=vendor.id,
|
||||
customer_email=data.customer_email,
|
||||
customer_phone=data.customer_phone,
|
||||
customer_name=data.customer_name,
|
||||
)
|
||||
|
||||
return CardResponse.model_validate(card)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Authenticated Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/loyalty/card")
|
||||
def get_my_card(
|
||||
request: Request,
|
||||
customer: CustomerContext = Depends(get_current_customer_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get customer's loyalty card and program info.
|
||||
Returns card details, program info, and available rewards.
|
||||
"""
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
if not vendor:
|
||||
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||
|
||||
logger.debug(f"Getting loyalty card for customer {customer.id}")
|
||||
|
||||
# Get program
|
||||
program = program_service.get_program_by_vendor(db, vendor.id)
|
||||
if not program:
|
||||
return {"card": None, "program": None, "locations": []}
|
||||
|
||||
# Look up card by customer email
|
||||
card = card_service.get_card_by_customer_email(
|
||||
db,
|
||||
company_id=program.company_id,
|
||||
customer_email=customer.email,
|
||||
)
|
||||
|
||||
if not card:
|
||||
return {"card": None, "program": None, "locations": []}
|
||||
|
||||
# Get company locations
|
||||
from app.modules.tenancy.models import Vendor as VendorModel
|
||||
locations = (
|
||||
db.query(VendorModel)
|
||||
.filter(VendorModel.company_id == program.company_id, VendorModel.is_active == True)
|
||||
.all()
|
||||
)
|
||||
|
||||
program_response = ProgramResponse.model_validate(program)
|
||||
program_response.is_stamps_enabled = program.is_stamps_enabled
|
||||
program_response.is_points_enabled = program.is_points_enabled
|
||||
program_response.display_name = program.display_name
|
||||
|
||||
return {
|
||||
"card": CardResponse.model_validate(card),
|
||||
"program": program_response,
|
||||
"locations": [{"id": v.id, "name": v.name} for v in locations],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/loyalty/transactions")
|
||||
def get_my_transactions(
|
||||
request: Request,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
customer: CustomerContext = Depends(get_current_customer_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get customer's loyalty transaction history.
|
||||
"""
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
if not vendor:
|
||||
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||
|
||||
logger.debug(f"Getting transactions for customer {customer.id}")
|
||||
|
||||
# Get program
|
||||
program = program_service.get_program_by_vendor(db, vendor.id)
|
||||
if not program:
|
||||
return {"transactions": [], "total": 0}
|
||||
|
||||
# Get card
|
||||
card = card_service.get_card_by_customer_email(
|
||||
db,
|
||||
company_id=program.company_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 Vendor as VendorModel
|
||||
|
||||
query = (
|
||||
db.query(LoyaltyTransaction)
|
||||
.filter(LoyaltyTransaction.card_id == card.id)
|
||||
.order_by(LoyaltyTransaction.transaction_at.desc())
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
transactions = query.offset(skip).limit(limit).all()
|
||||
|
||||
# Build response with vendor names
|
||||
tx_responses = []
|
||||
for tx in transactions:
|
||||
tx_data = {
|
||||
"id": tx.id,
|
||||
"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,
|
||||
"transaction_at": tx.transaction_at.isoformat() if tx.transaction_at else None,
|
||||
"notes": tx.notes,
|
||||
"vendor_name": None,
|
||||
}
|
||||
|
||||
if tx.vendor_id:
|
||||
vendor_obj = db.query(VendorModel).filter(VendorModel.id == tx.vendor_id).first()
|
||||
if vendor_obj:
|
||||
tx_data["vendor_name"] = vendor_obj.name
|
||||
|
||||
tx_responses.append(tx_data)
|
||||
|
||||
return {"transactions": tx_responses, "total": total}
|
||||
Reference in New Issue
Block a user