Files
orion/app/modules/loyalty/routes/api/vendor.py
Samir Boulahtit d8f3338bc8 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>
2026-02-05 22:10:27 +01:00

702 lines
22 KiB
Python

# app/modules/loyalty/routes/api/vendor.py
"""
Loyalty module vendor routes.
Company-based vendor endpoints for:
- Program management (company-wide, managed by vendor)
- Staff PINs (per-vendor)
- Card operations (stamps, points, redemptions, voids)
- Customer cards lookup
- Dashboard stats
All operations are scoped to the vendor's company.
Cards can be used at any vendor within the same company.
"""
import logging
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, require_module_access
from app.core.database import get_db
from app.modules.loyalty.exceptions import (
LoyaltyCardNotFoundException,
LoyaltyException,
LoyaltyProgramNotFoundException,
)
from app.modules.loyalty.schemas import (
CardDetailResponse,
CardEnrollRequest,
CardListResponse,
CardLookupResponse,
CardResponse,
PinCreate,
PinListResponse,
PinResponse,
PinUpdate,
PointsEarnRequest,
PointsEarnResponse,
PointsRedeemRequest,
PointsRedeemResponse,
PointsVoidRequest,
PointsVoidResponse,
PointsAdjustRequest,
PointsAdjustResponse,
ProgramCreate,
ProgramResponse,
ProgramStatsResponse,
ProgramUpdate,
CompanyStatsResponse,
StampRedeemRequest,
StampRedeemResponse,
StampRequest,
StampResponse,
StampVoidRequest,
StampVoidResponse,
TransactionListResponse,
TransactionResponse,
)
from app.modules.loyalty.services import (
card_service,
pin_service,
points_service,
program_service,
stamp_service,
wallet_service,
)
from app.modules.enums import FrontendType
from app.modules.tenancy.models import User, Vendor
logger = logging.getLogger(__name__)
# Vendor router with module access control
vendor_router = APIRouter(
prefix="/loyalty",
dependencies=[Depends(require_module_access("loyalty", FrontendType.VENDOR))],
)
def get_client_info(request: Request) -> tuple[str | None, str | None]:
"""Extract client IP and user agent from request."""
ip = request.client.host if request.client else None
user_agent = request.headers.get("user-agent")
return ip, user_agent
def get_vendor_company_id(db: Session, vendor_id: int) -> int:
"""Get the company ID for a vendor."""
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise HTTPException(status_code=404, detail="Vendor not found")
return vendor.company_id
# =============================================================================
# Program Management
# =============================================================================
@vendor_router.get("/program", response_model=ProgramResponse)
def get_program(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get the company's loyalty program."""
vendor_id = current_user.token_vendor_id
program = program_service.get_program_by_vendor(db, vendor_id)
if not program:
raise HTTPException(status_code=404, detail="No loyalty program configured")
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
@vendor_router.post("/program", response_model=ProgramResponse, status_code=201)
def create_program(
data: ProgramCreate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Create a loyalty program for the company."""
vendor_id = current_user.token_vendor_id
company_id = get_vendor_company_id(db, vendor_id)
try:
program = program_service.create_program(db, company_id, data)
except LoyaltyException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
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
@vendor_router.patch("/program", response_model=ProgramResponse)
def update_program(
data: ProgramUpdate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Update the company's loyalty program."""
vendor_id = current_user.token_vendor_id
program = program_service.get_program_by_vendor(db, vendor_id)
if not program:
raise HTTPException(status_code=404, detail="No loyalty program configured")
program = program_service.update_program(db, program.id, data)
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
@vendor_router.get("/stats", response_model=ProgramStatsResponse)
def get_stats(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get loyalty program statistics."""
vendor_id = current_user.token_vendor_id
program = program_service.get_program_by_vendor(db, vendor_id)
if not program:
raise HTTPException(status_code=404, detail="No loyalty program configured")
stats = program_service.get_program_stats(db, program.id)
return ProgramStatsResponse(**stats)
@vendor_router.get("/stats/company", response_model=CompanyStatsResponse)
def get_company_stats(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get company-wide loyalty statistics across all locations."""
vendor_id = current_user.token_vendor_id
company_id = get_vendor_company_id(db, vendor_id)
stats = program_service.get_company_stats(db, company_id)
if "error" in stats:
raise HTTPException(status_code=404, detail=stats["error"])
return CompanyStatsResponse(**stats)
# =============================================================================
# Staff PINs
# =============================================================================
@vendor_router.get("/pins", response_model=PinListResponse)
def list_pins(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""List staff PINs for this vendor location."""
vendor_id = current_user.token_vendor_id
program = program_service.get_program_by_vendor(db, vendor_id)
if not program:
raise HTTPException(status_code=404, detail="No loyalty program configured")
# List PINs for this vendor only
pins = pin_service.list_pins(db, program.id, vendor_id=vendor_id)
return PinListResponse(
pins=[PinResponse.model_validate(pin) for pin in pins],
total=len(pins),
)
@vendor_router.post("/pins", response_model=PinResponse, status_code=201)
def create_pin(
data: PinCreate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Create a new staff PIN for this vendor location."""
vendor_id = current_user.token_vendor_id
program = program_service.get_program_by_vendor(db, vendor_id)
if not program:
raise HTTPException(status_code=404, detail="No loyalty program configured")
pin = pin_service.create_pin(db, program.id, vendor_id, data)
return PinResponse.model_validate(pin)
@vendor_router.patch("/pins/{pin_id}", response_model=PinResponse)
def update_pin(
pin_id: int = Path(..., gt=0),
data: PinUpdate = None,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Update a staff PIN."""
pin = pin_service.update_pin(db, pin_id, data)
return PinResponse.model_validate(pin)
@vendor_router.delete("/pins/{pin_id}", status_code=204)
def delete_pin(
pin_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Delete a staff PIN."""
pin_service.delete_pin(db, pin_id)
@vendor_router.post("/pins/{pin_id}/unlock", response_model=PinResponse)
def unlock_pin(
pin_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Unlock a locked staff PIN."""
pin = pin_service.unlock_pin(db, pin_id)
return PinResponse.model_validate(pin)
# =============================================================================
# Card Management
# =============================================================================
@vendor_router.get("/cards", response_model=CardListResponse)
def list_cards(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
is_active: bool | None = Query(None),
search: str | None = Query(None, max_length=100),
enrolled_here: bool = Query(False, description="Only show cards enrolled at this location"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
List loyalty cards for the company.
By default lists all cards in the company's loyalty program.
Use enrolled_here=true to filter to cards enrolled at this location.
"""
vendor_id = current_user.token_vendor_id
company_id = get_vendor_company_id(db, vendor_id)
program = program_service.get_program_by_vendor(db, vendor_id)
if not program:
raise HTTPException(status_code=404, detail="No loyalty program configured")
# Filter by enrolled_at_vendor_id if requested
filter_vendor_id = vendor_id if enrolled_here else None
cards, total = card_service.list_cards(
db,
company_id,
vendor_id=filter_vendor_id,
skip=skip,
limit=limit,
is_active=is_active,
search=search,
)
card_responses = []
for card in cards:
response = CardResponse(
id=card.id,
card_number=card.card_number,
customer_id=card.customer_id,
company_id=card.company_id,
program_id=card.program_id,
enrolled_at_vendor_id=card.enrolled_at_vendor_id,
stamp_count=card.stamp_count,
stamps_target=program.stamps_target,
stamps_until_reward=max(0, program.stamps_target - card.stamp_count),
total_stamps_earned=card.total_stamps_earned,
stamps_redeemed=card.stamps_redeemed,
points_balance=card.points_balance,
total_points_earned=card.total_points_earned,
points_redeemed=card.points_redeemed,
is_active=card.is_active,
created_at=card.created_at,
has_google_wallet=bool(card.google_object_id),
has_apple_wallet=bool(card.apple_serial_number),
)
card_responses.append(response)
return CardListResponse(cards=card_responses, total=total)
@vendor_router.post("/cards/lookup", response_model=CardLookupResponse)
def lookup_card(
request: Request,
card_id: int | None = Query(None),
qr_code: str | None = Query(None),
card_number: str | None = Query(None),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Look up a card by ID, QR code, or card number.
Card must belong to the same company as the vendor.
"""
vendor_id = current_user.token_vendor_id
try:
# Uses lookup_card_for_vendor which validates company membership
card = card_service.lookup_card_for_vendor(
db,
vendor_id,
card_id=card_id,
qr_code=qr_code,
card_number=card_number,
)
except LoyaltyCardNotFoundException:
raise HTTPException(status_code=404, detail="Card not found")
program = card.program
# Check cooldown
can_stamp, _ = card.can_stamp(program.cooldown_minutes)
cooldown_ends = None
if not can_stamp and card.last_stamp_at:
from datetime import timedelta
cooldown_ends = card.last_stamp_at + timedelta(minutes=program.cooldown_minutes)
# Get stamps today
stamps_today = card_service.get_stamps_today(db, card.id)
# Get available points rewards
available_rewards = []
for reward in program.points_rewards or []:
if reward.get("is_active", True) and card.points_balance >= reward.get("points_required", 0):
available_rewards.append(reward)
return CardLookupResponse(
card_id=card.id,
card_number=card.card_number,
customer_id=card.customer_id,
customer_name=card.customer.full_name if card.customer else None,
customer_email=card.customer.email if card.customer else "",
company_id=card.company_id,
company_name=card.company.name if card.company else None,
stamp_count=card.stamp_count,
stamps_target=program.stamps_target,
stamps_until_reward=max(0, program.stamps_target - card.stamp_count),
points_balance=card.points_balance,
can_redeem_stamps=card.stamp_count >= program.stamps_target,
stamp_reward_description=program.stamps_reward_description,
available_rewards=available_rewards,
can_stamp=can_stamp,
cooldown_ends_at=cooldown_ends,
stamps_today=stamps_today,
max_daily_stamps=program.max_daily_stamps,
can_earn_more_stamps=stamps_today < program.max_daily_stamps,
)
@vendor_router.post("/cards/enroll", response_model=CardResponse, status_code=201)
def enroll_customer(
data: CardEnrollRequest,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Enroll a customer in the company's loyalty program.
The card will be associated with the company and track which
vendor enrolled them.
"""
vendor_id = current_user.token_vendor_id
if not data.customer_id:
raise HTTPException(status_code=400, detail="customer_id is required")
try:
card = card_service.enroll_customer_for_vendor(db, data.customer_id, vendor_id)
except LoyaltyException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
program = card.program
return CardResponse(
id=card.id,
card_number=card.card_number,
customer_id=card.customer_id,
company_id=card.company_id,
program_id=card.program_id,
enrolled_at_vendor_id=card.enrolled_at_vendor_id,
stamp_count=card.stamp_count,
stamps_target=program.stamps_target,
stamps_until_reward=max(0, program.stamps_target - card.stamp_count),
total_stamps_earned=card.total_stamps_earned,
stamps_redeemed=card.stamps_redeemed,
points_balance=card.points_balance,
total_points_earned=card.total_points_earned,
points_redeemed=card.points_redeemed,
is_active=card.is_active,
created_at=card.created_at,
)
@vendor_router.get("/cards/{card_id}/transactions", response_model=TransactionListResponse)
def get_card_transactions(
card_id: int = Path(..., gt=0),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get transaction history for a card."""
vendor_id = current_user.token_vendor_id
# Verify card belongs to this company
try:
card = card_service.lookup_card_for_vendor(db, vendor_id, card_id=card_id)
except LoyaltyCardNotFoundException:
raise HTTPException(status_code=404, detail="Card not found")
transactions, total = card_service.get_card_transactions(
db, card_id, skip=skip, limit=limit
)
return TransactionListResponse(
transactions=[TransactionResponse.model_validate(t) for t in transactions],
total=total,
)
# =============================================================================
# Stamp Operations
# =============================================================================
@vendor_router.post("/stamp", response_model=StampResponse)
def add_stamp(
request: Request,
data: StampRequest,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Add a stamp to a loyalty card."""
vendor_id = current_user.token_vendor_id
ip, user_agent = get_client_info(request)
try:
result = stamp_service.add_stamp(
db,
vendor_id=vendor_id,
card_id=data.card_id,
qr_code=data.qr_code,
card_number=data.card_number,
staff_pin=data.staff_pin,
ip_address=ip,
user_agent=user_agent,
notes=data.notes,
)
except LoyaltyException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
return StampResponse(**result)
@vendor_router.post("/stamp/redeem", response_model=StampRedeemResponse)
def redeem_stamps(
request: Request,
data: StampRedeemRequest,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Redeem stamps for a reward."""
vendor_id = current_user.token_vendor_id
ip, user_agent = get_client_info(request)
try:
result = stamp_service.redeem_stamps(
db,
vendor_id=vendor_id,
card_id=data.card_id,
qr_code=data.qr_code,
card_number=data.card_number,
staff_pin=data.staff_pin,
ip_address=ip,
user_agent=user_agent,
notes=data.notes,
)
except LoyaltyException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
return StampRedeemResponse(**result)
@vendor_router.post("/stamp/void", response_model=StampVoidResponse)
def void_stamps(
request: Request,
data: StampVoidRequest,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Void stamps for a return."""
vendor_id = current_user.token_vendor_id
ip, user_agent = get_client_info(request)
try:
result = stamp_service.void_stamps(
db,
vendor_id=vendor_id,
card_id=data.card_id,
qr_code=data.qr_code,
card_number=data.card_number,
stamps_to_void=data.stamps_to_void,
original_transaction_id=data.original_transaction_id,
staff_pin=data.staff_pin,
ip_address=ip,
user_agent=user_agent,
notes=data.notes,
)
except LoyaltyException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
return StampVoidResponse(**result)
# =============================================================================
# Points Operations
# =============================================================================
@vendor_router.post("/points", response_model=PointsEarnResponse)
def earn_points(
request: Request,
data: PointsEarnRequest,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Earn points from a purchase."""
vendor_id = current_user.token_vendor_id
ip, user_agent = get_client_info(request)
try:
result = points_service.earn_points(
db,
vendor_id=vendor_id,
card_id=data.card_id,
qr_code=data.qr_code,
card_number=data.card_number,
purchase_amount_cents=data.purchase_amount_cents,
order_reference=data.order_reference,
staff_pin=data.staff_pin,
ip_address=ip,
user_agent=user_agent,
notes=data.notes,
)
except LoyaltyException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
return PointsEarnResponse(**result)
@vendor_router.post("/points/redeem", response_model=PointsRedeemResponse)
def redeem_points(
request: Request,
data: PointsRedeemRequest,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Redeem points for a reward."""
vendor_id = current_user.token_vendor_id
ip, user_agent = get_client_info(request)
try:
result = points_service.redeem_points(
db,
vendor_id=vendor_id,
card_id=data.card_id,
qr_code=data.qr_code,
card_number=data.card_number,
reward_id=data.reward_id,
staff_pin=data.staff_pin,
ip_address=ip,
user_agent=user_agent,
notes=data.notes,
)
except LoyaltyException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
return PointsRedeemResponse(**result)
@vendor_router.post("/points/void", response_model=PointsVoidResponse)
def void_points(
request: Request,
data: PointsVoidRequest,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Void points for a return."""
vendor_id = current_user.token_vendor_id
ip, user_agent = get_client_info(request)
try:
result = points_service.void_points(
db,
vendor_id=vendor_id,
card_id=data.card_id,
qr_code=data.qr_code,
card_number=data.card_number,
points_to_void=data.points_to_void,
original_transaction_id=data.original_transaction_id,
order_reference=data.order_reference,
staff_pin=data.staff_pin,
ip_address=ip,
user_agent=user_agent,
notes=data.notes,
)
except LoyaltyException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
return PointsVoidResponse(**result)
@vendor_router.post("/cards/{card_id}/points/adjust", response_model=PointsAdjustResponse)
def adjust_points(
request: Request,
data: PointsAdjustRequest,
card_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Manually adjust points (vendor operation)."""
vendor_id = current_user.token_vendor_id
ip, user_agent = get_client_info(request)
try:
result = points_service.adjust_points(
db,
card_id=card_id,
points_delta=data.points_delta,
vendor_id=vendor_id,
reason=data.reason,
staff_pin=data.staff_pin,
ip_address=ip,
user_agent=user_agent,
)
except LoyaltyException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
return PointsAdjustResponse(**result)