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/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