Some checks failed
Phase 1 of the loyalty production launch plan: config & security hardening, dropped-data fix, DB integrity guards, rate limiting, and constant-time auth compare. 362 tests pass. - 1.4 Persist customer birth_date (new column + migration). Enrollment form was collecting it but the value was silently dropped because create_customer_for_enrollment never received it. Backfills existing customers without overwriting. - 1.1 LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON validated at startup (file must exist and be readable; ~ expanded). Adds is_google_wallet_enabled and is_apple_wallet_enabled derived flags. Prod path documented as ~/apps/orion/google-wallet-sa.json. - 1.5 CHECK constraints on loyalty_cards (points_balance, stamp_count non-negative) and loyalty_programs (min_purchase, points_per_euro, welcome_bonus non-negative; stamps_target >= 1). Mirrored as CheckConstraint in models. Pre-flight scan showed zero violations. - 1.3 @rate_limit on store mutating endpoints: stamp 60/min, redeem/points-earn 30-60/min, void/adjust 20/min, pin unlock 10/min. - 1.2 Constant-time hmac.compare_digest for Apple Wallet auth token (pulled forward from Phase 9 — code is safe whenever Apple ships). See app/modules/loyalty/docs/production-launch-plan.md for the full launch plan and remaining phases. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
328 lines
9.7 KiB
Python
328 lines
9.7 KiB
Python
# app/modules/loyalty/models/loyalty_program.py
|
|
"""
|
|
Loyalty program database model.
|
|
|
|
Merchant-based loyalty program configuration:
|
|
- Program belongs to Merchant (one program per merchant)
|
|
- All stores under a merchant share the same loyalty program
|
|
- Customers earn and redeem points at any location (store) within the merchant
|
|
|
|
Defines:
|
|
- Program type (stamps, points, hybrid)
|
|
- Stamp configuration (target, reward description)
|
|
- Points configuration (per euro rate, rewards catalog)
|
|
- Anti-fraud settings (cooldown, daily limits, PIN requirement)
|
|
- Branding (card name, color, logo)
|
|
- Wallet integration IDs (Google, Apple)
|
|
"""
|
|
|
|
import enum
|
|
from datetime import UTC, datetime
|
|
|
|
from sqlalchemy import (
|
|
Boolean,
|
|
CheckConstraint,
|
|
Column,
|
|
DateTime,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
String,
|
|
Text,
|
|
)
|
|
from sqlalchemy.dialects.sqlite import JSON
|
|
from sqlalchemy.orm import relationship
|
|
|
|
from app.core.database import Base
|
|
from models.database.base import SoftDeleteMixin, TimestampMixin
|
|
|
|
|
|
class LoyaltyType(str, enum.Enum):
|
|
"""Type of loyalty program."""
|
|
|
|
STAMPS = "stamps" # Collect N stamps, get reward
|
|
POINTS = "points" # Earn points per euro, redeem for rewards
|
|
HYBRID = "hybrid" # Both stamps and points
|
|
|
|
|
|
class LoyaltyProgram(Base, TimestampMixin, SoftDeleteMixin):
|
|
"""
|
|
Merchant's loyalty program configuration.
|
|
|
|
Program belongs to Merchant (chain-wide shared points).
|
|
All stores under a merchant share the same loyalty program.
|
|
Customers can earn and redeem at any store within the merchant.
|
|
|
|
Each merchant can have one loyalty program that defines:
|
|
- Program type and mechanics
|
|
- Stamp or points configuration
|
|
- Anti-fraud rules
|
|
- Branding and wallet integration
|
|
"""
|
|
|
|
__tablename__ = "loyalty_programs"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
# Merchant association (one program per merchant)
|
|
merchant_id = Column(
|
|
Integer,
|
|
ForeignKey("merchants.id", ondelete="CASCADE"),
|
|
unique=True,
|
|
nullable=False,
|
|
index=True,
|
|
comment="Merchant that owns this program (chain-wide)",
|
|
)
|
|
|
|
# Program type
|
|
loyalty_type = Column(
|
|
String(20),
|
|
default=LoyaltyType.POINTS.value,
|
|
nullable=False,
|
|
)
|
|
|
|
# =========================================================================
|
|
# Stamps Configuration
|
|
# =========================================================================
|
|
stamps_target = Column(
|
|
Integer,
|
|
default=10,
|
|
nullable=False,
|
|
comment="Number of stamps needed for reward",
|
|
)
|
|
stamps_reward_description = Column(
|
|
String(255),
|
|
default="Free item",
|
|
nullable=False,
|
|
comment="Description of stamp reward",
|
|
)
|
|
stamps_reward_value_cents = Column(
|
|
Integer,
|
|
nullable=True,
|
|
comment="Value of stamp reward in cents (for analytics)",
|
|
)
|
|
|
|
# =========================================================================
|
|
# Points Configuration
|
|
# =========================================================================
|
|
points_per_euro = Column(
|
|
Integer,
|
|
default=1,
|
|
nullable=False,
|
|
comment="Points earned per euro spent (1 euro = X points)",
|
|
)
|
|
points_rewards = Column(
|
|
JSON,
|
|
default=list,
|
|
nullable=False,
|
|
comment="List of point rewards: [{id, name, points_required, description}]",
|
|
)
|
|
|
|
# Points expiration and bonus settings
|
|
points_expiration_days = Column(
|
|
Integer,
|
|
nullable=True,
|
|
comment="Days of inactivity before points expire (None = never expire)",
|
|
)
|
|
welcome_bonus_points = Column(
|
|
Integer,
|
|
default=0,
|
|
nullable=False,
|
|
comment="Bonus points awarded on enrollment",
|
|
)
|
|
minimum_redemption_points = Column(
|
|
Integer,
|
|
default=100,
|
|
nullable=False,
|
|
comment="Minimum points required for any redemption",
|
|
)
|
|
minimum_purchase_cents = Column(
|
|
Integer,
|
|
default=0,
|
|
nullable=False,
|
|
comment="Minimum purchase amount (cents) to earn points (0 = no minimum)",
|
|
)
|
|
|
|
# Future tier configuration (Bronze/Silver/Gold)
|
|
tier_config = Column(
|
|
JSON,
|
|
nullable=True,
|
|
comment='Future: Tier thresholds {"bronze": 0, "silver": 1000, "gold": 5000}',
|
|
)
|
|
|
|
# =========================================================================
|
|
# Anti-Fraud Settings
|
|
# =========================================================================
|
|
cooldown_minutes = Column(
|
|
Integer,
|
|
default=15,
|
|
nullable=False,
|
|
comment="Minutes between stamps for same card",
|
|
)
|
|
max_daily_stamps = Column(
|
|
Integer,
|
|
default=5,
|
|
nullable=False,
|
|
comment="Maximum stamps per card per day",
|
|
)
|
|
require_staff_pin = Column(
|
|
Boolean,
|
|
default=True,
|
|
nullable=False,
|
|
comment="Require staff PIN for stamp/points operations",
|
|
)
|
|
|
|
# =========================================================================
|
|
# Branding
|
|
# =========================================================================
|
|
card_name = Column(
|
|
String(100),
|
|
nullable=True,
|
|
comment="Display name for loyalty card",
|
|
)
|
|
card_color = Column(
|
|
String(7),
|
|
default="#4F46E5",
|
|
nullable=False,
|
|
comment="Primary color for card (hex)",
|
|
)
|
|
card_secondary_color = Column(
|
|
String(7),
|
|
nullable=True,
|
|
comment="Secondary color for card (hex)",
|
|
)
|
|
logo_url = Column(
|
|
String(500),
|
|
nullable=True,
|
|
comment="URL to merchant logo for card",
|
|
)
|
|
hero_image_url = Column(
|
|
String(500),
|
|
nullable=True,
|
|
comment="URL to hero image for card",
|
|
)
|
|
|
|
# =========================================================================
|
|
# Wallet Integration
|
|
# =========================================================================
|
|
google_issuer_id = Column(
|
|
String(100),
|
|
nullable=True,
|
|
comment="Google Wallet Issuer ID",
|
|
)
|
|
google_class_id = Column(
|
|
String(200),
|
|
nullable=True,
|
|
comment="Google Wallet Loyalty Class ID",
|
|
)
|
|
apple_pass_type_id = Column(
|
|
String(100),
|
|
nullable=True,
|
|
comment="Apple Wallet Pass Type ID",
|
|
)
|
|
|
|
# =========================================================================
|
|
# Terms and Conditions
|
|
# =========================================================================
|
|
terms_text = Column(
|
|
Text,
|
|
nullable=True,
|
|
comment="Loyalty program terms and conditions",
|
|
)
|
|
privacy_url = Column(
|
|
String(500),
|
|
nullable=True,
|
|
comment="URL to privacy policy",
|
|
)
|
|
|
|
# =========================================================================
|
|
# Status
|
|
# =========================================================================
|
|
is_active = Column(
|
|
Boolean,
|
|
default=True,
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
activated_at = Column(
|
|
DateTime(timezone=True),
|
|
nullable=True,
|
|
comment="When program was first activated",
|
|
)
|
|
|
|
# =========================================================================
|
|
# Relationships
|
|
# =========================================================================
|
|
merchant = relationship("Merchant", backref="loyalty_program")
|
|
cards = relationship(
|
|
"LoyaltyCard",
|
|
back_populates="program",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
staff_pins = relationship(
|
|
"StaffPin",
|
|
back_populates="program",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
|
|
# Indexes & integrity constraints
|
|
__table_args__ = (
|
|
Index("idx_loyalty_program_merchant_active", "merchant_id", "is_active"),
|
|
CheckConstraint(
|
|
"minimum_purchase_cents >= 0",
|
|
name="ck_loyalty_programs_min_purchase_nonneg",
|
|
),
|
|
CheckConstraint(
|
|
"points_per_euro >= 0",
|
|
name="ck_loyalty_programs_points_per_euro_nonneg",
|
|
),
|
|
CheckConstraint(
|
|
"stamps_target >= 1",
|
|
name="ck_loyalty_programs_stamps_target_positive",
|
|
),
|
|
CheckConstraint(
|
|
"welcome_bonus_points >= 0",
|
|
name="ck_loyalty_programs_welcome_bonus_nonneg",
|
|
),
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<LoyaltyProgram(id={self.id}, merchant_id={self.merchant_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
|