Files
orion/app/modules/loyalty/exceptions.py
Samir Boulahtit 7d652716bb
Some checks failed
CI / ruff (push) Successful in 12s
CI / validate (push) Successful in 27s
CI / dependency-scanning (push) Successful in 31s
CI / pytest (push) Failing after 3h14m58s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
feat(loyalty): production readiness round 2 — 12 security, integrity & correctness fixes
Security:
- Fix TOCTOU race conditions: move balance/limit checks after row lock in redeem_points, add_stamp, redeem_stamps
- Add PIN ownership verification to update/delete/unlock store routes
- Gate adjust_points endpoint to merchant_owner role only

Data integrity:
- Track total_points_voided in void_points
- Add order_reference idempotency guard in earn_points

Correctness:
- Fix LoyaltyProgramAlreadyExistsException to use merchant_id parameter
- Add StorefrontProgramResponse excluding wallet IDs from public API
- Add bounds (±100000) to PointsAdjustRequest.points_delta

Audit & config:
- Add CARD_REACTIVATED transaction type with audit record
- Improve admin audit logging with actor identity and old values
- Use merchant-specific PIN lockout settings with global fallback
- Guard MerchantLoyaltySettings creation with get_or_create pattern

Tests: 27 new tests (265 total) covering all 12 items — unit and integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 23:37:23 +01:00

391 lines
12 KiB
Python

# app/modules/loyalty/exceptions.py
"""
Loyalty module exceptions.
Custom exceptions for loyalty program operations including
stamp/points management, card operations, and wallet integration.
"""
from typing import Any
from app.exceptions.base import (
BusinessLogicException,
ConflictException,
ResourceNotFoundException,
ValidationException,
)
class LoyaltyException(BusinessLogicException):
"""Base exception for loyalty module errors."""
def __init__(
self,
message: str,
error_code: str = "LOYALTY_ERROR",
details: dict[str, Any] | None = None,
):
super().__init__(message=message, error_code=error_code, details=details)
# =============================================================================
# Program Exceptions
# =============================================================================
class LoyaltyProgramNotFoundException(ResourceNotFoundException):
"""Raised when a loyalty program is not found."""
def __init__(self, identifier: str):
super().__init__("LoyaltyProgram", identifier)
class LoyaltyProgramAlreadyExistsException(ConflictException):
"""Raised when store already has a loyalty program."""
def __init__(self, merchant_id: int):
super().__init__(
message=f"Merchant {merchant_id} already has a loyalty program",
error_code="LOYALTY_PROGRAM_ALREADY_EXISTS",
details={"merchant_id": merchant_id},
)
class LoyaltyProgramInactiveException(LoyaltyException):
"""Raised when trying to use an inactive loyalty program."""
def __init__(self, program_id: int):
super().__init__(
message="Loyalty program is not active",
error_code="LOYALTY_PROGRAM_INACTIVE",
details={"program_id": program_id},
)
# =============================================================================
# Card Exceptions
# =============================================================================
class LoyaltyCardNotFoundException(ResourceNotFoundException):
"""Raised when a loyalty card is not found."""
def __init__(self, identifier: str):
super().__init__("LoyaltyCard", identifier)
class LoyaltyCardAlreadyExistsException(ConflictException):
"""Raised when customer already has a card for this program."""
def __init__(self, customer_id: int, program_id: int):
super().__init__(
message="Customer already enrolled in this loyalty program",
error_code="LOYALTY_CARD_ALREADY_EXISTS",
details={"customer_id": customer_id, "program_id": program_id},
)
class LoyaltyCardInactiveException(LoyaltyException):
"""Raised when trying to use an inactive loyalty card."""
def __init__(self, card_id: int):
super().__init__(
message="Loyalty card is not active",
error_code="LOYALTY_CARD_INACTIVE",
details={"card_id": card_id},
)
# =============================================================================
# Anti-Fraud Exceptions
# =============================================================================
class StaffPinNotFoundException(ResourceNotFoundException):
"""Raised when a staff PIN is not found."""
def __init__(self, identifier: str):
super().__init__("StaffPin", identifier)
class StaffPinRequiredException(LoyaltyException):
"""Raised when staff PIN is required but not provided."""
def __init__(self):
super().__init__(
message="Staff PIN is required for this operation",
error_code="STAFF_PIN_REQUIRED",
)
class InvalidStaffPinException(LoyaltyException):
"""Raised when staff PIN is invalid."""
def __init__(self, remaining_attempts: int | None = None):
details = {}
if remaining_attempts is not None:
details["remaining_attempts"] = remaining_attempts
super().__init__(
message="Invalid staff PIN",
error_code="INVALID_STAFF_PIN",
details=details if details else None,
)
class StaffPinLockedException(LoyaltyException):
"""Raised when staff PIN is locked due to too many failed attempts."""
def __init__(self, locked_until: str):
super().__init__(
message="Staff PIN is locked due to too many failed attempts",
error_code="STAFF_PIN_LOCKED",
details={"locked_until": locked_until},
)
class StampCooldownException(LoyaltyException):
"""Raised when trying to stamp before cooldown period ends."""
def __init__(self, cooldown_ends: str, cooldown_minutes: int):
super().__init__(
message=f"Please wait {cooldown_minutes} minutes between stamps",
error_code="STAMP_COOLDOWN",
details={"cooldown_ends": cooldown_ends, "cooldown_minutes": cooldown_minutes},
)
class DailyStampLimitException(LoyaltyException):
"""Raised when daily stamp limit is exceeded."""
def __init__(self, max_daily_stamps: int, stamps_today: int):
super().__init__(
message=f"Daily stamp limit of {max_daily_stamps} reached",
error_code="DAILY_STAMP_LIMIT",
details={"max_daily_stamps": max_daily_stamps, "stamps_today": stamps_today},
)
# =============================================================================
# Redemption Exceptions
# =============================================================================
class InsufficientStampsException(LoyaltyException):
"""Raised when card doesn't have enough stamps to redeem."""
def __init__(self, current_stamps: int, required_stamps: int):
super().__init__(
message=f"Insufficient stamps: {current_stamps}/{required_stamps}",
error_code="INSUFFICIENT_STAMPS",
details={"current_stamps": current_stamps, "required_stamps": required_stamps},
)
class InsufficientPointsException(LoyaltyException):
"""Raised when card doesn't have enough points to redeem."""
def __init__(self, current_points: int, required_points: int):
super().__init__(
message=f"Insufficient points: {current_points}/{required_points}",
error_code="INSUFFICIENT_POINTS",
details={"current_points": current_points, "required_points": required_points},
)
class InvalidRewardException(LoyaltyException):
"""Raised when trying to redeem an invalid or unavailable reward."""
def __init__(self, reward_id: str):
super().__init__(
message="Invalid or unavailable reward",
error_code="INVALID_REWARD",
details={"reward_id": reward_id},
)
# =============================================================================
# Wallet Exceptions
# =============================================================================
class WalletIntegrationException(LoyaltyException):
"""Raised when wallet integration fails."""
def __init__(self, provider: str, message: str):
super().__init__(
message=f"Wallet integration error: {message}",
error_code="WALLET_INTEGRATION_ERROR",
details={"provider": provider},
)
class GoogleWalletNotConfiguredException(LoyaltyException):
"""Raised when Google Wallet is not configured."""
def __init__(self):
super().__init__(
message="Google Wallet is not configured for this program",
error_code="GOOGLE_WALLET_NOT_CONFIGURED",
)
class AppleWalletNotConfiguredException(LoyaltyException):
"""Raised when Apple Wallet is not configured."""
def __init__(self):
super().__init__(
message="Apple Wallet is not configured for this program",
error_code="APPLE_WALLET_NOT_CONFIGURED",
)
# =============================================================================
# Authentication Exceptions
# =============================================================================
class InvalidAppleAuthTokenException(LoyaltyException):
"""Raised when Apple Wallet auth token is invalid."""
def __init__(self):
super().__init__(
message="Invalid Apple Wallet authentication token",
error_code="INVALID_APPLE_AUTH_TOKEN",
)
self.status_code = 401
class ApplePassGenerationException(LoyaltyException):
"""Raised when Apple Wallet pass generation fails."""
def __init__(self, card_id: int):
super().__init__(
message="Failed to generate Apple Wallet pass",
error_code="APPLE_PASS_GENERATION_FAILED",
details={"card_id": card_id},
)
self.status_code = 500
class DeviceRegistrationException(LoyaltyException):
"""Raised when Apple Wallet device registration/unregistration fails."""
def __init__(self, device_id: str, operation: str = "register"):
super().__init__(
message=f"Failed to {operation} device",
error_code="DEVICE_REGISTRATION_FAILED",
details={"device_id": device_id, "operation": operation},
)
self.status_code = 500
# =============================================================================
# Enrollment Exceptions
# =============================================================================
class SelfEnrollmentDisabledException(LoyaltyException):
"""Raised when self-enrollment is not allowed."""
def __init__(self):
super().__init__(
message="Self-enrollment is not available",
error_code="SELF_ENROLLMENT_DISABLED",
)
self.status_code = 403
class CustomerNotFoundByEmailException(LoyaltyException):
"""Raised when customer is not found by email during enrollment."""
def __init__(self, email: str):
super().__init__(
message="Customer not found with provided email",
error_code="CUSTOMER_NOT_FOUND_BY_EMAIL",
details={"email": email},
)
class CustomerIdentifierRequiredException(LoyaltyException):
"""Raised when neither customer_id nor email is provided."""
def __init__(self):
super().__init__(
message="Either customer_id or email is required",
error_code="CUSTOMER_IDENTIFIER_REQUIRED",
)
# =============================================================================
# Order Exceptions
# =============================================================================
class OrderReferenceRequiredException(LoyaltyException):
"""Raised when order reference is required but not provided."""
def __init__(self):
super().__init__(
message="Order reference required",
error_code="ORDER_REFERENCE_REQUIRED",
)
# =============================================================================
# Validation Exceptions
# =============================================================================
class LoyaltyValidationException(ValidationException): # noqa: MOD025
"""Raised when loyalty data validation fails."""
def __init__(
self,
message: str = "Loyalty validation failed",
field: str | None = None,
details: dict[str, Any] | None = None,
):
super().__init__(message=message, field=field, details=details)
self.error_code = "LOYALTY_VALIDATION_FAILED"
__all__ = [
# Base
"LoyaltyException",
# Program
"LoyaltyProgramNotFoundException",
"LoyaltyProgramAlreadyExistsException",
"LoyaltyProgramInactiveException",
# Card
"LoyaltyCardNotFoundException",
"LoyaltyCardAlreadyExistsException",
"LoyaltyCardInactiveException",
# Anti-Fraud
"StaffPinNotFoundException",
"StaffPinRequiredException",
"InvalidStaffPinException",
"StaffPinLockedException",
"StampCooldownException",
"DailyStampLimitException",
# Redemption
"InsufficientStampsException",
"InsufficientPointsException",
"InvalidRewardException",
# Wallet
"WalletIntegrationException",
"GoogleWalletNotConfiguredException",
"AppleWalletNotConfiguredException",
# Authentication
"InvalidAppleAuthTokenException",
"ApplePassGenerationException",
"DeviceRegistrationException",
# Enrollment
"SelfEnrollmentDisabledException",
"CustomerNotFoundByEmailException",
"CustomerIdentifierRequiredException",
# Order
"OrderReferenceRequiredException",
# Validation
"LoyaltyValidationException",
]