feat(loyalty): implement complete loyalty module MVP
Add stamp-based and points-based loyalty programs for vendors with: Database Models (5 tables): - loyalty_programs: Vendor program configuration - loyalty_cards: Customer cards with stamp/point balances - loyalty_transactions: Immutable audit log - staff_pins: Fraud prevention PINs (bcrypt hashed) - apple_device_registrations: Apple Wallet push tokens Services: - program_service: Program CRUD and statistics - card_service: Customer enrollment and card lookup - stamp_service: Stamp operations with anti-fraud checks - points_service: Points earning and redemption - pin_service: Staff PIN management with lockout - wallet_service: Unified wallet abstraction - google_wallet_service: Google Wallet API integration - apple_wallet_service: Apple Wallet .pkpass generation API Routes: - Admin: /api/v1/admin/loyalty/* (programs list, stats) - Vendor: /api/v1/vendor/loyalty/* (stamp, points, cards, PINs) - Public: /api/v1/loyalty/* (enrollment, Apple Web Service) Anti-Fraud Features: - Staff PIN verification (configurable per program) - Cooldown period between stamps (default 15 min) - Daily stamp limits (default 5/day) - PIN lockout after failed attempts Wallet Integration: - Google Wallet: LoyaltyClass and LoyaltyObject management - Apple Wallet: .pkpass generation with PKCS#7 signing - Apple Web Service endpoints for device registration/updates Also includes: - Alembic migration for all tables with indexes - Localization files (en, fr, de, lu) - Module documentation - Phase 2 interface and user journey plan Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
379
app/modules/loyalty/services/program_service.py
Normal file
379
app/modules/loyalty/services/program_service.py
Normal file
@@ -0,0 +1,379 @@
|
||||
# app/modules/loyalty/services/program_service.py
|
||||
"""
|
||||
Loyalty program service.
|
||||
|
||||
Handles CRUD operations for loyalty programs including:
|
||||
- Program creation and configuration
|
||||
- Program updates
|
||||
- Program activation/deactivation
|
||||
- Statistics retrieval
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.loyalty.exceptions import (
|
||||
LoyaltyProgramAlreadyExistsException,
|
||||
LoyaltyProgramNotFoundException,
|
||||
)
|
||||
from app.modules.loyalty.models import LoyaltyProgram, LoyaltyType
|
||||
from app.modules.loyalty.schemas.program import (
|
||||
ProgramCreate,
|
||||
ProgramUpdate,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProgramService:
|
||||
"""Service for loyalty program operations."""
|
||||
|
||||
# =========================================================================
|
||||
# Read Operations
|
||||
# =========================================================================
|
||||
|
||||
def get_program(self, db: Session, program_id: int) -> LoyaltyProgram | None:
|
||||
"""Get a loyalty program by ID."""
|
||||
return (
|
||||
db.query(LoyaltyProgram)
|
||||
.filter(LoyaltyProgram.id == program_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None:
|
||||
"""Get a vendor's loyalty program."""
|
||||
return (
|
||||
db.query(LoyaltyProgram)
|
||||
.filter(LoyaltyProgram.vendor_id == vendor_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_active_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None:
|
||||
"""Get a vendor's active loyalty program."""
|
||||
return (
|
||||
db.query(LoyaltyProgram)
|
||||
.filter(
|
||||
LoyaltyProgram.vendor_id == vendor_id,
|
||||
LoyaltyProgram.is_active == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def require_program(self, db: Session, program_id: int) -> LoyaltyProgram:
|
||||
"""Get a program or raise exception if not found."""
|
||||
program = self.get_program(db, program_id)
|
||||
if not program:
|
||||
raise LoyaltyProgramNotFoundException(str(program_id))
|
||||
return program
|
||||
|
||||
def require_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram:
|
||||
"""Get a vendor's program or raise exception if not found."""
|
||||
program = self.get_program_by_vendor(db, vendor_id)
|
||||
if not program:
|
||||
raise LoyaltyProgramNotFoundException(f"vendor:{vendor_id}")
|
||||
return program
|
||||
|
||||
def list_programs(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
is_active: bool | None = None,
|
||||
) -> tuple[list[LoyaltyProgram], int]:
|
||||
"""List all loyalty programs (admin)."""
|
||||
query = db.query(LoyaltyProgram)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(LoyaltyProgram.is_active == is_active)
|
||||
|
||||
total = query.count()
|
||||
programs = query.offset(skip).limit(limit).all()
|
||||
|
||||
return programs, total
|
||||
|
||||
# =========================================================================
|
||||
# Write Operations
|
||||
# =========================================================================
|
||||
|
||||
def create_program(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
data: ProgramCreate,
|
||||
) -> LoyaltyProgram:
|
||||
"""
|
||||
Create a new loyalty program for a vendor.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
data: Program configuration
|
||||
|
||||
Returns:
|
||||
Created program
|
||||
|
||||
Raises:
|
||||
LoyaltyProgramAlreadyExistsException: If vendor already has a program
|
||||
"""
|
||||
# Check if vendor already has a program
|
||||
existing = self.get_program_by_vendor(db, vendor_id)
|
||||
if existing:
|
||||
raise LoyaltyProgramAlreadyExistsException(vendor_id)
|
||||
|
||||
# Convert points_rewards to dict list for JSON storage
|
||||
points_rewards_data = [r.model_dump() for r in data.points_rewards]
|
||||
|
||||
program = LoyaltyProgram(
|
||||
vendor_id=vendor_id,
|
||||
loyalty_type=data.loyalty_type,
|
||||
# Stamps
|
||||
stamps_target=data.stamps_target,
|
||||
stamps_reward_description=data.stamps_reward_description,
|
||||
stamps_reward_value_cents=data.stamps_reward_value_cents,
|
||||
# Points
|
||||
points_per_euro=data.points_per_euro,
|
||||
points_rewards=points_rewards_data,
|
||||
# Anti-fraud
|
||||
cooldown_minutes=data.cooldown_minutes,
|
||||
max_daily_stamps=data.max_daily_stamps,
|
||||
require_staff_pin=data.require_staff_pin,
|
||||
# Branding
|
||||
card_name=data.card_name,
|
||||
card_color=data.card_color,
|
||||
card_secondary_color=data.card_secondary_color,
|
||||
logo_url=data.logo_url,
|
||||
hero_image_url=data.hero_image_url,
|
||||
# Terms
|
||||
terms_text=data.terms_text,
|
||||
privacy_url=data.privacy_url,
|
||||
# Status
|
||||
is_active=True,
|
||||
activated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
db.add(program)
|
||||
db.commit()
|
||||
db.refresh(program)
|
||||
|
||||
logger.info(
|
||||
f"Created loyalty program {program.id} for vendor {vendor_id} "
|
||||
f"(type: {program.loyalty_type})"
|
||||
)
|
||||
|
||||
return program
|
||||
|
||||
def update_program(
|
||||
self,
|
||||
db: Session,
|
||||
program_id: int,
|
||||
data: ProgramUpdate,
|
||||
) -> LoyaltyProgram:
|
||||
"""
|
||||
Update a loyalty program.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
program_id: Program ID
|
||||
data: Update data
|
||||
|
||||
Returns:
|
||||
Updated program
|
||||
"""
|
||||
program = self.require_program(db, program_id)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# Handle points_rewards specially (convert to dict list)
|
||||
if "points_rewards" in update_data and update_data["points_rewards"] is not None:
|
||||
update_data["points_rewards"] = [
|
||||
r.model_dump() if hasattr(r, "model_dump") else r
|
||||
for r in update_data["points_rewards"]
|
||||
]
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(program, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(program)
|
||||
|
||||
logger.info(f"Updated loyalty program {program_id}")
|
||||
|
||||
return program
|
||||
|
||||
def activate_program(self, db: Session, program_id: int) -> LoyaltyProgram:
|
||||
"""Activate a loyalty program."""
|
||||
program = self.require_program(db, program_id)
|
||||
program.activate()
|
||||
db.commit()
|
||||
db.refresh(program)
|
||||
logger.info(f"Activated loyalty program {program_id}")
|
||||
return program
|
||||
|
||||
def deactivate_program(self, db: Session, program_id: int) -> LoyaltyProgram:
|
||||
"""Deactivate a loyalty program."""
|
||||
program = self.require_program(db, program_id)
|
||||
program.deactivate()
|
||||
db.commit()
|
||||
db.refresh(program)
|
||||
logger.info(f"Deactivated loyalty program {program_id}")
|
||||
return program
|
||||
|
||||
def delete_program(self, db: Session, program_id: int) -> None:
|
||||
"""Delete a loyalty program and all associated data."""
|
||||
program = self.require_program(db, program_id)
|
||||
vendor_id = program.vendor_id
|
||||
|
||||
db.delete(program)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Deleted loyalty program {program_id} for vendor {vendor_id}")
|
||||
|
||||
# =========================================================================
|
||||
# Statistics
|
||||
# =========================================================================
|
||||
|
||||
def get_program_stats(self, db: Session, program_id: int) -> dict:
|
||||
"""
|
||||
Get statistics for a loyalty program.
|
||||
|
||||
Returns dict with:
|
||||
- total_cards, active_cards
|
||||
- total_stamps_issued, total_stamps_redeemed
|
||||
- total_points_issued, total_points_redeemed
|
||||
- etc.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.modules.loyalty.models import LoyaltyCard, LoyaltyTransaction
|
||||
|
||||
program = self.require_program(db, program_id)
|
||||
|
||||
# Card counts
|
||||
total_cards = (
|
||||
db.query(func.count(LoyaltyCard.id))
|
||||
.filter(LoyaltyCard.program_id == program_id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
active_cards = (
|
||||
db.query(func.count(LoyaltyCard.id))
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyCard.is_active == True,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Stamp totals from cards
|
||||
stamp_stats = (
|
||||
db.query(
|
||||
func.sum(LoyaltyCard.total_stamps_earned),
|
||||
func.sum(LoyaltyCard.stamps_redeemed),
|
||||
)
|
||||
.filter(LoyaltyCard.program_id == program_id)
|
||||
.first()
|
||||
)
|
||||
total_stamps_issued = stamp_stats[0] or 0
|
||||
total_stamps_redeemed = stamp_stats[1] or 0
|
||||
|
||||
# Points totals from cards
|
||||
points_stats = (
|
||||
db.query(
|
||||
func.sum(LoyaltyCard.total_points_earned),
|
||||
func.sum(LoyaltyCard.points_redeemed),
|
||||
)
|
||||
.filter(LoyaltyCard.program_id == program_id)
|
||||
.first()
|
||||
)
|
||||
total_points_issued = points_stats[0] or 0
|
||||
total_points_redeemed = points_stats[1] or 0
|
||||
|
||||
# This month's activity
|
||||
month_start = datetime.now(UTC).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
stamps_this_month = (
|
||||
db.query(func.count(LoyaltyTransaction.id))
|
||||
.join(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyTransaction.transaction_type == "stamp_earned",
|
||||
LoyaltyTransaction.transaction_at >= month_start,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
redemptions_this_month = (
|
||||
db.query(func.count(LoyaltyTransaction.id))
|
||||
.join(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyTransaction.transaction_type == "stamp_redeemed",
|
||||
LoyaltyTransaction.transaction_at >= month_start,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# 30-day active cards
|
||||
thirty_days_ago = datetime.now(UTC) - timedelta(days=30)
|
||||
cards_with_activity_30d = (
|
||||
db.query(func.count(func.distinct(LoyaltyTransaction.card_id)))
|
||||
.join(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyTransaction.transaction_at >= thirty_days_ago,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Averages
|
||||
avg_stamps = total_stamps_issued / total_cards if total_cards > 0 else 0
|
||||
avg_points = total_points_issued / total_cards if total_cards > 0 else 0
|
||||
|
||||
# Estimated liability (unredeemed value)
|
||||
current_stamps = (
|
||||
db.query(func.sum(LoyaltyCard.stamp_count))
|
||||
.filter(LoyaltyCard.program_id == program_id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
stamp_value = program.stamps_reward_value_cents or 0
|
||||
current_points = (
|
||||
db.query(func.sum(LoyaltyCard.points_balance))
|
||||
.filter(LoyaltyCard.program_id == program_id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
# Rough estimate: assume 100 points = €1
|
||||
points_value_cents = current_points // 100 * 100
|
||||
|
||||
estimated_liability = (
|
||||
(current_stamps * stamp_value // program.stamps_target) + points_value_cents
|
||||
)
|
||||
|
||||
return {
|
||||
"total_cards": total_cards,
|
||||
"active_cards": active_cards,
|
||||
"total_stamps_issued": total_stamps_issued,
|
||||
"total_stamps_redeemed": total_stamps_redeemed,
|
||||
"stamps_this_month": stamps_this_month,
|
||||
"redemptions_this_month": redemptions_this_month,
|
||||
"total_points_issued": total_points_issued,
|
||||
"total_points_redeemed": total_points_redeemed,
|
||||
"cards_with_activity_30d": cards_with_activity_30d,
|
||||
"average_stamps_per_card": round(avg_stamps, 2),
|
||||
"average_points_per_card": round(avg_points, 2),
|
||||
"estimated_liability_cents": estimated_liability,
|
||||
}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
program_service = ProgramService()
|
||||
Reference in New Issue
Block a user