Some checks failed
Security: - Fix TOCTOU race conditions: move balance/limit checks after row lock in redeem_points, add_stamp, redeem_stamps - Add PIN ownership verification to update/delete/unlock store routes - Gate adjust_points endpoint to merchant_owner role only Data integrity: - Track total_points_voided in void_points - Add order_reference idempotency guard in earn_points Correctness: - Fix LoyaltyProgramAlreadyExistsException to use merchant_id parameter - Add StorefrontProgramResponse excluding wallet IDs from public API - Add bounds (±100000) to PointsAdjustRequest.points_delta Audit & config: - Add CARD_REACTIVATED transaction type with audit record - Improve admin audit logging with actor identity and old values - Use merchant-specific PIN lockout settings with global fallback - Guard MerchantLoyaltySettings creation with get_or_create pattern Tests: 27 new tests (265 total) covering all 12 items — unit and integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
345 lines
10 KiB
Python
345 lines
10 KiB
Python
# 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
|