Connect the fully-implemented Google Wallet service to the loyalty module: - Create wallet class/object on customer enrollment - Sync wallet passes on stamp and points operations - Expose wallet URLs in storefront API responses - Add conditional "Add to Google Wallet" buttons on dashboard and enroll-success pages - Use platform-wide env var config (not per-merchant DB column) - Add Google service account patterns to .gitignore - Add LOYALTY_GOOGLE_* fields to app Settings - Update deployment docs and add local testing guide Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
563 lines
19 KiB
Python
563 lines
19 KiB
Python
# app/modules/loyalty/services/points_service.py
|
|
"""
|
|
Points service.
|
|
|
|
Merchant-based points operations:
|
|
- Points earned at any store count toward merchant total
|
|
- Points can be redeemed at any store within the merchant
|
|
- 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,
|
|
OrderReferenceRequiredException,
|
|
StaffPinRequiredException,
|
|
)
|
|
from app.modules.loyalty.models import 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,
|
|
*,
|
|
store_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
|
|
store_id: Store 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 store's merchant)
|
|
card = card_service.lookup_card_for_store(
|
|
db,
|
|
store_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 if order reference is required
|
|
from app.modules.loyalty.services.program_service import program_service
|
|
settings = program_service.get_merchant_settings(db, card.merchant_id)
|
|
if settings and settings.require_order_reference and not order_reference:
|
|
raise OrderReferenceRequiredException()
|
|
|
|
# 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, store_id=store_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(
|
|
merchant_id=card.merchant_id,
|
|
card_id=card.id,
|
|
store_id=store_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)
|
|
|
|
# Sync wallet passes with updated points balance
|
|
from app.modules.loyalty.services.wallet_service import wallet_service
|
|
|
|
wallet_service.sync_card_to_wallets(db, card)
|
|
|
|
logger.info(
|
|
f"Added {points_earned} points to card {card.id} at store {store_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,
|
|
"store_id": store_id,
|
|
}
|
|
|
|
def redeem_points(
|
|
self,
|
|
db: Session,
|
|
*,
|
|
store_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
|
|
store_id: Store 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 store's merchant)
|
|
card = card_service.lookup_card_for_store(
|
|
db,
|
|
store_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, store_id=store_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(
|
|
merchant_id=card.merchant_id,
|
|
card_id=card.id,
|
|
store_id=store_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)
|
|
|
|
# Sync wallet passes with updated points balance
|
|
from app.modules.loyalty.services.wallet_service import wallet_service
|
|
|
|
wallet_service.sync_card_to_wallets(db, card)
|
|
|
|
logger.info(
|
|
f"Redeemed {points_required} points from card {card.id} at store {store_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,
|
|
"store_id": store_id,
|
|
}
|
|
|
|
def void_points(
|
|
self,
|
|
db: Session,
|
|
*,
|
|
store_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
|
|
store_id: Store 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_store(
|
|
db,
|
|
store_id,
|
|
card_id=card_id,
|
|
qr_code=qr_code,
|
|
card_number=card_number,
|
|
)
|
|
|
|
program = card.program
|
|
|
|
# Check if void transactions are allowed
|
|
from app.modules.loyalty.services.program_service import program_service
|
|
settings = program_service.get_merchant_settings(db, card.merchant_id)
|
|
if settings and not settings.allow_void_transactions:
|
|
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, store_id=store_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(
|
|
merchant_id=card.merchant_id,
|
|
card_id=card.id,
|
|
store_id=store_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)
|
|
|
|
# Sync wallet passes with updated points balance
|
|
from app.modules.loyalty.services.wallet_service import wallet_service
|
|
|
|
wallet_service.sync_card_to_wallets(db, card)
|
|
|
|
logger.info(
|
|
f"Voided {actual_voided} points from card {card.id} at store {store_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,
|
|
"store_id": store_id,
|
|
}
|
|
|
|
def adjust_points(
|
|
self,
|
|
db: Session,
|
|
card_id: int,
|
|
points_delta: int,
|
|
*,
|
|
store_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/store operation).
|
|
|
|
Args:
|
|
db: Database session
|
|
card_id: Card ID
|
|
points_delta: Points to add (positive) or remove (negative)
|
|
store_id: Store 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 store provided
|
|
verified_pin = None
|
|
if program.require_staff_pin and staff_pin and store_id:
|
|
verified_pin = pin_service.verify_pin(db, program.id, staff_pin, store_id=store_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(
|
|
merchant_id=card.merchant_id,
|
|
card_id=card.id,
|
|
store_id=store_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)
|
|
|
|
# Sync wallet passes with updated points balance
|
|
from app.modules.loyalty.services.wallet_service import wallet_service
|
|
|
|
wallet_service.sync_card_to_wallets(db, 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()
|