feat(loyalty): implement Phase 2 - company-wide points system

Complete implementation of loyalty module Phase 2 features:

Database & Models:
- Add company_id to LoyaltyProgram for chain-wide loyalty
- Add company_id to LoyaltyCard for multi-location support
- Add CompanyLoyaltySettings model for admin-controlled settings
- Add points expiration, welcome bonus, and minimum redemption fields
- Add POINTS_EXPIRED, WELCOME_BONUS transaction types

Services:
- Update program_service for company-based queries
- Update card_service with enrollment and welcome bonus
- Update points_service with void_points for returns
- Update stamp_service for company context
- Update pin_service for company-wide operations

API Endpoints:
- Admin: Program listing with stats, company detail views
- Vendor: Terminal operations, card management, settings
- Storefront: Customer card/transactions, self-enrollment

UI Templates:
- Admin: Programs dashboard, company detail, settings
- Vendor: Terminal, cards list, card detail, settings, stats, enrollment
- Storefront: Dashboard, history, enrollment, success pages

Background Tasks:
- Point expiration task (daily, based on inactivity)
- Wallet sync task (hourly)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 22:10:27 +01:00
parent 3bdf1695fd
commit d8f3338bc8
54 changed files with 7252 additions and 186 deletions

View File

@@ -2,9 +2,15 @@
"""
Stamp service.
Company-based stamp operations:
- Stamps earned at any vendor count toward company total
- Stamps can be redeemed at any vendor within the company
- 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
"""
@@ -36,6 +42,7 @@ class StampService:
self,
db: Session,
*,
vendor_id: int,
card_id: int | None = None,
qr_code: str | None = None,
card_number: str | None = None,
@@ -54,6 +61,7 @@ class StampService:
Args:
db: Database session
vendor_id: Vendor ID (where stamp is being added)
card_id: Card ID
qr_code: QR code data
card_number: Card number
@@ -74,9 +82,10 @@ class StampService:
StampCooldownException: Cooldown period not elapsed
DailyStampLimitException: Daily limit reached
"""
# Look up the card
card = card_service.lookup_card(
# Look up the card (validates it belongs to vendor's company)
card = card_service.lookup_card_for_vendor(
db,
vendor_id,
card_id=card_id,
qr_code=qr_code,
card_number=card_number,
@@ -100,7 +109,7 @@ class StampService:
if program.require_staff_pin:
if not staff_pin:
raise StaffPinRequiredException()
verified_pin = pin_service.verify_pin(db, program.id, staff_pin)
verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id)
# Check cooldown
now = datetime.now(UTC)
@@ -121,14 +130,16 @@ class StampService:
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(
company_id=card.company_id,
card_id=card.id,
vendor_id=card.vendor_id,
vendor_id=vendor_id,
staff_pin_id=verified_pin.id if verified_pin else None,
transaction_type=TransactionType.STAMP_EARNED.value,
stamps_delta=1,
@@ -147,7 +158,7 @@ class StampService:
stamps_today += 1
logger.info(
f"Added stamp to card {card.id} "
f"Added stamp to card {card.id} at vendor {vendor_id} "
f"(stamps: {card.stamp_count}/{program.stamps_target}, "
f"today: {stamps_today}/{program.max_daily_stamps})"
)
@@ -168,12 +179,14 @@ class StampService:
"next_stamp_available_at": next_stamp_at,
"stamps_today": stamps_today,
"stamps_remaining_today": max(0, program.max_daily_stamps - stamps_today),
"vendor_id": vendor_id,
}
def redeem_stamps(
self,
db: Session,
*,
vendor_id: int,
card_id: int | None = None,
qr_code: str | None = None,
card_number: str | None = None,
@@ -187,6 +200,7 @@ class StampService:
Args:
db: Database session
vendor_id: Vendor ID (where redemption is happening)
card_id: Card ID
qr_code: QR code data
card_number: Card number
@@ -203,9 +217,10 @@ class StampService:
InsufficientStampsException: Not enough stamps
StaffPinRequiredException: PIN required but not provided
"""
# Look up the card
card = card_service.lookup_card(
# Look up the card (validates it belongs to vendor's company)
card = card_service.lookup_card_for_vendor(
db,
vendor_id,
card_id=card_id,
qr_code=qr_code,
card_number=card_number,
@@ -228,7 +243,7 @@ class StampService:
if program.require_staff_pin:
if not staff_pin:
raise StaffPinRequiredException()
verified_pin = pin_service.verify_pin(db, program.id, staff_pin)
verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id)
# Redeem stamps
now = datetime.now(UTC)
@@ -236,11 +251,13 @@ class StampService:
card.stamp_count -= stamps_redeemed
card.stamps_redeemed += 1
card.last_redemption_at = now
card.last_activity_at = now
# Create transaction
transaction = LoyaltyTransaction(
company_id=card.company_id,
card_id=card.id,
vendor_id=card.vendor_id,
vendor_id=vendor_id,
staff_pin_id=verified_pin.id if verified_pin else None,
transaction_type=TransactionType.STAMP_REDEEMED.value,
stamps_delta=-stamps_redeemed,
@@ -258,7 +275,7 @@ class StampService:
db.refresh(card)
logger.info(
f"Redeemed stamps from card {card.id} "
f"Redeemed stamps from card {card.id} at vendor {vendor_id} "
f"(reward: {program.stamps_reward_description}, "
f"total redemptions: {card.stamps_redeemed})"
)
@@ -272,6 +289,125 @@ class StampService:
"stamps_target": program.stamps_target,
"reward_description": program.stamps_reward_description,
"total_redemptions": card.stamps_redeemed,
"vendor_id": vendor_id,
}
def void_stamps(
self,
db: Session,
*,
vendor_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
vendor_id: Vendor 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_vendor(
db,
vendor_id,
card_id=card_id,
qr_code=qr_code,
card_number=card_number,
)
program = card.program
# 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, vendor_id=vendor_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(
company_id=card.company_id,
card_id=card.id,
vendor_id=vendor_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)
logger.info(
f"Voided {actual_voided} stamps from card {card.id} at vendor {vendor_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,
"vendor_id": vendor_id,
}