feat(loyalty): implement Phase 2 - company-wide points system

Complete implementation of loyalty module Phase 2 features:

Database & Models:
- Add company_id to LoyaltyProgram for chain-wide loyalty
- Add company_id to LoyaltyCard for multi-location support
- Add CompanyLoyaltySettings model for admin-controlled settings
- Add points expiration, welcome bonus, and minimum redemption fields
- Add POINTS_EXPIRED, WELCOME_BONUS transaction types

Services:
- Update program_service for company-based queries
- Update card_service with enrollment and welcome bonus
- Update points_service with void_points for returns
- Update stamp_service for company context
- Update pin_service for company-wide operations

API Endpoints:
- Admin: Program listing with stats, company detail views
- Vendor: Terminal operations, card management, settings
- Storefront: Customer card/transactions, self-enrollment

UI Templates:
- Admin: Programs dashboard, company detail, settings
- Vendor: Terminal, cards list, card detail, settings, stats, enrollment
- Storefront: Dashboard, history, enrollment, success pages

Background Tasks:
- Point expiration task (daily, based on inactivity)
- Wallet sync task (hourly)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 22:10:27 +01:00
parent 3bdf1695fd
commit d8f3338bc8
54 changed files with 7252 additions and 186 deletions

View File

@@ -1,6 +1,11 @@
# app/modules/loyalty/schemas/program.py
"""
Pydantic schemas for loyalty program operations.
Company-based programs:
- One program per company
- All vendors under a company share the same program
- Supports chain-wide loyalty across locations
"""
from datetime import datetime
@@ -18,12 +23,22 @@ class PointsRewardConfig(BaseModel):
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(
"stamps",
"points",
pattern="^(stamps|points|hybrid)$",
description="Program type: stamps, points, or hybrid",
)
@@ -42,11 +57,37 @@ class ProgramCreate(BaseModel):
)
# Points configuration
points_per_euro: int = Field(10, ge=1, le=1000, description="Points per euro spent")
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")
@@ -90,8 +131,15 @@ class ProgramUpdate(BaseModel):
stamps_reward_value_cents: int | None = Field(None, ge=0)
# Points configuration
points_per_euro: int | None = Field(None, ge=1, le=1000)
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)
@@ -123,7 +171,8 @@ class ProgramResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
company_id: int
company_name: str | None = None # Populated by API from Company join
loyalty_type: str
# Stamps
@@ -134,6 +183,10 @@ class ProgramResponse(BaseModel):
# 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
@@ -167,6 +220,12 @@ class ProgramResponse(BaseModel):
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 ProgramListResponse(BaseModel):
"""Schema for listing loyalty programs (admin)."""
@@ -201,3 +260,61 @@ class ProgramStatsResponse(BaseModel):
# Value
estimated_liability_cents: int = 0 # Unredeemed stamps/points value
class CompanyStatsResponse(BaseModel):
"""Schema for company-wide loyalty statistics across all locations."""
company_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
# Points - last 30 days
points_issued_30d: int = 0
points_redeemed_30d: int = 0
transactions_30d: int = 0
# Program info (optional)
program: dict | None = None
# Location breakdown
locations: list[dict] = [] # Per-location breakdown
class CompanySettingsResponse(BaseModel):
"""Schema for company loyalty settings."""
model_config = ConfigDict(from_attributes=True)
id: int
company_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 CompanySettingsUpdate(BaseModel):
"""Schema for updating company 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