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>
225 lines
6.4 KiB
Python
225 lines
6.4 KiB
Python
# app/modules/loyalty/models/staff_pin.py
|
|
"""
|
|
Staff PIN database model.
|
|
|
|
Company-based staff PINs:
|
|
- 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
|
|
|
|
Provides fraud prevention by requiring staff to authenticate
|
|
before performing stamp/points operations. Includes:
|
|
- Secure PIN hashing with bcrypt
|
|
- Failed attempt tracking
|
|
- Automatic lockout after too many failures
|
|
"""
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
import bcrypt
|
|
from sqlalchemy import (
|
|
Boolean,
|
|
Column,
|
|
DateTime,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
String,
|
|
)
|
|
from sqlalchemy.orm import relationship
|
|
|
|
from app.core.database import Base
|
|
from models.database.base import TimestampMixin
|
|
|
|
|
|
class StaffPin(Base, TimestampMixin):
|
|
"""
|
|
Staff PIN for loyalty operations.
|
|
|
|
Each staff member can have their own PIN to authenticate
|
|
stamp/points operations. PINs are hashed with bcrypt and
|
|
include lockout protection.
|
|
|
|
PINs are scoped to a specific vendor (location) within the
|
|
company's loyalty program.
|
|
"""
|
|
|
|
__tablename__ = "staff_pins"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
# Company association
|
|
company_id = Column(
|
|
Integer,
|
|
ForeignKey("companies.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
comment="Company that owns the loyalty program",
|
|
)
|
|
|
|
# Program and vendor relationships
|
|
program_id = Column(
|
|
Integer,
|
|
ForeignKey("loyalty_programs.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
vendor_id = Column(
|
|
Integer,
|
|
ForeignKey("vendors.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
comment="Vendor (location) where this staff member works",
|
|
)
|
|
|
|
# Staff identity
|
|
name = Column(
|
|
String(100),
|
|
nullable=False,
|
|
comment="Staff member name",
|
|
)
|
|
staff_id = Column(
|
|
String(50),
|
|
nullable=True,
|
|
index=True,
|
|
comment="Optional staff ID/employee number",
|
|
)
|
|
|
|
# PIN authentication
|
|
pin_hash = Column(
|
|
String(255),
|
|
nullable=False,
|
|
comment="bcrypt hash of PIN",
|
|
)
|
|
|
|
# Security tracking
|
|
failed_attempts = Column(
|
|
Integer,
|
|
default=0,
|
|
nullable=False,
|
|
comment="Consecutive failed PIN attempts",
|
|
)
|
|
locked_until = Column(
|
|
DateTime(timezone=True),
|
|
nullable=True,
|
|
comment="Lockout expires at this time",
|
|
)
|
|
last_used_at = Column(
|
|
DateTime(timezone=True),
|
|
nullable=True,
|
|
comment="Last successful use of PIN",
|
|
)
|
|
|
|
# Status
|
|
is_active = Column(
|
|
Boolean,
|
|
default=True,
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
|
|
# =========================================================================
|
|
# Relationships
|
|
# =========================================================================
|
|
company = relationship("Company", backref="staff_pins")
|
|
program = relationship("LoyaltyProgram", back_populates="staff_pins")
|
|
vendor = relationship("Vendor", backref="staff_pins")
|
|
|
|
# Indexes
|
|
__table_args__ = (
|
|
Index("idx_staff_pin_company_active", "company_id", "is_active"),
|
|
Index("idx_staff_pin_vendor_active", "vendor_id", "is_active"),
|
|
Index("idx_staff_pin_program_active", "program_id", "is_active"),
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<StaffPin(id={self.id}, name='{self.name}', vendor_id={self.vendor_id}, active={self.is_active})>"
|
|
|
|
# =========================================================================
|
|
# PIN Operations
|
|
# =========================================================================
|
|
|
|
def set_pin(self, plain_pin: str) -> None:
|
|
"""
|
|
Hash and store a PIN.
|
|
|
|
Args:
|
|
plain_pin: The plain text PIN (typically 4-6 digits)
|
|
"""
|
|
salt = bcrypt.gensalt()
|
|
self.pin_hash = bcrypt.hashpw(plain_pin.encode("utf-8"), salt).decode("utf-8")
|
|
|
|
def verify_pin(self, plain_pin: str) -> bool:
|
|
"""
|
|
Verify a PIN against the stored hash.
|
|
|
|
Args:
|
|
plain_pin: The plain text PIN to verify
|
|
|
|
Returns:
|
|
True if PIN matches, False otherwise
|
|
"""
|
|
if not self.pin_hash:
|
|
return False
|
|
return bcrypt.checkpw(plain_pin.encode("utf-8"), self.pin_hash.encode("utf-8"))
|
|
|
|
# =========================================================================
|
|
# Lockout Management
|
|
# =========================================================================
|
|
|
|
@property
|
|
def is_locked(self) -> bool:
|
|
"""Check if PIN is currently locked out."""
|
|
if not self.locked_until:
|
|
return False
|
|
return datetime.now(UTC) < self.locked_until
|
|
|
|
def record_failed_attempt(self, max_attempts: int = 5, lockout_minutes: int = 30) -> bool:
|
|
"""
|
|
Record a failed PIN attempt.
|
|
|
|
Args:
|
|
max_attempts: Maximum failed attempts before lockout
|
|
lockout_minutes: Duration of lockout in minutes
|
|
|
|
Returns:
|
|
True if account is now locked
|
|
"""
|
|
self.failed_attempts += 1
|
|
|
|
if self.failed_attempts >= max_attempts:
|
|
from datetime import timedelta
|
|
|
|
self.locked_until = datetime.now(UTC) + timedelta(minutes=lockout_minutes)
|
|
return True
|
|
|
|
return False
|
|
|
|
def record_success(self) -> None:
|
|
"""Record a successful PIN verification."""
|
|
self.failed_attempts = 0
|
|
self.locked_until = None
|
|
self.last_used_at = datetime.now(UTC)
|
|
|
|
def unlock(self) -> None:
|
|
"""Manually unlock a PIN (admin action)."""
|
|
self.failed_attempts = 0
|
|
self.locked_until = None
|
|
|
|
# =========================================================================
|
|
# Properties
|
|
# =========================================================================
|
|
|
|
@property
|
|
def remaining_attempts(self) -> int:
|
|
"""Get remaining attempts before lockout (assuming max 5)."""
|
|
return max(0, 5 - self.failed_attempts)
|
|
|
|
@property
|
|
def lockout_remaining_seconds(self) -> int | None:
|
|
"""Get seconds remaining in lockout, or None if not locked."""
|
|
if not self.locked_until:
|
|
return None
|
|
remaining = (self.locked_until - datetime.now(UTC)).total_seconds()
|
|
return max(0, int(remaining))
|