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,6 +2,11 @@
"""
Loyalty program service.
Company-based program management:
- Programs belong to companies, not individual vendors
- All vendors under a company share the same loyalty program
- One program per company
Handles CRUD operations for loyalty programs including:
- Program creation and configuration
- Program updates
@@ -18,7 +23,11 @@ from app.modules.loyalty.exceptions import (
LoyaltyProgramAlreadyExistsException,
LoyaltyProgramNotFoundException,
)
from app.modules.loyalty.models import LoyaltyProgram, LoyaltyType
from app.modules.loyalty.models import (
LoyaltyProgram,
LoyaltyType,
CompanyLoyaltySettings,
)
from app.modules.loyalty.schemas.program import (
ProgramCreate,
ProgramUpdate,
@@ -42,25 +51,53 @@ class ProgramService:
.first()
)
def get_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None:
"""Get a vendor's loyalty program."""
def get_program_by_company(self, db: Session, company_id: int) -> LoyaltyProgram | None:
"""Get a company's loyalty program."""
return (
db.query(LoyaltyProgram)
.filter(LoyaltyProgram.vendor_id == vendor_id)
.filter(LoyaltyProgram.company_id == company_id)
.first()
)
def get_active_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None:
"""Get a vendor's active loyalty program."""
def get_active_program_by_company(self, db: Session, company_id: int) -> LoyaltyProgram | None:
"""Get a company's active loyalty program."""
return (
db.query(LoyaltyProgram)
.filter(
LoyaltyProgram.vendor_id == vendor_id,
LoyaltyProgram.company_id == company_id,
LoyaltyProgram.is_active == True,
)
.first()
)
def get_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None:
"""
Get the loyalty program for a vendor.
Looks up the vendor's company and returns the company's program.
"""
from app.modules.tenancy.models import Vendor
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
return None
return self.get_program_by_company(db, vendor.company_id)
def get_active_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None:
"""
Get the active loyalty program for a vendor.
Looks up the vendor's company and returns the company's active program.
"""
from app.modules.tenancy.models import Vendor
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
return None
return self.get_active_program_by_company(db, vendor.company_id)
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)
@@ -68,6 +105,13 @@ class ProgramService:
raise LoyaltyProgramNotFoundException(str(program_id))
return program
def require_program_by_company(self, db: Session, company_id: int) -> LoyaltyProgram:
"""Get a company's program or raise exception if not found."""
program = self.get_program_by_company(db, company_id)
if not program:
raise LoyaltyProgramNotFoundException(f"company:{company_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)
@@ -82,15 +126,32 @@ class ProgramService:
skip: int = 0,
limit: int = 100,
is_active: bool | None = None,
search: str | None = None,
) -> tuple[list[LoyaltyProgram], int]:
"""List all loyalty programs (admin)."""
query = db.query(LoyaltyProgram)
"""List all loyalty programs (admin).
Args:
db: Database session
skip: Number of records to skip
limit: Maximum records to return
is_active: Filter by active status
search: Search by company name (case-insensitive)
"""
from app.modules.tenancy.models import Company
query = db.query(LoyaltyProgram).join(
Company, LoyaltyProgram.company_id == Company.id
)
if is_active is not None:
query = query.filter(LoyaltyProgram.is_active == is_active)
if search:
search_pattern = f"%{search}%"
query = query.filter(Company.name.ilike(search_pattern))
total = query.count()
programs = query.offset(skip).limit(limit).all()
programs = query.order_by(LoyaltyProgram.created_at.desc()).offset(skip).limit(limit).all()
return programs, total
@@ -101,33 +162,33 @@ class ProgramService:
def create_program(
self,
db: Session,
vendor_id: int,
company_id: int,
data: ProgramCreate,
) -> LoyaltyProgram:
"""
Create a new loyalty program for a vendor.
Create a new loyalty program for a company.
Args:
db: Database session
vendor_id: Vendor ID
company_id: Company ID
data: Program configuration
Returns:
Created program
Raises:
LoyaltyProgramAlreadyExistsException: If vendor already has a program
LoyaltyProgramAlreadyExistsException: If company already has a program
"""
# Check if vendor already has a program
existing = self.get_program_by_vendor(db, vendor_id)
# Check if company already has a program
existing = self.get_program_by_company(db, company_id)
if existing:
raise LoyaltyProgramAlreadyExistsException(vendor_id)
raise LoyaltyProgramAlreadyExistsException(company_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,
company_id=company_id,
loyalty_type=data.loyalty_type,
# Stamps
stamps_target=data.stamps_target,
@@ -136,6 +197,10 @@ class ProgramService:
# Points
points_per_euro=data.points_per_euro,
points_rewards=points_rewards_data,
points_expiration_days=data.points_expiration_days,
welcome_bonus_points=data.welcome_bonus_points,
minimum_redemption_points=data.minimum_redemption_points,
minimum_purchase_cents=data.minimum_purchase_cents,
# Anti-fraud
cooldown_minutes=data.cooldown_minutes,
max_daily_stamps=data.max_daily_stamps,
@@ -155,11 +220,19 @@ class ProgramService:
)
db.add(program)
db.flush()
# Create default company settings
settings = CompanyLoyaltySettings(
company_id=company_id,
)
db.add(settings)
db.commit()
db.refresh(program)
logger.info(
f"Created loyalty program {program.id} for vendor {vendor_id} "
f"Created loyalty program {program.id} for company {company_id} "
f"(type: {program.loyalty_type})"
)
@@ -224,12 +297,39 @@ class ProgramService:
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
company_id = program.company_id
# Also delete company settings
db.query(CompanyLoyaltySettings).filter(
CompanyLoyaltySettings.company_id == company_id
).delete()
db.delete(program)
db.commit()
logger.info(f"Deleted loyalty program {program_id} for vendor {vendor_id}")
logger.info(f"Deleted loyalty program {program_id} for company {company_id}")
# =========================================================================
# Company Settings
# =========================================================================
def get_company_settings(self, db: Session, company_id: int) -> CompanyLoyaltySettings | None:
"""Get company loyalty settings."""
return (
db.query(CompanyLoyaltySettings)
.filter(CompanyLoyaltySettings.company_id == company_id)
.first()
)
def get_or_create_company_settings(self, db: Session, company_id: int) -> CompanyLoyaltySettings:
"""Get or create company loyalty settings."""
settings = self.get_company_settings(db, company_id)
if not settings:
settings = CompanyLoyaltySettings(company_id=company_id)
db.add(settings)
db.commit()
db.refresh(settings)
return settings
# =========================================================================
# Statistics
@@ -374,6 +474,196 @@ class ProgramService:
"estimated_liability_cents": estimated_liability,
}
def get_company_stats(self, db: Session, company_id: int) -> dict:
"""
Get statistics for a company's loyalty program across all locations.
Returns dict with per-vendor breakdown.
"""
from datetime import UTC, datetime, timedelta
from sqlalchemy import func
from app.modules.loyalty.models import LoyaltyCard, LoyaltyTransaction
from app.modules.tenancy.models import Vendor
program = self.get_program_by_company(db, company_id)
# Base stats dict
stats = {
"company_id": company_id,
"program_id": program.id if program else None,
"total_cards": 0,
"active_cards": 0,
"total_points_issued": 0,
"total_points_redeemed": 0,
"points_issued_30d": 0,
"points_redeemed_30d": 0,
"transactions_30d": 0,
"program": None,
"locations": [],
}
if not program:
return stats
# Add program info
stats["program"] = {
"id": program.id,
"display_name": program.display_name,
"card_name": program.card_name,
"loyalty_type": program.loyalty_type.value if hasattr(program.loyalty_type, 'value') else str(program.loyalty_type),
"points_per_euro": program.points_per_euro,
"welcome_bonus_points": program.welcome_bonus_points,
"minimum_redemption_points": program.minimum_redemption_points,
"points_expiration_days": program.points_expiration_days,
"is_active": program.is_active,
}
thirty_days_ago = datetime.now(UTC) - timedelta(days=30)
# Total cards
stats["total_cards"] = (
db.query(func.count(LoyaltyCard.id))
.filter(LoyaltyCard.company_id == company_id)
.scalar()
or 0
)
# Active cards
stats["active_cards"] = (
db.query(func.count(LoyaltyCard.id))
.filter(
LoyaltyCard.company_id == company_id,
LoyaltyCard.is_active == True,
)
.scalar()
or 0
)
# Total points issued (all time)
stats["total_points_issued"] = (
db.query(func.sum(LoyaltyTransaction.points_delta))
.filter(
LoyaltyTransaction.company_id == company_id,
LoyaltyTransaction.points_delta > 0,
)
.scalar()
or 0
)
# Total points redeemed (all time)
stats["total_points_redeemed"] = (
db.query(func.sum(func.abs(LoyaltyTransaction.points_delta)))
.filter(
LoyaltyTransaction.company_id == company_id,
LoyaltyTransaction.points_delta < 0,
)
.scalar()
or 0
)
# Points issued (30 days)
stats["points_issued_30d"] = (
db.query(func.sum(LoyaltyTransaction.points_delta))
.filter(
LoyaltyTransaction.company_id == company_id,
LoyaltyTransaction.points_delta > 0,
LoyaltyTransaction.transaction_at >= thirty_days_ago,
)
.scalar()
or 0
)
# Points redeemed (30 days)
stats["points_redeemed_30d"] = (
db.query(func.sum(func.abs(LoyaltyTransaction.points_delta)))
.filter(
LoyaltyTransaction.company_id == company_id,
LoyaltyTransaction.points_delta < 0,
LoyaltyTransaction.transaction_at >= thirty_days_ago,
)
.scalar()
or 0
)
# Transactions (30 days)
stats["transactions_30d"] = (
db.query(func.count(LoyaltyTransaction.id))
.filter(
LoyaltyTransaction.company_id == company_id,
LoyaltyTransaction.transaction_at >= thirty_days_ago,
)
.scalar()
or 0
)
# Get all vendors for this company for location breakdown
vendors = db.query(Vendor).filter(Vendor.company_id == company_id).all()
location_stats = []
for vendor in vendors:
# Cards enrolled at this vendor
enrolled_count = (
db.query(func.count(LoyaltyCard.id))
.filter(
LoyaltyCard.company_id == company_id,
LoyaltyCard.enrolled_at_vendor_id == vendor.id,
)
.scalar()
or 0
)
# Points earned at this vendor
points_earned = (
db.query(func.sum(LoyaltyTransaction.points_delta))
.filter(
LoyaltyTransaction.company_id == company_id,
LoyaltyTransaction.vendor_id == vendor.id,
LoyaltyTransaction.points_delta > 0,
)
.scalar()
or 0
)
# Points redeemed at this vendor
points_redeemed = (
db.query(func.sum(func.abs(LoyaltyTransaction.points_delta)))
.filter(
LoyaltyTransaction.company_id == company_id,
LoyaltyTransaction.vendor_id == vendor.id,
LoyaltyTransaction.points_delta < 0,
)
.scalar()
or 0
)
# Transactions (30 days) at this vendor
transactions_30d = (
db.query(func.count(LoyaltyTransaction.id))
.filter(
LoyaltyTransaction.company_id == company_id,
LoyaltyTransaction.vendor_id == vendor.id,
LoyaltyTransaction.transaction_at >= thirty_days_ago,
)
.scalar()
or 0
)
location_stats.append({
"vendor_id": vendor.id,
"vendor_name": vendor.name,
"vendor_code": vendor.vendor_code,
"enrolled_count": enrolled_count,
"points_earned": points_earned,
"points_redeemed": points_redeemed,
"transactions_30d": transactions_30d,
})
stats["locations"] = location_stats
return stats
# Singleton instance
program_service = ProgramService()