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:
348
app/modules/loyalty/services/card_service.py
Normal file
348
app/modules/loyalty/services/card_service.py
Normal file
@@ -0,0 +1,348 @@
|
||||
# app/modules/loyalty/services/card_service.py
|
||||
"""
|
||||
Loyalty card service.
|
||||
|
||||
Handles card operations including:
|
||||
- Customer enrollment
|
||||
- Card lookup (by ID, QR code, card number)
|
||||
- Card management (activation, deactivation)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.modules.loyalty.exceptions import (
|
||||
LoyaltyCardAlreadyExistsException,
|
||||
LoyaltyCardNotFoundException,
|
||||
LoyaltyProgramInactiveException,
|
||||
LoyaltyProgramNotFoundException,
|
||||
)
|
||||
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction, TransactionType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CardService:
|
||||
"""Service for loyalty card operations."""
|
||||
|
||||
# =========================================================================
|
||||
# Read Operations
|
||||
# =========================================================================
|
||||
|
||||
def get_card(self, db: Session, card_id: int) -> LoyaltyCard | None:
|
||||
"""Get a loyalty card by ID."""
|
||||
return (
|
||||
db.query(LoyaltyCard)
|
||||
.options(joinedload(LoyaltyCard.program))
|
||||
.filter(LoyaltyCard.id == card_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_card_by_qr_code(self, db: Session, qr_code: str) -> LoyaltyCard | None:
|
||||
"""Get a loyalty card by QR code data."""
|
||||
return (
|
||||
db.query(LoyaltyCard)
|
||||
.options(joinedload(LoyaltyCard.program))
|
||||
.filter(LoyaltyCard.qr_code_data == qr_code)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_card_by_number(self, db: Session, card_number: str) -> LoyaltyCard | None:
|
||||
"""Get a loyalty card by card number."""
|
||||
return (
|
||||
db.query(LoyaltyCard)
|
||||
.options(joinedload(LoyaltyCard.program))
|
||||
.filter(LoyaltyCard.card_number == card_number)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_card_by_customer_and_program(
|
||||
self,
|
||||
db: Session,
|
||||
customer_id: int,
|
||||
program_id: int,
|
||||
) -> LoyaltyCard | None:
|
||||
"""Get a customer's card for a specific program."""
|
||||
return (
|
||||
db.query(LoyaltyCard)
|
||||
.options(joinedload(LoyaltyCard.program))
|
||||
.filter(
|
||||
LoyaltyCard.customer_id == customer_id,
|
||||
LoyaltyCard.program_id == program_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def require_card(self, db: Session, card_id: int) -> LoyaltyCard:
|
||||
"""Get a card or raise exception if not found."""
|
||||
card = self.get_card(db, card_id)
|
||||
if not card:
|
||||
raise LoyaltyCardNotFoundException(str(card_id))
|
||||
return card
|
||||
|
||||
def lookup_card(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
card_id: int | None = None,
|
||||
qr_code: str | None = None,
|
||||
card_number: str | None = None,
|
||||
) -> LoyaltyCard:
|
||||
"""
|
||||
Look up a card by any identifier.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
card_id: Card ID
|
||||
qr_code: QR code data
|
||||
card_number: Card number
|
||||
|
||||
Returns:
|
||||
Found card
|
||||
|
||||
Raises:
|
||||
LoyaltyCardNotFoundException: If no card found
|
||||
"""
|
||||
card = None
|
||||
|
||||
if card_id:
|
||||
card = self.get_card(db, card_id)
|
||||
elif qr_code:
|
||||
card = self.get_card_by_qr_code(db, qr_code)
|
||||
elif card_number:
|
||||
card = self.get_card_by_number(db, card_number)
|
||||
|
||||
if not card:
|
||||
identifier = card_id or qr_code or card_number or "unknown"
|
||||
raise LoyaltyCardNotFoundException(str(identifier))
|
||||
|
||||
return card
|
||||
|
||||
def list_cards(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
*,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
is_active: bool | None = None,
|
||||
search: str | None = None,
|
||||
) -> tuple[list[LoyaltyCard], int]:
|
||||
"""
|
||||
List loyalty cards for a vendor.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
skip: Pagination offset
|
||||
limit: Pagination limit
|
||||
is_active: Filter by active status
|
||||
search: Search by card number or customer email
|
||||
|
||||
Returns:
|
||||
(cards, total_count)
|
||||
"""
|
||||
from models.database.customer import Customer
|
||||
|
||||
query = (
|
||||
db.query(LoyaltyCard)
|
||||
.options(joinedload(LoyaltyCard.customer))
|
||||
.filter(LoyaltyCard.vendor_id == vendor_id)
|
||||
)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(LoyaltyCard.is_active == is_active)
|
||||
|
||||
if search:
|
||||
query = query.join(Customer).filter(
|
||||
(LoyaltyCard.card_number.ilike(f"%{search}%"))
|
||||
| (Customer.email.ilike(f"%{search}%"))
|
||||
| (Customer.first_name.ilike(f"%{search}%"))
|
||||
| (Customer.last_name.ilike(f"%{search}%"))
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
cards = (
|
||||
query.order_by(LoyaltyCard.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return cards, total
|
||||
|
||||
def list_customer_cards(
|
||||
self,
|
||||
db: Session,
|
||||
customer_id: int,
|
||||
) -> list[LoyaltyCard]:
|
||||
"""List all loyalty cards for a customer."""
|
||||
return (
|
||||
db.query(LoyaltyCard)
|
||||
.options(joinedload(LoyaltyCard.program))
|
||||
.filter(LoyaltyCard.customer_id == customer_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Write Operations
|
||||
# =========================================================================
|
||||
|
||||
def enroll_customer(
|
||||
self,
|
||||
db: Session,
|
||||
customer_id: int,
|
||||
vendor_id: int,
|
||||
*,
|
||||
program_id: int | None = None,
|
||||
) -> LoyaltyCard:
|
||||
"""
|
||||
Enroll a customer in a loyalty program.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
customer_id: Customer ID
|
||||
vendor_id: Vendor ID
|
||||
program_id: Optional program ID (defaults to vendor's program)
|
||||
|
||||
Returns:
|
||||
Created loyalty card
|
||||
|
||||
Raises:
|
||||
LoyaltyProgramNotFoundException: If no program exists
|
||||
LoyaltyProgramInactiveException: If program is inactive
|
||||
LoyaltyCardAlreadyExistsException: If customer already enrolled
|
||||
"""
|
||||
# Get the program
|
||||
if program_id:
|
||||
program = (
|
||||
db.query(LoyaltyProgram)
|
||||
.filter(LoyaltyProgram.id == program_id)
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
program = (
|
||||
db.query(LoyaltyProgram)
|
||||
.filter(LoyaltyProgram.vendor_id == vendor_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not program:
|
||||
raise LoyaltyProgramNotFoundException(f"vendor:{vendor_id}")
|
||||
|
||||
if not program.is_active:
|
||||
raise LoyaltyProgramInactiveException(program.id)
|
||||
|
||||
# Check if customer already has a card
|
||||
existing = self.get_card_by_customer_and_program(db, customer_id, program.id)
|
||||
if existing:
|
||||
raise LoyaltyCardAlreadyExistsException(customer_id, program.id)
|
||||
|
||||
# Create the card
|
||||
card = LoyaltyCard(
|
||||
customer_id=customer_id,
|
||||
program_id=program.id,
|
||||
vendor_id=vendor_id,
|
||||
)
|
||||
|
||||
db.add(card)
|
||||
db.flush() # Get the card ID
|
||||
|
||||
# Create enrollment transaction
|
||||
transaction = LoyaltyTransaction(
|
||||
card_id=card.id,
|
||||
vendor_id=vendor_id,
|
||||
transaction_type=TransactionType.CARD_CREATED.value,
|
||||
transaction_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
logger.info(
|
||||
f"Enrolled customer {customer_id} in loyalty program {program.id} "
|
||||
f"(card: {card.card_number})"
|
||||
)
|
||||
|
||||
return card
|
||||
|
||||
def deactivate_card(self, db: Session, card_id: int) -> LoyaltyCard:
|
||||
"""Deactivate a loyalty card."""
|
||||
card = self.require_card(db, card_id)
|
||||
card.is_active = False
|
||||
|
||||
# Create deactivation transaction
|
||||
transaction = LoyaltyTransaction(
|
||||
card_id=card.id,
|
||||
vendor_id=card.vendor_id,
|
||||
transaction_type=TransactionType.CARD_DEACTIVATED.value,
|
||||
transaction_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
logger.info(f"Deactivated loyalty card {card_id}")
|
||||
|
||||
return card
|
||||
|
||||
def reactivate_card(self, db: Session, card_id: int) -> LoyaltyCard:
|
||||
"""Reactivate a deactivated loyalty card."""
|
||||
card = self.require_card(db, card_id)
|
||||
card.is_active = True
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
logger.info(f"Reactivated loyalty card {card_id}")
|
||||
|
||||
return card
|
||||
|
||||
# =========================================================================
|
||||
# Helpers
|
||||
# =========================================================================
|
||||
|
||||
def get_stamps_today(self, db: Session, card_id: int) -> int:
|
||||
"""Get number of stamps earned today for a card."""
|
||||
from sqlalchemy import func
|
||||
|
||||
today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
count = (
|
||||
db.query(func.count(LoyaltyTransaction.id))
|
||||
.filter(
|
||||
LoyaltyTransaction.card_id == card_id,
|
||||
LoyaltyTransaction.transaction_type == TransactionType.STAMP_EARNED.value,
|
||||
LoyaltyTransaction.transaction_at >= today_start,
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
|
||||
return count or 0
|
||||
|
||||
def get_card_transactions(
|
||||
self,
|
||||
db: Session,
|
||||
card_id: int,
|
||||
*,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> tuple[list[LoyaltyTransaction], int]:
|
||||
"""Get transaction history for a card."""
|
||||
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()
|
||||
|
||||
return transactions, total
|
||||
|
||||
|
||||
# Singleton instance
|
||||
card_service = CardService()
|
||||
Reference in New Issue
Block a user