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:
2026-02-05 22:10:27 +01:00
parent 3bdf1695fd
commit d8f3338bc8
54 changed files with 7252 additions and 186 deletions

View File

@@ -2,12 +2,15 @@
"""
Loyalty module vendor routes.
Store/vendor endpoints for:
- Program management
- Staff PINs
- Card operations (stamps, points, redemptions)
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
@@ -36,14 +39,23 @@ from app.modules.loyalty.schemas import (
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,
@@ -54,7 +66,7 @@ from app.modules.loyalty.services import (
wallet_service,
)
from app.modules.enums import FrontendType
from app.modules.tenancy.models import User
from app.modules.tenancy.models import User, Vendor
logger = logging.getLogger(__name__)
@@ -72,6 +84,14 @@ def get_client_info(request: Request) -> tuple[str | None, str | None]:
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
# =============================================================================
@@ -82,7 +102,7 @@ def get_program(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get the vendor's loyalty program."""
"""Get the company's loyalty program."""
vendor_id = current_user.token_vendor_id
program = program_service.get_program_by_vendor(db, vendor_id)
@@ -103,11 +123,12 @@ def create_program(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Create a loyalty program for the vendor."""
"""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, vendor_id, data)
program = program_service.create_program(db, company_id, data)
except LoyaltyException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
@@ -125,7 +146,7 @@ def update_program(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Update the vendor's loyalty program."""
"""Update the company's loyalty program."""
vendor_id = current_user.token_vendor_id
program = program_service.get_program_by_vendor(db, vendor_id)
@@ -158,6 +179,22 @@ def get_stats(
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
# =============================================================================
@@ -168,14 +205,15 @@ def list_pins(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""List all staff PINs for the loyalty program."""
"""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")
pins = pin_service.list_pins(db, program.id)
# 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],
@@ -189,7 +227,7 @@ def create_pin(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Create a new staff PIN."""
"""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)
@@ -244,19 +282,30 @@ def list_cards(
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 vendor."""
"""
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,
vendor_id,
company_id,
vendor_id=filter_vendor_id,
skip=skip,
limit=limit,
is_active=is_active,
@@ -269,8 +318,9 @@ def list_cards(
id=card.id,
card_number=card.card_number,
customer_id=card.customer_id,
vendor_id=card.vendor_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),
@@ -298,12 +348,18 @@ def lookup_card(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Look up a card by ID, QR code, or card number."""
"""
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:
card = card_service.lookup_card(
# 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,
@@ -311,10 +367,6 @@ def lookup_card(
except LoyaltyCardNotFoundException:
raise HTTPException(status_code=404, detail="Card not found")
# Verify card belongs to this vendor
if card.vendor_id != vendor_id:
raise HTTPException(status_code=404, detail="Card not found")
program = card.program
# Check cooldown
@@ -328,18 +380,27 @@ def lookup_card(
# 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,
@@ -354,14 +415,19 @@ def enroll_customer(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Enroll a customer in the loyalty program."""
"""
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(db, data.customer_id, vendor_id)
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)
@@ -371,11 +437,12 @@ def enroll_customer(
id=card.id,
card_number=card.card_number,
customer_id=card.customer_id,
vendor_id=card.vendor_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=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,
@@ -386,6 +453,33 @@ def enroll_customer(
)
@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
# =============================================================================
@@ -399,11 +493,13 @@ def add_stamp(
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,
@@ -426,11 +522,13 @@ def redeem_stamps(
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,
@@ -445,6 +543,37 @@ def redeem_stamps(
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
# =============================================================================
@@ -458,11 +587,13 @@ def earn_points(
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,
@@ -487,11 +618,13 @@ def redeem_points(
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,
@@ -505,3 +638,64 @@ def redeem_points(
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)