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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user