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,9 +2,14 @@
"""
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
- Card lookup (by ID, QR code, card number)
- Customer enrollment (with welcome bonus)
- Card lookup (by ID, QR code, card number, email, phone)
- Card management (activation, deactivation)
"""
@@ -19,7 +24,12 @@ from app.modules.loyalty.exceptions import (
LoyaltyProgramInactiveException,
LoyaltyProgramNotFoundException,
)
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction, TransactionType
from app.modules.loyalty.models import (
LoyaltyCard,
LoyaltyProgram,
LoyaltyTransaction,
TransactionType,
)
logger = logging.getLogger(__name__)
@@ -51,10 +61,31 @@ class CardService:
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 == card_number)
.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()
)
@@ -89,6 +120,7 @@ class CardService:
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.
@@ -97,7 +129,8 @@ class CardService:
db: Database session
card_id: Card ID
qr_code: QR code data
card_number: Card number
card_number: Card number (with or without dashes)
company_id: Optional company filter
Returns:
Found card
@@ -118,28 +151,73 @@ class CardService:
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 list_cards(
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 vendor.
List loyalty cards for a company.
Args:
db: Database session
vendor_id: Vendor ID
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 or customer email
search: Search by card number, email, or name
Returns:
(cards, total_count)
@@ -149,18 +227,24 @@ class CardService:
query = (
db.query(LoyaltyCard)
.options(joinedload(LoyaltyCard.customer))
.filter(LoyaltyCard.vendor_id == vendor_id)
.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.ilike(f"%{search}%"))
(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()
@@ -181,7 +265,7 @@ class CardService:
"""List all loyalty cards for a customer."""
return (
db.query(LoyaltyCard)
.options(joinedload(LoyaltyCard.program))
.options(joinedload(LoyaltyCard.program), joinedload(LoyaltyCard.company))
.filter(LoyaltyCard.customer_id == customer_id)
.all()
)
@@ -194,18 +278,18 @@ class CardService:
self,
db: Session,
customer_id: int,
vendor_id: int,
company_id: int,
*,
program_id: int | None = None,
enrolled_at_vendor_id: int | None = None,
) -> LoyaltyCard:
"""
Enroll a customer in a loyalty program.
Enroll a customer in a company's loyalty program.
Args:
db: Database session
customer_id: Customer ID
vendor_id: Vendor ID
program_id: Optional program ID (defaults to vendor's program)
company_id: Company ID
enrolled_at_vendor_id: Vendor where customer enrolled (for analytics)
Returns:
Created loyalty card
@@ -216,35 +300,29 @@ class CardService:
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()
)
program = (
db.query(LoyaltyProgram)
.filter(LoyaltyProgram.company_id == company_id)
.first()
)
if not program:
raise LoyaltyProgramNotFoundException(f"vendor:{vendor_id}")
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_program(db, customer_id, program.id)
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,
vendor_id=vendor_id,
enrolled_at_vendor_id=enrolled_at_vendor_id,
)
db.add(card)
@@ -252,32 +330,88 @@ class CardService:
# Create enrollment transaction
transaction = LoyaltyTransaction(
company_id=company_id,
card_id=card.id,
vendor_id=vendor_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 loyalty program {program.id} "
f"(card: {card.card_number})"
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 deactivate_card(self, db: Session, card_id: int) -> LoyaltyCard:
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=card.vendor_id,
vendor_id=vendor_id,
transaction_type=TransactionType.CARD_DEACTIVATED.value,
transaction_at=datetime.now(UTC),
)
@@ -334,6 +468,7 @@ class CardService:
"""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())
)