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