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

@@ -33,8 +33,13 @@ from app.modules.loyalty.schemas.program import (
ProgramListResponse,
# Points rewards
PointsRewardConfig,
TierConfig,
# Stats
ProgramStatsResponse,
CompanyStatsResponse,
# Company settings
CompanySettingsResponse,
CompanySettingsUpdate,
)
from app.modules.loyalty.schemas.card import (
@@ -44,6 +49,9 @@ from app.modules.loyalty.schemas.card import (
CardDetailResponse,
CardListResponse,
CardLookupResponse,
# Transactions
TransactionResponse,
TransactionListResponse,
)
from app.modules.loyalty.schemas.stamp import (
@@ -52,6 +60,8 @@ from app.modules.loyalty.schemas.stamp import (
StampResponse,
StampRedeemRequest,
StampRedeemResponse,
StampVoidRequest,
StampVoidResponse,
)
from app.modules.loyalty.schemas.points import (
@@ -60,6 +70,10 @@ from app.modules.loyalty.schemas.points import (
PointsEarnResponse,
PointsRedeemRequest,
PointsRedeemResponse,
PointsVoidRequest,
PointsVoidResponse,
PointsAdjustRequest,
PointsAdjustResponse,
)
from app.modules.loyalty.schemas.pin import (
@@ -79,23 +93,35 @@ __all__ = [
"ProgramResponse",
"ProgramListResponse",
"PointsRewardConfig",
"TierConfig",
"ProgramStatsResponse",
"CompanyStatsResponse",
"CompanySettingsResponse",
"CompanySettingsUpdate",
# Card
"CardEnrollRequest",
"CardResponse",
"CardDetailResponse",
"CardListResponse",
"CardLookupResponse",
"TransactionResponse",
"TransactionListResponse",
# Stamp
"StampRequest",
"StampResponse",
"StampRedeemRequest",
"StampRedeemResponse",
"StampVoidRequest",
"StampVoidResponse",
# Points
"PointsEarnRequest",
"PointsEarnResponse",
"PointsRedeemRequest",
"PointsRedeemResponse",
"PointsVoidRequest",
"PointsVoidResponse",
"PointsAdjustRequest",
"PointsAdjustResponse",
# PIN
"PinCreate",
"PinUpdate",

View File

@@ -1,6 +1,11 @@
# app/modules/loyalty/schemas/card.py
"""
Pydantic schemas for loyalty card operations.
Company-based cards:
- Cards belong to a company's loyalty program
- One card per customer per company
- Can be used at any vendor within the company
"""
from datetime import datetime
@@ -29,8 +34,9 @@ class CardResponse(BaseModel):
id: int
card_number: str
customer_id: int
vendor_id: int
company_id: int
program_id: int
enrolled_at_vendor_id: int | None = None
# Stamps
stamp_count: int
@@ -64,6 +70,9 @@ class CardDetailResponse(CardResponse):
customer_name: str | None = None
customer_email: str | None = None
# Company info
company_name: str | None = None
# Program info
program_name: str
program_type: str
@@ -73,6 +82,7 @@ class CardDetailResponse(CardResponse):
last_stamp_at: datetime | None = None
last_points_at: datetime | None = None
last_redemption_at: datetime | None = None
last_activity_at: datetime | None = None
# Wallet URLs
google_wallet_url: str | None = None
@@ -98,6 +108,10 @@ class CardLookupResponse(BaseModel):
customer_name: str | None = None
customer_email: str
# Company context
company_id: int
company_name: str | None = None
# Current balances
stamp_count: int
stamps_target: int
@@ -108,6 +122,9 @@ class CardLookupResponse(BaseModel):
can_redeem_stamps: bool = False
stamp_reward_description: str | None = None
# Available points rewards
available_rewards: list[dict] = []
# Cooldown status
can_stamp: bool = True
cooldown_ends_at: datetime | None = None
@@ -116,3 +133,44 @@ class CardLookupResponse(BaseModel):
stamps_today: int = 0
max_daily_stamps: int = 5
can_earn_more_stamps: bool = True
class TransactionResponse(BaseModel):
"""Schema for a loyalty transaction."""
model_config = ConfigDict(from_attributes=True)
id: int
card_id: int
vendor_id: int | None = None
vendor_name: str | None = None
transaction_type: str
# Deltas
stamps_delta: int = 0
points_delta: int = 0
# Balances after
stamps_balance_after: int | None = None
points_balance_after: int | None = None
# Context
purchase_amount_cents: int | None = None
order_reference: str | None = None
reward_id: str | None = None
reward_description: str | None = None
notes: str | None = None
# Staff
staff_name: str | None = None
# Timestamps
transaction_at: datetime
created_at: datetime
class TransactionListResponse(BaseModel):
"""Schema for listing transactions."""
transactions: list[TransactionResponse]
total: int

View File

@@ -1,6 +1,11 @@
# app/modules/loyalty/schemas/points.py
"""
Pydantic schemas for points operations.
Company-based points:
- Points earned at any vendor count toward company total
- Points can be redeemed at any vendor within the company
- Supports voiding points for returns
"""
from pydantic import BaseModel, Field
@@ -67,6 +72,9 @@ class PointsEarnResponse(BaseModel):
points_balance: int
total_points_earned: int
# Location
vendor_id: int | None = None
class PointsRedeemRequest(BaseModel):
"""Schema for redeeming points for a reward."""
@@ -122,3 +130,108 @@ class PointsRedeemResponse(BaseModel):
card_number: str
points_balance: int
total_points_redeemed: int
# Location
vendor_id: int | None = None
class PointsVoidRequest(BaseModel):
"""Schema for voiding points (for returns)."""
card_id: int | None = Field(
None,
description="Card ID (use this or qr_code)",
)
qr_code: str | None = Field(
None,
description="QR code data from card scan",
)
card_number: str | None = Field(
None,
description="Card number (manual entry)",
)
# Points to void (use one method)
points_to_void: int | None = Field(
None,
gt=0,
description="Number of points to void",
)
original_transaction_id: int | None = Field(
None,
description="ID of original transaction to void",
)
order_reference: str | None = Field(
None,
max_length=100,
description="Order reference to find and void",
)
# Authentication
staff_pin: str | None = Field(
None,
min_length=4,
max_length=6,
description="Staff PIN for verification",
)
# Required metadata
notes: str | None = Field(
None,
max_length=500,
description="Reason for voiding",
)
class PointsVoidResponse(BaseModel):
"""Schema for points void response."""
success: bool = True
message: str = "Points voided successfully"
# Void info
points_voided: int
# Card state after void
card_id: int
card_number: str
points_balance: int
# Location
vendor_id: int | None = None
class PointsAdjustRequest(BaseModel):
"""Schema for manual points adjustment (admin)."""
points_delta: int = Field(
...,
description="Points to add (positive) or remove (negative)",
)
reason: str = Field(
...,
min_length=5,
max_length=500,
description="Reason for adjustment (required)",
)
staff_pin: str | None = Field(
None,
min_length=4,
max_length=6,
description="Staff PIN for verification",
)
class PointsAdjustResponse(BaseModel):
"""Schema for points adjustment response."""
success: bool = True
message: str = "Points adjusted successfully"
# Adjustment info
points_delta: int
# Card state after adjustment
card_id: int
card_number: str
points_balance: int

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

View File

@@ -1,6 +1,11 @@
# app/modules/loyalty/schemas/stamp.py
"""
Pydantic schemas for stamp operations.
Company-based stamps:
- Stamps earned at any vendor count toward company total
- Stamps can be redeemed at any vendor within the company
- Supports voiding stamps for returns
"""
from datetime import datetime
@@ -64,6 +69,9 @@ class StampResponse(BaseModel):
stamps_today: int
stamps_remaining_today: int
# Location
vendor_id: int | None = None
class StampRedeemRequest(BaseModel):
"""Schema for redeeming stamps for a reward."""
@@ -112,3 +120,67 @@ class StampRedeemResponse(BaseModel):
# Reward info
reward_description: str
total_redemptions: int # Lifetime redemptions for this card
# Location
vendor_id: int | None = None
class StampVoidRequest(BaseModel):
"""Schema for voiding stamps (for returns)."""
card_id: int | None = Field(
None,
description="Card ID (use this or qr_code)",
)
qr_code: str | None = Field(
None,
description="QR code data from card scan",
)
card_number: str | None = Field(
None,
description="Card number (manual entry)",
)
# Stamps to void (use one method)
stamps_to_void: int | None = Field(
None,
gt=0,
description="Number of stamps to void",
)
original_transaction_id: int | None = Field(
None,
description="ID of original transaction to void",
)
# Authentication
staff_pin: str | None = Field(
None,
min_length=4,
max_length=6,
description="Staff PIN for verification",
)
# Required metadata
notes: str | None = Field(
None,
max_length=500,
description="Reason for voiding",
)
class StampVoidResponse(BaseModel):
"""Schema for stamp void response."""
success: bool = True
message: str = "Stamps voided successfully"
# Void info
stamps_voided: int
# Card state after void
card_id: int
card_number: str
stamp_count: int
# Location
vendor_id: int | None = None