feat(loyalty): implement complete loyalty module MVP
Add stamp-based and points-based loyalty programs for vendors with: Database Models (5 tables): - loyalty_programs: Vendor program configuration - loyalty_cards: Customer cards with stamp/point balances - loyalty_transactions: Immutable audit log - staff_pins: Fraud prevention PINs (bcrypt hashed) - apple_device_registrations: Apple Wallet push tokens Services: - program_service: Program CRUD and statistics - card_service: Customer enrollment and card lookup - stamp_service: Stamp operations with anti-fraud checks - points_service: Points earning and redemption - pin_service: Staff PIN management with lockout - wallet_service: Unified wallet abstraction - google_wallet_service: Google Wallet API integration - apple_wallet_service: Apple Wallet .pkpass generation API Routes: - Admin: /api/v1/admin/loyalty/* (programs list, stats) - Vendor: /api/v1/vendor/loyalty/* (stamp, points, cards, PINs) - Public: /api/v1/loyalty/* (enrollment, Apple Web Service) Anti-Fraud Features: - Staff PIN verification (configurable per program) - Cooldown period between stamps (default 15 min) - Daily stamp limits (default 5/day) - PIN lockout after failed attempts Wallet Integration: - Google Wallet: LoyaltyClass and LoyaltyObject management - Apple Wallet: .pkpass generation with PKCS#7 signing - Apple Web Service endpoints for device registration/updates Also includes: - Alembic migration for all tables with indexes - Localization files (en, fr, de, lu) - Module documentation - Phase 2 interface and user journey plan Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
268
app/modules/loyalty/models/loyalty_program.py
Normal file
268
app/modules/loyalty/models/loyalty_program.py
Normal file
@@ -0,0 +1,268 @@
|
||||
# app/modules/loyalty/models/loyalty_program.py
|
||||
"""
|
||||
Loyalty program database model.
|
||||
|
||||
Defines the vendor's loyalty program configuration including:
|
||||
- Program type (stamps, points, hybrid)
|
||||
- Stamp configuration (target, reward description)
|
||||
- Points configuration (per euro rate, rewards catalog)
|
||||
- Anti-fraud settings (cooldown, daily limits, PIN requirement)
|
||||
- Branding (card name, color, logo)
|
||||
- Wallet integration IDs (Google, Apple)
|
||||
"""
|
||||
|
||||
import enum
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.dialects.sqlite import JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class LoyaltyType(str, enum.Enum):
|
||||
"""Type of loyalty program."""
|
||||
|
||||
STAMPS = "stamps" # Collect N stamps, get reward
|
||||
POINTS = "points" # Earn points per euro, redeem for rewards
|
||||
HYBRID = "hybrid" # Both stamps and points
|
||||
|
||||
|
||||
class LoyaltyProgram(Base, TimestampMixin):
|
||||
"""
|
||||
Vendor's loyalty program configuration.
|
||||
|
||||
Each vendor can have one loyalty program that defines:
|
||||
- Program type and mechanics
|
||||
- Stamp or points configuration
|
||||
- Anti-fraud rules
|
||||
- Branding and wallet integration
|
||||
"""
|
||||
|
||||
__tablename__ = "loyalty_programs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Vendor association (one program per vendor)
|
||||
vendor_id = Column(
|
||||
Integer,
|
||||
ForeignKey("vendors.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Program type
|
||||
loyalty_type = Column(
|
||||
String(20),
|
||||
default=LoyaltyType.STAMPS.value,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Stamps Configuration
|
||||
# =========================================================================
|
||||
stamps_target = Column(
|
||||
Integer,
|
||||
default=10,
|
||||
nullable=False,
|
||||
comment="Number of stamps needed for reward",
|
||||
)
|
||||
stamps_reward_description = Column(
|
||||
String(255),
|
||||
default="Free item",
|
||||
nullable=False,
|
||||
comment="Description of stamp reward",
|
||||
)
|
||||
stamps_reward_value_cents = Column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Value of stamp reward in cents (for analytics)",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Points Configuration
|
||||
# =========================================================================
|
||||
points_per_euro = Column(
|
||||
Integer,
|
||||
default=10,
|
||||
nullable=False,
|
||||
comment="Points earned per euro spent",
|
||||
)
|
||||
points_rewards = Column(
|
||||
JSON,
|
||||
default=list,
|
||||
nullable=False,
|
||||
comment="List of point rewards: [{id, name, points_required, description}]",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Anti-Fraud Settings
|
||||
# =========================================================================
|
||||
cooldown_minutes = Column(
|
||||
Integer,
|
||||
default=15,
|
||||
nullable=False,
|
||||
comment="Minutes between stamps for same card",
|
||||
)
|
||||
max_daily_stamps = Column(
|
||||
Integer,
|
||||
default=5,
|
||||
nullable=False,
|
||||
comment="Maximum stamps per card per day",
|
||||
)
|
||||
require_staff_pin = Column(
|
||||
Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
comment="Require staff PIN for stamp/points operations",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Branding
|
||||
# =========================================================================
|
||||
card_name = Column(
|
||||
String(100),
|
||||
nullable=True,
|
||||
comment="Display name for loyalty card",
|
||||
)
|
||||
card_color = Column(
|
||||
String(7),
|
||||
default="#4F46E5",
|
||||
nullable=False,
|
||||
comment="Primary color for card (hex)",
|
||||
)
|
||||
card_secondary_color = Column(
|
||||
String(7),
|
||||
nullable=True,
|
||||
comment="Secondary color for card (hex)",
|
||||
)
|
||||
logo_url = Column(
|
||||
String(500),
|
||||
nullable=True,
|
||||
comment="URL to vendor logo for card",
|
||||
)
|
||||
hero_image_url = Column(
|
||||
String(500),
|
||||
nullable=True,
|
||||
comment="URL to hero image for card",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Wallet Integration
|
||||
# =========================================================================
|
||||
google_issuer_id = Column(
|
||||
String(100),
|
||||
nullable=True,
|
||||
comment="Google Wallet Issuer ID",
|
||||
)
|
||||
google_class_id = Column(
|
||||
String(200),
|
||||
nullable=True,
|
||||
comment="Google Wallet Loyalty Class ID",
|
||||
)
|
||||
apple_pass_type_id = Column(
|
||||
String(100),
|
||||
nullable=True,
|
||||
comment="Apple Wallet Pass Type ID",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Terms and Conditions
|
||||
# =========================================================================
|
||||
terms_text = Column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="Loyalty program terms and conditions",
|
||||
)
|
||||
privacy_url = Column(
|
||||
String(500),
|
||||
nullable=True,
|
||||
comment="URL to privacy policy",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Status
|
||||
# =========================================================================
|
||||
is_active = Column(
|
||||
Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
activated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="When program was first activated",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Relationships
|
||||
# =========================================================================
|
||||
vendor = relationship("Vendor", back_populates="loyalty_program")
|
||||
cards = relationship(
|
||||
"LoyaltyCard",
|
||||
back_populates="program",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
staff_pins = relationship(
|
||||
"StaffPin",
|
||||
back_populates="program",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index("idx_loyalty_program_vendor_active", "vendor_id", "is_active"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<LoyaltyProgram(id={self.id}, vendor_id={self.vendor_id}, type='{self.loyalty_type}')>"
|
||||
|
||||
# =========================================================================
|
||||
# Properties
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def is_stamps_enabled(self) -> bool:
|
||||
"""Check if stamps are enabled for this program."""
|
||||
return self.loyalty_type in (LoyaltyType.STAMPS.value, LoyaltyType.HYBRID.value)
|
||||
|
||||
@property
|
||||
def is_points_enabled(self) -> bool:
|
||||
"""Check if points are enabled for this program."""
|
||||
return self.loyalty_type in (LoyaltyType.POINTS.value, LoyaltyType.HYBRID.value)
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Get display name for the program."""
|
||||
return self.card_name or "Loyalty Card"
|
||||
|
||||
def get_points_reward(self, reward_id: str) -> dict | None:
|
||||
"""Get a specific points reward by ID."""
|
||||
rewards = self.points_rewards or []
|
||||
for reward in rewards:
|
||||
if reward.get("id") == reward_id:
|
||||
return reward
|
||||
return None
|
||||
|
||||
def activate(self) -> None:
|
||||
"""Activate the loyalty program."""
|
||||
self.is_active = True
|
||||
if not self.activated_at:
|
||||
self.activated_at = datetime.now(UTC)
|
||||
|
||||
def deactivate(self) -> None:
|
||||
"""Deactivate the loyalty program."""
|
||||
self.is_active = False
|
||||
Reference in New Issue
Block a user