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 @@
"""
Staff PIN service.
Company-based PIN operations:
- PINs belong to a company's loyalty program
- Each vendor (location) has its own set of staff PINs
- Staff can only use PINs at their assigned location
Handles PIN operations including:
- PIN creation and management
- PIN verification with lockout
- PIN verification with lockout (per vendor)
- PIN security (failed attempts, lockout)
"""
@@ -41,16 +46,17 @@ class PinService:
db: Session,
program_id: int,
staff_id: str,
*,
vendor_id: int | None = None,
) -> StaffPin | None:
"""Get a staff PIN by employee ID."""
return (
db.query(StaffPin)
.filter(
StaffPin.program_id == program_id,
StaffPin.staff_id == staff_id,
)
.first()
query = db.query(StaffPin).filter(
StaffPin.program_id == program_id,
StaffPin.staff_id == staff_id,
)
if vendor_id:
query = query.filter(StaffPin.vendor_id == vendor_id)
return query.first()
def require_pin(self, db: Session, pin_id: int) -> StaffPin:
"""Get a PIN or raise exception if not found."""
@@ -64,16 +70,61 @@ class PinService:
db: Session,
program_id: int,
*,
vendor_id: int | None = None,
is_active: bool | None = None,
) -> list[StaffPin]:
"""List all staff PINs for a program."""
"""
List staff PINs for a program.
Args:
db: Database session
program_id: Program ID
vendor_id: Optional filter by vendor (location)
is_active: Filter by active status
Returns:
List of StaffPin objects
"""
query = db.query(StaffPin).filter(StaffPin.program_id == program_id)
if vendor_id is not None:
query = query.filter(StaffPin.vendor_id == vendor_id)
if is_active is not None:
query = query.filter(StaffPin.is_active == is_active)
return query.order_by(StaffPin.name).all()
def list_pins_for_company(
self,
db: Session,
company_id: int,
*,
vendor_id: int | None = None,
is_active: bool | None = None,
) -> list[StaffPin]:
"""
List staff PINs for a company.
Args:
db: Database session
company_id: Company ID
vendor_id: Optional filter by vendor (location)
is_active: Filter by active status
Returns:
List of StaffPin objects
"""
query = db.query(StaffPin).filter(StaffPin.company_id == company_id)
if vendor_id is not None:
query = query.filter(StaffPin.vendor_id == vendor_id)
if is_active is not None:
query = query.filter(StaffPin.is_active == is_active)
return query.order_by(StaffPin.vendor_id, StaffPin.name).all()
# =========================================================================
# Write Operations
# =========================================================================
@@ -91,13 +142,21 @@ class PinService:
Args:
db: Database session
program_id: Program ID
vendor_id: Vendor ID
vendor_id: Vendor ID (location where staff works)
data: PIN creation data
Returns:
Created PIN
"""
from app.modules.loyalty.models import LoyaltyProgram
# Get company_id from program
program = db.query(LoyaltyProgram).filter(LoyaltyProgram.id == program_id).first()
if not program:
raise StaffPinNotFoundException(f"program:{program_id}")
pin = StaffPin(
company_id=program.company_id,
program_id=program_id,
vendor_id=vendor_id,
name=data.name,
@@ -109,7 +168,9 @@ class PinService:
db.commit()
db.refresh(pin)
logger.info(f"Created staff PIN {pin.id} for '{pin.name}' in program {program_id}")
logger.info(
f"Created staff PIN {pin.id} for '{pin.name}' at vendor {vendor_id}"
)
return pin
@@ -158,11 +219,12 @@ class PinService:
"""Delete a staff PIN."""
pin = self.require_pin(db, pin_id)
program_id = pin.program_id
vendor_id = pin.vendor_id
db.delete(pin)
db.commit()
logger.info(f"Deleted staff PIN {pin_id} from program {program_id}")
logger.info(f"Deleted staff PIN {pin_id} from vendor {vendor_id}")
def unlock_pin(self, db: Session, pin_id: int) -> StaffPin:
"""Unlock a locked staff PIN."""
@@ -184,16 +246,21 @@ class PinService:
db: Session,
program_id: int,
plain_pin: str,
*,
vendor_id: int | None = None,
) -> StaffPin:
"""
Verify a staff PIN.
Checks all active PINs for the program and returns the matching one.
For company-wide programs, if vendor_id is provided, only checks
PINs assigned to that vendor. This ensures staff can only use
their PIN at their assigned location.
Args:
db: Database session
program_id: Program ID
plain_pin: Plain text PIN to verify
vendor_id: Optional vendor ID to restrict PIN lookup
Returns:
Verified StaffPin object
@@ -202,8 +269,8 @@ class PinService:
InvalidStaffPinException: PIN is invalid
StaffPinLockedException: PIN is locked
"""
# Get all active PINs for the program
pins = self.list_pins(db, program_id, is_active=True)
# Get active PINs (optionally filtered by vendor)
pins = self.list_pins(db, program_id, vendor_id=vendor_id, is_active=True)
if not pins:
raise InvalidStaffPinException()
@@ -220,7 +287,9 @@ class PinService:
pin.record_success()
db.commit()
logger.debug(f"PIN verified for '{pin.name}' in program {program_id}")
logger.debug(
f"PIN verified for '{pin.name}' at vendor {pin.vendor_id}"
)
return pin
@@ -254,6 +323,8 @@ class PinService:
db: Session,
program_id: int,
plain_pin: str,
*,
vendor_id: int | None = None,
) -> StaffPin | None:
"""
Find a matching PIN without recording attempts.
@@ -264,11 +335,12 @@ class PinService:
db: Database session
program_id: Program ID
plain_pin: Plain text PIN to check
vendor_id: Optional vendor ID to restrict lookup
Returns:
Matching StaffPin or None
"""
pins = self.list_pins(db, program_id, is_active=True)
pins = self.list_pins(db, program_id, vendor_id=vendor_id, is_active=True)
for pin in pins:
if not pin.is_locked and pin.verify_pin(plain_pin):