# app/modules/loyalty/models/loyalty_program.py """ Loyalty program database model. Merchant-based loyalty program configuration: - Program belongs to Merchant (one program per merchant) - All stores under a merchant share the same loyalty program - Customers earn and redeem points at any location (store) within the merchant Defines: - Program type (stamps, points, hybrid) - Stamp configuration (target, reward description) - Points configuration (per euro rate, rewards catalog) - Anti-fraud settings (cooldown, daily limits, PIN requirement) - Branding (card name, color, logo) - Wallet integration IDs (Google, Apple) """ import enum from datetime import UTC, datetime from sqlalchemy import ( Boolean, Column, DateTime, ForeignKey, Index, Integer, String, Text, ) from sqlalchemy.dialects.sqlite import JSON from sqlalchemy.orm import relationship from app.core.database import Base from models.database.base import SoftDeleteMixin, TimestampMixin class LoyaltyType(str, enum.Enum): """Type of loyalty program.""" STAMPS = "stamps" # Collect N stamps, get reward POINTS = "points" # Earn points per euro, redeem for rewards HYBRID = "hybrid" # Both stamps and points class LoyaltyProgram(Base, TimestampMixin, SoftDeleteMixin): """ Merchant's loyalty program configuration. Program belongs to Merchant (chain-wide shared points). All stores under a merchant share the same loyalty program. Customers can earn and redeem at any store within the merchant. Each merchant can have one loyalty program that defines: - Program type and mechanics - Stamp or points configuration - Anti-fraud rules - Branding and wallet integration """ __tablename__ = "loyalty_programs" id = Column(Integer, primary_key=True, index=True) # Merchant association (one program per merchant) merchant_id = Column( Integer, ForeignKey("merchants.id", ondelete="CASCADE"), unique=True, nullable=False, index=True, comment="Merchant that owns this program (chain-wide)", ) # Program type loyalty_type = Column( String(20), default=LoyaltyType.POINTS.value, nullable=False, ) # ========================================================================= # Stamps Configuration # ========================================================================= stamps_target = Column( Integer, default=10, nullable=False, comment="Number of stamps needed for reward", ) stamps_reward_description = Column( String(255), default="Free item", nullable=False, comment="Description of stamp reward", ) stamps_reward_value_cents = Column( Integer, nullable=True, comment="Value of stamp reward in cents (for analytics)", ) # ========================================================================= # Points Configuration # ========================================================================= points_per_euro = Column( Integer, default=1, nullable=False, comment="Points earned per euro spent (1 euro = X points)", ) points_rewards = Column( JSON, default=list, nullable=False, comment="List of point rewards: [{id, name, points_required, description}]", ) # Points expiration and bonus settings points_expiration_days = Column( Integer, nullable=True, comment="Days of inactivity before points expire (None = never expire)", ) welcome_bonus_points = Column( Integer, default=0, nullable=False, comment="Bonus points awarded on enrollment", ) minimum_redemption_points = Column( Integer, default=100, nullable=False, comment="Minimum points required for any redemption", ) minimum_purchase_cents = Column( Integer, default=0, nullable=False, comment="Minimum purchase amount (cents) to earn points (0 = no minimum)", ) # Future tier configuration (Bronze/Silver/Gold) tier_config = Column( JSON, nullable=True, comment='Future: Tier thresholds {"bronze": 0, "silver": 1000, "gold": 5000}', ) # ========================================================================= # Anti-Fraud Settings # ========================================================================= cooldown_minutes = Column( Integer, default=15, nullable=False, comment="Minutes between stamps for same card", ) max_daily_stamps = Column( Integer, default=5, nullable=False, comment="Maximum stamps per card per day", ) require_staff_pin = Column( Boolean, default=True, nullable=False, comment="Require staff PIN for stamp/points operations", ) # ========================================================================= # Branding # ========================================================================= card_name = Column( String(100), nullable=True, comment="Display name for loyalty card", ) card_color = Column( String(7), default="#4F46E5", nullable=False, comment="Primary color for card (hex)", ) card_secondary_color = Column( String(7), nullable=True, comment="Secondary color for card (hex)", ) logo_url = Column( String(500), nullable=True, comment="URL to merchant logo for card", ) hero_image_url = Column( String(500), nullable=True, comment="URL to hero image for card", ) # ========================================================================= # Wallet Integration # ========================================================================= google_issuer_id = Column( String(100), nullable=True, comment="Google Wallet Issuer ID", ) google_class_id = Column( String(200), nullable=True, comment="Google Wallet Loyalty Class ID", ) apple_pass_type_id = Column( String(100), nullable=True, comment="Apple Wallet Pass Type ID", ) # ========================================================================= # Terms and Conditions # ========================================================================= terms_text = Column( Text, nullable=True, comment="Loyalty program terms and conditions", ) privacy_url = Column( String(500), nullable=True, comment="URL to privacy policy", ) # ========================================================================= # Status # ========================================================================= is_active = Column( Boolean, default=True, nullable=False, index=True, ) activated_at = Column( DateTime(timezone=True), nullable=True, comment="When program was first activated", ) # ========================================================================= # Relationships # ========================================================================= merchant = relationship("Merchant", backref="loyalty_program") cards = relationship( "LoyaltyCard", back_populates="program", cascade="all, delete-orphan", ) staff_pins = relationship( "StaffPin", back_populates="program", cascade="all, delete-orphan", ) # Indexes __table_args__ = ( Index("idx_loyalty_program_merchant_active", "merchant_id", "is_active"), ) def __repr__(self) -> str: return f"" # ========================================================================= # Properties # ========================================================================= @property def is_stamps_enabled(self) -> bool: """Check if stamps are enabled for this program.""" return self.loyalty_type in (LoyaltyType.STAMPS.value, LoyaltyType.HYBRID.value) @property def is_points_enabled(self) -> bool: """Check if points are enabled for this program.""" return self.loyalty_type in (LoyaltyType.POINTS.value, LoyaltyType.HYBRID.value) @property def display_name(self) -> str: """Get display name for the program.""" return self.card_name or "Loyalty Card" def get_points_reward(self, reward_id: str) -> dict | None: """Get a specific points reward by ID.""" rewards = self.points_rewards or [] for reward in rewards: if reward.get("id") == reward_id: return reward return None def activate(self) -> None: """Activate the loyalty program.""" self.is_active = True if not self.activated_at: self.activated_at = datetime.now(UTC) def deactivate(self) -> None: """Deactivate the loyalty program.""" self.is_active = False