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:
@@ -2,9 +2,15 @@
|
||||
"""
|
||||
Points service.
|
||||
|
||||
Company-based points operations:
|
||||
- Points earned at any vendor count toward company total
|
||||
- Points can be redeemed at any vendor within the company
|
||||
- Supports voiding points for returns
|
||||
|
||||
Handles points operations including:
|
||||
- Earning points from purchases
|
||||
- Redeeming points for rewards
|
||||
- Voiding points (for returns)
|
||||
- Points balance management
|
||||
"""
|
||||
|
||||
@@ -34,6 +40,7 @@ class PointsService:
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
vendor_id: int,
|
||||
card_id: int | None = None,
|
||||
qr_code: str | None = None,
|
||||
card_number: str | None = None,
|
||||
@@ -51,6 +58,7 @@ class PointsService:
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID (where purchase is being made)
|
||||
card_id: Card ID
|
||||
qr_code: QR code data
|
||||
card_number: Card number
|
||||
@@ -64,9 +72,10 @@ class PointsService:
|
||||
Returns:
|
||||
Dict with operation result
|
||||
"""
|
||||
# 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,
|
||||
@@ -85,12 +94,26 @@ class PointsService:
|
||||
logger.warning(f"Points attempted on stamps-only program {program.id}")
|
||||
raise LoyaltyCardInactiveException(card.id)
|
||||
|
||||
# Check minimum purchase amount
|
||||
if program.minimum_purchase_cents > 0 and purchase_amount_cents < program.minimum_purchase_cents:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Purchase below minimum of €{program.minimum_purchase_cents/100:.2f}",
|
||||
"points_earned": 0,
|
||||
"points_per_euro": program.points_per_euro,
|
||||
"purchase_amount_cents": purchase_amount_cents,
|
||||
"card_id": card.id,
|
||||
"card_number": card.card_number,
|
||||
"points_balance": card.points_balance,
|
||||
"total_points_earned": card.total_points_earned,
|
||||
}
|
||||
|
||||
# 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)
|
||||
verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id)
|
||||
|
||||
# Calculate points
|
||||
# points_per_euro is per full euro, so divide cents by 100
|
||||
@@ -115,11 +138,13 @@ class PointsService:
|
||||
card.points_balance += points_earned
|
||||
card.total_points_earned += points_earned
|
||||
card.last_points_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.POINTS_EARNED.value,
|
||||
points_delta=points_earned,
|
||||
@@ -138,7 +163,7 @@ class PointsService:
|
||||
db.refresh(card)
|
||||
|
||||
logger.info(
|
||||
f"Added {points_earned} points to card {card.id} "
|
||||
f"Added {points_earned} points to card {card.id} at vendor {vendor_id} "
|
||||
f"(purchase: €{purchase_euros:.2f}, balance: {card.points_balance})"
|
||||
)
|
||||
|
||||
@@ -152,12 +177,14 @@ class PointsService:
|
||||
"card_number": card.card_number,
|
||||
"points_balance": card.points_balance,
|
||||
"total_points_earned": card.total_points_earned,
|
||||
"vendor_id": vendor_id,
|
||||
}
|
||||
|
||||
def redeem_points(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
vendor_id: int,
|
||||
card_id: int | None = None,
|
||||
qr_code: str | None = None,
|
||||
card_number: str | None = None,
|
||||
@@ -172,6 +199,7 @@ class PointsService:
|
||||
|
||||
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
|
||||
@@ -188,9 +216,10 @@ class PointsService:
|
||||
InvalidRewardException: Reward not found or inactive
|
||||
InsufficientPointsException: Not enough points
|
||||
"""
|
||||
# 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,
|
||||
@@ -215,6 +244,10 @@ class PointsService:
|
||||
points_required = reward["points_required"]
|
||||
reward_name = reward["name"]
|
||||
|
||||
# Check minimum redemption
|
||||
if points_required < program.minimum_redemption_points:
|
||||
raise InvalidRewardException(reward_id)
|
||||
|
||||
# Check if enough points
|
||||
if card.points_balance < points_required:
|
||||
raise InsufficientPointsException(card.points_balance, points_required)
|
||||
@@ -224,18 +257,20 @@ class PointsService:
|
||||
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 points
|
||||
now = datetime.now(UTC)
|
||||
card.points_balance -= points_required
|
||||
card.points_redeemed += points_required
|
||||
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.POINTS_REDEEMED.value,
|
||||
points_delta=-points_required,
|
||||
@@ -254,7 +289,7 @@ class PointsService:
|
||||
db.refresh(card)
|
||||
|
||||
logger.info(
|
||||
f"Redeemed {points_required} points from card {card.id} "
|
||||
f"Redeemed {points_required} points from card {card.id} at vendor {vendor_id} "
|
||||
f"(reward: {reward_name}, balance: {card.points_balance})"
|
||||
)
|
||||
|
||||
@@ -268,6 +303,140 @@ class PointsService:
|
||||
"card_number": card.card_number,
|
||||
"points_balance": card.points_balance,
|
||||
"total_points_redeemed": card.points_redeemed,
|
||||
"vendor_id": vendor_id,
|
||||
}
|
||||
|
||||
def void_points(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
vendor_id: int,
|
||||
card_id: int | None = None,
|
||||
qr_code: str | None = None,
|
||||
card_number: str | None = None,
|
||||
points_to_void: int | None = None,
|
||||
original_transaction_id: int | None = None,
|
||||
order_reference: str | None = None,
|
||||
staff_pin: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
notes: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Void points for a return.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
card_id: Card ID
|
||||
qr_code: QR code data
|
||||
card_number: Card number
|
||||
points_to_void: Number of points to void (if not using original_transaction_id)
|
||||
original_transaction_id: ID of original earn transaction to void
|
||||
order_reference: Order reference (to find original transaction)
|
||||
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 points 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.POINTS_EARNED.value,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if original_transaction:
|
||||
points_to_void = original_transaction.points_delta
|
||||
elif order_reference:
|
||||
original_transaction = (
|
||||
db.query(LoyaltyTransaction)
|
||||
.filter(
|
||||
LoyaltyTransaction.order_reference == order_reference,
|
||||
LoyaltyTransaction.card_id == card.id,
|
||||
LoyaltyTransaction.transaction_type == TransactionType.POINTS_EARNED.value,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if original_transaction:
|
||||
points_to_void = original_transaction.points_delta
|
||||
|
||||
if not points_to_void or points_to_void <= 0:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No points to void",
|
||||
"card_id": card.id,
|
||||
"card_number": card.card_number,
|
||||
"points_balance": card.points_balance,
|
||||
}
|
||||
|
||||
# Void the points (can reduce balance below what was earned)
|
||||
now = datetime.now(UTC)
|
||||
actual_voided = min(points_to_void, card.points_balance)
|
||||
card.points_balance = max(0, card.points_balance - points_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.POINTS_VOIDED.value,
|
||||
points_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,
|
||||
order_reference=order_reference,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
notes=notes or "Points voided for return",
|
||||
transaction_at=now,
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
logger.info(
|
||||
f"Voided {actual_voided} points from card {card.id} at vendor {vendor_id} "
|
||||
f"(balance: {card.points_balance})"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Points voided successfully",
|
||||
"points_voided": actual_voided,
|
||||
"card_id": card.id,
|
||||
"card_number": card.card_number,
|
||||
"points_balance": card.points_balance,
|
||||
"vendor_id": vendor_id,
|
||||
}
|
||||
|
||||
def adjust_points(
|
||||
@@ -276,18 +445,20 @@ class PointsService:
|
||||
card_id: int,
|
||||
points_delta: int,
|
||||
*,
|
||||
vendor_id: int | None = None,
|
||||
reason: str,
|
||||
staff_pin: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Manually adjust points (admin operation).
|
||||
Manually adjust points (admin/vendor operation).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
card_id: Card ID
|
||||
points_delta: Points to add (positive) or remove (negative)
|
||||
vendor_id: Vendor ID
|
||||
reason: Reason for adjustment
|
||||
staff_pin: Staff PIN for verification
|
||||
ip_address: Request IP for audit
|
||||
@@ -299,14 +470,15 @@ class PointsService:
|
||||
card = card_service.require_card(db, card_id)
|
||||
program = card.program
|
||||
|
||||
# Verify staff PIN if required
|
||||
# Verify staff PIN if required and vendor provided
|
||||
verified_pin = None
|
||||
if program.require_staff_pin and staff_pin:
|
||||
verified_pin = pin_service.verify_pin(db, program.id, staff_pin)
|
||||
if program.require_staff_pin and staff_pin and vendor_id:
|
||||
verified_pin = pin_service.verify_pin(db, program.id, staff_pin, vendor_id=vendor_id)
|
||||
|
||||
# Apply adjustment
|
||||
now = datetime.now(UTC)
|
||||
card.points_balance += points_delta
|
||||
card.last_activity_at = now
|
||||
|
||||
if points_delta > 0:
|
||||
card.total_points_earned += points_delta
|
||||
@@ -320,8 +492,9 @@ class PointsService:
|
||||
|
||||
# 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.POINTS_ADJUSTMENT.value,
|
||||
points_delta=points_delta,
|
||||
|
||||
Reference in New Issue
Block a user