# app/modules/loyalty/models/loyalty_card.py """ Loyalty card database model. Represents a customer's loyalty card (PassObject) that tracks: - Stamp count and history - Points balance and history - Wallet integration (Google/Apple pass IDs) - QR code for scanning """ import secrets from datetime import UTC, datetime 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 def generate_card_number() -> str: """Generate a unique 12-digit card number.""" return "".join([str(secrets.randbelow(10)) for _ in range(12)]) def generate_qr_code_data() -> str: """Generate unique QR code data (URL-safe token).""" return secrets.token_urlsafe(24) def generate_apple_auth_token() -> str: """Generate Apple Wallet authentication token.""" return secrets.token_urlsafe(32) class LoyaltyCard(Base, TimestampMixin): """ Customer's loyalty card (PassObject). Links a customer to a vendor's loyalty program and tracks: - Stamps and points balances - Wallet pass integration - Activity timestamps """ __tablename__ = "loyalty_cards" id = Column(Integer, primary_key=True, index=True) # Relationships customer_id = Column( Integer, ForeignKey("customers.id", ondelete="CASCADE"), nullable=False, index=True, ) 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="Denormalized for query performance", ) # ========================================================================= # Card Identification # ========================================================================= card_number = Column( String(20), unique=True, nullable=False, default=generate_card_number, index=True, comment="Human-readable card number", ) qr_code_data = Column( String(50), unique=True, nullable=False, default=generate_qr_code_data, index=True, comment="Data encoded in QR code for scanning", ) # ========================================================================= # Stamps Tracking # ========================================================================= stamp_count = Column( Integer, default=0, nullable=False, comment="Current stamps toward next reward", ) total_stamps_earned = Column( Integer, default=0, nullable=False, comment="Lifetime stamps earned", ) stamps_redeemed = Column( Integer, default=0, nullable=False, comment="Total rewards redeemed (stamps reset on redemption)", ) # ========================================================================= # Points Tracking # ========================================================================= points_balance = Column( Integer, default=0, nullable=False, comment="Current available points", ) total_points_earned = Column( Integer, default=0, nullable=False, comment="Lifetime points earned", ) points_redeemed = Column( Integer, default=0, nullable=False, comment="Lifetime points redeemed", ) # ========================================================================= # Wallet Integration # ========================================================================= # Google Wallet google_object_id = Column( String(200), nullable=True, index=True, comment="Google Wallet Loyalty Object ID", ) google_object_jwt = Column( String(2000), nullable=True, comment="JWT for Google Wallet 'Add to Wallet' button", ) # Apple Wallet apple_serial_number = Column( String(100), nullable=True, unique=True, index=True, comment="Apple Wallet pass serial number", ) apple_auth_token = Column( String(100), nullable=True, default=generate_apple_auth_token, comment="Apple Wallet authentication token for updates", ) # ========================================================================= # Activity Timestamps # ========================================================================= last_stamp_at = Column( DateTime(timezone=True), nullable=True, comment="Last stamp added (for cooldown)", ) last_points_at = Column( DateTime(timezone=True), nullable=True, comment="Last points earned", ) last_redemption_at = Column( DateTime(timezone=True), nullable=True, comment="Last reward redemption", ) # ========================================================================= # Status # ========================================================================= is_active = Column( Boolean, default=True, nullable=False, index=True, ) # ========================================================================= # Relationships # ========================================================================= customer = relationship("Customer", backref="loyalty_cards") program = relationship("LoyaltyProgram", back_populates="cards") vendor = relationship("Vendor", backref="loyalty_cards") transactions = relationship( "LoyaltyTransaction", back_populates="card", cascade="all, delete-orphan", order_by="desc(LoyaltyTransaction.created_at)", ) apple_devices = relationship( "AppleDeviceRegistration", back_populates="card", cascade="all, delete-orphan", ) # Indexes __table_args__ = ( Index("idx_loyalty_card_customer_program", "customer_id", "program_id", unique=True), Index("idx_loyalty_card_vendor_active", "vendor_id", "is_active"), ) def __repr__(self) -> str: return f"" # ========================================================================= # Stamp Operations # ========================================================================= def add_stamp(self) -> bool: """ Add a stamp to the card. Returns True if this stamp completed a reward cycle. """ self.stamp_count += 1 self.total_stamps_earned += 1 self.last_stamp_at = datetime.now(UTC) # Check if reward cycle is complete (handled by caller with program.stamps_target) return False # Caller checks against program.stamps_target def redeem_stamps(self, stamps_target: int) -> bool: """ Redeem stamps for a reward. Args: stamps_target: Number of stamps required for reward Returns True if redemption was successful. """ if self.stamp_count >= stamps_target: self.stamp_count -= stamps_target self.stamps_redeemed += 1 self.last_redemption_at = datetime.now(UTC) return True return False # ========================================================================= # Points Operations # ========================================================================= def add_points(self, points: int) -> None: """Add points to the card.""" self.points_balance += points self.total_points_earned += points self.last_points_at = datetime.now(UTC) def redeem_points(self, points: int) -> bool: """ Redeem points for a reward. Returns True if redemption was successful. """ if self.points_balance >= points: self.points_balance -= points self.points_redeemed += points self.last_redemption_at = datetime.now(UTC) return True return False # ========================================================================= # Properties # ========================================================================= @property def stamps_until_reward(self) -> int | None: """Get stamps remaining until next reward (needs program context).""" # This should be calculated with program.stamps_target return None def can_stamp(self, cooldown_minutes: int) -> tuple[bool, str | None]: """ Check if card can receive a stamp (cooldown check). Args: cooldown_minutes: Minutes required between stamps Returns: (can_stamp, error_message) """ if not self.last_stamp_at: return True, None now = datetime.now(UTC) elapsed = (now - self.last_stamp_at).total_seconds() / 60 if elapsed < cooldown_minutes: remaining = int(cooldown_minutes - elapsed) return False, f"Please wait {remaining} minutes before next stamp" return True, None