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:
356
app/modules/loyalty/services/points_service.py
Normal file
356
app/modules/loyalty/services/points_service.py
Normal file
@@ -0,0 +1,356 @@
|
||||
# app/modules/loyalty/services/points_service.py
|
||||
"""
|
||||
Points service.
|
||||
|
||||
Handles points operations including:
|
||||
- Earning points from purchases
|
||||
- Redeeming points for rewards
|
||||
- Points balance management
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.loyalty.exceptions import (
|
||||
InsufficientPointsException,
|
||||
InvalidRewardException,
|
||||
LoyaltyCardInactiveException,
|
||||
LoyaltyProgramInactiveException,
|
||||
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 PointsService:
|
||||
"""Service for points operations."""
|
||||
|
||||
def earn_points(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
card_id: int | None = None,
|
||||
qr_code: str | None = None,
|
||||
card_number: str | None = None,
|
||||
purchase_amount_cents: int,
|
||||
order_reference: str | None = None,
|
||||
staff_pin: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
notes: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Earn points from a purchase.
|
||||
|
||||
Points are calculated based on the program's points_per_euro rate.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
card_id: Card ID
|
||||
qr_code: QR code data
|
||||
card_number: Card number
|
||||
purchase_amount_cents: Purchase amount in cents
|
||||
order_reference: Order reference for tracking
|
||||
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
|
||||
"""
|
||||
# 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 points are enabled
|
||||
if not program.is_points_enabled:
|
||||
logger.warning(f"Points attempted on stamps-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)
|
||||
|
||||
# Calculate points
|
||||
# points_per_euro is per full euro, so divide cents by 100
|
||||
purchase_euros = purchase_amount_cents / 100
|
||||
points_earned = int(purchase_euros * program.points_per_euro)
|
||||
|
||||
if points_earned <= 0:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Purchase too small to earn points",
|
||||
"points_earned": 0,
|
||||
"points_per_euro": program.points_per_euro,
|
||||
"purchase_amount_cents": purchase_amount_cents,
|
||||
"card_id": card.id,
|
||||
"card_number": card.card_number,
|
||||
"points_balance": card.points_balance,
|
||||
"total_points_earned": card.total_points_earned,
|
||||
}
|
||||
|
||||
# Add points
|
||||
now = datetime.now(UTC)
|
||||
card.points_balance += points_earned
|
||||
card.total_points_earned += points_earned
|
||||
card.last_points_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.POINTS_EARNED.value,
|
||||
points_delta=points_earned,
|
||||
stamps_balance_after=card.stamp_count,
|
||||
points_balance_after=card.points_balance,
|
||||
purchase_amount_cents=purchase_amount_cents,
|
||||
order_reference=order_reference,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
notes=notes,
|
||||
transaction_at=now,
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
logger.info(
|
||||
f"Added {points_earned} points to card {card.id} "
|
||||
f"(purchase: €{purchase_euros:.2f}, balance: {card.points_balance})"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Points earned successfully",
|
||||
"points_earned": points_earned,
|
||||
"points_per_euro": program.points_per_euro,
|
||||
"purchase_amount_cents": purchase_amount_cents,
|
||||
"card_id": card.id,
|
||||
"card_number": card.card_number,
|
||||
"points_balance": card.points_balance,
|
||||
"total_points_earned": card.total_points_earned,
|
||||
}
|
||||
|
||||
def redeem_points(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
card_id: int | None = None,
|
||||
qr_code: str | None = None,
|
||||
card_number: str | None = None,
|
||||
reward_id: str,
|
||||
staff_pin: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
notes: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Redeem points for a reward.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
card_id: Card ID
|
||||
qr_code: QR code data
|
||||
card_number: Card number
|
||||
reward_id: ID of the reward to redeem
|
||||
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:
|
||||
InvalidRewardException: Reward not found or inactive
|
||||
InsufficientPointsException: Not enough points
|
||||
"""
|
||||
# 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)
|
||||
|
||||
# Find the reward
|
||||
reward = program.get_points_reward(reward_id)
|
||||
if not reward:
|
||||
raise InvalidRewardException(reward_id)
|
||||
|
||||
if not reward.get("is_active", True):
|
||||
raise InvalidRewardException(reward_id)
|
||||
|
||||
points_required = reward["points_required"]
|
||||
reward_name = reward["name"]
|
||||
|
||||
# Check if enough points
|
||||
if card.points_balance < points_required:
|
||||
raise InsufficientPointsException(card.points_balance, points_required)
|
||||
|
||||
# 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 points
|
||||
now = datetime.now(UTC)
|
||||
card.points_balance -= points_required
|
||||
card.points_redeemed += points_required
|
||||
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.POINTS_REDEEMED.value,
|
||||
points_delta=-points_required,
|
||||
stamps_balance_after=card.stamp_count,
|
||||
points_balance_after=card.points_balance,
|
||||
reward_id=reward_id,
|
||||
reward_description=reward_name,
|
||||
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 {points_required} points from card {card.id} "
|
||||
f"(reward: {reward_name}, balance: {card.points_balance})"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Reward redeemed successfully",
|
||||
"reward_id": reward_id,
|
||||
"reward_name": reward_name,
|
||||
"points_spent": points_required,
|
||||
"card_id": card.id,
|
||||
"card_number": card.card_number,
|
||||
"points_balance": card.points_balance,
|
||||
"total_points_redeemed": card.points_redeemed,
|
||||
}
|
||||
|
||||
def adjust_points(
|
||||
self,
|
||||
db: Session,
|
||||
card_id: int,
|
||||
points_delta: int,
|
||||
*,
|
||||
reason: str,
|
||||
staff_pin: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Manually adjust points (admin operation).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
card_id: Card ID
|
||||
points_delta: Points to add (positive) or remove (negative)
|
||||
reason: Reason for adjustment
|
||||
staff_pin: Staff PIN for verification
|
||||
ip_address: Request IP for audit
|
||||
user_agent: Request user agent for audit
|
||||
|
||||
Returns:
|
||||
Dict with operation result
|
||||
"""
|
||||
card = card_service.require_card(db, card_id)
|
||||
program = card.program
|
||||
|
||||
# Verify staff PIN if required
|
||||
verified_pin = None
|
||||
if program.require_staff_pin and staff_pin:
|
||||
verified_pin = pin_service.verify_pin(db, program.id, staff_pin)
|
||||
|
||||
# Apply adjustment
|
||||
now = datetime.now(UTC)
|
||||
card.points_balance += points_delta
|
||||
|
||||
if points_delta > 0:
|
||||
card.total_points_earned += points_delta
|
||||
else:
|
||||
# Negative adjustment - don't add to redeemed, just reduce balance
|
||||
pass
|
||||
|
||||
# Ensure balance doesn't go negative
|
||||
if card.points_balance < 0:
|
||||
card.points_balance = 0
|
||||
|
||||
# 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.POINTS_ADJUSTMENT.value,
|
||||
points_delta=points_delta,
|
||||
stamps_balance_after=card.stamp_count,
|
||||
points_balance_after=card.points_balance,
|
||||
notes=reason,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
transaction_at=now,
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
logger.info(
|
||||
f"Adjusted points for card {card.id} by {points_delta:+d} "
|
||||
f"(reason: {reason}, balance: {card.points_balance})"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Points adjusted successfully",
|
||||
"points_delta": points_delta,
|
||||
"card_id": card.id,
|
||||
"card_number": card.card_number,
|
||||
"points_balance": card.points_balance,
|
||||
}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
points_service = PointsService()
|
||||
Reference in New Issue
Block a user