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>
280 lines
9.0 KiB
Python
280 lines
9.0 KiB
Python
# 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()
|