From d8f3338bc8ce4b04c14b084ba72dfde09b0105c1 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Thu, 5 Feb 2026 22:10:27 +0100 Subject: [PATCH] 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 --- app/modules/loyalty/models/__init__.py | 10 + .../loyalty/models/company_settings.py | 135 +++++++ app/modules/loyalty/models/loyalty_card.py | 83 ++++- app/modules/loyalty/models/loyalty_program.py | 68 +++- .../loyalty/models/loyalty_transaction.py | 68 +++- app/modules/loyalty/models/staff_pin.py | 25 +- app/modules/loyalty/routes/api/admin.py | 139 +++++++- app/modules/loyalty/routes/api/storefront.py | 216 ++++++++++++ app/modules/loyalty/routes/api/vendor.py | 244 +++++++++++-- app/modules/loyalty/routes/pages/__init__.py | 13 +- app/modules/loyalty/routes/pages/admin.py | 124 +++++++ .../loyalty/routes/pages/storefront.py | 151 ++++++++ app/modules/loyalty/routes/pages/vendor.py | 226 ++++++++++++ app/modules/loyalty/schemas/__init__.py | 26 ++ app/modules/loyalty/schemas/card.py | 60 +++- app/modules/loyalty/schemas/points.py | 113 ++++++ app/modules/loyalty/schemas/program.py | 125 ++++++- app/modules/loyalty/schemas/stamp.py | 72 ++++ app/modules/loyalty/services/card_service.py | 209 +++++++++-- app/modules/loyalty/services/pin_service.py | 106 +++++- .../loyalty/services/points_service.py | 203 ++++++++++- .../loyalty/services/program_service.py | 332 ++++++++++++++++-- app/modules/loyalty/services/stamp_service.py | 156 +++++++- app/modules/loyalty/static/admin/js/.gitkeep | 0 .../static/admin/js/loyalty-analytics.js | 115 ++++++ .../static/admin/js/loyalty-company-detail.js | 208 +++++++++++ .../admin/js/loyalty-company-settings.js | 173 +++++++++ .../static/admin/js/loyalty-programs.js | 264 ++++++++++++++ .../static/storefront/js/loyalty-dashboard.js | 87 +++++ .../static/storefront/js/loyalty-enroll.js | 94 +++++ .../static/storefront/js/loyalty-history.js | 119 +++++++ app/modules/loyalty/static/vendor/js/.gitkeep | 0 .../static/vendor/js/loyalty-card-detail.js | 104 ++++++ .../loyalty/static/vendor/js/loyalty-cards.js | 160 +++++++++ .../static/vendor/js/loyalty-enroll.js | 101 ++++++ .../static/vendor/js/loyalty-settings.js | 118 +++++++ .../loyalty/static/vendor/js/loyalty-stats.js | 74 ++++ .../static/vendor/js/loyalty-terminal.js | 286 +++++++++++++++ app/modules/loyalty/tasks/__init__.py | 15 +- app/modules/loyalty/tasks/point_expiration.py | 185 +++++++++- .../templates/loyalty/admin/analytics.html | 162 +++++++++ .../loyalty/admin/company-detail.html | 238 +++++++++++++ .../loyalty/admin/company-settings.html | 180 ++++++++++ .../templates/loyalty/admin/programs.html | 241 +++++++++++++ .../loyalty/storefront/dashboard.html | 226 ++++++++++++ .../loyalty/storefront/enroll-success.html | 87 +++++ .../templates/loyalty/storefront/enroll.html | 135 +++++++ .../templates/loyalty/storefront/history.html | 107 ++++++ .../templates/loyalty/vendor/card-detail.html | 158 +++++++++ .../templates/loyalty/vendor/cards.html | 150 ++++++++ .../templates/loyalty/vendor/enroll.html | 146 ++++++++ .../templates/loyalty/vendor/settings.html | 158 +++++++++ .../templates/loyalty/vendor/stats.html | 134 +++++++ .../templates/loyalty/vendor/terminal.html | 309 ++++++++++++++++ 54 files changed, 7252 insertions(+), 186 deletions(-) create mode 100644 app/modules/loyalty/models/company_settings.py create mode 100644 app/modules/loyalty/routes/api/storefront.py create mode 100644 app/modules/loyalty/routes/pages/admin.py create mode 100644 app/modules/loyalty/routes/pages/storefront.py create mode 100644 app/modules/loyalty/routes/pages/vendor.py delete mode 100644 app/modules/loyalty/static/admin/js/.gitkeep create mode 100644 app/modules/loyalty/static/admin/js/loyalty-analytics.js create mode 100644 app/modules/loyalty/static/admin/js/loyalty-company-detail.js create mode 100644 app/modules/loyalty/static/admin/js/loyalty-company-settings.js create mode 100644 app/modules/loyalty/static/admin/js/loyalty-programs.js create mode 100644 app/modules/loyalty/static/storefront/js/loyalty-dashboard.js create mode 100644 app/modules/loyalty/static/storefront/js/loyalty-enroll.js create mode 100644 app/modules/loyalty/static/storefront/js/loyalty-history.js delete mode 100644 app/modules/loyalty/static/vendor/js/.gitkeep create mode 100644 app/modules/loyalty/static/vendor/js/loyalty-card-detail.js create mode 100644 app/modules/loyalty/static/vendor/js/loyalty-cards.js create mode 100644 app/modules/loyalty/static/vendor/js/loyalty-enroll.js create mode 100644 app/modules/loyalty/static/vendor/js/loyalty-settings.js create mode 100644 app/modules/loyalty/static/vendor/js/loyalty-stats.js create mode 100644 app/modules/loyalty/static/vendor/js/loyalty-terminal.js create mode 100644 app/modules/loyalty/templates/loyalty/admin/analytics.html create mode 100644 app/modules/loyalty/templates/loyalty/admin/company-detail.html create mode 100644 app/modules/loyalty/templates/loyalty/admin/company-settings.html create mode 100644 app/modules/loyalty/templates/loyalty/admin/programs.html create mode 100644 app/modules/loyalty/templates/loyalty/storefront/dashboard.html create mode 100644 app/modules/loyalty/templates/loyalty/storefront/enroll-success.html create mode 100644 app/modules/loyalty/templates/loyalty/storefront/enroll.html create mode 100644 app/modules/loyalty/templates/loyalty/storefront/history.html create mode 100644 app/modules/loyalty/templates/loyalty/vendor/card-detail.html create mode 100644 app/modules/loyalty/templates/loyalty/vendor/cards.html create mode 100644 app/modules/loyalty/templates/loyalty/vendor/enroll.html create mode 100644 app/modules/loyalty/templates/loyalty/vendor/settings.html create mode 100644 app/modules/loyalty/templates/loyalty/vendor/stats.html create mode 100644 app/modules/loyalty/templates/loyalty/vendor/terminal.html diff --git a/app/modules/loyalty/models/__init__.py b/app/modules/loyalty/models/__init__.py index c5cd3c14..428c1399 100644 --- a/app/modules/loyalty/models/__init__.py +++ b/app/modules/loyalty/models/__init__.py @@ -12,8 +12,10 @@ Usage: LoyaltyTransaction, StaffPin, AppleDeviceRegistration, + CompanyLoyaltySettings, LoyaltyType, TransactionType, + StaffPinPolicy, ) """ @@ -41,15 +43,23 @@ from app.modules.loyalty.models.apple_device import ( # Model AppleDeviceRegistration, ) +from app.modules.loyalty.models.company_settings import ( + # Enums + StaffPinPolicy, + # Model + CompanyLoyaltySettings, +) __all__ = [ # Enums "LoyaltyType", "TransactionType", + "StaffPinPolicy", # Models "LoyaltyProgram", "LoyaltyCard", "LoyaltyTransaction", "StaffPin", "AppleDeviceRegistration", + "CompanyLoyaltySettings", ] diff --git a/app/modules/loyalty/models/company_settings.py b/app/modules/loyalty/models/company_settings.py new file mode 100644 index 00000000..d7e3f82d --- /dev/null +++ b/app/modules/loyalty/models/company_settings.py @@ -0,0 +1,135 @@ +# app/modules/loyalty/models/company_settings.py +""" +Company loyalty settings database model. + +Admin-controlled settings that apply to a company's loyalty program. +These settings are managed by platform administrators, not vendors. +""" + +from sqlalchemy import ( + Boolean, + Column, + ForeignKey, + Index, + Integer, + String, +) +from sqlalchemy.orm import relationship + +from app.core.database import Base +from models.database.base import TimestampMixin + + +class StaffPinPolicy(str): + """Staff PIN policy options.""" + + REQUIRED = "required" # Staff PIN always required + OPTIONAL = "optional" # Vendor can choose + DISABLED = "disabled" # Staff PIN not used + + +class CompanyLoyaltySettings(Base, TimestampMixin): + """ + Admin-controlled settings for company loyalty programs. + + These settings are managed by platform administrators and + cannot be changed by vendors. They apply to all vendors + within the company. + """ + + __tablename__ = "company_loyalty_settings" + + id = Column(Integer, primary_key=True, index=True) + + # Company association (one settings per company) + company_id = Column( + Integer, + ForeignKey("companies.id", ondelete="CASCADE"), + unique=True, + nullable=False, + index=True, + comment="Company these settings apply to", + ) + + # ========================================================================= + # Staff PIN Policy (Admin-controlled) + # ========================================================================= + staff_pin_policy = Column( + String(20), + default=StaffPinPolicy.REQUIRED, + nullable=False, + comment="Staff PIN policy: required, optional, disabled", + ) + staff_pin_lockout_attempts = Column( + Integer, + default=5, + nullable=False, + comment="Max failed PIN attempts before lockout", + ) + staff_pin_lockout_minutes = Column( + Integer, + default=30, + nullable=False, + comment="Lockout duration in minutes", + ) + + # ========================================================================= + # Feature Toggles (Admin-controlled) + # ========================================================================= + allow_self_enrollment = Column( + Boolean, + default=True, + nullable=False, + comment="Allow customers to self-enroll via QR code", + ) + allow_void_transactions = Column( + Boolean, + default=True, + nullable=False, + comment="Allow voiding points for returns", + ) + allow_cross_location_redemption = Column( + Boolean, + default=True, + nullable=False, + comment="Allow redemption at any company location", + ) + + # ========================================================================= + # Audit Settings + # ========================================================================= + require_order_reference = Column( + Boolean, + default=False, + nullable=False, + comment="Require order reference when earning points", + ) + log_ip_addresses = Column( + Boolean, + default=True, + nullable=False, + comment="Log IP addresses for transactions", + ) + + # ========================================================================= + # Relationships + # ========================================================================= + company = relationship("Company", backref="loyalty_settings") + + # Indexes + __table_args__ = ( + Index("idx_company_loyalty_settings_company", "company_id"), + ) + + def __repr__(self) -> str: + return f"" + + @property + def is_staff_pin_required(self) -> bool: + """Check if staff PIN is required.""" + return self.staff_pin_policy == StaffPinPolicy.REQUIRED + + @property + def is_staff_pin_disabled(self) -> bool: + """Check if staff PIN is disabled.""" + return self.staff_pin_policy == StaffPinPolicy.DISABLED diff --git a/app/modules/loyalty/models/loyalty_card.py b/app/modules/loyalty/models/loyalty_card.py index 01a77fa8..6fd537a0 100644 --- a/app/modules/loyalty/models/loyalty_card.py +++ b/app/modules/loyalty/models/loyalty_card.py @@ -2,11 +2,16 @@ """ Loyalty card database model. +Company-based loyalty cards: +- Cards belong to a Company's loyalty program +- Customers can earn and redeem at any vendor within the company +- Tracks where customer enrolled for analytics + 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 +- QR code/barcode for scanning """ import secrets @@ -28,8 +33,9 @@ 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)]) + """Generate a unique 12-digit card number formatted as XXXX-XXXX-XXXX.""" + digits = "".join([str(secrets.randbelow(10)) for _ in range(12)]) + return f"{digits[:4]}-{digits[4:8]}-{digits[8:]}" def generate_qr_code_data() -> str: @@ -46,7 +52,10 @@ class LoyaltyCard(Base, TimestampMixin): """ Customer's loyalty card (PassObject). - Links a customer to a vendor's loyalty program and tracks: + Card belongs to a Company's loyalty program. + The customer can earn and redeem at any vendor within the company. + + Links a customer to a company's loyalty program and tracks: - Stamps and points balances - Wallet pass integration - Activity timestamps @@ -56,7 +65,16 @@ class LoyaltyCard(Base, TimestampMixin): id = Column(Integer, primary_key=True, index=True) - # Relationships + # Company association (card belongs to company's program) + company_id = Column( + Integer, + ForeignKey("companies.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="Company whose program this card belongs to", + ) + + # Customer and program relationships customer_id = Column( Integer, ForeignKey("customers.id", ondelete="CASCADE"), @@ -69,12 +87,14 @@ class LoyaltyCard(Base, TimestampMixin): nullable=False, index=True, ) - vendor_id = Column( + + # Track where customer enrolled (for analytics) + enrolled_at_vendor_id = Column( Integer, - ForeignKey("vendors.id", ondelete="CASCADE"), - nullable=False, + ForeignKey("vendors.id", ondelete="SET NULL"), + nullable=True, index=True, - comment="Denormalized for query performance", + comment="Vendor where customer enrolled (for analytics)", ) # ========================================================================= @@ -86,7 +106,7 @@ class LoyaltyCard(Base, TimestampMixin): nullable=False, default=generate_card_number, index=True, - comment="Human-readable card number", + comment="Human-readable card number (XXXX-XXXX-XXXX)", ) qr_code_data = Column( String(50), @@ -183,13 +203,18 @@ class LoyaltyCard(Base, TimestampMixin): last_points_at = Column( DateTime(timezone=True), nullable=True, - comment="Last points earned", + comment="Last points earned (for expiration tracking)", ) last_redemption_at = Column( DateTime(timezone=True), nullable=True, comment="Last reward redemption", ) + last_activity_at = Column( + DateTime(timezone=True), + nullable=True, + comment="Any activity (for expiration calculation)", + ) # ========================================================================= # Status @@ -204,9 +229,13 @@ class LoyaltyCard(Base, TimestampMixin): # ========================================================================= # Relationships # ========================================================================= + company = relationship("Company", backref="loyalty_cards") customer = relationship("Customer", backref="loyalty_cards") program = relationship("LoyaltyProgram", back_populates="cards") - vendor = relationship("Vendor", backref="loyalty_cards") + enrolled_at_vendor = relationship( + "Vendor", + backref="enrolled_loyalty_cards", + ) transactions = relationship( "LoyaltyTransaction", back_populates="card", @@ -219,14 +248,15 @@ class LoyaltyCard(Base, TimestampMixin): cascade="all, delete-orphan", ) - # Indexes + # Indexes - one card per customer per company __table_args__ = ( + Index("idx_loyalty_card_company_customer", "company_id", "customer_id", unique=True), + Index("idx_loyalty_card_company_active", "company_id", "is_active"), 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"" + return f"" # ========================================================================= # Stamp Operations @@ -241,6 +271,7 @@ class LoyaltyCard(Base, TimestampMixin): self.stamp_count += 1 self.total_stamps_earned += 1 self.last_stamp_at = datetime.now(UTC) + self.last_activity_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 @@ -258,6 +289,7 @@ class LoyaltyCard(Base, TimestampMixin): self.stamp_count -= stamps_target self.stamps_redeemed += 1 self.last_redemption_at = datetime.now(UTC) + self.last_activity_at = datetime.now(UTC) return True return False @@ -270,6 +302,7 @@ class LoyaltyCard(Base, TimestampMixin): self.points_balance += points self.total_points_earned += points self.last_points_at = datetime.now(UTC) + self.last_activity_at = datetime.now(UTC) def redeem_points(self, points: int) -> bool: """ @@ -281,9 +314,29 @@ class LoyaltyCard(Base, TimestampMixin): self.points_balance -= points self.points_redeemed += points self.last_redemption_at = datetime.now(UTC) + self.last_activity_at = datetime.now(UTC) return True return False + def void_points(self, points: int) -> None: + """ + Void points (for returns). + + Args: + points: Number of points to void + """ + self.points_balance = max(0, self.points_balance - points) + self.last_activity_at = datetime.now(UTC) + + def expire_points(self, points: int) -> None: + """ + Expire points due to inactivity. + + Args: + points: Number of points to expire + """ + self.points_balance = max(0, self.points_balance - points) + # ========================================================================= # Properties # ========================================================================= diff --git a/app/modules/loyalty/models/loyalty_program.py b/app/modules/loyalty/models/loyalty_program.py index dd3f06fb..283eda6d 100644 --- a/app/modules/loyalty/models/loyalty_program.py +++ b/app/modules/loyalty/models/loyalty_program.py @@ -2,7 +2,12 @@ """ Loyalty program database model. -Defines the vendor's loyalty program configuration including: +Company-based loyalty program configuration: +- Program belongs to Company (one program per company) +- All vendors under a company share the same loyalty program +- Customers earn and redeem points at any location (vendor) within the company + +Defines: - Program type (stamps, points, hybrid) - Stamp configuration (target, reward description) - Points configuration (per euro rate, rewards catalog) @@ -41,9 +46,13 @@ class LoyaltyType(str, enum.Enum): class LoyaltyProgram(Base, TimestampMixin): """ - Vendor's loyalty program configuration. + Company's loyalty program configuration. - Each vendor can have one loyalty program that defines: + Program belongs to Company (chain-wide shared points). + All vendors under a company share the same loyalty program. + Customers can earn and redeem at any vendor within the company. + + Each company can have one loyalty program that defines: - Program type and mechanics - Stamp or points configuration - Anti-fraud rules @@ -54,19 +63,20 @@ class LoyaltyProgram(Base, TimestampMixin): id = Column(Integer, primary_key=True, index=True) - # Vendor association (one program per vendor) - vendor_id = Column( + # Company association (one program per company) + company_id = Column( Integer, - ForeignKey("vendors.id", ondelete="CASCADE"), + ForeignKey("companies.id", ondelete="CASCADE"), unique=True, nullable=False, index=True, + comment="Company that owns this program (chain-wide)", ) # Program type loyalty_type = Column( String(20), - default=LoyaltyType.STAMPS.value, + default=LoyaltyType.POINTS.value, nullable=False, ) @@ -96,9 +106,9 @@ class LoyaltyProgram(Base, TimestampMixin): # ========================================================================= points_per_euro = Column( Integer, - default=10, + default=1, nullable=False, - comment="Points earned per euro spent", + comment="Points earned per euro spent (1 euro = X points)", ) points_rewards = Column( JSON, @@ -107,6 +117,38 @@ class LoyaltyProgram(Base, TimestampMixin): 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 # ========================================================================= @@ -151,7 +193,7 @@ class LoyaltyProgram(Base, TimestampMixin): logo_url = Column( String(500), nullable=True, - comment="URL to vendor logo for card", + comment="URL to company logo for card", ) hero_image_url = Column( String(500), @@ -210,7 +252,7 @@ class LoyaltyProgram(Base, TimestampMixin): # ========================================================================= # Relationships # ========================================================================= - vendor = relationship("Vendor", back_populates="loyalty_program") + company = relationship("Company", backref="loyalty_program") cards = relationship( "LoyaltyCard", back_populates="program", @@ -224,11 +266,11 @@ class LoyaltyProgram(Base, TimestampMixin): # Indexes __table_args__ = ( - Index("idx_loyalty_program_vendor_active", "vendor_id", "is_active"), + Index("idx_loyalty_program_company_active", "company_id", "is_active"), ) def __repr__(self) -> str: - return f"" + return f"" # ========================================================================= # Properties diff --git a/app/modules/loyalty/models/loyalty_transaction.py b/app/modules/loyalty/models/loyalty_transaction.py index 3d74bedf..3d7f092d 100644 --- a/app/modules/loyalty/models/loyalty_transaction.py +++ b/app/modules/loyalty/models/loyalty_transaction.py @@ -2,9 +2,15 @@ """ Loyalty transaction database model. +Company-based transaction tracking: +- Tracks which company and vendor 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.) """ @@ -31,10 +37,12 @@ class TransactionType(str, enum.Enum): # 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" @@ -44,6 +52,10 @@ class TransactionType(str, enum.Enum): 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): """ @@ -51,12 +63,25 @@ class LoyaltyTransaction(Base, TimestampMixin): Immutable audit log of all loyalty operations for fraud detection, analytics, and customer support. + + Tracks which vendor (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) + # Company association + company_id = Column( + Integer, + ForeignKey("companies.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="Company that owns the loyalty program", + ) + # Core relationships card_id = Column( Integer, @@ -66,10 +91,10 @@ class LoyaltyTransaction(Base, TimestampMixin): ) vendor_id = Column( Integer, - ForeignKey("vendors.id", ondelete="CASCADE"), - nullable=False, + ForeignKey("vendors.id", ondelete="SET NULL"), + nullable=True, index=True, - comment="Denormalized for query performance", + comment="Vendor (location) that processed this transaction", ) staff_pin_id = Column( Integer, @@ -79,6 +104,15 @@ class LoyaltyTransaction(Base, TimestampMixin): 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 # ========================================================================= @@ -175,15 +209,23 @@ class LoyaltyTransaction(Base, TimestampMixin): # ========================================================================= # Relationships # ========================================================================= + company = relationship("Company", backref="loyalty_transactions") card = relationship("LoyaltyCard", back_populates="transactions") vendor = relationship("Vendor", 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_vendor_date", "vendor_id", "transaction_at"), Index("idx_loyalty_tx_type_date", "transaction_type", "transaction_at"), + Index("idx_loyalty_tx_company_date", "company_id", "transaction_at"), + Index("idx_loyalty_tx_company_vendor", "company_id", "vendor_id"), ) def __repr__(self) -> str: @@ -202,6 +244,7 @@ class LoyaltyTransaction(Base, TimestampMixin): return self.transaction_type in ( TransactionType.STAMP_EARNED.value, TransactionType.STAMP_REDEEMED.value, + TransactionType.STAMP_VOIDED.value, TransactionType.STAMP_ADJUSTMENT.value, ) @@ -212,6 +255,9 @@ class LoyaltyTransaction(Base, TimestampMixin): 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 @@ -220,6 +266,7 @@ class LoyaltyTransaction(Base, TimestampMixin): return self.transaction_type in ( TransactionType.STAMP_EARNED.value, TransactionType.POINTS_EARNED.value, + TransactionType.WELCOME_BONUS.value, ) @property @@ -230,9 +277,24 @@ class LoyaltyTransaction(Base, TimestampMixin): 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 vendor_name(self) -> str | None: + """Get the name of the vendor where this transaction occurred.""" + if self.vendor: + return self.vendor.name + return None diff --git a/app/modules/loyalty/models/staff_pin.py b/app/modules/loyalty/models/staff_pin.py index 68cf9381..e24c255f 100644 --- a/app/modules/loyalty/models/staff_pin.py +++ b/app/modules/loyalty/models/staff_pin.py @@ -2,6 +2,11 @@ """ 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 @@ -34,13 +39,25 @@ class StaffPin(Base, TimestampMixin): 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) - # Relationships + # 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"), @@ -52,7 +69,7 @@ class StaffPin(Base, TimestampMixin): ForeignKey("vendors.id", ondelete="CASCADE"), nullable=False, index=True, - comment="Denormalized for query performance", + comment="Vendor (location) where this staff member works", ) # Staff identity @@ -104,17 +121,19 @@ class StaffPin(Base, TimestampMixin): # ========================================================================= # 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"" + return f"" # ========================================================================= # PIN Operations diff --git a/app/modules/loyalty/routes/api/admin.py b/app/modules/loyalty/routes/api/admin.py index 45b6802d..bf6cee1e 100644 --- a/app/modules/loyalty/routes/api/admin.py +++ b/app/modules/loyalty/routes/api/admin.py @@ -3,13 +3,14 @@ Loyalty module admin routes. Platform admin endpoints for: -- Viewing all loyalty programs +- Viewing all loyalty programs (company-based) +- Company loyalty settings management - Platform-wide analytics """ import logging -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api, require_module_access @@ -19,6 +20,9 @@ from app.modules.loyalty.schemas import ( ProgramListResponse, ProgramResponse, ProgramStatsResponse, + CompanyStatsResponse, + CompanySettingsResponse, + CompanySettingsUpdate, ) from app.modules.loyalty.services import program_service from app.modules.tenancy.models import User @@ -42,15 +46,22 @@ def list_programs( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), is_active: bool | None = Query(None), + search: str | None = Query(None, description="Search by company name"), current_user: User = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """List all loyalty programs (platform admin).""" + from sqlalchemy import func + + from app.modules.loyalty.models import LoyaltyCard, LoyaltyTransaction + from app.modules.tenancy.models import Company + programs, total = program_service.list_programs( db, skip=skip, limit=limit, is_active=is_active, + search=search, ) program_responses = [] @@ -59,6 +70,47 @@ def list_programs( response.is_stamps_enabled = program.is_stamps_enabled response.is_points_enabled = program.is_points_enabled response.display_name = program.display_name + + # Get company name + company = db.query(Company).filter(Company.id == program.company_id).first() + if company: + response.company_name = company.name + + # Get basic stats for this program + response.total_cards = ( + db.query(func.count(LoyaltyCard.id)) + .filter(LoyaltyCard.company_id == program.company_id) + .scalar() + or 0 + ) + response.active_cards = ( + db.query(func.count(LoyaltyCard.id)) + .filter( + LoyaltyCard.company_id == program.company_id, + LoyaltyCard.is_active == True, + ) + .scalar() + or 0 + ) + response.total_points_issued = ( + db.query(func.sum(LoyaltyTransaction.points_delta)) + .filter( + LoyaltyTransaction.company_id == program.company_id, + LoyaltyTransaction.points_delta > 0, + ) + .scalar() + or 0 + ) + response.total_points_redeemed = ( + db.query(func.sum(func.abs(LoyaltyTransaction.points_delta))) + .filter( + LoyaltyTransaction.company_id == program.company_id, + LoyaltyTransaction.points_delta < 0, + ) + .scalar() + or 0 + ) + program_responses.append(response) return ProgramListResponse(programs=program_responses, total=total) @@ -92,6 +144,60 @@ def get_program_stats( return ProgramStatsResponse(**stats) +# ============================================================================= +# Company Management +# ============================================================================= + + +@admin_router.get("/companies/{company_id}/stats", response_model=CompanyStatsResponse) +def get_company_stats( + company_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Get company-wide loyalty statistics across all locations.""" + stats = program_service.get_company_stats(db, company_id) + if "error" in stats: + raise HTTPException(status_code=404, detail=stats["error"]) + + return CompanyStatsResponse(**stats) + + +@admin_router.get("/companies/{company_id}/settings", response_model=CompanySettingsResponse) +def get_company_settings( + company_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Get company loyalty settings.""" + settings = program_service.get_or_create_company_settings(db, company_id) + return CompanySettingsResponse.model_validate(settings) + + +@admin_router.patch("/companies/{company_id}/settings", response_model=CompanySettingsResponse) +def update_company_settings( + data: CompanySettingsUpdate, + company_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Update company loyalty settings (admin only).""" + from app.modules.loyalty.models import CompanyLoyaltySettings + + settings = program_service.get_or_create_company_settings(db, company_id) + + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(settings, field, value) + + db.commit() + db.refresh(settings) + + logger.info(f"Updated company {company_id} loyalty settings: {list(update_data.keys())}") + + return CompanySettingsResponse.model_validate(settings) + + # ============================================================================= # Platform Stats # ============================================================================= @@ -136,10 +242,39 @@ def get_platform_stats( or 0 ) + # Points issued/redeemed (last 30 days) + points_issued_30d = ( + db.query(func.sum(LoyaltyTransaction.points_delta)) + .filter( + LoyaltyTransaction.transaction_at >= thirty_days_ago, + LoyaltyTransaction.points_delta > 0, + ) + .scalar() + or 0 + ) + + points_redeemed_30d = ( + db.query(func.sum(func.abs(LoyaltyTransaction.points_delta))) + .filter( + LoyaltyTransaction.transaction_at >= thirty_days_ago, + LoyaltyTransaction.points_delta < 0, + ) + .scalar() + or 0 + ) + + # Company count with programs + companies_with_programs = ( + db.query(func.count(func.distinct(LoyaltyProgram.company_id))).scalar() or 0 + ) + return { "total_programs": total_programs, "active_programs": active_programs, + "companies_with_programs": companies_with_programs, "total_cards": total_cards, "active_cards": active_cards, "transactions_30d": transactions_30d, + "points_issued_30d": points_issued_30d, + "points_redeemed_30d": points_redeemed_30d, } diff --git a/app/modules/loyalty/routes/api/storefront.py b/app/modules/loyalty/routes/api/storefront.py new file mode 100644 index 00000000..880d1803 --- /dev/null +++ b/app/modules/loyalty/routes/api/storefront.py @@ -0,0 +1,216 @@ +# app/modules/loyalty/routes/api/storefront.py +""" +Loyalty Module - Storefront API Routes + +Customer-facing endpoints for: +- View loyalty card and balance +- View transaction history +- Self-service enrollment +- Get program information + +Uses vendor from middleware context (VendorContextMiddleware). +""" + +import logging + +from fastapi import APIRouter, Depends, Query, Request +from sqlalchemy.orm import Session + +from app.api.deps import get_current_customer_api +from app.core.database import get_db +from app.modules.customers.schemas import CustomerContext +from app.modules.loyalty.services import card_service, program_service +from app.modules.loyalty.schemas import ( + CardResponse, + CardEnrollRequest, + TransactionListResponse, + TransactionResponse, + ProgramResponse, +) +from app.modules.tenancy.exceptions import VendorNotFoundException + +router = APIRouter() +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Public Endpoints (No Authentication Required) +# ============================================================================= + + +@router.get("/loyalty/program") +def get_program_info( + request: Request, + db: Session = Depends(get_db), +): + """ + Get loyalty program information for current vendor. + Public endpoint - no authentication required. + """ + vendor = getattr(request.state, "vendor", None) + if not vendor: + raise VendorNotFoundException("context", identifier_type="subdomain") + + program = program_service.get_program_by_vendor(db, vendor.id) + if not program: + return None + + response = ProgramResponse.model_validate(program) + response.is_stamps_enabled = program.is_stamps_enabled + response.is_points_enabled = program.is_points_enabled + response.display_name = program.display_name + + return response + + +@router.post("/loyalty/enroll") +def self_enroll( + request: Request, + data: CardEnrollRequest, + db: Session = Depends(get_db), +): + """ + Self-service enrollment. + Public endpoint - customers can enroll via QR code without authentication. + """ + vendor = getattr(request.state, "vendor", None) + if not vendor: + raise VendorNotFoundException("context", identifier_type="subdomain") + + logger.info(f"Self-enrollment for {data.customer_email} at vendor {vendor.subdomain}") + + card = card_service.enroll_customer( + db, + vendor_id=vendor.id, + customer_email=data.customer_email, + customer_phone=data.customer_phone, + customer_name=data.customer_name, + ) + + return CardResponse.model_validate(card) + + +# ============================================================================= +# Authenticated Endpoints +# ============================================================================= + + +@router.get("/loyalty/card") +def get_my_card( + request: Request, + customer: CustomerContext = Depends(get_current_customer_api), + db: Session = Depends(get_db), +): + """ + Get customer's loyalty card and program info. + Returns card details, program info, and available rewards. + """ + vendor = getattr(request.state, "vendor", None) + if not vendor: + raise VendorNotFoundException("context", identifier_type="subdomain") + + logger.debug(f"Getting loyalty card for customer {customer.id}") + + # Get program + program = program_service.get_program_by_vendor(db, vendor.id) + if not program: + return {"card": None, "program": None, "locations": []} + + # Look up card by customer email + card = card_service.get_card_by_customer_email( + db, + company_id=program.company_id, + customer_email=customer.email, + ) + + if not card: + return {"card": None, "program": None, "locations": []} + + # Get company locations + from app.modules.tenancy.models import Vendor as VendorModel + locations = ( + db.query(VendorModel) + .filter(VendorModel.company_id == program.company_id, VendorModel.is_active == True) + .all() + ) + + program_response = ProgramResponse.model_validate(program) + program_response.is_stamps_enabled = program.is_stamps_enabled + program_response.is_points_enabled = program.is_points_enabled + program_response.display_name = program.display_name + + return { + "card": CardResponse.model_validate(card), + "program": program_response, + "locations": [{"id": v.id, "name": v.name} for v in locations], + } + + +@router.get("/loyalty/transactions") +def get_my_transactions( + request: Request, + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + customer: CustomerContext = Depends(get_current_customer_api), + db: Session = Depends(get_db), +): + """ + Get customer's loyalty transaction history. + """ + vendor = getattr(request.state, "vendor", None) + if not vendor: + raise VendorNotFoundException("context", identifier_type="subdomain") + + logger.debug(f"Getting transactions for customer {customer.id}") + + # Get program + program = program_service.get_program_by_vendor(db, vendor.id) + if not program: + return {"transactions": [], "total": 0} + + # Get card + card = card_service.get_card_by_customer_email( + db, + company_id=program.company_id, + customer_email=customer.email, + ) + + if not card: + return {"transactions": [], "total": 0} + + # Get transactions + from sqlalchemy import func + from app.modules.loyalty.models import LoyaltyTransaction + from app.modules.tenancy.models import Vendor as VendorModel + + query = ( + db.query(LoyaltyTransaction) + .filter(LoyaltyTransaction.card_id == card.id) + .order_by(LoyaltyTransaction.transaction_at.desc()) + ) + + total = query.count() + transactions = query.offset(skip).limit(limit).all() + + # Build response with vendor names + tx_responses = [] + for tx in transactions: + tx_data = { + "id": tx.id, + "transaction_type": tx.transaction_type.value if hasattr(tx.transaction_type, 'value') else str(tx.transaction_type), + "points_delta": tx.points_delta, + "stamps_delta": tx.stamps_delta, + "balance_after": tx.balance_after, + "transaction_at": tx.transaction_at.isoformat() if tx.transaction_at else None, + "notes": tx.notes, + "vendor_name": None, + } + + if tx.vendor_id: + vendor_obj = db.query(VendorModel).filter(VendorModel.id == tx.vendor_id).first() + if vendor_obj: + tx_data["vendor_name"] = vendor_obj.name + + tx_responses.append(tx_data) + + return {"transactions": tx_responses, "total": total} diff --git a/app/modules/loyalty/routes/api/vendor.py b/app/modules/loyalty/routes/api/vendor.py index 7296f114..29ab72ad 100644 --- a/app/modules/loyalty/routes/api/vendor.py +++ b/app/modules/loyalty/routes/api/vendor.py @@ -2,12 +2,15 @@ """ Loyalty module vendor routes. -Store/vendor endpoints for: -- Program management -- Staff PINs -- Card operations (stamps, points, redemptions) +Company-based vendor endpoints for: +- Program management (company-wide, managed by vendor) +- Staff PINs (per-vendor) +- Card operations (stamps, points, redemptions, voids) - Customer cards lookup - Dashboard stats + +All operations are scoped to the vendor's company. +Cards can be used at any vendor within the same company. """ import logging @@ -36,14 +39,23 @@ from app.modules.loyalty.schemas import ( PointsEarnResponse, PointsRedeemRequest, PointsRedeemResponse, + PointsVoidRequest, + PointsVoidResponse, + PointsAdjustRequest, + PointsAdjustResponse, ProgramCreate, ProgramResponse, ProgramStatsResponse, ProgramUpdate, + CompanyStatsResponse, StampRedeemRequest, StampRedeemResponse, StampRequest, StampResponse, + StampVoidRequest, + StampVoidResponse, + TransactionListResponse, + TransactionResponse, ) from app.modules.loyalty.services import ( card_service, @@ -54,7 +66,7 @@ from app.modules.loyalty.services import ( wallet_service, ) from app.modules.enums import FrontendType -from app.modules.tenancy.models import User +from app.modules.tenancy.models import User, Vendor logger = logging.getLogger(__name__) @@ -72,6 +84,14 @@ def get_client_info(request: Request) -> tuple[str | None, str | None]: return ip, user_agent +def get_vendor_company_id(db: Session, vendor_id: int) -> int: + """Get the company ID for a vendor.""" + vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() + if not vendor: + raise HTTPException(status_code=404, detail="Vendor not found") + return vendor.company_id + + # ============================================================================= # Program Management # ============================================================================= @@ -82,7 +102,7 @@ def get_program( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): - """Get the vendor's loyalty program.""" + """Get the company's loyalty program.""" vendor_id = current_user.token_vendor_id program = program_service.get_program_by_vendor(db, vendor_id) @@ -103,11 +123,12 @@ def create_program( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): - """Create a loyalty program for the vendor.""" + """Create a loyalty program for the company.""" vendor_id = current_user.token_vendor_id + company_id = get_vendor_company_id(db, vendor_id) try: - program = program_service.create_program(db, vendor_id, data) + program = program_service.create_program(db, company_id, data) except LoyaltyException as e: raise HTTPException(status_code=e.status_code, detail=e.message) @@ -125,7 +146,7 @@ def update_program( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): - """Update the vendor's loyalty program.""" + """Update the company's loyalty program.""" vendor_id = current_user.token_vendor_id program = program_service.get_program_by_vendor(db, vendor_id) @@ -158,6 +179,22 @@ def get_stats( return ProgramStatsResponse(**stats) +@vendor_router.get("/stats/company", response_model=CompanyStatsResponse) +def get_company_stats( + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """Get company-wide loyalty statistics across all locations.""" + vendor_id = current_user.token_vendor_id + company_id = get_vendor_company_id(db, vendor_id) + + stats = program_service.get_company_stats(db, company_id) + if "error" in stats: + raise HTTPException(status_code=404, detail=stats["error"]) + + return CompanyStatsResponse(**stats) + + # ============================================================================= # Staff PINs # ============================================================================= @@ -168,14 +205,15 @@ def list_pins( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): - """List all staff PINs for the loyalty program.""" + """List staff PINs for this vendor location.""" vendor_id = current_user.token_vendor_id program = program_service.get_program_by_vendor(db, vendor_id) if not program: raise HTTPException(status_code=404, detail="No loyalty program configured") - pins = pin_service.list_pins(db, program.id) + # List PINs for this vendor only + pins = pin_service.list_pins(db, program.id, vendor_id=vendor_id) return PinListResponse( pins=[PinResponse.model_validate(pin) for pin in pins], @@ -189,7 +227,7 @@ def create_pin( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): - """Create a new staff PIN.""" + """Create a new staff PIN for this vendor location.""" vendor_id = current_user.token_vendor_id program = program_service.get_program_by_vendor(db, vendor_id) @@ -244,19 +282,30 @@ def list_cards( limit: int = Query(50, ge=1, le=100), is_active: bool | None = Query(None), search: str | None = Query(None, max_length=100), + enrolled_here: bool = Query(False, description="Only show cards enrolled at this location"), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): - """List loyalty cards for the vendor.""" + """ + List loyalty cards for the company. + + By default lists all cards in the company's loyalty program. + Use enrolled_here=true to filter to cards enrolled at this location. + """ vendor_id = current_user.token_vendor_id + company_id = get_vendor_company_id(db, vendor_id) program = program_service.get_program_by_vendor(db, vendor_id) if not program: raise HTTPException(status_code=404, detail="No loyalty program configured") + # Filter by enrolled_at_vendor_id if requested + filter_vendor_id = vendor_id if enrolled_here else None + cards, total = card_service.list_cards( db, - vendor_id, + company_id, + vendor_id=filter_vendor_id, skip=skip, limit=limit, is_active=is_active, @@ -269,8 +318,9 @@ def list_cards( id=card.id, card_number=card.card_number, customer_id=card.customer_id, - vendor_id=card.vendor_id, + company_id=card.company_id, program_id=card.program_id, + enrolled_at_vendor_id=card.enrolled_at_vendor_id, stamp_count=card.stamp_count, stamps_target=program.stamps_target, stamps_until_reward=max(0, program.stamps_target - card.stamp_count), @@ -298,12 +348,18 @@ def lookup_card( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): - """Look up a card by ID, QR code, or card number.""" + """ + Look up a card by ID, QR code, or card number. + + Card must belong to the same company as the vendor. + """ vendor_id = current_user.token_vendor_id try: - card = card_service.lookup_card( + # Uses lookup_card_for_vendor which validates company membership + card = card_service.lookup_card_for_vendor( db, + vendor_id, card_id=card_id, qr_code=qr_code, card_number=card_number, @@ -311,10 +367,6 @@ def lookup_card( except LoyaltyCardNotFoundException: raise HTTPException(status_code=404, detail="Card not found") - # Verify card belongs to this vendor - if card.vendor_id != vendor_id: - raise HTTPException(status_code=404, detail="Card not found") - program = card.program # Check cooldown @@ -328,18 +380,27 @@ def lookup_card( # Get stamps today stamps_today = card_service.get_stamps_today(db, card.id) + # Get available points rewards + available_rewards = [] + for reward in program.points_rewards or []: + if reward.get("is_active", True) and card.points_balance >= reward.get("points_required", 0): + available_rewards.append(reward) + return CardLookupResponse( card_id=card.id, card_number=card.card_number, customer_id=card.customer_id, customer_name=card.customer.full_name if card.customer else None, customer_email=card.customer.email if card.customer else "", + company_id=card.company_id, + company_name=card.company.name if card.company else None, stamp_count=card.stamp_count, stamps_target=program.stamps_target, stamps_until_reward=max(0, program.stamps_target - card.stamp_count), points_balance=card.points_balance, can_redeem_stamps=card.stamp_count >= program.stamps_target, stamp_reward_description=program.stamps_reward_description, + available_rewards=available_rewards, can_stamp=can_stamp, cooldown_ends_at=cooldown_ends, stamps_today=stamps_today, @@ -354,14 +415,19 @@ def enroll_customer( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): - """Enroll a customer in the loyalty program.""" + """ + Enroll a customer in the company's loyalty program. + + The card will be associated with the company and track which + vendor enrolled them. + """ vendor_id = current_user.token_vendor_id if not data.customer_id: raise HTTPException(status_code=400, detail="customer_id is required") try: - card = card_service.enroll_customer(db, data.customer_id, vendor_id) + card = card_service.enroll_customer_for_vendor(db, data.customer_id, vendor_id) except LoyaltyException as e: raise HTTPException(status_code=e.status_code, detail=e.message) @@ -371,11 +437,12 @@ def enroll_customer( id=card.id, card_number=card.card_number, customer_id=card.customer_id, - vendor_id=card.vendor_id, + company_id=card.company_id, program_id=card.program_id, + enrolled_at_vendor_id=card.enrolled_at_vendor_id, stamp_count=card.stamp_count, stamps_target=program.stamps_target, - stamps_until_reward=program.stamps_target, + stamps_until_reward=max(0, program.stamps_target - card.stamp_count), total_stamps_earned=card.total_stamps_earned, stamps_redeemed=card.stamps_redeemed, points_balance=card.points_balance, @@ -386,6 +453,33 @@ def enroll_customer( ) +@vendor_router.get("/cards/{card_id}/transactions", response_model=TransactionListResponse) +def get_card_transactions( + card_id: int = Path(..., gt=0), + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """Get transaction history for a card.""" + vendor_id = current_user.token_vendor_id + + # Verify card belongs to this company + try: + card = card_service.lookup_card_for_vendor(db, vendor_id, card_id=card_id) + except LoyaltyCardNotFoundException: + raise HTTPException(status_code=404, detail="Card not found") + + transactions, total = card_service.get_card_transactions( + db, card_id, skip=skip, limit=limit + ) + + return TransactionListResponse( + transactions=[TransactionResponse.model_validate(t) for t in transactions], + total=total, + ) + + # ============================================================================= # Stamp Operations # ============================================================================= @@ -399,11 +493,13 @@ def add_stamp( db: Session = Depends(get_db), ): """Add a stamp to a loyalty card.""" + vendor_id = current_user.token_vendor_id ip, user_agent = get_client_info(request) try: result = stamp_service.add_stamp( db, + vendor_id=vendor_id, card_id=data.card_id, qr_code=data.qr_code, card_number=data.card_number, @@ -426,11 +522,13 @@ def redeem_stamps( db: Session = Depends(get_db), ): """Redeem stamps for a reward.""" + vendor_id = current_user.token_vendor_id ip, user_agent = get_client_info(request) try: result = stamp_service.redeem_stamps( db, + vendor_id=vendor_id, card_id=data.card_id, qr_code=data.qr_code, card_number=data.card_number, @@ -445,6 +543,37 @@ def redeem_stamps( return StampRedeemResponse(**result) +@vendor_router.post("/stamp/void", response_model=StampVoidResponse) +def void_stamps( + request: Request, + data: StampVoidRequest, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """Void stamps for a return.""" + vendor_id = current_user.token_vendor_id + ip, user_agent = get_client_info(request) + + try: + result = stamp_service.void_stamps( + db, + vendor_id=vendor_id, + card_id=data.card_id, + qr_code=data.qr_code, + card_number=data.card_number, + stamps_to_void=data.stamps_to_void, + original_transaction_id=data.original_transaction_id, + staff_pin=data.staff_pin, + ip_address=ip, + user_agent=user_agent, + notes=data.notes, + ) + except LoyaltyException as e: + raise HTTPException(status_code=e.status_code, detail=e.message) + + return StampVoidResponse(**result) + + # ============================================================================= # Points Operations # ============================================================================= @@ -458,11 +587,13 @@ def earn_points( db: Session = Depends(get_db), ): """Earn points from a purchase.""" + vendor_id = current_user.token_vendor_id ip, user_agent = get_client_info(request) try: result = points_service.earn_points( db, + vendor_id=vendor_id, card_id=data.card_id, qr_code=data.qr_code, card_number=data.card_number, @@ -487,11 +618,13 @@ def redeem_points( db: Session = Depends(get_db), ): """Redeem points for a reward.""" + vendor_id = current_user.token_vendor_id ip, user_agent = get_client_info(request) try: result = points_service.redeem_points( db, + vendor_id=vendor_id, card_id=data.card_id, qr_code=data.qr_code, card_number=data.card_number, @@ -505,3 +638,64 @@ def redeem_points( raise HTTPException(status_code=e.status_code, detail=e.message) return PointsRedeemResponse(**result) + + +@vendor_router.post("/points/void", response_model=PointsVoidResponse) +def void_points( + request: Request, + data: PointsVoidRequest, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """Void points for a return.""" + vendor_id = current_user.token_vendor_id + ip, user_agent = get_client_info(request) + + try: + result = points_service.void_points( + db, + vendor_id=vendor_id, + card_id=data.card_id, + qr_code=data.qr_code, + card_number=data.card_number, + points_to_void=data.points_to_void, + original_transaction_id=data.original_transaction_id, + order_reference=data.order_reference, + staff_pin=data.staff_pin, + ip_address=ip, + user_agent=user_agent, + notes=data.notes, + ) + except LoyaltyException as e: + raise HTTPException(status_code=e.status_code, detail=e.message) + + return PointsVoidResponse(**result) + + +@vendor_router.post("/cards/{card_id}/points/adjust", response_model=PointsAdjustResponse) +def adjust_points( + request: Request, + data: PointsAdjustRequest, + card_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """Manually adjust points (vendor operation).""" + vendor_id = current_user.token_vendor_id + ip, user_agent = get_client_info(request) + + try: + result = points_service.adjust_points( + db, + card_id=card_id, + points_delta=data.points_delta, + vendor_id=vendor_id, + reason=data.reason, + staff_pin=data.staff_pin, + ip_address=ip, + user_agent=user_agent, + ) + except LoyaltyException as e: + raise HTTPException(status_code=e.status_code, detail=e.message) + + return PointsAdjustResponse(**result) diff --git a/app/modules/loyalty/routes/pages/__init__.py b/app/modules/loyalty/routes/pages/__init__.py index 8dd7186f..a1b4d074 100644 --- a/app/modules/loyalty/routes/pages/__init__.py +++ b/app/modules/loyalty/routes/pages/__init__.py @@ -1,8 +1,15 @@ # app/modules/loyalty/routes/pages/__init__.py """ -Loyalty module page routes. +Loyalty module page routes (HTML rendering). -Reserved for future HTML page endpoints (enrollment pages, etc.). +Provides Jinja2 template rendering for: +- Admin pages: Platform loyalty programs dashboard and company management +- Vendor pages: Loyalty terminal, cards management, settings +- Storefront pages: Customer loyalty dashboard, self-enrollment """ -__all__: list[str] = [] +from app.modules.loyalty.routes.pages.admin import router as admin_router +from app.modules.loyalty.routes.pages.vendor import router as vendor_router +from app.modules.loyalty.routes.pages.storefront import router as storefront_router + +__all__ = ["admin_router", "vendor_router", "storefront_router"] diff --git a/app/modules/loyalty/routes/pages/admin.py b/app/modules/loyalty/routes/pages/admin.py new file mode 100644 index 00000000..5a21f862 --- /dev/null +++ b/app/modules/loyalty/routes/pages/admin.py @@ -0,0 +1,124 @@ +# app/modules/loyalty/routes/pages/admin.py +""" +Loyalty Admin Page Routes (HTML rendering). + +Admin pages for: +- Platform loyalty programs dashboard +- Company loyalty program detail/configuration +- Platform-wide loyalty statistics +""" + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_db, require_menu_access +from app.templates_config import templates +from app.modules.enums import FrontendType +from app.modules.tenancy.models import User + +router = APIRouter() + +# Route configuration for module route discovery +ROUTE_CONFIG = { + "prefix": "/loyalty", + "tags": ["admin-loyalty"], +} + + +# ============================================================================ +# LOYALTY PROGRAMS DASHBOARD +# ============================================================================ + + +@router.get("/programs", response_class=HTMLResponse, include_in_schema=False) +async def admin_loyalty_programs( + request: Request, + current_user: User = Depends(require_menu_access("loyalty-programs", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render loyalty programs dashboard. + Shows all companies with loyalty programs and platform-wide statistics. + """ + return templates.TemplateResponse( + "loyalty/admin/programs.html", + { + "request": request, + "user": current_user, + }, + ) + + +@router.get("/analytics", response_class=HTMLResponse, include_in_schema=False) +async def admin_loyalty_analytics( + request: Request, + current_user: User = Depends(require_menu_access("loyalty-analytics", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render loyalty analytics dashboard. + Shows platform-wide loyalty statistics and trends. + """ + return templates.TemplateResponse( + "loyalty/admin/analytics.html", + { + "request": request, + "user": current_user, + }, + ) + + +# ============================================================================ +# COMPANY LOYALTY DETAIL +# ============================================================================ + + +@router.get( + "/companies/{company_id}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_loyalty_company_detail( + request: Request, + company_id: int = Path(..., description="Company ID"), + current_user: User = Depends(require_menu_access("loyalty-programs", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render company loyalty program detail page. + Shows company's loyalty program configuration and location breakdown. + """ + return templates.TemplateResponse( + "loyalty/admin/company-detail.html", + { + "request": request, + "user": current_user, + "company_id": company_id, + }, + ) + + +@router.get( + "/companies/{company_id}/settings", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_loyalty_company_settings( + request: Request, + company_id: int = Path(..., description="Company ID"), + current_user: User = Depends(require_menu_access("loyalty-programs", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render company loyalty settings page. + Admin-controlled settings like staff PIN policy. + """ + return templates.TemplateResponse( + "loyalty/admin/company-settings.html", + { + "request": request, + "user": current_user, + "company_id": company_id, + }, + ) diff --git a/app/modules/loyalty/routes/pages/storefront.py b/app/modules/loyalty/routes/pages/storefront.py new file mode 100644 index 00000000..5d628e0b --- /dev/null +++ b/app/modules/loyalty/routes/pages/storefront.py @@ -0,0 +1,151 @@ +# app/modules/loyalty/routes/pages/storefront.py +""" +Loyalty Storefront Page Routes (HTML rendering). + +Customer-facing pages for: +- Loyalty dashboard (view points, rewards, history) +- Self-service enrollment +- Digital card display +""" + +import logging + +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_current_customer_from_cookie_or_header, get_db +from app.modules.core.utils.page_context import get_storefront_context +from app.modules.customers.models import Customer +from app.templates_config import templates + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# No custom prefix - routes are mounted directly at /storefront/ +# Following same pattern as customers module + + +# ============================================================================ +# CUSTOMER LOYALTY DASHBOARD (Authenticated) +# ============================================================================ + + +@router.get( + "/account/loyalty", + response_class=HTMLResponse, + include_in_schema=False, +) +async def customer_loyalty_dashboard( + request: Request, + current_customer: Customer = Depends(get_current_customer_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render customer loyalty dashboard. + Shows points balance, available rewards, and transaction history. + """ + logger.debug( + "[STOREFRONT] customer_loyalty_dashboard REACHED", + extra={ + "path": request.url.path, + "customer_id": current_customer.id if current_customer else None, + }, + ) + + context = get_storefront_context(request, db=db) + return templates.TemplateResponse( + "loyalty/storefront/dashboard.html", + context, + ) + + +@router.get( + "/account/loyalty/history", + response_class=HTMLResponse, + include_in_schema=False, +) +async def customer_loyalty_history( + request: Request, + current_customer: Customer = Depends(get_current_customer_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render full transaction history page. + """ + logger.debug( + "[STOREFRONT] customer_loyalty_history REACHED", + extra={ + "path": request.url.path, + "customer_id": current_customer.id if current_customer else None, + }, + ) + + context = get_storefront_context(request, db=db) + return templates.TemplateResponse( + "loyalty/storefront/history.html", + context, + ) + + +# ============================================================================ +# SELF-SERVICE ENROLLMENT (Public - No Authentication) +# ============================================================================ + + +@router.get( + "/loyalty/join", + response_class=HTMLResponse, + include_in_schema=False, +) +async def loyalty_self_enrollment( + request: Request, + db: Session = Depends(get_db), +): + """ + Render self-service enrollment page. + Public page - customers can sign up via QR code at store counter. + """ + logger.debug( + "[STOREFRONT] loyalty_self_enrollment REACHED", + extra={ + "path": request.url.path, + }, + ) + + context = get_storefront_context(request, db=db) + return templates.TemplateResponse( + "loyalty/storefront/enroll.html", + context, + ) + + +@router.get( + "/loyalty/join/success", + response_class=HTMLResponse, + include_in_schema=False, +) +async def loyalty_enrollment_success( + request: Request, + card: str = Query(None, description="Card number"), + db: Session = Depends(get_db), +): + """ + Render enrollment success page. + Shows card number and next steps. + """ + logger.debug( + "[STOREFRONT] loyalty_enrollment_success REACHED", + extra={ + "path": request.url.path, + "card": card, + }, + ) + + context = get_storefront_context(request, db=db) + context["enrolled_card_number"] = card + return templates.TemplateResponse( + "loyalty/storefront/enroll-success.html", + context, + ) diff --git a/app/modules/loyalty/routes/pages/vendor.py b/app/modules/loyalty/routes/pages/vendor.py new file mode 100644 index 00000000..8fb0aa91 --- /dev/null +++ b/app/modules/loyalty/routes/pages/vendor.py @@ -0,0 +1,226 @@ +# app/modules/loyalty/routes/pages/vendor.py +""" +Loyalty Vendor Page Routes (HTML rendering). + +Vendor pages for: +- Loyalty terminal (primary daily interface for staff) +- Loyalty members management +- Program settings +- Stats dashboard +""" + +import logging + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_current_vendor_from_cookie_or_header, get_db +from app.modules.core.services.platform_settings_service import platform_settings_service +from app.templates_config import templates +from app.modules.tenancy.models import User, Vendor + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# Route configuration for module route discovery +ROUTE_CONFIG = { + "prefix": "/loyalty", + "tags": ["vendor-loyalty"], +} + + +# ============================================================================ +# HELPER: Build Vendor Context +# ============================================================================ + + +def get_vendor_context( + request: Request, + db: Session, + current_user: User, + vendor_code: str, + **extra_context, +) -> dict: + """Build template context for vendor loyalty pages.""" + # Load vendor from database + vendor = db.query(Vendor).filter(Vendor.subdomain == vendor_code).first() + + # Get platform defaults + platform_config = platform_settings_service.get_storefront_config(db) + + # Resolve with vendor override + storefront_locale = platform_config["locale"] + storefront_currency = platform_config["currency"] + + if vendor and vendor.storefront_locale: + storefront_locale = vendor.storefront_locale + + context = { + "request": request, + "user": current_user, + "vendor": vendor, + "vendor_code": vendor_code, + "storefront_locale": storefront_locale, + "storefront_currency": storefront_currency, + "dashboard_language": vendor.dashboard_language if vendor else "en", + } + + # Add any extra context + if extra_context: + context.update(extra_context) + + return context + + +# ============================================================================ +# LOYALTY TERMINAL (Primary Daily Interface) +# ============================================================================ + + +@router.get( + "/{vendor_code}/terminal", + response_class=HTMLResponse, + include_in_schema=False, +) +async def vendor_loyalty_terminal( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render loyalty terminal page. + Primary interface for staff to look up customers, award points, and process redemptions. + """ + return templates.TemplateResponse( + "loyalty/vendor/terminal.html", + get_vendor_context(request, db, current_user, vendor_code), + ) + + +# ============================================================================ +# LOYALTY MEMBERS +# ============================================================================ + + +@router.get( + "/{vendor_code}/cards", + response_class=HTMLResponse, + include_in_schema=False, +) +async def vendor_loyalty_cards( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render loyalty members list page. + Shows all loyalty card holders for this company. + """ + return templates.TemplateResponse( + "loyalty/vendor/cards.html", + get_vendor_context(request, db, current_user, vendor_code), + ) + + +@router.get( + "/{vendor_code}/cards/{card_id}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def vendor_loyalty_card_detail( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + card_id: int = Path(..., description="Loyalty card ID"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render loyalty card detail page. + Shows card holder info, transaction history, and actions. + """ + return templates.TemplateResponse( + "loyalty/vendor/card-detail.html", + get_vendor_context(request, db, current_user, vendor_code, card_id=card_id), + ) + + +# ============================================================================ +# PROGRAM SETTINGS +# ============================================================================ + + +@router.get( + "/{vendor_code}/settings", + response_class=HTMLResponse, + include_in_schema=False, +) +async def vendor_loyalty_settings( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render loyalty program settings page. + Allows vendor to configure points rate, rewards, branding, etc. + """ + return templates.TemplateResponse( + "loyalty/vendor/settings.html", + get_vendor_context(request, db, current_user, vendor_code), + ) + + +# ============================================================================ +# STATS DASHBOARD +# ============================================================================ + + +@router.get( + "/{vendor_code}/stats", + response_class=HTMLResponse, + include_in_schema=False, +) +async def vendor_loyalty_stats( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render loyalty statistics dashboard. + Shows vendor's loyalty program metrics and trends. + """ + return templates.TemplateResponse( + "loyalty/vendor/stats.html", + get_vendor_context(request, db, current_user, vendor_code), + ) + + +# ============================================================================ +# ENROLLMENT +# ============================================================================ + + +@router.get( + "/{vendor_code}/enroll", + response_class=HTMLResponse, + include_in_schema=False, +) +async def vendor_loyalty_enroll( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render customer enrollment page. + Staff interface for enrolling new customers into the loyalty program. + """ + return templates.TemplateResponse( + "loyalty/vendor/enroll.html", + get_vendor_context(request, db, current_user, vendor_code), + ) diff --git a/app/modules/loyalty/schemas/__init__.py b/app/modules/loyalty/schemas/__init__.py index d6634630..20531a1b 100644 --- a/app/modules/loyalty/schemas/__init__.py +++ b/app/modules/loyalty/schemas/__init__.py @@ -33,8 +33,13 @@ from app.modules.loyalty.schemas.program import ( ProgramListResponse, # Points rewards PointsRewardConfig, + TierConfig, # Stats ProgramStatsResponse, + CompanyStatsResponse, + # Company settings + CompanySettingsResponse, + CompanySettingsUpdate, ) from app.modules.loyalty.schemas.card import ( @@ -44,6 +49,9 @@ from app.modules.loyalty.schemas.card import ( CardDetailResponse, CardListResponse, CardLookupResponse, + # Transactions + TransactionResponse, + TransactionListResponse, ) from app.modules.loyalty.schemas.stamp import ( @@ -52,6 +60,8 @@ from app.modules.loyalty.schemas.stamp import ( StampResponse, StampRedeemRequest, StampRedeemResponse, + StampVoidRequest, + StampVoidResponse, ) from app.modules.loyalty.schemas.points import ( @@ -60,6 +70,10 @@ from app.modules.loyalty.schemas.points import ( PointsEarnResponse, PointsRedeemRequest, PointsRedeemResponse, + PointsVoidRequest, + PointsVoidResponse, + PointsAdjustRequest, + PointsAdjustResponse, ) from app.modules.loyalty.schemas.pin import ( @@ -79,23 +93,35 @@ __all__ = [ "ProgramResponse", "ProgramListResponse", "PointsRewardConfig", + "TierConfig", "ProgramStatsResponse", + "CompanyStatsResponse", + "CompanySettingsResponse", + "CompanySettingsUpdate", # Card "CardEnrollRequest", "CardResponse", "CardDetailResponse", "CardListResponse", "CardLookupResponse", + "TransactionResponse", + "TransactionListResponse", # Stamp "StampRequest", "StampResponse", "StampRedeemRequest", "StampRedeemResponse", + "StampVoidRequest", + "StampVoidResponse", # Points "PointsEarnRequest", "PointsEarnResponse", "PointsRedeemRequest", "PointsRedeemResponse", + "PointsVoidRequest", + "PointsVoidResponse", + "PointsAdjustRequest", + "PointsAdjustResponse", # PIN "PinCreate", "PinUpdate", diff --git a/app/modules/loyalty/schemas/card.py b/app/modules/loyalty/schemas/card.py index baa31aed..a10a395b 100644 --- a/app/modules/loyalty/schemas/card.py +++ b/app/modules/loyalty/schemas/card.py @@ -1,6 +1,11 @@ # app/modules/loyalty/schemas/card.py """ Pydantic schemas for loyalty card operations. + +Company-based cards: +- Cards belong to a company's loyalty program +- One card per customer per company +- Can be used at any vendor within the company """ from datetime import datetime @@ -29,8 +34,9 @@ class CardResponse(BaseModel): id: int card_number: str customer_id: int - vendor_id: int + company_id: int program_id: int + enrolled_at_vendor_id: int | None = None # Stamps stamp_count: int @@ -64,6 +70,9 @@ class CardDetailResponse(CardResponse): customer_name: str | None = None customer_email: str | None = None + # Company info + company_name: str | None = None + # Program info program_name: str program_type: str @@ -73,6 +82,7 @@ class CardDetailResponse(CardResponse): last_stamp_at: datetime | None = None last_points_at: datetime | None = None last_redemption_at: datetime | None = None + last_activity_at: datetime | None = None # Wallet URLs google_wallet_url: str | None = None @@ -98,6 +108,10 @@ class CardLookupResponse(BaseModel): customer_name: str | None = None customer_email: str + # Company context + company_id: int + company_name: str | None = None + # Current balances stamp_count: int stamps_target: int @@ -108,6 +122,9 @@ class CardLookupResponse(BaseModel): can_redeem_stamps: bool = False stamp_reward_description: str | None = None + # Available points rewards + available_rewards: list[dict] = [] + # Cooldown status can_stamp: bool = True cooldown_ends_at: datetime | None = None @@ -116,3 +133,44 @@ class CardLookupResponse(BaseModel): stamps_today: int = 0 max_daily_stamps: int = 5 can_earn_more_stamps: bool = True + + +class TransactionResponse(BaseModel): + """Schema for a loyalty transaction.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + card_id: int + vendor_id: int | None = None + vendor_name: str | None = None + transaction_type: str + + # Deltas + stamps_delta: int = 0 + points_delta: int = 0 + + # Balances after + stamps_balance_after: int | None = None + points_balance_after: int | None = None + + # Context + purchase_amount_cents: int | None = None + order_reference: str | None = None + reward_id: str | None = None + reward_description: str | None = None + notes: str | None = None + + # Staff + staff_name: str | None = None + + # Timestamps + transaction_at: datetime + created_at: datetime + + +class TransactionListResponse(BaseModel): + """Schema for listing transactions.""" + + transactions: list[TransactionResponse] + total: int diff --git a/app/modules/loyalty/schemas/points.py b/app/modules/loyalty/schemas/points.py index ff153b74..338060a5 100644 --- a/app/modules/loyalty/schemas/points.py +++ b/app/modules/loyalty/schemas/points.py @@ -1,6 +1,11 @@ # app/modules/loyalty/schemas/points.py """ Pydantic schemas for points operations. + +Company-based points: +- Points earned at any vendor count toward company total +- Points can be redeemed at any vendor within the company +- Supports voiding points for returns """ from pydantic import BaseModel, Field @@ -67,6 +72,9 @@ class PointsEarnResponse(BaseModel): points_balance: int total_points_earned: int + # Location + vendor_id: int | None = None + class PointsRedeemRequest(BaseModel): """Schema for redeeming points for a reward.""" @@ -122,3 +130,108 @@ class PointsRedeemResponse(BaseModel): card_number: str points_balance: int total_points_redeemed: int + + # Location + vendor_id: int | None = None + + +class PointsVoidRequest(BaseModel): + """Schema for voiding points (for returns).""" + + card_id: int | None = Field( + None, + description="Card ID (use this or qr_code)", + ) + qr_code: str | None = Field( + None, + description="QR code data from card scan", + ) + card_number: str | None = Field( + None, + description="Card number (manual entry)", + ) + + # Points to void (use one method) + points_to_void: int | None = Field( + None, + gt=0, + description="Number of points to void", + ) + original_transaction_id: int | None = Field( + None, + description="ID of original transaction to void", + ) + order_reference: str | None = Field( + None, + max_length=100, + description="Order reference to find and void", + ) + + # Authentication + staff_pin: str | None = Field( + None, + min_length=4, + max_length=6, + description="Staff PIN for verification", + ) + + # Required metadata + notes: str | None = Field( + None, + max_length=500, + description="Reason for voiding", + ) + + +class PointsVoidResponse(BaseModel): + """Schema for points void response.""" + + success: bool = True + message: str = "Points voided successfully" + + # Void info + points_voided: int + + # Card state after void + card_id: int + card_number: str + points_balance: int + + # Location + vendor_id: int | None = None + + +class PointsAdjustRequest(BaseModel): + """Schema for manual points adjustment (admin).""" + + points_delta: int = Field( + ..., + description="Points to add (positive) or remove (negative)", + ) + reason: str = Field( + ..., + min_length=5, + max_length=500, + description="Reason for adjustment (required)", + ) + staff_pin: str | None = Field( + None, + min_length=4, + max_length=6, + description="Staff PIN for verification", + ) + + +class PointsAdjustResponse(BaseModel): + """Schema for points adjustment response.""" + + success: bool = True + message: str = "Points adjusted successfully" + + # Adjustment info + points_delta: int + + # Card state after adjustment + card_id: int + card_number: str + points_balance: int diff --git a/app/modules/loyalty/schemas/program.py b/app/modules/loyalty/schemas/program.py index c621772a..896a965d 100644 --- a/app/modules/loyalty/schemas/program.py +++ b/app/modules/loyalty/schemas/program.py @@ -1,6 +1,11 @@ # app/modules/loyalty/schemas/program.py """ Pydantic schemas for loyalty program operations. + +Company-based programs: +- One program per company +- All vendors under a company share the same program +- Supports chain-wide loyalty across locations """ from datetime import datetime @@ -18,12 +23,22 @@ class PointsRewardConfig(BaseModel): is_active: bool = Field(True, description="Whether reward is currently available") +class TierConfig(BaseModel): + """Configuration for a loyalty tier (future use).""" + + id: str = Field(..., description="Tier identifier") + name: str = Field(..., max_length=50, description="Tier name (e.g., Bronze, Silver, Gold)") + points_threshold: int = Field(..., ge=0, description="Points needed to reach this tier") + benefits: list[str] = Field(default_factory=list, description="List of tier benefits") + multiplier: float = Field(1.0, ge=1.0, description="Points earning multiplier") + + class ProgramCreate(BaseModel): """Schema for creating a loyalty program.""" # Program type loyalty_type: str = Field( - "stamps", + "points", pattern="^(stamps|points|hybrid)$", description="Program type: stamps, points, or hybrid", ) @@ -42,11 +57,37 @@ class ProgramCreate(BaseModel): ) # Points configuration - points_per_euro: int = Field(10, ge=1, le=1000, description="Points per euro spent") + points_per_euro: int = Field(1, ge=1, le=100, description="Points per euro spent") points_rewards: list[PointsRewardConfig] = Field( default_factory=list, description="Available point rewards", ) + points_expiration_days: int | None = Field( + None, + ge=30, + description="Days of inactivity before points expire (None = never)", + ) + welcome_bonus_points: int = Field( + 0, + ge=0, + description="Bonus points awarded on enrollment", + ) + minimum_redemption_points: int = Field( + 100, + ge=1, + description="Minimum points required for redemption", + ) + minimum_purchase_cents: int = Field( + 0, + ge=0, + description="Minimum purchase amount to earn points (0 = no minimum)", + ) + + # Future: Tier configuration + tier_config: list[TierConfig] | None = Field( + None, + description="Tier configuration (future use)", + ) # Anti-fraud cooldown_minutes: int = Field(15, ge=0, le=1440, description="Minutes between stamps") @@ -90,8 +131,15 @@ class ProgramUpdate(BaseModel): stamps_reward_value_cents: int | None = Field(None, ge=0) # Points configuration - points_per_euro: int | None = Field(None, ge=1, le=1000) + points_per_euro: int | None = Field(None, ge=1, le=100) points_rewards: list[PointsRewardConfig] | None = None + points_expiration_days: int | None = Field(None, ge=30) + welcome_bonus_points: int | None = Field(None, ge=0) + minimum_redemption_points: int | None = Field(None, ge=1) + minimum_purchase_cents: int | None = Field(None, ge=0) + + # Future: Tier configuration + tier_config: list[TierConfig] | None = None # Anti-fraud cooldown_minutes: int | None = Field(None, ge=0, le=1440) @@ -123,7 +171,8 @@ class ProgramResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: int - vendor_id: int + company_id: int + company_name: str | None = None # Populated by API from Company join loyalty_type: str # Stamps @@ -134,6 +183,10 @@ class ProgramResponse(BaseModel): # Points points_per_euro: int points_rewards: list[PointsRewardConfig] = [] + points_expiration_days: int | None = None + welcome_bonus_points: int = 0 + minimum_redemption_points: int = 100 + minimum_purchase_cents: int = 0 # Anti-fraud cooldown_minutes: int @@ -167,6 +220,12 @@ class ProgramResponse(BaseModel): is_points_enabled: bool = False display_name: str = "Loyalty Card" + # Stats (populated by API) + total_cards: int | None = None + active_cards: int | None = None + total_points_issued: int | None = None + total_points_redeemed: int | None = None + class ProgramListResponse(BaseModel): """Schema for listing loyalty programs (admin).""" @@ -201,3 +260,61 @@ class ProgramStatsResponse(BaseModel): # Value estimated_liability_cents: int = 0 # Unredeemed stamps/points value + + +class CompanyStatsResponse(BaseModel): + """Schema for company-wide loyalty statistics across all locations.""" + + company_id: int + program_id: int | None = None # May be None if no program set up + + # Cards + total_cards: int = 0 + active_cards: int = 0 + + # Points - all time + total_points_issued: int = 0 + total_points_redeemed: int = 0 + + # Points - last 30 days + points_issued_30d: int = 0 + points_redeemed_30d: int = 0 + transactions_30d: int = 0 + + # Program info (optional) + program: dict | None = None + + # Location breakdown + locations: list[dict] = [] # Per-location breakdown + + +class CompanySettingsResponse(BaseModel): + """Schema for company loyalty settings.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + company_id: int + staff_pin_policy: str + staff_pin_lockout_attempts: int + staff_pin_lockout_minutes: int + allow_self_enrollment: bool + allow_void_transactions: bool + allow_cross_location_redemption: bool + created_at: datetime + updated_at: datetime + + +class CompanySettingsUpdate(BaseModel): + """Schema for updating company loyalty settings.""" + + staff_pin_policy: str | None = Field( + None, + pattern="^(required|optional|disabled)$", + description="Staff PIN policy: required, optional, or disabled", + ) + staff_pin_lockout_attempts: int | None = Field(None, ge=3, le=10) + staff_pin_lockout_minutes: int | None = Field(None, ge=5, le=120) + allow_self_enrollment: bool | None = None + allow_void_transactions: bool | None = None + allow_cross_location_redemption: bool | None = None diff --git a/app/modules/loyalty/schemas/stamp.py b/app/modules/loyalty/schemas/stamp.py index c7733393..4f2b9bbe 100644 --- a/app/modules/loyalty/schemas/stamp.py +++ b/app/modules/loyalty/schemas/stamp.py @@ -1,6 +1,11 @@ # app/modules/loyalty/schemas/stamp.py """ Pydantic schemas for stamp operations. + +Company-based stamps: +- Stamps earned at any vendor count toward company total +- Stamps can be redeemed at any vendor within the company +- Supports voiding stamps for returns """ from datetime import datetime @@ -64,6 +69,9 @@ class StampResponse(BaseModel): stamps_today: int stamps_remaining_today: int + # Location + vendor_id: int | None = None + class StampRedeemRequest(BaseModel): """Schema for redeeming stamps for a reward.""" @@ -112,3 +120,67 @@ class StampRedeemResponse(BaseModel): # Reward info reward_description: str total_redemptions: int # Lifetime redemptions for this card + + # Location + vendor_id: int | None = None + + +class StampVoidRequest(BaseModel): + """Schema for voiding stamps (for returns).""" + + card_id: int | None = Field( + None, + description="Card ID (use this or qr_code)", + ) + qr_code: str | None = Field( + None, + description="QR code data from card scan", + ) + card_number: str | None = Field( + None, + description="Card number (manual entry)", + ) + + # Stamps to void (use one method) + stamps_to_void: int | None = Field( + None, + gt=0, + description="Number of stamps to void", + ) + original_transaction_id: int | None = Field( + None, + description="ID of original transaction to void", + ) + + # Authentication + staff_pin: str | None = Field( + None, + min_length=4, + max_length=6, + description="Staff PIN for verification", + ) + + # Required metadata + notes: str | None = Field( + None, + max_length=500, + description="Reason for voiding", + ) + + +class StampVoidResponse(BaseModel): + """Schema for stamp void response.""" + + success: bool = True + message: str = "Stamps voided successfully" + + # Void info + stamps_voided: int + + # Card state after void + card_id: int + card_number: str + stamp_count: int + + # Location + vendor_id: int | None = None diff --git a/app/modules/loyalty/services/card_service.py b/app/modules/loyalty/services/card_service.py index f929a381..7fedfc87 100644 --- a/app/modules/loyalty/services/card_service.py +++ b/app/modules/loyalty/services/card_service.py @@ -2,9 +2,14 @@ """ Loyalty card service. +Company-based card operations: +- Cards belong to a company's loyalty program +- One card per customer per company +- Can be used at any vendor within the company + Handles card operations including: -- Customer enrollment -- Card lookup (by ID, QR code, card number) +- Customer enrollment (with welcome bonus) +- Card lookup (by ID, QR code, card number, email, phone) - Card management (activation, deactivation) """ @@ -19,7 +24,12 @@ from app.modules.loyalty.exceptions import ( LoyaltyProgramInactiveException, LoyaltyProgramNotFoundException, ) -from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction, TransactionType +from app.modules.loyalty.models import ( + LoyaltyCard, + LoyaltyProgram, + LoyaltyTransaction, + TransactionType, +) logger = logging.getLogger(__name__) @@ -51,10 +61,31 @@ class CardService: def get_card_by_number(self, db: Session, card_number: str) -> LoyaltyCard | None: """Get a loyalty card by card number.""" + # Normalize card number (remove dashes) + normalized = card_number.replace("-", "").replace(" ", "") return ( db.query(LoyaltyCard) .options(joinedload(LoyaltyCard.program)) - .filter(LoyaltyCard.card_number == card_number) + .filter( + LoyaltyCard.card_number.replace("-", "") == normalized + ) + .first() + ) + + def get_card_by_customer_and_company( + self, + db: Session, + customer_id: int, + company_id: int, + ) -> LoyaltyCard | None: + """Get a customer's card for a company's program.""" + return ( + db.query(LoyaltyCard) + .options(joinedload(LoyaltyCard.program)) + .filter( + LoyaltyCard.customer_id == customer_id, + LoyaltyCard.company_id == company_id, + ) .first() ) @@ -89,6 +120,7 @@ class CardService: card_id: int | None = None, qr_code: str | None = None, card_number: str | None = None, + company_id: int | None = None, ) -> LoyaltyCard: """ Look up a card by any identifier. @@ -97,7 +129,8 @@ class CardService: db: Database session card_id: Card ID qr_code: QR code data - card_number: Card number + card_number: Card number (with or without dashes) + company_id: Optional company filter Returns: Found card @@ -118,28 +151,73 @@ class CardService: identifier = card_id or qr_code or card_number or "unknown" raise LoyaltyCardNotFoundException(str(identifier)) + # Filter by company if specified + if company_id and card.company_id != company_id: + raise LoyaltyCardNotFoundException(str(card_id or qr_code or card_number)) + return card - def list_cards( + def lookup_card_for_vendor( self, db: Session, vendor_id: int, *, + card_id: int | None = None, + qr_code: str | None = None, + card_number: str | None = None, + ) -> LoyaltyCard: + """ + Look up a card for a specific vendor (must be in same company). + + Args: + db: Database session + vendor_id: Vendor ID (to get company context) + card_id: Card ID + qr_code: QR code data + card_number: Card number + + Returns: + Found card (verified to be in vendor's company) + + Raises: + LoyaltyCardNotFoundException: If no card found or wrong company + """ + from app.modules.tenancy.models import Vendor + + vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() + if not vendor: + raise LoyaltyCardNotFoundException("vendor not found") + + return self.lookup_card( + db, + card_id=card_id, + qr_code=qr_code, + card_number=card_number, + company_id=vendor.company_id, + ) + + def list_cards( + self, + db: Session, + company_id: int, + *, + vendor_id: int | None = None, skip: int = 0, limit: int = 50, is_active: bool | None = None, search: str | None = None, ) -> tuple[list[LoyaltyCard], int]: """ - List loyalty cards for a vendor. + List loyalty cards for a company. Args: db: Database session - vendor_id: Vendor ID + company_id: Company ID + vendor_id: Optional filter by enrolled vendor skip: Pagination offset limit: Pagination limit is_active: Filter by active status - search: Search by card number or customer email + search: Search by card number, email, or name Returns: (cards, total_count) @@ -149,18 +227,24 @@ class CardService: query = ( db.query(LoyaltyCard) .options(joinedload(LoyaltyCard.customer)) - .filter(LoyaltyCard.vendor_id == vendor_id) + .filter(LoyaltyCard.company_id == company_id) ) + if vendor_id: + query = query.filter(LoyaltyCard.enrolled_at_vendor_id == vendor_id) + if is_active is not None: query = query.filter(LoyaltyCard.is_active == is_active) if search: + # Normalize search term for card number matching + search_normalized = search.replace("-", "").replace(" ", "") query = query.join(Customer).filter( - (LoyaltyCard.card_number.ilike(f"%{search}%")) + (LoyaltyCard.card_number.replace("-", "").ilike(f"%{search_normalized}%")) | (Customer.email.ilike(f"%{search}%")) | (Customer.first_name.ilike(f"%{search}%")) | (Customer.last_name.ilike(f"%{search}%")) + | (Customer.phone.ilike(f"%{search}%")) ) total = query.count() @@ -181,7 +265,7 @@ class CardService: """List all loyalty cards for a customer.""" return ( db.query(LoyaltyCard) - .options(joinedload(LoyaltyCard.program)) + .options(joinedload(LoyaltyCard.program), joinedload(LoyaltyCard.company)) .filter(LoyaltyCard.customer_id == customer_id) .all() ) @@ -194,18 +278,18 @@ class CardService: self, db: Session, customer_id: int, - vendor_id: int, + company_id: int, *, - program_id: int | None = None, + enrolled_at_vendor_id: int | None = None, ) -> LoyaltyCard: """ - Enroll a customer in a loyalty program. + Enroll a customer in a company's loyalty program. Args: db: Database session customer_id: Customer ID - vendor_id: Vendor ID - program_id: Optional program ID (defaults to vendor's program) + company_id: Company ID + enrolled_at_vendor_id: Vendor where customer enrolled (for analytics) Returns: Created loyalty card @@ -216,35 +300,29 @@ class CardService: LoyaltyCardAlreadyExistsException: If customer already enrolled """ # Get the program - if program_id: - program = ( - db.query(LoyaltyProgram) - .filter(LoyaltyProgram.id == program_id) - .first() - ) - else: - program = ( - db.query(LoyaltyProgram) - .filter(LoyaltyProgram.vendor_id == vendor_id) - .first() - ) + program = ( + db.query(LoyaltyProgram) + .filter(LoyaltyProgram.company_id == company_id) + .first() + ) if not program: - raise LoyaltyProgramNotFoundException(f"vendor:{vendor_id}") + raise LoyaltyProgramNotFoundException(f"company:{company_id}") if not program.is_active: raise LoyaltyProgramInactiveException(program.id) # Check if customer already has a card - existing = self.get_card_by_customer_and_program(db, customer_id, program.id) + existing = self.get_card_by_customer_and_company(db, customer_id, company_id) if existing: raise LoyaltyCardAlreadyExistsException(customer_id, program.id) # Create the card card = LoyaltyCard( + company_id=company_id, customer_id=customer_id, program_id=program.id, - vendor_id=vendor_id, + enrolled_at_vendor_id=enrolled_at_vendor_id, ) db.add(card) @@ -252,32 +330,88 @@ class CardService: # Create enrollment transaction transaction = LoyaltyTransaction( + company_id=company_id, card_id=card.id, - vendor_id=vendor_id, + vendor_id=enrolled_at_vendor_id, transaction_type=TransactionType.CARD_CREATED.value, transaction_at=datetime.now(UTC), ) db.add(transaction) + # Award welcome bonus if configured + if program.welcome_bonus_points > 0: + card.add_points(program.welcome_bonus_points) + + bonus_transaction = LoyaltyTransaction( + company_id=company_id, + card_id=card.id, + vendor_id=enrolled_at_vendor_id, + transaction_type=TransactionType.WELCOME_BONUS.value, + points_delta=program.welcome_bonus_points, + points_balance_after=card.points_balance, + notes="Welcome bonus on enrollment", + transaction_at=datetime.now(UTC), + ) + db.add(bonus_transaction) + db.commit() db.refresh(card) logger.info( - f"Enrolled customer {customer_id} in loyalty program {program.id} " - f"(card: {card.card_number})" + f"Enrolled customer {customer_id} in company {company_id} loyalty program " + f"(card: {card.card_number}, bonus: {program.welcome_bonus_points} pts)" ) return card - def deactivate_card(self, db: Session, card_id: int) -> LoyaltyCard: + def enroll_customer_for_vendor( + self, + db: Session, + customer_id: int, + vendor_id: int, + ) -> LoyaltyCard: + """ + Enroll a customer through a specific vendor. + + Looks up the vendor's company and enrolls in the company's program. + + Args: + db: Database session + customer_id: Customer ID + vendor_id: Vendor ID + + Returns: + Created loyalty card + """ + from app.modules.tenancy.models import Vendor + + vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() + if not vendor: + raise LoyaltyProgramNotFoundException(f"vendor:{vendor_id}") + + return self.enroll_customer( + db, + customer_id, + vendor.company_id, + enrolled_at_vendor_id=vendor_id, + ) + + def deactivate_card( + self, + db: Session, + card_id: int, + *, + vendor_id: int | None = None, + ) -> LoyaltyCard: """Deactivate a loyalty card.""" card = self.require_card(db, card_id) card.is_active = False # Create deactivation transaction transaction = LoyaltyTransaction( + company_id=card.company_id, card_id=card.id, - vendor_id=card.vendor_id, + vendor_id=vendor_id, transaction_type=TransactionType.CARD_DEACTIVATED.value, transaction_at=datetime.now(UTC), ) @@ -334,6 +468,7 @@ class CardService: """Get transaction history for a card.""" query = ( db.query(LoyaltyTransaction) + .options(joinedload(LoyaltyTransaction.vendor)) .filter(LoyaltyTransaction.card_id == card_id) .order_by(LoyaltyTransaction.transaction_at.desc()) ) diff --git a/app/modules/loyalty/services/pin_service.py b/app/modules/loyalty/services/pin_service.py index 8c984938..d03da74a 100644 --- a/app/modules/loyalty/services/pin_service.py +++ b/app/modules/loyalty/services/pin_service.py @@ -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): diff --git a/app/modules/loyalty/services/points_service.py b/app/modules/loyalty/services/points_service.py index 837d73f6..6ac42af3 100644 --- a/app/modules/loyalty/services/points_service.py +++ b/app/modules/loyalty/services/points_service.py @@ -2,9 +2,15 @@ """ Points service. +Company-based points operations: +- Points earned at any vendor count toward company total +- Points can be redeemed at any vendor within the company +- Supports voiding points for returns + Handles points operations including: - Earning points from purchases - Redeeming points for rewards +- Voiding points (for returns) - Points balance management """ @@ -34,6 +40,7 @@ class PointsService: self, db: Session, *, + vendor_id: int, card_id: int | None = None, qr_code: str | None = None, card_number: str | None = None, @@ -51,6 +58,7 @@ class PointsService: Args: db: Database session + vendor_id: Vendor ID (where purchase is being made) card_id: Card ID qr_code: QR code data card_number: Card number @@ -64,9 +72,10 @@ class PointsService: Returns: Dict with operation result """ - # Look up the card - card = card_service.lookup_card( + # Look up the card (validates it belongs to vendor's company) + card = card_service.lookup_card_for_vendor( db, + vendor_id, card_id=card_id, qr_code=qr_code, card_number=card_number, @@ -85,12 +94,26 @@ class PointsService: logger.warning(f"Points attempted on stamps-only program {program.id}") raise LoyaltyCardInactiveException(card.id) + # Check minimum purchase amount + if program.minimum_purchase_cents > 0 and purchase_amount_cents < program.minimum_purchase_cents: + return { + "success": True, + "message": f"Purchase below minimum of €{program.minimum_purchase_cents/100:.2f}", + "points_earned": 0, + "points_per_euro": program.points_per_euro, + "purchase_amount_cents": purchase_amount_cents, + "card_id": card.id, + "card_number": card.card_number, + "points_balance": card.points_balance, + "total_points_earned": card.total_points_earned, + } + # Verify staff PIN if required verified_pin = None if program.require_staff_pin: if not staff_pin: raise StaffPinRequiredException() - verified_pin = pin_service.verify_pin(db, program.id, staff_pin) + verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id) # Calculate points # points_per_euro is per full euro, so divide cents by 100 @@ -115,11 +138,13 @@ class PointsService: card.points_balance += points_earned card.total_points_earned += points_earned card.last_points_at = now + card.last_activity_at = now # Create transaction transaction = LoyaltyTransaction( + company_id=card.company_id, card_id=card.id, - vendor_id=card.vendor_id, + vendor_id=vendor_id, staff_pin_id=verified_pin.id if verified_pin else None, transaction_type=TransactionType.POINTS_EARNED.value, points_delta=points_earned, @@ -138,7 +163,7 @@ class PointsService: db.refresh(card) logger.info( - f"Added {points_earned} points to card {card.id} " + f"Added {points_earned} points to card {card.id} at vendor {vendor_id} " f"(purchase: €{purchase_euros:.2f}, balance: {card.points_balance})" ) @@ -152,12 +177,14 @@ class PointsService: "card_number": card.card_number, "points_balance": card.points_balance, "total_points_earned": card.total_points_earned, + "vendor_id": vendor_id, } def redeem_points( self, db: Session, *, + vendor_id: int, card_id: int | None = None, qr_code: str | None = None, card_number: str | None = None, @@ -172,6 +199,7 @@ class PointsService: Args: db: Database session + vendor_id: Vendor ID (where redemption is happening) card_id: Card ID qr_code: QR code data card_number: Card number @@ -188,9 +216,10 @@ class PointsService: InvalidRewardException: Reward not found or inactive InsufficientPointsException: Not enough points """ - # Look up the card - card = card_service.lookup_card( + # Look up the card (validates it belongs to vendor's company) + card = card_service.lookup_card_for_vendor( db, + vendor_id, card_id=card_id, qr_code=qr_code, card_number=card_number, @@ -215,6 +244,10 @@ class PointsService: points_required = reward["points_required"] reward_name = reward["name"] + # Check minimum redemption + if points_required < program.minimum_redemption_points: + raise InvalidRewardException(reward_id) + # Check if enough points if card.points_balance < points_required: raise InsufficientPointsException(card.points_balance, points_required) @@ -224,18 +257,20 @@ class PointsService: if program.require_staff_pin: if not staff_pin: raise StaffPinRequiredException() - verified_pin = pin_service.verify_pin(db, program.id, staff_pin) + verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id) # Redeem points now = datetime.now(UTC) card.points_balance -= points_required card.points_redeemed += points_required card.last_redemption_at = now + card.last_activity_at = now # Create transaction transaction = LoyaltyTransaction( + company_id=card.company_id, card_id=card.id, - vendor_id=card.vendor_id, + vendor_id=vendor_id, staff_pin_id=verified_pin.id if verified_pin else None, transaction_type=TransactionType.POINTS_REDEEMED.value, points_delta=-points_required, @@ -254,7 +289,7 @@ class PointsService: db.refresh(card) logger.info( - f"Redeemed {points_required} points from card {card.id} " + f"Redeemed {points_required} points from card {card.id} at vendor {vendor_id} " f"(reward: {reward_name}, balance: {card.points_balance})" ) @@ -268,6 +303,140 @@ class PointsService: "card_number": card.card_number, "points_balance": card.points_balance, "total_points_redeemed": card.points_redeemed, + "vendor_id": vendor_id, + } + + def void_points( + self, + db: Session, + *, + vendor_id: int, + card_id: int | None = None, + qr_code: str | None = None, + card_number: str | None = None, + points_to_void: int | None = None, + original_transaction_id: int | None = None, + order_reference: str | None = None, + staff_pin: str | None = None, + ip_address: str | None = None, + user_agent: str | None = None, + notes: str | None = None, + ) -> dict: + """ + Void points for a return. + + Args: + db: Database session + vendor_id: Vendor ID + card_id: Card ID + qr_code: QR code data + card_number: Card number + points_to_void: Number of points to void (if not using original_transaction_id) + original_transaction_id: ID of original earn transaction to void + order_reference: Order reference (to find original transaction) + staff_pin: Staff PIN for verification + ip_address: Request IP for audit + user_agent: Request user agent for audit + notes: Reason for voiding + + Returns: + Dict with operation result + """ + # Look up the card + card = card_service.lookup_card_for_vendor( + db, + vendor_id, + card_id=card_id, + qr_code=qr_code, + card_number=card_number, + ) + + program = card.program + + # Verify staff PIN if required + verified_pin = None + if program.require_staff_pin: + if not staff_pin: + raise StaffPinRequiredException() + verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id) + + # Determine points to void + original_transaction = None + if original_transaction_id: + original_transaction = ( + db.query(LoyaltyTransaction) + .filter( + LoyaltyTransaction.id == original_transaction_id, + LoyaltyTransaction.card_id == card.id, + LoyaltyTransaction.transaction_type == TransactionType.POINTS_EARNED.value, + ) + .first() + ) + if original_transaction: + points_to_void = original_transaction.points_delta + elif order_reference: + original_transaction = ( + db.query(LoyaltyTransaction) + .filter( + LoyaltyTransaction.order_reference == order_reference, + LoyaltyTransaction.card_id == card.id, + LoyaltyTransaction.transaction_type == TransactionType.POINTS_EARNED.value, + ) + .first() + ) + if original_transaction: + points_to_void = original_transaction.points_delta + + if not points_to_void or points_to_void <= 0: + return { + "success": False, + "message": "No points to void", + "card_id": card.id, + "card_number": card.card_number, + "points_balance": card.points_balance, + } + + # Void the points (can reduce balance below what was earned) + now = datetime.now(UTC) + actual_voided = min(points_to_void, card.points_balance) + card.points_balance = max(0, card.points_balance - points_to_void) + card.last_activity_at = now + + # Create void transaction + transaction = LoyaltyTransaction( + company_id=card.company_id, + card_id=card.id, + vendor_id=vendor_id, + staff_pin_id=verified_pin.id if verified_pin else None, + transaction_type=TransactionType.POINTS_VOIDED.value, + points_delta=-actual_voided, + stamps_balance_after=card.stamp_count, + points_balance_after=card.points_balance, + related_transaction_id=original_transaction.id if original_transaction else None, + order_reference=order_reference, + ip_address=ip_address, + user_agent=user_agent, + notes=notes or "Points voided for return", + transaction_at=now, + ) + db.add(transaction) + + db.commit() + db.refresh(card) + + logger.info( + f"Voided {actual_voided} points from card {card.id} at vendor {vendor_id} " + f"(balance: {card.points_balance})" + ) + + return { + "success": True, + "message": "Points voided successfully", + "points_voided": actual_voided, + "card_id": card.id, + "card_number": card.card_number, + "points_balance": card.points_balance, + "vendor_id": vendor_id, } def adjust_points( @@ -276,18 +445,20 @@ class PointsService: card_id: int, points_delta: int, *, + vendor_id: int | None = None, reason: str, staff_pin: str | None = None, ip_address: str | None = None, user_agent: str | None = None, ) -> dict: """ - Manually adjust points (admin operation). + Manually adjust points (admin/vendor operation). Args: db: Database session card_id: Card ID points_delta: Points to add (positive) or remove (negative) + vendor_id: Vendor ID reason: Reason for adjustment staff_pin: Staff PIN for verification ip_address: Request IP for audit @@ -299,14 +470,15 @@ class PointsService: card = card_service.require_card(db, card_id) program = card.program - # Verify staff PIN if required + # Verify staff PIN if required and vendor provided verified_pin = None - if program.require_staff_pin and staff_pin: - verified_pin = pin_service.verify_pin(db, program.id, staff_pin) + if program.require_staff_pin and staff_pin and vendor_id: + verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id) # Apply adjustment now = datetime.now(UTC) card.points_balance += points_delta + card.last_activity_at = now if points_delta > 0: card.total_points_earned += points_delta @@ -320,8 +492,9 @@ class PointsService: # Create transaction transaction = LoyaltyTransaction( + company_id=card.company_id, card_id=card.id, - vendor_id=card.vendor_id, + vendor_id=vendor_id, staff_pin_id=verified_pin.id if verified_pin else None, transaction_type=TransactionType.POINTS_ADJUSTMENT.value, points_delta=points_delta, diff --git a/app/modules/loyalty/services/program_service.py b/app/modules/loyalty/services/program_service.py index f2ee3d64..f2e614ff 100644 --- a/app/modules/loyalty/services/program_service.py +++ b/app/modules/loyalty/services/program_service.py @@ -2,6 +2,11 @@ """ Loyalty program service. +Company-based program management: +- Programs belong to companies, not individual vendors +- All vendors under a company share the same loyalty program +- One program per company + Handles CRUD operations for loyalty programs including: - Program creation and configuration - Program updates @@ -18,7 +23,11 @@ from app.modules.loyalty.exceptions import ( LoyaltyProgramAlreadyExistsException, LoyaltyProgramNotFoundException, ) -from app.modules.loyalty.models import LoyaltyProgram, LoyaltyType +from app.modules.loyalty.models import ( + LoyaltyProgram, + LoyaltyType, + CompanyLoyaltySettings, +) from app.modules.loyalty.schemas.program import ( ProgramCreate, ProgramUpdate, @@ -42,25 +51,53 @@ class ProgramService: .first() ) - def get_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None: - """Get a vendor's loyalty program.""" + def get_program_by_company(self, db: Session, company_id: int) -> LoyaltyProgram | None: + """Get a company's loyalty program.""" return ( db.query(LoyaltyProgram) - .filter(LoyaltyProgram.vendor_id == vendor_id) + .filter(LoyaltyProgram.company_id == company_id) .first() ) - def get_active_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None: - """Get a vendor's active loyalty program.""" + def get_active_program_by_company(self, db: Session, company_id: int) -> LoyaltyProgram | None: + """Get a company's active loyalty program.""" return ( db.query(LoyaltyProgram) .filter( - LoyaltyProgram.vendor_id == vendor_id, + LoyaltyProgram.company_id == company_id, LoyaltyProgram.is_active == True, ) .first() ) + def get_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None: + """ + Get the loyalty program for a vendor. + + Looks up the vendor's company and returns the company's program. + """ + from app.modules.tenancy.models import Vendor + + vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() + if not vendor: + return None + + return self.get_program_by_company(db, vendor.company_id) + + def get_active_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None: + """ + Get the active loyalty program for a vendor. + + Looks up the vendor's company and returns the company's active program. + """ + from app.modules.tenancy.models import Vendor + + vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() + if not vendor: + return None + + return self.get_active_program_by_company(db, vendor.company_id) + def require_program(self, db: Session, program_id: int) -> LoyaltyProgram: """Get a program or raise exception if not found.""" program = self.get_program(db, program_id) @@ -68,6 +105,13 @@ class ProgramService: raise LoyaltyProgramNotFoundException(str(program_id)) return program + def require_program_by_company(self, db: Session, company_id: int) -> LoyaltyProgram: + """Get a company's program or raise exception if not found.""" + program = self.get_program_by_company(db, company_id) + if not program: + raise LoyaltyProgramNotFoundException(f"company:{company_id}") + return program + def require_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram: """Get a vendor's program or raise exception if not found.""" program = self.get_program_by_vendor(db, vendor_id) @@ -82,15 +126,32 @@ class ProgramService: skip: int = 0, limit: int = 100, is_active: bool | None = None, + search: str | None = None, ) -> tuple[list[LoyaltyProgram], int]: - """List all loyalty programs (admin).""" - query = db.query(LoyaltyProgram) + """List all loyalty programs (admin). + + Args: + db: Database session + skip: Number of records to skip + limit: Maximum records to return + is_active: Filter by active status + search: Search by company name (case-insensitive) + """ + from app.modules.tenancy.models import Company + + query = db.query(LoyaltyProgram).join( + Company, LoyaltyProgram.company_id == Company.id + ) if is_active is not None: query = query.filter(LoyaltyProgram.is_active == is_active) + if search: + search_pattern = f"%{search}%" + query = query.filter(Company.name.ilike(search_pattern)) + total = query.count() - programs = query.offset(skip).limit(limit).all() + programs = query.order_by(LoyaltyProgram.created_at.desc()).offset(skip).limit(limit).all() return programs, total @@ -101,33 +162,33 @@ class ProgramService: def create_program( self, db: Session, - vendor_id: int, + company_id: int, data: ProgramCreate, ) -> LoyaltyProgram: """ - Create a new loyalty program for a vendor. + Create a new loyalty program for a company. Args: db: Database session - vendor_id: Vendor ID + company_id: Company ID data: Program configuration Returns: Created program Raises: - LoyaltyProgramAlreadyExistsException: If vendor already has a program + LoyaltyProgramAlreadyExistsException: If company already has a program """ - # Check if vendor already has a program - existing = self.get_program_by_vendor(db, vendor_id) + # Check if company already has a program + existing = self.get_program_by_company(db, company_id) if existing: - raise LoyaltyProgramAlreadyExistsException(vendor_id) + raise LoyaltyProgramAlreadyExistsException(company_id) # Convert points_rewards to dict list for JSON storage points_rewards_data = [r.model_dump() for r in data.points_rewards] program = LoyaltyProgram( - vendor_id=vendor_id, + company_id=company_id, loyalty_type=data.loyalty_type, # Stamps stamps_target=data.stamps_target, @@ -136,6 +197,10 @@ class ProgramService: # Points points_per_euro=data.points_per_euro, points_rewards=points_rewards_data, + points_expiration_days=data.points_expiration_days, + welcome_bonus_points=data.welcome_bonus_points, + minimum_redemption_points=data.minimum_redemption_points, + minimum_purchase_cents=data.minimum_purchase_cents, # Anti-fraud cooldown_minutes=data.cooldown_minutes, max_daily_stamps=data.max_daily_stamps, @@ -155,11 +220,19 @@ class ProgramService: ) db.add(program) + db.flush() + + # Create default company settings + settings = CompanyLoyaltySettings( + company_id=company_id, + ) + db.add(settings) + db.commit() db.refresh(program) logger.info( - f"Created loyalty program {program.id} for vendor {vendor_id} " + f"Created loyalty program {program.id} for company {company_id} " f"(type: {program.loyalty_type})" ) @@ -224,12 +297,39 @@ class ProgramService: def delete_program(self, db: Session, program_id: int) -> None: """Delete a loyalty program and all associated data.""" program = self.require_program(db, program_id) - vendor_id = program.vendor_id + company_id = program.company_id + + # Also delete company settings + db.query(CompanyLoyaltySettings).filter( + CompanyLoyaltySettings.company_id == company_id + ).delete() db.delete(program) db.commit() - logger.info(f"Deleted loyalty program {program_id} for vendor {vendor_id}") + logger.info(f"Deleted loyalty program {program_id} for company {company_id}") + + # ========================================================================= + # Company Settings + # ========================================================================= + + def get_company_settings(self, db: Session, company_id: int) -> CompanyLoyaltySettings | None: + """Get company loyalty settings.""" + return ( + db.query(CompanyLoyaltySettings) + .filter(CompanyLoyaltySettings.company_id == company_id) + .first() + ) + + def get_or_create_company_settings(self, db: Session, company_id: int) -> CompanyLoyaltySettings: + """Get or create company loyalty settings.""" + settings = self.get_company_settings(db, company_id) + if not settings: + settings = CompanyLoyaltySettings(company_id=company_id) + db.add(settings) + db.commit() + db.refresh(settings) + return settings # ========================================================================= # Statistics @@ -374,6 +474,196 @@ class ProgramService: "estimated_liability_cents": estimated_liability, } + def get_company_stats(self, db: Session, company_id: int) -> dict: + """ + Get statistics for a company's loyalty program across all locations. + + Returns dict with per-vendor breakdown. + """ + from datetime import UTC, datetime, timedelta + + from sqlalchemy import func + + from app.modules.loyalty.models import LoyaltyCard, LoyaltyTransaction + from app.modules.tenancy.models import Vendor + + program = self.get_program_by_company(db, company_id) + + # Base stats dict + stats = { + "company_id": company_id, + "program_id": program.id if program else None, + "total_cards": 0, + "active_cards": 0, + "total_points_issued": 0, + "total_points_redeemed": 0, + "points_issued_30d": 0, + "points_redeemed_30d": 0, + "transactions_30d": 0, + "program": None, + "locations": [], + } + + if not program: + return stats + + # Add program info + stats["program"] = { + "id": program.id, + "display_name": program.display_name, + "card_name": program.card_name, + "loyalty_type": program.loyalty_type.value if hasattr(program.loyalty_type, 'value') else str(program.loyalty_type), + "points_per_euro": program.points_per_euro, + "welcome_bonus_points": program.welcome_bonus_points, + "minimum_redemption_points": program.minimum_redemption_points, + "points_expiration_days": program.points_expiration_days, + "is_active": program.is_active, + } + + thirty_days_ago = datetime.now(UTC) - timedelta(days=30) + + # Total cards + stats["total_cards"] = ( + db.query(func.count(LoyaltyCard.id)) + .filter(LoyaltyCard.company_id == company_id) + .scalar() + or 0 + ) + + # Active cards + stats["active_cards"] = ( + db.query(func.count(LoyaltyCard.id)) + .filter( + LoyaltyCard.company_id == company_id, + LoyaltyCard.is_active == True, + ) + .scalar() + or 0 + ) + + # Total points issued (all time) + stats["total_points_issued"] = ( + db.query(func.sum(LoyaltyTransaction.points_delta)) + .filter( + LoyaltyTransaction.company_id == company_id, + LoyaltyTransaction.points_delta > 0, + ) + .scalar() + or 0 + ) + + # Total points redeemed (all time) + stats["total_points_redeemed"] = ( + db.query(func.sum(func.abs(LoyaltyTransaction.points_delta))) + .filter( + LoyaltyTransaction.company_id == company_id, + LoyaltyTransaction.points_delta < 0, + ) + .scalar() + or 0 + ) + + # Points issued (30 days) + stats["points_issued_30d"] = ( + db.query(func.sum(LoyaltyTransaction.points_delta)) + .filter( + LoyaltyTransaction.company_id == company_id, + LoyaltyTransaction.points_delta > 0, + LoyaltyTransaction.transaction_at >= thirty_days_ago, + ) + .scalar() + or 0 + ) + + # Points redeemed (30 days) + stats["points_redeemed_30d"] = ( + db.query(func.sum(func.abs(LoyaltyTransaction.points_delta))) + .filter( + LoyaltyTransaction.company_id == company_id, + LoyaltyTransaction.points_delta < 0, + LoyaltyTransaction.transaction_at >= thirty_days_ago, + ) + .scalar() + or 0 + ) + + # Transactions (30 days) + stats["transactions_30d"] = ( + db.query(func.count(LoyaltyTransaction.id)) + .filter( + LoyaltyTransaction.company_id == company_id, + LoyaltyTransaction.transaction_at >= thirty_days_ago, + ) + .scalar() + or 0 + ) + + # Get all vendors for this company for location breakdown + vendors = db.query(Vendor).filter(Vendor.company_id == company_id).all() + + location_stats = [] + for vendor in vendors: + # Cards enrolled at this vendor + enrolled_count = ( + db.query(func.count(LoyaltyCard.id)) + .filter( + LoyaltyCard.company_id == company_id, + LoyaltyCard.enrolled_at_vendor_id == vendor.id, + ) + .scalar() + or 0 + ) + + # Points earned at this vendor + points_earned = ( + db.query(func.sum(LoyaltyTransaction.points_delta)) + .filter( + LoyaltyTransaction.company_id == company_id, + LoyaltyTransaction.vendor_id == vendor.id, + LoyaltyTransaction.points_delta > 0, + ) + .scalar() + or 0 + ) + + # Points redeemed at this vendor + points_redeemed = ( + db.query(func.sum(func.abs(LoyaltyTransaction.points_delta))) + .filter( + LoyaltyTransaction.company_id == company_id, + LoyaltyTransaction.vendor_id == vendor.id, + LoyaltyTransaction.points_delta < 0, + ) + .scalar() + or 0 + ) + + # Transactions (30 days) at this vendor + transactions_30d = ( + db.query(func.count(LoyaltyTransaction.id)) + .filter( + LoyaltyTransaction.company_id == company_id, + LoyaltyTransaction.vendor_id == vendor.id, + LoyaltyTransaction.transaction_at >= thirty_days_ago, + ) + .scalar() + or 0 + ) + + location_stats.append({ + "vendor_id": vendor.id, + "vendor_name": vendor.name, + "vendor_code": vendor.vendor_code, + "enrolled_count": enrolled_count, + "points_earned": points_earned, + "points_redeemed": points_redeemed, + "transactions_30d": transactions_30d, + }) + + stats["locations"] = location_stats + + return stats + # Singleton instance program_service = ProgramService() diff --git a/app/modules/loyalty/services/stamp_service.py b/app/modules/loyalty/services/stamp_service.py index 9251d821..9c72ebb8 100644 --- a/app/modules/loyalty/services/stamp_service.py +++ b/app/modules/loyalty/services/stamp_service.py @@ -2,9 +2,15 @@ """ Stamp service. +Company-based stamp operations: +- Stamps earned at any vendor count toward company total +- Stamps can be redeemed at any vendor within the company +- Supports voiding stamps for returns + Handles stamp operations including: - Adding stamps with anti-fraud checks - Redeeming stamps for rewards +- Voiding stamps (for returns) - Daily limit tracking """ @@ -36,6 +42,7 @@ class StampService: self, db: Session, *, + vendor_id: int, card_id: int | None = None, qr_code: str | None = None, card_number: str | None = None, @@ -54,6 +61,7 @@ class StampService: Args: db: Database session + vendor_id: Vendor ID (where stamp is being added) card_id: Card ID qr_code: QR code data card_number: Card number @@ -74,9 +82,10 @@ class StampService: StampCooldownException: Cooldown period not elapsed DailyStampLimitException: Daily limit reached """ - # Look up the card - card = card_service.lookup_card( + # Look up the card (validates it belongs to vendor's company) + card = card_service.lookup_card_for_vendor( db, + vendor_id, card_id=card_id, qr_code=qr_code, card_number=card_number, @@ -100,7 +109,7 @@ class StampService: if program.require_staff_pin: if not staff_pin: raise StaffPinRequiredException() - verified_pin = pin_service.verify_pin(db, program.id, staff_pin) + verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id) # Check cooldown now = datetime.now(UTC) @@ -121,14 +130,16 @@ class StampService: card.stamp_count += 1 card.total_stamps_earned += 1 card.last_stamp_at = now + card.last_activity_at = now # Check if reward earned reward_earned = card.stamp_count >= program.stamps_target # Create transaction transaction = LoyaltyTransaction( + company_id=card.company_id, card_id=card.id, - vendor_id=card.vendor_id, + vendor_id=vendor_id, staff_pin_id=verified_pin.id if verified_pin else None, transaction_type=TransactionType.STAMP_EARNED.value, stamps_delta=1, @@ -147,7 +158,7 @@ class StampService: stamps_today += 1 logger.info( - f"Added stamp to card {card.id} " + f"Added stamp to card {card.id} at vendor {vendor_id} " f"(stamps: {card.stamp_count}/{program.stamps_target}, " f"today: {stamps_today}/{program.max_daily_stamps})" ) @@ -168,12 +179,14 @@ class StampService: "next_stamp_available_at": next_stamp_at, "stamps_today": stamps_today, "stamps_remaining_today": max(0, program.max_daily_stamps - stamps_today), + "vendor_id": vendor_id, } def redeem_stamps( self, db: Session, *, + vendor_id: int, card_id: int | None = None, qr_code: str | None = None, card_number: str | None = None, @@ -187,6 +200,7 @@ class StampService: Args: db: Database session + vendor_id: Vendor ID (where redemption is happening) card_id: Card ID qr_code: QR code data card_number: Card number @@ -203,9 +217,10 @@ class StampService: InsufficientStampsException: Not enough stamps StaffPinRequiredException: PIN required but not provided """ - # Look up the card - card = card_service.lookup_card( + # Look up the card (validates it belongs to vendor's company) + card = card_service.lookup_card_for_vendor( db, + vendor_id, card_id=card_id, qr_code=qr_code, card_number=card_number, @@ -228,7 +243,7 @@ class StampService: if program.require_staff_pin: if not staff_pin: raise StaffPinRequiredException() - verified_pin = pin_service.verify_pin(db, program.id, staff_pin) + verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id) # Redeem stamps now = datetime.now(UTC) @@ -236,11 +251,13 @@ class StampService: card.stamp_count -= stamps_redeemed card.stamps_redeemed += 1 card.last_redemption_at = now + card.last_activity_at = now # Create transaction transaction = LoyaltyTransaction( + company_id=card.company_id, card_id=card.id, - vendor_id=card.vendor_id, + vendor_id=vendor_id, staff_pin_id=verified_pin.id if verified_pin else None, transaction_type=TransactionType.STAMP_REDEEMED.value, stamps_delta=-stamps_redeemed, @@ -258,7 +275,7 @@ class StampService: db.refresh(card) logger.info( - f"Redeemed stamps from card {card.id} " + f"Redeemed stamps from card {card.id} at vendor {vendor_id} " f"(reward: {program.stamps_reward_description}, " f"total redemptions: {card.stamps_redeemed})" ) @@ -272,6 +289,125 @@ class StampService: "stamps_target": program.stamps_target, "reward_description": program.stamps_reward_description, "total_redemptions": card.stamps_redeemed, + "vendor_id": vendor_id, + } + + def void_stamps( + self, + db: Session, + *, + vendor_id: int, + card_id: int | None = None, + qr_code: str | None = None, + card_number: str | None = None, + stamps_to_void: int | None = None, + original_transaction_id: int | None = None, + staff_pin: str | None = None, + ip_address: str | None = None, + user_agent: str | None = None, + notes: str | None = None, + ) -> dict: + """ + Void stamps for a return. + + Args: + db: Database session + vendor_id: Vendor ID + card_id: Card ID + qr_code: QR code data + card_number: Card number + stamps_to_void: Number of stamps to void (if not using original_transaction_id) + original_transaction_id: ID of original stamp transaction to void + staff_pin: Staff PIN for verification + ip_address: Request IP for audit + user_agent: Request user agent for audit + notes: Reason for voiding + + Returns: + Dict with operation result + """ + # Look up the card + card = card_service.lookup_card_for_vendor( + db, + vendor_id, + card_id=card_id, + qr_code=qr_code, + card_number=card_number, + ) + + program = card.program + + # Verify staff PIN if required + verified_pin = None + if program.require_staff_pin: + if not staff_pin: + raise StaffPinRequiredException() + verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id) + + # Determine stamps to void + original_transaction = None + if original_transaction_id: + original_transaction = ( + db.query(LoyaltyTransaction) + .filter( + LoyaltyTransaction.id == original_transaction_id, + LoyaltyTransaction.card_id == card.id, + LoyaltyTransaction.transaction_type == TransactionType.STAMP_EARNED.value, + ) + .first() + ) + if original_transaction: + stamps_to_void = original_transaction.stamps_delta + + if not stamps_to_void or stamps_to_void <= 0: + return { + "success": False, + "message": "No stamps to void", + "card_id": card.id, + "card_number": card.card_number, + "stamp_count": card.stamp_count, + } + + # Void the stamps (can reduce balance below what was earned) + now = datetime.now(UTC) + actual_voided = min(stamps_to_void, card.stamp_count) + card.stamp_count = max(0, card.stamp_count - stamps_to_void) + card.last_activity_at = now + + # Create void transaction + transaction = LoyaltyTransaction( + company_id=card.company_id, + card_id=card.id, + vendor_id=vendor_id, + staff_pin_id=verified_pin.id if verified_pin else None, + transaction_type=TransactionType.STAMP_VOIDED.value, + stamps_delta=-actual_voided, + stamps_balance_after=card.stamp_count, + points_balance_after=card.points_balance, + related_transaction_id=original_transaction.id if original_transaction else None, + ip_address=ip_address, + user_agent=user_agent, + notes=notes or "Stamps voided for return", + transaction_at=now, + ) + db.add(transaction) + + db.commit() + db.refresh(card) + + logger.info( + f"Voided {actual_voided} stamps from card {card.id} at vendor {vendor_id} " + f"(balance: {card.stamp_count})" + ) + + return { + "success": True, + "message": "Stamps voided successfully", + "stamps_voided": actual_voided, + "card_id": card.id, + "card_number": card.card_number, + "stamp_count": card.stamp_count, + "vendor_id": vendor_id, } diff --git a/app/modules/loyalty/static/admin/js/.gitkeep b/app/modules/loyalty/static/admin/js/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/app/modules/loyalty/static/admin/js/loyalty-analytics.js b/app/modules/loyalty/static/admin/js/loyalty-analytics.js new file mode 100644 index 00000000..3a49191f --- /dev/null +++ b/app/modules/loyalty/static/admin/js/loyalty-analytics.js @@ -0,0 +1,115 @@ +// app/modules/loyalty/static/admin/js/loyalty-analytics.js +// noqa: js-006 - async init pattern is safe, loadData has try/catch + +// Use centralized logger +const loyaltyAnalyticsLog = window.LogConfig.loggers.loyaltyAnalytics || window.LogConfig.createLogger('loyaltyAnalytics'); + +// ============================================ +// LOYALTY ANALYTICS FUNCTION +// ============================================ +function adminLoyaltyAnalytics() { + return { + // Inherit base layout functionality + ...data(), + + // Page identifier for sidebar active state + currentPage: 'loyalty-analytics', + + // Stats + stats: { + total_programs: 0, + active_programs: 0, + total_cards: 0, + active_cards: 0, + transactions_30d: 0, + points_issued_30d: 0, + points_redeemed_30d: 0, + companies_with_programs: 0 + }, + + // State + loading: false, + error: null, + + // Computed: Redemption rate percentage + get redemptionRate() { + if (this.stats.points_issued_30d === 0) return 0; + return Math.round((this.stats.points_redeemed_30d / this.stats.points_issued_30d) * 100); + }, + + // Computed: Issued percentage for progress bar + get issuedPercentage() { + const total = this.stats.points_issued_30d + this.stats.points_redeemed_30d; + if (total === 0) return 50; + return Math.round((this.stats.points_issued_30d / total) * 100); + }, + + // Computed: Redeemed percentage for progress bar + get redeemedPercentage() { + return 100 - this.issuedPercentage; + }, + + // Initialize + async init() { + loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZING ==='); + + // Prevent multiple initializations + if (window._loyaltyAnalyticsInitialized) { + loyaltyAnalyticsLog.warn('Loyalty analytics page already initialized, skipping...'); + return; + } + window._loyaltyAnalyticsInitialized = true; + + loyaltyAnalyticsLog.group('Loading analytics data'); + await this.loadStats(); + loyaltyAnalyticsLog.groupEnd(); + + loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZATION COMPLETE ==='); + }, + + // Load platform stats + async loadStats() { + this.loading = true; + this.error = null; + + try { + loyaltyAnalyticsLog.info('Fetching loyalty analytics...'); + + const response = await apiClient.get('/admin/loyalty/stats'); + + if (response) { + this.stats = { + total_programs: response.total_programs || 0, + active_programs: response.active_programs || 0, + total_cards: response.total_cards || 0, + active_cards: response.active_cards || 0, + transactions_30d: response.transactions_30d || 0, + points_issued_30d: response.points_issued_30d || 0, + points_redeemed_30d: response.points_redeemed_30d || 0, + companies_with_programs: response.companies_with_programs || 0 + }; + + loyaltyAnalyticsLog.info('Analytics loaded:', this.stats); + } + } catch (error) { + loyaltyAnalyticsLog.error('Failed to load analytics:', error); + this.error = error.message || 'Failed to load analytics'; + } finally { + this.loading = false; + } + }, + + // Format number with thousands separator + formatNumber(num) { + if (num === null || num === undefined) return '0'; + return new Intl.NumberFormat('en-US').format(num); + } + }; +} + +// Register logger for configuration +if (!window.LogConfig.loggers.loyaltyAnalytics) { + window.LogConfig.loggers.loyaltyAnalytics = window.LogConfig.createLogger('loyaltyAnalytics'); +} + +loyaltyAnalyticsLog.info('Loyalty analytics module loaded'); diff --git a/app/modules/loyalty/static/admin/js/loyalty-company-detail.js b/app/modules/loyalty/static/admin/js/loyalty-company-detail.js new file mode 100644 index 00000000..88d80a7b --- /dev/null +++ b/app/modules/loyalty/static/admin/js/loyalty-company-detail.js @@ -0,0 +1,208 @@ +// app/modules/loyalty/static/admin/js/loyalty-company-detail.js +// noqa: js-006 - async init pattern is safe, loadData has try/catch + +// Use centralized logger +const loyaltyCompanyDetailLog = window.LogConfig.loggers.loyaltyCompanyDetail || window.LogConfig.createLogger('loyaltyCompanyDetail'); + +// ============================================ +// LOYALTY COMPANY DETAIL FUNCTION +// ============================================ +function adminLoyaltyCompanyDetail() { + return { + // Inherit base layout functionality + ...data(), + + // Page identifier for sidebar active state + currentPage: 'loyalty-programs', + + // Company ID from URL + companyId: null, + + // Company data + company: null, + program: null, + stats: { + total_cards: 0, + active_cards: 0, + total_points_issued: 0, + total_points_redeemed: 0, + points_issued_30d: 0, + points_redeemed_30d: 0, + transactions_30d: 0 + }, + settings: null, + locations: [], + + // State + loading: false, + error: null, + + // Initialize + async init() { + loyaltyCompanyDetailLog.info('=== LOYALTY COMPANY DETAIL PAGE INITIALIZING ==='); + + // Prevent multiple initializations + if (window._loyaltyCompanyDetailInitialized) { + loyaltyCompanyDetailLog.warn('Loyalty company detail page already initialized, skipping...'); + return; + } + window._loyaltyCompanyDetailInitialized = true; + + // Extract company ID from URL + const pathParts = window.location.pathname.split('/'); + const companiesIndex = pathParts.indexOf('companies'); + if (companiesIndex !== -1 && pathParts[companiesIndex + 1]) { + this.companyId = parseInt(pathParts[companiesIndex + 1]); + } + + if (!this.companyId) { + this.error = 'Invalid company ID'; + loyaltyCompanyDetailLog.error('Could not extract company ID from URL'); + return; + } + + loyaltyCompanyDetailLog.info('Company ID:', this.companyId); + + loyaltyCompanyDetailLog.group('Loading company loyalty data'); + await this.loadCompanyData(); + loyaltyCompanyDetailLog.groupEnd(); + + loyaltyCompanyDetailLog.info('=== LOYALTY COMPANY DETAIL PAGE INITIALIZATION COMPLETE ==='); + }, + + // Load all company data + async loadCompanyData() { + this.loading = true; + this.error = null; + + try { + // Load company info + await this.loadCompany(); + + // Load loyalty-specific data in parallel + await Promise.all([ + this.loadStats(), + this.loadSettings(), + this.loadLocations() + ]); + } catch (error) { + loyaltyCompanyDetailLog.error('Failed to load company data:', error); + this.error = error.message || 'Failed to load company loyalty data'; + } finally { + this.loading = false; + } + }, + + // Load company basic info + async loadCompany() { + try { + loyaltyCompanyDetailLog.info('Fetching company info...'); + + // Get company from tenancy API + const response = await apiClient.get(`/admin/companies/${this.companyId}`); + + if (response) { + this.company = response; + loyaltyCompanyDetailLog.info('Company loaded:', this.company.name); + } + } catch (error) { + loyaltyCompanyDetailLog.error('Failed to load company:', error); + throw error; + } + }, + + // Load company loyalty stats + async loadStats() { + try { + loyaltyCompanyDetailLog.info('Fetching company loyalty stats...'); + + const response = await apiClient.get(`/admin/loyalty/companies/${this.companyId}/stats`); + + if (response) { + this.stats = { + total_cards: response.total_cards || 0, + active_cards: response.active_cards || 0, + total_points_issued: response.total_points_issued || 0, + total_points_redeemed: response.total_points_redeemed || 0, + points_issued_30d: response.points_issued_30d || 0, + points_redeemed_30d: response.points_redeemed_30d || 0, + transactions_30d: response.transactions_30d || 0 + }; + + // Also get program info from stats response + if (response.program) { + this.program = response.program; + } + + // Get location breakdown + if (response.locations) { + this.locations = response.locations; + } + + loyaltyCompanyDetailLog.info('Stats loaded:', this.stats); + } + } catch (error) { + loyaltyCompanyDetailLog.warn('Failed to load stats (company may not have loyalty program):', error.message); + // Don't throw - stats might fail if no program exists + } + }, + + // Load company loyalty settings + async loadSettings() { + try { + loyaltyCompanyDetailLog.info('Fetching company loyalty settings...'); + + const response = await apiClient.get(`/admin/loyalty/companies/${this.companyId}/settings`); + + if (response) { + this.settings = response; + loyaltyCompanyDetailLog.info('Settings loaded:', this.settings); + } + } catch (error) { + loyaltyCompanyDetailLog.warn('Failed to load settings:', error.message); + // Don't throw - settings might not exist yet + } + }, + + // Load location breakdown + async loadLocations() { + try { + loyaltyCompanyDetailLog.info('Fetching location breakdown...'); + + // This data comes with stats, but could be a separate endpoint + // For now, stats endpoint should return locations array + } catch (error) { + loyaltyCompanyDetailLog.warn('Failed to load locations:', error.message); + } + }, + + // Format date for display + formatDate(dateString) { + if (!dateString) return 'N/A'; + try { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } catch (e) { + loyaltyCompanyDetailLog.error('Date parsing error:', e); + return dateString; + } + }, + + // Format number with thousands separator + formatNumber(num) { + if (num === null || num === undefined) return '0'; + return new Intl.NumberFormat('en-US').format(num); + } + }; +} + +// Register logger for configuration +if (!window.LogConfig.loggers.loyaltyCompanyDetail) { + window.LogConfig.loggers.loyaltyCompanyDetail = window.LogConfig.createLogger('loyaltyCompanyDetail'); +} + +loyaltyCompanyDetailLog.info('Loyalty company detail module loaded'); diff --git a/app/modules/loyalty/static/admin/js/loyalty-company-settings.js b/app/modules/loyalty/static/admin/js/loyalty-company-settings.js new file mode 100644 index 00000000..fe3fb07e --- /dev/null +++ b/app/modules/loyalty/static/admin/js/loyalty-company-settings.js @@ -0,0 +1,173 @@ +// app/modules/loyalty/static/admin/js/loyalty-company-settings.js +// noqa: js-006 - async init pattern is safe, loadData has try/catch + +// Use centralized logger +const loyaltyCompanySettingsLog = window.LogConfig.loggers.loyaltyCompanySettings || window.LogConfig.createLogger('loyaltyCompanySettings'); + +// ============================================ +// LOYALTY COMPANY SETTINGS FUNCTION +// ============================================ +function adminLoyaltyCompanySettings() { + return { + // Inherit base layout functionality + ...data(), + + // Page identifier for sidebar active state + currentPage: 'loyalty-programs', + + // Company ID from URL + companyId: null, + + // Company data + company: null, + + // Settings form data + settings: { + staff_pin_policy: 'optional', + staff_pin_lockout_attempts: 5, + staff_pin_lockout_minutes: 30, + allow_self_enrollment: true, + allow_void_transactions: true, + allow_cross_location_redemption: true + }, + + // State + loading: false, + saving: false, + error: null, + + // Back URL + get backUrl() { + return `/admin/loyalty/companies/${this.companyId}`; + }, + + // Initialize + async init() { + loyaltyCompanySettingsLog.info('=== LOYALTY COMPANY SETTINGS PAGE INITIALIZING ==='); + + // Prevent multiple initializations + if (window._loyaltyCompanySettingsInitialized) { + loyaltyCompanySettingsLog.warn('Loyalty company settings page already initialized, skipping...'); + return; + } + window._loyaltyCompanySettingsInitialized = true; + + // Extract company ID from URL + const pathParts = window.location.pathname.split('/'); + const companiesIndex = pathParts.indexOf('companies'); + if (companiesIndex !== -1 && pathParts[companiesIndex + 1]) { + this.companyId = parseInt(pathParts[companiesIndex + 1]); + } + + if (!this.companyId) { + this.error = 'Invalid company ID'; + loyaltyCompanySettingsLog.error('Could not extract company ID from URL'); + return; + } + + loyaltyCompanySettingsLog.info('Company ID:', this.companyId); + + loyaltyCompanySettingsLog.group('Loading company settings data'); + await this.loadData(); + loyaltyCompanySettingsLog.groupEnd(); + + loyaltyCompanySettingsLog.info('=== LOYALTY COMPANY SETTINGS PAGE INITIALIZATION COMPLETE ==='); + }, + + // Load all data + async loadData() { + this.loading = true; + this.error = null; + + try { + // Load company info and settings in parallel + await Promise.all([ + this.loadCompany(), + this.loadSettings() + ]); + } catch (error) { + loyaltyCompanySettingsLog.error('Failed to load data:', error); + this.error = error.message || 'Failed to load settings'; + } finally { + this.loading = false; + } + }, + + // Load company basic info + async loadCompany() { + try { + loyaltyCompanySettingsLog.info('Fetching company info...'); + + const response = await apiClient.get(`/admin/companies/${this.companyId}`); + + if (response) { + this.company = response; + loyaltyCompanySettingsLog.info('Company loaded:', this.company.name); + } + } catch (error) { + loyaltyCompanySettingsLog.error('Failed to load company:', error); + throw error; + } + }, + + // Load settings + async loadSettings() { + try { + loyaltyCompanySettingsLog.info('Fetching company loyalty settings...'); + + const response = await apiClient.get(`/admin/loyalty/companies/${this.companyId}/settings`); + + if (response) { + // Merge with defaults to ensure all fields exist + this.settings = { + staff_pin_policy: response.staff_pin_policy || 'optional', + staff_pin_lockout_attempts: response.staff_pin_lockout_attempts || 5, + staff_pin_lockout_minutes: response.staff_pin_lockout_minutes || 30, + allow_self_enrollment: response.allow_self_enrollment !== false, + allow_void_transactions: response.allow_void_transactions !== false, + allow_cross_location_redemption: response.allow_cross_location_redemption !== false + }; + + loyaltyCompanySettingsLog.info('Settings loaded:', this.settings); + } + } catch (error) { + loyaltyCompanySettingsLog.warn('Failed to load settings, using defaults:', error.message); + // Keep default settings + } + }, + + // Save settings + async saveSettings() { + this.saving = true; + + try { + loyaltyCompanySettingsLog.info('Saving company loyalty settings...'); + + const response = await apiClient.patch( + `/admin/loyalty/companies/${this.companyId}/settings`, + this.settings + ); + + if (response) { + loyaltyCompanySettingsLog.info('Settings saved successfully'); + Utils.showToast('Settings saved successfully', 'success'); + + // Navigate back to company detail + window.location.href = this.backUrl; + } + } catch (error) { + loyaltyCompanySettingsLog.error('Failed to save settings:', error); + Utils.showToast(`Failed to save settings: ${error.message}`, 'error'); + } finally { + this.saving = false; + } + } + }; +} + +// Register logger for configuration +if (!window.LogConfig.loggers.loyaltyCompanySettings) { + window.LogConfig.loggers.loyaltyCompanySettings = window.LogConfig.createLogger('loyaltyCompanySettings'); +} + +loyaltyCompanySettingsLog.info('Loyalty company settings module loaded'); diff --git a/app/modules/loyalty/static/admin/js/loyalty-programs.js b/app/modules/loyalty/static/admin/js/loyalty-programs.js new file mode 100644 index 00000000..00603078 --- /dev/null +++ b/app/modules/loyalty/static/admin/js/loyalty-programs.js @@ -0,0 +1,264 @@ +// app/modules/loyalty/static/admin/js/loyalty-programs.js +// noqa: js-006 - async init pattern is safe, loadData has try/catch + +// Use centralized logger +const loyaltyProgramsLog = window.LogConfig.loggers.loyaltyPrograms || window.LogConfig.createLogger('loyaltyPrograms'); + +// ============================================ +// LOYALTY PROGRAMS LIST FUNCTION +// ============================================ +function adminLoyaltyPrograms() { + return { + // Inherit base layout functionality + ...data(), + + // Page identifier for sidebar active state + currentPage: 'loyalty-programs', + + // Programs page specific state + programs: [], + stats: { + total_programs: 0, + active_programs: 0, + total_cards: 0, + transactions_30d: 0, + points_issued_30d: 0, + points_redeemed_30d: 0, + companies_with_programs: 0 + }, + loading: false, + error: null, + + // Search and filters + filters: { + search: '', + is_active: '' + }, + + // Pagination state + pagination: { + page: 1, + per_page: 20, + total: 0, + pages: 0 + }, + + // Initialize + async init() { + loyaltyProgramsLog.info('=== LOYALTY PROGRAMS PAGE INITIALIZING ==='); + + // Prevent multiple initializations + if (window._loyaltyProgramsInitialized) { + loyaltyProgramsLog.warn('Loyalty programs page already initialized, skipping...'); + return; + } + window._loyaltyProgramsInitialized = true; + + // Load platform settings for rows per page + if (window.PlatformSettings) { + this.pagination.per_page = await window.PlatformSettings.getRowsPerPage(); + } + + loyaltyProgramsLog.group('Loading loyalty programs data'); + await Promise.all([ + this.loadPrograms(), + this.loadStats() + ]); + loyaltyProgramsLog.groupEnd(); + + loyaltyProgramsLog.info('=== LOYALTY PROGRAMS PAGE INITIALIZATION COMPLETE ==='); + }, + + // Debounced search + debouncedSearch() { + if (this._searchTimeout) { + clearTimeout(this._searchTimeout); + } + this._searchTimeout = setTimeout(() => { + loyaltyProgramsLog.info('Search triggered:', this.filters.search); + this.pagination.page = 1; + this.loadPrograms(); + }, 300); + }, + + // Computed: Get programs for current page (already paginated from server) + get paginatedPrograms() { + return this.programs; + }, + + // Computed: Total number of pages + get totalPages() { + return this.pagination.pages; + }, + + // Computed: Start index for pagination display + get startIndex() { + if (this.pagination.total === 0) return 0; + return (this.pagination.page - 1) * this.pagination.per_page + 1; + }, + + // Computed: End index for pagination display + get endIndex() { + const end = this.pagination.page * this.pagination.per_page; + return end > this.pagination.total ? this.pagination.total : end; + }, + + // Computed: Generate page numbers array with ellipsis + get pageNumbers() { + const pages = []; + const totalPages = this.totalPages; + const current = this.pagination.page; + + if (totalPages <= 7) { + // Show all pages if 7 or fewer + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + // Always show first page + pages.push(1); + + if (current > 3) { + pages.push('...'); + } + + // Show pages around current page + const start = Math.max(2, current - 1); + const end = Math.min(totalPages - 1, current + 1); + + for (let i = start; i <= end; i++) { + pages.push(i); + } + + if (current < totalPages - 2) { + pages.push('...'); + } + + // Always show last page + pages.push(totalPages); + } + + return pages; + }, + + // Load programs with search and pagination + async loadPrograms() { + this.loading = true; + this.error = null; + + try { + loyaltyProgramsLog.info('Fetching loyalty programs from API...'); + + const params = new URLSearchParams(); + params.append('skip', (this.pagination.page - 1) * this.pagination.per_page); + params.append('limit', this.pagination.per_page); + + if (this.filters.search) { + params.append('search', this.filters.search); + } + if (this.filters.is_active !== '') { + params.append('is_active', this.filters.is_active); + } + + const response = await apiClient.get(`/admin/loyalty/programs?${params}`); + + if (response.programs) { + this.programs = response.programs; + this.pagination.total = response.total; + this.pagination.pages = Math.ceil(response.total / this.pagination.per_page); + + loyaltyProgramsLog.info(`Loaded ${this.programs.length} programs (total: ${response.total})`); + } else { + loyaltyProgramsLog.warn('No programs in response'); + this.programs = []; + } + } catch (error) { + loyaltyProgramsLog.error('Failed to load programs:', error); + this.error = error.message || 'Failed to load loyalty programs'; + this.programs = []; + } finally { + this.loading = false; + } + }, + + // Load platform stats + async loadStats() { + try { + loyaltyProgramsLog.info('Fetching loyalty stats from API...'); + + const response = await apiClient.get('/admin/loyalty/stats'); + + if (response) { + this.stats = { + total_programs: response.total_programs || 0, + active_programs: response.active_programs || 0, + total_cards: response.total_cards || 0, + transactions_30d: response.transactions_30d || 0, + points_issued_30d: response.points_issued_30d || 0, + points_redeemed_30d: response.points_redeemed_30d || 0, + companies_with_programs: response.companies_with_programs || 0 + }; + + loyaltyProgramsLog.info('Stats loaded:', this.stats); + } + } catch (error) { + loyaltyProgramsLog.error('Failed to load stats:', error); + // Don't set error state for stats failure + } + }, + + // Pagination methods + previousPage() { + if (this.pagination.page > 1) { + this.pagination.page--; + loyaltyProgramsLog.info('Previous page:', this.pagination.page); + this.loadPrograms(); + } + }, + + nextPage() { + if (this.pagination.page < this.totalPages) { + this.pagination.page++; + loyaltyProgramsLog.info('Next page:', this.pagination.page); + this.loadPrograms(); + } + }, + + goToPage(pageNum) { + if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) { + this.pagination.page = pageNum; + loyaltyProgramsLog.info('Go to page:', this.pagination.page); + this.loadPrograms(); + } + }, + + // Format date for display + formatDate(dateString) { + if (!dateString) return 'N/A'; + try { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } catch (e) { + loyaltyProgramsLog.error('Date parsing error:', e); + return dateString; + } + }, + + // Format number with thousands separator + formatNumber(num) { + if (num === null || num === undefined) return '0'; + return new Intl.NumberFormat('en-US').format(num); + } + }; +} + +// Register logger for configuration +if (!window.LogConfig.loggers.loyaltyPrograms) { + window.LogConfig.loggers.loyaltyPrograms = window.LogConfig.createLogger('loyaltyPrograms'); +} + +loyaltyProgramsLog.info('Loyalty programs module loaded'); diff --git a/app/modules/loyalty/static/storefront/js/loyalty-dashboard.js b/app/modules/loyalty/static/storefront/js/loyalty-dashboard.js new file mode 100644 index 00000000..c6c71534 --- /dev/null +++ b/app/modules/loyalty/static/storefront/js/loyalty-dashboard.js @@ -0,0 +1,87 @@ +// app/modules/loyalty/static/storefront/js/loyalty-dashboard.js +// Customer loyalty dashboard + +function customerLoyaltyDashboard() { + return { + ...data(), + + // Data + card: null, + program: null, + rewards: [], + transactions: [], + locations: [], + + // UI state + loading: false, + showBarcode: false, + + async init() { + console.log('Customer loyalty dashboard initializing...'); + await this.loadData(); + }, + + async loadData() { + this.loading = true; + try { + await Promise.all([ + this.loadCard(), + this.loadTransactions() + ]); + } catch (error) { + console.error('Failed to load loyalty data:', error); + } finally { + this.loading = false; + } + }, + + async loadCard() { + try { + const response = await apiClient.get('/storefront/loyalty/card'); + if (response) { + this.card = response.card; + this.program = response.program; + this.rewards = response.program?.points_rewards || []; + this.locations = response.locations || []; + console.log('Loyalty card loaded:', this.card?.card_number); + } + } catch (error) { + if (error.status === 404) { + console.log('No loyalty card found'); + this.card = null; + } else { + throw error; + } + } + }, + + async loadTransactions() { + try { + const response = await apiClient.get('/storefront/loyalty/transactions?limit=10'); + if (response && response.transactions) { + this.transactions = response.transactions; + } + } catch (error) { + console.warn('Failed to load transactions:', error.message); + } + }, + + formatNumber(num) { + if (num == null) return '0'; + return new Intl.NumberFormat('en-US').format(num); + }, + + formatDate(dateString) { + if (!dateString) return '-'; + try { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } catch (e) { + return dateString; + } + } + }; +} diff --git a/app/modules/loyalty/static/storefront/js/loyalty-enroll.js b/app/modules/loyalty/static/storefront/js/loyalty-enroll.js new file mode 100644 index 00000000..c48e6b33 --- /dev/null +++ b/app/modules/loyalty/static/storefront/js/loyalty-enroll.js @@ -0,0 +1,94 @@ +// app/modules/loyalty/static/storefront/js/loyalty-enroll.js +// Self-service loyalty enrollment + +function customerLoyaltyEnroll() { + return { + ...data(), + + // Program info + program: null, + + // Form data + form: { + email: '', + first_name: '', + last_name: '', + phone: '', + birthday: '', + terms_accepted: false, + marketing_consent: false + }, + + // State + loading: false, + enrolling: false, + enrolled: false, + enrolledCard: null, + error: null, + + async init() { + console.log('Customer loyalty enroll initializing...'); + await this.loadProgram(); + }, + + async loadProgram() { + this.loading = true; + try { + const response = await apiClient.get('/storefront/loyalty/program'); + if (response) { + this.program = response; + console.log('Program loaded:', this.program.display_name); + } + } catch (error) { + if (error.status === 404) { + console.log('No loyalty program available'); + this.program = null; + } else { + console.error('Failed to load program:', error); + this.error = 'Failed to load program information'; + } + } finally { + this.loading = false; + } + }, + + async submitEnrollment() { + if (!this.form.email || !this.form.first_name || !this.form.terms_accepted) { + return; + } + + this.enrolling = true; + this.error = null; + + try { + const response = await apiClient.post('/storefront/loyalty/enroll', { + customer_email: this.form.email, + customer_name: [this.form.first_name, this.form.last_name].filter(Boolean).join(' '), + customer_phone: this.form.phone || null, + customer_birthday: this.form.birthday || null, + marketing_email_consent: this.form.marketing_consent, + marketing_sms_consent: this.form.marketing_consent + }); + + if (response) { + console.log('Enrollment successful:', response.card_number); + // Redirect to success page - extract base path from current URL + // Current page is at /storefront/loyalty/join, redirect to /storefront/loyalty/join/success + const currentPath = window.location.pathname; + const successUrl = currentPath.replace(/\/join\/?$/, '/join/success') + + '?card=' + encodeURIComponent(response.card_number); + window.location.href = successUrl; + } + } catch (error) { + console.error('Enrollment failed:', error); + if (error.message?.includes('already')) { + this.error = 'This email is already registered in our loyalty program.'; + } else { + this.error = error.message || 'Enrollment failed. Please try again.'; + } + } finally { + this.enrolling = false; + } + } + }; +} diff --git a/app/modules/loyalty/static/storefront/js/loyalty-history.js b/app/modules/loyalty/static/storefront/js/loyalty-history.js new file mode 100644 index 00000000..d53a881d --- /dev/null +++ b/app/modules/loyalty/static/storefront/js/loyalty-history.js @@ -0,0 +1,119 @@ +// app/modules/loyalty/static/storefront/js/loyalty-history.js +// Customer loyalty transaction history + +function customerLoyaltyHistory() { + return { + ...data(), + + // Data + card: null, + transactions: [], + + // Pagination + pagination: { + page: 1, + per_page: 20, + total: 0, + pages: 0 + }, + + // State + loading: false, + + async init() { + console.log('Customer loyalty history initializing...'); + await this.loadData(); + }, + + async loadData() { + this.loading = true; + try { + await Promise.all([ + this.loadCard(), + this.loadTransactions() + ]); + } catch (error) { + console.error('Failed to load history:', error); + } finally { + this.loading = false; + } + }, + + async loadCard() { + try { + const response = await apiClient.get('/storefront/loyalty/card'); + if (response) { + this.card = response.card; + } + } catch (error) { + console.warn('Failed to load card:', error.message); + } + }, + + async loadTransactions() { + try { + const params = new URLSearchParams(); + params.append('skip', (this.pagination.page - 1) * this.pagination.per_page); + params.append('limit', this.pagination.per_page); + + const response = await apiClient.get(`/storefront/loyalty/transactions?${params}`); + if (response) { + this.transactions = response.transactions || []; + this.pagination.total = response.total || 0; + this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page); + console.log(`Loaded ${this.transactions.length} transactions`); + } + } catch (error) { + console.error('Failed to load transactions:', error); + } + }, + + previousPage() { + if (this.pagination.page > 1) { + this.pagination.page--; + this.loadTransactions(); + } + }, + + nextPage() { + if (this.pagination.page < this.pagination.pages) { + this.pagination.page++; + this.loadTransactions(); + } + }, + + getTransactionLabel(tx) { + const type = tx.transaction_type || ''; + const labels = { + 'points_earned': 'Points Earned', + 'points_redeemed': 'Reward Redeemed', + 'points_voided': 'Points Voided', + 'welcome_bonus': 'Welcome Bonus', + 'points_expired': 'Points Expired', + 'stamp_earned': 'Stamp Earned', + 'stamp_redeemed': 'Stamp Redeemed' + }; + return labels[type] || type.replace(/_/g, ' '); + }, + + formatNumber(num) { + if (num == null) return '0'; + return new Intl.NumberFormat('en-US').format(num); + }, + + formatDateTime(dateString) { + if (!dateString) return '-'; + try { + return new Date(dateString).toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } catch (e) { + return dateString; + } + } + }; +} diff --git a/app/modules/loyalty/static/vendor/js/.gitkeep b/app/modules/loyalty/static/vendor/js/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/app/modules/loyalty/static/vendor/js/loyalty-card-detail.js b/app/modules/loyalty/static/vendor/js/loyalty-card-detail.js new file mode 100644 index 00000000..a16f917e --- /dev/null +++ b/app/modules/loyalty/static/vendor/js/loyalty-card-detail.js @@ -0,0 +1,104 @@ +// app/modules/loyalty/static/vendor/js/loyalty-card-detail.js +// noqa: js-006 - async init pattern is safe, loadData has try/catch + +const loyaltyCardDetailLog = window.LogConfig.loggers.loyaltyCardDetail || window.LogConfig.createLogger('loyaltyCardDetail'); + +function vendorLoyaltyCardDetail() { + return { + ...data(), + currentPage: 'loyalty-card-detail', + + cardId: null, + card: null, + transactions: [], + + loading: false, + error: null, + + async init() { + loyaltyCardDetailLog.info('=== LOYALTY CARD DETAIL PAGE INITIALIZING ==='); + if (window._loyaltyCardDetailInitialized) return; + window._loyaltyCardDetailInitialized = true; + + // Extract card ID from URL + const pathParts = window.location.pathname.split('/'); + const cardsIndex = pathParts.indexOf('cards'); + if (cardsIndex !== -1 && pathParts[cardsIndex + 1]) { + this.cardId = parseInt(pathParts[cardsIndex + 1]); + } + + if (!this.cardId) { + this.error = 'Invalid card ID'; + return; + } + + await this.loadData(); + loyaltyCardDetailLog.info('=== LOYALTY CARD DETAIL PAGE INITIALIZATION COMPLETE ==='); + }, + + async loadData() { + this.loading = true; + this.error = null; + + try { + await Promise.all([ + this.loadCard(), + this.loadTransactions() + ]); + } catch (error) { + loyaltyCardDetailLog.error('Failed to load data:', error); + this.error = error.message; + } finally { + this.loading = false; + } + }, + + async loadCard() { + const response = await apiClient.get(`/vendor/loyalty/cards/${this.cardId}`); + if (response) { + this.card = response; + loyaltyCardDetailLog.info('Card loaded:', this.card.card_number); + } + }, + + async loadTransactions() { + try { + const response = await apiClient.get(`/vendor/loyalty/cards/${this.cardId}/transactions?limit=50`); + if (response && response.transactions) { + this.transactions = response.transactions; + loyaltyCardDetailLog.info(`Loaded ${this.transactions.length} transactions`); + } + } catch (error) { + loyaltyCardDetailLog.warn('Failed to load transactions:', error.message); + } + }, + + formatNumber(num) { + return num == null ? '0' : new Intl.NumberFormat('en-US').format(num); + }, + + formatDate(dateString) { + if (!dateString) return '-'; + try { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', month: 'short', day: 'numeric' + }); + } catch (e) { return dateString; } + }, + + formatDateTime(dateString) { + if (!dateString) return '-'; + try { + return new Date(dateString).toLocaleString('en-US', { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit' + }); + } catch (e) { return dateString; } + } + }; +} + +if (!window.LogConfig.loggers.loyaltyCardDetail) { + window.LogConfig.loggers.loyaltyCardDetail = window.LogConfig.createLogger('loyaltyCardDetail'); +} +loyaltyCardDetailLog.info('Loyalty card detail module loaded'); diff --git a/app/modules/loyalty/static/vendor/js/loyalty-cards.js b/app/modules/loyalty/static/vendor/js/loyalty-cards.js new file mode 100644 index 00000000..90ce4b62 --- /dev/null +++ b/app/modules/loyalty/static/vendor/js/loyalty-cards.js @@ -0,0 +1,160 @@ +// app/modules/loyalty/static/vendor/js/loyalty-cards.js +// noqa: js-006 - async init pattern is safe, loadData has try/catch + +const loyaltyCardsLog = window.LogConfig.loggers.loyaltyCards || window.LogConfig.createLogger('loyaltyCards'); + +function vendorLoyaltyCards() { + return { + ...data(), + currentPage: 'loyalty-cards', + + // Data + cards: [], + program: null, + stats: { + total_cards: 0, + active_cards: 0, + new_this_month: 0, + total_points_balance: 0 + }, + + // Filters + filters: { + search: '', + status: '' + }, + + // Pagination + pagination: { + page: 1, + per_page: 20, + total: 0, + pages: 0 + }, + + // State + loading: false, + error: null, + + async init() { + loyaltyCardsLog.info('=== LOYALTY CARDS PAGE INITIALIZING ==='); + if (window._loyaltyCardsInitialized) return; + window._loyaltyCardsInitialized = true; + + if (window.PlatformSettings) { + this.pagination.per_page = await window.PlatformSettings.getRowsPerPage(); + } + + await this.loadData(); + loyaltyCardsLog.info('=== LOYALTY CARDS PAGE INITIALIZATION COMPLETE ==='); + }, + + async loadData() { + this.loading = true; + this.error = null; + try { + await Promise.all([ + this.loadProgram(), + this.loadCards(), + this.loadStats() + ]); + } catch (error) { + loyaltyCardsLog.error('Failed to load data:', error); + this.error = error.message; + } finally { + this.loading = false; + } + }, + + async loadProgram() { + try { + const response = await apiClient.get('/vendor/loyalty/program'); + if (response) this.program = response; + } catch (error) { + if (error.status !== 404) throw error; + } + }, + + async loadCards() { + const params = new URLSearchParams(); + params.append('skip', (this.pagination.page - 1) * this.pagination.per_page); + params.append('limit', this.pagination.per_page); + if (this.filters.search) params.append('search', this.filters.search); + if (this.filters.status) params.append('is_active', this.filters.status === 'active'); + + const response = await apiClient.get(`/vendor/loyalty/cards?${params}`); + if (response) { + this.cards = response.cards || []; + this.pagination.total = response.total || 0; + this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page); + } + }, + + async loadStats() { + try { + const response = await apiClient.get('/vendor/loyalty/stats'); + if (response) { + this.stats = { + total_cards: response.total_cards || 0, + active_cards: response.active_cards || 0, + new_this_month: response.new_this_month || 0, + total_points_balance: response.total_points_balance || 0 + }; + } + } catch (error) { + loyaltyCardsLog.warn('Failed to load stats:', error.message); + } + }, + + debouncedSearch() { + if (this._searchTimeout) clearTimeout(this._searchTimeout); + this._searchTimeout = setTimeout(() => { + this.pagination.page = 1; + this.loadCards(); + }, 300); + }, + + applyFilter() { + this.pagination.page = 1; + this.loadCards(); + }, + + get totalPages() { return this.pagination.pages; }, + get startIndex() { return this.pagination.total === 0 ? 0 : (this.pagination.page - 1) * this.pagination.per_page + 1; }, + get endIndex() { const end = this.pagination.page * this.pagination.per_page; return end > this.pagination.total ? this.pagination.total : end; }, + + get pageNumbers() { + const pages = []; + const total = this.totalPages; + const current = this.pagination.page; + if (total <= 7) { for (let i = 1; i <= total; i++) pages.push(i); } + else { + pages.push(1); + if (current > 3) pages.push('...'); + const start = Math.max(2, current - 1); + const end = Math.min(total - 1, current + 1); + for (let i = start; i <= end; i++) pages.push(i); + if (current < total - 2) pages.push('...'); + pages.push(total); + } + return pages; + }, + + previousPage() { if (this.pagination.page > 1) { this.pagination.page--; this.loadCards(); } }, + nextPage() { if (this.pagination.page < this.totalPages) { this.pagination.page++; this.loadCards(); } }, + goToPage(num) { if (num !== '...' && num >= 1 && num <= this.totalPages) { this.pagination.page = num; this.loadCards(); } }, + + formatNumber(num) { return num == null ? '0' : new Intl.NumberFormat('en-US').format(num); }, + formatDate(dateString) { + if (!dateString) return 'Never'; + try { + return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); + } catch (e) { return dateString; } + } + }; +} + +if (!window.LogConfig.loggers.loyaltyCards) { + window.LogConfig.loggers.loyaltyCards = window.LogConfig.createLogger('loyaltyCards'); +} +loyaltyCardsLog.info('Loyalty cards module loaded'); diff --git a/app/modules/loyalty/static/vendor/js/loyalty-enroll.js b/app/modules/loyalty/static/vendor/js/loyalty-enroll.js new file mode 100644 index 00000000..a2c3e766 --- /dev/null +++ b/app/modules/loyalty/static/vendor/js/loyalty-enroll.js @@ -0,0 +1,101 @@ +// app/modules/loyalty/static/vendor/js/loyalty-enroll.js +// noqa: js-006 - async init pattern is safe, loadData has try/catch + +const loyaltyEnrollLog = window.LogConfig.loggers.loyaltyEnroll || window.LogConfig.createLogger('loyaltyEnroll'); + +function vendorLoyaltyEnroll() { + return { + ...data(), + currentPage: 'loyalty-enroll', + + program: null, + form: { + first_name: '', + last_name: '', + email: '', + phone: '', + birthday: '', + marketing_email: false, + marketing_sms: false + }, + + enrolling: false, + enrolledCard: null, + loading: false, + error: null, + + async init() { + loyaltyEnrollLog.info('=== LOYALTY ENROLL PAGE INITIALIZING ==='); + if (window._loyaltyEnrollInitialized) return; + window._loyaltyEnrollInitialized = true; + + await this.loadProgram(); + loyaltyEnrollLog.info('=== LOYALTY ENROLL PAGE INITIALIZATION COMPLETE ==='); + }, + + async loadProgram() { + this.loading = true; + try { + const response = await apiClient.get('/vendor/loyalty/program'); + if (response) { + this.program = response; + loyaltyEnrollLog.info('Program loaded:', this.program.display_name); + } + } catch (error) { + if (error.status === 404) { + loyaltyEnrollLog.warn('No program configured'); + } else { + this.error = error.message; + } + } finally { + this.loading = false; + } + }, + + async enrollCustomer() { + if (!this.form.first_name || !this.form.email) return; + + this.enrolling = true; + + try { + loyaltyEnrollLog.info('Enrolling customer:', this.form.email); + + const response = await apiClient.post('/vendor/loyalty/cards/enroll', { + customer_email: this.form.email, + customer_phone: this.form.phone || null, + customer_name: [this.form.first_name, this.form.last_name].filter(Boolean).join(' '), + customer_birthday: this.form.birthday || null, + marketing_email_consent: this.form.marketing_email, + marketing_sms_consent: this.form.marketing_sms + }); + + if (response) { + this.enrolledCard = response; + loyaltyEnrollLog.info('Customer enrolled successfully:', response.card_number); + } + } catch (error) { + Utils.showToast(`Enrollment failed: ${error.message}`, 'error'); + loyaltyEnrollLog.error('Enrollment failed:', error); + } finally { + this.enrolling = false; + } + }, + + resetForm() { + this.form = { + first_name: '', + last_name: '', + email: '', + phone: '', + birthday: '', + marketing_email: false, + marketing_sms: false + }; + } + }; +} + +if (!window.LogConfig.loggers.loyaltyEnroll) { + window.LogConfig.loggers.loyaltyEnroll = window.LogConfig.createLogger('loyaltyEnroll'); +} +loyaltyEnrollLog.info('Loyalty enroll module loaded'); diff --git a/app/modules/loyalty/static/vendor/js/loyalty-settings.js b/app/modules/loyalty/static/vendor/js/loyalty-settings.js new file mode 100644 index 00000000..6b7dfe38 --- /dev/null +++ b/app/modules/loyalty/static/vendor/js/loyalty-settings.js @@ -0,0 +1,118 @@ +// app/modules/loyalty/static/vendor/js/loyalty-settings.js +// noqa: js-006 - async init pattern is safe, loadData has try/catch + +const loyaltySettingsLog = window.LogConfig.loggers.loyaltySettings || window.LogConfig.createLogger('loyaltySettings'); + +function vendorLoyaltySettings() { + return { + ...data(), + currentPage: 'loyalty-settings', + + settings: { + loyalty_type: 'points', + points_per_euro: 1, + welcome_bonus_points: 0, + minimum_redemption_points: 100, + points_expiration_days: null, + points_rewards: [], + card_name: '', + card_color: '#4F46E5', + is_active: true + }, + + loading: false, + saving: false, + error: null, + isNewProgram: false, + + async init() { + loyaltySettingsLog.info('=== LOYALTY SETTINGS PAGE INITIALIZING ==='); + if (window._loyaltySettingsInitialized) return; + window._loyaltySettingsInitialized = true; + + await this.loadSettings(); + loyaltySettingsLog.info('=== LOYALTY SETTINGS PAGE INITIALIZATION COMPLETE ==='); + }, + + async loadSettings() { + this.loading = true; + this.error = null; + + try { + const response = await apiClient.get('/vendor/loyalty/program'); + if (response) { + this.settings = { + loyalty_type: response.loyalty_type || 'points', + points_per_euro: response.points_per_euro || 1, + welcome_bonus_points: response.welcome_bonus_points || 0, + minimum_redemption_points: response.minimum_redemption_points || 100, + points_expiration_days: response.points_expiration_days || null, + points_rewards: response.points_rewards || [], + card_name: response.card_name || '', + card_color: response.card_color || '#4F46E5', + is_active: response.is_active !== false + }; + this.isNewProgram = false; + loyaltySettingsLog.info('Settings loaded'); + } + } catch (error) { + if (error.status === 404) { + loyaltySettingsLog.info('No program found, creating new'); + this.isNewProgram = true; + } else { + throw error; + } + } finally { + this.loading = false; + } + }, + + async saveSettings() { + this.saving = true; + + try { + // Ensure rewards have IDs + this.settings.points_rewards = this.settings.points_rewards.map((r, i) => ({ + ...r, + id: r.id || `reward_${i + 1}`, + is_active: r.is_active !== false + })); + + let response; + if (this.isNewProgram) { + response = await apiClient.post('/vendor/loyalty/program', this.settings); + this.isNewProgram = false; + } else { + response = await apiClient.patch('/vendor/loyalty/program', this.settings); + } + + Utils.showToast('Settings saved successfully', 'success'); + loyaltySettingsLog.info('Settings saved'); + } catch (error) { + Utils.showToast(`Failed to save: ${error.message}`, 'error'); + loyaltySettingsLog.error('Save failed:', error); + } finally { + this.saving = false; + } + }, + + addReward() { + this.settings.points_rewards.push({ + id: `reward_${Date.now()}`, + name: '', + points_required: 100, + description: '', + is_active: true + }); + }, + + removeReward(index) { + this.settings.points_rewards.splice(index, 1); + } + }; +} + +if (!window.LogConfig.loggers.loyaltySettings) { + window.LogConfig.loggers.loyaltySettings = window.LogConfig.createLogger('loyaltySettings'); +} +loyaltySettingsLog.info('Loyalty settings module loaded'); diff --git a/app/modules/loyalty/static/vendor/js/loyalty-stats.js b/app/modules/loyalty/static/vendor/js/loyalty-stats.js new file mode 100644 index 00000000..2eebd737 --- /dev/null +++ b/app/modules/loyalty/static/vendor/js/loyalty-stats.js @@ -0,0 +1,74 @@ +// app/modules/loyalty/static/vendor/js/loyalty-stats.js +// noqa: js-006 - async init pattern is safe, loadData has try/catch + +const loyaltyStatsLog = window.LogConfig.loggers.loyaltyStats || window.LogConfig.createLogger('loyaltyStats'); + +function vendorLoyaltyStats() { + return { + ...data(), + currentPage: 'loyalty-stats', + + stats: { + total_cards: 0, + active_cards: 0, + new_this_month: 0, + total_points_issued: 0, + total_points_redeemed: 0, + total_points_balance: 0, + points_issued_30d: 0, + points_redeemed_30d: 0, + transactions_30d: 0, + avg_points_per_member: 0 + }, + + loading: false, + error: null, + + async init() { + loyaltyStatsLog.info('=== LOYALTY STATS PAGE INITIALIZING ==='); + if (window._loyaltyStatsInitialized) return; + window._loyaltyStatsInitialized = true; + + await this.loadStats(); + loyaltyStatsLog.info('=== LOYALTY STATS PAGE INITIALIZATION COMPLETE ==='); + }, + + async loadStats() { + this.loading = true; + this.error = null; + + try { + const response = await apiClient.get('/vendor/loyalty/stats'); + if (response) { + this.stats = { + total_cards: response.total_cards || 0, + active_cards: response.active_cards || 0, + new_this_month: response.new_this_month || 0, + total_points_issued: response.total_points_issued || 0, + total_points_redeemed: response.total_points_redeemed || 0, + total_points_balance: response.total_points_balance || 0, + points_issued_30d: response.points_issued_30d || 0, + points_redeemed_30d: response.points_redeemed_30d || 0, + transactions_30d: response.transactions_30d || 0, + avg_points_per_member: response.avg_points_per_member || 0 + }; + loyaltyStatsLog.info('Stats loaded'); + } + } catch (error) { + loyaltyStatsLog.error('Failed to load stats:', error); + this.error = error.message; + } finally { + this.loading = false; + } + }, + + formatNumber(num) { + return num == null ? '0' : new Intl.NumberFormat('en-US').format(num); + } + }; +} + +if (!window.LogConfig.loggers.loyaltyStats) { + window.LogConfig.loggers.loyaltyStats = window.LogConfig.createLogger('loyaltyStats'); +} +loyaltyStatsLog.info('Loyalty stats module loaded'); diff --git a/app/modules/loyalty/static/vendor/js/loyalty-terminal.js b/app/modules/loyalty/static/vendor/js/loyalty-terminal.js new file mode 100644 index 00000000..b7a2fd22 --- /dev/null +++ b/app/modules/loyalty/static/vendor/js/loyalty-terminal.js @@ -0,0 +1,286 @@ +// app/modules/loyalty/static/vendor/js/loyalty-terminal.js +// noqa: js-006 - async init pattern is safe, loadData has try/catch + +// Use centralized logger +const loyaltyTerminalLog = window.LogConfig.loggers.loyaltyTerminal || window.LogConfig.createLogger('loyaltyTerminal'); + +// ============================================ +// VENDOR LOYALTY TERMINAL FUNCTION +// ============================================ +function vendorLoyaltyTerminal() { + return { + // Inherit base layout functionality + ...data(), + + // Page identifier + currentPage: 'loyalty-terminal', + + // Program state + program: null, + availableRewards: [], + + // Customer lookup + searchQuery: '', + lookingUp: false, + selectedCard: null, + + // Transaction inputs + earnAmount: null, + selectedReward: '', + + // PIN entry + showPinEntry: false, + pinDigits: '', + pendingAction: null, // 'earn' or 'redeem' + processing: false, + + // Recent transactions + recentTransactions: [], + + // State + loading: false, + error: null, + + // Initialize + async init() { + loyaltyTerminalLog.info('=== LOYALTY TERMINAL INITIALIZING ==='); + + // Prevent multiple initializations + if (window._loyaltyTerminalInitialized) { + loyaltyTerminalLog.warn('Loyalty terminal already initialized, skipping...'); + return; + } + window._loyaltyTerminalInitialized = true; + + await this.loadData(); + + loyaltyTerminalLog.info('=== LOYALTY TERMINAL INITIALIZATION COMPLETE ==='); + }, + + // Load initial data + async loadData() { + this.loading = true; + this.error = null; + + try { + await Promise.all([ + this.loadProgram(), + this.loadRecentTransactions() + ]); + } catch (error) { + loyaltyTerminalLog.error('Failed to load data:', error); + this.error = error.message || 'Failed to load terminal'; + } finally { + this.loading = false; + } + }, + + // Load program info + async loadProgram() { + try { + loyaltyTerminalLog.info('Loading program info...'); + const response = await apiClient.get('/vendor/loyalty/program'); + + if (response) { + this.program = response; + this.availableRewards = response.points_rewards || []; + loyaltyTerminalLog.info('Program loaded:', this.program.display_name); + } + } catch (error) { + if (error.status === 404) { + loyaltyTerminalLog.info('No program configured'); + this.program = null; + } else { + throw error; + } + } + }, + + // Load recent transactions + async loadRecentTransactions() { + try { + loyaltyTerminalLog.info('Loading recent transactions...'); + const response = await apiClient.get('/vendor/loyalty/transactions?limit=10'); + + if (response && response.transactions) { + this.recentTransactions = response.transactions; + loyaltyTerminalLog.info(`Loaded ${this.recentTransactions.length} transactions`); + } + } catch (error) { + loyaltyTerminalLog.warn('Failed to load transactions:', error.message); + // Don't throw - transactions are optional + } + }, + + // Look up customer + async lookupCustomer() { + if (!this.searchQuery) return; + + this.lookingUp = true; + this.selectedCard = null; + + try { + loyaltyTerminalLog.info('Looking up customer:', this.searchQuery); + const response = await apiClient.get(`/vendor/loyalty/cards/lookup?q=${encodeURIComponent(this.searchQuery)}`); + + if (response) { + this.selectedCard = response; + loyaltyTerminalLog.info('Customer found:', this.selectedCard.customer_name); + this.searchQuery = ''; + } + } catch (error) { + if (error.status === 404) { + Utils.showToast('Customer not found. You can enroll them as a new member.', 'warning'); + } else { + Utils.showToast(`Error looking up customer: ${error.message}`, 'error'); + } + loyaltyTerminalLog.error('Lookup failed:', error); + } finally { + this.lookingUp = false; + } + }, + + // Clear selected customer + clearCustomer() { + this.selectedCard = null; + this.earnAmount = null; + this.selectedReward = ''; + }, + + // Get selected reward points + getSelectedRewardPoints() { + if (!this.selectedReward) return 0; + const reward = this.availableRewards.find(r => r.id === this.selectedReward); + return reward ? reward.points_required : 0; + }, + + // Show PIN modal + showPinModal(action) { + this.pendingAction = action; + this.pinDigits = ''; + this.showPinEntry = true; + }, + + // PIN entry methods + addPinDigit(digit) { + if (this.pinDigits.length < 4) { + this.pinDigits += digit.toString(); + } + }, + + removePinDigit() { + this.pinDigits = this.pinDigits.slice(0, -1); + }, + + cancelPinEntry() { + this.showPinEntry = false; + this.pinDigits = ''; + this.pendingAction = null; + }, + + // Submit transaction + async submitTransaction() { + if (this.pinDigits.length !== 4) return; + + this.processing = true; + + try { + if (this.pendingAction === 'earn') { + await this.earnPoints(); + } else if (this.pendingAction === 'redeem') { + await this.redeemReward(); + } + + // Close modal and refresh + this.showPinEntry = false; + this.pinDigits = ''; + this.pendingAction = null; + + // Refresh customer card and transactions + if (this.selectedCard) { + await this.refreshCard(); + } + await this.loadRecentTransactions(); + + } catch (error) { + Utils.showToast(`Transaction failed: ${error.message}`, 'error'); + loyaltyTerminalLog.error('Transaction failed:', error); + } finally { + this.processing = false; + } + }, + + // Earn points + async earnPoints() { + loyaltyTerminalLog.info('Earning points...', { amount: this.earnAmount }); + + const response = await apiClient.post('/vendor/loyalty/points/earn', { + card_id: this.selectedCard.id, + purchase_amount_cents: Math.round(this.earnAmount * 100), + staff_pin: this.pinDigits + }); + + const pointsEarned = response.points_earned || Math.floor(this.earnAmount * (this.program?.points_per_euro || 1)); + Utils.showToast(`${pointsEarned} points awarded!`, 'success'); + + this.earnAmount = null; + }, + + // Redeem reward + async redeemReward() { + const reward = this.availableRewards.find(r => r.id === this.selectedReward); + if (!reward) return; + + loyaltyTerminalLog.info('Redeeming reward...', { reward: reward.name }); + + await apiClient.post('/vendor/loyalty/points/redeem', { + card_id: this.selectedCard.id, + reward_id: this.selectedReward, + staff_pin: this.pinDigits + }); + + Utils.showToast(`Reward redeemed: ${reward.name}`, 'success'); + + this.selectedReward = ''; + }, + + // Refresh card data + async refreshCard() { + try { + const response = await apiClient.get(`/vendor/loyalty/cards/${this.selectedCard.id}`); + if (response) { + this.selectedCard = response; + } + } catch (error) { + loyaltyTerminalLog.warn('Failed to refresh card:', error.message); + } + }, + + // Format number + formatNumber(num) { + if (num === null || num === undefined) return '0'; + return new Intl.NumberFormat('en-US').format(num); + }, + + // Format time + formatTime(dateString) { + if (!dateString) return '-'; + try { + const date = new Date(dateString); + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }); + } catch (e) { + return dateString; + } + } + }; +} + +// Register logger +if (!window.LogConfig.loggers.loyaltyTerminal) { + window.LogConfig.loggers.loyaltyTerminal = window.LogConfig.createLogger('loyaltyTerminal'); +} + +loyaltyTerminalLog.info('Loyalty terminal module loaded'); diff --git a/app/modules/loyalty/tasks/__init__.py b/app/modules/loyalty/tasks/__init__.py index cd4980dc..7697ad53 100644 --- a/app/modules/loyalty/tasks/__init__.py +++ b/app/modules/loyalty/tasks/__init__.py @@ -3,8 +3,17 @@ Loyalty module Celery tasks. Background tasks for: -- Point expiration -- Wallet synchronization +- Point expiration (daily at 02:00) +- Wallet synchronization (hourly) + +Task registration is handled by the module definition in definition.py +which specifies the task paths and schedules. """ -__all__: list[str] = [] +from app.modules.loyalty.tasks.point_expiration import expire_points +from app.modules.loyalty.tasks.wallet_sync import sync_wallet_passes + +__all__ = [ + "expire_points", + "sync_wallet_passes", +] diff --git a/app/modules/loyalty/tasks/point_expiration.py b/app/modules/loyalty/tasks/point_expiration.py index fba3bb52..670784c4 100644 --- a/app/modules/loyalty/tasks/point_expiration.py +++ b/app/modules/loyalty/tasks/point_expiration.py @@ -3,12 +3,20 @@ Point expiration task. Handles expiring points that are older than the configured -expiration period (future enhancement). +expiration period based on card inactivity. + +Runs daily at 02:00 via the scheduled task configuration in definition.py. """ import logging +from datetime import UTC, datetime, timedelta from celery import shared_task +from sqlalchemy.orm import Session + +from app.core.database import SessionLocal +from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction +from app.modules.loyalty.models.loyalty_transaction import TransactionType logger = logging.getLogger(__name__) @@ -16,26 +24,175 @@ logger = logging.getLogger(__name__) @shared_task(name="loyalty.expire_points") def expire_points() -> dict: """ - Expire points that are past their expiration date. + Expire points that are past their expiration date based on card inactivity. - This is a placeholder for future functionality where points - can be configured to expire after a certain period. + For each program with points_expiration_days configured: + 1. Find cards that haven't had activity in the expiration period + 2. Expire all points on those cards + 3. Create POINTS_EXPIRED transaction records + 4. Update card balances Returns: Summary of expired points """ - # Future implementation: - # 1. Find programs with point expiration enabled - # 2. Find cards with points earned before expiration threshold - # 3. Calculate points to expire - # 4. Create adjustment transactions - # 5. Update card balances - # 6. Notify customers (optional) + logger.info("Starting point expiration task...") - logger.info("Point expiration task running (no-op for now)") + db: Session = SessionLocal() + try: + result = _process_point_expiration(db) + db.commit() + logger.info( + f"Point expiration complete: {result['cards_processed']} cards, " + f"{result['points_expired']} points expired" + ) + return result + except Exception as e: + db.rollback() + logger.error(f"Point expiration task failed: {e}", exc_info=True) + return { + "status": "error", + "error": str(e), + "cards_processed": 0, + "points_expired": 0, + } + finally: + db.close() + + +def _process_point_expiration(db: Session) -> dict: + """ + Process point expiration for all programs. + + Args: + db: Database session + + Returns: + Summary of expired points + """ + total_cards_processed = 0 + total_points_expired = 0 + programs_processed = 0 + + # Find all active programs with point expiration configured + programs = ( + db.query(LoyaltyProgram) + .filter( + LoyaltyProgram.is_active == True, + LoyaltyProgram.points_expiration_days.isnot(None), + LoyaltyProgram.points_expiration_days > 0, + ) + .all() + ) + + logger.info(f"Found {len(programs)} programs with point expiration configured") + + for program in programs: + cards_count, points_count = _expire_points_for_program(db, program) + total_cards_processed += cards_count + total_points_expired += points_count + programs_processed += 1 + + logger.debug( + f"Program {program.id} (company {program.company_id}): " + f"{cards_count} cards, {points_count} points expired" + ) return { "status": "success", - "cards_processed": 0, - "points_expired": 0, + "programs_processed": programs_processed, + "cards_processed": total_cards_processed, + "points_expired": total_points_expired, } + + +def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[int, int]: + """ + Expire points for a specific loyalty program. + + Args: + db: Database session + program: Loyalty program to process + + Returns: + Tuple of (cards_processed, points_expired) + """ + if not program.points_expiration_days: + return 0, 0 + + # Calculate expiration threshold + expiration_threshold = datetime.now(UTC) - timedelta(days=program.points_expiration_days) + + logger.debug( + f"Processing program {program.id}: expiration after {program.points_expiration_days} days " + f"(threshold: {expiration_threshold})" + ) + + # Find cards with: + # - Points balance > 0 + # - Last activity before expiration threshold + # - Belonging to this program's company + cards_to_expire = ( + db.query(LoyaltyCard) + .filter( + LoyaltyCard.company_id == program.company_id, + LoyaltyCard.points_balance > 0, + LoyaltyCard.last_activity_at < expiration_threshold, + LoyaltyCard.is_active == True, + ) + .all() + ) + + if not cards_to_expire: + logger.debug(f"No cards to expire for program {program.id}") + return 0, 0 + + logger.info(f"Found {len(cards_to_expire)} cards to expire for program {program.id}") + + cards_processed = 0 + points_expired = 0 + + for card in cards_to_expire: + if card.points_balance <= 0: + continue + + expired_points = card.points_balance + + # Create expiration transaction + transaction = LoyaltyTransaction( + card_id=card.id, + company_id=program.company_id, + vendor_id=None, # System action, no vendor + transaction_type=TransactionType.POINTS_EXPIRED.value, + points_delta=-expired_points, + balance_after=0, + stamps_delta=0, + stamps_balance_after=card.stamps_balance, + notes=f"Points expired after {program.points_expiration_days} days of inactivity", + transaction_at=datetime.now(UTC), + ) + db.add(transaction) + + # Update card balance + card.points_balance = 0 + card.total_points_voided = (card.total_points_voided or 0) + expired_points + # Note: We don't update last_activity_at for expiration + + cards_processed += 1 + points_expired += expired_points + + logger.debug( + f"Expired {expired_points} points from card {card.id} " + f"(last activity: {card.last_activity_at})" + ) + + return cards_processed, points_expired + + +# Allow running directly for testing +if __name__ == "__main__": + import sys + + logging.basicConfig(level=logging.DEBUG) + result = expire_points() + print(f"Result: {result}") + sys.exit(0 if result["status"] == "success" else 1) diff --git a/app/modules/loyalty/templates/loyalty/admin/analytics.html b/app/modules/loyalty/templates/loyalty/admin/analytics.html new file mode 100644 index 00000000..fd456a69 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/admin/analytics.html @@ -0,0 +1,162 @@ +{# app/modules/loyalty/templates/loyalty/admin/analytics.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}Loyalty Analytics{% endblock %} + +{% block alpine_data %}adminLoyaltyAnalytics(){% endblock %} + +{% block content %} +{{ page_header('Loyalty Analytics') }} + +{{ loading_state('Loading analytics...') }} + +{{ error_state('Error loading analytics') }} + + +
+ +
+ +
+
+ +
+
+

+ Total Programs +

+

+ 0 +

+

+ active +

+
+
+ + +
+
+ +
+
+

+ Total Members +

+

+ 0 +

+

+ active +

+
+
+ + +
+
+ +
+
+

+ Points Issued (30d) +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Points Redeemed (30d) +

+

+ 0 +

+
+
+
+ + +
+ +
+

+ + Transaction Activity (30 Days) +

+
+
+ Total Transactions + 0 +
+
+ Companies with Programs + 0 +
+
+ Redemption Rate + 0% +
+
+
+ + +
+

+ + Points Overview +

+
+
+
+ Points Issued vs Redeemed (30d) +
+
+
+
+
+
+
+
+ Issued: + Redeemed: +
+
+
+
+
+ + + +
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/admin/company-detail.html b/app/modules/loyalty/templates/loyalty/admin/company-detail.html new file mode 100644 index 00000000..79a2a1b2 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/admin/company-detail.html @@ -0,0 +1,238 @@ +{# app/modules/loyalty/templates/loyalty/admin/company-detail.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/headers.html' import detail_page_header %} +{% from 'shared/macros/tables.html' import table_wrapper, table_header %} + +{% block title %}Company Loyalty Details{% endblock %} + +{% block alpine_data %}adminLoyaltyCompanyDetail(){% endblock %} + +{% block content %} +{% call detail_page_header("company?.name || 'Company Loyalty'", '/admin/loyalty/programs', subtitle_show='company') %} + +{% endcall %} + +{{ loading_state('Loading company loyalty details...') }} + +{{ error_state('Error loading company loyalty') }} + + +
+ + + + +
+ +
+
+ +
+
+

+ Total Members +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Active (30d) +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Points Issued (30d) +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Points Redeemed (30d) +

+

+ 0 +

+
+
+
+ + +
+

+ + Program Configuration +

+
+
+

Program Name

+

-

+
+
+

Points Per Euro

+

-

+
+
+

Welcome Bonus

+

-

+
+
+

Minimum Redemption

+

-

+
+
+

Points Expiration

+

-

+
+
+

Status

+ + + +
+
+
+ + +
+
+ +
+

No Loyalty Program

+

This company has not set up a loyalty program yet. Vendors can set up the program from their dashboard.

+
+
+
+ + +
+

+ + Location Breakdown () +

+ {% call table_wrapper() %} + {{ table_header(['Location', 'Enrolled', 'Points Earned', 'Points Redeemed', 'Transactions (30d)']) }} + + + + + TOTAL + 0 + 0 + 0 + 0 + + + {% endcall %} +
+ + +
+

+ + Admin Settings +

+
+
+

Staff PIN Policy

+ + + +
+
+

Self Enrollment

+ + + +
+
+

Cross-Location Redemption

+ + + +
+
+ +
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/admin/company-settings.html b/app/modules/loyalty/templates/loyalty/admin/company-settings.html new file mode 100644 index 00000000..826bef0a --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/admin/company-settings.html @@ -0,0 +1,180 @@ +{# app/modules/loyalty/templates/loyalty/admin/company-settings.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/headers.html' import detail_page_header %} +{% from 'shared/macros/forms.html' import form_section, form_actions %} + +{% block title %}Company Loyalty Settings{% endblock %} + +{% block alpine_data %}adminLoyaltyCompanySettings(){% endblock %} + +{% block content %} +{% call detail_page_header("'Loyalty Settings: ' + (company?.name || '')", backUrl, subtitle_show='company') %} + Admin-controlled settings for this company's loyalty program +{% endcall %} + +{{ loading_state('Loading settings...') }} + +{{ error_state('Error loading settings') }} + + +
+
+ +
+

+ + Staff PIN Policy +

+

+ Control whether staff members at this company's locations must enter a PIN to process loyalty transactions. +

+ +
+ + + + + +
+ + +
+

+ PIN Lockout Settings +

+
+
+ + +

Number of wrong attempts before lockout (3-10)

+
+
+ + +

How long to lock out after failed attempts (5-120 minutes)

+
+
+
+
+ + +
+

+ + Enrollment Settings +

+ +
+ +
+
+ + +
+

+ + Transaction Settings +

+ +
+ + + +
+
+ + +
+ + Cancel + + +
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/admin/programs.html b/app/modules/loyalty/templates/loyalty/admin/programs.html new file mode 100644 index 00000000..56f650f0 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/admin/programs.html @@ -0,0 +1,241 @@ +{# app/modules/loyalty/templates/loyalty/admin/programs.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/pagination.html' import pagination %} +{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/tables.html' import table_wrapper, table_header %} + +{% block title %}Loyalty Programs{% endblock %} + +{% block alpine_data %}adminLoyaltyPrograms(){% endblock %} + +{% block content %} +{{ page_header('Loyalty Programs') }} + +{{ loading_state('Loading loyalty programs...') }} + +{{ error_state('Error loading loyalty programs') }} + + +
+ +
+
+ +
+
+

+ Total Programs +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Active +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Total Members +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Transactions (30d) +

+

+ 0 +

+
+
+
+ + +
+
+ +
+
+ + + + +
+
+ + +
+ + + + + +
+
+
+ + +
+ {% call table_wrapper() %} + {{ table_header(['Company', 'Program Type', 'Members', 'Points Issued', 'Status', 'Created', 'Actions']) }} + + + + + + + + {% endcall %} + + {{ pagination() }} +
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/storefront/dashboard.html b/app/modules/loyalty/templates/loyalty/storefront/dashboard.html new file mode 100644 index 00000000..aadf7dae --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/storefront/dashboard.html @@ -0,0 +1,226 @@ +{# app/modules/loyalty/templates/loyalty/storefront/dashboard.html #} +{% extends "storefront/base.html" %} + +{% block title %}My Loyalty - {{ vendor.name }}{% endblock %} + +{% block alpine_data %}customerLoyaltyDashboard(){% endblock %} + +{% block content %} +
+ +
+ + + Back to Account + +

My Loyalty

+
+ + +
+ +
+ + +
+ +

Join Our Rewards Program!

+

Earn points on every purchase and redeem for rewards.

+ + + Join Now + +
+ + +
+ +
+
+
+
+

+

+
+ +
+ +
+

Points Balance

+

+
+ +
+
+

Card Number

+

+
+ +
+
+
+ + +
+
+

Total Earned

+

+
+
+

Total Redeemed

+

+
+
+ + +
+

Available Rewards

+
+ + +
+

+ + Show your card to staff to redeem rewards in-store. +

+
+ + +
+
+

Recent Activity

+ + View All + +
+
+ + +
+
+ + +
+

+ + Earn & Redeem Locations +

+
+
    + +
+
+
+
+
+ + +
+
+

Your Loyalty Card

+ + +
+
+ +
+

+
+ +

+ Show this to staff when making a purchase or redeeming rewards. +

+ + +
+ + +
+ + +
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/storefront/enroll-success.html b/app/modules/loyalty/templates/loyalty/storefront/enroll-success.html new file mode 100644 index 00000000..beebe386 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/storefront/enroll-success.html @@ -0,0 +1,87 @@ +{# app/modules/loyalty/templates/loyalty/storefront/enroll-success.html #} +{% extends "storefront/base.html" %} + +{% block title %}Welcome to Rewards! - {{ vendor.name }}{% endblock %} + +{% block alpine_data %}customerLoyaltyEnrollSuccess(){% endblock %} + +{% block content %} +
+
+ +
+
+ +
+
+ +

Welcome!

+

You're now a member of our rewards program.

+ + +
+

Your Card Number

+

{{ enrolled_card_number or 'Loading...' }}

+ +
+

+ Save your card to your phone for easy access: +

+
+ + +
+
+
+ + +
+

What's Next?

+
    +
  • + + Show your card number when making purchases to earn points +
  • +
  • + + Check your balance online or in the app +
  • +
  • + + Redeem points for rewards at any of our locations +
  • +
+
+ + + +
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/storefront/enroll.html b/app/modules/loyalty/templates/loyalty/storefront/enroll.html new file mode 100644 index 00000000..e4a2b04a --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/storefront/enroll.html @@ -0,0 +1,135 @@ +{# app/modules/loyalty/templates/loyalty/storefront/enroll.html #} +{% extends "storefront/base.html" %} + +{% block title %}Join Loyalty Program - {{ vendor.name }}{% endblock %} + +{% block alpine_data %}customerLoyaltyEnroll(){% endblock %} + +{% block content %} +
+
+ +
+ {% if vendor.logo_url %} + {{ vendor.name }} + {% endif %} +

Join Our Rewards Program!

+

+
+ + +
+ +
+ + +
+ +

Program Not Available

+

This store doesn't have a loyalty program set up yet.

+
+ + +
+ +
+ + Get bonus points when you join! +
+ +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +

For special birthday rewards

+
+ +
+ + +
+ + +
+ +
+

+ Already a member? Your points are linked to your email. +

+
+
+ + +
+

+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/storefront/history.html b/app/modules/loyalty/templates/loyalty/storefront/history.html new file mode 100644 index 00000000..dd6715a5 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/storefront/history.html @@ -0,0 +1,107 @@ +{# app/modules/loyalty/templates/loyalty/storefront/history.html #} +{% extends "storefront/base.html" %} + +{% block title %}Loyalty History - {{ vendor.name }}{% endblock %} + +{% block alpine_data %}customerLoyaltyHistory(){% endblock %} + +{% block content %} +
+ +
+ + + Back to Loyalty + +

Transaction History

+

View all your loyalty point transactions

+
+ + +
+ +
+ + +
+
+
+

Current Balance

+

+
+
+

Total Earned

+

+
+
+

Total Redeemed

+

+
+
+
+ + +
+ + + + + +
+ + + Page of + + +
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/vendor/card-detail.html b/app/modules/loyalty/templates/loyalty/vendor/card-detail.html new file mode 100644 index 00000000..0e2713dd --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/vendor/card-detail.html @@ -0,0 +1,158 @@ +{# app/modules/loyalty/templates/loyalty/vendor/card-detail.html #} +{% extends "vendor/base.html" %} +{% from 'shared/macros/headers.html' import detail_page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/tables.html' import table_wrapper, table_header %} + +{% block title %}Member Details{% endblock %} + +{% block alpine_data %}vendorLoyaltyCardDetail(){% endblock %} + +{% block content %} +{% call detail_page_header("card?.customer_name || 'Member Details'", '/vendor/' + vendor_code + '/loyalty/cards', subtitle_show='card') %} + Card: +{% endcall %} + +{{ loading_state('Loading member details...') }} +{{ error_state('Error loading member') }} + +
+ +
+
+
+ +
+
+

Points Balance

+

0

+
+
+
+
+ +
+
+

Total Earned

+

0

+
+
+
+
+ +
+
+

Total Redeemed

+

0

+
+
+
+
+ +
+
+

Member Since

+

-

+
+
+
+ +
+ +
+

+ + Customer Information +

+
+
+

Name

+

-

+
+
+

Email

+

-

+
+
+

Phone

+

-

+
+
+

Birthday

+

-

+
+
+
+ + +
+

+ + Card Details +

+
+
+

Card Number

+

-

+
+
+

Status

+ +
+
+

Last Activity

+

-

+
+
+

Enrolled At

+

-

+
+
+
+
+ + +
+

+ + Transaction History +

+ {% call table_wrapper() %} + {{ table_header(['Date', 'Type', 'Points', 'Location', 'Notes']) }} + + + + + {% endcall %} +
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/vendor/cards.html b/app/modules/loyalty/templates/loyalty/vendor/cards.html new file mode 100644 index 00000000..b6e35552 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/vendor/cards.html @@ -0,0 +1,150 @@ +{# app/modules/loyalty/templates/loyalty/vendor/cards.html #} +{% extends "vendor/base.html" %} +{% from 'shared/macros/pagination.html' import pagination %} +{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/tables.html' import table_wrapper, table_header %} + +{% block title %}Loyalty Members{% endblock %} + +{% block alpine_data %}vendorLoyaltyCards(){% endblock %} + +{% block content %} + +{% call page_header_flex(title='Loyalty Members', subtitle='View and manage your loyalty program members') %} +
+ {{ refresh_button(loading_var='loading', onclick='loadCards()', variant='secondary') }} + + + Enroll New + +
+{% endcall %} + +{{ loading_state('Loading members...') }} + +{{ error_state('Error loading members') }} + + +
+
+
+ +
+
+

Total Members

+

0

+
+
+
+
+ +
+
+

Active (30d)

+

0

+
+
+
+
+ +
+
+

New This Month

+

0

+
+
+
+
+ +
+
+

Total Points Balance

+

0

+
+
+
+ + +
+
+
+
+ + + + +
+
+ +
+
+ + +
+ {% call table_wrapper() %} + {{ table_header(['Member', 'Card Number', 'Points Balance', 'Last Activity', 'Status', 'Actions']) }} + + + + + {% endcall %} + + {{ pagination() }} +
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/vendor/enroll.html b/app/modules/loyalty/templates/loyalty/vendor/enroll.html new file mode 100644 index 00000000..87afe1a1 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/vendor/enroll.html @@ -0,0 +1,146 @@ +{# app/modules/loyalty/templates/loyalty/vendor/enroll.html #} +{% extends "vendor/base.html" %} +{% from 'shared/macros/headers.html' import detail_page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}Enroll Customer{% endblock %} + +{% block alpine_data %}vendorLoyaltyEnroll(){% endblock %} + +{% block content %} +{% call detail_page_header("'Enroll New Customer'", '/vendor/' + vendor_code + '/loyalty/terminal') %} + Add a new member to your loyalty program +{% endcall %} + +{{ loading_state('Loading...') }} +{{ error_state('Error loading enrollment form') }} + +
+
+ +
+

+ + Customer Information +

+ +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +

For birthday rewards (optional)

+
+
+
+ + +
+

+ + Communication Preferences +

+ +
+ + +
+
+ + +
+
+ +
+

Welcome Bonus

+

+ Customer will receive bonus points! +

+
+
+
+ + +
+ + Cancel + + +
+
+ + +
+
+
+
+ +
+

Customer Enrolled!

+

+ Card Number: +

+

+ Starting Balance: points +

+
+ + Back to Terminal + + +
+
+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/vendor/settings.html b/app/modules/loyalty/templates/loyalty/vendor/settings.html new file mode 100644 index 00000000..075e68d8 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/vendor/settings.html @@ -0,0 +1,158 @@ +{# app/modules/loyalty/templates/loyalty/vendor/settings.html #} +{% extends "vendor/base.html" %} +{% from 'shared/macros/headers.html' import page_header_flex %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}Loyalty Settings{% endblock %} + +{% block alpine_data %}vendorLoyaltySettings(){% endblock %} + +{% block content %} +{% call page_header_flex(title='Loyalty Program Settings', subtitle='Configure your loyalty program') %}{% endcall %} + +{{ loading_state('Loading settings...') }} +{{ error_state('Error loading settings') }} + +
+
+ +
+

+ + Points Configuration +

+
+
+ + +

1 EUR = point(s)

+
+
+ + +

Bonus points awarded on enrollment

+
+
+ + +
+
+ + +

Days of inactivity before points expire (0 = never)

+
+
+
+ + +
+
+

+ + Redemption Rewards +

+ +
+
+ + +
+
+ + +
+

+ + Branding +

+
+
+ + +
+
+ +
+ + +
+
+
+
+ + +
+

+ + Program Status +

+ +
+ + +
+ +
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/vendor/stats.html b/app/modules/loyalty/templates/loyalty/vendor/stats.html new file mode 100644 index 00000000..6bd21e66 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/vendor/stats.html @@ -0,0 +1,134 @@ +{# app/modules/loyalty/templates/loyalty/vendor/stats.html #} +{% extends "vendor/base.html" %} +{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}Loyalty Stats{% endblock %} + +{% block alpine_data %}vendorLoyaltyStats(){% endblock %} + +{% block content %} +{% call page_header_flex(title='Loyalty Statistics', subtitle='Track your loyalty program performance') %} +
+ {{ refresh_button(loading_var='loading', onclick='loadStats()', variant='secondary') }} +
+{% endcall %} + +{{ loading_state('Loading statistics...') }} +{{ error_state('Error loading statistics') }} + +
+ +
+
+
+ +
+
+

Total Members

+

0

+
+
+
+
+ +
+
+

Points Issued (30d)

+

0

+
+
+
+
+ +
+
+

Points Redeemed (30d)

+

0

+
+
+
+
+ +
+
+

Transactions (30d)

+

0

+
+
+
+ + +
+ +
+

+ + Points Overview +

+
+
+ Total Points Issued (All Time) + 0 +
+
+ Total Points Redeemed + 0 +
+
+ Outstanding Balance + 0 +
+
+
+ + +
+

+ + Member Activity +

+
+
+ Active Members (30d) + 0 +
+
+ New This Month + 0 +
+
+ Avg Points Per Member + 0 +
+
+
+
+ + + +
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/vendor/terminal.html b/app/modules/loyalty/templates/loyalty/vendor/terminal.html new file mode 100644 index 00000000..504e7680 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/vendor/terminal.html @@ -0,0 +1,309 @@ +{# app/modules/loyalty/templates/loyalty/vendor/terminal.html #} +{% extends "vendor/base.html" %} +{% from 'shared/macros/headers.html' import page_header_flex %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/modals.html' import modal_simple %} + +{% block title %}Loyalty Terminal{% endblock %} + +{% block alpine_data %}vendorLoyaltyTerminal(){% endblock %} + +{% block content %} + +{% call page_header_flex(title='Loyalty Terminal', subtitle='Process loyalty transactions') %} + +{% endcall %} + +{{ loading_state('Loading loyalty terminal...') }} + +{{ error_state('Error loading terminal') }} + + +
+
+ +
+

Loyalty Program Not Set Up

+

Your company doesn't have a loyalty program configured yet.

+ + + Set Up Loyalty Program + +
+
+
+ + +
+
+ +
+
+

+ + Find Customer +

+
+
+ +
+ + + + +
+ + + +
+
+
+
+
+ or +
+
+ + + + + Enroll New Customer + +
+
+ + +
+
+

+ + Customer Found +

+
+
+ +
+
+ +
+
+

+

+

+
+ +
+ + +
+

Points Balance

+

+
+ + +
+ +
+

+ + Earn Points +

+
+ +
+ EUR + +
+
+

+ Points to award: +

+ +
+ + +
+

+ + Redeem Reward +

+
+ + +
+ + +
+
+
+
+ + +
+
+ +

Search for a customer to process a transaction

+
+
+
+ + +
+
+

+ + Recent Transactions at This Location +

+
+
+ + + + + + + + + + + + + + +
TimeCustomerTypePointsNotes
+
+
+
+ + +{% call modal_simple(id='pinModal', title='Enter Staff PIN', show_var='showPinEntry') %} +
+

+ Enter your staff PIN to authorize this transaction. +

+
+
+ +
+
+
+ + + + +
+
+ + +
+
+{% endcall %} +{% endblock %} + +{% block extra_scripts %} + +{% endblock %}