feat(loyalty): implement complete loyalty module MVP
Add stamp-based and points-based loyalty programs for vendors with: Database Models (5 tables): - loyalty_programs: Vendor program configuration - loyalty_cards: Customer cards with stamp/point balances - loyalty_transactions: Immutable audit log - staff_pins: Fraud prevention PINs (bcrypt hashed) - apple_device_registrations: Apple Wallet push tokens Services: - program_service: Program CRUD and statistics - card_service: Customer enrollment and card lookup - stamp_service: Stamp operations with anti-fraud checks - points_service: Points earning and redemption - pin_service: Staff PIN management with lockout - wallet_service: Unified wallet abstraction - google_wallet_service: Google Wallet API integration - apple_wallet_service: Apple Wallet .pkpass generation API Routes: - Admin: /api/v1/admin/loyalty/* (programs list, stats) - Vendor: /api/v1/vendor/loyalty/* (stamp, points, cards, PINs) - Public: /api/v1/loyalty/* (enrollment, Apple Web Service) Anti-Fraud Features: - Staff PIN verification (configurable per program) - Cooldown period between stamps (default 15 min) - Daily stamp limits (default 5/day) - PIN lockout after failed attempts Wallet Integration: - Google Wallet: LoyaltyClass and LoyaltyObject management - Apple Wallet: .pkpass generation with PKCS#7 signing - Apple Web Service endpoints for device registration/updates Also includes: - Alembic migration for all tables with indexes - Localization files (en, fr, de, lu) - Module documentation - Phase 2 interface and user journey plan Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
279
app/modules/loyalty/services/stamp_service.py
Normal file
279
app/modules/loyalty/services/stamp_service.py
Normal file
@@ -0,0 +1,279 @@
|
||||
# app/modules/loyalty/services/stamp_service.py
|
||||
"""
|
||||
Stamp service.
|
||||
|
||||
Handles stamp operations including:
|
||||
- Adding stamps with anti-fraud checks
|
||||
- Redeeming stamps for rewards
|
||||
- Daily limit tracking
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.loyalty.config import config
|
||||
from app.modules.loyalty.exceptions import (
|
||||
DailyStampLimitException,
|
||||
InsufficientStampsException,
|
||||
LoyaltyCardInactiveException,
|
||||
LoyaltyProgramInactiveException,
|
||||
StampCooldownException,
|
||||
StaffPinRequiredException,
|
||||
)
|
||||
from app.modules.loyalty.models import LoyaltyCard, LoyaltyTransaction, TransactionType
|
||||
from app.modules.loyalty.services.card_service import card_service
|
||||
from app.modules.loyalty.services.pin_service import pin_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StampService:
|
||||
"""Service for stamp operations."""
|
||||
|
||||
def add_stamp(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
card_id: int | None = None,
|
||||
qr_code: str | None = None,
|
||||
card_number: str | None = None,
|
||||
staff_pin: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
notes: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Add a stamp to a loyalty card.
|
||||
|
||||
Performs all anti-fraud checks:
|
||||
- Staff PIN verification (if required)
|
||||
- Cooldown period check
|
||||
- Daily limit check
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
card_id: Card ID
|
||||
qr_code: QR code data
|
||||
card_number: Card number
|
||||
staff_pin: Staff PIN for verification
|
||||
ip_address: Request IP for audit
|
||||
user_agent: Request user agent for audit
|
||||
notes: Optional notes
|
||||
|
||||
Returns:
|
||||
Dict with operation result
|
||||
|
||||
Raises:
|
||||
LoyaltyCardNotFoundException: Card not found
|
||||
LoyaltyCardInactiveException: Card is inactive
|
||||
LoyaltyProgramInactiveException: Program is inactive
|
||||
StaffPinRequiredException: PIN required but not provided
|
||||
InvalidStaffPinException: PIN is invalid
|
||||
StampCooldownException: Cooldown period not elapsed
|
||||
DailyStampLimitException: Daily limit reached
|
||||
"""
|
||||
# Look up the card
|
||||
card = card_service.lookup_card(
|
||||
db,
|
||||
card_id=card_id,
|
||||
qr_code=qr_code,
|
||||
card_number=card_number,
|
||||
)
|
||||
|
||||
# Validate card and program
|
||||
if not card.is_active:
|
||||
raise LoyaltyCardInactiveException(card.id)
|
||||
|
||||
program = card.program
|
||||
if not program.is_active:
|
||||
raise LoyaltyProgramInactiveException(program.id)
|
||||
|
||||
# Check if stamps are enabled
|
||||
if not program.is_stamps_enabled:
|
||||
logger.warning(f"Stamp attempted on points-only program {program.id}")
|
||||
raise LoyaltyCardInactiveException(card.id)
|
||||
|
||||
# Verify staff PIN if required
|
||||
verified_pin = None
|
||||
if program.require_staff_pin:
|
||||
if not staff_pin:
|
||||
raise StaffPinRequiredException()
|
||||
verified_pin = pin_service.verify_pin(db, program.id, staff_pin)
|
||||
|
||||
# Check cooldown
|
||||
now = datetime.now(UTC)
|
||||
if card.last_stamp_at:
|
||||
cooldown_ends = card.last_stamp_at + timedelta(minutes=program.cooldown_minutes)
|
||||
if now < cooldown_ends:
|
||||
raise StampCooldownException(
|
||||
cooldown_ends.isoformat(),
|
||||
program.cooldown_minutes,
|
||||
)
|
||||
|
||||
# Check daily limit
|
||||
stamps_today = card_service.get_stamps_today(db, card.id)
|
||||
if stamps_today >= program.max_daily_stamps:
|
||||
raise DailyStampLimitException(program.max_daily_stamps, stamps_today)
|
||||
|
||||
# Add the stamp
|
||||
card.stamp_count += 1
|
||||
card.total_stamps_earned += 1
|
||||
card.last_stamp_at = now
|
||||
|
||||
# Check if reward earned
|
||||
reward_earned = card.stamp_count >= program.stamps_target
|
||||
|
||||
# Create transaction
|
||||
transaction = LoyaltyTransaction(
|
||||
card_id=card.id,
|
||||
vendor_id=card.vendor_id,
|
||||
staff_pin_id=verified_pin.id if verified_pin else None,
|
||||
transaction_type=TransactionType.STAMP_EARNED.value,
|
||||
stamps_delta=1,
|
||||
stamps_balance_after=card.stamp_count,
|
||||
points_balance_after=card.points_balance,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
notes=notes,
|
||||
transaction_at=now,
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
stamps_today += 1
|
||||
|
||||
logger.info(
|
||||
f"Added stamp to card {card.id} "
|
||||
f"(stamps: {card.stamp_count}/{program.stamps_target}, "
|
||||
f"today: {stamps_today}/{program.max_daily_stamps})"
|
||||
)
|
||||
|
||||
# Calculate next stamp availability
|
||||
next_stamp_at = now + timedelta(minutes=program.cooldown_minutes)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Stamp added successfully",
|
||||
"card_id": card.id,
|
||||
"card_number": card.card_number,
|
||||
"stamp_count": card.stamp_count,
|
||||
"stamps_target": program.stamps_target,
|
||||
"stamps_until_reward": max(0, program.stamps_target - card.stamp_count),
|
||||
"reward_earned": reward_earned,
|
||||
"reward_description": program.stamps_reward_description if reward_earned else None,
|
||||
"next_stamp_available_at": next_stamp_at,
|
||||
"stamps_today": stamps_today,
|
||||
"stamps_remaining_today": max(0, program.max_daily_stamps - stamps_today),
|
||||
}
|
||||
|
||||
def redeem_stamps(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
card_id: int | None = None,
|
||||
qr_code: str | None = None,
|
||||
card_number: str | None = None,
|
||||
staff_pin: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
notes: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Redeem stamps for a reward.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
card_id: Card ID
|
||||
qr_code: QR code data
|
||||
card_number: Card number
|
||||
staff_pin: Staff PIN for verification
|
||||
ip_address: Request IP for audit
|
||||
user_agent: Request user agent for audit
|
||||
notes: Optional notes
|
||||
|
||||
Returns:
|
||||
Dict with operation result
|
||||
|
||||
Raises:
|
||||
LoyaltyCardNotFoundException: Card not found
|
||||
InsufficientStampsException: Not enough stamps
|
||||
StaffPinRequiredException: PIN required but not provided
|
||||
"""
|
||||
# Look up the card
|
||||
card = card_service.lookup_card(
|
||||
db,
|
||||
card_id=card_id,
|
||||
qr_code=qr_code,
|
||||
card_number=card_number,
|
||||
)
|
||||
|
||||
# Validate card and program
|
||||
if not card.is_active:
|
||||
raise LoyaltyCardInactiveException(card.id)
|
||||
|
||||
program = card.program
|
||||
if not program.is_active:
|
||||
raise LoyaltyProgramInactiveException(program.id)
|
||||
|
||||
# Check if enough stamps
|
||||
if card.stamp_count < program.stamps_target:
|
||||
raise InsufficientStampsException(card.stamp_count, program.stamps_target)
|
||||
|
||||
# Verify staff PIN if required
|
||||
verified_pin = None
|
||||
if program.require_staff_pin:
|
||||
if not staff_pin:
|
||||
raise StaffPinRequiredException()
|
||||
verified_pin = pin_service.verify_pin(db, program.id, staff_pin)
|
||||
|
||||
# Redeem stamps
|
||||
now = datetime.now(UTC)
|
||||
stamps_redeemed = program.stamps_target
|
||||
card.stamp_count -= stamps_redeemed
|
||||
card.stamps_redeemed += 1
|
||||
card.last_redemption_at = now
|
||||
|
||||
# Create transaction
|
||||
transaction = LoyaltyTransaction(
|
||||
card_id=card.id,
|
||||
vendor_id=card.vendor_id,
|
||||
staff_pin_id=verified_pin.id if verified_pin else None,
|
||||
transaction_type=TransactionType.STAMP_REDEEMED.value,
|
||||
stamps_delta=-stamps_redeemed,
|
||||
stamps_balance_after=card.stamp_count,
|
||||
points_balance_after=card.points_balance,
|
||||
reward_description=program.stamps_reward_description,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
notes=notes,
|
||||
transaction_at=now,
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
logger.info(
|
||||
f"Redeemed stamps from card {card.id} "
|
||||
f"(reward: {program.stamps_reward_description}, "
|
||||
f"total redemptions: {card.stamps_redeemed})"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Reward redeemed successfully",
|
||||
"card_id": card.id,
|
||||
"card_number": card.card_number,
|
||||
"stamp_count": card.stamp_count,
|
||||
"stamps_target": program.stamps_target,
|
||||
"reward_description": program.stamps_reward_description,
|
||||
"total_redemptions": card.stamps_redeemed,
|
||||
}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
stamp_service = StampService()
|
||||
Reference in New Issue
Block a user