Files
orion/app/modules/loyalty/services/stamp_service.py
Samir Boulahtit 52b78ce346
Some checks failed
CI / ruff (push) Successful in 13s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
feat(loyalty): Phase 2A — transactional email notifications
Add async email notifications for 5 loyalty lifecycle events, using
the existing messaging module infrastructure (EmailService, EmailLog,
store template overrides).

- New seed script: scripts/seed/seed_email_templates_loyalty.py
  Seeds 5 templates × 4 locales (en/fr/de/lb) = 20 rows. Idempotent.
  Renamed existing script to seed_email_templates_core.py.
- Celery task: loyalty.send_notification_email — async dispatch with
  3 retries and 60s backoff. Opens own DB session.
- Notification service: LoyaltyNotificationService with 5 methods
  that resolve customer/card/program into template variables and
  enqueue via Celery (never blocks request handlers).
- Enrollment: sends loyalty_enrollment + loyalty_welcome_bonus (if
  bonus > 0) after card creation commit.
- Stamps: sends loyalty_reward_ready when stamp target reached.
- Expiration task: sends loyalty_points_expiring 14 days before expiry
  (tracked via new last_expiration_warning_at column to prevent dupes),
  and loyalty_points_expired after points are zeroed.
- Migration loyalty_005: adds last_expiration_warning_at to cards.
- 8 new unit tests for notification service dispatch.
- Fix: rate limiter autouse fixture in integration tests to prevent
  state bleed between tests.

Templates: loyalty_enrollment, loyalty_welcome_bonus,
loyalty_points_expiring, loyalty_points_expired, loyalty_reward_ready.
All support store-level overrides via the existing email template UI.

Birthday + re-engagement emails deferred to future marketing module
(cross-platform: OMS, loyalty, hosting).

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:11:56 +02:00

461 lines
16 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)
# Re-fetch with row lock to prevent concurrent modification
card = card_service.get_card_for_update(db, card.id)
# Check cooldown AFTER acquiring lock to prevent TOCTOU race
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 AFTER acquiring lock
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)
# Notify customer when they've earned a reward
if reward_earned:
try:
from app.modules.loyalty.services.notification_service import (
notification_service,
)
notification_service.send_reward_available(
db, card, program.stamps_reward_description or "Reward"
)
except Exception:
logger.warning(
f"Failed to queue reward notification for card {card.id}",
exc_info=True,
)
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)
# 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)
# Re-fetch with row lock to prevent concurrent modification
card = card_service.get_card_for_update(db, card.id)
# Check stamp count AFTER acquiring lock to prevent TOCTOU race
if card.stamp_count < program.stamps_target:
raise InsufficientStampsException(card.stamp_count, program.stamps_target)
# 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,
}
# Re-fetch with row lock to prevent concurrent modification
card = card_service.get_card_for_update(db, card.id)
# 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()