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>
357 lines
11 KiB
Python
357 lines
11 KiB
Python
# 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()
|