fix(loyalty): enforce cooldown on earn-points (was silently skipped)
Some checks failed
CI / ruff (push) Successful in 18s
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 started running

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:
2026-05-23 22:28:23 +02:00
parent 4b64233b5f
commit 93ab072f55
2 changed files with 28 additions and 2 deletions

View File

@@ -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

View File

@@ -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