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>
436 lines
15 KiB
Python
436 lines
15 KiB
Python
# app/modules/loyalty/services/stamp_service.py
|
|
"""
|
|
Stamp service.
|
|
|
|
Merchant-based stamp operations:
|
|
- Stamps earned at any store count toward merchant total
|
|
- Stamps can be redeemed at any store within the merchant
|
|
- Supports voiding stamps for returns
|
|
|
|
Handles stamp operations including:
|
|
- Adding stamps with anti-fraud checks
|
|
- Redeeming stamps for rewards
|
|
- Voiding stamps (for returns)
|
|
- Daily limit tracking
|
|
"""
|
|
|
|
import logging
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.modules.loyalty.exceptions import (
|
|
DailyStampLimitException,
|
|
InsufficientStampsException,
|
|
LoyaltyCardInactiveException,
|
|
LoyaltyProgramInactiveException,
|
|
StaffPinRequiredException,
|
|
StampCooldownException,
|
|
)
|
|
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 StampService:
|
|
"""Service for stamp operations."""
|
|
|
|
def add_stamp(
|
|
self,
|
|
db: Session,
|
|
*,
|
|
store_id: int,
|
|
card_id: int | None = None,
|
|
qr_code: str | None = None,
|
|
card_number: str | None = None,
|
|
staff_pin: str | None = None,
|
|
ip_address: str | None = None,
|
|
user_agent: str | None = None,
|
|
notes: str | None = None,
|
|
) -> dict:
|
|
"""
|
|
Add a stamp to a loyalty card.
|
|
|
|
Performs all anti-fraud checks:
|
|
- Staff PIN verification (if required)
|
|
- Cooldown period check
|
|
- Daily limit check
|
|
|
|
Args:
|
|
db: Database session
|
|
store_id: Store ID (where stamp is being added)
|
|
card_id: Card ID
|
|
qr_code: QR code data
|
|
card_number: Card number
|
|
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:
|
|
LoyaltyCardNotFoundException: Card not found
|
|
LoyaltyCardInactiveException: Card is inactive
|
|
LoyaltyProgramInactiveException: Program is inactive
|
|
StaffPinRequiredException: PIN required but not provided
|
|
InvalidStaffPinException: PIN is invalid
|
|
StampCooldownException: Cooldown period not elapsed
|
|
DailyStampLimitException: Daily limit reached
|
|
"""
|
|
# 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 stamps are enabled
|
|
if not program.is_stamps_enabled:
|
|
logger.warning(f"Stamp attempted on points-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, store_id=store_id)
|
|
|
|
# Check cooldown
|
|
now = datetime.now(UTC)
|
|
if card.last_stamp_at:
|
|
cooldown_ends = card.last_stamp_at + timedelta(minutes=program.cooldown_minutes)
|
|
if now < cooldown_ends:
|
|
raise StampCooldownException(
|
|
cooldown_ends.isoformat(),
|
|
program.cooldown_minutes,
|
|
)
|
|
|
|
# Check daily limit
|
|
stamps_today = card_service.get_stamps_today(db, card.id)
|
|
if stamps_today >= program.max_daily_stamps:
|
|
raise DailyStampLimitException(program.max_daily_stamps, stamps_today)
|
|
|
|
# Add the stamp
|
|
card.stamp_count += 1
|
|
card.total_stamps_earned += 1
|
|
card.last_stamp_at = now
|
|
card.last_activity_at = now
|
|
|
|
# Check if reward earned
|
|
reward_earned = card.stamp_count >= program.stamps_target
|
|
|
|
# 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.STAMP_EARNED.value,
|
|
stamps_delta=1,
|
|
stamps_balance_after=card.stamp_count,
|
|
points_balance_after=card.points_balance,
|
|
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 stamp count
|
|
from app.modules.loyalty.services.wallet_service import wallet_service
|
|
|
|
wallet_service.sync_card_to_wallets(db, card)
|
|
|
|
stamps_today += 1
|
|
|
|
logger.info(
|
|
f"Added stamp to card {card.id} at store {store_id} "
|
|
f"(stamps: {card.stamp_count}/{program.stamps_target}, "
|
|
f"today: {stamps_today}/{program.max_daily_stamps})"
|
|
)
|
|
|
|
# Calculate next stamp availability
|
|
next_stamp_at = now + timedelta(minutes=program.cooldown_minutes)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Stamp added successfully",
|
|
"card_id": card.id,
|
|
"card_number": card.card_number,
|
|
"stamp_count": card.stamp_count,
|
|
"stamps_target": program.stamps_target,
|
|
"stamps_until_reward": max(0, program.stamps_target - card.stamp_count),
|
|
"reward_earned": reward_earned,
|
|
"reward_description": program.stamps_reward_description if reward_earned else None,
|
|
"next_stamp_available_at": next_stamp_at,
|
|
"stamps_today": stamps_today,
|
|
"stamps_remaining_today": max(0, program.max_daily_stamps - stamps_today),
|
|
"store_id": store_id,
|
|
}
|
|
|
|
def redeem_stamps(
|
|
self,
|
|
db: Session,
|
|
*,
|
|
store_id: int,
|
|
card_id: int | None = None,
|
|
qr_code: str | None = None,
|
|
card_number: str | None = None,
|
|
staff_pin: str | None = None,
|
|
ip_address: str | None = None,
|
|
user_agent: str | None = None,
|
|
notes: str | None = None,
|
|
) -> dict:
|
|
"""
|
|
Redeem stamps 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
|
|
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:
|
|
LoyaltyCardNotFoundException: Card not found
|
|
InsufficientStampsException: Not enough stamps
|
|
StaffPinRequiredException: PIN required but not provided
|
|
"""
|
|
# 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 enough stamps
|
|
if card.stamp_count < program.stamps_target:
|
|
raise InsufficientStampsException(card.stamp_count, program.stamps_target)
|
|
|
|
# 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 stamps
|
|
now = datetime.now(UTC)
|
|
stamps_redeemed = program.stamps_target
|
|
card.stamp_count -= stamps_redeemed
|
|
card.stamps_redeemed += 1
|
|
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.STAMP_REDEEMED.value,
|
|
stamps_delta=-stamps_redeemed,
|
|
stamps_balance_after=card.stamp_count,
|
|
points_balance_after=card.points_balance,
|
|
reward_description=program.stamps_reward_description,
|
|
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 stamp count
|
|
from app.modules.loyalty.services.wallet_service import wallet_service
|
|
|
|
wallet_service.sync_card_to_wallets(db, card)
|
|
|
|
logger.info(
|
|
f"Redeemed stamps from card {card.id} at store {store_id} "
|
|
f"(reward: {program.stamps_reward_description}, "
|
|
f"total redemptions: {card.stamps_redeemed})"
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Reward redeemed successfully",
|
|
"card_id": card.id,
|
|
"card_number": card.card_number,
|
|
"stamp_count": card.stamp_count,
|
|
"stamps_target": program.stamps_target,
|
|
"reward_description": program.stamps_reward_description,
|
|
"total_redemptions": card.stamps_redeemed,
|
|
"store_id": store_id,
|
|
}
|
|
|
|
def void_stamps(
|
|
self,
|
|
db: Session,
|
|
*,
|
|
store_id: int,
|
|
card_id: int | None = None,
|
|
qr_code: str | None = None,
|
|
card_number: str | None = None,
|
|
stamps_to_void: int | None = None,
|
|
original_transaction_id: int | None = None,
|
|
staff_pin: str | None = None,
|
|
ip_address: str | None = None,
|
|
user_agent: str | None = None,
|
|
notes: str | None = None,
|
|
) -> dict:
|
|
"""
|
|
Void stamps for a return.
|
|
|
|
Args:
|
|
db: Database session
|
|
store_id: Store ID
|
|
card_id: Card ID
|
|
qr_code: QR code data
|
|
card_number: Card number
|
|
stamps_to_void: Number of stamps to void (if not using original_transaction_id)
|
|
original_transaction_id: ID of original stamp transaction to void
|
|
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 stamps 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.STAMP_EARNED.value,
|
|
)
|
|
.first()
|
|
)
|
|
if original_transaction:
|
|
stamps_to_void = original_transaction.stamps_delta
|
|
|
|
if not stamps_to_void or stamps_to_void <= 0:
|
|
return {
|
|
"success": False,
|
|
"message": "No stamps to void",
|
|
"card_id": card.id,
|
|
"card_number": card.card_number,
|
|
"stamp_count": card.stamp_count,
|
|
}
|
|
|
|
# Void the stamps (can reduce balance below what was earned)
|
|
now = datetime.now(UTC)
|
|
actual_voided = min(stamps_to_void, card.stamp_count)
|
|
card.stamp_count = max(0, card.stamp_count - stamps_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.STAMP_VOIDED.value,
|
|
stamps_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,
|
|
ip_address=ip_address,
|
|
user_agent=user_agent,
|
|
notes=notes or "Stamps voided for return",
|
|
transaction_at=now,
|
|
)
|
|
db.add(transaction)
|
|
|
|
db.commit()
|
|
db.refresh(card)
|
|
|
|
# Sync wallet passes with updated stamp count
|
|
from app.modules.loyalty.services.wallet_service import wallet_service
|
|
|
|
wallet_service.sync_card_to_wallets(db, card)
|
|
|
|
logger.info(
|
|
f"Voided {actual_voided} stamps from card {card.id} at store {store_id} "
|
|
f"(balance: {card.stamp_count})"
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Stamps voided successfully",
|
|
"stamps_voided": actual_voided,
|
|
"card_id": card.id,
|
|
"card_number": card.card_number,
|
|
"stamp_count": card.stamp_count,
|
|
"store_id": store_id,
|
|
}
|
|
|
|
|
|
# Singleton instance
|
|
stamp_service = StampService()
|