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>
484 lines
14 KiB
Python
484 lines
14 KiB
Python
# app/modules/loyalty/services/card_service.py
|
|
"""
|
|
Loyalty card service.
|
|
|
|
Company-based card operations:
|
|
- Cards belong to a company's loyalty program
|
|
- One card per customer per company
|
|
- Can be used at any vendor within the company
|
|
|
|
Handles card operations including:
|
|
- Customer enrollment (with welcome bonus)
|
|
- Card lookup (by ID, QR code, card number, email, phone)
|
|
- 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."""
|
|
# Normalize card number (remove dashes)
|
|
normalized = card_number.replace("-", "").replace(" ", "")
|
|
return (
|
|
db.query(LoyaltyCard)
|
|
.options(joinedload(LoyaltyCard.program))
|
|
.filter(
|
|
LoyaltyCard.card_number.replace("-", "") == normalized
|
|
)
|
|
.first()
|
|
)
|
|
|
|
def get_card_by_customer_and_company(
|
|
self,
|
|
db: Session,
|
|
customer_id: int,
|
|
company_id: int,
|
|
) -> LoyaltyCard | None:
|
|
"""Get a customer's card for a company's program."""
|
|
return (
|
|
db.query(LoyaltyCard)
|
|
.options(joinedload(LoyaltyCard.program))
|
|
.filter(
|
|
LoyaltyCard.customer_id == customer_id,
|
|
LoyaltyCard.company_id == company_id,
|
|
)
|
|
.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,
|
|
company_id: int | 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 (with or without dashes)
|
|
company_id: Optional company filter
|
|
|
|
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))
|
|
|
|
# Filter by company if specified
|
|
if company_id and card.company_id != company_id:
|
|
raise LoyaltyCardNotFoundException(str(card_id or qr_code or card_number))
|
|
|
|
return card
|
|
|
|
def lookup_card_for_vendor(
|
|
self,
|
|
db: Session,
|
|
vendor_id: int,
|
|
*,
|
|
card_id: int | None = None,
|
|
qr_code: str | None = None,
|
|
card_number: str | None = None,
|
|
) -> LoyaltyCard:
|
|
"""
|
|
Look up a card for a specific vendor (must be in same company).
|
|
|
|
Args:
|
|
db: Database session
|
|
vendor_id: Vendor ID (to get company context)
|
|
card_id: Card ID
|
|
qr_code: QR code data
|
|
card_number: Card number
|
|
|
|
Returns:
|
|
Found card (verified to be in vendor's company)
|
|
|
|
Raises:
|
|
LoyaltyCardNotFoundException: If no card found or wrong company
|
|
"""
|
|
from app.modules.tenancy.models import Vendor
|
|
|
|
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
|
if not vendor:
|
|
raise LoyaltyCardNotFoundException("vendor not found")
|
|
|
|
return self.lookup_card(
|
|
db,
|
|
card_id=card_id,
|
|
qr_code=qr_code,
|
|
card_number=card_number,
|
|
company_id=vendor.company_id,
|
|
)
|
|
|
|
def list_cards(
|
|
self,
|
|
db: Session,
|
|
company_id: int,
|
|
*,
|
|
vendor_id: int | None = None,
|
|
skip: int = 0,
|
|
limit: int = 50,
|
|
is_active: bool | None = None,
|
|
search: str | None = None,
|
|
) -> tuple[list[LoyaltyCard], int]:
|
|
"""
|
|
List loyalty cards for a company.
|
|
|
|
Args:
|
|
db: Database session
|
|
company_id: Company ID
|
|
vendor_id: Optional filter by enrolled vendor
|
|
skip: Pagination offset
|
|
limit: Pagination limit
|
|
is_active: Filter by active status
|
|
search: Search by card number, email, or name
|
|
|
|
Returns:
|
|
(cards, total_count)
|
|
"""
|
|
from app.modules.customers.models.customer import Customer
|
|
|
|
query = (
|
|
db.query(LoyaltyCard)
|
|
.options(joinedload(LoyaltyCard.customer))
|
|
.filter(LoyaltyCard.company_id == company_id)
|
|
)
|
|
|
|
if vendor_id:
|
|
query = query.filter(LoyaltyCard.enrolled_at_vendor_id == vendor_id)
|
|
|
|
if is_active is not None:
|
|
query = query.filter(LoyaltyCard.is_active == is_active)
|
|
|
|
if search:
|
|
# Normalize search term for card number matching
|
|
search_normalized = search.replace("-", "").replace(" ", "")
|
|
query = query.join(Customer).filter(
|
|
(LoyaltyCard.card_number.replace("-", "").ilike(f"%{search_normalized}%"))
|
|
| (Customer.email.ilike(f"%{search}%"))
|
|
| (Customer.first_name.ilike(f"%{search}%"))
|
|
| (Customer.last_name.ilike(f"%{search}%"))
|
|
| (Customer.phone.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), joinedload(LoyaltyCard.company))
|
|
.filter(LoyaltyCard.customer_id == customer_id)
|
|
.all()
|
|
)
|
|
|
|
# =========================================================================
|
|
# Write Operations
|
|
# =========================================================================
|
|
|
|
def enroll_customer(
|
|
self,
|
|
db: Session,
|
|
customer_id: int,
|
|
company_id: int,
|
|
*,
|
|
enrolled_at_vendor_id: int | None = None,
|
|
) -> LoyaltyCard:
|
|
"""
|
|
Enroll a customer in a company's loyalty program.
|
|
|
|
Args:
|
|
db: Database session
|
|
customer_id: Customer ID
|
|
company_id: Company ID
|
|
enrolled_at_vendor_id: Vendor where customer enrolled (for analytics)
|
|
|
|
Returns:
|
|
Created loyalty card
|
|
|
|
Raises:
|
|
LoyaltyProgramNotFoundException: If no program exists
|
|
LoyaltyProgramInactiveException: If program is inactive
|
|
LoyaltyCardAlreadyExistsException: If customer already enrolled
|
|
"""
|
|
# Get the program
|
|
program = (
|
|
db.query(LoyaltyProgram)
|
|
.filter(LoyaltyProgram.company_id == company_id)
|
|
.first()
|
|
)
|
|
|
|
if not program:
|
|
raise LoyaltyProgramNotFoundException(f"company:{company_id}")
|
|
|
|
if not program.is_active:
|
|
raise LoyaltyProgramInactiveException(program.id)
|
|
|
|
# Check if customer already has a card
|
|
existing = self.get_card_by_customer_and_company(db, customer_id, company_id)
|
|
if existing:
|
|
raise LoyaltyCardAlreadyExistsException(customer_id, program.id)
|
|
|
|
# Create the card
|
|
card = LoyaltyCard(
|
|
company_id=company_id,
|
|
customer_id=customer_id,
|
|
program_id=program.id,
|
|
enrolled_at_vendor_id=enrolled_at_vendor_id,
|
|
)
|
|
|
|
db.add(card)
|
|
db.flush() # Get the card ID
|
|
|
|
# Create enrollment transaction
|
|
transaction = LoyaltyTransaction(
|
|
company_id=company_id,
|
|
card_id=card.id,
|
|
vendor_id=enrolled_at_vendor_id,
|
|
transaction_type=TransactionType.CARD_CREATED.value,
|
|
transaction_at=datetime.now(UTC),
|
|
)
|
|
db.add(transaction)
|
|
|
|
# Award welcome bonus if configured
|
|
if program.welcome_bonus_points > 0:
|
|
card.add_points(program.welcome_bonus_points)
|
|
|
|
bonus_transaction = LoyaltyTransaction(
|
|
company_id=company_id,
|
|
card_id=card.id,
|
|
vendor_id=enrolled_at_vendor_id,
|
|
transaction_type=TransactionType.WELCOME_BONUS.value,
|
|
points_delta=program.welcome_bonus_points,
|
|
points_balance_after=card.points_balance,
|
|
notes="Welcome bonus on enrollment",
|
|
transaction_at=datetime.now(UTC),
|
|
)
|
|
db.add(bonus_transaction)
|
|
|
|
db.commit()
|
|
db.refresh(card)
|
|
|
|
logger.info(
|
|
f"Enrolled customer {customer_id} in company {company_id} loyalty program "
|
|
f"(card: {card.card_number}, bonus: {program.welcome_bonus_points} pts)"
|
|
)
|
|
|
|
return card
|
|
|
|
def enroll_customer_for_vendor(
|
|
self,
|
|
db: Session,
|
|
customer_id: int,
|
|
vendor_id: int,
|
|
) -> LoyaltyCard:
|
|
"""
|
|
Enroll a customer through a specific vendor.
|
|
|
|
Looks up the vendor's company and enrolls in the company's program.
|
|
|
|
Args:
|
|
db: Database session
|
|
customer_id: Customer ID
|
|
vendor_id: Vendor ID
|
|
|
|
Returns:
|
|
Created loyalty card
|
|
"""
|
|
from app.modules.tenancy.models import Vendor
|
|
|
|
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
|
if not vendor:
|
|
raise LoyaltyProgramNotFoundException(f"vendor:{vendor_id}")
|
|
|
|
return self.enroll_customer(
|
|
db,
|
|
customer_id,
|
|
vendor.company_id,
|
|
enrolled_at_vendor_id=vendor_id,
|
|
)
|
|
|
|
def deactivate_card(
|
|
self,
|
|
db: Session,
|
|
card_id: int,
|
|
*,
|
|
vendor_id: int | None = None,
|
|
) -> LoyaltyCard:
|
|
"""Deactivate a loyalty card."""
|
|
card = self.require_card(db, card_id)
|
|
card.is_active = False
|
|
|
|
# Create deactivation transaction
|
|
transaction = LoyaltyTransaction(
|
|
company_id=card.company_id,
|
|
card_id=card.id,
|
|
vendor_id=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)
|
|
.options(joinedload(LoyaltyTransaction.vendor))
|
|
.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()
|