Files
orion/app/modules/loyalty/services/points_service.py
Samir Boulahtit d8f3338bc8 feat(loyalty): implement Phase 2 - company-wide points system
Complete implementation of loyalty module Phase 2 features:

Database & Models:
- Add company_id to LoyaltyProgram for chain-wide loyalty
- Add company_id to LoyaltyCard for multi-location support
- Add CompanyLoyaltySettings model for admin-controlled settings
- Add points expiration, welcome bonus, and minimum redemption fields
- Add POINTS_EXPIRED, WELCOME_BONUS transaction types

Services:
- Update program_service for company-based queries
- Update card_service with enrollment and welcome bonus
- Update points_service with void_points for returns
- Update stamp_service for company context
- Update pin_service for company-wide operations

API Endpoints:
- Admin: Program listing with stats, company detail views
- Vendor: Terminal operations, card management, settings
- Storefront: Customer card/transactions, self-enrollment

UI Templates:
- Admin: Programs dashboard, company detail, settings
- Vendor: Terminal, cards list, card detail, settings, stats, enrollment
- Storefront: Dashboard, history, enrollment, success pages

Background Tasks:
- Point expiration task (daily, based on inactivity)
- Wallet sync task (hourly)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 22:10:27 +01:00

530 lines
18 KiB
Python

# app/modules/loyalty/services/points_service.py
"""
Points service.
Company-based points operations:
- Points earned at any vendor count toward company total
- Points can be redeemed at any vendor within the company
- Supports voiding points for returns
Handles points operations including:
- Earning points from purchases
- Redeeming points for rewards
- Voiding points (for returns)
- 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,
*,
vendor_id: int,
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
vendor_id: Vendor ID (where purchase is being made)
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 (validates it belongs to vendor's company)
card = card_service.lookup_card_for_vendor(
db,
vendor_id,
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)
# Check minimum purchase amount
if program.minimum_purchase_cents > 0 and purchase_amount_cents < program.minimum_purchase_cents:
return {
"success": True,
"message": f"Purchase below minimum of €{program.minimum_purchase_cents/100:.2f}",
"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,
}
# 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, vendor_id=vendor_id)
# 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
card.last_activity_at = now
# Create transaction
transaction = LoyaltyTransaction(
company_id=card.company_id,
card_id=card.id,
vendor_id=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} at vendor {vendor_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,
"vendor_id": vendor_id,
}
def redeem_points(
self,
db: Session,
*,
vendor_id: int,
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
vendor_id: Vendor ID (where redemption is happening)
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 (validates it belongs to vendor's company)
card = card_service.lookup_card_for_vendor(
db,
vendor_id,
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 minimum redemption
if points_required < program.minimum_redemption_points:
raise InvalidRewardException(reward_id)
# 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, vendor_id=vendor_id)
# Redeem points
now = datetime.now(UTC)
card.points_balance -= points_required
card.points_redeemed += points_required
card.last_redemption_at = now
card.last_activity_at = now
# Create transaction
transaction = LoyaltyTransaction(
company_id=card.company_id,
card_id=card.id,
vendor_id=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} at vendor {vendor_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,
"vendor_id": vendor_id,
}
def void_points(
self,
db: Session,
*,
vendor_id: int,
card_id: int | None = None,
qr_code: str | None = None,
card_number: str | None = None,
points_to_void: int | None = None,
original_transaction_id: int | None = None,
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:
"""
Void points for a return.
Args:
db: Database session
vendor_id: Vendor ID
card_id: Card ID
qr_code: QR code data
card_number: Card number
points_to_void: Number of points to void (if not using original_transaction_id)
original_transaction_id: ID of original earn transaction to void
order_reference: Order reference (to find original transaction)
staff_pin: Staff PIN for verification
ip_address: Request IP for audit
user_agent: Request user agent for audit
notes: Reason for voiding
Returns:
Dict with operation result
"""
# Look up the card
card = card_service.lookup_card_for_vendor(
db,
vendor_id,
card_id=card_id,
qr_code=qr_code,
card_number=card_number,
)
program = card.program
# 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, vendor_id=vendor_id)
# Determine points to void
original_transaction = None
if original_transaction_id:
original_transaction = (
db.query(LoyaltyTransaction)
.filter(
LoyaltyTransaction.id == original_transaction_id,
LoyaltyTransaction.card_id == card.id,
LoyaltyTransaction.transaction_type == TransactionType.POINTS_EARNED.value,
)
.first()
)
if original_transaction:
points_to_void = original_transaction.points_delta
elif order_reference:
original_transaction = (
db.query(LoyaltyTransaction)
.filter(
LoyaltyTransaction.order_reference == order_reference,
LoyaltyTransaction.card_id == card.id,
LoyaltyTransaction.transaction_type == TransactionType.POINTS_EARNED.value,
)
.first()
)
if original_transaction:
points_to_void = original_transaction.points_delta
if not points_to_void or points_to_void <= 0:
return {
"success": False,
"message": "No points to void",
"card_id": card.id,
"card_number": card.card_number,
"points_balance": card.points_balance,
}
# Void the points (can reduce balance below what was earned)
now = datetime.now(UTC)
actual_voided = min(points_to_void, card.points_balance)
card.points_balance = max(0, card.points_balance - points_to_void)
card.last_activity_at = now
# Create void transaction
transaction = LoyaltyTransaction(
company_id=card.company_id,
card_id=card.id,
vendor_id=vendor_id,
staff_pin_id=verified_pin.id if verified_pin else None,
transaction_type=TransactionType.POINTS_VOIDED.value,
points_delta=-actual_voided,
stamps_balance_after=card.stamp_count,
points_balance_after=card.points_balance,
related_transaction_id=original_transaction.id if original_transaction else None,
order_reference=order_reference,
ip_address=ip_address,
user_agent=user_agent,
notes=notes or "Points voided for return",
transaction_at=now,
)
db.add(transaction)
db.commit()
db.refresh(card)
logger.info(
f"Voided {actual_voided} points from card {card.id} at vendor {vendor_id} "
f"(balance: {card.points_balance})"
)
return {
"success": True,
"message": "Points voided successfully",
"points_voided": actual_voided,
"card_id": card.id,
"card_number": card.card_number,
"points_balance": card.points_balance,
"vendor_id": vendor_id,
}
def adjust_points(
self,
db: Session,
card_id: int,
points_delta: int,
*,
vendor_id: int | None = None,
reason: str,
staff_pin: str | None = None,
ip_address: str | None = None,
user_agent: str | None = None,
) -> dict:
"""
Manually adjust points (admin/vendor operation).
Args:
db: Database session
card_id: Card ID
points_delta: Points to add (positive) or remove (negative)
vendor_id: Vendor ID
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 and vendor provided
verified_pin = None
if program.require_staff_pin and staff_pin and vendor_id:
verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id)
# Apply adjustment
now = datetime.now(UTC)
card.points_balance += points_delta
card.last_activity_at = now
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(
company_id=card.company_id,
card_id=card.id,
vendor_id=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()