# app/modules/loyalty/models/loyalty_transaction.py """ Loyalty transaction database model. Merchant-based transaction tracking: - Tracks which merchant and store processed each transaction - Enables chain-wide reporting while maintaining per-location audit trails - Supports voiding transactions for returns Records all loyalty events including: - Stamps earned and redeemed - Points earned and redeemed - Welcome bonuses and expirations - Associated metadata (staff PIN, purchase amount, IP, etc.) """ import enum from sqlalchemy import ( Column, DateTime, ForeignKey, Index, Integer, String, Text, ) from sqlalchemy.orm import relationship from app.core.database import Base from models.database.base import TimestampMixin class TransactionType(str, enum.Enum): """Type of loyalty transaction.""" # Stamps STAMP_EARNED = "stamp_earned" STAMP_REDEEMED = "stamp_redeemed" STAMP_VOIDED = "stamp_voided" # Stamps voided due to return # Points POINTS_EARNED = "points_earned" POINTS_REDEEMED = "points_redeemed" POINTS_VOIDED = "points_voided" # Points voided due to return # Adjustments (manual corrections by staff) STAMP_ADJUSTMENT = "stamp_adjustment" POINTS_ADJUSTMENT = "points_adjustment" # Card lifecycle CARD_CREATED = "card_created" CARD_DEACTIVATED = "card_deactivated" # Bonuses and expiration WELCOME_BONUS = "welcome_bonus" # Welcome bonus points on enrollment POINTS_EXPIRED = "points_expired" # Points expired due to inactivity class LoyaltyTransaction(Base, TimestampMixin): """ Loyalty transaction record. Immutable audit log of all loyalty operations for fraud detection, analytics, and customer support. Tracks which store (location) processed the transaction, enabling chain-wide reporting while maintaining per-location audit trails. """ __tablename__ = "loyalty_transactions" id = Column(Integer, primary_key=True, index=True) # Merchant association merchant_id = Column( Integer, ForeignKey("merchants.id", ondelete="CASCADE"), nullable=False, index=True, comment="Merchant that owns the loyalty program", ) # Core relationships card_id = Column( Integer, ForeignKey("loyalty_cards.id", ondelete="CASCADE"), nullable=False, index=True, ) store_id = Column( Integer, ForeignKey("stores.id", ondelete="SET NULL"), nullable=True, index=True, comment="Store (location) that processed this transaction", ) staff_pin_id = Column( Integer, ForeignKey("staff_pins.id", ondelete="SET NULL"), nullable=True, index=True, comment="Staff PIN used for this operation", ) # Related transaction (for voids/returns) related_transaction_id = Column( Integer, ForeignKey("loyalty_transactions.id", ondelete="SET NULL"), nullable=True, index=True, comment="Original transaction (for voids/returns)", ) # ========================================================================= # Transaction Details # ========================================================================= transaction_type = Column( String(30), nullable=False, index=True, ) # Delta values (positive for earn, negative for redeem/adjustment) stamps_delta = Column( Integer, default=0, nullable=False, comment="Change in stamps (+1 for earn, -N for redeem)", ) points_delta = Column( Integer, default=0, nullable=False, comment="Change in points (+N for earn, -N for redeem)", ) # Balance after transaction (for historical reference) stamps_balance_after = Column( Integer, nullable=True, comment="Stamp count after this transaction", ) points_balance_after = Column( Integer, nullable=True, comment="Points balance after this transaction", ) # ========================================================================= # Purchase Context (for points earned) # ========================================================================= purchase_amount_cents = Column( Integer, nullable=True, comment="Purchase amount in cents (for points calculation)", ) order_reference = Column( String(100), nullable=True, index=True, comment="Reference to order that triggered points", ) # ========================================================================= # Reward Context (for redemptions) # ========================================================================= reward_id = Column( String(50), nullable=True, comment="ID of redeemed reward (from program.points_rewards)", ) reward_description = Column( String(255), nullable=True, comment="Description of redeemed reward", ) # ========================================================================= # Audit Fields # ========================================================================= ip_address = Column( String(45), nullable=True, comment="IP address of requester (IPv4 or IPv6)", ) user_agent = Column( String(500), nullable=True, comment="User agent string", ) notes = Column( Text, nullable=True, comment="Additional notes (e.g., reason for adjustment)", ) # ========================================================================= # Timestamps # ========================================================================= transaction_at = Column( DateTime(timezone=True), nullable=False, index=True, comment="When the transaction occurred (may differ from created_at)", ) # ========================================================================= # Relationships # ========================================================================= merchant = relationship("Merchant", backref="loyalty_transactions") card = relationship("LoyaltyCard", back_populates="transactions") store = relationship("Store", backref="loyalty_transactions") staff_pin = relationship("StaffPin", backref="transactions") related_transaction = relationship( "LoyaltyTransaction", remote_side=[id], backref="voiding_transactions", ) # Indexes __table_args__ = ( Index("idx_loyalty_tx_card_type", "card_id", "transaction_type"), Index("idx_loyalty_tx_store_date", "store_id", "transaction_at"), Index("idx_loyalty_tx_type_date", "transaction_type", "transaction_at"), Index("idx_loyalty_tx_merchant_date", "merchant_id", "transaction_at"), Index("idx_loyalty_tx_merchant_store", "merchant_id", "store_id"), ) def __repr__(self) -> str: return ( f"" ) # ========================================================================= # Properties # ========================================================================= @property def is_stamp_transaction(self) -> bool: """Check if this is a stamp-related transaction.""" return self.transaction_type in ( TransactionType.STAMP_EARNED.value, TransactionType.STAMP_REDEEMED.value, TransactionType.STAMP_VOIDED.value, TransactionType.STAMP_ADJUSTMENT.value, ) @property def is_points_transaction(self) -> bool: """Check if this is a points-related transaction.""" return self.transaction_type in ( TransactionType.POINTS_EARNED.value, TransactionType.POINTS_REDEEMED.value, TransactionType.POINTS_ADJUSTMENT.value, TransactionType.POINTS_VOIDED.value, TransactionType.WELCOME_BONUS.value, TransactionType.POINTS_EXPIRED.value, ) @property def is_earn_transaction(self) -> bool: """Check if this is an earning transaction (stamp or points).""" return self.transaction_type in ( TransactionType.STAMP_EARNED.value, TransactionType.POINTS_EARNED.value, TransactionType.WELCOME_BONUS.value, ) @property def is_redemption_transaction(self) -> bool: """Check if this is a redemption transaction.""" return self.transaction_type in ( TransactionType.STAMP_REDEEMED.value, TransactionType.POINTS_REDEEMED.value, ) @property def is_void_transaction(self) -> bool: """Check if this is a void transaction (for returns).""" return self.transaction_type in ( TransactionType.POINTS_VOIDED.value, TransactionType.STAMP_VOIDED.value, ) @property def staff_name(self) -> str | None: """Get the name of the staff member who performed this transaction.""" if self.staff_pin: return self.staff_pin.name return None @property def store_name(self) -> str | None: """Get the name of the store where this transaction occurred.""" if self.store: return self.store.name return None