Files
orion/app/modules/loyalty/services/points_service.py
Samir Boulahtit b5a803cde8 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>
2026-01-28 23:04:00 +01:00

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()