# app/modules/loyalty/schemas/program.py """ Pydantic schemas for loyalty program operations. Merchant-based programs: - One program per merchant - All stores under a merchant share the same program - Supports chain-wide loyalty across locations """ from datetime import datetime from pydantic import BaseModel, ConfigDict, Field class PointsRewardConfig(BaseModel): """Configuration for a points-based reward.""" id: str = Field(..., description="Unique reward identifier") name: str = Field(..., max_length=100, description="Reward name") points_required: int = Field(..., gt=0, description="Points needed to redeem") description: str | None = Field(None, max_length=255, description="Reward description") 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( "points", pattern="^(stamps|points|hybrid)$", description="Program type: stamps, points, or hybrid", ) # Stamps configuration stamps_target: int = Field(10, ge=1, le=50, description="Stamps needed for reward") stamps_reward_description: str = Field( "Free item", max_length=255, description="Description of stamp reward", ) stamps_reward_value_cents: int | None = Field( None, ge=0, description="Value of reward in cents (for analytics)", ) # Points configuration 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") max_daily_stamps: int = Field(5, ge=1, le=50, description="Max stamps per card per day") require_staff_pin: bool = Field(True, description="Require staff PIN for operations") # Branding card_name: str | None = Field(None, max_length=100, description="Display name for card") card_color: str = Field( "#4F46E5", pattern="^#[0-9A-Fa-f]{6}$", description="Primary color (hex)", ) card_secondary_color: str | None = Field( None, pattern="^#[0-9A-Fa-f]{6}$", description="Secondary color (hex)", ) logo_url: str | None = Field(None, max_length=500, description="Logo URL") hero_image_url: str | None = Field(None, max_length=500, description="Hero image URL") # Terms terms_text: str | None = Field(None, description="Terms and conditions") privacy_url: str | None = Field(None, max_length=500, description="Privacy policy URL") class ProgramUpdate(BaseModel): """Schema for updating a loyalty program.""" model_config = ConfigDict(from_attributes=True) # Program type (cannot change from stamps to points after cards exist) loyalty_type: str | None = Field( None, pattern="^(stamps|points|hybrid)$", ) # Stamps configuration stamps_target: int | None = Field(None, ge=1, le=50) stamps_reward_description: str | None = Field(None, max_length=255) stamps_reward_value_cents: int | None = Field(None, ge=0) # Points configuration 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) max_daily_stamps: int | None = Field(None, ge=1, le=50) require_staff_pin: bool | None = None # Branding card_name: str | None = Field(None, max_length=100) card_color: str | None = Field(None, pattern="^#[0-9A-Fa-f]{6}$") card_secondary_color: str | None = Field(None, pattern="^#[0-9A-Fa-f]{6}$") logo_url: str | None = Field(None, max_length=500) hero_image_url: str | None = Field(None, max_length=500) # Terms terms_text: str | None = None privacy_url: str | None = Field(None, max_length=500) # Wallet integration google_issuer_id: str | None = Field(None, max_length=100) apple_pass_type_id: str | None = Field(None, max_length=100) # Status is_active: bool | None = None class ProgramResponse(BaseModel): """Schema for loyalty program response.""" model_config = ConfigDict(from_attributes=True) id: int merchant_id: int merchant_name: str | None = None # Populated by API from Merchant join loyalty_type: str # Stamps stamps_target: int stamps_reward_description: str stamps_reward_value_cents: int | None = None # 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 max_daily_stamps: int require_staff_pin: bool # Branding card_name: str | None = None card_color: str card_secondary_color: str | None = None logo_url: str | None = None hero_image_url: str | None = None # Terms terms_text: str | None = None privacy_url: str | None = None # Wallet google_issuer_id: str | None = None google_class_id: str | None = None apple_pass_type_id: str | None = None # Status is_active: bool activated_at: datetime | None = None created_at: datetime updated_at: datetime # Computed is_stamps_enabled: bool = False 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 StorefrontProgramResponse(ProgramResponse): """Program response for unauthenticated storefront visitors — excludes wallet integration IDs.""" model_config = ConfigDict(from_attributes=True) google_issuer_id: None = Field(None, exclude=True) google_class_id: None = Field(None, exclude=True) apple_pass_type_id: None = Field(None, exclude=True) class ProgramListResponse(BaseModel): """Schema for listing loyalty programs (admin).""" programs: list[ProgramResponse] total: int class ProgramStatsResponse(BaseModel): """Schema for program statistics.""" # Cards total_cards: int = 0 active_cards: int = 0 new_this_month: int = 0 # Stamps (if enabled) total_stamps_issued: int = 0 total_stamps_redeemed: int = 0 stamps_this_month: int = 0 redemptions_this_month: int = 0 # Points (if enabled) total_points_issued: int = 0 total_points_redeemed: int = 0 total_points_balance: int = 0 points_this_month: int = 0 points_redeemed_this_month: int = 0 # Engagement cards_with_activity_30d: int = 0 average_stamps_per_card: float = 0.0 average_points_per_card: float = 0.0 avg_points_per_member: float = 0.0 # 30-day metrics transactions_30d: int = 0 points_issued_30d: int = 0 points_redeemed_30d: int = 0 # Value estimated_liability_cents: int = 0 # Unredeemed stamps/points value class MerchantStatsResponse(BaseModel): """Schema for merchant-wide loyalty statistics across all locations.""" merchant_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 # Members new_this_month: int = 0 # Points - last 30 days points_issued_30d: int = 0 points_redeemed_30d: int = 0 transactions_30d: int = 0 # Value estimated_liability_cents: int = 0 # Program info (optional) program: dict | None = None # Location breakdown locations: list[dict] = [] # Per-location breakdown class MerchantSettingsResponse(BaseModel): """Schema for merchant loyalty settings.""" model_config = ConfigDict(from_attributes=True) id: int merchant_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 MerchantSettingsUpdate(BaseModel): """Schema for updating merchant 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