Files
orion/app/modules/loyalty/schemas/program.py
Samir Boulahtit 7d652716bb
Some checks failed
CI / ruff (push) Successful in 12s
CI / validate (push) Successful in 27s
CI / dependency-scanning (push) Successful in 31s
CI / pytest (push) Failing after 3h14m58s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
feat(loyalty): production readiness round 2 — 12 security, integrity & correctness fixes
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>
2026-03-16 23:37:23 +01:00

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