fix(loyalty): enforce cooldown on earn-points (was silently skipped)
Some checks failed
Some checks failed
stamp_service.add_stamp checks card.last_stamp_at + cooldown_minutes before crediting and raises StampCooldownException if too soon. The parallel points_service.earn_points writes card.last_points_at but never reads it for enforcement — so cooldown_minutes was silently ignored for points-based programs. Mirror the stamps check in points_service.earn_points: after acquiring the row lock, compare now vs last_points_at + cooldown_minutes and raise the new PointsCooldownException if the cashier is inside the window. Add PointsCooldownException alongside StampCooldownException in exceptions.py with parity wording / error code POINTS_COOLDOWN. Surfaced during Test 3 step 3.6 — repeated earn-points calls for the same card kept crediting the customer with no rate limit even though the program's cooldown_minutes was set. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -154,6 +154,17 @@ class StampCooldownException(LoyaltyException):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PointsCooldownException(LoyaltyException):
|
||||||
|
"""Raised when trying to earn points before cooldown period ends."""
|
||||||
|
|
||||||
|
def __init__(self, cooldown_ends: str, cooldown_minutes: int):
|
||||||
|
super().__init__(
|
||||||
|
message=f"Please wait {cooldown_minutes} minutes between point-earning transactions",
|
||||||
|
error_code="POINTS_COOLDOWN",
|
||||||
|
details={"cooldown_ends": cooldown_ends, "cooldown_minutes": cooldown_minutes},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DailyStampLimitException(LoyaltyException):
|
class DailyStampLimitException(LoyaltyException):
|
||||||
"""Raised when daily stamp limit is exceeded."""
|
"""Raised when daily stamp limit is exceeded."""
|
||||||
|
|
||||||
@@ -401,6 +412,7 @@ __all__ = [
|
|||||||
"StaffPinRequiredException",
|
"StaffPinRequiredException",
|
||||||
"InvalidStaffPinException",
|
"InvalidStaffPinException",
|
||||||
"StaffPinLockedException",
|
"StaffPinLockedException",
|
||||||
|
"PointsCooldownException",
|
||||||
"StampCooldownException",
|
"StampCooldownException",
|
||||||
"DailyStampLimitException",
|
"DailyStampLimitException",
|
||||||
# Redemption
|
# Redemption
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Handles points operations including:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ from app.modules.loyalty.exceptions import (
|
|||||||
LoyaltyException,
|
LoyaltyException,
|
||||||
LoyaltyProgramInactiveException,
|
LoyaltyProgramInactiveException,
|
||||||
OrderReferenceRequiredException,
|
OrderReferenceRequiredException,
|
||||||
|
PointsCooldownException,
|
||||||
StaffPinRequiredException,
|
StaffPinRequiredException,
|
||||||
)
|
)
|
||||||
from app.modules.loyalty.models import LoyaltyTransaction, TransactionType
|
from app.modules.loyalty.models import LoyaltyTransaction, TransactionType
|
||||||
@@ -187,8 +188,21 @@ class PointsService:
|
|||||||
# Re-fetch with row lock to prevent concurrent modification
|
# Re-fetch with row lock to prevent concurrent modification
|
||||||
card = card_service.get_card_for_update(db, card.id)
|
card = card_service.get_card_for_update(db, card.id)
|
||||||
|
|
||||||
# Add points
|
# Check cooldown AFTER acquiring lock to prevent TOCTOU race.
|
||||||
|
# Mirrors stamp_service.add_stamp — without this, a cashier (or a
|
||||||
|
# malicious actor with terminal access) can earn points for the
|
||||||
|
# same customer over and over with no rate limit, even though the
|
||||||
|
# program's cooldown_minutes is set.
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
|
if card.last_points_at:
|
||||||
|
cooldown_ends = card.last_points_at + timedelta(minutes=program.cooldown_minutes)
|
||||||
|
if now < cooldown_ends:
|
||||||
|
raise PointsCooldownException(
|
||||||
|
cooldown_ends.isoformat(),
|
||||||
|
program.cooldown_minutes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add points
|
||||||
card.points_balance += points_earned
|
card.points_balance += points_earned
|
||||||
card.total_points_earned += points_earned
|
card.total_points_earned += points_earned
|
||||||
card.last_points_at = now
|
card.last_points_at = now
|
||||||
|
|||||||
Reference in New Issue
Block a user